create-mcp-tool
Skill3 starsChanged 34 days agoMITView on GitHub
Add a new MCP tool to this server template. Use when asked to "add a tool", "add an MCP tool", "create a new tool", or "implement a new tool".
2 other projects use a version of this.
Adopted by
Most recently taken up by
- jmrplens/libgen-mcptheir own version, first seen Aug 21, 2026
- jmrplens/gitlab-mcp-servertheir own version, first seen May 30, 2026
Other versions2 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 2
- jmrplens/gitlab-mcp-server★ 47same name
- jmrplens/libgen-mcp★ 31same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository3 other files it gives its agents.
- AGENTS.mdAGENTS.md
- migrate-mcp-template-to-effectSkill
- beads-engineering-backlogSkill
The file
We split this skill into the rules it gives an agent
12 rules. About 0 are common, 2 are distinctive, and 10 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
10
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 74
Use the shared adapter and Effect imports:
- Very specific: line 102
Use .annotate({ description: "..." }) for field descriptions and .check(...) with Effect checks such as Schema.isBetween, …
- Very specific: line 103
Use Schema.optionalKey for optional fields.
- Very specific: line 105
Keep the tool's business logic in an Effect.Effect and call runMcpEffect() only in the MCP registration callback
- Very specific: line 107
Keep the tool's logic in a function that receives the arguments and ctx it uses.
- Very specific: line 109
Never log the raw args object — tool inputs may carry user-provided or sensitive data.
- Very specific: line 113
Add an annotations field to signal the tool's safety profile to LLM clients.
- Very specific: line 207
Add tests to src/tools.test.ts, colocated with src/tools.ts.
- Distinctive: line 209
Follow the existing pattern:
- Very specific: line 269
Use InMemoryTransport.createLinkedPair() to connect a real Client and McpServer in-process — no HTTP server needed
- Very specific: line 271
Use afterEach to close both sides leak-safely, even if assertions fail
- Very specific: line 274
Add a schema-advertising assertion with client.listTools() when a tool introduces or changes an input/output schema
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Add an MCP Tool to This Template
This skill walks you through adding a new tool to the MCP server. If you need to reference MCP SDK types, capabilities, or advanced patterns not covered here, use the context7 MCP server or WebFetch to look up the @modelcontextprotocol/server documentation.
Template Conventions
| Concern | Location |
| Tool registration | src/tools.ts inside registerTools(server) via server.registerTool() |
| Tool logic | src/tools.ts — functions that take only the dependencies they need |
| Response formatting | createTextResult() / createErrorResult() from src/lib/utils.ts |
| Input validation | Effect Schema adapted with toMcpSchema() |
| Typed output | outputSchema + structuredContent (emitted automatically by createTextResult) |
| Tests | src/tools.test.ts (colocated with src/tools.ts) |
Everything for a tool lives in src/tools.ts. registerTools(server) is the single source of truth for tool wiring — it's called from getServer() in src/app.ts and reused by the tests, so registration can never drift from what's tested. Each tool's implementation is an Effect workflow that takes the arguments and ctx it uses, which keeps it small and easy to drive through every branch. Return runMcpEffect(effect) only at the MCP callback boundary. Use Effect.all with explicit concurrency for independent parallel work; keep Effect.tryPromise at Promise-based API boundaries. app.ts owns HTTP routing via createMcpHandler, while index.ts owns startup and shutdown; per the MCP 2026-07-28 spec there's no session: getServer() runs fresh for every request.
Step 1: Name the Tool
Tool names should use snake_case — this is the prevailing community convention (e.g. get_user, search_documents, list_items). The MCP spec (SEP-986) allows a-z, A-Z, 0-9, _, -, ., and /, but does not mandate a style. Names must be 1–64 characters and unique within the server.
The tool's description and every Effect Schema field's description annotation are how LLM clients decide when and how to call the tool — write them as if you're explaining the tool to someone who has never seen the codebase.
Step 2: Register the Tool
Open src/tools.ts and add a server.registerTool(...) call inside registerTools, following the pattern of the existing echo tool:
server.registerTool(
"my_tool",
{
title: "My Tool",
description: "A clear, specific description of what this tool does and when to use it",
inputSchema: toMcpSchema(
Schema.Struct({
query: Schema.String.annotate({
description: "The input to process — be specific about format or constraints",
}),
limit: Schema.optionalKey(
Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 })).annotate({
description: "Maximum number of results to return (default: 10)",
}),
),
}),
),
// Declare the shape of a successful result so clients can consume
// structuredContent (createTextResult emits it automatically).
outputSchema: toMcpSchema(
Schema.Struct({
output: Schema.String,
count: Schema.Number,
}),
),
annotations: { readOnlyHint: true, openWorldHint: false },
},
(args, ctx) => runMcpEffect(myTool(args, ctx)),
);Use the shared adapter and Effect imports:
import { Effect, Schema } from "effect";
import { toMcpSchema } from "./lib/mcp-schema.ts";
import { createErrorResult, createTextResult, runMcpEffect } from "./lib/utils.ts";Then implement myTool as an Effect workflow lower in the file:
function myTool(
args: { query: string; limit?: number },
ctx: ServerContext,
): Effect.Effect<CallToolResult> {
return Effect.gen(function* () {
const toolName = "my_tool";
const requestId = ctx.mcpReq.id;
const result = { output: args.query, count: 1 };
yield* logger.info({ toolName, requestId }, "Tool executed");
return createTextResult(result);
});
}Key points:
- Define input and output contracts with Effect
Schema.Struct(...), then wrap them withtoMcpSchema(...). The adapter supplies both Standard Schema validation and the JSON Schema needed by MCP, includingdraft-07anddraft-2020-12targets - Use
.annotate({ description: "..." })for field descriptions and.check(...)with Effect checks such asSchema.isBetween,Schema.isMinLengthandSchema.isPatternfor constraints - Use
Schema.optionalKeyfor optional fields.Schema.optionalalso acceptsundefined, which the JSON Schema advertises asnull, so clients get toldnullis allowed and then fail validation - Give
Schema.isPatternregexes theuflag; Effect omits patterns without it from the advertised JSON Schema - Keep the tool's business logic in an
Effect.Effectand callrunMcpEffect()only in the MCP registration callback - Return
createTextResult(data)on success — it emits both a text block andstructuredContentforoutputSchema-aware clients (structured content) - Keep the tool's logic in a function that receives the arguments and
ctxit uses. This keeps each tool small and testable - Yield
logger.info/logger.errorEffects inside the workflow, always includingtoolNameandrequestId(ctx.mcpReq.id) for correlation — there's nosessionIdunder the stateless spec - Never log the raw
argsobject — tool inputs may carry user-provided or sensitive data. Log individual fields only when they're known to be safe (seeaction/kindinelicitEcho's error/decline/cancel logs insrc/tools.ts)
Tool Annotations
Add an annotations field to signal the tool's safety profile to LLM clients. All fields are optional booleans — only set the ones that differ from the defaults:
| Annotation | Default | When to set |
readOnlyHint | false | true if the tool only reads data and never modifies anything |
destructiveHint | true | false if writes are additive only (e.g. create/append, never delete) — only meaningful when readOnlyHint: false |
idempotentHint | false | true if calling repeatedly with the same args has no additional effect — only meaningful when readOnlyHint: false |
openWorldHint | true | false if the tool operates on a closed, known domain (e.g. reads only from local config) |
server.registerTool(
"my_tool",
{
title: "My Tool",
description: "...",
inputSchema: toMcpSchema(Schema.Struct({ /* ... */ })),
annotations: {
readOnlyHint: true, // this tool only reads, never writes
openWorldHint: false, // operates on a closed local domain
},
},
(args, ctx) => runMcpEffect(myTool(args, ctx)),
);Step 3: Handle Errors
Report execution failures in-band with createErrorResult (which sets isError: true) rather than throwing — this lets the client/model distinguish a failure from a normal result. Reserve isError for genuine failures; valid outcomes (e.g. a user declining an elicitation, or an empty search) are normal results via createTextResult. See Error Handling in the spec.
function myTool(args, ctx): Effect.Effect<CallToolResult> {
return Effect.gen(function* () {
const toolName = "my_tool";
const requestId = ctx.mcpReq.id;
const result = yield* Effect.tryPromise({
try: () => doSomething(args.query),
catch: (error) => (error instanceof Error ? error.message : String(error)),
}).pipe(
Effect.tapError((error) =>
logger.error({ toolName, requestId, error }, "Tool execution failed"),
),
);
yield* logger.info({ toolName, requestId }, "Tool executed");
return createTextResult(result);
}).pipe(
Effect.catch((error) => Effect.succeed(createErrorResult({ error }))),
);
}Needing user input mid-call
If a tool must ask the user something before it can finish (the elicit_echo pattern), don't use the old synchronous elicitInput() push request — it throws on a 2026-07-28-era connection. Instead return an inputRequired() result and read the response back via inputResponse()/ctx.mcpReq.inputResponses on the retried call:
import type { CallToolResult, InputRequiredResult, ServerContext } from "@modelcontextprotocol/server";
import { inputRequired, inputResponse } from "@modelcontextprotocol/server";
import { Effect } from "effect";
function myTool(ctx: ServerContext): Effect.Effect<CallToolResult | InputRequiredResult> {
return Effect.gen(function* () {
const response = inputResponse(ctx.mcpReq.inputResponses, "confirm");
if (response.kind === "missing") {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: "Proceed?",
requestedSchema: {
type: "object",
properties: { confirm: { type: "boolean" } },
required: ["confirm"],
},
}),
},
});
}
// response.kind === "elicit" here; handle response.action (accept/decline/cancel)
// ...
return createTextResult({ ok: true });
});
}The client resolves the embedded request and retries the same tool call with the response attached — this handler runs again, on a fresh request id, and sees it via ctx.mcpReq.inputResponses. See elicitEcho in src/tools.ts for a complete worked example, including decline/cancel handling.
Step 4: Write Tests
Add tests to src/tools.test.ts, colocated with src/tools.ts. Tests use InMemoryTransport to wire up a real client/server pair in-process, exercising the full MCP protocol stack (tool registration, request/response, elicitation) with only the user-facing parts mocked.
Follow the existing pattern:
import { afterEach, describe, it, expect } from "vitest";
import { Client } from "@modelcontextprotocol/client";
import { McpServer, InMemoryTransport, type CallToolResult } from "@modelcontextprotocol/server";
import { registerTools } from "./tools.ts";
let harness: { client: Client; server: McpServer } | undefined;
afterEach(async () => {
if (harness) {
await harness.server.close();
await harness.client.close();
harness = undefined;
}
});
async function setupClientServer() {
const server = new McpServer({ name: "test-server", version: "0.0.0" });
registerTools(server);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client(
{ name: "test-client", version: "0.0.0" },
{ capabilities: { elicitation: {} } },
);
await server.connect(serverTransport);
await client.connect(clientTransport);
harness = { client, server };
return harness;
}
function parseContent(result: CallToolResult): Record<string, unknown> {
const item = result.content[0];
expect(item?.type).toBe("text");
if (item?.type !== "text") {
throw new Error("expected text content");
}
return JSON.parse(item.text);
}
describe("my_tool", () => {
it("returns the expected output", async () => {
const { client } = await setupClientServer();
const result = await client.callTool({
name: "my_tool",
arguments: { query: "hello" },
});
const parsed = parseContent(result);
expect(parsed.output).toBe("hello");
});
});Key points:
- Use
InMemoryTransport.createLinkedPair()to connect a realClientandMcpServerin-process — no HTTP server needed - Connect the server before the client (
server.connectthenclient.connect) so the initialize handshake completes - Use
afterEachto close both sides leak-safely, even if assertions fail - The existing
setupClientServertakes an options object — passelicitHandlerto answer elicitation, andsupportsElicitation: falseto test the unsupported-client path. Reuse it rather than adding new helpers client.callTool()already returns a typedCallToolResult— no schema re-validation needed, just narrowcontent[0].type === "text"before parsing it- Add a schema-advertising assertion with
client.listTools()when a tool introduces or changes an input/output schema - Assert
result.structuredContentforoutputSchema-aware output, andresult.isErrorto confirm failures are flagged (and that valid outcomes are not)
Run tests with:
npm run testStep 5: Verify
npm run build # compile TypeScript
npm run lint # ESLint
npm run format:check # Prettier formatting check
npm run test:ci # Vitest (non-interactive, outputs test-results.json)
npm run dev # start server with hot reload for manual testingTo test the tool interactively, connect an MCP client to http://localhost:3000/mcp after starting the dev server.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

