From c553df915c19ad0c801b282249bbac31decc1c51 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:50:36 -0700 Subject: [PATCH] fix(mcp): Asana catalog installs a working V2 pre-registered OAuth client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The V1 beta server https://mcp.asana.com/sse is retired and Asana's V2 server (https://mcp.asana.com/v2/mcp, Streamable HTTP) has no Dynamic Client Registration: every client must be an Asana "MCP app" the user registers in the developer console. The salvaged manifest fixed the URL and documented the manual `oauth:` block; this commit makes the catalog install itself produce that block so no hand-edit of config.yaml is needed. - hermes_cli/mcp_catalog.py: manifests may pin a closed `auth.oauth` mapping (client_id, client_secret, redirect_host, redirect_port, scope) that `_build_server_config` copies to `mcp_servers..oauth`; every `${VAR}` it references must be declared in `auth.env`, mirroring the api_key header contract, so a placeholder can never reach the token endpoint as a literal. `install_entry` now prompts `auth.env` for OAuth entries too (dashboard and Desktop already render `required_env` regardless of auth type). - optional-mcps/asana/manifest.yaml: declare ASANA_CLIENT_ID/SECRET, pin the client + `http://localhost:27890/callback` (Asana matches the redirect URL exactly), and rewrite post_install around the MCP-app registration steps. - tests: shipped-catalog invariant (no manifest installs the retired `/sse` URL; the Asana client credentials are declared `${VAR}` refs; callback pinned) + install-path invariant for the new `auth.oauth` block, including the undeclared-reference rejection. - docs: catalog section on entries that need a user-owned OAuth app. Live: `hermes mcp install asana` + `hermes mcp login asana` under a temp HERMES_HOME. Before: config `url: …/sse`, no oauth block, authorize URL on the V1 server (mcp.asana.com/authorize) with a DCR client and 127.0.0.1 redirect. After: config `url: …/v2/mcp` + oauth `${ASANA_CLIENT_ID}` refs, .env holds the values, authorize URL on app.asana.com/-/oauth_authorize with the configured client_id, redirect_uri=http://localhost:27890/callback and resource=https://mcp.asana.com/v2/mcp — the flow Asana's guide documents. --- hermes_cli/mcp_catalog.py | 39 ++++++++++-- optional-mcps/asana/manifest.yaml | 55 ++++++++++------- tests/hermes_cli/test_mcp_catalog.py | 81 +++++++++++++++++++------ website/docs/user-guide/features/mcp.md | 29 +++++++++ 4 files changed, 158 insertions(+), 46 deletions(-) diff --git a/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py index d213e8c16d..bdc8797d53 100644 --- a/hermes_cli/mcp_catalog.py +++ b/hermes_cli/mcp_catalog.py @@ -43,6 +43,14 @@ class AuthSpec: provider: Optional[str] = None # OAuth-specific (third-party provider like Google) scopes: List[str] = field(default_factory=list) env_var: Optional[str] = None + # Pre-registered OAuth client block copied verbatim to ``mcp_servers..oauth`` (vendors + # without Dynamic Client Registration). Secrets stay ``${VAR}`` references declared in ``env``. + oauth: Dict[str, Any] = field(default_factory=dict) + + +# ``auth.oauth`` keys a manifest may pin; everything else is a user-side tuning knob. +_MANIFEST_OAUTH_KEYS = frozenset({"client_id", "client_secret", "redirect_host", "redirect_port", "scope"}) +_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") @dataclass @@ -187,9 +195,25 @@ def _parse_auth(path: Path, raw: Any, name: str, http: bool) -> AuthSpec: f"{path}: http + api_key auth requires auth.env to declare " f"'{_required_key}' (the key the Authorization header references)" ) + oauth_raw = auth_raw.get("oauth") or {} + if oauth_raw and a_type != "oauth": + raise CatalogError(f"{path}: auth.oauth is only valid with auth.type 'oauth'") + oauth = _require_mapping(path, "auth.oauth", oauth_raw) + unknown = sorted(set(oauth) - _MANIFEST_OAUTH_KEYS) + if unknown or not all(isinstance(v, (str, int)) and not isinstance(v, bool) for v in oauth.values()): + raise CatalogError( + f"{path}: auth.oauth allows string/int values for {sorted(_MANIFEST_OAUTH_KEYS)} only" + + (f" (unknown: {unknown})" if unknown else "") + ) + # Same contract as api_key headers: install_entry persists only DECLARED env vars, so an + # undeclared ``${VAR}`` would reach the OAuth flow as a literal placeholder (invalid_client). + declared = {spec.name for spec in env_list} + undeclared = sorted({ref for v in oauth.values() if isinstance(v, str) for ref in _ENV_REF_RE.findall(v)} - declared) + if undeclared: + raise CatalogError(f"{path}: auth.oauth references env vars not declared in auth.env: {undeclared}") return AuthSpec( type=a_type, env=env_list, provider=auth_raw.get("provider"), - scopes=list(auth_raw.get("scopes") or []), env_var=auth_raw.get("env_var")) + scopes=list(auth_raw.get("scopes") or []), env_var=auth_raw.get("env_var"), oauth=dict(oauth)) def _parse_tools(path: Path, raw: Any) -> ToolsSpec: @@ -458,6 +482,8 @@ def _build_server_config(entry: CatalogEntry, install_dir: Optional[Path]) -> di cfg["url"] = t.url if entry.auth.type == "oauth": cfg["auth"] = "oauth" + if entry.auth.oauth: + cfg["oauth"] = dict(entry.auth.oauth) elif entry.auth.type == "api_key": from hermes_cli.mcp_config import _bearer_auth_headers @@ -629,8 +655,8 @@ def _apply_tool_selection( def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None: """Install a catalog entry end-to-end. - Order: git clone + bootstrap (if any); API-key prompt to .env or the ``auth: oauth`` marker; - write ``mcp_servers.``; probe + tool checklist (falling back per + Order: git clone + bootstrap (if any); credential prompts (``auth.env``) to .env; write + ``mcp_servers.`` (with the ``auth: oauth`` marker and any pre-registered ``oauth`` block); probe + tool checklist (falling back per :func:`_apply_tool_selection`); print post_install notes. """ print() @@ -643,11 +669,11 @@ def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None: install_dir = _do_git_install(entry) if entry.install is not None else None - if entry.auth.type == "api_key": + if entry.auth.env: print() _say(" Configure credentials:", Colors.CYAN) _prompt_env_vars(entry.auth.env) - elif entry.auth.type == "oauth" and entry.auth.provider: + if entry.auth.type == "oauth" and entry.auth.provider: # Provider-mediated OAuth relies on the existing `hermes auth ` flow; surface # guidance rather than auto-running it to keep install decoupled from provider-auth lifecycle. _say( @@ -656,8 +682,9 @@ def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None: "already authenticated.", Colors.YELLOW) elif entry.auth.type == "oauth": + client = "your pre-registered OAuth client" if entry.auth.oauth.get("client_id") else "native OAuth 2.1" _say( - " This MCP uses native OAuth 2.1; tokens will be acquired " + f" This MCP uses {client}; tokens will be acquired " "on first connection (browser flow).", Colors.DIM) diff --git a/optional-mcps/asana/manifest.yaml b/optional-mcps/asana/manifest.yaml index fd4a4b1969..511701a16d 100644 --- a/optional-mcps/asana/manifest.yaml +++ b/optional-mcps/asana/manifest.yaml @@ -5,19 +5,36 @@ manifest_version: 1 name: asana description: >- Tasks, projects, and goals from your Asana workspace. -source: https://developers.asana.com/docs/using-asanas-mcp-server +source: https://developers.asana.com/docs/integrating-with-asanas-mcp-server -# Asana's V2 remote MCP uses Streamable HTTP. It requires a user-owned, -# pre-registered Asana MCP app with static OAuth client credentials. -# V2 does not support Dynamic Client Registration. Hermes preserves its OAuth -# callback flow and uses the configured client credentials for token exchange -# and refresh. +# Asana's V2 remote MCP (Streamable HTTP). The V1 beta endpoint +# https://mcp.asana.com/sse is retired. V2 does not support Dynamic Client +# Registration: every client must be a user-owned, pre-registered Asana +# "MCP app" (developer console), so the OAuth block below pins that client +# and a fixed callback URL that the app must register verbatim. Hermes's MCP +# client + mcp_oauth_manager still handle discovery, PKCE, token exchange, +# and refresh with the configured credentials. transport: type: http url: https://mcp.asana.com/v2/mcp auth: type: oauth + # Prompted at install (CLI, dashboard, desktop) and stored in the profile's + # .env — config.yaml only ever carries the ${VAR} references below. + env: + - name: ASANA_CLIENT_ID + prompt: "Asana MCP app Client ID (developer console → your MCP app → OAuth)" + secret: false + - name: ASANA_CLIENT_SECRET + prompt: "Asana MCP app Client secret" + oauth: + client_id: "${ASANA_CLIENT_ID}" + client_secret: "${ASANA_CLIENT_SECRET}" + # Asana matches the redirect URL exactly; localhost and 127.0.0.1 are not + # interchangeable. Register http://localhost:27890/callback on the app. + redirect_host: localhost + redirect_port: 27890 # Composer-suggestion triggers (desktop brand pills). suggest: @@ -27,20 +44,14 @@ suggest: - asana.com post_install: | - Before logging in, create an Asana MCP app in the Asana developer console - and register Hermes's exact callback URL. The default is - http://localhost:27890/callback; if you configure a different redirect host - or port, register that exact URL instead. + Asana V2 has no Dynamic Client Registration: you need your own Asana + MCP app. In the developer console (https://app.asana.com/0/my-apps) + create an app of type "MCP app", then: + - OAuth → Redirect URL: http://localhost:27890/callback (exactly). + - Manage distribution → allow the workspace(s) you will use. + Its Client ID / Client secret are the ASANA_CLIENT_ID / ASANA_CLIENT_SECRET + values prompted above (stored in the profile's .env, never in config.yaml). - Store the app's client ID and client secret outside source control (for - example, as ASANA_CLIENT_ID and ASANA_CLIENT_SECRET in the active profile's - .env), then add them to this server's oauth configuration: - - mcp_servers: - asana: - oauth: - client_id: "${ASANA_CLIENT_ID}" - client_secret: "${ASANA_CLIENT_SECRET}" - - Run `hermes mcp login asana`, approve access in the browser, and reload or - restart the Hermes session or gateway that should expose the Asana tools. + Then run `hermes mcp login asana`, approve access in the browser, and + restart (or `/reload-mcp`) the Hermes session or gateway that should + expose the Asana tools. diff --git a/tests/hermes_cli/test_mcp_catalog.py b/tests/hermes_cli/test_mcp_catalog.py index 30d40de672..2939172ecc 100644 --- a/tests/hermes_cli/test_mcp_catalog.py +++ b/tests/hermes_cli/test_mcp_catalog.py @@ -588,6 +588,47 @@ class TestInstall: assert "${MCP_DEMO_API_KEY}" in raw assert "secret-val" not in raw + def test_install_oauth_preregistered_client_writes_oauth_block(self, catalog_dir, monkeypatch): + """Vendors without DCR: ``auth.oauth`` lands verbatim in ``mcp_servers..oauth`` while + the credentials it references are prompted into .env — config.yaml stays secret-free.""" + auth = { + "type": "oauth", + "env": [ + {"name": "DEMO_CLIENT_ID", "prompt": "id", "secret": False}, + {"name": "DEMO_CLIENT_SECRET", "prompt": "secret"}, + ], + "oauth": { + "client_id": "${DEMO_CLIENT_ID}", "client_secret": "${DEMO_CLIENT_SECRET}", + "redirect_host": "localhost", "redirect_port": 27890, + }, + } + _write_manifest(catalog_dir, "demo", _basic_manifest( + transport={"type": "http", "url": "https://mcp.example.com/v2/mcp"}, auth=auth)) + + from hermes_cli import mcp_catalog + from hermes_cli.config import get_config_path, get_env_value, load_config + + monkeypatch.setattr(mcp_catalog, "_prompt_input", lambda prompt, **kw: f"val-for-{prompt}") + mcp_catalog.install_entry(_entry("demo"), enable=True) + + server = load_config()["mcp_servers"]["demo"] + assert server["auth"] == "oauth" + assert server["oauth"] == { + "client_id": "val-for-id", "client_secret": "val-for-secret", + "redirect_host": "localhost", "redirect_port": 27890, + } + assert get_env_value("DEMO_CLIENT_SECRET") == "val-for-secret" + raw = get_config_path().read_text(encoding="utf-8") + assert "${DEMO_CLIENT_SECRET}" in raw and "val-for-secret" not in raw + + # A ``${VAR}`` the manifest never declares would reach the token endpoint as a literal + # placeholder (invalid_client): rejected at parse time, like the api_key header contract. + auth["oauth"]["client_id"] = "${UNDECLARED_ID}" + path = _write_manifest(catalog_dir, "demo2", _basic_manifest( + "demo2", transport={"type": "http", "url": "https://mcp.example.com/v2/mcp"}, auth=auth)) + with pytest.raises(mcp_catalog.CatalogError, match="UNDECLARED_ID"): + mcp_catalog._parse_manifest(path) + @@ -888,25 +929,29 @@ class TestToolsConfigIncludeMode: class TestShippedCatalog: - def test_asana_catalog_uses_v2_static_oauth_guidance(self): - """Asana V2 is Streamable HTTP and does not support OAuth DCR.""" - manifest = Path(__file__).parents[2] / "optional-mcps" / "asana" / "manifest.yaml" - raw_manifest = manifest.read_text(encoding="utf-8") - entry = yaml.safe_load(raw_manifest) + def test_asana_catalog_targets_v2_with_preregistered_client(self, monkeypatch): + """Asana's V1 ``/sse`` server is retired and V2 has no DCR: the shipped entry must install + as the V2 Streamable HTTP URL plus a pre-registered client whose credentials are ``${VAR}`` + references the install path actually prompts for (never literal values).""" + monkeypatch.delenv("HERMES_OPTIONAL_MCPS", raising=False) + from hermes_cli.mcp_catalog import _build_server_config, _catalog_root, _parse_manifest - assert entry["transport"] == { - "type": "http", - "url": "https://mcp.asana.com/v2/mcp", - } - assert "https://mcp.asana.com/sse" not in raw_manifest - assert "Streamable HTTP" in raw_manifest - assert "Native OAuth 2.1 + Dynamic Client Registration" not in raw_manifest - assert re.search( - r"does\s+not support Dynamic Client Registration", raw_manifest - ) - assert "pre-registered" in raw_manifest - assert "ASANA_CLIENT_ID" in raw_manifest - assert "ASANA_CLIENT_SECRET" in raw_manifest + root = _catalog_root() + if not root.exists(): + pytest.skip("optional-mcps/ not present in this checkout") + for m in root.glob("*/manifest.yaml"): + assert (_parse_manifest(m).transport.url or "") != "https://mcp.asana.com/sse", m + + entry = _parse_manifest(root / "asana" / "manifest.yaml") + cfg = _build_server_config(entry, None) + assert cfg["url"] == "https://mcp.asana.com/v2/mcp" + assert cfg["auth"] == "oauth" + declared = {spec.name for spec in entry.auth.env} + for key in ("client_id", "client_secret"): + ref = re.fullmatch(r"\$\{([A-Z_]+)\}", cfg["oauth"][key]) + assert ref and ref.group(1) in declared, (key, cfg["oauth"][key]) + # Asana matches the registered redirect URL exactly; the callback must be pinned. + assert cfg["oauth"]["redirect_host"] and cfg["oauth"]["redirect_port"] def test_all_shipped_manifests_parse(self, monkeypatch): """Every manifest in optional-mcps/ must parse cleanly. diff --git a/website/docs/user-guide/features/mcp.md b/website/docs/user-guide/features/mcp.md index 9677f5caa9..928e7c2985 100644 --- a/website/docs/user-guide/features/mcp.md +++ b/website/docs/user-guide/features/mcp.md @@ -176,6 +176,35 @@ Note this is distinct from `${INSTALL_DIR}` in catalog manifests, which is substituted at install-time with the path the catalog cloned the entry's repo into. +### Entries that need your own OAuth app (no DCR) + +Some vendors run their remote MCP behind OAuth but do **not** offer Dynamic +Client Registration — every client must be an app the user pre-registers in +the vendor's developer console. Asana's V2 server +(`https://mcp.asana.com/v2/mcp`) is the shipped example: the retired V1 +`https://mcp.asana.com/sse` server accepted any client; V2 does not. + +Such a manifest declares the credentials under `auth.env` and pins the +client under `auth.oauth`, so installing it (CLI picker, web dashboard or +Desktop) prompts for the Client ID / Client secret, stores them in the +profile's `.env`, and writes only `${VAR}` references to `config.yaml`: + +```yaml +mcp_servers: + asana: + url: https://mcp.asana.com/v2/mcp + auth: oauth + oauth: + client_id: "${ASANA_CLIENT_ID}" + client_secret: "${ASANA_CLIENT_SECRET}" + redirect_host: localhost # the vendor matches the redirect URL exactly + redirect_port: 27890 # register http://localhost:27890/callback on the app +``` + +Read the entry's `post_install` notes for the exact app type and redirect URL +to register, then run `hermes mcp login ` and restart (or +`/reload-mcp`) the session or gateway that should expose the tools. + ### Updating tool selection later ```bash