@cyanheads/mcp-ts-core 0.9.18 → 0.9.20
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/AGENTS.md +2 -2
- package/CLAUDE.md +2 -2
- package/README.md +1 -1
- package/changelog/0.9.x/0.9.19.md +22 -0
- package/changelog/0.9.x/0.9.20.md +27 -0
- package/dist/core/context.d.ts +11 -0
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +17 -4
- package/dist/core/context.js.map +1 -1
- package/dist/linter/rules/error-contract-rules.d.ts +5 -3
- package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
- package/dist/linter/rules/error-contract-rules.js +11 -5
- package/dist/linter/rules/error-contract-rules.js.map +1 -1
- package/dist/logs/combined.log +4 -0
- package/dist/logs/error.log +2 -0
- package/dist/logs/interactions.log +0 -0
- package/dist/mcp-server/notifications.d.ts +55 -0
- package/dist/mcp-server/notifications.d.ts.map +1 -0
- package/dist/mcp-server/notifications.js +51 -0
- package/dist/mcp-server/notifications.js.map +1 -0
- package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
- package/dist/mcp-server/resources/resource-registration.js +5 -3
- package/dist/mcp-server/resources/resource-registration.js.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts +7 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +11 -4
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
- package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
- package/dist/mcp-server/tools/tool-registration.js +6 -3
- package/dist/mcp-server/tools/tool-registration.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +8 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js +11 -4
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
- package/dist/types-global/errors.d.ts +30 -5
- package/dist/types-global/errors.d.ts.map +1 -1
- package/dist/utils/network/fetchWithTimeout.d.ts.map +1 -1
- package/dist/utils/network/fetchWithTimeout.js +33 -7
- package/dist/utils/network/fetchWithTimeout.js.map +1 -1
- package/dist/utils/network/retry.d.ts +15 -2
- package/dist/utils/network/retry.d.ts.map +1 -1
- package/dist/utils/network/retry.js +20 -0
- package/dist/utils/network/retry.js.map +1 -1
- package/package.json +13 -11
- package/scripts/release-github.ts +187 -0
- package/skills/add-service/SKILL.md +2 -2
- package/skills/add-tool/SKILL.md +30 -3
- package/skills/api-canvas/SKILL.md +16 -2
- package/skills/api-context/SKILL.md +32 -6
- package/skills/api-linter/SKILL.md +3 -3
- package/skills/api-utils/SKILL.md +2 -2
- package/skills/design-mcp-server/SKILL.md +20 -4
- package/skills/orchestrations/SKILL.md +2 -2
- package/skills/orchestrations/workflows/field-test-fix.md +9 -9
- package/skills/orchestrations/workflows/fix-wrapup-release.md +7 -7
- package/skills/orchestrations/workflows/greenfield-build.md +20 -20
- package/skills/orchestrations/workflows/maintenance-release.md +6 -6
- package/skills/release-and-publish/SKILL.md +21 -14
- package/templates/package.json +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyanheads/mcp-ts-core",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.20",
|
|
4
4
|
"mcpName": "io.github.cyanheads/mcp-ts-core",
|
|
5
5
|
"description": "Agent-native TypeScript framework for building MCP servers. Declarative definitions with auth, multi-backend storage, OpenTelemetry, and first-class support for Bun/Node/Cloudflare Workers.",
|
|
6
6
|
"main": "dist/core/index.js",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"scripts/lint-mcp.ts",
|
|
20
20
|
"scripts/lint-packaging.ts",
|
|
21
21
|
"scripts/list-skills.ts",
|
|
22
|
+
"scripts/release-github.ts",
|
|
22
23
|
"scripts/tree.ts",
|
|
23
24
|
"skills/",
|
|
24
25
|
"templates/",
|
|
@@ -161,6 +162,7 @@
|
|
|
161
162
|
"audit:refresh": "rm -f bun.lock && bun install && bun audit",
|
|
162
163
|
"changelog:build": "bun run scripts/build-changelog.ts",
|
|
163
164
|
"changelog:check": "bun run scripts/build-changelog.ts --check",
|
|
165
|
+
"release:github": "bun run scripts/release-github.ts",
|
|
164
166
|
"publish-mcp": "mcp-publisher login github -token \"$(security find-generic-password -a \"$USER\" -s mcp-publisher-github-pat -w)\" && mcp-publisher publish"
|
|
165
167
|
},
|
|
166
168
|
"resolutions": {
|
|
@@ -175,9 +177,9 @@
|
|
|
175
177
|
},
|
|
176
178
|
"devDependencies": {
|
|
177
179
|
"@biomejs/biome": "2.4.16",
|
|
178
|
-
"@cloudflare/vitest-pool-workers": "^0.16.
|
|
179
|
-
"@cloudflare/workers-types": "4.
|
|
180
|
-
"@duckdb/node-api": "^1.5.3-r.
|
|
180
|
+
"@cloudflare/vitest-pool-workers": "^0.16.11",
|
|
181
|
+
"@cloudflare/workers-types": "4.20260602.1",
|
|
182
|
+
"@duckdb/node-api": "^1.5.3-r.3",
|
|
181
183
|
"@hono/otel": "^1.1.2",
|
|
182
184
|
"@opentelemetry/exporter-metrics-otlp-http": "^0.218.0",
|
|
183
185
|
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
|
|
@@ -195,8 +197,8 @@
|
|
|
195
197
|
"@types/papaparse": "^5.5.2",
|
|
196
198
|
"@types/sanitize-html": "^2.16.1",
|
|
197
199
|
"@types/validator": "^13.15.10",
|
|
198
|
-
"@vitest/coverage-istanbul": "4.1.
|
|
199
|
-
"@vitest/ui": "4.1.
|
|
200
|
+
"@vitest/coverage-istanbul": "4.1.8",
|
|
201
|
+
"@vitest/ui": "4.1.8",
|
|
200
202
|
"better-sqlite3": "^12.10.0",
|
|
201
203
|
"bun-types": "^1.3.14",
|
|
202
204
|
"chrono-node": "^2.9.1",
|
|
@@ -207,10 +209,10 @@
|
|
|
207
209
|
"execa": "^9.6.1",
|
|
208
210
|
"fast-xml-parser": "^5.8.0",
|
|
209
211
|
"ignore": "^7.0.5",
|
|
210
|
-
"js-yaml": "^4.
|
|
212
|
+
"js-yaml": "^4.2.0",
|
|
211
213
|
"linkedom": "^0.18.12",
|
|
212
214
|
"node-cron": "^4.2.1",
|
|
213
|
-
"openai": "^6.
|
|
215
|
+
"openai": "^6.41.0",
|
|
214
216
|
"papaparse": "^5.5.3",
|
|
215
217
|
"partial-json": "^0.1.7",
|
|
216
218
|
"pdf-lib": "^1.17.1",
|
|
@@ -222,8 +224,8 @@
|
|
|
222
224
|
"typescript": "^6.0.3",
|
|
223
225
|
"unpdf": "^1.6.2",
|
|
224
226
|
"validator": "^13.15.35",
|
|
225
|
-
"vite": "8.0.
|
|
226
|
-
"vitest": "^4.1.
|
|
227
|
+
"vite": "8.0.16",
|
|
228
|
+
"vitest": "^4.1.8"
|
|
227
229
|
},
|
|
228
230
|
"keywords": [
|
|
229
231
|
"agent",
|
|
@@ -276,7 +278,7 @@
|
|
|
276
278
|
"dependencies": {
|
|
277
279
|
"@hono/mcp": "^0.3.0",
|
|
278
280
|
"@hono/node-server": "^2.0.4",
|
|
279
|
-
"@modelcontextprotocol/ext-apps": "^1.7.
|
|
281
|
+
"@modelcontextprotocol/ext-apps": "^1.7.3",
|
|
280
282
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
281
283
|
"@opentelemetry/api": "^1.9.1",
|
|
282
284
|
"dotenv": "^17.4.2",
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview Create (or repair) a GitHub Release on the current package version's
|
|
4
|
+
* annotated tag, enforcing the `v<VERSION>: <tag subject>` title format that
|
|
5
|
+
* `--notes-from-tag` alone cannot set.
|
|
6
|
+
*
|
|
7
|
+
* What it does:
|
|
8
|
+
* 1. Reads `version` from `package.json`.
|
|
9
|
+
* 2. Derives the tag subject via `git for-each-ref refs/tags/v<version>`.
|
|
10
|
+
* 3. Runs `gh release create v<version> --verify-tag --notes-from-tag
|
|
11
|
+
* --title "v<version>: <subject>"` plus `dist/*.mcpb` when
|
|
12
|
+
* `manifest.json` exists (MCPB bundle attach).
|
|
13
|
+
* 4. On "release already exists" (re-invocation after partial run):
|
|
14
|
+
* - If `manifest.json` exists: `gh release upload v<version> --clobber dist/*.mcpb`
|
|
15
|
+
* to attach/replace the asset, then `gh release edit` to set the title.
|
|
16
|
+
* - Otherwise: `gh release edit` to set the title on the existing release.
|
|
17
|
+
*
|
|
18
|
+
* The framework itself has no `manifest.json`/`.mcpb`, so the attach path is
|
|
19
|
+
* skipped here but scaffolded servers that do have a manifest get the full flow.
|
|
20
|
+
*
|
|
21
|
+
* @module scripts/release-github
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* // Create a GitHub Release for the current package version:
|
|
25
|
+
* // bun run release:github
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* // Dry-run — print the command that would be executed without running it:
|
|
29
|
+
* // bun run release:github -- --dry-run
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { spawnSync } from 'node:child_process';
|
|
33
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
34
|
+
import { resolve } from 'node:path';
|
|
35
|
+
import process from 'node:process';
|
|
36
|
+
|
|
37
|
+
const DRY_RUN = process.argv.includes('--dry-run');
|
|
38
|
+
|
|
39
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Run a command synchronously and return its trimmed stdout.
|
|
43
|
+
* Exits the process on non-zero exit when `required` is true.
|
|
44
|
+
*/
|
|
45
|
+
function run(
|
|
46
|
+
cmd: string,
|
|
47
|
+
args: string[],
|
|
48
|
+
options: { required?: boolean; capture?: boolean } = {},
|
|
49
|
+
): string {
|
|
50
|
+
const { required = true, capture = true } = options;
|
|
51
|
+
const result = spawnSync(cmd, args, {
|
|
52
|
+
encoding: 'utf-8',
|
|
53
|
+
stdio: capture ? ['ignore', 'pipe', 'pipe'] : ['inherit', 'inherit', 'pipe'],
|
|
54
|
+
});
|
|
55
|
+
const stdout = (result.stdout ?? '').trim();
|
|
56
|
+
const stderr = (result.stderr ?? '').trim();
|
|
57
|
+
|
|
58
|
+
if (result.error) {
|
|
59
|
+
console.error(`Failed to spawn '${cmd}': ${result.error.message}`);
|
|
60
|
+
if (required) process.exit(1);
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if ((result.status ?? 1) !== 0) {
|
|
65
|
+
if (required) {
|
|
66
|
+
console.error(`Command failed: ${cmd} ${args.join(' ')}`);
|
|
67
|
+
if (stderr) console.error(stderr);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
// Return stderr concatenated so callers can inspect the failure reason.
|
|
71
|
+
return `__ERROR__:${stderr}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return stdout;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Run a `gh` CLI command.
|
|
79
|
+
* On "release already exists" the return value starts with `__ERROR__:`.
|
|
80
|
+
*/
|
|
81
|
+
function gh(args: string[], options: { required?: boolean } = {}): string {
|
|
82
|
+
return run('gh', args, { required: options.required ?? true, capture: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
function main(): void {
|
|
88
|
+
// 1. Read version from package.json
|
|
89
|
+
const pkgPath = resolve('package.json');
|
|
90
|
+
if (!existsSync(pkgPath)) {
|
|
91
|
+
console.error('package.json not found in current directory. Run from the project root.');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string };
|
|
96
|
+
const version = pkg.version?.trim();
|
|
97
|
+
if (!version) {
|
|
98
|
+
console.error('package.json has no version field.');
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const tag = `v${version}`;
|
|
103
|
+
|
|
104
|
+
// 2. Derive tag subject via git for-each-ref
|
|
105
|
+
const subject = run('git', ['for-each-ref', `refs/tags/${tag}`, '--format=%(contents:subject)']);
|
|
106
|
+
|
|
107
|
+
if (!subject) {
|
|
108
|
+
console.error(
|
|
109
|
+
`Tag ${tag} not found locally or has no subject line. ` +
|
|
110
|
+
`Create the annotated tag first: git tag -a ${tag} -m "..."`,
|
|
111
|
+
);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const title = `${tag}: ${subject}`;
|
|
116
|
+
const hasMcpb = existsSync('manifest.json');
|
|
117
|
+
|
|
118
|
+
// 3. Build the gh release create command
|
|
119
|
+
const createArgs = [
|
|
120
|
+
'release',
|
|
121
|
+
'create',
|
|
122
|
+
tag,
|
|
123
|
+
'--verify-tag',
|
|
124
|
+
'--notes-from-tag',
|
|
125
|
+
'--title',
|
|
126
|
+
title,
|
|
127
|
+
];
|
|
128
|
+
if (hasMcpb) {
|
|
129
|
+
createArgs.push('dist/*.mcpb');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (DRY_RUN) {
|
|
133
|
+
console.log(`[dry-run] gh ${createArgs.join(' ')}`);
|
|
134
|
+
if (hasMcpb) {
|
|
135
|
+
console.log(
|
|
136
|
+
`[dry-run] fallback (if release exists): gh release upload ${tag} dist/*.mcpb --clobber`,
|
|
137
|
+
);
|
|
138
|
+
console.log(
|
|
139
|
+
`[dry-run] fallback (if release exists): gh release edit ${tag} --title "${title}"`,
|
|
140
|
+
);
|
|
141
|
+
} else {
|
|
142
|
+
console.log(
|
|
143
|
+
`[dry-run] fallback (if release exists): gh release edit ${tag} --title "${title}"`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.log(`Creating GitHub Release ${tag}…`);
|
|
150
|
+
console.log(` title: ${title}`);
|
|
151
|
+
if (hasMcpb) {
|
|
152
|
+
console.log(' asset: dist/*.mcpb');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 4. Try to create the release
|
|
156
|
+
const createResult = gh(createArgs, { required: false });
|
|
157
|
+
|
|
158
|
+
if (!createResult.startsWith('__ERROR__:')) {
|
|
159
|
+
// Success — print the release URL returned by gh
|
|
160
|
+
if (createResult) console.log(createResult);
|
|
161
|
+
console.log(`Release ${tag} created.`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const errText = createResult.slice('__ERROR__:'.length);
|
|
166
|
+
const alreadyExists = /release already exists/i.test(errText);
|
|
167
|
+
|
|
168
|
+
if (!alreadyExists) {
|
|
169
|
+
console.error(`gh release create failed:\n${errText}`);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 5. Release already exists — repair: upload asset (if applicable) and set title.
|
|
174
|
+
console.log(`Release ${tag} already exists. Repairing…`);
|
|
175
|
+
|
|
176
|
+
if (hasMcpb) {
|
|
177
|
+
console.log(' uploading dist/*.mcpb (--clobber)…');
|
|
178
|
+
gh(['release', 'upload', tag, 'dist/*.mcpb', '--clobber']);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
console.log(` setting title: ${title}`);
|
|
182
|
+
gh(['release', 'edit', tag, '--title', title]);
|
|
183
|
+
|
|
184
|
+
console.log(`Release ${tag} repaired.`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
main();
|
|
@@ -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.7"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -131,7 +131,7 @@ async fetchItem(id: string, ctx: Context): Promise<Item> {
|
|
|
131
131
|
|
|
132
132
|
1. **Calibrate backoff to the upstream.** 200–500ms for ephemeral failures, 1–2s for rate-limited APIs, 2–5s for service degradation. The default `baseDelayMs: 1000` suits most APIs.
|
|
133
133
|
2. **Check HTTP status before parsing.** `fetchWithTimeout` already throws on non-OK responses with granular status mapping (401→`Unauthorized`, 403→`Forbidden`, 404→`NotFound`, 408/425→`Timeout`, 422→`ValidationError`, 429→`RateLimited`, 5xx→`ServiceUnavailable`/`InternalError`) — this prevents feeding HTML error pages into XML/JSON parsers.
|
|
134
|
-
3. **Classify parse failures by content.** If the upstream returns HTTP 200 with an HTML error page, detect it and throw `ServiceUnavailable` (transient) instead of `SerializationError` (non-transient).
|
|
134
|
+
3. **Classify parse failures by content.** If the upstream returns HTTP 200 with an HTML error page, detect it and throw `ServiceUnavailable` (transient) instead of `SerializationError` (non-transient). **Exception — deterministic HTTP 200 errors fail fast, not transient.** Some upstreams return HTTP 200 with a structured error body for failures that will *never* succeed regardless of how many times you retry: a query too expensive for the server's budget, an oversized result set, or a malformed request the server rejects. Retrying these wastes upstream capacity and delays the client. Declare them in the contract with `retryable: false` (or pass `{ retryable: false }` in `data` at the throw site) — `withRetry`'s default predicate reads `error.data.retryable === false` and fails immediately, even for `Timeout`/`ServiceUnavailable` codes. `ctx.fail` auto-populates `data.retryable` from the contract entry, so declaring it once in `errors[]` is enough.
|
|
135
135
|
4. **Exhausted retries say so.** `withRetry` automatically enriches the final error with attempt count — callers know retries were already attempted.
|
|
136
136
|
|
|
137
137
|
### When you need finer-grained HTTP error classification
|
package/skills/add-tool/SKILL.md
CHANGED
|
@@ -4,7 +4,7 @@ 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: "2.
|
|
7
|
+
version: "2.12"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -574,7 +574,33 @@ Large payloads burn the agent's context window. Default to curated summaries; of
|
|
|
574
574
|
- **Lists**: Return top N with a total count and pagination cursor, not unbounded arrays
|
|
575
575
|
- **Large objects**: Return key fields by default; accept a `fields` or `verbose` parameter for full data
|
|
576
576
|
- **Binary/blob content**: Return metadata and a reference, not the raw content
|
|
577
|
-
- **
|
|
577
|
+
- **Analytical working sets**: When upstream returns more *analytical* rows (data an agent would SQL — aggregate, group, join) than fit in context, `DataCanvas` (`ctx.core.canvas?`, Tier 3 — opt-in via `CANVAS_PROVIDER_TYPE=duckdb`) lets you register the rows and return the `canvas_id` plus a preview so the agent can run SQL to slice down without a re-fetch. The `spillover()` helper (`@cyanheads/mcp-ts-core/canvas`) automates the overflow case: drain rows up to a character budget for the inline preview, auto-register the full source on overflow, return both as a discriminated union. **Two gates:** it must be analytical, not a discovery/search surface of categorical metadata (those don't earn a canvas regardless of row count — use MCP-side list filtering or pagination); and a tool emitting a `canvas_id` MUST be paired with a registered `dataframe_query` tool, or the handle is unreachable. Compute distributions or refinement hints across the full result — not the preview — so the agent gets honest aggregate signal on the rows it didn't read. See `api-canvas` for the register / query / export pattern and the spillover flow.
|
|
578
|
+
|
|
579
|
+
## MCP-side list filtering
|
|
580
|
+
|
|
581
|
+
When an upstream API has no native search but the relevant set is **bounded** (fits one or a few fetches), fetch it in full and filter on the server so an agent resolves a name → opaque ID in one call instead of scanning a blob. The `design-mcp-server` skill covers *when* to reach for this (the earns-its-keep gate, the `query`-vs-local-filter split); this is the *how*.
|
|
582
|
+
|
|
583
|
+
**Name the local param for the mechanic** — `filter` or `nameContains`, distinct from an upstream `query`. **Filter the complete set, not the page** (fetch up to the cap first). **Strict token match is the default** — normalize, then require every query token to appear; that handles word order and partials, needs no fuzzy library, and is too small to extract into a shared helper:
|
|
584
|
+
|
|
585
|
+
```typescript
|
|
586
|
+
const normalize = (s: string) =>
|
|
587
|
+
s.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9\s]/g, ' ');
|
|
588
|
+
|
|
589
|
+
// Filter the full bounded set — not a single page.
|
|
590
|
+
const tokens = normalize(input.nameContains).split(/\s+/).filter(Boolean);
|
|
591
|
+
const hits = items.filter((it) => {
|
|
592
|
+
const hay = normalize(it.name);
|
|
593
|
+
return tokens.every((t) => hay.includes(t));
|
|
594
|
+
});
|
|
595
|
+
if (hits.length === 0) {
|
|
596
|
+
ctx.enrich.notice(
|
|
597
|
+
`No name matched "${input.nameContains}". Call the tool without a filter to browse the full list.`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
return { items: hits };
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
**Add a fuzzy fallback only when a caller genuinely needs typo tolerance** — an LLM caller rarely does. If you do: fire it *only* when the strict match is empty, score against the best-matching token in each name (not the whole string) and **cap** the results, and label hits `approximate`. Test it against a **full-scale** fixture with a deliberate near-miss — a small fixture has no long-name noise floor, so a unit test won't catch a fallback that returns dozens of bogus matches. A bare "no match — browse the unfiltered list" often beats an `approximate` guess: it lets the model self-correct rather than commit to the wrong record.
|
|
578
604
|
|
|
579
605
|
## Checklist
|
|
580
606
|
|
|
@@ -597,8 +623,9 @@ Large payloads burn the agent's context window. Default to curated summaries; of
|
|
|
597
623
|
- [ ] Error contract declared inline on this tool — not imported from a shared module, even when other tools have near-identical entries
|
|
598
624
|
- [ ] `task: true` added if the tool is long-running
|
|
599
625
|
- [ ] If `task: true`: handler checks `ctx.signal.aborted` in its loop for cancellation support
|
|
600
|
-
- [ ] If tool returns unbounded arrays: pagination with total count, or `spillover()` / DataCanvas for
|
|
626
|
+
- [ ] If tool returns unbounded arrays: pagination with total count, or `spillover()` / DataCanvas for *analytical* working sets (an agent would SQL them — not a discovery/search surface). If any tool emits a `canvas_id`, a `dataframe_query` tool is registered in the same server — a token with no query tool is dead output
|
|
601
627
|
- [ ] If tool is feature-gated: evaluated whether `disabledTool()` wrapper is appropriate (present in manifest but uncallable)
|
|
628
|
+
- [ ] If the tool filters a bounded list locally (no upstream search): a distinct local param (`filter`/`nameContains`, not `query`), filters the full set (not one page), strict token match by default
|
|
602
629
|
- [ ] Registered in the project's existing `createApp()` tool list (directly or via barrel)
|
|
603
630
|
- [ ] Test file created via `add-test` skill, or handler tested directly with `createMockContext()`
|
|
604
631
|
- [ ] `bun run devcheck` passes
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
DataCanvas primitive reference — a Tier 3 SQL/analytical workspace for tabular MCP servers, backed by DuckDB. Use when registering tables from upstream APIs, running ad-hoc SQL across them, and exporting results. Covers the acquire → register → query → export flow, the token-sharing pattern for multi-agent collaboration, env config, and Cloudflare Workers fail-closed behavior.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.4"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -21,6 +21,17 @@ metadata:
|
|
|
21
21
|
|
|
22
22
|
---
|
|
23
23
|
|
|
24
|
+
## When canvas earns its keep
|
|
25
|
+
|
|
26
|
+
Two gates before wiring canvas in — **both** must be yes. Canvas that fails either is a SQL surface nobody queries.
|
|
27
|
+
|
|
28
|
+
1. **Is the data analytical, not just large?** Canvas is for tabular/numeric result sets an agent runs SQL over — aggregate, group, join, time-series filter. A **discovery/search surface** returning categorical metadata (titles, IDs, types, dates) where the workflow is *find the record, then drill into it* does **not** qualify, regardless of row count. A 5,000-row search result is still discovery. The gate is **shape, not size**: the right question is "would an agent write `SELECT … GROUP BY` against this?", not "does it have many rows?" For name→ID resolution over a bounded list, reach for MCP-side list filtering (see the `design-mcp-server` skill) instead.
|
|
29
|
+
2. **Is it too big to inline?** A result that fits the response (≤ ~100 rows of compact data) just gets inlined — no canvas. Canvas is the third option only when shape *and* size both call for it.
|
|
30
|
+
|
|
31
|
+
If canvas earns its keep, it carries an obligation: **a tool that emits a `canvas_id` MUST ship a `dataframe_query` tool in the same server's surface** (see the [simple-shape Tools row](#simple-shape-defaults) and the [Checklist](#checklist)). A `canvas_id` with no query tool is dead output — the agent literally cannot reach the staged data.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
24
35
|
## Imports
|
|
25
36
|
|
|
26
37
|
```ts
|
|
@@ -257,7 +268,7 @@ Most canvas use cases are public-data analytics: fetch from an upstream API, sta
|
|
|
257
268
|
| Table naming | `spillover()` auto-names the table `spilled_<id>`; pass `tableName` for a stable handle. A dataframe-query surface commonly adds its own `df_<id>` convention. |
|
|
258
269
|
| Access control | Possession of the `canvas_id` is access — unguessable in practice (see [token-sharing model](#the-token-sharing-model)). TTL + the framework rate limiter backstop brute force. |
|
|
259
270
|
| Enable flag | None of your own — canvas presence is the gate (`CANVAS_PROVIDER_TYPE=duckdb`; `getCanvas()` returns `undefined` otherwise). |
|
|
260
|
-
| Tools | A fetcher that spills
|
|
271
|
+
| Tools | A fetcher that spills **plus a `dataframe_query` tool — mandatory once anything emits a `canvas_id`**: a token with no query tool in the same server is dead output (the agent can't reach the staged data). `dataframe_describe` is strongly recommended — it lets the agent discover staged table and column names before writing SQL. `dataframe_drop` is optional. None are framework-provided; you register them. |
|
|
261
272
|
| Fetcher output | Two things in one response: the inline preview (answer to the immediate question) and the table handle (escape hatch for follow-up SQL via `dataframe_query`). Neither replaces the other. |
|
|
262
273
|
|
|
263
274
|
> The `MCP_HTTP_MAX_BODY_BYTES` request-body cap is **inbound-only** — it bounds the JSON-RPC request, not the upstream data a handler stages into the canvas or the rows it returns. Canvas servers send small requests (queries, SQL, canvas IDs) regardless of dataset size, so the cap never constrains canvas ingestion.
|
|
@@ -458,6 +469,7 @@ When the preview budget is small (single-digit rows) and the sniff window matter
|
|
|
458
469
|
|
|
459
470
|
### When *not* to use spillover
|
|
460
471
|
|
|
472
|
+
- **Discovery/search surfaces.** A result that's categorical metadata for *find-then-drill-in* — search hits, ID lookups, catalog browsing — is not analytical and doesn't earn a canvas regardless of row count (see [When canvas earns its keep](#when-canvas-earns-its-keep)). Use MCP-side list filtering or plain pagination instead.
|
|
461
473
|
- **Tiny known result.** If the upstream call returns ≤ 100 rows, just inline them — no canvas needed.
|
|
462
474
|
- **Headless register** (caller wants the full set on canvas with zero preview rows). Call `canvas.registerTable` directly. `previewChars` is rejected at `0`; spillover always implies a visible preview.
|
|
463
475
|
- **Workers runtime.** Canvas requires DuckDB native; spillover is a canvas-coupled helper. For Workers parity, persist via `ctx.state` instead.
|
|
@@ -501,6 +513,8 @@ When the preview budget is small (single-digit rows) and the sniff window matter
|
|
|
501
513
|
- [ ] Accessor wired in `setup()` callback via `setCanvas(core.canvas)`
|
|
502
514
|
- [ ] Handler guards for canvas availability (`if (!canvas) throw ...`)
|
|
503
515
|
- [ ] `canvas_id` accepted as optional input, returned in output
|
|
516
|
+
- [ ] A `dataframe_query` tool is registered in this server whenever any tool emits a `canvas_id` — a token with no query tool is dead output. Register `dataframe_describe` too (lets the agent discover staged table/column names)
|
|
517
|
+
- [ ] Canvas earns its keep: the staged data is analytical (an agent would SQL it), not a discovery/search surface of categorical metadata
|
|
504
518
|
- [ ] SQL queries are read-only (enforced by the four-layer gate, but don't attempt writes)
|
|
505
519
|
- [ ] Testing: mock the module-level `getCanvas()` accessor with `vi.spyOn` or a test setup that calls `setCanvas(mockCanvas)`
|
|
506
520
|
- [ ] `bun run devcheck` passes
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Canonical reference for the unified `Context` object passed to every tool and resource handler in `@cyanheads/mcp-ts-core`. Covers the full interface, all sub-APIs (`ctx.log`, `ctx.state`, `ctx.elicit`, `ctx.sample`, `ctx.progress`, `ctx.enrich`), and when to use each.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.6"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -42,7 +42,8 @@ interface Context {
|
|
|
42
42
|
readonly elicit?: (message: string, schema: z.ZodObject<z.ZodRawShape>) => Promise<ElicitResult>;
|
|
43
43
|
readonly sample?: (messages: SamplingMessage[], opts?: SamplingOpts) => Promise<CreateMessageResult>;
|
|
44
44
|
|
|
45
|
-
//
|
|
45
|
+
// List-changed / resource-updated notifications — wired in every handler ctx;
|
|
46
|
+
// delivery is request-scoped (see § list-changed notifications)
|
|
46
47
|
readonly notifyResourceListChanged?: () => void;
|
|
47
48
|
readonly notifyResourceUpdated?: (uri: string) => void;
|
|
48
49
|
readonly notifyPromptListChanged?: () => void;
|
|
@@ -328,6 +329,31 @@ interface SamplingOpts {
|
|
|
328
329
|
|
|
329
330
|
---
|
|
330
331
|
|
|
332
|
+
## List-changed notifications (`ctx.notify*`)
|
|
333
|
+
|
|
334
|
+
Fire-and-forget signals that the tool / resource / prompt list changed (the client should re-list), or that a specific resource was updated. The framework advertises the matching `listChanged` capabilities on every `initialize`. All four are wired in every tool and resource handler context — call with optional chaining (`?.`), the type is optional for mock / forward-compat only.
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
async handler(input, ctx) {
|
|
338
|
+
await enableFeatureTools();
|
|
339
|
+
ctx.notifyToolListChanged?.(); // tells the client to re-fetch tools/list
|
|
340
|
+
return { ok: true };
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### Delivery
|
|
345
|
+
|
|
346
|
+
A notification fired **from inside a handler** routes through that request's own channel (`relatedRequestId`), so it reaches the client on **every transport** — stdio, HTTP, and Workers — even though HTTP/Workers run a per-request `McpServer` with no long-lived notification channel.
|
|
347
|
+
|
|
348
|
+
| Fired from | stdio | HTTP / Workers |
|
|
349
|
+
|:-----------|:------|:---------------|
|
|
350
|
+
| A tool / resource handler | ✅ delivered | ✅ delivered (on the request's SSE response stream) |
|
|
351
|
+
| A `task: true` background handler, cron, or any non-request scope | ✅ delivered | ⚠️ dropped — no request scope to route through |
|
|
352
|
+
|
|
353
|
+
The background-under-HTTP gap is a known limitation; a session-scoped notification bus would close it. `notifyResourceUpdated` routes to the calling request, not to clients that subscribed to the URI — the framework tracks no subscription state.
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
331
357
|
## `ctx.signal`
|
|
332
358
|
|
|
333
359
|
Standard `AbortSignal`. Present on every context. Set when the client cancels the request or when a task tool is cancelled.
|
|
@@ -601,10 +627,10 @@ See `add-tool`'s **Tool Response Design** and `skills/api-linter` (`enrichment-*
|
|
|
601
627
|
| `ctx.enrich` | `Enrich` | Always; typed on `HandlerContext<R, E>` when an `enrichment` block is declared |
|
|
602
628
|
| `ctx.elicit` | `function \| undefined` | Client supports elicitation |
|
|
603
629
|
| `ctx.sample` | `function \| undefined` | Client supports sampling |
|
|
604
|
-
| `ctx.notifyResourceListChanged` | `function \| undefined` |
|
|
605
|
-
| `ctx.notifyResourceUpdated` | `function \| undefined` |
|
|
606
|
-
| `ctx.notifyPromptListChanged` | `function \| undefined` |
|
|
607
|
-
| `ctx.notifyToolListChanged` | `function \| undefined` |
|
|
630
|
+
| `ctx.notifyResourceListChanged` | `function \| undefined` | Always in handler ctx; delivery request-scoped (see [§ list-changed notifications](#list-changed-notifications-ctxnotify)) |
|
|
631
|
+
| `ctx.notifyResourceUpdated` | `function \| undefined` | Always in handler ctx; delivery request-scoped |
|
|
632
|
+
| `ctx.notifyPromptListChanged` | `function \| undefined` | Always in handler ctx; delivery request-scoped |
|
|
633
|
+
| `ctx.notifyToolListChanged` | `function \| undefined` | Always in handler ctx; delivery request-scoped |
|
|
608
634
|
| `ctx.progress` | `ContextProgress \| undefined` | Tool defined with `task: true` |
|
|
609
635
|
| `ctx.uri` | `URL \| undefined` | Resource handlers only |
|
|
610
636
|
| `ctx.fail` | `(reason, msg?, data?, opts?) => McpError` | Definition declares `errors[]` contract |
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
MCP definition linter rules reference. Use when `bun run lint:mcp` or `bun run devcheck` reports a lint error or warning (`format-parity`, `schema-is-object`, `name-format`, `server-json-*`, etc.) and you need to understand the rule, its severity, and how to fix it. Every rule ID the linter emits has an entry in this doc.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.6"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -682,13 +682,13 @@ Fires when `recovery` has fewer than 5 words. Short recoveries like "Try again."
|
|
|
682
682
|
|
|
683
683
|
**Severity:** warning
|
|
684
684
|
|
|
685
|
-
Cross-check rule. Fires when a handler throws a non-baseline code (via `JsonRpcErrorCode.X` or a factory like `notFound()`) that isn't declared in `errors[]`.
|
|
685
|
+
Cross-check rule. Fires when a handler throws a non-baseline code (via `new McpError(JsonRpcErrorCode.X, …)` or a factory like `notFound()`) that isn't declared in `errors[]`.
|
|
686
686
|
|
|
687
687
|
Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) are auto-allowed because they bubble from anywhere — services, framework utilities, the auto-classifier — and are implicitly always-possible on any tool. Only domain-specific codes need declaring.
|
|
688
688
|
|
|
689
689
|
**Fix:** add the missing code to `errors[]` with a stable reason, or route through `ctx.fail(reason, …)` if it maps to an existing entry.
|
|
690
690
|
|
|
691
|
-
**Heuristic limitations:** the scan reads `handler.toString()` and only
|
|
691
|
+
**Heuristic limitations:** the scan reads `handler.toString()` and only counts code *construction* sites — `new McpError(JsonRpcErrorCode.X, …)` and `throw factory(…)`. A bare `JsonRpcErrorCode.X` reference in a comparison (`err.code === JsonRpcErrorCode.X`) or a `case` label is not a throw and is correctly ignored. Indirect throws (`const e = notFound(); throw e;`), throws from called services, and throws via runtime helpers like `httpErrorFromResponse(...)` are invisible.
|
|
692
692
|
|
|
693
693
|
### error-contract-prefer-fail
|
|
694
694
|
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
API reference for all utilities exported from `@cyanheads/mcp-ts-core/utils`. Use when looking up utility method signatures, options, peer dependencies, or usage patterns.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "2.
|
|
7
|
+
version: "2.3"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -29,7 +29,7 @@ Utility exports from `@cyanheads/mcp-ts-core/utils`. Utilities with complex APIs
|
|
|
29
29
|
|
|
30
30
|
| Export | API | Notes |
|
|
31
31
|
|:-------|:----|:------|
|
|
32
|
-
| `fetchWithTimeout` | `(url, timeoutMs, context: RequestContext, options?: FetchWithTimeoutOptions) -> Promise<Response>` | Wraps `fetch` with `AbortController` timeout. `FetchWithTimeoutOptions` extends `RequestInit` (minus `signal`) and adds `rejectPrivateIPs?: boolean` and `signal?: AbortSignal` (external cancellation). SSRF guard (best-effort, not hard isolation): blocks RFC 1918, loopback, link-local, CGNAT, cloud metadata. DNS validation on Node, Bun, and Cloudflare Workers under `nodejs_compat`; hostname-only fallback otherwise. Manual redirect following (max 5) with per-hop SSRF check. **DNS rebinding / TOCTOU gap** — the validation lookup and `fetch`'s own resolution are independent; pair with egress controls or a DNS-pinning fetch proxy for strong isolation. |
|
|
32
|
+
| `fetchWithTimeout` | `(url, timeoutMs, context: RequestContext, options?: FetchWithTimeoutOptions) -> Promise<Response>` | Wraps `fetch` with `AbortController` timeout. `FetchWithTimeoutOptions` extends `RequestInit` (minus `signal`) and adds `rejectPrivateIPs?: boolean` and `signal?: AbortSignal` (external cancellation). SSRF guard (best-effort, not hard isolation): blocks RFC 1918, loopback, link-local, CGNAT, cloud metadata. DNS validation on Node, Bun, and Cloudflare Workers under `nodejs_compat`; hostname-only fallback otherwise. Manual redirect following (max 5) with per-hop SSRF check. **DNS rebinding / TOCTOU gap** — the validation lookup and `fetch`'s own resolution are independent; pair with egress controls or a DNS-pinning fetch proxy for strong isolation. **Error/log redaction:** URLs written into thrown errors and log lines are reduced to `origin + pathname` — the query string (where API keys commonly ride: `?api-key=…`, `?api_key=…`) never reaches the client or the logs. The actual request still uses the full URL. |
|
|
33
33
|
| `withRetry` | `<T>(fn: () => Promise<T>, options?: RetryOptions) -> Promise<T>` | Executes `fn` with exponential backoff. Retries on transient errors (`ServiceUnavailable`, `Timeout`, `RateLimited`); non-transient errors fail immediately. On exhaustion, enriches the final error with attempt count in message and `data.retryAttempts`. **Place the retry boundary around the full pipeline** (fetch + parse), not just the network call. `RetryOptions`: `maxRetries` (default `3`), `baseDelayMs` (default `1000`), `maxDelayMs` (default `30000`), `jitter` (default `0.25`), `operation` (log label), `context` (RequestContext), `signal` (AbortSignal), `isTransient` (custom predicate). |
|
|
34
34
|
| `httpErrorFromResponse` | `(response: Response, options?: HttpErrorFromResponseOptions) -> Promise<McpError>` | Maps an HTTP `Response` to a properly classified `McpError` — full status table including 401/403/408/422/429/5xx, body capture (truncated), `retry-after` header, optional `cause`. Use this instead of hand-rolling `if (status === 429) ...` ladders. Reads the response body — `clone()` first if you need it elsewhere. `HttpErrorFromResponseOptions`: `service?` (logical name in message, e.g. `'NCBI'`), `captureBody?` (default `true`), `bodyLimit?` (default `500`), `data?` (extra fields merged into `error.data`), `cause?`, `codeOverride?` (per-status mapping override). Pairs naturally with `withRetry` — both classify codes the same way. |
|
|
35
35
|
| `httpStatusToErrorCode` | `(status: number) -> JsonRpcErrorCode \| undefined` | Sync status → code lookup. Returns `undefined` for 1xx/2xx/3xx. Use when you need just the code without a `Response` object handy. |
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Design the tool surface, resources, and service layer for a new MCP server. Use when starting a new server, planning a major feature expansion, or when the user describes a domain/API they want to expose via MCP. Produces a design doc at docs/design.md that drives implementation.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "2.
|
|
7
|
+
version: "2.16"
|
|
8
8
|
audience: external
|
|
9
9
|
type: workflow
|
|
10
10
|
---
|
|
@@ -349,7 +349,7 @@ output: z.object({
|
|
|
349
349
|
```
|
|
350
350
|
|
|
351
351
|
- **Truncate large output with counts.** When a list exceeds a reasonable display size, show the top N and append "...and X more". Don't silently drop results.
|
|
352
|
-
- **Spill big
|
|
352
|
+
- **Spill big *analytical* results to a queryable surface.** When a tool's row set is something an agent would run SQL over (aggregate, group, join) *and* can exceed any reasonable context budget — paginated APIs, streamed exports, big query results — pair an inline preview with a `DataCanvas` table holding the full set. **Two rules gate this:** (1) it must earn its keep on *shape, not size* — a discovery/search surface of categorical metadata (titles, IDs) is not analytical and doesn't get a canvas regardless of row count; for name→ID resolution over a bounded list use [MCP-side list filtering](#mcp-side-list-filtering); (2) the `canvas_id` is reachable only if the same server **also exposes a `dataframe_query` tool** — emit one without the other and the handle is dead output. Compute distributions or refinement hints across the full result, not the preview, so aggregate signal stays honest. See `api-canvas` for the `spillover()` helper and both rules in full.
|
|
353
353
|
- **Mirror a bulk upstream instead of paginating it live.** When the server wraps a large or slow API whose corpus is queried far more than it changes, sync it once into a persistent local index and query that as the primary data path — not the live API per request. Match the backend to corpus size: ≲ tens of thousands of rows → an in-memory index (server-level, no primitive); ~10⁴–10⁷ → the `MirrorService` (embedded SQLite + FTS5; declare a schema + a `sync` ingester via `defineMirror`/`sqliteMirrorStore`, then `runSync`/`query`, see `api-mirror`); ≳ 10⁸ → an external store. Distinct lifecycle from DataCanvas: a mirror is long-lived and cross-session, refreshed on a schedule; canvas is ephemeral and per-session.
|
|
354
354
|
- **`format()` is the markdown twin of `structuredContent` — make both content-complete.** Different MCP clients forward different surfaces to the model: some (e.g., Claude Code) read `structuredContent` from `output`, others (e.g., Claude Desktop) read `content[]` from `format()`. Both must carry the same data so every client sees the same picture — `format()` just dresses it up with markdown. A thin `format()` that returns only a count or title leaves `content[]`-only clients blind to data that `structuredContent` clients can see. Render all fields the LLM needs, with structured markdown (headers, bold labels, lists) for readability.
|
|
355
355
|
- **Agent-facing context must reach both client surfaces — put it in `enrichment`.** `structuredContent` (from `output`) and `content[]` (from `format()`) are read by different clients. Empty-result notices, the query/filter as the server parsed it, and pagination totals — the context the agent *reasons with*, distinct from the domain payload — reach only `content[]` if hand-authored into `format()` text alone, leaving `structuredContent`-only clients (Claude Code) blind. (The reverse can't happen: `format-parity` drags every `output` field into `format()`, so `output`-authored context already reaches both.) An `enrichment` block — the success-path counterpart to `errors[]`, populated via `ctx.enrich(...)` — reaches both automatically: merged into `structuredContent`, advertised as `output.extend(enrichment)`, mirrored into a `content[]` trailer, no `format()` entry needed. How each field renders in that trailer is a per-tool call — a kind-tag (`notice`/`total`/`echo`/`delta`) when a canonical form fits, a domain key like `totalFound` otherwise, and an `enrichmentTrailer.render` for any structured (object/array) field so it doesn't ship as a JSON blob. See `add-tool`'s **Tool Response Design**.
|
|
@@ -400,6 +400,21 @@ query: z.record(z.unknown()).optional()
|
|
|
400
400
|
|
|
401
401
|
The pattern: name the shortcut for what it does (`text_search`, `name_search`), document what it expands to, and point to the full parameter for advanced use. Validate that at least one of the two is provided.
|
|
402
402
|
|
|
403
|
+
#### MCP-side list filtering
|
|
404
|
+
|
|
405
|
+
**Applies when:** an upstream API has no native search, the relevant set is bounded (fits one or a few fetches), and an agent needs to resolve a name → opaque ID. Skip when the API already searches, or when the set is unbounded (bills, votes, filings) — that belongs in the DataCanvas dataframe layer (`*_dataframe_query`), not an in-memory filter.
|
|
406
|
+
|
|
407
|
+
Two params, two behaviors — keep them named distinctly:
|
|
408
|
+
|
|
409
|
+
- **`query`** → **upstream** full-text search. The API does the work; it may honor operators and ranking.
|
|
410
|
+
- **a local filter param** → **fetched-then-filtered on our side**. Name it for the mechanic: `filter` or `nameContains` (the latter self-documents the local, name-keyed half of the split). Don't overload `query` for it — the two have different semantics and different cost.
|
|
411
|
+
|
|
412
|
+
**Earns-its-keep gate — all must hold:** bounded set; no native upstream search; real scan pain (opaque IDs, a large/unordered list, or a default page that hides relevant rows); and it filters the natural lookup key (name/title). When any fails, skip it — paginate, or send the agent to upstream `query`.
|
|
413
|
+
|
|
414
|
+
**Correctness: filter the *complete* bounded set, not the current page.** Fetch up to the cap (or page through) before filtering — filtering one page returns a misleading partial slice.
|
|
415
|
+
|
|
416
|
+
**Matching: strict token match is the default.** Normalize (lowercase, strip punctuation/diacritics) and require every query token to appear, so word order and missing interior words still match. That strict core is the ~90% case, needs no fuzzy library, and is too small to centralize (~6 lines — guidance, not a shared helper). Add a fuzzy fallback **only when a caller genuinely needs typo tolerance** (an LLM caller rarely does): fire it only when the strict match is empty, score against the best-matching *token* in each name (not the whole string) and **cap** the results — or one short query clears the threshold against dozens of long multi-word names — and label its hits `approximate`. Often a bare "no match — call the unfiltered list to browse" beats an `approximate` guess: it lets the model self-correct instead of committing to the wrong record. See `add-tool` for the param + handler implementation.
|
|
417
|
+
|
|
403
418
|
#### Error design
|
|
404
419
|
|
|
405
420
|
Errors are part of the tool's interface — design them during the design phase, not as an afterthought. Three aspects: **the contract** (which failures are public), **classification** (what error code), and **messaging** (what the LLM reads).
|
|
@@ -489,7 +504,7 @@ Skip for purely data/action-oriented servers.
|
|
|
489
504
|
|
|
490
505
|
**Server-as-service.** When the server IS the source of truth (knowledge graph, in-memory task tracker, local scratchpad, embedded inference wrapper), the resilience table below doesn't apply — there's no upstream to retry. The design questions shift to state management: what's tenant-scoped vs. global, what TTLs apply, what survives a restart, what the storage backend is. Plan persistence via `ctx.state` for tenant-scoped KV (auto-namespaced by `tenantId`), or use a `StorageService` provider directly when data must cross tenants. Service init still happens in `setup()`, accessed via `getMyService()` at request time. Calls within the server are local and synchronous-ish — the API-efficiency table below also doesn't apply.
|
|
491
506
|
|
|
492
|
-
**
|
|
507
|
+
**Analytical API servers: DataCanvas is one option.** For servers that fetch **analytical** data — result sets an agent runs SQL over (aggregate, group, join, time-series) — and want to expose a SQL workspace, the framework's optional `DataCanvas` primitive (Tier 3, opt-in via `CANVAS_PROVIDER_TYPE=duckdb`) handles lifecycle, ID generation, eviction, and export wiring so you don't design your own. **It earns its keep on shape, not size:** a discovery/search surface returning categorical metadata (titles, IDs, types) — where the workflow is find-the-record-then-drill-in — does *not* qualify even when the result is large; resolve names over a bounded set with [MCP-side list filtering](#mcp-side-list-filtering) instead. **If you opt in, the consumer tools are mandatory:** a tool that emits a `canvas_id` MUST be paired with a `dataframe_query` (and `dataframe_describe`) tool in the same surface — a `canvas_id` with no query tool is dead output the agent can't reach. Surface `canvas_id` as an optional input on register/query/export tools; the framework mints on omit and resolves on match. Tools access it via `ctx.core.canvas?` (undefined when disabled or running on Workers — DuckDB has no V8-isolate build). See `api-canvas` for the full reference.
|
|
493
508
|
|
|
494
509
|
For services wrapping external APIs, plan the resilience layer.
|
|
495
510
|
|
|
@@ -629,6 +644,7 @@ Items without an `If …:` prefix apply to every design. Conditional items only
|
|
|
629
644
|
- [ ] Design doc written to `docs/design.md`
|
|
630
645
|
- [ ] Design confirmed with user (or user pre-authorized implementation)
|
|
631
646
|
- [ ] **If ops share a noun:** related operations consolidated under one tool with `mode`/`operation` enum
|
|
647
|
+
- [ ] **If an upstream API has no native search but the relevant set is bounded:** MCP-side list filtering considered — a distinct local filter param (`filter`/`nameContains`, not `query`), filtering the full set, strict token match (fuzzy only when a caller needs typo tolerance)
|
|
632
648
|
- [ ] **If the server has workflow tools:** call-flow documented (upstream sequence + mode arms) in design doc's Workflow Analysis
|
|
633
649
|
- [ ] **If state-aware procedural guidance adds value:** instruction tool considered with `nextToolSuggestions` pre-filled from diagnostics
|
|
634
650
|
- [ ] **If workflow tools have destructive modes:** destructive arm guarded by `ctx.elicit` when available, with `destructiveHint` annotation as fallback for non-interactive clients
|
|
@@ -639,5 +655,5 @@ Items without an `If …:` prefix apply to every design. Conditional items only
|
|
|
639
655
|
- [ ] **If the server has external deps or shared state:** service layer planned (or explicitly skipped with reasoning)
|
|
640
656
|
- [ ] **If services wrap external APIs:** resilience planned (retry boundary, backoff, parse classification)
|
|
641
657
|
- [ ] **If multi-source server:** each source has its own service with independent auth/retry/rate-limit config. Fallback chains or fan-out strategy documented per tool. Output includes source provenance.
|
|
642
|
-
- [ ] **If exposing a SQL/analytical workspace
|
|
658
|
+
- [ ] **If exposing a SQL/analytical workspace is in scope:** DataCanvas considered (`api-canvas` skill), and it earns its keep on *analytical* fit (an agent would SQL it), not row count — a discovery/search surface of categorical metadata doesn't qualify. Any tool emitting a `canvas_id` is paired with a `dataframe_query` (+ `dataframe_describe`) tool in the same surface — a token with no query tool is dead output
|
|
643
659
|
- [ ] **If the server needs runtime config:** env vars identified in `server-config.ts`
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Pick and run a multi-phase workflow that chains foundational task skills (`git-wrapup`, `release-and-publish`, `maintenance`, `field-test`, `setup`, etc.) end-to-end. Routes user intent to a workflow file under `workflows/` — greenfield builds, maintenance + release, field-test + fix, or known-work + release. Single source for the universal rules (no commits without authorization, no destructive git, no marketing language), the orchestrator posture (own the goal, ground sub-agents in primary sources, verify against the goal), and the sub-agent strategy (orient block, parallel fanout, isolation, normalization) that apply across every workflow. Sub-agents are an optional capability — workflows run linearly when fanout isn't available.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.2"
|
|
8
8
|
audience: internal
|
|
9
9
|
type: workflow
|
|
10
10
|
---
|
|
@@ -157,7 +157,7 @@ For N targets in a phase:
|
|
|
157
157
|
3. Collect their reports
|
|
158
158
|
4. Verify with a read-only orchestrator check before advancing to the next phase
|
|
159
159
|
|
|
160
|
-
**Barriers only where gates sit.** Step 4's "advance to the next phase" implies a barrier — collect every target's phase-N result before any target starts phase N+1. That barrier is only required when a gate sits between the phases: a human decision (authorization, version-bump intent) or cross-target synthesis (the roll-up). Where no gate intervenes, a target may flow through consecutive phases independently — tier-3 platforms pipeline this for wall-clock, and even hand-spawned runs can let one sub-agent carry a target across adjacent gate-free phases. Keep the barrier at gate boundaries; drop it elsewhere.
|
|
160
|
+
**Barriers only where gates sit.** Step 4's "advance to the next phase" implies a barrier — collect every target's phase-N result before any target starts phase N+1. That barrier is only required when a gate sits between the phases: a human decision (authorization, version-bump intent) or cross-target synthesis (the roll-up). Where no gate intervenes, a target may flow through consecutive phases independently — tier-3 platforms pipeline this for wall-clock, and even hand-spawned runs can let one sub-agent carry a target across adjacent gate-free phases. Keep the barrier at gate boundaries; drop it elsewhere. Each workflow's phases table encodes this directly: the `Gate after` column marks every boundary as `barrier` (with a terse reason) or `gate-free` so the spawn/round structure is derivable without re-derivation.
|
|
161
161
|
|
|
162
162
|
### Editor / wrap-up separation
|
|
163
163
|
|