From c9280c66bd8390b07f8511996c4a8188561ee790 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:49:38 +0200 Subject: [PATCH 1/2] feat(publish): remote claude.ai connector (Streamable-HTTP + OAuth 2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second PUBLISH target (D19): a claude.ai *custom connector* is a REMOTE MCP server reached from Anthropic's cloud over HTTPS + OAuth 2.1 — a separate surface from the local stdio .mcpb. `claude-remote-connector` (coact/publish_remote.py) SCAFFOLDS a deployable service you host: - emits server/app.py (an ASGI app via py2mcp.http.mk_http_app), a connector_config.json, requirements.txt, a DEPLOY.md guide, and a Dockerfile. - the auth block is a resource-server (type=jwt): validate a managed IdP's JWTs, audience-bound (RFC 8707), never issue tokens, never forward them upstream. - no IdP/connector-url given -> clearly-marked placeholders + a loud warning (never a silently-unauthenticated config); a remote connector MUST require OAuth. - coact writes packaging; py2mcp.http builds + serves the MCP server (D17). The build is pure stdlib; py2mcp/fastmcp/uvicorn are runtime-only (missing -> warning). `import coact` pulls in none of them (isolation-tested). - IntegrationSpec.auth='oauth2.1' / deployment='remote-http' go live; same draft/ empty guards as .mcpb. CLI `coact publish --target claude-remote-connector [--connector-url --idp-issuer]`; exports publish_remote; DECISIONS D19; skill + README updated. 12 offline tests (one dev-only test execs the generated app.py to prove it builds a real authed ASGI app). The py2mcp http layer has its own upstream PR. Full suite: 405 passed. --- .claude/skills/coact-publish/SKILL.md | 57 +++-- README.md | 24 +- coact/__init__.py | 2 + coact/__main__.py | 15 +- coact/publish_remote.py | 335 ++++++++++++++++++++++++++ misc/docs/DECISIONS.md | 46 ++++ tests/test_publish_remote.py | 171 +++++++++++++ 7 files changed, 629 insertions(+), 21 deletions(-) create mode 100644 coact/publish_remote.py create mode 100644 tests/test_publish_remote.py diff --git a/.claude/skills/coact-publish/SKILL.md b/.claude/skills/coact-publish/SKILL.md index 98b6083..9e63e2f 100644 --- a/.claude/skills/coact-publish/SKILL.md +++ b/.claude/skills/coact-publish/SKILL.md @@ -10,11 +10,12 @@ description: >- functions for Claude", "turn this into a Claude extension/connector", "publish a local MCP server", "wrap my tools as a Claude Desktop extension". Also use to draft an integration from a natural-language description ("describe an - integration", "I want a Claude connector that can…") via `coact describe`. For - REMOTE claude.ai connectors (HTTPS + OAuth) this is the wrong target — that - surface is not built yet (see Limitations). + integration", "I want a Claude connector that can…") via `coact describe`. Also + covers REMOTE claude.ai connectors (a hosted Streamable-HTTP MCP server + OAuth + 2.1) via the `claude-remote-connector` target — use when the user wants a + cloud-reachable connector, not a local install. metadata: - version: 0.2.0 + version: 0.3.0 --- # coact publish — Python capability → Claude integration @@ -91,19 +92,47 @@ Install the result: Claude Desktop → Settings → Extensions → Install Exten (or double-click the `.mcpb`). The extension runs **on the user's machine** and needs a Python with `py2mcp` + `fastmcp` importable. +## Remote claude.ai connector (Streamable-HTTP + OAuth 2.1) + +A claude.ai **custom connector** is a *remote* MCP server reached from Anthropic's +cloud over HTTPS + OAuth — a different surface from the local `.mcpb`. Scaffold one +(a hosted service you deploy) with the `claude-remote-connector` target: + +```bash +coact publish mypkg.tools:summarize --target claude-remote-connector \ + --name my-conn --dest ./out \ + --connector-url https://my-conn.example.com --idp-issuer https://my-idp.example.com +# → ./out/my-conn-connector/ (server/app.py, connector_config.json, requirements.txt, +# DEPLOY.md, Dockerfile) +``` + +```python +from coact import publish_remote +publish_remote(["mypkg.tools:summarize"], name="my-conn", dest="out", + connector_url="https://my-conn.example.com", + idp_issuer="https://my-idp.example.com", + required_scopes=["mcp:read"]) +``` + +It scaffolds an OAuth 2.1 **resource server** (validates a managed IdP's JWTs via +`py2mcp.http.mk_http_app`; never issues tokens; audience-bound per RFC 8707). Omit +`--connector-url`/`--idp-issuer` to scaffold with **fill-in placeholders + a loud +warning**. Then follow the generated `DEPLOY.md`: set your IdP, run behind TLS +(`uvicorn server.app:app`), and add the HTTPS URL as a custom connector in claude.ai. +Needs `py2mcp>=0.1.4` + `fastmcp` + `uvicorn` where the service runs. + ## Key distinctions (don't conflate) -- **Local `.mcpb` (this target):** stdio, no OAuth, runs on the user's machine. -- **Remote claude.ai connector (NOT this target):** a remote MCP server reached - from Anthropic's cloud over HTTPS + OAuth — a different surface, not yet built. -- A `.mcpb` is *connectivity* (tools). A **Skill** (`SKILL.md`) is *procedural - knowledge*. They are complementary; this skill packages the former. +- **Local `.mcpb` (`claude-local-mcpb`):** stdio, no OAuth, runs on the user's machine. +- **Remote connector (`claude-remote-connector`):** a hosted MCP server reached from + Anthropic's cloud over HTTPS + OAuth — public, multi-user, you deploy it. +- A connector is *connectivity* (tools). A **Skill** (`SKILL.md`) is *procedural + knowledge*. They are complementary. ## Limitations (current) -- Only `claude-local-mcpb`. Remote connectors, Claude Code plugins, ChatGPT - Apps, and Gemini are planned targets (the registry is open-closed). -- The bundle references tools by `module:function`; the **functions must be - importable** in the Python that Claude Desktop runs (full dependency vendoring - into the bundle is a future refinement). +- Targets: `claude-local-mcpb` and `claude-remote-connector`. Claude Code plugins, + ChatGPT Apps, and Gemini are planned (the registry is open-closed). +- Tools are referenced by `module:function`; the **functions must be importable** + where the server runs (dependency vendoring is a future refinement). - Background: `misc/docs/CHATBOT_INTEGRATION_LANDSCAPE.md`. diff --git a/README.md b/README.md index 04c78dd..c9fdbcb 100644 --- a/README.md +++ b/README.md @@ -104,11 +104,27 @@ publish(["mypkg.tools:summarize", "mypkg.tools:translate"], Sources can be `module:function` refs, live callables, or a skill carrying a `coact: mcp:` block. `dry_run=True` (or `--dry-run`) previews the bundle without -writing it. This is the **local** surface (stdio, no OAuth); remote claude.ai -*connectors* (HTTPS + OAuth), Claude Code plugins, ChatGPT Apps, and Gemini are -planned targets on the same open-closed registry. Background: +writing it. This is the **local** surface (stdio, no OAuth). Install: `pip install coact[mcpb]`. + +For the **remote** surface — a claude.ai *custom connector* (a hosted +Streamable-HTTP MCP server reached from Anthropic's cloud over HTTPS + OAuth 2.1) — +use the `claude-remote-connector` target, which scaffolds a deployable service: + +```python +from coact import publish_remote +publish_remote(["mypkg.tools:summarize"], name="my-conn", dest="out", + connector_url="https://my-conn.example.com", # this server's public URL + idp_issuer="https://my-idp.example.com") # your managed IdP +# → out/my-conn-connector/ (server/app.py + connector_config.json + DEPLOY.md + …) +``` + +The scaffold is an OAuth 2.1 **resource server** (validates a managed IdP's +audience-bound JWTs; never issues tokens) built by +[`py2mcp`](https://github.com/i2mint/py2mcp)'s `http.mk_http_app`; coact writes the +deploy packaging, py2mcp/FastMCP serves the MCP. Follow the generated `DEPLOY.md`. +Claude Code plugins, ChatGPT Apps, and Gemini are further planned targets on the +same open-closed registry. Background: [`misc/docs/CHATBOT_INTEGRATION_LANDSCAPE.md`](misc/docs/CHATBOT_INTEGRATION_LANDSCAPE.md). -Install: `pip install coact[mcpb]`. There are two ways to get an `IntegrationSpec`. The **mechanical** ingress above (refs / callables / skills) uses **no LLM**. The **opt-in** ingress refines a diff --git a/coact/__init__.py b/coact/__init__.py index 133ab8f..f26fc5e 100644 --- a/coact/__init__.py +++ b/coact/__init__.py @@ -72,6 +72,7 @@ from coact.nl_ingress import integration_spec_from_description from coact.publish import PublishResult, publish, publish_targets from coact.publish_mcpb import publish_mcpb # registers 'claude-local-mcpb' +from coact.publish_remote import publish_remote # registers 'claude-remote-connector' from coact.scaffold import scaffold_fleet from coact.stores import AgentStore, agents_dir from coact.synthesis import synthesize_persona, synthesize_return_contract @@ -133,6 +134,7 @@ def _resolve_version() -> str: "publish_targets", "PublishResult", "publish_mcpb", + "publish_remote", # remote claude.ai connector (Streamable-HTTP + OAuth 2.1; D19) # Scaffold (the one topology-adjacent emitter — a starter you own; D8) "scaffold_fleet", # Synthesis & LLM facade diff --git a/coact/__main__.py b/coact/__main__.py index 4029b5e..b760ec4 100644 --- a/coact/__main__.py +++ b/coact/__main__.py @@ -147,15 +147,24 @@ def publish( name: str | None = None, author: str | None = None, dry_run: bool = False, + connector_url: str | None = None, + idp_issuer: str | None = None, ) -> str: """Publish a capability to a chatbot host (default: a local Claude Desktop .mcpb bundle). - ``--dry-run`` previews the bundle members (manifest + server) without writing - the ``.mcpb``. + ``--target claude-remote-connector`` instead scaffolds a REMOTE connector + (Streamable-HTTP MCP server + OAuth 2.1); ``--connector-url`` (its public URL) + and ``--idp-issuer`` (your managed identity provider) configure OAuth — omit + them to scaffold with fill-in placeholders. ``--dry-run`` previews without writing. """ src = source if len(source) > 1 else source[0] + extra: dict = {} + if connector_url is not None: + extra["connector_url"] = connector_url + if idp_issuer is not None: + extra["idp_issuer"] = idp_issuer res = _publish( - src, target=target, dest=dest, name=name, author=author, dry_run=dry_run + src, target=target, dest=dest, name=name, author=author, dry_run=dry_run, **extra ) return res.render() diff --git a/coact/publish_remote.py b/coact/publish_remote.py new file mode 100644 index 0000000..57bc96f --- /dev/null +++ b/coact/publish_remote.py @@ -0,0 +1,335 @@ +"""``claude-remote-connector`` publish target — scaffold a deployable **remote** +Claude connector (a public Streamable-HTTP MCP server with OAuth 2.1). + +A claude.ai **custom connector** is a *remote* MCP server reached from Anthropic's +cloud over public HTTPS — even on Desktop — so it needs a publicly-reachable +endpoint and **OAuth 2.1**. That is a different surface from the local +``.mcpb``/stdio target (:mod:`coact.publish_mcpb`): different transport, different +auth, different place it runs. This target produces a **deployment scaffold** (a +small project you host), not a single file. + +Division of labour (mirrors the ``.mcpb`` path and DECISIONS D17/D19): coact +writes only *packaging/deploy scaffolding*; the MCP server itself — Streamable +HTTP + the OAuth 2.1 **resource-server** wiring — is built by ``py2mcp``'s +:func:`py2mcp.http.mk_http_app`. So building the scaffold is **pure stdlib** +(``json`` only); ``py2mcp``/``fastmcp``/``uvicorn`` are needed only in the Python +that *runs* the deployed service, and a missing one is a warning, not a build error. + +Security posture baked into the scaffold (landscape doc §4.4/§8.5): the server is +an OAuth 2.1 **resource server** that *validates* a managed IdP's tokens (never an +authorization server of its own), tokens are **audience-bound** (RFC 8707) so one +minted for another service can't be replayed, and the server **never forwards** an +inbound token upstream (no confused-deputy). The connector binds locally and must +sit behind a TLS-terminating reverse proxy. +""" + +from __future__ import annotations + +import json +from importlib.util import find_spec +from pathlib import Path +from typing import Any, Optional + +from coact.integration import IntegrationSource, IntegrationSpec, integration_spec_from +from coact.publish import PublishResult, targets +from coact.util import safe_filename + +#: Placeholder host used in the emitted config when no real connector URL is given. +_PLACEHOLDER_HOST = "https://YOUR-CONNECTOR.example.com" +#: Placeholder IdP issuer used when no managed IdP is configured yet. +_PLACEHOLDER_IDP = "https://YOUR-IDP.example.com" + +#: The generated ``server/app.py`` — an ASGI app any ASGI server can run. +_SERVER_APP = '''\ +"""ASGI entry point for a coact-scaffolded remote Claude connector. + +Serves a py2mcp MCP server over Streamable HTTP with OAuth 2.1 (a resource +server). Run behind TLS, e.g.: + + uvicorn server.app:app --host 127.0.0.1 --port 8000 + +Requires `py2mcp` (>=0.1.4), `fastmcp`, and an ASGI server (uvicorn) importable. +""" +import json +import os + +from py2mcp.http import mk_http_app + +_CONFIG = os.path.join(os.path.dirname(__file__), "{config_name}") +with open(_CONFIG) as _f: + _cfg = json.load(_f) + +app = mk_http_app( + _cfg["refs"], + name=_cfg.get("name", "connector"), + auth=_cfg.get("auth"), + transport=_cfg.get("transport", "streamable-http"), + stateless_http=_cfg.get("stateless_http", True), +) +''' + +#: Name of the bundled connector config, under ``server/``. +SERVER_CONFIG_NAME = "connector_config.json" +_SERVER_APP = _SERVER_APP.format(config_name=SERVER_CONFIG_NAME) + +_REQUIREMENTS = "py2mcp>=0.1.4\nfastmcp>=3\nuvicorn[standard]>=0.27\n" + +_DOCKERFILE = '''\ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY server ./server +# Bind locally; terminate TLS at your reverse proxy / platform. +ENV PORT=8000 +CMD ["sh", "-c", "uvicorn server.app:app --host 0.0.0.0 --port ${PORT}"] +''' + + +def publish_remote( + source: IntegrationSource, + *, + dest: Optional[str] = None, + dry_run: bool = False, + name: Optional[str] = None, + author: Optional[str] = None, + version: str = "0.1.0", + description: str = "", + connector_url: Optional[str] = None, + idp_issuer: Optional[str] = None, + jwks_uri: Optional[str] = None, + audience: Optional[str] = None, + required_scopes: Optional[list] = None, + host: str = "127.0.0.1", + port: int = 8000, + transport: str = "streamable-http", + include_dockerfile: bool = True, +) -> PublishResult: + """Scaffold a remote Claude connector (Streamable-HTTP MCP server + OAuth 2.1). + + ``source`` is anything :func:`coact.integration.integration_spec_from` accepts. + The OAuth parameters describe the **managed IdP** that issues tokens and *this* + server's public identity; when omitted, placeholder values + a loud warning are + emitted so the scaffold is still useful (fill them in before going public). + + >>> res = publish_remote(['os.path:basename'], name='paths', dry_run=True) + >>> res.dry_run, sorted(res.files) # doctest: +NORMALIZE_WHITESPACE + (True, ['DEPLOY.md', 'Dockerfile', 'requirements.txt', 'server/app.py', + 'server/connector_config.json']) + """ + spec = integration_spec_from( + source, name=name, author=author, version=version, description=description + ) + spec.auth = "oauth2.1" + spec.deployment = "remote-http" + if spec.is_empty(): + raise ValueError("nothing to publish: the IntegrationSpec carries no tools.") + + runnable_refs = spec.runnable_refs() + if not runnable_refs: + proposed = ", ".join(ts.name for ts in spec.tool_specs) or "(none)" + raise ValueError( + f"This IntegrationSpec is a design draft: {len(spec.tool_specs)} " + f"proposed tool(s) [{proposed}], none bound to an importable " + "'module:function' handler. Bind handlers (or pass refs) before " + "scaffolding a runnable remote connector (see `coact describe`)." + ) + + auth, auth_warnings = _build_auth( + connector_url=connector_url, + idp_issuer=idp_issuer, + jwks_uri=jwks_uri, + audience=audience, + required_scopes=required_scopes, + ) + config = { + "name": spec.name, + "refs": runnable_refs, + "host": host, + "port": port, + "transport": transport, + "stateless_http": True, + "auth": auth, + } + members = { + "server/app.py": _SERVER_APP, + f"server/{SERVER_CONFIG_NAME}": json.dumps(config, indent=2), + "requirements.txt": _REQUIREMENTS, + "DEPLOY.md": _deploy_md(spec, auth, connector_url=connector_url), + } + if include_dockerfile: + members["Dockerfile"] = _DOCKERFILE + + warnings = list(auth_warnings) + if find_spec("py2mcp") is None: + warnings.append( + "py2mcp is not importable here; the deployed service needs `py2mcp` " + ">=0.1.4, `fastmcp`, and `uvicorn` installed where it runs." + ) + + dir_name = safe_filename(spec.name, suffix="-connector", kind="integration name") + instructions = _install_instructions(dir_name) + previews = {rel: _preview(content) for rel, content in members.items()} + + if dry_run: + return PublishResult( + target="claude-remote-connector", + dry_run=True, + files=previews, + instructions=instructions, + warnings=warnings, + ) + + dest_dir = (Path(dest) if dest is not None else Path.cwd()) / dir_name + for rel, content in members.items(): + out = dest_dir / rel + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(content) + + return PublishResult( + target="claude-remote-connector", + dry_run=False, + artifact=dest_dir, + files=previews, + instructions=instructions, + warnings=warnings, + ) + + +def _build_auth( + *, + connector_url: Optional[str], + idp_issuer: Optional[str], + jwks_uri: Optional[str], + audience: Optional[str], + required_scopes: Optional[list], +) -> tuple[dict, list[str]]: + """Build the ``auth`` config block (resource-server, ``type='jwt'``) + warnings. + + A fully-specified IdP yields a ready config; missing pieces yield clearly-marked + placeholders plus a loud warning — a remote connector MUST require OAuth 2.1, so + we never silently emit an unauthenticated config. + """ + warnings: list[str] = [] + base_url = connector_url or _PLACEHOLDER_HOST + issuer = idp_issuer or _PLACEHOLDER_IDP + resolved_jwks = jwks_uri or f"{issuer.rstrip('/')}/.well-known/jwks.json" + resolved_aud = audience or f"{base_url.rstrip('/')}/mcp" + + missing = [ + label + for label, value in (("connector_url", connector_url), ("idp_issuer", idp_issuer)) + if not value + ] + if missing: + warnings.append( + "OAuth is NOT fully configured (" + + ", ".join(f"missing {m}" for m in missing) + + "): placeholder values were written to " + f"server/{SERVER_CONFIG_NAME}. A remote connector MUST require OAuth " + "2.1 — set your managed IdP (issuer + JWKS) and this connector's public " + "URL before exposing it. See DEPLOY.md." + ) + + auth = { + "type": "jwt", + "jwks_uri": resolved_jwks, + "issuer": issuer, + "audience": resolved_aud, + "authorization_servers": [issuer], + "base_url": base_url, + "required_scopes": list(required_scopes or []), + } + return auth, warnings + + +def _deploy_md( + spec: IntegrationSpec, auth: dict, *, connector_url: Optional[str] +) -> str: + """The DEPLOY.md guide bundled with the scaffold (deploy + security + register).""" + tools = ", ".join(spec.runnable_refs()) + return f"""# Deploy: {spec.name} — remote Claude connector + +This is a **remote** MCP server (Streamable HTTP + OAuth 2.1) — a claude.ai +*custom connector*. It is reached from Anthropic's cloud over public **HTTPS**, +so it must be publicly reachable and authenticated. (This is *not* a local +`.mcpb` extension; that is a separate, local-stdio surface.) + +Tools exposed: {tools} + +## 1. Prerequisites + +```bash +pip install -r requirements.txt # py2mcp, fastmcp, uvicorn +``` + +## 2. Configure OAuth 2.1 (resource-server pattern) + +This server is an OAuth 2.1 **resource server**: it *validates* access tokens +issued by a **managed identity provider** (the authorization server) — it never +issues tokens itself. Use a managed IdP (Auth0, WorkOS AuthKit, Azure AD, Google, +Okta, …); **do not roll your own authorization server.** + +Edit `server/{SERVER_CONFIG_NAME}` → `auth`: + +- `issuer` / `authorization_servers`: your IdP's issuer URL. +- `jwks_uri`: the IdP's JWKS endpoint (signing keys). +- `audience`: **this** connector's resource id (its public URL) — the token's + `aud`. This audience binding (RFC 8707) is what stops a token minted for another + service being replayed here. Current value: `{auth['audience']}`. +- `base_url`: this connector's public base URL. +- `required_scopes`: scopes every request must carry (optional). + +The server publishes `/.well-known/oauth-protected-resource` (RFC 9728) pointing +clients at your IdP automatically — you do not write it. + +## 3. Run behind TLS + +```bash +uvicorn server.app:app --host 127.0.0.1 --port 8000 +``` + +Bind to localhost and terminate **HTTPS at a reverse proxy** (nginx/Caddy) or your +platform. A VPN-only/firewalled server cannot be a connector — Anthropic's cloud +connects from public IPs. For horizontal scale keep `stateless_http: true` (set) +or externalize session state; avoid sticky sessions. + +Container option: `docker build -t {spec.name}-connector . && docker run -p 8000:8000 {spec.name}-connector` +(still front it with TLS). + +## 4. Register in claude.ai + +Settings → Connectors → "Add custom connector" → enter your **public HTTPS URL** +(e.g. `{connector_url or _PLACEHOLDER_HOST}/mcp`). Claude runs the OAuth flow against +your IdP. (Team/Enterprise admins can manage org-wide.) + +## 5. Security checklist (designed in, not bolted on) + +- Resource server **only** — never an authorization server; tokens come from the IdP. +- **Validate the token audience**; the server **never forwards** an inbound token + upstream (no confused-deputy). Any upstream call your tools make uses its own creds. +- Least-privilege scopes; human approval for write/destructive tools. +- Never bake secrets into the config/repo — use env / your platform's secret store. +- Keep `fastmcp` patched (OAuth/transport CVEs have history). +""" + + +def _install_instructions(dir_name: str) -> str: + """Human next-steps for the produced scaffold.""" + return ( + f"Scaffolded {dir_name!r}: a REMOTE Claude connector (Streamable HTTP + " + "OAuth 2.1). Next: configure your managed IdP + this connector's public URL " + f"in {dir_name}/server/{SERVER_CONFIG_NAME}, run it behind TLS " + "(uvicorn server.app:app), then add its HTTPS URL as a custom connector in " + "claude.ai. Full guide: DEPLOY.md. This is NOT a local .mcpb — it runs as a " + "hosted service reached from Anthropic's cloud." + ) + + +def _preview(content: str, *, limit: int = 200) -> str: + """A one-line, length-bounded preview of a scaffold member's content.""" + text = " ".join((content if isinstance(content, str) else repr(content)).split()) + return text if len(text) <= limit else text[:limit] + "…" + + +targets.register("claude-remote-connector", publish_remote) diff --git a/misc/docs/DECISIONS.md b/misc/docs/DECISIONS.md index b934009..b378c24 100644 --- a/misc/docs/DECISIONS.md +++ b/misc/docs/DECISIONS.md @@ -496,3 +496,49 @@ landscape doc §9.2 — a **natural-language description** → a *draft* (resources/prompts only) in its message. The manifest keeps the bound function's own name/docstring (what `py2mcp` actually serves — no manifest↔runtime desync); a curated ToolSpec description only *fills an empty* one. + +## D19 — PUBLISH: the remote claude.ai connector (Streamable-HTTP + OAuth 2.1) + +`claude-local-mcpb` (D17) is the **local** surface — stdio, no OAuth, on the +user's machine. A claude.ai **custom connector** is the **remote** surface: a +public Streamable-HTTP MCP server reached from Anthropic's cloud (even on Desktop) +over HTTPS + OAuth 2.1. It is a *second target* (`claude-remote-connector`, +`coact/publish_remote.py`), never a flag on the local one — the local/remote split +is load-bearing (landscape §9.3). Decisions: + +- **The artifact is a deployment *scaffold*, not one file.** A remote connector is + a hosted service, so the target writes a small project (a `server/app.py` ASGI + entry, a `connector_config.json`, `requirements.txt`, a `DEPLOY.md` guide, an + optional `Dockerfile`) you host — coact "writes the design, you own the deploy" + (D8/D13), exactly as the `.mcpb` packs a bundle you install. + +- **coact writes packaging; py2mcp builds + serves the MCP server (D17 again).** + The Streamable-HTTP transport + OAuth 2.1 wiring is **py2mcp's** + (`py2mcp.http.mk_http_app` / `serve_http`, added upstream — first customer this + target), which wraps FastMCP's native machinery. So building the scaffold is + **pure stdlib** (`json`); `py2mcp`/`fastmcp`/`uvicorn` are needed only where the + service *runs*, and a missing one is a **warning**, not a build error — the same + posture as `.mcpb`. (aw_agents was the original hosting candidate but has no + HTTP/OAuth; py2mcp+FastMCP is the right substrate, so the plan moved there.) + +- **Resource-server OAuth, by construction (landscape §4.4/§8.5).** The emitted + `auth` block is `type: jwt`: the server is an OAuth 2.1 **resource server** that + *validates* a managed IdP's JWTs (`JWTVerifier` → `RemoteAuthProvider`) — it is + **never an authorization server** ("never roll your own AS"), tokens are + **audience-bound** (RFC 8707; a token for another service can't be replayed), it + publishes RFC 9728 protected-resource metadata, and it **never forwards** the + inbound token upstream (no confused-deputy). `DEPLOY.md` states the rules; the + config is generated to honor them. + +- **No silent unauthenticated config.** A remote connector MUST require OAuth, so + when the IdP / connector URL aren't supplied the scaffold emits clearly-marked + **placeholders + a loud warning** (never a working-but-open config). `IntegrationSpec` + carries `auth='oauth2.1'` / `deployment='remote-http'` (the slots reserved at D17 + go live). Same draft/empty guards as `.mcpb` (no scaffold for a handler-less draft). + +- **Packaging.** `coact/publish_remote.py` (self-registers `claude-remote-connector`); + exports `publish_remote`; CLI `coact publish … --target claude-remote-connector + [--connector-url … --idp-issuer …]`; reuses `coact[mcpb]` runtime deps (+ uvicorn + via the scaffold's `requirements.txt`). Offline tests assert the emitted files; a + dev-only test execs the generated `app.py` to prove it builds a real authed ASGI + app. The py2mcp HTTP layer has its own upstream test + PR. diff --git a/tests/test_publish_remote.py b/tests/test_publish_remote.py new file mode 100644 index 0000000..76fd937 --- /dev/null +++ b/tests/test_publish_remote.py @@ -0,0 +1,171 @@ +"""Tests for the claude-remote-connector publish target. + +Building the scaffold is pure stdlib (no py2mcp/fastmcp import), so these run +offline and assert on the emitted files' content. +""" + +import json + +import pytest + +from coact import ( + IntegrationSpec, + ToolSpec, + publish, + publish_remote, + publish_targets, +) +from coact.publish_remote import SERVER_CONFIG_NAME + +_MEMBERS = { + "DEPLOY.md", + "Dockerfile", + "requirements.txt", + "server/app.py", + f"server/{SERVER_CONFIG_NAME}", +} + + +def test_target_registered(): + assert "claude-remote-connector" in publish_targets() + + +def test_dry_run_writes_nothing(tmp_path): + res = publish( + ["os.path:basename"], + target="claude-remote-connector", + name="paths", + dest=str(tmp_path), + dry_run=True, + ) + assert res.dry_run is True + assert res.artifact is None + assert set(res.files) == _MEMBERS + assert list(tmp_path.iterdir()) == [] # nothing written + + +def test_writes_scaffold_dir(tmp_path): + res = publish_remote( + ["os.path:basename", "os.path:dirname"], + name="paths", + dest=str(tmp_path), + connector_url="https://conn.example.com", + idp_issuer="https://idp.example.com", + required_scopes=["mcp:read"], + ) + assert res.artifact is not None and res.artifact.is_dir() + assert res.artifact.name == "paths-connector" + written = {str(p.relative_to(res.artifact)) for p in res.artifact.rglob("*") if p.is_file()} + # rglob uses OS sep; normalize + written = {w.replace("\\", "/") for w in written} + assert _MEMBERS <= written + + cfg = json.loads((res.artifact / "server" / SERVER_CONFIG_NAME).read_text()) + assert cfg["refs"] == ["os.path:basename", "os.path:dirname"] + assert cfg["transport"] == "streamable-http" + assert cfg["stateless_http"] is True + auth = cfg["auth"] + assert auth["type"] == "jwt" + assert auth["issuer"] == "https://idp.example.com" + assert auth["authorization_servers"] == ["https://idp.example.com"] + assert auth["base_url"] == "https://conn.example.com" + # audience defaults to connector_url + /mcp (RFC 8707 resource binding) + assert auth["audience"] == "https://conn.example.com/mcp" + # jwks defaults under the issuer + assert auth["jwks_uri"] == "https://idp.example.com/.well-known/jwks.json" + assert auth["required_scopes"] == ["mcp:read"] + + app = (res.artifact / "server" / "app.py").read_text() + assert "py2mcp.http" in app and "mk_http_app" in app + + +def test_fully_configured_has_no_oauth_warning(tmp_path): + res = publish_remote( + ["os.path:basename"], + name="ok", + dest=str(tmp_path), + connector_url="https://conn.example.com", + idp_issuer="https://idp.example.com", + ) + assert not any("OAuth is NOT fully configured" in w for w in res.warnings) + + +def test_unconfigured_oauth_warns_and_uses_placeholders(tmp_path): + res = publish_remote(["os.path:basename"], name="todo", dest=str(tmp_path)) + assert any("OAuth is NOT fully configured" in w for w in res.warnings) + auth = json.loads((res.artifact / "server" / SERVER_CONFIG_NAME).read_text())["auth"] + assert "YOUR-IDP" in auth["issuer"] # placeholder emitted, not a silent unauthenticated config + assert auth["type"] == "jwt" + + +def test_explicit_audience_and_jwks_override(tmp_path): + res = publish_remote( + ["os.path:basename"], + name="x", + dest=str(tmp_path), + connector_url="https://conn.example.com", + idp_issuer="https://idp.example.com", + audience="https://conn.example.com/custom-aud", + jwks_uri="https://idp.example.com/keys", + ) + auth = json.loads((res.artifact / "server" / SERVER_CONFIG_NAME).read_text())["auth"] + assert auth["audience"] == "https://conn.example.com/custom-aud" + assert auth["jwks_uri"] == "https://idp.example.com/keys" + + +def test_no_dockerfile_when_disabled(): + res = publish_remote(["os.path:basename"], name="x", dry_run=True, include_dockerfile=False) + assert "Dockerfile" not in res.files + assert "server/app.py" in res.files + + +def test_pure_draft_rejected(): + spec = IntegrationSpec(name="d", tool_specs=[ToolSpec(name="t")]) # no handler + with pytest.raises(ValueError, match="design draft"): + publish_remote(spec) + + +def test_empty_rejected(): + with pytest.raises(ValueError): + publish_remote(IntegrationSpec(name="empty")) + + +def test_unsafe_name_rejected(tmp_path): + with pytest.raises(ValueError): + publish_remote(["os.path:basename"], name="../evil", dest=str(tmp_path)) + + +def test_bound_draft_scaffolds(tmp_path): + # a draft whose tools are bound to real refs is a valid remote source + spec = IntegrationSpec(name="wx", tool_specs=[ToolSpec(name="bn", handler="os.path:basename")]) + res = publish_remote( + spec, dest=str(tmp_path), connector_url="https://c.example.com", idp_issuer="https://i.example.com" + ) + cfg = json.loads((res.artifact / "server" / SERVER_CONFIG_NAME).read_text()) + assert cfg["refs"] == ["os.path:basename"] + + +def test_scaffolded_app_builds_real_authed_asgi_app(tmp_path): + """End-to-end (dev only): the generated server/app.py builds a real ASGI app. + + Skipped in bare CI (py2mcp/fastmcp are the [mcpb] extra, not installed there); + proves the scaffold is runnable, not just well-shaped. + """ + pytest.importorskip("py2mcp") + pytest.importorskip("fastmcp") + import importlib.util + + res = publish_remote( + ["os.path:basename"], + name="paths", + dest=str(tmp_path), + connector_url="https://conn.example.com", + idp_issuer="https://idp.example.com", + required_scopes=["mcp:read"], + ) + app_path = res.artifact / "server" / "app.py" + spec = importlib.util.spec_from_file_location("scaffolded_connector_app", app_path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) # reads connector_config.json next to app.py + assert callable(mod.app) # a Starlette ASGI app, with OAuth attached, built offline + assert hasattr(mod.app, "routes") From 75a3b743a49c3275604c30b1e3d3a944e6f8743c Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:06:17 +0200 Subject: [PATCH 2/2] harden: review fixes for the remote-connector target (8 findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial security review (8 confirmed / 8 refuted); all confirmed fixed: - [high] CLI --connector-url/--idp-issuer were forwarded unconditionally and crashed the default claude-local-mcpb target with a raw TypeError. They now raise a clear error unless --target claude-remote-connector. - [high] upstream py2mcp fix (separate PR): JWTVerifier skips audience validation when audience is None; mk_auth_provider now REQUIRES audience (RFC 8707, the confused-deputy defense — fail-closed). coact always supplies one, but the pin is bumped accordingly. - [low] a tools-less spec (resources/prompts only) now gets its own clear message instead of a misleading "0 proposed tool(s)" design-draft message. - [low] scaffold pins py2mcp>=0.1.5 (the release that actually ships py2mcp.http; 0.1.4 predated it) in all three places. - tests added: CLI flag routing (reject-on-wrong-target + forward-on-remote), the py2mcp-absent warning branch, placeholder audience-binding content, and preview truncation. Full suite: 410 passed, 4 skipped; ruff clean. --- coact/__main__.py | 14 +++++--- coact/publish_remote.py | 28 +++++++++++----- misc/docs/DECISIONS.md | 12 +++++++ tests/test_publish_remote.py | 65 +++++++++++++++++++++++++++++++++++- 4 files changed, 106 insertions(+), 13 deletions(-) diff --git a/coact/__main__.py b/coact/__main__.py index b760ec4..d907610 100644 --- a/coact/__main__.py +++ b/coact/__main__.py @@ -159,10 +159,16 @@ def publish( """ src = source if len(source) > 1 else source[0] extra: dict = {} - if connector_url is not None: - extra["connector_url"] = connector_url - if idp_issuer is not None: - extra["idp_issuer"] = idp_issuer + if connector_url is not None or idp_issuer is not None: + if target != "claude-remote-connector": + raise SystemExit( + "--connector-url/--idp-issuer apply only to " + f"--target claude-remote-connector, not {target!r}." + ) + if connector_url is not None: + extra["connector_url"] = connector_url + if idp_issuer is not None: + extra["idp_issuer"] = idp_issuer res = _publish( src, target=target, dest=dest, name=name, author=author, dry_run=dry_run, **extra ) diff --git a/coact/publish_remote.py b/coact/publish_remote.py index 57bc96f..a1ab0f1 100644 --- a/coact/publish_remote.py +++ b/coact/publish_remote.py @@ -48,7 +48,7 @@ uvicorn server.app:app --host 127.0.0.1 --port 8000 -Requires `py2mcp` (>=0.1.4), `fastmcp`, and an ASGI server (uvicorn) importable. +Requires `py2mcp` (>=0.1.5), `fastmcp`, and an ASGI server (uvicorn) importable. """ import json import os @@ -72,7 +72,7 @@ SERVER_CONFIG_NAME = "connector_config.json" _SERVER_APP = _SERVER_APP.format(config_name=SERVER_CONFIG_NAME) -_REQUIREMENTS = "py2mcp>=0.1.4\nfastmcp>=3\nuvicorn[standard]>=0.27\n" +_REQUIREMENTS = "py2mcp>=0.1.5\nfastmcp>=3\nuvicorn[standard]>=0.27\n" _DOCKERFILE = '''\ FROM python:3.12-slim @@ -127,12 +127,24 @@ def publish_remote( runnable_refs = spec.runnable_refs() if not runnable_refs: - proposed = ", ".join(ts.name for ts in spec.tool_specs) or "(none)" + if spec.tool_specs: # proposed tools exist, none bound -> a design draft + proposed = ", ".join(ts.name for ts in spec.tool_specs) + raise ValueError( + f"This IntegrationSpec is a design draft: {len(spec.tool_specs)} " + f"proposed tool(s) [{proposed}], none bound to an importable " + "'module:function' handler. Bind handlers (or pass refs) before " + "scaffolding a runnable remote connector (see `coact describe`)." + ) + declared = [] + if spec.resources: + declared.append(f"{len(spec.resources)} resource(s)") + if spec.prompts: + declared.append(f"{len(spec.prompts)} prompt(s)") raise ValueError( - f"This IntegrationSpec is a design draft: {len(spec.tool_specs)} " - f"proposed tool(s) [{proposed}], none bound to an importable " - "'module:function' handler. Bind handlers (or pass refs) before " - "scaffolding a runnable remote connector (see `coact describe`)." + "nothing to scaffold a remote connector from: this IntegrationSpec " + "declares " + (", ".join(declared) or "no tools") + " but no tools. The " + "claude-remote-connector target serves tools only — add 'module:function' " + "tool refs (or bound ToolSpecs)." ) auth, auth_warnings = _build_auth( @@ -164,7 +176,7 @@ def publish_remote( if find_spec("py2mcp") is None: warnings.append( "py2mcp is not importable here; the deployed service needs `py2mcp` " - ">=0.1.4, `fastmcp`, and `uvicorn` installed where it runs." + ">=0.1.5, `fastmcp`, and `uvicorn` installed where it runs." ) dir_name = safe_filename(spec.name, suffix="-connector", kind="integration name") diff --git a/misc/docs/DECISIONS.md b/misc/docs/DECISIONS.md index b378c24..0b81530 100644 --- a/misc/docs/DECISIONS.md +++ b/misc/docs/DECISIONS.md @@ -542,3 +542,15 @@ is load-bearing (landscape §9.3). Decisions: via the scaffold's `requirements.txt`). Offline tests assert the emitted files; a dev-only test execs the generated `app.py` to prove it builds a real authed ASGI app. The py2mcp HTTP layer has its own upstream test + PR. + +- **Review hardening (adversarial security pass, 8 confirmed / 8 refuted).** Two + HIGH: (1) the CLI `--connector-url`/`--idp-issuer` forwarded unconditionally and + crashed the default `.mcpb` target with a raw `TypeError` — now they are rejected + with a clear error unless `--target claude-remote-connector`; (2) **upstream + security fix in py2mcp**: `JWTVerifier` *skips* audience validation when `audience` + is `None`, so `mk_auth_provider` now **requires** `audience` (RFC 8707 is + mandatory — fail-closed, not fail-open). Plus: the tools-less-spec guard message is + distinguished from the design-draft one (as in `.mcpb`); the scaffold's py2mcp pin + is `>=0.1.5` (the release that actually ships `py2mcp.http`); CLI routing, + `py2mcp`-absent warning, placeholder audience-binding, and preview truncation are + now tested. diff --git a/tests/test_publish_remote.py b/tests/test_publish_remote.py index 76fd937..d611221 100644 --- a/tests/test_publish_remote.py +++ b/tests/test_publish_remote.py @@ -15,7 +15,8 @@ publish_remote, publish_targets, ) -from coact.publish_remote import SERVER_CONFIG_NAME +from coact import __main__ as cli +from coact.publish_remote import SERVER_CONFIG_NAME, _PLACEHOLDER_HOST _MEMBERS = { "DEPLOY.md", @@ -95,6 +96,8 @@ def test_unconfigured_oauth_warns_and_uses_placeholders(tmp_path): assert any("OAuth is NOT fully configured" in w for w in res.warnings) auth = json.loads((res.artifact / "server" / SERVER_CONFIG_NAME).read_text())["auth"] assert "YOUR-IDP" in auth["issuer"] # placeholder emitted, not a silent unauthenticated config + assert auth["base_url"] == _PLACEHOLDER_HOST + assert auth["audience"] == _PLACEHOLDER_HOST + "/mcp" # still audience-bound (RFC 8707) assert auth["type"] == "jwt" @@ -130,6 +133,12 @@ def test_empty_rejected(): publish_remote(IntegrationSpec(name="empty")) +def test_resources_only_rejected_with_clear_message(): + # not a "0 proposed tool(s)" draft message — a tools-less spec gets its own + with pytest.raises(ValueError, match="serves tools only"): + publish_remote(IntegrationSpec(name="r", resources=["data1"])) + + def test_unsafe_name_rejected(tmp_path): with pytest.raises(ValueError): publish_remote(["os.path:basename"], name="../evil", dest=str(tmp_path)) @@ -145,6 +154,60 @@ def test_bound_draft_scaffolds(tmp_path): assert cfg["refs"] == ["os.path:basename"] +def test_py2mcp_missing_warns(tmp_path, monkeypatch): + """When py2mcp isn't importable, the scaffold warns (runtime dep, not a build dep).""" + import importlib + + pr = importlib.import_module("coact.publish_remote") + monkeypatch.setattr(pr, "find_spec", lambda n: None) + res = pr.publish_remote( + ["os.path:basename"], + name="x", + dest=str(tmp_path), + connector_url="https://c.example.com", + idp_issuer="https://i.example.com", + ) + assert any("py2mcp is not importable" in w for w in res.warnings) + + +def test_dry_run_previews_are_bounded_one_liners(tmp_path): + res = publish( + ["os.path:basename"], + target="claude-remote-connector", + name="paths", + dest=str(tmp_path), + dry_run=True, + ) + # long members are truncated to a bounded single-line preview + assert res.files["DEPLOY.md"].endswith("…") + assert len(res.files["DEPLOY.md"]) == 201 # 200 chars + the ellipsis + assert "\n" not in res.files["server/app.py"] + # short members pass through verbatim (no ellipsis) + assert not res.files["requirements.txt"].endswith("…") + + +# --- CLI flag routing (the --connector-url/--idp-issuer guard) --------------- + + +def test_cli_connector_flags_rejected_for_mcpb_target(): + # the connector flags apply ONLY to the remote target — a clear error, not a TypeError + with pytest.raises(SystemExit, match="claude-remote-connector"): + cli.publish(["os.path:basename"], connector_url="https://x.example.com") + + +def test_cli_remote_target_forwards_flags(tmp_path): + out = cli.publish( + ["os.path:basename"], + target="claude-remote-connector", + name="paths", + dest=str(tmp_path), + dry_run=True, + connector_url="https://c.example.com", + idp_issuer="https://i.example.com", + ) + assert "Would publish" in out and "claude-remote-connector" in out + + def test_scaffolded_app_builds_real_authed_asgi_app(tmp_path): """End-to-end (dev only): the generated server/app.py builds a real ASGI app.