Merge branch 'main' into xiaofhua/pid-StdioClientTransport

This commit is contained in:
ihrpr 2025-06-30 14:44:36 +01:00
commit 0db4407d43
No known key found for this signature in database
9 changed files with 10145 additions and 873 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,12 @@
import pkceChallenge from "pkce-challenge";
import { LATEST_PROTOCOL_VERSION } from "../types.js";
import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull } from "../shared/auth.js";
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";
import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js";
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";
import { checkResourceAllowed, resourceUrlFromServerUrl } from "../shared/auth-utils.js";
/**
* Implements an end-to-end OAuth client to be used with one MCP server.
*
*
* This client relies upon a concept of an authorized "session," the exact
* meaning of which is application-defined. Tokens, authorization codes, and
* code verifiers should not cross different sessions.
@ -21,6 +22,11 @@ export interface OAuthClientProvider {
*/
get clientMetadata(): OAuthClientMetadata;
/**
* Returns a OAuth2 state parameter.
*/
state?(): string | Promise<string>;
/**
* Loads information about this OAuth client, as registered already with the
* server, or returns `undefined` if the client is not registered with the
@ -32,7 +38,7 @@ export interface OAuthClientProvider {
* If implemented, this permits the OAuth client to dynamically register with
* the server. Client information saved this way should later be read via
* `clientInformation()`.
*
*
* This method is not required to be implemented if client information is
* statically known (e.g., pre-registered).
*/
@ -66,6 +72,15 @@ export interface OAuthClientProvider {
* the authorization result.
*/
codeVerifier(): string | Promise<string>;
/**
* If defined, overrides the selection and validation of the
* RFC 8707 Resource Indicator. If left undefined, default
* validation behavior will be used.
*
* Implementations must verify the returned resource matches the MCP server.
*/
validateResourceURL?(serverUrl: string | URL, resource?: string): Promise<URL | undefined>;
}
export type AuthResult = "AUTHORIZED" | "REDIRECT";
@ -78,14 +93,36 @@ export class UnauthorizedError extends Error {
/**
* Orchestrates the full auth flow with a server.
*
*
* This can be used as a single entry point for all authorization functionality,
* instead of linking together the other lower-level functions in this module.
*/
export async function auth(
provider: OAuthClientProvider,
{ serverUrl, authorizationCode }: { serverUrl: string | URL, authorizationCode?: string }): Promise<AuthResult> {
const metadata = await discoverOAuthMetadata(serverUrl);
{ serverUrl,
authorizationCode,
scope,
resourceMetadataUrl
}: {
serverUrl: string | URL;
authorizationCode?: string;
scope?: string;
resourceMetadataUrl?: URL }): Promise<AuthResult> {
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
let authorizationServerUrl = serverUrl;
try {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {resourceMetadataUrl});
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
authorizationServerUrl = resourceMetadata.authorization_servers[0];
}
} catch {
// Ignore errors and fall back to /.well-known/oauth-authorization-server
}
const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
const metadata = await discoverOAuthMetadata(authorizationServerUrl);
// Handle client registration if needed
let clientInformation = await Promise.resolve(provider.clientInformation());
@ -98,7 +135,7 @@ export async function auth(
throw new Error("OAuth client information must be saveable for dynamic registration");
}
const fullInformation = await registerClient(serverUrl, {
const fullInformation = await registerClient(authorizationServerUrl, {
metadata,
clientMetadata: provider.clientMetadata,
});
@ -110,12 +147,13 @@ export async function auth(
// Exchange authorization code for tokens
if (authorizationCode !== undefined) {
const codeVerifier = await provider.codeVerifier();
const tokens = await exchangeAuthorization(serverUrl, {
const tokens = await exchangeAuthorization(authorizationServerUrl, {
metadata,
clientInformation,
authorizationCode,
codeVerifier,
redirectUri: provider.redirectUrl,
resource,
});
await provider.saveTokens(tokens);
@ -128,24 +166,30 @@ export async function auth(
if (tokens?.refresh_token) {
try {
// Attempt to refresh the token
const newTokens = await refreshAuthorization(serverUrl, {
const newTokens = await refreshAuthorization(authorizationServerUrl, {
metadata,
clientInformation,
refreshToken: tokens.refresh_token,
resource,
});
await provider.saveTokens(newTokens);
return "AUTHORIZED";
} catch (error) {
console.error("Could not refresh OAuth tokens:", error);
} catch {
// Could not refresh OAuth tokens
}
}
const state = provider.state ? await provider.state() : undefined;
// Start new authorization flow
const { authorizationUrl, codeVerifier } = await startAuthorization(serverUrl, {
const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, {
metadata,
clientInformation,
redirectUrl: provider.redirectUrl
state,
redirectUrl: provider.redirectUrl,
scope: scope || provider.clientMetadata.scope,
resource,
});
await provider.saveCodeVerifier(codeVerifier);
@ -153,17 +197,73 @@ export async function auth(
return "REDIRECT";
}
export async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise<URL | undefined> {
const defaultResource = resourceUrlFromServerUrl(serverUrl);
// If provider has custom validation, delegate to it
if (provider.validateResourceURL) {
return await provider.validateResourceURL(defaultResource, resourceMetadata?.resource);
}
// Only include resource parameter when Protected Resource Metadata is present
if (!resourceMetadata) {
return undefined;
}
// Validate that the metadata's resource is compatible with our request
if (!checkResourceAllowed({ requestedResource: defaultResource, configuredResource: resourceMetadata.resource })) {
throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`);
}
// Prefer the resource from metadata since it's what the server is telling us to request
return new URL(resourceMetadata.resource);
}
/**
* Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata.
* Extract resource_metadata from response header.
*/
export function extractResourceMetadataUrl(res: Response): URL | undefined {
const authenticateHeader = res.headers.get("WWW-Authenticate");
if (!authenticateHeader) {
return undefined;
}
const [type, scheme] = authenticateHeader.split(' ');
if (type.toLowerCase() !== 'bearer' || !scheme) {
return undefined;
}
const regex = /resource_metadata="([^"]*)"/;
const match = regex.exec(authenticateHeader);
if (!match) {
return undefined;
}
try {
return new URL(match[1]);
} catch {
return undefined;
}
}
/**
* Looks up RFC 9728 OAuth 2.0 Protected Resource 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(
export async function discoverOAuthProtectedResourceMetadata(
serverUrl: string | URL,
opts?: { protocolVersion?: string },
): Promise<OAuthMetadata | undefined> {
const url = new URL("/.well-known/oauth-authorization-server", serverUrl);
opts?: { protocolVersion?: string, resourceMetadataUrl?: string | URL },
): Promise<OAuthProtectedResourceMetadata> {
let url: URL
if (opts?.resourceMetadataUrl) {
url = new URL(opts?.resourceMetadataUrl);
} else {
url = new URL("/.well-known/oauth-protected-resource", serverUrl);
}
let response: Response;
try {
response = await fetch(url, {
@ -181,6 +281,99 @@ export async function discoverOAuthMetadata(
}
if (response.status === 404) {
throw new Error(`Resource server does not implement OAuth 2.0 Protected Resource Metadata.`);
}
if (!response.ok) {
throw new Error(
`HTTP ${response.status} trying to load well-known OAuth protected resource metadata.`,
);
}
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> {
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);
}
throw error;
}
}
/**
* Constructs the well-known path for OAuth metadata discovery
*/
function buildWellKnownPath(pathname: string): string {
let wellKnownPath = `/.well-known/oauth-authorization-server${pathname}`;
if (pathname.endsWith('/')) {
// Strip trailing slash from pathname to avoid double slashes
wellKnownPath = wellKnownPath.slice(0, -1);
}
return wellKnownPath;
}
/**
* Tries to discover OAuth metadata at a specific URL
*/
async function tryMetadataDiscovery(
url: URL,
protocolVersion: string,
): Promise<Response> {
const headers = {
"MCP-Protocol-Version": protocolVersion
};
return await fetchWithCorsRetry(url, headers);
}
/**
* Determines if fallback to root discovery should be attempted
*/
function shouldAttemptFallback(response: Response, pathname: string): boolean {
return response.status === 404 && pathname !== '/';
}
export async function discoverOAuthMetadata(
authorizationServerUrl: string | URL,
opts?: { protocolVersion?: string },
): Promise<OAuthMetadata | undefined> {
const issuer = new URL(authorizationServerUrl);
const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION;
// Try path-aware discovery first (RFC 8414 compliant)
const wellKnownPath = buildWellKnownPath(issuer.pathname);
const pathAwareUrl = new URL(wellKnownPath, issuer);
let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion);
// 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) {
return undefined;
}
@ -197,15 +390,21 @@ export async function discoverOAuthMetadata(
* Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL.
*/
export async function startAuthorization(
serverUrl: string | URL,
authorizationServerUrl: string | URL,
{
metadata,
clientInformation,
redirectUrl,
scope,
state,
resource,
}: {
metadata?: OAuthMetadata;
clientInformation: OAuthClientInformation;
redirectUrl: string | URL;
scope?: string;
state?: string;
resource?: URL;
},
): Promise<{ authorizationUrl: URL; codeVerifier: string }> {
const responseType = "code";
@ -230,7 +429,7 @@ export async function startAuthorization(
);
}
} else {
authorizationUrl = new URL("/authorize", serverUrl);
authorizationUrl = new URL("/authorize", authorizationServerUrl);
}
// Generate PKCE challenge
@ -247,6 +446,18 @@ export async function startAuthorization(
);
authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl));
if (state) {
authorizationUrl.searchParams.set("state", state);
}
if (scope) {
authorizationUrl.searchParams.set("scope", scope);
}
if (resource) {
authorizationUrl.searchParams.set("resource", resource.href);
}
return { authorizationUrl, codeVerifier };
}
@ -254,19 +465,21 @@ export async function startAuthorization(
* Exchanges an authorization code for an access token with the given server.
*/
export async function exchangeAuthorization(
serverUrl: string | URL,
authorizationServerUrl: string | URL,
{
metadata,
clientInformation,
authorizationCode,
codeVerifier,
redirectUri,
resource,
}: {
metadata?: OAuthMetadata;
clientInformation: OAuthClientInformation;
authorizationCode: string;
codeVerifier: string;
redirectUri: string | URL;
resource?: URL;
},
): Promise<OAuthTokens> {
const grantType = "authorization_code";
@ -284,7 +497,7 @@ export async function exchangeAuthorization(
);
}
} else {
tokenUrl = new URL("/token", serverUrl);
tokenUrl = new URL("/token", authorizationServerUrl);
}
// Exchange code for tokens
@ -300,6 +513,10 @@ export async function exchangeAuthorization(
params.set("client_secret", clientInformation.client_secret);
}
if (resource) {
params.set("resource", resource.href);
}
const response = await fetch(tokenUrl, {
method: "POST",
headers: {
@ -319,15 +536,17 @@ export async function exchangeAuthorization(
* Exchange a refresh token for an updated access token.
*/
export async function refreshAuthorization(
serverUrl: string | URL,
authorizationServerUrl: string | URL,
{
metadata,
clientInformation,
refreshToken,
resource,
}: {
metadata?: OAuthMetadata;
clientInformation: OAuthClientInformation;
refreshToken: string;
resource?: URL;
},
): Promise<OAuthTokens> {
const grantType = "refresh_token";
@ -345,7 +564,7 @@ export async function refreshAuthorization(
);
}
} else {
tokenUrl = new URL("/token", serverUrl);
tokenUrl = new URL("/token", authorizationServerUrl);
}
// Exchange refresh token
@ -359,6 +578,10 @@ export async function refreshAuthorization(
params.set("client_secret", clientInformation.client_secret);
}
if (resource) {
params.set("resource", resource.href);
}
const response = await fetch(tokenUrl, {
method: "POST",
headers: {
@ -366,19 +589,18 @@ export async function refreshAuthorization(
},
body: params,
});
if (!response.ok) {
throw new Error(`Token refresh failed: HTTP ${response.status}`);
}
return OAuthTokensSchema.parse(await response.json());
return OAuthTokensSchema.parse({ refresh_token: refreshToken, ...(await response.json()) });
}
/**
* Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591.
*/
export async function registerClient(
serverUrl: string | URL,
authorizationServerUrl: string | URL,
{
metadata,
clientMetadata,
@ -396,7 +618,7 @@ export async function registerClient(
registrationUrl = new URL(metadata.registration_endpoint);
} else {
registrationUrl = new URL("/register", serverUrl);
registrationUrl = new URL("/register", authorizationServerUrl);
}
const response = await fetch(registrationUrl, {
@ -412,4 +634,4 @@ export async function registerClient(
}
return OAuthClientInformationFullSchema.parse(await response.json());
}
}

View file

@ -12,7 +12,9 @@ import {
InitializeRequestSchema,
ListResourcesRequestSchema,
ListToolsRequestSchema,
CallToolRequestSchema,
CreateMessageRequestSchema,
ElicitRequestSchema,
ListRootsRequestSchema,
ErrorCode,
} from "../types.js";
@ -20,6 +22,9 @@ import { Transport } from "../shared/transport.js";
import { Server } from "../server/index.js";
import { InMemoryTransport } from "../inMemory.js";
/***
* Test: Initialize with Matching Protocol Version
*/
test("should initialize with matching protocol version", async () => {
const clientTransport: Transport = {
start: jest.fn().mockResolvedValue(undefined),
@ -75,6 +80,9 @@ test("should initialize with matching protocol version", async () => {
expect(client.getInstructions()).toEqual("test instructions");
});
/***
* Test: Initialize with Supported Older Protocol Version
*/
test("should initialize with supported older protocol version", async () => {
const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1];
const clientTransport: Transport = {
@ -123,6 +131,9 @@ test("should initialize with supported older protocol version", async () => {
expect(client.getInstructions()).toBeUndefined();
});
/***
* Test: Reject Unsupported Protocol Version
*/
test("should reject unsupported protocol version", async () => {
const clientTransport: Transport = {
start: jest.fn().mockResolvedValue(undefined),
@ -165,6 +176,9 @@ test("should reject unsupported protocol version", async () => {
expect(clientTransport.close).toHaveBeenCalled();
});
/***
* Test: Connect New Client to Old Supported Server Version
*/
test("should connect new client to old, supported server version", async () => {
const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1];
const server = new Server(
@ -228,6 +242,9 @@ test("should connect new client to old, supported server version", async () => {
});
});
/***
* Test: Version Negotiation with Old Client and Newer Server
*/
test("should negotiate version when client is old, and newer server supports its version", async () => {
const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1];
const server = new Server(
@ -291,6 +308,9 @@ test("should negotiate version when client is old, and newer server supports its
});
});
/***
* Test: Throw when Old Client and Server Version Mismatch
*/
test("should throw when client is old, and server doesn't support its version", async () => {
const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1];
const FUTURE_VERSION = "FUTURE_VERSION";
@ -353,6 +373,9 @@ test("should throw when client is old, and server doesn't support its version",
});
/***
* Test: Respect Server Capabilities
*/
test("should respect server capabilities", async () => {
const server = new Server(
{
@ -433,6 +456,9 @@ test("should respect server capabilities", async () => {
).rejects.toThrow("Server does not support completions");
});
/***
* Test: Respect Client Notification Capabilities
*/
test("should respect client notification capabilities", async () => {
const server = new Server(
{
@ -489,6 +515,9 @@ test("should respect client notification capabilities", async () => {
);
});
/***
* Test: Respect Server Notification Capabilities
*/
test("should respect server notification capabilities", async () => {
const server = new Server(
{
@ -535,6 +564,9 @@ test("should respect server notification capabilities", async () => {
);
});
/***
* Test: Only Allow setRequestHandler for Declared Capabilities
*/
test("should only allow setRequestHandler for declared capabilities", () => {
const client = new Client(
{
@ -566,9 +598,47 @@ test("should only allow setRequestHandler for declared capabilities", () => {
}).toThrow("Client does not support roots capability");
});
/*
Test that custom request/notification/result schemas can be used with the Client class.
*/
test("should allow setRequestHandler for declared elicitation capability", () => {
const client = new Client(
{
name: "test-client",
version: "1.0.0",
},
{
capabilities: {
elicitation: {},
},
},
);
// This should work because elicitation is a declared capability
expect(() => {
client.setRequestHandler(ElicitRequestSchema, () => ({
action: "accept",
content: {
username: "test-user",
confirmed: true,
},
}));
}).not.toThrow();
// This should throw because sampling is not a declared capability
expect(() => {
client.setRequestHandler(CreateMessageRequestSchema, () => ({
model: "test-model",
role: "assistant",
content: {
type: "text",
text: "Test response",
},
}));
}).toThrow("Client does not support sampling capability");
});
/***
* Test: Type Checking
* Test that custom request/notification/result schemas can be used with the Client class.
*/
test("should typecheck", () => {
const GetWeatherRequestSchema = RequestSchema.extend({
method: z.literal("weather/get"),
@ -645,6 +715,9 @@ test("should typecheck", () => {
});
});
/***
* Test: Handle Client Cancelling a Request
*/
test("should handle client cancelling a request", async () => {
const server = new Server(
{
@ -700,6 +773,9 @@ test("should handle client cancelling a request", async () => {
await expect(listResourcesPromise).rejects.toBe("Cancelled by test");
});
/***
* Test: Handle Request Timeout
*/
test("should handle request timeout", async () => {
const server = new Server(
{
@ -754,3 +830,474 @@ test("should handle request timeout", async () => {
code: ErrorCode.RequestTimeout,
});
});
describe('outputSchema validation', () => {
/***
* Test: Validate structuredContent Against outputSchema
*/
test('should validate structuredContent against outputSchema', async () => {
const server = new Server({
name: 'test-server',
version: '1.0.0',
}, {
capabilities: {
tools: {},
},
});
// Set up server handlers
server.setRequestHandler(InitializeRequestSchema, async (request) => ({
protocolVersion: request.params.protocolVersion,
capabilities: {},
serverInfo: {
name: 'test-server',
version: '1.0.0',
}
}));
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'test-tool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {},
},
outputSchema: {
type: 'object',
properties: {
result: { type: 'string' },
count: { type: 'number' },
},
required: ['result', 'count'],
additionalProperties: false,
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'test-tool') {
return {
structuredContent: { result: 'success', count: 42 },
};
}
throw new Error('Unknown tool');
});
const client = new Client({
name: 'test-client',
version: '1.0.0',
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
// List tools to cache the schemas
await client.listTools();
// Call the tool - should validate successfully
const result = await client.callTool({ name: 'test-tool' });
expect(result.structuredContent).toEqual({ result: 'success', count: 42 });
});
/***
* Test: Throw Error when structuredContent Does Not Match Schema
*/
test('should throw error when structuredContent does not match schema', async () => {
const server = new Server({
name: 'test-server',
version: '1.0.0',
}, {
capabilities: {
tools: {},
},
});
// Set up server handlers
server.setRequestHandler(InitializeRequestSchema, async (request) => ({
protocolVersion: request.params.protocolVersion,
capabilities: {},
serverInfo: {
name: 'test-server',
version: '1.0.0',
}
}));
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'test-tool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {},
},
outputSchema: {
type: 'object',
properties: {
result: { type: 'string' },
count: { type: 'number' },
},
required: ['result', 'count'],
additionalProperties: false,
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'test-tool') {
// Return invalid structured content (count is string instead of number)
return {
structuredContent: { result: 'success', count: 'not a number' },
};
}
throw new Error('Unknown tool');
});
const client = new Client({
name: 'test-client',
version: '1.0.0',
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
// List tools to cache the schemas
await client.listTools();
// Call the tool - should throw validation error
await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow(
/Structured content does not match the tool's output schema/
);
});
/***
* Test: Throw Error when Tool with outputSchema Returns No structuredContent
*/
test('should throw error when tool with outputSchema returns no structuredContent', async () => {
const server = new Server({
name: 'test-server',
version: '1.0.0',
}, {
capabilities: {
tools: {},
},
});
// Set up server handlers
server.setRequestHandler(InitializeRequestSchema, async (request) => ({
protocolVersion: request.params.protocolVersion,
capabilities: {},
serverInfo: {
name: 'test-server',
version: '1.0.0',
}
}));
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'test-tool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {},
},
outputSchema: {
type: 'object',
properties: {
result: { type: 'string' },
},
required: ['result'],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'test-tool') {
// Return content instead of structuredContent
return {
content: [{ type: 'text', text: 'This should be structured content' }],
};
}
throw new Error('Unknown tool');
});
const client = new Client({
name: 'test-client',
version: '1.0.0',
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
// List tools to cache the schemas
await client.listTools();
// Call the tool - should throw error
await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow(
/Tool test-tool has an output schema but did not return structured content/
);
});
/***
* Test: Handle Tools Without outputSchema Normally
*/
test('should handle tools without outputSchema normally', async () => {
const server = new Server({
name: 'test-server',
version: '1.0.0',
}, {
capabilities: {
tools: {},
},
});
// Set up server handlers
server.setRequestHandler(InitializeRequestSchema, async (request) => ({
protocolVersion: request.params.protocolVersion,
capabilities: {},
serverInfo: {
name: 'test-server',
version: '1.0.0',
}
}));
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'test-tool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {},
},
// No outputSchema
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'test-tool') {
// Return regular content
return {
content: [{ type: 'text', text: 'Normal response' }],
};
}
throw new Error('Unknown tool');
});
const client = new Client({
name: 'test-client',
version: '1.0.0',
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
// List tools to cache the schemas
await client.listTools();
// Call the tool - should work normally without validation
const result = await client.callTool({ name: 'test-tool' });
expect(result.content).toEqual([{ type: 'text', text: 'Normal response' }]);
});
/***
* Test: Handle Complex JSON Schema Validation
*/
test('should handle complex JSON schema validation', async () => {
const server = new Server({
name: 'test-server',
version: '1.0.0',
}, {
capabilities: {
tools: {},
},
});
// Set up server handlers
server.setRequestHandler(InitializeRequestSchema, async (request) => ({
protocolVersion: request.params.protocolVersion,
capabilities: {},
serverInfo: {
name: 'test-server',
version: '1.0.0',
}
}));
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'complex-tool',
description: 'A tool with complex schema',
inputSchema: {
type: 'object',
properties: {},
},
outputSchema: {
type: 'object',
properties: {
name: { type: 'string', minLength: 3 },
age: { type: 'integer', minimum: 0, maximum: 120 },
active: { type: 'boolean' },
tags: {
type: 'array',
items: { type: 'string' },
minItems: 1,
},
metadata: {
type: 'object',
properties: {
created: { type: 'string' },
},
required: ['created'],
},
},
required: ['name', 'age', 'active', 'tags', 'metadata'],
additionalProperties: false,
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'complex-tool') {
return {
structuredContent: {
name: 'John Doe',
age: 30,
active: true,
tags: ['user', 'admin'],
metadata: {
created: '2023-01-01T00:00:00Z',
},
},
};
}
throw new Error('Unknown tool');
});
const client = new Client({
name: 'test-client',
version: '1.0.0',
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
// List tools to cache the schemas
await client.listTools();
// Call the tool - should validate successfully
const result = await client.callTool({ name: 'complex-tool' });
expect(result.structuredContent).toBeDefined();
const structuredContent = result.structuredContent as { name: string; age: number };
expect(structuredContent.name).toBe('John Doe');
expect(structuredContent.age).toBe(30);
});
/***
* Test: Fail Validation with Additional Properties When Not Allowed
*/
test('should fail validation with additional properties when not allowed', async () => {
const server = new Server({
name: 'test-server',
version: '1.0.0',
}, {
capabilities: {
tools: {},
},
});
// Set up server handlers
server.setRequestHandler(InitializeRequestSchema, async (request) => ({
protocolVersion: request.params.protocolVersion,
capabilities: {},
serverInfo: {
name: 'test-server',
version: '1.0.0',
}
}));
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'strict-tool',
description: 'A tool with strict schema',
inputSchema: {
type: 'object',
properties: {},
},
outputSchema: {
type: 'object',
properties: {
name: { type: 'string' },
},
required: ['name'],
additionalProperties: false,
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'strict-tool') {
// Return structured content with extra property
return {
structuredContent: {
name: 'John',
extraField: 'not allowed',
},
};
}
throw new Error('Unknown tool');
});
const client = new Client({
name: 'test-client',
version: '1.0.0',
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
// List tools to cache the schemas
await client.listTools();
// Call the tool - should throw validation error due to additional property
await expect(client.callTool({ name: 'strict-tool' })).rejects.toThrow(
/Structured content does not match the tool's output schema/
);
});
});

View file

@ -39,7 +39,12 @@ import {
SubscribeRequest,
SUPPORTED_PROTOCOL_VERSIONS,
UnsubscribeRequest,
Tool,
ErrorCode,
McpError,
} from "../types.js";
import Ajv from "ajv";
import type { ValidateFunction } from "ajv";
export type ClientOptions = ProtocolOptions & {
/**
@ -86,6 +91,8 @@ export class Client<
private _serverVersion?: Implementation;
private _capabilities: ClientCapabilities;
private _instructions?: string;
private _cachedToolOutputValidators: Map<string, ValidateFunction> = new Map();
private _ajv: InstanceType<typeof Ajv>;
/**
* Initializes this client with the given name and version information.
@ -96,6 +103,7 @@ export class Client<
) {
super(options);
this._capabilities = options?.capabilities ?? {};
this._ajv = new Ajv();
}
/**
@ -157,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;
@ -295,6 +307,14 @@ export class Client<
}
break;
case "elicitation/create":
if (!this._capabilities.elicitation) {
throw new Error(
`Client does not support elicitation capability (required for ${method})`,
);
}
break;
case "roots/list":
if (!this._capabilities.roots) {
throw new Error(
@ -413,22 +433,84 @@ export class Client<
| typeof CompatibilityCallToolResultSchema = CallToolResultSchema,
options?: RequestOptions,
) {
return this.request(
const result = await this.request(
{ method: "tools/call", params },
resultSchema,
options,
);
// Check if the tool has an outputSchema
const validator = this.getToolOutputValidator(params.name);
if (validator) {
// If tool has outputSchema, it MUST return structuredContent (unless it's an error)
if (!result.structuredContent && !result.isError) {
throw new McpError(
ErrorCode.InvalidRequest,
`Tool ${params.name} has an output schema but did not return structured content`
);
}
// Only validate structured content if present (not when there's an error)
if (result.structuredContent) {
try {
// Validate the structured content (which is already an object) against the schema
const isValid = validator(result.structuredContent);
if (!isValid) {
throw new McpError(
ErrorCode.InvalidParams,
`Structured content does not match the tool's output schema: ${this._ajv.errorsText(validator.errors)}`
);
}
} catch (error) {
if (error instanceof McpError) {
throw error;
}
throw new McpError(
ErrorCode.InvalidParams,
`Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}`
);
}
}
}
return result;
}
private cacheToolOutputSchemas(tools: Tool[]) {
this._cachedToolOutputValidators.clear();
for (const tool of tools) {
// If the tool has an outputSchema, create and cache the Ajv validator
if (tool.outputSchema) {
try {
const validator = this._ajv.compile(tool.outputSchema);
this._cachedToolOutputValidators.set(tool.name, validator);
} catch {
// Ignore schema compilation errors
}
}
}
}
private getToolOutputValidator(toolName: string): ValidateFunction | undefined {
return this._cachedToolOutputValidators.get(toolName);
}
async listTools(
params?: ListToolsRequest["params"],
options?: RequestOptions,
) {
return this.request(
const result = await this.request(
{ method: "tools/list", params },
ListToolsResultSchema,
options,
);
// Cache the tools and their output schemas for future validation
this.cacheToolOutputSchemas(result.tools);
return result;
}
async sendRootsListChanged() {

View file

@ -6,9 +6,11 @@ import { OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { OAuthTokens } from "../shared/auth.js";
describe("SSEClientTransport", () => {
let server: Server;
let resourceServer: Server;
let authServer: Server;
let transport: SSEClientTransport;
let baseUrl: URL;
let resourceBaseUrl: URL;
let authBaseUrl: URL;
let lastServerRequest: IncomingMessage;
let sendServerMessage: ((message: string) => void) | null = null;
@ -17,8 +19,26 @@ describe("SSEClientTransport", () => {
lastServerRequest = null as unknown as IncomingMessage;
sendServerMessage = null;
authServer = createServer((req, res) => {
if (req.url === "/.well-known/oauth-authorization-server") {
res.writeHead(200, {
"Content-Type": "application/json"
});
res.end(JSON.stringify({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
registration_endpoint: "https://auth.example.com/register",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}));
return;
}
res.writeHead(401).end();
});
// Create a test server that will receive the EventSource connection
server = createServer((req, res) => {
resourceServer = createServer((req, res) => {
lastServerRequest = req;
// Send SSE headers
@ -30,7 +50,7 @@ describe("SSEClientTransport", () => {
// Send the endpoint event
res.write("event: endpoint\n");
res.write(`data: ${baseUrl.href}\n\n`);
res.write(`data: ${resourceBaseUrl.href}\n\n`);
// Store reference to send function for tests
sendServerMessage = (message: string) => {
@ -51,9 +71,9 @@ describe("SSEClientTransport", () => {
});
// Start server on random port
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resourceServer.listen(0, "127.0.0.1", () => {
const addr = resourceServer.address() as AddressInfo;
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
done();
});
@ -62,14 +82,15 @@ describe("SSEClientTransport", () => {
afterEach(async () => {
await transport.close();
await server.close();
await resourceServer.close();
await authServer.close();
jest.clearAllMocks();
});
describe("connection handling", () => {
it("establishes SSE connection and receives endpoint", async () => {
transport = new SSEClientTransport(baseUrl);
transport = new SSEClientTransport(resourceBaseUrl);
await transport.start();
expect(lastServerRequest.headers.accept).toBe("text/event-stream");
@ -78,27 +99,27 @@ describe("SSEClientTransport", () => {
it("rejects if server returns non-200 status", async () => {
// Create a server that returns 403
await server.close();
await resourceServer.close();
server = createServer((req, res) => {
resourceServer = createServer((req, res) => {
res.writeHead(403);
res.end();
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resourceServer.listen(0, "127.0.0.1", () => {
const addr = resourceServer.address() as AddressInfo;
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
transport = new SSEClientTransport(baseUrl);
transport = new SSEClientTransport(resourceBaseUrl);
await expect(transport.start()).rejects.toThrow();
});
it("closes EventSource connection on close()", async () => {
transport = new SSEClientTransport(baseUrl);
transport = new SSEClientTransport(resourceBaseUrl);
await transport.start();
const closePromise = new Promise((resolve) => {
@ -113,7 +134,7 @@ describe("SSEClientTransport", () => {
describe("message handling", () => {
it("receives and parses JSON-RPC messages", async () => {
const receivedMessages: JSONRPCMessage[] = [];
transport = new SSEClientTransport(baseUrl);
transport = new SSEClientTransport(resourceBaseUrl);
transport.onmessage = (msg) => receivedMessages.push(msg);
await transport.start();
@ -136,7 +157,7 @@ describe("SSEClientTransport", () => {
it("handles malformed JSON messages", async () => {
const errors: Error[] = [];
transport = new SSEClientTransport(baseUrl);
transport = new SSEClientTransport(resourceBaseUrl);
transport.onerror = (err) => errors.push(err);
await transport.start();
@ -151,7 +172,7 @@ describe("SSEClientTransport", () => {
});
it("handles messages via POST requests", async () => {
transport = new SSEClientTransport(baseUrl);
transport = new SSEClientTransport(resourceBaseUrl);
await transport.start();
const testMessage: JSONRPCMessage = {
@ -179,9 +200,9 @@ describe("SSEClientTransport", () => {
it("handles POST request failures", async () => {
// Create a server that returns 500 for POST
await server.close();
await resourceServer.close();
server = createServer((req, res) => {
resourceServer = createServer((req, res) => {
if (req.method === "GET") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
@ -189,7 +210,7 @@ describe("SSEClientTransport", () => {
Connection: "keep-alive",
});
res.write("event: endpoint\n");
res.write(`data: ${baseUrl.href}\n\n`);
res.write(`data: ${resourceBaseUrl.href}\n\n`);
} else {
res.writeHead(500);
res.end("Internal error");
@ -197,14 +218,14 @@ describe("SSEClientTransport", () => {
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resourceServer.listen(0, "127.0.0.1", () => {
const addr = resourceServer.address() as AddressInfo;
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
transport = new SSEClientTransport(baseUrl);
transport = new SSEClientTransport(resourceBaseUrl);
await transport.start();
const testMessage: JSONRPCMessage = {
@ -229,7 +250,7 @@ describe("SSEClientTransport", () => {
return fetch(url.toString(), { ...init, headers });
};
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
eventSourceInit: {
fetch: fetchWithAuth,
},
@ -247,7 +268,7 @@ describe("SSEClientTransport", () => {
"X-Custom-Header": "custom-value",
};
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
requestInit: {
headers: customHeaders,
},
@ -319,7 +340,7 @@ describe("SSEClientTransport", () => {
token_type: "Bearer"
});
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
});
@ -335,7 +356,7 @@ describe("SSEClientTransport", () => {
token_type: "Bearer"
});
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
});
@ -355,27 +376,50 @@ describe("SSEClientTransport", () => {
});
it("attempts auth flow on 401 during SSE connection", async () => {
// Create server that returns 401s
await server.close();
server = createServer((req, res) => {
// Create server that returns 401s
resourceServer.close();
authServer.close();
// Start auth server on random port
await new Promise<void>(resolve => {
authServer.listen(0, "127.0.0.1", () => {
const addr = authServer.address() as AddressInfo;
authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
resourceServer = createServer((req, res) => {
lastServerRequest = req;
if (req.url === "/.well-known/oauth-protected-resource") {
res.writeHead(200, {
'Content-Type': 'application/json',
})
.end(JSON.stringify({
resource: resourceBaseUrl.href,
authorization_servers: [`${authBaseUrl}`],
}));
return;
}
if (req.url !== "/") {
res.writeHead(404).end();
res.writeHead(404).end();
} else {
res.writeHead(401).end();
}
});
await new Promise<void>(resolve => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resourceServer.listen(0, "127.0.0.1", () => {
const addr = resourceServer.address() as AddressInfo;
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
});
@ -385,13 +429,33 @@ describe("SSEClientTransport", () => {
it("attempts auth flow on 401 during POST request", async () => {
// Create server that accepts SSE but returns 401 on POST
await server.close();
resourceServer.close();
authServer.close();
server = createServer((req, res) => {
await new Promise<void>(resolve => {
authServer.listen(0, "127.0.0.1", () => {
const addr = authServer.address() as AddressInfo;
authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
resourceServer = createServer((req, res) => {
lastServerRequest = req;
switch (req.method) {
case "GET":
if (req.url === "/.well-known/oauth-protected-resource") {
res.writeHead(200, {
'Content-Type': 'application/json',
})
.end(JSON.stringify({
resource: resourceBaseUrl.href,
authorization_servers: [`${authBaseUrl}`],
}));
return;
}
if (req.url !== "/") {
res.writeHead(404).end();
return;
@ -403,25 +467,25 @@ describe("SSEClientTransport", () => {
Connection: "keep-alive",
});
res.write("event: endpoint\n");
res.write(`data: ${baseUrl.href}\n\n`);
res.write(`data: ${resourceBaseUrl.href}\n\n`);
break;
case "POST":
res.writeHead(401);
res.end();
res.writeHead(401);
res.end();
break;
}
});
await new Promise<void>(resolve => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resourceServer.listen(0, "127.0.0.1", () => {
const addr = resourceServer.address() as AddressInfo;
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
});
@ -448,7 +512,7 @@ describe("SSEClientTransport", () => {
"X-Custom-Header": "custom-value",
};
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
requestInit: {
headers: customHeaders,
@ -483,11 +547,14 @@ describe("SSEClientTransport", () => {
});
// Create server that returns 401 for expired token, then accepts new token
await server.close();
resourceServer.close();
authServer.close();
let connectionAttempts = 0;
server = createServer((req, res) => {
lastServerRequest = req;
authServer = createServer((req, res) => {
if (req.url === "/.well-known/oauth-authorization-server") {
res.writeHead(404).end();
return;
}
if (req.url === "/token" && req.method === "POST") {
// Handle token refresh request
@ -512,16 +579,44 @@ describe("SSEClientTransport", () => {
return;
}
res.writeHead(401).end();
});
// Start auth server on random port
await new Promise<void>(resolve => {
authServer.listen(0, "127.0.0.1", () => {
const addr = authServer.address() as AddressInfo;
authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
let connectionAttempts = 0;
resourceServer = createServer((req, res) => {
lastServerRequest = req;
if (req.url === "/.well-known/oauth-protected-resource") {
res.writeHead(200, {
'Content-Type': 'application/json',
})
.end(JSON.stringify({
resource: resourceBaseUrl.href,
authorization_servers: [`${authBaseUrl}`],
}));
return;
}
if (req.url !== "/") {
res.writeHead(404).end();
return;
}
const auth = req.headers.authorization;
if (auth === "Bearer expired-token") {
res.writeHead(401).end();
return;
}
const auth = req.headers.authorization;
if (auth === "Bearer expired-token") {
res.writeHead(401).end();
return;
}
if (auth === "Bearer new-token") {
res.writeHead(200, {
@ -530,23 +625,23 @@ describe("SSEClientTransport", () => {
Connection: "keep-alive",
});
res.write("event: endpoint\n");
res.write(`data: ${baseUrl.href}\n\n`);
res.write(`data: ${resourceBaseUrl.href}\n\n`);
connectionAttempts++;
return;
}
res.writeHead(401).end();
res.writeHead(401).end();
});
await new Promise<void>(resolve => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resourceServer.listen(0, "127.0.0.1", () => {
const addr = resourceServer.address() as AddressInfo;
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
});
@ -573,12 +668,15 @@ describe("SSEClientTransport", () => {
currentTokens = tokens;
});
// Create server that accepts SSE but returns 401 on POST with expired token
await server.close();
// Create server that returns 401 for expired token, then accepts new token
resourceServer.close();
authServer.close();
let postAttempts = 0;
server = createServer((req, res) => {
lastServerRequest = req;
authServer = createServer((req, res) => {
if (req.url === "/.well-known/oauth-authorization-server") {
res.writeHead(404).end();
return;
}
if (req.url === "/token" && req.method === "POST") {
// Handle token refresh request
@ -603,6 +701,34 @@ describe("SSEClientTransport", () => {
return;
}
res.writeHead(401).end();
});
// Start auth server on random port
await new Promise<void>(resolve => {
authServer.listen(0, "127.0.0.1", () => {
const addr = authServer.address() as AddressInfo;
authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
let postAttempts = 0;
resourceServer = createServer((req, res) => {
lastServerRequest = req;
if (req.url === "/.well-known/oauth-protected-resource") {
res.writeHead(200, {
'Content-Type': 'application/json',
})
.end(JSON.stringify({
resource: resourceBaseUrl.href,
authorization_servers: [`${authBaseUrl}`],
}));
return;
}
switch (req.method) {
case "GET":
if (req.url !== "/") {
@ -616,7 +742,7 @@ describe("SSEClientTransport", () => {
Connection: "keep-alive",
});
res.write("event: endpoint\n");
res.write(`data: ${baseUrl.href}\n\n`);
res.write(`data: ${resourceBaseUrl.href}\n\n`);
break;
case "POST": {
@ -625,33 +751,33 @@ describe("SSEClientTransport", () => {
return;
}
const auth = req.headers.authorization;
if (auth === "Bearer expired-token") {
res.writeHead(401).end();
return;
}
if (auth === "Bearer new-token") {
res.writeHead(200).end();
postAttempts++;
return;
}
const auth = req.headers.authorization;
if (auth === "Bearer expired-token") {
res.writeHead(401).end();
return;
}
if (auth === "Bearer new-token") {
res.writeHead(200).end();
postAttempts++;
return;
}
res.writeHead(401).end();
break;
}
}
});
await new Promise<void>(resolve => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resourceServer.listen(0, "127.0.0.1", () => {
const addr = resourceServer.address() as AddressInfo;
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
});
@ -688,10 +814,14 @@ describe("SSEClientTransport", () => {
});
// Create server that returns 401 for all tokens
await server.close();
resourceServer.close();
authServer.close();
server = createServer((req, res) => {
lastServerRequest = req;
authServer = createServer((req, res) => {
if (req.url === "/.well-known/oauth-authorization-server") {
res.writeHead(404).end();
return;
}
if (req.url === "/token" && req.method === "POST") {
// Handle token refresh request - always fail
@ -699,6 +829,34 @@ describe("SSEClientTransport", () => {
return;
}
res.writeHead(401).end();
});
// Start auth server on random port
await new Promise<void>(resolve => {
authServer.listen(0, "127.0.0.1", () => {
const addr = authServer.address() as AddressInfo;
authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
resourceServer = createServer((req, res) => {
lastServerRequest = req;
if (req.url === "/.well-known/oauth-protected-resource") {
res.writeHead(200, {
'Content-Type': 'application/json',
})
.end(JSON.stringify({
resource: resourceBaseUrl.href,
authorization_servers: [`${authBaseUrl}`],
}));
return;
}
if (req.url !== "/") {
res.writeHead(404).end();
return;
@ -707,14 +865,14 @@ describe("SSEClientTransport", () => {
});
await new Promise<void>(resolve => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resourceServer.listen(0, "127.0.0.1", () => {
const addr = resourceServer.address() as AddressInfo;
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
transport = new SSEClientTransport(baseUrl, {
transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: mockAuthProvider,
});

View file

@ -1,7 +1,7 @@
import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource";
import { Transport } from "../shared/transport.js";
import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
export class SseError extends Error {
constructor(
@ -19,23 +19,23 @@ export class SseError extends Error {
export type SSEClientTransportOptions = {
/**
* An OAuth client provider to use for authentication.
*
*
* When an `authProvider` is specified and the SSE connection is started:
* 1. The connection is attempted with any existing access token from the `authProvider`.
* 2. If the access token has expired, the `authProvider` is used to refresh the token.
* 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`.
*
*
* After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `SSEClientTransport.finishAuth` with the authorization code before retrying the connection.
*
*
* If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown.
*
*
* `UnauthorizedError` might also be thrown when sending any message over the SSE transport, indicating that the session has expired, and needs to be re-authed and reconnected.
*/
authProvider?: OAuthClientProvider;
/**
* Customizes the initial SSE request to the server (the request that begins the stream).
*
*
* NOTE: Setting this property will prevent an `Authorization` header from
* being automatically attached to the SSE request, if an `authProvider` is
* also given. This can be worked around by setting the `Authorization` header
@ -58,9 +58,11 @@ export class SSEClientTransport implements Transport {
private _endpoint?: URL;
private _abortController?: AbortController;
private _url: URL;
private _resourceMetadataUrl?: URL;
private _eventSourceInit?: EventSourceInit;
private _requestInit?: RequestInit;
private _authProvider?: OAuthClientProvider;
private _protocolVersion?: string;
onclose?: () => void;
onerror?: (error: Error) => void;
@ -71,6 +73,7 @@ export class SSEClientTransport implements Transport {
opts?: SSEClientTransportOptions,
) {
this._url = url;
this._resourceMetadataUrl = undefined;
this._eventSourceInit = opts?.eventSourceInit;
this._requestInit = opts?.requestInit;
this._authProvider = opts?.authProvider;
@ -83,7 +86,7 @@ export class SSEClientTransport implements Transport {
let result: AuthResult;
try {
result = await auth(this._authProvider, { serverUrl: this._url });
result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
} catch (error) {
this.onerror?.(error as Error);
throw error;
@ -97,35 +100,52 @@ export class SSEClientTransport implements Transport {
}
private async _commonHeaders(): Promise<HeadersInit> {
const headers: HeadersInit = {};
const headers = {
...this._requestInit?.headers,
} as HeadersInit & Record<string, string>;
if (this._authProvider) {
const tokens = await this._authProvider.tokens();
if (tokens) {
headers["Authorization"] = `Bearer ${tokens.access_token}`;
}
}
if (this._protocolVersion) {
headers["mcp-protocol-version"] = this._protocolVersion;
}
return headers;
}
private _startOrAuth(): Promise<void> {
const fetchImpl = (this?._eventSourceInit?.fetch || fetch) as typeof fetch
return new Promise((resolve, reject) => {
this._eventSource = new EventSource(
this._url.href,
this._eventSourceInit ?? {
fetch: (url, init) => this._commonHeaders().then((headers) => fetch(url, {
...init,
headers: {
...headers,
Accept: "text/event-stream"
{
...this._eventSourceInit,
fetch: async (url, init) => {
const headers = await this._commonHeaders()
const response = await fetchImpl(url, {
...init,
headers: new Headers({
...headers,
Accept: "text/event-stream"
})
})
if (response.status === 401 && response.headers.has('www-authenticate')) {
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
}
})),
return response
},
},
);
this._abortController = new AbortController();
this._eventSource.onerror = (event) => {
if (event.code === 401 && this._authProvider) {
this._authThenStart().then(resolve, reject);
return;
}
@ -193,7 +213,7 @@ export class SSEClientTransport implements Transport {
throw new UnauthorizedError("No auth provider");
}
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode });
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl });
if (result !== "AUTHORIZED") {
throw new UnauthorizedError("Failed to authorize");
}
@ -212,7 +232,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,
@ -225,7 +245,10 @@ export class SSEClientTransport implements Transport {
const response = await fetch(this._endpoint, init);
if (!response.ok) {
if (response.status === 401 && this._authProvider) {
const result = await auth(this._authProvider, { serverUrl: this._url });
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
if (result !== "AUTHORIZED") {
throw new UnauthorizedError();
}
@ -244,4 +267,8 @@ export class SSEClientTransport implements Transport {
throw error;
}
}
setProtocolVersion(version: string): void {
this._protocolVersion = version;
}
}

View file

@ -56,6 +56,7 @@ export const DEFAULT_INHERITED_ENV_VARS =
"TEMP",
"USERNAME",
"USERPROFILE",
"PROGRAMFILES",
]
: /* list inspired by the default env inheritance of sudo */
["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"];

View file

@ -476,6 +476,37 @@ describe("StreamableHTTPClientTransport", () => {
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it("should always send specified custom headers (Headers class)", async () => {
const requestInit = {
headers: new Headers({
"X-Custom-Header": "CustomValue"
})
};
transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), {
requestInit: requestInit
});
let actualReqInit: RequestInit = {};
((global.fetch as jest.Mock)).mockImplementation(
async (_url, reqInit) => {
actualReqInit = reqInit;
return new Response(null, { status: 200, headers: { "content-type": "text/event-stream" } });
}
);
await transport.start();
await transport["_startOrAuthSse"]({});
expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("CustomValue");
(requestInit.headers as Headers).set("X-Custom-Header","SecondCustomValue");
await transport.send({ jsonrpc: "2.0", method: "test", params: {} } as JSONRPCMessage);
expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("SecondCustomValue");
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it("should have exponential backoff with configurable maxRetries", () => {
// This test verifies the maxRetries and backoff calculation directly

View file

@ -1,6 +1,6 @@
import { Transport } from "../shared/transport.js";
import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { EventSourceParserStream } from "eventsource-parser/stream";
// Default reconnection options for StreamableHTTP connections
@ -119,10 +119,12 @@ export type StreamableHTTPClientTransportOptions = {
export class StreamableHTTPClientTransport implements Transport {
private _abortController?: AbortController;
private _url: URL;
private _resourceMetadataUrl?: URL;
private _requestInit?: RequestInit;
private _authProvider?: OAuthClientProvider;
private _sessionId?: string;
private _reconnectionOptions: StreamableHTTPReconnectionOptions;
private _protocolVersion?: string;
onclose?: () => void;
onerror?: (error: Error) => void;
@ -133,6 +135,7 @@ export class StreamableHTTPClientTransport implements Transport {
opts?: StreamableHTTPClientTransportOptions,
) {
this._url = url;
this._resourceMetadataUrl = undefined;
this._requestInit = opts?.requestInit;
this._authProvider = opts?.authProvider;
this._sessionId = opts?.sessionId;
@ -146,7 +149,7 @@ export class StreamableHTTPClientTransport implements Transport {
let result: AuthResult;
try {
result = await auth(this._authProvider, { serverUrl: this._url });
result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
} catch (error) {
this.onerror?.(error as Error);
throw error;
@ -160,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) {
@ -171,10 +174,16 @@ 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 }
);
const extraHeaders = this._normalizeHeaders(this._requestInit?.headers);
return new Headers({
...headers,
...extraHeaders,
});
}
@ -225,7 +234,7 @@ export class StreamableHTTPClientTransport implements Transport {
/**
* Calculates the next reconnection delay using backoff algorithm
*
*
* @param attempt Current reconnection attempt count for the specific stream
* @returns Time to wait in milliseconds before next reconnection attempt
*/
@ -240,9 +249,23 @@ 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> };
}
/**
* Schedule a reconnection attempt with exponential backoff
*
*
* @param lastEventId The ID of the last received event for resumability
* @param attemptCount Current reconnection attempt count for this specific stream
*/
@ -356,7 +379,7 @@ export class StreamableHTTPClientTransport implements Transport {
throw new UnauthorizedError("No auth provider");
}
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode });
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl });
if (result !== "AUTHORIZED") {
throw new UnauthorizedError("Failed to authorize");
}
@ -401,7 +424,10 @@ export class StreamableHTTPClientTransport implements Transport {
if (!response.ok) {
if (response.status === 401 && this._authProvider) {
const result = await auth(this._authProvider, { serverUrl: this._url });
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
if (result !== "AUTHORIZED") {
throw new UnauthorizedError();
}
@ -470,12 +496,12 @@ export class StreamableHTTPClientTransport implements Transport {
/**
* Terminates the current session by sending a DELETE request to the server.
*
*
* Clients that no longer need a particular session
* (e.g., because the user is leaving the client application) SHOULD send an
* HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly
* terminate the session.
*
*
* The server MAY respond with HTTP 405 Method Not Allowed, indicating that
* the server does not allow clients to terminate sessions.
*/
@ -511,4 +537,11 @@ export class StreamableHTTPClientTransport implements Transport {
throw error;
}
}
setProtocolVersion(version: string): void {
this._protocolVersion = version;
}
get protocolVersion(): string | undefined {
return this._protocolVersion;
}
}