Merge branch 'main' into fix/1

This commit is contained in:
오병진 2025-07-02 20:24:40 +09:00 • committed by GitHub
commit f31cefe2fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 246 additions and 57 deletions

View file

@ -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,

View file

@ -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<string, string>,
): Promise<Response> {
headers?: Record<string, string>,
): Promise<Response | undefined> {
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<Response> {
): Promise<Response | undefined> {
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;
}

View file

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

View file

@ -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 {

View file

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

View file

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

View file

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

View file

@ -169,7 +169,7 @@ export class McpServer {
}
const args = parseResult.data;
const cb = tool.callback as ToolCallback<ZodRawShape>;
const cb = tool.callback as ToolCallback<ZodRawShape, 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>;
const cb = tool.callback as ToolCallback<undefined, ZodRawShape>;
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>
callback: ToolCallback<ZodRawShape | undefined, ZodRawShape | undefined>
): RegisteredTool {
const registeredTool: RegisteredTool = {
title,
@ -929,7 +929,7 @@ export class McpServer {
outputSchema?: OutputArgs;
annotations?: ToolAnnotations;
},
cb: ToolCallback<InputArgs>
cb: ToolCallback<InputArgs, OutputArgs>
): 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>
cb as ToolCallback<ZodRawShape | undefined, ZodRawShape | undefined>
);
}
@ -1138,6 +1138,16 @@ 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().
*
@ -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 undefined | ZodRawShape = undefined> =
Args extends ZodRawShape
export type ToolCallback<
InputArgs extends undefined | ZodRawShape = undefined,
OutputArgs extends undefined | ZodRawShape = undefined
> = InputArgs extends ZodRawShape
? (
args: z.objectOutputType<Args, ZodTypeAny>,
extra: RequestHandlerExtra<ServerRequest, ServerNotification>,
) => CallToolResult | Promise<CallToolResult>
: (extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => CallToolResult | Promise<CallToolResult>;
args: z.objectOutputType<InputArgs, ZodTypeAny>,
extra: RequestHandlerExtra<ServerRequest, ServerNotification>
) =>
| TypedCallToolResult<OutputArgs>
| Promise<TypedCallToolResult<OutputArgs>>
: (
extra: RequestHandlerExtra<ServerRequest, ServerNotification>
) =>
| TypedCallToolResult<OutputArgs>
| Promise<TypedCallToolResult<OutputArgs>>;
export type RegisteredTool = {
title?: string;
@ -1162,26 +1180,29 @@ export type RegisteredTool = {
inputSchema?: AnyZodObject;
outputSchema?: AnyZodObject;
annotations?: ToolAnnotations;
callback: ToolCallback<undefined | ZodRawShape>;
callback: ToolCallback<ZodRawShape | undefined, ZodRawShape | undefined>;
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>,
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, OutputArgs>
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)