@opencontextprotocol/agent 0.1.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/LICENSE +21 -0
- package/README.md +198 -0
- package/dist/src/agent.d.ts +112 -0
- package/dist/src/agent.d.ts.map +1 -0
- package/dist/src/agent.js +358 -0
- package/dist/src/agent.js.map +1 -0
- package/dist/src/context.d.ts +108 -0
- package/dist/src/context.d.ts.map +1 -0
- package/dist/src/context.js +196 -0
- package/dist/src/context.js.map +1 -0
- package/dist/src/errors.d.ts +40 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +63 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/headers.d.ts +63 -0
- package/dist/src/headers.d.ts.map +1 -0
- package/dist/src/headers.js +238 -0
- package/dist/src/headers.js.map +1 -0
- package/dist/src/http_client.d.ts +82 -0
- package/dist/src/http_client.d.ts.map +1 -0
- package/dist/src/http_client.js +181 -0
- package/dist/src/http_client.js.map +1 -0
- package/dist/src/index.d.ts +25 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +35 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/registry.d.ts +52 -0
- package/dist/src/registry.d.ts.map +1 -0
- package/dist/src/registry.js +164 -0
- package/dist/src/registry.js.map +1 -0
- package/dist/src/schema_discovery.d.ts +149 -0
- package/dist/src/schema_discovery.d.ts.map +1 -0
- package/dist/src/schema_discovery.js +707 -0
- package/dist/src/schema_discovery.js.map +1 -0
- package/dist/src/schemas/ocp-context.json +138 -0
- package/dist/src/storage.d.ts +110 -0
- package/dist/src/storage.d.ts.map +1 -0
- package/dist/src/storage.js +399 -0
- package/dist/src/storage.js.map +1 -0
- package/dist/src/validation.d.ts +169 -0
- package/dist/src/validation.d.ts.map +1 -0
- package/dist/src/validation.js +92 -0
- package/dist/src/validation.js.map +1 -0
- package/dist/tests/agent.test.d.ts +5 -0
- package/dist/tests/agent.test.d.ts.map +1 -0
- package/dist/tests/agent.test.js +536 -0
- package/dist/tests/agent.test.js.map +1 -0
- package/dist/tests/context.test.d.ts +5 -0
- package/dist/tests/context.test.d.ts.map +1 -0
- package/dist/tests/context.test.js +285 -0
- package/dist/tests/context.test.js.map +1 -0
- package/dist/tests/headers.test.d.ts +5 -0
- package/dist/tests/headers.test.d.ts.map +1 -0
- package/dist/tests/headers.test.js +356 -0
- package/dist/tests/headers.test.js.map +1 -0
- package/dist/tests/http_client.test.d.ts +5 -0
- package/dist/tests/http_client.test.d.ts.map +1 -0
- package/dist/tests/http_client.test.js +373 -0
- package/dist/tests/http_client.test.js.map +1 -0
- package/dist/tests/registry.test.d.ts +5 -0
- package/dist/tests/registry.test.d.ts.map +1 -0
- package/dist/tests/registry.test.js +232 -0
- package/dist/tests/registry.test.js.map +1 -0
- package/dist/tests/schema_discovery.test.d.ts +5 -0
- package/dist/tests/schema_discovery.test.d.ts.map +1 -0
- package/dist/tests/schema_discovery.test.js +1074 -0
- package/dist/tests/schema_discovery.test.js.map +1 -0
- package/dist/tests/storage.test.d.ts +5 -0
- package/dist/tests/storage.test.d.ts.map +1 -0
- package/dist/tests/storage.test.js +414 -0
- package/dist/tests/storage.test.js.map +1 -0
- package/dist/tests/validation.test.d.ts +5 -0
- package/dist/tests/validation.test.d.ts.map +1 -0
- package/dist/tests/validation.test.js +254 -0
- package/dist/tests/validation.test.js.map +1 -0
- package/package.json +51 -0
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OCP Schema Discovery
|
|
3
|
+
*
|
|
4
|
+
* Automatic OpenAPI schema discovery and tool extraction for OCP agents.
|
|
5
|
+
*/
|
|
6
|
+
import { SchemaDiscoveryError } from './errors.js';
|
|
7
|
+
import { readFileSync } from 'fs';
|
|
8
|
+
import { resolve } from 'path';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
import yaml from 'js-yaml';
|
|
11
|
+
// Configuration constants
|
|
12
|
+
const DEFAULT_API_TITLE = 'Unknown API';
|
|
13
|
+
const DEFAULT_API_VERSION = '1.0.0';
|
|
14
|
+
const SUPPORTED_HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete'];
|
|
15
|
+
/**
|
|
16
|
+
* OCP Schema Discovery Client
|
|
17
|
+
*
|
|
18
|
+
* Discovers and parses OpenAPI specifications to extract available tools.
|
|
19
|
+
*/
|
|
20
|
+
export class OCPSchemaDiscovery {
|
|
21
|
+
constructor() {
|
|
22
|
+
this.cache = new Map();
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Discover API from OpenAPI specification.
|
|
26
|
+
*
|
|
27
|
+
* @param specPath - URL or file path to OpenAPI specification (JSON or YAML)
|
|
28
|
+
* @param baseUrl - Optional override for API base URL
|
|
29
|
+
* @param includeResources - Optional list of resource names to filter tools by (case-insensitive, first resource segment matching)
|
|
30
|
+
* @param pathPrefix - Optional path prefix to strip before filtering (e.g., '/v1', '/api/v2')
|
|
31
|
+
* @returns API specification with extracted tools
|
|
32
|
+
*/
|
|
33
|
+
async discoverApi(specPath, baseUrl, includeResources, pathPrefix) {
|
|
34
|
+
// Normalize cache key (absolute path for files, URL as-is)
|
|
35
|
+
const cacheKey = this._normalizeCacheKey(specPath);
|
|
36
|
+
// Check cache
|
|
37
|
+
if (this.cache.has(cacheKey)) {
|
|
38
|
+
return this.cache.get(cacheKey);
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const spec = await this._fetchSpec(specPath);
|
|
42
|
+
this._specVersion = this._detectSpecVersion(spec);
|
|
43
|
+
const apiSpec = this._parseOpenApiSpec(spec, baseUrl);
|
|
44
|
+
// Cache the result
|
|
45
|
+
this.cache.set(cacheKey, apiSpec);
|
|
46
|
+
// Apply resource filtering if specified (only on newly parsed specs)
|
|
47
|
+
if (includeResources) {
|
|
48
|
+
const filteredTools = this._filterToolsByResources(apiSpec.tools, includeResources, pathPrefix);
|
|
49
|
+
return {
|
|
50
|
+
base_url: apiSpec.base_url,
|
|
51
|
+
title: apiSpec.title,
|
|
52
|
+
version: apiSpec.version,
|
|
53
|
+
description: apiSpec.description,
|
|
54
|
+
tools: filteredTools,
|
|
55
|
+
raw_spec: apiSpec.raw_spec
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return apiSpec;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
throw new SchemaDiscoveryError(`Failed to discover API: ${error instanceof Error ? error.message : String(error)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Normalize cache key: URLs as-is, file paths to absolute.
|
|
66
|
+
*/
|
|
67
|
+
_normalizeCacheKey(specPath) {
|
|
68
|
+
if (specPath.startsWith('http://') || specPath.startsWith('https://')) {
|
|
69
|
+
return specPath;
|
|
70
|
+
}
|
|
71
|
+
// Expand ~ and resolve to absolute path
|
|
72
|
+
let expanded = specPath;
|
|
73
|
+
if (specPath === '~' || specPath.startsWith('~/')) {
|
|
74
|
+
expanded = specPath.replace(/^~/, homedir());
|
|
75
|
+
}
|
|
76
|
+
return resolve(expanded);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Fetch OpenAPI spec from URL or local file.
|
|
80
|
+
*/
|
|
81
|
+
async _fetchSpec(specPath) {
|
|
82
|
+
if (specPath.startsWith('http://') || specPath.startsWith('https://')) {
|
|
83
|
+
return this._fetchFromUrl(specPath);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
return this._fetchFromFile(specPath);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Fetch OpenAPI specification from URL without resolving $refs.
|
|
91
|
+
* References are resolved lazily during tool creation.
|
|
92
|
+
*/
|
|
93
|
+
async _fetchFromUrl(url) {
|
|
94
|
+
try {
|
|
95
|
+
const response = await fetch(url);
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
98
|
+
}
|
|
99
|
+
return await response.json();
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
throw new SchemaDiscoveryError(`Failed to fetch OpenAPI spec from ${url}: ${error instanceof Error ? error.message : String(error)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Load OpenAPI specification from local JSON or YAML file.
|
|
107
|
+
*/
|
|
108
|
+
_fetchFromFile(filePath) {
|
|
109
|
+
try {
|
|
110
|
+
// Expand ~ for home directory
|
|
111
|
+
let expandedPath = filePath;
|
|
112
|
+
if (filePath === '~' || filePath.startsWith('~/')) {
|
|
113
|
+
expandedPath = filePath.replace(/^~/, homedir());
|
|
114
|
+
}
|
|
115
|
+
// Resolve to absolute path
|
|
116
|
+
const resolvedPath = resolve(expandedPath);
|
|
117
|
+
// Check file extension
|
|
118
|
+
const lowerPath = resolvedPath.toLowerCase();
|
|
119
|
+
const isJson = lowerPath.endsWith('.json');
|
|
120
|
+
const isYaml = lowerPath.endsWith('.yaml') || lowerPath.endsWith('.yml');
|
|
121
|
+
if (!isJson && !isYaml) {
|
|
122
|
+
const ext = resolvedPath.substring(resolvedPath.lastIndexOf('.'));
|
|
123
|
+
throw new SchemaDiscoveryError(`Unsupported file format: ${ext}. Supported formats: .json, .yaml, .yml`);
|
|
124
|
+
}
|
|
125
|
+
// Read file
|
|
126
|
+
const content = readFileSync(resolvedPath, 'utf-8');
|
|
127
|
+
// Parse based on format
|
|
128
|
+
if (isJson) {
|
|
129
|
+
return JSON.parse(content);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
return yaml.load(content);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error instanceof SchemaDiscoveryError) {
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
// YAML errors
|
|
140
|
+
if (error instanceof yaml.YAMLException) {
|
|
141
|
+
throw new SchemaDiscoveryError(`Invalid YAML in file ${filePath}: ${error.message}`);
|
|
142
|
+
}
|
|
143
|
+
// JSON errors
|
|
144
|
+
if (error instanceof SyntaxError) {
|
|
145
|
+
throw new SchemaDiscoveryError(`Invalid JSON in file ${filePath}: ${error.message}`);
|
|
146
|
+
}
|
|
147
|
+
// File not found
|
|
148
|
+
if (error.code === 'ENOENT') {
|
|
149
|
+
throw new SchemaDiscoveryError(`File not found: ${filePath}`);
|
|
150
|
+
}
|
|
151
|
+
throw new SchemaDiscoveryError(`Failed to load spec from ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Detect OpenAPI/Swagger version from spec.
|
|
156
|
+
*
|
|
157
|
+
* @returns Version string: 'swagger_2', 'openapi_3.0', 'openapi_3.1', 'openapi_3.2'
|
|
158
|
+
*/
|
|
159
|
+
_detectSpecVersion(spec) {
|
|
160
|
+
if ('swagger' in spec) {
|
|
161
|
+
const swaggerVersion = spec.swagger;
|
|
162
|
+
if (typeof swaggerVersion === 'string' && swaggerVersion.startsWith('2.')) {
|
|
163
|
+
return 'swagger_2';
|
|
164
|
+
}
|
|
165
|
+
throw new SchemaDiscoveryError(`Unsupported Swagger version: ${swaggerVersion}`);
|
|
166
|
+
}
|
|
167
|
+
else if ('openapi' in spec) {
|
|
168
|
+
const openapiVersion = spec.openapi;
|
|
169
|
+
if (typeof openapiVersion === 'string') {
|
|
170
|
+
if (openapiVersion.startsWith('3.0')) {
|
|
171
|
+
return 'openapi_3.0';
|
|
172
|
+
}
|
|
173
|
+
else if (openapiVersion.startsWith('3.1')) {
|
|
174
|
+
return 'openapi_3.1';
|
|
175
|
+
}
|
|
176
|
+
else if (openapiVersion.startsWith('3.2')) {
|
|
177
|
+
return 'openapi_3.2';
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
throw new SchemaDiscoveryError(`Unsupported OpenAPI version: ${openapiVersion}`);
|
|
181
|
+
}
|
|
182
|
+
throw new SchemaDiscoveryError('Unable to detect spec version: missing "swagger" or "openapi" field');
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Recursively resolve $ref references in OpenAPI spec with polymorphic keyword handling.
|
|
186
|
+
*
|
|
187
|
+
* @param obj - Current object being processed (object, array, or primitive)
|
|
188
|
+
* @param root - Root spec document for looking up references
|
|
189
|
+
* @param resolutionStack - Stack of refs currently being resolved (for circular detection)
|
|
190
|
+
* @param memo - Memoization cache to store resolved references
|
|
191
|
+
* @param insidePolymorphicKeyword - True if currently inside anyOf/oneOf/allOf
|
|
192
|
+
* @returns Object with all resolvable $refs replaced by their definitions
|
|
193
|
+
*/
|
|
194
|
+
_resolveRefs(obj, root, resolutionStack = [], memo = {}, insidePolymorphicKeyword = false) {
|
|
195
|
+
// Initialize on first call
|
|
196
|
+
if (root === undefined) {
|
|
197
|
+
root = obj;
|
|
198
|
+
}
|
|
199
|
+
// Handle object types
|
|
200
|
+
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
201
|
+
// Check for polymorphic keywords - process with flag set
|
|
202
|
+
if ('anyOf' in obj) {
|
|
203
|
+
const result = {
|
|
204
|
+
anyOf: obj.anyOf.map((item) => this._resolveRefs(item, root, resolutionStack, memo, true))
|
|
205
|
+
};
|
|
206
|
+
// Include other keys if present
|
|
207
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
208
|
+
if (k !== 'anyOf') {
|
|
209
|
+
result[k] = this._resolveRefs(v, root, resolutionStack, memo, insidePolymorphicKeyword);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
if ('oneOf' in obj) {
|
|
215
|
+
const result = {
|
|
216
|
+
oneOf: obj.oneOf.map((item) => this._resolveRefs(item, root, resolutionStack, memo, true))
|
|
217
|
+
};
|
|
218
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
219
|
+
if (k !== 'oneOf') {
|
|
220
|
+
result[k] = this._resolveRefs(v, root, resolutionStack, memo, insidePolymorphicKeyword);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
if ('allOf' in obj) {
|
|
226
|
+
const result = {
|
|
227
|
+
allOf: obj.allOf.map((item) => this._resolveRefs(item, root, resolutionStack, memo, true))
|
|
228
|
+
};
|
|
229
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
230
|
+
if (k !== 'allOf') {
|
|
231
|
+
result[k] = this._resolveRefs(v, root, resolutionStack, memo, insidePolymorphicKeyword);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
// Check if this is a $ref
|
|
237
|
+
if ('$ref' in obj && Object.keys(obj).length === 1) {
|
|
238
|
+
const refPath = obj.$ref;
|
|
239
|
+
// Only handle internal refs (start with #/)
|
|
240
|
+
if (!refPath.startsWith('#/')) {
|
|
241
|
+
return obj;
|
|
242
|
+
}
|
|
243
|
+
// If inside polymorphic keyword, check if ref points to an object
|
|
244
|
+
if (insidePolymorphicKeyword) {
|
|
245
|
+
try {
|
|
246
|
+
const resolved = this._lookupRef(root, refPath);
|
|
247
|
+
if (resolved !== null) {
|
|
248
|
+
// Check if it's an object schema
|
|
249
|
+
if (resolved.type === 'object' || 'properties' in resolved) {
|
|
250
|
+
// Keep the $ref unresolved for object schemas
|
|
251
|
+
return obj;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// If lookup fails, keep the ref
|
|
257
|
+
return obj;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Check memo cache
|
|
261
|
+
if (refPath in memo) {
|
|
262
|
+
return memo[refPath];
|
|
263
|
+
}
|
|
264
|
+
// Check for circular reference
|
|
265
|
+
if (resolutionStack.includes(refPath)) {
|
|
266
|
+
// Return a placeholder to break the cycle
|
|
267
|
+
const placeholder = { type: 'object', description: 'Circular reference' };
|
|
268
|
+
memo[refPath] = placeholder;
|
|
269
|
+
return placeholder;
|
|
270
|
+
}
|
|
271
|
+
// Resolve the reference
|
|
272
|
+
try {
|
|
273
|
+
const resolved = this._lookupRef(root, refPath);
|
|
274
|
+
if (resolved !== null) {
|
|
275
|
+
// Recursively resolve the resolved object with updated stack
|
|
276
|
+
const newStack = [...resolutionStack, refPath];
|
|
277
|
+
const resolvedObj = this._resolveRefs(resolved, root, newStack, memo, insidePolymorphicKeyword);
|
|
278
|
+
memo[refPath] = resolvedObj;
|
|
279
|
+
return resolvedObj;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// If lookup fails, return a placeholder
|
|
284
|
+
const placeholder = { type: 'object', description: 'Unresolved reference' };
|
|
285
|
+
memo[refPath] = placeholder;
|
|
286
|
+
return placeholder;
|
|
287
|
+
}
|
|
288
|
+
return obj;
|
|
289
|
+
}
|
|
290
|
+
// Not a $ref, recursively process all values
|
|
291
|
+
const result = {};
|
|
292
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
293
|
+
result[key] = this._resolveRefs(value, root, resolutionStack, memo, insidePolymorphicKeyword);
|
|
294
|
+
}
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
// Handle array types
|
|
298
|
+
if (Array.isArray(obj)) {
|
|
299
|
+
return obj.map(item => this._resolveRefs(item, root, resolutionStack, memo, insidePolymorphicKeyword));
|
|
300
|
+
}
|
|
301
|
+
// Primitives pass through unchanged
|
|
302
|
+
return obj;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Look up a reference path in the spec document.
|
|
306
|
+
*
|
|
307
|
+
* @param root - Root spec document
|
|
308
|
+
* @param refPath - Reference path like '#/components/schemas/User'
|
|
309
|
+
* @returns The referenced object, or null if not found
|
|
310
|
+
*/
|
|
311
|
+
_lookupRef(root, refPath) {
|
|
312
|
+
// Remove the leading '#/' and split by '/'
|
|
313
|
+
if (!refPath.startsWith('#/')) {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
const pathParts = refPath.substring(2).split('/');
|
|
317
|
+
// Navigate through the spec
|
|
318
|
+
let current = root;
|
|
319
|
+
for (const part of pathParts) {
|
|
320
|
+
if (current !== null && typeof current === 'object' && part in current) {
|
|
321
|
+
current = current[part];
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return current;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Parse OpenAPI specification and extract tools with lazy $ref resolution.
|
|
331
|
+
*/
|
|
332
|
+
_parseOpenApiSpec(spec, baseUrlOverride) {
|
|
333
|
+
// Initialize memoization cache for lazy $ref resolution
|
|
334
|
+
const memoCache = {};
|
|
335
|
+
// Extract API info
|
|
336
|
+
const info = spec.info || {};
|
|
337
|
+
const title = info.title || DEFAULT_API_TITLE;
|
|
338
|
+
const version = info.version || '1.0.0';
|
|
339
|
+
const description = info.description || '';
|
|
340
|
+
// Extract base URL (version-specific)
|
|
341
|
+
let baseUrl = baseUrlOverride;
|
|
342
|
+
if (!baseUrl) {
|
|
343
|
+
baseUrl = this._extractBaseUrl(spec);
|
|
344
|
+
}
|
|
345
|
+
// Extract tools from paths
|
|
346
|
+
const tools = [];
|
|
347
|
+
const paths = spec.paths || {};
|
|
348
|
+
for (const [path, pathItem] of Object.entries(paths)) {
|
|
349
|
+
if (typeof pathItem !== 'object' || pathItem === null)
|
|
350
|
+
continue;
|
|
351
|
+
for (const [method, operation] of Object.entries(pathItem)) {
|
|
352
|
+
if (!SUPPORTED_HTTP_METHODS.includes(method.toLowerCase())) {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const tool = this._createToolFromOperation(path, method, operation, spec, memoCache);
|
|
356
|
+
if (tool) {
|
|
357
|
+
tools.push(tool);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
base_url: baseUrl,
|
|
363
|
+
title,
|
|
364
|
+
version,
|
|
365
|
+
description,
|
|
366
|
+
tools,
|
|
367
|
+
raw_spec: spec
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Extract base URL from spec (version-aware).
|
|
372
|
+
*/
|
|
373
|
+
_extractBaseUrl(spec) {
|
|
374
|
+
if (this._specVersion === 'swagger_2') {
|
|
375
|
+
// Swagger 2.0: construct from host, basePath, and schemes
|
|
376
|
+
const schemes = spec.schemes || ['https'];
|
|
377
|
+
const host = spec.host || '';
|
|
378
|
+
const basePath = spec.basePath || '';
|
|
379
|
+
if (host) {
|
|
380
|
+
const scheme = schemes.length > 0 ? schemes[0] : 'https';
|
|
381
|
+
return `${scheme}://${host}${basePath}`;
|
|
382
|
+
}
|
|
383
|
+
return '';
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
// OpenAPI 3.x: use servers array
|
|
387
|
+
if (spec.servers && spec.servers.length > 0) {
|
|
388
|
+
return spec.servers[0].url || '';
|
|
389
|
+
}
|
|
390
|
+
return '';
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Create tool definition from OpenAPI operation.
|
|
395
|
+
*/
|
|
396
|
+
_createToolFromOperation(path, method, operation, specData, memoCache) {
|
|
397
|
+
if (!operation || typeof operation !== 'object') {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
// Generate tool name with proper validation and fallback logic
|
|
401
|
+
const operationId = operation.operationId;
|
|
402
|
+
let toolName = null;
|
|
403
|
+
// Try operationId first
|
|
404
|
+
if (operationId) {
|
|
405
|
+
const normalizedName = this._normalizeToolName(operationId);
|
|
406
|
+
if (this._isValidToolName(normalizedName)) {
|
|
407
|
+
toolName = normalizedName;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// If operationId failed, try fallback naming
|
|
411
|
+
if (!toolName) {
|
|
412
|
+
// Generate name from path and method
|
|
413
|
+
const cleanPath = path.replace(/\//g, '_').replace(/[{}]/g, '');
|
|
414
|
+
const fallbackName = `${method.toLowerCase()}${cleanPath}`;
|
|
415
|
+
const normalizedFallback = this._normalizeToolName(fallbackName);
|
|
416
|
+
if (this._isValidToolName(normalizedFallback)) {
|
|
417
|
+
toolName = normalizedFallback;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// If we can't generate a valid tool name, skip this operation
|
|
421
|
+
if (!toolName) {
|
|
422
|
+
console.warn(`Skipping operation ${method} ${path}: unable to generate valid tool name`);
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
const description = operation.summary || operation.description || 'No description provided';
|
|
426
|
+
const tags = operation.tags || [];
|
|
427
|
+
// Parse parameters (version-aware)
|
|
428
|
+
const parameters = this._parseParameters(operation.parameters || [], specData, memoCache);
|
|
429
|
+
// Add request body parameters (version-specific)
|
|
430
|
+
if (['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) {
|
|
431
|
+
if (this._specVersion === 'swagger_2') {
|
|
432
|
+
// Swagger 2.0: body is in parameters array
|
|
433
|
+
for (const param of (operation.parameters || [])) {
|
|
434
|
+
const bodyParams = this._parseSwagger2BodyParameter(param, specData, memoCache);
|
|
435
|
+
Object.assign(parameters, bodyParams);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
// OpenAPI 3.x: separate requestBody field
|
|
440
|
+
if (operation.requestBody) {
|
|
441
|
+
const bodyParams = this._parseOpenApi3RequestBody(operation.requestBody, specData, memoCache);
|
|
442
|
+
Object.assign(parameters, bodyParams);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// Parse response schema
|
|
447
|
+
const responseSchema = this._parseResponses(operation.responses || {}, specData, memoCache);
|
|
448
|
+
return {
|
|
449
|
+
name: toolName,
|
|
450
|
+
description,
|
|
451
|
+
method: method.toUpperCase(),
|
|
452
|
+
path,
|
|
453
|
+
parameters,
|
|
454
|
+
response_schema: responseSchema,
|
|
455
|
+
operation_id: operationId,
|
|
456
|
+
tags
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Normalize tool name to camelCase, removing special characters.
|
|
461
|
+
*/
|
|
462
|
+
_normalizeToolName(name) {
|
|
463
|
+
if (!name) {
|
|
464
|
+
return name;
|
|
465
|
+
}
|
|
466
|
+
// First, split PascalCase/camelCase words (e.g., "FetchAccount" -> "Fetch Account")
|
|
467
|
+
// Insert space before uppercase letters that follow lowercase letters or digits
|
|
468
|
+
const pascalSplit = name.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
|
469
|
+
// Replace separators (/, _, -, .) with spaces for processing
|
|
470
|
+
// Also handle multiple consecutive separators like //
|
|
471
|
+
const normalized = pascalSplit.replace(/[\/_.-]+/g, ' ');
|
|
472
|
+
// Split into words and filter out empty strings
|
|
473
|
+
const words = normalized.split(' ').filter(word => word);
|
|
474
|
+
if (words.length === 0) {
|
|
475
|
+
return name;
|
|
476
|
+
}
|
|
477
|
+
// Convert to camelCase: first word lowercase, rest capitalize
|
|
478
|
+
const camelCaseWords = [words[0].toLowerCase()];
|
|
479
|
+
for (let i = 1; i < words.length; i++) {
|
|
480
|
+
camelCaseWords.push(words[i].charAt(0).toUpperCase() + words[i].slice(1).toLowerCase());
|
|
481
|
+
}
|
|
482
|
+
return camelCaseWords.join('');
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Check if a normalized tool name is valid.
|
|
486
|
+
*/
|
|
487
|
+
_isValidToolName(name) {
|
|
488
|
+
if (!name) {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
// Must start with a letter
|
|
492
|
+
if (!/^[a-zA-Z]/.test(name)) {
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
// Must contain at least one alphanumeric character
|
|
496
|
+
if (!/[a-zA-Z0-9]/.test(name)) {
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
return true;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Parse OpenAPI parameters with lazy $ref resolution.
|
|
503
|
+
*/
|
|
504
|
+
_parseParameters(parameters, specData, memoCache) {
|
|
505
|
+
const result = {};
|
|
506
|
+
for (const param of parameters) {
|
|
507
|
+
if (!param || typeof param !== 'object')
|
|
508
|
+
continue;
|
|
509
|
+
const name = param.name;
|
|
510
|
+
if (!name)
|
|
511
|
+
continue;
|
|
512
|
+
const paramSchema = {
|
|
513
|
+
description: param.description || '',
|
|
514
|
+
required: param.required || false,
|
|
515
|
+
location: param.in || 'query',
|
|
516
|
+
type: 'string' // Default type
|
|
517
|
+
};
|
|
518
|
+
// Extract type from schema
|
|
519
|
+
const schema = param.schema || {};
|
|
520
|
+
if (schema) {
|
|
521
|
+
// Resolve any $refs in the parameter schema
|
|
522
|
+
const resolvedSchema = this._resolveRefs(schema, specData, [], memoCache);
|
|
523
|
+
paramSchema.type = resolvedSchema.type || 'string';
|
|
524
|
+
if ('enum' in resolvedSchema) {
|
|
525
|
+
paramSchema.enum = resolvedSchema.enum;
|
|
526
|
+
}
|
|
527
|
+
if ('format' in resolvedSchema) {
|
|
528
|
+
paramSchema.format = resolvedSchema.format;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
result[name] = paramSchema;
|
|
532
|
+
}
|
|
533
|
+
return result;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Parse OpenAPI 3.x request body with lazy $ref resolution.
|
|
537
|
+
*/
|
|
538
|
+
_parseOpenApi3RequestBody(requestBody, specData, memoCache) {
|
|
539
|
+
if (!requestBody || typeof requestBody !== 'object') {
|
|
540
|
+
return {};
|
|
541
|
+
}
|
|
542
|
+
const content = requestBody.content || {};
|
|
543
|
+
const jsonContent = content['application/json'];
|
|
544
|
+
if (!jsonContent || !jsonContent.schema) {
|
|
545
|
+
return {};
|
|
546
|
+
}
|
|
547
|
+
// Resolve the schema if it contains $refs
|
|
548
|
+
const schema = this._resolveRefs(jsonContent.schema, specData, [], memoCache);
|
|
549
|
+
// Handle object schemas
|
|
550
|
+
if (schema.type === 'object') {
|
|
551
|
+
const properties = schema.properties || {};
|
|
552
|
+
const required = schema.required || [];
|
|
553
|
+
const result = {};
|
|
554
|
+
for (const [name, propSchema] of Object.entries(properties)) {
|
|
555
|
+
if (typeof propSchema !== 'object' || propSchema === null)
|
|
556
|
+
continue;
|
|
557
|
+
const prop = propSchema;
|
|
558
|
+
result[name] = {
|
|
559
|
+
description: prop.description || '',
|
|
560
|
+
required: required.includes(name),
|
|
561
|
+
location: 'body',
|
|
562
|
+
type: prop.type || 'string'
|
|
563
|
+
};
|
|
564
|
+
if ('enum' in prop) {
|
|
565
|
+
result[name].enum = prop.enum;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return result;
|
|
569
|
+
}
|
|
570
|
+
return {};
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Parse Swagger 2.0 body parameter into parameters.
|
|
574
|
+
*/
|
|
575
|
+
_parseSwagger2BodyParameter(param, specData, memoCache) {
|
|
576
|
+
if (!param || typeof param !== 'object' || param.in !== 'body' || !param.schema) {
|
|
577
|
+
return {};
|
|
578
|
+
}
|
|
579
|
+
// Resolve the schema if it contains $refs
|
|
580
|
+
const schema = this._resolveRefs(param.schema, specData, [], memoCache);
|
|
581
|
+
// Handle object schemas
|
|
582
|
+
if (schema.type === 'object') {
|
|
583
|
+
const properties = schema.properties || {};
|
|
584
|
+
const required = schema.required || [];
|
|
585
|
+
const result = {};
|
|
586
|
+
for (const [name, propSchema] of Object.entries(properties)) {
|
|
587
|
+
if (typeof propSchema !== 'object' || propSchema === null)
|
|
588
|
+
continue;
|
|
589
|
+
const prop = propSchema;
|
|
590
|
+
result[name] = {
|
|
591
|
+
description: prop.description || '',
|
|
592
|
+
required: required.includes(name),
|
|
593
|
+
location: 'body',
|
|
594
|
+
type: prop.type || 'string'
|
|
595
|
+
};
|
|
596
|
+
if ('enum' in prop) {
|
|
597
|
+
result[name].enum = prop.enum;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return result;
|
|
601
|
+
}
|
|
602
|
+
return {};
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Parse OpenAPI responses with lazy $ref resolution (version-aware).
|
|
606
|
+
*/
|
|
607
|
+
_parseResponses(responses, specData, memoCache) {
|
|
608
|
+
// Find first 2xx response
|
|
609
|
+
for (const [statusCode, response] of Object.entries(responses)) {
|
|
610
|
+
if (statusCode.startsWith('2') && typeof response === 'object' && response !== null) {
|
|
611
|
+
if (this._specVersion === 'swagger_2') {
|
|
612
|
+
// Swagger 2.0: schema is directly in response
|
|
613
|
+
if (response.schema) {
|
|
614
|
+
// Resolve the schema if it contains $refs
|
|
615
|
+
return this._resolveRefs(response.schema, specData, [], memoCache);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
// OpenAPI 3.x: schema is in content.application/json
|
|
620
|
+
const content = response.content || {};
|
|
621
|
+
const jsonContent = content['application/json'];
|
|
622
|
+
if (jsonContent && jsonContent.schema) {
|
|
623
|
+
// Resolve the schema if it contains $refs
|
|
624
|
+
return this._resolveRefs(jsonContent.schema, specData, [], memoCache);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return undefined;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Get tools filtered by tag.
|
|
633
|
+
*/
|
|
634
|
+
getToolsByTag(apiSpec, tag) {
|
|
635
|
+
return apiSpec.tools.filter(tool => tool.tags && tool.tags.includes(tag));
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Filter tools to only include those whose first resource segment matches includeResources.
|
|
639
|
+
*/
|
|
640
|
+
_filterToolsByResources(tools, includeResources, pathPrefix) {
|
|
641
|
+
if (!includeResources || includeResources.length === 0) {
|
|
642
|
+
return tools;
|
|
643
|
+
}
|
|
644
|
+
// Normalize resource names to lowercase for case-insensitive matching
|
|
645
|
+
const normalizedResources = includeResources.map(r => r.toLowerCase());
|
|
646
|
+
return tools.filter(tool => {
|
|
647
|
+
let path = tool.path;
|
|
648
|
+
// Strip path prefix if provided
|
|
649
|
+
if (pathPrefix) {
|
|
650
|
+
const prefixLower = pathPrefix.toLowerCase();
|
|
651
|
+
const pathLower = path.toLowerCase();
|
|
652
|
+
if (pathLower.startsWith(prefixLower)) {
|
|
653
|
+
path = path.substring(pathPrefix.length);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
// Extract path segments by splitting on both '/' and '.'
|
|
657
|
+
const pathLower = path.toLowerCase();
|
|
658
|
+
// Replace dots with slashes for uniform splitting
|
|
659
|
+
const pathNormalized = pathLower.replace(/\./g, '/');
|
|
660
|
+
// Split by '/' and filter out empty segments and parameter placeholders
|
|
661
|
+
const segments = pathNormalized.split('/').filter(seg => seg && !seg.startsWith('{'));
|
|
662
|
+
// Check if the first segment matches any of the includeResources
|
|
663
|
+
return segments.length > 0 && normalizedResources.includes(segments[0]);
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Search tools by name or description.
|
|
668
|
+
*/
|
|
669
|
+
searchTools(apiSpec, query) {
|
|
670
|
+
const lowerQuery = query.toLowerCase();
|
|
671
|
+
return apiSpec.tools.filter(tool => tool.name.toLowerCase().includes(lowerQuery) ||
|
|
672
|
+
tool.description.toLowerCase().includes(lowerQuery));
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Generate human-readable tool documentation.
|
|
676
|
+
*/
|
|
677
|
+
generateToolDocumentation(tool) {
|
|
678
|
+
const docLines = [
|
|
679
|
+
`## ${tool.name}`,
|
|
680
|
+
`**Method:** ${tool.method}`,
|
|
681
|
+
`**Path:** ${tool.path}`,
|
|
682
|
+
`**Description:** ${tool.description}`,
|
|
683
|
+
''
|
|
684
|
+
];
|
|
685
|
+
if (Object.keys(tool.parameters).length > 0) {
|
|
686
|
+
docLines.push('### Parameters:');
|
|
687
|
+
for (const [paramName, paramInfo] of Object.entries(tool.parameters)) {
|
|
688
|
+
const required = paramInfo.required ? ' (required)' : ' (optional)';
|
|
689
|
+
const location = ` [${paramInfo.location || 'query'}]`;
|
|
690
|
+
docLines.push(`- **${paramName}**${required}${location}: ${paramInfo.description || ''}`);
|
|
691
|
+
}
|
|
692
|
+
docLines.push('');
|
|
693
|
+
}
|
|
694
|
+
if (tool.tags && tool.tags.length > 0) {
|
|
695
|
+
docLines.push(`**Tags:** ${tool.tags.join(', ')}`);
|
|
696
|
+
docLines.push('');
|
|
697
|
+
}
|
|
698
|
+
return docLines.join('\n');
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Clear the discovery cache.
|
|
702
|
+
*/
|
|
703
|
+
clearCache() {
|
|
704
|
+
this.cache.clear();
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
//# sourceMappingURL=schema_discovery.js.map
|