Files
operations/docs/architecture/response-envelopes.md
glm-5.1 51f233582d Align call-protocol.md and api-surface.md with envelope model
Remove supersession note from response-envelopes.md — both dependent
docs now reflect the ResponseEnvelope system. Key changes:

- call-protocol.md: CallHandler wraps and publishes (not handlers),
  call.responded.output uses ResponseEnvelopeSchema, respond() enforces
  envelope guard, call() resolves ResponseEnvelope, subscribe() yields
  ResponseEnvelope, references shared result pipeline
- api-surface.md: execute() returns Promise<ResponseEnvelope<TOutput>>,
  OperationEnv functions return Promise<ResponseEnvelope>, CallHandler
  calls handler directly and applies shared pipeline, respond() requires
  ResponseEnvelope, added Response Envelope Types and Utilities sections
- response-envelopes.md: removed supersession note, added Shared Result
  Pipeline section (detect→wrap→normalize→validate), unified execute()
  and CallHandler integration points to reference shared pipeline,
  updated migration checklist to mark doc changes complete
2026-05-10 08:37:40 +00:00

484 lines
28 KiB
Markdown

---
status: draft
last_updated: 2026-05-10
---
# Response Envelopes
Types, factory functions, integration points, and constraints for the `ResponseEnvelope` system. See [ADR-005](decisions/005-response-envelopes.md) for the design rationale.
## Problem
Only locally-defined operations return types matching their declared `outputSchema`. MCP operations return `ContentBlock[]` (or `structuredContent` when the tool declares `outputSchema`), OpenAPI operations return raw HTTP response data, and there is no way for consumers to:
- Know what source produced a result
- Access transport metadata (HTTP status, MCP error flags)
- Compose operations across sources with a uniform interface
- Access typed data from MCP tools that declare `outputSchema` (spec 2025-06-18+)
See ADR-005 for the full problem statement, composability analysis, and rationale.
## Types
### `ResponseEnvelope`
```ts
interface ResponseEnvelope<T = unknown> {
data: T
meta: ResponseMeta
}
```
Universal result wrapper. `data` holds the operation output. `meta` carries transport-specific metadata, discriminated on `meta.source`.
### `ResponseMeta` (discriminated union)
```ts
type ResponseMeta = LocalResponseMeta | HTTPResponseMeta | MCPResponseMeta
type ResponseSource = "local" | "http" | "mcp"
```
### `LocalResponseMeta`
```ts
interface LocalResponseMeta {
source: "local"
operationId: string
timestamp: number // Unix epoch milliseconds (Date.now())
}
```
Minimal metadata for in-process operations. `operationId` is the full `namespace.name` key. `timestamp` is set at wrap time.
### `HTTPResponseMeta`
```ts
interface HTTPResponseMeta {
source: "http"
statusCode: number
headers: Record<string, string>
contentType: string
}
```
Preserves HTTP response metadata. Multi-value headers are joined with `, ` per the `fetch` `Headers` specification. This does not preserve `Set-Cookie` as a separate header — known limitation, can be extended to `Record<string, string | string[]>` if needed.
### `MCPResponseMeta`
```ts
interface MCPResponseMeta {
source: "mcp"
isError: boolean
content: MCPContentBlock[]
structuredContent?: Record<string, unknown>
_meta?: Record<string, unknown>
}
```
Mirrors the MCP `CallToolResult` structure. Key semantics:
- `isError: true` means the tool ran but returned an error **result** — this is NOT a `CallError` exception. The handler wraps the result in an envelope. Consumers check `envelope.meta.isError` to distinguish error results from success results.
- `structuredContent` is the MCP structured output (spec version 2025-03-26+). When present, it is placed in `envelope.data` as the primary output.
- `content` always holds the full content block array, available in `meta` for rendering or fallback.
- `_meta` carries MCP protocol extensions.
### `MCPContentBlock`
```ts
type MCPContentBlock =
| { type: "text"; text: string; annotations?: MCPAnnotations }
| { type: "image"; data: string; mimeType: string; annotations?: MCPAnnotations }
| { type: "audio"; data: string; mimeType: string; annotations?: MCPAnnotations }
| { type: "resource"; resource: MCPResourceContent; annotations?: MCPAnnotations }
| { type: "resource_link"; uri: string; name: string; description?: string; mimeType?: string }
interface MCPResourceContent {
uri: string
mimeType?: string
text?: string
blob?: string
}
interface MCPAnnotations {
audience?: Array<"user" | "assistant">
priority?: number
lastModified?: string
}
```
These types are our own definitions, decoupled from `@modelcontextprotocol/sdk`. The MCP adapter maps from SDK types to these types. This avoids coupling the main barrel to the MCP SDK runtime dependency.
## Detection
### `isResponseEnvelope()`
```ts
const RESPONSE_SOURCES = ["local", "http", "mcp"] as const
function isResponseEnvelope(value: unknown): value is ResponseEnvelope {
if (typeof value !== "object" || value === null) return false
const obj = value as Record<string, unknown>
if (!("data" in obj) || !("meta" in obj)) return false
if (typeof obj.meta !== "object" || obj.meta === null) return false
return RESPONSE_SOURCES.includes((obj.meta as ResponseMeta).source as ResponseSource)
}
```
Used by `execute()` and `CallHandler` to detect whether a handler return value is already an envelope. Detection by `meta.source` discriminant is:
- JSON-serializable (works across pubsub transport boundaries)
- Cross-realm safe (no `Symbol.for()` iframe/Worker issues)
- Low false-positive risk (the known source strings are under our control)
If false positives become a concern, a versioned brand like `__envelopeVersion: 1` can be added to the envelope shape. New sources must be registered in `RESPONSE_SOURCES`.
## Factory Functions
Adapters and infrastructure use factory functions to construct envelopes. Adapters do not construct envelope objects directly.
```ts
function localEnvelope<T>(data: T, operationId: string): ResponseEnvelope<T> {
return { data, meta: { source: "local", operationId, timestamp: Date.now() } }
}
function httpEnvelope<T>(data: T, meta: Omit<HTTPResponseMeta, "source">): ResponseEnvelope<T> {
return { data, meta: { source: "http", ...meta } }
}
function mcpEnvelope<T>(data: T, meta: Omit<MCPResponseMeta, "source">): ResponseEnvelope<T> {
return { data, meta: { source: "mcp", ...meta } }
}
```
- `localEnvelope` is used by `execute()` and `CallHandler` when wrapping raw handler return values
- `httpEnvelope` is used by the OpenAPI adapter handler
- `mcpEnvelope` is used by the MCP adapter handler
- `T` resolves to `undefined` for void handlers — `localEnvelope(undefined, opId)` is valid
## Utility
### `unwrap()`
```ts
function unwrap<T>(envelope: ResponseEnvelope<T>): T {
return envelope.data
}
```
Convenience for the common case where metadata is not needed. Equivalent to `envelope.data`. Named for clarity at call sites and as a search target for envelope-unwrapping patterns.
## Schema Constants
TypeBox schemas for use in validation, spec definitions, and call protocol events:
```ts
const ResponseEnvelopeSchema = Type.Object({
data: Type.Unknown({ description: "Operation output" }),
meta: ResponseMetaSchema,
})
const ResponseMetaSchema = Type.Union([
LocalResponseMetaSchema,
HTTPResponseMetaSchema,
MCPResponseMetaSchema,
])
```
Individual meta schemas (`LocalResponseMetaSchema`, `HTTPResponseMetaSchema`, `MCPResponseMetaSchema`, `MCPContentBlockSchema`) are defined inline in the source. See `src/response-envelope.ts` for the exact definitions.
## Integration Points
### Shared Result Pipeline
Both `OperationRegistry.execute()` and `CallHandler` follow the same result processing pipeline after obtaining a handler result. This ensures consistent behavior whether the operation is invoked directly or through the call protocol:
1. **Detect envelope**: If result `isResponseEnvelope()` → pass through as-is (adapter handlers return pre-built envelopes)
2. **Wrap**: Otherwise → wrap in `localEnvelope(result, operationId)`
3. **Normalize**: If `spec.outputSchema` is not `Type.Unknown()`, apply `Value.Cast(spec.outputSchema, envelope.data)` — strips excess properties, fills defaults, upcasts values
4. **Validate**: Check `envelope.data` against `spec.outputSchema` with `collectErrors()`**warning-only**, logged but not thrown
The order matters: normalization happens before validation, so warnings reflect the normalized data shape. `Value.Cast()` may silently fix data that would otherwise fail validation (e.g., filling defaults for optional fields), and the validation step catches any remaining mismatches.
### Result Pipeline Differences
| Aspect | `execute()` | `CallHandler` |
|--------|-------------|---------------|
| Access control | Not checked (trusted internal calls) | Checks `AccessControl` before dispatch |
| Error handling | Throws `CallError` directly | Publishes `call.error` via pubsub |
| Publishing | Returns envelope directly | Publishes `call.responded` via `callMap.respond()` |
| Context | Direct call, no `requestId` | Has `requestId`, `parentRequestId`, `identity`, `deadline` |
### `OperationRegistry.execute()`
Returns `Promise<ResponseEnvelope<TOutput>>` instead of `Promise<TOutput>`.
Flow:
1. Look up spec and handler (existing)
2. Validate input with `validateOrThrow` (existing)
3. Await handler result
4. Apply shared result pipeline (detect → wrap → normalize → validate)
5. Return envelope
**Note**: `isResponseEnvelope()` does not validate that the envelope's `source` matches the operation's origin. An MCP handler that explicitly returns a `localEnvelope(...)` passes through as-is. Handlers that explicitly construct envelopes take responsibility for their metadata.
**Current source state** (`src/registry.ts` lines 80-105): `execute()` returns `Promise<TOutput>` directly. It does not wrap results in envelopes, does not call `isResponseEnvelope()`, and validates raw `result` against `outputSchema`. The `Value.Cast()` normalization step is not implemented. Changes needed:
| What | Current source | Target |
|------|---------------|--------|
| Return type | `Promise<TOutput>` | `Promise<ResponseEnvelope<TOutput>>` |
| Wrapping | None — returns raw `result` | `isResponseEnvelope(result) ? result : localEnvelope(result, operationId)` |
| Validation | `collectErrors(spec.outputSchema, result)` — on raw value | `collectErrors(spec.outputSchema, envelope.data)` — on envelope data |
| `Value.Cast()` | Not used | If `outputSchema !== Unknown`, `envelope.data = Value.Cast(spec.outputSchema, envelope.data)` |
### `CallHandler`
Takes full ownership of publishing `call.responded`. Handlers return values; they do NOT publish events.
Flow:
1. Look up spec by `operationId` from the registry via `getSpec()`
2. If not found, throw `CallError(OPERATION_NOT_FOUND, ...)`
3. Look up handler by `operationId` via `getHandler()`
4. If not found, throw `CallError(OPERATION_NOT_FOUND, "No handler registered for operation: ...")`
5. Check access control (existing)
6. Validate input with `validateOrThrow` (existing)
7. Call handler and await result
8. Apply shared result pipeline (detect → wrap → normalize → validate)
9. Publish `call.responded` via `callMap.respond(requestId, envelope)`
10. On handler exception → publish `call.error` (existing). Note: an envelope with `meta.isError: true` does **not** trigger `call.error`. Only thrown exceptions do.
**Current source state** (`src/call.ts` lines 182-233): `buildCallHandler` calls `handler(input, context)` (line 226) but does not use the return value. The handler is expected to publish `call.responded` itself. Changes needed:
| What | Current source | Target |
|------|---------------|--------|
| Handler model | Handler publishes `call.responded` itself; return value ignored | Handler returns value; `CallHandler` wraps and publishes |
| Return value | `await handler(input, context)` called but result discarded | Result captured, wrapped in envelope if needed, then published |
| Envelope detection | Not applicable | `isResponseEnvelope(result)` check before wrapping |
| Result pipeline | None | Detect → wrap → normalize → validate → publish |
| `call.responded.output` | `Type.Unknown()` | `ResponseEnvelopeSchema` |
| `PendingRequestMap.respond()` | Accepts any `unknown` value | Must enforce `isResponseEnvelope()` guard |
### `PendingRequestMap.respond()`
Enforces that `output` is a `ResponseEnvelope` via the `isResponseEnvelope()` type guard (not full schema validation). If called with a non-envelope value, throws. This prevents any code bypassing `CallHandler`'s envelope wrapping. A future iteration may make `respond()` internal (not exported on the public API surface) to further enforce this invariant.
**Current source state** (`src/call.ts` lines 151-156): `respond()` publishes `call.responded` with `output: unknown`. No envelope validation.
### `PendingRequestMap.call()`
Resolves with the `ResponseEnvelope` from `call.responded.output` instead of raw `unknown`.
**Current source state** (`src/call.ts` lines 120-149): `call()` returns `Promise<unknown>`. The type should become `Promise<ResponseEnvelope>`.
### `subscribe()`
Each yielded value is wrapped in `ResponseEnvelope`. `SubscriptionHandler` implementations still yield raw values — `subscribe()` wraps each yield, consistent with how local handlers return raw values and `execute()` wraps them. If a yielded value `isResponseEnvelope()`, it passes through. Otherwise, `localEnvelope(value, operationId)` with a fresh `timestamp` per yield.
### `buildEnv()`
Each function in the returned `OperationEnv` returns `Promise<ResponseEnvelope>` instead of `Promise<unknown>`.
### MCP Adapter (`from_mcp.ts`)
#### data shape and composability
MCP tool results have two shapes depending on whether the tool declares `outputSchema`:
- **With `outputSchema`** (MCP spec 2025-06-18+): `structuredContent` contains the typed output matching the tool's declared schema. The `from_mcp` adapter converts the tool's JSON Schema `outputSchema` to a TypeBox schema via `FromSchema` and sets it as the operation's `outputSchema`. At call time, `envelope.data` is `structuredContent`, which matches `outputSchema`. These operations are **fully composable** with local operations — the consumer gets typed data just like calling a local handler.
- **Without `outputSchema`**: No typed output is available. The operation's `outputSchema` is `Type.Unknown()`. `envelope.data` is `MCPContentBlock[]` (the raw content blocks). These operations are **not composable** — the consumer must inspect content blocks heuristically. Some MCP servers return `JSON.stringify`'d text in content blocks rather than structured output, making programmatic consumption even harder.
When `structuredContent` is present, it is the primary data path. When absent, `content` is the only data path. This means `envelope.data` has a different shape depending on the MCP server version and tool configuration. Consumers that need a consistent shape should check `meta.source === "mcp"` and `meta.structuredContent` to determine the access pattern.
#### outputSchema extraction
The `from_mcp` adapter SHOULD extract `outputSchema` from MCP tool definitions (when available) and convert it to a TypeBox schema:
```ts
// In createMCPClient, when building the operation spec:
outputSchema: tool.outputSchema
? FromSchema(tool.outputSchema) // Convert JSON Schema to TypeBox
: Type.Unknown() // No schema available
```
This enables `outputSchema` validation on `envelope.data` for MCP operations that declare their output schema, making them composable with local operations.
#### Envelope stripping with `Value.Cast()`
When an MCP operation has `outputSchema` and returns `structuredContent`, the `structuredContent` data can be cast against the TypeBox schema to strip excess properties and normalize it:
```ts
import { Value } from "@alkdev/typebox/value"
// In the MCP handler, after getting structuredContent:
const data = result.structuredContent
? Value.Cast(spec.outputSchema, result.structuredContent)
: mapMCPContentBlocks(result.content)
```
`Value.Cast()` upcasts the `structuredContent` value into the target type, retaining matching properties, filling defaults for missing ones, and stripping properties not in the schema. This ensures `envelope.data` reliably matches `outputSchema` — the same guarantee that local operations provide.
For MCP operations without `outputSchema` (where `outputSchema` is `Type.Unknown()`), `Value.Cast()` against `Type.Unknown()` is a no-op (accepts any value), so the data shape remains unpredictable. The composability gap exists only for these operations.
This same pattern applies to OpenAPI responses: the parsed JSON can be passed through `Value.Cast(spec.outputSchema, data)` to normalize it against the operation's `outputSchema`.
#### Handler behavior change
```ts
handler: async (input, context) => {
const result = await client.callTool({ name, arguments: input })
// MCP isError: true means the tool ran but returned an error result.
// Wrap in envelope — consumer checks meta.isError.
// Transport-level errors (tool not found, connection failure) still throw CallError.
return mcpEnvelope(
result.structuredContent ?? mapMCPContentBlocks(result.content),
{
isError: result.isError ?? false,
content: mapMCPContentBlocks(result.content),
structuredContent: result.structuredContent as Record<string, unknown> | undefined,
_meta: result._meta as Record<string, unknown> | undefined,
},
)
}
```
Key differences from current behavior:
- `isError: true` no longer throws. Error content is accessible via `envelope.data` and `envelope.meta.content`. The consumer checks `envelope.meta.isError`.
- `structuredContent` is preferred as `data` when available (MCP spec 2025-03-26+)
- Raw `content` blocks are always available in `meta.content`
- `mapMCPContentBlocks()` maps SDK `ContentBlock[]` to our `MCPContentBlock[]` (1:1 field mapping, decoupling us from the MCP SDK at the interface level). Signature: `(sdkBlocks: SDKContentBlock[]) => MCPContentBlock[]`. Unknown content block types (from newer MCP spec versions that our `MCPContentBlock` union doesn't yet cover) are mapped to `{ type: "text", text: JSON.stringify(block) }` as a fallback — preserving the data without crashing.
### OpenAPI Adapter (`from_openapi.ts`)
Handler behavior change:
```ts
handler: async (input, context) => {
// ... existing URL construction and fetch logic ...
const response = await fetch(url, fetchOptions)
if (!response.ok) {
throw new CallError("EXECUTION_ERROR", `HTTP ${response.status}: ${response.statusText}`)
}
const contentType = response.headers.get("Content-Type") || ""
let data: unknown
if (contentType.includes("application/json")) {
data = await response.json()
} else if (contentType.includes("text/")) {
data = await response.text()
} else {
data = await response.arrayBuffer()
}
return httpEnvelope(data, {
statusCode: response.status,
headers: Object.fromEntries(response.headers.entries()),
contentType,
})
}
```
- On HTTP error status → throw `CallError` (not an envelope — these are transport errors)
- Success responses → wrapped in `httpEnvelope` with full HTTP metadata
- Headers are a `Record<string, string>` snapshot (multi-value headers joined with `, ` per fetch spec)
### Value.Cast() for Data Normalization
The shared result pipeline (defined above) includes `Value.Cast()` normalization as step 3. This section provides additional context on how `Value.Cast()` works and why it matters for each source:
- **Local operations**: `Value.Cast()` provides normalization against the declared `outputSchema`, stripping excess properties and filling defaults — a stronger guarantee than `Value.Check()` validation alone (which only warns).
- **MCP operations with `outputSchema`**: `Value.Cast()` normalizes `structuredContent` against the TypeBox-converted schema, stripping any extra properties the MCP server may have added beyond the declared schema. This makes MCP data composable with local operations.
- **OpenAPI operations**: `Value.Cast()` normalizes the parsed HTTP response body against the operation's `outputSchema`, ensuring consistent data shapes.
Operations with `Type.Unknown()` `outputSchema` (typically MCP tools without `outputSchema`) are excluded — `Value.Cast()` against `Type.Unknown()` is a no-op (accepts any value).
### `CallEventSchema`
`call.responded.output` changes from `Type.Unknown()` to `ResponseEnvelopeSchema`:
```ts
"call.responded": Type.Object({
requestId: Type.String(),
output: ResponseEnvelopeSchema,
})
```
## `outputSchema` and Validation
`outputSchema` on `OperationSpec` validates `envelope.data`, not the full `ResponseEnvelope`. The envelope has its own `ResponseEnvelopeSchema` for call protocol validation. This keeps `outputSchema` focused on business data shape — no envelope schema bloat, and existing `outputSchema` definitions don't need changes.
The shared result pipeline (see [Shared Result Pipeline](#shared-result-pipeline)) applies two steps to `envelope.data` after wrapping:
1. **Normalization** (`Value.Cast`): When `outputSchema` is meaningful (not `Type.Unknown()`), `Value.Cast(spec.outputSchema, envelope.data)` normalizes the data — strips excess properties, fills defaults for missing ones, upcasts values to match the declared type. Normalization happens before validation.
2. **Warning validation** (`collectErrors` + `formatValueErrors`): Checks `envelope.data` against `spec.outputSchema` and logs warnings on mismatch. Validation happens after normalization, so it checks the normalized data shape.
The normalization step is optional: if `outputSchema` is `Type.Unknown()`, normalization is skipped (any value is accepted). This preserves the current behavior for MCP tools without `outputSchema`.
## Constraints
1. **No runtime envelope overhead for local handlers** — local handlers return raw values; wrapping happens in infrastructure (`execute()`, `CallHandler`), not in user code
2. **Envelope always present at call protocol boundary**`call.responded` always carries `ResponseEnvelope`, never raw values. `PendingRequestMap.respond()` enforces this.
3. **Adapters use factory functions**`localEnvelope()`, `httpEnvelope()`, `mcpEnvelope()`. Adapters do not construct envelope objects directly.
4. **MCPContentBlock types are our own** — not MCP SDK types. Avoids runtime dependency on `@modelcontextprotocol/sdk` in the main barrel.
5. **Breaking change: single-release, no transitional API**`execute()` return type and `call.responded` shape change in one release. Package is pre-1.0, call protocol is draft, consumers are coordinated.
6. **`unwrap()` is the simple-path API** — for consumers that don't need metadata
7. **`outputSchema` validates `envelope.data`** — `ResponseEnvelopeSchema` validates the envelope structure; `outputSchema` validates business data
8. **`isResponseEnvelope()` is the sole detection mechanism** — no Symbol brands, no duck-typing beyond the closed `source` discriminant set
## Open Questions
1. **Client abstraction** — Envelopes provide metadata and `unwrap()` provides simple access, but real usage may reveal patterns that justify a higher-level abstraction: e.g., a client that auto-handles MCP `isError` results (throwing on error content vs. returning it), or one that extracts pagination cursors from HTTP headers. Deferred until usage patterns from alkhub and opencode confirm whether `unwrap()` + `meta.source` dispatch is sufficient or whether a typed "client" per source adds real value.
2. **Subscription envelopes**`subscribe()` wraps each yield in `ResponseEnvelope`. For long-running subscriptions, `localResponseMeta.timestamp` updates per yield. Whether subscriptions need additional metadata (e.g., sequence numbers, cursor positions) is an open question for future iteration.
3. **`respond()` visibility** — Currently public on `PendingRequestMap`. After CallHandler takes ownership of publishing, `respond()` may become internal-only to enforce the envelope invariant.
4. **Envelope directionality (serving)** — The current design covers **consuming** remote operations (MCP, OpenAPI) and wrapping their results. The reverse direction — **serving** local operations via MCP or OpenAPI — is not yet addressed. When exposing operations as an MCP server, results must be wrapped in MCP's `CallToolResult` format (`structuredContent` + `content`). When exposing as OpenAPI, results must be serialized as HTTP response bodies. How envelopes interact with this serving direction is an open design question.
5. **JSON.stringify in MCP content blocks** — Some MCP servers return `JSON.stringify`'d data in text content blocks instead of using `structuredContent`. The `from_mcp` adapter could attempt `JSON.parse()` on text content when `structuredContent` is absent, but this is fragile (not all text content is JSON). If the operation has a meaningful `outputSchema`, `Value.Cast()` could normalize the parsed result against the schema, making the consumer's experience consistent regardless of whether the MCP server uses `structuredContent` or stringified JSON. Whether this JSON.parse heuristic belongs in the adapter or in consumer code is unresolved.
6. **MCP `outputSchema` extraction completeness** — The MCP spec (2025-06-18+) allows tools to declare `outputSchema`, but many existing servers don't. The adapter's `FromSchema` conversion of MCP `outputSchema` to TypeBox may encounter JSON Schema features that `FromSchema` doesn't handle. The fallback is `Type.Unknown()`, same as the no-schema case.
## Migration Checklist
The following documentation changes have been completed:
| Document | Section | Change | Status |
|----------|---------|--------|--------|
| `call-protocol.md` | CallHandler | Handler no longer publishes `call.responded`; returns values. CallHandler wraps and publishes. | ✅ |
| `call-protocol.md` | PendingRequestMap | `respond()` validates envelope via `isResponseEnvelope()`; resolves with `ResponseEnvelope` instead of `unknown` | ✅ |
| `call-protocol.md` | CallEventMap | `call.responded.output` changes from `Type.Unknown()` to `ResponseEnvelopeSchema` | ✅ |
| `api-surface.md` | `execute()` | Return type: `Promise<TOutput>``Promise<ResponseEnvelope<TOutput>>` | ✅ |
| `api-surface.md` | `PendingRequestMap.call()` | Resolve type: `unknown``ResponseEnvelope` | ✅ |
| `api-surface.md` | `subscribe()` | Yield type: `unknown``ResponseEnvelope` | ✅ |
| `api-surface.md` | `OperationEnv` | Inner function return type: `Promise<unknown>``Promise<ResponseEnvelope>` | ✅ |
| `api-surface.md` | `CallHandler` | Wraps handler result, publishes `call.responded`. No longer "handler publishes" model. | ✅ |
| `call-protocol.md` | `PendingRequestMap.respond()` | Now enforces `isResponseEnvelope()` check — throws on raw values. | ✅ |
| `api-surface.md` | `PendingRequestMap.respond()` | `respond()` now requires `ResponseEnvelope` argument. | ✅ |
| `adapters.md` | `from_mcp` | Handler returns `mcpEnvelope()`. MCP `isError: true` no longer throws. | ✅ (previous) |
| `adapters.md` | `from_mcp` | `outputSchema` extracted when available, via `FromSchema`. Falls back to `Type.Unknown()`. | ✅ (previous) |
| `adapters.md` | `from_openapi` | Handler returns `httpEnvelope()`. Error on HTTP error status still throws `CallError`. | ✅ (previous) |
The following **code** changes are still needed:
| Code | Change |
|------|--------|
| `src/registry.ts` | `execute()` returns `Promise<ResponseEnvelope<TOutput>>` |
| `src/call.ts` | `CallHandler` captures return value, wraps in envelope, publishes `call.responded` |
| `src/call.ts` | `CallEventSchema` `output` field changes to `ResponseEnvelopeSchema` |
| `src/call.ts` | `PendingRequestMap.respond()` adds `isResponseEnvelope()` guard |
| `src/call.ts` | `PendingRequestMap.call()` resolves with `ResponseEnvelope` |
| `src/subscribe.ts` | `subscribe()` wraps yields in `ResponseEnvelope` |
| `src/env.ts` | `buildEnv()` functions return `Promise<ResponseEnvelope>` |
| `src/response-envelope.ts` | New file: types, factory functions, detection, schemas |
| `src/from_mcp.ts` | Handler returns `mcpEnvelope()`, extracts `outputSchema`, uses `structuredContent` |
| `src/from_openapi.ts` | Handler returns `httpEnvelope()` |
Additionally, any code subscribing to `"call.responded"` events via the pubsub system (not just `PendingRequestMap`, but any direct pubsub consumer) must expect `ResponseEnvelope` instead of `unknown` in the event payload.
## References
- [ADR-005](decisions/005-response-envelopes.md) — Design rationale for response envelopes
- [call-protocol.md](call-protocol.md) — Call event shapes, PendingRequestMap, CallHandler
- [api-surface.md](api-surface.md) — Public API surface
- [adapters.md](adapters.md) — MCP and OpenAPI adapter internals