From 3e1884cd238ef329d4f473403ef441aa1e715109 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Sat, 16 May 2026 09:24:34 +0000 Subject: [PATCH] feat: add SchemaAdapter system for typemap integration (Zod/Valibot), fix scanner pathToFileURL - Add from_typemap.ts with SchemaAdapter interface, defaultAdapter, zodAdapter(), valibotAdapter() - OperationRegistry now accepts optional { schemaAdapter } config; default is TypeBox passthrough - Adapters use @alkdev/typemap (optional peer dep) with lazy init; detect schemas via ~standard vendor protocol - Fix scanner pathToFileURL to encode URI components and normalize Windows backslashes - Add from-typemap sub-path export in package.json and tsup config - Add test suite covering defaultAdapter, zodAdapter, valibotAdapter, and registry integration --- package-lock.json | 44 +++++++-- package.json | 22 ++++- src/from_typemap.ts | 115 +++++++++++++++++++++++ src/index.ts | 3 + src/registry.ts | 31 +++++-- src/scanner.ts | 6 +- test/from_typemap.test.ts | 187 ++++++++++++++++++++++++++++++++++++++ tsup.config.ts | 1 + 8 files changed, 390 insertions(+), 19 deletions(-) create mode 100644 src/from_typemap.ts create mode 100644 test/from_typemap.test.ts diff --git a/package-lock.json b/package-lock.json index 13d610f..9b005fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,20 +14,26 @@ "@logtape/logtape": "^2.0.0" }, "devDependencies": { + "@alkdev/typemap": "^0.10.1", "@modelcontextprotocol/sdk": "^1.12.1", "@types/node": "^22.0.0", "@vitest/coverage-v8": "^3.2.4", "tsup": "^8.5.1", "typescript": "^5.7.0", + "valibot": "^1.4.0", "vitest": "^3.1.0" }, "engines": { "node": ">=18.0.0" }, "peerDependencies": { + "@alkdev/typemap": "^0.10.0", "@modelcontextprotocol/sdk": "^1.0.0" }, "peerDependenciesMeta": { + "@alkdev/typemap": { + "optional": true + }, "@modelcontextprotocol/sdk": { "optional": true } @@ -36,9 +42,6 @@ "../pubsub": { "version": "0.1.0", "license": "MIT OR Apache-2.0", - "dependencies": { - "@repeaterjs/repeater": "^3.0.0" - }, "devDependencies": { "@types/node": "^22.0.0", "@vitest/coverage-v8": "^3.2.4", @@ -205,10 +208,6 @@ "node": ">=14" } }, - "../pubsub/node_modules/@repeaterjs/repeater": { - "version": "3.0.6", - "license": "MIT" - }, "../pubsub/node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.60.2", "cpu": [ @@ -1928,6 +1927,18 @@ "version": "0.34.49", "license": "MIT" }, + "node_modules/@alkdev/typemap": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@alkdev/typemap/-/typemap-0.10.1.tgz", + "integrity": "sha512-nCrZrrxgyWr3xBXjCWhUj56rH/Yb64l96Yl8gr3fYF5JS+nfiTgLcJ0shRvSAA8nznsiPfMJgmNrTGsdyHYs2g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@alkdev/typebox": "^0.34.49", + "valibot": "^1.0.0", + "zod": "^3.24.1" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "dev": true, @@ -5163,6 +5174,21 @@ "node": ">= 0.8" } }, + "node_modules/valibot": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.0.tgz", + "integrity": "sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/vary": { "version": "1.1.2", "dev": true, @@ -5455,7 +5481,9 @@ "license": "ISC" }, "node_modules/zod": { - "version": "4.4.1", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", "funding": { diff --git a/package.json b/package.json index 28482d3..dd4a2f8 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,16 @@ "types": "./dist/from-mcp.d.cts", "default": "./dist/from-mcp.cjs" } + }, + "./from-typemap": { + "import": { + "types": "./dist/from-typemap.d.ts", + "default": "./dist/from-typemap.js" + }, + "require": { + "types": "./dist/from-typemap.d.cts", + "default": "./dist/from-typemap.cjs" + } } }, "publishConfig": { @@ -53,27 +63,33 @@ ], "license": "MIT OR Apache-2.0", "dependencies": { - "@alkdev/typebox": "^0.34.49", "@alkdev/pubsub": "^0.1.0", + "@alkdev/typebox": "^0.34.49", "@logtape/logtape": "^2.0.0" }, "peerDependencies": { + "@alkdev/typemap": "^0.10.0", "@modelcontextprotocol/sdk": "^1.0.0" }, "peerDependenciesMeta": { "@modelcontextprotocol/sdk": { "optional": true + }, + "@alkdev/typemap": { + "optional": true } }, "devDependencies": { - "@types/node": "^22.0.0", + "@alkdev/typemap": "^0.10.1", "@modelcontextprotocol/sdk": "^1.12.1", + "@types/node": "^22.0.0", "@vitest/coverage-v8": "^3.2.4", "tsup": "^8.5.1", "typescript": "^5.7.0", + "valibot": "^1.4.0", "vitest": "^3.1.0" }, "engines": { "node": ">=18.0.0" } -} \ No newline at end of file +} diff --git a/src/from_typemap.ts b/src/from_typemap.ts new file mode 100644 index 0000000..4b00d8a --- /dev/null +++ b/src/from_typemap.ts @@ -0,0 +1,115 @@ +import type { TSchema } from "@alkdev/typebox"; +import { KindGuard } from "@alkdev/typebox"; +import { getLogger } from "@logtape/logtape"; + +const logger = getLogger("operations:typemap"); + +export interface SchemaAdapter { + toTypeBox(schema: unknown): TSchema; + init?(): Promise; +} + +export const defaultAdapter: SchemaAdapter = { + toTypeBox(schema: unknown): TSchema { + if (KindGuard.IsSchema(schema)) { + return schema as TSchema; + } + throw new Error( + `SchemaAdapter: expected a TypeBox schema, but received ${typeof schema}. ` + + `Install @alkdev/typemap and use zodAdapter/valibotAdapter to convert from other schema libraries.` + ); + }, +}; + +function isStandardSchemaFrom(value: unknown, vendor: string): boolean { + if (typeof value !== "object" || value === null) return false; + const standard = (value as Record)["~standard"]; + if (typeof standard !== "object" || standard === null) return false; + return (standard as Record)["vendor"] === vendor; +} + +export function zodAdapter(): SchemaAdapter & { init(): Promise } { + let TypeBoxFromZod: ((schema: unknown) => TSchema) | null = null; + let loaded = false; + + return { + async init(): Promise { + if (loaded) return; + try { + const typemap = await import("@alkdev/typemap"); + TypeBoxFromZod = typemap.TypeBoxFromZod as ((schema: unknown) => TSchema); + loaded = true; + logger.info("zodAdapter: loaded @alkdev/typemap"); + } catch { + throw new Error( + "zodAdapter requires @alkdev/typemap as a peer dependency. " + + "Install it with: npm install @alkdev/typemap" + ); + } + }, + toTypeBox(schema: unknown): TSchema { + if (KindGuard.IsSchema(schema)) { + return schema as TSchema; + } + if (isStandardSchemaFrom(schema, "zod") && TypeBoxFromZod) { + return TypeBoxFromZod(schema); + } + if (TypeBoxFromZod && !isStandardSchemaFrom(schema, "zod") && !isStandardSchemaFrom(schema, "valibot")) { + throw new Error( + `zodAdapter: schema is not a Zod or TypeBox schema (received ${typeof schema})` + ); + } + if (TypeBoxFromZod) { + throw new Error( + `zodAdapter: schema uses a different schema library than Zod. Use the appropriate adapter.` + ); + } + throw new Error( + "zodAdapter: not initialized. Call await adapter.init() before using toTypeBox()." + ); + }, + }; +} + +export function valibotAdapter(): SchemaAdapter & { init(): Promise } { + let TypeBoxFromValibot: ((schema: unknown) => TSchema) | null = null; + let loaded = false; + + return { + async init(): Promise { + if (loaded) return; + try { + const typemap = await import("@alkdev/typemap"); + TypeBoxFromValibot = typemap.TypeBoxFromValibot as ((schema: unknown) => TSchema); + loaded = true; + logger.info("valibotAdapter: loaded @alkdev/typemap"); + } catch { + throw new Error( + "valibotAdapter requires @alkdev/typemap as a peer dependency. " + + "Install it with: npm install @alkdev/typemap" + ); + } + }, + toTypeBox(schema: unknown): TSchema { + if (KindGuard.IsSchema(schema)) { + return schema as TSchema; + } + if (isStandardSchemaFrom(schema, "valibot") && TypeBoxFromValibot) { + return TypeBoxFromValibot(schema); + } + if (TypeBoxFromValibot && !isStandardSchemaFrom(schema, "valibot") && !isStandardSchemaFrom(schema, "zod")) { + throw new Error( + `valibotAdapter: schema is not a Valibot or TypeBox schema (received ${typeof schema})` + ); + } + if (TypeBoxFromValibot) { + throw new Error( + `valibotAdapter: schema uses a different schema library than Valibot. Use the appropriate adapter.` + ); + } + throw new Error( + "valibotAdapter: not initialized. Call await adapter.init() before using toTypeBox()." + ); + }, + }; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 52b770e..75d67d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export { OperationType, OperationContextSchema, OperationSpecSchema, AccessControlSchema, ErrorDefinitionSchema } from "./types.js"; export type { IOperationDefinition, OperationHandler, SubscriptionHandler, Identity, OperationEnv, OperationContext, OperationSpec, AccessControl, ErrorDefinition } from "./types.js"; export { OperationRegistry } from "./registry.js"; +export type { RegistryOptions } from "./registry.js"; export { formatValueErrors, assertIsSchema, validateOrThrow, collectErrors } from "./validation.js"; export { buildEnv } from "./env.js"; export type { EnvOptions } from "./env.js"; @@ -15,6 +16,8 @@ export { PendingRequestMap, buildCallHandler } from "./call.js"; export type { CallEventMap, CallEventMapValue, CallRequestedEvent, CallRespondedEvent, CallAbortedEvent, CallErrorEvent, CallHandler, CallHandlerConfig } from "./call.js"; export { checkAccess } from "./access.js"; export { subscribe } from "./subscribe.js"; +export { defaultAdapter, zodAdapter, valibotAdapter } from "./from_typemap.js"; +export type { SchemaAdapter } from "./from_typemap.js"; export { createMCPClient, closeMCPClient, MCPClientLoader } from "./from_mcp.js"; export type { MCPClientConfig, MCPClientWrapper } from "./from_mcp.js"; export { ResponseEnvelopeSchema, ResponseMetaSchema, RESPONSE_SOURCES, isResponseEnvelope, localEnvelope, httpEnvelope, mcpEnvelope, unwrap } from "./response-envelope.js"; diff --git a/src/registry.ts b/src/registry.ts index 5eea163..b2feec4 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -1,28 +1,44 @@ import type { OperationContext, OperationSpec, OperationHandler, SubscriptionHandler, Identity, AccessControl } from "./types.js"; import { getLogger } from "@logtape/logtape"; import { Value } from "@alkdev/typebox/value"; -import { KindGuard } from "@alkdev/typebox"; +import { KindGuard, type TSchema } from "@alkdev/typebox"; import { assertIsSchema, validateOrThrow, collectErrors, formatValueErrors } from "./validation.js"; import { isResponseEnvelope, localEnvelope, type ResponseEnvelope } from "./response-envelope.js"; import { CallError, InfrastructureErrorCode } from "./error.js"; import { checkAccess } from "./access.js"; +import type { SchemaAdapter } from "./from_typemap.js"; +import { defaultAdapter } from "./from_typemap.js"; const logger = getLogger("operations:registry"); +export interface RegistryOptions { + schemaAdapter?: SchemaAdapter; +} + export class OperationRegistry { private specs = new Map(); private handlers = new Map(); + private adapter: SchemaAdapter; + + constructor(options?: RegistryOptions) { + this.adapter = options?.schemaAdapter ?? defaultAdapter; + } private opId(namespace: string, name: string): string { return `${namespace}.${name}`; } + private toTypeBox(schema: unknown): TSchema { + return this.adapter.toTypeBox(schema); + } + register(operation: OperationSpec & { handler?: OperationHandler | SubscriptionHandler }): void { const id = this.opId(operation.namespace, operation.name); - assertIsSchema(operation.inputSchema, `${id} inputSchema`); - assertIsSchema(operation.outputSchema, `${id} outputSchema`); + const inputSchema = this.toTypeBox(operation.inputSchema); + const outputSchema = this.toTypeBox(operation.outputSchema); const { handler, ...spec } = operation; - this.specs.set(id, spec); + const resolvedSpec: OperationSpec = { ...spec, inputSchema, outputSchema }; + this.specs.set(id, resolvedSpec); if (handler) { this.handlers.set(id, handler); } @@ -37,9 +53,10 @@ export class OperationRegistry { registerSpec(spec: OperationSpec): void { const id = this.opId(spec.namespace, spec.name); - assertIsSchema(spec.inputSchema, `${id} inputSchema`); - assertIsSchema(spec.outputSchema, `${id} outputSchema`); - this.specs.set(id, spec); + const inputSchema = this.toTypeBox(spec.inputSchema); + const outputSchema = this.toTypeBox(spec.outputSchema); + const resolvedSpec: OperationSpec = { ...spec, inputSchema, outputSchema }; + this.specs.set(id, resolvedSpec); logger.info(`Registered spec: ${id}`); } diff --git a/src/scanner.ts b/src/scanner.ts index 180ac48..88776a5 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -93,5 +93,9 @@ async function processDirectory( } function pathToFileURL(absolutePath: string): string { - return `file://${absolutePath}`; + let normalized = absolutePath.replace(/\\/g, "/"); + if (!normalized.startsWith("/")) { + normalized = `/${normalized}`; + } + return `file://${encodeURI(normalized).replace(/#/g, "%23")}`; } \ No newline at end of file diff --git a/test/from_typemap.test.ts b/test/from_typemap.test.ts new file mode 100644 index 0000000..1342837 --- /dev/null +++ b/test/from_typemap.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from "vitest"; +import { defaultAdapter, zodAdapter, valibotAdapter, type SchemaAdapter } from "../src/from_typemap.js"; +import * as Type from "@alkdev/typebox"; +import { KindGuard } from "@alkdev/typebox"; +import { OperationRegistry, type RegistryOptions } from "../src/registry.js"; +import { OperationType, type IOperationDefinition } from "../src/index.js"; + +describe("defaultAdapter", () => { + it("passes through TypeBox schemas unchanged", () => { + const schema = Type.Object({ name: Type.String() }); + const result = defaultAdapter.toTypeBox(schema); + expect(result).toBe(schema); + }); + + it("throws for non-TypeBox schemas", () => { + expect(() => defaultAdapter.toTypeBox({})).toThrow("expected a TypeBox schema"); + expect(() => defaultAdapter.toTypeBox("string")).toThrow("expected a TypeBox schema"); + expect(() => defaultAdapter.toTypeBox(42)).toThrow("expected a TypeBox schema"); + }); +}); + +describe("zodAdapter", () => { + it("passes through TypeBox schemas without init", () => { + const adapter = zodAdapter(); + const schema = Type.Object({ name: Type.String() }); + const result = adapter.toTypeBox(schema); + expect(result).toBe(schema); + }); + + it("throws for non-TypeBox schemas when not initialized", () => { + const adapter = zodAdapter(); + expect(() => adapter.toTypeBox({})).toThrow("not initialized"); + }); + + it("converts Zod schemas after init", async () => { + const adapter = zodAdapter(); + await adapter.init(); + const { z } = await import("zod"); + const zodSchema = z.object({ name: z.string(), age: z.number() }); + const result = adapter.toTypeBox(zodSchema); + expect(KindGuard.IsSchema(result)).toBe(true); + }); + + it("passes through TypeBox schemas after init", async () => { + const adapter = zodAdapter(); + await adapter.init(); + const schema = Type.Object({ name: Type.String() }); + const result = adapter.toTypeBox(schema); + expect(result).toBe(schema); + }); + + it("throws for unrecognized schemas after init", async () => { + const adapter = zodAdapter(); + await adapter.init(); + expect(() => adapter.toTypeBox(42)).toThrow("not a Zod or TypeBox schema"); + }); + + it("init is idempotent", async () => { + const adapter = zodAdapter(); + await adapter.init(); + await adapter.init(); + }); +}); + +describe("valibotAdapter", () => { + it("passes through TypeBox schemas without init", () => { + const adapter = valibotAdapter(); + const schema = Type.Object({ name: Type.String() }); + const result = adapter.toTypeBox(schema); + expect(result).toBe(schema); + }); + + it("throws for non-TypeBox schemas when not initialized", () => { + const adapter = valibotAdapter(); + expect(() => adapter.toTypeBox({})).toThrow("not initialized"); + }); + + it("converts Valibot schemas after init", async () => { + const adapter = valibotAdapter(); + await adapter.init(); + const v = await import("valibot"); + const valibotSchema = v.object({ id: v.string(), active: v.boolean() }); + const result = adapter.toTypeBox(valibotSchema); + expect(KindGuard.IsSchema(result)).toBe(true); + }); + + it("passes through TypeBox schemas after init", async () => { + const adapter = valibotAdapter(); + await adapter.init(); + const schema = Type.Object({ name: Type.String() }); + const result = adapter.toTypeBox(schema); + expect(result).toBe(schema); + }); + + it("throws for unrecognized schemas after init", async () => { + const adapter = valibotAdapter(); + await adapter.init(); + expect(() => adapter.toTypeBox(42)).toThrow("not a Valibot or TypeBox schema"); + }); + + it("init is idempotent", async () => { + const adapter = valibotAdapter(); + await adapter.init(); + await adapter.init(); + }); +}); + +describe("OperationRegistry with SchemaAdapter", () => { + function makeOperation(overrides: Partial = {}): IOperationDefinition { + return { + name: "testOp", + namespace: "test", + version: "1.0.0", + type: OperationType.QUERY, + description: "A test operation", + inputSchema: Type.Object({ value: Type.String() }), + outputSchema: Type.Object({ result: Type.String() }), + accessControl: { requiredScopes: [] }, + handler: async (input: any) => ({ result: `processed: ${input.value}` }), + ...overrides, + }; + } + + it("works with default adapter (TypeBox schemas)", async () => { + const registry = new OperationRegistry(); + registry.register(makeOperation()); + const result = await registry.execute("test.testOp", { value: "hello" }, {}); + expect(result.data).toEqual({ result: "processed: hello" }); + }); + + it("works with zodAdapter after init", async () => { + const adapter = zodAdapter(); + await adapter.init(); + const registry = new OperationRegistry({ schemaAdapter: adapter }); + const { z } = await import("zod"); + registry.register({ + name: "zodOp", + namespace: "test", + version: "1.0.0", + type: OperationType.QUERY, + description: "Operation with Zod schemas", + inputSchema: z.object({ name: z.string() }) as any, + outputSchema: z.object({ greeting: z.string() }) as any, + accessControl: { requiredScopes: [] }, + handler: async (input: any) => ({ greeting: `Hello, ${input.name}` }), + }); + const result = await registry.execute("test.zodOp", { name: "world" }, {}); + expect(result.data).toEqual({ greeting: "Hello, world" }); + }); + + it("works with valibotAdapter after init", async () => { + const adapter = valibotAdapter(); + await adapter.init(); + const registry = new OperationRegistry({ schemaAdapter: adapter }); + const v = await import("valibot"); + registry.register({ + name: "valOp", + namespace: "test", + version: "1.0.0", + type: OperationType.QUERY, + description: "Operation with Valibot schemas", + inputSchema: v.object({ id: v.string() }) as any, + outputSchema: v.object({ status: v.string() }) as any, + accessControl: { requiredScopes: [] }, + handler: async (input: any) => ({ status: `ok: ${input.id}` }), + }); + const result = await registry.execute("test.valOp", { id: "abc" }, {}); + expect(result.data).toEqual({ status: "ok: abc" }); + }); + + it("throws with defaultAdapter for non-TypeBox schemas", () => { + const registry = new OperationRegistry(); + expect(() => registry.register({ + ...makeOperation(), + inputSchema: {} as any, + })).toThrow("expected a TypeBox schema"); + }); + + it("can use TypeBox schemas with zodAdapter (mixed)", async () => { + const adapter = zodAdapter(); + await adapter.init(); + const registry = new OperationRegistry({ schemaAdapter: adapter }); + registry.register(makeOperation()); + const result = await registry.execute("test.testOp", { value: "hello" }, {}); + expect(result.data).toEqual({ result: "processed: hello" }); + }); +}); \ No newline at end of file diff --git a/tsup.config.ts b/tsup.config.ts index 2dad94a..7d133d9 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: [ 'src/index.ts', 'src/from_mcp.ts', + 'src/from_typemap.ts', ], format: ['esm', 'cjs'], dts: true,