Files
operations/docs/architecture/response-envelopes.md
glm-5.1 a7e6cc94c7 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)
2026-05-10 04:58:05 +00:00

35 KiB
Raw Blame History

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 } 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.

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:

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:

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:

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: unknownResponseEnvelope
  • call.responded.output: unknownResponseEnvelope
  • 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.

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

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:

  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:

// 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:

  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:

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

  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 boundarycall.responded always carries ResponseEnvelope, never raw values. PendingRequestMap.respond() enforces this at runtime.
  3. Adapters use factory functionslocalEnvelope(), 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 APIexecute() 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 envelopeResponseEnvelopeSchema 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

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: unknownResponseEnvelope
api-surface.md subscribe() Yield type: unknownResponseEnvelope
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 CallToolResultspec.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 — Current event shapes and PendingRequestMap design
  • adapters.md — Current adapter handler implementations