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
50 mayElicit: t.mayElicit,50 mayElicit: t.mayElicit,
51 }));51 }));
52 }),52 }),
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 }),
53 ),66 ),
54);67);
apps/web/src/pages/sources.tsx+192−75
@@ -1,6 +1,6 @@
1import { useState, Suspense } from "react";1import { useReducer, useCallback, Suspense } from "react";
2import { Link } from "@tanstack/react-router";2import { Link } from "@tanstack/react-router";
3import { Result, useAtomValue, useAtomRefresh, sourcesAtom } from "@executor/react";3import { Result, useAtomValue, useAtomRefresh, useAtomSet, sourcesAtom, detectSource } from "@executor/react";
4import type { SourcePlugin } from "@executor/react";4import type { SourcePlugin } from "@executor/react";
5import { openApiSourcePlugin } from "@executor/plugin-openapi/react";5import { openApiSourcePlugin } from "@executor/plugin-openapi/react";
6import { mcpSourcePlugin } from "@executor/plugin-mcp/react";6import { mcpSourcePlugin } from "@executor/plugin-mcp/react";
@@ -19,78 +19,113 @@ const sourcePlugins: SourcePlugin[] = [ · 12 unmodified lines
19 graphqlSourcePlugin,19 graphqlSourcePlugin,
20];20];
21 21
22const 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
33type State = {
34 step: "list" | "detecting" | "adding";
35 url: string;
36 error: string | null;
37 pluginKey: string | null;
38 initialUrl: string | undefined;
39};
40
41type 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
51const init: State = { step: "list", url: "", error: null, pluginKey: null, initialUrl: undefined };
52
53function 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
22// ---------------------------------------------------------------------------76// ---------------------------------------------------------------------------
23// Page77// Page
24// ---------------------------------------------------------------------------78// ---------------------------------------------------------------------------
25 79
26export function SourcesPage() {80export function SourcesPage() {
27 const [adding, setAdding] = useState<string | null>(null);81 const [state, dispatch] = useReducer(reducer, init);
28 const sources = useAtomValue(sourcesAtom());82 const sources = useAtomValue(sourcesAtom());
29 const refreshSources = useAtomRefresh(sourcesAtom());83 const refreshSources = useAtomRefresh(sourcesAtom());
84 const doDetect = useAtomSet(detectSource, { mode: "promise" });
30 85
31 const plugin = adding86 const handleDetect = useCallback(async () => {
32 ? sourcePlugins.find((p) => p.key === adding)87 const trimmed = state.url.trim();
33 : undefined;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]);
34 109
35 const renderSourceGrid = (sources: readonly {110 // ---------------------------------------------------------------------------
36 id: string;111 // Adding view
37 name: string;112 // ---------------------------------------------------------------------------
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 );
81 113
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;
83 const AddComponent = plugin.add;117 const AddComponent = plugin.add;
84 return (118 return (
85 <div className="min-h-0 flex-1 overflow-y-auto">119 <div className="min-h-0 flex-1 overflow-y-auto">
86 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">120 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
87 <Suspense fallback={<p className="text-sm text-muted-foreground">Loading…</p>}>121 <Suspense fallback={<p className="text-sm text-muted-foreground">Loading…</p>}>
88 <AddComponent122 <AddComponent
123 initialUrl={state.initialUrl}
89 onComplete={() => {124 onComplete={() => {
90 setAdding(null);125 dispatch({ type: "back" });
91 refreshSources();126 refreshSources();
92 }}127 }}
93 onCancel={() => setAdding(null)}128 onCancel={() => dispatch({ type: "back" })}
94 />129 />
95 </Suspense>130 </Suspense>
96 </div>131 </div>
@@ -98,32 +133,64 @@ export function SourcesPage() { · 1 unmodified line
98 );133 );
99 }134 }
100 135
136 // ---------------------------------------------------------------------------
137 // List view
138 // ---------------------------------------------------------------------------
139
140 const isDetecting = state.step === "detecting";
141 const error = state.step === "list" ? state.error : null;
142
101 return (143 return (
102 <div className="min-h-0 flex-1 overflow-y-auto">144 <div className="min-h-0 flex-1 overflow-y-auto">
103 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">145 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
104 {/* Header */}146 {/* Header */}
105 <div className="flex items-end justify-between mb-8">147 <div className="mb-8">
106 <div>148 <div className="flex items-end justify-between">
107 <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl">149 <div>
108 Sources150 <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl">
109 </h1>151 Sources
110 <p className="mt-1.5 text-[14px] text-muted-foreground">152 </h1>
111 Tool providers available in this workspace.153 <p className="mt-1.5 text-[14px] text-muted-foreground">
112 </p>154 Tool providers available in this workspace.
155 </p>
156 </div>
113 </div>157 </div>
114 <div className="flex gap-2">158
115 {sourcePlugins.map((p) => (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 />
116 <button171 <button
117 key={p.key}172 onClick={handleDetect}
118 onClick={() => setAdding(p.key)}173 disabled={isDetecting || !state.url.trim()}
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"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"
120 >175 >
121 <svg viewBox="0 0 16 16" fill="none" className="size-3.5">176 {isDetecting ? "Detecting..." : "Detect"}
122 <path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
123 </svg>
124 Add {p.label}
125 </button>177 </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>
127 </div>194 </div>
128 </div>195 </div>
129 196
@@ -166,7 +233,7 @@ export function SourcesPage() { · 36 unmodified lines
166 Runtime sources exposed by the loaded executor plugins.233 Runtime sources exposed by the loaded executor plugins.
167 </p>234 </p>
168 </div>235 </div>
169 {renderSourceGrid(builtInSources)}236 <SourceGrid sources={builtInSources} />
170 </section>237 </section>
171 )}238 )}
172 239
@@ -180,7 +247,7 @@ export function SourcesPage() { · 7 unmodified lines
180 User-configured sources available in this workspace.247 User-configured sources available in this workspace.
181 </p>248 </p>
182 </div>249 </div>
183 {renderSourceGrid(connectedSources)}250 <SourceGrid sources={connectedSources} />
184 </section>251 </section>
185 )}252 )}
186 </div>253 </div>
@@ -191,3 +258,53 @@ export function SourcesPage() { · 4 unmodified lines
191 </div>258 </div>
192 );259 );
193}260}
261
262// ---------------------------------------------------------------------------
263// Source grid
264// ---------------------------------------------------------------------------
265
266function 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
69export const removeSource = ExecutorApiClient.mutation("sources", "remove");69export const removeSource = ExecutorApiClient.mutation("sources", "remove");
70 70
71export const refreshSource = ExecutorApiClient.mutation("sources", "refresh");71export const refreshSource = ExecutorApiClient.mutation("sources", "refresh");
72
73export const detectSource = ExecutorApiClient.mutation("sources", "detect");
packages/clients/react/src/index.ts+1−0
@@ -33,6 +33,7 @@ export { · 32 unmodified lines
33 removeSecret,33 removeSecret,
34 removeSource,34 removeSource,
35 refreshSource,35 refreshSource,
36 detectSource,
36} from "./atoms";37} from "./atoms";
37 38
38// Provider39// Provider
packages/clients/react/src/source-plugin.ts+2−0
@@ -24,10 +24,12 @@ export interface SourcePlugin { · 23 unmodified lines
24 * The "add source" flow.24 * The "add source" flow.
25 * Plugin controls the entire experience.25 * Plugin controls the entire experience.
26 * Call `onComplete` when done, `onCancel` to bail out.26 * Call `onComplete` when done, `onCancel` to bail out.
27 * `initialUrl` is provided when the user arrived via URL auto-detection.
27 */28 */
28 readonly add: ComponentType<{29 readonly add: ComponentType<{
29 readonly onComplete: () => void;30 readonly onComplete: () => void;
30 readonly onCancel: () => void;31 readonly onCancel: () => void;
32 readonly initialUrl?: string;
31 }>;33 }>;
32 34
33 /**35 /**
packages/core/api/src/sources/api.ts+17−0
@@ -39,6 +39,18 @@ const ToolMetadataResponse = Schema.Struct({ · 38 unmodified lines
39 mayElicit: Schema.optional(Schema.Boolean),39 mayElicit: Schema.optional(Schema.Boolean),
40});40});
41 41
42const DetectRequest = Schema.Struct({
43 url: Schema.String,
44});
45
46const 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
42// ---------------------------------------------------------------------------54// ---------------------------------------------------------------------------
43// Group55// Group
44// ---------------------------------------------------------------------------56// ---------------------------------------------------------------------------
@@ -60,4 +72,9 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 15 unmodified lines
60 HttpApiEndpoint.get("tools")`/scopes/${scopeIdParam}/sources/${sourceIdParam}/tools`72 HttpApiEndpoint.get("tools")`/scopes/${scopeIdParam}/sources/${sourceIdParam}/tools`
61 .addSuccess(Schema.Array(ToolMetadataResponse)),73 .addSuccess(Schema.Array(ToolMetadataResponse)),
62 )74 )
75 .add(
76 HttpApiEndpoint.post("detect")`/scopes/${scopeIdParam}/sources/detect`
77 .setPayload(DetectRequest)
78 .addSuccess(Schema.Array(DetectResultResponse)),
79 )
63 .prefix("/v1") {}80 .prefix("/v1") {}
packages/core/sdk/src/executor.ts+3−1
@@ -10,7 +10,7 @@ import type { · 9 unmodified lines
10 ToolListFilter,10 ToolListFilter,
11 InvokeOptions,11 InvokeOptions,
12} from "./tools";12} from "./tools";
13import type { Source, SourceRegistry } from "./sources";13import type { Source, SourceDetectionResult, SourceRegistry } from "./sources";
14import type { Policy, PolicyEngine } from "./policies";14import type { Policy, PolicyEngine } from "./policies";
15import type { Scope } from "./scope";15import type { Scope } from "./scope";
16import type {16import type {
@@ -70,6 +70,7 @@ export type Executor< · 53 unmodified lines
70 readonly list: () => Effect.Effect<readonly Source[]>;70 readonly list: () => Effect.Effect<readonly Source[]>;
71 readonly remove: (sourceId: string) => Effect.Effect<void>;71 readonly remove: (sourceId: string) => Effect.Effect<void>;
72 readonly refresh: (sourceId: string) => Effect.Effect<void>;72 readonly refresh: (sourceId: string) => Effect.Effect<void>;
73 readonly detect: (url: string) => Effect.Effect<readonly SourceDetectionResult[]>;
73 };74 };
74 75
75 readonly policies: {76 readonly policies: {
@@ -193,6 +194,7 @@ export const createExecutor = < · 117 unmodified lines
193 list: () => sources.list(),194 list: () => sources.list(),
194 remove: (sourceId: string) => sources.remove(sourceId),195 remove: (sourceId: string) => sources.remove(sourceId),
195 refresh: (sourceId: string) => sources.refresh(sourceId),196 refresh: (sourceId: string) => sources.refresh(sourceId),
197 detect: (url: string) => sources.detect(url),
196 },198 },
197 199
198 policies: {200 policies: {
packages/core/sdk/src/index.ts+1−0
@@ -27,6 +27,7 @@ export { · 26 unmodified lines
27// Sources27// Sources
28export {28export {
29 Source,29 Source,
30 SourceDetectionResult,
30 SourceRegistry,31 SourceRegistry,
31 makeInMemorySourceRegistry,32 makeInMemorySourceRegistry,
32 type SourceManager,33 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
19 canRefresh: Schema.optional(Schema.Boolean),19 canRefresh: Schema.optional(Schema.Boolean),
20}) {}20}) {}
21 21
22// ---------------------------------------------------------------------------
23// SourceDetectionResult — returned by detect() on a SourceManager
24// ---------------------------------------------------------------------------
25
26export 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
22// ---------------------------------------------------------------------------39// ---------------------------------------------------------------------------
23// SourceManager — plugin-provided source lifecycle handler40// SourceManager — plugin-provided source lifecycle handler
24//41//
@@ -38,6 +55,9 @@ export interface SourceManager { · 13 unmodified lines
38 55
39 /** Re-fetch / re-register tools for a source */56 /** Re-fetch / re-register tools for a source */
40 readonly refresh?: (sourceId: string) => Effect.Effect<void>;57 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>;
41}61}
42 62
43// ---------------------------------------------------------------------------63// ---------------------------------------------------------------------------
@@ -66,6 +86,9 @@ export class SourceRegistry extends Context.Tag( · 22 unmodified lines
66 86
67 /** Refresh a source by id. Finds the owning manager and delegates. */87 /** Refresh a source by id. Finds the owning manager and delegates. */
68 readonly refresh: (sourceId: string) => Effect.Effect<void>;88 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[]>;
69 }92 }
70>() {}93>() {}
71 94
@@ -139,5 +162,25 @@ export const makeInMemorySourceRegistry = () => { · 67 unmodified lines
139 }162 }
140 }163 }
141 }),164 }),
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 }),
142 };185 };
143};186};
packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx+13−4
@@ -1,4 +1,4 @@
1import { useCallback, useMemo, useState } from "react";1import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react";2import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react";
3 3
4import {4import {
@@ -263,15 +263,16 @@ function openOAuthPopup( · 258 unmodified lines
263export default function AddGoogleDiscoverySource(props: {263export default function AddGoogleDiscoverySource(props: {
264 readonly onComplete: () => void;264 readonly onComplete: () => void;
265 readonly onCancel: () => void;265 readonly onCancel: () => void;
266 readonly initialUrl?: string;
266}) {267}) {
267 const defaultTemplate =268 const defaultTemplate =
268 GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === "google-sheets")269 GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === "google-sheets")
269 ?? GOOGLE_DISCOVERY_TEMPLATES[0]!;270 ?? GOOGLE_DISCOVERY_TEMPLATES[0]!;
270 const [discoveryUrl, setDiscoveryUrl] = useState(271 const [discoveryUrl, setDiscoveryUrl] = useState(
271 defaultTemplate.discoveryUrl,272 props.initialUrl ?? defaultTemplate.discoveryUrl,
272 );273 );
273 const [name, setName] = useState(defaultTemplate.name);274 const [name, setName] = useState(props.initialUrl ? "" : defaultTemplate.name);
274 const [selectedTemplateId, setSelectedTemplateId] = useState(defaultTemplate.id);275 const [selectedTemplateId, setSelectedTemplateId] = useState(props.initialUrl ? "" : defaultTemplate.id);
275 const selectedTemplate =276 const selectedTemplate =
276 GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === selectedTemplateId)277 GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === selectedTemplateId)
277 ?? null;278 ?? null;
@@ -343,6 +344,14 @@ export default function AddGoogleDiscoverySource(props: { · 65 unmodified lines
343 }344 }
344 }, [discoveryUrl, doProbe, name]);345 }, [discoveryUrl, doProbe, name]);
345 346
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
346 const handleStartOAuth = useCallback(async () => {355 const handleStartOAuth = useCallback(async () => {
347 if (!probe) return;356 if (!probe) return;
348 setStartingOAuth(true);357 setStartingOAuth(true);
packages/plugins/google-discovery/src/sdk/plugin.ts+36−0
@@ -4,6 +4,7 @@ import { Effect, Option } from "effect"; · 3 unmodified lines
4 4
5import {5import {
6 Source,6 Source,
7 SourceDetectionResult,
7 definePlugin,8 definePlugin,
8 type ExecutorPlugin,9 type ExecutorPlugin,
9 type PluginContext,10 type PluginContext,
@@ -306,6 +307,41 @@ export const googleDiscoveryPlugin = (options?: { · 296 unmodified lines
306 yield* bindingStore.removeSourceMeta(sourceId);307 yield* bindingStore.removeSourceMeta(sourceId);
307 yield* bindingStore.removeSourceData(sourceId);308 yield* bindingStore.removeSourceData(sourceId);
308 }),309 }),
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 }),
309 refresh: (sourceId) =>345 refresh: (sourceId) =>
310 Effect.gen(function* () {346 Effect.gen(function* () {
311 const sourceData = yield* bindingStore.getSourceData(sourceId);347 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
157export default function AddGraphqlSource(props: {157export default function AddGraphqlSource(props: {
158 onComplete: () => void;158 onComplete: () => void;
159 onCancel: () => void;159 onCancel: () => void;
160 initialUrl?: string;
160}) {161}) {
161 const [endpoint, setEndpoint] = useState("");162 const [endpoint, setEndpoint] = useState(props.initialUrl ?? "");
162 const [namespace, setNamespace] = useState("");163 const [namespace, setNamespace] = useState("");
163 const [authSecretId, setAuthSecretId] = useState<string | null>(null);164 const [authSecretId, setAuthSecretId] = useState<string | null>(null);
164 const [adding, setAdding] = useState(false);165 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
4 4
5import {5import {
6 Source,6 Source,
7 SourceDetectionResult,
7 definePlugin,8 definePlugin,
8 registerRuntimeTools,9 registerRuntimeTools,
9 runtimeTool,10 runtimeTool,
@@ -233,6 +234,30 @@ export const graphqlPlugin = (options?: { · 223 unmodified lines
233 yield* operationStore.removeSourceMeta(sourceId);234 yield* operationStore.removeSourceMeta(sourceId);
234 yield* ctx.tools.unregisterBySource(sourceId);235 yield* ctx.tools.unregisterBySource(sourceId);
235 }),236 }),
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 }),
236 });261 });
237 262
238 const addSourceInternal = (config: GraphqlSourceConfig) =>263 const addSourceInternal = (config: GraphqlSourceConfig) =>
packages/plugins/mcp/src/react/AddMcpSource.tsx+14−2
@@ -1,4 +1,4 @@
1import { useReducer, useCallback } from "react";1import { useReducer, useCallback, useEffect, useRef } from "react";
2import { useAtomSet } from "@effect-atom/atom-react";2import { useAtomSet } from "@effect-atom/atom-react";
3 3
4import { ScopeId } from "@executor/react";4import { ScopeId } from "@executor/react";
@@ -164,8 +164,12 @@ function openOAuthPopup( · 159 unmodified lines
164export default function AddMcpSource(props: {164export default function AddMcpSource(props: {
165 onComplete: () => void;165 onComplete: () => void;
166 onCancel: () => void;166 onCancel: () => void;
167 initialUrl?: string;
167}) {168}) {
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 );
169 173
170 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" });174 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" });
171 const doAdd = useAtomSet(addMcpSource, { mode: "promise" });175 const doAdd = useAtomSet(addMcpSource, { mode: "promise" });
@@ -196,6 +200,14 @@ export default function AddMcpSource(props: { · 24 unmodified lines
196 }200 }
197 }, [state.url, doProbe]);201 }, [state.url, doProbe]);
198 202
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
199 const handleOAuth = useCallback(async () => {211 const handleOAuth = useCallback(async () => {
200 dispatch({ type: "oauth-start" });212 dispatch({ type: "oauth-start" });
201 try {213 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
4import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";4import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
5import {5import {
6 Source,6 Source,
7 SourceDetectionResult,
7 definePlugin,8 definePlugin,
8 type ExecutorPlugin,9 type ExecutorPlugin,
9 type PluginContext,10 type PluginContext,
@@ -378,6 +379,60 @@ export const mcpPlugin = (options?: { · 368 unmodified lines
378 addedSources.delete(sourceId);379 addedSources.delete(sourceId);
379 }).pipe(Effect.catchAll(() => Effect.void)),380 }).pipe(Effect.catchAll(() => Effect.void)),
380 381
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
381 refresh: (sourceId: string) =>436 refresh: (sourceId: string) =>
382 Effect.gen(function* () {437 Effect.gen(function* () {
383 const sd = yield* bindingStore.getSourceData(sourceId);438 const sd = yield* bindingStore.getSourceData(sourceId);
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+11−2
@@ -1,4 +1,4 @@
1import { useState } from "react";1import { useState, useEffect, useRef } from "react";
2import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";2import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";
3import { Option } from "effect";3import { Option } from "effect";
4 4
@@ -413,9 +413,10 @@ function prefixForHeader(preset: HeaderPreset, headerName: string): string | und · 408 unmodified lines
413export default function AddOpenApiSource(props: {413export default function AddOpenApiSource(props: {
414 onComplete: () => void;414 onComplete: () => void;
415 onCancel: () => void;415 onCancel: () => void;
416 initialUrl?: string;
416}) {417}) {
417 // Spec input418 // Spec input
418 const [specUrl, setSpecUrl] = useState("");419 const [specUrl, setSpecUrl] = useState(props.initialUrl ?? "");
419 const [analyzing, setAnalyzing] = useState(false);420 const [analyzing, setAnalyzing] = useState(false);
420 const [analyzeError, setAnalyzeError] = useState<string | null>(null);421 const [analyzeError, setAnalyzeError] = useState<string | null>(null);
421 422
@@ -435,6 +436,14 @@ export default function AddOpenApiSource(props: { · 13 unmodified lines
435 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" });436 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" });
436 const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" });437 const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" });
437 const secrets = useAtomValue(secretsAtom());438 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
438 447
439 const secretList: readonly SecretPickerSecret[] = Result.match(secrets, {448 const secretList: readonly SecretPickerSecret[] = Result.match(secrets, {
440 onInitial: () => [] as SecretPickerSecret[],449 onInitial: () => [] as SecretPickerSecret[],
packages/plugins/openapi/src/sdk/plugin.ts+32−1
@@ -4,6 +4,7 @@ import type { Layer } from "effect"; · 3 unmodified lines
4 4
5import {5import {
6 Source,6 Source,
7 SourceDetectionResult,
7 definePlugin,8 definePlugin,
8 registerRuntimeTools,9 registerRuntimeTools,
9 runtimeTool,10 runtimeTool,
@@ -201,7 +202,37 @@ export const openApiPlugin = (options?: { · 191 unmodified lines
201 yield* ctx.tools.unregisterBySource(sourceId);202 yield* ctx.tools.unregisterBySource(sourceId);
202 }),203 }),
203 204
204 // TODO: refresh requires storing original config per namespace205 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 }),
205 });236 });
206 237
207 const addSpecInternal = (config: OpenApiSpecConfig) =>238 const addSpecInternal = (config: OpenApiSpecConfig) =>