diff --git a/.changeset/ai-mcp-prior-discover.md b/.changeset/ai-mcp-prior-discover.md new file mode 100644 index 0000000000..8bc6be379c --- /dev/null +++ b/.changeset/ai-mcp-prior-discover.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-mcp': minor +--- + +Reuse a protocol `2026-07-28` `server/discover` result. `MCPClient.getDiscoverResult()` returns the result of the connection, or the adopted prior. `createMCPClient({ prior })` passes a saved one to the SDK `Client.connect`, so the client skips the `server/discover` request. `DiscoverResult` and `PriorDiscovery` are exported as types. diff --git a/docs/config.json b/docs/config.json index ae37dc60fb..3b2524e5d2 100644 --- a/docs/config.json +++ b/docs/config.json @@ -565,7 +565,7 @@ "label": "Manual MCP", "to": "tools/mcp-manual", "addedAt": "2026-06-05", - "updatedAt": "2026-10-21" + "updatedAt": "2026-10-22" }, { "label": "MCP Type Generation", diff --git a/docs/tools/mcp-manual.md b/docs/tools/mcp-manual.md index 9c6a28d008..224230c45c 100644 --- a/docs/tools/mcp-manual.md +++ b/docs/tools/mcp-manual.md @@ -137,6 +137,42 @@ if ( To serve an extension, see [Serve an MCP extension](../mcp/server#serve-an-mcp-extension). +## Skip the discover request + +On protocol `2026-07-28`, the client sends `server/discover` when it connects. Some hosts open a new connection for each request. They pay for that round trip each time. + +Save the result per user or token, then pass it back as `prior`: + +```ts +import { createMCPClient } from '@tanstack/ai-mcp' +import type { DiscoverResult } from '@tanstack/ai-mcp' + +const MAX_AGE_MS = 5 * 60_000 +const saved = new Map() + +export async function connect(token: string) { + const hit = saved.get(token) + const fresh = hit !== undefined && Date.now() - hit.savedAt < MAX_AGE_MS + const mcp = await createMCPClient({ + transport: { + type: 'http', + url: process.env.MCP_URL!, + headers: { Authorization: `Bearer ${token}` }, + }, + prior: fresh ? { kind: 'modern', discover: hit.discover } : undefined, + }) + const discover = mcp.getDiscoverResult() + if (!fresh && discover) saved.set(token, { discover, savedAt: Date.now() }) + return mcp +} +``` + +- `getDiscoverResult()` returns the `server/discover` result of the connection. It is `undefined` when the server speaks protocol `2025-11-25`. +- The result is plain JSON, so you can keep it in a cache or a database. +- Do not share a saved result between users. The spec treats it as private to one authorization context. See [Caching](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching). +- You decide how long to keep it. A stale result fails on the first request after connect, not on connect. +- With `prior`, connect sends no request. A server that is down, or a token that is no longer valid, fails on the first call. + ## Resources MCP resources are context documents (files, database records, web pages) the server exposes. Fetch them and inject them into `chat()` as content parts. diff --git a/packages/ai-mcp/skills/ai-mcp/SKILL.md b/packages/ai-mcp/skills/ai-mcp/SKILL.md index feeae587a2..48b6511508 100644 --- a/packages/ai-mcp/skills/ai-mcp/SKILL.md +++ b/packages/ai-mcp/skills/ai-mcp/SKILL.md @@ -299,6 +299,7 @@ The client keeps negotiation mode `auto`. `client.instructions` holds the server's instructions from the handshake, or `undefined` when the server sends none. Put them in the system prompt: `systemPrompts: client.instructions ? [client.instructions] : []`. `client.request` takes the same arguments as the SDK `Client.request`, with the client's `requestOptions`. Use it for an MCP extension method: check `client.capabilities.extensions`, then pass the request and a result schema. +`client.getDiscoverResult()` returns the protocol `2026-07-28` `server/discover` result. Pass it back as `prior: { kind: 'modern', discover }` to `createMCPClient` to skip that request. Key the saved result by user or token, and expire it yourself. With `prior`, connect sends no request, so auth and reachability errors show up on the first call. Two more client options: diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index 6e6032f84d..7bf7bd6a3a 100644 --- a/packages/ai-mcp/src/client.ts +++ b/packages/ai-mcp/src/client.ts @@ -37,8 +37,10 @@ import type { } from './types' import type { ClientOptions, + DiscoverResult, GetPromptResult, McpSubscription, + PriorDiscovery, Prompt, ReadResourceResult, Request as McpRequest, @@ -79,6 +81,14 @@ export interface MCPClient< * Optional so an existing hand-rolled `MCPClient` keeps compiling. */ readonly instructions?: string + /** + * The `server/discover` result of this connection, or the `prior` it + * adopted. Save it and pass it back as `prior: { kind: 'modern', discover }` + * to skip the `server/discover` request on a later connect. `undefined` + * when the server speaks protocol `2025-11-25`, or after a + * `{ kind: 'legacy' }` prior. + */ + getDiscoverResult: () => DiscoverResult | undefined /** * Auto-discovery: every server tool as a ServerTool. With a generated * descriptor, tool names are typed as the descriptor's name literals; @@ -251,7 +261,7 @@ class MCPClientImpl< } } - async connect(transport: Transport): Promise { + async connect(transport: Transport, prior?: PriorDiscovery): Promise { try { // A tools/list_changed notice drops the cached definitions so the next // callTool reads each tool's execution mode again. @@ -263,10 +273,12 @@ class MCPClientImpl< ) // The connect handshake gets the request options too, so `timeout` // also limits a server that does not answer while it connects. - await this.#client.connect( - transport, - sdkRequestOptions(this.#policy.requestOptions), - ) + // With `prior`, the SDK skips the server/discover probe and sends + // nothing here, so a dead or unauthorized server fails on first use. + await this.#client.connect(transport, { + ...sdkRequestOptions(this.#policy.requestOptions), + prior, + }) this.capabilities = this.#client.getServerCapabilities() ?? {} this.instructions = this.#client.getInstructions() // A failed listen only means that tool changes are not pushed. @@ -513,6 +525,10 @@ class MCPClientImpl< ) }) as Client['request'] + getDiscoverResult(): DiscoverResult | undefined { + return this.#client.getDiscoverResult() + } + async close(): Promise { if (this.#closed) return this.#closed = true @@ -644,7 +660,7 @@ async function connectTransport< requestOptions: options.requestOptions, }, ) - await impl.connect(transport) + await impl.connect(transport, options.prior) return impl } diff --git a/packages/ai-mcp/src/index.ts b/packages/ai-mcp/src/index.ts index 2d9ca9cb14..a3975c86e1 100644 --- a/packages/ai-mcp/src/index.ts +++ b/packages/ai-mcp/src/index.ts @@ -12,6 +12,8 @@ export type { ToolsOptions, } from './types' export type { + DiscoverResult, + PriorDiscovery, Tool as McpTool, ToolAnnotations, } from '@modelcontextprotocol/client' diff --git a/packages/ai-mcp/src/types.ts b/packages/ai-mcp/src/types.ts index 380cd742eb..083243e11f 100644 --- a/packages/ai-mcp/src/types.ts +++ b/packages/ai-mcp/src/types.ts @@ -1,6 +1,7 @@ import type { ServerTool, ToolDefinition } from '@tanstack/ai' import type { ClientOptions, + PriorDiscovery, Tool as McpToolDef, ToolAnnotations, } from '@modelcontextprotocol/client' @@ -153,6 +154,16 @@ export interface MCPClientOptions { /** Restart the timeout when the server sends a progress notification. */ resetTimeoutOnProgress?: boolean } + /** + * A saved era verdict for the SDK `Client.connect`. Save the discover + * result from `MCPClient.getDiscoverResult()` and pass + * `{ kind: 'modern', discover }` to skip the `server/discover` request. + * Reuse it only for the same authorization context, and expire it + * yourself. With `prior`, connect sends no request, so a dead server or + * a revoked token fails on the first call. A prior with no shared + * protocol version throws `MCPConnectionError`. + */ + prior?: PriorDiscovery /** * Choose the server tools the model gets. Default: every tool. * diff --git a/packages/ai-mcp/tests/apps/call-handler.test.ts b/packages/ai-mcp/tests/apps/call-handler.test.ts index 5c5e0e0d69..73b82f843c 100644 --- a/packages/ai-mcp/tests/apps/call-handler.test.ts +++ b/packages/ai-mcp/tests/apps/call-handler.test.ts @@ -76,6 +76,7 @@ function fakeClient(info: ServerInfo): MCPClient { getPrompt: unreached('getPrompt'), callTool: unreached('callTool'), request: unreached('request'), + getDiscoverResult: unreached('getDiscoverResult'), getInfo: () => info, close: unreached('close'), [Symbol.asyncDispose]: unreached('asyncDispose'), diff --git a/packages/ai-mcp/tests/client.test.ts b/packages/ai-mcp/tests/client.test.ts index 7a31a32e74..8341defd86 100644 --- a/packages/ai-mcp/tests/client.test.ts +++ b/packages/ai-mcp/tests/client.test.ts @@ -64,6 +64,73 @@ describe('createMCPClient', () => { expect(client.instructions).toBeUndefined() }) + it('skips server/discover when it gets a saved discover result as prior', async () => { + const server = createMCPServer({ + name: 'weather', + version: '1.0.0', + tools: [ + toolDefinition({ + name: 'get_weather', + description: 'Weather for a city', + inputSchema: z.object({ city: z.string() }), + }).server(async ({ city }) => `Sunny in ${city}`), + ], + }) + const methods: Array = [] + const transport = { + type: 'http' as const, + url: 'https://mcp.example.com/mcp', + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + const body: unknown = await request + .clone() + .json() + .catch(() => undefined) + if (typeof body === 'object' && body !== null && 'method' in body) { + methods.push(String(body.method)) + } + return server.fetch(request) + }, + } + + const first = await createMCPClient({ transport }) + const discover = first.getDiscoverResult() + await first.close() + expect(methods).toContain('server/discover') + expect(discover?.supportedVersions).toContain('2026-07-28') + if (discover === undefined) throw new Error('No discover result') + + methods.length = 0 + await using next = await createMCPClient({ + transport, + prior: { kind: 'modern', discover }, + }) + expect(next.getDiscoverResult()).toEqual(discover) + const called = await next.callTool('get_weather', { city: 'Paris' }) + expect(called.content).toEqual([{ type: 'text', text: 'Sunny in Paris' }]) + expect(methods).not.toContain('server/discover') + expect(methods).toContain('tools/call') + + // A prior with no shared protocol version fails connect before any request. + methods.length = 0 + await expect( + createMCPClient({ + transport, + prior: { + kind: 'modern', + discover: { ...discover, supportedVersions: ['2025-11-25'] }, + }, + }), + ).rejects.toThrow(MCPConnectionError) + expect(methods).toEqual([]) + }) + + it('has no discover result on a spec 2025 server', async () => { + const { clientTransport } = await makeSilentServer() + await using client = await createMCPClientFromTransport(clientTransport) + expect(client.getDiscoverResult()).toBeUndefined() + }) + it('sends an extension method with request and refuses it after close', async () => { const server = createMCPServer({ name: 'greeter', diff --git a/testing/e2e/src/routes/api.mcp-typed-test.ts b/testing/e2e/src/routes/api.mcp-typed-test.ts index 05a37f45a5..7b62c4c5cf 100644 --- a/testing/e2e/src/routes/api.mcp-typed-test.ts +++ b/testing/e2e/src/routes/api.mcp-typed-test.ts @@ -46,6 +46,8 @@ async function* closeMcpOnDrain( * It also calls `forecast` with the typed `callTool`. * - GET `?extension` calls the `notes/count` extension method of the * stateless server with `request`. + * - GET `?prior` connects to the stateless server twice. The second client + * gets the first one's discover result as `prior`. * - POST runs chat() with those tools. */ export const Route = createFileRoute('/api/mcp-typed-test')({ @@ -73,6 +75,38 @@ export const Route = createFileRoute('/api/mcp-typed-test')({ await notes.close() } } + if (url.searchParams.has('prior')) { + let discovers = 0 + const transport = { + type: 'http' as const, + url: `${url.origin}/api/mcp-typed-server?stateless`, + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + const body = await request.clone().text() + if (body.includes('"server/discover"')) discovers++ + return fetch(request) + }, + } + const first = await createMCPClient({ transport }) + const discover = first.getDiscoverResult() + await first.close() + if (!discover) throw new Error('no discover result') + const firstDiscovers = discovers + const next = await createMCPClient({ + transport, + prior: { kind: 'modern', discover }, + }) + try { + const called = await next.callTool('show_note', {}) + return Response.json({ + firstDiscovers, + nextDiscovers: discovers - firstDiscovers, + content: called.content, + }) + } finally { + await next.close() + } + } const mcp = await connect(request, 'alice') try { const tools = await mcp.tools() diff --git a/testing/e2e/tests/mcp-typed.spec.ts b/testing/e2e/tests/mcp-typed.spec.ts index 0ab4654763..de6373668e 100644 --- a/testing/e2e/tests/mcp-typed.spec.ts +++ b/testing/e2e/tests/mcp-typed.spec.ts @@ -236,6 +236,19 @@ test.describe('mcp: createMCPServer with auth, output schema, and a task', () => }) }) + test('a client with a saved discover result as prior skips server/discover', async ({ + request, + }) => { + const res = await request.get('/api/mcp-typed-test?prior') + const body = await res.text() + expect(res.ok(), body).toBe(true) + expect(JSON.parse(body)).toEqual({ + firstDiscovers: 1, + nextDiscovers: 0, + content: [{ type: 'text', text: 'shown' }], + }) + }) + test('a stateless server serves spec 2025 with resource context and tool _meta', async ({ request, }) => {