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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ This plugin allows you to:
* `Textbots > *`
* `Architect > Dependency Tracking > View`
* `Routing > Queue > View`
* `Architect > Flow Instance > View`
* `Architect > Flow Instance > Search`
* `Architect > Flow Instance Execution Data > View`

## Usage

Expand Down
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@
"prepare": "husky"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"purecloud-flow-scripting-api-sdk-javascript": "^0.68.3",
"purecloud-platform-client-v2": "^258.2.0",
"zod": "^4.0.0"
"@modelcontextprotocol/sdk": "^1.31.0",
"purecloud-flow-scripting-api-sdk-javascript": "^0.69.1",
"purecloud-platform-client-v2": "^263.2.0",
"zod": "^4.6.5"
},
"devDependencies": {
"@biomejs/biome": "2.5.7",
"@makingchatbots/genesys-cloud-architect-diagram-lib": "^1.5.0",
"@types/node": "^25.8.0",
"@biomejs/biome": "2.5.15",
"@makingchatbots/genesys-cloud-architect-diagram-lib": "^1.6.0",
"@types/node": "^26.6.3",
"esbuild": "^0.28.2",
"husky": "^9.1.7",
"typescript": "^7.0.2"
Expand Down
274 changes: 141 additions & 133 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

209,746 changes: 105,450 additions & 104,296 deletions scripts/deploy-runner.js

Large diffs are not rendered by default.

231 changes: 112 additions & 119 deletions servers/genesys-cloud-architect-mcp.js

Large diffs are not rendered by default.

249 changes: 241 additions & 8 deletions skills/interpret-flow-ir/SKILL.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions skills/write-flow/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ actionFactory.addActionCommunicate(freeTextPath, "Answer", '"Here is the answer.

When the DigitalMenu receives free-text input, the Dialog Engine checks trained intents before falling through to NoMatch. See the NLU section in `examples/digital-bot-flow.md`.

In bot flows (`createFlowBotAsync`), where `AskForIntent` is valid, the deployed action carries one output path per associated intent (keyed by the intent id, labelled with the intent name) plus `__NO_INTENT__`, `__KNOWLEDGE__` and `__MAX_NO_INPUTS__`. `flow_ir` resolves those as ordinary edges and lists the intents in the node's `description`, so a session can verify that each intent reached its task by reading the IR back. In execution data the action appears as `actionAskForIntent`, and its `outputPathId` is the intent id that matched, so `flow_execution_data`'s `takenBranch` names the intent by label.

## Output path enable/disable

`ArchActionOutput.enabled` controls whether an output path (MaxNoMatches, MaxNoInputs, etc.) is active. Paths default to disabled on most action types. Adding actions to a disabled path produces "Unreachable because path is disabled" warnings. Check `canEnableDisable` before setting.
Expand Down
16 changes: 16 additions & 0 deletions src/mcp-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import platformClient from "purecloud-platform-client-v2";
import { z } from "zod/v3";
import { deployFlow } from "./tools/deploy-flow.ts";
import { findFlow } from "./tools/find-flow.ts";
import { findFlowExecution } from "./tools/find-flow-execution.ts";
import { findQueue } from "./tools/find-queue.ts";
import { flowAction } from "./tools/flow-action.ts";
import { flowDependencies } from "./tools/flow-dependencies.ts";
import { flowExecutionData } from "./tools/flow-execution-data.ts";
import { flowIr } from "./tools/flow-ir.ts";
import { searchInFlow } from "./tools/search-in-flow.ts";
import { testBotFlow } from "./tools/test-bot-flow.ts";
Expand Down Expand Up @@ -71,6 +73,20 @@ server.registerTool(
searchInFlowTool.handler,
);

const findFlowExecutionTool = findFlowExecution({ architectApi });
server.registerTool(
"find_flow_execution",
findFlowExecutionTool.config,
findFlowExecutionTool.handler,
);

const flowExecutionDataTool = flowExecutionData({ architectApi });
server.registerTool(
"flow_execution_data",
flowExecutionDataTool.config,
flowExecutionDataTool.handler,
);

const deployFlowTool = deployFlow({
region: envVars.GENESYS_REGION,
clientId: envVars.GENESYS_CLIENT_ID,
Expand Down
8 changes: 8 additions & 0 deletions src/mcp-server/tools/fetch-flow-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ function describeFailure(flowId: string, err: unknown): string {
if (status === 404) {
return `Flow "${flowId}" not found. Check the flow id.`;
}
if (status === 410) {
return (
`Flow "${flowId}" has been deleted (410)` +
(detail ? `: ${detail}` : ".") +
" Its configuration is no longer retrievable; execution data recorded " +
"before the deletion may still exist."
);
}
if (status === 401) {
return (
`Not authenticated with Genesys Cloud (401) fetching flow "${flowId}": ` +
Expand Down
259 changes: 259 additions & 0 deletions src/mcp-server/tools/find-flow-execution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import type { ArchitectApi, Models } from "purecloud-platform-client-v2";
import { z } from "zod/v3";
import { formatApiError, toApiError } from "./api-error.ts";
import type { ToolFactory } from "./types.ts";

/** The API's own ceiling on results per query, per the help centre. */
const MAX_RESULTS = 200;

/** How long Genesys Cloud keeps execution data, per the help centre. */
const RETENTION_DAYS = 10;

interface FlowExecutionSummary {
/** The flow instance id; pass it to flow_execution_data as executionId. */
executionId: string;
flowId: string | null;
flowName: string | null;
flowType: string | null;
flowVersion: string | null;
conversationId: string | null;
workitemId?: string;
startDateTime: string | null;
endDateTime: string | null;
flowErrorReason?: string;
flowWarningReason?: string;
}

interface FindFlowExecutionResult {
query: {
conversationId?: string;
flowId?: string;
onlyWithErrors?: boolean;
};
executions: FlowExecutionSummary[];
total: number;
notes?: string[];
}

/**
* The API reports "NONE" rather than omitting a reason, so a caller filtering
* on presence would see every instance as errored. Normalise to absence.
*/
function reasonOrUndefined(reason: string | undefined): string | undefined {
return reason && reason !== "NONE" ? reason : undefined;
}

function toSummary(
entry: Models.FlowExecutionDataQueryResult,
): FlowExecutionSummary {
const flowErrorReason = reasonOrUndefined(entry.flowErrorReason);
const flowWarningReason = reasonOrUndefined(entry.flowWarningReason);
return {
executionId: entry.id ?? "",
flowId: entry.flowId ?? null,
flowName: entry.flowName ?? null,
flowType: entry.flowType ?? null,
flowVersion: entry.flowVersion ?? null,
conversationId: entry.conversationId ?? null,
...(entry.workitemId ? { workitemId: entry.workitemId } : {}),
startDateTime: entry.startDateTime ?? null,
endDateTime: entry.endDateTime ?? null,
...(flowErrorReason ? { flowErrorReason } : {}),
...(flowWarningReason ? { flowWarningReason } : {}),
};
}

function byStartTime(a: FlowExecutionSummary, b: FlowExecutionSummary): number {
return (a.startDateTime ?? "").localeCompare(b.startDateTime ?? "");
}

/**
* Best-effort check of the org-wide storage switch, used only to explain an
* empty result. Needs a permission the search itself does not, so a failure
* here is swallowed rather than turned into an error.
*/
async function storageEnabled(
architectApi: ArchitectApi,
): Promise<boolean | undefined> {
try {
const settings =
await architectApi.getFlowsInstancesSettingsExecutiondata();
return settings.enabled;
} catch {
return undefined;
}
}

export interface ToolConfig {
architectApi: ArchitectApi;
}

const inputSchema = {
conversationId: z
.string()
.min(1)
.optional()
.describe(
"The Genesys Cloud conversation id. Returns every flow instance " +
"that conversation ran, so a call that went through an inbound " +
"flow and then an in-queue flow yields two entries. Preferred " +
"filter: it is what the API indexes on.",
),
flowId: z
.string()
.min(1)
.optional()
.describe(
"The Architect flow id. Returns recent instances of that flow, " +
"whichever conversation ran them. Combine with conversationId " +
"to pick one flow out of a conversation.",
),
onlyWithErrors: z
.boolean()
.optional()
.describe(
"When true, only instances that ended with a flow error are " +
"returned (flowErrorReason is set). Applied to the instances " +
"the query fetched, so with a busy flow the errored runs " +
"beyond the API's 200-instance cap are not seen. Use it to " +
"find the runs worth inspecting with flow_execution_data.",
),
};

export const findFlowExecution: ToolFactory<ToolConfig, typeof inputSchema> = ({
architectApi,
}: ToolConfig) => ({
config: {
description:
"Finds the recorded executions (flow instances) of Genesys Cloud " +
"Architect flows, by conversation id and/or flow id. Returns each " +
"instance's executionId, flow id/name/type/version, conversationId, " +
"start and end times, and any flowErrorReason or flowWarningReason, " +
"oldest first. Use it to answer 'what did this conversation do' or " +
"'has this flow been failing', then pass an executionId to " +
"flow_execution_data for the action-by-action log. Only the last " +
`${RETENTION_DAYS} days are retained, the API cannot filter by date, and at most ` +
`${MAX_RESULTS} instances are returned per query, so prefer conversationId. ` +
"A run is listed only once it has ended. test_bot_flow runs have no " +
"conversation id, so find them by flowId or pass their sessionId " +
"straight to flow_execution_data.",
annotations: {
title: "Find Flow Execution",
readOnlyHint: true,
destructiveHint: false,
},
inputSchema,
},
handler: async ({ conversationId, flowId, onlyWithErrors }) => {
if (!conversationId && !flowId) {
return {
isError: true,
content: [
{
type: "text",
text: "Provide a conversationId, a flowId, or both.",
},
],
};
}

const criteria: Models.CriteriaItem[] = [];
if (conversationId) {
criteria.push({
key: "ConversationId",
operator: "eq",
value: conversationId,
});
}
if (flowId) {
criteria.push({ key: "FlowId", operator: "eq", value: flowId });
}

try {
const page = await architectApi.postFlowsInstancesQuery(
{ query: [{ and: criteria }] },
{ pageSize: MAX_RESULTS },
);
// The API's query operators are eq/gt/gte/lt/lte/begins, with no
// negation or null test, so "has an error" is filtered here.
const fetched = (page.entities ?? []).map(toSummary);
const executions = (
onlyWithErrors
? fetched.filter((e) => e.flowErrorReason !== undefined)
: fetched
).sort(byStartTime);
const total = Math.max(page.total ?? 0, fetched.length);

const notes: string[] = [];
if (
onlyWithErrors &&
executions.length === 0 &&
fetched.length > 0
) {
notes.push(
`None of the ${fetched.length} instance(s) fetched ended with a flow ` +
"error. Drop onlyWithErrors to see them.",
);
} else if (executions.length === 0) {
const enabled = await storageEnabled(architectApi);
if (enabled === false) {
notes.push(
"No instances matched, and execution data storage is " +
"disabled for this org (Architect > Settings > Execution " +
"data), so none are being recorded.",
);
} else {
notes.push(
"No instances matched. A run is listed only after it ends; " +
`execution data is kept for ${RETENTION_DAYS} days and is only ` +
"captured for flows published after storage was enabled " +
"(republish older flows); and test_bot_flow runs have no " +
"conversation id, so search for them by flowId. Check the " +
"id, or query by flowId to see whether the flow records " +
"anything at all.",
);
}
} else if (total > fetched.length) {
notes.push(
`${total} instances matched but only ${fetched.length} were ` +
"fetched. Narrow the query with a conversationId.",
);
} else if (fetched.length >= MAX_RESULTS) {
notes.push(
`The API caps results at ${MAX_RESULTS}; older instances may exist.`,
);
}

const result: FindFlowExecutionResult = {
query: {
...(conversationId ? { conversationId } : {}),
...(flowId ? { flowId } : {}),
...(onlyWithErrors ? { onlyWithErrors } : {}),
},
executions,
total,
...(notes.length > 0 ? { notes } : {}),
};
return {
content: [{ type: "text", text: JSON.stringify(result) }],
};
} catch (err) {
const { status } = toApiError(err);
const hint =
status === 403
? " The OAuth client needs the 'Architect > Flow Instance > Search' permission."
: status === 400
? " The query was rejected; check that the conversationId and flowId are well-formed Genesys Cloud ids."
: "";
return {
isError: true,
content: [
{
type: "text",
text: `Failed to search for flow executions: ${formatApiError(err)}${hint}`,
},
],
};
}
},
});
Loading
Loading