From 4d968dd4e8a76836a1ac460f51ac674fe6b4cb82 Mon Sep 17 00:00:00 2001 From: Ali Ibrahim Jr <48456829+IBJunior@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:04:30 +0200 Subject: [PATCH] feat: write an AGENTS.md into generated projects Generated projects explained how to run themselves and nothing about how to change themselves. The traps that actually bite - state discarded between requests, stdout corrupting the protocol stream, a token rejection returning 500 instead of 401 - were only comments in the code, where you find them after the bug rather than before. Adds src/templates/common/agents.md.ts, written for whoever edits the project rather than whoever runs it. The README keeps the install and deployment story; this has no overlap with it beyond a command block. The file is deliberately example-agnostic. The shipped notes example exists so nothing starts empty and is meant to be deleted once real tools arrive, but AGENTS.md stays - so naming notes-store or list_notes in the guidance would rot it the moment the example goes. It says the example is meant to be replaced, then uses a neutral illustration (list_invoices) for every rule. A test asserts no example-specific name reaches the output. It branches per variant, and the branching is the substance: - HTTP gets the per-request factory warning. createMcpHandler runs the factory once per request, so state on the server instance vanishes between calls - a write appears to succeed and the next read returns nothing. - stdio gets the stdout rule instead, since the factory warning does not apply there and the protocol channel does. - OAuth adds the OAuthError-not-Error rule, with the missing-JWKS guard called out as the deliberate exception. - FastMCP gets a trimmed version that states plainly it ships without tests, rather than leaving a gap a reader has to infer. SDK variants also carry the tool-design guidance - naming, parameter documentation, signalled truncation, actionable errors, payloads that cannot mean two things - and a section on organizing tools as they grow: flat files while there are few, then src/tools/ one per tool, then feature folders once a server spans unrelated domains. The scaffold ships flat because that suits a handful of tools; the advice on outgrowing it belongs in prose rather than in a directory structure the example has not earned. The naming rule interpolates the real project name, so the double-prefix warning reads "_invoices_list" rather than an abstract example. Also adds vitest.config.ts. generated/ holds scratch projects from testing the CLI by hand; they are gitignored, but since they now ship their own test suites the repo's own run was picking them up and failing on them. --- AGENTS.md | 33 ++++ README.md | 6 + src/project-generator.ts | 4 +- src/templates/common/agents.md.test.ts | 138 ++++++++++++++++ src/templates/common/agents.md.ts | 215 +++++++++++++++++++++++++ vitest.config.ts | 12 ++ 6 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 src/templates/common/agents.md.test.ts create mode 100644 src/templates/common/agents.md.ts create mode 100644 vitest.config.ts diff --git a/AGENTS.md b/AGENTS.md index ef57351..d7fb0c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,8 @@ create-mcp-server/ │ │ ├── tsconfig.json.ts # tsconfig.json template │ │ ├── gitignore.ts # .gitignore template │ │ ├── env.example.ts # .env.example template +│ │ ├── agents.md.ts # AGENTS.md template (per-variant guidance) +│ │ ├── agents.md.test.ts # Tests for the AGENTS.md template │ │ └── templates.test.ts # Tests for common templates │ ├── deployment/ # Deployment configuration templates │ │ ├── dockerfile.ts # Dockerfile template @@ -55,6 +57,7 @@ create-mcp-server/ │ ├── index.ts # Barrel export + getIndexTemplate │ ├── readme.ts # README.md template │ └── templates.test.ts +├── vitest.config.ts # Scopes the test run; excludes generated/ ├── dist/ # Compiled output (generated) ├── docs/ │ └── oauth-setup.md # OAuth setup guide for various providers @@ -132,6 +135,34 @@ Notes for editing these: - **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. +## Generated AGENTS.md + +`src/templates/common/agents.md.ts` writes an `AGENTS.md` into every generated +project. It is **not** a second README: the README says how to run the project, +this says what someone changing it will otherwise get wrong. + +**It must stay example-agnostic.** The shipped notes example is meant to be +deleted once real tools exist, but `AGENTS.md` stays — so anything naming +`notes-store`, `list_notes` and friends rots the moment the example goes. The +guidance uses a neutral illustration (`list_invoices`) instead, and a test +asserts no example-specific name reaches the output. + +It branches per variant, and the branching is the point: + +- **HTTP** gets the per-request factory warning (state belongs at module scope). +- **stdio** gets the stdout rule instead, since the factory warning does not apply. +- **OAuth** adds the `OAuthError`-not-`Error` rule. +- **FastMCP** gets a trimmed version that says plainly it ships without tests, + rather than leaving a silent gap. + +SDK variants also carry the tool-design guidance and a section on organizing +tools as they grow — flat files, then `src/tools/` per tool, then feature +folders. The scaffold ships the flat layout because it suits a handful of tools; +the advice on outgrowing it belongs here rather than in a directory structure +the example has not earned. + +Keep this file honest: if the templates change what they emit, this changes too. + ## Publishing ```bash @@ -280,6 +311,7 @@ Generated project structure for HTTP templates (+auth.ts when OAuth enabled for ├── tsconfig.json ├── .gitignore ├── .env.example +├── AGENTS.md └── README.md ``` @@ -299,6 +331,7 @@ Generated project structure for stdio templates (no Dockerfile): ├── tsconfig.json ├── .gitignore ├── .env.example +├── AGENTS.md └── README.md ``` diff --git a/README.md b/README.md index bc47c8e..6a19c54 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ my-mcp-server/ ├── tsconfig.json ├── .gitignore ├── .env.example +├── AGENTS.md # Working notes for humans and coding agents └── README.md ``` @@ -155,6 +156,11 @@ SDK projects come with a worked example — a small notes server — and a test 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. +Every project also gets an `AGENTS.md` covering the things that are easy to get +wrong: how to write a tool an agent can use, how to organize tools as they grow, +and the transport-specific traps (state is per-request over HTTP; stdout belongs +to the protocol over stdio). + **Scripts:** - `npm run dev` — build and start the server - `npm test` — run the test suite (SDK projects) diff --git a/src/project-generator.ts b/src/project-generator.ts index ffa00de..1b83a9e 100644 --- a/src/project-generator.ts +++ b/src/project-generator.ts @@ -5,6 +5,7 @@ import { getPackageJsonTemplate } from './templates/common/package.json.js'; import { getTsconfigTemplate } from './templates/common/tsconfig.json.js'; import { getGitignoreTemplate } from './templates/common/gitignore.js'; import { getEnvExampleTemplate } from './templates/common/env.example.js'; +import { getAgentsMdTemplate } from './templates/common/agents.md.js'; import type { CommonTemplateOptions, Framework, @@ -161,7 +162,8 @@ export async function generateProject(config: ProjectConfig): Promise { getTsconfigTemplate({ withTests: framework === 'sdk' }) ), writeFile(join(projectPath, '.gitignore'), getGitignoreTemplate()), - writeFile(join(projectPath, '.env.example'), getEnvExampleTemplate(templateOptions)) + writeFile(join(projectPath, '.env.example'), getEnvExampleTemplate(templateOptions)), + writeFile(join(projectPath, 'AGENTS.md'), getAgentsMdTemplate(projectName, templateOptions)) ); // Deployment files for HTTP transport only (stdio servers are not HTTP services) diff --git a/src/templates/common/agents.md.test.ts b/src/templates/common/agents.md.test.ts new file mode 100644 index 0000000..7cf198b --- /dev/null +++ b/src/templates/common/agents.md.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest'; +import { getAgentsMdTemplate } from './agents.md.js'; + +describe('getAgentsMdTemplate', () => { + const projectName = 'test-project'; + const sdkHttp = { framework: 'sdk', transport: 'http' } as const; + const sdkStdio = { framework: 'sdk', transport: 'stdio' } as const; + const fastmcp = { framework: 'fastmcp', transport: 'http' } as const; + + it('should title the document with the project name', () => { + expect(getAgentsMdTemplate(projectName, sdkHttp)).toContain(`# ${projectName}`); + }); + + // The shipped example is meant to be deleted once real tools exist, but + // AGENTS.md stays. Anything naming the example rots the moment it goes. + it('should not reference the shipped example', () => { + for (const options of [sdkHttp, sdkStdio, fastmcp]) { + const template = getAgentsMdTemplate(projectName, options); + expect(template).not.toContain('notes-store'); + expect(template).not.toContain('list_notes'); + expect(template).not.toContain('get_note'); + expect(template).not.toContain('create_note'); + expect(template).not.toContain('summarize-notes'); + } + }); + + it('should say the example is meant to be replaced', () => { + for (const options of [sdkHttp, fastmcp]) { + expect(getAgentsMdTemplate(projectName, options)).toContain('meant to be replaced'); + } + }); + + // The README covers running the project; this file covers changing it. + it('should point at the README rather than restate it', () => { + const template = getAgentsMdTemplate(projectName, sdkHttp); + expect(template).toContain('[README](README.md)'); + expect(template).not.toContain('docker build'); + expect(template).not.toContain('OAUTH_ISSUER_URL'); + }); + + describe('tool guidance', () => { + it('should carry the naming rule and its reason', () => { + const template = getAgentsMdTemplate(projectName, sdkHttp); + expect(template).toContain('Name it for the action, with its noun'); + // The generated name is interpolated so the warning is concrete. + expect(template).toContain(`${projectName}_invoices_list`); + }); + + it('should cover the contracts a description makes', () => { + const template = getAgentsMdTemplate(projectName, sdkHttp); + expect(template).toContain('say so and say how to continue'); + expect(template).toContain('isError'); + expect(template).toContain('Document every parameter'); + }); + + it('should say how to organize tools as they grow', () => { + const template = getAgentsMdTemplate(projectName, sdkHttp); + expect(template).toContain('Organizing tools as they grow'); + expect(template).toContain('src/tools/'); + expect(template).toContain('composition root'); + }); + }); + + describe('per-transport guidance', () => { + it('should warn HTTP projects about per-request server state', () => { + const template = getAgentsMdTemplate(projectName, sdkHttp); + expect(template).toContain('once per request'); + expect(template).toContain('module scope'); + }); + + it('should give stdio projects the stdout rule instead', () => { + const template = getAgentsMdTemplate(projectName, sdkStdio); + expect(template).toContain('stdout belongs to the protocol'); + expect(template).toContain('console.error'); + expect(template).not.toContain('once per request'); + }); + }); + + describe('OAuth', () => { + it('should explain the OAuthError requirement when OAuth is enabled', () => { + const template = getAgentsMdTemplate(projectName, { ...sdkHttp, withOAuth: true }); + expect(template).toContain('OAuthError'); + expect(template).toContain('WWW-Authenticate'); + expect(template).toContain('src/auth.ts'); + }); + + it('should omit the OAuth section when OAuth is disabled', () => { + const template = getAgentsMdTemplate(projectName, sdkHttp); + expect(template).not.toContain('OAuthError'); + expect(template).not.toContain('src/auth.ts'); + }); + }); + + describe('testing guidance', () => { + it('should frame the suite as an example rather than a quota', () => { + const template = getAgentsMdTemplate(projectName, sdkHttp); + expect(template).toContain('not a quota to match'); + expect(template).toContain('src/server.test.ts'); + }); + + it('should not promise tests FastMCP projects do not ship', () => { + const template = getAgentsMdTemplate(projectName, fastmcp); + expect(template).toContain('ships without a test setup'); + expect(template).not.toContain('src/server.test.ts'); + expect(template).not.toContain('npm test'); + }); + }); + + describe('layout', () => { + it('should describe the split primitives for SDK projects', () => { + const template = getAgentsMdTemplate(projectName, sdkHttp); + expect(template).toContain('src/tools.ts'); + expect(template).toContain('MCP imports'); + expect(template).toContain('Keep that seam'); + }); + + it('should describe the single-file layout for FastMCP projects', () => { + const template = getAgentsMdTemplate(projectName, fastmcp); + expect(template).toContain('the FastMCP server and its tools'); + expect(template).not.toContain('src/tools.ts'); + }); + }); + + describe('commands', () => { + it('should use the selected package manager', () => { + const template = getAgentsMdTemplate(projectName, { ...sdkHttp, packageManager: 'yarn' }); + expect(template).toContain('yarn dev'); + expect(template).toContain('yarn build'); + expect(template).not.toContain('npm run'); + }); + + it('should omit the test command for FastMCP projects', () => { + const template = getAgentsMdTemplate(projectName, fastmcp); + expect(template).toContain('npm run build'); + expect(template).not.toContain('npm test'); + }); + }); +}); diff --git a/src/templates/common/agents.md.ts b/src/templates/common/agents.md.ts new file mode 100644 index 0000000..ffc6672 --- /dev/null +++ b/src/templates/common/agents.md.ts @@ -0,0 +1,215 @@ +import type { CommonTemplateOptions } from './types.js'; + +/** + * AGENTS.md for the generated project. + * + * Deliberately not a second README. The README explains how to run the server; + * this explains the things that are not obvious from reading the code, and that + * someone changing it will otherwise get wrong. + */ +export function getAgentsMdTemplate(projectName: string, options?: CommonTemplateOptions): string { + const framework = options?.framework ?? 'sdk'; + const transport = options?.transport ?? 'http'; + const withOAuth = options?.withOAuth ?? false; + const packageManager = options?.packageManager ?? 'npm'; + + const isSdk = framework === 'sdk'; + const isStdio = transport === 'stdio'; + + const run = { + npm: { build: 'npm run build', test: 'npm test', dev: 'npm run dev' }, + pnpm: { build: 'pnpm build', test: 'pnpm test', dev: 'pnpm dev' }, + yarn: { build: 'yarn build', test: 'yarn test', dev: 'yarn dev' }, + }[packageManager]; + + const layout = isSdk + ? `- \`src/server.ts\` — creates the \`McpServer\` and registers the primitives. It + should stay a composition root; definitions do not belong here. +- \`src/tools.ts\`, \`src/prompts.ts\`, \`src/resources.ts\` — one file per MCP + primitive, each exporting a \`register*(server)\` function. +- Your data layer — the shipped example keeps this in its own module with **no + MCP imports**, so it can be tested without a server. Keep that seam. +- \`src/index.ts\` — ${isStdio ? 'stdio transport startup' : 'Express app and HTTP handler wiring'}.${ + withOAuth ? '\n- `src/auth.ts` — OAuth middleware and token verification.' : '' + } + +The project is generated with a small example so nothing starts empty. It is +meant to be replaced — delete it once your own tools exist. The guidance below +applies to whatever replaces it.` + : `- \`src/server.ts\` — the FastMCP server and its tools, prompts and resources. +- \`src/index.ts\` — transport startup. + +The project is generated with a small example so nothing starts empty. It is +meant to be replaced — delete it once your own tools exist.`; + + const perRequestSection = + isSdk && !isStdio + ? ` +## State lives in the module, not the server + +\`createMcpHandler\` runs the server factory **once per request**. A fresh +\`McpServer\` serves every call, so anything you store on the server instance is +discarded when the request ends. + +This is the most common way to break this project. If you add state — a cache, a +connection pool, a counter — put it at module scope, in its own module. State +created inside \`getServer()\` will silently vanish between calls: a tool that +writes will appear to work, and a later read will return nothing. +` + : ''; + + const stdioSection = isStdio + ? ` +## stdout belongs to the protocol + +This server speaks MCP over stdio, so **stdout carries JSON-RPC and nothing +else**. A stray \`console.log\`, a banner from a library, or a debug print will +corrupt the stream and the client will fail to parse it. + +Log to \`console.error\` instead. The same rule is why this project does not load +\`dotenv\` — its startup summary is noise on a channel that has to stay clean. +` + : ''; + + const oauthSection = withOAuth + ? ` +## OAuth: throw OAuthError, never a plain Error + +\`requireBearerAuth\` maps only \`OAuthError\` to a \`401\` with a +\`WWW-Authenticate\` challenge. Any other thrown error becomes a \`500\` with no +challenge header, which leaves the client no signal that it should +re-authenticate — it just sees a broken server. + +So every token rejection in \`src/auth.ts\` — bad signature, expired, wrong +issuer or audience — is converted into \`OAuthError\`. The one deliberate +exception is the missing-JWKS guard, which stays a plain \`Error\`: that is a +server misconfiguration and belongs in the 500 bucket. + +\`OAuthError\` and \`OAuthErrorCode\` are **value** exports of +\`@modelcontextprotocol/server\`, so import them alongside the \`import type\` +line, not inside it. +` + : ''; + + const testingSection = isSdk + ? ` +## Adding a tool means adding a test + +\`${run.test}\` runs two layers: + +- Data-layer tests — your logic on its own, no server and no transport. +- \`src/server.test.ts\` — a real MCP client over an in-memory transport, + asserting on the **payload a tool actually returns**. + +A tool description is a contract with a caller that cannot read your code. If +nothing checks that contract, the implementation drifts and the agent keeps +believing the description. That is the failure these tests exist to prevent. + +**One test per tool, in that spirit, is enough.** The existing tests are a worked +example, not a quota to match: each pins something a description promises and +nothing else verifies — that a truncated list says so, that an error names a next +step, that an unknown filter is distinguishable from an empty result. Do not add +tests that exercise your data structures; those teach nothing about the tool. + +Assert on the parsed payload rather than on internal calls. It is the surface the +agent sees, and it survives refactoring underneath. +` + : ` +## Testing + +This project ships without a test setup. If you add one, assert on the **payload +a tool returns** rather than on internal function calls — that is the surface an +agent reads, and a tool can be internally correct while returning something +misleading. +`; + + const toolDesignSection = isSdk + ? ` +## Writing a good tool + +A tool is a contract with a non-deterministic caller. It cannot ask what you +meant, so the description and the payload have to carry everything. + +When adding one: + +- **Name it for the action, with its noun** — \`list_invoices\`, not a bare + \`list\` and not \`invoices_list\`. Most MCP clients prepend the server name, so a + hardcoded namespace becomes \`${projectName}_invoices_list\`; a bare verb collides + with every other server's \`list\` on the clients that do not prefix. +- **Describe what it does, when to reach for it, and what it returns.** Include + example queries — this text is prompt engineering, not documentation. +- **Document every parameter with a format, an example and its constraints.** + \`'Invoice id as returned by list_invoices. Example: "inv_4821"'\` beats + \`id: string\`. +- **Bound anything that returns a collection.** Give it a default limit, and when + you truncate, *say so and say how to continue*. A silently capped list is a + tool that lies: the caller cannot tell a complete answer from a partial one. +- **Return errors as \`isError\` with a next move**, not thrown exceptions and not + bare codes. "No invoice has id X — call list_invoices to see the ids" is + actionable; "not found" is not. +- **Do not let two different situations produce the same payload.** An unknown + filter and a genuinely empty result must read differently, or the agent will + confidently report "you have none" when the truth is "you misspelled it". +- **Return what the next call needs.** If a create returns the new id, the agent + can chain straight into a read without a lookup. + +### Organizing tools as they grow + +The flat layout here suits a handful of tools. It is not meant to be permanent: + +- **A few tools** — keep them in \`src/tools.ts\`, as now. +- **Enough that the file is hard to scan** — split into \`src/tools/\`, one file + per tool, with an \`index.ts\` that re-exports a single \`registerTools\`. Do the + same for prompts and resources when they earn it. +- **Several unrelated domains** — group by feature instead of by primitive: + \`src/invoices/{tools,resources,store}.ts\`, \`src/search/...\`, each exposing its + own register function that \`server.ts\` calls. + +Whichever shape you land on, keep two things: \`server.ts\` stays a composition +root, and domain logic stays free of MCP imports so it remains testable on its +own. +` + : ''; + + const conventions = isSdk + ? ` +## Conventions worth keeping + +- This project uses the **MCP TypeScript SDK v2** split packages + (\`@modelcontextprotocol/server\`, \`/express\`, \`/node\`). Do not add the v1 + \`@modelcontextprotocol/sdk\` monolith — it is a different package line. +- Schemas are **Standard Schema** objects: \`inputSchema: z.object({ ... })\`, not + a raw shape. This needs zod ≥ 4.2; zod 3 fails silently on \`tools/list\`. +- A tool handler's second argument is \`ctx\` (\`ctx.mcpReq.signal\`, + \`ctx.http?.authInfo\`), not the v1 \`extra\`. +- Logging, sampling and roots are deprecated in v2 — avoid \`sendLoggingMessage\`. +` + : ''; + + const commands: Array<[string, string]> = [ + [run.dev, 'build and run'], + ...(isSdk ? ([[run.test, 'run the tests']] as Array<[string, string]>) : []), + [run.build, 'type-check and compile'], + ]; + const commandWidth = Math.max(...commands.map(([cmd]) => cmd.length)); + const commandLines = commands + .map(([cmd, note]) => `${cmd.padEnd(commandWidth)} # ${note}`) + .join('\n'); + + return `# ${projectName} + +Notes for anyone — human or coding agent — working on this project. The +[README](README.md) covers running it; this covers what is not obvious from the +code. + +## Layout + +${layout} +${perRequestSection}${stdioSection}${toolDesignSection}${testingSection}${oauthSection}${conventions} +## Commands + +\`\`\`bash +${commandLines} +\`\`\` +`; +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..89871bc --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // Tests run against both the TypeScript sources and the compiled output. + include: ['src/**/*.test.ts', 'dist/**/*.test.js'], + // `generated/` holds scratch projects produced while testing the CLI by + // hand. They are gitignored, but they now ship their own test suites, so + // without this the repo's own run picks them up. + exclude: ['**/node_modules/**', 'generated/**'], + }, +});