callman-mcp 0.10.2 → 0.11.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +14 -3
  3. package/dist/client/api.d.ts +26 -3
  4. package/dist/client/api.d.ts.map +1 -1
  5. package/dist/client/api.js +11 -0
  6. package/dist/client/api.js.map +1 -1
  7. package/dist/client/errors.d.ts.map +1 -1
  8. package/dist/client/errors.js +33 -1
  9. package/dist/client/errors.js.map +1 -1
  10. package/dist/client/types.d.ts +59 -0
  11. package/dist/client/types.d.ts.map +1 -1
  12. package/dist/domain/pipeline.d.ts +80 -0
  13. package/dist/domain/pipeline.d.ts.map +1 -0
  14. package/dist/domain/pipeline.js +139 -0
  15. package/dist/domain/pipeline.js.map +1 -0
  16. package/dist/domain/scenario/build.d.ts +6 -6
  17. package/dist/resources/docs/overview.d.ts +1 -1
  18. package/dist/resources/docs/overview.d.ts.map +1 -1
  19. package/dist/resources/docs/overview.js +20 -0
  20. package/dist/resources/docs/overview.js.map +1 -1
  21. package/dist/resources/generated/aiPacks.d.ts +1 -1
  22. package/dist/resources/generated/aiPacks.js +2 -2
  23. package/dist/tools/catalog.d.ts.map +1 -1
  24. package/dist/tools/catalog.js +4 -0
  25. package/dist/tools/catalog.js.map +1 -1
  26. package/dist/tools/collections.d.ts.map +1 -1
  27. package/dist/tools/collections.js +249 -17
  28. package/dist/tools/collections.js.map +1 -1
  29. package/dist/tools/mockSchemas.d.ts +48 -48
  30. package/dist/tools/mockShared.d.ts +64 -64
  31. package/dist/tools/run.d.ts +2 -0
  32. package/dist/tools/run.d.ts.map +1 -1
  33. package/dist/tools/run.js +34 -5
  34. package/dist/tools/run.js.map +1 -1
  35. package/dist-cjs/client/api.js +11 -0
  36. package/dist-cjs/client/errors.js +33 -1
  37. package/dist-cjs/domain/pipeline.js +144 -0
  38. package/dist-cjs/resources/docs/overview.js +20 -0
  39. package/dist-cjs/resources/generated/aiPacks.js +2 -2
  40. package/dist-cjs/tools/catalog.js +4 -0
  41. package/dist-cjs/tools/collections.js +247 -15
  42. package/dist-cjs/tools/run.js +33 -4
  43. package/package.json +2 -2
@@ -4,6 +4,8 @@ exports.registerCollectionTools = void 0;
4
4
  const zod_1 = require("zod");
5
5
  const errors_js_1 = require("../client/errors.js");
6
6
  const strict_js_1 = require("../lib/strict.js");
7
+ const ids_js_1 = require("../lib/ids.js");
8
+ const pipeline_js_1 = require("../domain/pipeline.js");
7
9
  const requestShape_js_1 = require("../domain/requestShape.js");
8
10
  const resolve_js_1 = require("../domain/resolve.js");
9
11
  const pagination_js_1 = require("../lib/pagination.js");
@@ -43,6 +45,99 @@ const variableWrites = (variables) => {
43
45
  // collection variables (older backend).
44
46
  const variablesView = (variables) => Array.isArray(variables) ? variables.map((variable) => ({ key: variable.key, value: variable.value, enabled: variable.enabled !== false })) : null;
45
47
  const UNSUPPORTED_VARIABLES_NOTE = "This server does not support collection variables yet (update the Callman backend); use an environment instead.";
48
+ // ── Pipeline collections (on-prem) ────────────────────────────────────────
49
+ // Changes go through review like scenarios, approved versions are kept, CI
50
+ // runs only the active approved version and its stored pass criteria decide
51
+ // the verdict. Approving is a person's decision in the app — tokens may
52
+ // submit and request, never approve.
53
+ const kindArg = zod_1.z
54
+ .enum(["pipeline"])
55
+ .optional()
56
+ .describe('On-prem: "pipeline" makes it a pipeline collection — reviewed like scenarios, CI runs only its approved version and its pipelineConfig decides pass/fail. Needs `group`. The way back to standard goes through review (desktop app)');
57
+ const groupArg = zod_1.z
58
+ .string()
59
+ .max(200)
60
+ .optional()
61
+ .describe("On-prem pipeline collections: the group whose approvers review it (name or id from the groups you belong to, as list_scenarios shows them). Optional when you belong to one group; fixed once a version is approved or a review is open");
62
+ const PIPELINE_DRAFT_NOTE = "Pipeline collection: edits change the DRAFT. CI (callme run, run_collection) keeps running the approved version until the change is submitted (submit_collection) and approved by a person in the Callman app.";
63
+ // The reviewing group: by id or name among the groups the user belongs to.
64
+ // Omitted → the only group the user has, when there is exactly one.
65
+ const resolveCollectionGroup = async (ctx, ws, ref) => {
66
+ let items;
67
+ try {
68
+ items = (await ctx.api.listMyScenarioGroups(ws)).items;
69
+ }
70
+ catch (error) {
71
+ // The group list needs scenarios:read; an id can still be passed through.
72
+ if (ref && (0, ids_js_1.isObjectId)(ref.trim()) && error instanceof errors_js_1.CallmanApiError && error.code === "PAT_SCOPE_MISSING")
73
+ return { id: ref.trim() };
74
+ throw error;
75
+ }
76
+ if (ref) {
77
+ if (items.length === 0)
78
+ throw new errors_js_1.ToolInputError("NO_REVIEW_GROUPS", "You belong to no group that can review collections. Pipeline collections exist on Callman on-prem only, and their group is an on-prem group.");
79
+ return { id: (0, resolve_js_1.resolveRef)("group", ref, items).item.id };
80
+ }
81
+ if (items.length === 1)
82
+ return { id: items[0].id, note: `Reviewed by the group "${items[0].name}" (your only group).` };
83
+ if (items.length > 1) {
84
+ throw new errors_js_1.ToolInputError("COLLECTION_GROUP_REQUIRED", "Pass `group`: the group whose approvers review this pipeline collection.", {
85
+ candidates: items.map((group) => ({ id: group.id, name: group.name })),
86
+ });
87
+ }
88
+ return undefined;
89
+ };
90
+ // Critical request refs (ids or names) → ids of requests in this collection.
91
+ const criticalRequestIds = async (ctx, ws, collectionId, refs) => {
92
+ if (refs.length === 0)
93
+ return [];
94
+ const tree = await (0, lookups_js_1.loadTree)(ctx, ws, collectionId);
95
+ return [...new Set(refs.map((ref) => (0, resolve_js_1.resolveRef)("request", ref, tree.requests, { aliases: (request) => [`${request.method} ${request.url}`] }).item.id))];
96
+ };
97
+ const reviewStateView = (collection) => collection.kind === "pipeline" && collection.review ? { review: collection.review } : {};
98
+ // What a collection is, for every read: kind always (older servers: standard),
99
+ // group / draft pass criteria / review state for pipeline collections.
100
+ const pipelineView = (collection) => ({
101
+ kind: collection.kind ?? "standard",
102
+ ...(collection.kind === "pipeline"
103
+ ? {
104
+ groupId: collection.groupId ?? null,
105
+ pipelineConfig: collection.pipelineConfig ?? null,
106
+ ...reviewStateView(collection),
107
+ }
108
+ : {}),
109
+ });
110
+ const reviewSummaryView = (summary) => ({
111
+ status: summary.status,
112
+ activeVersionNumber: summary.activeVersionNumber ?? null,
113
+ approvedAt: summary.approvedAt ?? null,
114
+ approvedByLabel: summary.approvedByLabel ?? null,
115
+ hasUnapprovedChanges: summary.hasUnapprovedChanges,
116
+ group: summary.groupId ? { id: summary.groupId, name: summary.groupName ?? null } : null,
117
+ });
118
+ const reviewRequestView = (request) => ({
119
+ id: request.id,
120
+ action: request.action,
121
+ status: request.status,
122
+ baseVersionNumber: request.baseVersionNumber ?? null,
123
+ note: request.note ?? null,
124
+ assigneeIds: request.assigneeIds ?? [],
125
+ submittedAt: request.submittedAt,
126
+ });
127
+ const reviewNoteArg = zod_1.z.string().max(2000).optional().describe("Note for the reviewers (what changed, why)");
128
+ const approversArg = zod_1.z.array(zod_1.z.string().max(255)).max(100).optional().describe("Approver emails; omit to notify every eligible approver of the group");
129
+ // Approver emails → user ids among the server's candidates for the action.
130
+ const approverIdsFor = async (ctx, ws, collectionId, action, emails) => {
131
+ if (!emails || emails.length === 0)
132
+ return undefined;
133
+ const candidates = await ctx.api.listCollectionApproverCandidates(ws, collectionId, action);
134
+ return emails.map((email) => {
135
+ const match = candidates.find((candidate) => candidate.email.toLowerCase() === email.trim().toLowerCase());
136
+ if (!match)
137
+ throw new errors_js_1.ToolInputError("APPROVER_NOT_ELIGIBLE", `"${email}" cannot approve this collection.`, { candidates: candidates.map((candidate) => candidate.email) });
138
+ return match.userId;
139
+ });
140
+ };
46
141
  // ── Collection / folder detail: docs + scripts ─────────────────────────────
47
142
  const DOCS_MAX_CHARS = 500_000;
48
143
  const SCRIPT_MAX_CHARS = 100_000;
@@ -93,7 +188,7 @@ const detailView = (item) => ({
93
188
  const registerCollectionTools = (server, ctx) => {
94
189
  server.registerTool("get_collection", {
95
190
  title: "Get a collection's documentation, scripts and variables",
96
- description: "A collection's details: its documentation (Markdown), its collection-level pre-request / post-response / tests scripts (they run around every request in it) and its collection variables ({ key, value, enabled }). Variable values are masked (••••ab) unless reveal: true AND the token holds environments:reveal. Use get_collection_tree for its folders and requests.",
191
+ description: "A collection's details: its documentation (Markdown), its collection-level pre-request / post-response / tests scripts (they run around every request in it) and its collection variables ({ key, value, enabled }). Variable values are masked (••••ab) unless reveal: true AND the token holds environments:reveal. Also its `kind`; an on-prem pipeline collection adds its group, its DRAFT pipelineConfig (pass criteria) and `review` (status, the active approved version, pending submission). Use get_collection_tree for its folders and requests.",
97
192
  inputSchema: { workspace: shared_js_1.workspaceArg, collection: (0, shared_js_1.refArg)("Collection"), reveal: zod_1.z.boolean().optional().describe("Return variable values unmasked (needs environments:reveal)") },
98
193
  annotations: shared_js_1.READ,
99
194
  }, (0, shared_js_1.guarded)(async ({ workspace, collection, reveal }) => {
@@ -107,8 +202,10 @@ const registerCollectionTools = (server, ctx) => {
107
202
  requestCount: detail.requestCount ?? null,
108
203
  folderCount: detail.folderCount ?? null,
109
204
  runnerSettings: detail.runnerSettings ?? null,
205
+ ...pipelineView(detail),
110
206
  },
111
207
  workspace: (0, lookups_js_1.workspaceTag)(ws),
208
+ ...(detail.kind === "pipeline" ? { pipelineNote: PIPELINE_DRAFT_NOTE } : {}),
112
209
  ...detailView(detail),
113
210
  variables: variablesView(detail.variables),
114
211
  ...(detail.variables === undefined ? { variablesNote: UNSUPPORTED_VARIABLES_NOTE } : {}),
@@ -137,7 +234,7 @@ const registerCollectionTools = (server, ctx) => {
137
234
  }));
138
235
  server.registerTool("list_collections", {
139
236
  title: "List collections",
140
- description: "List collections with request/folder counts — across ALL your workspaces unless `workspace` narrows it (the default workspace comes first; every row says its workspace). Optional `query` filters by name (substring). Use get_collection_tree to see what is inside one.",
237
+ description: "List collections with request/folder counts — across ALL your workspaces unless `workspace` narrows it (the default workspace comes first; every row says its workspace). Optional `query` filters by name (substring). Each row says its `kind`; on-prem pipeline collections add their review state. Use get_collection_tree to see what is inside one.",
141
238
  inputSchema: { workspace: shared_js_1.workspaceArg, query: zod_1.z.string().max(200).optional(), ...shared_js_1.cursorArgs },
142
239
  annotations: shared_js_1.READ,
143
240
  }, (0, shared_js_1.guarded)(async ({ workspace, query, cursor, limit }) => {
@@ -156,6 +253,17 @@ const registerCollectionTools = (server, ctx) => {
156
253
  requestCount: collection.requestCount ?? null,
157
254
  folderCount: collection.folderCount ?? null,
158
255
  updatedAt: collection.updatedAt ?? null,
256
+ kind: collection.kind ?? "standard",
257
+ ...(collection.kind === "pipeline" && collection.review
258
+ ? {
259
+ review: {
260
+ status: collection.review.status,
261
+ activeVersionNumber: collection.review.activeVersionNumber ?? null,
262
+ pendingAction: collection.review.pendingAction ?? null,
263
+ hasUnapprovedChanges: collection.review.hasUnapprovedChanges,
264
+ },
265
+ }
266
+ : {}),
159
267
  })),
160
268
  total: page.total,
161
269
  nextCursor: page.nextCursor,
@@ -195,7 +303,7 @@ const registerCollectionTools = (server, ctx) => {
195
303
  }));
196
304
  server.registerTool("create_collection", {
197
305
  title: "Create a collection",
198
- description: "Create an empty collection in `workspace` (or the session default — use_workspace; with one workspace, that one), optionally with its collection variables, docs and scripts. Refuses a name that already exists in that workspace unless allowDuplicateName is true — list_collections first and reuse an existing one when it fits.",
306
+ description: "Create an empty collection in `workspace` (or the session default — use_workspace; with one workspace, that one), optionally with its collection variables, docs and scripts. On-prem, kind: \"pipeline\" (+ group, pipelineConfig) creates a pipeline collection: it starts as a draft, and CI runs it only after submit_collection and a person's approval. Refuses a name that already exists in that workspace unless allowDuplicateName is true — list_collections first and reuse an existing one when it fits.",
199
307
  inputSchema: {
200
308
  workspace: shared_js_1.workspaceArg,
201
309
  name: zod_1.z.string().min(1).max(truncate_js_1.LIMITS.NAME),
@@ -204,9 +312,18 @@ const registerCollectionTools = (server, ctx) => {
204
312
  variables: collectionVariablesArg,
205
313
  docs: docsArg,
206
314
  scripts: scriptsArg,
315
+ kind: kindArg,
316
+ group: groupArg,
317
+ pipelineConfig: pipeline_js_1.pipelineConfigArg,
207
318
  },
208
319
  annotations: shared_js_1.WRITE,
209
- }, (0, shared_js_1.guarded)(async ({ workspace, name, allowDuplicateName, runnerSettings, variables, docs, scripts }) => {
320
+ }, (0, shared_js_1.guarded)(async ({ workspace, name, allowDuplicateName, runnerSettings, variables, docs, scripts, kind, group, pipelineConfig }) => {
321
+ if (kind !== "pipeline" && (group !== undefined || pipelineConfig !== undefined)) {
322
+ throw new errors_js_1.ToolInputError("KIND_REQUIRED", 'group and pipelineConfig belong to pipeline collections — pass kind: "pipeline" as well.');
323
+ }
324
+ if ((pipelineConfig?.passCriteria?.requiredRequests?.length ?? 0) > 0) {
325
+ throw new errors_js_1.ToolInputError("CRITICAL_REQUESTS_LATER", "A new collection has no requests yet. Create it, add the requests, then set passCriteria.requiredRequests with update_collection.");
326
+ }
210
327
  const writes = variables ? variableWrites(variables) : undefined;
211
328
  const ws = await ctx.resolveWorkspace(workspace);
212
329
  const existing = (await (0, lookups_js_1.loadCollections)(ctx, ws.id)).find((collection) => (0, lookups_js_1.sameName)(collection.name, name));
@@ -215,24 +332,39 @@ const registerCollectionTools = (server, ctx) => {
215
332
  existing: { id: existing.id, name: existing.name },
216
333
  });
217
334
  }
335
+ const reviewGroup = kind === "pipeline" ? await resolveCollectionGroup(ctx, ws.id, group) : undefined;
218
336
  const created = await ctx.api.createCollection(ws.id, {
219
337
  name: name.trim(),
220
338
  ...(runnerSettings ? { runnerSettings: compactSettings(runnerSettings) } : {}),
221
339
  ...(writes && writes.length > 0 ? { variables: writes } : {}),
222
340
  ...containerWrite({ docs, scripts }),
341
+ ...(kind === "pipeline"
342
+ ? {
343
+ kind,
344
+ ...(reviewGroup ? { groupId: reviewGroup.id } : {}),
345
+ ...(pipelineConfig ? { pipelineConfig: await (0, pipeline_js_1.mergePipelineConfig)(null, pipelineConfig, async () => []) } : {}),
346
+ }
347
+ : {}),
223
348
  });
224
349
  ctx.invalidate(`collections:${ws.id}`);
225
350
  const variablesLost = writes !== undefined && writes.length > 0 && !Array.isArray(created.variables);
226
351
  return (0, shared_js_1.ok)({
227
- collection: { id: created.id, name: created.name, ...(writes && writes.length > 0 && !variablesLost ? { variableKeys: writes.map((variable) => variable.key) } : {}) },
352
+ collection: {
353
+ id: created.id,
354
+ name: created.name,
355
+ ...(writes && writes.length > 0 && !variablesLost ? { variableKeys: writes.map((variable) => variable.key) } : {}),
356
+ ...pipelineView(created),
357
+ },
228
358
  workspace: (0, lookups_js_1.workspaceTag)(ws),
229
- note: "Created. Add requests with create_request / create_requests_bulk / import_content.",
359
+ note: created.kind === "pipeline"
360
+ ? `Created as a pipeline collection (draft). Add requests with create_request / create_requests_bulk / import_content, then submit_collection — CI runs it only once a person approves it.${reviewGroup?.note ? ` ${reviewGroup.note}` : ""}`
361
+ : "Created. Add requests with create_request / create_requests_bulk / import_content.",
230
362
  ...(variablesLost ? { warnings: [`The variables were not saved: ${UNSUPPORTED_VARIABLES_NOTE}`] } : {}),
231
363
  });
232
364
  }));
233
365
  server.registerTool("update_collection", {
234
366
  title: "Update a collection (name, runner settings, variables, docs, scripts)",
235
- description: "Rename a collection, toggle its runner settings (whether collection runs execute the requests' database steps), replace its collection variables (the whole list — read it with get_collection, change it, send it back), replace its documentation (Markdown — pass expectedDocsVersion from get_collection) and/or set its collection-level scripts.",
367
+ description: "Rename a collection, toggle its runner settings (whether collection runs execute the requests' database steps), replace its collection variables (the whole list — read it with get_collection, change it, send it back), replace its documentation (Markdown — pass expectedDocsVersion from get_collection) and/or set its collection-level scripts. On-prem: kind: \"pipeline\" (+ group) turns a standard collection into a pipeline collection, and pipelineConfig changes its pass criteria (omitted keys keep their value). A pipeline collection's edits change its draft; CI keeps running the approved version until submit_collection + approval.",
236
368
  inputSchema: {
237
369
  workspace: shared_js_1.workspaceArg,
238
370
  collection: (0, shared_js_1.refArg)("Collection"),
@@ -242,26 +374,60 @@ const registerCollectionTools = (server, ctx) => {
242
374
  docs: docsArg,
243
375
  expectedDocsVersion: expectedDocsVersionArg,
244
376
  scripts: scriptsArg,
377
+ kind: kindArg,
378
+ group: groupArg,
379
+ pipelineConfig: pipeline_js_1.pipelineConfigArg,
245
380
  },
246
381
  annotations: shared_js_1.IDEMPOTENT_WRITE,
247
- }, (0, shared_js_1.guarded)(async ({ workspace, collection, name, runnerSettings, variables, docs, expectedDocsVersion, scripts }) => {
248
- if (name === undefined && runnerSettings === undefined && variables === undefined && docs === undefined && scripts === undefined) {
249
- throw new errors_js_1.ToolInputError("NOTHING_TO_UPDATE", "Pass name, runnerSettings, variables, docs and/or scripts.");
382
+ }, (0, shared_js_1.guarded)(async ({ workspace, collection, name, runnerSettings, variables, docs, expectedDocsVersion, scripts, kind, group, pipelineConfig }) => {
383
+ if (name === undefined &&
384
+ runnerSettings === undefined &&
385
+ variables === undefined &&
386
+ docs === undefined &&
387
+ scripts === undefined &&
388
+ kind === undefined &&
389
+ group === undefined &&
390
+ pipelineConfig === undefined) {
391
+ throw new errors_js_1.ToolInputError("NOTHING_TO_UPDATE", "Pass name, runnerSettings, variables, docs, scripts, kind, group and/or pipelineConfig.");
250
392
  }
251
393
  requireDocsVersion(docs, expectedDocsVersion);
252
394
  const writes = variables ? variableWrites(variables) : undefined;
253
395
  const { ws, value: resolved, note } = await (0, lookups_js_1.findCollection)(ctx, workspace, collection);
254
- const updated = await ctx.api.updateCollection(ws.id, resolved.item.id, {
396
+ // Kind / group / pass criteria: the stored config is replaced whole, so
397
+ // the input is laid over the current draft.
398
+ let pipelineWrite = {};
399
+ let groupNote;
400
+ if (kind !== undefined || group !== undefined || pipelineConfig !== undefined) {
401
+ const current = await ctx.api.getCollection(ws.id, resolved.item.id);
402
+ const converting = kind === "pipeline" && current.kind !== "pipeline";
403
+ if (!converting && current.kind !== "pipeline" && (group !== undefined || pipelineConfig !== undefined)) {
404
+ throw new errors_js_1.ToolInputError("NOT_A_PIPELINE_COLLECTION", 'This is a standard collection — pass kind: "pipeline" to turn it into a pipeline collection (with group and pipelineConfig).');
405
+ }
406
+ const reviewGroup = converting || group !== undefined ? await resolveCollectionGroup(ctx, ws.id, group) : undefined;
407
+ groupNote = reviewGroup?.note;
408
+ pipelineWrite = {
409
+ ...(converting ? { kind } : {}),
410
+ ...(reviewGroup && (converting || group !== undefined) ? { groupId: reviewGroup.id } : {}),
411
+ ...(pipelineConfig
412
+ ? { pipelineConfig: await (0, pipeline_js_1.mergePipelineConfig)(current.pipelineConfig ?? null, pipelineConfig, (refs) => criticalRequestIds(ctx, ws.id, resolved.item.id, refs)) }
413
+ : {}),
414
+ };
415
+ }
416
+ const body = {
255
417
  ...(name !== undefined ? { name: name.trim() } : {}),
256
418
  ...(runnerSettings ? { runnerSettings: compactSettings(runnerSettings) } : {}),
257
419
  ...(writes !== undefined ? { variables: writes } : {}),
258
420
  ...containerWrite({ docs, expectedDocsVersion, scripts }),
259
- });
421
+ ...pipelineWrite,
422
+ };
423
+ if (Object.keys(body).length === 0)
424
+ throw new errors_js_1.ToolInputError("NOTHING_TO_UPDATE", "Nothing changes: it is already a pipeline collection.");
425
+ const updated = await ctx.api.updateCollection(ws.id, resolved.item.id, body);
260
426
  ctx.invalidate(`collections:${ws.id}`);
261
427
  // An older server drops the unknown field without an error: say so
262
428
  // instead of reporting the variables as saved.
263
429
  const variablesLost = writes !== undefined && !Array.isArray(updated.variables);
264
- if (variablesLost && name === undefined && runnerSettings === undefined && docs === undefined && scripts === undefined) {
430
+ if (variablesLost && name === undefined && runnerSettings === undefined && docs === undefined && scripts === undefined && Object.keys(pipelineWrite).length === 0) {
265
431
  throw new errors_js_1.ToolInputError("COLLECTION_VARIABLES_UNSUPPORTED", UNSUPPORTED_VARIABLES_NOTE);
266
432
  }
267
433
  return (0, shared_js_1.ok)({
@@ -277,8 +443,10 @@ const registerCollectionTools = (server, ctx) => {
277
443
  : {}),
278
444
  ...(docs !== undefined ? { docsVersion: updated.docsVersion ?? null } : {}),
279
445
  ...(scripts !== undefined ? { hasScripts: updated.hasScripts ?? null } : {}),
446
+ ...pipelineView(updated),
280
447
  },
281
448
  workspace: (0, lookups_js_1.workspaceTag)(ws),
449
+ ...(updated.kind === "pipeline" ? { pipelineNote: `${PIPELINE_DRAFT_NOTE}${groupNote ? ` ${groupNote}` : ""}` } : {}),
282
450
  resolved: [(0, resolve_js_1.resolvedSummary)(collection, resolved), ...(note ? [{ input: collection, note }] : [])],
283
451
  ...(variablesLost ? { warnings: [`The variables were not saved: ${UNSUPPORTED_VARIABLES_NOTE}`] } : {}),
284
452
  });
@@ -361,17 +529,81 @@ const registerCollectionTools = (server, ctx) => {
361
529
  }));
362
530
  server.registerTool("delete_collection", {
363
531
  title: "Delete a collection",
364
- description: "Delete a collection with all its folders and requests. Permanent — confirm with the user first.",
532
+ description: "Delete a collection with all its folders and requests. Permanent — confirm with the user first. An on-prem pipeline collection cannot be deleted directly: use request_collection_deletion (an approver decides).",
365
533
  inputSchema: { workspace: shared_js_1.workspaceArg, collection: (0, shared_js_1.refArg)("Collection"), confirm: shared_js_1.confirmArg },
366
534
  annotations: shared_js_1.DESTRUCTIVE,
367
535
  }, (0, shared_js_1.guarded)(async ({ workspace, collection, confirm }) => {
368
536
  (0, shared_js_1.requireConfirm)(confirm, "a collection");
369
537
  const { ws, value: resolved } = await (0, lookups_js_1.findCollection)(ctx, workspace, collection, { exact: true });
370
- await ctx.api.deleteCollection(ws.id, resolved.item.id);
538
+ try {
539
+ await ctx.api.deleteCollection(ws.id, resolved.item.id);
540
+ }
541
+ catch (error) {
542
+ if (error instanceof errors_js_1.CallmanApiError && error.code === "COLLECTION_DELETE_REQUIRES_REVIEW") {
543
+ throw new errors_js_1.ToolInputError("COLLECTION_DELETE_REQUIRES_REVIEW", `"${resolved.item.name}" is a pipeline collection: it is deleted through review. Call request_collection_deletion { collection: "${resolved.item.id}", confirm: true } (optionally with a note and approvers); it is deleted once an approver approves the request in the Callman app.`, { collection: { id: resolved.item.id, name: resolved.item.name }, next: "request_collection_deletion" });
544
+ }
545
+ throw error;
546
+ }
371
547
  ctx.invalidate(`collections:${ws.id}`);
372
548
  ctx.invalidate(`tree:${ws.id}:${resolved.item.id}`);
373
549
  return (0, shared_js_1.ok)({ deleted: { id: resolved.item.id, name: resolved.item.name }, workspace: (0, lookups_js_1.workspaceTag)(ws), resolved: [(0, resolve_js_1.resolvedSummary)(collection, resolved)] });
374
550
  }));
551
+ server.registerTool("submit_collection", {
552
+ title: "Submit a pipeline collection for review",
553
+ description: "On-prem pipeline collections: send the current draft (requests, folders, scripts, pass criteria) for review. CI runs only the approved version, so a new or edited pipeline collection must be approved before callme run / run_collection use the change. Optionally name approvers by email (default: every eligible approver of the collection's group is notified); replace: true swaps your own open submission for the current draft. Approval happens in the Callman app — AI tools cannot approve.",
554
+ inputSchema: {
555
+ workspace: shared_js_1.workspaceArg,
556
+ collection: (0, shared_js_1.refArg)("Collection"),
557
+ note: reviewNoteArg,
558
+ approvers: approversArg,
559
+ replace: zod_1.z.boolean().optional().describe("Replace your own pending submission with the current draft"),
560
+ },
561
+ annotations: shared_js_1.WRITE,
562
+ }, (0, shared_js_1.guarded)(async ({ workspace, collection, note, approvers, replace }) => {
563
+ const { ws, value: resolved } = await (0, lookups_js_1.findCollection)(ctx, workspace, collection);
564
+ const assigneeIds = await approverIdsFor(ctx, ws.id, resolved.item.id, "update", approvers);
565
+ const { summary, request } = await ctx.api.submitCollectionReview(ws.id, resolved.item.id, {
566
+ ...(note !== undefined ? { note } : {}),
567
+ ...(assigneeIds ? { assigneeIds } : {}),
568
+ ...(replace ? { replace: true } : {}),
569
+ });
570
+ ctx.invalidate(`collections:${ws.id}`);
571
+ return (0, shared_js_1.ok)({
572
+ collection: { id: resolved.item.id, name: resolved.item.name },
573
+ workspace: (0, lookups_js_1.workspaceTag)(ws),
574
+ review: reviewSummaryView(summary),
575
+ request: reviewRequestView(request),
576
+ note: "Submitted — an approver approves it in the Callman app. Until then CI keeps running the previous approved version (none yet: CI refuses the collection).",
577
+ });
578
+ }));
579
+ server.registerTool("request_collection_deletion", {
580
+ title: "Request deletion of a pipeline collection",
581
+ description: "On-prem pipeline collections are not deleted directly (delete_collection refuses them): this sends a deletion request to the group's approvers, and the collection is deleted when one approves it in the Callman app. Confirm with the user first. Optionally a note and approver emails.",
582
+ inputSchema: {
583
+ workspace: shared_js_1.workspaceArg,
584
+ collection: (0, shared_js_1.refArg)("Collection"),
585
+ note: reviewNoteArg,
586
+ approvers: approversArg,
587
+ confirm: shared_js_1.confirmArg,
588
+ },
589
+ annotations: { ...shared_js_1.WRITE, destructiveHint: true },
590
+ }, (0, shared_js_1.guarded)(async ({ workspace, collection, note, approvers, confirm }) => {
591
+ (0, shared_js_1.requireConfirm)(confirm, "a pipeline collection (once approved)");
592
+ const { ws, value: resolved } = await (0, lookups_js_1.findCollection)(ctx, workspace, collection, { exact: true });
593
+ const assigneeIds = await approverIdsFor(ctx, ws.id, resolved.item.id, "delete", approvers);
594
+ const { summary, request } = await ctx.api.requestCollectionDeletion(ws.id, resolved.item.id, {
595
+ ...(note !== undefined ? { note } : {}),
596
+ ...(assigneeIds ? { assigneeIds } : {}),
597
+ });
598
+ ctx.invalidate(`collections:${ws.id}`);
599
+ return (0, shared_js_1.ok)({
600
+ collection: { id: resolved.item.id, name: resolved.item.name },
601
+ workspace: (0, lookups_js_1.workspaceTag)(ws),
602
+ review: reviewSummaryView(summary),
603
+ request: reviewRequestView(request),
604
+ note: "Deletion requested — the collection is deleted when an approver approves the request in the Callman app. CI keeps running it until then.",
605
+ });
606
+ }));
375
607
  server.registerTool("delete_folder", {
376
608
  title: "Delete a folder",
377
609
  description: "Delete a folder and everything inside it (subfolders and requests). Permanent — confirm with the user first.",
@@ -37,6 +37,7 @@ exports.registerRunTools = exports.prepareRun = void 0;
37
37
  const zod_1 = require("zod");
38
38
  const callman_core_1 = require("callman-core");
39
39
  const errors_js_1 = require("../client/errors.js");
40
+ const pipeline_js_1 = require("../domain/pipeline.js");
40
41
  const resolve_js_1 = require("../domain/resolve.js");
41
42
  const redact_js_1 = require("../lib/redact.js");
42
43
  const truncate_js_1 = require("../lib/truncate.js");
@@ -103,6 +104,7 @@ const prepareRun = async (ctx, ws, collectionId, environmentRef) => {
103
104
  environment: bundle.environment ? { id: bundle.environment.id, name: bundle.environment.name } : null,
104
105
  warnings,
105
106
  redaction,
107
+ pipeline: bundle.pipeline ?? null,
106
108
  };
107
109
  };
108
110
  exports.prepareRun = prepareRun;
@@ -184,7 +186,7 @@ const registerRunTools = (server, ctx) => {
184
186
  }));
185
187
  server.registerTool("run_collection", {
186
188
  title: "Run a collection locally",
187
- description: `Run a collection (or one folder of it, or specific requests) sequentially from this machine with an environment and the collection's variables, sharing variables between requests like the collection runner. Requests switched off for the runner are skipped unless named in \`only\`; data-driven contracts run once per row. Returns per-request results (as in run_request, values shown as {{key}}, with \`failure\` / \`diagnosis\` where something broke), a pass/fail summary and the variable names scripts changed. \`bail\` stops at the first failure. ${LOCAL_RUN_LIMITS} Needs requests:run.`,
189
+ description: `Run a collection (or one folder of it, or specific requests) sequentially from this machine with an environment and the collection's variables, sharing variables between requests like the collection runner. Requests switched off for the runner are skipped unless named in \`only\`; data-driven contracts run once per row. Returns per-request results (as in run_request, values shown as {{key}}, with \`failure\` / \`diagnosis\` where something broke), a pass/fail summary, the variable names scripts changed and the \`verdict\` — the pass criteria callme run applies in CI: every executed request must pass, or \`passThreshold\` percent of them, and every \`criticalRequests\` entry. An on-prem pipeline collection runs its APPROVED version (not the draft) with its approved criteria (the inputs are then ignored, \`version\` says which version ran) and may refuse folder / partial runs. \`bail\` stops at the first failure (not with passThreshold). ${LOCAL_RUN_LIMITS} Needs requests:run.`,
188
190
  inputSchema: {
189
191
  workspace: shared_js_1.workspaceArg,
190
192
  collection: (0, shared_js_1.refArg)("Collection"),
@@ -192,12 +194,25 @@ const registerRunTools = (server, ctx) => {
192
194
  only: zod_1.z.array(zod_1.z.string().min(1).max(500)).max(200).optional().describe("Run only these requests (ids or names)"),
193
195
  environment: zod_1.z.string().max(200).optional(),
194
196
  bail: zod_1.z.boolean().optional(),
197
+ passThreshold: zod_1.z.number().int().min(1).max(100).optional().describe("The run passes when at least this percent of the executed requests passed (default: all must). Ignored for pipeline collections"),
198
+ criticalRequests: zod_1.z.array(zod_1.z.string().min(1).max(500)).max(200).optional().describe("Requests (ids or names) that must pass whatever the percentage. Ignored for pipeline collections"),
195
199
  maxBodyBytes: maxBodyArg,
196
200
  },
197
201
  annotations: shared_js_1.RUN,
198
- }, (0, shared_js_1.guarded)(async ({ workspace, collection, folder, only, environment, bail, maxBodyBytes }) => {
202
+ }, (0, shared_js_1.guarded)(async ({ workspace, collection, folder, only, environment, bail, passThreshold, criticalRequests, maxBodyBytes }) => {
199
203
  const { ws, value: resolved } = await (0, lookups_js_1.findCollection)(ctx, workspace, collection);
200
204
  const prepared = await (0, exports.prepareRun)(ctx, ws.id, resolved.item.id, environment);
205
+ // What decides the verdict: a pipeline collection's approved config,
206
+ // else the inputs. Critical requests are looked up in what runs.
207
+ const isPipeline = prepared.pipeline?.kind === "pipeline";
208
+ const criticalRequestIds = !isPipeline && criticalRequests && criticalRequests.length > 0
209
+ ? [...new Set(criticalRequests.map((ref) => (0, resolve_js_1.resolveRef)("request", ref, prepared.collection.requests, { aliases: (request) => [`${request.method} ${request.url}`] }).item.id))]
210
+ : [];
211
+ const runCriteria = (0, pipeline_js_1.resolveRunCriteria)(prepared.pipeline, {
212
+ passThreshold,
213
+ criticalRequestIds: isPipeline ? (criticalRequests ?? []) : criticalRequestIds,
214
+ bail,
215
+ });
201
216
  let selection = null;
202
217
  if (folder) {
203
218
  const target = await (0, lookups_js_1.resolveFolder)(ctx, ws.id, resolved.item.id, folder);
@@ -213,6 +228,9 @@ const registerRunTools = (server, ctx) => {
213
228
  }
214
229
  if (selection && selection.length === 0)
215
230
  throw new errors_js_1.ToolInputError("NOTHING_TO_RUN", "The selection contains no requests.");
231
+ if (selection && !runCriteria.allowFolderRuns) {
232
+ throw new errors_js_1.ToolInputError("PIPELINE_FOLDER_RUN_NOT_ALLOWED", `This pipeline collection's approved config${runCriteria.version ? ` v${runCriteria.version.number}` : ""} runs the whole collection only: drop folder / only (run_request runs a single request), or allow folder runs in its pipelineConfig and get the change approved.`);
233
+ }
216
234
  const outcome = await ctx.withRunLock(() => (0, executeCollection_js_1.executeCollection)({
217
235
  collection: prepared.collection,
218
236
  env: prepared.env,
@@ -220,13 +238,24 @@ const registerRunTools = (server, ctx) => {
220
238
  connections: prepared.connections,
221
239
  only: selection,
222
240
  includeDisabled: Boolean(only && only.length > 0),
223
- bail: bail ?? false,
241
+ bail: runCriteria.bail,
224
242
  deadlineMs: ctx.config.runTimeoutMs,
225
243
  }));
244
+ const verdict = (0, pipeline_js_1.evaluateRunVerdict)({
245
+ results: outcome.results,
246
+ criteria: runCriteria.criteria,
247
+ collectionName: prepared.collection.name,
248
+ version: runCriteria.version?.number ?? null,
249
+ requests: prepared.collection.requests,
250
+ selection,
251
+ });
226
252
  return (0, shared_js_1.ok)((0, summarize_js_1.summarizeRun)(outcome, maxBodyBytes ?? ctx.config.maxBodyBytes, {
227
- collection: { id: resolved.item.id, name: resolved.item.name },
253
+ collection: { id: resolved.item.id, name: resolved.item.name, kind: runCriteria.kind },
254
+ ...(runCriteria.version ? { version: runCriteria.version } : {}),
228
255
  workspace: (0, lookups_js_1.workspaceTag)(ws),
229
256
  environment: prepared.environment,
257
+ verdict,
258
+ ...(runCriteria.notes.length > 0 ? { notes: runCriteria.notes } : {}),
230
259
  warnings: prepared.warnings,
231
260
  resolved: [(0, resolve_js_1.resolvedSummary)(collection, resolved)],
232
261
  }, prepared.redaction));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "callman-mcp",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "description": "MCP server for Callman: lets Claude Code, Claude Desktop, Cursor, Codex and Gemini CLI manage collections, requests, environments, test scenarios, UI tests (web, Android, iOS) and system-design diagrams in your Callman workspace — cloud or on-prem.",
5
5
  "keywords": [
6
6
  "callman",
@@ -62,7 +62,7 @@
62
62
  },
63
63
  "dependencies": {
64
64
  "@modelcontextprotocol/sdk": "^1.30.0",
65
- "callman-core": "^1.42.0",
65
+ "callman-core": "^1.43.0",
66
66
  "elkjs": "^0.12.0",
67
67
  "js-yaml": "^4.1.0",
68
68
  "zod": "^3.25.0"