fix(mcp): Asana catalog installs a working V2 pre-registered OAuth client

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.<name>.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.
This commit is contained in:
teknium1
2026-09-18 00:50:36 -07:00
committed by Teknium
parent 7954418d90
commit c553df915c
4 changed files with 158 additions and 46 deletions

View File

@@ -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.<name>.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.<name>``; probe + tool checklist (falling back per
Order: git clone + bootstrap (if any); credential prompts (``auth.env``) to .env; write
``mcp_servers.<name>`` (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 <provider>` 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)

View File

@@ -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.

View File

@@ -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.<name>.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.

View File

@@ -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 <name>` and restart (or
`/reload-mcp`) the session or gateway that should expose the tools.
### Updating tool selection later
```bash