@cyanheads/mcp-ts-core 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +7 -9
  3. package/biome.json +1 -1
  4. package/dist/mcp-server/apps/appBuilders.d.ts +9 -1
  5. package/dist/mcp-server/apps/appBuilders.d.ts.map +1 -1
  6. package/dist/mcp-server/apps/appBuilders.js +64 -2
  7. package/dist/mcp-server/apps/appBuilders.js.map +1 -1
  8. package/dist/mcp-server/resources/utils/resourceDefinition.d.ts +16 -8
  9. package/dist/mcp-server/resources/utils/resourceDefinition.d.ts.map +1 -1
  10. package/dist/mcp-server/resources/utils/resourceDefinition.js.map +1 -1
  11. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  12. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +11 -1
  13. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  14. package/package.json +15 -15
  15. package/skills/add-app-tool/SKILL.md +54 -32
  16. package/skills/add-prompt/SKILL.md +16 -11
  17. package/skills/add-resource/SKILL.md +16 -11
  18. package/skills/add-service/SKILL.md +47 -6
  19. package/skills/add-test/SKILL.md +23 -24
  20. package/skills/add-tool/SKILL.md +55 -13
  21. package/skills/api-testing/SKILL.md +56 -10
  22. package/skills/api-workers/SKILL.md +9 -7
  23. package/skills/design-mcp-server/SKILL.md +10 -19
  24. package/skills/devcheck/SKILL.md +20 -6
  25. package/skills/field-test/SKILL.md +17 -2
  26. package/skills/maintenance/SKILL.md +4 -2
  27. package/skills/migrate-mcp-ts-template/SKILL.md +14 -12
  28. package/skills/polish-docs-meta/SKILL.md +4 -4
  29. package/skills/setup/SKILL.md +9 -9
  30. package/templates/AGENTS.md +6 -1
  31. package/templates/CLAUDE.md +6 -2
  32. package/templates/src/mcp-server/resources/definitions/echo-app-ui.app-resource.ts +25 -4
@@ -4,14 +4,14 @@ description: >
4
4
  Scaffold a new MCP prompt template. Use when the user asks to add a prompt, create a reusable message template, or define a prompt for LLM interactions.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.0"
7
+ version: "1.1"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
11
11
 
12
12
  ## Context
13
13
 
14
- Prompts use the `prompt()` builder from `@cyanheads/mcp-ts-core`. Each prompt lives in `src/mcp-server/prompts/definitions/` with a `.prompt.ts` suffix and is registered in the barrel `index.ts`.
14
+ Prompts use the `prompt()` builder from `@cyanheads/mcp-ts-core`. Each prompt lives in `src/mcp-server/prompts/definitions/` with a `.prompt.ts` suffix and is registered into `createApp()` in `src/index.ts`. Some repos later add `definitions/index.ts` barrels; match the project's current pattern.
15
15
 
16
16
  Prompts are pure message templates — no `Context`, no auth, no side effects.
17
17
 
@@ -23,7 +23,7 @@ For the full `prompt()` API, read:
23
23
 
24
24
  1. **Ask the user** for the prompt's name, purpose, and arguments
25
25
  2. **Create the file** at `src/mcp-server/prompts/definitions/{{prompt-name}}.prompt.ts`
26
- 3. **Register** the prompt in `src/mcp-server/prompts/definitions/index.ts`
26
+ 3. **Register** the prompt in the project's existing `createApp()` prompt list (directly in `src/index.ts` for fresh scaffolds, or via a barrel if the repo already has one)
27
27
  4. **Run `bun run devcheck`** to verify
28
28
 
29
29
  ## Template
@@ -74,17 +74,22 @@ generate: (args) => [
74
74
  ],
75
75
  ```
76
76
 
77
- ### Barrel registration
77
+ ### Registration
78
78
 
79
79
  ```typescript
80
- // src/mcp-server/prompts/definitions/index.ts
81
- import { {{PROMPT_EXPORT}} } from './{{prompt-name}}.prompt.js';
82
- export const allPromptDefinitions = [
83
- // ... existing prompts
84
- {{PROMPT_EXPORT}},
85
- ];
80
+ // src/index.ts (fresh scaffold default)
81
+ import { createApp } from '@cyanheads/mcp-ts-core';
82
+ import { {{PROMPT_EXPORT}} } from './mcp-server/prompts/definitions/{{prompt-name}}.prompt.js';
83
+
84
+ await createApp({
85
+ tools: [/* existing tools */],
86
+ resources: [/* existing resources */],
87
+ prompts: [{{PROMPT_EXPORT}}],
88
+ });
86
89
  ```
87
90
 
91
+ If the repo already uses `src/mcp-server/prompts/definitions/index.ts`, update that barrel instead.
92
+
88
93
  ## Checklist
89
94
 
90
95
  - [ ] File created at `src/mcp-server/prompts/definitions/{{prompt-name}}.prompt.ts`
@@ -92,5 +97,5 @@ export const allPromptDefinitions = [
92
97
  - [ ] JSDoc `@fileoverview` and `@module` header present
93
98
  - [ ] `generate` function returns valid message array
94
99
  - [ ] No side effects — prompts are pure templates
95
- - [ ] Registered in `definitions/index.ts` barrel and `allPromptDefinitions`
100
+ - [ ] Registered in the project's existing `createApp()` prompt list (directly or via barrel)
96
101
  - [ ] `bun run devcheck` passes
@@ -4,14 +4,14 @@ description: >
4
4
  Scaffold a new MCP resource definition. Use when the user asks to add a resource, expose data via URI, or create a readable endpoint.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.1"
7
+ version: "1.2"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
11
11
 
12
12
  ## Context
13
13
 
14
- Resources use the `resource()` builder from `@cyanheads/mcp-ts-core`. Each resource lives in `src/mcp-server/resources/definitions/` with a `.resource.ts` suffix and is registered in the barrel `index.ts`.
14
+ Resources use the `resource()` builder from `@cyanheads/mcp-ts-core`. Each resource lives in `src/mcp-server/resources/definitions/` with a `.resource.ts` suffix and is registered into `createApp()` in `src/index.ts`. Some repos later add `definitions/index.ts` barrels; follow the pattern already used by the project.
15
15
 
16
16
  **Tool coverage.** Not all MCP clients expose resources — many are tool-only (Claude Code, Cursor, most chat UIs). Before adding a resource, verify the same data is reachable via the tool surface — either through a dedicated tool, included in another tool's output, or bundled into a broader tool. A resource whose data has no tool path is invisible to a large share of agents.
17
17
 
@@ -24,7 +24,7 @@ For the full `resource()` API, pagination utilities, and `Context` interface, re
24
24
  1. **Ask the user** for the resource's URI template, purpose, and data shape
25
25
  2. **Design the URI** — use `{paramName}` for path parameters (e.g., `myscheme://{itemId}/data`)
26
26
  3. **Create the file** at `src/mcp-server/resources/definitions/{{resource-name}}.resource.ts`
27
- 4. **Register** the resource in `src/mcp-server/resources/definitions/index.ts`
27
+ 4. **Register** the resource in the project's existing `createApp()` resource list (directly in `src/index.ts` for fresh scaffolds, or via a barrel if the repo already has one)
28
28
  5. **Run `bun run devcheck`** to verify
29
29
  6. **Smoke-test** with `bun run dev:stdio` or `dev:http`
30
30
 
@@ -93,17 +93,22 @@ async handler(params, ctx) {
93
93
  },
94
94
  ```
95
95
 
96
- ### Barrel registration
96
+ ### Registration
97
97
 
98
98
  ```typescript
99
- // src/mcp-server/resources/definitions/index.ts
100
- import { {{RESOURCE_EXPORT}} } from './{{resource-name}}.resource.js';
101
- export const allResourceDefinitions = [
102
- // ... existing resources
103
- {{RESOURCE_EXPORT}},
104
- ];
99
+ // src/index.ts (fresh scaffold default)
100
+ import { createApp } from '@cyanheads/mcp-ts-core';
101
+ import { {{RESOURCE_EXPORT}} } from './mcp-server/resources/definitions/{{resource-name}}.resource.js';
102
+
103
+ await createApp({
104
+ tools: [/* existing tools */],
105
+ resources: [{{RESOURCE_EXPORT}}],
106
+ prompts: [/* existing prompts */],
107
+ });
105
108
  ```
106
109
 
110
+ If the repo already uses `src/mcp-server/resources/definitions/index.ts`, update that barrel instead of changing the registration style.
111
+
107
112
  ## Checklist
108
113
 
109
114
  - [ ] File created at `src/mcp-server/resources/definitions/{{resource-name}}.resource.ts`
@@ -114,6 +119,6 @@ export const allResourceDefinitions = [
114
119
  - [ ] Data is reachable via the tool surface (dedicated tool, another tool's output, or not needed for tool-only agents)
115
120
  - [ ] `list()` function provided if the resource is discoverable
116
121
  - [ ] Pagination used for large result sets (`extractCursor`/`paginateArray`)
117
- - [ ] Registered in `definitions/index.ts` barrel and `allResourceDefinitions`
122
+ - [ ] Registered in the project's existing `createApp()` resource list (directly or via barrel)
118
123
  - [ ] `bun run devcheck` passes
119
124
  - [ ] Smoke-tested with `bun run dev:stdio` or `dev:http`
@@ -4,7 +4,7 @@ description: >
4
4
  Scaffold a new service integration. Use when the user asks to add a service, integrate an external API, or create a reusable domain module with its own initialization and state.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.0"
7
+ version: "1.2"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -25,7 +25,7 @@ For the full service pattern, `CoreServices`, and `Context` interface, read:
25
25
  2. **Create the directory** at `src/services/{{domain}}/`
26
26
  3. **Create the service file** at `src/services/{{domain}}/{{domain}}-service.ts`
27
27
  4. **Create types** at `src/services/{{domain}}/types.ts` if needed
28
- 5. **Register in `setup()`** in the server's entry point (`src/index.ts`)
28
+ 5. **Register in `setup()`** in the server's entry point (`src/index.ts`, or `src/worker.ts` for Worker-only servers)
29
29
  6. **Run `bun run devcheck`** to verify
30
30
 
31
31
  ## Template
@@ -79,9 +79,9 @@ import { createApp } from '@cyanheads/mcp-ts-core';
79
79
  import { init{{ServiceName}} } from './services/{{domain}}/{{domain}}-service.js';
80
80
 
81
81
  await createApp({
82
- tools: allToolDefinitions,
83
- resources: allResourceDefinitions,
84
- prompts: allPromptDefinitions,
82
+ tools: [/* existing tools */],
83
+ resources: [/* existing resources */],
84
+ prompts: [/* existing prompts */],
85
85
  setup(core) {
86
86
  init{{ServiceName}}(core.config, core.storage);
87
87
  },
@@ -100,7 +100,7 @@ handler: async (input, ctx) => {
100
100
 
101
101
  ## Resilience (External API Services)
102
102
 
103
- When a service wraps an external API, apply these patterns. See `docs/service-resilience.md` for full rationale.
103
+ When a service wraps an external API, apply these patterns. For the framework retry contract, see `skills/api-utils/SKILL.md`.
104
104
 
105
105
  ### Retry wraps the full pipeline
106
106
 
@@ -150,6 +150,46 @@ parseResponse<T>(text: string): T {
150
150
  }
151
151
  ```
152
152
 
153
+ ### Sparse upstream payloads
154
+
155
+ Third-party APIs often omit fields entirely instead of returning `null`. If your raw response types, normalized domain types, or tool output schemas are stricter than the real upstream payloads, you'll either fail validation or silently invent facts.
156
+
157
+ **Guidance:**
158
+
159
+ 1. **Raw upstream types default to optional unless presence is guaranteed.** Trust the docs only after you've verified real payloads.
160
+ 2. **Preserve absence when it means "unknown".** Missing data is different from `false`, `0`, `''`, or an empty array.
161
+ 3. **Don't fabricate defaults during normalization** unless the upstream contract or your own tool semantics explicitly define them.
162
+ 4. **With `exactOptionalPropertyTypes`, omit absent fields instead of returning `undefined`.** Conditional spreads keep the normalized object honest.
163
+
164
+ ```typescript
165
+ type RawRepo = {
166
+ id: string;
167
+ name: string;
168
+ archived?: boolean;
169
+ star_count?: number;
170
+ description?: string | null;
171
+ };
172
+
173
+ type Repo = {
174
+ id: string;
175
+ name: string;
176
+ archived?: boolean;
177
+ starCount?: number;
178
+ description?: string;
179
+ };
180
+
181
+ function normalizeRepo(raw: RawRepo): Repo {
182
+ const description = raw.description?.trim();
183
+ return {
184
+ id: raw.id,
185
+ name: raw.name,
186
+ ...(typeof raw.archived === 'boolean' && { archived: raw.archived }),
187
+ ...(typeof raw.star_count === 'number' && { starCount: raw.star_count }),
188
+ ...(description ? { description } : {}),
189
+ };
190
+ }
191
+ ```
192
+
153
193
  ## API Efficiency
154
194
 
155
195
  When a service wraps an external API, design methods to minimize upstream calls. These patterns compound — a tool calling 3 service methods that each make N requests is 3N calls; batching drops it to 3.
@@ -193,5 +233,6 @@ Silent truncation is a data integrity bug — the caller thinks it has all resul
193
233
  - [ ] `init` function registered in `setup()` callback in `src/index.ts`
194
234
  - [ ] Accessor throws `Error` if not initialized
195
235
  - [ ] If wrapping external API: retry covers full pipeline (fetch + parse), backoff calibrated
236
+ - [ ] If wrapping external API: raw/domain types reflect real upstream sparsity; missing values are preserved as unknown, not fabricated into concrete facts
196
237
  - [ ] If wrapping external API: batch endpoints used where available, field selection applied, pagination handled
197
238
  - [ ] `bun run devcheck` passes
@@ -1,17 +1,17 @@
1
1
  ---
2
2
  name: add-test
3
3
  description: >
4
- Scaffold a test file for an existing tool, resource, or service. Use when the user asks to add tests, improve coverage, or when a definition exists without a colocated test file.
4
+ Scaffold a test file for an existing tool, resource, or service. Use when the user asks to add tests, improve coverage, or when a definition exists without a matching test file.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.0"
7
+ version: "1.3"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
11
11
 
12
12
  ## Context
13
13
 
14
- Tests use Vitest and `createMockContext` from `@cyanheads/mcp-ts-core/testing`. Test files are colocated with their source: `foo.tool.ts` gets `foo.tool.test.ts` in the same directory.
14
+ Tests use Vitest and `createMockContext` from `@cyanheads/mcp-ts-core/testing`. If the repo already has tests, match the existing layout. If the repo has no existing tests, create a root `tests/` directory that mirrors the `src/` structure (e.g. `tests/mcp-server/tools/definitions/echo.tool.test.ts` for `src/mcp-server/tools/definitions/echo.tool.ts`).
15
15
 
16
16
  For the full `createMockContext` API and testing patterns, read:
17
17
 
@@ -21,9 +21,9 @@ For the full `createMockContext` API and testing patterns, read:
21
21
 
22
22
  1. **Identify the target** — which tool, resource, or service needs tests
23
23
  2. **Read the source file** — understand the handler's logic, input/output schemas, error paths, and which `ctx` features it uses
24
- 3. **Create the test file** colocated with the source
24
+ 3. **Create the test file** in the repo's existing test layout
25
25
  4. **Write test cases** covering happy path, error paths, and edge cases
26
- 5. **Run `npm test`** to verify
26
+ 5. **Run `bun run test`** to verify
27
27
  6. **Run `bun run devcheck`** to verify types
28
28
 
29
29
  ## Determining What to Test
@@ -38,7 +38,8 @@ Read the handler and identify:
38
38
  | **`ctx.state` usage** | Use `createMockContext({ tenantId: 'test' })` to enable storage |
39
39
  | **`ctx.elicit` / `ctx.sample`** | Mock with `vi.fn()`, also test the absent case (undefined) |
40
40
  | **`ctx.progress`** | Use `createMockContext({ progress: true })` for task tools |
41
- | **`format` function** | Test separately if defined — it's pure, no ctx needed |
41
+ | **`format` function** | Test separately if defined — it's pure, no ctx needed. Verify it renders the IDs and fields the model needs, not just a count or title. For projection-style tools, test non-default field selections. |
42
+ | **Sparse upstream payloads** | For third-party API integrations, build a fixture with omitted fields. Assert normalized output still validates and `format()` preserves unknown values instead of inventing facts. |
42
43
  | **Auth scopes** | Not tested at handler level (framework enforces) — skip |
43
44
 
44
45
  ## Templates
@@ -48,12 +49,12 @@ Read the handler and identify:
48
49
  ```typescript
49
50
  /**
50
51
  * @fileoverview Tests for {{TOOL_NAME}} tool.
51
- * @module mcp-server/tools/definitions/{{TOOL_NAME}}.test
52
+ * @module tests/tools/{{TOOL_NAME}}.tool.test
52
53
  */
53
54
 
54
55
  import { describe, expect, it } from 'vitest';
55
56
  import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
56
- import { {{TOOL_EXPORT}} } from './{{tool-name}}.tool.js';
57
+ import { {{TOOL_EXPORT}} } from '@/mcp-server/tools/definitions/{{tool-name}}.tool.js';
57
58
 
58
59
  describe('{{TOOL_EXPORT}}', () => {
59
60
  it('returns expected output for valid input', async () => {
@@ -75,11 +76,11 @@ describe('{{TOOL_EXPORT}}', () => {
75
76
  await expect({{TOOL_EXPORT}}.handler(input, ctx)).rejects.toThrow();
76
77
  });
77
78
 
78
- it('formats output correctly', () => {
79
+ it('formats output completely', () => {
79
80
  const output = { /* mock output matching the output schema */ };
80
81
  const blocks = {{TOOL_EXPORT}}.format!(output);
81
- expect(blocks).toHaveLength(1);
82
- expect(blocks[0].type).toBe('text');
82
+ expect(blocks.some((block) => block.type === 'text')).toBe(true);
83
+ // Assert the rendered text includes the IDs/fields the LLM needs to act on.
83
84
  });
84
85
  });
85
86
  ```
@@ -89,12 +90,12 @@ describe('{{TOOL_EXPORT}}', () => {
89
90
  ```typescript
90
91
  /**
91
92
  * @fileoverview Tests for {{RESOURCE_NAME}} resource.
92
- * @module mcp-server/resources/definitions/{{RESOURCE_NAME}}.test
93
+ * @module tests/resources/{{RESOURCE_NAME}}.resource.test
93
94
  */
94
95
 
95
96
  import { describe, expect, it } from 'vitest';
96
97
  import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
97
- import { {{RESOURCE_EXPORT}} } from './{{resource-name}}.resource.js';
98
+ import { {{RESOURCE_EXPORT}} } from '@/mcp-server/resources/definitions/{{resource-name}}.resource.js';
98
99
 
99
100
  describe('{{RESOURCE_EXPORT}}', () => {
100
101
  it('returns data for valid params', async () => {
@@ -131,16 +132,16 @@ describe('{{RESOURCE_EXPORT}}', () => {
131
132
  ```typescript
132
133
  /**
133
134
  * @fileoverview Tests for {{SERVICE_NAME}} service.
134
- * @module services/{{domain}}/{{SERVICE_NAME}}.test
135
+ * @module tests/services/{{domain}}/{{domain}}-service.test
135
136
  */
136
137
 
137
138
  import { beforeEach, describe, expect, it } from 'vitest';
138
139
  import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
139
- import { init{{ServiceClass}}, get{{ServiceClass}} } from './{{service-name}}-service.js';
140
+ import { get{{ServiceClass}}, init{{ServiceClass}} } from '@/services/{{domain}}/{{domain}}-service.js';
140
141
 
141
142
  describe('{{ServiceClass}}', () => {
142
143
  beforeEach(() => {
143
- // Re-initialize with fresh config/storage per suite
144
+ // Re-initialize with fresh config/storage for each test
144
145
  init{{ServiceClass}}(mockConfig, mockStorage);
145
146
  });
146
147
 
@@ -150,15 +151,11 @@ describe('{{ServiceClass}}', () => {
150
151
  const result = await service.doWork('input', ctx);
151
152
  expect(result).toBeDefined();
152
153
  });
153
-
154
- it('throws when not initialized', () => {
155
- // Reset the singleton — this is the only case where accessing
156
- // the module internals is acceptable
157
- expect(() => get{{ServiceClass}}()).toThrow(/not initialized/);
158
- });
159
154
  });
160
155
  ```
161
156
 
157
+ If you need to test the accessor's "not initialized" guard, do it in a separate isolated-module test (`vi.resetModules()` before importing the service module). Don't mix that assertion into a suite that already calls `init{{ServiceClass}}()` in `beforeEach()`.
158
+
162
159
  ### Task tool test
163
160
 
164
161
  For tools with `task: true`, use `createMockContext({ progress: true })`:
@@ -202,15 +199,17 @@ When scaffolding tests for an existing handler, use the Zod schemas to generate
202
199
  4. **Defaults** — omit optional fields, verify defaults are applied in the output
203
200
  5. **Boundaries** — if the schema has `.min()`, `.max()`, `.length()`, test at the boundaries
204
201
  6. **Error paths** — trace the handler logic for throw conditions, construct inputs that trigger each
202
+ 7. **Sparse upstream fixtures** — if the handler/service wraps a third-party API, add at least one fixture where upstream omits optional fields entirely. Assert that the output still validates and that `format()` renders uncertainty honestly (`Not available`, omitted badge, etc.) instead of fabricating values.
205
203
 
206
204
  ## Checklist
207
205
 
208
- - [ ] Test file created at `src/.../{{name}}.test.ts` (colocated with source)
206
+ - [ ] Test file created in the repo's existing layout (`tests/...` or colocated with source)
209
207
  - [ ] JSDoc `@fileoverview` and `@module` header present
210
208
  - [ ] Happy path tested with valid input → expected output
211
209
  - [ ] Error paths tested (at least one `.rejects.toThrow()`)
212
210
  - [ ] `format` function tested if defined
213
211
  - [ ] `createMockContext` options match handler's ctx usage (`tenantId`, `progress`, `elicit`, `sample`)
214
212
  - [ ] Service re-initialized in `beforeEach` if handler depends on a service singleton
215
- - [ ] `npm test` passes
213
+ - [ ] If wrapping external API: sparse-payload case tested (omitted upstream fields still validate; `format()` does not invent facts)
214
+ - [ ] `bun run test` passes
216
215
  - [ ] `bun run devcheck` passes
@@ -4,14 +4,14 @@ description: >
4
4
  Scaffold a new MCP tool definition. Use when the user asks to add a tool, create a new tool, or implement a new capability for the server.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.1"
7
+ version: "1.3"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
11
11
 
12
12
  ## Context
13
13
 
14
- Tools use the `tool()` builder from `@cyanheads/mcp-ts-core`. Each tool lives in `src/mcp-server/tools/definitions/` with a `.tool.ts` suffix and is registered in the barrel `index.ts`.
14
+ Tools use the `tool()` builder from `@cyanheads/mcp-ts-core`. Each tool lives in `src/mcp-server/tools/definitions/` with a `.tool.ts` suffix and is registered into `createApp()` in `src/index.ts`. Some larger repos later add `definitions/index.ts` barrels; match the pattern already used by the project you're editing.
15
15
 
16
16
  For the full `tool()` API, `Context` interface, and error codes, read:
17
17
 
@@ -23,7 +23,7 @@ For the full `tool()` API, `Context` interface, and error codes, read:
23
23
  2. **Determine if long-running** — if the tool involves streaming, polling, or
24
24
  multi-step async work, it should use `task: true`
25
25
  3. **Create the file** at `src/mcp-server/tools/definitions/{{tool-name}}.tool.ts`
26
- 4. **Register** the tool in `src/mcp-server/tools/definitions/index.ts`
26
+ 4. **Register** the tool in the project's existing `createApp()` tool list (directly in `src/index.ts` for fresh scaffolds, or via a barrel if the repo already has one)
27
27
  5. **Run `bun run devcheck`** to verify
28
28
  6. **Smoke-test** with `bun run dev:stdio` or `dev:http`
29
29
 
@@ -96,19 +96,23 @@ export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
96
96
  });
97
97
  ```
98
98
 
99
- ### Barrel registration
99
+ ### Registration
100
100
 
101
101
  ```typescript
102
- // src/mcp-server/tools/definitions/index.ts
103
- import { existingTool } from './existing-tool.tool.js';
104
- import { {{TOOL_EXPORT}} } from './{{tool-name}}.tool.js';
105
-
106
- export const allToolDefinitions = [
107
- existingTool,
108
- {{TOOL_EXPORT}},
109
- ];
102
+ // src/index.ts (fresh scaffold default)
103
+ import { createApp } from '@cyanheads/mcp-ts-core';
104
+ import { existingTool } from './mcp-server/tools/definitions/existing-tool.tool.js';
105
+ import { {{TOOL_EXPORT}} } from './mcp-server/tools/definitions/{{tool-name}}.tool.js';
106
+
107
+ await createApp({
108
+ tools: [existingTool, {{TOOL_EXPORT}}],
109
+ resources: [/* existing resources */],
110
+ prompts: [/* existing prompts */],
111
+ });
110
112
  ```
111
113
 
114
+ If the repo already uses `src/mcp-server/tools/definitions/index.ts`, update that barrel instead of switching patterns midstream.
115
+
112
116
  ## Tool Response Design
113
117
 
114
118
  Tool responses are the LLM's only window into what happened. Every response should leave the agent informed about outcome, current state, and what to do next. This applies to success, partial success, empty results, and errors alike.
@@ -177,6 +181,43 @@ if (results.length === 0) {
177
181
  }
178
182
  ```
179
183
 
184
+ ### Sparse upstream data must stay honest
185
+
186
+ When tool output comes from a third-party API, don't overstate certainty. Upstream systems often omit fields entirely; the tool schema and `format()` should preserve that uncertainty instead of collapsing it into fake `false`, `0`, or empty-string facts.
187
+
188
+ **Guidance:**
189
+
190
+ - Use optional output fields when the upstream source is sparse.
191
+ - Render unknown values explicitly (`Not available`, `Unknown`) instead of inventing a concrete value.
192
+ - Only render booleans, badges, counts, and summary facts when they are actually known.
193
+
194
+ ```typescript
195
+ output: z.object({
196
+ repos: z.array(z.object({
197
+ id: z.string().describe('Repository ID.'),
198
+ name: z.string().describe('Repository name.'),
199
+ archived: z.boolean().optional()
200
+ .describe('Archived status when provided by the upstream API. Omitted when unknown.'),
201
+ stars: z.number().optional()
202
+ .describe('Star count when provided by the upstream API. Omitted when unknown.'),
203
+ })).describe('Repositories returned by the search.'),
204
+ }),
205
+
206
+ format: (result) => [{
207
+ type: 'text',
208
+ text: result.repos.map((repo) => [
209
+ `## ${repo.name}`,
210
+ `**ID:** ${repo.id}`,
211
+ typeof repo.archived === 'boolean'
212
+ ? `**Archived:** ${repo.archived ? 'Yes' : 'No'}`
213
+ : '**Archived:** Not available',
214
+ repo.stars != null
215
+ ? `**Stars:** ${repo.stars}`
216
+ : '**Stars:** Not available',
217
+ ].join('\n')).join('\n\n'),
218
+ }],
219
+ ```
220
+
180
221
  ### Error classification and messaging
181
222
 
182
223
  The framework auto-classifies many errors at runtime (HTTP status codes, JS error types, common patterns). Use explicit error factories when you want a specific code and clear recovery guidance; plain `throw new Error()` when auto-classification is sufficient.
@@ -270,8 +311,9 @@ Large payloads burn the agent's context window. Default to curated summaries; of
270
311
  - [ ] Optional nested objects guarded for empty inner values from form-based clients (check `?.field` truthiness, not just object presence)
271
312
  - [ ] `handler(input, ctx)` is pure — throws on failure, no try/catch
272
313
  - [ ] `format()` renders all data the LLM needs (not just a count or title) — `content[]` is the only field most clients forward to the model
314
+ - [ ] If wrapping external API: output schema and `format()` preserve uncertainty from sparse upstream payloads instead of inventing concrete values
273
315
  - [ ] `auth` scopes declared if the tool needs authorization
274
316
  - [ ] `task: true` added if the tool is long-running
275
- - [ ] Registered in `definitions/index.ts` barrel and `allToolDefinitions`
317
+ - [ ] Registered in the project's existing `createApp()` tool list (directly or via barrel)
276
318
  - [ ] `bun run devcheck` passes
277
319
  - [ ] Smoke-tested with `bun run dev:stdio` or `dev:http`
@@ -4,7 +4,7 @@ description: >
4
4
  Testing patterns for MCP tool/resource handlers using `createMockContext` and Vitest. Covers mock context options, handler testing, McpError assertions, format testing, Vitest config setup, and test isolation conventions.
5
5
  metadata:
6
6
  author: cyanheads
7
- version: "1.0"
7
+ version: "1.2"
8
8
  audience: external
9
9
  type: reference
10
10
  ---
@@ -13,7 +13,7 @@ metadata:
13
13
 
14
14
  Tests target handler behavior directly — call `handler(input, ctx)`, assert on the return value or thrown error. The framework's handler factory (try/catch, formatting, telemetry) is not involved. Use `createMockContext` from `@cyanheads/mcp-ts-core/testing` to construct the `ctx` argument.
15
15
 
16
- **Philosophy:** Test behavior, not implementation. Refactors should not break tests. Colocate test files with source (`foo.tool.ts` `foo.tool.test.ts`). Integration tests at I/O boundaries over unit tests of internals.
16
+ **Philosophy:** Test behavior, not implementation. Refactors should not break tests. Match the repo's existing test layout: fresh scaffolds use `tests/`, while colocated `src/**/*.test.ts` files are also supported. Integration tests at I/O boundaries over unit tests of internals.
17
17
 
18
18
  ---
19
19
 
@@ -28,8 +28,10 @@ createMockContext({ sample: vi.fn().mockResolvedValue(...) }) // with MCP sampli
28
28
  createMockContext({ elicit: vi.fn().mockResolvedValue(...) }) // with elicitation
29
29
  createMockContext({ progress: true }) // with task progress (ctx.progress populated)
30
30
  createMockContext({ requestId: 'my-id' }) // override request ID (default: 'test-request-id')
31
+ createMockContext({ notifyResourceListChanged: () => {} }) // with resource-list change notifier
32
+ createMockContext({ notifyResourceUpdated: (_uri) => {} }) // with resource update notifier
31
33
  createMockContext({ signal: controller.signal }) // custom AbortSignal
32
- createMockContext({ auth: { clientId: 'test', scopes: [] } }) // with auth context
34
+ createMockContext({ auth: { clientId: 'test', scopes: [], sub: 'test-user' } }) // with auth context
33
35
  createMockContext({ uri: new URL('myscheme://item/123') }) // for resource handler testing
34
36
  ```
35
37
 
@@ -39,6 +41,8 @@ createMockContext({ uri: new URL('myscheme://item/123') }) // for resource han
39
41
  interface MockContextOptions {
40
42
  auth?: AuthContext;
41
43
  elicit?: (message: string, schema: z.ZodObject<z.ZodRawShape>) => Promise<ElicitResult>;
44
+ notifyResourceListChanged?: () => void;
45
+ notifyResourceUpdated?: (uri: string) => void;
42
46
  progress?: boolean;
43
47
  requestId?: string;
44
48
  sample?: (messages: SamplingMessage[], opts?: SamplingOpts) => Promise<CreateMessageResult>;
@@ -53,6 +57,8 @@ interface MockContextOptions {
53
57
  | _(none)_ | Minimal context — `ctx.state` operations throw without `tenantId`; `ctx.elicit`/`ctx.sample`/`ctx.progress` are `undefined` |
54
58
  | `auth` | Sets `ctx.auth` for scope-checking tests |
55
59
  | `elicit` | Assigns a function to `ctx.elicit` for testing elicitation calls |
60
+ | `notifyResourceListChanged` | Assigns `ctx.notifyResourceListChanged` for resource notification tests |
61
+ | `notifyResourceUpdated` | Assigns `ctx.notifyResourceUpdated` for resource update notification tests |
56
62
  | `progress` | Populates `ctx.progress` with real state-tracking implementation (see below) |
57
63
  | `requestId` | Overrides `ctx.requestId` (default: `'test-request-id'`) |
58
64
  | `sample` | Assigns a function to `ctx.sample` for testing sampling calls |
@@ -101,8 +107,8 @@ expect(log.calls.some(c => c.level === 'info' && c.msg.includes('Processing'))).
101
107
  ## Full test example
102
108
 
103
109
  ```ts
104
- // src/mcp-server/tools/definitions/my-tool.tool.test.ts
105
- import { describe, expect, it, vi } from 'vitest';
110
+ // tests/tools/my-tool.tool.test.ts
111
+ import { describe, expect, it } from 'vitest';
106
112
  import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
107
113
  import { myTool } from '@/mcp-server/tools/definitions/my-tool.tool.js';
108
114
 
@@ -120,15 +126,16 @@ describe('myTool', () => {
120
126
  await expect(myTool.handler(input, ctx)).rejects.toThrow();
121
127
  });
122
128
 
123
- it('formats response correctly', () => {
129
+ it('formats response completely', () => {
124
130
  const result = { result: 'test' };
125
131
  const blocks = myTool.format!(result);
126
132
  expect(blocks[0].type).toBe('text');
133
+ expect((blocks[0] as { text?: string }).text).toContain('test');
127
134
  });
128
135
  });
129
136
  ```
130
137
 
131
- Parse input through `myTool.input.parse(...)` to validate against the Zod schema and produce the typed input the handler expects. Call `myTool.handler(input, ctx)` directly, not through the MCP SDK or any framework wrapper. Assert on the return value for happy paths; use `.rejects.toThrow()` for error paths. Test `format` separately if the tool defines one — it's a pure function and needs no `ctx`.
138
+ Parse input through `myTool.input.parse(...)` to validate against the Zod schema and produce the typed input the handler expects. Call `myTool.handler(input, ctx)` directly, not through the MCP SDK or any framework wrapper. Assert on the return value for happy paths; use `.rejects.toThrow()` for error paths. Test `format` separately if the tool defines one — it's a pure function and needs no `ctx`. Verify the rendered text includes the fields the LLM needs, and for projection-style tools, add a case with non-default field selections.
132
139
 
133
140
  ---
134
141
 
@@ -138,7 +145,7 @@ Parse input through `myTool.input.parse(...)` to validate against the Zod schema
138
145
  it('uses elicitation when available', async () => {
139
146
  const elicit = vi.fn().mockResolvedValue({
140
147
  action: 'accept',
141
- data: { format: 'json' },
148
+ content: { format: 'json' },
142
149
  });
143
150
  const ctx = createMockContext({ elicit });
144
151
  const input = myTool.input.parse({ query: 'hello' });
@@ -203,6 +210,45 @@ The pattern: parse through the schema (confirms Zod accepts the payload), call t
203
210
 
204
211
  ---
205
212
 
213
+ ## Testing with sparse upstream payloads
214
+
215
+ This is a different problem from form-client `''` payloads. Here the upstream API omits fields entirely. The risk is either a validation failure from an over-strict schema or a quiet lie where missing data turns into a concrete fact.
216
+
217
+ ```ts
218
+ describe('sparse upstream payloads', () => {
219
+ it('preserves missing upstream fields as unknown', async () => {
220
+ const upstream = {
221
+ id: 'repo-123',
222
+ name: 'Widget Repo',
223
+ // archived and star_count omitted entirely
224
+ };
225
+
226
+ const normalized = normalizeRepo(upstream);
227
+ expect(normalized).toEqual({
228
+ id: 'repo-123',
229
+ name: 'Widget Repo',
230
+ });
231
+
232
+ const output = repoSearchTool.output.parse({
233
+ repos: [normalized],
234
+ });
235
+ const blocks = repoSearchTool.format!(output);
236
+ expect((blocks[0] as { text: string }).text).toContain('Archived:** Not available');
237
+ expect((blocks[0] as { text: string }).text).not.toContain('Archived:** No');
238
+ });
239
+ });
240
+ ```
241
+
242
+ **What to verify:**
243
+
244
+ - Fixtures omit fields entirely, not just set them to `null` or `''`.
245
+ - Normalization/helpers tolerate missing fields without fabricating defaults.
246
+ - Handler output still validates against the declared output schema.
247
+ - `format()` uses explicit unknown-state fallbacks instead of inventing facts.
248
+ - Tool-semantic defaults are tested separately from upstream absence so the distinction stays clear.
249
+
250
+ ---
251
+
206
252
  ## Vitest config
207
253
 
208
254
  Extend the framework's base config using `mergeConfig`. The base provides `globals: true`, `pool: 'forks'`, `isolate: true`, `tsconfigPaths`, and a Zod SSR compatibility fix. Add only the `@/` alias for your server's source:
@@ -244,8 +290,8 @@ describe('myTool with service', () => {
244
290
  });
245
291
  ```
246
292
 
247
- - Re-init services with `initMyService()` (or equivalent) per test suite the module-level singleton must be reset so tests don't share state.
248
- - Vitest runs test files in separate worker threads — parallel file execution is safe by default.
293
+ - Re-init services with `initMyService()` (or equivalent) in `beforeEach` when tests share a module-level singleton.
294
+ - Vitest runs test files in separate workers — parallel file execution is safe by default.
249
295
  - Use `createMockContext({ tenantId })` whenever the handler accesses `ctx.state` — omitting `tenantId` causes `ctx.state` to throw.
250
296
 
251
297
  ---