Chapter 4: Read from a real datastore
So far the tickets have come from a JSON file in a /data directory next to the server. That was fine for getting started, but it dodges a question every real deployment has to answer: when your tools read from an actual database, where does the database credential live?
The obvious answer is .env, alongside the Linear API key. This results in too much power and one static secret on many machines. Now the tickets move to Supabase, and the key that reads them lives in your Keycard vault, where the server borrows it for one call at a time.
This chapter, you’ll apply the identity you established in Chapter 3. By the end, your audit log won’t just show that your agent signed in. It’ll show every read tool call, on your behalf.
Vault token exchange
Section titled “Vault token exchange”You’ll make three related code changes to implement vault token exchange for the database resource:
src/keycard.ts(new) is the MCP server’s connection to Keycard: it exchanges the caller’s bearer token for a credential valid for one named resource. This is OAuth 2.0 Token Exchange (RFC 8693). The Keycard SDK handles the protocol for you.src/tickets.tskeeps its schema and existing functions, but the source now queries a Keycard-provided Supabase database with a secret API key obtained through token exchange. The key is never cached locally or stored on disk, and is obtained fresh on every call.- The two tools that read tickets pass the caller’s authorized identity to the datastore. The feature is
loadTickets(auth)instead ofloadTickets().
Note that src/linear.ts and the shared API key are untouched. This workshop moves one variable per chapter. The Linear key’s reckoning comes in Chapter 6, and when it does, it’ll use the exchangeForCredential function you’re about to write.
Why exchange per request
Section titled “Why exchange per request”The Supabase vault resource you configured in Keycard in Chapter 1 holds the (instructor-provided) Supabase secret key. Your server could, in theory, fetch it once at startup and keep it in memory. We’re deliberately not doing that, for the obvious reason of security, and the slightly less obvious reason of auditability.
A token exchange takes two inputs:
- who is requesting access: your MCP server client, authenticated with its client credentials, and
- on whose behalf: the caller’s Keycard-minted bearer token, issued when you signed in last chapter.
Keycard checks both, logs the event, and only then releases the credential. Do that once at startup and the audit log shows one authentication event at boot, as you saw last chapter. Now do a token exchange per request, and every tool call becomes a credentials:issue event attributed to the human user. This is the same key that often lives in an .env file, but now when something goes wrong, you can see which entity did what, and when.
Add the Supabase dependency
Section titled “Add the Supabase dependency”From inside your working server directory (the same one you’ve been evolving since Chapter 0):
npm install @supabase/supabase-js@2.108.2Add the Supabase URL to .env
Section titled “Add the Supabase URL to .env”Your MCP client application’s credentials are already in .env from Chapter 1. Now you’ll use them. They authenticate your application to Keycard’s token endpoint, so Keycard knows which registered app is requesting an exchange. Chapter 2’s bearer auth didn’t need them because verifying incoming tokens only requires Keycard’s public keys. Exchanging tokens takes more than that.
The Supabase project URL is not a secret, so you can safely add it to .env:
# The support database containing the tickets. Also the# vault resource identifier you registered in Chapter 1; must match exactly.SUPABASE_URL=https://tsfavofmgkbyuujubppe.supabase.coYou are not adding the database credential. SUPABASE_URL is just an address; it’s configuration rather than secret. The secret API key that can actually read the tickets never appears in this file, on disk, or in your code.
Implement token exchange for Supabase
Section titled “Implement token exchange for Supabase”Add per-request Keycard token exchange for the datastore. Create src/keycard.ts: read KEYCARD_URL, KEYCARD_CLIENT_ID, and KEYCARD_CLIENT_SECRET from the environment (failing clearly if any is missing), construct a single module-level AuthProvider (imported from “@keycardai/mcp/server/auth/provider”) with zoneUrl set to KEYCARD_URL and applicationCredential set to a new ClientSecret (imported from “@keycardai/mcp/server/auth/credentials”) built from those client credentials, and export an async function exchangeForCredential(subjectToken, resource) that calls authProvider.exchangeTokens(subjectToken, resource) and returns the resulting AccessContext’s access(resource).accessToken. Never cache the returned credential.
Then rewrite the internals of src/tickets.ts to read from Supabase instead of data/tickets.json. Keep TicketSchema and Ticket exactly as they are. Read SUPABASE_URL from the environment. Make loadTickets and getTicket async, each gaining an AuthInfo parameter (import the type from “@modelcontextprotocol/sdk/server/auth/types.js”); getTicket keeps ticketId as its first parameter, with auth second. On every call, exchange the caller’s bearer token (auth.token) for the Supabase secret key using SUPABASE_URL as the resource, build a client with createClient(SUPABASE_URL, key) passing auth options persistSession: false and autoRefreshToken: false, and query the “tickets” table: order by created_at ascending for the list, .eq(“id”, ticketId).maybeSingle() for the single lookup, validating rows with TicketSchema.
Finally update the two tools that read tickets, src/tools/get-tickets.ts and src/tools/escalate-ticket.ts: their handlers receive (args, extra), so take extra.authInfo, fail clearly if it’s missing, and pass it to the now-async loadTickets/getTicket calls. Don’t change src/linear.ts or src/tools/delete-issue.ts.
Observe the changes your agent makes, approve when necessary, then restart the server with npm run dev.
What the agent builds
Section titled “What the agent builds”src/keycard.ts is short and to-the-point. (The full file also contains the same small requireEnv helper you’ve seen in every file; it’s omitted here.)
import { AuthProvider } from "@keycardai/mcp/server/auth/provider";import { ClientSecret } from "@keycardai/mcp/server/auth/credentials";
export const authProvider = new AuthProvider({ zoneUrl: requireEnv("KEYCARD_URL"), applicationCredential: new ClientSecret(requireEnv("KEYCARD_CLIENT_ID"), requireEnv("KEYCARD_CLIENT_SECRET")),});
export async function exchangeForCredential(subjectToken: string, resource: string): Promise<string> { const accessContext = await authProvider.exchangeTokens(subjectToken, resource); return accessContext.access(resource).accessToken;}The AuthProvider is constructed once with your app’s identity and Keycard. Each tool call is still a separate exchange: subjectToken is the caller’s bearer token, and resource identifies one registered resource, in this case Supabase. Keycard resolves the resource by exact string match, which is why SUPABASE_URL in .env has to match the identifier in Keycard character for character. exchangeTokens returns an AccessContext keyed by resource; access(resource) hands back that resource’s token, or throws if the exchange failed.
In src/tickets.ts, one small function is added:
async function supabaseForCaller(auth: AuthInfo) { const secretKey = await exchangeForCredential(auth.token, SUPABASE_URL); return createClient(SUPABASE_URL, secretKey, { auth: { persistSession: false, autoRefreshToken: false }, });}You create a fresh client on each request, built around a key that disappears when the call is done.
In the tool handlers, the missing link between Chapter 2 and Chapter 4 is extra.authInfo:
async ({ ticketId }, extra) => { const auth = extra.authInfo; // ... tickets = await loadTickets(auth);requireBearerAuth verified the caller’s token back in Chapter 2, and the Keycard MCP SDK has been quietly handing it to every tool handler as extra.authInfo ever since. Now you are finally using it. The bearer token your agent presents to call the tool is the subject token of the exchange. Now you have established the delegation chain with real tokens.
You can delete tickets.json from the data/ folder now because nothing reads it. Its support tickets are still seedable from data/seed.sql in the mcp-server/ reference folder, which is how the workshop’s Supabase project was seeded. If you’re doing this self-paced with your own Supabase project, run that seed.sql file in your Supabase SQL Editor before continuing.
Run the prompt
Section titled “Run the prompt”Restart the MCP server (npm run dev), then prompt it the same as before:
Using the support-escalation tools, list the open support tickets.
The same tickets come back, as expected. The tool’s directive and output didn’t change, but its source did. The tickets are now read from Supabase with a key your server held for milliseconds, on the authority of the token your agent got when you signed in.
See token exchange in the audit log
Section titled “See token exchange in the audit log”Chapter 3 ended with a promise: authentication was in the audit log, but per-call tool authorizations weren’t. Now they are. Make some tool calls. Afterwards, you’ll review the audit log.
List all open support tickets with their IDs.
Review what your agent tells you about where the tickets are now being read from. Then run another prompt:
Show me support ticket ID 3f9a2b1c-7d4e-4a8b-9c6f-1e2d3a4b5c6d.
-
Go to console.keycard.ai and click Audit Log.
-
You’ll see a
credentials:issueevent on your Supabase Database resource for each tool call you just made. Open one. The Actor Details show a two-identity chain again, but this time it’s your MCP application and you. In Chapter 3 the authentication chain was your local coding agent acting on your behalf (you can still review it in the audit log for a refresher). This time, it’s the Keycard Workshop MCP Client exchanging your token for the database key. The raw Request Information showsgrant_type: urn:ietf:params:oauth:grant-type:token-exchange, with the Supabase project URL as the credential provider.
Back in Chapter 0, everyone simultaneously reading tickets through this MCP server produced nothing: no log, no identity, and no way to ask “who read the ticket with the SSN in it?” Now the audit log tells the full story: the resource, the application, the agent, and the user.