History

add source discovery system to auto-detect source type from URL

Probe an arbitrary URL to determine whether it serves an OpenAPI spec, GraphQL introspection endpoint, or MCP server. For OpenAPI sources, parse security schemes to infer auth requirements. For MCP, attempt tool discovery with OAuth fallback detection.

Rhys Sullivan committed 8 months ago

Commit ff7c45f1 parent 0b07e212 files · +973 −0Browse files

Unified · Split

2 files
  1. packages/control-plane/src/runtime/source-discovery.ts +885 −0
  2. packages/control-plane/src/schema/models/source-discovery.ts +88 −0
packages/control-plane/src/runtime/source-discovery.ts+885−0
@@ -0,0 +1,885 @@
1import {
2 FetchHttpClient,
3 HttpClient,
4 HttpClientRequest,
5} from "@effect/platform";
6import {
7 createSdkMcpConnector,
8 discoverMcpToolsFromConnector,
9} from "@executor-v3/codemode-mcp";
10import {
11 extractOpenApiManifest,
12 parseOpenApiDocument,
13} from "@executor-v3/codemode-openapi";
14import {
15 type SourceAuthInference,
16 type SourceDiscoveryResult,
17 type SourceProbeAuth,
18} from "#schema";
19import * as Effect from "effect/Effect";
20import { getIntrospectionQuery } from "graphql";
21import { startMcpOAuthAuthorization } from "./mcp-oauth";
22import { namespaceFromSourceName } from "./tool-artifacts";
23
24const GRAPHQL_INTROSPECTION_QUERY = getIntrospectionQuery();
25
26type HttpProbeResponse = {
27 status: number;
28 headers: Readonly<Record<string, string>>;
29 text: string;
30};
31
32type OpenApiProbeResult = {
33 result: SourceDiscoveryResult;
34};
35
36type GraphqlProbeResult = {
37 result: SourceDiscoveryResult;
38};
39
40type McpProbeResult = {
41 result: SourceDiscoveryResult;
42};
43
44type OpenApiSecurityCandidate = {
45 name: string;
46 kind: "bearer" | "oauth2" | "apiKey" | "basic" | "unknown";
47 supported: boolean;
48 headerName: string | null;
49 prefix: string | null;
50 parameterName: string | null;
51 parameterLocation: "header" | "query" | "cookie" | null;
52 oauthAuthorizationUrl: string | null;
53 oauthTokenUrl: string | null;
54 oauthScopes: string[];
55 reason: string;
56};
57
58const isRecord = (value: unknown): value is Record<string, unknown> =>
59 typeof value === "object" && value !== null && !Array.isArray(value);
60
61const asString = (value: unknown): string | null =>
62 typeof value === "string" && value.length > 0 ? value : null;
63
64const trimOrNull = (value: string | null | undefined): string | null => {
65 if (value == null) {
66 return null;
67 }
68
69 const trimmed = value.trim();
70 return trimmed.length > 0 ? trimmed : null;
71};
72
73const defaultNameFromEndpoint = (endpoint: string): string => new URL(endpoint).hostname;
74
75const normalizeUrl = (value: string): string => {
76 const trimmed = value.trim();
77 if (trimmed.length === 0) {
78 throw new Error("Source URL is required");
79 }
80
81 const parsed = new URL(trimmed);
82 if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
83 throw new Error("Source URL must use http or https");
84 }
85
86 return parsed.toString();
87};
88
89const unsupportedAuthInference = (
90 kind: SourceAuthInference["suggestedKind"],
91 input: Omit<SourceAuthInference, "suggestedKind" | "supported">,
92): SourceAuthInference => ({
93 ...input,
94 suggestedKind: kind,
95 supported: false,
96});
97
98const supportedAuthInference = (
99 kind: Extract<SourceAuthInference["suggestedKind"], "none" | "bearer" | "oauth2">,
100 input: Omit<SourceAuthInference, "suggestedKind" | "supported">,
101): SourceAuthInference => ({
102 ...input,
103 suggestedKind: kind,
104 supported: true,
105});
106
107const unknownAuthInference = (reason: string): SourceAuthInference => ({
108 suggestedKind: "unknown",
109 confidence: "low",
110 supported: false,
111 reason,
112 headerName: null,
113 prefix: null,
114 parameterName: null,
115 parameterLocation: null,
116 oauthAuthorizationUrl: null,
117 oauthTokenUrl: null,
118 oauthScopes: [],
119});
120
121const noneAuthInference = (
122 reason: string,
123 confidence: SourceAuthInference["confidence"] = "high",
124): SourceAuthInference => supportedAuthInference("none", {
125 confidence,
126 reason,
127 headerName: null,
128 prefix: null,
129 parameterName: null,
130 parameterLocation: null,
131 oauthAuthorizationUrl: null,
132 oauthTokenUrl: null,
133 oauthScopes: [],
134});
135
136const parseChallengeAuthInference = (
137 headers: Readonly<Record<string, string>>,
138 fallbackReason: string,
139): SourceAuthInference => {
140 const challenge = headers["www-authenticate"] ?? headers["WWW-Authenticate"];
141 if (!challenge) {
142 return unknownAuthInference(fallbackReason);
143 }
144
145 const normalized = challenge.toLowerCase();
146 if (normalized.includes("bearer")) {
147 return supportedAuthInference("bearer", {
148 confidence: normalized.includes("realm=") ? "medium" : "low",
149 reason: `Derived from HTTP challenge: ${challenge}`,
150 headerName: "Authorization",
151 prefix: "Bearer ",
152 parameterName: null,
153 parameterLocation: null,
154 oauthAuthorizationUrl: null,
155 oauthTokenUrl: null,
156 oauthScopes: [],
157 });
158 }
159
160 if (normalized.includes("basic")) {
161 return unsupportedAuthInference("basic", {
162 confidence: "medium",
163 reason: `Derived from HTTP challenge: ${challenge}`,
164 headerName: "Authorization",
165 prefix: "Basic ",
166 parameterName: null,
167 parameterLocation: null,
168 oauthAuthorizationUrl: null,
169 oauthTokenUrl: null,
170 oauthScopes: [],
171 });
172 }
173
174 return unknownAuthInference(fallbackReason);
175};
176
177const probeHeadersFromAuth = (probeAuth: SourceProbeAuth | null | undefined): Record<string, string> => {
178 if (probeAuth == null || probeAuth.kind === "none") {
179 return {};
180 }
181
182 if (probeAuth.kind === "headers") {
183 return { ...probeAuth.headers };
184 }
185
186 if (probeAuth.kind === "basic") {
187 return {
188 Authorization: `Basic ${Buffer.from(`${probeAuth.username}:${probeAuth.password}`).toString("base64")}`,
189 };
190 }
191
192 return {
193 [trimOrNull(probeAuth.headerName) ?? "Authorization"]: `${probeAuth.prefix ?? "Bearer "}${probeAuth.token}`,
194 };
195};
196
197const executeHttpProbe = (input: {
198 method: "GET" | "POST";
199 url: string;
200 headers?: Readonly<Record<string, string>>;
201 body?: string;
202}): Effect.Effect<HttpProbeResponse, Error, never> =>
203 Effect.gen(function* () {
204 const client = yield* HttpClient.HttpClient;
205 let request = HttpClientRequest.make(input.method)(input.url, {
206 headers: input.headers ?? {},
207 });
208
209 if (input.body !== undefined) {
210 request = HttpClientRequest.bodyText(
211 request,
212 input.body,
213 input.headers?.["content-type"] ?? input.headers?.["Content-Type"] ?? "application/json",
214 );
215 }
216
217 const response = yield* client.execute(request).pipe(
218 Effect.mapError((cause) => cause instanceof Error ? cause : new Error(String(cause))),
219 );
220
221 const text = yield* response.text.pipe(
222 Effect.mapError((cause) => cause instanceof Error ? cause : new Error(String(cause))),
223 );
224
225 return {
226 status: response.status,
227 headers: response.headers,
228 text,
229 } satisfies HttpProbeResponse;
230 }).pipe(Effect.provide(FetchHttpClient.layer));
231
232const parseStructuredDocument = (value: string): unknown =>
233 parseOpenApiDocument(value) as unknown;
234
235const readLocalRef = (document: Record<string, unknown>, ref: string): unknown => {
236 if (!ref.startsWith("#/")) {
237 return undefined;
238 }
239
240 let current: unknown = document;
241 for (const rawSegment of ref.slice(2).split("/")) {
242 const segment = rawSegment.replaceAll("~1", "/").replaceAll("~0", "~");
243 if (!isRecord(current)) {
244 return undefined;
245 }
246 current = current[segment];
247 }
248
249 return current;
250};
251
252const resolveSecurityScheme = (
253 document: Record<string, unknown>,
254 input: unknown,
255 depth = 0,
256): Record<string, unknown> | null => {
257 if (!isRecord(input)) {
258 return null;
259 }
260
261 const ref = asString(input["$ref"]);
262 if (ref && depth < 5) {
263 return resolveSecurityScheme(document, readLocalRef(document, ref), depth + 1);
264 }
265
266 return input;
267};
268
269const collectAppliedSecurityCandidates = (document: Record<string, unknown>): Array<{
270 name: string;
271 scopes: string[];
272}> => {
273 const seen = new Set<string>();
274 const candidates: Array<{ name: string; scopes: string[] }> = [];
275
276 const addRequirementArray = (value: unknown) => {
277 if (!Array.isArray(value)) {
278 return;
279 }
280
281 for (const requirement of value) {
282 if (!isRecord(requirement)) {
283 continue;
284 }
285
286 for (const [name, scopesValue] of Object.entries(requirement)) {
287 if (name.length === 0 || seen.has(name)) {
288 continue;
289 }
290
291 seen.add(name);
292 candidates.push({
293 name,
294 scopes: Array.isArray(scopesValue)
295 ? scopesValue.filter((scope): scope is string => typeof scope === "string")
296 : [],
297 });
298 }
299 }
300 };
301
302 addRequirementArray(document.security);
303
304 const paths = document.paths;
305 if (!isRecord(paths)) {
306 return candidates;
307 }
308
309 for (const pathItem of Object.values(paths)) {
310 if (!isRecord(pathItem)) {
311 continue;
312 }
313
314 addRequirementArray(pathItem.security);
315
316 for (const operation of Object.values(pathItem)) {
317 if (!isRecord(operation)) {
318 continue;
319 }
320 addRequirementArray(operation.security);
321 }
322 }
323
324 return candidates;
325};
326
327const securityCandidateFromScheme = (input: {
328 name: string;
329 scopes: string[];
330 scheme: Record<string, unknown>;
331}): OpenApiSecurityCandidate => {
332 const type = asString(input.scheme.type)?.toLowerCase() ?? "";
333
334 if (type === "oauth2") {
335 const flows = isRecord(input.scheme.flows) ? input.scheme.flows : {};
336 const flow = Object.values(flows).find(isRecord) ?? null;
337 const declaredScopes = flow && isRecord(flow.scopes)
338 ? Object.keys(flow.scopes).filter((scope) => scope.length > 0)
339 : [];
340 const oauthScopes = [...new Set([...input.scopes, ...declaredScopes])].sort();
341
342 return {
343 name: input.name,
344 kind: "oauth2",
345 supported: true,
346 headerName: "Authorization",
347 prefix: "Bearer ",
348 parameterName: null,
349 parameterLocation: null,
350 oauthAuthorizationUrl: flow ? trimOrNull(asString(flow.authorizationUrl)) : null,
351 oauthTokenUrl: flow ? trimOrNull(asString(flow.tokenUrl)) : null,
352 oauthScopes,
353 reason: `OpenAPI security scheme \"${input.name}\" declares OAuth2`,
354 };
355 }
356
357 if (type === "http") {
358 const scheme = asString(input.scheme.scheme)?.toLowerCase() ?? "";
359 if (scheme === "bearer") {
360 return {
361 name: input.name,
362 kind: "bearer",
363 supported: true,
364 headerName: "Authorization",
365 prefix: "Bearer ",
366 parameterName: null,
367 parameterLocation: null,
368 oauthAuthorizationUrl: null,
369 oauthTokenUrl: null,
370 oauthScopes: input.scopes,
371 reason: `OpenAPI security scheme \"${input.name}\" declares HTTP bearer auth`,
372 };
373 }
374
375 if (scheme === "basic") {
376 return {
377 name: input.name,
378 kind: "basic",
379 supported: false,
380 headerName: "Authorization",
381 prefix: "Basic ",
382 parameterName: null,
383 parameterLocation: null,
384 oauthAuthorizationUrl: null,
385 oauthTokenUrl: null,
386 oauthScopes: input.scopes,
387 reason: `OpenAPI security scheme \"${input.name}\" declares HTTP basic auth`,
388 };
389 }
390 }
391
392 if (type === "apiKey") {
393 const location = asString(input.scheme.in);
394 const parameterLocation = location === "header" || location === "query" || location === "cookie"
395 ? location
396 : null;
397
398 return {
399 name: input.name,
400 kind: "apiKey",
401 supported: false,
402 headerName: parameterLocation === "header" ? trimOrNull(asString(input.scheme.name)) : null,
403 prefix: null,
404 parameterName: trimOrNull(asString(input.scheme.name)),
405 parameterLocation,
406 oauthAuthorizationUrl: null,
407 oauthTokenUrl: null,
408 oauthScopes: input.scopes,
409 reason: `OpenAPI security scheme \"${input.name}\" declares API key auth`,
410 };
411 }
412
413 return {
414 name: input.name,
415 kind: "unknown",
416 supported: false,
417 headerName: null,
418 prefix: null,
419 parameterName: null,
420 parameterLocation: null,
421 oauthAuthorizationUrl: null,
422 oauthTokenUrl: null,
423 oauthScopes: input.scopes,
424 reason: `OpenAPI security scheme \"${input.name}\" uses unsupported type ${type || "unknown"}`,
425 };
426};
427
428const inferOpenApiAuth = (document: Record<string, unknown>): SourceAuthInference => {
429 const components = isRecord(document.components) ? document.components : {};
430 const securitySchemes = isRecord(components.securitySchemes)
431 ? components.securitySchemes
432 : {};
433 const appliedCandidates = collectAppliedSecurityCandidates(document);
434
435 if (appliedCandidates.length === 0) {
436 if (Object.keys(securitySchemes).length === 0) {
437 return noneAuthInference("OpenAPI document does not declare security requirements");
438 }
439
440 const fallbackCandidate = Object.entries(securitySchemes)
441 .map(([name, value]) => securityCandidateFromScheme({
442 name,
443 scopes: [],
444 scheme: resolveSecurityScheme(document, value) ?? {},
445 }))
446 .sort((left, right) => {
447 const priority = { oauth2: 0, bearer: 1, apiKey: 2, basic: 3, unknown: 4 } as const;
448 return priority[left.kind] - priority[right.kind] || left.name.localeCompare(right.name);
449 })[0];
450
451 if (!fallbackCandidate) {
452 return noneAuthInference("OpenAPI document does not declare security requirements");
453 }
454
455 const confidence = fallbackCandidate.kind === "unknown" ? "low" : "medium";
456 if (fallbackCandidate.kind === "oauth2") {
457 return supportedAuthInference("oauth2", {
458 confidence,
459 reason: `${fallbackCandidate.reason}; scheme is defined but not explicitly applied to operations`,
460 headerName: fallbackCandidate.headerName,
461 prefix: fallbackCandidate.prefix,
462 parameterName: fallbackCandidate.parameterName,
463 parameterLocation: fallbackCandidate.parameterLocation,
464 oauthAuthorizationUrl: fallbackCandidate.oauthAuthorizationUrl,
465 oauthTokenUrl: fallbackCandidate.oauthTokenUrl,
466 oauthScopes: fallbackCandidate.oauthScopes,
467 });
468 }
469
470 if (fallbackCandidate.kind === "bearer") {
471 return supportedAuthInference("bearer", {
472 confidence,
473 reason: `${fallbackCandidate.reason}; scheme is defined but not explicitly applied to operations`,
474 headerName: fallbackCandidate.headerName,
475 prefix: fallbackCandidate.prefix,
476 parameterName: fallbackCandidate.parameterName,
477 parameterLocation: fallbackCandidate.parameterLocation,
478 oauthAuthorizationUrl: fallbackCandidate.oauthAuthorizationUrl,
479 oauthTokenUrl: fallbackCandidate.oauthTokenUrl,
480 oauthScopes: fallbackCandidate.oauthScopes,
481 });
482 }
483
484 if (fallbackCandidate.kind === "apiKey") {
485 return unsupportedAuthInference("apiKey", {
486 confidence,
487 reason: `${fallbackCandidate.reason}; scheme is defined but not explicitly applied to operations`,
488 headerName: fallbackCandidate.headerName,
489 prefix: fallbackCandidate.prefix,
490 parameterName: fallbackCandidate.parameterName,
491 parameterLocation: fallbackCandidate.parameterLocation,
492 oauthAuthorizationUrl: fallbackCandidate.oauthAuthorizationUrl,
493 oauthTokenUrl: fallbackCandidate.oauthTokenUrl,
494 oauthScopes: fallbackCandidate.oauthScopes,
495 });
496 }
497
498 if (fallbackCandidate.kind === "basic") {
499 return unsupportedAuthInference("basic", {
500 confidence,
501 reason: `${fallbackCandidate.reason}; scheme is defined but not explicitly applied to operations`,
502 headerName: fallbackCandidate.headerName,
503 prefix: fallbackCandidate.prefix,
504 parameterName: fallbackCandidate.parameterName,
505 parameterLocation: fallbackCandidate.parameterLocation,
506 oauthAuthorizationUrl: fallbackCandidate.oauthAuthorizationUrl,
507 oauthTokenUrl: fallbackCandidate.oauthTokenUrl,
508 oauthScopes: fallbackCandidate.oauthScopes,
509 });
510 }
511
512 return unknownAuthInference(`${fallbackCandidate.reason}; scheme is defined but not explicitly applied to operations`);
513 }
514
515 const resolvedCandidates = appliedCandidates
516 .map(({ name, scopes }) => {
517 const scheme = resolveSecurityScheme(document, securitySchemes[name]);
518 return scheme == null ? null : securityCandidateFromScheme({ name, scopes, scheme });
519 })
520 .filter((candidate): candidate is OpenApiSecurityCandidate => candidate !== null)
521 .sort((left, right) => {
522 const priority = { oauth2: 0, bearer: 1, apiKey: 2, basic: 3, unknown: 4 } as const;
523 return priority[left.kind] - priority[right.kind] || left.name.localeCompare(right.name);
524 });
525
526 const selected = resolvedCandidates[0];
527 if (!selected) {
528 return unknownAuthInference("OpenAPI security requirements reference schemes that could not be resolved");
529 }
530
531 if (selected.kind === "oauth2") {
532 return supportedAuthInference("oauth2", {
533 confidence: "high",
534 reason: selected.reason,
535 headerName: selected.headerName,
536 prefix: selected.prefix,
537 parameterName: selected.parameterName,
538 parameterLocation: selected.parameterLocation,
539 oauthAuthorizationUrl: selected.oauthAuthorizationUrl,
540 oauthTokenUrl: selected.oauthTokenUrl,
541 oauthScopes: selected.oauthScopes,
542 });
543 }
544
545 if (selected.kind === "bearer") {
546 return supportedAuthInference("bearer", {
547 confidence: "high",
548 reason: selected.reason,
549 headerName: selected.headerName,
550 prefix: selected.prefix,
551 parameterName: selected.parameterName,
552 parameterLocation: selected.parameterLocation,
553 oauthAuthorizationUrl: selected.oauthAuthorizationUrl,
554 oauthTokenUrl: selected.oauthTokenUrl,
555 oauthScopes: selected.oauthScopes,
556 });
557 }
558
559 if (selected.kind === "apiKey") {
560 return unsupportedAuthInference("apiKey", {
561 confidence: "high",
562 reason: selected.reason,
563 headerName: selected.headerName,
564 prefix: selected.prefix,
565 parameterName: selected.parameterName,
566 parameterLocation: selected.parameterLocation,
567 oauthAuthorizationUrl: selected.oauthAuthorizationUrl,
568 oauthTokenUrl: selected.oauthTokenUrl,
569 oauthScopes: selected.oauthScopes,
570 });
571 }
572
573 if (selected.kind === "basic") {
574 return unsupportedAuthInference("basic", {
575 confidence: "high",
576 reason: selected.reason,
577 headerName: selected.headerName,
578 prefix: selected.prefix,
579 parameterName: selected.parameterName,
580 parameterLocation: selected.parameterLocation,
581 oauthAuthorizationUrl: selected.oauthAuthorizationUrl,
582 oauthTokenUrl: selected.oauthTokenUrl,
583 oauthScopes: selected.oauthScopes,
584 });
585 }
586
587 return unknownAuthInference(selected.reason);
588};
589
590const deriveOpenApiEndpoint = (input: {
591 normalizedUrl: string;
592 document: Record<string, unknown>;
593}): string => {
594 const servers = input.document.servers;
595 if (Array.isArray(servers)) {
596 const first = servers.find(isRecord);
597 const serverUrl = first ? trimOrNull(asString(first.url)) : null;
598 if (serverUrl) {
599 try {
600 return new URL(serverUrl, input.normalizedUrl).toString();
601 } catch {
602 return input.normalizedUrl;
603 }
604 }
605 }
606
607 return new URL(input.normalizedUrl).origin;
608};
609
610const tryDetectOpenApi = (input: {
611 normalizedUrl: string;
612 headers: Readonly<Record<string, string>>;
613}): Effect.Effect<OpenApiProbeResult | null, never, never> =>
614 Effect.gen(function* () {
615 const response = yield* Effect.either(executeHttpProbe({
616 method: "GET",
617 url: input.normalizedUrl,
618 headers: input.headers,
619 }));
620
621 if (response._tag === "Left") {
622 return null;
623 }
624
625 if (response.right.status < 200 || response.right.status >= 300) {
626 return null;
627 }
628
629 const manifest = yield* Effect.either(
630 extractOpenApiManifest(input.normalizedUrl, response.right.text),
631 );
632 if (manifest._tag === "Left") {
633 return null;
634 }
635
636 const document = yield* Effect.either(Effect.try({
637 try: () => parseStructuredDocument(response.right.text),
638 catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)),
639 }));
640
641 const parsedDocument = document._tag === "Right" && isRecord(document.right)
642 ? document.right
643 : {};
644 const endpoint = deriveOpenApiEndpoint({
645 normalizedUrl: input.normalizedUrl,
646 document: parsedDocument,
647 });
648 const name = trimOrNull(asString(parsedDocument.info && isRecord(parsedDocument.info) ? parsedDocument.info.title : null))
649 ?? defaultNameFromEndpoint(endpoint);
650
651 return {
652 result: {
653 detectedKind: "openapi",
654 confidence: "high",
655 endpoint,
656 specUrl: input.normalizedUrl,
657 name,
658 namespace: namespaceFromSourceName(name),
659 transport: null,
660 authInference: inferOpenApiAuth(parsedDocument),
661 toolCount: manifest.right.tools.length,
662 warnings: [],
663 },
664 } satisfies OpenApiProbeResult;
665 }).pipe(Effect.catchAll(() => Effect.succeed(null)));
666
667const looksLikeGraphqlEndpoint = (normalizedUrl: string): boolean =>
668 /graphql/i.test(new URL(normalizedUrl).pathname);
669
670const tryParseJson = (value: string): unknown => {
671 try {
672 return JSON.parse(value) as unknown;
673 } catch {
674 return null;
675 }
676};
677
678const tryDetectGraphql = (input: {
679 normalizedUrl: string;
680 headers: Readonly<Record<string, string>>;
681}): Effect.Effect<GraphqlProbeResult | null, never, never> =>
682 Effect.gen(function* () {
683 const response = yield* Effect.either(executeHttpProbe({
684 method: "POST",
685 url: input.normalizedUrl,
686 headers: {
687 accept: "application/graphql-response+json, application/json",
688 "content-type": "application/json",
689 ...input.headers,
690 },
691 body: JSON.stringify({ query: GRAPHQL_INTROSPECTION_QUERY }),
692 }));
693
694 if (response._tag === "Left") {
695 return null;
696 }
697
698 const parsed = tryParseJson(response.right.text);
699 const contentType = (response.right.headers["content-type"] ?? "").toLowerCase();
700 const data = isRecord(parsed) && isRecord(parsed.data) ? parsed.data : null;
701 if (data && isRecord(data.__schema)) {
702 const name = defaultNameFromEndpoint(input.normalizedUrl);
703 return {
704 result: {
705 detectedKind: "graphql",
706 confidence: "high",
707 endpoint: input.normalizedUrl,
708 specUrl: null,
709 name,
710 namespace: namespaceFromSourceName(name),
711 transport: null,
712 authInference: noneAuthInference("GraphQL introspection succeeded without an advertised auth requirement", "medium"),
713 toolCount: null,
714 warnings: [],
715 },
716 } satisfies GraphqlProbeResult;
717 }
718
719 const errors = isRecord(parsed) && Array.isArray(parsed.errors)
720 ? parsed.errors
721 : [];
722 const graphqlErrors = errors
723 .map((error) => isRecord(error) ? asString(error.message) : null)
724 .filter((message): message is string => message !== null);
725
726 const mediumConfidenceGraphql =
727 contentType.includes("application/graphql-response+json")
728 || (looksLikeGraphqlEndpoint(input.normalizedUrl) && response.right.status >= 400 && response.right.status < 500)
729 || graphqlErrors.some((message) => /introspection|graphql|query/i.test(message));
730
731 if (!mediumConfidenceGraphql) {
732 return null;
733 }
734
735 const name = defaultNameFromEndpoint(input.normalizedUrl);
736 return {
737 result: {
738 detectedKind: "graphql",
739 confidence: data ? "high" : "medium",
740 endpoint: input.normalizedUrl,
741 specUrl: null,
742 name,
743 namespace: namespaceFromSourceName(name),
744 transport: null,
745 authInference:
746 response.right.status === 401 || response.right.status === 403
747 ? parseChallengeAuthInference(
748 response.right.headers,
749 "GraphQL endpoint rejected introspection and did not advertise a concrete auth scheme",
750 )
751 : noneAuthInference(
752 graphqlErrors.length > 0
753 ? `GraphQL endpoint responded with errors during introspection: ${graphqlErrors[0]}`
754 : "GraphQL endpoint shape detected",
755 "medium",
756 ),
757 toolCount: null,
758 warnings: graphqlErrors.length > 0 ? [graphqlErrors[0]] : [],
759 },
760 } satisfies GraphqlProbeResult;
761 }).pipe(Effect.catchAll(() => Effect.succeed(null)));
762
763const tryDetectMcp = (input: {
764 normalizedUrl: string;
765 headers: Readonly<Record<string, string>>;
766}): Effect.Effect<McpProbeResult | null, never, never> =>
767 Effect.gen(function* () {
768 const connector = createSdkMcpConnector({
769 endpoint: input.normalizedUrl,
770 headers: input.headers,
771 transport: "auto",
772 });
773
774 const discovered = yield* Effect.either(discoverMcpToolsFromConnector({
775 connect: connector,
776 sourceKey: "discovery",
777 namespace: namespaceFromSourceName(defaultNameFromEndpoint(input.normalizedUrl)),
778 }));
779
780 if (discovered._tag === "Right") {
781 const name = defaultNameFromEndpoint(input.normalizedUrl);
782 return {
783 result: {
784 detectedKind: "mcp",
785 confidence: "high",
786 endpoint: input.normalizedUrl,
787 specUrl: null,
788 name,
789 namespace: namespaceFromSourceName(name),
790 transport: "auto",
791 authInference: noneAuthInference("MCP tool discovery succeeded without an advertised auth requirement", "medium"),
792 toolCount: discovered.right.manifest.tools.length,
793 warnings: [],
794 },
795 } satisfies McpProbeResult;
796 }
797
798 const oauthProbe = yield* Effect.either(startMcpOAuthAuthorization({
799 endpoint: input.normalizedUrl,
800 redirectUrl: "http://127.0.0.1/executor-v3/discovery/oauth/callback",
801 state: "source-discovery",
802 }));
803
804 if (oauthProbe._tag === "Left") {
805 return null;
806 }
807
808 const name = defaultNameFromEndpoint(input.normalizedUrl);
809 return {
810 result: {
811 detectedKind: "mcp",
812 confidence: "high",
813 endpoint: input.normalizedUrl,
814 specUrl: null,
815 name,
816 namespace: namespaceFromSourceName(name),
817 transport: "auto",
818 authInference: supportedAuthInference("oauth2", {
819 confidence: "high",
820 reason: "MCP endpoint advertised OAuth during discovery",
821 headerName: "Authorization",
822 prefix: "Bearer ",
823 parameterName: null,
824 parameterLocation: null,
825 oauthAuthorizationUrl: oauthProbe.right.authorizationUrl,
826 oauthTokenUrl: oauthProbe.right.authorizationServerUrl,
827 oauthScopes: [],
828 }),
829 toolCount: null,
830 warnings: ["OAuth is required before MCP tools can be listed."],
831 },
832 } satisfies McpProbeResult;
833 }).pipe(Effect.catchAll(() => Effect.succeed(null)));
834
835const fallbackDiscoveryResult = (normalizedUrl: string): SourceDiscoveryResult => {
836 const endpoint = normalizedUrl;
837 const name = defaultNameFromEndpoint(endpoint);
838 return {
839 detectedKind: "unknown",
840 confidence: "low",
841 endpoint,
842 specUrl: null,
843 name,
844 namespace: namespaceFromSourceName(name),
845 transport: null,
846 authInference: unknownAuthInference("Could not infer source kind or auth requirements from the provided URL"),
847 toolCount: null,
848 warnings: ["Could not confirm whether the URL is OpenAPI, GraphQL, or MCP."],
849 };
850};
851
852export const discoverSource = (input: {
853 url: string;
854 probeAuth?: SourceProbeAuth | null;
855}): Effect.Effect<SourceDiscoveryResult, Error, never> =>
856 Effect.gen(function* () {
857 const normalizedUrl = normalizeUrl(input.url);
858 const headers = probeHeadersFromAuth(input.probeAuth);
859
860 const openApi = yield* tryDetectOpenApi({
861 normalizedUrl,
862 headers,
863 });
864 if (openApi) {
865 return openApi.result;
866 }
867
868 const graphql = yield* tryDetectGraphql({
869 normalizedUrl,
870 headers,
871 });
872 if (graphql) {
873 return graphql.result;
874 }
875
876 const mcp = yield* tryDetectMcp({
877 normalizedUrl,
878 headers,
879 });
880 if (mcp) {
881 return mcp.result;
882 }
883
884 return fallbackDiscoveryResult(normalizedUrl);
885 });
packages/control-plane/src/schema/models/source-discovery.ts+88−0
@@ -0,0 +1,88 @@
1import { Schema } from "effect";
2
3import { StringMapSchema, SourceTransportSchema } from "./source";
4
5export const SourceDiscoveryKindSchema = Schema.Literal(
6 "mcp",
7 "openapi",
8 "graphql",
9 "unknown",
10);
11
12export const SourceDiscoveryConfidenceSchema = Schema.Literal(
13 "low",
14 "medium",
15 "high",
16);
17
18export const SourceDiscoveryAuthKindSchema = Schema.Literal(
19 "none",
20 "bearer",
21 "oauth2",
22 "apiKey",
23 "basic",
24 "unknown",
25);
26
27export const SourceDiscoveryAuthParameterLocationSchema = Schema.Literal(
28 "header",
29 "query",
30 "cookie",
31);
32
33export const SourceProbeAuthSchema = Schema.Union(
34 Schema.Struct({
35 kind: Schema.Literal("none"),
36 }),
37 Schema.Struct({
38 kind: Schema.Literal("bearer"),
39 headerName: Schema.optional(Schema.NullOr(Schema.String)),
40 prefix: Schema.optional(Schema.NullOr(Schema.String)),
41 token: Schema.String,
42 }),
43 Schema.Struct({
44 kind: Schema.Literal("basic"),
45 username: Schema.String,
46 password: Schema.String,
47 }),
48 Schema.Struct({
49 kind: Schema.Literal("headers"),
50 headers: StringMapSchema,
51 }),
52);
53
54export const SourceAuthInferenceSchema = Schema.Struct({
55 suggestedKind: SourceDiscoveryAuthKindSchema,
56 confidence: SourceDiscoveryConfidenceSchema,
57 supported: Schema.Boolean,
58 reason: Schema.String,
59 headerName: Schema.NullOr(Schema.String),
60 prefix: Schema.NullOr(Schema.String),
61 parameterName: Schema.NullOr(Schema.String),
62 parameterLocation: Schema.NullOr(SourceDiscoveryAuthParameterLocationSchema),
63 oauthAuthorizationUrl: Schema.NullOr(Schema.String),
64 oauthTokenUrl: Schema.NullOr(Schema.String),
65 oauthScopes: Schema.Array(Schema.String),
66});
67
68export const SourceDiscoveryResultSchema = Schema.Struct({
69 detectedKind: SourceDiscoveryKindSchema,
70 confidence: SourceDiscoveryConfidenceSchema,
71 endpoint: Schema.String,
72 specUrl: Schema.NullOr(Schema.String),
73 name: Schema.NullOr(Schema.String),
74 namespace: Schema.NullOr(Schema.String),
75 transport: Schema.NullOr(SourceTransportSchema),
76 authInference: SourceAuthInferenceSchema,
77 toolCount: Schema.NullOr(Schema.Number),
78 warnings: Schema.Array(Schema.String),
79});
80
81export type SourceDiscoveryKind = typeof SourceDiscoveryKindSchema.Type;
82export type SourceDiscoveryConfidence = typeof SourceDiscoveryConfidenceSchema.Type;
83export type SourceDiscoveryAuthKind = typeof SourceDiscoveryAuthKindSchema.Type;
84export type SourceDiscoveryAuthParameterLocation =
85 typeof SourceDiscoveryAuthParameterLocationSchema.Type;
86export type SourceProbeAuth = typeof SourceProbeAuthSchema.Type;
87export type SourceAuthInference = typeof SourceAuthInferenceSchema.Type;
88export type SourceDiscoveryResult = typeof SourceDiscoveryResultSchema.Type;