feat: migrate verified template implementation into main repo

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jax 2026-03-11 22:10:25 +08:00
parent 284812ac4c
commit 8df0a58dfa
30 changed files with 4289 additions and 6 deletions

23
.gitignore vendored
View File

@ -1,8 +1,19 @@
# MAC # macOS
DS_Store .DS_Store
# package # dependencies
node_modules node_modules/
# oh-my-opencode # build and package artifacts
.sisyphus dist/
*.tgz
# test artifacts
coverage/
# local env files
.env
.env.*
# local tooling state
.sisyphus/

86
README.md Normal file
View File

@ -0,0 +1,86 @@
# TS MCP Template (V1)
A Node.js template for building Model Context Protocol (MCP) servers with TypeScript. This template focuses on a local-first development experience, protocol-safe stdio execution, and explicit capability registration.
## Status: V1 Alpha
This template supports:
- Node.js 20+ runtime
- Single-package architecture
- Protocol-safe Local Stdio transport (Primary)
- Streamable HTTP transport (Secondary)
- Explicit, function-based tool, resource, and prompt registration
- Vitest-powered transport and core logic testing
It does not currently support Bun, Deno, edge runtimes, built-in authentication, or telemetry.
## Quickstart: Local Stdio
Stdio is the primary transport for local development and integration with desktop LLM clients like Claude.
1. **Install dependencies**
```bash
npm install
```
2. **Run with MCP Inspector**
The template includes a protocol-safe launch configuration in `package.json` to avoid stdout pollution. Use this command to test your server:
```bash
npx @modelcontextprotocol/inspector node ./node_modules/tsx/dist/cli.mjs src/stdio.ts
```
3. **Build the project**
To prepare for production or use with a compiled entry point:
```bash
npm run build
```
## Usage: HTTP
The HTTP transport uses the Node-native `StreamableHTTPServerTransport` for remote or web-based connections.
1. **Start the HTTP server**
```bash
npm run dev:http
```
The server defaults to `127.0.0.1:3000`. You can configure the port via the `HTTP_PORT` environment variable.
2. **Endpoints**
- `POST /mcp`: Initialize a new session and handle requests.
- `GET /mcp`: Handle ongoing session requests.
## Development and Testing
- **Typecheck**: `npm run typecheck`
- **Test**: `npm test` (Runs Vitest smoke tests for stdio, HTTP, and core logic)
- **Dev (Core Logic)**: `npm run dev` (Executes `src/index.ts`)
## Repository Structure
- `src/core/`: The shared MCP core factory (`createMcpCore`) that builds the server and registry.
- `src/capabilities/`: Definitions and handlers for tools, resources, and prompts.
- `src/stdio.ts`: The stdio entry point and transport setup.
- `src/http.ts`: The HTTP entry point and session management.
- `src/config/`: Runtime configuration and environment variable parsing.
- `src/lib/`: Internal utilities, stderr-safe logging, and error handling.
## Extension Guidance
This template uses an explicit, function-based registration pattern instead of decorators or reflection.
### 1. Define Contracts
Add new tool or prompt schemas in `src/capabilities/contracts.ts` to share types between handlers and tests.
### 2. Implement Handlers
Create or update files in `src/capabilities/` (e.g., `tools.ts`):
1. Define the handler logic.
2. Update the `register...Capabilities` function to add descriptors to the registry.
3. Update the `register...Handlers` function to wire the logic to the `McpServer` instance.
### 3. Register with Core
If you create new capability modules, ensure they are called within `src/capabilities/index.ts` in the `registerCoreCapabilities` and `registerCoreMcpCapabilities` functions.
## V1 Limitations
- **No Auth**: HTTP transport does not include built-in authentication.
- **Node-only**: Specifically tuned for Node.js 20+.
- **No Monorepo**: Designed as a standalone project template.
- **Local Focus**: Deployment guides for cloud providers are not provided in V1.

2768
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
package.json Normal file
View File

@ -0,0 +1,52 @@
{
"name": "mcp-typescript-template",
"version": "0.1.0",
"description": "Reusable Node.js + TypeScript template for MCP servers.",
"license": "MIT",
"keywords": [
"mcp",
"model-context-protocol",
"typescript",
"template"
],
"files": [
"README.md",
"src",
"tests",
"dist",
"package.json",
"tsconfig.json",
"tsconfig.build.json",
"vitest.config.ts"
],
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "tsx src/index.ts",
"dev:http": "tsx src/http.ts",
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"mcp": {
"stdio": {
"command": "node",
"args": [
"./node_modules/tsx/dist/cli.mjs",
"src/stdio.ts"
]
}
},
"devDependencies": {
"@types/node": "^24.0.0",
"tsx": "^4.20.0",
"typescript": "^5.8.0",
"vitest": "^3.2.0"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"zod": "^3.25.76"
}
}

View File

@ -0,0 +1,95 @@
import { AppError } from "../lib/errors.js";
export const EXAMPLE_ECHO_TOOL_NAME = "example.echo";
export const EXAMPLE_SUMMARY_PROMPT_NAME = "example.summary";
export type ExampleEchoToolInput = {
message: string;
uppercase?: boolean;
};
export type ExampleEchoToolResult = {
output: string;
};
export type ExampleSummaryPromptInput = {
topic: string;
audience?: string;
};
type ObjectRecord = Record<string, unknown>;
function asRecord(value: unknown, context: string): ObjectRecord {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as ObjectRecord;
}
throw new AppError(`${context} must be an object`, "E_CONTRACT_INVALID_INPUT", {
cause: value,
});
}
function asNonEmptyString(value: unknown, field: string): string {
if (typeof value !== "string") {
throw new AppError(`${field} must be a string`, "E_CONTRACT_INVALID_INPUT", {
cause: value,
});
}
const normalized = value.trim();
if (!normalized) {
throw new AppError(`${field} must not be empty`, "E_CONTRACT_INVALID_INPUT", {
cause: value,
});
}
return normalized;
}
function asOptionalBoolean(value: unknown, field: string): boolean | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== "boolean") {
throw new AppError(`${field} must be a boolean when provided`, "E_CONTRACT_INVALID_INPUT", {
cause: value,
});
}
return value;
}
export function parseExampleEchoToolInput(input: unknown): ExampleEchoToolInput {
const record = asRecord(input, EXAMPLE_ECHO_TOOL_NAME);
return {
message: asNonEmptyString(record.message, "message"),
uppercase: asOptionalBoolean(record.uppercase, "uppercase"),
};
}
export function runExampleEchoTool(input: unknown): ExampleEchoToolResult {
const parsed = parseExampleEchoToolInput(input);
return {
output: parsed.uppercase ? parsed.message.toUpperCase() : parsed.message,
};
}
export function parseExampleSummaryPromptInput(input: unknown): ExampleSummaryPromptInput {
const record = asRecord(input, EXAMPLE_SUMMARY_PROMPT_NAME);
return {
topic: asNonEmptyString(record.topic, "topic"),
audience:
record.audience === undefined
? undefined
: asNonEmptyString(record.audience, "audience"),
};
}
export function renderExampleSummaryPrompt(input: unknown): string {
const parsed = parseExampleSummaryPromptInput(input);
const audienceLabel = parsed.audience ? ` for ${parsed.audience}` : "";
return `Write a concise summary about ${parsed.topic}${audienceLabel}.`;
}

63
src/capabilities/index.ts Normal file
View File

@ -0,0 +1,63 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
registerPromptCapabilities,
registerPromptHandlers,
} from "./prompts.js";
import {
registerResourceCapabilities,
registerResourceHandlers,
} from "./resources.js";
import { registerToolCapabilities, registerToolHandlers } from "./tools.js";
import type { CapabilityRegistration, CapabilityRegistry } from "./types.js";
import { createCapabilityRegistry } from "./types.js";
export { registerPromptCapabilities } from "./prompts.js";
export { registerPromptHandlers } from "./prompts.js";
export { registerResourceCapabilities } from "./resources.js";
export { registerResourceHandlers, TEMPLATE_STATUS_RESOURCE_URI } from "./resources.js";
export { registerToolCapabilities } from "./tools.js";
export { registerToolHandlers } from "./tools.js";
export {
EXAMPLE_ECHO_TOOL_NAME,
EXAMPLE_SUMMARY_PROMPT_NAME,
parseExampleEchoToolInput,
parseExampleSummaryPromptInput,
renderExampleSummaryPrompt,
runExampleEchoTool,
} from "./contracts.js";
export type {
CapabilityDescriptor,
CapabilityKind,
CapabilityRegistration,
CapabilityRegistry,
} from "./types.js";
export type {
ExampleEchoToolInput,
ExampleEchoToolResult,
ExampleSummaryPromptInput,
} from "./contracts.js";
export { createCapabilityRegistry } from "./types.js";
export const registerCoreCapabilities: CapabilityRegistration = (registry) => {
registerToolCapabilities(registry);
registerResourceCapabilities(registry);
registerPromptCapabilities(registry);
};
export function registerCoreMcpCapabilities(server: McpServer): void {
registerToolHandlers(server);
registerResourceHandlers(server);
registerPromptHandlers(server);
}
export function registerCapabilities(
registrations: CapabilityRegistration[] = [registerCoreCapabilities],
): CapabilityRegistry {
const registry = createCapabilityRegistry();
for (const register of registrations) {
register(registry);
}
return registry;
}

View File

@ -0,0 +1,41 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import {
EXAMPLE_SUMMARY_PROMPT_NAME,
renderExampleSummaryPrompt,
} from "./contracts.js";
import type { CapabilityRegistration } from "./types.js";
export const registerPromptCapabilities: CapabilityRegistration = (registry) => {
registry.prompts.push({
name: EXAMPLE_SUMMARY_PROMPT_NAME,
kind: "prompt",
description: "Summary prompt with a validated topic/audience contract",
});
};
const summaryPromptArgsSchema = {
topic: z.string(),
audience: z.string().optional(),
};
export function registerPromptHandlers(server: McpServer): void {
server.registerPrompt(
EXAMPLE_SUMMARY_PROMPT_NAME,
{
description: "Summary prompt with a validated topic/audience contract",
argsSchema: summaryPromptArgsSchema,
},
(input) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: renderExampleSummaryPrompt(input),
},
},
],
}),
);
}

View File

@ -0,0 +1,33 @@
import type { CapabilityRegistration } from "./types.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
export const TEMPLATE_STATUS_RESOURCE_URI = "template://status";
export const registerResourceCapabilities: CapabilityRegistration = (registry) => {
registry.resources.push({
name: TEMPLATE_STATUS_RESOURCE_URI,
kind: "resource",
description: "Baseline status resource descriptor",
});
};
export function registerResourceHandlers(server: McpServer): void {
server.registerResource(
"template-status",
TEMPLATE_STATUS_RESOURCE_URI,
{
title: "Template Status",
description: "Baseline status resource descriptor",
mimeType: "application/json",
},
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify({ status: "ok", template: "ts-mcp-template" }),
},
],
}),
);
}

39
src/capabilities/tools.ts Normal file
View File

@ -0,0 +1,39 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import {
EXAMPLE_ECHO_TOOL_NAME,
runExampleEchoTool,
type ExampleEchoToolResult,
} from "./contracts.js";
import type { CapabilityRegistration } from "./types.js";
export const registerToolCapabilities: CapabilityRegistration = (registry) => {
registry.tools.push({
name: EXAMPLE_ECHO_TOOL_NAME,
kind: "tool",
description: "Echo text with a validated input contract",
});
};
const exampleEchoInputSchema = z.object({
message: z.string(),
uppercase: z.boolean().optional(),
});
function toEchoToolResponse(result: ExampleEchoToolResult) {
return {
content: [{ type: "text" as const, text: result.output }],
structuredContent: result,
};
}
export function registerToolHandlers(server: McpServer): void {
server.registerTool(
EXAMPLE_ECHO_TOOL_NAME,
{
description: "Echo text with a validated input contract",
inputSchema: exampleEchoInputSchema,
},
async (input) => toEchoToolResponse(runExampleEchoTool(input)),
);
}

23
src/capabilities/types.ts Normal file
View File

@ -0,0 +1,23 @@
export type CapabilityKind = "tool" | "resource" | "prompt";
export type CapabilityDescriptor = {
name: string;
kind: CapabilityKind;
description: string;
};
export type CapabilityRegistry = {
tools: CapabilityDescriptor[];
resources: CapabilityDescriptor[];
prompts: CapabilityDescriptor[];
};
export type CapabilityRegistration = (registry: CapabilityRegistry) => void;
export function createCapabilityRegistry(): CapabilityRegistry {
return {
tools: [],
resources: [],
prompts: [],
};
}

2
src/config/index.ts Normal file
View File

@ -0,0 +1,2 @@
export type { RuntimeConfig, RuntimeMode } from "./runtime.js";
export { parseRuntimeConfig, RUNTIME_MODES } from "./runtime.js";

48
src/config/runtime.ts Normal file
View File

@ -0,0 +1,48 @@
export const RUNTIME_MODES = ["development", "test", "production"] as const;
export type RuntimeMode = (typeof RUNTIME_MODES)[number];
export interface RuntimeConfig {
mode: RuntimeMode;
port: number;
}
const DEFAULT_PORT = 3000;
function parseRuntimeMode(rawMode: string | undefined): RuntimeMode {
const normalized = rawMode?.trim().toLowerCase();
if (normalized === undefined || normalized.length === 0) {
return "development";
}
if (RUNTIME_MODES.includes(normalized as RuntimeMode)) {
return normalized as RuntimeMode;
}
throw new Error(
`Invalid RUNTIME_MODE '${rawMode}'. Expected one of: ${RUNTIME_MODES.join(", ")}.`
);
}
function parseHttpPort(rawPort: string | undefined): number {
if (rawPort === undefined || rawPort.trim().length === 0) {
return DEFAULT_PORT;
}
const value = Number(rawPort);
const isInteger = Number.isInteger(value);
if (!isInteger || value < 1 || value > 65535) {
throw new Error(`Invalid HTTP_PORT '${rawPort}'. Expected an integer from 1 to 65535.`);
}
return value;
}
export function parseRuntimeConfig(env: NodeJS.ProcessEnv = process.env): RuntimeConfig {
return {
mode: parseRuntimeMode(env.RUNTIME_MODE),
port: parseHttpPort(env.HTTP_PORT)
};
}

2
src/core/index.ts Normal file
View File

@ -0,0 +1,2 @@
export { createMcpCore } from "./mcp-core.js";
export type { McpCore, McpCoreFactoryOptions } from "./mcp-core.js";

42
src/core/mcp-core.ts Normal file
View File

@ -0,0 +1,42 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Implementation } from "@modelcontextprotocol/sdk/types.js";
import {
registerCapabilities,
registerCoreCapabilities,
registerCoreMcpCapabilities,
type CapabilityRegistration,
type CapabilityRegistry,
} from "../capabilities/index.js";
export type McpCore = {
server: McpServer;
registry: CapabilityRegistry;
};
export type McpCoreFactoryOptions = {
serverInfo?: Implementation;
capabilityRegistrations?: CapabilityRegistration[];
registerServerCapabilities?: (server: McpServer) => void;
};
const DEFAULT_SERVER_INFO: Implementation = {
name: "ts-mcp-template",
version: "0.1.0",
};
export function createMcpCore(options: McpCoreFactoryOptions = {}): McpCore {
const capabilityRegistrations =
options.capabilityRegistrations ?? [registerCoreCapabilities];
const registerServerCapabilities =
options.registerServerCapabilities ?? registerCoreMcpCapabilities;
const server = new McpServer(options.serverInfo ?? DEFAULT_SERVER_INFO);
const registry = registerCapabilities(capabilityRegistrations);
registerServerCapabilities(server);
return {
server,
registry,
};
}

220
src/http.ts Normal file
View File

@ -0,0 +1,220 @@
import { randomUUID } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { parseRuntimeConfig } from "./config/index.js";
import { createMcpCore, type McpCore } from "./core/index.js";
import { asError, toErrorPayload } from "./lib/errors.js";
import { createLogger } from "./lib/logger.js";
const logger = createLogger({ name: "mcp-http" });
type SessionState = {
core: McpCore;
transport: StreamableHTTPServerTransport;
};
export type HttpServerOptions = {
host?: string;
port?: number;
};
const MCP_ROUTE = "/mcp";
function readSessionIdHeader(req: IncomingMessage): string | undefined {
const header = req.headers["mcp-session-id"];
if (Array.isArray(header)) {
return header[0];
}
return header;
}
function respondJson(
res: ServerResponse,
statusCode: number,
payload: Record<string, unknown>,
): void {
const body = JSON.stringify(payload);
res.statusCode = statusCode;
res.setHeader("content-type", "application/json");
res.setHeader("content-length", Buffer.byteLength(body));
res.end(body);
}
function respondJsonRpcError(
res: ServerResponse,
statusCode: number,
message: string,
code: number,
): void {
respondJson(res, statusCode, {
jsonrpc: "2.0",
error: {
code,
message,
},
id: null,
});
}
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
if (chunks.length === 0) {
return undefined;
}
const raw = Buffer.concat(chunks).toString("utf8").trim();
if (raw.length === 0) {
return undefined;
}
try {
return JSON.parse(raw);
} catch {
throw new Error("Invalid JSON body");
}
}
async function handleMcpRequest(
req: IncomingMessage,
res: ServerResponse,
sessions: Map<string, SessionState>,
): Promise<void> {
const method = req.method ?? "GET";
const parsedBody = method === "POST" ? await readJsonBody(req) : undefined;
const sessionId = readSessionIdHeader(req);
if (sessionId !== undefined) {
const existing = sessions.get(sessionId);
if (existing === undefined) {
respondJsonRpcError(res, 404, "Session not found", -32001);
return;
}
await existing.transport.handleRequest(req, res, parsedBody);
return;
}
if (method === "POST" && isInitializeRequest(parsedBody)) {
const core = createMcpCore();
const state: SessionState = {
core,
transport: new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (newSessionId) => {
sessions.set(newSessionId, state);
},
}),
};
state.transport.onclose = () => {
if (state.transport.sessionId !== undefined) {
sessions.delete(state.transport.sessionId);
}
};
await core.server.connect(state.transport);
await state.transport.handleRequest(req, res, parsedBody);
return;
}
respondJsonRpcError(res, 400, "Bad Request: No valid session ID provided", -32000);
}
async function handleHttpRequest(
req: IncomingMessage,
res: ServerResponse,
sessions: Map<string, SessionState>,
): Promise<void> {
const path = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`).pathname;
if (path !== MCP_ROUTE) {
respondJson(res, 404, {
error: "Not Found",
message: `Route '${path}' is not available. Use '${MCP_ROUTE}'.`,
});
return;
}
const method = req.method ?? "GET";
if (method !== "GET" && method !== "POST" && method !== "DELETE") {
respondJson(res, 405, {
error: "Method Not Allowed",
message: `Method '${method}' is not supported on '${MCP_ROUTE}'.`,
});
return;
}
await handleMcpRequest(req, res, sessions);
}
export async function startHttpServer(options: HttpServerOptions = {}): Promise<Server> {
const runtime = parseRuntimeConfig();
const host = options.host ?? "127.0.0.1";
const port = options.port ?? runtime.port;
const sessions = new Map<string, SessionState>();
const server = createServer((req, res) => {
void handleHttpRequest(req, res, sessions).catch((error: unknown) => {
logger.error("Failed to process HTTP request", toErrorPayload(asError(error)));
if (!res.headersSent) {
respondJsonRpcError(res, 500, "Internal server error", -32603);
}
});
});
server.on("close", () => {
for (const state of sessions.values()) {
void state.transport.close().catch(() => {
logger.warn("Failed to close streamable HTTP transport");
});
}
sessions.clear();
});
await new Promise<void>((resolvePromise, reject) => {
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
resolvePromise();
});
});
logger.info("MCP HTTP server started", {
host,
port,
route: MCP_ROUTE,
});
return server;
}
export async function runHttpEntrypoint(): Promise<void> {
try {
await startHttpServer();
} catch (error: unknown) {
logger.error("Fatal HTTP startup error", toErrorPayload(asError(error)));
process.exitCode = 1;
}
}
function isDirectExecution(): boolean {
const entryPath = process.argv[1];
if (entryPath === undefined) {
return false;
}
return import.meta.url === pathToFileURL(resolve(entryPath)).href;
}
if (isDirectExecution()) {
void runHttpEntrypoint();
}

4
src/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from "./capabilities/index.js";
export * from "./core/index.js";
export * from "./http.js";
export * from "./stdio.js";

44
src/lib/errors.ts Normal file
View File

@ -0,0 +1,44 @@
export class AppError extends Error {
public readonly code: string;
public readonly cause?: unknown;
constructor(message: string, code: string, options?: { cause?: unknown }) {
super(message);
this.name = "AppError";
this.code = code;
this.cause = options?.cause;
}
}
export function asError(value: unknown): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
return new Error("Unknown error");
}
export function toErrorPayload(error: unknown): { name: string; message: string; code?: string } {
if (error instanceof AppError) {
return {
name: error.name,
message: error.message,
code: error.code
};
}
const normalized = asError(error);
return {
name: normalized.name,
message: normalized.message
};
}
export function isAppError(error: unknown): error is AppError {
return error instanceof AppError;
}

69
src/lib/logger.ts Normal file
View File

@ -0,0 +1,69 @@
export type LogLevel = "debug" | "info" | "warn" | "error";
export interface Logger {
debug: (message: string, details?: unknown) => void;
info: (message: string, details?: unknown) => void;
warn: (message: string, details?: unknown) => void;
error: (message: string, details?: unknown) => void;
}
export interface LoggerOptions {
name?: string;
sink?: (line: string) => void;
now?: () => Date;
}
function serializeDetails(details: unknown): string {
if (details === undefined) {
return "";
}
if (details instanceof Error) {
return JSON.stringify({
name: details.name,
message: details.message,
stack: details.stack
});
}
if (typeof details === "string") {
return JSON.stringify({ details });
}
return JSON.stringify(details);
}
export function createLogger(options: LoggerOptions = {}): Logger {
const name = options.name ?? "app";
// MCP stdio servers must keep stdout reserved for protocol messages.
const sink = options.sink ?? ((line: string) => process.stderr.write(`${line}\n`));
const now = options.now ?? (() => new Date());
const write = (level: LogLevel, message: string, details?: unknown): void => {
const payload = {
timestamp: now().toISOString(),
level,
logger: name,
message,
...(details === undefined ? {} : { details })
};
sink(JSON.stringify(payload));
};
return {
debug: (message, details) => write("debug", message, details),
info: (message, details) => write("info", message, details),
warn: (message, details) => write("warn", message, details),
error: (message, details) => write("error", message, details)
};
}
export function formatLogLine(level: LogLevel, message: string, details?: unknown): string {
const suffix = serializeDetails(details);
if (suffix.length === 0) {
return `[${level.toUpperCase()}] ${message}`;
}
return `[${level.toUpperCase()}] ${message} ${suffix}`;
}

38
src/stdio.ts Normal file
View File

@ -0,0 +1,38 @@
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createMcpCore } from "./core/index.js";
import { asError, toErrorPayload } from "./lib/errors.js";
import { createLogger } from "./lib/logger.js";
const logger = createLogger({ name: "mcp-stdio" });
export async function startStdioServer(): Promise<void> {
const { server } = createMcpCore();
const transport = new StdioServerTransport();
await server.connect(transport);
logger.info("MCP stdio server started");
}
export async function runStdioEntrypoint(): Promise<void> {
try {
await startStdioServer();
} catch (error: unknown) {
logger.error("Fatal stdio startup error", toErrorPayload(asError(error)));
process.exitCode = 1;
}
}
function isDirectExecution(): boolean {
const entryPath = process.argv[1];
if (entryPath === undefined) {
return false;
}
return import.meta.url === pathToFileURL(resolve(entryPath)).href;
}
if (isDirectExecution()) {
void runStdioEntrypoint();
}

View File

@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
EXAMPLE_ECHO_TOOL_NAME,
EXAMPLE_SUMMARY_PROMPT_NAME,
type CapabilityRegistration,
createCapabilityRegistry,
registerCapabilities,
registerCoreCapabilities,
} from "../src/capabilities/index.js";
describe("capability registration", () => {
it("aggregates core registrations through plain function composition", () => {
const registry = registerCapabilities();
expect(registry.tools.map((entry) => entry.name)).toEqual([
EXAMPLE_ECHO_TOOL_NAME,
]);
expect(registry.resources.map((entry) => entry.name)).toEqual([
"template://status",
]);
expect(registry.prompts.map((entry) => entry.name)).toEqual([
EXAMPLE_SUMMARY_PROMPT_NAME,
]);
});
it("supports explicit composition with custom registrations", () => {
const registerCustom: CapabilityRegistration = (registry) => {
registry.tools.push({
name: "custom.tool",
kind: "tool",
description: "Custom tool descriptor",
});
};
const registry = registerCapabilities([
registerCoreCapabilities,
registerCustom,
]);
expect(registry.tools.map((entry) => entry.name)).toEqual([
EXAMPLE_ECHO_TOOL_NAME,
"custom.tool",
]);
expect(registry.resources).toHaveLength(1);
expect(registry.prompts).toHaveLength(1);
});
it("lets callers register into an existing registry", () => {
const registry = createCapabilityRegistry();
registerCoreCapabilities(registry);
expect(registry.tools).toHaveLength(1);
expect(registry.resources).toHaveLength(1);
expect(registry.prompts).toHaveLength(1);
});
});

View File

@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { AppError } from "../src/lib/errors.js";
import {
parseExampleEchoToolInput,
parseExampleSummaryPromptInput,
renderExampleSummaryPrompt,
runExampleEchoTool,
} from "../src/capabilities/index.js";
describe("example capability contracts", () => {
it("accepts valid tool input and applies uppercase transform", () => {
expect(
parseExampleEchoToolInput({ message: " hello team ", uppercase: true }),
).toEqual({
message: "hello team",
uppercase: true,
});
expect(runExampleEchoTool({ message: "hello", uppercase: true })).toEqual({
output: "HELLO",
});
});
it("rejects invalid tool input shapes", () => {
expect(() => parseExampleEchoToolInput("bad-input")).toThrow(AppError);
expect(() => parseExampleEchoToolInput({ message: "" })).toThrow(
"message must not be empty",
);
expect(() =>
parseExampleEchoToolInput({ message: "valid", uppercase: "yes" }),
).toThrow("uppercase must be a boolean when provided");
});
it("accepts valid prompt input and renders generic prompt text", () => {
expect(
parseExampleSummaryPromptInput({
topic: "event-driven systems",
audience: "new contributors",
}),
).toEqual({
topic: "event-driven systems",
audience: "new contributors",
});
expect(
renderExampleSummaryPrompt({
topic: "API design",
}),
).toBe("Write a concise summary about API design.");
});
it("rejects invalid prompt input shapes", () => {
expect(() => parseExampleSummaryPromptInput(null)).toThrow(AppError);
expect(() => parseExampleSummaryPromptInput({ topic: 42 })).toThrow(
"topic must be a string",
);
expect(() =>
parseExampleSummaryPromptInput({
topic: "valid topic",
audience: " ",
}),
).toThrow("audience must not be empty");
});
});

69
tests/config.test.ts Normal file
View File

@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { parseRuntimeConfig } from "../src/config/index.js";
describe("parseRuntimeConfig", () => {
it("returns defaults when env values are missing", () => {
expect(parseRuntimeConfig({})).toEqual({
mode: "development",
port: 3000,
});
});
it("parses valid runtime mode and port", () => {
expect(
parseRuntimeConfig({
RUNTIME_MODE: "production",
HTTP_PORT: "8080",
}),
).toEqual({
mode: "production",
port: 8080,
});
});
it("normalizes mode casing and surrounding whitespace", () => {
expect(
parseRuntimeConfig({
RUNTIME_MODE: " PrOdUcTiOn ",
HTTP_PORT: " 8081 ",
}),
).toEqual({
mode: "production",
port: 8081,
});
});
it("rejects invalid runtime mode", () => {
expect(() =>
parseRuntimeConfig({
RUNTIME_MODE: "staging",
}),
).toThrow(/Invalid RUNTIME_MODE/);
});
it("rejects invalid http port", () => {
expect(() =>
parseRuntimeConfig({
HTTP_PORT: "0",
}),
).toThrow(/Invalid HTTP_PORT/);
expect(() =>
parseRuntimeConfig({
HTTP_PORT: "not-a-number",
}),
).toThrow(/Invalid HTTP_PORT/);
expect(() =>
parseRuntimeConfig({
HTTP_PORT: "65536",
}),
).toThrow(/Invalid HTTP_PORT/);
expect(() =>
parseRuntimeConfig({
HTTP_PORT: "42.5",
}),
).toThrow(/Invalid HTTP_PORT/);
});
});

101
tests/http.test.ts Normal file
View File

@ -0,0 +1,101 @@
import { readFile } from "node:fs/promises";
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { afterEach, describe, expect, it } from "vitest";
import { EXAMPLE_ECHO_TOOL_NAME } from "../src/capabilities/index.js";
import { startHttpServer } from "../src/http.js";
const testsDir = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(testsDir, "..");
const packageJsonPath = resolve(projectRoot, "package.json");
let activeServer: Server | undefined;
afterEach(async () => {
if (activeServer !== undefined) {
await new Promise<void>((resolveClose, rejectClose) => {
activeServer?.close((error) => {
if (error) {
rejectClose(error);
return;
}
resolveClose();
});
});
activeServer = undefined;
}
});
async function startTestServer(): Promise<URL> {
activeServer = await startHttpServer({ host: "127.0.0.1", port: 0 });
const address = activeServer.address() as AddressInfo;
return new URL(`http://127.0.0.1:${address.port}/mcp`);
}
describe("HTTP entrypoint", () => {
it("supports streamable HTTP initialize flow and shared capabilities", async () => {
const endpoint = await startTestServer();
const transport = new StreamableHTTPClientTransport(endpoint);
const client = new Client({ name: "http-test-client", version: "0.0.0" }, { capabilities: {} });
await client.connect(transport);
const tools = await client.listTools();
expect(tools.tools.map((tool) => tool.name)).toContain(EXAMPLE_ECHO_TOOL_NAME);
await client.close();
await transport.close();
});
it("returns 404 on unknown routes", async () => {
await startTestServer();
const address = activeServer?.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${address.port}/not-mcp`);
expect(response.status).toBe(404);
await expect(response.json()).resolves.toMatchObject({
error: "Not Found",
});
});
it("returns explicit invalid-session errors", async () => {
await startTestServer();
const address = activeServer?.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${address.port}/mcp`, {
method: "POST",
headers: {
"content-type": "application/json",
"mcp-session-id": "missing-session",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: "invalid-session",
method: "ping",
params: {},
}),
});
expect(response.status).toBe(404);
await expect(response.json()).resolves.toMatchObject({
jsonrpc: "2.0",
error: {
message: "Session not found",
},
id: null,
});
});
it("publishes an HTTP entry command", async () => {
const packageJsonRaw = await readFile(packageJsonPath, "utf8");
const packageJson = JSON.parse(packageJsonRaw) as {
scripts?: Record<string, string>;
};
expect(packageJson.scripts?.["dev:http"]).toBe("tsx src/http.ts");
});
});

View File

@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
import { AppError, asError, isAppError, toErrorPayload } from "../src/lib/errors.js";
import { createLogger } from "../src/lib/logger.js";
describe("logger", () => {
it("writes JSON logs using provided sink", () => {
const sink = vi.fn<(line: string) => void>();
const logger = createLogger({
name: "unit",
sink,
now: () => new Date("2026-01-01T00:00:00.000Z")
});
logger.info("ready", { port: 3000 });
expect(sink).toHaveBeenCalledTimes(1);
expect(JSON.parse(sink.mock.calls[0][0] as string)).toEqual({
timestamp: "2026-01-01T00:00:00.000Z",
level: "info",
logger: "unit",
message: "ready",
details: { port: 3000 }
});
});
it("defaults to stderr and never writes to stdout", () => {
const stderrWrite = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
const stdoutWrite = vi
.spyOn(process.stdout, "write")
.mockImplementation(() => true);
const logger = createLogger({ name: "unit" });
logger.error("failed");
expect(stderrWrite).toHaveBeenCalledTimes(1);
expect(stdoutWrite).not.toHaveBeenCalled();
stderrWrite.mockRestore();
stdoutWrite.mockRestore();
});
});
describe("errors", () => {
it("normalizes unknown values into Error", () => {
expect(asError("boom")).toBeInstanceOf(Error);
expect(asError("boom").message).toBe("boom");
expect(asError(123).message).toBe("Unknown error");
});
it("exposes consistent payloads", () => {
const appError = new AppError("Bad input", "BAD_INPUT");
expect(isAppError(appError)).toBe(true);
expect(toErrorPayload(appError)).toEqual({
name: "AppError",
message: "Bad input",
code: "BAD_INPUT"
});
expect(toErrorPayload(new Error("Oops"))).toEqual({
name: "Error",
message: "Oops"
});
});
});

81
tests/mcp-core.test.ts Normal file
View File

@ -0,0 +1,81 @@
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import {
type CapabilityRegistration,
EXAMPLE_ECHO_TOOL_NAME,
EXAMPLE_SUMMARY_PROMPT_NAME,
TEMPLATE_STATUS_RESOURCE_URI,
} from "../src/capabilities/index.js";
import { createMcpCore } from "../src/core/index.js";
type McpServerInternals = {
_registeredTools: Record<string, unknown>;
_registeredResources: Record<string, unknown>;
_registeredPrompts: Record<string, unknown>;
};
describe("MCP core factory", () => {
it("wires example capabilities through the shared core path", () => {
const core = createMcpCore();
const serverInternals = core.server as unknown as McpServerInternals;
expect(core.registry.tools.map((entry) => entry.name)).toEqual([
EXAMPLE_ECHO_TOOL_NAME,
]);
expect(core.registry.resources.map((entry) => entry.name)).toEqual([
TEMPLATE_STATUS_RESOURCE_URI,
]);
expect(core.registry.prompts.map((entry) => entry.name)).toEqual([
EXAMPLE_SUMMARY_PROMPT_NAME,
]);
expect(Object.keys(serverInternals._registeredTools)).toEqual([
EXAMPLE_ECHO_TOOL_NAME,
]);
expect(Object.keys(serverInternals._registeredResources)).toEqual([
TEMPLATE_STATUS_RESOURCE_URI,
]);
expect(Object.keys(serverInternals._registeredPrompts)).toEqual([
EXAMPLE_SUMMARY_PROMPT_NAME,
]);
});
it("creates an unconnected core that does not require transport wiring", () => {
const core = createMcpCore();
expect(core.server.isConnected()).toBe(false);
});
it("supports explicit registry composition and server capability wiring", () => {
const registerCustom: CapabilityRegistration = (registry) => {
registry.tools.push({
name: "custom.tool",
kind: "tool",
description: "Custom test capability",
});
};
const registerServerCapabilities = vi.fn<(server: unknown) => void>();
const core = createMcpCore({
capabilityRegistrations: [registerCustom],
registerServerCapabilities,
});
expect(core.registry.tools.map((entry) => entry.name)).toEqual(["custom.tool"]);
expect(core.registry.resources).toEqual([]);
expect(core.registry.prompts).toEqual([]);
expect(registerServerCapabilities).toHaveBeenCalledTimes(1);
expect(registerServerCapabilities).toHaveBeenCalledWith(core.server);
});
it("does not import stdio/http transport wrappers in shared core", async () => {
const here = dirname(fileURLToPath(import.meta.url));
const coreModulePath = resolve(here, "../src/core/mcp-core.ts");
const source = await readFile(coreModulePath, "utf8");
expect(source).not.toMatch(/server\/(stdio|sse|streamableHttp)\.js/);
expect(source).not.toMatch(/(?:express|hono)/);
});
});

8
tests/smoke.test.ts Normal file
View File

@ -0,0 +1,8 @@
import { describe, expect, it } from "vitest";
import * as template from "../src/index.js";
describe("template package smoke test", () => {
it("exposes the MCP core factory from public entrypoint", () => {
expect(typeof template.createMcpCore).toBe("function");
});
});

123
tests/stdio.test.ts Normal file
View File

@ -0,0 +1,123 @@
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { describe, expect, it } from "vitest";
import { EXAMPLE_ECHO_TOOL_NAME } from "../src/capabilities/index.js";
const testsDir = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(testsDir, "..");
const stdioEntryPath = resolve(projectRoot, "src/stdio.ts");
const packageJsonPath = resolve(projectRoot, "package.json");
type StdioCommandConfig = {
mcp?: {
stdio?: {
command?: string;
args?: string[];
};
};
scripts?: Record<string, string>;
};
async function readStdioCommandConfig(): Promise<{ command: string; args: string[] }> {
const packageJsonRaw = await readFile(packageJsonPath, "utf8");
const packageJson = JSON.parse(packageJsonRaw) as StdioCommandConfig;
return {
command: packageJson.mcp?.stdio?.command ?? "node",
args: packageJson.mcp?.stdio?.args ?? [],
};
}
async function createConnectedStdioClient(): Promise<{
client: Client;
transport: StdioClientTransport;
}> {
const stdioCommand = await readStdioCommandConfig();
const transport = new StdioClientTransport({
command: stdioCommand.command,
args: stdioCommand.args,
cwd: projectRoot,
stderr: "pipe",
});
const client = new Client(
{ name: "stdio-test-client", version: "0.0.0" },
{ capabilities: {} },
);
await client.connect(transport);
return { client, transport };
}
describe("stdio entrypoint", () => {
it("starts over stdio and keeps startup diagnostics on stderr", async () => {
const stdioCommand = await readStdioCommandConfig();
const transport = new StdioClientTransport({
command: stdioCommand.command,
args: stdioCommand.args,
cwd: projectRoot,
stderr: "pipe",
});
const stderrMessages: string[] = [];
const stderr = transport.stderr;
stderr?.on("data", (chunk) => {
stderrMessages.push(chunk.toString());
});
const client = new Client(
{ name: "stdio-test-client", version: "0.0.0" },
{ capabilities: {} },
);
await client.connect(transport);
const tools = await client.listTools();
expect(tools.tools.map((tool) => tool.name)).toContain(EXAMPLE_ECHO_TOOL_NAME);
expect(stderrMessages.join("")).toContain("MCP stdio server started");
await client.close();
await transport.close();
});
it("rejects invalid tool calls over stdio transport", async () => {
const { client, transport } = await createConnectedStdioClient();
const missingToolResult = await client.callTool({
name: "template.missing-tool",
arguments: {},
});
const invalidInputResult = await client.callTool({
name: EXAMPLE_ECHO_TOOL_NAME,
arguments: { uppercase: true },
});
expect(missingToolResult.isError).toBe(true);
expect(JSON.stringify(missingToolResult.content)).toMatch(/not found|unknown/i);
expect(invalidInputResult.isError).toBe(true);
expect(JSON.stringify(invalidInputResult.content)).toMatch(/message|required|invalid/i);
await client.close();
await transport.close();
});
it("publishes a non-npm stdio launch command for MCP clients", async () => {
const packageJsonRaw = await readFile(packageJsonPath, "utf8");
const packageJson = JSON.parse(packageJsonRaw) as StdioCommandConfig;
expect(packageJson.mcp?.stdio?.command).not.toMatch(/^npm|^npx/);
expect(packageJson.scripts?.["start:stdio"]).toBeUndefined();
expect(packageJson.mcp?.stdio?.command).toBe("node");
expect(packageJson.mcp?.stdio?.args).toEqual([
"./node_modules/tsx/dist/cli.mjs",
"src/stdio.ts",
]);
});
it("does not write non-protocol logs to stdout from source entry", async () => {
const source = await readFile(stdioEntryPath, "utf8");
expect(source).not.toMatch(/console\.log\(/);
expect(source).not.toMatch(/process\.stdout\.write\(/);
});
});

10
tsconfig.build.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src"],
"exclude": ["tests", "**/*.test.ts", "node_modules"]
}

16
tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"types": ["node", "vitest/globals"],
"rootDir": ".",
"outDir": "dist"
},
"include": ["src", "tests", "vitest.config.ts"],
"exclude": ["dist", "node_modules"]
}

7
vitest.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"]
}
});