How to Add Tools to Your MCP Server
Register tools with server.tool(), define parameters with Zod, implement callbacks, and test in Cursor Agent and Claude Desktop.
--- title: How to add tools to an MCP server description: Tools are what make an MCP server useful. Here is how to register them with server.tool(), define parameters with Zod schemas, connect to external APIs, and test across multiple clients. author: FractionalSkill ---
How to add tools to an MCP server
An MCP server with no tools is like a phone with no apps installed. It connects to the client, the handshake works, the status light turns green. And then nothing happens because there is nothing for the AI to actually call.
Tools are the working parts. Each one gives the AI a specific action it can perform: create a task, list teams, pull a report, update a record. The MCP TypeScript SDK makes registering them straightforward, but there are a few patterns and gotchas that will save you real debugging time if you get them right the first time.
What follows is the full walkthrough for adding tools to an MCP server, from a single server.tool() call through testing in both Cursor and Claude Desktop.
The anatomy of server.tool()
Every tool you register follows the same four-part structure. The TypeScript SDK handles the protocol details. Your job is to fill in these four pieces.
| Component | What it does | Example |
|---|---|---|
| Name | A string identifier the AI sees and calls by name | "create_task" |
| Description | Tells the AI when and why to use this tool | "Create a new task for a team in Linear" |
| Parameters | A Zod schema defining what inputs the tool accepts | { teamId: z.string(), title: z.string() } |
| Callback | The function that runs when the AI invokes the tool | Makes an API call, returns the result |
The description matters more than most people expect. The AI reads it to decide which tool fits the user's request. A vague description like "does stuff with tasks" leads to missed tool calls. A specific description like "Create a new task for a team in Linear" gives the model exactly what it needs to route correctly.
Here is a complete server.tool() implementation that creates a task in Linear:
server.tool(
"create_task",
"Create a new task for a team in Linear",
{
teamId: z.string().describe("ID of the team to create the task for"),
title: z.string().describe("Title of the task"),
description: z.string().describe("Description of the task"),
priority: z.number().describe(
"Priority of the task, 0-4 where 0 is no priority"
),
},
async ({ teamId, title, description, priority }) => {
try {
const issue = await linearClient.createIssue({
teamId,
title,
description,
priority,
});
return {
content: [{ type: "text", text: JSON.stringify(issue) }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Error creating task: ${error}` }],
};
}
}
);
Name is the first argument. Description is the second. Parameters come third as a Zod schema object where each field gets its own .describe() string. Callback is the async function at the end that does the actual work.
The callback always returns an object with a content array. Each item in that array has a type (usually "text") and the text value itself. Wrapping the API response in JSON.stringify lets the AI parse the structured data and synthesize a clean response for the user.
> Error handling is not optional. Wrap every callback in a try-catch. When something breaks during an API call, a clean error message helps the AI explain what went wrong. Without it, you get cryptic failures that are painful to debug.
Building tools that work together
A single tool rarely covers a complete workflow. The create_task tool above needs a team ID, but users don't memorize team IDs. They know team names. That means you need a second tool that retrieves the available teams and their IDs.
Here is what that looks like:
server.tool(
"list_teams",
"List all teams in Linear",
{},
async () => {
const teams = await linearClient.teams();
return {
content: [{ type: "text", text: JSON.stringify(teams) }],
};
}
);
This tool takes no parameters at all. The empty object {} for the schema is valid. It queries the Linear API for all teams under your account and returns them.
The AI chains these together automatically. When a user says "create a new task in the Demos team that says finish MCP course," the AI recognizes it needs the team ID first. It calls listteams, finds the team named "Demos," extracts the ID, then calls createtask with that ID plus the title. You don't write the orchestration logic. The model handles it.
Every time you add or modify a tool, you need to recompile. The TypeScript SDK compiles to JavaScript, and the MCP client runs the compiled output.
npm run build
After the build completes, refresh or restart the MCP server in your client. In Cursor, go to Settings, find the MCP tab, and hit the refresh icon next to your server. The status indicator should turn green, confirming the client loaded your updated tools.
Testing across Cursor and Claude Desktop
One of the practical benefits of MCP is that the same server works in any compatible client. Build it once, connect it to Cursor Agent, Claude Desktop, or any other MCP client without changing a line of server code.
Testing in Cursor Agent. After recompiling and refreshing, open the agent panel and ask a question that should trigger your tool.
You: What are my teams in Linear?
Cursor Agent: [Runs list_teams tool]
Your Linear workspace has 4 teams:
- Demos
- Content
- Takeoff
- Takeoff AI
You: Create a new task in Demos that says "finish MCP course"
Cursor Agent: [Runs create_task tool]
Task successfully created in the Demos team:
Title: finish MCP course
Status: Backlog
The agent identifies the right tool from your description, calls it, and presents the result. If you have both listteams and createtask registered, it chains them together when the workflow requires it.
Testing in Claude Desktop. Open the Claude Desktop config file and add your server entry alongside any existing servers:
{
"mcpServers": {
"linear": {
"command": "node",
"args": [
"/path/to/your/build/index.js",
"your-linear-api-key"
]
}
}
}
Save the file, then fully quit and reopen Claude Desktop. Once it loads, you should see your tools listed in the available tools section. The same queries work identically: ask for your teams, then ask it to create a task.
> The console.error gotcha with Claude Desktop > > If Claude Desktop throws errors when loading your MCP server, check your logging statements. Claude Desktop's MCP client reads stdout for protocol messages. Any console.log() call in your server code writes to stdout and corrupts the protocol stream. > > The fix: replace every console.log() with console.error() in your server code. console.error() writes to stderr, which the MCP client ignores. Your logging still works for debugging, but it won't interfere with the protocol. > > This is specific to Claude Desktop. Cursor's MCP implementation handles it differently and doesn't have this issue.
From two tools to a full server
The pattern for scaling an MCP server is the same pattern you used for the first tool. Each server.tool() call is independent. You can add as many as your API supports. A Linear MCP server might eventually include tools for creating tasks, listing teams, updating issue status, assigning tasks, and querying project timelines.
A few guidelines that keep things clean as the tool count grows:
- One action per tool. A tool called
manage_tasksthat creates, updates, and deletes based on a mode parameter is harder for the AI to reason about than three separate tools with clear names. - Describe parameters for the AI, not for developers. The
.describe()string on each Zod field is what the model reads to understand what value to pass. "Priority of the task, 0-4 where 0 is no priority" is more useful than "integer value." - Return structured data.
JSON.stringifyon the API response gives the AI the full payload to work with. It will extract exactly what the user asked for and present it cleanly. - Keep API keys out of your code. Pass them as command-line arguments or environment variables. The examples here use command-line arguments for clarity, but environment variables are the more secure approach for any server you share or deploy.
The tools are the part of MCP that people actually interact with. Resources and prompts add more capabilities, but tools are where the real operational value starts. Get two or three working tools connected to an API you use daily, and the workflow possibilities become obvious fast.
> Start here. Pick one API you already use in your client work, whether that's Linear, Notion, Asana, or a CRM. Build a list tool and a create tool for its most common object. Register both with server.tool(), recompile, and test in your preferred client. Two tools, one API, working end-to-end. Build from there.