# Writing an MCP server for your stack

Web page: https://academy.memberpress.ai/courses/code-agents/l5/

Lesson 5 of 10 in [Claude Code Agents — Sub-Agents, Tools, and MCP](https://academy.memberpress.ai/courses/code-agents/), 30 min.

Video: https://www.youtube.com/watch?v=TCgqtloDhzw

A tool you define inline in a single API call is available only to that call. The next session, you redefine it. The next project, you copy-paste it and it drifts. An MCP server makes your tools available to every MCP-aware client — Claude Code, Claude Desktop, or any other client that speaks the protocol — without re-defining them per call. You write the tool once. You register it once. Every session picks it up automatically.

Here is a minimal working server. It wraps one internal API endpoint and exposes it as a single tool:

```typescript
// servers/internal-api/index.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: "internal-api", version: "1.0.0" });

server.tool(
  "get_deploy_status",
  "Return the current deploy status for a given app ID. Call this when the user asks whether a deploy succeeded, failed, or is in progress.",
  { app_id: z.string().describe("The app identifier, e.g. 'api-prod'") },
  async ({ app_id }) => {
    const resp = await fetch(`https://internal.example.com/deploys/${app_id}/status`);
    if (!resp.ok) {
      return {
        content: [{ type: "text", text: `Error: ${resp.status} ${resp.statusText}` }],
        isError: true
      };
    }
    const data = await resp.json();
    return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
  }
);

await server.connect(new StdioServerTransport());
```

Build it, then register it in `.mcp.json` at your project root:

```json
{
  "mcpServers": {
    "internal-api": {
      "command": "node",
      "args": ["servers/internal-api/dist/index.js"],
      "env": {
        "INTERNAL_API_TOKEN": "${INTERNAL_API_TOKEN}"
      }
    }
  }
}
```

Restart Claude Code. The tool appears automatically in the session. The `INTERNAL_API_TOKEN` environment variable is read from your shell at startup — it is never hardcoded in `.mcp.json`. The `${INTERNAL_API_TOKEN}` syntax in the env block is a pass-through: Claude Code reads the value from the environment and injects it into the server process. If you source your secrets in your shell profile, they are available without listing them explicitly in the env block.

**Resources and prompts**

MCP servers expose three kinds of things. Tools (functions the model calls) are the most common. Resources (files or structured data the model can read) are useful when you have reference data that changes infrequently — an OpenAPI schema, a list of valid environment identifiers, a product catalog. Prompts (reusable message templates) are rarely needed for internal API servers but become useful for standardizing how teams invoke complex workflows.

For most internal wrappers, ship tools only and add resources if you find yourself embedding the same reference data in every tool call. The rule: if the model needs it every time, put it in a resource. If the model needs it sometimes, put it in the tool's description.

**Authenticating correctly**

The `env` block in `.mcp.json` is for variables that must be explicitly passed to the server process. Variables already in your shell environment are inherited automatically — you do not need to list `PATH` or variables you have in your profile. List only variables that are specific to this server or that need to be set to a specific value. Never hardcode a token value in `.mcp.json` — the file is committed to the repo.

**If you get stuck**

Claude Code does not show the new tool. Check three things in order: (1) `.mcp.json` is at the project root, not in a subdirectory. (2) The server binary exists at the path specified in `args` — run the `node` command from that path manually and confirm the server starts without errors. (3) You restarted Claude Code after editing `.mcp.json` — MCP servers are loaded at session start, not dynamically. Editing `.mcp.json` while a session is open has no effect until restart.

The server starts but tools are not listed. The `name` in `McpServer({ name: ... })` must be unique across all servers registered in `.mcp.json`. If two servers share a name, one will be silently dropped with no error message. Choose a name that is specific to your stack and will not collide.

The tool call returns a 401 or 403. The environment variable is not being passed. In `.mcp.json`, verify the variable name in the `env` block matches the exact name your code reads with `process.env`. Case matters. If the variable is in your shell but not in the env block, add it to the block — inheritance is not guaranteed across all session launch paths.

**Cross-reference**

l4 covers tool definition schemas in depth — the `z.string().describe(...)` pattern in this lesson maps directly to the JSON Schema covered there. l6 answers when a tool in an MCP server is the right choice versus a shell script the model invokes with Bash. The HeyGen MCP server in `.mcp.json` at this project root is a working example of exactly this pattern at production scale.

**Deliverable**

Build a single-tool MCP server that wraps one real endpoint in your stack. It must: authenticate via an environment variable read from `process.env` (never hardcoded), return `isError: true` with a human-readable message on failure, and be registered in `.mcp.json` so Claude Code discovers it automatically. Verify by opening a new Claude Code session and confirming the tool name appears in the session tool list before you write any prompts that use it.

**Further reading**

- ["Closing the Agent Discovery Loop"](https://sethshoultes.com/blog/closing-the-agent-discovery-loop.html) — what it takes for an MCP tool to be discoverable enough that an agent picks it without being told.
- ["What Agents Actually Need From Your API"](https://sethshoultes.com/blog/what-agents-need.html) — the description-as-decision-rule pattern, applied to the API the tool wraps.

## Try these prompts

- Write the minimal TypeScript MCP server skeleton that registers one tool called get_deploy_status using the McpServer SDK and StdioServerTransport. Include the Zod schema for app_id.
- Write the .mcp.json entry to register an MCP server called internal-api that runs node servers/internal-api/dist/index.js and passes INTERNAL_API_TOKEN from the environment.
- List the three things to check in order when Claude Code does not show a newly registered MCP tool after editing .mcp.json.

Next: [When to add a tool vs. when to write a script](https://academy.memberpress.ai/courses/code-agents/l6/)
