Skip to content

feat: replace the greet example with a testable notes domain - #31

Merged
IBJunior merged 1 commit into
mainfrom
feat/notes-example
Sep 19, 2026
Merged

IBJunior merged 1 commit into
mainfrom
feat/notes-example

Conversation

@IBJunior

@IBJunior IBJunior commented Sep 19, 2026

Copy link
Copy Markdown
Member

Replaces the greet example in the SDK templates with a small notes domain built to be worth testing, and splits the generated server.ts into one file per MCP primitive.

Third of the sequence. This is the groundwork for the next PR, which adds the generated test suite — wiring vitest against greet and then deleting greet would have doubled the review.

Why

The old example returned a string from a tool called greet. There was nothing in it to assert, which is a poor thing for an MCP scaffolder to teach: a tool description is a contract with a non-deterministic caller, so an unverified contract is the default failure mode.

The example

list_notes(tag?, limit=20)        -> page + matched/returned/truncated + hint
get_note(id)                      -> the note, or an actionable isError
create_note(title, body, tags?)   -> the note including its new id
notes://{id}                      -> ResourceTemplate, list implemented
summarize-notes                   -> prompt over a tag

Four properties greet could not demonstrate, each one an assertion the next PR can make:

  • Truncation is signalled, not silently applied. The store returns the page and the unfiltered total, so the tool can say "showing 5 of 31" and suggest how to narrow. A store that capped internally could not report this honestly — which is exactly the bug that makes a tool lie to an agent.
  • An unknown tag is distinguished from a tag with no notes. Both produce an empty list. Conflating them makes an agent report "you have no notes" when the truth is "that tag does not exist".
  • Failures return isError with a next move, not thrown exceptions.
  • create_note returns the id, so the agent chains straight to get_note with no intervening lookup.

Naming: no server namespace

Tool names carry their noun but no prefix. Most MCP clients prepend the server name, so notes_list would surface as my-server_notes_list; a bare verb like list would collide with every other server's list on the clients that don't prefix. list_notes reads correctly either way. Confirmed against the SDK that the protocol carries the bare name — prefixing is the client's job.

Structure

server.ts is now just the composition root:

src/
  server.ts        # new McpServer + registerTools/Prompts/Resources
  tools.ts         # registerTools(server)
  prompts.ts       # registerPrompts(server)
  resources.ts     # registerResources(server)
  notes-store.ts   # domain logic, no MCP imports
  index.ts         # transport wiring

One file per primitive keeps each small enough to read at a glance and gives the next change an obvious home. The single-file layout only held while the example was a one-line greet.

notes-store.ts stays free of MCP imports, for two reasons beyond testability:

  1. server.ts is re-exported by stateful/ and stdio/, so anything added there grows in three places.
  2. createMcpHandler runs the factory once per request. State held on the server instance is discarded between calls, so a created note would never survive to a later list. Keeping notes at module scope is what makes the example work over HTTP at all — verified below.

ResourceTemplate is constructed with an explicit list. The v2 type declares that key as required rather than optional, so omitting it fails tsc with TS2741 in the generated project. Implementing it also makes resources/list enumerate the notes.

Verification

All three SDK variants generated, installed and compiled on TS 7 (tsc exit 0), each emitting server.ts, tools.ts, prompts.ts, resources.ts and notes-store.ts.

Behaviour driven through a real client over InMemoryTransport:

TOOLS:            list_notes, get_note, create_note
CREATED:          note_00000001
GET roundtrip:    true
TRUNCATION:       {"returned":5,"matched":31,"truncated":true}
HINT:             Showing 5 of 31 matches. Narrow the result with a tag filter,
                  or raise limit (maximum 100).
UNKNOWN TAG:      isError | No tag "nope" exists... Available tags: bulk, release.
BAD ID:           isError | No note has id "note_zzzzzzzz". Ids look like...
PROMPTS:          summarize-notes
RESOURCES:        31 listed, read ok

The per-request state invariant, over real HTTP: a note created in one request is visible in a second, separate request ("matched": 1, with the note returned). That is the check that would have caught the factory bug.

stdio: stdout carries 2 lines, both valid JSON-RPC, 0 non-JSON; MCP Server running on stdio on stderr.

  • npm test — 410 tests across 16 files (up from 380)
  • npm run lint clean; new files pass prettier --check

Test changes

Three files asserted on greet/greeting-resource through the re-export shim and were updated. Rather than restate string presence, the stateless assertions now pin design guarantees — truncation is signalled, unknown tags are distinguishable, errors are isError, no server namespace is hardcoded into tool names, and ResourceTemplate gets its list. Those assertions moved to new getToolsTemplate / getPromptsTemplate / getResourcesTemplate blocks, with server.ts now asserted to compose rather than register. The stateful/ and stdio/ tests only check the composition arrives via re-export.

FastMCP keeps its existing example for now; it has no test setup yet either. Worth a tracked follow-up so the gap does not go quiet.

The SDK templates shipped a greet tool that returned a string. Nothing about
it was worth testing, which is a poor thing for an MCP scaffolder to teach -
a tool description is a contract with a non-deterministic caller, and an
unverified contract is the default failure.

Replaces it with a small notes server designed against the tool-design
principles, chosen so the contract has something to assert:

  list_notes(tag?, limit=20)  -> page + matched/returned/truncated + hint
  get_note(id)                -> the note, or an actionable isError
  create_note(title, body, tags?) -> the note including its new id
  notes://{id}                -> ResourceTemplate with list implemented
  summarize-notes             -> prompt over a tag

Four properties the old example could not demonstrate:

- Truncation is signalled, not silently applied. The store returns the page
  and the unfiltered total, so the tool can say "showing 5 of 31" and suggest
  how to narrow. A store that capped internally could not report this.
- An unknown tag is distinguished from a tag with no notes. Both produce an
  empty list; conflating them makes an agent report "you have no notes" when
  the truth is "that tag does not exist".
- Failures come back as isError with a next move, not as thrown exceptions.
- create_note returns the id, so the agent can chain to get_note with no
  intervening lookup.

Tool names carry their noun but no server namespace. Most MCP clients prepend
the server name, so a "notes_" prefix would surface as "my-server_notes_list";
a bare verb would collide with every other server's "list" on the clients that
do not prefix. "list_notes" reads correctly either way.

Splits the primitives into tools.ts, prompts.ts and resources.ts, each
exporting a register function that server.ts calls. server.ts is now just the
composition root. One file per primitive keeps each small enough to read at a
glance and gives the next change an obvious home; the single-file layout only
held while the example was a one-line greet.

Domain logic goes in a new notes-store.ts, free of MCP imports. Two reasons
beyond testability: server.ts is re-exported by stateful/ and stdio/, so
anything added there grows in three places; and createMcpHandler runs the
factory once per request, so state held on the server instance is discarded
between calls and a created note would never survive to a later list. Keeping
the notes at module scope is what makes the example work over HTTP at all.

ResourceTemplate is constructed with an explicit list: the v2 type declares
that key as required rather than optional, so omitting it fails tsc with
TS2741 in the generated project.

FastMCP keeps its existing example for now.
@IBJunior
IBJunior merged commit 2c7fb61 into main Sep 19, 2026
2 checks passed
@IBJunior
IBJunior deleted the feat/notes-example branch September 19, 2026 15:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant