
Research
/Security News
Mini Shai-Hulud Campaign Hits Red Hat Cloud Services npm Packages
A mini Shai-Hulud campaign compromised Red Hat Cloud Services npm packages to steal developer and CI/CD secrets during installation.
toolception
Advanced tools
Dynamic MCP server toolkit for runtime toolset management with Fastify transport and meta-tools
Building MCP servers with dozens or hundreds of tools often harms LLM performance and developer experience:
Toolception addresses this by grouping tools into toolsets and letting you expose only what’s needed, when it’s needed.
enable_toolset, disable_toolset, list_toolsets, describe_toolset, list_tools).context safely to loaders.tools.listChanged notifications so clients can react to updated tool lists.set.tool to avoid collisions and clarify intent.ToolRegistry validates names and prevents collisions.ModuleLoaders are deterministic/idempotent for repeatable runs and caching.list_toolsets → enables a set → calls namespaced tools (e.g., core.ping).list_tools and invoke as usual.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. In STATIC mode, a single server instance is created and reused for all clients:
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"] },
};
// Load specific toolsets
createMcpServer({
catalog: staticCatalog,
startup: { mode: "STATIC", toolsets: ["search", "quotes"] },
http: { port: 3001 },
createServer: () =>
new McpServer({
name: "static-1",
version: "0.0.0",
capabilities: { tools: { listChanged: false } },
}),
});
// Load ALL toolsets
createMcpServer({
catalog: staticCatalog,
startup: { mode: "STATIC", toolsets: "ALL" },
http: { port: 3002 },
createServer: () =>
new McpServer({
name: "static-2",
version: "0.0.0",
capabilities: { tools: { listChanged: false } },
}),
});
Use createPermissionBasedMcpServer when you need to enforce client-specific toolset permissions. This is ideal for multi-tenant applications, security-sensitive environments, or when different clients should have different levels of access.
npm i toolception
import { createPermissionBasedMcpServer } from "toolception";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const catalog = {
admin: {
name: "Admin Tools",
description: "Administrative operations",
modules: ["admin"],
},
user: {
name: "User Tools",
description: "Standard user operations",
modules: ["user"],
},
};
const adminTool = {
name: "delete_user",
description: "Delete a user account",
inputSchema: {
type: "object",
properties: {
userId: { type: "string", description: "User ID to delete" },
},
required: ["userId"],
},
handler: async ({ userId }: { userId: string }) => ({
content: [{ type: "text", text: `User ${userId} deleted` }],
}),
} as const;
const userTool = {
name: "get_profile",
description: "Get user profile information",
inputSchema: {
type: "object",
properties: {
userId: { type: "string", description: "User ID" },
},
required: ["userId"],
},
handler: async ({ userId }: { userId: string }) => ({
content: [{ type: "text", text: `Profile for ${userId}: {...}` }],
}),
} as const;
const moduleLoaders = {
admin: async () => [adminTool],
user: async () => [userTool],
};
You have two options for managing permissions:
Header-Based Permissions:
Config-Based Permissions:
Option A: Header-Based Permissions
const createServer = () =>
new McpServer({
name: "permission-header-server",
version: "1.0.0",
capabilities: { tools: { listChanged: false } },
});
const { start, close } = await createPermissionBasedMcpServer({
catalog,
moduleLoaders,
permissions: {
source: "headers",
headerName: "mcp-toolset-permissions", // optional, this is default
},
http: { port: 3000 },
createServer,
});
await start();
Option B: Config-Based Permissions (Static Map)
const createServer = () =>
new McpServer({
name: "permission-config-server",
version: "1.0.0",
capabilities: { tools: { listChanged: false } },
});
const { start, close } = await createPermissionBasedMcpServer({
catalog,
moduleLoaders,
permissions: {
source: "config",
staticMap: {
"admin-client-id": ["admin", "user"],
"user-client-id": ["user"],
},
defaultPermissions: [], // unknown clients get no toolsets
},
http: { port: 3000 },
createServer,
});
await start();
Option C: Config-Based Permissions (Resolver Function)
const createServer = () =>
new McpServer({
name: "permission-resolver-server",
version: "1.0.0",
capabilities: { tools: { listChanged: false } },
});
const { start, close } = await createPermissionBasedMcpServer({
catalog,
moduleLoaders,
permissions: {
source: "config",
resolver: (clientId: string) => {
// Your custom permission logic
if (clientId.startsWith("admin-")) {
return ["admin", "user"];
}
if (clientId.startsWith("user-")) {
return ["user"];
}
return [];
},
defaultPermissions: [],
},
http: { port: 3000 },
createServer,
});
await start();
process.on("SIGINT", async () => {
await close();
process.exit(0);
});
process.on("SIGTERM", async () => {
await close();
process.exit(0);
});
Use header-based permissions when you have an authentication gateway or proxy that validates and sets permission headers. This approach is flexible for dynamic permissions but requires external header validation.
import { createPermissionBasedMcpServer } from "toolception";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const createServer = () =>
new McpServer({
name: "permission-header-server",
version: "1.0.0",
capabilities: { tools: { listChanged: false } },
});
const { start, close } = await createPermissionBasedMcpServer({
catalog: {
admin: {
name: "Admin",
description: "Admin tools",
modules: ["admin"],
},
user: {
name: "User",
description: "User tools",
modules: ["user"],
},
},
moduleLoaders: {
admin: async () => [
/* admin tools */
],
user: async () => [
/* user tools */
],
},
permissions: {
source: "headers",
headerName: "mcp-toolset-permissions", // optional, this is default
},
http: { port: 3000 },
createServer,
});
await start();
When to use:
Use a static map when you have a fixed set of clients with known permissions. This provides server-side control and better security.
import { createPermissionBasedMcpServer } from "toolception";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const createServer = () =>
new McpServer({
name: "permission-config-server",
version: "1.0.0",
capabilities: { tools: { listChanged: false } },
});
const { start, close } = await createPermissionBasedMcpServer({
catalog: {
admin: {
name: "Admin",
description: "Admin tools",
modules: ["admin"],
},
user: {
name: "User",
description: "User tools",
modules: ["user"],
},
},
moduleLoaders: {
admin: async () => [
/* admin tools */
],
user: async () => [
/* user tools */
],
},
permissions: {
source: "config",
staticMap: {
"admin-client-id": ["admin", "user"],
"user-client-id": ["user"],
},
defaultPermissions: [], // clients not in map get no toolsets
},
http: { port: 3000 },
createServer,
});
await start();
When to use:
Use a resolver function when you need custom logic to determine permissions, such as looking up from a database or applying complex rules.
import { createPermissionBasedMcpServer } from "toolception";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const createServer = () =>
new McpServer({
name: "permission-resolver-server",
version: "1.0.0",
capabilities: { tools: { listChanged: false } },
});
const { start, close } = await createPermissionBasedMcpServer({
catalog: {
admin: {
name: "Admin",
description: "Admin tools",
modules: ["admin"],
},
user: {
name: "User",
description: "User tools",
modules: ["user"],
},
},
moduleLoaders: {
admin: async () => [
/* admin tools */
],
user: async () => [
/* user tools */
],
},
permissions: {
source: "config",
resolver: (clientId: string) => {
// Custom logic - could check database, config file, etc.
if (clientId.startsWith("admin-")) {
return ["admin", "user"];
}
if (clientId.startsWith("user-")) {
return ["user"];
}
return [];
},
staticMap: {
// optional fallback
"special-client": ["admin"],
},
defaultPermissions: [],
},
http: { port: 3000 },
createServer,
});
await start();
When to use:
Note: Resolver functions must be synchronous. If you need to fetch permissions from external sources, do so before server creation and cache the results.
Toolception supports custom HTTP endpoints alongside MCP protocol endpoints, enabling REST-like APIs with Zod validation and type inference.
import { createMcpServer, defineEndpoint } from "toolception";
import { z } from "zod";
const { start } = await createMcpServer({
// ... standard options
http: {
port: 3000,
customEndpoints: [
defineEndpoint({
method: "GET",
path: "/api/users",
querySchema: z.object({
limit: z.coerce.number().int().positive().default(10),
role: z.enum(["admin", "user"]).optional(),
}),
responseSchema: z.object({
users: z.array(z.object({ id: z.string(), name: z.string() })),
}),
handler: async (req) => {
// req.query is typed: { limit: number, role?: "admin" | "user" }
// req.clientId is available from mcp-client-id header
return { users: [{ id: "1", name: "Alice" }] };
},
}),
],
},
});
z.coerce for type conversion)/users/:userId)Handlers receive a typed request object:
{
body: TBody, // Validated from bodySchema
query: TQuery, // Validated from querySchema
params: TParams, // Validated from paramsSchema
headers: Record<string, string | string[] | undefined>,
clientId: string, // From mcp-client-id header or auto-generated
}
Use definePermissionAwareEndpoint in permission-based servers to access client permissions:
import { createPermissionBasedMcpServer, definePermissionAwareEndpoint } from "toolception";
const { start } = await createPermissionBasedMcpServer({
// ... permission config
http: {
customEndpoints: [
definePermissionAwareEndpoint({
method: "GET",
path: "/api/me",
responseSchema: z.object({
clientId: z.string(),
allowedToolsets: z.array(z.string()),
isAdmin: z.boolean(),
}),
handler: async (req) => {
// req.allowedToolsets and req.failedToolsets are available
return {
clientId: req.clientId,
allowedToolsets: req.allowedToolsets,
isAdmin: req.allowedToolsets.includes("admin-tools"),
};
},
}),
],
},
});
Validation failures return standardized error responses:
Example error response:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed for query",
"details": [
{
"code": "invalid_type",
"path": ["limit"],
"message": "Expected number, received string"
}
]
}
}
Custom endpoints cannot override built-in MCP paths:
/mcp - MCP JSON-RPC endpoint/healthz - Health check/tools - Tool listing/.well-known/mcp-config - Configuration schemaSee examples/custom-endpoints-demo.ts for a full working example with GET, POST, PUT, DELETE endpoints, pagination, and permission-aware handlers.
Use the sessionContext option to enable per-client context values extracted from query parameters. This is useful for multi-tenant scenarios where each client needs different configuration (API tokens, user IDs, etc.) passed to module loaders.
import { createMcpServer } from "toolception";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const { start } = await createMcpServer({
catalog: { /* ... */ },
moduleLoaders: { /* ... */ },
context: { baseValue: 'shared' }, // Base context for all sessions
sessionContext: {
enabled: true,
queryParam: {
name: 'config',
encoding: 'base64',
allowedKeys: ['API_TOKEN', 'USER_ID'], // Security: always specify
},
merge: 'shallow',
},
createServer: () => new McpServer({
name: "my-server",
version: "1.0.0",
capabilities: { tools: { listChanged: true } },
}),
http: { port: 3000 },
});
await start();
# Encode session config as base64
CONFIG=$(echo -n '{"API_TOKEN":"user-secret-token","USER_ID":"123"}' | base64)
# Connect with session config
curl -X POST "http://localhost:3000/mcp?config=$CONFIG" \
-H "mcp-client-id: my-client" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"initialize",...}'
const moduleLoaders = {
tenant: async (ctx: any) => {
// ctx = { baseValue: 'shared', API_TOKEN: 'user-secret-token', USER_ID: '123' }
return [/* tools using ctx.API_TOKEN */];
},
};
For advanced use cases, provide a custom resolver function:
sessionContext: {
enabled: true,
queryParam: { allowedKeys: ['tenant_id'] },
contextResolver: (request, baseContext, parsedConfig) => ({
...baseContext,
...parsedConfig,
clientId: request.clientId,
timestamp: Date.now(),
}),
}
allowedKeys: Without a whitelist, any key in the query config is acceptedWires your MCP SDK server to dynamic/static tool management and a Fastify HTTP transport.
Requirements
createServer must be provided.createServer.createServer and reused for all clients.Record<string, ToolSetDefinition>
name, description, optional inline tools, optional modules (for lazy loaders), and optional decisionCriteria.Record<string, ModuleLoader>
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
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" }
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 mode)
enable_toolset, disable_toolset, list_toolsets, describe_toolset, list_tools).list_tools (other meta-tools are not applicable since toolsets are fixed at startup).ExposurePolicy
| 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
allowlist and denylist are present, the entry must be in allowlist and not in denylist to pass.GET /tools.unknown
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
moduleLoaders receive context. Direct tools defined inline in catalog do not.context.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;
},
},
],
};
SessionContextConfig
Configuration for per-session context extraction from query parameters. Enables multi-tenant use cases where each client session can have its own context values passed to module loaders. See Per-session context for detailed usage examples.
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Whether session context extraction is enabled |
queryParam.name | string | 'config' | Query parameter name |
queryParam.encoding | 'base64' | 'json' | 'base64' | Encoding format |
queryParam.allowedKeys | string[] | - | Whitelist of allowed keys (recommended for security) |
contextResolver | function | - | Custom context resolver function |
merge | 'shallow' | 'deep' | 'shallow' | How to merge with base context |
Notes
context option{ host?: string; port?: number; basePath?: string; cors?: boolean; logger?: boolean; customEndpoints?: CustomEndpointDefinition[] }
0.0.0.0, port 3000, basePath /, CORS enabled, logger disabled.customEndpoints: Optional array of custom HTTP endpoints to register alongside MCP protocol endpoints. See Custom HTTP endpoints for details.() => McpServer
Required factory to create the SDK server instance(s).
object
GET /.well-known/mcp-config for client discovery.Creates a permission-aware MCP server where each client receives only the toolsets they're authorized to access. This function provides a separate API for permission-based scenarios while maintaining the same interface as createMcpServer.
Requirements
createServer must be providedpermissions configuration must be providedPermissionConfig
Defines how client permissions are resolved and enforced.
Permission Source Types
| Source | Description | Use Case | Security Level |
|---|---|---|---|
headers | Read permissions from request headers | Behind authenticated proxy/gateway | Medium (requires external validation) |
config | Server-side permission lookup | Direct server control | High (server-controlled) |
Header-Based Configuration
| Field | Type | Default | Description |
|---|---|---|---|
source | 'headers' | required | Indicates header-based permissions |
headerName | string | 'mcp-toolset-permissions' | Header name containing comma-separated toolset list |
Config-Based Configuration
| Field | Type | Required | Description |
|---|---|---|---|
source | 'config' | yes | Indicates config-based permissions |
staticMap | Record<string, string[]> | one of staticMap or resolver | Maps client IDs to toolset arrays |
resolver | (clientId: string) => string[] | one of staticMap or resolver | Function returning toolset array for client |
defaultPermissions | string[] | no | Fallback permissions for unknown clients (default: []) |
Notes
staticMap or resolver must be providedresolver is tried first, then staticMap, then defaultPermissionsSame as createMcpServer - see options.catalog.
Same as createMcpServer - see options.moduleLoaders.
ExposurePolicy (partial support)
Permission-based servers only support namespaceToolsWithSetKey. Other policy fields are ignored because toolset access is controlled by permissions:
| Field | Support |
|---|---|
namespaceToolsWithSetKey | ✅ Supported (default: true) |
allowlist | ⚠️ Ignored (determined by client permissions) |
denylist | ⚠️ Ignored (use permissions instead) |
maxActiveToolsets | ⚠️ Ignored (determined by permission count) |
onLimitExceeded | ⚠️ Ignored (no toolset limits enforced) |
Note: If you provide ignored options, the server will log warnings at startup to alert you.
Same as createMcpServer - see options.http.
Same as createMcpServer - see options.createServer.
Same as createMcpServer - see options.configSchema.
Same as createMcpServer - see options.context.
SessionContextConfig
Session context is available in permission-based servers but has limited support. Because permission-based servers determine toolsets at connection time based on permissions, the session context cannot affect which toolsets are loaded. However, the merged context is still passed to module loaders.
Note: A warning is issued if sessionContext is used with createPermissionBasedMcpServer. For full session context support with per-session toolset caching, use createMcpServer with DYNAMIC mode.
Meta-tools are registered based on mode:
DYNAMIC mode (registered by default, or when registerMetaTools is true):
enable_toolset - Enable a toolset by namedisable_toolset - Disable a toolset by name (state only; tools remain registered)list_toolsets - List available toolsets with active statusdescribe_toolset - Describe a specific toolset with definition and toolslist_tools - List currently registered tool namesSTATIC mode (when registerMetaTools is true):
list_tools - List currently registered tool namesNote: In STATIC mode, enable_toolset, disable_toolset, list_toolsets, and describe_toolset are not available since toolsets are fixed at startup.
When connecting to a permission-based server with header-based permissions, include the mcp-toolset-permissions header with a comma-separated list of toolsets:
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
const clientId = "my-client-id";
const allowedToolsets = ["user", "reports"]; // determined by your auth system
const transport = new StreamableHTTPClientTransport(
new URL("http://localhost:3000/mcp"),
{
requestInit: {
headers: {
"mcp-client-id": clientId,
"mcp-toolset-permissions": allowedToolsets.join(","),
},
},
}
);
const client = new Client({ name: "example-client", version: "1.0.0" });
await client.connect(transport);
// Client can only access tools from 'user' and 'reports' toolsets
const tools = await client.listTools();
console.log(tools); // Only shows user.* and reports.* tools
await client.close();
Important: Your application layer must validate and potentially sign/encrypt the permission header to prevent tampering. The MCP server trusts the header value as-is.
When connecting to a permission-based server with config-based permissions, only provide the mcp-client-id header. The server looks up permissions internally:
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
const clientId = "admin-client-id"; // matches server's staticMap or resolver
const transport = new StreamableHTTPClientTransport(
new URL("http://localhost:3000/mcp"),
{
requestInit: {
headers: {
"mcp-client-id": clientId,
// No permission header needed - server looks up permissions
},
},
}
);
const client = new Client({ name: "example-client", version: "1.0.0" });
await client.connect(transport);
// Client receives toolsets based on server configuration
const tools = await client.listTools();
console.log(tools); // Shows tools based on server's permission config
await client.close();
Security: Config-based permissions provide better security since the client cannot influence their own permissions. Ensure your client IDs are authenticated and validated before reaching the MCP server.
mcp-client-id HTTP header on every request.POST /mcp, GET /mcp, DELETE /mcp) return a 400 error. Custom endpoints still accept anonymous clients with auto-generated IDs.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();
mcp-session-id./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();
Use Header-Based Permissions When:
Use Config-Based Permissions When:
Header-Based Pattern:
Client → Auth Gateway → MCP Server
(validates,
sets headers)
The auth gateway must:
mcp-toolset-permissions headerConfig-Based Pattern:
Client → MCP Server → Permission Lookup
(validates (staticMap or
client-id) resolver)
The MCP server:
If using header-based permissions, implement validation to prevent tampering:
import crypto from "crypto";
// Example: Using HMAC to sign permission headers
function signPermissions(
clientId: string,
toolsets: string[],
secret: string
): string {
const data = `${clientId}:${toolsets.join(",")}`;
const signature = crypto
.createHmac("sha256", secret)
.update(data)
.digest("hex");
return `${toolsets.join(",")};sig=${signature}`;
}
function verifyPermissions(
clientId: string,
headerValue: string,
secret: string
): string[] {
const [toolsetsStr, sigPart] = headerValue.split(";sig=");
const expectedSig = crypto
.createHmac("sha256", secret)
.update(`${clientId}:${toolsetsStr}`)
.digest("hex");
if (sigPart !== expectedSig) {
throw new Error("Invalid permission signature");
}
return toolsetsStr.split(",").map((s) => s.trim());
}
// In your auth gateway:
const clientId = "user-123";
const allowedToolsets = ["user", "reports"];
const signedHeader = signPermissions(clientId, allowedToolsets, SECRET_KEY);
// Forward to MCP server with signed header
fetch("http://mcp-server:3000/mcp", {
headers: {
"mcp-client-id": clientId,
"mcp-toolset-permissions": signedHeader,
},
});
Header-Based Permissions:
Config-Based Permissions:
General Security:
When a client attempts to access unauthorized toolsets:
listTools()Create a server where each tenant has access to their own toolsets plus shared tools:
import { createPermissionBasedMcpServer } from "toolception";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const { start, close } = await createPermissionBasedMcpServer({
catalog: {
"tenant-a-tools": {
name: "Tenant A",
description: "Tools for tenant A",
modules: ["tenant-a"],
},
"tenant-b-tools": {
name: "Tenant B",
description: "Tools for tenant B",
modules: ["tenant-b"],
},
"shared-tools": {
name: "Shared",
description: "Shared tools",
modules: ["shared"],
},
},
moduleLoaders: {
"tenant-a": async () => [
/* tenant A specific tools */
],
"tenant-b": async () => [
/* tenant B specific tools */
],
shared: async () => [
/* shared tools */
],
},
permissions: {
source: "config",
resolver: (clientId: string) => {
const [tenant] = clientId.split("-");
if (tenant === "tenantA") {
return ["tenant-a-tools", "shared-tools"];
}
if (tenant === "tenantB") {
return ["tenant-b-tools", "shared-tools"];
}
return ["shared-tools"]; // unknown tenants get only shared tools
},
},
http: { port: 3000 },
createServer: () =>
new McpServer({
name: "multi-tenant-server",
version: "1.0.0",
capabilities: { tools: { listChanged: false } },
}),
});
await start();
Integrate with an external authentication system by pre-loading permissions:
import { createPermissionBasedMcpServer } from "toolception";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
// Pre-load permissions from your auth system
// This should be done before server creation and cached
const permissionCache = new Map<string, string[]>();
async function loadPermissionsFromAuthSystem() {
// Fetch permissions from your auth system
// This is just an example - implement according to your system
const users = await authSystem.getAllUsers();
for (const user of users) {
const permissions = await authSystem.getUserPermissions(user.id);
permissionCache.set(user.id, permissions.allowedToolsets);
}
}
// Load permissions at startup
await loadPermissionsFromAuthSystem();
// Optionally refresh permissions periodically
setInterval(loadPermissionsFromAuthSystem, 5 * 60 * 1000); // every 5 minutes
const { start, close } = await createPermissionBasedMcpServer({
catalog: {
/* your toolsets */
},
moduleLoaders: {
/* your loaders */
},
permissions: {
source: "config",
resolver: (clientId: string) => {
// Synchronous lookup from pre-loaded cache
return permissionCache.get(clientId) || [];
},
defaultPermissions: ["public"], // unauthenticated users get public tools
},
http: { port: 3000 },
createServer: () =>
new McpServer({
name: "auth-integrated-server",
version: "1.0.0",
capabilities: { tools: { listChanged: false } },
}),
});
await start();
Implement role-based access control with predefined role-to-toolset mappings:
import { createPermissionBasedMcpServer } from "toolception";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
// Define role-to-toolset mappings
const rolePermissions = {
admin: ["admin-tools", "user-tools", "reports", "analytics"],
manager: ["user-tools", "reports", "analytics"],
user: ["user-tools", "reports"],
guest: ["public-tools"],
};
// Map client IDs to roles (could come from database, JWT claims, etc.)
function getRoleForClient(clientId: string): string {
// Example: extract role from client ID or look up in database
if (clientId.startsWith("admin-")) return "admin";
if (clientId.startsWith("manager-")) return "manager";
if (clientId.startsWith("user-")) return "user";
return "guest";
}
const { start, close } = await createPermissionBasedMcpServer({
catalog: {
"admin-tools": {
name: "Admin",
description: "Admin tools",
modules: ["admin"],
},
"user-tools": {
name: "User",
description: "User tools",
modules: ["user"],
},
reports: {
name: "Reports",
description: "Reporting tools",
modules: ["reports"],
},
analytics: {
name: "Analytics",
description: "Analytics tools",
modules: ["analytics"],
},
"public-tools": {
name: "Public",
description: "Public tools",
modules: ["public"],
},
},
moduleLoaders: {
admin: async () => [
/* admin tools */
],
user: async () => [
/* user tools */
],
reports: async () => [
/* report tools */
],
analytics: async () => [
/* analytics tools */
],
public: async () => [
/* public tools */
],
},
permissions: {
source: "config",
staticMap: {
// Known admin users
"admin-user-1": rolePermissions.admin,
"admin-user-2": rolePermissions.admin,
// Known managers
"manager-user-1": rolePermissions.manager,
// Known regular users
"regular-user-1": rolePermissions.user,
"regular-user-2": rolePermissions.user,
},
resolver: (clientId: string) => {
// Dynamic role lookup for clients not in static map
const role = getRoleForClient(clientId);
return rolePermissions[role] || rolePermissions.guest;
},
defaultPermissions: rolePermissions.guest,
},
http: { port: 3000 },
createServer: () =>
new McpServer({
name: "rbac-server",
version: "1.0.0",
capabilities: { tools: { listChanged: false } },
}),
});
await start();
catalog[toolset].tools and registered when that toolset is enabled.moduleLoaders[moduleKey]() and registered when enabling a toolset that references modules: [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:
Dynamic mode (startup.mode = "DYNAMIC")
enable_toolset, disable_toolset, list_toolsets, describe_toolset, list_toolsStatic mode (startup.mode = "STATIC")
toolsets array or "ALL")list_tools meta-tool is available (toolsets cannot be changed at runtime)Apache-2.0. See LICENSE for details.
FAQs
Dynamic MCP server toolkit for runtime toolset management with Fastify transport and meta-tools
The npm package toolception receives a total of 89 weekly downloads. As such, toolception popularity was classified as not popular.
We found that toolception demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Research
/Security News
A mini Shai-Hulud campaign compromised Red Hat Cloud Services npm packages to steal developer and CI/CD secrets during installation.

Research
/Security News
The North Korean malware loader hides in a Packagist-listed package and its GitHub branch to fetch and execute remote code in a likely Contagious Interview-style lure.

Security News
The Rust project is moving toward formal rules on LLM use in contributions after months of internal debate over maintainer burden, code quality, and contributor experience.