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.x as the 0.x of any other package. Sorry for the disservice, every breaking will be properly labeled in the PR name.
A lightweight, schema-agnostic Model Context Protocol (MCP) server implementation with unified API design.
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
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
pnpm install tmcp
# Choose your preferred schema library adapter
pnpm install @tmcp/adapter-zod zod
# Choose your preferred transport
pnpm install @tmcp/transport-stdio # For CLI/desktop apps
pnpm install @tmcp/transport-http # For web-based clientsimport { McpServer } from 'tmcp';
import { ZodJsonSchemaAdapter } from '@tmcp/adapter-zod';
import { StdioTransport } from '@tmcp/transport-stdio';
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 a + b;
case 'subtract':
return a - b;
case 'multiply':
return a * b;
case 'divide':
return a / b;
}
},
);
// Start the server with stdio transport
const transport = new StdioTransport(server);
transport.listen();import { McpServer } from 'tmcp';
import { ZodJsonSchemaAdapter } from '@tmcp/adapter-zod';
import { HttpTransport } from '@tmcp/transport-http';
import { z } from 'zod';
const adapter = new ZodJsonSchemaAdapter();
const server = new McpServer(/* ... same server config ... */);
// Add tools as above...
// Create HTTP transport
const transport = new HttpTransport(server);
// Use with your preferred HTTP server (Bun example)
Bun.serve({
port: 3000,
async fetch(req) {
const response = await transport.respond(req);
if (response === null) {
return new Response('Not Found', { status: 404 });
}
return response;
},
});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:
// tools/calculator.ts
import { defineTool } from 'tmcp/tool';
import { tool } from 'tmcp/utils';
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 }) => tool.text(`${a} + ${b} = ${a + b}`),
);// server.ts
import { McpServer } from 'tmcp';
import { addTool } from './tools/calculator.js';
const server = new McpServer(/* ... */);
// Register a single tool
server.tool(addTool);
// Or register multiple tools at once
server.tools([addTool, multiplyTool, divideTool]);This approach enables:
- Modular organization: Keep related functionality in separate files
- Reusability: Share tool definitions across multiple servers
- Better testing: Test tool logic independently
- Type safety: Full TypeScript inference for parameters and return types
The same pattern works for definePrompt, defineResource, and defineTemplate with their corresponding server.prompts(), server.resources(), and server.templates() methods.
Adding the primitive to the server will error if the tool uses a different validation library than the one expressed in the adapter.
The main server class that handles MCP protocol communications.
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.
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 { content: [{ type: 'text', text: 'Tool 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 { content: [{ type: 'text', text: 'Tool result' }] };
},
);
server.tool(myTool);
// Register multiple tools at once
server.tools([tool1, tool2, tool3]);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: {
paramName: (arg, context) => ({
completion: {
values: ['completion1', 'completion2'],
total: 2,
hasMore: false
}
})
} // 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]);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]);Most handlers end by returning some variant of { content: [...] }, { messages: [...] }, or { completion: { ... } }. That shape is repetitive and easy to get wrong, especially when you also need to wire isError or structuredContent. The tmcp/utils entry point ships tiny factories that return the correct MCP payloads for you so handlers can stay focused on business logic.
import { tool, resource, prompt, complete } from 'tmcp/utils';
server.tool({ name: 'health-check', description: 'Ping' }, async () =>
tool.text('ok'),
);
server.tool(
{ name: 'profile-picture', description: 'My Profile Picture' },
async () => tool.media('image', await loadPng(), 'image/png'),
);
server.resource(
{ name: 'readme', description: 'Project 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: 'Template with completion',
uri: 'users/{id}',
complete: {
id: async (arg) => complete.values(await findMatchingIds(arg)),
},
},
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,
);Each helper is fully typed and returns the correct MCP structure (CallToolResult, ReadResourceResult, GetPromptResult, or CompleteResult). That means you can still provide structuredContent, embed resources, or merge multiple results via mix without having to copy/paste the surrounding boilerplate.
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: {
id: (arg, context) => ({
completion: {
values: ['id1', 'id2', 'id3'],
total: 3,
hasMore: false
}
})
} // 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]);Process an incoming MCP request with optional context information.
const response = server.receive(jsonRpcRequest, {
sessionId: 'session-123',
auth: authInfo,
sessionInfo: {
clientCapabilities, // cached from the client's initialize call
clientInfo, // client name/version metadata
logLevel, // last log level requested by the client
},
custom: {
userId: 'alice',
},
});Tip
sessionInfo is automatically populated by the built-in transports. You can read it from handlers via server.ctx.sessionInfo to tailor behaviour to the connected client.
Send a raw JSON-RPC request to the connected client. This is useful for
calling experimental MCP APIs or any custom client method that does not yet
have a dedicated helper in McpServer or to send a request with a custom JSON-schema that is not expressible with your validation library.
const result = await server.request({
method: 'elicitation/create',
params: {
message: 'Provide deployment metadata',
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.
// Use different schemas for different tools
import { z } from 'zod';
import * as v from 'valibot';
server.tool(
{
name: 'zod-tool',
schema: z.object({ name: z.string() }),
},
async ({ name }) => `Hello ${name}`,
);
server.tool(
{
name: 'valibot-tool',
schema: v.object({ age: v.number() }),
},
async ({ age }) => `Age: ${age}`,
);server.template(
{
name: 'user-profile',
description: 'Get user profile by ID',
uri: 'users/{userId}/profile',
complete: (arg, context) => {
// Provide completions for userId parameter
return ['user1', 'user2', 'user3'];
},
},
async (uri, params) => {
const user = await getUserById(params.userId);
return {
contents: [
{
uri,
mimeType: 'application/json',
text: JSON.stringify(user),
},
],
};
},
);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;
return await createUser(user, preferences, tags);
},
);Contributions are welcome! Please see our contributing guidelines for details.
Huge thanks to Sean O'Bannon that provided us with the @tmcp scope on npm.
MIT © Paolo Ricciuti