Initial package implementation: operations registry, call protocol, and adapters

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
This commit is contained in:
2026-04-30 12:34:26 +00:00
parent 9c41f683ee
commit 29f0dd7af0
37 changed files with 9287 additions and 0 deletions

103
docs/architecture/README.md Normal file
View File

@@ -0,0 +1,103 @@
---
status: draft
last_updated: 2026-04-30
---
# @alkdev/operations Architecture
Typed operations registry, call protocol, and adapters (MCP, OpenAPI). Everything is an operation with TypeBox schemas, access control metadata, and a handler. The call protocol provides unified event-based invocation that works the same whether local, remote, or streamed.
## Why This Exists
Extracted from `@alkdev/alkhub_ts/packages/core/operations/` and `packages/core/mcp/`. The operations system was already self-contained within alkhub, depending only on `@alkdev/typebox` and `@alkdev/pubsub`. Extracting into a standalone package:
1. **Reduces coupling** — alkhub depends on operations, not the other way around
2. **Enables reuse** — multiple alkhub packages and external consumers can share the same operations registry and call protocol
3. **Isolates peer deps** — MCP SDK and other heavy dependencies are optional; consumers that don't need them shouldn't carry them
4. **Standalone utility** — the call protocol, validation, and schema conversion are useful outside alkhub (e.g., opencode OpenAPI import)
## Core Principle
**The operation definition is the contract.** Every API endpoint, agent action, coordination tool, and MCP tool is an `IOperationDefinition` with typed input/output schemas, access control, and a handler. The registry executes them. The call protocol routes them. Adapters generate them from external specs.
All paths funnel into the same registry:
```
Hub HTTP API routes → registry.execute("namespace.operation", input, ctx)
MCP server tools → registry.execute(...)
FromOpenAPI ops → fetch(remote REST API)
MCP client tools → MCPClientLoader → registry.execute(...)
Agent session LLM → tool calls with JSON Schema → registry.execute(...)
```
Access control, validation, and error handling are consistent regardless of entry point.
## What This Package Provides
- **Core types** — `IOperationDefinition`, `OperationSpec`, `OperationType`, `AccessControl`, `Identity`, `OperationContext`
- **Registry** — `OperationRegistry` with register, execute, validate, spec extraction
- **Call protocol** — `PendingRequestMap`, `CallHandler`, `call≡subscribe` event semantics
- **Subscribe** — `subscribe()` for `AsyncGenerator`-based subscription operations
- **Env builder** — `buildEnv()` for nested operation calls (direct or call protocol mode)
- **Validation** — `assertIsSchema`, `validateOrThrow`, `collectErrors`, `formatValueErrors`
- **Error model** — `CallError`, `InfrastructureErrorCode`, `mapError`
- **Schema conversion** — `FromSchema` converts JSON Schema to TypeBox
- **Adapters**:
- `FromOpenAPI` / `FromOpenAPIFile` / `FromOpenAPIUrl` — OpenAPI spec to operations
- `createMCPClient` / `MCPClientLoader` — MCP server tools to operations (peer dep: `@modelcontextprotocol/sdk`)
- `scanOperations` — filesystem auto-discovery of operation definitions
## Consumer Context
### alkhub (hub-spoke coordinator)
The hub uses the operations registry as the single execution engine for all work. Operations are registered from multiple sources (local definitions, OpenAPI imports, MCP tool connections). The call protocol routes invocations through `PendingRequestMap` for call graph tracking, abort cascading, and structured error handling.
### opencode (agent tool use)
`FromOpenAPI` generates typed operation definitions from any OpenAPI spec. This provides an instant typed client without hand-writing handlers. MCP tool connections are managed through `MCPClientLoader`.
### Spoke SDK (future)
Spokes will import `@alkdev/operation` for operation definitions and `@alkdev/pubsub` for the call protocol event transport. The `buildEnv` call protocol mode connects nested operations through `PendingRequestMap`.
## Threat Model
- **Schema trust** — `FromSchema` converts arbitrary JSON Schema to TypeBox. Malformed or deeply nested schemas could cause excessive CPU or memory. Input validation (`validateOrThrow`) runs before handler execution, but the schemas themselves are trusted.
- **Handler trust** — operation handlers are arbitrary async functions. The registry runs them in the same process. No sandboxing.
- **Peer dep isolation** — `@modelcontextprotocol/sdk` is an optional peer dependency. Consumers that don't use `from_mcp` don't install it. The sub-path export `@alkdev/operations/from-mcp` makes this explicit.
- **Access control enforcement** — `CallHandler` checks `AccessControl` before dispatch. Direct `registry.execute()` calls bypass access control by design (internal trusted calls). Untrusted callers must use the call protocol.
## Architecture Documents
| Document | Content |
|----------|---------|
| [api-surface.md](api-surface.md) | All public types, registry, call protocol, subscribe, env, adapters |
| [call-protocol.md](call-protocol.md) | PendingRequestMap, CallHandler, call≡subscribe, events, error model, access control |
| [adapters.md](adapters.md) | from_schema, from_openapi, from_mcp, scanner — how they work, how to add new adapters |
| [build-distribution.md](build-distribution.md) | Dependencies, project structure, sub-path exports, peer deps, build tooling |
## Document Lifecycle
Architecture documents use YAML frontmatter with `status` and `last_updated` fields:
```yaml
---
status: draft | stable | deprecated
last_updated: YYYY-MM-DD
---
```
| Status | Meaning | Transitions |
|--------|---------|-------------|
| `draft` | Under active development. Content may change. | → `stable` when implementation is complete and tests verify API contract. |
| `stable` | API contracts are locked. Changes require review cycle. | → `deprecated` when superseded. |
| `deprecated` | Superseded. Kept for reference. | Removed when no longer referenced. |
## References
- Source: `src/` in this package
- Provenance: `@alkdev/alkhub_ts/packages/core/operations/` and `packages/core/mcp/`
- Related: `@alkdev/pubsub` (call protocol transport), `@alkdev/typebox` (schema system)
- alkhub operations doc: `@alkdev/alkhub_ts/docs/architecture/operations.md`
- alkhub call protocol doc: `@alkdev/alkhub_ts/docs/architecture/call-graph.md`

View File

@@ -0,0 +1,280 @@
---
status: draft
last_updated: 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
```ts
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)`
```ts
function FromOpenAPI(spec: OpenAPISpec, config: HTTPServiceConfig): IOperationDefinition[]
```
Processes all paths in the spec. For each path and method combination:
1. **Resolve `$ref`**`resolveRefsRecursive` 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 type**`GET``QUERY`, `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
### `FromOpenAPIFile(path, config, fs?)`
```ts
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)`
```ts
async function FromOpenAPIUrl(
url: string,
config: HTTPServiceConfig,
): Promise<IOperationDefinition[]>
```
Fetches an OpenAPI JSON spec from a URL.
### `HTTPServiceConfig`
```ts
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
- `timeout``AbortSignal.timeout` for fetch calls
### `OpenAPIFS`
```ts
interface OpenAPIFS {
readFile(path: string): Promise<string>
}
```
Injectable filesystem interface for runtime-agnostic file reading. See [ADR-002](decisions/002-fs-injection.md).
### 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:
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 `IOperationDefinition[]`. Supports both stdio and HTTP transports.
### `createMCPClient(name, config)`
```ts
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 an `IOperationDefinition`:
- `name`: tool name
- `namespace`: the `name` parameter (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`: calls `client.callTool({ name, arguments })`
- `accessControl`: `{ requiredScopes: [] }` (no auth by default)
### `MCPClientConfig`
```ts
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`
```ts
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](decisions/003-peer-dep-adapters.md).
## 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)`
```ts
async function scanOperations(
dirPath: string,
fs: ScannerFS,
): Promise<IOperationDefinition[]>
```
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 `OperationDefinitionSchema` using `collectErrors`
4. Valid operations are added to the result array; invalid ones log a warning and are skipped
5. Directories are recursed
### `ScannerFS`
```ts
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](decisions/002-fs-injection.md).
### Expected Module Shape
```ts
// 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`):
1. **Create `src/from_grpc.ts`** — implement the adapter that produces `IOperationDefinition[]` from gRPC service definitions
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](decisions/003-peer-dep-adapters.md)
4. **Inject runtime dependencies** — follow the `ScannerFS` / `OpenAPIFS` pattern for any filesystem or platform-specific APIs. See [ADR-002](decisions/002-fs-injection.md)
5. **Use `FromSchema`** for any JSON Schema → TypeBox conversion needed by the adapter
6. **Write tests** — test the adapter in isolation, mock external services
7. **Update architecture docs** — add adapter section here and update the API surface table

View File

@@ -0,0 +1,314 @@
---
status: draft
last_updated: 2026-04-30
---
# API Surface
All public types, registry, call protocol, subscribe, env, validation, and adapters. See [call-protocol.md](call-protocol.md) for detailed call protocol semantics and [adapters.md](adapters.md) for adapter internals.
## Core Types
### `OperationType`
```ts
enum OperationType {
QUERY = "query",
MUTATION = "mutation",
SUBSCRIPTION = "subscription",
}
```
- `QUERY` — read-only, no side effects
- `MUTATION` — write, has side effects
- `SUBSCRIPTION` — async generator, yields multiple values over time
### `Identity`
```ts
interface Identity {
id: string
scopes: string[]
resources?: Record<string, string[]>
}
```
Caller security context. `scopes` are global permissions (AND-checked against `requiredScopes`). `resources` maps `"type:id"` to action arrays (checked against `resourceType`/`resourceAction`). Derived from keypal `ApiKeyMetadata`.
### `AccessControl`
```ts
type AccessControl = Static<typeof AccessControlSchema>
const AccessControlSchema = Type.Object({
requiredScopes: Type.Array(Type.String()),
requiredScopesAny: Type.Optional(Type.Array(Type.String())),
resourceType: Type.Optional(Type.String()),
resourceAction: Type.Optional(Type.String()),
customAuth: Type.Optional(Type.String()),
})
```
| Field | Semantics |
|-------|-----------|
| `requiredScopes` | AND — caller must have ALL listed scopes |
| `requiredScopesAny` | OR — caller must have at least ONE listed scope |
| `resourceType` | Resource category for resource-scoped checks |
| `resourceAction` | Required action on the resource |
| `customAuth` | Name of custom auth function (not yet enforced) |
### `ErrorDefinition`
```ts
type ErrorDefinition = Static<typeof ErrorDefinitionSchema>
const ErrorDefinitionSchema = Type.Object({
code: Type.String(),
description: Type.String(),
schema: Type.Unknown(),
httpStatus: Type.Optional(Type.Number()),
})
```
Declared on `IOperationDefinition.errorSchemas`. Contract between operation and callers about what errors it may produce.
### `OperationContext`
```ts
type OperationContext = Static<typeof OperationContextSchema> & {
env?: OperationEnv
stream?: () => AsyncIterable<unknown>
pubsub?: unknown
}
```
Passed to every handler. `env` provides namespace-keyed access to other operations (via `buildEnv`). `stream` and `pubsub` support subscription and event patterns.
### `OperationSpec`
```ts
interface OperationSpec<TInput = unknown, TOutput = unknown> {
name: string
namespace: string
version: string
type: OperationType
title?: string
description: string
tags?: string[]
inputSchema: TSchema
outputSchema: TSchema
errorSchemas?: ErrorDefinition[]
accessControl: AccessControl
_meta?: Record<string, unknown>
}
```
Serializable, hashable subset of an operation definition. No handler — safe to send over the wire.
### `IOperationDefinition`
```ts
interface IOperationDefinition<TInput, TOutput, TContext> extends OperationSpec<TInput, TOutput> {
handler: OperationHandler<TInput, TOutput, TContext> | SubscriptionHandler<TInput, TOutput, TContext>
}
```
Full definition including the runtime handler. Registered with `OperationRegistry`.
### `OperationHandler` / `SubscriptionHandler`
```ts
type OperationHandler<TInput, TOutput, TContext> = (
input: TInput, context: TContext,
) => Promise<TOutput> | TOutput
type SubscriptionHandler<TInput, TOutput, TContext> = (
input: TInput, context: TContext,
) => AsyncGenerator<TOutput, void, unknown>
```
`OperationHandler` returns a single value. `SubscriptionHandler` yields values over time.
### `OperationEnv`
```ts
type OperationEnv = Record<string, Record<string, (input: unknown) => Promise<unknown>>>
```
Namespace-keyed operation map. Accessed as `env.namespace.operationName(input)`. Created by `buildEnv`.
## Registry
### `OperationRegistry`
| Method | Signature | Description |
|--------|-----------|-------------|
| `register(operation)` | `(operation: IOperationDefinition) => void` | Register by `{namespace}.{name}` key. Validates schemas. |
| `registerAll(operations)` | `(operations: IOperationDefinition[]) => void` | Bulk register. |
| `get(id)` | `(id: string) => IOperationDefinition \| undefined` | Get by full id (`"namespace.name"`). |
| `getByName(namespace, name)` | `(namespace: string, name: string) => IOperationDefinition \| undefined` | Get by parts. |
| `list()` | `() => IOperationDefinition[]` | All registered operations. |
| `getSpec(id)` | `(id: string) => OperationSpec \| undefined` | Serializable spec (no handler). |
| `getAllSpecs()` | `() => OperationSpec[]` | All serializable specs. |
| `execute(operationId, input, context)` | `(id: string, input: TInput, ctx: OperationContext) => Promise<TOutput>` | Validate input, run handler, warn on output mismatch. Throws if not found or validation fails. |
Registration key format: `{namespace}.{name}`. Overwrite on duplicate.
`execute` validates input with `validateOrThrow` before calling the handler. Output validation uses `collectErrors` and logs warnings — it does not throw.
## Call Protocol
### `PendingRequestMap`
See [call-protocol.md](call-protocol.md) for full semantics.
| Method | Signature | Description |
|--------|-----------|-------------|
| `constructor(eventTarget?)` | `(eventTarget?: EventTarget)` | Creates internal pubsub, wires subscription handlers for responded/error/aborted. |
| `call(operationId, input, options?)` | `Promise<unknown>` | Publish `call.requested`, return Promise that resolves on `call.responded`. |
| `respond(requestId, output)` | `void` | Publish `call.responded`. |
| `emitError(requestId, code, message, details?)` | `void` | Publish `call.error`. |
| `abort(requestId)` | `void` | Publish `call.aborted`, reject pending Promise. |
| `getPendingCount()` | `number` | Number of in-flight requests. |
### `CallHandler`
```ts
type CallHandler = (event: CallRequestedEvent) => Promise<void>
```
Created by `buildCallHandler({ registry, eventTarget? })`. Subscribes to `call.requested`, checks access control, validates input, executes via registry. On success: no-op (handler is expected to publish `call.responded` through the PendingRequestMap). On failure: throws `CallError`.
### `CallEventMap`
```ts
const CallEventMap = {
"call.requested": Type.Object({ ... }),
"call.responded": Type.Object({ ... }),
"call.aborted": Type.Object({ ... }),
"call.error": Type.Object({ ... }),
}
```
Typed event map compatible with `@alkdev/pubsub`. See [call-protocol.md](call-protocol.md) for event shapes.
### Event Types
| Type | Fields | Description |
|------|--------|-------------|
| `CallRequestedEvent` | `requestId, operationId, input, parentRequestId?, deadline?, identity?` | Initiates a call |
| `CallRespondedEvent` | `requestId, output` | Successful response |
| `CallAbortedEvent` | `requestId` | Call cancelled |
| `CallErrorEvent` | `requestId, code, message, details?` | Error response |
## Subscribe
### `subscribe`
```ts
function subscribe(
registry: OperationRegistry,
operationId: string,
input: unknown,
context: OperationContext,
): AsyncGenerator<unknown, void, unknown>
```
Direct subscription execution. Gets the operation, casts its handler to `AsyncGenerator`, yields each value. Properly cleans up the generator on iteration stop (calls `generator.return()` in `finally`).
This is the synchronous alternative to the call protocol's `call.requested``call.responded` flow for subscriptions. Use `subscribe()` for in-process subscription consumption; use `PendingRequestMap` for cross-transport subscription.
## Env Builder
### `buildEnv`
```ts
function buildEnv(options: EnvOptions): OperationEnv
interface EnvOptions {
registry: OperationRegistry
context: OperationContext
allowedNamespaces?: string[]
callMap?: PendingRequestMap
}
```
Creates a namespace-keyed `OperationEnv` for nested operation calls. Two modes:
- **Direct mode**: `buildEnv({ registry, context })` — env functions call `registry.execute()`
- **Call protocol mode**: `buildEnv({ registry, context, callMap })` — env functions call `callMap.call()`, publishing `call.requested` events with `parentRequestId` for call graph tracking
`SUBSCRIPTION` operations are filtered out — env only provides QUERY and MUTATION operations for nested calls.
`allowedNamespaces` restricts which namespaces are available.
## Validation
| Export | Signature | Description |
|--------|-----------|-------------|
| `assertIsSchema(schema, context?)` | `(unknown, string?) => void` | Throws if `schema` is not a valid TypeBox schema. |
| `validateOrThrow(schema, value, context?)` | `(TSchema, unknown, string?) => void` | Throws with formatted errors if value fails schema check. |
| `collectErrors(schema, value)` | `(TSchema, unknown) => Array<{path, message}>` | Returns errors array (empty if valid). |
| `formatValueErrors(errors, indent?)` | `(Iterable<{path, message}>, string?) => string` | Human-readable error formatting. |
## Error Model
### `CallError`
```ts
class CallError extends Error {
readonly code: CallErrorCode
readonly details?: unknown
constructor(code: CallErrorCode, message: string, details?: unknown)
}
```
### `InfrastructureErrorCode`
```ts
enum InfrastructureErrorCode {
OPERATION_NOT_FOUND = "OPERATION_NOT_FOUND",
ACCESS_DENIED = "ACCESS_DENIED",
VALIDATION_ERROR = "VALIDATION_ERROR",
TIMEOUT = "TIMEOUT",
ABORTED = "ABORTED",
EXECUTION_ERROR = "EXECUTION_ERROR",
UNKNOWN_ERROR = "UNKNOWN_ERROR",
}
```
`CallErrorCode` is `InfrastructureErrorCode | string` — domain codes from `errorSchemas` are plain strings.
### `mapError`
```ts
function mapError(error: unknown, errorSchemas?: { code: string; schema: unknown }[]): CallError
```
Converts any thrown value to `CallError`. If the thrown value is already a `CallError`, returns it. If it's an `Error` and `errorSchemas` are provided, matches against declared error codes. Falls back to `EXECUTION_ERROR` for unmatched `Error` instances and `UNKNOWN_ERROR` for non-Error values.
## Schema Conversion
### `FromSchema`
```ts
function FromSchema<T>(T: T): TSchema
```
Converts JSON Schema to TypeBox `TSchema`. Handles: `allOf`, `anyOf`, `oneOf`, `enum`, `object` (with `required` tracking), `tuple`, `array`, `const`, `$ref`, primitives (`string`, `number`, `integer`, `boolean`, `null`). Unknown shapes fall back to `Type.Unknown()`.
Used internally by `FromOpenAPI` to convert OpenAPI JSON Schema definitions to TypeBox. Also used by `from_mcp` to convert MCP tool `inputSchema` (which is JSON Schema).
## Adapters
See [adapters.md](adapters.md) for detailed adapter documentation.
| Adapter | Import | Description |
|---------|--------|-------------|
| `FromOpenAPI` | Main barrel | OpenAPI spec → `IOperationDefinition[]` |
| `FromOpenAPIFile` | Main barrel | OpenAPI file → `IOperationDefinition[]` |
| `FromOpenAPIUrl` | Main barrel | OpenAPI URL → `IOperationDefinition[]` |
| `createMCPClient` | `from-mcp` sub-path | MCP server → `MCPClientWrapper` with tool operations |
| `closeMCPClient` | `from-mcp` sub-path | Close MCP client connection |
| `MCPClientLoader` | `from-mcp` sub-path | Manage multiple MCP servers |
| `scanOperations` | Main barrel | Filesystem auto-discovery of operation definitions |

View File

@@ -0,0 +1,137 @@
---
status: draft
last_updated: 2026-04-30
---
# Build & Distribution
Dependencies, project structure, sub-path exports, peer deps, and build tooling.
## Dependencies
### Runtime
| Package | Purpose |
|---------|---------|
| `@alkdev/typebox` | Schema system. `Type` for building schemas, `Value` for validation, `KindGuard` for schema assertion. |
| `@alkdev/pubsub` | Call protocol transport. `PendingRequestMap` creates an internal `PubSub` for event routing. |
| `@logtape/logtape` | Structured logging. Direct import, no wrapper. See [ADR-001](decisions/001-logger-direct-import.md). |
### Peer (Optional)
| Package | Required By | Purpose |
|---------|-------------|---------|
| `@modelcontextprotocol/sdk` | `from_mcp` sub-path | MCP client transport (stdio, HTTP). Dynamic import — only loaded when `createMCPClient` is called. |
### Dev
| Package | Purpose |
|---------|---------|
| `tsup` | Build tool. Dual ESM + CJS with declarations. |
| `typescript` | Type checking (`tsc --noEmit` for lint). |
| `vitest` | Test runner. |
| `@vitest/coverage-v8` | V8 coverage provider. |
| `@modelcontextprotocol/sdk` | Dev dep for MCP tests. Also listed as optional peer. |
| `@types/node` | Node.js type definitions. |
## Project Structure
```
@alkdev/operations/
src/
index.ts # Barrel: re-exports all public API
types.ts # Core types: IOperationDefinition, OperationSpec, OperationType, etc.
registry.ts # OperationRegistry: register, execute, get, list
validation.ts # assertIsSchema, validateOrThrow, collectErrors, formatValueErrors
call.ts # PendingRequestMap, buildCallHandler, CallEventMap, event types
subscribe.ts # subscribe(): direct AsyncGenerator execution
env.ts # buildEnv(): namespace-keyed env with direct/call-protocol modes
error.ts # CallError, InfrastructureErrorCode, mapError
from_schema.ts # FromSchema: JSON Schema → TypeBox conversion
from_openapi.ts # FromOpenAPI, FromOpenAPIFile, FromOpenAPIUrl
from_mcp.ts # createMCPClient, closeMCPClient, MCPClientLoader
scanner.ts # scanOperations: filesystem auto-discovery
test/
# Unit tests per module
docs/
architecture.md
architecture/
README.md
api-surface.md
call-protocol.md
adapters.md
build-distribution.md
decisions/
package.json
tsconfig.json
tsup.config.ts
vitest.config.ts
```
## Sub-Path Exports
```json
{
"exports": {
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
},
"./from-mcp": {
"import": { "types": "./dist/from-mcp.d.ts", "default": "./dist/from-mcp.js" },
"require": { "types": "./dist/from-mcp.d.cts", "default": "./dist/from-mcp.cjs" }
}
}
}
```
The `./from-mcp` sub-path isolates the MCP SDK peer dependency. Consumers that don't use MCP don't need to install `@modelcontextprotocol/sdk`. See [ADR-003](decisions/003-peer-dep-adapters.md).
The main barrel (`src/index.ts`) re-exports everything including `createMCPClient` and `MCPClientLoader` for convenience. The sub-path exists for explicit dependency isolation, not for excluding from the barrel.
## Build
- **Tool**: `tsup` — produces dual ESM + CJS with declarations
- **Entry points**: `src/index.ts`, `src/from_mcp.ts`
- **Format**: ESM + CJS
- **Target**: `es2022`
- **Splitting**: enabled
```ts
// tsup.config.ts
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts', 'src/from_mcp.ts'],
format: ['esm', 'cjs'],
dts: true,
sourcemap: true,
clean: true,
splitting: true,
target: 'es2022',
})
```
## Scripts
| Script | Command | Purpose |
|--------|---------|---------|
| `build` | `tsup` | Build ESM + CJS + declarations |
| `lint` | `tsc --noEmit` | Type-check only (no emit) |
| `test` | `vitest run` | Run tests |
| `test:watch` | `vitest` | Watch mode |
| `test:coverage` | `vitest run --coverage` | Coverage report (v8) |
## Testing
- **Runner**: `vitest`
- **Coverage**: `@vitest/coverage-v8`
- **Config**: `vitest.config.ts`
Tests should mock external services (MCP servers, HTTP endpoints) and use injectable FS interfaces (`ScannerFS`, `OpenAPIFS`) rather than real filesystem access.
## Targets
- **Publish**: npm (`@alkdev/operations`)
- **Runtime**: Node 18+, Deno, Bun — pure JS except `from_mcp` which requires `@modelcontextprotocol/sdk`
- **Deno compatibility**: Source is standard TypeScript with no Deno-specific APIs. Runtime-agnostic FS injection means Deno can provide its own `ScannerFS` and `OpenAPIFS` implementations

View File

@@ -0,0 +1,283 @@
---
status: draft
last_updated: 2026-04-30
---
# Call Protocol
PendingRequestMap, CallHandler, call≡subscribe semantics, event types, error model, and access control.
## Overview
The call protocol is the unified transport layer for all operation invocations. It provides a single event-based mechanism that works the same whether the call is local (in-process), remote (hub↔spoke over websocket), or streamed (subscription). It is built on `@alkdev/pubsub`.
At the protocol level, `call` and `subscribe` are the same thing with different consumption patterns:
- **`call`**: Publish `call.requested`, subscribe to `call.responded:{requestId}`, resolve on first response → `Promise<TOutput>`
- **`subscribe`**: Publish `call.requested`, subscribe to `call.responded:{requestId}`, yield each response → `AsyncIterable<TOutput>`
Both use the same event types, the same `requestId` correlation, and the same `PendingRequestMap`. `call` is semantically `subscribe().next()`.
## Event Types
All communication flows through typed events. The event map is defined as `CallEventMap` using TypeBox schemas, compatible with `@alkdev/pubsub`'s `PubSubPublishArgsByKey`.
### `CallEventMap`
```ts
const CallEventMap = {
"call.requested": Type.Object({
requestId: Type.String(),
operationId: Type.String(),
input: Type.Unknown(),
parentRequestId: Type.Optional(Type.String()),
deadline: Type.Optional(Type.Number()),
identity: Type.Optional(Type.Object({
id: Type.String(),
scopes: Type.Array(Type.String()),
resources: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String()))),
})),
}),
"call.responded": Type.Object({
requestId: Type.String(),
output: Type.Unknown(),
}),
"call.aborted": Type.Object({
requestId: Type.String(),
}),
"call.error": Type.Object({
requestId: Type.String(),
code: Type.String(),
message: Type.String(),
details: Type.Optional(Type.Unknown()),
}),
}
```
### Request Correlation
Every call has a unique `requestId` (UUID). Nested calls include `parentRequestId` to track the call chain. Responses and errors match to requests by `requestId`.
### Event Flow
```
Caller Handler
│ │
│─── call.requested ───────────────>│
│ {requestId, operationId, │
│ input, identity, deadline} │
│ │
│<── call.responded ────────────────│
│ {requestId, output} │
```
On error:
```
│<── call.error ────────────────────│
│ {requestId, code, message, │
│ details} │
```
On abort (caller cancels):
```
│─── call.aborted ─────────────────>│
│ {requestId} │
```
### Identity
The `identity` field in `call.requested` carries the caller's security context through the call chain. Derived from keypal's `ApiKeyMetadata``scopes` maps directly, `resources` uses key format `"type:id"` with scope arrays. Checked by `CallHandler` against the operation's `AccessControl`.
## PendingRequestMap
`PendingRequestMap` manages in-flight requests and provides the `call()` interface. It wraps `@alkdev/pubsub` internally.
### Construction
```ts
const callMap = new PendingRequestMap(eventTarget?)
```
- Creates an internal `PubSub<CallPubSubMap>` using `createPubSub`
- If `eventTarget` is provided, passes it to `createPubSub` for transport-level event routing (Redis, WebSocket, etc.)
- Wires subscription handlers for `call.responded`, `call.error`, and `call.aborted` to route events back to waiting callers
### `call(operationId, input, options?)`
```ts
async call(
operationId: string,
input: unknown,
options?: { parentRequestId?: string; deadline?: number; identity?: Identity },
): Promise<unknown>
```
1. Generate `requestId` via `crypto.randomUUID()`
2. Create a `PendingRequest` with `resolve`/`reject` from a new Promise
3. If `deadline` is set, start a timeout timer that rejects with `TIMEOUT`
4. Store `PendingRequest` in the internal map
5. Publish `call.requested` event with all fields
6. Return the Promise (resolves on `call.responded`, rejects on `call.error` or `call.aborted`)
### Internal Subscription Wiring
On construction, three async loops subscribe to pubsub topics:
- **`call.responded`**: Look up `PendingRequest` by `requestId`, clear timer if set, resolve with `output`
- **`call.error`**: Look up `PendingRequest`, clear timer, reject with `CallError(code, message, details)`
- **`call.aborted`**: Look up `PendingRequest`, clear timer, reject with `CallError(ABORTED, ...)`
### `respond(requestId, output)`
Publishes `call.responded`. Used by handlers to send results back through the protocol.
### `emitError(requestId, code, message, details?)`
Publishes `call.error`. Used by handlers to send errors.
### `abort(requestId)`
Looks up the `PendingRequest`, clears its timer, publishes `call.aborted`, rejects the Promise with `CallError(ABORTED, ...)`.
## CallHandler
`buildCallHandler` creates a function that bridges pubsub events to `OperationRegistry.execute()`.
```ts
function buildCallHandler(config: CallHandlerConfig): CallHandler
interface CallHandlerConfig {
registry: OperationRegistry
eventTarget?: EventTarget
}
type CallHandler = (event: CallRequestedEvent) => Promise<void>
```
### Handler Flow
1. Look up operation by `operationId` from the registry
2. If not found, throw `CallError(OPERATION_NOT_FOUND, ...)`
3. Check access control (see below)
4. Validate input with `validateOrThrow`
5. Execute operation handler
6. On success: the handler is expected to have published `call.responded` through whatever mechanism
7. On failure: `mapError` converts the thrown value to `CallError`
The `CallHandler` is designed to be wired into a pubsub subscription:
```ts
const callHandler = buildCallHandler({ registry, eventTarget })
pubsub.subscribe("call.requested", callHandler)
```
## Access Control
### Enforcement Point
`CallHandler` enforces `AccessControl` before dispatching to `registry.execute()`. Direct `registry.execute()` calls bypass access control — this is by design for trusted internal calls.
### Flow
```
call.requested event arrives with Identity
→ Look up operation's AccessControl
→ Check requiredScopes (caller has ALL?)
→ Check requiredScopesAny (caller has ANY?)
→ Check resourceType/resourceAction against identity.resources
→ All pass → proceed to execute
→ Any fail → throw CallError(ACCESS_DENIED, ...)
```
### `checkAccess` Implementation
```ts
function checkAccess(accessControl: AccessControl, identity: Identity): boolean
```
1. If `requiredScopes` is non-empty, verify `identity.scopes` contains every entry (AND)
2. If `requiredScopesAny` is non-empty, verify `identity.scopes` contains at least one entry (OR)
3. If `resourceType` and `resourceAction` are set, verify `identity.resources["{resourceType}:{resourceId}"]` includes `resourceAction`
4. Return `true` if all applicable checks pass
Note: Access control without an `identity` in the `CallRequestedEvent` is **allowed** — unauthenticated calls are permitted if the `AccessControl` check passes (e.g., operations with empty `requiredScopes`).
## Error Model
The call protocol uses a unified error model. Both infrastructure and domain errors flow through `CallError`.
### `CallError`
```ts
class CallError extends Error {
readonly code: CallErrorCode // InfrastructureErrorCode | string
readonly details?: unknown
}
```
### Infrastructure Error Codes
Reserved codes produced by `CallHandler` and `PendingRequestMap`:
| Code | When | Details |
|------|------|---------|
| `OPERATION_NOT_FOUND` | No operation matches `operationId` | `{ operationId: string }` |
| `ACCESS_DENIED` | Missing scopes | `{ requiredScopes?: string[] }` |
| `VALIDATION_ERROR` | Input fails `inputSchema` check | Wrapped from `Value.Errors` |
| `TIMEOUT` | Deadline exceeded | `{ deadline: number }` |
| `ABORTED` | Call cancelled | — |
| `EXECUTION_ERROR` | Handler threw, no `errorSchemas` match | `{ message: string }` |
| `UNKNOWN_ERROR` | Non-Error thrown | `{ raw: string }` |
### Domain Error Propagation
Operations declare their possible errors via `errorSchemas` on `IOperationDefinition`. When a handler throws, `mapError` matches the thrown error against declared schemas — falls back to `EXECUTION_ERROR` if no match.
`errorSchemas` is the contract between operation and callers about what errors it might produce. No `errorSchemas` = safe default with `EXECUTION_ERROR` wrapper.
### `mapError` Resolution
1. If already a `CallError`, return as-is
2. If `Error` instance and `errorSchemas` provided, check if `error.message` includes any declared error code → return `CallError(code, message, error)`
3. If `Error` instance, return `CallError(EXECUTION_ERROR, error.message, error)`
4. Otherwise, return `CallError(UNKNOWN_ERROR, String(error), { raw: String(error) })`
## Nested Call Wiring
Routing is an env construction concern, not a separate protocol layer. `buildEnv` creates the `OperationEnv`:
- **Direct mode**: `buildEnv({ registry, context })` — env functions call `registry.execute()` directly
- **Call protocol mode**: `buildEnv({ registry, context, callMap })` — env functions call `callMap.call()`, publishing `call.requested` events with `parentRequestId` propagation
`parentRequestId` enables call graph reconstruction and abort cascading — every nested call includes it.
## Transport Mapping
The call protocol is transport-agnostic. The `PubSub` event target determines how events move:
| Transport | Use Case | EventTarget impl |
|-----------|----------|-----------------|
| In-process | Local hub operations | Browser `EventTarget` (default) |
| Redis | Cross-process events | `RedisEventTarget` (from `@alkdev/pubsub`) |
| WebSocket | Hub ↔ spoke bidirectional | `WebSocketEventTarget` (future) |
Same protocol, same event shapes, same `PendingRequestMap` — different `eventTarget`.
## Subscribe (Direct)
The `subscribe()` function provides direct in-process subscription consumption:
```ts
async function* subscribe(
registry: OperationRegistry,
operationId: string,
input: unknown,
context: OperationContext,
): AsyncGenerator<unknown, void, unknown>
```
Gets the operation from the registry, casts its handler to `AsyncGenerator`, and yields values. Properly cleans up with `generator.return()` in a `finally` block.
Use `subscribe()` for in-process consumption. Use `PendingRequestMap.call()` for cross-transport invocation that resolves after one event. For cross-transport streaming, use `PendingRequestMap.subscribe()` to yield multiple events.

View File

@@ -0,0 +1,35 @@
# ADR-001: Direct @logtape/logtape Import
**Status**: Accepted
**Date**: 2026-04-30
## Context
The operations package needs structured logging. Within alkhub, logging went through a wrapper module (`core/logger/mod.ts`) that configured logtape categories and re-exported a logger instance. Now that operations is a standalone package, we need to decide how to handle logging.
Two approaches:
1. **Wrapper module** — create `src/logger.ts` that configures logtape and exports configured logger instances
2. **Direct import** — import `getLogger` from `@logtape/logtape` directly in each module
## Decision
Import `@logtape/logtape` directly. No wrapper module.
## Rationale
1. **Simpler** — one less file to maintain. The logtape API (`getLogger("category")`) is already clean and doesn't need wrapping.
2. **Category convention** — logtape uses dot-separated category strings (e.g., `"operations:registry"`, `"operations:call"`). These are self-documenting and don't need a function to build them.
3. **Configuration is external** — logtape configuration (log levels, sinks, categories) belongs to the application, not the library. The library just emits logs; the consumer decides what to do with them. A wrapper module would imply the library owns configuration, which it shouldn't.
4. **Consistent with logtape's design** — logtape's `getLogger()` is designed to be called directly in each module. It's not a per-invocation cost — `getLogger` returns a cached instance.
5. **No hidden state** — a wrapper module could carry configuration state that makes the library harder to reason about in isolation. Direct import means the library is stateless with respect to logging.
## Consequences
- Consumers must configure `@logtape/logtape` in their application if they want to see log output from this package
- Log categories follow the `operations:{module}` convention throughout
- `@logtape/logtape` is a direct runtime dependency (not a peer dep — it's small and we control its version)

View File

@@ -0,0 +1,48 @@
# ADR-002: Inject Filesystem Dependencies for Runtime Agnosticism
**Status**: Accepted
**Date**: 2026-04-30
## Context
The operations package must work in both Node.js and Deno. Two functions need filesystem access:
1. `scanOperations(dirPath, fs)` — recursive directory scan for `.ts` operation files
2. `FromOpenAPIFile(path, config, fs?)` — read OpenAPI JSON spec from filesystem
In Node.js, these use `node:fs/promises` and `node:path`. In Deno, they would use `Deno.readDir()` and `Deno.cwd()`. Direct use of Node APIs would break Deno; direct use of Deno globals would break Node.
## Decision
Inject filesystem dependencies through interfaces, not global imports.
```ts
interface ScannerFS {
readdir(path: string): AsyncIterable<{ name: string; isFile: boolean; isDirectory: boolean }>
cwd(): string
}
interface OpenAPIFS {
readFile(path: string): Promise<string>
}
```
Callers provide the FS implementation. When `OpenAPIFS` is not provided, `FromOpenAPIFile` falls back to `node:fs/promises` via dynamic import.
## Rationale
1. **No platform globals in source** — no `Deno.*` calls anywhere in `src/`. Both Node and Deno consumers work by providing the right FS interface.
2. **Testability** — tests provide mock FS implementations. No filesystem mocking libraries needed.
3. **Consistent pattern**`ScannerFS` and `OpenAPIFS` follow the same pattern: minimal interface, consumer-provided implementation, optional Node fallback.
4. **Deno path module** — the original alkhub scanner used `@std/path` (Deno standard library path module) for `resolve()` and `extname()`. The extracted version avoids this dependency by using simple string operations (`endsWith(".ts")`, path construction with `/`).
5. **Node fallback is dynamic**`FromOpenAPIFile` uses `await import("node:fs/promises")` as a fallback when no `fs` is provided. This keeps the Node path out of the module graph when a custom FS is injected, and avoids top-level Node imports that would break Deno.
## Consequences
- Callers in Deno must provide `ScannerFS` and `OpenAPIFS` implementations using `Deno.readDir()` and `Deno.readTextFile()`
- Callers in Node can omit the `fs` parameter for `FromOpenAPIFile` (Node fallback) but must provide `ScannerFS` for `scanOperations`
- The `pathToFileURL` helper in scanner uses a simple `file://` prefix construction rather than `url.pathToFileURL()` to avoid importing Node's `url` module

View File

@@ -0,0 +1,54 @@
# ADR-003: Peer Dependencies for Adapter Isolation
**Status**: Accepted
**Date**: 2026-04-30
## Context
The MCP adapter (`from_mcp.ts`) depends on `@modelcontextprotocol/sdk` for `Client`, `StdioClientTransport`, and `StreamableHTTPClientTransport`. This dependency is heavy (transitive deps) and only needed by consumers that connect to MCP servers. Other adapters may have similar heavy dependencies in the future (e.g., gRPC, GraphQL).
Two approaches:
1. **Regular dependency** — list `@modelcontextprotocol/sdk` as a direct dependency. All consumers install it.
2. **Optional peer dependency + sub-path export** — list it as an optional peer dependency, import dynamically, and expose via a separate `./from-mcp` sub-path export.
## Decision
Use optional peer dependency with sub-path export.
```json
{
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.0.0"
},
"peerDependenciesMeta": {
"@modelcontextprotocol/sdk": { "optional": true }
},
"exports": {
".": { ... },
"./from-mcp": { ... }
}
}
```
## Rationale
1. **Zero-cost for non-MCP consumers**`npm install @alkdev/operations` does not install `@modelcontextprotocol/sdk`. Only consumers that `import { createMCPClient } from "@alkdev/operations/from-mcp"` need to install it.
2. **Dynamic import**`from_mcp.ts` uses `await import("@modelcontextprotocol/sdk/client/index.js")` and `await import("@modelcontextprotocol/sdk/client/stdio.js")`. The MCP SDK is loaded only when `createMCPClient` is actually called, not at module parse time.
3. **Explicit dependency declaration** — the sub-path import makes it clear at the import site that this code needs the MCP SDK. A barrel-only import doesn't communicate this.
4. **No bundler reliance** — sub-path exports don't depend on the consumer's bundler correctly tree-shaking. Not all consumers use bundlers (Deno, Node with `--experimental-strip-types`).
5. **Follows established pattern**`@alkdev/pubsub` uses the same approach for its Redis adapter (sub-path export with optional ioredis peer dep).
6. **Incremental** — future adapters (gRPC, GraphQL) will follow the same pattern. Each adds one peer dep entry and one sub-path export.
## Consequences
- `package.json` has a peer dep entry for each adapter's external dependency
- Both barrel and sub-path work — barrel re-exports everything for convenience, sub-path for explicitness
- `tsup` must list each adapter as a separate entry point
- Consumer docs should recommend sub-path imports for adapter-specific code
- The `from_mcp.ts` module also imports from `from_schema.ts` and `types.ts` (core), which are bundled into the sub-path output by tsup's code splitting

View File

@@ -0,0 +1,50 @@
# ADR-004: Schema Const Naming Convention
**Status**: Accepted
**Date**: 2026-04-30
## Context
TypeBox schemas and their inferred types need different names but represent the same concept. For example, the `AccessControl` schema defines the runtime shape and the `AccessControl` type provides the TypeScript interface. Using the same name for both creates a naming collision.
Two naming conventions are common in TypeBox codebases:
1. **Same name**`AccessControl` for both the schema constant and the inferred type (relies on TypeScript's value/type namespace separation)
2. **Different names**`AccessControlSchema` for the schema constant, `AccessControl` for the inferred type
## Decision
Use the `Schema` suffix for schema constants. The inferred type uses the bare name.
```ts
export const AccessControlSchema = Type.Object({ ... })
export type AccessControl = Static<typeof AccessControlSchema>
export const OperationSpecSchema = Type.Object({ ... })
export type OperationSpec = Static<typeof OperationSpecSchema>
export const ErrorDefinitionSchema = Type.Object({ ... })
export type ErrorDefinition = Static<typeof ErrorDefinitionSchema>
export const OperationDefinitionSchema = Type.Object({ ... })
export interface IOperationDefinition<...> extends OperationSpec<...> { ... }
```
## Rationale
1. **No ambiguity**`AccessControl` always refers to the type, `AccessControlSchema` always refers to the runtime schema. No mental overhead to distinguish them.
2. **TypeScript namespace separation is fragile** — while TypeScript technically allows the same name for a value and a type (they occupy different namespaces), this creates confusion when reading code. `const ac: AccessControl = ...` — which `AccessControl`? The schema or the type? With the `Schema` suffix, it's immediately clear.
3. **Consistent with TypeBox conventions** — TypeBox's own `Type.*` factory functions produce schema objects. Naming the const with a `Schema` suffix aligns with the mental model that these are schema definitions, not data instances.
4. **Export clarity** — in `index.ts`, both are exported. Having distinct names means `import { AccessControlSchema, AccessControl }` is immediately clear: one is a runtime value, one is a type.
5. **Existing pattern** — this convention is already used in `OperationDefinitionSchema` vs `IOperationDefinition` and `OperationSpecSchema` vs `OperationSpec` in the codebase.
## Consequences
- All schema const names end in `Schema`
- All type names are the bare concept name (or prefixed with `I` for interfaces)
- This applies to `AccessControlSchema`/`AccessControl`, `ErrorDefinitionSchema`/`ErrorDefinition`, `OperationContextSchema`/`OperationContext`, `OperationSpecSchema`/`OperationSpec`, `OperationDefinitionSchema`/`IOperationDefinition`
- When adding new schemas, follow this convention: `FooSchema` for the const, `Foo` for the type