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
61 changes: 61 additions & 0 deletions .github/workflows/examples.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Examples

# Runs each example end to end. Template changes trigger it too, so a drifting
# example shows up on the PR that caused the drift.
on:
pull_request:
branches: [main]
paths: ['examples/**', 'src/templates/**', '.github/workflows/examples.yml']
push:
branches: [main]
paths: ['examples/**', 'src/templates/**', '.github/workflows/examples.yml']

jobs:
notes-oauth-keycloak:
runs-on: ubuntu-latest
defaults:
run:
working-directory: examples/notes-oauth-keycloak

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: 'npm'
cache-dependency-path: examples/notes-oauth-keycloak/package-lock.json

- name: Start Keycloak
run: docker compose up -d --wait

- name: Install dependencies
run: npm ci

- name: Unit tests
run: npm test

- name: Build
run: npm run build

- name: Start the server
run: |
cp .env.example .env
nohup npm start > server.log 2>&1 &
for i in $(seq 1 30); do
curl -sf http://localhost:3000/health && exit 0
sleep 1
done
cat server.log
exit 1

- name: Smoke test
run: npm run smoke

- name: Logs on failure
if: failure()
run: |
cat server.log || true
docker compose logs keycloak
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ create-mcp-server/
├── dist/ # Compiled output (generated)
├── docs/
│ └── oauth-setup.md # OAuth setup guide for various providers
├── examples/ # Runnable, end-to-end servers built with the CLI
│ └── notes-oauth-keycloak/ # --oauth + Keycloak via docker compose, per-user notes
├── official-examples/ # Reference MCP server examples
├── package.json
├── tsconfig.json
Expand Down Expand Up @@ -209,6 +211,15 @@ writes nothing — so check the files landed rather than trusting the exit code.
`.agents/` is deliberately **not** in the generated `.gitignore`: the skill is
meant to be committed so the whole team gets the same copy.

## Examples

`examples/` holds complete servers built with the CLI, one folder per example, indexed in `examples/README.md`. They are not part of the published package (`files` is `dist` only) and the root `lint`, `format`, `tsc` and `test` scripts do not touch them.

- **Start from unmodified CLI output.** Scaffold with a pinned CLI version, commit that output as-is, then add the example's changes in later commits. Mark each change in code with an `// example:` comment and list it in the example's README, so readers can see what the CLI gives them and what they add themselves.
- **Each example runs on its own.** Its own `package.json`, lockfile, tests and README. Anything external it needs (Keycloak, a database) comes from a `docker-compose.yml` in the example, set up from files, never from manual console steps.
- **CI runs them end to end.** `.github/workflows/examples.yml` runs on changes to `examples/**` and `src/templates/**`, so a template change that breaks an example fails on the same PR. When that happens, update the example to match the template (or re-scaffold) rather than letting it pin old behavior.
- **notes-oauth-keycloak:** the realm file must be named `<realm>-realm.json` or Keycloak refuses the import. The compose network has a fixed subnet because host requests reach Keycloak from the bridge gateway, which the realm's Trusted Hosts registration policy must list.

## Publishing

Releases are published by CI, not from a laptop. The flow:
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ to the protocol over stdio).
- `npm test` — run the test suite
- `npm run inspect` — open MCP Inspector (update URL in `package.json` if needed)

## Examples

Complete servers built with the CLI, each runnable on its own:

- [notes-oauth-keycloak](examples/notes-oauth-keycloak/): `--oauth` end to end, with a local Keycloak (realm already set up) via Docker Compose and notes kept per user.

## Learning Resources

| Guide | Description |
Expand Down
9 changes: 9 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Examples

Complete MCP servers built with `create-mcp-server`. Each one starts as the unmodified output of the CLI, so its git history shows exactly what was added on top.

| Example | What it shows | CLI version |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------- |
| [notes-oauth-keycloak](./notes-oauth-keycloak/) | `--oauth` end to end: a local Keycloak with the realm already set up, and notes kept per user | 0.11.0 |

Every example is self-contained: `cd` into it and follow its README. CI runs each example's tests and smoke check (see `.github/workflows/examples.yml`).
34 changes: 34 additions & 0 deletions examples/notes-oauth-keycloak/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Dependencies
node_modules/

# Build output (rebuilt in container)
dist/

# Git
.git/
.gitignore

# Environment files (should be set in container)
.env
.env.*

# Documentation
*.md

# IDE
.vscode/
.idea/

# OS files
.DS_Store
Thumbs.db

# Logs
*.log
npm-debug.log*

# Test files
*.test.ts
*.spec.ts
__tests__/
coverage/
13 changes: 13 additions & 0 deletions examples/notes-oauth-keycloak/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
PORT=3000

# Comma-separated list of additional allowed hosts (for deployment behind reverse proxies)
# ALLOWED_HOSTS=my-server.example.com,my-server.us-central1.run.app

# OAuth Configuration
# example: preset for the Keycloak in docker-compose.yml. If you change
# KEYCLOAK_PORT, change the port here too.
OAUTH_ISSUER_URL=http://localhost:8080/realms/mcp-realm

# Keycloak access tokens carry no audience for this server by default, so leave
# this empty. Set it once you add an audience mapper in Keycloak.
OAUTH_AUDIENCE=
5 changes: 5 additions & 0 deletions examples/notes-oauth-keycloak/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
dist/
*.log
.DS_Store
.env
151 changes: 151 additions & 0 deletions examples/notes-oauth-keycloak/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# notes-oauth-keycloak

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

- `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` — Express app and HTTP handler wiring.
- `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.

## 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.

## 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 `notes-oauth-keycloak_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.

## Adding a tool means adding a test

`npm 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.

## The tool-design skill

`.agents/skills/tool-design/SKILL.md` is the long form of the guidance above,
with worked examples under `references/`. Read it before designing a tool
surface, not after.

It was copied in when this project was scaffolded, so it is the version that
shipped with the CLI you used — no network needed, and it will not change
underneath you. The skill does get updated upstream. To pull a newer copy when
you want one:

```bash
npx skills update -p -y
```

That is yours to run, not something this project does for you. If the directory
is missing entirely, add it with `npx skills add agentailor/skills --skill tool-design -a universal`.

Commit `.agents/` so everyone working on this project gets the same copy.

## 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.

## 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`.

## Commands

```bash
npm run dev # build and run
npm test # run the tests
npm run build # type-check and compile
```
36 changes: 36 additions & 0 deletions examples/notes-oauth-keycloak/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Multi-stage build for production
FROM node:22-alpine AS builder

WORKDIR /app

# Copy package files
COPY package.json package-lock.json ./

# Install all dependencies (including dev)
RUN npm ci

# Copy source code
COPY . .

# Build the application
RUN npm run build

# Production stage
FROM node:22-alpine AS production

WORKDIR /app

# Copy package files
COPY package.json package-lock.json ./

# Install production dependencies only
RUN npm ci --omit=dev

# Copy built application from builder stage
COPY --from=builder /app/dist ./dist

# Expose the port the app runs on
EXPOSE 3000

# Start the application
CMD ["node", "dist/index.js"]
Loading
Loading