@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.
- package/dist/index.js +1128 -650
- package/package.json +1 -1
- package/src/client.test.ts +2 -2
- package/src/client.ts +201 -145
- package/src/modern.integration.test.ts +1 -1
- package/src/server.integration.test.ts +782 -243
- package/src/tools/autonomy.ts +3 -5
- package/src/tools/backlogs.ts +182 -72
- package/src/tools/builds.ts +142 -53
- package/src/tools/catalogue.test.ts +28 -22
- package/src/tools/context.ts +126 -56
- package/src/tools/documents.ts +12 -5
- package/src/tools/errors.ts +40 -2
- package/src/tools/hints.ts +37 -9
- package/src/tools/inbox.ts +149 -28
- package/src/tools/index.ts +15 -1
- package/src/tools/instructions.ts +32 -154
- package/src/tools/jobs.ts +6 -5
- package/src/tools/knowledge.ts +4 -6
- package/src/tools/maintenance.ts +53 -32
- package/src/tools/markdown.ts +13 -5
- package/src/tools/observe.ts +32 -14
- package/src/tools/product-documents.ts +4 -6
- package/src/tools/products.ts +32 -27
- package/src/tools/prompts.ts +14 -20
- package/src/tools/queue.ts +47 -26
- package/src/tools/readiness.ts +172 -0
- package/src/tools/repositories.ts +11 -9
- package/src/tools/resources.ts +16 -19
- package/src/tools/sections.test.ts +3 -3
- package/src/tools/sections.ts +57 -16
- package/src/tools/setup.ts +47 -51
- package/src/tools/summarize.test.ts +20 -0
- package/src/tools/summarize.ts +17 -13
- package/src/tools/wait.ts +39 -26
- package/taiga-ai-mcp-server-0.19.0-dev.451.tgz +0 -0
- package/tsconfig.json +1 -5
- package/taiga-ai-mcp-server-0.18.0-rc.445.tgz +0 -0
package/src/tools/prompts.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
'
|
|
112
|
+
'taiga_implement_initiative',
|
|
113
113
|
{
|
|
114
|
-
title: 'Implement
|
|
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
|
-
'
|
|
118
|
+
'taiga_list_initiatives gives the item ID.',
|
|
119
119
|
argsSchema: z.object({
|
|
120
|
-
|
|
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.
|
|
127
|
-
'No
|
|
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
|
|
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
|
|
134
|
-
const origin = defaultOriginFor(product
|
|
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
|
-
|
|
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
|
package/src/tools/queue.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
-
import {
|
|
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
|
|
19
|
+
/** How many entries the read names; past this an entry keeps its initiativeId only. */
|
|
11
20
|
const NAMED_ENTRIES = 25;
|
|
12
21
|
|
|
13
|
-
|
|
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 (
|
|
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
|
|
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 `
|
|
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 (
|
|
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:
|
|
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
|
|
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 =
|
|
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' ?
|
|
114
|
-
const lifecycle = item?.lifecycle
|
|
115
|
-
const latestBuild = lifecycle?.latestBuild
|
|
116
|
-
const row:
|
|
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 ? {
|
|
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
|
|
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
|
|
148
|
-
: 'An entry whose initiative could not be read carries
|
|
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
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
|
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 {
|
|
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
|
|
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.
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
270
|
+
).data;
|
|
269
271
|
return toolResult({
|
|
270
272
|
...pickFields([updated], LINK_FIELDS)[0],
|
|
271
273
|
baseBranchProtection: updated.baseBranchProtection ?? null,
|
package/src/tools/resources.ts
CHANGED
|
@@ -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
|
-
(
|
|
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
|
|
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
|
|
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), ...
|
|
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
|
|
166
|
-
const type = documentType(String(variables
|
|
167
|
-
const origin = requireOneOf('Origin', String(variables
|
|
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
|
|
185
|
-
const type = documentType(String(variables
|
|
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
|
|
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://
|
|
197
|
+
new ResourceTemplate('taiga://initiatives/{initiativeId}', { list: undefined }),
|
|
201
198
|
{
|
|
202
|
-
title: '
|
|
199
|
+
title: 'Initiative',
|
|
203
200
|
description:
|
|
204
|
-
'An initiative in full: description, acceptance criteria, technical context.
|
|
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
|
|
206
|
+
const initiativeId = requireId('Initiative ID', String(variables['initiativeId']));
|
|
210
207
|
const item = (
|
|
211
208
|
await served(
|
|
212
|
-
client.getBacklogItem(
|
|
213
|
-
'No
|
|
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')
|
|
54
|
-
expect(applySectionEdit(sections, 'list', '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')
|
|
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', () => {
|