Merge branch 'main' into request-propagation-in-tools

This commit is contained in:
Konstantin Konstantinov 2025-06-14 01:10:58 +03:00 • committed by GitHub
commit e714050642
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 378 additions and 23 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/sdk",
"version": "1.12.2",
"version": "1.12.3",
"description": "Model Context Protocol implementation for TypeScript",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",

View file

@ -165,6 +165,10 @@ export class Client<
this._serverCapabilities = result.capabilities;
this._serverVersion = result.serverInfo;
// HTTP transports must set the protocol version in each header after initialization.
if (transport.setProtocolVersion) {
transport.setProtocolVersion(result.protocolVersion);
}
this._instructions = result.instructions;

View file

@ -62,6 +62,7 @@ export class SSEClientTransport implements Transport {
private _eventSourceInit?: EventSourceInit;
private _requestInit?: RequestInit;
private _authProvider?: OAuthClientProvider;
private _protocolVersion?: string;
onclose?: () => void;
onerror?: (error: Error) => void;
@ -99,13 +100,18 @@ export class SSEClientTransport implements Transport {
}
private async _commonHeaders(): Promise<HeadersInit> {
const headers: HeadersInit = { ...this._requestInit?.headers };
const headers = {
...this._requestInit?.headers,
} as HeadersInit & Record<string, string>;
if (this._authProvider) {
const tokens = await this._authProvider.tokens();
if (tokens) {
(headers as Record<string, string>)["Authorization"] = `Bearer ${tokens.access_token}`;
headers["Authorization"] = `Bearer ${tokens.access_token}`;
}
}
if (this._protocolVersion) {
headers["mcp-protocol-version"] = this._protocolVersion;
}
return headers;
}
@ -214,7 +220,7 @@ export class SSEClientTransport implements Transport {
try {
const commonHeaders = await this._commonHeaders();
const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers });
const headers = new Headers(commonHeaders);
headers.set("content-type", "application/json");
const init = {
...this._requestInit,
@ -249,4 +255,8 @@ export class SSEClientTransport implements Transport {
throw error;
}
}
setProtocolVersion(version: string): void {
this._protocolVersion = version;
}
}

View file

@ -124,6 +124,7 @@ export class StreamableHTTPClientTransport implements Transport {
private _authProvider?: OAuthClientProvider;
private _sessionId?: string;
private _reconnectionOptions: StreamableHTTPReconnectionOptions;
private _protocolVersion?: string;
onclose?: () => void;
onerror?: (error: Error) => void;
@ -162,7 +163,7 @@ export class StreamableHTTPClientTransport implements Transport {
}
private async _commonHeaders(): Promise<Headers> {
const headers: HeadersInit = {};
const headers: HeadersInit & Record<string, string> = {};
if (this._authProvider) {
const tokens = await this._authProvider.tokens();
if (tokens) {
@ -173,6 +174,9 @@ export class StreamableHTTPClientTransport implements Transport {
if (this._sessionId) {
headers["mcp-session-id"] = this._sessionId;
}
if (this._protocolVersion) {
headers["mcp-protocol-version"] = this._protocolVersion;
}
return new Headers(
{ ...headers, ...this._requestInit?.headers }
@ -516,4 +520,11 @@ export class StreamableHTTPClientTransport implements Transport {
throw error;
}
}
setProtocolVersion(version: string): void {
this._protocolVersion = version;
}
get protocolVersion(): string | undefined {
return this._protocolVersion;
}
}

View file

@ -5,7 +5,7 @@ import { Client } from '../client/index.js';
import { StreamableHTTPClientTransport } from '../client/streamableHttp.js';
import { McpServer } from '../server/mcp.js';
import { StreamableHTTPServerTransport } from '../server/streamableHttp.js';
import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema } from '../types.js';
import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from '../types.js';
import { z } from 'zod';
describe('Streamable HTTP Transport Session Management', () => {
@ -211,6 +211,27 @@ describe('Streamable HTTP Transport Session Management', () => {
// Clean up
await transport.close();
});
it('should set protocol version after connecting', async () => {
// Create and connect a client
const client = new Client({
name: 'test-client',
version: '1.0.0'
});
const transport = new StreamableHTTPClientTransport(baseUrl);
// Verify protocol version is not set before connecting
expect(transport.protocolVersion).toBeUndefined();
await client.connect(transport);
// Verify protocol version is set after connecting
expect(transport.protocolVersion).toBe(DEFAULT_NEGOTIATED_PROTOCOL_VERSION);
// Clean up
await transport.close();
});
});
describe('Stateful Mode', () => {

View file

@ -251,10 +251,12 @@ export class Server<
this._clientCapabilities = request.params.capabilities;
this._clientVersion = request.params.clientInfo;
return {
protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)
? requestedVersion
: LATEST_PROTOCOL_VERSION,
: LATEST_PROTOCOL_VERSION;
return {
protocolVersion,
capabilities: this.getCapabilities(),
serverInfo: this._serverInfo,
...(this._instructions && { instructions: this._instructions }),

View file

@ -3466,4 +3466,144 @@ describe("prompt()", () => {
expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true);
expect(result.messages[0].content.text).toContain("Received request ID:");
});
/***
* Test: Resource Template Metadata Priority
*/
test("should prioritize individual resource metadata over template metadata", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.resource(
"test",
new ResourceTemplate("test://resource/{id}", {
list: async () => ({
resources: [
{
name: "Resource 1",
uri: "test://resource/1",
description: "Individual resource description",
mimeType: "text/plain",
},
{
name: "Resource 2",
uri: "test://resource/2",
// This resource has no description or mimeType
},
],
}),
}),
{
description: "Template description",
mimeType: "application/json",
},
async (uri) => ({
contents: [
{
uri: uri.href,
text: "Test content",
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "resources/list",
},
ListResourcesResultSchema,
);
expect(result.resources).toHaveLength(2);
// Resource 1 should have its own metadata
expect(result.resources[0].name).toBe("Resource 1");
expect(result.resources[0].description).toBe("Individual resource description");
expect(result.resources[0].mimeType).toBe("text/plain");
// Resource 2 should inherit template metadata
expect(result.resources[1].name).toBe("Resource 2");
expect(result.resources[1].description).toBe("Template description");
expect(result.resources[1].mimeType).toBe("application/json");
});
/***
* Test: Resource Template Metadata Overrides All Fields
*/
test("should allow resource to override all template metadata fields", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.resource(
"test",
new ResourceTemplate("test://resource/{id}", {
list: async () => ({
resources: [
{
name: "Overridden Name",
uri: "test://resource/1",
description: "Overridden description",
mimeType: "text/markdown",
// Add any other metadata fields if they exist
},
],
}),
}),
{
name: "Template Name",
description: "Template description",
mimeType: "application/json",
},
async (uri) => ({
contents: [
{
uri: uri.href,
text: "Test content",
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "resources/list",
},
ListResourcesResultSchema,
);
expect(result.resources).toHaveLength(1);
// All fields should be from the individual resource, not the template
expect(result.resources[0].name).toBe("Overridden Name");
expect(result.resources[0].description).toBe("Overridden description");
expect(result.resources[0].mimeType).toBe("text/markdown");
});
});

View file

@ -373,8 +373,9 @@ export class McpServer {
const result = await template.resourceTemplate.listCallback(extra);
for (const resource of result.resources) {
templateResources.push({
...resource,
...template.metadata,
// the defined resource metadata should override the template metadata if present
...resource,
});
}
}

View file

@ -18,7 +18,6 @@ const MAXIMUM_MESSAGE_SIZE = "4mb";
export class SSEServerTransport implements Transport {
private _sseResponse?: ServerResponse;
private _sessionId: string;
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage, extra: { authInfo?: AuthInfo, requestInfo: RequestInfo }) => void;

View file

@ -185,6 +185,8 @@ async function sendPostRequest(baseUrl: URL, message: JSONRPCMessage | JSONRPCMe
if (sessionId) {
headers["mcp-session-id"] = sessionId;
// After initialization, include the protocol version header
headers["mcp-protocol-version"] = "2025-03-26";
}
return fetch(baseUrl, {
@ -279,7 +281,7 @@ describe("StreamableHTTPServerTransport", () => {
expectErrorResponse(errorData, -32600, /Only one initialization request is allowed/);
});
it("should pandle post requests via sse response correctly", async () => {
it("should handle post requests via sse response correctly", async () => {
sessionId = await initializeServer();
const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId);
@ -441,6 +443,7 @@ describe("StreamableHTTPServerTransport", () => {
headers: {
Accept: "text/event-stream",
"mcp-session-id": sessionId,
"mcp-protocol-version": "2025-03-26",
},
});
@ -482,6 +485,7 @@ describe("StreamableHTTPServerTransport", () => {
headers: {
Accept: "text/event-stream",
"mcp-session-id": sessionId,
"mcp-protocol-version": "2025-03-26",
},
});
@ -513,6 +517,7 @@ describe("StreamableHTTPServerTransport", () => {
headers: {
Accept: "text/event-stream",
"mcp-session-id": sessionId,
"mcp-protocol-version": "2025-03-26",
},
});
@ -524,6 +529,7 @@ describe("StreamableHTTPServerTransport", () => {
headers: {
Accept: "text/event-stream",
"mcp-session-id": sessionId,
"mcp-protocol-version": "2025-03-26",
},
});
@ -542,6 +548,7 @@ describe("StreamableHTTPServerTransport", () => {
headers: {
Accept: "application/json",
"mcp-session-id": sessionId,
"mcp-protocol-version": "2025-03-26",
},
});
@ -735,6 +742,7 @@ describe("StreamableHTTPServerTransport", () => {
headers: {
Accept: "text/event-stream",
"mcp-session-id": sessionId,
"mcp-protocol-version": "2025-03-26",
},
});
@ -770,7 +778,10 @@ describe("StreamableHTTPServerTransport", () => {
// Now DELETE the session
const deleteResponse = await fetch(tempUrl, {
method: "DELETE",
headers: { "mcp-session-id": tempSessionId || "" },
headers: {
"mcp-session-id": tempSessionId || "",
"mcp-protocol-version": "2025-03-26",
},
});
expect(deleteResponse.status).toBe(200);
@ -786,13 +797,124 @@ describe("StreamableHTTPServerTransport", () => {
// Try to delete with invalid session ID
const response = await fetch(baseUrl, {
method: "DELETE",
headers: { "mcp-session-id": "invalid-session-id" },
headers: {
"mcp-session-id": "invalid-session-id",
"mcp-protocol-version": "2025-03-26",
},
});
expect(response.status).toBe(404);
const errorData = await response.json();
expectErrorResponse(errorData, -32001, /Session not found/);
});
describe("protocol version header validation", () => {
it("should accept requests with matching protocol version", async () => {
sessionId = await initializeServer();
// Send request with matching protocol version
const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId);
expect(response.status).toBe(200);
});
it("should accept requests without protocol version header", async () => {
sessionId = await initializeServer();
// Send request without protocol version header
const response = await fetch(baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"mcp-session-id": sessionId,
// No mcp-protocol-version header
},
body: JSON.stringify(TEST_MESSAGES.toolsList),
});
expect(response.status).toBe(200);
});
it("should reject requests with unsupported protocol version", async () => {
sessionId = await initializeServer();
// Send request with unsupported protocol version
const response = await fetch(baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"mcp-session-id": sessionId,
"mcp-protocol-version": "1999-01-01", // Unsupported version
},
body: JSON.stringify(TEST_MESSAGES.toolsList),
});
expect(response.status).toBe(400);
const errorData = await response.json();
expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/);
});
it("should accept when protocol version differs from negotiated version", async () => {
sessionId = await initializeServer();
// Spy on console.warn to verify warning is logged
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
// Send request with different but supported protocol version
const response = await fetch(baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"mcp-session-id": sessionId,
"mcp-protocol-version": "2024-11-05", // Different but supported version
},
body: JSON.stringify(TEST_MESSAGES.toolsList),
});
// Request should still succeed
expect(response.status).toBe(200);
warnSpy.mockRestore();
});
it("should handle protocol version validation for GET requests", async () => {
sessionId = await initializeServer();
// GET request with unsupported protocol version
const response = await fetch(baseUrl, {
method: "GET",
headers: {
Accept: "text/event-stream",
"mcp-session-id": sessionId,
"mcp-protocol-version": "invalid-version",
},
});
expect(response.status).toBe(400);
const errorData = await response.json();
expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/);
});
it("should handle protocol version validation for DELETE requests", async () => {
sessionId = await initializeServer();
// DELETE request with unsupported protocol version
const response = await fetch(baseUrl, {
method: "DELETE",
headers: {
"mcp-session-id": sessionId,
"mcp-protocol-version": "invalid-version",
},
});
expect(response.status).toBe(400);
const errorData = await response.json();
expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/);
});
});
});
describe("StreamableHTTPServerTransport with AuthInfo", () => {
@ -1185,6 +1307,7 @@ describe("StreamableHTTPServerTransport with resumability", () => {
headers: {
Accept: "text/event-stream",
"mcp-session-id": sessionId,
"mcp-protocol-version": "2025-03-26",
},
});
@ -1261,6 +1384,7 @@ describe("StreamableHTTPServerTransport with resumability", () => {
headers: {
Accept: "text/event-stream",
"mcp-session-id": sessionId,
"mcp-protocol-version": "2025-03-26",
"last-event-id": firstEventId
},
});
@ -1347,14 +1471,20 @@ describe("StreamableHTTPServerTransport in stateless mode", () => {
// Open first SSE stream
const stream1 = await fetch(baseUrl, {
method: "GET",
headers: { Accept: "text/event-stream" },
headers: {
Accept: "text/event-stream",
"mcp-protocol-version": "2025-03-26"
},
});
expect(stream1.status).toBe(200);
// Open second SSE stream - should still be rejected, stateless mode still only allows one
const stream2 = await fetch(baseUrl, {
method: "GET",
headers: { Accept: "text/event-stream" },
headers: {
Accept: "text/event-stream",
"mcp-protocol-version": "2025-03-26"
},
});
expect(stream2.status).toBe(409); // Conflict - only one stream allowed
});

View file

@ -1,6 +1,6 @@
import { IncomingMessage, ServerResponse } from "node:http";
import { Transport } from "../shared/transport.js";
import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js";
import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId, SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from "../types.js";
import getRawBody from "raw-body";
import contentType from "content-type";
import { randomUUID } from "node:crypto";
@ -111,7 +111,7 @@ export class StreamableHTTPServerTransport implements Transport {
private _eventStore?: EventStore;
private _onsessioninitialized?: (sessionId: string) => void;
sessionId?: string | undefined;
sessionId?: string;
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage, extra: MessageExtraInfo) => void;
@ -173,6 +173,9 @@ export class StreamableHTTPServerTransport implements Transport {
if (!this.validateSession(req, res)) {
return;
}
if (!this.validateProtocolVersion(req, res)) {
return;
}
// Handle resumability: check for Last-Event-ID header
if (this._eventStore) {
const lastEventId = req.headers['last-event-id'] as string | undefined;
@ -380,11 +383,17 @@ export class StreamableHTTPServerTransport implements Transport {
}
}
// If an Mcp-Session-Id is returned by the server during initialization,
// clients using the Streamable HTTP transport MUST include it
// in the Mcp-Session-Id header on all of their subsequent HTTP requests.
if (!isInitializationRequest && !this.validateSession(req, res)) {
return;
if (!isInitializationRequest) {
// If an Mcp-Session-Id is returned by the server during initialization,
// clients using the Streamable HTTP transport MUST include it
// in the Mcp-Session-Id header on all of their subsequent HTTP requests.
if (!this.validateSession(req, res)) {
return;
}
// Mcp-Protocol-Version header is required for all requests after initialization.
if (!this.validateProtocolVersion(req, res)) {
return;
}
}
@ -459,6 +468,9 @@ export class StreamableHTTPServerTransport implements Transport {
if (!this.validateSession(req, res)) {
return;
}
if (!this.validateProtocolVersion(req, res)) {
return;
}
await this.close();
res.writeHead(200).end();
}
@ -526,6 +538,25 @@ export class StreamableHTTPServerTransport implements Transport {
return true;
}
private validateProtocolVersion(req: IncomingMessage, res: ServerResponse): boolean {
let protocolVersion = req.headers["mcp-protocol-version"] ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION;
if (Array.isArray(protocolVersion)) {
protocolVersion = protocolVersion[protocolVersion.length - 1];
}
if (!SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) {
res.writeHead(400).end(JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32000,
message: `Bad Request: Unsupported protocol version (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")})`
},
id: null
}));
return false;
}
return true;
}
async close(): Promise<void> {
// Close all SSE connections

View file

@ -76,4 +76,9 @@ export interface Transport {
* The session ID generated for this connection.
*/
sessionId?: string;
/**
* Sets the protocol version used for the connection (called when the initialize response is received).
*/
setProtocolVersion?: (version: string) => void;
}

View file

@ -1,6 +1,7 @@
import { z, ZodTypeAny } from "zod";
export const LATEST_PROTOCOL_VERSION = "2025-03-26";
export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26";
export const SUPPORTED_PROTOCOL_VERSIONS = [
LATEST_PROTOCOL_VERSION,
"2024-11-05",