mctxdocs
Build Your MCP Server

Tools

Actions the MCP client can invoke. Learn handler signatures, the T schema system, annotations, mimeType, progress tracking, and error handling.

Need help? Connect help.mctx.ai for instant answers.

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>
ParameterTypeNotes
argsRecord<string, any>Validated input from the client. Shape matches your .input definition.
askAskFunction | nullLLM sampling function. null when the client doesn't support sampling. Always guard before calling.
ctxMcpContextRequest 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:

TypeKey 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:

HintDefaultWhat it communicates
readOnlyHintfalseTool only reads data — no writes, creates, or deletes
destructiveHinttrueTool can permanently destroy data
openWorldHinttrueTool calls external systems (APIs, databases, file systems)
idempotentHintfalseSame 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:

  1. Does it write, create, update, or delete anything? If no → readOnlyHint: true
  2. Can it permanently destroy data (delete records, drop tables)? If no → destructiveHint: false
  3. Does it call external services (APIs, databases, file systems)? If yes → openWorldHint: true
  4. Does the same input always produce the same output? If yes → idempotentHint: true

Common patterns:

Tool typereadOnlydestructiveopenWorldidempotent
Read-only API querytruefalsetruetrue
Pure local computationtruefalsefalsetrue
Creates a recordfalsefalsetruefalse
Deletes or modifies datafalsetruetruefalse

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


See something wrong? Report it or suggest an improvement — your feedback helps make these docs better.