@opengeni/capabilities 0.1.1
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 +190 -0
- package/README.md +23 -0
- package/THIRD_PARTY_NOTICES +28 -0
- package/dist/auth.d.ts +3 -0
- package/dist/graphql.d.ts +52 -0
- package/dist/http.d.ts +15 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +1944 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-manifest.d.ts +25 -0
- package/dist/openapi.d.ts +69 -0
- package/dist/providers.d.ts +44 -0
- package/dist/revision.d.ts +4 -0
- package/dist/types.d.ts +94 -0
- package/package.json +46 -0
- package/src/auth.ts +171 -0
- package/src/graphql.ts +625 -0
- package/src/http.ts +131 -0
- package/src/index.ts +8 -0
- package/src/mcp-manifest.ts +90 -0
- package/src/openapi.ts +846 -0
- package/src/providers.ts +559 -0
- package/src/revision.ts +40 -0
- package/src/types.ts +126 -0
package/src/openapi.ts
ADDED
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
import type { CallToolResultContent, MCPCallToolOptions, MCPServer } from "@openai/agents";
|
|
2
|
+
import { load as parseYaml } from "js-yaml";
|
|
3
|
+
|
|
4
|
+
import { applyCredentialPlacements } from "./auth";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_INTEGRATION_RESPONSE_BYTES,
|
|
7
|
+
DEFAULT_INTEGRATION_TIMEOUT_MS,
|
|
8
|
+
MAX_INTEGRATION_SPEC_BYTES,
|
|
9
|
+
MAX_INTEGRATION_TOOLS,
|
|
10
|
+
fetchWithDeadline,
|
|
11
|
+
readIntegrationResponse,
|
|
12
|
+
} from "./http";
|
|
13
|
+
import { canonicalJson, immutableRevisionId, sha256Hex, stableToolId } from "./revision";
|
|
14
|
+
import type {
|
|
15
|
+
IntegrationCredentialResolver,
|
|
16
|
+
IntegrationInvocationAuthority,
|
|
17
|
+
IntegrationRevision,
|
|
18
|
+
IntegrationToolDefinition,
|
|
19
|
+
IntegrationTransport,
|
|
20
|
+
JsonSchema,
|
|
21
|
+
} from "./types";
|
|
22
|
+
import { IntegrationInvocationError, IntegrationProtocolError } from "./types";
|
|
23
|
+
|
|
24
|
+
export type OpenApiHttpMethod =
|
|
25
|
+
| "get"
|
|
26
|
+
| "put"
|
|
27
|
+
| "post"
|
|
28
|
+
| "delete"
|
|
29
|
+
| "patch"
|
|
30
|
+
| "head"
|
|
31
|
+
| "options"
|
|
32
|
+
| "trace";
|
|
33
|
+
|
|
34
|
+
export interface OpenApiParameterBinding {
|
|
35
|
+
readonly name: string;
|
|
36
|
+
readonly location: "path" | "query" | "header" | "cookie";
|
|
37
|
+
readonly required: boolean;
|
|
38
|
+
readonly schema: JsonSchema;
|
|
39
|
+
readonly description?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface OpenApiOperationBinding {
|
|
43
|
+
readonly method: OpenApiHttpMethod;
|
|
44
|
+
readonly pathTemplate: string;
|
|
45
|
+
readonly serverUrl: string;
|
|
46
|
+
readonly parameters: readonly OpenApiParameterBinding[];
|
|
47
|
+
readonly requestBody?: {
|
|
48
|
+
readonly required: boolean;
|
|
49
|
+
readonly contentTypes: readonly string[];
|
|
50
|
+
readonly schemas: Readonly<Record<string, JsonSchema>>;
|
|
51
|
+
};
|
|
52
|
+
readonly requiredScopeAlternatives?: readonly (readonly string[])[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type OpenApiRevision = IntegrationRevision<OpenApiOperationBinding, "openapi">;
|
|
56
|
+
|
|
57
|
+
export interface CompileOpenApiOptions {
|
|
58
|
+
readonly integrationId: string;
|
|
59
|
+
readonly sourceUrl?: string;
|
|
60
|
+
readonly baseUrl?: string;
|
|
61
|
+
readonly provider?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface OpenApiServerOptions {
|
|
65
|
+
readonly revision: OpenApiRevision;
|
|
66
|
+
readonly transport: IntegrationTransport;
|
|
67
|
+
readonly credentialResolver?: IntegrationCredentialResolver;
|
|
68
|
+
readonly authority: IntegrationInvocationAuthority;
|
|
69
|
+
readonly timeoutMs?: number;
|
|
70
|
+
readonly maxResponseBytes?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type OpenApiAuthDiscovery =
|
|
74
|
+
| { kind: "none" }
|
|
75
|
+
| {
|
|
76
|
+
kind: "oauth2";
|
|
77
|
+
scopes: string[];
|
|
78
|
+
}
|
|
79
|
+
| {
|
|
80
|
+
kind: "api_key";
|
|
81
|
+
carrier: "header" | "query" | "cookie";
|
|
82
|
+
name: string;
|
|
83
|
+
}
|
|
84
|
+
| { kind: "http"; scheme: string };
|
|
85
|
+
|
|
86
|
+
type LocalMcpTool = Awaited<ReturnType<MCPServer["listTools"]>>[number];
|
|
87
|
+
|
|
88
|
+
const methods = new Set<OpenApiHttpMethod>([
|
|
89
|
+
"get",
|
|
90
|
+
"put",
|
|
91
|
+
"post",
|
|
92
|
+
"delete",
|
|
93
|
+
"patch",
|
|
94
|
+
"head",
|
|
95
|
+
"options",
|
|
96
|
+
"trace",
|
|
97
|
+
]);
|
|
98
|
+
const forbiddenParameterHeaders = new Set([
|
|
99
|
+
"host",
|
|
100
|
+
"content-length",
|
|
101
|
+
"connection",
|
|
102
|
+
"transfer-encoding",
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
export function parseOpenApiDocument(source: string | Uint8Array): Record<string, unknown> {
|
|
106
|
+
const bytes = typeof source === "string" ? Buffer.byteLength(source) : source.byteLength;
|
|
107
|
+
if (bytes === 0 || bytes > MAX_INTEGRATION_SPEC_BYTES) {
|
|
108
|
+
throw new IntegrationProtocolError(
|
|
109
|
+
"openapi_spec_size",
|
|
110
|
+
`OpenAPI document must be between 1 and ${MAX_INTEGRATION_SPEC_BYTES} bytes`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const text =
|
|
114
|
+
typeof source === "string" ? source : new TextDecoder("utf-8", { fatal: true }).decode(source);
|
|
115
|
+
let parsed: unknown;
|
|
116
|
+
try {
|
|
117
|
+
parsed = parseYaml(text, { json: true });
|
|
118
|
+
} catch {
|
|
119
|
+
throw new IntegrationProtocolError(
|
|
120
|
+
"openapi_parse",
|
|
121
|
+
"OpenAPI document is not valid JSON or YAML",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (
|
|
125
|
+
!isRecord(parsed) ||
|
|
126
|
+
typeof parsed.openapi !== "string" ||
|
|
127
|
+
!/^3\.(?:0|1)(?:\.|$)/.test(parsed.openapi)
|
|
128
|
+
) {
|
|
129
|
+
throw new IntegrationProtocolError(
|
|
130
|
+
"openapi_version",
|
|
131
|
+
"Only OpenAPI 3.0 and 3.1 documents are supported",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (!isRecord(parsed.paths)) {
|
|
135
|
+
throw new IntegrationProtocolError("openapi_paths", "OpenAPI document has no paths object");
|
|
136
|
+
}
|
|
137
|
+
return parsed;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function compileOpenApiRevision(
|
|
141
|
+
source: string | Uint8Array | Record<string, unknown>,
|
|
142
|
+
options: CompileOpenApiOptions,
|
|
143
|
+
): OpenApiRevision {
|
|
144
|
+
const document = isRecord(source) ? source : parseOpenApiDocument(source);
|
|
145
|
+
const contentSha256 = sha256Hex(canonicalJson(document));
|
|
146
|
+
const revisionId = immutableRevisionId("openapi", contentSha256);
|
|
147
|
+
const info = isRecord(document.info) ? document.info : {};
|
|
148
|
+
const documentServers = readServers(document.servers, options.baseUrl, options.sourceUrl);
|
|
149
|
+
const documentSecurity = readSecurity(document.security);
|
|
150
|
+
const tools: IntegrationToolDefinition[] = [];
|
|
151
|
+
const bindings: Record<string, OpenApiOperationBinding> = {};
|
|
152
|
+
const seen = new Map<string, number>();
|
|
153
|
+
|
|
154
|
+
for (const [pathTemplate, rawPathItem] of Object.entries(
|
|
155
|
+
document.paths as Record<string, unknown>,
|
|
156
|
+
)) {
|
|
157
|
+
const pathItem = resolveObject(document, rawPathItem, "path item");
|
|
158
|
+
const sharedParameters = readParameters(document, pathItem.parameters);
|
|
159
|
+
const pathServers = readServers(pathItem.servers, undefined, undefined);
|
|
160
|
+
for (const [rawMethod, rawOperation] of Object.entries(pathItem)) {
|
|
161
|
+
const method = rawMethod.toLowerCase() as OpenApiHttpMethod;
|
|
162
|
+
if (!methods.has(method) || !isRecord(rawOperation)) continue;
|
|
163
|
+
const operation = resolveObject(document, rawOperation, "operation");
|
|
164
|
+
const operationKey = operationIdentity(method, pathTemplate, operation.operationId);
|
|
165
|
+
const id = stableToolId(operationKey, seen);
|
|
166
|
+
const parameters = mergeParameters(
|
|
167
|
+
sharedParameters,
|
|
168
|
+
readParameters(document, operation.parameters),
|
|
169
|
+
);
|
|
170
|
+
const requestBody = readRequestBody(document, operation.requestBody);
|
|
171
|
+
const serverUrl = firstServerUrl(
|
|
172
|
+
readServers(operation.servers, undefined, undefined),
|
|
173
|
+
pathServers,
|
|
174
|
+
documentServers,
|
|
175
|
+
);
|
|
176
|
+
const requiredScopeAlternatives =
|
|
177
|
+
operation.security === undefined ? documentSecurity : readSecurity(operation.security);
|
|
178
|
+
const safety = classifyHttpSafety(method, operation);
|
|
179
|
+
const inputSchema = operationInputSchema(parameters, requestBody);
|
|
180
|
+
const outputSchema = operationOutputSchema(document, operation.responses);
|
|
181
|
+
const summary = stringValue(operation.summary) ?? stringValue(operation.description);
|
|
182
|
+
tools.push({
|
|
183
|
+
id,
|
|
184
|
+
operationKey,
|
|
185
|
+
name: summary ?? `${method.toUpperCase()} ${pathTemplate}`,
|
|
186
|
+
description: toolDescription(method, pathTemplate, operation, safety),
|
|
187
|
+
inputSchema,
|
|
188
|
+
...(outputSchema ? { outputSchema } : {}),
|
|
189
|
+
safety,
|
|
190
|
+
approvalMode: safety === "read" ? "never" : "ask",
|
|
191
|
+
deprecated: operation.deprecated === true,
|
|
192
|
+
});
|
|
193
|
+
bindings[id] = {
|
|
194
|
+
method,
|
|
195
|
+
pathTemplate,
|
|
196
|
+
serverUrl,
|
|
197
|
+
parameters,
|
|
198
|
+
...(requestBody ? { requestBody } : {}),
|
|
199
|
+
...(requiredScopeAlternatives.length > 0 ? { requiredScopeAlternatives } : {}),
|
|
200
|
+
};
|
|
201
|
+
if (tools.length > MAX_INTEGRATION_TOOLS) {
|
|
202
|
+
throw new IntegrationProtocolError(
|
|
203
|
+
"openapi_tool_limit",
|
|
204
|
+
`OpenAPI document exceeds the ${MAX_INTEGRATION_TOOLS}-tool limit`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (tools.length === 0) {
|
|
210
|
+
throw new IntegrationProtocolError("openapi_empty", "OpenAPI document exposes no operations");
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
id: revisionId,
|
|
214
|
+
protocol: "openapi",
|
|
215
|
+
integrationId: options.integrationId,
|
|
216
|
+
contentSha256,
|
|
217
|
+
source: {
|
|
218
|
+
...(options.sourceUrl ? { url: options.sourceUrl } : {}),
|
|
219
|
+
...(options.provider ? { provider: options.provider } : {}),
|
|
220
|
+
},
|
|
221
|
+
title: stringValue(info.title) ?? options.integrationId,
|
|
222
|
+
...(stringValue(info.description) ? { description: stringValue(info.description)! } : {}),
|
|
223
|
+
...(stringValue(info.version) ? { version: stringValue(info.version)! } : {}),
|
|
224
|
+
tools,
|
|
225
|
+
bindings,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function discoverOpenApiAuth(document: Record<string, unknown>): OpenApiAuthDiscovery {
|
|
230
|
+
const components = isRecord(document.components) ? document.components : {};
|
|
231
|
+
const schemes = isRecord(components.securitySchemes) ? components.securitySchemes : {};
|
|
232
|
+
for (const raw of Object.values(schemes)) {
|
|
233
|
+
const scheme = resolveObject(document, raw, "security scheme");
|
|
234
|
+
if (scheme.type === "oauth2") {
|
|
235
|
+
const flows = isRecord(scheme.flows) ? scheme.flows : {};
|
|
236
|
+
const scopes = new Set<string>();
|
|
237
|
+
for (const flow of Object.values(flows)) {
|
|
238
|
+
if (!isRecord(flow) || !isRecord(flow.scopes)) continue;
|
|
239
|
+
for (const scope of Object.keys(flow.scopes)) scopes.add(scope);
|
|
240
|
+
}
|
|
241
|
+
return { kind: "oauth2", scopes: [...scopes].sort() };
|
|
242
|
+
}
|
|
243
|
+
if (
|
|
244
|
+
scheme.type === "apiKey" &&
|
|
245
|
+
(scheme.in === "header" || scheme.in === "query" || scheme.in === "cookie") &&
|
|
246
|
+
typeof scheme.name === "string" &&
|
|
247
|
+
scheme.name.length > 0
|
|
248
|
+
) {
|
|
249
|
+
return { kind: "api_key", carrier: scheme.in, name: scheme.name };
|
|
250
|
+
}
|
|
251
|
+
if (scheme.type === "http" && typeof scheme.scheme === "string") {
|
|
252
|
+
return { kind: "http", scheme: scheme.scheme.toLowerCase() };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { kind: "none" };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export class OpenApiMcpServer implements MCPServer {
|
|
259
|
+
readonly cacheToolsList = true;
|
|
260
|
+
readonly useStructuredContent = true;
|
|
261
|
+
readonly name: string;
|
|
262
|
+
|
|
263
|
+
constructor(private readonly options: OpenApiServerOptions) {
|
|
264
|
+
this.name = `openapi:${stableToolId(options.revision.integrationId)}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async connect(): Promise<void> {}
|
|
268
|
+
async close(): Promise<void> {}
|
|
269
|
+
async invalidateToolsCache(): Promise<void> {}
|
|
270
|
+
|
|
271
|
+
async listTools(): Promise<LocalMcpTool[]> {
|
|
272
|
+
return this.options.revision.tools.map(
|
|
273
|
+
(tool) =>
|
|
274
|
+
({
|
|
275
|
+
name: tool.id,
|
|
276
|
+
description: tool.description,
|
|
277
|
+
inputSchema: normalizeMcpSchema(tool.inputSchema),
|
|
278
|
+
annotations: {
|
|
279
|
+
readOnlyHint: tool.safety === "read",
|
|
280
|
+
destructiveHint: tool.safety === "destructive",
|
|
281
|
+
idempotentHint: isIdempotentMethod(this.options.revision.bindings[tool.id]?.method),
|
|
282
|
+
openWorldHint: true,
|
|
283
|
+
},
|
|
284
|
+
_meta: {
|
|
285
|
+
"opengeni/approvalMode": tool.approvalMode,
|
|
286
|
+
"opengeni/operationKey": tool.operationKey,
|
|
287
|
+
"opengeni/revisionId": this.options.revision.id,
|
|
288
|
+
},
|
|
289
|
+
}) as LocalMcpTool,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async callTool(
|
|
294
|
+
toolName: string,
|
|
295
|
+
args: Record<string, unknown> | null,
|
|
296
|
+
_meta?: Record<string, unknown> | null,
|
|
297
|
+
callOptions?: MCPCallToolOptions,
|
|
298
|
+
): Promise<CallToolResultContent> {
|
|
299
|
+
const result = await invokeOpenApiOperation(
|
|
300
|
+
this.options,
|
|
301
|
+
toolName,
|
|
302
|
+
args ?? {},
|
|
303
|
+
callOptions?.signal,
|
|
304
|
+
);
|
|
305
|
+
const content = [
|
|
306
|
+
{
|
|
307
|
+
type: "text" as const,
|
|
308
|
+
text: JSON.stringify(result),
|
|
309
|
+
},
|
|
310
|
+
] as CallToolResultContent;
|
|
311
|
+
content.structuredContent = result as Record<string, unknown>;
|
|
312
|
+
content.isError = result.ok === false;
|
|
313
|
+
return content;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function createOpenApiMcpServer(options: OpenApiServerOptions): MCPServer {
|
|
318
|
+
return new OpenApiMcpServer(options);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function invokeOpenApiOperation(
|
|
322
|
+
options: OpenApiServerOptions,
|
|
323
|
+
toolId: string,
|
|
324
|
+
args: Record<string, unknown>,
|
|
325
|
+
signal?: AbortSignal,
|
|
326
|
+
): Promise<Record<string, unknown>> {
|
|
327
|
+
const binding = options.revision.bindings[toolId];
|
|
328
|
+
if (!binding) {
|
|
329
|
+
throw new IntegrationInvocationError(
|
|
330
|
+
"operation_not_found",
|
|
331
|
+
"Integration operation is not present in the frozen revision",
|
|
332
|
+
"not_started",
|
|
333
|
+
false,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
const firstCredential = await resolveOpenApiCredential(options, binding, toolId, args, false);
|
|
337
|
+
let response = await sendOpenApiRequest(options, binding, args, firstCredential, signal);
|
|
338
|
+
if (response.status === 401 && options.credentialResolver && options.authority.connectionRef) {
|
|
339
|
+
const refreshed = await resolveOpenApiCredential(options, binding, toolId, args, true);
|
|
340
|
+
if (isReplaySafeMethod(binding.method) && refreshed) {
|
|
341
|
+
await response.body?.cancel().catch(() => undefined);
|
|
342
|
+
response = await sendOpenApiRequest(options, binding, args, refreshed, signal);
|
|
343
|
+
} else {
|
|
344
|
+
await response.body?.cancel().catch(() => undefined);
|
|
345
|
+
throw new IntegrationInvocationError(
|
|
346
|
+
"authorization_rejected",
|
|
347
|
+
"The connected account is no longer authorized for this operation",
|
|
348
|
+
isReplaySafeMethod(binding.method) ? "failed" : "unknown",
|
|
349
|
+
false,
|
|
350
|
+
response.status,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (response.status >= 300 && response.status < 400) {
|
|
355
|
+
await response.body?.cancel().catch(() => undefined);
|
|
356
|
+
throw new IntegrationInvocationError(
|
|
357
|
+
"redirect_rejected",
|
|
358
|
+
"Integration attempted to redirect a credential-bearing request",
|
|
359
|
+
binding.method === "get" || binding.method === "head" ? "failed" : "unknown",
|
|
360
|
+
false,
|
|
361
|
+
response.status,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
const payload = await readIntegrationResponse(
|
|
365
|
+
response,
|
|
366
|
+
options.maxResponseBytes ?? DEFAULT_INTEGRATION_RESPONSE_BYTES,
|
|
367
|
+
);
|
|
368
|
+
const result = {
|
|
369
|
+
ok: response.ok,
|
|
370
|
+
status: response.status,
|
|
371
|
+
contentType: payload.contentType,
|
|
372
|
+
data: payload.data,
|
|
373
|
+
};
|
|
374
|
+
if (!response.ok && (response.status === 401 || response.status === 403)) {
|
|
375
|
+
throw new IntegrationInvocationError(
|
|
376
|
+
"authorization_rejected",
|
|
377
|
+
"The connected account is no longer authorized for this operation",
|
|
378
|
+
binding.method === "get" || binding.method === "head" ? "failed" : "unknown",
|
|
379
|
+
false,
|
|
380
|
+
response.status,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
return result;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function resolveOpenApiCredential(
|
|
387
|
+
options: OpenApiServerOptions,
|
|
388
|
+
binding: OpenApiOperationBinding,
|
|
389
|
+
toolId: string,
|
|
390
|
+
args: Record<string, unknown>,
|
|
391
|
+
forceRefresh: boolean,
|
|
392
|
+
): Promise<Awaited<ReturnType<IntegrationCredentialResolver["resolve"]>>> {
|
|
393
|
+
if (!options.credentialResolver || !options.authority.connectionRef) return null;
|
|
394
|
+
const destinationUrl = buildOperationUrl(binding, args).toString();
|
|
395
|
+
const credential = await options.credentialResolver.resolve({
|
|
396
|
+
...options.authority,
|
|
397
|
+
protocol: "openapi",
|
|
398
|
+
integrationId: options.revision.integrationId,
|
|
399
|
+
revisionId: options.revision.id,
|
|
400
|
+
operationKey: toolId,
|
|
401
|
+
destinationUrl,
|
|
402
|
+
...(binding.requiredScopeAlternatives
|
|
403
|
+
? { requiredScopeAlternatives: binding.requiredScopeAlternatives }
|
|
404
|
+
: {}),
|
|
405
|
+
...(forceRefresh ? { forceRefresh: true } : {}),
|
|
406
|
+
});
|
|
407
|
+
if (!credential && !forceRefresh) {
|
|
408
|
+
throw new IntegrationInvocationError(
|
|
409
|
+
"connection_required",
|
|
410
|
+
"This integration needs a connected account",
|
|
411
|
+
"not_started",
|
|
412
|
+
false,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
return credential;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function sendOpenApiRequest(
|
|
419
|
+
options: OpenApiServerOptions,
|
|
420
|
+
binding: OpenApiOperationBinding,
|
|
421
|
+
args: Record<string, unknown>,
|
|
422
|
+
credential: Awaited<ReturnType<IntegrationCredentialResolver["resolve"]>>,
|
|
423
|
+
signal?: AbortSignal,
|
|
424
|
+
): Promise<Response> {
|
|
425
|
+
const url = buildOperationUrl(binding, args);
|
|
426
|
+
const headers = buildOperationHeaders(binding, args);
|
|
427
|
+
const body = buildOperationBody(binding, args, headers);
|
|
428
|
+
if (credential) applyCredentialPlacements(url, headers, credential);
|
|
429
|
+
return await fetchWithDeadline(
|
|
430
|
+
options.transport,
|
|
431
|
+
url,
|
|
432
|
+
{
|
|
433
|
+
method: binding.method.toUpperCase(),
|
|
434
|
+
headers,
|
|
435
|
+
...(body !== undefined ? { body } : {}),
|
|
436
|
+
...(signal ? { signal } : {}),
|
|
437
|
+
},
|
|
438
|
+
options.timeoutMs ?? DEFAULT_INTEGRATION_TIMEOUT_MS,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function readServers(
|
|
443
|
+
value: unknown,
|
|
444
|
+
explicitBaseUrl: string | undefined,
|
|
445
|
+
sourceUrl: string | undefined,
|
|
446
|
+
): string[] {
|
|
447
|
+
if (explicitBaseUrl) return [normalizeServerUrl(explicitBaseUrl)];
|
|
448
|
+
const servers = Array.isArray(value)
|
|
449
|
+
? value.flatMap((entry): string[] =>
|
|
450
|
+
isRecord(entry) && typeof entry.url === "string"
|
|
451
|
+
? [resolveServerUrl(entry.url, sourceUrl)]
|
|
452
|
+
: [],
|
|
453
|
+
)
|
|
454
|
+
: [];
|
|
455
|
+
if (servers.length > 0) return servers;
|
|
456
|
+
if (sourceUrl && URL.canParse(sourceUrl)) {
|
|
457
|
+
const source = new URL(sourceUrl);
|
|
458
|
+
return [`${source.origin}/`];
|
|
459
|
+
}
|
|
460
|
+
return [];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function firstServerUrl(...groups: readonly string[][]): string {
|
|
464
|
+
const server = groups.flat().find(Boolean);
|
|
465
|
+
if (!server) {
|
|
466
|
+
throw new IntegrationProtocolError(
|
|
467
|
+
"openapi_server_missing",
|
|
468
|
+
"OpenAPI operation has no resolvable server URL",
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
return server;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function resolveServerUrl(value: string, sourceUrl?: string): string {
|
|
475
|
+
if (/[{}]/.test(value)) {
|
|
476
|
+
throw new IntegrationProtocolError(
|
|
477
|
+
"openapi_server_variable",
|
|
478
|
+
"OpenAPI server variables require an explicit resolved base URL",
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
try {
|
|
482
|
+
return normalizeServerUrl(sourceUrl ? new URL(value, sourceUrl).toString() : value);
|
|
483
|
+
} catch {
|
|
484
|
+
throw new IntegrationProtocolError("openapi_server_invalid", "OpenAPI server URL is invalid");
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function normalizeServerUrl(value: string): string {
|
|
489
|
+
const url = new URL(value);
|
|
490
|
+
if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.hash) {
|
|
491
|
+
throw new IntegrationProtocolError("openapi_server_invalid", "OpenAPI server URL is invalid");
|
|
492
|
+
}
|
|
493
|
+
return url.toString();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function readParameters(
|
|
497
|
+
document: Record<string, unknown>,
|
|
498
|
+
value: unknown,
|
|
499
|
+
): OpenApiParameterBinding[] {
|
|
500
|
+
if (!Array.isArray(value)) return [];
|
|
501
|
+
return value.flatMap((raw): OpenApiParameterBinding[] => {
|
|
502
|
+
const parameter = resolveObject(document, raw, "parameter");
|
|
503
|
+
const location = parameter.in;
|
|
504
|
+
if (
|
|
505
|
+
typeof parameter.name !== "string" ||
|
|
506
|
+
(location !== "path" &&
|
|
507
|
+
location !== "query" &&
|
|
508
|
+
location !== "header" &&
|
|
509
|
+
location !== "cookie")
|
|
510
|
+
) {
|
|
511
|
+
return [];
|
|
512
|
+
}
|
|
513
|
+
if (location === "header" && forbiddenParameterHeaders.has(parameter.name.toLowerCase()))
|
|
514
|
+
return [];
|
|
515
|
+
return [
|
|
516
|
+
{
|
|
517
|
+
name: parameter.name,
|
|
518
|
+
location,
|
|
519
|
+
required: location === "path" || parameter.required === true,
|
|
520
|
+
schema: dereferenceSchema(document, parameter.schema),
|
|
521
|
+
...(stringValue(parameter.description)
|
|
522
|
+
? { description: stringValue(parameter.description)! }
|
|
523
|
+
: {}),
|
|
524
|
+
},
|
|
525
|
+
];
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function mergeParameters(
|
|
530
|
+
base: readonly OpenApiParameterBinding[],
|
|
531
|
+
override: readonly OpenApiParameterBinding[],
|
|
532
|
+
): OpenApiParameterBinding[] {
|
|
533
|
+
const merged = new Map(base.map((entry) => [`${entry.location}:${entry.name}`, entry]));
|
|
534
|
+
for (const entry of override) merged.set(`${entry.location}:${entry.name}`, entry);
|
|
535
|
+
return [...merged.values()];
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function readRequestBody(
|
|
539
|
+
document: Record<string, unknown>,
|
|
540
|
+
value: unknown,
|
|
541
|
+
): OpenApiOperationBinding["requestBody"] | undefined {
|
|
542
|
+
if (value === undefined) return undefined;
|
|
543
|
+
const body = resolveObject(document, value, "request body");
|
|
544
|
+
if (!isRecord(body.content)) return undefined;
|
|
545
|
+
const schemas: Record<string, JsonSchema> = {};
|
|
546
|
+
for (const [contentType, rawMedia] of Object.entries(body.content)) {
|
|
547
|
+
if (!isRecord(rawMedia)) continue;
|
|
548
|
+
schemas[contentType.toLowerCase()] = dereferenceSchema(document, rawMedia.schema);
|
|
549
|
+
}
|
|
550
|
+
const contentTypes = Object.keys(schemas);
|
|
551
|
+
return contentTypes.length === 0
|
|
552
|
+
? undefined
|
|
553
|
+
: { required: body.required === true, contentTypes, schemas };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function operationInputSchema(
|
|
557
|
+
parameters: readonly OpenApiParameterBinding[],
|
|
558
|
+
body: OpenApiOperationBinding["requestBody"],
|
|
559
|
+
): JsonSchema {
|
|
560
|
+
const properties: Record<string, unknown> = {};
|
|
561
|
+
const required: string[] = [];
|
|
562
|
+
for (const location of ["path", "query", "header", "cookie"] as const) {
|
|
563
|
+
const group = parameters.filter((entry) => entry.location === location);
|
|
564
|
+
if (group.length === 0) continue;
|
|
565
|
+
properties[location] = {
|
|
566
|
+
type: "object",
|
|
567
|
+
properties: Object.fromEntries(
|
|
568
|
+
group.map((entry) => [
|
|
569
|
+
entry.name,
|
|
570
|
+
{ ...entry.schema, ...(entry.description ? { description: entry.description } : {}) },
|
|
571
|
+
]),
|
|
572
|
+
),
|
|
573
|
+
required: group.filter((entry) => entry.required).map((entry) => entry.name),
|
|
574
|
+
additionalProperties: false,
|
|
575
|
+
};
|
|
576
|
+
if (group.some((entry) => entry.required)) required.push(location);
|
|
577
|
+
}
|
|
578
|
+
if (body) {
|
|
579
|
+
properties.body = body.schemas[body.contentTypes[0]!] ?? {};
|
|
580
|
+
if (body.contentTypes.length > 1) {
|
|
581
|
+
properties.contentType = { type: "string", enum: body.contentTypes };
|
|
582
|
+
}
|
|
583
|
+
if (body.required) required.push("body");
|
|
584
|
+
}
|
|
585
|
+
return { type: "object", properties, required, additionalProperties: false };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function operationOutputSchema(
|
|
589
|
+
document: Record<string, unknown>,
|
|
590
|
+
value: unknown,
|
|
591
|
+
): JsonSchema | undefined {
|
|
592
|
+
if (!isRecord(value)) return undefined;
|
|
593
|
+
for (const status of ["200", "201", "202", "203", "204", "default"]) {
|
|
594
|
+
if (!(status in value)) continue;
|
|
595
|
+
const response = resolveObject(document, value[status], "response");
|
|
596
|
+
if (!isRecord(response.content)) return undefined;
|
|
597
|
+
for (const media of Object.values(response.content)) {
|
|
598
|
+
if (isRecord(media) && media.schema !== undefined) {
|
|
599
|
+
return dereferenceSchema(document, media.schema);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return undefined;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function readSecurity(value: unknown): readonly (readonly string[])[] {
|
|
607
|
+
if (!Array.isArray(value)) return [];
|
|
608
|
+
return value.flatMap((entry): string[][] => {
|
|
609
|
+
if (!isRecord(entry)) return [];
|
|
610
|
+
const scopes = Object.values(entry).flatMap((raw) =>
|
|
611
|
+
Array.isArray(raw) ? raw.filter((scope): scope is string => typeof scope === "string") : [],
|
|
612
|
+
);
|
|
613
|
+
return scopes.length > 0 ? [[...new Set(scopes)].sort()] : [];
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function classifyHttpSafety(
|
|
618
|
+
method: OpenApiHttpMethod,
|
|
619
|
+
operation: Record<string, unknown>,
|
|
620
|
+
): IntegrationToolDefinition["safety"] {
|
|
621
|
+
if (method === "get" || method === "head" || method === "options") return "read";
|
|
622
|
+
const text =
|
|
623
|
+
`${stringValue(operation.operationId) ?? ""} ${stringValue(operation.summary) ?? ""}`.toLowerCase();
|
|
624
|
+
return method === "delete" || /\b(delete|destroy|remove|revoke|cancel|purge)\b/.test(text)
|
|
625
|
+
? "destructive"
|
|
626
|
+
: "write";
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function operationIdentity(method: OpenApiHttpMethod, path: string, operationId: unknown): string {
|
|
630
|
+
return typeof operationId === "string" && operationId.trim()
|
|
631
|
+
? operationId.trim()
|
|
632
|
+
: `${method}_${path}`;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function toolDescription(
|
|
636
|
+
method: OpenApiHttpMethod,
|
|
637
|
+
path: string,
|
|
638
|
+
operation: Record<string, unknown>,
|
|
639
|
+
safety: IntegrationToolDefinition["safety"],
|
|
640
|
+
): string {
|
|
641
|
+
const description = stringValue(operation.description) ?? stringValue(operation.summary);
|
|
642
|
+
const approval =
|
|
643
|
+
safety === "read" ? "Read-only." : "Changes external state and requires approval.";
|
|
644
|
+
return `${description ? `${description.trim()} ` : ""}${method.toUpperCase()} ${path}. ${approval}`.trim();
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function isIdempotentMethod(method: OpenApiHttpMethod | undefined): boolean {
|
|
648
|
+
return (
|
|
649
|
+
method === "get" ||
|
|
650
|
+
method === "head" ||
|
|
651
|
+
method === "options" ||
|
|
652
|
+
method === "put" ||
|
|
653
|
+
method === "delete"
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function isReplaySafeMethod(method: OpenApiHttpMethod): boolean {
|
|
658
|
+
return method === "get" || method === "head" || method === "options";
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function buildOperationUrl(binding: OpenApiOperationBinding, args: Record<string, unknown>): URL {
|
|
662
|
+
const pathArgs = objectValue(args.path);
|
|
663
|
+
const path = binding.pathTemplate.replace(/\{([^}]+)\}/g, (_match, name: string) => {
|
|
664
|
+
const value = pathArgs[name];
|
|
665
|
+
if (value === undefined || value === null) {
|
|
666
|
+
throw new IntegrationInvocationError(
|
|
667
|
+
"path_parameter_missing",
|
|
668
|
+
"A required integration path parameter is missing",
|
|
669
|
+
"not_started",
|
|
670
|
+
false,
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
return encodeURIComponent(scalarString(value));
|
|
674
|
+
});
|
|
675
|
+
const base = new URL(binding.serverUrl);
|
|
676
|
+
const url = new URL(
|
|
677
|
+
path.replace(/^\//, ""),
|
|
678
|
+
base.toString().endsWith("/") ? base : new URL(`${base}/`),
|
|
679
|
+
);
|
|
680
|
+
const query = objectValue(args.query);
|
|
681
|
+
for (const [name, value] of Object.entries(query)) appendQueryValue(url, name, value);
|
|
682
|
+
return url;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function buildOperationHeaders(
|
|
686
|
+
binding: OpenApiOperationBinding,
|
|
687
|
+
args: Record<string, unknown>,
|
|
688
|
+
): Headers {
|
|
689
|
+
const headers = new Headers({ accept: "application/json, text/plain;q=0.9, */*;q=0.5" });
|
|
690
|
+
for (const [name, value] of Object.entries(objectValue(args.header))) {
|
|
691
|
+
if (forbiddenParameterHeaders.has(name.toLowerCase())) continue;
|
|
692
|
+
headers.set(name, scalarString(value));
|
|
693
|
+
}
|
|
694
|
+
const cookies = Object.entries(objectValue(args.cookie)).map(
|
|
695
|
+
([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(scalarString(value))}`,
|
|
696
|
+
);
|
|
697
|
+
if (cookies.length > 0) headers.set("cookie", cookies.join("; "));
|
|
698
|
+
if (binding.requestBody && args.body !== undefined) {
|
|
699
|
+
const requested =
|
|
700
|
+
typeof args.contentType === "string" ? args.contentType.toLowerCase() : undefined;
|
|
701
|
+
const contentType =
|
|
702
|
+
requested && binding.requestBody.contentTypes.includes(requested)
|
|
703
|
+
? requested
|
|
704
|
+
: binding.requestBody.contentTypes[0]!;
|
|
705
|
+
headers.set("content-type", contentType);
|
|
706
|
+
}
|
|
707
|
+
return headers;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function buildOperationBody(
|
|
711
|
+
binding: OpenApiOperationBinding,
|
|
712
|
+
args: Record<string, unknown>,
|
|
713
|
+
headers: Headers,
|
|
714
|
+
): BodyInit | undefined {
|
|
715
|
+
if (!binding.requestBody || args.body === undefined) return undefined;
|
|
716
|
+
const contentType = headers.get("content-type") ?? "application/json";
|
|
717
|
+
if (contentType === "application/x-www-form-urlencoded") {
|
|
718
|
+
const params = new URLSearchParams();
|
|
719
|
+
for (const [key, value] of Object.entries(objectValue(args.body)))
|
|
720
|
+
appendSearchParam(params, key, value);
|
|
721
|
+
return params;
|
|
722
|
+
}
|
|
723
|
+
if (contentType === "application/json" || contentType.endsWith("+json")) {
|
|
724
|
+
return JSON.stringify(args.body);
|
|
725
|
+
}
|
|
726
|
+
if (typeof args.body === "string") return args.body;
|
|
727
|
+
throw new IntegrationInvocationError(
|
|
728
|
+
"request_body_unsupported",
|
|
729
|
+
"This operation requires a text body for the selected content type",
|
|
730
|
+
"not_started",
|
|
731
|
+
false,
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function appendQueryValue(url: URL, name: string, value: unknown): void {
|
|
736
|
+
if (Array.isArray(value)) {
|
|
737
|
+
for (const entry of value) url.searchParams.append(name, scalarString(entry));
|
|
738
|
+
} else if (value !== undefined && value !== null) {
|
|
739
|
+
url.searchParams.append(name, scalarString(value));
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function appendSearchParam(params: URLSearchParams, name: string, value: unknown): void {
|
|
744
|
+
if (Array.isArray(value)) {
|
|
745
|
+
for (const entry of value) params.append(name, scalarString(entry));
|
|
746
|
+
} else if (value !== undefined && value !== null) {
|
|
747
|
+
params.append(name, scalarString(value));
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function scalarString(value: unknown): string {
|
|
752
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
753
|
+
return String(value);
|
|
754
|
+
}
|
|
755
|
+
throw new IntegrationInvocationError(
|
|
756
|
+
"parameter_invalid",
|
|
757
|
+
"Integration parameters must be strings, numbers, booleans, or arrays of them",
|
|
758
|
+
"not_started",
|
|
759
|
+
false,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function objectValue(value: unknown): Record<string, unknown> {
|
|
764
|
+
return isRecord(value) ? value : {};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function resolveObject(
|
|
768
|
+
document: Record<string, unknown>,
|
|
769
|
+
value: unknown,
|
|
770
|
+
label: string,
|
|
771
|
+
): Record<string, unknown> {
|
|
772
|
+
const resolved = resolveLocalRef(document, value);
|
|
773
|
+
if (!isRecord(resolved)) {
|
|
774
|
+
throw new IntegrationProtocolError("openapi_shape", `OpenAPI ${label} is invalid`);
|
|
775
|
+
}
|
|
776
|
+
return resolved;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function resolveLocalRef(document: Record<string, unknown>, value: unknown): unknown {
|
|
780
|
+
if (!isRecord(value) || typeof value.$ref !== "string") return value;
|
|
781
|
+
if (!value.$ref.startsWith("#/")) {
|
|
782
|
+
throw new IntegrationProtocolError(
|
|
783
|
+
"openapi_external_ref",
|
|
784
|
+
"External OpenAPI references are not supported; bundle the document first",
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
return value.$ref
|
|
788
|
+
.slice(2)
|
|
789
|
+
.split("/")
|
|
790
|
+
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
|
|
791
|
+
.reduce<unknown>((current, part) => (isRecord(current) ? current[part] : undefined), document);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function dereferenceSchema(
|
|
795
|
+
document: Record<string, unknown>,
|
|
796
|
+
value: unknown,
|
|
797
|
+
seen = new Set<string>(),
|
|
798
|
+
depth = 0,
|
|
799
|
+
): JsonSchema {
|
|
800
|
+
if (depth > 20) return {};
|
|
801
|
+
if (isRecord(value) && typeof value.$ref === "string") {
|
|
802
|
+
if (seen.has(value.$ref)) return {};
|
|
803
|
+
const nextSeen = new Set(seen).add(value.$ref);
|
|
804
|
+
return dereferenceSchema(document, resolveLocalRef(document, value), nextSeen, depth + 1);
|
|
805
|
+
}
|
|
806
|
+
if (!isRecord(value)) return {};
|
|
807
|
+
const result: Record<string, unknown> = {};
|
|
808
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
809
|
+
if (key === "properties" && isRecord(entry)) {
|
|
810
|
+
result.properties = Object.fromEntries(
|
|
811
|
+
Object.entries(entry).map(([name, schema]) => [
|
|
812
|
+
name,
|
|
813
|
+
dereferenceSchema(document, schema, seen, depth + 1),
|
|
814
|
+
]),
|
|
815
|
+
);
|
|
816
|
+
} else if (key === "items") {
|
|
817
|
+
result.items = dereferenceSchema(document, entry, seen, depth + 1);
|
|
818
|
+
} else if (key === "allOf" || key === "anyOf" || key === "oneOf") {
|
|
819
|
+
result[key] = Array.isArray(entry)
|
|
820
|
+
? entry.map((schema) => dereferenceSchema(document, schema, seen, depth + 1))
|
|
821
|
+
: [];
|
|
822
|
+
} else if (key !== "$ref") {
|
|
823
|
+
result[key] = entry;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
return result;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function normalizeMcpSchema(schema: JsonSchema): LocalMcpTool["inputSchema"] {
|
|
830
|
+
return {
|
|
831
|
+
type: "object",
|
|
832
|
+
properties: isRecord(schema.properties) ? schema.properties : {},
|
|
833
|
+
required: Array.isArray(schema.required)
|
|
834
|
+
? schema.required.filter((entry): entry is string => typeof entry === "string")
|
|
835
|
+
: [],
|
|
836
|
+
additionalProperties: schema.additionalProperties === true,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function stringValue(value: unknown): string | undefined {
|
|
841
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
845
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
846
|
+
}
|