Merge branch 'main' into fix/1
This commit is contained in:
commit
f31cefe2fa
8 changed files with 246 additions and 57 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
57
src/examples/server/toolWithSampleServer.ts
Normal file
57
src/examples/server/toolWithSampleServer.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue