Extracted from alkhub_ts packages/core/operations/ and packages/core/mcp/. - Runtime-agnostic (injected fs/env deps, no Deno globals) - Direct @logtape/logtape import instead of logger wrapper - PendingRequestMap with pubsub-wired call protocol - Peer-dep isolation for MCP adapter (sub-path export) - Schema const naming convention (XSchema + X type alias) - 68 tests passing, build + lint + test all green
9.9 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-04-30 |
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 IOperationDefinition.inputSchema and outputSchema must be TypeBox schemas (for Value.Check validation), but external specs (OpenAPI, MCP) provide JSON Schema.
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 IOperationDefinition[] 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): IOperationDefinition[]
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
FromOpenAPIFile(path, config, fs?)
async function FromOpenAPIFile(
path: string,
config: HTTPServiceConfig,
fs?: OpenAPIFS,
): Promise<IOperationDefinition[]>
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<IOperationDefinition[]>
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 IOperationDefinition[]. 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 an
IOperationDefinition:name: tool namenamespace: thenameparameter (used as grouping)type:MUTATION(all MCP tools are mutations)inputSchema:FromSchema(tool.inputSchema)(converts JSON Schema to TypeBox)outputSchema:Type.Unknown()(MCP doesn't provide output schemas)handler: callsclient.callTool({ name, arguments })accessControl:{ requiredScopes: [] }(no auth by default)
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(): IOperationDefinition[]
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 definitions from the filesystem. Recursively scans .ts files, imports them, and validates that the default export satisfies OperationDefinitionSchema.
scanOperations(dirPath, fs)
async function scanOperations(
dirPath: string,
fs: ScannerFS,
): Promise<IOperationDefinition[]>
- Walk directory tree using
fs.readdir() - For each
.tsfile, construct afile://URL and dynamicimport() - If the module has a default export, validate it against
OperationDefinitionSchemausingcollectErrors - Valid operations are added to the result array; invalid ones log a warning and are skipped
- Directories are recursed
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
// 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
Adding a New Adapter
To add a new adapter (e.g., from_grpc):
- Create
src/from_grpc.ts— implement the adapter that producesIOperationDefinition[]from gRPC service definitions - 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
- 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 - Write tests — test the adapter in isolation, mock external services
- Update architecture docs — add adapter section here and update the API surface table