@taiga-ai/mcp-server 0.25.0-dev.466 → 0.25.0-dev.467

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
@@ -3781,20 +3781,27 @@ const INITIATIVE_CHECKS = 10;
3781
3781
  * The status of the initiative behind each run still waiting on its pull
3782
3782
  * request (PRO-2800). A reviewing agent read a run of a canceled initiative as
3783
3783
  * "waiting for a person to review and merge" while Action Required called the
3784
- * same review stale. A 404 is 'deleted'. Best effort: an initiative past the
3785
- * cap, one whose read failed, and every one without backlog:read is
3786
- * 'unchecked', which withInitiativeState adds to the row's outcome.
3784
+ * same review stale.
3785
+ *
3786
+ * The run says it itself since PRO-2808 (initiativeClosed: done, canceled,
3787
+ * archived, deleted, or null while live), for every key that can read it.
3788
+ * Against an API from before that, each initiative is read: a 404 is
3789
+ * 'deleted', and an initiative past the cap, one whose read failed, and every
3790
+ * one without backlog:read is 'unchecked', which withInitiativeState adds to
3791
+ * the row's outcome.
3787
3792
  */
3788
3793
  async function initiativeStates(client, rows) {
3789
3794
  const states = /* @__PURE__ */ new Map();
3790
3795
  const waiting = rows.filter((row) => ["needs_review", "completed"].includes(String(row["status"])) && row["prMerged"] !== true);
3791
- const all = [...new Set(waiting.map((row) => row["backlogItemId"]).filter((id) => typeof id === "string"))];
3796
+ const told = waiting.filter((row) => "initiativeClosed" in row);
3797
+ for (const row of told) if (typeof row["backlogItemId"] === "string") states.set(row["backlogItemId"], typeof row["initiativeClosed"] === "string" ? row["initiativeClosed"] : "live");
3798
+ const all = [...new Set(waiting.filter((row) => !("initiativeClosed" in row)).map((row) => row["backlogItemId"]).filter((id) => typeof id === "string" && !states.has(id)))];
3792
3799
  const ids = client.hasCapability("backlog:read") ? all.slice(0, INITIATIVE_CHECKS) : [];
3793
3800
  for (const id of all) if (!ids.includes(id)) states.set(id, "unchecked");
3794
3801
  await Promise.all(ids.map(async (id) => {
3795
3802
  try {
3796
3803
  const item = (await client.getBacklogItem(id)).data;
3797
- states.set(id, String(item["status"] ?? item["state"]));
3804
+ states.set(id, item["archivedAt"] ? "archived" : String(item["status"] ?? item["state"]));
3798
3805
  } catch (error) {
3799
3806
  if (error instanceof TaigaApiError && error.status === 404) return void states.set(id, "deleted");
3800
3807
  if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
@@ -3803,7 +3810,18 @@ async function initiativeStates(client, rows) {
3803
3810
  }));
3804
3811
  return states;
3805
3812
  }
3806
- /** A readable run row that also says when its initiative was canceled or deleted while its pull request waited. */
3813
+ /**
3814
+ * How each way an initiative ends reads on a run it left waiting: the same set
3815
+ * Action Required closes a run's review for (PRO-2806). A done one's waiting
3816
+ * run is not the one that landed it: that run is merged and never waiting.
3817
+ */
3818
+ const INITIATIVE_ENDED = {
3819
+ done: "was marked done",
3820
+ canceled: "was canceled",
3821
+ archived: "was archived",
3822
+ deleted: "was deleted"
3823
+ };
3824
+ /** A readable run row that also says when its initiative ended while its pull request waited. */
3807
3825
  function withInitiativeState(row, states) {
3808
3826
  const state = states.get(String(row["backlogItemId"]));
3809
3827
  if (!["gated", "unmerged"].includes(String(row["disposition"]))) return row;
@@ -3811,14 +3829,15 @@ function withInitiativeState(row, states) {
3811
3829
  ...row,
3812
3830
  outcome: `${String(row["outcome"])} Whether its initiative still wants it was not checked.`
3813
3831
  };
3814
- if (state !== "canceled" && state !== "deleted") return row;
3832
+ const ended = state ? INITIATIVE_ENDED[state] : void 0;
3833
+ if (!ended) return row;
3815
3834
  const pr = typeof row["prNumber"] === "number" ? `PR #${row["prNumber"]}` : "its pull request";
3816
3835
  const open = row["prState"] === "open" ? ` ${pr} is still open on the forge.` : "";
3817
3836
  return {
3818
3837
  ...row,
3819
3838
  disposition: "abandoned",
3820
3839
  actions: [],
3821
- outcome: `Its initiative was ${state}, so nobody is waiting on this run.${open}`
3840
+ outcome: `Its initiative ${ended}, so nobody is waiting on this run.${open}`
3822
3841
  };
3823
3842
  }
3824
3843
  /** A step that changed only prose: no code of its own could have broken a test. */
@@ -3925,7 +3944,7 @@ function registerBuildTools(server, client) {
3925
3944
  if (!client.hasCapability("products:read")) return;
3926
3945
  server.registerTool("taiga_list_runs", {
3927
3946
  title: "List runs",
3928
- description: "List coding agent runs, newest first: branch, pull request, step progress, outcome (one sentence), actions and disposition: in_flight, gated (a person owes a review and merge), succeeded (merged), failed, stopped, rejected (pull request closed unmerged), unmerged, or abandoned (initiative canceled or deleted). Without productId, every product's runs; active: true keeps running ones. taiga_get_run reads one; carry work on from the initiative (taiga_resume_initiative).",
3947
+ description: "List coding agent runs, newest first: branch, pull request, step progress, outcome (one sentence), actions and disposition: in_flight, gated (a person owes a review and merge), succeeded (merged), failed, stopped, rejected (pull request closed unmerged), unmerged, or abandoned (its initiative ended: done, canceled, archived or deleted). Without productId, every product's runs; active: true keeps running ones. taiga_get_run reads one; carry work on from the initiative (taiga_resume_initiative).",
3929
3948
  inputSchema: z.object({
3930
3949
  productId: productId.optional(),
3931
3950
  initiativeId: idInput("initiative", "taiga_list_initiatives (query finds one by title) gives it").optional().describe("Only this initiative's runs -- get from taiga_list_initiatives; needs productId"),
@@ -21420,41 +21439,29 @@ const MISSING_DOCUMENTS_HOW = "An imported product gets them from its import (ta
21420
21439
  /**
21421
21440
  * Refuse a name already taken (PRO-2800): the API appends -2 to the slug and
21422
21441
  * creates a second one, so a retried call or two agents at once left
21423
- * duplicates nobody asked for. allowDuplicateName says the person means it.
21442
+ * duplicates nobody asked for. The API checks it in the create's transaction
21443
+ * (PRO-2808), so names past any page and two creates at once are caught too.
21444
+ * allowDuplicateName says the person means it.
21424
21445
  */
21425
21446
  const allowDuplicateName = z.boolean().optional().describe("Create it even when one with the same name exists (default false: refused, naming the existing one)");
21426
- /** The same name, as a person would read it: case and surrounding space aside. */
21427
- const sameName = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
21428
- /** Pages read looking for a taken name; past this the create goes ahead. */
21429
- const NAME_PAGES = 5;
21430
- /**
21431
- * The first row with this name, across pages. Best effort: a list the API
21432
- * refuses or fails leaves the check out rather than the create.
21433
- */
21434
- async function firstNamed(name, read) {
21435
- let cursor;
21436
- try {
21437
- for (let pageIndex = 0; pageIndex < NAME_PAGES; pageIndex += 1) {
21438
- const result = await read(cursor);
21439
- const same = result.data.find((row) => sameName(row.name, name));
21440
- if (same) return same;
21441
- if (!result.nextCursor) return null;
21442
- cursor = result.nextCursor;
21443
- }
21444
- } catch (error) {
21445
- if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
21446
- }
21447
- return null;
21448
- }
21449
21447
  /**
21450
- * The refusal names the one that exists and leaves the choice to the person: another session may have made it, so
21451
- * "use it" would hand the agent someone else's work (PRO-2800, review 6).
21452
- */
21453
- function duplicateName(kind, existing, where) {
21454
- const made = typeof existing.createdAt === "string" ? `, created ${existing.createdAt}` : "";
21455
- return toolError("DUPLICATE_NAME", `A ${kind} named "${existing.name}" already exists ${where} (${existing.id}${made}). It may not be this person's: ask whether they meant that one, want another name, or want a second (allowDuplicateName: true).`, {
21456
- existingId: existing.id,
21457
- ...existing.slug ? { existingSlug: existing.slug } : {}
21448
+ * The API's refusal of a taken name, as the tool's: it names the one that
21449
+ * exists when the caller may read it, and leaves the choice to the person.
21450
+ * Another session may have made it, so "use it" would hand the agent someone
21451
+ * else's work (PRO-2800, review 6). Anything else is not this refusal.
21452
+ */
21453
+ function duplicateName(kind, error, where) {
21454
+ if (!(error instanceof TaigaApiError) || error.status !== 409 || error.details?.["reason"] !== "duplicate_name") return null;
21455
+ const said = (key) => typeof error.details?.[key] === "string" ? error.details[key] : void 0;
21456
+ const [existingId, existingSlug, name, createdAt] = [
21457
+ said("existingId"),
21458
+ said("existingSlug"),
21459
+ said("existingName"),
21460
+ said("existingCreatedAt")
21461
+ ];
21462
+ return toolError("DUPLICATE_NAME", `${name ? `A ${kind} named "${name}"` : `A ${kind} with this name`} already exists ${where}${existingId ? ` (${existingId}${createdAt ? `, created ${createdAt}` : ""})` : ""}. ${existingId ? "It may not be this person's: ask whether they meant that one, want another name, or want a second" : "The caller cannot read it: ask whether they want another name or a second"} (allowDuplicateName: true).`, {
21463
+ ...existingId ? { existingId } : {},
21464
+ ...existingSlug ? { existingSlug } : {}
21458
21465
  });
21459
21466
  }
21460
21467
  /** A product row the model can act on: how it started, and while in setup what it still needs. */
@@ -21495,14 +21502,16 @@ function registerSetupTools(server, client) {
21495
21502
  }),
21496
21503
  annotations: WRITE
21497
21504
  }, (params) => runTool(async () => {
21498
- if (!params.allowDuplicateName) {
21499
- const same = await firstNamed(params.name, async (cursor) => client.listFactories({
21500
- limit: 100,
21501
- cursor
21502
- }));
21503
- if (same) return duplicateName("factory", same, "in this organization");
21505
+ try {
21506
+ return toolResult(pickFields([(await client.createFactory({
21507
+ name: params.name,
21508
+ refuseDuplicateName: !params.allowDuplicateName
21509
+ })).data], FACTORY_FIELDS)[0]);
21510
+ } catch (error) {
21511
+ const refused = duplicateName("factory", error, "in this organization");
21512
+ if (refused) return refused;
21513
+ throw error;
21504
21514
  }
21505
- return toolResult(pickFields([(await client.createFactory({ name: params.name })).data], FACTORY_FIELDS)[0]);
21506
21515
  }));
21507
21516
  server.registerTool("taiga_rename_factory", {
21508
21517
  title: "Rename a factory",
@@ -21567,22 +21576,22 @@ function registerSetupTools(server, client) {
21567
21576
  }, (params) => runTool(async () => {
21568
21577
  const contradiction = notesWithNothingToDescribe(params);
21569
21578
  if (contradiction) return contradiction;
21570
- if (!params.allowDuplicateName) {
21571
- const same = await firstNamed(params.name, async (cursor) => client.listProducts({
21579
+ let product;
21580
+ try {
21581
+ product = (await client.createProduct({
21582
+ name: params.name,
21572
21583
  factoryId: params.factoryId,
21573
- limit: 100,
21574
- cursor
21575
- }));
21576
- if (same) return duplicateName("product", same, "in this factory");
21584
+ ...params.start === "import" ? { projectType: "brownfield" } : {},
21585
+ ...params.ownsInfrastructure !== void 0 ? { ownsInfrastructure: params.ownsInfrastructure } : {},
21586
+ ...params.ownsDeployment !== void 0 ? { ownsDeployment: params.ownsDeployment } : {},
21587
+ ...params.deploymentNotes !== void 0 ? { deploymentNotes: params.deploymentNotes } : {},
21588
+ refuseDuplicateName: !params.allowDuplicateName
21589
+ })).data;
21590
+ } catch (error) {
21591
+ const refused = duplicateName("product", error, "in this factory");
21592
+ if (refused) return refused;
21593
+ throw error;
21577
21594
  }
21578
- const product = (await client.createProduct({
21579
- name: params.name,
21580
- factoryId: params.factoryId,
21581
- ...params.start === "import" ? { projectType: "brownfield" } : {},
21582
- ...params.ownsInfrastructure !== void 0 ? { ownsInfrastructure: params.ownsInfrastructure } : {},
21583
- ...params.ownsDeployment !== void 0 ? { ownsDeployment: params.ownsDeployment } : {},
21584
- ...params.deploymentNotes !== void 0 ? { deploymentNotes: params.deploymentNotes } : {}
21585
- })).data;
21586
21595
  return toolResult(await productRow(client, product));
21587
21596
  }));
21588
21597
  server.registerTool("taiga_update_product", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taiga-ai/mcp-server",
3
- "version": "0.25.0-dev.466",
3
+ "version": "0.25.0-dev.467",
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
@@ -44,6 +44,8 @@ import type {
44
44
  SetupTaskListEntry,
45
45
  TenantDocument,
46
46
  UptimeReport,
47
+ CreateFactoryInput,
48
+ CreateProductInput,
47
49
  } from '@taiga/types';
48
50
  import { SERVER_VERSION } from './version.js';
49
51
 
@@ -844,7 +846,7 @@ export class TaigaClient {
844
846
  return this.request<Page<Factory>>('GET', `/factories${qs ? `?${qs}` : ''}`);
845
847
  }
846
848
 
847
- async createFactory(body: { name: string }) {
849
+ async createFactory(body: CreateFactoryInput) {
848
850
  return this.request<One<Factory>>('POST', '/factories', body);
849
851
  }
850
852
 
@@ -856,14 +858,18 @@ export class TaigaClient {
856
858
  // Setting a product up
857
859
  // ---------------------------------------------------------------------------
858
860
 
859
- async createProduct(body: {
860
- name: string;
861
- factoryId: string;
862
- projectType?: 'brownfield' | undefined;
863
- ownsInfrastructure?: boolean | null | undefined;
864
- ownsDeployment?: boolean | null | undefined;
865
- deploymentNotes?: string | undefined;
866
- }) {
861
+ async createProduct(
862
+ body: Pick<
863
+ CreateProductInput,
864
+ | 'name'
865
+ | 'factoryId'
866
+ | 'projectType'
867
+ | 'ownsInfrastructure'
868
+ | 'ownsDeployment'
869
+ | 'deploymentNotes'
870
+ | 'refuseDuplicateName'
871
+ >
872
+ ) {
867
873
  return this.request<One<Product>>('POST', '/products', body);
868
874
  }
869
875
 
@@ -5790,13 +5790,14 @@ describe('MCP server integration', () => {
5790
5790
  );
5791
5791
 
5792
5792
  // Following the organization is saying nothing, as the web's dialog does.
5793
- expect(firstBody).toEqual({ name: 'Billing', factoryId: FACTORY_ID });
5793
+ expect(firstBody).toEqual({ name: 'Billing', factoryId: FACTORY_ID, refuseDuplicateName: true });
5794
5794
  expect(sentBody(fetchSpy, 'POST', '/v1/products')).toEqual({
5795
5795
  name: 'Billing',
5796
5796
  factoryId: FACTORY_ID,
5797
5797
  projectType: 'brownfield',
5798
5798
  ownsDeployment: false,
5799
5799
  deploymentNotes: 'Platform team owns the pipelines',
5800
+ refuseDuplicateName: true,
5800
5801
  });
5801
5802
  expect(scratch).toMatchObject({ id: PRODUCT_ID, projectType: 'greenfield' });
5802
5803
  expect(imported).toMatchObject({ projectType: 'brownfield', ownsDeployment: false });
@@ -5889,7 +5890,7 @@ describe('MCP server integration', () => {
5889
5890
  arguments: { productId: PRODUCT_ID, factoryId: OTHER_FACTORY_ID, stopLine: true },
5890
5891
  });
5891
5892
 
5892
- expect(factoryBody).toEqual({ name: 'Payments' });
5893
+ expect(factoryBody).toEqual({ name: 'Payments', refuseDuplicateName: true });
5893
5894
  expect(created).toEqual({ id: OTHER_FACTORY_ID, name: 'Payments', slug: 'payments', isDefault: false });
5894
5895
  expect(renamed.name).toBe('Payments EU');
5895
5896
  expect(seen).toEqual(preview);
@@ -7810,58 +7811,142 @@ describe('what the fourth agent review found (PRO-2800)', () => {
7810
7811
  expect(runs[0]!['actions']).toEqual([]);
7811
7812
  });
7812
7813
 
7813
- it('refuses a factory or product name already taken, unless the person wants a second', async () => {
7814
+ it("reads how a run's initiative ended from the run itself, with no backlog read (PRO-2808)", async () => {
7815
+ const base = { productId: PRODUCT_ID, status: 'needs_review', prState: 'open', completedSteps: 3, totalSteps: 3 };
7814
7816
  const { client, fetchSpy } = await bootMcpPair({
7815
- capabilities: ALL_CAPABILITIES,
7817
+ // No backlog:read: before PRO-2808 these rows said only that nobody checked.
7818
+ capabilities: ['products:read'],
7816
7819
  routes: {
7817
- 'GET /v1/factories': { data: [{ id: FACTORY_ID, name: 'Platform', slug: 'platform' }], nextCursor: null },
7818
- 'POST /v1/factories': { data: { id: 'new', name: 'platform', slug: 'platform-2', isDefault: false } },
7820
+ [`/v1/products/${PRODUCT_ID}/builds`]: {
7821
+ data: [
7822
+ { ...base, id: 'r1', backlogItemId: 'i1', prNumber: 10, initiativeClosed: 'archived' },
7823
+ { ...base, id: 'r2', backlogItemId: 'i2', prNumber: 11, initiativeClosed: 'done' },
7824
+ { ...base, id: 'r3', backlogItemId: 'i3', prNumber: 12, initiativeClosed: null },
7825
+ ],
7826
+ nextCursor: null,
7827
+ },
7819
7828
  },
7820
7829
  });
7821
7830
 
7822
- const refused = await client.callTool({ name: 'taiga_create_factory', arguments: { name: ' platform ' } });
7823
- const made = await client.callTool({
7824
- name: 'taiga_create_factory',
7825
- arguments: { name: 'platform', allowDuplicateName: true },
7826
- });
7831
+ const runs = parseToolResult(
7832
+ await client.callTool({ name: 'taiga_list_runs', arguments: { productId: PRODUCT_ID } })
7833
+ ).data as Record<string, unknown>[];
7827
7834
 
7828
- expect(parseToolResult(refused)).toMatchObject({ error: 'DUPLICATE_NAME', details: { existingId: FACTORY_ID } });
7829
- expect(made.isError).toBeFalsy();
7830
- expect(
7831
- fetchSpy.mock.calls.filter((call: unknown[]) => (call[1] as RequestInit | undefined)?.method === 'POST')
7832
- ).toHaveLength(1);
7835
+ expect(runs.map((run) => [run['disposition'], run['outcome']])).toEqual([
7836
+ [
7837
+ 'abandoned',
7838
+ 'Its initiative was archived, so nobody is waiting on this run. PR #10 is still open on the forge.',
7839
+ ],
7840
+ [
7841
+ 'abandoned',
7842
+ 'Its initiative was marked done, so nobody is waiting on this run. PR #11 is still open on the forge.',
7843
+ ],
7844
+ [runs[2]!['disposition'], expect.not.stringMatching(/not checked|abandoned|nobody/)],
7845
+ ]);
7846
+ expect(runs[2]!['disposition']).not.toBe('abandoned');
7847
+ expect(fetchSpy.mock.calls.some(([url]) => String(url).includes('/v1/backlog/'))).toBe(false);
7833
7848
  });
7834
7849
 
7835
- it('refuses a product name already taken, unless the person wants a second', async () => {
7836
- const { client, fetchSpy } = await bootMcpPair({
7837
- capabilities: ALL_CAPABILITIES,
7850
+ it('reads an archived initiative as archived from an API that does not say it on the run (PRO-2808)', async () => {
7851
+ const base = { productId: PRODUCT_ID, status: 'needs_review', prState: 'open', completedSteps: 3, totalSteps: 3 };
7852
+ const { client } = await bootMcpPair({
7853
+ capabilities: ['products:read', 'backlog:read'],
7838
7854
  routes: {
7839
- 'GET /v1/products': {
7840
- data: [{ id: PRODUCT_ID, name: 'Web', slug: 'web', factoryId: FACTORY_ID }],
7855
+ [`/v1/products/${PRODUCT_ID}/builds`]: {
7856
+ data: [{ ...base, id: 'r1', backlogItemId: ITEM_ID, prNumber: 10 }],
7841
7857
  nextCursor: null,
7842
7858
  },
7843
- 'POST /v1/products': {
7844
- data: { id: PRODUCT_ID_2, name: 'web', slug: 'web-2', status: 'active', factoryId: FACTORY_ID, settings: {} },
7859
+ // Archiving keeps the status the initiative had.
7860
+ [`GET /v1/backlog/${ITEM_ID}`]: {
7861
+ data: { id: ITEM_ID, status: 'in_progress', state: 'build', archivedAt: '2026-10-09T10:00:00.000Z' },
7845
7862
  },
7846
7863
  },
7847
7864
  });
7848
7865
 
7849
- const refused = await client.callTool({
7850
- name: 'taiga_create_product',
7851
- arguments: { factoryId: FACTORY_ID, name: 'WEB', start: 'scratch' },
7866
+ const runs = parseToolResult(
7867
+ await client.callTool({ name: 'taiga_list_runs', arguments: { productId: PRODUCT_ID } })
7868
+ ).data as Record<string, unknown>[];
7869
+
7870
+ expect(runs[0]).toMatchObject({
7871
+ disposition: 'abandoned',
7872
+ outcome: expect.stringMatching(/^Its initiative was archived/),
7852
7873
  });
7874
+ });
7875
+
7876
+ it('asks the API to refuse a taken name, and says so naming the one that exists (PRO-2808)', async () => {
7877
+ const reply = (ok: boolean, status: number, body: unknown) => ({
7878
+ ok,
7879
+ status,
7880
+ json: () => Promise.resolve(body),
7881
+ text: () => Promise.resolve(JSON.stringify(body)),
7882
+ });
7883
+ const taken = reply(false, 409, {
7884
+ error: 'CONFLICT',
7885
+ code: 'CONFLICT',
7886
+ message: 'A factory named "Platform" already exists in this organization.',
7887
+ details: {
7888
+ reason: 'duplicate_name',
7889
+ existingId: FACTORY_ID,
7890
+ existingSlug: 'platform',
7891
+ existingName: 'Platform',
7892
+ existingCreatedAt: '2026-10-10T10:00:00.000Z',
7893
+ },
7894
+ });
7895
+ const { client, fetchSpy } = await bootMcpPair({
7896
+ capabilities: ALL_CAPABILITIES,
7897
+ routes: {
7898
+ 'POST /v1/factories': (_url: string, init: RequestInit) =>
7899
+ JSON.parse(init.body as string).refuseDuplicateName
7900
+ ? taken
7901
+ : reply(true, 201, { data: { id: 'new', name: 'platform', slug: 'platform-2', isDefault: false } }),
7902
+ },
7903
+ });
7904
+
7905
+ const refused = parseToolResult(
7906
+ await client.callTool({ name: 'taiga_create_factory', arguments: { name: ' platform ' } })
7907
+ );
7853
7908
  const made = await client.callTool({
7854
- name: 'taiga_create_product',
7855
- arguments: { factoryId: FACTORY_ID, name: 'web', start: 'scratch', allowDuplicateName: true },
7909
+ name: 'taiga_create_factory',
7910
+ arguments: { name: 'platform', allowDuplicateName: true },
7856
7911
  });
7857
7912
 
7858
- expect(parseToolResult(refused)).toMatchObject({ error: 'DUPLICATE_NAME', details: { existingId: PRODUCT_ID } });
7913
+ expect(refused).toMatchObject({ error: 'DUPLICATE_NAME', details: { existingId: FACTORY_ID } });
7914
+ expect(refused.message).toBe(
7915
+ `A factory named "Platform" already exists in this organization (${FACTORY_ID}, created ` +
7916
+ "2026-10-10T10:00:00.000Z). It may not be this person's: ask whether they meant that one, want another " +
7917
+ 'name, or want a second (allowDuplicateName: true).'
7918
+ );
7859
7919
  expect(made.isError).toBeFalsy();
7860
- const posts = fetchSpy.mock.calls.filter(
7861
- (call: unknown[]) =>
7862
- (call[1] as RequestInit | undefined)?.method === 'POST' && String(call[0]).endsWith('/v1/products')
7920
+ const bodies = fetchSpy.mock.calls
7921
+ .filter((call: unknown[]) => (call[1] as RequestInit | undefined)?.method === 'POST')
7922
+ .map((call: unknown[]) => JSON.parse((call[1] as RequestInit).body as string));
7923
+ expect(bodies.map((body: Record<string, unknown>) => body['refuseDuplicateName'])).toEqual([true, false]);
7924
+ // No list read first: the API's check is the one that holds.
7925
+ expect(fetchSpy.mock.calls.some((call: unknown[]) => String(call[0]).includes('/v1/factories?'))).toBe(false);
7926
+ });
7927
+
7928
+ it('says a product name is taken without naming the one a caller cannot read', async () => {
7929
+ const { client } = await bootMcpPair({
7930
+ capabilities: ALL_CAPABILITIES,
7931
+ routes: {
7932
+ 'POST /v1/products': refusal(409, 'CONFLICT', 'A product with this name already exists in this factory.', {
7933
+ reason: 'duplicate_name',
7934
+ }),
7935
+ },
7936
+ });
7937
+
7938
+ const refused = parseToolResult(
7939
+ await client.callTool({
7940
+ name: 'taiga_create_product',
7941
+ arguments: { factoryId: FACTORY_ID, name: 'WEB', start: 'scratch' },
7942
+ })
7943
+ );
7944
+
7945
+ expect(refused).toMatchObject({ error: 'DUPLICATE_NAME', details: {} });
7946
+ expect(refused.message).toBe(
7947
+ 'A product with this name already exists in this factory. The caller cannot read it: ask whether they want ' +
7948
+ 'another name or a second (allowDuplicateName: true).'
7863
7949
  );
7864
- expect(posts).toHaveLength(1);
7865
7950
  });
7866
7951
 
7867
7952
  it('bounds the full policy standing and reads one violation whole', async () => {
@@ -8172,36 +8257,6 @@ describe('what the fourth agent review found (PRO-2800)', () => {
8172
8257
  expect(list.staleNote).toMatch(/^1 review was not checked for staleness/);
8173
8258
  });
8174
8259
 
8175
- it('looks for a taken product name in its own factory only, and leaves the choice to the person', async () => {
8176
- const { client, fetchSpy } = await bootMcpPair({
8177
- capabilities: ALL_CAPABILITIES,
8178
- routes: {
8179
- 'GET /v1/products': {
8180
- data: [{ id: PRODUCT_ID, name: 'Web', slug: 'web', createdAt: '2026-10-10T10:00:00Z' }],
8181
- nextCursor: null,
8182
- },
8183
- },
8184
- });
8185
-
8186
- const refused = parseToolResult(
8187
- await client.callTool({
8188
- name: 'taiga_create_product',
8189
- arguments: { factoryId: FACTORY_ID, name: 'web', start: 'scratch' },
8190
- })
8191
- );
8192
-
8193
- const listed = fetchSpy.mock.calls
8194
- .map((call: unknown[]) => String(call[0]))
8195
- .find((url: string) => url.includes('/v1/products?'));
8196
- expect(new URL(listed!).searchParams.get('factoryId')).toBe(FACTORY_ID);
8197
- expect(refused.message).toBe(
8198
- `A product named "Web" already exists in this factory (${PRODUCT_ID}, created 2026-10-10T10:00:00Z). It may ` +
8199
- "not be this person's: ask whether they meant that one, want another name, or want a second " +
8200
- '(allowDuplicateName: true).'
8201
- );
8202
- expect(refused.message).not.toMatch(/Use it/);
8203
- });
8204
-
8205
8260
  it('names a factory ID passed where a product ID belongs, and drops the transport text from a miss', async () => {
8206
8261
  const { client } = await bootMcpPair({
8207
8262
  capabilities: ['products:read', 'backlog:read', 'factories:read'],
@@ -273,9 +273,14 @@ const INITIATIVE_CHECKS = 10;
273
273
  * The status of the initiative behind each run still waiting on its pull
274
274
  * request (PRO-2800). A reviewing agent read a run of a canceled initiative as
275
275
  * "waiting for a person to review and merge" while Action Required called the
276
- * same review stale. A 404 is 'deleted'. Best effort: an initiative past the
277
- * cap, one whose read failed, and every one without backlog:read is
278
- * 'unchecked', which withInitiativeState adds to the row's outcome.
276
+ * same review stale.
277
+ *
278
+ * The run says it itself since PRO-2808 (initiativeClosed: done, canceled,
279
+ * archived, deleted, or null while live), for every key that can read it.
280
+ * Against an API from before that, each initiative is read: a 404 is
281
+ * 'deleted', and an initiative past the cap, one whose read failed, and every
282
+ * one without backlog:read is 'unchecked', which withInitiativeState adds to
283
+ * the row's outcome.
279
284
  */
280
285
  export async function initiativeStates(
281
286
  client: TaigaClient,
@@ -286,8 +291,19 @@ export async function initiativeStates(
286
291
  const waiting = rows.filter(
287
292
  (row) => ['needs_review', 'completed'].includes(String(row['status'])) && row['prMerged'] !== true
288
293
  );
294
+ const told = waiting.filter((row) => 'initiativeClosed' in row);
295
+ for (const row of told) {
296
+ if (typeof row['backlogItemId'] === 'string') {
297
+ states.set(row['backlogItemId'], typeof row['initiativeClosed'] === 'string' ? row['initiativeClosed'] : 'live');
298
+ }
299
+ }
289
300
  const all = [
290
- ...new Set(waiting.map((row) => row['backlogItemId']).filter((id): id is string => typeof id === 'string')),
301
+ ...new Set(
302
+ waiting
303
+ .filter((row) => !('initiativeClosed' in row))
304
+ .map((row) => row['backlogItemId'])
305
+ .filter((id): id is string => typeof id === 'string' && !states.has(id))
306
+ ),
291
307
  ];
292
308
  // Without backlog:read, or past the cap, a waiting run's initiative is not read: it is marked unchecked so
293
309
  // the row does not claim a person is waiting when nobody may be.
@@ -297,7 +313,8 @@ export async function initiativeStates(
297
313
  ids.map(async (id) => {
298
314
  try {
299
315
  const item = (await client.getBacklogItem(id)).data as unknown as Record<string, unknown>;
300
- states.set(id, String(item['status'] ?? item['state']));
316
+ // Archiving keeps the status it had, so it is read apart, as the API reads it (initiativeClosed).
317
+ states.set(id, item['archivedAt'] ? 'archived' : String(item['status'] ?? item['state']));
301
318
  } catch (error) {
302
319
  if (error instanceof TaigaApiError && error.status === 404) return void states.set(id, 'deleted');
303
320
  if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
@@ -308,7 +325,19 @@ export async function initiativeStates(
308
325
  return states;
309
326
  }
310
327
 
311
- /** A readable run row that also says when its initiative was canceled or deleted while its pull request waited. */
328
+ /**
329
+ * How each way an initiative ends reads on a run it left waiting: the same set
330
+ * Action Required closes a run's review for (PRO-2806). A done one's waiting
331
+ * run is not the one that landed it: that run is merged and never waiting.
332
+ */
333
+ const INITIATIVE_ENDED: Record<string, string> = {
334
+ done: 'was marked done',
335
+ canceled: 'was canceled',
336
+ archived: 'was archived',
337
+ deleted: 'was deleted',
338
+ };
339
+
340
+ /** A readable run row that also says when its initiative ended while its pull request waited. */
312
341
  export function withInitiativeState(
313
342
  row: Record<string, unknown>,
314
343
  states: Map<string, string>
@@ -320,7 +349,8 @@ export function withInitiativeState(
320
349
  if (state === 'unchecked') {
321
350
  return { ...row, outcome: `${String(row['outcome'])} Whether its initiative still wants it was not checked.` };
322
351
  }
323
- if (state !== 'canceled' && state !== 'deleted') return row;
352
+ const ended = state ? INITIATIVE_ENDED[state] : undefined;
353
+ if (!ended) return row;
324
354
  const pr = typeof row['prNumber'] === 'number' ? `PR #${row['prNumber']}` : 'its pull request';
325
355
  const open = row['prState'] === 'open' ? ` ${pr} is still open on the forge.` : '';
326
356
  return {
@@ -328,7 +358,7 @@ export function withInitiativeState(
328
358
  disposition: 'abandoned',
329
359
  // Retry or start over cannot succeed once the initiative is gone.
330
360
  actions: [],
331
- outcome: `Its initiative was ${state}, so nobody is waiting on this run.${open}`,
361
+ outcome: `Its initiative ${ended}, so nobody is waiting on this run.${open}`,
332
362
  };
333
363
  }
334
364
 
@@ -485,8 +515,9 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
485
515
  description:
486
516
  'List coding agent runs, newest first: branch, pull request, step progress, outcome (one sentence), ' +
487
517
  'actions and disposition: in_flight, gated (a person owes a review and merge), succeeded (merged), ' +
488
- 'failed, stopped, rejected (pull request closed unmerged), unmerged, or abandoned (initiative canceled ' +
489
- "or deleted). Without productId, every product's runs; active: true keeps running ones. taiga_get_run " +
518
+ 'failed, stopped, rejected (pull request closed unmerged), unmerged, or abandoned (its initiative ended: ' +
519
+ "done, canceled, archived or deleted). Without productId, every product's runs; active: true keeps " +
520
+ 'running ones. taiga_get_run ' +
490
521
  'reads one; carry work on from the initiative (taiga_resume_initiative).',
491
522
  inputSchema: z.object({
492
523
  productId: productId.optional(),
@@ -5,7 +5,7 @@ import type { Product } from '@taiga/types';
5
5
  import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
6
6
  import { OPERATE, OPERATE_SETTING, READ_ONLY, WRITE } from './annotations.js';
7
7
  import { isImportedProduct } from './catalogue.js';
8
- import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
8
+ import { runTool, toolError, toolResult } from './errors.js';
9
9
  import { setupReadiness } from './readiness.js';
10
10
  import { withStop } from './hints.js';
11
11
  import { idInput, productId } from './inputs.js';
@@ -68,58 +68,42 @@ type ProductRow = WithoutTenant<Product>;
68
68
  /**
69
69
  * Refuse a name already taken (PRO-2800): the API appends -2 to the slug and
70
70
  * creates a second one, so a retried call or two agents at once left
71
- * duplicates nobody asked for. allowDuplicateName says the person means it.
71
+ * duplicates nobody asked for. The API checks it in the create's transaction
72
+ * (PRO-2808), so names past any page and two creates at once are caught too.
73
+ * allowDuplicateName says the person means it.
72
74
  */
73
75
  const allowDuplicateName = z
74
76
  .boolean()
75
77
  .optional()
76
78
  .describe('Create it even when one with the same name exists (default false: refused, naming the existing one)');
77
79
 
78
- /** The same name, as a person would read it: case and surrounding space aside. */
79
- const sameName = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase();
80
-
81
- /** Pages read looking for a taken name; past this the create goes ahead. */
82
- const NAME_PAGES = 5;
83
-
84
80
  /**
85
- * The first row with this name, across pages. Best effort: a list the API
86
- * refuses or fails leaves the check out rather than the create.
81
+ * The API's refusal of a taken name, as the tool's: it names the one that
82
+ * exists when the caller may read it, and leaves the choice to the person.
83
+ * Another session may have made it, so "use it" would hand the agent someone
84
+ * else's work (PRO-2800, review 6). Anything else is not this refusal.
87
85
  */
88
- async function firstNamed<T extends { id: string; name: string; slug?: string; createdAt?: unknown }>(
89
- name: string,
90
- read: (cursor: string | undefined) => Promise<{ data: T[]; nextCursor: string | null }>
91
- ): Promise<T | null> {
92
- let cursor: string | undefined;
93
- try {
94
- for (let pageIndex = 0; pageIndex < NAME_PAGES; pageIndex += 1) {
95
- const result = await read(cursor);
96
- const same = result.data.find((row) => sameName(row.name, name));
97
- if (same) return same;
98
- if (!result.nextCursor) return null;
99
- cursor = result.nextCursor;
100
- }
101
- } catch (error) {
102
- if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
86
+ function duplicateName(kind: 'factory' | 'product', error: unknown, where: string) {
87
+ if (!(error instanceof TaigaApiError) || error.status !== 409 || error.details?.['reason'] !== 'duplicate_name') {
88
+ return null;
103
89
  }
104
- return null;
105
- }
106
-
107
- /**
108
- * The refusal names the one that exists and leaves the choice to the person: another session may have made it, so
109
- * "use it" would hand the agent someone else's work (PRO-2800, review 6).
110
- */
111
- function duplicateName(
112
- kind: 'factory' | 'product',
113
- existing: { id: string; name: string; slug?: string; createdAt?: unknown },
114
- where: string
115
- ) {
116
- const made = typeof existing.createdAt === 'string' ? `, created ${existing.createdAt}` : '';
117
- return toolError(
118
- 'DUPLICATE_NAME',
119
- `A ${kind} named "${existing.name}" already exists ${where} (${existing.id}${made}). It may not be this ` +
120
- "person's: ask whether they meant that one, want another name, or want a second (allowDuplicateName: true).",
121
- { existingId: existing.id, ...(existing.slug ? { existingSlug: existing.slug } : {}) }
122
- );
90
+ const said = (key: string) => (typeof error.details?.[key] === 'string' ? (error.details[key] as string) : undefined);
91
+ const [existingId, existingSlug, name, createdAt] = [
92
+ said('existingId'),
93
+ said('existingSlug'),
94
+ said('existingName'),
95
+ said('existingCreatedAt'),
96
+ ];
97
+ const which = name ? `A ${kind} named "${name}"` : `A ${kind} with this name`;
98
+ const ids = existingId ? ` (${existingId}${createdAt ? `, created ${createdAt}` : ''})` : '';
99
+ // Without the holder's id the caller cannot read it, so "that one" is not a choice they have.
100
+ const choices = existingId
101
+ ? "It may not be this person's: ask whether they meant that one, want another name, or want a second"
102
+ : 'The caller cannot read it: ask whether they want another name or a second';
103
+ return toolError('DUPLICATE_NAME', `${which} already exists ${where}${ids}. ${choices} (allowDuplicateName: true).`, {
104
+ ...(existingId ? { existingId } : {}),
105
+ ...(existingSlug ? { existingSlug } : {}),
106
+ });
123
107
  }
124
108
 
125
109
  /** A product row the model can act on: how it started, and while in setup what it still needs. */
@@ -186,11 +170,17 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
186
170
  },
187
171
  (params) =>
188
172
  runTool(async () => {
189
- if (!params.allowDuplicateName) {
190
- const same = await firstNamed(params.name, async (cursor) => client.listFactories({ limit: 100, cursor }));
191
- if (same) return duplicateName('factory', same, 'in this organization');
173
+ try {
174
+ const made = await client.createFactory({
175
+ name: params.name,
176
+ refuseDuplicateName: !params.allowDuplicateName,
177
+ });
178
+ return toolResult(pickFields([made.data], FACTORY_FIELDS)[0]);
179
+ } catch (error) {
180
+ const refused = duplicateName('factory', error, 'in this organization');
181
+ if (refused) return refused;
182
+ throw error;
192
183
  }
193
- return toolResult(pickFields([(await client.createFactory({ name: params.name })).data], FACTORY_FIELDS)[0]);
194
184
  })
195
185
  );
196
186
 
@@ -308,23 +298,25 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
308
298
  runTool(async () => {
309
299
  const contradiction = notesWithNothingToDescribe(params);
310
300
  if (contradiction) return contradiction;
311
- if (!params.allowDuplicateName) {
312
- // Within the factory it goes into: the same name in another factory is a different product.
313
- const same = await firstNamed(params.name, async (cursor) =>
314
- client.listProducts({ factoryId: params.factoryId, limit: 100, cursor })
315
- );
316
- if (same) return duplicateName('product', same, 'in this factory');
301
+ let product: ProductRow;
302
+ try {
303
+ product = (
304
+ await client.createProduct({
305
+ name: params.name,
306
+ factoryId: params.factoryId,
307
+ ...(params.start === 'import' ? { projectType: 'brownfield' as const } : {}),
308
+ ...(params.ownsInfrastructure !== undefined ? { ownsInfrastructure: params.ownsInfrastructure } : {}),
309
+ ...(params.ownsDeployment !== undefined ? { ownsDeployment: params.ownsDeployment } : {}),
310
+ ...(params.deploymentNotes !== undefined ? { deploymentNotes: params.deploymentNotes } : {}),
311
+ // Within the factory it goes into: the same name in another factory is a different product.
312
+ refuseDuplicateName: !params.allowDuplicateName,
313
+ })
314
+ ).data;
315
+ } catch (error) {
316
+ const refused = duplicateName('product', error, 'in this factory');
317
+ if (refused) return refused;
318
+ throw error;
317
319
  }
318
- const product = (
319
- await client.createProduct({
320
- name: params.name,
321
- factoryId: params.factoryId,
322
- ...(params.start === 'import' ? { projectType: 'brownfield' as const } : {}),
323
- ...(params.ownsInfrastructure !== undefined ? { ownsInfrastructure: params.ownsInfrastructure } : {}),
324
- ...(params.ownsDeployment !== undefined ? { ownsDeployment: params.ownsDeployment } : {}),
325
- ...(params.deploymentNotes !== undefined ? { deploymentNotes: params.deploymentNotes } : {}),
326
- })
327
- ).data;
328
320
  return toolResult(await productRow(client, product));
329
321
  })
330
322
  );