History

feat: auto detect sources

Rhys Sullivan committed 7 months ago

Commit 87bd7371 parent a4d4fd917 files · +462 −86Browse files

Unified · Split

17 files
  1. apps/server/src/handlers/sources.ts +13 −0
  2. apps/web/src/pages/sources.tsx +192 −75
  3. packages/clients/react/src/atoms.ts +2 −0
  4. packages/clients/react/src/index.ts +1 −0
  5. packages/clients/react/src/source-plugin.ts +2 −0
  6. packages/core/api/src/sources/api.ts +17 −0
  7. packages/core/sdk/src/executor.ts +3 −1
  8. packages/core/sdk/src/index.ts +1 −0
  9. packages/core/sdk/src/sources.ts +43 −0
  10. packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +13 −4
  11. packages/plugins/google-discovery/src/sdk/plugin.ts +36 −0
  12. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +2 −1
  13. packages/plugins/graphql/src/sdk/plugin.ts +25 −0
  14. packages/plugins/mcp/src/react/AddMcpSource.tsx +14 −2
  15. packages/plugins/mcp/src/sdk/plugin.ts +55 −0
  16. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +11 −2
  17. packages/plugins/openapi/src/sdk/plugin.ts +32 −1
apps/server/src/handlers/sources.ts+13−0
@@ -50,5 +50,18 @@ export const SourcesHandlers = HttpApiBuilder.group( · 49 unmodified lines
5050 mayElicit: t.mayElicit,
5151 }));
5252 }),
53+ )
54+ .handle("detect", ({ path, payload }) =>
55+ Effect.gen(function* () {
56+ const executor = yield* ExecutorService;
57+ const results = yield* executor.sources.detect(payload.url);
58+ return results.map((r) => ({
59+ kind: r.kind,
60+ confidence: r.confidence,
61+ endpoint: r.endpoint,
62+ name: r.name,
63+ namespace: r.namespace,
64+ }));
65+ }),
5366 ),
5467 );
apps/web/src/pages/sources.tsx+192−75
@@ -1,6 +1,6 @@
1−import { useState, Suspense } from "react";
1+import { useReducer, useCallback, Suspense } from "react";
22 import { Link } from "@tanstack/react-router";
3−import { Result, useAtomValue, useAtomRefresh, sourcesAtom } from "@executor/react";
3+import { Result, useAtomValue, useAtomRefresh, useAtomSet, sourcesAtom, detectSource } from "@executor/react";
44 import type { SourcePlugin } from "@executor/react";
55 import { openApiSourcePlugin } from "@executor/plugin-openapi/react";
66 import { mcpSourcePlugin } from "@executor/plugin-mcp/react";
@@ -19,78 +19,113 @@ const sourcePlugins: SourcePlugin[] = [ · 12 unmodified lines
1919 graphqlSourcePlugin,
2020 ];
2121
22+const KIND_TO_PLUGIN_KEY: Record<string, string> = {
23+ openapi: "openapi",
24+ mcp: "mcp",
25+ graphql: "graphql",
26+ googleDiscovery: "googleDiscovery",
27+};
28+
29+// ---------------------------------------------------------------------------
30+// State machine
31+// ---------------------------------------------------------------------------
32+
33+type State = {
34+ step: "list" | "detecting" | "adding";
35+ url: string;
36+ error: string | null;
37+ pluginKey: string | null;
38+ initialUrl: string | undefined;
39+};
40+
41+type Action =
42+ | { type: "set-url"; url: string }
43+ | { type: "detect-start" }
44+ | { type: "detect-ok"; pluginKey: string; url: string }
45+ | { type: "detect-no-match" }
46+ | { type: "detect-unknown-kind"; kind: string }
47+ | { type: "detect-fail" }
48+ | { type: "add-manual"; pluginKey: string }
49+ | { type: "back" };
50+
51+const init: State = { step: "list", url: "", error: null, pluginKey: null, initialUrl: undefined };
52+
53+function reducer(state: State, action: Action): State {
54+ switch (action.type) {
55+ case "set-url":
56+ return { ...state, step: "list", url: action.url, error: null };
57+ case "detect-start":
58+ return { ...state, step: "detecting", error: null };
59+ case "detect-ok":
60+ return { ...state, step: "adding", pluginKey: action.pluginKey, initialUrl: action.url };
61+ case "detect-no-match":
62+ return { ...state, step: "list", error: "Could not detect a source type from this URL. Try adding manually." };
63+ case "detect-unknown-kind":
64+ return { ...state, step: "list", error: `Detected source type "${action.kind}" but no plugin is available for it.` };
65+ case "detect-fail":
66+ return { ...state, step: "list", error: "Detection failed. Try adding a source manually." };
67+ case "add-manual":
68+ return { ...state, step: "adding", pluginKey: action.pluginKey, initialUrl: undefined };
69+ case "back":
70+ return init;
71+ default:
72+ return state;
73+ }
74+}
75+
2276 // ---------------------------------------------------------------------------
2377 // Page
2478 // ---------------------------------------------------------------------------
2579
2680 export function SourcesPage() {
27− const [adding, setAdding] = useState<string | null>(null);
81+ const [state, dispatch] = useReducer(reducer, init);
2882 const sources = useAtomValue(sourcesAtom());
2983 const refreshSources = useAtomRefresh(sourcesAtom());
84+ const doDetect = useAtomSet(detectSource, { mode: "promise" });
3085
31− const plugin = adding
32− ? sourcePlugins.find((p) => p.key === adding)
33− : undefined;
86+ const handleDetect = useCallback(async () => {
87+ const trimmed = state.url.trim();
88+ if (!trimmed) return;
89+ dispatch({ type: "detect-start" });
90+ try {
91+ const results = await doDetect({
92+ path: { scopeId: "default" as any },
93+ payload: { url: trimmed },
94+ });
95+ if (results.length === 0) {
96+ dispatch({ type: "detect-no-match" });
97+ return;
98+ }
99+ const pluginKey = KIND_TO_PLUGIN_KEY[results[0].kind];
100+ if (pluginKey) {
101+ dispatch({ type: "detect-ok", pluginKey, url: trimmed });
102+ } else {
103+ dispatch({ type: "detect-unknown-kind", kind: results[0].kind });
104+ }
105+ } catch {
106+ dispatch({ type: "detect-fail" });
107+ }
108+ }, [state.url, doDetect]);
34109
35− const renderSourceGrid = (sources: readonly {
36− id: string;
37− name: string;
38− kind: string;
39− runtime?: boolean;
40− }[]) => (
41− <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
42− {sources.map((s) => (
43− <Link
44− key={s.id}
45− to="/sources/$namespace"
46− params={{ namespace: s.id }}
47− className="flex h-full flex-col rounded-2xl border border-border bg-card px-5 py-4 transition-colors hover:border-primary/25 hover:bg-card/90"
48− >
49− <div className="flex items-start gap-3">
50− <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
51− <svg viewBox="0 0 16 16" className="size-4">
52− <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.2" />
53− <path d="M8 5v6M5 8h6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
54− </svg>
55− </div>
56− <div className="min-w-0 flex-1">
57− <div className="flex items-start justify-between gap-2">
58− <div className="truncate text-sm font-semibold text-foreground">
59− {s.name}
60− </div>
61− <div className="flex shrink-0 items-center gap-1.5">
62− {s.runtime && (
63− <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
64− built-in
65− </span>
66− )}
67− <span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground">
68− {s.kind}
69− </span>
70− </div>
71− </div>
72− <div className="mt-0.5 text-xs text-muted-foreground">
73− {s.id}
74− </div>
75− </div>
76− </div>
77− </Link>
78− ))}
79− </div>
80− );
110+ // ---------------------------------------------------------------------------
111+ // Adding view
112+ // ---------------------------------------------------------------------------
81113
82− if (plugin) {
114+ if (state.step === "adding" && state.pluginKey) {
115+ const plugin = sourcePlugins.find((p) => p.key === state.pluginKey);
116+ if (!plugin) return null;
83117 const AddComponent = plugin.add;
84118 return (
85119 <div className="min-h-0 flex-1 overflow-y-auto">
86120 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
87121 <Suspense fallback={<p className="text-sm text-muted-foreground">Loading…</p>}>
88122 <AddComponent
123+ initialUrl={state.initialUrl}
89124 onComplete={() => {
90− setAdding(null);
125+ dispatch({ type: "back" });
91126 refreshSources();
92127 }}
93− onCancel={() => setAdding(null)}
128+ onCancel={() => dispatch({ type: "back" })}
94129 />
95130 </Suspense>
96131 </div>
@@ -98,32 +133,64 @@ export function SourcesPage() { · 1 unmodified line
98133 );
99134 }
100135
136+ // ---------------------------------------------------------------------------
137+ // List view
138+ // ---------------------------------------------------------------------------
139+
140+ const isDetecting = state.step === "detecting";
141+ const error = state.step === "list" ? state.error : null;
142+
101143 return (
102144 <div className="min-h-0 flex-1 overflow-y-auto">
103145 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
104146 {/* Header */}
105− <div className="flex items-end justify-between mb-8">
106− <div>
107− <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl">
108− Sources
109− </h1>
110− <p className="mt-1.5 text-[14px] text-muted-foreground">
111− Tool providers available in this workspace.
112− </p>
147+ <div className="mb-8">
148+ <div className="flex items-end justify-between">
149+ <div>
150+ <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl">
151+ Sources
152+ </h1>
153+ <p className="mt-1.5 text-[14px] text-muted-foreground">
154+ Tool providers available in this workspace.
155+ </p>
156+ </div>
113157 </div>
114− <div className="flex gap-2">
115− {sourcePlugins.map((p) => (
158+
159+ {/* URL detection input */}
160+ <div className="mt-5">
161+ <div className="flex gap-2">
162+ <input
163+ type="url"
164+ value={state.url}
165+ onChange={(e) => dispatch({ type: "set-url", url: e.target.value })}
166+ onKeyDown={(e) => { if (e.key === "Enter") handleDetect(); }}
167+ placeholder="Paste a URL to auto-detect source type..."
168+ disabled={isDetecting}
169+ className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
170+ />
116171 <button
117− key={p.key}
118− onClick={() => setAdding(p.key)}
119− className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
172+ onClick={handleDetect}
173+ disabled={isDetecting || !state.url.trim()}
174+ className="inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:pointer-events-none"
120175 >
121− <svg viewBox="0 0 16 16" fill="none" className="size-3.5">
122− <path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
123− </svg>
124− Add {p.label}
176+ {isDetecting ? "Detecting..." : "Detect"}
125177 </button>
126− ))}
178+ </div>
179+ {error && (
180+ <p className="mt-2 text-sm text-destructive">{error}</p>
181+ )}
182+ <div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
183+ <span>Or add manually:</span>
184+ {sourcePlugins.map((p) => (
185+ <button
186+ key={p.key}
187+ onClick={() => dispatch({ type: "add-manual", pluginKey: p.key })}
188+ className="rounded-md border border-border px-2 py-1 text-xs font-medium transition-colors hover:bg-muted"
189+ >
190+ {p.label}
191+ </button>
192+ ))}
193+ </div>
127194 </div>
128195 </div>
129196
@@ -166,7 +233,7 @@ export function SourcesPage() { · 36 unmodified lines
166233 Runtime sources exposed by the loaded executor plugins.
167234 </p>
168235 </div>
169− {renderSourceGrid(builtInSources)}
236+ <SourceGrid sources={builtInSources} />
170237 </section>
171238 )}
172239
@@ -180,7 +247,7 @@ export function SourcesPage() { · 7 unmodified lines
180247 User-configured sources available in this workspace.
181248 </p>
182249 </div>
183− {renderSourceGrid(connectedSources)}
250+ <SourceGrid sources={connectedSources} />
184251 </section>
185252 )}
186253 </div>
@@ -191,3 +258,53 @@ export function SourcesPage() { · 4 unmodified lines
191258 </div>
192259 );
193260 }
261+
262+// ---------------------------------------------------------------------------
263+// Source grid
264+// ---------------------------------------------------------------------------
265+
266+function SourceGrid(props: {
267+ sources: readonly { id: string; name: string; kind: string; runtime?: boolean }[];
268+}) {
269+ return (
270+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
271+ {props.sources.map((s) => (
272+ <Link
273+ key={s.id}
274+ to="/sources/$namespace"
275+ params={{ namespace: s.id }}
276+ className="flex h-full flex-col rounded-2xl border border-border bg-card px-5 py-4 transition-colors hover:border-primary/25 hover:bg-card/90"
277+ >
278+ <div className="flex items-start gap-3">
279+ <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
280+ <svg viewBox="0 0 16 16" className="size-4">
281+ <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.2" />
282+ <path d="M8 5v6M5 8h6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
283+ </svg>
284+ </div>
285+ <div className="min-w-0 flex-1">
286+ <div className="flex items-start justify-between gap-2">
287+ <div className="truncate text-sm font-semibold text-foreground">
288+ {s.name}
289+ </div>
290+ <div className="flex shrink-0 items-center gap-1.5">
291+ {s.runtime && (
292+ <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
293+ built-in
294+ </span>
295+ )}
296+ <span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground">
297+ {s.kind}
298+ </span>
299+ </div>
300+ </div>
301+ <div className="mt-0.5 text-xs text-muted-foreground">
302+ {s.id}
303+ </div>
304+ </div>
305+ </div>
306+ </Link>
307+ ))}
308+ </div>
309+ );
310+}
packages/clients/react/src/atoms.ts+2−0
@@ -69,3 +69,5 @@ export const removeSecret = ExecutorApiClient.mutation("secrets", "remove"); · 68 unmodified lines
6969 export const removeSource = ExecutorApiClient.mutation("sources", "remove");
7070
7171 export const refreshSource = ExecutorApiClient.mutation("sources", "refresh");
72+
73+export const detectSource = ExecutorApiClient.mutation("sources", "detect");
packages/clients/react/src/index.ts+1−0
@@ -33,6 +33,7 @@ export { · 32 unmodified lines
3333 removeSecret,
3434 removeSource,
3535 refreshSource,
36+ detectSource,
3637 } from "./atoms";
3738
3839 // Provider
packages/clients/react/src/source-plugin.ts+2−0
@@ -24,10 +24,12 @@ export interface SourcePlugin { · 23 unmodified lines
2424 * The "add source" flow.
2525 * Plugin controls the entire experience.
2626 * Call `onComplete` when done, `onCancel` to bail out.
27+ * `initialUrl` is provided when the user arrived via URL auto-detection.
2728 */
2829 readonly add: ComponentType<{
2930 readonly onComplete: () => void;
3031 readonly onCancel: () => void;
32+ readonly initialUrl?: string;
3133 }>;
3234
3335 /**
packages/core/api/src/sources/api.ts+17−0
@@ -39,6 +39,18 @@ const ToolMetadataResponse = Schema.Struct({ · 38 unmodified lines
3939 mayElicit: Schema.optional(Schema.Boolean),
4040 });
4141
42+const DetectRequest = Schema.Struct({
43+ url: Schema.String,
44+});
45+
46+const DetectResultResponse = Schema.Struct({
47+ kind: Schema.String,
48+ confidence: Schema.Literal("high", "medium", "low"),
49+ endpoint: Schema.String,
50+ name: Schema.String,
51+ namespace: Schema.String,
52+});
53+
4254 // ---------------------------------------------------------------------------
4355 // Group
4456 // ---------------------------------------------------------------------------
@@ -60,4 +72,9 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 15 unmodified lines
6072 HttpApiEndpoint.get("tools")`/scopes/${scopeIdParam}/sources/${sourceIdParam}/tools`
6173 .addSuccess(Schema.Array(ToolMetadataResponse)),
6274 )
75+ .add(
76+ HttpApiEndpoint.post("detect")`/scopes/${scopeIdParam}/sources/detect`
77+ .setPayload(DetectRequest)
78+ .addSuccess(Schema.Array(DetectResultResponse)),
79+ )
6380 .prefix("/v1") {}
packages/core/sdk/src/executor.ts+3−1
@@ -10,7 +10,7 @@ import type { · 9 unmodified lines
1010 ToolListFilter,
1111 InvokeOptions,
1212 } from "./tools";
13−import type { Source, SourceRegistry } from "./sources";
13+import type { Source, SourceDetectionResult, SourceRegistry } from "./sources";
1414 import type { Policy, PolicyEngine } from "./policies";
1515 import type { Scope } from "./scope";
1616 import type {
@@ -70,6 +70,7 @@ export type Executor< · 53 unmodified lines
7070 readonly list: () => Effect.Effect<readonly Source[]>;
7171 readonly remove: (sourceId: string) => Effect.Effect<void>;
7272 readonly refresh: (sourceId: string) => Effect.Effect<void>;
73+ readonly detect: (url: string) => Effect.Effect<readonly SourceDetectionResult[]>;
7374 };
7475
7576 readonly policies: {
@@ -193,6 +194,7 @@ export const createExecutor = < · 117 unmodified lines
193194 list: () => sources.list(),
194195 remove: (sourceId: string) => sources.remove(sourceId),
195196 refresh: (sourceId: string) => sources.refresh(sourceId),
197+ detect: (url: string) => sources.detect(url),
196198 },
197199
198200 policies: {
packages/core/sdk/src/index.ts+1−0
@@ -27,6 +27,7 @@ export { · 26 unmodified lines
2727 // Sources
2828 export {
2929 Source,
30+ SourceDetectionResult,
3031 SourceRegistry,
3132 makeInMemorySourceRegistry,
3233 type SourceManager,
packages/core/sdk/src/sources.ts+43−0
@@ -19,6 +19,23 @@ export class Source extends Schema.Class<Source>("Source")({ · 18 unmodified lines
1919 canRefresh: Schema.optional(Schema.Boolean),
2020 }) {}
2121
22+// ---------------------------------------------------------------------------
23+// SourceDetectionResult — returned by detect() on a SourceManager
24+// ---------------------------------------------------------------------------
25+
26+export class SourceDetectionResult extends Schema.Class<SourceDetectionResult>("SourceDetectionResult")({
27+ /** Plugin kind that detected this source */
28+ kind: Schema.String,
29+ /** How confident the plugin is that the URL matches */
30+ confidence: Schema.Literal("high", "medium", "low"),
31+ /** The URL that was probed */
32+ endpoint: Schema.String,
33+ /** Suggested human-readable name */
34+ name: Schema.String,
35+ /** Suggested namespace */
36+ namespace: Schema.String,
37+}) {}
38+
2239 // ---------------------------------------------------------------------------
2340 // SourceManager — plugin-provided source lifecycle handler
2441 //
@@ -38,6 +55,9 @@ export interface SourceManager { · 13 unmodified lines
3855
3956 /** Re-fetch / re-register tools for a source */
4057 readonly refresh?: (sourceId: string) => Effect.Effect<void>;
58+
59+ /** Detect whether a URL matches this plugin's source type */
60+ readonly detect?: (url: string) => Effect.Effect<SourceDetectionResult | null>;
4161 }
4262
4363 // ---------------------------------------------------------------------------
@@ -66,6 +86,9 @@ export class SourceRegistry extends Context.Tag( · 22 unmodified lines
6686
6787 /** Refresh a source by id. Finds the owning manager and delegates. */
6888 readonly refresh: (sourceId: string) => Effect.Effect<void>;
89+
90+ /** Detect source type from a URL by probing all registered plugins */
91+ readonly detect: (url: string) => Effect.Effect<readonly SourceDetectionResult[]>;
6992 }
7093 >() {}
7194
@@ -139,5 +162,25 @@ export const makeInMemorySourceRegistry = () => { · 67 unmodified lines
139162 }
140163 }
141164 }),
165+
166+ detect: (url: string) =>
167+ Effect.gen(function* () {
168+ const detectors = [...managers.values()]
169+ .filter((m) => m.detect)
170+ .map((m) =>
171+ m.detect!(url).pipe(
172+ Effect.timeout("5 seconds"),
173+ Effect.catchAll(() => Effect.succeed(null)),
174+ ),
175+ );
176+
177+ const results = yield* Effect.all(detectors, { concurrency: "unbounded" });
178+ return results
179+ .filter((r): r is SourceDetectionResult => r !== null)
180+ .sort((a, b) => {
181+ const order = { high: 0, medium: 1, low: 2 };
182+ return order[a.confidence] - order[b.confidence];
183+ });
184+ }),
142185 };
143186 };
packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx+13−4
@@ -1,4 +1,4 @@
1−import { useCallback, useMemo, useState } from "react";
1+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22 import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react";
33
44 import {
@@ -263,15 +263,16 @@ function openOAuthPopup( · 258 unmodified lines
263263 export default function AddGoogleDiscoverySource(props: {
264264 readonly onComplete: () => void;
265265 readonly onCancel: () => void;
266+ readonly initialUrl?: string;
266267 }) {
267268 const defaultTemplate =
268269 GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === "google-sheets")
269270 ?? GOOGLE_DISCOVERY_TEMPLATES[0]!;
270271 const [discoveryUrl, setDiscoveryUrl] = useState(
271− defaultTemplate.discoveryUrl,
272+ props.initialUrl ?? defaultTemplate.discoveryUrl,
272273 );
273− const [name, setName] = useState(defaultTemplate.name);
274− const [selectedTemplateId, setSelectedTemplateId] = useState(defaultTemplate.id);
274+ const [name, setName] = useState(props.initialUrl ? "" : defaultTemplate.name);
275+ const [selectedTemplateId, setSelectedTemplateId] = useState(props.initialUrl ? "" : defaultTemplate.id);
275276 const selectedTemplate =
276277 GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === selectedTemplateId)
277278 ?? null;
@@ -343,6 +344,14 @@ export default function AddGoogleDiscoverySource(props: { · 65 unmodified lines
343344 }
344345 }, [discoveryUrl, doProbe, name]);
345346
347+ const autoProbed = useRef(false);
348+ useEffect(() => {
349+ if (props.initialUrl && !autoProbed.current) {
350+ autoProbed.current = true;
351+ handleProbe();
352+ }
353+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
354+
346355 const handleStartOAuth = useCallback(async () => {
347356 if (!probe) return;
348357 setStartingOAuth(true);
packages/plugins/google-discovery/src/sdk/plugin.ts+36−0
@@ -4,6 +4,7 @@ import { Effect, Option } from "effect"; · 3 unmodified lines
44
55 import {
66 Source,
7+ SourceDetectionResult,
78 definePlugin,
89 type ExecutorPlugin,
910 type PluginContext,
@@ -306,6 +307,41 @@ export const googleDiscoveryPlugin = (options?: { · 296 unmodified lines
306307 yield* bindingStore.removeSourceMeta(sourceId);
307308 yield* bindingStore.removeSourceData(sourceId);
308309 }),
310+ detect: (url: string) =>
311+ Effect.gen(function* () {
312+ const trimmed = url.trim();
313+ if (!trimmed) return null;
314+ try { new URL(trimmed); } catch { return null; }
315+
316+ // Only probe URLs that look like Google Discovery docs
317+ const isGoogleUrl = trimmed.includes("googleapis.com");
318+ const isDiscoveryPath = trimmed.includes("/discovery/") || trimmed.includes("$discovery");
319+ if (!isGoogleUrl && !isDiscoveryPath) return null;
320+
321+ const discoveryText = yield* fetchDiscoveryDocument(trimmed).pipe(
322+ Effect.catchAll(() => Effect.succeed(null)),
323+ );
324+ if (!discoveryText) return null;
325+
326+ const manifest = yield* extractGoogleDiscoveryManifest(discoveryText).pipe(
327+ Effect.catchAll(() => Effect.succeed(null)),
328+ );
329+ if (!manifest) return null;
330+
331+ const name = Option.getOrElse(manifest.title, () => `${manifest.service} ${manifest.version}`);
332+
333+ return new SourceDetectionResult({
334+ kind: "googleDiscovery",
335+ confidence: "high",
336+ endpoint: trimmed,
337+ name,
338+ namespace: deriveNamespace({
339+ name,
340+ service: manifest.service,
341+ version: manifest.version,
342+ }),
343+ });
344+ }),
309345 refresh: (sourceId) =>
310346 Effect.gen(function* () {
311347 const sourceData = yield* bindingStore.getSourceData(sourceId);
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+2−1
@@ -157,8 +157,9 @@ function AuthHeaderRow(props: { · 156 unmodified lines
157157 export default function AddGraphqlSource(props: {
158158 onComplete: () => void;
159159 onCancel: () => void;
160+ initialUrl?: string;
160161 }) {
161− const [endpoint, setEndpoint] = useState("");
162+ const [endpoint, setEndpoint] = useState(props.initialUrl ?? "");
162163 const [namespace, setNamespace] = useState("");
163164 const [authSecretId, setAuthSecretId] = useState<string | null>(null);
164165 const [adding, setAdding] = useState(false);
packages/plugins/graphql/src/sdk/plugin.ts+25−0
@@ -4,6 +4,7 @@ import type { Layer } from "effect"; · 3 unmodified lines
44
55 import {
66 Source,
7+ SourceDetectionResult,
78 definePlugin,
89 registerRuntimeTools,
910 runtimeTool,
@@ -233,6 +234,30 @@ export const graphqlPlugin = (options?: { · 223 unmodified lines
233234 yield* operationStore.removeSourceMeta(sourceId);
234235 yield* ctx.tools.unregisterBySource(sourceId);
235236 }),
237+
238+ detect: (url: string) =>
239+ Effect.gen(function* () {
240+ const trimmed = url.trim();
241+ if (!trimmed) return null;
242+ try { new URL(trimmed); } catch { return null; }
243+
244+ const ok = yield* introspect(trimmed).pipe(
245+ Effect.provide(httpClientLayer),
246+ Effect.map(() => true),
247+ Effect.catchAll(() => Effect.succeed(false)),
248+ );
249+
250+ if (!ok) return null;
251+
252+ const name = namespaceFromEndpoint(trimmed);
253+ return new SourceDetectionResult({
254+ kind: "graphql",
255+ confidence: "high",
256+ endpoint: trimmed,
257+ name,
258+ namespace: name,
259+ });
260+ }),
236261 });
237262
238263 const addSourceInternal = (config: GraphqlSourceConfig) =>
packages/plugins/mcp/src/react/AddMcpSource.tsx+14−2
@@ -1,4 +1,4 @@
1−import { useReducer, useCallback } from "react";
1+import { useReducer, useCallback, useEffect, useRef } from "react";
22 import { useAtomSet } from "@effect-atom/atom-react";
33
44 import { ScopeId } from "@executor/react";
@@ -164,8 +164,12 @@ function openOAuthPopup( · 159 unmodified lines
164164 export default function AddMcpSource(props: {
165165 onComplete: () => void;
166166 onCancel: () => void;
167+ initialUrl?: string;
167168 }) {
168− const [state, dispatch] = useReducer(reducer, init);
169+ const [state, dispatch] = useReducer(
170+ reducer,
171+ props.initialUrl ? { step: "url" as const, url: props.initialUrl } : init,
172+ );
169173
170174 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" });
171175 const doAdd = useAtomSet(addMcpSource, { mode: "promise" });
@@ -196,6 +200,14 @@ export default function AddMcpSource(props: { · 24 unmodified lines
196200 }
197201 }, [state.url, doProbe]);
198202
203+ const autoProbed = useRef(false);
204+ useEffect(() => {
205+ if (props.initialUrl && !autoProbed.current) {
206+ autoProbed.current = true;
207+ handleProbe();
208+ }
209+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
210+
199211 const handleOAuth = useCallback(async () => {
200212 dispatch({ type: "oauth-start" });
201213 try {
packages/plugins/mcp/src/sdk/plugin.ts+55−0
@@ -4,6 +4,7 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth. · 3 unmodified lines
44 import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
55 import {
66 Source,
7+ SourceDetectionResult,
78 definePlugin,
89 type ExecutorPlugin,
910 type PluginContext,
@@ -378,6 +379,60 @@ export const mcpPlugin = (options?: { · 368 unmodified lines
378379 addedSources.delete(sourceId);
379380 }).pipe(Effect.catchAll(() => Effect.void)),
380381
382+ detect: (url: string) =>
383+ Effect.gen(function* () {
384+ const trimmed = url.trim();
385+ if (!trimmed) return null;
386+ try { new URL(trimmed); } catch { return null; }
387+
388+ const name = (() => {
389+ try { return new URL(trimmed).hostname; } catch { return "mcp"; }
390+ })();
391+ const namespace = deriveMcpNamespace({ endpoint: trimmed });
392+
393+ const connector = createMcpConnector({
394+ transport: "remote",
395+ endpoint: trimmed,
396+ });
397+
398+ const connected = yield* discoverTools(connector).pipe(
399+ Effect.map(() => true),
400+ Effect.catchAll(() => Effect.succeed(false)),
401+ );
402+
403+ if (connected) {
404+ return new SourceDetectionResult({
405+ kind: "mcp",
406+ confidence: "high",
407+ endpoint: trimmed,
408+ name,
409+ namespace,
410+ });
411+ }
412+
413+ // Probe for OAuth — still means it's an MCP server
414+ const hasOAuth = yield* startMcpOAuthAuthorization({
415+ endpoint: trimmed,
416+ redirectUrl: "http://127.0.0.1/executor/discovery/oauth/probe",
417+ state: "probe",
418+ }).pipe(
419+ Effect.map(() => true),
420+ Effect.catchAll(() => Effect.succeed(false)),
421+ );
422+
423+ if (hasOAuth) {
424+ return new SourceDetectionResult({
425+ kind: "mcp",
426+ confidence: "high",
427+ endpoint: trimmed,
428+ name,
429+ namespace,
430+ });
431+ }
432+
433+ return null;
434+ }),
435+
381436 refresh: (sourceId: string) =>
382437 Effect.gen(function* () {
383438 const sd = yield* bindingStore.getSourceData(sourceId);
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+11−2
@@ -1,4 +1,4 @@
1−import { useState } from "react";
1+import { useState, useEffect, useRef } from "react";
22 import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";
33 import { Option } from "effect";
44
@@ -413,9 +413,10 @@ function prefixForHeader(preset: HeaderPreset, headerName: string): string | und · 408 unmodified lines
413413 export default function AddOpenApiSource(props: {
414414 onComplete: () => void;
415415 onCancel: () => void;
416+ initialUrl?: string;
416417 }) {
417418 // Spec input
418− const [specUrl, setSpecUrl] = useState("");
419+ const [specUrl, setSpecUrl] = useState(props.initialUrl ?? "");
419420 const [analyzing, setAnalyzing] = useState(false);
420421 const [analyzeError, setAnalyzeError] = useState<string | null>(null);
421422
@@ -435,6 +436,14 @@ export default function AddOpenApiSource(props: { · 13 unmodified lines
435436 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" });
436437 const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" });
437438 const secrets = useAtomValue(secretsAtom());
439+ const autoAnalyzed = useRef(false);
440+
441+ useEffect(() => {
442+ if (props.initialUrl && !autoAnalyzed.current) {
443+ autoAnalyzed.current = true;
444+ handleAnalyze();
445+ }
446+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
438447
439448 const secretList: readonly SecretPickerSecret[] = Result.match(secrets, {
440449 onInitial: () => [] as SecretPickerSecret[],
packages/plugins/openapi/src/sdk/plugin.ts+32−1
@@ -4,6 +4,7 @@ import type { Layer } from "effect"; · 3 unmodified lines
44
55 import {
66 Source,
7+ SourceDetectionResult,
78 definePlugin,
89 registerRuntimeTools,
910 runtimeTool,
@@ -201,7 +202,37 @@ export const openApiPlugin = (options?: { · 191 unmodified lines
201202 yield* ctx.tools.unregisterBySource(sourceId);
202203 }),
203204
204− // TODO: refresh requires storing original config per namespace
205+ detect: (url: string) =>
206+ Effect.gen(function* () {
207+ const trimmed = url.trim();
208+ if (!trimmed) return null;
209+ try { new URL(trimmed); } catch { return null; }
210+
211+ // Try fetching the URL and parsing as OpenAPI spec
212+ // parse() handles both URLs directly and spec text
213+ const doc = yield* parse(trimmed).pipe(
214+ Effect.catchAll(() => Effect.succeed(null)),
215+ );
216+ if (!doc) return null;
217+
218+ const result = yield* extract(doc).pipe(
219+ Effect.catchAll(() => Effect.succeed(null)),
220+ );
221+ if (!result) return null;
222+
223+ const namespace = Option.getOrElse(result.title, () => "api")
224+ .toLowerCase()
225+ .replace(/[^a-z0-9]+/g, "_");
226+ const name = Option.getOrElse(result.title, () => namespace);
227+
228+ return new SourceDetectionResult({
229+ kind: "openapi",
230+ confidence: "high",
231+ endpoint: trimmed,
232+ name,
233+ namespace,
234+ });
235+ }),
205236 });
206237
207238 const addSpecInternal = (config: OpenApiSpecConfig) =>