Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 44 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,14 @@ create-mcp-server/
│ │ ├── index.ts # Barrel exports
│ │ └── templates.test.ts # Tests for deployment templates
│ ├── sdk/ # Official MCP SDK v2 templates
│ │ ├── stateless/ # Shared HTTP template (source of truth)
│ │ │ ├── server.ts # MCP server definition template
│ │ ├── stateless/ # Shared SDK templates (source of truth)
│ │ │ ├── server.ts # Composition root (registers the primitives)
│ │ │ ├── tools.ts # Tool definitions template
│ │ │ ├── prompts.ts # Prompt definitions template
│ │ │ ├── resources.ts # Resource definitions template
│ │ │ ├── store.ts # notes-store.ts template (no MCP imports)
│ │ │ ├── store-test.ts # notes-store.test.ts template
│ │ │ ├── server-test.ts # server.test.ts template
│ │ │ ├── index.ts # getIndexTemplate (createMcpHandler + toNodeHandler)
│ │ │ ├── readme.ts # README.md template (OAuth-aware)
│ │ │ └── templates.test.ts
Expand Down Expand Up @@ -109,6 +115,23 @@ Two rules follow from that:
- **`auth.ts` loads the env itself.** ES imports are hoisted, so `auth.ts` is evaluated before `index.ts` runs `config()`. Without its own load, its module-scope `CONFIG` reads empty values and OAuth breaks silently. Keep the `config()` call above `CONFIG`.
- **The stdio template must never load dotenv.** stdout is the protocol channel there. A test asserts the emitted index contains no `dotenv`.

## Generated project tests

SDK projects ship a working test setup; FastMCP does not yet. Two layers, from
`src/templates/sdk/stateless/store-test.ts` and `server-test.ts`:

- **`notes-store.test.ts`** exercises the store with no MCP involved — fast, no transport.
- **`server.test.ts`** drives a real `Client` over `InMemoryTransport.createLinkedPair()` and asserts on the **parsed tool payload**, which is the surface an agent actually reads. A tool can be internally correct and still return something misleading.

**Keep the emitted suite small and exemplary.** It is a template people copy, so its size sets an expectation. Every test must pin something a tool's *description* promises and nothing else checks — truncation signalled, errors actionable, an unknown filter distinguishable from an empty result. Do not add tests that exercise the example's own data structures: a test that a `Map` stores things teaches nothing about tool design and inflates what a reader thinks is required. Nine tests is the current budget, and adding one should mean retiring one.

Notes for editing these:

- **No `vitest.config.ts` is emitted, and none is needed.** Vitest discovers `src/**/*.test.ts` and handles TypeScript out of the box.
- **`tsconfig.json` excludes `src/**/*.test.ts`.** Without it the tests compile into `dist/` and ship in the Docker production image. `getTsconfigTemplate({ withTests })` controls this; it is on for SDK projects only.
- **The emitted test code avoids template literals entirely**, using string concatenation instead. Nesting a template literal inside the template literal that generates it needs `\\\`` and `\\\${`, and getting that wrong emits a stray backslash that fails to parse. A test asserts no escaped template-literal syntax reaches the output.
- **`@modelcontextprotocol/client` must track `@modelcontextprotocol/server`'s major.** Both are in `TEMPLATE_PACKAGES`, but the update script cannot enforce the pairing — check it whenever a major is flagged.

## Publishing

```bash
Expand Down Expand Up @@ -180,9 +203,7 @@ Features:
- Express.js via `createMcpExpressApp` + `toNodeHandler`
- Single `app.all('/mcp', ...)` route — the handler owns method dispatch
- Serves protocol `2026-07-28`; 2025-era clients handled via the default `legacy: 'stateless'`
- Example prompt (`greeting-template`)
- Example tool (`greet`)
- Example resource (`greeting-resource`)
- The notes example: `list_notes` / `get_note` / `create_note`, a `notes://{id}` resource template, and a `summarize-notes` prompt
- Health check at `GET /health`
- Environment variable support for PORT and ALLOWED_HOSTS
- **Optional OAuth authentication** (`withOAuth`)
Expand Down Expand Up @@ -224,7 +245,7 @@ A stdio MCP server using SDK v2. Uses `serveStdio` — for local clients like Cl
Features:
- `serveStdio(() => getServer())` from `@modelcontextprotocol/server/stdio` (no HTTP server, no Express)
- Pins one server instance per connection; negotiates protocol era from the opening exchange
- Same example prompt, tool, and resource as the shared HTTP template
- Same example tools, prompt, and resource as the shared HTTP template
- No PORT/ALLOWED_HOSTS environment variables
- No Dockerfile generated (stdio servers are run directly)
- MCP Inspector CLI mode (`mcp-inspector --cli node dist/index.js`)
Expand All @@ -244,9 +265,15 @@ Generated project structure for HTTP templates (+auth.ts when OAuth enabled for
```
{project-name}/
├── src/
│ ├── server.ts # MCP server with tools/prompts/resources
│ ├── index.ts # Server startup configuration
│ └── auth.ts # OAuth middleware (SDK HTTP + OAuth only)
│ ├── server.ts # Creates the McpServer, registers the primitives
│ ├── tools.ts # Tool definitions (SDK only)
│ ├── prompts.ts # Prompt definitions (SDK only)
│ ├── resources.ts # Resource definitions (SDK only)
│ ├── notes-store.ts # Example data layer (SDK only)
│ ├── notes-store.test.ts # Store unit tests (SDK only)
│ ├── server.test.ts # Payload-level tests (SDK only)
│ ├── index.ts # Server startup configuration
│ └── auth.ts # OAuth middleware (SDK HTTP + OAuth only)
├── Dockerfile # Multi-stage Docker build
├── .dockerignore # Docker ignore file
├── package.json
Expand All @@ -260,8 +287,14 @@ Generated project structure for stdio templates (no Dockerfile):
```
{project-name}/
├── src/
│ ├── server.ts # MCP server with tools/prompts/resources
│ └── index.ts # stdio transport startup
│ ├── server.ts # Creates the McpServer, registers the primitives
│ ├── tools.ts # Tool definitions (SDK only)
│ ├── prompts.ts # Prompt definitions (SDK only)
│ ├── resources.ts # Resource definitions (SDK only)
│ ├── notes-store.ts # Example data layer (SDK only)
│ ├── notes-store.test.ts # Store unit tests (SDK only)
│ ├── server.test.ts # Payload-level tests (SDK only)
│ └── index.ts # stdio transport startup
├── package.json
├── tsconfig.json
├── .gitignore
Expand Down
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,15 @@ SDK v2 serves every HTTP request through a single per-request idiom: `createMcpH
```
my-mcp-server/
├── src/
│ ├── server.ts # MCP server (tools, prompts, resources)
│ ├── index.ts # Express app and transport setup
│ └── auth.ts # OAuth middleware (if enabled)
│ ├── server.ts # Creates the server, registers the primitives
│ ├── tools.ts # Tool definitions
│ ├── prompts.ts # Prompt definitions
│ ├── resources.ts # Resource definitions
│ ├── notes-store.ts # Example data layer (no MCP imports)
│ ├── notes-store.test.ts # Store unit tests
│ ├── server.test.ts # Tests driven through a real MCP client
│ ├── index.ts # Express app and transport setup
│ └── auth.ts # OAuth middleware (if enabled)
├── Dockerfile # Production-ready Docker build
├── package.json
├── tsconfig.json
Expand All @@ -145,8 +151,13 @@ my-mcp-server/
└── README.md
```

SDK projects come with a worked example — a small notes server — and a test suite
that runs green immediately, so there is a working pattern to copy when you add
your own tools. FastMCP projects ship the example only, without tests, for now.

**Scripts:**
- `npm run dev` — build and start the server
- `npm test` — run the test suite (SDK projects)
- `npm run inspect` — open MCP Inspector (update URL in `package.json` if needed)

## Learning Resources
Expand Down
5 changes: 5 additions & 0 deletions scripts/update-template-deps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,18 @@ const TEMPLATE_PACKAGES = [
'@modelcontextprotocol/express',
'@modelcontextprotocol/node',
'@modelcontextprotocol/inspector',
// devDependency of generated SDK projects, for the in-memory test harness.
// Must track @modelcontextprotocol/server's major; the script cannot enforce
// that, so check it when a major is flagged.
'@modelcontextprotocol/client',
'express',
'hono',
'fastmcp',
'zod',
'dotenv',
'jose',
'typescript',
'vitest',
'@types/node',
'@types/express',
];
Expand Down
12 changes: 10 additions & 2 deletions src/project-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
getToolsTemplate as getSdkToolsTemplate,
getPromptsTemplate as getSdkPromptsTemplate,
getResourcesTemplate as getSdkResourcesTemplate,
getStoreTestTemplate as getSdkStoreTestTemplate,
getServerTestTemplate as getSdkServerTestTemplate,
} from './templates/sdk/stateless/index.js';
import { getAuthTemplate as getSdkAuthTemplate } from './templates/sdk/stateful/index.js';
import {
Expand Down Expand Up @@ -141,7 +143,10 @@ export async function generateProject(config: ProjectConfig): Promise<void> {
writeFile(join(srcPath, 'tools.ts'), getSdkToolsTemplate()),
writeFile(join(srcPath, 'prompts.ts'), getSdkPromptsTemplate()),
writeFile(join(srcPath, 'resources.ts'), getSdkResourcesTemplate()),
writeFile(join(srcPath, 'notes-store.ts'), getSdkStoreTemplate())
writeFile(join(srcPath, 'notes-store.ts'), getSdkStoreTemplate()),
// Two layers: the store in isolation, and the payload an agent reads.
writeFile(join(srcPath, 'notes-store.test.ts'), getSdkStoreTestTemplate()),
writeFile(join(srcPath, 'server.test.ts'), getSdkServerTestTemplate())
);
}

Expand All @@ -151,7 +156,10 @@ export async function generateProject(config: ProjectConfig): Promise<void> {
join(projectPath, 'package.json'),
getPackageJsonTemplate(projectName, templateOptions)
),
writeFile(join(projectPath, 'tsconfig.json'), getTsconfigTemplate()),
writeFile(
join(projectPath, 'tsconfig.json'),
getTsconfigTemplate({ withTests: framework === 'sdk' })
),
writeFile(join(projectPath, '.gitignore'), getGitignoreTemplate()),
writeFile(join(projectPath, '.env.example'), getEnvExampleTemplate(templateOptions))
);
Expand Down
12 changes: 12 additions & 0 deletions src/templates/common/package.json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export function getPackageJsonTemplate(
const framework = options?.framework ?? 'sdk';
const transport = options?.transport ?? 'http';

// Tests ship with SDK projects only; FastMCP has no test setup yet.
const withTests = framework === 'sdk';

let dependencies: Record<string, string>;
let devDependencies: Record<string, string>;

Expand All @@ -18,6 +21,12 @@ export function getPackageJsonTemplate(
};
const zodDependency = { zod: '^4.6.5' };
const dotEnvDependency = { dotenv: '^18.0.1' };
// The client drives the server over an in-memory transport in the generated
// tests, so it must track @modelcontextprotocol/server's major.
const testDevDependencies = {
vitest: '^5.0.1',
'@modelcontextprotocol/client': '^2.0.0',
};

if (framework === 'fastmcp') {
// FastMCP pulls in @modelcontextprotocol/sdk v1 itself, so this branch
Expand All @@ -41,6 +50,7 @@ export function getPackageJsonTemplate(

devDependencies = {
...commonDevDependencies,
...testDevDependencies,
};
} else {
// hono is a peer dependency of @modelcontextprotocol/node, so the generated
Expand All @@ -62,6 +72,7 @@ export function getPackageJsonTemplate(
devDependencies = {
'@types/express': '^5.0.6',
...commonDevDependencies,
...testDevDependencies,
};
}

Expand All @@ -83,6 +94,7 @@ export function getPackageJsonTemplate(
build: 'tsc',
dev: 'tsc && node dist/index.js',
start: 'node dist/index.js',
...(withTests ? { test: 'vitest run', 'test:watch': 'vitest' } : {}),
...inspectScripts,
},
dependencies,
Expand Down
12 changes: 10 additions & 2 deletions src/templates/common/tsconfig.json.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
export function getTsconfigTemplate(): string {
export function getTsconfigTemplate(options?: { withTests?: boolean }): string {
const withTests = options?.withTests ?? false;

// Test files are type-checked by the editor and by vitest, but kept out of
// the build so they never reach dist/ or the production Docker image.
const exclude = withTests
? ['node_modules', 'dist', 'src/**/*.test.ts']
: ['node_modules', 'dist'];

const tsconfig = {
compilerOptions: {
target: 'ES2022',
Expand All @@ -14,7 +22,7 @@ export function getTsconfigTemplate(): string {
types: ['node'],
},
include: ['src/**/*'],
exclude: ['node_modules', 'dist'],
exclude,
};

return JSON.stringify(tsconfig, null, 2) + '\n';
Expand Down
2 changes: 2 additions & 0 deletions src/templates/sdk/stateful/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ export { getStoreTemplate } from '../stateless/store.js';
export { getToolsTemplate } from '../stateless/tools.js';
export { getPromptsTemplate } from '../stateless/prompts.js';
export { getResourcesTemplate } from '../stateless/resources.js';
export { getStoreTestTemplate } from '../stateless/store-test.js';
export { getServerTestTemplate } from '../stateless/server-test.js';
2 changes: 2 additions & 0 deletions src/templates/sdk/stateless/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,5 @@ export { getStoreTemplate } from './store.js';
export { getToolsTemplate } from './tools.js';
export { getPromptsTemplate } from './prompts.js';
export { getResourcesTemplate } from './resources.js';
export { getStoreTestTemplate } from './store-test.js';
export { getServerTestTemplate } from './server-test.js';
18 changes: 18 additions & 0 deletions src/templates/sdk/stateless/readme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,23 @@ export function getReadmeTemplate(projectName: string, options?: TemplateOptions
build: 'npm run build',
start: 'npm start',
inspect: 'npm run inspect',
test: 'npm test',
},
pnpm: {
install: 'pnpm install',
dev: 'pnpm dev',
build: 'pnpm build',
start: 'pnpm start',
inspect: 'pnpm inspect',
test: 'pnpm test',
},
yarn: {
install: 'yarn',
dev: 'yarn dev',
build: 'yarn build',
start: 'yarn start',
inspect: 'yarn inspect',
test: 'yarn test',
},
}[packageManager];

Expand Down Expand Up @@ -202,6 +205,21 @@ well. Notes are held in memory, so they last until the process restarts.

- **summarize-notes** - Summarize the notes carrying a given tag

## Tests

\`\`\`bash
${commands.test}
\`\`\`

Two layers: \`src/notes-store.test.ts\` covers the data layer on its own, and
\`src/server.test.ts\` drives a real MCP client over an in-memory transport and
asserts on the payload a tool actually returns.

These are a worked example, not a quota. Each one pins something a tool's
description promises but nothing else checks - that a truncated list says so,
that an error names a next step, that an unknown filter is distinguishable from
an empty result. When you add a tool, one test in that spirit is enough.

## Project Structure

${projectStructure}
Expand Down
Loading
Loading