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.

539 lines (538 loc) • 19.5 kB
import { ToolInputRequiredError, createServerToolContext, inputDeclinedMessage } from "./context.js"; import { isServedOverStdio, rememberServerOptions } from "./registry.js"; import { parseToolOutput } from "./output.js"; import { getTask, startTask, toCallToolResult } from "./tasks.js"; import { inMemoryTaskStore } from "./stores.js"; import { convertSchemaToJsonSchema } from "@tanstack/ai"; import { McpServer, ProtocolError, ProtocolErrorCode, ResourceTemplate, WebStandardStreamableHTTPServerTransport, acceptedContent, createMcpHandler, fromJsonSchema, inputRequired, inputResponse, isCallToolResult, isLegacyRequest, legacyStatelessFallback, requireBearerAuth } from "@modelcontextprotocol/server"; //#region src/server/create-server.ts var sessionIdleMs = 18e5; var inputKey = "input"; var sampleMaxTokens = 1024; var inputFormSchema = { type: "object", properties: { value: { type: "string" } }, required: ["value"] }; var emptyObjectSchema = fromJsonSchema({ type: "object", properties: {} }); /** * 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) * ``` */ function createMCPServer(options) { const taskStore = options.taskStore ?? inMemoryTaskStore(); const sessions = /* @__PURE__ */ new Map(); const tools = options.tools ?? []; const resources = options.resources ?? []; const prompts = options.prompts ?? []; const hasTaskTool = tools.some((tool) => tool.execution === "task"); const schemas = compileSchemas(tools, prompts); const gate = options.auth === void 0 ? void 0 : requireBearerAuth(options.auth); const contextByRequest = /* @__PURE__ */ new WeakMap(); const factory = (ctx) => { const request = ctx.requestInfo; return buildMcpServer({ options, resources, schemas, taskStore, era: ctx.era === "modern" ? "2026" : "2025", sessionless: ctx.era !== "modern", hasTaskTool, owner: ownerOf(ctx.authInfo), appContext: () => request === void 0 ? void 0 : contextByRequest.get(request) }); }; const modern = createMcpHandler(factory, { legacy: "reject", keepAliveMs: 0, onerror: options.onerror }); 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) => 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, handleOptions) { await closeIdleSessions(sessions); let authInfo = handleOptions?.authInfo; if (authInfo === void 0 && gate !== void 0) { const verdict = await gate(request); if (verdict instanceof Response) return verdict; authInfo = verdict; } const owner = ownerOf(authInfo); const context = handleOptions?.context; 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 !== void 0) contextByRequest.set(request, context); const requestOptions = authInfo === void 0 ? void 0 : { authInfo }; if (legacy) return stateless(request, requestOptions); return modern.fetch(request, requestOptions); } }; rememberServerOptions(mcpServer, options); return mcpServer; } function ownerOf(authInfo) { if (authInfo === void 0) return void 0; const subject = authInfo.extra?.sub; return JSON.stringify([authInfo.clientId, typeof subject === "string" ? subject : null]); } async function closeIdleSessions(sessions) { const cutoff = Date.now() - sessionIdleMs; for (const [id, session] of sessions) { if (session.lastUsed > cutoff) continue; sessions.delete(id); await session.transport.close().catch(() => void 0); await session.server.close().catch(() => void 0); } } function compileSchemas(tools, prompts) { 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 })) }; } function outputSchemaOf(schema) { try { return standardSchema(schema, "output"); } catch { return; } } function buildMcpServer(input) { const server = new McpServer({ name: input.options.name, version: input.options.version }, serverOptions(input.era, input.hasTaskTool)); if (input.options.onerror !== void 0) 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; } function serverOptions(era, hasTaskTool) { if (!hasTaskTool || era !== "2025") return void 0; return { capabilities: { tasks: { requests: { tools: { call: {} } } } } }; } function registerServerTool(server, { tool, inputSchema, outputSchema: compiledOutput }, input) { const asTask = tool.execution === "task" && input.era === "2025"; const outputSchema = asTask ? void 0 : 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 !== void 0); } 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" }; } var annotationHints = [ "readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint" ]; function toolPresentation(metadata) { const title = typeof metadata?.title === "string" ? metadata.title : void 0; const _meta = isRecord(metadata?._meta) ? metadata._meta : void 0; const raw = metadata?.annotations; if (!isRecord(raw)) return { title, _meta }; const annotations = {}; 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, args, input, authInfo) { 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.`); return { content: [{ type: "text", text: polled.task.taskId }], task: polled.task }; } function taskContext(sample, authInfo, appContext) { return { context: { ...appContext(), authInfo, async requestInput(_request) { throw new Error("ctx.context.requestInput is not supported in an execution: \"task\" tool."); }, async sample(request) { if (sample === void 0) throw new Error("ctx.context.sample in an execution: \"task\" tool needs the sample option of createMCPServer."); return sample(request); } }, abortSignal: new AbortController().signal, emitCustomEvent() {} }; } async function runTool(tool, args, ctx) { const execute = tool.execute; if (execute === void 0) throw new Error(`Tool ${tool.name} has no execute function.`); const output = await execute(args ?? {}, ctx); return parseToolOutput(tool, output, isCallToolResult); } var sessionlessMessage = "needs a spec 2025 session. Set sessions: \"memory\" in createMCPServer."; function toolCallContext(era, sessionless, sdkCtx, sample, appContext) { const authInfo = sdkCtx.http?.authInfo; const hooks = era === "2025" ? createServerToolContext({ era: "2025", waitForInput: sessionless ? () => Promise.reject(/* @__PURE__ */ new Error(`ctx.context.requestInput ${sessionlessMessage}`)) : (request) => waitForInput(sdkCtx, request), clientSample: sessionless ? (request) => sample === void 0 ? Promise.reject(/* @__PURE__ */ 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 }); return { context: { ...appContext(), ...hooks }, abortSignal: sdkCtx.mcpReq.signal, emitCustomEvent() {} }; } function inputAnswer(sdkCtx) { const content = acceptedContent(sdkCtx.mcpReq.inputResponses, inputKey); if (content === void 0) return void 0; if (typeof content.value === "string") return content.value; return content; } function inputDeclined(sdkCtx) { const view = inputResponse(sdkCtx.mcpReq.inputResponses, inputKey); return view.kind === "elicit" && view.action !== "accept"; } async function waitForInput(sdkCtx, request) { const result = await sdkCtx.mcpReq.elicitInput({ message: request.message, mode: "form", requestedSchema: inputFormSchema }); if (result.action !== "accept" || result.content === void 0) throw new Error(inputDeclinedMessage); const content = result.content; if (content !== void 0 && typeof content.value === "string") return content.value; return content; } async function askClientToSample(sdkCtx, request) { const messages = request.messages.map((message) => ({ role: message.role === "assistant" ? "assistant" : "user", content: { type: "text", text: message.content } })); return textFromContent((await sdkCtx.mcpReq.requestSampling({ messages, maxTokens: sampleMaxTokens })).content); } function textFromContent(content) { 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) { return isRecord(value) && value.type === "text" && typeof value.text === "string"; } function registerServerResource(server, resource, appContext) { const metadata = { mimeType: resource.mimeType }; const resourceContext = (sdkCtx) => ({ context: { ...appContext(), authInfo: sdkCtx.http?.authInfo } }); const read = async (uri, variables, sdkCtx) => resourceContents(uri.href, resource.mimeType, await resource.read(uri, variables, resourceContext(sdkCtx))); if (resource.uri !== void 0) { server.registerResource(resource.name, resource.uri, metadata, (uri, ctx) => read(uri, {}, ctx)); return; } if (resource.uriTemplate === void 0) return; const list = resource.list; const template = new ResourceTemplate(resource.uriTemplate, { list: list === void 0 ? void 0 : (sdkCtx) => list(resourceContext(sdkCtx)) }); server.registerResource(resource.name, template, metadata, read); } function resourceContents(uri, mimeType, body) { 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 }] }; return { contents: [{ uri, mimeType, text: JSON.stringify(body) ?? "" }] }; } function registerServerPrompt(server, { prompt, argsSchema }) { server.registerPrompt(prompt.name, { description: prompt.description, argsSchema }, async (args) => { return { messages: promptMessages(await prompt.render(args ?? {})) }; }); } function promptMessages(rendered) { return (Array.isArray(rendered) ? rendered.filter(isPromptMessage) : []).map((item) => { const content = { type: "text", text: item.content }; if (item.role === "assistant") return { role: "assistant", content }; return { role: "user", content }; }); } function isPromptMessage(value) { return isRecord(value) && typeof value.role === "string" && typeof value.content === "string"; } var taskIdParams = { "~standard": { version: 1, vendor: "tanstack-ai-mcp", validate(value) { const taskId = isRecord(value) ? value.taskId : void 0; if (typeof taskId !== "string" || taskId.length === 0) return { issues: [{ message: "taskId is required" }] }; return { value: { taskId } }; } } }; function registerLegacyTaskMethods(server, store, owner) { 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) { if (!isRecord(params) || typeof params.taskId !== "string") throw new ProtocolError(ProtocolErrorCode.InvalidParams, "taskId is required"); return params.taskId; } function standardSchema(schema, io = "input") { if (!isSchemaInput(schema)) return void 0; const jsonSchema = convertSchemaToJsonSchema(schema, { io }); if (io === "input") return isJsonObjectSchema(jsonSchema) ? fromJsonSchema(jsonSchema) : void 0; return isJsonSchemaRoot(jsonSchema) ? fromJsonSchema(jsonSchema) : void 0; } function isJsonSchemaRoot(value) { return isRecord(value); } function isSchemaInput(schema) { if (!isRecord(schema)) return false; if ("~standard" in schema) return true; return schema.type !== void 0; } function isJsonObjectSchema(value) { return isRecord(value) && value.type === "object"; } async function legacyFetch(request, routes) { 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, sessionId, sessions, caller) { const session = sessions.get(sessionId); if (session === void 0 || 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, input) { let session; const server = input.build(() => session === void 0 ? 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 { if (transport.sessionId === void 0) 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) { return typeof value === "object" && value !== null && !Array.isArray(value); } //#endregion export { createMCPServer }; //# sourceMappingURL=create-server.js.map