@taiga-ai/mcp-server 0.18.0-dev.447 → 0.18.0-dev.448

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.
@@ -267,7 +267,7 @@ function toolNames(tools: { name: string }[]) {
267
267
  }
268
268
 
269
269
  /** Extract parsed JSON from the first text content block of a tool call result. */
270
- function parseToolResult(result: Record<string, unknown>) {
270
+ function parseToolResult(result: { content?: unknown }) {
271
271
  const content = result.content as { type: string; text: string }[];
272
272
  return JSON.parse(content[0]!.text);
273
273
  }
@@ -580,7 +580,7 @@ describe('MCP server integration', () => {
580
580
  it('should replace one section of the existing draft and keep the rest', async () => {
581
581
  const draft = policyDocument({ id: 'draft-1', version: null, isDraft: true });
582
582
  const saved = policyDocument({ id: 'draft-1', version: null, isDraft: true });
583
- (saved.data.sections as Record<string, { content: string }>).summary!.content = 'New summary';
583
+ saved.data.sections.summary.content = 'New summary';
584
584
 
585
585
  const { client, fetchSpy } = await bootMcpPair({
586
586
  capabilities: ALL_CAPABILITIES,
@@ -750,9 +750,9 @@ describe('MCP server integration', () => {
750
750
  const { tools } = await client.listTools();
751
751
  const byName = Object.fromEntries(tools.map((t) => [t.name, t.annotations]));
752
752
 
753
- expect(byName.taiga_publish_document).toMatchObject({ destructiveHint: true, idempotentHint: false });
754
- expect(byName.taiga_publish_factory_document).toMatchObject({ destructiveHint: true, idempotentHint: false });
755
- expect(byName.taiga_update_document).toMatchObject({ destructiveHint: false, idempotentHint: true });
753
+ expect(byName['taiga_publish_document']).toMatchObject({ destructiveHint: true, idempotentHint: false });
754
+ expect(byName['taiga_publish_factory_document']).toMatchObject({ destructiveHint: true, idempotentHint: false });
755
+ expect(byName['taiga_update_document']).toMatchObject({ destructiveHint: false, idempotentHint: true });
756
756
  });
757
757
 
758
758
  it('should hand the model the API message when the publish gate refuses the draft', async () => {
@@ -2904,8 +2904,9 @@ describe('MCP server integration', () => {
2904
2904
  }
2905
2905
  // A section id points at the read tool of its own scope, which lists the valid ids.
2906
2906
  const sectionHelp = (name: string) =>
2907
- (tools.find((t) => t.name === name)!.inputSchema.properties as Record<string, { description?: string }>)
2908
- .sectionId!.description;
2907
+ (tools.find((t) => t.name === name)!.inputSchema.properties as Record<string, { description?: string }>)[
2908
+ 'sectionId'
2909
+ ]!.description;
2909
2910
  expect(sectionHelp('taiga_update_document')).toContain('taiga_get_document,');
2910
2911
  expect(sectionHelp('taiga_update_factory_document')).toContain('taiga_get_factory_document');
2911
2912
  expect(sectionHelp('taiga_update_product_document')).toContain('taiga_get_product_document');
@@ -3323,7 +3324,7 @@ describe('MCP server integration', () => {
3323
3324
  const move = tools.find((t) => t.name === 'taiga_move_backlog_item')!;
3324
3325
  const props = move.inputSchema.properties as Record<string, { enum?: string[] }>;
3325
3326
  // The queue is the only way to start work: no "plan this now", nothing dropped into Build.
3326
- expect(props.state!.enum).toEqual(['backlog', 'todo', 'queue', 'done', 'canceled']);
3327
+ expect(props['state']!.enum).toEqual(['backlog', 'todo', 'queue', 'done', 'canceled']);
3327
3328
 
3328
3329
  const before = fetchSpy.mock.calls.length;
3329
3330
  for (const state of ['build', 'plan']) {
@@ -6849,7 +6850,7 @@ describe('what the second agent review found (PRO-2778, PRO-2780)', () => {
6849
6850
  const { tools } = await client.listTools();
6850
6851
  const metrics = tools.find((t) => t.name === 'taiga_get_delivery_metrics')!;
6851
6852
  const properties = metrics.inputSchema.properties as Record<string, { enum?: unknown }>;
6852
- expect(properties.windowDays!.enum).toEqual([30, 60, 90]);
6853
+ expect(properties['windowDays']!.enum).toEqual([30, 60, 90]);
6853
6854
  });
6854
6855
 
6855
6856
  it('opens every writing tool with what it does, and leaves the reads as they were', async () => {
@@ -15,8 +15,6 @@ import { pickFields } from './summarize.js';
15
15
  * is taiga_set_auto_build.
16
16
  */
17
17
 
18
- type Row = Record<string, unknown>;
19
-
20
18
  export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
21
19
  if (client.hasCapability('products:write')) {
22
20
  server.registerTool(
@@ -54,7 +52,7 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
54
52
  ...(params.autoBuild !== undefined ? { autoBuild: params.autoBuild } : {}),
55
53
  ...(autoMerge !== undefined ? { autoMerge } : {}),
56
54
  })
57
- ).data as Row;
55
+ ).data;
58
56
  return toolResult(pickFields([product], ['id', 'name', 'slug', 'autoBuild', 'autoMerge'])[0]);
59
57
  })
60
58
  );
@@ -82,7 +80,7 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
82
80
  },
83
81
  (params) =>
84
82
  runTool(async () => {
85
- const item = (await client.updateBacklogItem(params.itemId, { autoMerge: params.autoMerge })).data as Row;
83
+ const item = (await client.updateBacklogItem(params.itemId, { autoMerge: params.autoMerge })).data;
86
84
  return toolResult(pickFields([item], ['id', 'title', 'state', 'autoMerge', 'autoBuild'])[0]);
87
85
  })
88
86
  );
@@ -1,6 +1,7 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
- import { TaigaApiError, type TaigaClient } from '../client.js';
3
+ import type { ActivityEvent, ActivityActor, BacklogItem } from '@taiga/types';
4
+ import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
4
5
  import { PLAN_REJECTION_COMMENT_MAX_CHARS, PR_CLOSING_STATES, USER_DROP_STATES, resolveAutoBuild } from '@taiga/types';
5
6
  import { OPERATE, OPERATE_SETTING, READ_ONLY } from './annotations.js';
6
7
  import { idInput, pagination, productId } from './inputs.js';
@@ -83,16 +84,16 @@ async function planFailureOf(
83
84
  itemId: string,
84
85
  itemCause: unknown
85
86
  ): Promise<Record<string, unknown> | null> {
86
- let events: Record<string, unknown>[];
87
+ let events: WithoutTenant<ActivityEvent>[];
87
88
  try {
88
- events = (await client.listBacklogItemActivity(itemId)).data as Record<string, unknown>[];
89
+ events = (await client.listBacklogItemActivity(itemId)).data;
89
90
  } catch (error) {
90
91
  if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
91
92
  throw error;
92
93
  }
93
94
  // Only a failure of the latest attempt: one before the last plan_started
94
95
  // belongs to an earlier attempt, even when it failed for the same reason.
95
- let failed: Record<string, unknown> | undefined;
96
+ let failed: WithoutTenant<ActivityEvent> | undefined;
96
97
  for (const event of [...events].reverse()) {
97
98
  if (event.kind !== 'turn') continue;
98
99
  if (event.trigger === 'plan_started') break;
@@ -102,8 +103,8 @@ async function planFailureOf(
102
103
  }
103
104
  }
104
105
  if (!failed) return null;
105
- const actor = (failed.actor ?? {}) as { type?: unknown; name?: unknown };
106
- const data = (failed.data ?? {}) as { cause?: unknown };
106
+ const actor: Partial<ActivityActor> = failed.actor ?? {};
107
+ const data: { cause?: unknown } = failed.data ?? {};
107
108
  const cause = typeof data.cause === 'string' ? data.cause : null;
108
109
  // The event is recorded best effort: one that disagrees with the item's own
109
110
  // cause belongs to an earlier failure, and its who and when are not this one's.
@@ -140,13 +141,12 @@ const PLAN_STEP_SUMMARY_FIELDS = [
140
141
  'createsResources',
141
142
  ] as const;
142
143
 
144
+ type Item = WithoutTenant<BacklogItem>;
145
+
143
146
  /** The outcome fields of an initiative, with how many of its criteria are met. */
144
- const outcomeOf = (item: unknown) => {
145
- const row = item as Record<string, unknown>;
146
- return { ...pickFields([row], ITEM_OUTCOME_FIELDS)[0], criteria: criteriaCounts(row) };
147
- };
147
+ const outcomeOf = (item: Item) => ({ ...pickFields([item], ITEM_OUTCOME_FIELDS)[0], criteria: criteriaCounts(item) });
148
148
 
149
- const outcome = (item: unknown) => toolResult(outcomeOf(item));
149
+ const outcome = (item: Item) => toolResult(outcomeOf(item));
150
150
 
151
151
  /**
152
152
  * Each tool is registered under the capability its route requires, so a
@@ -218,9 +218,7 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
218
218
  const { query, ...filters } = params;
219
219
  if (query === undefined) {
220
220
  const result = await client.listBacklogItems(params.productId, filters);
221
- return toolResult(
222
- page(itemRows(result.data as Record<string, unknown>[], BACKLOG_SUMMARY_FIELDS), result.nextCursor)
223
- );
221
+ return toolResult(page(itemRows(result.data, BACKLOG_SUMMARY_FIELDS), result.nextCursor));
224
222
  }
225
223
  if (params.cursor !== undefined) {
226
224
  return toolError('VALIDATION_ERROR', SEARCH_WITH_CURSOR);
@@ -255,9 +253,12 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
255
253
  },
256
254
  (params) =>
257
255
  runTool(async () => {
258
- const item = (await client.getBacklogItem(params.itemId)).data as Record<string, unknown>;
256
+ const item = (await client.getBacklogItem(params.itemId)).data;
259
257
  if (item.planningStatus !== 'failed') return toolResult(item);
260
- const planFailure = await planFailureOf(client, params.itemId, item.planFailureCause);
258
+ // Always undefined: the API sends the cause only under `lifecycle`, so
259
+ // planFailureOf's cross-check never fires (PRO-2799). Kept as it was.
260
+ const ownCause = (item as { planFailureCause?: unknown }).planFailureCause;
261
+ const planFailure = await planFailureOf(client, params.itemId, ownCause);
261
262
  return toolResult(planFailure ? { ...item, planFailure } : item);
262
263
  })
263
264
  );
@@ -281,9 +282,9 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
281
282
  },
282
283
  (params) =>
283
284
  runTool(async () => {
284
- const plan = (await client.getActivePlan(params.itemId)).data as Record<string, unknown>;
285
+ const plan = (await client.getActivePlan(params.itemId)).data;
285
286
  if (params.full) return toolResult(plan);
286
- const steps = Array.isArray(plan.steps) ? (plan.steps as Record<string, unknown>[]) : [];
287
+ const steps = Array.isArray(plan.steps) ? plan.steps : [];
287
288
  return toolResult({
288
289
  ...pickFields([plan], PLAN_SUMMARY_FIELDS)[0],
289
290
  steps: pickFields(steps, PLAN_STEP_SUMMARY_FIELDS),
@@ -331,7 +332,7 @@ function registerRequest(server: McpServer, client: TaigaClient) {
331
332
  targetId: params.productId,
332
333
  ...(userPrompt ? { metadata: { userPrompt } } : {}),
333
334
  })
334
- ).data as Record<string, unknown>;
335
+ ).data;
335
336
  return toolResult(jobReport(job));
336
337
  })
337
338
  );
@@ -415,7 +416,7 @@ function registerPlanAgain(server: McpServer, client: TaigaClient) {
415
416
  },
416
417
  (params) =>
417
418
  runTool(async () => {
418
- const item = (await client.getBacklogItem(params.itemId)).data as Record<string, unknown>;
419
+ const item = (await client.getBacklogItem(params.itemId)).data;
419
420
  if (!BUILD_STATION.has(String(item.state))) {
420
421
  return toolError(
421
422
  'CONFLICT',
@@ -437,12 +438,11 @@ const isApiFailure = (error: unknown) => error instanceof TaigaApiError || isNet
437
438
  * decided it: its own setting, or the product's default when it has none.
438
439
  * Null when the product cannot be read; the outcome then says so instead.
439
440
  */
440
- async function reviewGate(client: TaigaClient, item: Record<string, unknown>) {
441
+ async function reviewGate(client: TaigaClient, item: Item) {
441
442
  const own = typeof item.autoBuild === 'boolean' ? item.autoBuild : null;
442
443
  try {
443
444
  // The product's default answers only when the initiative has no answer of its own.
444
- const product =
445
- own === null ? ((await client.getProduct(String(item.productId))).data as { autoBuild?: unknown }) : {};
445
+ const product: { autoBuild?: boolean | null } = own === null ? (await client.getProduct(item.productId)).data : {};
446
446
  // Absent on the product reads as its true default (@taiga/types).
447
447
  const productDefault = typeof product.autoBuild === 'boolean' ? product.autoBuild : true;
448
448
  const builds = resolveAutoBuild({ initiative: own, product: productDefault });
@@ -460,9 +460,9 @@ async function reviewGate(client: TaigaClient, item: Record<string, unknown>) {
460
460
  }
461
461
 
462
462
  /** Where a queued initiative stands in its product's line; null, and the outcome says so, when the queue cannot be read. */
463
- async function placeInQueue(client: TaigaClient, item: Record<string, unknown>) {
463
+ async function placeInQueue(client: TaigaClient, item: Item) {
464
464
  try {
465
- const view = await queueView(client, String(item.productId), { name: 'blocking' });
465
+ const view = await queueView(client, item.productId, { name: 'blocking' });
466
466
  const entry = view.entries.find((row) => row.backlogItemId === item.id);
467
467
  return { position: entry?.position ?? null, of: view.entries.length, waitingOn: view.waitingOn };
468
468
  } catch (error) {
@@ -519,10 +519,7 @@ function registerQueue(server: McpServer, client: TaigaClient) {
519
519
  if (autoBuild !== undefined) await client.setAutoBuild(id, autoBuild);
520
520
  // The move the web makes: one write that answers with the refreshed
521
521
  // item, rather than the queue route plus two reads around it.
522
- const item = (await client.transitionInitiative(id, { state: 'queue', ...placement })).data as Record<
523
- string,
524
- unknown
525
- >;
522
+ const item = (await client.transitionInitiative(id, { state: 'queue', ...placement })).data;
526
523
  const [gate, queue] = await Promise.all([reviewGate(client, item), placeInQueue(client, item)]);
527
524
  return toolResult({
528
525
  ...outcomeOf(item),
@@ -551,7 +548,7 @@ function registerReviewGate(server: McpServer, client: TaigaClient) {
551
548
  },
552
549
  (params) =>
553
550
  runTool(async () => {
554
- const item = (await client.setAutoBuild(params.itemId, params.autoBuild)).data as Record<string, unknown>;
551
+ const item = (await client.setAutoBuild(params.itemId, params.autoBuild)).data;
555
552
  const gate = await reviewGate(client, item);
556
553
  return toolResult({
557
554
  ...outcomeOf(item),
@@ -1,6 +1,7 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
- import type { TaigaClient } from '../client.js';
3
+ import type { BuildRunStep, BuildRunWithContext } from '@taiga/types';
4
+ import type { TaigaClient, WithoutTenant } from '../client.js';
4
5
  import { OPERATE, READ_ONLY } from './annotations.js';
5
6
  import { idInput, pagination, productId } from './inputs.js';
6
7
  import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
@@ -54,7 +55,10 @@ const RUN_OUTCOME_FIELDS = [
54
55
  * stalled in the hardening pass after the steps and the retry continues it.
55
56
  * Null for a run still going or that finished.
56
57
  */
57
- export function stoppedIn(run: Record<string, unknown>, steps: Record<string, unknown>[]): string | null {
58
+ export function stoppedIn(
59
+ run: Pick<WithoutTenant<BuildRunWithContext>, 'prMerged' | 'status' | 'hardeningResult'>,
60
+ steps: readonly Pick<WithoutTenant<BuildRunStep>, 'status' | 'stepOrder' | 'title'>[]
61
+ ): string | null {
58
62
  // A merged run is finished, whatever its status still says: the merge is
59
63
  // latched apart from the status, which can stay at a stale failed or stopped.
60
64
  if (run.prMerged === true || (run.status !== 'failed' && run.status !== 'stopped')) return null;
@@ -131,11 +135,11 @@ async function activityRows(
131
135
  throw error;
132
136
  }),
133
137
  ]);
134
- const nameById = new Map<string, { name: unknown; slug: unknown }>();
135
- for (const product of (products?.data ?? []) as Record<string, unknown>[]) {
136
- nameById.set(String(product.id), { name: product.name, slug: product.slug });
138
+ const nameById = new Map<string, { name: string; slug: string }>();
139
+ for (const product of products?.data ?? []) {
140
+ nameById.set(product.id, { name: product.name, slug: product.slug });
137
141
  }
138
- const rows = pickFields(runs.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS).map((row) => {
142
+ const rows = pickFields(runs.data, BUILD_SUMMARY_FIELDS).map((row) => {
139
143
  const product = nameById.get(String(row.productId));
140
144
  return product ? { ...row, product } : row;
141
145
  });
@@ -204,9 +208,7 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
204
208
  }
205
209
  if (params.productId) {
206
210
  const result = await client.listBuildRuns(params.productId, params);
207
- return toolResult(
208
- page(pickFields(result.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS), result.nextCursor)
209
- );
211
+ return toolResult(page(pickFields(result.data, BUILD_SUMMARY_FIELDS), result.nextCursor));
210
212
  }
211
213
  return toolResult(await activityRows(client, params));
212
214
  })
@@ -243,8 +245,8 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
243
245
  client.getBuildRun(params.buildId),
244
246
  client.listBuildRunSteps(params.buildId),
245
247
  ]);
246
- const runRow = run.data as Record<string, unknown>;
247
- const rows = steps.data as Record<string, unknown>[];
248
+ const runRow = run.data;
249
+ const rows = steps.data;
248
250
  const where = stoppedIn(runRow, rows);
249
251
  const stop = where ? { stoppedIn: where } : {};
250
252
  if (params.full) return toolResult({ ...runRow, ...stop, steps: steps.data });
@@ -278,7 +280,7 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
278
280
  (params) =>
279
281
  runTool(async () => {
280
282
  const result = await client.stopBuildRun(params.buildId);
281
- return toolResult(pickFields([result.data as Record<string, unknown>], RUN_OUTCOME_FIELDS)[0]);
283
+ return toolResult(pickFields([result.data], RUN_OUTCOME_FIELDS)[0]);
282
284
  })
283
285
  );
284
286
  }
@@ -104,7 +104,7 @@ describe('tool descriptions against the catalogue', () => {
104
104
  const tools = await allTools();
105
105
  const list = tools.find((t) => t.name === 'taiga_list_documents')!;
106
106
  const get = tools.find((t) => t.name === 'taiga_get_document')!;
107
- const typeEnum = (get.inputSchema.properties as Record<string, { enum?: string[] }>).type!.enum;
107
+ const typeEnum = (get.inputSchema.properties as Record<string, { enum?: string[] }>)['type']!.enum;
108
108
 
109
109
  for (const type of POLICY_TYPES) {
110
110
  expect(list.description, type).toContain(type);
@@ -118,10 +118,10 @@ describe('tool descriptions against the catalogue', () => {
118
118
  const props = (name: string) =>
119
119
  tools.find((t) => t.name === name)!.inputSchema.properties as Record<string, { enum?: string[] }>;
120
120
 
121
- expect(props('taiga_list_products').status!.enum).toEqual([...PRODUCT_STATUSES]);
122
- expect(props('taiga_get_product_document').type!.enum).toEqual([...PRODUCT_DOCUMENT_TYPES]);
123
- expect(props('taiga_get_product_document').origin!.enum).toEqual(['plan', 'reality']);
124
- expect(props('taiga_get_factory_document').type!.enum).toEqual([...FACTORY_DOCUMENT_TYPES]);
121
+ expect(props('taiga_list_products')['status']!.enum).toEqual([...PRODUCT_STATUSES]);
122
+ expect(props('taiga_get_product_document')['type']!.enum).toEqual([...PRODUCT_DOCUMENT_TYPES]);
123
+ expect(props('taiga_get_product_document')['origin']!.enum).toEqual(['plan', 'reality']);
124
+ expect(props('taiga_get_factory_document')['type']!.enum).toEqual([...FACTORY_DOCUMENT_TYPES]);
125
125
  });
126
126
 
127
127
  it('has a display name for every product document type, the one the web uses', () => {
@@ -1,20 +1,24 @@
1
1
  import type { McpServer, CallToolResult } from '@modelcontextprotocol/server';
2
2
  import { isSettledOrArchived } from '@taiga/types';
3
+ import type { BacklogItem, Product } from '@taiga/types';
3
4
  import { z } from 'zod';
4
- import { TaigaApiError, type TaigaClient } from '../client.js';
5
+ import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
5
6
  import { READ_ONLY } from './annotations.js';
6
7
  import { BACKLOG_SUMMARY_FIELDS } from './backlogs.js';
7
- import {
8
- POLICY_TYPES,
9
- defaultOriginFor,
10
- isImportedProduct,
11
- setupNextStep,
12
- type ProductSettingsView,
13
- } from './catalogue.js';
8
+ import { POLICY_TYPES, defaultOriginFor, isImportedProduct, setupNextStep } from './catalogue.js';
14
9
  import { apiFailureText, isNetworkFailure, networkFailureDetail, runTool, toolError, toolResult } from './errors.js';
15
10
  import { productId, remoteUrl, slug } from './inputs.js';
16
11
  import { PRODUCT_SUMMARY_FIELDS, resolveProductRef } from './products.js';
17
- import { budgetSections, instructionsText, isRecord, sectionContent, sectionsOf, stripProvenance } from './sections.js';
12
+ import {
13
+ budgetSections,
14
+ instructionsText,
15
+ isRecord,
16
+ sectionContent,
17
+ sectionsOf,
18
+ stripProvenance,
19
+ type DocumentData,
20
+ type StoredDocument,
21
+ } from './sections.js';
18
22
  import { itemRows, pickFields } from './summarize.js';
19
23
 
20
24
  /**
@@ -72,7 +76,7 @@ export const DOCUMENT_BUDGET = 12_000;
72
76
  * which a session's opening read does not need. The full read keeps them.
73
77
  * Says whether any were dropped, so the bundle can tell the reader.
74
78
  */
75
- function withoutCitations(doc: Record<string, unknown>): { doc: Record<string, unknown>; dropped: boolean } {
79
+ function withoutCitations(doc: StoredDocument): { doc: StoredDocument; dropped: boolean } {
76
80
  const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
77
81
  if (!sections) return { doc, dropped: false };
78
82
  let dropped = false;
@@ -84,7 +88,7 @@ function withoutCitations(doc: Record<string, unknown>): { doc: Record<string, u
84
88
  return [id, rest];
85
89
  })
86
90
  );
87
- return { doc: { ...doc, data: { ...(doc.data as Record<string, unknown>), sections: kept } }, dropped };
91
+ return { doc: { ...doc, data: { ...(doc.data as DocumentData), sections: kept } }, dropped };
88
92
  }
89
93
 
90
94
  /**
@@ -133,7 +137,7 @@ function isNotFound(reason: unknown): boolean {
133
137
 
134
138
  /** The factory's standing guidance in full; null, with a note, when none is published or it is not section-shaped. */
135
139
  async function factoryPart(client: TaigaClient, factoryId: string) {
136
- const factory = (await client.getFactory(factoryId)).data as Record<string, unknown>;
140
+ const factory = (await client.getFactory(factoryId)).data;
137
141
  const base = { id: factory.id, name: factory.name, slug: factory.slug };
138
142
  try {
139
143
  const doc = (await client.getFactoryDocument(factoryId, 'factory_instructions')).data;
@@ -165,9 +169,9 @@ async function factoryPart(client: TaigaClient, factoryId: string) {
165
169
  * outline by default, the whole sections up to a budget or in full when
166
170
  * asked. Null, with a note, where unpublished.
167
171
  */
168
- async function documentsPart(client: TaigaClient, product: Record<string, unknown>, depth: DocumentDepth) {
169
- const productId = String(product.id);
170
- const origin = defaultOriginFor(product as ProductSettingsView);
172
+ async function documentsPart(client: TaigaClient, product: WithoutTenant<Product>, depth: DocumentDepth) {
173
+ const productId = product.id;
174
+ const origin = defaultOriginFor(product);
171
175
  const reads = await Promise.allSettled(
172
176
  CONTEXT_DOCUMENT_TYPES.map((type) => client.getProductDocument(productId, type, origin))
173
177
  );
@@ -176,7 +180,7 @@ async function documentsPart(client: TaigaClient, product: Record<string, unknow
176
180
  CONTEXT_DOCUMENT_TYPES.forEach((type, index) => {
177
181
  const read = reads[index]!;
178
182
  if (read.status === 'fulfilled') {
179
- const doc = stripProvenance(read.value.data as Record<string, unknown>);
183
+ const doc = stripProvenance(read.value.data);
180
184
  if (depth === 'full') out[type] = doc;
181
185
  else if (depth === 'budget') {
182
186
  const lean = withoutCitations(doc);
@@ -200,7 +204,7 @@ async function documentsPart(client: TaigaClient, product: Record<string, unknow
200
204
  );
201
205
  } else out[type] = cut.doc;
202
206
  } else {
203
- const sections = sectionsOf((doc.data as Record<string, unknown>) ?? {});
207
+ const sections = sectionsOf(doc.data ?? {});
204
208
  if (sections) {
205
209
  // Each id with the length of its text, so the next read is sized before it is
206
210
  // made (a reality section's citations come on top).
@@ -224,14 +228,14 @@ async function documentsPart(client: TaigaClient, product: Record<string, unknow
224
228
 
225
229
  /** Every published policy as its section ids, so the model knows which one to read with taiga_get_document. */
226
230
  async function policiesPart(client: TaigaClient) {
227
- const rows = (await client.listTenantDocuments({ limit: 100 })).data as Record<string, unknown>[];
231
+ const rows = (await client.listTenantDocuments({ limit: 100 })).data;
228
232
  return rows
229
233
  .filter((row) => (POLICY_TYPES as readonly string[]).includes(String(row.type)) && row.isDraft === false)
230
234
  .map((row) => ({
231
235
  type: row.type,
232
236
  name: row.name,
233
237
  version: row.version,
234
- sections: Object.keys(sectionsOf((row.data as Record<string, unknown>) ?? {}) ?? {}),
238
+ sections: Object.keys(sectionsOf(row.data ?? {}) ?? {}),
235
239
  }));
236
240
  }
237
241
 
@@ -249,7 +253,7 @@ const OPEN_BACKLOG_STATUSES = ['backlog', 'in_progress'] as const;
249
253
  async function backlogPart(client: TaigaClient, productId: string) {
250
254
  // Keyed by id: an item that moves from backlog to in_progress between the
251
255
  // two reads would otherwise appear twice.
252
- const open = new Map<string, Record<string, unknown>>();
256
+ const open = new Map<string, WithoutTenant<BacklogItem>>();
253
257
  let truncated = false;
254
258
  let pageCapped = false;
255
259
  for (const status of OPEN_BACKLOG_STATUSES) {
@@ -261,10 +265,8 @@ async function backlogPart(client: TaigaClient, productId: string) {
261
265
  break;
262
266
  }
263
267
  const result = await client.listBacklogItems(productId, { status, limit: 100, cursor });
264
- for (const row of result.data as Record<string, unknown>[]) {
265
- if (
266
- !isSettledOrArchived({ status: String(row.status), archivedAt: (row.archivedAt as string | null) ?? null })
267
- ) {
268
+ for (const row of result.data) {
269
+ if (!isSettledOrArchived({ status: String(row.status), archivedAt: row.archivedAt ?? null })) {
268
270
  open.set(String(row.id), row);
269
271
  }
270
272
  }
@@ -301,22 +303,22 @@ async function backlogPart(client: TaigaClient, productId: string) {
301
303
  async function repositoriesPart(client: TaigaClient, productId: string) {
302
304
  const result = await client.listProductRepositories(productId);
303
305
  return {
304
- items: pickFields(result.data as Record<string, unknown>[], REPOSITORY_SUMMARY_FIELDS),
306
+ items: pickFields(result.data, REPOSITORY_SUMMARY_FIELDS),
305
307
  truncated: result.nextCursor !== null,
306
308
  };
307
309
  }
308
310
 
309
311
  async function buildsPart(client: TaigaClient, productId: string) {
310
- const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data as Record<string, unknown>[];
312
+ const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data;
311
313
  return pickFields(rows, CONTEXT_BUILD_FIELDS);
312
314
  }
313
315
 
314
316
  export async function productContext(
315
317
  client: TaigaClient,
316
- product: Record<string, unknown>,
318
+ product: WithoutTenant<Product>,
317
319
  options: { documents: DocumentDepth }
318
320
  ): Promise<CallToolResult> {
319
- const productId = String(product.id);
321
+ const productId = product.id;
320
322
  const granted = (part: Part) => client.hasCapability(PARTS[part]);
321
323
  const notGranted = (Object.keys(PARTS) as Part[]).filter((part) => !granted(part));
322
324
 
@@ -335,8 +337,8 @@ export async function productContext(
335
337
  const bundle: Record<string, unknown> = {
336
338
  product: {
337
339
  ...pickFields([product], PRODUCT_SUMMARY_FIELDS)[0],
338
- projectType: isImportedProduct(product as ProductSettingsView) ? 'brownfield' : 'greenfield',
339
- defaultOrigin: defaultOriginFor(product as ProductSettingsView),
340
+ projectType: isImportedProduct(product) ? 'brownfield' : 'greenfield',
341
+ defaultOrigin: defaultOriginFor(product),
340
342
  // A product in setup says what it waits for: nothing else in the bundle does.
341
343
  ...(nextStep ? { nextStep } : {}),
342
344
  },
@@ -1,6 +1,7 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
+ import type { FactoryDocument, TenantDocument } from '@taiga/types';
2
3
  import { z } from 'zod';
3
- import { TaigaApiError, type TaigaClient, type TaigaDocument } from '../client.js';
4
+ import { TaigaApiError, type TaigaClient, type TaigaDocument, type WithoutTenant } from '../client.js';
4
5
  import { READ_ONLY } from './annotations.js';
5
6
  import { idInput, pagination } from './inputs.js';
6
7
  import {
@@ -27,7 +28,10 @@ import { page, pickFields } from './summarize.js';
27
28
  */
28
29
  const DOCUMENT_SUMMARY_FIELDS = ['type', 'name', 'description', 'version', 'isDraft', 'hasDraft', 'updatedAt'] as const;
29
30
 
30
- const summaries = (rows: unknown[]) => pickFields(rows as Record<string, unknown>[], DOCUMENT_SUMMARY_FIELDS);
31
+ /** A row of the company or factory document list; product documents list through their own tools. */
32
+ type ListedDocument = WithoutTenant<TenantDocument | FactoryDocument>;
33
+
34
+ const summaries = (rows: ListedDocument[]) => pickFields(rows, DOCUMENT_SUMMARY_FIELDS);
31
35
 
32
36
  /** What a lookup behind a miss said when it failed, so an unknown stays unknown. */
33
37
  function failedLookup(what: string, reason: unknown): string {
@@ -36,10 +40,10 @@ function failedLookup(what: string, reason: unknown): string {
36
40
  }
37
41
 
38
42
  /** `a (v3), b (v1)` for a not-found message. */
39
- function publishedList(rows: unknown[]): string {
43
+ function publishedList(rows: ListedDocument[]): string {
40
44
  // The list routes return the latest draft rows beside the published ones,
41
45
  // and a draft named as published sends the model into a second miss.
42
- return (rows as { type: string; version: unknown; isDraft: boolean }[])
46
+ return rows
43
47
  .filter((row) => row.isDraft === false)
44
48
  .map((row) => `${row.type} (v${String(row.version)})`)
45
49
  .join(', ');
@@ -6,8 +6,11 @@ import { AsyncLocalStorage } from 'node:async_hooks';
6
6
  * hears only that guesses another ID, or a name. Each ID a tool takes names
7
7
  * the tool that lists the right ones; the tool's arguments say which apply.
8
8
  */
9
+ const ITEM_ID_HINT =
10
+ "taiga_list_backlog_items lists a product's initiatives with their IDs; a run or plan ID is not one.";
11
+
9
12
  const BY_ARGUMENT: Record<string, string> = {
10
- itemId: "taiga_list_backlog_items lists a product's initiatives with their IDs; a run or plan ID is not one.",
13
+ itemId: ITEM_ID_HINT,
11
14
  backlogItemId: "taiga_list_backlog_items lists a product's initiatives with their IDs.",
12
15
  buildId: 'taiga_list_builds lists the runs, of one product or of every product the key can see.',
13
16
  planId: "taiga_get_plan gives the initiative's active plan and its ID.",
@@ -40,7 +43,7 @@ const BY_TOOL: Record<string, Hint> = {
40
43
  /\bplan\b/i.test(said)
41
44
  ? 'taiga_get_backlog_item says whether one is being generated (planningStatus planning) or failed, and ' +
42
45
  'taiga_plan_again asks for a new one.'
43
- : BY_ARGUMENT.itemId,
46
+ : ITEM_ID_HINT,
44
47
  };
45
48
 
46
49
  /** The hint for a 404 from one tool call: the tool's own, else one per ID it was given. */
@@ -14,8 +14,6 @@ import { page, pickFields } from './summarize.js';
14
14
  * everybody, so these tools say what they clear.
15
15
  */
16
16
 
17
- type Row = Record<string, unknown>;
18
-
19
17
  const ITEM_FIELDS = [
20
18
  'id',
21
19
  'productId',
@@ -95,7 +93,7 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
95
93
  // one until its snooze ends, and so does this, so an agent does not
96
94
  // act on what the team put off.
97
95
  const now = Date.now();
98
- const rows = result.data as Row[];
96
+ const rows = result.data;
99
97
  const due = rows.filter(
100
98
  (row) =>
101
99
  !(row.status === 'snoozed' && typeof row.snoozedUntil === 'string' && Date.parse(row.snoozedUntil) > now)
@@ -139,7 +137,7 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
139
137
  ...(params.cursor === undefined ? { withTotal: true } : {}),
140
138
  });
141
139
  return toolResult({
142
- ...page(pickFields(result.data as Row[], SETUP_TASK_FIELDS), result.nextCursor),
140
+ ...page(pickFields(result.data, SETUP_TASK_FIELDS), result.nextCursor),
143
141
  ...totalOf(result),
144
142
  });
145
143
  })
@@ -181,7 +179,7 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
181
179
  ...(params.status !== undefined ? { status: params.status } : {}),
182
180
  ...(params.snoozedUntil !== undefined ? { snoozedUntil: params.snoozedUntil } : {}),
183
181
  });
184
- return toolResult(pickFields([result.data as Row], ITEM_FIELDS)[0]);
182
+ return toolResult(pickFields([result.data], ITEM_FIELDS)[0]);
185
183
  })
186
184
  );
187
185
  }
package/src/tools/jobs.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
2
  import { createdInitiativeIdsOf, createdInitiativesOf, isBacklogGenerationEmptyResult } from '@taiga/types';
3
3
  import { z } from 'zod';
4
+ import type { AgentJob } from '@taiga/types';
4
5
  import { idInput } from './inputs.js';
5
- import type { TaigaClient } from '../client.js';
6
+ import type { TaigaClient, WithoutTenant } from '../client.js';
6
7
  import { READ_ONLY } from './annotations.js';
7
8
  import { runTool, toolResult } from './errors.js';
8
9
  import { pickFields } from './summarize.js';
@@ -13,7 +14,7 @@ import { pickFields } from './summarize.js';
13
14
  * it is, and what came of it.
14
15
  */
15
16
 
16
- type Row = Record<string, unknown>;
17
+ type Job = WithoutTenant<AgentJob>;
17
18
 
18
19
  /** What a job says about itself: its kind, how far it got, and why it failed. */
19
20
  const JOB_FIELDS = [
@@ -43,7 +44,7 @@ export const SETTLED_JOB_STATUSES: ReadonlySet<string> = new Set(['completed', '
43
44
  * own words and the way to decide otherwise). Null while the job runs, and
44
45
  * for a job that is not an initiative request.
45
46
  */
46
- function requestOutcome(job: Row): Row | null {
47
+ function requestOutcome(job: Job) {
47
48
  if (job.type !== 'generate_backlog' || job.status !== 'completed') return null;
48
49
  const created = createdInitiativesOf(job.result);
49
50
  if (created) return { outcome: 'created', initiatives: created };
@@ -63,7 +64,7 @@ function requestOutcome(job: Row): Row | null {
63
64
  }
64
65
 
65
66
  /** A job as the tools hand it over: its own fields, and for an initiative request, what came of it. */
66
- export function jobReport(job: Row): Row {
67
+ export function jobReport(job: Job) {
67
68
  const outcome = requestOutcome(job);
68
69
  return { ...pickFields([job], JOB_FIELDS)[0], ...(outcome ? { outcome } : {}) };
69
70
  }
@@ -85,6 +86,6 @@ export function registerJobTools(server: McpServer, client: TaigaClient) {
85
86
  inputSchema: z.object({ jobId: idInput('job', 'the tool that started the job returned it') }),
86
87
  annotations: READ_ONLY,
87
88
  },
88
- (params) => runTool(async () => toolResult(jobReport((await client.getAgentJob(params.jobId)).data as Row)))
89
+ (params) => runTool(async () => toolResult(jobReport((await client.getAgentJob(params.jobId)).data)))
89
90
  );
90
91
  }