- Starter guide
- Static startup
- API
- Client ID lifecycle
- Session ID lifecycle
- Tool types
- Startup modes
- License
npm i toolception
import { createMcpServer } from "toolception";
const catalog = {
quotes: { name: "Quotes", description: "Market quotes", modules: ["quotes"] },
};
const quoteTool = {
name: "price",
description: "Return a fake price",
inputSchema: {
type: "object",
properties: { symbol: { type: "string" } },
required: ["symbol"],
},
handler: async ({ symbol }: { symbol: string }) => ({
content: [{ type: "text", text: `${symbol}: 123.45` }],
}),
} as const;
const moduleLoaders = {
quotes: async () => [quoteTool],
};
const configSchema = {
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: {
REQUIRED_PARAM: { type: "string", title: "Required Param" },
OPTIONAL_PARAM: { type: "string", title: "Optional Param" },
},
required: ["REQUIRED_PARAM"],
} as const;
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();
process.on("SIGINT", async () => {
await close();
process.exit(0);
});
process.on("SIGTERM", async () => {
await close();
process.exit(0);
});
Enable some or ALL toolsets at bootstrap. Note: provide a server or factory:
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"] },
};
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 } },
}),
});
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.
Record<string, ToolSetDefinition>
- Defines available toolsets to expose. Each item includes
name
,description
, optional inlinetools
, optionalmodules
(for lazy loaders), and optionaldecisionCriteria
.
Record<string, ModuleLoader>
- Maps module keys to async loaders returning
McpToolDefinition[]
. Referenced by toolsets viamodules: [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<McpToolDefinition[]> 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
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"] },
};
{ 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 |
boolean
(default: true in DYNAMIC mode; false in STATIC unless explicitly set)
- Whether to register management tools like
enable_toolset
,disable_toolset
,list_tools
.
ExposurePolicy
- 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
anddenylist
are present, the entry must be inallowlist
and not indenylist
to pass. - Namespacing is applied consistently at registration time and reflected in
GET /tools
.
unknown
- 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
receivecontext
. Direct tools defined inline incatalog
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
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;
},
},
],
};
{ host?: string; port?: number; basePath?: string; cors?: boolean; logger?: boolean }
- Fastify transport configuration. Defaults: host
0.0.0.0
, port3000
, basePath/
, CORS enabled, logger disabled.
() => McpServer
Required factory to create the SDK server instance(s).
object
- JSON Schema exposed at
GET /.well-known/mcp-config
for client discovery.
Enabled by default when mode is DYNAMIC (or when registerMetaTools
is true):
enable_toolset
,disable_toolset
,list_tools
Only in DYNAMIC mode:list_toolsets
,describe_toolset
- What: Clients identify themselves via the
mcp-client-id
HTTP header on every request. - Who generates it: The client. Use a stable identifier (e.g., UUID persisted locally).
- If omitted: The server assigns a one-off
anon-<uuid>
and skips caching; this is unsuitable for multi-request flows and SSE.
Examples (official MCP client)
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
// Create a stable client id (persist it for reuse across runs)
const clientId = "my-stable-client-id"; // e.g., from disk/env
// Transport manages HTTP, including SSE and JSON-RPC framing
const transport = new StreamableHTTPClientTransport(
new URL("http://localhost:3000/mcp"),
{
requestInit: { headers: { "mcp-client-id": clientId } },
}
);
// High-level MCP client
const client = new Client({ name: "example-client", version: "1.0.0" });
// Connect negotiates capabilities and establishes a session. Transport handles session id.
await client.connect(transport);
// Call a tool (example)
const res = await client.listTools();
console.log(res);
// Close when done
await client.close();
- What: A per-session identifier returned by the server on initialize.
- Who generates it: The server during initialize. The client must read it from the initialize response headers and send it back on subsequent requests via
mcp-session-id
. - Used for: Follow-up JSON-RPC requests (POST
/mcp
), SSE stream (GET/mcp
), and termination (DELETE/mcp
).
Examples (official MCP client)
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
const clientId = "my-stable-client-id";
const transport = new StreamableHTTPClientTransport(
new URL("http://localhost:3000/mcp"),
{
requestInit: { headers: { "mcp-client-id": clientId } },
}
);
const client = new Client({ name: "example-client", version: "1.0.0" });
await client.connect(transport);
// Session id is handled by the transport. No need to manually set mcp-session-id.
// Call tools
await client.callTool({ name: "enable_toolset", arguments: { name: "core" } });
const ping = await client.callTool({ name: "core.ping", arguments: {} });
console.log(ping);
// When finished
await client.close();
- Direct tools: defined inline under
catalog[toolset].tools
and registered when that toolset is enabled. - Module-produced tools: returned by
moduleLoaders[moduleKey]()
and registered when enabling a toolset that referencesmodules: [moduleKey]
.
Use direct tools for simple/local utilities; use module-produced tools to share tools across multiple toolsets or lazily load heavier definitions.
Note on dynamic mode: Both direct and module-produced tools are supported. Module-produced tools help minimize startup footprint by enabling on-demand loading at enable-time.
The server operates in one of two primary modes (legacy load-all is not recommended here):
-
Dynamic mode (startup.mode = "DYNAMIC")
- Starts with meta-tools for runtime management:
enable_toolset
,disable_toolset
,list_toolsets
,describe_toolset
, andlist_tools
(always available) - Tools are loaded on-demand via meta-tool calls
- Best for flexible, task-specific workflows where tool needs change
- Starts with meta-tools for runtime management:
-
Static mode (startup.mode = "STATIC")
- Pre-loads specific toolsets at startup (
toolsets
array or "ALL") - Meta-tools limited to
list_tools
by default - Best for known, consistent tool requirements
- Pre-loads specific toolsets at startup (
Apache-2.0. See LICENSE
for details.