mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 18:06:11 +02:00
## Thinking Path > - Paperclip lets people manage AI agents and govern their tools. > - Apps already supports provider-hosted MCP servers. > - Useful providers still need real setup and read proof before Browse exposure. > - Browser testing found a Bitly token-format failure and an OAuth callback that replaced an explicit empty agent selection. > - This PR qualifies four provider methods and fixes those shared OAuth paths. > - Providers that still need login or approval remain withheld from new Browse listings. ## Linked Issues or Issue Description Refs: #15621, the merged hosted MCP qualification batch. Searches found no duplicate Bitly or Context7 implementation PR. **What existing behavior does this improve?** Apps setup, provider MCP discovery, token exchange, and saved agent access through an OAuth callback. **Subsystem affected** Shared Apps definitions, the server tool-access service, and the UI setup/recovery route. **Current behavior** Bitly's callback can fail with `oauth_access_token_missing` when its token response is not JSON. Curated MCP callbacks can treat an explicitly saved empty install set as access for all agents. Context7 is hidden and Hugging Face lacks a curated anonymous method. Saved drafts for hidden providers cannot finish setup through their catalog wizard. **Proposed behavior** Request JSON token responses. Record explicit remote MCP install choices in the install transaction and preserve them at callback. Keep managed subject/default access when no choice was saved. Expose the qualified Bitly and Context7 methods, add anonymous Hugging Face Hub access, and record Resend qualification. Resume hidden providers only through an exact matching company-visible saved draft. **Reason and benefit** People can connect useful hosted tools through the existing Apps flow. An explicit zero-agent selection survives authorization. Public documentation lookups need no OAuth app, account, or key. **Breaking changes** Explicitly saved empty MCP install choices now remain empty. Existing managed defaults without a saved choice retain their behavior. No schema migration or API shape change. ## What Changed - Qualify Bitly OAuth, Context7 public MCP, Hugging Face public MCP, and Resend OAuth with real browser setup, useful UI reads, and reload checks. - Negotiate JSON OAuth token responses and preserve explicitly saved MCP agent reach with a transactional config marker. - Add durable provider overrides, permission reviews, dated text-only evidence, and official branding provenance. Remove all seven evidence images and their report references. - Restore Finish setup for hidden-provider drafts with company/application/provider checks; keep fresh setup withheld. Preserve success after the exact resumed draft becomes active, and restore valid completed URLs to that exact saved Permissions page; reject initially active, disabled, or archived resume targets. Align the Context7 route assertion with its new qualified availability. - Retain hidden ClickUp, Klaviyo, and monday.com research with exact login/approval gates. monday.com requires provider callback allowlisting. ## Verification - Connector service suite: 420 passed, including default/empty/agent/company reach and managed OAuth lifecycle coverage. - Shared catalog: 36 passed. Override tests: 3 passed. Brand checks: 101 identities passed. - Focused UI setup/recovery, Browse, and OAuth-helper suites: 306 passed. Actual hidden-draft resume/reload passed; a public Context7 draft seeded in the isolated app completed through the active-state refetch and persisted with zero agents after reload. The completed URL refresh and View connection open the exact saved Permissions page. Fresh hidden setup, normal initially active resume, disabled/archived status, and provider mismatch are rejected; a draft at the completion stage stays in setup. - Recursive typecheck and build passed again after rebase. The full local Vitest run began before rebase and was cancelled as superseded while still in the general-server phase; it is not a completed pass. Exact-head CI supplies broad coverage. The focused connector service suite passed against the final code. UI typecheck, build, and token gates passed again after the draft-recovery fix. - Targeted mock MCP Playwright journey: 1 passed (34.9s). It now verifies View connection reaches the exact saved Permissions route, both discovered actions, and reload persistence. This replaces the stale Browse expectation found by CI. - All CI gates passed on final commit `32bb4c6422411eee632486950e45e2735102d1b3`. The inherited-pause test timed out in the cleanup run and passed on one retry without code changes; the entire file also passed locally, 36/36. Its exact timing cause remains unresolved. A signoff timeout on the preceding production-code commit also passed on its single retry. Greptile reviewed the final commit at 5/5 with no open findings. - Actual UI reads: Bitly current-user read; Resend List Domains including a fresh setup after the final callback refinement; Context7 React docs; Hugging Face public docs search. No provider writes or real agent runtimes ran. - [Text-only qualification report](https://github.com/paperclipai/paperclip/blob/32bb4c6422411eee632486950e45e2735102d1b3/doc/connections/easy-mcp-batch-2-2026-10-09.md) include evidence limits and exact blockers. Account/profile/domain results are omitted. ## Risks - Bitly `mcp.all` and Resend `full_access` remain broad provider grants. Qualification kept writes Off and selected no agents. Read proof does not establish write behavior or refresh rotation. - The explicit-choice marker applies to new install saves; older unmarked flows keep their existing defaults. Its JSONB merge avoids overwriting concurrent config fields. - Public provider catalogs and rate limits can change. Unqualified providers remain withheld from new Browse entries; retained connections remain available for recovery. ## Model Used OpenAI Codex, based on GPT-6, plus OpenAI `gpt-6-luna` subagents for provider research, browser testing, and review. The exact primary serving variant and context-window size are not exposed by this runtime. Used reasoning, code execution, and browser tools. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
301 lines
14 KiB
TypeScript
301 lines
14 KiB
TypeScript
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
|
import { createServer, type Server } from "node:http";
|
|
import { listenOnFetchAllowedPort } from "./fetch-allowed-port";
|
|
|
|
// prosumer MCP flow — QA harness for the prosumer Connect-an-app flow on top of the
|
|
// tool-access foundation. Covers the M-series happy path (gallery + key paste
|
|
// → choose access → install → success), the expired-key reconnect path,
|
|
// the Needs-attention surface, and a regression check that /apps/advanced
|
|
// still mounts.
|
|
//
|
|
// The spec boots the shared local_trusted Playwright webServer (see
|
|
// playwright.config), spawns a small in-process mock HTTP MCP server that
|
|
// responds to tools/list with read-only and write-side-effect tools, then
|
|
// drives the wizard via the Apps UI for evidence + screenshots.
|
|
|
|
const SCREENSHOT_DIR = "test-results";
|
|
|
|
type Seed = { companyId: string; prefix: string };
|
|
|
|
async function newCompany(request: APIRequestContext, label: string): Promise<Seed> {
|
|
const res = await request.post("/api/companies", { data: { name: `prosumer MCP flow ${label} ${Date.now()}` } });
|
|
expect(res.ok(), `create company failed ${res.status()}: ${await res.text()}`).toBe(true);
|
|
const company = await res.json();
|
|
return {
|
|
companyId: company.id,
|
|
prefix: company.issuePrefix ?? company.prefix ?? company.urlKey ?? "E2E",
|
|
};
|
|
}
|
|
|
|
// ---- Mock MCP HTTP fixture --------------------------------------------------
|
|
// Minimal MCP JSON-RPC server. /catalog refresh hits this with method
|
|
// `tools/list`; the gateway calls it with `tools/call`. We expose one
|
|
// read-only and one write tool so setup can apply risk-based ask-first defaults.
|
|
|
|
type MockMcpServer = { url: string; close: () => Promise<void>; captures: Array<{ method: string; params: unknown }> };
|
|
|
|
async function startMockMcp(options: { expectedHeader?: string } = {}): Promise<MockMcpServer> {
|
|
const captures: Array<{ method: string; params: unknown }> = [];
|
|
const server: Server = createServer(async (req, res) => {
|
|
if (req.method !== "POST") {
|
|
res.writeHead(405).end();
|
|
return;
|
|
}
|
|
if (options.expectedHeader && req.headers.authorization !== options.expectedHeader) {
|
|
res.writeHead(401, { "Content-Type": "application/json", "WWW-Authenticate": "Bearer" });
|
|
res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32000, message: "Unauthorized" } }));
|
|
return;
|
|
}
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
let payload: { id?: string | number; method?: string; params?: unknown } = {};
|
|
try {
|
|
payload = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
} catch {
|
|
// fall through to method routing — will land in default
|
|
}
|
|
captures.push({ method: String(payload.method ?? "<unknown>"), params: payload.params });
|
|
|
|
if (payload.method === "tools/list") {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: payload.id ?? null,
|
|
result: {
|
|
tools: [
|
|
{
|
|
name: "list_widgets",
|
|
title: "List widgets",
|
|
description: "Read-only listing of widgets.",
|
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
},
|
|
{
|
|
// Namespaced name (namespaced tool names): real MCP servers prefix tool names
|
|
// ("github:create_issue"). The classifier must still see the "create"
|
|
// verb and land this under "Can make changes" with the toggle OFF —
|
|
// the old leading-anchor regex fell through to read-only.
|
|
name: "qa10864:create_widget",
|
|
title: "Create widget",
|
|
description: "Creates a widget — has side effects.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { name: { type: "string" } },
|
|
required: ["name"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
if (payload.method === "tools/call") {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ jsonrpc: "2.0", id: payload.id ?? null, result: { content: [{ type: "text", text: "ok" }] } }));
|
|
return;
|
|
}
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ jsonrpc: "2.0", id: payload.id ?? null, result: {} }));
|
|
});
|
|
const port = await listenOnFetchAllowedPort(server);
|
|
return {
|
|
url: `http://127.0.0.1:${port}/`,
|
|
captures,
|
|
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
|
};
|
|
}
|
|
|
|
// ---- Helpers ----------------------------------------------------------------
|
|
|
|
async function gotoApps(page: Page, prefix: string) {
|
|
await page.goto(`/${prefix}/apps/connections`);
|
|
}
|
|
|
|
async function gotoConnect(page: Page, prefix: string) {
|
|
await page.goto(`/${prefix}/apps`);
|
|
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 30_000 });
|
|
const customConnector = page
|
|
.getByRole("list", { name: "Connector list" })
|
|
.getByRole("listitem")
|
|
.filter({ hasText: "Connect your own tool" });
|
|
await customConnector.getByRole("button", { name: "Connect", exact: true }).click();
|
|
await customConnector.getByRole("button", { name: "Connect your own MCP server" }).click();
|
|
}
|
|
|
|
async function gotoAdvanced(page: Page, prefix: string) {
|
|
await page.goto(`/${prefix}/apps/advanced`);
|
|
}
|
|
|
|
async function gotoNeedsAttention(page: Page, prefix: string) {
|
|
await page.goto(`/${prefix}/apps/connections`);
|
|
}
|
|
|
|
// ---- Tests ------------------------------------------------------------------
|
|
|
|
test.describe.serial("prosumer MCP flow prosumer MCP flow", () => {
|
|
test.setTimeout(180_000); // vite-dev cold bundling is slow on first hit
|
|
|
|
let mock: MockMcpServer;
|
|
|
|
test.beforeAll(async () => {
|
|
mock = await startMockMcp();
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
await mock?.close();
|
|
});
|
|
|
|
test("Connect wizard happy path: link mode → success", async ({ page, request }) => {
|
|
const seed = await newCompany(request, "connect");
|
|
|
|
await gotoConnect(page, seed.prefix);
|
|
|
|
// Browse launches the BYO link-mode connect wizard.
|
|
await expect(page.getByText("Connect your own MCP server", { exact: true })).toBeVisible({ timeout: 30_000 });
|
|
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-01-gallery.png`, fullPage: true });
|
|
|
|
// Use the "Connect with a link" path against the mock MCP server.
|
|
const linkInput = page.getByPlaceholder("https://example.com/actions");
|
|
await linkInput.fill(mock.url);
|
|
await page.getByRole("button", { name: "Continue" }).click();
|
|
|
|
// There is no separate Access step: the link opens the key screen, which
|
|
// states the default access in one line. The mock needs no key, and a
|
|
// credential challenge from the server is what would ask for one.
|
|
await expect(page.getByRole("heading", { name: "Connect your own MCP server" })).toBeVisible({ timeout: 15_000 });
|
|
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-02-key-step.png`, fullPage: true });
|
|
|
|
// Submit (button label is "Check link").
|
|
await page.getByRole("button", { name: /Check link/i }).click();
|
|
|
|
// A successful generic probe commits discovered actions and the stated
|
|
// access defaults transactionally, so the key check lands on success.
|
|
await expect(page.getByRole("heading", { name: /is ready\.$/i })).toBeVisible({ timeout: 30_000 });
|
|
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-05-success.png`, fullPage: true });
|
|
|
|
// Verify the mock saw a tools/list call from the catalog refresh.
|
|
expect(mock.captures.some((c) => c.method === "tools/list")).toBe(true);
|
|
|
|
const connectionsResponse = await request.get(`/api/companies/${seed.companyId}/tools/connections`);
|
|
expect(connectionsResponse.ok(), `list connections failed ${connectionsResponse.status()}`).toBe(true);
|
|
const connectionsPayload = await connectionsResponse.json();
|
|
const savedConnection = connectionsPayload.connections.find(
|
|
(connection: { config?: { url?: string } }) => connection.config?.url === mock.url,
|
|
);
|
|
expect(savedConnection?.id).toBeTruthy();
|
|
const mockPort = new URL(savedConnection.config.url).port;
|
|
expect(mockPort).toBeTruthy();
|
|
|
|
// View the exact saved connection and confirm its discovered actions survive a reload.
|
|
await page.getByRole("button", { name: "View connection" }).click();
|
|
await expect(page).toHaveURL(
|
|
new RegExp(`/${seed.prefix}/apps/${savedConnection.id}/permissions$`),
|
|
{ timeout: 15_000 },
|
|
);
|
|
const savedHeading = page.getByRole("heading", { level: 1 });
|
|
await expect(savedHeading).toBeVisible();
|
|
const savedHeadingText = (await savedHeading.textContent())?.trim();
|
|
expect(savedHeadingText).toBeTruthy();
|
|
expect(savedHeadingText).toContain(mockPort);
|
|
await expect(page.getByRole("heading", { name: "Actions", exact: true })).toBeVisible();
|
|
await expect(page.getByText("List widgets", { exact: true })).toBeVisible();
|
|
await expect(page.getByText("Create widget", { exact: true })).toBeVisible();
|
|
await page.reload();
|
|
await expect(page).toHaveURL(
|
|
new RegExp(`/${seed.prefix}/apps/${savedConnection.id}/permissions$`),
|
|
{ timeout: 15_000 },
|
|
);
|
|
await expect(page.getByRole("heading", { name: savedHeadingText, exact: true })).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "Actions", exact: true })).toBeVisible();
|
|
await expect(page.getByText("List widgets", { exact: true })).toBeVisible();
|
|
await expect(page.getByText("Create widget", { exact: true })).toBeVisible();
|
|
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-06-permissions.png`, fullPage: true });
|
|
});
|
|
|
|
test("Expired key → health sweep → Needs attention → reconnect → green", async ({ page, request }) => {
|
|
const seed = await newCompany(request, "attention");
|
|
|
|
// Spawn a dedicated mock we can break partway through.
|
|
const ephemeral = await startMockMcp();
|
|
try {
|
|
const connect = await request.post(`/api/companies/${seed.companyId}/tools/apps/connect`, {
|
|
data: { link: ephemeral.url, credentialValues: { "credentials.authorization": "qa-token" } },
|
|
});
|
|
expect(connect.ok(), `connect failed ${connect.status()}: ${await connect.text()}`).toBe(true);
|
|
const connectResult = await connect.json();
|
|
const connectionId = connectResult.connectionId as string;
|
|
const enabledCatalogEntryIds = (connectResult.actions?.readOnly ?? []).map(
|
|
(action: { catalogEntryId: string }) => action.catalogEntryId,
|
|
);
|
|
const finish = await request.post(`/api/companies/${seed.companyId}/tools/apps/${connectionId}/finish`, {
|
|
data: { enabledCatalogEntryIds, askFirstCatalogEntryIds: [], access: "all_agents" },
|
|
});
|
|
expect(finish.ok(), `finish failed ${finish.status()}: ${await finish.text()}`).toBe(true);
|
|
|
|
// Break the mock so the next health-check fails (simulates expired key /
|
|
// dead remote — the same observable shape the server uses to mark a
|
|
// connection unhealthy).
|
|
await ephemeral.close();
|
|
|
|
const health = await request.post(`/api/tool-connections/${connectionId}/health-check`);
|
|
expect(health.status(), `health-check should fail with the mock down`).toBe(502);
|
|
|
|
const before = await request.get(`/api/tool-connections/${connectionId}`);
|
|
const beforeBody = await before.json();
|
|
expect(beforeBody.healthStatus).not.toBe("ok");
|
|
|
|
// Needs-attention page should surface this connection.
|
|
await gotoNeedsAttention(page, seed.prefix);
|
|
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 30_000 });
|
|
await expect(page.getByText("Needs attention", { exact: true }).first()).toBeVisible({ timeout: 30_000 });
|
|
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-07-needs-attention.png`, fullPage: true });
|
|
|
|
// App detail should expose the reconnect call-to-action.
|
|
await page.goto(`/${seed.prefix}/apps/${connectionId}`);
|
|
await expect(page.getByRole("button", { name: /Reconnect|Replace key/i }).first()).toBeVisible({ timeout: 20_000 });
|
|
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-08-app-detail-reconnect.png`, fullPage: true });
|
|
|
|
// Bring the mock back so reconnect succeeds.
|
|
const recovered = await startMockMcp();
|
|
try {
|
|
// The reconnect endpoint replaces credentials but does not change the URL,
|
|
// so we update the connection URL through PATCH first to point at the
|
|
// recovered mock — this mirrors what users do when the remote moves.
|
|
const repatch = await request.patch(`/api/tool-connections/${connectionId}`, {
|
|
data: { config: { url: recovered.url } },
|
|
});
|
|
expect(repatch.ok(), `patch url failed ${repatch.status()}: ${await repatch.text()}`).toBe(true);
|
|
|
|
await page.reload();
|
|
await page.getByLabel("App key", { exact: true }).fill("fresh-key");
|
|
await page.getByRole("button", { name: "Check & reconnect", exact: true }).click();
|
|
await expect(page.getByText("Reconnected", { exact: true })).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "This app needs reconnecting" })).toHaveCount(0);
|
|
await expect(page.getByText("Still not working", { exact: true })).toHaveCount(0);
|
|
|
|
const after = await request.get(`/api/tool-connections/${connectionId}`);
|
|
const afterBody = await after.json();
|
|
expect(afterBody.healthStatus).toBe("ok");
|
|
} finally {
|
|
await recovered.close();
|
|
}
|
|
} finally {
|
|
await ephemeral.close().catch(() => {});
|
|
}
|
|
});
|
|
|
|
test("/apps/advanced mounts the paste-config path", async ({ page, request }) => {
|
|
const seed = await newCompany(request, "advanced");
|
|
|
|
await gotoAdvanced(page, seed.prefix);
|
|
await expect(page.getByRole("heading", { name: "Advanced setup" })).toBeVisible({ timeout: 20_000 });
|
|
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-09-advanced-default.png`, fullPage: true });
|
|
|
|
await expect(page.getByRole("link", { name: "Paste a config" })).toBeVisible();
|
|
await expect(page.getByRole("link", { name: /Run your own|Self host|Stdio|Local/i })).toHaveCount(0);
|
|
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-10-advanced-paste-tab.png`, fullPage: true });
|
|
});
|
|
});
|