- Split monolithic 680-line response-envelopes.md into focused ADR-005 (decisions/005-response-envelopes.md, 152 lines) and specification (response-envelopes.md, 441 lines) - ADR-005: consolidate 10 inline ADRs into coherent decision record with rationale for data+meta envelope shape, handler responsibility shift, string discriminant detection, and composability analysis - Spec: types, factory functions, integration points, constraints, migration checklist, and open questions - Add MCP outputSchema extraction (2025-06-18+ spec) with FromSchema conversion and<Value.Cast()> normalization for structuredContent - Add current-source-vs-spec implementation gap tables to registry, call, mcp adapter, and openapi adapter integration points - Update adapters.md: from_mcp outputSchema extraction, structuredContent handling, isError non-throw behavior, Value.Cast() for data normalization - Add open questions: serving directionality, JSON.stringify in MCP content, outputSchema extraction completeness, respond() visibility - Note: existing call-protocol.md and api-surface.md describe pre-envelope behavior; this spec supersedes them until updated per migration checklist
15 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-05-09 |
Adapters
How FromSchema, FromOpenAPI, from_mcp, and scanner work. How to add new adapters.
FromSchema
Source: src/from_schema.ts
Export: FromSchema (main barrel)
Purpose
Converts JSON Schema to TypeBox TSchema. Required because OperationSpec.inputSchema and outputSchema must be TypeBox schemas (for Value.Check validation), but external specs (OpenAPI, MCP) provide JSON Schema. In the future, @alkdev/typemap may replace or supplement FromSchema to support Zod and Valibot input schemas as well.
Conversion Rules
| JSON Schema Construct | TypeBox Output |
|---|---|
allOf |
Type.IntersectEvaluated(rest, schema) |
anyOf |
Type.UnionEvaluated(rest, schema) |
oneOf |
Type.UnionEvaluated(rest, schema) |
enum |
Type.UnionEvaluated(literals) |
object (with properties + required) |
Type.Object(properties, schema) — required fields are non-optional, others wrapped in Type.Optional() |
array (with items array) |
Type.Tuple(rest, schema) |
array (with items object) |
Type.Array(FromSchema(items), schema) |
const |
Type.Literal(value, schema) |
$ref |
Type.Ref(path) |
string |
Type.String(schema) |
number |
Type.Number(schema) |
integer |
Type.Integer(schema) |
boolean |
Type.Boolean(schema) |
null |
Type.Null(schema) |
| Unrecognized | Type.Unknown(schema) |
$ref resolution is not handled by FromSchema — callers must resolve $ref pointers to concrete schemas before passing them to FromSchema. See FromOpenAPI for how resolveRefsRecursive handles this.
Usage
import { FromSchema } from "@alkdev/operations"
const typeboxSchema = FromSchema({
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
})
FromOpenAPI
Source: src/from_openapi.ts
Exports: FromOpenAPI, FromOpenAPIFile, FromOpenAPIUrl (main barrel); OpenAPISpec, OpenAPIOperation, OpenAPIParameter, HTTPServiceConfig, OpenAPIFS (types)
Purpose
Generates OperationSpec & { handler }[] from OpenAPI specs. Each path+method combination becomes an operation with an auto-generated fetch handler.
FromOpenAPI(spec, config)
function FromOpenAPI(spec: OpenAPISpec, config: HTTPServiceConfig): Array<OperationSpec & { handler: OperationHandler }>
Processes all paths in the spec. For each path and method combination:
- Resolve
$ref—resolveRefsRecursiveresolves all$refpointers in the spec, handling circular references - Build input schema — merges path parameters, query parameters, and request body into a single
Type.Object - Build output schema — extracts response schema from
200/201content, falls back toType.Unknown() - Detect operation type —
GET→QUERY,text/event-streamresponse →SUBSCRIPTION, everything else →MUTATION - Generate operation id — uses
operationIdif present, otherwise normalizes{method}_{path_parts} - Create handler — auto-generated
fetchhandler that:- Interpolates path parameters into the URL
- Passes query parameters as search params
- Sends request body as JSON
- Applies auth headers from config
- Returns JSON, text, or
ArrayBufferbased on response content type
Current source state (src/from_openapi.ts): The handler currently returns raw response data — response.json(), response.text(), or response.arrayBuffer() (lines 273-279). It does NOT wrap the result in httpEnvelope(). Error handling throws a plain Error (line 268) instead of CallError. The response-envelopes spec requires wrapping in httpEnvelope() and throwing CallError on HTTP errors. The Value.Cast() normalization step against outputSchema is also not yet implemented. See response-envelopes.md for the full specification.
| What | Current source (src/from_openapi.ts) |
Target (per response-envelopes spec) |
|---|---|---|
| Handler return value | Returns raw response.json() / .text() / .arrayBuffer() (lines 273-279) |
Returns httpEnvelope(data, { statusCode, headers, contentType }) |
| Error handling | Throws Error(\HTTP ${status}`)` (line 268) |
Throws CallError("EXECUTION_ERROR", ...) |
Value.Cast() |
Not used | If outputSchema !== Unknown, cast Value.Cast(outputSchema, data) |
FromOpenAPIFile(path, config, fs?)
async function FromOpenAPIFile(
path: string,
config: HTTPServiceConfig,
fs?: OpenAPIFS,
): Promise<Array<OperationSpec & { handler: OperationHandler }>>
Reads an OpenAPI JSON file. If fs is provided, uses fs.readFile() (runtime-agnostic). Otherwise, uses Node.js node:fs/promises.
FromOpenAPIUrl(url, config)
async function FromOpenAPIUrl(
url: string,
config: HTTPServiceConfig,
): Promise<Array<OperationSpec & { handler: OperationHandler }>>
Fetches an OpenAPI JSON spec from a URL.
HTTPServiceConfig
interface HTTPServiceConfig {
namespace: string
baseUrl: string
headers?: Record<string, string>
auth?: {
type: "bearer" | "apiKey" | "basic"
token?: string
headerName?: string
prefix?: string
}
timeout?: number
}
namespace— operation namespace (e.g.,"opencode")baseUrl— base URL for all requests in this specauth— bearer, apiKey (custom header), or basic authtimeout—AbortSignal.timeoutfor fetch calls
OpenAPIFS
interface OpenAPIFS {
readFile(path: string): Promise<string>
}
Injectable filesystem interface for runtime-agnostic file reading. See ADR-002.
Known Gap: SSE Subscription Handlers
FromOpenAPI correctly detects SSE endpoints (text/event-stream → SUBSCRIPTION) but the auto-generated handler does a one-shot fetch and returns the response body. For SUBSCRIPTION operations, the handler should be an async generator that:
- Calls
fetch()with the constructed URL/params - Reads the response body as a stream
- Parses SSE frames (
data:lines,event:lines) - Yields each parsed event
- Cleans up on iteration stop
from_mcp
Source: src/from_mcp.ts
Exports: createMCPClient, closeMCPClient, MCPClientLoader (sub-path @alkdev/operations/from-mcp)
Peer dep: @modelcontextprotocol/sdk (optional)
Purpose
Connects to MCP (Model Context Protocol) servers and wraps their tools as OperationSpec & { handler }[]. Supports both stdio and HTTP transports.
createMCPClient(name, config)
async function createMCPClient(
name: string,
config: MCPClientConfig,
): Promise<MCPClientWrapper>
- Dynamic-import
@modelcontextprotocol/sdk(peer dep — not loaded if MCP is not used) - Create transport:
StreamableHTTPClientTransportforurlconfig,StdioClientTransportforcommandconfig - Connect the client
- Call
client.listTools()to discover available tools - For each tool, create a
OperationSpec & { handler }:name: tool namenamespace: thenameparameter (used as grouping)type:MUTATION(all MCP tools are mutations)
FromSchema(tool.inputSchema) (converts JSON Schema to TypeBox)
- `outputSchema`: `tool.outputSchema ? FromSchema(tool.outputSchema) : Type.Unknown()` (MCP spec 2025-06-18+ provides `outputSchema`; older tools lack it)
- `handler`: calls `client.callTool({ name, arguments })`, wraps result in `mcpEnvelope()`
- `accessControl`: `{ requiredScopes: [] }` (no auth by default)
Current source state (src/from_mcp.ts line 66): outputSchema: Type.Unknown() for all tools. The tool.outputSchema property is not used. The handler currently throws on isError (line 76) and returns result.content directly (line 79) instead of wrapping in mcpEnvelope(). The structuredContent field on CallToolResult is not used. See response-envelopes.md for the full specification of what needs to change.
outputSchema and structuredContent
The MCP spec (2025-06-18+) adds outputSchema to tool definitions and structuredContent to CallToolResult. When a tool declares outputSchema:
- At discovery time:
FromSchema(tool.outputSchema)converts the JSON Schema to TypeBox, giving the operation a meaningfuloutputSchema - At call time:
result.structuredContentcontains data matching that schema - The handler uses
Value.Cast(spec.outputSchema, result.structuredContent)to normalize the data against the TypeBox schema — stripping excess properties from the MCP envelope and filling defaults envelope.datais the cast result, which matchesoutputSchema— fully composable with local operations
When a tool does NOT declare outputSchema:
outputSchemaisType.Unknown()— no type information availableresult.structuredContentis absentenvelope.dataisMCPContentBlock[]— not composable, consumer must inspect content blocks- Some MCP servers return
JSON.stringify'd data in text content blocks — the adapter could attemptJSON.parse()but this is fragile and not currently implemented
See response-envelopes.md for the full envelope specification and envelope stripping with Value.Cast().
Implementation changes needed (tracking spec vs. current source):
| What | Current source (src/from_mcp.ts) |
Target (per response-envelopes spec) |
|---|---|---|
outputSchema at discovery |
Type.Unknown() for all tools (line 66) |
tool.outputSchema ? FromSchema(tool.outputSchema) : Type.Unknown() |
| Handler return value | Returns result.content (line 79) |
Returns mcpEnvelope(data, meta) |
structuredContent |
Not used | Prefer as data when present; fall back to mapMCPContentBlocks(result.content) |
isError handling |
Throws Error (line 76) |
Wraps in envelope with meta.isError: true, does NOT throw |
Value.Cast() |
Not used | If structuredContent && outputSchema !== Unknown, cast Value.Cast(outputSchema, structuredContent) |
The CallToolResult type in the installed SDK (@modelcontextprotocol/sdk DRAFT-2026-v1) already includes structuredContent?: { [key: string]: unknown } and the Tool type already includes outputSchema?. No SDK upgrade needed — only the adapter code needs updating.
MCPClientConfig
interface MCPClientConfig {
command?: string
args?: string[]
env?: Record<string, string>
cwd?: string
url?: string
headers?: Record<string, string>
}
Either command (stdio transport) or url (HTTP transport) must be provided.
MCPClientLoader
class MCPClientLoader {
async load(config: Record<string, MCPClientConfig>): Promise<MCPClientWrapper[]>
getClient(name: string): MCPClientWrapper | undefined
getAllWrappers(): MCPClientWrapper[]
getAllOperations(): Array<OperationSpec & { handler: OperationHandler }>
async closeAll(): Promise<void>
}
Manages multiple MCP client connections. load() connects to all configured servers in sequence, getAllOperations() collects all tool operations from all connected clients, closeAll() gracefully shuts down all connections.
Sub-Path Export
from_mcp is exported via sub-path @alkdev/operations/from-mcp because it has a peer dependency on @modelcontextprotocol/sdk. Consumers that don't use MCP don't need to install it. See ADR-003.
Scanner
Source: src/scanner.ts
Exports: scanOperations (main barrel), OperationManifest, ScannerFS (types)
Purpose
Auto-discovers operation specs from the filesystem. Recursively scans .ts files, imports them, and validates that the default export satisfies OperationSpecSchema. Handlers must be registered separately via registry.registerHandler().
scanOperations(dirPath, fs)
async function scanOperations(
dirPath: string,
fs: ScannerFS,
): Promise<OperationSpec[]>
- Walk directory tree using
fs.readdir() - For each
.tsfile, construct afile://URL and dynamicimport() - If the module has a default export, validate it against
OperationSpecSchemausingcollectErrors - Valid specs are added to the result array; invalid ones log a warning and are skipped
- Directories are recursed
Note: The scanner validates against OperationSpecSchema (no handler field). If a scanned module exports both spec and handler, use registry.register() instead.
ScannerFS
interface ScannerFS {
readdir(path: string): AsyncIterable<{ name: string; isFile: boolean; isDirectory: boolean }>
cwd(): string
}
Injectable filesystem interface. No Deno.* globals or Node-specific imports in the scanner source. The consumer provides the FS implementation. See ADR-002.
Expected Module Shape
Spec + handler together (legacy, still supported)
// operations/myOperation.ts
import { Type } from "@alkdev/typebox"
import { OperationType, type IOperationDefinition } from "@alkdev/operations"
export default {
name: "myOperation",
namespace: "myapp",
version: "1.0.0",
type: OperationType.QUERY,
description: "Does something useful",
inputSchema: Type.Object({ name: Type.String() }),
outputSchema: Type.Object({ result: Type.String() }),
accessControl: { requiredScopes: ["read"] },
handler: async (input) => ({ result: `Hello, ${input.name}` }),
} satisfies IOperationDefinition
Spec only (recommended for scanned modules)
// operations/myOperation.ts
import { Type } from "@alkdev/typebox"
import { OperationType, type OperationSpec } from "@alkdev/operations"
export default {
name: "myOperation",
namespace: "myapp",
version: "1.0.0",
type: OperationType.QUERY,
description: "Does something useful",
inputSchema: Type.Object({ name: Type.String() }),
outputSchema: Type.Object({ result: Type.String() }),
accessControl: { requiredScopes: ["read"] },
} satisfies OperationSpec
Then register the handler separately:
registry.registerSpec(scannedSpec)
registry.registerHandler("myapp.myOperation", myHandler)
Adding a New Adapter
To add a new adapter (e.g., from_grpc):
- Create
src/from_grpc.ts— implement the adapter that producesOperationSpec[](spec-only) orArray<OperationSpec & { handler }>(spec+handler) - Export from
src/index.ts— add named exports to the barrel - If the adapter has peer dependencies:
- Add to
peerDependenciesandpeerDependenciesMetainpackage.json - Add a sub-path entry in
exports(e.g.,"./from-grpc") - Add a separate entry in
tsup.config.ts - See ADR-003
- Add to
- If handlers are provided, they can be registered alongside specs or separately via
registry.registerHandler() - Inject runtime dependencies — follow the
ScannerFS/OpenAPIFSpattern for any filesystem or platform-specific APIs. See ADR-002 - Use
FromSchemafor any JSON Schema → TypeBox conversion needed by the adapter (or@alkdev/typemapfor Zod/Valibot) - Write tests — test the adapter in isolation, mock external services
- Update architecture docs — add adapter section here and update the API surface table