Unified · Split
| @@ -50,5 +50,18 @@ export const SourcesHandlers = HttpApiBuilder.group( · 49 unmodified lines | ||
| 50 | 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 | ); |
| @@ -1,6 +1,6 @@ | ||
| 1 | −import { useState, Suspense } from "react"; | |
| 1 | +import { useReducer, useCallback, Suspense } from "react"; | |
| 2 | 2 | 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"; | |
| 4 | 4 | import type { SourcePlugin } from "@executor/react"; |
| 5 | 5 | import { openApiSourcePlugin } from "@executor/plugin-openapi/react"; |
| 6 | 6 | import { mcpSourcePlugin } from "@executor/plugin-mcp/react"; |
| @@ -19,78 +19,113 @@ const sourcePlugins: SourcePlugin[] = [ · 12 unmodified lines | ||
| 19 | 19 | graphqlSourcePlugin, |
| 20 | 20 | ]; |
| 21 | 21 | |
| 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 | + | |
| 22 | 76 | // --------------------------------------------------------------------------- |
| 23 | 77 | // Page |
| 24 | 78 | // --------------------------------------------------------------------------- |
| 25 | 79 | |
| 26 | 80 | export function SourcesPage() { |
| 27 | − const [adding, setAdding] = useState<string | null>(null); | |
| 81 | + const [state, dispatch] = useReducer(reducer, init); | |
| 28 | 82 | const sources = useAtomValue(sourcesAtom()); |
| 29 | 83 | const refreshSources = useAtomRefresh(sourcesAtom()); |
| 84 | + const doDetect = useAtomSet(detectSource, { mode: "promise" }); | |
| 30 | 85 | |
| 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]); | |
| 34 | 109 | |
| 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 | + // --------------------------------------------------------------------------- | |
| 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 | 117 | const AddComponent = plugin.add; |
| 84 | 118 | return ( |
| 85 | 119 | <div className="min-h-0 flex-1 overflow-y-auto"> |
| 86 | 120 | <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> |
| 87 | 121 | <Suspense fallback={<p className="text-sm text-muted-foreground">Loading…</p>}> |
| 88 | 122 | <AddComponent |
| 123 | + initialUrl={state.initialUrl} | |
| 89 | 124 | onComplete={() => { |
| 90 | − setAdding(null); | |
| 125 | + dispatch({ type: "back" }); | |
| 91 | 126 | refreshSources(); |
| 92 | 127 | }} |
| 93 | − onCancel={() => setAdding(null)} | |
| 128 | + onCancel={() => dispatch({ type: "back" })} | |
| 94 | 129 | /> |
| 95 | 130 | </Suspense> |
| 96 | 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 | 143 | return ( |
| 102 | 144 | <div className="min-h-0 flex-1 overflow-y-auto"> |
| 103 | 145 | <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> |
| 104 | 146 | {/* 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> | |
| 113 | 157 | </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 | + /> | |
| 116 | 171 | <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" | |
| 120 | 175 | > |
| 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"} | |
| 125 | 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 | 194 | </div> |
| 128 | 195 | </div> |
| 129 | 196 | |
| @@ -166,7 +233,7 @@ export function SourcesPage() { · 36 unmodified lines | ||
| 166 | 233 | Runtime sources exposed by the loaded executor plugins. |
| 167 | 234 | </p> |
| 168 | 235 | </div> |
| 169 | − {renderSourceGrid(builtInSources)} | |
| 236 | + <SourceGrid sources={builtInSources} /> | |
| 170 | 237 | </section> |
| 171 | 238 | )} |
| 172 | 239 | |
| @@ -180,7 +247,7 @@ export function SourcesPage() { · 7 unmodified lines | ||
| 180 | 247 | User-configured sources available in this workspace. |
| 181 | 248 | </p> |
| 182 | 249 | </div> |
| 183 | − {renderSourceGrid(connectedSources)} | |
| 250 | + <SourceGrid sources={connectedSources} /> | |
| 184 | 251 | </section> |
| 185 | 252 | )} |
| 186 | 253 | </div> |
| @@ -191,3 +258,53 @@ export function SourcesPage() { · 4 unmodified lines | ||
| 191 | 258 | </div> |
| 192 | 259 | ); |
| 193 | 260 | } |
| 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 | +} | |
| @@ -69,3 +69,5 @@ export const removeSecret = ExecutorApiClient.mutation("secrets", "remove"); · 68 unmodified lines | ||
| 69 | 69 | export const removeSource = ExecutorApiClient.mutation("sources", "remove"); |
| 70 | 70 | |
| 71 | 71 | export const refreshSource = ExecutorApiClient.mutation("sources", "refresh"); |
| 72 | + | |
| 73 | +export const detectSource = ExecutorApiClient.mutation("sources", "detect"); | |
| @@ -33,6 +33,7 @@ export { · 32 unmodified lines | ||
| 33 | 33 | removeSecret, |
| 34 | 34 | removeSource, |
| 35 | 35 | refreshSource, |
| 36 | + detectSource, | |
| 36 | 37 | } from "./atoms"; |
| 37 | 38 | |
| 38 | 39 | // Provider |
| @@ -24,10 +24,12 @@ export interface SourcePlugin { · 23 unmodified lines | ||
| 24 | 24 | * The "add source" flow. |
| 25 | 25 | * Plugin controls the entire experience. |
| 26 | 26 | * Call `onComplete` when done, `onCancel` to bail out. |
| 27 | + * `initialUrl` is provided when the user arrived via URL auto-detection. | |
| 27 | 28 | */ |
| 28 | 29 | readonly add: ComponentType<{ |
| 29 | 30 | readonly onComplete: () => void; |
| 30 | 31 | readonly onCancel: () => void; |
| 32 | + readonly initialUrl?: string; | |
| 31 | 33 | }>; |
| 32 | 34 | |
| 33 | 35 | /** |
| @@ -39,6 +39,18 @@ const ToolMetadataResponse = Schema.Struct({ · 38 unmodified lines | ||
| 39 | 39 | mayElicit: Schema.optional(Schema.Boolean), |
| 40 | 40 | }); |
| 41 | 41 | |
| 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 | + | |
| 42 | 54 | // --------------------------------------------------------------------------- |
| 43 | 55 | // Group |
| 44 | 56 | // --------------------------------------------------------------------------- |
| @@ -60,4 +72,9 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 15 unmodified lines | ||
| 60 | 72 | HttpApiEndpoint.get("tools")`/scopes/${scopeIdParam}/sources/${sourceIdParam}/tools` |
| 61 | 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 | 80 | .prefix("/v1") {} |
| @@ -10,7 +10,7 @@ import type { · 9 unmodified lines | ||
| 10 | 10 | ToolListFilter, |
| 11 | 11 | InvokeOptions, |
| 12 | 12 | } from "./tools"; |
| 13 | −import type { Source, SourceRegistry } from "./sources"; | |
| 13 | +import type { Source, SourceDetectionResult, SourceRegistry } from "./sources"; | |
| 14 | 14 | import type { Policy, PolicyEngine } from "./policies"; |
| 15 | 15 | import type { Scope } from "./scope"; |
| 16 | 16 | import type { |
| @@ -70,6 +70,7 @@ export type Executor< · 53 unmodified lines | ||
| 70 | 70 | readonly list: () => Effect.Effect<readonly Source[]>; |
| 71 | 71 | readonly remove: (sourceId: string) => Effect.Effect<void>; |
| 72 | 72 | readonly refresh: (sourceId: string) => Effect.Effect<void>; |
| 73 | + readonly detect: (url: string) => Effect.Effect<readonly SourceDetectionResult[]>; | |
| 73 | 74 | }; |
| 74 | 75 | |
| 75 | 76 | readonly policies: { |
| @@ -193,6 +194,7 @@ export const createExecutor = < · 117 unmodified lines | ||
| 193 | 194 | list: () => sources.list(), |
| 194 | 195 | remove: (sourceId: string) => sources.remove(sourceId), |
| 195 | 196 | refresh: (sourceId: string) => sources.refresh(sourceId), |
| 197 | + detect: (url: string) => sources.detect(url), | |
| 196 | 198 | }, |
| 197 | 199 | |
| 198 | 200 | policies: { |
| @@ -27,6 +27,7 @@ export { · 26 unmodified lines | ||
| 27 | 27 | // Sources |
| 28 | 28 | export { |
| 29 | 29 | Source, |
| 30 | + SourceDetectionResult, | |
| 30 | 31 | SourceRegistry, |
| 31 | 32 | makeInMemorySourceRegistry, |
| 32 | 33 | type SourceManager, |
| @@ -19,6 +19,23 @@ export class Source extends Schema.Class<Source>("Source")({ · 18 unmodified lines | ||
| 19 | 19 | canRefresh: Schema.optional(Schema.Boolean), |
| 20 | 20 | }) {} |
| 21 | 21 | |
| 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 | + | |
| 22 | 39 | // --------------------------------------------------------------------------- |
| 23 | 40 | // SourceManager — plugin-provided source lifecycle handler |
| 24 | 41 | // |
| @@ -38,6 +55,9 @@ export interface SourceManager { · 13 unmodified lines | ||
| 38 | 55 | |
| 39 | 56 | /** Re-fetch / re-register tools for a source */ |
| 40 | 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 | 87 | /** Refresh a source by id. Finds the owning manager and delegates. */ |
| 68 | 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 | }; |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { useCallback, useMemo, useState } from "react"; | |
| 1 | +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 2 | 2 | import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react"; |
| 3 | 3 | |
| 4 | 4 | import { |
| @@ -263,15 +263,16 @@ function openOAuthPopup( · 258 unmodified lines | ||
| 263 | 263 | export default function AddGoogleDiscoverySource(props: { |
| 264 | 264 | readonly onComplete: () => void; |
| 265 | 265 | readonly onCancel: () => void; |
| 266 | + readonly initialUrl?: string; | |
| 266 | 267 | }) { |
| 267 | 268 | const defaultTemplate = |
| 268 | 269 | GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === "google-sheets") |
| 269 | 270 | ?? GOOGLE_DISCOVERY_TEMPLATES[0]!; |
| 270 | 271 | const [discoveryUrl, setDiscoveryUrl] = useState( |
| 271 | − defaultTemplate.discoveryUrl, | |
| 272 | + props.initialUrl ?? defaultTemplate.discoveryUrl, | |
| 272 | 273 | ); |
| 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); | |
| 275 | 276 | const selectedTemplate = |
| 276 | 277 | GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === selectedTemplateId) |
| 277 | 278 | ?? null; |
| @@ -343,6 +344,14 @@ export default function AddGoogleDiscoverySource(props: { · 65 unmodified lines | ||
| 343 | 344 | } |
| 344 | 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 | 355 | const handleStartOAuth = useCallback(async () => { |
| 347 | 356 | if (!probe) return; |
| 348 | 357 | setStartingOAuth(true); |
| @@ -4,6 +4,7 @@ import { Effect, Option } from "effect"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | 6 | Source, |
| 7 | + SourceDetectionResult, | |
| 7 | 8 | definePlugin, |
| 8 | 9 | type ExecutorPlugin, |
| 9 | 10 | type PluginContext, |
| @@ -306,6 +307,41 @@ export const googleDiscoveryPlugin = (options?: { · 296 unmodified lines | ||
| 306 | 307 | yield* bindingStore.removeSourceMeta(sourceId); |
| 307 | 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 | 345 | refresh: (sourceId) => |
| 310 | 346 | Effect.gen(function* () { |
| 311 | 347 | const sourceData = yield* bindingStore.getSourceData(sourceId); |
| @@ -157,8 +157,9 @@ function AuthHeaderRow(props: { · 156 unmodified lines | ||
| 157 | 157 | export default function AddGraphqlSource(props: { |
| 158 | 158 | onComplete: () => void; |
| 159 | 159 | onCancel: () => void; |
| 160 | + initialUrl?: string; | |
| 160 | 161 | }) { |
| 161 | − const [endpoint, setEndpoint] = useState(""); | |
| 162 | + const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); | |
| 162 | 163 | const [namespace, setNamespace] = useState(""); |
| 163 | 164 | const [authSecretId, setAuthSecretId] = useState<string | null>(null); |
| 164 | 165 | const [adding, setAdding] = useState(false); |
| @@ -4,6 +4,7 @@ import type { Layer } from "effect"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | 6 | Source, |
| 7 | + SourceDetectionResult, | |
| 7 | 8 | definePlugin, |
| 8 | 9 | registerRuntimeTools, |
| 9 | 10 | runtimeTool, |
| @@ -233,6 +234,30 @@ export const graphqlPlugin = (options?: { · 223 unmodified lines | ||
| 233 | 234 | yield* operationStore.removeSourceMeta(sourceId); |
| 234 | 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 | 263 | const addSourceInternal = (config: GraphqlSourceConfig) => |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { useReducer, useCallback } from "react"; | |
| 1 | +import { useReducer, useCallback, useEffect, useRef } from "react"; | |
| 2 | 2 | import { useAtomSet } from "@effect-atom/atom-react"; |
| 3 | 3 | |
| 4 | 4 | import { ScopeId } from "@executor/react"; |
| @@ -164,8 +164,12 @@ function openOAuthPopup( · 159 unmodified lines | ||
| 164 | 164 | export default function AddMcpSource(props: { |
| 165 | 165 | onComplete: () => void; |
| 166 | 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 | 174 | const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" }); |
| 171 | 175 | const doAdd = useAtomSet(addMcpSource, { mode: "promise" }); |
| @@ -196,6 +200,14 @@ export default function AddMcpSource(props: { · 24 unmodified lines | ||
| 196 | 200 | } |
| 197 | 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 | 211 | const handleOAuth = useCallback(async () => { |
| 200 | 212 | dispatch({ type: "oauth-start" }); |
| 201 | 213 | try { |
| @@ -4,6 +4,7 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth. · 3 unmodified lines | ||
| 4 | 4 | import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; |
| 5 | 5 | import { |
| 6 | 6 | Source, |
| 7 | + SourceDetectionResult, | |
| 7 | 8 | definePlugin, |
| 8 | 9 | type ExecutorPlugin, |
| 9 | 10 | type PluginContext, |
| @@ -378,6 +379,60 @@ export const mcpPlugin = (options?: { · 368 unmodified lines | ||
| 378 | 379 | addedSources.delete(sourceId); |
| 379 | 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 | 436 | refresh: (sourceId: string) => |
| 382 | 437 | Effect.gen(function* () { |
| 383 | 438 | const sd = yield* bindingStore.getSourceData(sourceId); |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { useState } from "react"; | |
| 1 | +import { useState, useEffect, useRef } from "react"; | |
| 2 | 2 | import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react"; |
| 3 | 3 | import { Option } from "effect"; |
| 4 | 4 | |
| @@ -413,9 +413,10 @@ function prefixForHeader(preset: HeaderPreset, headerName: string): string | und · 408 unmodified lines | ||
| 413 | 413 | export default function AddOpenApiSource(props: { |
| 414 | 414 | onComplete: () => void; |
| 415 | 415 | onCancel: () => void; |
| 416 | + initialUrl?: string; | |
| 416 | 417 | }) { |
| 417 | 418 | // Spec input |
| 418 | − const [specUrl, setSpecUrl] = useState(""); | |
| 419 | + const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); | |
| 419 | 420 | const [analyzing, setAnalyzing] = useState(false); |
| 420 | 421 | const [analyzeError, setAnalyzeError] = useState<string | null>(null); |
| 421 | 422 | |
| @@ -435,6 +436,14 @@ export default function AddOpenApiSource(props: { · 13 unmodified lines | ||
| 435 | 436 | const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" }); |
| 436 | 437 | const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" }); |
| 437 | 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 | 448 | const secretList: readonly SecretPickerSecret[] = Result.match(secrets, { |
| 440 | 449 | onInitial: () => [] as SecretPickerSecret[], |
| @@ -4,6 +4,7 @@ import type { Layer } from "effect"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | 6 | Source, |
| 7 | + SourceDetectionResult, | |
| 7 | 8 | definePlugin, |
| 8 | 9 | registerRuntimeTools, |
| 9 | 10 | runtimeTool, |
| @@ -201,7 +202,37 @@ export const openApiPlugin = (options?: { · 191 unmodified lines | ||
| 201 | 202 | yield* ctx.tools.unregisterBySource(sourceId); |
| 202 | 203 | }), |
| 203 | 204 | |
| 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 | + }), | |
| 205 | 236 | }); |
| 206 | 237 | |
| 207 | 238 | const addSpecInternal = (config: OpenApiSpecConfig) => |