Skip to main content

create-mcp-tool

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".

Source facts

Repository
nickytonline/mcp-typescript-template
Last source activity
September 30, 2026 at 14:11
Detected SKILL.md language
English
Stars
58
Forks
12

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
create-mcp-tool
description
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".
metadata
{"author":"nickytonline","version":"1.1.0"}
# 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](https://github.com/modelcontextprotocol/typescript-sdk). --- ## 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](https://modelcontextprotocol.io/seps/986-specify-format-for-tool-names)) 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: ```typescript 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.annotations({ description: "The input to process — be specific about format or constraints", }), limit: Schema.optional( Schema.Int.pipe(Schema.between(1, 100)).annotations({ 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: ```typescript 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: ```typescript 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 with `toMcpSchema(...)`. The adapter supplies both Standard Schema validation and the JSON Schema needed by MCP, including `draft-07` and `draft-2020-12` targets - Use `Schema.annotations({ description: "..." })` for field descriptions; use Effect combinators such as `Schema.optional`, `Schema.Int`, and `Schema.between` for constraints - Keep the tool's business logic in an `Effect.Effect` and call `runMcpEffect()` only in the MCP registration callback - Return `createTextResult(data)` on success — it emits both a text block and `structuredContent` for `outputSchema`-aware clients ([structured content](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content)) - Keep the tool's logic in a function that receives the arguments and `ctx` it uses. This keeps each tool small and testable - Yield `logger.info`/`logger.error` Effects inside the workflow, always including `toolName` and `requestId` (`ctx.mcpReq.id`) for correlation — there's no `sessionId` under the stateless spec - Never log the raw `args` object — tool inputs may carry user-provided or sensitive data. Log individual fields only when they're known to be safe (see `action`/`kind` in `elicitEcho`'s error/decline/cancel logs in `src/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) | ```typescript 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](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#error-handling) in the spec. ```typescript 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.catchAll((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: ```typescript 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: ```typescript 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 real `Client` and `McpServer` in-process — no HTTP server needed - Connect the server before the client (`server.connect` then `client.connect`) so the initialize handshake completes - Use `afterEach` to close both sides leak-safely, even if assertions fail - The existing `setupClientServer` takes an options object — pass `elicitHandler` to answer elicitation, and `supportsElicitation: false` to test the unsupported-client path. Reuse it rather than adding new helpers - `client.callTool()` already returns a typed `CallToolResult` — no schema re-validation needed, just narrow `content[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.structuredContent` for `outputSchema`-aware output, and `result.isError` to confirm failures are flagged (and that valid outcomes are *not*) Run tests with: ```bash npm run test ``` --- ## Step 5: Verify ```bash 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 testing ``` To test the tool interactively, connect an MCP client to `http://localhost:3000/mcp` after starting the dev server.
View on GitHub