diff --git a/README.md b/README.md index 2800cfb1..5cc1f4b2 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - [Tools](#tools) - [Prompts](#prompts) - [Completions](#completions) + - [Sampling](#sampling) - [Running Your Server](#running-your-server) - [stdio](#stdio) - [Streamable HTTP](#streamable-http) @@ -44,6 +45,8 @@ The Model Context Protocol allows applications to provide context for LLMs in a npm install @modelcontextprotocol/sdk ``` +> ⚠️ MCP requires Node v18.x up to work fine. + ## Quick Start Let's create a simple MCP server that exposes a calculator tool and some data: @@ -382,6 +385,68 @@ import { getDisplayName } from "@modelcontextprotocol/sdk/shared/metadataUtils.j const displayName = getDisplayName(tool); ``` +### Sampling + +MCP servers can request LLM completions from connected clients that support sampling. + +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; + +const mcpServer = new McpServer({ + name: "tools-with-sample-server", + version: "1.0.0", +}); + +// Tool that uses LLM sampling to summarize any text +mcpServer.registerTool( + "summarize", + { + description: "Summarize any text using an LLM", + inputSchema: { + text: z.string().describe("Text to summarize"), + }, + }, + async ({ text }) => { + // Call the LLM through MCP sampling + const response = await mcpServer.server.createMessage({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Please summarize the following text concisely:\n\n${text}`, + }, + }, + ], + maxTokens: 500, + }); + + return { + content: [ + { + type: "text", + text: response.content.type === "text" ? response.content.text : "Unable to generate summary", + }, + ], + }; + } +); + +async function main() { + const transport = new StdioServerTransport(); + await mcpServer.connect(transport); + console.log("MCP server is running..."); +} + +main().catch((error) => { + console.error("Server error:", error); + process.exit(1); +}); +``` + + ## Running Your Server MCP servers in TypeScript need to be connected to a transport to communicate with clients. How you start the server depends on the choice of transport: @@ -588,8 +653,17 @@ app.delete('/mcp', async (req: Request, res: Response) => { // Start the server const PORT = 3000; -app.listen(PORT, () => { - console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`); +setupServer().then(() => { + app.listen(PORT, (error) => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } + console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`); + }); +}).catch(error => { + console.error('Failed to set up the server:', error); + process.exit(1); }); ``` diff --git a/package-lock.json b/package-lock.json index 5f7cb0f5..16b90a3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.13.2", + "version": "1.13.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.13.2", + "version": "1.13.3", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index 31bc3562..e5061966 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.13.2", + "version": "1.13.3", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 8e77c0a5..8155e134 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -403,6 +403,19 @@ describe("OAuth Authorization", () => { expect(mockFetch).toHaveBeenCalledTimes(2); }); + it("returns undefined when both CORS requests fail in fetchWithCorsRetry", async () => { + // fetchWithCorsRetry tries with headers (fails with CORS), then retries without headers (also fails with CORS) + // simulating a 404 w/o headers set. We want this to return undefined, not throw TypeError + mockFetch.mockImplementation(() => { + // Both the initial request with headers and retry without headers fail with CORS TypeError + return Promise.reject(new TypeError("Failed to fetch")); + }); + + // This should return undefined (the desired behavior after the fix) + const metadata = await discoverOAuthMetadata("https://auth.example.com/path"); + expect(metadata).toBeUndefined(); + }); + it("returns undefined when discovery endpoint returns 404", async () => { mockFetch.mockResolvedValueOnce({ ok: false, diff --git a/src/client/auth.ts b/src/client/auth.ts index 37690574..71101a42 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -292,25 +292,24 @@ export async function discoverOAuthProtectedResourceMetadata( return OAuthProtectedResourceMetadataSchema.parse(await response.json()); } -/** - * Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata. - * - * If the server returns a 404 for the well-known endpoint, this function will - * return `undefined`. Any other errors will be thrown as exceptions. - */ /** * Helper function to handle fetch with CORS retry logic */ async function fetchWithCorsRetry( url: URL, - headers: Record, -): Promise { + headers?: Record, +): Promise { try { return await fetch(url, { headers }); } catch (error) { - // CORS errors come back as TypeError, retry without headers if (error instanceof TypeError) { - return await fetch(url); + if (headers) { + // CORS errors come back as TypeError, retry without headers + return fetchWithCorsRetry(url) + } else { + // We're getting CORS errors on retry too, return undefined + return undefined + } } throw error; } @@ -334,7 +333,7 @@ function buildWellKnownPath(pathname: string): string { async function tryMetadataDiscovery( url: URL, protocolVersion: string, -): Promise { +): Promise { const headers = { "MCP-Protocol-Version": protocolVersion }; @@ -344,10 +343,16 @@ async function tryMetadataDiscovery( /** * Determines if fallback to root discovery should be attempted */ -function shouldAttemptFallback(response: Response, pathname: string): boolean { - return response.status === 404 && pathname !== '/'; +function shouldAttemptFallback(response: Response | undefined, pathname: string): boolean { + return !response || response.status === 404 && pathname !== '/'; } +/** + * Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata. + * + * If the server returns a 404 for the well-known endpoint, this function will + * return `undefined`. Any other errors will be thrown as exceptions. + */ export async function discoverOAuthMetadata( authorizationServerUrl: string | URL, opts?: { protocolVersion?: string }, @@ -362,18 +367,10 @@ export async function discoverOAuthMetadata( // If path-aware discovery fails with 404, try fallback to root discovery if (shouldAttemptFallback(response, issuer.pathname)) { - try { - const rootUrl = new URL("/.well-known/oauth-authorization-server", issuer); - response = await tryMetadataDiscovery(rootUrl, protocolVersion); - - if (response.status === 404) { - return undefined; - } - } catch { - // If fallback fails, return undefined - return undefined; - } - } else if (response.status === 404) { + const rootUrl = new URL("/.well-known/oauth-authorization-server", issuer); + response = await tryMetadataDiscovery(rootUrl, protocolVersion); + } + if (!response || response.status === 404) { return undefined; } diff --git a/src/client/stdio.test.ts b/src/client/stdio.test.ts index eb7f0e1b..0e92eac1 100644 --- a/src/client/stdio.test.ts +++ b/src/client/stdio.test.ts @@ -74,6 +74,7 @@ test("should read messages", async () => { await client.close(); }); + test("should properly set default environment variables in spawned process", async () => { await envAsyncLocalStorage.run({ env: {} }, async () => { const client = new StdioClientTransport(serverParameters); @@ -128,4 +129,13 @@ test("should override default environment variables with custom ones", async () } } }); + +test("should return child process pid", async () => { + const client = new StdioClientTransport(serverParameters); + + await client.start(); + expect(client.pid).not.toBeNull(); + await client.close(); + expect(client.pid).toBeNull(); + }); diff --git a/src/client/stdio.ts b/src/client/stdio.ts index eba7f98b..62292ce1 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -189,6 +189,15 @@ export class StdioClientTransport implements Transport { return this._process?.stderr ?? null; } + /** + * The child process pid spawned by this transport. + * + * This is only available after the transport has been started. + */ + get pid(): number | null { + return this._process?.pid ?? null; + } + private processReadBuffer() { while (true) { try { diff --git a/src/examples/server/mcpServerOutputSchema.ts b/src/examples/server/mcpServerOutputSchema.ts index 75bfe690..de3b363e 100644 --- a/src/examples/server/mcpServerOutputSchema.ts +++ b/src/examples/server/mcpServerOutputSchema.ts @@ -43,7 +43,14 @@ server.registerTool( void country; // Simulate weather API call const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; - const conditions = ["sunny", "cloudy", "rainy", "stormy", "snowy"][Math.floor(Math.random() * 5)]; + const conditionCandidates = [ + "sunny", + "cloudy", + "rainy", + "stormy", + "snowy", + ] as const; + const conditions = conditionCandidates[Math.floor(Math.random() * conditionCandidates.length)]; const structuredContent = { temperature: { @@ -77,4 +84,4 @@ async function main() { main().catch((error) => { console.error("Server error:", error); process.exit(1); -}); \ No newline at end of file +}); diff --git a/src/examples/server/toolWithSampleServer.ts b/src/examples/server/toolWithSampleServer.ts new file mode 100644 index 00000000..44e5cecb --- /dev/null +++ b/src/examples/server/toolWithSampleServer.ts @@ -0,0 +1,57 @@ + +// Run with: npx tsx src/examples/server/toolWithSampleServer.ts + +import { McpServer } from "../../server/mcp.js"; +import { StdioServerTransport } from "../../server/stdio.js"; +import { z } from "zod"; + +const mcpServer = new McpServer({ + name: "tools-with-sample-server", + version: "1.0.0", +}); + +// Tool that uses LLM sampling to summarize any text +mcpServer.registerTool( + "summarize", + { + description: "Summarize any text using an LLM", + inputSchema: { + text: z.string().describe("Text to summarize"), + }, + }, + async ({ text }) => { + // Call the LLM through MCP sampling + const response = await mcpServer.server.createMessage({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Please summarize the following text concisely:\n\n${text}`, + }, + }, + ], + maxTokens: 500, + }); + + return { + content: [ + { + type: "text", + text: response.content.type === "text" ? response.content.text : "Unable to generate summary", + }, + ], + }; + } +); + +async function main() { + const transport = new StdioServerTransport(); + await mcpServer.connect(transport); + console.log("MCP server is running..."); +} + +main().catch((error) => { + console.error("Server error:", error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 0764ffe8..dc96a1b0 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -267,6 +267,7 @@ describe("tool()", () => { expect(result.tools[0].name).toBe("test"); expect(result.tools[0].inputSchema).toEqual({ type: "object", + properties: {}, }); // Adding the tool before the connection was established means no notification was sent @@ -1311,7 +1312,7 @@ describe("tool()", () => { resultType: "structured", // Missing required 'timestamp' field someExtraField: "unexpected" // Extra field not in schema - }, + } as unknown as { processedInput: string; resultType: string; timestamp: string }, // Type assertion to bypass TypeScript validation for testing purposes }) ); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 1d984edc..a5624e15 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -169,7 +169,7 @@ export class McpServer { } const args = parseResult.data; - const cb = tool.callback as ToolCallback; + const cb = tool.callback as ToolCallback; try { result = await Promise.resolve(cb(args, extra)); } catch (error) { @@ -184,7 +184,7 @@ export class McpServer { }; } } else { - const cb = tool.callback as ToolCallback; + const cb = tool.callback as ToolCallback; try { result = await Promise.resolve(cb(extra)); } catch (error) { @@ -772,7 +772,7 @@ export class McpServer { inputSchema: ZodRawShape | undefined, outputSchema: ZodRawShape | undefined, annotations: ToolAnnotations | undefined, - callback: ToolCallback + callback: ToolCallback ): RegisteredTool { const registeredTool: RegisteredTool = { title, @@ -929,7 +929,7 @@ export class McpServer { outputSchema?: OutputArgs; annotations?: ToolAnnotations; }, - cb: ToolCallback + cb: ToolCallback ): RegisteredTool { if (this._registeredTools[name]) { throw new Error(`Tool ${name} is already registered`); @@ -944,7 +944,7 @@ export class McpServer { inputSchema, outputSchema, annotations, - cb as ToolCallback + cb as ToolCallback ); } @@ -1138,6 +1138,16 @@ export class ResourceTemplate { } } +/** + * Type helper to create a strongly-typed CallToolResult with structuredContent + */ +type TypedCallToolResult = + OutputArgs extends ZodRawShape + ? CallToolResult & { + structuredContent?: z.objectOutputType; + } + : CallToolResult; + /** * Callback for a tool handler registered with Server.tool(). * @@ -1148,13 +1158,21 @@ export class ResourceTemplate { * - `content` if the tool does not have an outputSchema * - Both fields are optional but typically one should be provided */ -export type ToolCallback = - Args extends ZodRawShape +export type ToolCallback< + InputArgs extends undefined | ZodRawShape = undefined, + OutputArgs extends undefined | ZodRawShape = undefined +> = InputArgs extends ZodRawShape ? ( - args: z.objectOutputType, - extra: RequestHandlerExtra, - ) => CallToolResult | Promise - : (extra: RequestHandlerExtra) => CallToolResult | Promise; + args: z.objectOutputType, + extra: RequestHandlerExtra + ) => + | TypedCallToolResult + | Promise> + : ( + extra: RequestHandlerExtra + ) => + | TypedCallToolResult + | Promise>; export type RegisteredTool = { title?: string; @@ -1162,26 +1180,29 @@ export type RegisteredTool = { inputSchema?: AnyZodObject; outputSchema?: AnyZodObject; annotations?: ToolAnnotations; - callback: ToolCallback; + callback: ToolCallback; enabled: boolean; enable(): void; disable(): void; - update( - updates: { - name?: string | null, - title?: string, - description?: string, - paramsSchema?: InputArgs, - outputSchema?: OutputArgs, - annotations?: ToolAnnotations, - callback?: ToolCallback, - enabled?: boolean - }): void - remove(): void + update< + InputArgs extends ZodRawShape, + OutputArgs extends ZodRawShape + >(updates: { + name?: string | null; + title?: string; + description?: string; + paramsSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + callback?: ToolCallback + enabled?: boolean + }): void; + remove(): void; }; const EMPTY_OBJECT_JSON_SCHEMA = { type: "object" as const, + properties: {}, }; // Helper to check if an object is a Zod schema (ZodRawShape)