Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
app:
image: ghcr.io/activepieces/activepieces:0.80.1
image: ghcr.io/activepieces/activepieces:0.83.0
container_name: activepieces-app
restart: unless-stopped
ports:
Expand Down
18 changes: 18 additions & 0 deletions docs/mcp/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,24 @@ Get the detailed input property schema for a specific piece action or trigger. R
| `flowId` | string | No | Flow ID for resolving dependent dropdowns that need step context. |
| `input` | object | No | Known input values for resolving dependent DYNAMIC properties (e.g., `{"body_type": "json"}`). |

### ap_resolve_property_options

Resolve dropdown options for a single piece property. Returns available choices with labels and internal IDs. Use this to discover valid values for dynamic dropdown fields (e.g., Slack channels, Google Sheets, email labels) before configuring a step.

| Input | Type | Required | Description |
|-------|------|----------|-------------|
| `pieceName` | string | Yes | Piece name (e.g., `@activepieces/piece-slack`) |
| `actionOrTriggerName` | string | Yes | Action or trigger name (e.g., `send_channel_message`) |
| `type` | string | Yes | `action` or `trigger` |
| `propertyName` | string | Yes | The exact property name to resolve (e.g., `channel`) |
| `auth` | string | Yes | Connection externalId — required to fetch options from the user's account |
| `input` | object | No | Values for parent properties that this field depends on (refreshers) |
| `searchValue` | string | No | Search term to filter results for large dropdown lists (e.g., "sales" to find sales-related channels) |

<Tip>
Each option in the response has a `label` (human-readable name) and a `value` (internal ID). Always pass the **value** when configuring a step — never use the label. For example, if the response includes `{label: "general", value: "C1234567890"}`, use `"C1234567890"` as the channel value.
</Tip>

### ap_validate_step_config

Validate a step configuration before applying it. Returns field-level errors without modifying any flow.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "activepieces",
"version": "0.82.1",
"version": "0.83.0",
"packageManager": "bun@1.3.3",
"scripts": {
"prebuild": "node tools/scripts/install-bun.js",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import {
ActivepiecesError,
AdminRetryRunsRequestBody,
ApplyLicenseKeyByEmailRequestBody,
ErrorCode,
FlowRetryStrategy,
FlowRun,
FlowRunStatus,
IncreaseAICreditsForPlatformRequestBody,
isNil,
PlatformRole,
ProjectId,
} from '@activepieces/shared'
Expand All @@ -23,7 +19,6 @@ import { openRouterApi } from '../platform-plan/openrouter/openrouter-api'

export const adminPlatformService = (log: FastifyBaseLogger) => ({


retryRuns: async ({
createdAfter,
createdBefore,
Expand All @@ -32,27 +27,18 @@ export const adminPlatformService = (log: FastifyBaseLogger) => ({
const strategy = FlowRetryStrategy.FROM_FAILED_STEP

let query = flowRunRepo().createQueryBuilder('flow_run').where({
status: In([FlowRunStatus.INTERNAL_ERROR]),
id: In(runIds ?? []),
})
if (!isNil(runIds)) {
query = query.andWhere({
id: In(runIds),
if (!createdBefore) {
query = query.andWhere('flow_run.created <= :createdBefore', {
createdBefore,
})
}
if (!createdAfter || !createdBefore) {
throw new ActivepiecesError({
code: ErrorCode.VALIDATION,
params: {
message: 'createdAfter and createdBefore are required',
},
if (!createdAfter) {
query = query.andWhere('flow_run.created >= :createdAfter', {
createdAfter,
})
}
query = query.andWhere('flow_run.created >= :createdAfter', {
createdAfter,
})
query = query.andWhere('flow_run.created <= :createdBefore', {
createdBefore,
})

const flowRuns = await query.getMany()
const flowRunsByProject = flowRuns.reduce((acc, flowRun) => {
Expand All @@ -69,6 +55,7 @@ export const adminPlatformService = (log: FastifyBaseLogger) => ({
})
}
},

async applyLicenseKeyByEmail({ email, licenseKey }: ApplyLicenseKeyByEmailRequestBody): Promise<void> {
const identity = await userIdentityService(log).getIdentityByEmail(email)
if (!identity) {
Expand All @@ -93,7 +80,7 @@ export const adminPlatformService = (log: FastifyBaseLogger) => ({
}
await licenseKeysService(log).applyLimits(platform.id, key)
},
async increaseAiCredits({ amountInUsd, platformId }: IncreaseAICreditsForPlatformRequestBody): Promise<void> {
async increaseAiCredits({ amountInUsd, platformId }: IncreaseAICreditsForPlatformRequestBody): Promise<void> {
const { apiKeyHash } = await aiProviderService(log).getOrCreateActivePiecesProviderAuthConfig(platformId)
const { data: key } = await openRouterApi.getKey({ hash: apiKeyHash })

Expand Down
14 changes: 3 additions & 11 deletions packages/server/api/src/app/mcp/tools/ap-get-piece-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ async function resolvePropertyOptions({ props, componentProps, pieceName, pieceV
}
}
catch (err) {
log.debug({ err, propertyName: prop.name }, 'Failed to resolve property, keeping placeholder note')
log.warn({ err, propertyName: prop.name }, 'Failed to resolve property options — dropdown will be empty. Try calling ap_get_piece_props again with auth.')
}
}))
}
Expand Down Expand Up @@ -183,15 +183,7 @@ async function discoverAvailableConnections({ pieceName, projectId, log }: {
}
}

function withTimeout<T>({ promise, ms }: { promise: Promise<T>, ms: number }): Promise<T> {
let timer: ReturnType<typeof setTimeout>
return Promise.race([
promise.finally(() => clearTimeout(timer)),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(`Property resolution timed out after ${ms}ms`)), ms)
}),
])
}
const { withTimeout } = mcpUtils

const getPiecePropsInput = z.object({
pieceName: z.string().describe('The piece name (e.g. "@activepieces/piece-slack"). Use ap_list_pieces to get valid values.'),
Expand All @@ -202,7 +194,7 @@ const getPiecePropsInput = z.object({
input: z.record(z.string(), z.unknown()).optional().describe('Known input values to resolve dependent dynamic properties.'),
})

const PROPERTY_TIMEOUT_MS = 15_000
const PROPERTY_TIMEOUT_MS = 30_000

type ResolvePropertyOptionsParams = {
props: PropSummary[]
Expand Down
127 changes: 127 additions & 0 deletions packages/server/api/src/app/mcp/tools/ap-resolve-property-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { PiecePropertyMap, PropertyType } from '@activepieces/pieces-framework'
import {
EngineResponse,
EngineResponseStatus,
isNil,
isObject,
McpToolDefinition,
ProjectScopedMcpServer,
WorkerJobType,
} from '@activepieces/shared'
import { FastifyBaseLogger } from 'fastify'
import { z } from 'zod'
import { getPiecePackageWithoutArchive } from '../../pieces/metadata/piece-metadata-service'
import { projectService } from '../../project/project-service'
import { userInteractionWatcher } from '../../workers/user-interaction-watcher'
import { mcpUtils } from './mcp-utils'

const RESOLVE_TIMEOUT_MS = 30_000
const { withTimeout } = mcpUtils

export const apResolvePropertyOptionsTool = (mcp: ProjectScopedMcpServer, log: FastifyBaseLogger): McpToolDefinition => {
return {
title: 'ap_resolve_property_options',
description: 'Resolve dropdown options for a single piece property. Returns the available options with labels and values (IDs). Use this to discover valid values for DROPDOWN fields (e.g. Slack channels, Google Sheets, email labels). Always use the `value` from the returned options, not the `label`.',
inputSchema: resolvePropertyOptionsInput.shape,
annotations: { readOnlyHint: true, openWorldHint: false },
execute: async (args) => {
try {
const { pieceName, actionOrTriggerName, type, propertyName, auth, input: providedInput, searchValue } = resolvePropertyOptionsInput.parse(args)

const lookup = await mcpUtils.lookupPieceComponent({
pieceName,
componentName: actionOrTriggerName,
componentType: type,
projectId: mcp.projectId,
log,
})
if (lookup.error) {
return lookup.error
}

const { piece, component, pieceName: normalized } = lookup
const propDef = component.props[propertyName]
if (isNil(propDef)) {
return {
content: [{ type: 'text', text: `❌ Property "${propertyName}" not found on ${normalized}/${actionOrTriggerName}. Use ap_get_piece_props to see available properties.` }],
}
}

const project = await projectService(log).getOneOrThrow(mcp.projectId)

const piecePackage = await getPiecePackageWithoutArchive(log, project.platformId, { pieceName: normalized, pieceVersion: piece.version })

const input: Record<string, unknown> = {
...(providedInput ?? {}),
...(auth ? { auth: `{{connections['${auth}']}}` } : {}),
}

const result = await withTimeout({
promise: userInteractionWatcher.submitAndWaitForResponse<EngineResponse<{
options: Array<{ label: string, value: unknown }> | PiecePropertyMap
disabled?: boolean
}>>({
jobType: WorkerJobType.EXECUTE_PROPERTY,
platformId: project.platformId,
projectId: mcp.projectId,
flowVersion: undefined,
propertyName,
actionOrTriggerName,
input,
sampleData: {},
searchValue,
piece: piecePackage,
}, log),
ms: RESOLVE_TIMEOUT_MS,
})

if (result.status !== EngineResponseStatus.OK || isNil(result.response?.options)) {
return {
content: [{ type: 'text', text: `⚠️ Could not resolve options for "${propertyName}". You may use the value the user provided directly — it may work at runtime. However, the dropdown in the flow editor will appear unset. Mention this to the user.` }],
}
}

const { options } = result.response

if (propDef.type === PropertyType.DYNAMIC && isObject(options) && !Array.isArray(options)) {
const dynamicFields = mcpUtils.buildPropSummaries(options as PiecePropertyMap)
return {
content: [{ type: 'text', text: `✅ Dynamic fields for "${propertyName}":\n${JSON.stringify(dynamicFields, null, 2)}` }],
}
}

if (Array.isArray(options)) {
const mapped = options.map((o: { label: string, value: unknown }) => ({ label: o.label, value: o.value }))
if (mapped.length === 0) {
return {
content: [{ type: 'text', text: `⚠️ No options found for "${propertyName}". The account may have no items. You may use the value the user provided directly, but the dropdown in the flow editor will appear unset.` }],
}
}
return {
content: [{ type: 'text', text: `✅ Options for "${propertyName}" (${mapped.length} found). IMPORTANT: Use the "value" field (the ID), NOT the "label", when setting this property.\n${JSON.stringify(mapped, null, 2)}` }],
}
}

return {
content: [{ type: 'text', text: `⚠️ Unexpected response format for "${propertyName}".` }],
}
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
return {
content: [{ type: 'text', text: `⚠️ Options resolution timed out for "${(args as Record<string, unknown>).propertyName ?? 'unknown'}": ${message}. You may use the user-provided value directly — it often works at runtime. The dropdown in the flow editor may appear unset; mention this to the user.` }],
}
}
},
}
}

const resolvePropertyOptionsInput = z.object({
pieceName: z.string().describe('The piece name (e.g. "@activepieces/piece-slack").'),
actionOrTriggerName: z.string().describe('The action or trigger name (e.g. "send_channel_message").'),
type: z.enum(['action', 'trigger']).describe('Whether this is an action or trigger.'),
propertyName: z.string().describe('The exact property name to resolve options for (e.g. "channel").'),
auth: z.string().describe('Connection externalId — required to resolve options from the user\'s account.'),
input: z.record(z.string(), z.unknown()).optional().describe('Values for parent properties that this field depends on (refreshers).'),
searchValue: z.string().optional().describe('Search/filter term to narrow results for large dropdown lists (e.g., "sales" to find sales-related channels).'),
})
3 changes: 3 additions & 0 deletions packages/server/api/src/app/mcp/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { apLockAndPublishTool } from './ap-lock-and-publish'
import { apManageFieldsTool } from './ap-manage-fields'
import { apManageNotesTool } from './ap-manage-notes'
import { apRenameFlowTool } from './ap-rename-flow'
import { apResolvePropertyOptionsTool } from './ap-resolve-property-options'
import { apRetryRunTool } from './ap-retry-run'
import { apRunActionTool } from './ap-run-action'
import { apSetupGuideTool } from './ap-setup-guide'
Expand All @@ -44,6 +45,7 @@ export const LOCKED_TOOL_NAMES: string[] = [
'ap_validate_flow',
'ap_list_pieces',
'ap_get_piece_props',
'ap_resolve_property_options',
'ap_validate_step_config',
'ap_list_connections',
'ap_list_ai_models',
Expand Down Expand Up @@ -94,6 +96,7 @@ export const activepiecesTools = (mcp: ProjectScopedMcpServer, log: FastifyBaseL
apValidateFlowTool(mcp, log),
apListPiecesTool(mcp, log),
apGetPiecePropsTool(mcp, log),
apResolvePropertyOptionsTool(mcp, log),
apValidateStepConfigTool(mcp, log),
apListConnectionsTool(mcp, log),
apUpdateTriggerTool(mcp, log),
Expand Down
13 changes: 12 additions & 1 deletion packages/server/api/src/app/mcp/tools/mcp-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ function buildPropSummaries(props: PiecePropertyMap, depth = 0): PropSummary[] {
summary.options = prop.options.options.map((o: { label: string, value: unknown }) => ({ label: o.label, value: o.value }))
}
if (prop.type === PropertyType.DROPDOWN || prop.type === PropertyType.MULTI_SELECT_DROPDOWN) {
summary.note = 'Dynamic dropdown — options load from your account via API. Configure in the Activepieces UI, or provide a known value.'
summary.note = 'Resolve with ap_resolve_property_options. Use the returned value (ID), not label.'
}
if (prop.type === PropertyType.DYNAMIC) {
summary.note = 'DYNAMIC — call ap_get_piece_props with auth+input to resolve sub-fields.'
Expand Down Expand Up @@ -276,6 +276,16 @@ async function fillDefaultsForMissingOptionalProps({ settings, platformId, log }
}
}

function withTimeout<T>({ promise, ms }: { promise: Promise<T>, ms: number }): Promise<T> {
let timer: ReturnType<typeof setTimeout>
return Promise.race([
promise.finally(() => clearTimeout(timer)),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)
}),
])
}

export const mcpUtils = {
mcpToolError,
truncate,
Expand All @@ -289,6 +299,7 @@ export const mcpUtils = {
findResolvableProps,
validateAuth,
fillDefaultsForMissingOptionalProps,
withTimeout,
STEP_REFERENCE_HINT,
BRANCH_CONDITIONS_INPUT_SCHEMA,
}
Expand Down
Loading
Loading