Skip to main content

Vend a token

POST /api/tokens/vend
Thin wrapper around Better Auth’s getAccessToken(). This is the legacy entry point used by the stdio runtime; the HTTP MCP transport calls resolveToken() directly (see below).
ParameterInRequiredDescription
userIdbodyYesUser ID
providerbodyYesOAuth provider name (e.g. github, google)
Success response (200):
{
  "accessToken": "gho_...",
  "refreshed": false
}
Error responses:
StatusCodeDescription
400Missing userId or provider
404NOT_LINKEDNo connection found for this provider
500VEND_FAILEDToken retrieval failed

resolveToken() — three-source resolution

Inside the HTTP MCP transport (apps/server/src/routes/mcp-remote.ts), every plugin handler’s ctx.auth.getToken("<provider>") runs through resolveToken(userId, provider) which checks three sources in order:
  1. tool_connections row for the user with tool_id = <provider>. If present, decrypt and return the stored secret. This is how dashboard-managed API keys (granola personal/enterprise) and any future per-user tokens work.
  2. Better Auth getAccessToken({ providerId, userId }). Returns the OAuth access token (auto-refreshing if expired). Covers the social providers (github/google/slack/linear) and Generic OAuth providers (quickbooks/gcp/docusign/carta).
  3. process.env["${UPPER(provider).replace(/-/g, "_")}_API_KEY"]. Falls back to a server-wide env var. This is how the built-in tools (attio, browserbase, firecrawl, perplexity) get their keys without per-user storage.
If all three miss, resolveToken() throws No credentials for "<provider>". Connect it in the Toolshed dashboard. — surfaced to the agent verbatim by call_tool.

Plugin perspective

Plugin handlers don’t need to know which source supplies the token. They just call:
const token = await ctx.auth.getToken("granola-personal")
For OAuth tokens the plugin receives a Bearer access token; for API keys it receives the raw key string. The plugin’s job is to put it in the correct header for the upstream API.