Skip to content

Chapter 6: Delegate access to Linear

The LINEAR_API_KEY is still in .env. The workshop has repeatedly mentioned why this is problematic. It has misleading attribution (every ticket is authored by the API key owner), it’s over-permissioned (full workspace access for a tool that creates one issue), and it’s shared (the same string on every laptop in the room). Chapters 4 (Supabase) and 5 (Anthropic) kept their credentials out of .env by storing them in the Keycard vault. Finally, this chapter deletes the Linear API key.

You’ll replace the API key with Linear OAuth. Access becomes a per-request token exchange.

You’ll also add scopes to your MCP server’s token exchange requests. Scopes are a subset of permissions.

You set OAuth scopes for Linear in Keycard in Chapter 1. In Linear, reading issues requires read. Creating an issue requires issues:create. Trashing one requires write. In this chapter, you’ll declare what Linear scopes your MCP server intends to use for each tool. The request is recorded per call in your audit log. In Chapter 7 you’ll use those scopes to enforce policy.

The API key collapsed two different questions into one static credential:

  1. Keycard delegation: on whose behalf? Every exchange holds the caller’s token as the subject. The audit log attributes each credential to your application, acting on your behalf. This has been implemented since Chapter 4.
  2. Linear attribution: who does Linear say did something? Your Linear OAuth token acts as the real Linear user who authorized it: you. So Linear attributes the issue to your actual account natively. You’ve fully replaced the synthetic name and shared key with the correct identity.

Because the issue is now authored by you and not a bot, the fact that an agent filed it on your behalf would be invisible on the issue itself. So the server appends a one-line footer to the issue body naming the Keycard application’s client ID, the entity that is acting on the human’s behalf. The human is the author, the agentic client is named in the footer, and Keycard’s audit log proves the delegation chain.

  • src/keycard.ts adds an optional scope parameter: exchangeForCredential(subjectToken, resource, scope?). Vault exchanges don’t pass scope because their scope is predefined on creation. OAuth access requests support scopes to limit permissions.
  • src/linear.ts changes requireEnv("LINEAR_API_KEY") to a token exchange for https://api.linear.app with only the scope that one task requires.
  • The tools thread auth through to the Linear functions, the same way the ticket reads already do. escalate_ticket also appends a footer that identifies the app that acted on behalf of the user.
  • .env loses the Linear API key.

Linear’s personal API keys are sent as a bare Authorization header. OAuth access tokens use the Bearer prefix.

In both .env and .env.example, delete LINEAR_API_KEY and its entire warning comment block.

Keep LINEAR_TEAM_ID, which is config, not a credential. You’ll replace the static, shared, full-access credential with token exchange brokered by Keycard.

Like the Supabase URL in Chapter 4, the Linear API URL is not a secret, so you can safely add it to .env:

.env
# The Linear API URL. Also the Linear resource identifier
# you registered in Chapter 1; must match exactly.
LINEAR_API_URL=https://api.linear.app

LINEAR_API_URL matches the Linear resource identifier you registered in Keycard in Chapter 1.

Prompt your coding agent

Replace the shared Linear API key with per-request Keycard token exchange, scoped per operation.

In src/keycard.ts, give exchangeForCredential a third optional parameter scope (a string). When it is set, pass it to authProvider.exchangeTokens as the requestScopes option ({ requestScopes: scope }); when it is absent, pass no options object at all, so a vault exchange names no scope. Update the function’s doc comment to document scope (vault exchanges don’t pass one; OAuth exchanges request exactly what the calling tool needs, named on the wire as requestScopes, and the request is recorded in the audit log and evaluated by Keycard policy) and drop its aside that OAuth resources come in a later chapter, since that chapter is now. Also export the app’s client id as a named const APP_CLIENT_ID, reading KEYCARD_CLIENT_ID with the existing requireEnv (the same client id already used to build the AuthProvider), so the escalate tool can stamp which app filed an issue. Make no other changes to the file.

In src/linear.ts: remove the LINEAR_API_KEY usage entirely. Add a module constant LINEAR_API_URL read once from the environment with requireEnv (it is the resource identifier registered in Keycard, exact string match — and not a secret, so it lives in .env like SUPABASE_URL and ANTHROPIC_API_URL) and derive the GraphQL URL from it by appending “/graphql”. Move LINEAR_TEAM_ID to a module-level constant read once with requireEnv too. Rename linearRequest’s first parameter to accessToken and send it as “Bearer ” + accessToken in the Authorization header (the old personal API key was sent bare; OAuth tokens take the Bearer prefix; update the comment). Give getLabelIds, createIssue, and trashIssue a final auth parameter (AuthInfo). At the top of getLabelIds, exchange auth.token for LINEAR_API_URL with scope “read” (the lookup is a read, so it requests the read scope — the narrowest Linear scope that authorizes the labels query; there is no labels-only scope). At the top of createIssue, exchange auth.token for LINEAR_API_URL with scope “issues:create”, then create the issue with teamId, title, description, priority, and labelIds in the issueCreate input — no createAsUser, because the token acts as the authorizing user and Linear attributes the issue to them natively. At the top of trashIssue, exchange with scope “write” (trashing edits an existing issue, which is write territory).

In src/tools/escalate-ticket.ts, pass the existing auth value to getLabelIds and createIssue, and append a footer to the issue description: after the support-ticket line, add a ”---” divider element and a line built as a template literal Submitted by Keycard app ${APP_CLIENT_ID} via Support Escalation MCP, importing APP_CLIENT_ID from ../keycard.js (the app’s KEYCARD_CLIENT_ID — the actor recorded on every credential exchange). If the registrar’s doc comment still claims Linear gets the shared key for one more chapter, remove that sentence (it may already be gone — leave the rest of the comment as is). In src/tools/delete-issue.ts, change the handler signature to (args, extra), guard extra.authInfo exactly the way escalate-ticket.ts does, and pass it to trashIssue. No other changes to the tools.

Don’t touch .env or .env.example (those edits are the manual step above). Don’t change src/pii.ts, src/tickets.ts, src/server.ts, or src/tools/get-tickets.ts.

Look at the trashIssue function in src/linear.ts:

src/linear.ts
export async function trashIssue(issueId: string, auth: AuthInfo): Promise<void> {
// Trashing is an edit to an existing issue, which is a write operation.
const accessToken = await exchangeForCredential(auth.token, LINEAR_API_URL, "write");
const data = await linearRequest<{ issueDelete: { success: boolean } }>(
accessToken,
// ...etc

Only one line changes at the top of the function. Compare the exchanges your server now makes: Supabase gets no scope (a vault returns the stored secret), Anthropic gets no scope (same), and Linear declares read, issues:create, or write depending on which tool is called.

Escalating a ticket makes two Linear token exchanges, each scoped differently. First getLabelIds uses the read scope to resolve Linear label names.

src/linear.ts
const accessToken = await exchangeForCredential(auth.token, LINEAR_API_URL, "read");
const data = await linearRequest<{
issueLabels: { nodes: { id: string }[] };
}>(
accessToken,
`query LabelIds($names: [String!]!) {
issueLabels(filter: { name: { in: $names } }) {
nodes { id }
}
}`,
{ names },
);

Then createIssue exchanges the caller’s token for a Linear token that will exercise the issues:create scope. Because that token acts as you, Linear records you as the author:

src/linear.ts
const accessToken = await exchangeForCredential(auth.token, LINEAR_API_URL, "issues:create");

Restart the MCP server (npm run dev) and re-run the Chapter 0 prompt one more time:

Prompt your coding agent

Using the support-escalation tools, list the open support tickets. Then escalate the critical payment ticket (the one about double charges) to engineering with escalate_ticket.

Open the issue. Your work from Chapter 5 still produces an engineering-friendly title, masked PII, and appropriate labels. Under Activity, you should now see that the issue was created by you under your authorized Linear account. At the bottom of the description, a footer identifies the application that filed the issue on your behalf.

Your token was the subject of every exchange that built this issue. In the shared Linear workspace, everyone now sees their own Linear identity on their issues, instead of the one shared API key that belongs to learn@keycard.ai.

Then clean up after yourself. This is also a test:

Prompt your coding agent

Delete the Linear issue you just created with delete_issue.

It succeeds. Nothing prevents the agent from using the write scope to delete or modify issues yet. In Chapter 7, you’ll write a policy that forbids over-permissive scopes.

Keycard console
  1. In console.keycard.ai, open your Audit Log and find the escalation you just ran.

  2. Count the credentials:issue events for the escalate_ticket tool call: Supabase Database (reading the ticket), Anthropic API (rewriting it), and Linear API twice (resolving label IDs, then creating the issue). All of these exchanges compose one escalation. Each is logged and attributed to your Keycard Workshop MCP Client application acting on your behalf.

  3. Open one of the Linear API events. Actor Details shows the familiar two-identity chain (your application + you), and the Request information now has something vault events don’t: scopes aren’t an empty array (read, issues:create, or write).

In Chapter 4, the audit log told you who touched what. The Linear events add what scope of access was requested. You’ll use those scopes in Chapter 7 to more granularly control what your agent is allowed to do.