From 2e532f940da85a4563483926ea310d55fb57b61f Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:19:23 -0700 Subject: [PATCH] Only probe the MCP server URL once it looks complete --- .changeset/mcp-add-url-typing-probe.md | 11 ++++ .../mcp-add-url-typing-probe.test.ts | 48 ++++++++++++++ .../mcp/src/react/AddMcpIntegration.tsx | 21 ++++++- .../plugins/mcp/src/react/probe-url.test.ts | 63 +++++++++++++++++++ packages/plugins/mcp/src/react/probe-url.ts | 25 ++++++++ 5 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 .changeset/mcp-add-url-typing-probe.md create mode 100644 e2e/scenarios/mcp-add-url-typing-probe.test.ts create mode 100644 packages/plugins/mcp/src/react/probe-url.test.ts create mode 100644 packages/plugins/mcp/src/react/probe-url.ts diff --git a/.changeset/mcp-add-url-typing-probe.md b/.changeset/mcp-add-url-typing-probe.md new file mode 100644 index 0000000000..babe50b07a --- /dev/null +++ b/.changeset/mcp-add-url-typing-probe.md @@ -0,0 +1,11 @@ +--- +"@executor-js/plugin-mcp": patch +--- + +**The add-MCP form stops dialling the server URL while it is still being typed** + +The Server URL field auto-probes the endpoint after a 400ms pause. The only condition on that probe was that the trimmed value was non-empty, so every pause in typing dialled whatever was in the field: "h", "http://", the "a" in "http://a". Each of those probes failed, and the field dropped into a loading state and then an error with a retry button, for a value the user never meant to submit. + +The probe now runs only when the value looks like a finished endpoint: it parses as a URL, its scheme is http or https, and its hostname is either a local development host or has a dot with a label on each side. The debounce is unchanged, so a completed URL is still probed without the user having to submit. + +A probe that is superseded is also no longer allowed to answer. The field could previously report the outcome of a request for a URL that had since been edited, because each probe dispatched its result unconditionally. Editing the URL now invalidates any probe already in flight, and its reply is discarded rather than applied to the current value. diff --git a/e2e/scenarios/mcp-add-url-typing-probe.test.ts b/e2e/scenarios/mcp-add-url-typing-probe.test.ts new file mode 100644 index 0000000000..da84f00cdc --- /dev/null +++ b/e2e/scenarios/mcp-add-url-typing-probe.test.ts @@ -0,0 +1,48 @@ +// The add-MCP flow probes the Server URL as it is typed. Every keystroke is a +// prefix of the next one, so a probe on a half-typed value dials something the +// user never meant to submit and drops the field into a loading and then an +// error state. This guards that an incomplete URL is left alone and that a +// finished one is still probed without being submitted. +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; + +const urlField = "https://mcp.example.com"; +const retry = "Try again"; + +scenario( + "MCP add flow · a half-typed Server URL is not probed", + {}, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the add-MCP flow", async () => { + await page.goto("/integrations/add/mcp", { waitUntil: "networkidle" }); + await page.getByPlaceholder(urlField).waitFor(); + }); + + await step("A URL still being typed is left alone", async () => { + // Well past the 400ms debounce. The field must stay editable — the + // probing state replaces it with a skeleton — and report no failure. + for (const partial of ["h", "http://", "http://a", "https://mcp"]) { + await page.getByPlaceholder(urlField).fill(partial); + await page.waitForTimeout(700); + await page.getByPlaceholder(urlField).waitFor({ state: "visible" }); + await page.getByRole("button", { name: retry }).waitFor({ state: "hidden" }); + } + }); + + await step("A finished URL is still probed", async () => { + // `.invalid` is reserved and never resolves, so the probe fails and the + // retry affordance appears. That it appears at all is the assertion: + // the shape gate lets a complete URL through. + await page.getByPlaceholder(urlField).fill("https://mcp.notareal.invalid/mcp"); + await page.getByRole("button", { name: retry }).waitFor(); + }); + }); + }), +); diff --git a/packages/plugins/mcp/src/react/AddMcpIntegration.tsx b/packages/plugins/mcp/src/react/AddMcpIntegration.tsx index 16aecb08b1..7377360003 100644 --- a/packages/plugins/mcp/src/react/AddMcpIntegration.tsx +++ b/packages/plugins/mcp/src/react/AddMcpIntegration.tsx @@ -39,6 +39,7 @@ import type { McpAuthMethodInput } from "../sdk/types"; import { probeMcpEndpoint, addMcpServer } from "./atoms"; import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config"; +import { isProbableMcpEndpoint } from "./probe-url"; import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode"; import { mcpPresets, type McpPreset } from "../sdk/presets"; @@ -270,11 +271,23 @@ export default function AddMcpIntegration(props: { // ---- Remote actions ---- + // Each probe run takes a token. Editing the URL invalidates it, so a reply + // that lands after the user has moved on is dropped instead of reporting on + // a URL that is no longer in the field. The probe atom exposes no abort + // signal, so the request itself still finishes; only its answer is ignored. + const probeRunRef = useRef(0); + + useEffect(() => { + probeRunRef.current += 1; + }, [state.url]); + const handleProbe = useCallback(async () => { + const run = (probeRunRef.current += 1); dispatch({ type: "probe-start" }); const exit = await doProbe({ payload: { endpoint: state.url.trim() }, }); + if (run !== probeRunRef.current) return; if (Exit.isFailure(exit)) { dispatch({ type: "probe-fail", @@ -291,12 +304,14 @@ export default function AddMcpIntegration(props: { handleProbeRef.current = handleProbe; // Auto-probe whenever the URL changes (debounced) while we're on the - // remote transport and not already probing/probed. + // remote transport and not already probing/probed. The shape gate keeps a + // half-typed URL from being dialled: without it every keystroke that is a + // non-empty string gets probed, and the field flashes through a loading and + // then an error state for values the user never meant to submit. useEffect(() => { if (transport !== "remote") return; if (state.step !== "url") return; - const trimmed = state.url.trim(); - if (!trimmed) return; + if (!isProbableMcpEndpoint(state.url)) return; const handle = setTimeout(() => { handleProbeRef.current(); }, 400); diff --git a/packages/plugins/mcp/src/react/probe-url.test.ts b/packages/plugins/mcp/src/react/probe-url.test.ts new file mode 100644 index 0000000000..4980e1920f --- /dev/null +++ b/packages/plugins/mcp/src/react/probe-url.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { isProbableMcpEndpoint } from "./probe-url"; + +describe("isProbableMcpEndpoint", () => { + it("rejects the prefixes a URL passes through while it is typed", () => { + for (const typed of [ + "", + " ", + "h", + "ht", + "htt", + "http", + "http:", + "http:/", + "http://", + "http://a", + "http://example", + "https://mcp", + "https://mcp.", + "https://.com", + ]) { + expect(isProbableMcpEndpoint(typed), typed).toBe(false); + } + }); + + it("accepts a finished http(s) endpoint", () => { + for (const finished of [ + "https://mcp.example.com", + "https://mcp.example.com/mcp", + "http://example.com:4789/mcp?codemode=false", + "https://example.co.uk/sse", + " https://mcp.example.com/mcp ", + "HTTPS://MCP.EXAMPLE.COM/mcp", + ]) { + expect(isProbableMcpEndpoint(finished), finished).toBe(true); + } + }); + + it("accepts local development hosts, which have no dot to wait for", () => { + for (const local of [ + "http://localhost", + "http://localhost:4789/mcp", + "http://app.localhost:4789/mcp", + "http://127.0.0.1:4789/mcp", + "http://[::1]:4789/mcp", + ]) { + expect(isProbableMcpEndpoint(local), local).toBe(true); + } + }); + + it("rejects schemes the MCP remote transport cannot dial", () => { + for (const wrongScheme of [ + "ftp://mcp.example.com", + "ws://mcp.example.com", + "file:///tmp/mcp", + "mailto:someone@example.com", + "npx @example/mcp-server", + ]) { + expect(isProbableMcpEndpoint(wrongScheme), wrongScheme).toBe(false); + } + }); +}); diff --git a/packages/plugins/mcp/src/react/probe-url.ts b/packages/plugins/mcp/src/react/probe-url.ts new file mode 100644 index 0000000000..95a5484fd4 --- /dev/null +++ b/packages/plugins/mcp/src/react/probe-url.ts @@ -0,0 +1,25 @@ +// The remote add form probes the endpoint as the user types, so the gate below +// decides which intermediate strings are worth dialling. Every keystroke is a +// prefix of the next one: "h", "ht", "http://a" all parse or fail in ways that +// tell us nothing, and probing them puts the field into a loading state and +// then an error state for a URL the user never meant to submit. + +/** Whether `value` looks like a finished MCP endpoint, and so is worth dialling + * while the user is still typing. Format-only: it says nothing about whether a + * server answers there. */ +export const isProbableMcpEndpoint = (value: string): boolean => { + const trimmed = value.trim(); + if (!URL.canParse(trimmed)) return false; + + const url = new URL(trimmed); + if (url.protocol !== "http:" && url.protocol !== "https:") return false; + + const hostname = url.hostname.toLowerCase(); + // Local development servers have no dot to wait for. + if (hostname === "localhost" || hostname.endsWith(".localhost")) return true; + // An IPv6 literal arrives bracketed and is complete once it parses. + if (hostname.startsWith("[")) return true; + // Otherwise wait for a dot with a label on each side. A bare "example" is + // still being typed; "example.com" is something we can dial. + return hostname.includes(".") && !hostname.startsWith(".") && !hostname.endsWith("."); +};