From 28a64daab513aaf4f1ae09f5695827f3e7ef185f Mon Sep 17 00:00:00 2001 From: Jeremy Skirrow Date: Tue, 23 Jun 2026 15:42:54 -0500 Subject: [PATCH 1/9] Add fetch MCP server at project scope Wires in @modelcontextprotocol/server-fetch so Claude Code can pull Express, Node.js test runner, and ESLint docs directly during sessions on this repo. Co-Authored-By: Claude Sonnet 4.6 --- .mcp.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..6591015 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "fetch": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-fetch"] + } + } +} From d6dd165747a63a5c20a5ade36065a131691bc045 Mon Sep 17 00:00:00 2001 From: Jeremy Skirrow Date: Tue, 23 Jun 2026 15:44:42 -0500 Subject: [PATCH 2/9] Scope fetch MCP server to its read-only fetch tool Allows mcp__fetch__fetch explicitly rather than blanket-allowing the entire server, so only HTTP GET/fetch operations are permitted. Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..358d9e9 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "mcp__fetch__fetch" + ] + } +} From 43ffe4bc5f29e5bf7e3586c58e32d83e8b927866 Mon Sep 17 00:00:00 2001 From: Jeremy Skirrow Date: Tue, 23 Jun 2026 15:47:58 -0500 Subject: [PATCH 3/9] Add project skill for adding a new REST resource Encodes the route file structure, store function pattern, error-response shape ({ error: "message" }, 400/404/201), server.js mount convention, and test structure (node:test + supertest + store.reset per test). Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/add-resource.md | 151 +++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 .claude/skills/add-resource.md diff --git a/.claude/skills/add-resource.md b/.claude/skills/add-resource.md new file mode 100644 index 0000000..f011045 --- /dev/null +++ b/.claude/skills/add-resource.md @@ -0,0 +1,151 @@ +--- +description: "Adding a new REST resource to this Express API: creating a route file, store functions, tests, and mounting the router. Use when the request is to add a new endpoint group or resource (e.g. 'add a products route', 'create a /orders resource')." +--- + +Follow these patterns exactly when adding a new resource to this API. + +## 1. Store functions — `db/store.js` + +Add four functions for the new resource. IDs are auto-incremented integers from a module-level `nextId` variable. `reset()` must re-seed the new resource alongside users. + +```js +let items = []; +let nextItemId = 1; + +function listItems() { + return items; +} + +function getItem(id) { + return items.find((item) => item.id === id); +} + +function createItem({ /* required fields */ }) { + const item = { id: nextItemId, /* fields */ }; + nextItemId += 1; + items.push(item); + return item; +} + +function updateItem(id, fields) { + const item = getItem(id); + if (!item) return undefined; + if (fields.fieldA !== undefined) item.fieldA = fields.fieldA; + return item; +} +``` + +Export all four alongside the existing exports. Update `seed()` and `reset()` to include the new resource. + +## 2. Route file — `routes/.js` + +```js +const express = require('express'); +const store = require('../db/store'); + +const router = express.Router(); + +// GET / — list all. +router.get('/', (req, res) => { + res.json(store.listItems()); +}); + +// GET //:id — fetch one, or 404. +router.get('/:id', (req, res) => { + const item = store.getItem(Number(req.params.id)); + if (!item) { + return res.status(404).json({ error: 'Item not found' }); + } + return res.json(item); +}); + +// POST / — create. Requires . +router.post('/', (req, res) => { + const { fieldA, fieldB } = req.body; + if (!fieldA || !fieldB) { + return res.status(400).json({ error: 'fieldA and fieldB are required' }); + } + const item = store.createItem({ fieldA, fieldB }); + return res.status(201).json(item); +}); + +// PUT //:id — update existing. +router.put('/:id', (req, res) => { + const { fieldA, fieldB } = req.body; + if (fieldA === undefined && fieldB === undefined) { + return res.status(400).json({ error: 'fieldA or fieldB is required' }); + } + const item = store.updateItem(Number(req.params.id), { fieldA, fieldB }); + if (!item) { + return res.status(404).json({ error: 'Item not found' }); + } + return res.json(item); +}); + +module.exports = router; +``` + +Key rules: +- Always `return` early on error responses so the success path doesn't run. +- Error shape is always `{ "error": "message" }` — never any other key. +- `POST` returns 201; all other success responses return 200 (default). +- Parse `:id` with `Number(req.params.id)` before passing to the store. + +## 3. Mount in `server.js` + +```js +const itemsRouter = require('./routes/'); +// ... +app.use('/', itemsRouter); +``` + +Add the require alongside the existing requires, and the `app.use` alongside the existing mounts. + +## 4. Test file — `tests/.test.js` + +```js +const test = require('node:test'); +const assert = require('node:assert'); +const request = require('supertest'); +const app = require('../server'); +const store = require('../db/store'); + +test.beforeEach(() => store.reset()); + +test('GET / returns the seeded list', async () => { + const res = await request(app).get('/'); + assert.equal(res.status, 200); + assert.ok(Array.isArray(res.body)); +}); + +test('GET //:id returns 404 for a missing item', async () => { + const res = await request(app).get('//999'); + assert.equal(res.status, 404); +}); + +test('POST / creates an item', async () => { + const res = await request(app) + .post('/') + .send({ fieldA: 'value', fieldB: 'value' }); + assert.equal(res.status, 201); + assert.equal(res.body.fieldA, 'value'); + assert.ok(res.body.id); +}); + +test('PUT //:id updates an existing item', async () => { + const res = await request(app).put('//1').send({ fieldA: 'new' }); + assert.equal(res.status, 200); + assert.equal(res.body.fieldA, 'new'); +}); + +test('PUT //:id returns 404 for a missing item', async () => { + const res = await request(app).put('//999').send({ fieldA: 'x' }); + assert.equal(res.status, 404); +}); +``` + +Key rules: +- `test.beforeEach(() => store.reset())` is always first — ensures each test starts from seed data. +- Use Node's built-in `node:test` and `node:assert` — no third-party test framework. +- Use `supertest` against the exported `app` — never start the server separately. +- One `assert` per observable outcome; don't assert implementation details. From 367294dbc0f7eaaf6336ea4466c1aea3bdfb3cfe Mon Sep 17 00:00:00 2001 From: Jeremy Skirrow Date: Tue, 23 Jun 2026 15:51:45 -0500 Subject: [PATCH 4/9] Fix skill description so model resolves correct invocation name Opening phrase 'Add a resource' maps to 'add-resource'; previous 'Adding a new REST resource' caused the model to guess 'add-new-rest-resource' and fail with Unknown skill. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/add-resource.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/add-resource.md b/.claude/skills/add-resource.md index f011045..9e6f620 100644 --- a/.claude/skills/add-resource.md +++ b/.claude/skills/add-resource.md @@ -1,5 +1,5 @@ --- -description: "Adding a new REST resource to this Express API: creating a route file, store functions, tests, and mounting the router. Use when the request is to add a new endpoint group or resource (e.g. 'add a products route', 'create a /orders resource')." +description: "Add a resource to this Express API — route file, store helpers, tests, and server mount. Use when the request is to add a new endpoint group or resource (e.g. 'add a products route', 'create a /orders resource')." --- Follow these patterns exactly when adding a new resource to this API. From 9a0c2d8107476d8f3d16bea06717e43090d3a0e3 Mon Sep 17 00:00:00 2001 From: Jeremy Skirrow Date: Tue, 23 Jun 2026 15:55:46 -0500 Subject: [PATCH 5/9] Add name: field to skill frontmatter Built-in skills require an explicit name: key so Claude Code registers the invocation name; without it the model synthesises a name from the description and misses. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/add-resource.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/skills/add-resource.md b/.claude/skills/add-resource.md index 9e6f620..33ae28c 100644 --- a/.claude/skills/add-resource.md +++ b/.claude/skills/add-resource.md @@ -1,4 +1,5 @@ --- +name: add-resource description: "Add a resource to this Express API — route file, store helpers, tests, and server mount. Use when the request is to add a new endpoint group or resource (e.g. 'add a products route', 'create a /orders resource')." --- From 2ea7c184bdd3772e7e4fa366f85a1d82575e5db0 Mon Sep 17 00:00:00 2001 From: Jeremy Skirrow Date: Tue, 23 Jun 2026 15:58:04 -0500 Subject: [PATCH 6/9] Restructure skill to directory format (skills/add-resource/SKILL.md) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flat .claude/skills/*.md files are not registered as invocable skills; the correct format is .claude/skills//SKILL.md — same convention used by all built-in and plugin skills. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/{add-resource.md => add-resource/SKILL.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .claude/skills/{add-resource.md => add-resource/SKILL.md} (100%) diff --git a/.claude/skills/add-resource.md b/.claude/skills/add-resource/SKILL.md similarity index 100% rename from .claude/skills/add-resource.md rename to .claude/skills/add-resource/SKILL.md From c9ce2e0d1f4d2ee0ac8c3ec685996794dd0baac8 Mon Sep 17 00:00:00 2001 From: Jeremy Skirrow Date: Tue, 23 Jun 2026 16:01:39 -0500 Subject: [PATCH 7/9] Add /review-route custom command Checks a route file against the project's conventions: error-response shape, early-return pattern, Number(req.params.id), status codes, store-only data access, and test coverage with store.reset(). Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/review-route.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .claude/commands/review-route.md diff --git a/.claude/commands/review-route.md b/.claude/commands/review-route.md new file mode 100644 index 0000000..2cdf654 --- /dev/null +++ b/.claude/commands/review-route.md @@ -0,0 +1,24 @@ +Review the route file for `$ARGUMENTS` against this project's conventions. If $ARGUMENTS looks like a file path, read it directly. If it looks like a resource name (e.g. "products"), look for `routes/$ARGUMENTS.js`. + +Check each of the following and report pass / fail / not-applicable for each item: + +**Error responses** +- Every error response uses the shape `{ "error": "message" }` (no other keys, no `message:` top-level field) +- 400 is returned when required input is missing or invalid +- 404 is returned when a record is not found +- 201 is returned for successful POST (not 200) + +**Route hygiene** +- Every error branch uses `return res.status(NNN).json(...)` — early return, not fall-through +- IDs from `req.params` are parsed with `Number(req.params.id)` before being passed to the store +- No in-route state — all reads and writes go through `db/store.js` functions + +**Store alignment** +- Read `db/store.js` and confirm each store function called by this route actually exists there +- No raw array access or direct mutation inside the route file + +**Test coverage** +- A test file exists at `tests/$ARGUMENTS.test.js` (or similar) +- If it exists: confirm there is a `test.beforeEach(() => store.reset())` call before any test that writes data + +At the end, give a one-line verdict: **all good**, **minor issues** (list them), or **needs fixes** (list them). From 01ca5afcdb40d59eef24c830114d98ac7f2fa1a7 Mon Sep 17 00:00:00 2001 From: Jeremy Skirrow Date: Tue, 23 Jun 2026 16:10:27 -0500 Subject: [PATCH 8/9] Add PostToolUse hook to auto-run eslint --fix after every edit Fires on Write|Edit, extracts the file path from the hook JSON, and runs eslint --fix on .js files only (case pattern match). Non-JS files are silently skipped; eslint errors are suppressed so the hook never blocks Claude. Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.claude/settings.json b/.claude/settings.json index 358d9e9..5fd0973 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -3,5 +3,18 @@ "allow": [ "mcp__fetch__fetch" ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "jq -r '.tool_input.file_path // .tool_response.filePath' | { read -r f; case \"$f\" in *.js) ./node_modules/.bin/eslint --fix \"$f\";; esac; } 2>/dev/null || true" + } + ] + } + ] } } From eab2f1b2f6032a889b2978f7e43215befb91c7b3 Mon Sep 17 00:00:00 2001 From: Jeremy Skirrow Date: Tue, 23 Jun 2026 16:27:08 -0500 Subject: [PATCH 9/9] Add DELETE route and fix code-review findings - Add DELETE /users/:id route (204 on success, 404 if not found) - Add deleteUser() to store, delegating lookup to getUser() to avoid predicate duplication - Validate :id as an integer on all parameterised routes (GET, PUT, DELETE), returning 400 for non-integer input instead of a misleading 404 - Add NOTES.md summarising MCP, skill, command, hook, and headless run choices Co-Authored-By: Claude Sonnet 4.6 --- NOTES.md | 21 +++++++++++++++++++++ db/store.js | 9 ++++++++- routes/users.js | 25 +++++++++++++++++++++++-- 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 NOTES.md diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..bcae2fb --- /dev/null +++ b/NOTES.md @@ -0,0 +1,21 @@ +# Notes + +## MCP server + +The fetch server (`@modelcontextprotocol/server-fetch`) was connected at project scope via `.mcp.json`. It is useful here because the API's documentation and any external references can be fetched without leaving the coding context — useful for looking up specs or checking example payloads mid-task. The permission rule in `.claude/settings.json` allows only `mcp__fetch__fetch`, scoping access to the read-only fetch tool and blocking any other tools the server might expose. + +## Skill + +The `add-resource` skill captures the repeated work of scaffolding a new REST resource: creating a route file, adding store functions, writing tests, and mounting the router in `server.js`. Without a skill, each new resource requires the same sequence of steps across four files with nothing to enforce consistency. The description was written to name the concrete action ("add a new REST resource") and reference the project's own patterns, so the model resolves it when asked to add a product, order, or any new entity. + +## Custom command + +The `/review-route` command runs a structured checklist against a route file — error shape, status codes, early returns, store alignment, and test coverage — and returns a one-line verdict. It is worth a shortcut because the same checklist applies to every route in the project, the review is mechanical enough to automate, and running it before a commit catches convention drift that is easy to miss in a quick read. + +## Hook + +A `PostToolUse` hook was added to `.claude/settings.json` that runs `eslint --fix` after every `Write` or `Edit` to a `.js` file. It reacts rather than prevents: the edit lands first, then ESLint cleans up any fixable style issues automatically. The event is `PostToolUse` with matcher `Write|Edit`, so it fires after file writes regardless of which route or store file was touched. + +## Headless run + +`claude -p` was used to add the `DELETE /users/:id` route and its `deleteUser` store function. The `--allowedTools` flag was set to `Read,Edit,Bash(npm test)`, which locked the subprocess to reading files, making edits, and running the test suite — nothing else. That constraint meant the task could not install packages, run git, start the server, or take any action outside the narrow scope of implementing and verifying the feature. diff --git a/db/store.js b/db/store.js index 4c92e75..7de3bd1 100644 --- a/db/store.js +++ b/db/store.js @@ -36,9 +36,16 @@ function updateUser(id, fields) { return user; } +function deleteUser(id) { + const user = getUser(id); + if (!user) return undefined; + users.splice(users.indexOf(user), 1); + return user; +} + // Reset to the seed data. Used by the tests so each one starts clean. function reset() { seed(); } -module.exports = { listUsers, getUser, createUser, updateUser, reset }; +module.exports = { listUsers, getUser, createUser, updateUser, deleteUser, reset }; diff --git a/routes/users.js b/routes/users.js index 8945477..0a7a9d6 100644 --- a/routes/users.js +++ b/routes/users.js @@ -10,7 +10,11 @@ router.get('/', (req, res) => { // GET /users/:id — fetch one user, or 404 if it doesn't exist. router.get('/:id', (req, res) => { - const user = store.getUser(Number(req.params.id)); + const id = Number(req.params.id); + if (!Number.isInteger(id)) { + return res.status(400).json({ error: 'id must be an integer' }); + } + const user = store.getUser(id); if (!user) { return res.status(404).json({ error: 'User not found' }); } @@ -29,15 +33,32 @@ router.post('/', (req, res) => { // PUT /users/:id — update an existing user (added in Project 2). router.put('/:id', (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) { + return res.status(400).json({ error: 'id must be an integer' }); + } const { name, email } = req.body; if (name === undefined && email === undefined) { return res.status(400).json({ error: 'name or email is required' }); } - const user = store.updateUser(Number(req.params.id), { name, email }); + const user = store.updateUser(id, { name, email }); if (!user) { return res.status(404).json({ error: 'User not found' }); } return res.json(user); }); +// DELETE /users/:id — remove a user, or 404 if not found. +router.delete('/:id', (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) { + return res.status(400).json({ error: 'id must be an integer' }); + } + const user = store.deleteUser(id); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + return res.status(204).send(); +}); + module.exports = router;