Skip to main content
Glama
VitorKaeZ

MCP Template

by VitorKaeZ
README.md
# MCP Template

> Production-ready [Model Context Protocol](https://modelcontextprotocol.io) server template in TypeScript — **MCP 2026-07-28**, SOLID architecture, **dynamic tool registration**, dual transport (stdio + HTTP), and pluggable authentication.

![CI](https://github.com/VitorKaeZ/mcp-server-template/actions/workflows/ci.yml/badge.svg)
![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)
![License: MIT](https://img.shields.io/badge/License-MIT-green)

Clone it, drop a file in `src/tools/`, and you have a new tool. No registry to edit, no boilerplate to wire.

---

## Highlights

- šŸ†• **MCP 2026-07-28** — stateless core, `server/discover`, cache hints, and multi-round-trip tools, on the v2 TypeScript SDK. Pre-2026 clients keep working on the same endpoint.
- šŸ”Œ **Drop-in tools** — create `src/tools/<name>.tool.ts` and it's auto-discovered and registered at startup.
- 🧱 **SOLID by design** — clear seams between config, transport, registry, auth, and tools; dependencies injected, never reached for globally.
- 🚦 **Two transports** — `stdio` for local clients (Claude Desktop, Claude Code) and Streamable **HTTP** for remote deployments, from the same code.
- šŸ” **API key _or_ OAuth 2.0** — a shared secret for simple deployments, or a full RFC 9728 resource server for enterprise IdPs (Entra, Okta), selected by env.
- šŸ–¼ļø **MCP Apps** — tools can render an interactive UI panel in the conversation, with a text fallback for hosts that don't support it.
- āœ… **Quality baked in** — strict TypeScript, Zod validation, structured logging (pino), Vitest, ESLint + Prettier, GitHub Actions, Docker.

## Quick start

```bash
npm install
cp .env.example .env

npm run dev            # stdio transport, hot reload
# or
TRANSPORT=http npm run dev
```

Build and run for production:

```bash
npm run build
npm start
```

## Creating a tool

This is the whole workflow. Create a file ending in `.tool.ts` under `src/tools/`:

```ts
// src/tools/greet.tool.ts
import { z } from "zod";
import { defineTool } from "../core/tool.js";

export default defineTool({
  name: "greet",
  description: "Greets a person by name.",
  inputSchema: z.object({
    name: z.string().min(1).describe("Who to greet"),
  }),
  outputSchema: z.object({
    greeting: z.string(),
  }),
  handler: ({ name }, { logger }) => {
    logger.debug({ name }, "greeting");
    return {
      content: [{ type: "text", text: `Hello, ${name}!` }],
      structuredContent: { greeting: `Hello, ${name}!` },
    };
  },
});
```

Restart the server — `greet` is live. The `args` are fully typed from `inputSchema`, and the second argument is the injected [`ToolContext`](src/core/tool-context.ts) (`config`, `logger`, `httpClient`) plus `mcp`, the per-request MCP context.

A few things worth knowing:

- Schemas are [Standard Schema](https://standardschema.dev) values, so Zod is the default but ArkType or Valibot work unchanged.
- `outputSchema` is optional, but once declared every non-error result **must** carry `structuredContent`.
- Throwing a [`ToolError`](src/utils/errors.ts) returns a proper error _result_ to the model rather than aborting the call with a protocol error.
- Tools are registered in sorted order, because 2026-07-28 asks `tools/list` to be stable so clients and prompt caches can rely on it.

Need an external API? Use the injected client instead of `fetch`:

```ts
const data = await httpClient.get(`/resources/${id}`);
```

The bundled [get-current-weather.tool.ts](src/tools/get-current-weather.tool.ts) and [get-forecast.tool.ts](src/tools/get-forecast.tool.ts) are working references: they consult the free [Open-Meteo](https://open-meteo.com) API (no key needed) through the injected client and a dedicated [`WeatherService`](src/services/weather.ts).

## Configuration

All config is validated at startup in [src/config/env.ts](src/config/env.ts). See [.env.example](.env.example) for every supported variable and its defaults.

## Protocol versions and compatibility

This server speaks **MCP 2026-07-28**, which is a breaking redesign rather than an increment: there is no `initialize` handshake, no `Mcp-Session-Id`, and every request carries its protocol version and client capabilities in `_meta`. The server answers the new mandatory `server/discover` RPC, and the SDK stamps `resultType` and the `_meta` server-identity envelope on every response.

**Statelessness is the headline.** A fresh server instance is built per request, so there is nothing to keep in memory between calls and the process fits serverless and edge runtimes. Anything that must survive across calls travels in explicit, server-signed handles (see multi-round-trip tools below).

**Older clients still work.** `LEGACY_MODE=stateless` (the default) serves pre-2026 clients from the same endpoint and the same server factory, so today's hosts keep working while you develop against the new protocol. Set `LEGACY_MODE=reject` to accept only 2026 clients.

Two consequences worth remembering:

- `ping` no longer exists — use the `/health` HTTP route for liveness checks.
- Roots, Sampling and Logging are deprecated (SEP-2577). This server logs to stderr, which is the recommended replacement.

## Inspecting the server

```bash
npm run inspect                        # stdio, negotiates 2026-07-28
npm run inspect -- http://localhost:3000/mcp
npm run inspect -- --legacy            # force the pre-2026 path
```

[`scripts/inspect.ts`](scripts/inspect.ts) connects with the v2 client and prints the negotiated protocol era, the tools (flagging which have a UI), the resources, and a sample call.

### Using the official MCP Inspector

Inspector 2.x is built on the v2 client and speaks 2026-07-28 — but **it connects on the legacy path by default**, because the SDK's own default is `versionNegotiation: { mode: "legacy" }`. Modern is opt-in. Point it at a config that pins the era:

```jsonc
// inspector.json
{
  "mcpServers": {
    "mcp-template": {
      "type": "http",
      "url": "http://127.0.0.1:3000/mcp",
      "protocolEra": "modern", // "legacy" (default) | "auto" | "modern"
    },
  },
}
```

```bash
npx @modelcontextprotocol/inspector --config ./inspector.json --server mcp-template
```

Without `protocolEra`, you are testing the compatibility path: `server/discover`, the `_meta` envelope and `resultType` never come into play, and **multi-round-trip tools cannot work at all** — legacy stateless HTTP has no server-to-client request channel, so `plan_outfit` fails with a capability error that looks like a server bug but is not.

Two more things worth knowing:

- A `GET /mcp` returning `405` is expected and spec-compliant for stateless serving; the client handles it.
- The published Inspector 2.x packages omit `clients/web/static/` from their `files` list, so the MCP Apps sandbox fails with `ENOENT: … sandbox_proxy.html`. Until that is fixed upstream, copy the file from the repository's `main` branch into the installed package.

## Authentication

**Protect the server (inbound).** Two modes, selected by `AUTH_MODE`:

| Mode      | When to use                                    | How clients authenticate                                    |
| --------- | ---------------------------------------------- | ----------------------------------------------------------- |
| `api-key` | Single-tenant deployments, internal tools      | `Authorization: Bearer <key>` or `x-api-key: <key>`         |
| `oauth`   | Enterprise IdPs (Entra, Okta, Auth0, Keycloak) | OAuth 2.0 bearer token, validated by RFC 7662 introspection |

`api-key` is a constant-time comparison against `MCP_API_KEY`. Setting the older `REQUIRE_AUTH=true` still selects it, so existing `.env` files keep working.

`oauth` turns the server into a proper OAuth 2.0 **resource server**. It publishes RFC 9728 protected-resource metadata at `/.well-known/oauth-protected-resource`, so clients can discover the authorization server, and answers unauthenticated requests with a `401` carrying the correct `WWW-Authenticate` challenge. Tokens are checked for expiry and for audience (RFC 8707) — a token minted for another resource is refused rather than silently accepted. Verified identity reaches handlers as `ctx.mcp.http?.authInfo`.

Both modes sit behind the same [`AuthStrategy`](src/auth/auth-strategy.ts) interface, so a different scheme (mTLS, HMAC, local JWT verification) is a new file, not a transport change.

**Consume an external API (outbound).** Set `EXTERNAL_API_BASE_URL` / `EXTERNAL_API_KEY`. The shared [`HttpClient`](src/clients/http-client.ts) injects the key, applies timeouts and retries, and is handed to every tool via the context.

## MCP Apps: tools with a UI

A tool can render an interactive panel in the conversation instead of only returning text. Point it at a UI resource:

```ts
_meta: uiToolMeta("ui://mcp-template/weather-panel.html"),
```

The panel itself is registered in [src/apps/register.ts](src/apps/register.ts) and served as an `text/html;profile=mcp-app` resource. [get-forecast.tool.ts](src/tools/get-forecast.tool.ts) is a working example.

Hosts that don't support the extension ignore `_meta` and fall back to the tool's text content, so **always return readable text as well**. Set `MCP_APPS_ENABLED=false` to stop advertising the extension entirely.

The server side is implemented directly against the wire contract rather than via `@modelcontextprotocol/ext-apps`, which is still a v1-only package and cannot be mixed with the v2 SDK.

## Multi-round-trip tools

When a tool needs input mid-call, 2026-07-28 replaces server-initiated elicitation with **MRTR**: the handler returns `input_required` and the client retries the same call with the answers.

[plan-outfit.tool.ts](src/tools/plan-outfit.tool.ts) is a complete example. The pattern:

```ts
const previous = mcp.mcpReq.requestState?.<State>();

if (!previous) {
  return inputRequired({
    inputRequests: { preferences: inputRequired.elicit({ message, requestedSchema }) },
    requestState: await codec.mint({ phase: "awaiting-preferences", city }, mcp),
  });
}

const answers = acceptedContent(mcp.mcpReq.inputResponses, "preferences", schema);
```

Two rules that are easy to get wrong:

- `inputResponses` are **per round** and never accumulate. Thread everything the next round needs through `requestState` and switch on an explicit phase — never infer progress from which keys happen to be present.
- `requestState` is **signed, not encrypted**. It is readable by the client, so never put secrets in it. Set `REQUEST_STATE_SECRET` (32+ chars) in production; without it an ephemeral key is generated and paused calls break across restarts and replicas.

Clients must declare the `elicitation` capability. One that doesn't gets a clean `-32021` refusal naming what was missing, rather than an opaque failure.

## What's not here: the Tasks extension

`io.modelcontextprotocol/tasks` is **not** implemented, because it cannot currently be served on a 2026-07-28 connection. The SDK's dispatch checks a per-era method registry before handler lookup, and the 2026 registry contains exactly ten methods — `tasks/get` and `tasks/cancel` are not among them, so they answer `-32601` even with a handler registered. There is also no `@modelcontextprotocol/ext-tasks` package on npm.

For long-running work today, use `notifications/progress` with `ctx.mcp.mcpReq.signal` for cancellation, and MRTR for human-in-the-loop pauses. See [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663) for the extension's design.

## Using with Claude Desktop

Add to `claude_desktop_config.json` (stdio):

```json
{
  "mcpServers": {
    "mcp-template": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-template/dist/index.js"]
    }
  }
}
```

## Architecture

```
src/
ā”œā”€ā”€ index.ts            Composition root: load config → pick transport
ā”œā”€ā”€ server.ts           Per-request server factory: capabilities, cache hints, registry
ā”œā”€ā”€ config/env.ts       Zod-validated environment (single source of truth)
ā”œā”€ā”€ core/
│   ā”œā”€ā”€ tool.ts         ToolDefinition contract + defineTool() helper
│   ā”œā”€ā”€ tool-registry.ts  Dynamic discovery & registration of *.tool.ts
│   ā”œā”€ā”€ tool-context.ts   Dependency-injection container for handlers
│   ā”œā”€ā”€ mcp-meta.ts     Typed access to the 2026 _meta request envelope
│   ā”œā”€ā”€ request-state.ts  Signed state for multi-round-trip calls
│   └── logger.ts       Structured logging (to stderr, stdio-safe)
ā”œā”€ā”€ transports/         stdio (serveStdio) + Streamable HTTP (createMcpHandler)
ā”œā”€ā”€ auth/               AuthStrategy + API-key, OAuth bearer, token introspection
ā”œā”€ā”€ apps/               MCP Apps: UI resource and its wire constants
ā”œā”€ā”€ clients/            Shared HTTP client for outbound API calls
ā”œā”€ā”€ services/           Domain logic (e.g. WeatherService) used by tools
ā”œā”€ā”€ tools/              šŸ‘ˆ your tools — one file each, auto-registered
└── utils/errors.ts     Typed errors
```

**How SOLID shows up here:**

- **S**ingle responsibility — one tool per file; registry, transport, config, and auth are each isolated.
- **O**pen/closed — add a tool or an auth strategy by adding a file; nothing existing changes.
- **L**iskov — every tool is interchangeable behind `ToolDefinition`.
- **I**nterface segregation — small, focused contracts (`ToolDefinition`, `AuthStrategy`).
- **D**ependency inversion — handlers depend on the injected `ToolContext`, never on globals.

## Scripts

| Script              | Purpose                                 |
| ------------------- | --------------------------------------- |
| `npm run dev`       | Run with hot reload (tsx)               |
| `npm run build`     | Clean `dist/` and compile               |
| `npm start`         | Run the compiled server                 |
| `npm run inspect`   | Connect as a client and dump the server |
| `npm run typecheck` | Type-check sources and tests            |
| `npm run lint`      | ESLint                                  |
| `npm run format`    | Prettier (write)                        |
| `npm test`          | Run the test suite (Vitest)             |

> `build` cleans `dist/` first on purpose: tools are discovered by scanning the output directory, so a stale `.tool.js` from an earlier build would otherwise still be served after you delete its source.

## Docker

```bash
docker build -t mcp-template .
docker run -p 3000:3000 -e AUTH_MODE=api-key -e MCP_API_KEY=secret mcp-template
```

Because the protocol is stateless, containers scale horizontally with no sticky sessions. Set `REQUEST_STATE_SECRET` to the same value across replicas so a multi-round-trip call can resume on any of them.

## License

MIT Ā© Vitor Kaez