mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
feat(apps): make the BYO URL card the guided universal flow
Pasting an MCP URL now walks the operator through whatever the endpoint turns out to need. The simple path is unchanged in length — address, name, "does it need a key?" — but the dead ends are gone: an endpoint that wants browser sign-in goes to sign-in instead of a "coming soon" toast, and an invalid, private-network, unreachable, or credential-demanding endpoint says which of those it was and which field to change, inline, next to that field. Advanced authentication adds the explicit choices for operators who already know: no sign-in, key or token, custom headers, or browser sign-in with a client they registered themselves. Header values become Paperclip secrets and are rejected client-side before a round trip if Paperclip wouldn't send them. The choices are phrased as how the *server* authenticates; nothing on this screen mentions DCR, CIMD, PKCE or an RFC, and a test asserts that stays true. The endpoint host and "Unverified server" now ride on the step header, so they stay visible through review, access and install rather than only on the screen where the address was pasted. A curated definition matching the endpoint is offered as a branded shortcut beside — not instead of — the generic form. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0d24cc8505
commit
7e5898d69a
6 files changed
+1192
-93
No files matched your search
@@ -2031,6 +2031,8 @@ export {
|
||||
GENERIC_MCP_AUTH_MODES,
|
||||
genericMcpAuthModeSchema,
|
||||
genericMcpOAuthClientSchema,
|
||||
type GenericMcpAuthMode,
|
||||
type GenericMcpOAuthClient,
|
||||
reconnectToolAppSchema,
|
||||
createToolApplicationSchema,
|
||||
finishToolAppSchema,
|
||||
|
||||
@@ -868,6 +868,8 @@ export {
|
||||
GENERIC_MCP_AUTH_MODES,
|
||||
genericMcpAuthModeSchema,
|
||||
genericMcpOAuthClientSchema,
|
||||
type GenericMcpAuthMode,
|
||||
type GenericMcpOAuthClient,
|
||||
reconnectToolAppSchema,
|
||||
finishToolAppSchema,
|
||||
updateToolApplicationSchema,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { CONNECTABLE_APP_DEFINITIONS } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { AppsConnect } from "./AppsConnect";
|
||||
|
||||
@@ -191,7 +192,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://www.example.com/actions");
|
||||
|
||||
expect(container.textContent).toContain("Connect with a link");
|
||||
expect(container.textContent).toContain("Connect your own MCP server");
|
||||
expect(container.textContent).toContain("https://www.example.com/actions");
|
||||
expect(container.textContent).toContain("Does it need a key?");
|
||||
expect(buttonByText("No")).toBeTruthy();
|
||||
@@ -651,7 +652,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
container.querySelectorAll<HTMLInputElement>("input"),
|
||||
).filter((i) => i.type === "password");
|
||||
expect(passwordInputs).toHaveLength(1);
|
||||
expect(container.textContent).toContain("Your key is stored securely.");
|
||||
expect(container.textContent).toContain("Stored securely.");
|
||||
|
||||
await act(async () => setInputValue(passwordInputs[0], "secret-key"));
|
||||
await flushReact();
|
||||
@@ -683,7 +684,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Connect with a link");
|
||||
expect(container.textContent).toContain("Connect your own MCP server");
|
||||
expect(container.textContent).toContain(zapierUrl);
|
||||
expect(nameInputFrom(container)?.value).toBe("Zapier");
|
||||
expect(container.querySelector('input[type="password"]')).toBeNull();
|
||||
@@ -865,7 +866,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
"link=https%3A%2F%2Fwww.example.com%2Factions&name=Bla&applicationId=app-77";
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Connect with a link");
|
||||
expect(container.textContent).toContain("Connect your own MCP server");
|
||||
expect(container.textContent).toContain("https://www.example.com/actions");
|
||||
const nameInput = Array.from(container.querySelectorAll<HTMLInputElement>("input")).find(
|
||||
(i) => i.getAttribute("placeholder") === "My app",
|
||||
@@ -1076,3 +1077,304 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PAP-17087 — the BYO URL card is now the guided universal flow. What matters is
|
||||
* that the simple path stayed simple, that each failure mode names the thing the
|
||||
* operator has to change, and that a pasted endpoint that needs sign-in actually
|
||||
* gets there instead of a "coming soon" toast.
|
||||
*/
|
||||
describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSearch.value = "";
|
||||
mockParams.appKey = undefined;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
listGalleryMock.mockResolvedValue({ apps: [ZAPIER] });
|
||||
listApplicationsMock.mockResolvedValue({ applications: [] });
|
||||
listConnectionsMock.mockResolvedValue({ connections: [] });
|
||||
listAgentsMock.mockResolvedValue([]);
|
||||
finishAppMock.mockResolvedValue({});
|
||||
putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
|
||||
startOAuthMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
provider: "mcp_example_test",
|
||||
authorizationUrl: "https://auth.example.test/authorize?state=abc",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
});
|
||||
connectAppMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
application: { id: "app-1", name: "mcp.example.test" },
|
||||
actions: { readOnly: [], canMakeChanges: [] },
|
||||
catalog: [],
|
||||
suggestedDefaults: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(container);
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
const root = createRoot(container);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AppsConnect />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
return root;
|
||||
}
|
||||
|
||||
async function openAdvanced() {
|
||||
await act(async () => {
|
||||
buttonContaining("Advanced authentication")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
// Production branches on `error instanceof ApiError`, so the double has to be a
|
||||
// real one — a look-alike would silently fall through to the generic message and
|
||||
// make these tests pass for the wrong reason.
|
||||
function apiError(status: number, code: string, message: string) {
|
||||
return new ApiError(message, status, { error: message, details: { code } });
|
||||
}
|
||||
|
||||
it("keeps the endpoint host and Unverified server visible from setup through review", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
|
||||
expect(container.textContent).toContain("Unverified server");
|
||||
expect(container.textContent).toContain("mcp.example.test");
|
||||
|
||||
connectAppMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
application: { id: "app-1", name: "mcp.example.test" },
|
||||
actions: {
|
||||
readOnly: [{
|
||||
catalogEntryId: "cat-read",
|
||||
toolName: "list_things",
|
||||
title: "List things",
|
||||
description: null,
|
||||
riskLevel: "read",
|
||||
isReadOnly: true,
|
||||
isWrite: false,
|
||||
isDestructive: false,
|
||||
status: "active",
|
||||
}],
|
||||
canMakeChanges: [],
|
||||
},
|
||||
catalog: [],
|
||||
suggestedDefaults: { askFirstRiskLevels: ["write", "destructive"] },
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
// Now on the review step — the label has to survive the step change.
|
||||
expect(container.textContent).toContain("List things");
|
||||
expect(container.textContent).toContain("Unverified server");
|
||||
expect(container.textContent).toContain("mcp.example.test");
|
||||
});
|
||||
|
||||
it("offers a curated setup as a convenience without leaving the generic flow", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.zapier.com/api/v1/connect?token=t");
|
||||
|
||||
// Both routes are present: the branded shortcut and the generic form itself.
|
||||
expect(container.textContent).toContain("Paperclip has a guided setup for Zapier.");
|
||||
expect(container.textContent).toContain("Connect your own MCP server");
|
||||
expect(buttonByText("Check link")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("explains a private-network address instead of blaming the key", async () => {
|
||||
connectAppMock.mockRejectedValue(
|
||||
apiError(400, "remote_http_private_endpoint", "Remote MCP connection URL cannot target private or reserved network addresses"),
|
||||
);
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("That address is inside a private network");
|
||||
// Still on the setup screen with the address in hand, not bounced back.
|
||||
expect(buttonByText("Check link")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("explains an unreachable host", async () => {
|
||||
connectAppMock.mockRejectedValue(apiError(400, "remote_http_dns_failed", "hostname could not be resolved"));
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("We couldn't find that host");
|
||||
});
|
||||
|
||||
it("opens advanced authentication when the server wants a credential we can't discover", async () => {
|
||||
connectAppMock.mockRejectedValue(apiError(502, "oauth_challenge", "This app needs you to sign in."));
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("This server wants a credential");
|
||||
// The advanced section is now open, so the fields to fix it are on screen.
|
||||
expect(container.textContent).toContain("Custom headers");
|
||||
expect(container.textContent).not.toContain("coming soon");
|
||||
});
|
||||
|
||||
it("sends the operator to sign-in when the endpoint needs browser authorization", async () => {
|
||||
connectAppMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
application: { id: "app-1", name: "mcp.example.test" },
|
||||
actions: { readOnly: [], canMakeChanges: [] },
|
||||
catalog: [],
|
||||
suggestedDefaults: {},
|
||||
auth: { kind: "oauth", startUrl: "https://auth.example.test/authorize?state=abc" },
|
||||
});
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(navigateTopLevelMock).toHaveBeenCalledWith("https://auth.example.test/authorize?state=abc");
|
||||
});
|
||||
|
||||
it("asks for a preregistered client rather than losing the draft", async () => {
|
||||
connectAppMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
application: { id: "app-1", name: "mcp.example.test" },
|
||||
actions: { readOnly: [], canMakeChanges: [] },
|
||||
catalog: [],
|
||||
suggestedDefaults: {},
|
||||
auth: { kind: "oauth", startUrl: null, manualClientRequired: true },
|
||||
});
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("This server needs sign-in details you create yourself");
|
||||
expect(container.textContent).toContain("Client ID");
|
||||
expect(container.textContent).toContain("Client secret");
|
||||
// No redirect happened: there is nothing to redirect to yet.
|
||||
expect(navigateTopLevelMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits custom headers as secret-backed credential values", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await openAdvanced();
|
||||
await act(async () => {
|
||||
buttonByText("Custom headers")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const nameInput = Array.from(container.querySelectorAll<HTMLInputElement>("input"))
|
||||
.find((input) => input.getAttribute("aria-label") === "Header name")!;
|
||||
await act(async () => setInputValue(nameInput, "X-Api-Key"));
|
||||
await flushReact();
|
||||
const valueInput = Array.from(container.querySelectorAll<HTMLInputElement>("input"))
|
||||
.find((input) => input.type === "password")!;
|
||||
await act(async () => setInputValue(valueInput, "phx_secret"));
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
const [, input] = connectAppMock.mock.calls[0];
|
||||
expect(input).toMatchObject({
|
||||
link: "https://mcp.example.test/mcp",
|
||||
authMode: "custom_headers",
|
||||
credentialValues: { "headers.X-Api-Key": "phx_secret" },
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks a header Paperclip refuses to send before making a request", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await openAdvanced();
|
||||
await act(async () => {
|
||||
buttonByText("Custom headers")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const nameInput = Array.from(container.querySelectorAll<HTMLInputElement>("input"))
|
||||
.find((input) => input.getAttribute("aria-label") === "Header name")!;
|
||||
await act(async () => setInputValue(nameInput, "Host"));
|
||||
await flushReact();
|
||||
const valueInput = Array.from(container.querySelectorAll<HTMLInputElement>("input"))
|
||||
.find((input) => input.type === "password")!;
|
||||
await act(async () => setInputValue(valueInput, "evil.example"));
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain('Paperclip manages the "Host" header');
|
||||
expect(buttonByText("Check link")?.disabled).toBe(true);
|
||||
expect(connectAppMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends preregistered client credentials when the operator supplies them", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await openAdvanced();
|
||||
await act(async () => {
|
||||
buttonByText("Browser sign-in")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const clientIdInput = container.querySelector<HTMLInputElement>("#generic-mcp-client-id")!;
|
||||
await act(async () => setInputValue(clientIdInput, "operator-client"));
|
||||
await flushReact();
|
||||
const clientSecretInput = container.querySelector<HTMLInputElement>("#generic-mcp-client-secret")!;
|
||||
await act(async () => setInputValue(clientSecretInput, "operator-secret"));
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const [, input] = connectAppMock.mock.calls[0];
|
||||
expect(input).toMatchObject({
|
||||
authMode: "oauth",
|
||||
oauthClient: { clientId: "operator-client", clientSecret: "operator-secret" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps protocol jargon off the consumer path", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await openAdvanced();
|
||||
|
||||
for (const jargon of ["DCR", "Dynamic Client Registration", "CIMD", "Client ID Metadata", "RFC", "PKCE"]) {
|
||||
expect(container.textContent, jargon).not.toContain(jargon);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Loader2,
|
||||
Lock,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
@@ -50,6 +51,18 @@ import { navigateTopLevel } from "@/lib/browserNavigation";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import { appSourceConnectHref, isMcpDirectOAuthConnectSlug } from "./app-connect-policy";
|
||||
import { parseGoogleSheetIds } from "./google-sheets";
|
||||
import {
|
||||
canSubmitGenericConnect,
|
||||
customHeaderError,
|
||||
endpointHost,
|
||||
genericConnectGuidance,
|
||||
genericConnectPayload,
|
||||
newCustomHeaderRow,
|
||||
type CustomHeaderRow,
|
||||
type GenericConnectDraft,
|
||||
type GenericConnectGuidance,
|
||||
type GenericMcpAuthMode,
|
||||
} from "./generic-mcp-connect";
|
||||
import { autoExtendNotice, INSTALL_ALL_WARNING, installInfoNotice, installPayload } from "@/lib/tool-installs";
|
||||
|
||||
type Step = "gallery" | "key" | "actions" | "who" | "install" | "success";
|
||||
@@ -70,7 +83,6 @@ function appConnectHref(appKey: string, step: Step): string {
|
||||
}
|
||||
type AppAccessSelection = "all_agents" | { agentIds: string[] };
|
||||
type InstallMode = "none" | "specific" | "all";
|
||||
const LINK_CREDENTIAL_CONFIG_PATH = "credentials.authorization";
|
||||
|
||||
const STEP_LABELS = ["Pick app", "Add your key", "Choose actions", "Choose access", "Install tools"];
|
||||
const STEP_INDEX: Record<Exclude<Step, "success">, number> = {
|
||||
@@ -177,6 +189,15 @@ export function AppsConnect() {
|
||||
const [linkName, setLinkName] = useState(prefill.name || (zapierSource ? "Zapier" : ""));
|
||||
const [linkNeedsKey, setLinkNeedsKey] = useState(false);
|
||||
const [linkKey, setLinkKey] = useState("");
|
||||
// Generic ("connect your own MCP server") flow state. `authMode: auto` is the
|
||||
// simple path: Paperclip probes the endpoint and branches on what it finds.
|
||||
const [linkAuthMode, setLinkAuthMode] = useState<GenericMcpAuthMode>("auto");
|
||||
const [linkHeaders, setLinkHeaders] = useState<CustomHeaderRow[]>(() => [newCustomHeaderRow()]);
|
||||
const [linkOAuthClientId, setLinkOAuthClientId] = useState("");
|
||||
const [linkOAuthClientSecret, setLinkOAuthClientSecret] = useState("");
|
||||
const [linkAdvancedOpen, setLinkAdvancedOpen] = useState(false);
|
||||
const [linkGuidance, setLinkGuidance] = useState<GenericConnectGuidance | null>(null);
|
||||
const [genericOAuthPending, setGenericOAuthPending] = useState(false);
|
||||
const [credentials, setCredentials] = useState<Record<string, string>>({});
|
||||
const [connectionMethodKey, setConnectionMethodKey] = useState("");
|
||||
const [configValues, setConfigValues] = useState<Record<string, string | boolean>>({});
|
||||
@@ -193,6 +214,50 @@ export function AppsConnect() {
|
||||
const directOAuthStartedRef = useRef(false);
|
||||
const directOAuthRetryingRef = useRef(false);
|
||||
|
||||
const resetGenericAuthState = () => {
|
||||
setLinkAuthMode("auto");
|
||||
setLinkHeaders([newCustomHeaderRow()]);
|
||||
setLinkOAuthClientId("");
|
||||
setLinkOAuthClientSecret("");
|
||||
setLinkAdvancedOpen(false);
|
||||
setLinkGuidance(null);
|
||||
setGenericOAuthPending(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Switch to a curated app's branded setup. Reached from the gallery grid and, as
|
||||
* a convenience, from the guided generic flow when the pasted endpoint matches a
|
||||
* definition — the generic path stays available either way.
|
||||
*/
|
||||
const useMatchedGalleryEntry = (picked: AppDefinition) => {
|
||||
if (
|
||||
getAvailableConnectionMethod(picked)?.auth === "oauth" &&
|
||||
isMcpDirectOAuthConnectSlug(picked.slug)
|
||||
) {
|
||||
navigate(appSourceConnectHref(picked.slug));
|
||||
return;
|
||||
}
|
||||
setEntry(picked);
|
||||
setGalleryName(picked.name);
|
||||
setLinkUrl("");
|
||||
setLinkName("");
|
||||
setLinkNeedsKey(false);
|
||||
setLinkKey("");
|
||||
resetGenericAuthState();
|
||||
setCredentials({});
|
||||
const methods = getAvailableConnectionMethods(picked);
|
||||
const initialMethod = methods.length === 1 ? methods[0]! : null;
|
||||
setConnectionMethodKey(initialMethod?.key ?? "");
|
||||
setConfigValues(defaultMethodConfig(initialMethod));
|
||||
setGoogleSheetsLinks("");
|
||||
setGoogleSheetsError(null);
|
||||
setConnectResult(null);
|
||||
setInstallMode("none");
|
||||
setInstallAgentIds(new Set());
|
||||
setStep("key");
|
||||
navigate(appConnectHref(picked.slug, "key"));
|
||||
};
|
||||
|
||||
const openGallery = () => {
|
||||
setEntry(null);
|
||||
setGalleryName("");
|
||||
@@ -200,6 +265,7 @@ export function AppsConnect() {
|
||||
setLinkName("");
|
||||
setLinkNeedsKey(false);
|
||||
setLinkKey("");
|
||||
resetGenericAuthState();
|
||||
setCredentials({});
|
||||
setConnectionMethodKey("");
|
||||
setConfigValues({});
|
||||
@@ -250,6 +316,13 @@ export function AppsConnect() {
|
||||
[applicationsQuery.data, connectionsQuery.data, createNewConnection, directOAuthSource, prefill.applicationId],
|
||||
);
|
||||
|
||||
// A curated definition covering the pasted endpoint is offered as a branded
|
||||
// convenience only; the generic flow remains the default and stays complete.
|
||||
const linkMatchedEntry = useMemo(
|
||||
() => (linkUrl && !entry ? getAppDefinitionForUrl(linkUrl, galleryQuery.data?.apps ?? []) : null),
|
||||
[entry, galleryQuery.data, linkUrl],
|
||||
);
|
||||
|
||||
const directOAuthEntry = entry &&
|
||||
getAvailableConnectionMethod(entry)?.auth === "oauth" &&
|
||||
isMcpDirectOAuthConnectSlug(entry.slug)
|
||||
@@ -302,28 +375,46 @@ export function AppsConnect() {
|
||||
applicationId: prefill.applicationId,
|
||||
});
|
||||
}
|
||||
const trimmedKey = linkNeedsKey ? linkKey.trim() : "";
|
||||
const trimmedName = linkName.trim();
|
||||
return toolsApi.connectApp(selectedCompanyId!, {
|
||||
link: linkUrl,
|
||||
name: trimmedName || undefined,
|
||||
credentialValues: trimmedKey ? { [LINK_CREDENTIAL_CONFIG_PATH]: trimmedKey } : undefined,
|
||||
...genericConnectPayload({
|
||||
link: linkUrl,
|
||||
name: linkName,
|
||||
authMode: linkAuthMode,
|
||||
needsKey: linkNeedsKey,
|
||||
keyValue: linkKey,
|
||||
headers: linkHeaders,
|
||||
oauthClientId: linkOAuthClientId,
|
||||
oauthClientSecret: linkOAuthClientSecret,
|
||||
}),
|
||||
applicationId: prefill.applicationId,
|
||||
});
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.auth?.kind === "oauth") {
|
||||
setConnectResult(result);
|
||||
// Discovery worked but this authorization server insists on a client the
|
||||
// operator registers themselves. Keep the draft and ask for it in place
|
||||
// rather than sending them back to the start.
|
||||
if (result.auth.manualClientRequired) {
|
||||
setLinkGuidance(genericConnectGuidance("oauth_manual_client_required", null));
|
||||
setLinkAuthMode("oauth");
|
||||
setLinkAdvancedOpen(true);
|
||||
setGenericOAuthPending(false);
|
||||
return;
|
||||
}
|
||||
const startUrl = result.auth.startUrl?.trim();
|
||||
if (!startUrl) {
|
||||
setOAuthPhase("starting");
|
||||
setGenericOAuthPending(true);
|
||||
startOAuth(result.connectionId);
|
||||
return;
|
||||
}
|
||||
setOAuthPhase("redirecting");
|
||||
setGenericOAuthPending(true);
|
||||
navigateTopLevel(startUrl);
|
||||
return;
|
||||
}
|
||||
setLinkGuidance(null);
|
||||
setConnectResult(result);
|
||||
const defaults: Record<string, boolean> = {};
|
||||
for (const a of result.actions.readOnly) defaults[a.catalogEntryId] = true;
|
||||
@@ -348,14 +439,20 @@ export function AppsConnect() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const oauthRequired = details?.code === "oauth_challenge";
|
||||
// The generic URL path explains the specific corrective action inline,
|
||||
// beside the fields the operator has to change. A toast can't do that, and
|
||||
// for a pasted address "check your key" is usually the wrong advice.
|
||||
if (!entry && linkUrl) {
|
||||
const code = typeof details?.code === "string" ? details.code : null;
|
||||
const guidance = genericConnectGuidance(code, error instanceof Error ? error.message : null);
|
||||
setLinkGuidance(guidance);
|
||||
setGenericOAuthPending(false);
|
||||
if (guidance.focus === "credentials") setLinkAdvancedOpen(true);
|
||||
return;
|
||||
}
|
||||
pushToast({
|
||||
title: oauthRequired ? "Sign-in required" : "Couldn’t connect",
|
||||
body: oauthRequired
|
||||
? "This app needs you to sign in - coming soon."
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "Please check your key and try again.",
|
||||
title: "Couldn’t connect",
|
||||
body: error instanceof Error ? error.message : "Please check your key and try again.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
@@ -525,6 +622,39 @@ export function AppsConnect() {
|
||||
);
|
||||
}
|
||||
|
||||
// A pasted endpoint that needs browser sign-in gets the same waiting/retry
|
||||
// screen a curated OAuth app does, minus the branding it doesn't have.
|
||||
if (genericOAuthPending && !entry && linkUrl && step === "key") {
|
||||
return (
|
||||
<OAuthConnectStateScreen
|
||||
identity={{
|
||||
name: linkName.trim() || endpointHost(linkUrl) || "this server",
|
||||
unverifiedHost: endpointHost(linkUrl),
|
||||
}}
|
||||
phase={oauthPhase}
|
||||
error={oauthError}
|
||||
onRetry={() => {
|
||||
setOAuthError(null);
|
||||
const connectionId = connectResult?.connectionId;
|
||||
if (connectionId) {
|
||||
setOAuthPhase("starting");
|
||||
startOAuth(connectionId);
|
||||
return;
|
||||
}
|
||||
// No draft to resume, so fall back to the setup screen rather than
|
||||
// creating a second connection for the same endpoint.
|
||||
setGenericOAuthPending(false);
|
||||
setOAuthPhase("entry");
|
||||
}}
|
||||
onCancel={() => {
|
||||
setGenericOAuthPending(false);
|
||||
setOAuthPhase("entry");
|
||||
setOAuthError(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const appName =
|
||||
connectResult?.application.name ??
|
||||
entry?.name ??
|
||||
@@ -562,6 +692,7 @@ export function AppsConnect() {
|
||||
? { name: "Zapier", logoUrl: zapierEntry?.branding.logoUrl ?? null }
|
||||
: undefined
|
||||
}
|
||||
unverifiedHost={!entry && !zapierSource && step !== "gallery" ? endpointHost(linkUrl) : null}
|
||||
onCancel={() => navigate("/apps")}
|
||||
/>
|
||||
)}
|
||||
@@ -572,33 +703,7 @@ export function AppsConnect() {
|
||||
apps={galleryQuery.data?.apps ?? []}
|
||||
byo={searchParams.get("byo") === "1"}
|
||||
source={searchParams.get("source")}
|
||||
onPick={(picked) => {
|
||||
if (
|
||||
getAvailableConnectionMethod(picked)?.auth === "oauth" &&
|
||||
isMcpDirectOAuthConnectSlug(picked.slug)
|
||||
) {
|
||||
navigate(appSourceConnectHref(picked.slug));
|
||||
return;
|
||||
}
|
||||
setEntry(picked);
|
||||
setGalleryName(picked.name);
|
||||
setLinkUrl("");
|
||||
setLinkName("");
|
||||
setLinkNeedsKey(false);
|
||||
setLinkKey("");
|
||||
setCredentials({});
|
||||
const methods = getAvailableConnectionMethods(picked);
|
||||
const initialMethod = methods.length === 1 ? methods[0]! : null;
|
||||
setConnectionMethodKey(initialMethod?.key ?? "");
|
||||
setConfigValues(defaultMethodConfig(initialMethod));
|
||||
setGoogleSheetsLinks("");
|
||||
setGoogleSheetsError(null);
|
||||
setConnectResult(null);
|
||||
setInstallMode("none");
|
||||
setInstallAgentIds(new Set());
|
||||
setStep("key");
|
||||
navigate(appConnectHref(picked.slug, "key"));
|
||||
}}
|
||||
onPick={useMatchedGalleryEntry}
|
||||
onUseLink={(url) => {
|
||||
const matchedEntry = getAppDefinitionForUrl(url, galleryQuery.data?.apps ?? []);
|
||||
setEntry(null);
|
||||
@@ -671,9 +776,35 @@ export function AppsConnect() {
|
||||
}}
|
||||
keyValue={linkKey}
|
||||
onKeyChange={setLinkKey}
|
||||
submitting={connectMutation.isPending}
|
||||
authMode={linkAuthMode}
|
||||
onAuthModeChange={(next) => {
|
||||
setLinkAuthMode(next);
|
||||
setLinkGuidance(null);
|
||||
// Leaving the simple path means the explicit choice governs; drop the
|
||||
// "does it need a key?" answer so the two can't disagree.
|
||||
if (next !== "auto") setLinkNeedsKey(false);
|
||||
if (next !== "bearer" && next !== "auto") setLinkKey("");
|
||||
}}
|
||||
headers={linkHeaders}
|
||||
onHeadersChange={(next) => {
|
||||
setLinkHeaders(next);
|
||||
setLinkGuidance(null);
|
||||
}}
|
||||
oauthClientId={linkOAuthClientId}
|
||||
onOAuthClientIdChange={setLinkOAuthClientId}
|
||||
oauthClientSecret={linkOAuthClientSecret}
|
||||
onOAuthClientSecretChange={setLinkOAuthClientSecret}
|
||||
advancedOpen={linkAdvancedOpen}
|
||||
onAdvancedOpenChange={setLinkAdvancedOpen}
|
||||
guidance={linkGuidance}
|
||||
matchedEntry={linkMatchedEntry}
|
||||
onUseMatchedEntry={linkMatchedEntry ? () => useMatchedGalleryEntry(linkMatchedEntry) : undefined}
|
||||
submitting={connectMutation.isPending || genericOAuthPending}
|
||||
onBack={() => setStep("gallery")}
|
||||
onConnect={() => connectMutation.mutate(undefined)}
|
||||
onConnect={() => {
|
||||
setLinkGuidance(null);
|
||||
connectMutation.mutate(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -755,6 +886,7 @@ function StepHeader({
|
||||
activeIndex,
|
||||
labels,
|
||||
appIdentity,
|
||||
unverifiedHost,
|
||||
onCancel,
|
||||
}: {
|
||||
subtitle: string;
|
||||
@@ -762,6 +894,12 @@ function StepHeader({
|
||||
activeIndex: number;
|
||||
labels: string[];
|
||||
appIdentity?: { name: string; logoUrl: string | null };
|
||||
/**
|
||||
* Host of an unknown remote MCP server. Present for the whole generic flow so
|
||||
* the operator can see whose server they are configuring at every step, not
|
||||
* just on the screen where they pasted the address.
|
||||
*/
|
||||
unverifiedHost?: string | null;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -776,6 +914,7 @@ function StepHeader({
|
||||
{appIdentity ? `Connect ${appIdentity.name}` : "Connect an app"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
|
||||
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-2" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
@@ -801,34 +940,40 @@ function StepHeader({
|
||||
|
||||
export function OAuthConnectStateScreen({
|
||||
entry,
|
||||
identity,
|
||||
phase,
|
||||
error,
|
||||
onRetry,
|
||||
onCancel,
|
||||
}: {
|
||||
entry: AppDefinition;
|
||||
/** A curated app. Omit for a generic endpoint and pass `identity` instead. */
|
||||
entry?: AppDefinition | null;
|
||||
/** Identity for an unknown remote MCP server: its own name plus its host. */
|
||||
identity?: { name: string; unverifiedHost: string | null };
|
||||
phase: OAuthConnectPhase;
|
||||
error?: string | null;
|
||||
onRetry: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const serverName = entry?.name ?? identity?.name ?? "this server";
|
||||
const unverifiedHost = entry ? null : identity?.unverifiedHost ?? null;
|
||||
const status = phase === "entry"
|
||||
? {
|
||||
title: `Connect ${entry.name} to Paperclip`,
|
||||
body: `Paperclip will open ${entry.name} so you can choose a workspace and approve access.`,
|
||||
title: `Connect ${serverName} to Paperclip`,
|
||||
body: `Paperclip will open ${serverName} so you can choose a workspace and approve access.`,
|
||||
}
|
||||
: phase === "starting"
|
||||
? {
|
||||
title: "Preparing secure sign-in",
|
||||
body: `Paperclip is creating a secure ${entry.name} connection.`,
|
||||
body: `Paperclip is creating a secure ${serverName} connection.`,
|
||||
}
|
||||
: phase === "redirecting"
|
||||
? {
|
||||
title: `Opening ${entry.name}`,
|
||||
body: `Continue in ${entry.name} to choose a workspace and approve access.`,
|
||||
title: `Opening ${serverName}`,
|
||||
body: `Continue in ${serverName} to choose a workspace and approve access.`,
|
||||
}
|
||||
: {
|
||||
title: `${entry.name} couldn’t connect`,
|
||||
title: `${serverName} couldn’t connect`,
|
||||
body: error ?? "Paperclip couldn’t start secure sign-in. Try again.",
|
||||
};
|
||||
|
||||
@@ -839,7 +984,8 @@ export function OAuthConnectStateScreen({
|
||||
step="key"
|
||||
activeIndex={0}
|
||||
labels={["Connect", "Review actions", "Choose access", "Install tools"]}
|
||||
appIdentity={{ name: entry.name, logoUrl: entry.branding.logoUrl }}
|
||||
appIdentity={entry ? { name: entry.name, logoUrl: entry.branding.logoUrl } : undefined}
|
||||
unverifiedHost={unverifiedHost}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
|
||||
@@ -856,6 +1002,7 @@ export function OAuthConnectStateScreen({
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-bold tracking-tight">{status.title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{status.body}</p>
|
||||
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-2" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -864,7 +1011,7 @@ export function OAuthConnectStateScreen({
|
||||
<Button type="button" onClick={onRetry}>Try again</Button>
|
||||
) : (
|
||||
<Button type="button" disabled>
|
||||
{phase === "redirecting" ? `Opening ${entry.name}…` : "Preparing…"}
|
||||
{phase === "redirecting" ? `Opening ${serverName}…` : "Preparing…"}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>Back to apps</Button>
|
||||
@@ -1208,6 +1355,19 @@ function defaultLinkName(link: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The guided universal flow for an unknown remote MCP server (PAP-17087, plan 2).
|
||||
*
|
||||
* The simple path stays exactly as short as it was — URL, name, "does it need a
|
||||
* key?" — because that is all most servers need. Everything protocol-shaped lives
|
||||
* behind "Advanced authentication", and no OAuth/DCR/CIMD jargon appears on the
|
||||
* consumer path: the operator picks how the server authenticates, not which RFC
|
||||
* Paperclip will use to satisfy it.
|
||||
*
|
||||
* The endpoint host and the "Unverified server" label stay visible the whole way
|
||||
* through, so the operator can always see whose server they are about to let
|
||||
* agents call.
|
||||
*/
|
||||
function LinkConnectStep({
|
||||
link,
|
||||
name,
|
||||
@@ -1216,6 +1376,19 @@ function LinkConnectStep({
|
||||
onNeedsKeyChange,
|
||||
keyValue,
|
||||
onKeyChange,
|
||||
authMode,
|
||||
onAuthModeChange,
|
||||
headers,
|
||||
onHeadersChange,
|
||||
oauthClientId,
|
||||
onOAuthClientIdChange,
|
||||
oauthClientSecret,
|
||||
onOAuthClientSecretChange,
|
||||
advancedOpen,
|
||||
onAdvancedOpenChange,
|
||||
guidance,
|
||||
matchedEntry,
|
||||
onUseMatchedEntry,
|
||||
submitting,
|
||||
onBack,
|
||||
onConnect,
|
||||
@@ -1227,10 +1400,43 @@ function LinkConnectStep({
|
||||
onNeedsKeyChange: (next: boolean) => void;
|
||||
keyValue: string;
|
||||
onKeyChange: (next: string) => void;
|
||||
authMode: GenericMcpAuthMode;
|
||||
onAuthModeChange: (next: GenericMcpAuthMode) => void;
|
||||
headers: CustomHeaderRow[];
|
||||
onHeadersChange: (next: CustomHeaderRow[]) => void;
|
||||
oauthClientId: string;
|
||||
onOAuthClientIdChange: (next: string) => void;
|
||||
oauthClientSecret: string;
|
||||
onOAuthClientSecretChange: (next: string) => void;
|
||||
advancedOpen: boolean;
|
||||
onAdvancedOpenChange: (next: boolean) => void;
|
||||
guidance: GenericConnectGuidance | null;
|
||||
/** A curated app whose endpoint matches, offered as a convenience only. */
|
||||
matchedEntry?: AppDefinition | null;
|
||||
onUseMatchedEntry?: () => void;
|
||||
submitting: boolean;
|
||||
onBack: () => void;
|
||||
onConnect: () => void;
|
||||
}) {
|
||||
const host = endpointHost(link);
|
||||
const headerError = authMode === "custom_headers" ? customHeaderError(headers) : null;
|
||||
const draft: GenericConnectDraft = {
|
||||
link,
|
||||
name,
|
||||
authMode,
|
||||
needsKey,
|
||||
keyValue,
|
||||
headers,
|
||||
oauthClientId,
|
||||
oauthClientSecret,
|
||||
};
|
||||
const canSubmit = canSubmitGenericConnect(draft);
|
||||
const showSimpleKeyQuestion = authMode === "auto";
|
||||
|
||||
const updateHeader = (id: string, patch: Partial<CustomHeaderRow>) => {
|
||||
onHeadersChange(headers.map((row) => (row.id === id ? { ...row, ...patch } : row)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -1238,51 +1444,76 @@ function LinkConnectStep({
|
||||
<Link2 className="h-5 w-5 text-muted-foreground" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-bold tracking-tight">Connect with a link</h2>
|
||||
<p className="mt-1 truncate text-sm text-muted-foreground">{link}</p>
|
||||
<h2 className="text-xl font-bold tracking-tight">Connect your own MCP server</h2>
|
||||
<p className="mt-1 truncate font-mono text-sm text-muted-foreground" title={link}>{link}</p>
|
||||
<UnverifiedServerBadge host={host} className="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{matchedEntry && onUseMatchedEntry ? (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<AppLogo name={matchedEntry.name} logoUrl={matchedEntry.branding.logoUrl} size={24} />
|
||||
<span className="truncate">Paperclip has a guided setup for {matchedEntry.name}.</span>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onUseMatchedEntry}>
|
||||
Use {matchedEntry.name}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{guidance ? (
|
||||
<div className="mt-6">
|
||||
<InlineBanner tone="warning" title={guidance.title}>
|
||||
{guidance.body}
|
||||
</InlineBanner>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 space-y-6">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">Name</label>
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="generic-mcp-name">Name</label>
|
||||
<Input
|
||||
id="generic-mcp-name"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="My app"
|
||||
className="mt-2 h-11"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
We filled this in from the link. Change it if you’d like.
|
||||
We filled this in from the address. Change it if you'd like.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">Does it need a key?</label>
|
||||
<div className="mt-2 inline-flex rounded-lg border border-border bg-muted/50 p-1">
|
||||
<SegmentedOption
|
||||
label="No"
|
||||
selected={!needsKey}
|
||||
onClick={() => onNeedsKeyChange(false)}
|
||||
/>
|
||||
<SegmentedOption
|
||||
label="Yes"
|
||||
selected={needsKey}
|
||||
onClick={() => onNeedsKeyChange(true)}
|
||||
/>
|
||||
{showSimpleKeyQuestion && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">Does it need a key?</label>
|
||||
<div className="mt-2 inline-flex rounded-lg border border-border bg-muted/50 p-1">
|
||||
<SegmentedOption
|
||||
label="No"
|
||||
selected={!needsKey}
|
||||
onClick={() => onNeedsKeyChange(false)}
|
||||
/>
|
||||
<SegmentedOption
|
||||
label="Yes"
|
||||
selected={needsKey}
|
||||
onClick={() => onNeedsKeyChange(true)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{needsKey
|
||||
? "Paste the key this app gave you."
|
||||
: "Most servers just work from the address — pick Yes only if the server gave you a key, or if it asks you to sign in."}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{needsKey
|
||||
? "Paste the key this app gave you."
|
||||
: "Most apps just work from the link — pick Yes only if the app gave you a key."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsKey && (
|
||||
{(showSimpleKeyQuestion && needsKey) || authMode === "bearer" ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">App key</label>
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="generic-mcp-key">App key</label>
|
||||
<Input
|
||||
id="generic-mcp-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={keyValue}
|
||||
@@ -1291,18 +1522,116 @@ function LinkConnectStep({
|
||||
className="mt-2 h-11 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 rounded-lg bg-muted/50 p-4">
|
||||
<Lock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">Your key is stored securely.</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
You can replace it anytime from this app’s page.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StoredSecurelyNote />
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<Collapsible open={advancedOpen} onOpenChange={onAdvancedOpenChange}>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg border border-border bg-background px-3 py-2 text-left text-sm font-medium text-foreground hover:bg-accent/40">
|
||||
Advanced authentication
|
||||
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform", advancedOpen && "rotate-180")} />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-5 pt-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only needed when the server's docs are specific about how to authenticate.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{GENERIC_AUTH_MODE_OPTIONS.map((option) => (
|
||||
<SegmentedOption
|
||||
key={option.mode}
|
||||
label={option.label}
|
||||
selected={authMode === option.mode}
|
||||
onClick={() => onAuthModeChange(option.mode)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{GENERIC_AUTH_MODE_OPTIONS.find((option) => option.mode === authMode)?.hint}
|
||||
</p>
|
||||
|
||||
{authMode === "custom_headers" ? (
|
||||
<div className="space-y-3">
|
||||
{headers.map((row) => (
|
||||
<div key={row.id} className="flex items-start gap-2">
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(e) => updateHeader(row.id, { name: e.target.value })}
|
||||
placeholder="Header name"
|
||||
aria-label="Header name"
|
||||
className="h-10 font-mono"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={row.value}
|
||||
onChange={(e) => updateHeader(row.id, { value: e.target.value })}
|
||||
placeholder="Value"
|
||||
aria-label={row.name.trim() ? `Value for ${row.name.trim()}` : "Header value"}
|
||||
className="h-10 font-mono"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-10 shrink-0"
|
||||
onClick={() => onHeadersChange(headers.filter((candidate) => candidate.id !== row.id))}
|
||||
disabled={headers.length === 1}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onHeadersChange([...headers, newCustomHeaderRow()])}
|
||||
>
|
||||
Add another header
|
||||
</Button>
|
||||
{headerError ? <p className="text-xs text-destructive">{headerError}</p> : null}
|
||||
<StoredSecurelyNote />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{authMode === "oauth" ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paperclip sets sign-in up on its own whenever the server allows it. Only fill these in when the
|
||||
server's docs tell you to register Paperclip yourself first.
|
||||
</p>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="generic-mcp-client-id">
|
||||
Client ID
|
||||
</label>
|
||||
<Input
|
||||
id="generic-mcp-client-id"
|
||||
value={oauthClientId}
|
||||
onChange={(e) => onOAuthClientIdChange(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder="Optional"
|
||||
className="mt-2 h-11 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="generic-mcp-client-secret">
|
||||
Client secret
|
||||
</label>
|
||||
<Input
|
||||
id="generic-mcp-client-secret"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={oauthClientSecret}
|
||||
onChange={(e) => onOAuthClientSecretChange(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="mt-2 h-11 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<StoredSecurelyNote />
|
||||
</div>
|
||||
) : null}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
@@ -1311,9 +1640,9 @@ function LinkConnectStep({
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="hidden text-xs text-muted-foreground sm:inline">
|
||||
We’ll check the link before turning anything on.
|
||||
We'll check the server before turning anything on.
|
||||
</span>
|
||||
<Button onClick={onConnect} disabled={submitting || (needsKey && keyValue.trim().length === 0)}>
|
||||
<Button onClick={onConnect} disabled={submitting || !canSubmit || Boolean(headerError)}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{submitting ? "Checking…" : "Check link"}
|
||||
</Button>
|
||||
@@ -1323,6 +1652,67 @@ function LinkConnectStep({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the operator is choosing is how the *server* authenticates, in its own
|
||||
* terms. Paperclip decides internally whether that means a preconfigured client,
|
||||
* a client ID metadata document, dynamic registration, or the credentials pasted
|
||||
* below — none of which belongs on this screen.
|
||||
*/
|
||||
const GENERIC_AUTH_MODE_OPTIONS: Array<{ mode: GenericMcpAuthMode; label: string; hint: string }> = [
|
||||
{
|
||||
mode: "auto",
|
||||
label: "Let Paperclip check",
|
||||
hint: "Paperclip asks the server what it needs and walks you through it. Start here.",
|
||||
},
|
||||
{
|
||||
mode: "none",
|
||||
label: "No sign-in needed",
|
||||
hint: "The server is open to anyone with the address.",
|
||||
},
|
||||
{
|
||||
mode: "bearer",
|
||||
label: "Key or token",
|
||||
hint: "Paperclip sends your key as an Authorization header.",
|
||||
},
|
||||
{
|
||||
mode: "custom_headers",
|
||||
label: "Custom headers",
|
||||
hint: "For servers that name their own headers. Values are stored as Paperclip secrets and can\u2019t be read back.",
|
||||
},
|
||||
{
|
||||
mode: "oauth",
|
||||
label: "Browser sign-in",
|
||||
hint: "You\u2019ll sign in at the provider. Add a client ID and secret only if the provider requires you to register Paperclip first.",
|
||||
},
|
||||
];
|
||||
|
||||
function UnverifiedServerBadge({ host, className }: { host: string | null; className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-2 text-xs", className)}>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 font-medium text-amber-700 dark:text-amber-400">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
Unverified server
|
||||
</span>
|
||||
{host ? <span className="font-mono text-muted-foreground">{host}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StoredSecurelyNote() {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-muted/50 p-4">
|
||||
<Lock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">Stored securely.</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Paperclip keeps this in its encrypted secret store. You can replace it anytime from this app's page,
|
||||
but it can't be read back.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentedOption({
|
||||
label,
|
||||
selected,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canSubmitGenericConnect,
|
||||
customHeaderError,
|
||||
endpointHost,
|
||||
genericConnectGuidance,
|
||||
genericConnectPayload,
|
||||
newCustomHeaderRow,
|
||||
type GenericConnectDraft,
|
||||
} from "./generic-mcp-connect";
|
||||
|
||||
function draft(overrides: Partial<GenericConnectDraft> = {}): GenericConnectDraft {
|
||||
return {
|
||||
link: "https://mcp.example.test/mcp",
|
||||
name: "Example",
|
||||
authMode: "auto",
|
||||
needsKey: false,
|
||||
keyValue: "",
|
||||
headers: [newCustomHeaderRow()],
|
||||
oauthClientId: "",
|
||||
oauthClientSecret: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("endpointHost", () => {
|
||||
it("returns the host so the operator can see whose server this is", () => {
|
||||
expect(endpointHost("https://mcp.example.test/mcp?project_id=1")).toBe("mcp.example.test");
|
||||
expect(endpointHost("http://127.0.0.1:8848/mcp")).toBe("127.0.0.1:8848");
|
||||
});
|
||||
|
||||
it("returns null for anything that isn't a URL", () => {
|
||||
expect(endpointHost("not a url")).toBeNull();
|
||||
expect(endpointHost("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("genericConnectGuidance", () => {
|
||||
it("names the URL as the thing to fix for invalid, unsafe and unreachable endpoints", () => {
|
||||
for (const code of [
|
||||
"mcp_remote_url_invalid",
|
||||
"remote_http_private_endpoint",
|
||||
"remote_http_dns_failed",
|
||||
"runtime_error",
|
||||
]) {
|
||||
expect(genericConnectGuidance(code, null).focus, code).toBe("url");
|
||||
}
|
||||
});
|
||||
|
||||
it("names the credentials for a challenge with no discoverable sign-in", () => {
|
||||
const guidance = genericConnectGuidance("oauth_challenge", null);
|
||||
expect(guidance.focus).toBe("credentials");
|
||||
expect(guidance.body).toContain("Advanced authentication");
|
||||
});
|
||||
|
||||
it("points at the deployment when Paperclip itself has no public HTTPS address", () => {
|
||||
expect(genericConnectGuidance("oauth_redirect_origin_unsupported", null).focus).toBe("deployment");
|
||||
});
|
||||
|
||||
it("passes a rejected header's own message through", () => {
|
||||
const guidance = genericConnectGuidance(
|
||||
"mcp_header_rejected",
|
||||
'Paperclip manages the "Host" header and cannot send a custom value for it.',
|
||||
);
|
||||
expect(guidance.body).toContain('"Host"');
|
||||
expect(guidance.focus).toBe("credentials");
|
||||
});
|
||||
|
||||
it("falls back to the server's message for an unrecognised code", () => {
|
||||
expect(genericConnectGuidance("something_new", "Upstream said no.").body).toBe("Upstream said no.");
|
||||
expect(genericConnectGuidance(null, null).body).toContain("Check it and try again");
|
||||
});
|
||||
});
|
||||
|
||||
describe("customHeaderError", () => {
|
||||
it("accepts a filled row and ignores a blank one", () => {
|
||||
expect(customHeaderError([
|
||||
{ id: "a", name: "X-Api-Key", value: "abc" },
|
||||
{ id: "b", name: "", value: "" },
|
||||
])).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects headers Paperclip refuses to send", () => {
|
||||
expect(customHeaderError([{ id: "a", name: "Host", value: "evil.example" }]))
|
||||
.toContain('Paperclip manages the "Host" header');
|
||||
});
|
||||
|
||||
it("rejects a value that would split the request", () => {
|
||||
expect(customHeaderError([{ id: "a", name: "X-Api-Key", value: "abc\r\nX-Injected: 1" }]))
|
||||
.toContain("line breaks");
|
||||
});
|
||||
|
||||
it("asks for a value on a half-filled row and flags duplicates", () => {
|
||||
expect(customHeaderError([{ id: "a", name: "X-Api-Key", value: "" }]))
|
||||
.toContain('Add a value for "X-Api-Key"');
|
||||
expect(customHeaderError([
|
||||
{ id: "a", name: "X-Api-Key", value: "one" },
|
||||
{ id: "b", name: "x-api-key", value: "two" },
|
||||
])).toContain("listed twice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("genericConnectPayload", () => {
|
||||
it("sends nothing but the link on the simplest path", () => {
|
||||
expect(genericConnectPayload(draft())).toEqual({
|
||||
link: "https://mcp.example.test/mcp",
|
||||
name: "Example",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits authMode on the simple path so the server probes and decides", () => {
|
||||
const payload = genericConnectPayload(draft({ needsKey: true, keyValue: "abc" }));
|
||||
expect(payload.authMode).toBeUndefined();
|
||||
expect(payload.credentialValues).toEqual({ "credentials.authorization": "abc" });
|
||||
});
|
||||
|
||||
it("sends the explicit mode chosen under Advanced authentication", () => {
|
||||
expect(genericConnectPayload(draft({ authMode: "none" }))).toMatchObject({ authMode: "none" });
|
||||
expect(genericConnectPayload(draft({ authMode: "bearer", keyValue: " abc " })))
|
||||
.toMatchObject({ authMode: "bearer", credentialValues: { "credentials.authorization": "abc" } });
|
||||
});
|
||||
|
||||
it("maps custom headers to headers.* credential paths", () => {
|
||||
const payload = genericConnectPayload(draft({
|
||||
authMode: "custom_headers",
|
||||
headers: [
|
||||
{ id: "a", name: " X-Api-Key ", value: "phx_secret" },
|
||||
{ id: "b", name: "X-Project", value: "12345" },
|
||||
{ id: "c", name: "", value: "" },
|
||||
],
|
||||
}));
|
||||
expect(payload.credentialValues).toEqual({
|
||||
"headers.X-Api-Key": "phx_secret",
|
||||
"headers.X-Project": "12345",
|
||||
});
|
||||
});
|
||||
|
||||
it("only sends a preregistered client when the operator supplied one", () => {
|
||||
expect(genericConnectPayload(draft({ authMode: "oauth" })).oauthClient).toBeUndefined();
|
||||
expect(genericConnectPayload(draft({ authMode: "oauth", oauthClientId: "cid" })).oauthClient)
|
||||
.toEqual({ clientId: "cid" });
|
||||
expect(genericConnectPayload(draft({
|
||||
authMode: "oauth",
|
||||
oauthClientId: "cid",
|
||||
oauthClientSecret: "shh",
|
||||
})).oauthClient).toEqual({ clientId: "cid", clientSecret: "shh" });
|
||||
});
|
||||
|
||||
it("does not carry a bearer key into a custom-header or no-auth submission", () => {
|
||||
// Switching modes must not leak a value the operator typed under a different
|
||||
// one — the wizard clears it, and the payload builder does not resurrect it.
|
||||
expect(genericConnectPayload(draft({ authMode: "custom_headers", keyValue: "stale" })).credentialValues)
|
||||
.toBeUndefined();
|
||||
expect(genericConnectPayload(draft({ authMode: "none", keyValue: "stale" })).credentialValues)
|
||||
.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canSubmitGenericConnect", () => {
|
||||
it("needs a link", () => {
|
||||
expect(canSubmitGenericConnect(draft({ link: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the simple no-key path through and holds the yes-key path until a key is typed", () => {
|
||||
expect(canSubmitGenericConnect(draft())).toBe(true);
|
||||
expect(canSubmitGenericConnect(draft({ needsKey: true }))).toBe(false);
|
||||
expect(canSubmitGenericConnect(draft({ needsKey: true, keyValue: "abc" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a key for the explicit bearer mode", () => {
|
||||
expect(canSubmitGenericConnect(draft({ authMode: "bearer" }))).toBe(false);
|
||||
expect(canSubmitGenericConnect(draft({ authMode: "bearer", keyValue: "abc" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("requires at least one valid header for custom-header mode", () => {
|
||||
expect(canSubmitGenericConnect(draft({ authMode: "custom_headers" }))).toBe(false);
|
||||
expect(canSubmitGenericConnect(draft({
|
||||
authMode: "custom_headers",
|
||||
headers: [{ id: "a", name: "X-Api-Key", value: "abc" }],
|
||||
}))).toBe(true);
|
||||
expect(canSubmitGenericConnect(draft({
|
||||
authMode: "custom_headers",
|
||||
headers: [{ id: "a", name: "Host", value: "abc" }],
|
||||
}))).toBe(false);
|
||||
});
|
||||
|
||||
it("allows no-auth and browser sign-in without any value", () => {
|
||||
expect(canSubmitGenericConnect(draft({ authMode: "none" }))).toBe(true);
|
||||
expect(canSubmitGenericConnect(draft({ authMode: "oauth" }))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { checkMcpRemoteHeaderName, checkMcpRemoteHeaderValue, mcpRemoteHeaderRejectionMessage } from "@paperclipai/shared";
|
||||
import type { GenericMcpAuthMode } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* Logic behind the guided "Connect your own MCP server" flow (PAP-17087).
|
||||
*
|
||||
* Kept out of the component so the branch an operator lands on — and the
|
||||
* corrective advice they get — is directly testable, and so the wizard never has
|
||||
* to reason about protocol error codes inline.
|
||||
*/
|
||||
|
||||
export type { GenericMcpAuthMode };
|
||||
|
||||
export interface CustomHeaderRow {
|
||||
/** Stable key so React can track rows across add/remove without reordering values. */
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
let customHeaderRowSeq = 0;
|
||||
|
||||
/** A blank custom-header row. The id is local only — it never reaches the API. */
|
||||
export function newCustomHeaderRow(): CustomHeaderRow {
|
||||
customHeaderRowSeq += 1;
|
||||
return { id: `header-${customHeaderRowSeq}`, name: "", value: "" };
|
||||
}
|
||||
|
||||
/** The endpoint host, shown prominently so the operator always sees who they are trusting. */
|
||||
export function endpointHost(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).host || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the operator has to look to fix a failed connect attempt. The wizard uses
|
||||
* this to decide whether to reopen the URL field or the credential section, rather
|
||||
* than leaving them to guess which of the two was wrong.
|
||||
*/
|
||||
export type GenericConnectFocus = "url" | "credentials" | "deployment" | "none";
|
||||
|
||||
export interface GenericConnectGuidance {
|
||||
title: string;
|
||||
body: string;
|
||||
focus: GenericConnectFocus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn an API failure into the specific corrective action the plan asks for:
|
||||
* invalid, unsafe, unreachable, or "needs a credential we could not discover".
|
||||
*
|
||||
* Anything unrecognised falls through to the server's own message, which is
|
||||
* already UI-safe (upstream bodies and secrets are stripped server-side).
|
||||
*/
|
||||
export function genericConnectGuidance(
|
||||
code: string | null | undefined,
|
||||
message: string | null | undefined,
|
||||
): GenericConnectGuidance {
|
||||
const fallback = message?.trim() || "Paperclip couldn't connect to that address. Check it and try again.";
|
||||
switch (code) {
|
||||
case "mcp_remote_url_missing":
|
||||
case "mcp_remote_url_invalid":
|
||||
return {
|
||||
title: "That doesn't look like a server address",
|
||||
body: "Paste the full address, starting with https:// — for example https://mcp.example.com/mcp.",
|
||||
focus: "url",
|
||||
};
|
||||
case "remote_http_private_endpoint":
|
||||
return {
|
||||
title: "That address is inside a private network",
|
||||
body: "This Paperclip is reachable from the internet, so it won't call addresses on your local network. Use the server's public address instead.",
|
||||
focus: "url",
|
||||
};
|
||||
case "remote_http_dns_failed":
|
||||
return {
|
||||
title: "We couldn't find that host",
|
||||
body: "The address didn't resolve. Check the spelling, or confirm the server is published on the internet.",
|
||||
focus: "url",
|
||||
};
|
||||
case "mcp_header_rejected":
|
||||
return {
|
||||
title: "Paperclip can't send that header",
|
||||
body: fallback,
|
||||
focus: "credentials",
|
||||
};
|
||||
case "oauth_challenge":
|
||||
return {
|
||||
title: "This server wants a credential",
|
||||
body: "It asked us to authenticate but didn't offer a sign-in Paperclip can complete on its own. Add the key or headers its docs list under Advanced authentication.",
|
||||
focus: "credentials",
|
||||
};
|
||||
case "oauth_manual_client_required":
|
||||
case "oauth_manual_client_rebinding_required":
|
||||
return {
|
||||
title: "This server needs sign-in details you create yourself",
|
||||
body: "Register Paperclip in the provider's settings, then add the client ID and secret it gives you under Advanced authentication.",
|
||||
focus: "credentials",
|
||||
};
|
||||
case "oauth_redirect_origin_unsupported":
|
||||
case "oauth_redirect_uri_invalid":
|
||||
return {
|
||||
title: "This Paperclip needs a public HTTPS address first",
|
||||
body: "Sign-in sends the operator back to Paperclip, so this instance has to be reachable over HTTPS. Ask your Paperclip admin to configure it.",
|
||||
focus: "deployment",
|
||||
};
|
||||
case "runtime_error":
|
||||
return {
|
||||
title: "We couldn't reach that server",
|
||||
body: "Nothing answered at that address. Confirm the server is running and the address is right, then try again.",
|
||||
focus: "url",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: "Paperclip couldn't connect",
|
||||
body: fallback,
|
||||
focus: "none",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First client-side complaint about the custom headers, or null when they're fine.
|
||||
* The API validates these too; checking here means the operator finds out before a
|
||||
* round trip rather than after one.
|
||||
*/
|
||||
export function customHeaderError(rows: CustomHeaderRow[]): string | null {
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const name = row.name.trim();
|
||||
if (!name && !row.value.trim()) continue;
|
||||
const nameCheck = checkMcpRemoteHeaderName(name);
|
||||
if (!nameCheck.ok) return mcpRemoteHeaderRejectionMessage(name, nameCheck.reason!);
|
||||
const valueCheck = checkMcpRemoteHeaderValue(row.value);
|
||||
if (!valueCheck.ok) return mcpRemoteHeaderRejectionMessage(name, valueCheck.reason!);
|
||||
if (!row.value.trim()) return `Add a value for "${name}", or remove the row.`;
|
||||
const lower = name.toLowerCase();
|
||||
if (seen.has(lower)) return `"${name}" is listed twice.`;
|
||||
seen.add(lower);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface GenericConnectDraft {
|
||||
link: string;
|
||||
name: string;
|
||||
authMode: GenericMcpAuthMode;
|
||||
/** Simple path: the operator answered "Yes" to "does it need a key?". */
|
||||
needsKey: boolean;
|
||||
keyValue: string;
|
||||
headers: CustomHeaderRow[];
|
||||
oauthClientId: string;
|
||||
oauthClientSecret: string;
|
||||
}
|
||||
|
||||
export interface GenericConnectPayload {
|
||||
link: string;
|
||||
name?: string;
|
||||
authMode?: GenericMcpAuthMode;
|
||||
credentialValues?: Record<string, string>;
|
||||
oauthClient?: { clientId: string; clientSecret?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the connect request from wizard state.
|
||||
*
|
||||
* `authMode` is only sent when the operator made an explicit choice under Advanced
|
||||
* authentication; the simple path leaves it off so the server probes and decides.
|
||||
* Header values become `headers.<Name>` credential paths, which the server turns
|
||||
* into Paperclip secrets — nothing here ever puts a value in the config.
|
||||
*/
|
||||
export function genericConnectPayload(draft: GenericConnectDraft): GenericConnectPayload {
|
||||
const credentialValues: Record<string, string> = {};
|
||||
const bearerKey = draft.keyValue.trim();
|
||||
const useBearer = draft.authMode === "bearer" || (draft.authMode === "auto" && draft.needsKey);
|
||||
if (useBearer && bearerKey) credentialValues["credentials.authorization"] = bearerKey;
|
||||
if (draft.authMode === "custom_headers") {
|
||||
for (const row of draft.headers) {
|
||||
const name = row.name.trim();
|
||||
if (!name || !row.value.trim()) continue;
|
||||
credentialValues[`headers.${name}`] = row.value;
|
||||
}
|
||||
}
|
||||
const trimmedName = draft.name.trim();
|
||||
const clientId = draft.oauthClientId.trim();
|
||||
const clientSecret = draft.oauthClientSecret.trim();
|
||||
return {
|
||||
link: draft.link,
|
||||
...(trimmedName ? { name: trimmedName } : {}),
|
||||
...(draft.authMode === "auto" ? {} : { authMode: draft.authMode }),
|
||||
...(Object.keys(credentialValues).length > 0 ? { credentialValues } : {}),
|
||||
...(draft.authMode === "oauth" && clientId
|
||||
? { oauthClient: { clientId, ...(clientSecret ? { clientSecret } : {}) } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Can "Check link" be pressed? */
|
||||
export function canSubmitGenericConnect(draft: GenericConnectDraft): boolean {
|
||||
if (!draft.link.trim()) return false;
|
||||
if (draft.authMode === "auto") return !draft.needsKey || draft.keyValue.trim().length > 0;
|
||||
if (draft.authMode === "bearer") return draft.keyValue.trim().length > 0;
|
||||
if (draft.authMode === "custom_headers") {
|
||||
const filled = draft.headers.filter((row) => row.name.trim() && row.value.trim());
|
||||
return filled.length > 0 && customHeaderError(draft.headers) === null;
|
||||
}
|
||||
// "oauth" here means the operator is supplying a preregistered client, and
|
||||
// "none" means they are asserting the server is public — neither needs a value.
|
||||
return true;
|
||||
}
|
||||
Reference in new issue
Block a user