Files
operations/docs/architecture/adapters.md
glm-5.1 ddc0607b90 docs: clean up ADR-005 architecture docs after envelope implementation
Remove stale ADR-005 drift tables across all architecture docs since
ResponseEnvelope types, factories, detection, and integration points
are now fully implemented in source code. Key changes:

- api-surface.md: Remove ADR-005 drift table (all items implemented),
  retain ADR-006 drift table without execute() return type (now done)
- call-protocol.md: Remove ADR-005 drift table, update ADR-006 table,
  fix CallHandlerConfig to show callMap? (current source)
- adapters.md: Remove 'current source state' and 'implementation
  changes needed' tables for from_mcp and from_openapi, replace with
  current-accurate descriptions of envelope behavior
- response-envelopes.md: Remove 'current source state' blocks,
  update migration checklist to show all code changes completed
- 005-response-envelopes.md: Change status from Draft to Implemented
- 006-unified-invocation-path.md: Update Prerequisites section to note
  ADR-005 is now implemented
- build-distribution.md: Add response-envelope.ts to source layout
- architecture.md: Add response-envelopes.md link and ADR-005/006
  entries to design decisions table
- README.md: Add response-envelopes.md to documents table
- Update last_updated dates on all changed docs
2026-05-11 02:55:13 +00:00

14 KiB

status, last_updated
status last_updated
draft 2026-05-11

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

The handler wraps results in httpEnvelope() with HTTP metadata (status code, headers, content type). On HTTP error status, it throws CallError("EXECUTION_ERROR", ...). Value.Cast() normalization against outputSchema is applied by registry.execute() and CallHandler as part of the shared result pipeline — see response-envelopes.md.

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)

The handler returns pre-built ResponseEnvelope instances via mcpEnvelope(). isError: true results are wrapped in the envelope (not thrown), so consumers check envelope.meta.isError. structuredContent is preferred as envelope.data when available; otherwise mapMCPContentBlocks(result.content) is used. Value.Cast() normalization against outputSchema is applied by registry.execute() and CallHandler as part of the shared result pipeline.

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().

The CallToolResult type in the installed SDK (@modelcontextprotocol/sdk DRAFT-2026-v1) includes structuredContent?: { [key: string]: unknown } and the Tool type includes outputSchema?. The adapter code extracts outputSchema at discovery time and uses structuredContent at call time.

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