From a70c4544f37add7b3b3106fbf3808f709c5fb6d1 Mon Sep 17 00:00:00 2001 From: Argjent Sahiti Date: Wed, 21 Oct 2026 22:31:56 +0200 Subject: [PATCH] feat(ai-mcp): reuse a saved server/discover result with prior MCPClient.getDiscoverResult() returns the spec 2026-07-28 server/discover result of the connection. createMCPClient({ prior }) passes a saved result to the SDK Client.connect, so a host that connects to the same server many times skips the discover round trip. DiscoverResult and PriorDiscovery are exported as types. --- .changeset/ai-mcp-prior-discover.md | 5 ++ docs/config.json | 2 +- docs/tools/mcp-manual.md | 25 ++++++++++ packages/ai-mcp/skills/ai-mcp/SKILL.md | 1 + packages/ai-mcp/src/client.ts | 28 ++++++++--- packages/ai-mcp/src/index.ts | 2 + packages/ai-mcp/src/types.ts | 18 +++++++ packages/ai-mcp/tests/client.test.ts | 49 ++++++++++++++++++++ testing/e2e/src/routes/api.mcp-typed-test.ts | 33 +++++++++++++ testing/e2e/tests/mcp-typed.spec.ts | 17 +++++++ 10 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 .changeset/ai-mcp-prior-discover.md diff --git a/.changeset/ai-mcp-prior-discover.md b/.changeset/ai-mcp-prior-discover.md new file mode 100644 index 0000000000..f93d5482b8 --- /dev/null +++ b/.changeset/ai-mcp-prior-discover.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-mcp': minor +--- + +Reuse a spec 2026 `server/discover` result. `MCPClient.getDiscoverResult()` returns the result of the connection, and `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 3e6326867d..19e53e3fc0 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-09-29" + "updatedAt": "2026-10-21" }, { "label": "MCP Type Generation", diff --git a/docs/tools/mcp-manual.md b/docs/tools/mcp-manual.md index 0fea893a99..4a5907fe7c 100644 --- a/docs/tools/mcp-manual.md +++ b/docs/tools/mcp-manual.md @@ -102,6 +102,31 @@ const stream = chat({ In a [multi-server pool](./mcp#per-server-access), each client has its own value: `pool.clients.github!.instructions`. +## Skip the discover request + +On spec 2026, the client sends `server/discover` when it connects. A host that connects to the same server again and again, for example one connection per request, pays for that round trip each time. Save the result once, then pass it back as `prior`: + +```ts +import { createMCPClient } from '@tanstack/ai-mcp' +import type { DiscoverResult } from '@tanstack/ai-mcp' + +const transport = { type: 'http' as const, url: process.env.MCP_URL! } +let saved: DiscoverResult | undefined + +export async function connect() { + const mcp = await createMCPClient({ + transport, + prior: saved ? { kind: 'modern', discover: saved } : undefined, + }) + saved ??= mcp.getDiscoverResult?.() + return mcp +} +``` + +- `getDiscoverResult()` returns the `server/discover` result of the connection. It is `undefined` when the server speaks spec 2025. +- The result is plain JSON, so you can keep it in a cache or a database. +- Reuse it only for the same user or token, and only for as long as its `ttlMs` and `cacheScope` allow. See [Caching](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching) in the MCP spec. + ## 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 d5b580c6b4..4b6a572bd5 100644 --- a/packages/ai-mcp/skills/ai-mcp/SKILL.md +++ b/packages/ai-mcp/skills/ai-mcp/SKILL.md @@ -297,6 +297,7 @@ If the server does not support that spec, the client uses the 2025 initialize ha 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.getDiscoverResult()` returns the spec 2026 `server/discover` result. Pass it back as `prior: { kind: 'modern', discover }` to `createMCPClient` to skip that request. Reuse it only for the same auth context, within its `ttlMs` and `cacheScope`. Two more client options: diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index 64726c9c3b..9b712a2214 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, Resource, @@ -75,6 +77,15 @@ 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 spec 2025. + * + * Optional so an existing hand-rolled `MCPClient` keeps compiling. + */ + 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; @@ -239,7 +250,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. @@ -251,10 +262,11 @@ 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), - ) + // `prior` adopts a saved discover result and skips server/discover. + await this.#client.connect(transport, { + ...sdkRequestOptions(this.#policy.requestOptions), + ...(prior === undefined ? {} : { prior }), + }) this.capabilities = this.#client.getServerCapabilities() ?? {} this.instructions = this.#client.getInstructions() // A failed listen only means that tool changes are not pushed. @@ -479,6 +491,10 @@ class MCPClientImpl< return result as TypedCallToolResult } + getDiscoverResult(): DiscoverResult | undefined { + return this.#client.getDiscoverResult() + } + async close(): Promise { if (this.#closed) return this.#closed = true @@ -610,7 +626,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..bd62f948d0 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,23 @@ export interface MCPClientOptions { /** Restart the timeout when the server sends a progress notification. */ resetTimeoutOnProgress?: boolean } + /** + * A saved `server/discover` result for the SDK `Client.connect`. With + * `{ kind: 'modern', discover }` the client skips the `server/discover` + * request. Save it from `MCPClient.getDiscoverResult()`. Reuse it only + * for the same authorization context, and only while its `ttlMs` and + * `cacheScope` allow it. + * + * ```ts + * const first = await createMCPClient({ transport }) + * const discover = first.getDiscoverResult?.() + * const next = await createMCPClient({ + * transport, + * prior: discover ? { kind: 'modern', discover } : undefined, + * }) + * ``` + */ + prior?: PriorDiscovery /** * Choose the server tools the model gets. Default: every tool. * diff --git a/packages/ai-mcp/tests/client.test.ts b/packages/ai-mcp/tests/client.test.ts index 8bb42ece2f..099ce8f4d8 100644 --- a/packages/ai-mcp/tests/client.test.ts +++ b/packages/ai-mcp/tests/client.test.ts @@ -8,6 +8,7 @@ import { Server } from '@modelcontextprotocol/server' import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' import { createMCPClient, createMCPClientFromTransport } from '../src/client' +import { createMCPServer } from '../src/server/create-server' import { DuplicateToolNameError, MCPConnectionError, @@ -63,6 +64,54 @@ 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') + }) + it('binds passed toolDefinitions to the server, typed + validated', async () => { const { clientTransport } = await makeServerWithWeatherTool() await using client = await createMCPClientFromTransport(clientTransport) diff --git a/testing/e2e/src/routes/api.mcp-typed-test.ts b/testing/e2e/src/routes/api.mcp-typed-test.ts index d046abd195..eaf901860a 100644 --- a/testing/e2e/src/routes/api.mcp-typed-test.ts +++ b/testing/e2e/src/routes/api.mcp-typed-test.ts @@ -43,12 +43,45 @@ async function* closeMcpOnDrain( * * - GET returns the discovered tools, so a test can read `outputSchema`. * It also calls `forecast` with the typed `callTool`. + * - 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')({ server: { handlers: { GET: async ({ request }) => { + const url = new URL(request.url) + if (url.searchParams.has('prior')) { + const transport = { + type: 'http' as const, + url: `${url.origin}/api/mcp-typed-server?stateless`, + } + const first = await createMCPClient({ transport }) + const discover = first.getDiscoverResult?.() + await first.close() + if (discover === undefined) { + return Response.json( + { error: 'no discover result' }, + { status: 500 }, + ) + } + const next = await createMCPClient({ + transport, + prior: { kind: 'modern', discover }, + }) + try { + const called = await next.callTool('show_note', {}) + return Response.json({ + supportedVersions: discover.supportedVersions, + adopted: next.getDiscoverResult?.() ?? null, + discover, + 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 f019e877ce..316a834c79 100644 --- a/testing/e2e/tests/mcp-typed.spec.ts +++ b/testing/e2e/tests/mcp-typed.spec.ts @@ -224,6 +224,23 @@ test.describe('mcp: createMCPServer with auth, output schema, and a task', () => expect(other.status()).toBe(404) }) + test('a client reuses a saved discover result as prior', async ({ + request, + }) => { + const res = await request.get('/api/mcp-typed-test?prior') + const body = await res.text() + expect(res.ok(), body).toBe(true) + const result = JSON.parse(body) as { + supportedVersions: Array + adopted: unknown + discover: unknown + content: unknown + } + expect(result.supportedVersions).toContain('2026-07-28') + expect(result.adopted).toEqual(result.discover) + expect(result.content).toEqual([{ type: 'text', text: 'shown' }]) + }) + test('a stateless server serves spec 2025 with resource context and tool _meta', async ({ request, }) => {