@allocator-one/infraone 0.0.0-stage → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1080 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ ACTIVITY_MAX_LIMIT,
4
+ BUDGET_MAX_DOLLARS,
5
+ CONDITION_KINDS,
6
+ GATE_OWNER_ROLES,
7
+ IDEMPOTENCY_KEY_FORMAT,
8
+ JOBS_MAX_LIMIT,
9
+ JOB_STATES,
10
+ JOB_STATUS_ALIASES,
11
+ MAX_IDEMPOTENCY_KEY_BYTES,
12
+ MCP_SURFACE,
13
+ OUTCOME_MAX_CHARACTERS,
14
+ PLAN_EXECUTORS,
15
+ PLAN_LIST_MAX_LIMIT,
16
+ PLAN_STATUSES,
17
+ RELATION_CREATE_TYPES,
18
+ RELATION_UPDATE_TYPES,
19
+ STEER_MAX_CHARACTERS,
20
+ activity,
21
+ archive,
22
+ assignments,
23
+ blocked,
24
+ close,
25
+ comment,
26
+ comments,
27
+ conditionList,
28
+ create,
29
+ deleteComment,
30
+ dep,
31
+ docCreate,
32
+ docList,
33
+ docShow,
34
+ docUpdate,
35
+ editComment,
36
+ gateSet,
37
+ jobCancel,
38
+ jobContinue,
39
+ jobPause,
40
+ jobShow,
41
+ jobSteer,
42
+ jobsList,
43
+ labelCreate,
44
+ labelDelete,
45
+ labelList,
46
+ labelUpdate,
47
+ linkAdd,
48
+ linkList,
49
+ linkRm,
50
+ linkUpdate,
51
+ list,
52
+ memberAdd,
53
+ memberList,
54
+ memberSetRole,
55
+ milestoneCreate,
56
+ milestoneList,
57
+ planCreate,
58
+ planEdit,
59
+ planHold,
60
+ planList,
61
+ planRelease,
62
+ planShow,
63
+ prepareRun,
64
+ procedureRun,
65
+ procedureSave,
66
+ procedureShow,
67
+ projectCreate,
68
+ projectList,
69
+ projectShow,
70
+ projectUpdate,
71
+ ready,
72
+ relationAdd,
73
+ relationList,
74
+ relationRm,
75
+ relationUpdate,
76
+ search,
77
+ show,
78
+ skillsList,
79
+ startRun,
80
+ stateAdd,
81
+ stateDelete,
82
+ stateOrder,
83
+ stateUpdate,
84
+ stats,
85
+ teamCreate,
86
+ teamList,
87
+ teamRename,
88
+ teamSetDefault,
89
+ toolsList,
90
+ toolsSearch,
91
+ update,
92
+ upload,
93
+ userList,
94
+ workflowStates
95
+ } from "./chunk-CEEQW6ZV.js";
96
+ import {
97
+ Client,
98
+ HttpError,
99
+ TransportError,
100
+ UsageError,
101
+ errorDetails,
102
+ explainExpertRefusal,
103
+ resolveConfig
104
+ } from "./chunk-BHBEIT5I.js";
105
+ import {
106
+ CLI_VERSION,
107
+ invokedName,
108
+ named
109
+ } from "./chunk-5THYIKLW.js";
110
+
111
+ // src/mcp/server.ts
112
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
113
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
114
+ import { z } from "zod";
115
+ var NeedsLoginError = class extends Error {
116
+ };
117
+ function context() {
118
+ const resolved = resolveConfig();
119
+ for (const warning of resolved.warnings) process.stderr.write(named(warning + "\n"));
120
+ if (resolved.kind === "error") throw new NeedsLoginError(resolved.message);
121
+ if (resolved.kind === "needs-login") {
122
+ throw new NeedsLoginError(
123
+ `Not signed in to ${resolved.baseUrl}${resolved.org === void 0 ? "" : ` for organization ${resolved.org}`}. Run \`${invokedName()} login\` in a terminal (or set HARNESS_API_TOKEN for headless use) \u2014 the MCP server never opens a browser itself.`
124
+ );
125
+ }
126
+ return { client: new Client(resolved.config), config: resolved.config };
127
+ }
128
+ function toolError(message) {
129
+ return { content: [{ type: "text", text: message }], isError: true };
130
+ }
131
+ async function run(work) {
132
+ let token = "";
133
+ try {
134
+ const ctx = context();
135
+ token = ctx.config.token;
136
+ const output = await work(ctx);
137
+ const content = [{ type: "text", text: JSON.stringify(output.json) }];
138
+ if (output.notice !== void 0 && output.notice !== "") {
139
+ content.push({ type: "text", text: named(output.notice.trimEnd()) });
140
+ }
141
+ if (output.partial === true) {
142
+ content.push({ type: "text", text: "partial failure \u2014 inspect the JSON entries for per-item errors" });
143
+ return { content, isError: true };
144
+ }
145
+ return { content };
146
+ } catch (caught) {
147
+ const error = explainExpertRefusal(caught, token);
148
+ if (error instanceof NeedsLoginError) return toolError(error.message);
149
+ if (error instanceof HttpError) {
150
+ return toolError(named(`${error.message} (HTTP ${error.status})${errorDetails(error.body)}`));
151
+ }
152
+ if (error instanceof TransportError) return toolError(`cannot reach the API: ${error.message}`);
153
+ if (error instanceof Error) return toolError(named(error.message));
154
+ throw error;
155
+ }
156
+ }
157
+ var page = {
158
+ limit: z.number().int().positive().optional().describe("page size (server may cap it)"),
159
+ page: z.number().int().positive().optional().describe("page number, 1-based"),
160
+ all: z.boolean().optional().describe("follow every page")
161
+ };
162
+ var identifier = z.string().describe("issue identifier, e.g. IO-123");
163
+ var commentId = z.string().uuid().describe("comment UUID from issue_show or comment_list");
164
+ var dateField = z.string().optional().describe('YYYY-MM-DD; "" clears the date');
165
+ var planCondition = z.object({
166
+ kind: z.enum(CONDITION_KINDS).optional().describe("not_before, gate or event; optional with gate"),
167
+ not_before: z.string().optional().describe("not_before: an ISO 8601 UTC instant"),
168
+ gate: z.string().optional().describe("shorthand for kind gate with this key; the label defaults to it, e.g. finance-sign-off"),
169
+ key: z.string().optional().describe("gate: a stable lowercase slug (a-z, 0-9 and -), unique among the step's gates; gate_set takes it"),
170
+ label: z.string().optional().describe("gate: what a person ticks, e.g. Finance sign-off; defaults to the key"),
171
+ owner: z.string().nullable().optional().describe(
172
+ `gate: who ticks it. role:<role> (${GATE_OWNER_ROLES.join(", ")}), user:<id or email> (a member who can use Operator) or expert:<email> (an outside expert, never a member's email). Only the owner ticks an owned gate, and nobody names an owner they would hold themselves (owner_is_actor). null or absent: no owner`
173
+ ),
174
+ event: z.string().optional().describe("event: deal_stage_changed, opportunity_stage_changed, form_submitted or investment_executed"),
175
+ subject_id: z.string().optional().describe("event: the record the event is about (one you can read)"),
176
+ target_id: z.string().optional().describe("event: the stage or status a stage change must reach")
177
+ }).passthrough();
178
+ var planIssueFields = {
179
+ title: z.string(),
180
+ description: z.string().optional().describe("an Operator step's description is its instruction (required for one)"),
181
+ design: z.string().optional(),
182
+ notes: z.string().optional(),
183
+ acceptance_criteria: z.string().optional(),
184
+ priority: z.number().int().optional().describe("0 none, 1 urgent, 2 high, 3 medium, 4 low"),
185
+ estimate: z.number().optional(),
186
+ due_date: z.string().optional().describe("YYYY-MM-DD"),
187
+ assignee: z.string().optional().describe("email or user id; never on an Operator step"),
188
+ label_ids: z.array(z.string()).optional()
189
+ };
190
+ var planStep = z.object({
191
+ ...planIssueFields,
192
+ key: z.string().describe("unique in the plan; other steps refer to this step by it"),
193
+ parent: z.string().optional().describe("key of the step that contains this one; the root when absent"),
194
+ blocked_by: z.array(z.string()).optional().describe("keys of the steps this one waits for"),
195
+ executor: z.enum(PLAN_EXECUTORS).optional().describe("none (a person, the default) or operator"),
196
+ team: z.string().optional().describe("team key for a person's step; Operator steps go to the Operator team"),
197
+ conditions: z.array(planCondition).optional().describe("wait conditions: all must hold before the step is ready")
198
+ }).passthrough();
199
+ var planDocument = z.object({
200
+ ...planIssueFields,
201
+ parent: z.string().optional().describe("an existing issue to file the root under, e.g. IO-12"),
202
+ retry_budget: z.number().int().min(0).optional().describe("retries per failed Operator step (default 2)"),
203
+ spend_budget_cents: z.number().int().min(0).nullable().optional().describe("needed to release a plan with Operator steps"),
204
+ steps: z.array(planStep).min(1).describe(
205
+ "with the root, at most the organization's max plan steps (default 1000, at most 2000), and at most 100 Operator steps"
206
+ )
207
+ }).passthrough();
208
+ var planId = z.string().describe("the plan's UUID, or its root issue's identifier (e.g. FR-1, any casing): plan_list or issue_show's plan.root");
209
+ var planStepEdit = z.object({
210
+ step: z.string().optional().describe("the step's key; give this or issue"),
211
+ issue: z.string().optional().describe("the step's issue identifier; give this or step"),
212
+ executor: z.enum(PLAN_EXECUTORS).optional().describe("none hands the step to a person"),
213
+ key: z.string().nullable().optional().describe("the step's new key")
214
+ }).passthrough();
215
+ var idempotencyKey = z.string().min(1).max(MAX_IDEMPOTENCY_KEY_BYTES).regex(IDEMPOTENCY_KEY_FORMAT, "must use printable ASCII without leading or trailing spaces");
216
+ var OPERATOR_APPROVAL_NOTE = "Approval happens in the app: give a person the approval_url (pending_approval.url on job_show); no tool approves a change set. By reference only, never the objective's text.";
217
+ var OPERATOR_JOB_TOOLS = [
218
+ {
219
+ name: "jobs_list",
220
+ description: "List the Operator jobs the caller sees in the workbench, newest first: state, waiting_on (approval, person, reconciliation, verification or delivery), objective_kind, owner, plan step, window, and the workbench url and approval_url. " + OPERATOR_APPROVAL_NOTE + " Pages by cursor; all follows every page, and a second text item says when older jobs exist.",
221
+ readOnly: true,
222
+ shape: {
223
+ status: z.array(z.enum([...JOB_STATES, ...JOB_STATUS_ALIASES])).min(1).optional().describe("job states, or the aliases open (not done), waiting and done"),
224
+ mine: z.boolean().optional().describe("only jobs the caller owns"),
225
+ limit: z.number().int().min(1).max(JOBS_MAX_LIMIT).optional().describe("jobs per page (default 20)"),
226
+ all: z.boolean().optional().describe("follow every page")
227
+ },
228
+ handler: (ctx, args) => jobsList(ctx, {
229
+ status: args["status"] === void 0 ? void 0 : args["status"].join(","),
230
+ mine: args["mine"],
231
+ limit: args["limit"],
232
+ all: args["all"]
233
+ })
234
+ },
235
+ {
236
+ name: "job_show",
237
+ description: "One Operator job by reference: state and waiting_on, plan version, progress (stage, situation, needs_viewer), window (remaining and budget, in ms), spend in cents, blockers, pending_approval with its url, grant, verification status, effect counts, artifact links, pinned skill identity, people, saved procedures and viewer_can. " + OPERATOR_APPROVAL_NOTE + " Poll it to follow a job: it has ended when state is completed, failed or cancelled, and a person has to act exactly when waiting_on is set; a job paused with no waiting_on resumes by itself.",
238
+ readOnly: true,
239
+ shape: { id: z.string().uuid().describe("Operator job UUID, from jobs_list") },
240
+ handler: (ctx, args) => jobShow(ctx, args["id"])
241
+ }
242
+ ];
243
+ var OPERATOR_CONTROL_NOTE = "Not idempotent: never retry it on your own; when an error says the outcome is unknown (a timeout, a dropped connection or a 5xx), read the job with job_show before trying again. Approving stays in the app: no tool approves a change set. Returns the job as job_show does, by reference only.";
244
+ var jobId = z.string().uuid().describe("Operator job UUID, from jobs_list");
245
+ var OPERATOR_CONTROL_TOOLS = [
246
+ {
247
+ name: "job_steer",
248
+ description: "Steer an Operator job as Steer does in the app: posts the instruction in the job's room, attributed to the caller. The job's owner and collaborators may steer, wherever the app offers it (not a finished, failed or awaiting-approval job). Work the instruction changes needs a fresh approval: earlier approvals stop applying to it. Steering a running job stops its current run so it can take the instruction. " + OPERATOR_CONTROL_NOTE,
249
+ readOnly: false,
250
+ idempotent: false,
251
+ shape: {
252
+ id: jobId,
253
+ instruction: z.string().min(1).describe(
254
+ `what to change, in plain language: 1 to ${STEER_MAX_CHARACTERS} characters once trimmed`
255
+ )
256
+ },
257
+ handler: (ctx, args) => jobSteer(ctx, args["id"], args["instruction"])
258
+ },
259
+ {
260
+ name: "job_cancel",
261
+ description: "Cancel an Operator job as Cancel does in the app; only the job's execution principal may. It cannot be undone, but committed effects stay committed: the job's effects still count them, and staged ones that never ran count as unknown. " + OPERATOR_CONTROL_NOTE,
262
+ readOnly: false,
263
+ destructive: true,
264
+ idempotent: false,
265
+ shape: { id: jobId },
266
+ handler: (ctx, args) => jobCancel(ctx, args["id"])
267
+ },
268
+ {
269
+ name: "job_pause",
270
+ description: "Pause a running Operator job as Pause does in the app; only the job's execution principal may. Holds it for a person without resetting its active-time budget; continuing it runs it again. " + OPERATOR_CONTROL_NOTE,
271
+ readOnly: false,
272
+ idempotent: false,
273
+ shape: { id: jobId },
274
+ handler: (ctx, args) => jobPause(ctx, args["id"])
275
+ }
276
+ ];
277
+ var OPERATOR_RUN_TOOLS = [
278
+ {
279
+ name: "job_start",
280
+ description: `Start an Operator job from an outcome, as Run as job does in Chat Mode in the app: the request is posted, attributed to the caller, in a new organization chat of theirs, and Operator takes it from there under the caller's own current rights. Returns job_id, state, workbench_url and, when a change set already awaits approval, approval_url. Approval happens in the app: give a person the approval_url; this tool never approves anything, and no tool does. Each call starts another job, so never call it again to "retry": not after a timeout, a dropped connection or a 5xx either. An "Outcome unknown" error means a job may have been started; look for it with jobs_list (mine) before calling again. Only the organization's admins may pass a budget. Follow the job with job_show.`,
281
+ readOnly: false,
282
+ destructive: false,
283
+ idempotent: false,
284
+ shape: {
285
+ outcome: z.string().describe(
286
+ `what the job should achieve, in plain language: 1 to ${OUTCOME_MAX_CHARACTERS} characters once trimmed`
287
+ ),
288
+ budget: z.number().positive().max(BUDGET_MAX_DOLLARS).optional().describe(
289
+ `the job's budget in US dollars, at most two decimals, up to ${BUDGET_MAX_DOLLARS}; only the organization's admins may pass one`
290
+ ),
291
+ skill: z.string().optional().describe("an Operator skill's invocation name, like /summarize-lps (skills_list names them)")
292
+ },
293
+ handler: async (ctx, args) => {
294
+ const request = prepareRun(args["outcome"], {
295
+ budget: args["budget"] === void 0 ? void 0 : String(args["budget"]),
296
+ skill: args["skill"]
297
+ }, MCP_SURFACE);
298
+ const job = await startRun(ctx, request, MCP_SURFACE);
299
+ return {
300
+ json: {
301
+ job_id: job.job_id,
302
+ state: job.state,
303
+ workbench_url: job.workbench_url,
304
+ ...job.approval_url === null ? {} : { approval_url: job.approval_url }
305
+ },
306
+ human: `Started Operator job ${job.job_id}.`
307
+ };
308
+ }
309
+ }
310
+ ];
311
+ var NOT_IDEMPOTENT = 'Not idempotent: never retry it automatically, not after a timeout or a 5xx either. An "Outcome unknown" error may have landed and says what to check before calling again; only "cannot reach the API" means the request never reached the server, so nothing changed.';
312
+ var OPERATOR_LIFECYCLE_TOOLS = [
313
+ {
314
+ name: "job_continue",
315
+ description: "Continue an Operator job, as Continue does in the app: opens a new audited window for a job that ran out of time or waits on a person, or retries a failed run or verification. Only the job's execution principal may. Returns the job as job_show does. Approval still happens in the app, at its approval_url. " + NOT_IDEMPOTENT,
316
+ readOnly: false,
317
+ destructive: false,
318
+ idempotent: false,
319
+ shape: { id: z.string().uuid().describe("Operator job UUID, from jobs_list") },
320
+ handler: (ctx, args) => jobContinue(ctx, args["id"])
321
+ },
322
+ {
323
+ name: "job_changes",
324
+ description: "An Operator job's latest change set by reference: its manifest id, version, status, digest, plan version, maximum effect count, and each action's type, version, order, effect count, required capability ids and digest; never values or targets. change_set is null while the job has proposed nothing. Beside it, the job's state, waiting_on, workbench url and approval_url: review and approve in the app, at that link.",
325
+ readOnly: true,
326
+ shape: { id: z.string().uuid().describe("Operator job UUID, from jobs_list") },
327
+ handler: (ctx, args) => jobShow(ctx, args["id"], { changes: true })
328
+ },
329
+ {
330
+ name: "procedure_save",
331
+ description: "Save a completed Operator job whose work was verified as a versioned procedure, as Save procedure does in the app. Only the job's owner can. Returns the procedure as procedure_show does, or only its id and url when it can't be read back. Saving again saves another procedure. " + NOT_IDEMPOTENT,
332
+ readOnly: false,
333
+ destructive: false,
334
+ idempotent: false,
335
+ shape: {
336
+ job_id: z.string().uuid().describe("Operator job UUID, from jobs_list"),
337
+ name: z.string().trim().min(1).describe("the procedure's name; trimmed and cut to 200 characters")
338
+ },
339
+ handler: (ctx, args) => procedureSave(ctx, args["job_id"], args["name"])
340
+ },
341
+ {
342
+ name: "procedure_show",
343
+ description: "One saved Operator procedure: its name, source job_id and job_url (the app), current_version, each version's recipe_digest, capability_ids and action_roster, and the parameter_schema a run of the current version takes (scoped_inputs.required naming contact_reconciliation means a run needs group_id and source_resource_id). Never a version's recipe or anything the saved job read.",
344
+ readOnly: true,
345
+ shape: { id: z.string().uuid().describe("Operator procedure UUID, from job_show's procedures") },
346
+ handler: (ctx, args) => procedureShow(ctx, args["id"])
347
+ },
348
+ {
349
+ name: "procedure_run",
350
+ description: "Run a saved Operator procedure as a new job, as Rerun does in the app: fresh inputs, the caller's own current rights, and a change set that needs its own approval in the app. Nothing of the saved job carries over. Returns the new job as job_show does, or only its id and url when it can't be read back; give a person its approval_url. " + NOT_IDEMPOTENT,
351
+ readOnly: false,
352
+ destructive: false,
353
+ idempotent: false,
354
+ shape: {
355
+ id: z.string().uuid().describe("Operator procedure UUID"),
356
+ version: z.number().int().min(1).optional().describe("the version expected to be current; the run is refused if it no longer is (default: the current one)"),
357
+ parameters: z.record(z.unknown()).optional().describe("values for the procedure's parameter_schema (procedure_show), default {}"),
358
+ group_id: z.string().min(1).optional().describe("the contact group to reconcile, when scoped_inputs require it"),
359
+ source_resource_id: z.string().min(1).optional().describe("the uploaded file to reconcile against, when scoped_inputs require it")
360
+ },
361
+ handler: (ctx, args) => procedureRun(ctx, args["id"], {
362
+ version: args["version"],
363
+ parameters: args["parameters"],
364
+ group: args["group_id"],
365
+ source: args["source_resource_id"]
366
+ })
367
+ }
368
+ ];
369
+ var TOOLS = [
370
+ // ── Issues ──
371
+ {
372
+ name: "issue_list",
373
+ description: "List issues with filters. ready=true \u2192 unblocked open issues, each with agent_step (true when it is filed in the Operator team, where plan steps Operator runs live: only the plan driver claims those, and a claim refusal is the authority); blocked=true \u2192 issues waiting on a blocker.",
374
+ readOnly: true,
375
+ shape: {
376
+ ...page,
377
+ ready: z.boolean().optional(),
378
+ blocked: z.boolean().optional(),
379
+ status: z.enum(["open", "in_progress", "closed"]).optional(),
380
+ assignee: z.string().optional().describe('me, a user id, an email, or a full name; "" for unassigned'),
381
+ created_by: z.string().optional().describe("me or a creator user id"),
382
+ involved: z.literal("me").optional().describe("issues created by, assigned to, or mentioning the caller"),
383
+ priority: z.enum(["urgent", "high", "medium", "low", "none"]).optional(),
384
+ label: z.string().optional(),
385
+ type: z.enum(["task", "bug", "feature", "epic"]).optional(),
386
+ search: z.string().optional(),
387
+ team: z.string().optional(),
388
+ parent: z.string().optional().describe("list children of this epic"),
389
+ scope: z.enum(["all", "archived"]).optional()
390
+ },
391
+ handler: (ctx, args) => {
392
+ if (args["ready"] === true && args["blocked"] === true) {
393
+ throw new UsageError("ready and blocked are mutually exclusive \u2014 pass one, not both");
394
+ }
395
+ const opts = { ...args, createdBy: args["created_by"] };
396
+ if (args["ready"] === true) return ready(ctx, opts);
397
+ if (args["blocked"] === true) return blocked(ctx, opts);
398
+ return list(ctx, opts);
399
+ }
400
+ },
401
+ {
402
+ name: "issue_search",
403
+ description: "Search issues by meaning: ranked results across a lexical and a semantic signal, so describing a problem in your own words finds issues that share none of them. Pass the question as asked \u2014 plain language works better than keywords. An exact identifier always ranks first. Returns at most 20 results, no pagination; use issue_list with search for paginated lexical filtering.",
404
+ readOnly: true,
405
+ shape: {
406
+ query: z.string().describe("the question or phrase to search for"),
407
+ limit: z.number().int().optional().describe("maximum results (default 10, capped at 20)")
408
+ },
409
+ handler: (ctx, args) => {
410
+ const limit = args["limit"] === void 0 ? {} : { limit: args["limit"] };
411
+ return search(ctx, [String(args["query"])], limit);
412
+ }
413
+ },
414
+ {
415
+ name: "issue_show",
416
+ description: "Full issue detail: fields, relations, children, comments. blockers gives each unresolved blocker's state_type and archived flag: only a completed blocker resolves, so a cancelled or archived one still blocks. waiting_on_condition and conditions say which wait conditions (not_before, gate, event) still hold it; tick a gate with gate_set. plan is the plan governing the issue (the nearest rooted at it or an ancestor), or null: its id, root identifier (which the plan tools take), status, held, the revision number it runs, in_revision (whether that revision lists the issue: an Operator step outside it waits for plan_release, a person's step does not) and the issue's executor. images=true downloads attached images to the local cache and includes their paths as local_images.",
417
+ readOnly: true,
418
+ shape: { id: identifier, images: z.boolean().optional() },
419
+ handler: (ctx, args) => show(ctx, args["id"], { images: args["images"] === true })
420
+ },
421
+ {
422
+ name: "activity_list",
423
+ description: "Mixed issue activity: comments, creation, state and assignee changes, wait-condition events, and field changes (priority, labels, parent, title, description, archived) with from/to values. actor_label, when set, is the agent or CLI label a write was made under. Returns the newest page unless all=true.",
424
+ readOnly: true,
425
+ shape: {
426
+ id: identifier,
427
+ limit: z.number().int().min(1).max(ACTIVITY_MAX_LIMIT).optional().describe(`entries per page (default 20, max ${ACTIVITY_MAX_LIMIT})`),
428
+ all: z.boolean().optional().describe("follow cursors through all older activity")
429
+ },
430
+ handler: (ctx, args) => activity(ctx, args["id"], args)
431
+ },
432
+ {
433
+ name: "issue_create",
434
+ description: "Create an issue. type becomes a type/ label.",
435
+ readOnly: false,
436
+ shape: {
437
+ title: z.string(),
438
+ team: z.string().optional().describe("team key; defaults to HARNESS_TEAM"),
439
+ description: z.string().optional(),
440
+ design: z.string().optional(),
441
+ notes: z.string().optional(),
442
+ acceptance_criteria: z.string().optional(),
443
+ type: z.enum(["task", "bug", "feature", "epic"]).optional(),
444
+ priority: z.enum(["urgent", "high", "medium", "low", "none"]).optional(),
445
+ due_date: dateField,
446
+ parent: z.string().optional(),
447
+ assignee: z.string().optional(),
448
+ labels: z.array(z.string()).optional(),
449
+ idempotency_key: idempotencyKey.optional().describe("reuse the same printable-ASCII key when deliberately retrying this create")
450
+ },
451
+ handler: (ctx, args) => create(ctx, {
452
+ ...args,
453
+ acceptance: args["acceptance_criteria"],
454
+ dueDate: args["due_date"],
455
+ label: args["labels"],
456
+ idempotencyKey: args["idempotency_key"]
457
+ })
458
+ },
459
+ {
460
+ name: "issue_update",
461
+ description: "Update issue fields or workflow state. To take an issue, use issue_claim instead of assignee.",
462
+ readOnly: false,
463
+ shape: {
464
+ id: identifier,
465
+ title: z.string().optional(),
466
+ description: z.string().optional(),
467
+ design: z.string().optional(),
468
+ notes: z.string().optional().describe("replaces the notes"),
469
+ append_notes: z.string().optional().describe("appends to the notes"),
470
+ acceptance_criteria: z.string().optional(),
471
+ priority: z.enum(["urgent", "high", "medium", "low", "none"]).optional(),
472
+ due_date: dateField,
473
+ state: z.string().optional().describe("workflow state by name or type"),
474
+ parent: z.string().optional(),
475
+ assignee: z.string().optional().describe('"" unassigns'),
476
+ add_labels: z.array(z.string()).optional().describe("label names or ids to add without replacing existing labels"),
477
+ remove_labels: z.array(z.string()).optional().describe("attached label names or ids to remove without replacing others")
478
+ },
479
+ handler: (ctx, args) => update(ctx, args["id"], {
480
+ ...args,
481
+ acceptance: args["acceptance_criteria"],
482
+ appendNotes: args["append_notes"],
483
+ dueDate: args["due_date"],
484
+ addLabel: args["add_labels"],
485
+ removeLabel: args["remove_labels"]
486
+ })
487
+ },
488
+ {
489
+ name: "issue_claim",
490
+ description: "Atomically claim an issue: assigns it and moves it to the team's first started state. A conflict names its reason: someone else holds it, or it is not claimable (e.g. agent_step, waiting_on_condition). A refusal that comes from a plan (agent_step, plan_not_released, not_in_plan_revision, edited_since_release) names the plan by its root: the release that fixes it when it is the one plan governing the issue (plan_release takes the root), the nearest plan to look at when several do. A person never claims an agent_step, an Operator step.",
491
+ readOnly: false,
492
+ shape: { id: identifier },
493
+ handler: (ctx, args) => update(ctx, args["id"], { claim: true })
494
+ },
495
+ {
496
+ name: "issue_close",
497
+ description: "Close issues. Set duplicate_of for one source issue to close it as a duplicate; it cannot be combined with cancel. The result lists newly unblocked issues \u2014 surface them. still_blocking lists the dependents a cancelled or duplicate-closed issue still blocks: only completing releases them.",
498
+ readOnly: false,
499
+ shape: {
500
+ ids: z.array(identifier).min(1),
501
+ reason: z.string().optional().describe("why it is done, e.g. the commit hash"),
502
+ cancel: z.boolean().optional().describe("close as canceled instead of completed; incompatible with duplicate_of"),
503
+ duplicate_of: identifier.optional().describe("canonical issue; requires exactly one source id and is incompatible with cancel")
504
+ },
505
+ handler: (ctx, args) => close(ctx, args["ids"], {
506
+ reason: args["reason"],
507
+ cancel: args["cancel"],
508
+ duplicateOf: args["duplicate_of"]
509
+ })
510
+ },
511
+ {
512
+ name: "issue_comment",
513
+ description: "Comment on an issue. Mentions resolve server-side and notify the mentioned people.",
514
+ readOnly: false,
515
+ shape: {
516
+ id: identifier,
517
+ body: z.string(),
518
+ mentions: z.array(z.string()).optional()
519
+ },
520
+ handler: (ctx, args) => comment(ctx, args["id"], [args["body"]], args["mentions"] ?? [])
521
+ },
522
+ {
523
+ name: "issue_comment_edit",
524
+ description: "Edit one of your issue comments within its edit window.",
525
+ readOnly: false,
526
+ shape: {
527
+ id: identifier,
528
+ comment_id: commentId,
529
+ body: z.string().min(1).refine((body) => body.trim() !== "", "comment body must not be blank")
530
+ },
531
+ handler: (ctx, args) => editComment(ctx, args["id"], args["comment_id"], [args["body"]])
532
+ },
533
+ {
534
+ name: "issue_comment_delete",
535
+ description: "Delete one of your issue comments.",
536
+ readOnly: false,
537
+ shape: {
538
+ id: identifier,
539
+ comment_id: commentId
540
+ },
541
+ handler: (ctx, args) => deleteComment(ctx, args["id"], args["comment_id"])
542
+ },
543
+ {
544
+ name: "issue_archive",
545
+ description: "Archive issues (or restore them with restore=true). Archived issues show up under scope=archived.",
546
+ readOnly: false,
547
+ shape: { ids: z.array(identifier).min(1), restore: z.boolean().optional() },
548
+ handler: (ctx, args) => archive(ctx, args["ids"], { restore: args["restore"] === true })
549
+ },
550
+ {
551
+ name: "comment_list",
552
+ description: "An issue's complete comment thread, oldest first. Follows every older page; pagination failures return an error, not a partial thread.",
553
+ readOnly: true,
554
+ shape: { id: identifier },
555
+ handler: (ctx, args) => comments(ctx, args["id"])
556
+ },
557
+ // ── Relations ──
558
+ {
559
+ name: "relation_add",
560
+ description: "Add a blocking dependency: blocker blocks issue.",
561
+ readOnly: false,
562
+ shape: { issue: identifier, blocker: identifier },
563
+ handler: (ctx, args) => dep(ctx, "add", args["issue"], args["blocker"])
564
+ },
565
+ {
566
+ name: "relation_rm",
567
+ description: "Remove a blocking dependency.",
568
+ readOnly: false,
569
+ shape: { issue: identifier, blocker: identifier },
570
+ handler: (ctx, args) => dep(ctx, "rm", args["issue"], args["blocker"])
571
+ },
572
+ {
573
+ name: "relation_list",
574
+ description: "List every typed relation on an issue, including stable relation ids.",
575
+ readOnly: true,
576
+ shape: { issue: identifier },
577
+ handler: (ctx, args) => relationList(ctx, args["issue"])
578
+ },
579
+ {
580
+ name: "relation_create",
581
+ description: "Create a typed relation between two issues.",
582
+ readOnly: false,
583
+ shape: {
584
+ issue: identifier,
585
+ related_issue: identifier,
586
+ type: z.enum(RELATION_CREATE_TYPES)
587
+ },
588
+ handler: (ctx, args) => relationAdd(ctx, args["issue"], args["related_issue"], args["type"])
589
+ },
590
+ {
591
+ name: "relation_update",
592
+ description: "Change a relation's direction, resolving it by relation id or unique related issue.",
593
+ readOnly: false,
594
+ shape: {
595
+ issue: identifier,
596
+ id_or_issue: z.string(),
597
+ type: z.enum(RELATION_UPDATE_TYPES)
598
+ },
599
+ handler: (ctx, args) => relationUpdate(ctx, args["issue"], args["id_or_issue"], args["type"])
600
+ },
601
+ {
602
+ name: "relation_delete",
603
+ description: "Remove a typed relation by relation id or unique related issue.",
604
+ readOnly: false,
605
+ shape: { issue: identifier, id_or_issue: z.string() },
606
+ handler: (ctx, args) => relationRm(ctx, args["issue"], args["id_or_issue"])
607
+ },
608
+ // ── Links (URLs on an issue) ──
609
+ {
610
+ name: "link_list",
611
+ description: "URLs attached to an issue (distinct from blocking relations).",
612
+ readOnly: true,
613
+ shape: { issue: identifier },
614
+ handler: (ctx, args) => linkList(ctx, args["issue"])
615
+ },
616
+ {
617
+ name: "link_add",
618
+ description: "Attach a URL to an issue.",
619
+ readOnly: false,
620
+ shape: { issue: identifier, url: z.string(), title: z.string().optional() },
621
+ handler: (ctx, args) => linkAdd(ctx, args["issue"], args["url"], args["title"])
622
+ },
623
+ {
624
+ name: "link_update",
625
+ description: "Edit a link by link id or exact URL.",
626
+ readOnly: false,
627
+ shape: {
628
+ issue: identifier,
629
+ id_or_url: z.string(),
630
+ change: z.union([
631
+ z.object({ url: z.string(), title: z.string().optional() }),
632
+ z.object({ title: z.string(), url: z.string().optional() })
633
+ ])
634
+ },
635
+ handler: (ctx, args) => linkUpdate(ctx, args["issue"], args["id_or_url"], args["change"])
636
+ },
637
+ {
638
+ name: "link_rm",
639
+ description: "Remove a link from an issue, by link id or exact URL.",
640
+ readOnly: false,
641
+ shape: { issue: identifier, id_or_url: z.string() },
642
+ handler: (ctx, args) => linkRm(ctx, args["issue"], args["id_or_url"])
643
+ },
644
+ // ── Projects ──
645
+ {
646
+ name: "project_list",
647
+ description: "List projects.",
648
+ readOnly: true,
649
+ shape: { ...page, search: z.string().optional(), state: z.string().optional() },
650
+ handler: (ctx, args) => projectList(ctx, args)
651
+ },
652
+ {
653
+ name: "project_show",
654
+ description: "Project detail including milestones.",
655
+ readOnly: true,
656
+ shape: { id: z.string() },
657
+ handler: (ctx, args) => projectShow(ctx, args["id"])
658
+ },
659
+ {
660
+ name: "project_create",
661
+ description: "Create a project.",
662
+ readOnly: false,
663
+ shape: {
664
+ name: z.string(),
665
+ description: z.string().optional(),
666
+ summary: z.string().optional(),
667
+ state: z.string().optional(),
668
+ lead_id: z.string().optional(),
669
+ start_date: dateField,
670
+ target_date: dateField
671
+ },
672
+ handler: (ctx, args) => projectCreate(ctx, {
673
+ ...args,
674
+ lead: args["lead_id"],
675
+ startDate: args["start_date"],
676
+ targetDate: args["target_date"]
677
+ })
678
+ },
679
+ {
680
+ name: "project_update",
681
+ description: "Update a project.",
682
+ readOnly: false,
683
+ shape: {
684
+ id: z.string(),
685
+ name: z.string().optional(),
686
+ description: z.string().optional(),
687
+ summary: z.string().optional(),
688
+ state: z.string().optional(),
689
+ lead_id: z.string().optional(),
690
+ start_date: dateField,
691
+ target_date: dateField
692
+ },
693
+ handler: (ctx, args) => projectUpdate(ctx, args["id"], {
694
+ ...args,
695
+ lead: args["lead_id"],
696
+ startDate: args["start_date"],
697
+ targetDate: args["target_date"]
698
+ })
699
+ },
700
+ // ── Milestones ──
701
+ {
702
+ name: "milestone_list",
703
+ description: "List a project's milestones.",
704
+ readOnly: true,
705
+ shape: { project_id: z.string() },
706
+ handler: (ctx, args) => milestoneList(ctx, args["project_id"])
707
+ },
708
+ {
709
+ name: "milestone_create",
710
+ description: "Create a milestone in a project.",
711
+ readOnly: false,
712
+ shape: {
713
+ project_id: z.string(),
714
+ name: z.string(),
715
+ description: z.string().optional(),
716
+ target_date: dateField
717
+ },
718
+ handler: (ctx, args) => milestoneCreate(ctx, { project: args["project_id"], name: args["name"], description: args["description"], targetDate: args["target_date"] })
719
+ },
720
+ // ── Documents ──
721
+ {
722
+ name: "document_list",
723
+ description: "List documents.",
724
+ readOnly: true,
725
+ shape: { ...page, search: z.string().optional() },
726
+ handler: (ctx, args) => docList(ctx, args)
727
+ },
728
+ {
729
+ name: "document_show",
730
+ description: "Document detail including full content.",
731
+ readOnly: true,
732
+ shape: { id: z.string() },
733
+ handler: (ctx, args) => docShow(ctx, args["id"])
734
+ },
735
+ {
736
+ name: "document_create",
737
+ description: "Create a document.",
738
+ readOnly: false,
739
+ shape: { title: z.string(), content: z.string().optional(), project_id: z.string().optional() },
740
+ handler: (ctx, args) => docCreate(ctx, { title: args["title"], content: args["content"], project: args["project_id"] })
741
+ },
742
+ {
743
+ name: "document_update",
744
+ description: "Update a document.",
745
+ readOnly: false,
746
+ shape: {
747
+ id: z.string(),
748
+ title: z.string().optional(),
749
+ content: z.string().optional(),
750
+ project_id: z.string().optional()
751
+ },
752
+ handler: (ctx, args) => docUpdate(ctx, args["id"], { title: args["title"], content: args["content"], project: args["project_id"] })
753
+ },
754
+ // ── Labels, teams, states, users, stats ──
755
+ {
756
+ name: "label_list",
757
+ description: "List labels.",
758
+ readOnly: true,
759
+ shape: {},
760
+ handler: (ctx) => labelList(ctx)
761
+ },
762
+ {
763
+ name: "label_create",
764
+ description: "Create a label.",
765
+ readOnly: false,
766
+ shape: { name: z.string(), color: z.string().optional() },
767
+ handler: (ctx, args) => labelCreate(ctx, args["name"], args["color"])
768
+ },
769
+ {
770
+ name: "team_list",
771
+ description: "List teams.",
772
+ readOnly: true,
773
+ shape: {},
774
+ handler: (ctx) => teamList(ctx)
775
+ },
776
+ {
777
+ name: "team_create",
778
+ description: "Create a team. The key becomes the identifier prefix for the team's issues (upcased, letters/digits, letter first); numbering starts above any issues already carrying that prefix. The server seeds the default workflow states, so the team is immediately usable.",
779
+ readOnly: false,
780
+ shape: {
781
+ key: z.string().describe("team key, e.g. IOS"),
782
+ name: z.string().describe("display name")
783
+ },
784
+ handler: (ctx, args) => teamCreate(ctx, { key: args["key"], name: args["name"] })
785
+ },
786
+ {
787
+ name: "workflow_states",
788
+ description: "A team's workflow states (name + type).",
789
+ readOnly: true,
790
+ shape: { team: z.string().optional().describe("team key; defaults to HARNESS_TEAM") },
791
+ handler: (ctx, args) => workflowStates(ctx, args["team"])
792
+ },
793
+ {
794
+ name: "team_rename",
795
+ description: "Rename a team. The key is create-only \u2014 it is the identifier prefix of every issue filed under the team \u2014 so this changes the display name only. Needs Harness administrator standing.",
796
+ readOnly: false,
797
+ shape: {
798
+ key: z.string().describe("team key, e.g. IOS"),
799
+ name: z.string().describe("new display name")
800
+ },
801
+ handler: (ctx, args) => teamRename(ctx, args["key"], args["name"])
802
+ },
803
+ {
804
+ name: "team_set_default",
805
+ description: "Make a team the organization's default filing target, clearing the previous default. Needs Harness administrator standing.",
806
+ readOnly: false,
807
+ shape: { key: z.string().describe("team key") },
808
+ handler: (ctx, args) => teamSetDefault(ctx, args["key"])
809
+ },
810
+ {
811
+ name: "state_add",
812
+ description: "Add a workflow state to a team, appended after its existing states. Type is one of backlog, unstarted, started, completed, canceled. Needs Harness administrator standing.",
813
+ readOnly: false,
814
+ shape: {
815
+ name: z.string().describe("display name"),
816
+ type: z.string().describe("backlog | unstarted | started | completed | canceled"),
817
+ color: z.string().optional().describe("swatch name"),
818
+ team: z.string().optional().describe("team key; defaults to HARNESS_TEAM")
819
+ },
820
+ handler: (ctx, args) => stateAdd(ctx, { team: args["team"], name: args["name"], type: args["type"], color: args["color"] })
821
+ },
822
+ {
823
+ name: "state_update",
824
+ description: "Rename, retype or recolor one of a team's workflow states. Pass at least one of name, type, color. Needs Harness administrator standing.",
825
+ readOnly: false,
826
+ shape: {
827
+ id: z.string().describe("workflow state id (from workflow_states)"),
828
+ name: z.string().optional(),
829
+ type: z.string().optional().describe("backlog | unstarted | started | completed | canceled"),
830
+ color: z.string().optional().describe("swatch name"),
831
+ team: z.string().optional().describe("team key; defaults to HARNESS_TEAM")
832
+ },
833
+ handler: (ctx, args) => stateUpdate(ctx, args["id"], {
834
+ team: args["team"],
835
+ name: args["name"],
836
+ type: args["type"],
837
+ color: args["color"]
838
+ })
839
+ },
840
+ {
841
+ name: "state_order",
842
+ description: "Reorder a team's workflow states. Send EVERY state id, in the order you want them \u2014 a partial list is refused rather than half-applied, because renumbering part of the order would collide with the positions it left alone. Call workflow_states first to get the ids. Needs Harness administrator standing.",
843
+ readOnly: false,
844
+ shape: {
845
+ state_ids: z.string().describe("comma-separated state ids, in the desired order \u2014 all of them"),
846
+ team: z.string().optional().describe("team key; defaults to HARNESS_TEAM")
847
+ },
848
+ handler: (ctx, args) => stateOrder(ctx, args["state_ids"], { team: args["team"] })
849
+ },
850
+ {
851
+ name: "state_delete",
852
+ description: "Delete a workflow state. Refused while issues still sit in it \u2014 move them out first. Needs Harness administrator standing.",
853
+ readOnly: false,
854
+ shape: {
855
+ id: z.string().describe("workflow state id"),
856
+ team: z.string().optional().describe("team key; defaults to HARNESS_TEAM")
857
+ },
858
+ // `yes` is the CLI's typo guard for a human at a shell; an MCP call is
859
+ // already an explicit, named request to delete this id.
860
+ handler: (ctx, args) => stateDelete(ctx, args["id"], { team: args["team"], yes: true })
861
+ },
862
+ {
863
+ name: "label_update",
864
+ description: "Rename or recolor a label. Color is a swatch name (purple, green, red, yellow, pink, blue, orange, gray) or a hex code. Needs Harness administrator standing.",
865
+ readOnly: false,
866
+ shape: {
867
+ id: z.string().describe("label id (from label_list)"),
868
+ name: z.string().optional(),
869
+ color: z.string().optional().describe("swatch name or hex code")
870
+ },
871
+ handler: (ctx, args) => labelUpdate(ctx, args["id"], { name: args["name"], color: args["color"] })
872
+ },
873
+ {
874
+ name: "label_delete",
875
+ description: "Delete a label. Refused while issues still carry it, or while other labels are nested under it. Needs Harness administrator standing.",
876
+ readOnly: false,
877
+ shape: { id: z.string().describe("label id") },
878
+ handler: (ctx, args) => labelDelete(ctx, args["id"], { yes: true })
879
+ },
880
+ {
881
+ name: "member_list",
882
+ description: "Who can reach THIS organization's tracker, and at what rank \u2014 its grant holders plus its own admins. Not the same as user_list, which is the deployment-wide assignee picker and never lists a collaborator. Each entry carries both role (the granted rank, absent for an org admin) and standing (what that rank resolves to). Needs Harness administrator standing.",
883
+ readOnly: true,
884
+ shape: {},
885
+ handler: (ctx) => memberList(ctx)
886
+ },
887
+ {
888
+ name: "member_add",
889
+ description: "Grant a tracker rank to an existing account by email, minting the organization membership if they have none. The account must already exist. Refused if they are already on the roster \u2014 use member_set_role to change a rank. Needs Harness administrator standing.",
890
+ readOnly: false,
891
+ shape: {
892
+ email: z.string().describe("address of an existing account"),
893
+ role: z.string().describe("admin | staff | collaborator")
894
+ },
895
+ handler: (ctx, args) => memberAdd(ctx, { email: args["email"], role: args["role"] })
896
+ },
897
+ {
898
+ name: "member_set_role",
899
+ description: "Change or revoke a member's tracker rank; 'none' revokes. Refused when it would remove the organization's last Harness administrator. Needs Harness administrator standing.",
900
+ readOnly: false,
901
+ shape: {
902
+ user_id: z.string().describe("the member's user id (from member_list)"),
903
+ role: z.string().describe("admin | staff | collaborator | none")
904
+ },
905
+ handler: (ctx, args) => memberSetRole(ctx, args["user_id"], { role: args["role"] })
906
+ },
907
+ {
908
+ name: "user_list",
909
+ description: "List users (for assignee resolution).",
910
+ readOnly: true,
911
+ shape: {},
912
+ handler: (ctx) => userList(ctx)
913
+ },
914
+ {
915
+ name: "stats",
916
+ description: "Per-team and total issue counts.",
917
+ readOnly: true,
918
+ shape: {},
919
+ handler: (ctx) => stats(ctx)
920
+ },
921
+ // ── Plans and gates ──
922
+ {
923
+ name: "plan_list",
924
+ description: `A team's plans, newest first: each plan's id, root (its root issue's identifier, which the other plan tools take in place of the id), title, status, held, revision (null before its first release), released_at and inserted_at. One list of at most ${PLAN_LIST_MAX_LIMIT}, no pages: a second text item says when it came back full. issue_show's plan names the plan an issue belongs to.`,
925
+ readOnly: true,
926
+ shape: {
927
+ team: z.string().optional().describe("team key whose issues root the plans; defaults to HARNESS_TEAM"),
928
+ status: z.enum(PLAN_STATUSES).optional().describe("only plans in this status"),
929
+ limit: z.number().int().min(1).max(PLAN_LIST_MAX_LIMIT).optional().describe("maximum plans (default 50)")
930
+ },
931
+ handler: (ctx, args) => planList(ctx, { team: args["team"], status: args["status"], limit: args["limit"] })
932
+ },
933
+ {
934
+ name: "plan_create",
935
+ description: "Create a plan: a root issue and its steps, created in one transaction or not at all, and held until a person releases it (plan_release). Steps refer to each other by key: parent (containment) and blocked_by (dependencies). executor operator files a step in the organization's Operator team, where only the plan driver claims it; a person's step goes to its team (or the plan's). conditions are wait conditions (not_before, gate, event); a gate may carry a key (gate: <key> is shorthand) and an owner, who alone ticks it. An invalid plan writes nothing and the error lists every violation by step key; too_many_steps says which cap the plan is over: the organization's max plan steps (default 1000, which a Harness admin raises in Harness settings \u2192 Plans, up to 2000) or the fixed 100 Operator steps of the delegated grant. Reuse idempotency_key when retrying. Once released, the changes an Operator step proposes need a person's approval in the app, as plan_release says.",
936
+ readOnly: false,
937
+ shape: {
938
+ team: z.string().optional().describe("team key for the root and people's steps; defaults to HARNESS_TEAM"),
939
+ plan: planDocument.describe("the root's fields plus steps"),
940
+ idempotency_key: idempotencyKey.optional().describe("reuse the same printable-ASCII key when deliberately retrying this create")
941
+ },
942
+ handler: (ctx, args) => planCreate(ctx, args["plan"], { team: args["team"], idempotencyKey: args["idempotency_key"] })
943
+ },
944
+ {
945
+ name: "plan_show",
946
+ description: "A plan, by its id or its root issue's identifier: status, budgets, current revision and spend estimate, and its whole tree root first: each step's key, executor, parent, state, ready flag, held flag (changed since the release: only an Operator step waits for the next release, a person's stays ready), in_revision, wait conditions (a gate's key and owner) and waiting_for (each open gate's owner and holders, the people who can tick it: [] when nobody can, null when unowned or not known), plus the blocks edges between steps.",
947
+ readOnly: true,
948
+ shape: { id: planId },
949
+ handler: (ctx, args) => planShow(ctx, args["id"])
950
+ },
951
+ {
952
+ name: "plan_edit",
953
+ description: "Change step executors and keys (each step named by its key or issue identifier) and the plan's budgets. Editing a released Operator step, or handing one to a person, holds it until the plan is released again; a person's step stays workable, and the next release takes the change in. A budget edit needs the plan held. Descriptions, parents and edges are edited with issue_update and relation_add/relation_rm; conditions are set at plan_create, and gate_set ticks a gate. Once released, the changes an Operator step proposes need a person's approval in the app, as plan_release says.",
954
+ readOnly: false,
955
+ shape: {
956
+ id: planId,
957
+ steps: z.array(planStepEdit).optional(),
958
+ retry_budget: z.number().int().min(0).optional(),
959
+ spend_budget_cents: z.number().int().min(0).nullable().optional().describe("null clears the budget")
960
+ },
961
+ handler: (ctx, args) => {
962
+ const { id, ...edits } = args;
963
+ return planEdit(ctx, id, edits);
964
+ }
965
+ },
966
+ {
967
+ name: "plan_release",
968
+ description: "Release a held plan (or one that needs attention) as the caller: freezes its next revision, which takes in the steps added or edited since the last one, and lets its ready steps run. A plan over a step cap is refused with too_many_steps, naming the cap as plan_create does. A plan with Operator steps needs a spend budget and the caller's right to run Operator; collaborators join its plan room. Each Operator step runs as a job (jobs_list names its plan_step), and the changes a job proposes need a person's approval in the app: give them the job's approval_url. No tool approves.",
969
+ readOnly: false,
970
+ shape: {
971
+ id: planId,
972
+ collaborators: z.array(z.string()).max(20).optional().describe("people of this organization, by id or email"),
973
+ retry_budget: z.number().int().min(0).optional(),
974
+ spend_budget_cents: z.number().int().min(0).nullable().optional().describe("null clears the budget")
975
+ },
976
+ handler: (ctx, args) => planRelease(ctx, args["id"], {
977
+ collaborators: args["collaborators"],
978
+ retryBudget: args["retry_budget"],
979
+ spendBudgetCents: args["spend_budget_cents"]
980
+ })
981
+ },
982
+ {
983
+ name: "plan_hold",
984
+ description: "Pause every step of a plan: none is ready until it is released again.",
985
+ readOnly: false,
986
+ shape: { id: planId },
987
+ handler: (ctx, args) => planHold(ctx, args["id"])
988
+ },
989
+ {
990
+ name: "gate_set",
991
+ description: "Satisfy (satisfied true) or un-satisfy (false) a gate wait condition on an issue, found by its key, label or condition id (condition_list and issue_show list conditions). A condition id writes directly, without a staff-only conditions lookup; a key or label needs that lookup. Needs staff standing in the organization or a place in its plan's room; only an owned gate's owner ticks it, and its owner or staff untick it. Anyone else gets a 403 naming the owner. An outside expert's pxc_ credential ticks the gates it owns on a step assigned to it (assignment_list); any other step is 404. An Operator-only login (operator-cli) is refused here, as on every tool outside Operator's, even for a person in the plan's room.",
992
+ readOnly: false,
993
+ shape: {
994
+ issue: identifier,
995
+ gate: z.string().describe("the gate's key, its label, or its condition id"),
996
+ satisfied: z.boolean()
997
+ },
998
+ handler: (ctx, args) => gateSet(ctx, args["issue"], args["gate"], args["satisfied"])
999
+ },
1000
+ {
1001
+ name: "condition_list",
1002
+ description: "An issue's wait conditions (not_before, gate, event), oldest first: each one's id, kind, a gate's key, label and owner, and whether, when and by whom it was satisfied. gate_set ticks a gate. With an outside expert's pxc_ credential: only the gates it owns on a step assigned to it, in the expert view (owner as {kind, role}, satisfied_by as {kind: you|role|expert|member}); any other step is 404.",
1003
+ readOnly: true,
1004
+ shape: { issue: identifier },
1005
+ handler: (ctx, args) => conditionList(ctx, args["issue"])
1006
+ },
1007
+ {
1008
+ name: "assignment_list",
1009
+ description: "An outside expert's own assignments, for their pxc_ credential: expert ({id, role}), plan ({id, status} or null), and assignments, each step assigned to them with its identifier, title, state, due_date, description (null when Operator runs the step), open_blockers (a count) and the gates on it they own. gate_set ticks one. A pxc_ credential reaches only this, condition_list and gate_set: every other tool is refused as expert_bound_credential. Any other credential is refused as expert_credential_required.",
1010
+ readOnly: true,
1011
+ shape: {},
1012
+ handler: (ctx) => assignments(ctx)
1013
+ },
1014
+ // ── Operator jobs (CLI-43) ──
1015
+ ...OPERATOR_JOB_TOOLS,
1016
+ // ── Operator job controls (CLI-46) ──
1017
+ ...OPERATOR_CONTROL_TOOLS,
1018
+ // ── Operator job start (CLI-45) ──
1019
+ ...OPERATOR_RUN_TOOLS,
1020
+ // ── Operator job lifecycle (CLI-47) ──
1021
+ ...OPERATOR_LIFECYCLE_TOOLS,
1022
+ // ── Uploads ──
1023
+ {
1024
+ name: "upload",
1025
+ description: "Upload a local image (png/jpg/webp/gif, up to 25 MB) or H.264 MP4 video (8-bit 4:2:0, optional AAC audio and mov_text subtitles, up to 100 MB). Returns metadata and markdown: images use image markdown; videos use ordinary links rendered as inline players in Harness issues. A video is private to its uploader until that user saves its link in an issue description or comment. Reading an issue does not automatically download videos.",
1026
+ readOnly: false,
1027
+ shape: { path: z.string().describe("local file path") },
1028
+ handler: (ctx, args) => upload(ctx, args["path"])
1029
+ },
1030
+ // ── Operator's catalog (CLI-44) ──
1031
+ {
1032
+ name: "tools_search",
1033
+ description: "Browse or search what Operator can use now in this organization: only capabilities that are switched on appear. Without query, the index in id order (25 a page); with query, a keyword search ranked by score (15 a page). domain narrows either to one domain. Each capability carries its domain, summary, description, mode, effect and kind: read, write, or send (a write delivered beyond the platform). Write and send capabilities, and any proposal, always need a person's approval in the app before they take effect; nothing here runs or approves anything. A page that isn't the last comes with a notice naming the next cursor; all=true follows every page.",
1034
+ readOnly: true,
1035
+ shape: {
1036
+ query: z.string().optional().describe("search terms, at most 200 bytes; omit to browse the index"),
1037
+ domain: z.string().optional().describe("only this capability domain, e.g. crm"),
1038
+ cursor: z.string().optional().describe("the next page's cursor, from the previous call's notice"),
1039
+ all: z.boolean().optional().describe("follow every page")
1040
+ },
1041
+ handler: (ctx, args) => {
1042
+ const opts = { domain: args["domain"], cursor: args["cursor"], all: args["all"] };
1043
+ return args["query"] === void 0 ? toolsList(ctx, opts) : toolsSearch(ctx, args["query"], opts);
1044
+ }
1045
+ },
1046
+ {
1047
+ name: "skills_list",
1048
+ description: "The organization's skills assigned to Operator, in title order: each skill's id, title, invocation_name and content_sha256. A skill's content and description are never exposed.",
1049
+ readOnly: true,
1050
+ shape: {},
1051
+ handler: (ctx) => skillsList(ctx)
1052
+ }
1053
+ ];
1054
+ function buildServer(name = "harness") {
1055
+ const server = new McpServer({ name, version: CLI_VERSION });
1056
+ for (const tool of TOOLS) {
1057
+ server.registerTool(
1058
+ tool.name,
1059
+ {
1060
+ description: tool.description,
1061
+ inputSchema: tool.shape,
1062
+ annotations: {
1063
+ readOnlyHint: tool.readOnly,
1064
+ ...tool.destructive === void 0 ? {} : { destructiveHint: tool.destructive },
1065
+ ...tool.idempotent === void 0 ? {} : { idempotentHint: tool.idempotent }
1066
+ }
1067
+ },
1068
+ ((args) => run((ctx) => tool.handler(ctx, args)))
1069
+ );
1070
+ }
1071
+ return server;
1072
+ }
1073
+ async function startMcpServer(name = "harness") {
1074
+ const server = buildServer(name);
1075
+ await server.connect(new StdioServerTransport());
1076
+ }
1077
+ export {
1078
+ buildServer,
1079
+ startMcpServer
1080
+ };