@taiga-ai/mcp-server 0.11.0 → 0.12.0-dev.417

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.
@@ -47,12 +47,19 @@ export const SERVER_INSTRUCTIONS = [
47
47
  'every product the key can see, and active: true the ones executing now.',
48
48
  '',
49
49
  '**Operating the line** (taiga_create_backlog_item / taiga_queue_backlog_item / taiga_move_backlog_item /',
50
- 'taiga_resume_backlog_item / taiga_get_plan / taiga_approve_plan / taiga_reject_plan / taiga_stop_build /',
51
- 'taiga_resume_build): create an initiative, put it in the queue the coding agent works, move it on the',
52
- 'board, read and approve or reject its plan, stop or resume a run. Queueing, approving and resuming start',
53
- 'paid agent work that pushes commits and opens pull requests; stopping interrupts it. Tell the person what',
54
- 'you are about to start or stop and wait for their yes before calling these; a client that prompts on',
55
- 'destructive tools does that for you. A refusal (CONFLICT) carries the API reason: say it as it is.',
50
+ 'taiga_resume_backlog_item / taiga_set_auto_build / taiga_get_plan / taiga_approve_plan / taiga_reject_plan /',
51
+ 'taiga_stop_build / taiga_resume_build): create an initiative, put it in the queue the coding agent works,',
52
+ 'move it on the board, decide whether its plan waits for a review (autoBuild: false) or is built at once, read',
53
+ 'and approve or reject its plan, stop or resume a run. Queueing, approving and resuming start paid agent work',
54
+ 'that pushes commits and opens pull requests; stopping interrupts it. Tell the person what you are about to',
55
+ 'start or stop and wait for their yes before calling these; a client that prompts on destructive tools does',
56
+ 'that for you. A refusal (CONFLICT) carries the API reason: say it as it is.',
57
+ '',
58
+ '**Watching the line** (taiga_get_queue / taiga_wait_for_backlog_item / taiga_wait_for_build): the queue',
59
+ 'read says what the line is waiting on (a plan to review, a pull request to merge, a halt and what lifts',
60
+ 'it). Planning takes minutes and a build up to an hour: wait with the wait tools rather than reading the',
61
+ 'same item again and again; each returns when the point is reached, when only a person can move it on,',
62
+ 'or at its timeout (call again to keep waiting).',
56
63
  '',
57
64
  '**Sections.** Policies and instructions are maps of sections. Each section carries its text and two',
58
65
  'provenance fields you can read past: `source` says where the text originally came from (template:',
@@ -64,7 +71,8 @@ export const SERVER_INSTRUCTIONS = [
64
71
  '',
65
72
  'Workflow: start with taiga_get_product_context, naming the product by its git remote URL inside a checkout',
66
73
  '(git remote get-url origin), else by slug or ID from taiga_list_products. It returns the product, the',
67
- 'factory instructions, the specification and architecture, every policy as its section ids, the open backlog',
74
+ 'factory instructions, the specification and architecture as the whole sections that fit (the rest named for',
75
+ 'taiga_get_product_document to read by section), every policy as its section ids, the open backlog',
68
76
  'items, the repositories and the latest runs in one call, and names any part it could not read. Then read',
69
77
  'what you need in full: a policy with taiga_get_document, a backlog item with taiga_get_backlog_item.',
70
78
  'Without it, use taiga_list_factories and taiga_list_products, then read the factory instructions,',
@@ -17,7 +17,7 @@ import {
17
17
  } from './catalogue.js';
18
18
  import { apiFailureCode, apiFailureText, isMissOrRefusal, runTool, toolError, toolResult } from './errors.js';
19
19
  import { parseRemote } from './remote.js';
20
- import { stripProvenance } from './sections.js';
20
+ import { pickSections, stripProvenance } from './sections.js';
21
21
  import { page, pickFields } from './summarize.js';
22
22
 
23
23
  /** What a product row needs to say for the model to pick one and read it in full. */
@@ -424,6 +424,8 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
424
424
  'Read the plan to learn what to build: specification (requirements), architecture (design), ' +
425
425
  'threat_model (security risks), technology_decisions. Read reality, when the product has it, to ' +
426
426
  "learn what the code actually does before changing it. Omit origin for the product's own timeline. " +
427
+ 'A large document runs to tens of thousands of characters: name the sections you need (the ids ' +
428
+ 'taiga_get_product_context lists) and the read carries only those, listing the rest with their sizes. ' +
427
429
  'A read that finds nothing says which documents and timelines the product does have.',
428
430
  inputSchema: {
429
431
  productId,
@@ -431,6 +433,12 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
431
433
  .enum(PRODUCT_DOCUMENT_TYPES)
432
434
  .describe('Document type -- taiga_list_product_documents shows which ones the product has'),
433
435
  origin: originInput,
436
+ sections: z
437
+ .array(z.string().min(1).max(200))
438
+ .min(1)
439
+ .max(50)
440
+ .optional()
441
+ .describe('Only these sections, by id; omit for the whole document'),
434
442
  },
435
443
  annotations: READ_ONLY,
436
444
  },
@@ -443,7 +451,24 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
443
451
  const origin = params.origin ?? defaultOriginFor(product, params.type);
444
452
  try {
445
453
  const result = await client.getProductDocument(params.productId, params.type, origin);
446
- return toolResult(stripProvenance(result.data as Record<string, unknown>));
454
+ const doc = stripProvenance(result.data as Record<string, unknown>);
455
+ if (!params.sections) return toolResult(doc);
456
+ const cut = pickSections(doc, new Set(params.sections));
457
+ if (!cut) {
458
+ return toolError(
459
+ 'VALIDATION_FAILED',
460
+ `The ${origin} ${params.type} is not made of sections; read it without sections.`
461
+ );
462
+ }
463
+ const all = Object.keys((doc.data as { sections: Record<string, unknown> }).sections);
464
+ const unknown = params.sections.filter((id) => !all.includes(id));
465
+ if (unknown.length > 0) {
466
+ return toolError(
467
+ 'NOT_FOUND',
468
+ `The ${origin} ${params.type} has no section ${unknown.join(', ')}; its sections are ${all.join(', ')}.`
469
+ );
470
+ }
471
+ return toolResult({ ...cut.doc, omittedSections: cut.omittedSections });
447
472
  } catch (error) {
448
473
  if (!(error instanceof TaigaApiError) || error.status !== 404) throw error;
449
474
  // A product the key cannot see is a product miss, not a document
@@ -93,7 +93,7 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
93
93
  'taiga_list_products lists the slugs.'
94
94
  );
95
95
  if ('refusal' in resolved) refuse(resolved.refusal);
96
- const result = await productContext(client, resolved.product, { fullDocuments: true });
96
+ const result = await productContext(client, resolved.product, { documents: 'full' });
97
97
  const bundle = (result.content[0] as { text?: string } | undefined)?.text ?? '{}';
98
98
  return message(
99
99
  [
@@ -263,8 +263,11 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
263
263
 
264
264
  /** A part the key's capabilities do not cover; named in its place rather than fetched. */
265
265
  class NotGranted extends Error {
266
- constructor(readonly capability: string) {
266
+ readonly capability: string;
267
+
268
+ constructor(capability: string) {
267
269
  super(`needs ${capability}`);
270
+ this.capability = capability;
268
271
  }
269
272
  line(what: string): string {
270
273
  return `(${what} need ${this.capability}, which this key lacks.)`;
@@ -0,0 +1,168 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { HALT_CAUSES, canResumeHalt, haltClearsItself, renderHalt, type Halt, type HaltCause } from '@taiga/types';
3
+ import { TaigaApiError, type TaigaClient } from '../client.js';
4
+ import { READ_ONLY } from './annotations.js';
5
+ import { isNetworkFailure, runTool, toolResult } from './errors.js';
6
+ import { productId } from './inputs.js';
7
+ import { criteriaCounts } from './summarize.js';
8
+
9
+ /** How many entries the read names; past this an entry keeps its backlogItemId only. */
10
+ const NAMED_ENTRIES = 25;
11
+
12
+ type Row = Record<string, unknown>;
13
+
14
+ const MOVE_ASIDE =
15
+ 'or move it down (taiga_queue_backlog_item with afterId) or off the line (taiga_move_backlog_item) to let the rest pass.';
16
+
17
+ /**
18
+ * What lifts a halt, in the tools that do it. Read from the halt registry in
19
+ * @taiga/types (how each cause clears, and whether a person may lift it), so
20
+ * the advice cannot drift from what the API accepts; plan review is the one
21
+ * cause a tool answers directly.
22
+ */
23
+ function haltNextStep(cause: string | null): string {
24
+ if (cause === 'awaiting_plan_review') {
25
+ return 'Read the plan with taiga_get_plan, then approve it (taiga_approve_plan) or send it back (taiga_reject_plan).';
26
+ }
27
+ const spec = cause ? (HALT_CAUSES as Record<string, { clearedWhen: string } | undefined>)[cause] : undefined;
28
+ if (!cause || !spec) return `Read the initiative with taiga_get_backlog_item to see why, ${MOVE_ASIDE}`;
29
+ const known = cause as HaltCause;
30
+ if (canResumeHalt(known)) return `taiga_resume_backlog_item lifts it, ${MOVE_ASIDE}`;
31
+ if (haltClearsItself(known)) return `It clears when ${spec.clearedWhen}; ${MOVE_ASIDE}`;
32
+ return `Only taking it off the line clears it (taiga_move_backlog_item): ${spec.clearedWhen}.`;
33
+ }
34
+
35
+ /**
36
+ * The entry the line waits on, by the queue's two kinds of blocker
37
+ * (@taiga/types): an active entry holds every other one wherever it sits, so
38
+ * it answers first; without one, the first halted entry holds the entries
39
+ * behind it. -1 when neither is there.
40
+ */
41
+ function blockingIndex<T>(entries: T[], stateOf: (entry: T) => unknown): number {
42
+ const active = entries.findIndex((entry) => stateOf(entry) === 'active');
43
+ return active >= 0 ? active : entries.findIndex((entry) => stateOf(entry) === 'halted');
44
+ }
45
+
46
+ /** The one sentence a person needs: what the line is doing, or what it is waiting on. */
47
+ function waitingOn(rows: Row[]): string | null {
48
+ const at = blockingIndex(rows, (row) => row.entryState);
49
+ if (at < 0) {
50
+ return rows.length > 0
51
+ ? 'Nothing in the queue is running; the first queued initiative starts when the line is free.'
52
+ : null;
53
+ }
54
+ const head = rows[at]!;
55
+ const name = typeof head.title === 'string' ? `"${head.title}"` : `Initiative ${String(head.backlogItemId)}`;
56
+ if (head.entryState === 'halted') {
57
+ const halt = head.halt as { reason: string; nextStep: string };
58
+ const ahead = at > 0 ? ' Entries ahead of it are not held by it.' : '';
59
+ return `${name} halted the queue, holding the entries behind it: ${halt.reason} ${halt.nextStep}${ahead}`;
60
+ }
61
+ const lifecycle = head.lifecycle as { turn?: unknown; stage?: unknown; cta?: unknown } | undefined;
62
+ const held = 'every other queued initiative waits until its pull request merges.';
63
+ if (!lifecycle) return `${name} is the active entry; ${held}`;
64
+ const pr = typeof head.prUrl === 'string' ? ` (${head.prUrl})` : '';
65
+ // An active entry with a landed plan is past its review gate (a closed
66
+ // gate halts it instead): it waits for the Build slot, not for anyone.
67
+ if (lifecycle.cta === 'start_build') return `${name} has its plan and waits for the Build slot; ${held}`;
68
+ if (lifecycle.turn === 'you') {
69
+ return `${name} is waiting on a person (${String(lifecycle.cta ?? lifecycle.stage)})${pr}; ${held}`;
70
+ }
71
+ return `${name} is ${String(lifecycle.stage)}${pr}; ${held}`;
72
+ }
73
+
74
+ /**
75
+ * The product's queue as a person reads it: each live entry in order with its
76
+ * initiative's title and where that initiative is, a halted entry with why and
77
+ * what lifts it, and one sentence saying what the line is waiting on. The
78
+ * route carries item ids only; each is named by one item read (when the key
79
+ * may read the backlog), and a read the API fails leaves that entry unnamed
80
+ * rather than failing the queue.
81
+ */
82
+ export async function queueView(
83
+ client: TaigaClient,
84
+ product: string,
85
+ options: { name: 'all' | 'blocking' } = { name: 'all' }
86
+ ) {
87
+ const entries = ((await client.listQueue(product)).data as Row[]).filter((entry) => entry.state !== 'settled');
88
+ const canName = client.hasCapability('backlog:read');
89
+ // 'blocking' names only the entry the line waits on: a caller after one
90
+ // position and waitingOn need no more reads than that.
91
+ const blocking = blockingIndex(entries, (entry) => entry.state);
92
+ const toName = !canName
93
+ ? []
94
+ : options.name === 'all'
95
+ ? entries.slice(0, NAMED_ENTRIES).map((_, index) => index)
96
+ : blocking >= 0
97
+ ? [blocking]
98
+ : [];
99
+ const settled = await Promise.allSettled(
100
+ toName.map((index) => client.getBacklogItem(String(entries[index]!.backlogItemId)))
101
+ );
102
+ const reads = new Map(toName.map((index, at) => [index, settled[at]!]));
103
+ let unnamed = options.name === 'all' ? entries.length - toName.length : 0;
104
+ const rows = entries.map((entry, index) => {
105
+ const read = reads.get(index);
106
+ if (read?.status === 'rejected') {
107
+ // Only the API failing leaves an entry unnamed; a bug here surfaces.
108
+ if (!(read.reason instanceof TaigaApiError || isNetworkFailure(read.reason))) throw read.reason;
109
+ unnamed += 1;
110
+ }
111
+ const item = read?.status === 'fulfilled' ? (read.value.data as Row) : null;
112
+ const lifecycle = item?.lifecycle as Row | undefined;
113
+ const latestBuild = lifecycle?.latestBuild as Row | null | undefined;
114
+ const row: Row = { position: index + 1, backlogItemId: entry.backlogItemId, entryState: entry.state };
115
+ if (item) {
116
+ Object.assign(row, {
117
+ title: item.title,
118
+ state: item.state,
119
+ planningStatus: item.planningStatus,
120
+ needsHumanCause: item.needsHumanCause,
121
+ criteria: criteriaCounts(item),
122
+ lifecycle: lifecycle ? { turn: lifecycle.turn, stage: lifecycle.stage, cta: lifecycle.cta } : null,
123
+ ...(latestBuild ? { buildId: latestBuild.id, prUrl: latestBuild.prUrl ?? null } : {}),
124
+ });
125
+ }
126
+ if (entry.state === 'halted') {
127
+ const cause = typeof entry.haltCause === 'string' ? entry.haltCause : null;
128
+ // The registry renders the sentence; the stored prose answers for a
129
+ // cause this build does not know yet.
130
+ const rendered = cause ? renderHalt({ cause, params: entry.haltParams ?? {} } as Halt) : '';
131
+ row.halt = {
132
+ cause,
133
+ reason: rendered || (typeof entry.haltReason === 'string' ? entry.haltReason : 'The queue stopped here.'),
134
+ nextStep: haltNextStep(cause),
135
+ };
136
+ }
137
+ return row;
138
+ });
139
+ const note =
140
+ unnamed === 0
141
+ ? null
142
+ : !canName
143
+ ? 'Entries carry backlogItemId only: naming them needs backlog:read, which this key lacks.'
144
+ : entries.length > NAMED_ENTRIES
145
+ ? `Only the first ${NAMED_ENTRIES} entries are named; the rest carry backlogItemId only.`
146
+ : 'An entry whose initiative could not be read carries backlogItemId only; taiga_get_backlog_item reads it.';
147
+ return { entries: rows, waitingOn: waitingOn(rows), ...(note ? { note } : {}) };
148
+ }
149
+
150
+ export function registerQueueTools(server: McpServer, client: TaigaClient) {
151
+ if (!client.hasCapability('products:read')) return;
152
+
153
+ server.registerTool(
154
+ 'taiga_get_queue',
155
+ {
156
+ title: 'Get the product queue',
157
+ description:
158
+ "Read a product's queue, the line the coding agent works one initiative at a time: each entry in order " +
159
+ 'with its title, where it is (planning, building, in review) and whose turn it is, a halted entry with why ' +
160
+ 'it stopped and what lifts it, and waitingOn, one sentence saying what the line is waiting for. The ' +
161
+ "line advances only when the active entry's pull request merges, so an entry waiting on a review holds " +
162
+ 'everything behind it. Use this when a queued initiative is not moving.',
163
+ inputSchema: { productId },
164
+ annotations: READ_ONLY,
165
+ },
166
+ (params) => runTool(async () => toolResult(await queueView(client, params.productId)))
167
+ );
168
+ }
@@ -1,5 +1,13 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { applySectionEdit, instructionsText, sectionContent, sectionsOf, stripProvenance } from './sections.js';
2
+ import {
3
+ applySectionEdit,
4
+ budgetSections,
5
+ instructionsText,
6
+ pickSections,
7
+ sectionContent,
8
+ sectionsOf,
9
+ stripProvenance,
10
+ } from './sections.js';
3
11
 
4
12
  describe('sectionsOf', () => {
5
13
  it('returns the sections map of a section-shaped document', () => {
@@ -106,3 +114,30 @@ describe('stripProvenance', () => {
106
114
  expect(stripProvenance(doc)).toEqual(doc);
107
115
  });
108
116
  });
117
+
118
+ describe('budgetSections and pickSections', () => {
119
+ // Parsed, as the API's JSON arrives: an own "__proto__" key, not a prototype.
120
+ const doc = JSON.parse(
121
+ '{"data":{"sections":{"__proto__":{"content":"aaaa"},"constructor":{"content":"bbbb"},"toString":{"content":"cccc"}}}}'
122
+ ) as Record<string, unknown>;
123
+
124
+ it('keeps a section named like an Object.prototype member as an ordinary section', () => {
125
+ const cut = budgetSections(doc, 8)!;
126
+ const kept = (cut.doc.data as { sections: Record<string, unknown> }).sections;
127
+
128
+ expect(Object.keys(kept)).toEqual(['__proto__', 'constructor']);
129
+ expect(Object.getPrototypeOf(kept)).toBe(Object.prototype);
130
+ expect(cut.omittedSections).toEqual([{ id: 'toString', characters: 4 }]);
131
+ });
132
+
133
+ it('lists every section not picked, including one named like a prototype member', () => {
134
+ const cut = pickSections(doc, new Set(['toString']))!;
135
+
136
+ expect(cut.omittedSections.map((s) => s.id)).toEqual(['__proto__', 'constructor']);
137
+ });
138
+
139
+ it('answers null for a document that is not made of sections', () => {
140
+ expect(budgetSections({ data: { legacy: 'x' } }, 10)).toBeNull();
141
+ expect(pickSections({ data: { legacy: 'x' } }, new Set(['a']))).toBeNull();
142
+ });
143
+ });
@@ -75,3 +75,53 @@ export function instructionsText(sections: Sections): string | null {
75
75
  const text = parts.join('\n\n').trim();
76
76
  return text || null;
77
77
  }
78
+
79
+ /** A document cut to some of its sections, and the ones left out with their sizes. */
80
+ export interface SectionCut {
81
+ doc: Record<string, unknown>;
82
+ omittedSections: { id: string; characters: number }[];
83
+ }
84
+
85
+ function cutTo(doc: Record<string, unknown>, sections: Sections, kept: Sections): SectionCut {
86
+ const omittedSections = Object.keys(sections)
87
+ .filter((id) => !Object.hasOwn(kept, id))
88
+ .map((id) => ({ id, characters: sectionContent(sections, id)?.length ?? 0 }));
89
+ return { doc: { ...doc, data: { ...(doc.data as Record<string, unknown>), sections: kept } }, omittedSections };
90
+ }
91
+
92
+ /**
93
+ * The named sections of a section-shaped document, in the order the API
94
+ * returns them (a jsonb column: shorter ids first, then by bytes, not the
95
+ * order a person wrote them);
96
+ * every other section is listed with its size so the reader knows what else
97
+ * there is. Null when the document is not made of sections.
98
+ */
99
+ export function pickSections(doc: Record<string, unknown>, ids: ReadonlySet<string>): SectionCut | null {
100
+ const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
101
+ if (!sections) return null;
102
+ return cutTo(doc, sections, Object.fromEntries(Object.entries(sections).filter(([id]) => ids.has(id))));
103
+ }
104
+
105
+ /**
106
+ * As many whole sections as fit `budget` characters of text, the rest listed
107
+ * with their sizes so the reader can ask for them by id. Whole, never cut: a
108
+ * section's text is one argument, and half of it reads as all of it. Not "the
109
+ * opening": the document's own order is not in the data (a jsonb column
110
+ * hands back shorter ids first), so a section that does not fit is skipped
111
+ * and a later, smaller one may still be carried. Null when the document is
112
+ * not made of sections.
113
+ */
114
+ export function budgetSections(doc: Record<string, unknown>, budget: number): SectionCut | null {
115
+ const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
116
+ if (!sections) return null;
117
+ // Entries, not assignment: a section id is text, and `kept["__proto__"] = x` would set a prototype.
118
+ const kept: [string, unknown][] = [];
119
+ let used = 0;
120
+ for (const id of Object.keys(sections)) {
121
+ const length = sectionContent(sections, id)?.length ?? 0;
122
+ if (used + length > budget) continue;
123
+ kept.push([id, sections[id]]);
124
+ used += length;
125
+ }
126
+ return cutTo(doc, sections, Object.fromEntries(kept));
127
+ }
@@ -20,3 +20,26 @@ export function pickFields<T extends Record<string, unknown>>(
20
20
  export function page<T>(data: T[], nextCursor: string | null | undefined): { data: T[]; nextCursor: string | null } {
21
21
  return { data, nextCursor: nextCursor ?? null };
22
22
  }
23
+
24
+ /**
25
+ * How many of an initiative's acceptance criteria are met: one pair a row can
26
+ * carry, so "done with two unmet" shows in a list without reading the item.
27
+ * Null while it has none (an unplanned initiative).
28
+ */
29
+ export function criteriaCounts(item: Record<string, unknown>): { met: number; total: number } | null {
30
+ const criteria = item.acceptanceCriteria;
31
+ if (!Array.isArray(criteria) || criteria.length === 0) return null;
32
+ const met = criteria.filter((c) => typeof c === 'object' && c !== null && (c as { met?: unknown }).met === true);
33
+ return { met: met.length, total: criteria.length };
34
+ }
35
+
36
+ /**
37
+ * Initiative rows cut to `fields`, each with its criteria counts. A row that
38
+ * does not carry `acceptanceCriteria` at all gets no count rather than a
39
+ * null that would read as "has none".
40
+ */
41
+ export function itemRows(items: Record<string, unknown>[], fields: readonly string[]): Record<string, unknown>[] {
42
+ return pickFields(items, fields).map((row, index) =>
43
+ 'acceptanceCriteria' in items[index]! ? { ...row, criteria: criteriaCounts(items[index]!) } : row
44
+ );
45
+ }