From c54017794853fbf096f80076e500c015e22b6b1c Mon Sep 17 00:00:00 2001 From: EthanHuo Date: Wed, 11 Nov 2026 14:44:04 +0800 Subject: [PATCH] feat: self-repairing Schema Cache and background upkeep; remove @refresh (ADR 0011), release 0.16.0 The Schema Cache is a hint; servers are the authority. A CLI call to an uncached tool, or input missing a cached-required field, re-syncs the server before rejecting. Invalid Params rejections and new sessions re-list in the background, and mcpxd sweeps schemas older than a week at start and daily. Background listings never open a browser. @refresh, the last remnant of ADR 0007, is gone; @add/@login/CLI re-sync share syncSchemas (protocol 4). --- GLOSSARY.md | 4 +- README.md | 25 +- docs/adr/0007-authentication-is-explicit.md | 2 +- ...-is-a-hint-kept-fresh-in-the-background.md | 20 ++ package.json | 2 +- src/daemon-server.ts | 16 +- src/json-schema-standard.ts | 29 ++- src/references/daemon.md | 4 +- src/router.ts | 120 ++++++--- src/runtime-authentication.ts | 4 +- src/runtime-protocol.ts | 12 +- src/runtime-session-pool.ts | 55 +++- src/runtime.ts | 63 +++-- src/schema-upkeep.ts | 184 +++++++++++++ src/skill-template.ts | 8 +- tests/daemon-client.test.ts | 12 +- tests/oauth-challenge.test.ts | 6 +- tests/router.test.ts | 1 - tests/runtime-authentication.test.ts | 18 +- tests/runtime-cli.test.ts | 8 +- tests/runtime-protocol.test.ts | 4 +- tests/schema-upkeep.test.ts | 242 ++++++++++++++++++ tests/skill-template.test.ts | 3 +- 23 files changed, 724 insertions(+), 118 deletions(-) create mode 100644 docs/adr/0011-the-schema-cache-is-a-hint-kept-fresh-in-the-background.md create mode 100644 src/schema-upkeep.ts create mode 100644 tests/schema-upkeep.test.ts diff --git a/GLOSSARY.md b/GLOSSARY.md index 5c2f939..a1f09fa 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -35,7 +35,7 @@ A resource server's `401` or `403 insufficient_scope` answer with its `WWW-Authe _Avoid_: Auth probe, auth discovery result **Authentication Flow**: -One shared attempt to make a Credential Identity usable: a silent token refresh when possible, otherwise browser authorization. Calls and `@refresh` join the same flow and wait for its outcome; an Authorization Challenge starts or joins it too. +One shared attempt to make a Credential Identity usable: a silent token refresh when possible, otherwise browser authorization. Calls, `@add`, and `@login` join the same flow and wait for its outcome; an Authorization Challenge starts or joins it too. _Avoid_: Per-command login, token retry **Caller Input**: @@ -47,7 +47,7 @@ One caller-owned request that moves from accepted through queued or active work _Avoid_: Detached job, background task **Schema Cache**: -Rebuildable knowledge MCPX has observed about server tools and their schemas. +Rebuildable knowledge MCPX has observed about server tools and their schemas. A hint for agents, never the authority on what a call accepts; repaired on evidence of drift and swept weekly in the background (ADR 0011). _Avoid_: Declared tools, registry schema **Active State**: diff --git a/README.md b/README.md index d9a7d21..36714ec 100644 --- a/README.md +++ b/README.md @@ -120,10 +120,18 @@ Focus on an internal mcpx command: mcpx @schema '.["@add"]' ``` -mcpx caches discovered tool schemas in the global registry. Cached schemas older -than one day are refreshed opportunistically in a single background worker when -mcpx starts, so normal commands do not block on schema discovery. Servers that -have not discovered any tools yet are still retried synchronously during startup. +mcpx caches discovered tool schemas so commands and `@schema` answer without +asking the server. The cache is a hint, not the authority, and repairs itself +([ADR 0011](docs/adr/0011-the-schema-cache-is-a-hint-kept-fresh-in-the-background.md)): + +- calling a tool the cache does not know, or omitting a field the cached schema + requires, re-lists that server before the call is rejected; +- a server's Invalid Params rejection, a new session (for schemas over an hour + old) and `tools/list_changed` re-list it in the background; +- `mcpxd` re-lists every schema older than a week when it starts and daily + while it runs. + +Background listings never open a browser; there is no manual refresh command. ## Control Commands @@ -136,7 +144,6 @@ mcpx @add "{ name: '', url: '', bearer: 'env:' }" mcpx @add "{ name: '', transport: 'stdio', command: '', args: [''] }" mcpx @remove "{ name: '' }" mcpx @login "{ name: '' }" -mcpx @refresh mcpx @daemon.status mcpx @daemon.stop mcpx @daemon.server @@ -168,12 +175,6 @@ mcpx @add '{ }' ``` -`@refresh` checks registered MCP servers concurrently: it makes each server's -credentials usable, then refreshes its cached tool schemas. Progress for every -server (token refresh, browser authorization URL, tool listing, result) is -printed to stderr as it happens; stdout carries only the final report. A failing -server is reported by name and does not prevent the others from being checked. - Stdio servers are called through `mcpxd`, a user-local daemon that reuses stdio MCP sessions across CLI invocations. `mcpxd` starts on demand, keeps idle stdio servers warm, and can be inspected or stopped with `mcpx @daemon.status` and @@ -228,7 +229,7 @@ OAuth credentials repair themselves during ordinary tool calls: Providers that need a manual OAuth client can only prompt in an interactive terminal; without one, the call fails immediately with `reauth-required` and -`mcpx @refresh` must be run in a terminal. +`mcpx @login "{ name: '' }"` must be run in a terminal. Tool calls use a 5 minute request timeout by default. Set `MCPX_TOOL_CALL_TIMEOUT_MS` when a remote MCP server legitimately needs longer. diff --git a/docs/adr/0007-authentication-is-explicit.md b/docs/adr/0007-authentication-is-explicit.md index 547ba7e..840fefe 100644 --- a/docs/adr/0007-authentication-is-explicit.md +++ b/docs/adr/0007-authentication-is-explicit.md @@ -1,7 +1,7 @@ --- type: Decision title: Authentication Is Explicit -status: superseded by 0008 +status: superseded by 0008 and 0011 --- # Authentication Is Explicit diff --git a/docs/adr/0011-the-schema-cache-is-a-hint-kept-fresh-in-the-background.md b/docs/adr/0011-the-schema-cache-is-a-hint-kept-fresh-in-the-background.md new file mode 100644 index 0000000..aad7e95 --- /dev/null +++ b/docs/adr/0011-the-schema-cache-is-a-hint-kept-fresh-in-the-background.md @@ -0,0 +1,20 @@ +--- +type: Decision +title: The Schema Cache Is a Hint, Kept Fresh in the Background +status: accepted +--- + +# The Schema Cache Is a Hint, Kept Fresh in the Background + +MCP gives a client no cheap way to ask whether a server's tools changed: `tools/list` carries no version, ETag, or TTL, and `notifications/tools/list_changed` only reaches an open session. So mcpx does not try to learn the moment a schema changes. The Schema Cache is a hint for agents (`@schema`, generated commands); the server stays the authority on what a call accepts, and the cache is repaired wherever evidence of drift appears: + +- **Unknown tool.** A CLI call to `.` that the cache does not list re-syncs that server's schema before it is reported unknown. +- **Missing required field.** Input that misses a field the cached schema requires is judged again by the server's current schema before it is rejected. +- **Invalid Params.** A call the server rejects with JSON-RPC `-32602` (unknown tool, stale argument shape) re-lists the server in the background, at most once a minute. +- **Reconnect.** Every new session re-lists, in the background, a schema older than an hour. +- **`tools/list_changed`.** A notification during a call re-lists, as before. +- **Sweep.** mcpxd re-lists every schema older than a week when it starts and daily while it lives. mcpxd is the one MCP Runtime per user (ADR 0001) and each server has at most one listing in flight, so the sweep is exclusive without a lock file. + +The first two are interactive: someone is waiting, so they may authorize like any Call (ADR 0008). Background listings never open a browser or ask for input; a credential that is unusable is recorded as `reauth-required` on the cached entry and left for the next real Call, and a failure keeps the last good tools. + +This removes `@refresh`, the last remnant of [ADR 0007](0007-authentication-is-explicit.md): authorization already repairs itself during Calls (ADR 0008), signing in again is `@login` (ADR 0010), and schemas no longer need a manual trigger. `@add`, `@login`, and the CLI's re-sync share one internal `syncSchemas` Runtime operation (protocol 4). diff --git a/package.json b/package.json index 3c28726..78f55d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcpx", - "version": "0.15.0", + "version": "0.16.0", "license": "MIT", "bin": { "mcpx": "./src/main.ts" diff --git a/src/daemon-server.ts b/src/daemon-server.ts index bc3d845..f560ee9 100644 --- a/src/daemon-server.ts +++ b/src/daemon-server.ts @@ -23,6 +23,9 @@ let lastActivityAt = Date.now() const SESSION_IDLE_MS = 15 * 60 * 1000 const DAEMON_IDLE_MS = 30 * 60 * 1000 const CLEANUP_INTERVAL_MS = 30 * 1000 +// mcpxd is the one Runtime per user, so its sweep is the exclusive background +// schema upkeep (ADR 0011): once when it starts, then daily while it lives. +const SCHEMA_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000 export async function runDaemonServer(): Promise { await ensureDaemonDir() @@ -163,9 +166,16 @@ async function cleanupIdleRuntime(server: net.Server): Promise { } async function getRuntime(): Promise { - runtimePromise ??= openRuntimeStores(daemonDir()).then( - (stores) => new McpRuntime(stores), - ) + runtimePromise ??= openRuntimeStores(daemonDir()).then((stores) => { + const runtime = new McpRuntime(stores) + const sweep = () => { + if (!stopping) void runtime.sweepSchemas().catch(() => {}) + } + // After the operation that started the Runtime, not ahead of it. + setTimeout(sweep, 0).unref() + setInterval(sweep, SCHEMA_SWEEP_INTERVAL_MS).unref() + return runtime + }) return runtimePromise } diff --git a/src/json-schema-standard.ts b/src/json-schema-standard.ts index e066cf6..f3c5c42 100644 --- a/src/json-schema-standard.ts +++ b/src/json-schema-standard.ts @@ -8,14 +8,33 @@ type StandardSchema = StandardSchemaV1< > & StandardJSONSchemaV1, Record> -export function jsonSchemaToStandardSchema(schema: unknown): StandardSchema { +export type StandardSchemaOptions = { + /** + * Called when input misses a field the cached schema requires: returns the + * server's current schema to judge the input by instead, or `undefined` to + * keep the cached verdict. A stale cache must not reject a valid call. + */ + resync?: () => Promise +} + +export function jsonSchemaToStandardSchema( + schema: unknown, + options: StandardSchemaOptions = {}, +): StandardSchema { const inputSchema = normalizeInputSchema(schema) return { '~standard': { version: 1, vendor: 'mcpx', - validate: (value: unknown) => validateObjectInput(value, inputSchema), + validate: async (value: unknown) => { + const result = validateObjectInput(value, inputSchema) + if (!result.issues || !options.resync || !isObject(value)) return result + const current = await options.resync() + return current === undefined + ? result + : validateObjectInput(value, normalizeInputSchema(current)) + }, jsonSchema: { input: () => inputSchema, // argc derives CLI parameter help from output schemas; MCP tool ingress @@ -57,7 +76,7 @@ function validateObjectInput( value: unknown, schema: JsonSchema, ): StandardSchemaV1.Result> { - if (!value || typeof value !== 'object' || Array.isArray(value)) { + if (!isObject(value)) { return { issues: [{ message: 'Input must be a JSON object.' }], } @@ -83,3 +102,7 @@ function validateObjectInput( // every provider's JSON Schema dialect can be locally enforced yet. return { value: input } } + +function isObject(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} diff --git a/src/references/daemon.md b/src/references/daemon.md index b5acc01..902adc3 100644 --- a/src/references/daemon.md +++ b/src/references/daemon.md @@ -62,8 +62,8 @@ when a tool call fails with no useful CLI output. thrashing or session-id rejection loop). - Two registry entries with identical launch parameters appear as separate sessions in status (key derivation may not match expectation). -- Tool schema in the project does not match the live MCP server's tools and - `mcpx @refresh` does not converge. +- Tool schema in the project does not match the live MCP server's tools even + after calling the tool (calls re-sync a stale schema). In all other cases prefer filing an issue with the command, input, and any relevant log excerpt at https://github.com/celados/mcpx/issues. diff --git a/src/router.ts b/src/router.ts index 9f6c436..34112b4 100644 --- a/src/router.ts +++ b/src/router.ts @@ -126,15 +126,18 @@ export async function runMcpx( cwd: string, mainPath: string, ): Promise { - const snapshot = (await requestRuntime( - { requestId: crypto.randomUUID(), op: 'registrySnapshot' }, - mainPath, - )) as RuntimeRegistrySnapshot - const registry: RegistryView = { - servers: snapshot.servers as unknown as RegistryConfig['servers'], + const schemas = new SchemaResync(mainPath) + let registry = await schemas.registry() + // A tool the cache has never seen may be one the server added since: ask + // the server before calling it unknown (ADR 0011). + const target = toolTarget(argv[0], registry) + if (target) { + const current = registry + // An unreachable server leaves the cached verdict standing. + registry = await schemas.resync(target.serverName).catch(() => current) } - const app = cli(buildRouter(registry), { + const app = cli(buildRouter(registry, schemas), { name: 'mcpx', version: MCPX_VERSION, description: 'Global MCP registry and agent-facing command surface.', @@ -152,9 +155,72 @@ export async function runMcpx( ) } -function buildRouter(registry: RegistryView): Router { +/** + * The CLI's one way back to the server when its cached schema looks stale: at + * most one `syncSchemas` per server per invocation. + */ +class SchemaResync { + readonly #mainPath: string + readonly #synced = new Map>() + + constructor(mainPath: string) { + this.#mainPath = mainPath + } + + async registry(): Promise { + const snapshot = (await requestRuntime( + { requestId: crypto.randomUUID(), op: 'registrySnapshot' }, + this.#mainPath, + )) as RuntimeRegistrySnapshot + return { servers: snapshot.servers as unknown as RegistryConfig['servers'] } + } + + resync(serverName: string): Promise { + let synced = this.#synced.get(serverName) + if (!synced) { + synced = requestRuntime( + { + requestId: crypto.randomUUID(), + op: 'syncSchemas', + serverNames: [serverName], + }, + this.#mainPath, + { onEvent: renderRuntimeProgress, onInput: promptForRuntimeInput }, + ).then(() => this.registry()) + this.#synced.set(serverName, synced) + } + return synced + } + + /** The server's current input schema for a tool, if it still has one. */ + async inputSchema(serverName: string, toolName: string): Promise { + const registry = await this.resync(serverName).catch(() => undefined) + const tool = registry?.servers[serverName]?.tools?.find( + (item) => item.name === toolName, + ) + return tool ? (tool.inputSchema ?? {}) : undefined + } +} + +function toolTarget( + arg: string | undefined, + registry: RegistryView, +): { serverName: string; commandName: string } | undefined { + if (!arg || arg.startsWith('@') || arg.startsWith('-')) return undefined + const dot = arg.indexOf('.') + if (dot <= 0) return undefined + const serverName = arg.slice(0, dot) + const commandName = arg.slice(dot + 1) + const server = registry.servers[serverName] + if (!server || !commandName) return undefined + if (server.tools?.some((tool) => tool.commandName === commandName)) + return undefined + return { serverName, commandName } +} + +function buildRouter(registry: RegistryView, schemas?: SchemaResync): Router { return { - ...buildServerRouter(registry), + ...buildServerRouter(registry, schemas), '@add': c .meta({ description: @@ -182,11 +248,6 @@ function buildRouter(registry: RegistryView): Router { examples: [`mcpx @login "{ name: 'higgsfield' }"`], }) .input(loginInput), - '@refresh': c.meta({ - description: - 'Refresh all registered MCP server schemas and report auth status.', - examples: ['mcpx @refresh'], - }), '@daemon': group( { description: 'Inspect or stop the local mcpxd daemon.' }, { @@ -218,7 +279,10 @@ function buildRouter(registry: RegistryView): Router { } } -function buildServerRouter(registry: RegistryView): Record { +function buildServerRouter( + registry: RegistryView, + schemas?: SchemaResync, +): Record { const servers: Record = {} for (const [serverName, server] of Object.entries(registry.servers)) { const tools = server.tools ?? [] @@ -228,7 +292,16 @@ function buildServerRouter(registry: RegistryView): Record { .meta({ description: describeTool(tool, serverName), }) - .input(jsonSchemaToStandardSchema(tool.inputSchema)) + .input( + jsonSchemaToStandardSchema( + tool.inputSchema, + schemas + ? { + resync: () => schemas.inputSchema(serverName, tool.name), + } + : {}, + ), + ) } servers[serverName] = group( { description: describeServerTools(tools.length) }, @@ -357,7 +430,7 @@ function buildHandlers( await requestRuntime( { requestId: crypto.randomUUID(), - op: 'refreshServers', + op: 'syncSchemas', serverNames: [assertServerName(options.input.name)], signIn: true, }, @@ -388,19 +461,6 @@ function buildHandlers( ) } - handlers['@refresh'] = async ( - options: HandlerOptions>, - ) => { - return await renderOutput( - await requestRuntime( - { requestId: crypto.randomUUID(), op: 'refreshServers' }, - mainPath, - { onEvent: renderRuntimeProgress, onInput: promptForRuntimeInput }, - ), - options.context, - ) - } - handlers['@daemon'] = { status: async (options: HandlerOptions>) => { return await renderOutput( diff --git a/src/runtime-authentication.ts b/src/runtime-authentication.ts index ef36302..27e0115 100644 --- a/src/runtime-authentication.ts +++ b/src/runtime-authentication.ts @@ -219,7 +219,7 @@ function authorizationFailure(serverName: string, error: unknown): Error { if (error.code === 'timeout') { return new RuntimeOperationError( 'timeout', - `Authorization for ${serverName} was not completed within 5 minutes. Run mcpx @refresh to retry.`, + `Authorization for ${serverName} was not completed within 5 minutes. Retry the command, or run mcpx @login.`, ) } if (error.code === 'operation-failed') @@ -275,7 +275,7 @@ function parseManualClient( if (typeof input.reason === 'string' && input.reason) { throw reauthRequired( serverName, - `OAuth client credentials must be entered interactively (${input.reason}). Run mcpx @refresh in a terminal`, + `OAuth client credentials must be entered interactively (${input.reason}). Run mcpx @login in a terminal`, ) } throw new RuntimeOperationError( diff --git a/src/runtime-protocol.ts b/src/runtime-protocol.ts index 60809d0..2f3328e 100644 --- a/src/runtime-protocol.ts +++ b/src/runtime-protocol.ts @@ -1,6 +1,6 @@ import { MCPX_VERSION } from './version' -export const DAEMON_PROTOCOL_VERSION = 3 +export const DAEMON_PROTOCOL_VERSION = 4 export type NotificationMode = 'buffer' | 'discard' @@ -83,8 +83,8 @@ export type RuntimeIntent = | { requestId: string; op: 'removeServers'; serverNames: string[] } | { requestId: string - op: 'refreshServers' - serverNames?: string[] + op: 'syncSchemas' + serverNames: string[] /** Replace each OAuth grant with a new browser sign-in. */ signIn?: boolean } @@ -181,10 +181,10 @@ export function parseRuntimeIntent( isStringArray(value.serverNames) ? (value as RuntimeIntent) : invalidFrame() - case 'refreshServers': + case 'syncSchemas': return hasOnly(value, ['requestId', 'op', 'serverNames', 'signIn']) && - hasRequired(value, ['requestId', 'op']) && - (value.serverNames === undefined || isStringArray(value.serverNames)) && + hasRequired(value, ['requestId', 'op', 'serverNames']) && + isStringArray(value.serverNames) && (value.signIn === undefined || typeof value.signIn === 'boolean') ? (value as RuntimeIntent) : invalidFrame() diff --git a/src/runtime-session-pool.ts b/src/runtime-session-pool.ts index 01039c9..ef11677 100644 --- a/src/runtime-session-pool.ts +++ b/src/runtime-session-pool.ts @@ -32,6 +32,8 @@ import { type Connect = typeof connectMcpClient +const INVALID_PARAMS = -32602 + type ResolvedServer = { key: string config: ServerConfig @@ -77,10 +79,29 @@ export type RuntimeSessionStatus = { hasRetainedSessionId: boolean } +/** + * Evidence that a server's cached tool schemas may be out of date: a session + * was (re)connected, or the server rejected a call as an unknown tool or with + * invalid params. + */ +export type SchemaSignal = { + serverName: string + reason: 'connected' | 'rejected' +} + +export type ListToolsOptions = { + /** + * `false` for background work: a challenged listing fails with + * `reauth-required` instead of starting browser authorization. + */ + interactive?: boolean +} + export class RuntimeSessionPool { readonly #stores: RuntimeStores readonly #connect: Connect readonly #authentication: RuntimeAuthentication + readonly #onSchemaSignal: ((signal: SchemaSignal) => void) | undefined readonly #sessions = new Map() readonly #bearerCursors = new Map() #accepting = true @@ -90,9 +111,11 @@ export class RuntimeSessionPool { options: { connect?: Connect authentication?: RuntimeAuthentication + onSchemaSignal?: (signal: SchemaSignal) => void } = {}, ) { this.#stores = stores + this.#onSchemaSignal = options.onSchemaSignal this.#connect = options.connect ?? connectMcpClient this.#authentication = options.authentication ?? new RuntimeAuthentication(stores) @@ -116,6 +139,7 @@ export class RuntimeSessionPool { input.serverName, call, signal, + true, (credential) => this.#callTool(session, call, input, credential, signal), ), @@ -126,6 +150,7 @@ export class RuntimeSessionPool { async listTools( serverName: string, caller: RuntimeCaller, + options: ListToolsOptions = {}, ): Promise>> { this.#assertAccepting() const resolved = await this.#resolveServer(serverName) @@ -154,6 +179,7 @@ export class RuntimeSessionPool { serverName, call, signal, + options.interactive !== false, async (credential) => { const timeout = setTimeout(() => { void call.cancel('timeout') @@ -205,6 +231,7 @@ export class RuntimeSessionPool { serverName: string, call: RuntimeCall, signal: AbortSignal, + interactive: boolean, run: (credential: AttemptCredential) => Promise, ): Promise { for (let retried = false; ; retried = true) { @@ -224,7 +251,7 @@ export class RuntimeSessionPool { if (!challenge) throw error session.evictCount += 1 await this.#closeSession(session, false) - if (retried) throw reauthRequired(serverName) + if (retried || !interactive) throw reauthRequired(serverName) } if ( (await this.#authorize(serverName, call, challenge)) === 'disconnected' @@ -259,10 +286,18 @@ export class RuntimeSessionPool { input.notificationMode === 'discard' ? undefined : buffer try { const connection = await this.#ensureConnected(session, credential) - const result = await connection.client.callTool( - { name: input.toolName, arguments: input.input }, - { ...requestOptions, signal }, - ) + const result = await connection.client + .callTool( + { name: input.toolName, arguments: input.input }, + { ...requestOptions, signal }, + ) + .catch((error: unknown) => { + // Servers answer an unknown tool or a stale argument shape with + // JSON-RPC Invalid Params; either way our cached schema may be stale. + if ((error as { code?: unknown })?.code === INVALID_PARAMS) + this.#signal(input.serverName, 'rejected') + throw error + }) const notifications = await flushNotificationBuffer(buffer) const toolsChanged = buffer.toolsChanged() || session.pendingToolsChanged session.pendingToolsChanged = false @@ -482,6 +517,8 @@ export class RuntimeSessionPool { ) } session.connection = connection + for (const serverName of session.serverNames) + this.#signal(serverName, 'connected') return connection }) .finally(() => { @@ -510,6 +547,14 @@ export class RuntimeSessionPool { if (!retire) session.closing = false } + #signal(serverName: string, reason: SchemaSignal['reason']): void { + try { + this.#onSchemaSignal?.({ serverName, reason }) + } catch { + // Schema upkeep is advisory; it must never fail the call that hinted it. + } + } + #assertAccepting(): void { if (!this.#accepting) { throw new RuntimeOperationError('cancelled', 'MCP Runtime is stopping.') diff --git a/src/runtime.ts b/src/runtime.ts index 9c401ab..cf110e7 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -7,12 +7,14 @@ import { progressEvent, RuntimeAuthentication } from './runtime-authentication' import { RuntimeCall, RuntimeOperationError } from './runtime-call' import { DAEMON_PROTOCOL_VERSION } from './runtime-protocol' import { RuntimeSessionPool } from './runtime-session-pool' +import { SchemaUpkeep } from './schema-upkeep' import { MCPX_VERSION } from './version' export class McpRuntime { readonly #stores: RuntimeStores readonly #sessions: RuntimeSessionPool readonly #authentication: RuntimeAuthentication + readonly #upkeep: SchemaUpkeep constructor( stores: RuntimeStores, @@ -24,9 +26,23 @@ export class McpRuntime { this.#stores = stores this.#authentication = options.authentication ?? new RuntimeAuthentication(stores) + this.#upkeep = new SchemaUpkeep(stores, () => this.#sessions) this.#sessions = options.sessions ?? - new RuntimeSessionPool(stores, { authentication: this.#authentication }) + new RuntimeSessionPool(stores, { + authentication: this.#authentication, + onSchemaSignal: this.#upkeep.signal, + }) + } + + /** Background re-listing of stale schemas; never opens a browser. */ + sweepSchemas(staleMs?: number): Promise { + return this.#upkeep.sweep(staleMs) + } + + /** Settles once every background schema listing started so far is done. */ + schemaUpkeepIdle(): Promise { + return this.#upkeep.idle() } async cleanupIdleSessions(maxIdleMs: number): Promise { @@ -75,6 +91,7 @@ export class McpRuntime { return } case 'stop': + this.#upkeep.close() await Promise.all([ this.#sessions.close(), this.#authentication.close(), @@ -85,13 +102,11 @@ export class McpRuntime { result: { stopping: true }, }) return - case 'refreshServers': + case 'syncSchemas': try { - const outcome = await this.#refreshServers( - intent.serverNames, - caller, - { signIn: intent.signIn === true }, - ) + const outcome = await this.#syncSchemas(intent.serverNames, caller, { + signIn: intent.signIn === true, + }) if (outcome.status === 'disconnected') return await caller.send({ requestId: intent.requestId, @@ -128,7 +143,7 @@ export class McpRuntime { result.server.transport !== 'stdio' && result.server.auth.kind === 'oauth' ) { - const outcome = await this.#refreshServers( + const outcome = await this.#syncSchemas( [intent.serverName], caller, { signIn: false }, @@ -195,13 +210,17 @@ export class McpRuntime { } } } - async #refreshServers( - serverNames: string[] | undefined, + /** + * Makes each server's credential usable — interactively, since someone is + * waiting — and lists its tools into the Schema Cache. Used by `@add`, + * `@login`, and a CLI that met a tool or field its cache did not know. + */ + async #syncSchemas( + names: string[], caller: RuntimeCaller, options: { signIn: boolean }, - ): Promise { + ): Promise { const registry = await this.#stores.registry.read() - const names = serverNames ?? Object.keys(registry.servers).sort() const missing = names.filter((name) => !registry.servers[name]) if (missing.length > 0) { throw new RuntimeOperationError( @@ -236,11 +255,11 @@ export class McpRuntime { }) .catch(() => {}) } - const refreshed = new Set() - const failed = new Map() + const synced = new Set() + const failed = new Map() try { - await forEachConcurrent(names, REFRESH_CONCURRENCY, async (name) => { + await forEachConcurrent(names, SYNC_CONCURRENCY, async (name) => { if (disconnected || stopped) return try { report(name, 'checking', 'checking credentials') @@ -266,7 +285,7 @@ export class McpRuntime { }, } }) - refreshed.add(name) + synced.add(name) report( name, 'ok', @@ -311,7 +330,7 @@ export class McpRuntime { if (disconnected) return { status: 'disconnected' } return { status: 'completed', - refreshed: names.filter((name) => refreshed.has(name)), + synced: names.filter((name) => synced.has(name)), failed: names.flatMap((name) => { const failure = failed.get(name) return failure ? [failure] : [] @@ -320,22 +339,22 @@ export class McpRuntime { } } -export type RuntimeRefreshOutcome = +export type RuntimeSyncOutcome = | { status: 'completed' - refreshed: string[] - failed: RuntimeRefreshFailure[] + synced: string[] + failed: RuntimeSyncFailure[] } | { status: 'disconnected' } -export type RuntimeRefreshFailure = { +export type RuntimeSyncFailure = { serverName: string message: string } // Servers are independent; a bounded fan-out keeps one slow browser flow from // holding every other server hostage without opening a page per server at once. -const REFRESH_CONCURRENCY = 4 +const SYNC_CONCURRENCY = 4 async function forEachConcurrent( items: T[], diff --git a/src/schema-upkeep.ts b/src/schema-upkeep.ts new file mode 100644 index 0000000..4d7528b --- /dev/null +++ b/src/schema-upkeep.ts @@ -0,0 +1,184 @@ +import type { RuntimeCaller } from './runtime-caller' +import type { RuntimeSessionPool, SchemaSignal } from './runtime-session-pool' +import type { RuntimeStores } from './runtime-stores' + +import { normalizeTools } from './discovery' +import { RuntimeOperationError } from './runtime-call' + +/** A schema listed more recently than this is not re-listed on reconnect. */ +const CONNECTED_FRESH_MS = 60 * 60 * 1000 +/** A rejection re-lists at most this often, so a broken call cannot loop. */ +const REJECTED_FRESH_MS = 60 * 1000 +/** The sweep re-lists every server whose schema is older than this. */ +export const SWEEP_STALE_MS = 7 * 24 * 60 * 60 * 1000 +const SWEEP_CONCURRENCY = 2 + +/** + * Keeps the Schema Cache close to what servers actually expose, in the + * background, without ever opening a browser (ADR 0011). The cache is a hint + * for agents; servers stay the authority on what a call accepts. + * + * - A session (re)connect re-lists a schema older than an hour; + * - An Invalid Params rejection re-lists right away; + * - A sweep re-lists every schema older than a week. + * + * One mcpxd per user and one in-flight listing per server make the upkeep + * exclusive: concurrent hints for a server share its listing. + */ +export class SchemaUpkeep { + readonly #stores: RuntimeStores + readonly #sessions: () => RuntimeSessionPool + readonly #inFlight = new Map>() + readonly #hints = new Set>() + #sweeping: Promise | undefined + #closed = false + + constructor(stores: RuntimeStores, sessions: () => RuntimeSessionPool) { + this.#stores = stores + this.#sessions = sessions + } + + /** The session pool's hint that a server's schema may be stale. */ + readonly signal = (signal: SchemaSignal): void => { + const hint = this.#relistIfOlderThan( + signal.serverName, + signal.reason === 'connected' ? CONNECTED_FRESH_MS : REJECTED_FRESH_MS, + ) + .catch(() => {}) + .finally(() => { + this.#hints.delete(hint) + }) + this.#hints.add(hint) + } + + /** Re-lists every server whose schema is older than `staleMs`. Exclusive. */ + sweep(staleMs: number = SWEEP_STALE_MS): Promise { + this.#sweeping ??= this.#sweep(staleMs).finally(() => { + this.#sweeping = undefined + }) + return this.#sweeping + } + + /** Resolves once every listing started so far has settled. */ + async idle(): Promise { + while (this.#hints.size > 0 || this.#inFlight.size > 0 || this.#sweeping) + await Promise.all([ + this.#sweeping, + ...this.#hints, + ...this.#inFlight.values(), + ]) + } + + close(): void { + this.#closed = true + } + + async #sweep(staleMs: number): Promise { + const { registry, schemas } = await this.#stores.readState() + const now = Date.now() + const stale = Object.keys(registry.servers) + .sort() + .filter( + (name) => + now - listedAt(schemas.servers[name]?.discoveredAt) >= staleMs, + ) + let next = 0 + await Promise.all( + Array.from( + { length: Math.min(SWEEP_CONCURRENCY, stale.length) }, + async () => { + while (next < stale.length && !this.#closed) + await this.#relist(stale[next++] as string) + }, + ), + ) + } + + async #relistIfOlderThan(serverName: string, freshMs: number): Promise { + if (this.#closed || this.#inFlight.has(serverName)) return + const cached = (await this.#stores.schemas.read()).servers[serverName] + const lastTry = Math.max( + listedAt(cached?.discoveredAt), + listedAt(cached?.refreshStatus?.checkedAt), + ) + if (Date.now() - lastTry < freshMs) return + await this.#relist(serverName) + } + + #relist(serverName: string): Promise { + const running = this.#inFlight.get(serverName) + if (running) return running + const run = this.#list(serverName).finally(() => { + this.#inFlight.delete(serverName) + }) + this.#inFlight.set(serverName, run) + return run + } + + async #list(serverName: string): Promise { + if (this.#closed) return + const checkedAt = new Date().toISOString() + try { + const tools = await this.#sessions().listTools( + serverName, + backgroundCaller(serverName), + { interactive: false }, + ) + await this.#stores.updateState((state) => { + if (!state.registry.servers[serverName]) return + state.schemas.servers[serverName] = { + tools: normalizeTools(tools), + discoveredAt: checkedAt, + refreshStatus: { checkedAt, status: 'ok' }, + } + }) + } catch (error) { + // Keep the last good tools; record why this check failed. A credential + // the next real call can repair is not upkeep's to fix. + const failure = + error instanceof RuntimeOperationError + ? error + : new RuntimeOperationError( + 'operation-failed', + error instanceof Error ? error.message : String(error), + ) + if (failure.code === 'cancelled') return + await this.#stores + .updateState((state) => { + if (!state.registry.servers[serverName]) return + state.schemas.servers[serverName] = { + ...state.schemas.servers[serverName], + refreshStatus: { + checkedAt, + status: + failure.code === 'reauth-required' + ? 'reauth-required' + : 'unreachable', + message: failure.message, + }, + } + }) + .catch(() => {}) + } + } +} + +function listedAt(value: string | undefined): number { + const time = value ? Date.parse(value) : Number.NaN + return Number.isFinite(time) ? time : 0 +} + +/** Nobody waits on upkeep: no input, no disconnect, frames are dropped. */ +function backgroundCaller(serverName: string): RuntimeCaller { + return { + id: `upkeep:${serverName}:${crypto.randomUUID()}`, + onDisconnect: () => () => {}, + requestInput: async () => { + throw new RuntimeOperationError( + 'reauth-required', + 'Background schema upkeep cannot ask for input.', + ) + }, + send: async () => {}, + } +} diff --git a/src/skill-template.ts b/src/skill-template.ts index b84db8d..d717d89 100644 --- a/src/skill-template.ts +++ b/src/skill-template.ts @@ -271,17 +271,15 @@ When browser authorization is needed — including the first call to a protected tool on a server that lists its tools anonymously — the call prints the authorization URL to stderr and waits up to 5 minutes; share that URL with the user and let the call finish. If a call still returns \`reauth-required\` (for example, a provider -that needs a manual OAuth client and no terminal is available), run: +that needs a manual OAuth client and no terminal is available), or the user +wants a different account, run: \`\`\`bash -mcpx @refresh +mcpx @login "{ name: '' }" \`\`\` If the current session is not a TTY, ask the user to run it in their terminal. -To sign in with a different account, run -\`mcpx @login "{ name: '' }"\` and share the printed URL with the user. - Once \`mcpx\` lists the server and a focused \`@schema\` call succeeds, return to the skill. ` diff --git a/tests/daemon-client.test.ts b/tests/daemon-client.test.ts index d0f1a6e..75c2399 100644 --- a/tests/daemon-client.test.ts +++ b/tests/daemon-client.test.ts @@ -46,12 +46,12 @@ describe('MCP Runtime process client', () => { mainPath, )) as { protocolVersion: number; activeServers: number } - expect(status.protocolVersion).toBe(3) + expect(status.protocolVersion).toBe(4) expect(status.activeServers).toBe(0) expect((await fs.stat(daemonSocketPath())).mode & 0o777).toBe(0o600) }) - it('stops an older protocol daemon before starting v3', async () => { + it('stops an older protocol daemon before starting v4', async () => { await startFakeDaemon({ protocolVersion: 2, version: '0.0.0' }) const status = (await requestRuntime( @@ -59,7 +59,7 @@ describe('MCP Runtime process client', () => { mainPath, )) as { protocolVersion: number } - expect(status.protocolVersion).toBe(3) + expect(status.protocolVersion).toBe(4) expect(fakeDaemon).toBeUndefined() }) @@ -84,7 +84,7 @@ describe('MCP Runtime process client', () => { try { expect(await requestJsonLine(socket, helloMessage())).toMatchObject({ ok: true, - protocolVersion: 3, + protocolVersion: 4, }) expect( await requestJsonLine(socket, { requestId: 'first', op: 'status' }), @@ -128,7 +128,7 @@ describe('MCP Runtime process client', () => { it('rejects when a daemon closes before sending a terminal frame', async () => { await startFakeDaemon({ - protocolVersion: 3, + protocolVersion: 4, version: '0.9.15', closeOnIntent: true, }) @@ -142,7 +142,7 @@ describe('MCP Runtime process client', () => { it('aborts an active CLI input provider after the terminal frame', async () => { await startFakeDaemon({ - protocolVersion: 3, + protocolVersion: 4, version: '0.9.15', inputThenTerminal: true, }) diff --git a/tests/oauth-challenge.test.ts b/tests/oauth-challenge.test.ts index d4ffde4..f7008b0 100644 --- a/tests/oauth-challenge.test.ts +++ b/tests/oauth-challenge.test.ts @@ -167,7 +167,7 @@ describe('OAuth discovered from a call-time challenge', () => { // The grant still works; a refresh leaves it alone, a sign-in does not. const refresh = createInMemoryRuntimeCaller('refresh') await runtime.handle( - { requestId: 'refresh', op: 'refreshServers', serverNames: ['wb'] }, + { requestId: 'refresh', op: 'syncSchemas', serverNames: ['wb'] }, refresh, ) expect(browserUrls).toHaveLength(1) @@ -175,7 +175,7 @@ describe('OAuth discovered from a call-time challenge', () => { await runtime.handle( { requestId: 'login', - op: 'refreshServers', + op: 'syncSchemas', serverNames: ['wb'], signIn: true, }, @@ -183,7 +183,7 @@ describe('OAuth discovered from a call-time challenge', () => { ) expect(login.frames.find((frame) => frame.kind === 'result')).toMatchObject( { - result: { status: 'completed', refreshed: ['wb'], failed: [] }, + result: { status: 'completed', synced: ['wb'], failed: [] }, }, ) expect(browserUrls).toHaveLength(2) diff --git a/tests/router.test.ts b/tests/router.test.ts index 31ce4b4..ee65c88 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -12,7 +12,6 @@ describe('router', () => { '@add', '@daemon', '@login', - '@refresh', '@remove', '@skill-install', ]) diff --git a/tests/runtime-authentication.test.ts b/tests/runtime-authentication.test.ts index 02a0f88..430dbd8 100644 --- a/tests/runtime-authentication.test.ts +++ b/tests/runtime-authentication.test.ts @@ -70,7 +70,7 @@ describe('Runtime authentication', () => { runtime.handle( { requestId: caller.id, - op: 'refreshServers', + op: 'syncSchemas', serverNames: ['fixture'], }, caller, @@ -124,11 +124,11 @@ describe('Runtime authentication', () => { }, } const firstRun = runtime.handle( - { requestId: 'first', op: 'refreshServers', serverNames: ['fixture'] }, + { requestId: 'first', op: 'syncSchemas', serverNames: ['fixture'] }, first, ) const secondRun = runtime.handle( - { requestId: 'second', op: 'refreshServers', serverNames: ['fixture'] }, + { requestId: 'second', op: 'syncSchemas', serverNames: ['fixture'] }, second, ) await waitFor(() => callbackOpen && secondJoined) @@ -166,7 +166,11 @@ describe('Runtime authentication', () => { const caller = createInMemoryRuntimeCaller('partial-refresh') await runtime.handle( - { requestId: 'partial-refresh', op: 'refreshServers' }, + { + requestId: 'partial-refresh', + op: 'syncSchemas', + serverNames: ['broken', 'working'], + }, caller, ) @@ -176,7 +180,7 @@ describe('Runtime authentication', () => { kind: 'result', result: { status: 'completed', - refreshed: ['working'], + synced: ['working'], failed: [ { serverName: 'broken', @@ -416,7 +420,7 @@ describe('Runtime authentication', () => { error: { code: 'reauth-required', message: - 'Credentials for fixture must be refreshed: OAuth client credentials must be entered interactively (Interactive terminal required.). Run mcpx @refresh in a terminal', + 'Credentials for fixture must be refreshed: OAuth client credentials must be entered interactively (Interactive terminal required.). Run mcpx @login in a terminal', }, }, ]) @@ -445,7 +449,7 @@ describe('Runtime authentication', () => { } await runtime.handle( - { requestId: 'manual', op: 'refreshServers', serverNames: ['fixture'] }, + { requestId: 'manual', op: 'syncSchemas', serverNames: ['fixture'] }, caller, ) diff --git a/tests/runtime-cli.test.ts b/tests/runtime-cli.test.ts index e0b603f..a816857 100644 --- a/tests/runtime-cli.test.ts +++ b/tests/runtime-cli.test.ts @@ -109,7 +109,7 @@ describe('Runtime CLI adapter lifecycle', () => { for (const child of children) expect(isAlive(child.pid)).toBe(false) }, 10_000) - it('single-flights five explicit refresh CLI processes through one local token request', async () => { + it('single-flights five CLI calls to an uncached tool through one local token request', async () => { // Keep the flow active while independently booting Bun processes reach the // warm daemon; the assertion is about overlapping callers, not loader speed. const fixture = startHttpFixture({ tokenDelayMs: 500 }) @@ -118,7 +118,7 @@ describe('Runtime CLI adapter lifecycle', () => { // processes connect; start the daemon so this test isolates flow sharing. expect((await runCli(['@daemon.status', ...rawContext])).exitCode).toBe(0) const refreshes = Array.from({ length: 5 }, () => - spawnObserved(['@refresh', ...rawContext]), + spawnObserved(['controlled.echo', ...rawContext]), ) const results = await Promise.all(refreshes.map(waitForExit)) @@ -131,10 +131,10 @@ describe('Runtime CLI adapter lifecycle', () => { for (const child of refreshes) expect(isAlive(child.pid)).toBe(false) }, 10_000) - it('cancels an active refresh flow before Runtime stop completes', async () => { + it('cancels an active authorization flow before Runtime stop completes', async () => { const fixture = startHttpFixture({ holdToken: true }) await seedExpiredOAuth(fixture.url, fixture.issuer) - const refresh = spawnObserved(['@refresh', ...rawContext]) + const refresh = spawnObserved(['controlled.echo', ...rawContext]) await waitFor(async () => fixture.tokenRequests() === 1) const stopped = await runCli(['@daemon.stop', ...rawContext]) diff --git a/tests/runtime-protocol.test.ts b/tests/runtime-protocol.test.ts index 1372205..d119643 100644 --- a/tests/runtime-protocol.test.ts +++ b/tests/runtime-protocol.test.ts @@ -61,12 +61,12 @@ describe('Runtime protocol v3', () => { expect( parseRuntimeIntent({ requestId: 'request-1', - op: 'refreshServers', + op: 'syncSchemas', serverNames: ['fixture'], }), ).toEqual({ requestId: 'request-1', - op: 'refreshServers', + op: 'syncSchemas', serverNames: ['fixture'], }) diff --git a/tests/schema-upkeep.test.ts b/tests/schema-upkeep.test.ts new file mode 100644 index 0000000..8ae6744 --- /dev/null +++ b/tests/schema-upkeep.test.ts @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import fs from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import type { McpConnection } from '../src/mcp-client' + +import { jsonSchemaToStandardSchema } from '../src/json-schema-standard' +import { RuntimeAuthentication } from '../src/runtime-authentication' +import { RuntimeCall } from '../src/runtime-call' +import { createInMemoryRuntimeCaller } from '../src/runtime-caller' +import { RuntimeSessionPool } from '../src/runtime-session-pool' +import { openRuntimeStores } from '../src/runtime-stores' +import { SchemaUpkeep } from '../src/schema-upkeep' + +const DAY = 24 * 60 * 60 * 1000 + +describe('Schema upkeep', () => { + const roots: string[] = [] + + afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => fs.rm(root, { recursive: true })), + ) + }) + + it('sweeps only schemas older than a week, keeping last good tools on failure', async () => { + const stores = await createStores({ + stale: server('stale', ago(8 * DAY)), + fresh: server('fresh', ago(DAY)), + down: server('down', ago(30 * DAY)), + }) + const listed: string[] = [] + const { upkeep } = upkeepWith(stores, (url) => { + const name = new URL(url).pathname.slice(1) + listed.push(name) + if (name === 'down') throw new Error('connection refused') + return [{ name: 'echo' }, { name: 'added' }] + }) + + // Concurrent sweeps share one pass: the upkeep is exclusive. + await Promise.all([upkeep.sweep(), upkeep.sweep()]) + + expect(listed.sort()).toEqual(['down', 'stale']) + const { schemas } = await stores.readState() + expect(schemas.servers.stale?.tools?.map((tool) => tool.name)).toEqual([ + 'echo', + 'added', + ]) + expect(schemas.servers.stale?.refreshStatus?.status).toBe('ok') + expect(schemas.servers.fresh?.tools?.map((tool) => tool.name)).toEqual([ + 'echo', + ]) + expect(schemas.servers.down?.tools?.map((tool) => tool.name)).toEqual([ + 'echo', + ]) + expect(schemas.servers.down?.refreshStatus).toMatchObject({ + status: 'unreachable', + message: expect.stringContaining('connection refused'), + }) + }) + + it('never opens a browser for an OAuth server whose token expired', async () => { + const stores = await createStores( + { + locked: { + url: 'http://127.0.0.1:1/locked', + auth: { kind: 'oauth' }, + tools: [{ name: 'echo', commandName: 'echo' }], + discoveredAt: ago(30 * DAY), + }, + }, + { + locked: { + tokens: { + value: { access_token: 'old', token_type: 'Bearer' }, + expiresAt: '2000-01-01T00:00:00.000Z', + }, + }, + }, + ) + let authorizations = 0 + const { upkeep } = upkeepWith(stores, () => [{ name: 'echo' }], { + authorizeOAuth: async () => { + authorizations += 1 + }, + }) + + await upkeep.sweep() + + expect(authorizations).toBe(0) + const { schemas } = await stores.readState() + expect(schemas.servers.locked?.refreshStatus?.status).toBe( + 'reauth-required', + ) + }) + + it('re-lists on reconnect when the schema is over an hour old, and on Invalid Params', async () => { + const stores = await createStores({ + fixture: server('fixture', ago(2 * 60 * 60 * 1000)), + }) + let lists = 0 + let reject = false + const { upkeep, pool } = upkeepWith( + stores, + () => { + lists += 1 + return [{ name: 'echo' }] + }, + {}, + async () => { + if (reject) + throw Object.assign(new Error('Unknown tool: echo'), { + code: -32602, + }) + return { content: [{ type: 'text', text: 'ok' }] } + }, + ) + + await callEcho(pool) + await upkeep.idle() + expect(lists).toBe(1) + + // Fresh now: another call on the same session does not re-list. + await callEcho(pool) + await upkeep.idle() + expect(lists).toBe(1) + + // A rejection re-lists at once, but not more than once a minute. + reject = true + await callEcho(pool) + await upkeep.idle() + expect(lists).toBe(1) + await stores.updateState((state) => { + state.schemas.servers.fixture = { + ...state.schemas.servers.fixture, + discoveredAt: ago(2 * 60 * 1000), + refreshStatus: { checkedAt: ago(2 * 60 * 1000), status: 'ok' }, + } + }) + await callEcho(pool) + await upkeep.idle() + expect(lists).toBe(2) + }) + + it('judges a missing required field by the server schema the CLI re-synced', async () => { + let resyncs = 0 + const stale = jsonSchemaToStandardSchema( + { type: 'object', required: ['removed'] }, + { + resync: async () => { + resyncs += 1 + return { type: 'object', required: ['query'] } + }, + }, + ) + + expect(await stale['~standard'].validate({ query: 'x' })).toEqual({ + value: { query: 'x' }, + }) + expect(await stale['~standard'].validate({})).toMatchObject({ + issues: [{ message: 'Missing required field "query".' }], + }) + // Valid input never asks the server. + await stale['~standard'].validate({ removed: 1 }) + expect(resyncs).toBe(2) + }) + + function upkeepWith( + stores: Awaited>, + listTools: (url: string) => Array<{ name: string }>, + authentication: ConstructorParameters[1] = {}, + callTool: (...args: any[]) => Promise = async () => ({ + content: [{ type: 'text', text: 'ok' }], + }), + ) { + let pool: RuntimeSessionPool | undefined + const upkeep = new SchemaUpkeep(stores, () => pool!) + pool = new RuntimeSessionPool(stores, { + authentication: new RuntimeAuthentication(stores, authentication), + onSchemaSignal: upkeep.signal, + connect: async (config) => { + const url = config.transport === 'stdio' ? '' : config.url + return fakeConnection(async () => ({ tools: listTools(url) }), callTool) + }, + }) + return { upkeep, pool } + } + + async function createStores( + servers: Record, + oauth: Record = {}, + ) { + const root = await fs.mkdtemp(path.join(tmpdir(), 'mcpx-upkeep-')) + roots.push(root) + await fs.writeFile( + path.join(root, 'servers.json'), + JSON.stringify({ version: 1, servers }), + ) + const stores = await openRuntimeStores(root) + if (Object.keys(oauth).length > 0) + await stores.updateState((state) => { + Object.assign(state.credentials.oauth, oauth) + }) + return stores + } +}) + +function server(name: string, discoveredAt: string) { + return { + url: `http://127.0.0.1:1/${name}`, + auth: { kind: 'none' }, + tools: [{ name: 'echo', commandName: 'echo' }], + discoveredAt, + } +} + +function ago(ms: number): string { + return new Date(Date.now() - ms).toISOString() +} + +async function callEcho(pool: RuntimeSessionPool): Promise { + const call = new RuntimeCall(createInMemoryRuntimeCaller(crypto.randomUUID())) + await pool + .call(call, { serverName: 'fixture', toolName: 'echo', input: {} }) + .catch(() => {}) +} + +function fakeConnection( + listTools: () => Promise<{ tools: Array<{ name: string }> }>, + callTool: (...args: any[]) => Promise, +): McpConnection { + return { + client: { callTool, listTools } as unknown as McpConnection['client'], + close: async () => {}, + pid: () => null, + stderr: null, + sessionId: () => 'fixture-session', + updateHeaders: () => {}, + updateToken: () => {}, + } +} diff --git a/tests/skill-template.test.ts b/tests/skill-template.test.ts index bfb8ef4..a8ac219 100644 --- a/tests/skill-template.test.ts +++ b/tests/skill-template.test.ts @@ -79,7 +79,8 @@ describe('mcpx skill template', () => { expect(servers).toContain( `mcpx @add '{"name":"sentry","url":"https://mcp.sentry.dev/mcp"}'`, ) - expect(servers).toContain('mcpx @refresh') + expect(servers).toContain(`mcpx @login "{ name: '' }"`) + expect(servers).not.toContain('@refresh') }) it('builds temporary mcpx skill markdown without project-local wording', () => {