UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

496 lines (439 loc) 15.2 kB
import { $inject, Alepha, TypeBoxError } from "alepha"; import { $logger } from "alepha/logger"; import { McpError, McpMethodNotFoundError, McpPromptNotFoundError, McpResourceNotFoundError, McpToolNotFoundError, } from "../errors/McpError.ts"; import { createErrorResponse, createInternalError, createResponse, isSupportedProtocolVersion, MCP_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, } from "../helpers/jsonrpc.ts"; import type { JsonRpcRequest, JsonRpcResponse, McpCapabilities, McpContent, McpContext, McpInitializeResult, McpPromptDescriptor, McpPromptGetResult, McpPromptMessage, McpResourceContent, McpResourceDescriptor, McpResourceReadResult, McpServerInfo, McpToolCallResult, McpToolDescriptor, } from "../interfaces/McpTypes.ts"; import type { PromptPrimitive } from "../primitives/$prompt.ts"; import type { ResourcePrimitive } from "../primitives/$resource.ts"; import type { ToolPrimitive } from "../primitives/$tool.ts"; // --------------------------------------------------------------------------------------------------------------------- /** * Core MCP server provider that handles protocol messages. * * This provider maintains registries of tools, resources, and prompts, * and routes incoming JSON-RPC requests to the appropriate handlers. * * It is transport-agnostic - actual communication is handled by * transport providers like StdioMcpTransport or SseMcpTransport. */ export class McpServerProvider { protected readonly log = $logger(); protected readonly alepha = $inject(Alepha); protected readonly tools = new Map<string, ToolPrimitive<any>>(); protected readonly resources = new Map<string, ResourcePrimitive>(); protected readonly prompts = new Map<string, PromptPrimitive<any>>(); protected initialized = false; /** * Protocol version negotiated with the client during `initialize`. * Used by transports to validate the `MCP-Protocol-Version` header on * subsequent HTTP requests (per spec 2025-06-18+). */ public negotiatedVersion: string = MCP_PROTOCOL_VERSION; /** * Server identity returned during `initialize`. Consumers may override * fields directly (e.g. `mcpServer.serverInfo = { name: "lore-mcp", * version: "0.20.3", description: "..." }`) — the `description` field * is supported per spec 2025-11-25 (minor change #2). */ public serverInfo: McpServerInfo = { name: "alepha-mcp", version: "1.0.0", }; // ----------------------------------------------------------------------------------------------------------------- // Registration Methods // ----------------------------------------------------------------------------------------------------------------- /** * Register a tool with the MCP server. */ public registerTool(tool: ToolPrimitive<any>): void { this.log.trace(`Registering MCP tool: ${tool.name}`); this.tools.set(tool.name, tool); } /** * Register a resource with the MCP server. */ public registerResource(resource: ResourcePrimitive): void { this.log.trace(`Registering MCP resource: ${resource.uri}`); this.resources.set(resource.uri, resource); } /** * Register a prompt with the MCP server. */ public registerPrompt(prompt: PromptPrimitive<any>): void { this.log.trace(`Registering MCP prompt: ${prompt.name}`); this.prompts.set(prompt.name, prompt); } // ----------------------------------------------------------------------------------------------------------------- // Getters // ----------------------------------------------------------------------------------------------------------------- /** * Get the server capabilities based on registered primitives. */ public getCapabilities(): McpCapabilities { return { tools: this.tools.size > 0 ? {} : undefined, resources: this.resources.size > 0 ? {} : undefined, prompts: this.prompts.size > 0 ? {} : undefined, }; } /** * Get all registered tools. */ public getTools(): ToolPrimitive<any>[] { return Array.from(this.tools.values()); } /** * Get all registered resources. */ public getResources(): ResourcePrimitive[] { return Array.from(this.resources.values()); } /** * Get all registered prompts. */ public getPrompts(): PromptPrimitive<any>[] { return Array.from(this.prompts.values()); } /** * Get a tool by name. */ public getTool(name: string): ToolPrimitive<any> | undefined { return this.tools.get(name); } /** * Get a resource by URI. */ public getResource(uri: string): ResourcePrimitive | undefined { return this.resources.get(uri); } /** * Get a prompt by name. */ public getPrompt(name: string): PromptPrimitive<any> | undefined { return this.prompts.get(name); } // ----------------------------------------------------------------------------------------------------------------- // Message Handling // ----------------------------------------------------------------------------------------------------------------- /** * Handle an incoming JSON-RPC request. * * @param request - The parsed JSON-RPC request * @param context - Optional context from the transport layer (headers, auth, etc.) * @returns The JSON-RPC response, or null for notifications */ public async handleMessage( request: JsonRpcRequest, context?: McpContext, ): Promise<JsonRpcResponse | null> { const id = request.id; // Notifications have no id and expect no response if (id === undefined) { await this.handleNotification(request); return null; } try { const result = await this.handleRequest(request, context); return createResponse(id, result); } catch (error) { this.log.error("MCP request failed", error); // Preserve error code from McpError instances if (error instanceof McpError) { return createErrorResponse(id, { code: error.code, message: error.message, }); } return createErrorResponse( id, createInternalError((error as Error).message), ); } } /** * Handle a JSON-RPC request that expects a response. */ protected async handleRequest( request: JsonRpcRequest, context?: McpContext, ): Promise<unknown> { const { method, params = {} } = request; switch (method) { case "initialize": return this.handleInitialize(params); case "ping": return this.handlePing(); case "tools/list": return this.handleToolsList(); case "tools/call": return this.handleToolsCall(params, context); case "resources/list": return this.handleResourcesList(); case "resources/read": return this.handleResourcesRead(params, context); case "prompts/list": return this.handlePromptsList(); case "prompts/get": return this.handlePromptsGet(params, context); default: throw new McpMethodNotFoundError(method); } } /** * Handle a notification (no response expected). */ protected async handleNotification(request: JsonRpcRequest): Promise<void> { const { method } = request; switch (method) { case "notifications/initialized": this.log.debug("MCP client initialized"); break; case "notifications/cancelled": this.log.debug("MCP request cancelled", request.params); break; default: this.log.debug(`Unknown MCP notification: ${method}`); } } // ----------------------------------------------------------------------------------------------------------------- // Protocol Handlers // ----------------------------------------------------------------------------------------------------------------- protected handleInitialize( params: Record<string, unknown>, ): McpInitializeResult { const requested = params.protocolVersion; // Echo the client's version when supported, otherwise reply with our // preferred version (highest entry in SUPPORTED_PROTOCOL_VERSIONS). // The client can then decide to retry, downgrade, or disconnect. const negotiated = isSupportedProtocolVersion(requested) ? requested : SUPPORTED_PROTOCOL_VERSIONS[0]; this.log.info("MCP client initializing", { clientInfo: params.clientInfo, requestedProtocolVersion: requested, negotiatedProtocolVersion: negotiated, }); this.initialized = true; this.negotiatedVersion = negotiated; return { protocolVersion: negotiated, capabilities: this.getCapabilities(), serverInfo: this.serverInfo, }; } protected handlePing(): Record<string, never> { return {}; } protected handleToolsList(): { tools: McpToolDescriptor[] } { return { tools: Array.from(this.tools.values()).map((t) => t.toDescriptor()), }; } protected async handleToolsCall( params: Record<string, unknown>, context?: McpContext, ): Promise<McpToolCallResult> { const name = params.name as string; const args = (params.arguments ?? {}) as Record<string, unknown>; const tool = this.tools.get(name); if (!tool) { // McpToolNotFoundError is intentionally a JSON-RPC protocol error, // not a tool execution error — see SEP-1303 (only validation/runtime // failures of an existing tool are reported via isError: true). throw new McpToolNotFoundError(name); } try { const result = await tool.execute(args, context); // A tool WITHOUT an output schema may return raw MCP content blocks // (e.g. an `image` block) instead of JSON — used for binary payloads // like attachment previews. Recognized by the CallToolResult shape // (`{ content: McpContent[] }`); passed through verbatim. Tools that // declare an output schema always go through the structured path // below, so a JSON result that happens to carry a `content` array is // never mistaken for raw content. if (!tool.hasOutputSchema()) { const raw = this.asRawToolContent(result); if (raw) { return raw; } } const callResult: McpToolCallResult = { content: [ { type: "text", text: typeof result === "string" ? result : JSON.stringify(result ?? null), }, ], }; // Spec 2025-06-18: when the tool declares an outputSchema, the server // MUST populate `structuredContent` with the validated result. The // text-stringified `content` block remains as a back-compat fallback. if (tool.hasOutputSchema() && result !== undefined) { callResult.structuredContent = result; } return callResult; } catch (error) { // Spec 2025-11-25 / SEP-1303: input-validation failures (and other // tool-runtime errors) are returned as Tool Execution Errors, not // JSON-RPC protocol errors, so the model can self-correct. // For TypeBox validation errors we surface the failing path so the // model knows which argument was malformed. if (error instanceof TypeBoxError) { const path = error.value?.path || "/"; const message = error.value?.message || error.message; return { content: [ { type: "text", text: `Validation error at ${path}: ${message}`, }, ], structuredContent: { errors: [{ path, message }], }, isError: true, }; } return { content: [ { type: "text", text: `Error: ${(error as Error).message}`, }, ], isError: true, }; } } /** * Recognize a tool handler's return value as a pre-built MCP tool result — * i.e. it already carries a `content` array of content blocks (text, image, * audio, resource, resource_link). Returns the normalized * {@link McpToolCallResult} when matched, or `undefined` to fall back to the * default JSON/text encoding. Only ever consulted for tools that did NOT * declare an output schema (see {@link handleToolCall}). */ protected asRawToolContent(result: unknown): McpToolCallResult | undefined { if (!result || typeof result !== "object") { return undefined; } const candidate = result as { content?: unknown; isError?: unknown; _meta?: unknown; }; if (!Array.isArray(candidate.content) || candidate.content.length === 0) { return undefined; } const allBlocks = candidate.content.every( (block): block is McpContent => !!block && typeof block === "object" && typeof (block as { type?: unknown }).type === "string", ); if (!allBlocks) { return undefined; } return { content: candidate.content as McpContent[], isError: typeof candidate.isError === "boolean" ? candidate.isError : undefined, _meta: candidate._meta && typeof candidate._meta === "object" ? (candidate._meta as Record<string, unknown>) : undefined, }; } protected handleResourcesList(): { resources: McpResourceDescriptor[] } { return { resources: Array.from(this.resources.values()).map((r) => r.toDescriptor(), ), }; } protected async handleResourcesRead( params: Record<string, unknown>, context?: McpContext, ): Promise<McpResourceReadResult> { const uri = params.uri as string; const resource = this.resources.get(uri); if (!resource) { throw new McpResourceNotFoundError(uri); } const content = await resource.read(context); const resourceContent: McpResourceContent = { uri, mimeType: resource.mimeType, }; if (content.text !== undefined) { resourceContent.text = content.text; } if (content.blob !== undefined) { // Convert binary to base64 for transport resourceContent.blob = Buffer.from(content.blob).toString("base64"); } return { contents: [resourceContent], }; } protected handlePromptsList(): { prompts: McpPromptDescriptor[] } { return { prompts: Array.from(this.prompts.values()).map((p) => p.toDescriptor()), }; } protected async handlePromptsGet( params: Record<string, unknown>, context?: McpContext, ): Promise<McpPromptGetResult> { const name = params.name as string; const args = (params.arguments ?? {}) as Record<string, string>; const prompt = this.prompts.get(name); if (!prompt) { throw new McpPromptNotFoundError(name); } const messages = await prompt.get(args, context); const mcpMessages: McpPromptMessage[] = messages.map((msg) => ({ role: msg.role, content: { type: "text" as const, text: msg.content, }, })); return { description: prompt.description, messages: mcpMessages, }; } }