TypeScript + OIDC npm Publishing + CI/CD.
Build your MCP server. One-click publish. Zero secrets needed.
English | 한국어
Part of Starter Series — Stop explaining CI/CD to your AI every time. Clone and start.
Docker Deploy · Discord Bot · Telegram Bot · Browser Extension · Electron App · npm Package · React Native · VS Code Extension · MCP Server · Python MCP Server · Cloudflare Pages
- MCP SDK —
@modelcontextprotocol/sdkwith stdio transport - TypeScript — Strict mode, ES2022 target, Zod-validated tool schemas
- Safety Annotations — readOnly/destructive/idempotent hints on every tool
- Prompts — Guided workflow templates for common tasks
- Response Helpers —
ok()anderr()for consistent tool responses - Config — Environment variable parsing pattern
- CI — gitleaks, npm audit, license compliance, ESLint, build, test
- CD — OIDC trusted publishing to npm (zero secrets needed)
- Dependabot — Automated dependency + GitHub Actions updates
git clone https://github.com/starter-series/mcp-server-starter.git my-mcp-server
cd my-mcp-server
rm -rf .git && git init
npm install
npm run devTool names must be globally unique across all MCP servers a client connects to. Prefix with your module name (e.g.,
mymodule_actioninstead ofaction).
Create src/tools/your-tool.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'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
};
export async function handler({ input }: { input: string }) {
try {
return ok(`Processed: ${input}`);
} catch (e) {
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
}
}Register in src/index.ts:
import * as yourTool from './tools/your-tool.js';
server.registerTool(yourTool.name, yourTool.config, yourTool.handler);Create src/prompts/your-prompt.ts:
import { z } from 'zod';
export const name = 'your-prompt';
export const description = 'Guided workflow description';
export const schema = {
param: z.string().optional().describe('Optional parameter'),
};
export function handler({ param }: { param?: string }) {
return {
description,
messages: [{
role: 'user' as const,
content: { type: 'text' as const, text: `Prompt text with ${param ?? 'default'}` },
}],
};
}Register in src/index.ts:
import * as yourPrompt from './prompts/your-prompt.js';
server.prompt(yourPrompt.name, yourPrompt.description, yourPrompt.schema, yourPrompt.handler);server.resource("example://data", "Example Resource", async () => ({
contents: [{ uri: "example://data", text: "Resource content here" }]
}));This starter uses stdio (the standard for local MCP servers). If you need HTTP transport — for registries like Smithery/mcp.so or remote deployments — use StreamableHTTPServerTransport with Express:
import express from 'express';
import { randomUUID } from 'node:crypto';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
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(); // your McpServer factory
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.
See the MCP SDK docs for full details.
npm run build
npx @modelcontextprotocol/inspector node dist/index.jsAdd to ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"my-mcp-server": {
"command": "node",
"args": ["/absolute/path/to/dist/index.js"]
}
}
}{
"mcpServers": {
"my-mcp-server": {
"command": "npx",
"args": ["-y", "my-mcp-server"]
}
}
}- Secret scanning (gitleaks)
- Large file detection (>5 MB)
- License compliance (blocks GPL/AGPL)
- Security audit (
npm audit) - Lint (ESLint + TypeScript)
- Build (TypeScript compilation)
- Test (Jest)
| 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 |
- CI gate (must pass)
- Version guard (prevents duplicate releases)
- npm publish with OIDC + provenance
- GitHub Release
Setup: See docs/NPM_PUBLISH_SETUP.md
src/
├── index.ts # Server entry — tool/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)
└── prompts/
└── hello.ts # Example prompt (replace with your own)
tests/
├── greet.test.js # Tool tests
├── helpers.test.js # Helper tests
└── hello.test.js # Prompt tests
| Command | Description |
|---|---|
npm run dev |
Run with tsx (no build needed) |
npm run build |
Compile TypeScript |
npm start |
Run compiled server |
npm test |
Build + run tests (pretest auto-builds) |
npm run lint |
ESLint |
npm run version:patch |
Bump patch version |
MIT