Skip to content

Commit 1bd72d8

Browse files
outof-placeclaude
andcommitted
feat(worker): expose MCP workflow graph read and enabled toggle (AIW-286)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kfeohXE66xx7RJPxZ2pvH
1 parent a81c8c8 commit 1bd72d8

12 files changed

Lines changed: 518 additions & 11 deletions

apps/worker/src/mcp/contracts.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ describe("MCP public contracts", () => {
5656
"blocks.list",
5757
"blocks.get",
5858
"runs.stats",
59+
"workflows.get_graph",
60+
"workflows.set_enabled",
5961
]);
6062
expect(new Set(FIRST_SLICE_TOOLS).size).toBe(FIRST_SLICE_TOOLS.length);
6163
});

apps/worker/src/mcp/contracts.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,16 @@ export const FIRST_SLICE_TOOLS = [
8686
// per-run detail and nothing that rolled runs up, the same gap prompts.list
8787
// once closed for prompts.get.
8888
"runs.stats",
89+
// Appended last, again to keep the published order of everything before them
90+
// byte-identical. These two close the last gap in authoring a workflow from a
91+
// client: workflows.save_draft takes a WHOLE graph, but nothing returned an
92+
// existing definition's graph with its per-node configuration and the revision
93+
// tokens a save or a publish is gated on, so an agent could only edit a graph it
94+
// had itself just composed; and workflows.set_enabled is the definition's own
95+
// enable switch, the field workflows.publish inherits rather than sets, so an
96+
// agent could deploy a graph but never turn the definition on or off.
97+
"workflows.get_graph",
98+
"workflows.set_enabled",
8999
] as const;
90100
export type McpToolName = (typeof FIRST_SLICE_TOOLS)[number];
91101

apps/worker/src/mcp/contracts/mcp-contract.json

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"contractHash": "9d49bdaa24bf72887ec0477a779ab5f81f88e8805afd4806685b0ddb6b3561fe",
2+
"contractHash": "623636ee5d127b0d2b69418f8d1c25b23c5a009e140eca520f75ed646cb09ed3",
33
"errorCodes": [
44
"UNAUTHENTICATED",
55
"INSUFFICIENT_SCOPE",
@@ -915,6 +915,65 @@
915915
"idempotentHint": true,
916916
"openWorldHint": false
917917
}
918+
},
919+
{
920+
"name": "workflows.get_graph",
921+
"description": "Read a definition's workflow graph, in the exact `{schemaVersion, nodes, edges}` shape workflows.save_draft accepts, for BOTH the current draft and the deployed version. Every node carries its full `configuration`, `inputs` and `additionalInputs`, and the pinned `repositoryScope` rides along too, so a graph fetched here can be edited and sent straight back to workflows.save_draft without losing anything: saving the unmodified draft yields the same `graphHash` this tool reports for it in `draftGraphHash`. `draftRevision` is the token workflows.save_draft takes as `expectedDraftRevision` (0 for a definition that has never been saved, where `draft` is null), and `deployedVersion` is the token workflows.publish takes as `expectedDeployedVersion` (null when nothing is deployed yet, where `deployed` is null). `draftGraphHash` and `deployedGraphHash` are sha256 over the canonical JSON of each stored version, directly comparable with the `graphHash` workflows.save_draft and workflows.publish report for the same version. Any secret configured for this deployment is redacted from the reply exactly as everywhere else on this surface; a stored graph does not carry one, so that redaction leaves the round trip lossless. An unknown or archived definition is NOT_FOUND.",
922+
"inputSchema": {
923+
"type": "object",
924+
"properties": {
925+
"definitionId": {
926+
"type": "integer",
927+
"exclusiveMinimum": 0,
928+
"maximum": 2147483647
929+
}
930+
},
931+
"required": [
932+
"definitionId"
933+
],
934+
"additionalProperties": false,
935+
"$schema": "http://json-schema.org/draft-07/schema#"
936+
},
937+
"annotations": {
938+
"readOnlyHint": true,
939+
"destructiveHint": false,
940+
"idempotentHint": true,
941+
"openWorldHint": false
942+
}
943+
},
944+
{
945+
"name": "workflows.set_enabled",
946+
"description": "Turn a definition's `enabled` switch on or off, independent of publishing: this is the one field workflows.publish inherits rather than sets. Runs through exactly the dashboard's own guardrails. Enabling a definition with no deployable version is refused with CONFLICT, and enabling one whose deployed graph no longer passes the deployment gate with VALIDATION_FAILED. Enabling a definition whose trigger another enabled definition already owns is refused with CONFLICT naming that definition (for example, a second `trigger_ticket_ai` while one is already enabled), so two definitions cannot silently answer the same event. Enabling arms the deployed head's real-event triggers, minting webhook endpoints and syncing schedule rows, so from then on real ticket and pull request events execute this graph; disabling releases those bindings, so they stop. `enabled` in the reply is the resulting state and `triggerTypes` the triggers now (or no longer) live. Idempotent per idempotencyKey. A concurrent change to the definition is refused with CONFLICT: reload before retrying.",
947+
"inputSchema": {
948+
"type": "object",
949+
"properties": {
950+
"definitionId": {
951+
"type": "integer",
952+
"exclusiveMinimum": 0,
953+
"maximum": 2147483647
954+
},
955+
"enabled": {
956+
"type": "boolean"
957+
},
958+
"idempotencyKey": {
959+
"type": "string",
960+
"format": "uuid"
961+
}
962+
},
963+
"required": [
964+
"definitionId",
965+
"enabled",
966+
"idempotencyKey"
967+
],
968+
"additionalProperties": false,
969+
"$schema": "http://json-schema.org/draft-07/schema#"
970+
},
971+
"annotations": {
972+
"readOnlyHint": false,
973+
"destructiveHint": true,
974+
"idempotentHint": true,
975+
"openWorldHint": true
976+
}
918977
}
919978
]
920979
}

apps/worker/src/mcp/policy.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,30 @@ const DISPATCH_PREFLIGHT_POLICY = {
228228
roles: DISPATCH_POLICY.roles,
229229
} as const satisfies McpToolPolicy;
230230

231+
// Reading a whole authorable graph: every node's configuration, the pinned
232+
// repositories, and the revision tokens a save or a publish is gated on. It is the
233+
// read half of authoring, exactly what workflows.save_draft consumes, so it rides
234+
// workflows:write and its role list the way dispatch_preflight rides runs:dispatch
235+
// -- discovery that feeds a privileged action is gated behind that action's scope.
236+
// workflows.list stays mcp:read because naming what exists is coarse discovery; this
237+
// hands back the instruction itself, so it costs the authoring consent.
238+
const WORKFLOW_GRAPH_READ_POLICY = {
239+
...READ_POLICY,
240+
scope: WORKFLOW_WRITE_POLICY.scope,
241+
roles: WORKFLOW_WRITE_POLICY.roles,
242+
} as const satisfies McpToolPolicy;
243+
244+
// Flipping a definition's enable switch, the same switch the dashboard's toggle sets
245+
// and the one workflows.publish INHERITS rather than changes. Enabling arms the
246+
// deployed head's real-event triggers -- the store mints the webhook endpoints and
247+
// syncs the schedule rows of a live head -- so from that moment real ticket and pull
248+
// request events execute the graph, and disabling releases those bindings again. That
249+
// is the same destructive, open-world replacement of what the platform runs that a
250+
// publish is, so it takes the publish annotations, and it rides the same
251+
// workflows:write scope and admin/owner list: deciding what runs for real events is
252+
// the authoring authority, not the dispatch one.
253+
const WORKFLOW_SET_ENABLED_POLICY = WORKFLOW_PUBLISH_POLICY;
254+
231255
const TOOL_POLICY = {
232256
"system.capabilities": READ_POLICY,
233257
"tickets.get": READ_POLICY,
@@ -249,6 +273,11 @@ const TOOL_POLICY = {
249273
"workflows.create": WORKFLOW_WRITE_POLICY,
250274
"workflows.save_draft": WORKFLOW_WRITE_POLICY,
251275
"workflows.publish": WORKFLOW_PUBLISH_POLICY,
276+
// A read shaped like the authoring writes it feeds, and the enable switch those
277+
// writes inherit but never set. See the two policies above for why both ride
278+
// workflows:write rather than mcp:read or runs:dispatch.
279+
"workflows.get_graph": WORKFLOW_GRAPH_READ_POLICY,
280+
"workflows.set_enabled": WORKFLOW_SET_ENABLED_POLICY,
252281
// A plain read: seeing THAT a run is waiting and what it asked is what a
253282
// read-only client needs to report a stuck run to a person, and gating it behind
254283
// the dispatch scope would hide the question from the client most likely to be

apps/worker/src/mcp/server.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ const PUBLISHED: McpToolName[] = [
5555
"blocks.list",
5656
"blocks.get",
5757
"runs.stats",
58+
"workflows.get_graph",
59+
"workflows.set_enabled",
5860
];
5961

6062
const cleanups: Array<() => Promise<void>> = [];

apps/worker/src/mcp/server.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import { registerRunStatsTools } from "./tools/run-stats.js";
1414
import { registerRunTools } from "./tools/runs.js";
1515
import { registerTicketWriteTools } from "./tools/ticket-write.js";
1616
import { registerTicketTools } from "./tools/tickets.js";
17-
import { registerWorkflowAuthoringTools } from "./tools/workflow-authoring.js";
17+
import {
18+
registerWorkflowAuthoringTools,
19+
registerWorkflowGraphTools,
20+
} from "./tools/workflow-authoring.js";
1821
import { registerWorkflowTools } from "./tools/workflows.js";
1922

2023
export const MCP_PROTOCOL_VERSION = "2025-11-25" as const;
@@ -67,6 +70,9 @@ export function createMcpServer(deps: McpToolDependencies): McpServer {
6770
registerTicketWriteTools(server, deps);
6871
registerBlockTools(server, deps);
6972
registerRunStatsTools(server, deps);
73+
// Last, so workflows.get_graph and workflows.set_enabled enumerate at the end of
74+
// tools/list, matching their appended position in FIRST_SLICE_TOOLS.
75+
registerWorkflowGraphTools(server, deps);
7076

7177
return server;
7278
}

apps/worker/src/mcp/surface-e2e.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ const PUBLISHED = [
129129
"blocks.list",
130130
"blocks.get",
131131
"runs.stats",
132+
"workflows.get_graph",
133+
"workflows.set_enabled",
132134
];
133135

134136
const READ_ANNOTATIONS = {
@@ -231,6 +233,14 @@ const EXPECTED_ANNOTATIONS: Record<string, Record<string, boolean>> = {
231233
"blocks.list": READ_ANNOTATIONS,
232234
"blocks.get": READ_ANNOTATIONS,
233235
"runs.stats": READ_ANNOTATIONS,
236+
// Reading a whole authorable graph changes nothing, so it keeps the read
237+
// annotations even though it is gated behind workflows:write, exactly as
238+
// dispatch_preflight keeps them behind runs:dispatch.
239+
"workflows.get_graph": READ_ANNOTATIONS,
240+
// Flipping the enable switch arms or releases a live head's real-event triggers,
241+
// the same destructive, open-world change to what the platform runs that a publish
242+
// is.
243+
"workflows.set_enabled": WORKFLOW_PUBLISH_ANNOTATIONS,
234244
};
235245

236246
const DOMAINS = ["system", "tickets", "runs", "workflows", "prompts", "blocks"];

apps/worker/src/mcp/tool-catalog.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ const CATALOGUED = [
6363
"blocks.list",
6464
"blocks.get",
6565
"runs.stats",
66+
"workflows.get_graph",
67+
"workflows.set_enabled",
6668
] as const;
6769

6870
// Captured off the real McpServer, through the real createMcpServer, because the

apps/worker/src/mcp/tool-catalog.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,28 @@ export const MCP_TOOL_CATALOG = {
455455
.strict(),
456456
annotations: policyFor("runs.stats").annotations,
457457
},
458+
"workflows.get_graph": {
459+
description:
460+
"Read a definition's workflow graph, in the exact `{schemaVersion, nodes, edges}` shape workflows.save_draft accepts, for BOTH the current draft and the deployed version. Every node carries its full `configuration`, `inputs` and `additionalInputs`, and the pinned `repositoryScope` rides along too, so a graph fetched here can be edited and sent straight back to workflows.save_draft without losing anything: saving the unmodified draft yields the same `graphHash` this tool reports for it in `draftGraphHash`. `draftRevision` is the token workflows.save_draft takes as `expectedDraftRevision` (0 for a definition that has never been saved, where `draft` is null), and `deployedVersion` is the token workflows.publish takes as `expectedDeployedVersion` (null when nothing is deployed yet, where `deployed` is null). `draftGraphHash` and `deployedGraphHash` are sha256 over the canonical JSON of each stored version, directly comparable with the `graphHash` workflows.save_draft and workflows.publish report for the same version. Any secret configured for this deployment is redacted from the reply exactly as everywhere else on this surface; a stored graph does not carry one, so that redaction leaves the round trip lossless. An unknown or archived definition is NOT_FOUND.",
461+
inputSchema: z
462+
.object({
463+
definitionId: z.number().int().positive().max(DEFINITION_ID_MAX),
464+
})
465+
.strict(),
466+
annotations: policyFor("workflows.get_graph").annotations,
467+
},
468+
"workflows.set_enabled": {
469+
description:
470+
"Turn a definition's `enabled` switch on or off, independent of publishing: this is the one field workflows.publish inherits rather than sets. Runs through exactly the dashboard's own guardrails. Enabling a definition with no deployable version is refused with CONFLICT, and enabling one whose deployed graph no longer passes the deployment gate with VALIDATION_FAILED. Enabling a definition whose trigger another enabled definition already owns is refused with CONFLICT naming that definition (for example, a second `trigger_ticket_ai` while one is already enabled), so two definitions cannot silently answer the same event. Enabling arms the deployed head's real-event triggers, minting webhook endpoints and syncing schedule rows, so from then on real ticket and pull request events execute this graph; disabling releases those bindings, so they stop. `enabled` in the reply is the resulting state and `triggerTypes` the triggers now (or no longer) live. Idempotent per idempotencyKey. A concurrent change to the definition is refused with CONFLICT: reload before retrying.",
471+
inputSchema: z
472+
.object({
473+
definitionId: z.number().int().positive().max(DEFINITION_ID_MAX),
474+
enabled: z.boolean(),
475+
idempotencyKey: z.string().uuid(),
476+
})
477+
.strict(),
478+
annotations: policyFor("workflows.set_enabled").annotations,
479+
},
458480
} satisfies Record<McpToolName, McpToolDefinition>;
459481

460482
export const MCP_ENABLED_DOMAINS = [

0 commit comments

Comments
 (0)