Skip to main content
Glama
VitorKaeZ

MCP Template

by VitorKaeZ

MCP Template

Production-ready Model Context Protocol server template in TypeScript β€” MCP 2026-07-28, SOLID architecture, dynamic tool registration, dual transport (stdio + HTTP), and pluggable authentication.

CI TypeScript License: MIT

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.

Related MCP server: MCP Server Starter

Quick start

npm install
cp .env.example .env

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

Build and run for production:

npm run build
npm start

Creating a tool

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

// 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 (config, logger, httpClient) plus mcp, the per-request MCP context.

A few things worth knowing:

  • Schemas are Standard Schema 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 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:

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

The bundled get-current-weather.tool.ts and get-forecast.tool.ts are working references: they consult the free Open-Meteo API (no key needed) through the injected client and a dedicated WeatherService.

Configuration

All config is validated at startup in src/config/env.ts. See .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

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 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:

// inspector.json
{
  "mcpServers": {
    "mcp-template": {
      "type": "http",
      "url": "http://127.0.0.1:3000/mcp",
      "protocolEra": "modern", // "legacy" (default) | "auto" | "modern"
    },
  },
}
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 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 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:

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

The panel itself is registered in src/apps/register.ts and served as an text/html;profile=mcp-app resource. 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 is a complete example. The pattern:

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 for the extension's design.

Using with Claude Desktop

Add to claude_desktop_config.json (stdio):

{
  "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:

  • Single responsibility β€” one tool per file; registry, transport, config, and auth are each isolated.

  • Open/closed β€” add a tool or an auth strategy by adding a file; nothing existing changes.

  • Liskov β€” every tool is interchangeable behind ToolDefinition.

  • Interface segregation β€” small, focused contracts (ToolDefinition, AuthStrategy).

  • Dependency 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

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

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.

No tool schema history has been recorded yet.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

Related MCP Servers

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/VitorKaeZ/mcp-server-template'

If you have feedback or need assistance with the MCP directory API, please join our Discord server