Add remote subscription support so spokes can consume streaming operations over pubsub transports (WebSocket, Redis). Extract checkAccess to access.ts to break circular dep between call.ts and subscribe.ts.
29 KiB
status, last_updated
| status | last_updated |
|---|---|
| stable | 2026-05-16 |
Response Envelopes
Types, factory functions, integration points, and constraints for the ResponseEnvelope system. See ADR-005 for the design rationale.
Problem
Only locally-defined operations return types matching their declared outputSchema. MCP operations return ContentBlock[] (or structuredContent when the tool declares outputSchema), OpenAPI operations return raw HTTP response data, and there is no way for consumers to:
- Know what source produced a result
- Access transport metadata (HTTP status, MCP error flags)
- Compose operations across sources with a uniform interface
- Access typed data from MCP tools that declare
outputSchema(spec 2025-06-18+)
See ADR-005 for the full problem statement, composability analysis, and rationale.
Types
ResponseEnvelope
interface ResponseEnvelope<T = unknown> {
data: T
meta: ResponseMeta
}
Universal result wrapper. data holds the operation output. meta carries transport-specific metadata, discriminated on meta.source.
ResponseMeta (discriminated union)
type ResponseMeta = LocalResponseMeta | HTTPResponseMeta | MCPResponseMeta
type ResponseSource = "local" | "http" | "mcp"
LocalResponseMeta
interface LocalResponseMeta {
source: "local"
operationId: string
timestamp: number // Unix epoch milliseconds (Date.now())
}
Minimal metadata for in-process operations. operationId is the full namespace.name key. timestamp is set at wrap time.
HTTPResponseMeta
interface HTTPResponseMeta {
source: "http"
statusCode: number
headers: Record<string, string>
contentType: string
}
Preserves HTTP response metadata. Multi-value headers are joined with , per the fetch Headers specification. This does not preserve Set-Cookie as a separate header — known limitation, can be extended to Record<string, string | string[]> if needed.
MCPResponseMeta
interface MCPResponseMeta {
source: "mcp"
isError: boolean
content: MCPContentBlock[]
structuredContent?: Record<string, unknown>
_meta?: Record<string, unknown>
}
Mirrors the MCP CallToolResult structure. Key semantics:
isError: truemeans the tool ran but returned an error result — this is NOT aCallErrorexception. The handler wraps the result in an envelope. Consumers checkenvelope.meta.isErrorto distinguish error results from success results.structuredContentis the MCP structured output (spec version 2025-03-26+). When present, it is placed inenvelope.dataas the primary output.contentalways holds the full content block array, available inmetafor rendering or fallback._metacarries MCP protocol extensions.
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 are our own definitions, decoupled from @modelcontextprotocol/sdk. The MCP adapter maps from SDK types to these types. This avoids coupling the main barrel to the MCP SDK runtime dependency.
Detection
isResponseEnvelope()
const RESPONSE_SOURCES = ["local", "http", "mcp"] as const
function isResponseEnvelope(value: unknown): value is ResponseEnvelope {
if (typeof value !== "object" || value === null) return false
const obj = value as Record<string, unknown>
if (!("data" in obj) || !("meta" in obj)) return false
if (typeof obj.meta !== "object" || obj.meta === null) return false
return RESPONSE_SOURCES.includes((obj.meta as ResponseMeta).source as ResponseSource)
}
Used by execute() and CallHandler to detect whether a handler return value is already an envelope. Detection by meta.source discriminant is:
- JSON-serializable (works across pubsub transport boundaries)
- Cross-realm safe (no
Symbol.for()iframe/Worker issues) - Low false-positive risk (the known source strings are under our control)
If false positives become a concern, a versioned brand like __envelopeVersion: 1 can be added to the envelope shape. New sources must be registered in RESPONSE_SOURCES.
Factory Functions
Adapters and infrastructure use factory functions to construct envelopes. Adapters do not construct envelope objects directly.
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 } }
}
localEnvelopeis used byexecute()andCallHandlerwhen wrapping raw handler return valueshttpEnvelopeis used by the OpenAPI adapter handlermcpEnvelopeis used by the MCP adapter handlerTresolves toundefinedfor void handlers —localEnvelope(undefined, opId)is valid
Utility
unwrap()
function unwrap<T>(envelope: ResponseEnvelope<T>): T {
return envelope.data
}
Convenience for the common case where metadata is not needed. Equivalent to envelope.data. Named for clarity at call sites and as a search target for envelope-unwrapping patterns.
Schema Constants
TypeBox schemas for use in validation, spec definitions, and call protocol events:
const ResponseEnvelopeSchema = Type.Object({
data: Type.Unknown({ description: "Operation output" }),
meta: ResponseMetaSchema,
})
const ResponseMetaSchema = Type.Union([
LocalResponseMetaSchema,
HTTPResponseMetaSchema,
MCPResponseMetaSchema,
])
Individual meta schemas (LocalResponseMetaSchema, HTTPResponseMetaSchema, MCPResponseMetaSchema, MCPContentBlockSchema) are defined inline in the source. See src/response-envelope.ts for the exact definitions.
Integration Points
Shared Result Pipeline
Both OperationRegistry.execute() and CallHandler follow the same result processing pipeline after obtaining a handler result. This ensures consistent behavior whether the operation is invoked directly or through the call protocol:
- Detect envelope: If result
isResponseEnvelope()→ pass through as-is (adapter handlers return pre-built envelopes) - Wrap: Otherwise → wrap in
localEnvelope(result, operationId) - Normalize: If
spec.outputSchemais notType.Unknown(), applyValue.Cast(spec.outputSchema, envelope.data)— strips excess properties, fills defaults, upcasts values - Validate: Check
envelope.dataagainstspec.outputSchemawithcollectErrors()— warning-only, logged but not thrown
The order matters: normalization happens before validation, so warnings reflect the normalized data shape. Value.Cast() may silently fix data that would otherwise fail validation (e.g., filling defaults for optional fields), and the validation step catches any remaining mismatches.
Result Pipeline Differences
| Aspect | execute() |
CallHandler |
|---|---|---|
| Access control | Not checked (trusted internal calls) | Checks AccessControl before dispatch |
| Error handling | Throws CallError directly |
Publishes call.error via pubsub |
| Publishing | Returns envelope directly | Publishes call.responded via callMap.respond() |
| Context | Direct call, no requestId |
Has requestId, parentRequestId, identity, deadline |
OperationRegistry.execute()
Returns Promise<ResponseEnvelope<TOutput>> instead of Promise<TOutput>.
Flow:
- Look up spec and handler (existing)
- Validate input with
validateOrThrow(existing) - Await handler result
- Apply shared result pipeline (detect → wrap → normalize → validate)
- Return envelope
Note: isResponseEnvelope() does not validate that the envelope's source matches the operation's origin. An MCP handler that explicitly returns a localEnvelope(...) passes through as-is. Handlers that explicitly construct envelopes take responsibility for their metadata.
CallHandler
Takes full ownership of publishing call.responded. Handlers return values; they do NOT publish events.
Flow:
- Look up spec by
operationIdfrom the registry viagetSpec() - If not found, throw
CallError(OPERATION_NOT_FOUND, ...) - Look up handler by
operationIdviagetHandler() - If not found, throw
CallError(OPERATION_NOT_FOUND, "No handler registered for operation: ...") - Check access control (existing)
- Validate input with
validateOrThrow(existing) - Call handler and await result
- Apply shared result pipeline (detect → wrap → normalize → validate)
- Publish
call.respondedviacallMap.respond(requestId, envelope) - On handler exception → publish
call.error(existing). Note: an envelope withmeta.isError: truedoes not triggercall.error. Only thrown exceptions do.
PendingRequestMap.respond()
Enforces that output is a ResponseEnvelope via the isResponseEnvelope() type guard (not full schema validation). If called with a non-envelope value, throws. This prevents any code bypassing CallHandler's envelope wrapping. A future iteration may make respond() internal (not exported on the public API surface) to further enforce this invariant.
PendingRequestMap.call()
Resolves with the ResponseEnvelope from call.responded.output instead of raw unknown.
subscribe()
Each yielded value is wrapped in ResponseEnvelope. SubscriptionHandler implementations still yield raw values — subscribe() wraps each yield, consistent with how local handlers return raw values and execute() wraps them. If a yielded value isResponseEnvelope(), it passes through. Otherwise, localEnvelope(value, operationId) with a fresh timestamp per yield.
buildEnv()
Each function in the returned OperationEnv returns Promise<ResponseEnvelope> instead of Promise<unknown>.
MCP Adapter (from_mcp.ts)
data shape and composability
MCP tool results have two shapes depending on whether the tool declares outputSchema:
-
With
outputSchema(MCP spec 2025-06-18+):structuredContentcontains the typed output matching the tool's declared schema. Thefrom_mcpadapter converts the tool's JSON SchemaoutputSchemato a TypeBox schema viaFromSchemaand sets it as the operation'soutputSchema. At call time,envelope.dataisstructuredContent, which matchesoutputSchema. These operations are fully composable with local operations — the consumer gets typed data just like calling a local handler. -
Without
outputSchema: No typed output is available. The operation'soutputSchemaisType.Unknown().envelope.dataisMCPContentBlock[](the raw content blocks). These operations are not composable — the consumer must inspect content blocks heuristically. Some MCP servers returnJSON.stringify'd text in content blocks rather than structured output, making programmatic consumption even harder.
When structuredContent is present, it is the primary data path. When absent, content is the only data path. This means envelope.data has a different shape depending on the MCP server version and tool configuration. Consumers that need a consistent shape should check meta.source === "mcp" and meta.structuredContent to determine the access pattern.
outputSchema extraction
The from_mcp adapter SHOULD extract outputSchema from MCP tool definitions (when available) and convert it to a TypeBox schema:
// In createMCPClient, when building the operation spec:
outputSchema: tool.outputSchema
? FromSchema(tool.outputSchema) // Convert JSON Schema to TypeBox
: Type.Unknown() // No schema available
This enables outputSchema validation on envelope.data for MCP operations that declare their output schema, making them composable with local operations.
Envelope stripping with Value.Cast()
When an MCP operation has outputSchema and returns structuredContent, the structuredContent data can be cast against the TypeBox schema to strip excess properties and normalize it:
import { Value } from "@alkdev/typebox/value"
// In the MCP handler, after getting structuredContent:
const data = result.structuredContent
? Value.Cast(spec.outputSchema, result.structuredContent)
: mapMCPContentBlocks(result.content)
Value.Cast() upcasts the structuredContent value into the target type, retaining matching properties, filling defaults for missing ones, and stripping properties not in the schema. This ensures envelope.data reliably matches outputSchema — the same guarantee that local operations provide.
For MCP operations without outputSchema (where outputSchema is Type.Unknown()), Value.Cast() against Type.Unknown() is a no-op (accepts any value), so the data shape remains unpredictable. The composability gap exists only for these operations.
This same pattern applies to OpenAPI responses: the parsed JSON can be passed through Value.Cast(spec.outputSchema, data) to normalize it against the operation's outputSchema.
Handler behavior change
handler: async (input, context) => {
const result = await client.callTool({ name, arguments: input })
// MCP isError: true means the tool ran but returned an error result.
// Wrap in envelope — consumer checks meta.isError.
// Transport-level errors (tool not found, connection failure) still throw CallError.
return mcpEnvelope(
result.structuredContent ?? mapMCPContentBlocks(result.content),
{
isError: result.isError ?? false,
content: mapMCPContentBlocks(result.content),
structuredContent: result.structuredContent as Record<string, unknown> | undefined,
_meta: result._meta as Record<string, unknown> | undefined,
},
)
}
Key differences from current behavior:
isError: trueno longer throws. Error content is accessible viaenvelope.dataandenvelope.meta.content. The consumer checksenvelope.meta.isError.structuredContentis preferred asdatawhen available (MCP spec 2025-03-26+)- Raw
contentblocks are always available inmeta.content mapMCPContentBlocks()maps SDKContentBlock[]to ourMCPContentBlock[](1:1 field mapping, decoupling us from the MCP SDK at the interface level). Signature:(sdkBlocks: SDKContentBlock[]) => MCPContentBlock[]. Unknown content block types (from newer MCP spec versions that ourMCPContentBlockunion doesn't yet cover) are mapped to{ type: "text", text: JSON.stringify(block) }as a fallback — preserving the data without crashing.
OpenAPI Adapter (from_openapi.ts)
QUERY / MUTATION handler
Handler behavior for single-return operations:
handler: async (input, context) => {
// ... existing URL construction and fetch logic ...
const response = await fetch(url, fetchOptions)
if (!response.ok) {
throw new CallError("EXECUTION_ERROR", `HTTP ${response.status}: ${response.statusText}`)
}
const contentType = response.headers.get("Content-Type") || ""
let data: unknown
if (contentType.includes("application/json")) {
data = await response.json()
} else if (contentType.includes("text/")) {
data = await response.text()
} else {
data = await response.arrayBuffer()
}
return httpEnvelope(data, {
statusCode: response.status,
headers: Object.fromEntries(response.headers.entries()),
contentType,
})
}
- On HTTP error status → throw
CallError(not an envelope — these are transport errors) - Success responses → wrapped in
httpEnvelopewith full HTTP metadata - Headers are a
Record<string, string>snapshot (multi-value headers joined with,per fetch spec)
SUBSCRIPTION handler (SSE)
For SUBSCRIPTION-type operations (detected by text/event-stream in response content type), the handler is an AsyncGenerator that:
handler: async function* (input, context) => {
// ... URL construction and fetch logic (same as QUERY/MUTATION) ...
const response = await fetch(url, fetchOptions)
if (!response.ok) {
throw new CallError("EXECUTION_ERROR", `HTTP ${response.status}: ${response.statusText}`)
}
// Parse the SSE stream
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ""
let eventType = ""
let data = ""
let lastEventId = ""
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// Parse SSE frames from buffer
// ... (see adapters.md for full parsing rules) ...
// When a complete event is dispatched:
yield httpEnvelope(parsedData, {
statusCode: response.status,
headers: Object.fromEntries(response.headers.entries()),
contentType: "text/event-stream",
})
}
} finally {
reader.releaseLock()
}
}
- Each SSE event is yielded as a
ResponseEnvelopewithmeta.contentType: "text/event-stream" - The SSE
eventtype andidfields are not carried in the envelope — a futureSSEResponseMetasource type may be added if per-event metadata is needed - On HTTP error status → throw
CallErrorfrom the generator body before first yield - On stream parse error → log warning, skip malformed frame, continue
finallyblock closes theReadableStreamreader
Value.Cast() for Data Normalization
The shared result pipeline (defined above) includes Value.Cast() normalization as step 3. This section provides additional context on how Value.Cast() works and why it matters for each source:
- Local operations:
Value.Cast()provides normalization against the declaredoutputSchema, stripping excess properties and filling defaults — a stronger guarantee thanValue.Check()validation alone (which only warns). - MCP operations with
outputSchema:Value.Cast()normalizesstructuredContentagainst the TypeBox-converted schema, stripping any extra properties the MCP server may have added beyond the declared schema. This makes MCP data composable with local operations. - OpenAPI operations:
Value.Cast()normalizes the parsed HTTP response body against the operation'soutputSchema, ensuring consistent data shapes.
Operations with Type.Unknown() outputSchema (typically MCP tools without outputSchema) are excluded — Value.Cast() against Type.Unknown() is a no-op (accepts any value).
CallEventSchema
call.responded.output changes from Type.Unknown() to ResponseEnvelopeSchema:
"call.responded": Type.Object({
requestId: Type.String(),
output: ResponseEnvelopeSchema,
})
Type Erasure at Runtime Boundaries
ResponseEnvelope<T> carries a compile-time type parameter T, but at the call protocol boundary (pubsub serialization, WebSocket transport), T is erased to unknown. This means:
registry.execute<TInput, TOutput>()returnsPromise<ResponseEnvelope<TOutput>>— type-safe at compile timePendingRequestMap.call()returnsPromise<ResponseEnvelope>—TOutputis not available (the caller doesn't know the operation's output type without a spec lookup)subscribe()yieldsAsyncGenerator<ResponseEnvelope, void, unknown>— similarly untyped
The runtime guarantee for envelope.data shape is outputSchema + Value.Cast() normalization (step 3 of the shared result pipeline). When outputSchema is Type.Unknown(), no runtime shape guarantee exists — the consumer must handle arbitrary data.
outputSchema and Validation`
outputSchema on OperationSpec validates envelope.data, not the full ResponseEnvelope. The envelope has its own ResponseEnvelopeSchema for call protocol validation. This keeps outputSchema focused on business data shape — no envelope schema bloat, and existing outputSchema definitions don't need changes.
The shared result pipeline (see Shared Result Pipeline) applies two steps to envelope.data after wrapping:
- Normalization (
Value.Cast): WhenoutputSchemais meaningful (notType.Unknown()),Value.Cast(spec.outputSchema, envelope.data)normalizes the data — strips excess properties, fills defaults for missing ones, upcasts values to match the declared type. Normalization happens before validation. - Warning validation (
collectErrors+formatValueErrors): Checksenvelope.dataagainstspec.outputSchemaand logs warnings on mismatch. Validation happens after normalization, so it checks the normalized data shape.
The normalization step is optional: if outputSchema is Type.Unknown(), normalization is skipped (any value is accepted). This preserves the current behavior for MCP tools without outputSchema.
Constraints
- No runtime envelope overhead for local handlers — local handlers return raw values; wrapping happens in infrastructure (
execute(),CallHandler), not in user code - Envelope always present at call protocol boundary —
call.respondedalways carriesResponseEnvelope, never raw values.PendingRequestMap.respond()enforces this. - Adapters use factory functions —
localEnvelope(),httpEnvelope(),mcpEnvelope(). 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.respondedshape change in one release. Package is pre-1.0, call protocol is draft, consumers are coordinated. unwrap()is the simple-path API — for consumers that don't need metadataoutputSchemavalidatesenvelope.data—ResponseEnvelopeSchemavalidates the envelope structure;outputSchemavalidates business dataisResponseEnvelope()is the sole detection mechanism — no Symbol brands, no duck-typing beyond the closedsourcediscriminant set
Open Questions
-
Client abstraction — Envelopes provide metadata and
unwrap()provides simple access, but real usage may reveal patterns that justify a higher-level abstraction: e.g., a client that auto-handles MCPisErrorresults (throwing on error content vs. returning it), or one that extracts pagination cursors from HTTP headers. Deferred until usage patterns from alkhub and opencode confirm whetherunwrap()+meta.sourcedispatch is sufficient or whether a typed "client" per source adds real value. -
SSEResponseMeta — SSE events currently use
httpEnvelope()withcontentType: "text/event-stream". The SSEeventtype andidfields are dropped by the parser — they are not available in theResponseEnvelope. Thedatafield value (typically JSON) is the primaryenvelope.datapayload. A futureSSEResponseMetawithsource: "sse",eventType: string,lastEventId: stringcould carry this per-event metadata if usage patterns confirm the need. See ADR-007. -
respond()visibility — Resolved:respond()remains public onPendingRequestMap. The call protocol is the integration surface for spoke/hub SDKs (see amended ADR-006), which means spokes needrespond()for publishingcall.respondedevents back to the hub. The envelope invariant is still enforced by theisResponseEnvelope()guard. -
Envelope directionality (serving) — The current design covers consuming remote operations (MCP, OpenAPI) and wrapping their results. The reverse direction — serving local operations via MCP or OpenAPI — is not yet addressed. When exposing operations as an MCP server, results must be wrapped in MCP's
CallToolResultformat (structuredContent+content). When exposing as OpenAPI, results must be serialized as HTTP response bodies. How envelopes interact with this serving direction is an open design question. -
JSON.stringify in MCP content blocks — Some MCP servers return
JSON.stringify'd data in text content blocks instead of usingstructuredContent. Thefrom_mcpadapter could attemptJSON.parse()on text content whenstructuredContentis absent, but this is fragile (not all text content is JSON). If the operation has a meaningfuloutputSchema,Value.Cast()could normalize the parsed result against the schema, making the consumer's experience consistent regardless of whether the MCP server usesstructuredContentor stringified JSON. Whether this JSON.parse heuristic belongs in the adapter or in consumer code is unresolved. -
MCP
outputSchemaextraction completeness — The MCP spec (2025-06-18+) allows tools to declareoutputSchema, but many existing servers don't. The adapter'sFromSchemaconversion of MCPoutputSchemato TypeBox may encounter JSON Schema features thatFromSchemadoesn't handle. The fallback isType.Unknown(), same as the no-schema case.
Migration Checklist
The following documentation changes have been completed:
| Document | Section | Change | Status |
|---|---|---|---|
call-protocol.md |
CallHandler | Handler no longer publishes call.responded; returns values. CallHandler wraps and publishes. |
✅ |
call-protocol.md |
PendingRequestMap | respond() validates envelope via isResponseEnvelope(); resolves with ResponseEnvelope instead of unknown |
✅ |
call-protocol.md |
CallEventMap | call.responded.output changes from Type.Unknown() to ResponseEnvelopeSchema |
✅ |
api-surface.md |
execute() |
Return type: Promise<TOutput> → Promise<ResponseEnvelope<TOutput>> |
✅ |
api-surface.md |
PendingRequestMap.call() |
Resolve type: unknown → ResponseEnvelope |
✅ |
api-surface.md |
subscribe() |
Yield type: unknown → ResponseEnvelope |
✅ |
api-surface.md |
OperationEnv |
Inner function return type: Promise<unknown> → Promise<ResponseEnvelope> |
✅ |
api-surface.md |
CallHandler |
Wraps handler result, publishes call.responded. No longer "handler publishes" model. |
✅ |
call-protocol.md |
PendingRequestMap.respond() |
Now enforces isResponseEnvelope() check — throws on raw values. |
✅ |
api-surface.md |
PendingRequestMap.respond() |
respond() now requires ResponseEnvelope argument. |
✅ |
adapters.md |
from_mcp |
Handler returns mcpEnvelope(). MCP isError: true no longer throws. |
✅ |
adapters.md |
from_mcp |
outputSchema extracted when available, via FromSchema. Falls back to Type.Unknown(). |
✅ |
adapters.md |
from_openapi |
Handler returns httpEnvelope(). Error on HTTP error status still throws CallError. |
✅ |
The following documentation changes are pending for the subscription transport feature:
| Document | Section | Change | Status |
|---|---|---|---|
adapters.md |
FromOpenAPI SSE handlers |
Subscription handler as AsyncGenerator, SSE parsing, per-yield envelope | ✅ (doc updated) |
call-protocol.md |
PendingRequestMap | Add subscribe() method for remote subscriptions |
✅ (doc updated) |
call-protocol.md |
CallHandler | Dispatch on operation type: execute() for QUERY/MUTATION, subscribe() for SUBSCRIPTION |
✅ (doc updated) |
call-protocol.md |
Transport Mapping | Add WebSocket topology diagram, mention subscribe() over transport |
✅ (doc updated) |
api-surface.md |
PendingRequestMap | Add subscribe() method to the table |
✅ (doc updated) |
api-surface.md |
Subscribe | Add SSE operations note, mention PendingRequestMap.subscribe() for remote |
✅ (doc updated) |
decisions/007-subscription-transport.md |
New ADR | SSE subscription handler, PendingRequestMap.subscribe(), CallHandler dispatch | ✅ (doc created) |
response-envelopes.md |
OpenAPI adapter | Separate QUERY/MUTATION handler from SUBSCRIPTION handler (SSE) | ✅ (doc updated) |
response-envelopes.md |
Open Questions | Replace subscription envelopes question with SSEResponseMeta question | ✅ (doc updated) |
The following code changes are pending:
| Code | Change | Status |
|---|---|---|
src/from_openapi.ts |
Generate SubscriptionHandler (AsyncGenerator) for SUBSCRIPTION operations, parse SSE stream, yield per-event |
✅ Implemented |
src/call.ts |
Add PendingRequestMap.subscribe() method using Repeater from @alkdev/pubsub |
✅ Implemented |
src/call.ts |
Update CallHandler to dispatch on operation type |
✅ Implemented |
src/subscribe.ts |
Ensure subscribe() handles httpEnvelope detection for SSE yields |
✅ Already handles envelopes |
References
- ADR-005 — Design rationale for response envelopes
- call-protocol.md — Call event shapes, PendingRequestMap, CallHandler
- api-surface.md — Public API surface
- adapters.md — MCP and OpenAPI adapter internals