Restructure response envelopes architecture: split ADR from spec, add Value.Cast composability, document implementation gaps

- Split monolithic 680-line response-envelopes.md into focused ADR-005
  (decisions/005-response-envelopes.md, 152 lines) and specification
  (response-envelopes.md, 441 lines)
- ADR-005: consolidate 10 inline ADRs into coherent decision record with
  rationale for data+meta envelope shape, handler responsibility shift,
  string discriminant detection, and composability analysis
- Spec: types, factory functions, integration points, constraints, migration
  checklist, and open questions
- Add MCP outputSchema extraction (2025-06-18+ spec) with FromSchema
  conversion and<Value.Cast()> normalization for structuredContent
- Add current-source-vs-spec implementation gap tables to registry, call,
  mcp adapter, and openapi adapter integration points
- Update adapters.md: from_mcp outputSchema extraction, structuredContent
  handling, isError non-throw behavior, Value.Cast() for data normalization
- Add open questions: serving directionality, JSON.stringify in MCP content,
  outputSchema extraction completeness, respond() visibility
- Note: existing call-protocol.md and api-surface.md describe pre-envelope
  behavior; this spec supersedes them until updated per migration checklist
This commit is contained in:
2026-05-10 07:56:27 +00:00
parent a7e6cc94c7
commit 81f89e0f6c
3 changed files with 482 additions and 504 deletions

View File

@@ -77,7 +77,15 @@ Processes all paths in the spec. For each path and method combination:
- Passes query parameters as search params
- Sends request body as JSON
- Applies auth headers from config
- Returns JSON, text, or `ArrayBuffer` based on response content type
- Returns JSON, text, or `ArrayBuffer` based on response content type
**Current source state** (`src/from_openapi.ts`): The handler currently returns raw response data — `response.json()`, `response.text()`, or `response.arrayBuffer()` (lines 273-279). It does NOT wrap the result in `httpEnvelope()`. Error handling throws a plain `Error` (line 268) instead of `CallError`. The response-envelopes spec requires wrapping in `httpEnvelope()` and throwing `CallError` on HTTP errors. The `Value.Cast()` normalization step against `outputSchema` is also not yet implemented. See [response-envelopes.md](response-envelopes.md) for the full specification.
| What | Current source (`src/from_openapi.ts`) | Target (per response-envelopes spec) |
|------|------------------------------------------|---------------------------------------|
| Handler return value | Returns raw `response.json()` / `.text()` / `.arrayBuffer()` (lines 273-279) | Returns `httpEnvelope(data, { statusCode, headers, contentType })` |
| Error handling | Throws `Error(\`HTTP ${status}\`)` (line 268) | Throws `CallError("EXECUTION_ERROR", ...)` |
| `Value.Cast()` | Not used | If `outputSchema !== Unknown`, cast `Value.Cast(outputSchema, data)` |
### `FromOpenAPIFile(path, config, fs?)`
@@ -167,13 +175,45 @@ async function createMCPClient(
3. Connect the client
4. Call `client.listTools()` to discover available tools
5. For each tool, create a `OperationSpec & { handler }`:
- `name`: tool name
- `namespace`: the `name` parameter (used as grouping)
- `type`: `MUTATION` (all MCP tools are mutations)
- `inputSchema`: `FromSchema(tool.inputSchema)` (converts JSON Schema to TypeBox)
- `outputSchema`: `Type.Unknown()` (MCP doesn't provide output schemas)
- `handler`: calls `client.callTool({ name, arguments })`
- `accessControl`: `{ requiredScopes: [] }` (no auth by default)
- `name`: tool name
- `namespace`: the `name` parameter (used as grouping)
- `type`: `MUTATION` (all MCP tools are mutations)
### `FromSchema(tool.inputSchema)` (converts JSON Schema to TypeBox)
- `outputSchema`: `tool.outputSchema ? FromSchema(tool.outputSchema) : Type.Unknown()` (MCP spec 2025-06-18+ provides `outputSchema`; older tools lack it)
- `handler`: calls `client.callTool({ name, arguments })`, wraps result in `mcpEnvelope()`
- `accessControl`: `{ requiredScopes: [] }` (no auth by default)
**Current source state** (`src/from_mcp.ts` line 66): `outputSchema: Type.Unknown()` for all tools. The `tool.outputSchema` property is not used. The handler currently throws on `isError` (line 76) and returns `result.content` directly (line 79) instead of wrapping in `mcpEnvelope()`. The `structuredContent` field on `CallToolResult` is not used. See [response-envelopes.md](response-envelopes.md) for the full specification of what needs to change.
### outputSchema and structuredContent
The MCP spec (2025-06-18+) adds `outputSchema` to tool definitions and `structuredContent` to `CallToolResult`. When a tool declares `outputSchema`:
1. At discovery time: `FromSchema(tool.outputSchema)` converts the JSON Schema to TypeBox, giving the operation a meaningful `outputSchema`
2. At call time: `result.structuredContent` contains data matching that schema
3. The handler uses `Value.Cast(spec.outputSchema, result.structuredContent)` to normalize the data against the TypeBox schema — stripping excess properties from the MCP envelope and filling defaults
4. `envelope.data` is the cast result, which matches `outputSchema` — **fully composable with local operations**
When a tool does NOT declare `outputSchema`:
1. `outputSchema` is `Type.Unknown()` — no type information available
2. `result.structuredContent` is absent
3. `envelope.data` is `MCPContentBlock[]` — not composable, consumer must inspect content blocks
4. Some MCP servers return `JSON.stringify`'d data in text content blocks — the adapter could attempt `JSON.parse()` but this is fragile and not currently implemented
See [response-envelopes.md](response-envelopes.md) for the full envelope specification and envelope stripping with `Value.Cast()`.
**Implementation changes needed** (tracking spec vs. current source):
| What | Current source (`src/from_mcp.ts`) | Target (per response-envelopes spec) |
|------|--------------------------------------|---------------------------------------|
| `outputSchema` at discovery | `Type.Unknown()` for all tools (line 66) | `tool.outputSchema ? FromSchema(tool.outputSchema) : Type.Unknown()` |
| Handler return value | Returns `result.content` (line 79) | Returns `mcpEnvelope(data, meta)` |
| `structuredContent` | Not used | Prefer as `data` when present; fall back to `mapMCPContentBlocks(result.content)` |
| `isError` handling | Throws `Error` (line 76) | Wraps in envelope with `meta.isError: true`, does NOT throw |
| `Value.Cast()` | Not used | If `structuredContent && outputSchema !== Unknown`, cast `Value.Cast(outputSchema, structuredContent)` |
The `CallToolResult` type in the installed SDK (`@modelcontextprotocol/sdk` DRAFT-2026-v1) already includes `structuredContent?: { [key: string]: unknown }` and the `Tool` type already includes `outputSchema?`. No SDK upgrade needed — only the adapter code needs updating.
### `MCPClientConfig`

View File

@@ -0,0 +1,153 @@
# ADR-005: Response Envelopes for Transport-Aware Results
**Status**: Draft
**Date**: 2026-05-10
## Context
Operations from different sources produce results in fundamentally different shapes:
- **Local operations** return whatever the handler function returns — a plain value matching `outputSchema`
- **MCP operations** return `CallToolResult``{ content: ContentBlock[], structuredContent?: Record<string, unknown>, isError?: boolean }`
- **OpenAPI (HTTP) operations** return raw HTTP response bodies — parsed JSON, text, or `ArrayBuffer`, with no metadata about the HTTP transaction itself
Currently, all three are treated identically: `outputSchema: Type.Unknown()` for MCP, and OpenAPI handlers return whatever `response.json()` / `.text()` / `.arrayBuffer()` produces. There is no way for consumers to:
1. Know what kind of source produced the result
2. Access transport metadata (HTTP status, MCP error flags, content type)
3. Distinguish structured vs. unstructured content within the same result
4. Compose operations across sources — only local handlers return types matching their declared `outputSchema`
5. Access the typed data that MCP tools with `outputSchema` can provide (MCP spec 2025-06-18+)
This composability gap forces each consumer to write adapter-specific unwrapping logic, defeating the purpose of a unified registry.
### The composability spectrum
Composability — the ability to call an operation and get data matching `outputSchema` regardless of source — depends on whether the source provides schema information:
- **Local operations**: Fully composable. Handler returns a value, infrastructure validates against `outputSchema`.
- **MCP operations with `outputSchema`** (spec 2025-06-18+): Composable. The tool declares its output schema at discovery time. `structuredContent` contains data matching that schema. The adapter can convert the MCP `outputSchema` to TypeBox and set it on the operation.
- **MCP operations without `outputSchema`**: Not composable. `outputSchema` is `Type.Unknown()`. The result is `MCPContentBlock[]` — unstructured content that must be interpreted heuristically. Many MCP servers return `JSON.stringify`'d text in content blocks rather than structured output.
- **OpenAPI operations**: Composable to the extent the spec's response schema is accurate. The adapter converts the OpenAPI response schema to TypeBox for `outputSchema`.
The envelope design targets the common cases (local + MCP with `outputSchema` + OpenAPI) and provides metadata for the uncommon cases (MCP without `outputSchema`).
## Decision
Introduce a `ResponseEnvelope<T>` type that wraps every operation result with source metadata. Every `execute()` call and every `call.responded` event carries a `ResponseEnvelope`. Adapters produce envelopes via factory functions; local handlers return raw values that the infrastructure wraps.
### Envelope shape
```ts
interface ResponseEnvelope<T = unknown> {
data: T
meta: ResponseMeta
}
type ResponseMeta = LocalResponseMeta | HTTPResponseMeta | MCPResponseMeta
```
- `data` holds the operation output — always in the same place regardless of source
- `meta` carries transport-specific metadata, discriminated on `source`
### Where wrapping happens
- **Local handlers** return raw values. `OperationRegistry.execute()` and `CallHandler` detect raw values and wrap them in `localEnvelope()`
- **MCP handlers** return `mcpEnvelope(...)` — they construct the envelope because they have the MCP-specific metadata
- **OpenAPI handlers** return `httpEnvelope(...)` — they construct the envelope because they have the HTTP response metadata
- **Detection**: `isResponseEnvelope()` checks for `meta.source` being a known source string. If the handler already returned an envelope, the infrastructure passes it through
### Handler responsibility shift
Currently, handlers in the call protocol are expected to publish `call.responded` themselves. This creates ambiguity about who owns envelope construction. After this change:
- **Handlers return values** — they don't publish events. Local handlers return raw values; adapter handlers return envelopes
- **`CallHandler` (and `execute()`) wraps and publishes** — they call the handler, detect whether the result is already an envelope, wrap if needed, validate `envelope.data` against `outputSchema`, and publish `call.responded`
### API surface changes
| API | Before | After |
|-----|--------|-------|
| `registry.execute()` return | `Promise<TOutput>` | `Promise<ResponseEnvelope<TOutput>>` |
| `callMap.call()` resolve | `unknown` | `ResponseEnvelope` |
| `call.responded.output` | `Type.Unknown()` | `ResponseEnvelopeSchema` |
| Local handler return | raw value (unchanged) | raw value (infrastructure wraps) |
| MCP handler return | `ContentBlock[]` | `mcpEnvelope(...)` |
| OpenAPI handler return | raw parsed body | `httpEnvelope(...)` |
| `outputSchema` validation target | raw return value | `envelope.data` |
| `subscribe()` yield | raw value | `ResponseEnvelope` per yield |
| `OperationEnv` function return | `Promise<unknown>` | `Promise<ResponseEnvelope>` |
These are **breaking changes**. The package is pre-1.0, the call protocol is draft status, and the consumers (alkhub, opencode) are under our control. There is no transitional API — a single-release breaking change.
## Rationale
### Why `data` + discriminated `meta`
Several designs were considered:
1. **Discriminated union per source**`{ type: "mcp", ...mcpFields } | { type: "http", ...httpFields } | { type: "local", data: T }` — puts the actual output in a different property per source, requiring source dispatch to get the data
2. **Generic envelope with typed meta**`{ data: T, meta: ResponseMeta }` where `ResponseMeta` is discriminated on `source`
3. **Adapter-specific envelopes** — each adapter defines its own output type, no shared interface
Option 2 (chosen) keeps `data` in a predictable location regardless of source. The 90% case (just get the result) is `envelope.data`. The 10% case (inspect transport details) checks `envelope.meta.source` and narrows. Adding new sources means adding a new variant to `ResponseMeta`, not restructuring the envelope.
### Why `execute()` always returns `ResponseEnvelope`
A `execute()` that returns `TOutput | ResponseEnvelope<TOutput>` would require every call site to check. A `execute()` that returns `TOutput` for local ops and `ResponseEnvelope<TOutput>` for adapter ops is unpredictable. Always returning `ResponseEnvelope` gives a uniform interface: consumers always know the shape, `unwrap()` is always available for the simple path, and metadata is always accessible.
### Composability and `outputSchema`
The core composability problem is that only local operations reliably return types matching their declared `outputSchema`. With envelopes, composability depends on whether the source provides schema information:
- **Local operations**: `envelope.data` always matches `outputSchema` — the handler returns a value that the infrastructure validates against the schema.
- **MCP operations with `outputSchema`** (spec 2025-06-18+): The MCP tool declares an `outputSchema` at discovery time. The `from_mcp` adapter can convert this to a TypeBox schema and set it as the operation's `outputSchema`. At call time, `structuredContent` contains data validated against that schema, so `envelope.data` matches `outputSchema`. These operations are fully composable. The `Value.Cast()` function from `@alkdev/typebox/value` can further normalize `structuredContent` against the TypeBox schema, stripping excess properties and filling defaults — ensuring `envelope.data` cleanly matches the declared type, just like local operations.
- **MCP operations without `outputSchema`** (pre-2025-06-18 or tools that don't declare it): `outputSchema` is `Type.Unknown()`. `envelope.data` is `MCPContentBlock[]` (the raw content blocks). No composability — the consumer must inspect the content blocks.
- **OpenAPI operations**: `outputSchema` is derived from the OpenAPI spec's response schema. `envelope.data` contains the parsed response body. These are composable to the extent the OpenAPI spec's response schema is accurate.
For MCP, composability improves as more servers adopt `outputSchema`. The `from_mcp` adapter should extract and use `outputSchema` when available (see integration points in the spec document).
### Why handlers don't publish `call.responded`
The current model has handlers calling `callMap.respond()` directly, which means they must manually construct and send the event. This is error-prone and makes it unclear who is responsible for envelope construction. By having handlers return values and having the infrastructure (CallHandler, execute) own wrapping and publishing, there is a single point of envelope construction and event emission.
### Why MCP `isError: true` doesn't throw
MCP distinguishes between "the tool call failed at the protocol level" (transport error, tool not found — these throw `CallError`) and "the tool ran but returned an error result" (`isError: true` in `CallToolResult`). The latter is still useful content — the consumer may want to display the error text, log it, or take corrective action. Wrapping it in an envelope with `meta.isError: true` preserves this content while giving consumers a clear way to distinguish error results from success results.
### Why string discriminant for envelope detection
Three approaches were considered for detecting whether a handler return value is already a `ResponseEnvelope`:
1. **Duck-typing** — check for `data` + `meta` properties. Risk: false positives on user data that happens to have those properties.
2. **`Symbol.for()` / `Symbol()` brand** — a branded property that marks an object as an envelope. `Symbol.for()` has cross-realm issues (iframes, workers). `Symbol()` requires exporting/importing the symbol, creating import coupling for adapters.
3. **Closed-set string discriminant** — check `meta.source` against `["local", "http", "mcp"]`. JSON-serializable, cross-realm safe, no import coupling. Low false-positive risk because the source strings are under our control.
Option 3 was chosen. See [response-envelopes.md § Detection](../response-envelopes.md#detection) for the implementation.
## Consequences
- All `execute()` consumers must access `.data` on the result or use `unwrap()`
- All `call.responded` event consumers must expect `ResponseEnvelope` instead of `unknown`
- Handlers that currently call `callMap.respond()` must be updated to return values instead
- MCP adapters must use `mcpEnvelope()` instead of returning `ContentBlock[]` directly
- OpenAPI adapters must use `httpEnvelope()` instead of returning raw response data
- `outputSchema` continues to describe the business data shape (`envelope.data`), not the envelope itself
- The envelope shape is JSON-serializable (important for pubsub over Redis/WebSocket)
- New metadata sources require adding a variant to `ResponseMeta` and updating `RESPONSE_SOURCES`
- MCP operations with `outputSchema` (2025-06-18+ spec) become composable with local operations — `envelope.data` matches the TypeBox-converted `outputSchema`. `Value.Cast()` from `@alkdev/typebox/value` can further normalize `structuredContent` against the schema, stripping excess properties and filling defaults
- MCP operations without `outputSchema` remain non-composable — `envelope.data` is `MCPContentBlock[]` and `outputSchema` is `Type.Unknown()`
## Out of Scope
This ADR does **not** cover:
- **Envelope transformation/middleware chains** — no support for intercepting or transforming envelopes between handler and consumer
- **Envelope caching** — envelopes are not cache keys or cached values; caching is a consumer concern
- **Pagination via envelope metadata** — HTTP pagination cursors in headers are accessible via `meta.headers` but there is no built-in pagination abstraction
- **Envelope-based retry logic** — retry policies are a consumer concern, not an envelope concern
- **Serving direction** — envelopes cover consuming remote operations. When the operations package exposes operations *as* an MCP server or OpenAPI endpoint, the results must be wrapped in the protocol's format (MCP `CallToolResult`, HTTP response). This reverse direction is a separate concern.
A future "client" abstraction may address some of these patterns; see Open Questions in the spec document.
> **Note on ADR consolidation**: The original draft document contained 10 inline ADR sections (ADR-005 through ADR-014), each covering a sub-decision (envelope shape, MCP handling, HTTP handling, local handling, unwrap, call protocol changes, detection, outputSchema validation, client deferral). These have been consolidated into this single ADR because they constitute one coherent design decision — introducing response envelopes — with the individual trade-offs documented as rationale subsections. See [response-envelopes.md](../response-envelopes.md) for the full specification.

View File

@@ -5,306 +5,20 @@ last_updated: 2026-05-10
# Response Envelopes
How adapter-produced operations wrap their outputs in structured envelopes, how consumers unwrap them, and how the call protocol carries envelope information through the event layer.
> **Note**: This spec supersedes the current behavior described in [call-protocol.md](call-protocol.md) and [api-surface.md](api-surface.md). Those documents describe the pre-envelope model where `execute()` returns `TOutput` and handlers publish `call.responded` directly. The migration checklist below tracks the required updates. Until those documents are updated, this spec is the authoritative source for envelope behavior.
Types, factory functions, integration points, and constraints for the `ResponseEnvelope` system. See [ADR-005](decisions/005-response-envelopes.md) for the design rationale.
## Problem
Every adapter produces operation results in a different wire format:
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:
- **MCP** returns `CallToolResult``{ content: ContentBlock[], structuredContent?: Record<string, unknown>, isError?: boolean }` where `ContentBlock` is a discriminated union (`text`, `image`, `audio`, `resource`).
- **OpenAPI (HTTP)** returns raw HTTP response bodies — `application/json` → parsed JSON, `text/*` → string, or `ArrayBuffer` for binary. There is no envelope; error information is in the HTTP status code.
- **Local operations** return whatever the handler function returns — a plain value matching `outputSchema`.
- 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+)
Currently the codebase treats all three identically: `outputSchema: Type.Unknown()` for MCP, and for OpenAPI the handler just returns whatever `response.json()` / `.text()` / `.arrayBuffer()` produces. There is no unified way for consumers to:
1. Know *what kind of thing* produced the result (MCP tool? HTTP call? local handler?)
2. Access metadata about the response (HTTP status, content type, MCP error flags)
3. Distinguish structured vs. unstructured content within the same result
4. Handle multi-content responses (MCP arrays) vs. single-value responses (local ops)
This gap forces each consumer to write adapter-specific unwrapping logic, which defeats the purpose of a unified registry.
## Design Decisions
### ADR-005: Response Envelope as a First-Class Concept
**Context**: Operations from different sources produce results in fundamentally different shapes. The registry treats them all as `unknown` output, losing structural information that consumers need.
**Decision**: Introduce a `ResponseEnvelope` type that wraps every operation result with transport metadata. Adapters produce envelopes; consumers unwrap or inspect them.
**Trade-offs**:
- (+) Consumers get a uniform interface regardless of operation source
- (+) Envelope-preserving handlers can pass through metadata (HTTP status, MCP content blocks) without losing it
- (+) Error metadata (MCP `isError`, HTTP status codes) is accessible without ad-hoc conventions
- () Adds a wrapping layer for local operations that don't need it
- () Requires all existing consumers to decide: unwrap or carry the envelope
**Rationale**: The benefit of uniform access outweighs the wrapping cost. Local operations get the simplest envelope (`{ data, meta: { source: "local" } }`); adapters add their own metadata. Consumers that don't care about metadata can use `unwrap()`.
---
### ADR-006: Envelope Structure — Flat Data + Typed Metadata
**Context**: Several envelope designs were considered:
1. **Discriminated union per source**`{ type: "mcp", ...mcpFields } | { type: "http", ...httpFields } | { type: "local", data: T }`
2. **Generic envelope with typed meta**`{ data: T, meta: ResponseMeta }` where `ResponseMeta` is a discriminated union
3. **Adapter-specific envelope per adapter** — each adapter defines its own output type
**Decision**: Option 2 — a generic `ResponseEnvelope<T>` with a `data` field for the actual result and a `meta` field carrying transport-specific metadata.
```ts
interface ResponseEnvelope<T = unknown> {
data: T
meta: ResponseMeta
}
type ResponseMeta =
| LocalResponseMeta
| HTTPResponseMeta
| MCPResponseMeta
```
**Trade-offs**:
- (+) Single type to pattern-match on, one `meta.source` to dispatch
- (+) `data` is always in the same place regardless of source
- (+) Easy to add new metadata variants without changing the envelope shape
- () `meta` must be discriminated on `source` for type narrowing
- () Local operations add a trivially-simple envelope
**Rationale**: Having `data` in a predictable location makes the 90% case (just get the result) simple. `meta` is for the 10% case (inspect transport details). A discriminated union on `source` gives typed access to each variant's fields.
---
### ADR-007: MCP Output Handling — `structuredContent` First
**Context**: MCP `CallToolResult` has two result fields:
- `content: ContentBlock[]` — unstructured array of text/image/audio/resource blocks
- `structuredContent?: Record<string, unknown>` — structured output matching the tool's `outputSchema` (MCP spec version 2025-03-26+)
When `structuredContent` is present, it is the typed, schema-conforming output. When absent, only `content` is available and must be interpreted heuristically.
**Decision**: The MCP adapter will:
1. If `structuredContent` is present → use it as `data` in the envelope, `Type.Unknown()` remains the `outputSchema` (since we can't reliably map MCP's JSON Schema output to TypeBox at tool-discovery time)
2. If `structuredContent` is absent → use `content` as `data` (the `MCPContentBlock[]` array)
3. Always include the full `CallToolResult` metadata in `meta` (`isError`, `_meta`, full `content` array)
4. If `isError: true` → the tool ran but returned an error result. The handler wraps the result in an envelope with `meta.isError: true` and **does not throw**. This preserves the error content for the consumer. This differs from protocol-level errors (tool not found, transport failure) which still throw `CallError`.
The `outputSchema` on MCP-generated operations remains `Type.Unknown()` because MCP does not provide output schemas at tool-listing time, and even with `structuredContent` the schema validation would need to happen at call time.
**Trade-offs**:
- (+) Consumers get preferential access to structured output when available
- (+) The raw content blocks are still in `meta` for rendering or fallback
- (+) Matches MCP spec intent: `structuredContent` is the programmatically-usable output
- (+) Error results are accessible to consumers — MCP `isError` doesn't always mean a call failure
- () The shape of `data` changes based on whether `structuredContent` is present
- () `outputSchema` can't reflect this dynamically
- () Consumers must check `meta.isError` for MCP operations, whereas they check thrown exceptions for other sources
**Rationale**: MCP is moving toward `structuredContent` as the primary output channel. Prioritizing it positions us correctly. The fallback to `content` handles older servers. Since `outputSchema` is already `Type.Unknown()` for MCP ops, the variability is acceptable. Not throwing on `isError: true` preserves the MCP semantic where error results are still useful content, not call failures.
---
### ADR-008: HTTP Response Envelope — Preserve Transport Metadata
**Context**: The OpenAPI adapter currently returns raw deserialized response bodies with no metadata about the HTTP transaction itself. Consumers that need HTTP status codes, headers, or content-type information have no way to access them.
**Decision**: The OpenAPI adapter handler will return a `ResponseEnvelope` with `HTTPResponseMeta`:
```ts
interface HTTPResponseMeta {
source: "http"
statusCode: number
headers: Record<string, string>
contentType: string
}
```
- `data` contains the parsed response body (JSON object, string, or ArrayBuffer — unchanged from current behavior)
- `statusCode` preserves the HTTP status code
- `headers` captures response headers for downstream use (pagination links, rate limits, etc.)
- `contentType` records the original content type
**Known limitation**: `headers` is `Record<string, string>` which does not preserve multi-value headers or `Set-Cookie`. For APIs that use these, headers with the same key are joined with `, ` (following the `fetch` `Headers` specification). This is a deliberate simplification; if multi-value headers become critical, the type can be extended to `Record<string, string | string[]>`.
**Trade-offs**:
- (+) Consumers can make decisions based on HTTP metadata (pagination, caching, error differentiation)
- (+) No information is lost from the HTTP response (within the single-value header limitation)
- () Breaking change from the current raw return value
- () Headers map is a snapshot; streaming headers are not supported
- () Multi-value headers (especially `Set-Cookie`) are collapsed
**Rationale**: HTTP responses carry essential metadata. Losing it in an adapter that's supposed to expose the full capability of the underlying protocol is a design flaw. The envelope preserves this with minimal overhead. The multi-value header limitation can be addressed in a future iteration if needed.
---
### ADR-009: Local Operations Get Minimal Envelopes
**Context**: Local operations (registered directly in-process) return plain values from their handlers. Forcing them to wrap in `ResponseEnvelope` would be ergonomically poor.
**Decision**: Local operation handlers return raw values. `OperationRegistry.execute()` and `CallHandler` automatically wrap raw values in a minimal `ResponseEnvelope` with `LocalResponseMeta`:
```ts
interface LocalResponseMeta {
source: "local"
operationId: string
timestamp: number // Unix epoch milliseconds (Date.now())
}
```
This means consumers of `execute()` and call protocol responses always receive `ResponseEnvelope`, regardless of whether the operation came from MCP, HTTP, or local code.
For handlers that return `void` or `undefined`, the envelope wraps `data: undefined`. The envelope is still produced — `data` is `undefined` and `meta` contains the operation metadata.
**Trade-offs**:
- (+) Uniform result type for all consumers — no adapter-specific code needed
- (+) Handlers stay simple — return raw values
- (+) Call protocol events naturally carry envelope data
- () Local envelope wrapping adds a thin layer that may seem unnecessary for in-process calls
- () Call protocol events currently have `output: unknown`; changing this to `output: ResponseEnvelope` is a breaking change
**Rationale**: The uniform interface is worth the thin wrapping layer. Most code just calls `envelope.data` and moves on. Code that cares about transport metadata inspects `envelope.meta`.
---
### ADR-010: `unwrap()` Utility for Simple Consumption
**Context**: Most consumers don't care about transport metadata. They just want the output value.
**Decision**: Provide an `unwrap()` utility:
```ts
function unwrap<T>(envelope: ResponseEnvelope<T>): T {
return envelope.data
}
```
This is literally `envelope.data`. It exists as a named function for documentation purposes and to make intent explicit at call sites.
**Trade-offs**:
- (+) Clear intent at call sites: `unwrap(result)` is self-documenting
- (+) Easy to search for — all envelope-unwrapping sites are explicit
- (+) Provides a place to add validation or transformation logic later if needed
- () It's just property access; some may find it unnecessary
**Rationale**: The cost is near-zero and the clarity benefit is real. It future-proofs the API for when envelope handling might need to evolve.
---
### ADR-011: Call Protocol Breaking Change and Handler Responsibility Shift
**Context**: The `call.responded` event currently has `{ requestId, output: unknown }`. With envelopes, `output` should carry a `ResponseEnvelope`. This is a breaking change for all call protocol consumers. Additionally, the current architecture has handlers publishing `call.responded` themselves, which creates an ambiguity about who is responsible for wrapping.
**Decision — Breaking change**: `call.responded.output` changes from `unknown` to `ResponseEnvelope`. This is a single-release breaking change — there is no transitional API. The call protocol is in draft status, the package is pre-1.0, and the consumers (alkhub, opencode) are under our control.
**Decision — Handler responsibility shift**: `CallHandler` becomes the sole publisher of `call.responded`. Handlers always return their result value (either raw or a `ResponseEnvelope`). `CallHandler`:
1. Validates input and checks access control (existing behavior)
2. Calls `handler(input, context)`
3. Wraps the return value in a `ResponseEnvelope` if it isn't one already
4. Publishes `call.responded` via `callMap.respond(requestId, envelope)`
5. On error, publishes `call.error` (existing behavior)
This eliminates the current pattern where handlers must publish `call.responded` themselves. Handlers that currently call `callMap.respond()` directly must be updated to return values instead.
**Migration impact**:
- `execute()` return type: `Promise<TOutput>``Promise<ResponseEnvelope<TOutput>>`
- `callMap.call()` resolve value: `unknown``ResponseEnvelope`
- `call.responded.output`: `unknown``ResponseEnvelope`
- Handler contract: handlers return values, they do NOT publish events
- Consumers that do `const result = await registry.execute(...)` must change to `const envelope = await registry.execute(...)` and access `envelope.data` or use `unwrap()`
**Trade-offs**:
- (+) Clear single responsibility: `CallHandler` wraps and publishes, handlers compute
- (+) Every `call.responded` event is guaranteed to carry a `ResponseEnvelope`
- (+) No ambiguity about who handles envelope construction
- () Breaking change for all call protocol consumers — no transitional period
- () Handlers that currently publish `call.responded` must be refactored to return values
- () `execute()` callers must adapt to envelope return type
**Rationale**: Having a single, clear ownership model for event publishing and envelope wrapping eliminates the ambiguity entirely. The breaking change is contained and the consumers are known and coordinated. A transitional API would add complexity for no long-term benefit.
---
### ADR-012: Envelope Detection via Discriminant
**Context**: When `execute()` or `CallHandler` receives a handler return value, it needs to determine whether it's already a `ResponseEnvelope` or a raw value that needs wrapping.
Initially, duck-typing (`data` + `meta` + `meta.source` is a string) was considered, but this has a false-positive risk on user data that happens to have those properties. A `Symbol` brand was then considered, but `Symbol.for()` has cross-realm issues (iframes, workers) and `Symbol()` requires exporting for adapters, creating a coupling point.
**Decision**: Use a plain string discriminant. A `ResponseEnvelope` is detected by checking for `meta.source` being one of the known source strings (`"local"`, `"http"`, `"mcp"`). This is a closed set that we control.
```ts
const RESPONSE_SOURCES = ["local", "http", "mcp"] as const
type ResponseSource = typeof RESPONSE_SOURCES[number]
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)
}
```
**Why not `Symbol`**: `Symbol.for()` is realm-specific and fails across iframe/Worker boundaries. `Symbol()` requires the symbol to be exported and imported by every consumer and adapter, creating tight coupling. The string discriminant approach works everywhere and the false-positive risk is negligible because `meta.source` must be one of our three known strings.
**Why not duck-typing alone**: A user could return `{ data: "hello", meta: { source: "local" } }` as an operation output that happens to match the envelope shape. However, since the known sources (`"local"`, `"http"`, `"mcp"`) are under our control and unlikely to appear naturally in operation outputs, the risk is acceptable. If it becomes a problem, we can add a versioned brand string like `__envelopeVersion: 1`.
**Trade-offs**:
- (+) Works across all JS realms (no Symbol cross-realm issues)
- (+) No import coupling — adapters don't need to import a symbol
- (+) JSON-serializable — envelope survives transport boundaries (pubsub over Redis/WebSocket)
- (+) `isResponseEnvelope()` type guard provides TypeScript narrowing
- () Theoretical false-positive if a user returns an object with `meta.source: "local"` by coincidence
- () New sources must be registered in `RESPONSE_SOURCES`
**Rationale**: The string discriminant approach is the simplest, most portable, and most debuggable option. It works with JSON serialization (important for cross-transport), requires no cross-realm Symbol handling, and the closed `RESPONSE_SOURCES` set prevents accidental collision. The `isResponseEnvelope()` guard provides a single point of detection logic.
---
### ADR-013: `outputSchema` Validates Inner Data, Not Envelope
**Context**: After envelope wrapping, `execute()` returns `ResponseEnvelope<TOutput>`. But `outputSchema` on `OperationSpec` describes the inner `data` shape (e.g., `Type.Object({ result: Type.String() })`), not the full envelope. If output validation is changed to validate against the envelope, it would always fail.
**Decision**: `outputSchema` validates the inner `data` of the envelope, not the full `ResponseEnvelope`. The current `registry.execute()` validation flow changes to:
1. Run handler
2. Wrap result in envelope (if not already one)
3. Validate `envelope.data` against `spec.outputSchema` (warning-only, not thrown)
4. Return envelope
This means `outputSchema` continues to describe the business data shape, not the transport envelope. The envelope is always present in the return value but is not part of the schema contract.
**Trade-offs**:
- (+) `outputSchema` stays focused on business data — no envelope schema bloat
- (+) Existing `outputSchema` definitions don't need changes
- (+) Clear separation: schema describes data, envelope describes transport
- () Callers must understand that `outputSchema` validates `data`, not the full envelope
- () The envelope shape itself is not validated by `outputSchema` (but it has its own `ResponseEnvelopeSchema`)
---
### ADR-014: No Client Abstraction (Yet)
**Context**: A "client" that handles envelopes, retries, and transport details was considered. This would be a higher-level abstraction that sits above the registry and call protocol.
**Decision**: Do not introduce a `Client` class at this time. The current architecture has three distinct layers that serve different purposes:
- `OperationRegistry` — spec/handler storage, `execute()` for direct calls
- `PendingRequestMap` / `CallHandler` — event-based call protocol
- `ResponseEnvelope` — result wrapping with metadata
A "client" would conflate these layers. Instead, the envelope concept is introduced as a result type that flows through existing mechanisms.
The `buildEnv()` function is the closest thing to a "client" — it provides namespace-keyed operation access. It should be updated to propagate envelopes.
**Trade-offs**:
- (+) Avoids over-engineering; the envelope type is sufficient for the current need
- (+) Each layer stays focused on its concern
- () There's no single "call this operation and give me the result" API that handles envelope unwrapping
- () Consumers must understand the envelope concept to get at data
**Rationale**: The registry + execute + envelope is sufficient. A client abstraction can be introduced later if a clear pattern emerges from usage.
See ADR-005 for the full problem statement, composability analysis, and rationale.
## Types
@@ -317,30 +31,43 @@ interface ResponseEnvelope<T = unknown> {
}
```
The universal result wrapper. `data` holds the operation output. `meta` carries transport-specific metadata.
Note: There is no Symbol brand on `ResponseEnvelope`. Detection is via `isResponseEnvelope()` which checks `meta.source` against the known sources. See ADR-012.
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> // Single-value only; multi-value headers joined with ", "
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
@@ -350,6 +77,13 @@ interface MCPResponseMeta {
}
```
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
@@ -374,45 +108,9 @@ interface MCPAnnotations {
}
```
These types mirror the MCP `CallToolResult` and `ContentBlock` structure, but are defined independently to avoid coupling to the MCP SDK as a runtime dependency. The MCP adapter maps from SDK types to these types.
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.
### Schema Constants
```ts
const LocalResponseMetaSchema = Type.Object({
source: Type.Literal("local"),
operationId: Type.String(),
timestamp: Type.Number({ description: "Unix epoch milliseconds" }),
})
const HTTPResponseMetaSchema = Type.Object({
source: Type.Literal("http"),
statusCode: Type.Number(),
headers: Type.Record(Type.String(), Type.String()),
contentType: Type.String(),
})
const MCPResponseMetaSchema = Type.Object({
source: Type.Literal("mcp"),
isError: Type.Boolean(),
content: Type.Array(MCPContentBlockSchema),
structuredContent: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
_meta: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
})
const ResponseMetaSchema = Type.Union([
LocalResponseMetaSchema,
HTTPResponseMetaSchema,
MCPResponseMetaSchema,
])
const ResponseEnvelopeSchema = Type.Object({
data: Type.Unknown({ description: "Operation output" }),
meta: ResponseMetaSchema,
})
```
TypeBox schemas for use in validation, spec definitions, and call protocol events.
## Detection
### `isResponseEnvelope()`
@@ -424,25 +122,21 @@ function isResponseEnvelope(value: unknown): value is ResponseEnvelope {
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
)
return RESPONSE_SOURCES.includes((obj.meta as ResponseMeta).source as ResponseSource)
}
```
Type guard for envelope detection. Used by `execute()` and `CallHandler` to determine whether a handler return value is already an envelope or needs wrapping.
Used by `execute()` and `CallHandler` to detect whether a handler return value is already an envelope. Detection by `meta.source` discriminant is:
### `unwrap()`
- 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)
```ts
function unwrap<T>(envelope: ResponseEnvelope<T>): T {
return envelope.data
}
```
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
## Factory Functions
Adapters should not construct envelope objects directly. Instead, they should use factory functions that ensure the correct `source` discriminant and consistent construction:
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> {
@@ -458,149 +152,187 @@ function mcpEnvelope<T>(data: T, meta: Omit<MCPResponseMeta, "source">): Respons
}
```
These factories ensure the `source` string matches exactly, are used by `isResponseEnvelope()` detection, and provide a single point of construction for future evolution. Note: `localEnvelope(undefined, opId)` is valid — `T` resolves to `undefined` for void handlers, and the envelope wraps `data: undefined`.
- `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
## Integration Points
## Utility
### API Impact Summary
| API | Before | After |
|-----|--------|-------|
| `registry.execute()` return | `Promise<TOutput>` | `Promise<ResponseEnvelope<TOutput>>` |
| `callMap.call()` resolve value | `unknown` (raw) | `ResponseEnvelope` |
| `call.responded.output` | `unknown` | `ResponseEnvelopeSchema` |
| MCP handler return | `ContentBlock[]` (raw) | `ResponseEnvelope` (via `mcpEnvelope()`) |
| HTTP handler return | `any` (JSON/text/ArrayBuffer) | `ResponseEnvelope` (via `httpEnvelope()`) |
| Local handler return | raw value (unchanged) | raw value (infrastructure wraps via `localEnvelope()`) |
| `outputSchema` validation target | raw return value | `envelope.data` |
| `subscribe()` yield | raw value | `ResponseEnvelope` per yield |
### `OperationRegistry.execute()`
Currently returns `Promise<TOutput>`. After envelopes, returns `Promise<ResponseEnvelope<TOutput>>`:
### `unwrap()`
```ts
async execute<TInput = unknown, TOutput = unknown>(
operationId: string,
input: TInput,
context: OperationContext,
): Promise<ResponseEnvelope<TOutput>>
```
Implementation:
1. Look up spec and handler (existing behavior)
2. Validate input with `validateOrThrow` (existing behavior)
3. Run the handler
4. If the return value `isResponseEnvelope()` → pass through as the result
5. Otherwise → wrap in `localEnvelope(result, operationId)`
6. Validate `envelope.data` against `spec.outputSchema` (warning-only, not thrown)
7. Return envelope
Note: `isResponseEnvelope()` detects envelopes by `meta.source` matching a known source string. It does not validate that the envelope's `source` matches the operation's origin. For example, an MCP handler that explicitly returns a `localEnvelope(...)` would pass through as-is. Handlers that explicitly construct envelopes take responsibility for their metadata.
### `CallHandler`
The `CallHandler` currently calls `handler(input, context)` and expects the handler to publish `call.responded` itself. After envelopes, `CallHandler` takes full ownership of publishing:
1. Look up spec and handler (existing behavior)
2. Check access control (existing behavior)
3. Validate input (existing behavior)
4. Call `handler(input, context)` and await the result
5. If the result `isResponseEnvelope()` → use it directly
6. Otherwise → wrap in `localEnvelope(result, operationId)`
7. Validate `envelope.data` against `spec.outputSchema` — warning-only, logged but not thrown (consistent with `execute()`)
8. Publish `call.responded` via `callMap.respond(requestId, envelope)`
9. On handler exception → publish `call.error` (existing behavior). Note: an envelope with `meta.isError: true` (e.g., MCP error results) does **not** trigger `call.error`. Only thrown exceptions from the handler trigger `call.error`. An envelope with `meta.isError: true` is a successful return where the consumer checks `envelope.meta.isError`.
This eliminates the current pattern where handlers must publish `call.responded` themselves. Handlers never call `callMap.respond()` — they return values.
**`PendingRequestMap.respond()` enforcement**: Since `CallHandler` is the sole publisher, `respond(requestId, output)` must enforce that `output` is a valid `ResponseEnvelope`. If `respond()` is called with a non-envelope value, it throws an error. This prevents any code bypassing `CallHandler` from publishing raw values to the call protocol. A future iteration may make `respond()` internal (not exported on the public API surface) to further enforce this invariant.
### `PendingRequestMap.call()`
Currently resolves with `responded.output` (typed as `unknown`). After envelopes, resolves with the `ResponseEnvelope`:
```ts
// Before
pending.resolve(responded.output)
// After
pending.resolve(responded.output as ResponseEnvelope)
```
### `buildEnv()`
Currently returns `OperationEnv = Record<string, Record<string, (input: unknown) => Promise<unknown>>>`. After envelopes, each function returns `Promise<ResponseEnvelope>`. The type becomes:
```ts
type OperationEnv = Record<string, Record<string, (input: unknown) => Promise<ResponseEnvelope>>>
```
Nested operation calls always get envelopes, which is consistent with `execute()`.
### `subscribe()`
Currently yields raw handler output. After envelopes:
```ts
async function* subscribe(
registry: OperationRegistry,
operationId: string,
input: unknown,
context: OperationContext,
): AsyncGenerator<ResponseEnvelope, void, unknown>
```
Wrapping flow:
1. Get spec and handler from registry (existing behavior)
2. Cast handler to `AsyncGenerator` and iterate
3. For each yielded value: if `isResponseEnvelope(value)` → yield it directly; otherwise → wrap in `localEnvelope(value, operationId)` and yield
4. `operationId` comes from the `subscribe()` parameter; `timestamp` is `Date.now()` per yield (fresh timestamp for each emitted event)
5. On generator cleanup, call `generator.return()` in `finally` (existing behavior)
Adapter subscription handlers (e.g., SSE in OpenAPI) that explicitly return `ResponseEnvelope` values pass through via `isResponseEnvelope()` detection. The same `ResponseEnvelope` type is used for consistency — there is no separate `SubscriptionEnvelope`.
### MCP Adapter (`from_mcp.ts`)
Current handler:
```ts
handler: async (input) => {
const result = await client.callTool({ name, arguments: input })
if (result.isError) throw new Error(`MCP tool error: ${JSON.stringify(result.content)}`)
return result.content // returns ContentBlock[]
function unwrap<T>(envelope: ResponseEnvelope<T>): T {
return envelope.data
}
```
After envelopes:
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
handler: async (input) => {
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
### `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. If result `isResponseEnvelope()` → pass through as-is
5. Otherwise → wrap in `localEnvelope(result, operationId)`
6. Validate `envelope.data` against `spec.outputSchema` — warning-only, logged but not thrown
7. 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 and handler (existing)
2. Check access control (existing)
3. Validate input (existing)
4. Call handler and await result
5. If result `isResponseEnvelope()` → use directly
6. Otherwise → wrap in `localEnvelope(result, operationId)`
7. Validate `envelope.data` against `spec.outputSchema` — warning-only
8. Publish `call.responded` via `callMap.respond(requestId, envelope)`
9. 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 |
| `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 })
if (result.isError) {
// MCP isError: true means the tool ran but returned an error result.
// We still wrap it in an envelope — the consumer checks meta.isError.
// Transport-level errors (tool not found, connection failure) still throw CallError.
}
// 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,
structuredContent: result.structuredContent as Record<string, unknown> | undefined,
_meta: result._meta as Record<string, unknown> | undefined,
},
)
}
```
**Key behavior change**: MCP `isError: true` no longer throws. The error content is accessible via `envelope.data` and `envelope.meta.content`. The consumer checks `envelope.meta.isError` to distinguish error results from success results. This preserves the MCP semantic where error content is still useful.
**`mapMCPContentBlocks()`**: Maps SDK `ContentBlock[]` to our `MCPContentBlock[]`. Signature: `(sdkBlocks: SDKContentBlock[]) => MCPContentBlock[]`. This is a 1:1 field mapping from MCP SDK types to our own `MCPContentBlock` discriminated union, decoupling us from the MCP SDK at the interface level. Each SDK content block type (`text`, `image`, `audio`, `resource`, `resource_link`) maps to the corresponding variant in our `MCPContentBlock` type, preserving all fields including optional `annotations`.
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`)
Current handler returns raw `response.json()` / `.text()` / `.arrayBuffer()`.
Handler behavior change:
After envelopes:
```ts
handler: async (input, context) => {
// ... existing URL construction and fetch logic ...
@@ -628,11 +360,38 @@ handler: async (input, context) => {
}
```
### Call Protocol Event Shape
- 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)
`call.responded` changes from `{ requestId, output: unknown }` to `{ requestId, output: ResponseEnvelope }`.
### Value.Cast() for Data Normalization
When an adapter has a meaningful `outputSchema` (not `Type.Unknown()`), `Value.Cast()` from `@alkdev/typebox/value` can normalize `envelope.data` against the schema:
```ts
import { Value } from "@alkdev/typebox/value"
// In execute(), after wrapping the result:
if (!isResponseEnvelope(result)) {
envelope = localEnvelope(result, operationId)
} else {
envelope = result
}
// Normalize data against outputSchema (when schema is meaningful)
if (spec.outputSchema[Kind] !== "Unknown") {
envelope.data = Value.Cast(spec.outputSchema, envelope.data)
}
```
This strips excess properties, fills defaults for missing ones, and upcasts values to match the declared type. For local operations, this provides the same guarantee as `Value.Check()` validation but with normalization instead of just warning. For MCP operations with `outputSchema`, it strips envelope-like properties that MCP servers might add beyond the declared schema. For OpenAPI operations, it normalizes the response body against the spec.
Operations with `Type.Unknown()` `outputSchema` (typically MCP tools without `outputSchema`) are excluded — `Value.Cast()` against `Type.Unknown()` is a no-op.
### `CallEventSchema`
`call.responded.output` changes from `Type.Unknown()` to `ResponseEnvelopeSchema`:
`CallRespondedEventSchema`:
```ts
"call.responded": Type.Object({
requestId: Type.String(),
@@ -640,41 +399,67 @@ handler: async (input, context) => {
})
```
## `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.
There are two validation/normalization steps for `envelope.data`:
1. **Warning validation** (`collectErrors` + `formatValueErrors`): Checks `envelope.data` against `spec.outputSchema` and logs warnings on mismatch. This is the existing behavior in `execute()`.
2. **Data 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. This is particularly important for MCP `structuredContent` (which may contain extra properties from the MCP envelope) and OpenAPI response bodies (which may have extra fields not in the spec).
The `Value.Cast()` 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, not in user code
2. **Envelope is always present at the call protocol boundary**`call.responded` always carries `ResponseEnvelope`, never raw values. `PendingRequestMap.respond()` enforces this at runtime.
3. **Adapters use factory functions**`localEnvelope()`, `httpEnvelope()`, `mcpEnvelope()` ensure correct `source` discriminant and consistent construction. 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.output` shape change in one release. Call protocol is draft status, package is pre-1.0, consumers are coordinated.
6. **`unwrap()` is the recommended simple-path API** — for consumers that don't need metadata
7. **`outputSchema` validates `envelope.data`, not the full envelope** — `ResponseEnvelopeSchema` validates the envelope structure at the call protocol level; `outputSchema` validates the business data
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
## Related Spec Updates
## Open Questions
When this spec stabilizes, the following documents must be updated to reflect envelope changes:
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
When this spec stabilizes, the following documents and code must be updated:
| Document | Section | Change |
|----------|----------|--------|
|----------|---------|--------|
| `call-protocol.md` | CallHandler | Handler no longer publishes `call.responded`; returns values. CallHandler wraps and publishes. |
| `call-protocol.md` | PendingRequestMap | `respond()` validates envelope; resolves with `ResponseEnvelope` instead of `unknown` |
| `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 | New: wraps handler result, publishes `call.responded`. No longer "handler publishes" model. |
| `adapters.md` | from_mcp | Handler returns `mcpEnvelope()`. MCP `isError: true` no longer throws. |
| `adapters.md` | from_openapi | Handler returns `httpEnvelope()`. Error on HTTP error status still throws `CallError`. |
| `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` | New: wraps handler result, publishes `call.responded`. No longer "handler publishes" model. |
| `call-protocol.md` | `PendingRequestMap.respond()` | Now enforces `isResponseEnvelope()` check — throws on raw values. Behavioral breaking change. |
| `api-surface.md` | `PendingRequestMap.respond()` | Same — `respond()` now requires `ResponseEnvelope` argument. |
| `adapters.md` | `from_mcp` | Handler returns `mcpEnvelope()`. MCP `isError: true` no longer throws. |
| `adapters.md` | `from_mcp` | `outputSchema` extracted from MCP tool definitions when available (2025-06-18+ spec), converted via `FromSchema`. Falls back to `Type.Unknown()`. |
| `adapters.md` | `from_openapi` | Handler returns `httpEnvelope()`. Error on HTTP error status still throws `CallError`. |
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
- MCP `CallToolResult``spec.types.d.ts` in `@modelcontextprotocol/sdk`
- MCP `ContentBlock` type — discriminated union of `text`, `image`, `audio`, `resource`, `resource_link`
- OpenAPI response handling — `src/from_openapi.ts`, `createHTTPOperation` handler
- Call protocol events — `src/call.ts`, `CallEventSchema`
- [call-protocol.md](call-protocol.md) — Current event shapes and `PendingRequestMap` design
- [adapters.md](adapters.md) — Current adapter handler implementations
- [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