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.
527 lines
29 KiB
Markdown
527 lines
29 KiB
Markdown
---
|
|
status: stable
|
|
last_updated: 2026-05-16
|
|
---
|
|
|
|
# Response Envelopes
|
|
|
|
Types, factory functions, integration points, and constraints for the `ResponseEnvelope` system. See [ADR-005](decisions/005-response-envelopes.md) 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`
|
|
|
|
```ts
|
|
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)
|
|
|
|
```ts
|
|
type ResponseMeta = LocalResponseMeta | HTTPResponseMeta | MCPResponseMeta
|
|
type ResponseSource = "local" | "http" | "mcp"
|
|
```
|
|
|
|
### `LocalResponseMeta`
|
|
|
|
```ts
|
|
interface LocalResponseMeta {
|
|
source: "local"
|
|
operationId: string
|
|
timestamp: number // Unix epoch milliseconds (Date.now())
|
|
}
|
|
```
|
|
|
|
Minimal metadata for in-process operations. `operationId` is the full `namespace.name` key. `timestamp` is set at wrap time.
|
|
|
|
### `HTTPResponseMeta`
|
|
|
|
```ts
|
|
interface HTTPResponseMeta {
|
|
source: "http"
|
|
statusCode: number
|
|
headers: Record<string, string>
|
|
contentType: string
|
|
}
|
|
```
|
|
|
|
Preserves HTTP response metadata. Multi-value headers are joined with `, ` per the `fetch` `Headers` specification. This does not preserve `Set-Cookie` as a separate header — known limitation, can be extended to `Record<string, string | string[]>` if needed.
|
|
|
|
### `MCPResponseMeta`
|
|
|
|
```ts
|
|
interface MCPResponseMeta {
|
|
source: "mcp"
|
|
isError: boolean
|
|
content: MCPContentBlock[]
|
|
structuredContent?: Record<string, unknown>
|
|
_meta?: Record<string, unknown>
|
|
}
|
|
```
|
|
|
|
Mirrors the MCP `CallToolResult` structure. Key semantics:
|
|
|
|
- `isError: true` means the tool ran but returned an error **result** — this is NOT a `CallError` exception. The handler wraps the result in an envelope. Consumers check `envelope.meta.isError` to distinguish error results from success results.
|
|
- `structuredContent` is the MCP structured output (spec version 2025-03-26+). When present, it is placed in `envelope.data` as the primary output.
|
|
- `content` always holds the full content block array, available in `meta` for rendering or fallback.
|
|
- `_meta` carries MCP protocol extensions.
|
|
|
|
### `MCPContentBlock`
|
|
|
|
```ts
|
|
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()`
|
|
|
|
```ts
|
|
const RESPONSE_SOURCES = ["local", "http", "mcp"] as const
|
|
|
|
function isResponseEnvelope(value: unknown): value is ResponseEnvelope {
|
|
if (typeof value !== "object" || value === null) return false
|
|
const obj = value as Record<string, unknown>
|
|
if (!("data" in obj) || !("meta" in obj)) return false
|
|
if (typeof obj.meta !== "object" || obj.meta === null) return false
|
|
return RESPONSE_SOURCES.includes((obj.meta as ResponseMeta).source as ResponseSource)
|
|
}
|
|
```
|
|
|
|
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.
|
|
|
|
```ts
|
|
function localEnvelope<T>(data: T, operationId: string): ResponseEnvelope<T> {
|
|
return { data, meta: { source: "local", operationId, timestamp: Date.now() } }
|
|
}
|
|
|
|
function httpEnvelope<T>(data: T, meta: Omit<HTTPResponseMeta, "source">): ResponseEnvelope<T> {
|
|
return { data, meta: { source: "http", ...meta } }
|
|
}
|
|
|
|
function mcpEnvelope<T>(data: T, meta: Omit<MCPResponseMeta, "source">): ResponseEnvelope<T> {
|
|
return { data, meta: { source: "mcp", ...meta } }
|
|
}
|
|
```
|
|
|
|
- `localEnvelope` is used by `execute()` and `CallHandler` when wrapping raw handler return values
|
|
- `httpEnvelope` is used by the OpenAPI adapter handler
|
|
- `mcpEnvelope` is used by the MCP adapter handler
|
|
- `T` resolves to `undefined` for void handlers — `localEnvelope(undefined, opId)` is valid
|
|
|
|
## Utility
|
|
|
|
### `unwrap()`
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
1. **Detect envelope**: If result `isResponseEnvelope()` → pass through as-is (adapter handlers return pre-built envelopes)
|
|
2. **Wrap**: Otherwise → wrap in `localEnvelope(result, operationId)`
|
|
3. **Normalize**: If `spec.outputSchema` is not `Type.Unknown()`, apply `Value.Cast(spec.outputSchema, envelope.data)` — strips excess properties, fills defaults, upcasts values
|
|
4. **Validate**: Check `envelope.data` against `spec.outputSchema` with `collectErrors()` — **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:
|
|
1. Look up spec and handler (existing)
|
|
2. Validate input with `validateOrThrow` (existing)
|
|
3. Await handler result
|
|
4. Apply shared result pipeline (detect → wrap → normalize → validate)
|
|
5. 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:
|
|
1. Look up spec by `operationId` from the registry via `getSpec()`
|
|
2. If not found, throw `CallError(OPERATION_NOT_FOUND, ...)`
|
|
3. Look up handler by `operationId` via `getHandler()`
|
|
4. If not found, throw `CallError(OPERATION_NOT_FOUND, "No handler registered for operation: ...")`
|
|
5. Check access control (existing)
|
|
6. Validate input with `validateOrThrow` (existing)
|
|
7. Call handler and await result
|
|
8. Apply shared result pipeline (detect → wrap → normalize → validate)
|
|
9. Publish `call.responded` via `callMap.respond(requestId, envelope)`
|
|
10. On handler exception → publish `call.error` (existing). Note: an envelope with `meta.isError: true` does **not** trigger `call.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+): `structuredContent` contains the typed output matching the tool's declared schema. The `from_mcp` adapter converts the tool's JSON Schema `outputSchema` to a TypeBox schema via `FromSchema` and sets it as the operation's `outputSchema`. At call time, `envelope.data` is `structuredContent`, which matches `outputSchema`. These operations are **fully composable** with local operations — the consumer gets typed data just like calling a local handler.
|
|
|
|
- **Without `outputSchema`**: No typed output is available. The operation's `outputSchema` is `Type.Unknown()`. `envelope.data` is `MCPContentBlock[]` (the raw content blocks). These operations are **not composable** — the consumer must inspect content blocks heuristically. Some MCP servers return `JSON.stringify`'d text in content blocks rather than structured output, making programmatic consumption even harder.
|
|
|
|
When `structuredContent` is present, it is the primary data path. When absent, `content` is the only data path. This means `envelope.data` has a different shape depending on the MCP server version and tool configuration. Consumers that need a consistent shape should check `meta.source === "mcp"` and `meta.structuredContent` to determine the access pattern.
|
|
|
|
#### outputSchema extraction
|
|
|
|
The `from_mcp` adapter SHOULD extract `outputSchema` from MCP tool definitions (when available) and convert it to a TypeBox schema:
|
|
|
|
```ts
|
|
// In createMCPClient, when building the operation spec:
|
|
outputSchema: tool.outputSchema
|
|
? FromSchema(tool.outputSchema) // Convert JSON Schema to TypeBox
|
|
: Type.Unknown() // No schema available
|
|
```
|
|
|
|
This enables `outputSchema` validation on `envelope.data` for MCP operations that declare their output schema, making them composable with local operations.
|
|
|
|
#### Envelope stripping with `Value.Cast()`
|
|
|
|
When an MCP operation has `outputSchema` and returns `structuredContent`, the `structuredContent` data can be cast against the TypeBox schema to strip excess properties and normalize it:
|
|
|
|
```ts
|
|
import { Value } from "@alkdev/typebox/value"
|
|
|
|
// In the MCP handler, after getting structuredContent:
|
|
const data = result.structuredContent
|
|
? Value.Cast(spec.outputSchema, result.structuredContent)
|
|
: mapMCPContentBlocks(result.content)
|
|
```
|
|
|
|
`Value.Cast()` upcasts the `structuredContent` value into the target type, retaining matching properties, filling defaults for missing ones, and stripping properties not in the schema. This ensures `envelope.data` reliably matches `outputSchema` — the same guarantee that local operations provide.
|
|
|
|
For MCP operations without `outputSchema` (where `outputSchema` is `Type.Unknown()`), `Value.Cast()` against `Type.Unknown()` is a no-op (accepts any value), so the data shape remains unpredictable. The composability gap exists only for these operations.
|
|
|
|
This same pattern applies to OpenAPI responses: the parsed JSON can be passed through `Value.Cast(spec.outputSchema, data)` to normalize it against the operation's `outputSchema`.
|
|
|
|
#### Handler behavior change
|
|
|
|
```ts
|
|
handler: async (input, context) => {
|
|
const result = await client.callTool({ name, arguments: input })
|
|
// 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: true` no longer throws. Error content is accessible via `envelope.data` and `envelope.meta.content`. The consumer checks `envelope.meta.isError`.
|
|
- `structuredContent` is preferred as `data` when available (MCP spec 2025-03-26+)
|
|
- Raw `content` blocks are always available in `meta.content`
|
|
- `mapMCPContentBlocks()` maps SDK `ContentBlock[]` to our `MCPContentBlock[]` (1:1 field mapping, decoupling us from the MCP SDK at the interface level). Signature: `(sdkBlocks: SDKContentBlock[]) => MCPContentBlock[]`. Unknown content block types (from newer MCP spec versions that our `MCPContentBlock` union doesn't yet cover) are mapped to `{ type: "text", text: JSON.stringify(block) }` as a fallback — preserving the data without crashing.
|
|
|
|
### OpenAPI Adapter (`from_openapi.ts`)
|
|
|
|
#### QUERY / MUTATION handler
|
|
|
|
Handler behavior for single-return operations:
|
|
|
|
```ts
|
|
handler: async (input, context) => {
|
|
// ... existing URL construction and fetch logic ...
|
|
const response = await fetch(url, fetchOptions)
|
|
|
|
if (!response.ok) {
|
|
throw new CallError("EXECUTION_ERROR", `HTTP ${response.status}: ${response.statusText}`)
|
|
}
|
|
|
|
const contentType = response.headers.get("Content-Type") || ""
|
|
let data: unknown
|
|
if (contentType.includes("application/json")) {
|
|
data = await response.json()
|
|
} else if (contentType.includes("text/")) {
|
|
data = await response.text()
|
|
} else {
|
|
data = await response.arrayBuffer()
|
|
}
|
|
|
|
return httpEnvelope(data, {
|
|
statusCode: response.status,
|
|
headers: Object.fromEntries(response.headers.entries()),
|
|
contentType,
|
|
})
|
|
}
|
|
```
|
|
|
|
- On HTTP error status → throw `CallError` (not an envelope — these are transport errors)
|
|
- Success responses → wrapped in `httpEnvelope` with full HTTP metadata
|
|
- Headers are a `Record<string, string>` snapshot (multi-value headers joined with `, ` per fetch spec)
|
|
|
|
#### SUBSCRIPTION handler (SSE)
|
|
|
|
For `SUBSCRIPTION`-type operations (detected by `text/event-stream` in response content type), the handler is an `AsyncGenerator` that:
|
|
|
|
```ts
|
|
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 `ResponseEnvelope` with `meta.contentType: "text/event-stream"`
|
|
- The SSE `event` type and `id` fields are not carried in the envelope — a future `SSEResponseMeta` source type may be added if per-event metadata is needed
|
|
- On HTTP error status → throw `CallError` from the generator body before first yield
|
|
- On stream parse error → log warning, skip malformed frame, continue
|
|
- `finally` block closes the `ReadableStream` reader
|
|
|
|
### 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 declared `outputSchema`, stripping excess properties and filling defaults — a stronger guarantee than `Value.Check()` validation alone (which only warns).
|
|
- **MCP operations with `outputSchema`**: `Value.Cast()` normalizes `structuredContent` against 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's `outputSchema`, 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`:
|
|
|
|
```ts
|
|
"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>()` returns `Promise<ResponseEnvelope<TOutput>>` — type-safe at compile time
|
|
- `PendingRequestMap.call()` returns `Promise<ResponseEnvelope>` — `TOutput` is not available (the caller doesn't know the operation's output type without a spec lookup)
|
|
- `subscribe()` yields `AsyncGenerator<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](#shared-result-pipeline)) applies two steps to `envelope.data` after wrapping:
|
|
|
|
1. **Normalization** (`Value.Cast`): When `outputSchema` is meaningful (not `Type.Unknown()`), `Value.Cast(spec.outputSchema, envelope.data)` normalizes the data — strips excess properties, fills defaults for missing ones, upcasts values to match the declared type. Normalization happens before validation.
|
|
2. **Warning validation** (`collectErrors` + `formatValueErrors`): Checks `envelope.data` against `spec.outputSchema` and 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
|
|
|
|
1. **No runtime envelope overhead for local handlers** — local handlers return raw values; wrapping happens in infrastructure (`execute()`, `CallHandler`), not in user code
|
|
2. **Envelope always present at call protocol boundary** — `call.responded` always carries `ResponseEnvelope`, never raw values. `PendingRequestMap.respond()` enforces this.
|
|
3. **Adapters use factory functions** — `localEnvelope()`, `httpEnvelope()`, `mcpEnvelope()`. Adapters do not construct envelope objects directly.
|
|
4. **MCPContentBlock types are our own** — not MCP SDK types. Avoids runtime dependency on `@modelcontextprotocol/sdk` in the main barrel.
|
|
5. **Breaking change: single-release, no transitional API** — `execute()` return type and `call.responded` shape change in one release. Package is pre-1.0, call protocol is draft, consumers are coordinated.
|
|
6. **`unwrap()` is the simple-path API** — for consumers that don't need metadata
|
|
7. **`outputSchema` validates `envelope.data`** — `ResponseEnvelopeSchema` validates the envelope structure; `outputSchema` validates business data
|
|
8. **`isResponseEnvelope()` is the sole detection mechanism** — no Symbol brands, no duck-typing beyond the closed `source` discriminant set
|
|
|
|
## Open Questions
|
|
|
|
1. **Client abstraction** — Envelopes provide metadata and `unwrap()` provides simple access, but real usage may reveal patterns that justify a higher-level abstraction: e.g., a client that auto-handles MCP `isError` results (throwing on error content vs. returning it), or one that extracts pagination cursors from HTTP headers. Deferred until usage patterns from alkhub and opencode confirm whether `unwrap()` + `meta.source` dispatch is sufficient or whether a typed "client" per source adds real value.
|
|
|
|
2. **SSEResponseMeta** — SSE events currently use `httpEnvelope()` with `contentType: "text/event-stream"`. The SSE `event` type and `id` fields are **dropped** by the parser — they are not available in the `ResponseEnvelope`. The `data` field value (typically JSON) is the primary `envelope.data` payload. A future `SSEResponseMeta` with `source: "sse"`, `eventType: string`, `lastEventId: string` could carry this per-event metadata if usage patterns confirm the need. See [ADR-007](decisions/007-subscription-transport.md).
|
|
|
|
3. **`respond()` visibility** — Resolved: `respond()` remains public on `PendingRequestMap`. The call protocol is the integration surface for spoke/hub SDKs (see amended ADR-006), which means spokes need `respond()` for publishing `call.responded` events back to the hub. The envelope invariant is still enforced by the `isResponseEnvelope()` guard.
|
|
|
|
4. **Envelope directionality (serving)** — The current design covers **consuming** remote operations (MCP, OpenAPI) and wrapping their results. The reverse direction — **serving** local operations via MCP or OpenAPI — is not yet addressed. When exposing operations as an MCP server, results must be wrapped in MCP's `CallToolResult` format (`structuredContent` + `content`). When exposing as OpenAPI, results must be serialized as HTTP response bodies. How envelopes interact with this serving direction is an open design question.
|
|
|
|
5. **JSON.stringify in MCP content blocks** — Some MCP servers return `JSON.stringify`'d data in text content blocks instead of using `structuredContent`. The `from_mcp` adapter could attempt `JSON.parse()` on text content when `structuredContent` is absent, but this is fragile (not all text content is JSON). If the operation has a meaningful `outputSchema`, `Value.Cast()` could normalize the parsed result against the schema, making the consumer's experience consistent regardless of whether the MCP server uses `structuredContent` or stringified JSON. Whether this JSON.parse heuristic belongs in the adapter or in consumer code is unresolved.
|
|
|
|
6. **MCP `outputSchema` extraction completeness** — The MCP spec (2025-06-18+) allows tools to declare `outputSchema`, but many existing servers don't. The adapter's `FromSchema` conversion of MCP `outputSchema` to TypeBox may encounter JSON Schema features that `FromSchema` doesn't handle. The fallback is `Type.Unknown()`, same as the no-schema case.
|
|
|
|
## Migration Checklist
|
|
|
|
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](decisions/005-response-envelopes.md) — Design rationale for response envelopes
|
|
- [call-protocol.md](call-protocol.md) — Call event shapes, PendingRequestMap, CallHandler
|
|
- [api-surface.md](api-surface.md) — Public API surface
|
|
- [adapters.md](adapters.md) — MCP and OpenAPI adapter internals |