@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/graphql.ts
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import type { CallToolResultContent, MCPCallToolOptions, MCPServer } from "@openai/agents";
|
|
2
|
+
import {
|
|
3
|
+
buildClientSchema,
|
|
4
|
+
getIntrospectionQuery,
|
|
5
|
+
getNamedType,
|
|
6
|
+
isEnumType,
|
|
7
|
+
isInputObjectType,
|
|
8
|
+
isInterfaceType,
|
|
9
|
+
isListType,
|
|
10
|
+
isNonNullType,
|
|
11
|
+
isObjectType,
|
|
12
|
+
isScalarType,
|
|
13
|
+
isUnionType,
|
|
14
|
+
parse,
|
|
15
|
+
type GraphQLInputType,
|
|
16
|
+
type GraphQLNamedType,
|
|
17
|
+
type GraphQLOutputType,
|
|
18
|
+
type IntrospectionQuery,
|
|
19
|
+
} from "graphql";
|
|
20
|
+
|
|
21
|
+
import { applyCredentialPlacements } from "./auth";
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_INTEGRATION_RESPONSE_BYTES,
|
|
24
|
+
DEFAULT_INTEGRATION_TIMEOUT_MS,
|
|
25
|
+
MAX_INTEGRATION_SPEC_BYTES,
|
|
26
|
+
MAX_INTEGRATION_TOOLS,
|
|
27
|
+
fetchWithDeadline,
|
|
28
|
+
readIntegrationResponse,
|
|
29
|
+
} from "./http";
|
|
30
|
+
import { canonicalJson, immutableRevisionId, sha256Hex, stableToolId } from "./revision";
|
|
31
|
+
import type {
|
|
32
|
+
IntegrationCredentialResolver,
|
|
33
|
+
IntegrationInvocationAuthority,
|
|
34
|
+
IntegrationRevision,
|
|
35
|
+
IntegrationTransport,
|
|
36
|
+
JsonSchema,
|
|
37
|
+
} from "./types";
|
|
38
|
+
import { IntegrationInvocationError, IntegrationProtocolError } from "./types";
|
|
39
|
+
|
|
40
|
+
export interface GraphqlOperationBinding {
|
|
41
|
+
readonly kind: "query" | "mutation";
|
|
42
|
+
readonly fieldName: string;
|
|
43
|
+
readonly operationName: string;
|
|
44
|
+
readonly variableDefinitions: readonly string[];
|
|
45
|
+
readonly variableNames: readonly string[];
|
|
46
|
+
readonly defaultSelection?: string;
|
|
47
|
+
readonly selectionAllowed: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type GraphqlRevision = IntegrationRevision<GraphqlOperationBinding, "graphql">;
|
|
51
|
+
|
|
52
|
+
export interface CompileGraphqlOptions {
|
|
53
|
+
readonly integrationId: string;
|
|
54
|
+
readonly endpoint: string;
|
|
55
|
+
readonly name?: string;
|
|
56
|
+
readonly sourceUrl?: string;
|
|
57
|
+
readonly provider?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface GraphqlServerOptions {
|
|
61
|
+
readonly revision: GraphqlRevision;
|
|
62
|
+
readonly endpoint: string;
|
|
63
|
+
readonly transport: IntegrationTransport;
|
|
64
|
+
readonly credentialResolver?: IntegrationCredentialResolver;
|
|
65
|
+
readonly authority: IntegrationInvocationAuthority;
|
|
66
|
+
readonly staticHeaders?: Readonly<Record<string, string>>;
|
|
67
|
+
readonly staticQuery?: Readonly<Record<string, string>>;
|
|
68
|
+
readonly timeoutMs?: number;
|
|
69
|
+
readonly maxResponseBytes?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type LocalMcpTool = Awaited<ReturnType<MCPServer["listTools"]>>[number];
|
|
73
|
+
|
|
74
|
+
export function compileGraphqlRevision(
|
|
75
|
+
introspection: IntrospectionQuery | { readonly data?: IntrospectionQuery } | string,
|
|
76
|
+
options: CompileGraphqlOptions,
|
|
77
|
+
): GraphqlRevision {
|
|
78
|
+
const document = parseIntrospection(introspection);
|
|
79
|
+
let schema;
|
|
80
|
+
try {
|
|
81
|
+
schema = buildClientSchema(document);
|
|
82
|
+
} catch {
|
|
83
|
+
throw new IntegrationProtocolError(
|
|
84
|
+
"graphql_introspection_invalid",
|
|
85
|
+
"GraphQL introspection result cannot build a client schema",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const endpoint = validateGraphqlEndpoint(options.endpoint);
|
|
89
|
+
const contentSha256 = sha256Hex(canonicalJson(document));
|
|
90
|
+
const id = immutableRevisionId("graphql", contentSha256);
|
|
91
|
+
const tools = [] as GraphqlRevision["tools"] extends readonly (infer T)[] ? T[] : never;
|
|
92
|
+
const bindings: Record<string, GraphqlOperationBinding> = {};
|
|
93
|
+
const seen = new Map<string, number>();
|
|
94
|
+
|
|
95
|
+
for (const [kind, root] of [
|
|
96
|
+
["query", schema.getQueryType()],
|
|
97
|
+
["mutation", schema.getMutationType()],
|
|
98
|
+
] as const) {
|
|
99
|
+
if (!root) continue;
|
|
100
|
+
for (const field of Object.values(root.getFields()).sort((left, right) =>
|
|
101
|
+
left.name.localeCompare(right.name),
|
|
102
|
+
)) {
|
|
103
|
+
const toolId = stableToolId(`${kind}_${field.name}`, seen);
|
|
104
|
+
const namedOutput = getNamedType(field.type);
|
|
105
|
+
const selectionAllowed = !isLeafType(namedOutput);
|
|
106
|
+
const defaultSelection = selectionAllowed
|
|
107
|
+
? buildDefaultSelection(field.type, new Set(), 0)
|
|
108
|
+
: undefined;
|
|
109
|
+
const properties: Record<string, unknown> = Object.fromEntries(
|
|
110
|
+
field.args.map((arg) => [
|
|
111
|
+
arg.name,
|
|
112
|
+
{
|
|
113
|
+
...inputTypeSchema(arg.type, new Set(), 0),
|
|
114
|
+
...(arg.description ? { description: arg.description } : {}),
|
|
115
|
+
},
|
|
116
|
+
]),
|
|
117
|
+
);
|
|
118
|
+
if (selectionAllowed) {
|
|
119
|
+
properties.select = {
|
|
120
|
+
type: "string",
|
|
121
|
+
description:
|
|
122
|
+
"Optional GraphQL field selection without outer braces. The default selects safe scalar fields.",
|
|
123
|
+
maxLength: 4_000,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const required = field.args.filter((arg) => isNonNullType(arg.type)).map((arg) => arg.name);
|
|
127
|
+
const description = [
|
|
128
|
+
field.description?.trim(),
|
|
129
|
+
kind === "mutation"
|
|
130
|
+
? "Changes external state and requires approval."
|
|
131
|
+
: "Read-only GraphQL query.",
|
|
132
|
+
]
|
|
133
|
+
.filter(Boolean)
|
|
134
|
+
.join(" ");
|
|
135
|
+
tools.push({
|
|
136
|
+
id: toolId,
|
|
137
|
+
operationKey: `${kind}:${field.name}`,
|
|
138
|
+
name: field.name,
|
|
139
|
+
description,
|
|
140
|
+
inputSchema: {
|
|
141
|
+
type: "object",
|
|
142
|
+
properties,
|
|
143
|
+
required,
|
|
144
|
+
additionalProperties: false,
|
|
145
|
+
},
|
|
146
|
+
safety: kind === "query" ? "read" : "write",
|
|
147
|
+
approvalMode: kind === "query" ? "never" : "ask",
|
|
148
|
+
deprecated: field.deprecationReason != null,
|
|
149
|
+
});
|
|
150
|
+
bindings[toolId] = {
|
|
151
|
+
kind,
|
|
152
|
+
fieldName: field.name,
|
|
153
|
+
operationName: stableGraphqlName(`${kind}_${field.name}`),
|
|
154
|
+
variableDefinitions: field.args.map((arg) => `$${arg.name}: ${String(arg.type)}`),
|
|
155
|
+
variableNames: field.args.map((arg) => arg.name),
|
|
156
|
+
...(defaultSelection ? { defaultSelection } : {}),
|
|
157
|
+
selectionAllowed,
|
|
158
|
+
};
|
|
159
|
+
if (tools.length > MAX_INTEGRATION_TOOLS) {
|
|
160
|
+
throw new IntegrationProtocolError(
|
|
161
|
+
"graphql_tool_limit",
|
|
162
|
+
`GraphQL schema exceeds the ${MAX_INTEGRATION_TOOLS}-tool limit`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (tools.length === 0) {
|
|
168
|
+
throw new IntegrationProtocolError("graphql_empty", "GraphQL schema exposes no root fields");
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
id,
|
|
172
|
+
protocol: "graphql",
|
|
173
|
+
integrationId: options.integrationId,
|
|
174
|
+
contentSha256,
|
|
175
|
+
source: {
|
|
176
|
+
url: options.sourceUrl ?? endpoint,
|
|
177
|
+
...(options.provider ? { provider: options.provider } : {}),
|
|
178
|
+
},
|
|
179
|
+
title: options.name?.trim() || options.integrationId,
|
|
180
|
+
tools,
|
|
181
|
+
bindings,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function fetchGraphqlIntrospection(
|
|
186
|
+
options: Omit<GraphqlServerOptions, "revision">,
|
|
187
|
+
): Promise<IntrospectionQuery> {
|
|
188
|
+
const request = { query: getIntrospectionQuery({ descriptions: true }) };
|
|
189
|
+
const firstCredential = await resolveGraphqlCredential(
|
|
190
|
+
options,
|
|
191
|
+
"graphql-introspection",
|
|
192
|
+
"pending",
|
|
193
|
+
"__introspection",
|
|
194
|
+
false,
|
|
195
|
+
);
|
|
196
|
+
let response = await sendGraphqlRequest(options, request, firstCredential);
|
|
197
|
+
if (response.status === 401 && options.credentialResolver && options.authority.connectionRef) {
|
|
198
|
+
const refreshed = await resolveGraphqlCredential(
|
|
199
|
+
options,
|
|
200
|
+
"graphql-introspection",
|
|
201
|
+
"pending",
|
|
202
|
+
"__introspection",
|
|
203
|
+
true,
|
|
204
|
+
);
|
|
205
|
+
await response.body?.cancel().catch(() => undefined);
|
|
206
|
+
if (!refreshed) {
|
|
207
|
+
throw new IntegrationInvocationError(
|
|
208
|
+
"graphql_introspection_rejected",
|
|
209
|
+
"GraphQL endpoint did not return an introspection schema",
|
|
210
|
+
"failed",
|
|
211
|
+
false,
|
|
212
|
+
401,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
response = await sendGraphqlRequest(options, request, refreshed);
|
|
216
|
+
}
|
|
217
|
+
if (response.status >= 300 && response.status < 400) {
|
|
218
|
+
await response.body?.cancel().catch(() => undefined);
|
|
219
|
+
throw new IntegrationInvocationError(
|
|
220
|
+
"redirect_rejected",
|
|
221
|
+
"GraphQL endpoint attempted to redirect the introspection request",
|
|
222
|
+
"unknown",
|
|
223
|
+
false,
|
|
224
|
+
response.status,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
const body = await readIntegrationResponse(response, MAX_INTEGRATION_SPEC_BYTES);
|
|
228
|
+
if (!response.ok || !isRecord(body.data) || !isRecord(body.data.data)) {
|
|
229
|
+
throw new IntegrationInvocationError(
|
|
230
|
+
"graphql_introspection_rejected",
|
|
231
|
+
"GraphQL endpoint did not return an introspection schema",
|
|
232
|
+
"failed",
|
|
233
|
+
false,
|
|
234
|
+
response.status,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
return body.data.data as unknown as IntrospectionQuery;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export class GraphqlMcpServer implements MCPServer {
|
|
241
|
+
readonly cacheToolsList = true;
|
|
242
|
+
readonly useStructuredContent = true;
|
|
243
|
+
readonly name: string;
|
|
244
|
+
|
|
245
|
+
constructor(private readonly options: GraphqlServerOptions) {
|
|
246
|
+
this.name = `graphql:${stableToolId(options.revision.integrationId)}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async connect(): Promise<void> {}
|
|
250
|
+
async close(): Promise<void> {}
|
|
251
|
+
async invalidateToolsCache(): Promise<void> {}
|
|
252
|
+
|
|
253
|
+
async listTools(): Promise<LocalMcpTool[]> {
|
|
254
|
+
return this.options.revision.tools.map(
|
|
255
|
+
(tool) =>
|
|
256
|
+
({
|
|
257
|
+
name: tool.id,
|
|
258
|
+
description: tool.description,
|
|
259
|
+
inputSchema: normalizeMcpSchema(tool.inputSchema),
|
|
260
|
+
annotations: {
|
|
261
|
+
readOnlyHint: tool.safety === "read",
|
|
262
|
+
destructiveHint: false,
|
|
263
|
+
idempotentHint: tool.safety === "read",
|
|
264
|
+
openWorldHint: true,
|
|
265
|
+
},
|
|
266
|
+
_meta: {
|
|
267
|
+
"opengeni/approvalMode": tool.approvalMode,
|
|
268
|
+
"opengeni/operationKey": tool.operationKey,
|
|
269
|
+
"opengeni/revisionId": this.options.revision.id,
|
|
270
|
+
},
|
|
271
|
+
}) as LocalMcpTool,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async callTool(
|
|
276
|
+
toolName: string,
|
|
277
|
+
args: Record<string, unknown> | null,
|
|
278
|
+
_meta?: Record<string, unknown> | null,
|
|
279
|
+
callOptions?: MCPCallToolOptions,
|
|
280
|
+
): Promise<CallToolResultContent> {
|
|
281
|
+
const result = await invokeGraphqlOperation(
|
|
282
|
+
this.options,
|
|
283
|
+
toolName,
|
|
284
|
+
args ?? {},
|
|
285
|
+
callOptions?.signal,
|
|
286
|
+
);
|
|
287
|
+
const content = [
|
|
288
|
+
{ type: "text" as const, text: JSON.stringify(result) },
|
|
289
|
+
] as CallToolResultContent;
|
|
290
|
+
content.structuredContent = result;
|
|
291
|
+
content.isError = result.ok === false;
|
|
292
|
+
return content;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function createGraphqlMcpServer(options: GraphqlServerOptions): MCPServer {
|
|
297
|
+
return new GraphqlMcpServer(options);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function invokeGraphqlOperation(
|
|
301
|
+
options: GraphqlServerOptions,
|
|
302
|
+
toolId: string,
|
|
303
|
+
args: Record<string, unknown>,
|
|
304
|
+
signal?: AbortSignal,
|
|
305
|
+
): Promise<Record<string, unknown>> {
|
|
306
|
+
const binding = options.revision.bindings[toolId];
|
|
307
|
+
if (!binding) {
|
|
308
|
+
throw new IntegrationInvocationError(
|
|
309
|
+
"operation_not_found",
|
|
310
|
+
"GraphQL operation is not present in the frozen revision",
|
|
311
|
+
"not_started",
|
|
312
|
+
false,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
const select = binding.selectionAllowed
|
|
316
|
+
? validateGraphqlSelection(
|
|
317
|
+
typeof args.select === "string" ? args.select : (binding.defaultSelection ?? "__typename"),
|
|
318
|
+
)
|
|
319
|
+
: undefined;
|
|
320
|
+
const variables = Object.fromEntries(
|
|
321
|
+
binding.variableNames.flatMap((name) => (args[name] === undefined ? [] : [[name, args[name]]])),
|
|
322
|
+
);
|
|
323
|
+
const definitions = binding.variableDefinitions.length
|
|
324
|
+
? `(${binding.variableDefinitions.join(", ")})`
|
|
325
|
+
: "";
|
|
326
|
+
const argumentsText = binding.variableNames.length
|
|
327
|
+
? `(${binding.variableNames.map((name) => `${name}: $${name}`).join(", ")})`
|
|
328
|
+
: "";
|
|
329
|
+
const query = `${binding.kind} ${binding.operationName}${definitions} { ${binding.fieldName}${argumentsText}${select ? ` { ${select} }` : ""} }`;
|
|
330
|
+
const request = { query, variables, operationName: binding.operationName };
|
|
331
|
+
const firstCredential = await resolveGraphqlCredential(
|
|
332
|
+
options,
|
|
333
|
+
options.revision.integrationId,
|
|
334
|
+
options.revision.id,
|
|
335
|
+
toolId,
|
|
336
|
+
false,
|
|
337
|
+
);
|
|
338
|
+
let response = await sendGraphqlRequest(options, request, firstCredential, signal);
|
|
339
|
+
if (response.status === 401 && options.credentialResolver && options.authority.connectionRef) {
|
|
340
|
+
const refreshed = await resolveGraphqlCredential(
|
|
341
|
+
options,
|
|
342
|
+
options.revision.integrationId,
|
|
343
|
+
options.revision.id,
|
|
344
|
+
toolId,
|
|
345
|
+
true,
|
|
346
|
+
);
|
|
347
|
+
await response.body?.cancel().catch(() => undefined);
|
|
348
|
+
if (binding.kind === "query" && refreshed) {
|
|
349
|
+
response = await sendGraphqlRequest(options, request, refreshed, signal);
|
|
350
|
+
} else {
|
|
351
|
+
throw new IntegrationInvocationError(
|
|
352
|
+
"authorization_rejected",
|
|
353
|
+
"The connected account is no longer authorized for this GraphQL operation",
|
|
354
|
+
binding.kind === "mutation" ? "unknown" : "failed",
|
|
355
|
+
false,
|
|
356
|
+
401,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (response.status >= 300 && response.status < 400) {
|
|
361
|
+
await response.body?.cancel().catch(() => undefined);
|
|
362
|
+
throw new IntegrationInvocationError(
|
|
363
|
+
"redirect_rejected",
|
|
364
|
+
"GraphQL endpoint attempted to redirect a credential-bearing request",
|
|
365
|
+
binding.kind === "mutation" ? "unknown" : "failed",
|
|
366
|
+
false,
|
|
367
|
+
response.status,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
const payload = await readIntegrationResponse(
|
|
371
|
+
response,
|
|
372
|
+
options.maxResponseBytes ?? DEFAULT_INTEGRATION_RESPONSE_BYTES,
|
|
373
|
+
);
|
|
374
|
+
if (response.status === 401 || response.status === 403) {
|
|
375
|
+
throw new IntegrationInvocationError(
|
|
376
|
+
"authorization_rejected",
|
|
377
|
+
"The connected account is no longer authorized for this GraphQL operation",
|
|
378
|
+
binding.kind === "mutation" ? "unknown" : "failed",
|
|
379
|
+
false,
|
|
380
|
+
response.status,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
const graph = isRecord(payload.data) ? payload.data : {};
|
|
384
|
+
return {
|
|
385
|
+
ok: response.ok && !Array.isArray(graph.errors),
|
|
386
|
+
status: response.status,
|
|
387
|
+
data: graph.data ?? null,
|
|
388
|
+
errors: graph.errors ?? null,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function resolveGraphqlCredential(
|
|
393
|
+
options: Omit<GraphqlServerOptions, "revision"> | GraphqlServerOptions,
|
|
394
|
+
integrationId: string,
|
|
395
|
+
revisionId: string,
|
|
396
|
+
operationKey: string,
|
|
397
|
+
forceRefresh: boolean,
|
|
398
|
+
): Promise<Awaited<ReturnType<IntegrationCredentialResolver["resolve"]>>> {
|
|
399
|
+
if (!options.credentialResolver || !options.authority.connectionRef) return null;
|
|
400
|
+
const credential = await options.credentialResolver.resolve({
|
|
401
|
+
...options.authority,
|
|
402
|
+
protocol: "graphql",
|
|
403
|
+
integrationId,
|
|
404
|
+
revisionId,
|
|
405
|
+
operationKey,
|
|
406
|
+
destinationUrl: graphqlEndpoint(options).toString(),
|
|
407
|
+
...(forceRefresh ? { forceRefresh: true } : {}),
|
|
408
|
+
});
|
|
409
|
+
if (!credential && !forceRefresh) {
|
|
410
|
+
throw new IntegrationInvocationError(
|
|
411
|
+
"connection_required",
|
|
412
|
+
"This GraphQL integration needs a connected account",
|
|
413
|
+
"not_started",
|
|
414
|
+
false,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
return credential;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function sendGraphqlRequest(
|
|
421
|
+
options: Omit<GraphqlServerOptions, "revision"> | GraphqlServerOptions,
|
|
422
|
+
request: Record<string, unknown>,
|
|
423
|
+
credential: Awaited<ReturnType<IntegrationCredentialResolver["resolve"]>>,
|
|
424
|
+
signal?: AbortSignal,
|
|
425
|
+
): Promise<Response> {
|
|
426
|
+
const endpoint = graphqlEndpoint(options);
|
|
427
|
+
const headers = new Headers(options.staticHeaders);
|
|
428
|
+
headers.set("accept", "application/json");
|
|
429
|
+
headers.set("content-type", "application/json");
|
|
430
|
+
if (credential) applyCredentialPlacements(endpoint, headers, credential);
|
|
431
|
+
return await fetchWithDeadline(
|
|
432
|
+
options.transport,
|
|
433
|
+
endpoint,
|
|
434
|
+
{
|
|
435
|
+
method: "POST",
|
|
436
|
+
headers,
|
|
437
|
+
body: JSON.stringify(request),
|
|
438
|
+
...(signal ? { signal } : {}),
|
|
439
|
+
},
|
|
440
|
+
options.timeoutMs ?? DEFAULT_INTEGRATION_TIMEOUT_MS,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function graphqlEndpoint(options: Pick<GraphqlServerOptions, "endpoint" | "staticQuery">): URL {
|
|
445
|
+
const endpoint = new URL(validateGraphqlEndpoint(options.endpoint));
|
|
446
|
+
for (const [name, value] of Object.entries(options.staticQuery ?? {})) {
|
|
447
|
+
endpoint.searchParams.set(name, value);
|
|
448
|
+
}
|
|
449
|
+
return endpoint;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function validateGraphqlSelection(value: string): string {
|
|
453
|
+
const normalized = value.trim();
|
|
454
|
+
if (!normalized || normalized.length > 4_000) {
|
|
455
|
+
throw new IntegrationInvocationError(
|
|
456
|
+
"graphql_selection_invalid",
|
|
457
|
+
"GraphQL selection must contain between 1 and 4000 characters",
|
|
458
|
+
"not_started",
|
|
459
|
+
false,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
const document = parse(`fragment OpenGeniSelection on Placeholder { ${normalized} }`);
|
|
464
|
+
if (
|
|
465
|
+
document.definitions.length !== 1 ||
|
|
466
|
+
document.definitions[0]?.kind !== "FragmentDefinition"
|
|
467
|
+
) {
|
|
468
|
+
throw new Error("invalid selection document");
|
|
469
|
+
}
|
|
470
|
+
return normalized;
|
|
471
|
+
} catch {
|
|
472
|
+
throw new IntegrationInvocationError(
|
|
473
|
+
"graphql_selection_invalid",
|
|
474
|
+
"GraphQL selection is invalid",
|
|
475
|
+
"not_started",
|
|
476
|
+
false,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function parseIntrospection(
|
|
482
|
+
value: IntrospectionQuery | { readonly data?: IntrospectionQuery } | string,
|
|
483
|
+
): IntrospectionQuery {
|
|
484
|
+
let parsed: unknown = value;
|
|
485
|
+
if (typeof value === "string") {
|
|
486
|
+
if (Buffer.byteLength(value) > MAX_INTEGRATION_SPEC_BYTES) {
|
|
487
|
+
throw new IntegrationProtocolError(
|
|
488
|
+
"graphql_introspection_size",
|
|
489
|
+
`GraphQL introspection exceeds ${MAX_INTEGRATION_SPEC_BYTES} bytes`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
try {
|
|
493
|
+
parsed = JSON.parse(value);
|
|
494
|
+
} catch {
|
|
495
|
+
throw new IntegrationProtocolError(
|
|
496
|
+
"graphql_introspection_parse",
|
|
497
|
+
"GraphQL introspection is not valid JSON",
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (isRecord(parsed) && isRecord(parsed.data) && isRecord(parsed.data.__schema)) {
|
|
502
|
+
return parsed.data as unknown as IntrospectionQuery;
|
|
503
|
+
}
|
|
504
|
+
if (isRecord(parsed) && isRecord(parsed.__schema)) {
|
|
505
|
+
return parsed as unknown as IntrospectionQuery;
|
|
506
|
+
}
|
|
507
|
+
throw new IntegrationProtocolError(
|
|
508
|
+
"graphql_introspection_shape",
|
|
509
|
+
"GraphQL introspection result has no __schema object",
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function validateGraphqlEndpoint(value: string): string {
|
|
514
|
+
let endpoint: URL;
|
|
515
|
+
try {
|
|
516
|
+
endpoint = new URL(value);
|
|
517
|
+
} catch {
|
|
518
|
+
throw new IntegrationProtocolError(
|
|
519
|
+
"graphql_endpoint_invalid",
|
|
520
|
+
"GraphQL endpoint URL is invalid",
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
if (
|
|
524
|
+
!/^https?:$/.test(endpoint.protocol) ||
|
|
525
|
+
endpoint.username ||
|
|
526
|
+
endpoint.password ||
|
|
527
|
+
endpoint.hash
|
|
528
|
+
) {
|
|
529
|
+
throw new IntegrationProtocolError(
|
|
530
|
+
"graphql_endpoint_invalid",
|
|
531
|
+
"GraphQL endpoint URL is invalid",
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
return endpoint.toString();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function inputTypeSchema(input: GraphQLInputType, seen: Set<string>, depth: number): JsonSchema {
|
|
538
|
+
if (depth > 12) return {};
|
|
539
|
+
if (isNonNullType(input)) return inputTypeSchema(input.ofType, seen, depth + 1);
|
|
540
|
+
if (isListType(input)) {
|
|
541
|
+
return { type: "array", items: inputTypeSchema(input.ofType, seen, depth + 1) };
|
|
542
|
+
}
|
|
543
|
+
const type = getNamedType(input);
|
|
544
|
+
if (isScalarType(type)) return scalarSchema(type.name);
|
|
545
|
+
if (isEnumType(type))
|
|
546
|
+
return { type: "string", enum: type.getValues().map((entry) => entry.name) };
|
|
547
|
+
if (isInputObjectType(type)) {
|
|
548
|
+
if (seen.has(type.name)) return { type: "object", additionalProperties: true };
|
|
549
|
+
const nextSeen = new Set(seen).add(type.name);
|
|
550
|
+
const fields = Object.values(type.getFields());
|
|
551
|
+
return {
|
|
552
|
+
type: "object",
|
|
553
|
+
properties: Object.fromEntries(
|
|
554
|
+
fields.map((field) => [
|
|
555
|
+
field.name,
|
|
556
|
+
{
|
|
557
|
+
...inputTypeSchema(field.type, nextSeen, depth + 1),
|
|
558
|
+
...(field.description ? { description: field.description } : {}),
|
|
559
|
+
},
|
|
560
|
+
]),
|
|
561
|
+
),
|
|
562
|
+
required: fields.filter((field) => isNonNullType(field.type)).map((field) => field.name),
|
|
563
|
+
additionalProperties: false,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
return {};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function scalarSchema(name: string): JsonSchema {
|
|
570
|
+
if (name === "Boolean") return { type: "boolean" };
|
|
571
|
+
if (name === "Int") return { type: "integer" };
|
|
572
|
+
if (name === "Float") return { type: "number" };
|
|
573
|
+
if (name === "ID" || name === "String") return { type: "string" };
|
|
574
|
+
return { description: `GraphQL scalar ${name}` };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function buildDefaultSelection(
|
|
578
|
+
output: GraphQLOutputType,
|
|
579
|
+
seen: Set<string>,
|
|
580
|
+
depth: number,
|
|
581
|
+
): string | undefined {
|
|
582
|
+
const type = getNamedType(output);
|
|
583
|
+
if (isLeafType(type)) return undefined;
|
|
584
|
+
if (depth > 2 || seen.has(type.name)) return "__typename";
|
|
585
|
+
if (isUnionType(type) || isInterfaceType(type)) return "__typename";
|
|
586
|
+
if (!isObjectType(type)) return "__typename";
|
|
587
|
+
const nextSeen = new Set(seen).add(type.name);
|
|
588
|
+
const fields = Object.values(type.getFields());
|
|
589
|
+
const scalarFields = fields.filter((field) => isLeafType(getNamedType(field.type))).slice(0, 20);
|
|
590
|
+
const selections = scalarFields.map((field) => field.name);
|
|
591
|
+
if (selections.length < 3 && depth < 2) {
|
|
592
|
+
const nested = fields.find(
|
|
593
|
+
(field) => field.args.length === 0 && !isLeafType(getNamedType(field.type)),
|
|
594
|
+
);
|
|
595
|
+
if (nested) {
|
|
596
|
+
const child = buildDefaultSelection(nested.type, nextSeen, depth + 1);
|
|
597
|
+
if (child) selections.push(`${nested.name} { ${child} }`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return selections.length ? selections.join(" ") : "__typename";
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function isLeafType(type: GraphQLNamedType): boolean {
|
|
604
|
+
return isScalarType(type) || isEnumType(type);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function stableGraphqlName(value: string): string {
|
|
608
|
+
const normalized = value.replace(/[^_0-9A-Za-z]/g, "_").replace(/^([^_A-Za-z])/, "_$1");
|
|
609
|
+
return normalized || "OpenGeniOperation";
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function normalizeMcpSchema(schema: JsonSchema): LocalMcpTool["inputSchema"] {
|
|
613
|
+
return {
|
|
614
|
+
type: "object",
|
|
615
|
+
properties: isRecord(schema.properties) ? schema.properties : {},
|
|
616
|
+
required: Array.isArray(schema.required)
|
|
617
|
+
? schema.required.filter((entry): entry is string => typeof entry === "string")
|
|
618
|
+
: [],
|
|
619
|
+
additionalProperties: schema.additionalProperties === true,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
624
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
625
|
+
}
|