@spec2tools/core 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/dist/ai-tools.d.ts +13 -0
- package/dist/ai-tools.js +22 -0
- package/dist/code-mode.d.ts +12 -0
- package/dist/code-mode.js +233 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/openapi-parser.js +68 -22
- package/dist/tool-executor.d.ts +2 -1
- package/dist/tool-executor.js +42 -26
- package/dist/types.d.ts +9 -0
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -64,6 +64,39 @@ const tools = createExecutableTools(toolDefs, baseUrl, authManager);
|
|
|
64
64
|
const result = await executeToolByName(tools, 'getUser', { id: '123' });
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
+
### AI SDK Tools
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { toAISDKTools } from '@spec2tools/core';
|
|
71
|
+
|
|
72
|
+
// Convert core tools to AI SDK-compatible ToolSet
|
|
73
|
+
const aiTools = toAISDKTools(tools);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Code Mode
|
|
77
|
+
|
|
78
|
+
Collapse any AI SDK `ToolSet` into 2 tools (`search` + `execute`). The model discovers endpoints via keyword search and calls them by writing Python code, executed in a sandboxed [Monty](https://github.com/pydantic/monty) interpreter.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { toCodeModeTools } from '@spec2tools/core';
|
|
82
|
+
|
|
83
|
+
// Convert N tools into 2 code-mode tools
|
|
84
|
+
const codeModeTools = toCodeModeTools(aiTools);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
This also works with tools from any MCP server via the AI SDK:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { createMCPClient } from 'ai';
|
|
91
|
+
import { toCodeModeTools } from '@spec2tools/core';
|
|
92
|
+
|
|
93
|
+
const client = await createMCPClient({
|
|
94
|
+
transport: { type: 'sse', url: 'http://localhost:3000/sse' },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const tools = toCodeModeTools(await client.tools());
|
|
98
|
+
```
|
|
99
|
+
|
|
67
100
|
### Error Classes
|
|
68
101
|
|
|
69
102
|
```ts
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { generateText } from 'ai';
|
|
2
|
+
import type { Tool } from './types.js';
|
|
3
|
+
type ToolSet = NonNullable<Parameters<typeof generateText>[0]['tools']>;
|
|
4
|
+
/**
|
|
5
|
+
* Convert core Tool[] into AI SDK ToolSet.
|
|
6
|
+
*
|
|
7
|
+
* Wraps each core tool with the `ai` package's `tool()` helper,
|
|
8
|
+
* producing a `Record<string, CoreTool>` compatible with `generateText`,
|
|
9
|
+
* `streamText`, etc.
|
|
10
|
+
*/
|
|
11
|
+
export declare function toAISDKTools(tools: Tool[]): ToolSet;
|
|
12
|
+
export type { ToolSet };
|
|
13
|
+
//# sourceMappingURL=ai-tools.d.ts.map
|
package/dist/ai-tools.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { tool } from 'ai';
|
|
2
|
+
/**
|
|
3
|
+
* Convert core Tool[] into AI SDK ToolSet.
|
|
4
|
+
*
|
|
5
|
+
* Wraps each core tool with the `ai` package's `tool()` helper,
|
|
6
|
+
* producing a `Record<string, CoreTool>` compatible with `generateText`,
|
|
7
|
+
* `streamText`, etc.
|
|
8
|
+
*/
|
|
9
|
+
export function toAISDKTools(tools) {
|
|
10
|
+
const aiTools = {};
|
|
11
|
+
for (const t of tools) {
|
|
12
|
+
aiTools[t.name] = tool({
|
|
13
|
+
description: t.description,
|
|
14
|
+
inputSchema: t.parameters,
|
|
15
|
+
execute: async (params) => {
|
|
16
|
+
return t.execute(params);
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return aiTools;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=ai-tools.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { generateText } from 'ai';
|
|
2
|
+
type ToolSet = NonNullable<Parameters<typeof generateText>[0]['tools']>;
|
|
3
|
+
/**
|
|
4
|
+
* Transform an AI SDK ToolSet into exactly 2 code-mode tools: `search` and `execute`.
|
|
5
|
+
*
|
|
6
|
+
* The model discovers endpoints via keyword search and calls them by writing
|
|
7
|
+
* Python code that is executed in a sandboxed Monty interpreter. This reduces
|
|
8
|
+
* token consumption by ~99.9% for large APIs (N tools → 2 tools).
|
|
9
|
+
*/
|
|
10
|
+
export declare function toCodeModeTools(tools: ToolSet): ToolSet;
|
|
11
|
+
export {};
|
|
12
|
+
//# sourceMappingURL=code-mode.d.ts.map
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { tool } from 'ai';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
4
|
+
import { Monty, MontySnapshot, MontyRuntimeError, MontySyntaxError, MontyTypingError, } from '@pydantic/monty';
|
|
5
|
+
/**
|
|
6
|
+
* Convert a tool name into a valid Python identifier.
|
|
7
|
+
* Replaces hyphens, dots, spaces, etc. with underscores and
|
|
8
|
+
* prefixes with `_` if the name starts with a digit.
|
|
9
|
+
*/
|
|
10
|
+
function toPythonName(name) {
|
|
11
|
+
let py = name.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
12
|
+
if (/^[0-9]/.test(py))
|
|
13
|
+
py = `_${py}`;
|
|
14
|
+
return py || '_unnamed';
|
|
15
|
+
}
|
|
16
|
+
function jsonSchemaTypeToPython(schema) {
|
|
17
|
+
if (!schema)
|
|
18
|
+
return 'any';
|
|
19
|
+
const type = schema.type;
|
|
20
|
+
if (type === 'string')
|
|
21
|
+
return 'str';
|
|
22
|
+
if (type === 'integer')
|
|
23
|
+
return 'int';
|
|
24
|
+
if (type === 'number')
|
|
25
|
+
return 'float';
|
|
26
|
+
if (type === 'boolean')
|
|
27
|
+
return 'bool';
|
|
28
|
+
if (type === 'array')
|
|
29
|
+
return 'list';
|
|
30
|
+
if (type === 'object')
|
|
31
|
+
return 'dict';
|
|
32
|
+
if (schema.enum)
|
|
33
|
+
return 'str';
|
|
34
|
+
return 'any';
|
|
35
|
+
}
|
|
36
|
+
function buildIndex(tools) {
|
|
37
|
+
const index = [];
|
|
38
|
+
for (const [name, t] of Object.entries(tools)) {
|
|
39
|
+
const pyName = toPythonName(name);
|
|
40
|
+
const description = 'description' in t ? (t.description || '') : '';
|
|
41
|
+
const params = [];
|
|
42
|
+
try {
|
|
43
|
+
// AI SDK tool() stores the schema as inputSchema (raw Zod)
|
|
44
|
+
// but other wrappers may use parameters
|
|
45
|
+
const schema = ('inputSchema' in t ? t.inputSchema : null) ??
|
|
46
|
+
('parameters' in t ? t.parameters : null);
|
|
47
|
+
if (schema && typeof schema === 'object') {
|
|
48
|
+
const schemaObj = schema;
|
|
49
|
+
let jsonSchema = null;
|
|
50
|
+
// AI SDK Schema wrapper — has a jsonSchema property
|
|
51
|
+
if ('jsonSchema' in schemaObj && schemaObj.jsonSchema) {
|
|
52
|
+
jsonSchema = schemaObj.jsonSchema;
|
|
53
|
+
}
|
|
54
|
+
// Raw Zod schema — has _def property
|
|
55
|
+
else if ('_def' in schemaObj) {
|
|
56
|
+
jsonSchema = zodToJsonSchema(schema, {
|
|
57
|
+
target: 'jsonSchema7',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (jsonSchema) {
|
|
61
|
+
const properties = jsonSchema.properties || {};
|
|
62
|
+
const required = new Set(jsonSchema.required || []);
|
|
63
|
+
for (const [pName, pSchema] of Object.entries(properties)) {
|
|
64
|
+
params.push({
|
|
65
|
+
name: pName,
|
|
66
|
+
type: jsonSchemaTypeToPython(pSchema),
|
|
67
|
+
required: required.has(pName),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Cannot introspect schema; proceed with empty params
|
|
75
|
+
}
|
|
76
|
+
const paramStrs = params.map((p) => {
|
|
77
|
+
if (p.required)
|
|
78
|
+
return `${p.name}: ${p.type}`;
|
|
79
|
+
return `${p.name}: ${p.type} = None`;
|
|
80
|
+
});
|
|
81
|
+
const signature = `${pyName}(${paramStrs.join(', ')})`;
|
|
82
|
+
const exampleParams = params
|
|
83
|
+
.filter((p) => p.required)
|
|
84
|
+
.slice(0, 2)
|
|
85
|
+
.map((p) => {
|
|
86
|
+
if (p.type === 'str')
|
|
87
|
+
return `${p.name}="..."`;
|
|
88
|
+
if (p.type === 'int' || p.type === 'float')
|
|
89
|
+
return `${p.name}=1`;
|
|
90
|
+
if (p.type === 'bool')
|
|
91
|
+
return `${p.name}=True`;
|
|
92
|
+
return `${p.name}=...`;
|
|
93
|
+
});
|
|
94
|
+
const example = `${pyName}(${exampleParams.join(', ')})`;
|
|
95
|
+
index.push({
|
|
96
|
+
originalName: name,
|
|
97
|
+
pyName,
|
|
98
|
+
description,
|
|
99
|
+
signature,
|
|
100
|
+
example,
|
|
101
|
+
searchText: `${name} ${pyName} ${description}`.toLowerCase(),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return index;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Recursively convert Monty's Map objects (Python dicts) to plain JS objects
|
|
108
|
+
* so they JSON.stringify correctly.
|
|
109
|
+
*/
|
|
110
|
+
function montyToJson(value) {
|
|
111
|
+
if (value instanceof Map) {
|
|
112
|
+
const obj = {};
|
|
113
|
+
for (const [k, v] of value) {
|
|
114
|
+
obj[String(k)] = montyToJson(v);
|
|
115
|
+
}
|
|
116
|
+
return obj;
|
|
117
|
+
}
|
|
118
|
+
if (Array.isArray(value)) {
|
|
119
|
+
return value.map(montyToJson);
|
|
120
|
+
}
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Transform an AI SDK ToolSet into exactly 2 code-mode tools: `search` and `execute`.
|
|
125
|
+
*
|
|
126
|
+
* The model discovers endpoints via keyword search and calls them by writing
|
|
127
|
+
* Python code that is executed in a sandboxed Monty interpreter. This reduces
|
|
128
|
+
* token consumption by ~99.9% for large APIs (N tools → 2 tools).
|
|
129
|
+
*/
|
|
130
|
+
export function toCodeModeTools(tools) {
|
|
131
|
+
const index = buildIndex(tools);
|
|
132
|
+
// Build a mapping from Python-safe names to original tool names
|
|
133
|
+
const pyToOriginal = new Map();
|
|
134
|
+
for (const entry of index) {
|
|
135
|
+
pyToOriginal.set(entry.pyName, entry.originalName);
|
|
136
|
+
}
|
|
137
|
+
const pythonNames = [...pyToOriginal.keys()];
|
|
138
|
+
const searchTool = tool({
|
|
139
|
+
description: 'Search for available API endpoints by keyword. Returns matching ' +
|
|
140
|
+
'function signatures with parameter types and usage examples. ' +
|
|
141
|
+
'Use this to discover what functions are available before writing code.',
|
|
142
|
+
inputSchema: z.object({
|
|
143
|
+
query: z
|
|
144
|
+
.string()
|
|
145
|
+
.describe('Keywords to search for in endpoint names and descriptions'),
|
|
146
|
+
}),
|
|
147
|
+
execute: async ({ query }) => {
|
|
148
|
+
const keywords = query
|
|
149
|
+
.toLowerCase()
|
|
150
|
+
.split(/\s+/)
|
|
151
|
+
.filter(Boolean);
|
|
152
|
+
const matches = index.filter((entry) => keywords.some((kw) => entry.searchText.includes(kw)));
|
|
153
|
+
if (matches.length === 0) {
|
|
154
|
+
return `No endpoints found matching "${query}". Try different keywords.`;
|
|
155
|
+
}
|
|
156
|
+
return matches
|
|
157
|
+
.map((m) => `${m.signature}\n ${m.description}\n Example: ${m.example}`)
|
|
158
|
+
.join('\n\n');
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
const executeTool = tool({
|
|
162
|
+
description: 'Execute Python code that calls API endpoints. Functions discovered ' +
|
|
163
|
+
'via search are available to call directly. Always use keyword arguments.\n' +
|
|
164
|
+
'Example:\n' +
|
|
165
|
+
' users = listUsers(limit=10)\n' +
|
|
166
|
+
' user = getUser(id="123")\n' +
|
|
167
|
+
' users',
|
|
168
|
+
inputSchema: z.object({
|
|
169
|
+
code: z
|
|
170
|
+
.string()
|
|
171
|
+
.describe('Python code to execute. Use keyword arguments when calling API functions.'),
|
|
172
|
+
}),
|
|
173
|
+
execute: async ({ code }) => {
|
|
174
|
+
try {
|
|
175
|
+
const m = new Monty(code, { externalFunctions: pythonNames });
|
|
176
|
+
let progress = m.start({
|
|
177
|
+
limits: { maxDurationSecs: 30 },
|
|
178
|
+
});
|
|
179
|
+
while (progress instanceof MontySnapshot) {
|
|
180
|
+
const snapshot = progress;
|
|
181
|
+
const originalName = pyToOriginal.get(snapshot.functionName);
|
|
182
|
+
const toolEntry = originalName ? tools[originalName] : undefined;
|
|
183
|
+
if (!toolEntry ||
|
|
184
|
+
!('execute' in toolEntry) ||
|
|
185
|
+
!toolEntry.execute) {
|
|
186
|
+
// Should not happen since all tool names are registered,
|
|
187
|
+
// but handle gracefully
|
|
188
|
+
progress = snapshot.resume({
|
|
189
|
+
exception: {
|
|
190
|
+
type: 'ValueError',
|
|
191
|
+
message: `Unknown function: ${snapshot.functionName}`,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const params = {
|
|
197
|
+
...snapshot.kwargs,
|
|
198
|
+
};
|
|
199
|
+
try {
|
|
200
|
+
const result = await toolEntry.execute(params, {});
|
|
201
|
+
progress = snapshot.resume({ returnValue: result });
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
progress = snapshot.resume({
|
|
205
|
+
exception: {
|
|
206
|
+
type: 'RuntimeError',
|
|
207
|
+
message: error instanceof Error ? error.message : String(error),
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return montyToJson(progress.output);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
if (error instanceof MontySyntaxError) {
|
|
216
|
+
return `Syntax Error: ${error.message}`;
|
|
217
|
+
}
|
|
218
|
+
if (error instanceof MontyRuntimeError) {
|
|
219
|
+
return `Runtime Error: ${error.message}\n${error.traceback()}`;
|
|
220
|
+
}
|
|
221
|
+
if (error instanceof MontyTypingError) {
|
|
222
|
+
return `Type Error: ${error.displayDiagnostics()}`;
|
|
223
|
+
}
|
|
224
|
+
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
return {
|
|
229
|
+
search: searchTool,
|
|
230
|
+
execute: executeTool,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
//# sourceMappingURL=code-mode.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -3,4 +3,7 @@ export type { HttpMethod, Tool, AuthType, AuthConfig, Session, OpenAPISpec, Path
|
|
|
3
3
|
export { loadOpenAPISpec, extractBaseUrl, extractAuthConfig, extractOperationAuthConfig, parseOperations, formatToolSchema, formatToolSignature, } from './openapi-parser.js';
|
|
4
4
|
export { AuthManager } from './auth-manager.js';
|
|
5
5
|
export { createExecutableTools, executeToolByName } from './tool-executor.js';
|
|
6
|
+
export { toAISDKTools } from './ai-tools.js';
|
|
7
|
+
export type { ToolSet } from './ai-tools.js';
|
|
8
|
+
export { toCodeModeTools } from './code-mode.js';
|
|
6
9
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -6,4 +6,8 @@ export { loadOpenAPISpec, extractBaseUrl, extractAuthConfig, extractOperationAut
|
|
|
6
6
|
export { AuthManager } from './auth-manager.js';
|
|
7
7
|
// Tool Executor
|
|
8
8
|
export { createExecutableTools, executeToolByName } from './tool-executor.js';
|
|
9
|
+
// AI SDK Tools
|
|
10
|
+
export { toAISDKTools } from './ai-tools.js';
|
|
11
|
+
// Code Mode
|
|
12
|
+
export { toCodeModeTools } from './code-mode.js';
|
|
9
13
|
//# sourceMappingURL=index.js.map
|
package/dist/openapi-parser.js
CHANGED
|
@@ -109,10 +109,39 @@ function parseSecurityScheme(scheme, scopes) {
|
|
|
109
109
|
}
|
|
110
110
|
return { type: 'none' };
|
|
111
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Resolve a $ref to its actual schema object
|
|
114
|
+
*/
|
|
115
|
+
function resolveRef(ref, spec, visited = new Set()) {
|
|
116
|
+
// Detect circular references
|
|
117
|
+
if (visited.has(ref)) {
|
|
118
|
+
throw new UnsupportedSchemaError(ref, 'Circular $ref detected');
|
|
119
|
+
}
|
|
120
|
+
visited.add(ref);
|
|
121
|
+
// Only support #/components/schemas/ references
|
|
122
|
+
if (!ref.startsWith('#/components/schemas/')) {
|
|
123
|
+
throw new UnsupportedSchemaError(ref, 'Only #/components/schemas/ $refs are supported');
|
|
124
|
+
}
|
|
125
|
+
const schemaName = ref.replace('#/components/schemas/', '');
|
|
126
|
+
const schema = spec.components?.schemas?.[schemaName];
|
|
127
|
+
if (!schema) {
|
|
128
|
+
throw new UnsupportedSchemaError(ref, `Schema "${schemaName}" not found in components`);
|
|
129
|
+
}
|
|
130
|
+
// If the resolved schema has a $ref, resolve it recursively
|
|
131
|
+
if (schema.$ref) {
|
|
132
|
+
return resolveRef(schema.$ref, spec, visited);
|
|
133
|
+
}
|
|
134
|
+
return schema;
|
|
135
|
+
}
|
|
112
136
|
/**
|
|
113
137
|
* Convert an OpenAPI schema to a Zod schema
|
|
114
138
|
*/
|
|
115
|
-
function schemaToZod(schema, path, depth = 0) {
|
|
139
|
+
function schemaToZod(schema, path, spec, depth = 0, visited = new Set()) {
|
|
140
|
+
// Resolve $ref if present
|
|
141
|
+
if (schema.$ref) {
|
|
142
|
+
const resolvedSchema = resolveRef(schema.$ref, spec, new Set(visited));
|
|
143
|
+
return schemaToZod(resolvedSchema, path, spec, depth, visited);
|
|
144
|
+
}
|
|
116
145
|
// Check for unsupported features
|
|
117
146
|
if (schema.anyOf) {
|
|
118
147
|
throw new UnsupportedSchemaError(path, 'anyOf is not supported');
|
|
@@ -123,9 +152,6 @@ function schemaToZod(schema, path, depth = 0) {
|
|
|
123
152
|
if (schema.allOf) {
|
|
124
153
|
throw new UnsupportedSchemaError(path, 'allOf is not supported');
|
|
125
154
|
}
|
|
126
|
-
if (schema.$ref) {
|
|
127
|
-
throw new UnsupportedSchemaError(path, '$ref is not supported');
|
|
128
|
-
}
|
|
129
155
|
// Handle array types
|
|
130
156
|
if (schema.type === 'array') {
|
|
131
157
|
if (!schema.items) {
|
|
@@ -134,7 +160,7 @@ function schemaToZod(schema, path, depth = 0) {
|
|
|
134
160
|
if (schema.items.type === 'object') {
|
|
135
161
|
throw new UnsupportedSchemaError(path, 'Arrays of objects are not supported');
|
|
136
162
|
}
|
|
137
|
-
const itemSchema = schemaToZod(schema.items, `${path}.items`, depth);
|
|
163
|
+
const itemSchema = schemaToZod(schema.items, `${path}.items`, spec, depth, visited);
|
|
138
164
|
let arraySchema = z.array(itemSchema);
|
|
139
165
|
if (schema.description) {
|
|
140
166
|
arraySchema = arraySchema.describe(schema.description);
|
|
@@ -150,7 +176,7 @@ function schemaToZod(schema, path, depth = 0) {
|
|
|
150
176
|
const required = schema.required || [];
|
|
151
177
|
const shape = {};
|
|
152
178
|
for (const [propName, propSchema] of Object.entries(properties)) {
|
|
153
|
-
let zodProp = schemaToZod(propSchema, `${path}.${propName}`, depth + 1);
|
|
179
|
+
let zodProp = schemaToZod(propSchema, `${path}.${propName}`, spec, depth + 1, visited);
|
|
154
180
|
if (!required.includes(propName)) {
|
|
155
181
|
zodProp = zodProp.optional();
|
|
156
182
|
}
|
|
@@ -197,15 +223,17 @@ function schemaToZod(schema, path, depth = 0) {
|
|
|
197
223
|
/**
|
|
198
224
|
* Build parameters schema from path and query parameters
|
|
199
225
|
*/
|
|
200
|
-
function buildParametersSchema(parameters, operationId) {
|
|
226
|
+
function buildParametersSchema(parameters, operationId, spec) {
|
|
201
227
|
const shape = {};
|
|
228
|
+
const pathParams = new Set();
|
|
229
|
+
const queryParams = new Set();
|
|
202
230
|
for (const param of parameters) {
|
|
203
231
|
if (param.in !== 'path' && param.in !== 'query') {
|
|
204
232
|
continue; // Skip header and cookie parameters
|
|
205
233
|
}
|
|
206
234
|
let paramSchema;
|
|
207
235
|
if (param.schema) {
|
|
208
|
-
paramSchema = schemaToZod(param.schema, `${operationId}.parameters.${param.name}`, 0);
|
|
236
|
+
paramSchema = schemaToZod(param.schema, `${operationId}.parameters.${param.name}`, spec, 0);
|
|
209
237
|
}
|
|
210
238
|
else {
|
|
211
239
|
paramSchema = z.string();
|
|
@@ -217,45 +245,58 @@ function buildParametersSchema(parameters, operationId) {
|
|
|
217
245
|
paramSchema = paramSchema.optional();
|
|
218
246
|
}
|
|
219
247
|
shape[param.name] = paramSchema;
|
|
248
|
+
// Track which set this parameter belongs to
|
|
249
|
+
if (param.in === 'path') {
|
|
250
|
+
pathParams.add(param.name);
|
|
251
|
+
}
|
|
252
|
+
else if (param.in === 'query') {
|
|
253
|
+
queryParams.add(param.name);
|
|
254
|
+
}
|
|
220
255
|
}
|
|
221
|
-
return shape;
|
|
256
|
+
return { shape, pathParams, queryParams };
|
|
222
257
|
}
|
|
223
258
|
/**
|
|
224
259
|
* Build request body schema
|
|
225
260
|
*/
|
|
226
|
-
function buildRequestBodySchema(operation, operationId) {
|
|
261
|
+
function buildRequestBodySchema(operation, operationId, spec) {
|
|
262
|
+
const shape = {};
|
|
263
|
+
const bodyParams = new Set();
|
|
227
264
|
if (!operation.requestBody?.content) {
|
|
228
|
-
return {};
|
|
265
|
+
return { shape, bodyParams };
|
|
229
266
|
}
|
|
230
267
|
const jsonContent = operation.requestBody.content['application/json'];
|
|
231
268
|
if (!jsonContent?.schema) {
|
|
232
|
-
return {};
|
|
269
|
+
return { shape, bodyParams };
|
|
233
270
|
}
|
|
234
271
|
const schema = jsonContent.schema;
|
|
235
272
|
// Check for file uploads
|
|
236
273
|
if (schema.type === 'string' && schema.format === 'binary') {
|
|
237
274
|
throw new UnsupportedSchemaError(`${operationId}.requestBody`, 'File uploads are not supported');
|
|
238
275
|
}
|
|
276
|
+
// Resolve $ref if present
|
|
277
|
+
let resolvedSchema = schema;
|
|
278
|
+
if (schema.$ref) {
|
|
279
|
+
resolvedSchema = resolveRef(schema.$ref, spec);
|
|
280
|
+
}
|
|
239
281
|
// For object schemas, flatten properties into the parameter shape
|
|
240
|
-
if (
|
|
241
|
-
const properties =
|
|
242
|
-
const required =
|
|
243
|
-
const shape = {};
|
|
282
|
+
if (resolvedSchema.type === 'object' || resolvedSchema.properties) {
|
|
283
|
+
const properties = resolvedSchema.properties || {};
|
|
284
|
+
const required = resolvedSchema.required || [];
|
|
244
285
|
for (const [propName, propSchema] of Object.entries(properties)) {
|
|
245
286
|
// Check for file upload in properties
|
|
246
287
|
if (propSchema.type === 'string' &&
|
|
247
288
|
propSchema.format === 'binary') {
|
|
248
289
|
throw new UnsupportedSchemaError(`${operationId}.requestBody.${propName}`, 'File uploads are not supported');
|
|
249
290
|
}
|
|
250
|
-
let zodProp = schemaToZod(propSchema, `${operationId}.requestBody.${propName}`, 1);
|
|
291
|
+
let zodProp = schemaToZod(propSchema, `${operationId}.requestBody.${propName}`, spec, 1);
|
|
251
292
|
if (!required.includes(propName)) {
|
|
252
293
|
zodProp = zodProp.optional();
|
|
253
294
|
}
|
|
254
295
|
shape[propName] = zodProp;
|
|
296
|
+
bodyParams.add(propName); // Track that this is a body parameter
|
|
255
297
|
}
|
|
256
|
-
return shape;
|
|
257
298
|
}
|
|
258
|
-
return {};
|
|
299
|
+
return { shape, bodyParams };
|
|
259
300
|
}
|
|
260
301
|
/**
|
|
261
302
|
* Generate tool name from operation
|
|
@@ -292,9 +333,9 @@ export function parseOperations(spec) {
|
|
|
292
333
|
...(operation.parameters || []),
|
|
293
334
|
];
|
|
294
335
|
// Build combined schema from parameters and request body
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
const combinedShape = { ...
|
|
336
|
+
const parametersResult = buildParametersSchema(allParameters, operationId, spec);
|
|
337
|
+
const bodyResult = buildRequestBodySchema(operation, operationId, spec);
|
|
338
|
+
const combinedShape = { ...parametersResult.shape, ...bodyResult.shape };
|
|
298
339
|
const parameters = z.object(combinedShape);
|
|
299
340
|
// Extract operation-specific auth config
|
|
300
341
|
const authConfig = extractOperationAuthConfig(spec, operation);
|
|
@@ -305,6 +346,11 @@ export function parseOperations(spec) {
|
|
|
305
346
|
httpMethod: method,
|
|
306
347
|
path,
|
|
307
348
|
authConfig,
|
|
349
|
+
parameterMetadata: {
|
|
350
|
+
pathParams: parametersResult.pathParams,
|
|
351
|
+
queryParams: parametersResult.queryParams,
|
|
352
|
+
bodyParams: bodyResult.bodyParams,
|
|
353
|
+
},
|
|
308
354
|
});
|
|
309
355
|
}
|
|
310
356
|
}
|
package/dist/tool-executor.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { Tool, HttpMethod, AuthConfig } from './types.js';
|
|
2
|
+
import { Tool, HttpMethod, AuthConfig, ParameterMetadata } from './types.js';
|
|
3
3
|
import { AuthManager } from './auth-manager.js';
|
|
4
4
|
interface ToolDefinition {
|
|
5
5
|
name: string;
|
|
@@ -8,6 +8,7 @@ interface ToolDefinition {
|
|
|
8
8
|
httpMethod: HttpMethod;
|
|
9
9
|
path: string;
|
|
10
10
|
authConfig?: AuthConfig;
|
|
11
|
+
parameterMetadata?: ParameterMetadata;
|
|
11
12
|
}
|
|
12
13
|
/**
|
|
13
14
|
* Create executable tools from tool definitions
|
package/dist/tool-executor.js
CHANGED
|
@@ -19,8 +19,8 @@ function createExecutor(tool, baseUrl, authManager) {
|
|
|
19
19
|
const validatedParams = tool.parameters.parse(params);
|
|
20
20
|
// Build URL with path parameters replaced
|
|
21
21
|
let url = buildUrl(baseUrl, tool.path, validatedParams);
|
|
22
|
-
// Separate path, query, and body parameters
|
|
23
|
-
const { queryParams, bodyParams } = separateParams(validatedParams, tool.
|
|
22
|
+
// Separate path, query, and body parameters using metadata
|
|
23
|
+
const { queryParams, bodyParams } = separateParams(validatedParams, tool.parameterMetadata);
|
|
24
24
|
// Add query parameters
|
|
25
25
|
const urlObj = new URL(url);
|
|
26
26
|
for (const [key, value] of Object.entries(queryParams)) {
|
|
@@ -122,39 +122,55 @@ function buildUrl(baseUrl, path, params) {
|
|
|
122
122
|
return `${baseUrl}${finalPath}`;
|
|
123
123
|
}
|
|
124
124
|
/**
|
|
125
|
-
* Separate parameters into path, query, and body params
|
|
125
|
+
* Separate parameters into path, query, and body params using metadata
|
|
126
126
|
*/
|
|
127
|
-
function separateParams(params,
|
|
127
|
+
function separateParams(params, metadata) {
|
|
128
128
|
const pathParams = {};
|
|
129
129
|
const queryParams = {};
|
|
130
130
|
const bodyParams = {};
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
131
|
+
// If we have metadata, use it to categorize parameters
|
|
132
|
+
if (metadata) {
|
|
133
|
+
for (const [key, value] of Object.entries(params)) {
|
|
134
|
+
if (metadata.pathParams.has(key)) {
|
|
135
|
+
pathParams[key] = value;
|
|
136
|
+
}
|
|
137
|
+
else if (metadata.queryParams.has(key)) {
|
|
138
|
+
queryParams[key] = value;
|
|
139
|
+
}
|
|
140
|
+
else if (metadata.bodyParams.has(key)) {
|
|
141
|
+
bodyParams[key] = value;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
// Fallback: if not in metadata, treat as body param
|
|
145
|
+
bodyParams[key] = value;
|
|
146
|
+
}
|
|
145
147
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
// Fallback to old behavior if no metadata (shouldn't happen with new parser)
|
|
151
|
+
// This keeps backward compatibility
|
|
152
|
+
const pathParamNames = extractPathParamNames(params);
|
|
153
|
+
for (const [key, value] of Object.entries(params)) {
|
|
154
|
+
if (pathParamNames.has(key)) {
|
|
155
|
+
pathParams[key] = value;
|
|
156
|
+
}
|
|
157
|
+
else if (isPrimitive(value)) {
|
|
158
|
+
queryParams[key] = value;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
bodyParams[key] = value;
|
|
162
|
+
}
|
|
149
163
|
}
|
|
150
164
|
}
|
|
151
|
-
// For simplicity, if there are non-path primitive params,
|
|
152
|
-
// we need to determine if they're query or body based on HTTP method
|
|
153
|
-
// Since we don't have that info here, we'll treat all non-path primitives
|
|
154
|
-
// as potential query params for GET/DELETE, and body params for POST/PUT/PATCH
|
|
155
|
-
// This is handled by the executor which knows the method
|
|
156
165
|
return { pathParams, queryParams, bodyParams };
|
|
157
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Extract path parameter names from params (fallback)
|
|
169
|
+
*/
|
|
170
|
+
function extractPathParamNames(params) {
|
|
171
|
+
// This is a fallback - in practice, metadata should always be provided
|
|
172
|
+
return new Set();
|
|
173
|
+
}
|
|
158
174
|
/**
|
|
159
175
|
* Check if value is a primitive type
|
|
160
176
|
*/
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
3
|
+
export interface ParameterMetadata {
|
|
4
|
+
/** Parameters that come from path (e.g., {id} in /users/{id}) */
|
|
5
|
+
pathParams: Set<string>;
|
|
6
|
+
/** Parameters that come from query string */
|
|
7
|
+
queryParams: Set<string>;
|
|
8
|
+
/** Parameters that come from request body */
|
|
9
|
+
bodyParams: Set<string>;
|
|
10
|
+
}
|
|
3
11
|
export interface Tool {
|
|
4
12
|
name: string;
|
|
5
13
|
description: string;
|
|
@@ -8,6 +16,7 @@ export interface Tool {
|
|
|
8
16
|
httpMethod: HttpMethod;
|
|
9
17
|
path: string;
|
|
10
18
|
authConfig?: AuthConfig;
|
|
19
|
+
parameterMetadata?: ParameterMetadata;
|
|
11
20
|
}
|
|
12
21
|
export type AuthType = 'oauth2' | 'apiKey' | 'bearer' | 'basic' | 'none';
|
|
13
22
|
export interface AuthConfig {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spec2tools/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Core utilities for OpenAPI parsing and authentication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"directory": "packages/core"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
+
"@pydantic/monty": "^0.0.7",
|
|
32
|
+
"ai": "^6.0.68",
|
|
31
33
|
"express": "^5.0.0",
|
|
32
34
|
"open": "^10.1.0",
|
|
33
35
|
"yaml": "^2.5.0",
|
|
@@ -37,7 +39,8 @@
|
|
|
37
39
|
"devDependencies": {
|
|
38
40
|
"@types/express": "^5.0.0",
|
|
39
41
|
"@types/node": "^22.0.0",
|
|
40
|
-
"typescript": "^5.6.0"
|
|
42
|
+
"typescript": "^5.6.0",
|
|
43
|
+
"vitest": "^4.0.18"
|
|
41
44
|
},
|
|
42
45
|
"engines": {
|
|
43
46
|
"node": ">=18.0.0"
|
|
@@ -45,6 +48,8 @@
|
|
|
45
48
|
"scripts": {
|
|
46
49
|
"build": "tsc",
|
|
47
50
|
"dev": "tsc --watch",
|
|
48
|
-
"typecheck": "tsc --noEmit"
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"test": "vitest run",
|
|
53
|
+
"test:watch": "vitest"
|
|
49
54
|
}
|
|
50
55
|
}
|