Unified · Split
| @@ -26,6 +26,7 @@ · 25 unmodified lines | |||
| 26 | "name": "@executor-v3/codemode-core", | 26 | "name": "@executor-v3/codemode-core", |
| 27 | "dependencies": { | 27 | "dependencies": { |
| 28 | "@standard-schema/spec": "^1.0.0", | 28 | "@standard-schema/spec": "^1.0.0", |
| 29 | "ajv": "^8.17.1", | ||
| 29 | "effect": "^3.19.19", | 30 | "effect": "^3.19.19", |
| 30 | }, | 31 | }, |
| 31 | "devDependencies": { | 32 | "devDependencies": { |
| @@ -12,6 +12,7 @@ · 11 unmodified lines | |||
| 12 | }, | 12 | }, |
| 13 | "dependencies": { | 13 | "dependencies": { |
| 14 | "@standard-schema/spec": "^1.0.0", | 14 | "@standard-schema/spec": "^1.0.0", |
| 15 | "ajv": "^8.17.1", | ||
| 15 | "effect": "^3.19.19" | 16 | "effect": "^3.19.19" |
| 16 | }, | 17 | }, |
| 17 | "devDependencies": { | 18 | "devDependencies": { |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | executeCodeWithTools, | 9 | executeCodeWithTools, |
| 10 | makeToolInvokerFromTools, | 10 | makeToolInvokerFromTools, |
| 11 | mergeToolMaps, | 11 | mergeToolMaps, |
| 12 | standardSchemaFromJsonSchema, | ||
| 12 | toTool, | 13 | toTool, |
| 13 | type CodeExecutor, | 14 | type CodeExecutor, |
| 14 | type ToolDescriptor, | 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 | it.effect("hydrates dynamic discover results via search + directory", () => | 127 | it.effect("hydrates dynamic discover results via search + directory", () => |
| 83 | Effect.gen(function* () { | 128 | Effect.gen(function* () { |
| 84 | const descriptors: Record<string, ToolDescriptor> = { | 129 | const descriptors: Record<string, ToolDescriptor> = { |
| @@ -1,4 +1,5 @@ | |||
| 1 | export * from "./types"; | 1 | export * from "./types"; |
| 2 | export * from "./json-schema"; | ||
| 2 | export * from "./tool-map"; | 3 | export * from "./tool-map"; |
| 3 | export * from "./discovery"; | 4 | export * from "./discovery"; |
| 4 | export * from "./system-tools"; | 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 | }): Effect.Effect<unknown, Error> => { | 76 | }): Effect.Effect<unknown, Error> => { |
| 77 | const validate = getSchemaValidator(input.schema); | 77 | const validate = getSchemaValidator(input.schema); |
| 78 | if (!validate) { | 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 | return Effect.tryPromise({ | 84 | return Effect.tryPromise({ |
| @@ -18,12 +18,23 @@ export type ToolMetadata = { · 17 unmodified lines | |||
| 18 | 18 | ||
| 19 | export type ExecutableTool = { | 19 | export type ExecutableTool = { |
| 20 | description?: string; | 20 | description?: string; |
| 21 | inputSchema?: StandardSchema; | 21 | inputSchema: StandardSchema; |
| 22 | outputSchema?: StandardSchema; | 22 | outputSchema?: StandardSchema; |
| 23 | parameters?: StandardSchema; | 23 | parameters?: StandardSchema; |
| 24 | execute: (...args: any[]) => unknown; | 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 | export type ToolDefinition = { | 38 | export type ToolDefinition = { |
| 28 | tool: ExecutableTool; | 39 | tool: ExecutableTool; |
| 29 | metadata?: ToolMetadata; | 40 | metadata?: ToolMetadata; |
| @@ -1,7 +1,12 @@ | |||
| 1 | import * as Data from "effect/Data"; | 1 | import * as Data from "effect/Data"; |
| 2 | import * as Effect from "effect/Effect"; | 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 | export type McpClientLike = { | 11 | export type McpClientLike = { |
| 7 | listTools: () => Promise<unknown>; | 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 | const readListedTools = (value: unknown): Array<Record<string, unknown>> => { | 101 | const readListedTools = (value: unknown): Array<Record<string, unknown>> => { |
| 81 | const root = toRecord(value); | 102 | const root = toRecord(value); |
| 82 | const tools = root.tools; | 103 | const tools = root.tools; |
| @@ -167,6 +188,7 @@ export const createMcpToolsFromManifest = (input: { · 84 unmodified lines | |||
| 167 | toTool({ | 188 | toTool({ |
| 168 | tool: { | 189 | tool: { |
| 169 | description: entry.description ?? `MCP tool: ${entry.toolName}`, | 190 | description: entry.description ?? `MCP tool: ${entry.toolName}`, |
| 191 | inputSchema: inputSchemaFromManifest(entry.inputSchemaJson), | ||
| 170 | execute: async (args: unknown) => | 192 | execute: async (args: unknown) => |
| 171 | withConnection(input.connect, async (connection) => { | 193 | withConnection(input.connect, async (connection) => { |
| 172 | const payloadArgs = toRecord(args); | 194 | const payloadArgs = toRecord(args); |
| @@ -1,7 +1,12 @@ | |||
| 1 | import * as Data from "effect/Data"; | 1 | import * as Data from "effect/Data"; |
| 2 | import * as Effect from "effect/Effect"; | 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 | import { | 11 | import { |
| 7 | extractOpenApiManifest, | 12 | extractOpenApiManifest, |
| @@ -179,6 +184,22 @@ const decodeFetchResponseBody = async (response: Response): Promise<unknown> => · 171 unmodified lines | |||
| 179 | return response.text(); | 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 | type CreateOpenApiToolsFromManifestInput = { | 203 | type CreateOpenApiToolsFromManifestInput = { |
| 183 | manifest: OpenApiToolManifest; | 204 | manifest: OpenApiToolManifest; |
| 184 | baseUrl: string; | 205 | baseUrl: string; |
| @@ -297,6 +318,7 @@ export const createOpenApiToolsFromManifest = ( · 112 unmodified lines | |||
| 297 | result[toolPath] = toTool({ | 318 | result[toolPath] = toTool({ |
| 298 | tool: { | 319 | tool: { |
| 299 | description, | 320 | description, |
| 321 | inputSchema: inputSchemaFromTypingJson(extracted.typing?.inputSchemaJson), | ||
| 300 | execute: async (args: unknown) => { | 322 | execute: async (args: unknown) => { |
| 301 | const decodedArgs = asToolArgs(args); | 323 | const decodedArgs = asToolArgs(args); |
| 302 | const request = buildFetchRequest({ | 324 | const request = buildFetchRequest({ |