diff --git a/Dockerfile b/Dockerfile index 96d1e27cb7f3..c363f43dff2c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -88,7 +88,7 @@ RUN node -e "\ process.stdout.write(JSON.stringify(names));\ " > packages/server/api/dist/src/migration-manifest.json -# Remove workspaces not needed at runtime: pieces except the 4 the api imports, +# Remove workspaces not needed at runtime: pieces except the 5 the api imports, # plus web/cli/tests-e2e/embed-sdk whose deps (react & friends) would otherwise land # in the runtime node_modules. dist/packages/web is already built and kept. # Then drop the removed entries from the root workspaces list and regenerate bun.lock. @@ -99,6 +99,7 @@ RUN rm -rf packages/pieces/core packages/pieces/custom \ ! -name square \ ! -name facebook-leads \ ! -name intercom \ + ! -name microsoft-teams-bot \ -exec rm -rf {} + && \ node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.workspaces=p.workspaces.filter(w=>fs.existsSync(w.replace('/*','')));fs.writeFileSync('package.json',JSON.stringify(p,null,2))" && \ rm -f bun.lock && bun install diff --git a/brain/knowledge/ai-intelligence/ai-providers.md b/brain/knowledge/ai-intelligence/ai-providers.md index 37c10a090cfd..099aaa58a408 100644 --- a/brain/knowledge/ai-intelligence/ai-providers.md +++ b/brain/knowledge/ai-intelligence/ai-providers.md @@ -58,6 +58,7 @@ Lets platform admins configure one or more LLM backends for AI pieces in flows. - **Attribution headers are for `ACTIVEPIECES` only, and go through the factory's `extraHeaders` option rather than a local `createOpenRouter` call.** The managed provider is OpenRouter under the hood on our own key, so the `x-ap-*` headers are what tag *our* account's events: `x-ap-platform-id` / `x-ap-conversation-id` / `x-ap-run-id` on the agent path, `x-ap-project-id` / `x-ap-flow-id` / `x-ap-run-id` on the piece path. BYOK `OPENROUTER` is a customer's own account and must not get them. Constructing the provider inline to attach headers is also what silently drops `openRouterSettings` (the web-search plugin), since the factory is the only place that still passes them. (`CUSTOM` separately receives the piece-path metadata headers — that is #11700's metadata forwarding for self-hosted OpenAI-compatible endpoints, older than either the rename or the Autumn work and unrelated to OpenRouter attribution. Its precedence is deliberate: admin-configured `defaultHeaders` override the `x-ap-*` metadata, and the api key is applied last.) - **`mistralViaOpenRouter` does not mean "the managed provider"; it is read only inside the `MISTRAL` case, and that branch looks like dead legacy.** `ACTIVEPIECES` routes through OpenRouter unconditionally and ignores the flag, so the only thing the agent path's `mistralViaOpenRouter: true` does is send a `MISTRAL` chat row to openrouter.ai — carrying that row's *Mistral* key, which cannot authenticate there. `MISTRAL` also has no `ALLOWED_CHAT_MODELS_BY_PROVIDER` entry, so `getCuratedChatModels` returns `undefined` for it and the resolver falls back to a tier's OpenRouter-shaped id. The fall-through arrived as a drive-by in #13489, not as a routing decision. Don't infer "this provider is AP-managed" from that case group. - **AI Tool Configs** are a *sibling* feature (same `ai/` dir), distinct from AI Providers: they give the chat assistant external capabilities via `/v1/ai-tools` (platform-admin, EE/Cloud). **AiToolCapability** = `WEB_SEARCH`/`WEB_SCRAPING`/`IMAGE_GENERATION`; **AiToolProvider** = `TAVILY`/`FIRECRAWL`/`APIFY`/`FAL`. One config per capability (unique on platformId+capability); consumed by chat via `getEnabledTools()`. **Because the config is per-platform, it can never serve a first-run flow on Cloud.** A self-serve signup lands on a brand-new platform with no configs at all, so `getEnabledTools()` returns `{}` for exactly the users a new-signup feature is aimed at, and any capability read from it silently no-ops rather than failing loudly. A capability that has to work for someone who just signed up needs a cloud-wide `AppSystemProp` key instead, the way `TURNSTILE_SECRET_KEY`, `FEATUREBASE_API_KEY` and `APPSUMO_TOKEN` are sourced. Note there is no `ENRICHMENT` capability here, so anything needing people or company enrichment has nowhere to read a key from today. +- **`/v1/ai-tools` is registered only in the CLOUD and ENTERPRISE branches of `app.ts`, but the AI Center page that reads it is not edition-gated** — so a Community admin opening the Capabilities tab fired `useAiToolConfigs`, got Fastify's `Route not found`, and the query's `meta.showErrorDialog` popped the global "Failed to load data" dialog. Shipped that way from #13911 until the tab was gated on `ApFlagId.EDITION` in the page. Two things make this class of bug hard to place: the dialog is opened from `QueryCache.onError` in `query-client.ts`, so it is page-independent, and React Query's 3 default retries mean it lands several seconds later on whatever page you navigated to next (the report was against `/platform/setup/general`). When a screenshot's edition is in doubt, read the sidebar: **Billing & subscription** and **Usage** carry a lock only when `edition === COMMUNITY`, every other lock there is plan-driven. Any new EE-only route needs its UI entry point gated the same way, `enabled:` on the query or hiding the surface. ### Key files diff --git a/brain/knowledge/ai-intelligence/mcp-server.md b/brain/knowledge/ai-intelligence/mcp-server.md index a8f0e8cd55c6..72bc110eb4fa 100644 --- a/brain/knowledge/ai-intelligence/mcp-server.md +++ b/brain/knowledge/ai-intelligence/mcp-server.md @@ -34,6 +34,9 @@ Exposes an Activepieces project as an MCP server so AI clients (Claude Desktop, - **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. - External MCP-server validation for the agent piece lives under `agents/`, NOT here (it's a probe, not the AP-as-server feature). +- **Every registered tool must declare all three safety hints** — `readOnlyHint`, `destructiveHint`, `openWorldHint`. `McpToolDefinition.annotations` is optional and `buildToolConfig` passes it straight through, so an omitted hint is silent: MCP clients fall back to protocol defaults, but a ChatGPT Apps submission treats any missing hint as a blocker. The two dynamic paths are the easiest to miss because they build their tool config inline instead of from an `McpToolDefinition` — `registerFlowTools` (one tool per enabled MCP-trigger flow) and `registerPlaceholderTools` (the no-project-selected state, which is what a fresh external reviewer meets first). Placeholders annotate per list — locked names get the read-only triple, controllable names get `destructive: true, openWorld: true` — so a stand-in never advertises itself as safer than the tool it represents. +- **`openWorldHint` means the tool can change state in a third-party system**, not that it makes an outbound call. Anything that executes real connector steps needs it: `ap_test_flow`, `ap_test_step`, `ap_retry_run`, `ap_run_action`, and every dynamic flow tool. A read that only calls a connected account to populate dropdowns (`ap_get_piece_props`, `ap_resolve_property_options`, `ap_resolve_property_chain`) does not. `ap_retry_run` originally declared `false` here and was wrong — a retry re-runs the published flow and can resend the same Slack message or repeat an outbound write. +- The hints are **advisory metadata for the client, never enforcement**. Authorization stays with `permissionChecker.wrapExecute` and each tool's `permission`; changing an annotation changes what a client is told, not what a caller is allowed to do. ### Key files diff --git a/bun.lock b/bun.lock index b3a099d8d9fe..b6e77559b5f0 100644 --- a/bun.lock +++ b/bun.lock @@ -162,7 +162,7 @@ }, "packages/core/shared": { "name": "@activepieces/shared", - "version": "0.141.0", + "version": "0.142.0", "dependencies": { "@activepieces/core-execution": "workspace:*", "@activepieces/core-formula": "workspace:*", @@ -5870,7 +5870,7 @@ }, "packages/pieces/community/microsoft-teams-bot": { "name": "@activepieces/piece-microsoft-teams-bot", - "version": "0.0.2", + "version": "0.1.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10759,6 +10759,7 @@ "devDependencies": { "@activepieces/piece-facebook-leads": "workspace:*", "@activepieces/piece-intercom": "workspace:*", + "@activepieces/piece-microsoft-teams-bot": "workspace:*", "@activepieces/piece-slack": "workspace:*", "@activepieces/piece-square": "workspace:*", "@faker-js/faker": "8.2.0", diff --git a/docs/install/reference/breaking-changes.mdx b/docs/install/reference/breaking-changes.mdx index 9bd897153281..265759460207 100644 --- a/docs/install/reference/breaking-changes.mdx +++ b/docs/install/reference/breaking-changes.mdx @@ -8,6 +8,14 @@ icon: "hammer" ### What has changed? +#### Workers no longer pre-warm the flow cache on startup by default + +Since v0.86.1 every worker pre-filled its local piece and code cache on startup by resolving and compiling every enabled flow on the platform. That warm-up costs memory and CPU proportional to the number of enabled flows: on instances with many flows it pinned each worker at its CPU limit for the duration and spiked memory enough to OOM-kill small workers, especially during upgrades when all workers restart at once. The warm-up is now opt-in behind the new `AP_PREWARM_CACHE_ON_STARTUP` worker environment variable, which defaults to `false`. When disabled, caches fill lazily on each flow's first run after a worker starts, exactly as they did before v0.86.1. + +#### What you need to do + +Nothing, unless you want to keep the pre-v0.88 warm-up behaviour. Set `AP_PREWARM_CACHE_ON_STARTUP=true` on your worker containers to restore it — recommended only if your instance has a modest number of enabled flows and your workers have memory headroom. See [Environment Variables](/install/reference/environment-variables) for details. + #### A new workspace is named after the company in the sign-up email Completing sign-up used to always name the new platform after the person, as `"Ahmad's Platform"`. It now reads the email domain first: `ahmad@activepieces.com` creates a platform called `Activepieces`, and the project alongside it becomes `Activepieces's Project`. @@ -65,6 +73,23 @@ The Docker image used to run the app and worker under PM2, which restarted a cra #### What you need to do Nothing if you deploy with Docker Compose, Kubernetes/Helm, or any orchestrator that already restarts failed containers (all official deployments do). If you run the image with a bare `docker run` and relied on PM2 to keep the container alive across process crashes, add a restart policy: `docker run --restart unless-stopped ...`. + +#### The Microsoft Teams Bot piece no longer needs a server-side installation record + +`POST /v1/teams-bot/webhook` and `POST /v1/teams-bot/send` are removed, and the `teams_bot_installation` table is dropped. The webhook existed only to capture Microsoft's per-tenant `serviceUrl` at install time so the send endpoint could look it up. + +The piece now posts to Microsoft's documented global Teams service endpoint directly, so it holds no server-side state and its messaging endpoint moves to the shared app-webhooks route, `/api/v1/app-events/microsoft-teams-bot`. Because the installed-or-not check is no longer a cached row, it is answered by Microsoft at send time: posting to a team the bot is not a member of now fails with `403 The bot is not part of the conversation roster` instead of a stale local lookup. + +The piece is published as `0.1.0` with a minimum supported release of `0.88.4`, so releases below `0.88.4` keep being offered `0.0.2`, which still has the server route it depends on. + +#### What you need to do + +Nothing for existing connections; they keep working untouched, and sending no longer reads the dropped table. + +If you already have a flow using **Microsoft Teams Bot** piece version `0.0.2`, open the step and upgrade the piece to `0.1.0`. Flow steps pin an exact piece version, so an existing step stays on `0.0.2` after you upgrade, and `0.0.2` calls the `/v1/teams-bot/send` route this release removes. That step will fail with a 404 until it is upgraded. + +Optionally repoint the **Messaging endpoint** on your Azure Bot resource from `/api/v1/teams-bot/webhook` to `/api/v1/app-events/microsoft-teams-bot`; an endpoint left on the old path returns 404 and is otherwise harmless. If you call `/v1/teams-bot/send` directly from your own code, switch to the piece's **Send Channel Message as Bot** action. + #### Table record filters compare date columns chronologically The `gt`, `gte`, `lt` and `lte` operators on `GET /v1/records` used to parse every cell value as a number. On a Date column that meant `2026-08-12T14:30:00Z` was read as `2026`, so two dates in the same year always compared equal and a range filter matched nothing. Those four operators now compare Date and Date & Time columns as instants. diff --git a/docs/install/reference/environment-variables.mdx b/docs/install/reference/environment-variables.mdx index a7bad7f6c89e..9965bfa6da3c 100644 --- a/docs/install/reference/environment-variables.mdx +++ b/docs/install/reference/environment-variables.mdx @@ -145,6 +145,7 @@ run timeouts, and the network egress posture for user code. Read | `AP_EXECUTION_MODE` | Sandbox strategy: `UNSANDBOXED`, `SANDBOX_PROCESS`, `SANDBOX_CODE_ONLY`, or `SANDBOX_CODE_AND_PROCESS`. | `UNSANDBOXED` | | `AP_CONTAINER_TYPE` | Which services run in the container: `APP` (API only), `WORKER` (worker only), or `WORKER_AND_APP` (both). | `WORKER_AND_APP` | | `AP_WORKER_CONCURRENCY` | Concurrent jobs a worker processes at once. Each job uses one sandbox instance. | `5` | +| `AP_PREWARM_CACHE_ON_STARTUP` | Pre-fill the worker's local piece and code cache on startup by resolving every enabled flow on the platform, instead of filling it lazily on each flow's first run. Enabling it removes the one-time cold-start latency of the first run after a worker (re)starts, but the warm-up itself costs memory and CPU proportional to the number of enabled flows — on instances with many flows it can pin the worker at its CPU limit for the duration of the warm-up and spike memory enough to OOM-kill small workers, especially when all workers restart at once during an upgrade. Keep it disabled unless your instance has a modest number of enabled flows, your workers have memory headroom, and first-run latency after deploys matters to you. | `false` | | `AP_SANDBOX_MEMORY_LIMIT` | Maximum memory (KB) a single sandboxed engine process can use. Each process runs at most one execution at a time. | `1048576` | | `AP_SANDBOX_PROPAGATED_ENV_VARS` | Comma-separated environment variables propagated into sandboxed code. For pieces, keep everything in the authentication object so it works across instances. | `None` | | `AP_FLOW_TIMEOUT_SECONDS` | Maximum runtime for a single flow run, in seconds. | `600` | diff --git a/packages/pieces/community/microsoft-teams-bot/package.json b/packages/pieces/community/microsoft-teams-bot/package.json index 8268153b5602..c1b6ff9f794c 100644 --- a/packages/pieces/community/microsoft-teams-bot/package.json +++ b/packages/pieces/community/microsoft-teams-bot/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-microsoft-teams-bot", - "version": "0.0.2", + "version": "0.1.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/microsoft-teams-bot/src/i18n/translation.json b/packages/pieces/community/microsoft-teams-bot/src/i18n/translation.json index 5b43aa8fef6b..a2f8eab971b6 100644 --- a/packages/pieces/community/microsoft-teams-bot/src/i18n/translation.json +++ b/packages/pieces/community/microsoft-teams-bot/src/i18n/translation.json @@ -3,7 +3,8 @@ "Bot App ID": "Bot App ID", "Bot App Secret": "Bot App Secret", "Tenant ID": "Tenant ID", - "\nRegister a **single-tenant** Azure Bot in your own Microsoft tenant, then paste its credentials below. Microsoft deprecated multi-tenant bots, so the bot must live in the **same tenant** you use Microsoft Teams from — sign in to the [Azure Portal](https://portal.azure.com) with an admin of that tenant.\n\n📖 **[Full step-by-step guide (bot setup + packaging the Teams app)](https://github.com/activepieces/activepieces/blob/main/packages/pieces/community/microsoft-teams-bot/teams-app/README.md)** — the steps b": "\nRegister a **single-tenant** Azure Bot in your own Microsoft tenant, then paste its credentials below. Microsoft deprecated multi-tenant bots, so the bot must live in the **same tenant** you use Microsoft Teams from — sign in to the [Azure Portal](https://portal.azure.com) with an admin of that tenant.\n\n📖 **[Full step-by-step guide (bot setup + packaging the Teams app)](https://github.com/activepieces/activepieces/blob/main/packages/pieces/community/microsoft-teams-bot/teams-app/README.md)** — the steps below are the short version.\n\n**1. Create the Azure Bot**\n- Create an **Azure Bot** resource → **Pricing tier: Free (F0)** → **Type of App: Single Tenant** → **Create new Microsoft App ID**. This creates the App Registration.\n\n**2. Enable the Teams channel**\n- On the Bot resource → **Channels** → add **Microsoft Teams** and Apply. (Without this, installing the app fails.)\n\n**3. Set the messaging endpoint**\nOn the Bot resource → **Configuration** → **Messaging endpoint**, paste:\n\n```text\n{{frontendUrl}}/api/v1/teams-bot/webhook\n```\n\nThis is how the bot learns where to post — the app **must** be installed for sending to work.\n\n**4. Create a secret**\n- App Registration → **Certificates & secrets** → **New client secret** → copy the **Value** (not the Secret ID).\n\n**5. Grant Graph permissions (for the Team & Channel dropdowns)**\n- App Registration → **API permissions** → **Add → Microsoft Graph → Application permissions** → add `Team.ReadBasic.All` and `Channel.ReadBasic.All` → **Grant admin consent** (both must show green).\n\n**6. Install the bot in your team**\n- Package your Teams app (`manifest.json` `botId` = this App ID) and add it to the team/channel you want to post to. On install, the bot registers itself so Activepieces can message that channel.\n\nThen fill in:\n- **Bot App ID** — the Application (client) ID\n- **Bot App Secret** — the client secret **Value**\n- **Tenant ID** — your Directory (tenant) ID", + "Your Directory (tenant) ID. Must be the same tenant you use Microsoft Teams from. The bot is single-tenant and cannot post into another tenant.": "Your Directory (tenant) ID. Must be the same tenant you use Microsoft Teams from. The bot is single-tenant and cannot post into another tenant.", + "\nRegister a **single-tenant** Azure Bot in your own Microsoft tenant, then paste its credentials below. Microsoft deprecated multi-tenant bots, so the bot must live in the **same tenant** you use Microsoft Teams from. Sign in to the [Azure Portal](https://portal.azure.com) with an admin of that tenant.\n\n📖 **[Full step-by-step guide (bot setup + packaging the Teams app)](https://github.com/activepieces/activepieces/blob/main/packages/pieces/community/microsoft-teams-bot/teams-app/README.md)**: the steps below are the short version.\n\n**1. Create the Azure Bot**\n- Create an **Azure Bot** resource → **Pricing tier: Free (F0)** → **Type of App: Single Tenant** → **Create new Microsoft App ID**. This creates the App Registration.\n\n**2. Enable the Teams channel**\n- On the Bot resource → **Channels** → add **Microsoft Teams** and Apply. (Without this, installing the app fails.)\n\n**3. Set the messaging endpoint**\nOn the Bot resource → **Configuration** → **Messaging endpoint**, paste:\n\n```text\n{{frontendUrl}}/api/v1/app-events/microsoft-teams-bot\n```\n\nAzure requires a messaging endpoint. The app **must** be installed in the team for sending to work.\n\n**4. Create a secret**\n- App Registration → **Certificates & secrets** → **New client secret** → copy the **Value** (not the Secret ID).\n\n**5. Grant Graph permissions (for the Team & Channel dropdowns)**\n- App Registration → **API permissions** → **Add → Microsoft Graph → Application permissions** → add `Team.ReadBasic.All` and `Channel.ReadBasic.All` → **Grant admin consent** (both must show green).\n\n**6. Install the bot in your team**\n- Package your Teams app (`manifest.json` `botId` = this App ID) and add it to the team/channel you want to post to. Sending fails until the bot is a member of the team.\n\nThen fill in:\n- **Bot App ID**: the Application (client) ID\n- **Bot App Secret**: the client secret **Value**\n- **Tenant ID**: your Directory (tenant) ID": "\nRegister a **single-tenant** Azure Bot in your own Microsoft tenant, then paste its credentials below. Microsoft deprecated multi-tenant bots, so the bot must live in the **same tenant** you use Microsoft Teams from. Sign in to the [Azure Portal](https://portal.azure.com) with an admin of that tenant.\n\n📖 **[Full step-by-step guide (bot setup + packaging the Teams app)](https://github.com/activepieces/activepieces/blob/main/packages/pieces/community/microsoft-teams-bot/teams-app/README.md)**: the steps below are the short version.\n\n**1. Create the Azure Bot**\n- Create an **Azure Bot** resource → **Pricing tier: Free (F0)** → **Type of App: Single Tenant** → **Create new Microsoft App ID**. This creates the App Registration.\n\n**2. Enable the Teams channel**\n- On the Bot resource → **Channels** → add **Microsoft Teams** and Apply. (Without this, installing the app fails.)\n\n**3. Set the messaging endpoint**\nOn the Bot resource → **Configuration** → **Messaging endpoint**, paste:\n\n```text\n{{frontendUrl}}/api/v1/app-events/microsoft-teams-bot\n```\n\nAzure requires a messaging endpoint. The app **must** be installed in the team for sending to work.\n\n**4. Create a secret**\n- App Registration → **Certificates & secrets** → **New client secret** → copy the **Value** (not the Secret ID).\n\n**5. Grant Graph permissions (for the Team & Channel dropdowns)**\n- App Registration → **API permissions** → **Add → Microsoft Graph → Application permissions** → add `Team.ReadBasic.All` and `Channel.ReadBasic.All` → **Grant admin consent** (both must show green).\n\n**6. Install the bot in your team**\n- Package your Teams app (`manifest.json` `botId` = this App ID) and add it to the team/channel you want to post to. Sending fails until the bot is a member of the team.\n\nThen fill in:\n- **Bot App ID**: the Application (client) ID\n- **Bot App Secret**: the client secret **Value**\n- **Tenant ID**: your Directory (tenant) ID", "Send Channel Message as Bot": "Send Channel Message as Bot", "Sends a message to a channel from the Activepieces Bot. The bot must be installed in the team first.": "Sends a message to a channel from the Activepieces Bot. The bot must be installed in the team first.", "Team ID": "Team ID", @@ -12,4 +13,4 @@ "Message": "Message", "Text": "Text", "HTML": "HTML" -} \ No newline at end of file +} diff --git a/packages/pieces/community/microsoft-teams-bot/src/index.ts b/packages/pieces/community/microsoft-teams-bot/src/index.ts index d340fc1c575a..a87c10eea021 100644 --- a/packages/pieces/community/microsoft-teams-bot/src/index.ts +++ b/packages/pieces/community/microsoft-teams-bot/src/index.ts @@ -5,11 +5,15 @@ import { microsoftTeamsBotAuth } from './lib/auth'; export const microsoftTeamsBot = createPiece({ displayName: 'Microsoft Teams Bot', description: 'Send messages to Teams channels from the Activepieces Bot, once it has been installed into a team.', - minimumSupportedRelease: '0.86.4', + minimumSupportedRelease: '0.88.4', logoUrl: 'https://cdn.activepieces.com/pieces/microsoft-teams.png', categories: [PieceCategory.COMMUNICATION], auth: microsoftTeamsBotAuth, authors: ['kishanprmr'], + events: { + parseAndReply: () => ({ reply: { headers: {}, body: {} } }), + verify: () => false, + }, actions: [ sendChannelMessageAsBotAction, ], diff --git a/packages/pieces/community/microsoft-teams-bot/src/lib/actions/send-channel-message-as-bot.ts b/packages/pieces/community/microsoft-teams-bot/src/lib/actions/send-channel-message-as-bot.ts index b361a2d67aef..c42aa3278b68 100644 --- a/packages/pieces/community/microsoft-teams-bot/src/lib/actions/send-channel-message-as-bot.ts +++ b/packages/pieces/community/microsoft-teams-bot/src/lib/actions/send-channel-message-as-bot.ts @@ -1,7 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; -import { httpClient, HttpMethod } from '@activepieces/pieces-common'; import { microsoftTeamsBotAuth } from '../auth'; import { microsoftTeamsBotCommon } from '../common'; +import { botConnector } from '../common/bot-connector'; export const sendChannelMessageAsBotAction = createAction({ auth: microsoftTeamsBotAuth, @@ -37,13 +37,14 @@ export const sendChannelMessageAsBotAction = createAction({ const { teamId, channelId, contentType, content } = context.propsValue; const { appId, appSecret, tenantId } = context.auth.props; - const response = await httpClient.sendRequest({ - method: HttpMethod.POST, - url: `${context.server.apiUrl}v1/teams-bot/send`, - headers: { Authorization: `Bearer ${context.server.token}` }, - body: { appId, appSecret, tenantId, teamId, channelId, content, contentType }, + return botConnector.sendChannelMessage({ + appId, + appSecret, + tenantId, + teamId, + channelId, + content, + contentType, }); - - return response.body; }, }); diff --git a/packages/pieces/community/microsoft-teams-bot/src/lib/auth.ts b/packages/pieces/community/microsoft-teams-bot/src/lib/auth.ts index 4259862b3b62..c65e15991cc5 100644 --- a/packages/pieces/community/microsoft-teams-bot/src/lib/auth.ts +++ b/packages/pieces/community/microsoft-teams-bot/src/lib/auth.ts @@ -2,9 +2,9 @@ import { PieceAuth, Property } from '@activepieces/pieces-framework'; import { createGraphClient, getAppOnlyToken, GRAPH_DEFAULT_SCOPE, withGraphRetry } from './common/graph'; const authDesc = ` -Register a **single-tenant** Azure Bot in your own Microsoft tenant, then paste its credentials below. Microsoft deprecated multi-tenant bots, so the bot must live in the **same tenant** you use Microsoft Teams from — sign in to the [Azure Portal](https://portal.azure.com) with an admin of that tenant. +Register a **single-tenant** Azure Bot in your own Microsoft tenant, then paste its credentials below. Microsoft deprecated multi-tenant bots, so the bot must live in the **same tenant** you use Microsoft Teams from. Sign in to the [Azure Portal](https://portal.azure.com) with an admin of that tenant. -📖 **[Full step-by-step guide (bot setup + packaging the Teams app)](https://github.com/activepieces/activepieces/blob/main/packages/pieces/community/microsoft-teams-bot/teams-app/README.md)** — the steps below are the short version. +📖 **[Full step-by-step guide (bot setup + packaging the Teams app)](https://github.com/activepieces/activepieces/blob/main/packages/pieces/community/microsoft-teams-bot/teams-app/README.md)**: the steps below are the short version. **1. Create the Azure Bot** - Create an **Azure Bot** resource → **Pricing tier: Free (F0)** → **Type of App: Single Tenant** → **Create new Microsoft App ID**. This creates the App Registration. @@ -16,10 +16,10 @@ Register a **single-tenant** Azure Bot in your own Microsoft tenant, then paste On the Bot resource → **Configuration** → **Messaging endpoint**, paste: \`\`\`text -{{frontendUrl}}/api/v1/teams-bot/webhook +{{frontendUrl}}/api/v1/app-events/microsoft-teams-bot \`\`\` -This is how the bot learns where to post — the app **must** be installed for sending to work. +Azure requires a messaging endpoint. The app **must** be installed in the team for sending to work. **4. Create a secret** - App Registration → **Certificates & secrets** → **New client secret** → copy the **Value** (not the Secret ID). @@ -28,12 +28,12 @@ This is how the bot learns where to post — the app **must** be installed for s - App Registration → **API permissions** → **Add → Microsoft Graph → Application permissions** → add \`Team.ReadBasic.All\` and \`Channel.ReadBasic.All\` → **Grant admin consent** (both must show green). **6. Install the bot in your team** -- Package your Teams app (\`manifest.json\` \`botId\` = this App ID) and add it to the team/channel you want to post to. On install, the bot registers itself so Activepieces can message that channel. +- Package your Teams app (\`manifest.json\` \`botId\` = this App ID) and add it to the team/channel you want to post to. Sending fails until the bot is a member of the team. Then fill in: -- **Bot App ID** — the Application (client) ID -- **Bot App Secret** — the client secret **Value** -- **Tenant ID** — your Directory (tenant) ID`; +- **Bot App ID**: the Application (client) ID +- **Bot App Secret**: the client secret **Value** +- **Tenant ID**: your Directory (tenant) ID`; export const microsoftTeamsBotAuth = PieceAuth.CustomAuth({ description: authDesc, @@ -49,6 +49,8 @@ export const microsoftTeamsBotAuth = PieceAuth.CustomAuth({ }), tenantId: Property.ShortText({ displayName: 'Tenant ID', + description: + 'Your Directory (tenant) ID. Must be the same tenant you use Microsoft Teams from. The bot is single-tenant and cannot post into another tenant.', required: true, }), }, diff --git a/packages/pieces/community/microsoft-teams-bot/src/lib/common/bot-connector.ts b/packages/pieces/community/microsoft-teams-bot/src/lib/common/bot-connector.ts new file mode 100644 index 000000000000..d40bb89acbf0 --- /dev/null +++ b/packages/pieces/community/microsoft-teams-bot/src/lib/common/bot-connector.ts @@ -0,0 +1,153 @@ +import { HttpError, httpClient, HttpMethod } from '@activepieces/pieces-common'; +import { tryCatch } from '@activepieces/pieces-framework'; +import { getAppOnlyToken } from './graph'; +import { microsoftCloud } from './microsoft-cloud'; + +const BOT_CONNECTOR_SCOPE = 'https://api.botframework.com/.default'; + +const sendChannelMessage = async ({ + appId, + appSecret, + tenantId, + teamId, + channelId, + content, + contentType, +}: SendChannelMessageParams): Promise => { + const { data: botToken, error: tokenError } = await tryCatch(() => + getAppOnlyToken({ + tenantId, + appId, + appSecret, + scope: BOT_CONNECTOR_SCOPE, + }), + ); + + if (tokenError) { + throw new Error( + describeFailure({ + error: tokenError, + fallback: + 'Microsoft rejected the bot credentials. Check the Bot App ID, Bot App Secret and Tenant ID on this connection', + }), + ); + } + + const { data: created, error } = await tryCatch(() => + createChannelConversation({ + serviceUrl: microsoftCloud.getBotServiceUrl(), + botToken, + botAppId: appId, + tenantId, + channelId, + content, + contentType, + }), + ); + + if (error) { + throw new Error(describeConversationError(error)); + } + + const messageId = created.activityId ?? created.id.split('messageid=')[1] ?? created.id; + return { + id: created.id, + activityId: created.activityId ?? messageId, + messageId, + messageType: 'message', + webUrl: `https://teams.microsoft.com/l/message/${encodeURIComponent( + channelId, + )}/${messageId}?groupId=${teamId}&tenantId=${tenantId}&createdTime=${messageId}&parentMessageId=${messageId}`, + teamId, + channelId, + tenantId, + }; +}; + +const createChannelConversation = async ({ + serviceUrl, + botToken, + botAppId, + tenantId, + channelId, + content, + contentType, +}: CreateChannelConversationParams): Promise => { + const baseUrl = serviceUrl.endsWith('/') ? serviceUrl : `${serviceUrl}/`; + const response = await httpClient.sendRequest({ + method: HttpMethod.POST, + url: `${baseUrl}v3/conversations`, + headers: { Authorization: `Bearer ${botToken}` }, + body: { + isGroup: true, + bot: { id: `28:${botAppId}`, name: 'Activepieces' }, + channelData: { + channel: { id: channelId }, + tenant: { id: tenantId }, + }, + activity: { + type: 'message', + text: content, + textFormat: contentType === 'html' ? 'xml' : 'plain', + }, + }, + }); + + return response.body; +}; + +function describeConversationError(error: Error): string { + const status = error instanceof HttpError ? error.response.status : undefined; + if (status === 401) { + return 'Microsoft rejected the bot token. Check that the Microsoft Teams channel is enabled on the Azure Bot resource.'; + } + if (status === 403 || status === 404) { + return "Activepieces Bot is not installed in this team. Upload the bot's Teams app package and add it to this team, then try again."; + } + return describeFailure({ error, fallback: 'Failed to send the Teams channel message' }); +} + +function describeFailure({ error, fallback }: { error: Error; fallback: string }): string { + if (error instanceof HttpError) { + return `${fallback} (HTTP ${error.response.status}): ${JSON.stringify(error.response.body)}`; + } + return `${fallback}: ${error.message}`; +} + +export const botConnector = { sendChannelMessage }; + +type SendChannelMessageParams = { + appId: string; + appSecret: string; + tenantId: string; + teamId: string; + channelId: string; + content: string; + contentType: string; +}; + +type CreateChannelConversationParams = { + serviceUrl: string; + botToken: string; + botAppId: string; + tenantId: string; + channelId: string; + content: string; + contentType: string; +}; + +type CreateConversationResponse = { + id: string; + activityId?: string; +}; + +type SendChannelMessageResult = { + id: string; + activityId: string; + messageId: string; + messageType: string; + webUrl: string; + teamId: string; + channelId: string; + tenantId: string; +}; diff --git a/packages/pieces/community/microsoft-teams-bot/src/lib/common/graph.ts b/packages/pieces/community/microsoft-teams-bot/src/lib/common/graph.ts index b6d6cbc5de36..b580b49bdee8 100644 --- a/packages/pieces/community/microsoft-teams-bot/src/lib/common/graph.ts +++ b/packages/pieces/community/microsoft-teams-bot/src/lib/common/graph.ts @@ -1,7 +1,7 @@ import { Client, PageCollection } from '@microsoft/microsoft-graph-client'; import { httpClient, HttpMethod } from '@activepieces/pieces-common'; import { tryCatch } from '@activepieces/pieces-framework'; -import { getGraphBaseUrl } from './microsoft-cloud'; +import { microsoftCloud } from './microsoft-cloud'; // Single-tenant app-only token. Same app registration mints a Graph token // (list teams/channels) and a Bot Connector token (send) via different scopes. @@ -39,7 +39,7 @@ export const createGraphClient = (accessToken: string, cloud?: string | null): C authProvider: { getAccessToken: () => Promise.resolve(accessToken), }, - baseUrl: getGraphBaseUrl(cloud), + baseUrl: microsoftCloud.getGraphBaseUrl(cloud), }); }; diff --git a/packages/pieces/community/microsoft-teams-bot/src/lib/common/microsoft-cloud.ts b/packages/pieces/community/microsoft-teams-bot/src/lib/common/microsoft-cloud.ts index 32f307fce971..e6cc36a6236e 100644 --- a/packages/pieces/community/microsoft-teams-bot/src/lib/common/microsoft-cloud.ts +++ b/packages/pieces/community/microsoft-teams-bot/src/lib/common/microsoft-cloud.ts @@ -6,8 +6,20 @@ const GRAPH_BASE_URLS: Record = { [GOV_LOGIN_HOST]: 'https://graph.microsoft.us', }; +const BOT_SERVICE_URLS: Record = { + [COMMERCIAL_LOGIN_HOST]: 'https://smba.trafficmanager.net/teams/', + [GOV_LOGIN_HOST]: 'https://smba.infra.gov.teams.microsoft.us/teams/', +}; + // ponytail: single global-commercial default; add a cloud auth field if GCC High is needed. -export function getGraphBaseUrl(cloudLoginHost?: string | null): string { +function getGraphBaseUrl(cloudLoginHost?: string | null): string { const host = cloudLoginHost ?? COMMERCIAL_LOGIN_HOST; return GRAPH_BASE_URLS[host] ?? GRAPH_BASE_URLS[COMMERCIAL_LOGIN_HOST]; } + +function getBotServiceUrl(cloudLoginHost?: string | null): string { + const host = cloudLoginHost ?? COMMERCIAL_LOGIN_HOST; + return BOT_SERVICE_URLS[host] ?? BOT_SERVICE_URLS[COMMERCIAL_LOGIN_HOST]; +} + +export const microsoftCloud = { getGraphBaseUrl, getBotServiceUrl }; diff --git a/packages/pieces/community/microsoft-teams-bot/teams-app/README.md b/packages/pieces/community/microsoft-teams-bot/teams-app/README.md index 984229aaa703..4618bc01ae06 100644 --- a/packages/pieces/community/microsoft-teams-bot/teams-app/README.md +++ b/packages/pieces/community/microsoft-teams-bot/teams-app/README.md @@ -1,4 +1,4 @@ -# Microsoft Teams Bot — Setup & Packaging +# Microsoft Teams Bot: Setup & Packaging This piece sends messages to a Teams channel **as a bot** (not as a signed-in user). That requires you to register your own Azure Bot, package a Teams app, and install it into your team. @@ -7,7 +7,7 @@ This piece sends messages to a Teams channel **as a bot** (not as a signed-in us ## Prerequisites - An **Azure subscription** in the *same tenant* as your Teams (the **Free / F0** bot tier is $0). -- **Admin rights** in that tenant — you'll need to grant admin consent for the Graph permissions. +- **Admin rights** in that tenant, since you'll need to grant admin consent for the Graph permissions. --- @@ -30,14 +30,14 @@ Bot resource → **Channels** → add **Microsoft Teams** → accept the terms Bot resource → **Configuration** → **Messaging endpoint**: ``` -https:///api/v1/teams-bot/webhook +https:///api/v1/app-events/microsoft-teams-bot ``` -e.g. `https://cloud.activepieces.com/api/v1/teams-bot/webhook`, or your tunnel URL when developing locally. This is how the bot reports where it's installed so Activepieces can post to it. +e.g. `https://cloud.activepieces.com/api/v1/app-events/microsoft-teams-bot`, or your tunnel URL when developing locally. Azure requires a messaging endpoint to be set; Activepieces acknowledges the activities Teams delivers here and does not need them to send messages. ## 4. Create a client secret -App Registration (link from the bot's Configuration page) → **Certificates & secrets** → **New client secret** → copy the **Value** (not the Secret ID — the Value is shown only once). +App Registration (link from the bot's Configuration page) → **Certificates & secrets** → **New client secret** → copy the **Value** (not the Secret ID; the Value is shown only once). ## 5. Grant Microsoft Graph permissions @@ -54,9 +54,9 @@ Then click **Grant admin consent for <your org>**. Both must show a green This folder contains everything you need: -- `manifest.json` — the Teams app manifest -- `color.png` — 192×192 full-color icon -- `outline.png` — 32×32 transparent outline icon +- `manifest.json`: the Teams app manifest +- `color.png`: 192×192 full-color icon +- `outline.png`: 32×32 transparent outline icon In `manifest.json`, replace the **``** placeholder (it appears in both `id` and `bots[0].botId`) with your **Microsoft App ID** from step 1. Then zip the three files (zip the files directly, not the folder): @@ -70,7 +70,7 @@ zip activepieces-teams-bot.zip manifest.json color.png outline.png - **Sideload (dev):** Teams → **Apps** → **Manage your apps** → **Upload an app** → **Upload a custom app** → pick the zip → add it to the target **team/channel**. - **Org-wide / production:** Teams Admin Center → **Teams apps** → **Manage apps** → **Upload new app**, or submit to the Teams Store via Partner Center. -On install, the bot receives an `installationUpdate` event and Activepieces stores where to reach it. **The bot must be installed in a team before you can post to its channels.** +**The bot must be installed in a team before you can post to its channels.** Sending to a team the bot is not a member of fails with `403 The bot is not part of the conversation roster`. ## 8. Connect in Activepieces @@ -91,5 +91,5 @@ Create a connection on the **Microsoft Teams Bot** piece with: | Connection fails / dropdowns empty | Graph **application** permissions not added or admin consent not granted (step 5). Delegated permissions do **not** work here. | | Send fails: `Authorization has been denied for this request` | The **Tenant ID** (or the bot) is a *different* tenant than your Teams. Bot and Teams must be the same tenant. | | Install fails: `BulkMembershipRequest` | The **Microsoft Teams channel** isn't enabled on the bot (step 2). | -| Send fails: `Incorrect conversation creation parameters` | The bot isn't installed in that team, or the channel id is wrong — reinstall the app (step 7). | +| Send fails: `Incorrect conversation creation parameters` | The bot isn't installed in that team, or the channel id is wrong; reinstall the app (step 7). | | `Activepieces Bot is not installed in this team` | Install the app into the team (step 7) so the bot's endpoint gets registered. | diff --git a/packages/server/api/package.json b/packages/server/api/package.json index d033eb398ce9..547c00b251e0 100644 --- a/packages/server/api/package.json +++ b/packages/server/api/package.json @@ -113,6 +113,7 @@ "devDependencies": { "@activepieces/piece-facebook-leads": "workspace:*", "@activepieces/piece-intercom": "workspace:*", + "@activepieces/piece-microsoft-teams-bot": "workspace:*", "@activepieces/piece-slack": "workspace:*", "@activepieces/piece-square": "workspace:*", "@faker-js/faker": "8.2.0", diff --git a/packages/server/api/src/app/app.ts b/packages/server/api/src/app/app.ts index 8df942952172..f5523c7a7cd0 100644 --- a/packages/server/api/src/app/app.ts +++ b/packages/server/api/src/app/app.ts @@ -102,7 +102,6 @@ import { platformModule } from './platform/platform.module' import { projectHooks } from './project/project-hooks' import { storeEntryModule } from './store-entry/store-entry.module' import { tablesModule } from './tables/tables.module' -import { teamsBotModule } from './teams-bot/teams-bot.module' import { templateModule } from './template/template.module' import { toolSearchReindexJob } from './tool-search/tool-search-reindex.job' import { appEventRoutingModule } from './trigger/app-event-routing/app-event-routing.module' @@ -216,7 +215,6 @@ export const setupApp = async (app: FastifyInstance): Promise = await app.register(fileModule) await app.register(flagModule) await app.register(storeEntryModule) - await app.register(teamsBotModule) await app.register(folderModule) await pieceSyncService(app.log).setup() toolSearchReindexJob(app.log).register() diff --git a/packages/server/api/src/app/database/database-connection.ts b/packages/server/api/src/app/database/database-connection.ts index 661a6e349d40..d50be4cfa4f8 100644 --- a/packages/server/api/src/app/database/database-connection.ts +++ b/packages/server/api/src/app/database/database-connection.ts @@ -57,7 +57,6 @@ import { CellEntity } from '../tables/record/cell.entity' import { RecordEntity } from '../tables/record/record.entity' import { TableWebhookEntity } from '../tables/table/table-webhook.entity' import { TableEntity } from '../tables/table/table.entity' -import { TeamsBotInstallationEntity } from '../teams-bot/teams-bot-installation.entity' import { TemplateEntity } from '../template/template.entity' import { ToolSearchIndexEntity } from '../tool-search/tool-search-index.entity' import { AppEventRoutingEntity } from '../trigger/app-event-routing/app-event-routing.entity' @@ -115,7 +114,6 @@ function getEntities(): EntitySchema[] { UserMemoryEntity, TriggerSourceEntity, WaitpointEntity, - TeamsBotInstallationEntity, // Enterprise PieceSetEntity, ConcurrencyPoolEntity, diff --git a/packages/server/api/src/app/database/migration/postgres/1835000000000-WidenMcpOAuthState.ts b/packages/server/api/src/app/database/migration/postgres/1835000000000-WidenMcpOAuthState.ts new file mode 100644 index 000000000000..15ab1935f0fa --- /dev/null +++ b/packages/server/api/src/app/database/migration/postgres/1835000000000-WidenMcpOAuthState.ts @@ -0,0 +1,17 @@ +import { QueryRunner } from 'typeorm' +import { Migration } from '../../migration' + +export class WidenMcpOAuthState1835000000000 implements Migration { + name = 'WidenMcpOAuthState1835000000000' + release = '0.88.3' + breaking = true + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "mcp_oauth_authorization_code" ALTER COLUMN "state" TYPE character varying(2048)') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('UPDATE "mcp_oauth_authorization_code" SET "state" = NULL WHERE length("state") > 512') + await queryRunner.query('ALTER TABLE "mcp_oauth_authorization_code" ALTER COLUMN "state" TYPE character varying(512)') + } +} diff --git a/packages/server/api/src/app/database/migration/postgres/1836000000000-DropTeamsBotInstallation.ts b/packages/server/api/src/app/database/migration/postgres/1836000000000-DropTeamsBotInstallation.ts new file mode 100644 index 000000000000..1eef4c18713f --- /dev/null +++ b/packages/server/api/src/app/database/migration/postgres/1836000000000-DropTeamsBotInstallation.ts @@ -0,0 +1,31 @@ +import { QueryRunner } from 'typeorm' +import { Migration } from '../../migration' + +export class DropTeamsBotInstallation1836000000000 implements Migration { + name = 'DropTeamsBotInstallation1836000000000' + breaking = true + release = '0.88.4' + transaction = true + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP TABLE IF EXISTS "teams_bot_installation" CASCADE + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "teams_bot_installation" ( + "id" character varying(21) NOT NULL, + "created" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "appId" character varying(255) NOT NULL, + "tenantId" character varying(255) NOT NULL, + "teamsTeamId" character varying(255) NOT NULL, + "serviceUrl" character varying(512) NOT NULL, + CONSTRAINT "UQ_9d4e7cb17346c4840309540b56d" UNIQUE ("appId", "tenantId", "teamsTeamId"), + CONSTRAINT "PK_teams_bot_installation" PRIMARY KEY ("id") + ) + `) + } +} diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index 27f07c93fd3b..b5005e91d13c 100644 --- a/packages/server/api/src/app/database/postgres-connection.ts +++ b/packages/server/api/src/app/database/postgres-connection.ts @@ -426,6 +426,8 @@ import { AddChatPersonalization1831000000000 } from './migration/postgres/183100 import { BackfillChatPersonalizationForExistingUsers1832000000000 } from './migration/postgres/1832000000000-BackfillChatPersonalizationForExistingUsers' import { ClearRoleFromCompanyPersonalization1833000000000 } from './migration/postgres/1833000000000-ClearRoleFromCompanyPersonalization' import { AddAutoCreatePersonalProjectsToPlatform1834000000000 } from './migration/postgres/1834000000000-AddAutoCreatePersonalProjectsToPlatform' +import { WidenMcpOAuthState1835000000000 } from './migration/postgres/1835000000000-WidenMcpOAuthState' +import { DropTeamsBotInstallation1836000000000 } from './migration/postgres/1836000000000-DropTeamsBotInstallation' const getSslConfig = (): boolean | TlsOptions => { const useSsl = system.get(AppSystemProp.POSTGRES_USE_SSL) @@ -867,6 +869,8 @@ export const getMigrations = (): (new () => Migration)[] => { BackfillChatPersonalizationForExistingUsers1832000000000, ClearRoleFromCompanyPersonalization1833000000000, AddAutoCreatePersonalProjectsToPlatform1834000000000, + WidenMcpOAuthState1835000000000, + DropTeamsBotInstallation1836000000000, ] return migrations } diff --git a/packages/server/api/src/app/mcp/mcp-server-builder.ts b/packages/server/api/src/app/mcp/mcp-server-builder.ts index 7a294a75ad3c..8b9bdea4073e 100644 --- a/packages/server/api/src/app/mcp/mcp-server-builder.ts +++ b/packages/server/api/src/app/mcp/mcp-server-builder.ts @@ -139,7 +139,7 @@ function registerFlowTools({ server, mcp, projectId, permissionChecker, log }: R const toolName = mcpToolNameUtils.createToolName(baseName) const flowPermissionError = permissionChecker.check(Permission.WRITE_RUN, toolName) - server.registerTool(toolName, { title: toolName, description: toolDescription, inputSchema: zodFromInputSchema }, async (args: Record) => { + server.registerTool(toolName, { title: toolName, description: toolDescription, inputSchema: zodFromInputSchema, annotations: FLOW_TOOL_ANNOTATIONS }, async (args: Record) => { if (flowPermissionError) { return flowPermissionError } @@ -211,11 +211,13 @@ function registerStaticTools({ server, mcp, projectId, userId, permissionChecker } function registerPlaceholderTools(server: McpServer): void { + const lockedToolSet = new Set(LOCKED_TOOL_NAMES) const allToolNames = [...LOCKED_TOOL_NAMES, ...ALL_CONTROLLABLE_TOOL_NAMES] allToolNames.forEach((toolName) => { server.registerTool(toolName, { title: toolName, description: `${toolName} — requires a project to be selected first.`, + annotations: lockedToolSet.has(toolName) ? LOCKED_PLACEHOLDER_ANNOTATIONS : CONTROLLABLE_PLACEHOLDER_ANNOTATIONS, }, async () => ({ content: [{ type: 'text' as const, text: `No project selected. Please select a project from the dropdown in the chat input area before using ${toolName}.` }], })) @@ -265,6 +267,10 @@ function buildToolConfig(tool: McpToolDefinition): Record { } } +const FLOW_TOOL_ANNOTATIONS = { readOnlyHint: false, destructiveHint: false, openWorldHint: true } +const LOCKED_PLACEHOLDER_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, openWorldHint: false } +const CONTROLLABLE_PLACEHOLDER_ANNOTATIONS = { readOnlyHint: false, destructiveHint: true, openWorldHint: true } + type RegisterToolsParams = { server: McpServer mcp: PopulatedMcpServer diff --git a/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-authorize.controller.ts b/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-authorize.controller.ts index 32c7b9d492d1..9cc86252382a 100644 --- a/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-authorize.controller.ts +++ b/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-authorize.controller.ts @@ -66,7 +66,7 @@ const AuthorizeRequest = { response_type: z.string().max(64), code_challenge: mcpOAuthValidation.storableText(256).refine((value) => value.length >= 43, { message: 'code_challenge is too short' }), code_challenge_method: z.string().max(8).default('S256'), - state: mcpOAuthValidation.storableText(512).optional(), + state: mcpOAuthValidation.storableText(2048).optional(), scope: mcpOAuthValidation.storableText(512).optional(), resource: mcpOAuthValidation.storableText(2048).optional(), }), diff --git a/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-code.entity.ts b/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-code.entity.ts index 9fda85edde64..8022a22903f0 100644 --- a/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-code.entity.ts +++ b/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-code.entity.ts @@ -45,7 +45,7 @@ export const McpOAuthAuthorizationCodeEntity = new EntitySchema { try { const { flowId, routerStepName, branchName, conditions } = addBranchInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-change-flow-status.ts b/packages/server/api/src/app/mcp/tools/ap-change-flow-status.ts index 3fa13194ffda..0c8d32f4689c 100644 --- a/packages/server/api/src/app/mcp/tools/ap-change-flow-status.ts +++ b/packages/server/api/src/app/mcp/tools/ap-change-flow-status.ts @@ -20,7 +20,7 @@ export const apChangeFlowStatusTool = ({ mcp, userId }: McpToolContext, log: Fas flowId: z.string().describe('The id of the flow'), status: z.enum([FlowStatus.ENABLED, FlowStatus.DISABLED]).describe('The new status: ENABLED to activate the flow, DISABLED to pause it'), }, - annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, execute: async (args) => { const { flowId, status } = changeFlowStatusInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-create-table.ts b/packages/server/api/src/app/mcp/tools/ap-create-table.ts index e2d9a881b123..19d406be7b07 100644 --- a/packages/server/api/src/app/mcp/tools/ap-create-table.ts +++ b/packages/server/api/src/app/mcp/tools/ap-create-table.ts @@ -22,7 +22,7 @@ export const apCreateTableTool = (mcp: ProjectScopedMcpServer, log: FastifyBaseL permission: Permission.WRITE_TABLE, description: 'Create a new table with an initial set of fields. Types: TEXT, NUMBER, DATE, DATETIME, STATIC_DROPDOWN. DATE and DATETIME both hold an ISO-8601 UTC timestamp; DATETIME additionally shows and edits the time of day.', inputSchema: createTableInput.shape, - annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: false }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, execute: async (args) => { try { const { name, fields } = createTableInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-delete-branch.ts b/packages/server/api/src/app/mcp/tools/ap-delete-branch.ts index fd578a3bd783..c23636266c75 100644 --- a/packages/server/api/src/app/mcp/tools/ap-delete-branch.ts +++ b/packages/server/api/src/app/mcp/tools/ap-delete-branch.ts @@ -23,7 +23,7 @@ export const apDeleteBranchTool = ({ mcp, userId }: McpToolContext, log: Fastify branchIndex: z.number().describe('The index of the branch to delete (0-based). Cannot delete the fallback/last branch.'), displayName: z.string().optional().describe('Short approval prompt shown to the user (e.g. "Delete branch 2 from router"). Must include what the action does and the target name.'), }, - annotations: { destructiveHint: true, openWorldHint: false }, + annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false }, execute: async (args) => { const { flowId, routerStepName, branchIndex } = deleteBranchInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-delete-records.ts b/packages/server/api/src/app/mcp/tools/ap-delete-records.ts index ec6420c6c4ac..04c835ac247c 100644 --- a/packages/server/api/src/app/mcp/tools/ap-delete-records.ts +++ b/packages/server/api/src/app/mcp/tools/ap-delete-records.ts @@ -17,7 +17,7 @@ export const apDeleteRecordsTool = (mcp: ProjectScopedMcpServer, log: FastifyBas permission: Permission.WRITE_TABLE, description: 'Permanently delete one or more records by their IDs.', inputSchema: deleteRecordsInput.shape, - annotations: { destructiveHint: true, openWorldHint: false }, + annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false }, execute: async (args) => { try { const { tableId, recordIds } = deleteRecordsInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-delete-step.ts b/packages/server/api/src/app/mcp/tools/ap-delete-step.ts index 6e647688b970..786c41b0a09d 100644 --- a/packages/server/api/src/app/mcp/tools/ap-delete-step.ts +++ b/packages/server/api/src/app/mcp/tools/ap-delete-step.ts @@ -21,7 +21,7 @@ export const apDeleteStepTool = ({ mcp, userId }: McpToolContext, log: FastifyBa stepName: z.string().describe('The name of the step to delete. Use ap_flow_structure to get valid values.'), displayName: z.string().optional().describe('Short approval prompt shown to the user (e.g. "Delete Send Email step"). Must include what the action does and the target name.'), }, - annotations: { destructiveHint: true, openWorldHint: false }, + annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false }, execute: async (args) => { const { flowId, stepName } = deleteStepInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-delete-table.ts b/packages/server/api/src/app/mcp/tools/ap-delete-table.ts index 2765377e4615..45c6432c55f9 100644 --- a/packages/server/api/src/app/mcp/tools/ap-delete-table.ts +++ b/packages/server/api/src/app/mcp/tools/ap-delete-table.ts @@ -16,7 +16,7 @@ export const apDeleteTableTool = (mcp: ProjectScopedMcpServer, log: FastifyBaseL permission: Permission.WRITE_TABLE, description: 'Permanently delete a table and all its data.', inputSchema: deleteTableInput.shape, - annotations: { destructiveHint: true, openWorldHint: false }, + annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false }, execute: async (args) => { try { const { tableId } = deleteTableInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-duplicate-flow.ts b/packages/server/api/src/app/mcp/tools/ap-duplicate-flow.ts index 154eb0dfb769..b430c53cfc93 100644 --- a/packages/server/api/src/app/mcp/tools/ap-duplicate-flow.ts +++ b/packages/server/api/src/app/mcp/tools/ap-duplicate-flow.ts @@ -20,7 +20,7 @@ export const apDuplicateFlowTool = ({ mcp, userId }: McpToolContext, log: Fastif flowId: z.string().describe('The id of the flow to duplicate. Use ap_list_flows to find it.'), name: z.string().optional().describe('Name for the duplicated flow. Defaults to "Copy of {original name}".'), }, - annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: false }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, execute: async (args) => { try { const { flowId, name } = duplicateFlowInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-insert-records.ts b/packages/server/api/src/app/mcp/tools/ap-insert-records.ts index 42f6c0641097..68a6ffb23596 100644 --- a/packages/server/api/src/app/mcp/tools/ap-insert-records.ts +++ b/packages/server/api/src/app/mcp/tools/ap-insert-records.ts @@ -17,7 +17,7 @@ export const apInsertRecordsTool = (mcp: ProjectScopedMcpServer, log: FastifyBas permission: Permission.WRITE_TABLE, description: 'Insert one or more records into a table. Max 50 records per call.', inputSchema: insertRecordsInput.shape, - annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: false }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, execute: async (args) => { try { const { tableId, records } = insertRecordsInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-list-ai-models.ts b/packages/server/api/src/app/mcp/tools/ap-list-ai-models.ts index 53ea09f74700..9ec16e2dd3ea 100644 --- a/packages/server/api/src/app/mcp/tools/ap-list-ai-models.ts +++ b/packages/server/api/src/app/mcp/tools/ap-list-ai-models.ts @@ -16,7 +16,7 @@ export const apListAiModelsTool = (mcp: ProjectScopedMcpServer, log: FastifyBase title: 'ap_list_ai_models', description: 'List configured AI providers and their available models. Use this to discover valid provider and model values for configuring Run Agent steps. The output shows provider names and model IDs needed for the aiProviderModel input.', inputSchema: listAiModelsInput.shape, - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, execute: async (args) => { try { const { provider: filterProvider } = listAiModelsInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-lock-and-publish.ts b/packages/server/api/src/app/mcp/tools/ap-lock-and-publish.ts index 2f5854e58abd..ffe129dc9e9c 100644 --- a/packages/server/api/src/app/mcp/tools/ap-lock-and-publish.ts +++ b/packages/server/api/src/app/mcp/tools/ap-lock-and-publish.ts @@ -18,7 +18,7 @@ export const apLockAndPublishTool = ({ mcp, userId }: McpToolContext, log: Fasti inputSchema: { flowId: z.string().describe('The id of the flow to publish'), }, - annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: false }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, execute: async (args) => { const { flowId } = lockAndPublishInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-manage-fields.ts b/packages/server/api/src/app/mcp/tools/ap-manage-fields.ts index ac47bfbe60a6..c2622438f259 100644 --- a/packages/server/api/src/app/mcp/tools/ap-manage-fields.ts +++ b/packages/server/api/src/app/mcp/tools/ap-manage-fields.ts @@ -21,7 +21,7 @@ export const apManageFieldsTool = (mcp: ProjectScopedMcpServer, log: FastifyBase permission: Permission.WRITE_TABLE, description: 'Add, rename, or delete fields on a table. Max 100 fields per table.', inputSchema: manageFieldsInput.shape, - annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: false }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, execute: async (args) => { try { const { tableId, operation, fieldId, name, type, options } = manageFieldsInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-manage-notes.ts b/packages/server/api/src/app/mcp/tools/ap-manage-notes.ts index 4688e0c9a39b..bb90471cc5f6 100644 --- a/packages/server/api/src/app/mcp/tools/ap-manage-notes.ts +++ b/packages/server/api/src/app/mcp/tools/ap-manage-notes.ts @@ -44,7 +44,7 @@ export const apManageNotesTool = ({ mcp, userId }: McpToolContext, log: FastifyB }, // destructiveHint is false because ADD and UPDATE are the common paths; // DELETE is possible but clients shouldn't over-restrict the whole tool. - annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: false }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, execute: async (args) => { const { flowId, operation: op, noteId, content, color, position, size } = manageNotesInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-rename-flow.ts b/packages/server/api/src/app/mcp/tools/ap-rename-flow.ts index eb9654b91076..447bd15f8d5e 100644 --- a/packages/server/api/src/app/mcp/tools/ap-rename-flow.ts +++ b/packages/server/api/src/app/mcp/tools/ap-rename-flow.ts @@ -20,7 +20,7 @@ export const apRenameFlowTool = ({ mcp, userId }: McpToolContext, log: FastifyBa flowId: z.string().describe('The id of the flow to rename'), displayName: z.string().describe('The new display name for the flow'), }, - annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, execute: async (args) => { const { flowId, displayName } = renameFlowInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-retry-run.ts b/packages/server/api/src/app/mcp/tools/ap-retry-run.ts index df96cc4a605e..22284aa7ddea 100644 --- a/packages/server/api/src/app/mcp/tools/ap-retry-run.ts +++ b/packages/server/api/src/app/mcp/tools/ap-retry-run.ts @@ -20,7 +20,7 @@ export const apRetryRunTool = (mcp: ProjectScopedMcpServer, log: FastifyBaseLogg permission: Permission.WRITE_RUN, description: 'Retry a failed flow run. FROM_FAILED_STEP resumes at failure point, ON_LATEST_VERSION re-runs entirely.', inputSchema: retryRunInput.shape, - annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: false }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, execute: async (args) => { try { const { flowRunId, strategy } = retryRunInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-run-action.ts b/packages/server/api/src/app/mcp/tools/ap-run-action.ts index f51629a91c4f..2bc9d60ef346 100644 --- a/packages/server/api/src/app/mcp/tools/ap-run-action.ts +++ b/packages/server/api/src/app/mcp/tools/ap-run-action.ts @@ -11,7 +11,7 @@ export const apRunActionTool = (mcp: ProjectScopedMcpServer, log: FastifyBaseLog permission: Permission.WRITE_RUN, description: 'Execute a single piece action once, without building or saving a flow. Use this for one-shot tasks like "check my inbox" or "send one Slack message". For recurring/triggered work, build a flow with ap_build_flow instead.', inputSchema: runActionInput.shape, - annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, execute: async (args) => { try { const { pieceName, actionName, input, connectionExternalId } = runActionInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-search-actions.ts b/packages/server/api/src/app/mcp/tools/ap-search-actions.ts index bc16c503e927..2a22b460889b 100644 --- a/packages/server/api/src/app/mcp/tools/ap-search-actions.ts +++ b/packages/server/api/src/app/mcp/tools/ap-search-actions.ts @@ -9,7 +9,7 @@ export const apSearchActionsTool = (mcp: ProjectScopedMcpServer, log: FastifyBas title: 'ap_search_actions', description: 'Find piece actions by natural-language task description (e.g. "send a message to a Slack channel"). Returns the most semantically relevant actions ranked by similarity — lightweight rows only — or an empty list when nothing in the catalog is relevant (it does not force a match). Always available: when no embedding model is configured it falls back to a keyword catalog search (response "mode":"keyword", lexical not semantic). Each row carries a `connected` flag indicating whether this project already has a connection for the piece. Optionally scope to a single piece with `pieceName`. This is the discovery step: take a result\'s pieceName + actionName to ap_get_piece_props for its input schema, then ap_run_action to execute it.', inputSchema: searchActionsInput.shape, - annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, execute: async (args) => { try { const { query, limit, pieceName } = searchActionsInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-search-triggers.ts b/packages/server/api/src/app/mcp/tools/ap-search-triggers.ts index 236f5ab71d6e..62bff6f8618c 100644 --- a/packages/server/api/src/app/mcp/tools/ap-search-triggers.ts +++ b/packages/server/api/src/app/mcp/tools/ap-search-triggers.ts @@ -9,7 +9,7 @@ export const apSearchTriggersTool = (mcp: ProjectScopedMcpServer, log: FastifyBa title: 'ap_search_triggers', description: 'Find piece triggers (the event that starts a flow) by natural-language description of when the flow should run (e.g. "when a new row is added to a Google Sheet", "when an email arrives"). Returns the most semantically relevant triggers ranked by similarity — lightweight rows only — or an empty list when nothing in the catalog is relevant (it does not force a match). Always available: when no embedding model is configured it falls back to a keyword catalog search (response "mode":"keyword", lexical not semantic). Each row carries a `connected` flag indicating whether this project already has a connection for the piece. Optionally scope to a single piece with `pieceName`. This is the discovery step: take a result\'s pieceName + triggerName to ap_get_piece_props for its input schema.', inputSchema: searchTriggersInput.shape, - annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, execute: async (args) => { try { const { query, limit, pieceName } = searchTriggersInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-set-project-context.ts b/packages/server/api/src/app/mcp/tools/ap-set-project-context.ts index 9ccbf3dcbf58..319337f27e80 100644 --- a/packages/server/api/src/app/mcp/tools/ap-set-project-context.ts +++ b/packages/server/api/src/app/mcp/tools/ap-set-project-context.ts @@ -19,6 +19,8 @@ export const apSetProjectContextTool = ({ platformId, userId, selectionScope, lo }, annotations: { readOnlyHint: false, + destructiveHint: false, + openWorldHint: false, idempotentHint: true, }, execute: async (args: Record) => { diff --git a/packages/server/api/src/app/mcp/tools/ap-setup-guide.ts b/packages/server/api/src/app/mcp/tools/ap-setup-guide.ts index 5014e7c6b126..833f46585f15 100644 --- a/packages/server/api/src/app/mcp/tools/ap-setup-guide.ts +++ b/packages/server/api/src/app/mcp/tools/ap-setup-guide.ts @@ -18,7 +18,7 @@ export const apSetupGuideTool = (mcp: ProjectScopedMcpServer, log: FastifyBaseLo title: 'ap_setup_guide', description: 'Get setup instructions for connections or AI providers. Returns steps for the user to follow in the UI.', inputSchema: setupGuideInput.shape, - annotations: { readOnlyHint: true, openWorldHint: false }, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, execute: async (args) => { try { const { topic, pieceName } = setupGuideInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-test-flow.ts b/packages/server/api/src/app/mcp/tools/ap-test-flow.ts index 8e00a5b7f4b9..34a0d359fcef 100644 --- a/packages/server/api/src/app/mcp/tools/ap-test-flow.ts +++ b/packages/server/api/src/app/mcp/tools/ap-test-flow.ts @@ -17,7 +17,7 @@ export const apTestFlowTool = ({ mcp, userId }: McpToolContext, log: FastifyBase permission: Permission.WRITE_FLOW, description: 'Test a flow end-to-end in the test environment. Requires a configured trigger. Waits up to 120s. Pass triggerTestData to provide mock trigger output when no sample data exists.', inputSchema: testFlowInput.shape, - annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: true }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, execute: async (args) => { try { const { flowId, triggerTestData } = testFlowInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-test-step.ts b/packages/server/api/src/app/mcp/tools/ap-test-step.ts index e7149d28d0b3..9b8a607379a6 100644 --- a/packages/server/api/src/app/mcp/tools/ap-test-step.ts +++ b/packages/server/api/src/app/mcp/tools/ap-test-step.ts @@ -18,7 +18,7 @@ export const apTestStepTool = ({ mcp, userId }: McpToolContext, log: FastifyBase permission: Permission.WRITE_FLOW, description: 'Test a single step within a flow. Runs all steps up to and including the specified step. The flow must have a configured trigger. Pass triggerTestData when no sample data exists.', inputSchema: testStepInput.shape, - annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: true }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, execute: async (args) => { try { const { flowId, stepName, triggerTestData } = testStepInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-update-branch.ts b/packages/server/api/src/app/mcp/tools/ap-update-branch.ts index d18a040a341a..31a8523b5dc9 100644 --- a/packages/server/api/src/app/mcp/tools/ap-update-branch.ts +++ b/packages/server/api/src/app/mcp/tools/ap-update-branch.ts @@ -26,7 +26,7 @@ export const apUpdateBranchTool = ({ mcp, userId }: McpToolContext, log: Fastify branchName: z.string().optional().describe('New display name for the branch'), conditions: mcpUtils.BRANCH_CONDITIONS_INPUT_SCHEMA.optional().describe('New conditions array (outer array = OR groups, inner array = AND conditions). Replaces the existing conditions entirely.'), }, - annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, execute: async (args) => { try { const { flowId, routerStepName, branchIndex, branchName, conditions } = updateBranchInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-update-record.ts b/packages/server/api/src/app/mcp/tools/ap-update-record.ts index 2ae752351f6b..94af2bee2499 100644 --- a/packages/server/api/src/app/mcp/tools/ap-update-record.ts +++ b/packages/server/api/src/app/mcp/tools/ap-update-record.ts @@ -18,7 +18,7 @@ export const apUpdateRecordTool = (mcp: ProjectScopedMcpServer, log: FastifyBase permission: Permission.WRITE_TABLE, description: 'Update specific cells in a record. Only specified fields are changed.', inputSchema: updateRecordInput.shape, - annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, execute: async (args) => { try { const { tableId, recordId, fields: fieldValues } = updateRecordInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-update-step.ts b/packages/server/api/src/app/mcp/tools/ap-update-step.ts index 3601bf1537f7..0aa658b8f91c 100644 --- a/packages/server/api/src/app/mcp/tools/ap-update-step.ts +++ b/packages/server/api/src/app/mcp/tools/ap-update-step.ts @@ -41,7 +41,7 @@ export const apUpdateStepTool = ({ mcp, userId }: McpToolContext, log: FastifyBa continueOnFailure: z.boolean().optional().describe('For CODE/PIECE steps: set true on the step that can fail (the one whose failure you want to react to), NOT on the recovery step. The flow keeps running on failure and the step gains On success / On failure branches — add handler steps into them with ap_add_step using stepLocationRelativeToParent INSIDE_ON_SUCCESS_BRANCH / INSIDE_ON_FAILURE_BRANCH and parentStepName = this step.'), retryOnFailure: z.boolean().optional().describe('For CODE/PIECE steps: whether to retry this step on failure.'), }, - annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, execute: async (args) => { const { flowId, stepName, displayName, input, auth, actionName, loopItems, skip, sourceCode, packageJson, continueOnFailure, retryOnFailure } = updateStepInput.parse(args) diff --git a/packages/server/api/src/app/mcp/tools/ap-update-trigger.ts b/packages/server/api/src/app/mcp/tools/ap-update-trigger.ts index 456811f785d3..9150b0e82056 100644 --- a/packages/server/api/src/app/mcp/tools/ap-update-trigger.ts +++ b/packages/server/api/src/app/mcp/tools/ap-update-trigger.ts @@ -29,7 +29,7 @@ export const apUpdateTriggerTool = ({ mcp, userId }: McpToolContext, log: Fastif auth: z.string().optional().describe('Connection `externalId` from `ap_list_connections`. The tool wraps it automatically as `{{connections[\'externalId\']}}`.'), displayName: z.string().optional().describe('Display name for the trigger step'), }, - annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, execute: async (args) => { const { flowId, pieceName, triggerName, input: rawInput, auth, displayName: rawDisplayName } = updateTriggerInput.parse(args) diff --git a/packages/server/api/src/app/teams-bot/teams-bot-installation.entity.ts b/packages/server/api/src/app/teams-bot/teams-bot-installation.entity.ts deleted file mode 100644 index 649640197619..000000000000 --- a/packages/server/api/src/app/teams-bot/teams-bot-installation.entity.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { EntitySchema } from 'typeorm' -import { BaseColumnSchemaPart } from '../database/database-common' - -// ponytail: intentionally global — NOT scoped by projectId/platformId. -// A row is created by Microsoft's installationUpdate webhook, where no AP session -// exists; it is keyed by Azure identity (appId/tenantId/teamsTeamId) and holds only -// the Microsoft serviceUrl (routing state, not a secret). The capability to send is -// the appSecret, re-verified by Microsoft on every token mint — not any row here. -// The same Azure bot shared across projects SHOULD map to one installation row. -type TeamsBotInstallationSchema = { - id: string - appId: string - tenantId: string - teamsTeamId: string - serviceUrl: string - created: Date - updated: Date -} - -export const TeamsBotInstallationEntity = new EntitySchema({ - name: 'teams_bot_installation', - columns: { - ...BaseColumnSchemaPart, - appId: { - type: String, - length: 255, - nullable: false, - }, - tenantId: { - type: String, - length: 255, - nullable: false, - }, - teamsTeamId: { - type: String, - length: 255, - }, - serviceUrl: { - type: String, - length: 512, - }, - }, - uniques: [ - { - columns: ['appId', 'tenantId', 'teamsTeamId'], - }, - ], -}) diff --git a/packages/server/api/src/app/teams-bot/teams-bot-installation.repo.ts b/packages/server/api/src/app/teams-bot/teams-bot-installation.repo.ts deleted file mode 100644 index 889c671cc942..000000000000 --- a/packages/server/api/src/app/teams-bot/teams-bot-installation.repo.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { apId } from '@activepieces/shared' -import { repoFactory } from '../core/db/repo-factory' -import { TeamsBotInstallationEntity } from './teams-bot-installation.entity' - -const teamsBotInstallationRepo = repoFactory(TeamsBotInstallationEntity) - -export const teamsBotInstallationDb = { - async upsert({ appId, tenantId, teamsTeamId, serviceUrl }: { - appId: string - tenantId: string - teamsTeamId: string - serviceUrl: string - }): Promise { - await teamsBotInstallationRepo().upsert( - { id: apId(), appId, tenantId, teamsTeamId, serviceUrl }, - { conflictPaths: ['appId', 'tenantId', 'teamsTeamId'], skipUpdateIfNoValuesChanged: true }, - ) - }, - - async findOne({ appId, tenantId, teamsTeamId }: { - appId: string - tenantId: string - teamsTeamId: string - }) { - return teamsBotInstallationRepo().findOneBy({ appId, tenantId, teamsTeamId }) - }, - - async remove({ appId, tenantId, teamsTeamId }: { - appId: string - tenantId: string - teamsTeamId: string - }): Promise { - await teamsBotInstallationRepo().delete({ appId, tenantId, teamsTeamId }) - }, -} diff --git a/packages/server/api/src/app/teams-bot/teams-bot.controller.ts b/packages/server/api/src/app/teams-bot/teams-bot.controller.ts deleted file mode 100644 index ae4730915116..000000000000 --- a/packages/server/api/src/app/teams-bot/teams-bot.controller.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { FastifyBaseLogger } from 'fastify' -import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' -import { StatusCodes } from 'http-status-codes' -import JwksRsa from 'jwks-rsa' -import { z } from 'zod' -import { securityAccess } from '../core/security/authorization/fastify-security' -import { JwtSignAlgorithm, jwtUtils } from '../helper/jwt-utils' -import { teamsBotService } from './teams-bot.service' - -const BOT_FRAMEWORK_JWKS_URI = 'https://login.botframework.com/v1/.well-known/keys' -const BOT_FRAMEWORK_ISSUER = 'https://api.botframework.com' - - -const jwksClient = JwksRsa({ - jwksUri: BOT_FRAMEWORK_JWKS_URI, - cache: true, - cacheMaxEntries: 10, - rateLimit: true, - jwksRequestsPerMinute: 10, -}) - -async function verifyBotFrameworkJwt(token: string, expectedAppId: string, log: FastifyBaseLogger): Promise { - const decoded = token ? jwtUtils.decode({ jwt: token }) : null - const kid = decoded?.header.kid - if (!kid) { - return false - } - try { - const signingKey = await jwksClient.getSigningKey(kid) - const publicKey = signingKey.getPublicKey() - await jwtUtils.decodeAndVerify({ - jwt: token, - key: publicKey, - algorithm: JwtSignAlgorithm.RS256, - issuer: BOT_FRAMEWORK_ISSUER, - audience: expectedAppId, - }) - return true - } - catch (error) { - log.warn({ error }, 'Failed to verify Bot Framework JWT on teams-bot webhook') - return false - } -} - -export const teamsBotController: FastifyPluginAsyncZod = async (fastify) => { - fastify.post('/webhook', WebhookRequest, async (request, reply) => { - const authHeader = request.headers['authorization'] ?? '' - const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '' - const activity = request.body as TeamsActivity - - const recipientId = activity.recipient?.id ?? '' - const appId = recipientId.startsWith('28:') ? recipientId.slice(3) : undefined - - // Without a derivable appId the JWT audience check is skipped (jwt verifies - // aud only when an expected value is passed), so reject before verifying. - if (!appId) { - return reply.status(StatusCodes.UNAUTHORIZED).send() - } - - const isValid = await verifyBotFrameworkJwt(token, appId, request.log) - if (!isValid) { - return reply.status(StatusCodes.UNAUTHORIZED).send() - } - - if (activity.type === 'installationUpdate') { - const tenantId = activity.channelData?.tenant?.id - const teamsTeamId = activity.channelData?.team?.aadGroupId - if (tenantId && teamsTeamId) { - if (activity.action === 'add' && activity.serviceUrl) { - await teamsBotService.handleInstallation({ appId, tenantId, teamsTeamId, serviceUrl: activity.serviceUrl }) - } - else if (activity.action === 'remove') { - await teamsBotService.handleUninstallation({ appId, tenantId, teamsTeamId }) - } - } - } - - return reply.status(StatusCodes.OK).send() - }) - - fastify.post('/send', SendRequest, async (request, reply) => { - const { appId, appSecret, tenantId, teamId, channelId, content, contentType } = request.body - const result = await teamsBotService.sendToChannel({ appId, appSecret, tenantId, teamId, channelId, content, contentType }) - return reply.status(StatusCodes.OK).send(result) - }) -} - -type TeamsActivity = { - type: string - action?: string - serviceUrl: string - recipient?: { id: string } - channelData?: { - tenant?: { id: string } - team?: { id: string, aadGroupId?: string } - } -} - -const WebhookRequest = { - config: { - security: securityAccess.public(), - }, - schema: { - body: z.object({}).passthrough(), - }, -} - -const SendRequest = { - config: { - security: securityAccess.engine(), - }, - schema: { - body: z.object({ - appId: z.string(), - appSecret: z.string(), - tenantId: z.string(), - teamId: z.string(), - channelId: z.string(), - content: z.string(), - contentType: z.string(), - }), - }, -} diff --git a/packages/server/api/src/app/teams-bot/teams-bot.module.ts b/packages/server/api/src/app/teams-bot/teams-bot.module.ts deleted file mode 100644 index 5316670ecc5f..000000000000 --- a/packages/server/api/src/app/teams-bot/teams-bot.module.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' -import { teamsBotController } from './teams-bot.controller' - -export const teamsBotModule: FastifyPluginAsyncZod = async (app) => { - await app.register(teamsBotController, { prefix: '/v1/teams-bot' }) -} diff --git a/packages/server/api/src/app/teams-bot/teams-bot.service.ts b/packages/server/api/src/app/teams-bot/teams-bot.service.ts deleted file mode 100644 index 17547cce3d2d..000000000000 --- a/packages/server/api/src/app/teams-bot/teams-bot.service.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { safeHttp } from '@activepieces/server-utils' -import { ActivepiecesError, ErrorCode, isNil } from '@activepieces/shared' -import { teamsBotInstallationDb } from './teams-bot-installation.repo' - -export const teamsBotService = { - async handleInstallation({ appId, tenantId, teamsTeamId, serviceUrl }: { - appId: string - tenantId: string - teamsTeamId: string - serviceUrl: string - }): Promise { - await teamsBotInstallationDb.upsert({ appId, tenantId, teamsTeamId, serviceUrl }) - }, - - async handleUninstallation({ appId, tenantId, teamsTeamId }: { - appId: string - tenantId: string - teamsTeamId: string - }): Promise { - await teamsBotInstallationDb.remove({ appId, tenantId, teamsTeamId }) - }, - - async sendToChannel({ appId, appSecret, tenantId, teamId, channelId, content, contentType }: { - appId: string - appSecret: string - tenantId: string - teamId: string - channelId: string - content: string - contentType: string - }): Promise { - const installation = await teamsBotInstallationDb.findOne({ appId, tenantId, teamsTeamId: teamId }) - if (isNil(installation)) { - throw new ActivepiecesError({ - code: ErrorCode.VALIDATION, - params: { - message: 'Activepieces Bot is not installed in this team. Add it from the Teams App Store first.', - }, - }) - } - - const botToken = await getBotFrameworkToken({ appId, appSecret, tenantId }) - const created = await createChannelConversation({ - serviceUrl: installation.serviceUrl, - botToken, - botAppId: appId, - tenantId, - channelId, - content, - contentType, - }) - - const messageId = created.activityId ?? created.id.split('messageid=')[1] ?? created.id - return { - id: created.id, - activityId: created.activityId ?? messageId, - messageId, - messageType: 'message', - webUrl: `https://teams.microsoft.com/l/message/${encodeURIComponent(channelId)}/${messageId}?groupId=${teamId}&tenantId=${tenantId}&createdTime=${messageId}&parentMessageId=${messageId}`, - teamId, - channelId, - tenantId, - } - }, -} - -async function getBotFrameworkToken({ appId, appSecret, tenantId }: { - appId: string - appSecret: string - tenantId: string -}): Promise { - const params = new URLSearchParams({ - grant_type: 'client_credentials', - client_id: appId, - client_secret: appSecret, - scope: 'https://api.botframework.com/.default', - }) - - const response = await safeHttp.axios.post<{ access_token: string }>( - `https://login.microsoftonline.com/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`, - params.toString(), - { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }, - ) - - return response.data.access_token -} - -async function createChannelConversation({ serviceUrl, botToken, botAppId, tenantId, channelId, content, contentType }: { - serviceUrl: string - botToken: string - botAppId: string - tenantId: string - channelId: string - content: string - contentType: string -}): Promise { - const baseUrl = serviceUrl.endsWith('/') ? serviceUrl : `${serviceUrl}/` - const response = await safeHttp.axios.post( - `${baseUrl}v3/conversations`, - { - isGroup: true, - bot: { id: `28:${botAppId}`, name: 'Activepieces' }, - channelData: { - channel: { id: channelId }, - tenant: { id: tenantId }, - }, - activity: { - type: 'message', - text: content, - textFormat: contentType === 'html' ? 'xml' : 'plain', - }, - }, - { headers: { Authorization: `Bearer ${botToken}` } }, - ) - - return response.data -} - -type CreateConversationResponse = { - id: string - activityId?: string -} - -type SendChannelMessageResult = { - id: string - activityId: string - messageId: string - messageType: string - webUrl: string - teamId: string - channelId: string - tenantId: string -} diff --git a/packages/server/api/src/app/trigger/app-event-routing/app-event-routing.module.ts b/packages/server/api/src/app/trigger/app-event-routing/app-event-routing.module.ts index 36cb86e2703b..4ad855515287 100644 --- a/packages/server/api/src/app/trigger/app-event-routing/app-event-routing.module.ts +++ b/packages/server/api/src/app/trigger/app-event-routing/app-event-routing.module.ts @@ -1,6 +1,7 @@ import { ActivepiecesError, apId, assertNotNullOrUndefined, ErrorCode, isNil } from '@activepieces/core-utils' import { facebookLeads } from '@activepieces/piece-facebook-leads' import { intercom } from '@activepieces/piece-intercom' +import { microsoftTeamsBot } from '@activepieces/piece-microsoft-teams-bot' import { slack } from '@activepieces/piece-slack' import { square } from '@activepieces/piece-square' import { Piece, PieceAuthProperty } from '@activepieces/pieces-framework' @@ -24,12 +25,14 @@ const appWebhooks: Record = { slack: '@activepieces/piece-slack', square: '@activepieces/piece-square', 'facebook-leads': '@activepieces/piece-facebook-leads', intercom: '@activepieces/piece-intercom', + 'microsoft-teams-bot': '@activepieces/piece-microsoft-teams-bot', } export const appEventRoutingModule: FastifyPluginAsyncZod = async (app) => { diff --git a/packages/server/api/test/integration/ce/mcp/mcp-oauth-chatgpt-conformance.test.ts b/packages/server/api/test/integration/ce/mcp/mcp-oauth-chatgpt-conformance.test.ts new file mode 100644 index 000000000000..bd16850f880f --- /dev/null +++ b/packages/server/api/test/integration/ce/mcp/mcp-oauth-chatgpt-conformance.test.ts @@ -0,0 +1,78 @@ +import { FastifyInstance } from 'fastify' +import { beforeAll, describe, expect, it } from 'vitest' +import { MCP_OAUTH_REDIRECT_URI, mcpOAuthTestHelpers } from '../../../helpers/mcp-oauth' +import { createTestContext, TestContext } from '../../../helpers/test-context' +import { setupTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance +let ctx: TestContext + +const CHATGPT_RELAY_STATE = 'openai_platform_oauth_relay__eyJvYXV0aF9pZCI6Im9hdXRoX3NfMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAiLCJhcHBfaWQiOiJhc2RrX2FwcF8xMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMSIsInZlcnNpb25faWQiOiJhc2RrX2FwcF92XzIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyIiwib3JnX2lkIjoib3JnLTMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMyIsInRhcmdldF91cmkiOiJodHRwczovL3BsYXRmb3JtLm9wZW5haS5jb20vcGx1Z2lucy9lZGl0L2FzZGtfYXBwXzExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExL2FzZGtfYXBwX3ZfMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjI/c2VjdGlvbj1NQ1ArU2VydmVyIn0=' + +const STATE_LIMIT = 2048 + +async function authorize({ state }: { state: string }): ReturnType { + const client = await mcpOAuthTestHelpers.registerClient({ app, tokenEndpointAuthMethod: 'none' }) + const { challenge } = mcpOAuthTestHelpers.generatePkce() + + return app.inject({ + method: 'GET', + url: '/authorize?' + new URLSearchParams({ + client_id: client.client_id, + redirect_uri: MCP_OAUTH_REDIRECT_URI, + response_type: 'code', + code_challenge: challenge, + code_challenge_method: 'S256', + scope: 'mcp', + resource: 'https://cloud.activepieces.com/mcp/platform', + state, + }).toString(), + }) +} + +function authRequestIdFrom(location: string): string { + return new URL(location, MCP_OAUTH_REDIRECT_URI).searchParams.get('authRequestId') ?? '' +} + +async function consentThenApprove({ state }: { state: string }): Promise { + const consent = await authorize({ state }) + expect(consent.statusCode).toBe(302) + + const approved = await ctx.post('/v1/mcp-oauth/approve', { + authRequestId: authRequestIdFrom(String(consent.headers.location)), + projectId: ctx.project.id, + }) + expect(approved.statusCode).toBe(200) + + return new URL(approved.json().redirectUrl) +} + +describe('ChatGPT platform connector conformance', () => { + beforeAll(async () => { + app = await setupTestEnvironment({ fresh: true }) + ctx = await createTestContext(app) + }) + + it('returns the relay state to the connector unchanged after consent', async () => { + expect(CHATGPT_RELAY_STATE.length).toBeGreaterThan(512) + + const redirect = await consentThenApprove({ state: CHATGPT_RELAY_STATE }) + expect(redirect.searchParams.get('state')).toBe(CHATGPT_RELAY_STATE) + expect(redirect.searchParams.get('code')).toEqual(expect.any(String)) + }) + + it('stores and returns a relay state at the length limit', async () => { + const state = 'a'.repeat(STATE_LIMIT) + + const redirect = await consentThenApprove({ state }) + + expect(redirect.searchParams.get('state')).toBe(state) + expect(redirect.searchParams.get('code')).toEqual(expect.any(String)) + }) + + it('refuses a relay state beyond the length limit without a 5xx', async () => { + const consent = await authorize({ state: 'a'.repeat(STATE_LIMIT + 1) }) + + expect(consent.statusCode).toBe(400) + }) +}) diff --git a/packages/server/api/test/integration/cloud/platform/platform.test.ts b/packages/server/api/test/integration/cloud/platform/platform.test.ts index f9b4c315dc25..8f0b0039092c 100644 --- a/packages/server/api/test/integration/cloud/platform/platform.test.ts +++ b/packages/server/api/test/integration/cloud/platform/platform.test.ts @@ -575,7 +575,33 @@ describe('Platform API', () => { // assert expect(response?.statusCode).toBe(StatusCodes.OK) - expect(Object.keys(responseBody).length).toBe(24) + expect(Object.keys(responseBody).sort()).toStrictEqual([ + 'allowedAuthDomains', + 'allowedEmbedOrigins', + 'autoCreatePersonalProjects', + 'billingEnforced', + 'cloudAuthEnabled', + 'created', + 'emailAuthEnabled', + 'enforceAllowedAuthDomains', + 'favIconUrl', + 'federatedAuthProviders', + 'fullLogoUrl', + 'googleAuthEnabled', + 'id', + 'logoIconUrl', + 'name', + 'ownerId', + 'pieceSelectorConfig', + 'pinnedPieces', + 'plan', + 'primaryColor', + 'ssoDomain', + 'ssoDomainVerification', + 'themeColors', + 'updated', + 'usage', + ]) expect(responseBody.id).toBe(mockPlatform.id) expect(responseBody.ownerId).toBe(mockOwner.id) expect(responseBody.name).toBe(mockPlatform.name) diff --git a/packages/server/worker/src/lib/config/configs.ts b/packages/server/worker/src/lib/config/configs.ts index 5d48562c529f..93f39b31783e 100644 --- a/packages/server/worker/src/lib/config/configs.ts +++ b/packages/server/worker/src/lib/config/configs.ts @@ -51,6 +51,7 @@ export enum WorkerSystemProp { EXECUTION_MODE = 'AP_EXECUTION_MODE', REUSE_SANDBOX = 'AP_REUSE_SANDBOX', CACHE_BASE_PATH = 'AP_CACHE_BASE_PATH', + PREWARM_CACHE_ON_STARTUP = 'AP_PREWARM_CACHE_ON_STARTUP', } const defaultValues: Partial> = { @@ -63,6 +64,9 @@ const defaultValues: Partial> = { // The destination is concurrency 1 + horizontal replicas (ADR 0003). [WorkerSystemProp.WORKER_CONCURRENCY]: '5', [WorkerSystemProp.CACHE_BASE_PATH]: 'cache', + // Off by default: prewarm resolves and compiles every enabled flow on the platform, so its + // startup memory/CPU cost grows with flow count and can OOM small workers on large instances. + [WorkerSystemProp.PREWARM_CACHE_ON_STARTUP]: 'false', } export const system = { diff --git a/packages/server/worker/src/lib/worker.ts b/packages/server/worker/src/lib/worker.ts index d4be796d539f..9d9e99b84e79 100644 --- a/packages/server/worker/src/lib/worker.ts +++ b/packages/server/worker/src/lib/worker.ts @@ -200,11 +200,15 @@ async function startPollingWorkers(apiClient: WorkerToApiContract): Promise { const { agentId } = useParams<{ agentId: string }>(); - const { platform } = platformHooks.useCurrentPlatform(); + const agentsAvailable = useAgentsAvailable(); const [configureOpen, setConfigureOpen] = useState(); const [conversationsOpen, setConversationsOpen] = useState(true); const [searchParams, setSearchParams] = useSearchParams(); @@ -544,7 +547,7 @@ const AgentEditorContent = () => { }; const { data: agent, isLoading } = agentsQueries.useAgent({ id: agentId ?? '', - enabled: agentId !== undefined && platform.plan.agentsEnabled, + enabled: agentId !== undefined && agentsAvailable, }); if (isLoading || agent === undefined) { @@ -684,10 +687,10 @@ const AgentEditorContent = () => { }; const AgentEditorPage = () => { - const { platform } = platformHooks.useCurrentPlatform(); + const agentsAvailable = useAgentsAvailable(); return ( { - const { platform } = platformHooks.useCurrentPlatform(); + const agentsAvailable = useAgentsAvailable(); return ( { const navigate = useNavigate(); const { project } = projectCollectionUtils.useCurrentProject(); const { data: allProjects } = projectCollectionUtils.useAll(); - const { platform } = platformHooks.useCurrentPlatform(); + const agentsAvailable = useAgentsAvailable(); const { data, isLoading } = agentsQueries.useAgents({ - enabled: platform.plan.agentsEnabled, + enabled: agentsAvailable, }); const agents = useMemo(() => { diff --git a/packages/web/src/app/routes/platform/setup/ai/index.tsx b/packages/web/src/app/routes/platform/setup/ai/index.tsx index a9340f147749..c752eb5eed19 100644 --- a/packages/web/src/app/routes/platform/setup/ai/index.tsx +++ b/packages/web/src/app/routes/platform/setup/ai/index.tsx @@ -1,9 +1,10 @@ -import { PlatformRole } from '@activepieces/shared'; +import { ApEdition, ApFlagId, PlatformRole } from '@activepieces/shared'; import { t } from 'i18next'; import { Bot, WandSparkles } from 'lucide-react'; import { useSearchParams } from 'react-router-dom'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { flagsHooks } from '@/hooks/flags-hooks'; import { userHooks } from '@/hooks/user-hooks'; import { cn } from '@/lib/utils'; @@ -31,9 +32,15 @@ export default function AIProvidersPage() { function AICenter() { const [searchParams, setSearchParams] = useSearchParams(); + const { data: edition } = flagsHooks.useFlag(ApFlagId.EDITION); + const capabilitiesEnabled = edition !== ApEdition.COMMUNITY; const rawTab = searchParams.get('tab'); - const activeTab = isTabValue(rawTab) ? rawTab : 'providers'; + const requestedTab = isTabValue(rawTab) ? rawTab : 'providers'; + const activeTab = + requestedTab === 'capabilities' && !capabilitiesEnabled + ? 'providers' + : requestedTab; const setTab = (tab: TabValue) => { const newParams = new URLSearchParams(searchParams); @@ -47,7 +54,15 @@ function AICenter() { const navItems: { value: TabValue; label: string; icon: typeof Bot }[] = [ { value: 'providers', label: t('Providers'), icon: Bot }, - { value: 'capabilities', label: t('Capabilities'), icon: WandSparkles }, + ...(capabilitiesEnabled + ? [ + { + value: 'capabilities' as const, + label: t('Capabilities'), + icon: WandSparkles, + }, + ] + : []), ]; return ( @@ -93,9 +108,11 @@ function AICenter() { - - - + {capabilitiesEnabled && ( + + + + )} diff --git a/packages/web/src/features/agents/hooks/agents-hooks.ts b/packages/web/src/features/agents/hooks/agents-hooks.ts index db4bdfda3a2f..b2a0f0423ba0 100644 --- a/packages/web/src/features/agents/hooks/agents-hooks.ts +++ b/packages/web/src/features/agents/hooks/agents-hooks.ts @@ -25,15 +25,16 @@ export const useAgentsEnabled = (): boolean => { return agentsEnabled === true; }; -export const useAgentsNavVisible = (): boolean => { - const agentsEnabled = useAgentsEnabled(); +export const useAgentsAvailable = (): boolean => { + const releaseEnabled = useAgentsEnabled(); const { platform } = platformHooks.useCurrentPlatform(); + return releaseEnabled && platform.plan.agentsEnabled; +}; + +export const useAgentsNavVisible = (): boolean => { + const available = useAgentsAvailable(); const { checkAccess } = useAuthorization(); - return ( - agentsEnabled && - platform.plan.agentsEnabled && - checkAccess(Permission.READ_AGENT) - ); + return available && checkAccess(Permission.READ_AGENT); }; export const agentsQueries = { diff --git a/packages/web/src/features/agents/index.ts b/packages/web/src/features/agents/index.ts index 53624af01af0..7b96cec2e86d 100644 --- a/packages/web/src/features/agents/index.ts +++ b/packages/web/src/features/agents/index.ts @@ -15,4 +15,8 @@ export { SUPPORTED_AI_PROVIDERS } from './ai-providers'; export type { AiProviderInfo } from './ai-providers'; export { AgentStructuredOutput } from './structured-output'; export { agentQueries, agentMutations } from './hooks/agent-hooks'; -export { useAgentsEnabled, useAgentsNavVisible } from './hooks/agents-hooks'; +export { + useAgentsAvailable, + useAgentsEnabled, + useAgentsNavVisible, +} from './hooks/agents-hooks';