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)
35 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 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 }whereContentBlockis a discriminated union (text,image,audio,resource). - OpenAPI (HTTP) returns raw HTTP response bodies —
application/json→ parsed JSON,text/*→ string, orArrayBufferfor 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:
- Know what kind of thing produced the result (MCP tool? HTTP call? local handler?)
- Access metadata about the response (HTTP status, content type, MCP error flags)
- Distinguish structured vs. unstructured content within the same result
- 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:
- Discriminated union per source —
{ type: "mcp", ...mcpFields } | { type: "http", ...httpFields } | { type: "local", data: T } - Generic envelope with typed meta —
{ data: T, meta: ResponseMeta }whereResponseMetais a discriminated union - 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.
interface ResponseEnvelope<T = unknown> {
data: T
meta: ResponseMeta
}
type ResponseMeta =
| LocalResponseMeta
| HTTPResponseMeta
| MCPResponseMeta
Trade-offs:
- (+) Single type to pattern-match on, one
meta.sourceto dispatch - (+)
datais always in the same place regardless of source - (+) Easy to add new metadata variants without changing the envelope shape
- (−)
metamust be discriminated onsourcefor 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 blocksstructuredContent?: Record<string, unknown>— structured output matching the tool'soutputSchema(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:
- If
structuredContentis present → use it asdatain the envelope,Type.Unknown()remains theoutputSchema(since we can't reliably map MCP's JSON Schema output to TypeBox at tool-discovery time) - If
structuredContentis absent → usecontentasdata(theMCPContentBlock[]array) - Always include the full
CallToolResultmetadata inmeta(isError,_meta, fullcontentarray) - If
isError: true→ the tool ran but returned an error result. The handler wraps the result in an envelope withmeta.isError: trueand does not throw. This preserves the error content for the consumer. This differs from protocol-level errors (tool not found, transport failure) which still throwCallError.
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
metafor rendering or fallback - (+) Matches MCP spec intent:
structuredContentis the programmatically-usable output - (+) Error results are accessible to consumers — MCP
isErrordoesn't always mean a call failure - (−) The shape of
datachanges based on whetherstructuredContentis present - (−)
outputSchemacan't reflect this dynamically - (−) Consumers must check
meta.isErrorfor 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:
interface HTTPResponseMeta {
source: "http"
statusCode: number
headers: Record<string, string>
contentType: string
}
datacontains the parsed response body (JSON object, string, or ArrayBuffer — unchanged from current behavior)statusCodepreserves the HTTP status codeheaderscaptures response headers for downstream use (pagination links, rate limits, etc.)contentTyperecords 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:
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 tooutput: ResponseEnvelopeis 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:
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:
- Validates input and checks access control (existing behavior)
- Calls
handler(input, context) - Wraps the return value in a
ResponseEnvelopeif it isn't one already - Publishes
call.respondedviacallMap.respond(requestId, envelope) - 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→ResponseEnvelopecall.responded.output:unknown→ResponseEnvelope- Handler contract: handlers return values, they do NOT publish events
- Consumers that do
const result = await registry.execute(...)must change toconst envelope = await registry.execute(...)and accessenvelope.dataor useunwrap()
Trade-offs:
- (+) Clear single responsibility:
CallHandlerwraps and publishes, handlers compute - (+) Every
call.respondedevent is guaranteed to carry aResponseEnvelope - (+) No ambiguity about who handles envelope construction
- (−) Breaking change for all call protocol consumers — no transitional period
- (−) Handlers that currently publish
call.respondedmust 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.
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:
- Run handler
- Wrap result in envelope (if not already one)
- Validate
envelope.dataagainstspec.outputSchema(warning-only, not thrown) - 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:
- (+)
outputSchemastays focused on business data — no envelope schema bloat - (+) Existing
outputSchemadefinitions don't need changes - (+) Clear separation: schema describes data, envelope describes transport
- (−) Callers must understand that
outputSchemavalidatesdata, not the full envelope - (−) The envelope shape itself is not validated by
outputSchema(but it has its ownResponseEnvelopeSchema)
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 callsPendingRequestMap/CallHandler— event-based call protocolResponseEnvelope— 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
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)
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
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
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()
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()
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:
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>>:
async execute<TInput = unknown, TOutput = unknown>(
operationId: string,
input: TInput,
context: OperationContext,
): Promise<ResponseEnvelope<TOutput>>
Implementation:
- Look up spec and handler (existing behavior)
- Validate input with
validateOrThrow(existing behavior) - Run the handler
- If the return value
isResponseEnvelope()→ pass through as the result - Otherwise → wrap in
localEnvelope(result, operationId) - Validate
envelope.dataagainstspec.outputSchema(warning-only, not thrown) - 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:
- Look up spec and handler (existing behavior)
- Check access control (existing behavior)
- Validate input (existing behavior)
- Call
handler(input, context)and await the result - If the result
isResponseEnvelope()→ use it directly - Otherwise → wrap in
localEnvelope(result, operationId) - Validate
envelope.dataagainstspec.outputSchema— warning-only, logged but not thrown (consistent withexecute()) - Publish
call.respondedviacallMap.respond(requestId, envelope) - On handler exception → publish
call.error(existing behavior). Note: an envelope withmeta.isError: true(e.g., MCP error results) does not triggercall.error. Only thrown exceptions from the handler triggercall.error. An envelope withmeta.isError: trueis a successful return where the consumer checksenvelope.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:
// 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:
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:
async function* subscribe(
registry: OperationRegistry,
operationId: string,
input: unknown,
context: OperationContext,
): AsyncGenerator<ResponseEnvelope, void, unknown>
Wrapping flow:
- Get spec and handler from registry (existing behavior)
- Cast handler to
AsyncGeneratorand iterate - For each yielded value: if
isResponseEnvelope(value)→ yield it directly; otherwise → wrap inlocalEnvelope(value, operationId)and yield operationIdcomes from thesubscribe()parameter;timestampisDate.now()per yield (fresh timestamp for each emitted event)- On generator cleanup, call
generator.return()infinally(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:
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:
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:
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:
"call.responded": Type.Object({
requestId: Type.String(),
output: ResponseEnvelopeSchema,
})
Constraints
- No runtime envelope overhead for local handlers — local handlers return raw values; wrapping happens in infrastructure, not in user code
- Envelope is always present at the call protocol boundary —
call.respondedalways carriesResponseEnvelope, never raw values.PendingRequestMap.respond()enforces this at runtime. - Adapters use factory functions —
localEnvelope(),httpEnvelope(),mcpEnvelope()ensure correctsourcediscriminant and consistent construction. Adapters do not construct envelope objects directly. - MCPContentBlock types are our own, not MCP SDK types — avoids runtime dependency on
@modelcontextprotocol/sdkin the main barrel - Breaking change: single-release, no transitional API —
execute()return type andcall.responded.outputshape change in one release. Call protocol is draft status, package is pre-1.0, consumers are coordinated. unwrap()is the recommended simple-path API — for consumers that don't need metadataoutputSchemavalidatesenvelope.data, not the full envelope —ResponseEnvelopeSchemavalidates the envelope structure at the call protocol level;outputSchemavalidates the business dataisResponseEnvelope()is the sole detection mechanism — no Symbol brands, no duck-typing beyond the closedsourcediscriminant 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.tsin@modelcontextprotocol/sdk - MCP
ContentBlocktype — discriminated union oftext,image,audio,resource,resource_link - OpenAPI response handling —
src/from_openapi.ts,createHTTPOperationhandler - Call protocol events —
src/call.ts,CallEventSchema - call-protocol.md — Current event shapes and
PendingRequestMapdesign - adapters.md — Current adapter handler implementations