Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/mcp-add-url-typing-probe.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions e2e/scenarios/mcp-add-url-typing-probe.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
}),
);
21 changes: 18 additions & 3 deletions packages/plugins/mcp/src/react/AddMcpIntegration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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",
Expand All @@ -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);
Expand Down
63 changes: 63 additions & 0 deletions packages/plugins/mcp/src/react/probe-url.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
25 changes: 25 additions & 0 deletions packages/plugins/mcp/src/react/probe-url.ts
Original file line number Diff line number Diff line change
@@ -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(".");
};
Loading