Add response envelopes architecture spec (ADR-005 through ADR-014)
Introduce ResponseEnvelope as a first-class concept for uniform operation result handling across MCP, OpenAPI, and local adapters. Key decisions: - ResponseEnvelope<T> wraps every result with typed metadata (LocalResponseMeta, HTTPResponseMeta, MCPResponseMeta) - CallHandler becomes sole publisher of call.responded (handler responsibility shift from publish-to-return) - Envelope detection via closed-set string discriminant (isResponseEnvelope) — no Symbols, JSON-serializable - MCP isError:true no longer throws; wraps in envelope with meta.isError flag preserving error content for consumers - outputSchema validates envelope.data, not the full envelope - PendingRequestMap.respond() validates envelope at runtime - Factory functions (localEnvelope, httpEnvelope, mcpEnvelope) ensure consistent construction - Breaking change: execute() returns ResponseEnvelope<TOutput>, call.responded.output is ResponseEnvelopeSchema - No Client abstraction yet (ADR-014)
This commit is contained in:
680
docs/architecture/response-envelopes.md
Normal file
680
docs/architecture/response-envelopes.md
Normal file
@@ -0,0 +1,680 @@
|
||||
---
|
||||
status: draft
|
||||
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.
|
||||
|
||||
## Problem
|
||||
|
||||
Every adapter produces operation results in a different wire format:
|
||||
|
||||
- **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`.
|
||||
|
||||
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.
|
||||
|
||||
## Types
|
||||
|
||||
### `ResponseEnvelope`
|
||||
|
||||
```ts
|
||||
interface ResponseEnvelope<T = unknown> {
|
||||
data: T
|
||||
meta: ResponseMeta
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### `ResponseMeta` (discriminated union)
|
||||
|
||||
```ts
|
||||
type ResponseMeta = LocalResponseMeta | HTTPResponseMeta | MCPResponseMeta
|
||||
|
||||
type ResponseSource = "local" | "http" | "mcp"
|
||||
|
||||
interface LocalResponseMeta {
|
||||
source: "local"
|
||||
operationId: string
|
||||
timestamp: number // Unix epoch milliseconds (Date.now())
|
||||
}
|
||||
|
||||
interface HTTPResponseMeta {
|
||||
source: "http"
|
||||
statusCode: number
|
||||
headers: Record<string, string> // Single-value only; multi-value headers joined with ", "
|
||||
contentType: string
|
||||
}
|
||||
|
||||
interface MCPResponseMeta {
|
||||
source: "mcp"
|
||||
isError: boolean
|
||||
content: MCPContentBlock[]
|
||||
structuredContent?: Record<string, unknown>
|
||||
_meta?: Record<string, unknown>
|
||||
}
|
||||
```
|
||||
|
||||
### `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 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### `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
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Type guard for envelope detection. Used by `execute()` and `CallHandler` to determine whether a handler return value is already an envelope or needs wrapping.
|
||||
|
||||
### `unwrap()`
|
||||
|
||||
```ts
|
||||
function unwrap<T>(envelope: ResponseEnvelope<T>): T {
|
||||
return envelope.data
|
||||
}
|
||||
```
|
||||
|
||||
### Factory Functions
|
||||
|
||||
Adapters should not construct envelope objects directly. Instead, they should use factory functions that ensure the correct `source` discriminant and consistent construction:
|
||||
|
||||
```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 } }
|
||||
}
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
## Integration Points
|
||||
|
||||
### 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>>`:
|
||||
|
||||
```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[]
|
||||
}
|
||||
```
|
||||
|
||||
After envelopes:
|
||||
```ts
|
||||
handler: async (input) => {
|
||||
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.
|
||||
}
|
||||
return mcpEnvelope(
|
||||
result.structuredContent ?? mapMCPContentBlocks(result.content),
|
||||
{
|
||||
isError: result.isError ?? false,
|
||||
content: mapMCPContentBlocks(result.content),
|
||||
structuredContent: result.structuredContent,
|
||||
_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`.
|
||||
|
||||
### OpenAPI Adapter (`from_openapi.ts`)
|
||||
|
||||
Current handler returns raw `response.json()` / `.text()` / `.arrayBuffer()`.
|
||||
|
||||
After envelopes:
|
||||
```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,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Call Protocol Event Shape
|
||||
|
||||
`call.responded` changes from `{ requestId, output: unknown }` to `{ requestId, output: ResponseEnvelope }`.
|
||||
|
||||
`CallRespondedEventSchema`:
|
||||
```ts
|
||||
"call.responded": Type.Object({
|
||||
requestId: Type.String(),
|
||||
output: ResponseEnvelopeSchema,
|
||||
})
|
||||
```
|
||||
|
||||
## 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
|
||||
8. **`isResponseEnvelope()` is the sole detection mechanism** — no Symbol brands, no duck-typing beyond the closed `source` discriminant set
|
||||
|
||||
## Related Spec Updates
|
||||
|
||||
When this spec stabilizes, the following documents must be updated to reflect envelope changes:
|
||||
|
||||
| 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` | 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`. |
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user