feat(core): Add detailLevel option to MCP get_workflow_details

Adds an optional detailLevel input ('full' | 'execution', default 'full')
to the get_workflow_details MCP tool. With 'execution', the response
omits the graph fields (nodes, connections, nodeGroups, activeVersion,
settings, meta) and returns only workflow metadata plus trigger info —
enough to run the workflow via execute_workflow at a fraction of the
response size. 'execution' also skips loading the active version
relation from the database.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW82a6VEyiYjRxtiz8VzzT
This commit is contained in:
Claude 2026-07-29 09:48:15 +00:00
parent 1f6cddd1ab
commit cd2bda5984
No known key found for this signature in database
4 changed files with 93 additions and 21 deletions

View File

@ -91,7 +91,7 @@ describe('get-workflow-details MCP tool', () => {
);
expect('pinData' in payload.workflow).toBe(false);
expect(payload.workflow.nodes.every((n) => !('credentials' in n))).toBe(true);
expect(payload.workflow.nodes?.every((n) => !('credentials' in n))).toBe(true);
expect(payload.triggerInfo).toContain('MOCK_TRIGGER_DETAILS');
expect(payload.workflow.versionId).toBe(workflow.versionId);
expect(payload.workflow.activeVersionId).toBe(workflow.activeVersionId);
@ -101,6 +101,51 @@ describe('get-workflow-details MCP tool', () => {
expect(payload.workflow.canExecute).toBe(true);
});
test("omits graph fields when detailLevel is 'execution'", async () => {
const workflow = createWorkflow({ activeVersionId: uuid() });
const findWorkflowForUser = vi.fn().mockResolvedValue(workflow);
const workflowFinderService = mockInstance(WorkflowFinderService, {
findWorkflowForUser,
});
const credentialsService = mockInstance(CredentialsService, {});
const endpoints = { webhook: 'webhook', webhookTest: 'webhook-test' };
const payload = await getWorkflowDetails(
user,
baseWebhookUrl,
workflowFinderService,
credentialsService,
nodeTypes,
endpoints,
roleService,
projectService,
{ workflowId: 'wf-1', detailLevel: 'execution' },
);
// Skips loading the active version relation entirely
expect(findWorkflowForUser).toHaveBeenCalledWith(
'wf-1',
user,
['workflow:read'],
expect.objectContaining({ includeActiveVersion: false }),
);
expect(payload.workflow.nodes).toBeUndefined();
expect(payload.workflow.connections).toBeUndefined();
expect(payload.workflow.nodeGroups).toBeUndefined();
expect(payload.workflow.activeVersion).toBeUndefined();
expect(payload.workflow.settings).toBeUndefined();
expect(payload.workflow.meta).toBeUndefined();
// Everything needed to execute is still present
expect(payload.triggerInfo).toContain('MOCK_TRIGGER_DETAILS');
expect(payload.workflow.id).toBe('wf-1');
expect(payload.workflow.active).toBe(true);
expect(payload.workflow.scopes).toEqual(['workflow:read', 'workflow:execute']);
expect(payload.workflow.canExecute).toBe(true);
expect(payload.workflow.description).toBeUndefined();
});
test('presents node groups by member node names, dropping stale ids', async () => {
const workflow = createWorkflow({
nodeGroups: [{ id: 'group-1', name: 'Intake', nodeIds: ['node-1', 'stale-id'] }],

View File

@ -70,7 +70,7 @@ export type SearchWorkflowsResult = {
export type WorkflowDetailsResult = z.infer<WorkflowDetailsOutputSchema>;
export type WorkflowDetailsWorkflow = WorkflowDetailsResult['workflow'];
export type WorkflowDetailsNode = WorkflowDetailsWorkflow['nodes'][number];
export type WorkflowDetailsNode = NonNullable<WorkflowDetailsWorkflow['nodes']>[number];
// JSON-RPC types for MCP protocol
export type JSONRPCRequest = {

View File

@ -20,8 +20,16 @@ import { getMcpWorkflow } from './workflow-validation.utils';
const inputSchema = {
workflowId: z.string().describe('The ID of the workflow to retrieve'),
detailLevel: z
.enum(['full', 'execution'])
.optional()
.describe(
"Level of detail to return. 'full' (default) includes the complete workflow graph (nodes, connections, node groups, active version). 'execution' omits the graph and returns only the workflow metadata and trigger information needed to run it — prefer it when the goal is just to execute the workflow via execute_workflow.",
),
} satisfies z.ZodRawShape;
export type WorkflowDetailsLevel = NonNullable<z.infer<typeof inputSchema.detailLevel>>;
export type WorkflowDetailsOutputSchema = typeof workflowDetailsOutputSchema;
const outputSchema = workflowDetailsOutputSchema.shape satisfies z.ZodRawShape;
@ -55,8 +63,14 @@ export const createWorkflowDetailsTool = (
openWorldHint: false, // Works with internal n8n data only
},
},
handler: async ({ workflowId }: { workflowId: string }) => {
const parameters = { workflowId };
handler: async ({
workflowId,
detailLevel = 'full',
}: {
workflowId: string;
detailLevel?: WorkflowDetailsLevel;
}) => {
const parameters = { workflowId, detailLevel };
const telemetryPayload: UserCalledMCPToolEventPayload = {
user_id: user.id,
tool_name: 'get_workflow_details',
@ -73,7 +87,7 @@ export const createWorkflowDetailsTool = (
endpoints,
roleService,
projectService,
{ workflowId },
{ workflowId, detailLevel },
testBaseWebhookUrl,
);
@ -84,7 +98,7 @@ export const createWorkflowDetailsTool = (
workflow_id: workflowId,
workflow_name: payload.workflow.name,
trigger_count: payload.workflow.triggerCount,
node_count: payload.workflow.nodes.length,
node_count: payload.workflow.nodes?.length,
},
};
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
@ -115,15 +129,16 @@ export async function getWorkflowDetails(
endpoints: WebhookEndpoints,
roleService: RoleService,
projectService: ProjectService,
{ workflowId }: { workflowId: string },
{ workflowId, detailLevel = 'full' }: { workflowId: string; detailLevel?: WorkflowDetailsLevel },
testBaseWebhookUrl: string = baseWebhookUrl,
): Promise<WorkflowDetailsResult> {
const includeGraph = detailLevel === 'full';
const workflow = await getMcpWorkflow(
workflowId,
user,
['workflow:read'],
workflowFinderService,
{ includeActiveVersion: true, includeTags: true },
{ includeActiveVersion: includeGraph, includeTags: true },
);
// Compute user scopes for this workflow
@ -135,7 +150,7 @@ export async function getWorkflowDetails(
const nodes = workflow.nodes ?? [];
const connections = workflow.connections ?? {};
const activeVersion =
workflow.activeVersionId && workflow.activeVersion
includeGraph && workflow.activeVersionId && workflow.activeVersion
? {
nodes: (workflow.activeVersion.nodes ?? []).map(
({ credentials: _credentials, ...node }) => node,
@ -174,17 +189,23 @@ export async function getWorkflowDetails(
triggerCount: workflow.triggerCount,
createdAt: workflow.createdAt.toISOString(),
updatedAt: workflow.updatedAt.toISOString(),
settings: workflow.settings ?? null,
connections,
nodes: nodes.map(({ credentials: _credentials, ...node }) => node),
nodeGroups: toNodeGroupSummary(workflow.nodeGroups ?? [], nodes),
activeVersion,
tags: toTagSummary(workflow.tags),
meta: workflow.meta ?? null,
parentFolderId: workflow.parentFolder?.id ?? null,
description: workflow.description ?? undefined,
scopes,
canExecute,
// The graph fields dominate the response size; skip them when the caller
// only needs enough to execute the workflow.
...(includeGraph
? {
settings: workflow.settings ?? null,
connections,
nodes: nodes.map(({ credentials: _credentials, ...node }) => node),
nodeGroups: toNodeGroupSummary(workflow.nodeGroups ?? [], nodes),
activeVersion,
meta: workflow.meta ?? null,
}
: {}),
};
return { workflow: sanitizedWorkflow, triggerInfo: triggerNotice };

View File

@ -112,6 +112,8 @@ export const toNodeGroupSummary = (
}));
};
const GRAPH_FIELD_OMISSION_NOTE = "Omitted when detailLevel is 'execution'.";
export const workflowDetailsOutputSchema = z.object({
workflow: z
.object({
@ -127,10 +129,13 @@ export const workflowDetailsOutputSchema = z.object({
triggerCount: z.number(),
createdAt: z.string().nullable(),
updatedAt: z.string().nullable(),
settings: workflowSettingsSchema,
connections: z.record(z.unknown()),
nodes: z.array(nodeSchema),
nodeGroups: z.array(nodeGroupSchema).describe('Node groups in the workflow'),
settings: workflowSettingsSchema.optional().describe(GRAPH_FIELD_OMISSION_NOTE),
connections: z.record(z.unknown()).optional().describe(GRAPH_FIELD_OMISSION_NOTE),
nodes: z.array(nodeSchema).optional().describe(GRAPH_FIELD_OMISSION_NOTE),
nodeGroups: z
.array(nodeGroupSchema)
.optional()
.describe(`Node groups in the workflow. ${GRAPH_FIELD_OMISSION_NOTE}`),
activeVersion: z
.object({
nodes: z.array(nodeSchema),
@ -138,9 +143,10 @@ export const workflowDetailsOutputSchema = z.object({
nodeGroups: z.array(nodeGroupSchema).describe('Node groups in the active version'),
})
.nullable()
.describe('Active workflow graph, if available'),
.optional()
.describe(`Active workflow graph, if available. ${GRAPH_FIELD_OMISSION_NOTE}`),
tags: z.array(tagSchema),
meta: workflowMetaSchema,
meta: workflowMetaSchema.optional().describe(GRAPH_FIELD_OMISSION_NOTE),
parentFolderId: z.string().nullable(),
description: z.string().optional().describe('The description of the workflow'),
scopes: z.array(z.string()).describe('User permissions for this workflow'),