From b1702eaebe885d3d8d2cb63cc7f5bf4f4976138f Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Fri, 26 Jun 2026 16:04:34 +0300 Subject: [PATCH] feat(mcp): add @gemstack/mcp agent-agnostic MCP server framework (Phase 1) Graduates the framework-agnostic core of @rudderjs/mcp into a standalone, dependency-light package (runtime deps: @modelcontextprotocol/sdk, zod, reflect-metadata; zero @rudderjs/*). Implements Phase 1 of gemstack #18. - Strip Rudder framework wiring from the core: drop provider.ts (McpProvider), the make-* CLI scaffolders, doctor.ts, and the inspector (these stay with the Rudder binding in Phase 2). - DI resolver seam (Decision 2): replace the globalThis container read with an instance-scoped McpResolver passed at construction. @Handle resolves at call time off the server; built-in createResolver().register for the no-container case; a requested dependency with no resolver, or one that yields undefined, fails loudly naming the member and token. No global setter. - Framework-neutral HTTP (Decision 3): createMcpHttpHandler returns a plain node:http (req, res) handler; createWebRequestHandler returns a Web Standard (request) => Response engine for Hono/Vike/edge. - Generic OAuth2 (Decision 4): oauth2McpMiddleware takes a Connect (req,res,next) signature + a user-supplied verifyToken; no passport / @rudderjs/core coupling. - Drop @rudderjs/json-schema (Decision 5): inline Zod 4 native z.toJSONSchema with the date-time override + open-object fallback. - Fresh 0.0.0 -> 0.1.0 via changeset (Decision 7). Build + typecheck + 100 tests green standalone, including a raw node:http acceptance test serving a server with no framework present. Closes #19. --- .changeset/gemstack-mcp-initial.md | 13 + packages/mcp/README.md | 146 ++ packages/mcp/package.json | 71 + packages/mcp/src/Mcp.ts | 80 + packages/mcp/src/McpPrompt.ts | 37 + packages/mcp/src/McpResource.ts | 37 + packages/mcp/src/McpResponse.ts | 15 + packages/mcp/src/McpServer.ts | 136 ++ packages/mcp/src/McpTool.ts | 79 + packages/mcp/src/auth/oauth2.ts | 206 +++ packages/mcp/src/decorators.ts | 181 +++ packages/mcp/src/index.test.ts | 1370 +++++++++++++++++ packages/mcp/src/index.ts | 33 + packages/mcp/src/observers.ts | 52 + packages/mcp/src/resolver.ts | 68 + packages/mcp/src/runtime.ts | 12 + .../mcp/src/runtime/consume-tool-return.ts | 51 + packages/mcp/src/runtime/handle-deps.ts | 106 ++ packages/mcp/src/runtime/node-handler.ts | 97 ++ .../mcp/src/runtime/observers-accessor.ts | 12 + packages/mcp/src/runtime/sdk-server.ts | 215 +++ packages/mcp/src/runtime/web-handler.ts | 80 + packages/mcp/src/testing.ts | 135 ++ packages/mcp/src/types.ts | 10 + packages/mcp/src/uri-template.ts | 22 + packages/mcp/src/utils.ts | 6 + packages/mcp/src/zod-to-json-schema.ts | 41 + packages/mcp/tsconfig.build.json | 6 + packages/mcp/tsconfig.json | 5 + packages/mcp/tsconfig.test.json | 5 + pnpm-lock.yaml | 19 + 31 files changed, 3346 insertions(+) create mode 100644 .changeset/gemstack-mcp-initial.md create mode 100644 packages/mcp/README.md create mode 100644 packages/mcp/package.json create mode 100644 packages/mcp/src/Mcp.ts create mode 100644 packages/mcp/src/McpPrompt.ts create mode 100644 packages/mcp/src/McpResource.ts create mode 100644 packages/mcp/src/McpResponse.ts create mode 100644 packages/mcp/src/McpServer.ts create mode 100644 packages/mcp/src/McpTool.ts create mode 100644 packages/mcp/src/auth/oauth2.ts create mode 100644 packages/mcp/src/decorators.ts create mode 100644 packages/mcp/src/index.test.ts create mode 100644 packages/mcp/src/index.ts create mode 100644 packages/mcp/src/observers.ts create mode 100644 packages/mcp/src/resolver.ts create mode 100644 packages/mcp/src/runtime.ts create mode 100644 packages/mcp/src/runtime/consume-tool-return.ts create mode 100644 packages/mcp/src/runtime/handle-deps.ts create mode 100644 packages/mcp/src/runtime/node-handler.ts create mode 100644 packages/mcp/src/runtime/observers-accessor.ts create mode 100644 packages/mcp/src/runtime/sdk-server.ts create mode 100644 packages/mcp/src/runtime/web-handler.ts create mode 100644 packages/mcp/src/testing.ts create mode 100644 packages/mcp/src/types.ts create mode 100644 packages/mcp/src/uri-template.ts create mode 100644 packages/mcp/src/utils.ts create mode 100644 packages/mcp/src/zod-to-json-schema.ts create mode 100644 packages/mcp/tsconfig.build.json create mode 100644 packages/mcp/tsconfig.json create mode 100644 packages/mcp/tsconfig.test.json diff --git a/.changeset/gemstack-mcp-initial.md b/.changeset/gemstack-mcp-initial.md new file mode 100644 index 0000000..fb278f3 --- /dev/null +++ b/.changeset/gemstack-mcp-initial.md @@ -0,0 +1,13 @@ +--- +"@gemstack/mcp": minor +--- + +Initial release. An agent-agnostic framework for authoring MCP servers — the graduation of the mature `@rudderjs/mcp` into a standalone, dependency-light package (runtime deps: `@modelcontextprotocol/sdk`, `zod`, `reflect-metadata`; zero `@rudderjs/*`). + +- `McpServer` / `McpTool` / `McpResource` / `McpPrompt` / `McpResponse` / `Mcp` plus the metadata + MCP-spec annotation decorators. +- **Instance-scoped DI seam**: `@Handle(...)` resolves dependencies through a resolver passed at construction (`new Server({ resolver })`), never off `globalThis`. Built-in `createResolver().register(token, instance)` for the no-container case; a `@Handle` dependency with no resolver (or a resolver yielding `undefined`) fails loudly, naming the member and token — never injects `undefined`. +- **Framework-neutral HTTP**: `createMcpHttpHandler(server)` returns a plain `node:http` `(req, res)` handler (also fits Express/Connect); `createWebRequestHandler(server)` returns a Web Standard `(request) => Promise` for Hono/Vike/edge runtimes. `startStdio` for CLI/stdio. +- **Generic OAuth 2.1**: `oauth2McpMiddleware` takes a user-supplied `verifyToken` (the binding wires its own auth) and emits RFC 9728 protected-resource metadata; no auth provider baked in. +- `McpTestClient` for in-process testing, and an observer registry for tool/resource/prompt tracing. + +Schema conversion uses Zod 4's native `z.toJSONSchema` directly. The Rudder-specific provider, CLI scaffolders, and doctor check stay in `@rudderjs/mcp`, which becomes a thin binding over this core (Phase 2). diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 0000000..18db78a --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,146 @@ +# @gemstack/mcp + +An agent-agnostic framework for **authoring Model Context Protocol (MCP) servers** in TypeScript: declare tools, resources, and prompts as classes; serve them over a framework-neutral HTTP handler or stdio; protect them with OAuth 2.1. No framework required. + +This is the graduation of the mature `@rudderjs/mcp` server framework into a standalone, dependency-light package. Its only runtime dependencies are `@modelcontextprotocol/sdk`, `zod`, and `reflect-metadata`. + +## Which MCP package do I want? + +There are two MCP packages in GemStack, on opposite axes — don't conflate them: + +| Package | Axis | Use it to… | +|---|---|---| +| **`@gemstack/mcp`** (this) | **server authoring** | Build an MCP *server*: hand-author tools/resources/prompts and serve them. Agent-agnostic — depends on no AI runtime. | +| `@gemstack/ai-mcp` | agent ↔ MCP bridge | Consume remote MCP tools as `@gemstack/ai-sdk` Agent tools, or expose a single Agent as an MCP server. Depends on `@gemstack/ai-sdk`. | + +## Install + +```bash +npm install @gemstack/mcp +``` + +`reflect-metadata` must be imported once at your entry point (for the decorators): + +```ts +import 'reflect-metadata' +``` + +## Quick start + +Define a tool and a server: + +```ts +import { McpServer, McpTool, McpResponse, Name, Description } from '@gemstack/mcp' +import { z } from 'zod' + +@Description('Echo a message back to the caller') +class EchoTool extends McpTool { + schema() { return z.object({ message: z.string() }) } + async handle(input: { message: string }) { + return McpResponse.text(input.message) + } +} + +@Name('demo') +class DemoServer extends McpServer { + protected tools = [EchoTool] +} +``` + +Serve it over raw `node:http` — no framework involved: + +```ts +import { createServer } from 'node:http' +import { createMcpHttpHandler } from '@gemstack/mcp' + +const handler = createMcpHttpHandler(new DemoServer()) +createServer((req, res) => { void handler(req, res) }).listen(3000) +``` + +`createMcpHttpHandler` returns a plain `(req, res)` handler, so it also mounts on Express/Connect. For Hono, Vike, or any Fetch-style runtime, use `createWebRequestHandler` from `@gemstack/mcp/runtime` (`(request: Request) => Promise`). For a CLI/stdio server, use `startStdio` from the same subpath. + +### Resources and prompts + +```ts +import { McpResource, McpPrompt } from '@gemstack/mcp' + +class VersionResource extends McpResource { + uri() { return 'info://version' } + async handle() { return '1.0.0' } +} + +class GreetPrompt extends McpPrompt { + arguments() { return z.object({ name: z.string() }) } + async handle(args: { name: string }) { + return [{ role: 'user' as const, content: `Hello ${args.name}` }] + } +} +``` + +URI templates (`weather://location/{city}`) are matched and their params passed to `handle(params)`. + +## Dependency injection — `@Handle` + +A tool/resource/prompt method can ask for dependencies beyond its first argument. Mark it with `@Handle(...)` and construct the server with a **resolver**: + +```ts +import { McpServer, McpTool, McpResponse, Handle, createResolver } from '@gemstack/mcp' + +class Logger { info(msg: string) { console.log(msg) } } + +class LogTool extends McpTool { + schema() { return z.object({ message: z.string() }) } + @Handle(Logger) + async handle(input: { message: string }, log: Logger) { + log.info(input.message) + return McpResponse.text('logged') + } +} + +class LogServer extends McpServer { protected tools = [LogTool] } + +const resolver = createResolver().register(Logger, new Logger()) +const server = new LogServer({ resolver }) +``` + +The resolver is **instance-scoped** — passed at construction, never read off a global. Wire it to any container (Awilix, tsyringe, InversifyJS, a framework binding) with a one-function adapter implementing `McpResolver = { resolve(token): unknown }`. If a `@Handle` method requests a dependency and no resolver is provided — or the resolver yields `undefined` — the call fails loudly, naming the member and token; it never injects `undefined`. + +## OAuth 2.1 + +Protect a web endpoint with bearer tokens. The core is auth-agnostic: you supply a `verifyToken` that validates the JWT (signature, expiry, revocation) and returns its claims, or `null`/throws when invalid. + +```ts +import { oauth2McpMiddleware } from '@gemstack/mcp' + +const mw = oauth2McpMiddleware('/mcp', { + scopes: ['mcp.read'], + verifyToken: async (jwt) => { + // validate however you like; return claims or null + return { sub: 'user-1', scopes: ['mcp.read'] } + }, +}) +``` + +On success the verified claims are attached to the request as `req.mcpAuth`. `registerOAuth2Metadata(...)` emits the RFC 9728 protected-resource metadata document. + +## Testing + +`McpTestClient` exercises a server's tools/resources/prompts in-process, with no transport: + +```ts +import { McpTestClient } from '@gemstack/mcp/testing' + +const client = new McpTestClient(DemoServer) +const result = await client.callTool('echo', { message: 'hi' }) + +// With DI: +const client2 = new McpTestClient(LogServer, { resolver }) +``` + +## Observers + +Subscribe to structured tool/resource/prompt events (for tracing/telemetry) via `@gemstack/mcp/observers`. + +## License + +MIT diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 0000000..b692cbb --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,71 @@ +{ + "name": "@gemstack/mcp", + "version": "0.0.0", + "description": "Agent-agnostic framework for authoring Model Context Protocol (MCP) servers: tools, resources, prompts, decorators, OAuth 2.1, a framework-neutral HTTP handler, and a test client. The graduation of @rudderjs/mcp.", + "keywords": [ + "mcp", + "model-context-protocol", + "server", + "tools", + "resources", + "prompts", + "ai", + "agents", + "oauth", + "gemstack" + ], + "license": "MIT", + "homepage": "https://github.com/gemstack-land/gemstack/tree/main/packages/mcp#readme", + "bugs": { + "url": "https://github.com/gemstack-land/gemstack/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/gemstack-land/gemstack", + "directory": "packages/mcp" + }, + "type": "module", + "engines": { + "node": ">=22.12.0" + }, + "files": [ + "dist" + ], + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./observers": { + "import": "./dist/observers.js", + "types": "./dist/observers.d.ts" + }, + "./runtime": { + "import": "./dist/runtime.js", + "types": "./dist/runtime.d.ts" + }, + "./testing": { + "import": "./dist/testing.js", + "types": "./dist/testing.d.ts" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "dev": "tsc -p tsconfig.build.json --watch", + "typecheck": "tsc --noEmit", + "test": "tsc -p tsconfig.test.json && cd dist-test && node --test", + "clean": "rm -rf dist dist-test" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "reflect-metadata": "^0.2.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.4.0" + }, + "author": "Suleiman Shahbari" +} diff --git a/packages/mcp/src/Mcp.ts b/packages/mcp/src/Mcp.ts new file mode 100644 index 0000000..7fc901c --- /dev/null +++ b/packages/mcp/src/Mcp.ts @@ -0,0 +1,80 @@ +import type { McpServer, McpServerOptions } from './McpServer.js' +import type { OAuth2McpOptions } from './auth/oauth2.js' +import type { McpResolver } from './resolver.js' + +type ServerClass = new (options?: McpServerOptions) => McpServer + +export interface McpWebEntry { + server: ServerClass + middleware: unknown[] + /** Set when `.oauth2()` was chained on the builder. */ + oauth2?: OAuth2McpOptions + /** Set when `.resolver()` was chained — the DI resolver to construct the server with. */ + resolver?: McpResolver +} + +export interface McpWebBuilder { + /** Add middleware to this web MCP endpoint. */ + middleware(mw: unknown[]): McpWebBuilder + /** + * Protect this endpoint with OAuth 2.1 bearer tokens. Registers an RFC 9728 + * Protected Resource Metadata endpoint alongside it. Supply a `verifyToken` + * (see {@link OAuth2McpOptions}) so the endpoint can validate bearer tokens. + */ + oauth2(options?: OAuth2McpOptions): McpWebBuilder + /** Construct this server with a DI resolver (for `@Handle()` dependencies). */ + resolver(resolver: McpResolver): McpWebBuilder +} + +/** + * Shared singleton store routed through `globalThis` so the registry survives + * the case where `@gemstack/mcp` is loaded twice — typical in a bundled server + * where the host inlines `@gemstack/mcp` but `Mcp.web()` / `Mcp.local()` calls + * run from a separate `node_modules` copy. Without a shared store, servers + * registered from one copy would be invisible to the mounter reading the other + * — every `/mcp/*` request would 404. + */ +interface McpServersStore { + web: Map + local: Map +} + +const _g = globalThis as Record +if (!_g['__gemstack_mcp_servers__']) { + _g['__gemstack_mcp_servers__'] = { + web: new Map(), + local: new Map(), + } satisfies McpServersStore +} +const _store = _g['__gemstack_mcp_servers__'] as McpServersStore + +export class Mcp { + /** Register an MCP server on an HTTP endpoint (Streamable HTTP transport) */ + static web(path: string, server: ServerClass, middleware: unknown[] = []): McpWebBuilder { + const entry: McpWebEntry = { server, middleware } + _store.web.set(path, entry) + const builder: McpWebBuilder = { + middleware(mw: unknown[]) { + entry.middleware.push(...mw) + return builder + }, + oauth2(options: OAuth2McpOptions = {}) { + entry.oauth2 = options + return builder + }, + resolver(resolver: McpResolver) { + entry.resolver = resolver + return builder + }, + } + return builder + } + + /** Register an MCP server as a local CLI command (stdio transport) */ + static local(name: string, server: ServerClass): void { + _store.local.set(name, server) + } + + static getWebServers(): Map { return _store.web } + static getLocalServers(): Map { return _store.local } +} diff --git a/packages/mcp/src/McpPrompt.ts b/packages/mcp/src/McpPrompt.ts new file mode 100644 index 0000000..b6fe984 --- /dev/null +++ b/packages/mcp/src/McpPrompt.ts @@ -0,0 +1,37 @@ +import { toKebabCase } from './utils.js' +import { getDescription } from './decorators.js' +import type { ZodLikeObject } from './types.js' + +export interface McpPromptMessage { + role: 'user' | 'assistant' + content: string +} + +export abstract class McpPrompt { + /** Prompt name */ + name(): string { + return toKebabCase(this.constructor.name.replace(/Prompt$/, '')) + } + + /** Description */ + description(): string { + return getDescription(this.constructor) ?? '' + } + + /** Arguments schema — a Zod object (v3 or v4). */ + arguments?(): ZodLikeObject + + /** + * Generate prompt messages. Extra parameters beyond `args` are resolved + * from the DI container when the method is decorated with `@Handle()`. + */ + abstract handle(args: Record, ...deps: unknown[]): Promise + + /** + * Optional hook controlling whether this prompt is exposed to clients. + * + * Returning `false` hides the prompt from `prompts/list` AND causes + * `prompts/get` to throw "Unknown prompt" — preventing bypass. + */ + shouldRegister?(): boolean | Promise +} diff --git a/packages/mcp/src/McpResource.ts b/packages/mcp/src/McpResource.ts new file mode 100644 index 0000000..866018a --- /dev/null +++ b/packages/mcp/src/McpResource.ts @@ -0,0 +1,37 @@ +import { getDescription } from './decorators.js' + +export abstract class McpResource { + /** Resource URI pattern — can contain `{param}` placeholders for templates */ + abstract uri(): string + + /** MIME type */ + mimeType(): string { + return 'text/plain' + } + + /** Resource description */ + description(): string { + return getDescription(this.constructor) ?? '' + } + + /** Whether this resource uses URI templates (has `{param}` placeholders) */ + isTemplate(): boolean { + return this.uri().includes('{') + } + + /** + * Handle resource read. Receives extracted params if this is a template + * resource. Extra parameters beyond `params` are resolved from the DI + * container when the method is decorated with `@Handle()`. + */ + abstract handle(params?: Record, ...deps: unknown[]): Promise + + /** + * Optional hook controlling whether this resource is exposed to clients. + * + * Returning `false` hides the resource from `resources/list` and + * `resources/templates/list`, AND causes `resources/read` to throw + * "Unknown resource" — preventing bypass via direct URI. + */ + shouldRegister?(): boolean | Promise +} diff --git a/packages/mcp/src/McpResponse.ts b/packages/mcp/src/McpResponse.ts new file mode 100644 index 0000000..1c06c72 --- /dev/null +++ b/packages/mcp/src/McpResponse.ts @@ -0,0 +1,15 @@ +import type { McpToolResult } from './McpTool.js' + +export class McpResponse { + static text(content: string): McpToolResult { + return { content: [{ type: 'text', text: content }] } + } + + static json(data: unknown): McpToolResult { + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] } + } + + static error(message: string): McpToolResult { + return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true } + } +} diff --git a/packages/mcp/src/McpServer.ts b/packages/mcp/src/McpServer.ts new file mode 100644 index 0000000..3ed687a --- /dev/null +++ b/packages/mcp/src/McpServer.ts @@ -0,0 +1,136 @@ +import type { McpTool } from './McpTool.js' +import type { McpResource } from './McpResource.js' +import type { McpPrompt } from './McpPrompt.js' +import { getServerMetadata } from './decorators.js' +import type { McpResolver } from './resolver.js' + +export interface McpServerMetadata { + name?: string + version?: string + instructions?: string +} + +export interface McpServerOptions { + /** + * DI resolver used to construct tool / resource / prompt classes and to + * inject `@Handle()` method dependencies. Omit it for plain (no-DI) servers; + * supply one (e.g. from {@link createResolver} or a framework binding) to + * auto-wire dependencies. See {@link McpResolver}. + */ + resolver?: McpResolver +} + +/** + * Server-initiated notification target. The runtime attaches one of these per + * SDK session it spins up so McpServer can fan out notifications to all + * connected clients. + */ +export interface McpNotificationTarget { + notification(notification: { method: string; params?: Record }): Promise | void +} + +export abstract class McpServer { + /** Tool classes to register */ + protected tools: (new () => McpTool)[] = [] + + /** Resource classes to register */ + protected resources: (new () => McpResource)[] = [] + + /** Prompt classes to register */ + protected prompts: (new () => McpPrompt)[] = [] + + // Lazy-initialised in attachSdk so subclasses don't need to call super(). + // Module-private would be cleaner but the runtime in another file needs to + // attach/detach without breaking encapsulation, so we keep it on the instance. + private _attached?: Set + + // DI resolver supplied at construction. The runtime reads it (via _resolver()) + // and threads it to every @Handle call site + primitive construction. + #resolver: McpResolver | undefined + + constructor(options: McpServerOptions = {}) { + this.#resolver = options.resolver + } + + /** @internal — runtime reads the DI resolver supplied at construction. */ + _resolver(): McpResolver | undefined { + return this.#resolver + } + + /** Server metadata — override or use decorators */ + metadata(): Required> & Pick { + const meta = getServerMetadata(this.constructor) + return { + name: meta.name ?? this.constructor.name, + version: meta.version ?? '1.0.0', + ...(meta.instructions != null ? { instructions: meta.instructions } : {}), + } + } + + /** @internal — called by the runtime when a new SDK session is connected. Returns a detach function. */ + attachSdk(target: McpNotificationTarget): () => void { + if (!this._attached) this._attached = new Set() + this._attached.add(target) + return () => { this._attached?.delete(target) } + } + + /** @internal — runtime/inspector/testing only. Exposes the protected tool classes array. */ + _tools(): (new () => McpTool)[] { + return this.tools + } + + /** @internal — runtime/inspector/testing only. Exposes the protected resource classes array. */ + _resources(): (new () => McpResource)[] { + return this.resources + } + + /** @internal — runtime/inspector/testing only. Exposes the protected prompt classes array. */ + _prompts(): (new () => McpPrompt)[] { + return this.prompts + } + + /** @internal — exposed for tests; counts active notification targets. */ + attachedCount(): number { + return this._attached?.size ?? 0 + } + + /** + * Push a notification to every attached SDK session. Errors from a single + * target (e.g. a closed transport) are swallowed so one dead session can't + * block the others. + * + * Most callers should use the higher-level helpers (`notifyResourceUpdated`, + * `notifyToolListChanged`, etc.) — this is the escape hatch. + */ + async notify(method: string, params?: Record): Promise { + if (!this._attached || this._attached.size === 0) return + for (const target of this._attached) { + try { + await target.notification(params !== undefined ? { method, params } : { method }) + } catch { + // Dead transport. Drop silently — the runtime's session-close handler + // will detach soon. Logging here would spam during normal disconnects. + } + } + } + + /** Notify all connected clients that a specific resource changed. */ + async notifyResourceUpdated(uri: string): Promise { + await this.notify('notifications/resources/updated', { uri }) + } + + /** Notify all connected clients that the resource list changed (added/removed). */ + async notifyResourceListChanged(): Promise { + await this.notify('notifications/resources/list_changed') + } + + /** Notify all connected clients that the tool list changed (added/removed). */ + async notifyToolListChanged(): Promise { + await this.notify('notifications/tools/list_changed') + } + + /** Notify all connected clients that the prompt list changed (added/removed). */ + async notifyPromptListChanged(): Promise { + await this.notify('notifications/prompts/list_changed') + } +} diff --git a/packages/mcp/src/McpTool.ts b/packages/mcp/src/McpTool.ts new file mode 100644 index 0000000..0f85993 --- /dev/null +++ b/packages/mcp/src/McpTool.ts @@ -0,0 +1,79 @@ +import { toKebabCase } from './utils.js' +import { getDescription } from './decorators.js' +import type { ZodLikeObject } from './types.js' + +export interface McpToolResult { + content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> + isError?: boolean +} + +/** + * Progress update yielded by a streaming tool. Forwarded as a + * `notifications/progress` message when the calling client supplied a + * `progressToken` in the request `_meta`. Streaming tools that run without a + * progressToken still execute; the runtime drops the yields silently. + */ +export interface McpToolProgress { + /** Current progress value — typically 0..total or unbounded. */ + progress: number + /** Optional total — if set, clients can render a progress bar. */ + total?: number + /** Optional human-readable status message. */ + message?: string +} + +/** Return shape for a tool's `handle()` — a plain Promise *or* an async generator that yields progress updates. */ +export type McpToolReturn = + | Promise + | AsyncGenerator + +export abstract class McpTool { + /** Tool name — derived from class name if not overridden. ClassName -> kebab-case minus "Tool" suffix */ + name(): string { + return toKebabCase(this.constructor.name.replace(/Tool$/, '')) + } + + /** Tool description — override or use @Description decorator */ + description(): string { + return getDescription(this.constructor) ?? '' + } + + /** Input schema — a Zod object (v3 or v4). */ + abstract schema(): ZodLikeObject + + /** Optional output schema — advertises the structure of the tool's response. */ + outputSchema?(): ZodLikeObject + + /** + * Handle the tool call. + * + * Two shapes are accepted: + * 1. `async handle(input)` returning the final result. + * 2. `async *handle(input)` yielding `McpToolProgress` updates and returning + * the final result. Yields are forwarded to the client as + * `notifications/progress` messages when the caller supplied a + * `progressToken`. Mirror of @gemstack/ai-sdk's streaming-tool pattern — + * don't take a "send" callback parameter. + * + * Extra parameters beyond `input` are resolved from the DI container when + * the method is decorated with `@Handle(Token1, …)`. Example: + * + * ```ts + * @Handle(Logger) + * async handle(input, logger: Logger) { ... } + * ``` + */ + abstract handle(input: Record, ...deps: unknown[]): McpToolReturn + + /** + * Optional hook controlling whether this tool is exposed to clients. + * + * Returning `false` hides the tool from `tools/list` AND causes `tools/call` + * to return an "unknown tool" error — preventing bypass via direct call. + * + * Use for static gating (env flags, feature toggles, build mode). The hook + * runs with no arguments today; per-request gating (auth-scoped tools) is + * tracked as future work. + */ + shouldRegister?(): boolean | Promise +} diff --git a/packages/mcp/src/auth/oauth2.ts b/packages/mcp/src/auth/oauth2.ts new file mode 100644 index 0000000..b9d2d42 --- /dev/null +++ b/packages/mcp/src/auth/oauth2.ts @@ -0,0 +1,206 @@ +/** + * OAuth 2.1 bearer-token protection for an MCP web endpoint, framework-agnostic. + * + * The core does NOT know how to verify a token — that is the binding's / app's + * job. Supply a {@link VerifyToken} via {@link OAuth2McpOptions.verifyToken}: it + * validates the JWT (signature, expiry, revocation — whatever your authorization + * server requires) and returns the token's claims, or `null` / throws when the + * token is invalid. The Rudder binding wires `@rudderjs/passport` here; a + * non-Rudder app supplies its own verifier. + * + * On failure the middleware adds an RFC 9728 `WWW-Authenticate` header pointing + * clients at the protected-resource metadata document (see + * {@link registerOAuth2Metadata}). + */ + +/** Claims returned by a successful {@link VerifyToken}. Extra claims pass through. */ +export interface VerifiedToken { + /** Subject (user id) claim, if present. */ + sub?: string + /** Granted scopes. The wildcard `'*'` grants all scopes. */ + scopes?: string[] + [claim: string]: unknown +} + +/** + * Verifies a bearer token and returns its claims, or `null` / throws when the + * token is invalid. A thrown `Error`'s message is surfaced in the challenge + * (e.g. `throw new Error('Token has been revoked.')`); `null` yields a generic + * "Invalid or expired token." response. + */ +export type VerifyToken = (jwt: string) => Promise | VerifiedToken | null + +export interface OAuth2McpOptions { + /** Scopes required on the bearer token. Missing scopes → 403 `insufficient_scope`. */ + scopes?: string[] + /** Canonical URL of this protected MCP resource. Defaults to the current request URL. */ + resource?: string + /** Authorization server URL(s) advertised via RFC 9728. Defaults to the app origin. */ + authorizationServers?: string[] + /** Scopes advertised in the protected-resource metadata document. */ + scopesSupported?: string[] + /** Token verifier. Required for the endpoint to accept any token (see {@link VerifyToken}). */ + verifyToken?: VerifyToken +} + +/** Minimal Connect-style request shape the middleware reads. */ +export interface OAuth2Request { + headers: Record + protocol?: string + host?: string + hostname?: string + [key: string]: unknown +} + +/** Minimal Connect-style response shape the middleware writes to. */ +export interface OAuth2Response { + status(code: number): { json(data: unknown): void } + header?(key: string, value: string): void +} + +export type OAuth2Next = () => unknown | Promise +export type OAuth2Middleware = (req: OAuth2Request, res: OAuth2Response, next: OAuth2Next) => Promise + +/** Auth context attached to the request after a successful verification. */ +export interface McpAuthContext { + sub?: string + scopes?: string[] + claims: VerifiedToken +} + +/** + * Protect an MCP web endpoint with OAuth 2.1 Bearer tokens. On success the + * verified claims are attached to the request as `req.mcpAuth` + * ({@link McpAuthContext}) and `next()` is called. + */ +export function oauth2McpMiddleware(mcpPath: string, options: OAuth2McpOptions = {}): OAuth2Middleware { + const metadataPath = `/.well-known/oauth-protected-resource${mcpPath}` + const requiredScopes = options.scopes ?? [] + const verifyToken = options.verifyToken + + return async function OAuth2McpMiddleware(req, res, next) { + const authHeader = getHeader(req, 'authorization') + const metadataUrl = absoluteUrl(req, metadataPath) + + if (!authHeader?.startsWith('Bearer ')) { + challenge(res, metadataUrl, 'invalid_token', 'Bearer token required.') + return + } + + if (!verifyToken) { + challenge(res, metadataUrl, 'invalid_token', 'OAuth provider not configured.') + return + } + + const jwt = authHeader.slice(7).trim() + let claims: VerifiedToken | null + try { + claims = await verifyToken(jwt) + } catch (err) { + const msg = err instanceof Error && err.message ? err.message : 'Invalid or expired token.' + challenge(res, metadataUrl, 'invalid_token', msg) + return + } + if (!claims) { + challenge(res, metadataUrl, 'invalid_token', 'Invalid or expired token.') + return + } + + if (requiredScopes.length > 0) { + const tokenScopes = Array.isArray(claims.scopes) ? claims.scopes : [] + const granted = tokenScopes.includes('*') + if (!granted) { + const missing = requiredScopes.filter((s) => !tokenScopes.includes(s)) + if (missing.length > 0) { + challenge(res, metadataUrl, 'insufficient_scope', + `Missing scope(s): ${missing.join(', ')}`, + requiredScopes.join(' ')) + return + } + } + } + + const auth: McpAuthContext = { + ...(claims.sub !== undefined ? { sub: claims.sub } : {}), + ...(claims.scopes !== undefined ? { scopes: claims.scopes } : {}), + claims, + } + ;(req as Record)['mcpAuth'] = auth + + await next() + } +} + +/** Register the RFC 9728 Protected Resource Metadata endpoint for an MCP path. */ +export function registerOAuth2Metadata( + router: { + get(path: string, handler: (req: unknown, res: unknown) => unknown, middleware?: unknown[]): unknown + }, + mcpPath: string, + options: OAuth2McpOptions, +): void { + const metadataPath = `/.well-known/oauth-protected-resource${mcpPath}` + + router.get(metadataPath, (req: unknown, res: unknown) => { + const origin = absoluteUrl(req as OAuth2Request, '') + const resource = options.resource ?? `${origin}${mcpPath}` + const authServers = options.authorizationServers && options.authorizationServers.length > 0 + ? options.authorizationServers + : [origin] + + const body: Record = { + resource, + authorization_servers: authServers, + bearer_methods_supported: ['header'], + } + if (options.scopesSupported && options.scopesSupported.length > 0) { + body['scopes_supported'] = options.scopesSupported + } + + ;(res as { json: (data: unknown) => void }).json(body) + }) +} + +// ─── helpers ────────────────────────────────────────────── + +function absoluteUrl(req: OAuth2Request, path: string): string { + const host = getHeader(req, 'x-forwarded-host') + ?? req.host + ?? getHeader(req, 'host') + ?? req.hostname + ?? 'localhost' + const proto = getHeader(req, 'x-forwarded-proto') + ?? req.protocol + ?? 'http' + return `${proto}://${host}${path}` +} + +function getHeader(req: OAuth2Request, name: string): string | undefined { + const v = req.headers[name] + if (Array.isArray(v)) return v[0] + return v +} + +function challenge( + res: OAuth2Response, + metadataUrl: string, + error: 'invalid_token' | 'insufficient_scope', + description: string, + scope?: string, +): void { + const parts: string[] = [`resource_metadata="${metadataUrl}"`, `error="${error}"`] + // Escape backslashes BEFORE quotes — otherwise a description ending in `\` + // would let the trailing backslash escape the closing quote and break out of + // the RFC 7235 quoted-string. Order matters: `\` → `\\`, then `"` → `\"`. + if (description) { + const escaped = description.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + parts.push(`error_description="${escaped}"`) + } + if (scope) parts.push(`scope="${scope}"`) + res.header?.('WWW-Authenticate', `Bearer ${parts.join(', ')}`) + + const statusCode = error === 'insufficient_scope' ? 403 : 401 + const body: Record = { error, error_description: description } + if (scope) body['scope'] = scope + res.status(statusCode).json(body) +} diff --git a/packages/mcp/src/decorators.ts b/packages/mcp/src/decorators.ts new file mode 100644 index 0000000..ecea7c1 --- /dev/null +++ b/packages/mcp/src/decorators.ts @@ -0,0 +1,181 @@ +import 'reflect-metadata' + +// Metadata keys MUST use `Symbol.for(...)` rather than `Symbol(...)` so that +// they have the same identity across every module instance loaded in the +// process. A bundled app's `entry.mjs` typically inlines `decorators.ts` (the +// `@Handle` / `@Description` decorators run at module-load time when the +// user's tool class is defined), while the MCP runtime that later reads the +// metadata is resolved through `await import('@gemstack/mcp/...')` — +// node_modules → a second copy of `decorators.ts` with a separate `Symbol(...)` +// identity. Write under one symbol, read from the other, get `undefined` → +// every `@Handle(...)` injection silently fell back to no DI. `Symbol.for(...)` +// shares one identity in the process-global symbol registry regardless of how +// many bundled copies exist. Same class of bug fixed in router (#507) and the +// static-state-singleton audit (#498/#500–#506). +const NAME_KEY = Symbol.for('gemstack.mcp.name') +const VERSION_KEY = Symbol.for('gemstack.mcp.version') +const INSTRUCTIONS_KEY = Symbol.for('gemstack.mcp.instructions') +const DESCRIPTION_KEY = Symbol.for('gemstack.mcp.description') +const INJECT_KEY = Symbol.for('gemstack.mcp.inject') + +// Tool annotations (MCP spec hints — clients use these to decide auto-approval, batching, sandboxing) +const READ_ONLY_KEY = Symbol.for('gemstack.mcp.readOnly') +const DESTRUCTIVE_KEY = Symbol.for('gemstack.mcp.destructive') +const IDEMPOTENT_KEY = Symbol.for('gemstack.mcp.idempotent') +const OPEN_WORLD_KEY = Symbol.for('gemstack.mcp.openWorld') + +// Resource annotations +const AUDIENCE_KEY = Symbol.for('gemstack.mcp.audience') +const PRIORITY_KEY = Symbol.for('gemstack.mcp.priority') +const LAST_MODIFIED_KEY = Symbol.for('gemstack.mcp.lastModified') + +export function Name(name: string): ClassDecorator { + return (target) => { Reflect.defineMetadata(NAME_KEY, name, target) } +} + +export function Version(version: string): ClassDecorator { + return (target) => { Reflect.defineMetadata(VERSION_KEY, version, target) } +} + +export function Instructions(instructions: string): ClassDecorator { + return (target) => { Reflect.defineMetadata(INSTRUCTIONS_KEY, instructions, target) } +} + +export function Description(description: string): ClassDecorator { + return (target) => { Reflect.defineMetadata(DESCRIPTION_KEY, description, target) } +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export function getServerMetadata(target: Function): { name: string | undefined; version: string | undefined; instructions: string | undefined } { + return { + name: Reflect.getMetadata(NAME_KEY, target) as string | undefined, + version: Reflect.getMetadata(VERSION_KEY, target) as string | undefined, + instructions: Reflect.getMetadata(INSTRUCTIONS_KEY, target) as string | undefined, + } +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export function getDescription(target: Function): string | undefined { + return Reflect.getMetadata(DESCRIPTION_KEY, target) as string | undefined +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type InjectToken = (new (...args: any[]) => unknown) | string | symbol + +/** + * Marks a method (typically `handle`) as wanting DI-resolved parameters beyond + * the first one. Pass the tokens explicitly — one per extra parameter: + * + * ```ts + * @Handle(GreetingService, Logger) + * async handle(input, greeter: GreetingService, logger: Logger) { ... } + * ``` + * + * If called with no arguments, the runtime falls back to `design:paramtypes` + * metadata (which requires `emitDecoratorMetadata: true` AND a build tool that + * honours it — plain `tsc` does, but esbuild/Vite typically do not). + */ +export function Handle(...tokens: InjectToken[]): MethodDecorator { + return (target, propertyKey) => { + Reflect.defineMetadata(INJECT_KEY, tokens, target, propertyKey) + } +} + +export function getInjectTokens(target: object, propertyKey: string | symbol): InjectToken[] | undefined { + return Reflect.getMetadata(INJECT_KEY, target, propertyKey) as InjectToken[] | undefined +} + +export type { InjectToken } + +// ─── Tool annotations (MCP spec) ───────────────────────── +// +// Per the MCP spec, tools may carry behavior hints that clients (Claude +// Desktop, Cursor, etc.) use to decide whether to auto-approve a call, batch +// it, or sandbox it. The hints are advisory — clients still apply their own +// policy. Both `true` and `false` are meaningful (vs. omitted), so each +// decorator accepts an explicit value with a default of `true`. +// +// Spec reference: https://modelcontextprotocol.io/specification + +/** Tool does not modify state. */ +export function IsReadOnly(value = true): ClassDecorator { + return (target) => { Reflect.defineMetadata(READ_ONLY_KEY, value, target) } +} + +/** Tool may perform destructive updates. */ +export function IsDestructive(value = true): ClassDecorator { + return (target) => { Reflect.defineMetadata(DESTRUCTIVE_KEY, value, target) } +} + +/** Repeated calls with the same input have no additional effect. */ +export function IsIdempotent(value = true): ClassDecorator { + return (target) => { Reflect.defineMetadata(IDEMPOTENT_KEY, value, target) } +} + +/** Tool interacts with external systems (network, filesystem outside the server). */ +export function IsOpenWorld(value = true): ClassDecorator { + return (target) => { Reflect.defineMetadata(OPEN_WORLD_KEY, value, target) } +} + +export interface ToolAnnotations { + readOnlyHint?: boolean + destructiveHint?: boolean + idempotentHint?: boolean + openWorldHint?: boolean +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export function getToolAnnotations(target: Function): ToolAnnotations | undefined { + const a: ToolAnnotations = {} + const ro = Reflect.getMetadata(READ_ONLY_KEY, target) as boolean | undefined + const de = Reflect.getMetadata(DESTRUCTIVE_KEY, target) as boolean | undefined + const id = Reflect.getMetadata(IDEMPOTENT_KEY, target) as boolean | undefined + const ow = Reflect.getMetadata(OPEN_WORLD_KEY, target) as boolean | undefined + if (ro !== undefined) a.readOnlyHint = ro + if (de !== undefined) a.destructiveHint = de + if (id !== undefined) a.idempotentHint = id + if (ow !== undefined) a.openWorldHint = ow + return Object.keys(a).length > 0 ? a : undefined +} + +// ─── Resource annotations (MCP spec) ───────────────────── + +export type AudienceRole = 'user' | 'assistant' + +/** Intended audience(s). One or both of `'user'`, `'assistant'`. */ +export function Audience(...roles: AudienceRole[]): ClassDecorator { + if (roles.length === 0) throw new Error('@Audience requires at least one role') + return (target) => { Reflect.defineMetadata(AUDIENCE_KEY, roles, target) } +} + +/** Importance score, 0..1. */ +export function Priority(value: number): ClassDecorator { + if (value < 0 || value > 1 || Number.isNaN(value)) { + throw new Error(`@Priority must be between 0 and 1, got ${value}`) + } + return (target) => { Reflect.defineMetadata(PRIORITY_KEY, value, target) } +} + +/** Last-modified timestamp. ISO 8601 string or Date. */ +export function LastModified(value: string | Date): ClassDecorator { + const iso = value instanceof Date ? value.toISOString() : value + return (target) => { Reflect.defineMetadata(LAST_MODIFIED_KEY, iso, target) } +} + +export interface ResourceAnnotations { + audience?: AudienceRole[] + priority?: number + lastModified?: string +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export function getResourceAnnotations(target: Function): ResourceAnnotations | undefined { + const a: ResourceAnnotations = {} + const aud = Reflect.getMetadata(AUDIENCE_KEY, target) as AudienceRole[] | undefined + const pri = Reflect.getMetadata(PRIORITY_KEY, target) as number | undefined + const lm = Reflect.getMetadata(LAST_MODIFIED_KEY, target) as string | undefined + if (aud !== undefined) a.audience = aud + if (pri !== undefined) a.priority = pri + if (lm !== undefined) a.lastModified = lm + return Object.keys(a).length > 0 ? a : undefined +} diff --git a/packages/mcp/src/index.test.ts b/packages/mcp/src/index.test.ts new file mode 100644 index 0000000..c43aeab --- /dev/null +++ b/packages/mcp/src/index.test.ts @@ -0,0 +1,1370 @@ +import 'reflect-metadata' +import { describe, it, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { z } from 'zod' +import { + Mcp, McpServer, McpTool, McpResource, McpPrompt, McpResponse, + Name, Version, Instructions, Description, Handle, + IsReadOnly, IsDestructive, IsIdempotent, IsOpenWorld, + Audience, Priority, LastModified, + McpTestClient, createResolver, + type McpResolver, type McpToolProgress, type McpToolResult, +} from './index.js' +import type { VerifyToken } from './auth/oauth2.js' +import { consumeToolReturn } from './runtime.js' +import { toKebabCase } from './utils.js' +import { zodToJsonSchema } from './zod-to-json-schema.js' + +// ─── toKebabCase ────────────────────────────────────────── + +describe('toKebabCase', () => { + it('converts PascalCase to kebab-case', () => { + assert.equal(toKebabCase('MyToolName'), 'my-tool-name') + }) + + it('converts camelCase to kebab-case', () => { + assert.equal(toKebabCase('searchUsers'), 'search-users') + }) + + it('converts spaces and underscores', () => { + assert.equal(toKebabCase('hello_world test'), 'hello-world-test') + }) + + it('handles single word', () => { + assert.equal(toKebabCase('Hello'), 'hello') + }) +}) + +// ─── zodToJsonSchema ────────────────────────────────────── + +describe('zodToJsonSchema', () => { + it('converts string fields', () => { + const schema = z.object({ name: z.string() }) + const result = zodToJsonSchema(schema) + + assert.deepStrictEqual(result, { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }) + }) + + it('converts number fields', () => { + const schema = z.object({ count: z.number() }) + const result = zodToJsonSchema(schema) + + assert.deepStrictEqual(result.properties, { count: { type: 'number' } }) + assert.deepStrictEqual(result.required, ['count']) + }) + + it('converts boolean fields', () => { + const schema = z.object({ active: z.boolean() }) + const result = zodToJsonSchema(schema) + + assert.deepStrictEqual(result.properties, { active: { type: 'boolean' } }) + }) + + it('handles optional fields (not in required)', () => { + const schema = z.object({ name: z.string(), bio: z.string().optional() }) + const result = zodToJsonSchema(schema) + + assert.deepStrictEqual(result.required, ['name']) + assert.ok('bio' in (result.properties as Record)) + }) + + it('handles default fields (not in required)', () => { + const schema = z.object({ limit: z.number().default(10) }) + const result = zodToJsonSchema(schema) + + assert.ok(!('required' in result) || (result.required as string[]).length === 0) + }) + + it('handles enum fields', () => { + const schema = z.object({ role: z.enum(['admin', 'user']) }) + const result = zodToJsonSchema(schema) + + const prop = (result.properties as Record>)['role'] + assert.equal(prop!['type'], 'string') + assert.deepStrictEqual(prop!['enum'], ['admin', 'user']) + }) + + it('handles array fields', () => { + const schema = z.object({ tags: z.array(z.string()) }) + const result = zodToJsonSchema(schema) + + const prop = (result.properties as Record>)['tags'] + assert.equal(prop!['type'], 'array') + assert.deepStrictEqual(prop!['items'], { type: 'string' }) + }) + + it('handles description on fields', () => { + const schema = z.object({ query: z.string().describe('Search query') }) + const result = zodToJsonSchema(schema) + + const prop = (result.properties as Record>)['query'] + assert.equal(prop!['description'], 'Search query') + }) + + // ─── Expanded type coverage ───────────────────────────── + + it('handles nested object fields', () => { + const schema = z.object({ + profile: z.object({ name: z.string(), age: z.number() }), + }) + const prop = (zodToJsonSchema(schema).properties as Record>)['profile'] + assert.equal(prop!['type'], 'object') + const nested = prop!['properties'] as Record> + assert.equal(nested['name']!['type'], 'string') + assert.equal(nested['age']!['type'], 'number') + assert.deepStrictEqual(prop!['required'], ['name', 'age']) + }) + + it('handles union fields → anyOf', () => { + const schema = z.object({ id: z.union([z.string(), z.number()]) }) + const prop = (zodToJsonSchema(schema).properties as Record>)['id'] + assert.ok(Array.isArray(prop!['anyOf'])) + const types = (prop!['anyOf'] as Record[]).map((p) => p['type']) + assert.deepStrictEqual(types, ['string', 'number']) + }) + + it('handles literal fields → const (single value)', () => { + const schema = z.object({ kind: z.literal('user') }) + const prop = (zodToJsonSchema(schema).properties as Record>)['kind'] + assert.equal(prop!['const'], 'user') + }) + + it('handles nullable fields → anyOf with a null branch', () => { + const schema = z.object({ name: z.nullable(z.string()) }) + const prop = (zodToJsonSchema(schema).properties as Record>)['name'] + assert.deepStrictEqual(prop!['anyOf'], [{ type: 'string' }, { type: 'null' }]) + // Nullable is still required — JSON Schema separates required from nullability. + assert.deepStrictEqual(zodToJsonSchema(schema).required, ['name']) + }) + + it('handles date fields → string with date-time format', () => { + // `z.date()` has no native JSON Schema representation, but it serializes to an + // ISO string over the wire — so the inline converter maps it to + // `string` + `date-time` (via Zod 4's `toJSONSchema` override hook). + const schema = z.object({ created: z.date() }) + const prop = (zodToJsonSchema(schema).properties as Record>)['created'] + assert.equal(prop!['type'], 'string') + assert.equal(prop!['format'], 'date-time') + }) + + it('handles record fields → object with additionalProperties', () => { + const schema = z.object({ counts: z.record(z.string(), z.number()) }) + const prop = (zodToJsonSchema(schema).properties as Record>)['counts'] + assert.equal(prop!['type'], 'object') + const ap = prop!['additionalProperties'] as Record + assert.equal(ap['type'], 'number') + }) + + it('handles tuple fields → prefixItems', () => { + const schema = z.object({ pair: z.tuple([z.string(), z.number()]) }) + const prop = (zodToJsonSchema(schema).properties as Record>)['pair'] + assert.equal(prop!['type'], 'array') + const prefix = prop!['prefixItems'] as Record[] + assert.equal(prefix.length, 2) + assert.equal(prefix[0]!['type'], 'string') + assert.equal(prefix[1]!['type'], 'number') + }) + + it('handles a union of literals → anyOf of consts', () => { + const schema = z.object({ role: z.union([z.literal('a'), z.literal('b')]) }) + const prop = (zodToJsonSchema(schema).properties as Record>)['role'] + const consts = (prop!['anyOf'] as Record[]).map((p) => p['const']) + assert.deepStrictEqual(consts, ['a', 'b']) + }) + + it('falls back to an open object schema for a non-Standard-Schema input', () => { + // A bare `{ shape }` with no `~standard` vendor tag can't be dispatched — + // the shared converter returns null and the shim degrades to `{ type: 'object' }`. + const notZod = { shape: { name: {} } } as unknown as z.ZodObject + assert.deepStrictEqual(zodToJsonSchema(notZod), { type: 'object' }) + }) +}) + +// ─── McpResponse ────────────────────────────────────────── + +describe('McpResponse', () => { + it('text() returns text content', () => { + const result = McpResponse.text('hello') + assert.deepStrictEqual(result, { + content: [{ type: 'text', text: 'hello' }], + }) + }) + + it('json() returns formatted JSON', () => { + const result = McpResponse.json({ key: 'value' }) + assert.equal(result.content[0]!.type, 'text') + assert.ok((result.content[0] as { text: string }).text.includes('"key"')) + }) + + it('error() returns error content', () => { + const result = McpResponse.error('something broke') + assert.equal(result.isError, true) + assert.ok((result.content[0] as { text: string }).text.includes('Error:')) + }) +}) + +// ─── Decorators ─────────────────────────────────────────── + +describe('Decorators', () => { + it('@Name sets server name', () => { + @Name('my-server') + @Version('2.0.0') + class TestServer extends McpServer {} + + const server = new TestServer() + const meta = server.metadata() + assert.equal(meta.name, 'my-server') + assert.equal(meta.version, '2.0.0') + }) + + it('@Instructions sets server instructions', () => { + @Instructions('Be helpful') + class TestServer extends McpServer {} + + const server = new TestServer() + const meta = server.metadata() + assert.equal(meta.instructions, 'Be helpful') + }) + + it('defaults to class name and 1.0.0 without decorators', () => { + class PlainServer extends McpServer {} + + const server = new PlainServer() + const meta = server.metadata() + assert.equal(meta.name, 'PlainServer') + assert.equal(meta.version, '1.0.0') + }) + + it('@Description sets tool/prompt/resource description', () => { + @Description('Does something useful') + class TestTool extends McpTool { + schema() { return z.object({}) } + async handle() { return McpResponse.text('done') } + } + + const tool = new TestTool() + assert.equal(tool.description(), 'Does something useful') + }) +}) + +// ─── McpTool ────────────────────────────────────────────── + +describe('McpTool', () => { + it('derives name from class name in kebab-case, removing Tool suffix', () => { + class SearchUsersTool extends McpTool { + schema() { return z.object({ query: z.string() }) } + async handle() { return McpResponse.text('found') } + } + + const tool = new SearchUsersTool() + assert.equal(tool.name(), 'search-users') + }) + + it('description() returns empty string without @Description', () => { + class PlainTool extends McpTool { + schema() { return z.object({}) } + async handle() { return McpResponse.text('ok') } + } + + assert.equal(new PlainTool().description(), '') + }) +}) + +// ─── McpPrompt ──────────────────────────────────────────── + +describe('McpPrompt', () => { + it('derives name from class name, removing Prompt suffix', () => { + class CodeReviewPrompt extends McpPrompt { + async handle() { return [{ role: 'user' as const, content: 'Review this' }] } + } + + assert.equal(new CodeReviewPrompt().name(), 'code-review') + }) +}) + +// ─── McpResource ────────────────────────────────────────── + +describe('McpResource', () => { + it('defaults mimeType to text/plain', () => { + class TestResource extends McpResource { + uri() { return 'file:///test.txt' } + async handle() { return 'content' } + } + + assert.equal(new TestResource().mimeType(), 'text/plain') + }) +}) + +// ─── Mcp Registry ───────────────────────────────────────── + +describe('Mcp', () => { + beforeEach(() => { + // Clear registries between tests + Mcp.getWebServers().clear() + Mcp.getLocalServers().clear() + }) + + it('registers and retrieves web servers', () => { + class TestServer extends McpServer {} + Mcp.web('/mcp', TestServer) + + const servers = Mcp.getWebServers() + assert.equal(servers.size, 1) + assert.ok(servers.has('/mcp')) + }) + + it('registers and retrieves local servers', () => { + class TestServer extends McpServer {} + Mcp.local('test', TestServer) + + const servers = Mcp.getLocalServers() + assert.equal(servers.size, 1) + assert.ok(servers.has('test')) + }) + + it('web servers include middleware', () => { + class TestServer extends McpServer {} + const middleware = [() => {}] + Mcp.web('/mcp', TestServer, middleware) + + const entry = Mcp.getWebServers().get('/mcp') + assert.ok(entry) + assert.equal(entry.middleware.length, 1) + }) + + it('.oauth2() stores options on the entry', () => { + class TestServer extends McpServer {} + Mcp.web('/mcp', TestServer).oauth2({ scopes: ['mcp.read'] }) + + const entry = Mcp.getWebServers().get('/mcp') + assert.ok(entry) + assert.ok(entry.oauth2) + assert.deepStrictEqual(entry.oauth2.scopes, ['mcp.read']) + }) + + it('.oauth2() defaults to empty options when called without args', () => { + class TestServer extends McpServer {} + Mcp.web('/mcp', TestServer).oauth2() + + const entry = Mcp.getWebServers().get('/mcp') + assert.ok(entry?.oauth2) + assert.deepStrictEqual(entry.oauth2, {}) + }) +}) + +// ─── oauth2McpMiddleware ────────────────────────────────── + +describe('oauth2McpMiddleware', () => { + function mockRes() { + const calls: { status?: number; body?: unknown; headers: Record } = { headers: {} } + const res = { + status(code: number) { calls.status = code; return res }, + header(key: string, value: string) { calls.headers[key.toLowerCase()] = value; return res }, + json(data: unknown) { calls.body = data }, + raw: {}, + } + return { res, calls } + } + + function mockReq(authHeader?: string) { + return { + headers: { ...(authHeader ? { authorization: authHeader } : {}), host: 'app.test' }, + raw: {}, + } + } + + it('returns 401 with WWW-Authenticate when no bearer token', async () => { + const { oauth2McpMiddleware } = await import('./auth/oauth2.js') + const mw = oauth2McpMiddleware('/mcp/secure') + const { res, calls } = mockRes() + let nextCalled = false + await mw(mockReq() as never, res as never, async () => { nextCalled = true }) + + assert.equal(calls.status, 401) + assert.ok(calls.headers['www-authenticate']?.includes('Bearer')) + assert.ok(calls.headers['www-authenticate']?.includes('resource_metadata=')) + assert.ok(calls.headers['www-authenticate']?.includes('/.well-known/oauth-protected-resource/mcp/secure')) + assert.equal(nextCalled, false) + }) + + it('returns 401 when no token verifier is configured', async () => { + const { oauth2McpMiddleware } = await import('./auth/oauth2.js') + const mw = oauth2McpMiddleware('/mcp/secure') // no verifyToken supplied + const { res, calls } = mockRes() + await mw(mockReq('Bearer does-not-matter') as never, res as never, async () => {}) + + // No verifier wired — the endpoint can't validate the token → 401. + assert.equal(calls.status, 401) + assert.ok(calls.headers['www-authenticate']) + }) +}) + +// ─── McpTestClient ──────────────────────────────────────── + +describe('McpTestClient', () => { + class EchoTool extends McpTool { + schema() { return z.object({ message: z.string() }) } + async handle(input: Record) { + return McpResponse.text(String(input['message'])) + } + } + + class InfoResource extends McpResource { + uri() { return 'info://version' } + async handle() { return '1.0.0' } + } + + class GreetPrompt extends McpPrompt { + async handle(args: Record) { + return [{ role: 'user' as const, content: `Hello ${String(args['name'])}` }] + } + } + + class TestServer extends McpServer { + protected tools = [EchoTool] + protected resources = [InfoResource] + protected prompts = [GreetPrompt] + } + + it('lists tools', async () => { + const client = new McpTestClient(TestServer) + const tools = await client.listTools() + assert.equal(tools.length, 1) + assert.equal(tools[0]!.name, 'echo') + }) + + it('calls a tool', async () => { + const client = new McpTestClient(TestServer) + const result = await client.callTool('echo', { message: 'hi' }) + assert.equal(result.content[0]!.type, 'text') + assert.equal((result.content[0] as { text: string }).text, 'hi') + }) + + it('throws on unknown tool', async () => { + const client = new McpTestClient(TestServer) + await assert.rejects( + () => client.callTool('nonexistent'), + { message: /not found/ }, + ) + }) + + it('lists and reads resources', async () => { + const client = new McpTestClient(TestServer) + const resources = await client.listResources() + assert.equal(resources.length, 1) + assert.equal(resources[0]!.uri, 'info://version') + + const content = await client.readResource('info://version') + assert.equal(content, '1.0.0') + }) + + it('throws on unknown resource', async () => { + const client = new McpTestClient(TestServer) + await assert.rejects( + () => client.readResource('info://unknown'), + { message: /not found/ }, + ) + }) + + it('lists and gets prompts', async () => { + const client = new McpTestClient(TestServer) + const prompts = await client.listPrompts() + assert.equal(prompts.length, 1) + assert.equal(prompts[0]!.name, 'greet') + + const messages = await client.getPrompt('greet', { name: 'World' }) + assert.equal(messages.length, 1) + assert.equal(messages[0]!.content, 'Hello World') + }) + + it('assertion helpers work', () => { + const client = new McpTestClient(TestServer) + client.assertToolExists('echo') + client.assertToolCount(1) + client.assertResourceExists('info://version') + client.assertResourceCount(1) + client.assertPromptExists('greet') + client.assertPromptCount(1) + }) + + it('assertion helpers throw on mismatch', () => { + const client = new McpTestClient(TestServer) + assert.throws(() => client.assertToolExists('missing'), /not found/) + assert.throws(() => client.assertToolCount(99), /Expected 99/) + assert.throws(() => client.assertResourceExists('missing://x'), /not found/) + assert.throws(() => client.assertPromptExists('missing'), /not found/) + }) +}) + +// ─── MCP protocol annotations (M1 + M2) ────────────────── + +describe('Tool annotations', () => { + @IsReadOnly() + @IsIdempotent() + class GetUserTool extends McpTool { + schema() { return z.object({ id: z.string() }) } + async handle() { return McpResponse.text('ok') } + } + + @IsDestructive() + @IsOpenWorld() + class DeleteFileTool extends McpTool { + schema() { return z.object({ path: z.string() }) } + async handle() { return McpResponse.text('deleted') } + } + + class PlainTool extends McpTool { + schema() { return z.object({}) } + async handle() { return McpResponse.text('plain') } + } + + class TestServer extends McpServer { + protected tools = [GetUserTool, DeleteFileTool, PlainTool] + } + + it('surfaces readOnlyHint + idempotentHint on a read tool', async () => { + const client = new McpTestClient(TestServer) + const list = await client.listTools() + const t = list.find((x) => x.name === 'get-user')! + assert.ok(t.annotations, 'expected annotations on get-user') + assert.equal(t.annotations.readOnlyHint, true) + assert.equal(t.annotations.idempotentHint, true) + assert.equal(t.annotations.destructiveHint, undefined) + assert.equal(t.annotations.openWorldHint, undefined) + }) + + it('surfaces destructiveHint + openWorldHint on a destructive tool', async () => { + const client = new McpTestClient(TestServer) + const list = await client.listTools() + const t = list.find((x) => x.name === 'delete-file')! + assert.ok(t.annotations) + assert.equal(t.annotations.destructiveHint, true) + assert.equal(t.annotations.openWorldHint, true) + }) + + it('omits annotations entirely when no hints are set', async () => { + const client = new McpTestClient(TestServer) + const list = await client.listTools() + const t = list.find((x) => x.name === 'plain')! + assert.equal(t.annotations, undefined) + }) + + it('explicit @IsReadOnly(false) emits false (not omitted)', async () => { + @IsReadOnly(false) + class WriterTool extends McpTool { + schema() { return z.object({}) } + async handle() { return McpResponse.text('w') } + } + class S extends McpServer { protected tools = [WriterTool] } + const list = await new McpTestClient(S).listTools() + assert.equal(list[0]!.annotations?.readOnlyHint, false) + }) +}) + +describe('Resource annotations', () => { + @Audience('user') + @Priority(0.9) + class ReleaseNotes extends McpResource { + uri() { return 'file://release-notes' } + async handle() { return 'notes' } + } + + @Audience('user', 'assistant') + @LastModified('2026-05-09T00:00:00Z') + class Manual extends McpResource { + uri() { return 'file://manual' } + async handle() { return 'manual' } + } + + class PlainResource extends McpResource { + uri() { return 'file://plain' } + async handle() { return 'plain' } + } + + class TestServer extends McpServer { + protected resources = [ReleaseNotes, Manual, PlainResource] + } + + it('surfaces audience + priority', async () => { + const list = await new McpTestClient(TestServer).listResources() + const r = list.find((x) => x.uri === 'file://release-notes')! + assert.deepStrictEqual(r.annotations?.audience, ['user']) + assert.equal(r.annotations?.priority, 0.9) + }) + + it('surfaces multi-role audience and lastModified', async () => { + const list = await new McpTestClient(TestServer).listResources() + const r = list.find((x) => x.uri === 'file://manual')! + assert.deepStrictEqual(r.annotations?.audience, ['user', 'assistant']) + assert.equal(r.annotations?.lastModified, '2026-05-09T00:00:00Z') + }) + + it('omits annotations when none set', async () => { + const list = await new McpTestClient(TestServer).listResources() + const r = list.find((x) => x.uri === 'file://plain')! + assert.equal(r.annotations, undefined) + }) + + it('@Priority validates the 0..1 range', () => { + assert.throws(() => Priority(1.5), /between 0 and 1/) + assert.throws(() => Priority(-0.1), /between 0 and 1/) + }) + + it('@Audience requires at least one role', () => { + assert.throws(() => Audience(), /at least one role/) + }) + + it('@LastModified accepts a Date and serializes to ISO', async () => { + @LastModified(new Date('2026-01-01T00:00:00Z')) + class DatedResource extends McpResource { + uri() { return 'file://dated' } + async handle() { return 'd' } + } + class S extends McpServer { protected resources = [DatedResource] } + const list = await new McpTestClient(S).listResources() + assert.equal(list[0]!.annotations?.lastModified, '2026-01-01T00:00:00.000Z') + }) +}) + +// ─── shouldRegister conditional registration (M3) ───────── + +describe('shouldRegister', () => { + it('hides a tool from listings when shouldRegister returns false', async () => { + let visible = true + class GatedTool extends McpTool { + schema() { return z.object({}) } + async handle() { return McpResponse.text('ok') } + shouldRegister() { return visible } + } + class AlwaysOnTool extends McpTool { + schema() { return z.object({}) } + async handle() { return McpResponse.text('ok') } + } + class S extends McpServer { protected tools = [GatedTool, AlwaysOnTool] } + + visible = true + let list = await new McpTestClient(S).listTools() + assert.equal(list.length, 2) + + visible = false + list = await new McpTestClient(S).listTools() + assert.equal(list.length, 1) + assert.equal(list[0]!.name, 'always-on') + }) + + it('rejected tool calls throw "not found" — preventing bypass', async () => { + class GatedTool extends McpTool { + schema() { return z.object({}) } + async handle() { return McpResponse.text('ok') } + shouldRegister() { return false } + } + class S extends McpServer { protected tools = [GatedTool] } + const client = new McpTestClient(S) + await assert.rejects(() => client.callTool('gated'), /not found/) + }) + + it('hides resources from listings and reads', async () => { + class GatedResource extends McpResource { + uri() { return 'file://gated' } + async handle() { return 'secret' } + shouldRegister() { return false } + } + class S extends McpServer { protected resources = [GatedResource] } + const client = new McpTestClient(S) + const list = await client.listResources() + assert.equal(list.length, 0) + await assert.rejects(() => client.readResource('file://gated'), /not found/) + }) + + it('hides prompts from listings and gets', async () => { + class GatedPrompt extends McpPrompt { + async handle() { return [{ role: 'user' as const, content: 'hi' }] } + shouldRegister() { return false } + } + class S extends McpServer { protected prompts = [GatedPrompt] } + const client = new McpTestClient(S) + const list = await client.listPrompts() + assert.equal(list.length, 0) + await assert.rejects(() => client.getPrompt('gated'), /not found/) + }) + + it('supports async shouldRegister', async () => { + class AsyncGatedTool extends McpTool { + schema() { return z.object({}) } + async handle() { return McpResponse.text('ok') } + async shouldRegister() { await new Promise((r) => setTimeout(r, 1)); return false } + } + class S extends McpServer { protected tools = [AsyncGatedTool] } + const list = await new McpTestClient(S).listTools() + assert.equal(list.length, 0) + }) +}) + +// ─── Streaming tools (progress notifications) ──────────── + +describe('Streaming tools — progress notifications', () => { + class CountTool extends McpTool { + schema() { return z.object({ n: z.number() }) } + async *handle(input: Record): AsyncGenerator { + const n = Number(input['n']) + for (let i = 1; i <= n; i++) { + yield { progress: i, total: n, message: `tick ${i}/${n}` } + } + return McpResponse.text(`done: ${n}`) + } + } + class CountServer extends McpServer { protected tools = [CountTool] } + + it('McpTestClient drains progress yields and returns the final value', async () => { + const client = new McpTestClient(CountServer) + const progress: McpToolProgress[] = [] + const result = await client.callTool('count', { n: 3 }, (p) => progress.push(p)) + assert.equal((result.content[0] as { text: string }).text, 'done: 3') + assert.equal(progress.length, 3) + assert.deepStrictEqual(progress.map((p) => p.progress), [1, 2, 3]) + assert.equal(progress[2]!.total, 3) + assert.equal(progress[2]!.message, 'tick 3/3') + }) + + it('McpTestClient with no onProgress drops yields silently', async () => { + const client = new McpTestClient(CountServer) + const result = await client.callTool('count', { n: 5 }) + assert.equal((result.content[0] as { text: string }).text, 'done: 5') + }) + + it('consumeToolReturn forwards yields as notifications/progress when meta.progressToken is present', async () => { + const sent: { method: string; params: Record }[] = [] + const tool = new CountTool() + const ret = tool.handle({ n: 2 }) + const result = await consumeToolReturn( + ret, + { sendNotification: async (n) => { sent.push(n) } }, + { progressToken: 'tok-123' }, + ) + assert.equal((result.content[0] as { text: string }).text, 'done: 2') + assert.equal(sent.length, 2) + assert.equal(sent[0]!.method, 'notifications/progress') + assert.deepStrictEqual(sent[0]!.params, { progressToken: 'tok-123', progress: 1, total: 2, message: 'tick 1/2' }) + }) + + it('consumeToolReturn drops yields when no progressToken is supplied', async () => { + const sent: unknown[] = [] + const tool = new CountTool() + const ret = tool.handle({ n: 4 }) + const result = await consumeToolReturn( + ret, + { sendNotification: async (n) => { sent.push(n) } }, + undefined, + ) + assert.equal((result.content[0] as { text: string }).text, 'done: 4') + assert.equal(sent.length, 0) + }) + + it('consumeToolReturn with a plain Promise return is a no-op pass-through', async () => { + class Plain extends McpTool { + schema() { return z.object({}) } + async handle(_input: Record) { return McpResponse.text('plain') } + } + const tool = new Plain() + const ret = tool.handle({}) + const sent: unknown[] = [] + const result = await consumeToolReturn( + ret, + { sendNotification: async (n) => { sent.push(n) } }, + { progressToken: 'unused' }, + ) + assert.equal((result.content[0] as { text: string }).text, 'plain') + assert.equal(sent.length, 0) + }) +}) + +// ─── @Handle DI injection ───────────────────────────────── + +describe('@Handle DI injection', () => { + class Logger { + entries: string[] = [] + info(msg: string) { this.entries.push(msg) } + } + + let logger: Logger + let resolver: McpResolver + + beforeEach(() => { + logger = new Logger() + // Instance-scoped resolver — no globalThis container. The server is + // constructed with it; the runtime threads it to every @Handle call site. + resolver = createResolver().register(Logger, logger) + }) + + it('resolves extra method params from the resolver', async () => { + class LogTool extends McpTool { + schema() { return z.object({ message: z.string() }) } + @Handle(Logger) + async handle(input: Record, log: Logger) { + log.info(String(input['message'])) + return McpResponse.text('logged') + } + } + class LogServer extends McpServer { protected tools = [LogTool] } + + const client = new McpTestClient(LogServer, { resolver }) + const result = await client.callTool('log', { message: 'hi' }) + assert.equal((result.content[0] as { text: string }).text, 'logged') + assert.deepStrictEqual(logger.entries, ['hi']) + }) + + it('supports implicit token resolution via design:paramtypes (plain tsc)', async () => { + class PingTool extends McpTool { + schema() { return z.object({}) } + @Handle() + async handle(_input: Record, log: Logger) { + log.info('ping') + return McpResponse.text('pong') + } + } + class PingServer extends McpServer { protected tools = [PingTool] } + + const client = new McpTestClient(PingServer, { resolver }) + const result = await client.callTool('ping', {}) + assert.equal((result.content[0] as { text: string }).text, 'pong') + assert.deepStrictEqual(logger.entries, ['ping']) + }) + + it('still calls handle(input) when the method is not decorated (no resolver needed)', async () => { + class PlainTool extends McpTool { + schema() { return z.object({ n: z.number() }) } + async handle(input: Record) { + return McpResponse.text(`got ${input['n']}`) + } + } + class PlainServer extends McpServer { protected tools = [PlainTool] } + + const client = new McpTestClient(PlainServer) + const result = await client.callTool('plain', { n: 7 }) + assert.equal((result.content[0] as { text: string }).text, 'got 7') + }) + + it('throws a clear error when @Handle deps are requested but no resolver was provided', async () => { + class NeedyTool extends McpTool { + schema() { return z.object({}) } + @Handle(Logger) + async handle(_input: Record, _log: Logger) { + return McpResponse.text('never') + } + } + class NeedyServer extends McpServer { protected tools = [NeedyTool] } + + const client = new McpTestClient(NeedyServer) // no resolver supplied + await assert.rejects(() => client.callTool('needy', {}), /without a resolver/) + }) + + it('throws a clear named error when the resolver returns undefined', async () => { + class BadTool extends McpTool { + schema() { return z.object({}) } + @Handle(Logger) + async handle(_input: Record, _log: Logger) { + return McpResponse.text('never') + } + } + class BadServer extends McpServer { protected tools = [BadTool] } + + const undefinedResolver: McpResolver = { resolve: () => undefined } + const client = new McpTestClient(BadServer, { resolver: undefinedResolver }) + await assert.rejects(() => client.callTool('bad', {}), /never inject undefined/) + }) + + it('@Handle metadata key uses Symbol.for(...) so define-side and read-side survive bundle splits', () => { + // The user's tool class is decorated at module-load in the app bundle; + // the MCP runtime reads the metadata later from a node_modules-resolved + // copy of `decorators.ts`. `Symbol(...)` would give two distinct + // identities → read-side `Reflect.getMetadata` returns `undefined` and + // every `@Handle(...)`-injected dep is silently dropped. The fix uses + // `Symbol.for(...)` so both copies share the process-global symbol + // registry entry. Pin the exact key so a future refactor doesn't drift. + class T extends McpTool { + schema() { return z.object({}) } + @Handle(Logger) + async handle(_input: Record, _log: Logger) { + return McpResponse.text('ok') + } + } + const instance = new T() + const stored = Reflect.getMetadata(Symbol.for('gemstack.mcp.inject'), instance, 'handle') + assert.ok(stored, 'metadata must be readable via the registered Symbol.for key') + }) +}) + +// ─── Server-initiated notifications ────────────────────── + +describe('Server-initiated notifications', () => { + class NotifyServer extends McpServer {} + + it('attached SDKs receive notify() fan-out, detach removes them', async () => { + const server = new NotifyServer() + const got1: { method: string; params?: unknown }[] = [] + const got2: { method: string; params?: unknown }[] = [] + const detach1 = server.attachSdk({ notification: async (n) => { got1.push(n) } }) + server.attachSdk({ notification: async (n) => { got2.push(n) } }) + + assert.equal(server.attachedCount(), 2) + + await server.notifyResourceUpdated('file:///foo.txt') + await server.notifyResourceListChanged() + await server.notifyToolListChanged() + await server.notifyPromptListChanged() + + assert.deepStrictEqual(got1.map((n) => n.method), [ + 'notifications/resources/updated', + 'notifications/resources/list_changed', + 'notifications/tools/list_changed', + 'notifications/prompts/list_changed', + ]) + assert.deepStrictEqual(got1[0]!.params, { uri: 'file:///foo.txt' }) + // List-changed methods send no params + assert.equal(got1[1]!.params, undefined) + assert.deepStrictEqual(got1.length, got2.length) + + detach1() + assert.equal(server.attachedCount(), 1) + await server.notifyToolListChanged() + assert.equal(got1.length, 4) // unchanged after detach + assert.equal(got2.length, 5) // got the new one + }) + + it('notify() swallows errors from one target so others still receive', async () => { + const server = new NotifyServer() + const good: string[] = [] + server.attachSdk({ notification: () => { throw new Error('dead transport') } }) + server.attachSdk({ notification: async (n) => { good.push(n.method) } }) + await server.notifyToolListChanged() + assert.deepStrictEqual(good, ['notifications/tools/list_changed']) + }) + + it('notify() with no attached targets is a no-op', async () => { + const server = new NotifyServer() + assert.equal(server.attachedCount(), 0) + await server.notifyToolListChanged() // must not throw + }) + + it('custom method via notify() escape hatch', async () => { + const server = new NotifyServer() + const got: { method: string; params?: unknown }[] = [] + server.attachSdk({ notification: async (n) => { got.push(n) } }) + await server.notify('notifications/custom', { foo: 'bar' }) + assert.deepStrictEqual(got, [{ method: 'notifications/custom', params: { foo: 'bar' } }]) + }) +}) + +// ─── McpObserverRegistry ────────────────────────────────── + +describe('McpObserverRegistry', () => { + it('fan-outs events to every subscriber', async () => { + const { McpObserverRegistry } = await import('./observers.js') + const reg = new McpObserverRegistry() + const calls: string[] = [] + reg.subscribe((e) => calls.push(`a:${e.kind}`)) + reg.subscribe((e) => calls.push(`b:${e.kind}`)) + + reg.emit({ + kind: 'tool.called', serverName: 's', name: 't', + input: {}, output: null, duration: 1, + }) + assert.deepStrictEqual(calls, ['a:tool.called', 'b:tool.called']) + }) + + it('unsubscribe removes the observer', async () => { + const { McpObserverRegistry } = await import('./observers.js') + const reg = new McpObserverRegistry() + const calls: string[] = [] + const off = reg.subscribe(() => calls.push('x')) + off() + reg.emit({ kind: 'tool.called', serverName: 's', name: 't', input: {}, output: null, duration: 0 }) + assert.deepStrictEqual(calls, []) + }) + + it('swallows observer errors so MCP servers never break', async () => { + const { McpObserverRegistry } = await import('./observers.js') + const reg = new McpObserverRegistry() + reg.subscribe(() => { throw new Error('observer bug') }) + const good: string[] = [] + reg.subscribe((e) => good.push(e.kind)) + + reg.emit({ kind: 'tool.called', serverName: 's', name: 't', input: {}, output: null, duration: 0 }) + assert.deepStrictEqual(good, ['tool.called']) + }) + + it('global singleton is installed on globalThis', async () => { + const { mcpObservers } = await import('./observers.js') + assert.ok(mcpObservers) + const g = globalThis as Record + assert.equal(g['__gemstack_mcp_observers__'], mcpObservers) + }) + + it('emits tool.called + tool.failed around tool invocations', async () => { + const { mcpObservers } = await import('./observers.js') + const events: { kind: string; name: string; error?: string }[] = [] + const off = mcpObservers.subscribe((e) => events.push({ kind: e.kind, name: e.name, ...(e.error ? { error: e.error } : {}) })) + + try { + class GoodTool extends McpTool { + schema() { return z.object({}) } + async handle() { return McpResponse.text('ok') } + } + class BadTool extends McpTool { + schema() { return z.object({}) } + async handle(): Promise { throw new Error('boom') } + } + class S extends McpServer { protected tools = [GoodTool, BadTool] } + + const client = new McpTestClient(S) + // McpTestClient calls handle() directly (no runtime emission). This + // test just verifies subscribe/emit wiring on the shared registry. + mcpObservers.emit({ kind: 'tool.called', serverName: 'S', name: 'good', input: {}, output: { ok: true }, duration: 3 }) + mcpObservers.emit({ kind: 'tool.failed', serverName: 'S', name: 'bad', input: {}, output: null, duration: 2, error: 'boom' }) + + await client.callTool('good') + assert.deepStrictEqual(events, [ + { kind: 'tool.called', name: 'good' }, + { kind: 'tool.failed', name: 'bad', error: 'boom' }, + ]) + } finally { off() } + }) +}) + +// ─── createSdkServer — end-to-end via InMemoryTransport ── +// +// Drives the SDK's request handlers (ListTools/CallTool/ReadResource/etc.) +// through a real Client ↔ Server roundtrip using the SDK's InMemoryTransport +// pair. This exercises the wiring that McpTestClient skips. + +describe('createSdkServer — SDK handlers', () => { + async function connect(ServerClass: new () => McpServer) { + const { Client } = await import('@modelcontextprotocol/sdk/client/index.js') + const { InMemoryTransport } = await import('@modelcontextprotocol/sdk/inMemory.js') + const { createSdkServer } = await import('./runtime.js') + + const sdk = createSdkServer(new ServerClass()) + const [clientT, serverT] = InMemoryTransport.createLinkedPair() + await sdk.connect(serverT) + + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }) + await client.connect(clientT) + return { sdk, client } + } + + class EchoTool extends McpTool { + schema() { return z.object({ message: z.string() }) } + async handle(input: Record) { + return McpResponse.text(String(input['message'])) + } + } + class BoomTool extends McpTool { + schema() { return z.object({}) } + async handle(): Promise { throw new Error('boom') } + } + class StaticResource extends McpResource { + uri() { return 'info://version' } + async handle() { return '1.0.0' } + } + class WeatherResource extends McpResource { + uri() { return 'weather://location/{city}' } + async handle(params?: Record) { return `Weather in ${params?.['city'] ?? '?'}: sunny` } + } + class GreetPrompt extends McpPrompt { + arguments() { return z.object({ name: z.string() }) } + async handle(args: Record) { + return [{ role: 'user' as const, content: `Hello ${String(args['name'])}` }] + } + } + class TestServer extends McpServer { + protected tools = [EchoTool, BoomTool] + protected resources = [StaticResource, WeatherResource] + protected prompts = [GreetPrompt] + } + + it('tools/list returns name + description + inputSchema', async () => { + const { client } = await connect(TestServer) + const list = await client.listTools() + assert.equal(list.tools.length, 2) + const echo = list.tools.find((t) => t.name === 'echo')! + assert.ok(echo.inputSchema) + const inputSchema = echo.inputSchema as unknown as { properties: { message: unknown } } + assert.ok(inputSchema.properties.message) + }) + + it('tools/call happy path returns content', async () => { + const { client } = await connect(TestServer) + const result = await client.callTool({ name: 'echo', arguments: { message: 'hi' } }) + const content = result.content as Array<{ type: string; text: string }> + assert.equal(content[0]!.text, 'hi') + assert.equal(result.isError, undefined) + }) + + it('tools/call on unknown tool returns isError + "Unknown tool"', async () => { + const { client } = await connect(TestServer) + const result = await client.callTool({ name: 'missing', arguments: {} }) + assert.equal(result.isError, true) + const content = result.content as Array<{ type: string; text: string }> + assert.ok(content[0]!.text.includes('Unknown tool')) + }) + + it('tools/call failure returns isError + emits tool.failed observer event', async () => { + const { mcpObservers } = await import('./observers.js') + const seen: Array<{ kind: string; name: string; error?: string }> = [] + const off = mcpObservers.subscribe((e) => { + if (e.name === 'boom') seen.push({ kind: e.kind, name: e.name, ...(e.error ? { error: e.error } : {}) }) + }) + try { + const { client } = await connect(TestServer) + const result = await client.callTool({ name: 'boom', arguments: {} }) + assert.equal(result.isError, true) + const content = result.content as Array<{ type: string; text: string }> + assert.ok(content[0]!.text.includes('Error: boom')) + assert.equal(seen.length, 1) + assert.equal(seen[0]!.kind, 'tool.failed') + assert.equal(seen[0]!.error, 'boom') + } finally { off() } + }) + + it('resources/read on a static URI returns content', async () => { + const { client } = await connect(TestServer) + const result = await client.readResource({ uri: 'info://version' }) + const c0 = result.contents[0] as { uri: string; text: string } + assert.equal(c0.text, '1.0.0') + assert.equal(c0.uri, 'info://version') + }) + + it('resources/read on a template URI extracts params and forwards', async () => { + const { client } = await connect(TestServer) + const result = await client.readResource({ uri: 'weather://location/paris' }) + const c0 = result.contents[0] as { text: string } + assert.equal(c0.text, 'Weather in paris: sunny') + }) + + it('resources/read on unknown URI surfaces the SDK error', async () => { + const { client } = await connect(TestServer) + await assert.rejects( + () => client.readResource({ uri: 'missing://x' }), + /Unknown resource/, + ) + }) + + it('prompts/list returns name + description + arguments', async () => { + const { client } = await connect(TestServer) + const list = await client.listPrompts() + assert.equal(list.prompts.length, 1) + assert.equal(list.prompts[0]!.name, 'greet') + const args = list.prompts[0]!.arguments as Array<{ name: string; required: boolean }> + assert.equal(args[0]!.name, 'name') + }) + + it('prompts/get returns messages', async () => { + const { client } = await connect(TestServer) + const result = await client.getPrompt({ name: 'greet', arguments: { name: 'World' } }) + assert.equal(result.messages.length, 1) + assert.equal((result.messages[0] as { content: { text?: string; type: string } }).content.text, 'Hello World') + }) +}) + +// ─── oauth2McpMiddleware — happy paths via a user-supplied verifyToken ── + +describe('oauth2McpMiddleware — happy paths', () => { + function mockRes() { + const calls: { status?: number; body?: unknown; headers: Record } = { headers: {} } + const res = { + status(code: number) { calls.status = code; return res }, + header(key: string, value: string) { calls.headers[key.toLowerCase()] = value; return res }, + json(data: unknown) { calls.body = data }, + } + return { res, calls } + } + + function mockReq(authHeader?: string) { + return { + headers: { ...(authHeader ? { authorization: authHeader } : {}), host: 'app.test' }, + } + } + + // A stand-in for the binding's verifier (the Rudder binding wires passport here). + function verifier(opts: { scopes?: string[]; sub?: string } = {}): VerifyToken { + return async () => ({ + sub: opts.sub ?? 'user-1', + ...(opts.scopes ? { scopes: opts.scopes } : {}), + }) + } + + it('valid token with no scope requirement calls next() and attaches req.mcpAuth', async () => { + const { oauth2McpMiddleware } = await import('./auth/oauth2.js') + const mw = oauth2McpMiddleware('/mcp/secure', { verifyToken: verifier({ scopes: ['mcp.read'] }) }) + const { res } = mockRes() + const req = mockReq('Bearer abc') + let nextCalled = false + await mw(req as never, res as never, async () => { nextCalled = true }) + assert.equal(nextCalled, true) + const auth = (req as Record)['mcpAuth'] as { sub?: string; scopes?: string[] } + assert.equal(auth.sub, 'user-1') + assert.deepStrictEqual(auth.scopes, ['mcp.read']) + }) + + it('valid token with present required scope calls next()', async () => { + const { oauth2McpMiddleware } = await import('./auth/oauth2.js') + const mw = oauth2McpMiddleware('/mcp/secure', { scopes: ['mcp.read'], verifyToken: verifier({ scopes: ['mcp.read', 'mcp.write'] }) }) + const { res } = mockRes() + let nextCalled = false + await mw(mockReq('Bearer abc') as never, res as never, async () => { nextCalled = true }) + assert.equal(nextCalled, true) + }) + + it('valid token missing required scope returns 403 insufficient_scope', async () => { + const { oauth2McpMiddleware } = await import('./auth/oauth2.js') + const mw = oauth2McpMiddleware('/mcp/secure', { scopes: ['mcp.admin'], verifyToken: verifier({ scopes: ['mcp.read'] }) }) + const { res, calls } = mockRes() + let nextCalled = false + await mw(mockReq('Bearer abc') as never, res as never, async () => { nextCalled = true }) + assert.equal(nextCalled, false) + assert.equal(calls.status, 403) + assert.ok(calls.headers['www-authenticate']?.includes('insufficient_scope')) + assert.ok(calls.headers['www-authenticate']?.includes('scope="mcp.admin"')) + assert.equal((calls.body as { error: string }).error, 'insufficient_scope') + }) + + it('valid token with wildcard scope `*` bypasses scope check', async () => { + const { oauth2McpMiddleware } = await import('./auth/oauth2.js') + const mw = oauth2McpMiddleware('/mcp/secure', { scopes: ['mcp.admin'], verifyToken: verifier({ scopes: ['*'] }) }) + const { res } = mockRes() + let nextCalled = false + await mw(mockReq('Bearer abc') as never, res as never, async () => { nextCalled = true }) + assert.equal(nextCalled, true) + }) + + it('a verifier that throws (e.g. revoked) returns 401 invalid_token surfacing its message', async () => { + const { oauth2McpMiddleware } = await import('./auth/oauth2.js') + const mw = oauth2McpMiddleware('/mcp/secure', { + verifyToken: async () => { throw new Error('Token has been revoked.') }, + }) + const { res, calls } = mockRes() + let nextCalled = false + await mw(mockReq('Bearer abc') as never, res as never, async () => { nextCalled = true }) + assert.equal(nextCalled, false) + assert.equal(calls.status, 401) + assert.ok(calls.headers['www-authenticate']?.includes('invalid_token')) + assert.ok(calls.headers['www-authenticate']?.includes('revoked')) + }) + + it('a verifier that returns null returns 401 invalid_token', async () => { + const { oauth2McpMiddleware } = await import('./auth/oauth2.js') + const mw = oauth2McpMiddleware('/mcp/secure', { verifyToken: async () => null }) + const { res, calls } = mockRes() + let nextCalled = false + await mw(mockReq('Bearer abc') as never, res as never, async () => { nextCalled = true }) + assert.equal(nextCalled, false) + assert.equal(calls.status, 401) + assert.ok(calls.headers['www-authenticate']?.includes('invalid_token')) + }) +}) + +describe('registerOAuth2Metadata', () => { + it('emits RFC 9728 protected-resource metadata document', async () => { + const { registerOAuth2Metadata } = await import('./auth/oauth2.js') + type Handler = (req: unknown, res: unknown) => unknown + let registeredPath: string | null = null + let registeredHandler: Handler | null = null + const router = { + get(path: string, handler: Handler) { + registeredPath = path + registeredHandler = handler + }, + } + registerOAuth2Metadata(router, '/mcp/secure', { scopesSupported: ['mcp.read', 'mcp.write'] }) + + assert.equal(registeredPath, '/.well-known/oauth-protected-resource/mcp/secure') + + let body: Record | null = null + const req = { headers: { host: 'app.test' } } + const res = { json: (data: Record) => { body = data } } + ;(registeredHandler as unknown as Handler)(req, res) + + assert.ok(body) + const b = body as Record + assert.equal(b['resource'], 'http://app.test/mcp/secure') + assert.deepStrictEqual(b['authorization_servers'], ['http://app.test']) + assert.deepStrictEqual(b['bearer_methods_supported'], ['header']) + assert.deepStrictEqual(b['scopes_supported'], ['mcp.read', 'mcp.write']) + }) +}) + +describe('Mcp servers registry on globalThis', () => { + it('state lives on globalThis so it survives a second copy of @gemstack/mcp', () => { + // Vite-bundled server apps inline `@gemstack/mcp` (the route mounter + // reads `Mcp.getWebServers()`) into entry.mjs, but any `Mcp.web()` / + // `Mcp.local()` calls in `routes/console.ts` or `app/Mcp/...` can run + // from a node_modules copy resolved via the provider auto-discovery + // manifest. Without a globalThis-routed store, servers registered from + // the externalized copy would never be visible to the bundled copy's + // mounter — every `/mcp/*` request would 404. This test pins the + // contract: writes from this module copy are visible on a global key + // the second copy would also read from. + class S extends McpServer { Name = 'Echo'; Version = '1.0.0' } + Mcp.web('/mcp/echo', S) + Mcp.local('echo', S) + const store = (globalThis as Record)['__gemstack_mcp_servers__'] as { + web: Map + local: Map + } | undefined + assert.ok(store, 'global store should exist after Mcp.web()') + assert.ok(store.web.has('/mcp/echo')) + assert.ok(store.local.has('echo')) + }) +}) + +// ─── createMcpHttpHandler — framework-neutral node:http ─── + +describe('createMcpHttpHandler', () => { + class EchoTool extends McpTool { + schema() { return z.object({ message: z.string() }) } + async handle(input: Record) { + return McpResponse.text(String(input['message'])) + } + } + class HttpServer extends McpServer { protected tools = [EchoTool] } + + it('serves an MCP server over raw node:http with no framework present', async () => { + const { createServer } = await import('node:http') + const { Client } = await import('@modelcontextprotocol/sdk/client/index.js') + const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js') + const { createMcpHttpHandler } = await import('./runtime.js') + + const handler = createMcpHttpHandler(new HttpServer()) + const httpServer = createServer((req, res) => { void handler(req, res) }) + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)) + const port = (httpServer.address() as { port: number }).port + + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }) + const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`)) + + try { + // `as never`: the SDK's Transport type trips exactOptionalPropertyTypes + // (sessionId: string vs string | undefined) — runtime-compatible. + await client.connect(transport as never) + + const list = await client.listTools() + assert.equal(list.tools.length, 1) + assert.equal(list.tools[0]!.name, 'echo') + + const result = await client.callTool({ name: 'echo', arguments: { message: 'hi over http' } }) + const content = result.content as Array<{ type: string; text: string }> + assert.equal(content[0]!.text, 'hi over http') + } finally { + await client.close().catch(() => {}) + await transport.close().catch(() => {}) + await new Promise((resolve) => { + httpServer.close(() => resolve()) + httpServer.closeAllConnections?.() + }) + } + }) +}) diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 0000000..7d8a9b6 --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,33 @@ +export { McpServer } from './McpServer.js' +export type { McpServerMetadata, McpServerOptions } from './McpServer.js' +export { McpTool } from './McpTool.js' +export type { McpToolResult, McpToolProgress, McpToolReturn } from './McpTool.js' +export { McpResource } from './McpResource.js' +export { McpPrompt } from './McpPrompt.js' +export type { McpPromptMessage } from './McpPrompt.js' +export { McpResponse } from './McpResponse.js' +export { Mcp } from './Mcp.js' +export type { McpWebEntry, McpWebBuilder } from './Mcp.js' +export { + Name, Version, Instructions, Description, Handle, + IsReadOnly, IsDestructive, IsIdempotent, IsOpenWorld, + Audience, Priority, LastModified, +} from './decorators.js' +export type { InjectToken, ToolAnnotations, ResourceAnnotations, AudienceRole } from './decorators.js' +// DI seam: supply a resolver to a server (or McpTestClient) to inject @Handle deps. +export { createResolver } from './resolver.js' +export type { McpResolver, MutableResolver } from './resolver.js' +// OAuth 2.1 protection for web endpoints (bring your own `verifyToken`). +export { oauth2McpMiddleware, registerOAuth2Metadata } from './auth/oauth2.js' +export type { + OAuth2McpOptions, VerifyToken, VerifiedToken, McpAuthContext, + OAuth2Request, OAuth2Response, OAuth2Next, OAuth2Middleware, +} from './auth/oauth2.js' +// Framework-neutral HTTP handler — mount an MCP server on raw `node:http`, +// Express, Connect, etc. The SDK-wiring runtime primitives (createSdkServer, +// startStdio, createWebRequestHandler) live at `@gemstack/mcp/runtime` so the +// main entry doesn't pull `@modelcontextprotocol/sdk` into the boot path. +export { createMcpHttpHandler } from './runtime/node-handler.js' +export { McpTestClient } from './testing.js' +export type { McpTestClientOptions } from './testing.js' +export type { McpObserverEvent, McpObserver, McpObserverRegistry } from './observers.js' diff --git a/packages/mcp/src/observers.ts b/packages/mcp/src/observers.ts new file mode 100644 index 0000000..234410a --- /dev/null +++ b/packages/mcp/src/observers.ts @@ -0,0 +1,52 @@ +/** + * MCP observer registry — Telescope and other collectors subscribe here to + * receive structured events when tools, resources, and prompts are invoked. + * + * Same architecture as `@gemstack/ai-sdk/observers`: a singleton stored on + * `globalThis` so state survives Vite SSR module re-evaluation, and a + * try/catch around dispatch so an observer error never breaks an MCP + * server. + */ + +export interface McpObserverEvent { + kind: + | 'tool.called' | 'tool.failed' + | 'resource.read' | 'resource.failed' + | 'prompt.rendered' | 'prompt.failed' + serverName: string + /** Tool name, resource URI, or prompt name */ + name: string + /** Tool args, resource URI params, or prompt args */ + input: unknown + /** Tool result, resource content, or prompt messages (null on failure) */ + output: unknown + /** Wall-clock duration in ms */ + duration: number + /** Present on `*.failed` events */ + error?: string +} + +export type McpObserver = (event: McpObserverEvent) => void + +export class McpObserverRegistry { + private observers: McpObserver[] = [] + + subscribe(fn: McpObserver): () => void { + this.observers.push(fn) + return () => { this.observers = this.observers.filter((o) => o !== fn) } + } + + emit(event: McpObserverEvent): void { + for (const observer of this.observers) { + try { observer(event) } catch { /* observer errors must not break MCP servers */ } + } + } + + reset(): void { this.observers = [] } +} + +const _g = globalThis as Record +if (!_g['__gemstack_mcp_observers__']) { + _g['__gemstack_mcp_observers__'] = new McpObserverRegistry() +} +export const mcpObservers = _g['__gemstack_mcp_observers__'] as McpObserverRegistry diff --git a/packages/mcp/src/resolver.ts b/packages/mcp/src/resolver.ts new file mode 100644 index 0000000..2fd17f4 --- /dev/null +++ b/packages/mcp/src/resolver.ts @@ -0,0 +1,68 @@ +import type { InjectToken } from './decorators.js' + +/** + * Dependency-injection seam for `@Handle()`-decorated tool / resource / prompt + * methods (and for constructing the primitive classes themselves). + * + * `@gemstack/mcp` is framework-agnostic: it has no container of its own and + * never reaches for one off `globalThis`. Instead a resolver is supplied + * per-server at construction — `new MyServer({ resolver })` — and the runtime + * threads it to every `@Handle` call site. Wire it to whatever container you + * already use (Awilix, tsyringe, InversifyJS, a framework container, …) with a + * one-function adapter, or use the built-in {@link createResolver} for the + * no-container case. + */ +export interface McpResolver { + /** + * Resolve a dependency for a given token. Throwing (or returning `undefined` + * for a `@Handle` dependency) is surfaced as a loud, named error by the + * runtime — a resolver must never silently inject `undefined`. + */ + resolve(token: unknown): unknown +} + +/** A {@link McpResolver} with imperative registration, returned by {@link createResolver}. */ +export interface MutableResolver extends McpResolver { + /** Bind a token (class, string, or symbol) to a concrete instance. Chainable. */ + register(token: InjectToken, instance: unknown): this +} + +/** + * A minimal built-in resolver for the no-container case. Bind instances with + * `.register(Token, instance)`; unregistered **class** tokens are constructed + * with `new Token()` as a convenience, and unregistered string/symbol tokens + * throw (there is nothing to construct). + * + * ```ts + * const resolver = createResolver().register(Logger, new Logger()) + * const server = new MyServer({ resolver }) + * ``` + */ +export function createResolver(): MutableResolver { + const registry = new Map() + const resolver: MutableResolver = { + register(token, instance) { + registry.set(token, instance) + return resolver + }, + resolve(token) { + if (registry.has(token)) return registry.get(token) + if (typeof token === 'function') { + return new (token as new () => unknown)() + } + throw new Error( + `[gemstack/mcp] no binding registered for token ${describeToken(token)}. ` + + `Register it with createResolver().register(token, instance).`, + ) + }, + } + return resolver +} + +/** Human-readable token label for error messages. */ +export function describeToken(token: unknown): string { + if (typeof token === 'function') return token.name || 'anonymous class' + if (typeof token === 'symbol') return token.toString() + if (typeof token === 'string') return JSON.stringify(token) + return String(token) +} diff --git a/packages/mcp/src/runtime.ts b/packages/mcp/src/runtime.ts new file mode 100644 index 0000000..81a80a3 --- /dev/null +++ b/packages/mcp/src/runtime.ts @@ -0,0 +1,12 @@ +// Barrel re-exporting the runtime's sibling modules. Each sibling owns one +// concern: SDK wiring, the framework-neutral HTTP handler, DI helpers, +// tool-return consumption, observer-registry access. Touch the siblings — this +// file is intentionally thin so external consumers (bindings, testing, +// telescope) keep their `from './runtime.js'` / `from '../runtime.js'` imports +// stable. + +export { createSdkServer, startStdio } from './runtime/sdk-server.js' +export { createWebRequestHandler, type WebRequestHandlerOptions } from './runtime/web-handler.js' +export { createMcpHttpHandler } from './runtime/node-handler.js' +export { consumeToolReturn } from './runtime/consume-tool-return.js' +export { resolveOrConstruct, resolveHandleDeps, isRegistered, filterRegistered } from './runtime/handle-deps.js' diff --git a/packages/mcp/src/runtime/consume-tool-return.ts b/packages/mcp/src/runtime/consume-tool-return.ts new file mode 100644 index 0000000..db6133a --- /dev/null +++ b/packages/mcp/src/runtime/consume-tool-return.ts @@ -0,0 +1,51 @@ +import type { McpToolResult, McpToolReturn, McpToolProgress } from '../McpTool.js' + +/** SDK request handler `extra` shape — minimal; we only use sendNotification. */ +export type SdkRequestExtra = { + sendNotification?: (notification: { method: string; params: Record }) => Promise | void +} + +/** + * Type guard distinguishing the streaming variant of `McpToolReturn` (an async + * generator) from a plain `Promise`. Plain Promises don't have + * `Symbol.asyncIterator`, so the presence of both `.next` and the + * `Symbol.asyncIterator` method narrows reliably. + */ +function isAsyncGen(v: McpToolReturn): v is AsyncGenerator { + const maybe = v as { next?: unknown; [Symbol.asyncIterator]?: unknown } + return typeof maybe.next === 'function' + && typeof maybe[Symbol.asyncIterator] === 'function' +} + +/** + * Run a tool's `handle()` return value to completion. + * + * - Plain `Promise` → just await it. + * - `AsyncGenerator` → iterate, forwarding each + * yield as a `notifications/progress` message to the client (only when the + * request supplied a `progressToken` in `_meta`), and resolve to the final + * value the generator returns. + * + * Errors propagate normally so the outer try/catch handles them. + */ +export async function consumeToolReturn( + ret: McpToolReturn, + extra: SdkRequestExtra | undefined, + meta: Record | undefined, +): Promise { + if (!isAsyncGen(ret)) return await ret + + const progressToken = meta?.['progressToken'] + const sendNotification = extra?.sendNotification + + while (true) { + const next = await ret.next() + if (next.done) return next.value + if (progressToken !== undefined && sendNotification) { + await sendNotification({ + method: 'notifications/progress', + params: { progressToken, ...next.value }, + }) + } + } +} diff --git a/packages/mcp/src/runtime/handle-deps.ts b/packages/mcp/src/runtime/handle-deps.ts new file mode 100644 index 0000000..cdc108d --- /dev/null +++ b/packages/mcp/src/runtime/handle-deps.ts @@ -0,0 +1,106 @@ +import { getInjectTokens, type InjectToken } from '../decorators.js' +import { type McpResolver, describeToken } from '../resolver.js' + +export type Ctor = new (...args: any[]) => T + +/** + * Construct a tool / resource / prompt class. When a {@link McpResolver} is + * supplied (off the owning server), it gets first refusal so a container can + * auto-wire constructor dependencies; on a miss (resolver throws, or returns + * `undefined`) we fall back to a plain `new Ctor()` so primitives with no DI + * needs always instantiate. + */ +export function resolveOrConstruct(Ctor: Ctor, resolver?: McpResolver): T { + if (resolver) { + try { + const resolved = resolver.resolve(Ctor) + if (resolved !== undefined) return resolved as T + } catch { + // Resolver couldn't build it — fall back to a plain constructor. + } + } + return new Ctor() +} + +/** + * Resolve the dependencies a `@Handle()`-decorated method asks for, beyond its + * first parameter (index 0 is reserved for the tool input / resource params / + * prompt arguments). + * + * Token sources, in order: + * 1. Explicit tokens from `@Handle(Type1, Type2, …)` — always reliable. + * 2. Fallback: `design:paramtypes` (needs `emitDecoratorMetadata` AND a build + * tool that honours it — plain `tsc` does; esbuild/Vite typically do not). + * + * When a method asks for dependencies but no resolver was provided, or the + * resolver throws / yields `undefined`, this throws a loud error naming the + * member and token — it never silently injects `undefined`. + */ +export function resolveHandleDeps( + instance: object, + propertyKey: string, + resolver?: McpResolver, +): unknown[] { + const tokens = injectTokensFor(instance, propertyKey) + if (tokens.length === 0) return [] + + const member = `${instance.constructor.name}.${propertyKey}()` + + if (!resolver) { + throw new Error( + `[gemstack/mcp] ${member} requests ${tokens.length} injected ` + + `dependency/dependencies via @Handle, but the server was constructed without a resolver. ` + + `Pass one — new MyServer({ resolver: createResolver().register(Token, instance) }) — ` + + `or, in tests, new McpTestClient(Server, { resolver }).`, + ) + } + + return tokens.map((token) => { + let resolved: unknown + try { + resolved = resolver.resolve(token) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + throw new Error( + `[gemstack/mcp] failed to resolve dependency ${describeToken(token)} for ${member}: ${msg}`, + ) + } + if (resolved === undefined) { + throw new Error( + `[gemstack/mcp] resolver returned undefined for dependency ${describeToken(token)} ` + + `requested by ${member}; a resolver must never inject undefined.`, + ) + } + return resolved + }) +} + +/** Explicit `@Handle(...)` tokens, else the `design:paramtypes` tail (index 1+). */ +function injectTokensFor(instance: object, propertyKey: string): InjectToken[] { + const explicit = getInjectTokens(instance, propertyKey) + if (explicit && explicit.length > 0) return explicit + + const paramTypes = Reflect.getMetadata('design:paramtypes', instance, propertyKey) as + Ctor[] | undefined + if (!paramTypes || paramTypes.length <= 1) return [] + return paramTypes.slice(1) as InjectToken[] +} + +/** + * Resolve `shouldRegister?()` for a primitive. Items without the hook are + * always registered. Awaits async hooks. + */ +export async function isRegistered(item: { shouldRegister?(): boolean | Promise }): Promise { + if (!item.shouldRegister) return true + return Boolean(await item.shouldRegister()) +} + +export async function filterRegistered }>( + items: T[], +): Promise { + const out: T[] = [] + for (const item of items) { + if (await isRegistered(item)) out.push(item) + } + return out +} diff --git a/packages/mcp/src/runtime/node-handler.ts b/packages/mcp/src/runtime/node-handler.ts new file mode 100644 index 0000000..b2e808a --- /dev/null +++ b/packages/mcp/src/runtime/node-handler.ts @@ -0,0 +1,97 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' +import type { McpServer } from '../McpServer.js' +import { createWebRequestHandler, type WebRequestHandlerOptions } from './web-handler.js' + +/** + * A framework-neutral `node:http` request handler for an MCP server. Mount it + * on a raw `http.createServer(...)`, or anywhere a `(req, res)` handler fits + * (Express, Connect), with no framework present: + * + * ```ts + * import { createServer } from 'node:http' + * import { createMcpHttpHandler } from '@gemstack/mcp/runtime' + * + * const handler = createMcpHttpHandler(new MyServer()) + * createServer((req, res) => { void handler(req, res) }).listen(3000) + * ``` + * + * It bridges Node's `IncomingMessage`/`ServerResponse` to the Web Standard + * `Request`/`Response` that the MCP SDK's streamable-HTTP transport speaks, + * streaming the response body so SSE notification channels stay open. + */ +export function createMcpHttpHandler( + server: McpServer, + options?: WebRequestHandlerOptions, +): (req: IncomingMessage, res: ServerResponse) => Promise { + const handle = createWebRequestHandler(server, options) + + return async (req: IncomingMessage, res: ServerResponse): Promise => { + try { + const request = await toWebRequest(req) + const response = await handle(request) + await writeWebResponse(res, response) + } catch (err) { + if (!res.headersSent) { + res.writeHead(500, { 'content-type': 'application/json' }) + } + const message = err instanceof Error ? err.message : String(err) + res.end(JSON.stringify({ error: 'internal_error', message })) + } + } +} + +/** Build a Web Standard `Request` from a Node `IncomingMessage`. */ +async function toWebRequest(req: IncomingMessage): Promise { + const host = (req.headers['host'] as string | undefined) ?? 'localhost' + const proto = (firstHeader(req.headers['x-forwarded-proto']) ?? 'http') + const url = `${proto}://${host}${req.url ?? '/'}` + + const headers = new Headers() + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) continue + if (Array.isArray(value)) { + for (const v of value) headers.append(key, v) + } else { + headers.set(key, value) + } + } + + const method = req.method ?? 'GET' + if (method === 'GET' || method === 'HEAD') { + return new Request(url, { method, headers }) + } + + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(chunk as Buffer) + const body = Buffer.concat(chunks) + return body.length > 0 + ? new Request(url, { method, headers, body }) + : new Request(url, { method, headers }) +} + +/** Stream a Web Standard `Response` back through a Node `ServerResponse`. */ +async function writeWebResponse(res: ServerResponse, response: Response): Promise { + const headers: Record = {} + response.headers.forEach((value, key) => { headers[key] = value }) + res.writeHead(response.status, headers) + + if (!response.body) { + res.end() + return + } + + const reader = response.body.getReader() + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + if (value) res.write(Buffer.from(value)) + } + } finally { + res.end() + } +} + +function firstHeader(v: string | string[] | undefined): string | undefined { + return Array.isArray(v) ? v[0] : v +} diff --git a/packages/mcp/src/runtime/observers-accessor.ts b/packages/mcp/src/runtime/observers-accessor.ts new file mode 100644 index 0000000..bdf51b3 --- /dev/null +++ b/packages/mcp/src/runtime/observers-accessor.ts @@ -0,0 +1,12 @@ +import type { McpObserverRegistry } from '../observers.js' + +// Lazy accessor — avoids importing the registry eagerly so the global +// singleton is always the one on `globalThis`, even across SSR re-eval. +let _mcpObs: McpObserverRegistry | null | undefined + +export function getMcpObservers(): McpObserverRegistry | null { + if (_mcpObs === undefined) { + _mcpObs = (globalThis as Record)['__gemstack_mcp_observers__'] as McpObserverRegistry | undefined ?? null + } + return _mcpObs +} diff --git a/packages/mcp/src/runtime/sdk-server.ts b/packages/mcp/src/runtime/sdk-server.ts new file mode 100644 index 0000000..fd17ff4 --- /dev/null +++ b/packages/mcp/src/runtime/sdk-server.ts @@ -0,0 +1,215 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js' +import type { McpServer } from '../McpServer.js' +import type { McpTool } from '../McpTool.js' +import type { McpResource } from '../McpResource.js' +import type { McpPrompt } from '../McpPrompt.js' +import { zodToJsonSchema } from '../zod-to-json-schema.js' +import { getToolAnnotations, getResourceAnnotations } from '../decorators.js' +import { matchUriTemplate } from '../uri-template.js' +import { getMcpObservers } from './observers-accessor.js' +import { consumeToolReturn } from './consume-tool-return.js' +import { resolveOrConstruct, resolveHandleDeps, isRegistered, filterRegistered } from './handle-deps.js' + +export function createSdkServer(server: McpServer): Server { + const meta = server.metadata() + const resolver = server._resolver() + const sdk = new Server( + { name: meta.name, version: meta.version }, + { capabilities: { tools: {}, resources: {}, prompts: {} } }, + ) + + const tools: McpTool[] = server._tools().map((T) => resolveOrConstruct(T, resolver)) + const resources: McpResource[] = server._resources().map((R) => resolveOrConstruct(R, resolver)) + const prompts: McpPrompt[] = server._prompts().map((P) => resolveOrConstruct(P, resolver)) + + // ── Tools ──────────────────────────────────────────────── + sdk.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: (await filterRegistered(tools)).map((t) => { + const def: Record = { + name: t.name(), + description: t.description(), + inputSchema: zodToJsonSchema(t.schema()), + } + if (t.outputSchema) { + def['outputSchema'] = zodToJsonSchema(t.outputSchema()) + } + const annotations = getToolAnnotations(t.constructor) + if (annotations) { + def['annotations'] = annotations + } + return def + }), + })) + + sdk.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + const tool = tools.find((t) => t.name() === request.params.name) + if (!tool || !(await isRegistered(tool))) { + return { content: [{ type: 'text' as const, text: `Unknown tool: ${request.params.name}` }], isError: true } + } + const input = (request.params.arguments ?? {}) as Record + const start = performance.now() + try { + const extras = resolveHandleDeps(tool, 'handle', resolver) + const ret = tool.handle(input, ...extras as []) + const result = await consumeToolReturn(ret, extra, request.params._meta) + getMcpObservers()?.emit({ + kind: 'tool.called', serverName: meta.name, name: tool.name(), + input, output: result, duration: performance.now() - start, + }) + return { ...result } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + getMcpObservers()?.emit({ + kind: 'tool.failed', serverName: meta.name, name: tool.name(), + input, output: null, duration: performance.now() - start, error: msg, + }) + return { content: [{ type: 'text' as const, text: `Error: ${msg}` }], isError: true } + } + }) + + // ── Resources ──────────────────────────────────────────── + const staticResources = resources.filter((r) => !r.isTemplate()) + const templateResources = resources.filter((r) => r.isTemplate()) + + function decorateResource(r: McpResource): Record { + const def: Record = { + uri: r.uri(), + name: r.uri(), + description: r.description(), + mimeType: r.mimeType(), + } + const annotations = getResourceAnnotations(r.constructor) + if (annotations) { + def['annotations'] = annotations + } + return def + } + + sdk.setRequestHandler(ListResourcesRequestSchema, async () => ({ + resources: (await filterRegistered(staticResources)).map(decorateResource), + })) + + if (templateResources.length > 0) { + sdk.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ + resourceTemplates: (await filterRegistered(templateResources)).map((r) => { + const def: Record = { + uriTemplate: r.uri(), + name: r.uri(), + description: r.description(), + mimeType: r.mimeType(), + } + const annotations = getResourceAnnotations(r.constructor) + if (annotations) { + def['annotations'] = annotations + } + return def + }), + })) + } + + sdk.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const uri = request.params.uri + + // Try exact match first (static resources) + let resource = staticResources.find((r) => r.uri() === uri) + let params: Record | undefined + + // Try template match + if (!resource) { + for (const tmpl of templateResources) { + const extracted = matchUriTemplate(tmpl.uri(), uri) + if (extracted) { + resource = tmpl + params = extracted + break + } + } + } + + if (!resource || !(await isRegistered(resource))) { + throw new Error(`Unknown resource: ${uri}`) + } + const start = performance.now() + try { + const extras = resolveHandleDeps(resource, 'handle', resolver) + const text = await resource.handle(params, ...extras as []) + getMcpObservers()?.emit({ + kind: 'resource.read', serverName: meta.name, name: resource.uri(), + input: params ?? { uri }, output: text, duration: performance.now() - start, + }) + return { + contents: [{ uri, text, mimeType: resource.mimeType() }], + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + getMcpObservers()?.emit({ + kind: 'resource.failed', serverName: meta.name, name: resource.uri(), + input: params ?? { uri }, output: null, duration: performance.now() - start, error: msg, + }) + throw err + } + }) + + // ── Prompts ────────────────────────────────────────────── + sdk.setRequestHandler(ListPromptsRequestSchema, async () => ({ + prompts: (await filterRegistered(prompts)).map((p) => ({ + name: p.name(), + description: p.description(), + ...(p.arguments ? { arguments: Object.keys(p.arguments().shape as Record).map((k) => ({ name: k, required: true })) } : {}), + })), + })) + + sdk.setRequestHandler(GetPromptRequestSchema, async (request) => { + const prompt = prompts.find((p) => p.name() === request.params.name) + if (!prompt || !(await isRegistered(prompt))) { + throw new Error(`Unknown prompt: ${request.params.name}`) + } + const args = (request.params.arguments ?? {}) as Record + const start = performance.now() + try { + const extras = resolveHandleDeps(prompt, 'handle', resolver) + const messages = await prompt.handle(args, ...extras as []) + getMcpObservers()?.emit({ + kind: 'prompt.rendered', serverName: meta.name, name: prompt.name(), + input: args, output: messages, duration: performance.now() - start, + }) + // The MCP wire format requires `content` to be a structured object + // (`{ type: 'text', text: string }`); McpPrompt's public API still + // returns `content: string` for ergonomics, so we adapt on the way out. + return { + messages: messages.map((m) => ({ + role: m.role, + content: { type: 'text' as const, text: m.content }, + })), + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + getMcpObservers()?.emit({ + kind: 'prompt.failed', serverName: meta.name, name: prompt.name(), + input: args, output: null, duration: performance.now() - start, error: msg, + }) + throw err + } + }) + + return sdk +} + +/** Start a server with stdio transport */ +export async function startStdio(server: McpServer): Promise { + const sdk = createSdkServer(server) + const transport = new StdioServerTransport() + await sdk.connect(transport) + // Stdio is process-lifetime — no detach needed; the SDK lives until exit. + server.attachSdk(sdk) +} diff --git a/packages/mcp/src/runtime/web-handler.ts b/packages/mcp/src/runtime/web-handler.ts new file mode 100644 index 0000000..9fe9000 --- /dev/null +++ b/packages/mcp/src/runtime/web-handler.ts @@ -0,0 +1,80 @@ +import type { Server } from '@modelcontextprotocol/sdk/server/index.js' +import type { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import type { McpServer } from '../McpServer.js' +import { createSdkServer } from './sdk-server.js' + +type WebTransport = WebStandardStreamableHTTPServerTransport + +export interface WebRequestHandlerOptions { + /** + * Session-id generator for stateful mode (defaults to `crypto.randomUUID`). + * Pass `sessionIdGenerator: undefined` explicitly for **stateless** mode — a + * single transport is created lazily and reused for the handler's lifetime. + */ + sessionIdGenerator?: (() => string) | undefined +} + +/** + * Framework-neutral MCP request handler: maps a Web Standard `Request` to a + * `Response` using the MCP SDK's streamable-HTTP transport. This is the engine + * behind {@link createMcpHttpHandler} (raw `node:http`) and any binding that can + * hand it a `Request` (Hono, Vike, the Fetch API, edge runtimes). + * + * ### Session lifecycle + * - **Stateful** (default) — each new client gets a fresh transport + SDK pair, + * stored once the SDK fires `onsessioninitialized`; both are torn down on + * `onsessionclosed`. + * - **Stateless** (`sessionIdGenerator: undefined`) — one transport + SDK pair, + * created on the first request and reused (never detached). + */ +export function createWebRequestHandler( + server: McpServer, + options?: WebRequestHandlerOptions, +): (request: Request) => Promise { + const sessions = new Map() + const stateless = !!options && 'sessionIdGenerator' in options && options.sessionIdGenerator === undefined + const sessionIdGen = stateless ? undefined : (options?.sessionIdGenerator ?? (() => crypto.randomUUID())) + + let TransportCtor: typeof WebStandardStreamableHTTPServerTransport | undefined + + return async (request: Request): Promise => { + if (!TransportCtor) { + ({ WebStandardStreamableHTTPServerTransport: TransportCtor } = await import( + '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' + )) + } + + // Stateless: one transport reused for the handler's lifetime. + if (!sessionIdGen) { + let entry = sessions.get('__stateless__') + if (!entry) { + const transport = new TransportCtor() + const sdk = createSdkServer(server) + await sdk.connect(transport) + server.attachSdk(sdk) + entry = { transport, sdk } + sessions.set('__stateless__', entry) + } + return entry.transport.handleRequest(request) + } + + // Stateful: route by session-id header. + const sessionId = request.headers.get('mcp-session-id') + if (sessionId && sessions.has(sessionId)) { + return sessions.get(sessionId)!.transport.handleRequest(request) + } + + // New session — `detach` is captured in a closure so `onsessionclosed` can + // release the attached SDK without holding a stale reference. + let detach: () => void = () => {} + const transport = new TransportCtor({ + sessionIdGenerator: sessionIdGen, + onsessioninitialized: (id: string) => { sessions.set(id, { transport, sdk }) }, + onsessionclosed: (id: string) => { sessions.delete(id); detach() }, + }) + const sdk = createSdkServer(server) + await sdk.connect(transport) + detach = server.attachSdk(sdk) + return transport.handleRequest(request) + } +} diff --git a/packages/mcp/src/testing.ts b/packages/mcp/src/testing.ts new file mode 100644 index 0000000..71b0171 --- /dev/null +++ b/packages/mcp/src/testing.ts @@ -0,0 +1,135 @@ +import type { McpServer, McpServerOptions } from './McpServer.js' +import type { McpTool, McpToolResult, McpToolProgress } from './McpTool.js' +import type { McpResource } from './McpResource.js' +import type { McpPrompt, McpPromptMessage } from './McpPrompt.js' +// Import from the cheap sibling modules directly so the test client doesn't +// pull `@modelcontextprotocol/sdk` through the `runtime.ts` barrel. +import { resolveOrConstruct, resolveHandleDeps, isRegistered, filterRegistered } from './runtime/handle-deps.js' +import { consumeToolReturn } from './runtime/consume-tool-return.js' +import { getToolAnnotations, getResourceAnnotations, type ToolAnnotations, type ResourceAnnotations } from './decorators.js' +import type { McpResolver } from './resolver.js' + +export interface McpTestClientOptions { + /** DI resolver for `@Handle()` dependencies + primitive construction. */ + resolver?: McpResolver +} + +export class McpTestClient { + private tools: McpTool[] + private resources: McpResource[] + private prompts: McpPrompt[] + private resolver: McpResolver | undefined + + constructor(ServerClass: new (options?: McpServerOptions) => McpServer, options: McpTestClientOptions = {}) { + this.resolver = options.resolver + const server = new ServerClass(this.resolver ? { resolver: this.resolver } : {}) + this.tools = server._tools().map((T) => resolveOrConstruct(T, this.resolver)) + this.resources = server._resources().map((R) => resolveOrConstruct(R, this.resolver)) + this.prompts = server._prompts().map((P) => resolveOrConstruct(P, this.resolver)) + } + + /** + * Invoke a tool by name. Handles both plain async tools and streaming + * generator tools — for the latter, progress yields are captured if a + * collector is supplied via `onProgress`, otherwise dropped silently. + */ + async callTool( + name: string, + input: Record = {}, + onProgress?: (p: McpToolProgress) => void, + ): Promise { + const tool = this.tools.find((t) => t.name() === name) + if (!tool || !(await isRegistered(tool))) throw new Error(`Tool "${name}" not found`) + const extras = resolveHandleDeps(tool, 'handle', this.resolver) + const ret = tool.handle(input, ...extras as []) + const extra = onProgress + ? { + sendNotification: async (n: { method: string; params: Record }) => { + if (n.method === 'notifications/progress') { + const { progressToken: _t, ...rest } = n.params as Record + onProgress(rest as unknown as McpToolProgress) + } + }, + } + : undefined + // Pass a synthetic progressToken so the runtime forwards yields to onProgress. + return consumeToolReturn(ret, extra, onProgress ? { progressToken: 'test' } : undefined) + } + + async listTools(): Promise> { + return (await filterRegistered(this.tools)).map((t) => { + const annotations = getToolAnnotations(t.constructor) + return { + name: t.name(), + description: t.description(), + ...(annotations ? { annotations } : {}), + } + }) + } + + async listResources(): Promise> { + return (await filterRegistered(this.resources)).map((r) => { + const annotations = getResourceAnnotations(r.constructor) + return { + uri: r.uri(), + description: r.description(), + ...(annotations ? { annotations } : {}), + } + }) + } + + async listPrompts(): Promise> { + return (await filterRegistered(this.prompts)).map((p) => ({ + name: p.name(), + description: p.description(), + })) + } + + async readResource(uri: string): Promise { + const resource = this.resources.find((r) => r.uri() === uri) + if (!resource || !(await isRegistered(resource))) throw new Error(`Resource "${uri}" not found`) + return resource.handle() + } + + async getPrompt(name: string, args: Record = {}): Promise { + const prompt = this.prompts.find((p) => p.name() === name) + if (!prompt || !(await isRegistered(prompt))) throw new Error(`Prompt "${name}" not found`) + return prompt.handle(args) + } + + assertToolExists(name: string): void { + if (!this.tools.some((t) => t.name() === name)) { + throw new Error(`Expected tool "${name}" to exist, but it was not found`) + } + } + + assertToolCount(expected: number): void { + if (this.tools.length !== expected) { + throw new Error(`Expected ${expected} tools, but found ${this.tools.length}`) + } + } + + assertResourceExists(uri: string): void { + if (!this.resources.some((r) => r.uri() === uri)) { + throw new Error(`Expected resource "${uri}" to exist, but it was not found`) + } + } + + assertResourceCount(expected: number): void { + if (this.resources.length !== expected) { + throw new Error(`Expected ${expected} resources, but found ${this.resources.length}`) + } + } + + assertPromptExists(name: string): void { + if (!this.prompts.some((p) => p.name() === name)) { + throw new Error(`Expected prompt "${name}" to exist, but it was not found`) + } + } + + assertPromptCount(expected: number): void { + if (this.prompts.length !== expected) { + throw new Error(`Expected ${expected} prompts, but found ${this.prompts.length}`) + } + } +} diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts new file mode 100644 index 0000000..9d30cc5 --- /dev/null +++ b/packages/mcp/src/types.ts @@ -0,0 +1,10 @@ +/** + * Structural type that matches the shape the MCP runtime needs from a Zod + * object schema: a `.shape` record of nested schemas. Both Zod v3's + * `ZodObject` and Zod v4's `ZodObject<{...}>` satisfy this, + * so tools / resources / prompts authored against either major version + * type-check without a version bump in `@gemstack/mcp`. + */ +export interface ZodLikeObject { + shape: Record +} diff --git a/packages/mcp/src/uri-template.ts b/packages/mcp/src/uri-template.ts new file mode 100644 index 0000000..d7b1f70 --- /dev/null +++ b/packages/mcp/src/uri-template.ts @@ -0,0 +1,22 @@ +/** + * Match a URI against a template pattern like `weather://location/{city}`. + * Returns extracted params or null if no match. + * + * Used by both the SDK runtime (`resources/read` template matching) and the + * inspector's HTTP API. Keep the two in sync — duplicating this matcher caused + * subtle drift in earlier revisions. + */ +export function matchUriTemplate(template: string, uri: string): Record | null { + const paramNames: string[] = [] + const regexStr = template.replace(/\{(\w+)\}/g, (_, name: string) => { + paramNames.push(name) + return '([^/]+)' + }) + const match = uri.match(new RegExp(`^${regexStr}$`)) + if (!match) return null + const params: Record = {} + for (let i = 0; i < paramNames.length; i++) { + params[paramNames[i]!] = decodeURIComponent(match[i + 1]!) + } + return params +} diff --git a/packages/mcp/src/utils.ts b/packages/mcp/src/utils.ts new file mode 100644 index 0000000..b8e6f75 --- /dev/null +++ b/packages/mcp/src/utils.ts @@ -0,0 +1,6 @@ +export function toKebabCase(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/[\s_]+/g, '-') + .toLowerCase() +} diff --git a/packages/mcp/src/zod-to-json-schema.ts b/packages/mcp/src/zod-to-json-schema.ts new file mode 100644 index 0000000..7d09bd8 --- /dev/null +++ b/packages/mcp/src/zod-to-json-schema.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import type { ZodLikeObject } from './types.js' + +/** + * Zod → JSON Schema for MCP tool/prompt input + output schemas. + * + * Uses Zod 4's native `z.toJSONSchema()` directly (Zod is a hard dependency), so + * the package carries no framework coupling for schema conversion. MCP + * tool/prompt parameters are request inputs, so we convert with `io: 'input'`. + * + * - `unrepresentable: 'any'` keeps types with no JSON Schema analogue (`z.date()`, + * `z.bigint()`) from throwing — they degrade to an open `{}` instead of crashing + * the document. + * - The `override` then upgrades `z.date()` → `{ type: 'string', format: 'date-time' }` + * (a date serializes to an ISO string on the wire). `z.bigint()` stays open — no + * single safe JSON representation, so we don't guess. + * - The per-schema `$schema` dialect marker is stripped (tool/prompt schemas don't + * want it). + * + * Falls back to an open object schema (`{ type: 'object' }`) when the input can't + * be converted (e.g. a non-Zod `{ shape }`), so a tool always advertises *some* + * input shape. + */ +export function zodToJsonSchema(schema: ZodLikeObject): Record { + try { + const json = z.toJSONSchema(schema as unknown as z.ZodType, { + io: 'input', + unrepresentable: 'any', + override: (ctx) => { + if (ctx.zodSchema?._zod?.def?.type === 'date') { + ctx.jsonSchema.type = 'string' + ctx.jsonSchema.format = 'date-time' + } + }, + }) as Record + delete json['$schema'] + return json + } catch { + return { type: 'object' } + } +} diff --git a/packages/mcp/tsconfig.build.json b/packages/mcp/tsconfig.build.json new file mode 100644 index 0000000..e578064 --- /dev/null +++ b/packages/mcp/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/mcp/tsconfig.test.json b/packages/mcp/tsconfig.test.json new file mode 100644 index 0000000..eebda2f --- /dev/null +++ b/packages/mcp/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist-test", "rootDir": "src" }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40d39a8..910ed8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,25 @@ importers: specifier: ^5.4.0 version: 5.9.3 + packages/mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.4.3) + reflect-metadata: + specifier: ^0.2.0 + version: 0.2.2 + zod: + specifier: ^4.0.0 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.43 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + packages: '@anthropic-ai/sdk@0.105.0':