tmcp
v1.19.3
Published
The main tmcp library
Readme
[!WARNING] Unfortunately i published the 1.0 by mistake...this package is currently under heavy development so there will be breaking changes in minors...threat this
1.xas the0.xof any other package. Sorry for the disservice, every breaking will be properly labeled in the PR name.
tmcp
A lightweight, schema-agnostic Model Context Protocol (MCP) server implementation with unified API design.
Why tmcp?
tmcp offers significant advantages over the official MCP SDK:
- 🔄 Schema Agnostic: Works with any validation library through adapters
- 📦 No Weird Dependencies: Minimal footprint with only essential dependencies (looking at you
express) - 🎯 Unified API: Consistent, intuitive interface across all MCP capabilities
- 🔌 Extensible: Easy to add support for new schema libraries
- ⚡ Lightweight: No bloat, just what you need
Supported Schema Libraries
tmcp works with all major schema validation libraries through its adapter system:
- Zod -
@tmcp/adapter-zod - Valibot -
@tmcp/adapter-valibot - ArkType -
@tmcp/adapter-arktype - Effect Schema -
@tmcp/adapter-effect - Zod v3 -
@tmcp/adapter-zod-v3
Installation
pnpm install tmcp
# Choose your preferred schema library adapter
pnpm install @tmcp/adapter-zod zodQuick Start
import { McpServer } from 'tmcp';
import { ZodJsonSchemaAdapter } from '@tmcp/adapter-zod';
import { z } from 'zod';
const adapter = new ZodJsonSchemaAdapter();
const server = new McpServer(
{
name: 'my-server',
version: '1.0.0',
description: 'My awesome MCP server',
},
{
adapter,
capabilities: {
tools: { listChanged: true },
prompts: { listChanged: true },
resources: { listChanged: true },
},
},
);
// While the adapter is optional (you can opt out by explicitly passing `adapter: undefined`)
// without an adapter the server cannot accept inputs, produce structured outputs, or request
// elicitations at all only do this for very simple servers.
// Define a tool with type-safe schema
server.tool(
{
name: 'calculate',
description: 'Perform mathematical calculations',
schema: z.object({
operation: z.enum(['add', 'subtract', 'multiply', 'divide']),
a: z.number(),
b: z.number(),
}),
},
async ({ operation, a, b }) => {
switch (operation) {
case 'add':
return {
content: [{ type: 'text', text: `${a} + ${b} = ${a + b}` }],
};
case 'subtract':
return {
content: [{ type: 'text', text: `${a} - ${b} = ${a - b}` }],
};
case 'multiply':
return {
content: [{ type: 'text', text: `${a} × ${b} = ${a * b}` }],
};
case 'divide':
return {
content: [{ type: 'text', text: `${a} ÷ ${b} = ${a / b}` }],
};
}
},
);
// Process incoming requests
server.receive(request);Return helpers to skip boilerplate
Even the tiny calculator tool above ends with four near-identical return { content: [{ type: 'text', text: ... }] } blocks. Most MCP handlers have to build the same shapes: CallToolResult, ReadResourceResult, GetPromptResult, or CompleteResult. Writing them by hand is repetitive boilerplate. The tmcp/utils entry point ships a handful of factories that emit the correct shape for you.
import { tool, resource, prompt, complete } from 'tmcp/utils';
// Without helpers – lots of envelope noise
server.tool(
{ name: 'health-check', description: 'A health check tool' },
async () => ({
content: [{ type: 'text', text: 'ok' }],
}),
);
// With helpers – just describe the payload once
server.tool(
{ name: 'health-check', description: 'A health check tool' },
async () => tool.text('ok'),
);
server.tool(
{ name: 'profile-picture', description: 'Your profile picture' },
async () => tool.media('image', await loadPng(), 'image/png'),
);
server.tool(
{
name: 'get-images',
description:
'Get the orders details and the images of all the products',
schema: v.object({
items: v.array(ItemsSchema),
}),
},
async () => {
const orders = await loadOrders();
const images = await loadImages(orders);
return tool.mix(
[
tool.text("Here's your images"),
...images.map((img) => tool.media('image', img)),
],
orders,
);
},
);
server.resource(
{
name: 'readme',
description: 'Top-level README',
uri: 'file://README.md',
},
async (uri) =>
resource.text(uri, await readFile(uri, 'utf8'), 'text/markdown'),
);
server.prompt(
{
name: 'explain',
description: '',
schema: v.object({ topic: v.string() }),
},
async ({ topic }) => prompt.message(`Explain ${topic} like I am five.`),
);
server.template(
{
name: 'users',
description: 'Supports completion',
uri: 'users/{id}',
complete: {
id: async (arg) =>
complete.values(await findMatchingIds(arg), false),
},
},
async (uri) => resource.blob(uri, await fetchUserBlob(uri)),
);you can also compose different kind of tools with tool.mix
tool.mix([
tool.text('Indexed workspace'),
tool.media('image', png, 'image/png'),
]);however be aware that
- you can't pass
tool.structuredtotool.mix(but you can pass a second argument that will be the structured content) - if you pass even one
tool.errorto thetool.mixthe whole return value will be an error
const structuredContent = {
cool: true,
};
tool.mix(
[
tool.text(JSON.stringify(structuredContent)),
tool.media('image', png, 'image/png'),
],
structuredContent,
);Helper catalog
tool– buildCallToolResultobjects viatext,error,media,resource,resourceLink,structured, andmix(merge multiple kind of tool response).resource– returnReadResourceResultthroughtext,blob, ormix(mergeresource.textandresource.blob).prompt– generateGetPromptResultusingmessage(single string),messages(array of strings),text,media,resource,resourceLinkandmix(merge multiple kind of tool response).complete– createCompleteResultpayloads withvalues(list, hasMore?, total?).
All helpers are typed, so TypeScript will prevent you from returning malformed payloads while cutting down the repetitive envelopes that used to appear in almost every handler.
Defining Tools, Prompts, Resources, and Templates in Separate Files
For better code organization and reusability, you can define your tools, prompts, resources, and templates in separate files using the defineTool, definePrompt, defineResource, and defineTemplate utilities. This approach allows you to:
- Organize by feature: Group related tools in feature-specific modules
- Reuse across servers: Share common tools between different MCP servers
Example Structure
// tools/calculator.js
import { defineTool } from 'tmcp/tool';
import { z } from 'zod';
export const addTool = defineTool(
{
name: 'add',
description: 'Add two numbers',
schema: z.object({
a: z.number(),
b: z.number(),
}),
},
async ({ a, b }) => ({
content: [{ type: 'text', text: `${a} + ${b} = ${a + b}` }],
}),
);
export const multiplyTool = defineTool(
{
name: 'multiply',
description: 'Multiply two numbers',
schema: z.object({
a: z.number(),
b: z.number(),
}),
},
async ({ a, b }) => ({
content: [{ type: 'text', text: `${a} × ${b} = ${a * b}` }],
}),
);// prompts/code-review.js
import { definePrompt } from 'tmcp/prompt';
import { z } from 'zod';
export const codeReviewPrompt = definePrompt(
{
name: 'code-review',
description: 'Generate code review prompt',
schema: z.object({
code: z.string(),
language: z.string(),
}),
},
async ({ code, language }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Review this ${language} code:\n\n${code}`,
},
},
],
}),
);// resources/files.js
import { defineResource } from 'tmcp/resource';
import { readFile } from 'node:fs/promises';
export const readmeResource = defineResource(
{
name: 'readme',
description: 'Project README file',
uri: 'file://README.md',
},
async (uri) => ({
contents: [
{
uri,
mimeType: 'text/markdown',
text: await readFile(uri.replace('file://', ''), 'utf8'),
},
],
}),
);// templates/user-files.js
import { defineTemplate } from 'tmcp/template';
import { getUserFile } from '../api/users.js';
export const userFileTemplate = defineTemplate(
{
name: 'user-files',
description: 'Access user files by ID',
uri: 'users/{userId}/files/{filename}',
complete: {
userId: async (arg) => ({
completion: {
values: await searchUserIds(arg),
hasMore: false,
},
}),
},
},
async (uri, { userId, filename }) => ({
contents: [
{
uri,
mimeType: 'text/plain',
text: await getUserFile(userId, filename),
},
],
}),
);// server.js
import { McpServer } from 'tmcp';
import { ZodJsonSchemaAdapter } from '@tmcp/adapter-zod';
import { addTool, multiplyTool } from './tools/calculator.js';
import { codeReviewPrompt } from './prompts/code-review.js';
import { readmeResource } from './resources/files.js';
import { userFileTemplate } from './templates/user-files.js';
const server = new McpServer(
{
name: 'my-server',
version: '1.0.0',
},
{
adapter: new ZodJsonSchemaAdapter(),
},
);
// Register multiple tools at once
server.tools([addTool, multiplyTool]);
// Or register individually
server.tool(addTool);
// Same for prompts, resources, and templates
server.prompts([codeReviewPrompt]);
server.resources([readmeResource]);
server.templates([userFileTemplate]);Adding the primitive to the server will error if the tool uses a different validation library than the one expressed in the adapter.
API Reference
McpServer
The main server class that handles MCP protocol communications.
Constructor
new McpServer(serverInfo, options);serverInfo: Server metadata (name, version, description)options: Configuration object with optional adapter (for schema conversion) and capabilities
[!IMPORTANT] While the adapter is optional (you can opt out by explicitly passing
adapter: undefined) without an adapter the server cannot accept inputs, produce structured outputs, or request elicitations at all only do this for very simple servers.
Methods
tool(definition, handler) / tools(definitions)
Register one or more tools with optional schema validation.
// Register a single tool inline
server.tool(
{
name: 'tool-name',
description: 'Tool description',
schema: yourSchema, // optional
},
async (input) => {
// Tool implementation
return result;
},
);
// Register a tool created with defineTool
import { defineTool } from 'tmcp/tool';
const myTool = defineTool(
{
name: 'tool-name',
description: 'Tool description',
schema: yourSchema,
},
async (input) => {
return result;
},
);
server.tool(myTool);
// Register multiple tools at once
server.tools([tool1, tool2, tool3]);sessionInfo contains the latest clientCapabilities, clientInfo, and logLevel reported by the connected client. Built-in transports populate it automatically, so every handler can branch on client features without additional bookkeeping.
prompt(definition, handler) / prompts(definitions)
Register one or more prompt templates with optional schema validation.
// Register a single prompt inline
server.prompt(
{
name: 'prompt-name',
description: 'Prompt description',
schema: yourSchema, // optional
complete: (arg, context) => ['completion1', 'completion2'] // optional
},
async (input) => {
// Prompt implementation
return { messages: [...] };
}
);
// Register a prompt created with definePrompt
import { definePrompt } from 'tmcp/prompt';
const myPrompt = definePrompt(
{
name: 'prompt-name',
description: 'Prompt description',
schema: yourSchema,
},
async (input) => {
return { messages: [...] };
}
);
server.prompt(myPrompt);
// Register multiple prompts at once
server.prompts([prompt1, prompt2, prompt3]);resource(definition, handler) / resources(definitions)
Register one or more static resources.
// Register a single resource inline
server.resource(
{
name: 'resource-name',
description: 'Resource description',
uri: 'file://path/to/resource'
},
async (uri, params) => {
// Resource implementation
return { contents: [...] };
}
);
// Register a resource created with defineResource
import { defineResource } from 'tmcp/resource';
const myResource = defineResource(
{
name: 'resource-name',
description: 'Resource description',
uri: 'file://path/to/resource'
},
async (uri) => {
return { contents: [...] };
}
);
server.resource(myResource);
// Register multiple resources at once
server.resources([resource1, resource2, resource3]);template(definition, handler) / templates(definitions)
Register one or more URI templates for dynamic resources.
// Register a single template inline
server.template(
{
name: 'template-name',
description: 'Template description',
uri: 'file://path/{id}/resource',
complete: (arg, context) => ['id1', 'id2'] // optional
},
async (uri, params) => {
// Template implementation using params.id
return { contents: [...] };
}
);
// Register a template created with defineTemplate
import { defineTemplate } from 'tmcp/template';
const myTemplate = defineTemplate(
{
name: 'template-name',
description: 'Template description',
uri: 'file://path/{id}/resource',
},
async (uri, params) => {
return { contents: [...] };
}
);
server.template(myTemplate);
// Register multiple templates at once
server.templates([template1, template2, template3]);withContext<T>()
Specify the type of custom context for type-safe access to application-specific data.
interface MyCustomContext {
userId: string;
permissions: string[];
database: DatabaseConnection;
}
const server = new McpServer(serverInfo, options).withContext<MyCustomContext>();
// Now you can access typed custom context in handlers
server.tool(
{
name: 'get-user-data',
description: 'Get current user data',
},
async () => {
const { userId, database } = server.ctx.custom!;
const userData = await database.users.findById(userId);
return {
content: [{ type: 'text', text: JSON.stringify(userData) }],
};
},
);ctx
Access the current request context, including session ID, auth info, client metadata, and custom context.
server.tool(
{
name: 'context-aware-tool',
description: 'Tool that uses request context',
},
async () => {
const { sessionId, auth, sessionInfo, custom } = server.ctx;
if (!custom?.userId) {
throw new Error('User authentication required');
}
if (sessionInfo?.clientInfo) {
console.log(`Serving ${sessionInfo.clientInfo.name}`);
}
return {
content: [
{
type: 'text',
text: `Hello user ${custom.userId} in session ${sessionId}`,
},
],
};
},
);receive(request, context?)
Process an incoming MCP request with optional context.
// Basic usage
const response = server.receive(jsonRpcRequest);
// With custom context (typically used by transports)
const response = server.receive(jsonRpcRequest, {
sessionId: 'session-123',
auth: authInfo,
sessionInfo: {
clientCapabilities,
clientInfo,
logLevel,
},
custom: customContextData,
});request({ method, params })
Send a raw JSON-RPC request to the connected client. This lets you call
experimental MCP APIs or proprietary extensions before they gain a dedicated
method on McpServer or to send a request with a custom JSON-schema that is not expressible with your validation library.
const response = await server.request({
method: 'elicitation/create',
params: {
message: 'Describe the deployment plan',
requestedSchema: {
type: 'object',
required: ['region', 'replicas', 'features'],
properties: {
region: {
type: 'string',
enum: ['us-east-1', 'us-west-2', 'eu-central-1'],
},
replicas: { type: 'integer', minimum: 1, maximum: 20 },
features: {
type: 'array',
items: {
type: 'string',
enum: ['canary', 'observability', 'autoscaling'],
},
minItems: 1,
},
},
},
},
});method: Fully qualified MCP client method nameparams(optional): JSON-RPC params object/array accepted by that method
Handle the resolved payload like any other JSON-RPC response—cast or (better) validate as needed when using this escape hatch.
elicitation(schema)
Request client elicitation with schema validation.
const result = await server.elicitation(schema);message(request)
Request language model sampling from the client.
const result = await server.message({
messages: [
{
role: 'user',
content: { type: 'text', text: 'Hello!' },
},
],
});refreshRoots()
Refresh the roots list from the client.
await server.refreshRoots();
console.log(server.roots); // Access current rootschanged(type, id)
Send notifications for subscriptions. When you call changed('resource', uri) the server now emits a broadcast notification, and the built-in transports look up which sessions subscribed to that URI before delivering it.
server.changed('resource', 'file://path/to/resource');progress(progress, total?, message?)
Report progress during long-running operations. Progress notifications are only sent when a progress token is provided by the client in the request's _meta.progressToken field.
server.tool(
{
name: 'process-large-file',
description: 'Process a large file with progress updates',
},
async (input) => {
const totalSteps = 100;
for (let i = 0; i <= totalSteps; i++) {
// Report progress with current step, total steps, and optional message
server.progress(
i,
totalSteps,
`Processing step ${i}/${totalSteps}`,
);
// Simulate work
await processStep(i);
}
return {
content: [{ type: 'text', text: 'Processing complete!' }],
};
},
);Parameters:
progress(number): Current progress value (should be ≤ total and always increase)total(number, optional): Maximum progress value (defaults to 1)message(string, optional): Descriptive message about current progress
Notes:
- Progress notifications are only sent when the client provides a
progressTokenin the request - Progress values should be monotonically increasing within a single operation
- Each session maintains its own progress context
log(level, data, logger?)
Send log messages to connected clients when logging is enabled.
// Log at different severity levels
server.log('info', 'Server started successfully');
server.log('warning', 'Configuration missing, using defaults');
server.log('error', 'Failed to connect to database', 'database-logger');Parameters:
level(string): Log level ('debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency')data(any): Data to loglogger(string, optional): Logger name/category
on(event, callback)
Listen to server events.
Available events:
initialize– fired after the client handshake with the parsed initialize payloadsend– emitted for point-to-point responses/notifications destined for the active sessionbroadcast– emitted for fan-out notifications (for examplenotifications/resources/updated)subscription– raised when the client subscribes to a resource URIloglevelchange– fired when the client requests a different log level
server.on('initialize', ({ clientInfo }) => {
console.log('Client initialized:', clientInfo?.name);
});
server.on('send', ({ request }) => {
console.log('Sending message:', request);
});
server.on('broadcast', ({ request }) => {
console.log('Broadcasting:', request.method);
});Advanced Examples
Progress Reporting
Provide real-time progress updates for long-running operations:
server.tool(
{
name: 'analyze-codebase',
description: 'Analyze a large codebase with progress tracking',
schema: z.object({
path: z.string(),
includeTests: z.boolean().default(false),
}),
},
async ({ path, includeTests }) => {
// Discover files to analyze
const files = await discoverFiles(path, includeTests);
const totalFiles = files.length;
server.progress(0, totalFiles, 'Starting analysis...');
const results = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Update progress with current file being processed
server.progress(
i + 1,
totalFiles,
`Analyzing ${file} (${i + 1}/${totalFiles})`,
);
// Analyze the file
const analysis = await analyzeFile(file);
results.push(analysis);
}
// Final progress update
server.progress(totalFiles, totalFiles, 'Analysis complete!');
return {
content: [
{
type: 'text',
text: `Analyzed ${totalFiles} files. Found ${results.length} issues.`,
},
],
structuredContent: {
totalFiles,
results,
issues: results.length,
},
};
},
);
// Progress also works in prompts and resources
server.prompt(
{
name: 'generate-report',
description: 'Generate a comprehensive report',
schema: z.object({
sections: z.array(z.string()),
}),
},
async ({ sections }) => {
const messages = [];
for (let i = 0; i < sections.length; i++) {
server.progress(
i,
sections.length,
`Generating section: ${sections[i]}`,
);
const content = await generateSection(sections[i]);
messages.push({
role: 'user',
content: { type: 'text', text: content },
});
}
server.progress(sections.length, sections.length, 'Report complete');
return { messages };
},
);Client Interaction Features
// Elicitation - Request structured data from client
const userData = await server.elicitation(
z.object({
name: z.string(),
age: z.number(),
preferences: z.array(z.string()),
}),
);
// Message sampling - Request AI responses
const aiResponse = await server.message({
messages: [
{
role: 'user',
content: { type: 'text', text: 'Explain quantum computing' },
},
],
maxTokens: 100,
});
// Roots management - Access client's filesystem roots
await server.refreshRoots();
console.log('Available roots:', server.roots);Event Handling
// Listen to server events
server.on('initialize', (data) => {
console.log('Client capabilities:', data.capabilities);
});
server.on('send', ({ request }) => {
console.log('Outgoing request:', request.method);
});
server.on('broadcast', ({ request }) => {
if (request.method === 'notifications/resources/updated') {
console.log('Resource updated:', request.params.uri);
}
});Resource Subscriptions
// Subscribe to resource changes
server.resource(
{
name: 'file-watcher',
description: 'Watch file changes',
uri: 'file://watched/file.txt',
},
async (uri) => {
return {
contents: [
{
uri,
mimeType: 'text/plain',
text: await readFile(uri),
},
],
};
},
);
// Notify subscribers when resource changes
server.changed('resource', 'file://watched/file.txt');Completion API
The completion API allows you to provide auto-completion suggestions for prompt and template parameters. Completion functions return an object with completion containing values, total, and hasMore properties.
Completion Response Format
{
completion: {
values: string[], // Array of completion values (max 100 items)
total?: number, // Total number of available completions
hasMore?: boolean // Whether there are more completions available
}
}Prompt Parameter Completion
server.prompt(
{
name: 'story-generator',
description: 'Generate a story with specific parameters',
schema: z.object({
genre: z.string(),
length: z.enum(['short', 'medium', 'long']),
character: z.string(),
}),
complete: {
genre: (arg, context) => {
const genres = [
'fantasy',
'sci-fi',
'mystery',
'romance',
'thriller',
];
const filtered = genres.filter((g) =>
g.startsWith(arg.toLowerCase()),
);
return {
completion: {
values: filtered,
total: filtered.length,
hasMore: false,
},
};
},
length: (arg, context) => {
const lengths = ['short', 'medium', 'long'];
const filtered = lengths.filter((l) => l.includes(arg));
return {
completion: {
values: filtered,
total: filtered.length,
hasMore: false,
},
};
},
character: (arg, context) => {
// Dynamic completion based on genre
const characters = {
fantasy: ['wizard', 'dragon', 'knight', 'elf'],
'sci-fi': ['robot', 'alien', 'cyborg', 'space-explorer'],
mystery: ['detective', 'suspect', 'witness', 'victim'],
default: ['hero', 'villain', 'sidekick', 'mentor'],
};
const genre = context.params?.genre || 'default';
const charList = characters[genre] || characters.default;
const filtered = charList.filter((c) => c.includes(arg));
return {
completion: {
values: filtered,
total: filtered.length,
hasMore: false,
},
};
},
},
},
async (input) => {
return {
description: `A ${input.length} ${input.genre} story featuring a ${input.character}`,
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Write a ${input.length} ${input.genre} story featuring a ${input.character}.`,
},
},
],
};
},
);Resource Template Completion
server.template(
{
name: 'user-profile',
description: 'Get user profile by ID',
uri: 'users/{userId}/profile',
complete: {
userId: (arg, context) => {
// Filter users based on the current input
const allUsers = ['user1', 'user2', 'user3', 'admin-user'];
const filtered = allUsers.filter((id) => id.includes(arg));
return {
completion: {
values: filtered.slice(0, 10), // Limit to 10 results
total: filtered.length,
hasMore: filtered.length > 10,
},
};
},
},
},
async (uri, params) => {
const user = await getUserById(params.userId);
return {
contents: [
{
uri,
mimeType: 'application/json',
text: JSON.stringify(user),
},
],
};
},
);Advanced Completion Examples
// Completion with async data fetching
server.template(
{
name: 'project-files',
description: 'Access project files by path',
uri: 'projects/{projectId}/files/{filePath}',
complete: {
projectId: (arg, context) => {
// Static list of project IDs
const projects = ['web-app', 'mobile-app', 'api-server'];
const filtered = projects.filter((p) => p.includes(arg));
return {
completion: {
values: filtered,
total: filtered.length,
hasMore: false,
},
};
},
filePath: async (arg, context) => {
// Dynamic file path completion based on project
const projectId = context.params?.projectId;
if (!projectId) {
return {
completion: {
values: [],
total: 0,
hasMore: false,
},
};
}
// Simulate fetching files from filesystem
const files = await getProjectFiles(projectId);
const filtered = files.filter((f) => f.includes(arg));
return {
completion: {
values: filtered.slice(0, 20),
total: filtered.length,
hasMore: filtered.length > 20,
},
};
},
},
},
async (uri, params) => {
const content = await readProjectFile(
params.projectId,
params.filePath,
);
return {
contents: [
{
uri,
mimeType: 'text/plain',
text: content,
},
],
};
},
);
// Completion with pagination support
server.prompt(
{
name: 'search-documents',
description: 'Search through large document collection',
schema: z.object({
query: z.string(),
category: z.string(),
}),
complete: {
category: (arg, context) => {
// Large category list with pagination
const allCategories = generateCategoryList(); // Assume this returns 500+ items
const filtered = allCategories.filter((c) =>
c.toLowerCase().includes(arg.toLowerCase()),
);
return {
completion: {
values: filtered.slice(0, 50), // Show first 50 matches
total: filtered.length,
hasMore: filtered.length > 50,
},
};
},
},
},
async (input) => {
const results = await searchDocuments(input.query, input.category);
return {
description: `Search results for "${input.query}" in ${input.category}`,
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Found ${results.length} documents matching "${input.query}"`,
},
},
],
};
},
);Complex Validation
const complexSchema = z.object({
user: z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().min(18).max(120),
}),
preferences: z
.object({
theme: z.enum(['light', 'dark']),
notifications: z.boolean(),
})
.optional(),
tags: z.array(z.string()).default([]),
});
server.tool(
{
name: 'create-user',
description: 'Create a new user with preferences',
schema: complexSchema,
},
async (input) => {
// Input is fully typed and validated
const { user, preferences, tags } = input;
const result = await createUser(user, preferences, tags);
return {
content: [
{
type: 'text',
text: `User created: ${user.name} (${user.email})`,
},
],
};
},
);Dynamic Enabling/Disabling
All MCP capabilities (tools, prompts, resources, templates) support dynamic enabling and disabling through the enabled function. This allows you to conditionally show or hide capabilities based on runtime conditions, user permissions, or any other logic.
Basic Usage
The enabled function is called before each list operation and determines whether the capability should be included in the response:
server.tool(
{
name: 'admin-only-tool',
description: 'Administrative tool',
enabled: () => {
// Only show this tool if user is admin
return getCurrentUser().isAdmin;
},
},
async (input) => {
return { content: [{ type: 'text', text: 'Admin action completed' }] };
},
);Async Enabled Functions
The enabled function can be synchronous or asynchronous:
server.resource(
{
name: 'private-document',
description: 'Access private documents',
uri: 'private://document.txt',
enabled: async () => {
// Check permissions asynchronously
const user = await getCurrentUser();
return await hasPermission(user.id, 'read-private-docs');
},
},
async (uri) => {
return {
contents: [
{
uri,
mimeType: 'text/plain',
text: await readPrivateDocument(uri),
},
],
};
},
);Context-Aware Enabling
Within the enabled function you can read the session context with server.ctx, allowing for user-specific or session-specific logic:
server.prompt(
{
name: 'personalized-prompt',
description: 'User-specific prompt template',
enabled: () => {
// Access session information
const sessionId = server.ctx.sessionId;
const userPrefs = getUserPreferences(sessionId);
return userPrefs.enablePersonalization;
},
},
async (input) => {
return {
messages: [
{
role: 'user',
content: { type: 'text', text: 'Personalized content...' },
},
],
};
},
);or enable something based on client capabilities or info
server.prompt(
{
name: 'personalized-prompt',
description: 'Claude Code prompt',
enabled: () => {
// Access session information
const clientInfo = server.ctx.sessionInfo?.clientInfo;
return clientInfo?.name === 'Claude Code';
},
},
async (input) => {
return {
messages: [
{
role: 'user',
content: { type: 'text', text: 'Personalized content...' },
},
],
};
},
);or
server.prompt(
{
name: 'fetch-repositories',
description: 'fetch the repositories of the user',
enabled: () => {
// Access session information
const clientCapabilities =
server.ctx.sessionInfo?.clientCapabilities;
return clientCapabilities?.elicitation != null;
},
},
async (input) => {
const username = await server.elicitation(
v.object({
value: v.string(),
}),
);
const repos = await fetchRepos(username.value);
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Your repositories are ${repos.join(', ')}`,
},
},
],
};
},
);Template Enabling
URI templates also support the enabled function:
server.template(
{
name: 'user-files',
description: 'Access user-specific files',
uri: 'users/{userId}/files/{filename}',
enabled: async () => {
// Check if file system is available
return await isFileSystemMounted();
},
complete: {
userId: (arg, context) => ({
completion: {
values: ['user1', 'user2', 'user3'],
total: 3,
hasMore: false,
},
}),
},
},
async (uri, params) => {
const content = await readUserFile(params.userId, params.filename);
return {
contents: [
{
uri,
mimeType: 'text/plain',
text: content,
},
],
};
},
);Error Handling
If an enabled function throws an error, the capability will be treated as disabled:
server.tool(
{
name: 'network-tool',
description: 'Tool requiring network access',
enabled: () => {
// If network check fails, tool is disabled
if (!checkNetworkConnection()) {
throw new Error('Network unavailable');
}
return true;
},
},
async (input) => {
return {
content: [{ type: 'text', text: 'Network operation completed' }],
};
},
);Performance Considerations
- The
enabledfunction is called every time a list is requested - Keep enabled functions lightweight to avoid performance issues
- Consider caching expensive checks when possible
- Async functions add latency to list operations
Use Cases
Common scenarios where enabled functions are useful:
- Permission-based access: Show tools only to authorized users
- Feature flags: Enable/disable features based on configuration
- Resource availability: Hide resources when underlying systems are unavailable
- User preferences: Customize available capabilities per user
- Time-based access: Enable tools only during specific hours
- License restrictions: Limit features based on subscription level
Dynamic Properties with Getters
Sometimes you need properties that are computed dynamically at list-time rather than registration-time. For example, you might want to serve different descriptions based on which client is connected.
tmcp preserves JavaScript getters on the configuration object, allowing you to define properties that are evaluated each time the capability is listed:
server.tool(
{
name: 'search',
get description() {
const client = server.ctx.sessionInfo?.clientInfo?.name;
if (client === 'claude-code') {
return 'Search the codebase for files, symbols, or text patterns';
}
return 'Search for information';
},
},
() => {
return { content: [{ type: 'text', text: 'Search results...' }] };
},
);The same pattern works for resources, templates, and prompts.
Contributing
Contributions are welcome! Please see our contributing guidelines for details.
Acknowledgments
Huge thanks to Sean O'Bannon that provided us with the @tmcp scope on npm.
License
MIT © Paolo Ricciuti
