Merge branch 'main' into fix/1

This commit is contained in:
오병진 2025-07-07 18:45:13 +09:00 • committed by GitHub
commit 4607f37953
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 141 additions and 86 deletions

View file

@ -1041,7 +1041,7 @@ Client-side: Handle elicitation requests
```typescript
// This is a placeholder - implement based on your UI framework
async function getInputFromUser(message: string, schema: any): Promise<{
action: "accept" | "reject" | "cancel";
action: "accept" | "decline" | "cancel";
data?: Record<string, any>;
}> {
// This should be implemented depending on the app

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "@modelcontextprotocol/sdk",
"version": "1.13.3",
"version": "1.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@modelcontextprotocol/sdk",
"version": "1.13.3",
"version": "1.15.0",
"license": "MIT",
"dependencies": {
"ajv": "^6.12.6",

View file

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

View file

@ -262,6 +262,38 @@ describe("SSEClientTransport", () => {
expect(lastServerRequest.headers.authorization).toBe(authToken);
});
it("uses custom fetch implementation from options", async () => {
const authToken = "Bearer custom-token";
const fetchWithAuth = jest.fn((url: string | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
headers.set("Authorization", authToken);
return fetch(url.toString(), { ...init, headers });
});
transport = new SSEClientTransport(resourceBaseUrl, {
fetch: fetchWithAuth,
});
await transport.start();
expect(lastServerRequest.headers.authorization).toBe(authToken);
// Send a message to verify fetchWithAuth used for POST as well
const message: JSONRPCMessage = {
jsonrpc: "2.0",
id: "1",
method: "test",
params: {},
};
await transport.send(message);
expect(fetchWithAuth).toHaveBeenCalledTimes(2);
expect(lastServerRequest.method).toBe("POST");
expect(lastServerRequest.headers.authorization).toBe(authToken);
});
it("passes custom headers to fetch requests", async () => {
const customHeaders = {
Authorization: "Bearer test-token",

View file

@ -1,5 +1,5 @@
import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource";
import { Transport } from "../shared/transport.js";
import { Transport, FetchLike } from "../shared/transport.js";
import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
@ -47,6 +47,11 @@ export type SSEClientTransportOptions = {
* Customizes recurring POST requests to the server.
*/
requestInit?: RequestInit;
/**
* Custom fetch implementation used for all network requests.
*/
fetch?: FetchLike;
};
/**
@ -62,6 +67,7 @@ export class SSEClientTransport implements Transport {
private _eventSourceInit?: EventSourceInit;
private _requestInit?: RequestInit;
private _authProvider?: OAuthClientProvider;
private _fetch?: FetchLike;
private _protocolVersion?: string;
onclose?: () => void;
@ -77,6 +83,7 @@ export class SSEClientTransport implements Transport {
this._eventSourceInit = opts?.eventSourceInit;
this._requestInit = opts?.requestInit;
this._authProvider = opts?.authProvider;
this._fetch = opts?.fetch;
}
private async _authThenStart(): Promise<void> {
@ -117,7 +124,7 @@ export class SSEClientTransport implements Transport {
}
private _startOrAuth(): Promise<void> {
const fetchImpl = (this?._eventSourceInit?.fetch || fetch) as typeof fetch
const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typeof fetch
return new Promise((resolve, reject) => {
this._eventSource = new EventSource(
this._url.href,
@ -242,7 +249,7 @@ export class SSEClientTransport implements Transport {
signal: this._abortController?.signal,
};
const response = await fetch(this._endpoint, init);
const response = await (this._fetch ?? fetch)(this._endpoint, init);
if (!response.ok) {
if (response.status === 401 && this._authProvider) {

View file

@ -1,4 +1,4 @@
import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from "./streamableHttp.js";
import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions, StartSSEOptions } from "./streamableHttp.js";
import { OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { JSONRPCMessage } from "../types.js";
@ -443,6 +443,35 @@ describe("StreamableHTTPClientTransport", () => {
expect(errorSpy).toHaveBeenCalled();
});
it("uses custom fetch implementation", async () => {
const authToken = "Bearer custom-token";
const fetchWithAuth = jest.fn((url: string | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
headers.set("Authorization", authToken);
return (global.fetch as jest.Mock)(url, { ...init, headers });
});
(global.fetch as jest.Mock)
.mockResolvedValueOnce(
new Response(null, { status: 200, headers: { "content-type": "text/event-stream" } })
)
.mockResolvedValueOnce(new Response(null, { status: 202 }));
transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { fetch: fetchWithAuth });
await transport.start();
await (transport as unknown as { _startOrAuthSse: (opts: StartSSEOptions) => Promise<void> })._startOrAuthSse({});
await transport.send({ jsonrpc: "2.0", method: "test", params: {}, id: "1" } as JSONRPCMessage);
expect(fetchWithAuth).toHaveBeenCalled();
for (const call of (global.fetch as jest.Mock).mock.calls) {
const headers = call[1].headers as Headers;
expect(headers.get("Authorization")).toBe(authToken);
}
});
it("should always send specified custom headers", async () => {
const requestInit = {
@ -530,7 +559,7 @@ describe("StreamableHTTPClientTransport", () => {
// Second retry - should double (2^1 * 100 = 200)
expect(getDelay(1)).toBe(200);
// Third retry - should double again (2^2 * 100 = 400)
// Third retry - should double again (2^2 * 100 = 400)
expect(getDelay(2)).toBe(400);
// Fourth retry - should double again (2^3 * 100 = 800)

View file

@ -1,4 +1,4 @@
import { Transport } from "../shared/transport.js";
import { Transport, FetchLike } from "../shared/transport.js";
import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { EventSourceParserStream } from "eventsource-parser/stream";
@ -23,7 +23,7 @@ export class StreamableHTTPError extends Error {
/**
* Options for starting or authenticating an SSE connection
*/
interface StartSSEOptions {
export interface StartSSEOptions {
/**
* The resumption token used to continue long-running requests that were interrupted.
*
@ -99,6 +99,11 @@ export type StreamableHTTPClientTransportOptions = {
*/
requestInit?: RequestInit;
/**
* Custom fetch implementation used for all network requests.
*/
fetch?: FetchLike;
/**
* Options to configure the reconnection behavior.
*/
@ -122,6 +127,7 @@ export class StreamableHTTPClientTransport implements Transport {
private _resourceMetadataUrl?: URL;
private _requestInit?: RequestInit;
private _authProvider?: OAuthClientProvider;
private _fetch?: FetchLike;
private _sessionId?: string;
private _reconnectionOptions: StreamableHTTPReconnectionOptions;
private _protocolVersion?: string;
@ -138,6 +144,7 @@ export class StreamableHTTPClientTransport implements Transport {
this._resourceMetadataUrl = undefined;
this._requestInit = opts?.requestInit;
this._authProvider = opts?.authProvider;
this._fetch = opts?.fetch;
this._sessionId = opts?.sessionId;
this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS;
}
@ -200,7 +207,7 @@ export class StreamableHTTPClientTransport implements Transport {
headers.set("last-event-id", resumptionToken);
}
const response = await fetch(this._url, {
const response = await (this._fetch ?? fetch)(this._url, {
method: "GET",
headers,
signal: this._abortController?.signal,
@ -251,15 +258,15 @@ export class StreamableHTTPClientTransport implements Transport {
private _normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
if (!headers) return {};
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries());
}
if (Array.isArray(headers)) {
return Object.fromEntries(headers);
}
return { ...headers as Record<string, string> };
}
@ -414,7 +421,7 @@ export class StreamableHTTPClientTransport implements Transport {
signal: this._abortController?.signal,
};
const response = await fetch(this._url, init);
const response = await (this._fetch ?? fetch)(this._url, init);
// Handle session ID received during initialization
const sessionId = response.headers.get("mcp-session-id");
@ -520,7 +527,7 @@ export class StreamableHTTPClientTransport implements Transport {
signal: this._abortController?.signal,
};
const response = await fetch(this._url, init);
const response = await (this._fetch ?? fetch)(this._url, init);
// We specifically handle 405 as a valid response according to the spec,
// meaning the server does not support explicit session termination

View file

@ -363,7 +363,7 @@ async function connect(url?: string): Promise<void> {
continue;
} else {
console.log('Maximum attempts reached. Declining request.');
return { action: 'reject' };
return { action: 'decline' };
}
}
@ -381,7 +381,7 @@ async function connect(url?: string): Promise<void> {
continue;
} else {
console.log('Maximum attempts reached. Declining request.');
return { action: 'reject' };
return { action: 'decline' };
}
}
@ -408,13 +408,13 @@ async function connect(url?: string): Promise<void> {
console.log('Please re-enter the information...');
continue;
} else {
return { action: 'reject' };
return { action: 'decline' };
}
}
}
console.log('Maximum attempts reached. Declining request.');
return { action: 'reject' };
return { action: 'decline' };
});
transport = new StreamableHTTPClientTransport(

View file

@ -43,14 +43,7 @@ server.registerTool(
void country;
// Simulate weather API call
const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10;
const conditionCandidates = [
"sunny",
"cloudy",
"rainy",
"stormy",
"snowy",
] as const;
const conditions = conditionCandidates[Math.floor(Math.random() * conditionCandidates.length)];
const conditions = ["sunny", "cloudy", "rainy", "stormy", "snowy"][Math.floor(Math.random() * 5)];
const structuredContent = {
temperature: {
@ -84,4 +77,4 @@ async function main() {
main().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});
});

View file

@ -206,12 +206,12 @@ const getServer = () => {
},
],
};
} else if (result.action === 'reject') {
} else if (result.action === 'decline') {
return {
content: [
{
type: 'text',
text: `No information was collected. User rejected ${infoType} information request.`,
text: `No information was collected. User declined ${infoType} information request.`,
},
],
};
@ -433,7 +433,7 @@ if (useOAuth) {
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`);
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
const oauthMetadata: OAuthMetadata = setupAuthServer({authServerUrl, mcpServerUrl, strictResource: strictOAuth});
const oauthMetadata: OAuthMetadata = setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: strictOAuth });
const tokenVerifier = {
verifyAccessToken: async (token: string) => {
@ -499,7 +499,12 @@ const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
// MCP POST endpoint with optional auth
const mcpPostHandler = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
console.log(sessionId? `Received MCP request for session: ${sessionId}`: 'Received MCP request:', req.body);
if (sessionId) {
console.log(`Received MCP request for session: ${sessionId}`);
} else {
console.log('Request body:', req.body);
}
if (useOAuth && req.auth) {
console.log('Authenticated user:', req.auth);
}

View file

@ -538,7 +538,7 @@ test("should allow elicitation reject and cancel without validation", async () =
client.setRequestHandler(ElicitRequestSchema, (request) => {
requestCount++;
if (requestCount === 1) {
return { action: "reject" };
return { action: "decline" };
} else {
return { action: "cancel" };
}
@ -566,7 +566,7 @@ test("should allow elicitation reject and cancel without validation", async () =
requestedSchema: schema,
}),
).resolves.toEqual({
action: "reject",
action: "decline",
});
// Test cancel - should not validate

View file

@ -1312,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
},
})
);

View file

@ -169,7 +169,7 @@ export class McpServer {
}
const args = parseResult.data;
const cb = tool.callback as ToolCallback<ZodRawShape, ZodRawShape>;
const cb = tool.callback as ToolCallback<ZodRawShape>;
try {
result = await Promise.resolve(cb(args, extra));
} catch (error) {
@ -184,7 +184,7 @@ export class McpServer {
};
}
} else {
const cb = tool.callback as ToolCallback<undefined, ZodRawShape>;
const cb = tool.callback as ToolCallback<undefined>;
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<ZodRawShape | undefined, ZodRawShape | undefined>
callback: ToolCallback<ZodRawShape | undefined>
): RegisteredTool {
const registeredTool: RegisteredTool = {
title,
@ -929,7 +929,7 @@ export class McpServer {
outputSchema?: OutputArgs;
annotations?: ToolAnnotations;
},
cb: ToolCallback<InputArgs, OutputArgs>
cb: ToolCallback<InputArgs>
): 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<ZodRawShape | undefined, ZodRawShape | undefined>
cb as ToolCallback<ZodRawShape | undefined>
);
}
@ -1138,16 +1138,6 @@ export class ResourceTemplate {
}
}
/**
* Type helper to create a strongly-typed CallToolResult with structuredContent
*/
type TypedCallToolResult<OutputArgs extends undefined | ZodRawShape> =
OutputArgs extends ZodRawShape
? CallToolResult & {
structuredContent?: z.objectOutputType<OutputArgs, ZodTypeAny>;
}
: CallToolResult;
/**
* Callback for a tool handler registered with Server.tool().
*
@ -1158,21 +1148,13 @@ type TypedCallToolResult<OutputArgs extends undefined | ZodRawShape> =
* - `content` if the tool does not have an outputSchema
* - Both fields are optional but typically one should be provided
*/
export type ToolCallback<
InputArgs extends undefined | ZodRawShape = undefined,
OutputArgs extends undefined | ZodRawShape = undefined
> = InputArgs extends ZodRawShape
export type ToolCallback<Args extends undefined | ZodRawShape = undefined> =
Args extends ZodRawShape
? (
args: z.objectOutputType<InputArgs, ZodTypeAny>,
extra: RequestHandlerExtra<ServerRequest, ServerNotification>
) =>
| TypedCallToolResult<OutputArgs>
| Promise<TypedCallToolResult<OutputArgs>>
: (
extra: RequestHandlerExtra<ServerRequest, ServerNotification>
) =>
| TypedCallToolResult<OutputArgs>
| Promise<TypedCallToolResult<OutputArgs>>;
args: z.objectOutputType<Args, ZodTypeAny>,
extra: RequestHandlerExtra<ServerRequest, ServerNotification>,
) => CallToolResult | Promise<CallToolResult>
: (extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => CallToolResult | Promise<CallToolResult>;
export type RegisteredTool = {
title?: string;
@ -1180,24 +1162,22 @@ export type RegisteredTool = {
inputSchema?: AnyZodObject;
outputSchema?: AnyZodObject;
annotations?: ToolAnnotations;
callback: ToolCallback<ZodRawShape | undefined, ZodRawShape | undefined>;
callback: ToolCallback<undefined | ZodRawShape>;
enabled: boolean;
enable(): void;
disable(): void;
update<
InputArgs extends ZodRawShape,
OutputArgs extends ZodRawShape
>(updates: {
name?: string | null;
title?: string;
description?: string;
paramsSchema?: InputArgs;
outputSchema?: OutputArgs;
annotations?: ToolAnnotations;
callback?: ToolCallback<InputArgs, OutputArgs>
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<InputArgs>,
enabled?: boolean
}): void
remove(): void
};
const EMPTY_OBJECT_JSON_SCHEMA = {

View file

@ -1,10 +1,12 @@
import { JSONRPCMessage, MessageExtraInfo, RequestId } from "../types.js";
export type FetchLike = (url: string | URL, init?: RequestInit) => Promise<Response>;
/**
* Options for sending a JSON-RPC message.
*/
export type TransportSendOptions = {
/**
/**
* If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with.
*/
relatedRequestId?: RequestId;
@ -38,7 +40,7 @@ export interface Transport {
/**
* Sends a JSON-RPC message (request or response).
*
*
* If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with.
*/
send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void>;
@ -64,9 +66,9 @@ export interface Transport {
/**
* Callback for when a message (request or response) is received over the connection.
*
*
* Includes the requestInfo and authInfo if the transport is authenticated.
*
*
* The requestInfo can be used to get the original request information (headers, etc.)
*/
onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;

View file

@ -1238,7 +1238,7 @@ export const ElicitResultSchema = ResultSchema.extend({
/**
* The user's response action.
*/
action: z.enum(["accept", "reject", "cancel"]),
action: z.enum(["accept", "decline", "cancel"]),
/**
* The collected user input content (only present if action is "accept").
*/