UNPKG

@tanstack/ai-mcp

Version:

Host-side Model Context Protocol client for TanStack AI: discover and run MCP server tools, resources, and prompts in any adapter's chat() loop, with generated end-to-end types.

1,008 lines (945 loc) • 31.9 kB
import { convertSchemaToJsonSchema } from '@tanstack/ai' import type { AnyServerTool, SchemaInput } from '@tanstack/ai' import { McpServer, ProtocolError, ProtocolErrorCode, ResourceTemplate, WebStandardStreamableHTTPServerTransport, acceptedContent, createMcpHandler, fromJsonSchema, isCallToolResult, inputRequired, inputResponse, isLegacyRequest, legacyStatelessFallback, requireBearerAuth, } from '@modelcontextprotocol/server' import type { AuthInfo, BearerAuthOptions, JsonSchemaType, McpRequestContext, McpServerFactory, ServerContext, StandardSchemaWithJSON, ToolAnnotations, Variables, } from '@modelcontextprotocol/server' import { ToolInputRequiredError, createServerToolContext, inputDeclinedMessage, } from './context' import type { SampleRequest, ToolInputRequest } from './context' import { isServedOverStdio, rememberServerOptions } from './registry' import { parseToolOutput } from './output' import type { MCPResourceContext, MCPResourceList } from './definitions' import { getTask, startTask, toCallToolResult } from './tasks' import { inMemoryTaskStore } from './stores' import type { TaskStore } from './stores' // ponytail: idle sessions close only when a later request runs the sweep. // A server that gets no traffic keeps them until the next request. const sessionIdleMs = 30 * 60 * 1000 const inputKey = 'input' const sampleMaxTokens = 1024 const inputFormSchema = { type: 'object' as const, properties: { value: { type: 'string' as const }, }, required: ['value'], } const emptyObjectSchema = fromJsonSchema({ type: 'object', properties: {}, }) type ProtocolYear = '2025' | '2026' type McpResource = { name: string mimeType: string uri?: string uriTemplate?: string list?: MCPResourceList read: BivariantResourceRead } // A method type is bivariant, so a `read` with narrower params assigns. // The lint rule bans method signatures, hence the class. type BivariantResourceRead = BivariantResourceReadSignature['bivarianceHack'] declare abstract class BivariantResourceReadSignature { abstract bivarianceHack( uri: URL, variables: Variables, ctx: MCPResourceContext, ): unknown } // A method type is bivariant. A function property is strict, so a prompt // with a specific input would not assign to `unknown`. type BivariantCallback<TInput, TOutput> = BivariantCallbackSignature< TInput, TOutput >['bivarianceHack'] declare abstract class BivariantCallbackSignature<TInput, TOutput> { abstract bivarianceHack(input: TInput): TOutput } type McpPrompt = { name: string description: string argsSchema: { parse: (input: unknown) => unknown } render: BivariantCallback<unknown, unknown> } export type MCPServerOptions = { name: string version: string tools?: ReadonlyArray<AnyServerTool> resources?: ReadonlyArray<McpResource> prompts?: ReadonlyArray<McpPrompt> taskStore?: TaskStore /** * Bearer-token auth, in the shape the MCP SDK uses. * `verifier` is an `OAuthTokenVerifier`. See `jwtVerifier` and * `introspectionVerifier`. `requiredScopes` and `resourceMetadataUrl` * are optional. */ auth?: BearerAuthOptions /** * How to serve a spec 2025 client. `'stateless'` (the default) serves * each spec 2025 request with a new server and keeps no session, so it * works on a host with many instances. `'memory'` keeps sessions in this * process for 30 idle minutes. `'reject'` answers every spec 2025 request * with the SDK rejection. Under `serveMCPStdio`, the default is * `'memory'`. Elicitation and client sampling need a * session: in that mode `ctx.context.requestInput` throws, and * `ctx.context.sample` calls `sample` or throws. */ sessions?: 'memory' | 'reject' | 'stateless' /** * Receives errors that the SDK does not send to a tool: transport and * protocol errors, and rejected requests. `serveMCPStdio` also sends its * transport errors here instead of to stderr. It only reports. It does * not change the response. */ onerror?: (error: Error) => void sample?: (request: SampleRequest) => Promise<unknown> waitUntil?: (promise: Promise<unknown>) => void } /** * Per-request options for `server.handle`. * * `authInfo` is a token your own middleware already verified. The server * skips its `auth` gate for that request. * `context` is merged into `ctx.context` for every tool call, resource read, * and resource list of that request. */ export type MCPHandleOptions = { authInfo?: AuthInfo context?: Record<string, unknown> } type AppContext = () => Record<string, unknown> | undefined /** * One MCP server and the definitions it serves. * * `tools`, `resources`, and `prompts` keep the types from the arguments. * Export this object from one package and import it in another. * `createMCPClient({ server })` then uses those types. */ export type MCPServer< TTools extends ReadonlyArray<AnyServerTool> = ReadonlyArray<AnyServerTool>, TResources extends ReadonlyArray<McpResource> = ReadonlyArray<McpResource>, TPrompts extends ReadonlyArray<McpPrompt> = ReadonlyArray<McpPrompt>, > = { readonly name: string readonly version: string readonly tools: TTools readonly resources: TResources readonly prompts: TPrompts fetch: (request: Request) => Promise<Response> handle: (request: Request, options?: MCPHandleOptions) => Promise<Response> } type LegacySession = { transport: WebStandardStreamableHTTPServerTransport server: McpServer owner: string | undefined lastUsed: number // ponytail: the latest request's context. Two calls in flight on one // session share it; both belong to the same owner. context: Record<string, unknown> | undefined } type LegacySessions = Map<string, LegacySession> /** * Builds an MCP HTTP server. * * `options.name` and `options.version` name the server. * `options.tools` is a list of `toolDefinition().server()` tools. * `options.resources` uses `resourceDefinition().read()`. * `options.prompts` uses `promptDefinition().render()`. * `options.taskStore` keeps task records. The default store is in memory. * `options.auth` checks the bearer token with the SDK gate. * A missing or bad token gets a 401. A token without a required scope gets a 403. * Spec 2025 sessions and tasks belong to the caller: the `clientId` of the * token plus its `sub` claim. A tool reads the token as `ctx.context.authInfo`. * A spec 2025 client gets no session unless `options.sessions` is `'memory'`. * `options.sample` is the model adapter for `ctx.context.sample` on spec 2026. * `options.waitUntil` receives the task promise so a worker can stay alive. * * The result has `fetch(request)`, `handle(request, options)`, `tools`, * `resources`, and `prompts`. `handle` takes a token your own middleware * verified and values for `ctx.context`. * Those three lists keep the types you passed in. * Export the result from one package and pass it to `createMCPClient({ server })` in another. * `fetch` serves tools, resources, and prompts. * It speaks spec `2026-07-28` and spec 2025. * It does not serve the OAuth discovery documents. Mount * `oauthMetadataResponse` at the app root for them. * * On spec 2025, a tool with `execution: 'task'` returns a task handle before * the work ends. Spec 2026-07-28 has no tasks, so that tool runs inline there. * A tool reads its hooks on `ctx.context`. Type it with `MCPToolContext`. * On spec 2026, `ctx.context.sample` calls `options.sample` and does not ask the client. * On spec 2025 with `sessions: 'memory'`, `ctx.context.sample` asks the MCP client. * On spec 2026, `ctx.context.requestInput` stops the call until the client sends the answer. * On spec 2025 with `sessions: 'memory'`, `ctx.context.requestInput` waits on the open session. * * @param options - Server name, version, tools, and the optional stores * * @example * ```ts * const server = createMCPServer({ * name: 'weather', * version: '1.0.0', * tools: [getWeather], * }) * * return server.fetch(request) * ``` */ export function createMCPServer< const TTools extends ReadonlyArray<AnyServerTool> = [], const TResources extends ReadonlyArray<McpResource> = [], const TPrompts extends ReadonlyArray<McpPrompt> = [], >( options: Omit<MCPServerOptions, 'tools' | 'resources' | 'prompts'> & { tools?: TTools resources?: TResources prompts?: TPrompts }, ) { const taskStore = options.taskStore ?? inMemoryTaskStore() const sessions: LegacySessions = new Map() const tools = options.tools ?? [] const resources = options.resources ?? [] const prompts = options.prompts ?? [] const hasTaskTool = tools.some((tool) => tool.execution === 'task') // Convert and compile each schema once. Every request reuses them. const schemas = compileSchemas(tools, prompts) const gate = options.auth === undefined ? undefined : requireBearerAuth(options.auth) // modern.fetch and legacyStatelessFallback hand the factory the same // Request object that handle received. const contextByRequest = new WeakMap<Request, Record<string, unknown>>() const factory: McpServerFactory = (ctx) => { const request = ctx.requestInfo return buildMcpServer({ options, resources, schemas, taskStore, era: ctx.era === 'modern' ? '2026' : '2025', // Only the stateless leg builds a spec 2025 server here. sessionless: ctx.era !== 'modern', hasTaskTool, owner: ownerOf(ctx.authInfo), appContext: () => request === undefined ? undefined : contextByRequest.get(request), }) } const modern = createMcpHandler(factory, { legacy: 'reject', keepAliveMs: 0, onerror: options.onerror, }) // createMcpHandler hands its own stateless leg a clone of the request, // so the context lookup would miss. Route spec 2025 here instead. const stateless = legacyStatelessFallback(factory, options.onerror) const mcpServer = { name: options.name, version: options.version, tools, resources, prompts, /** * Serves one MCP HTTP request. This is a plain Fetch handler. * * @param request - The HTTP request to the MCP route */ fetch: (request: Request): Promise<Response> => mcpServer.handle(request), /** * Serves one MCP HTTP request with values from your own middleware. * * A spec 2026 request uses the per-request envelope. * A spec 2025 request uses the session id header. * When `auth` is set, a missing or invalid bearer token returns 401, * and a token without a required scope returns 403. * `handleOptions.authInfo` skips that gate. `handleOptions.context` reaches * `ctx.context` of every tool call, resource read, and resource list of * this request. * * @param request - The HTTP request to the MCP route * @param handleOptions - A verified token and values for `ctx.context` */ async handle(request: Request, handleOptions?: MCPHandleOptions) { await closeIdleSessions(sessions) let authInfo = handleOptions?.authInfo if (authInfo === undefined && gate !== undefined) { const verdict = await gate(request) if (verdict instanceof Response) return verdict authInfo = verdict } const owner = ownerOf(authInfo) const context = handleOptions?.context // Over stdio, one client owns the process, so sessions are the default. const sessionMode = options.sessions ?? (isServedOverStdio(mcpServer) ? 'memory' : 'stateless') const legacy = sessionMode !== 'reject' && (await isLegacyRequest(request)) if (legacy && sessionMode === 'memory') { return legacyFetch(request, { open: () => openLegacySession(request, { sessions, owner, authInfo, context, build: (appContext) => buildMcpServer({ options, resources, schemas, taskStore, era: '2025', sessionless: false, hasTaskTool, owner, appContext, }), }), resume: (sessionId) => resumeLegacySession(request, sessionId, sessions, { owner, authInfo, context, }), }) } if (context !== undefined) contextByRequest.set(request, context) // The SDK passes authInfo through to the factory and to each handler. const requestOptions = authInfo === undefined ? undefined : { authInfo } if (legacy) return stateless(request, requestOptions) return modern.fetch(request, requestOptions) }, } rememberServerOptions(mcpServer, options) return mcpServer } // Sessions and tasks belong to one caller: the client id plus the subject. function ownerOf(authInfo: McpRequestContext['authInfo']) { if (authInfo === undefined) return undefined const subject = authInfo.extra?.sub return JSON.stringify([ authInfo.clientId, typeof subject === 'string' ? subject : null, ]) } async function closeIdleSessions(sessions: LegacySessions) { const cutoff = Date.now() - sessionIdleMs for (const [id, session] of sessions) { if (session.lastUsed > cutoff) continue sessions.delete(id) await session.transport.close().catch(() => undefined) await session.server.close().catch(() => undefined) } } type CompiledTool = { tool: AnyServerTool inputSchema: StandardSchemaWithJSON outputSchema: StandardSchemaWithJSON | undefined } type CompiledPrompt = { prompt: McpPrompt argsSchema: StandardSchemaWithJSON } type CompiledSchemas = { tools: ReadonlyArray<CompiledTool> prompts: ReadonlyArray<CompiledPrompt> } function compileSchemas( tools: ReadonlyArray<AnyServerTool>, prompts: ReadonlyArray<McpPrompt>, ): CompiledSchemas { return { tools: tools.map((tool) => ({ tool, inputSchema: standardSchema(tool.inputSchema) ?? emptyObjectSchema, outputSchema: outputSchemaOf(tool.outputSchema), })), prompts: prompts.map((prompt) => ({ prompt, argsSchema: standardSchema(prompt.argsSchema) ?? emptyObjectSchema, })), } } // An output schema can have any root, like z.string(). The SDK wraps it in // `{ result }` for a spec 2025 client. A bare transform has no JSON Schema // for its output view, so that tool advertises no output schema. function outputSchemaOf(schema: unknown) { try { return standardSchema(schema, 'output') } catch { return undefined } } function buildMcpServer(input: { options: MCPServerOptions resources: ReadonlyArray<McpResource> schemas: CompiledSchemas taskStore: TaskStore era: ProtocolYear sessionless: boolean hasTaskTool: boolean owner: string | undefined appContext: AppContext }) { const server = new McpServer( { name: input.options.name, version: input.options.version }, serverOptions(input.era, input.hasTaskTool), ) if (input.options.onerror !== undefined) { server.server.onerror = input.options.onerror } for (const compiled of input.schemas.tools) { registerServerTool(server, compiled, input) } for (const resource of input.resources) { registerServerResource(server, resource, input.appContext) } for (const compiled of input.schemas.prompts) { registerServerPrompt(server, compiled) } if (input.era === '2025') { registerLegacyTaskMethods(server, input.taskStore, input.owner) } return server } // Tasks exist on spec 2025-11-25 only. Spec 2026-07-28 has no tasks yet. function serverOptions(era: ProtocolYear, hasTaskTool: boolean) { if (!hasTaskTool || era !== '2025') return undefined return { capabilities: { tasks: { requests: { tools: { call: {} } } }, }, } } function registerServerTool( server: McpServer, { tool, inputSchema, outputSchema: compiledOutput }: CompiledTool, input: { options: MCPServerOptions taskStore: TaskStore era: ProtocolYear sessionless: boolean owner: string | undefined appContext: AppContext }, ) { // A task tool answers with a task handle, not with its output. const asTask = tool.execution === 'task' && input.era === '2025' const outputSchema = asTask ? undefined : compiledOutput const registered = server.registerTool( tool.name, { ...toolPresentation(tool.metadata), description: tool.description, inputSchema, outputSchema, }, async (args, sdkCtx) => { if (asTask) { return runTaskTool(tool, args, input, sdkCtx.http?.authInfo) } const ctx = toolCallContext( input.era, input.sessionless, sdkCtx, input.options.sample, input.appContext, ) try { const output = await runTool(tool, args, ctx) return toCallToolResult(output, outputSchema !== undefined) } catch (error) { if (error instanceof ToolInputRequiredError) { return inputRequired({ inputRequests: { [inputKey]: inputRequired.elicit({ message: error.request.message, mode: 'form', requestedSchema: inputFormSchema, }), }, }) } throw error } }, ) if (asTask) { registered.execution = { taskSupport: 'required' } } } const annotationHints = [ 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint', ] as const // `metadata.title`, `metadata.annotations`, and `metadata._meta` on a tool // definition reach the host as the MCP tool title, annotations, and `_meta`. // `_meta.ui.resourceUri` links an MCP Apps view to the tool. function toolPresentation(metadata: Record<string, unknown> | undefined) { const title = typeof metadata?.title === 'string' ? metadata.title : undefined const _meta = isRecord(metadata?._meta) ? metadata._meta : undefined const raw = metadata?.annotations if (!isRecord(raw)) return { title, _meta } const annotations: ToolAnnotations = {} if (typeof raw.title === 'string') annotations.title = raw.title for (const hint of annotationHints) { if (typeof raw[hint] === 'boolean') annotations[hint] = raw[hint] } return { title, annotations, _meta } } async function runTaskTool( tool: AnyServerTool, args: unknown, input: { options: MCPServerOptions taskStore: TaskStore owner: string | undefined appContext: AppContext }, authInfo: AuthInfo | undefined, ) { const ctx = taskContext(input.options.sample, authInfo, input.appContext) const handle = await startTask(() => runTool(tool, args, ctx), { store: input.taskStore, waitUntil: input.options.waitUntil, owner: input.owner, }) const polled = await getTask(handle.taskId, input.taskStore, input.owner) if (polled === null) { throw new Error(`Task ${handle.taskId} was not saved.`) } // Spec 2025-11-25 `CreateTaskResult` is `{ task }`. The SDK also needs // `content` on a tools/call result, so the handle carries the task id. return { content: [{ type: 'text' as const, text: polled.task.taskId }], task: polled.task, } } // A task keeps running after tools/call answers with the task handle. // So it must not use that request: its signal, elicitation, or sampling. function taskContext( sample: MCPServerOptions['sample'], authInfo: AuthInfo | undefined, appContext: AppContext, ) { return { context: { ...appContext(), authInfo, async requestInput(_request: ToolInputRequest): Promise<never> { throw new Error( 'ctx.context.requestInput is not supported in an execution: "task" tool.', ) }, async sample(request: SampleRequest) { if (sample === undefined) { throw new Error( 'ctx.context.sample in an execution: "task" tool needs the sample option of createMCPServer.', ) } return sample(request) }, }, // ponytail: nothing aborts this signal until tasks/cancel is implemented. abortSignal: new AbortController().signal, emitCustomEvent() {}, } } async function runTool( tool: AnyServerTool, args: unknown, ctx: ReturnType<typeof toolCallContext> | ReturnType<typeof taskContext>, ) { const execute = tool.execute if (execute === undefined) { throw new Error(`Tool ${tool.name} has no execute function.`) } const output: unknown = await execute(args ?? {}, ctx) return parseToolOutput(tool, output, isCallToolResult) } // A stateless spec 2025 server never saw initialize and keeps no session, // so it cannot send the client a request. const sessionlessMessage = 'needs a spec 2025 session. Set sessions: "memory" in createMCPServer.' function toolCallContext( era: ProtocolYear, sessionless: boolean, sdkCtx: ServerContext, sample: MCPServerOptions['sample'], appContext: AppContext, ) { const authInfo = sdkCtx.http?.authInfo const hooks = era === '2025' ? createServerToolContext({ era: '2025', waitForInput: sessionless ? () => Promise.reject( new Error(`ctx.context.requestInput ${sessionlessMessage}`), ) : (request) => waitForInput(sdkCtx, request), clientSample: sessionless ? (request) => sample === undefined ? Promise.reject( new Error(`ctx.context.sample ${sessionlessMessage}`), ) : sample(request) : (request) => askClientToSample(sdkCtx, request), sample, authInfo, }) : createServerToolContext({ era: '2026', inputAnswer: inputAnswer(sdkCtx), inputDeclined: inputDeclined(sdkCtx), sample, authInfo, }) // The hooks win over a same-named value from `handle` options. return { context: { ...appContext(), ...hooks }, abortSignal: sdkCtx.mcpReq.signal, emitCustomEvent() {}, } } function inputAnswer(sdkCtx: ServerContext) { const content = acceptedContent(sdkCtx.mcpReq.inputResponses, inputKey) if (content === undefined) return undefined if (typeof content.value === 'string') return content.value return content } // A decline or a cancel must end the call. Without this check the tool // asks again, and the client shows the same question again. function inputDeclined(sdkCtx: ServerContext) { const view = inputResponse(sdkCtx.mcpReq.inputResponses, inputKey) return view.kind === 'elicit' && view.action !== 'accept' } async function waitForInput(sdkCtx: ServerContext, request: ToolInputRequest) { const result = await sdkCtx.mcpReq.elicitInput({ message: request.message, mode: 'form', requestedSchema: inputFormSchema, }) const declined = result.action !== 'accept' || result.content === undefined if (declined) { throw new Error(inputDeclinedMessage) } const content = result.content if (content !== undefined && typeof content.value === 'string') { return content.value } return content } async function askClientToSample( sdkCtx: ServerContext, request: SampleRequest, ) { const messages = request.messages.map((message) => ({ role: message.role === 'assistant' ? ('assistant' as const) : ('user' as const), content: { type: 'text' as const, text: message.content }, })) const result = await sdkCtx.mcpReq.requestSampling({ messages, maxTokens: sampleMaxTokens, }) return textFromContent(result.content) } function textFromContent(content: unknown) { if (isTextBlock(content)) return content.text if (!Array.isArray(content)) { throw new Error('The client sample result has no text.') } const blocks = content for (const block of blocks) { if (isTextBlock(block)) return block.text } throw new Error('The client sample result has no text.') } function isTextBlock(value: unknown): value is { type: 'text'; text: string } { return ( isRecord(value) && value.type === 'text' && typeof value.text === 'string' ) } function registerServerResource( server: McpServer, resource: McpResource, appContext: AppContext, ) { const metadata = { mimeType: resource.mimeType } const resourceContext = (sdkCtx: ServerContext): MCPResourceContext => ({ context: { ...appContext(), authInfo: sdkCtx.http?.authInfo }, }) const read = async (uri: URL, variables: Variables, sdkCtx: ServerContext) => resourceContents( uri.href, resource.mimeType, await resource.read(uri, variables, resourceContext(sdkCtx)), ) if (resource.uri !== undefined) { server.registerResource(resource.name, resource.uri, metadata, (uri, ctx) => read(uri, {}, ctx), ) return } if (resource.uriTemplate === undefined) return const list = resource.list const template = new ResourceTemplate(resource.uriTemplate, { list: list === undefined ? undefined : (sdkCtx: ServerContext) => list(resourceContext(sdkCtx)), }) server.registerResource(resource.name, template, metadata, read) } function resourceContents(uri: string, mimeType: string, body: unknown) { if (isRecord(body) && typeof body.text === 'string') { return { contents: [{ uri, mimeType, text: body.text }] } } if (isRecord(body) && typeof body.blob === 'string') { return { contents: [{ uri, mimeType, blob: body.blob }] } } if (typeof body === 'string') { return { contents: [{ uri, mimeType, text: body }] } } // JSON.stringify(undefined) is undefined. A text block needs a string. return { contents: [{ uri, mimeType, text: JSON.stringify(body) ?? '' }] } } function registerServerPrompt( server: McpServer, { prompt, argsSchema }: CompiledPrompt, ) { server.registerPrompt( prompt.name, { description: prompt.description, argsSchema }, async (args) => { const rendered = await prompt.render(args ?? {}) return { messages: promptMessages(rendered) } }, ) } function promptMessages(rendered: unknown) { const items = Array.isArray(rendered) ? rendered.filter(isPromptMessage) : [] return items.map((item) => { const content = { type: 'text' as const, text: item.content } if (item.role === 'assistant') { return { role: 'assistant' as const, content } } return { role: 'user' as const, content } }) } function isPromptMessage( value: unknown, ): value is { role: string; content: string } { return ( isRecord(value) && typeof value.role === 'string' && typeof value.content === 'string' ) } const taskIdParams = { '~standard': { version: 1 as const, vendor: 'tanstack-ai-mcp', validate(value: unknown) { const taskId = isRecord(value) ? value.taskId : undefined if (typeof taskId !== 'string' || taskId.length === 0) { return { issues: [{ message: 'taskId is required' }] } } return { value: { taskId } } }, }, } function registerLegacyTaskMethods( server: McpServer, store: TaskStore, owner: string | undefined, ) { server.server.setRequestHandler( 'tasks/get', { params: taskIdParams }, async (params) => { const polled = await getTask(taskIdFrom(params), store, owner) if (polled === null) { throw new ProtocolError( ProtocolErrorCode.InvalidParams, 'Task not found', ) } return polled.task }, ) server.server.setRequestHandler( 'tasks/result', { params: taskIdParams }, async (params) => { const polled = await getTask(taskIdFrom(params), store, owner) if (polled === null || polled.record.status !== 'completed') { throw new ProtocolError( ProtocolErrorCode.InvalidParams, 'Task result is not ready', ) } return toCallToolResult(polled.record.result) }, ) } function taskIdFrom(params: unknown) { if (!isRecord(params) || typeof params.taskId !== 'string') { throw new ProtocolError( ProtocolErrorCode.InvalidParams, 'taskId is required', ) } return params.taskId } // Input schemas and prompt arguments must have an object root. // An output schema can have any root and uses the parsed (output) view. function standardSchema(schema: unknown, io: 'input' | 'output' = 'input') { if (!isSchemaInput(schema)) return undefined const jsonSchema = convertSchemaToJsonSchema(schema, { io }) if (io === 'input') { return isJsonObjectSchema(jsonSchema) ? fromJsonSchema(jsonSchema) : undefined } return isJsonSchemaRoot(jsonSchema) ? fromJsonSchema(jsonSchema) : undefined } // The converter returns one JSON Schema object for a Standard Schema. function isJsonSchemaRoot(value: unknown): value is JsonSchemaType { return isRecord(value) } function isSchemaInput(schema: unknown): schema is SchemaInput { if (!isRecord(schema)) return false if ('~standard' in schema) return true return schema.type !== undefined } function isJsonObjectSchema(value: unknown): value is { type: 'object' } { return isRecord(value) && value.type === 'object' } async function legacyFetch( request: Request, routes: { open: () => Promise<Response> resume: (sessionId: string) => Promise<Response> }, ) { const sessionId = request.headers.get('mcp-session-id') if (sessionId !== null && sessionId.length > 0) { return routes.resume(sessionId) } return routes.open() } async function resumeLegacySession( request: Request, sessionId: string, sessions: LegacySessions, caller: { owner: string | undefined authInfo: AuthInfo | undefined context: Record<string, unknown> | undefined }, ) { const session = sessions.get(sessionId) // Another caller gets the same answer as an unknown id. if (session === undefined || session.owner !== caller.owner) { return sessionNotFound() } session.lastUsed = Date.now() session.context = caller.context return session.transport.handleRequest(request, { authInfo: caller.authInfo, }) } async function openLegacySession( request: Request, input: { sessions: LegacySessions owner: string | undefined authInfo: AuthInfo | undefined context: Record<string, unknown> | undefined build: (appContext: AppContext) => McpServer }, ) { // Before the session exists, the opening request's context applies. let session: LegacySession | undefined const server = input.build(() => session === undefined ? input.context : session.context, ) const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, keepAliveMs: 0, onsessioninitialized: (id) => { session = { transport, server, owner: input.owner, lastUsed: Date.now(), context: input.context, } input.sessions.set(id, session) }, onsessionclosed: (id) => { input.sessions.delete(id) }, }) try { await server.connect(transport) return await transport.handleRequest(request, { authInfo: input.authInfo, }) } finally { // A request that never opens a session must not keep the server. if (transport.sessionId === undefined) await server.close() } } function sessionNotFound() { return Response.json( { jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Session not found' }, }, { status: 404 }, ) } function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value) }