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).
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.
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/ |
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:
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>.
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>.
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>.
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>.
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>.
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>.
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.
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:
- Issues credentials via
POST /zones/<zone-id>/application-credentialswith body{"application_id":"<proxy-application-id>","type":"password"}. Thepasswordcredential type is what mints an OAuthclient_id(identifier) +client_secret(password) pair. This route is on the zone, NOT nested under/applications/<id>/. - Validates the response contains
identifierandpassword(without printing them). - Pipes each value through
jqdirectly into aPOST /zones/<zone-id>/resourcescall to create vault resources aturn:<name>:client_id(fromidentifier) andurn:<name>:client_secret(frompassword). - 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:
-
Tell the user to issue credentials in https://console.keycard.ai → Applications →
<name>→ Credentials. -
Wait for them to confirm. The agent must NOT ask the user to paste the
client_id/client_secretinto chat. -
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
-
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.
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.
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).
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.
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.
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 syncThen 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.shfailed before attaching the secret - a
[[credentials.default]]entry pointing at the wrong URN — re-check the<name>substitution in the appended blocks keycard.tomlhas unresolved<org-id>/<zone-id>placeholders, or is missing[org]/[zone]blocks entirely —keycard runwill 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.
Quick-reference prohibitions. §10 has diagnostics for the ones that commonly go wrong at runtime.
- Do not modify the user's pre-existing
keycard.tomloutside the project directory. The project-local one is bootstrapped from it but lives independently. - Do not run the underlying
keycard agent api .../credentialscall yourself — always invokescripts/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].urlfield tokeycard.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.
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>/mcpIf 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 |
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.
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." |
The agent SHOULD print these as concrete suggestions after the handoff recap, so the user knows what to do first:
- Search Linear's tool catalog. Ask Claude: "Use
searchwith the patternissue." — lists matching Linear MCP tools with their input schemas. - Execute a Linear tool. Pick a tool from the search results (e.g.
list_issues) and ask Claude: "Useexecuteto calllist_issueswith{}." - Watch the consent flow. The first
/mcpauthentication surfaces both the proxy and Linear consent (because of the dependency wiring in §1f-bis). Subsequent sessions skip consent. - Inspect the brokering. Check the proxy's terminal output — each
search/executecall triggers a visible token-exchange request. The brokered Linear token is never logged (by design).
- Swap the upstream. The Linear MCP URL is defined in
upstream.pyasLINEAR_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.pyfilters the upstream tool catalog by regex;tools/execute.pyforwards 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 thehellopattern from the base template. The proxy and direct tools coexist fine.
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.
-
Vault provider vs. STS provider mix-up. The proxy resource (§1f) MUST use the
keycard-stsprovider — it mints OAuth tokens for the/mcpendpoint. The vault resources in §1g MUST use akeycard-vaultprovider — they store the proxy'sclient_id/client_secret. Swapping them causesinvalid_targeton one side and "no secret material" on the other. Fix: check each resource'scredential_provider_idinhttps://console.keycard.ai → Resources. -
Dependencies silently dropped on resource POST. The
dependenciesfield is only accepted viaPUT /applications/<app-id>/dependencies/<resource-id>with an empty body. Passingdependencies: [...]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. -
keycard runrefusing to nest.keycard runrequires an interactive TTY and will not nest inside the agent's ownkeycard runsession. Do NOT try to smoke-test withkeycard run -- uvicorn main:appfrom within the agent — use the API-based vault-resource check (§4) instead. The actualkeycard runtest happens when the user starts the proxy themselves. -
${...}interpolation inkeycard.toml. TOML does not expand environment variables. Never write${KEYCARD_URL}or any${...}syntax intokeycard.toml— it will be treated as a literal string. Runtime config (KEYCARD_URL,PORT) goes in.env;keycard.tomlonly holds static IDs and credential-mapping entries. -
KEYCARD_CLIENT_IDorKEYCARD_CLIENT_SECRETwritten to.envor source code. These secrets MUST only enter the process viakeycard runbrokering from the vault. Putting them in.envdefeats 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 forgotkeycard run— they ranuvicorn main:appdirectly instead ofkeycard run -- uvicorn main:app. -
Linear consent screen not appearing. If the user authenticates via
/mcpbut 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 withGET /zones/<zone-id>/applications/<proxy-application-id>/dependencies— the Linear resource must appear initems. If not, re-run the PUT. -
invalid_targetorRequested authorization for unknown resource ...on the proxy's own/mcp. Same root cause as the base template — the Resource identifier must behttp://localhost:<port>/mcpwith the correct port and the/mcpsuffix. Check the registered Resource inhttps://console.keycard.ai → Resources.