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
33 changes: 33 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand All @@ -299,6 +331,7 @@ Generated project structure for stdio templates (no Dockerfile):
├── tsconfig.json
├── .gitignore
├── .env.example
├── AGENTS.md
└── README.md
```

Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,19 @@ my-mcp-server/
├── tsconfig.json
├── .gitignore
├── .env.example
├── AGENTS.md # Working notes for humans and coding agents
└── 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.

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)
Expand Down
4 changes: 3 additions & 1 deletion src/project-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -161,7 +162,8 @@ export async function generateProject(config: ProjectConfig): Promise<void> {
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)
Expand Down
138 changes: 138 additions & 0 deletions src/templates/common/agents.md.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading
Loading