@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
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: api-context
|
|
3
3
|
description: >
|
|
4
|
-
Canonical reference for the unified `Context` object passed to every tool and resource handler in `@cyanheads/mcp-ts-core`. Covers the full interface, its `RequestContext` base, all sub-APIs (`ctx.log`, `ctx.state`, `ctx.requestInput`, `ctx.inputs`, `ctx.enrich`, `ctx.content`), and when to use each.
|
|
4
|
+
Canonical reference for the unified `Context` object passed to every tool and resource handler in `@cyanheads/mcp-ts-core`. Covers the full interface, its `RequestContext` base, all sub-APIs (`ctx.log`, `ctx.state`, `ctx.requestInput`, `ctx.inputs`, `ctx.clientCapabilities`, `ctx.enrich`, `ctx.content`), and when to use each.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "2.
|
|
7
|
+
version: "2.9"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -24,7 +24,7 @@ import type { Context } from '@cyanheads/mcp-ts-core';
|
|
|
24
24
|
|
|
25
25
|
interface Context extends RequestContext {
|
|
26
26
|
// Identity & tracing (inherited from RequestContext — see § RequestContext)
|
|
27
|
-
readonly requestId: string; //
|
|
27
|
+
readonly requestId: string; // Per request; returned on its errors as data.requestId
|
|
28
28
|
readonly timestamp: string; // ISO 8601 request start time
|
|
29
29
|
readonly tenantId?: string; // JWT 'tid' claim; 'default' for stdio and HTTP+MCP_AUTH_MODE=none
|
|
30
30
|
readonly sessionId?: string; // Mcp-Session-Id (HTTP stateful/auto); undefined elsewhere unless opted in
|
|
@@ -42,8 +42,12 @@ interface Context extends RequestContext {
|
|
|
42
42
|
readonly state: ContextState;
|
|
43
43
|
|
|
44
44
|
// Multi-round-trip input — always present, both eras (see § ctx.requestInput)
|
|
45
|
-
readonly requestInput: RequestInputFn; // (spec) => never — suspends and asks the caller
|
|
46
|
-
readonly inputs: ContextInputs; //
|
|
45
|
+
readonly requestInput: RequestInputFn; // (spec, options?) => never — suspends and asks the caller
|
|
46
|
+
readonly inputs: ContextInputs; // the request's responses, limited to what the client declared
|
|
47
|
+
// What the client declared: the SDK-parsed `initialize` value (2025 era) or the
|
|
48
|
+
// request's envelope as sent (2026-07-28); undefined when no view exists
|
|
49
|
+
// (see § ctx.clientCapabilities)
|
|
50
|
+
readonly clientCapabilities: ClientCapabilities | undefined;
|
|
47
51
|
|
|
48
52
|
// List-changed / resource-updated notifications — wired in every handler ctx;
|
|
49
53
|
// delivery is request-scoped (see § list-changed notifications)
|
|
@@ -70,8 +74,9 @@ interface Context extends RequestContext {
|
|
|
70
74
|
// pushes a raw ContentBlock.
|
|
71
75
|
readonly content: ContentCollect;
|
|
72
76
|
|
|
73
|
-
//
|
|
74
|
-
//
|
|
77
|
+
// Contract resolver — always present (returns {} when no contract is attached or the
|
|
78
|
+
// reason is unknown), strictly typed on HandlerContext<R> against declared reasons.
|
|
79
|
+
// The framework already sends a declared hint with any failure carrying the reason.
|
|
75
80
|
recoveryFor(reason: string): { recovery: { hint: string } } | {};
|
|
76
81
|
}
|
|
77
82
|
```
|
|
@@ -82,7 +87,7 @@ interface Context extends RequestContext {
|
|
|
82
87
|
|
|
83
88
|
| Field | Always present | Source |
|
|
84
89
|
|:------|:--------------|:-------|
|
|
85
|
-
| `requestId` | Yes |
|
|
90
|
+
| `requestId` | Yes | The client's JSON-RPC id when it is a string, otherwise a generated `XXXXX-XXXXX` token. Every log record of the call carries it, and the framework returns it on the call's error envelope as `data.requestId` |
|
|
86
91
|
| `timestamp` | Yes | ISO 8601, request start |
|
|
87
92
|
| `tenantId` | Stdio and HTTP+`MCP_AUTH_MODE=none` (as `'default'`); JWT `tid` claim in HTTP+`jwt`/`oauth` | JWT / single-tenant default |
|
|
88
93
|
| `sessionId` | HTTP `stateful` / `auto` mode; undefined for stdio and stateless HTTP unless opted in | `Mcp-Session-Id` header (or server-minted) — see [§ `ctx.sessionId`](#ctxsessionid) |
|
|
@@ -94,7 +99,7 @@ interface Context extends RequestContext {
|
|
|
94
99
|
|
|
95
100
|
## `RequestContext` — the one canonical request shape
|
|
96
101
|
|
|
97
|
-
`Context extends RequestContext`. There is a single request-shape type; the handler-facing `Context` adds handler-only surfaces (`log`, `state`, `signal`, `requestInput`, `inputs`, `enrich`, `content`, `uri`) on top of it and redeclares none of the identity fields. A handler's `ctx` is therefore assignable anywhere a `RequestContext` is — services, storage, the framework logger — with no slice helper and no cast.
|
|
102
|
+
`Context extends RequestContext`. There is a single request-shape type; the handler-facing `Context` adds handler-only surfaces (`log`, `state`, `signal`, `requestInput`, `inputs`, `clientCapabilities`, `enrich`, `content`, `uri`) on top of it and redeclares none of the identity fields. A handler's `ctx` is therefore assignable anywhere a `RequestContext` is — services, storage, the framework logger — with no slice helper and no cast.
|
|
98
103
|
|
|
99
104
|
```ts
|
|
100
105
|
import { requestContextService, withExtra } from '@cyanheads/mcp-ts-core/utils';
|
|
@@ -327,7 +332,20 @@ Always present, on every transport and both protocol eras. A handler that needs
|
|
|
327
332
|
|
|
328
333
|
One code path serves both eras. A 2026-07-28 client fulfils the embedded requests and retries the call; for a 2025-era session the SDK's legacy shim fulfils the same returns by issuing real `elicitation/create` / `sampling/createMessage` / `roots/list` round trips and re-entering the handler itself.
|
|
329
334
|
|
|
330
|
-
**A 2025-era client that declared no matching capability is refused, with an envelope.** URL-mode elicitation needs `elicitation.url`, form-mode needs `elicitation.form` (a bare `elicitation: {}` satisfies it), sampling needs `sampling` — `sampling.tools` when the request carries `tools` / `toolChoice` — and `roots/list` needs `roots`. `ctx.requestInput` runs the check on the result it builds and throws the refusal instead of the signal, so it never reaches the wire and the handler fails where it stands — the execution measurement records it as a failed call, and each family's usual error path shapes it. A tool gets `isError` with `structuredContent.error.code = -32600` (`InvalidRequest`), `data.reason: 'client_capability_missing'`, and a `data.recovery.hint` naming the capability; a resource read gets the same code, reason, and hint through the JSON-RPC error envelope. A prompt's `generate` receives no `ctx`, so it has no `ctx.requestInput` to gate. The check runs on every round, so a handler that elicits first and samples second is gated again on the second. A return carrying only `requestState` asks the client for nothing and is never gated. On the 2026-07-28 leg the SDK owns this check and a violation surfaces as its `MissingRequiredClientCapabilityError` (`-32021`) instead.
|
|
335
|
+
**A 2025-era client that declared no matching capability is refused, with an envelope.** URL-mode elicitation needs `elicitation.url`, form-mode needs `elicitation.form` (a bare `elicitation: {}` satisfies it), sampling needs `sampling` — `sampling.tools` when the request carries `tools` / `toolChoice` — and `roots/list` needs `roots`. `ctx.requestInput` runs the check on the result it builds and throws the refusal instead of the signal, so it never reaches the wire and the handler fails where it stands — the execution measurement records it as a failed call, its `Error in tool:<name>` record logs at `notice` (a property of the client's connection, not a server fault), and each family's usual error path shapes it. A tool gets `isError` with `structuredContent.error.code = -32600` (`InvalidRequest`), `data.reason: 'client_capability_missing'`, and a `data.recovery.hint` naming the capability; a resource read gets the same code, reason, and hint through the JSON-RPC error envelope. A prompt's `generate` receives no `ctx`, so it has no `ctx.requestInput` to gate. The check runs on every round, so a handler that elicits first and samples second is gated again on the second. A return carrying only `requestState` asks the client for nothing and is never gated. On the 2026-07-28 leg the SDK owns this check and a violation surfaces as its `MissingRequiredClientCapabilityError` (`-32021`) instead.
|
|
336
|
+
|
|
337
|
+
**The refusal's hint ends at reconnecting** — ``Reconnect with a client that declares the `elicitation.form` capability.`` — and offers no other way to supply the answer, because a consent gate deliberately has no input field for it: the model would fill it in. A handler whose own arguments can stand in for the answer says so per call with the optional second argument, a sentence appended to the hint after a space:
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
return ctx.requestInput(
|
|
341
|
+
{ inputRequests: { noun: inputRequired.elicit({ message: 'I need a noun.', requestedSchema: Answer }) } },
|
|
342
|
+
{ fallbackHint: 'Or call again with noun supplied.' },
|
|
343
|
+
);
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
The option shapes that refusal alone, on a tool call and a resource read alike. A connection that can serve the request never sees it, and the 2026-07-28 leg's `-32021` is untouched.
|
|
347
|
+
|
|
348
|
+
**Responses of a kind the client never declared never reach `ctx.inputs`, on either era.** The SDK lifts `inputResponses` off every client request — a first call and a 2025-era request included — so a client can arrive pre-answered with nothing having asked. The framework keeps only what the client's declared capabilities cover, at the same mode level the refusal above applies to requests: an elicit result carrying `content` is a form-mode answer and needs `elicitation.form` (a bare `elicitation: {}` counts), while one without — a URL-mode accept, a decline, a cancel — needs `elicitation` in any mode; a sampling result needs `sampling`, and `sampling.tools` when its content holds a `tool_use` or `tool_result` block; a roots result needs `roots`. An entry of no recognizable kind is dropped, and a request with no capability view — a 2025-era request under `MCP_SESSION_MODE=stateless` — carries none. A form gate facing a client without `elicitation.form` — a URL-only client included — therefore asks, and is refused, instead of acting on an answer nobody was shown. Legitimate rounds are untouched: the 2025 shim only issues requests the connection declared, and on 2026-07-28 the SDK refuses an embedded request the envelope does not cover. A client that *did* declare the capability can still pre-answer — which is what the consent record below is for.
|
|
331
349
|
|
|
332
350
|
**`MCP_SESSION_MODE` decides whether that second leg exists.** Under `stateful` / `auto` the shim has the session it needs. Under `stateless` each 2025-era request is served by a fresh instance that never saw `initialize`, so its client-capability view is empty and the round trip is refused rather than attempted — fail-closed, but the handler never gets its answer. The refusal carries the same envelope, with a message and hint that name the per-request case and point at a stateful session. Ship `stateless` on a server whose destructive tools gate on `ctx.requestInput` and those tools become unusable for v1 HTTP clients. 2026-07-28 clients are unaffected in either mode: that revision has no server→client request channel at all, which is precisely why `input_required` exists. stdio is unaffected in either mode.
|
|
333
351
|
|
|
@@ -341,36 +359,98 @@ Read `ctx.inputs` first, request only what is still missing, and write the call
|
|
|
341
359
|
import { inputRequired, tool, z } from '@cyanheads/mcp-ts-core';
|
|
342
360
|
import { validationError } from '@cyanheads/mcp-ts-core/errors';
|
|
343
361
|
|
|
344
|
-
const
|
|
345
|
-
|
|
362
|
+
const Format = z.object({ format: z.enum(['json', 'csv']).describe('Export format') });
|
|
363
|
+
|
|
364
|
+
export const exportReport = tool('export_report', {
|
|
365
|
+
description: 'Export a report, asking for the format when the caller left it out.',
|
|
366
|
+
input: z.object({
|
|
367
|
+
reportId: z.string().describe('Report to export'),
|
|
368
|
+
format: z.enum(['json', 'csv']).optional().describe('Export format; asked for when omitted'),
|
|
369
|
+
}),
|
|
370
|
+
output: z.object({ url: z.string().describe('Download URL') }),
|
|
371
|
+
|
|
372
|
+
handler(input, ctx) {
|
|
373
|
+
// A declined or cancelled prompt is a dead end, not a round to retry —
|
|
374
|
+
// re-asking loops until the round budget runs out.
|
|
375
|
+
const view = ctx.inputs.view('format');
|
|
376
|
+
if (view.kind === 'elicit' && view.action !== 'accept') {
|
|
377
|
+
throw validationError(`User ${view.action} the format prompt.`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const format = input.format ?? ctx.inputs.accepted('format', Format)?.format;
|
|
381
|
+
if (!format) {
|
|
382
|
+
return ctx.requestInput(
|
|
383
|
+
{ inputRequests: { format: inputRequired.elicit({ message: 'Which format?', requestedSchema: Format }) } },
|
|
384
|
+
{ fallbackHint: 'Or call again with format supplied.' },
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
// `format` is narrowed here.
|
|
388
|
+
return { url: exportAs(input.reportId, format) };
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
`ctx.requestInput` returns `never`, so `return ctx.requestInput(...)` type-checks against any output type. Calling it as a bare statement works at runtime — and is the only option from a service-layer helper — but TypeScript will not narrow across it.
|
|
394
|
+
|
|
395
|
+
### Consent gates — redeem a server record
|
|
396
|
+
|
|
397
|
+
A destructive handler cannot take an accepted answer on `ctx.inputs` as proof the user was asked: a client that declared `elicitation` can send `inputResponses` on a call nothing prompted for, and any `requestState` — sealed or not — can be replayed within its lifetime. What proves the round is a record the server wrote when it asked. Store what the prompt confirmed in `ctx.state` under a random id — the operation, the authenticated caller, the target, and a hash of what the target holds — send only that id as `requestState`, and **redeem the record — read it and delete it — before anything else in the handler**. An unknown, used, or expired id, or a record naming another operation, caller, target, or content, is a fresh prompt, never a proceed.
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
import { randomUUID } from 'node:crypto';
|
|
401
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
402
|
+
import { inputRequired, tool, z } from '@cyanheads/mcp-ts-core';
|
|
403
|
+
import { validationError } from '@cyanheads/mcp-ts-core/errors';
|
|
404
|
+
|
|
405
|
+
const Confirm = z.object({ confirm: z.boolean().describe('Whether to proceed with the deletion.') });
|
|
406
|
+
const Consent = z.object({
|
|
407
|
+
operation: z.string().describe('Tool the record was minted for'),
|
|
408
|
+
clientId: z.string().describe('Authenticated client that was asked; empty without auth'),
|
|
409
|
+
subject: z.string().describe('Authenticated subject that was asked; empty without auth'),
|
|
410
|
+
target: z.string().describe('Path the user confirmed'),
|
|
411
|
+
contentHash: z.string().describe('Hash of what the path held when the user confirmed'),
|
|
346
412
|
});
|
|
347
413
|
|
|
348
414
|
export const deletePath = tool('delete_path', {
|
|
349
|
-
description: 'Delete a path after
|
|
415
|
+
description: 'Delete a path after the user confirms it.',
|
|
350
416
|
input: z.object({ path: z.string().describe('Path to delete') }),
|
|
351
417
|
output: z.object({ deleted: z.string().describe('The path that was deleted') }),
|
|
352
418
|
annotations: { destructiveHint: true },
|
|
353
419
|
|
|
354
|
-
handler(input, ctx) {
|
|
355
|
-
//
|
|
356
|
-
|
|
420
|
+
async handler(input, ctx) {
|
|
421
|
+
// 1. Redeem first: whatever this round carries, the record is spent now.
|
|
422
|
+
const id = ctx.inputs.state();
|
|
423
|
+
const record = id && /^[0-9a-f-]{36}$/.test(id) ? await ctx.state.get(`consent/${id}`, Consent) : null;
|
|
424
|
+
if (record) await ctx.state.delete(`consent/${id}`);
|
|
425
|
+
|
|
426
|
+
// 2. What this call would confirm: this operation, for this caller, on this target as it is now.
|
|
427
|
+
const expected = {
|
|
428
|
+
operation: 'delete_path',
|
|
429
|
+
clientId: ctx.auth?.clientId ?? '',
|
|
430
|
+
subject: ctx.auth?.sub ?? '',
|
|
431
|
+
target: input.path,
|
|
432
|
+
contentHash: await hashOf(input.path),
|
|
433
|
+
};
|
|
434
|
+
const matches = record !== null && isDeepStrictEqual(record, expected);
|
|
435
|
+
|
|
436
|
+
// 3. Only a matching record makes the answer on ctx.inputs mean anything.
|
|
357
437
|
const view = ctx.inputs.view('confirm');
|
|
358
|
-
if (view.kind === 'elicit' && view.action !== 'accept') {
|
|
359
|
-
throw validationError(`User ${view.action} the
|
|
438
|
+
if (matches && view.kind === 'elicit' && view.action !== 'accept') {
|
|
439
|
+
throw validationError(`User ${view.action} the deletion.`);
|
|
360
440
|
}
|
|
441
|
+
const answer = matches ? ctx.inputs.accepted('confirm', Confirm) : undefined;
|
|
361
442
|
|
|
362
|
-
|
|
443
|
+
// 4. No matching record, or no answer: ask, with a fresh record.
|
|
363
444
|
if (!answer) {
|
|
445
|
+
const fresh = randomUUID();
|
|
446
|
+
await ctx.state.set(`consent/${fresh}`, expected, { ttl: 600 });
|
|
364
447
|
return ctx.requestInput({
|
|
365
448
|
inputRequests: {
|
|
366
|
-
confirm: inputRequired.elicit({
|
|
367
|
-
message: `Delete ${input.path}?`,
|
|
368
|
-
requestedSchema: Confirm,
|
|
369
|
-
}),
|
|
449
|
+
confirm: inputRequired.elicit({ message: `Delete ${input.path}?`, requestedSchema: Confirm }),
|
|
370
450
|
},
|
|
451
|
+
requestState: fresh,
|
|
371
452
|
});
|
|
372
453
|
}
|
|
373
|
-
// `answer` is narrowed here.
|
|
374
454
|
if (!answer.confirm) throw validationError('Deletion not confirmed.');
|
|
375
455
|
|
|
376
456
|
return { deleted: remove(input.path) };
|
|
@@ -378,7 +458,33 @@ export const deletePath = tool('delete_path', {
|
|
|
378
458
|
});
|
|
379
459
|
```
|
|
380
460
|
|
|
381
|
-
|
|
461
|
+
- **Bind the record to the operation and the caller.** Without `operation`, an id minted by another consent-gated tool — or by a resource read's round, whose `operation` is the `ctx.uri.href` it read — confirms this one. Without `clientId` and `subject`, another user in the same tenant redeems an id they were handed: sealing binds the state to its principal only while `MCP_REQUEST_STATE_KEY` is set, and the record binds the caller either way. `ctx.state` is tenant-scoped already. On stdio and under `MCP_AUTH_MODE=none` both fields are empty — every caller is the same principal there.
|
|
462
|
+
- **Redeeming is single-use against a sequential replay, not against concurrent retries.** `ctx.state` has no atomic read-and-delete, so retries carrying one id at the same moment can each read the record before any delete lands — five concurrent retries on `filesystem` acted five times. Until an atomic `take` exists ([#593](https://github.com/cyanheads/mcp-ts-core/issues/593)), make an action that must not repeat idempotent per record (pass the record id as the upstream idempotency key), or accept that risk knowingly.
|
|
463
|
+
- **The record's storage must be shared by every instance a retry can reach.** A 2026-07-28 retry is a new request — under stateless HTTP, behind a load balancer, or on Workers it can land on another instance, which finds nothing in a process-local `in-memory` store and asks again (safe, but the user never gets through). Use `filesystem`, `supabase`, or `cloudflare-d1` there. Never `cloudflare-kv`: it is eventually consistent, so a retry served elsewhere may not see the record yet, and a replay can outrun the delete, which widens the race above. 2025-era rounds stay inside the process that asked.
|
|
464
|
+
- **Carrying the target in `requestState` and comparing it on re-entry is replayable.** Signing the state proves only that this server once minted it; the same sealed string confirms the same deletion again until it expires. Single-use has to be enforced on the server.
|
|
465
|
+
- **`ctx.clientCapabilities` is never a reason to skip the prompt.** A gate that proceeds when `elicitation` is absent is the bypass the gate exists to prevent; let the `client_capability_missing` refusal (2025 era) or `-32021` (2026-07-28) stand.
|
|
466
|
+
- **Declare `sessionMode: { default: 'stateful', require: 'stateful' }`** when the server also serves 2025-era clients over HTTP — see above.
|
|
467
|
+
|
|
468
|
+
### `ctx.clientCapabilities` — asking only when the client can answer
|
|
469
|
+
|
|
470
|
+
The capabilities the client declared for this request: on a 2025-era connection the SDK's parsed `initialize` value, on 2026-07-28 the request's own `io.modelcontextprotocol/clientCapabilities` envelope as sent. `{}` when it declared none; `undefined` when no view exists — a 2025-era request served per-request under `MCP_SESSION_MODE=stateless`. `extensions` carries declared extensions such as `io.modelcontextprotocol/ui`.
|
|
471
|
+
|
|
472
|
+
The two sources differ in shape. A bare `elicitation: {}` counts as declaring `elicitation.form` either way, but the SDK normalizes it while parsing `initialize`, so on a 2025-era connection it reads back as `{ elicitation: { form: {} } }`, and on 2026-07-28 as `{ elicitation: {} }`. A check for form mode that looks only at `elicitation.form` misses the bare 2026-07-28 declaration.
|
|
473
|
+
|
|
474
|
+
Use it for **optional** context, where a client that cannot answer should fall through to another source rather than fail the call — which a 2026-07-28 request cannot do otherwise, since its `-32021` is raised after the handler has returned. Read `ctx.inputs` first, so the retry does not ask again:
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
handler(input, ctx) {
|
|
478
|
+
const roots = ctx.inputs.view('roots');
|
|
479
|
+
if (roots.kind === 'roots') return fromRoots(roots.roots);
|
|
480
|
+
if (ctx.clientCapabilities?.roots) {
|
|
481
|
+
return ctx.requestInput({ inputRequests: { roots: inputRequired.listRoots() } });
|
|
482
|
+
}
|
|
483
|
+
return fromLaunchDirectory(); // the next source — no error, nothing sent
|
|
484
|
+
}
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
Never use it to decide whether to ask for consent (see above).
|
|
382
488
|
|
|
383
489
|
### Building the embedded requests
|
|
384
490
|
|
|
@@ -398,28 +504,33 @@ At least one of `inputRequests` or `requestState` must be supplied — the build
|
|
|
398
504
|
```ts
|
|
399
505
|
return ctx.requestInput({
|
|
400
506
|
inputRequests: { confirm: inputRequired.elicit({ message, requestedSchema: Confirm }) },
|
|
401
|
-
requestState:
|
|
507
|
+
requestState: jobId,
|
|
402
508
|
});
|
|
403
509
|
// Next round:
|
|
404
510
|
const state = ctx.inputs.state<string>();
|
|
405
511
|
```
|
|
406
512
|
|
|
407
|
-
`requestState` round-trips **through the client
|
|
513
|
+
`requestState` round-trips **through the client**. Keep it an opaque handle — an id for a record in `ctx.state` — rather than the data itself.
|
|
514
|
+
|
|
515
|
+
**Set `MCP_REQUEST_STATE_KEY` to seal it.** With the key (≥ 32 bytes) configured, the framework signs the string a handler returns — on tool, resource, and prompt results alike — into the SDK codec's envelope, bound to the request's authenticated `clientId`, `subject`, and `tenantId` and valid for 900 s, and every server instance verifies an echoed state before the handler runs. A forged, tampered, expired, other-principal, or other-key state is answered as `-32602` with `data.reason: 'invalid_request_state'` and never reaches the handler. Handlers change nothing: they still return a plain string, and `ctx.inputs.state()` returns that original string. Every instance a retry can reach — stateless replicas, Worker isolates, a restarted process — needs the same key; there is no per-process random key. Unset, no verifier runs and the handler reads whatever string the client sent. The sealed state is signed, not encrypted: the client can read the payload, so never put a secret in it.
|
|
408
516
|
|
|
409
|
-
|
|
517
|
+
Signed is not single-use: a sealed state still verifies every time it is replayed within 900 s, and it names no operation — a state one tool sealed verifies on a call to another. Anything that must happen once — a consent gate above all — redeems a server record bound to its operation.
|
|
410
518
|
|
|
411
|
-
|
|
519
|
+
### `ctx.inputs` — reading the request's responses
|
|
520
|
+
|
|
521
|
+
Populated once the client (or the legacy shim) has answered a `ctx.requestInput`, limited to the answers the client's declared capabilities cover — kind and mode, as described above.
|
|
412
522
|
|
|
413
523
|
| Member | Returns |
|
|
414
524
|
|:---|:---|
|
|
415
525
|
| `accepted(key, schema?)` | The accepted form-mode content for `key`, or `undefined` when the key is missing, the user declined or cancelled, the response was another kind, or (with a schema) validation failed |
|
|
416
526
|
| `view(key)` | Discriminated view of one entry: `{ kind: 'missing' }` \| `{ kind: 'elicit', action, content? }` \| `{ kind: 'sampling', result }` \| `{ kind: 'roots', roots }` |
|
|
417
|
-
| `state<T>()` |
|
|
527
|
+
| `state<T>()` | The `requestState` this round carried — the handler's own string, verified and unsealed when `MCP_REQUEST_STATE_KEY` is set, the raw wire string otherwise — or `undefined` when the round carried none |
|
|
418
528
|
| `dropped` | Keys the SDK dropped because the client sent a wrapped rather than a bare response object. Re-issue those requests instead of hard-failing |
|
|
419
|
-
| `responses` | The raw response map, for kinds the helpers don't cover |
|
|
529
|
+
| `responses` | The raw response map, for kinds the helpers don't cover — filtered the same way |
|
|
420
530
|
|
|
421
|
-
|
|
531
|
+
Three rules follow from what the SDK does *not* do:
|
|
422
532
|
|
|
533
|
+
- **A response is client-supplied, even from a capable client.** It can arrive on a call nothing asked, so it proves a prompt was answered only alongside a record the handler redeemed.
|
|
423
534
|
- **Responses are never re-validated against the schema the request advertised.** Pass the schema to `accepted(key, schema)` wherever the content matters, and treat every value as untrusted client input.
|
|
424
535
|
- **`undefined` from `accepted()` collapses five different outcomes into one.** Missing, declined, cancelled, wrong response kind, and schema-invalid are indistinguishable through it. Branch on `view(key)` when decline/cancel needs different handling from "not asked yet" — as above, re-issuing a request the user already declined just burns rounds.
|
|
425
536
|
|
|
@@ -433,6 +544,18 @@ const ctx = createMockContext({
|
|
|
433
544
|
});
|
|
434
545
|
```
|
|
435
546
|
|
|
547
|
+
`createMockContext({ clientCapabilities })` seeds `ctx.clientCapabilities` and applies the production filter to the seeded responses; omitted, `ctx.clientCapabilities` is `undefined` and nothing is filtered. Each mock context has its own `ctx.state`, so to drive a consent gate's second round, copy the record its first round stored into the second context before calling the handler. The record carries the caller, so seed the same `auth` (or none) on both:
|
|
548
|
+
|
|
549
|
+
```ts
|
|
550
|
+
const first = createMockContext();
|
|
551
|
+
const asked = await expectInputRequired(() => deletePath.handler(input, first));
|
|
552
|
+
const record = await first.state.get(`consent/${asked.requestState}`);
|
|
553
|
+
|
|
554
|
+
const ctx = createMockContext({ inputResponses: accept, requestState: asked.requestState });
|
|
555
|
+
await ctx.state.set(`consent/${asked.requestState}`, record);
|
|
556
|
+
await expect(deletePath.handler(input, ctx)).resolves.toEqual({ deleted: input.path });
|
|
557
|
+
```
|
|
558
|
+
|
|
436
559
|
**Convention:** only call `ctx.requestInput` from tool, resource, and prompt handlers — not from services.
|
|
437
560
|
|
|
438
561
|
---
|
|
@@ -605,12 +728,14 @@ The contract is opt-in. See `framework-skills/api-errors/SKILL.md` for the full
|
|
|
605
728
|
|
|
606
729
|
## `ctx.recoveryFor`
|
|
607
730
|
|
|
608
|
-
Always present on `Context`. Resolves the contract `recovery` for a given reason and returns the canonical wire shape `{ recovery: { hint } }`, ready to spread into `data`.
|
|
731
|
+
Always present on `Context`. Resolves the contract `recovery` for a given reason and returns the canonical wire shape `{ recovery: { hint } }`, ready to spread into `data`.
|
|
732
|
+
|
|
733
|
+
It is not what puts the hint on the wire. The handler factory fills `data.recovery.hint` from the matching `errors[]` entry for any failure that carries a declared reason and no hint of its own — a bare `ctx.fail('reason')` and a service throw alike — so a static hint needs no call here:
|
|
609
734
|
|
|
610
735
|
```ts
|
|
611
736
|
async handler(input, ctx) {
|
|
612
|
-
// Static recovery —
|
|
613
|
-
if (queue.full()) throw ctx.fail('queue_full'
|
|
737
|
+
// Static recovery — the framework fills the contract's hint onto the wire.
|
|
738
|
+
if (queue.full()) throw ctx.fail('queue_full');
|
|
614
739
|
|
|
615
740
|
// Dynamic recovery — interpolate runtime context, override the contract default.
|
|
616
741
|
if (!matched) throw ctx.fail('no_match', `No items for "${input.query}"`, {
|
|
@@ -619,6 +744,8 @@ async handler(input, ctx) {
|
|
|
619
744
|
}
|
|
620
745
|
```
|
|
621
746
|
|
|
747
|
+
Reach for `ctx.recoveryFor` when the hint has to ride the thrown error itself — a test asserting `data.recovery` on the handler's own throw — or when a site deliberately sends another entry's guidance.
|
|
748
|
+
|
|
622
749
|
### Signature
|
|
623
750
|
|
|
624
751
|
```ts
|
|
@@ -637,13 +764,11 @@ ctx.recoveryFor(reason: R): { recovery: { hint: string } }
|
|
|
637
764
|
| Unknown reason | Returns `{}` (TS prevents this for typed callers; runtime is loose for JS / stale contracts). |
|
|
638
765
|
| Declared reason | Returns `{ recovery: { hint: <contract.recovery> } }` — spread into `data`. |
|
|
639
766
|
| Override | Caller can override by spreading `recoveryFor` first then writing `recovery: { hint: '...' }` after — last write wins. |
|
|
640
|
-
| Service usage |
|
|
641
|
-
|
|
642
|
-
### Why opt-in resolution, not auto-population
|
|
767
|
+
| Service usage | A service throwing a factory error with `data: { reason }` gets the declared hint from the framework's fill; it needs no `ctx` for it. |
|
|
643
768
|
|
|
644
|
-
|
|
769
|
+
### The wire hint follows the reason
|
|
645
770
|
|
|
646
|
-
The
|
|
771
|
+
The contract is the single source of truth for the recovery hint, and the framework applies it where the failure leaves the handler: in the tool and resource factories, before the failure is logged and the envelope built, matched on `data.reason` alone. A throw-site `recovery` of any shape wins. The thrown `McpError` is never changed, so a handler-level test sees exactly what the throw site wrote; `runToolContract` applies the same fill, so a contract test sees the production envelope. The `≥5 words` lint rule on contract `recovery` is what makes the default worth sending.
|
|
647
772
|
|
|
648
773
|
---
|
|
649
774
|
|
|
@@ -812,8 +937,9 @@ Test content blocks with `getContentBlocks(ctx)` from `@cyanheads/mcp-ts-core/te
|
|
|
812
937
|
| `ctx.signal` | `AbortSignal` | Always |
|
|
813
938
|
| `ctx.enrich` | `Enrich` | Always; typed on `HandlerContext<R, E>` when an `enrichment` block is declared |
|
|
814
939
|
| `ctx.content` | `ContentCollect` | Always — prepends image/audio blocks to `content[]`, never `structuredContent` |
|
|
815
|
-
| `ctx.requestInput` | `(spec) => never` | Always — suspends the handler and asks the caller for more input |
|
|
816
|
-
| `ctx.inputs` | `ContextInputs` | Always;
|
|
940
|
+
| `ctx.requestInput` | `(spec, options?) => never` | Always — suspends the handler and asks the caller for more input; `options.fallbackHint` extends a 2025-era capability refusal's hint |
|
|
941
|
+
| `ctx.inputs` | `ContextInputs` | Always; carries only the answers the client's declared capabilities cover (kind and mode), none when no capability view exists |
|
|
942
|
+
| `ctx.clientCapabilities` | `ClientCapabilities \| undefined` | Always as a key; `{}` when the client declared none, `undefined` when no view exists (2025-era stateless HTTP) |
|
|
817
943
|
| `ctx.notifyResourceListChanged` | `function \| undefined` | Always in handler ctx; delivery request-scoped (see [§ list-changed notifications](#list-changed-notifications-ctxnotify)) |
|
|
818
944
|
| `ctx.notifyResourceUpdated` | `function \| undefined` | Always in handler ctx; limited to URIs the client subscribed to, through the listen filter (2026) or the subscribe registry (2025) |
|
|
819
945
|
| `ctx.notifyPromptListChanged` | `function \| undefined` | Always in handler ctx; delivery request-scoped |
|