Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 131 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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"] },
Expand All @@ -115,20 +128,36 @@ 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 } },
}),
});
```

## API

### 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)

Expand All @@ -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<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

```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)
Expand All @@ -158,25 +234,73 @@ 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)

`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` 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<string, unknown> }`
`() => 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)

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "toolception",
"version": "0.1.0",
"version": "0.2.0",
"private": false,
"type": "module",
"main": "dist/index.js",
Expand All @@ -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": {},
Expand Down
65 changes: 60 additions & 5 deletions src/core/ServerOrchestrator.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,14 +21,16 @@ export class ServerOrchestrator {
private readonly mode: Exclude<Mode, "ALL">;
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:
Expand All @@ -49,14 +51,67 @@ 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) {
void this.manager.enableToolsets(initial);
}
}

private resolveStartupConfig(
startup: { mode?: Exclude<Mode, "ALL">; toolsets?: string[] | "ALL" },
catalog: ToolSetCatalog
): { mode: Exclude<Mode, "ALL">; 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<Mode, "ALL"> {
return this.mode;
}
Expand Down
12 changes: 6 additions & 6 deletions src/meta/registerMetaTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function registerMetaTools(
const result = await manager.enableToolset(name);
return {
content: [{ type: "text", text: JSON.stringify(result) }],
} as any;
};
}
);

Expand All @@ -32,7 +32,7 @@ export function registerMetaTools(
const result = await manager.disableToolset(name);
return {
content: [{ type: "text", text: JSON.stringify(result) }],
} as any;
};
}
);

Expand Down Expand Up @@ -64,7 +64,7 @@ export function registerMetaTools(
content: [
{ type: "text", text: JSON.stringify({ toolsets: items }) },
],
} as any;
};
}
);

Expand All @@ -84,7 +84,7 @@ export function registerMetaTools(
text: JSON.stringify({ error: `Unknown toolset '${name}'` }),
},
],
} as any;
};
}
const payload = {
key: name,
Expand All @@ -99,7 +99,7 @@ export function registerMetaTools(
};
return {
content: [{ type: "text", text: JSON.stringify(payload) }],
} as any;
};
}
);
}
Expand All @@ -116,7 +116,7 @@ export function registerMetaTools(
};
return {
content: [{ type: "text", text: JSON.stringify(payload) }],
} as any;
};
}
);
}
Loading