@taiga-ai/mcp-server 0.25.0-rc.472 → 0.25.0-rc.474

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.
package/dist/index.js CHANGED
@@ -356,11 +356,11 @@ var TaigaClient = class {
356
356
  async getPolicyStanding(productId) {
357
357
  return this.request("GET", `/products/${seg(productId)}/maintenance/policies`);
358
358
  }
359
- async remediatePolicyControl(productId, controlId) {
360
- return this.request("POST", `/products/${seg(productId)}/maintenance/policies/controls/${seg(controlId)}/remediate`, {});
359
+ async remediatePolicyControl(productId, controlId, body = {}) {
360
+ return this.request("POST", `/products/${seg(productId)}/maintenance/policies/controls/${seg(controlId)}/remediate`, body);
361
361
  }
362
- async remediatePolicyViolation(productId, divergenceId) {
363
- return this.request("POST", `/products/${seg(productId)}/maintenance/policies/divergences/${seg(divergenceId)}/remediate`, {});
362
+ async remediatePolicyViolation(productId, divergenceId, body = {}) {
363
+ return this.request("POST", `/products/${seg(productId)}/maintenance/policies/divergences/${seg(divergenceId)}/remediate`, body);
364
364
  }
365
365
  async listInbox(opts) {
366
366
  const { productId, ...rest } = opts;
@@ -4284,6 +4284,7 @@ function situationOf(item) {
4284
4284
  const handback = handbackSaid(build.reviewEscalation);
4285
4285
  if (handback) stage += `, handed back because ${handback}`;
4286
4286
  }
4287
+ if (life.stage === "done" || life.stage === "canceled") stage += endingSaid(item);
4287
4288
  parts.push(`${stage}.`);
4288
4289
  if (typeof item["needsHumanCause"] === "string") {
4289
4290
  const why = [renderHalt({
@@ -4301,6 +4302,26 @@ function situationOf(item) {
4301
4302
  else if (move) parts.push(`Next: ${move}.`);
4302
4303
  return parts.join(" ");
4303
4304
  }
4305
+ /**
4306
+ * How a settled initiative ended, as the rest of its "Done" or "Canceled"
4307
+ * sentence (PRO-2821): the pull request that landed it, the move that settled
4308
+ * it and who made it, and what took over. Only the single read carries
4309
+ * `ending`; a list row says nothing more.
4310
+ */
4311
+ function endingSaid(item) {
4312
+ const ending = item["ending"];
4313
+ const merged = typeof ending?.landedPr?.number === "number" ? ending.landedPr.number : null;
4314
+ const said = [];
4315
+ if (merged !== null) said.push(`: PR #${merged} merged`);
4316
+ else if (ending && (ending.via === "moved" || ending.via === "off_the_line")) {
4317
+ const who = typeof ending.by?.name === "string" ? ending.by.name : ending.by?.type === "system" ? "Taiga" : "someone";
4318
+ const when = typeof ending.at === "string" ? ` on ${ending.at.slice(0, 10)}` : "";
4319
+ said.push(ending.via === "off_the_line" ? ` by ${who}${when}, who took it off the line` : ` by ${who}${when}`);
4320
+ }
4321
+ const next = ending?.supersededBy;
4322
+ if (next && typeof next.id === "string") said.push(`; superseded by "${String(next.title)}" (${next.id})`);
4323
+ return said.join("");
4324
+ }
4304
4325
  /** A row or item with its situation added when it has a lifecycle. */
4305
4326
  function withSituation(item, row = item) {
4306
4327
  const situation = situationOf(item);
@@ -5018,16 +5039,18 @@ function registerMaintenanceTools(server, client) {
5018
5039
  }));
5019
5040
  server.registerTool("taiga_fix_policy_violation", {
5020
5041
  title: "Fix policy violations",
5021
- description: "Raise one initiative that fixes a product's policy violations and add it to the end of the line. Name a control to cover all its violations with no initiative yet, or one violationId when the control has several. The violation stays listed until the next assessment no longer finds it, even while the fix is under way.",
5042
+ description: "Raise one initiative that fixes a product's policy violations and add it to the end of the line. Name a control to cover all its violations with no initiative yet, or one violationId when the control has several. The violation stays listed until the next assessment no longer finds it, even while the fix is under way. A violation whose last fix was done after it was last assessed is refused: refresh the reality documents to reassess it first, or pass fixAgain: true if the person wants another fix anyway.",
5022
5043
  inputSchema: z.object({
5023
5044
  productId,
5024
5045
  controlId: z.string().min(1).max(40).optional().describe("The control's identifier, e.g. SEC-3.4"),
5025
- violationId: idInput("violation", "taiga_get_policy_standing gives it").optional()
5046
+ violationId: idInput("violation", "taiga_get_policy_standing gives it").optional(),
5047
+ fixAgain: z.boolean().optional().describe("Raise a fix even though the last one was done after the last assessment (default false)")
5026
5048
  }),
5027
5049
  annotations: OPERATE
5028
5050
  }, (params) => runTool(async () => {
5029
5051
  if (params.controlId === void 0 === (params.violationId === void 0)) return toolError("VALIDATION_ERROR", "Name either a control (controlId) or one violation (violationId), not both or neither.");
5030
- return toolResult((params.controlId ? await client.remediatePolicyControl(params.productId, params.controlId) : await client.remediatePolicyViolation(params.productId, params.violationId)).data);
5052
+ const body = { refuseIfFixedSinceAssessment: params.fixAgain !== true };
5053
+ return toolResult((params.controlId ? await client.remediatePolicyControl(params.productId, params.controlId, body) : await client.remediatePolicyViolation(params.productId, params.violationId, body)).data);
5031
5054
  }));
5032
5055
  }
5033
5056
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taiga-ai/mcp-server",
3
- "version": "0.25.0-rc.472",
3
+ "version": "0.25.0-rc.474",
4
4
  "description": "MCP server for Taiga -- gives AI assistants access to documents and products",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/client.ts CHANGED
@@ -31,6 +31,7 @@ import type {
31
31
  MonitoringHistory,
32
32
  MoveProductResult,
33
33
  PolicyConformance,
34
+ PolicyRemediationInput,
34
35
  PolicyRemediationResult,
35
36
  Product,
36
37
  ProductDocument,
@@ -647,19 +648,19 @@ export class TaigaClient {
647
648
  return this.request<One<PolicyConformance>>('GET', `/products/${seg(productId)}/maintenance/policies`);
648
649
  }
649
650
 
650
- async remediatePolicyControl(productId: string, controlId: string) {
651
+ async remediatePolicyControl(productId: string, controlId: string, body: PolicyRemediationInput = {}) {
651
652
  return this.request<One<PolicyRemediationResult>>(
652
653
  'POST',
653
654
  `/products/${seg(productId)}/maintenance/policies/controls/${seg(controlId)}/remediate`,
654
- {}
655
+ body
655
656
  );
656
657
  }
657
658
 
658
- async remediatePolicyViolation(productId: string, divergenceId: string) {
659
+ async remediatePolicyViolation(productId: string, divergenceId: string, body: PolicyRemediationInput = {}) {
659
660
  return this.request<One<PolicyRemediationResult>>(
660
661
  'POST',
661
662
  `/products/${seg(productId)}/maintenance/policies/divergences/${seg(divergenceId)}/remediate`,
662
- {}
663
+ body
663
664
  );
664
665
  }
665
666
 
@@ -5189,7 +5189,7 @@ describe('MCP server integration', () => {
5189
5189
  );
5190
5190
  await client.callTool({
5191
5191
  name: 'taiga_fix_policy_violation',
5192
- arguments: { productId: PRODUCT_ID, violationId: VIOLATION_ID },
5192
+ arguments: { productId: PRODUCT_ID, violationId: VIOLATION_ID, fixAgain: true },
5193
5193
  });
5194
5194
  const both = await client.callTool({
5195
5195
  name: 'taiga_fix_policy_violation',
@@ -5201,6 +5201,19 @@ describe('MCP server integration', () => {
5201
5201
  `/v1/products/${PRODUCT_ID}/maintenance/policies/controls/SEC-3.4/remediate`,
5202
5202
  `/v1/products/${PRODUCT_ID}/maintenance/policies/divergences/${VIOLATION_ID}/remediate`,
5203
5203
  ]);
5204
+ // An unassessed last fix is refused unless the person wants another (PRO-2821).
5205
+ expect(
5206
+ sentBody(fetchSpy, 'POST', `/v1/products/${PRODUCT_ID}/maintenance/policies/controls/SEC-3.4/remediate`)
5207
+ ).toEqual({
5208
+ refuseIfFixedSinceAssessment: true,
5209
+ });
5210
+ expect(
5211
+ sentBody(
5212
+ fetchSpy,
5213
+ 'POST',
5214
+ `/v1/products/${PRODUCT_ID}/maintenance/policies/divergences/${VIOLATION_ID}/remediate`
5215
+ )
5216
+ ).toEqual({ refuseIfFixedSinceAssessment: false });
5204
5217
  expect(both.isError).toBe(true);
5205
5218
  });
5206
5219
 
@@ -7683,6 +7696,132 @@ describe('what the third agent review found (PRO-2800)', () => {
7683
7696
  expect(runs[3]).toHaveProperty('prDraft', true);
7684
7697
  });
7685
7698
 
7699
+ it('says how a settled initiative ended: the merged PR, who canceled it and what took over (PRO-2821)', async () => {
7700
+ const NEXT = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f21';
7701
+ const canceled = {
7702
+ id: ITEM_ID,
7703
+ title: 'SDLC-3.5: No secret detection',
7704
+ status: 'canceled',
7705
+ state: 'canceled',
7706
+ needsHumanCause: null,
7707
+ lifecycle: { turn: null, stage: 'canceled', cta: null, planFailureCause: null, latestBuild: null },
7708
+ ending: {
7709
+ status: 'canceled',
7710
+ via: 'off_the_line',
7711
+ at: '2026-10-08T05:36:08.000Z',
7712
+ by: { type: 'user', id: 'u1', name: 'Riley' },
7713
+ landedPr: null,
7714
+ supersededBy: { id: NEXT, title: 'SDLC-3.5: No secret detection' },
7715
+ },
7716
+ };
7717
+ const done = {
7718
+ id: NEXT,
7719
+ title: 'SDLC-3.5: No secret detection',
7720
+ status: 'done',
7721
+ state: 'done',
7722
+ needsHumanCause: null,
7723
+ lifecycle: {
7724
+ turn: null,
7725
+ stage: 'done',
7726
+ cta: 'view_pr',
7727
+ planFailureCause: null,
7728
+ latestBuild: { status: 'completed', prNumber: 20, prMerged: true },
7729
+ },
7730
+ };
7731
+ const { client } = await bootMcpPair({
7732
+ capabilities: ['backlog:read'],
7733
+ routes: {
7734
+ [`GET /v1/backlog/${ITEM_ID}`]: { data: canceled },
7735
+ [`/v1/products/${PRODUCT_ID}/backlog`]: { data: [done], nextCursor: null },
7736
+ },
7737
+ });
7738
+
7739
+ const got = parseToolResult(
7740
+ await client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } })
7741
+ );
7742
+ const listed = parseToolResult(
7743
+ await client.callTool({ name: 'taiga_list_initiatives', arguments: { productId: PRODUCT_ID, status: 'all' } })
7744
+ );
7745
+
7746
+ expect(got.situation).toBe(
7747
+ `Canceled by Riley on 2026-10-08, who took it off the line; superseded by "SDLC-3.5: No secret detection" (${NEXT}).`
7748
+ );
7749
+ // A list row has no ending: its latest run cannot say how it ended.
7750
+ expect(listed.data[0].situation).toMatch(/^Done\./);
7751
+ expect(listed.data[0].situation).not.toMatch(/merged/);
7752
+ });
7753
+
7754
+ it('names the pull request a merge landed on the single read (PRO-2821)', async () => {
7755
+ const done = {
7756
+ id: ITEM_ID,
7757
+ title: 'Add login',
7758
+ status: 'done',
7759
+ state: 'done',
7760
+ needsHumanCause: null,
7761
+ lifecycle: { turn: null, stage: 'done', cta: 'view_pr', planFailureCause: null, latestBuild: null },
7762
+ ending: {
7763
+ status: 'done',
7764
+ via: 'merged',
7765
+ at: null,
7766
+ by: null,
7767
+ landedPr: { number: 20, url: 'https://x/20' },
7768
+ supersededBy: null,
7769
+ },
7770
+ };
7771
+ const { client } = await bootMcpPair({
7772
+ capabilities: ['backlog:read'],
7773
+ routes: { [`GET /v1/backlog/${ITEM_ID}`]: { data: done } },
7774
+ });
7775
+
7776
+ const got = parseToolResult(
7777
+ await client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } })
7778
+ );
7779
+
7780
+ expect(got.situation).toMatch(/^Done: PR #20 merged\./);
7781
+ });
7782
+
7783
+ it('does not read a merge into a canceled initiative that kept its merged run (PRO-2821)', async () => {
7784
+ const item = {
7785
+ id: ITEM_ID,
7786
+ title: 'Add login',
7787
+ status: 'canceled',
7788
+ state: 'canceled',
7789
+ needsHumanCause: null,
7790
+ lifecycle: {
7791
+ turn: null,
7792
+ stage: 'canceled',
7793
+ cta: null,
7794
+ planFailureCause: null,
7795
+ latestBuild: { status: 'completed', prNumber: 20, prMerged: true },
7796
+ },
7797
+ };
7798
+ const ending = {
7799
+ status: 'canceled',
7800
+ via: 'moved',
7801
+ at: '2026-10-09T10:00:00.000Z',
7802
+ by: { type: 'user', name: 'Riley' },
7803
+ landedPr: null,
7804
+ supersededBy: null,
7805
+ };
7806
+ const { client } = await bootMcpPair({
7807
+ capabilities: ['backlog:read'],
7808
+ routes: {
7809
+ [`GET /v1/backlog/${ITEM_ID}`]: { data: { ...item, ending } },
7810
+ [`/v1/products/${PRODUCT_ID}/backlog`]: { data: [item], nextCursor: null },
7811
+ },
7812
+ });
7813
+
7814
+ const got = parseToolResult(
7815
+ await client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } })
7816
+ );
7817
+ const listed = parseToolResult(
7818
+ await client.callTool({ name: 'taiga_list_initiatives', arguments: { productId: PRODUCT_ID, status: 'all' } })
7819
+ );
7820
+
7821
+ expect(got.situation).toBe('Canceled by Riley on 2026-10-09.');
7822
+ expect(listed.data[0].situation).toBe('Canceled.');
7823
+ });
7824
+
7686
7825
  it('says where an initiative stands and the call that moves it on, in one sentence', async () => {
7687
7826
  const item = {
7688
7827
  id: ITEM_ID,
@@ -134,6 +134,7 @@ export function situationOf(item: Record<string, unknown>): string | null {
134
134
  const handback = handbackSaid((build as { reviewEscalation?: unknown }).reviewEscalation);
135
135
  if (handback) stage += `, handed back because ${handback}`;
136
136
  }
137
+ if (life.stage === 'done' || life.stage === 'canceled') stage += endingSaid(item);
137
138
  parts.push(`${stage}.`);
138
139
  if (typeof item['needsHumanCause'] === 'string') {
139
140
  const halt = renderHalt({ cause: item['needsHumanCause'], params: {} } as unknown as Halt);
@@ -160,6 +161,41 @@ export function situationOf(item: Record<string, unknown>): string | null {
160
161
  return parts.join(' ');
161
162
  }
162
163
 
164
+ /**
165
+ * How a settled initiative ended, as the rest of its "Done" or "Canceled"
166
+ * sentence (PRO-2821): the pull request that landed it, the move that settled
167
+ * it and who made it, and what took over. Only the single read carries
168
+ * `ending`; a list row says nothing more.
169
+ */
170
+ function endingSaid(item: Record<string, unknown>) {
171
+ const ending = item['ending'] as
172
+ | {
173
+ via?: unknown;
174
+ at?: unknown;
175
+ by?: { name?: unknown; type?: unknown } | null;
176
+ landedPr?: { number?: unknown } | null;
177
+ supersededBy?: { id?: unknown; title?: unknown } | null;
178
+ }
179
+ | null
180
+ | undefined;
181
+ // The single read's ending decides. A list row has none, and its latest run cannot say how it ended: a merged
182
+ // initiative can be reopened and moved to Done by hand, keeping that run. It says Done or Canceled alone.
183
+ const merged = typeof ending?.landedPr?.number === 'number' ? ending.landedPr.number : null;
184
+ const said: string[] = [];
185
+ if (merged !== null) said.push(`: PR #${merged} merged`);
186
+ else if (ending && (ending.via === 'moved' || ending.via === 'off_the_line')) {
187
+ const who =
188
+ typeof ending.by?.name === 'string' ? ending.by.name : ending.by?.type === 'system' ? 'Taiga' : 'someone';
189
+ const when = typeof ending.at === 'string' ? ` on ${ending.at.slice(0, 10)}` : '';
190
+ said.push(ending.via === 'off_the_line' ? ` by ${who}${when}, who took it off the line` : ` by ${who}${when}`);
191
+ }
192
+ const next = ending?.supersededBy;
193
+ if (next && typeof next.id === 'string') {
194
+ said.push(`; superseded by "${String(next.title)}" (${next.id})`);
195
+ }
196
+ return said.join('');
197
+ }
198
+
163
199
  /** A row or item with its situation added when it has a lifecycle. */
164
200
  export function withSituation(
165
201
  item: Record<string, unknown>,
@@ -515,11 +515,16 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
515
515
  "Raise one initiative that fixes a product's policy violations and add it to the end of the line. Name a " +
516
516
  'control to cover all its violations with no initiative yet, or one violationId when the control has ' +
517
517
  'several. The violation stays listed until the next assessment no longer finds it, even while the fix is ' +
518
- 'under way.',
518
+ 'under way. A violation whose last fix was done after it was last assessed is refused: refresh the ' +
519
+ 'reality documents to reassess it first, or pass fixAgain: true if the person wants another fix anyway.',
519
520
  inputSchema: z.object({
520
521
  productId,
521
522
  controlId: z.string().min(1).max(40).optional().describe("The control's identifier, e.g. SEC-3.4"),
522
523
  violationId: idInput('violation', 'taiga_get_policy_standing gives it').optional(),
524
+ fixAgain: z
525
+ .boolean()
526
+ .optional()
527
+ .describe('Raise a fix even though the last one was done after the last assessment (default false)'),
523
528
  }),
524
529
  annotations: OPERATE,
525
530
  },
@@ -531,9 +536,11 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
531
536
  'Name either a control (controlId) or one violation (violationId), not both or neither.'
532
537
  );
533
538
  }
539
+ // The API refuses a violation whose last fix is unassessed, unless the person wants another (PRO-2821).
540
+ const body = { refuseIfFixedSinceAssessment: params.fixAgain !== true };
534
541
  const result = params.controlId
535
- ? await client.remediatePolicyControl(params.productId, params.controlId)
536
- : await client.remediatePolicyViolation(params.productId, params.violationId!);
542
+ ? await client.remediatePolicyControl(params.productId, params.controlId, body)
543
+ : await client.remediatePolicyViolation(params.productId, params.violationId!, body);
537
544
  return toolResult(result.data);
538
545
  })
539
546
  );