From 6080daf26281a6443b4772b740d122f7b572e08b Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:46:15 -0500 Subject: [PATCH 1/3] fix(runner): start native OpenCode without postinstall (#15702) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip Runner starts a qualified OpenCode runtime for native tasks. > - The default launch path uses a file populated by OpenCode's postinstall script. > - Package managers can skip that script and leave a text placeholder. Native task startup then fails with `spawn ENOEXEC` before the model request. > - This pull request resolves the declared platform package directly and includes the pinned runtime in the published server dependency graph. > - Users can run native OpenCode tasks after a normal install without global commands or manual dependency repair. ## Linked Issues or Issue Description **What happened?** Native OpenCode tasks fail at session startup when dependency postinstall is skipped. The Runner selects `opencode-ai/bin/opencode.exe`, which contains a text placeholder. The published server also lacks the OpenCode runtime dependency used by its vendored Runner. **Expected behavior** Paperclip starts the qualified platform binary installed by the package manager. macOS uses a Mac binary. No manual postinstall command is needed. **Steps to reproduce** 1. Install dependencies with lifecycle scripts disabled. 2. Configure a native OpenCode agent with an OpenRouter model and valid credentials. 3. Assign a task. The previous default fails before any model request. Related work: #15682 fixes HTTP transport registration. #15422 includes compatible OpenCode resolution in a broader agent setup change. This PR isolates the runtime repair for current users. ## What Changed - Resolve pinned OpenCode platform packages through the module dependency graph in source, standalone, and vendored server layouts. - Check package identity, version, platform, executable containment, and execute permission. Preserve verified executable launch profiles. - Use the resolver for new and resumed native sessions. Reject missing remote pack executables before controller dependency lookup. - Declare `opencode-ai@1.18.34` as a published server dependency. Leave platform selection to the consumer's package manager. - Check remote paths for the selected provider only. An unused OpenCode path must not reject a Pi run on macOS. - Add skipped-postinstall and invalid artifact regressions. Document automatic runtime selection. ## Verification - Regression tests fail with the previous default path and pass with platform resolution. - Source, standalone, and vendored layout fixtures cover Apple Silicon, Intel Mac, and Linux x64 baseline packages. - A fresh npm install with `--ignore-scripts` resolves the macOS executable and reports `1.18.34`. The placeholder remains unchanged. - Live browser test on Apple Silicon: a native OpenCode/OpenRouter/DeepSeek task ran shell arithmetic, created and uploaded a verified 23-byte file, and completed. A follow-up ran shell arithmetic and completed using the same session. Both runs committed successfully while the wrapper remained broken. - `pnpm -r typecheck` and `pnpm build` passed. All 269 affected Runner tests and 8 server packaging tests passed. `pnpm test:run` encountered 15 initial database-heavy failures; all 15 passed on an isolated group rerun. The redundant local run was then stopped after the matching server and workspace CI suites passed. All final-head checks passed (56 passed, 2 skipped); its unrelated chat-retry browser assertion passed on one rerun. An additional local repeat could not start its temporary server within the startup timeout. Greptile reviewed the final head at 5/5 with no open findings. ## Risks - Native runtime resolution requires the pinned optional platform package. Installations that omit optional dependencies fail with an explicit runtime error. - Supported native targets remain Apple Silicon, Intel Mac, and Linux x64. This change does not qualify another platform. - Published server installations now install the OpenCode dependency. Remote provider packs retain their verified executable paths and digests. - No schema changes or agent configuration migration. - Repository policy assigns `pnpm-lock.yaml` to CI. PR CI refreshes dependency resolution before frozen installs, and the master workflow updates the tracked lockfile. This PR leaves the tracked lockfile unchanged. ## Model Used - OpenAI GPT-6 through Codex, with reasoning, tool use, and code execution. The session does not expose the exact GPT-6 variant or context window. - Live verification used OpenRouter `deepseek/deepseek-v4-flash-0731`; it did not author this patch. ## 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 --- doc/DEVELOPING.md | 9 +++ .../drivers/opencode/opencode-command.test.ts | 60 ++++++++++++++++ .../opencode/opencode-server-driver.ts | 69 ++++++++++++++++++- .../src/live/runnerd-codex-transport.test.ts | 9 +++ .../src/live/runnerd-codex-transport.ts | 22 ++++-- server/package.json | 1 + .../server-package-build-script.test.ts | 10 +++ 7 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 packages/paperclip-runner/src/drivers/opencode/opencode-command.test.ts diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 35fcb16bc1a..e9d2c94b9fa 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -548,6 +548,15 @@ Activity-only reorderings wait for one second without further activity changes; new and removed tasks appear immediately. Titles, status, and live indicators stay current during that delay. +### OpenCode native runtime + +Paperclip Runner resolves the pinned OpenCode platform package installed with +Paperclip. On Apple Silicon and Intel Macs it runs the matching macOS binary; +on Linux x64 it runs the qualified baseline binary. No global OpenCode command +or dependency postinstall script is required. This also works when installation +scripts are disabled. The published server declares the same pinned dependency +as the source Runner package. + ## One-Command Local Run For a first-time local install, you can bootstrap and run in one command: diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-command.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-command.test.ts new file mode 100644 index 00000000000..d5db904c632 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-command.test.ts @@ -0,0 +1,60 @@ +import { chmod, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, expect, it } from "vitest"; +import { resolvePinnedOpenCodeCommand } from "./opencode-server-driver.js"; + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); + +async function installed(layout: "source" | "standalone" | "vendored", platform: string, architecture: string, name: string) { + const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-opencode-install-"))); + roots.push(root); + const issuer = join(root, layout === "vendored" ? "dist/vendor/paperclip-runner/drivers/opencode/driver.js" + : layout === "source" ? "src/drivers/opencode/driver.ts" : "dist/drivers/opencode/driver.js"); + const wrapper = join(root, "node_modules/opencode-ai"); + const artifact = join(root, "node_modules", name); + const executable = join(artifact, "bin/opencode"); + await mkdir(dirname(issuer), { recursive: true }); + await mkdir(join(wrapper, "bin"), { recursive: true }); + await mkdir(dirname(executable), { recursive: true }); + await writeFile(join(wrapper, "package.json"), JSON.stringify({ name: "opencode-ai", version: "1.18.34", optionalDependencies: { [name]: "1.18.34" } })); + const placeholder = 'echo "postinstall was not run" >&2\nexit 1\n'; + await writeFile(join(wrapper, "bin/opencode.exe"), placeholder, { mode: 0o755 }); + await writeFile(join(artifact, "package.json"), JSON.stringify({ name, version: "1.18.34", os: [platform], cpu: [architecture] })); + await writeFile(executable, "#!/bin/sh\necho 1.18.34\n", { mode: 0o755 }); + return { issuer: pathToFileURL(issuer), wrapper, artifact, executable, root, placeholder }; +} + +it.each(["source", "standalone", "vendored"] as const)("resolves the platform binary in a %s install with postinstall skipped", async layout => { + for (const [platform, architecture, name] of [ + ["darwin", "arm64", "opencode-darwin-arm64"], + ["darwin", "x64", "opencode-darwin-x64-baseline"], + ["linux", "x64", "opencode-linux-x64-baseline"], + ] as const) { + const install = await installed(layout, platform, architecture, name); + expect(resolvePinnedOpenCodeCommand(install.issuer, { platform, architecture })).toBe(install.executable); + expect(await readFile(join(install.wrapper, "bin/opencode.exe"), "utf8")).toBe(install.placeholder); + } +}); + +it("rejects missing or mismatched qualified dependencies", async () => { + const install = await installed("vendored", "darwin", "arm64", "opencode-darwin-arm64"); + await writeFile(join(install.artifact, "package.json"), JSON.stringify({ name: "opencode-darwin-arm64", version: "1.18.17", os: ["darwin"], cpu: ["arm64"] })); + expect(() => resolvePinnedOpenCodeCommand(install.issuer, { platform: "darwin", architecture: "arm64" })).toThrow("identity mismatch"); + await rm(install.artifact, { recursive: true }); + expect(() => resolvePinnedOpenCodeCommand(install.issuer, { platform: "darwin", architecture: "arm64" })).toThrow("qualified platform dependency is missing"); + expect(() => resolvePinnedOpenCodeCommand(install.issuer, { platform: "win32", architecture: "x64" })).toThrow("not qualified"); +}); + +it("rejects executables outside the platform package or without execute permission", async () => { + const install = await installed("source", "darwin", "arm64", "opencode-darwin-arm64"); + await chmod(install.executable, 0o644); + expect(() => resolvePinnedOpenCodeCommand(install.issuer, { platform: "darwin", architecture: "arm64" })).toThrow("Pinned OpenCode runtime unavailable"); + await rm(install.executable); + const outside = join(install.root, "outside"); + await writeFile(outside, "#!/bin/sh\n", { mode: 0o755 }); + await symlink(outside, install.executable); + expect(() => resolvePinnedOpenCodeCommand(install.issuer, { platform: "darwin", architecture: "arm64" })).toThrow("escapes its qualified platform package"); +}); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index fa95ea19b4d..81abbfd3f06 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -1,5 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { randomBytes } from "node:crypto"; +import { accessSync, constants, readFileSync, realpathSync, statSync } from "node:fs"; +import { createRequire } from "node:module"; import { chmod, mkdir, @@ -15,7 +17,7 @@ import { Agent as HttpAgent, createServer as createHttpServer, request as reques import { Agent as HttpsAgent, request as requestHttps } from "node:https"; import { getCACertificates } from "node:tls"; import { pipeline } from "node:stream/promises"; -import { dirname, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { CODEX_SKILLLESS_BASE_INSTRUCTIONS, @@ -83,6 +85,69 @@ export const QUALIFIED_OPENCODE_VERSION = "1.18.34" as const; export const QUALIFIED_OPENCODE_MODEL = "openrouter/deepseek/deepseek-v4-flash-0731" as const; +/** Resolve a declared, pinned native dependency without PATH or install fallback. */ +export function resolvePinnedOpenCodeCommand( + issuer: string | URL = import.meta.url, + target: { platform?: string; architecture?: string } = {}, +): string { + try { + const platform = target.platform ?? process.platform; + const architecture = target.architecture ?? process.arch; + const packageNames: Record = { + "linux-x64": ["opencode-linux-x64-baseline"], + "darwin-arm64": ["opencode-darwin-arm64"], + "darwin-x64": ["opencode-darwin-x64-baseline"], + }; + const candidates = packageNames[`${platform}-${architecture}`]; + if (!candidates) { + throw new Error("OpenCode native target is not qualified"); + } + const manifestPath = realpathSync(createRequire(issuer).resolve("opencode-ai/package.json")); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + if (manifest.name !== "opencode-ai" || manifest.version !== QUALIFIED_OPENCODE_VERSION) { + throw new Error(`OpenCode package version mismatch: expected opencode-ai@${QUALIFIED_OPENCODE_VERSION}`); + } + const dependencyRequire = createRequire(manifestPath); + for (const name of candidates) { + if (manifest.optionalDependencies?.[name] !== QUALIFIED_OPENCODE_VERSION) { + throw new Error("OpenCode platform dependency does not match its qualified version"); + } + let platformManifest: string; + try { + platformManifest = realpathSync(dependencyRequire.resolve(`${name}/package.json`)); + } catch (error) { + if (["MODULE_NOT_FOUND", "ENOENT"].includes((error as NodeJS.ErrnoException).code ?? "")) { + continue; + } + throw error; + } + const metadata = JSON.parse(readFileSync(platformManifest, "utf8")); + if (metadata.name !== name || metadata.version !== QUALIFIED_OPENCODE_VERSION + || !Array.isArray(metadata.os) || !metadata.os.includes(platform) + || !Array.isArray(metadata.cpu) || !metadata.cpu.includes(architecture)) { + throw new Error("OpenCode installed platform artifact identity mismatch"); + } + const root = dirname(platformManifest); + const executable = realpathSync(resolve(root, "bin/opencode")); + const inside = relative(root, executable); + if (!inside || inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) { + throw new Error("OpenCode executable escapes its qualified platform package"); + } + if (!statSync(executable).isFile()) { + throw new Error("OpenCode executable is not a regular file"); + } + accessSync(executable, constants.X_OK); + return executable; + } + throw new Error("OpenCode qualified platform dependency is missing"); + } catch (error) { + throw new Error( + `Pinned OpenCode runtime unavailable: ${(error as Error).message}. Restore Paperclip's runtime dependencies.`, + { cause: error }, + ); + } +} + type DynamicToolHandler = (call: { tool: string; callId: string; @@ -2255,7 +2320,7 @@ async function startRuntime(input: { } input.options.commandLifecycle?.beforeSpawn(); child = spawn( - input.options.command ?? "opencode", + input.options.command ?? resolvePinnedOpenCodeCommand(), ["serve", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: input.cwd, diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index d8fed91521b..fd3c6da2148 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -1587,6 +1587,15 @@ it.each(["acpx-runtime-sidecar.cjs", "opencode-app-server-proxy.cjs"] as const)( }, ); +it("requires a remote OpenCode executable before resolving controller dependencies", () => { + expect(() => runnerdLaunchProfileInternals.resolveRunnerOpenCodeExecutable({ + runnerFilesystemRoot: "/provider-pack", + })).toThrow("OpenCode executable is missing from the provider pack"); + expect(runnerdLaunchProfileInternals.resolveRunnerOpenCodeExecutable({ + runnerFilesystemRoot: "/provider-pack", opencodeCommand: "/provider-pack/opencode", + })).toBe("/provider-pack/opencode"); +}); + it("derives the ACPX package authority only from the verified dist/cli layout", () => { const runnerPackageRoot = fileURLToPath(new URL("../..", import.meta.url)); expect( diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 4290d68d90b..08a8886f3b9 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1,3 +1,4 @@ +import { resolvePinnedOpenCodeCommand } from "../drivers/opencode/opencode-server-driver.js"; import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js"; import { admittedPiThinkingLevel, resolvePiThinkingLevel } from "../drivers/acpx/pi-thinking.js"; import { configuredEnvironment } from "../configured-environment.js"; @@ -3182,6 +3183,14 @@ function opencodeRunnerLaunchProfile( }; } +function resolveRunnerOpenCodeExecutable(options: RunnerdCodexTransportOptions): string { + if (options.opencodeCommand !== undefined) return options.opencodeCommand; + if (options.runnerFilesystemRoot) { + throw new Error("runner_remote_provider_artifact_incompatible: OpenCode executable is missing from the provider pack"); + } + return resolvePinnedOpenCodeCommand(); +} + function authorizedToolSet( tools: readonly Readonly>[], ): Record { @@ -4731,8 +4740,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { : undefined; const opencodeExecutable = provider === "opencode" - ? (this.options.opencodeCommand ?? - resolve(packageRoot, "node_modules/opencode-ai/bin/opencode.exe")) + ? resolveRunnerOpenCodeExecutable(this.options) : null; const runnerAcpxLaunchProfile = provider === "acpx" @@ -4757,9 +4765,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { ) { const providerPaths = [ ["provider Node", providerNodeCommand], - ["OpenCode proxy", opencodeProxyPath], - ["ACPX sidecar", acpxSidecarPath], - ["OpenCode executable", opencodeExecutable ?? "opencode"], + ...(provider === "opencode" + ? [["OpenCode proxy", opencodeProxyPath], ["OpenCode executable", opencodeExecutable!]] as const + : [["ACPX sidecar", acpxSidecarPath]] as const), ] as const; for (const [label, candidate] of providerPaths) { if ( @@ -5358,8 +5366,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.options.providerNodeCommand ?? process.execPath; const opencodeExecutable = provider === "opencode" - ? (this.options.opencodeCommand ?? - resolve(packageRoot, "node_modules/opencode-ai/bin/opencode.exe")) + ? resolveRunnerOpenCodeExecutable(this.options) : null; const runnerAcpxLaunchProfile = provider === "acpx" @@ -7040,6 +7047,7 @@ export const runnerdLaunchProfileInternals = Object.freeze({ acpxProviderPackageAuthority, acpxRunnerLaunchProfile, resolveBuildOwnedCliArtifact, + resolveRunnerOpenCodeExecutable, maxOutboxBytes: RUNNERD_MAX_OUTBOX_BYTES, p0ReserveBytes: RUNNERD_P0_RESERVE_BYTES, }); diff --git a/server/package.json b/server/package.json index b0fa25129fe..3b1a969073f 100644 --- a/server/package.json +++ b/server/package.json @@ -97,6 +97,7 @@ "nice-grpc": "2.1.17", "nice-grpc-common": "2.0.4", "open": "^11.0.4", + "opencode-ai": "1.18.34", "pino": "^10.0.0", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", diff --git a/server/src/__tests__/server-package-build-script.test.ts b/server/src/__tests__/server-package-build-script.test.ts index 83b9ea8fa4c..64cbbf17ab2 100644 --- a/server/src/__tests__/server-package-build-script.test.ts +++ b/server/src/__tests__/server-package-build-script.test.ts @@ -103,6 +103,16 @@ describe("server package build script", () => { ); }); + it("ships the pinned OpenCode dependency resolved by the vendored Runner", () => { + const server = JSON.parse(readFileSync(packageJsonPath, "utf8")); + const runner = JSON.parse(readFileSync(new URL( + "../../../packages/paperclip-runner/package.json", import.meta.url, + ), "utf8")); + // createRequire resolution is dynamic and is not covered by the import scan. + expect(server.dependencies["opencode-ai"]).toBe("1.18.34"); + expect(server.dependencies["opencode-ai"]).toBe(runner.dependencies["opencode-ai"]); + }); + it("loads runner source when the source server starts before workspace builds", () => { const shim = readFileSync(runnerShimPath, "utf8"); From 011e5669bad61c435a4745fb10812bef70376841 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:48:20 -0500 Subject: [PATCH 2/3] chore(lockfile): refresh pnpm-lock.yaml (#15678) Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml. Co-authored-by: lockfile-bot --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba54e00a77c..861e3c98a24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1043,6 +1043,9 @@ importers: open: specifier: ^11.0.4 version: 11.0.4 + opencode-ai: + specifier: 1.18.34 + version: 1.18.34 pino: specifier: ^10.0.0 version: 10.3.1 From 0726a00e80f392598b17b847a2bb7fa663f25dcd Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:52:33 -0500 Subject: [PATCH 3/3] feat(apps): qualify Bitly, Context7, Hugging Face and Resend MCP (#15686) ## 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 --- .../easy-mcp-batch-2-2026-10-09.md | 67 +++++ ...p-batch-2-brand-provenance-2026-10-09.json | 38 +++ .../tool-method-permission-reviews.json | 178 ++++++++--- .../shared/src/app-definitions.generated.ts | 41 +-- packages/shared/src/app-definitions.test.ts | 23 +- packages/shared/src/app-definitions.ts | 6 +- .../shared/src/app-definitions/bitly.json | 29 +- .../shared/src/app-definitions/clickup.json | 53 ++++ .../shared/src/app-definitions/context7.json | 18 +- .../src/app-definitions/hugging-face.json | 22 ++ .../shared/src/app-definitions/klaviyo.json | 59 ++++ .../shared/src/app-definitions/monday.json | 46 +++ .../shared/src/app-definitions/resend.json | 6 +- .../shared/src/self-serve-mcp-research.json | 12 +- scripts/app-definition-overrides/bitly.json | 84 ++++++ scripts/app-definition-overrides/clickup.json | 53 ++++ .../app-definition-overrides/context7.json | 41 +++ .../hugging-face.json | 69 +++++ scripts/app-definition-overrides/klaviyo.json | 59 ++++ scripts/app-definition-overrides/monday.json | 46 +++ scripts/app-definition-overrides/resend.json | 45 +++ .../src/__tests__/tool-access-service.test.ts | 245 ++++++++------- server/src/services/tool-access.ts | 27 +- tests/e2e/apps-prosumer-mcp-flow.spec.ts | 37 ++- ui/public/brands/apps/clickup.png | Bin 0 -> 8660 bytes ui/public/brands/apps/klaviyo.png | Bin 0 -> 3929 bytes ui/public/brands/apps/manifest.json | 23 +- ui/public/brands/apps/monday-dark.png | Bin 0 -> 15843 bytes ui/public/brands/apps/monday.png | Bin 0 -> 14581 bytes .../ConnectionSetupFlow.oauth.test.ts | 40 +++ .../connections/ConnectionSetupFlow.tsx | 111 ++++++- ui/src/pages/apps/AppsConnect.test.tsx | 281 ++++++++++++++++++ ui/src/pages/apps/Browse.test.tsx | 32 ++ ui/src/pages/apps/Browse.tsx | 11 + ui/src/pages/apps/app-connect-policy.test.ts | 9 +- ui/src/pages/apps/app-connect-policy.ts | 8 +- 36 files changed, 1595 insertions(+), 224 deletions(-) create mode 100644 doc/connections/easy-mcp-batch-2-2026-10-09.md create mode 100644 doc/connections/easy-mcp-batch-2-brand-provenance-2026-10-09.json create mode 100644 packages/shared/src/app-definitions/clickup.json create mode 100644 packages/shared/src/app-definitions/klaviyo.json create mode 100644 packages/shared/src/app-definitions/monday.json create mode 100644 scripts/app-definition-overrides/bitly.json create mode 100644 scripts/app-definition-overrides/clickup.json create mode 100644 scripts/app-definition-overrides/context7.json create mode 100644 scripts/app-definition-overrides/hugging-face.json create mode 100644 scripts/app-definition-overrides/klaviyo.json create mode 100644 scripts/app-definition-overrides/monday.json create mode 100644 scripts/app-definition-overrides/resend.json create mode 100644 ui/public/brands/apps/clickup.png create mode 100644 ui/public/brands/apps/klaviyo.png create mode 100644 ui/public/brands/apps/monday-dark.png create mode 100644 ui/public/brands/apps/monday.png diff --git a/doc/connections/easy-mcp-batch-2-2026-10-09.md b/doc/connections/easy-mcp-batch-2-2026-10-09.md new file mode 100644 index 00000000000..5055edb7faf --- /dev/null +++ b/doc/connections/easy-mcp-batch-2-2026-10-09.md @@ -0,0 +1,67 @@ +# Hosted MCP qualification — 2026-10-09 + +This batch continues the verified connector work in [PR #15621](https://github.com/paperclipai/paperclip/pull/15621). Provider research was checked against the existing paperclip-content integration research and the canonical [connector playbook](CONNECTOR-PLAYBOOK.md). Luna workers researched and exercised separate providers in an isolated local Paperclip company. + +## Completed browser journeys + +| Provider / method | Setup | Discovered actions | Bounded UI Test | Persistence | +| --- | --- | --- | --- | --- | +| Bitly / OAuth | New account and `mcp.all` explicitly approved; HTTPS DCR/OAuth | 56: 30 read, 26 write | `bitly_get_user`, 2.0s; returned profile values omitted | Reload and catalog refresh retained 56 actions, Just me, zero selected agents, all writes Off | +| Context7 / public MCP | No account, key, or OAuth | 2 read, 0 write | Resolve React → `/reactjs/react.dev`, 2.9s; useEffect cleanup documentation query, 3.0s | Repeat query after reload passed in 3.1s; zero selected agents retained | +| Hugging Face / public MCP | No account, key, or OAuth; existing OAuth method retained separately | 4 read, 0 write | `hf_fs` public documentation search, 0.7s | Exact same query passed after reload in 0.8s; 4 actions and zero selected agents retained | +| Resend / OAuth | Team grant `full_access` explicitly approved; HTTPS DCR/OAuth | 132: 55 read, 77 write | `List Domains` at 4.3s and 0.9s; final fresh setup after the access-marker refinement passed in 1.4s; one entry each, values omitted | Fresh setup/reload retained Just me and zero agents; all actions Off in final reloaded state | + +Tests used the actual Apps setup and Permissions Test dialog. The built-in Connector Tester was the test actor; no real agent adapter, task, or runtime was started. Discovery and read proof do not establish write behavior, token-refresh rotation, or every advertised action. + +Bitly's older recovery connection was left with all 56 actions Off. Resend's three older qualification connections were also left all Off. Provider-wide OAuth grants still exist; local action policies govern execution separately. No email was sent, link created, destructive provider action executed, purchase made, or paid job started. + +For Resend, an earlier bulk-Off change was not reflected after a reload. Its historical cause is unresolved. A controlled Off → Ask first → reload → Off → reload check, waiting for the visible Saving indicator to clear before each reload, preserved both settled values. No policy persistence defect was reproduced. Qualification records the final persisted state, not an assumed explanation for that earlier observation. + +## Incomplete providers + +These definitions and durable overrides are retained for further work, but withheld as new Browse candidates. Existing installed/incomplete connections remain visible for recovery. They are not claimed as live-qualified. + +| Provider | Observed gate | Next concrete step | +| --- | --- | --- | +| monday.com | Registration HTTP 400: `redirect_uris are not allowed: please contact monday to add the redirect URIs to the allowlist` | Obtain provider approval for the HTTPS callback. Account-admin approval may also apply after registration. No grant or account read occurred. | +| ClickUp | Both retained and fresh Google identity flows returned to blank ClickUp login with `Password required` | Resolve the ClickUp account/login route, then review its `read write` provider consent and run a bounded workspace read. No grant or authenticated catalog occurred. | +| Klaviyo | Initial client authorization reached; no account or exact scope strings enumerated; optional prompt-intent collection unchecked | Await the separate client authorization decision, then review any subsequent account/scope grant. No approval, account read, campaign send, or deletion occurred. | + +The saved monday.com draft was resumed from Browse and survived reload in the actual app after the recovery fix. Fresh `source=monday` setup stayed withheld, and using the same draft with `source=clickup` showed a provider-mismatch error. These checks did not retry authorization or approve a provider grant. An extra public Context7 connection was seeded as a saved draft through the local API solely to exercise recovery. Browse → Finish setup → Connect reached the success screen after activation, with zero agents selected; the saved Connected state and zero-agent choice survived a Permissions reload. Refreshing the generated `stage=complete` URL now opens that exact saved Permissions page after validating active status, company, application, and provider. View connection also opens that exact page. A normal active recovery URL and a completed URL with a mismatched provider stayed blocked in the browser; a draft at the completion stage does not imply success. + +monday.com is recorded as provider-approval blocked in the research ledger. ClickUp and Klaviyo retain their documented DCR setup research but remain hidden pending actual qualification. + +## Shared fixes found during real use + +- Token exchange now requests `Accept: application/json`. Bitly documents a legacy form response when this header is absent. The first callback reported `oauth_access_token_missing`; a fresh equivalent authorization succeeded after the header fix. The earlier token response body/content type was not captured, so the exact old payload remains unproven. Code exchange and refresh use the same exchange function. +- Remote MCP callbacks preserve an explicitly saved Access step, including zero selected agents. Saving installs now records a transactional marker, distinguishing a deliberate empty selection from a flow that has not selected access. The old branch only recognized aggregator MCP methods, causing fresh Resend setup to replace an empty install set with access for all agents. Empty, individual-agent, company, and unconfigured-default selections have regression coverage for install rows and profile bindings. Managed OAuth keeps its subject/recommended defaults when no Access choice was saved. + +- Saved hidden-provider drafts now reach their exact setup wizard from Finish setup. The wizard validates the company-visible draft, its application, and matching provider before resolving a hidden definition; unknown or mismatched drafts stay on an error screen. Fresh hidden-provider setup remains withheld. Completion now accepts the exact active row only after this flow successfully finishes it, so the draft → active refetch preserves the success screen; normal initially active, disabled, archived, and mismatched recovery URLs remain rejected. + +Provider-specific changes use durable overrides so ingestion preserves them. Bitly and Resend use full documented account capabilities; Context7 and Hugging Face add or qualify public read endpoints. No custom skills or channels are required for these basic MCP tool connections. + +## Evidence and sources + +Evidence is recorded as text only. Account-profile and domain results are omitted; no evidence images are included. Provider-specific `liveProof` and `evidenceLimit` records are in [tool-method-permission-reviews.json](tool-method-permission-reviews.json); the dated research entries are in [self-serve-mcp-research.json](../../packages/shared/src/self-serve-mcp-research.json). + +- [Bitly hosted MCP quickstart](https://dev.bitly.com/bitly-mcp/overview/quickstart/) and [token response format](https://dev.bitly.com/docs/getting-started/authentication/). +- [Context7 API guide](https://context7.com/docs/api-guide) and [official MCP source](https://github.com/upstash/context7/blob/master/packages/mcp/src/index.ts). +- [Resend remote MCP](https://resend.com/changelog/remote-mcp-server). +- [monday.com MCP integration](https://developer.monday.com/api-reference/docs/integrate-with-monday-mcp). +- [ClickUp MCP](https://developer.clickup.com/docs/connect-an-ai-assistant-to-clickups-mcp-server). +- [Klaviyo MCP](https://developers.klaviyo.com/en/docs/connect_to_the_klaviyo_mcp_server). +- [Hugging Face MCP](https://huggingface.co/docs/hub/agents-mcp) and [official server](https://github.com/huggingface/hf-mcp-server). + +## Validation + +- Shared catalog suite: 36 passed. +- Durable override suite: 3 passed. +- Focused UI setup, recovery, Browse, and OAuth-helper suites: 306 passed. +- Targeted mock MCP Playwright journey: 1 passed in 34.9s on an isolated harness. It checks the exact saved Permissions route, both advertised actions, and reload persistence. CI caught its stale Browse expectation after View connection changed; the test now covers the intended destination. +- Brand validation: 101 identities passed. +- Full connector service suite: 420 passed, including the four-case JSON-token/access regression and existing managed OAuth lifecycle coverage. +- Recursive typecheck: passed again after rebasing onto current master. +- Recursive production build: passed again after rebasing onto current master. UI typecheck, production build, and token gates passed again after the draft-recovery fix. +- Full local repository Vitest was started before the rebase and cancelled as superseded while still in the general-server phase. It is not counted as a completed pass. Broad current-head verification is tracked in [PR #15686 checks](https://github.com/paperclipai/paperclip/pull/15686/checks); the complete connector service suite passed separately against the final production code. + +The local host reached its macOS System V shared-memory limit during a service restart. Only stale, unattached user-owned segments with dead creator/operator processes were reclaimed; other running apps and their databases were left intact. This host resource failure is separate from provider login/approval gates. diff --git a/doc/connections/easy-mcp-batch-2-brand-provenance-2026-10-09.json b/doc/connections/easy-mcp-batch-2-brand-provenance-2026-10-09.json new file mode 100644 index 00000000000..9f5c858cfa3 --- /dev/null +++ b/doc/connections/easy-mcp-batch-2-brand-provenance-2026-10-09.json @@ -0,0 +1,38 @@ +{ + "reviewedAt": "2026-10-09", + "providers": [ + { + "slug": "monday", + "provider": "monday.com", + "localAsset": "/brands/apps/monday.png", + "sourceUrl": "https://monday.com/p/wp-content/uploads/2024/03/monday.com_-1.png", + "sourceContext": "https://monday.com/p/news/press-kit/ (official press kit; complete monday.com wordmark)", + "sha256": "08d1592465b32f1cc6b6d2efd2da64f22496d4d3a887f6506e083dceceb390e2", + "darkAsset": "/brands/apps/monday-dark.png", + "darkSourceUrl": "https://monday.com/p/wp-content/uploads/2024/03/White-logo.png", + "darkSha256": "a5e8e4984f7a79c5e9d7de8b94d6c229d98c54ed2863eca6d2a080ad17d8510a", + "retrievedAt": "2026-10-09", + "note": "Unmodified official press-kit full wordmark variants. The provider's press-kit guidance asks for the complete monday.com text and the colored mark; white wordmark variant is used on dark surfaces." + }, + { + "slug": "clickup", + "provider": "ClickUp", + "file": "ui/public/brands/apps/clickup.png", + "sourceUrl": "https://clickup.com/favicons/apple-touch-icon.png", + "linkedFrom": "https://clickup.com/", + "retrievedAt": "2026-10-09", + "sha256": "fb45023441f0794d1ece6ef59542d360c8bf4f4c3bde2c84c4d546ae8019ed72", + "note": "Official ClickUp touch icon, retained without modification." + }, + { + "slug": "klaviyo", + "provider": "Klaviyo", + "file": "ui/public/brands/apps/klaviyo.png", + "sourceUrl": "https://www.klaviyo.com/icons/icon-144x144.png", + "linkedFrom": "https://www.klaviyo.com/", + "retrievedAt": "2026-10-09", + "sha256": "61a5bd241fb300f51f6bce5c71887e3795e91c64ca9e62d676e0ed9eae6e8796", + "note": "Official Klaviyo site icon; retained at native 144x144 pixels without modification." + } + ] +} diff --git a/doc/connections/tool-method-permission-reviews.json b/doc/connections/tool-method-permission-reviews.json index 88e88ec310e..d83dd6a8e43 100644 --- a/doc/connections/tool-method-permission-reviews.json +++ b/doc/connections/tool-method-permission-reviews.json @@ -40,7 +40,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Actions depend on the operator-supplied API and key; grant read/write on the required resources at the provider. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions.", + "keyPermissions": "Actions depend on the operator-supplied API and key; grant read/write on the required resources at the provider. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions.", "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." }, { @@ -113,18 +113,22 @@ "app": "bitly", "method": "mcp-oauth", "auth": "oauth", - "policy": "provider-default", - "requestedScopes": [], - "capability": "provider-controlled", - "supportedActions": "Create/manage links in authorized groups.", + "policy": "explicit", + "requestedScopes": [ + "mcp.all" + ], + "capability": "write", + "supportedActions": "Read user, organizations, groups, links, QR codes, analytics, domains, and Bitly Sites; create/update links and QR codes, alter group/domain/site settings, create or edit site content, and publish; permanently delete short links, Sites, site blocks, and redirects. Domain-registration tools may create a complimentary domain after accepting provider/registrar agreements. Actions remain subject to the authorized Bitly account and group permissions.", "evidence": [ - "https://dev.bitly.com/bitly-mcp/overview/quickstart/", + "https://dev.bitly.com/bitly-mcp/using-the-bitly-mcp-server/configuration-details/", + "https://dev.bitly.com/bitly-mcp/using-the-bitly-mcp-server/mcp-tools-reference/", + "https://dev.bitly.com/bitly-mcp/overview/mcp-changelog/", "https://api-ssl.bitly.com/.well-known/oauth-protected-resource", "https://api-ssl.bitly.com/.well-known/oauth-authorization-server" ], - "reviewedAt": "2026-09-30", - "liveProof": "not-run", - "providerDefaultReason": "The official MCP quickstart uses browser authorization without client-selected scopes; neither protected-resource nor authorization-server metadata advertises a scope list. Bitly account permissions govern link actions." + "reviewedAt": "2026-10-09", + "liveProof": "HTTPS DCR/OAuth with approved mcp.all completed after JSON token response negotiation. Discovered 56 actions (30 read, 26 write). bitly_get_user passed through the Paperclip Test dialog in 2.0s; account values omitted. Reload/catalog refresh retained actions, zero selected agents, and all 26 writes Off. Older recovery connection left all 56 actions Off.", + "evidenceLimit": "Direct UI Test using the Connector Tester actor, not an agent adapter run. No links created, provider writes, paid actions, token refresh or API-key method tested." }, { "app": "bitly", @@ -132,16 +136,16 @@ "auth": "api_key", "policy": "provider-key", "requestedScopes": [], - "capability": "provider-controlled", - "supportedActions": "Create/manage links in authorized groups.", + "capability": "write", + "supportedActions": "The same broad provider MCP catalog as the OAuth method, under the account associated with the API token: link and QR management, analytics, groups, custom-domain operations, and Bitly Sites, including publishing and deletion.", "evidence": [ "https://dev.bitly.com/bitly-mcp/overview/quickstart/", - "https://api-ssl.bitly.com/.well-known/oauth-protected-resource", - "https://api-ssl.bitly.com/.well-known/oauth-authorization-server" + "https://dev.bitly.com/bitly-mcp/using-the-bitly-mcp-server/configuration-details/", + "https://dev.bitly.com/bitly-mcp/using-the-bitly-mcp-server/mcp-tools-reference/" ], - "reviewedAt": "2026-09-30", + "reviewedAt": "2026-10-09", "liveProof": "not-run", - "keyPermissions": "Create/manage links in authorized groups. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "A Bitly API token from Settings \u2192 API is documented for MCP bearer auth, but no restriction scope is documented; treat it as capable of the full MCP catalog under that account. Paperclip cannot narrow the token." }, { "app": "box", @@ -209,7 +213,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Create/stop browser sessions and run browser tasks under the API key. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "Create/stop browser sessions and run browser tasks under the API key. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." }, { "app": "candid", @@ -283,7 +287,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Cloudflare API actions selected at provider consent or on the API token. Identity scopes alone do not grant account writes. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "Cloudflare API actions selected at provider consent or on the API token. Identity scopes alone do not grant account writes. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." }, { "app": "cloudinary", @@ -339,7 +343,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Modify documents/tables permitted by the account. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "Modify documents/tables permitted by the account. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." }, { "app": "cognee", @@ -354,7 +358,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Add/process knowledge and manage authorized datasets. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "Add/process knowledge and manage authorized datasets. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." }, { "app": "composio", @@ -377,13 +381,16 @@ "policy": "endpoint", "requestedScopes": [], "capability": "read", - "supportedActions": "None: library documentation retrieval.", + "supportedActions": "Resolve public library names to Context7 IDs and retrieve current public documentation/code examples for a specified library and topic. These are provider-declared read-only, non-destructive lookups; no library, account, repository, or workspace mutation is exposed by the reviewed remote server definition.", "evidence": [ - "https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization" + "https://context7.com/docs/api-guide", + "https://github.com/upstash/context7/blob/master/server.json", + "https://github.com/upstash/context7/blob/master/packages/mcp/README.md", + "https://github.com/upstash/context7/blob/master/packages/mcp/src/index.ts" ], - "reviewedAt": "2026-09-30", - "liveProof": "not-run", - "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." + "reviewedAt": "2026-10-09", + "liveProof": "Anonymous Paperclip connection succeeded. Discovery returned 2 read actions (resolve-library-id and query-docs), 0 write actions. Resolve React returned official React docs ID /reactjs/react.dev (source reputation High) in 2.9s; query-docs returned useEffect cleanup guidance from React documentation in 3.0s. After page reload, agent restriction remained Just agents I pick with 0 selected and the same bounded query succeeded again in 3.1s. No API key, OAuth grant, account data, private repository, provider writes, or agent run.", + "evidenceLimit": "Live proof covers anonymous MCP initialize/discovery and one narrow public React documentation lookup through Paperclip's actual Apps connection and Test dialog, including a successful repeat after reload. It does not prove elevated API-key rate limits, private-repository access, or any non-public provider content." }, { "app": "egnyte", @@ -506,7 +513,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Use an API key for an account with write access to the meetings your agents need to share, rename, move or turn into soundbites. Paperclip cannot increase the key’s permissions." + "keyPermissions": "Use an API key for an account with write access to the meetings your agents need to share, rename, move or turn into soundbites. Paperclip cannot increase the key\u2019s permissions." }, { "app": "gauge", @@ -571,7 +578,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Repository contents, issues, pull requests and other granted toolsets; selected repos and installation/PAT permissions apply. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions.", + "keyPermissions": "Repository contents, issues, pull requests and other granted toolsets; selected repos and installation/PAT permissions apply. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions.", "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." }, { @@ -1267,7 +1274,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Launch/manage browsers, profiles, apps and browser automation. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "Launch/manage browsers, profiles, apps and browser automation. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." }, { "app": "linear", @@ -1356,7 +1363,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Add/update/delete memories under the API key account. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "Add/update/delete memories under the API key account. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." }, { "app": "miro", @@ -1471,7 +1478,7 @@ ], "reviewedAt": "2026-10-02", "liveProof": "not-run", - "keyPermissions": "Project, branch, compute, snapshot, SQL and schema changes within the key’s reach. A project-scoped key limits access to one project with Editor rights; personal and organization keys reach every project they can access. Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "Project, branch, compute, snapshot, SQL and schema changes within the key\u2019s reach. A project-scoped key limits access to one project with Editor rights; personal and organization keys reach every project they can access. Paperclip cannot increase an existing key\u2019s permissions." }, { "app": "notion", @@ -1553,7 +1560,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Incident and on-call changes require a full-access API key and role access. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "Incident and on-call changes require a full-access API key and role access. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." }, { "app": "pagerduty", @@ -1568,7 +1575,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Incident and on-call changes require a full-access API key and role access. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "Incident and on-call changes require a full-access API key and role access. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." }, { "app": "planetscale", @@ -1639,7 +1646,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Analytics objects, feature flags, experiments, error triage and other enabled product actions. readonly/feature/tool filters remain enforced. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "Analytics objects, feature flags, experiments, error triage and other enabled product actions. readonly/feature/tool filters remain enforced. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." }, { "app": "postman", @@ -1707,7 +1714,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." }, { "app": "postman", @@ -1724,7 +1731,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." }, { "app": "postman", @@ -1741,7 +1748,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." }, { "app": "railway", @@ -1795,7 +1802,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Provider-authorized payment/account tools; financial policy gates remain. Live verification outstanding. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions.", + "keyPermissions": "Provider-authorized payment/account tools; financial policy gates remain. Live verification outstanding. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions.", "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." }, { @@ -1807,14 +1814,17 @@ "full_access" ], "capability": "write", - "supportedActions": "Send email and manage domains/templates/account resources. full_access covers the MCP management tools as well as sending.", + "supportedActions": "The Resend hosted MCP exposes account/resource reads and mutations, including email sending and domain/template/account management. Provider documentation describes full_access as broad read/create/update/delete access; the separate emails:send grant does not provide a bounded account-metadata read. During qualification all 77 Write actions and all 55 Read actions were explicitly Off in Paperclip after reload.", "evidence": [ "https://resend.com/changelog/remote-mcp-server", + "https://resend.com/changelog/oauth-support", + "https://resend.com/changelog/new-api-key-permissions", "https://mcp.resend.com/.well-known/oauth-protected-resource", "https://api.resend.com/.well-known/oauth-authorization-server" ], - "reviewedAt": "2026-09-30", - "liveProof": "not-run" + "reviewedAt": "2026-10-09", + "liveProof": "Fresh OAuth connection succeeded under the previously approved paperclip/full_access grant. Paperclip catalog exposed 132 actions (55 Read, 77 Write). List Domains succeeded twice via UI Test as Connector Tester and returned one entry each. After the test, all action permissions were Off and stayed Off after reload; human audience Just me and agent audience Just agents I pick with zero selected also persisted. Final fresh setup after the access-marker refinement again preserved Just me and zero agents; one List Domains UI Test passed in 1.4s and final reload confirmed all 132 actions Off.", + "evidenceLimit": "Test execution was through Paperclip's UI Test dialog as Connector Tester, not a real agent adapter/task. The bounded read returned one entry; names and identifiers are omitted. No provider writes, deletes, sends, or agent runs occurred. Paperclip Off policies do not narrow the provider-side full_access OAuth grant." }, { "app": "sanity", @@ -1847,7 +1857,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Edit content permitted by project role; global is MCP authorization, not a bypass of project ACLs. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "Edit content permitted by project role; global is MCP authorization, not a bypass of project ACLs. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." }, { "app": "sentry", @@ -1994,7 +2004,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Account- and sandbox-specific writes selected during Stripe consent; financial approvals and restricted-key permissions remain. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "Account- and sandbox-specific writes selected during Stripe consent; financial approvals and restricted-key permissions remain. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." }, { "app": "supabase", @@ -2039,7 +2049,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Project/database/environment/storage changes and Edge Function deployment. Project selection and optional read-only configuration remain authoritative. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "Project/database/environment/storage changes and Edge Function deployment. Project selection and optional read-only configuration remain authoritative. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." }, { "app": "superagent", @@ -2310,7 +2320,7 @@ "offline_access" ], "capability": "read", - "supportedActions": "Run public web search and extract content from public pages using the account’s Tavily API key and credit allowance. These are read/search operations; no provider business-record write action is exposed.", + "supportedActions": "Run public web search and extract content from public pages using the account\u2019s Tavily API key and credit allowance. These are read/search operations; no provider business-record write action is exposed.", "evidence": [ "https://docs.tavily.com/documentation/mcp", "https://mcp.tavily.com/.well-known/oauth-protected-resource/mcp", @@ -2388,7 +2398,7 @@ ], "reviewedAt": "2026-10-08", "liveProof": "not-run", - "keyPermissions": "Create an API key for the intended Firecrawl team with access to the search, scrape, parse, and any job tools you intend to use. Jobs can consume credits; Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "Create an API key for the intended Firecrawl team with access to the search, scrape, parse, and any job tools you intend to use. Jobs can consume credits; Paperclip cannot increase an existing key\u2019s permissions." }, { "app": "firecrawl", @@ -2491,7 +2501,7 @@ ], "reviewedAt": "2026-10-08", "liveProof": "not-run", - "keyPermissions": "At key creation, choose read-only or read/write according to the approved task. Read/write keys can reach URL-submission and related provider actions; Paperclip cannot increase an existing key’s permissions." + "keyPermissions": "At key creation, choose read-only or read/write according to the approved task. Read/write keys can reach URL-submission and related provider actions; Paperclip cannot increase an existing key\u2019s permissions." }, { "app": "airtable", @@ -2544,6 +2554,84 @@ "reviewedAt": "2026-10-08", "evidenceLevel": "provider-documentation-reviewed", "liveProof": "not-run" + }, + { + "app": "clickup", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "read", + "write" + ], + "capability": "write", + "supportedActions": "Search workspace content and read tasks, lists, folders, docs, comments, members, and time entries; create or update tasks, lists, folders, docs, comments, tags, links, dependencies, time entries, and chat messages; start/stop time tracking; documented tool listing includes task deletion. Actions remain limited by the signed-in user's ClickUp permissions and authorized workspace(s).", + "evidence": [ + "https://developer.clickup.com/docs/connect-an-ai-assistant-to-clickups-mcp-server", + "https://developer.clickup.com/docs/mcp-tools", + "https://mcp.clickup.com/.well-known/oauth-protected-resource/mcp", + "https://mcp.clickup.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-10-09", + "liveProof": "blocked-at-provider-login: retained and fresh OAuth flows both returned to blank ClickUp login after Google account selection; Password required remained.", + "evidenceLimit": "Public OAuth discovery and browser login attempt only. No provider grant, authenticated catalog, or account read." + }, + { + "app": "klaviyo", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "provider-default", + "requestedScopes": [], + "capability": "write", + "supportedActions": "Within the URL toolset filter, read campaign, metric, and segment context and use supported campaign write tools. The provider's campaigns:write toolset may include sending, scheduling, deletion, or other mutation tools; the authenticated catalog must be reviewed individually and Paperclip writes remain Off during qualification.", + "evidence": [ + "https://developers.klaviyo.com/en/docs/connect_to_the_klaviyo_mcp_server", + "https://developers.klaviyo.com/en/docs/klaviyo_mcp_server_available_tools", + "https://developers.klaviyo.com/en/docs/authenticate_", + "https://mcp.klaviyo.com/.well-known/oauth-protected-resource/mcp", + "https://mcp.klaviyo.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-10-09", + "liveProof": "pending-client-approval: initial provider authorization screen reached with optional prompt-intent collection unchecked; no approval submitted.", + "providerDefaultReason": "Klaviyo's OAuth protected-resource and authorization-server metadata omit scopes_supported. The official MCP guide instead documents a toolsets URL parameter that limits exposed tools using API scope names. The source URL selects campaigns:read, campaigns:write, metrics:read, and segments:read; OAuth consent remains provider-controlled.", + "evidenceLimit": "Public discovery and initial client authorization screen only. Account/scope grant, authenticated catalog, and provider read remain unverified." + }, + { + "app": "monday", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "provider-default", + "requestedScopes": [], + "capability": "write", + "supportedActions": "Read and manage boards and items; create/update boards, groups, columns, items, docs, dashboards, views, forms, workflows, automations and platform agents; post item updates or send user notifications; run low-level GraphQL queries/mutations where exposed. All actions remain subject to the authorized user's permissions and account MCP limits. Verify the live catalog and keep destructive, notification, automation/workflow publication and agent lifecycle actions Off unless separately authorized.", + "evidence": [ + "https://developer.monday.com/api-reference/docs/integrate-with-monday-mcp", + "https://developer.monday.com/api-reference/docs/mcp-dynamic-client-registration", + "https://developer.monday.com/api-reference/docs/mcp-dynamic-connectors", + "https://developer.monday.com/api-reference/docs/platform-mcp-tools", + "https://developer.monday.com/api-reference/docs/monday-mcp-security-overview" + ], + "reviewedAt": "2026-10-09", + "liveProof": "blocked-before-consent: provider DCR HTTP 400 says redirect_uris are not allowed and asks to contact monday for callback allowlisting.", + "providerDefaultReason": "The official hosted-MCP DCR documentation describes OAuth authorization and account-admin approval for additional connectors; the hosted MCP runs as the authorizing user, inheriting that user's monday.com permissions and account MCP limits. The DCR guide does not document client-selected OAuth scope names for this route, so Paperclip sends no guessed scopes. Tool-level authority is bounded by the provider account and user's permissions; verify the live catalog before enabling actions.", + "evidenceLimit": "Public metadata and registration failure only. No client registered, consent accepted, authenticated catalog, or account read." + }, + { + "app": "hugging-face", + "method": "public-mcp", + "auth": "none", + "policy": "endpoint", + "requestedScopes": [], + "capability": "read", + "supportedActions": "Anonymous public Hub lookups only: hf_whoami returns the current auth context without credential values; hub_repo_search searches public model, dataset, and Space repositories; hub_repo_details reads public repo overview, dataset structure, and bounded dataset preview; hf_fs exposes only ls, cat, attach, stat, find, and search against canonical hf:// URIs. No write, inference, dynamic Space, or job tool was advertised in the live anonymous tool catalog. Hugging Face documents that anonymous use is rate-limited.", + "evidence": [ + "https://huggingface.co/docs/hub/agents-mcp", + "https://huggingface.co/blog/building-hf-mcp", + "https://github.com/huggingface/hf-mcp-server" + ], + "reviewedAt": "2026-10-09", + "liveProof": "Paperclip Apps qualification passed: connected the public-mcp method from the natural Connectors catalog; initialization/discovery showed exactly 4 Read actions and 0 Write actions. Restricted agent access to Just agents I pick with 0 selected; the public method requires no identity. A bounded hf_fs search of hf://docs for MCP with limit 3 succeeded (3 public doc entries; UI preview reported one successful result row) in 0.7s. After reload, the page still showed Connected, 4 actions, Read 4, Write 0, and no agents can use this connection; the same query succeeded again in 0.8s. The Test dialog reported the direct call ran as Connector Tester; no heartbeat/agent execution or other provider tool call occurred.", + "evidenceLimit": "Qualification used only public documentation and did not use a Hugging Face account, OAuth, API key, private repo, write, inference, Space execution, or job. Anonymous endpoint was directly probed separately and returned 4 read tools, matching Paperclip discovery. The setup flow says no identity required; a one-member human audience was saved before reload, but human identity controls were not shown after reload. A root browser inspection confirmed four read actions and zero agents selected; a privacy-safe permissions screenshot was saved. Rate limits and later provider catalog changes remain outside this proof." } ] } diff --git a/packages/shared/src/app-definitions.generated.ts b/packages/shared/src/app-definitions.generated.ts index 62047f770f7..ae5dc89ac2f 100644 --- a/packages/shared/src/app-definitions.generated.ts +++ b/packages/shared/src/app-definitions.generated.ts @@ -79,23 +79,26 @@ import a77 from "./app-definitions/firecrawl.json" with { type: "json" }; import a78 from "./app-definitions/parallel-search.json" with { type: "json" }; import a79 from "./app-definitions/windsor-ai.json" with { type: "json" }; import a80 from "./app-definitions/gsc-wizard.json" with { type: "json" }; -import a81 from "./app-definitions/gmail.json" with { type: "json" }; -import a82 from "./app-definitions/google-drive.json" with { type: "json" }; -import a83 from "./app-definitions/google-docs.json" with { type: "json" }; -import a84 from "./app-definitions/google-sheets.json" with { type: "json" }; -import a85 from "./app-definitions/google-slides.json" with { type: "json" }; -import a86 from "./app-definitions/google-calendar.json" with { type: "json" }; -import a87 from "./app-definitions/google-chat.json" with { type: "json" }; -import a88 from "./app-definitions/google-people.json" with { type: "json" }; -import a89 from "./app-definitions/google-workspace-search.json" with { type: "json" }; -import a90 from "./app-definitions/openai.json" with { type: "json" }; -import a91 from "./app-definitions/openrouter.json" with { type: "json" }; -import a92 from "./app-definitions/xai.json" with { type: "json" }; -import a93 from "./app-definitions/google.json" with { type: "json" }; -import a94 from "./app-definitions/bedrock.json" with { type: "json" }; -import a95 from "./app-definitions/responses-api.json" with { type: "json" }; -import a96 from "./app-definitions/messages-api.json" with { type: "json" }; -import a97 from "./app-definitions/chat-completions-api.json" with { type: "json" }; -import a98 from "./app-definitions/local.json" with { type: "json" }; +import a81 from "./app-definitions/clickup.json" with { type: "json" }; +import a82 from "./app-definitions/klaviyo.json" with { type: "json" }; +import a83 from "./app-definitions/gmail.json" with { type: "json" }; +import a84 from "./app-definitions/google-drive.json" with { type: "json" }; +import a85 from "./app-definitions/google-docs.json" with { type: "json" }; +import a86 from "./app-definitions/google-sheets.json" with { type: "json" }; +import a87 from "./app-definitions/google-slides.json" with { type: "json" }; +import a88 from "./app-definitions/google-calendar.json" with { type: "json" }; +import a89 from "./app-definitions/google-chat.json" with { type: "json" }; +import a90 from "./app-definitions/google-people.json" with { type: "json" }; +import a91 from "./app-definitions/google-workspace-search.json" with { type: "json" }; +import a92 from "./app-definitions/openai.json" with { type: "json" }; +import a93 from "./app-definitions/openrouter.json" with { type: "json" }; +import a94 from "./app-definitions/xai.json" with { type: "json" }; +import a95 from "./app-definitions/google.json" with { type: "json" }; +import a96 from "./app-definitions/bedrock.json" with { type: "json" }; +import a97 from "./app-definitions/responses-api.json" with { type: "json" }; +import a98 from "./app-definitions/messages-api.json" with { type: "json" }; +import a99 from "./app-definitions/chat-completions-api.json" with { type: "json" }; +import a100 from "./app-definitions/local.json" with { type: "json" }; +import a101 from "./app-definitions/monday.json" with { type: "json" }; import type { AppDefinition } from "./types/app-definition.js"; -export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90,a91,a92,a93,a94,a95,a96,a97,a98] as AppDefinition[]; +export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90,a91,a92,a93,a94,a95,a96,a97,a98,a99,a100,a101] as AppDefinition[]; diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index 6297d586bff..f92d1f1e6d8 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -12,6 +12,7 @@ import { getAvailableConnectionMethods, getAppDefinitionForUrl, getConnectableAppDefinition, + getAppStoreDefinition, getRecommendedConnectionMethod, recommendedDefaultsForApp, resolveConnectionMethodServerUrl, @@ -273,11 +274,12 @@ describe("AppDefinition catalog", () => { "google-workspace-search", ]), ); - expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(60); + expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(63); expect(BLOCKED_MCP_PROVIDERS.map((entry) => entry.slug)).toEqual([ "g2", "vercel", "zomato", + "monday", ]); const definitionSlugs = new Set(APP_DEFINITIONS.map((app) => app.slug)); const connectableSlugs = new Set( @@ -294,7 +296,7 @@ describe("AppDefinition catalog", () => { ), ).toEqual([]); for (const entry of BLOCKED_MCP_PROVIDERS) - expect(connectableSlugs.has(entry.slug)).toBe(false); + expect(getAppStoreDefinition(entry.slug)).toBeNull(); }); it("separates GitHub tools from the review bot without changing the provider identity", () => { const github = getConnectableAppDefinition("github")!; @@ -437,15 +439,15 @@ describe("AppDefinition catalog", () => { expect(channel("slack")?.guidanceMd).toContain("reactions"); expect(channel("slack")?.guidanceMd).toContain("direct messages"); }); - it("keeps a complete, unique, dated evidence ledger for all 63 researched MCP providers", () => { + it("keeps a complete, unique, dated evidence ledger for all 67 researched MCP providers", () => { // Ledger-wide date reflects the last full re-verification (2026-08-26); // later provider additions carry their own research evidence, but // bumping the shared date would overstate freshness for the other providers. expect(SELF_SERVE_MCP_RESEARCH.verifiedAt).toBe("2026-08-26"); - expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(63); + expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(67); expect( new Set(SELF_SERVE_MCP_RESEARCH.entries.map((entry) => entry.slug)), - ).toHaveProperty("size", 63); + ).toHaveProperty("size", 67); for (const entry of SELF_SERVE_MCP_RESEARCH.entries) { expect(new URL(entry.docsUrl).protocol).toBe("https:"); expect(new URL(entry.serverUrl).protocol).toBe("https:"); @@ -849,16 +851,17 @@ describe("AppDefinition catalog", () => { it("withholds unverified and reserved providers from the app store without deleting their definitions", () => { expect([...APP_STORE_HIDDEN_SLUGS].sort()).toEqual([ "beehiiv", - "bitly", "brex", "candid", + "clickup", "coda", - "context7", "egnyte", "embat", "kernel", + "klaviyo", "local-falcon", "manufact", + "monday", "oreilly", "planetscale", "razorpay", @@ -868,7 +871,7 @@ describe("AppDefinition catalog", () => { "ticktick", "xero", ]); - expect(APP_STORE_DEFINITIONS).toHaveLength(77); + expect(APP_STORE_DEFINITIONS).toHaveLength(79); const connectableSlugs = new Set( CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug), ); @@ -927,7 +930,7 @@ describe("AppDefinition catalog", () => { ); } }); - it("keeps every researched self-serve candidate implemented while blocked providers stay absent", () => { + it("keeps every researched self-serve candidate implemented while blocked providers stay out of Browse", () => { const definitions = new Map( CONNECTABLE_APP_DEFINITIONS.map((entry) => [entry.slug, entry]), ); @@ -936,7 +939,7 @@ describe("AppDefinition catalog", () => { true, ); for (const blocked of BLOCKED_MCP_PROVIDERS) - expect(definitions.has(blocked.slug)).toBe(false); + expect(getAppStoreDefinition(blocked.slug)).toBeNull(); }); it("keeps all Google Workspace profiles aligned with their app, endpoint, scopes, ownership, risk, and write policy", () => { expect(GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS).toEqual( diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts index 2e1409f9f6e..6cde0916f2f 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -10,6 +10,7 @@ export const CONNECTABLE_APP_SLUGS = new Set([ "browser-use-cloud", "cognee", ...SELF_SERVE_MCP_CANDIDATES.map((entry) => entry.slug), + "monday", "enterpret", "zapier", "arcade", @@ -51,16 +52,17 @@ export const CONNECTABLE_APP_DEFINITIONS = APP_DEFINITIONS.filter((app) => */ export const APP_STORE_HIDDEN_SLUGS = new Set([ "beehiiv", - "bitly", "brex", "candid", + "clickup", "coda", - "context7", "egnyte", "embat", "kernel", + "klaviyo", "local-falcon", "manufact", + "monday", "oreilly", "planetscale", "razorpay", diff --git a/packages/shared/src/app-definitions/bitly.json b/packages/shared/src/app-definitions/bitly.json index fe65a46c5c7..81f4937e92f 100644 --- a/packages/shared/src/app-definitions/bitly.json +++ b/packages/shared/src/app-definitions/bitly.json @@ -25,16 +25,20 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://api-ssl.bitly.com/v4/mcp" + "serverUrl": "https://api-ssl.bitly.com/v4/mcp", + "scopesHint": [ + "mcp.all" + ] }, - "guidanceMd": "Connect Bitly in the browser. A Bitly account with either browser authorization or an API token.", - "riskTier": "S2", + "guidanceMd": "Connect Bitly in the browser. Choose only the intended workspace. Review the discovered actions and keep writes and destructive operations disabled until explicitly needed.", + "riskTier": "S4", "label": "Sign in with Bitly", "consoleLinks": { - "docs": "https://dev.bitly.com/bitly-mcp/overview/quickstart/" + "docs": "https://dev.bitly.com/bitly-mcp/using-the-bitly-mcp-server/configuration-details/" }, "warnings": [ - "A Bitly account with either browser authorization or an API token." + "The mcp.all scope covers Bitly's broad MCP catalog, including link and QR management, analytics, groups, custom domains, and Bitly Sites.", + "The catalog includes publishing and deletion operations, including permanent short-link deletion, site deletion, and custom-domain changes. Review the full catalog and keep write and destructive actions Off during read-only qualification." ] }, { @@ -44,12 +48,12 @@ "ownershipModes": [ "customer" ], - "whenToUse": "Use a restricted customer-owned key when browser sign-in is not suitable.", + "whenToUse": "Use a customer-created Bitly API token when browser sign-in is not suitable.", "defaults": { "serverUrl": "https://api-ssl.bitly.com/v4/mcp" }, - "guidanceMd": "Use a customer-created Bitly key. A Bitly account with either browser authorization or an API token.", - "riskTier": "S2", + "guidanceMd": "Generate a Bitly API token under Settings → API. The token may authorize the MCP's full catalog under your Bitly account; Paperclip cannot narrow the token. Review discovered actions and keep writes and destructive operations disabled until explicitly needed.", + "riskTier": "S4", "label": "Use an API key", "credentialFields": [ { @@ -59,7 +63,7 @@ "required": true, "placeholder": "Paste your Bitly API token", "secret": true, - "helperMd": "Create/manage links in authorized groups. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." + "helperMd": "A Bitly API token from Settings → API is documented for MCP bearer auth, but no restriction scope is documented; treat it as capable of the full MCP catalog under that account. Paperclip cannot narrow the token." } ], "keyPlacement": { @@ -68,11 +72,12 @@ "prefix": "Bearer " }, "consoleLinks": { - "keys": "https://dev.bitly.com/bitly-mcp/overview/quickstart/", - "docs": "https://dev.bitly.com/bitly-mcp/overview/quickstart/" + "keys": "https://bitly.com/settings/api", + "docs": "https://dev.bitly.com/bitly-mcp/using-the-bitly-mcp-server/configuration-details/" }, "warnings": [ - "A Bitly account with either browser authorization or an API token." + "This API token is not documented as a restricted MCP token. It may authorize Bitly's broad MCP catalog, including link creation/update/deletion, QR codes, Sites publishing/deletion, and custom-domain operations.", + "Review the discovered catalog and keep write and destructive actions Off during read-only qualification." ] } ] diff --git a/packages/shared/src/app-definitions/clickup.json b/packages/shared/src/app-definitions/clickup.json new file mode 100644 index 00000000000..2367ba3afc2 --- /dev/null +++ b/packages/shared/src/app-definitions/clickup.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 1, + "slug": "clickup", + "name": "ClickUp", + "description": "Search ClickUp workspaces and manage tasks, docs, and conversations through the official MCP server.", + "categories": [ + "productivity" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/clickup.png" + }, + "urlPatterns": [ + "https://mcp.clickup.com/*" + ], + "docsUrl": "https://developer.clickup.com/docs/connect-an-ai-assistant-to-clickups-mcp-server", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "label": "Sign in with ClickUp", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "grantKinds": [ + "user" + ], + "whenToUse": "Connect the provider-hosted ClickUp MCP server with browser sign-in.", + "defaults": { + "serverUrl": "https://mcp.clickup.com/mcp", + "scopesHint": [ + "read", + "write" + ] + }, + "guidanceMd": "Choose only the ClickUp workspace needed. Confirm the target list and task before changing content, starting a timer, posting a chat message, or deleting anything.", + "warnings": [ + "ClickUp's MCP may expose task, document, and chat writes, time tracking, and task deletion under the signed-in user's permissions. Review the catalog and keep write and destructive actions disabled until explicitly needed.", + "Workspace usage may be subject to plan-based rolling daily MCP limits." + ], + "consoleLinks": { + "docs": "https://developer.clickup.com/docs/connect-an-ai-assistant-to-clickups-mcp-server" + }, + "riskTier": "S4", + "requiredResourceFilters": [ + "workspace", + "list" + ] + } + ] +} diff --git a/packages/shared/src/app-definitions/context7.json b/packages/shared/src/app-definitions/context7.json index 04a0bedb59e..b7e78083051 100644 --- a/packages/shared/src/app-definitions/context7.json +++ b/packages/shared/src/app-definitions/context7.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "slug": "context7", "name": "Context7", - "description": "Look up current documentation for software libraries.", + "description": "Search public library documentation and retrieve current code examples.", "categories": [ "developer" ], @@ -22,12 +22,20 @@ "ownershipModes": [ "customer" ], - "whenToUse": "Use the provider-hosted connection for the quickest setup.", + "whenToUse": "Use the public hosted MCP server for read-only library documentation lookups.", "defaults": { "serverUrl": "https://mcp.context7.com/mcp" }, - "guidanceMd": "Connect Context7 to give agents current library documentation.", - "riskTier": "S1" + "guidanceMd": "Connect Context7 to resolve public library IDs and retrieve current documentation. Anonymous access is supported with low rate limits; this method does not configure an API key. Keep queries limited to public documentation questions and do not include credentials, personal data, or proprietary code.", + "riskTier": "S1", + "consoleLinks": { + "docs": "https://context7.com/docs/api-guide" + }, + "warnings": [ + "Context7 processes the query text. Do not include secrets, personal information, or proprietary code.", + "Anonymous requests have low rate limits. Provider documentation says an API key raises limits and supports private repositories; this public method does not configure a key." + ] } - ] + ], + "docsUrl": "https://context7.com/docs/api-guide" } diff --git a/packages/shared/src/app-definitions/hugging-face.json b/packages/shared/src/app-definitions/hugging-face.json index b1a5d9dce20..6567ad1e62c 100644 --- a/packages/shared/src/app-definitions/hugging-face.json +++ b/packages/shared/src/app-definitions/hugging-face.json @@ -42,6 +42,28 @@ "warnings": [ "A Hugging Face account." ] + }, + { + "key": "public-mcp", + "transport": "mcp_remote", + "auth": "none", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use the anonymous hosted MCP endpoint for public Hugging Face Hub lookups.", + "defaults": { + "serverUrl": "https://huggingface.co/mcp" + }, + "guidanceMd": "This anonymous method exposes the public Hugging Face Hub read tools for repository search, public repository details, datasets, and documentation. It does not require an account or API key and is rate-limited. Keep queries to public information; do not include secrets, personal data, private repository names, or proprietary content.", + "riskTier": "S1", + "label": "Use public Hugging Face Hub", + "consoleLinks": { + "docs": "https://huggingface.co/docs/hub/agents-mcp" + }, + "warnings": [ + "Anonymous requests are rate-limited by Hugging Face.", + "This method only advertises read tools for the public Hub; do not include secrets, personal data, private repository names, or proprietary content in queries." + ] } ] } diff --git a/packages/shared/src/app-definitions/klaviyo.json b/packages/shared/src/app-definitions/klaviyo.json new file mode 100644 index 00000000000..839d800d676 --- /dev/null +++ b/packages/shared/src/app-definitions/klaviyo.json @@ -0,0 +1,59 @@ +{ + "schemaVersion": 1, + "slug": "klaviyo", + "name": "Klaviyo", + "description": "Review Klaviyo campaign performance and prepare campaign changes for review.", + "categories": [ + "commerce" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/klaviyo.png" + }, + "urlPatterns": [ + "https://mcp.klaviyo.com/*" + ], + "docsUrl": "https://developers.klaviyo.com/en/docs/connect_to_the_klaviyo_mcp_server", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "label": "Sign in with Klaviyo", + "transport": "mcp_remote", + "auth": "oauth", + "oauthClientRegistration": "dcr", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Connect to the provider-hosted Klaviyo MCP server with browser sign-in.", + "defaults": { + "serverUrl": "https://mcp.klaviyo.com/mcp?toolsets=campaigns:read,campaigns:write,metrics:read,segments:read" + }, + "guidanceMd": "Review campaign and metric data, then prepare campaign changes for human review. Campaign write access may expose sends and other state changes; never send or schedule a campaign without explicit authorization and an allowed Paperclip action.", + "riskTier": "S4", + "tenantFields": [ + { + "key": "readOnly", + "label": "Read-only mode", + "type": "checkbox", + "advanced": true, + "defaultValue": false, + "helperMd": "Turn on to remove Klaviyo MCP tools that can modify account data. This provider-side restriction is separate from Paperclip action permissions.", + "transport": { + "location": "query", + "name": "read-only", + "format": "boolean", + "omitFalse": true + } + } + ], + "consoleLinks": { + "docs": "https://developers.klaviyo.com/en/docs/connect_to_the_klaviyo_mcp_server" + }, + "warnings": [ + "Klaviyo's remote MCP requires an Owner, Admin, or Manager account role.", + "The campaigns:write toolset may expose campaign sending and other writes. Keep write actions Off until reviewed; the provider's Advanced read-only mode is optional and does not replace Paperclip action permissions." + ] + } + ] +} diff --git a/packages/shared/src/app-definitions/monday.json b/packages/shared/src/app-definitions/monday.json new file mode 100644 index 00000000000..fa8e7d607fa --- /dev/null +++ b/packages/shared/src/app-definitions/monday.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 1, + "slug": "monday", + "name": "monday.com", + "description": "Read and manage boards and work items in monday.com.", + "categories": [ + "productivity" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/monday.png", + "darkLogoUrl": "/brands/apps/monday-dark.png" + }, + "urlPatterns": [ + "https://mcp.monday.com/*" + ], + "docsUrl": "https://developer.monday.com/api-reference/docs/integrate-with-monday-mcp", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "label": "Sign in with monday.com", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Connect the hosted monday.com MCP server using OAuth and dynamic client registration.", + "defaults": { + "serverUrl": "https://mcp.monday.com/mcp" + }, + "guidanceMd": "Use the selected monday.com account only. Read the target board and its columns before acting, and verify the exact board and item before any change. Keep write and destructive actions Off unless the user explicitly asks for a specific change. Never send messages, publish, or trigger automations through a board action without separate authorization.", + "warnings": [ + "The hosted MCP server acts as the authorizing monday.com user and inherits that user's monday.com permissions. An account admin may also restrict MCP access to selected workspaces.", + "An additional OAuth/DCR connection can require account-admin approval for Paperclip and its exact callback URL before user authorization completes. If approval is pending, reconnect after the account admin approves it.", + "monday.com personal API tokens are documented for personal use, prototyping, and testing, and the provider says they are not intended for integrations used by other people. This connector uses the OAuth/DCR route for additional MCP clients; account-level approval may be required.", + "The tool catalog includes actions that can send notifications (optionally email), delete or activate automations, publish workflows, and create or run monday.com platform agents; a low-level tool can execute GraphQL mutations. Keep these actions Off unless separately authorized, and never run agents or change automations during qualification.", + "MCP calls count toward the account's monday.com API call limits. Keep tests bounded and avoid repeated discovery or broad queries." + ], + "consoleLinks": { + "docs": "https://developer.monday.com/api-reference/docs/integrate-with-monday-mcp" + }, + "riskTier": "S4" + } + ] +} diff --git a/packages/shared/src/app-definitions/resend.json b/packages/shared/src/app-definitions/resend.json index 4bb386d5b26..38f7595265f 100644 --- a/packages/shared/src/app-definitions/resend.json +++ b/packages/shared/src/app-definitions/resend.json @@ -31,14 +31,14 @@ "full_access" ] }, - "guidanceMd": "Connect Resend in the browser. A Resend account with access to the relevant domains.", - "riskTier": "S3", + "guidanceMd": "Sign in to the intended Resend team. The full_access grant covers reading and managing account resources, including sending email and deletion. Review the discovered actions in Paperclip before using them.", + "riskTier": "S4", "label": "Sign in with Resend", "consoleLinks": { "docs": "https://resend.com/changelog/remote-mcp-server" }, "warnings": [ - "A Resend account with access to the relevant domains." + "Resend full_access grants broad account read/write/delete access; Paperclip action policies govern execution separately." ] } ] diff --git a/packages/shared/src/self-serve-mcp-research.json b/packages/shared/src/self-serve-mcp-research.json index c743f3bb29b..25c89d032c0 100644 --- a/packages/shared/src/self-serve-mcp-research.json +++ b/packages/shared/src/self-serve-mcp-research.json @@ -5,12 +5,12 @@ { "slug": "jira", "name": "Jira", "wave": 1, "status": "self_serve", "docsUrl": "https://support.atlassian.com/atlassian-rovo-mcp-server/docs/getting-started-with-the-atlassian-remote-mcp-server/", "serverUrl": "https://mcp.atlassian.com/v1/mcp/authv2", "authMode": "dcr_cimd", "prerequisite": "An active Jira or Confluence site; tenant policy may require an administrator to approve the client.", "riskTier": "S3" }, { "slug": "airtable", "name": "Airtable", "wave": 1, "status": "self_serve", "docsUrl": "https://support.airtable.com/articles/9897799762-using-the-airtable-mcp-server", "serverUrl": "https://mcp.airtable.com/mcp", "authMode": "dcr", "prerequisite": "An Airtable account; enterprise administrators may need to allowlist the client.", "riskTier": "S3" }, { "slug": "beehiiv", "name": "beehiiv", "wave": 1, "status": "self_serve", "docsUrl": "https://www.beehiiv.com/features/mcp/getting-started", "serverUrl": "https://mcp.beehiiv.com/mcp", "authMode": "dcr", "prerequisite": "A beehiiv account; the subscription plan controls available write capabilities.", "riskTier": "S3" }, - { "slug": "bitly", "name": "Bitly", "wave": 1, "status": "self_serve", "docsUrl": "https://dev.bitly.com/bitly-mcp/overview/quickstart/", "serverUrl": "https://api-ssl.bitly.com/v4/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Bitly account with either browser authorization or an API token.", "riskTier": "S2" }, + { "slug": "bitly", "name": "Bitly", "wave": 1, "status": "self_serve", "docsUrl": "https://dev.bitly.com/bitly-mcp/overview/quickstart/", "serverUrl": "https://api-ssl.bitly.com/v4/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Bitly account. OAuth mcp.all or a personal API token grants the account-authorized MCP catalog, including publishing, domain management, and permanent deletion; review each action before enabling it.", "riskTier": "S4", "verifiedAt": "2026-10-09", "browserOnboarding": "completed-oauth", "liveRead": "passed: bitly_get_user through UI Test; reload and catalog refresh persisted 56 actions, zero selected agents, writes Off", "liveWrite": "not-run", "blocker": null }, { "slug": "candid", "name": "Candid", "wave": 1, "status": "self_serve", "docsUrl": "https://learning.candid.org/getting-started-with-the-candid-mcp-connector/375441", "serverUrl": "https://mcp.candid.org/mcp", "authMode": "dcr", "prerequisite": "A Candid account with access to the MCP connector.", "riskTier": "S2" }, { "slug": "cloudflare", "name": "Cloudflare", "wave": 1, "status": "self_serve", "docsUrl": "https://developers.cloudflare.com/agents/model-context-protocol/cloudflare/servers-for-cloudflare/", "serverUrl": "https://mcp.cloudflare.com/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Cloudflare account with access to the resources being connected.", "riskTier": "S3" }, { "slug": "cloudinary", "name": "Cloudinary", "wave": 1, "status": "self_serve", "docsUrl": "https://cloudinary.com/documentation/cloudinary_llm_mcp", "serverUrl": "https://asset-management.mcp.cloudinary.com/mcp", "authMode": "dcr", "prerequisite": "A Cloudinary account; authorization is limited by the signed-in user's roles.", "riskTier": "S3" }, { "slug": "coda", "name": "Coda", "wave": 1, "status": "self_serve", "docsUrl": "https://help.coda.io/hc/en-us/articles/44722769665549-Security-recommendations-for-the-Coda-MCP", "serverUrl": "https://coda.io/apis/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Coda account; the hosted MCP service is currently beta.", "riskTier": "S3" }, - { "slug": "hugging-face", "name": "Hugging Face", "wave": 1, "status": "self_serve", "docsUrl": "https://huggingface.co/docs/hub/agents-mcp", "serverUrl": "https://huggingface.co/mcp?login&gradio=none", "authMode": "dcr_cimd", "prerequisite": "A Hugging Face account.", "riskTier": "S2" }, + { "slug": "hugging-face", "name": "Hugging Face", "wave": 2, "status": "self_serve", "docsUrl": "https://huggingface.co/docs/hub/agents-mcp", "serverUrl": "https://huggingface.co/mcp", "authMode": "public", "prerequisite": "No account, OAuth grant, or API key is needed for anonymous public Hub lookup. Hugging Face says anonymous requests are rate-limited.", "riskTier": "S1", "verifiedAt": "2026-10-09", "browserOnboarding": "completed-public-no-auth", "liveRead": "passed in Paperclip UI: hf_fs search hf://docs MCP --limit 3 returned public documentation; repeated after reload (0.7s, 0.8s). UI discovery showed 4 Read / 0 Write and no agents can use the connection.", "liveWrite": "not-applicable", "blocker": null }, { "slug": "kernel", "name": "Kernel", "wave": 1, "status": "self_serve", "docsUrl": "https://www.kernel.sh/docs/reference/mcp-server/", "serverUrl": "https://mcp.onkernel.com/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Kernel account with either browser authorization or an API key.", "riskTier": "S3" }, { "slug": "local-falcon", "name": "Local Falcon", "wave": 1, "status": "self_serve", "docsUrl": "https://docs.localfalcon.com/", "serverUrl": "https://mcp.localfalcon.com", "authMode": "dcr", "prerequisite": "A Local Falcon account with MCP access.", "riskTier": "S2" }, { "slug": "make", "name": "Make", "wave": 1, "status": "self_serve", "docsUrl": "https://developers.make.com/mcp-server", "serverUrl": "https://mcp.make.com", "authMode": "dcr", "prerequisite": "A Make account and access to the scenarios exposed to MCP.", "riskTier": "S3" }, @@ -21,7 +21,7 @@ { "slug": "oreilly", "name": "O'Reilly", "wave": 1, "status": "self_serve", "docsUrl": "https://learning.oreilly.com/apidocs/mcp/content/", "serverUrl": "https://api.oreilly.com/api/content-discovery/v1/mcp/", "authMode": "dcr_or_api_key", "prerequisite": "An O'Reilly Learning subscription with MCP or API access.", "riskTier": "S2" }, { "slug": "planetscale", "name": "PlanetScale", "wave": 1, "status": "self_serve", "docsUrl": "https://planetscale.com/docs/connect/mcp", "serverUrl": "https://mcp.pscale.dev/mcp/planetscale", "authMode": "dcr", "prerequisite": "A PlanetScale account; database and branch access are chosen during authorization.", "riskTier": "S4" }, { "slug": "posthog", "name": "PostHog", "wave": 1, "status": "self_serve", "docsUrl": "https://posthog.com/docs/model-context-protocol", "serverUrl": "https://mcp.posthog.com/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A PostHog account; personal API keys and optional organization or project pinning are supported as alternatives and advanced controls.", "riskTier": "S3" }, - { "slug": "resend", "name": "Resend", "wave": 1, "status": "self_serve", "docsUrl": "https://resend.com/changelog/remote-mcp-server", "serverUrl": "https://mcp.resend.com/mcp", "authMode": "dcr", "prerequisite": "A Resend account with access to the relevant domains.", "riskTier": "S3" }, + { "slug": "resend", "name": "Resend", "wave": 2, "status": "self_serve", "docsUrl": "https://resend.com/changelog/remote-mcp-server", "serverUrl": "https://mcp.resend.com/mcp", "authMode": "dcr", "prerequisite": "A Resend team account. OAuth full_access grants broad resource read/write/delete and sending capability; review the discovered actions and configure Paperclip access before use.", "riskTier": "S4", "verifiedAt": "2026-10-09", "browserOnboarding": "completed-oauth", "liveRead": "passed: List Domains succeeded twice via the Paperclip Permissions Test dialog as Connector Tester; each returned one entry. No provider-returned domain content is retained here.", "liveWrite": "not-run", "blocker": null, "evidenceLimit": "The live test used Paperclip's synthetic Connector Tester, not a real agent runtime. Final reloaded Paperclip policy showed all 55 Read and 77 Write actions Off, Just me, and Just agents I pick with zero selected. No provider write, delete, or email send occurred. OAuth scope full_access remains provider-wide; disabling Paperclip actions does not narrow the granted provider token." }, { "slug": "sentry", "name": "Sentry", "wave": 1, "status": "self_serve", "docsUrl": "https://mcp.sentry.dev/.well-known/oauth-authorization-server", "serverUrl": "https://mcp.sentry.dev/mcp", "authMode": "dcr_cimd", "prerequisite": "A Sentry account with access to the relevant organizations and projects.", "riskTier": "S3" }, { "slug": "ticktick", "name": "TickTick", "wave": 1, "status": "self_serve", "docsUrl": "https://help.ticktick.com/articles/7438129581631995904", "serverUrl": "https://mcp.ticktick.com", "authMode": "dcr", "prerequisite": "A TickTick account with MCP access.", "riskTier": "S3" }, { "slug": "todoist", "name": "Todoist", "wave": 1, "status": "self_serve", "docsUrl": "https://developer.todoist.com/", "serverUrl": "https://ai.todoist.net/mcp", "authMode": "dcr", "prerequisite": "A Todoist account.", "riskTier": "S3" }, @@ -64,6 +64,10 @@ { "slug": "firecrawl", "name": "Firecrawl", "wave": 3, "status": "self_serve", "docsUrl": "https://docs.firecrawl.dev/mcp-server", "serverUrl": "https://mcp.firecrawl.dev/v2/mcp-oauth", "authMode": "dcr_or_api_key", "prerequisite": "Sign in and choose the Firecrawl team whose usage should apply. This route exposes the account-authorized tool surface; review paid jobs and action policies before execution.", "riskTier": "S4", "verifiedAt": "2026-10-08" }, { "slug": "parallel-search", "name": "Parallel Search", "wave": 3, "status": "self_serve", "docsUrl": "https://docs.parallel.ai/integrations/mcp/search-mcp", "serverUrl": "https://search.parallel.ai/mcp", "authMode": "public", "prerequisite": "The public route exposes web_search and web_fetch. Keep source URLs, excerpts, and retrieval context in the result; this is separate from Parallel Task MCP.", "riskTier": "S3", "verifiedAt": "2026-10-08" }, { "slug": "windsor-ai", "name": "Windsor.ai", "wave": 3, "status": "self_serve", "docsUrl": "https://mcp.windsor.ai/", "serverUrl": "https://mcp.windsor.ai/", "authMode": "dcr_or_api_key", "prerequisite": "Connect Windsor.ai, then verify which advertising, analytics, CRM, or warehouse sources are linked. Keep source account and metric definitions with results. Review any action that changes campaigns, budgets, posts, or listings.", "riskTier": "S4", "verifiedAt": "2026-10-08" }, - { "slug": "gsc-wizard", "name": "GSC Wizard", "wave": 3, "status": "self_serve", "docsUrl": "https://www.gscwizard.com/claude-search-console-mcp.html", "serverUrl": "https://mcp.gscwizard.com/mcp", "authMode": "dcr_or_api_key", "prerequisite": "Connect the GSC Wizard account and its authorized properties. Check site identity and date bounds before querying; review any URL submission or property-changing action separately.", "riskTier": "S3", "verifiedAt": "2026-10-08" } + { "slug": "gsc-wizard", "name": "GSC Wizard", "wave": 3, "status": "self_serve", "docsUrl": "https://www.gscwizard.com/claude-search-console-mcp.html", "serverUrl": "https://mcp.gscwizard.com/mcp", "authMode": "dcr_or_api_key", "prerequisite": "Connect the GSC Wizard account and its authorized properties. Check site identity and date bounds before querying; review any URL submission or property-changing action separately.", "riskTier": "S3", "verifiedAt": "2026-10-08" }, + { "slug": "clickup", "name": "ClickUp", "wave": 1, "status": "self_serve", "docsUrl": "https://developer.clickup.com/docs/connect-an-ai-assistant-to-clickups-mcp-server", "serverUrl": "https://mcp.clickup.com/mcp", "authMode": "dcr", "prerequisite": "Sign in to a ClickUp account and select only the intended workspace. Actions remain bounded by the signed-in user’s ClickUp permissions; MCP calls may be subject to workspace-plan rate limits.", "riskTier": "S4", "verifiedAt": "2026-10-09", "browserOnboarding": "blocked-login", "liveRead": "not-run", "blocker": "Fresh and retained Google sign-in both returned to the blank ClickUp login form with Password required" }, + { "slug": "klaviyo", "name": "Klaviyo", "wave": 2, "status": "self_serve", "docsUrl": "https://developers.klaviyo.com/en/docs/connect_to_the_klaviyo_mcp_server", "serverUrl": "https://mcp.klaviyo.com/mcp?toolsets=campaigns:read,campaigns:write,metrics:read,segments:read", "authMode": "dcr", "prerequisite": "Hosted MCP requires a Klaviyo Owner, Admin, or Manager role. The server's toolsets parameter selects campaign read/write, metrics read, and segment read catalog families; exact authenticated actions still need review.", "riskTier": "S4", "verifiedAt": "2026-10-09", "browserOnboarding": "pending-client-approval", "liveRead": "not-run", "blocker": "Initial client authorization awaits user approval; account and scopes not yet enumerated" }, + { "slug": "monday", "name": "monday.com", "wave": 2, "status": "blocked", "docsUrl": "https://developer.monday.com/api-reference/docs/integrate-with-monday-mcp", "serverUrl": "https://mcp.monday.com/mcp", "authMode": "provider_approval", "prerequisite": "Provider DCR rejects the Paperclip HTTPS callback with HTTP 400: redirect_uris are not allowed. Contact monday.com to allowlist the callback before OAuth setup; account-admin approval may also be needed.", "riskTier": "S4", "verifiedAt": "2026-10-09", "browserOnboarding": "blocked-callback-allowlist", "liveRead": "not-run", "blocker": "Provider callback allowlisting required" }, + { "slug": "context7", "name": "Context7", "wave": 2, "status": "self_serve", "docsUrl": "https://context7.com/docs/api-guide", "serverUrl": "https://mcp.context7.com/mcp", "authMode": "public", "prerequisite": "No account or API key is required for anonymous remote MCP use; the official docs state anonymous requests have low rate limits. An optional API key raises limits and supports private repositories, but this curated method does not request or store one.", "riskTier": "S1", "verifiedAt": "2026-10-09", "browserOnboarding": "completed-public-no-auth", "liveRead": "passed: resolve-library-id and query-docs returned React public documentation; repeat query passed after reload with 0 agents selected", "liveWrite": "not-applicable", "blocker": null } ] } diff --git a/scripts/app-definition-overrides/bitly.json b/scripts/app-definition-overrides/bitly.json new file mode 100644 index 00000000000..6adff77870b --- /dev/null +++ b/scripts/app-definition-overrides/bitly.json @@ -0,0 +1,84 @@ +{ + "schemaVersion": 1, + "slug": "bitly", + "name": "Bitly", + "description": "Connect Bitly's provider-hosted MCP server.", + "categories": [ + "analytics" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/bitly.svg" + }, + "urlPatterns": [ + "https://api-ssl.bitly.com/*" + ], + "docsUrl": "https://dev.bitly.com/bitly-mcp/overview/quickstart/", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", + "defaults": { + "serverUrl": "https://api-ssl.bitly.com/v4/mcp", + "scopesHint": [ + "mcp.all" + ] + }, + "guidanceMd": "Connect Bitly in the browser. Choose only the intended workspace. Review the discovered actions and keep writes and destructive operations disabled until explicitly needed.", + "riskTier": "S4", + "label": "Sign in with Bitly", + "consoleLinks": { + "docs": "https://dev.bitly.com/bitly-mcp/using-the-bitly-mcp-server/configuration-details/" + }, + "warnings": [ + "The mcp.all scope covers Bitly's broad MCP catalog, including link and QR management, analytics, groups, custom domains, and Bitly Sites.", + "The catalog includes publishing and deletion operations, including permanent short-link deletion, site deletion, and custom-domain changes. Review the full catalog and keep write and destructive actions Off during read-only qualification." + ] + }, + { + "key": "mcp-api-key", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a customer-created Bitly API token when browser sign-in is not suitable.", + "defaults": { + "serverUrl": "https://api-ssl.bitly.com/v4/mcp" + }, + "guidanceMd": "Generate a Bitly API token under Settings → API. The token may authorize the MCP's full catalog under your Bitly account; Paperclip cannot narrow the token. Review discovered actions and keep writes and destructive operations disabled until explicitly needed.", + "riskTier": "S4", + "label": "Use an API key", + "credentialFields": [ + { + "key": "authorization", + "label": "Bitly API key", + "type": "password", + "required": true, + "placeholder": "Paste your Bitly API token", + "secret": true, + "helperMd": "Bitly's API token may authorize the MCP's broad catalog under your account. Paperclip cannot narrow the token; keep write and destructive actions Off unless you explicitly need them." + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "consoleLinks": { + "keys": "https://bitly.com/settings/api", + "docs": "https://dev.bitly.com/bitly-mcp/using-the-bitly-mcp-server/configuration-details/" + }, + "warnings": [ + "This API token is not documented as a restricted MCP token. It may authorize Bitly's broad MCP catalog, including link creation/update/deletion, QR codes, Sites publishing/deletion, and custom-domain operations.", + "Review the discovered catalog and keep write and destructive actions Off during read-only qualification." + ] + } + ] +} diff --git a/scripts/app-definition-overrides/clickup.json b/scripts/app-definition-overrides/clickup.json new file mode 100644 index 00000000000..2367ba3afc2 --- /dev/null +++ b/scripts/app-definition-overrides/clickup.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 1, + "slug": "clickup", + "name": "ClickUp", + "description": "Search ClickUp workspaces and manage tasks, docs, and conversations through the official MCP server.", + "categories": [ + "productivity" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/clickup.png" + }, + "urlPatterns": [ + "https://mcp.clickup.com/*" + ], + "docsUrl": "https://developer.clickup.com/docs/connect-an-ai-assistant-to-clickups-mcp-server", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "label": "Sign in with ClickUp", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "grantKinds": [ + "user" + ], + "whenToUse": "Connect the provider-hosted ClickUp MCP server with browser sign-in.", + "defaults": { + "serverUrl": "https://mcp.clickup.com/mcp", + "scopesHint": [ + "read", + "write" + ] + }, + "guidanceMd": "Choose only the ClickUp workspace needed. Confirm the target list and task before changing content, starting a timer, posting a chat message, or deleting anything.", + "warnings": [ + "ClickUp's MCP may expose task, document, and chat writes, time tracking, and task deletion under the signed-in user's permissions. Review the catalog and keep write and destructive actions disabled until explicitly needed.", + "Workspace usage may be subject to plan-based rolling daily MCP limits." + ], + "consoleLinks": { + "docs": "https://developer.clickup.com/docs/connect-an-ai-assistant-to-clickups-mcp-server" + }, + "riskTier": "S4", + "requiredResourceFilters": [ + "workspace", + "list" + ] + } + ] +} diff --git a/scripts/app-definition-overrides/context7.json b/scripts/app-definition-overrides/context7.json new file mode 100644 index 00000000000..2ae85474ae8 --- /dev/null +++ b/scripts/app-definition-overrides/context7.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 1, + "slug": "context7", + "name": "Context7", + "description": "Search public library documentation and retrieve current code examples.", + "categories": [ + "developer" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/context7.svg", + "darkLogoUrl": "/brands/apps/context7-dark.svg" + }, + "urlPatterns": [ + "https://mcp.context7.com/*" + ], + "docsUrl": "https://context7.com/docs/api-guide", + "methods": [ + { + "key": "mcp", + "transport": "mcp_remote", + "auth": "none", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use the public hosted MCP server for read-only library documentation lookups.", + "defaults": { + "serverUrl": "https://mcp.context7.com/mcp" + }, + "guidanceMd": "Connect Context7 to resolve public library IDs and retrieve current documentation. Anonymous access is supported with low rate limits; this method does not configure an API key. Keep queries limited to public documentation questions and do not include credentials, personal data, or proprietary code.", + "riskTier": "S1", + "consoleLinks": { + "docs": "https://context7.com/docs/api-guide" + }, + "warnings": [ + "Context7 processes the query text. Do not include secrets, personal information, or proprietary code.", + "Anonymous requests have low rate limits. Provider documentation says an API key raises limits and supports private repositories; this public method does not configure a key." + ] + } + ] +} diff --git a/scripts/app-definition-overrides/hugging-face.json b/scripts/app-definition-overrides/hugging-face.json new file mode 100644 index 00000000000..6567ad1e62c --- /dev/null +++ b/scripts/app-definition-overrides/hugging-face.json @@ -0,0 +1,69 @@ +{ + "schemaVersion": 1, + "slug": "hugging-face", + "name": "Hugging Face", + "description": "Connect Hugging Face's provider-hosted MCP server.", + "categories": [ + "ai" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/hugging-face.svg" + }, + "urlPatterns": [ + "https://huggingface.co/*" + ], + "docsUrl": "https://huggingface.co/docs/hub/agents-mcp", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", + "defaults": { + "serverUrl": "https://huggingface.co/mcp?login&gradio=none", + "scopesHint": [ + "read-mcp", + "read-repos", + "contribute-repos", + "jobs" + ] + }, + "guidanceMd": "Connect Hugging Face in the browser. A Hugging Face account.", + "riskTier": "S2", + "label": "Sign in with Hugging Face", + "consoleLinks": { + "docs": "https://huggingface.co/docs/hub/agents-mcp" + }, + "warnings": [ + "A Hugging Face account." + ] + }, + { + "key": "public-mcp", + "transport": "mcp_remote", + "auth": "none", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use the anonymous hosted MCP endpoint for public Hugging Face Hub lookups.", + "defaults": { + "serverUrl": "https://huggingface.co/mcp" + }, + "guidanceMd": "This anonymous method exposes the public Hugging Face Hub read tools for repository search, public repository details, datasets, and documentation. It does not require an account or API key and is rate-limited. Keep queries to public information; do not include secrets, personal data, private repository names, or proprietary content.", + "riskTier": "S1", + "label": "Use public Hugging Face Hub", + "consoleLinks": { + "docs": "https://huggingface.co/docs/hub/agents-mcp" + }, + "warnings": [ + "Anonymous requests are rate-limited by Hugging Face.", + "This method only advertises read tools for the public Hub; do not include secrets, personal data, private repository names, or proprietary content in queries." + ] + } + ] +} diff --git a/scripts/app-definition-overrides/klaviyo.json b/scripts/app-definition-overrides/klaviyo.json new file mode 100644 index 00000000000..839d800d676 --- /dev/null +++ b/scripts/app-definition-overrides/klaviyo.json @@ -0,0 +1,59 @@ +{ + "schemaVersion": 1, + "slug": "klaviyo", + "name": "Klaviyo", + "description": "Review Klaviyo campaign performance and prepare campaign changes for review.", + "categories": [ + "commerce" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/klaviyo.png" + }, + "urlPatterns": [ + "https://mcp.klaviyo.com/*" + ], + "docsUrl": "https://developers.klaviyo.com/en/docs/connect_to_the_klaviyo_mcp_server", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "label": "Sign in with Klaviyo", + "transport": "mcp_remote", + "auth": "oauth", + "oauthClientRegistration": "dcr", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Connect to the provider-hosted Klaviyo MCP server with browser sign-in.", + "defaults": { + "serverUrl": "https://mcp.klaviyo.com/mcp?toolsets=campaigns:read,campaigns:write,metrics:read,segments:read" + }, + "guidanceMd": "Review campaign and metric data, then prepare campaign changes for human review. Campaign write access may expose sends and other state changes; never send or schedule a campaign without explicit authorization and an allowed Paperclip action.", + "riskTier": "S4", + "tenantFields": [ + { + "key": "readOnly", + "label": "Read-only mode", + "type": "checkbox", + "advanced": true, + "defaultValue": false, + "helperMd": "Turn on to remove Klaviyo MCP tools that can modify account data. This provider-side restriction is separate from Paperclip action permissions.", + "transport": { + "location": "query", + "name": "read-only", + "format": "boolean", + "omitFalse": true + } + } + ], + "consoleLinks": { + "docs": "https://developers.klaviyo.com/en/docs/connect_to_the_klaviyo_mcp_server" + }, + "warnings": [ + "Klaviyo's remote MCP requires an Owner, Admin, or Manager account role.", + "The campaigns:write toolset may expose campaign sending and other writes. Keep write actions Off until reviewed; the provider's Advanced read-only mode is optional and does not replace Paperclip action permissions." + ] + } + ] +} diff --git a/scripts/app-definition-overrides/monday.json b/scripts/app-definition-overrides/monday.json new file mode 100644 index 00000000000..fa8e7d607fa --- /dev/null +++ b/scripts/app-definition-overrides/monday.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 1, + "slug": "monday", + "name": "monday.com", + "description": "Read and manage boards and work items in monday.com.", + "categories": [ + "productivity" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/monday.png", + "darkLogoUrl": "/brands/apps/monday-dark.png" + }, + "urlPatterns": [ + "https://mcp.monday.com/*" + ], + "docsUrl": "https://developer.monday.com/api-reference/docs/integrate-with-monday-mcp", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "label": "Sign in with monday.com", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Connect the hosted monday.com MCP server using OAuth and dynamic client registration.", + "defaults": { + "serverUrl": "https://mcp.monday.com/mcp" + }, + "guidanceMd": "Use the selected monday.com account only. Read the target board and its columns before acting, and verify the exact board and item before any change. Keep write and destructive actions Off unless the user explicitly asks for a specific change. Never send messages, publish, or trigger automations through a board action without separate authorization.", + "warnings": [ + "The hosted MCP server acts as the authorizing monday.com user and inherits that user's monday.com permissions. An account admin may also restrict MCP access to selected workspaces.", + "An additional OAuth/DCR connection can require account-admin approval for Paperclip and its exact callback URL before user authorization completes. If approval is pending, reconnect after the account admin approves it.", + "monday.com personal API tokens are documented for personal use, prototyping, and testing, and the provider says they are not intended for integrations used by other people. This connector uses the OAuth/DCR route for additional MCP clients; account-level approval may be required.", + "The tool catalog includes actions that can send notifications (optionally email), delete or activate automations, publish workflows, and create or run monday.com platform agents; a low-level tool can execute GraphQL mutations. Keep these actions Off unless separately authorized, and never run agents or change automations during qualification.", + "MCP calls count toward the account's monday.com API call limits. Keep tests bounded and avoid repeated discovery or broad queries." + ], + "consoleLinks": { + "docs": "https://developer.monday.com/api-reference/docs/integrate-with-monday-mcp" + }, + "riskTier": "S4" + } + ] +} diff --git a/scripts/app-definition-overrides/resend.json b/scripts/app-definition-overrides/resend.json new file mode 100644 index 00000000000..38f7595265f --- /dev/null +++ b/scripts/app-definition-overrides/resend.json @@ -0,0 +1,45 @@ +{ + "schemaVersion": 1, + "slug": "resend", + "name": "Resend", + "description": "Connect Resend's provider-hosted MCP server.", + "categories": [ + "communication" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/resend.svg", + "darkLogoUrl": "/brands/apps/resend-dark.svg" + }, + "urlPatterns": [ + "https://mcp.resend.com/*" + ], + "docsUrl": "https://resend.com/changelog/remote-mcp-server", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", + "defaults": { + "serverUrl": "https://mcp.resend.com/mcp", + "scopesHint": [ + "full_access" + ] + }, + "guidanceMd": "Sign in to the intended Resend team. The full_access grant covers reading and managing account resources, including sending email and deletion. Review the discovered actions in Paperclip before using them.", + "riskTier": "S4", + "label": "Sign in with Resend", + "consoleLinks": { + "docs": "https://resend.com/changelog/remote-mcp-server" + }, + "warnings": [ + "Resend full_access grants broad account read/write/delete access; Paperclip action policies govern execution separately." + ] + } + ] +} diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index fac6f6ec09f..97a9be58ec2 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -12563,122 +12563,147 @@ describeEmbeddedPostgres("tool access service", () => { expect(JSON.stringify(completed)).not.toContain("supabase-dcr-secret"); }); - it("preserves the provider's DCR client-auth ordering for Miro token exchange", async () => { - vi.stubEnv("PAPERCLIP_TOOL_OAUTH_MIRO_CLIENT_ID", ""); - vi.stubEnv("PAPERCLIP_TOOL_OAUTH_MIRO_CLIENT_SECRET", ""); - vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); - vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", ""); - const company = await createCompany(db); - await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); - const service = createTestToolAccessService(db); - const connected = await service.connectGalleryApp(company.id, { - galleryKey: "miro", - connectionMethodKey: "mcp-oauth", - name: "Miro DCR", - }); - const redirectUri = "https://paperclip.example/api/tools/oauth/callback"; - vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { - const href = String(url); - if ( - href === "https://mcp.miro.com/.well-known/oauth-protected-resource" - ) { - return mcpHttpResponse({ - resource: "https://mcp.miro.com/", - authorization_servers: ["https://mcp.miro.com/"], - }); + it.each(["default", "none", "agent", "company"] as const)( + "negotiates JSON OAuth tokens and preserves MCP agent reach (%s)", async (reach) => { + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_MIRO_CLIENT_ID", ""); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_MIRO_CLIENT_SECRET", ""); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", ""); + const company = await createCompany(db); + await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); + const service = createTestToolAccessService(db); + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "miro", + connectionMethodKey: "mcp-oauth", + name: "Miro DCR", + }); + const agent = await createAgent(db, company.id); + const installs = reach === "company" || reach === "default" + ? [{ targetType: "company" as const, targetId: company.id }] + : reach === "agent" + ? [{ targetType: "agent" as const, targetId: agent.id }] + : []; + if (reach !== "default") { + await service.putConnectionInstalls(connected.connectionId, { installs }); } - if ( - href === "https://mcp.miro.com/.well-known/oauth-authorization-server" - ) { - return mcpHttpResponse({ - issuer: "https://mcp.miro.com/", - authorization_endpoint: "https://mcp.miro.com/authorize", - token_endpoint: "https://mcp.miro.com/token", - registration_endpoint: "https://mcp.miro.com/register", - grant_types_supported: ["authorization_code", "refresh_token"], - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: [ + const redirectUri = "https://paperclip.example/api/tools/oauth/callback"; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + const href = String(url); + if ( + href === "https://mcp.miro.com/.well-known/oauth-protected-resource" + ) { + return mcpHttpResponse({ + resource: "https://mcp.miro.com/", + authorization_servers: ["https://mcp.miro.com/"], + }); + } + if ( + href === "https://mcp.miro.com/.well-known/oauth-authorization-server" + ) { + return mcpHttpResponse({ + issuer: "https://mcp.miro.com/", + authorization_endpoint: "https://mcp.miro.com/authorize", + token_endpoint: "https://mcp.miro.com/token", + registration_endpoint: "https://mcp.miro.com/register", + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: [ + "client_secret_post", + "client_secret_basic", + ], + }); + } + if (href === "https://mcp.miro.com/register") { + const requestBody = JSON.parse(String(init?.body)) as Record< + string, + unknown + >; + expect(requestBody.token_endpoint_auth_method).toBe( "client_secret_post", - "client_secret_basic", - ], - }); - } - if (href === "https://mcp.miro.com/register") { - const requestBody = JSON.parse(String(init?.body)) as Record< - string, - unknown - >; - expect(requestBody.token_endpoint_auth_method).toBe( - "client_secret_post", - ); - return mcpHttpResponse({ - client_id: "miro-dcr-client", - client_secret: "miro-dcr-secret", - redirect_uris: [redirectUri], - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - token_endpoint_auth_method: "client_secret_post", - }); - } - if (href === "https://mcp.miro.com/token") { - const headers = new Headers(init?.headers); - const body = init?.body as URLSearchParams; - expect(headers.get("authorization")).toBeNull(); - expect(body.get("client_id")).toBe("miro-dcr-client"); - expect(body.get("client_secret")).toBe("miro-dcr-secret"); - expect(body.get("code")).toBe("miro-code"); - return mcpHttpResponse({ - access_token: "miro-access-token", - refresh_token: "miro-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }); - } - if (href === "https://mcp.miro.com/") { - expect(new Headers(init?.headers).get("authorization")).toBe( - "Bearer miro-access-token", - ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [{ name: "whoami", annotations: { readOnlyHint: true } }], - }, - }); - } - throw new Error(`unexpected fetch ${href}`); - }); + ); + return mcpHttpResponse({ + client_id: "miro-dcr-client", + client_secret: "miro-dcr-secret", + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "client_secret_post", + }); + } + if (href === "https://mcp.miro.com/token") { + const headers = new Headers(init?.headers); + const body = init?.body as URLSearchParams; + // Match providers that negotiate JSON only when explicitly requested. + if (headers.get("accept") !== "application/json") { + return new Response("access_token=miro-access-token", { + headers: { "content-type": "application/x-www-form-urlencoded" }, + }); + } + expect(headers.get("authorization")).toBeNull(); + expect(body.get("client_id")).toBe("miro-dcr-client"); + expect(body.get("client_secret")).toBe("miro-dcr-secret"); + expect(body.get("code")).toBe("miro-code"); + return mcpHttpResponse({ + access_token: "miro-access-token", + refresh_token: "miro-refresh-token", + expires_in: 3600, + token_type: "Bearer", + }); + } + if (href === "https://mcp.miro.com/") { + expect(new Headers(init?.headers).get("authorization")).toBe( + "Bearer miro-access-token", + ); + return mcpHttpResponse({ + jsonrpc: "2.0", + id: "paperclip-catalog-refresh", + result: { + tools: [{ name: "whoami", annotations: { readOnlyHint: true } }], + }, + }); + } + throw new Error(`unexpected fetch ${href}`); + }); - const started = await service.startOAuth( - company.id, - connected.connectionId, - { + const started = await service.startOAuth( + company.id, + connected.connectionId, + { + redirectUri, + actor: { actorType: "user", actorId: "board" }, + }, + ); + const state = new URL(started.authorizationUrl).searchParams.get("state"); + expect(state).toBeTruthy(); + const completed = await service.completeOAuthCallback({ + state: state!, + code: "miro-code", redirectUri, actor: { actorType: "user", actorId: "board" }, - }, - ); - const state = new URL(started.authorizationUrl).searchParams.get("state"); - expect(state).toBeTruthy(); - const completed = await service.completeOAuthCallback({ - state: state!, - code: "miro-code", - redirectUri, - actor: { actorType: "user", actorId: "board" }, - }); + }); - expect(completed.actions.readOnly).toEqual([ - expect.objectContaining({ toolName: "whoami", riskLevel: "read" }), - ]); - const [connection] = await db - .select() - .from(toolConnections) - .where(eq(toolConnections.id, connected.connectionId)); - expect(connection.config).toMatchObject({ - oauth: { clientTokenEndpointAuthMethod: "client_secret_post" }, - }); - expect(JSON.stringify(connection.config)).not.toContain("miro-dcr-secret"); - expect(JSON.stringify(completed)).not.toContain("miro-dcr-secret"); - }); + expect(completed.actions.readOnly).toEqual([ + expect.objectContaining({ toolName: "whoami", riskLevel: "read" }), + ]); + const [connection] = await db + .select() + .from(toolConnections) + .where(eq(toolConnections.id, connected.connectionId)); + expect(connection.config).toMatchObject({ + oauth: { clientTokenEndpointAuthMethod: "client_secret_post" }, + }); + expect(JSON.stringify(connection.config)).not.toContain("miro-dcr-secret"); + expect(JSON.stringify(completed)).not.toContain("miro-dcr-secret"); + const installed = await db.select().from(toolConnectionInstalls) + .where(eq(toolConnectionInstalls.connectionId, connected.connectionId)); + expect(installed.map(({ targetType, targetId }) => ({ targetType, targetId }))).toEqual(installs); + const [profile] = await db.select().from(toolProfiles) + .where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`)); + const bindings = await db.select().from(toolProfileBindings) + .where(eq(toolProfileBindings.profileId, profile.id)); + expect(bindings.map(({ targetType, targetId }) => ({ targetType, targetId }))).toEqual(installs); + }, + ); it("accepts provider-added DCR grants without adopting them", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_HUGGING_FACE_CLIENT_ID", ""); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 108f885efa6..ee5ca187644 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -10664,6 +10664,8 @@ export function toolAccessService( }); const headers: Record = { "content-type": "application/x-www-form-urlencoded", + // Some providers otherwise return a legacy form-encoded token response. + accept: "application/json", }; if (tokenEndpointAuthMethod === "client_secret_basic") { if (!input.clientSecret) { @@ -15348,7 +15350,13 @@ export function toolAccessService( : suggestedAgentIds.length > 0 ? { agentIds: suggestedAgentIds } : "all_agents"; - const remoteMcpAccess = isRemoteMcpConnectorMethod(input.connection.config.sourceTemplateKey, input.connection.config.connectionMethodKey); + // An explicitly saved empty Access selection means no agents. A missing + // selection must still use managed OAuth's subject/recommended defaults. + const remoteMcpAccess = isRemoteMcpConnectorMethod( + input.connection.config.sourceTemplateKey, + input.connection.config.connectionMethodKey, + ) || (input.connection.transport === "mcp_remote" + && input.connection.config.mcpAgentAccessConfigured === true); const access: FinishToolApp["access"] = deferTaskAccess ? { agentIds: [] } : installs.length === 0 @@ -18826,12 +18834,26 @@ export function toolAccessService( ); } } + const markMcpAccessConfigured = connection.transport === "mcp_remote" + && connection.config.mcpAgentAccessConfigured !== true; const accessExtensions: Array<{ targetType: "company" | "agent"; targetId: string; profileId: string; }> = []; await db.transaction(async (tx) => { + if (markMcpAccessConfigured) { + // Preserve an explicit zero-agent selection through OAuth. Merge the + // marker in SQL so concurrent credential/config changes are retained. + await tx.update(toolConnections).set({ + config: sql`${toolConnections.config} || '{"mcpAgentAccessConfigured":true}'::jsonb`, + transportConfig: sql`${toolConnections.transportConfig} || '{"mcpAgentAccessConfigured":true}'::jsonb`, + updatedAt: now(), + }).where(and( + eq(toolConnections.companyId, connection.companyId), + eq(toolConnections.id, connection.id), + )); + } const existing = await tx .select() .from(toolConnectionInstalls) @@ -18940,7 +18962,7 @@ export function toolAccessService( }); } } - if (removeIds.length > 0 || additions.length > 0) { + if (markMcpAccessConfigured || removeIds.length > 0 || additions.length > 0) { const binding = actorBinding(actor); await tx.insert(toolAccessAuditEvents).values({ companyId: connection.companyId, @@ -18951,6 +18973,7 @@ export function toolAccessService( outcome: "success", reasonCode: "installs_changed", details: { + ...(markMcpAccessConfigured ? { mcpAgentAccessConfigured: true } : {}), added: additions.map((install) => ({ targetType: install.targetType, targetId: install.targetId, diff --git a/tests/e2e/apps-prosumer-mcp-flow.spec.ts b/tests/e2e/apps-prosumer-mcp-flow.spec.ts index ddba28ec581..36f0698944a 100644 --- a/tests/e2e/apps-prosumer-mcp-flow.spec.ts +++ b/tests/e2e/apps-prosumer-mcp-flow.spec.ts @@ -178,11 +178,40 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => { // Verify the mock saw a tools/list call from the catalog refresh. expect(mock.captures.some((c) => c.method === "tools/list")).toBe(true); - // Return through the UI so a page reload cannot hide a stale catalog cache. + 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.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 15_000 }); - await expect(page.getByRole("heading", { name: `${new URL(mock.url).host} for the organization`, exact: true })).toBeVisible(); - await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-06-apps-list.png`, fullPage: true }); + 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 }) => { diff --git a/ui/public/brands/apps/clickup.png b/ui/public/brands/apps/clickup.png new file mode 100644 index 0000000000000000000000000000000000000000..fb813c8400dbcb5e7d55108d800c8bdb88a36f21 GIT binary patch literal 8660 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 literal 0 HcmV?d00001 diff --git a/ui/public/brands/apps/klaviyo.png b/ui/public/brands/apps/klaviyo.png new file mode 100644 index 0000000000000000000000000000000000000000..44e67218947961b7aba42128003332a2f0f46f78 GIT binary patch literal 3929 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 nEq%>u0LO;h*C=M*j3xUr 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 literal 0 HcmV?d00001 diff --git a/ui/public/brands/apps/monday.png b/ui/public/brands/apps/monday.png new file mode 100644 index 0000000000000000000000000000000000000000..2ef7cfb67d686abcbd800498e06574af318fded3 GIT binary patch literal 14581 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 literal 0 HcmV?d00001 diff --git a/ui/src/features/connections/ConnectionSetupFlow.oauth.test.ts b/ui/src/features/connections/ConnectionSetupFlow.oauth.test.ts index 80e2c62b587..8b7da25a7fb 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.oauth.test.ts +++ b/ui/src/features/connections/ConnectionSetupFlow.oauth.test.ts @@ -7,6 +7,7 @@ import { isConnectionDefinitionUnavailable, isVercelConnectUnavailable, readConnectionIntentOAuthOutcome, + retainedResumeMatches, retainedReconnectMatches, requestedConnectionSetupResolution, requestedConnectionEntry, @@ -91,12 +92,18 @@ describe("retained reconnect definition lookup", () => { requestedAppKey: "github", galleryApps: [], reconnectConnection: connection, + resumeConnection: null, + resumeApplication: null, + completedResumeConnectionId: null, applications: [githubApplication], })?.slug).toBe("github"); expect(requestedConnectionEntry({ requestedAppKey: "notion", galleryApps: [], reconnectConnection: connection, + resumeConnection: null, + resumeApplication: null, + completedResumeConnectionId: null, applications: [githubApplication], })).toBeNull(); }); @@ -141,6 +148,9 @@ describe("retained reconnect definition lookup", () => { requestedAppKey: "github", galleryApps: [], reconnectConnection: null, + resumeConnection: null, + resumeApplication: null, + completedResumeConnectionId: null, applications: [], })).toBeNull(); const visibleNotion = getConnectableAppDefinition("notion")!; @@ -148,10 +158,40 @@ describe("retained reconnect definition lookup", () => { requestedAppKey: "notion", galleryApps: [visibleNotion], reconnectConnection: null, + resumeConnection: null, + resumeApplication: null, + completedResumeConnectionId: null, applications: [], })).toBe(visibleNotion); }); + it.each([ + ["draft", true], + ["active", true], + ["disabled", false], + ["archived", false], + ] as const)("allows retained resume status %s only when appropriate", (status, expected) => { + const resumeConnection = { + id: "connection-1", + companyId: "company-1", + applicationId: "app-clickup", + status, + config: { sourceTemplateKey: "clickup" }, + } as unknown as ToolConnection; + const resumeApplication = { + id: "app-clickup", + companyId: "company-1", + metadata: { sourceTemplateKey: "clickup" }, + } as unknown as ToolApplication; + + expect(retainedResumeMatches({ + requestedAppKey: "clickup", + resumeConnection, + resumeApplication, + completedResumeConnectionId: "connection-1", + })).toBe(expected); + }); + it("does not apply fresh-setup availability to an exact retained reconnect", () => { expect(isVercelConnectUnavailable({ credentialSource: "vercel_connect", diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index d8dd7adcaf4..ef275af4245 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -81,7 +81,7 @@ import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHand import { redactUrlSecrets } from "@/lib/redact-url-secrets"; import { askFirstCatalogEntryIdsFor } from "./connection-defaults"; import { AppLogo } from "@/pages/apps/AppLogo"; -import { appApplicationSourceSlug } from "@/pages/apps/app-definition-display"; +import { appApplicationSourceSlug, appConnectionSourceSlug } from "@/pages/apps/app-definition-display"; import { UnverifiedServerBadge } from "@/pages/apps/UnverifiedServerBadge"; import { appSourceConnectHref, @@ -209,10 +209,19 @@ export function requestedConnectionEntry(input: { requestedAppKey: string; galleryApps: readonly AppDefinition[]; reconnectConnection: ToolConnection | null; + resumeConnection: ToolConnection | null; + resumeApplication: ToolApplication | null; + completedResumeConnectionId: string | null; applications: readonly ToolApplication[]; }): AppDefinition | null { const visible = input.galleryApps.find((candidate) => candidate.slug === input.requestedAppKey); if (visible) return visible; + if (retainedResumeMatches({ + requestedAppKey: input.requestedAppKey, + resumeConnection: input.resumeConnection, + resumeApplication: input.resumeApplication, + completedResumeConnectionId: input.completedResumeConnectionId, + })) return getConnectableAppDefinition(input.requestedAppKey); if (!input.reconnectConnection) return null; const application = input.applications.find( (candidate) => candidate.id === input.reconnectConnection?.applicationId, @@ -221,6 +230,27 @@ export function requestedConnectionEntry(input: { return getConnectableAppDefinition(input.requestedAppKey); } +export function retainedResumeMatches(input: { + requestedAppKey: string; + resumeConnection: ToolConnection | null; + resumeApplication: ToolApplication | null; + completedResumeConnectionId: string | null; +}): boolean { + const { requestedAppKey, resumeConnection, resumeApplication, completedResumeConnectionId } = input; + return Boolean( + resumeConnection + && ( + resumeConnection.status === "draft" + || (resumeConnection.status === "active" && resumeConnection.id === completedResumeConnectionId) + ) + && resumeApplication + && resumeConnection.applicationId === resumeApplication.id + && resumeConnection.companyId === resumeApplication.companyId + && appApplicationSourceSlug(resumeApplication) === requestedAppKey + && appConnectionSourceSlug(resumeConnection) === requestedAppKey, + ); +} + export function retainedReconnectMatches(input: { requestedAppKey: string | undefined; byo: boolean; @@ -728,6 +758,10 @@ function StandardConnectionSetupFlow({ const customerClientResumeRef = useRef(null); const hydratedResumeConnectionIdRef = useRef(null); const [hydratedResumeConnectionId, setHydratedResumeConnectionId] = useState(null); + // Finishing a resumed unauthenticated/API-key draft activates that exact row. + // Permit it through retained-draft validation only after this flow's own + // finish request succeeds; an initially active resume remains invalid. + const [completedResumeConnectionId, setCompletedResumeConnectionId] = useState(null); const oauthPopupRef = useRef(null); const [dialogOAuthConnectionId, setDialogOAuthConnectionId] = useState(null); const [authorizationFallbackUrl, setAuthorizationFallbackUrl] = useState(null); @@ -1172,6 +1206,26 @@ function StandardConnectionSetupFlow({ : null, [connectionsQuery.data, resumeConnectionId], ); + const resumeApplication = useMemo( + () => resumeConnection + ? (applicationsQuery.data?.applications ?? []).find( + (application) => application.id === resumeConnection.applicationId, + ) ?? null + : null, + [applicationsQuery.data, resumeConnection], + ); + const completedRouteResumeConnectionId = routeStage === "complete" + && resumeConnection?.status === "active" + ? resumeConnection.id + : null; + const resumeSourceMatches = requestedAppKey + ? retainedResumeMatches({ + requestedAppKey, + resumeConnection, + resumeApplication, + completedResumeConnectionId: completedResumeConnectionId ?? completedRouteResumeConnectionId, + }) + : false; const identityConnection = resumeConnection ?? reconnectConnection; const reconnectGrantKind: ConnectionGrantKind | null = identityConnection ? identityConnection.credentialPolicy === "per_user" @@ -1485,15 +1539,33 @@ function StandardConnectionSetupFlow({ useEffect(() => { if (!requestedAppKey || galleryQuery.isLoading || !galleryQuery.data) return; - if (reconnectConnectionId && ( + if ((resumeConnectionId || reconnectConnectionId) && ( !connectionsQuery.isFetchedAfterMount || !applicationsQuery.isFetchedAfterMount )) return; + // A resume URL is only a recovery route. Invalid or cross-provider drafts + // must remain on the blocked recovery screen instead of falling through to + // fresh setup for a hidden provider. + if ( + resumeConnectionId + && routeStage === "complete" + && resumeConnection?.status === "active" + && completedResumeConnectionId !== resumeConnectionId + && resumeSourceMatches + ) { + navigate(`/apps/${resumeConnection.id}/permissions`, { replace: true }); + return; + } + if (resumeConnectionId && !resumeSourceMatches) return; + const requestedEntry = requestedConnectionEntry({ requestedAppKey, galleryApps: galleryQuery.data.apps, reconnectConnection, + resumeConnection, + resumeApplication, + completedResumeConnectionId, applications: applicationsQuery.data?.applications ?? [], }); const requestedEntryAdvertisesManagedConnector = Boolean( @@ -1638,6 +1710,12 @@ function StandardConnectionSetupFlow({ reconnectConnection, reconnectConnectionId, reconnectSourceMatches, + resumeConnection, + resumeApplication, + resumeSourceMatches, + completedResumeConnectionId, + completedRouteResumeConnectionId, + routeStage, resumeConnectionId, fullRequestedDefinition, requestedAppKey, @@ -1756,6 +1834,9 @@ function StandardConnectionSetupFlow({ return finished; }, onSuccess: async (_finished, input) => { + if (resumeConnectionId && input.result.connectionId === resumeConnectionId) { + setCompletedResumeConnectionId(resumeConnectionId); + } await queryClient.invalidateQueries({ queryKey: ["tools"] }); await queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) }); setAppStep("success"); @@ -1826,6 +1907,26 @@ function StandardConnectionSetupFlow({ ); } + if ( + resumeConnectionId + && connectionsQuery.isFetchedAfterMount + && applicationsQuery.isFetchedAfterMount + && resumeConnection + && !resumeSourceMatches + ) { + return ( +

+

This setup can’t be resumed

+

+ This saved draft does not match the requested provider. The connection was not changed. +

+ +
+ ); + } + if ( reconnectConnectionId && connectionsQuery.isFetchedAfterMount @@ -2597,7 +2698,11 @@ function StandardConnectionSetupFlow({ installCount: installAgentIds.size, lockedAgentId: requestedAgentId ?? null, })} - onDone={onCancel ?? (() => navigate("/apps"))} + onDone={onCancel ?? (() => navigate( + host === "page" && connectResult?.connectionId + ? `/apps/${connectResult.connectionId}/permissions` + : "/apps", + ))} /> )} diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index 24fe9eb4568..10b6a08f3e4 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -2666,6 +2666,287 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { ); }); + it("resumes a hidden provider draft only after matching its company-visible application", async () => { + const connectionId = "22222222-2222-4222-8222-222222222222"; + mockSearch.value = `source=clickup&resume=${connectionId}`; + listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); + listApplicationsMock.mockResolvedValueOnce({ + applications: [{ + id: "app-clickup", + status: "active", + metadata: { sourceTemplateKey: "clickup" }, + }], + }); + listConnectionsMock.mockResolvedValueOnce({ + connections: [{ + id: connectionId, + applicationId: "app-clickup", + authKind: "oauth", + credentialPolicy: "per_user", + status: "draft", + config: { sourceTemplateKey: "clickup" }, + transportConfig: {}, + }], + }); + startOAuthMock.mockResolvedValueOnce({ + connectionId, + provider: "clickup", + authorizationUrl: "https://app.clickup.com/api?state=resumed", + expiresAt: "2099-01-01T00:00:00.000Z", + }); + + await render(); + await vi.waitFor(() => { + expect(container.textContent).toContain("Finish connecting ClickUp"); + }); + expect(getAppStoreDefinition("clickup")).toBeNull(); + await act(async () => { + buttonByText("Finish with ClickUp")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(connectAppMock).not.toHaveBeenCalled(); + expect(startOAuthMock).toHaveBeenCalledWith(connectionId, { asCurrentUser: true }); + }); + + it("keeps a resumed unauthenticated draft through its finish-time active transition", async () => { + const connectionId = "22222222-2222-4222-8222-222222222222"; + const draft = { + id: connectionId, + companyId: "company-1", + applicationId: "app-context7", + authKind: "none", + credentialPolicy: "shared", + status: "draft", + transport: "mcp_remote", + config: { sourceTemplateKey: "context7", connectionMethodKey: "mcp", url: "https://mcp.context7.com/mcp" }, + transportConfig: { sourceTemplateKey: "context7" }, + }; + mockSearch.value = `source=context7&resume=${connectionId}`; + listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); + listApplicationsMock.mockResolvedValue({ + applications: [{ + id: "app-context7", + companyId: "company-1", + status: "active", + metadata: { sourceTemplateKey: "context7" }, + }], + }); + listConnectionsMock + .mockResolvedValueOnce({ connections: [draft] }) + .mockResolvedValue({ connections: [{ ...draft, status: "active" }] }); + connectAppMock.mockResolvedValueOnce({ + connectionId, + connection: draft, + application: { name: "Context7" }, + catalog: [{ id: "tool-context7", name: "resolve-library-id", status: "active" }], + actions: { readOnly: [{ catalogEntryId: "tool-context7", riskLevel: "read" }], canMakeChanges: [] }, + }); + + await render(); + await vi.waitFor(() => expect(container.textContent).toContain("Connect Context7")); + await act(async () => { + buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await vi.waitFor(() => expect(finishAppMock).toHaveBeenCalledOnce()); + await vi.waitFor(() => { + expect(container.textContent).not.toContain("This setup can’t be resumed"); + expect(container.textContent).toContain("Context7 is ready."); + }); + + expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ resumeConnectionId: connectionId })); + expect(finishAppMock).toHaveBeenCalledWith("company-1", connectionId, expect.any(Object)); + expect(startOAuthMock).not.toHaveBeenCalled(); + await act(async () => { + buttonByText("View connection")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(mockNavigate).toHaveBeenLastCalledWith(`/apps/${connectionId}/permissions`); + }); + + it("restores a valid completed resume URL to the exact connection permissions page", async () => { + const connectionId = "22222222-2222-4222-8222-222222222222"; + mockSearch.value = `source=clickup&stage=complete&resume=${connectionId}`; + listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); + listApplicationsMock.mockResolvedValueOnce({ + applications: [{ + id: "app-clickup", + companyId: "company-1", + status: "active", + metadata: { sourceTemplateKey: "clickup" }, + }], + }); + listConnectionsMock.mockResolvedValueOnce({ + connections: [{ + id: connectionId, + companyId: "company-1", + applicationId: "app-clickup", + authKind: "oauth", + credentialPolicy: "per_user", + status: "active", + config: { sourceTemplateKey: "clickup" }, + transportConfig: { sourceTemplateKey: "clickup" }, + }], + }); + + await render(); + await vi.waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(`/apps/${connectionId}/permissions`, { replace: true }); + }); + expect(connectAppMock).not.toHaveBeenCalled(); + expect(finishAppMock).not.toHaveBeenCalled(); + expect(startOAuthMock).not.toHaveBeenCalled(); + }); + + it("does not treat a draft connection as completed from the URL stage", async () => { + const connectionId = "22222222-2222-4222-8222-222222222222"; + mockSearch.value = `source=clickup&stage=complete&resume=${connectionId}`; + listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); + listApplicationsMock.mockResolvedValueOnce({ + applications: [{ + id: "app-clickup", + companyId: "company-1", + status: "active", + metadata: { sourceTemplateKey: "clickup" }, + }], + }); + listConnectionsMock.mockResolvedValueOnce({ + connections: [{ + id: connectionId, + companyId: "company-1", + applicationId: "app-clickup", + authKind: "oauth", + credentialPolicy: "per_user", + status: "draft", + config: { sourceTemplateKey: "clickup" }, + transportConfig: { sourceTemplateKey: "clickup" }, + }], + }); + + await render(); + await vi.waitFor(() => expect(container.textContent).toContain("Finish connecting ClickUp")); + + expect(mockNavigate).not.toHaveBeenCalledWith(`/apps/${connectionId}/permissions`, { replace: true }); + expect(container.textContent).not.toContain("ClickUp is ready."); + expect(connectAppMock).not.toHaveBeenCalled(); + expect(finishAppMock).not.toHaveBeenCalled(); + }); + + it("does not treat a mismatched active connection as completed from the URL stage", async () => { + const connectionId = "22222222-2222-4222-8222-222222222222"; + mockSearch.value = `source=clickup&stage=complete&resume=${connectionId}`; + listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); + listApplicationsMock.mockResolvedValueOnce({ + applications: [{ + id: "app-monday", + companyId: "company-1", + status: "active", + metadata: { sourceTemplateKey: "monday" }, + }], + }); + listConnectionsMock.mockResolvedValueOnce({ + connections: [{ + id: connectionId, + companyId: "company-1", + applicationId: "app-monday", + authKind: "oauth", + credentialPolicy: "per_user", + status: "active", + config: { sourceTemplateKey: "clickup" }, + transportConfig: { sourceTemplateKey: "clickup" }, + }], + }); + + await render(); + await vi.waitFor(() => expect(container.textContent).toContain("This setup can’t be resumed")); + + expect(mockNavigate).not.toHaveBeenCalledWith(`/apps/${connectionId}/permissions`, { replace: true }); + expect(connectAppMock).not.toHaveBeenCalled(); + expect(finishAppMock).not.toHaveBeenCalled(); + expect(startOAuthMock).not.toHaveBeenCalled(); + }); + + it("rejects a matching-provider resume when the saved connection was already active", async () => { + const connectionId = "22222222-2222-4222-8222-222222222222"; + mockSearch.value = `source=clickup&resume=${connectionId}`; + listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); + listApplicationsMock.mockResolvedValueOnce({ + applications: [{ + id: "app-clickup", + companyId: "company-1", + status: "active", + metadata: { sourceTemplateKey: "clickup" }, + }], + }); + listConnectionsMock.mockResolvedValueOnce({ + connections: [{ + id: connectionId, + companyId: "company-1", + applicationId: "app-clickup", + authKind: "oauth", + credentialPolicy: "per_user", + status: "active", + config: { sourceTemplateKey: "clickup" }, + transportConfig: { sourceTemplateKey: "clickup" }, + }], + }); + + await render(); + await vi.waitFor(() => expect(container.textContent).toContain("This setup can’t be resumed")); + + expect(connectAppMock).not.toHaveBeenCalled(); + expect(finishAppMock).not.toHaveBeenCalled(); + expect(startOAuthMock).not.toHaveBeenCalled(); + }); + + it("does not resolve a hidden provider when a resumed draft belongs to another provider", async () => { + const connectionId = "22222222-2222-4222-8222-222222222222"; + mockSearch.value = `source=clickup&resume=${connectionId}`; + listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); + listApplicationsMock.mockResolvedValueOnce({ + applications: [{ + id: "app-monday", + status: "active", + metadata: { sourceTemplateKey: "monday" }, + }], + }); + listConnectionsMock.mockResolvedValueOnce({ + connections: [{ + id: connectionId, + applicationId: "app-monday", + authKind: "oauth", + credentialPolicy: "per_user", + status: "draft", + config: { sourceTemplateKey: "clickup" }, + transportConfig: {}, + }], + }); + + await render(); + await vi.waitFor(() => { + expect(container.textContent).toContain("This setup can’t be resumed"); + }); + + expect(connectAppMock).not.toHaveBeenCalled(); + expect(startOAuthMock).not.toHaveBeenCalled(); + }); + + it("keeps an unavailable hidden draft on the blocked recovery screen", async () => { + const connectionId = "22222222-2222-4222-8222-222222222222"; + mockSearch.value = `source=clickup&resume=${connectionId}`; + listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); + listApplicationsMock.mockResolvedValueOnce({ applications: [] }); + listConnectionsMock.mockResolvedValueOnce({ connections: [] }); + + await render(); + await vi.waitFor(() => { + expect(container.textContent).toContain("The saved connection no longer exists"); + }); + + expect(mockNavigate).not.toHaveBeenCalled(); + expect(connectAppMock).not.toHaveBeenCalled(); + expect(startOAuthMock).not.toHaveBeenCalled(); + }); + it("shows installation recovery for GitHub even when an advanced PAT method is available", async () => { const connectionId = "22222222-2222-4222-8222-222222222222"; mockSearch.value = `source=github&resume=${connectionId}&oauth=failed&code=github_installation_required&installation_url=https%3A%2F%2Fgithub.com%2Fapps%2Fpaperclip-for-github%2Finstallations%2Fnew`; diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index 556d8c7f681..d641d4e1f6b 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -1404,6 +1404,38 @@ describe("Connectors landing page", () => { ); }); + it("resumes a hidden provider only from its existing draft account row", async () => { + listApplicationsMock.mockResolvedValue({ applications: [application({ + id: "app-clickup", + name: "ClickUp", + applicationKey: "app-gallery:clickup:one", + metadata: { sourceTemplateKey: "clickup" }, + })] }); + listConnectionsMock.mockResolvedValue({ connections: [connection({ + id: "conn-clickup-draft", + applicationId: "app-clickup", + name: "ClickUp", + status: "draft", + config: { sourceTemplateKey: "clickup" }, + })] }); + + await renderBrowse(); + + const row = container.querySelector('[data-app-slug="clickup"]'); + expect(row).not.toBeNull(); + expect(row?.textContent).toContain("Setup incomplete"); + const finish = Array.from(row!.querySelectorAll("button")).find( + (button) => button.textContent === "Finish setup", + ); + await act(async () => { + finish?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(navigateMock).toHaveBeenCalledWith( + "/apps/connect?source=clickup&resume=conn-clickup-draft", + ); + expect(getAppStoreDefinition("clickup")).toBeNull(); + }); + it("filters the single list without restoring section chrome", async () => { await renderBrowse(); diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index 75077a5ccc5..a3fc1b27242 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -6,6 +6,7 @@ import { AggregatorAppManager } from "./AggregatorAppManager"; import { aiConnectionRouterPluginKey, connectionSetupVerbForApp, + getConnectableAppDefinition, isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE, } from "@paperclipai/shared"; @@ -315,6 +316,16 @@ function accountActionHref( if (connection.status === "draft" && row.entry) { return appSourceResumeHref(row.slug, connection.id); } + // Hidden curated definitions are not included in the gallery, but their + // already-saved drafts still need the same exact-provider resume flow. + // Keep fresh setup hidden by only using this fallback for an existing draft + // whose company row and saved connection identify the same known provider. + if (connection.status === "draft") { + const sourceSlug = appConnectionSourceSlug(connection); + if (sourceSlug && sourceSlug === row.slug && getConnectableAppDefinition(sourceSlug)) { + return appSourceResumeHref(sourceSlug, connection.id); + } + } return `/apps/${connection.id}/permissions`; } diff --git a/ui/src/pages/apps/app-connect-policy.test.ts b/ui/src/pages/apps/app-connect-policy.test.ts index 9eb3c919f6b..2c0a46e4dda 100644 --- a/ui/src/pages/apps/app-connect-policy.test.ts +++ b/ui/src/pages/apps/app-connect-policy.test.ts @@ -29,7 +29,7 @@ describe("app connect policy", () => { expect(canEnterAppsConnect(new URLSearchParams("source=jira"))).toBe(true); expect(canEnterAppsConnect(new URLSearchParams("source=asana"))).toBe(true); expect(canEnterAppsConnect(new URLSearchParams("source=github"))).toBe(true); - expect(canEnterAppsConnect(new URLSearchParams("source=context7"))).toBe(false); + expect(canEnterAppsConnect(new URLSearchParams("source=context7"))).toBe(true); expect(canEnterAppsConnect(new URLSearchParams("source=zapier"))).toBe(true); expect(canEnterAppsConnect(new URLSearchParams("source=unknown"))).toBe(false); expect(canEnterAppsConnect(new URLSearchParams("source=model-provider"))).toBe(false); @@ -56,6 +56,13 @@ describe("app connect policy", () => { expect(canEnterAppsConnect(new URLSearchParams("source=unknown&reconnect=connection-1"))).toBe(false); }); + it("admits hidden providers only for known-provider retained setup routes", () => { + expect(canEnterAppsConnect(new URLSearchParams("source=monday"))).toBe(false); + expect(canEnterAppsConnect(new URLSearchParams("source=monday&resume=connection-1"))).toBe(true); + expect(canEnterAppsConnect(new URLSearchParams("source=monday&reconnect=connection-1"))).toBe(true); + expect(canEnterAppsConnect(new URLSearchParams("source=unknown&resume=connection-1"))).toBe(false); + }); + it("builds a generic source deep link", () => { expect(appSourceConnectHref("notion")).toBe("/apps/connect?source=notion"); expect(appSourceConnectHref("notion", "intent-1")) diff --git a/ui/src/pages/apps/app-connect-policy.ts b/ui/src/pages/apps/app-connect-policy.ts index fd5384a1cf3..3bc74b87068 100644 --- a/ui/src/pages/apps/app-connect-policy.ts +++ b/ui/src/pages/apps/app-connect-policy.ts @@ -81,7 +81,11 @@ export function canEnterAppsConnect( ) return false; // A retained connection may belong to a provider hidden from fresh catalog // setup. Admit only known providers here; the setup flow then proves the - // exact reconnect target is visible to the selected company before rendering. - if (getConnectableAppDefinition(source) && searchParams.get("reconnect")?.trim()) return true; + // exact reconnect or incomplete-draft target belongs to the selected company + // and matches this provider before resolving the hidden definition. + if ( + getConnectableAppDefinition(source) + && (searchParams.get("reconnect")?.trim() || searchParams.get("resume")?.trim()) + ) return true; return source === "agentmail" || chatConnectorsEnabled ? appSupportsCatalogSetup(entry) : appSupportsToolCatalogSetup(entry); }