diff --git a/brain/knowledge/ai-intelligence/ai-agents.md b/brain/knowledge/ai-intelligence/ai-agents.md index aea8d090342a..7539f45a6d05 100644 --- a/brain/knowledge/ai-intelligence/ai-agents.md +++ b/brain/knowledge/ai-intelligence/ai-agents.md @@ -35,6 +35,14 @@ A flow step type (the `run_agent` action of `@activepieces/piece-ai`) that runs - **Credit is read from a status or the specific `insufficient_quota` marker, never loose patterns over a response body.** OpenAI signals billing exhaustion as a *retryable* 429 with the marker in the **body**, so credit is checked before the retryable verdict — but scanning a body for `credits`/`402` made a provider 500 whose HTML error page said "credits" complete as a billing failure and hide a real outage. - **`ENTITY_NOT_FOUND` counts only for an AI-provider `entityType`, and `VALIDATION` counts for nothing.** A bare not-found is our bug; the `VALIDATION` that reaches this surface is the conversation concurrency lock, and a conversation stuck `STREAMING` is a state worth keeping visible. A completed job stores no `errorMessage`, so the `warn` log carrying `agentRun.errorClass` is the only remaining record. +- **A tool name is user text on four paths, and only the worker sees all of them.** `createToolName` is applied by the flow-tool dialog and the piece-tool stores, but a knowledge-base name was stored as typed and the AI piece's `toolName` is free `ShortText`. That string becomes the AI-SDK `ToolSet` key verbatim, which is how a name earned a 400 from Anthropic — *our* request, never the user's fault, which is why widening the status allow-list to 400 would have been the wrong fix. `mcpToolNameUtils.toValidToolName` is the guard, applied by `agentToolPolicy.withValidNames` in `execute-agent-run` — the one place the flow-step, chat and eval enqueue paths converge (`agent-conversation-controller` validates tool names not at all; `agent-run-controller` checks only the reserved prefix and duplicates, and does it on the *raw* names, so it cannot see a collision the rewrite creates). Four things it has to get right: + - **The pattern is the intersection of every provider we ship, not the one from the error we happened to see.** Anthropic's `^[a-zA-Z0-9_.-]{1,64}$` is the loosest: OpenAI and Bedrock reject `.`, and Gemini requires a leading letter or underscore. Guarding with Anthropic's rule leaves `handbook.pdf` — the obvious name for a knowledge base file — still failing everywhere else. The guard is `^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$`. + - **It rewrites only a name that already fails**, because `createToolName` is not idempotent and re-running it would break the names `migrate-v16` persisted. `toValidToolName` re-checks its own output and re-derives from a prefixed source when `createToolName` returns a leading digit. + - **It dedupes, because sanitising converges.** `Company Docs` and `Company docs` map to one key, and every name with no `[a-z0-9_-]` at all used to hash identically — two CJK-named tools became the same key. `Object.fromEntries` is last-wins, so one tool vanished from the toolset with no error and answered from the wrong source. `createToolName` now hashes the original when the sanitised form is empty, and `withValidNames` is a list→list function holding a `taken` set. + - **MCP tools are left alone**, because `agent-mcp-client` already derives a sanitised key from `${toolName}_${name}`. + Server-side `toolName` is log-only — `executePieceTool` / `executeFlowTool` / `executeKnowledgeBaseTool` route on `piece`, `flowId` and `knowledgeBaseFileId` — so rewriting it breaks no lookup. `stepResultFrom` must be passed the knowledge-base tools too, or its `ToolCallType.KNOWLEDGE_BASE` branch is unreachable and the card shows the rewritten key instead of the file name. +- **A retired model is user config, and only a marker in the message says so.** A provider 400 stays `internal` by default; one whose *message* matches `MODEL_UNAVAILABLE_PATTERNS` is `user`. Two deliberate narrowings, both learned the hard way: the body is **never** scanned, because any 400 carrying an HTML error page that says "deprecated" in its footer would launder our own outage; and a retired model on the managed `activepieces` key is **ours**, since `resolveModelIdForProvider` substitutes `curatedModels[0]` for anything uncurated — a stale constant in our repo failing every platform at once must page, not read as "the customer picked a bad model". That substitution still classifies as `user` on a BYO key, which is the residual gap. +- **The curated chat lists rot silently and nothing checks them, but "the ticket said it's deprecated" is not evidence.** `ALLOWED_CHAT_MODELS_BY_PROVIDER` (`core/piece-types/src/lib/ai-providers.ts`) is the only thing deciding what the pickers offer; the models.dev catalog is metadata keyed by id and adds or removes nothing. Check a suspected-dead id against models.dev before deleting it — of the four ENG-466 named, only `grok-4.1-fast` had actually gone; the Gemini 2.5 pair was live, current and the *cheapest* Google option. Removing a live model is not a cleanup: `resolveModelIdForProvider` falls through to `curatedModels[0]`, so a BYO customer pinned to Flash would have silently moved to a Pro preview at roughly five times the token price, on their own key, with no notice and no migration. Three things move together when editing a list: `CHAT_MODEL_LABELS` (a curated id with no label fails `ai-providers.test.ts`), `MANAGED_MODEL_WEIGHTS` in `flow-run-ai-usage-tracker.ts` (its `?? 2` default is *below* the table's floor of 6, so a forgotten managed model under-bills — every `x-ai/*` id does today), and the **order**, since `curatedModels[0]` is both the picker's first row and the fallback for every unrecognised selection. - **Whatever enqueues an agent run must pre-check the same thing the worker resolves.** The chat route asked "is any provider enabled for chat" while the worker looked up the run's *pinned* provider, and the flow-step route checked nothing at all — so a run enqueued fine and could only fail. Both now call `agentHelpers.assertRunProviderConfigured`, which mirrors the worker's lookup. A pre-check that answers a *different* question than the worker is worse than none: it makes the failure look impossible. - **Everything the agent job does before its try/catch has no recovery.** `getAgentConfig` used to run outside it, so a config failure sent no error to the chat client and never called `releaseFlowStep` — the flow run sat PAUSED until `AP_PAUSED_FLOW_TIMEOUT_DAYS`. Anything added above that block needs its own failure path, or a paused run leaks. - **Build the unattended tool set as an allow-list.** Removing chat tools by name failed three times running — display tools, then build-plan and phase tools, then `ap_discover_action_auth` and `ap_load_guide`, which live with the local tools and so survived a filter written by tool group. Grouping tracks where a tool was constructed, not whether it assumes someone is reading. A flow step gets exactly what it is listed: its configured piece actions, the public-web readers, and the structured-output tool. Anything added to chat later stays out by default. @@ -63,3 +71,6 @@ Paths verified 2026-07-17. An earlier version pointed at `packages/core/shared/s - **A knowledge base uploaded through the UI is not searchable.** Nothing in the upload path generates chunk embeddings; `knowledge-base.controller.ts` only *accepts* an embedding on a chunk. Chunks land with `embedding IS NULL`, and search filters those out, so the result is an empty answer rather than an error. - **`knowledge_base_chunk` is created by a migration that records itself as run even when pgvector is absent.** A database that gains pgvector later never gets the table, because the migration is already marked complete. Deleting its row from `migrations` replays it safely, since the DDL is `CREATE TABLE IF NOT EXISTS`. - **Embeddings are stored at a fixed 768 dimensions, and most models do not return that.** `text-embedding-3-small` answers 1536, and the `dimensions` provider option is namespaced under `openai`, so the OpenRouter and managed paths never see it. `agentAiUtils.toStorageEmbedding` truncates and re-normalises instead, which is what the option does server-side and works whatever the provider returns. This only holds for Matryoshka-trained models — adding a model that is not one will truncate badly and silently. + +- **Saving a saved agent publishes it.** `POST /v1/agents/:id` sets `goLive: true` unless the body says otherwise, so an ordinary save copies the draft over the published snapshot. There is no separate publish step in the UI, deliberately: two versions with no history means nobody can say which one a linked flow runs. The consequence is easy to trip over in tests and callers — anything that needs `draft` to differ from `published` has to write the row directly (`db.update('agent', id, { draft })`) or pass `goLive: false`, which is what the Test tab uses to stage a change it can run without shipping it. A test that edited the draft through the API to prove "a flow runs the published copy" was quietly moving the copy it was asserting about, and it only started failing when the save-publishes change merged from another branch. +- **`ap_add_agent_tools` will save a tool with no connection pinned.** `connectionExternalId` is optional, so a tool the AI adds without one carries no `predefinedInput.auth` for good. The visible symptom is a connection picker card on *every* conversation with that agent, which reads as the card being broken or the credential expiring — it is neither. The agent has nothing to use, so it asks, and the answer only ever lands on the run (`__store_selected_connection` writes a map the agent tool set does not read), so the next conversation asks again. Fixing the card is the wrong end: pin a connection when the tool is created, and write a chosen or repaired one back into `draft.tools` via `editDraftTools`. diff --git a/brain/knowledge/ai-intelligence/mcp-server.md b/brain/knowledge/ai-intelligence/mcp-server.md index 72bc110eb4fa..cd27137709fb 100644 --- a/brain/knowledge/ai-intelligence/mcp-server.md +++ b/brain/knowledge/ai-intelligence/mcp-server.md @@ -21,15 +21,35 @@ Exposes an Activepieces project as an MCP server so AI clients (Claude Desktop, ### How it works -- Main protocol endpoint: `POST /v1/mcp/:projectId/http` (StreamableHTTP). Config: `GET/POST /v1/mcp/:projectId`, rotate token via `.../rotate`. -- Auth: `Authorization: Bearer {token}` or `?token=`. OAuth 2.0 PKCE also supported for clients that need it. +- Main protocol endpoint: `POST /mcp` at the domain root, plus `POST /mcp/platform` (StreamableHTTP), both registered in `server.ts`. Config lives under the project API (`GET/POST` on the project MCP server route). +- Auth is **OAuth-only**: `resolveIdentity` accepts an `Authorization: Bearer` value only if `mcpOAuthTokenService.verifyAccessToken` verifies it as a signed JWT with audience `JwtAudience.MCP_OAUTH_ACCESS`. There is no static-token authenticator and no `?token=` query path. - AI pieces consume MCP tools over three transports: `SIMPLE_HTTP`, `STREAMABLE_HTTP`, `SSE`. - Embed SDK adds `authorizeMcp()` (in-embed OAuth consent), `mcpSettings()`, and `generateMcpToken()` (mints `{ mcpServerUrl, mcpToken }` with no OAuth flow, backed by `POST /v1/projects/:projectId/mcp-server/token` — a short-lived 15-min project-scoped token). ### Gotchas +- **`mcp_server.token` is dead — nothing reads it.** It is written by the `getOrCreate` defaults and by both `/rotate` routes (`mcpServerService.rotateToken` / `rotatePlatformToken`), and consulted by **no authenticator**, so "rotating" it rotates a secret that grants nothing. It is still on the public `McpServer` zod schema, so the API keeps shipping a secret-shaped 72-char string that authenticates nothing — do not reach for it as a credential, and do not tell a self-hoster to. The settings panel is consistent with reality already (`mcp-credentials.tsx` renders the URL and *"Authentication is handled via OAuth"*, never a token). Deleting the column, the two routes, and the schema field is a breaking API-response change and has not been done. +- **`mcp_oauth_token.clientKey` is decided once, at sign-in.** `exchangeCode` derives it from the registration's + redirect URIs via `mcpOAuthClientIdentity`, so the grants list can filter and group in SQL instead of loading + every `mcp_oauth_client` row on the platform to re-derive keys in memory. Two consequences: sharpening the + heuristic later does **not** relabel existing grants (they age out in 30 days, and an active client relabels on + its next refresh, which backfills a NULL key), and `NULL` is not a third state — it means "signed in before the + column existed" and reads as `unknown` everywhere, including the `?clientKeys=unknown` filter. +- **Claude Code and Codex re-run Dynamic Client Registration on *every* sign-in**, registering the exact + ephemeral loopback port they are about to bind (`http://localhost:/callback`, + `http://127.0.0.1:/callback/`). So the exact-string `validateRedirectUri` works and + RFC 8252 port-agnostic matching is not needed — but a fresh `mcp_oauth_client` row and `clientId` is + minted per sign-in, so `clientId` is **not** a stable identity for "a connected client", and those rows + accumulate unbounded. Measured 2026-08-23 (Claude Code 2.1.235, Codex 0.149.0). +- **Never advertise `client_id_metadata_document_supported`** in the authorization-server metadata while + `client_id` is validated against `^[A-Za-z0-9_-]{1,64}$`. Claude Code prefers a Client ID Metadata + Document, whose `client_id` is a URL; it only falls back to DCR because we stay silent about CIMD. + Advertising it without widening the `client_id` shape breaks Claude Code sign-in outright. +- **A static `Authorization` header is worse than none for MCP clients.** In Codex, setting `bearer_token_env_var` or an `Authorization` header short-circuits to bearer auth and skips OAuth discovery entirely; in Claude Code a rejected `Authorization` header surfaces as a failed connection rather than falling back to OAuth. So a partially-built static-token path silently disables the OAuth path that does work. Related: headless/CI (`claude -p`, the SDK) has no `/mcp` panel and therefore no supported way to connect today. + - Flow attribution: `ap_create_flow`/`ap_build_flow`/`ap_duplicate_flow` stamp `ownerId` (OAuth user) and `createdBy: { type: 'MCP', id }`. - `MCP_SERVER_CONNECTED` is deduped to at most one/user/server/day (`telemetryDedupe.onceToday`) — a daily-active signal, not request volume. Per-call usage is `MCP_TOOL_CALLED`. +- **The MCP URL must be reachable without a redirect.** A cross-origin `301/302/307/308` strips the `Authorization` header in every spec-conforming client, and "cross-origin" includes the scheme — so a plain `http`→`https` canonicalisation at the proxy is as fatal as apex→www. It fails *loudly-looking-fine*: discovery is request-derived (`networkUtils.getRequestBaseUrl` reads `x-forwarded-proto`/host), so OAuth sign-in completes against the canonical origin while the client keeps POSTing the URL it was given, yielding permanent `401`s or a re-auth loop rather than a clean error. Activepieces never redirects there itself — the only prefixes are `/mcp` and `/mcp/platform`, and Fastify runs `ignoreTrailingSlash: true` so `/mcp/` matches the same route with no `301` — so it is always operator proxy config, and undetectable server-side (the proxy answers the pre-redirect request; AP never sees it). - OAuth discovery URLs are built via `domainHelper.getPublicUrlFromRequest` so subpath-hosted instances advertise the right prefix. `401`s carry an RFC 9728 `WWW-Authenticate: Bearer resource_metadata="…"` header. Host-root `.well-known/oauth-*` must still be forwarded to AP by the operator. - **DCR must issue a client secret when `token_endpoint_auth_method` is omitted.** RFC 7591 §2 says an omitted value defaults to `client_secret_basic`, *not* `none`, and [Microsoft Copilot Studio](https://learn.microsoft.com/en-us/microsoft-365/copilot/extensibility/plugin-authentication-dynamic-client-registration) refuses DCR outright without one ("DCR without a client secret isn't supported yet"). Defaulting an omitted method to `none` looks like it fixes the "public client handed a secret" contradiction, but it resolves it the wrong way: it breaks Copilot and makes `client_secret_basic` support unreachable for every client that omits the field. Resolve it the other way — default to `client_secret_basic` and keep issuing the secret. - `x-ap-conversation-id` header (EE chat) rebinds the server to a conversation's project, but only when scoping matches the token — it can never widen the grant. diff --git a/brain/knowledge/engineering/architecture-spine.md b/brain/knowledge/engineering/architecture-spine.md index 05270196462d..899097fdf025 100644 --- a/brain/knowledge/engineering/architecture-spine.md +++ b/brain/knowledge/engineering/architecture-spine.md @@ -44,6 +44,8 @@ Activepieces: open-source AI-first workflow automation platform (self-hosted or **`unique()` from `core-utils` is O(n²) over `JSON.stringify` — never put it on a hot path.** It is `filter` + `findIndex` with a `JSON.stringify` on *both* sides of every comparison, so it blocks the event loop: 1k items → 42ms, 5k → 889ms, 10k → 3.6s, during which health checks, websockets and webhook dispatch all stall. It exists for deep-equality dedupe of objects; for primitives use `[...new Set(xs)]`. Found 2026-07 as the first statement of the bulk record delete the same PR was trying to speed up (GIT-1652). +**`kebabCase()` from `core-utils` does not strip punctuation, so it cannot make a URL slug — reach for `slugify()`.** The two sit next to each other in `core-utils/utils.ts` and read as synonyms, but `kebabCase` only splits camelCase and swaps spaces/underscores for hyphens: `"Acme Inc."` comes back `acme-inc.`, dot intact, and any `&`, `'` or `/` survives too. `slugify` is the one that drops every non-alphanumeric run. Picking the wrong one is invisible in dev (single-word brand names are identical under both) and only shows up once a real customer name reaches the path, query string or config key you built with it. Neither has a fallback for an all-punctuation input — both return `''` — so a caller that needs a non-empty slug supplies its own default (`slugify(name) || 'activepieces'`, as the MCP client catalog does). Note `piece_set.key` is generated with `kebabCase`, which is deliberate: it is an opaque handle with a random suffix, not a URL. + **`DeleteResult.affected` is `undefined` on PGlite — don't count rows with it.** TypeORM's `PostgresQueryRunner` only sets `affected` when the driver result carries `rowCount`; PGlite reports `affectedRows` instead and `typeorm-pglite` doesn't map it. So `result.affected ?? 0` is correct on `pg` and silently `0` on every PGlite deployment and test — the worst failure mode, since CI is green. Use `.returning('id')` and count the rows. **Migration timestamps are hand-picked, so two PRs in flight will collide.** `postgres-connection.ts` uses round numbers (`1815000000000`, `1816000000000`, …), not `Date.now()`, and TypeORM orders migrations by the 13-digit suffix of the class name. Two branches both taking "the next one" produce duplicate keys, and ordering — including `rollback-migrations.ts` — silently falls back to `getMigrations()` array order. Check `git ls-tree main packages/server/api/src/app/database/migration/postgres/` for the number before you commit, and re-check after any rebase. diff --git a/brain/knowledge/engineering/web-feature-anatomy.md b/brain/knowledge/engineering/web-feature-anatomy.md index 69006e7b6c60..5a0e33037ba1 100644 --- a/brain/knowledge/engineering/web-feature-anatomy.md +++ b/brain/knowledge/engineering/web-feature-anatomy.md @@ -79,3 +79,5 @@ Verify with `npx turbo run lint --filter=web`, or `npm run lint-dev` for the who - **`packages/web`'s lint script only globs `src/**`, so nothing under `packages/web/test/` is ever linted** — not by CI's `lint` job, not by `npm run lint-dev`. Running `npx eslint 'test/**/*.{ts,tsx}'` from `packages/web` today reports 21 errors nobody has seen, so a new web test needs a manual eslint pass or it ships with errors. Most common trap: `testing-library/render-result-naming-convention` fires on any local helper whose name merely *starts with* `render` even when testing-library is not involved — renaming `render` to `renderTabText` does not silence it, only a name that doesn't begin with `render` does. - **`AllowOnlyLoggedInUserOnlyGuard` calls its hooks after two early returns, and the linter only lets it.** `react-hooks/rules-of-hooks` does not flag member-expression calls, so `platformHooks.useCurrentPlatform()` / `flagsHooks.useFlags()` sail past it — but add a bare `useSomething()` there and the rule fires, correctly: `isLoggedIn()` can change between renders, so those calls really are conditional. Anything new that needs to run once a session is authenticated belongs in a null-rendering component placed inside the returned `` subtree, which mounts only after the guard passes. That is why automatic trial activation is `` and not a hook. - **The layering is lint-enforced, not just a convention.** `packages/web/.eslintrc.json` has an `import/no-restricted-paths` zone making the codebase unidirectional: `src/app` may import `src/features`, and both may import `src/lib`/`hooks`/`components`/`types`/`utils` — never the reverse (the one exception is `app/query-client.ts`). So a hook that a public route needs belongs in `src/lib`, but anything rendering a feature's components has to live in that feature; you cannot keep the pair in one `lib` file. It fails as an `import/no-restricted-paths` **error**, not a warning, so it blocks lint. +- **Arbitrary Tailwind values for type, tracking and radius get sent back in review — `packages/web` has its own scale and it is not stock Tailwind.** There is no `tailwind.config.js`; this is Tailwind v4 and the theme lives in the `@theme` block of `src/styles.css`, which *adds* `--text-xss: 0.65rem`, *overrides* `--text-3xl` to 1.75rem and `--text-4xl` to 2rem (both smaller than stock), and derives `--radius-{sm,md,lg,xs,xss}` from a single `--radius: 0.5rem`. So `text-[13px]`, `tracking-[-0.025em]` and `rounded-[11px]` are not just style nits — they sit *between* real tokens and drift the page off the scale. Map them: 10–11px → `text-xss`, 11.5–12.5px → `text-xs`, 13–13.5px → `text-sm`, 15–15.5px → `text-base`; negative tracking → `tracking-tight`, uppercase-eyebrow tracking → `tracking-wide`/`wider`; any `rounded-[9–11px]` → `rounded-md`. Layout constraints are the exception and stay arbitrary — `max-w-[628px]` for a reading measure or `lg:w-[344px]` for a sidebar have no token equivalent and are idiomatic. Fractional spacing (`size-5.5`, `size-8.5`, `size-13`) is valid in v4 and beats `size-[22px]`. Neither eslint nor `tsc` catches any of this, so it only ever surfaces in review. +- **`npx prettier --check` lies about `packages/web` — it flags files nobody has touched, so never treat it as a gate.** Prettier is not in any CI workflow, and the root `.prettierrc` is a single `{"singleQuote": true}` while the resolved binary is prettier **2.8.4**, whose `trailingComma` default is `es5`. The checked-in code is formatted by prettier 3 (via the editor / eslint integration), which defaults to `all` — so every multi-line call with a trailing comma reads as a "code style issue". Running `--check` on a file straight out of `git show HEAD:` reproduces it. If you want to know whether your own edit is formatted, diff `npx prettier ` against the file and check the hunks are yours; the pass/fail verdict is meaningless. `npx turbo run lint --filter=web` is the real gate. diff --git a/brain/knowledge/flows-execution/chat.md b/brain/knowledge/flows-execution/chat.md index e83de92354cc..06aa2931fa49 100644 --- a/brain/knowledge/flows-execution/chat.md +++ b/brain/knowledge/flows-execution/chat.md @@ -61,3 +61,5 @@ Entry point: `agentModule`, the Fastify plugin registered in `packages/server/ap - `packages/web/src/features/chat/` — API client, Zustand store, `use-chat.ts`, `chunk-reducer.ts`, streaming and voice hooks Paths verified 2026-08-19 against main. An earlier version pointed at `ee/chat/chat-model-factory.ts` and `ee/chat/chat-history-hygiene.ts`; both were folded into `packages/server/utils/src/agent-ai-utils.ts`. Every `ee/chat/` path on this page before that date is dead — see the chat-to-agent rename gotcha above. + +- **`setConversationId` is a reload, not a setter.** It calls `stopStream()`, resets the interaction stores and refetches history, so handing it the id of a conversation the hook is *already in* destroys the turn in flight. `AIChatBox` seeds it from the `conversationId` prop in an effect, which makes the obvious wiring — feed `onConversationCreated` back into that prop — kill the very turn that created the conversation: the pane goes blank while the reply completes fine on the server. It now early-returns when the id is unchanged, so re-seeding is a no-op, but the shape is worth knowing before adding another caller. diff --git a/brain/knowledge/pieces-engine/piece-sets.md b/brain/knowledge/pieces-engine/piece-sets.md index 8e0d950e6b3e..94b496626604 100644 --- a/brain/knowledge/pieces-engine/piece-sets.md +++ b/brain/knowledge/pieces-engine/piece-sets.md @@ -23,6 +23,8 @@ A named, reusable piece/action/trigger visibility configuration a platform admin - The **whole** `/v1/piece-sets` module is behind that flag, `GET` included — so on a locked plan the web list query is `enabled: false`, the table is simply empty, and row actions never render. Only toolbar/entry points need a UI guard. The `LockedAlert` + `RequestTrial featureKey="ENTERPRISE_PIECES"` lives once on `PlatformPiecesPage`, above the tabs, since the same flag gates both the Pieces and Piece Sets tabs; the details route redirects back to the tab rather than hanging on a spinner waiting for a query that will never run. - There is **no** install-time sync and no `onPieceCreated` hook — resolution is purely read-time. See ADR 0001 (visibility derived, not materialized). - Embed auth: a v4 JWT carries a `pieceSet` key claim; legacy v2/v3 tokens carry `piecesTags` (only the first tag honored, resolved to `key = tag`, else Default). Enforcement (`applyProjectPieceAccess`) runs unconditionally, not gated by the flag. +- **`usePieces({ skipProjectFilter: true })` is not a caching flag — it silently turns piece-set filtering off.** It drops `projectId` from `GET /v1/pieces`, and `resolveVisibility` (`ee/pieces/filters/piece-filtering-utils.ts`) bails to `null` the moment *either* `platformId` or `projectId` is nil, so the response is the unfiltered platform catalog. `platformId` still comes from the principal, so this is not a tenancy hole — but any surface using it advertises pieces a restricted project's flows and MCP server will not actually expose. Correct for platform-admin screens (the piece-set editor has to list pieces you have not permitted yet) and for a marketing-style showcase; wrong anywhere the list implies "what you can use here". The absence of `projectId` is easy to miss at the call site because the flag reads like a client-side concern. + - Migration is three ordered steps: create table + backfill (`1807...`), then `CREATE INDEX CONCURRENTLY` (`1808...`, non-transactional), then the breaking drop of legacy platform piece-filter columns (`1809...`). Legacy `tag`/`piece_tag` tables are kept only because the backfill reads them once via raw SQL. ### Key files diff --git a/bun.lock b/bun.lock index 59f2123b2f84..6302c92efe57 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "activepieces", @@ -161,7 +162,7 @@ }, "packages/core/shared": { "name": "@activepieces/shared", - "version": "0.149.0", + "version": "0.150.0", "dependencies": { "@activepieces/core-execution": "workspace:*", "@activepieces/core-formula": "workspace:*", @@ -11047,6 +11048,7 @@ "devDependencies": { "@tailwindcss/postcss": "4.1.17", "@tailwindcss/vite": "4.1.17", + "@testing-library/react": "16.3.3", "@types/canvas-confetti": "^1.9.0", "@types/deep-equal": "1.0.1", "@types/pako": "2.0.3", @@ -11055,6 +11057,7 @@ "@types/react": "19", "@types/react-dom": "19", "@types/semver": "7.5.6", + "jsdom": "26.1.0", "pg": "8.20.0", "tailwindcss": "4.1.17", }, @@ -14127,6 +14130,10 @@ "@tediousjs/connection-string": ["@tediousjs/connection-string@1.1.0", "", {}, "sha512-z9ZBWEG+8pIB5V1zYzlRPXx0oRJ5H7coPnMQK8EZOw03UTPI9Umn6viL36f5w+CuqkKsnCM50RVStpjZmR0Bng=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/react": ["@testing-library/react@16.3.3", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg=="], + "@tiptap/core": ["@tiptap/core@3.15.3", "", { "peerDependencies": { "@tiptap/pm": "^3.15.3" } }, "sha512-bmXydIHfm2rEtGju39FiQNfzkFx9CDvJe+xem1dgEZ2P6Dj7nQX9LnA1ZscW7TuzbBRkL5p3dwuBIi3f62A66A=="], "@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.22.5", "", { "peerDependencies": { "@tiptap/core": "3.22.5" } }, "sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ=="], @@ -14229,6 +14236,8 @@ "@types/amqplib": ["@types/amqplib@0.10.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-IVj3avf9AQd2nXCx0PGk/OYq7VmHiyNxWFSb5HhU9ATh+i+gHWvVcljFTcTWQ/dyHJCTrzCixde+r/asL2ErDA=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/autocannon": ["@types/autocannon@7.12.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-Pd4nPf7wRpacULa6D/EC9x3CwzFQXwA0z5WFuik/fvJjW44V3WzBTM3jtt8nSBoflUNgswPiMCtgrr1bwnAcMg=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -15263,6 +15272,8 @@ "docusign-esign": ["docusign-esign@8.1.0", "", { "dependencies": { "@devhigley/parse-proxy": "^1.0.3", "axios": "^1.6.8", "csv-stringify": "^1.0.0", "jsonwebtoken": "^9.0.0", "passport-oauth2": "^1.6.1", "safe-buffer": "^5.1.2" } }, "sha512-p+YgSlAv5OspREJT6NvgEZMR5L8j1SGMZRUM2NEVvdcW35dmklB3Kx2ia8SrKfq5/U9sTSEiSQJiphKQGBgU0w=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], @@ -16299,6 +16310,8 @@ "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "mailparser": ["mailparser@3.9.3", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", "iconv-lite": "0.7.2", "libmime": "5.3.7", "linkify-it": "5.0.0", "nodemailer": "7.0.13", "punycode.js": "2.3.1", "tlds": "1.261.0" } }, "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ=="], @@ -16855,6 +16868,8 @@ "pretty-bytes": ["pretty-bytes@7.1.0", "", {}, "sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], "prism-react-renderer": ["prism-react-renderer@2.4.1", "", { "dependencies": { "@types/prismjs": "^1.26.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig=="], @@ -17533,6 +17548,10 @@ "tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="], + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + + "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], "to-buffer": ["to-buffer@1.2.2", "", { "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" } }, "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw=="], @@ -19619,6 +19638,12 @@ "@tanstack/react-query/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + "@testing-library/dom/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/react/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@tiptap/markdown/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], "@tryfabric/martian/@notionhq/client": ["@notionhq/client@1.0.4", "", { "dependencies": { "@types/node-fetch": "^2.5.10", "node-fetch": "^2.6.1" } }, "sha512-m7zZ5l3RUktayf1lRBV1XMb8HSKsmWTv/LZPqP7UGC1NMzOlc+bbTOPNQ4CP/c1P4cP61VWLb/zBq7a3c0nMaw=="], @@ -20313,6 +20338,10 @@ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "promise-retry/retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], @@ -20631,6 +20660,8 @@ "web/@types/qs": ["@types/qs@6.9.7", "", {}, "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw=="], + "web/jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], + "web/marked": ["marked@18.0.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg=="], "web/pg": ["pg@8.20.0", "", { "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA=="], @@ -21923,6 +21954,10 @@ "wav/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "web/jsdom/rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + + "web/jsdom/tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], + "webpack/eslint-scope/estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], "winston-transport/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], diff --git a/packages/core/execution/test/automation/agents/mcp-tool-name-util.test.ts b/packages/core/execution/test/automation/agents/mcp-tool-name-util.test.ts index 9b4d5e5661a6..d070249fe0e1 100644 --- a/packages/core/execution/test/automation/agents/mcp-tool-name-util.test.ts +++ b/packages/core/execution/test/automation/agents/mcp-tool-name-util.test.ts @@ -1,7 +1,7 @@ import { mcpToolNameUtils as pieceTypesUtils } from '@activepieces/core-piece-types' import { mcpToolNameUtils } from '../../../src/lib/agents/mcp-tool-name-util' -const { createToolName, createPieceToolName } = mcpToolNameUtils +const { createToolName, createPieceToolName, toValidToolName, suggestToolName } = mcpToolNameUtils describe('mcpToolNameUtils canonicalization', () => { it('resolves to the same implementation from both entry points', () => { @@ -66,3 +66,55 @@ describe('createPieceToolName', () => { expect(createPieceToolName('@activepieces/piece-google-sheets', 'insert_row')).toBe('google-sheets-insert_row_q388b6_mcp') }) }) + +describe('toValidToolName', () => { + it('leaves a name every provider already accepts', () => { + for (const name of ['company_docs', 'ap_show_questions', 'slack-send_message', 'a']) { + expect(toValidToolName(name)).toBe(name) + } + }) + + it('rewrites a name a provider would reject', () => { + expect(toValidToolName('Company Docs')).toBe(createToolName('Company Docs')) + expect(toValidToolName('文档')).toBe(createToolName('文档')) + }) + + it('rewrites names that one provider accepts and another rejects', () => { + expect(toValidToolName('handbook.pdf')).not.toBe('handbook.pdf') + expect(toValidToolName('2024_reports')).toMatch(/^[a-zA-Z_]/) + }) + + it('gives distinct names to inputs that sanitize to nothing', () => { + const collapsed = ['文档', '検索', '!!!', ' '].map(toValidToolName) + + expect(new Set(collapsed).size).toBe(4) + }) + + it('rewrites a slug that is merely too long, which no character check would catch', () => { + const overLong = 'q3_2026_company_handbook_and_employee_onboarding_guide_revision_4_pdf' + + expect(overLong.length).toBeGreaterThan(64) + expect(toValidToolName(overLong).length).toBeLessThanOrEqual(64) + }) + + it('is idempotent, so re-running it cannot drift a stored name', () => { + for (const name of ['Company Docs', 'a'.repeat(100), '!!!', ' ']) { + const once = toValidToolName(name) + expect(toValidToolName(once)).toBe(once) + expect(once).toMatch(/^[a-zA-Z0-9_.-]{1,64}$/) + } + }) +}) + +describe('suggestToolName', () => { + it('keeps a readable slug, so the dialog does not show a hashed name for an ordinary file', () => { + expect(suggestToolName('Company Handbook.pdf')).toBe('company_handbook_pdf') + expect(suggestToolName('Products Catalog')).toBe('products_catalog') + }) + + it('still guarantees a name every provider accepts', () => { + for (const sourceName of ['Q3 2026 Company Handbook and Employee Onboarding Guide Revision 4.pdf', '2024 Reports', '文档', '!!!']) { + expect(suggestToolName(sourceName)).toMatch(/^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$/) + } + }) +}) diff --git a/packages/core/piece-types/src/lib/agents.ts b/packages/core/piece-types/src/lib/agents.ts index cb8f0443e652..2d83cf5074d7 100644 --- a/packages/core/piece-types/src/lib/agents.ts +++ b/packages/core/piece-types/src/lib/agents.ts @@ -261,17 +261,33 @@ function shortHash(str: string): string { return h.toString(36).padStart(6, '0').slice(-6) } -function createToolName(name: string): string { - const sanitized = name +function sanitizeToolName(name: string): string { + return name .toLowerCase() .replace(/[^a-z0-9_-]/g, '_') .replace(/_+/g, '_') .replace(/^_+|_+$/g, '') +} + +function createToolName(name: string): string { + const sanitized = sanitizeToolName(name) const prefix = sanitized.slice(0, MAX_PREFIX_LENGTH) - const hash = shortHash(sanitized) + const hash = shortHash(sanitized.length > 0 ? sanitized : name) return `${prefix}_${hash}_mcp` } +function toValidToolName(name: string): string { + if (PROVIDER_TOOL_NAME_PATTERN.test(name)) { + return name + } + const generated = createToolName(name) + return PROVIDER_TOOL_NAME_PATTERN.test(generated) ? generated : createToolName(`tool_${name}`) +} + +function suggestToolName(sourceName: string): string { + return toValidToolName(sanitizeToolName(sourceName)) +} + function createPieceToolName(pieceName: string, actionName: string): string { const PIECE_NAME_PREFIX = 'piece-' const idx = pieceName.indexOf(PIECE_NAME_PREFIX) @@ -281,8 +297,9 @@ function createPieceToolName(pieceName: string, actionName: string): string { } const MAX_PREFIX_LENGTH = 53 +const PROVIDER_TOOL_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$/ -export const mcpToolNameUtils = { createToolName, createPieceToolName } +export const mcpToolNameUtils = { createToolName, createPieceToolName, toValidToolName, suggestToolName } export type ToolCallBase = z.infer diff --git a/packages/core/piece-types/src/lib/ai-providers.ts b/packages/core/piece-types/src/lib/ai-providers.ts index a258a3caca1a..ccc55ad72e0f 100644 --- a/packages/core/piece-types/src/lib/ai-providers.ts +++ b/packages/core/piece-types/src/lib/ai-providers.ts @@ -220,8 +220,8 @@ const CF_GATEWAY_SUBMODEL_TO_PROVIDER: Record = { const OPENAI_CHAT_MODELS = ['gpt-5.5', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4.1', 'gpt-4.1-mini'] as const const ANTHROPIC_CHAT_MODELS = ['claude-sonnet-4-6', 'claude-opus-4-7', 'claude-haiku-4-5'] as const const ANTHROPIC_OPENROUTER_CHAT_MODELS = ['claude-sonnet-4.6', 'claude-opus-4.7', 'claude-haiku-4.5'] as const -const GOOGLE_CHAT_MODELS = ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-3.1-pro-preview', 'gemini-3-flash-preview'] as const -const X_AI_OPENROUTER_CHAT_MODELS = ['grok-4.20', 'grok-4.1-fast'] as const +const GOOGLE_CHAT_MODELS = ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-3.7-flash', 'gemini-3.1-pro-preview', 'gemini-3-flash-preview'] as const +const X_AI_OPENROUTER_CHAT_MODELS = ['grok-4.20'] as const export const ALLOWED_CHAT_MODELS_BY_PROVIDER: Partial> = { [AIProviderName.OPENAI]: OPENAI_CHAT_MODELS, @@ -246,6 +246,7 @@ const CHAT_MODEL_LABELS: Record = { 'claude-haiku-4-5': 'Claude Haiku 4.5', 'gemini-2.5-pro': 'Gemini 2.5 Pro', 'gemini-2.5-flash': 'Gemini 2.5 Flash', + 'gemini-3.7-flash': 'Gemini 3.7 Flash', 'gemini-3.1-pro-preview': 'Gemini 3.1 Pro Preview', 'gemini-3-flash-preview': 'Gemini 3 Flash Preview', } diff --git a/packages/core/shared/package.json b/packages/core/shared/package.json index 0176dd7946c0..7d56925d6f9e 100644 --- a/packages/core/shared/package.json +++ b/packages/core/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.149.0", + "version": "0.150.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/core/shared/src/lib/automation/mcp/mcp-oauth.ts b/packages/core/shared/src/lib/automation/mcp/mcp-oauth.ts index 368cdd7e1bf5..ca442a5ce663 100644 --- a/packages/core/shared/src/lib/automation/mcp/mcp-oauth.ts +++ b/packages/core/shared/src/lib/automation/mcp/mcp-oauth.ts @@ -1,6 +1,10 @@ import { BaseModelSchema } from '@activepieces/core-utils' import { z } from 'zod' +export const McpOAuthClientKey = z.enum(['claude', 'claude-code', 'chatgpt', 'cursor', 'vscode', 'codex', 'gemini-cli', 'opencode', 'windsurf', 'unknown']) + +export type McpOAuthClientKey = z.infer + export const McpOAuthClient = z.object({ ...BaseModelSchema, clientId: z.string(), @@ -19,12 +23,14 @@ export const McpOAuthToken = z.object({ ...BaseModelSchema, refreshToken: z.string(), clientId: z.string(), + clientKey: McpOAuthClientKey.nullable(), userId: z.string(), projectId: z.string().nullable(), platformId: z.string(), scopes: z.array(z.string()).nullable(), expiresAt: z.string(), revoked: z.boolean(), + lastUsedAt: z.string().nullable(), }) export type McpOAuthToken = z.infer diff --git a/packages/core/shared/src/lib/ee/agent/agent.ts b/packages/core/shared/src/lib/ee/agent/agent.ts index 3add889daeb6..f5f3c8ad5025 100644 --- a/packages/core/shared/src/lib/ee/agent/agent.ts +++ b/packages/core/shared/src/lib/ee/agent/agent.ts @@ -85,7 +85,9 @@ const CreateAgentRequest = z.object({ draft: AgentConfig, }) -const UpdateAgentRequest = CreateAgentRequest.omit({ projectId: true }).partial() +const UpdateAgentRequest = CreateAgentRequest.omit({ projectId: true }).partial().extend({ + goLive: z.boolean().optional(), +}) const AgentDraftFields = z.object({ displayName: z.string().min(1, formErrors.required).max(MAX_AGENT_NAME_LENGTH), diff --git a/packages/core/utils/src/lib/utils.ts b/packages/core/utils/src/lib/utils.ts index d40d2186bc12..f6b5ddac53fa 100644 --- a/packages/core/utils/src/lib/utils.ts +++ b/packages/core/utils/src/lib/utils.ts @@ -87,6 +87,13 @@ export function kebabCase(str: string): string { .replace(/^-+|-+$/g, '') // Remove leading and trailing hyphens } +export function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + export function isEmpty(value: T | null | undefined): boolean { if (value == null) { diff --git a/packages/server/api/src/app/app-connection/app-connection-service/app-connection-service.ts b/packages/server/api/src/app/app-connection/app-connection-service/app-connection-service.ts index 0174396030d0..22c0338ab39c 100644 --- a/packages/server/api/src/app/app-connection/app-connection-service/app-connection-service.ts +++ b/packages/server/api/src/app/app-connection/app-connection-service/app-connection-service.ts @@ -1,6 +1,6 @@ import { ActivepiecesError, apId, Cursor, ErrorCode, isNil, Metadata, PlatformId, ProjectId, SeekPage, spreadIfDefined, tryCatch, tryCatchSync, unique, UserId } from '@activepieces/core-utils' import { PieceMetadata } from '@activepieces/pieces-framework' -import { ApEdition, ApEnvironment, AppConnection, AppConnectionId, AppConnectionOwners, AppConnectionScope, AppConnectionStatus, AppConnectionType, AppConnectionValue, AppConnectionWithoutSensitiveData, ConnectionState, EngineResponse, EngineResponseStatus, ExecuteResolveConnectionIdentifierResponse, ExecuteValidateAuthResponse, MAX_PLATFORM_APP_CONNECTION_OWNERS, OAuth2GrantType, PlatformAppConnectionOwner, PlatformAppConnectionOwnersResponse, PlatformAppConnectionProjectInfo, PlatformAppConnectionsListItem, PlatformRole, UpsertAppConnectionRequestBody, User, UserIdentity, UserWithMetaInformation, WorkerJobType } from '@activepieces/shared' +import { ApEdition, ApEnvironment, AppConnection, AppConnectionId, AppConnectionOwners, AppConnectionScope, AppConnectionStatus, AppConnectionType, AppConnectionValue, AppConnectionWithoutSensitiveData, ConnectionState, EngineResponse, EngineResponseStatus, ExecuteResolveConnectionIdentifierResponse, ExecuteValidateAuthResponse, MAX_PLATFORM_APP_CONNECTION_OWNERS, OAuth2GrantType, PlatformAppConnectionOwner, PlatformAppConnectionOwnersResponse, PlatformAppConnectionProjectInfo, PlatformAppConnectionsListItem, PlatformRole, UpsertAppConnectionRequestBody, WorkerJobType } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import semver from 'semver' import { ArrayContains, Equal, FindOperator, FindOptionsWhere, ILike, In } from 'typeorm' @@ -19,7 +19,7 @@ import { pieceMetadataService, } from '../../pieces/metadata/piece-metadata-service' import { projectRepo } from '../../project/project-service' -import { userService } from '../../user/user-service' +import { mapToUserWithMetaInformation, userService } from '../../user/user-service' import { userInteractionWatcher } from '../../workers/user-interaction-watcher' import { AppConnectionEntity, @@ -936,29 +936,6 @@ async function fetchFlowIdsForConnections( return flowIdsByExternalId } -function mapToUserWithMetaInformation(owner: (User & { identity?: UserIdentity }) | null): UserWithMetaInformation | null { - if (isNil(owner)) { - return null - } - const identity = owner.identity - if (isNil(identity)) { - return null - } - - return { - id: owner.id, - email: identity.email, - firstName: identity.firstName, - lastName: identity.lastName, - platformId: owner.platformId, - platformRole: owner.platformRole, - status: owner.status, - externalId: owner.externalId, - created: owner.created, - updated: owner.updated, - } -} - function validatePieceVersion(pieceVersion: string): void { if (!semver.valid(pieceVersion)) { throw new ActivepiecesError({ diff --git a/packages/server/api/src/app/database/migration/postgres/1838000000000-AddMcpOAuthTokenLastUsedAndClientKey.ts b/packages/server/api/src/app/database/migration/postgres/1838000000000-AddMcpOAuthTokenLastUsedAndClientKey.ts new file mode 100644 index 000000000000..679301d20516 --- /dev/null +++ b/packages/server/api/src/app/database/migration/postgres/1838000000000-AddMcpOAuthTokenLastUsedAndClientKey.ts @@ -0,0 +1,37 @@ +import { QueryRunner } from 'typeorm' +import { system } from '../../../helper/system/system' +import { AppSystemProp } from '../../../helper/system/system-props' +import { DatabaseType } from '../../database-type' +import { Migration } from '../../migration' + +export class AddMcpOAuthTokenLastUsedAndClientKey1838000000000 implements Migration { + name = 'AddMcpOAuthTokenLastUsedAndClientKey1838000000000' + breaking = false + release = '0.88.4' + transaction = false + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "mcp_oauth_token" + ADD COLUMN IF NOT EXISTS "lastUsedAt" TIMESTAMP WITH TIME ZONE + `) + await queryRunner.query(` + ALTER TABLE "mcp_oauth_token" + ADD COLUMN IF NOT EXISTS "clientKey" character varying(32) + `) + const concurrently = isPGlite() ? '' : 'CONCURRENTLY' + await queryRunner.query('DROP INDEX IF EXISTS "idx_mcp_oauth_token_platform_user_revoked"') + await queryRunner.query(` + CREATE INDEX ${concurrently} IF NOT EXISTS "idx_mcp_oauth_token_platform_user_revoked_created" + ON "mcp_oauth_token" ("platformId", "userId", "revoked", "created") + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX IF EXISTS "idx_mcp_oauth_token_platform_user_revoked_created"') + await queryRunner.query('ALTER TABLE "mcp_oauth_token" DROP COLUMN IF EXISTS "clientKey"') + await queryRunner.query('ALTER TABLE "mcp_oauth_token" DROP COLUMN IF EXISTS "lastUsedAt"') + } +} + +const isPGlite = (): boolean => system.get(AppSystemProp.DB_TYPE) === DatabaseType.PGLITE diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index 874ea39529b7..007c0064b1b6 100644 --- a/packages/server/api/src/app/database/postgres-connection.ts +++ b/packages/server/api/src/app/database/postgres-connection.ts @@ -429,6 +429,7 @@ import { AddAutoCreatePersonalProjectsToPlatform1834000000000 } from './migratio import { WidenMcpOAuthState1835000000000 } from './migration/postgres/1835000000000-WidenMcpOAuthState' import { DropTeamsBotInstallation1836000000000 } from './migration/postgres/1836000000000-DropTeamsBotInstallation' import { AddAiProviderStatus1837000000000 } from './migration/postgres/1837000000000-AddAiProviderStatus' +import { AddMcpOAuthTokenLastUsedAndClientKey1838000000000 } from './migration/postgres/1838000000000-AddMcpOAuthTokenLastUsedAndClientKey' const getSslConfig = (): boolean | TlsOptions => { const useSsl = system.get(AppSystemProp.POSTGRES_USE_SSL) @@ -873,6 +874,7 @@ export const getMigrations = (): (new () => Migration)[] => { WidenMcpOAuthState1835000000000, DropTeamsBotInstallation1836000000000, AddAiProviderStatus1837000000000, + AddMcpOAuthTokenLastUsedAndClientKey1838000000000, ] return migrations } diff --git a/packages/server/api/src/app/ee/agent/agent-controller.ts b/packages/server/api/src/app/ee/agent/agent-controller.ts index 03e92e9e20dc..fad25f6eb0ef 100644 --- a/packages/server/api/src/app/ee/agent/agent-controller.ts +++ b/packages/server/api/src/app/ee/agent/agent-controller.ts @@ -79,7 +79,7 @@ export const agentController: FastifyPluginAsyncZod = async (app) => { projectId: request.projectId, userId: await resolveUserId(request), request: request.body, - goLive: true, + goLive: request.body.goLive ?? true, }) applicationEvents(request.log).sendUserEvent(request, { action: ApplicationEventName.AGENT_UPDATED, diff --git a/packages/server/api/src/app/ee/agent/agent-service.ts b/packages/server/api/src/app/ee/agent/agent-service.ts index daadbe8b2197..29c1ed0f927e 100644 --- a/packages/server/api/src/app/ee/agent/agent-service.ts +++ b/packages/server/api/src/app/ee/agent/agent-service.ts @@ -102,7 +102,7 @@ export const agentService = (log: FastifyBaseLogger) => ({ }) const draft = isNil(request.draft) ? agent.draft : sanitizeObjectForPostgresql(request.draft) const published = goLive && agentUtils.isPublishable(draft) ? draft : agent.published - await agentRepo().save({ ...omit(agent, ['published']), ...request, draft, published, visibility, sharedWithUserIds }) + await agentRepo().save({ ...omit(agent, ['published']), ...omit(request, ['goLive']), draft, published, visibility, sharedWithUserIds }) return this.getOneOrThrow({ id, projectId, userId }) }, diff --git a/packages/server/api/src/app/flows/flow-run/flow-run-ai-usage-tracker.ts b/packages/server/api/src/app/flows/flow-run/flow-run-ai-usage-tracker.ts index 7579de4d1db5..6a33a2269a69 100644 --- a/packages/server/api/src/app/flows/flow-run/flow-run-ai-usage-tracker.ts +++ b/packages/server/api/src/app/flows/flow-run/flow-run-ai-usage-tracker.ts @@ -118,6 +118,7 @@ const MANAGED_MODEL_WEIGHTS: Record = { 'google/gemini-3.1-pro-preview-customtools': 6, 'google/gemini-3.5-flash': 6, 'google/gemini-3.6-flash': 6, + 'google/gemini-3.7-flash': 6, 'mistralai/mistral-medium-3-5': 6, 'moonshotai/kimi-k3': 10, 'openai/gpt-4': 45, diff --git a/packages/server/api/src/app/mcp/oauth/client/mcp-oauth-client-identity.ts b/packages/server/api/src/app/mcp/oauth/client/mcp-oauth-client-identity.ts new file mode 100644 index 000000000000..fa165bdf97b6 --- /dev/null +++ b/packages/server/api/src/app/mcp/oauth/client/mcp-oauth-client-identity.ts @@ -0,0 +1,70 @@ +import { McpOAuthClientKey } from '@activepieces/shared' + +const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '::1', '[::1]'] +const CURSOR_LOOPBACK_PORT = '8787' +const VSCODE_LOOPBACK_PORT = '33418' +const CODEX_CALLBACK_PATH = /^\/callback\/[^/]+$/ +const GEMINI_CLI_CALLBACK_PATH = '/oauth/callback' +const OPENCODE_CALLBACK_PATH = '/mcp/oauth/callback' + +function parseRedirectUri(redirectUri: string): URL | null { + try { + return new URL(redirectUri) + } + catch { + return null + } +} + +function isLoopback(url: URL): boolean { + return LOOPBACK_HOSTS.includes(url.hostname.toLowerCase()) +} + +function clientKeyFromUrl(url: URL): McpOAuthClientKey | null { + const host = url.hostname.toLowerCase() + const scheme = url.protocol.toLowerCase() + const loopback = isLoopback(url) + + if (host === 'claude.ai') { + return 'claude' + } + if (host === 'chatgpt.com') { + return 'chatgpt' + } + if (host === 'www.cursor.com' || scheme === 'cursor:' || (loopback && url.port === CURSOR_LOOPBACK_PORT)) { + return 'cursor' + } + if (host === 'vscode.dev' || scheme === 'vscode:' || scheme === 'vscode-insiders:' || (loopback && url.port === VSCODE_LOOPBACK_PORT)) { + return 'vscode' + } + if (loopback && CODEX_CALLBACK_PATH.test(url.pathname)) { + return 'codex' + } + if (loopback && url.pathname === OPENCODE_CALLBACK_PATH) { + return 'opencode' + } + if (loopback && url.pathname === GEMINI_CLI_CALLBACK_PATH) { + return 'gemini-cli' + } + if (loopback && url.pathname === '/callback') { + return 'claude-code' + } + if (scheme === 'windsurf:') { + return 'windsurf' + } + return null +} + +export const mcpOAuthClientIdentity = { + detectClientKey({ redirectUris }: DetectClientKeyParams): McpOAuthClientKey { + return redirectUris + .map(parseRedirectUri) + .filter((url): url is URL => url !== null) + .map(clientKeyFromUrl) + .find((candidate) => candidate !== null) ?? 'unknown' + }, +} + +type DetectClientKeyParams = { + redirectUris: string[] +} diff --git a/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-token.controller.ts b/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-token.controller.ts index 779abe0c2548..b231bc847864 100644 --- a/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-token.controller.ts +++ b/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-token.controller.ts @@ -72,6 +72,7 @@ async function handleAuthorizationCode({ authorizationHeader, body, reply }: Han } const tokens = await mcpOAuthTokenService.exchangeCode({ + redirectUris: client.redirectUris, codeVerifier: code_verifier, codeChallenge: authCode.codeChallenge, codeChallengeMethod: authCode.codeChallengeMethod, @@ -96,6 +97,7 @@ async function handleRefreshToken({ authorizationHeader, body, reply }: HandlerP if (isNil(client)) return const tokens = await mcpOAuthTokenService.refreshAccessToken({ + redirectUris: client.redirectUris, refreshToken: refresh_token, clientId: client.clientId, }) diff --git a/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-token.entity.ts b/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-token.entity.ts index c4b7c2f2ca5c..0d63c313ed31 100644 --- a/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-token.entity.ts +++ b/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-token.entity.ts @@ -16,6 +16,11 @@ export const McpOAuthTokenEntity = new EntitySchema({ length: 64, nullable: false, }, + clientKey: { + type: String, + length: 32, + nullable: true, + }, userId: ApIdSchema, projectId: { ...ApIdSchema, @@ -36,6 +41,10 @@ export const McpOAuthTokenEntity = new EntitySchema({ nullable: false, default: false, }, + lastUsedAt: { + type: 'timestamp with time zone', + nullable: true, + }, }, indices: [ { @@ -43,5 +52,9 @@ export const McpOAuthTokenEntity = new EntitySchema({ columns: ['refreshToken'], unique: true, }, + { + name: 'idx_mcp_oauth_token_platform_user_revoked_created', + columns: ['platformId', 'userId', 'revoked', 'created'], + }, ], }) diff --git a/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-token.service.ts b/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-token.service.ts index 179d9a998ca4..25d2ee0af0c8 100644 --- a/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-token.service.ts +++ b/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-token.service.ts @@ -1,9 +1,10 @@ import { randomBytes } from 'crypto' -import { apId, sanitizeObjectForPostgresql } from '@activepieces/core-utils' +import { apId, isNil, sanitizeObjectForPostgresql, spreadIfDefined } from '@activepieces/core-utils' import { cryptoUtils } from '@activepieces/server-utils' import { McpOAuthToken } from '@activepieces/shared' import { repoFactory } from '../../../core/db/repo-factory' import { JwtAudience, jwtUtils } from '../../../helper/jwt-utils' +import { mcpOAuthClientIdentity } from '../client/mcp-oauth-client-identity' import { mcpOAuthPkce } from '../mcp-oauth.pkce' import { McpOAuthTokenEntity } from './mcp-oauth-token.entity' @@ -52,12 +53,14 @@ export const mcpOAuthTokenService = { id: apId(), refreshToken: hashedRefreshToken, clientId: params.clientId, + clientKey: mcpOAuthClientIdentity.detectClientKey({ redirectUris: params.redirectUris }), userId: params.userId, projectId: params.projectId, platformId: params.platformId, scopes: params.scopes, expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_30_DAYS_MS).toISOString(), revoked: false, + lastUsedAt: null, created: new Date().toISOString(), updated: new Date().toISOString(), } @@ -89,6 +92,11 @@ export const mcpOAuthTokenService = { throw new OAuthTokenError('invalid_grant', 'Client mismatch') } + await repo().update({ id: record.id }, { + lastUsedAt: new Date().toISOString(), + ...spreadIfDefined('clientKey', isNil(record.clientKey) ? mcpOAuthClientIdentity.detectClientKey({ redirectUris: params.redirectUris }) : undefined), + }) + const accessToken = await issueAccessToken({ userId: record.userId, projectId: record.projectId, @@ -145,6 +153,7 @@ type IssueAccessTokenParams = { } type ExchangeCodeParams = { + redirectUris: string[] codeVerifier: string codeChallenge: string codeChallengeMethod: string @@ -161,6 +170,7 @@ type RevokeRefreshTokenParams = { } type RefreshParams = { + redirectUris: string[] refreshToken: string clientId: string } diff --git a/packages/server/api/src/app/user/user-service.ts b/packages/server/api/src/app/user/user-service.ts index c2291c11e7a5..1fc051c0bfa9 100644 --- a/packages/server/api/src/app/user/user-service.ts +++ b/packages/server/api/src/app/user/user-service.ts @@ -261,6 +261,29 @@ export const userService = (log: FastifyBaseLogger) => ({ }, }) +export function mapToUserWithMetaInformation(user: (User & { identity?: UserIdentity }) | null): UserWithMetaInformation | null { + if (isNil(user)) { + return null + } + const identity = user.identity + if (isNil(identity)) { + return null + } + return { + id: user.id, + email: identity.email, + firstName: identity.firstName, + lastName: identity.lastName, + platformId: user.platformId, + platformRole: user.platformRole, + status: user.status, + externalId: user.externalId, + created: user.created, + updated: user.updated, + lastActiveDate: user.lastActiveDate, + imageUrl: identity.imageUrl, + } +} async function assertNotPlatformOwner({ id, platformId, log }: DeleteParams & { log: FastifyBaseLogger }): Promise { const platform = await platformService(log).getOneOrThrow(platformId) diff --git a/packages/server/api/src/app/variable/variable.service.ts b/packages/server/api/src/app/variable/variable.service.ts index d50f325be257..efa92843172a 100644 --- a/packages/server/api/src/app/variable/variable.service.ts +++ b/packages/server/api/src/app/variable/variable.service.ts @@ -1,11 +1,12 @@ import { ActivepiecesError, ApId, apId, Cursor, ErrorCode, isNil, Metadata, PlatformId, ProjectId, SeekPage, spreadIfDefined, UserId } from '@activepieces/core-utils' -import { AppConnectionOwners, User, UserIdentity, UserWithMetaInformation, Variable, VariableWithoutSensitiveData } from '@activepieces/shared' +import { AppConnectionOwners, Variable, VariableWithoutSensitiveData } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { Equal, ILike, QueryFailedError } from 'typeorm' import { repoFactory } from '../core/db/repo-factory' import { encryptUtils } from '../helper/encryption' import { buildPaginator } from '../helper/pagination/build-paginator' import { paginationHelper } from '../helper/pagination/pagination-utils' +import { mapToUserWithMetaInformation } from '../user/user-service' import { VariableEntity, VariableSchema } from './variable.entity' export const variableRepo = repoFactory(VariableEntity) @@ -188,28 +189,6 @@ function stripSensitiveData(row: VariableSchema): VariableWithoutSensitiveData { const MAX_VARIABLE_OWNERS = 200 -function mapToUserWithMetaInformation(owner: (User & { identity?: UserIdentity }) | null): UserWithMetaInformation | null { - if (isNil(owner)) { - return null - } - const identity = owner.identity - if (isNil(identity)) { - return null - } - return { - id: owner.id, - email: identity.email, - firstName: identity.firstName, - lastName: identity.lastName, - platformId: owner.platformId, - platformRole: owner.platformRole, - status: owner.status, - externalId: owner.externalId, - created: owner.created, - updated: owner.updated, - } -} - type CreateParams = { projectId: string platformId: string diff --git a/packages/server/api/test/helpers/mocks/index.ts b/packages/server/api/test/helpers/mocks/index.ts index ca1026a51b5d..f4dd329c3903 100644 --- a/packages/server/api/test/helpers/mocks/index.ts +++ b/packages/server/api/test/helpers/mocks/index.ts @@ -740,7 +740,7 @@ export const createMockFolder = (folder?: Partial): Folder => { created: folder?.created ?? faker.date.recent().toISOString(), updated: folder?.updated ?? faker.date.recent().toISOString(), projectId: folder?.projectId ?? apId(), - displayName: folder?.displayName ?? faker.lorem.word(), + displayName: folder?.displayName ?? `${faker.lorem.word()}-${apId()}`, displayOrder: folder?.displayOrder ?? faker.number.int({ min: 0, max: 100 }), } } diff --git a/packages/server/api/test/integration/ce/mcp/mcp-oauth-token.test.ts b/packages/server/api/test/integration/ce/mcp/mcp-oauth-token.test.ts index 0ea2805437d8..1303cb404721 100644 --- a/packages/server/api/test/integration/ce/mcp/mcp-oauth-token.test.ts +++ b/packages/server/api/test/integration/ce/mcp/mcp-oauth-token.test.ts @@ -19,6 +19,7 @@ async function exchangeFreshCode(clientId: string): Promise { const codeVerifier = randomBytes(32).toString('base64url') const codeChallenge = createHash('sha256').update(codeVerifier).digest().toString('base64url') const tokens = await mcpOAuthTokenService.exchangeCode({ + redirectUris: [], codeVerifier, codeChallenge, codeChallengeMethod: 'S256', @@ -40,7 +41,7 @@ describe('MCP OAuth token refresh', () => { const clientId = apId() const refreshToken = await exchangeFreshCode(clientId) - const refreshed = await mcpOAuthTokenService.refreshAccessToken({ refreshToken, clientId }) + const refreshed = await mcpOAuthTokenService.refreshAccessToken({ redirectUris: [], refreshToken, clientId }) expect(refreshed.access_token).toBeTypeOf('string') expect(refreshed.refresh_token).toBe(refreshToken) @@ -50,8 +51,8 @@ describe('MCP OAuth token refresh', () => { const clientId = apId() const first = await exchangeFreshCode(clientId) - const second = await mcpOAuthTokenService.refreshAccessToken({ refreshToken: first, clientId }) - const third = await mcpOAuthTokenService.refreshAccessToken({ refreshToken: requireRefreshToken(second), clientId }) + const second = await mcpOAuthTokenService.refreshAccessToken({ redirectUris: [], refreshToken: first, clientId }) + const third = await mcpOAuthTokenService.refreshAccessToken({ redirectUris: [], refreshToken: requireRefreshToken(second), clientId }) expect(third.refresh_token).toBe(first) }) diff --git a/packages/server/api/test/integration/ee/agent/agent-controller.test.ts b/packages/server/api/test/integration/ee/agent/agent-controller.test.ts index 2dfed2a894ab..19fc88091f9b 100644 --- a/packages/server/api/test/integration/ee/agent/agent-controller.test.ts +++ b/packages/server/api/test/integration/ee/agent/agent-controller.test.ts @@ -136,6 +136,78 @@ describe('agent publish', () => { expect((await ctx.get(`/v1/agents/${agent.id}`)).json().published).not.toBeNull() }) + it('stages the draft without going live, so a change can be tested first', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + await ctx.post(`/v1/agents/${agent.id}`, { description: 'Live now.' }) + const live = (await ctx.get(`/v1/agents/${agent.id}`)).json().published + + await ctx.post(`/v1/agents/${agent.id}`, { draft: { ...agentBody(ctx.project.id).draft, instructions: 'Only for the test run.' }, goLive: false }) + + const after = (await ctx.get(`/v1/agents/${agent.id}`)).json() + expect(after.draft.instructions).toBe('Only for the test run.') + expect(after.published).toStrictEqual(live) + }) + + it.each([['explicitly true', true], ['absent', undefined]])( + 'publishes when goLive is %s, so every existing caller keeps working', + async (_label, goLive) => { + const ctx = await context() + const agent = await createAgent(ctx) + + await ctx.post(`/v1/agents/${agent.id}`, { draft: { ...agentBody(ctx.project.id).draft, instructions: 'Should be live.' }, ...(goLive === undefined ? {} : { goLive }) }) + + expect((await ctx.get(`/v1/agents/${agent.id}`)).json().published.instructions).toBe('Should be live.') + }) + + it('keeps published pinned to the same copy across repeated staging', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + await ctx.post(`/v1/agents/${agent.id}`, { description: 'Live copy.' }) + const live = (await ctx.get(`/v1/agents/${agent.id}`)).json().published + + for (const attempt of ['first', 'second', 'third']) { + await ctx.post(`/v1/agents/${agent.id}`, { draft: { ...agentBody(ctx.project.id).draft, instructions: `Staged ${attempt}.` }, goLive: false }) + } + + const after = (await ctx.get(`/v1/agents/${agent.id}`)).json() + expect(after.draft.instructions).toBe('Staged third.') + expect(after.published).toStrictEqual(live) + }) + + it('stages against an agent nobody published yet without inventing a published copy', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + expect(agent.published).toBeNull() + + await ctx.post(`/v1/agents/${agent.id}`, { draft: { ...agentBody(ctx.project.id).draft, instructions: 'Only a draft.' }, goLive: false }) + + const after = (await ctx.get(`/v1/agents/${agent.id}`)).json() + expect(after.draft.instructions).toBe('Only a draft.') + expect(after.published).toBeNull() + }) + + it('publishes the staged draft when the explicit publish route is used afterwards', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + + await ctx.post(`/v1/agents/${agent.id}`, { draft: { ...agentBody(ctx.project.id).draft, instructions: 'Staged for review.' }, goLive: false }) + await ctx.post(`/v1/agents/${agent.id}/publish`) + + expect((await ctx.get(`/v1/agents/${agent.id}`)).json().published.instructions).toBe('Staged for review.') + }) + + it('goes live on the next save, so staging is not a trap', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + + await ctx.post(`/v1/agents/${agent.id}`, { draft: { ...agentBody(ctx.project.id).draft, instructions: 'Staged.' }, goLive: false }) + await ctx.post(`/v1/agents/${agent.id}`, { displayName: 'Ready' }) + + const after = (await ctx.get(`/v1/agents/${agent.id}`)).json() + expect(after.published.instructions).toBe('Staged.') + }) + it('publishes nothing while the instructions are empty, because there is nothing runnable to pin', async () => { const ctx = await context() const agent = await createAgent(ctx, { draft: { ...agentBody(ctx.project.id).draft, instructions: '' } }) diff --git a/packages/server/api/test/unit/app/mcp/oauth/client/mcp-oauth-client-identity.test.ts b/packages/server/api/test/unit/app/mcp/oauth/client/mcp-oauth-client-identity.test.ts new file mode 100644 index 000000000000..cdf9070f29b4 --- /dev/null +++ b/packages/server/api/test/unit/app/mcp/oauth/client/mcp-oauth-client-identity.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { mcpOAuthClientIdentity } from '../../../../../../src/app/mcp/oauth/client/mcp-oauth-client-identity' + +function detectClientKey(redirectUris: string[]) { + return mcpOAuthClientIdentity.detectClientKey({ redirectUris }) +} + +describe('mcpOAuthClientIdentity.detectClientKey', () => { + it('identifies remote-dialing web clients by host', () => { + expect(detectClientKey(['https://claude.ai/api/mcp/auth_callback'])).toBe('claude') + expect(detectClientKey(['https://chatgpt.com/connector_platform_oauth_redirect'])).toBe('chatgpt') + expect(detectClientKey(['https://www.cursor.com/api/auth/mcp/callback'])).toBe('cursor') + expect(detectClientKey(['https://vscode.dev/redirect'])).toBe('vscode') + }) + + it('identifies editors by their private-use scheme', () => { + expect(detectClientKey(['cursor://anysphere.cursor-retrieval/oauth/callback'])).toBe('cursor') + expect(detectClientKey(['vscode://mcp/callback'])).toBe('vscode') + expect(detectClientKey(['vscode-insiders://mcp/callback'])).toBe('vscode') + expect(detectClientKey(['windsurf://mcp/callback'])).toBe('windsurf') + }) + + it('identifies editors by their reserved loopback port', () => { + expect(detectClientKey(['http://127.0.0.1:8787/callback'])).toBe('cursor') + expect(detectClientKey(['http://localhost:33418/callback'])).toBe('vscode') + }) + + it('separates the CLIs by their loopback callback path', () => { + expect(detectClientKey(['http://localhost:1455/callback/abc123'])).toBe('codex') + expect(detectClientKey(['http://localhost:54545/callback'])).toBe('claude-code') + expect(detectClientKey(['http://127.0.0.1:9999/callback'])).toBe('claude-code') + expect(detectClientKey(['http://localhost:41337/oauth/callback'])).toBe('gemini-cli') + expect(detectClientKey(['http://127.0.0.1:19876/mcp/oauth/callback'])).toBe('opencode') + }) + + it('never hides an unmatched grant, and never trusts clientName as a signal', () => { + expect(detectClientKey(['https://example.com/oauth/callback'])).toBe('unknown') + expect(detectClientKey([])).toBe('unknown') + expect(detectClientKey(['not a url'])).toBe('unknown') + }) +}) diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-tool-policy.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-tool-policy.ts index deb5b5dcf89f..e12c9f894274 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-tool-policy.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-tool-policy.ts @@ -1,7 +1,28 @@ -import { AgentRunSource } from '@activepieces/shared' +import { AgentRunSource, mcpToolNameUtils, TASK_COMPLETION_TOOL_NAME } from '@activepieces/shared' import { ToolSet } from 'ai' const UNATTENDED_WEB_TOOLS = ['ap_fetch_url', 'ap_web_search', 'ap_scrape_url'] +const BUILT_IN_TOOL_PREFIX = 'ap_' + +function withValidNames({ tools, reserved = [] }: { tools: T[], reserved?: string[] }): T[] { + const taken = new Set(reserved) + return tools.map((tool) => { + const claimable = toClaimableName(tool.toolName) + let name = claimable + for (let attempt = 1; taken.has(name); attempt++) { + name = toClaimableName(`${claimable}_${attempt}`) + } + taken.add(name) + return name === tool.toolName ? tool : { ...tool, toolName: name } + }) +} + +function toClaimableName(name: string): string { + const valid = mcpToolNameUtils.toValidToolName(name) + return valid.startsWith(BUILT_IN_TOOL_PREFIX) || valid === TASK_COMPLETION_TOOL_NAME + ? mcpToolNameUtils.createToolName(`tool_${valid}`) + : valid +} // Listed, never subtracted: a group missing from a branch is unreachable, so a group added // elsewhere cannot leak into a surface that should not have it. @@ -54,7 +75,7 @@ function selectToolsForSource({ source, groups }: { source: AgentRunSource, grou } } -export const agentToolPolicy = { selectToolsForSource } +export const agentToolPolicy = { selectToolsForSource, withValidNames } export { UNATTENDED_WEB_TOOLS } export type AgentToolGroups = { diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts index 9fc7c1672e7c..f2b664952dad 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts @@ -44,7 +44,11 @@ export const executeAgentRunJob: JobHandler tool.toolName) }) + const configuredPieceTools = configuredTools.filter(isPieceTool) + const configuredKnowledgeBaseTools = configuredTools.filter(isKnowledgeBaseTool) + const reportedTools = [...configuredPieceTools, ...configuredKnowledgeBaseTools] const sendEventWithRetry = ({ event }: { event: AgentEvent }) => retryWithBackoff({ @@ -82,7 +86,7 @@ export const executeAgentRunJob: JobHandler pushProgress(output) const reportProgress = (uiParts: PersistedAgentPart[]) => - pushProgress(stepResultFrom({ prompt: userMessage, uiParts, timestamp: new Date().toISOString(), tools: configuredPieceTools, stillRunning: true })) + pushProgress(stepResultFrom({ prompt: userMessage, uiParts, timestamp: new Date().toISOString(), tools: reportedTools, stillRunning: true })) try { const config = await ctx.apiClient.getAgentConfig({ @@ -130,8 +134,7 @@ export const executeAgentRunJob: JobHandler { @@ -294,7 +297,7 @@ export const executeAgentRunJob: JobHandler ctx.apiClient.saveAgentMessages(savePayload), description: 'Saving the transcript', throwOnExhausted: true, log }) - answer = stepResultFrom({ prompt: userMessage, uiParts, timestamp: new Date().toISOString(), tools: configuredPieceTools, structuredOutput: structured.output, failure: incompleteReason({ truncatedAfterRetries, budgetExceeded }) }) + answer = stepResultFrom({ prompt: userMessage, uiParts, timestamp: new Date().toISOString(), tools: reportedTools, structuredOutput: structured.output, failure: incompleteReason({ truncatedAfterRetries, budgetExceeded }) }) if (autoTitle) { await sendEventWithRetry({ @@ -370,7 +373,7 @@ export const executeAgentRunJob: JobHandler releaseFlowStep({ ctx, conversationId, flowRunId, waitpointId, output: failedResult, source, log })) // Empty arrays here mean "mark this turn ERROR" — they do NOT wipe history. The diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-personalization-research.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-personalization-research.ts index bdb7188b72fb..43c0f12529fd 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-personalization-research.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-personalization-research.ts @@ -1,4 +1,4 @@ -import { AIProviderName, isNil, tryCatch } from '@activepieces/core-utils' +import { AIProviderName, isNil, slugify, tryCatch } from '@activepieces/core-utils' import { agentAiUtils, safeHttp } from '@activepieces/server-utils' import { CHAT_SUGGESTION_CARD_IMAGE_IDS, EngineResponseStatus, ExecutePersonalizationResearchJobData, WorkerJobType } from '@activepieces/shared' import { generateObject, generateText, LanguageModel, stepCountIs } from 'ai' @@ -747,10 +747,6 @@ function editDistance(a: string, b: string): number { return previous[b.length] } -function slugify(value: string): string { - return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') -} - function stripControlChars(value: string): string { // eslint-disable-next-line no-control-regex return value.replace(/[\u0000-\u001f\u007f]/g, '') diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/run-agent-turn.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/run-agent-turn.ts index 005cf1f2cf04..0329b054b822 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/run-agent-turn.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/run-agent-turn.ts @@ -15,6 +15,7 @@ const STREAM_RETRY_BASE_DELAY_MS = 1_000 const QUOTA_MARKER = /insufficient_quota/i const CREDIT_ERROR_PATTERNS = [/credits/i, /\b402\b/, /payment.required/i, QUOTA_MARKER] const USER_FAULT_STATUS_CODES = new Set([401, 403, 404]) +const MODEL_UNAVAILABLE_PATTERNS = [/\bis deprecated\b/i, /no longer (available|supported)/i, /\bmodel_not_found\b/i, /\bunknown model\b/i, /\bdecommissioned\b/i] const USER_CONFIG_ENTITY_TYPES = new Set(['AIProvider', 'ChatAiProvider']) const CONTINUE_NUDGE = '[system note — not from the user] Your previous response was cut off by the output token limit before it finished. Continue exactly where you stopped. If a tool call was cut off, re-issue it in FULL. Do not repeat content you already produced.' const EMPTY_OUTPUT_NUDGE = '[system note — not from the user] Your previous step produced no visible reply to the user. Continue the task now: either call the next tool, or write your reply to the user. Do not stop silently.' @@ -339,6 +340,9 @@ export function classifyAgentRunError({ error, provider }: { error: unknown, pro ? 'user' : 'internal' } + if (apiError.statusCode === 400 && provider !== AIProviderName.ACTIVEPIECES && MODEL_UNAVAILABLE_PATTERNS.some((pattern) => pattern.test(message))) { + return 'user' + } return USER_FAULT_STATUS_CODES.has(apiError.statusCode ?? 0) && (apiError.statusCode === 404 || provider !== AIProviderName.ACTIVEPIECES) ? 'user' diff --git a/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-tool-policy.test.ts b/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-tool-policy.test.ts index 797f669f623d..dadbaf51daf5 100644 --- a/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-tool-policy.test.ts +++ b/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-tool-policy.test.ts @@ -1,4 +1,4 @@ -import { AgentRunSource } from '@activepieces/shared' +import { AgentRunSource, mcpToolNameUtils } from '@activepieces/shared' import { Tool, ToolSet } from 'ai' import { describe, expect, it } from 'vitest' import { agentToolPolicy, AgentToolGroups } from '../../../../../../src/lib/execute/jobs/ee/agent/agent-tool-policy' @@ -182,3 +182,68 @@ describe('the shape of the policy itself', () => { } }) }) + +describe('withValidNames', () => { + const names = (tools: { toolName: string }[], reserved?: string[]): string[] => + agentToolPolicy.withValidNames({ tools, ...(reserved === undefined ? {} : { reserved }) }).map((tool) => tool.toolName) + + it('rewrites a name the providers would reject and keeps the rest of the tool', () => { + const [rewritten] = agentToolPolicy.withValidNames({ tools: [{ toolName: 'Company Docs', sourceId: 'kb-1' }] }) + + expect(rewritten.toolName).toMatch(PROVIDER_PATTERN) + expect(rewritten.sourceId).toBe('kb-1') + }) + + it('leaves an already generated name alone, because createToolName is not idempotent', () => { + const generated = mcpToolNameUtils.createToolName('Company Docs') + + expect(names([{ toolName: generated }])).toEqual([generated]) + }) + + it('rewrites a dotted name, which only Anthropic accepts', () => { + expect(names([{ toolName: 'handbook.pdf' }])).not.toEqual(['handbook.pdf']) + }) + + it('keeps two tools that would otherwise collapse onto one toolset key', () => { + const collided = names([{ toolName: 'Company Docs' }, { toolName: 'Company docs' }]) + + expect(new Set(collided).size).toBe(2) + for (const name of collided) { + expect(name).toMatch(PROVIDER_PATTERN) + } + }) + + it('keeps tools whose names share no characters a provider accepts', () => { + const collided = names([{ toolName: '\u6587\u6863' }, { toolName: '\u691c\u7d22' }, { toolName: '!!!' }]) + + expect(new Set(collided).size).toBe(3) + }) + + it('does not hand a collision the name of a tool that already claimed it', () => { + const collided = names([{ toolName: 'x_2' }, { toolName: 'x' }, { toolName: 'x' }]) + + expect(new Set(collided).size).toBe(3) + }) + + it('does not reuse a name another tool group already claimed', () => { + expect(names([{ toolName: 'company_docs' }], ['company_docs'])).not.toEqual(['company_docs']) + }) + + it('never lets a configured tool hold a built-in name, which the policy spreads last', () => { + for (const builtIn of ['ap_fetch_url', 'ap_show_questions', 'updateTaskStatus']) { + const [name] = names([{ toolName: builtIn }]) + + expect(name).not.toBe(builtIn) + expect(name.startsWith('ap_')).toBe(false) + expect(name).toMatch(PROVIDER_PATTERN) + } + }) + + it('trims a name past the 64 character limit', () => { + const [name] = names([{ toolName: 'a'.repeat(200) }]) + + expect(name.length).toBeLessThanOrEqual(64) + }) +}) + +const PROVIDER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$/ diff --git a/packages/server/worker/test/lib/execute/jobs/ee/agent/run-agent-turn-guards.test.ts b/packages/server/worker/test/lib/execute/jobs/ee/agent/run-agent-turn-guards.test.ts index 148d2305d5db..f822301d1ff5 100644 --- a/packages/server/worker/test/lib/execute/jobs/ee/agent/run-agent-turn-guards.test.ts +++ b/packages/server/worker/test/lib/execute/jobs/ee/agent/run-agent-turn-guards.test.ts @@ -45,6 +45,27 @@ describe('classifyAgentRunError', () => { expect(classify(apiError({ statusCode, message: 'the provider said no' }))).toBe(expected) }) + it.each([ + 'Grok 4.1 Fast is deprecated', + 'gemini-2.5-pro is no longer available', + 'the model was decommissioned', + ])('blames the user for a 400 that names a retired model: %s', (message) => { + expect(classify(apiError({ statusCode: 400, message }))).toBe('user') + }) + + it('does not read the retired-model marker out of a response body, which carries error pages we did not write', () => { + expect(classify(apiError({ statusCode: 400, message: 'Bad Request', responseBody: '
this endpoint is deprecated
' }))).toBe('internal') + }) + + it('never blames the user for a retired model on the managed key, which we chose for them', () => { + expect(classify(apiError({ statusCode: 400, message: 'Grok 4.1 Fast is deprecated' }), AIProviderName.ACTIVEPIECES)).toBe('internal') + expect(classify(apiError({ statusCode: 400, message: 'Grok 4.1 Fast is deprecated' }), AIProviderName.OPENROUTER)).toBe('user') + }) + + it('keeps a 400 we caused internal, so an illegal tool name is not laundered as user config', () => { + expect(classify(apiError({ statusCode: 400, message: "tools.0.name: should match pattern '^[a-zA-Z0-9_.-]{1,64}$'" }))).toBe('internal') + }) + it('never blames the user for the managed key, which is ours and fails everyone at once', () => { for (const statusCode of [401, 403]) { expect(classify(apiError({ statusCode, message: 'Unauthorized' }), AIProviderName.ACTIVEPIECES)).toBe('internal') diff --git a/packages/web/package.json b/packages/web/package.json index f4de6f72377c..f6a6a4d3a8ea 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -118,6 +118,8 @@ }, "devDependencies": { "@tailwindcss/postcss": "4.1.17", + "@testing-library/react": "16.3.3", + "jsdom": "26.1.0", "@tailwindcss/vite": "4.1.17", "@types/canvas-confetti": "^1.9.0", "@types/deep-equal": "1.0.1", diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 556669d9bf1f..5633e48758d6 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -1,4 +1,14 @@ { + "Write instructions before testing": "Write instructions before testing", + "Try it before it goes live": "Try it before it goes live", + "Needs a model to run": "Needs a model to run", + "Try “Only reply to paying customers” or “Add Slack and Notion”.": "Try “Only reply to paying customers” or “Add Slack and Notion”.", + "Give it a real task. It runs on the settings beside you.": "Give it a real task. It runs on the settings beside you.", + "Test": "Test", + "Pick a model before testing": "Pick a model before testing", + "Try {name}...": "Try {name}...", + "Give the agent a real task. It answers on the settings beside you, so you can watch a change work before every flow gets it.": "Give the agent a real task. It answers on the settings beside you, so you can watch a change work before every flow gets it.", + "Your changes couldn't be staged for testing. Try again.": "Your changes couldn't be staged for testing. Try again.", "Support": "Support", "Runs": "Runs", "Edit flow": "Edit flow", @@ -2489,6 +2499,78 @@ "Activate Trial Key": "Activate Trial Key", "Enter your trial key to unlock enterprise features.": "Enter your trial key to unlock enterprise features.", "Enter your trial key": "Enter your trial key", + "Add to Claude": "Add to Claude", + "Add to Cursor": "Add to Cursor", + "Add to VS Code": "Add to VS Code", + "Any MCP client": "Any MCP client", + "All projects": "All projects", + "MCP client": "MCP client", + "Add the connector": "Add the connector", + "Add the server": "Add the server", + "All clients": "All clients", + "All {total} clients": "All {total} clients", + "Anything else": "Anything else", + "Authenticate": "Authenticate", + "Chat apps": "Chat apps", + "Check it works": "Check it works", + "Check your client’s docs for where the server URL goes.": "Check your client’s docs for where the server URL goes.", + "Client not listed?": "Client not listed?", + "Desktop and web · needs a public HTTPS address": "Desktop and web · needs a public HTTPS address", + "Editor · runs locally": "Editor · runs locally", + "Editors": "Editors", + "MCP": "MCP", + "MCP server JSON": "MCP server JSON", + "No client matches your search.": "No client matches your search.", + "Opens VS Code and writes the server into .vscode/mcp.json for this workspace.": "Opens VS Code and writes the server into .vscode/mcp.json for this workspace.", + "Pass --scope project to keep it to one repository.": "Pass --scope project to keep it to one repository.", + "Paste the server URL": "Paste the server URL", + "Run /mcp inside Claude Code and pick Authenticate.": "Run /mcp inside Claude Code and pick Authenticate.", + "Streamable HTTP · OAuth": "Streamable HTTP · OAuth", + "Terminal": "Terminal", + "Terminal · runs locally": "Terminal · runs locally", + "The client opens an OAuth prompt on the first tool call. Approve the project.": "The client opens an OAuth prompt on the first tool call. Approve the project.", + "Your server URL is not reachable from the internet, so this client cannot dial it.": "Your server URL is not reachable from the internet, so this client cannot dial it.", + "Add a connector": "Add a connector", + "Config file": "Config file", + "Copy config": "Copy config", + "Copy link": "Copy link", + "From the folder you want the tools available in.": "From the folder you want the tools available in.", + "If it answers with your tools, you’re set.": "If it answers with your tools, you’re set.", + "Need the exact steps?": "Need the exact steps?", + "No API keys to manage": "No API keys to manage", + "One click install": "One click install", + "One command": "One command", + "One link for everywhere you use AI.": "One link for everywhere you use AI.", + "Pick a client for step-by-step setup, or copy the link and paste it wherever you like.": "Pick a client for step-by-step setup, or copy the link and paste it wherever you like.", + "Revoke any client in one click": "Revoke any client in one click", + "Run this in your terminal": "Run this in your terminal", + "Search {total} clients": "Search {total} clients", + "See all {total} clients": "See all {total} clients", + "See the raw config": "See the raw config", + "Streamable HTTP or SSE. Point it at the link and it works.": "Streamable HTTP or SSE. Point it at the link and it works.", + "Using something else?": "Using something else?", + "Watch the full setup": "Watch the full setup", + "Where do you want to use it?": "Where do you want to use it?", + "Your AI gets all of this": "Your AI gets all of this", + "{count} pieces, plus every flow you’ve built — ready to run.": "{count} pieces, plus every flow you’ve built — ready to run.", + "Every piece you can use, plus every flow you’ve built.": "Every piece you can use, plus every flow you’ve built.", + "Your AI stops guessing and starts doing — sending the Slack message, updating the CRM, running the flow. Paste it into any client that speaks MCP.": "Your AI stops guessing and starts doing — sending the Slack message, updating the CRM, running the flow. Paste it into any client that speaks MCP.", + "desktop and web, needs a public HTTPS URL": "desktop and web, needs a public HTTPS URL", + "one command, nothing to edit": "one command, nothing to edit", + "we write the config for you": "we write the config for you", + "{client} docs": "{client} docs", + "Or edit {path}": "Or edit {path}", + "{client} opens your browser on the first tool call. Approve the project.": "{client} opens your browser on the first tool call. Approve the project.", + "Opens Cursor and writes the server into ~/.cursor/mcp.json.": "Opens Cursor and writes the server into ~/.cursor/mcp.json.", + "Paste the server URL as a custom connector. This client dials your server from the internet, so localhost will not reach it.": "Paste the server URL as a custom connector. This client dials your server from the internet, so localhost will not reach it.", + "Paste the server URL as a connector. ChatGPT dials your server from the internet, so localhost will not reach it.": "Paste the server URL as a connector. ChatGPT dials your server from the internet, so localhost will not reach it.", + "Add a Streamable HTTP MCP server pointing at this URL — the {client} docs say where its server list lives.": "Add a Streamable HTTP MCP server pointing at this URL — the {client} docs say where its server list lives.", + "{client} opens {brand} in your browser. Approve the project it can reach.": "{client} opens {brand} in your browser. Approve the project it can reach.", + "What {brand} tools do you have?": "What {brand} tools do you have?", + "Post a note in Slack that the deploy finished.": "Post a note in Slack that the deploy finished.", + "This server is already listed in Claude’s directory — add it there in one click.": "This server is already listed in Claude’s directory — add it there in one click.", + "Add from the Claude directory": "Add from the Claude directory", + "Open Cascade, click the plugins icon, then Manage plugins → View raw config.": "Open Cascade, click the plugins icon, then Manage plugins → View raw config.", "Could not save this key": "Could not save this key", "Who am I teaming up with?": "Who am I teaming up with?", "I'm your AI teammate — research, emails, whole automations, run end to end. Tell me who you are and I'll line up examples built just for you.": "I'm your AI teammate — research, emails, whole automations, run end to end. Tell me who you are and I'll line up examples built just for you.", diff --git a/packages/web/src/app/builder/pieces-selector/generic-piece-selector-item.tsx b/packages/web/src/app/builder/pieces-selector/generic-piece-selector-item.tsx index fae05d9c3961..8bff696be9da 100644 --- a/packages/web/src/app/builder/pieces-selector/generic-piece-selector-item.tsx +++ b/packages/web/src/app/builder/pieces-selector/generic-piece-selector-item.tsx @@ -1,6 +1,4 @@ -import type { ActionClassification } from '@activepieces/pieces-framework'; import { FlowActionType, FlowTriggerType } from '@activepieces/shared'; -import { t } from 'i18next'; import { CardListItem } from '@/components/custom/card-list'; import { Badge } from '@/components/ui/badge'; @@ -10,6 +8,7 @@ import { StepMetadataWithSuggestions, PIECE_SELECTOR_ELEMENTS_HEIGHTS, } from '@/features/pieces'; +import { ACTION_CLASSIFICATION_BADGES } from '@/features/pieces/utils/action-classification'; import { cn } from '@/lib/utils'; type GenericActionOrTriggerItemProps = { item: PieceSelectorItem; @@ -79,12 +78,13 @@ const GenericActionOrTriggerItem = ({ {pieceSelectorItemInfo.classification && ( - {CLASSIFICATION_BADGE[ + {ACTION_CLASSIFICATION_BADGES[ pieceSelectorItemInfo.classification ].label()} @@ -107,13 +107,3 @@ const GenericActionOrTriggerItem = ({ GenericActionOrTriggerItem.displayName = 'GenericActionOrTriggerItem'; export default GenericActionOrTriggerItem; - -const CLASSIFICATION_BADGE: Record< - ActionClassification, - { label: () => string; variant: 'accent' | 'destructive' } -> = { - READ: { label: () => t('Read'), variant: 'accent' }, - SEARCH: { label: () => t('Search'), variant: 'accent' }, - WRITE: { label: () => t('Write'), variant: 'accent' }, - DESTRUCTIVE: { label: () => t('Destructive'), variant: 'destructive' }, -}; diff --git a/packages/web/src/app/components/global-search/static-pages.ts b/packages/web/src/app/components/global-search/static-pages.ts index fab2fbca9a46..e46f52582f7c 100644 --- a/packages/web/src/app/components/global-search/static-pages.ts +++ b/packages/web/src/app/components/global-search/static-pages.ts @@ -3,6 +3,7 @@ import { type ComponentType } from 'react'; import { BotIcon } from '@/components/icons/bot'; import { ChartLineIcon } from '@/components/icons/chart-line'; import { CompassIcon } from '@/components/icons/compass'; +import { ConnectIcon } from '@/components/icons/connect'; import { FileHeartIcon } from '@/components/icons/file-heart'; import { FileJson2Icon } from '@/components/icons/file-json2'; import { FrameIcon } from '@/components/icons/frame'; @@ -49,6 +50,12 @@ export const STATIC_PAGES: StaticPage[] = [ href: '/impact', icon: ChartLineIcon, }, + { + id: 'page-mcp', + label: 'MCP', + href: '/mcp-server', + icon: ConnectIcon, + }, // Platform Admin pages { id: 'page-platform-projects', diff --git a/packages/web/src/app/components/primary-rail/index.tsx b/packages/web/src/app/components/primary-rail/index.tsx index 24cdb2e73b6e..ceacc4be72a9 100644 --- a/packages/web/src/app/components/primary-rail/index.tsx +++ b/packages/web/src/app/components/primary-rail/index.tsx @@ -1,3 +1,4 @@ +import { Permission } from '@activepieces/core-utils'; import { ApEdition, ApFlagId, @@ -23,6 +24,7 @@ import { Shield, SlidersHorizontal, SquarePen, + Unplug, UserCogIcon, } from 'lucide-react'; import { motion, useReducedMotion } from 'motion/react'; @@ -59,7 +61,10 @@ import { } from '@/features/projects'; import { templatesTelemetryApi } from '@/features/templates'; import { useRailCollapsed } from '@/features/workspace/lib/rail-collapsed'; -import { useIsPlatformAdmin } from '@/hooks/authorization-hooks'; +import { + useAuthorization, + useIsPlatformAdmin, +} from '@/hooks/authorization-hooks'; import { flagsHooks } from '@/hooks/flags-hooks'; import { platformHooks } from '@/hooks/platform-hooks'; import { userHooks } from '@/hooks/user-hooks'; @@ -81,6 +86,7 @@ export function PrimaryRail() { toggle: toggleCollapsed, } = useRailCollapsed(); const showAgents = useAgentsNavVisible(); + const { checkAccess } = useAuthorization(); if (embedState.isEmbedded || embedState.hideSideNav) { return null; @@ -153,6 +159,15 @@ export function PrimaryRail() { label={t('Impact')} isActive={({ pathname }) => pathname.startsWith('/impact')} /> + {checkAccess(Permission.READ_MCP) && ( + pathname.startsWith('/mcp-server')} + /> + )} diff --git a/packages/web/src/app/components/project-layout/index.tsx b/packages/web/src/app/components/project-layout/index.tsx index 3126d9caa7df..5ce671c8acff 100644 --- a/packages/web/src/app/components/project-layout/index.tsx +++ b/packages/web/src/app/components/project-layout/index.tsx @@ -1,5 +1,6 @@ import { isNil } from '@activepieces/core-utils'; import { ApEdition, ApFlagId } from '@activepieces/shared'; +import { Unplug } from 'lucide-react'; import React, { ComponentType } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; @@ -85,6 +86,13 @@ export function ProjectDashboardLayout({ icon: BotIcon, hasPermission: true, }, + { + to: '/mcp-server', + label: t('MCP'), + show: !isEmbedded, + icon: Unplug, + hasPermission: true, + }, ]; const hideHeader = diff --git a/packages/web/src/app/components/project-settings/mcp-server/mcp-credentials.tsx b/packages/web/src/app/components/project-settings/mcp-server/mcp-credentials.tsx index 75c1a93e9e7a..03643b60a681 100644 --- a/packages/web/src/app/components/project-settings/mcp-server/mcp-credentials.tsx +++ b/packages/web/src/app/components/project-settings/mcp-server/mcp-credentials.tsx @@ -1,13 +1,11 @@ -import { ApFlagId } from '@activepieces/shared'; import { t } from 'i18next'; +import { useMcpServerUrl } from '@/app/routes/mcp-server/mcp-server-url'; import { CopyButton } from '@/components/custom/clipboard/copy-button'; import { CollapsibleJson } from '@/components/custom/collapsible-json'; -import { flagsHooks } from '@/hooks/flags-hooks'; export function McpCredentials() { - const { data: publicUrl } = flagsHooks.useFlag(ApFlagId.PUBLIC_URL); - const serverUrl = `${(publicUrl ?? '').replace(/\/$/, '')}/mcp`; + const { serverUrl } = useMcpServerUrl(); const jsonConfiguration = { mcpServers: { diff --git a/packages/web/src/app/routes/agents/id/index.tsx b/packages/web/src/app/routes/agents/id/index.tsx index 449d0f7018aa..f118e0178dc4 100644 --- a/packages/web/src/app/routes/agents/id/index.tsx +++ b/packages/web/src/app/routes/agents/id/index.tsx @@ -19,12 +19,14 @@ import { ChevronLeft, ChevronsLeft, ChevronsRight, + FlaskConical, + Loader2, Rocket, Settings2, Sparkles, } from 'lucide-react'; import { motion } from 'motion/react'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { unstable_useBlocker, @@ -74,6 +76,9 @@ import { import { api } from '@/lib/api'; import { cn } from '@/lib/utils'; +import { agentEditState, HeaderStatus } from '../lib/agent-edit-state'; +import { agentTestGate } from '../lib/agent-test-gate'; + const ConfigureAgentSchema = z.object({ displayName: z.string().min(1, formErrors.required), description: z.string(), @@ -122,6 +127,12 @@ const buildCapabilityNote = (agent: Agent): string => { const CONVERSATION_QUERY_PARAM = 'conversation'; +const HEADER_STATUS_COPY: Record string> = { + 'needs-model': () => t('Needs a model to run'), + live: () => t('Live'), + pending: () => t('Changes not live yet'), +}; + type AgentRequirement = { label: string; hint: string; @@ -375,14 +386,18 @@ const useWarnBeforeLosingChanges = (hasChanges: boolean) => { }; const LeaveWithoutSavingDialog = ({ - blocker, + open, + onKeepEditing, + onDiscard, }: { - blocker: ReturnType; + open: boolean; + onKeepEditing: () => void; + onDiscard: () => void; }) => ( { - if (!open) blocker.reset?.(); + open={open} + onOpenChange={(next) => { + if (!next) onKeepEditing(); }} > @@ -395,10 +410,10 @@ const LeaveWithoutSavingDialog = ({ - - @@ -429,6 +444,7 @@ const AgentEditScreen = ({ onEdited: () => void; }) => { const [tab, setTab] = useState('configure'); + const [mode, setMode] = useState('edit'); const [syncedDraft, setSyncedDraft] = useState(() => formValuesOf(agent), ); @@ -438,58 +454,143 @@ const AgentEditScreen = ({ mode: 'onChange', }); const updateAgent = agentsMutations.useUpdateAgent({ id: agent.id }); + const stageDraft = agentsMutations.useUpdateAgent({ id: agent.id }); const [justLaunched, setJustLaunched] = useState(false); + const [testConversationId, setTestConversationId] = useState( + null, + ); const values = form.watch(); const formNeedsModel = isNil(values.draft?.modelName) || isNil(values.draft?.provider); + const testGate = agentTestGate.blockedReason({ draft: values.draft }); + const blockedFromTesting = isNil(testGate) + ? null + : testGate === 'model' + ? t('Pick a model before testing') + : t('Write instructions before testing'); const live = liveValuesOf(agent); const hasChanges = - isNil(live) || JSON.stringify(values) !== JSON.stringify(live); - const unsavedTyping = JSON.stringify(values) !== JSON.stringify(syncedDraft); + isNil(live) || !agentEditState.sameConfig({ left: values, right: live }); + const unsavedTyping = !agentEditState.sameConfig({ + left: values, + right: syncedDraft, + }); const leaveBlocker = useWarnBeforeLosingChanges(unsavedTyping); + const [exitRequested, setExitRequested] = useState(false); + const leaveDecision = agentEditState.leaveGuard({ + blockerState: leaveBlocker.state, + exitRequested, + }); + const testRequested = useRef(false); + const writeSeq = useRef(0); + const writeLock = useRef(agentEditState.createWriteLock()); useEffect(() => { const fromServer = formValuesOf(agent); - if (JSON.stringify(fromServer) === JSON.stringify(syncedDraft)) return; + if (agentEditState.sameConfig({ left: fromServer, right: syncedDraft })) + return; if (unsavedTyping) return; form.reset(fromServer); setSyncedDraft(fromServer); }, [agent, syncedDraft, unsavedTyping, form]); + const setServerError = (error: Error, fallback: string) => + form.setError('root.serverError', { + type: 'manual', + message: api.extractServerErrorMessage(error, fallback), + }); + + const releaseWrite = () => writeLock.current.release(); + const claimWrite = () => writeLock.current.claim(); + + const openTestWithLatestEdits = form.handleSubmit((values) => { + const seq = ++writeSeq.current; + return stageDraft.mutate( + { ...toUpdateRequest(values), goLive: false }, + { + onSuccess: () => { + if (seq !== writeSeq.current) return; + setSyncedDraft(values); + if (testRequested.current) setMode('test'); + }, + onError: (error) => + setServerError( + error, + t("Your changes couldn't be staged for testing. Try again."), + ), + onSettled: releaseWrite, + }, + ); + }, releaseWrite); + + const changeMode = (next: string) => { + testRequested.current = next === 'test'; + const intent = agentEditState.modeIntent({ + next, + unsavedTyping, + blockedReason: blockedFromTesting, + }); + if (intent === 'switch') { + setMode(next); + return; + } + if (!claimWrite()) return; + void openTestWithLatestEdits(); + }; + const handleSubmit = (values: ConfigureAgentValues) => { form.clearErrors('root.serverError'); + const seq = ++writeSeq.current; updateAgent.mutate(toUpdateRequest(values), { onSuccess: () => { - form.reset(values); + if (seq !== writeSeq.current) return; setSyncedDraft(values); setJustLaunched(true); window.setTimeout(() => setJustLaunched(false), 1600); toast(t('Live — every flow using this agent just got the update')); }, onError: (error) => - form.setError('root.serverError', { - type: 'manual', - message: api.extractServerErrorMessage( - error, - t("Your changes weren't saved. Try again."), - ), - }), + setServerError(error, t("Your changes weren't saved. Try again.")), + onSettled: releaseWrite, }); }; + const saveAndGoLive = form.handleSubmit(handleSubmit, releaseWrite); + const submitIfIdle = (event: React.FormEvent) => { + if (!claimWrite()) { + event.preventDefault(); + return; + } + void saveAndGoLive(event); + }; + return (
- + { + setExitRequested(false); + leaveBlocker.reset?.(); + }} + onDiscard={() => { + setExitRequested(false); + if (leaveDecision.discardAction === 'proceed') { + leaveBlocker.proceed?.(); + return; + } + onExit(); + }} + />
+ ); +} diff --git a/packages/web/src/app/routes/mcp-server/connect/client-instructions.tsx b/packages/web/src/app/routes/mcp-server/connect/client-instructions.tsx new file mode 100644 index 000000000000..670f0421efca --- /dev/null +++ b/packages/web/src/app/routes/mcp-server/connect/client-instructions.tsx @@ -0,0 +1,227 @@ +import { t } from 'i18next'; +import { ExternalLink, MessageSquare, Plug } from 'lucide-react'; + +import { BackLink } from '@/components/custom/back-link'; +import { CopyButton } from '@/components/custom/clipboard/copy-button'; +import { CollapsibleJson } from '@/components/custom/collapsible-json'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +import { ClientIcon } from '../client-icon'; +import { CatalogClient, SetupInstruction } from '../mcp-client-catalog'; +import { useMcpNav } from '../mcp-nav'; +import { PageBand } from '../page-band'; + +export function ClientInstructions({ + client, + serverUrl, + isReachableFromInternet, + totalClients, +}: { + client: CatalogClient; + serverUrl: string; + isReachableFromInternet: boolean; + totalClients: number; +}) { + const nav = useMcpNav(); + return ( +
+
+ + +
+ +
+

+ {client.name} +

+ + {client.subtitle} + +
+ +
+
+
+ + +
+ {client.setupVideoUrl && ( +
+ + {t('Watch the full setup')} + +
+ )} + {client.instructions.map((instruction, index) => ( + + ))} +
+ +
+
+ + {t('Server URL')} + + + {serverUrl} + +
+ + {t('Copy link')} + + {client.config && ( + + {t('Copy config')} + + )} +
+
+ +
+
+
+ ); +} + +function SetupInstructionItem({ + number, + instruction, + config, + isLast, + isReachableFromInternet, +}: { + number: number; + instruction: SetupInstruction; + config?: { label: string; snippet: string }; + isLast: boolean; + isReachableFromInternet: boolean; +}) { + const blockedByPrivateUrl = + instruction.action?.requiresInternetReachableUrl === true && + !isReachableFromInternet; + + return ( +
+
+ + {number} + + {!isLast &&
} +
+
+
+ {instruction.title} + + {instruction.body} + +
+ {instruction.command && } + {instruction.action && ( + + )} + {blockedByPrivateUrl && ( + + {t( + 'Your server URL is not reachable from the internet, so this client cannot dial it.', + )} + + )} + {instruction.prompts && ( +
+ {instruction.prompts.map((prompt) => ( + + + {prompt} + + ))} +
+ )} + {config && ( + + )} +
+
+ ); +} + +function TerminalBlock({ command }: { command: string }) { + return ( +
+
+ + {t('Terminal')} + + + {t('Copy')} + +
+
+ + $ + +
+          {command}
+        
+
+
+ ); +} diff --git a/packages/web/src/app/routes/mcp-server/connect/client-picker.tsx b/packages/web/src/app/routes/mcp-server/connect/client-picker.tsx new file mode 100644 index 000000000000..deb7584e4246 --- /dev/null +++ b/packages/web/src/app/routes/mcp-server/connect/client-picker.tsx @@ -0,0 +1,177 @@ +import { t } from 'i18next'; +import { ChevronRight, Search } from 'lucide-react'; +import { useState } from 'react'; + +import { BackLink } from '@/components/custom/back-link'; +import { CopyButton } from '@/components/custom/clipboard/copy-button'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +import { ClientIcon } from '../client-icon'; +import { + CatalogClient, + ClientGroup, + mcpClientCatalog, +} from '../mcp-client-catalog'; +import { useMcpNav } from '../mcp-nav'; +import { PageBand } from '../page-band'; + +import { ClientCard } from './client-card'; + +export function ClientPicker({ + clients, + serverUrl, +}: { + clients: CatalogClient[]; + serverUrl: string; +}) { + const nav = useMcpNav(); + const [search, setSearch] = useState(''); + const query = search.trim().toLowerCase(); + const matchingClients = clients.filter( + (client) => query === '' || client.name.toLowerCase().includes(query), + ); + + return ( +
+
+ + +
+
+

+ {t('Where do you want to use it?')} +

+

+ {t( + 'Pick a client for step-by-step setup, or copy the link and paste it wherever you like.', + )} +

+
+
+ + {abbreviateServerUrl(serverUrl)} + + + {t('Copy')} + +
+
+
+ + setSearch(event.target.value)} + placeholder={t('Search {total} clients', { + total: clients.length, + })} + className="h-11 pl-10 pr-36" + autoFocus + /> + +
+
+
+ + + {mcpClientCatalog.groups().map((group) => { + const groupClients = matchingClients.filter( + (client) => client.group === group.key, + ); + if (groupClients.length === 0) { + return null; + } + return ( + + ); + })} + {matchingClients.length === 0 && ( + + {t('No client matches your search.')} + + )} + +
+ ); +} + +function ClientGroupSection({ + group, + clients, +}: { + group: ClientGroup; + clients: CatalogClient[]; +}) { + const nav = useMcpNav(); + const isCatchAll = group.key === 'other'; + + return ( +
+
+ + {group.label} + + {!isCatchAll && ( + <> + + {clients.length} + + + · {group.tagline} + + + )} +
+ {isCatchAll ? ( + clients.map((client) => ( + + )) + ) : ( +
+ {clients.map((client) => ( + nav.showClient(client.key)} + /> + ))} +
+ )} +
+ ); +} + +function abbreviateServerUrl(serverUrl: string): string { + try { + return `…${new URL(serverUrl).pathname}`; + } catch { + return serverUrl; + } +} diff --git a/packages/web/src/app/routes/mcp-server/connect/connect-landing.tsx b/packages/web/src/app/routes/mcp-server/connect/connect-landing.tsx new file mode 100644 index 000000000000..076977c831dd --- /dev/null +++ b/packages/web/src/app/routes/mcp-server/connect/connect-landing.tsx @@ -0,0 +1,90 @@ +import { t } from 'i18next'; +import { Check, ChevronRight } from 'lucide-react'; + +import { CopyButton } from '@/components/custom/clipboard/copy-button'; + +import { CatalogClient, POPULAR_CLIENT_KEYS } from '../mcp-client-catalog'; +import { useMcpNav } from '../mcp-nav'; +import { PageBand } from '../page-band'; +import { PiecesShowcase } from '../pieces-showcase'; + +import { ClientCard } from './client-card'; + +export function ConnectLanding({ + clients, + serverUrl, +}: { + clients: CatalogClient[]; + serverUrl: string; +}) { + const nav = useMcpNav(); + const popular = POPULAR_CLIENT_KEYS.map((key) => + clients.find((client) => client.key === key), + ).filter((client): client is CatalogClient => client !== undefined); + + return ( +
+ +
+

+ {t('One link for everywhere you use AI.')} +

+

+ {t( + 'Your AI stops guessing and starts doing — sending the Slack message, updating the CRM, running the flow. Paste it into any client that speaks MCP.', + )} +

+
+ + {serverUrl} + + + {t('Copy link')} + +
+
+ + +
+
+ +
+ + {t('Need the exact steps?')} + + {popular.map((client, index) => ( + nav.showClient(client.key)} + /> + ))} + +
+
+ + +
+ ); +} + +function TrustPoint({ text }: { text: string }) { + return ( + + + {text} + + ); +} diff --git a/packages/web/src/app/routes/mcp-server/connect/connect-tab.tsx b/packages/web/src/app/routes/mcp-server/connect/connect-tab.tsx new file mode 100644 index 000000000000..d4620268b17a --- /dev/null +++ b/packages/web/src/app/routes/mcp-server/connect/connect-tab.tsx @@ -0,0 +1,46 @@ +import { ApEdition, ApFlagId } from '@activepieces/shared'; +import { useMemo } from 'react'; + +import { flagsHooks } from '@/hooks/flags-hooks'; + +import { mcpClientCatalog } from '../mcp-client-catalog'; +import { useMcpNav } from '../mcp-nav'; + +import { ClientInstructions } from './client-instructions'; +import { ClientPicker } from './client-picker'; +import { ConnectLanding } from './connect-landing'; + +export function ConnectTab({ + serverUrl, + isReachableFromInternet, +}: { + serverUrl: string; + isReachableFromInternet: boolean; +}) { + const { view, clientKey } = useMcpNav(); + const { websiteName } = flagsHooks.useWebsiteBranding(); + const { data: edition } = flagsHooks.useFlag(ApFlagId.EDITION); + const isCloud = edition === ApEdition.CLOUD; + const clients = useMemo( + () => mcpClientCatalog.clients({ serverUrl, websiteName, isCloud }), + [serverUrl, websiteName, isCloud], + ); + const selected = clients.find((client) => client.key === clientKey) ?? null; + + if (view === 'client' && selected !== null) { + return ( + + ); + } + + if (view === 'browse') { + return ; + } + + return ; +} diff --git a/packages/web/src/app/routes/mcp-server/index.tsx b/packages/web/src/app/routes/mcp-server/index.tsx new file mode 100644 index 000000000000..5f64caa1bed1 --- /dev/null +++ b/packages/web/src/app/routes/mcp-server/index.tsx @@ -0,0 +1,22 @@ +import { t } from 'i18next'; + +import { PageHeader } from '@/components/custom/page-header'; +import { piecesHooks } from '@/features/pieces/hooks/pieces-hooks'; + +import { ConnectTab } from './connect/connect-tab'; +import { useMcpServerUrl } from './mcp-server-url'; + +export default function McpServerPage() { + const { serverUrl, isReachableFromInternet } = useMcpServerUrl(); + piecesHooks.usePrefetchPieces({ skipProjectFilter: true }); + + return ( +
+ + +
+ ); +} diff --git a/packages/web/src/app/routes/mcp-server/mcp-client-catalog.ts b/packages/web/src/app/routes/mcp-server/mcp-client-catalog.ts new file mode 100644 index 000000000000..86ffe439214a --- /dev/null +++ b/packages/web/src/app/routes/mcp-server/mcp-client-catalog.ts @@ -0,0 +1,486 @@ +import { slugify } from '@activepieces/core-utils'; +import { t } from 'i18next'; + +import { MCP_CLIENT_BRANDING } from './mcp-client-display'; + +const GENERIC_ICON = MCP_CLIENT_BRANDING.unknown.icon; +const FALLBACK_SLUG = 'activepieces'; + +function claudeDeepLink({ + serverUrl, + brandName, +}: { + serverUrl: string; + brandName: string; +}): string { + const params = new URLSearchParams({ + modal: 'add-custom-connector', + connectorName: brandName, + connectorUrl: serverUrl, + }); + return `https://claude.ai/customize/connectors?${params.toString()}`; +} + +function cursorDeepLink({ + serverUrl, + slug, +}: { + serverUrl: string; + slug: string; +}): string { + const config = btoa(JSON.stringify({ url: serverUrl })); + return `cursor://anysphere.cursor-deeplink/mcp/install?name=${slug}&config=${encodeURIComponent( + config, + )}`; +} + +function vscodeDeepLink({ + serverUrl, + slug, +}: { + serverUrl: string; + slug: string; +}): string { + const config = JSON.stringify({ name: slug, type: 'http', url: serverUrl }); + return `vscode:mcp/install?${encodeURIComponent(config)}`; +} + +function prettyJson(value: unknown): string { + return JSON.stringify(value, null, 2); +} + +function mcpServersJson({ + slug, + serverConfig, +}: { + slug: string; + serverConfig: object; +}): string { + return prettyJson({ mcpServers: { [slug]: serverConfig } }); +} + +const CLOUD_LISTINGS = { + claude: 'https://claude.ai/directory/cloud-activepieces-com', + cursor: + 'https://cursor.directory/plugins/activepieces-mcp-connector-for-cursor', +}; + +const SELF_HOSTED_SETUP_VIDEOS = { + claude: + 'https://cdn.activepieces.com/videos/mcp-tutorials/Claude%20MCP%20-%20Step%201.mp4', + chatgpt: + 'https://cdn.activepieces.com/videos/mcp-tutorials/ChatGPT%20MCP%20-%20Step%201.mp4', +}; + +function catalogEntries({ + url, + brand: { name, slug }, +}: { + url: string; + brand: Brand; +}): CatalogEntry[] { + return [ + { + key: 'claude-code', + ...MCP_CLIENT_BRANDING['claude-code'], + group: 'terminal', + setupHint: t('One command'), + docsUrl: 'https://docs.claude.com/en/docs/claude-code/mcp', + install: { + body: t('From the folder you want the tools available in.'), + command: `claude mcp add --transport http ${slug} ${url}`, + }, + auth: t('Run /mcp inside Claude Code and pick Authenticate.'), + config: { + path: '.mcp.json', + snippet: mcpServersJson({ slug, serverConfig: { type: 'http', url } }), + }, + }, + { + key: 'codex', + ...MCP_CLIENT_BRANDING.codex, + group: 'terminal', + setupHint: t('One command'), + docsUrl: 'https://developers.openai.com/codex/mcp', + install: { + body: t('From the folder you want the tools available in.'), + command: `codex mcp add ${slug} --url ${url}`, + }, + config: { + path: '~/.codex/config.toml', + snippet: `[mcp_servers.${slug}]\nurl = "${url}"`, + }, + }, + { + key: 'gemini-cli', + ...MCP_CLIENT_BRANDING['gemini-cli'], + group: 'terminal', + setupHint: t('One command'), + docsUrl: + 'https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html', + install: { + body: t('Pass --scope project to keep it to one repository.'), + command: `gemini mcp add --transport http ${slug} ${url}`, + }, + config: { + path: '~/.gemini/settings.json', + snippet: mcpServersJson({ slug, serverConfig: { httpUrl: url } }), + }, + }, + { + key: 'opencode', + ...MCP_CLIENT_BRANDING.opencode, + group: 'terminal', + setupHint: t('Config file'), + docsUrl: 'https://opencode.ai/docs/mcp-servers/', + config: { + path: '~/.config/opencode/opencode.json', + snippet: prettyJson({ + mcp: { [slug]: { type: 'remote', url, enabled: true } }, + }), + }, + }, + { + key: 'windsurf', + ...MCP_CLIENT_BRANDING.windsurf, + group: 'editors', + setupHint: t('Config file'), + docsUrl: 'https://docs.windsurf.com/windsurf/cascade/mcp', + install: { + body: t( + 'Open Cascade, click the plugins icon, then Manage plugins → View raw config.', + ), + command: url, + }, + config: { + path: '~/.codeium/windsurf/mcp_config.json', + snippet: mcpServersJson({ slug, serverConfig: { serverUrl: url } }), + }, + }, + { + key: 'cursor', + ...MCP_CLIENT_BRANDING.cursor, + group: 'editors', + setupHint: t('One click install'), + docsUrl: 'https://docs.cursor.com/context/mcp', + install: { + body: t('Opens Cursor and writes the server into ~/.cursor/mcp.json.'), + action: { + label: t('Add to Cursor'), + href: cursorDeepLink({ serverUrl: url, slug }), + }, + }, + cloud: { docsUrl: CLOUD_LISTINGS.cursor }, + config: { + path: '~/.cursor/mcp.json', + snippet: mcpServersJson({ slug, serverConfig: { url } }), + }, + }, + { + key: 'vscode', + ...MCP_CLIENT_BRANDING.vscode, + group: 'editors', + setupHint: t('One click install'), + docsUrl: 'https://code.visualstudio.com/docs/copilot/chat/mcp-servers', + install: { + body: t( + 'Opens VS Code and writes the server into .vscode/mcp.json for this workspace.', + ), + action: { + label: t('Add to VS Code'), + href: vscodeDeepLink({ serverUrl: url, slug }), + }, + }, + config: { + path: '.vscode/mcp.json', + snippet: prettyJson({ servers: { [slug]: { type: 'http', url } } }), + }, + }, + { + key: 'claude', + ...MCP_CLIENT_BRANDING.claude, + group: 'chat', + setupHint: t('Add a connector'), + selfHostedVideoUrl: SELF_HOSTED_SETUP_VIDEOS.claude, + docsUrl: + 'https://support.claude.com/en/articles/11175166-getting-started-with-custom-connectors-using-remote-mcp', + install: { + body: t( + 'Paste the server URL as a custom connector. This client dials your server from the internet, so localhost will not reach it.', + ), + action: { + label: t('Add to Claude'), + href: claudeDeepLink({ serverUrl: url, brandName: name }), + requiresInternetReachableUrl: true, + }, + }, + cloud: { + docsUrl: CLOUD_LISTINGS.claude, + install: { + body: t( + 'This server is already listed in Claude’s directory — add it there in one click.', + ), + action: { + label: t('Add from the Claude directory'), + href: CLOUD_LISTINGS.claude, + }, + }, + }, + }, + { + key: 'chatgpt', + ...MCP_CLIENT_BRANDING.chatgpt, + group: 'chat', + setupHint: t('Add a connector'), + selfHostedVideoUrl: SELF_HOSTED_SETUP_VIDEOS.chatgpt, + docsUrl: 'https://platform.openai.com/docs/mcp', + install: { + body: t( + 'Paste the server URL as a connector. ChatGPT dials your server from the internet, so localhost will not reach it.', + ), + command: url, + }, + }, + { + key: 'unknown', + icon: GENERIC_ICON, + name: t('Any MCP client'), + group: 'other', + setupHint: t( + 'Streamable HTTP or SSE. Point it at the link and it works.', + ), + docsUrl: 'https://modelcontextprotocol.io/clients', + install: { + body: t('Check your client’s docs for where the server URL goes.'), + command: url, + }, + config: { snippet: mcpServersJson({ slug, serverConfig: { url } }) }, + }, + ]; +} + +function localAuthBody(client: string): string { + return t( + '{client} opens your browser on the first tool call. Approve the project.', + { client }, + ); +} + +const GROUP_ORDER: ClientGroupKey[] = ['terminal', 'editors', 'chat', 'other']; + +const GROUP_COPY: Record = { + terminal: { + label: () => t('Terminal'), + tagline: () => t('one command, nothing to edit'), + installTitle: () => t('Run this in your terminal'), + subtitle: () => t('Terminal · runs locally'), + authBody: ({ client }) => localAuthBody(client), + }, + editors: { + label: () => t('Editors'), + tagline: () => t('we write the config for you'), + installTitle: () => t('Add the server'), + subtitle: () => t('Editor · runs locally'), + authBody: ({ client }) => localAuthBody(client), + }, + chat: { + label: () => t('Chat apps'), + tagline: () => t('desktop and web, needs a public HTTPS URL'), + installTitle: () => t('Add the connector'), + subtitle: () => t('Desktop and web · needs a public HTTPS address'), + authBody: ({ client, brand }) => + t( + '{client} opens {brand} in your browser. Approve the project it can reach.', + { client, brand }, + ), + }, + other: { + label: () => t('Anything else'), + installTitle: () => t('Paste the server URL'), + subtitle: () => t('Streamable HTTP · OAuth'), + authBody: () => + t( + 'The client opens an OAuth prompt on the first tool call. Approve the project.', + ), + }, +}; + +function installBody(entry: CatalogEntry): string { + return ( + entry.install?.body ?? + t( + 'Add a Streamable HTTP MCP server pointing at this URL — the {client} docs say where its server list lives.', + { client: entry.name }, + ) + ); +} + +function configLabel(config: ConfigSnippet): string { + return config.path + ? t('Or edit {path}', { path: config.path }) + : t('MCP server JSON'); +} + +function verifyPrompts(brandName: string): string[] { + return [ + `"${t('What {brand} tools do you have?', { brand: brandName })}"`, + `"${t('Post a note in Slack that the deploy finished.')}"`, + ]; +} + +function toCatalogClient({ + entry, + url, + brandName, + isCloud, +}: { + entry: CatalogEntry; + url: string; + brandName: string; + isCloud: boolean; +}): CatalogClient { + const groupCopy = GROUP_COPY[entry.group]; + return { + key: entry.key, + icon: entry.icon, + name: entry.name, + group: entry.group, + setupHint: entry.setupHint, + subtitle: groupCopy.subtitle(), + docsUrl: entry.docsUrl, + setupVideoUrl: isCloud ? undefined : entry.selfHostedVideoUrl, + config: entry.config && { + label: configLabel(entry.config), + snippet: entry.config.snippet, + }, + instructions: [ + { + title: groupCopy.installTitle(), + body: installBody(entry), + command: entry.install ? entry.install.command : url, + action: entry.install?.action, + }, + { + title: t('Authenticate'), + body: + entry.auth ?? + groupCopy.authBody({ client: entry.name, brand: brandName }), + }, + { + title: t('Check it works'), + body: t('If it answers with your tools, you’re set.'), + prompts: verifyPrompts(brandName), + }, + ], + }; +} + +export const mcpClientCatalog = { + clients: ({ + serverUrl, + websiteName, + isCloud, + }: { + serverUrl: string; + websiteName: string; + isCloud: boolean; + }): CatalogClient[] => + catalogEntries({ + url: serverUrl, + brand: { name: websiteName, slug: slugify(websiteName) || FALLBACK_SLUG }, + }) + .map((entry) => + isCloud && entry.cloud ? { ...entry, ...entry.cloud } : entry, + ) + .map((entry) => + toCatalogClient({ + entry, + url: serverUrl, + brandName: websiteName, + isCloud, + }), + ), + + groups: (): ClientGroup[] => + GROUP_ORDER.map((key) => ({ + key, + label: GROUP_COPY[key].label(), + tagline: GROUP_COPY[key].tagline?.(), + })), +}; + +export const POPULAR_CLIENT_KEYS = ['claude-code', 'cursor', 'claude']; + +export type ClientGroupKey = 'terminal' | 'editors' | 'chat' | 'other'; + +export type ClientGroup = { + key: ClientGroupKey; + label: string; + tagline?: string; +}; + +export type SetupInstruction = { + title: string; + body: string; + command?: string; + prompts?: string[]; + action?: SetupLink; +}; + +export type CatalogClient = { + key: string; + icon: string; + name: string; + group: ClientGroupKey; + setupHint: string; + subtitle: string; + docsUrl: string; + setupVideoUrl?: string; + config?: { + label: string; + snippet: string; + }; + instructions: SetupInstruction[]; +}; + +type Brand = { + name: string; + slug: string; +}; + +type SetupLink = { + label: string; + href: string; + requiresInternetReachableUrl?: boolean; +}; + +type GroupCopy = { + label: () => string; + tagline?: () => string; + installTitle: () => string; + subtitle: () => string; + authBody: (params: { client: string; brand: string }) => string; +}; + +type ConfigSnippet = { + path?: string; + snippet: string; +}; + +type CatalogEntry = { + key: string; + icon: string; + name: string; + group: ClientGroupKey; + setupHint: string; + docsUrl: string; + install?: { + body: string; + command?: string; + action?: SetupLink; + }; + auth?: string; + selfHostedVideoUrl?: string; + config?: ConfigSnippet; + cloud?: Pick & { docsUrl?: string }; +}; diff --git a/packages/web/src/app/routes/mcp-server/mcp-client-display.ts b/packages/web/src/app/routes/mcp-server/mcp-client-display.ts new file mode 100644 index 000000000000..9e2e4fe51c70 --- /dev/null +++ b/packages/web/src/app/routes/mcp-server/mcp-client-display.ts @@ -0,0 +1,43 @@ +import { McpOAuthClientKey } from '@activepieces/shared'; +import { t } from 'i18next'; + +const CDN_ICONS_URL = 'https://cdn.activepieces.com/icons'; + +function icon(key: McpOAuthClientKey): string { + return MCP_CLIENT_BRANDING[key].icon; +} + +function label({ key, clientName }: LabelParams): string { + return key === 'unknown' + ? clientName ?? t('MCP client') + : MCP_CLIENT_BRANDING[key].name; +} + +export const mcpClientDisplay = { icon, label }; + +export const MCP_CLIENT_BRANDING: Record< + McpOAuthClientKey, + { icon: string; name: string } +> = { + claude: { icon: `${CDN_ICONS_URL}/claude.svg`, name: 'Claude' }, + 'claude-code': { + icon: `${CDN_ICONS_URL}/claude-code.svg`, + name: 'Claude Code', + }, + chatgpt: { icon: `${CDN_ICONS_URL}/openai.svg`, name: 'ChatGPT' }, + codex: { icon: `${CDN_ICONS_URL}/codex.svg`, name: 'Codex' }, + 'gemini-cli': { icon: `${CDN_ICONS_URL}/gemini.svg`, name: 'Gemini CLI' }, + opencode: { icon: `${CDN_ICONS_URL}/opencode.svg`, name: 'OpenCode' }, + cursor: { icon: `${CDN_ICONS_URL}/cursor.svg`, name: 'Cursor' }, + vscode: { icon: `${CDN_ICONS_URL}/vscode.svg`, name: 'VS Code' }, + windsurf: { icon: `${CDN_ICONS_URL}/windsurf.svg`, name: 'Windsurf' }, + unknown: { + icon: `${CDN_ICONS_URL}/mcp-with-background.svg`, + name: 'MCP client', + }, +}; + +type LabelParams = { + key: McpOAuthClientKey; + clientName: string | null; +}; diff --git a/packages/web/src/app/routes/mcp-server/mcp-nav.ts b/packages/web/src/app/routes/mcp-server/mcp-nav.ts new file mode 100644 index 000000000000..a21a5d01f5f9 --- /dev/null +++ b/packages/web/src/app/routes/mcp-server/mcp-nav.ts @@ -0,0 +1,24 @@ +import { useSearchParams } from 'react-router-dom'; + +export function useMcpNav(): McpNav { + const [params, setParams] = useSearchParams(); + const clientKey = params.get('client'); + + return { + clientKey, + view: clientKey ? 'client' : params.has('browse') ? 'browse' : 'landing', + showLanding: () => setParams({}), + showBrowse: () => setParams({ browse: '1' }), + showClient: (key: string) => setParams({ client: key }), + }; +} + +export type McpView = 'landing' | 'browse' | 'client'; + +export type McpNav = { + view: McpView; + clientKey: string | null; + showLanding: () => void; + showBrowse: () => void; + showClient: (key: string) => void; +}; diff --git a/packages/web/src/app/routes/mcp-server/mcp-server-url.ts b/packages/web/src/app/routes/mcp-server/mcp-server-url.ts new file mode 100644 index 000000000000..57fed97c62c2 --- /dev/null +++ b/packages/web/src/app/routes/mcp-server/mcp-server-url.ts @@ -0,0 +1,16 @@ +import { ApFlagId } from '@activepieces/shared'; + +import { flagsHooks } from '@/hooks/flags-hooks'; +import { formatUtils } from '@/lib/format-utils'; + +export function useMcpServerUrl(): { + serverUrl: string; + isReachableFromInternet: boolean; +} { + const { data: publicUrl } = flagsHooks.useFlag(ApFlagId.PUBLIC_URL); + const base = (publicUrl ?? '').replace(/\/$/, ''); + return { + serverUrl: `${base}/mcp`, + isReachableFromInternet: formatUtils.urlIsPubliclyReachable(base), + }; +} diff --git a/packages/web/src/app/routes/mcp-server/page-band.tsx b/packages/web/src/app/routes/mcp-server/page-band.tsx new file mode 100644 index 000000000000..2cc71e41b6a2 --- /dev/null +++ b/packages/web/src/app/routes/mcp-server/page-band.tsx @@ -0,0 +1,19 @@ +import { ReactNode } from 'react'; + +import { cn } from '@/lib/utils'; + +export function PageBand({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/web/src/app/routes/mcp-server/pieces-showcase.tsx b/packages/web/src/app/routes/mcp-server/pieces-showcase.tsx new file mode 100644 index 000000000000..44ea96e842dd --- /dev/null +++ b/packages/web/src/app/routes/mcp-server/pieces-showcase.tsx @@ -0,0 +1,106 @@ +import { PieceMetadataModelSummary } from '@activepieces/pieces-framework'; +import { t } from 'i18next'; +import { useMemo } from 'react'; + +import ImageWithFallback from '@/components/custom/image-with-fallback'; +import { Skeleton } from '@/components/ui/skeleton'; +import { piecesHooks } from '@/features/pieces/hooks/pieces-hooks'; +import { pieceSearchUtils } from '@/features/pieces/utils/piece-search-utils'; +import { cn } from '@/lib/utils'; + +import { PageBand } from './page-band'; + +export function PiecesShowcase() { + const { pieces, isLoading } = piecesHooks.usePieces({ + skipProjectFilter: true, + }); + const tiles = useMemo(() => popularFirst(pieces ?? []), [pieces]); + + if (!isLoading && tiles.length === 0) { + return null; + } + + return ( +
+ +
+

+ {t('Your AI gets all of this')} +

+

+ {isLoading + ? t('Every piece you can use, plus every flow you’ve built.') + : t( + '{count} pieces, plus every flow you’ve built — ready to run.', + { count: tiles.length }, + )} +

+
+
+ {isLoading ? ( + <> + + + + ) : ( + <> + index % 2 === 0)} /> + index % 2 === 1)} + className="pl-8" + /> + + )} +
+
+
+ ); +} + +function popularFirst( + pieces: PieceMetadataModelSummary[], +): PieceMetadataModelSummary[] { + const rank = (piece: PieceMetadataModelSummary) => { + const index = pieceSearchUtils.POPULAR_PIECES_NAMES.indexOf(piece.name); + return index === -1 ? pieceSearchUtils.POPULAR_PIECES_NAMES.length : index; + }; + return [...pieces].sort((a, b) => rank(a) - rank(b)); +} + +function TileRow({ + tiles, + className = '', +}: { + tiles: PieceMetadataModelSummary[]; + className?: string; +}) { + return ( +
+ {tiles.map((tile) => ( + + + + ))} +
+ ); +} + +function TileRowSkeleton({ className = '' }: { className?: string }) { + return ( +
+ {Array.from({ length: SKELETON_TILE_COUNT }).map((_, index) => ( + + ))} +
+ ); +} + +const SKELETON_TILE_COUNT = 24; diff --git a/packages/web/src/app/routes/project-routes.tsx b/packages/web/src/app/routes/project-routes.tsx index 40162e50a50f..d5acb19fbbb4 100644 --- a/packages/web/src/app/routes/project-routes.tsx +++ b/packages/web/src/app/routes/project-routes.tsx @@ -26,6 +26,7 @@ const FlowBuilderPage = lazyWithRetry( 'flow-builder', ); const AnalyticsPage = lazyWithRetry(() => import('./impact'), 'analytics'); +const McpServerPage = lazyWithRetry(() => import('./mcp-server'), 'mcp-server'); const ProjectReleasesPage = lazyWithRetry( () => import('./project-release').then((m) => ({ @@ -264,4 +265,18 @@ export const projectRoutes = [ ), }, + { + path: '/mcp-server', + element: ( + + + + + + + + + + ), + }, ]; diff --git a/packages/web/src/components/custom/back-link.tsx b/packages/web/src/components/custom/back-link.tsx new file mode 100644 index 000000000000..5578cf5c2c05 --- /dev/null +++ b/packages/web/src/components/custom/back-link.tsx @@ -0,0 +1,27 @@ +import { ArrowLeft } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +export function BackLink({ + label, + onClick, + className, +}: { + label: string; + onClick: () => void; + className?: string; +}) { + return ( + + ); +} diff --git a/packages/web/src/components/custom/clipboard/copy-button.tsx b/packages/web/src/components/custom/clipboard/copy-button.tsx index 1565ff5c5d62..c7c4e6072c2d 100644 --- a/packages/web/src/components/custom/clipboard/copy-button.tsx +++ b/packages/web/src/components/custom/clipboard/copy-button.tsx @@ -16,7 +16,6 @@ interface CopyButtonProps extends ButtonProps { tooltipSide?: React.ComponentProps['side']; withoutTooltip?: boolean; children?: React.ReactNode; - variant?: 'ghost' | 'outline'; } export const CopyButton = forwardRef( @@ -27,6 +26,7 @@ export const CopyButton = forwardRef( tooltipSide, withoutTooltip = false, variant = 'outline', + children, ...props }, ref, @@ -46,22 +46,29 @@ export const CopyButton = forwardRef( }, }); - if (withoutTooltip) { + const content = ( + <> + {isCopied ? ( + + ) : ( + + )} + {children} + + ); + + if (withoutTooltip || children) { return ( ); } @@ -77,11 +84,7 @@ export const CopyButton = forwardRef( onClick={() => copyToClipboard()} {...props} > - {isCopied ? ( - - ) : ( - - )} + {content} {t('Copy')} diff --git a/packages/web/src/components/custom/data-table/data-table-toolbar.tsx b/packages/web/src/components/custom/data-table/data-table-toolbar.tsx index 10bea0a161ca..209be84f93e8 100644 --- a/packages/web/src/components/custom/data-table/data-table-toolbar.tsx +++ b/packages/web/src/components/custom/data-table/data-table-toolbar.tsx @@ -2,6 +2,7 @@ import { cn, DASHBOARD_CONTENT_PADDING_X } from '@/lib/utils'; type DataTableToolbarProps = { children?: React.ReactNode; + className?: string; }; const DataTableToolbar = (params: DataTableToolbarProps) => { @@ -9,7 +10,7 @@ const DataTableToolbar = (params: DataTableToolbarProps) => {
diff --git a/packages/web/src/components/custom/data-table/index.tsx b/packages/web/src/components/custom/data-table/index.tsx index f7c7fbeaa2f3..316925d0d3eb 100644 --- a/packages/web/src/components/custom/data-table/index.tsx +++ b/packages/web/src/components/custom/data-table/index.tsx @@ -92,6 +92,7 @@ interface DataTableProps< getRowClassName?: (row: RowDataWithActions, index: number) => string; isRowSelectionDisabled?: (row: RowDataWithActions) => boolean; virtualizeRows?: boolean; + bordered?: boolean; } export type DataTableFilters = DataTableFilterProps & { @@ -128,6 +129,7 @@ export function DataTable< initialSorting = [], clientPagination = false, clientFiltering = false, + bordered = false, getRowClassName, isRowSelectionDisabled, virtualizeRows = false, @@ -340,7 +342,7 @@ export function DataTable< {((filters && filters.length > 0) || (customFilters && customFilters.length > 0) || (toolbarButtons && toolbarButtons.length > 0)) && ( - +
{filters && @@ -369,10 +371,15 @@ export function DataTable<
tr:last-child]:border-b-0', + )} > - tool.toolName === toolName.trim() && - (!editingKbTool || editingKbTool.toolName !== toolName.trim()), + tool.toolName === resolvedToolName && + (!editingKbTool || editingKbTool.toolName !== resolvedToolName), ); if (isDuplicate) { toast.error(t('A tool with this name already exists')); @@ -147,7 +149,7 @@ function KnowledgeBaseDialogContent({ const newTool: AgentKnowledgeBaseTool = { type: AgentToolType.KNOWLEDGE_BASE, - toolName: toolName.trim(), + toolName: resolvedToolName, sourceType, sourceId: sourceId.trim(), sourceName: sourceName.trim(), @@ -291,13 +293,6 @@ function KnowledgeBaseDialogContent({ ); } -function slugify(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '_') - .replace(/^_|_$/g, ''); -} - type AgentKnowledgeBaseDialogProps = { tools: AgentTool[]; onToolsUpdate: (tools: AgentTool[]) => void; diff --git a/packages/web/src/features/chat/lib/chat-utils.ts b/packages/web/src/features/chat/lib/chat-utils.ts index f58b5ac77228..f709da74dedb 100644 --- a/packages/web/src/features/chat/lib/chat-utils.ts +++ b/packages/web/src/features/chat/lib/chat-utils.ts @@ -567,8 +567,19 @@ function sanitizeTitle(title: string): string { return title.replace(/[*_`~#]/g, '').trim(); } +function reopensSameConversation({ + current, + next, +}: { + current: string | null; + next: string; +}): boolean { + return current === next; +} + export const chatUtils = { newChatEvent: 'ap:new-chat', + reopensSameConversation, sanitizeTitle, formatToolLabel: ({ part }: { part: AnyToolPart }) => formatToolName({ part }), diff --git a/packages/web/src/features/chat/lib/use-chat.ts b/packages/web/src/features/chat/lib/use-chat.ts index 53cb31302847..6213fb28faf9 100644 --- a/packages/web/src/features/chat/lib/use-chat.ts +++ b/packages/web/src/features/chat/lib/use-chat.ts @@ -773,6 +773,13 @@ export function useAgentChat({ const setConversationId = useCallback( async (id: string) => { + if ( + chatUtils.reopensSameConversation({ + current: conversationIdRef.current, + next: id, + }) + ) + return; stopStream(); setIsPollingForAgentReply(false); updateSendStatus({ type: 'idle' }); diff --git a/packages/web/src/features/pieces/hooks/pieces-hooks.ts b/packages/web/src/features/pieces/hooks/pieces-hooks.ts index ec4e0cf5fa51..07a3ae5bed63 100644 --- a/packages/web/src/features/pieces/hooks/pieces-hooks.ts +++ b/packages/web/src/features/pieces/hooks/pieces-hooks.ts @@ -20,6 +20,7 @@ import { import { QueryClient, useMutation, + usePrefetchQuery, useQueries, useQuery, } from '@tanstack/react-query'; @@ -82,6 +83,9 @@ type UsePiecesProps = { isTableQuery?: boolean; skipProjectFilter?: boolean; }; +type UsePrefetchPiecesProps = { + skipProjectFilter?: boolean; +}; type UsePiecesSearchProps = { searchQuery: string; enabled?: boolean; @@ -181,26 +185,14 @@ export const piecesHooks = { skipProjectFilter = false, }: UsePiecesProps) => { const { i18n } = useTranslation(); - const projectId = skipProjectFilter - ? undefined - : authenticationSession.getProjectId()!; const query = useQuery({ - queryKey: [ - isTableQuery ? 'pieces-table' : 'pieces', + ...piecesQueryOptions({ searchQuery, includeHidden, + isTableQuery, skipProjectFilter, - projectId, - i18n.language, - ], - queryFn: () => - piecesApi.list({ - projectId, - searchQuery, - includeHidden, - locale: i18n.language as LocalesEnum, - }), - staleTime: searchQuery ? 0 : Infinity, + locale: i18n.language as LocalesEnum, + }), meta: isTableQuery ? { showErrorDialog: true, loadSubsetOptions: {} } : undefined, @@ -211,6 +203,19 @@ export const piecesHooks = { refetch: query.refetch, }; }, + usePrefetchPieces: ({ + skipProjectFilter = false, + }: UsePrefetchPiecesProps) => { + const { i18n } = useTranslation(); + usePrefetchQuery( + piecesQueryOptions({ + includeHidden: false, + isTableQuery: false, + skipProjectFilter, + locale: i18n.language as LocalesEnum, + }), + ); + }, usePiecesSearch: ( props: UsePiecesSearchProps, ): { @@ -578,3 +583,34 @@ function invalidatePieceCaches(queryClient: QueryClient): Promise { } export const pieceCacheUtils = { invalidatePieceCaches }; + +function piecesQueryOptions({ + searchQuery, + includeHidden, + isTableQuery, + skipProjectFilter, + locale, +}: { + searchQuery?: string; + includeHidden: boolean; + isTableQuery: boolean; + skipProjectFilter: boolean; + locale: LocalesEnum; +}) { + const projectId = skipProjectFilter + ? undefined + : authenticationSession.getProjectId()!; + return { + queryKey: [ + isTableQuery ? 'pieces-table' : 'pieces', + searchQuery, + includeHidden, + skipProjectFilter, + projectId, + locale, + ], + queryFn: () => + piecesApi.list({ projectId, searchQuery, includeHidden, locale }), + staleTime: searchQuery ? 0 : Infinity, + }; +} diff --git a/packages/web/src/features/pieces/utils/action-classification.ts b/packages/web/src/features/pieces/utils/action-classification.ts new file mode 100644 index 000000000000..f8c118b87c3b --- /dev/null +++ b/packages/web/src/features/pieces/utils/action-classification.ts @@ -0,0 +1,12 @@ +import type { ActionClassification } from '@activepieces/pieces-framework'; +import { t } from 'i18next'; + +export const ACTION_CLASSIFICATION_BADGES: Record< + ActionClassification, + { label: () => string; variant: 'accent' | 'destructive' } +> = { + READ: { label: () => t('Read'), variant: 'accent' }, + SEARCH: { label: () => t('Search'), variant: 'accent' }, + WRITE: { label: () => t('Write'), variant: 'accent' }, + DESTRUCTIVE: { label: () => t('Destructive'), variant: 'destructive' }, +}; diff --git a/packages/web/src/features/pieces/utils/piece-search-utils.ts b/packages/web/src/features/pieces/utils/piece-search-utils.ts index 4a750211ce4e..f423b6607e24 100644 --- a/packages/web/src/features/pieces/utils/piece-search-utils.ts +++ b/packages/web/src/features/pieces/utils/piece-search-utils.ts @@ -185,4 +185,5 @@ export const pieceSearchUtils = { getPinnedPieces, getPopularPieces, getHighlightedPieces, + POPULAR_PIECES_NAMES, }; diff --git a/packages/web/src/lib/format-utils.ts b/packages/web/src/lib/format-utils.ts index 82cf540bff32..3bb0cbaa06b5 100644 --- a/packages/web/src/lib/format-utils.ts +++ b/packages/web/src/lib/format-utils.ts @@ -1,3 +1,4 @@ +import { isNil, tryCatchSync } from '@activepieces/core-utils'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; dayjs.extend(duration); @@ -210,19 +211,40 @@ export const formatUtils = { } return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`; }, - urlIsNotLocalhostOrIp(url: string): boolean { - const parsed = new URL(url); - if ( - parsed.hostname === 'localhost' || - parsed.hostname === '127.0.0.1' || - parsed.hostname === '::1' - ) { + urlIsPubliclyReachable(url: string): boolean { + const { data: parsed } = tryCatchSync(() => new URL(url)); + if (isNil(parsed) || parsed.protocol !== 'https:') { return false; } - const ipv4Regex = /^(?:\d{1,3}\.){3}\d{1,3}$/; - if (ipv4Regex.test(parsed.hostname)) { + const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + if (hostname === 'localhost' || hostname.endsWith('.localhost')) { return false; } - return parsed.protocol === 'https:'; + if (hostname.includes(':')) { + return !/^(::1$|f[cd]|fe[89ab])/.test(hostname); + } + return !isPrivateIpv4(hostname); }, }; + +function isPrivateIpv4(hostname: string): boolean { + const octets = hostname.split('.').map(Number); + const isIpv4 = + octets.length === 4 && + octets.every( + (octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255, + ); + if (!isIpv4) { + return false; + } + const [first, second] = octets; + return ( + first === 0 || + first === 10 || + first === 127 || + (first === 100 && second >= 64 && second <= 127) || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) + ); +} diff --git a/packages/web/src/lib/test/utils.test.ts b/packages/web/src/lib/test/utils.test.ts index 2a5e65790b97..d361539036bc 100644 --- a/packages/web/src/lib/test/utils.test.ts +++ b/packages/web/src/lib/test/utils.test.ts @@ -101,31 +101,61 @@ describe('formatUtils.formatToHoursAndMinutes', () => { }); }); -describe('formatUtils.urlIsNotLocalhostOrIp', () => { +describe('formatUtils.urlIsPubliclyReachable', () => { it('returns false for localhost', () => { - expect(formatUtils.urlIsNotLocalhostOrIp('http://localhost:3000')).toBe( + expect(formatUtils.urlIsPubliclyReachable('http://localhost:3000')).toBe( + false, + ); + expect(formatUtils.urlIsPubliclyReachable('https://localhost:3000')).toBe( false, ); }); - it('returns false for 127.0.0.1', () => { - expect(formatUtils.urlIsNotLocalhostOrIp('http://127.0.0.1:3000')).toBe( + it('returns false for loopback addresses', () => { + expect(formatUtils.urlIsPubliclyReachable('https://127.0.0.1:3000')).toBe( + false, + ); + expect(formatUtils.urlIsPubliclyReachable('https://[::1]:3000')).toBe( false, ); }); - it('returns false for IP address', () => { - expect(formatUtils.urlIsNotLocalhostOrIp('http://192.168.1.1:3000')).toBe( + it('returns false for private and link-local addresses', () => { + expect(formatUtils.urlIsPubliclyReachable('https://192.168.1.1')).toBe( + false, + ); + expect(formatUtils.urlIsPubliclyReachable('https://10.0.0.1')).toBe(false); + expect(formatUtils.urlIsPubliclyReachable('https://172.20.0.1')).toBe( + false, + ); + expect(formatUtils.urlIsPubliclyReachable('https://169.254.169.254')).toBe( false, ); + expect(formatUtils.urlIsPubliclyReachable('https://[fd00::1]')).toBe(false); + }); + + it('returns true for a routable public IP over https', () => { + expect(formatUtils.urlIsPubliclyReachable('https://203.0.113.5')).toBe( + true, + ); + expect(formatUtils.urlIsPubliclyReachable('https://172.32.0.1')).toBe(true); }); it('returns true for valid https URL', () => { - expect(formatUtils.urlIsNotLocalhostOrIp('https://example.com')).toBe(true); + expect(formatUtils.urlIsPubliclyReachable('https://example.com')).toBe( + true, + ); }); it('returns false for http URL with valid domain', () => { - expect(formatUtils.urlIsNotLocalhostOrIp('http://example.com')).toBe(false); + expect(formatUtils.urlIsPubliclyReachable('http://example.com')).toBe( + false, + ); + }); + + it('returns false for a value that is not a URL at all', () => { + expect(formatUtils.urlIsPubliclyReachable('')).toBe(false); + expect(formatUtils.urlIsPubliclyReachable('not a url')).toBe(false); }); }); diff --git a/packages/web/test/app/routes/agents/id/leave-dialog.test.tsx b/packages/web/test/app/routes/agents/id/leave-dialog.test.tsx new file mode 100644 index 000000000000..a4f4adb6d468 --- /dev/null +++ b/packages/web/test/app/routes/agents/id/leave-dialog.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { LeaveWithoutSavingDialog } from '@/app/routes/agents/id'; + +vi.mock('i18next', () => ({ t: (key: string) => key })); + +afterEach(cleanup); + +describe('LeaveWithoutSavingDialog', () => { + it('renders nothing while closed, so a clean exit is never interrupted', () => { + render( + , + ); + + expect(screen.queryByText('Leave without saving?')).toBeNull(); + }); + + it('warns that the edits are discarded, not merely unsaved', () => { + render( + , + ); + + expect(screen.getByText('Leave without saving?')).toBeTruthy(); + expect( + screen.getByText( + 'These edits have not gone live yet. Leave now and they are discarded.', + ), + ).toBeTruthy(); + }); + + it('keeps editing without discarding when the safe choice is taken', () => { + const onKeepEditing = vi.fn(); + const onDiscard = vi.fn(); + render( + , + ); + + screen.getByText('Keep editing').click(); + + expect(onKeepEditing).toHaveBeenCalledTimes(1); + expect(onDiscard).not.toHaveBeenCalled(); + }); + + it('discards only on the destructive choice', () => { + const onKeepEditing = vi.fn(); + const onDiscard = vi.fn(); + render( + , + ); + + screen.getByText('Discard changes').click(); + + expect(onDiscard).toHaveBeenCalledTimes(1); + expect(onKeepEditing).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/test/app/routes/agents/id/test-pane.test.tsx b/packages/web/test/app/routes/agents/id/test-pane.test.tsx new file mode 100644 index 000000000000..bb856c708367 --- /dev/null +++ b/packages/web/test/app/routes/agents/id/test-pane.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('i18next', () => ({ t: (key: string) => key })); +vi.mock('@/app/routes/chat-with-ai/ai-chat-box', () => ({ + AIChatBox: (props: Record) => ( +
+ ), +})); + +import { TestPane } from '@/app/routes/agents/id'; + +const agent = { + id: 'agent_1', + displayName: 'Inbox agent', + draft: { tools: [], instructions: 'Sort it.' }, +} as never; + +afterEach(cleanup); + +describe('TestPane', () => { + it('refuses to run without a model and says which thing is missing', () => { + render( + , + ); + + expect(screen.getByText('Pick a model before testing')).toBeTruthy(); + expect(screen.queryByTestId('chat')).toBeNull(); + }); + + it('refuses to run without instructions, so nothing unrunnable is offered', () => { + render( + , + ); + + expect(screen.getByText('Write instructions before testing')).toBeTruthy(); + expect(screen.queryByTestId('chat')).toBeNull(); + }); + + it('runs the agent itself, not the builder, so the test is a real agent turn', () => { + render( + , + ); + + const chat = screen.getByTestId('chat'); + expect(chat.getAttribute('data-agent')).toBe('agent_1'); + expect(chat.getAttribute('data-builder')).toBe('undefined'); + }); + + it('resumes the thread it is given, so switching modes does not lose it', () => { + render( + , + ); + + expect(screen.getByTestId('chat').getAttribute('data-conversation')).toBe( + 'conv_9', + ); + }); +}); diff --git a/packages/web/test/app/routes/agents/id/write-race.test.tsx b/packages/web/test/app/routes/agents/id/write-race.test.tsx new file mode 100644 index 000000000000..5a27c9950408 --- /dev/null +++ b/packages/web/test/app/routes/agents/id/write-race.test.tsx @@ -0,0 +1,177 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, render, screen } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mutateCalls: { goLive?: boolean }[] = []; + +vi.mock('i18next', () => ({ t: (key: string) => key })); +vi.mock('sonner', () => ({ toast: vi.fn() })); +vi.mock('@/app/routes/chat-with-ai/ai-chat-box', () => ({ + AIChatBox: () =>
, +})); +vi.mock('@/app/builder/step-settings/agent-settings/agent-tools', () => ({ + AgentTools: () =>
, +})); +vi.mock('@/features/agents', () => ({ + AIModelSelector: () =>
, + AgentStructuredOutput: () =>
, + useAgentsAvailable: () => true, +})); +vi.mock('@/features/agents/hooks/agents-hooks', () => ({ + agentsMutations: { + useUpdateAgent: () => ({ + isPending: false, + mutate: (request: { goLive?: boolean }) => { + mutateCalls.push(request); + }, + }), + }, + agentsQueries: { useAgent: () => ({ data: undefined, isLoading: false }) }, +})); + +import { AgentEditScreen } from '@/app/routes/agents/id'; + +const agent = { + id: 'agent_1', + displayName: 'Inbox agent', + description: null, + icon: 'bot', + color: 'PURPLE', + projectId: 'proj_1', + visibility: 'PROJECT', + sharedWithUserIds: [], + published: null, + draft: { + instructions: 'Sort the inbox.', + provider: 'openai', + providerConfigId: null, + modelName: 'gpt-5', + maxSteps: 10, + tools: [], + structuredOutput: [], + }, +} as never; + +const renderScreen = () => { + const router = createMemoryRouter( + [ + { + path: '/', + element: ( + + ), + }, + ], + { initialEntries: ['/'] }, + ); + return render( + + + , + ); +}; + +const clickTestTab = () => { + const trigger = screen.getByText('Test').closest('[role="tab"]'); + if (!trigger) throw new Error('Test tab not rendered'); + // Radix activates a tab on pointerdown, not click, so a plain .click() is a no-op here. + for (const type of ['pointerdown', 'mousedown', 'click']) { + trigger.dispatchEvent( + new MouseEvent(type, { bubbles: true, cancelable: true, button: 0 }), + ); + } + return trigger; +}; + +const typeInInstructions = (value: string) => { + const box = document.querySelector('textarea'); + if (!box) throw new Error('instructions textarea not rendered'); + const setter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + 'value', + )?.set; + setter?.call(box, value); + box.dispatchEvent(new Event('input', { bubbles: true })); +}; + +beforeEach(() => { + mutateCalls.length = 0; +}); +afterEach(cleanup); + +describe('the edit screen never lets two writes race', () => { + const armAndRace = async ({ saveFirst }: { saveFirst: boolean }) => { + renderScreen(); + typeInInstructions('Sort the inbox differently.'); + await new Promise((resolve) => setTimeout(resolve, 60)); + + if (saveFirst) { + document.querySelector('form')?.requestSubmit(); + clickTestTab(); + } else { + clickTestTab(); + document.querySelector('form')?.requestSubmit(); + } + await new Promise((resolve) => setTimeout(resolve, 120)); + }; + + it('issues one write when Test and Save are both triggered before either settles', async () => { + await armAndRace({ saveFirst: false }); + + // The stage having won is also the harness guard: if the tab intent had never + // fired there would be no race, and the surviving write would be the save. + expect(mutateCalls).toHaveLength(1); + expect(mutateCalls[0]?.goLive).toBe(false); + }); + + it('drops the save rather than queueing it behind the stage', async () => { + await armAndRace({ saveFirst: false }); + + expect(mutateCalls.filter((call) => call.goLive === undefined)).toHaveLength( + 0, + ); + }); + + it('lets the first intent win when the order is reversed', async () => { + await armAndRace({ saveFirst: true }); + + expect(mutateCalls).toHaveLength(1); + expect(mutateCalls[0]?.goLive).toBeUndefined(); + }); + + it('a lone save still goes live, so the lock does not block ordinary use', async () => { + renderScreen(); + typeInInstructions('Sort the inbox differently.'); + await new Promise((resolve) => setTimeout(resolve, 60)); + + document.querySelector('form')?.requestSubmit(); + await new Promise((resolve) => setTimeout(resolve, 120)); + + expect(mutateCalls).toHaveLength(1); + expect(mutateCalls[0]?.goLive).toBeUndefined(); + }); + + it('a lone Test stages without publishing', async () => { + renderScreen(); + typeInInstructions('Sort the inbox differently.'); + await new Promise((resolve) => setTimeout(resolve, 60)); + + clickTestTab(); + await new Promise((resolve) => setTimeout(resolve, 120)); + + expect(mutateCalls).toHaveLength(1); + expect(mutateCalls[0]?.goLive).toBe(false); + }); + + it('does not write at all when Test is pressed with nothing unsaved', async () => { + renderScreen(); + await new Promise((resolve) => setTimeout(resolve, 60)); + + clickTestTab(); + await new Promise((resolve) => setTimeout(resolve, 120)); + + expect(mutateCalls).toHaveLength(0); + }); +}); diff --git a/packages/web/test/app/routes/agents/lib/agent-edit-state.test.ts b/packages/web/test/app/routes/agents/lib/agent-edit-state.test.ts new file mode 100644 index 000000000000..6f23b6f54594 --- /dev/null +++ b/packages/web/test/app/routes/agents/lib/agent-edit-state.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest'; + +import { agentEditState } from '@/app/routes/agents/lib/agent-edit-state'; + +const config = (over: Record = {}) => ({ + displayName: 'Inbox agent', + description: '', + icon: 'BOT', + color: 'PURPLE', + draft: { instructions: 'Sort it.', modelName: 'gpt-5', provider: 'OPENAI' }, + ...over, +}); + +describe('sameConfig', () => { + it('treats an identical shape as unchanged', () => { + expect( + agentEditState.sameConfig({ left: config(), right: config() }), + ).toBe(true); + }); + + it.each([ + ['a changed instruction', { draft: { instructions: 'Other.' } }], + ['a changed name', { displayName: 'Renamed' }], + ['a changed model', { draft: { instructions: 'Sort it.', modelName: 'x' } }], + ])('sees %s as changed', (_label, over) => { + expect( + agentEditState.sameConfig({ left: config(), right: config(over) }), + ).toBe(false); + }); + + it('does not confuse null with undefined, so a cleared field counts as an edit', () => { + expect( + agentEditState.sameConfig({ + left: { modelName: null }, + right: { modelName: undefined }, + }), + ).toBe(false); + }); + + it('sees an added empty description as a change, since the server stores it', () => { + expect( + agentEditState.sameConfig({ left: {}, right: { description: '' } }), + ).toBe(false); + }); + + it('is order-sensitive on arrays, because tool order is meaningful', () => { + expect( + agentEditState.sameConfig({ left: { tools: ['a', 'b'] }, right: { tools: ['b', 'a'] } }), + ).toBe(false); + }); +}); + +describe('headerStatus', () => { + it('says live right after a launch, even while the refetch is in flight', () => { + expect( + agentEditState.headerStatus({ + needsModel: true, + justLaunched: true, + live: null, + hasChanges: true, + }), + ).toBe('live'); + }); + + it('asks for a model before anything else, since nothing can run without one', () => { + expect( + agentEditState.headerStatus({ + needsModel: true, + justLaunched: false, + live: config(), + hasChanges: false, + }), + ).toBe('needs-model'); + }); + + it('says pending for an agent that was never published', () => { + expect( + agentEditState.headerStatus({ + needsModel: false, + justLaunched: false, + live: null, + hasChanges: true, + }), + ).toBe('pending'); + }); + + it('says pending while a staged draft differs from the live copy', () => { + expect( + agentEditState.headerStatus({ + needsModel: false, + justLaunched: false, + live: config(), + hasChanges: true, + }), + ).toBe('pending'); + }); + + it('says live only when a published copy exists and nothing differs', () => { + expect( + agentEditState.headerStatus({ + needsModel: false, + justLaunched: false, + live: config(), + hasChanges: false, + }), + ).toBe('live'); + }); + + it('never claims live for an unpublished agent with no changes, which cannot happen but must not lie', () => { + expect( + agentEditState.headerStatus({ + needsModel: false, + justLaunched: false, + live: null, + hasChanges: false, + }), + ).toBe('pending'); + }); +}); + +describe('modeIntent', () => { + it('stages only when switching to test with unsaved typing and nothing blocking', () => { + expect( + agentEditState.modeIntent({ + next: 'test', + unsavedTyping: true, + blockedReason: null, + }), + ).toBe('stage'); + }); + + it('switches without writing when there is nothing unsaved to stage', () => { + expect( + agentEditState.modeIntent({ + next: 'test', + unsavedTyping: false, + blockedReason: null, + }), + ).toBe('switch'); + }); + + it.each(['model', 'instructions'])( + 'never stages an unrunnable config (%s missing), so the draft is not overwritten', + (reason) => { + expect( + agentEditState.modeIntent({ + next: 'test', + unsavedTyping: true, + blockedReason: reason, + }), + ).toBe('switch'); + }, + ); + + it.each(['edit', 'configure', 'settings', ''])( + 'never stages when moving to %s, because only test needs the draft persisted', + (next) => { + expect( + agentEditState.modeIntent({ next, unsavedTyping: true, blockedReason: null }), + ).toBe('switch'); + }, + ); +}); + +describe('createWriteLock', () => { + it('lets the first claim through', () => { + expect(agentEditState.createWriteLock().claim()).toBe(true); + }); + + it('refuses a second claim while the first is held, which is the race guard', () => { + const lock = agentEditState.createWriteLock(); + expect(lock.claim()).toBe(true); + expect(lock.claim()).toBe(false); + expect(lock.claim()).toBe(false); + }); + + it('lets the next writer in after a release', () => { + const lock = agentEditState.createWriteLock(); + lock.claim(); + lock.release(); + expect(lock.claim()).toBe(true); + }); + + it('survives a release that was never claimed, so a validation failure cannot lock writes out', () => { + const lock = agentEditState.createWriteLock(); + lock.release(); + lock.release(); + expect(lock.claim()).toBe(true); + }); + + it('reports whether it is held, so the caller can reason about the window', () => { + const lock = agentEditState.createWriteLock(); + expect(lock.held()).toBe(false); + lock.claim(); + expect(lock.held()).toBe(true); + lock.release(); + expect(lock.held()).toBe(false); + }); + + it('gives each screen its own lock, so one agent cannot block another', () => { + const first = agentEditState.createWriteLock(); + const second = agentEditState.createWriteLock(); + first.claim(); + expect(second.claim()).toBe(true); + }); + + it('admits exactly one of many simultaneous writers', () => { + const lock = agentEditState.createWriteLock(); + const admitted = Array.from({ length: 25 }, () => lock.claim()).filter(Boolean); + expect(admitted).toHaveLength(1); + }); +}); + +describe('leaveGuard', () => { + it('stays closed when nothing is trying to leave', () => { + expect( + agentEditState.leaveGuard({ blockerState: 'unblocked', exitRequested: false }), + ).toStrictEqual({ open: false, discardAction: 'none' }); + }); + + it('opens for a blocked router navigation and lets the router proceed', () => { + expect( + agentEditState.leaveGuard({ blockerState: 'blocked', exitRequested: false }), + ).toStrictEqual({ open: true, discardAction: 'proceed' }); + }); + + it('opens for the back arrow and exits in-app, since no navigation is pending', () => { + expect( + agentEditState.leaveGuard({ blockerState: 'unblocked', exitRequested: true }), + ).toStrictEqual({ open: true, discardAction: 'exit' }); + }); + + it('prefers the router when both are pending, so the queued navigation is not dropped', () => { + expect( + agentEditState.leaveGuard({ blockerState: 'blocked', exitRequested: true }), + ).toStrictEqual({ open: true, discardAction: 'proceed' }); + }); + + it.each(['proceeding', 'unblocked'])( + 'stays closed while the blocker is %s and no in-app exit was asked for', + (blockerState) => { + expect( + agentEditState.leaveGuard({ blockerState, exitRequested: false }).open, + ).toBe(false); + }, + ); +}); diff --git a/packages/web/test/app/routes/agents/lib/agent-test-gate.test.ts b/packages/web/test/app/routes/agents/lib/agent-test-gate.test.ts new file mode 100644 index 000000000000..859781bbdfe3 --- /dev/null +++ b/packages/web/test/app/routes/agents/lib/agent-test-gate.test.ts @@ -0,0 +1,54 @@ +import { AIProviderName } from '@activepieces/shared'; +import { describe, expect, it } from 'vitest'; + +import { agentTestGate } from '@/app/routes/agents/lib/agent-test-gate'; + +const runnable = { + instructions: 'Triage the inbox.', + provider: AIProviderName.ANTHROPIC, + modelName: 'claude-sonnet-4-6', + tools: [], + structuredOutput: [], + maxSteps: 20, +}; + +describe('agentTestGate.blockedReason', () => { + it('lets a runnable draft through', () => { + expect(agentTestGate.blockedReason({ draft: runnable })).toBeNull(); + }); + + it('blocks on a missing model, because the run cannot resolve one', () => { + expect( + agentTestGate.blockedReason({ draft: { ...runnable, modelName: null } }), + ).toBe('model'); + expect( + agentTestGate.blockedReason({ draft: { ...runnable, provider: null } }), + ).toBe('model'); + }); + + it.each([ + ['empty', ''], + ['spaces', ' '], + ['tabs', '\t\t'], + ['newlines', '\n\n'], + ])( + 'blocks %s instructions, so testing never overwrites the draft with an unrunnable one', + (_kind, instructions) => { + expect( + agentTestGate.blockedReason({ draft: { ...runnable, instructions } }), + ).toBe('instructions'); + }, + ); + + it('reports the missing model first, so the fix order matches the panel', () => { + expect( + agentTestGate.blockedReason({ + draft: { ...runnable, modelName: null, instructions: '' }, + }), + ).toBe('model'); + }); + + it('blocks an absent draft rather than staging nothing', () => { + expect(agentTestGate.blockedReason({ draft: undefined })).toBe('model'); + }); +}); diff --git a/packages/web/test/app/routes/mcp-server/mcp-client-catalog.test.ts b/packages/web/test/app/routes/mcp-server/mcp-client-catalog.test.ts new file mode 100644 index 000000000000..cbeeba4335cb --- /dev/null +++ b/packages/web/test/app/routes/mcp-server/mcp-client-catalog.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('i18next', () => ({ + default: { language: 'en-US' }, + t: (key: string) => key, +})); + +const { mcpClientCatalog } = await import( + '@/app/routes/mcp-server/mcp-client-catalog' +); + +const SERVER_URL = 'https://cloud.activepieces.com/mcp'; + +function clientNamed(key: string, isCloud: boolean) { + return mcpClientCatalog + .clients({ + serverUrl: SERVER_URL, + websiteName: 'Activepieces', + isCloud, + }) + .find((client) => client.key === key); +} + +describe('mcpClientCatalog cloud overrides', () => { + it('sends Claude to the directory listing on cloud only', () => { + expect(clientNamed('claude', true)?.instructions[0].action?.href).toBe( + 'https://claude.ai/directory/cloud-activepieces-com', + ); + expect(clientNamed('claude', false)?.instructions[0].action?.href).toContain( + 'add-custom-connector', + ); + }); + + it('leaves clients without a cloud override untouched', () => { + expect(clientNamed('codex', true)).toEqual(clientNamed('codex', false)); + }); + + it('keeps the Cursor deep link on cloud and only swaps the docs link', () => { + const cloudCursor = clientNamed('cursor', true); + expect(cloudCursor?.docsUrl).toBe( + 'https://cursor.directory/plugins/activepieces-mcp-connector-for-cursor', + ); + expect(cloudCursor?.instructions[0]).toEqual( + clientNamed('cursor', false)?.instructions[0], + ); + }); +}); + +describe('mcpClientCatalog generated commands', () => { + it('encodes the server url into the Cursor deep link', () => { + const href = + clientNamed('cursor', false)?.instructions[0].action?.href ?? ''; + const config = new URL(href).searchParams.get('config') ?? ''; + expect(JSON.parse(atob(config))).toEqual({ url: SERVER_URL }); + }); + + it('encodes the server url into the VS Code deep link', () => { + const href = + clientNamed('vscode', false)?.instructions[0].action?.href ?? ''; + expect(JSON.parse(decodeURIComponent(href.split('?')[1]))).toEqual({ + name: 'activepieces', + type: 'http', + url: SERVER_URL, + }); + }); + + it('builds the Claude Code add command', () => { + expect(clientNamed('claude-code', false)?.instructions[0].command).toBe( + `claude mcp add --transport http activepieces ${SERVER_URL}`, + ); + }); +}); diff --git a/packages/web/test/features/chat/lib/chat-reopen-guard.test.ts b/packages/web/test/features/chat/lib/chat-reopen-guard.test.ts new file mode 100644 index 000000000000..34976144e4e6 --- /dev/null +++ b/packages/web/test/features/chat/lib/chat-reopen-guard.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { chatUtils } from '@/features/chat/lib/chat-utils'; + +describe('chatUtils.reopensSameConversation', () => { + it('is true for the id the chat is already in, which must not reload and kill the stream', () => { + expect( + chatUtils.reopensSameConversation({ current: 'conv_1', next: 'conv_1' }), + ).toBe(true); + }); + + it('is false when switching to a different conversation, which must reload', () => { + expect( + chatUtils.reopensSameConversation({ current: 'conv_1', next: 'conv_2' }), + ).toBe(false); + }); + + it('is false for the first conversation of a fresh chat box', () => { + expect( + chatUtils.reopensSameConversation({ current: null, next: 'conv_1' }), + ).toBe(false); + }); + + it('does not treat a null current as matching an empty id', () => { + expect(chatUtils.reopensSameConversation({ current: null, next: '' })).toBe( + false, + ); + }); + + it('is case-sensitive, because ids are opaque and case-significant', () => { + expect( + chatUtils.reopensSameConversation({ current: 'Conv_1', next: 'conv_1' }), + ).toBe(false); + }); +});