diff --git a/README.md b/README.md index 5e1f4af..1c304ec 100644 --- a/README.md +++ b/README.md @@ -75,14 +75,25 @@ const configSchema = { } as const; ``` -### Step 7: Create and start the MCP server +### Step 7: Create the MCP SDK server and start Toolception ```ts +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +// You own the SDK server; pass a factory into Toolception (required in DYNAMIC mode) +const createServer = () => + new McpServer({ + name: "my-mcp-server", + version: "0.0.0", + capabilities: { tools: { listChanged: true } }, + }); + const { start, close } = await createMcpServer({ catalog, moduleLoaders, startup: { mode: "DYNAMIC" }, http: { port: 3000 }, + createServer, // configSchema, // uncomment to expose at /.well-known/mcp-config }); await start(); @@ -103,9 +114,11 @@ process.on("SIGTERM", async () => { ## Static startup -Enable some or ALL toolsets at bootstrap: +Enable some or ALL toolsets at bootstrap. Note: provide a server or factory: ```ts +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + const staticCatalog = { search: { name: "Search", description: "Search tools", modules: ["search"] }, quotes: { name: "Quotes", description: "Market quotes", modules: ["quotes"] }, @@ -115,12 +128,22 @@ createMcpServer({ catalog: staticCatalog, startup: { mode: "STATIC", toolsets: ["search", "quotes"] }, http: { port: 3001 }, + server: new McpServer({ + name: "static-1", + version: "0.0.0", + capabilities: { tools: { listChanged: false } }, + }), }); createMcpServer({ catalog: staticCatalog, startup: { mode: "STATIC", toolsets: "ALL" }, http: { port: 3002 }, + server: new McpServer({ + name: "static-2", + version: "0.0.0", + capabilities: { tools: { listChanged: false } }, + }), }); ``` @@ -128,7 +151,13 @@ createMcpServer({ ### createMcpServer(options) -Creates an MCP server with dynamic/static tool management and Fastify HTTP transport. +Wires your MCP SDK server to dynamic/static tool management and a Fastify HTTP transport. + +Requirements + +- `createServer` must be provided. +- In DYNAMIC mode, a fresh server instance is created per client via `createServer`. +- In STATIC mode, a single server instance is created once via `createServer` and reused for all clients. #### options.catalog (required) @@ -142,12 +171,59 @@ Creates an MCP server with dynamic/static tool management and Fastify HTTP trans - Maps module keys to async loaders returning `McpToolDefinition[]`. Referenced by toolsets via `modules: [key]`. +Usage and behavior + +| Aspect | Details | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Key naming | The object key is the module identifier referenced in `catalog[toolset].modules`. Example: `{ ext: async () => [...] }` and `modules: ["ext"]`. | +| Loader signature | `(context?: unknown) => Promise` or `McpToolDefinition[]` | +| When called | STATIC mode: at startup (for specified toolsets or ALL). DYNAMIC mode: when a toolset is enabled via meta-tools. | +| Return value | An array of tools to register. Tool names should be unique per toolset; if `namespaceToolsWithSetKey` is true, names are prefixed at registration. | +| Errors | Throwing rejects the enable/preload flow for that toolset and surfaces an error to the caller. | +| Idempotency | Loaders may be invoked multiple times across runs/clients. Keep them deterministic/idempotent. Implement internal caching if they perform expensive I/O. | + +Example + +```ts +const moduleLoaders = { + ext: async (ctx?: unknown) => [ + { + name: "echo", + description: "Echo back provided text", + inputSchema: { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + }, + handler: async ({ text }: { text: string }) => ({ + content: [{ type: "text", text }], + }), + }, + ], +}; + +const catalog = { + ext: { name: "Extensions", description: "Extra tools", modules: ["ext"] }, +}; +``` + #### options.startup (optional) `{ mode?: "DYNAMIC" | "STATIC"; toolsets?: string[] | "ALL" }` - Controls startup behavior. In STATIC mode, pre-load specific toolsets (or ALL). In DYNAMIC, register meta-tools and load on demand. +Startup precedence and validation + +| Input | Effective mode | Toolset handling | Outcome/Notes | +| ---------------------------------------------------- | -------------- | ----------------------------------- | -------------------------------------------------------------------------------- | +| `startup.mode = "DYNAMIC"` (toolsets present or not) | DYNAMIC | `startup.toolsets` is ignored | Manage toolsets at runtime via meta-tools; logs a warning if `toolsets` provided | +| `startup.mode = "STATIC"`, `toolsets = "ALL"` | STATIC | Preload all toolsets from `catalog` | OK | +| `startup.mode = "STATIC"`, `toolsets = [names]` | STATIC | Validate names against `catalog` | Invalid names warn; if none valid remain → error | +| No `startup.mode`, `toolsets = "ALL"` | STATIC | Preload all toolsets | OK | +| No `startup.mode`, `toolsets = [names]` | STATIC | Validate names against `catalog` | Invalid names warn; if none valid remain → error | +| No `startup.mode`, no `toolsets` | DYNAMIC | No preloads | Default behavior; manage toolsets at runtime via meta-tools | + #### options.registerMetaTools (optional) `boolean` (default: true in DYNAMIC mode; false in STATIC unless explicitly set) @@ -158,7 +234,21 @@ Creates an MCP server with dynamic/static tool management and Fastify HTTP trans `ExposurePolicy` -- Limits and namespacing for registered tools (e.g., `maxActiveToolsets`, `namespaceToolsWithSetKey`, `allowlist`/`denylist`). +- Controls which toolsets can be activated and how tools are named when registered. + +| Field | Type | Purpose | Example | +| -------------------------- | ----------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `maxActiveToolsets` | `number` | Limit how many toolsets can be active at once. Prevents tool bloat. | `{ maxActiveToolsets: 1 }` blocks enabling a second toolset | +| `namespaceToolsWithSetKey` | `boolean` | Prefix tool names with the toolset key when registering, to avoid name collisions. | With `true`, enabling `core` registers `core.ping` instead of `ping` | +| `allowlist` | `string[]` | Only these toolsets may be enabled. Others are denied. | `{ allowlist: ["core"] }` prevents enabling `ext` | +| `denylist` | `string[]` | These toolsets cannot be enabled. | `{ denylist: ["ext"] }` blocks `ext` | +| `onLimitExceeded` | `(attempted, active) => void` | Callback when `maxActiveToolsets` would be exceeded. | Log or telemetry hook | + +Notes + +- Policy is enforced at enable time (via meta-tools or static preload). +- If both `allowlist` and `denylist` are present, the entry must be in `allowlist` and not in `denylist` to pass. +- Namespacing is applied consistently at registration time and reflected in `GET /tools`. #### options.context (optional) @@ -166,17 +256,51 @@ Creates an MCP server with dynamic/static tool management and Fastify HTTP trans - Arbitrary context passed to `moduleLoaders` during tool resolution. +| Field | Type | Purpose | Example | +| --------- | --------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `context` | `unknown` | Extra data/injectables available to every `ModuleLoader(context)` call when resolving tools. | `{ db, cache, apiClients }` used inside loaders to build tools | + +Notes + +- Only `moduleLoaders` receive `context`. Direct tools defined inline in `catalog` do not. +- Not exposed to clients over HTTP; it stays in-process on the server. +- Keep it lightweight and stable; prefer passing handles (e.g., db client) rather than huge data blobs. +- STATIC mode: loaders are invoked at startup with the same `context`. +- DYNAMIC mode: loaders are invoked at enable time with the same `context`. + +Example + +```ts +const moduleLoaders = { + ext: async (ctx: any) => [ + { + name: "echo", + description: "Echo using a backing service", + inputSchema: { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + }, + handler: async ({ text }: { text: string }) => { + const result = await ctx.apiClients.echoService.send(text); + return { content: [{ type: "text", text: result }] } as any; + }, + }, + ], +}; +``` + #### options.http (optional) `{ host?: string; port?: number; basePath?: string; cors?: boolean; logger?: boolean }` - Fastify transport configuration. Defaults: host `0.0.0.0`, port `3000`, basePath `/`, CORS enabled, logger disabled. -#### options.mcp (optional) +#### options.createServer (optional) -`{ name?: string; version?: string; capabilities?: Record }` +`() => McpServer` -- Overrides MCP server identity and capabilities; `tools.listChanged` is set automatically based on mode. +Required factory to create the SDK server instance(s). #### options.configSchema (optional) diff --git a/package.json b/package.json index ab5250f..240d1a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "toolception", - "version": "0.1.0", + "version": "0.2.0", "private": false, "type": "module", "main": "dist/index.js", @@ -18,6 +18,8 @@ "test": "vitest", "test:run": "vitest run", "test:coverage": "vitest run --coverage", + "dev:server-demo": "tsx tests/smoke-e2e/server-demo.ts", + "dev:client-demo": "tsx tests/smoke-e2e/client-demo.ts", "prepublishOnly": "npm run typecheck && npm run build && npm run test:run" }, "peerDependencies": {}, diff --git a/src/core/ServerOrchestrator.ts b/src/core/ServerOrchestrator.ts index acca43a..28bc115 100644 --- a/src/core/ServerOrchestrator.ts +++ b/src/core/ServerOrchestrator.ts @@ -1,5 +1,5 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { ModeResolver } from "../mode/ModeResolver.js"; +import { ToolsetValidator } from "../mode/ToolsetValidator.js"; import { ModuleResolver } from "../mode/ModuleResolver.js"; import { DynamicToolManager } from "./DynamicToolManager.js"; import { registerMetaTools } from "../meta/registerMetaTools.js"; @@ -21,14 +21,16 @@ export class ServerOrchestrator { private readonly mode: Exclude; private readonly resolver: ModuleResolver; private readonly manager: DynamicToolManager; + private readonly toolsetValidator: ToolsetValidator; constructor(options: ServerOrchestratorOptions) { - const modeResolver = new ModeResolver(); + this.toolsetValidator = new ToolsetValidator(); const startup = options.startup ?? {}; - this.mode = startup.mode ?? "DYNAMIC"; + const resolved = this.resolveStartupConfig(startup, options.catalog); + this.mode = resolved.mode; this.resolver = new ModuleResolver({ catalog: options.catalog, - moduleLoaders: options.moduleLoaders as any, + moduleLoaders: options.moduleLoaders, }); const toolRegistry = new ToolRegistry({ namespaceWithToolset: @@ -49,7 +51,7 @@ export class ServerOrchestrator { } // Startup behavior - const initial = startup.toolsets; + const initial = resolved.toolsets; if (initial === "ALL") { void this.manager.enableToolsets(this.resolver.getAvailableToolsets()); } else if (Array.isArray(initial) && initial.length > 0) { @@ -57,6 +59,59 @@ export class ServerOrchestrator { } } + private resolveStartupConfig( + startup: { mode?: Exclude; toolsets?: string[] | "ALL" }, + catalog: ToolSetCatalog + ): { mode: Exclude; toolsets?: string[] | "ALL" } { + // Explicit mode dominates + if (startup.mode) { + if (startup.mode === "DYNAMIC" && startup.toolsets) { + console.warn("startup.toolsets provided but ignored in DYNAMIC mode"); + return { mode: "DYNAMIC" }; + } + if (startup.mode === "STATIC") { + if (startup.toolsets === "ALL") + return { mode: "STATIC", toolsets: "ALL" }; + const names = Array.isArray(startup.toolsets) ? startup.toolsets : []; + const valid: string[] = []; + for (const name of names) { + const { isValid, sanitized, error } = + this.toolsetValidator.validateToolsetName(name, catalog); + if (isValid && sanitized) valid.push(sanitized); + else if (error) console.warn(error); + } + if (names.length > 0 && valid.length === 0) { + throw new Error( + "STATIC mode requires valid toolsets or 'ALL'; none were valid" + ); + } + return { mode: "STATIC", toolsets: valid }; + } + return { mode: startup.mode }; + } + + // No explicit mode; infer from toolsets + if (startup.toolsets === "ALL") return { mode: "STATIC", toolsets: "ALL" }; + if (Array.isArray(startup.toolsets) && startup.toolsets.length > 0) { + const valid: string[] = []; + for (const name of startup.toolsets) { + const { isValid, sanitized, error } = + this.toolsetValidator.validateToolsetName(name, catalog); + if (isValid && sanitized) valid.push(sanitized); + else if (error) console.warn(error); + } + if (valid.length === 0) { + throw new Error( + "STATIC mode requires valid toolsets or 'ALL'; none were valid" + ); + } + return { mode: "STATIC", toolsets: valid }; + } + + // Default + return { mode: "DYNAMIC" }; + } + public getMode(): Exclude { return this.mode; } diff --git a/src/meta/registerMetaTools.ts b/src/meta/registerMetaTools.ts index da3f62a..f92e5ca 100644 --- a/src/meta/registerMetaTools.ts +++ b/src/meta/registerMetaTools.ts @@ -19,7 +19,7 @@ export function registerMetaTools( const result = await manager.enableToolset(name); return { content: [{ type: "text", text: JSON.stringify(result) }], - } as any; + }; } ); @@ -32,7 +32,7 @@ export function registerMetaTools( const result = await manager.disableToolset(name); return { content: [{ type: "text", text: JSON.stringify(result) }], - } as any; + }; } ); @@ -64,7 +64,7 @@ export function registerMetaTools( content: [ { type: "text", text: JSON.stringify({ toolsets: items }) }, ], - } as any; + }; } ); @@ -84,7 +84,7 @@ export function registerMetaTools( text: JSON.stringify({ error: `Unknown toolset '${name}'` }), }, ], - } as any; + }; } const payload = { key: name, @@ -99,7 +99,7 @@ export function registerMetaTools( }; return { content: [{ type: "text", text: JSON.stringify(payload) }], - } as any; + }; } ); } @@ -116,7 +116,7 @@ export function registerMetaTools( }; return { content: [{ type: "text", text: JSON.stringify(payload) }], - } as any; + }; } ); } diff --git a/src/mode/ModeResolver.ts b/src/mode/ModeResolver.ts index 0d36648..356d791 100644 --- a/src/mode/ModeResolver.ts +++ b/src/mode/ModeResolver.ts @@ -1,20 +1,24 @@ import type { Mode, ToolSetCatalog } from "../types/index.js"; -export interface ModeResolverKeys { +interface ModeResolverKeys { dynamic?: string[]; // keys that, when present/true, enable dynamic mode toolsets?: string[]; // keys that carry comma-separated toolsets } -export interface ModeResolverOptions { +interface ModeResolverOptions { keys?: ModeResolverKeys; } const DEFAULT_KEYS: Required = { - dynamic: ["dynamic-tool-discovery", "dynamicToolDiscovery", "DYNAMIC_TOOL_DISCOVERY"], + dynamic: [ + "dynamic-tool-discovery", + "dynamicToolDiscovery", + "DYNAMIC_TOOL_DISCOVERY", + ], toolsets: ["tool-sets", "toolSets", "FMP_TOOL_SETS"], }; -export class ModeResolver { +export class ToolsetValidator { private readonly keys: Required; constructor(options: ModeResolverOptions = {}) { @@ -24,7 +28,10 @@ export class ModeResolver { }; } - public resolveMode(env?: Record, args?: Record): Mode | null { + public resolveMode( + env?: Record, + args?: Record + ): Mode | null { // Check args first if (this.isDynamicEnabled(args)) return "DYNAMIC"; @@ -40,7 +47,10 @@ export class ModeResolver { return null; // no override } - public parseCommaSeparatedToolSets(input: string, catalog: ToolSetCatalog): string[] { + public parseCommaSeparatedToolSets( + input: string, + catalog: ToolSetCatalog + ): string[] { if (!input || typeof input !== "string") return []; const raw = input .split(",") @@ -51,12 +61,20 @@ export class ModeResolver { const result: string[] = []; for (const name of raw) { if (valid.has(name)) result.push(name); - else console.warn(`Invalid toolset '${name}' ignored. Available: ${Array.from(valid).join(", ")}`); + else + console.warn( + `Invalid toolset '${name}' ignored. Available: ${Array.from( + valid + ).join(", ")}` + ); } return result; } - public getModulesForToolSets(toolsets: string[], catalog: ToolSetCatalog): string[] { + public getModulesForToolSets( + toolsets: string[], + catalog: ToolSetCatalog + ): string[] { const modules = new Set(); for (const name of toolsets) { const def = catalog[name]; @@ -66,33 +84,64 @@ export class ModeResolver { return Array.from(modules); } - public validateToolsetName(name: unknown, catalog: ToolSetCatalog): { isValid: boolean; sanitized?: string; error?: string } { + public validateToolsetName( + name: unknown, + catalog: ToolSetCatalog + ): { isValid: boolean; sanitized?: string; error?: string } { if (!name || typeof name !== "string") { - return { isValid: false, error: `Invalid toolset name provided. Must be a non-empty string. Available toolsets: ${Object.keys(catalog).join(", ")}` }; + return { + isValid: false, + error: `Invalid toolset name provided. Must be a non-empty string. Available toolsets: ${Object.keys( + catalog + ).join(", ")}`, + }; } const sanitized = name.trim(); if (sanitized.length === 0) { - return { isValid: false, error: `Empty toolset name provided. Available toolsets: ${Object.keys(catalog).join(", ")}` }; + return { + isValid: false, + error: `Empty toolset name provided. Available toolsets: ${Object.keys( + catalog + ).join(", ")}`, + }; } if (!catalog[sanitized]) { - return { isValid: false, error: `Toolset '${sanitized}' not found. Available toolsets: ${Object.keys(catalog).join(", ")}` }; + return { + isValid: false, + error: `Toolset '${sanitized}' not found. Available toolsets: ${Object.keys( + catalog + ).join(", ")}`, + }; } return { isValid: true, sanitized }; } - public validateToolsetModules(toolsetNames: string[], catalog: ToolSetCatalog): { isValid: boolean; modules?: string[]; error?: string } { + public validateToolsetModules( + toolsetNames: string[], + catalog: ToolSetCatalog + ): { isValid: boolean; modules?: string[]; error?: string } { try { const modules = this.getModulesForToolSets(toolsetNames, catalog); if (!modules || modules.length === 0) { - return { isValid: false, error: `No modules found for toolsets: ${toolsetNames.join(", ")}` }; + return { + isValid: false, + error: `No modules found for toolsets: ${toolsetNames.join(", ")}`, + }; } return { isValid: true, modules }; } catch (error) { - return { isValid: false, error: `Error resolving modules for ${toolsetNames.join(", ")}: ${error instanceof Error ? error.message : "Unknown error"}` }; + return { + isValid: false, + error: `Error resolving modules for ${toolsetNames.join(", ")}: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + }; } } - private isDynamicEnabled(source?: Record | Record): boolean { + private isDynamicEnabled( + source?: Record | Record + ): boolean { if (!source) return false; for (const key of this.keys.dynamic) { const value = (source as any)[key]; @@ -105,13 +154,15 @@ export class ModeResolver { return false; } - private getToolsetsString(source?: Record | Record): string | undefined { + private getToolsetsString( + source?: Record | Record + ): string | undefined { if (!source) return undefined; for (const key of this.keys.toolsets) { const value = (source as any)[key]; - if (typeof value === "string" && value.trim().length > 0) return value as string; + if (typeof value === "string" && value.trim().length > 0) + return value as string; } return undefined; } } - diff --git a/src/mode/ToolsetValidator.ts b/src/mode/ToolsetValidator.ts new file mode 100644 index 0000000..889c754 --- /dev/null +++ b/src/mode/ToolsetValidator.ts @@ -0,0 +1 @@ +export * from "./ModeResolver.js"; diff --git a/src/server/createMcpServer.ts b/src/server/createMcpServer.ts index f4b9e09..ab33aeb 100644 --- a/src/server/createMcpServer.ts +++ b/src/server/createMcpServer.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ExposurePolicy, Mode, ToolSetCatalog } from "../types/index.js"; import { ServerOrchestrator } from "../core/ServerOrchestrator.js"; import { @@ -14,34 +14,20 @@ export interface CreateMcpServerOptions { startup?: { mode?: Exclude; toolsets?: string[] | "ALL" }; registerMetaTools?: boolean; http?: FastifyTransportOptions; - mcp?: { - name?: string; - version?: string; - capabilities?: Record; - }; + /** Factory to create an MCP server instance. Required. + * In DYNAMIC mode, a new instance is created per client bundle. + * In STATIC mode, a single instance is created and reused across bundles. + */ + createServer: () => McpServer; configSchema?: object; } export async function createMcpServer(options: CreateMcpServerOptions) { const mode: Exclude = options.startup?.mode ?? "DYNAMIC"; - const name = options.mcp?.name ?? "mcp-dynamic-tooling"; - const version = options.mcp?.version ?? "0.0.0"; - const baseCaps = options.mcp?.capabilities ?? {}; - const mergedCaps = { - ...baseCaps, - tools: { - ...(typeof (baseCaps as any).tools === "object" - ? (baseCaps as any).tools - : {}), - // listChanged is internal-only and computed by mode - listChanged: mode === "DYNAMIC", - }, - } as any; - const server = new McpServer({ - name, - version, - capabilities: mergedCaps, - }); + if (typeof options.createServer !== "function") { + throw new Error("createMcpServer: `createServer` (factory) is required"); + } + const baseServer: McpServer = options.createServer(); // Typed, guarded notifier type NotifierA = { @@ -67,12 +53,12 @@ export async function createMcpServer(options: CreateMcpServerOptions) { }; const orchestrator = new ServerOrchestrator({ - server, + server: baseServer, catalog: options.catalog, moduleLoaders: options.moduleLoaders, exposurePolicy: options.exposurePolicy, context: options.context, - notifyToolsListChanged: async () => notifyToolsChanged(server), + notifyToolsListChanged: async () => notifyToolsChanged(baseServer), startup: options.startup, registerMetaTools: options.registerMetaTools !== undefined @@ -83,47 +69,31 @@ export async function createMcpServer(options: CreateMcpServerOptions) { const transport = new FastifyTransport( orchestrator.getManager(), () => { - // Create a fresh server + orchestrator bundle for a new client when needed - const innerMode: Exclude = - options.startup?.mode ?? "DYNAMIC"; - const innerName = options.mcp?.name ?? name; - const innerVersion = options.mcp?.version ?? version; - const innerBaseCaps = options.mcp?.capabilities ?? baseCaps; - const innerMergedCaps = { - ...innerBaseCaps, - tools: { - ...(typeof (innerBaseCaps as any).tools === "object" - ? (innerBaseCaps as any).tools - : {}), - listChanged: innerMode === "DYNAMIC", - }, - } as any; - const server = new McpServer({ - name: innerName, - version: innerVersion, - capabilities: innerMergedCaps, - }); + // Create a server + orchestrator bundle + // for a new client when needed + const createdServer: McpServer = + mode === "DYNAMIC" ? options.createServer() : baseServer; const orchestrator = new ServerOrchestrator({ - server, + server: createdServer, catalog: options.catalog, moduleLoaders: options.moduleLoaders, exposurePolicy: options.exposurePolicy, context: options.context, - notifyToolsListChanged: async () => notifyToolsChanged(server), + notifyToolsListChanged: async () => notifyToolsChanged(createdServer), startup: options.startup, registerMetaTools: options.registerMetaTools !== undefined ? options.registerMetaTools - : innerMode === "DYNAMIC", + : mode === "DYNAMIC", }); - return { server, orchestrator }; + return { server: createdServer, orchestrator }; }, options.http, options.configSchema ); return { - server, + server: baseServer, start: async () => { await transport.start(); }, diff --git a/tests/createMcpServer.test.ts b/tests/createMcpServer.test.ts index 53bb1eb..09d28a1 100644 --- a/tests/createMcpServer.test.ts +++ b/tests/createMcpServer.test.ts @@ -1,19 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -// Mock SDK McpServer to capture constructor args -vi.mock("@modelcontextprotocol/sdk/server/mcp.js", () => { - return { - McpServer: class McpServerMock { - public static lastArgs: any; - public server: any = {}; - constructor(args: any) { - (McpServerMock as any).lastArgs = args; - } - tool() {} - async connect() {} - }, - }; -}); +// We no longer construct McpServer inside the library; provide a simple fake // Mock FastifyTransport to capture constructor args and avoid opening sockets vi.mock("../src/http/FastifyTransport.js", () => { @@ -29,7 +16,6 @@ vi.mock("../src/http/FastifyTransport.js", () => { }; }); -import { McpServer as McpServerMock } from "@modelcontextprotocol/sdk/server/mcp.js"; import { FastifyTransport as FastifyTransportMock } from "../src/http/FastifyTransport.js"; import { createMcpServer } from "../src/server/createMcpServer.js"; @@ -37,20 +23,50 @@ const catalog = { core: { name: "Core", description: "", tools: [] }, } as any; +function makeFakeServer() { + const calls: string[] = []; + const server = { + tool: (name: string) => { + calls.push(name); + }, + } as any; + return { server, calls }; +} + +function makeFakeServerFactory() { + const created: Array<{ server: any; calls: string[] }> = []; + const createServer = () => { + const s = makeFakeServer(); + created.push(s); + return s.server; + }; + return { createServer, created } as const; +} + describe("createMcpServer", () => { beforeEach(() => { - (McpServerMock as any).lastArgs = undefined; (FastifyTransportMock as any).lastArgs = undefined; }); - it("sets listChanged true in dynamic mode, false in static mode", async () => { - await createMcpServer({ catalog, startup: { mode: "DYNAMIC" } }); - const dyn = (McpServerMock as any).lastArgs; - expect(dyn.capabilities.tools.listChanged).toBe(true); + it("registers meta-tools by default in dynamic mode, not in static mode", async () => { + const d = makeFakeServerFactory(); + await createMcpServer({ + catalog, + startup: { mode: "DYNAMIC" }, + createServer: d.createServer, + }); + const baseDyn = d.created[0]; + expect(baseDyn.calls.includes("list_tools")).toBe(true); + expect(baseDyn.calls.includes("list_toolsets")).toBe(true); - await createMcpServer({ catalog, startup: { mode: "STATIC" } }); - const stat = (McpServerMock as any).lastArgs; - expect(stat.capabilities.tools.listChanged).toBe(false); + const s = makeFakeServerFactory(); + await createMcpServer({ + catalog, + startup: { mode: "STATIC" }, + createServer: s.createServer, + }); + const baseStat = s.created[0]; + expect(baseStat.calls.length).toBe(0); }); it("passes configSchema to FastifyTransport constructor", async () => { @@ -58,13 +74,54 @@ describe("createMcpServer", () => { type: "object", properties: { FOO: { type: "string" } }, }; + const { createServer } = makeFakeServerFactory(); await createMcpServer({ catalog, startup: { mode: "DYNAMIC" }, + createServer, configSchema, }); const args = (FastifyTransportMock as any).lastArgs; // args: [manager, createBundle, httpOptions, configSchema] expect(args?.[3]).toEqual(configSchema); }); + + it("reuses a single instance in STATIC mode bundles", async () => { + const f = makeFakeServerFactory(); + await createMcpServer({ + catalog, + startup: { mode: "STATIC" }, + createServer: f.createServer, + }); + const bundleFactory = (FastifyTransportMock as any).lastArgs?.[1]; + const b1 = bundleFactory(); + const b2 = bundleFactory(); + expect(b1.server).toBe(b2.server); + }); + + it("creates a fresh instance per bundle in DYNAMIC mode", async () => { + const factory = makeFakeServerFactory(); + await createMcpServer({ + catalog, + startup: { mode: "DYNAMIC" }, + createServer: factory.createServer, + }); + // base server created immediately + expect(factory.created.length).toBe(1); + const base = factory.created[0]; + expect(base.calls.includes("list_tools")).toBe(true); + + // per-client bundle uses a fresh server + const bundleFactory = (FastifyTransportMock as any).lastArgs?.[1]; + const b1 = bundleFactory(); + const b2 = bundleFactory(); + expect(factory.created.length).toBe(3); + const s1 = factory.created[1]; + const s2 = factory.created[2]; + expect(b1.server).toBe(s1.server); + expect(b2.server).toBe(s2.server); + expect(b1.server).not.toBe(b2.server); + expect(s1.calls.includes("list_tools")).toBe(true); + expect(s2.calls.includes("list_tools")).toBe(true); + }); }); diff --git a/tests/dynamicToolManager.test.ts b/tests/dynamicToolManager.test.ts index 438c7dd..ed6e517 100644 --- a/tests/dynamicToolManager.test.ts +++ b/tests/dynamicToolManager.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { DynamicToolManager } from "../src/core/DynamicToolManager.js"; import { ModuleResolver } from "../src/mode/ModuleResolver.js"; import { ToolRegistry } from "../src/core/ToolRegistry.js"; @@ -65,6 +65,56 @@ describe("DynamicToolManager", () => { expect((await manager.enableToolset("ext")).success).toBe(false); // exceeds max }); + it("returns validation error for unknown toolset and handles resolver failure", async () => { + const { server } = createFakeMcpServer(); + const resolver = new ModuleResolver({ catalog }); + const manager = new DynamicToolManager({ server, resolver }); + // Unknown toolset + const bad = await manager.enableToolset("does-not-exist"); + expect(bad.success).toBe(false); + expect(bad.message).toMatch(/not found|Invalid/); + + // Force resolver failure + vi.spyOn(resolver, "resolveToolsForToolsets").mockRejectedValue( + new Error("loader exploded") + ); + const err = await manager.enableToolset("core"); + expect(err.success).toBe(false); + expect(err.message).toMatch(/loader exploded/); + }); + + it("disableToolset validates input and warns when notify fails", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { server } = createFakeMcpServer(); + const resolver = new ModuleResolver({ catalog }); + const manager = new DynamicToolManager({ + server, + resolver, + // simulate notify failing + onToolsListChanged: async () => { + throw new Error("notify failed"); + }, + }); + + // invalid disable + const invalid = await manager.disableToolset(""); + expect(invalid.success).toBe(false); + + // not active + const notActive = await manager.disableToolset("core"); + expect(notActive.success).toBe(false); + + // enable then disable, hitting notify warning path + await manager.enableToolset("core"); + const res = await manager.disableToolset("core"); + expect(res.success).toBe(true); + expect(warn).toHaveBeenCalledWith( + "Failed to send tool list change notification:", + expect.any(Error) + ); + warn.mockRestore(); + }); + it("disableToolset updates state and returns message", async () => { const { server } = createFakeMcpServer(); const resolver = new ModuleResolver({ catalog }); diff --git a/tests/serverOrchestrator.test.ts b/tests/serverOrchestrator.test.ts index 08901e0..32f357d 100644 --- a/tests/serverOrchestrator.test.ts +++ b/tests/serverOrchestrator.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { ServerOrchestrator } from "../src/core/ServerOrchestrator.js"; import { createFakeMcpServer } from "./helpers/fakes.js"; @@ -72,4 +72,33 @@ describe("ServerOrchestrator", () => { expect(names).toContain("enable_toolset"); expect(names).toContain("disable_toolset"); }); + + it("ignores toolsets in DYNAMIC mode with a warning", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { server, tools } = createFakeMcpServer(); + new ServerOrchestrator({ + server, + catalog: catalog as any, + startup: { mode: "DYNAMIC", toolsets: ["core", "ext"] }, + }); + await new Promise((r) => setTimeout(r, 0)); + const names = tools.map((t) => t.name); + expect(names).not.toContain("core.ping"); + expect(names).not.toContain("ext.echo"); + expect(warn).toHaveBeenCalledWith( + "startup.toolsets provided but ignored in DYNAMIC mode" + ); + warn.mockRestore(); + }); + + it("throws in STATIC mode when toolsets are invalid/empty", async () => { + expect( + () => + new ServerOrchestrator({ + server: createFakeMcpServer().server, + catalog: catalog as any, + startup: { toolsets: ["nope"], mode: "STATIC" }, + }) + ).toThrow(/STATIC mode requires valid toolsets or 'ALL'; none were valid/); + }); }); diff --git a/tests/smoke-e2e/README.md b/tests/smoke-e2e/README.md index 66310dc..442da59 100644 --- a/tests/smoke-e2e/README.md +++ b/tests/smoke-e2e/README.md @@ -8,6 +8,10 @@ This directory contains a runnable MCP server and client to smoke-test the HTTP/ ```bash npm run dev:server-demo ``` +- Run in STATIC mode (preload ALL toolsets): + ```bash + STARTUP_MODE=STATIC TOOLSETS=ALL npm run dev:server-demo + ``` - Or directly with tsx: ```bash npx --yes tsx tests/smoke-e2e/server-demo.ts diff --git a/tests/smoke-e2e/client-demo.ts b/tests/smoke-e2e/client-demo.ts index 95b4b7a..e66454a 100644 --- a/tests/smoke-e2e/client-demo.ts +++ b/tests/smoke-e2e/client-demo.ts @@ -24,30 +24,48 @@ async function main() { const listBefore = await client.listTools(); console.log("tools before:", JSON.stringify(listBefore, null, 2)); - await client.callTool({ - name: "enable_toolset", - arguments: { name: "core" }, - } as any); + const toolNamesBefore = new Set( + (listBefore as any)?.tools?.map((t: any) => t.name) ?? [] + ); + if (!toolNamesBefore.has("core.ping")) { + await client.callTool({ + name: "enable_toolset", + arguments: { name: "core" }, + } as any); + } const ping = await client.callTool({ name: "core.ping", arguments: {}, } as any); console.log("core.ping:", JSON.stringify(ping, null, 2)); + const pingText = (ping as any)?.content?.[0]?.text ?? ""; + if (!String(pingText).toLowerCase().includes("pong")) { + throw new Error( + "Smoke check failed: core.ping did not return expected text" + ); + } - await client.callTool({ - name: "enable_toolset", - arguments: { name: "ext" }, - } as any); + if (!toolNamesBefore.has("ext.echo")) { + await client.callTool({ + name: "enable_toolset", + arguments: { name: "ext" }, + } as any); + } const echo = await client.callTool({ name: "ext.echo", arguments: { text: "hello" }, } as any); console.log("ext.echo:", JSON.stringify(echo, null, 2)); + const echoText = (echo as any)?.content?.[0]?.text ?? ""; + if (String(echoText) !== "hello") { + throw new Error("Smoke check failed: ext.echo did not echo expected text"); + } const listAfter = await client.listTools(); console.log("tools after:", JSON.stringify(listAfter, null, 2)); await client.close(); + console.log("Smoke test OK"); } main().catch((err) => { diff --git a/tests/smoke-e2e/server-demo.ts b/tests/smoke-e2e/server-demo.ts index 0845b62..770e091 100644 --- a/tests/smoke-e2e/server-demo.ts +++ b/tests/smoke-e2e/server-demo.ts @@ -1,5 +1,6 @@ // Run with: npx --yes tsx tests/smoke-e2e/server-demo.ts import { createMcpServer } from "../../src/server/createMcpServer.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ToolSetCatalog, ModuleLoader } from "../../src/types/index.js"; import { z } from "zod"; @@ -36,12 +37,28 @@ const moduleLoaders: Record = { const PORT = Number(process.env.PORT ?? 3003); +// Provide SDK server instances externally +const createServer = () => + new McpServer({ + name: "toolception-server-demo", + version: "0.1.0", + capabilities: { tools: { listChanged: true } }, + }); + +const STATIC = (process.env.STARTUP_MODE || "").toUpperCase() === "STATIC"; + const { start, close } = await createMcpServer({ catalog, moduleLoaders, - startup: { mode: "DYNAMIC" }, + startup: STATIC + ? { + mode: "STATIC", + toolsets: + (process.env.TOOLSETS as any) === "ALL" ? "ALL" : ["core", "ext"], + } + : { mode: "DYNAMIC" }, http: { port: PORT }, - mcp: { name: "toolception-server-demo", version: "0.1.0" }, + createServer, configSchema: { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", diff --git a/tests/modeResolver.test.ts b/tests/toolsetValidator.test.ts similarity index 77% rename from tests/modeResolver.test.ts rename to tests/toolsetValidator.test.ts index ef37cd2..178dbbb 100644 --- a/tests/modeResolver.test.ts +++ b/tests/toolsetValidator.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from "vitest"; -import { ModeResolver } from "../src/mode/ModeResolver.js"; +import { ToolsetValidator } from "../src/mode/ToolsetValidator.js"; -describe("ModeResolver", () => { +describe("ToolsetValidator", () => { it("detects dynamic from args/env", () => { - const r = new ModeResolver(); + const r = new ToolsetValidator(); expect(r.resolveMode(undefined, { DYNAMIC_TOOL_DISCOVERY: "true" })).toBe( "DYNAMIC" ); @@ -13,13 +13,13 @@ describe("ModeResolver", () => { }); it("detects static when toolsets present", () => { - const r = new ModeResolver(); + const r = new ToolsetValidator(); expect(r.resolveMode(undefined, { FMP_TOOL_SETS: "a,b" })).toBe("STATIC"); expect(r.resolveMode({ FMP_TOOL_SETS: "a" }, undefined)).toBe("STATIC"); }); it("parses comma separated toolsets and validates", () => { - const r = new ModeResolver(); + const r = new ToolsetValidator(); const catalog = { a: {} as any, b: {} as any }; expect(r.parseCommaSeparatedToolSets("a,b,c", catalog as any)).toEqual([ "a", diff --git a/vitest.config.ts b/vitest.config.ts index 2df950c..f3d5a89 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,9 +4,11 @@ export default defineConfig({ test: { coverage: { exclude: [ + "tests/**", "examples/**", "vite.config.ts", "vitest.config.ts", + "src/types/**", "src/index.ts", ], },