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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: pnpm run lint
- run: pnpm run typecheck # Check TS types. Not done by linting or building
- run: pnpm run build
- name: Verify committed bundles match source
run: |
git diff --stat --exit-code servers/ bin/ || {
echo "::error::Committed bundles are out of sync with source. Run 'pnpm run build' and commit the result."
exit 1
}
- name: Smoke test MCP server
run: >
pnpm dlx @modelcontextprotocol/inspector --cli node servers/genesys-cloud-architect-mcp.js --method tools/list
Expand Down
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pnpm biome check --write --staged --no-errors-on-unmatched
pnpm typecheck
pnpm build
git add servers/genesys-cloud-architect-mcp.js
git add bin/deploy-runner.js
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"build": "pnpm run build:mcp-server && pnpm run build:deploy-runner",
"build:mcp-server": "esbuild src/mcp-server/index.ts --bundle --platform=node --target=node22 --format=cjs --minify --tree-shaking=true --define:process.env.npm_package_version=\\\"$npm_package_version\\\" --outfile=servers/genesys-cloud-architect-mcp.js",
"build:deploy-runner": "esbuild src/deploy-runner/index.ts --bundle --platform=node --target=node22 --format=cjs --outfile=bin/deploy-runner.js",
"typecheck": "tsc --noEmit",
"lint": "biome check",
"lint:fix": "biome check --write",
"format": "biome format --write",
Expand Down
48 changes: 24 additions & 24 deletions servers/genesys-cloud-architect-mcp.js

Large diffs are not rendered by default.

29 changes: 21 additions & 8 deletions src/mcp-server/tools/deploy-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ export interface DeployFlowConfig {
readonly clientSecret: string;
}

export const deployFlow: ToolFactory<DeployFlowConfig> = (toolConfig) => ({
const inputSchema = {
flowFile: z.string().min(1).describe("Path to the TypeScript flow file"),
};

export const deployFlow: ToolFactory<DeployFlowConfig, typeof inputSchema> = (
toolConfig,
) => ({
config: {
description:
"Deploys a Genesys Cloud Architect flow from a TypeScript file. " +
Expand All @@ -36,15 +42,10 @@ export const deployFlow: ToolFactory<DeployFlowConfig> = (toolConfig) => ({
readOnlyHint: false,
destructiveHint: true,
},
inputSchema: {
flowFile: z
.string()
.min(1)
.describe("Path to the TypeScript flow file"),
},
inputSchema,
},
handler: async ({ flowFile }) => {
const absolutePath = path.resolve(flowFile as string);
const absolutePath = path.resolve(flowFile);
if (!fs.existsSync(absolutePath)) {
return {
isError: true,
Expand Down Expand Up @@ -102,6 +103,18 @@ export const deployFlow: ToolFactory<DeployFlowConfig> = (toolConfig) => ({
});
}, DEPLOY_TIMEOUT_MS);

child.on("error", (err) => {
settle({
isError: true,
content: [
{
type: "text",
text: `Failed to start deploy runner: ${err.message}`,
},
],
});
});

let stdoutBuf = "";
child.stdout.on("data", (chunk: Buffer) => {
stdoutBuf += chunk.toString();
Expand Down
44 changes: 20 additions & 24 deletions src/mcp-server/tools/flow-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,22 @@ function planLookups(requestedIds: readonly string[]): {
return { lookupIds, requestedFor, strippedAny };
}

export const flowAction: ToolFactory<ToolConfig> = ({
const inputSchema = {
flowId: z.string().min(1).describe("The Genesys Cloud Architect flow ID"),
actionIds: z
.array(z.string().min(1))
.min(1)
.max(MAX_ACTION_IDS)
.describe(
"Action GUIDs, taken from the `id` of flow_ir nodes whose `kind` is " +
'"action". Batch every action of interest into one call rather than ' +
"calling this tool repeatedly. Branch-output ids of the form " +
"`<actionId>::<outputId>` are also accepted; the suffix is stripped and " +
`the underlying action is returned. Maximum ${MAX_ACTION_IDS} ids.`,
),
};

export const flowAction: ToolFactory<ToolConfig, typeof inputSchema> = ({
architectApi,
}: ToolConfig) => ({
config: {
Expand All @@ -68,30 +83,13 @@ export const flowAction: ToolFactory<ToolConfig> = ({
readOnlyHint: true,
destructiveHint: false,
},
inputSchema: {
flowId: z
.string()
.min(1)
.describe("The Genesys Cloud Architect flow ID"),
actionIds: z
.array(z.string().min(1))
.min(1)
.max(MAX_ACTION_IDS)
.describe(
"Action GUIDs, taken from the `id` of flow_ir nodes whose `kind` is " +
'"action". Batch every action of interest into one call rather than ' +
"calling this tool repeatedly. Branch-output ids of the form " +
"`<actionId>::<outputId>` are also accepted; the suffix is stripped and " +
`the underlying action is returned. Maximum ${MAX_ACTION_IDS} ids.`,
),
},
inputSchema,
},
handler: async ({ flowId, actionIds }) => {
let configuration: unknown;
try {
configuration = await architectApi.getFlowLatestconfiguration(
flowId as string,
);
configuration =
await architectApi.getFlowLatestconfiguration(flowId);
} catch {
return {
isError: true,
Expand All @@ -104,9 +102,7 @@ export const flowAction: ToolFactory<ToolConfig> = ({
};
}

const { lookupIds, requestedFor, strippedAny } = planLookups(
actionIds as string[],
);
const { lookupIds, requestedFor, strippedAny } = planLookups(actionIds);
// `findRawActions` never throws and always answers for every id, so a
// batch where nothing matched is still a successful lookup.
const lookup = findRawActions(configuration, lookupIds);
Expand Down
15 changes: 7 additions & 8 deletions src/mcp-server/tools/flow-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ export interface ToolConfig {
architectApi: ArchitectApi;
}

export const flowDependencies: ToolFactory<ToolConfig> = ({
const inputSchema = {
flowId: z.string().min(1).describe("The Genesys Cloud Architect flow ID"),
};

export const flowDependencies: ToolFactory<ToolConfig, typeof inputSchema> = ({
architectApi,
}: ToolConfig) => ({
config: {
Expand All @@ -64,18 +68,13 @@ export const flowDependencies: ToolFactory<ToolConfig> = ({
readOnlyHint: true,
destructiveHint: false,
},
inputSchema: {
flowId: z
.string()
.min(1)
.describe("The Genesys Cloud Architect flow ID"),
},
inputSchema,
},
handler: async ({ flowId }) => {
try {
let flow: platformClient.Models.Flow;
try {
flow = await architectApi.getFlow(flowId as string);
flow = await architectApi.getFlow(flowId);
} catch {
return {
isError: true,
Expand Down
40 changes: 19 additions & 21 deletions src/mcp-server/tools/flow-ir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,22 @@ export interface ToolConfig {
architectApi: ArchitectApi;
}

export const flowIr: ToolFactory<ToolConfig> = ({
const inputSchema = {
flowId: z.string().min(1).describe("The Genesys Cloud Architect flow ID"),
task: z
.string()
.min(1)
.optional()
.describe(
"Optional. Restrict the returned nodes to a single task, by task id or " +
"task name (case-insensitive). Use this to explore a large flow one " +
"task at a time. The full task list is always returned, and a node's " +
"predecessors may reference nodes in other tasks, which will not " +
"appear in the filtered node list.",
),
};

export const flowIr: ToolFactory<ToolConfig, typeof inputSchema> = ({
architectApi,
}: ToolConfig) => ({
config: {
Expand All @@ -46,30 +61,13 @@ export const flowIr: ToolFactory<ToolConfig> = ({
readOnlyHint: true,
destructiveHint: false,
},
inputSchema: {
flowId: z
.string()
.min(1)
.describe("The Genesys Cloud Architect flow ID"),
task: z
.string()
.min(1)
.optional()
.describe(
"Optional. Restrict the returned nodes to a single task, by task id or " +
"task name (case-insensitive). Use this to explore a large flow one " +
"task at a time. The full task list is always returned, and a node's " +
"predecessors may reference nodes in other tasks, which will not " +
"appear in the filtered node list.",
),
},
inputSchema,
},
handler: async ({ flowId, task }) => {
let configuration: unknown;
try {
configuration = await architectApi.getFlowLatestconfiguration(
flowId as string,
);
configuration =
await architectApi.getFlowLatestconfiguration(flowId);
} catch {
return {
isError: true,
Expand Down
83 changes: 43 additions & 40 deletions src/mcp-server/tools/test-bot-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,31 @@ export interface TestBotFlowConfig {
textbotsApi: TextbotsApi;
}

export const testBotFlow: ToolFactory<TestBotFlowConfig> = ({
textbotsApi,
}) => ({
const inputSchema = {
flowId: z
.string()
.optional()
.describe(
"The bot flow ID to test. Required when starting a new session.",
),
sessionId: z
.string()
.optional()
.describe(
"Session ID from a previous call. Required when continuing an existing conversation.",
),
message: z
.string()
.optional()
.describe(
"User message to send to the bot. Required when continuing a session.",
),
};

export const testBotFlow: ToolFactory<
TestBotFlowConfig,
typeof inputSchema
> = ({ textbotsApi }) => ({
config: {
description:
"Tests a deployed Genesys Cloud Architect Bot Flow and Digital Bot Flow by simulating a text conversation. " +
Expand All @@ -77,26 +99,7 @@ export const testBotFlow: ToolFactory<TestBotFlowConfig> = ({
readOnlyHint: false,
destructiveHint: false,
},
inputSchema: {
flowId: z
.string()
.optional()
.describe(
"The bot flow ID to test. Required when starting a new session.",
),
sessionId: z
.string()
.optional()
.describe(
"Session ID from a previous call. Required when continuing an existing conversation.",
),
message: z
.string()
.optional()
.describe(
"User message to send to the bot. Required when continuing a session.",
),
},
inputSchema,
},
handler: async ({ flowId, sessionId, message }) => {
try {
Expand Down Expand Up @@ -124,21 +127,9 @@ export const testBotFlow: ToolFactory<TestBotFlowConfig> = ({
};
}

if (sessionId && !message) {
return {
isError: true,
content: [
{
type: "text",
text: "A message is required when continuing an existing session.",
},
],
};
}

if (flowId) {
const session = await textbotsApi.postTextbotsBotflowsSessions({
flow: { id: flowId as string },
flow: { id: flowId },
externalSessionId: "",
inputData: { variables: {} },
channel: {
Expand All @@ -164,7 +155,19 @@ export const testBotFlow: ToolFactory<TestBotFlowConfig> = ({
return drainNoOps(textbotsApi, session.id, turn);
}

const previousTurnId = sessions.get(sessionId as string);
if (!sessionId || !message) {
return {
isError: true,
content: [
{
type: "text",
text: "A message is required when continuing an existing session.",
},
],
};
}

const previousTurnId = sessions.get(sessionId);
if (!previousTurnId) {
return {
isError: true,
Expand All @@ -178,7 +181,7 @@ export const testBotFlow: ToolFactory<TestBotFlowConfig> = ({
}

const turn = await textbotsApi.postTextbotsBotflowsSessionTurns(
sessionId as string,
sessionId,
{
previousTurn: { id: previousTurnId },
inputEventType: "UserInput",
Expand All @@ -187,15 +190,15 @@ export const testBotFlow: ToolFactory<TestBotFlowConfig> = ({
alternatives: [
{
transcript: {
text: message as string,
text: message,
},
},
],
},
},
);

return drainNoOps(textbotsApi, sessionId as string, turn);
return drainNoOps(textbotsApi, sessionId, turn);
} catch (err) {
return {
isError: true,
Expand Down
4 changes: 2 additions & 2 deletions src/mcp-server/tools/types.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
import type { ZodRawShape } from "zod/v3";
import type { objectOutputType, ZodRawShape, ZodTypeAny } from "zod/v3";

export type ToolConfig<T extends ZodRawShape = ZodRawShape> = {
config: {
description: string;
annotations: ToolAnnotations;
inputSchema: T;
};
handler: (args: Record<string, unknown>) => Promise<{
handler: (args: objectOutputType<T, ZodTypeAny>) => Promise<{
isError?: boolean;
content: Array<{ type: "text"; text: string }>;
}>;
Expand Down
Loading