Skip to content

Latest commit

 

History

History
436 lines (307 loc) · 28.8 KB

File metadata and controls

436 lines (307 loc) · 28.8 KB

SPEC — mcp-brokered-credentials-python

MCP proxy to Linear with Keycard-brokered credentials — no static API keys, just OAuth token exchange at call time.

An MCP proxy server that sits between Claude and Linear's official MCP server at https://mcp.linear.app/mcp. It exposes two tools — search (regex-search Linear's tool catalog) and execute (call any Linear tool by name). When Claude calls either tool, the proxy exchanges the user's Keycard bearer token for a Linear-scoped OAuth token via RFC 8693, then forwards the call upstream. Credentials are minted per-request — no API keys are stored anywhere.

Pick this template to see Keycard's brokered-credential flow in action, or as a pattern for proxying any OAuth-protected MCP server (swap Linear's URL and the same plumbing works for any upstream supporting OAuth 2.1 + dynamic client registration).

The agent (e.g. the keycard-template-app skill) MUST perform every step in this document before declaring the project ready. The keycard run -- uvicorn main:app workflow at the end depends on every primitive being in place.

Throughout, treat <name> as the kebab-case project name (default: mcp-brokered-credentials-python) and <port> as the local HTTP port (default: 8000).

0. How the agent uses this document

This SPEC is input for the agent, not output for the user. The skill is the sole renderer of user-facing narration. Read this document, extract the structured data below (primitives, edits, port, build command, smoke tests, handoff commands), and reason from it — but never quote or print any of it verbatim. If a sentence here is jargon-heavy, terse, or implementation-focused, the skill paraphrases it before showing anything to the user.

0c. Concepts the agent should introduce

Introduce each concept once, the first time it appears during narration. Use the one-sentence framing below and include the docs link so the user can read more in a side tab.

Order Concept First appears at One-sentence framing Docs
1 Zone Step 3 (resolve context) Your private Keycard environment that holds users, apps, and policies and issues credentials. https://docs.keycard.ai/platform/concepts/zones/
2 Organization Step 3 (resolve context) The account that owns one or more zones — usually your company or team. https://docs.keycard.ai/platform/concepts/zones/
3 OAuth provider §1a (Linear provider) A credential provider that can mint or broker OAuth tokens for a third-party service — here it handles the OAuth 2.1 + dynamic client registration flow that Linear's MCP server expects. https://docs.keycard.ai/platform/concepts/providers/
4 Application §1b (Linear app) A software actor registered with Keycard — the proxy and Linear each get one, so Keycard knows who is asking for credentials and who is granting them. https://docs.keycard.ai/platform/concepts/applications/
5 Resource §1c (Linear resource) The protected API your application calls — the resource identifier must match the URL exactly so Keycard's STS knows which tokens to issue. https://docs.keycard.ai/platform/concepts/resources/
6 Dependency §1f-bis (wiring) A declared relationship that controls which resources an application can access — a simpler way to enforce access policy without writing policies directly. https://docs.keycard.ai/platform/concepts/applications/#dependencies
7 STS provider §1d (proxy provider) The credential source built into every zone that mints OAuth access tokens for resources within the zone. https://docs.keycard.ai/platform/concepts/providers/
8 Vault provider §1g (credentials) A provider that stores static secrets (like the proxy's client_id/client_secret) and delivers them at runtime via keycard run — no secrets in files. https://docs.keycard.ai/platform/concepts/providers/
9 Application credentials §1g (credentials) The credential the proxy uses to authenticate its own token-exchange calls to Keycard's STS. https://docs.keycard.ai/platform/concepts/applications/
10 Keycard issuer §2 (KEYCARD_URL) The URL apps use to discover Keycard's signing keys and token endpoints; for your zone it's https://<zone-id>.<env-domain>. https://docs.keycard.ai/platform/architecture/standards-and-protocols/

1. Required Keycard primitives

The agent MUST ensure every primitive below exists in the active Keycard environment. Most calls are idempotent; on 409 ("already exists"), GET the list endpoint and reuse the existing record's ID.

Background reading on the domain model the steps below build:

1a. Linear credential provider

See Providers for the concept; specifically the access credential provider role — these issue access tokens for resources hosted by third parties so credentials can be brokered between domains.

The Linear MCP server is the upstream resource. It needs a Keycard provider that can mint OAuth tokens accepted by https://mcp.linear.app/mcp. Linear MCP supports OAuth 2.1 with dynamic client registration, so Keycard's generic OAuth provider is appropriate.

keycard agent api -X POST /zones/<zone-id>/providers --org <org-id> -d '{
  "name": "linear",
  "identifier": "https://mcp.linear.app",
  "type": "oauth",
  "description": "Linear MCP OAuth provider"
}'

If the active Keycard build does not accept those fields verbatim, abort and ask the user to create the provider manually:

Could not auto-register the Linear provider. Create it at
https://console.keycard.ai → Providers with identifier
"https://mcp.linear.app" pointing at the Linear MCP authorization
server, then retry.

Carry the result forward as <linear-provider-id>.

1b. Linear application

See Applications. The Linear application represents the upstream Linear MCP service as a Keycard application — it's what the dependency (§1f-bis) points at, giving the proxy permission to access Linear's resource.

keycard agent api -X POST /zones/<zone-id>/applications --org <org-id> -d '{
  "name": "linear",
  "identifier": "https://mcp.linear.app/mcp",
  "description": "Linear MCP application",
  "consent": "required"
}'

The consent enum is required | implicit. "explicit" is rejected with a validation error.

Carry the result forward as <linear-application-id>.

1c. Linear resource

See Resources. A resource is a protected API/MCP server/database that an application accesses; the proxy will request brokered tokens against this resource's identifier.

The resource identifier MUST be https://mcp.linear.app/mcp exactly — that is the URL the proxy hits, and Keycard's STS rejects token requests whose resource does not match the registered identifier.

keycard agent api -X POST /zones/<zone-id>/resources --org <org-id> -d '{
  "name": "linear",
  "identifier": "https://mcp.linear.app/mcp",
  "description": "Linear MCP server (upstream)",
  "application_id": "<linear-application-id>",
  "credential_provider_id": "<linear-provider-id>"
}'

Carry the result forward as <linear-resource-id>.

1d. STS provider for the proxy

See Zones → Security Token Service. Every zone has a built-in STS that mints access tokens for resources within the zone; the proxy resource (§1f) uses it as its credential provider.

Discover the keycard-sts provider — same step as the base template:

keycard agent api /zones/<zone-id>/providers --org <org-id>

Pick the entry with type = "keycard-sts" (see gotcha 1 if unsure about provider type selection).

Carry the result forward as <sts-provider-id>.

1e. Proxy application

See Applications. This is the application that THIS server identifies as when it talks to Keycard's STS. Its client_id / client_secret are the credentials the proxy uses to authenticate the token-exchange call.

keycard agent api -X POST /zones/<zone-id>/applications --org <org-id> -d '{
  "name": "<name>",
  "identifier": "<name>",
  "description": "MCP proxy that brokers Linear credentials",
  "consent": "implicit"
}'

Carry the result forward as <proxy-application-id>.

1f. Proxy resource

See Resources. The proxy registers itself as a resource at http://localhost:<port>/mcp so Keycard's STS will mint mcp-scoped tokens that authenticate users to the proxy.

keycard agent api -X POST /zones/<zone-id>/resources --org <org-id> -d '{
  "name": "<name>",
  "identifier": "http://localhost:<port>/mcp",
  "description": "MCP proxy scaffolded by keycard-template-app",
  "scopes": ["mcp:tools"],
  "application_id": "<proxy-application-id>",
  "credential_provider_id": "<sts-provider-id>"
}'

Carry the result forward as <proxy-resource-id>.

1f-bis. Wire Linear as a dependency of the proxy application

See Applications → Dependencies. Dependencies control which resources an application can access — a way to enforce access policy without writing policies directly.

Dependencies live on the application, not the resource. Use PUT /applications/<app-id>/dependencies/<resource-id> with an empty JSON body (see gotcha 2 for why other routes silently fail).

keycard agent api -X PUT \
  "/zones/<zone-id>/applications/<proxy-application-id>/dependencies/<linear-resource-id>" \
  --org <org-id> -d '{}'

Verify the wiring landed:

keycard agent api \
  "/zones/<zone-id>/applications/<proxy-application-id>/dependencies" \
  --org <org-id>

The Linear resource MUST appear in the returned items. If it does not, abort:

Could not wire Linear as a dependency of the proxy application. Open
https://console.keycard.ai → Applications → <name> → Dependencies and
connect the Linear resource manually, then retry.

1g. Proxy application credentials + vault resources

This step issues application credentials for the proxy and copies them into zone-vault resources in a single local operation. The bundled script scripts/provision-credentials.sh keeps the credential material (client_id, client_secret) inside one shell process — the agent never reads or echoes those values, only the non-sensitive URNs that resolve them at runtime.

Before running the script, confirm a keycard-vault provider exists in the zone (type = "keycard-vault"). If there are several, ask the user which to use. Carry it forward as <vault-provider-id>. If the zone has no vault provider, abort:

This template requires a keycard-vault provider in the zone to broker
the proxy application credentials. Create one in
https://console.keycard.ai → Providers (type "Vault"), then retry.

Then invoke the script:

bash scripts/provision-credentials.sh \
  --zone "<zone-id>" \
  --org "<org-id>" \
  --app-id "<proxy-application-id>" \
  --vault-provider "<vault-provider-id>" \
  --name "<name>"

The script:

  1. Issues credentials via POST /zones/<zone-id>/application-credentials with body {"application_id":"<proxy-application-id>","type":"password"}. The password credential type is what mints an OAuth client_id (identifier) + client_secret (password) pair. This route is on the zone, NOT nested under /applications/<id>/.
  2. Validates the response contains identifier and password (without printing them).
  3. Pipes each value through jq directly into a POST /zones/<zone-id>/resources call to create vault resources at urn:<name>:client_id (from identifier) and urn:<name>:client_secret (from password).
  4. Wipes the in-memory credential variable and exits.

On failure, the script prints a remediation message pointing at the Keycard console. The agent MUST NOT attempt to recover by running the underlying POST .../credentials call itself — that would expose the credential payload in the tool-call transcript. The correct fallback is:

  1. Tell the user to issue credentials in https://console.keycard.ai → Applications → <name> → Credentials.

  2. Wait for them to confirm. The agent must NOT ask the user to paste the client_id / client_secret into chat.

  3. Have the user export them in their own shell and run a tiny inline pipe so the secrets never enter the agent transcript:

    export PROXY_CLIENT_ID='...'      # user pastes in their terminal
    export PROXY_CLIENT_SECRET='...'  # user pastes in their terminal
    
    jq -n --arg n "<name>" --arg pid "<vault-provider-id>" --arg s "$PROXY_CLIENT_ID" '{
      name: ($n + "-client-id"),
      identifier: ("urn:" + $n + ":client_id"),
      description: ("Brokered client_id for the " + $n + " proxy application"),
      credential_provider_id: $pid,
      secret: $s
    }' | keycard agent api -X POST "/zones/<zone-id>/resources" --org "<org-id>" -d @-
    
    jq -n --arg n "<name>" --arg pid "<vault-provider-id>" --arg s "$PROXY_CLIENT_SECRET" '{
      name: ($n + "-client-secret"),
      identifier: ("urn:" + $n + ":client_secret"),
      description: ("Brokered client_secret for the " + $n + " proxy application"),
      credential_provider_id: $pid,
      secret: $s
    }' | keycard agent api -X POST "/zones/<zone-id>/resources" --org "<org-id>" -d @-
    
    unset PROXY_CLIENT_ID PROXY_CLIENT_SECRET
  4. Resume at §3.

If the vault resources already exist, the script exits non-zero. To rotate, delete the existing vault resources first and re-run.

2. Configuration the agent MUST write

KEYCARD_URL

Resolve KEYCARD_URL from the Keycard ID + KEYCARD_ENV domain mapping:

KEYCARD_ENV Domain Scheme
production (default if unset) keycard.cloud https
staging keycard-stage.cloud https
dev keycard-dev.cloud https
localdev localdev.keycard.sh http

Example: ID <id> in production → https://<id>.keycard.cloud.

.env

Write .env in the project root from .env.example:

KEYCARD_URL=https://<id>.keycard.cloud
PORT=<port>

KEYCARD_URL is read by the MCP server itself. main.py loads .env via python-dotenv (load_dotenv(find_dotenv(usecwd=True))), so it is picked up automatically when the server starts.

.env MUST NOT contain KEYCARD_CLIENT_ID or KEYCARD_CLIENT_SECRET — they are brokered at runtime by keycard run (see gotcha 5).

keycard.toml

The template ships a minimal keycard.toml at the project root with only [org] and [zone] placeholder blocks:

[org]
id = "<org-id>"

[zone]
id = "<zone-id>"

The agent MUST replace both placeholder IDs with the values from the user's existing zone-bound keycard.toml (the one already present in the parent / cwd before scaffolding). If no such file exists, abort and tell the user to run keycard init first — without it, keycard run cannot authenticate.

Then the agent MUST append two [[credentials.default]] entries that match the vault resources created in step 1g:

[[credentials.default]]
env_var = "KEYCARD_CLIENT_ID"
resource = "urn:<name>:client_id"

[[credentials.default]]
env_var = "KEYCARD_CLIENT_SECRET"
resource = "urn:<name>:client_secret"

Substitute <name> for the kebab-case project name so the URNs line up exactly with what provision-credentials.sh created.

Use the keycard-upsert-config skill for these edits. Nothing else belongs in keycard.toml — the MCP server reads runtime config (KEYCARD_URL, PORT) from .env. See gotchas 4 and 5 for common mistakes with this file.

3. Runtime credential discovery

At startup, AuthProvider in keycardai.mcp.server.auth calls _discover_application_credential() which probes the environment and returns the first matching provider:

Priority Env signal Provider Typical environment
1 KEYCARD_CLIENT_ID + KEYCARD_CLIENT_SECRET both set ClientSecret Local via keycard run
2 KEYCARD_APPLICATION_CREDENTIAL_TYPE=eks_workload_identity EKSWorkloadIdentity Explicit override
3 KEYCARD_APPLICATION_CREDENTIAL_TYPE=web_identity WebIdentity Explicit override
4 Any of KEYCARD_EKS_WORKLOAD_IDENTITY_TOKEN_FILE, AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE, AWS_WEB_IDENTITY_TOKEN_FILE EKSWorkloadIdentity EKS pods with mounted identity tokens
5 (none matched) None (basic exchange, no client auth)

To force a specific provider, set KEYCARD_APPLICATION_CREDENTIAL_TYPE to eks_workload_identity or web_identity.

4. Agent verification

There is no build step for Python — uv sync installs dependencies but no compilation is required. Verify the vault resources exist and are wired to a keycard-vault provider. Do NOT use keycard run here — see gotcha 3 in §10.

uv sync

Then a non-interactive smoke test that the two vault resources exist:

for urn in "urn:<name>:client_id" "urn:<name>:client_secret"; do
  keycard agent api "/zones/<zone-id>/resources?identifier=${urn}" --org "<org-id>" \
    | jq -e --arg u "$urn" '.items[] | select(.identifier == $u) | .credential_provider_id' >/dev/null \
    || { echo "MISSING: $urn"; exit 1; }
done
echo "OK: both vault resources resolve"

Both lookups MUST succeed and return a non-null credential_provider_id. If either fails, the most likely causes are:

  • the matching vault resource does not exist, has the wrong identifier, or provision-credentials.sh failed before attaching the secret
  • a [[credentials.default]] entry pointing at the wrong URN — re-check the <name> substitution in the appended blocks
  • keycard.toml has unresolved <org-id> / <zone-id> placeholders, or is missing [org] / [zone] blocks entirely — keycard run will fail to authenticate when the user runs the proxy

Do NOT start the proxy server inside the agent session (see gotcha 3). The user starts it themselves in §6.

5. Agent guardrails

Quick-reference prohibitions. §10 has diagnostics for the ones that commonly go wrong at runtime.

  • Do not modify the user's pre-existing keycard.toml outside the project directory. The project-local one is bootstrapped from it but lives independently.
  • Do not run the underlying keycard agent api .../credentials call yourself — always invoke scripts/provision-credentials.sh. The credential payload must not appear in any tool-call transcript.
  • Do not commit .env, KEYCARD_URL, or anything containing the Keycard ID.
  • Do not add [project], [server], schema_version, or any [zone].url field to keycard.toml. The shipped file is intentionally minimal — only [org].id, [zone].id, and the two [[credentials.default]] blocks belong there.
  • Do not write Cedar or per-tool policy — v1 auth is OAuth scope-based (mcp:tools); per-tool authorization is out of scope.
  • Do not print SPEC phrasing such as "transitive consent", "RFC 8693", "client_assertion", "private_key_jwt", "STS provider", or "bearer token scoped to mcp:tools" in any user-facing message — translate to outcomes per §0c.

6. Handoff data (for the skill to render)

After provisioning and verification, register the proxy in Claude's local MCP config so the user can call it after restarting Claude:

claude mcp remove <name> 2>/dev/null || true
claude mcp add --transport http <name> http://localhost:<port>/mcp

If claude is not on PATH, tell the user to add this to ~/.claude.json:

{
  "mcpServers": {
    "<name>": {
      "url": "http://localhost:<port>/mcp"
    }
  }
}

The skill renders the handoff from the following data:

Key Value Run-where Reason (plain outcomes)
name <name> Kebab-case project name used everywhere
port <port> Local HTTP port the proxy listens on
install_command uv sync this session Installs dependencies
start_command cd <name> && keycard run -- uvicorn main:app --host 0.0.0.0 --port <port> their terminal (separate, keep open) The wrapper hands the proxy a short-lived credential it needs to start; the proxy refuses to boot without it
expected_ready_log uvicorn startup output on port <port> Signal to wait for before proceeding
claude_register_command claude mcp add --transport http <name> http://localhost:<port>/mcp this session (agent may execute) Adds the proxy to Claude's MCP config
restart_command keycard run -- claude their terminal Claude only discovers MCP servers at startup, so a restart is required
auth_command /mcp → pick <name> new agent session Completes the OAuth flow; the first time, the user will see a Linear consent screen because this proxy depends on Linear
try_it search with a regex like issue; execute with a tool name from the results new agent session Confirms the proxy can call Linear on the user's behalf using a brokered credential

Carried IDs for the recap

After §1 and §4, the agent has these values in hand: <zone-id>, <linear-provider-id>, <linear-application-id>, <linear-resource-id>, <sts-provider-id>, <proxy-application-id>, <proxy-resource-id>, <vault-provider-id>, and the verified vault URNs (urn:<name>:client_id, urn:<name>:client_secret). The skill renders them in its own voice — the SPEC does not supply a recap template. Point the user at https://console.keycard.ai for looking up any of the registered IDs.

7. What to say to the user (per-step narration)

The keycard-template-app skill prescribes the narration style; the lines below supply the template-specific content. Paraphrase into the skill's voice — never read verbatim, and follow the jargon prohibition in §5. Use §0c for first-mention framing; after that, use plain outcome language.

Step Tell the user before you start
Step 4 (copy) "Copying the mcp-brokered-credentials-python blueprint into ./<name> — this gives you an MCP proxy that talks to Linear's MCP server using Keycard-brokered credentials. No Linear API key needed."
Step 6 (fill in) "Filling in .env with your zone's OIDC issuer URL and a free local port. I'll also write keycard.toml with your zone/org IDs and two credential entries — those tell keycard run to broker the proxy's client_id and client_secret from the zone vault at startup."
Step 7 (register) "This template needs more Keycard primitives than the basic server. I'm about to register: (1) an OAuth provider for Linear so Keycard can broker tokens to mcp.linear.app, (2) a Linear Application + Resource so Keycard knows what it's brokering, (3) your proxy's own Application + Resource backed by the zone's STS, (4) a dependency from the proxy to Linear — dependencies control which resources an application can access, so you don't need to write policies yourself, and (5) application credentials stored in the zone vault. Each of these builds on the one before — I'll name what I get back after each step."
Step 8 (smoke-test) "Installing dependencies with uv sync and verifying the two vault resources (urn:<name>:client_id and urn:<name>:client_secret) resolve correctly in the API. I'm not starting the server here — it needs keycard run to broker the secrets into the process, which can't nest inside the agent session. The actual end-to-end test happens when you run it yourself in the next step."
Step 9 (handoff) "All the Keycard-side setup is done — your zone knows about both the proxy and Linear, and the vault has the proxy's credentials. The last bit has to happen in your terminal: start the proxy with keycard run -- uvicorn main:app --host 0.0.0.0 --port <port>, restart Claude, and authenticate via /mcp."

8. Things to try once it's running

The agent SHOULD print these as concrete suggestions after the handoff recap, so the user knows what to do first:

  1. Search Linear's tool catalog. Ask Claude: "Use search with the pattern issue." — lists matching Linear MCP tools with their input schemas.
  2. Execute a Linear tool. Pick a tool from the search results (e.g. list_issues) and ask Claude: "Use execute to call list_issues with {}."
  3. Watch the consent flow. The first /mcp authentication surfaces both the proxy and Linear consent (because of the dependency wiring in §1f-bis). Subsequent sessions skip consent.
  4. Inspect the brokering. Check the proxy's terminal output — each search/execute call triggers a visible token-exchange request. The brokered Linear token is never logged (by design).

9. Where to extend

  • Swap the upstream. The Linear MCP URL is defined in upstream.py as LINEAR_MCP_URL. To proxy a different OAuth-MCP server (e.g. GitHub, Slack), change this constant and register the new upstream's provider/application/resource in Keycard following the same pattern as §1a–§1c.
  • Change what gets proxied. tools/search.py filters the upstream tool catalog by regex; tools/execute.py forwards a single tool call. You can add tools that call multiple upstream tools, aggregate results, or transform the response before returning it.
  • Understand the token exchange. AuthProvider._discover_application_credential() handles credential discovery (see §3 for the priority table). The @auth_provider.grant(LINEAR_MCP_URL) decorator on each tool handles the per-call token exchange — this is the core brokering pattern you'd reuse for any upstream.
  • Add a direct tool (no upstream). You can also add tools that don't fan out — just create a file in tools/ following the hello pattern from the base template. The proxy and direct tools coexist fine.

10. Common gotchas the agent should pre-empt

When one of these fails, surface the matching diagnostic verbatim rather than printing a generic error. Any framing around diagnostics is the skill's voice, never quoted from this section.

  1. Vault provider vs. STS provider mix-up. The proxy resource (§1f) MUST use the keycard-sts provider — it mints OAuth tokens for the /mcp endpoint. The vault resources in §1g MUST use a keycard-vault provider — they store the proxy's client_id/client_secret. Swapping them causes invalid_target on one side and "no secret material" on the other. Fix: check each resource's credential_provider_id in https://console.keycard.ai → Resources.

  2. Dependencies silently dropped on resource POST. The dependencies field is only accepted via PUT /applications/<app-id>/dependencies/<resource-id> with an empty body. Passing dependencies: [...] on a resource or application POST/PATCH is silently ignored — the API returns 200 but the dependency is not created. Always use the dedicated PUT route (§1f-bis) and verify with the GET endpoint afterward.

  3. keycard run refusing to nest. keycard run requires an interactive TTY and will not nest inside the agent's own keycard run session. Do NOT try to smoke-test with keycard run -- uvicorn main:app from within the agent — use the API-based vault-resource check (§4) instead. The actual keycard run test happens when the user starts the proxy themselves.

  4. ${...} interpolation in keycard.toml. TOML does not expand environment variables. Never write ${KEYCARD_URL} or any ${...} syntax into keycard.toml — it will be treated as a literal string. Runtime config (KEYCARD_URL, PORT) goes in .env; keycard.toml only holds static IDs and credential-mapping entries.

  5. KEYCARD_CLIENT_ID or KEYCARD_CLIENT_SECRET written to .env or source code. These secrets MUST only enter the process via keycard run brokering from the vault. Putting them in .env defeats the entire point of the template (demonstrating zero-static-secret credential delivery). If the proxy fails to start with "Could not discover application credentials", the user likely forgot keycard run — they ran uvicorn main:app directly instead of keycard run -- uvicorn main:app.

  6. Linear consent screen not appearing. If the user authenticates via /mcp but never sees a Linear consent prompt, the dependency wiring (§1f-bis) is missing — without it the proxy cannot access the Linear resource at all. Verify with GET /zones/<zone-id>/applications/<proxy-application-id>/dependencies — the Linear resource must appear in items. If not, re-run the PUT.

  7. invalid_target or Requested authorization for unknown resource ... on the proxy's own /mcp. Same root cause as the base template — the Resource identifier must be http://localhost:<port>/mcp with the correct port and the /mcp suffix. Check the registered Resource in https://console.keycard.ai → Resources.