UNPKG

toolception

Version:

Dynamic MCP server toolkit for runtime toolset management with Fastify transport and meta-tools

731 lines (730 loc) 23.3 kB
import { z as f } from "zod"; import p from "fastify"; import A from "@fastify/cors"; import { randomUUID as m } from "node:crypto"; import { StreamableHTTPServerTransport as w } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest as b } from "@modelcontextprotocol/sdk/types.js"; const g = { dynamic: [ "dynamic-tool-discovery", "dynamicToolDiscovery", "DYNAMIC_TOOL_DISCOVERY" ], toolsets: ["tool-sets", "toolSets", "FMP_TOOL_SETS"] }; class S { constructor(e = {}) { this.keys = { dynamic: e.keys?.dynamic ?? g.dynamic, toolsets: e.keys?.toolsets ?? g.toolsets }; } resolveMode(e, s) { return this.isDynamicEnabled(s) ? "DYNAMIC" : this.getToolsetsString(s) ? "STATIC" : this.isDynamicEnabled(e) ? "DYNAMIC" : this.getToolsetsString(e) ? "STATIC" : null; } parseCommaSeparatedToolSets(e, s) { if (!e || typeof e != "string") return []; const t = e.split(",").map((i) => i.trim()).filter((i) => i.length > 0), o = new Set(Object.keys(s)), r = []; for (const i of t) o.has(i) ? r.push(i) : console.warn( `Invalid toolset '${i}' ignored. Available: ${Array.from( o ).join(", ")}` ); return r; } getModulesForToolSets(e, s) { const t = /* @__PURE__ */ new Set(); for (const o of e) { const r = s[o]; r && (r.modules || []).forEach((i) => t.add(i)); } return Array.from(t); } validateToolsetName(e, s) { if (!e || typeof e != "string") return { isValid: !1, error: `Invalid toolset name provided. Must be a non-empty string. Available toolsets: ${Object.keys( s ).join(", ")}` }; const t = e.trim(); return t.length === 0 ? { isValid: !1, error: `Empty toolset name provided. Available toolsets: ${Object.keys( s ).join(", ")}` } : s[t] ? { isValid: !0, sanitized: t } : { isValid: !1, error: `Toolset '${t}' not found. Available toolsets: ${Object.keys( s ).join(", ")}` }; } validateToolsetModules(e, s) { try { const t = this.getModulesForToolSets(e, s); return !t || t.length === 0 ? { isValid: !1, error: `No modules found for toolsets: ${e.join(", ")}` } : { isValid: !0, modules: t }; } catch (t) { return { isValid: !1, error: `Error resolving modules for ${e.join(", ")}: ${t instanceof Error ? t.message : "Unknown error"}` }; } } isDynamicEnabled(e) { if (!e) return !1; for (const s of this.keys.dynamic) { const t = e[s]; if (t === !0 || typeof t == "string" && t.trim().toLowerCase() === "true") return !0; } return !1; } getToolsetsString(e) { if (e) for (const s of this.keys.toolsets) { const t = e[s]; if (typeof t == "string" && t.trim().length > 0) return t; } } } class x { constructor(e) { this.catalog = e.catalog, this.moduleLoaders = e.moduleLoaders ?? {}; } getAvailableToolsets() { return Object.keys(this.catalog); } getToolsetDefinition(e) { return this.catalog[e]; } validateToolsetName(e) { if (!e || typeof e != "string") return { isValid: !1, error: `Invalid toolset name provided. Must be a non-empty string. Available toolsets: ${this.getAvailableToolsets().join( ", " )}` }; const s = e.trim(); return s.length === 0 ? { isValid: !1, error: `Empty toolset name provided. Available toolsets: ${this.getAvailableToolsets().join( ", " )}` } : this.catalog[s] ? { isValid: !0, sanitized: s } : { isValid: !1, error: `Toolset '${s}' not found. Available toolsets: ${this.getAvailableToolsets().join( ", " )}` }; } async resolveToolsForToolsets(e, s) { const t = []; for (const o of e) { const r = this.catalog[o]; if (r && (Array.isArray(r.tools) && r.tools.length > 0 && t.push(...r.tools), Array.isArray(r.modules) && r.modules.length > 0)) for (const i of r.modules) { const l = this.moduleLoaders[i]; if (l) try { const n = await l(s); Array.isArray(n) && n.length > 0 && t.push(...n); } catch (n) { console.warn( `Module loader '${i}' failed for toolset '${o}':`, n ); } } } return t; } } class T extends Error { constructor(e, s, t, o) { super(e), this.name = "ToolingError", this.code = s, this.details = t; } } class v { constructor(e = {}) { this.names = /* @__PURE__ */ new Set(), this.toolsetToNames = /* @__PURE__ */ new Map(), this.options = { namespaceWithToolset: e.namespaceWithToolset ?? !0 }; } getSafeName(e, s) { return !this.options.namespaceWithToolset || s.startsWith(`${e}.`) ? s : `${e}.${s}`; } has(e) { return this.names.has(e); } add(e) { if (this.names.has(e)) throw new T( `Tool name collision: '${e}' already registered`, "E_TOOL_NAME_CONFLICT" ); this.names.add(e); } addForToolset(e, s) { this.add(s); const t = this.toolsetToNames.get(e) ?? /* @__PURE__ */ new Set(); t.add(s), this.toolsetToNames.set(e, t); } mapAndValidate(e, s) { return s.map((t) => { const o = this.getSafeName(e, t.name); if (this.has(o)) throw new T( `Tool name collision for '${o}'`, "E_TOOL_NAME_CONFLICT" ); return { ...t, name: o }; }); } list() { return Array.from(this.names); } listByToolset() { const e = {}; for (const [s, t] of this.toolsetToNames.entries()) e[s] = Array.from(t); return e; } } class C { constructor(e) { this.activeToolsets = /* @__PURE__ */ new Set(), this.server = e.server, this.resolver = e.resolver, this.context = e.context, this.onToolsListChanged = e.onToolsListChanged, this.exposurePolicy = e.exposurePolicy, this.toolRegistry = e.toolRegistry ?? new v({ namespaceWithToolset: !0 }); } getAvailableToolsets() { return this.resolver.getAvailableToolsets(); } getActiveToolsets() { return Array.from(this.activeToolsets); } getToolsetDefinition(e) { return this.resolver.getToolsetDefinition(e); } isActive(e) { return this.activeToolsets.has(e); } async enableToolset(e) { const s = this.resolver.validateToolsetName(e); if (!s.isValid || !s.sanitized) return { success: !1, message: s.error || "Unknown validation error" }; const t = s.sanitized; if (this.activeToolsets.has(t)) return { success: !1, message: `Toolset '${t}' is already enabled.` }; try { const o = await this.resolver.resolveToolsForToolsets( [t], this.context ); if (this.exposurePolicy?.allowlist && !this.exposurePolicy.allowlist.includes(t)) return { success: !1, message: `Toolset '${t}' is not allowed by policy.` }; if (this.exposurePolicy?.denylist && this.exposurePolicy.denylist.includes(t)) return { success: !1, message: `Toolset '${t}' is denied by policy.` }; if (this.exposurePolicy?.maxActiveToolsets !== void 0 && this.activeToolsets.size + 1 > this.exposurePolicy.maxActiveToolsets) return this.exposurePolicy.onLimitExceeded?.( [t], Array.from(this.activeToolsets) ), { success: !1, message: `Activation exceeds maxActiveToolsets (${this.exposurePolicy.maxActiveToolsets}).` }; if (o && o.length > 0) { const r = this.toolRegistry.mapAndValidate( t, o ); this.registerDirectTools(r, t); } this.activeToolsets.add(t); try { await this.onToolsListChanged?.(); } catch (r) { console.warn("Failed to send tool list change notification:", r); } return { success: !0, message: `Toolset '${t}' enabled successfully. Registered ${o?.length ?? 0} tools.` }; } catch (o) { return this.activeToolsets.delete(t), { success: !1, message: `Failed to enable toolset '${t}': ${o instanceof Error ? o.message : "Unknown error"}` }; } } async disableToolset(e) { const s = this.resolver.validateToolsetName(e); if (!s.isValid || !s.sanitized) { const o = Array.from(this.activeToolsets).join(", ") || "none"; return { success: !1, message: `${s.error || "Unknown validation error"} Active toolsets: ${o}` }; } const t = s.sanitized; if (!this.activeToolsets.has(t)) return { success: !1, message: `Toolset '${t}' is not currently active. Active toolsets: ${Array.from(this.activeToolsets).join(", ") || "none"}` }; this.activeToolsets.delete(t); try { await this.onToolsListChanged?.(); } catch (o) { console.warn("Failed to send tool list change notification:", o); } return { success: !0, message: `Toolset '${t}' disabled successfully. Individual tools remain registered due to MCP limitations.` }; } getStatus() { return { availableToolsets: this.getAvailableToolsets(), activeToolsets: this.getActiveToolsets(), registeredModules: [], totalToolsets: this.getAvailableToolsets().length, activeCount: this.activeToolsets.size, tools: this.toolRegistry.list(), toolsetToTools: this.toolRegistry.listByToolset() }; } async enableToolsets(e) { const s = []; for (const r of e) try { const i = await this.enableToolset(r); s.push({ name: r, ...i }); } catch (i) { s.push({ name: r, success: !1, message: i instanceof Error ? i.message : "Unknown error", code: "E_INTERNAL" }); } const t = s.every((r) => r.success), o = t ? "All toolsets enabled" : "Some toolsets failed to enable"; if (s.length > 0) try { await this.onToolsListChanged?.(); } catch { } return { success: t, results: s, message: o }; } registerDirectTools(e, s) { for (const t of e) try { this.server.tool( t.name, t.description, t.inputSchema, async (o) => await t.handler(o) ), s ? this.toolRegistry.addForToolset(s, t.name) : this.toolRegistry.add(t.name); } catch (o) { throw console.error(`Failed to register direct tool '${t.name}':`, o), o; } } async enableAllToolsets() { const e = this.getAvailableToolsets(); return this.enableToolsets(e); } } function I(a, e, s) { const t = s?.mode ?? "DYNAMIC"; a.tool( "enable_toolset", "Enable a toolset by name", { name: f.string().describe("Toolset name") }, async (o) => { const { name: r } = o, i = await e.enableToolset(r); return { content: [{ type: "text", text: JSON.stringify(i) }] }; } ), a.tool( "disable_toolset", "Disable a toolset by name (state only)", { name: f.string().describe("Toolset name") }, async (o) => { const { name: r } = o, i = await e.disableToolset(r); return { content: [{ type: "text", text: JSON.stringify(i) }] }; } ), t === "DYNAMIC" && (a.tool( "list_toolsets", "List available toolsets with active status and definitions", {}, async () => { const o = e.getAvailableToolsets(), r = e.getStatus().toolsetToTools, i = o.map((l) => { const n = e.getToolsetDefinition(l); return { key: l, active: e.isActive(l), definition: n ? { name: n.name, description: n.description, modules: n.modules ?? [], decisionCriteria: n.decisionCriteria ?? void 0 } : null, tools: r[l] ?? [] }; }); return { content: [ { type: "text", text: JSON.stringify({ toolsets: i }) } ] }; } ), a.tool( "describe_toolset", "Describe a toolset with definition, active status and tools", { name: f.string().describe("Toolset name") }, async (o) => { const { name: r } = o, i = e.getToolsetDefinition(r), l = e.getStatus().toolsetToTools; if (!i) return { content: [ { type: "text", text: JSON.stringify({ error: `Unknown toolset '${r}'` }) } ] }; const n = { key: r, active: e.isActive(r), definition: { name: i.name, description: i.description, modules: i.modules ?? [], decisionCriteria: i.decisionCriteria ?? void 0 }, tools: l[r] ?? [] }; return { content: [{ type: "text", text: JSON.stringify(n) }] }; } )), a.tool( "list_tools", "List currently registered tool names (best effort)", {}, async () => { const o = e.getStatus(), r = { tools: o.tools, toolsetToTools: o.toolsetToTools }; return { content: [{ type: "text", text: JSON.stringify(r) }] }; } ); } class y { constructor(e) { this.toolsetValidator = new S(); const s = e.startup ?? {}, t = this.resolveStartupConfig(s, e.catalog); this.mode = t.mode, this.resolver = new x({ catalog: e.catalog, moduleLoaders: e.moduleLoaders }); const o = new v({ namespaceWithToolset: e.exposurePolicy?.namespaceToolsWithSetKey ?? !0 }); this.manager = new C({ server: e.server, resolver: this.resolver, context: e.context, onToolsListChanged: e.notifyToolsListChanged, exposurePolicy: e.exposurePolicy, toolRegistry: o }), e.registerMetaTools !== !1 && I(e.server, this.manager, { mode: this.mode }); const r = t.toolsets; r === "ALL" ? this.manager.enableToolsets(this.resolver.getAvailableToolsets()) : Array.isArray(r) && r.length > 0 && this.manager.enableToolsets(r); } resolveStartupConfig(e, s) { if (e.mode) { if (e.mode === "DYNAMIC" && e.toolsets) return console.warn("startup.toolsets provided but ignored in DYNAMIC mode"), { mode: "DYNAMIC" }; if (e.mode === "STATIC") { if (e.toolsets === "ALL") return { mode: "STATIC", toolsets: "ALL" }; const t = Array.isArray(e.toolsets) ? e.toolsets : [], o = []; for (const r of t) { const { isValid: i, sanitized: l, error: n } = this.toolsetValidator.validateToolsetName(r, s); i && l ? o.push(l) : n && console.warn(n); } if (t.length > 0 && o.length === 0) throw new Error( "STATIC mode requires valid toolsets or 'ALL'; none were valid" ); return { mode: "STATIC", toolsets: o }; } return { mode: e.mode }; } if (e.toolsets === "ALL") return { mode: "STATIC", toolsets: "ALL" }; if (Array.isArray(e.toolsets) && e.toolsets.length > 0) { const t = []; for (const o of e.toolsets) { const { isValid: r, sanitized: i, error: l } = this.toolsetValidator.validateToolsetName(o, s); r && i ? t.push(i) : l && console.warn(l); } if (t.length === 0) throw new Error( "STATIC mode requires valid toolsets or 'ALL'; none were valid" ); return { mode: "STATIC", toolsets: t }; } return { mode: "DYNAMIC" }; } getMode() { return this.mode; } getManager() { return this.manager; } } class M { constructor(e = {}) { this.storage = /* @__PURE__ */ new Map(), this.maxSize = e.maxSize ?? 1e3, this.ttlMs = e.ttlMs ?? 1e3 * 60 * 60; const s = e.pruneIntervalMs ?? 1e3 * 60 * 10; this.pruneInterval = setInterval(() => this.pruneExpired(), s); } getEntryCount() { return this.storage.size; } getMaxSize() { return this.maxSize; } getTtl() { return this.ttlMs; } get(e) { const s = this.storage.get(e); return s ? Date.now() - s.lastAccessed > this.ttlMs ? (this.delete(e), null) : (s.lastAccessed = Date.now(), this.storage.delete(e), this.storage.set(e, s), s.resource) : null; } set(e, s) { this.storage.size >= this.maxSize && this.evictLeastRecentlyUsed(); const t = { resource: s, lastAccessed: Date.now() }; this.storage.set(e, t); } delete(e) { this.storage.delete(e); } stop() { this.pruneInterval && (clearInterval(this.pruneInterval), this.pruneInterval = void 0); } evictLeastRecentlyUsed() { const e = this.storage.keys().next().value; e && this.delete(e); } pruneExpired() { const e = Date.now(); for (const [s, t] of this.storage.entries()) e - t.lastAccessed > this.ttlMs && this.delete(s); } } class L { constructor(e, s, t = {}, o) { this.app = null, this.clientCache = new M(), this.defaultManager = e, this.createBundle = s, this.options = { host: t.host ?? "0.0.0.0", port: t.port ?? 3e3, basePath: t.basePath ?? "/", cors: t.cors ?? !0, logger: t.logger ?? !1, app: t.app }, this.configSchema = o; } async start() { if (this.app) return; const e = this.options.app ?? p({ logger: this.options.logger }); this.options.cors && await e.register(A, { origin: !0 }); const s = this.options.basePath.endsWith("/") ? this.options.basePath.slice(0, -1) : this.options.basePath; e.get(`${s}/healthz`, async () => ({ ok: !0 })), e.get(`${s}/tools`, async () => this.defaultManager.getStatus()), e.get(`${s}/.well-known/mcp-config`, async (t, o) => (o.header("Content-Type", "application/schema+json; charset=utf-8"), this.configSchema ?? { $schema: "https://json-schema.org/draft/2020-12/schema", title: "MCP Session Configuration", description: "Schema for the /mcp endpoint configuration", type: "object", properties: {}, required: [], "x-mcp-version": "1.0", "x-query-style": "dot+bracket" })), e.post( `${s}/mcp`, async (t, o) => { const r = t.headers["mcp-client-id"]?.trim(), i = r && r.length > 0 ? r : `anon-${m()}`, l = !i.startsWith("anon-"); let n = l ? this.clientCache.get(i) : null; if (!n) { const h = this.createBundle(), u = h.sessions; n = { server: h.server, orchestrator: h.orchestrator, sessions: u instanceof Map ? u : /* @__PURE__ */ new Map() }, l && this.clientCache.set(i, n); } const c = t.headers["mcp-session-id"]; let d; if (c && n.sessions.get(c)) d = n.sessions.get(c); else if (!c && b(t.body)) { const h = m(); d = new w({ sessionIdGenerator: () => h, onsessioninitialized: (u) => { n.sessions.set(u, d); } }); try { await n.server.connect(d); } catch { return o.code(500), { jsonrpc: "2.0", error: { code: -32603, message: "Error initializing server." }, id: null }; } d.onclose = () => { d?.sessionId && n.sessions.delete(d.sessionId); }; } else return o.code(400), { jsonrpc: "2.0", error: { code: -32e3, message: "Session not found or expired" }, id: null }; return await d.handleRequest( t.raw, o.raw, t.body ), o; } ), e.get(`${s}/mcp`, async (t, o) => { const r = t.headers["mcp-client-id"]?.trim(), i = r && r.length > 0 ? r : ""; if (!i) return o.code(400), "Missing mcp-client-id"; const l = this.clientCache.get(i); if (!l) return o.code(400), "Invalid or expired client"; const n = t.headers["mcp-session-id"]; if (!n) return o.code(400), "Missing mcp-session-id"; const c = l.sessions.get(n); return c ? (await c.handleRequest(t.raw, o.raw), o) : (o.code(400), "Invalid or expired session ID"); }), e.delete( `${s}/mcp`, async (t, o) => { const r = t.headers["mcp-client-id"]?.trim(), i = r && r.length > 0 ? r : "", l = t.headers["mcp-session-id"]; if (!i || !l) return o.code(400), { jsonrpc: "2.0", error: { code: -32600, message: "Missing mcp-client-id or mcp-session-id header" }, id: null }; const n = this.clientCache.get(i), c = n?.sessions.get(l); if (!n || !c) return o.code(404), { jsonrpc: "2.0", error: { code: -32e3, message: "Session not found or expired" }, id: null }; try { if (typeof c.close == "function") try { await c.close(); } catch { } } finally { c?.sessionId ? n.sessions.delete(c.sessionId) : n.sessions.delete(l); } return o.code(204).send(), o; } ), this.options.app || await e.listen({ host: this.options.host, port: this.options.port }), this.app = e; } async stop() { this.app && (this.options.app || await this.app.close(), this.app = null); } } async function O(a) { const e = a.startup?.mode ?? "DYNAMIC"; if (typeof a.createServer != "function") throw new Error("createMcpServer: `createServer` (factory) is required"); const s = a.createServer(), t = (n) => typeof n?.server?.notification == "function", o = (n) => typeof n?.notifyToolsListChanged == "function", r = async (n) => { try { if (t(n)) { await n.server.notification({ method: "notifications/tools/list_changed" }); return; } o(n) && await n.notifyToolsListChanged(); } catch { } }, i = new y({ server: s, catalog: a.catalog, moduleLoaders: a.moduleLoaders, exposurePolicy: a.exposurePolicy, context: a.context, notifyToolsListChanged: async () => r(s), startup: a.startup, registerMetaTools: a.registerMetaTools !== void 0 ? a.registerMetaTools : e === "DYNAMIC" }), l = new L( i.getManager(), () => { if (e === "STATIC") return { server: s, orchestrator: i }; const n = a.createServer(), c = new y({ server: n, catalog: a.catalog, moduleLoaders: a.moduleLoaders, exposurePolicy: a.exposurePolicy, context: a.context, notifyToolsListChanged: async () => r(n), startup: a.startup, registerMetaTools: a.registerMetaTools !== void 0 ? a.registerMetaTools : e === "DYNAMIC" }); return { server: n, orchestrator: c }; }, a.http, a.configSchema ); return { server: s, start: async () => { await l.start(); }, close: async () => { await l.stop(); } }; } export { O as createMcpServer }; //# sourceMappingURL=index.js.map