MCP Server Starter
README.md
<div align="center">
# MCP Server Starter
**TypeScript MCP reference implementation + OIDC npm publish path.**
Build a small stdio MCP server with a working tool, resource, prompt, smoke test, and package gate.
[](https://github.com/starter-series/mcp-server-starter/actions/workflows/ci.yml)
[](LICENSE)
**English** | [한국어](README.ko.md)
</div>
---
> **Part of [Starter Series](https://github.com/starter-series/starter-series)** — Stop explaining CI/CD to your AI every time. Clone and start.
>
> [Docker Deploy](https://github.com/starter-series/docker-deploy-starter) · [Browser Extension](https://github.com/starter-series/browser-extension-starter) · [npm Package](https://github.com/starter-series/npm-package-starter) · [VS Code Extension](https://github.com/starter-series/vscode-extension-starter) · **MCP Server** · [Python MCP Server](https://github.com/starter-series/python-mcp-server-starter)
---
## About This Starter
**Currently implemented**
- MCP SDK — `@modelcontextprotocol/sdk` with stdio transport
- TypeScript — Strict mode (incl. `noUncheckedIndexedAccess` + `verbatimModuleSyntax`), ES2023 target, Zod-validated input & output schemas
- Safety annotations — readOnly/destructive/idempotent hints on every tool
- Prompts — Guided workflow templates via `registerPrompt` (SDK v1.29+)
- Resources — Data exposure pattern with metadata + handler
- Response helpers — `ok()` and `err()` for consistent tool responses
- Config — Environment variable parsing pattern
- CI — gitleaks, npm audit, license compliance, ESLint, build, test
- CodeQL — Static security analysis (push/PR + weekly)
- CD — OIDC trusted publishing path for downstream packages
- Dependabot — Automated dependency + GitHub Actions updates
**Planned**
Nothing on a public roadmap. The starter is intentionally finished; downstream projects extend it. Breaking-change waves (Node EOL, SDK majors) land via Dependabot.
**Design intent**
Bootstrap a small reference MCP server with one working tool, resource, and prompt. Stdio is the default because every local MCP client speaks it; HTTP transport is shown inline (see below) rather than bundled, to keep the dependency surface minimal. OIDC publishing is documented for downstream packages after you replace the template package name and npm metadata. Globally-unique tool names are enforced by convention — prefix with your module name, since name collisions across servers are the most common foot-gun.
**Non-goals**
- Auth primitives, HMAC signing, rate-limiting, audit logging, human-in-the-loop infra. Those belong in a host, not a server template.
- Express / HTTP transport in the default scaffold. Template stays small; HTTP is documented for those who need it.
- Opinionated logging, tracing, or observability stack. Pick what fits downstream.
**Redacted**
None. Public template.
## Quick Start
**Via [create-starter](https://github.com/starter-series/create-starter)** (recommended):
```bash
gh repo create my-mcp-server --template starter-series/mcp-server-starter --clone
cd my-mcp-server && npm install && npm run dev
```
Before publishing, replace the placeholder package name, description, and `bin` key. `npm publish` is blocked while `package.json` still uses the template defaults.
**Or clone directly:**
```bash
git clone https://github.com/starter-series/mcp-server-starter my-mcp-server
cd my-mcp-server && npm install && npm run dev
```
## Adding Tools
> **Tool names must be globally unique** across all MCP servers a client connects to. Prefix with your module name (e.g., `mymodule_action` instead of `action`).
Create `src/tools/your-tool.ts`:
```ts
import { z } from 'zod';
import { ok, err } from '../helpers.js';
export const name = 'your_tool';
export const config = {
title: 'Your Tool',
description: 'What your tool does',
inputSchema: {
input: z.string().describe('Input parameter'),
},
// Declare `outputSchema` to return structured results — the 2026 MCP spec
// requires servers to populate `structuredContent` (validated against this
// schema) alongside the text mirror. Omit `outputSchema` for free-form
// text-only tools.
outputSchema: {
result: z.string().describe('Processed value'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
};
export async function handler({ input }: { input: string }) {
try {
const result = `Processed: ${input}`;
// Pass the structured payload as the second arg to `ok()` — it's set as
// `structuredContent` alongside the text mirror in `content[]`.
return ok(result, { result });
} catch (e) {
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
}
}
```
Register in `src/index.ts`:
```ts
import * as yourTool from './tools/your-tool.js';
server.registerTool(yourTool.name, yourTool.config, yourTool.handler);
```
## Adding Prompts
Prompts are reusable, parameterized message templates the client can surface to the user or feed to the model. Use `registerPrompt` (SDK v1.29+) for full control — `title` + `description` + Zod-validated `argsSchema`. See `src/prompts/code-review.ts` for a templated example.
Create `src/prompts/your-prompt.ts`:
```ts
import { z } from 'zod';
export const name = 'your-prompt';
export const title = 'Your Prompt';
export const description = 'Guided workflow description';
export const argsSchema = {
param: z.string().min(1).describe('Required parameter'),
};
export function handler({ param }: { param: string }) {
return {
description,
messages: [{
role: 'user' as const,
content: { type: 'text' as const, text: `Prompt text with ${param}` },
}],
};
}
```
Register in `src/index.ts`:
```ts
import * as yourPrompt from './prompts/your-prompt.js';
server.registerPrompt(
yourPrompt.name,
{ title: yourPrompt.title, description: yourPrompt.description, argsSchema: yourPrompt.argsSchema },
yourPrompt.handler,
);
```
## Adding Resources
Resources expose **data** to the client (Tools perform **actions**). See `src/resources/server-info.ts` for a working example.
Create `src/resources/your-resource.ts`:
```ts
export const name = 'your-resource';
export const uri = 'info://your/resource';
export const metadata = {
title: 'Your Resource',
description: 'What this resource exposes',
mimeType: 'application/json',
};
export async function handler(resourceUri: URL) {
return {
contents: [{ uri: resourceUri.href, mimeType: metadata.mimeType, text: '...' }],
};
}
```
Register in `src/index.ts`:
```ts
import * as yourResource from './resources/your-resource.js';
server.registerResource(yourResource.name, yourResource.uri, yourResource.metadata, yourResource.handler);
```
## HTTP Transport
This starter uses **stdio** (the standard for local MCP servers). If you need HTTP transport — for registries like [Smithery](https://smithery.ai)/[mcp.so](https://mcp.so) or remote deployments — use `StreamableHTTPServerTransport` with Express:
```ts
import express from 'express';
import { randomUUID } from 'node:crypto';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { createServer } from 'my-mcp-server';
const app = express();
app.use(express.json());
const sessions = new Map<string, StreamableHTTPServerTransport>();
app.post('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string;
const existing = sessions.get(sessionId);
if (existing) {
await existing.handleRequest(req, res);
return;
}
if (!isInitializeRequest(req.body)) {
res.status(400).json({ error: 'Bad Request: Not an initialize request' });
return;
}
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
transport.onclose = () => {
const id = transport.sessionId;
if (id) sessions.delete(id);
};
const server = createServer();
await server.connect(transport);
if (transport.sessionId) sessions.set(transport.sessionId, transport);
await transport.handleRequest(req, res);
});
app.get('/mcp', async (req, res) => {
const t = sessions.get(req.headers['mcp-session-id'] as string);
if (!t) return res.status(400).end();
await t.handleRequest(req, res);
});
app.delete('/mcp', async (req, res) => {
const t = sessions.get(req.headers['mcp-session-id'] as string);
if (!t) return res.status(400).end();
await t.handleRequest(req, res);
});
app.listen(3000);
```
> **Why the complexity?** Without `isInitializeRequest`, every POST creates a new transport → "Already connected" errors. Without GET, clients can't receive server notifications via SSE.
**Stateless mode** — if your server holds no per-session state (every tool call is independent), construct the transport with `sessionIdGenerator: undefined` and skip the session map. Stateless is simpler to deploy behind a stateless load balancer and is the recommended default for serverless hosts (Cloudflare Workers, Vercel functions). Keep the stateful pattern above only if you need server-initiated notifications or per-session caches.
See the [MCP SDK docs](https://github.com/modelcontextprotocol/typescript-sdk) for full details.
## Testing Locally
### MCP Inspector
```bash
npm run build
npm run smoke:mcp
npm run pack:check
npx @modelcontextprotocol/inspector node dist/index.js
```
`npm run smoke:mcp` starts the compiled `dist/index.js` stdio entrypoint through the MCP SDK client, calls the example tool, and checks the registered resource and prompt. `npm run pack:check` runs `npm pack --dry-run --json` and verifies `main`, `exports`, `types`, `bin`, and `files` all point at packed files.
### Claude Desktop
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
"mcpServers": {
"my-mcp-server": {
"command": "node",
"args": ["/absolute/path/to/dist/index.js"]
}
}
}
```
### After Publishing to npm
```json
{
"mcpServers": {
"my-mcp-server": {
"command": "npx",
"args": ["-y", "my-mcp-server"]
}
}
}
```
## CI/CD
### CI (every push/PR)
1. Secret scanning (gitleaks)
2. Large file detection (>5 MB)
3. License compliance (blocks GPL/AGPL)
4. Security audit (`npm audit`)
5. Lint (ESLint + TypeScript)
6. Build (TypeScript compilation)
7. Test (Jest)
### Security & Maintenance
| Workflow | What it does |
|----------|-------------|
| CodeQL (`codeql.yml`) | Static analysis for security vulnerabilities (push/PR + weekly) |
| Maintenance (`maintenance.yml`) | Weekly CI health check — auto-creates issue on failure |
| Stale (`stale.yml`) | Labels inactive issues/PRs after 30 days, auto-closes after 7 more |
### CD (manual trigger)
1. CI gate (must pass)
2. Version guard (prevents duplicate releases)
3. npm publish with OIDC + provenance
4. GitHub Release
**Setup:** See [docs/NPM_PUBLISH_SETUP.md](docs/NPM_PUBLISH_SETUP.md) — trusted publishers configured on/after 2026-05-20 must select **`npm publish`** as an allowed action, or releases fail with an authorization error.
## Project Structure
```
src/
├── index.ts # Server entry — tool/resource/prompt registration + transport
├── config.ts # Environment variable config
├── helpers.ts # ok() / err() response helpers
├── tools/
│ └── greet.ts # Example tool with annotations (replace with your own)
├── resources/
│ └── server-info.ts # Example resource exposing server metadata (replace)
└── prompts/
├── hello.ts # Zero-arg prompt example (replace with your own)
└── code-review.ts # Templated prompt with Zod-validated args
tests/
├── greet.test.js # Tool tests
├── helpers.test.js # Helper tests
├── server-info.test.js # Resource tests
├── hello.test.js # Prompt tests
└── code-review.test.js # Templated prompt tests
```
## Scripts
| Command | Description |
|---------|-------------|
| `npm run dev` | Run with tsx (no build needed) |
| `npm run build` | Compile TypeScript |
| `npm start` | Run compiled server |
| `npm run smoke:mcp` | Start the compiled stdio server and call it through the MCP SDK client |
| `npm run pack:check` | Verify package entrypoints and npm tarball contents |
| `npm run template:check` | Fail publishing while template placeholders remain |
| `npm test` | Build + run tests (`pretest` auto-builds) |
| `npm run lint` | ESLint |
| `npm run version:patch` | Bump patch version |
## License
MIT
TDQS
A3.7/5.0
Scored across 1 tool
Disambiguation5/5
With only one tool, there is no possibility of confusion between tools.
Naming Consistency5/5
A single tool has no inconsistencies; its name is simple and clear.
Tool Count3/5
One tool is appropriate for a starter template, but it feels very thin for any real functionality.
Completeness2/5
The server only offers a greeting tool, lacking any broader capabilities that would fulfill a practical domain.
Maintenance
ActivityMaintained
ResponsivenessUnresponsive