Files
operations/docs/architecture/adapters.md
glm-5.1 81f89e0f6c Restructure response envelopes architecture: split ADR from spec, add Value.Cast composability, document implementation gaps
- 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
2026-05-10 07:56:27 +00:00

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:

  1. Resolve $refresolveRefsRecursive resolves all $ref pointers in the spec, handling circular references
  2. Build input schema — merges path parameters, query parameters, and request body into a single Type.Object
  3. Build output schema — extracts response schema from 200/201 content, falls back to Type.Unknown()
  4. Detect operation typeGETQUERY, text/event-stream response → SUBSCRIPTION, everything else → MUTATION
  5. Generate operation id — uses operationId if present, otherwise normalizes {method}_{path_parts}
  6. Create handler — auto-generated fetch handler 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 ArrayBuffer based 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 spec
  • auth — bearer, apiKey (custom header), or basic auth
  • timeoutAbortSignal.timeout for 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-streamSUBSCRIPTION) 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:

  1. Calls fetch() with the constructed URL/params
  2. Reads the response body as a stream
  3. Parses SSE frames (data: lines, event: lines)
  4. Yields each parsed event
  5. 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>
  1. Dynamic-import @modelcontextprotocol/sdk (peer dep — not loaded if MCP is not used)
  2. Create transport: StreamableHTTPClientTransport for url config, StdioClientTransport for command config
  3. Connect the client
  4. Call client.listTools() to discover available tools
  5. For each tool, create a OperationSpec & { handler }:
    • name: tool name
    • namespace: the name parameter (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:

  1. At discovery time: FromSchema(tool.outputSchema) converts the JSON Schema to TypeBox, giving the operation a meaningful outputSchema
  2. At call time: result.structuredContent contains data matching that schema
  3. 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
  4. envelope.data is the cast result, which matches outputSchemafully composable with local operations

When a tool does NOT declare outputSchema:

  1. outputSchema is Type.Unknown() — no type information available
  2. result.structuredContent is absent
  3. envelope.data is MCPContentBlock[] — not composable, consumer must inspect content blocks
  4. Some MCP servers return JSON.stringify'd data in text content blocks — the adapter could attempt JSON.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[]>
  1. Walk directory tree using fs.readdir()
  2. For each .ts file, construct a file:// URL and dynamic import()
  3. If the module has a default export, validate it against OperationSpecSchema using collectErrors
  4. Valid specs are added to the result array; invalid ones log a warning and are skipped
  5. 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
// 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):

  1. Create src/from_grpc.ts — implement the adapter that produces OperationSpec[] (spec-only) or Array<OperationSpec & { handler }> (spec+handler)
  2. Export from src/index.ts — add named exports to the barrel
  3. If the adapter has peer dependencies:
    • Add to peerDependencies and peerDependenciesMeta in package.json
    • Add a sub-path entry in exports (e.g., "./from-grpc")
    • Add a separate entry in tsup.config.ts
    • See ADR-003
  4. If handlers are provided, they can be registered alongside specs or separately via registry.registerHandler()
  5. Inject runtime dependencies — follow the ScannerFS / OpenAPIFS pattern for any filesystem or platform-specific APIs. See ADR-002
  6. Use FromSchema for any JSON Schema → TypeBox conversion needed by the adapter (or @alkdev/typemap for Zod/Valibot)
  7. Write tests — test the adapter in isolation, mock external services
  8. Update architecture docs — add adapter section here and update the API surface table