Unified · Split
| @@ -26,6 +26,7 @@ · 25 unmodified lines | ||
| 26 | 26 | "name": "@executor-v3/codemode-core", |
| 27 | 27 | "dependencies": { |
| 28 | 28 | "@standard-schema/spec": "^1.0.0", |
| 29 | + "ajv": "^8.17.1", | |
| 29 | 30 | "effect": "^3.19.19", |
| 30 | 31 | }, |
| 31 | 32 | "devDependencies": { |
| @@ -12,6 +12,7 @@ · 11 unmodified lines | ||
| 12 | 12 | }, |
| 13 | 13 | "dependencies": { |
| 14 | 14 | "@standard-schema/spec": "^1.0.0", |
| 15 | + "ajv": "^8.17.1", | |
| 15 | 16 | "effect": "^3.19.19" |
| 16 | 17 | }, |
| 17 | 18 | "devDependencies": { |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | executeCodeWithTools, |
| 10 | 10 | makeToolInvokerFromTools, |
| 11 | 11 | mergeToolMaps, |
| 12 | + standardSchemaFromJsonSchema, | |
| 12 | 13 | toTool, |
| 13 | 14 | type CodeExecutor, |
| 14 | 15 | type ToolDescriptor, |
| @@ -79,6 +80,50 @@ describe("codemode-core", () => { · 64 unmodified lines | ||
| 79 | 80 | }), |
| 80 | 81 | ); |
| 81 | 82 | |
| 83 | + | |
| 84 | + it.effect("builds Standard Schema validators from JSON Schema", () => | |
| 85 | + Effect.gen(function* () { | |
| 86 | + const tools = { | |
| 87 | + "math.add": { | |
| 88 | + description: "Add two numbers", | |
| 89 | + inputSchema: standardSchemaFromJsonSchema({ | |
| 90 | + type: "object", | |
| 91 | + required: ["a", "b"], | |
| 92 | + properties: { | |
| 93 | + a: { type: "number" }, | |
| 94 | + b: { type: "number" }, | |
| 95 | + }, | |
| 96 | + additionalProperties: false, | |
| 97 | + }), | |
| 98 | + execute: ({ a, b }: { a: number; b: number }) => ({ sum: a + b }), | |
| 99 | + }, | |
| 100 | + } satisfies ToolMap; | |
| 101 | + | |
| 102 | + const invoker = makeToolInvokerFromTools({ tools }); | |
| 103 | + | |
| 104 | + const success = yield* invoker.invoke({ | |
| 105 | + path: "math.add", | |
| 106 | + args: { a: 1, b: 2 }, | |
| 107 | + }); | |
| 108 | + expect(success).toEqual({ sum: 3 }); | |
| 109 | + | |
| 110 | + const failure = yield* Effect.either( | |
| 111 | + invoker.invoke({ | |
| 112 | + path: "math.add", | |
| 113 | + args: { a: "1", b: 2 }, | |
| 114 | + }), | |
| 115 | + ); | |
| 116 | + | |
| 117 | + expect(failure._tag).toBe("Left"); | |
| 118 | + if (failure._tag === "Left") { | |
| 119 | + expect(failure.left).toBeInstanceOf(Error); | |
| 120 | + if (failure.left instanceof Error) { | |
| 121 | + expect(failure.left.message).toContain("Input validation failed"); | |
| 122 | + } | |
| 123 | + } | |
| 124 | + }), | |
| 125 | + ); | |
| 126 | + | |
| 82 | 127 | it.effect("hydrates dynamic discover results via search + directory", () => |
| 83 | 128 | Effect.gen(function* () { |
| 84 | 129 | const descriptors: Record<string, ToolDescriptor> = { |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | export * from "./types"; |
| 2 | +export * from "./json-schema"; | |
| 2 | 3 | export * from "./tool-map"; |
| 3 | 4 | export * from "./discovery"; |
| 4 | 5 | export * from "./system-tools"; |
| @@ -0,0 +1,73 @@ | ||
| 1 | +import type { ErrorObject } from "ajv"; | |
| 2 | +import Ajv2020 from "ajv/dist/2020"; | |
| 3 | + | |
| 4 | +import type { StandardSchema } from "./types"; | |
| 5 | +import { unknownInputSchema } from "./types"; | |
| 6 | + | |
| 7 | +const ajv = new Ajv2020({ | |
| 8 | + allErrors: true, | |
| 9 | + strict: false, | |
| 10 | + validateSchema: false, | |
| 11 | + allowUnionTypes: true, | |
| 12 | +}); | |
| 13 | + | |
| 14 | +const decodePointerSegment = (segment: string): PropertyKey => { | |
| 15 | + const decoded = segment.replaceAll("~1", "/").replaceAll("~0", "~"); | |
| 16 | + return /^\d+$/.test(decoded) ? Number(decoded) : decoded; | |
| 17 | +}; | |
| 18 | + | |
| 19 | +const pointerToPath = (pointer: string | undefined): ReadonlyArray<PropertyKey> | undefined => { | |
| 20 | + if (!pointer || pointer.length === 0 || pointer === "/") { | |
| 21 | + return undefined; | |
| 22 | + } | |
| 23 | + | |
| 24 | + return pointer | |
| 25 | + .split("/") | |
| 26 | + .slice(1) | |
| 27 | + .filter((segment) => segment.length > 0) | |
| 28 | + .map(decodePointerSegment); | |
| 29 | +}; | |
| 30 | + | |
| 31 | +const toIssueMessage = (error: ErrorObject): string => { | |
| 32 | + const keyword = error.keyword.trim(); | |
| 33 | + const message = (error.message ?? "Invalid value").trim(); | |
| 34 | + return keyword.length > 0 ? `${keyword}: ${message}` : message; | |
| 35 | +}; | |
| 36 | + | |
| 37 | +export const standardSchemaFromJsonSchema = ( | |
| 38 | + schema: unknown, | |
| 39 | + options?: { | |
| 40 | + vendor?: string; | |
| 41 | + fallback?: StandardSchema; | |
| 42 | + }, | |
| 43 | +): StandardSchema => { | |
| 44 | + try { | |
| 45 | + const validate = ajv.compile(schema as Record<string, unknown>); | |
| 46 | + | |
| 47 | + return { | |
| 48 | + "~standard": { | |
| 49 | + version: 1, | |
| 50 | + vendor: options?.vendor ?? "json-schema", | |
| 51 | + validate: (value: unknown) => { | |
| 52 | + const valid = validate(value); | |
| 53 | + if (valid) { | |
| 54 | + return { value }; | |
| 55 | + } | |
| 56 | + | |
| 57 | + const issues = (validate.errors ?? []).map((error) => ({ | |
| 58 | + message: toIssueMessage(error), | |
| 59 | + path: pointerToPath(error.instancePath), | |
| 60 | + })); | |
| 61 | + | |
| 62 | + return { | |
| 63 | + issues: issues.length > 0 | |
| 64 | + ? issues | |
| 65 | + : [{ message: "Invalid value" }], | |
| 66 | + }; | |
| 67 | + }, | |
| 68 | + }, | |
| 69 | + }; | |
| 70 | + } catch { | |
| 71 | + return options?.fallback ?? unknownInputSchema; | |
| 72 | + } | |
| 73 | +}; | |
| @@ -76,7 +76,9 @@ const parseInput = (input: { · 75 unmodified lines | ||
| 76 | 76 | }): Effect.Effect<unknown, Error> => { |
| 77 | 77 | const validate = getSchemaValidator(input.schema); |
| 78 | 78 | if (!validate) { |
| 79 | − return Effect.succeed(input.value); | |
| 79 | + return Effect.fail( | |
| 80 | + new Error(`Tool ${input.path} has no Standard Schema validator on inputSchema`), | |
| 81 | + ); | |
| 80 | 82 | } |
| 81 | 83 | |
| 82 | 84 | return Effect.tryPromise({ |
| @@ -18,12 +18,23 @@ export type ToolMetadata = { · 17 unmodified lines | ||
| 18 | 18 | |
| 19 | 19 | export type ExecutableTool = { |
| 20 | 20 | description?: string; |
| 21 | − inputSchema?: StandardSchema; | |
| 21 | + inputSchema: StandardSchema; | |
| 22 | 22 | outputSchema?: StandardSchema; |
| 23 | 23 | parameters?: StandardSchema; |
| 24 | 24 | execute: (...args: any[]) => unknown; |
| 25 | 25 | }; |
| 26 | 26 | |
| 27 | +export const unknownInputSchema: StandardSchema = { | |
| 28 | + "~standard": { | |
| 29 | + version: 1, | |
| 30 | + vendor: "@executor-v3/codemode-core", | |
| 31 | + validate: (value: unknown) => ({ | |
| 32 | + value, | |
| 33 | + }), | |
| 34 | + }, | |
| 35 | +}; | |
| 36 | + | |
| 37 | + | |
| 27 | 38 | export type ToolDefinition = { |
| 28 | 39 | tool: ExecutableTool; |
| 29 | 40 | metadata?: ToolMetadata; |
| @@ -1,7 +1,12 @@ | ||
| 1 | 1 | import * as Data from "effect/Data"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | |
| 4 | −import { toTool, type ToolMap } from "@executor-v3/codemode-core"; | |
| 4 | +import { | |
| 5 | + standardSchemaFromJsonSchema, | |
| 6 | + toTool, | |
| 7 | + type ToolMap, | |
| 8 | + unknownInputSchema, | |
| 9 | +} from "@executor-v3/codemode-core"; | |
| 5 | 10 | |
| 6 | 11 | export type McpClientLike = { |
| 7 | 12 | listTools: () => Promise<unknown>; |
| @@ -77,6 +82,22 @@ const stringifyJson = (value: unknown): string | undefined => { · 69 unmodified lines | ||
| 77 | 82 | } |
| 78 | 83 | }; |
| 79 | 84 | |
| 85 | +const inputSchemaFromManifest = (inputSchemaJson: string | undefined) => { | |
| 86 | + if (!inputSchemaJson) { | |
| 87 | + return unknownInputSchema; | |
| 88 | + } | |
| 89 | + | |
| 90 | + try { | |
| 91 | + return standardSchemaFromJsonSchema(JSON.parse(inputSchemaJson), { | |
| 92 | + vendor: "mcp", | |
| 93 | + fallback: unknownInputSchema, | |
| 94 | + }); | |
| 95 | + } catch { | |
| 96 | + return unknownInputSchema; | |
| 97 | + } | |
| 98 | +}; | |
| 99 | + | |
| 100 | + | |
| 80 | 101 | const readListedTools = (value: unknown): Array<Record<string, unknown>> => { |
| 81 | 102 | const root = toRecord(value); |
| 82 | 103 | const tools = root.tools; |
| @@ -167,6 +188,7 @@ export const createMcpToolsFromManifest = (input: { · 84 unmodified lines | ||
| 167 | 188 | toTool({ |
| 168 | 189 | tool: { |
| 169 | 190 | description: entry.description ?? `MCP tool: ${entry.toolName}`, |
| 191 | + inputSchema: inputSchemaFromManifest(entry.inputSchemaJson), | |
| 170 | 192 | execute: async (args: unknown) => |
| 171 | 193 | withConnection(input.connect, async (connection) => { |
| 172 | 194 | const payloadArgs = toRecord(args); |
| @@ -1,7 +1,12 @@ | ||
| 1 | 1 | import * as Data from "effect/Data"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | |
| 4 | −import { toTool, type ToolMap } from "@executor-v3/codemode-core"; | |
| 4 | +import { | |
| 5 | + standardSchemaFromJsonSchema, | |
| 6 | + toTool, | |
| 7 | + type ToolMap, | |
| 8 | + unknownInputSchema, | |
| 9 | +} from "@executor-v3/codemode-core"; | |
| 5 | 10 | |
| 6 | 11 | import { |
| 7 | 12 | extractOpenApiManifest, |
| @@ -179,6 +184,22 @@ const decodeFetchResponseBody = async (response: Response): Promise<unknown> => · 171 unmodified lines | ||
| 179 | 184 | return response.text(); |
| 180 | 185 | }; |
| 181 | 186 | |
| 187 | +const inputSchemaFromTypingJson = (inputSchemaJson: string | undefined) => { | |
| 188 | + if (!inputSchemaJson) { | |
| 189 | + return unknownInputSchema; | |
| 190 | + } | |
| 191 | + | |
| 192 | + try { | |
| 193 | + return standardSchemaFromJsonSchema(JSON.parse(inputSchemaJson), { | |
| 194 | + vendor: "openapi", | |
| 195 | + fallback: unknownInputSchema, | |
| 196 | + }); | |
| 197 | + } catch { | |
| 198 | + return unknownInputSchema; | |
| 199 | + } | |
| 200 | +}; | |
| 201 | + | |
| 202 | + | |
| 182 | 203 | type CreateOpenApiToolsFromManifestInput = { |
| 183 | 204 | manifest: OpenApiToolManifest; |
| 184 | 205 | baseUrl: string; |
| @@ -297,6 +318,7 @@ export const createOpenApiToolsFromManifest = ( · 112 unmodified lines | ||
| 297 | 318 | result[toolPath] = toTool({ |
| 298 | 319 | tool: { |
| 299 | 320 | description, |
| 321 | + inputSchema: inputSchemaFromTypingJson(extracted.typing?.inputSchemaJson), | |
| 300 | 322 | execute: async (args: unknown) => { |
| 301 | 323 | const decodedArgs = asToolArgs(args); |
| 302 | 324 | const request = buildFetchRequest({ |