agentleFS
nickytonlinenickytonline/beads-mcp/.agents/skills/create-mcp-tool/SKILL.md

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

  • Other versions2 projects use a version of this.

    Word for word · 0

    None found.

    Edited · 0

    None found.

    Wrote their own version · 2

    1. jmrplens/gitlab-mcp-server★ 47same name
    2. 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.

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
  1. Distinctive:

    Use the shared adapter and Effect imports:

    line 74
  2. Very specific:

    Use .annotate({ description: "..." }) for field descriptions and .check(...) with Effect checks such as Schema.isBetween, …

    line 102
  3. Very specific:

    Use Schema.optionalKey for optional fields.

    line 103
  4. Very specific:

    Keep the tool's business logic in an Effect.Effect and call runMcpEffect() only in the MCP registration callback

    line 105
  5. Very specific:

    Keep the tool's logic in a function that receives the arguments and ctx it uses.

    line 107
  6. Very specific:

    Never log the raw args object — tool inputs may carry user-provided or sensitive data.

    line 109
  7. Very specific:

    Add an annotations field to signal the tool's safety profile to LLM clients.

    line 113
  8. Very specific:

    Add tests to src/tools.test.ts, colocated with src/tools.ts.

    line 207
  9. Distinctive:

    Follow the existing pattern:

    line 209
  10. Very specific:

    Use InMemoryTransport.createLinkedPair() to connect a real Client and McpServer in-process — no HTTP server needed

    line 269
  11. Very specific:

    Use afterEach to close both sides leak-safely, even if assertions fail

    line 271
  12. Very specific:

    Add a schema-advertising assertion with client.listTools() when a tool introduces or changes an input/output schema

    line 274

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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

ConcernLocation
Tool registrationsrc/tools.ts inside registerTools(server) via server.registerTool()
Tool logicsrc/tools.ts — functions that take only the dependencies they need
Response formattingcreateTextResult() / createErrorResult() from src/lib/utils.ts
Input validationEffect Schema adapted with toMcpSchema()
Typed outputoutputSchema + structuredContent (emitted automatically by createTextResult)
Testssrc/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 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 .annotate({ description: "..." }) for field descriptions and .check(...) with Effect checks such as Schema.isBetween, Schema.isMinLength and Schema.isPattern for constraints
  • Use Schema.optionalKey for optional fields. Schema.optional also accepts undefined, which the JSON Schema advertises as null, so clients get told null is allowed and then fail validation
  • Give Schema.isPattern regexes the u flag; Effect omits patterns without it from the advertised JSON Schema
  • 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)
  • 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:

AnnotationDefaultWhen to set
readOnlyHintfalsetrue if the tool only reads data and never modifies anything
destructiveHinttruefalse if writes are additive only (e.g. create/append, never delete) — only meaningful when readOnlyHint: false
idempotentHintfalsetrue if calling repeatedly with the same args has no additional effect — only meaningful when readOnlyHint: false
openWorldHinttruefalse 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 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:

npm run test

Step 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 testing

To test the tool interactively, connect an MCP client to http://localhost:3000/mcp after starting the dev server.

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.