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 @@
1+import {
2+ FetchHttpClient,
3+ HttpClient,
4+ HttpClientRequest,
5+} from "@effect/platform";
6+import {
7+ createSdkMcpConnector,
8+ discoverMcpToolsFromConnector,
9+} from "@executor-v3/codemode-mcp";
10+import {
11+ extractOpenApiManifest,
12+ parseOpenApiDocument,
13+} from "@executor-v3/codemode-openapi";
14+import {
15+ type SourceAuthInference,
16+ type SourceDiscoveryResult,
17+ type SourceProbeAuth,
18+} from "#schema";
19+import * as Effect from "effect/Effect";
20+import { getIntrospectionQuery } from "graphql";
21+import { startMcpOAuthAuthorization } from "./mcp-oauth";
22+import { namespaceFromSourceName } from "./tool-artifacts";
23+
24+const GRAPHQL_INTROSPECTION_QUERY = getIntrospectionQuery();
25+
26+type HttpProbeResponse = {
27+ status: number;
28+ headers: Readonly<Record<string, string>>;
29+ text: string;
30+};
31+
32+type OpenApiProbeResult = {
33+ result: SourceDiscoveryResult;
34+};
35+
36+type GraphqlProbeResult = {
37+ result: SourceDiscoveryResult;
38+};
39+
40+type McpProbeResult = {
41+ result: SourceDiscoveryResult;
42+};
43+
44+type 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+
58+const isRecord = (value: unknown): value is Record<string, unknown> =>
59+ typeof value === "object" && value !== null && !Array.isArray(value);
60+
61+const asString = (value: unknown): string | null =>
62+ typeof value === "string" && value.length > 0 ? value : null;
63+
64+const 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+
73+const defaultNameFromEndpoint = (endpoint: string): string => new URL(endpoint).hostname;
74+
75+const 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+
89+const unsupportedAuthInference = (
90+ kind: SourceAuthInference["suggestedKind"],
91+ input: Omit<SourceAuthInference, "suggestedKind" | "supported">,
92+): SourceAuthInference => ({
93+ ...input,
94+ suggestedKind: kind,
95+ supported: false,
96+});
97+
98+const 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+
107+const 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+
121+const 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+
136+const 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+
177+const 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+
197+const 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+
232+const parseStructuredDocument = (value: string): unknown =>
233+ parseOpenApiDocument(value) as unknown;
234+
235+const 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+
252+const 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+
269+const 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+
327+const 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+
428+const 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+
590+const 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+
610+const 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+
667+const looksLikeGraphqlEndpoint = (normalizedUrl: string): boolean =>
668+ /graphql/i.test(new URL(normalizedUrl).pathname);
669+
670+const tryParseJson = (value: string): unknown => {
671+ try {
672+ return JSON.parse(value) as unknown;
673+ } catch {
674+ return null;
675+ }
676+};
677+
678+const 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+
763+const 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+
835+const 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+
852+export 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 @@
1+import { Schema } from "effect";
2+
3+import { StringMapSchema, SourceTransportSchema } from "./source";
4+
5+export const SourceDiscoveryKindSchema = Schema.Literal(
6+ "mcp",
7+ "openapi",
8+ "graphql",
9+ "unknown",
10+);
11+
12+export const SourceDiscoveryConfidenceSchema = Schema.Literal(
13+ "low",
14+ "medium",
15+ "high",
16+);
17+
18+export const SourceDiscoveryAuthKindSchema = Schema.Literal(
19+ "none",
20+ "bearer",
21+ "oauth2",
22+ "apiKey",
23+ "basic",
24+ "unknown",
25+);
26+
27+export const SourceDiscoveryAuthParameterLocationSchema = Schema.Literal(
28+ "header",
29+ "query",
30+ "cookie",
31+);
32+
33+export 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+
54+export 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+
68+export 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+
81+export type SourceDiscoveryKind = typeof SourceDiscoveryKindSchema.Type;
82+export type SourceDiscoveryConfidence = typeof SourceDiscoveryConfidenceSchema.Type;
83+export type SourceDiscoveryAuthKind = typeof SourceDiscoveryAuthKindSchema.Type;
84+export type SourceDiscoveryAuthParameterLocation =
85+ typeof SourceDiscoveryAuthParameterLocationSchema.Type;
86+export type SourceProbeAuth = typeof SourceProbeAuthSchema.Type;
87+export type SourceAuthInference = typeof SourceAuthInferenceSchema.Type;
88+export type SourceDiscoveryResult = typeof SourceDiscoveryResultSchema.Type;