diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 5fa2e14d94..892f74c1d5 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -232,6 +232,10 @@ export class Client extends Protocol { private _cachedToolOutputValidators: Map> = new Map(); private _cachedKnownTaskTools: Set = new Set(); private _cachedRequiredTaskTools: Set = new Set(); + /** Epoch ms after which the tool-metadata cache is stale (from SEP-2549 list `ttl`). */ + private _toolCacheExpiresAt: number | undefined; + /** Set when the SEP-2549 ttl has elapsed. The cache is NOT cleared on expiry (validators stay enforced); this flag tells callers a refresh is recommended. */ + private _toolCacheStale = false; private _experimental?: { tasks: ExperimentalClientTasks }; private _listChangedDebounceTimers: Map> = new Map(); private _pendingListChangedConfig?: ListChangedHandlers; @@ -924,11 +928,34 @@ export class Client extends Protocol { return this._cachedRequiredTaskTools.has(toolName); } + /** + * Marks the tool-metadata cache stale when the SEP-2549 `ttl` has elapsed. + * Does NOT clear the caches: validators and required-task guards stay enforced + * until `listTools()` repopulates them. TTL is a freshness hint for re-fetching, + * not a signal to drop client-side enforcement (a server-supplied `ttl: 0` must + * not bypass output validation). + */ + private _maybeExpireToolCache(): void { + if (this._toolCacheExpiresAt !== undefined && Date.now() >= this._toolCacheExpiresAt) { + this._toolCacheStale = true; + this._toolCacheExpiresAt = undefined; + } + } + + /** True when the tool list should be re-fetched (SEP-2549 `ttl` elapsed). Validators remain enforced while stale. */ + get isToolCacheStale(): boolean { + this._maybeExpireToolCache(); + return this._toolCacheStale; + } + /** * Cache validators for tool output schemas. * Called after {@linkcode listTools | listTools()} to pre-compile validators for better performance. */ - private cacheToolMetadata(tools: Tool[]): void { + private cacheToolMetadata(tools: Tool[], ttlSeconds?: number): void { + // Clamp to >= 1s so a server cannot force immediate expiry of the validator cache. + this._toolCacheExpiresAt = ttlSeconds === undefined ? undefined : Date.now() + Math.max(1, ttlSeconds) * 1000; + this._toolCacheStale = false; this._cachedToolOutputValidators.clear(); this._cachedKnownTaskTools.clear(); this._cachedRequiredTaskTools.clear(); @@ -988,7 +1015,7 @@ export class Client extends Protocol { const result = await this._requestWithSchema({ method: 'tools/list', params }, ListToolsResultSchema, options); // Cache the tools and their output schemas for future validation - this.cacheToolMetadata(result.tools); + this.cacheToolMetadata(result.tools, result.ttl); return result; } diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index a243c1b829..3af6f463d5 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -613,7 +613,13 @@ export const PaginatedResultSchema = ResultSchema.extend({ * An opaque token representing the pagination position after the last returned result. * If present, there may be more results available. */ - nextCursor: CursorSchema.optional() + nextCursor: CursorSchema.optional(), + /** + * How long (in seconds) this result may be considered fresh before re-fetching (SEP-2549). + * Allows clients to cache list responses and poll on a predictable schedule, supplementing + * (not replacing) the `list_changed` notification mechanism. + */ + ttl: z.number().int().nonnegative().optional() }); /** diff --git a/packages/core/test/spec.types.test.ts b/packages/core/test/spec.types.test.ts index d26a4cd701..97b43cd1d5 100644 --- a/packages/core/test/spec.types.test.ts +++ b/packages/core/test/spec.types.test.ts @@ -831,6 +831,7 @@ type _K_ElicitRequestURLParams = Assert>; type _K_BaseMetadata = Assert>; type _K_Implementation = Assert>; +// @ts-expect-error SEP-2549: SDK adds optional `ttl` ahead of upstream spec; remove once spec.types.ts has it type _K_PaginatedResult = Assert>; type _K_ListRootsResult = Assert>; type _K_Root = Assert>; @@ -847,9 +848,12 @@ type _K_ResourceTemplateReference = Assert>; type _K_ToolAnnotations = Assert>; type _K_Tool = Assert>; +// @ts-expect-error SEP-2549: SDK adds optional `ttl` ahead of upstream spec; remove once spec.types.ts has it type _K_ListToolsResult = Assert>; type _K_CallToolResult = Assert>; +// @ts-expect-error SEP-2549: SDK adds optional `ttl` ahead of upstream spec; remove once spec.types.ts has it type _K_ListResourcesResult = Assert>; +// @ts-expect-error SEP-2549: SDK adds optional `ttl` ahead of upstream spec; remove once spec.types.ts has it type _K_ListResourceTemplatesResult = Assert>; type _K_ReadResourceResult = Assert>; type _K_ResourceContents = Assert>; @@ -859,6 +863,7 @@ type _K_Resource = Assert // @ts-expect-error Genuine mismatch: SDK PromptArgument is missing 'title' from spec type _K_PromptArgument = Assert>; type _K_Prompt = Assert>; +// @ts-expect-error SEP-2549: SDK adds optional `ttl` ahead of upstream spec; remove once spec.types.ts has it type _K_ListPromptsResult = Assert>; type _K_GetPromptResult = Assert>; type _K_TextContent = Assert>; @@ -902,6 +907,7 @@ type _K_RelatedTaskMetadata = Assert>; type _K_CreateTaskResult = Assert>; type _K_GetTaskResult = Assert>; +// @ts-expect-error SEP-2549: SDK adds optional `ttl` ahead of upstream spec; remove once spec.types.ts has it type _K_ListTasksResult = Assert>; type _K_CancelTaskResult = Assert>; type _K_GetTaskPayloadResult = Assert>; diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index fb45fd5db6..0dc6a32071 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -73,9 +73,18 @@ export class McpServer { private _registeredTools: { [name: string]: RegisteredTool } = {}; private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; private _experimental?: { tasks: ExperimentalMcpServerTasks }; + private readonly _listTtl: number | undefined; constructor(serverInfo: Implementation, options?: ServerOptions) { this.server = new Server(serverInfo, options); + // Wire schema is `.int().nonnegative()`; coerce so a fractional or negative + // option value does not make every client `listTools()` throw a Zod error. + this._listTtl = options?.listTtlSeconds === undefined ? undefined : Math.max(0, Math.round(options.listTtlSeconds)); + } + + /** Builds the SEP-2549 `ttl` slot for paginated list results when configured. */ + private _ttl(): { ttl?: number } { + return this._listTtl === undefined ? {} : { ttl: this._listTtl }; } /** @@ -136,6 +145,7 @@ export class McpServer { this.server.setRequestHandler( 'tools/list', (): ListToolsResult => ({ + ...this._ttl(), tools: Object.entries(this._registeredTools) .filter(([, tool]) => tool.enabled) .map(([name, tool]): Tool => { @@ -467,7 +477,7 @@ export class McpServer { } } - return { resources: [...resources, ...templateResources] }; + return { ...this._ttl(), resources: [...resources, ...templateResources] }; }); this.server.setRequestHandler('resources/templates/list', async () => { @@ -477,7 +487,7 @@ export class McpServer { ...template.metadata })); - return { resourceTemplates }; + return { ...this._ttl(), resourceTemplates }; }); this.server.setRequestHandler('resources/read', async (request, ctx) => { @@ -525,6 +535,7 @@ export class McpServer { this.server.setRequestHandler( 'prompts/list', (): ListPromptsResult => ({ + ...this._ttl(), prompts: Object.entries(this._registeredPrompts) .filter(([, prompt]) => prompt.enabled) .map(([name, prompt]): Prompt => { diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index f6a34f02da..4a8dd334bb 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -86,6 +86,14 @@ export type ServerOptions = ProtocolOptions & { * @default {@linkcode DefaultJsonSchemaValidator} ({@linkcode index.AjvJsonSchemaValidator | AjvJsonSchemaValidator} on Node.js, `CfWorkerJsonSchemaValidator` on Cloudflare Workers) */ jsonSchemaValidator?: jsonSchemaValidator; + + /** + * Optional `ttl` (in seconds) included on `tools/list`, `prompts/list`, `resources/list`, + * and `resources/templates/list` responses (SEP-2549). Tells clients how long the response + * may be considered fresh; clients may cache and re-poll on this schedule. Supplements, does + * not replace, the `list_changed` notification mechanism. + */ + listTtlSeconds?: number; }; /** diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 92af09744c..fd7a818f59 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -512,6 +512,43 @@ describe('Zod v4', () => { ]); }); + /*** + * Test: SEP-2549 list TTL + */ + test('should include ttl on list results when listTtlSeconds is configured', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }, { listTtlSeconds: 60 }); + const client = new Client({ name: 'test client', version: '1.0' }); + + mcpServer.registerTool('t', {}, async () => ({ content: [] })); + mcpServer.registerPrompt('p', {}, async () => ({ messages: [] })); + mcpServer.registerResource('r', 'file:///r', {}, async () => ({ contents: [] })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const tools = await client.request({ method: 'tools/list' }); + expect(tools.ttl).toBe(60); + const prompts = await client.request({ method: 'prompts/list' }); + expect(prompts.ttl).toBe(60); + const resources = await client.request({ method: 'resources/list' }); + expect(resources.ttl).toBe(60); + const templates = await client.request({ method: 'resources/templates/list' }); + expect(templates.ttl).toBe(60); + }); + + test('should omit ttl on list results when listTtlSeconds is not configured', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }); + const client = new Client({ name: 'test client', version: '1.0' }); + + mcpServer.registerTool('t', {}, async () => ({ content: [] })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const tools = await client.request({ method: 'tools/list' }); + expect(tools).not.toHaveProperty('ttl'); + }); + /*** * Test: Updating Existing Tool */