Tools
Actions the MCP client can invoke. Learn handler signatures, the T schema system, annotations, mimeType, progress tracking, and error handling.
A tool is an action an MCP client can invoke. The client sees the tool's name, description, and input schema, then calls it when the user asks for something your tool can do.
Register a tool with server.tool(name, handler):
import { createServer, T } from "@mctx-ai/mcp";
const server = createServer();
const greet = (args) => `Hello, ${args.name}!`;
greet.description = "Greet a person by name";
greet.input = {
name: T.string({ required: true, description: "Name to greet" }),
};
server.tool("greet", greet);
export default { fetch: server.fetch };Handler signature
handler(args, ask?, ctx?) => result | Promise<result>| Parameter | Type | Notes |
|---|---|---|
args | Record<string, any> | Validated input from the client. Shape matches your .input definition. |
ask | AskFunction | null | LLM sampling function. null when the client doesn't support sampling. Always guard before calling. |
ctx | McpContext | Request context. Contains ctx.userId — the authenticated subscriber identity injected by the mctx platform. |
The handler can return a string, an object (auto-serialized to JSON), or a Promise of either.
// String return
const greet = (args) => `Hello, ${args.name}!`;
// Object return — serialized to JSON automatically
const calculate = (args) => ({
operation: args.op,
result: args.a + args.b,
});
// Async — fetch external data
const getWeather = async (args) => {
const response = await fetch(`https://weather.example.com/${args.city}`);
return response.json();
};The T schema
T is the type system for defining tool inputs. The framework validates input against your schema before your handler runs — you never need to check types manually.
import { T } from "@mctx-ai/mcp";
handler.input = {
name: T.string({ required: true, description: "User name" }),
age: T.number({ min: 0, max: 150 }),
role: T.string({ enum: ["admin", "user", "guest"], default: "user" }),
active: T.boolean({ default: true }),
tags: T.array({ items: T.string() }),
address: T.object({
properties: {
city: T.string({ required: true }),
zip: T.string({ pattern: "^[0-9]{5}$" }),
},
}),
};Type options:
| Type | Key options |
|---|---|
T.string() | required, description, enum, default, minLength, maxLength, pattern, format |
T.number() | required, description, enum, default, min, max |
T.boolean() | required, description, default |
T.array() | required, description, items, default |
T.object() | required, description, properties, additionalProperties, default |
See the Framework API Reference for the full option list.
Tool description and mimeType
.description tells the client what the tool does. Write it from the client's point of view — what action does it perform?
handler.description = "Search the project issue tracker by keyword and status";.mimeType tells the client how to interpret the result. Omit it when returning plain strings or JSON objects — those are the default. Set it explicitly for structured text formats:
handler.mimeType = "text/markdown";
// or
handler.mimeType = "application/json";Annotations
Annotations are hints you attach to a tool to tell clients how safe and consequential it is. Clients use them to decide whether to ask for user permission before calling the tool, and to show appropriate safety UI.
const searchDocs = async (args) => {
const results = await db.search(args.query);
return results;
};
searchDocs.description = "Search documentation by keyword";
searchDocs.input = { query: T.string({ required: true }) };
searchDocs.annotations = {
readOnlyHint: true, // only reads; no writes or side effects
destructiveHint: false, // cannot destroy anything
openWorldHint: true, // calls an external database
idempotentHint: true, // same query always returns the same results
};
server.tool("search_docs", searchDocs);The four hints:
| Hint | Default | What it communicates |
|---|---|---|
readOnlyHint | false | Tool only reads data — no writes, creates, or deletes |
destructiveHint | true | Tool can permanently destroy data |
openWorldHint | true | Tool calls external systems (APIs, databases, file systems) |
idempotentHint | false | Same input always produces the same result |
Defaults are pessimistic. If you omit an annotation, clients assume the worst case: writes are possible, data could be destroyed, external services are involved. Set all four explicitly on every tool.
Decision checklist:
- Does it write, create, update, or delete anything? If no →
readOnlyHint: true - Can it permanently destroy data (delete records, drop tables)? If no →
destructiveHint: false - Does it call external services (APIs, databases, file systems)? If yes →
openWorldHint: true - Does the same input always produce the same output? If yes →
idempotentHint: true
Common patterns:
| Tool type | readOnly | destructive | openWorld | idempotent |
|---|---|---|---|---|
| Read-only API query | true | false | true | true |
| Pure local computation | true | false | false | true |
| Creates a record | false | false | true | false |
| Deletes or modifies data | false | true | true | false |
Annotations are advisory — they help clients make better decisions, but clients are not required to enforce them. A readOnlyHint: true tool is still responsible for not writing data.
Progress-tracked tools
Some operations take time. Use a generator function to report progress so the client can show a status indicator:
import { createProgress } from "@mctx-ai/mcp";
const analyzeRepo = function* (args) {
const step = createProgress(3); // 3 total steps
yield step(); // 1/3
// clone and scan the repo...
yield step(); // 2/3
// analyze code patterns...
yield step(); // 3/3
// build summary...
return "Analysis complete: 47 files, 12 potential improvements found.";
};
analyzeRepo.description = "Analyze a GitHub repository for code quality issues";
analyzeRepo.input = {
repoUrl: T.string({ required: true, description: "GitHub repository URL" }),
};
analyzeRepo.annotations = {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: true,
idempotentHint: true,
};
server.tool("analyze_repo", analyzeRepo);createProgress(total?) returns a step function. Each yield step() emits a progress notification to the client. Pass total to show determinate progress (1 of 3); omit it for indeterminate progress.
The handler can also be an async function* if you need to await work between steps.
Limits: The framework enforces maxExecutionTime: 60000ms and maxYields: 10000 as generator guardrails. Handlers that exceed either limit are terminated.
Error handling
Throw an Error to signal a hard failure. The framework converts it into an MCP error response. The client receives the error message; your handler stops immediately.
const divide = (args) => {
if (args.b === 0) {
throw new Error("Cannot divide by zero. Provide a non-zero divisor.");
}
return args.a / args.b;
};Write error messages for the client — they are shown to the user or passed to the LLM for interpretation. Be specific about what went wrong and what the caller can do to fix it.
See also
- Resources — named data the client can read
- Prompts — reusable conversation templates
- Concepts — handlers, the server object, and how it all fits together
- Framework API Reference — every export, type, and option
See something wrong? Report it or suggest an improvement — your feedback helps make these docs better.