@cyanheads/mcp-ts-core 0.13.8 → 0.13.10
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 +51 -24
- package/CLAUDE.md +51 -24
- package/README.md +10 -10
- package/changelog/0.13.x/0.13.10.md +118 -0
- package/changelog/0.13.x/0.13.9.md +113 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +31 -9
- package/dist/config/index.js.map +1 -1
- package/dist/core/app.d.ts +6 -3
- package/dist/core/app.d.ts.map +1 -1
- package/dist/core/app.js +21 -5
- package/dist/core/app.js.map +1 -1
- package/dist/core/context.d.ts +114 -21
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +40 -0
- package/dist/core/context.js.map +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/serverManifest.d.ts +6 -0
- package/dist/core/serverManifest.d.ts.map +1 -1
- package/dist/core/serverManifest.js +6 -0
- package/dist/core/serverManifest.js.map +1 -1
- package/dist/core/worker.d.ts +6 -0
- package/dist/core/worker.d.ts.map +1 -1
- package/dist/core/worker.js +1 -0
- package/dist/core/worker.js.map +1 -1
- package/dist/linter/rules/error-contract-rules.d.ts +3 -44
- package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
- package/dist/linter/rules/error-contract-rules.js +8 -144
- package/dist/linter/rules/error-contract-rules.js.map +1 -1
- package/dist/linter/rules/index.d.ts +1 -1
- package/dist/linter/rules/index.d.ts.map +1 -1
- package/dist/linter/rules/index.js +1 -1
- package/dist/linter/rules/index.js.map +1 -1
- package/dist/linter/rules/resource-rules.d.ts.map +1 -1
- package/dist/linter/rules/resource-rules.js +1 -2
- package/dist/linter/rules/resource-rules.js.map +1 -1
- package/dist/linter/rules/tool-rules.d.ts +2 -1
- package/dist/linter/rules/tool-rules.d.ts.map +1 -1
- package/dist/linter/rules/tool-rules.js +37 -3
- package/dist/linter/rules/tool-rules.js.map +1 -1
- package/dist/mcp-server/handlerContext.d.ts +26 -13
- package/dist/mcp-server/handlerContext.d.ts.map +1 -1
- package/dist/mcp-server/handlerContext.js +32 -17
- package/dist/mcp-server/handlerContext.js.map +1 -1
- package/dist/mcp-server/inputRequired.d.ts +133 -12
- package/dist/mcp-server/inputRequired.d.ts.map +1 -1
- package/dist/mcp-server/inputRequired.js +192 -20
- package/dist/mcp-server/inputRequired.js.map +1 -1
- package/dist/mcp-server/prompts/prompt-registration.d.ts +10 -2
- package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
- package/dist/mcp-server/prompts/prompt-registration.js +49 -11
- package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
- package/dist/mcp-server/resources/resource-registration.d.ts +4 -2
- package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
- package/dist/mcp-server/resources/resource-registration.js +6 -4
- package/dist/mcp-server/resources/resource-registration.js.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts +4 -3
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +42 -14
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
- package/dist/mcp-server/server.d.ts +9 -0
- package/dist/mcp-server/server.d.ts.map +1 -1
- package/dist/mcp-server/server.js +14 -13
- package/dist/mcp-server/server.js.map +1 -1
- package/dist/mcp-server/tools/tool-registration.d.ts +7 -3
- package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
- package/dist/mcp-server/tools/tool-registration.js +9 -5
- package/dist/mcp-server/tools/tool-registration.js.map +1 -1
- package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts +163 -40
- package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/inputPrevalidation.js +330 -114
- package/dist/mcp-server/tools/utils/inputPrevalidation.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +40 -19
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js +387 -130
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
- package/dist/mcp-server/transports/http/httpErrorHandler.d.ts.map +1 -1
- package/dist/mcp-server/transports/http/httpErrorHandler.js +7 -1
- package/dist/mcp-server/transports/http/httpErrorHandler.js.map +1 -1
- package/dist/mcp-server/transports/http/httpTransport.d.ts.map +1 -1
- package/dist/mcp-server/transports/http/httpTransport.js +65 -9
- package/dist/mcp-server/transports/http/httpTransport.js.map +1 -1
- package/dist/mcp-server/transports/stdio/stdioTransport.d.ts +9 -5
- package/dist/mcp-server/transports/stdio/stdioTransport.d.ts.map +1 -1
- package/dist/mcp-server/transports/stdio/stdioTransport.js +9 -5
- package/dist/mcp-server/transports/stdio/stdioTransport.js.map +1 -1
- package/dist/services/canvas/core/CanvasRegistry.d.ts +6 -2
- package/dist/services/canvas/core/CanvasRegistry.d.ts.map +1 -1
- package/dist/services/canvas/core/CanvasRegistry.js +7 -3
- package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts +82 -18
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +620 -328
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.js.map +1 -1
- package/dist/services/canvas/providers/duckdb/exportWriter.d.ts +11 -7
- package/dist/services/canvas/providers/duckdb/exportWriter.d.ts.map +1 -1
- package/dist/services/canvas/providers/duckdb/exportWriter.js +19 -16
- package/dist/services/canvas/providers/duckdb/exportWriter.js.map +1 -1
- package/dist/services/mirror/core/defineMirror.d.ts +1 -0
- package/dist/services/mirror/core/defineMirror.d.ts.map +1 -1
- package/dist/services/mirror/core/defineMirror.js +1 -0
- package/dist/services/mirror/core/defineMirror.js.map +1 -1
- package/dist/testing/index.d.ts +17 -2
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +21 -7
- package/dist/testing/index.js.map +1 -1
- package/dist/types-global/errors.d.ts +18 -15
- package/dist/types-global/errors.d.ts.map +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/internal/error-handler/errorHandler.d.ts +5 -4
- package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
- package/dist/utils/internal/error-handler/errorHandler.js +9 -7
- package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
- package/dist/utils/internal/error-handler/types.d.ts +3 -1
- package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
- package/dist/utils/internal/performance.d.ts +4 -2
- package/dist/utils/internal/performance.d.ts.map +1 -1
- package/dist/utils/internal/performance.js +8 -6
- package/dist/utils/internal/performance.js.map +1 -1
- package/dist/utils/internal/telemetryMessages.d.ts +0 -1
- package/dist/utils/internal/telemetryMessages.d.ts.map +1 -1
- package/dist/utils/internal/telemetryMessages.js +0 -1
- package/dist/utils/internal/telemetryMessages.js.map +1 -1
- package/dist/utils/network/pacer.d.ts +38 -5
- package/dist/utils/network/pacer.d.ts.map +1 -1
- package/dist/utils/network/pacer.js +87 -25
- package/dist/utils/network/pacer.js.map +1 -1
- package/dist/utils/telemetry/attributes.d.ts +10 -5
- package/dist/utils/telemetry/attributes.d.ts.map +1 -1
- package/dist/utils/telemetry/attributes.js +10 -5
- package/dist/utils/telemetry/attributes.js.map +1 -1
- package/framework-skills/add-app-tool/SKILL.md +3 -3
- package/framework-skills/add-export/SKILL.md +5 -16
- package/framework-skills/add-prompt/SKILL.md +7 -3
- package/framework-skills/add-resource/SKILL.md +7 -5
- package/framework-skills/add-service/SKILL.md +3 -12
- package/framework-skills/add-test/SKILL.md +6 -3
- package/framework-skills/add-tool/SKILL.md +40 -42
- package/framework-skills/api-auth/SKILL.md +2 -2
- package/framework-skills/api-canvas/SKILL.md +17 -8
- package/framework-skills/api-config/SKILL.md +5 -4
- package/framework-skills/api-context/SKILL.md +168 -42
- package/framework-skills/api-errors/SKILL.md +48 -51
- package/framework-skills/api-linter/SKILL.md +30 -35
- package/framework-skills/api-mirror/SKILL.md +2 -1
- package/framework-skills/api-telemetry/SKILL.md +14 -10
- package/framework-skills/api-testing/SKILL.md +43 -11
- package/framework-skills/api-utils/SKILL.md +2 -2
- package/framework-skills/api-workers/SKILL.md +3 -1
- package/framework-skills/design-mcp-server/SKILL.md +6 -6
- package/framework-skills/field-test/SKILL.md +5 -5
- package/framework-skills/git-wrapup/SKILL.md +8 -6
- package/framework-skills/orchestrations/SKILL.md +7 -6
- package/framework-skills/orchestrations/workflows/field-test-fix.md +9 -19
- package/framework-skills/orchestrations/workflows/fix-wrapup-release.md +7 -7
- package/framework-skills/orchestrations/workflows/greenfield-build.md +8 -5
- package/framework-skills/orchestrations/workflows/maintenance-release.md +8 -8
- package/framework-skills/polish-docs-meta/SKILL.md +4 -4
- package/framework-skills/release-and-publish/SKILL.md +8 -6
- package/framework-skills/release-pr-review/SKILL.md +38 -24
- package/framework-skills/report-issue-framework/SKILL.md +7 -5
- package/framework-skills/report-issue-local/SKILL.md +8 -6
- package/framework-skills/security-pass/SKILL.md +14 -13
- package/package.json +6 -5
- package/scripts/devcheck.ts +7 -6
- package/scripts/install-otel.ts +84 -0
- package/scripts/lint-mcp.ts +87 -27
- package/scripts/lint-packaging.ts +226 -4
- package/scripts/release-github.ts +117 -5
- package/templates/.env.example +2 -0
- package/templates/AGENTS.md +5 -4
- package/templates/CLAUDE.md +5 -4
- package/templates/Dockerfile +67 -50
- package/templates/_.mcpbignore +2 -0
- package/templates/src/mcp-server/tools/definitions/echo.tool.ts +3 -8
|
@@ -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.32"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -19,8 +19,8 @@ Tools use the `tool()` builder from `@cyanheads/mcp-ts-core`. Each tool lives in
|
|
|
19
19
|
2. **Determine if it needs input the caller may not supply** — a confirmation, a choice, the client's roots — which makes it a multi-round-trip handler (`ctx.requestInput` / `ctx.inputs`, see `api-context`)
|
|
20
20
|
3. **Create the file** at `src/mcp-server/tools/definitions/{{tool-name}}.tool.ts`
|
|
21
21
|
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)
|
|
22
|
-
5. **Run `bun run devcheck`** to verify —
|
|
23
|
-
6. **Smoke-test** with `bun run rebuild && bun run start:stdio` (or `start:http`)
|
|
22
|
+
5. **Run `bun run devcheck`** to verify — it applies Biome's formatting fixes as it runs
|
|
23
|
+
6. **Smoke-test** with `bun run rebuild && bun run start:stdio < /dev/null` (or `start:http`) — the `Core services constructed` log record must list the new tool in its `tools` field (the message text shows only counts); if it doesn't, the tool never reached `createApp()`
|
|
24
24
|
|
|
25
25
|
## Naming
|
|
26
26
|
|
|
@@ -80,8 +80,8 @@ export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
|
|
|
80
80
|
// `recovery` is required (≥ 5 words) — it's the agent's next move when this
|
|
81
81
|
// failure fires. Forcing function for thoughtful guidance: placeholders like
|
|
82
82
|
// "Try again." get flagged by the linter. The contract `recovery` is the
|
|
83
|
-
// single source of truth for what flows to the wire —
|
|
84
|
-
//
|
|
83
|
+
// single source of truth for what flows to the wire — the framework sends it
|
|
84
|
+
// with any failure carrying the reason and no hint of its own.
|
|
85
85
|
errors: [
|
|
86
86
|
{ reason: 'queue_full', code: JsonRpcErrorCode.RateLimited,
|
|
87
87
|
when: 'Local queue at capacity.', retryable: true,
|
|
@@ -95,10 +95,9 @@ export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
|
|
|
95
95
|
// Without: throw via factories (`notFound`, `validationError`, …) or plain `Error`.
|
|
96
96
|
const items = await search(input);
|
|
97
97
|
if (queue.full()) {
|
|
98
|
-
// Static recovery —
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
throw ctx.fail('queue_full', undefined, { ...ctx.recoveryFor('queue_full') });
|
|
98
|
+
// Static recovery — the string lives in errors[] above, and the framework
|
|
99
|
+
// puts it on both client surfaces as `data.recovery.hint`.
|
|
100
|
+
throw ctx.fail('queue_full');
|
|
102
101
|
}
|
|
103
102
|
// Surface what the agent reasons with — echoed query, true total — on BOTH
|
|
104
103
|
// client surfaces, with no format() plumbing. An empty result is a notice,
|
|
@@ -137,29 +136,25 @@ A handler that needs something the caller didn't supply returns `ctx.requestInpu
|
|
|
137
136
|
import { inputRequired, tool, z } from '@cyanheads/mcp-ts-core';
|
|
138
137
|
import { validationError } from '@cyanheads/mcp-ts-core/errors';
|
|
139
138
|
|
|
140
|
-
const
|
|
139
|
+
const Choice = z.object({ region: z.enum(['us', 'eu']).describe('Region to deploy to.') });
|
|
141
140
|
|
|
142
141
|
export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
|
|
143
142
|
description: '{{TOOL_DESCRIPTION}}',
|
|
144
143
|
input: z.object({ /* ... */ }),
|
|
145
144
|
output: z.object({ /* ... */ }),
|
|
146
|
-
annotations: { destructiveHint: true },
|
|
147
145
|
|
|
148
146
|
handler(input, ctx) {
|
|
147
|
+
// A declined or cancelled prompt is a dead end — don't re-ask it.
|
|
148
|
+
const view = ctx.inputs.view('region');
|
|
149
|
+
if (view.kind === 'elicit' && view.action !== 'accept') {
|
|
150
|
+
throw validationError(`User ${view.action} the region prompt.`);
|
|
151
|
+
}
|
|
149
152
|
// Read what a prior round collected before asking for anything.
|
|
150
|
-
const answer = ctx.inputs.accepted('
|
|
153
|
+
const answer = ctx.inputs.accepted('region', Choice);
|
|
151
154
|
if (!answer) {
|
|
152
|
-
// A declined or cancelled prompt is a dead end — don't re-ask it.
|
|
153
|
-
const view = ctx.inputs.view('confirm');
|
|
154
|
-
if (view.kind === 'elicit' && view.action !== 'accept') {
|
|
155
|
-
throw validationError(`User ${view.action} the confirmation.`);
|
|
156
|
-
}
|
|
157
155
|
return ctx.requestInput({
|
|
158
156
|
inputRequests: {
|
|
159
|
-
|
|
160
|
-
message: `Proceed with ${input.target}?`,
|
|
161
|
-
requestedSchema: Confirm,
|
|
162
|
-
}),
|
|
157
|
+
region: inputRequired.elicit({ message: 'Which region?', requestedSchema: Choice }),
|
|
163
158
|
},
|
|
164
159
|
});
|
|
165
160
|
}
|
|
@@ -169,6 +164,8 @@ export const {{TOOL_EXPORT}} = tool('{{tool_name}}', {
|
|
|
169
164
|
});
|
|
170
165
|
```
|
|
171
166
|
|
|
167
|
+
**A confirmation before a destructive step is different.** `ctx.inputs` only carries response kinds the client declared, but a client that declared `elicitation` can still send an "accepted" answer on a call nothing asked, and any `requestState` replays within its lifetime. A consent gate stores `{ operation, clientId, subject, target, contentHash }` in `ctx.state` under a random id, sends only that id as `requestState`, redeems the record before anything else in the handler, and asks again on an unknown, used, or expired id or on any field that differs from this call — the full handler, and the concurrency limit an action that must not repeat has to design around, are in `api-context` § *Consent gates*. Pair it with `MCP_REQUEST_STATE_KEY` so a retry can only carry state this server minted.
|
|
168
|
+
|
|
172
169
|
Write it as `return ctx.requestInput(...)` — the `never` return type makes it valid in return position for any output, and it is what lets TypeScript narrow the line below. Full reference (`inputRequired.elicitUrl` / `.createMessage` / `.listRoots`, `requestState`, decline handling): `framework-skills/api-context`.
|
|
173
170
|
|
|
174
171
|
### Registration
|
|
@@ -281,7 +278,7 @@ Two limits worth knowing when you write a schema:
|
|
|
281
278
|
|
|
282
279
|
### Three things the framework fixes before the schema sees the arguments
|
|
283
280
|
|
|
284
|
-
Strict input is right for a misspelling the caller can fix, and wrong when the arguments the model wrote were correct and something between the model and the schema was not. An ordered step inside `parseToolArguments` covers those cases: **drop client-added keys → key aliases → parse → on failure, repair and one re-parse
|
|
281
|
+
Strict input is right for a misspelling the caller can fix, and wrong when the arguments the model wrote were correct and something between the model and the schema was not. An ordered step inside `parseToolArguments` covers those cases: **drop client-added keys → key aliases → parse → on failure, repair and one re-parse**, plus one alias-first retry when that still fails and the drop discarded a key (below). All three stages are on by default and none changes what `tools/list` advertises. A call they rescue carries nothing about them in its response — each change in the attempt your handler receives emits a debug log and a counter (`mcp.input.ignored_key`, `mcp.input.aliased`, `mcp.input.coerced`) instead, so a new client artifact surfaces in telemetry rather than as a failed call. A call they cannot rescue is rejected with the first order's rewrites and underscore-rule drops reported, as `data.input` and as closing hint sentences (`Validated query as targetQuery.`, `Dropped undeclared key _max.`), because the issues name only the keys that were validated and the caller could not otherwise tell a bad value from a moved or discarded key.
|
|
285
282
|
|
|
286
283
|
**1. Client-added root keys are dropped.** Some clients put their own keys inside `arguments`: a placeholder when the model sends none, a call description, a call id, or a `_meta` block that belongs on `params`. The model never wrote them and cannot remove them, so the retry fails identically. An undeclared root key is dropped when it is underscore-prefixed or on the built-in list (`_meta`, `tool_call_description`, `toolCallId`). Three boundaries: a declared key is never dropped (on a union root, that means every variant's keys); an author-opened root is left alone; and a tool declaring any underscore-prefixed key of its own switches the underscore rule off — otherwise a misspelled `_cursor` would vanish silently, which is the failure strict input exists to prevent.
|
|
287
284
|
|
|
@@ -297,9 +294,11 @@ export const drugProfile = tool('drug_profile', {
|
|
|
297
294
|
|
|
298
295
|
Alongside those, an undeclared key whose case-folded form (`-`/`_` stripped, lowercased) names exactly one declared key is rewritten too — `max_results`, `Max-Results`, and `MAXRESULTS` all reach a declared `maxResults`, with nothing declared. Neither half advertises anything: `inputSchema` is byte-identical with or without `inputAliases`, so the canonical key keeps its place in `required` and the model is still told to use it.
|
|
299
296
|
|
|
300
|
-
Declare an alias where the meaning is certain and the mapping is one-to-one — a sibling tool's spelling for the same concept, the upstream API's own name, a shorthand weaker models reach for. It is not fuzzy matching: a key matching no alias and no declared key is still rejected by name, with the accepted-key hint. Four boundaries: a rewrite applies only when the target key is absent (alias *and* target present fails exactly as it does today); an author-opened root is never rewritten; a union root resolves against the variant the discriminator selects, and rewrites nothing when the discriminator is absent or unrecognized; and a `headerParam`-designated target is never rewritten *to* — the SDK cross-checks the `Mcp-Param-<Name>` header against the raw body before dispatch, so a later rewrite would hand your handler a value no intermediary attested. `lint:mcp` rejects an alias that shadows a declared key, names a target that does not exist, or is ambiguous against another alias or key (`input-alias-conflict`).
|
|
297
|
+
Declare an alias where the meaning is certain and the mapping is one-to-one — a sibling tool's spelling for the same concept, the upstream API's own name, a shorthand weaker models reach for. It is not fuzzy matching: a key matching no alias and no declared key is still rejected by name, with the accepted-key hint. Four boundaries: a rewrite applies only when the target key is absent (alias *and* target present fails exactly as it does today); an author-opened root is never rewritten; a union root resolves against the variant the discriminator selects, and rewrites nothing when the discriminator is absent or unrecognized; and a `headerParam`-designated target is never rewritten *to* — the SDK cross-checks the `Mcp-Param-<Name>` header against the raw body before dispatch, so a later rewrite would hand your handler a value no intermediary attested. `lint:mcp` rejects an alias that shadows a declared key, names a target that does not exist or is `headerParam`-designated, or is ambiguous against another alias or key (`input-alias-conflict`).
|
|
298
|
+
|
|
299
|
+
**3. A stringified array or object, or an integer sent for a string, is repaired after the parse fails.** `statusFilter: "[\"RECRUITING\"]"` against `z.array(z.string())`, or `target: "{\"type\":\"path\",\"path\":\"a.md\"}"` against an object or discriminated-union field, is a serialization slip the server can undo with certainty — `JSON.parse` is the exact inverse of the `JSON.stringify` that produced it. So is `station_id: 8654467` against `z.string()`: `String(n)` of a safe integer is the digits the caller sent. That certainty is what separates a repair from the nearest-key guessing strict input refuses. The repair runs *only* on the failure branch, *only* at the paths the rejection's own issues name, once — a value one repair produced is never repaired again, and a number inside one branch of a union field stays as sent — and is kept only if the repaired arguments then pass your schema. So it cannot touch a value that was already valid: a free-text field legitimately holding `"[1,2,3]"` or `"{…}"` is not in the issue list, so it survives untouched even when the same call carries a genuine stringified value in another field. The integer repair is gated further, to issues that say the value failed for being a number (a wrong type, a string-only enum, a union every branch of which refused the type): `-1` against `z.union([z.number().int().positive(), z.string()])` keeps its rejection instead of slipping past your constraint through the string branch, and `-0`, a fraction, or an integer past `Number.MAX_SAFE_INTEGER` is never repaired. Your schema still decides the repaired string — `20260922` against `z.iso.date()` stays rejected. It walks values only: no key is added, dropped, or renamed. When nothing validates, the original rejection is thrown — exactly what the same call gets under `coerce: false`.
|
|
301
300
|
|
|
302
|
-
**
|
|
301
|
+
**When the drop took a key the alias stage wanted.** The drop runs first, so it also discards an underscore spelling of a declared key (`_query` for `query`), a declared `inputAliases: { _q: 'query' }`, and an ignore-listed key a declared alias names. If the call then fails, repair included, the step reruns both stages with the alias stage first, where those keys are rewritten instead, and keeps that retry only if it validates, again with its own repair. The retry never case-folds an ignore-listed key onto a declared one (`_meta` stays the client's even beside a declared `meta`), and still drops an underscore key it cannot resolve. It never changes which calls validate: `{ query: 'abc', _max_results: '12345' }` against an optional `maxResults: z.number()` validates with `_max_results` dropped, so the retry never runs. A call neither order validates gets the retry's rejection, which describes the arguments as the caller meant them: `{ _q: 'ab' }` against `query: z.string().min(3)` reports the too-short `query` and closes `Validated _q as query.`, not a missing `query` beside a dropped `_q`.
|
|
303
302
|
|
|
304
303
|
Turn any stage off per server — there is no per-tool switch:
|
|
305
304
|
|
|
@@ -308,13 +307,13 @@ await createApp({
|
|
|
308
307
|
input: {
|
|
309
308
|
ignoreKeys: ['some_client_field'], // adds to the built-in list; `false` disables the stage
|
|
310
309
|
caseStyleAliases: false, // declared `inputAliases` only
|
|
311
|
-
coerce: false, // never
|
|
310
|
+
coerce: false, // never repair an argument value
|
|
312
311
|
},
|
|
313
312
|
tools: allToolDefinitions,
|
|
314
313
|
});
|
|
315
314
|
```
|
|
316
315
|
|
|
317
|
-
Those three stages are the whole of the framework's input edge: argument **key** names, and
|
|
316
|
+
Those three stages are the whole of the framework's input edge: argument **key** names, and three **value** shapes — a JSON-stringified array or object, which `JSON.parse` inverts with certainty, and a safe integer sent for a string, whose digits `String(n)` restores. Every other value normalization is domain knowledge and belongs to the tool: the case or bare-leaf form of a code, a unit or vocabulary alias, a composite identifier assembled from two arguments, a delimiter-joined list, a spelled-out name. Which variants a given input accepts is decided per input at design time (`design-mcp-server` § *Parameter descriptions*) and applied at the head of the handler, on the unambiguous mappings only.
|
|
318
317
|
|
|
319
318
|
### Multi-mode tools take a discriminated-union input
|
|
320
319
|
|
|
@@ -369,7 +368,7 @@ input: z.object({
|
|
|
369
368
|
|
|
370
369
|
The emitted property carries `"x-mcp-header": "Region"` and nothing else about the field changes — description, type, validation, and requiredness are untouched. Order does not matter: `headerParam(z.string(), 'Region').describe('…')` and `headerParam(z.string().describe('…'), 'Region')` are the same schema.
|
|
371
370
|
|
|
372
|
-
**It mirrors, it does not relocate.** When the body carries a value for a designated property, the matching `Mcp-Param-<Name>` header MUST be present and decode to an equal value; the SDK cross-checks the pair before dispatch and rejects a disagreement with `-32020` (`HeaderMismatch`, HTTP `400`). Absent or `null` in the body means no header is expected. **Your handler still reads the argument from `input`** — there is nothing new to do in the handler body.
|
|
371
|
+
**It mirrors, it does not relocate.** When the body carries a value for a designated property, the matching `Mcp-Param-<Name>` header MUST be present and decode to an equal value; the SDK cross-checks the pair before dispatch and rejects a disagreement with `-32020` (`HeaderMismatch`, HTTP `400`). Absent or `null` in the body means no header is expected. **Your handler still reads the argument from `input`** — there is nothing new to do in the handler body. Browser clients need nothing extra either: the HTTP transport's CORS preflight allows `Mcp-Param-<Name>` for every designation on a registered tool, for each origin `MCP_ALLOWED_ORIGINS` accepts.
|
|
373
372
|
|
|
374
373
|
**Where a designation is legal.** The property must be primitive-typed (`string`, `integer`, `number`, `boolean`) and statically reachable through a chain of `properties` keys. Top-level and nested `z.object()` fields qualify. These do not:
|
|
375
374
|
|
|
@@ -655,10 +654,9 @@ export const fetchArticles = tool('fetch_articles', {
|
|
|
655
654
|
input: z.object({ pmids: z.array(z.string()).describe('PMIDs to fetch') }),
|
|
656
655
|
output: z.object({ articles: z.array(ArticleSchema).describe('Resolved articles') }),
|
|
657
656
|
async handler(input, ctx) {
|
|
658
|
-
// Static recovery —
|
|
659
|
-
//
|
|
660
|
-
|
|
661
|
-
if (queue.full()) throw ctx.fail('queue_full', undefined, { ...ctx.recoveryFor('queue_full') });
|
|
657
|
+
// Static recovery — the framework fills the contract recovery onto the wire,
|
|
658
|
+
// mirrored into content[] text for format()-only clients.
|
|
659
|
+
if (queue.full()) throw ctx.fail('queue_full');
|
|
662
660
|
|
|
663
661
|
const articles = await fetch(input.pmids);
|
|
664
662
|
if (articles.length === 0) {
|
|
@@ -673,7 +671,7 @@ export const fetchArticles = tool('fetch_articles', {
|
|
|
673
671
|
});
|
|
674
672
|
```
|
|
675
673
|
|
|
676
|
-
|
|
674
|
+
**The declared `recovery` reaches the wire on its own.** When a failure whose `data.reason` names a contract entry leaves the handler without `data.recovery`, the framework sets `data.recovery.hint` to that entry's `recovery` — both client surfaces, the error log record, and `runToolContract` alike — whatever code it was thrown with. Pass `{ recovery: { hint: \`…${dynamic}…\` } }` when you need runtime context; a throw-site hint always wins. `ctx.recoveryFor(reason)` still resolves the entry into `{ recovery: { hint } }` for a hint that must ride the thrown error itself. The contract is the single source of truth — write the recovery once, lint validates it ≥5 words, the framework carries it to every failure with that reason.
|
|
677
675
|
|
|
678
676
|
**Baseline codes** (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring. Wire-level behavior is identical when the contract is omitted, but you lose the type-checked `ctx.fail`, the `tools/list` advertisement, and conformance lint coverage — declare a contract whenever the tool has a domain-specific failure mode.
|
|
679
677
|
|
|
@@ -681,12 +679,12 @@ export const fetchArticles = tool('fetch_articles', {
|
|
|
681
679
|
|
|
682
680
|
#### Service-layer throws
|
|
683
681
|
|
|
684
|
-
API-wrapping tools usually delegate to a service: `await ncbi.fetch(input, ctx)`. The throw lives in the service, not the handler. Services accept `ctx` (the unified Context) so they can call `ctx.log`, `ctx.
|
|
682
|
+
API-wrapping tools usually delegate to a service: `await ncbi.fetch(input, ctx)`. The throw lives in the service, not the handler. Services accept `ctx` (the unified Context) so they can call `ctx.log`, `ctx.state`, etc. The handler doesn't catch — it just bubbles, and the framework's auto-classifier preserves `data` on the wire.
|
|
685
683
|
|
|
686
|
-
The contract entry on the tool and the `data: { reason }` on the service throw need to use the **same reason string** so the two sides line up.
|
|
684
|
+
The contract entry on the tool and the `data: { reason }` on the service throw need to use the **same reason string** so the two sides line up. That reason is all it takes: the framework fills the calling tool's declared `recovery` onto the failure as it leaves the handler.
|
|
687
685
|
|
|
688
686
|
```typescript
|
|
689
|
-
// service — receives ctx; passes data.reason
|
|
687
|
+
// service — receives ctx; passes data.reason
|
|
690
688
|
import type { Context } from '@cyanheads/mcp-ts-core';
|
|
691
689
|
import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
|
|
692
690
|
|
|
@@ -695,9 +693,8 @@ export class NcbiService {
|
|
|
695
693
|
const response = await fetchWithRetry(...);
|
|
696
694
|
if (!response.ok) {
|
|
697
695
|
throw serviceUnavailable(`NCBI returned HTTP ${response.status}`, {
|
|
698
|
-
reason: 'ncbi_unreachable',
|
|
696
|
+
reason: 'ncbi_unreachable', // the caller's declared recovery is filled from this
|
|
699
697
|
status: response.status,
|
|
700
|
-
...ctx.recoveryFor('ncbi_unreachable'), // resolves from caller's contract
|
|
701
698
|
});
|
|
702
699
|
}
|
|
703
700
|
return response.json();
|
|
@@ -717,7 +714,7 @@ export const fetchArticles = tool('fetch_articles', {
|
|
|
717
714
|
});
|
|
718
715
|
```
|
|
719
716
|
|
|
720
|
-
|
|
717
|
+
A tool that declares no matching entry gets no hint — the service doesn't have to know which tool called it.
|
|
721
718
|
|
|
722
719
|
Add `thrownBy: 'service'` to a contract entry the service produces once the handler also throws one of its own. `error-contract-unthrown` reads the handler body alone: as soon as one literal `ctx.fail(` appears there, every declared reason the body does not name is flagged, and the marker is what tells the rule this one is thrown a layer down. Lint-only metadata — the entry stays typed, advertised, and thrown exactly as an unmarked one.
|
|
723
720
|
|
|
@@ -746,8 +743,9 @@ throw serviceUnavailable(`arXiv API returned HTTP ${status}. Retry in a few seco
|
|
|
746
743
|
// clients (Claude Desktop) see the same guidance that structuredContent clients
|
|
747
744
|
// (Claude Code) read from `error.data.recovery.hint`. A hint the message already
|
|
748
745
|
// contains verbatim is dropped from the text rather than stated twice; it stays
|
|
749
|
-
// on structuredContent regardless. `data.reason
|
|
750
|
-
//
|
|
746
|
+
// on structuredContent regardless. `data.reason`, `data.retryable`, and the
|
|
747
|
+
// framework's own `data.requestId` render as a closing
|
|
748
|
+
// `(reason … · not retryable · request <id>)` line; other `data` keys reach
|
|
751
749
|
// structuredContent only.
|
|
752
750
|
import { invalidParams } from '@cyanheads/mcp-ts-core/errors';
|
|
753
751
|
throw invalidParams(
|
|
@@ -884,7 +882,7 @@ return { items: hits };
|
|
|
884
882
|
- [ ] `errors: [...]` contract declared for the tool's domain-specific failure modes — or block deleted if no domain failures apply (baseline codes bubble freely)
|
|
885
883
|
- [ ] Error contract declared inline on this tool — not imported from a shared module, even when other tools have near-identical entries
|
|
886
884
|
- [ ] Long loops check `ctx.signal.aborted` so a cancelled request (or a closed transport) stops the work
|
|
887
|
-
- [ ] If the tool needs caller input it may not have been given: reads `ctx.inputs` first, requests only what is missing via `return ctx.requestInput(...)`, and treats a declined/cancelled response as terminal rather than re-asking
|
|
885
|
+
- [ ] If the tool needs caller input it may not have been given: reads `ctx.inputs` first, requests only what is missing via `return ctx.requestInput(...)`, and treats a declined/cancelled response as terminal rather than re-asking. A destructive confirmation redeems a `ctx.state` consent record bound to the operation, caller, and target (`api-context` § *Consent gates*) rather than trusting the answer alone
|
|
888
886
|
- [ ] 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
|
|
889
887
|
- [ ] If tool returns one large *document* (not a row set) that can overflow context: `outlineOnOverflow()` returns a `full | outline` union so the agent re-calls with `sections: [...]` — not one-sided truncation
|
|
890
888
|
- [ ] If tool is feature-gated: evaluated whether `disabledTool()` wrapper is appropriate (present in manifest but uncallable)
|
|
@@ -893,4 +891,4 @@ return { items: hits };
|
|
|
893
891
|
- [ ] Registered in the project's existing `createApp()` tool list (directly or via barrel)
|
|
894
892
|
- [ ] Test file created via `add-test` skill, or handler tested directly with `createMockContext()`
|
|
895
893
|
- [ ] `bun run devcheck` passes
|
|
896
|
-
- [ ] Smoke-tested with `bun run rebuild && bun run start:stdio` (or `start:http`)
|
|
894
|
+
- [ ] Smoke-tested with `bun run rebuild && bun run start:stdio < /dev/null` (or `start:http`); the `Core services constructed` record lists the new tool in its `tools` field
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Authentication, authorization, and multi-tenancy patterns for `@cyanheads/mcp-ts-core`. Use when implementing auth scopes on tools/resources, configuring auth modes (none/jwt/oauth), working with JWT/OAuth env vars, or understanding how tenantId flows through ctx.state.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.5"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -146,7 +146,7 @@ A `WARNING`-level log is emitted at startup whenever the flag is active so opera
|
|
|
146
146
|
| `DELETE /mcp` | Yes (when auth enabled) — session termination |
|
|
147
147
|
| `OPTIONS /mcp` | No (handled by CORS middleware before auth) |
|
|
148
148
|
|
|
149
|
-
**CORS:** Set `MCP_ALLOWED_ORIGINS` to a comma-separated list of allowed origins, or `*` for open access.
|
|
149
|
+
**CORS:** Set `MCP_ALLOWED_ORIGINS` to a comma-separated list of allowed origins, or `*` for open access. Left unset, only loopback browser origins reach the endpoint. The preflight for an accepted origin allows every request header the server reads: `Content-Type`, `Authorization`, `Mcp-Session-Id`, `MCP-Protocol-Version`, the 2026-07-28 `Mcp-Method` and `Mcp-Name`, `Last-Event-ID` (SSE resume), and one `Mcp-Param-<Name>` per `headerParam` designation on a registered tool — derived from the tool definitions, nothing to configure. Any other origin gets the first four only, so it learns no designation names.
|
|
150
150
|
|
|
151
151
|
**Stdio mode:** No HTTP auth layer. Authorization is handled entirely by the host process.
|
|
152
152
|
|
|
@@ -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, per-table TTL, 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: "2.
|
|
7
|
+
version: "2.6"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -88,7 +88,7 @@ That collapse is also why the capacity hint reads the way it does: under `defaul
|
|
|
88
88
|
|
|
89
89
|
### Advertising the id shape
|
|
90
90
|
|
|
91
|
-
`CanvasIdSchema` is exported from `@cyanheads/mcp-ts-core/canvas` — `z.string().regex(/^[A-Za-z0-9_-]{10}
|
|
91
|
+
`CanvasIdSchema` is exported from `@cyanheads/mcp-ts-core/canvas` — `z.string().regex(/^[A-Za-z0-9_-]{10}$/, message)` with a `.describe()` naming where an id comes from. A tool that declares its `canvas_id` field with it advertises the constraint in `inputSchema`, so a model sees the shape before it calls and an impossible value is rejected at argument validation rather than inside the handler:
|
|
92
92
|
|
|
93
93
|
```ts
|
|
94
94
|
import { CanvasIdSchema } from '@cyanheads/mcp-ts-core/canvas';
|
|
@@ -100,7 +100,7 @@ input: z.object({
|
|
|
100
100
|
}),
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
-
The two halves are independent. On a tool that adopts the shape, `"x"` fails as `InvalidParams` (-32602) with the framework's own `reason: 'invalid_arguments'
|
|
103
|
+
The two halves are independent. On a tool that adopts the shape, `"x"` or a table name like `"df_abc123"` fails as `InvalidParams` (-32602) with the framework's own `reason: 'invalid_arguments'`, and the handler never runs — so `canvas_id_malformed` never fires there. The rejection's message, `data.issues[0].message`, and recovery hint carry the schema's own sentence rather than the bare pattern: *Expected a canvas ID exactly as an earlier response on this server returned it: 10 characters of letters, digits, hyphens, and underscores. A table name is not a canvas ID.* A check message is not part of the emitted JSON Schema, so the advertised `inputSchema` stays `pattern` plus `description`. It covers tools that have not adopted it and ids the registry receives from somewhere other than a validated argument, `importFrom`'s source id in particular. Adopting the shape does not change any existing server's advertised schema until that server adopts it.
|
|
104
104
|
|
|
105
105
|
---
|
|
106
106
|
|
|
@@ -116,6 +116,15 @@ The two halves are independent. On a tool that adopts the shape, `"x"` fails as
|
|
|
116
116
|
|
|
117
117
|
The sweeper runs as an `unref`'d `setInterval` — does not keep the event loop alive on its own. Shutdown via `core.canvas.shutdown(ctx)` (called automatically from `ServerHandle.shutdown()`) stops the sweeper and tears down every active DuckDB instance.
|
|
118
118
|
|
|
119
|
+
### Scratch directory
|
|
120
|
+
|
|
121
|
+
On first use the DuckDB provider creates one private directory, `mcp-canvas-XXXXXX`, under `CANVAS_TEMP_PATH` (the OS temp directory when unset) with `mkdtemp` — mode `0700` on POSIX; on Windows it inherits the parent's ACL, so there the parent must not grant other users access. All scratch I/O stays inside it:
|
|
122
|
+
|
|
123
|
+
- **Spills.** Each canvas gets its own DuckDB `temp_directory` there. DuckDB names spill files by block size alone, so canvases sharing one directory would overwrite each other's evicted blocks once two of them spill past `memory_limit` at the same time.
|
|
124
|
+
- **Staging.** Stream exports and `importFrom` write their transient files directly in it, under `crypto.randomUUID()` names, and unlink them once consumed. The directory's mode is the boundary, not the name.
|
|
125
|
+
|
|
126
|
+
Dropping or expiring a canvas removes its spill directory once the calls still running on it settle, without waiting for them, since those calls can still be reading back the blocks it spilled. `shutdown()` removes the whole private directory once the calls still running against it settle, and does not wait for them: until then the directory stays in place and private, so no other local user can re-create its name and receive what those calls still write. A canvas whose creation straddles the shutdown is refused with `ServiceUnavailable` (-32000). A provider used again afterwards makes a fresh directory. After a crash or `SIGKILL` the directory stays behind, still private, and the next start does not sweep it — remove stale `mcp-canvas-*` directories by hand. A configured `CANVAS_TEMP_PATH` is created if missing and gets no ownership or mode check, so it must not be a directory another local user controls. When the private directory cannot be created, canvas creation fails with `ConfigurationError` (-32008) and the next attempt retries.
|
|
127
|
+
|
|
119
128
|
---
|
|
120
129
|
|
|
121
130
|
## API
|
|
@@ -172,7 +181,7 @@ Querying a table that does not exist throws `NotFound` (`data.reason: 'missing_t
|
|
|
172
181
|
|
|
173
182
|
A `SELECT` that parses but fails to prepare for any other reason — a mistyped column, an unknown scalar or table function, type, or collation, a schema the canvas does not have, an invalid expression — throws `ValidationError` (`data.reason: 'invalid_sql'`) and preserves the DuckDB binder detail in `data.binderMessage` (e.g. `Referenced column "x" not found...`, often with a candidate suggestion). This is distinct from `non_select_statement`, reserved for statements that genuinely aren't `SELECT`s — here the shape is fine, so the agent should fix the named column or function. DuckDB's FROM-first form (`FROM t`, `FROM t SELECT a`) is a `SELECT`: it passes the gate, and one that fails to prepare is classified the same way.
|
|
174
183
|
|
|
175
|
-
A `SELECT` that prepares and then fails on the staged data throws `ValidationError` (`data.reason: 'sql_execution_error'`) with the engine message preserved and a hint pointing at `TRY_CAST` or filtering the offending rows. The split follows DuckDB's own execution-error classes — `Conversion Error`, `Invalid Input Error`, `Out of Range Error` — matched on the message prefix. Engine faults (`IO Error`, `INTERNAL Error`, `Out of Memory Error`, and anything unmatched) stay `DatabaseError
|
|
184
|
+
A `SELECT` that prepares and then fails on the staged data throws `ValidationError` (`data.reason: 'sql_execution_error'`) with the engine message preserved and a hint pointing at `TRY_CAST` or filtering the offending rows. The split follows DuckDB's own execution-error classes — `Conversion Error`, `Invalid Input Error`, `Out of Range Error` — matched on the message prefix. `sql_read_only` and `sql_parse_error` are matched the same way: DuckDB's `Permission Error` or a write refused in `read-only mode`, and `Parser Error`. Engine faults (`IO Error`, `INTERNAL Error`, `Out of Memory Error`, and anything unmatched) stay `DatabaseError` whatever their text says, so an export, import, or spill failing on I/O — `Permission denied`, `Read-only file system` — is never reported to the caller as bad SQL. Every engine message the provider throws has the export root and the [scratch directory](#scratch-directory) replaced with `[path]`, leaving only the part below them (the caller's own export name); the raw engine error stays on `cause` for logs. `DUCKDB_ERROR_REASONS` exports these alongside `SQL_GATE_REASONS`.
|
|
176
185
|
|
|
177
186
|
**Every gate and engine rejection carries `data.recovery.hint`**, which the framework mirrors into `content[]` as a `Recovery:` line — so the guidance reaches `structuredContent`-only and `content[]`-only clients alike. The hints name a capability, never a framework method: an MCP client sees only the consuming server's tool names, so `registerTable()` or `describe()` in a hint is guidance it cannot follow. Write your own hints the same way (see `api-errors`).
|
|
178
187
|
|
|
@@ -229,7 +238,7 @@ const result = await instance.query("SELECT total FROM sales_by_region WHERE reg
|
|
|
229
238
|
|
|
230
239
|
### `instance.importFrom(sourceCanvasId, sourceTableName, options?)`
|
|
231
240
|
|
|
232
|
-
Copy a table from another canvas the caller controls into this one. The lifecycle wrapper validates tenancy on both ids before the provider sees either. Round-trips through a Parquet file
|
|
241
|
+
Copy a table from another canvas the caller controls into this one. The lifecycle wrapper validates tenancy on both ids before the provider sees either. Round-trips through a Parquet file in the provider's [scratch directory](#scratch-directory) so `TIMESTAMP`/`DATE`/`BLOB` columns survive losslessly.
|
|
233
242
|
|
|
234
243
|
```ts
|
|
235
244
|
const imported = await target.importFrom(source.canvasId, 'orders', { asName: 'orders_copy' });
|
|
@@ -246,7 +255,7 @@ Export a canvas table. Path-based exports are sandboxed to `CANVAS_EXPORT_PATH`
|
|
|
246
255
|
// Path target — written inside the sandbox.
|
|
247
256
|
await instance.export('g_with_obs', { format: 'parquet', path: 'observations.parquet' });
|
|
248
257
|
|
|
249
|
-
// Stream target — copied to a file
|
|
258
|
+
// Stream target — copied to a file in the provider's scratch directory, piped to the stream, unlinked.
|
|
250
259
|
await instance.export('g_with_obs', { format: 'csv', stream: writableStream });
|
|
251
260
|
```
|
|
252
261
|
|
|
@@ -298,7 +307,7 @@ If your tool surfaces row data via `structuredContent`, the JSON-safe shape flow
|
|
|
298
307
|
| `CANVAS_PROVIDER_TYPE` | `canvas.providerType` | `none` (also: `duckdb`) |
|
|
299
308
|
| `CANVAS_DEFAULT_MEMORY_LIMIT_MB` | `canvas.defaultMemoryLimitMb` | `1024` |
|
|
300
309
|
| `CANVAS_EXPORT_PATH` | `canvas.exportRootPath` | `./.canvas-exports` |
|
|
301
|
-
| `CANVAS_TEMP_PATH` | `canvas.tempRootPath` |
|
|
310
|
+
| `CANVAS_TEMP_PATH` | `canvas.tempRootPath` | `os.tmpdir()` — parent of the private [scratch directory](#scratch-directory) |
|
|
302
311
|
| `CANVAS_MAX_CANVASES_PER_TENANT` | `canvas.maxCanvasesPerTenant` | `100` |
|
|
303
312
|
| `CANVAS_TTL_MS` | `canvas.ttlMs` | `86_400_000` (24 h) |
|
|
304
313
|
| `CANVAS_ABSOLUTE_CAP_MS` | `canvas.absoluteCapMs` | `604_800_000` (7 d) |
|
|
@@ -563,7 +572,7 @@ Pass `schema` explicitly whenever a column's type can't be read off the first ro
|
|
|
563
572
|
- [ ] Accessor wired in `setup()` callback via `setCanvas(core.canvas)`
|
|
564
573
|
- [ ] Handler guards for canvas availability (`if (!canvas) throw ...`)
|
|
565
574
|
- [ ] `canvas_id` accepted as optional input, returned in output
|
|
566
|
-
- [ ] 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)
|
|
575
|
+
- [ ] 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), and `dataframe_drop` behind its opt-in env flag — wrapped in `disabledTool()` while the flag is off
|
|
567
576
|
- [ ] Canvas earns its keep: the staged data is analytical (an agent would SQL it), not a discovery/search surface of categorical metadata
|
|
568
577
|
- [ ] SQL queries are read-only (enforced by the four-layer gate, but don't attempt writes)
|
|
569
578
|
- [ ] Testing: mock the module-level `getCanvas()` accessor with `vi.spyOn` or a test setup that calls `setCanvas(mockCanvas)`
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Reference for core and server configuration in `@cyanheads/mcp-ts-core`. Covers env var tables with defaults, priority order, server-specific Zod schema pattern, and Workers lazy-parsing requirement.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.23"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -105,7 +105,7 @@ await createApp({ sessionMode: { default: 'stateful', require: 'stateful' } });
|
|
|
105
105
|
| `MCP_HTTP_PORT` | `mcpHttpPort` | `3010` | Port for HTTP transport |
|
|
106
106
|
| `MCP_HTTP_HOST` | `mcpHttpHost` | `127.0.0.1` | Bind address |
|
|
107
107
|
| `MCP_HTTP_ENDPOINT_PATH` | `mcpHttpEndpointPath` | `/mcp` | HTTP endpoint path |
|
|
108
|
-
| `MCP_HTTP_MAX_BODY_BYTES` | `mcpHttpMaxBodyBytes` | `1048576` (1 MiB) | Max **inbound** JSON-RPC request body; oversized requests get `413` before per-request allocation. Does **not** cap upstream data staged into a canvas or response sizes. `0` disables (defer to runtime/proxy). |
|
|
108
|
+
| `MCP_HTTP_MAX_BODY_BYTES` | `mcpHttpMaxBodyBytes` | `1048576` (1 MiB) | Max **inbound** JSON-RPC request body; oversized requests get `413` before per-request allocation. Does **not** cap upstream data staged into a canvas or response sizes. `0` disables (defer to runtime/proxy). The only body limit in force — the SDK's own 4 MiB read cap never engages, so a value above 4 MiB holds as set. |
|
|
109
109
|
| `MCP_HTTP_MAX_PORT_RETRIES` | `mcpHttpMaxPortRetries` | `15` | Rungs of the port ladder walked when a bind collides; each rung tries `port + 1`. See [Port binding](#port-binding) |
|
|
110
110
|
| `MCP_HTTP_PORT_RETRY_DELAY_MS` | `mcpHttpPortRetryDelayMs` | `50` | Delay between port retries (ms) |
|
|
111
111
|
| `MCP_SESSION_MODE` | `mcpSessionMode` | `auto` | `stateless` \| `stateful` \| `auto`; `auto` resolves to `stateful`. Under `stateless`, the 2025-era multi-round-trip shim still runs but its capability gate refuses: each request is served by an instance that never processed `initialize`, so the client-capability view is empty and a `ctx.requestInput` round can never be answered — fail-closed, but unconditional, so the tool is unusable for those clients rather than merely guarded. 2026-07-28 clients and stdio are unaffected. Seed it from code with `createApp({ sessionMode })` — see below |
|
|
@@ -113,7 +113,7 @@ await createApp({ sessionMode: { default: 'stateful', require: 'stateful' } });
|
|
|
113
113
|
| `MCP_HTTP_RESUMABILITY` | `mcpHttpResumability` | `true` | SSE stream replay under stateful HTTP. On by default — selecting a session mode is the opt-in. Kill switch only; no effect on stateless serving or the session-less 2026-07-28 era |
|
|
114
114
|
| `MCP_HTTP_RESUMABILITY_MAX_EVENTS` | `mcpHttpResumabilityMaxEvents` | `512` | Events retained per session for replay; oldest evicted first. Lower it on a server whose tools return large results |
|
|
115
115
|
| `MCP_HTTP_RESUMABILITY_TTL_MS` | `mcpHttpResumabilityTtlMs` | `300000` | 5 min; how long a retained event stays replayable |
|
|
116
|
-
| `MCP_ALLOWED_ORIGINS` | `mcpAllowedOrigins` | — | Comma-separated list;
|
|
116
|
+
| `MCP_ALLOWED_ORIGINS` | `mcpAllowedOrigins` | — | Comma-separated list of browser origins the MCP endpoint accepts; others get `403`. Omitted, CORS is wildcard but only loopback origins pass; `*` accepts any origin (disables DNS-rebinding protection). An accepted origin's preflight also allows `Mcp-Method`, `Mcp-Name`, `Last-Event-ID`, and each tool's `Mcp-Param-<Name>` — see `api-auth` |
|
|
117
117
|
| `MCP_SERVER_RESOURCE_IDENTIFIER` | `mcpServerResourceIdentifier` | — | RFC 8707 resource indicator URL |
|
|
118
118
|
| `MCP_PUBLIC_URL` | `mcpPublicUrl` | — | Public-facing origin for reverse proxies (Cloudflare Tunnel, nginx, ALB) so emitted URLs carry the correct scheme |
|
|
119
119
|
| `MCP_HEARTBEAT_INTERVAL_MS` | `mcpHeartbeatIntervalMs` | `0` (disabled) | Heartbeat ping interval; 0 disables |
|
|
@@ -137,6 +137,7 @@ await createApp({ sessionMode: { default: 'stateful', require: 'stateful' } });
|
|
|
137
137
|
| `MCP_AUTH_MODE` | `mcpAuthMode` | `none` | `none` \| `jwt` \| `oauth` |
|
|
138
138
|
| `MCP_AUTH_SECRET_KEY` | `mcpAuthSecretKey` | — | Required for `jwt` mode; min 32 chars |
|
|
139
139
|
| `MCP_AUTH_DISABLE_SCOPE_CHECKS` | `mcpAuthDisableScopeChecks` | `false` | When `true`, bypasses both `withRequiredScopes` (declared `auth: [...]`) and `checkScopes` (runtime/tenant scopes). Token validation (sig/aud/iss/exp) intact. Logs a `WARNING` at startup. See `api-auth` skill. |
|
|
140
|
+
| `MCP_REQUEST_STATE_KEY` | `mcpRequestStateKey` | — | Opt-in, any auth mode. When set, the framework seals the `requestState` a handler returns with `ctx.requestInput` (bound to the request's `clientId` / `subject` / `tenantId`, valid 900 s) and every server instance rejects any other state as `-32602` `invalid_request_state` before the handler runs; `ctx.inputs.state()` still returns the handler's string. Must be ≥ 32 UTF-8 bytes — shorter fails startup with a `ConfigurationError` naming the variable — and identical on every instance a retry can reach (stateless replicas, Worker isolates, restarts). Unset or empty: no verifier, state round-trips raw. See `api-context` § `requestState` |
|
|
140
141
|
| `OAUTH_ISSUER_URL` | `oauthIssuerUrl` | — | Required for `oauth` mode |
|
|
141
142
|
| `OAUTH_AUDIENCE` | `oauthAudience` | — | Required for `oauth` mode |
|
|
142
143
|
| `OAUTH_JWKS_URI` | `oauthJwksUri` | — | Override JWKS endpoint (otherwise derived from issuer) |
|
|
@@ -166,7 +167,7 @@ await createApp({ sessionMode: { default: 'stateful', require: 'stateful' } });
|
|
|
166
167
|
| `CANVAS_PROVIDER_TYPE` | `canvas.providerType` | `none` | `none` \| `duckdb`. Set to `duckdb` to enable `core.canvas`. Fails closed on Cloudflare Workers (DuckDB has no V8-isolate build). |
|
|
167
168
|
| `CANVAS_DEFAULT_MEMORY_LIMIT_MB` | `canvas.defaultMemoryLimitMb` | `1024` | Per-canvas DuckDB `memory_limit` PRAGMA value, in MB. |
|
|
168
169
|
| `CANVAS_EXPORT_PATH` | `canvas.exportRootPath` | `./.canvas-exports` | Sandbox root for path-targeted exports. Absolute paths and `..` traversal are rejected. |
|
|
169
|
-
| `CANVAS_TEMP_PATH` | `canvas.tempRootPath` |
|
|
170
|
+
| `CANVAS_TEMP_PATH` | `canvas.tempRootPath` | `os.tmpdir()` | Parent of the provider's private scratch directory: on first use the DuckDB provider creates `mcp-canvas-XXXXXX` inside it (`mkdtemp`, `0700` on POSIX) for each canvas's own DuckDB `temp_directory` and the transient files behind stream exports and `importFrom`; shutdown removes it once the calls still running against it settle. Created if missing, with no ownership or mode check — must not be a directory another local user controls, and on Windows, where the private directory inherits the parent's ACL, must not grant other users access. Never resolves to the process cwd — DuckDB's own cwd-relative `.tmp` default fails on a non-root or read-only container rootfs. |
|
|
170
171
|
| `CANVAS_MAX_CANVASES_PER_TENANT` | `canvas.maxCanvasesPerTenant` | `100` | Active canvas cap per tenant; throws `RateLimited` when exceeded. |
|
|
171
172
|
| `CANVAS_TTL_MS` | `canvas.ttlMs` | `86400000` | Sliding TTL (24 h). Every operation extends the expiry. |
|
|
172
173
|
| `CANVAS_ABSOLUTE_CAP_MS` | `canvas.absoluteCapMs` | `604800000` | Absolute cap from creation (7 d). Sliding window clamps to this. |
|