diff --git a/AGENTS.md b/AGENTS.md index 0c3647e..ef57351 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 @@ -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`) @@ -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`) @@ -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 @@ -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 diff --git a/README.md b/README.md index 621447e..bc47c8e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/scripts/update-template-deps.mjs b/scripts/update-template-deps.mjs index 2d6a6ab..cfd4926 100644 --- a/scripts/update-template-deps.mjs +++ b/scripts/update-template-deps.mjs @@ -27,6 +27,10 @@ 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', @@ -34,6 +38,7 @@ const TEMPLATE_PACKAGES = [ 'dotenv', 'jose', 'typescript', + 'vitest', '@types/node', '@types/express', ]; diff --git a/src/project-generator.ts b/src/project-generator.ts index 9ab706f..ffa00de 100644 --- a/src/project-generator.ts +++ b/src/project-generator.ts @@ -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 { @@ -141,7 +143,10 @@ export async function generateProject(config: ProjectConfig): Promise { 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()) ); } @@ -151,7 +156,10 @@ export async function generateProject(config: ProjectConfig): Promise { 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)) ); diff --git a/src/templates/common/package.json.ts b/src/templates/common/package.json.ts index 5009ec6..a23e6ff 100644 --- a/src/templates/common/package.json.ts +++ b/src/templates/common/package.json.ts @@ -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; let devDependencies: Record; @@ -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 @@ -41,6 +50,7 @@ export function getPackageJsonTemplate( devDependencies = { ...commonDevDependencies, + ...testDevDependencies, }; } else { // hono is a peer dependency of @modelcontextprotocol/node, so the generated @@ -62,6 +72,7 @@ export function getPackageJsonTemplate( devDependencies = { '@types/express': '^5.0.6', ...commonDevDependencies, + ...testDevDependencies, }; } @@ -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, diff --git a/src/templates/common/tsconfig.json.ts b/src/templates/common/tsconfig.json.ts index a948d2d..c449e50 100644 --- a/src/templates/common/tsconfig.json.ts +++ b/src/templates/common/tsconfig.json.ts @@ -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', @@ -14,7 +22,7 @@ export function getTsconfigTemplate(): string { types: ['node'], }, include: ['src/**/*'], - exclude: ['node_modules', 'dist'], + exclude, }; return JSON.stringify(tsconfig, null, 2) + '\n'; diff --git a/src/templates/sdk/stateful/index.ts b/src/templates/sdk/stateful/index.ts index ef6c438..fb6d238 100644 --- a/src/templates/sdk/stateful/index.ts +++ b/src/templates/sdk/stateful/index.ts @@ -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'; diff --git a/src/templates/sdk/stateless/index.ts b/src/templates/sdk/stateless/index.ts index f3cc84c..c763827 100644 --- a/src/templates/sdk/stateless/index.ts +++ b/src/templates/sdk/stateless/index.ts @@ -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'; diff --git a/src/templates/sdk/stateless/readme.ts b/src/templates/sdk/stateless/readme.ts index e4412a4..35e206b 100644 --- a/src/templates/sdk/stateless/readme.ts +++ b/src/templates/sdk/stateless/readme.ts @@ -15,6 +15,7 @@ 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', @@ -22,6 +23,7 @@ export function getReadmeTemplate(projectName: string, options?: TemplateOptions build: 'pnpm build', start: 'pnpm start', inspect: 'pnpm inspect', + test: 'pnpm test', }, yarn: { install: 'yarn', @@ -29,6 +31,7 @@ export function getReadmeTemplate(projectName: string, options?: TemplateOptions build: 'yarn build', start: 'yarn start', inspect: 'yarn inspect', + test: 'yarn test', }, }[packageManager]; @@ -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} diff --git a/src/templates/sdk/stateless/server-test.ts b/src/templates/sdk/stateless/server-test.ts new file mode 100644 index 0000000..67b2783 --- /dev/null +++ b/src/templates/sdk/stateless/server-test.ts @@ -0,0 +1,175 @@ +export function getServerTestTemplate(): string { + return `import { describe, it, expect, beforeEach } from 'vitest'; +import { InMemoryTransport } from '@modelcontextprotocol/server'; +import { Client } from '@modelcontextprotocol/client'; +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { getServer } from './server.js'; +import * as notes from './notes-store.js'; + +// A worked example of testing a tool, not a target to match. Each test below +// pins something a tool's description promises but nothing else checks - that +// is the bar worth copying. Adding a tool is worth one such test; it does not +// oblige you to cover every branch of your own code here. +// +// The assertions run against the payload the tool returns, driven through a +// real client over an in-memory transport - no HTTP, no subprocess. That is +// the surface an agent reads, and a tool can be internally correct while still +// returning something misleading. + +async function connect(): Promise { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test', version: '1.0.0' }); + await Promise.all([getServer().connect(serverTransport), client.connect(clientTransport)]); + return client; +} + +/** Tools return JSON as text; parse it rather than asserting on the wrapper. */ +function payload(result: CallToolResult): Record { + const [first] = result.content as Array<{ type: string; text: string }>; + return JSON.parse(first.text); +} + +function text(result: CallToolResult): string { + const [first] = result.content as Array<{ type: string; text: string }>; + return first.text; +} + +describe('server', () => { + let client: Client; + + beforeEach(async () => { + notes.seedForTests([]); + client = await connect(); + }); + + // Generic on purpose: this keeps holding once you replace the notes example + // with your own tools. An undescribed parameter is a common reason an agent + // calls a tool wrongly. + it('documents every tool and every parameter', async () => { + const { tools } = await client.listTools(); + + expect(tools.length).toBeGreaterThan(0); + + for (const tool of tools) { + expect(tool.description, tool.name + ' has no description').toBeTruthy(); + + const properties = (tool.inputSchema?.properties ?? {}) as Record< + string, + { description?: string } + >; + for (const [name, schema] of Object.entries(properties)) { + expect(schema.description, tool.name + '.' + name + ' has no description').toBeTruthy(); + } + } + }); + + it('says so when a result is truncated, and how to narrow it', async () => { + // The headline contract: a capped list must not look complete. + for (let i = 0; i < 30; i++) { + await client.callTool({ + name: 'create_note', + arguments: { title: 'note ' + i, body: '' }, + }); + } + + const result = payload( + (await client.callTool({ + name: 'list_notes', + arguments: { limit: 5 }, + })) as CallToolResult + ); + + expect(result.returned).toBe(5); + expect(result.matched).toBe(30); + expect(result.truncated).toBe(true); + expect(result.hint).toContain('5 of 30'); + }); + + it('does not claim truncation when everything fits', async () => { + // The inverse case, so "truncated" means something rather than being + // hardcoded true. + await client.callTool({ name: 'create_note', arguments: { title: 'only', body: '' } }); + + const result = payload( + (await client.callTool({ name: 'list_notes', arguments: {} })) as CallToolResult + ); + + expect(result.truncated).toBe(false); + expect(result.hint).toBeUndefined(); + }); + + it('distinguishes an unknown tag from a tag with no notes', async () => { + await client.callTool({ + name: 'create_note', + arguments: { title: 'tagged', body: '', tags: ['release'] }, + }); + + const unknown = (await client.callTool({ + name: 'list_notes', + arguments: { tag: 'nope' }, + })) as CallToolResult; + + expect(unknown.isError).toBe(true); + expect(text(unknown)).toContain('release'); + + const known = payload( + (await client.callTool({ + name: 'list_notes', + arguments: { tag: 'release' }, + })) as CallToolResult + ); + + expect(known.matched).toBe(1); + }); + + it('returns an actionable error rather than throwing', async () => { + const result = (await client.callTool({ + name: 'get_note', + arguments: { id: 'note_zzzzzzzz' }, + })) as CallToolResult; + + expect(result.isError).toBe(true); + // The message has to give the agent a next move, not just say "not found". + expect(text(result)).toContain('list_notes'); + }); + + it('returns an id the next call can use', async () => { + // Chaining without a lookup only works if create hands back the id. + const created = payload( + (await client.callTool({ + name: 'create_note', + arguments: { title: 'Ship v2', body: 'draft' }, + })) as CallToolResult + ); + + const read = payload( + (await client.callTool({ + name: 'get_note', + arguments: { id: created.id }, + })) as CallToolResult + ); + + expect(read.title).toBe('Ship v2'); + }); + + it('lists the resources it can read', async () => { + // A ResourceTemplate needs both halves wired up: list enumerates, and the + // read callback resolves the same uri. Easy to get one without the other. + const created = payload( + (await client.callTool({ + name: 'create_note', + arguments: { title: 'Ship v2', body: 'draft' }, + })) as CallToolResult + ); + + const uri = 'notes://' + created.id; + + const { resources } = await client.listResources(); + expect(resources.map((r) => r.uri)).toContain(uri); + + const read = await client.readResource({ uri }); + expect(JSON.parse(read.contents[0].text as string).title).toBe('Ship v2'); + }); +}); +`; +} diff --git a/src/templates/sdk/stateless/store-test.ts b/src/templates/sdk/stateless/store-test.ts new file mode 100644 index 0000000..f6a6086 --- /dev/null +++ b/src/templates/sdk/stateless/store-test.ts @@ -0,0 +1,38 @@ +export function getStoreTestTemplate(): string { + return `import { describe, it, expect, beforeEach } from 'vitest'; +import * as notes from './notes-store.js'; + +// The store has no MCP imports, so these run without a server or a transport. +// Only two tests live here, and both exist because a tool contract depends on +// them - the rest of the store is ordinary code that needs no demonstration. + +describe('notes-store', () => { + beforeEach(() => { + notes.seedForTests([]); + }); + + it('reports the full match count, not the size of the page', () => { + // list_notes can only say "showing 5 of 30" because the store reports both. + // A store that returned rows.length as the total would make every truncated + // result look complete - the tool would lie without anything failing. + for (let i = 0; i < 30; i++) { + notes.create({ title: 'note ' + i, body: '' }); + } + + const result = notes.list({ limit: 5 }); + + expect(result.rows).toHaveLength(5); + expect(result.total).toBe(30); + }); + + it('distinguishes an unknown tag from a tag with no notes', () => { + // Both would otherwise return an empty list, and the agent would report + // "you have no notes" when the truth is "that tag does not exist". + notes.create({ title: 'tagged', body: '', tags: ['release'] }); + + expect(() => notes.list({ tag: 'nope', limit: 10 })).toThrow(notes.UnknownTagError); + expect(notes.list({ tag: 'release', limit: 10 }).total).toBe(1); + }); +}); +`; +} diff --git a/src/templates/sdk/stateless/templates.test.ts b/src/templates/sdk/stateless/templates.test.ts index e8dabd6..4bc23b4 100644 --- a/src/templates/sdk/stateless/templates.test.ts +++ b/src/templates/sdk/stateless/templates.test.ts @@ -7,6 +7,8 @@ import { getToolsTemplate, getPromptsTemplate, getResourcesTemplate, + getStoreTestTemplate, + getServerTestTemplate, } from './index.js'; describe('sdk/stateless templates', () => { @@ -295,4 +297,52 @@ describe('sdk/stateless templates', () => { expect(template).toContain('export function seedForTests('); }); }); + + describe('generated test templates', () => { + it('should exercise the store without a transport', () => { + const template = getStoreTestTemplate(); + expect(template).toContain("import * as notes from './notes-store.js'"); + expect(template).not.toContain('@modelcontextprotocol/client'); + expect(template).not.toContain('InMemoryTransport'); + }); + + it('should pin the store invariants the tools depend on', () => { + const template = getStoreTestTemplate(); + expect(template).toContain('reports the full match count'); + expect(template).toContain('distinguishes an unknown tag from a tag with no notes'); + expect(template).toContain('seedForTests'); + }); + + it('should drive the server through a real client', () => { + const template = getServerTestTemplate(); + expect(template).toContain("import { Client } from '@modelcontextprotocol/client'"); + expect(template).toContain('InMemoryTransport.createLinkedPair()'); + expect(template).toContain("import { getServer } from './server.js'"); + }); + + it('should assert on the parsed payload, not the result wrapper', () => { + const template = getServerTestTemplate(); + expect(template).toContain('JSON.parse(first.text)'); + expect(template).toContain('result.truncated'); + expect(template).toContain('result.matched'); + }); + + it('should cover the contracts that would otherwise fail silently', () => { + const template = getServerTestTemplate(); + expect(template).toContain('says so when a result is truncated'); + expect(template).toContain('does not claim truncation when everything fits'); + expect(template).toContain('distinguishes an unknown tag from a tag with no notes'); + expect(template).toContain('documents every tool and every parameter'); + }); + + // Emitted code with a stray backslash-backtick fails to parse, and the + // template functions are the only place that can introduce one. + it('should not emit escaped template-literal syntax', () => { + const backslash = String.fromCharCode(92); + for (const template of [getStoreTestTemplate(), getServerTestTemplate()]) { + expect(template).not.toContain(backslash + '`'); + expect(template).not.toContain(backslash + '$'); + } + }); + }); }); diff --git a/src/templates/sdk/stdio/index.ts b/src/templates/sdk/stdio/index.ts index 9aa9410..a6e6270 100644 --- a/src/templates/sdk/stdio/index.ts +++ b/src/templates/sdk/stdio/index.ts @@ -20,3 +20,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'; diff --git a/src/templates/sdk/stdio/readme.ts b/src/templates/sdk/stdio/readme.ts index b3706c1..b44a273 100644 --- a/src/templates/sdk/stdio/readme.ts +++ b/src/templates/sdk/stdio/readme.ts @@ -12,6 +12,7 @@ export function getReadmeTemplate(projectName: string, options?: TemplateOptions inspectTools: 'npm run inspect:tools', inspectPrompts: 'npm run inspect:prompts', inspectResources: 'npm run inspect:resources', + test: 'npm test', }, pnpm: { install: 'pnpm install', @@ -21,6 +22,7 @@ export function getReadmeTemplate(projectName: string, options?: TemplateOptions inspectTools: 'pnpm inspect:tools', inspectPrompts: 'pnpm inspect:prompts', inspectResources: 'pnpm inspect:resources', + test: 'pnpm test', }, yarn: { install: 'yarn', @@ -30,6 +32,7 @@ export function getReadmeTemplate(projectName: string, options?: TemplateOptions inspectTools: 'yarn inspect:tools', inspectPrompts: 'yarn inspect:prompts', inspectResources: 'yarn inspect:resources', + test: 'yarn test', }, }[packageManager]; @@ -113,6 +116,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 \`\`\`