#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { cp, mkdir, readdir, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
type Command = 'init' | 'generate';
type GenerateKind = 'tool' | 'resource' | 'prompt';
type CliOptions = {
force: boolean;
install: boolean;
};
const SDK_VENDOR_DIR = 'vendor/mcp-sdk-v2';
function printHelp(): void {
process.stdout.write(
[
'MCP Stateless Starter CLI',
'',
'Usage:',
' mcp-stateless-starter init <project-name> [--install] [--force]',
' mcp-stateless-starter generate <tool|resource|prompt> <name> [--force]',
'',
'Examples:',
' mcp-stateless-starter init my-mcp-server --install',
' mcp-stateless-starter generate tool calculate_invoice_total',
'',
].join('\n'),
);
}
function parseOptions(rawArgs: string[]): { positional: string[]; options: CliOptions } {
const positional: string[] = [];
const options: CliOptions = {
force: false,
install: false,
};
for (const arg of rawArgs) {
if (arg === '--force') {
options.force = true;
continue;
}
if (arg === '--install') {
options.install = true;
continue;
}
positional.push(arg);
}
return { positional, options };
}
function toKebabCase(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function toPascalCase(value: string): string {
return value
.split(/[^a-zA-Z0-9]/g)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
}
async function ensureProjectDirectory(targetDir: string, force: boolean): Promise<void> {
if (!existsSync(targetDir)) {
await mkdir(targetDir, { recursive: true });
return;
}
const entries = await readdir(targetDir);
if (entries.length > 0 && !force) {
throw new Error(
`Target directory is not empty: ${targetDir}. Use --force if you want to continue anyway.`,
);
}
}
function renderStarterPackageJson(projectName: string): string {
const normalizedName = toKebabCase(projectName);
return `${JSON.stringify(
{
name: normalizedName || 'my-mcp-server',
version: '0.1.0',
private: true,
type: 'module',
scripts: {
dev: 'tsx src/server.ts',
build: 'tsc -p tsconfig.json',
start: 'node dist/server.js',
typecheck: 'tsc --noEmit',
},
dependencies: {
'@cfworker/json-schema': '^4.1.1',
'@modelcontextprotocol/express':
'file:vendor/mcp-sdk-v2/modelcontextprotocol-express-2.0.0-alpha.0.tgz',
'@modelcontextprotocol/node':
'file:vendor/mcp-sdk-v2/modelcontextprotocol-node-2.0.0-alpha.0.tgz',
'@modelcontextprotocol/server':
'file:vendor/mcp-sdk-v2/modelcontextprotocol-server-2.0.0-alpha.0.tgz',
express: '^5.2.1',
zod: '^4.3.5',
},
devDependencies: {
'@types/express': '^5.0.6',
'@types/node': '^22.10.6',
tsx: '^4.19.2',
typescript: '^5.7.3',
},
engines: {
node: '>=20.0.0',
},
},
null,
2,
)}\n`;
}
function renderStarterReadme(projectName: string): string {
return [
`# ${projectName}`,
'',
'Stateless MCP server starter generated by `mcp-stateless-starter`.',
'',
'## Run',
'',
'```bash',
'npm install',
'npm run dev',
'```',
'',
'Server endpoint: `http://127.0.0.1:1071/mcp`',
'',
'## Why this starter',
'',
'- Uses MCP TypeScript SDK v2 pre-release APIs (`registerTool`, `NodeStreamableHTTPServerTransport`).',
'- Uses stateless Streamable HTTP (`sessionIdGenerator: undefined`).',
'- Keeps everything in one server file so it is easy to expand.',
'',
'## Next steps',
'',
'1. Add new tools with `generate tool <name>` from the parent boilerplate project.',
'2. Split handlers into files under `src/` once the project grows.',
'3. Add production middleware (auth, rate-limit, observability) for your domain.',
'',
].join('\n');
}
function renderStarterTsConfig(): string {
return `${JSON.stringify(
{
compilerOptions: {
target: 'ES2022',
module: 'NodeNext',
moduleResolution: 'NodeNext',
outDir: 'dist',
rootDir: 'src',
strict: true,
noUnusedLocals: true,
noUnusedParameters: true,
noImplicitReturns: true,
noFallthroughCasesInSwitch: true,
noUncheckedIndexedAccess: true,
exactOptionalPropertyTypes: false,
forceConsistentCasingInFileNames: true,
esModuleInterop: true,
skipLibCheck: true,
types: ['node'],
},
include: ['src'],
},
null,
2,
)}\n`;
}
function renderStarterServer(projectName: string): string {
const normalizedName = toKebabCase(projectName) || 'my-mcp-server';
return [
"import { createMcpExpressApp } from '@modelcontextprotocol/express';",
"import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';",
"import { McpServer } from '@modelcontextprotocol/server';",
"import type { Request, Response } from 'express';",
"import * as z from 'zod/v4';",
'',
`const serverInfo = { name: '${normalizedName}', version: '0.1.0' } as const;`,
"const host = process.env.HOST || '127.0.0.1';",
"const port = Number.parseInt(process.env.PORT || '1071', 10);",
'',
'function createServer(): McpServer {',
' const server = new McpServer(serverInfo, { capabilities: { logging: {} } });',
'',
' server.registerTool(',
" 'hello',",
' {',
" description: 'Sample tool from starter template',",
' inputSchema: z.object({',
" name: z.string().default('world'),",
' }),',
' },',
" async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }),",
' );',
'',
' return server;',
'}',
'',
'const app = createMcpExpressApp({ host });',
'',
"app.post('/mcp', async (req: Request, res: Response) => {",
' const server = createServer();',
' const transport = new NodeStreamableHTTPServerTransport({',
' sessionIdGenerator: undefined,',
' });',
'',
' let closed = false;',
' const closeAll = async () => {',
' if (closed) return;',
' closed = true;',
' await Promise.allSettled([transport.close(), server.close()]);',
' };',
'',
" res.on('close', () => {",
' void closeAll();',
' });',
'',
' try {',
' await server.connect(transport);',
' await transport.handleRequest(req, res, req.body);',
' } catch {',
' await closeAll();',
' if (!res.headersSent) {',
" res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: null });",
' }',
' }',
'});',
'',
"app.get('/mcp', (_req: Request, res: Response) => {",
" res.status(405).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Use POST /mcp' }, id: null });",
'});',
'',
"app.delete('/mcp', (_req: Request, res: Response) => {",
" res.status(405).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Use POST /mcp' }, id: null });",
'});',
'',
'app.listen(port, host, () => {',
' process.stdout.write(`Starter server ready at http://${host}:${String(port)}/mcp\\n`);',
'});',
'',
].join('\n');
}
function renderGeneratedFile(
kind: GenerateKind,
rawName: string,
): { fileName: string; code: string } {
const kebab = toKebabCase(rawName);
const pascal = toPascalCase(rawName);
if (!kebab) {
throw new Error('Invalid name. Use letters, numbers, dashes, or underscores.');
}
switch (kind) {
case 'tool': {
return {
fileName: `${kebab}.ts`,
code: [
"import type { McpServer } from '@modelcontextprotocol/server';",
"import * as z from 'zod/v4';",
'',
`export function register${pascal}Tool(server: McpServer): void {`,
' server.registerTool(',
` '${kebab}',`,
' {',
` description: 'TODO: describe ${kebab}',`,
' inputSchema: z.object({}),',
' },',
" async () => ({ content: [{ type: 'text', text: 'TODO' }] }),",
' );',
'}',
'',
].join('\n'),
};
}
case 'resource': {
return {
fileName: `${kebab}.ts`,
code: [
"import type { McpServer, ReadResourceResult } from '@modelcontextprotocol/server';",
'',
`export function register${pascal}Resource(server: McpServer): void {`,
' server.registerResource(',
` '${kebab}',`,
` 'boilerplate://${kebab}',`,
' {',
` title: '${pascal}',`,
` description: 'TODO: describe resource ${kebab}',`,
` mimeType: 'text/plain',`,
' },',
' async (uri): Promise<ReadResourceResult> => ({',
' contents: [',
' {',
' uri: uri.href,',
` text: 'TODO: implement ${kebab} resource',`,
' },',
' ],',
' }),',
' );',
'}',
'',
].join('\n'),
};
}
case 'prompt': {
return {
fileName: `${kebab}.ts`,
code: [
"import type { GetPromptResult, McpServer } from '@modelcontextprotocol/server';",
"import * as z from 'zod/v4';",
'',
`export function register${pascal}Prompt(server: McpServer): void {`,
' server.registerPrompt(',
` '${kebab}',`,
' {',
` title: '${pascal}',`,
` description: 'TODO: describe prompt ${kebab}',`,
' argsSchema: z.object({}),',
' },',
' (): GetPromptResult => ({',
' messages: [',
' {',
" role: 'user',",
" content: { type: 'text', text: 'TODO: implement prompt body' },",
' },',
' ],',
' }),',
' );',
'}',
'',
].join('\n'),
};
}
}
}
function getRepoRoot(): string {
const thisFile = fileURLToPath(import.meta.url);
return resolve(dirname(thisFile), '..');
}
async function runNpmInstall(targetDir: string): Promise<void> {
await new Promise<void>((resolvePromise, rejectPromise) => {
const child = spawn('npm', ['install'], {
cwd: targetDir,
stdio: 'inherit',
shell: false,
});
child.on('error', (error) => {
rejectPromise(error);
});
child.on('exit', (exitCode) => {
if (exitCode === 0) {
resolvePromise();
return;
}
rejectPromise(new Error(`npm install failed with exit code ${String(exitCode)}`));
});
});
}
async function runInit(projectName: string, options: CliOptions): Promise<void> {
const repoRoot = getRepoRoot();
const targetDir = resolve(process.cwd(), projectName);
await ensureProjectDirectory(targetDir, options.force);
await mkdir(join(targetDir, 'src'), { recursive: true });
await writeFile(join(targetDir, '.gitignore'), 'node_modules\ndist\n.env\n', 'utf8');
await writeFile(join(targetDir, 'package.json'), renderStarterPackageJson(projectName), 'utf8');
await writeFile(join(targetDir, 'tsconfig.json'), renderStarterTsConfig(), 'utf8');
await writeFile(join(targetDir, 'README.md'), renderStarterReadme(projectName), 'utf8');
await writeFile(join(targetDir, 'src/server.ts'), renderStarterServer(projectName), 'utf8');
const sourceVendorDir = join(repoRoot, SDK_VENDOR_DIR);
const targetVendorDir = join(targetDir, SDK_VENDOR_DIR);
if (!existsSync(sourceVendorDir)) {
throw new Error(`Missing SDK vendor directory: ${sourceVendorDir}`);
}
await mkdir(join(targetDir, 'vendor'), { recursive: true });
await cp(sourceVendorDir, targetVendorDir, { recursive: true });
process.stdout.write(`Created starter project in ${targetDir}\n`);
if (options.install) {
process.stdout.write('Running npm install...\n');
await runNpmInstall(targetDir);
process.stdout.write('Install complete.\n');
}
}
async function runGenerate(kind: GenerateKind, name: string, options: CliOptions): Promise<void> {
const generated = renderGeneratedFile(kind, name);
const targetDir = join(process.cwd(), 'src', `${kind}s`);
const targetFile = join(targetDir, generated.fileName);
await mkdir(targetDir, { recursive: true });
if (existsSync(targetFile) && !options.force) {
throw new Error(`File already exists: ${targetFile}. Use --force to overwrite.`);
}
await writeFile(targetFile, generated.code, 'utf8');
process.stdout.write(`Created ${kind}: ${targetFile}\n`);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
printHelp();
return;
}
const command = args[0] as Command;
const { positional, options } = parseOptions(args.slice(1));
switch (command) {
case 'init': {
const projectName = positional[0];
if (!projectName) {
throw new Error('Missing project name. Usage: init <project-name>');
}
await runInit(projectName, options);
return;
}
case 'generate': {
const kind = positional[0] as GenerateKind | undefined;
const name = positional[1];
if (!kind || !name) {
throw new Error('Usage: generate <tool|resource|prompt> <name>');
}
if (!['tool', 'resource', 'prompt'].includes(kind)) {
throw new Error(`Unknown generate target: ${kind}`);
}
await runGenerate(kind, name, options);
return;
}
default:
throw new Error(`Unknown command: ${command}`);
}
}
void main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`CLI failed: ${message}\n`);
process.exitCode = 1;
});