From e38901779f7eb51e629472b75422aeec0880595f Mon Sep 17 00:00:00 2001 From: "Vladyslav G." Date: Thu, 10 Sep 2026 12:25:28 +0200 Subject: [PATCH] feat(mcp): add brokered token sources Expose a process-local connector token-source binding through the public MCP port. Persist only the source id and scopes, resolve an ephemeral bearer token at dial time, and surface safe missing, expired, revoked, and unavailable states.\n\nKeep DCR and vault-header flows intact, clear bindings on MCP shutdown, and cover the public contract, persistence boundary, and dial behaviour. --- docs/plugin-authoring.md | 44 +++++++ src/index.ts | 4 + src/mcp-connectors.ts | 42 ++++++ .../mcp/connector-token-source.test.ts | 122 ++++++++++++++++++ src/plugins/mcp/index.test.ts | 1 + src/plugins/mcp/index.ts | 17 ++- src/plugins/mcp/servers.test.ts | 35 +++++ src/plugins/mcp/servers.ts | 67 ++++++++-- src/plugins/mcp/token-sources.test.ts | 60 +++++++++ src/plugins/mcp/token-sources.ts | 74 +++++++++++ 10 files changed, 457 insertions(+), 9 deletions(-) create mode 100644 src/plugins/mcp/connector-token-source.test.ts create mode 100644 src/plugins/mcp/token-sources.test.ts create mode 100644 src/plugins/mcp/token-sources.ts diff --git a/docs/plugin-authoring.md b/docs/plugin-authoring.md index b683b8c..e4b5300 100644 --- a/docs/plugin-authoring.md +++ b/docs/plugin-authoring.md @@ -104,6 +104,50 @@ const result = await host.ports.mcpConnectors?.beginDcrAuthorization({ `status(name)` returns only name, connection state, OAuth state and tool count. It never returns an endpoint, vault binding, token, or secret value. +### Use a product-brokered short-lived token + +When an organization owns the OAuth relationship, bind a runtime token source +from the product plugin and declare its non-secret id on the connector. The MCP +plugin asks the resolver only while it is about to dial; it writes neither the +access token it receives nor a refresh token or client secret to `mcp.json`. + +```ts +import { definePlugin } from '@hoshi/harness' + +export default [ + definePlugin({ + name: 'example-platform', + description: 'Makes organization-brokered connectors available.', + uses: ['mcpConnectors'], + setup(host) { + const connectors = host.ports.mcpConnectors + if (!connectors) return + + connectors.bindTokenSource('platform.example', async ({ name, scopes }) => { + const token = await mintShortLivedTokenFromYourPlatform({ name, scopes }) + return token ? { state: 'available', accessToken: token } : { state: 'unavailable' } + }) + + void connectors.install({ + name: 'example', + transport: 'http', + url: 'https://mcp.example.com/', + tokenSource: { id: 'platform.example', scopes: ['documents.read'] }, + }) + }, + }), +] +``` + +Bind the source during every plugin setup. Source bindings are process-local by +design, so a restart begins unbound; a stored connector whose source was not +registered is visibly `unreachable` with `needs-auth` rather than being dialled +without a credential. Resolver outcomes are sanitized: an expired token reports +`expired`; unavailable, revoked, or failed broker requests report `needs-auth`. +Do not return provider errors or token metadata. A connector uses either +`tokenSource` or `vaultHeaders`, never both; DCR remains the machine-owned OAuth +path above. + ## Describe what the machine can do `capability` is the public, stable identity of the unit your plugin adds. Its diff --git a/src/index.ts b/src/index.ts index 8ec72da..3def2b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -271,6 +271,10 @@ export type { McpConnectorPort, McpConnectorSnapshot, McpConnectorStatus, + McpConnectorTokenSource, + McpConnectorTokenSourceReference, + McpConnectorTokenSourceRequest, + McpConnectorTokenSourceResult, McpConnectorTransport, McpConnectorVaultHeader, McpDcrAuthorizationRequest, diff --git a/src/mcp-connectors.ts b/src/mcp-connectors.ts index 734dd33..acb70c0 100644 --- a/src/mcp-connectors.ts +++ b/src/mcp-connectors.ts @@ -16,12 +16,49 @@ export interface McpConnectorVaultHeader { template: string } +/** A runtime-only identity source supplied by another plugin. The persisted + * connector definition names this source, but never carries a token, a refresh + * token, or a client secret. */ +export interface McpConnectorTokenSourceReference { + /** Stable, product-defined name of a source bound during plugin setup. */ + id: string + /** Permissions the product asks its broker to mint for this connector. */ + scopes?: string[] +} + +/** What a product-owned broker can safely tell the MCP plugin. A token is + * consumed immediately to make one connection and is never written to storage. + * The non-ready cases intentionally contain no upstream error detail: a + * machine UI can distinguish an expired authorization from one needing action + * without exposing a provider response, secret, or account information. */ +export type McpConnectorTokenSourceResult = + | { state: 'available'; accessToken: string } + | { state: 'unavailable' | 'expired' | 'revoked' } + +export interface McpConnectorTokenSourceRequest { + /** Connector asking for the token; useful when one broker serves several. */ + name: string + /** The declaration's scopes, copied rather than read from durable storage by + * the product plugin. */ + scopes: string[] +} + +/** A product plugin binds this during every boot. It is deliberately runtime + * state: a restarted machine must re-establish its Platform connection before + * a persisted connector can use the source again. */ +export type McpConnectorTokenSource = ( + input: McpConnectorTokenSourceRequest, +) => Promise + export interface McpConnectorDeclaration { name: string url: string transport: McpConnectorTransport /** Header name -> vault binding. Literal credentials are intentionally absent. */ vaultHeaders?: Record + /** A non-secret broker source. Mutually exclusive with vault headers: one + * connector has one credential owner. */ + tokenSource?: McpConnectorTokenSourceReference } export type McpConnectorAuthStatus = 'authenticated' | 'needs-auth' | 'expired' | null @@ -45,6 +82,11 @@ export interface McpDcrAuthorizationRequest { /** The MCP plugin's narrow service for external product plugins. */ export interface McpConnectorPort { install(input: McpConnectorDeclaration): Promise + /** Bind a product-owned, short-lived bearer-token resolver for this process. + * Call from plugin setup on every boot. The returned function releases only + * this binding, so a plugin can cleanly shut down without affecting another + * source. */ + bindTokenSource(id: string, resolve: McpConnectorTokenSource): () => void beginDcrAuthorization(input: McpDcrAuthorizationRequest): Promise<{ url: string }> status(name: string): Promise } diff --git a/src/plugins/mcp/connector-token-source.test.ts b/src/plugins/mcp/connector-token-source.test.ts new file mode 100644 index 0000000..c0475a0 --- /dev/null +++ b/src/plugins/mcp/connector-token-source.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +const mocks = vi.hoisted(() => ({ + close: vi.fn(), + connect: vi.fn(), +})) + +vi.mock('@openharness/core', () => ({ + closeMCPClients: mocks.close, + connectMCPServers: mocks.connect, +})) + +import { configureStateRoot, resetStateRoot } from '../../kernel/store.js' +import { setSecret } from '../../kernel/secrets.js' +import { installRemoteServer, listServers, mcpTools } from './servers.js' +import { bindTokenSource, clearTokenSources } from './token-sources.js' + +describe('a provider-brokered connector', () => { + let state = '' + + beforeEach(async () => { + state = await mkdtemp(path.join(tmpdir(), 'hoshi-mcp-token-source-')) + configureStateRoot(state) + clearTokenSources() + mocks.connect.mockReset() + mocks.close.mockReset() + mocks.connect.mockResolvedValue({ clients: [], tools: {} }) + }) + + afterEach(async () => { + clearTokenSources() + resetStateRoot() + await rm(state, { recursive: true, force: true }) + }) + + it('dials with a brokered bearer token while persisting only the source id and scopes', async () => { + await installRemoteServer({ + name: 'google-drive', + transport: 'http', + url: 'https://mcp.example.test/', + tokenSource: { id: 'platform.google', scopes: ['drive.readonly'] }, + }) + bindTokenSource('platform.google', async () => ({ state: 'available', accessToken: 'ephemeral-access-token' })) + + await mcpTools() + + expect(mocks.connect).toHaveBeenCalledWith({ + 'google-drive': { + type: 'http', + url: 'https://mcp.example.test/', + headers: { Authorization: 'Bearer ephemeral-access-token' }, + }, + }) + const persisted = await readFile(path.join(state, 'mcp.json'), 'utf8') + expect(persisted).toContain('platform.google') + expect(persisted).toContain('drive.readonly') + expect(persisted).not.toContain('ephemeral-access-token') + expect(persisted).not.toMatch(/refresh|client.secret/i) + }) + + it('keeps the existing vault-header path when no runtime source is declared', async () => { + await installRemoteServer({ + name: 'github', + transport: 'http', + url: 'https://mcp.example.test/', + vaultHeaders: { Authorization: { key: 'GITHUB_TOKEN', template: 'Bearer {{secret}}' } }, + }) + await setSecret('GITHUB_TOKEN', 'vault-token') + + await mcpTools() + + expect(mocks.connect).toHaveBeenCalledWith({ + github: { + type: 'http', + url: 'https://mcp.example.test/', + headers: { Authorization: 'Bearer vault-token' }, + }, + }) + }) + + it('does not dial without a source and exposes a safe missing-source status', async () => { + await installRemoteServer({ + name: 'missing-source', + transport: 'http', + url: 'https://mcp.example.test/', + tokenSource: { id: 'platform.missing' }, + }) + + await expect(listServers()).resolves.toMatchObject([ + { + name: 'missing-source', + status: 'unreachable', + auth: 'needs-auth', + error: 'The required connector token source is not available.', + }, + ]) + expect(mocks.connect).not.toHaveBeenCalled() + }) + + it('reports an expired brokered authorization without forwarding the broker response', async () => { + await installRemoteServer({ + name: 'expired-source', + transport: 'http', + url: 'https://mcp.example.test/', + tokenSource: { id: 'platform.expired' }, + }) + bindTokenSource('platform.expired', async () => ({ state: 'expired' })) + + await expect(listServers()).resolves.toMatchObject([ + { + name: 'expired-source', + status: 'unreachable', + auth: 'expired', + error: 'The connector authorization has expired.', + }, + ]) + expect(mocks.connect).not.toHaveBeenCalled() + }) +}) diff --git a/src/plugins/mcp/index.test.ts b/src/plugins/mcp/index.test.ts index c440da1..dfba3c3 100644 --- a/src/plugins/mcp/index.test.ts +++ b/src/plugins/mcp/index.test.ts @@ -25,6 +25,7 @@ describe('the public MCP connector port', () => { expect(provided?.mcpConnectors).toEqual({ install: expect.any(Function), + bindTokenSource: expect.any(Function), beginDcrAuthorization: expect.any(Function), status: expect.any(Function), }) diff --git a/src/plugins/mcp/index.ts b/src/plugins/mcp/index.ts index 38af6e0..1f88497 100644 --- a/src/plugins/mcp/index.ts +++ b/src/plugins/mcp/index.ts @@ -10,8 +10,9 @@ import oauthCallback from './mcp.oauth.callback.get.js' import startOauth from './mcp.name.oauth.post.js' import disconnectOauth from './mcp.name.oauth.delete.js' import { refreshExpiringTokens } from './oauth-connect.js' -import { connectorStatus, installRemoteServer } from './servers.js' +import { connectorStatus, installRemoteServer, invalidateMcpConnections } from './servers.js' import { beginDcrAuthorization } from './mcp.name.oauth.post.js' +import { bindTokenSource, clearTokenSources } from './token-sources.js' /** * ── MCP connectors ─────────────────────────────────────────────────────────── @@ -43,6 +44,14 @@ export default definePlugin({ ...current, mcpConnectors: { install: installRemoteServer, + bindTokenSource: (id, resolve) => { + const release = bindTokenSource(id, resolve) + invalidateMcpConnections() + return () => { + release() + invalidateMcpConnections() + } + }, beginDcrAuthorization, status: connectorStatus, }, @@ -86,5 +95,11 @@ export default definePlugin({ /** Renew what is close to expiring. On the machine's own timer: the * no-polling rule is about the client↔machine wire. */ host.jobs.every(5 * 60_000, refreshExpiringTokens) + return { + shutdown: () => { + clearTokenSources() + invalidateMcpConnections() + }, + } }, }) diff --git a/src/plugins/mcp/servers.test.ts b/src/plugins/mcp/servers.test.ts index 526e462..cd3a5ad 100644 --- a/src/plugins/mcp/servers.test.ts +++ b/src/plugins/mcp/servers.test.ts @@ -142,4 +142,39 @@ describe('the public declared-connector boundary', () => { }), ).toThrow(InvalidServerError) }) + + it('persists only a named runtime token source and its scopes, never a bearer token', () => { + expect( + parseRemoteConnector({ + name: 'google-drive', + transport: 'http', + url: 'https://mcp.example.test/', + tokenSource: { id: 'platform.google', scopes: ['drive.readonly'] }, + }), + ).toEqual({ + type: 'http', + url: 'https://mcp.example.test/', + tokenSource: { id: 'platform.google', scopes: ['drive.readonly'] }, + }) + }) + + it('refuses malformed sources and a declaration that tries to combine credential owners', () => { + expect(() => + parseRemoteConnector({ + name: 'bad-source', + transport: 'http', + url: 'https://mcp.example.test/', + tokenSource: { id: 'not valid' }, + }), + ).toThrow(InvalidServerError) + expect(() => + parseRemoteConnector({ + name: 'two-auth-modes', + transport: 'http', + url: 'https://mcp.example.test/', + vaultHeaders: { Authorization: { key: 'TOKEN', template: 'Bearer {{secret}}' } }, + tokenSource: { id: 'platform.google' }, + }), + ).toThrow(InvalidServerError) + }) }) diff --git a/src/plugins/mcp/servers.ts b/src/plugins/mcp/servers.ts index cfbcca6..c43b278 100644 --- a/src/plugins/mcp/servers.ts +++ b/src/plugins/mcp/servers.ts @@ -5,8 +5,14 @@ import { authorizationHeader } from './oauth-connect.js' import { authorizedConnectors, authStatus, forgetAuth, type AuthStatus } from './oauth-store.js' import { publishMachineEvent } from '../../kernel/events.js' import { isValidSecretKey, readSecretValue } from '../../kernel/secrets.js' -import type { McpConnectorDeclaration, McpConnectorSnapshot, McpConnectorVaultHeader } from '../../mcp-connectors.js' +import type { + McpConnectorDeclaration, + McpConnectorSnapshot, + McpConnectorTokenSourceReference, + McpConnectorVaultHeader, +} from '../../mcp-connectors.js' import { forgetFlowsFor } from './oauth-flow.js' +import { resolveTokenSource, TokenSourceUnavailableError } from './token-sources.js' /** * ── MCP connectors ─────────────────────────────────────────────────────────── @@ -41,6 +47,9 @@ export type StoredServer = MCPServerConfig & { enabled?: boolean /** Credential references only. Resolved immediately before connecting. */ vaultHeaders?: Record + /** A runtime-only product broker reference. This is identity metadata, never + * a credential: the resolver supplies a short-lived token at dial time. */ + tokenSource?: McpConnectorTokenSourceReference } export interface McpServer { @@ -106,7 +115,10 @@ async function writeDefinitions(servers: Record): Promise< interface Live { clients: Awaited>['clients'] tools: ToolSet - statuses: Map + statuses: Map< + string, + { status: ServerStatus; auth: AuthStatus | null; toolCount: number; tools: string[]; error: string | null } + > } let live: Promise | null = null @@ -123,14 +135,28 @@ function invalidate(): void { void previous?.then(({ clients }) => closeMCPClients(clients)).catch(() => undefined) } +/** Runtime credentials are intentionally not part of a stored definition. When + * their provider comes up, goes away, or is rebound, discard an old failed dial + * so the next turn observes the current runtime source rather than a cache. */ +export function invalidateMcpConnections(): void { + invalidate() +} + /** Merge the machine's bearer token into a remote connector's headers. Returns * the config untouched for stdio, and for anything with no authorization. */ async function withAuthorization(name: string, config: MCPServerConfig): Promise { - if (config.type === 'stdio') return config const stored = config as StoredServer + if (stored.type === 'stdio') return config const vaultHeaders = await resolveVaultHeaders(stored.vaultHeaders) const header = await authorizationHeader(name) - return { ...config, headers: { ...config.headers, ...vaultHeaders, ...header } } + const token = stored.tokenSource ? await resolveTokenSource(stored.tokenSource, name) : null + const headers = { ...stored.headers, ...vaultHeaders, ...header, ...(token ? { Authorization: `Bearer ${token}` } : {}) } + if (stored.type === 'http') return { type: 'http', url: stored.url, headers } + return { + type: 'sse', + url: stored.url, + headers, + } } /** Resolve only while connecting. Stored declarations carry vault key NAMES and @@ -163,7 +189,7 @@ async function connectAll(): Promise { * **/ if (enabled === false) { - statuses.set(name, { status: 'disabled', toolCount: 0, tools: [], error: null }) + statuses.set(name, { status: 'disabled', auth: null, toolCount: 0, tools: [], error: null }) continue } try { @@ -181,6 +207,7 @@ async function connectAll(): Promise { tools = { ...tools, ...connection.tools } statuses.set(name, { status: 'connected', + auth: stored.tokenSource ? 'authenticated' : null, toolCount: Object.keys(connection.tools).length, tools: Object.keys(connection.tools), error: null, @@ -195,6 +222,7 @@ async function connectAll(): Promise { **/ statuses.set(name, { status: 'unreachable', + auth: error instanceof TokenSourceUnavailableError ? error.auth : null, toolCount: 0, tools: [], error: error instanceof Error ? error.message : String(error), @@ -230,7 +258,7 @@ export async function listServers(): Promise { name, config, status: state?.status ?? 'unreachable', - auth: authStatus(auth.get(name) ?? null), + auth: state?.auth ?? authStatus(auth.get(name) ?? null), toolCount: state?.toolCount ?? 0, tools: state?.tools ?? [], error: state?.error ?? null, @@ -294,6 +322,21 @@ function vaultHeaderBindings(value: unknown): Record + if (typeof id !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(id)) { + throw new InvalidServerError('tokenSource needs a valid source id.') + } + if (scopes !== undefined && (!Array.isArray(scopes) || scopes.some((scope) => typeof scope !== 'string' || !scope.trim()))) { + throw new InvalidServerError('tokenSource scopes must be non-empty strings.') + } + return { id, ...(scopes ? { scopes: [...new Set(scopes)] as string[] } : {}) } +} + export function parseRemoteConnector(input: McpConnectorDeclaration): StoredServer { const config = parseConfig({ type: input.transport, url: input.url }) if (config.type === 'stdio') throw new InvalidServerError('A declared connector must use an HTTP or SSE transport.') @@ -302,7 +345,11 @@ export function parseRemoteConnector(input: McpConnectorDeclaration): StoredServ throw new InvalidServerError('A declared connector needs an http or https endpoint.') } const vaultHeaders = vaultHeaderBindings(input.vaultHeaders) - return { ...config, ...(vaultHeaders ? { vaultHeaders } : {}) } + const tokenSource = tokenSourceReference(input.tokenSource) + if (vaultHeaders && tokenSource) { + throw new InvalidServerError('A declared connector may use vault headers or a token source, not both.') + } + return { ...config, ...(vaultHeaders ? { vaultHeaders } : {}), ...(tokenSource ? { tokenSource } : {}) } } export function parseConfig(raw: unknown): MCPServerConfig { @@ -368,9 +415,13 @@ export async function installRemoteServer(input: McpConnectorDeclaration): Promi const previous = servers[input.name] const changedEndpoint = !previous || previous.type === 'stdio' || previous.type !== definition.type || previous.url !== definition.url + const changedAuthentication = + !previous || + JSON.stringify((previous as StoredServer).vaultHeaders ?? null) !== JSON.stringify(definition.vaultHeaders ?? null) || + JSON.stringify((previous as StoredServer).tokenSource ?? null) !== JSON.stringify(definition.tokenSource ?? null) servers[input.name] = definition await writeDefinitions(servers) - if (changedEndpoint) { + if (changedEndpoint || changedAuthentication) { forgetFlowsFor(input.name) await forgetAuth(input.name) publishMachineEvent('mcp.changed', { name: input.name, auth: null }) diff --git a/src/plugins/mcp/token-sources.test.ts b/src/plugins/mcp/token-sources.test.ts new file mode 100644 index 0000000..c93f878 --- /dev/null +++ b/src/plugins/mcp/token-sources.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { + bindTokenSource, + clearTokenSources, + InvalidTokenSourceError, + resolveTokenSource, + TokenSourceUnavailableError, +} from './token-sources.js' + +describe('runtime connector token sources', () => { + it('passes a connector and copied scopes to a bound resolver, but returns only its ephemeral token to the dialler', async () => { + const calls: unknown[] = [] + const release = bindTokenSource('platform.google', async (input) => { + calls.push(input) + return { state: 'available', accessToken: 'short-lived-token' } + }) + + await expect(resolveTokenSource({ id: 'platform.google', scopes: ['drive.readonly'] }, 'google-drive')).resolves.toBe( + 'short-lived-token', + ) + expect(calls).toEqual([{ name: 'google-drive', scopes: ['drive.readonly'] }]) + release() + }) + + it('turns absent, expired, revoked, and throwing resolvers into safe machine states', async () => { + await expect(resolveTokenSource({ id: 'missing' }, 'connector')).rejects.toMatchObject({ + auth: 'needs-auth', + message: 'The required connector token source is not available.', + }) + + for (const [id, result, auth] of [ + ['expired', { state: 'expired' }, 'expired'], + ['revoked', { state: 'revoked' }, 'needs-auth'], + ['unavailable', { state: 'unavailable' }, 'needs-auth'], + ] as const) { + const release = bindTokenSource(id, async () => result) + await expect(resolveTokenSource({ id }, 'connector')).rejects.toBeInstanceOf(TokenSourceUnavailableError) + await expect(resolveTokenSource({ id }, 'connector')).rejects.toMatchObject({ auth }) + release() + } + + const release = bindTokenSource('throws', async () => { + throw new Error('upstream response must not cross this boundary') + }) + await expect(resolveTokenSource({ id: 'throws' }, 'connector')).rejects.toMatchObject({ + auth: 'needs-auth', + message: 'The connector authorization is not available.', + }) + release() + }) + + it('does not allow one plugin to replace another source, and clears registrations at shutdown', async () => { + bindTokenSource('shared', async () => ({ state: 'available', accessToken: 'first' })) + expect(() => bindTokenSource('shared', async () => ({ state: 'available', accessToken: 'second' }))).toThrow( + InvalidTokenSourceError, + ) + clearTokenSources() + await expect(resolveTokenSource({ id: 'shared' }, 'connector')).rejects.toMatchObject({ auth: 'needs-auth' }) + }) +}) diff --git a/src/plugins/mcp/token-sources.ts b/src/plugins/mcp/token-sources.ts new file mode 100644 index 0000000..30ab0ca --- /dev/null +++ b/src/plugins/mcp/token-sources.ts @@ -0,0 +1,74 @@ +import type { + McpConnectorTokenSource, + McpConnectorTokenSourceReference, + McpConnectorTokenSourceResult, +} from '../../mcp-connectors.js' +import type { AuthStatus } from './oauth-store.js' + +/** + * Product brokers are process-local on purpose. Their Platform credentials and + * any token they mint belong to the product plugin, not a connector definition + * on disk. A restart therefore begins with an empty map and requires the + * product plugin to bind its source again during setup. + */ +const sources = new Map() +const SOURCE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/ + +export class InvalidTokenSourceError extends Error {} + +/** Bind a source for the current harness process. A second plugin may not + * silently replace the first source with the same name: doing so would turn a + * declaration into an implicit credential-routing decision. */ +export function bindTokenSource(id: string, resolve: McpConnectorTokenSource): () => void { + if (!SOURCE_ID.test(id)) { + throw new InvalidTokenSourceError('A connector token source id must use letters, digits, dots, dashes, or underscores.') + } + if (typeof resolve !== 'function') throw new InvalidTokenSourceError('A connector token source needs a resolver function.') + const existing = sources.get(id) + if (existing && existing !== resolve) throw new InvalidTokenSourceError('A connector token source is already bound.') + sources.set(id, resolve) + return () => { + if (sources.get(id) === resolve) sources.delete(id) + } +} + +/** Called by the MCP plugin shutdown hook. This makes the restart contract + * executable in same-process library tests as well as a real daemon restart. */ +export function clearTokenSources(): void { + sources.clear() +} + +/** A deliberately ordinary error surface. Neither a resolver exception nor an + * upstream OAuth response reaches a machine client or an agent tool list. */ +export class TokenSourceUnavailableError extends Error { + constructor(readonly auth: AuthStatus, message: string) { + super(message) + } +} + +export async function resolveTokenSource( + reference: McpConnectorTokenSourceReference, + name: string, +): Promise { + const resolve = sources.get(reference.id) + if (!resolve) { + throw new TokenSourceUnavailableError('needs-auth', 'The required connector token source is not available.') + } + + let result: McpConnectorTokenSourceResult + try { + result = await resolve({ name, scopes: [...(reference.scopes ?? [])] }) + } catch { + throw new TokenSourceUnavailableError('needs-auth', 'The connector authorization is not available.') + } + + if (result?.state === 'available' && typeof result.accessToken === 'string' && result.accessToken) { + return result.accessToken + } + if (result?.state === 'expired') { + throw new TokenSourceUnavailableError('expired', 'The connector authorization has expired.') + } + /** Revocation and transient broker failure have the same safe next action: + * reconnect the product account. Do not expose which one occurred. */ + throw new TokenSourceUnavailableError('needs-auth', 'The connector authorization is not available.') +}