History

schema

Rhys Sullivan committed 8 months ago

Commit ef83afa1 parent bc109b49 files · +182 −4Browse files

Unified · Split

9 files
  1. bun.lock +1 −0
  2. packages/codemode-core/package.json +1 −0
  3. packages/codemode-core/src/index.test.ts +45 −0
  4. packages/codemode-core/src/index.ts +1 −0
  5. packages/codemode-core/src/json-schema.ts +73 −0
  6. packages/codemode-core/src/tool-map.ts +3 −1
  7. packages/codemode-core/src/types.ts +12 −1
  8. packages/codemode-mcp/src/mcp-tools.ts +23 −1
  9. packages/codemode-openapi/src/openapi-tools.ts +23 −1
bun.lock+1−0
@@ -26,6 +26,7 @@ · 25 unmodified lines
2626 "name": "@executor-v3/codemode-core",
2727 "dependencies": {
2828 "@standard-schema/spec": "^1.0.0",
29+ "ajv": "^8.17.1",
2930 "effect": "^3.19.19",
3031 },
3132 "devDependencies": {
packages/codemode-core/package.json+1−0
@@ -12,6 +12,7 @@ · 11 unmodified lines
1212 },
1313 "dependencies": {
1414 "@standard-schema/spec": "^1.0.0",
15+ "ajv": "^8.17.1",
1516 "effect": "^3.19.19"
1617 },
1718 "devDependencies": {
packages/codemode-core/src/index.test.ts+45−0
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 executeCodeWithTools,
1010 makeToolInvokerFromTools,
1111 mergeToolMaps,
12+ standardSchemaFromJsonSchema,
1213 toTool,
1314 type CodeExecutor,
1415 type ToolDescriptor,
@@ -79,6 +80,50 @@ describe("codemode-core", () => { · 64 unmodified lines
7980 }),
8081 );
8182
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+
82127 it.effect("hydrates dynamic discover results via search + directory", () =>
83128 Effect.gen(function* () {
84129 const descriptors: Record<string, ToolDescriptor> = {
packages/codemode-core/src/index.ts+1−0
@@ -1,4 +1,5 @@
11 export * from "./types";
2+export * from "./json-schema";
23 export * from "./tool-map";
34 export * from "./discovery";
45 export * from "./system-tools";
packages/codemode-core/src/json-schema.ts+73−0
@@ -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+};
packages/codemode-core/src/tool-map.ts+3−1
@@ -76,7 +76,9 @@ const parseInput = (input: { · 75 unmodified lines
7676 }): Effect.Effect<unknown, Error> => {
7777 const validate = getSchemaValidator(input.schema);
7878 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+ );
8082 }
8183
8284 return Effect.tryPromise({
packages/codemode-core/src/types.ts+12−1
@@ -18,12 +18,23 @@ export type ToolMetadata = { · 17 unmodified lines
1818
1919 export type ExecutableTool = {
2020 description?: string;
21− inputSchema?: StandardSchema;
21+ inputSchema: StandardSchema;
2222 outputSchema?: StandardSchema;
2323 parameters?: StandardSchema;
2424 execute: (...args: any[]) => unknown;
2525 };
2626
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+
2738 export type ToolDefinition = {
2839 tool: ExecutableTool;
2940 metadata?: ToolMetadata;
packages/codemode-mcp/src/mcp-tools.ts+23−1
@@ -1,7 +1,12 @@
11 import * as Data from "effect/Data";
22 import * as Effect from "effect/Effect";
33
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";
510
611 export type McpClientLike = {
712 listTools: () => Promise<unknown>;
@@ -77,6 +82,22 @@ const stringifyJson = (value: unknown): string | undefined => { · 69 unmodified lines
7782 }
7883 };
7984
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+
80101 const readListedTools = (value: unknown): Array<Record<string, unknown>> => {
81102 const root = toRecord(value);
82103 const tools = root.tools;
@@ -167,6 +188,7 @@ export const createMcpToolsFromManifest = (input: { · 84 unmodified lines
167188 toTool({
168189 tool: {
169190 description: entry.description ?? `MCP tool: ${entry.toolName}`,
191+ inputSchema: inputSchemaFromManifest(entry.inputSchemaJson),
170192 execute: async (args: unknown) =>
171193 withConnection(input.connect, async (connection) => {
172194 const payloadArgs = toRecord(args);
packages/codemode-openapi/src/openapi-tools.ts+23−1
@@ -1,7 +1,12 @@
11 import * as Data from "effect/Data";
22 import * as Effect from "effect/Effect";
33
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";
510
611 import {
712 extractOpenApiManifest,
@@ -179,6 +184,22 @@ const decodeFetchResponseBody = async (response: Response): Promise<unknown> => · 171 unmodified lines
179184 return response.text();
180185 };
181186
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+
182203 type CreateOpenApiToolsFromManifestInput = {
183204 manifest: OpenApiToolManifest;
184205 baseUrl: string;
@@ -297,6 +318,7 @@ export const createOpenApiToolsFromManifest = ( · 112 unmodified lines
297318 result[toolPath] = toTool({
298319 tool: {
299320 description,
321+ inputSchema: inputSchemaFromTypingJson(extracted.typing?.inputSchemaJson),
300322 execute: async (args: unknown) => {
301323 const decodedArgs = asToolArgs(args);
302324 const request = buildFetchRequest({