From b5f3a70505cd3ed697e34e53160ac71976c9808d Mon Sep 17 00:00:00 2001 From: gokul-aot Date: Thu, 25 Jun 2026 14:06:31 +0530 Subject: [PATCH 1/6] OIDC implemented for google drive mcp Signed-off-by: gokul-aot --- config/connectors.yaml | 4 ++ docs/google_drive_connector.md | 30 +++++++++ sample.env | 3 + src/bindings/factory.py | 47 ++++++++++++- src/bindings/mcp_server/auth.py | 41 +++++++++++- src/bindings/mcp_server/server.py | 65 +++++++++++++++++- src/node_wire_google_drive/logic.py | 42 ++++++------ src/node_wire_runtime/auth/base.py | 18 +++++ tests/test_auth_providers.py | 56 ++++++++++++++++ tests/test_google_drive.py | 59 +++++++++++++++- tests/test_mcp_auth.py | 100 ++++++++++++++++++++++++++++ 11 files changed, 437 insertions(+), 28 deletions(-) diff --git a/config/connectors.yaml b/config/connectors.yaml index bc5d18e..669abe6 100644 --- a/config/connectors.yaml +++ b/config/connectors.yaml @@ -46,6 +46,10 @@ connectors: sa_json_secret: GOOGLE_DRIVE_SA_JSON scopes: - https://www.googleapis.com/auth/drive + # OIDC / per-user Drive (ToolHive upstream bearer passthrough): + # auth: + # provider: upstream_bearer + # Or: GOOGLE_DRIVE_AUTH_PROVIDER=upstream_bearer (overrides yaml when set) fhir_epic: enabled: true diff --git a/docs/google_drive_connector.md b/docs/google_drive_connector.md index b887852..2ea977b 100644 --- a/docs/google_drive_connector.md +++ b/docs/google_drive_connector.md @@ -15,6 +15,36 @@ For **MCP** (e.g. ToolHive), tools are named `google_drive.` from the co --- +## User OAuth (OIDC / upstream bearer) + +For **per-user Google Drive access** (each caller uses their own Drive), set: + +```yaml +google_drive: + auth: + provider: upstream_bearer +``` + +Or set the environment variable (overrides `connectors.yaml` when present): + +```env +GOOGLE_DRIVE_AUTH_PROVIDER=upstream_bearer +``` + +Allowed values: `service_account` (default), `upstream_bearer`. + +Run the **google-drive-only** MCP server (`python -m agents.google_drive_mcp`) with `NW_MCP_TRANSPORT=streamable-http`. The `Authorization: Bearer` token on each MCP request must be the Google access token (typically issued via ToolHive embedded OIDC). Do **not** set `NW_MCP_API_KEY`, `NW_MCP_JWT_SECRET`, or `GOOGLE_DRIVE_SA_JSON` for this profile. + +**ToolHive OIDC manifests:** copy and adapt from [mcp-builder `out/google-drive-mcp/deploy/`](https://github.com/stacklok/mcp-builder/tree/main/out/google-drive-mcp/deploy) (`mcpexternalauthconfig.yaml`, `mcpoidcconfig.yaml`, `mcpserver.yaml`) — use image/entrypoint `nw-google-drive` / `python -m agents.google_drive_mcp`. + +**Ponytail:** Passthrough MCP auth applies only when this server exposes `google_drive` alone with `upstream_bearer`. The unified `mcp_entrypoint` with multiple connectors keeps API-key/JWT MCP auth. + +With `NW_MCP_SCOPE_POLICY_DEFAULT=deny` (recommended for production), the google-drive MCP server auto-grants the per-action MCP scopes (`mcp:google_drive.`) from its manifest to upstream bearer callers so `tools/list` is not empty. Google OAuth on the `Authorization: Bearer` token remains the boundary for Drive API access—refresh that access token when Drive calls fail with auth errors. + +For **shared-folder automation** (single service identity), keep the [service account setup](#google-drive-service-account-setup) below. + +--- + ## Google Drive service account setup This guide walks you through creating a Google Cloud service account and connecting it to Node Wire. A service account is a special type of Google account used by applications (rather than humans) to authenticate with Google APIs. diff --git a/sample.env b/sample.env index a09c535..2552a59 100644 --- a/sample.env +++ b/sample.env @@ -20,6 +20,9 @@ CERNER_SCOPES="system/Patient.read system/Encounter.read system/DocumentReferenc # Google Drive GOOGLE_DRIVE_SA_JSON=/absolute/path/to/service-account.json GOOGLE_DRIVE_FOLDER_ID=your-google-drive-folder-id +# Auth profile: service_account (default) or upstream_bearer (MCP OIDC per-user Drive) +# GOOGLE_DRIVE_AUTH_PROVIDER=service_account +# GOOGLE_DRIVE_AUTH_PROVIDER=upstream_bearer # SMTP SMTP_HOST=smtp.gmail.com diff --git a/src/bindings/factory.py b/src/bindings/factory.py index fc63426..1395d31 100644 --- a/src/bindings/factory.py +++ b/src/bindings/factory.py @@ -28,6 +28,24 @@ _PLATFORM_ROOT = Path(__file__).resolve().parent.parent.parent _DEFAULT_CONFIG_PATH = _PLATFORM_ROOT / "config" / "connectors.yaml" +_GOOGLE_DRIVE_AUTH_PROVIDER_ENV = "GOOGLE_DRIVE_AUTH_PROVIDER" +_GOOGLE_DRIVE_AUTH_PROVIDERS = frozenset({"service_account", "upstream_bearer"}) + + +def _resolve_google_drive_auth(auth_cfg: dict[str, Any]) -> dict[str, Any]: + """Apply GOOGLE_DRIVE_AUTH_PROVIDER env override when set (wins over connectors.yaml).""" + override = os.environ.get(_GOOGLE_DRIVE_AUTH_PROVIDER_ENV, "").strip() + if not override: + return auth_cfg + if override not in _GOOGLE_DRIVE_AUTH_PROVIDERS: + raise ValueError( + f"{_GOOGLE_DRIVE_AUTH_PROVIDER_ENV} must be one of " + f"{sorted(_GOOGLE_DRIVE_AUTH_PROVIDERS)!r}, got {override!r}" + ) + merged = dict(auth_cfg) + merged["provider"] = override + return merged + def _resolve_env_vars(data: Any) -> Any: if isinstance(data, dict): @@ -174,12 +192,15 @@ def load(self) -> None: for connector_id, cfg in connectors_cfg.items(): enabled = bool(cfg.get("enabled", False)) exposed_via = list(cfg.get("exposed_via", [])) + cfg_raw: Dict[str, Any] = dict(cfg) + if connector_id == "google_drive": + cfg_raw["auth"] = _resolve_google_drive_auth(cfg_raw.get("auth") or {}) self._configs[connector_id] = ConnectorConfig( id=connector_id, enabled=enabled, exposed_via=exposed_via, - raw=cfg, + raw=cfg_raw, ) if not enabled: @@ -215,6 +236,8 @@ def _build_auth_provider(self, connector_id: str, cfg: dict) -> Any: ) auth_cfg = cfg.get("auth") or {} + if connector_id == "google_drive": + auth_cfg = _resolve_google_drive_auth(auth_cfg) provider_type = auth_cfg.get("provider", "none") if provider_type in ("none", ""): @@ -254,6 +277,28 @@ def _build_auth_provider(self, connector_id: str, cfg: dict) -> Any: scopes=auth_cfg.get("scopes"), ) + if provider_type == "upstream_bearer": + from node_wire_runtime.auth.base import AuthProvider, get_upstream_bearer + + class _UpstreamBearerProvider(AuthProvider): # type: ignore[misc] + per_request_credentials = True + + async def get_headers(self) -> dict: + token = get_upstream_bearer() + if not token: + raise RuntimeError("Upstream bearer token required") + return {"Authorization": f"Bearer {token}"} + + async def get_client_credentials(self): # type: ignore[override] + from google.oauth2.credentials import Credentials # type: ignore[import] + + token = get_upstream_bearer() + if not token: + return None + return Credentials(token=token) + + return _UpstreamBearerProvider() + if provider_type == "static_credentials": # SMTP-style: returns (username, password) tuple via get_client_credentials(). # We use a lightweight wrapper around StaticTokenAuthProvider pair. diff --git a/src/bindings/mcp_server/auth.py b/src/bindings/mcp_server/auth.py index 5f22dcc..95a3867 100644 --- a/src/bindings/mcp_server/auth.py +++ b/src/bindings/mcp_server/auth.py @@ -4,6 +4,7 @@ # from __future__ import annotations +import contextvars import os import logging from pathlib import Path @@ -19,8 +20,13 @@ decode_binding_jwt, parse_api_key_scopes_from_env, ) +from node_wire_runtime.auth.base import reset_upstream_bearer, set_upstream_bearer -logger = logging.getLogger("bindings.mcp_server.auth") +logger = logging.getLogger(__name__) + +_upstream_reset_ctx: contextvars.ContextVar[contextvars.Token | None] = contextvars.ContextVar( + "_mcp_upstream_reset", default=None +) # Back-compat: callers may still import ``McpIdentity`` / ``build_identity`` from MCP auth. McpIdentity = CallerIdentity @@ -227,7 +233,32 @@ def authenticate_mcp_request( *, headers: Mapping[str, Any] | None = None, meta: Mapping[str, Any] | None = None, + upstream_passthrough: bool = False, + upstream_granted_scopes: tuple[str, ...] = (), ) -> CallerIdentity | None: + if upstream_passthrough: + if mcp_auth_disabled(): + return None + token = extract_token(headers=headers, meta=meta) + if not token: + raise McpAuthRequiredError() + _upstream_reset_ctx.set(set_upstream_bearer(token)) + # Ponytail: MCP scopes gate tool visibility on this server; the Google OAuth + # access token on the request is the upstream authz boundary for Drive API. + identity = build_caller_identity( + { + "sub": "upstream-bearer", + "tenant_id": None, + "scopes": list(upstream_granted_scopes), + }, + "upstream_bearer", + ) + logger.info( + "MCP upstream passthrough accepted", + extra={"auth_type": identity.auth_type, "principal": identity.principal}, + ) + return identity + logger.info( "MCP auth gate status", extra={ @@ -259,3 +290,11 @@ def authenticate_mcp_request( }, ) return identity + + +def reset_upstream_passthrough_context() -> None: + """Clear upstream bearer set during passthrough auth (call in middleware finally).""" + reset_tok = _upstream_reset_ctx.get() + if reset_tok is not None: + reset_upstream_bearer(reset_tok) + _upstream_reset_ctx.set(None) diff --git a/src/bindings/mcp_server/server.py b/src/bindings/mcp_server/server.py index ba7d1eb..ed1104b 100644 --- a/src/bindings/mcp_server/server.py +++ b/src/bindings/mcp_server/server.py @@ -13,9 +13,10 @@ from typing import Any, Dict, List, Mapping, Optional, Tuple from bindings.factory import ConnectorFactory -from bindings.mcp_server.auth import ( - McpAuthError, +from bindings.mcp_server.auth import ( + McpAuthError, authenticate_mcp_request, + reset_upstream_passthrough_context, log_effective_mcp_auth_state, ) from node_wire_runtime.caller_identity import CallerIdentity @@ -23,6 +24,7 @@ action_allowed_for_identity_scopes, load_scope_map_from_env, load_scope_policy_default_from_env, + resolve_required_scope_for_action, ) from node_wire_runtime.connector_registry import auto_register from node_wire_runtime.manifest import MCP_MANIFEST_CONTRACT_VERSION, build_manifest @@ -115,6 +117,45 @@ def _process_response_payload(data: Any, max_items: int) -> Tuple[Any, bool, int return data, False, 0, next_page_token +def _resolve_upstream_passthrough( + factory: ConnectorFactory, + connector_ids: frozenset[str] | None, +) -> bool: + """Enable when google_drive-only MCP server uses upstream_bearer auth.""" + if connector_ids != frozenset({"google_drive"}): + return False + cfg = factory._configs.get("google_drive") + if cfg is None: + return False + auth = cfg.raw.get("auth") or {} + return auth.get("provider") == "upstream_bearer" + + +def _upstream_passthrough_scopes( + factory: ConnectorFactory, + connector_ids: frozenset[str] | None, +) -> tuple[str, ...]: + if connector_ids is None: + return () + scope_map = load_scope_map_from_env() + default_mode = load_scope_policy_default_from_env() + manifest = build_manifest(factory.list_for_protocol("mcp")) + scopes: set[str] = set() + for entry in manifest: + cid = entry["connector_id"] + if cid not in connector_ids: + continue + required = resolve_required_scope_for_action( + connector_id=cid, + action=str(entry["action"]), + action_scope_map=scope_map, + default_mode=default_mode, + ) + if required: + scopes.add(required) + return tuple(sorted(scopes)) + + class McpServer: """ Manifest-driven MCP server: tools come from connector metadata; execution @@ -137,6 +178,14 @@ def __init__( auto_register() self._factory = ConnectorFactory() self._factory.load() + self._upstream_passthrough = _resolve_upstream_passthrough( + self._factory, self._connector_ids + ) + self._upstream_passthrough_scopes = ( + _upstream_passthrough_scopes(self._factory, self._connector_ids) + if self._upstream_passthrough + else () + ) try: from importlib.metadata import version as pkg_version @@ -225,6 +274,8 @@ def _ensure_identity( return authenticate_mcp_request( headers=_http_request_headers.get(), meta=meta, + upstream_passthrough=self._upstream_passthrough, + upstream_granted_scopes=self._upstream_passthrough_scopes, ) def _request_meta_from_context(self) -> Mapping[str, Any] | None: @@ -495,6 +546,9 @@ def _build_streamable_http_app(self, *, session_manager: Any, path: str) -> Any: from starlette.responses import JSONResponse from starlette.routing import Route + upstream_passthrough = self._upstream_passthrough + upstream_granted_scopes = self._upstream_passthrough_scopes + @asynccontextmanager async def lifespan(app: Starlette): async with session_manager.run(): @@ -505,7 +559,11 @@ async def dispatch(self, request: Request, call_next): # type: ignore[override] if request.url.path != path: return await call_next(request) try: - identity = authenticate_mcp_request(headers=request.headers) + identity = authenticate_mcp_request( + headers=request.headers, + upstream_passthrough=upstream_passthrough, + upstream_granted_scopes=upstream_granted_scopes, + ) except McpAuthError as exc: headers: Dict[str, str] = {} if exc.www_authenticate: @@ -522,6 +580,7 @@ async def dispatch(self, request: Request, call_next): # type: ignore[override] return await call_next(request) finally: _streamable_http_identity_ctx.reset(token) + reset_upstream_passthrough_context() # Use a wrapper class to ensure Starlette treats this as an ASGI app # without the automatic redirection logic of Mount(). diff --git a/src/node_wire_google_drive/logic.py b/src/node_wire_google_drive/logic.py index 3b5e49f..e0ba07e 100644 --- a/src/node_wire_google_drive/logic.py +++ b/src/node_wire_google_drive/logic.py @@ -50,27 +50,7 @@ class GoogleDriveConnector(BaseConnector): def build_client(self) -> Any: import asyncio - # get_client_credentials() is async; run it synchronously here since - # build_client() is called from the synchronous get_client() accessor. - try: - loop = asyncio.get_event_loop() - if loop.is_running(): - # In an async context, we can't use run_until_complete. - # Instead, fetch credentials synchronously via the underlying - # ServiceAccountAuthProvider._build_credentials() pattern. - # This code path is reached during connector initialisation - # inside an async frame (e.g. in tests with pytest-asyncio). - import concurrent.futures - - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - creds = pool.submit( - lambda: asyncio.run(self._auth_provider.get_client_credentials()) - ).result() - else: - creds = loop.run_until_complete(self._auth_provider.get_client_credentials()) - except RuntimeError: - creds = asyncio.run(self._auth_provider.get_client_credentials()) - + creds = asyncio.run(self._auth_provider.get_client_credentials()) if creds is None: # Fallback for NoAuthProvider or unconfigured provider — # attempt direct secret resolution for backward compatibility. @@ -111,6 +91,13 @@ def build_client(self) -> Any: return build("drive", "v3", credentials=creds) + def get_client(self) -> Any: + if getattr(self._auth_provider, "per_request_credentials", False): + return self.build_client() + if self._client is None: + self._client = self.build_client() + return self._client + def _translate_and_raise_http_error(self, exc: HttpError) -> None: status = exc.resp.status content_str = str(getattr(exc, "content", "") or "") @@ -140,7 +127,18 @@ async def _execute_action_spec( spec = GOOGLE_DRIVE_ACTION_SPECS.get(action_name) if spec is None: raise ValueError(f"No action spec registered for {action_name!r}") - drive = self.get_client() + if getattr(self._auth_provider, "per_request_credentials", False): + creds = await self._auth_provider.get_client_credentials() + if creds is None: + raise GoogleDriveAuthError("Upstream bearer token required") + drive = build("drive", "v3", credentials=creds) + else: + if self._client is None: + creds = await self._auth_provider.get_client_credentials() + if creds is None: + raise GoogleDriveAuthError("Authentication credentials unavailable") + self._client = build("drive", "v3", credentials=creds) + drive = self._client extra = {"trace_id": trace_id, **(log_extra or {})} logger.info("Google Drive %s", action_name, extra=extra) try: diff --git a/src/node_wire_runtime/auth/base.py b/src/node_wire_runtime/auth/base.py index 45eb281..958a488 100644 --- a/src/node_wire_runtime/auth/base.py +++ b/src/node_wire_runtime/auth/base.py @@ -18,9 +18,27 @@ from __future__ import annotations +import contextvars from abc import ABC, abstractmethod from typing import Any, Dict +_upstream_bearer_ctx: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "_upstream_bearer", default=None +) + + +def get_upstream_bearer() -> str | None: + """Bearer token from the current MCP request (upstream OIDC passthrough).""" + return _upstream_bearer_ctx.get() + + +def set_upstream_bearer(token: str | None) -> contextvars.Token: + return _upstream_bearer_ctx.set(token) + + +def reset_upstream_bearer(ctx_token: contextvars.Token) -> None: + _upstream_bearer_ctx.reset(ctx_token) + class AuthProvider(ABC): """ diff --git a/tests/test_auth_providers.py b/tests/test_auth_providers.py index 522f3da..d26a73c 100644 --- a/tests/test_auth_providers.py +++ b/tests/test_auth_providers.py @@ -462,3 +462,59 @@ def test_factory_builds_service_account_provider() -> None: } provider = factory._build_auth_provider("google_drive", cfg) assert isinstance(provider, ServiceAccountAuthProvider) + + +@pytest.mark.asyncio +async def test_factory_builds_upstream_bearer_provider() -> None: + from bindings.factory import ConnectorFactory + from node_wire_runtime.auth.base import get_upstream_bearer, reset_upstream_bearer, set_upstream_bearer + + sp = _DictSecretProvider({}) + factory = ConnectorFactory.__new__(ConnectorFactory) + factory._secret_provider = sp + + provider = factory._build_auth_provider("google_drive", {"auth": {"provider": "upstream_bearer"}}) + assert getattr(provider, "per_request_credentials", False) is True + + ctx = set_upstream_bearer("google-access-token") + try: + creds = await provider.get_client_credentials() + assert creds is not None + assert creds.token == "google-access-token" + headers = await provider.get_headers() + assert headers["Authorization"] == "Bearer google-access-token" + finally: + reset_upstream_bearer(ctx) + + assert get_upstream_bearer() is None + + +def test_google_drive_auth_provider_env_overrides_yaml( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from bindings.factory import ConnectorFactory + + monkeypatch.setenv("GOOGLE_DRIVE_AUTH_PROVIDER", "upstream_bearer") + sp = _DictSecretProvider({}) + factory = ConnectorFactory.__new__(ConnectorFactory) + factory._secret_provider = sp + + cfg = { + "auth": { + "provider": "service_account", + "sa_json_secret": "GOOGLE_DRIVE_SA_JSON", + } + } + provider = factory._build_auth_provider("google_drive", cfg) + assert getattr(provider, "per_request_credentials", False) is True + assert not isinstance(provider, ServiceAccountAuthProvider) + + +def test_google_drive_auth_provider_env_invalid_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from bindings.factory import ConnectorFactory, _resolve_google_drive_auth + + monkeypatch.setenv("GOOGLE_DRIVE_AUTH_PROVIDER", "oauth2") + with pytest.raises(ValueError, match="GOOGLE_DRIVE_AUTH_PROVIDER"): + _resolve_google_drive_auth({"provider": "service_account"}) diff --git a/tests/test_google_drive.py b/tests/test_google_drive.py index 0a8ba8c..81be951 100644 --- a/tests/test_google_drive.py +++ b/tests/test_google_drive.py @@ -19,10 +19,13 @@ ) from node_wire_google_drive.logic import DEFAULT_LIST_FIELDS, GoogleDriveConnector from node_wire_google_drive.schema import ( + FilesListOperation, FilesUploadOperation, GoogleDriveOperationInput, ) from node_wire_runtime import SecretProvider +from node_wire_runtime.auth import ServiceAccountAuthProvider +from node_wire_runtime.auth.base import reset_upstream_bearer, set_upstream_bearer class MockSecretProvider(SecretProvider): @@ -41,7 +44,14 @@ def __init__(self, status: int, *, content: str = "", reason: str = "") -> None: def _connector() -> GoogleDriveConnector: - return GoogleDriveConnector(secret_provider=MockSecretProvider()) + sp = MockSecretProvider() + return GoogleDriveConnector( + secret_provider=sp, + auth_provider=ServiceAccountAuthProvider( + secret_provider=sp, + sa_json_secret="GOOGLE_DRIVE_SA_JSON", + ), + ) def test_files_upload_operation_requires_exactly_one_body_source() -> None: @@ -125,6 +135,53 @@ def test_google_drive_schema_discriminator_validation(): GoogleDriveOperationInput.model_validate({"action": "files.unknown", "file_id": "abc123"}) +@pytest.mark.asyncio +async def test_google_drive_upstream_bearer_uses_request_token() -> None: + from bindings.factory import ConnectorFactory + + sp = MockSecretProvider() + factory = ConnectorFactory.__new__(ConnectorFactory) + factory._secret_provider = sp + provider = factory._build_auth_provider("google_drive", {"auth": {"provider": "upstream_bearer"}}) + connector = GoogleDriveConnector(secret_provider=sp, auth_provider=provider) + + drive = MagicMock() + files_api = drive.files.return_value + list_call = files_api.list.return_value + list_call.execute.return_value = {"files": []} + + ctx = set_upstream_bearer("google-token-a") + try: + with patch("node_wire_google_drive.logic.build", return_value=drive) as mock_build: + await connector._execute_action_spec( + "files.list", + FilesListOperation(action="files.list", page_size=5), + trace_id="trace-1", + ) + creds = mock_build.call_args.kwargs["credentials"] + assert creds.token == "google-token-a" + finally: + reset_upstream_bearer(ctx) + + +@pytest.mark.asyncio +async def test_google_drive_upstream_bearer_no_token_raises() -> None: + from bindings.factory import ConnectorFactory + + sp = MockSecretProvider() + factory = ConnectorFactory.__new__(ConnectorFactory) + factory._secret_provider = sp + provider = factory._build_auth_provider("google_drive", {"auth": {"provider": "upstream_bearer"}}) + connector = GoogleDriveConnector(secret_provider=sp, auth_provider=provider) + + with pytest.raises(GoogleDriveAuthError, match="Upstream bearer token required"): + await connector._execute_action_spec( + "files.list", + FilesListOperation(action="files.list", page_size=5), + trace_id="trace-1", + ) + + @pytest.mark.parametrize( ("action", "payload", "status", "expected_exception"), [ diff --git a/tests/test_mcp_auth.py b/tests/test_mcp_auth.py index 2b1fab9..01c1680 100644 --- a/tests/test_mcp_auth.py +++ b/tests/test_mcp_auth.py @@ -15,9 +15,11 @@ McpAuthRequiredError, authenticate_mcp_request, mcp_auth_disabled, + reset_upstream_passthrough_context, ) from bindings.mcp_server.server import McpServer from tests.jwt_test_helpers import mint_test_jwt +from node_wire_runtime.auth.base import get_upstream_bearer @pytest.fixture(autouse=True) @@ -43,6 +45,30 @@ def test_mcp_auth_missing_token_returns_401(monkeypatch: pytest.MonkeyPatch) -> assert exc_info.value.detail == "Authentication required" +def test_mcp_upstream_passthrough_missing_bearer(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + + with pytest.raises(McpAuthRequiredError): + authenticate_mcp_request(upstream_passthrough=True) + + +def test_mcp_upstream_passthrough_accepts_opaque_google_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + + identity = authenticate_mcp_request( + headers={"Authorization": "Bearer not-an-nw-api-key"}, + upstream_passthrough=True, + ) + assert identity is not None + assert identity.auth_type == "upstream_bearer" + assert identity.principal == "upstream-bearer" + assert get_upstream_bearer() == "not-an-nw-api-key" + reset_upstream_passthrough_context() + assert get_upstream_bearer() is None + + def test_mcp_auth_invalid_token_returns_403(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) monkeypatch.setenv("NW_MCP_API_KEY", "unit-test-secret") @@ -356,6 +382,80 @@ def test_streamable_http_edge_auth_accepts_valid_token(monkeypatch: pytest.Monke assert response.json()["ok"] is True +def test_streamable_http_upstream_passthrough_accepts_google_bearer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.setenv("GOOGLE_DRIVE_AUTH_PROVIDER", "upstream_bearer") + monkeypatch.setenv("NW_MCP_API_KEY", "unit-test-secret") + + server = McpServer(connector_ids=["google_drive"]) + assert server._upstream_passthrough is True + + app = server._build_streamable_http_app( + session_manager=_FakeStreamableSessionManager(), + path="/mcp", + ) + client = TestClient(app) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": "1", "method": "tools/list"}, + headers={"Authorization": "Bearer google-access-token"}, + ) + + assert response.status_code == 200 + assert response.json()["ok"] is True + + +def test_upstream_passthrough_denied_mode_lists_google_drive_tools( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.setenv("NW_MCP_SCOPE_POLICY_DEFAULT", "deny") + monkeypatch.setenv("GOOGLE_DRIVE_AUTH_PROVIDER", "upstream_bearer") + + server = McpServer(connector_ids=["google_drive"]) + assert server._upstream_passthrough is True + assert server._upstream_passthrough_scopes + + identity = authenticate_mcp_request( + headers={"Authorization": "Bearer google-access-token"}, + upstream_passthrough=True, + upstream_granted_scopes=server._upstream_passthrough_scopes, + ) + assert identity is not None + + names = {t["name"] for t in server.list_tools(identity=identity)} + assert "google_drive.files.list" in names + assert "google_drive.files.upload" in names + reset_upstream_passthrough_context() + + +def test_streamable_http_upstream_passthrough_denied_lists_tools( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.setenv("NW_MCP_SCOPE_POLICY_DEFAULT", "deny") + monkeypatch.setenv("GOOGLE_DRIVE_AUTH_PROVIDER", "upstream_bearer") + + server = McpServer(connector_ids=["google_drive"]) + assert server._upstream_passthrough_scopes + + app = server._build_streamable_http_app( + session_manager=_FakeStreamableSessionManager(), + path="/mcp", + ) + client = TestClient(app) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": "1", "method": "tools/list"}, + headers={"Authorization": "Bearer google-access-token"}, + ) + + assert response.status_code == 200 + assert response.json()["ok"] is True + + @pytest.mark.asyncio async def test_streamable_http_identity_context_is_used_by_mcp_server( monkeypatch: pytest.MonkeyPatch, From a69af2d07f9de3de7cc254c2c6943225485ebadf Mon Sep 17 00:00:00 2001 From: gokul-aot Date: Tue, 30 Jun 2026 12:50:32 +0530 Subject: [PATCH 2/6] PR related issues Signed-off-by: gokul-aot --- src/bindings/mcp_server/auth.py | 6 ++++-- src/node_wire_google_drive/logic.py | 23 ++++++++++++++++------- tests/conftest.py | 1 + tests/test_auth_providers.py | 2 +- tests/test_mcp_auth.py | 19 +++++++++++++++++++ 5 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/bindings/mcp_server/auth.py b/src/bindings/mcp_server/auth.py index 95a3867..be4d05e 100644 --- a/src/bindings/mcp_server/auth.py +++ b/src/bindings/mcp_server/auth.py @@ -237,12 +237,14 @@ def authenticate_mcp_request( upstream_granted_scopes: tuple[str, ...] = (), ) -> CallerIdentity | None: if upstream_passthrough: - if mcp_auth_disabled(): - return None token = extract_token(headers=headers, meta=meta) if not token: + if mcp_auth_disabled(): + return None raise McpAuthRequiredError() _upstream_reset_ctx.set(set_upstream_bearer(token)) + if mcp_auth_disabled(): + return None # Ponytail: MCP scopes gate tool visibility on this server; the Google OAuth # access token on the request is the upstream authz boundary for Drive API. identity = build_caller_identity( diff --git a/src/node_wire_google_drive/logic.py b/src/node_wire_google_drive/logic.py index e0ba07e..c112673 100644 --- a/src/node_wire_google_drive/logic.py +++ b/src/node_wire_google_drive/logic.py @@ -50,7 +50,21 @@ class GoogleDriveConnector(BaseConnector): def build_client(self) -> Any: import asyncio - creds = asyncio.run(self._auth_provider.get_client_credentials()) + async def _fetch_creds() -> Any: + return await self._auth_provider.get_client_credentials() + + try: + loop = asyncio.get_event_loop() + except RuntimeError: + creds = asyncio.run(_fetch_creds()) + else: + if loop.is_running(): + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + creds = pool.submit(asyncio.run, _fetch_creds()).result() + else: + creds = loop.run_until_complete(_fetch_creds()) if creds is None: # Fallback for NoAuthProvider or unconfigured provider — # attempt direct secret resolution for backward compatibility. @@ -133,12 +147,7 @@ async def _execute_action_spec( raise GoogleDriveAuthError("Upstream bearer token required") drive = build("drive", "v3", credentials=creds) else: - if self._client is None: - creds = await self._auth_provider.get_client_credentials() - if creds is None: - raise GoogleDriveAuthError("Authentication credentials unavailable") - self._client = build("drive", "v3", credentials=creds) - drive = self._client + drive = self.get_client() extra = {"trace_id": trace_id, **(log_extra or {})} logger.info("Google Drive %s", action_name, extra=extra) try: diff --git a/tests/conftest.py b/tests/conftest.py index 51a66d7..9d97f67 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -69,6 +69,7 @@ def _preload_connector_logic_modules() -> None: def _rest_auth_disabled_for_tests(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("NW_REST_AUTH_DISABLED", "true") monkeypatch.setenv("NW_MCP_AUTH_DISABLED", "true") + monkeypatch.delenv("GOOGLE_DRIVE_AUTH_PROVIDER", raising=False) monkeypatch.setenv("NW_MCP_SCOPE_POLICY_DEFAULT", "allow") monkeypatch.setenv("NW_JWT_AUDIENCE", "node-wire-test") monkeypatch.setenv("NW_JWT_ISSUER", "node-wire-test-issuer") diff --git a/tests/test_auth_providers.py b/tests/test_auth_providers.py index d26a73c..af101cb 100644 --- a/tests/test_auth_providers.py +++ b/tests/test_auth_providers.py @@ -513,7 +513,7 @@ def test_google_drive_auth_provider_env_overrides_yaml( def test_google_drive_auth_provider_env_invalid_raises( monkeypatch: pytest.MonkeyPatch, ) -> None: - from bindings.factory import ConnectorFactory, _resolve_google_drive_auth + from bindings.factory import _resolve_google_drive_auth monkeypatch.setenv("GOOGLE_DRIVE_AUTH_PROVIDER", "oauth2") with pytest.raises(ValueError, match="GOOGLE_DRIVE_AUTH_PROVIDER"): diff --git a/tests/test_mcp_auth.py b/tests/test_mcp_auth.py index 01c1680..97a7173 100644 --- a/tests/test_mcp_auth.py +++ b/tests/test_mcp_auth.py @@ -47,15 +47,33 @@ def test_mcp_auth_missing_token_returns_401(monkeypatch: pytest.MonkeyPatch) -> def test_mcp_upstream_passthrough_missing_bearer(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) with pytest.raises(McpAuthRequiredError): authenticate_mcp_request(upstream_passthrough=True) +def test_mcp_upstream_passthrough_sets_bearer_when_auth_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Local dev: NW_MCP_AUTH_DISABLED must still thread Google token to Drive.""" + monkeypatch.setenv("NW_MCP_AUTH_DISABLED", "true") + + identity = authenticate_mcp_request( + headers={"Authorization": "Bearer google-access-token"}, + upstream_passthrough=True, + ) + assert identity is None + assert get_upstream_bearer() == "google-access-token" + reset_upstream_passthrough_context() + assert get_upstream_bearer() is None + + def test_mcp_upstream_passthrough_accepts_opaque_google_token( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) identity = authenticate_mcp_request( headers={"Authorization": "Bearer not-an-nw-api-key"}, @@ -411,6 +429,7 @@ def test_upstream_passthrough_denied_mode_lists_google_drive_tools( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) monkeypatch.setenv("NW_MCP_SCOPE_POLICY_DEFAULT", "deny") monkeypatch.setenv("GOOGLE_DRIVE_AUTH_PROVIDER", "upstream_bearer") From 90ad0f9f03cc39fc7f5d832d6772ec06117d871a Mon Sep 17 00:00:00 2001 From: gokul-aot Date: Tue, 30 Jun 2026 13:04:50 +0530 Subject: [PATCH 3/6] PR related issues-ruff & lint Signed-off-by: gokul-aot --- src/bindings/mcp_server/server.py | 4 ++-- tests/test_auth_providers.py | 10 ++++++++-- tests/test_google_drive.py | 8 ++++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/bindings/mcp_server/server.py b/src/bindings/mcp_server/server.py index ed1104b..adff955 100644 --- a/src/bindings/mcp_server/server.py +++ b/src/bindings/mcp_server/server.py @@ -13,8 +13,8 @@ from typing import Any, Dict, List, Mapping, Optional, Tuple from bindings.factory import ConnectorFactory -from bindings.mcp_server.auth import ( - McpAuthError, +from bindings.mcp_server.auth import ( + McpAuthError, authenticate_mcp_request, reset_upstream_passthrough_context, log_effective_mcp_auth_state, diff --git a/tests/test_auth_providers.py b/tests/test_auth_providers.py index af101cb..e29300f 100644 --- a/tests/test_auth_providers.py +++ b/tests/test_auth_providers.py @@ -467,13 +467,19 @@ def test_factory_builds_service_account_provider() -> None: @pytest.mark.asyncio async def test_factory_builds_upstream_bearer_provider() -> None: from bindings.factory import ConnectorFactory - from node_wire_runtime.auth.base import get_upstream_bearer, reset_upstream_bearer, set_upstream_bearer + from node_wire_runtime.auth.base import ( + get_upstream_bearer, + reset_upstream_bearer, + set_upstream_bearer, + ) sp = _DictSecretProvider({}) factory = ConnectorFactory.__new__(ConnectorFactory) factory._secret_provider = sp - provider = factory._build_auth_provider("google_drive", {"auth": {"provider": "upstream_bearer"}}) + provider = factory._build_auth_provider( + "google_drive", {"auth": {"provider": "upstream_bearer"}} + ) assert getattr(provider, "per_request_credentials", False) is True ctx = set_upstream_bearer("google-access-token") diff --git a/tests/test_google_drive.py b/tests/test_google_drive.py index 81be951..a135883 100644 --- a/tests/test_google_drive.py +++ b/tests/test_google_drive.py @@ -142,7 +142,9 @@ async def test_google_drive_upstream_bearer_uses_request_token() -> None: sp = MockSecretProvider() factory = ConnectorFactory.__new__(ConnectorFactory) factory._secret_provider = sp - provider = factory._build_auth_provider("google_drive", {"auth": {"provider": "upstream_bearer"}}) + provider = factory._build_auth_provider( + "google_drive", {"auth": {"provider": "upstream_bearer"}} + ) connector = GoogleDriveConnector(secret_provider=sp, auth_provider=provider) drive = MagicMock() @@ -171,7 +173,9 @@ async def test_google_drive_upstream_bearer_no_token_raises() -> None: sp = MockSecretProvider() factory = ConnectorFactory.__new__(ConnectorFactory) factory._secret_provider = sp - provider = factory._build_auth_provider("google_drive", {"auth": {"provider": "upstream_bearer"}}) + provider = factory._build_auth_provider( + "google_drive", {"auth": {"provider": "upstream_bearer"}} + ) connector = GoogleDriveConnector(secret_provider=sp, auth_provider=provider) with pytest.raises(GoogleDriveAuthError, match="Upstream bearer token required"): From dbed320685234fdd392d319893bceadcc1149c3c Mon Sep 17 00:00:00 2001 From: gokul-aot Date: Wed, 24 Jun 2026 19:20:31 +0530 Subject: [PATCH 4/6] mcp-builder script is added with read me Signed-off-by: gokul-aot --- scripts/README.md | 237 ++++++++++++++++ scripts/build-mcp-server.sh | 519 +++++++++++++++++++++++++++++++++++ scripts/mcp-servers.registry | 33 +++ 3 files changed, 789 insertions(+) create mode 100644 scripts/README.md create mode 100644 scripts/build-mcp-server.sh create mode 100644 scripts/mcp-servers.registry diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..d8747cd --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,237 @@ +# build-mcp-server.sh + +Shell script to build a ToolHive-ready MCP server from a scope fixture and OpenAPI spec. It runs validate, generate, dependency sync, and optional quality checks in one pass. + +--- + +## Prerequisites + +| Requirement | Notes | +|-------------|--------| +| [uv](https://docs.astral.sh/uv/) | On `PATH`, or set `UV` to the full path | +| mcp-builder repo | Clone with `pyproject.toml`, `src/`, and `e2e/fixtures/` | +| [mcp-template-py](https://github.com/stacklok/mcp-template-py) | Default: `../mcp-template-py` relative to mcp-builder root | +| [Task](https://taskfile.dev/) | Optional; skipped with a warning if missing | +| `curl` | Downloads OpenAPI specs when missing | +| `swagger2openapi` | Required for Slack only | + +### One-time setup + +```bash +cd /path/to/mcp-builder +uv sync + +git clone https://github.com/stacklok/mcp-template-py.git ../mcp-template-py +``` + +--- + +## Usage + +### From the repo + +Run from the mcp-builder root when the script lives in `scripts/`: + +```bash +scripts/build-mcp-server.sh spotify +``` + +Builds the Spotify MCP server using the repo as root (resolved automatically from the script location). + +### From anywhere with explicit root + +Pass `--root` when your working directory is not the mcp-builder clone: + +```bash +scripts/build-mcp-server.sh spotify --root G:/SPACE/mcp-builder +``` + +Same clone from WSL: + +```bash +scripts/build-mcp-server.sh spotify --root /mnt/g/SPACE/mcp-builder +``` + +### List servers + +Print all registry aliases and their output directories: + +```bash +scripts/build-mcp-server.sh --list --root G:/SPACE/mcp-builder +``` + +### Options + +Skip quality checks for a faster build: + +```bash +scripts/build-mcp-server.sh github --root G:/SPACE/mcp-builder --skip-check +``` + +--- + +## Repo root resolution + +The script needs the mcp-builder repository root (where `pyproject.toml` lives). First match wins: + +1. `--root PATH` +2. `MCP_BUILDER_ROOT` environment variable +3. Parent of the `scripts/` directory + +The root must contain `pyproject.toml` and `scripts/mcp-servers.registry`. + +--- + +## Supported servers + +Aliases are defined in `mcp-servers.registry` (pipe-delimited): + +```text +alias|scope_yaml|openapi_spec|download_url|server_name +``` + +| Alias | Output directory | +|-------|------------------| +| `spotify` | `out/spotify-mcp` | +| `github` | `out/github-mcp` | +| `slack` | `out/slack-mcp` | +| `google-drive`, `google_drive` | `out/google-drive-mcp` | +| `stripe` | `out/stripe-mcp` | +| `jira`, `jira-cloud` | `out/jira-cloud-mcp` | +| `bamboohr` | `out/bamboohr-mcp` | +| `twilio` | `out/twilio-mcp` | +| `zoom` | `out/zoom-mcp` | +| `petstore` | `out/petstore-mcp` | + +OpenAPI specs are downloaded on first run when a URL is configured (Slack uses a special Swagger 2.0 conversion — see below). + +--- + +## Options + +| Option | Description | +|--------|-------------| +| `--root PATH` | Path to mcp-builder repo | +| `--list` | List registry aliases and exit | +| `--template DIR` | Path to `mcp-template-py` (default: `../mcp-template-py`) | +| `--skip-download` | Do not fetch OpenAPI spec; fail if file missing | +| `--skip-validate` | Skip `mcp-builder validate` | +| `--skip-sync` | Skip `uv sync` in generated project | +| `--skip-check` | Skip `task check` in generated project | +| `--force` | Remove `out/-mcp` before generate **(default)** | +| `--no-force` | Fail if output directory already exists | +| `-h`, `--help` | Show help | + +### Environment variables + +| Variable | Description | +|----------|-------------| +| `MCP_BUILDER_ROOT` | Default repo root if `--root` is omitted | +| `MCP_TEMPLATE_DIR` | Default template path if `--template` is omitted | +| `UV` | Full path to `uv` when not on PATH | +| `PYTHONUTF8` | Set to `1` on Windows for UTF-8 OpenAPI files (set automatically by the script) | + +--- + +## What the script does + +1. Resolve mcp-builder root and load scope/spec paths from the registry. +2. Download the OpenAPI spec if missing (unless `--skip-download`). +3. Run `uv sync` in the mcp-builder repo. +4. Run `mcp-builder validate` (unless `--skip-validate`). +5. Remove `out/-mcp` if it exists (default `--force`). +6. Run `mcp-builder generate`. +7. Run `uv sync` in the generated project (unless `--skip-sync`). +8. Run `task check` (unless `--skip-check`). + +--- + +## Output + +Generated projects are written to: + +```text +/out/-mcp/ +``` + +Typical layout: + +```text +out/-mcp/ +├── src/_mcp/ +├── deploy/ +├── Dockerfile +├── Taskfile.yml +└── pyproject.toml +``` + +--- + +## Slack OpenAPI download + +Slack’s upstream spec is Swagger 2.0. The registry entry uses `slack:swagger2`, which requires: + +```bash +npm install -g swagger2openapi +``` + +--- + +## Adding a server to the registry + +1. Add a scope YAML under `e2e/fixtures/real/`. +2. Add a line to `mcp-servers.registry`: + +```text +my-api|e2e/fixtures/real/my_api.yaml|e2e/fixtures/real/my_api_openapi.yaml|https://example.com/openapi.yaml|my-api +``` + +3. Build: + +```bash +scripts/build-mcp-server.sh my-api --root /path/to/mcp-builder +``` + +| Field | Meaning | +|-------|---------| +| `alias` | CLI name | +| `scope_yaml` | Path relative to repo root | +| `openapi_spec` | Path relative to repo root | +| `download_url` | `curl` URL, `slack:swagger2`, or empty | +| `server_name` | Output directory: `out/-mcp` | + +--- + +## Platform notes + +### WSL and Windows `uv` + +From WSL, the script can use a Windows `uv.exe` if Linux `uv` is not on PATH. Paths are converted automatically and `PYTHONUTF8` is passed through when needed. + +### Encoding on Windows + +Some OpenAPI specs contain non-ASCII content. The script sets `PYTHONUTF8=1` automatically. If you run `mcp-builder` commands manually on Windows, export `PYTHONUTF8=1` first. + +### Re-running generate + +`mcp-builder generate` fails if the output directory already exists. By default the script removes it (`--force`). Use `--no-force` to keep an existing build and fail instead. + +--- + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| `uv` not found | Install uv or set `UV` to the full path | +| `charmap` codec error | Set `PYTHONUTF8=1` when running mcp-builder manually on Windows | +| `FileExistsError` on generate | Use default `--force`, or delete `out/-mcp` manually | +| `mcp-template-py not found` | Clone the template or pass `--template` | +| Unknown server alias | Run `--list` and check `mcp-servers.registry` | +| Slack download fails | Install `swagger2openapi` | + +--- + +## See also + +- [mcp-builder README](../README.md) +- [e2e/download_openapi_specs.sh](../e2e/download_openapi_specs.sh) diff --git a/scripts/build-mcp-server.sh b/scripts/build-mcp-server.sh new file mode 100644 index 0000000..6693ba0 --- /dev/null +++ b/scripts/build-mcp-server.sh @@ -0,0 +1,519 @@ +#!/usr/bin/env bash +## +## Build a ToolHive-ready MCP server from e2e fixture scopes. +## +## Usage: +## scripts/build-mcp-server.sh spotify +## scripts/build-mcp-server.sh spotify --root /path/to/mcp-builder +## scripts/build-mcp-server.sh --list --root G:/SPACE/mcp-builder +## +## Repo root resolution (first match wins): +## 1. --root PATH +## 2. MCP_BUILDER_ROOT environment variable +## 3. Parent of this script's directory (when script lives in repo/scripts/) +## +## Environment: +## MCP_BUILDER_ROOT Path to mcp-builder repo +## MCP_TEMPLATE_DIR Path to mcp-template-py checkout +## UV Full path to uv when not on PATH (common on Windows Git Bash/WSL) +## +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Defaults (overridden by flags) +ROOT_OVERRIDE="" +OUTPUT_DIR="" +TEMPLATE_DIR="${MCP_TEMPLATE_DIR:-}" +DO_DOWNLOAD=1 +DO_VALIDATE=1 +DO_GENERATE=1 +DO_SYNC=1 +DO_CHECK=1 +DO_FORCE=1 +LIST_ONLY=0 +SERVER_ARG="" + +UV_CMD="" + +log() { + echo "==> $*" +} + +info() { + echo " $*" +} + +usage() { + cat <<'EOF' +Usage: scripts/build-mcp-server.sh [options] + scripts/build-mcp-server.sh --list [options] + +Build an MCP server project from e2e fixture scopes (validate → generate → uv sync → task check). + +Arguments: + Registry alias (spotify, github, slack, google-drive, …) + +Options: + --root PATH Path to mcp-builder repo (or set MCP_BUILDER_ROOT) + --list List known server aliases and exit + --template DIR mcp-template-py checkout (default: ../mcp-template-py) + --skip-download Do not fetch OpenAPI spec (fail if missing) + --skip-validate Skip mcp-builder validate + --skip-sync Skip uv sync in generated project + --skip-check Skip task check in generated project + --force Remove out/-mcp before generate (default) + --no-force Fail if output project directory already exists + -h, --help Show this help + +Examples: + scripts/build-mcp-server.sh spotify --root G:/SPACE/mcp-builder + scripts/build-mcp-server.sh github --root /home/user/mcp-builder --skip-check + scripts/build-mcp-server.sh --list --root G:/SPACE/mcp-builder +EOF +} + +normalize_server_arg() { + local s="$1" + s="${s%,}" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + echo "$s" +} + +# Convert Windows paths (G:\foo) to a form cd accepts in Git Bash/WSL when possible. +normalize_root_path() { + local p="$1" + if [[ "$p" =~ ^[A-Za-z]:\\ ]] || [[ "$p" =~ ^[A-Za-z]:/ ]]; then + if command -v cygpath &>/dev/null; then + cygpath "$p" + return + fi + if command -v wslpath &>/dev/null; then + wslpath -u "$p" 2>/dev/null && return + fi + # Git Bash: G:\SPACE\mcp-builder → /g/SPACE/mcp-builder + if [[ "$p" =~ ^([A-Za-z]):[/\\](.*)$ ]]; then + local drive="${BASH_REMATCH[1],}" + local rest="${BASH_REMATCH[2]//\\//}" + echo "/${drive}/${rest}" + return + fi + fi + echo "$p" +} + +resolve_mcp_builder_root() { + local root="" + if [[ -n "$ROOT_OVERRIDE" ]]; then + root="$(normalize_root_path "$ROOT_OVERRIDE")" + elif [[ -n "${MCP_BUILDER_ROOT:-}" ]]; then + root="$(normalize_root_path "$MCP_BUILDER_ROOT")" + else + root="$(cd "${SCRIPT_DIR}/.." && pwd)" + fi + + if ! cd "$root" 2>/dev/null; then + echo "ERROR: cannot access mcp-builder root: ${root}" >&2 + exit 1 + fi + ROOT="$(pwd)" + REGISTRY="${ROOT}/scripts/mcp-servers.registry" + OUTPUT_DIR="${ROOT}/out" + + if [[ ! -f "${ROOT}/pyproject.toml" ]]; then + echo "ERROR: not a valid mcp-builder repo (missing pyproject.toml): ${ROOT}" >&2 + exit 1 + fi + if [[ ! -f "$REGISTRY" ]]; then + echo "ERROR: registry not found: ${REGISTRY}" >&2 + echo "Ensure scripts/mcp-servers.registry exists in the repo." >&2 + exit 1 + fi +} + +registry_lines() { + grep -v '^[[:space:]]*#' "$REGISTRY" | grep -v '^[[:space:]]*$' || true +} + +list_servers() { + echo "Known MCP server aliases (scripts/mcp-servers.registry):" + echo " mcp-builder root: ${ROOT}" + echo "" + while IFS='|' read -r alias _scope _spec _url server_name; do + printf " %-16s -> %s/out/%s-mcp\n" "$alias" "$ROOT" "$server_name" + done < <(registry_lines) + echo "" + echo "Run: scripts/build-mcp-server.sh [--root PATH]" +} + +resolve_alias() { + local alias="$1" + local line + line="$(registry_lines | grep -E "^${alias}\\|" | head -n 1 || true)" + if [[ -z "$line" ]]; then + echo "ERROR: unknown server alias: ${alias}" >&2 + echo "Run with --list to see known aliases." >&2 + exit 1 + fi + echo "$line" +} + +uses_windows_uv() { + [[ "$UV_CMD" == *.exe ]] +} + +resolve_uv() { + if [[ -n "$UV_CMD" ]]; then + return 0 + fi + + if [[ -n "${UV:-}" ]]; then + if [[ -x "$UV" || -f "$UV" ]]; then + UV_CMD="$UV" + return 0 + fi + echo "ERROR: UV is set to '${UV}' but that file is not executable." >&2 + exit 1 + fi + + if command -v uv &>/dev/null; then + UV_CMD="$(command -v uv)" + return 0 + fi + if command -v uv.exe &>/dev/null; then + UV_CMD="$(command -v uv.exe)" + return 0 + fi + + local candidates=( + "${ROOT}/.venv/Scripts/uv.exe" + "${ROOT}/.venv/bin/uv" + "${HOME}/.local/bin/uv" + "${HOME}/.local/bin/uv.exe" + ) + + if [[ -n "${USERPROFILE:-}" ]]; then + local uf="${USERPROFILE//\\//}" + candidates+=( + "${uf}/.local/bin/uv.exe" + "${uf}/AppData/Roaming/Python/Python313/Scripts/uv.exe" + ) + fi + + if [[ -d "/mnt/c/Users" ]] && command -v cmd.exe &>/dev/null; then + local wuser + wuser="$(cmd.exe /c "echo %USERNAME%" 2>/dev/null | tr -d '\r\n' | xargs)" || true + if [[ -n "$wuser" ]]; then + candidates+=( + "/mnt/c/Users/${wuser}/.local/bin/uv.exe" + "/mnt/c/Users/${wuser}/AppData/Roaming/Python/Python313/Scripts/uv.exe" + ) + fi + fi + + local c + for c in "${candidates[@]}"; do + if [[ -f "$c" ]]; then + UV_CMD="$c" + return 0 + fi + done + + if command -v cmd.exe &>/dev/null; then + local winpath + winpath="$(cmd.exe /c "where uv" 2>/dev/null | head -n 1 | tr -d '\r\n' | xargs)" || true + if [[ -n "$winpath" ]]; then + if command -v wslpath &>/dev/null; then + UV_CMD="$(wslpath "$winpath" 2>/dev/null || true)" + elif command -v cygpath &>/dev/null; then + UV_CMD="$(cygpath "$winpath" 2>/dev/null || true)" + elif [[ "$winpath" =~ ^([A-Za-z]):\\(.*)$ ]]; then + local drive="${BASH_REMATCH[1],}" + local rest="${BASH_REMATCH[2]//\\//}" + UV_CMD="/${drive}/${rest}" + else + UV_CMD="$winpath" + fi + if [[ -n "$UV_CMD" && -f "$UV_CMD" ]]; then + return 0 + fi + fi + fi + + echo "ERROR: 'uv' not found." >&2 + echo " Install: https://docs.astral.sh/uv/" >&2 + echo " Or set UV to the full path to uv." >&2 + exit 1 +} + +require_uv() { + resolve_uv + info "using uv: ${UV_CMD}" +} + +setup_python_utf8() { + export PYTHONUTF8=1 + # WSL: Windows uv.exe does not inherit Linux env unless listed in WSLENV. + if uses_windows_uv && [[ -f /proc/version ]] && grep -qi microsoft /proc/version; then + case ":${WSLENV:-}:" in + *:PYTHONUTF8:*) ;; + *) export WSLENV="${WSLENV:+${WSLENV}:}PYTHONUTF8" ;; + esac + fi +} + +to_uv_path() { + local p="$1" + if uses_windows_uv && command -v wslpath &>/dev/null; then + wslpath -w "$p" + elif uses_windows_uv && command -v cygpath &>/dev/null; then + cygpath -w "$p" + else + echo "$p" + fi +} + +run() { + log "$*" + "$@" +} + +remove_existing_project() { + local dir="$1" + if [[ ! -d "$dir" ]]; then + return 0 + fi + log "Removing existing project: ${dir}" + chmod -R u+w "$dir" 2>/dev/null || true + rm -rf "$dir" +} + +download_slack_spec() { + local spec_path="$1" + local tmp + tmp="$(mktemp "${TMPDIR:-/tmp}/slack_swagger.XXXXXX.json")" + curl -fsSL -o "$tmp" \ + "https://raw.githubusercontent.com/slackapi/slack-api-specs/master/web-api/slack_web_openapi_v2.json" + if ! command -v swagger2openapi &>/dev/null; then + rm -f "$tmp" + echo "ERROR: slack requires swagger2openapi. Install: npm install -g swagger2openapi" >&2 + exit 1 + fi + swagger2openapi "$tmp" -o "$spec_path" --yaml + rm -f "$tmp" +} + +download_spec() { + local spec_rel="$1" + local download_url="$2" + local spec_path="${ROOT}/${spec_rel}" + + if [[ -z "$download_url" ]]; then + if [[ ! -f "$spec_path" ]]; then + echo "ERROR: OpenAPI spec not found: ${spec_path} (no download URL configured)" >&2 + exit 1 + fi + info "OpenAPI spec present: ${spec_rel}" + return 0 + fi + + if [[ -f "$spec_path" ]]; then + info "OpenAPI spec found (skipping download): ${spec_path}" + return 0 + fi + + mkdir -p "$(dirname "$spec_path")" + if [[ "$download_url" == "slack:swagger2" ]]; then + log "Downloading and converting Slack spec → ${spec_rel}" + download_slack_spec "$spec_path" + return 0 + fi + + log "Downloading OpenAPI spec → ${spec_rel}" + curl -fsSL -o "$spec_path" "$download_url" +} + +build_one() { + local alias="$1" + local scope_rel spec_rel download_url server_name + local scope_path spec_path project_dir template_dir + + UV_CMD="" + + IFS='|' read -r _alias scope_rel spec_rel download_url server_name <<< "$(resolve_alias "$alias")" + scope_path="${ROOT}/${scope_rel}" + spec_path="${ROOT}/${spec_rel}" + project_dir="${OUTPUT_DIR}/${server_name}-mcp" + + if [[ -n "$TEMPLATE_DIR" ]]; then + template_dir="$(normalize_root_path "$TEMPLATE_DIR")" + else + template_dir="$(cd "${ROOT}/../mcp-template-py" 2>/dev/null && pwd || echo "${ROOT}/../mcp-template-py")" + fi + + if [[ ! -d "$template_dir" ]]; then + echo "ERROR: mcp-template-py not found at: ${template_dir}" >&2 + echo "Clone: git clone https://github.com/stacklok/mcp-template-py.git " >&2 + echo "Or pass: --template PATH" >&2 + exit 1 + fi + + if [[ ! -f "$scope_path" ]]; then + echo "ERROR: scope not found: ${scope_path}" >&2 + exit 1 + fi + + if [[ "$DO_DOWNLOAD" -eq 1 ]]; then + download_spec "$spec_rel" "$download_url" + elif [[ ! -f "$spec_path" ]]; then + echo "ERROR: OpenAPI spec not found: ${spec_path}" >&2 + exit 1 + fi + + echo "" + log "Building MCP server: ${server_name} (alias: ${alias})" + echo " mcp-builder root: ${ROOT}" + echo " scope: ${scope_rel}" + echo " spec: ${spec_rel}" + echo " output: ${project_dir}" + + cd "$ROOT" + require_uv + setup_python_utf8 + echo " PYTHONUTF8=1" + + run "$UV_CMD" sync + + if [[ "$DO_VALIDATE" -eq 1 ]]; then + run "$UV_CMD" run mcp-builder validate "$(to_uv_path "$scope_path")" --openapi-spec "$(to_uv_path "$spec_path")" + fi + + if [[ "$DO_GENERATE" -eq 1 ]]; then + if [[ "$DO_FORCE" -eq 1 ]]; then + remove_existing_project "$project_dir" + fi + run "$UV_CMD" run mcp-builder generate \ + "$(to_uv_path "$scope_path")" \ + "$(to_uv_path "$spec_path")" \ + "$(to_uv_path "$template_dir")" \ + --output-dir "$(to_uv_path "$OUTPUT_DIR")" + fi + + if [[ "$DO_SYNC" -eq 1 ]]; then + if [[ ! -d "$project_dir" ]]; then + echo "ERROR: expected project at ${project_dir}" >&2 + exit 1 + fi + log "Installing dependencies in ${project_dir}" + (cd "$project_dir" && "$UV_CMD" sync) + fi + + if [[ "$DO_CHECK" -eq 1 ]]; then + if ! command -v task &>/dev/null; then + echo "WARNING: 'task' not found; skipping task check. Install: https://taskfile.dev/" >&2 + else + log "Running task check in ${project_dir}" + (cd "$project_dir" && task check) + fi + fi + + cat <&2 + exit 2 + fi + ROOT_OVERRIDE="$2" + shift 2 + ;; + --template) + TEMPLATE_DIR="$2" + shift 2 + ;; + --skip-download) + DO_DOWNLOAD=0 + shift + ;; + --skip-validate) + DO_VALIDATE=0 + shift + ;; + --skip-sync) + DO_SYNC=0 + shift + ;; + --skip-check) + DO_CHECK=0 + shift + ;; + --force) + DO_FORCE=1 + shift + ;; + --no-force) + DO_FORCE=0 + shift + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + break + ;; + -*) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + *) + if [[ -z "$SERVER_ARG" ]]; then + arg="$(normalize_server_arg "$1")" + if [[ -n "$arg" ]]; then + SERVER_ARG="$arg" + fi + else + extra="$(normalize_server_arg "$1")" + if [[ -n "$extra" ]]; then + echo "Unexpected argument: $1" >&2 + usage >&2 + exit 2 + fi + fi + shift + ;; + esac +done + +resolve_mcp_builder_root + +if [[ "$LIST_ONLY" -eq 1 ]]; then + list_servers + exit 0 +fi + +if [[ -z "$SERVER_ARG" ]]; then + usage >&2 + exit 2 +fi + +build_one "$SERVER_ARG" diff --git a/scripts/mcp-servers.registry b/scripts/mcp-servers.registry new file mode 100644 index 0000000..890b9a8 --- /dev/null +++ b/scripts/mcp-servers.registry @@ -0,0 +1,33 @@ +# MCP server registry for scripts/build-mcp-server.sh +# +# Format (pipe-delimited, one alias per line): +# alias|scope_yaml|openapi_spec|download_url|server_name +# +# download_url: +# - https://... curl the spec into openapi_spec path +# - slack:swagger2 download Swagger 2.0 and convert with swagger2openapi +# - (empty) spec must already exist on disk (no download) +# +# server_name sets the generated project directory: out/-mcp/ + +google-drive|e2e/fixtures/real/google_drive.yaml|e2e/fixtures/real/google_drive_openapi.yaml|https://raw.githubusercontent.com/APIs-guru/openapi-directory/main/APIs/googleapis.com/drive/v3/openapi.yaml|google-drive +google_drive|e2e/fixtures/real/google_drive.yaml|e2e/fixtures/real/google_drive_openapi.yaml|https://raw.githubusercontent.com/APIs-guru/openapi-directory/main/APIs/googleapis.com/drive/v3/openapi.yaml|google-drive + +github|e2e/fixtures/real/github.yaml|e2e/fixtures/real/github_openapi.yaml|https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml|github + +bamboohr|e2e/fixtures/real/bamboohr.yaml|e2e/fixtures/real/bamboohr_openapi.yaml|https://openapi.bamboohr.io/main/latest/docs/openapi/public-openapi.yaml|bamboohr + +jira|e2e/fixtures/real/jira.yaml|e2e/fixtures/real/jira_openapi.yaml|https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json|jira-cloud +jira-cloud|e2e/fixtures/real/jira.yaml|e2e/fixtures/real/jira_openapi.yaml|https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json|jira-cloud + +slack|e2e/fixtures/real/slack.yaml|e2e/fixtures/real/slack_openapi.yaml|slack:swagger2|slack + +petstore|e2e/fixtures/real/petstore.yaml|e2e/fixtures/real/petstore_openapi.json|https://petstore3.swagger.io/api/v3/openapi.json|petstore + +stripe|e2e/fixtures/real/stripe.yaml|e2e/fixtures/real/stripe_openapi.yaml|https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.yaml|stripe + +twilio|e2e/fixtures/real/twilio.yaml|e2e/fixtures/real/twilio_openapi.yaml|https://raw.githubusercontent.com/twilio/twilio-oai/main/spec/yaml/twilio_api_v2010.yaml|twilio + +spotify|e2e/fixtures/real/spotify.yaml|e2e/fixtures/real/spotify_openapi.yaml|https://raw.githubusercontent.com/APIs-guru/openapi-directory/main/APIs/spotify.com/1.0.0/openapi.yaml|spotify + +zoom|e2e/fixtures/real/zoom.yaml|e2e/fixtures/real/zoom_openapi.yaml|https://raw.githubusercontent.com/APIs-guru/openapi-directory/main/APIs/zoom.us/2.0.0/openapi.yaml|zoom From b344c6a5a5ebb27ecb1627816be14b51bdae0a96 Mon Sep 17 00:00:00 2001 From: gokul-aot Date: Tue, 30 Jun 2026 13:57:44 +0530 Subject: [PATCH 5/6] lint issues Signed-off-by: gokul-aot --- scripts/README.md | 6 ++++++ scripts/build-mcp-server.sh | 3 +++ scripts/mcp-servers.registry | 3 +++ 3 files changed, 12 insertions(+) diff --git a/scripts/README.md b/scripts/README.md index d8747cd..828d511 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,3 +1,9 @@ + + # build-mcp-server.sh Shell script to build a ToolHive-ready MCP server from a scope fixture and OpenAPI spec. It runs validate, generate, dependency sync, and optional quality checks in one pass. diff --git a/scripts/build-mcp-server.sh b/scripts/build-mcp-server.sh index 6693ba0..93e2cac 100644 --- a/scripts/build-mcp-server.sh +++ b/scripts/build-mcp-server.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bash ## +## SPDX-FileCopyrightText: 2026 AOT Technologies +## SPDX-License-Identifier: Apache-2.0 +## ## Build a ToolHive-ready MCP server from e2e fixture scopes. ## ## Usage: diff --git a/scripts/mcp-servers.registry b/scripts/mcp-servers.registry index 890b9a8..a7024ae 100644 --- a/scripts/mcp-servers.registry +++ b/scripts/mcp-servers.registry @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# SPDX-License-Identifier: Apache-2.0 +# # MCP server registry for scripts/build-mcp-server.sh # # Format (pipe-delimited, one alias per line): From c333ddb3d171ea71268c85d225117e617e9dbedb Mon Sep 17 00:00:00 2001 From: gokul-aot Date: Wed, 1 Jul 2026 10:24:44 +0530 Subject: [PATCH 6/6] readme updated Signed-off-by: gokul-aot --- scripts/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/README.md b/scripts/README.md index 828d511..7812515 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -8,6 +8,8 @@ SPDX-License-Identifier: Apache-2.0 Shell script to build a ToolHive-ready MCP server from a scope fixture and OpenAPI spec. It runs validate, generate, dependency sync, and optional quality checks in one pass. +> **In the node-wire repo:** `scripts/mcp-servers.registry` and `scripts/build-mcp-server.sh` are kept here for convenience, but scope paths (`e2e/fixtures/real/…`) and generated output (`out/-mcp/`) live in the **[mcp-builder](https://github.com/stacklok/mcp-builder)** clone. Pass `--root /path/to/mcp-builder` (or set `MCP_BUILDER_ROOT`) — do not rely on node-wire as the default root. + --- ## Prerequisites @@ -84,6 +86,8 @@ The script needs the mcp-builder repository root (where `pyproject.toml` lives). 2. `MCP_BUILDER_ROOT` environment variable 3. Parent of the `scripts/` directory +When the script lives under **node-wire** (not mcp-builder), option 3 resolves to the node-wire root, which does **not** contain `e2e/fixtures/`. Always use `--root` or `MCP_BUILDER_ROOT` in that case. + The root must contain `pyproject.toml` and `scripts/mcp-servers.registry`. --- @@ -241,3 +245,6 @@ Some OpenAPI specs contain non-ASCII content. The script sets `PYTHONUTF8=1` aut - [mcp-builder README](../README.md) - [e2e/download_openapi_specs.sh](../e2e/download_openapi_specs.sh) +- [mcp-builder on GitHub](https://github.com/stacklok/mcp-builder) — upstream repo for `e2e/fixtures/` and `out/-mcp/deploy/` +- [Google Drive connector (OIDC / ToolHive manifests)](../docs/google_drive_connector.md#user-oauth-oidc--upstream-bearer) +- [Node Wire MCP servers](../docs/mcp-servers.md)