diff --git a/package.json b/package.json index 5c595515d..0cd148116 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,6 @@ "mcp" ], "exports": { - ".": { - "import": "./dist/esm/index.js", - "require": "./dist/cjs/index.js" - }, "./client": { "import": "./dist/esm/client/index.js", "require": "./dist/cjs/client/index.js" diff --git a/src/client/index.test.ts b/src/client/client.test.ts similarity index 100% rename from src/client/index.test.ts rename to src/client/client.test.ts diff --git a/src/client/client.ts b/src/client/client.ts new file mode 100644 index 000000000..cc6819815 --- /dev/null +++ b/src/client/client.ts @@ -0,0 +1,440 @@ +import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js'; +import type { Transport } from '../shared/transport.js'; +import { + type CallToolRequest, + CallToolResultSchema, + type ClientCapabilities, + type ClientNotification, + type ClientRequest, + type ClientResult, + type CompatibilityCallToolResultSchema, + type CompleteRequest, + CompleteResultSchema, + EmptyResultSchema, + ErrorCode, + type GetPromptRequest, + GetPromptResultSchema, + type Implementation, + InitializeResultSchema, + LATEST_PROTOCOL_VERSION, + type ListPromptsRequest, + ListPromptsResultSchema, + type ListResourcesRequest, + ListResourcesResultSchema, + type ListResourceTemplatesRequest, + ListResourceTemplatesResultSchema, + type ListToolsRequest, + ListToolsResultSchema, + type LoggingLevel, + McpError, + type Notification, + type ReadResourceRequest, + ReadResourceResultSchema, + type Request, + type Result, + type ServerCapabilities, + SUPPORTED_PROTOCOL_VERSIONS, + type SubscribeRequest, + type Tool, + type UnsubscribeRequest +} from '../types.js'; +import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; +import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js'; + +export type ClientOptions = ProtocolOptions & { + /** + * Capabilities to advertise as being supported by this client. + */ + capabilities?: ClientCapabilities; + + /** + * JSON Schema validator for tool output validation. + * + * The validator is used to validate structured content returned by tools + * against their declared output schemas. + * + * @default AjvJsonSchemaValidator + * + * @example + * ```typescript + * // ajv + * const client = new Client( + * { name: 'my-client', version: '1.0.0' }, + * { + * capabilities: {}, + * jsonSchemaValidator: new AjvJsonSchemaValidator() + * } + * ); + * + * // @cfworker/json-schema + * const client = new Client( + * { name: 'my-client', version: '1.0.0' }, + * { + * capabilities: {}, + * jsonSchemaValidator: new CfWorkerJsonSchemaValidator() + * } + * ); + * ``` + */ + jsonSchemaValidator?: jsonSchemaValidator; +}; + +/** + * An MCP client on top of a pluggable transport. + * + * The client will automatically begin the initialization flow with the server when connect() is called. + * + * To use with custom types, extend the base Request/Notification/Result types and pass them as type parameters: + * + * ```typescript + * // Custom schemas + * const CustomRequestSchema = RequestSchema.extend({...}) + * const CustomNotificationSchema = NotificationSchema.extend({...}) + * const CustomResultSchema = ResultSchema.extend({...}) + * + * // Type aliases + * type CustomRequest = z.infer + * type CustomNotification = z.infer + * type CustomResult = z.infer + * + * // Create typed client + * const client = new Client({ + * name: "CustomClient", + * version: "1.0.0" + * }) + * ``` + */ +export class Client< + RequestT extends Request = Request, + NotificationT extends Notification = Notification, + ResultT extends Result = Result +> extends Protocol { + private _serverCapabilities?: ServerCapabilities; + private _serverVersion?: Implementation; + private _capabilities: ClientCapabilities; + private _instructions?: string; + private _jsonSchemaValidator: jsonSchemaValidator; + private _cachedToolOutputValidators: Map> = new Map(); + + /** + * Initializes this client with the given name and version information. + */ + constructor( + private _clientInfo: Implementation, + options?: ClientOptions + ) { + super(options); + this._capabilities = options?.capabilities ?? {}; + this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator(); + } + + /** + * Registers new capabilities. This can only be called before connecting to a transport. + * + * The new capabilities will be merged with any existing capabilities previously given (e.g., at initialization). + */ + public registerCapabilities(capabilities: ClientCapabilities): void { + if (this.transport) { + throw new Error('Cannot register capabilities after connecting to transport'); + } + + this._capabilities = mergeCapabilities(this._capabilities, capabilities); + } + + protected assertCapability(capability: keyof ServerCapabilities, method: string): void { + if (!this._serverCapabilities?.[capability]) { + throw new Error(`Server does not support ${capability} (required for ${method})`); + } + } + + override async connect(transport: Transport, options?: RequestOptions): Promise { + await super.connect(transport); + // When transport sessionId is already set this means we are trying to reconnect. + // In this case we don't need to initialize again. + if (transport.sessionId !== undefined) { + return; + } + try { + const result = await this.request( + { + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: this._capabilities, + clientInfo: this._clientInfo + } + }, + InitializeResultSchema, + options + ); + + if (result === undefined) { + throw new Error(`Server sent invalid initialize result: ${result}`); + } + + if (!SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { + throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); + } + + 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; + + await this.notification({ + method: 'notifications/initialized' + }); + } catch (error) { + // Disconnect if initialization fails. + void this.close(); + throw error; + } + } + + /** + * After initialization has completed, this will be populated with the server's reported capabilities. + */ + getServerCapabilities(): ServerCapabilities | undefined { + return this._serverCapabilities; + } + + /** + * After initialization has completed, this will be populated with information about the server's name and version. + */ + getServerVersion(): Implementation | undefined { + return this._serverVersion; + } + + /** + * After initialization has completed, this may be populated with information about the server's instructions. + */ + getInstructions(): string | undefined { + return this._instructions; + } + + protected assertCapabilityForMethod(method: RequestT['method']): void { + switch (method as ClientRequest['method']) { + case 'logging/setLevel': + if (!this._serverCapabilities?.logging) { + throw new Error(`Server does not support logging (required for ${method})`); + } + break; + + case 'prompts/get': + case 'prompts/list': + if (!this._serverCapabilities?.prompts) { + throw new Error(`Server does not support prompts (required for ${method})`); + } + break; + + case 'resources/list': + case 'resources/templates/list': + case 'resources/read': + case 'resources/subscribe': + case 'resources/unsubscribe': + if (!this._serverCapabilities?.resources) { + throw new Error(`Server does not support resources (required for ${method})`); + } + + if (method === 'resources/subscribe' && !this._serverCapabilities.resources.subscribe) { + throw new Error(`Server does not support resource subscriptions (required for ${method})`); + } + + break; + + case 'tools/call': + case 'tools/list': + if (!this._serverCapabilities?.tools) { + throw new Error(`Server does not support tools (required for ${method})`); + } + break; + + case 'completion/complete': + if (!this._serverCapabilities?.completions) { + throw new Error(`Server does not support completions (required for ${method})`); + } + break; + + case 'initialize': + // No specific capability required for initialize + break; + + case 'ping': + // No specific capability required for ping + break; + } + } + + protected assertNotificationCapability(method: NotificationT['method']): void { + switch (method as ClientNotification['method']) { + case 'notifications/roots/list_changed': + if (!this._capabilities.roots?.listChanged) { + throw new Error(`Client does not support roots list changed notifications (required for ${method})`); + } + break; + + case 'notifications/initialized': + // No specific capability required for initialized + break; + + case 'notifications/cancelled': + // Cancellation notifications are always allowed + break; + + case 'notifications/progress': + // Progress notifications are always allowed + break; + } + } + + protected assertRequestHandlerCapability(method: string): void { + switch (method) { + case 'sampling/createMessage': + if (!this._capabilities.sampling) { + throw new Error(`Client does not support sampling capability (required for ${method})`); + } + 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(`Client does not support roots capability (required for ${method})`); + } + break; + + case 'ping': + // No specific capability required for ping + break; + } + } + + async ping(options?: RequestOptions) { + return this.request({ method: 'ping' }, EmptyResultSchema, options); + } + + async complete(params: CompleteRequest['params'], options?: RequestOptions) { + return this.request({ method: 'completion/complete', params }, CompleteResultSchema, options); + } + + async setLoggingLevel(level: LoggingLevel, options?: RequestOptions) { + return this.request({ method: 'logging/setLevel', params: { level } }, EmptyResultSchema, options); + } + + async getPrompt(params: GetPromptRequest['params'], options?: RequestOptions) { + return this.request({ method: 'prompts/get', params }, GetPromptResultSchema, options); + } + + async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions) { + return this.request({ method: 'prompts/list', params }, ListPromptsResultSchema, options); + } + + async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions) { + return this.request({ method: 'resources/list', params }, ListResourcesResultSchema, options); + } + + async listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions) { + return this.request({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); + } + + async readResource(params: ReadResourceRequest['params'], options?: RequestOptions) { + return this.request({ method: 'resources/read', params }, ReadResourceResultSchema, options); + } + + async subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions) { + return this.request({ method: 'resources/subscribe', params }, EmptyResultSchema, options); + } + + async unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions) { + return this.request({ method: 'resources/unsubscribe', params }, EmptyResultSchema, options); + } + + async callTool( + params: CallToolRequest['params'], + resultSchema: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema = CallToolResultSchema, + options?: RequestOptions + ) { + 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 against the schema + const validationResult = validator(result.structuredContent); + + if (!validationResult.valid) { + throw new McpError( + ErrorCode.InvalidParams, + `Structured content does not match the tool's output schema: ${validationResult.errorMessage}` + ); + } + } 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; + } + + /** + * Cache validators for tool output schemas. + * Called after listTools() to pre-compile validators for better performance. + */ + private cacheToolOutputSchemas(tools: Tool[]): void { + this._cachedToolOutputValidators.clear(); + + for (const tool of tools) { + // If the tool has an outputSchema, create and cache the validator + if (tool.outputSchema) { + const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType); + this._cachedToolOutputValidators.set(tool.name, toolValidator); + } + } + } + + /** + * Get cached validator for a tool + */ + private getToolOutputValidator(toolName: string): JsonSchemaValidator | undefined { + return this._cachedToolOutputValidators.get(toolName); + } + + async listTools(params?: ListToolsRequest['params'], options?: RequestOptions) { + 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() { + return this.notification({ method: 'notifications/roots/list_changed' }); + } +} diff --git a/src/client/index.ts b/src/client/index.ts index cc6819815..62ad9aebb 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,440 +1,7 @@ -import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js'; -import type { Transport } from '../shared/transport.js'; -import { - type CallToolRequest, - CallToolResultSchema, - type ClientCapabilities, - type ClientNotification, - type ClientRequest, - type ClientResult, - type CompatibilityCallToolResultSchema, - type CompleteRequest, - CompleteResultSchema, - EmptyResultSchema, - ErrorCode, - type GetPromptRequest, - GetPromptResultSchema, - type Implementation, - InitializeResultSchema, - LATEST_PROTOCOL_VERSION, - type ListPromptsRequest, - ListPromptsResultSchema, - type ListResourcesRequest, - ListResourcesResultSchema, - type ListResourceTemplatesRequest, - ListResourceTemplatesResultSchema, - type ListToolsRequest, - ListToolsResultSchema, - type LoggingLevel, - McpError, - type Notification, - type ReadResourceRequest, - ReadResourceResultSchema, - type Request, - type Result, - type ServerCapabilities, - SUPPORTED_PROTOCOL_VERSIONS, - type SubscribeRequest, - type Tool, - type UnsubscribeRequest -} from '../types.js'; -import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; -import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js'; - -export type ClientOptions = ProtocolOptions & { - /** - * Capabilities to advertise as being supported by this client. - */ - capabilities?: ClientCapabilities; - - /** - * JSON Schema validator for tool output validation. - * - * The validator is used to validate structured content returned by tools - * against their declared output schemas. - * - * @default AjvJsonSchemaValidator - * - * @example - * ```typescript - * // ajv - * const client = new Client( - * { name: 'my-client', version: '1.0.0' }, - * { - * capabilities: {}, - * jsonSchemaValidator: new AjvJsonSchemaValidator() - * } - * ); - * - * // @cfworker/json-schema - * const client = new Client( - * { name: 'my-client', version: '1.0.0' }, - * { - * capabilities: {}, - * jsonSchemaValidator: new CfWorkerJsonSchemaValidator() - * } - * ); - * ``` - */ - jsonSchemaValidator?: jsonSchemaValidator; -}; - -/** - * An MCP client on top of a pluggable transport. - * - * The client will automatically begin the initialization flow with the server when connect() is called. - * - * To use with custom types, extend the base Request/Notification/Result types and pass them as type parameters: - * - * ```typescript - * // Custom schemas - * const CustomRequestSchema = RequestSchema.extend({...}) - * const CustomNotificationSchema = NotificationSchema.extend({...}) - * const CustomResultSchema = ResultSchema.extend({...}) - * - * // Type aliases - * type CustomRequest = z.infer - * type CustomNotification = z.infer - * type CustomResult = z.infer - * - * // Create typed client - * const client = new Client({ - * name: "CustomClient", - * version: "1.0.0" - * }) - * ``` - */ -export class Client< - RequestT extends Request = Request, - NotificationT extends Notification = Notification, - ResultT extends Result = Result -> extends Protocol { - private _serverCapabilities?: ServerCapabilities; - private _serverVersion?: Implementation; - private _capabilities: ClientCapabilities; - private _instructions?: string; - private _jsonSchemaValidator: jsonSchemaValidator; - private _cachedToolOutputValidators: Map> = new Map(); - - /** - * Initializes this client with the given name and version information. - */ - constructor( - private _clientInfo: Implementation, - options?: ClientOptions - ) { - super(options); - this._capabilities = options?.capabilities ?? {}; - this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator(); - } - - /** - * Registers new capabilities. This can only be called before connecting to a transport. - * - * The new capabilities will be merged with any existing capabilities previously given (e.g., at initialization). - */ - public registerCapabilities(capabilities: ClientCapabilities): void { - if (this.transport) { - throw new Error('Cannot register capabilities after connecting to transport'); - } - - this._capabilities = mergeCapabilities(this._capabilities, capabilities); - } - - protected assertCapability(capability: keyof ServerCapabilities, method: string): void { - if (!this._serverCapabilities?.[capability]) { - throw new Error(`Server does not support ${capability} (required for ${method})`); - } - } - - override async connect(transport: Transport, options?: RequestOptions): Promise { - await super.connect(transport); - // When transport sessionId is already set this means we are trying to reconnect. - // In this case we don't need to initialize again. - if (transport.sessionId !== undefined) { - return; - } - try { - const result = await this.request( - { - method: 'initialize', - params: { - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: this._capabilities, - clientInfo: this._clientInfo - } - }, - InitializeResultSchema, - options - ); - - if (result === undefined) { - throw new Error(`Server sent invalid initialize result: ${result}`); - } - - if (!SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { - throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); - } - - 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; - - await this.notification({ - method: 'notifications/initialized' - }); - } catch (error) { - // Disconnect if initialization fails. - void this.close(); - throw error; - } - } - - /** - * After initialization has completed, this will be populated with the server's reported capabilities. - */ - getServerCapabilities(): ServerCapabilities | undefined { - return this._serverCapabilities; - } - - /** - * After initialization has completed, this will be populated with information about the server's name and version. - */ - getServerVersion(): Implementation | undefined { - return this._serverVersion; - } - - /** - * After initialization has completed, this may be populated with information about the server's instructions. - */ - getInstructions(): string | undefined { - return this._instructions; - } - - protected assertCapabilityForMethod(method: RequestT['method']): void { - switch (method as ClientRequest['method']) { - case 'logging/setLevel': - if (!this._serverCapabilities?.logging) { - throw new Error(`Server does not support logging (required for ${method})`); - } - break; - - case 'prompts/get': - case 'prompts/list': - if (!this._serverCapabilities?.prompts) { - throw new Error(`Server does not support prompts (required for ${method})`); - } - break; - - case 'resources/list': - case 'resources/templates/list': - case 'resources/read': - case 'resources/subscribe': - case 'resources/unsubscribe': - if (!this._serverCapabilities?.resources) { - throw new Error(`Server does not support resources (required for ${method})`); - } - - if (method === 'resources/subscribe' && !this._serverCapabilities.resources.subscribe) { - throw new Error(`Server does not support resource subscriptions (required for ${method})`); - } - - break; - - case 'tools/call': - case 'tools/list': - if (!this._serverCapabilities?.tools) { - throw new Error(`Server does not support tools (required for ${method})`); - } - break; - - case 'completion/complete': - if (!this._serverCapabilities?.completions) { - throw new Error(`Server does not support completions (required for ${method})`); - } - break; - - case 'initialize': - // No specific capability required for initialize - break; - - case 'ping': - // No specific capability required for ping - break; - } - } - - protected assertNotificationCapability(method: NotificationT['method']): void { - switch (method as ClientNotification['method']) { - case 'notifications/roots/list_changed': - if (!this._capabilities.roots?.listChanged) { - throw new Error(`Client does not support roots list changed notifications (required for ${method})`); - } - break; - - case 'notifications/initialized': - // No specific capability required for initialized - break; - - case 'notifications/cancelled': - // Cancellation notifications are always allowed - break; - - case 'notifications/progress': - // Progress notifications are always allowed - break; - } - } - - protected assertRequestHandlerCapability(method: string): void { - switch (method) { - case 'sampling/createMessage': - if (!this._capabilities.sampling) { - throw new Error(`Client does not support sampling capability (required for ${method})`); - } - 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(`Client does not support roots capability (required for ${method})`); - } - break; - - case 'ping': - // No specific capability required for ping - break; - } - } - - async ping(options?: RequestOptions) { - return this.request({ method: 'ping' }, EmptyResultSchema, options); - } - - async complete(params: CompleteRequest['params'], options?: RequestOptions) { - return this.request({ method: 'completion/complete', params }, CompleteResultSchema, options); - } - - async setLoggingLevel(level: LoggingLevel, options?: RequestOptions) { - return this.request({ method: 'logging/setLevel', params: { level } }, EmptyResultSchema, options); - } - - async getPrompt(params: GetPromptRequest['params'], options?: RequestOptions) { - return this.request({ method: 'prompts/get', params }, GetPromptResultSchema, options); - } - - async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions) { - return this.request({ method: 'prompts/list', params }, ListPromptsResultSchema, options); - } - - async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions) { - return this.request({ method: 'resources/list', params }, ListResourcesResultSchema, options); - } - - async listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions) { - return this.request({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); - } - - async readResource(params: ReadResourceRequest['params'], options?: RequestOptions) { - return this.request({ method: 'resources/read', params }, ReadResourceResultSchema, options); - } - - async subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions) { - return this.request({ method: 'resources/subscribe', params }, EmptyResultSchema, options); - } - - async unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions) { - return this.request({ method: 'resources/unsubscribe', params }, EmptyResultSchema, options); - } - - async callTool( - params: CallToolRequest['params'], - resultSchema: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema = CallToolResultSchema, - options?: RequestOptions - ) { - 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 against the schema - const validationResult = validator(result.structuredContent); - - if (!validationResult.valid) { - throw new McpError( - ErrorCode.InvalidParams, - `Structured content does not match the tool's output schema: ${validationResult.errorMessage}` - ); - } - } 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; - } - - /** - * Cache validators for tool output schemas. - * Called after listTools() to pre-compile validators for better performance. - */ - private cacheToolOutputSchemas(tools: Tool[]): void { - this._cachedToolOutputValidators.clear(); - - for (const tool of tools) { - // If the tool has an outputSchema, create and cache the validator - if (tool.outputSchema) { - const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType); - this._cachedToolOutputValidators.set(tool.name, toolValidator); - } - } - } - - /** - * Get cached validator for a tool - */ - private getToolOutputValidator(toolName: string): JsonSchemaValidator | undefined { - return this._cachedToolOutputValidators.get(toolName); - } - - async listTools(params?: ListToolsRequest['params'], options?: RequestOptions) { - 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() { - return this.notification({ method: 'notifications/roots/list_changed' }); - } -} +// Client exports +export * from './auth.js'; +export * from './client.js'; +export * from './sse.js'; +export * from './stdio.js'; +export * from './streamableHttp.js'; +export * from './websocket.js'; diff --git a/src/server/auth/index.ts b/src/server/auth/index.ts new file mode 100644 index 000000000..f5b60e26f --- /dev/null +++ b/src/server/auth/index.ts @@ -0,0 +1,21 @@ +// Auth exports - root auth level +export * from './clients.js'; +export * from './errors.js'; +export * from './provider.js'; +export * from './router.js'; +export * from './types.js'; + +// Auth exports - handlers +export * from './handlers/authorize.js'; +export * from './handlers/metadata.js'; +export * from './handlers/register.js'; +export * from './handlers/revoke.js'; +export * from './handlers/token.js'; + +// Auth exports - middleware +export * from './middleware/allowedMethods.js'; +export * from './middleware/bearerAuth.js'; +export * from './middleware/clientAuth.js'; + +// Auth exports - providers +export * from './providers/proxyProvider.js'; diff --git a/src/server/index.ts b/src/server/index.ts index 47b5f538f..0a8d15713 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,391 +1,10 @@ -import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js'; -import { - type ClientCapabilities, - type CreateMessageRequest, - CreateMessageResultSchema, - type ElicitRequest, - type ElicitResult, - ElicitResultSchema, - EmptyResultSchema, - ErrorCode, - type Implementation, - InitializedNotificationSchema, - type InitializeRequest, - InitializeRequestSchema, - type InitializeResult, - LATEST_PROTOCOL_VERSION, - type ListRootsRequest, - ListRootsResultSchema, - type LoggingLevel, - LoggingLevelSchema, - type LoggingMessageNotification, - McpError, - type Notification, - type Request, - type ResourceUpdatedNotification, - type Result, - type ServerCapabilities, - type ServerNotification, - type ServerRequest, - type ServerResult, - SetLevelRequestSchema, - SUPPORTED_PROTOCOL_VERSIONS -} from '../types.js'; -import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; -import type { JsonSchemaType, jsonSchemaValidator } from '../validation/types.js'; - -export type ServerOptions = ProtocolOptions & { - /** - * Capabilities to advertise as being supported by this server. - */ - capabilities?: ServerCapabilities; - - /** - * Optional instructions describing how to use the server and its features. - */ - instructions?: string; - - /** - * JSON Schema validator for elicitation response validation. - * - * The validator is used to validate user input returned from elicitation - * requests against the requested schema. - * - * @default AjvJsonSchemaValidator - * - * @example - * ```typescript - * // ajv (default) - * const server = new Server( - * { name: 'my-server', version: '1.0.0' }, - * { - * capabilities: {} - * jsonSchemaValidator: new AjvJsonSchemaValidator() - * } - * ); - * - * // @cfworker/json-schema - * const server = new Server( - * { name: 'my-server', version: '1.0.0' }, - * { - * capabilities: {}, - * jsonSchemaValidator: new CfWorkerJsonSchemaValidator() - * } - * ); - * ``` - */ - jsonSchemaValidator?: jsonSchemaValidator; -}; - -/** - * An MCP server on top of a pluggable transport. - * - * This server will automatically respond to the initialization flow as initiated from the client. - * - * To use with custom types, extend the base Request/Notification/Result types and pass them as type parameters: - * - * ```typescript - * // Custom schemas - * const CustomRequestSchema = RequestSchema.extend({...}) - * const CustomNotificationSchema = NotificationSchema.extend({...}) - * const CustomResultSchema = ResultSchema.extend({...}) - * - * // Type aliases - * type CustomRequest = z.infer - * type CustomNotification = z.infer - * type CustomResult = z.infer - * - * // Create typed server - * const server = new Server({ - * name: "CustomServer", - * version: "1.0.0" - * }) - * ``` - * @deprecated Use `McpServer` instead for the high-level API. Only use `Server` for advanced use cases. - */ -export class Server< - RequestT extends Request = Request, - NotificationT extends Notification = Notification, - ResultT extends Result = Result -> extends Protocol { - private _clientCapabilities?: ClientCapabilities; - private _clientVersion?: Implementation; - private _capabilities: ServerCapabilities; - private _instructions?: string; - private _jsonSchemaValidator: jsonSchemaValidator; - - /** - * Callback for when initialization has fully completed (i.e., the client has sent an `initialized` notification). - */ - oninitialized?: () => void; - - /** - * Initializes this server with the given name and version information. - */ - constructor( - private _serverInfo: Implementation, - options?: ServerOptions - ) { - super(options); - this._capabilities = options?.capabilities ?? {}; - this._instructions = options?.instructions; - this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator(); - - this.setRequestHandler(InitializeRequestSchema, request => this._oninitialize(request)); - this.setNotificationHandler(InitializedNotificationSchema, () => this.oninitialized?.()); - - if (this._capabilities.logging) { - this.setRequestHandler(SetLevelRequestSchema, async (request, extra) => { - const transportSessionId: string | undefined = - extra.sessionId || (extra.requestInfo?.headers['mcp-session-id'] as string) || undefined; - const { level } = request.params; - const parseResult = LoggingLevelSchema.safeParse(level); - if (parseResult.success) { - this._loggingLevels.set(transportSessionId, parseResult.data); - } - return {}; - }); - } - } - - // Map log levels by session id - private _loggingLevels = new Map(); - - // Map LogLevelSchema to severity index - private readonly LOG_LEVEL_SEVERITY = new Map(LoggingLevelSchema.options.map((level, index) => [level, index])); - - // Is a message with the given level ignored in the log level set for the given session id? - private isMessageIgnored = (level: LoggingLevel, sessionId?: string): boolean => { - const currentLevel = this._loggingLevels.get(sessionId); - return currentLevel ? this.LOG_LEVEL_SEVERITY.get(level)! < this.LOG_LEVEL_SEVERITY.get(currentLevel)! : false; - }; - - /** - * Registers new capabilities. This can only be called before connecting to a transport. - * - * The new capabilities will be merged with any existing capabilities previously given (e.g., at initialization). - */ - public registerCapabilities(capabilities: ServerCapabilities): void { - if (this.transport) { - throw new Error('Cannot register capabilities after connecting to transport'); - } - this._capabilities = mergeCapabilities(this._capabilities, capabilities); - } - - protected assertCapabilityForMethod(method: RequestT['method']): void { - switch (method as ServerRequest['method']) { - case 'sampling/createMessage': - if (!this._clientCapabilities?.sampling) { - throw new Error(`Client does not support sampling (required for ${method})`); - } - break; - - case 'elicitation/create': - if (!this._clientCapabilities?.elicitation) { - throw new Error(`Client does not support elicitation (required for ${method})`); - } - break; - - case 'roots/list': - if (!this._clientCapabilities?.roots) { - throw new Error(`Client does not support listing roots (required for ${method})`); - } - break; - - case 'ping': - // No specific capability required for ping - break; - } - } - - protected assertNotificationCapability(method: (ServerNotification | NotificationT)['method']): void { - switch (method as ServerNotification['method']) { - case 'notifications/message': - if (!this._capabilities.logging) { - throw new Error(`Server does not support logging (required for ${method})`); - } - break; - - case 'notifications/resources/updated': - case 'notifications/resources/list_changed': - if (!this._capabilities.resources) { - throw new Error(`Server does not support notifying about resources (required for ${method})`); - } - break; - - case 'notifications/tools/list_changed': - if (!this._capabilities.tools) { - throw new Error(`Server does not support notifying of tool list changes (required for ${method})`); - } - break; - - case 'notifications/prompts/list_changed': - if (!this._capabilities.prompts) { - throw new Error(`Server does not support notifying of prompt list changes (required for ${method})`); - } - break; - - case 'notifications/cancelled': - // Cancellation notifications are always allowed - break; - - case 'notifications/progress': - // Progress notifications are always allowed - break; - } - } - - protected assertRequestHandlerCapability(method: string): void { - switch (method) { - case 'completion/complete': - if (!this._capabilities.completions) { - throw new Error(`Server does not support completions (required for ${method})`); - } - break; - - case 'logging/setLevel': - if (!this._capabilities.logging) { - throw new Error(`Server does not support logging (required for ${method})`); - } - break; - - case 'prompts/get': - case 'prompts/list': - if (!this._capabilities.prompts) { - throw new Error(`Server does not support prompts (required for ${method})`); - } - break; - - case 'resources/list': - case 'resources/templates/list': - case 'resources/read': - if (!this._capabilities.resources) { - throw new Error(`Server does not support resources (required for ${method})`); - } - break; - - case 'tools/call': - case 'tools/list': - if (!this._capabilities.tools) { - throw new Error(`Server does not support tools (required for ${method})`); - } - break; - - case 'ping': - case 'initialize': - // No specific capability required for these methods - break; - } - } - - private async _oninitialize(request: InitializeRequest): Promise { - const requestedVersion = request.params.protocolVersion; - - this._clientCapabilities = request.params.capabilities; - this._clientVersion = request.params.clientInfo; - - const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion) ? requestedVersion : LATEST_PROTOCOL_VERSION; - - return { - protocolVersion, - capabilities: this.getCapabilities(), - serverInfo: this._serverInfo, - ...(this._instructions && { instructions: this._instructions }) - }; - } - - /** - * After initialization has completed, this will be populated with the client's reported capabilities. - */ - getClientCapabilities(): ClientCapabilities | undefined { - return this._clientCapabilities; - } - - /** - * After initialization has completed, this will be populated with information about the client's name and version. - */ - getClientVersion(): Implementation | undefined { - return this._clientVersion; - } - - private getCapabilities(): ServerCapabilities { - return this._capabilities; - } - - async ping() { - return this.request({ method: 'ping' }, EmptyResultSchema); - } - - async createMessage(params: CreateMessageRequest['params'], options?: RequestOptions) { - return this.request({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); - } - - async elicitInput(params: ElicitRequest['params'], options?: RequestOptions): Promise { - const result = await this.request({ method: 'elicitation/create', params }, ElicitResultSchema, options); - - // Validate the response content against the requested schema if action is "accept" - if (result.action === 'accept' && result.content && params.requestedSchema) { - try { - const validator = this._jsonSchemaValidator.getValidator(params.requestedSchema as JsonSchemaType); - const validationResult = validator(result.content); - - if (!validationResult.valid) { - throw new McpError( - ErrorCode.InvalidParams, - `Elicitation response content does not match requested schema: ${validationResult.errorMessage}` - ); - } - } catch (error) { - if (error instanceof McpError) { - throw error; - } - throw new McpError( - ErrorCode.InternalError, - `Error validating elicitation response: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - return result; - } - - async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions) { - return this.request({ method: 'roots/list', params }, ListRootsResultSchema, options); - } - - /** - * Sends a logging message to the client, if connected. - * Note: You only need to send the parameters object, not the entire JSON RPC message - * @see LoggingMessageNotification - * @param params - * @param sessionId optional for stateless and backward compatibility - */ - async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string) { - if (this._capabilities.logging) { - if (!this.isMessageIgnored(params.level, sessionId)) { - return this.notification({ method: 'notifications/message', params }); - } - } - } - - async sendResourceUpdated(params: ResourceUpdatedNotification['params']) { - return this.notification({ - method: 'notifications/resources/updated', - params - }); - } - - async sendResourceListChanged() { - return this.notification({ - method: 'notifications/resources/list_changed' - }); - } - - async sendToolListChanged() { - return this.notification({ method: 'notifications/tools/list_changed' }); - } - - async sendPromptListChanged() { - return this.notification({ method: 'notifications/prompts/list_changed' }); - } -} +// Server exports +export * from './completable.js'; +export * from './mcp.js'; +export * from './server.js'; +export * from './sse.js'; +export * from './stdio.js'; +export * from './streamableHttp.js'; + +// Auth exports +export * from './auth/index.js'; diff --git a/src/server/index.test.ts b/src/server/server.test.ts similarity index 100% rename from src/server/index.test.ts rename to src/server/server.test.ts diff --git a/src/server/server.ts b/src/server/server.ts new file mode 100644 index 000000000..47b5f538f --- /dev/null +++ b/src/server/server.ts @@ -0,0 +1,391 @@ +import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js'; +import { + type ClientCapabilities, + type CreateMessageRequest, + CreateMessageResultSchema, + type ElicitRequest, + type ElicitResult, + ElicitResultSchema, + EmptyResultSchema, + ErrorCode, + type Implementation, + InitializedNotificationSchema, + type InitializeRequest, + InitializeRequestSchema, + type InitializeResult, + LATEST_PROTOCOL_VERSION, + type ListRootsRequest, + ListRootsResultSchema, + type LoggingLevel, + LoggingLevelSchema, + type LoggingMessageNotification, + McpError, + type Notification, + type Request, + type ResourceUpdatedNotification, + type Result, + type ServerCapabilities, + type ServerNotification, + type ServerRequest, + type ServerResult, + SetLevelRequestSchema, + SUPPORTED_PROTOCOL_VERSIONS +} from '../types.js'; +import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; +import type { JsonSchemaType, jsonSchemaValidator } from '../validation/types.js'; + +export type ServerOptions = ProtocolOptions & { + /** + * Capabilities to advertise as being supported by this server. + */ + capabilities?: ServerCapabilities; + + /** + * Optional instructions describing how to use the server and its features. + */ + instructions?: string; + + /** + * JSON Schema validator for elicitation response validation. + * + * The validator is used to validate user input returned from elicitation + * requests against the requested schema. + * + * @default AjvJsonSchemaValidator + * + * @example + * ```typescript + * // ajv (default) + * const server = new Server( + * { name: 'my-server', version: '1.0.0' }, + * { + * capabilities: {} + * jsonSchemaValidator: new AjvJsonSchemaValidator() + * } + * ); + * + * // @cfworker/json-schema + * const server = new Server( + * { name: 'my-server', version: '1.0.0' }, + * { + * capabilities: {}, + * jsonSchemaValidator: new CfWorkerJsonSchemaValidator() + * } + * ); + * ``` + */ + jsonSchemaValidator?: jsonSchemaValidator; +}; + +/** + * An MCP server on top of a pluggable transport. + * + * This server will automatically respond to the initialization flow as initiated from the client. + * + * To use with custom types, extend the base Request/Notification/Result types and pass them as type parameters: + * + * ```typescript + * // Custom schemas + * const CustomRequestSchema = RequestSchema.extend({...}) + * const CustomNotificationSchema = NotificationSchema.extend({...}) + * const CustomResultSchema = ResultSchema.extend({...}) + * + * // Type aliases + * type CustomRequest = z.infer + * type CustomNotification = z.infer + * type CustomResult = z.infer + * + * // Create typed server + * const server = new Server({ + * name: "CustomServer", + * version: "1.0.0" + * }) + * ``` + * @deprecated Use `McpServer` instead for the high-level API. Only use `Server` for advanced use cases. + */ +export class Server< + RequestT extends Request = Request, + NotificationT extends Notification = Notification, + ResultT extends Result = Result +> extends Protocol { + private _clientCapabilities?: ClientCapabilities; + private _clientVersion?: Implementation; + private _capabilities: ServerCapabilities; + private _instructions?: string; + private _jsonSchemaValidator: jsonSchemaValidator; + + /** + * Callback for when initialization has fully completed (i.e., the client has sent an `initialized` notification). + */ + oninitialized?: () => void; + + /** + * Initializes this server with the given name and version information. + */ + constructor( + private _serverInfo: Implementation, + options?: ServerOptions + ) { + super(options); + this._capabilities = options?.capabilities ?? {}; + this._instructions = options?.instructions; + this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator(); + + this.setRequestHandler(InitializeRequestSchema, request => this._oninitialize(request)); + this.setNotificationHandler(InitializedNotificationSchema, () => this.oninitialized?.()); + + if (this._capabilities.logging) { + this.setRequestHandler(SetLevelRequestSchema, async (request, extra) => { + const transportSessionId: string | undefined = + extra.sessionId || (extra.requestInfo?.headers['mcp-session-id'] as string) || undefined; + const { level } = request.params; + const parseResult = LoggingLevelSchema.safeParse(level); + if (parseResult.success) { + this._loggingLevels.set(transportSessionId, parseResult.data); + } + return {}; + }); + } + } + + // Map log levels by session id + private _loggingLevels = new Map(); + + // Map LogLevelSchema to severity index + private readonly LOG_LEVEL_SEVERITY = new Map(LoggingLevelSchema.options.map((level, index) => [level, index])); + + // Is a message with the given level ignored in the log level set for the given session id? + private isMessageIgnored = (level: LoggingLevel, sessionId?: string): boolean => { + const currentLevel = this._loggingLevels.get(sessionId); + return currentLevel ? this.LOG_LEVEL_SEVERITY.get(level)! < this.LOG_LEVEL_SEVERITY.get(currentLevel)! : false; + }; + + /** + * Registers new capabilities. This can only be called before connecting to a transport. + * + * The new capabilities will be merged with any existing capabilities previously given (e.g., at initialization). + */ + public registerCapabilities(capabilities: ServerCapabilities): void { + if (this.transport) { + throw new Error('Cannot register capabilities after connecting to transport'); + } + this._capabilities = mergeCapabilities(this._capabilities, capabilities); + } + + protected assertCapabilityForMethod(method: RequestT['method']): void { + switch (method as ServerRequest['method']) { + case 'sampling/createMessage': + if (!this._clientCapabilities?.sampling) { + throw new Error(`Client does not support sampling (required for ${method})`); + } + break; + + case 'elicitation/create': + if (!this._clientCapabilities?.elicitation) { + throw new Error(`Client does not support elicitation (required for ${method})`); + } + break; + + case 'roots/list': + if (!this._clientCapabilities?.roots) { + throw new Error(`Client does not support listing roots (required for ${method})`); + } + break; + + case 'ping': + // No specific capability required for ping + break; + } + } + + protected assertNotificationCapability(method: (ServerNotification | NotificationT)['method']): void { + switch (method as ServerNotification['method']) { + case 'notifications/message': + if (!this._capabilities.logging) { + throw new Error(`Server does not support logging (required for ${method})`); + } + break; + + case 'notifications/resources/updated': + case 'notifications/resources/list_changed': + if (!this._capabilities.resources) { + throw new Error(`Server does not support notifying about resources (required for ${method})`); + } + break; + + case 'notifications/tools/list_changed': + if (!this._capabilities.tools) { + throw new Error(`Server does not support notifying of tool list changes (required for ${method})`); + } + break; + + case 'notifications/prompts/list_changed': + if (!this._capabilities.prompts) { + throw new Error(`Server does not support notifying of prompt list changes (required for ${method})`); + } + break; + + case 'notifications/cancelled': + // Cancellation notifications are always allowed + break; + + case 'notifications/progress': + // Progress notifications are always allowed + break; + } + } + + protected assertRequestHandlerCapability(method: string): void { + switch (method) { + case 'completion/complete': + if (!this._capabilities.completions) { + throw new Error(`Server does not support completions (required for ${method})`); + } + break; + + case 'logging/setLevel': + if (!this._capabilities.logging) { + throw new Error(`Server does not support logging (required for ${method})`); + } + break; + + case 'prompts/get': + case 'prompts/list': + if (!this._capabilities.prompts) { + throw new Error(`Server does not support prompts (required for ${method})`); + } + break; + + case 'resources/list': + case 'resources/templates/list': + case 'resources/read': + if (!this._capabilities.resources) { + throw new Error(`Server does not support resources (required for ${method})`); + } + break; + + case 'tools/call': + case 'tools/list': + if (!this._capabilities.tools) { + throw new Error(`Server does not support tools (required for ${method})`); + } + break; + + case 'ping': + case 'initialize': + // No specific capability required for these methods + break; + } + } + + private async _oninitialize(request: InitializeRequest): Promise { + const requestedVersion = request.params.protocolVersion; + + this._clientCapabilities = request.params.capabilities; + this._clientVersion = request.params.clientInfo; + + const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion) ? requestedVersion : LATEST_PROTOCOL_VERSION; + + return { + protocolVersion, + capabilities: this.getCapabilities(), + serverInfo: this._serverInfo, + ...(this._instructions && { instructions: this._instructions }) + }; + } + + /** + * After initialization has completed, this will be populated with the client's reported capabilities. + */ + getClientCapabilities(): ClientCapabilities | undefined { + return this._clientCapabilities; + } + + /** + * After initialization has completed, this will be populated with information about the client's name and version. + */ + getClientVersion(): Implementation | undefined { + return this._clientVersion; + } + + private getCapabilities(): ServerCapabilities { + return this._capabilities; + } + + async ping() { + return this.request({ method: 'ping' }, EmptyResultSchema); + } + + async createMessage(params: CreateMessageRequest['params'], options?: RequestOptions) { + return this.request({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); + } + + async elicitInput(params: ElicitRequest['params'], options?: RequestOptions): Promise { + const result = await this.request({ method: 'elicitation/create', params }, ElicitResultSchema, options); + + // Validate the response content against the requested schema if action is "accept" + if (result.action === 'accept' && result.content && params.requestedSchema) { + try { + const validator = this._jsonSchemaValidator.getValidator(params.requestedSchema as JsonSchemaType); + const validationResult = validator(result.content); + + if (!validationResult.valid) { + throw new McpError( + ErrorCode.InvalidParams, + `Elicitation response content does not match requested schema: ${validationResult.errorMessage}` + ); + } + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.InternalError, + `Error validating elicitation response: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + return result; + } + + async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions) { + return this.request({ method: 'roots/list', params }, ListRootsResultSchema, options); + } + + /** + * Sends a logging message to the client, if connected. + * Note: You only need to send the parameters object, not the entire JSON RPC message + * @see LoggingMessageNotification + * @param params + * @param sessionId optional for stateless and backward compatibility + */ + async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string) { + if (this._capabilities.logging) { + if (!this.isMessageIgnored(params.level, sessionId)) { + return this.notification({ method: 'notifications/message', params }); + } + } + } + + async sendResourceUpdated(params: ResourceUpdatedNotification['params']) { + return this.notification({ + method: 'notifications/resources/updated', + params + }); + } + + async sendResourceListChanged() { + return this.notification({ + method: 'notifications/resources/list_changed' + }); + } + + async sendToolListChanged() { + return this.notification({ method: 'notifications/tools/list_changed' }); + } + + async sendPromptListChanged() { + return this.notification({ method: 'notifications/prompts/list_changed' }); + } +} diff --git a/src/shared/index.ts b/src/shared/index.ts new file mode 100644 index 000000000..9f594b065 --- /dev/null +++ b/src/shared/index.ts @@ -0,0 +1,9 @@ +export * from './auth-utils.js'; +export * from './auth.js'; +export * from './metadataUtils.js'; +export * from './protocol.js'; +export * from './stdio.js'; +export * from './transport.js'; +export * from './uriTemplate.js'; +export * from '../types.js'; +export * from '../inMemory.js';