@taiga-ai/mcp-server 0.18.0-rc.445 → 0.19.0-dev.451

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.
@@ -1,7 +1,7 @@
1
1
  import { type GetPromptResult, type McpServer, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
3
  import { TaigaApiError, type TaigaClient } from '../client.js';
4
- import { POLICY_TYPES, defaultOriginFor, type ProductSettingsView } from './catalogue.js';
4
+ import { POLICY_TYPES, defaultOriginFor } from './catalogue.js';
5
5
  import { productContext } from './context.js';
6
6
  import { apiFailureCode, apiFailureText, isMissOrRefusal, isNetworkFailure, networkFailureDetail } from './errors.js';
7
7
  import { idInput, productId, remoteUrl, slug } from './inputs.js';
@@ -97,7 +97,7 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
97
97
  '',
98
98
  'Before writing code: follow the factory instructions; read the policy sections that touch what you are',
99
99
  'about to change with taiga_get_document (security, data, SDLC, architecture at least); read the backlog',
100
- 'item you are implementing with taiga_get_backlog_item. Parts listed under notGranted or unavailable',
100
+ 'item you are implementing with taiga_get_initiative. Parts listed under notGranted or unavailable',
101
101
  'could not be read; say so rather than assuming they are empty.',
102
102
  '',
103
103
  fenced('json', bundle),
@@ -109,29 +109,29 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
109
109
 
110
110
  if (client.hasCapability('backlog:read') && client.hasCapability('products:read')) {
111
111
  server.registerPrompt(
112
- 'taiga_implement_backlog_item',
112
+ 'taiga_implement_initiative',
113
113
  {
114
- title: 'Implement a backlog item',
114
+ title: 'Implement an initiative',
115
115
  description:
116
116
  'Implement one initiative: its description and acceptance criteria, the architecture of the product ' +
117
117
  "from its own timeline, the factory's instructions and the policy sections to check against. " +
118
- 'taiga_list_backlog_items gives the item ID.',
118
+ 'taiga_list_initiatives gives the item ID.',
119
119
  argsSchema: z.object({
120
- itemId: idInput('backlog item', 'taiga_list_backlog_items (query finds one by title) gives it'),
120
+ initiativeId: idInput('initiative', 'taiga_list_initiatives (query finds one by title) gives it'),
121
121
  }),
122
122
  },
123
123
  async (args) => {
124
124
  const item = (
125
125
  await served(
126
- client.getBacklogItem(args.itemId),
127
- 'No backlog item with that ID the key can see; taiga_list_backlog_items shows what exists'
126
+ client.getBacklogItem(args.initiativeId),
127
+ 'No initiative with that ID the key can see; taiga_list_initiatives shows what exists'
128
128
  )
129
- ).data as Record<string, unknown>;
129
+ ).data;
130
130
  const productId = String(item.productId);
131
131
  const product = (
132
132
  await served(client.getProduct(productId), 'The product of that item is not visible to the key')
133
- ).data as Record<string, unknown>;
134
- const origin = defaultOriginFor(product as ProductSettingsView, 'architecture');
133
+ ).data;
134
+ const origin = defaultOriginFor(product, 'architecture');
135
135
 
136
136
  // The three reads are independent; each answers for itself.
137
137
  const [architecture, instructions, policies] = await Promise.allSettled([
@@ -146,10 +146,7 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
146
146
 
147
147
  const architectureText =
148
148
  architecture.status === 'fulfilled'
149
- ? fenced(
150
- 'markdown',
151
- renderDocument(stripProvenance(architecture.value.data as Record<string, unknown>)).text
152
- )
149
+ ? fenced('markdown', renderDocument(stripProvenance(architecture.value.data)).text)
153
150
  : isNotFound(architecture.reason)
154
151
  ? `(No published ${origin} architecture; taiga_list_product_documents shows what exists.)`
155
152
  : unreadable('The architecture', architecture.reason);
@@ -170,14 +167,11 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
170
167
  policies.status === 'fulfilled'
171
168
  ? fenced(
172
169
  'text',
173
- (policies.value.data as Record<string, unknown>[])
170
+ policies.value.data
174
171
  .filter(
175
172
  (row) => (POLICY_TYPES as readonly string[]).includes(String(row.type)) && row.isDraft === false
176
173
  )
177
- .map(
178
- (row) =>
179
- `- ${String(row.type)}: ${Object.keys(sectionsOf((row.data as Record<string, unknown>) ?? {}) ?? {}).join(', ')}`
180
- )
174
+ .map((row) => `- ${String(row.type)}: ${Object.keys(sectionsOf(row.data ?? {}) ?? {}).join(', ')}`)
181
175
  .join('\n') || '(No policy is published.)'
182
176
  )
183
177
  : policies.reason instanceof NotGranted
@@ -1,5 +1,14 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
- import { HALT_CAUSES, canResumeHalt, haltClearsItself, renderHalt, type Halt, type HaltCause } from '@taiga/types';
2
+ import {
3
+ HALT_CAUSES,
4
+ canResumeHalt,
5
+ haltClearsItself,
6
+ renderHalt,
7
+ type BacklogItem,
8
+ type BacklogItemLifecycle,
9
+ type Halt,
10
+ type HaltCause,
11
+ } from '@taiga/types';
3
12
  import { TaigaApiError, type TaigaClient } from '../client.js';
4
13
  import { z } from 'zod';
5
14
  import { OPERATE_SETTING, READ_ONLY } from './annotations.js';
@@ -7,13 +16,27 @@ import { isNetworkFailure, runTool, toolResult } from './errors.js';
7
16
  import { productId } from './inputs.js';
8
17
  import { criteriaCounts } from './summarize.js';
9
18
 
10
- /** How many entries the read names; past this an entry keeps its backlogItemId only. */
19
+ /** How many entries the read names; past this an entry keeps its initiativeId only. */
11
20
  const NAMED_ENTRIES = 25;
12
21
 
13
- type Row = Record<string, unknown>;
22
+ /** One queue entry as the read reports it: where it sits, and its initiative when that could be named. */
23
+ interface QueueRow {
24
+ position: number;
25
+ backlogItemId: string;
26
+ entryState: string;
27
+ title?: string;
28
+ state?: BacklogItem['state'];
29
+ planningStatus?: BacklogItem['planningStatus'];
30
+ needsHumanCause?: BacklogItem['needsHumanCause'];
31
+ criteria?: { met: number; total: number } | null;
32
+ lifecycle?: Pick<BacklogItemLifecycle, 'turn' | 'stage' | 'cta'> | null;
33
+ runId?: string;
34
+ prUrl?: string | null;
35
+ halt?: { cause: string | null; reason: string; nextStep: string };
36
+ }
14
37
 
15
38
  const MOVE_ASIDE =
16
- 'or move it down (taiga_queue_backlog_item with afterId) or off the line (taiga_move_backlog_item) to let the rest pass.';
39
+ 'or move it down (taiga_queue_initiative with afterId) or off the line (taiga_move_initiative) to let the rest pass.';
17
40
 
18
41
  /**
19
42
  * What lifts a halt, in the tools that do it. Read from the halt registry in
@@ -26,11 +49,11 @@ function haltNextStep(cause: string | null): string {
26
49
  return 'Read the plan with taiga_get_plan, then approve it (taiga_approve_plan) or send it back (taiga_reject_plan).';
27
50
  }
28
51
  const spec = cause ? (HALT_CAUSES as Record<string, { clearedWhen: string } | undefined>)[cause] : undefined;
29
- if (!cause || !spec) return `Read the initiative with taiga_get_backlog_item to see why, ${MOVE_ASIDE}`;
52
+ if (!cause || !spec) return `Read the initiative with taiga_get_initiative to see why, ${MOVE_ASIDE}`;
30
53
  const known = cause as HaltCause;
31
- if (canResumeHalt(known)) return `taiga_resume_backlog_item lifts it, ${MOVE_ASIDE}`;
54
+ if (canResumeHalt(known)) return `taiga_resume_initiative lifts it, ${MOVE_ASIDE}`;
32
55
  if (haltClearsItself(known)) return `It clears when ${spec.clearedWhen}; ${MOVE_ASIDE}`;
33
- return `Only taking it off the line clears it (taiga_move_backlog_item): ${spec.clearedWhen}.`;
56
+ return `Only taking it off the line clears it (taiga_move_initiative): ${spec.clearedWhen}.`;
34
57
  }
35
58
 
36
59
  /**
@@ -45,7 +68,7 @@ function blockingIndex<T>(entries: T[], stateOf: (entry: T) => unknown): number
45
68
  }
46
69
 
47
70
  /** The one sentence a person needs: what the line is doing, or what it is waiting on. */
48
- function waitingOn(rows: Row[]): string | null {
71
+ function waitingOn(rows: QueueRow[]): string | null {
49
72
  const at = blockingIndex(rows, (row) => row.entryState);
50
73
  if (at < 0) {
51
74
  return rows.length > 0
@@ -59,7 +82,7 @@ function waitingOn(rows: Row[]): string | null {
59
82
  const ahead = at > 0 ? ' Entries ahead of it are not held by it.' : '';
60
83
  return `${name} halted the queue, holding the entries behind it: ${halt.reason} ${halt.nextStep}${ahead}`;
61
84
  }
62
- const lifecycle = head.lifecycle as { turn?: unknown; stage?: unknown; cta?: unknown } | undefined;
85
+ const lifecycle = head.lifecycle;
63
86
  const held = 'every other queued initiative waits until its pull request merges.';
64
87
  if (!lifecycle) return `${name} is the active entry; ${held}`;
65
88
  const pr = typeof head.prUrl === 'string' ? ` (${head.prUrl})` : '';
@@ -86,7 +109,7 @@ export async function queueView(
86
109
  options: { name: 'all' | 'blocking' } = { name: 'all' }
87
110
  ) {
88
111
  const [queue, paused] = await Promise.all([client.listQueue(product), linePausedSince(client, product)]);
89
- const entries = (queue.data as Row[]).filter((entry) => entry.state !== 'settled');
112
+ const entries = queue.data.filter((entry) => entry.state !== 'settled');
90
113
  const canName = client.hasCapability('backlog:read');
91
114
  // 'blocking' names only the entry the line waits on: a caller after one
92
115
  // position and waitingOn need no more reads than that.
@@ -98,9 +121,7 @@ export async function queueView(
98
121
  : blocking >= 0
99
122
  ? [blocking]
100
123
  : [];
101
- const settled = await Promise.allSettled(
102
- toName.map((index) => client.getBacklogItem(String(entries[index]!.backlogItemId)))
103
- );
124
+ const settled = await Promise.allSettled(toName.map((index) => client.getBacklogItem(entries[index]!.backlogItemId)));
104
125
  const reads = new Map(toName.map((index, at) => [index, settled[at]!]));
105
126
  let unnamed = options.name === 'all' ? entries.length - toName.length : 0;
106
127
  const rows = entries.map((entry, index) => {
@@ -110,10 +131,10 @@ export async function queueView(
110
131
  if (!(read.reason instanceof TaigaApiError || isNetworkFailure(read.reason))) throw read.reason;
111
132
  unnamed += 1;
112
133
  }
113
- const item = read?.status === 'fulfilled' ? (read.value.data as Row) : null;
114
- const lifecycle = item?.lifecycle as Row | undefined;
115
- const latestBuild = lifecycle?.latestBuild as Row | null | undefined;
116
- const row: Row = { position: index + 1, backlogItemId: entry.backlogItemId, entryState: entry.state };
134
+ const item = read?.status === 'fulfilled' ? read.value.data : null;
135
+ const lifecycle = item?.lifecycle;
136
+ const latestBuild = lifecycle?.latestBuild;
137
+ const row: QueueRow = { position: index + 1, backlogItemId: entry.backlogItemId, entryState: entry.state };
117
138
  if (item) {
118
139
  Object.assign(row, {
119
140
  title: item.title,
@@ -122,8 +143,8 @@ export async function queueView(
122
143
  needsHumanCause: item.needsHumanCause,
123
144
  criteria: criteriaCounts(item),
124
145
  lifecycle: lifecycle ? { turn: lifecycle.turn, stage: lifecycle.stage, cta: lifecycle.cta } : null,
125
- ...(latestBuild ? { buildId: latestBuild.id, prUrl: latestBuild.prUrl ?? null } : {}),
126
- });
146
+ ...(latestBuild ? { runId: latestBuild.id, prUrl: latestBuild.prUrl ?? null } : {}),
147
+ } satisfies Partial<QueueRow>);
127
148
  }
128
149
  if (entry.state === 'halted') {
129
150
  const cause = typeof entry.haltCause === 'string' ? entry.haltCause : null;
@@ -142,10 +163,10 @@ export async function queueView(
142
163
  unnamed === 0
143
164
  ? null
144
165
  : !canName
145
- ? 'Entries carry backlogItemId only: naming them needs backlog:read, which this key lacks.'
166
+ ? 'Entries carry initiativeId only: naming them needs backlog:read, which this key lacks.'
146
167
  : entries.length > NAMED_ENTRIES
147
- ? `Only the first ${NAMED_ENTRIES} entries are named; the rest carry backlogItemId only.`
148
- : 'An entry whose initiative could not be read carries backlogItemId only; taiga_get_backlog_item reads it.';
168
+ ? `Only the first ${NAMED_ENTRIES} entries are named; the rest carry initiativeId only.`
169
+ : 'An entry whose initiative could not be read carries initiativeId only; taiga_get_initiative reads it.';
149
170
  // A paused line answers before anything in it: nothing is planned or built until it is resumed.
150
171
  const line =
151
172
  typeof paused === 'string'
@@ -165,7 +186,7 @@ export async function queueView(
165
186
  */
166
187
  async function linePausedSince(client: TaigaClient, product: string): Promise<string | false | null> {
167
188
  try {
168
- const row = (await client.getProduct(product)).data as { lineStoppedAt?: unknown };
189
+ const row = (await client.getProduct(product)).data;
169
190
  return typeof row.lineStoppedAt === 'string' ? row.lineStoppedAt : false;
170
191
  } catch (error) {
171
192
  if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
@@ -177,7 +198,7 @@ export function registerQueueTools(server: McpServer, client: TaigaClient) {
177
198
  if (!client.hasCapability('products:read')) return;
178
199
 
179
200
  server.registerTool(
180
- 'taiga_get_queue',
201
+ 'taiga_get_line',
181
202
  {
182
203
  title: 'Get the product queue',
183
204
  description:
@@ -205,7 +226,7 @@ export function registerQueueTools(server: McpServer, client: TaigaClient) {
205
226
  'keep their places. Resuming plans the interrupted initiative again from scratch, carries the interrupted ' +
206
227
  'build on from its first incomplete step, and the line picks up where it was; a build you had stopped ' +
207
228
  'yourself before pausing stays stopped. Idempotent. Pausing interrupts work and resuming starts it: read ' +
208
- 'taiga_get_queue first to see what it would interrupt, and ask the person.',
229
+ 'taiga_get_line first to see what it would interrupt, and ask the person.',
209
230
  inputSchema: z.object({
210
231
  productId,
211
232
  paused: z.boolean().describe('true pauses the line, false resumes it'),
@@ -214,7 +235,7 @@ export function registerQueueTools(server: McpServer, client: TaigaClient) {
214
235
  },
215
236
  (params) =>
216
237
  runTool(async () => {
217
- const row = (await client.setLineStopped(params.productId, params.paused)).data as Row;
238
+ const row = (await client.setLineStopped(params.productId, params.paused)).data;
218
239
  return toolResult({
219
240
  productId: row.id,
220
241
  linePaused: typeof row.lineStoppedAt === 'string',
@@ -0,0 +1,172 @@
1
+ import { TaigaApiError, type TaigaClient } from '../client.js';
2
+ import { PRODUCT_DOCUMENT_NAMES, isImportedProduct, setupNextStep, type ProductSettingsView } from './catalogue.js';
3
+ import { isNetworkFailure } from './errors.js';
4
+
5
+ type Row = Record<string, unknown>;
6
+
7
+ /**
8
+ * What a product in setup still needs, read rather than learned from a
9
+ * refused write (PRO-2800). A reviewing agent linked a repository and picked
10
+ * a branch, and every read still said "link its repository": nextStep read
11
+ * the Discovery step alone, and the only way to see the missing documents was
12
+ * to call taiga_finish_discovery and read its refusal. This reads the same
13
+ * preconditions the API refuses on (GET /products/:id/setup), the linked
14
+ * repositories and the import, and says the one next move.
15
+ */
16
+ export interface Readiness {
17
+ /** Whether taiga_finish_discovery would succeed now. Null when the API could not say. */
18
+ ready: boolean | null;
19
+ /** Null when the links could not be read: not the same as none linked. */
20
+ repository: { linked: false } | { linked: true; name: unknown; branch: unknown } | null;
21
+ /** An imported product's import: not started, running, complete, failed. */
22
+ import?: string;
23
+ discoveryStep?: unknown;
24
+ /** The required documents not yet published, by name; null when the API could not say. */
25
+ missingDocuments: string[] | null;
26
+ /** The required checks not yet stamped (stamped in the web); null when the API could not say. */
27
+ openChecks: string[] | null;
28
+ /** The one move that gets it closer; null when too little could be read to say. */
29
+ nextStep: string | null;
30
+ }
31
+
32
+ const IMPORT_STATE: Record<string, string> = {
33
+ pending: 'not started',
34
+ analyzing: 'running',
35
+ generating_docs: 'running',
36
+ complete: 'complete',
37
+ failed: 'failed',
38
+ cancelled: 'failed',
39
+ };
40
+
41
+ /** A failure that is the API's (refused, missing, unreachable), not a bug in this code. */
42
+ function isApiSide(error: unknown): boolean {
43
+ return error instanceof TaigaApiError || isNetworkFailure(error);
44
+ }
45
+
46
+ /** The documents by the names the web uses. */
47
+ function named(types: string[]): string {
48
+ return types.map((type) => PRODUCT_DOCUMENT_NAMES[type as keyof typeof PRODUCT_DOCUMENT_NAMES] ?? type).join(', ');
49
+ }
50
+
51
+ /** The product's setup readiness, or null once it has finished setup. */
52
+ export async function setupReadiness(client: TaigaClient, product: Row): Promise<Readiness | null> {
53
+ if (product['status'] !== 'draft') return null;
54
+ const id = String(product['id']);
55
+ const imported = isImportedProduct(product as ProductSettingsView);
56
+ const [repos, blockers, full] = await Promise.all([
57
+ client.listProductRepositories(id).then(
58
+ (result) => result.data as Row[],
59
+ (error: unknown) => {
60
+ if (isApiSide(error)) return null;
61
+ throw error;
62
+ }
63
+ ),
64
+ // An API from before the read answers 404 to it: fall back to the step.
65
+ client.getProductSetupBlockers(id).then(
66
+ (result) => result.data,
67
+ (error: unknown) => {
68
+ if (isApiSide(error)) return null;
69
+ throw error;
70
+ }
71
+ ),
72
+ // A write's answer carries no discoveryStep; the read derives it.
73
+ 'discoveryStep' in product || imported
74
+ ? Promise.resolve(product)
75
+ : client.getProduct(id).then(
76
+ (result) => result.data as Row,
77
+ (error: unknown) => {
78
+ if (isApiSide(error)) return product;
79
+ throw error;
80
+ }
81
+ ),
82
+ ]);
83
+
84
+ const link = repos?.[0];
85
+ const repository = !repos
86
+ ? null
87
+ : link
88
+ ? { linked: true as const, name: link['repositoryName'], branch: link['effectiveBranch'] }
89
+ : { linked: false as const };
90
+ const settings = (product['settings'] ?? {}) as { importStatus?: unknown };
91
+ const importState = imported ? (IMPORT_STATE[String(settings.importStatus)] ?? 'not started') : undefined;
92
+ const missing = blockers ? blockers.missingDocuments : null;
93
+ const openChecks = blockers ? blockers.openChecks.map((check) => check.criterion) : null;
94
+ // What the API refuses on, nothing else: a running import is in nextStep, not a blocker.
95
+ const ready = blockers ? missing!.length === 0 && openChecks!.length === 0 : null;
96
+
97
+ return {
98
+ ready,
99
+ repository,
100
+ ...(importState ? { import: importState } : {}),
101
+ ...(imported ? {} : { discoveryStep: full['discoveryStep'] ?? null }),
102
+ missingDocuments: missing ? missing.map((type) => named([type])) : null,
103
+ openChecks,
104
+ nextStep: nextStepFor({
105
+ imported,
106
+ importState,
107
+ linked: repository?.linked ?? null,
108
+ missing,
109
+ openChecks,
110
+ product: full,
111
+ }),
112
+ };
113
+ }
114
+
115
+ function nextStepFor(state: {
116
+ imported: boolean;
117
+ importState: string | undefined;
118
+ /** Null when the links could not be read. */
119
+ linked: boolean | null;
120
+ missing: string[] | null;
121
+ openChecks: string[] | null;
122
+ product: Row;
123
+ }): string | null {
124
+ const finish =
125
+ 'taiga_finish_discovery opens the product and locks its documents (ask the person: it is their decision ' +
126
+ 'that the documents are right).';
127
+ if (state.imported) {
128
+ if (state.linked === false) {
129
+ return 'Link its repository (taiga_list_linkable_repositories, then taiga_link_repository), then taiga_start_import.';
130
+ }
131
+ if (state.linked === null && state.importState === 'not started')
132
+ return setupNextStep(state.product as ProductSettingsView);
133
+ switch (state.importState) {
134
+ case 'running':
135
+ return 'The import is running and derives the documents from the code; taiga_wait_for_job follows it.';
136
+ case 'failed':
137
+ return 'The last import did not finish: taiga_start_import runs it again.';
138
+ case 'complete':
139
+ if (state.missing && state.missing.length > 0) {
140
+ return (
141
+ `The import finished but ${named(state.missing)} ${state.missing.length === 1 ? 'is' : 'are'} not ` +
142
+ 'published: taiga_list_product_documents shows them, taiga_publish_product_document publishes a draft.'
143
+ );
144
+ }
145
+ return `The import is done: review the reality documents (taiga_list_product_documents), then ${finish}`;
146
+ default:
147
+ return 'taiga_start_import derives the documents from the code and runs by itself (paid agent work: ask first).';
148
+ }
149
+ }
150
+ // Without the API's read, the Discovery step is all there is to go on, and
151
+ // without that, nothing: a guess would send a product at its documents back
152
+ // to the conversation.
153
+ if (!state.missing) return setupNextStep(state.product as ProductSettingsView);
154
+ if (state.missing.includes('specification')) {
155
+ return (
156
+ 'The specification comes from the Discovery conversation, held in the web: the person describes the ' +
157
+ 'product there. The other documents follow from it.' +
158
+ (state.linked === false
159
+ ? ' Linking a repository (taiga_link_repository) can wait until before the first build.'
160
+ : '')
161
+ );
162
+ }
163
+ if (state.missing.length > 0) {
164
+ return (
165
+ `Still to publish: ${named(state.missing)}. taiga_generate_product_documents generates the missing ones ` +
166
+ '(paid agent work: ask first); a draft is published with taiga_publish_product_document.'
167
+ );
168
+ }
169
+ if (state.openChecks && state.openChecks.length > 0)
170
+ return `The documents are published; the checks are stamped in the web, then ${finish}`;
171
+ return `Every required document is published: ${finish}`;
172
+ }
@@ -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 { GithubRepository } from '@taiga/types';
4
+ import type { TaigaClient, WithoutTenant } from '../client.js';
4
5
  import { OPERATE, OPERATE_SETTING, READ_ONLY } from './annotations.js';
5
6
  import { runTool, toolError, toolResult } from './errors.js';
6
7
  import { idInput, pagination, productId } from './inputs.js';
@@ -15,8 +16,6 @@ import { SEARCH_WITH_CURSOR, page, pickFields } from './summarize.js';
15
16
  * repositories it can reach to a product is a separate act, per product.
16
17
  */
17
18
 
18
- type Row = Record<string, unknown>;
19
-
20
19
  const LINK_FIELDS = [
21
20
  'id',
22
21
  'provider',
@@ -47,6 +46,8 @@ const GITHUB_REPOSITORY_FIELDS = [
47
46
  'defaultBranch',
48
47
  'description',
49
48
  'language',
49
+ // A product already links it (PRO-2800); absent from an API that predates the field.
50
+ 'linked',
50
51
  ] as const;
51
52
 
52
53
  const repositoryId = idInput(
@@ -81,7 +82,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
81
82
  // The route answers one page of 100 and takes no cursor: past that, the list says it is cut.
82
83
  const result = await client.listProductRepositories(params.productId);
83
84
  return toolResult({
84
- data: pickFields(result.data as Row[], LINK_FIELDS),
85
+ data: pickFields(result.data, LINK_FIELDS),
85
86
  truncated: result.nextCursor !== null,
86
87
  });
87
88
  })
@@ -115,7 +116,8 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
115
116
  "The repositories the organization's GitHub connection can reach, to link one to a product " +
116
117
  '(taiga_link_repository takes its id). Searches by name with query. Name the GitHub account (account: ' +
117
118
  'its login, the owner in owner/repo, or installationId) to read only its repositories; without one it ' +
118
- 'reads every connected account and takes rows from each in turn, so one account cannot fill the page. No ' +
119
+ 'reads every connected account and takes rows from each in turn, so one account cannot fill the page. ' +
120
+ 'linked: true means a product already links it (taiga_resolve_product says which). No ' +
119
121
  'connection means GitHub is not connected yet: that is done once, in the web, by someone who can ' +
120
122
  'install the app.',
121
123
  inputSchema: z.object({
@@ -135,7 +137,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
135
137
  },
136
138
  (params) =>
137
139
  runTool(async () => {
138
- const installations = (await client.listGithubInstallations()).data as Row[];
140
+ const installations = (await client.listGithubInstallations()).data;
139
141
  const accounts = pickFields(installations, INSTALLATION_FIELDS);
140
142
  if (installations.length === 0) {
141
143
  return toolError(
@@ -167,7 +169,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
167
169
  // are read: a search answers its ranked matches whatever the page
168
170
  // size, and several accounts add up.
169
171
  const max = params.limit ?? 25;
170
- const rows = (data: unknown[]) => pickFields(data as Row[], GITHUB_REPOSITORY_FIELDS);
172
+ const rows = (data: WithoutTenant<GithubRepository>[]) => pickFields(data, GITHUB_REPOSITORY_FIELDS);
171
173
  if (chosen.length === 1) {
172
174
  const result = await client.listInstallationRepositories(String(chosen[0]!.id), {
173
175
  q: params.query,
@@ -227,7 +229,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
227
229
  githubRepositoryId: params.githubRepositoryId,
228
230
  ...(params.replacesRepositoryId ? { replacesRepositoryId: params.replacesRepositoryId } : {}),
229
231
  });
230
- return toolResult(pickFields([linked.data as Row], LINK_FIELDS)[0]);
232
+ return toolResult(pickFields([linked.data], LINK_FIELDS)[0]);
231
233
  })
232
234
  );
233
235
 
@@ -265,7 +267,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
265
267
  ...(params.baseBranch !== undefined ? { baseBranch: params.baseBranch } : {}),
266
268
  ...(params.label !== undefined ? { label: params.label } : {}),
267
269
  })
268
- ).data as Row;
270
+ ).data;
269
271
  return toolResult({
270
272
  ...pickFields([updated], LINK_FIELDS)[0],
271
273
  baseBranchProtection: updated.baseBranchProtection ?? null,
@@ -16,7 +16,6 @@ import {
16
16
  productDocumentName,
17
17
  type ProductDocumentOrigin,
18
18
  type ProductDocumentType,
19
- type ProductSettingsView,
20
19
  } from './catalogue.js';
21
20
  import { productId as productIdInput } from './inputs.js';
22
21
  import { mimeTypeOf, renderDocument } from './markdown.js';
@@ -69,9 +68,7 @@ async function served<T>(read: Promise<T>, miss: string): Promise<T> {
69
68
  export function registerResources(server: McpServer, client: TaigaClient) {
70
69
  if (client.hasCapability('tenant:documents:read')) {
71
70
  const published = async () =>
72
- ((await client.listTenantDocuments({ limit: 100 })).data as Record<string, unknown>[]).filter(
73
- (row) => row.isDraft === false
74
- );
71
+ (await client.listTenantDocuments({ limit: 100 })).data.filter((row) => row.isDraft === false);
75
72
 
76
73
  server.registerResource(
77
74
  'organization-document',
@@ -95,7 +92,7 @@ export function registerResources(server: McpServer, client: TaigaClient) {
95
92
  'tone of voice or the standing instructions. Sections rendered as headings.',
96
93
  },
97
94
  async (uri, variables) => {
98
- const type = requireOneOf('Document type', String(variables.type), TENANT_DOCUMENT_TYPES);
95
+ const type = requireOneOf('Document type', String(variables['type']), TENANT_DOCUMENT_TYPES);
99
96
  try {
100
97
  return contents(uri.href, (await client.getTenantDocument(type)).data);
101
98
  } catch (error) {
@@ -127,7 +124,7 @@ export function registerResources(server: McpServer, client: TaigaClient) {
127
124
  'section map, JSON otherwise (the read says which).',
128
125
  },
129
126
  async (uri, variables) => {
130
- const factoryId = requireId('Factory ID', String(variables.factoryId));
127
+ const factoryId = requireId('Factory ID', String(variables['factoryId']));
131
128
  const doc = await served(
132
129
  client.getFactoryDocument(factoryId, FACTORY_DOCUMENT_TYPES[0]),
133
130
  'This factory has not published instructions, or the key cannot see the factory'
@@ -147,7 +144,7 @@ export function registerResources(server: McpServer, client: TaigaClient) {
147
144
  client.getProductDocument(productId, type, origin),
148
145
  `No published ${origin} ${type} for this product, or the key cannot see it; taiga_list_product_documents shows what exists`
149
146
  );
150
- return contents(uri.href, { name: productDocumentName(type), ...(result.data as Record<string, unknown>) });
147
+ return contents(uri.href, { name: productDocumentName(type), ...result.data });
151
148
  };
152
149
 
153
150
  // The timeline template first: the SDK tries templates in registration
@@ -162,9 +159,9 @@ export function registerResources(server: McpServer, client: TaigaClient) {
162
159
  'Markdown for a section map, JSON otherwise (the read says which).',
163
160
  },
164
161
  async (uri, variables) => {
165
- const productId = requireId('Product ID', String(variables.productId));
166
- const type = documentType(String(variables.type));
167
- const origin = requireOneOf('Origin', String(variables.origin), PRODUCT_DOCUMENT_ORIGINS);
162
+ const productId = requireId('Product ID', String(variables['productId']));
163
+ const type = documentType(String(variables['type']));
164
+ const origin = requireOneOf('Origin', String(variables['origin']), PRODUCT_DOCUMENT_ORIGINS);
168
165
  return read(uri, productId, type, origin);
169
166
  }
170
167
  );
@@ -181,14 +178,14 @@ export function registerResources(server: McpServer, client: TaigaClient) {
181
178
  'otherwise (the read says which).',
182
179
  },
183
180
  async (uri, variables) => {
184
- const productId = requireId('Product ID', String(variables.productId));
185
- const type = documentType(String(variables.type));
181
+ const productId = requireId('Product ID', String(variables['productId']));
182
+ const type = documentType(String(variables['type']));
186
183
  const product = (
187
184
  await served(
188
185
  client.getProduct(productId),
189
186
  'No product with that ID the key can see; taiga_list_products shows them'
190
187
  )
191
- ).data as ProductSettingsView;
188
+ ).data;
192
189
  return read(uri, productId, type, defaultOriginFor(product, type));
193
190
  }
194
191
  );
@@ -197,20 +194,20 @@ export function registerResources(server: McpServer, client: TaigaClient) {
197
194
  if (client.hasCapability('backlog:read')) {
198
195
  server.registerResource(
199
196
  'backlog-item',
200
- new ResourceTemplate('taiga://backlog/{itemId}', { list: undefined }),
197
+ new ResourceTemplate('taiga://initiatives/{initiativeId}', { list: undefined }),
201
198
  {
202
- title: 'Backlog item',
199
+ title: 'Initiative',
203
200
  description:
204
- 'An initiative in full: description, acceptance criteria, technical context. taiga_list_backlog_items ' +
201
+ 'An initiative in full: description, acceptance criteria, technical context. taiga_list_initiatives ' +
205
202
  'gives the item ID.',
206
203
  mimeType: 'application/json',
207
204
  },
208
205
  async (uri, variables) => {
209
- const itemId = requireId('Backlog item ID', String(variables.itemId));
206
+ const initiativeId = requireId('Initiative ID', String(variables['initiativeId']));
210
207
  const item = (
211
208
  await served(
212
- client.getBacklogItem(itemId),
213
- 'No backlog item with that ID the key can see; taiga_list_backlog_items shows what exists'
209
+ client.getBacklogItem(initiativeId),
210
+ 'No initiative with that ID the key can see; taiga_list_initiatives shows what exists'
214
211
  )
215
212
  ).data;
216
213
  return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(item, null, 2) }] };
@@ -50,12 +50,12 @@ describe('applySectionEdit', () => {
50
50
  });
51
51
 
52
52
  it('turns a bare-string or array-valued section into an object with the new text', () => {
53
- expect(applySectionEdit(sections, 'scope', 'new').scope).toEqual({ content: 'new' });
54
- expect(applySectionEdit(sections, 'list', 'new').list).toEqual({ content: 'new' });
53
+ expect(applySectionEdit(sections, 'scope', 'new')['scope']).toEqual({ content: 'new' });
54
+ expect(applySectionEdit(sections, 'list', 'new')['list']).toEqual({ content: 'new' });
55
55
  });
56
56
 
57
57
  it('adds a section that did not exist', () => {
58
- expect(applySectionEdit(sections, 'retention', 'keep 90 days').retention).toEqual({ content: 'keep 90 days' });
58
+ expect(applySectionEdit(sections, 'retention', 'keep 90 days')['retention']).toEqual({ content: 'keep 90 days' });
59
59
  });
60
60
 
61
61
  it('does not mutate its input', () => {