@taiga-ai/mcp-server 0.25.0-rc.474 → 0.26.0-dev.481
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/README.md +111 -0
- package/dist/index.js +292 -115
- package/package.json +15 -2
- package/scripts/build.mjs +0 -37
- package/scripts/prepare-publish.cjs +0 -49
- package/src/client.test.ts +0 -424
- package/src/client.ts +0 -977
- package/src/config.test.ts +0 -24
- package/src/config.ts +0 -28
- package/src/index.ts +0 -45
- package/src/modern.integration.test.ts +0 -153
- package/src/server.integration.test.ts +0 -9214
- package/src/server.ts +0 -18
- package/src/tools/annotations.ts +0 -38
- package/src/tools/autonomy.ts +0 -86
- package/src/tools/backlogs.ts +0 -802
- package/src/tools/builds.ts +0 -668
- package/src/tools/catalogue.test.ts +0 -170
- package/src/tools/catalogue.ts +0 -223
- package/src/tools/context.ts +0 -513
- package/src/tools/documents.ts +0 -375
- package/src/tools/errors.ts +0 -172
- package/src/tools/hints.ts +0 -99
- package/src/tools/inbox.ts +0 -546
- package/src/tools/index.ts +0 -239
- package/src/tools/inputs.ts +0 -47
- package/src/tools/instructions.ts +0 -41
- package/src/tools/jobs.ts +0 -90
- package/src/tools/knowledge.ts +0 -249
- package/src/tools/maintenance.ts +0 -547
- package/src/tools/markdown.test.ts +0 -249
- package/src/tools/markdown.ts +0 -151
- package/src/tools/observe.ts +0 -311
- package/src/tools/product-documents.ts +0 -184
- package/src/tools/products.ts +0 -529
- package/src/tools/prompts.ts +0 -265
- package/src/tools/queue.ts +0 -250
- package/src/tools/readiness.ts +0 -177
- package/src/tools/remote.test.ts +0 -44
- package/src/tools/remote.ts +0 -107
- package/src/tools/repositories.ts +0 -360
- package/src/tools/request-prompt.test.ts +0 -32
- package/src/tools/request-prompt.ts +0 -28
- package/src/tools/resources.ts +0 -217
- package/src/tools/sections.test.ts +0 -143
- package/src/tools/sections.ts +0 -287
- package/src/tools/setup.ts +0 -455
- package/src/tools/summarize.test.ts +0 -20
- package/src/tools/summarize.ts +0 -96
- package/src/tools/wait.ts +0 -490
- package/src/version.test.ts +0 -10
- package/src/version.ts +0 -14
- package/taiga-ai-mcp-server-0.25.0-rc.474.tgz +0 -0
- package/tsconfig.json +0 -21
- package/vitest.config.ts +0 -9
package/src/tools/context.ts
DELETED
|
@@ -1,513 +0,0 @@
|
|
|
1
|
-
import type { McpServer, CallToolResult } from '@modelcontextprotocol/server';
|
|
2
|
-
import { isSettledOrArchived } from '@taiga/types';
|
|
3
|
-
import type { BacklogItem, Product } from '@taiga/types';
|
|
4
|
-
import { z } from 'zod';
|
|
5
|
-
import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
|
|
6
|
-
import { READ_ONLY } from './annotations.js';
|
|
7
|
-
import { situatedRows } from './backlogs.js';
|
|
8
|
-
import { POLICY_TYPES, defaultOriginFor, isImportedProduct } from './catalogue.js';
|
|
9
|
-
import {
|
|
10
|
-
apiFailureText,
|
|
11
|
-
inProductWords,
|
|
12
|
-
isNetworkFailure,
|
|
13
|
-
networkFailureDetail,
|
|
14
|
-
runTool,
|
|
15
|
-
toolError,
|
|
16
|
-
toolResult,
|
|
17
|
-
} from './errors.js';
|
|
18
|
-
import { productId, remoteUrl, slug } from './inputs.js';
|
|
19
|
-
import { PRODUCT_SUMMARY_FIELDS, resolveProductRef } from './products.js';
|
|
20
|
-
import {
|
|
21
|
-
budgetSections,
|
|
22
|
-
instructionsText,
|
|
23
|
-
isRecord,
|
|
24
|
-
sectionContent,
|
|
25
|
-
sectionsOf,
|
|
26
|
-
stripProvenance,
|
|
27
|
-
type DocumentData,
|
|
28
|
-
type StoredDocument,
|
|
29
|
-
} from './sections.js';
|
|
30
|
-
import { pickFields } from './summarize.js';
|
|
31
|
-
import { initiativeStates, readableRow, withInitiativeState } from './builds.js';
|
|
32
|
-
import { setupReadiness } from './readiness.js';
|
|
33
|
-
import { standingSummary } from './maintenance.js';
|
|
34
|
-
import { attentionCounts } from './inbox.js';
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* One call at the start of a coding session: everything the model would
|
|
38
|
-
* otherwise collect in eight, fanned out here. Each part stands on its own
|
|
39
|
-
* (Promise.allSettled): a part the API refuses or fails is named under
|
|
40
|
-
* `unavailable` with what the API said, a part the key's capabilities do not
|
|
41
|
-
* cover is named under `notGranted`, and neither is ever shown as empty. A
|
|
42
|
-
* failure that is not the API's (a bug in this code) is not downgraded; it
|
|
43
|
-
* fails the call so it surfaces.
|
|
44
|
-
*/
|
|
45
|
-
|
|
46
|
-
/** The two documents a coding session starts from; the rest are a read away. */
|
|
47
|
-
const CONTEXT_DOCUMENT_TYPES = ['specification', 'architecture'] as const;
|
|
48
|
-
|
|
49
|
-
const REPOSITORY_SUMMARY_FIELDS = [
|
|
50
|
-
'id',
|
|
51
|
-
'provider',
|
|
52
|
-
'repositoryName',
|
|
53
|
-
'repositoryUrl',
|
|
54
|
-
'effectiveBranch',
|
|
55
|
-
'isManaged',
|
|
56
|
-
] as const;
|
|
57
|
-
|
|
58
|
-
/** How many of the latest runs to carry; taiga_list_runs pages the rest. */
|
|
59
|
-
const BUILDS_IN_CONTEXT = 5;
|
|
60
|
-
|
|
61
|
-
/** A run as the bundle carries it: enough to say what was built and where; taiga_get_run has the rest. */
|
|
62
|
-
const CONTEXT_BUILD_FIELDS = [
|
|
63
|
-
'id',
|
|
64
|
-
'runNumber',
|
|
65
|
-
'backlogItemId',
|
|
66
|
-
'targetTitle',
|
|
67
|
-
'status',
|
|
68
|
-
'failedReason',
|
|
69
|
-
'completedSteps',
|
|
70
|
-
'totalSteps',
|
|
71
|
-
'baseBranch',
|
|
72
|
-
'prNumber',
|
|
73
|
-
'prUrl',
|
|
74
|
-
'prState',
|
|
75
|
-
'prMerged',
|
|
76
|
-
'prDraft',
|
|
77
|
-
'reviewEscalation',
|
|
78
|
-
'error',
|
|
79
|
-
// Read for hardeningNote; the compact row drops it.
|
|
80
|
-
'hardeningResult',
|
|
81
|
-
'createdAt',
|
|
82
|
-
] as const;
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Characters of section text each of the two documents may bring by default.
|
|
86
|
-
* Two of them plus the rest of the bundle stay well inside the 25k-token
|
|
87
|
-
* tool result Claude Code accepts (a 67k-character bundle was refused); a
|
|
88
|
-
* larger product's documents come cut, with the sections left out named.
|
|
89
|
-
*/
|
|
90
|
-
export const DOCUMENT_BUDGET = 12_000;
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* The document without each section's citations: on a reality document they
|
|
94
|
-
* are the code evidence behind every claim and as long as the text itself,
|
|
95
|
-
* which a session's opening read does not need. The full read keeps them.
|
|
96
|
-
* Says whether any were dropped, so the bundle can tell the reader.
|
|
97
|
-
*/
|
|
98
|
-
function withoutCitations(doc: StoredDocument): { doc: StoredDocument; dropped: boolean } {
|
|
99
|
-
const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
|
|
100
|
-
if (!sections) return { doc, dropped: false };
|
|
101
|
-
let dropped = false;
|
|
102
|
-
const kept = Object.fromEntries(
|
|
103
|
-
Object.entries(sections).map(([id, section]) => {
|
|
104
|
-
if (!isRecord(section) || !('citations' in section)) return [id, section];
|
|
105
|
-
dropped = true;
|
|
106
|
-
const { citations: _citations, ...rest } = section;
|
|
107
|
-
return [id, rest];
|
|
108
|
-
})
|
|
109
|
-
);
|
|
110
|
-
return { doc: { ...doc, data: { ...(doc.data as DocumentData), sections: kept } }, dropped };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* How much of the two documents the bundle carries. The outline by default
|
|
115
|
-
* (PRO-2778): the whole sections that fit a budget made the opening read run
|
|
116
|
-
* to 43k characters for an active product, most of it prose a "where does it
|
|
117
|
-
* stand" question never reads.
|
|
118
|
-
*/
|
|
119
|
-
export const DOCUMENT_DEPTHS = ['outline', 'budget', 'full'] as const;
|
|
120
|
-
export type DocumentDepth = (typeof DOCUMENT_DEPTHS)[number];
|
|
121
|
-
|
|
122
|
-
/** Pages of 100 the backlog part follows before it stops and says so. */
|
|
123
|
-
const BACKLOG_PAGE_LIMIT = 10;
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Open items the bundle carries, the newest of those read; past this it says truncated and
|
|
127
|
-
* taiga_list_initiatives pages the rest. Keeps a product with hundreds of
|
|
128
|
-
* open initiatives inside the tool result the document budget is sized for.
|
|
129
|
-
*/
|
|
130
|
-
const BACKLOG_IN_CONTEXT = 50;
|
|
131
|
-
|
|
132
|
-
const PARTS = {
|
|
133
|
-
factory: 'factories:read',
|
|
134
|
-
documents: 'products:read',
|
|
135
|
-
policies: 'tenant:documents:read',
|
|
136
|
-
initiatives: 'backlog:read',
|
|
137
|
-
repositories: 'products:read',
|
|
138
|
-
runs: 'products:read',
|
|
139
|
-
actionRequired: 'products:read',
|
|
140
|
-
maintenance: 'products:read',
|
|
141
|
-
policyStanding: 'products:read',
|
|
142
|
-
} as const;
|
|
143
|
-
type Part = keyof typeof PARTS;
|
|
144
|
-
|
|
145
|
-
/** What the API said when a part failed, in the words the bundle reports. */
|
|
146
|
-
function unavailableReason(reason: unknown): string {
|
|
147
|
-
if (reason instanceof TaigaApiError) return apiFailureText(reason);
|
|
148
|
-
return `the API could not be reached: ${networkFailureDetail(reason as TypeError)}`;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Whether a failed part is the API's doing; anything else is ours and propagates. */
|
|
152
|
-
function isApiFailure(reason: unknown): boolean {
|
|
153
|
-
return reason instanceof TaigaApiError || isNetworkFailure(reason);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function isNotFound(reason: unknown): boolean {
|
|
157
|
-
return reason instanceof TaigaApiError && reason.status === 404;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** The factory's standing guidance in full; null, with a note, when none is published or it is not section-shaped. */
|
|
161
|
-
async function factoryPart(client: TaigaClient, factoryId: string) {
|
|
162
|
-
const factory = (await client.getFactory(factoryId)).data;
|
|
163
|
-
const base = { id: factory.id, name: factory.name, slug: factory.slug };
|
|
164
|
-
try {
|
|
165
|
-
const doc = (await client.getFactoryDocument(factoryId, 'factory_instructions')).data;
|
|
166
|
-
const sections = sectionsOf(doc.data);
|
|
167
|
-
if (!sections) {
|
|
168
|
-
return {
|
|
169
|
-
...base,
|
|
170
|
-
instructions: null,
|
|
171
|
-
instructionsVersion: doc.version,
|
|
172
|
-
note: 'The published instructions are not made of sections; read them with taiga_get_factory_document.',
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
// Every section, as the API injects them into the agents; a factory
|
|
176
|
-
// whose instructions grew a second section is not cut to the first.
|
|
177
|
-
return { ...base, instructions: instructionsText(sections), instructionsVersion: doc.version };
|
|
178
|
-
} catch (error) {
|
|
179
|
-
if (!isNotFound(error)) throw error;
|
|
180
|
-
return {
|
|
181
|
-
...base,
|
|
182
|
-
instructions: null,
|
|
183
|
-
instructionsVersion: null,
|
|
184
|
-
note: 'This factory has not published instructions yet.',
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* The specification and architecture from the product's own timeline: their
|
|
191
|
-
* outline by default, the whole sections up to a budget or in full when
|
|
192
|
-
* asked. Null, with a note, where unpublished.
|
|
193
|
-
*/
|
|
194
|
-
async function documentsPart(client: TaigaClient, product: WithoutTenant<Product>, depth: DocumentDepth) {
|
|
195
|
-
const productId = product.id;
|
|
196
|
-
const origin = defaultOriginFor(product);
|
|
197
|
-
const reads = await Promise.allSettled(
|
|
198
|
-
CONTEXT_DOCUMENT_TYPES.map((type) => client.getProductDocument(productId, type, origin))
|
|
199
|
-
);
|
|
200
|
-
const out: Record<string, unknown> = { origin };
|
|
201
|
-
const notes: string[] = [];
|
|
202
|
-
CONTEXT_DOCUMENT_TYPES.forEach((type, index) => {
|
|
203
|
-
const read = reads[index]!;
|
|
204
|
-
if (read.status === 'fulfilled') {
|
|
205
|
-
const doc = stripProvenance(read.value.data);
|
|
206
|
-
if (depth === 'full') out[type] = doc;
|
|
207
|
-
else if (depth === 'budget') {
|
|
208
|
-
const lean = withoutCitations(doc);
|
|
209
|
-
if (lean.dropped) {
|
|
210
|
-
notes.push(
|
|
211
|
-
`The ${origin} ${type} leaves out each section's citations (its code evidence); taiga_get_product_document carries them.`
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
const cut = budgetSections(lean.doc, DOCUMENT_BUDGET);
|
|
215
|
-
if (!cut) {
|
|
216
|
-
// Not made of sections: whole when small, else pointed at.
|
|
217
|
-
const small = JSON.stringify(lean.doc).length <= DOCUMENT_BUDGET;
|
|
218
|
-
out[type] = small ? lean.doc : { version: doc.version, sections: null };
|
|
219
|
-
if (!small)
|
|
220
|
-
notes.push(`The ${origin} ${type} is not made of sections; read it with taiga_get_product_document.`);
|
|
221
|
-
} else if (cut.omittedSections.length > 0) {
|
|
222
|
-
out[type] = { ...cut.doc, omittedSections: cut.omittedSections };
|
|
223
|
-
notes.push(
|
|
224
|
-
`The ${origin} ${type} carries the whole sections that fit; read the ones under omittedSections with ` +
|
|
225
|
-
'taiga_get_product_document, naming them in sections.'
|
|
226
|
-
);
|
|
227
|
-
} else out[type] = cut.doc;
|
|
228
|
-
} else {
|
|
229
|
-
const sections = sectionsOf(doc.data ?? {});
|
|
230
|
-
if (sections) {
|
|
231
|
-
// Each id with the length of its text, so the next read is sized before it is
|
|
232
|
-
// made (a reality section's citations come on top).
|
|
233
|
-
const outline = Object.keys(sections).map((id) => ({
|
|
234
|
-
id,
|
|
235
|
-
characters: sectionContent(sections, id)?.length ?? 0,
|
|
236
|
-
}));
|
|
237
|
-
out[type] = { version: doc.version, sections: outline };
|
|
238
|
-
} else {
|
|
239
|
-
out[type] = { version: doc.version, sections: null };
|
|
240
|
-
notes.push(`The ${origin} ${type} is not made of sections; read it with taiga_get_product_document.`);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
} else if (isNotFound(read.reason)) {
|
|
244
|
-
out[type] = null;
|
|
245
|
-
notes.push(`No published ${origin} ${type}; taiga_get_product_document without type shows what the product has.`);
|
|
246
|
-
} else throw read.reason;
|
|
247
|
-
});
|
|
248
|
-
return { ...out, notes };
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** Every published policy with its section count, so the model knows which one to read with taiga_get_document. */
|
|
252
|
-
async function policiesPart(client: TaigaClient) {
|
|
253
|
-
const rows = (await client.listTenantDocuments({ limit: 100 })).data as Record<string, unknown>[];
|
|
254
|
-
// Each policy with how many sections it has, not their ids: the same for every product, and about a fifth of
|
|
255
|
-
// the bundle (PRO-2800). taiga_get_document with sections: [] lists them, and a control id reads its section.
|
|
256
|
-
return rows
|
|
257
|
-
.filter((row) => (POLICY_TYPES as readonly string[]).includes(String(row['type'])) && row['isDraft'] === false)
|
|
258
|
-
.map((row) => ({
|
|
259
|
-
type: row['type'],
|
|
260
|
-
name: row['name'],
|
|
261
|
-
version: row['version'],
|
|
262
|
-
sections: Object.keys(sectionsOf((row['data'] as Record<string, unknown>) ?? {}) ?? {}).length,
|
|
263
|
-
}));
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/** The statuses an item is open in; the API's filter takes one at a time. */
|
|
267
|
-
const OPEN_BACKLOG_STATUSES = ['backlog', 'in_progress'] as const;
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* The items still open: one status-filtered read per open status (the API
|
|
271
|
-
* leaves archived items out), each paged in full up to a cap the result
|
|
272
|
-
* reports. The list is oldest first and the open items are the newest, so a
|
|
273
|
-
* single page would miss exactly the ones that matter. Rows carrying a
|
|
274
|
-
* retired status (readable for a rolling deploy) match neither filter;
|
|
275
|
-
* the settled check below keeps the shape honest for whatever comes back.
|
|
276
|
-
*/
|
|
277
|
-
async function backlogPart(client: TaigaClient, productId: string) {
|
|
278
|
-
// Keyed by id: an item that moves from backlog to in_progress between the
|
|
279
|
-
// two reads would otherwise appear twice.
|
|
280
|
-
const open = new Map<string, WithoutTenant<BacklogItem>>();
|
|
281
|
-
let truncated = false;
|
|
282
|
-
let pageCapped = false;
|
|
283
|
-
for (const status of OPEN_BACKLOG_STATUSES) {
|
|
284
|
-
let cursor: string | undefined;
|
|
285
|
-
for (let pageIndex = 0; ; pageIndex += 1) {
|
|
286
|
-
if (pageIndex === BACKLOG_PAGE_LIMIT) {
|
|
287
|
-
truncated = true;
|
|
288
|
-
pageCapped = true;
|
|
289
|
-
break;
|
|
290
|
-
}
|
|
291
|
-
const result = await client.listBacklogItems(productId, { status, limit: 100, cursor });
|
|
292
|
-
for (const row of result.data) {
|
|
293
|
-
if (!isSettledOrArchived({ status: String(row.status), archivedAt: row.archivedAt ?? null })) {
|
|
294
|
-
open.set(String(row.id), row);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
if (!result.nextCursor) break;
|
|
298
|
-
cursor = result.nextCursor;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
// One read per status, so the rows arrive grouped by status, not by age.
|
|
302
|
-
// Ids are UUIDv7, time-ordered: sorted by id, the newest are the last.
|
|
303
|
-
const rows = [...open.values()].sort((a, b) =>
|
|
304
|
-
String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0
|
|
305
|
-
);
|
|
306
|
-
if (rows.length > BACKLOG_IN_CONTEXT) truncated = true;
|
|
307
|
-
// Every open column counted, zeros too: two reviews could not tell an empty backlog from a cut list (PRO-2800).
|
|
308
|
-
const byColumn: Record<string, number> = { backlog: 0, todo: 0, queue: 0, plan: 0, build: 0 };
|
|
309
|
-
for (const row of rows) if (row.state) byColumn[row.state] = (byColumn[row.state] ?? 0) + 1;
|
|
310
|
-
return {
|
|
311
|
-
byColumn,
|
|
312
|
-
items: situatedRows(rows.slice(-BACKLOG_IN_CONTEXT)),
|
|
313
|
-
truncated,
|
|
314
|
-
// The list pages oldest first and takes no order, so a status past the
|
|
315
|
-
// page cap was read only up to its oldest items: these are the newest of
|
|
316
|
-
// what was read, not of the backlog.
|
|
317
|
-
...(pageCapped
|
|
318
|
-
? {
|
|
319
|
-
note:
|
|
320
|
-
`More than ${BACKLOG_PAGE_LIMIT * 100} open items in one status: only the oldest of them were read, so ` +
|
|
321
|
-
'the newest may be missing here and byColumn counts only those read; taiga_list_initiatives pages the rest.',
|
|
322
|
-
}
|
|
323
|
-
: {}),
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* The linked repositories. The route serves one page of 100 and takes no
|
|
329
|
-
* cursor, so past that the bundle says the list is cut rather than complete.
|
|
330
|
-
*/
|
|
331
|
-
async function repositoriesPart(links: () => ReturnType<TaigaClient['listProductRepositories']>) {
|
|
332
|
-
const result = await links();
|
|
333
|
-
return {
|
|
334
|
-
items: pickFields(result.data, REPOSITORY_SUMMARY_FIELDS),
|
|
335
|
-
truncated: result.nextCursor !== null,
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
async function buildsPart(client: TaigaClient, productId: string) {
|
|
340
|
-
const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data as Record<string, unknown>[];
|
|
341
|
-
const states = await initiativeStates(client, rows);
|
|
342
|
-
// The fields the outcome is made from are read, then the row is cut to what the bundle says: each run with its
|
|
343
|
-
// two-sentence outcome and every PR field was half the context, repeated by taiga_list_runs (PRO-2800).
|
|
344
|
-
return pickFields(rows, CONTEXT_BUILD_FIELDS)
|
|
345
|
-
.map(readableRow)
|
|
346
|
-
.map((row) => withInitiativeState(row, states))
|
|
347
|
-
.map((row) => pickFields([row], CONTEXT_RUN_SAID)[0]!);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/** A run in the bundle: which one, for what, how it ended, and its pull request. taiga_get_run has the rest. */
|
|
351
|
-
const CONTEXT_RUN_SAID = [
|
|
352
|
-
'id',
|
|
353
|
-
'runNumber',
|
|
354
|
-
'backlogItemId',
|
|
355
|
-
'targetTitle',
|
|
356
|
-
'disposition',
|
|
357
|
-
'outcome',
|
|
358
|
-
// Rare and short, and what the outcome sentence does not say.
|
|
359
|
-
'failedReasonMeans',
|
|
360
|
-
'hardeningNote',
|
|
361
|
-
'prUrl',
|
|
362
|
-
'createdAt',
|
|
363
|
-
] as const;
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* What waits on a person for this product, counted as taiga_list_action_required's summary counts it (PRO-2800):
|
|
367
|
-
* the reviewing agent called four tools to answer "how does it stand", and two later reviews found this count and
|
|
368
|
-
* the list's disagree (18 open here, 16 there), because this one counted stale reviews and put-off items as open.
|
|
369
|
-
*/
|
|
370
|
-
async function attentionPart(client: TaigaClient, productId: string) {
|
|
371
|
-
const result = await client.listInbox({ productId, limit: 100 });
|
|
372
|
-
const counts = await attentionCounts(client, result.data as Record<string, unknown>[]);
|
|
373
|
-
return {
|
|
374
|
-
...counts,
|
|
375
|
-
...(result.nextCursor
|
|
376
|
-
? { note: 'These count the first 100 items; taiga_list_action_required pages the rest.' }
|
|
377
|
-
: {}),
|
|
378
|
-
};
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/** The latest sweep and what it left open, as counts; taiga_list_findings lists them. */
|
|
382
|
-
async function maintenancePart(client: TaigaClient, productId: string) {
|
|
383
|
-
const summary = (await client.getMaintenanceSummary(productId)).data as Record<string, unknown>;
|
|
384
|
-
const last = summary['lastRun'] as Record<string, unknown> | null | undefined;
|
|
385
|
-
return {
|
|
386
|
-
openFindings: summary['openFindings'],
|
|
387
|
-
bySeverity: summary['severityCounts'],
|
|
388
|
-
lastSweep: last ? { status: last['status'], completedAt: last['completedAt'] } : null,
|
|
389
|
-
...(last ? {} : { note: 'Never swept: no findings is not an all-clear.' }),
|
|
390
|
-
};
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/** Where the product stands against the policies, as counts; taiga_get_policy_standing has each violation. */
|
|
394
|
-
async function standingPart(client: TaigaClient, productId: string) {
|
|
395
|
-
const standing = (await client.getPolicyStanding(productId)).data;
|
|
396
|
-
const summary = standingSummary(standing);
|
|
397
|
-
// No violations from an assessment that never ran, or failed, is no all-clear (PRO-2800).
|
|
398
|
-
const state = (standing.assessment as { state?: unknown } | null | undefined)?.state;
|
|
399
|
-
return {
|
|
400
|
-
openViolations: summary.violations,
|
|
401
|
-
bySeverity: summary.bySeverity,
|
|
402
|
-
controlsViolated: summary.controls,
|
|
403
|
-
...(state === 'never' || state === 'failed'
|
|
404
|
-
? {
|
|
405
|
-
note: `The assessment ${state === 'never' ? 'has never run' : 'failed'}: no violations here is not an all-clear.`,
|
|
406
|
-
}
|
|
407
|
-
: {}),
|
|
408
|
-
};
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
export async function productContext(
|
|
412
|
-
client: TaigaClient,
|
|
413
|
-
product: WithoutTenant<Product>,
|
|
414
|
-
options: { documents: DocumentDepth }
|
|
415
|
-
): Promise<CallToolResult> {
|
|
416
|
-
const productId = product.id;
|
|
417
|
-
const granted = (part: Part) => client.hasCapability(PARTS[part]);
|
|
418
|
-
const notGranted = (Object.keys(PARTS) as Part[]).filter((part) => !granted(part));
|
|
419
|
-
|
|
420
|
-
// One read of the links serves the repositories part and the setup readiness.
|
|
421
|
-
let linksRead: ReturnType<TaigaClient['listProductRepositories']> | undefined;
|
|
422
|
-
const links = () => (linksRead ??= client.listProductRepositories(productId));
|
|
423
|
-
const fetchers: Record<Part, () => Promise<unknown>> = {
|
|
424
|
-
factory: () => factoryPart(client, String(product.factoryId)),
|
|
425
|
-
documents: () => documentsPart(client, product, options.documents),
|
|
426
|
-
policies: () => policiesPart(client),
|
|
427
|
-
initiatives: () => backlogPart(client, productId),
|
|
428
|
-
repositories: () => repositoriesPart(links),
|
|
429
|
-
runs: () => buildsPart(client, productId),
|
|
430
|
-
actionRequired: () => attentionPart(client, productId),
|
|
431
|
-
maintenance: () => maintenancePart(client, productId),
|
|
432
|
-
policyStanding: () => standingPart(client, productId),
|
|
433
|
-
};
|
|
434
|
-
const parts = (Object.keys(fetchers) as Part[]).filter(granted);
|
|
435
|
-
const [settled, readiness] = await Promise.all([
|
|
436
|
-
Promise.allSettled(parts.map((part) => fetchers[part]())),
|
|
437
|
-
setupReadiness(client, product, links),
|
|
438
|
-
]);
|
|
439
|
-
|
|
440
|
-
const bundle: Record<string, unknown> = {
|
|
441
|
-
product: {
|
|
442
|
-
...pickFields([product], PRODUCT_SUMMARY_FIELDS)[0],
|
|
443
|
-
projectType: isImportedProduct(product) ? 'brownfield' : 'greenfield',
|
|
444
|
-
defaultOrigin: defaultOriginFor(product),
|
|
445
|
-
// A product in setup says what it still needs: nothing else in the bundle does.
|
|
446
|
-
...(readiness ? { readiness } : {}),
|
|
447
|
-
},
|
|
448
|
-
};
|
|
449
|
-
const unavailable: Record<string, string> = {};
|
|
450
|
-
parts.forEach((part, index) => {
|
|
451
|
-
const result = settled[index]!;
|
|
452
|
-
if (result.status === 'fulfilled') bundle[part] = result.value;
|
|
453
|
-
else if (isApiFailure(result.reason)) unavailable[part] = unavailableReason(result.reason);
|
|
454
|
-
else throw result.reason;
|
|
455
|
-
});
|
|
456
|
-
const result = {
|
|
457
|
-
...bundle,
|
|
458
|
-
notGranted: notGranted.map((part) => `${part} (needs ${PARTS[part]})`),
|
|
459
|
-
unavailable,
|
|
460
|
-
readNext:
|
|
461
|
-
'Read a policy with taiga_get_document (sections: [] for its outline, or a control id such as SEC-5.1), the ' +
|
|
462
|
-
"specification's or architecture's sections or another product document with taiga_get_product_document, " +
|
|
463
|
-
'an initiative with taiga_get_initiative (taiga_list_initiatives pages a truncated backlog) and a run ' +
|
|
464
|
-
'with taiga_get_run.',
|
|
465
|
-
};
|
|
466
|
-
// Its own size, so a model can judge what one more read would cost: counted
|
|
467
|
-
// with the field in place, until the count no longer changes its own length.
|
|
468
|
-
// Measured as sent: toolResult renames keys into the product's words.
|
|
469
|
-
let characters = JSON.stringify(inProductWords(result), null, 2).length;
|
|
470
|
-
for (let measured = -1; measured !== characters;) {
|
|
471
|
-
measured = characters;
|
|
472
|
-
characters = JSON.stringify(inProductWords({ ...result, characters: measured }), null, 2).length;
|
|
473
|
-
}
|
|
474
|
-
return toolResult({ ...result, characters });
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
export function registerContextTools(server: McpServer, client: TaigaClient) {
|
|
478
|
-
if (!client.hasCapability('products:read')) return;
|
|
479
|
-
|
|
480
|
-
server.registerTool(
|
|
481
|
-
'taiga_get_product_context',
|
|
482
|
-
{
|
|
483
|
-
title: 'Get product context',
|
|
484
|
-
description:
|
|
485
|
-
'Read where a product stands in one call; the first read for any product question. Name it by ID, slug ' +
|
|
486
|
-
'or git remote URL. Returns product (in setup, readiness and next move), factory with its instructions, ' +
|
|
487
|
-
'specification and architecture outlines (section ids, sizes), policies with section counts, open ' +
|
|
488
|
-
"initiatives with a count per board column (byColumn; taiga_list_initiatives status: 'all' has the done " +
|
|
489
|
-
'and canceled), repositories, the latest five runs (taiga_list_runs has more), and counts for Action Required, open findings and ' +
|
|
490
|
-
'policy violations. A part that failed is under unavailable, one the key cannot read under notGranted; ' +
|
|
491
|
-
`neither shows as empty. documents: budget returns whole sections up to about ${DOCUMENT_BUDGET / 1000}k ` +
|
|
492
|
-
'characters each, full returns all.',
|
|
493
|
-
inputSchema: z.object({
|
|
494
|
-
productId: productId.optional(),
|
|
495
|
-
slug: slug.optional(),
|
|
496
|
-
remoteUrl: remoteUrl.optional(),
|
|
497
|
-
documents: z
|
|
498
|
-
.enum(DOCUMENT_DEPTHS)
|
|
499
|
-
.optional()
|
|
500
|
-
.describe(
|
|
501
|
-
'The specification and architecture as outline (section ids and sizes, the default), budget (the whole sections that fit) or full'
|
|
502
|
-
),
|
|
503
|
-
}),
|
|
504
|
-
annotations: READ_ONLY,
|
|
505
|
-
},
|
|
506
|
-
(params) =>
|
|
507
|
-
runTool(async () => {
|
|
508
|
-
const resolved = await resolveProductRef(client, params, ['productId', 'slug', 'remoteUrl']);
|
|
509
|
-
if ('refusal' in resolved) return toolError(resolved.refusal.code, resolved.refusal.message);
|
|
510
|
-
return productContext(client, resolved.product, { documents: params.documents ?? 'outline' });
|
|
511
|
-
})
|
|
512
|
-
);
|
|
513
|
-
}
|