AnswerQA

How do I build a small MCP server my team can use?

Answer

Use @modelcontextprotocol/sdk with stdio transport for local servers and Streamable HTTP for remote. Tools need a name, a description (what the LLM reads), and a Zod input schema. Wire to Claude Code with `claude mcp add` or a `.mcp.json` entry.

By Kalle Lamminpää Verified May 7, 2026

The reference Postgres MCP server is archived; the documented Postgres example in Connect Claude Code to your Postgres database recommends vendoring a known-good copy. Sometimes the right move is to ship your own. The TypeScript SDK makes a small server cheap to write; the part most beginners get wrong is the tool description, which is the LLM’s only API doc.

Pick a transport

TransportWhen to useHow Claude Code reaches it
stdioLocal server, runs as a subprocess of Claude Codeclaude mcp add my-tool -- node ./server.js
Streamable HTTPRemote server, accessed over the networkclaude mcp add --transport http my-tool https://mcp.example.com/api

Stdio is the default and the simplest: Claude Code starts the binary as a child process, both ends speak JSON-RPC over stdin/stdout. HTTP is for genuinely remote servers (a service hosted somewhere your team can reach via OAuth or API key). Most internal team MCP servers should start as stdio; promote to HTTP when you need to share state across users or hide credentials behind the server.

A minimal stdio server

package.json:

{
  "name": "linear-bridge",
  "type": "module",
  "main": "dist/server.js",
  "scripts": { "build": "tsc", "start": "node dist/server.js" },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.0.0",
    "zod": "^3.23.0"
  },
  "devDependencies": { "typescript": "^5.4.0", "@types/node": "^20.0.0" }
}

src/server.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({ name: "linear-bridge", version: "0.1.0" });

server.registerTool(
  "search_issues",
  {
    title: "Search Linear issues",
    description:
      "Search the Linear tracker for issues matching a free-text query. " +
      "Returns the top 10 matches with id, title, status, and assignee. " +
      "Use this when the user asks to find, list, or summarize issues.",
    inputSchema: {
      query: z.string().describe("Free-text search query. Linear-flavored syntax is supported."),
      limit: z.number().int().min(1).max(50).default(10).describe("Max results to return."),
    },
  },
  async ({ query, limit }) => {
    const apiKey = process.env.LINEAR_API_KEY;
    if (!apiKey) {
      return {
        isError: true,
        content: [{ type: "text", text: "LINEAR_API_KEY not set on the server process." }],
      };
    }

    const gql = `
      query($first: Int!) {
        issues(first: $first) {
          nodes { id title state { name } assignee { name } }
        }
      }
    `;

    const response = await fetch("https://api.linear.app/graphql", {
      method: "POST",
      headers: { "Content-Type": "application/json", "Authorization": apiKey },
      body: JSON.stringify({ query: gql, variables: { first: limit } }),
    }).then((r) => r.json());

    // Client-side filter for the free-text query; replace with a real Linear
    // search filter once you have the schema you want.
    const matches = (response.data?.issues?.nodes ?? []).filter((n: any) =>
      n.title.toLowerCase().includes(query.toLowerCase()),
    );

    return {
      content: [{ type: "text", text: JSON.stringify(matches, null, 2) }],
    };
  },
);

const transport = new StdioServerTransport();
await server.connect(transport);

A minimal tsconfig.json next to package.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["src"]
}

Build and wire it in:

npm install
npm run build
claude mcp add --env LINEAR_API_KEY=lin_api_... linear-bridge -- node ./dist/server.js

The --env option (and --transport, --scope, --header) must come before the server name. The -- before the command separates Claude Code’s flags from the server’s own argv. Inside Claude Code, /mcp shows server status; claude mcp get linear-bridge shows the configured command and any auth state.

The dev loop

Stdio servers do not hot-reload; Claude Code spawns the process at session start. Iteration loop:

  1. Edit src/server.ts.
  2. npm run build (or run tsc --watch in another terminal).
  3. Restart Claude (Ctrl-D, claude again). The new build picks up.

For tighter iteration, point claude mcp add at a tsx runner: claude mcp add my-tool -- npx tsx src/server.ts. Each session restart re-runs the latest source without a build step.

Tool description = LLM API doc

The single most important field is the description. The LLM reads only this string (plus the input-schema descriptions) to decide whether to call your tool and what arguments to pass. A weak description like "Searches Linear" does not get auto-invoked; the model has no idea when “Linear” is the right move. The same tool with "Search the Linear tracker for issues. Use when the user asks to find, list, or summarize tickets." gets invoked correctly, because the model can match the user’s prompt against the description’s verbs and nouns.

Patterns that work:

  • Lead with the action verb. “Search…”, “Create…”, “Update…”, “Read…” beats “Linear integration” or “Issue tool”.
  • Tell the model when to use it. A “Use when…” sentence is the most direct signal.
  • Tell the model what comes back. “Returns the top 10 matches with id, title, status, assignee” beats omitting return shape.
  • Describe each input. Zod’s .describe() chain feeds the LLM input documentation. “Free-text query” plus an example beats “query string”.

Tool descriptions get truncated when many tools share a session. Keep them under 200 characters of useful text; do not pad.

Publish patterns

Internal-only. Vendor the built server into the consuming repo (vendor/linear-bridge/) and reference it in .mcp.json with node ./vendor/linear-bridge/dist/server.js. No npm install on session start.

Open source. Publish to npm as @your-org/mcp-server-name. Users install via npm install -g, then claude mcp add pointing at the installed binary. Document the env vars the server needs.

Internal HTTP. Host the server behind a stable URL with API-key or OAuth auth. claude mcp add --transport http --header "Authorization: Bearer ..." registers it. Users do not need any local install.

Footguns

TypeScript’s non-null assertion on env vars hides problems at runtime, not at startup. process.env.LINEAR_API_KEY! does not throw; the assertion is erased at compile time. The actual failure surfaces inside whatever you passed undefined to: a fetch call that sends Authorization: undefined and gets a 401, an opaque “internal server error” later, or a tool result that confuses the model. Validate env at startup, log a clear error to stderr (which Claude Code captures), and either abort or return a tool-shaped error so the diagnostic is recoverable.

Logging to stdout breaks the protocol. Stdio MCP uses stdout for JSON-RPC; any console.log in your tool implementation interleaves with the protocol stream and corrupts it. Always log to stderr (console.error, or a dedicated logger configured to stderr). The tool’s return value goes via the protocol; everything else is stderr.

Tool descriptions truncate when sessions have many tools. Claude Code allocates a per-session description budget. With dozens of MCP tools registered, descriptions get shortened from the back, and your “Use when…” hint disappears, and your tool stops auto-invoking. Verify by asking “what tools are available?” mid-session; if your tool is name-only, raise SLASH_COMMAND_TOOL_CHAR_BUDGET or move low-priority MCP tools to skill bundles instead.

Returning huge tool output bloats parent context. A search_issues that returns 500 issues at 200 chars each lands as 100KB in Claude’s context. Cap result count in the tool itself (the example caps at 50 via Zod), or wrap the tool call in a subagent so only a summary returns to the parent. Server-side filtering beats client-side context burn every time.

claude mcp add writes to local scope by default, not project scope. Local scope is per-user-per-project and lives in your ~/.claude.json keyed to the project path, so it is not committed to the repo and not visible to teammates. Project scope writes <repo>/.mcp.json, which is committed. For a shared team server, pass --scope project or edit .mcp.json directly. User scope (--scope user) makes the server available across all your projects but still stays local to your machine.

Tool errors should be tool-shaped. A tool that hits a 401 from Linear should return { isError: true, content: [...] } so the model sees the message and can react. Throwing an unhandled exception fails the JSON-RPC turn cleanly (the SDK reports it back to Claude as an error), but you lose control of the message Claude sees. Catch known failures inside the tool, format them as content, return cleanly.

When NOT to build your own MCP server

  • An off-the-shelf server already exists. Postgres, GitHub, Slack, Linear, and many others have community or first-party MCP servers. Use those before writing yours.
  • The work fits in a skill or a subagent. A skill that runs Bash(curl ...) against an internal API is cheaper than maintaining an MCP server. Reach for MCP when you need persistent connection state, structured tool returns, or a tool list that changes at runtime.
  • You only need it once. A throwaway script you run manually does not need MCP wiring. Save the protocol overhead for tools you will reuse across many sessions.
  • The team will not maintain it. An internal MCP server with no maintainer becomes the dependency that breaks at the worst moment. Either commit to ownership or use a public server you can point at someone else’s repo.

Sources

Was this helpful?