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
51 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;
}
}

View file

@ -33,6 +33,12 @@ A full-featured interactive client that connects to a Streamable HTTP server, de
npx tsx src/examples/client/simpleStreamableHttp.ts
```
Example client with OAuth:
```bash
npx tsx src/examples/client/simpleOAuthClient.js
```
### Backwards Compatible Client
A client that implements backwards compatibility according to the [MCP specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility), allowing it to work with both new and legacy servers. This client demonstrates:
@ -67,6 +73,12 @@ A server that implements the Streamable HTTP transport (protocol version 2025-03
```bash
npx tsx src/examples/server/simpleStreamableHttp.ts
# To add a demo of authentication to this example, use:
npx tsx src/examples/server/simpleStreamableHttp.ts --oauth
# To mitigate impersonation risks, enable strict Resource Identifier verification:
npx tsx src/examples/server/simpleStreamableHttp.ts --oauth --oauth-strict
```
##### JSON Response Mode Server

View file

@ -0,0 +1,422 @@
#!/usr/bin/env node
import { createServer } from 'node:http';
import { createInterface } from 'node:readline';
import { URL } from 'node:url';
import { exec } from 'node:child_process';
import { Client } from '../../client/index.js';
import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js';
import { OAuthClientInformation, OAuthClientInformationFull, OAuthClientMetadata, OAuthTokens } from '../../shared/auth.js';
import {
CallToolRequest,
ListToolsRequest,
CallToolResultSchema,
ListToolsResultSchema
} from '../../types.js';
import { OAuthClientProvider, UnauthorizedError } from '../../client/auth.js';
// Configuration
const DEFAULT_SERVER_URL = 'http://localhost:3000/mcp';
const CALLBACK_PORT = 8090; // Use different port than auth server (3001)
const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`;
/**
* In-memory OAuth client provider for demonstration purposes
* In production, you should persist tokens securely
*/
class InMemoryOAuthClientProvider implements OAuthClientProvider {
private _clientInformation?: OAuthClientInformationFull;
private _tokens?: OAuthTokens;
private _codeVerifier?: string;
constructor(
private readonly _redirectUrl: string | URL,
private readonly _clientMetadata: OAuthClientMetadata,
onRedirect?: (url: URL) => void
) {
this._onRedirect = onRedirect || ((url) => {
console.log(`Redirect to: ${url.toString()}`);
});
}
private _onRedirect: (url: URL) => void;
get redirectUrl(): string | URL {
return this._redirectUrl;
}
get clientMetadata(): OAuthClientMetadata {
return this._clientMetadata;
}
clientInformation(): OAuthClientInformation | undefined {
return this._clientInformation;
}
saveClientInformation(clientInformation: OAuthClientInformationFull): void {
this._clientInformation = clientInformation;
}
tokens(): OAuthTokens | undefined {
return this._tokens;
}
saveTokens(tokens: OAuthTokens): void {
this._tokens = tokens;
}
redirectToAuthorization(authorizationUrl: URL): void {
this._onRedirect(authorizationUrl);
}
saveCodeVerifier(codeVerifier: string): void {
this._codeVerifier = codeVerifier;
}
codeVerifier(): string {
if (!this._codeVerifier) {
throw new Error('No code verifier saved');
}
return this._codeVerifier;
}
}
/**
* Interactive MCP client with OAuth authentication
* Demonstrates the complete OAuth flow with browser-based authorization
*/
class InteractiveOAuthClient {
private client: Client | null = null;
private readonly rl = createInterface({
input: process.stdin,
output: process.stdout,
});
constructor(private serverUrl: string) { }
/**
* Prompts user for input via readline
*/
private async question(query: string): Promise<string> {
return new Promise((resolve) => {
this.rl.question(query, resolve);
});
}
/**
* Opens the authorization URL in the user's default browser
*/
private async openBrowser(url: string): Promise<void> {
console.log(`🌐 Opening browser for authorization: ${url}`);
const command = `open "${url}"`;
exec(command, (error) => {
if (error) {
console.error(`Failed to open browser: ${error.message}`);
console.log(`Please manually open: ${url}`);
}
});
}
/**
* Example OAuth callback handler - in production, use a more robust approach
* for handling callbacks and storing tokens
*/
/**
* Starts a temporary HTTP server to receive the OAuth callback
*/
private async waitForOAuthCallback(): Promise<string> {
return new Promise<string>((resolve, reject) => {
const server = createServer((req, res) => {
// Ignore favicon requests
if (req.url === '/favicon.ico') {
res.writeHead(404);
res.end();
return;
}
console.log(`📥 Received callback: ${req.url}`);
const parsedUrl = new URL(req.url || '', 'http://localhost');
const code = parsedUrl.searchParams.get('code');
const error = parsedUrl.searchParams.get('error');
if (code) {
console.log(`✅ Authorization code received: ${code?.substring(0, 10)}...`);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<html>
<body>
<h1>Authorization Successful!</h1>
<p>You can close this window and return to the terminal.</p>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>
`);
resolve(code);
setTimeout(() => server.close(), 3000);
} else if (error) {
console.log(`❌ Authorization error: ${error}`);
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(`
<html>
<body>
<h1>Authorization Failed</h1>
<p>Error: ${error}</p>
</body>
</html>
`);
reject(new Error(`OAuth authorization failed: ${error}`));
} else {
console.log(`❌ No authorization code or error in callback`);
res.writeHead(400);
res.end('Bad request');
reject(new Error('No authorization code provided'));
}
});
server.listen(CALLBACK_PORT, () => {
console.log(`OAuth callback server started on http://localhost:${CALLBACK_PORT}`);
});
});
}
private async attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Promise<void> {
console.log('🚢 Creating transport with OAuth provider...');
const baseUrl = new URL(this.serverUrl);
const transport = new StreamableHTTPClientTransport(baseUrl, {
authProvider: oauthProvider
});
console.log('🚢 Transport created');
try {
console.log('🔌 Attempting connection (this will trigger OAuth redirect)...');
await this.client!.connect(transport);
console.log('✅ Connected successfully');
} catch (error) {
if (error instanceof UnauthorizedError) {
console.log('🔐 OAuth required - waiting for authorization...');
const callbackPromise = this.waitForOAuthCallback();
const authCode = await callbackPromise;
await transport.finishAuth(authCode);
console.log('🔐 Authorization code received:', authCode);
console.log('🔌 Reconnecting with authenticated transport...');
await this.attemptConnection(oauthProvider);
} else {
console.error('❌ Connection failed with non-auth error:', error);
throw error;
}
}
}
/**
* Establishes connection to the MCP server with OAuth authentication
*/
async connect(): Promise<void> {
console.log(`🔗 Attempting to connect to ${this.serverUrl}...`);
const clientMetadata: OAuthClientMetadata = {
client_name: 'Simple OAuth MCP Client',
redirect_uris: [CALLBACK_URL],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_post',
scope: 'mcp:tools'
};
console.log('🔐 Creating OAuth provider...');
const oauthProvider = new InMemoryOAuthClientProvider(
CALLBACK_URL,
clientMetadata,
(redirectUrl: URL) => {
console.log(`📌 OAuth redirect handler called - opening browser`);
console.log(`Opening browser to: ${redirectUrl.toString()}`);
this.openBrowser(redirectUrl.toString());
}
);
console.log('🔐 OAuth provider created');
console.log('👤 Creating MCP client...');
this.client = new Client({
name: 'simple-oauth-client',
version: '1.0.0',
}, { capabilities: {} });
console.log('👤 Client created');
console.log('🔐 Starting OAuth flow...');
await this.attemptConnection(oauthProvider);
// Start interactive loop
await this.interactiveLoop();
}
/**
* Main interactive loop for user commands
*/
async interactiveLoop(): Promise<void> {
console.log('\n🎯 Interactive MCP Client with OAuth');
console.log('Commands:');
console.log(' list - List available tools');
console.log(' call <tool_name> [args] - Call a tool');
console.log(' quit - Exit the client');
console.log();
while (true) {
try {
const command = await this.question('mcp> ');
if (!command.trim()) {
continue;
}
if (command === 'quit') {
break;
} else if (command === 'list') {
await this.listTools();
} else if (command.startsWith('call ')) {
await this.handleCallTool(command);
} else {
console.log('❌ Unknown command. Try \'list\', \'call <tool_name>\', or \'quit\'');
}
} catch (error) {
if (error instanceof Error && error.message === 'SIGINT') {
console.log('\n\n👋 Goodbye!');
break;
}
console.error('❌ Error:', error);
}
}
}
private async listTools(): Promise<void> {
if (!this.client) {
console.log('❌ Not connected to server');
return;
}
try {
const request: ListToolsRequest = {
method: 'tools/list',
params: {},
};
const result = await this.client.request(request, ListToolsResultSchema);
if (result.tools && result.tools.length > 0) {
console.log('\n📋 Available tools:');
result.tools.forEach((tool, index) => {
console.log(`${index + 1}. ${tool.name}`);
if (tool.description) {
console.log(` Description: ${tool.description}`);
}
console.log();
});
} else {
console.log('No tools available');
}
} catch (error) {
console.error('❌ Failed to list tools:', error);
}
}
private async handleCallTool(command: string): Promise<void> {
const parts = command.split(/\s+/);
const toolName = parts[1];
if (!toolName) {
console.log('❌ Please specify a tool name');
return;
}
// Parse arguments (simple JSON-like format)
let toolArgs: Record<string, unknown> = {};
if (parts.length > 2) {
const argsString = parts.slice(2).join(' ');
try {
toolArgs = JSON.parse(argsString);
} catch {
console.log('❌ Invalid arguments format (expected JSON)');
return;
}
}
await this.callTool(toolName, toolArgs);
}
private async callTool(toolName: string, toolArgs: Record<string, unknown>): Promise<void> {
if (!this.client) {
console.log('❌ Not connected to server');
return;
}
try {
const request: CallToolRequest = {
method: 'tools/call',
params: {
name: toolName,
arguments: toolArgs,
},
};
const result = await this.client.request(request, CallToolResultSchema);
console.log(`\n🔧 Tool '${toolName}' result:`);
if (result.content) {
result.content.forEach((content) => {
if (content.type === 'text') {
console.log(content.text);
} else {
console.log(content);
}
});
} else {
console.log(result);
}
} catch (error) {
console.error(`❌ Failed to call tool '${toolName}':`, error);
}
}
close(): void {
this.rl.close();
if (this.client) {
// Note: Client doesn't have a close method in the current implementation
// This would typically close the transport connection
}
}
}
/**
* Main entry point
*/
async function main(): Promise<void> {
const serverUrl = process.env.MCP_SERVER_URL || DEFAULT_SERVER_URL;
console.log('🚀 Simple MCP OAuth Client');
console.log(`Connecting to: ${serverUrl}`);
console.log();
const client = new InteractiveOAuthClient(serverUrl);
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log('\n\n👋 Goodbye!');
client.close();
process.exit(0);
});
try {
await client.connect();
} catch (error) {
console.error('Failed to start client:', error);
process.exit(1);
} finally {
client.close();
}
}
// Run if this file is executed directly
main().catch((error) => {
console.error('Unhandled error:', error);
process.exit(1);
});

View file

@ -14,7 +14,13 @@ import {
ListResourcesResultSchema,
LoggingMessageNotificationSchema,
ResourceListChangedNotificationSchema,
ElicitRequestSchema,
ResourceLink,
ReadResourceRequest,
ReadResourceResultSchema,
} from '../../types.js';
import { getDisplayName } from '../../shared/metadataUtils.js';
import Ajv from "ajv";
// Create readline interface for user input
const readline = createInterface({
@ -54,10 +60,13 @@ function printHelp(): void {
console.log(' call-tool <name> [args] - Call a tool with optional JSON arguments');
console.log(' greet [name] - Call the greet tool');
console.log(' multi-greet [name] - Call the multi-greet tool with notifications');
console.log(' collect-info [type] - Test elicitation with collect-user-info tool (contact/preferences/feedback)');
console.log(' start-notifications [interval] [count] - Start periodic notifications');
console.log(' run-notifications-tool-with-resumability [interval] [count] - Run notification tool with resumability');
console.log(' list-prompts - List available prompts');
console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments');
console.log(' list-resources - List available resources');
console.log(' read-resource <uri> - Read a specific resource by URI');
console.log(' help - Show this help');
console.log(' quit - Exit the program');
}
@ -114,6 +123,10 @@ function commandLoop(): void {
await callMultiGreetTool(args[1] || 'MCP User');
break;
case 'collect-info':
await callCollectInfoTool(args[1] || 'contact');
break;
case 'start-notifications': {
const interval = args[1] ? parseInt(args[1], 10) : 2000;
const count = args[2] ? parseInt(args[2], 10) : 10;
@ -121,6 +134,13 @@ function commandLoop(): void {
break;
}
case 'run-notifications-tool-with-resumability': {
const interval = args[1] ? parseInt(args[1], 10) : 2000;
const count = args[2] ? parseInt(args[2], 10) : 10;
await runNotificationsToolWithResumability(interval, count);
break;
}
case 'list-prompts':
await listPrompts();
break;
@ -146,6 +166,14 @@ function commandLoop(): void {
await listResources();
break;
case 'read-resource':
if (args.length < 2) {
console.log('Usage: read-resource <uri>');
} else {
await readResource(args[1]);
}
break;
case 'help':
printHelp();
break;
@ -183,15 +211,212 @@ async function connect(url?: string): Promise<void> {
console.log(`Connecting to ${serverUrl}...`);
try {
// Create a new client
// Create a new client with elicitation capability
client = new Client({
name: 'example-client',
version: '1.0.0'
}, {
capabilities: {
elicitation: {},
},
});
client.onerror = (error) => {
console.error('\x1b[31mClient error:', error, '\x1b[0m');
}
// Set up elicitation request handler with proper validation
client.setRequestHandler(ElicitRequestSchema, async (request) => {
console.log('\n🔔 Elicitation Request Received:');
console.log(`Message: ${request.params.message}`);
console.log('Requested Schema:');
console.log(JSON.stringify(request.params.requestedSchema, null, 2));
const schema = request.params.requestedSchema;
const properties = schema.properties;
const required = schema.required || [];
// Set up AJV validator for the requested schema
const ajv = new Ajv();
const validate = ajv.compile(schema);
let attempts = 0;
const maxAttempts = 3;
while (attempts < maxAttempts) {
attempts++;
console.log(`\nPlease provide the following information (attempt ${attempts}/${maxAttempts}):`);
const content: Record<string, unknown> = {};
let inputCancelled = false;
// Collect input for each field
for (const [fieldName, fieldSchema] of Object.entries(properties)) {
const field = fieldSchema as {
type?: string;
title?: string;
description?: string;
default?: unknown;
enum?: string[];
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
format?: string;
};
const isRequired = required.includes(fieldName);
let prompt = `${field.title || fieldName}`;
// Add helpful information to the prompt
if (field.description) {
prompt += ` (${field.description})`;
}
if (field.enum) {
prompt += ` [options: ${field.enum.join(', ')}]`;
}
if (field.type === 'number' || field.type === 'integer') {
if (field.minimum !== undefined && field.maximum !== undefined) {
prompt += ` [${field.minimum}-${field.maximum}]`;
} else if (field.minimum !== undefined) {
prompt += ` [min: ${field.minimum}]`;
} else if (field.maximum !== undefined) {
prompt += ` [max: ${field.maximum}]`;
}
}
if (field.type === 'string' && field.format) {
prompt += ` [format: ${field.format}]`;
}
if (isRequired) {
prompt += ' *required*';
}
if (field.default !== undefined) {
prompt += ` [default: ${field.default}]`;
}
prompt += ': ';
const answer = await new Promise<string>((resolve) => {
readline.question(prompt, (input) => {
resolve(input.trim());
});
});
// Check for cancellation
if (answer.toLowerCase() === 'cancel' || answer.toLowerCase() === 'c') {
inputCancelled = true;
break;
}
// Parse and validate the input
try {
if (answer === '' && field.default !== undefined) {
content[fieldName] = field.default;
} else if (answer === '' && !isRequired) {
// Skip optional empty fields
continue;
} else if (answer === '') {
throw new Error(`${fieldName} is required`);
} else {
// Parse the value based on type
let parsedValue: unknown;
if (field.type === 'boolean') {
parsedValue = answer.toLowerCase() === 'true' || answer.toLowerCase() === 'yes' || answer === '1';
} else if (field.type === 'number') {
parsedValue = parseFloat(answer);
if (isNaN(parsedValue as number)) {
throw new Error(`${fieldName} must be a valid number`);
}
} else if (field.type === 'integer') {
parsedValue = parseInt(answer, 10);
if (isNaN(parsedValue as number)) {
throw new Error(`${fieldName} must be a valid integer`);
}
} else if (field.enum) {
if (!field.enum.includes(answer)) {
throw new Error(`${fieldName} must be one of: ${field.enum.join(', ')}`);
}
parsedValue = answer;
} else {
parsedValue = answer;
}
content[fieldName] = parsedValue;
}
} catch (error) {
console.log(`❌ Error: ${error}`);
// Continue to next attempt
break;
}
}
if (inputCancelled) {
return { action: 'cancel' };
}
// If we didn't complete all fields due to an error, try again
if (Object.keys(content).length !== Object.keys(properties).filter(name =>
required.includes(name) || content[name] !== undefined
).length) {
if (attempts < maxAttempts) {
console.log('Please try again...');
continue;
} else {
console.log('Maximum attempts reached. Declining request.');
return { action: 'reject' };
}
}
// Validate the complete object against the schema
const isValid = validate(content);
if (!isValid) {
console.log('❌ Validation errors:');
validate.errors?.forEach(error => {
console.log(` - ${error.dataPath || 'root'}: ${error.message}`);
});
if (attempts < maxAttempts) {
console.log('Please correct the errors and try again...');
continue;
} else {
console.log('Maximum attempts reached. Declining request.');
return { action: 'reject' };
}
}
// Show the collected data and ask for confirmation
console.log('\n✅ Collected data:');
console.log(JSON.stringify(content, null, 2));
const confirmAnswer = await new Promise<string>((resolve) => {
readline.question('\nSubmit this information? (yes/no/cancel): ', (input) => {
resolve(input.trim().toLowerCase());
});
});
if (confirmAnswer === 'yes' || confirmAnswer === 'y') {
return {
action: 'accept',
content,
};
} else if (confirmAnswer === 'cancel' || confirmAnswer === 'c') {
return { action: 'cancel' };
} else if (confirmAnswer === 'no' || confirmAnswer === 'n') {
if (attempts < maxAttempts) {
console.log('Please re-enter the information...');
continue;
} else {
return { action: 'reject' };
}
}
}
console.log('Maximum attempts reached. Declining request.');
return { action: 'reject' };
});
transport = new StreamableHTTPClientTransport(
new URL(serverUrl),
{
@ -264,12 +489,12 @@ async function terminateSession(): Promise<void> {
console.log('Terminating session with ID:', transport.sessionId);
await transport.terminateSession();
console.log('Session terminated successfully');
// Check if sessionId was cleared after termination
if (!transport.sessionId) {
console.log('Session ID has been cleared');
sessionId = undefined;
// Also close the transport and clear client objects
await transport.close();
console.log('Transport closed after session termination');
@ -309,7 +534,7 @@ async function listTools(): Promise<void> {
console.log(' No tools available');
} else {
for (const tool of toolsResult.tools) {
console.log(` - ${tool.name}: ${tool.description}`);
console.log(` - id: ${tool.name}, name: ${getDisplayName(tool)}, description: ${tool.description}`);
}
}
} catch (error) {
@ -333,11 +558,91 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<vo
};
console.log(`Calling tool '${name}' with args:`, args);
const result = await client.request(request, CallToolResultSchema);
console.log('Tool result:');
const resourceLinks: ResourceLink[] = [];
result.content.forEach(item => {
if (item.type === 'text') {
console.log(` ${item.text}`);
} else if (item.type === 'resource_link') {
const resourceLink = item as ResourceLink;
resourceLinks.push(resourceLink);
console.log(` 📁 Resource Link: ${resourceLink.name}`);
console.log(` URI: ${resourceLink.uri}`);
if (resourceLink.mimeType) {
console.log(` Type: ${resourceLink.mimeType}`);
}
if (resourceLink.description) {
console.log(` Description: ${resourceLink.description}`);
}
} else if (item.type === 'resource') {
console.log(` [Embedded Resource: ${item.resource.uri}]`);
} else if (item.type === 'image') {
console.log(` [Image: ${item.mimeType}]`);
} else if (item.type === 'audio') {
console.log(` [Audio: ${item.mimeType}]`);
} else {
console.log(` [Unknown content type]:`, item);
}
});
// Offer to read resource links
if (resourceLinks.length > 0) {
console.log(`\nFound ${resourceLinks.length} resource link(s). Use 'read-resource <uri>' to read their content.`);
}
} catch (error) {
console.log(`Error calling tool ${name}: ${error}`);
}
}
async function callGreetTool(name: string): Promise<void> {
await callTool('greet', { name });
}
async function callMultiGreetTool(name: string): Promise<void> {
console.log('Calling multi-greet tool with notifications...');
await callTool('multi-greet', { name });
}
async function callCollectInfoTool(infoType: string): Promise<void> {
console.log(`Testing elicitation with collect-user-info tool (${infoType})...`);
await callTool('collect-user-info', { infoType });
}
async function startNotifications(interval: number, count: number): Promise<void> {
console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`);
await callTool('start-notification-stream', { interval, count });
}
async function runNotificationsToolWithResumability(interval: number, count: number): Promise<void> {
if (!client) {
console.log('Not connected to server.');
return;
}
try {
console.log(`Starting notification stream with resumability: interval=${interval}ms, count=${count || 'unlimited'}`);
console.log(`Using resumption token: ${notificationsToolLastEventId || 'none'}`);
const request: CallToolRequest = {
method: 'tools/call',
params: {
name: 'start-notification-stream',
arguments: { interval, count }
}
};
const onLastEventIdUpdate = (event: string) => {
notificationsToolLastEventId = event;
console.log(`Updated resumption token: ${event}`);
};
const result = await client.request(request, CallToolResultSchema, {
resumptionToken: notificationsToolLastEventId, onresumptiontoken: onLastEventIdUpdate
resumptionToken: notificationsToolLastEventId,
onresumptiontoken: onLastEventIdUpdate
});
console.log('Tool result:');
@ -349,24 +654,10 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<vo
}
});
} catch (error) {
console.log(`Error calling tool ${name}: ${error}`);
console.log(`Error starting notification stream: ${error}`);
}
}
async function callGreetTool(name: string): Promise<void> {
await callTool('greet', { name });
}
async function callMultiGreetTool(name: string): Promise<void> {
console.log('Calling multi-greet tool with notifications...');
await callTool('multi-greet', { name });
}
async function startNotifications(interval: number, count: number): Promise<void> {
console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`);
await callTool('start-notification-stream', { interval, count });
}
async function listPrompts(): Promise<void> {
if (!client) {
console.log('Not connected to server.');
@ -384,7 +675,7 @@ async function listPrompts(): Promise<void> {
console.log(' No prompts available');
} else {
for (const prompt of promptsResult.prompts) {
console.log(` - ${prompt.name}: ${prompt.description}`);
console.log(` - id: ${prompt.name}, name: ${getDisplayName(prompt)}, description: ${prompt.description}`);
}
}
} catch (error) {
@ -435,7 +726,7 @@ async function listResources(): Promise<void> {
console.log(' No resources available');
} else {
for (const resource of resourcesResult.resources) {
console.log(` - ${resource.name}: ${resource.uri}`);
console.log(` - id: ${resource.name}, name: ${getDisplayName(resource)}, description: ${resource.uri}`);
}
}
} catch (error) {
@ -443,6 +734,42 @@ async function listResources(): Promise<void> {
}
}
async function readResource(uri: string): Promise<void> {
if (!client) {
console.log('Not connected to server.');
return;
}
try {
const request: ReadResourceRequest = {
method: 'resources/read',
params: { uri }
};
console.log(`Reading resource: ${uri}`);
const result = await client.request(request, ReadResourceResultSchema);
console.log('Resource contents:');
for (const content of result.contents) {
console.log(` URI: ${content.uri}`);
if (content.mimeType) {
console.log(` Type: ${content.mimeType}`);
}
if ('text' in content && typeof content.text === 'string') {
console.log(' Content:');
console.log(' ---');
console.log(content.text.split('\n').map((line: string) => ' ' + line).join('\n'));
console.log(' ---');
} else if ('blob' in content && typeof content.blob === 'string') {
console.log(` [Binary data: ${content.blob.length} bytes]`);
}
}
} catch (error) {
console.log(`Error reading resource ${uri}: ${error}`);
}
}
async function cleanup(): Promise<void> {
if (client && transport) {
try {
@ -456,7 +783,7 @@ async function cleanup(): Promise<void> {
console.error('Error terminating session:', error);
}
}
// Then close the transport
await transport.close();
} catch (error) {

View file

@ -0,0 +1,218 @@
import { randomUUID } from 'node:crypto';
import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js';
import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js';
import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from '../../shared/auth.js';
import express, { Request, Response } from "express";
import { AuthInfo } from '../../server/auth/types.js';
import { createOAuthMetadata, mcpAuthRouter } from '../../server/auth/router.js';
import { resourceUrlFromServerUrl } from '../../shared/auth-utils.js';
export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore {
private clients = new Map<string, OAuthClientInformationFull>();
async getClient(clientId: string) {
return this.clients.get(clientId);
}
async registerClient(clientMetadata: OAuthClientInformationFull) {
this.clients.set(clientMetadata.client_id, clientMetadata);
return clientMetadata;
}
}
/**
* 🚨 DEMO ONLY - NOT FOR PRODUCTION
*
* This example demonstrates MCP OAuth flow but lacks some of the features required for production use,
* for example:
* - Persistent token storage
* - Rate limiting
*/
export class DemoInMemoryAuthProvider implements OAuthServerProvider {
clientsStore = new DemoInMemoryClientsStore();
private codes = new Map<string, {
params: AuthorizationParams,
client: OAuthClientInformationFull}>();
private tokens = new Map<string, AuthInfo>();
constructor(private validateResource?: (resource?: URL) => boolean) {}
async authorize(
client: OAuthClientInformationFull,
params: AuthorizationParams,
res: Response
): Promise<void> {
const code = randomUUID();
const searchParams = new URLSearchParams({
code,
});
if (params.state !== undefined) {
searchParams.set('state', params.state);
}
this.codes.set(code, {
client,
params
});
const targetUrl = new URL(client.redirect_uris[0]);
targetUrl.search = searchParams.toString();
res.redirect(targetUrl.toString());
}
async challengeForAuthorizationCode(
client: OAuthClientInformationFull,
authorizationCode: string
): Promise<string> {
// Store the challenge with the code data
const codeData = this.codes.get(authorizationCode);
if (!codeData) {
throw new Error('Invalid authorization code');
}
return codeData.params.codeChallenge;
}
async exchangeAuthorizationCode(
client: OAuthClientInformationFull,
authorizationCode: string,
// Note: code verifier is checked in token.ts by default
// it's unused here for that reason.
_codeVerifier?: string
): Promise<OAuthTokens> {
const codeData = this.codes.get(authorizationCode);
if (!codeData) {
throw new Error('Invalid authorization code');
}
if (codeData.client.client_id !== client.client_id) {
throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`);
}
if (this.validateResource && !this.validateResource(codeData.params.resource)) {
throw new Error(`Invalid resource: ${codeData.params.resource}`);
}
this.codes.delete(authorizationCode);
const token = randomUUID();
const tokenData = {
token,
clientId: client.client_id,
scopes: codeData.params.scopes || [],
expiresAt: Date.now() + 3600000, // 1 hour
resource: codeData.params.resource,
type: 'access',
};
this.tokens.set(token, tokenData);
return {
access_token: token,
token_type: 'bearer',
expires_in: 3600,
scope: (codeData.params.scopes || []).join(' '),
};
}
async exchangeRefreshToken(
_client: OAuthClientInformationFull,
_refreshToken: string,
_scopes?: string[],
_resource?: URL
): Promise<OAuthTokens> {
throw new Error('Not implemented for example demo');
}
async verifyAccessToken(token: string): Promise<AuthInfo> {
const tokenData = this.tokens.get(token);
if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) {
throw new Error('Invalid or expired token');
}
return {
token,
clientId: tokenData.clientId,
scopes: tokenData.scopes,
expiresAt: Math.floor(tokenData.expiresAt / 1000),
resource: tokenData.resource,
};
}
}
export const setupAuthServer = ({authServerUrl, mcpServerUrl, strictResource}: {authServerUrl: URL, mcpServerUrl: URL, strictResource: boolean}): OAuthMetadata => {
// Create separate auth server app
// NOTE: This is a separate app on a separate port to illustrate
// how to separate an OAuth Authorization Server from a Resource
// server in the SDK. The SDK is not intended to be provide a standalone
// authorization server.
const validateResource = strictResource ? (resource?: URL) => {
if (!resource) return false;
const expectedResource = resourceUrlFromServerUrl(mcpServerUrl);
return resource.toString() === expectedResource.toString();
} : undefined;
const provider = new DemoInMemoryAuthProvider(validateResource);
const authApp = express();
authApp.use(express.json());
// For introspection requests
authApp.use(express.urlencoded());
// Add OAuth routes to the auth server
// NOTE: this will also add a protected resource metadata route,
// but it won't be used, so leave it.
authApp.use(mcpAuthRouter({
provider,
issuerUrl: authServerUrl,
scopesSupported: ['mcp:tools'],
}));
authApp.post('/introspect', async (req: Request, res: Response) => {
try {
const { token } = req.body;
if (!token) {
res.status(400).json({ error: 'Token is required' });
return;
}
const tokenInfo = await provider.verifyAccessToken(token);
res.json({
active: true,
client_id: tokenInfo.clientId,
scope: tokenInfo.scopes.join(' '),
exp: tokenInfo.expiresAt,
aud: tokenInfo.resource,
});
return
} catch (error) {
res.status(401).json({
active: false,
error: 'Unauthorized',
error_description: `Invalid token: ${error}`
});
}
});
const auth_port = authServerUrl.port;
// Start the auth server
authApp.listen(auth_port, () => {
console.log(`OAuth Authorization Server listening on port ${auth_port}`);
});
// Note: we could fetch this from the server, but then we end up
// with some top level async which gets annoying.
const oauthMetadata: OAuthMetadata = createOAuthMetadata({
provider,
issuerUrl: authServerUrl,
scopesSupported: ['mcp:tools'],
})
oauthMetadata.introspection_endpoint = new URL("/introspect", authServerUrl).href;
return oauthMetadata;
}

View file

@ -0,0 +1,80 @@
#!/usr/bin/env node
/**
* Example MCP server using the high-level McpServer API with outputSchema
* This demonstrates how to easily create tools with structured output
*/
import { McpServer } from "../../server/mcp.js";
import { StdioServerTransport } from "../../server/stdio.js";
import { z } from "zod";
const server = new McpServer(
{
name: "mcp-output-schema-high-level-example",
version: "1.0.0",
}
);
// Define a tool with structured output - Weather data
server.registerTool(
"get_weather",
{
description: "Get weather information for a city",
inputSchema: {
city: z.string().describe("City name"),
country: z.string().describe("Country code (e.g., US, UK)")
},
outputSchema: {
temperature: z.object({
celsius: z.number(),
fahrenheit: z.number()
}),
conditions: z.enum(["sunny", "cloudy", "rainy", "stormy", "snowy"]),
humidity: z.number().min(0).max(100),
wind: z.object({
speed_kmh: z.number(),
direction: z.string()
})
},
},
async ({ city, country }) => {
// Parameters are available but not used in this example
void city;
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 structuredContent = {
temperature: {
celsius: temp_c,
fahrenheit: Math.round((temp_c * 9 / 5 + 32) * 10) / 10
},
conditions,
humidity: Math.round(Math.random() * 100),
wind: {
speed_kmh: Math.round(Math.random() * 50),
direction: ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Math.floor(Math.random() * 8)]
}
};
return {
content: [{
type: "text",
text: JSON.stringify(structuredContent, null, 2)
}],
structuredContent
};
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("High-level Output Schema Example Server running on stdio");
}
main().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});

View file

@ -3,22 +3,34 @@ import { randomUUID } from 'node:crypto';
import { z } from 'zod';
import { McpServer } from '../../server/mcp.js';
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js';
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js';
import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js';
import { CallToolResult, GetPromptResult, isInitializeRequest, PrimitiveSchemaDefinition, ReadResourceResult, ResourceLink } from '../../types.js';
import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
import { setupAuthServer } from './demoInMemoryOAuthProvider.js';
import { OAuthMetadata } from 'src/shared/auth.js';
import { checkResourceAllowed } from 'src/shared/auth-utils.js';
// Check for OAuth flag
const useOAuth = process.argv.includes('--oauth');
const strictOAuth = process.argv.includes('--oauth-strict');
// Create an MCP server with implementation details
const getServer = () => {
const server = new McpServer({
name: 'simple-streamable-http-server',
version: '1.0.0',
version: '1.0.0'
}, { capabilities: { logging: {} } });
// Register a simple tool that returns a greeting
server.tool(
server.registerTool(
'greet',
'A simple greeting tool',
{
name: z.string().describe('Name to greet'),
title: 'Greeting Tool', // Display name for UI
description: 'A simple greeting tool',
inputSchema: {
name: z.string().describe('Name to greet'),
},
},
async ({ name }): Promise<CallToolResult> => {
return {
@ -40,7 +52,7 @@ const getServer = () => {
name: z.string().describe('Name to greet'),
},
{
title: 'Multiple Greeting Tool',
title: 'Multiple Greeting Tool',
readOnlyHint: true,
openWorldHint: false
},
@ -76,13 +88,165 @@ const getServer = () => {
};
}
);
// Register a simple prompt
server.prompt(
'greeting-template',
'A simple greeting prompt template',
// Register a tool that demonstrates elicitation (user input collection)
// This creates a closure that captures the server instance
server.tool(
'collect-user-info',
'A tool that collects user information through elicitation',
{
name: z.string().describe('Name to include in greeting'),
infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect'),
},
async ({ infoType }): Promise<CallToolResult> => {
let message: string;
let requestedSchema: {
type: 'object';
properties: Record<string, PrimitiveSchemaDefinition>;
required?: string[];
};
switch (infoType) {
case 'contact':
message = 'Please provide your contact information';
requestedSchema = {
type: 'object',
properties: {
name: {
type: 'string',
title: 'Full Name',
description: 'Your full name',
},
email: {
type: 'string',
title: 'Email Address',
description: 'Your email address',
format: 'email',
},
phone: {
type: 'string',
title: 'Phone Number',
description: 'Your phone number (optional)',
},
},
required: ['name', 'email'],
};
break;
case 'preferences':
message = 'Please set your preferences';
requestedSchema = {
type: 'object',
properties: {
theme: {
type: 'string',
title: 'Theme',
description: 'Choose your preferred theme',
enum: ['light', 'dark', 'auto'],
enumNames: ['Light', 'Dark', 'Auto'],
},
notifications: {
type: 'boolean',
title: 'Enable Notifications',
description: 'Would you like to receive notifications?',
default: true,
},
frequency: {
type: 'string',
title: 'Notification Frequency',
description: 'How often would you like notifications?',
enum: ['daily', 'weekly', 'monthly'],
enumNames: ['Daily', 'Weekly', 'Monthly'],
},
},
required: ['theme'],
};
break;
case 'feedback':
message = 'Please provide your feedback';
requestedSchema = {
type: 'object',
properties: {
rating: {
type: 'integer',
title: 'Rating',
description: 'Rate your experience (1-5)',
minimum: 1,
maximum: 5,
},
comments: {
type: 'string',
title: 'Comments',
description: 'Additional comments (optional)',
maxLength: 500,
},
recommend: {
type: 'boolean',
title: 'Would you recommend this?',
description: 'Would you recommend this to others?',
},
},
required: ['rating', 'recommend'],
};
break;
default:
throw new Error(`Unknown info type: ${infoType}`);
}
try {
// Use the underlying server instance to elicit input from the client
const result = await server.server.elicitInput({
message,
requestedSchema,
});
if (result.action === 'accept') {
return {
content: [
{
type: 'text',
text: `Thank you! Collected ${infoType} information: ${JSON.stringify(result.content, null, 2)}`,
},
],
};
} else if (result.action === 'reject') {
return {
content: [
{
type: 'text',
text: `No information was collected. User rejected ${infoType} information request.`,
},
],
};
} else {
return {
content: [
{
type: 'text',
text: `Information collection was cancelled by the user.`,
},
],
};
}
} catch (error) {
return {
content: [
{
type: 'text',
text: `Error collecting ${infoType} information: ${error}`,
},
],
};
}
}
);
// Register a simple prompt with title
server.registerPrompt(
'greeting-template',
{
title: 'Greeting Template', // Display name for UI
description: 'A simple greeting prompt template',
argsSchema: {
name: z.string().describe('Name to include in greeting'),
},
},
async ({ name }): Promise<GetPromptResult> => {
return {
@ -141,10 +305,14 @@ const getServer = () => {
);
// Create a simple resource at a fixed URI
server.resource(
server.registerResource(
'greeting-resource',
'https://example.com/greetings/default',
{ mimeType: 'text/plain' },
{
title: 'Default Greeting', // Display name for UI
description: 'A simple greeting resource',
mimeType: 'text/plain'
},
async (): Promise<ReadResourceResult> => {
return {
contents: [
@ -156,22 +324,187 @@ const getServer = () => {
};
}
);
// Create additional resources for ResourceLink demonstration
server.registerResource(
'example-file-1',
'file:///example/file1.txt',
{
title: 'Example File 1',
description: 'First example file for ResourceLink demonstration',
mimeType: 'text/plain'
},
async (): Promise<ReadResourceResult> => {
return {
contents: [
{
uri: 'file:///example/file1.txt',
text: 'This is the content of file 1',
},
],
};
}
);
server.registerResource(
'example-file-2',
'file:///example/file2.txt',
{
title: 'Example File 2',
description: 'Second example file for ResourceLink demonstration',
mimeType: 'text/plain'
},
async (): Promise<ReadResourceResult> => {
return {
contents: [
{
uri: 'file:///example/file2.txt',
text: 'This is the content of file 2',
},
],
};
}
);
// Register a tool that returns ResourceLinks
server.registerTool(
'list-files',
{
title: 'List Files with ResourceLinks',
description: 'Returns a list of files as ResourceLinks without embedding their content',
inputSchema: {
includeDescriptions: z.boolean().optional().describe('Whether to include descriptions in the resource links'),
},
},
async ({ includeDescriptions = true }): Promise<CallToolResult> => {
const resourceLinks: ResourceLink[] = [
{
type: 'resource_link',
uri: 'https://example.com/greetings/default',
name: 'Default Greeting',
mimeType: 'text/plain',
...(includeDescriptions && { description: 'A simple greeting resource' })
},
{
type: 'resource_link',
uri: 'file:///example/file1.txt',
name: 'Example File 1',
mimeType: 'text/plain',
...(includeDescriptions && { description: 'First example file for ResourceLink demonstration' })
},
{
type: 'resource_link',
uri: 'file:///example/file2.txt',
name: 'Example File 2',
mimeType: 'text/plain',
...(includeDescriptions && { description: 'Second example file for ResourceLink demonstration' })
}
];
return {
content: [
{
type: 'text',
text: 'Here are the available files as resource links:',
},
...resourceLinks,
{
type: 'text',
text: '\nYou can read any of these resources using their URI.',
}
],
};
}
);
return server;
};
const MCP_PORT = 3000;
const AUTH_PORT = 3001;
const app = express();
app.use(express.json());
// Set up OAuth if enabled
let authMiddleware = null;
if (useOAuth) {
// Create auth middleware for MCP endpoints
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 tokenVerifier = {
verifyAccessToken: async (token: string) => {
const endpoint = oauthMetadata.introspection_endpoint;
if (!endpoint) {
throw new Error('No token verification endpoint available in metadata');
}
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
token: token
}).toString()
});
if (!response.ok) {
throw new Error(`Invalid or expired token: ${await response.text()}`);
}
const data = await response.json();
if (strictOAuth) {
if (!data.aud) {
throw new Error(`Resource Indicator (RFC8707) missing`);
}
if (!checkResourceAllowed({ requestedResource: data.aud, configuredResource: mcpServerUrl })) {
throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`);
}
}
// Convert the response to AuthInfo format
return {
token,
clientId: data.client_id,
scopes: data.scope ? data.scope.split(' ') : [],
expiresAt: data.exp,
};
}
}
// Add metadata routes to the main MCP server
app.use(mcpAuthMetadataRouter({
oauthMetadata,
resourceServerUrl: mcpServerUrl,
scopesSupported: ['mcp:tools'],
resourceName: 'MCP Demo Server',
}));
authMiddleware = requireBearerAuth({
verifier: tokenVerifier,
requiredScopes: [],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
});
}
// Map to store transports by session ID
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
app.post('/mcp', async (req: Request, res: Response) => {
console.log('Received MCP request:', req.body);
// 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 (useOAuth && req.auth) {
console.log('Authenticated user:', req.auth);
}
try {
// Check for existing session ID
const sessionId = req.headers['mcp-session-id'] as string | undefined;
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
// Reuse existing transport
transport = transports[sessionId];
@ -234,16 +567,27 @@ app.post('/mcp', async (req: Request, res: Response) => {
});
}
}
});
};
// Set up routes with conditional auth middleware
if (useOAuth && authMiddleware) {
app.post('/mcp', authMiddleware, mcpPostHandler);
} else {
app.post('/mcp', mcpPostHandler);
}
// Handle GET requests for SSE streams (using built-in support from StreamableHTTP)
app.get('/mcp', async (req: Request, res: Response) => {
const mcpGetHandler = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}
if (useOAuth && req.auth) {
console.log('Authenticated SSE connection from user:', req.auth);
}
// Check for Last-Event-ID header for resumability
const lastEventId = req.headers['last-event-id'] as string | undefined;
if (lastEventId) {
@ -254,10 +598,17 @@ app.get('/mcp', async (req: Request, res: Response) => {
const transport = transports[sessionId];
await transport.handleRequest(req, res);
});
};
// Set up GET route with conditional auth middleware
if (useOAuth && authMiddleware) {
app.get('/mcp', authMiddleware, mcpGetHandler);
} else {
app.get('/mcp', mcpGetHandler);
}
// Handle DELETE requests for session termination (according to MCP spec)
app.delete('/mcp', async (req: Request, res: Response) => {
const mcpDeleteHandler = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
@ -275,12 +626,17 @@ app.delete('/mcp', async (req: Request, res: Response) => {
res.status(500).send('Error processing session termination');
}
}
});
};
// Start the server
const PORT = 3000;
app.listen(PORT, () => {
console.log(`MCP Streamable HTTP Server listening on port ${PORT}`);
// Set up DELETE route with conditional auth middleware
if (useOAuth && authMiddleware) {
app.delete('/mcp', authMiddleware, mcpDeleteHandler);
} else {
app.delete('/mcp', mcpDeleteHandler);
}
app.listen(MCP_PORT, () => {
console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`);
});
// Handle server shutdown

View file

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

View file

@ -276,6 +276,34 @@ describe('Authorization Handler', () => {
});
});
describe('Resource parameter validation', () => {
it('propagates resource parameter', async () => {
const mockProviderWithResource = jest.spyOn(mockProvider, 'authorize');
const response = await supertest(app)
.get('/authorize')
.query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256',
resource: 'https://api.example.com/resource'
});
expect(response.status).toBe(302);
expect(mockProviderWithResource).toHaveBeenCalledWith(
validClient,
expect.objectContaining({
resource: new URL('https://api.example.com/resource'),
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge123'
}),
expect.any(Object)
);
});
});
describe('Successful authorization', () => {
it('handles successful authorization with all parameters', async () => {
const response = await supertest(app)

View file

@ -35,6 +35,7 @@ const RequestAuthorizationParamsSchema = z.object({
code_challenge_method: z.literal("S256"),
scope: z.string().optional(),
state: z.string().optional(),
resource: z.string().url().optional(),
});
export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler {
@ -98,7 +99,6 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
const status = error instanceof ServerError ? 500 : 400;
res.status(status).json(error.toResponseObject());
} else {
console.error("Unexpected error looking up client:", error);
const serverError = new ServerError("Internal Server Error");
res.status(500).json(serverError.toResponseObject());
}
@ -115,7 +115,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
throw new InvalidRequestError(parseResult.error.message);
}
const { scope, code_challenge } = parseResult.data;
const { scope, code_challenge, resource } = parseResult.data;
state = parseResult.data.state;
// Validate scopes
@ -138,13 +138,13 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
scopes: requestedScopes,
redirectUri: redirect_uri,
codeChallenge: code_challenge,
resource: resource ? new URL(resource) : undefined,
}, res);
} catch (error) {
// Post-redirect errors - redirect with error parameters
if (error instanceof OAuthError) {
res.redirect(302, createErrorRedirect(redirect_uri, error, state));
} else {
console.error("Unexpected error during authorization:", error);
const serverError = new ServerError("Internal Server Error");
res.redirect(302, createErrorRedirect(redirect_uri, serverError, state));
}

View file

@ -1,9 +1,9 @@
import express, { RequestHandler } from "express";
import { OAuthMetadata } from "../../../shared/auth.js";
import { OAuthMetadata, OAuthProtectedResourceMetadata } from "../../../shared/auth.js";
import cors from 'cors';
import { allowedMethods } from "../middleware/allowedMethods.js";
export function metadataHandler(metadata: OAuthMetadata): RequestHandler {
export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler {
// Nested router so we can configure middleware and restrict HTTP method
const router = express.Router();
@ -16,4 +16,4 @@ export function metadataHandler(metadata: OAuthMetadata): RequestHandler {
});
return router;
}
}

View file

@ -104,7 +104,6 @@ export function clientRegistrationHandler({
const status = error instanceof ServerError ? 500 : 400;
res.status(status).json(error.toResponseObject());
} else {
console.error("Unexpected error registering client:", error);
const serverError = new ServerError("Internal Server Error");
res.status(500).json(serverError.toResponseObject());
}

Some files were not shown because too many files have changed in this diff Show more