@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.
- package/CLAUDE.md +1 -1
- package/README.md +7 -9
- package/biome.json +1 -1
- package/dist/mcp-server/apps/appBuilders.d.ts +9 -1
- package/dist/mcp-server/apps/appBuilders.d.ts.map +1 -1
- package/dist/mcp-server/apps/appBuilders.js +64 -2
- package/dist/mcp-server/apps/appBuilders.js.map +1 -1
- package/dist/mcp-server/resources/utils/resourceDefinition.d.ts +16 -8
- package/dist/mcp-server/resources/utils/resourceDefinition.d.ts.map +1 -1
- package/dist/mcp-server/resources/utils/resourceDefinition.js.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +11 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
- package/package.json +15 -15
- package/skills/add-app-tool/SKILL.md +54 -32
- package/skills/add-prompt/SKILL.md +16 -11
- package/skills/add-resource/SKILL.md +16 -11
- package/skills/add-service/SKILL.md +47 -6
- package/skills/add-test/SKILL.md +23 -24
- package/skills/add-tool/SKILL.md +55 -13
- package/skills/api-testing/SKILL.md +56 -10
- package/skills/api-workers/SKILL.md +9 -7
- package/skills/design-mcp-server/SKILL.md +10 -19
- package/skills/devcheck/SKILL.md +20 -6
- package/skills/field-test/SKILL.md +17 -2
- package/skills/maintenance/SKILL.md +4 -2
- package/skills/migrate-mcp-ts-template/SKILL.md +14 -12
- package/skills/polish-docs-meta/SKILL.md +4 -4
- package/skills/setup/SKILL.md +9 -9
- package/templates/AGENTS.md +6 -1
- package/templates/CLAUDE.md +6 -2
- 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.
|
|
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
|
|
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/
|
|
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
|
-
###
|
|
77
|
+
### Registration
|
|
78
78
|
|
|
79
79
|
```typescript
|
|
80
|
-
// src/
|
|
81
|
-
import {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 `
|
|
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.
|
|
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
|
|
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/
|
|
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
|
-
###
|
|
96
|
+
### Registration
|
|
97
97
|
|
|
98
98
|
```typescript
|
|
99
|
-
// src/
|
|
100
|
-
import {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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 `
|
|
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.
|
|
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:
|
|
83
|
-
resources:
|
|
84
|
-
prompts:
|
|
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.
|
|
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
|
package/skills/add-test/SKILL.md
CHANGED
|
@@ -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
|
|
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.
|
|
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`.
|
|
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**
|
|
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 `
|
|
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
|
|
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 '
|
|
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
|
|
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).
|
|
82
|
-
|
|
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
|
|
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 '
|
|
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}}/{{
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
- [ ]
|
|
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
|
package/skills/add-tool/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|
|
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/
|
|
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
|
-
###
|
|
99
|
+
### Registration
|
|
100
100
|
|
|
101
101
|
```typescript
|
|
102
|
-
// src/
|
|
103
|
-
import {
|
|
104
|
-
import {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 `
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
105
|
-
import { describe, expect, it
|
|
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
|
|
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
|
-
|
|
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)
|
|
248
|
-
- Vitest runs test files in separate
|
|
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
|
---
|