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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user