Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ import { CreateAutomationTool } from './automations-tools/create-automation/crea
import { GetAutomationRunsTool } from './automations-tools/get-automation-runs/get-automation-runs-tool';
import { GetAutomationStatisticsTool } from './automations-tools/get-automation-statistics/get-automation-statistics-tool';
import { CreateWorkflowBuilderTool } from './workflow-builder-tools/create-workflow/create-workflow-tool';
import { GetWorkflowTool } from './workflow-builder-tools/get-workflow/get-workflow-tool';
import { ListWorkflowsTool } from './workflow-builder-tools/list-workflows/list-workflows-tool';
import { UpdateWorkflowTool } from './workflow-builder-tools/update-workflow/update-workflow-tool';
import { PlanWorkflowTool } from './workflow-builder-tools/plan-workflow/plan-workflow-tool';
import { PublishWorkflowTool } from './workflow-builder-tools/publish-workflow/publish-workflow-tool';
Expand Down Expand Up @@ -180,6 +182,8 @@ export const allGraphqlApiTools: BaseMondayApiToolConstructor[] = [
GetAutomationStatisticsTool,
// Workflow Builder Tools
CreateWorkflowBuilderTool,
GetWorkflowTool,
ListWorkflowsTool,
// Cast: ctor signature (api, apiToken, context?) doesn't match BaseMondayApiToolConstructor.
UpdateWorkflowTool as unknown as BaseMondayApiToolConstructor,
PlanWorkflowTool as unknown as BaseMondayApiToolConstructor,
Expand Down Expand Up @@ -266,6 +270,8 @@ export * from './agents-tools';
export * from './automations-tools';
// Workflow Builder Tools
export * from './workflow-builder-tools/create-workflow/create-workflow-tool';
export * from './workflow-builder-tools/get-workflow/get-workflow-tool';
export * from './workflow-builder-tools/list-workflows/list-workflows-tool';
export * from './workflow-builder-tools/update-workflow/update-workflow-tool';
export * from './workflow-builder-tools/plan-workflow/plan-workflow-tool';
export * from './workflow-builder-tools/publish-workflow/publish-workflow-tool';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ const PUBLIC_BASE_URL = 'https://api.monday.com';

export const WORKFLOW_BUILDER_AGENT_URL = `${PUBLIC_BASE_URL}${WORKFLOW_BUILDER_AGENT_PATH}`;
export const WORKFLOW_PLANNER_AGENT_URL = `${PUBLIC_BASE_URL}${WORKFLOW_PLANNER_AGENT_PATH}`;

// Read limits mirror the workflow-builder subgraph (get_workflow / live_workflows_page).
export const MAX_WORKFLOWS_PER_QUERY = 50;
export const DEFAULT_LIVE_WORKFLOWS_PAGE_SIZE = 50;
export const MAX_LIVE_WORKFLOWS_PAGE_SIZE = 100;
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { MondayAgentToolkit } from 'src/mcp/toolkit';
import { callToolByNameRawAsync, createMockApiClient, parseToolResult } from '../../test-utils/mock-api-client';

describe('GetWorkflowTool', () => {
let mocks: ReturnType<typeof createMockApiClient>;

beforeEach(() => {
mocks = createMockApiClient();
jest.spyOn(MondayAgentToolkit.prototype as any, 'createApiClient').mockReturnValue(mocks.mockApiClient);
});

const mockWorkflow = {
id: '100',
title: 'My Workflow',
description: 'A sample workflow',
active: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-02T00:00:00Z',
steps: [{ node_id: '1', block_reference_id: '10', title: 'First step' }],
};

const expectedWorkflow = {
id: '100',
title: 'My Workflow',
description: 'A sample workflow',
active: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-02T00:00:00Z',
steps: [{ node_id: '1', block_reference_id: '10', title: 'First step' }],
};

it('should return workflows mapped to the read model', async () => {
mocks.setResponseOnce({ workflows: [mockWorkflow] });

const result = await callToolByNameRawAsync('get_workflow', { workflowIds: ['100'] });
const parsed = parseToolResult(result);

expect(parsed.workflows).toEqual([expectedWorkflow]);
expect(parsed.message).toContain('1');
});

it('should call workflows query with ids and versionOverride dev', async () => {
mocks.setResponseOnce({ workflows: [] });

await callToolByNameRawAsync('get_workflow', { workflowIds: ['100', '200'] });

expect(mocks.getMockRequest()).toHaveBeenCalledWith(
expect.stringContaining('workflows'),
{ ids: ['100', '200'] },
expect.objectContaining({ versionOverride: 'dev' }),
);
});

it('should default workflows to an empty array when the query returns null', async () => {
mocks.setResponseOnce({ workflows: null });

const result = await callToolByNameRawAsync('get_workflow', { workflowIds: ['100'] });
const parsed = parseToolResult(result);

expect(parsed.workflows).toEqual([]);
expect(parsed.message).toContain('0');
});

it('should coerce nullable fields to safe defaults', async () => {
mocks.setResponseOnce({
workflows: [{ id: '100', title: null, description: null, active: null, created_at: null, updated_at: null, steps: null }],
});

const result = await callToolByNameRawAsync('get_workflow', { workflowIds: ['100'] });
const parsed = parseToolResult(result);

expect(parsed.workflows).toEqual([
{ id: '100', title: null, description: null, active: false, created_at: null, updated_at: null, steps: [] },
]);
});

it('should reject an empty workflowIds array', async () => {
const result = await callToolByNameRawAsync('get_workflow', { workflowIds: [] });

expect(result.content[0].text).toContain('Provide at least one workflow ID');
});

it('should reject whitespace-only workflow IDs', async () => {
const result = await callToolByNameRawAsync('get_workflow', { workflowIds: [' '] });

expect(result.content[0].text).toContain('workflowId must be a non-empty string');
});

it('should propagate GraphQL errors with operation context', async () => {
mocks.setError('Not authorized');

const result = await callToolByNameRawAsync('get_workflow', { workflowIds: ['100'] });

expect(result.content[0].text).toContain('Failed to get workflow');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { z } from 'zod';
import { ToolInputType, ToolOutputType, ToolType } from '../../../../tool';
import { BaseMondayApiTool, createMondayApiAnnotations } from '../../base-monday-api-tool';
import { rethrowWithContext } from '../../../../../utils';
import { WorkflowAutomation } from '../../../../../monday-graphql/generated/graphql.dev/graphql';
import { MAX_WORKFLOWS_PER_QUERY } from '../constants';
import { toWorkflowReadModel } from '../workflow-read-model';
import { getWorkflowsQuery } from './get-workflow.graphql.dev';

interface GetWorkflowsQueryResponse {
readonly workflows: WorkflowAutomation[] | null;
}

export const getWorkflowToolSchema = {
workflowIds: z
.array(z.string().trim().min(1, 'workflowId must be a non-empty string'))
.min(1, 'Provide at least one workflow ID')
.max(MAX_WORKFLOWS_PER_QUERY, `Cannot request more than ${MAX_WORKFLOWS_PER_QUERY} workflows per call`)
.describe(`The workflow object IDs to fetch, as strings (1..${MAX_WORKFLOWS_PER_QUERY}).`),
};

export class GetWorkflowTool extends BaseMondayApiTool<typeof getWorkflowToolSchema> {
name = 'get_workflow';
type = ToolType.READ;
annotations = createMondayApiAnnotations({
title: 'Get Workflow',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
});

getDescription(): string {
return `Read one or more live workflows by their workflow object ID, returning metadata and the ordered list of steps.

Use this to inspect a standalone, workspace-level workflow's definition — its title, description, active state, and steps. Workflows are cross-board, workspace-level objects, distinct from board automations (use list_automations for those).

Returns a "workflows" array where each entry has id, title, description, active, created_at, updated_at, and steps (each step has node_id, block_reference_id, and title). IDs that don't resolve to a workflow are omitted from the result.

Note: if directing the user to a workflow in the UI, the correct URL path is custom_objects/ — e.g. {account}.monday.com/custom_objects/{id}.
`;
}

getInputSchema() {
return getWorkflowToolSchema;
}

protected async executeInternal(input: ToolInputType<typeof getWorkflowToolSchema>): Promise<ToolOutputType<never>> {
try {
const res = await this.mondayApi.request<GetWorkflowsQueryResponse>(
getWorkflowsQuery,
{ ids: input.workflowIds },
{ versionOverride: 'dev' },
);

const workflows = (res.workflows ?? []).map(toWorkflowReadModel);

return {
content: {
message: `Found ${workflows.length} workflow(s) for ${input.workflowIds.length} requested ID(s)`,
workflows,
},
};
} catch (error) {
rethrowWithContext(error, 'get workflow');
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { gql } from 'graphql-request';

export const getWorkflowsQuery = gql`
query getWorkflows($ids: [ID!]!) {
workflows(ids: $ids) {
id
title
description
active
created_at
updated_at
steps {
node_id
block_reference_id
title
}
}
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { MondayAgentToolkit } from 'src/mcp/toolkit';
import { callToolByNameRawAsync, createMockApiClient, parseToolResult } from '../../test-utils/mock-api-client';

describe('ListWorkflowsTool', () => {
let mocks: ReturnType<typeof createMockApiClient>;

beforeEach(() => {
mocks = createMockApiClient();
jest.spyOn(MondayAgentToolkit.prototype as any, 'createApiClient').mockReturnValue(mocks.mockApiClient);
});

const mockWorkflow = {
id: '100',
title: 'My Workflow',
description: 'A sample workflow',
active: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-02T00:00:00Z',
steps: [{ node_id: '1', block_reference_id: '10', title: 'First step' }],
};

const expectedWorkflow = {
id: '100',
title: 'My Workflow',
description: 'A sample workflow',
active: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-02T00:00:00Z',
steps: [{ node_id: '1', block_reference_id: '10', title: 'First step' }],
};

it('should return live workflows mapped to the read model', async () => {
mocks.setResponseOnce({
live_workflows_page: { data: [mockWorkflow], page_info: { has_next_page: false, end_cursor: null } },
});

const result = await callToolByNameRawAsync('list_workflows', {});
const parsed = parseToolResult(result);

expect(parsed.workflows).toEqual([expectedWorkflow]);
expect(parsed.message).toContain('1');
});

it('should call live_workflows_page with versionOverride dev', async () => {
mocks.setResponseOnce({
live_workflows_page: { data: [], page_info: { has_next_page: false, end_cursor: null } },
});

await callToolByNameRawAsync('list_workflows', {});

expect(mocks.getMockRequest()).toHaveBeenCalledWith(
expect.stringContaining('live_workflows_page'),
{ pagination: {} },
expect.objectContaining({ versionOverride: 'dev' }),
);
});

it('should forward limit and cursor into pagination', async () => {
mocks.setResponseOnce({
live_workflows_page: { data: [], page_info: { has_next_page: false, end_cursor: null } },
});

await callToolByNameRawAsync('list_workflows', { limit: 25, cursor: '99' });

expect(mocks.getMockRequest()).toHaveBeenCalledWith(
expect.anything(),
{ pagination: { limit: 25, last_id: '99' } },
expect.anything(),
);
});

it('should surface pagination metadata from page_info', async () => {
mocks.setResponseOnce({
live_workflows_page: { data: [mockWorkflow], page_info: { has_next_page: true, end_cursor: '100' } },
});

const result = await callToolByNameRawAsync('list_workflows', {});
const parsed = parseToolResult(result);

expect(parsed.pagination).toEqual({ nextCursor: '100', hasMore: true });
});

it('should default workflows to an empty array when page data is missing', async () => {
mocks.setResponseOnce({ live_workflows_page: null });

const result = await callToolByNameRawAsync('list_workflows', {});
const parsed = parseToolResult(result);

expect(parsed.workflows).toEqual([]);
expect(parsed.pagination).toEqual({ nextCursor: null, hasMore: false });
});

it('should reject a limit above the maximum', async () => {
const result = await callToolByNameRawAsync('list_workflows', { limit: 500 });

expect(result.content[0].text).toContain('less than or equal to 100');
});

it('should propagate GraphQL errors with operation context', async () => {
mocks.setError('Not authorized');

const result = await callToolByNameRawAsync('list_workflows', {});

expect(result.content[0].text).toContain('Failed to list live workflows');
});
});
Loading