@taiga-ai/mcp-server 0.7.0-dev.396 → 0.9.0-rc.403
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 +747 -35
- package/package.json +1 -1
- package/src/client.ts +20 -4
- package/src/server.integration.test.ts +891 -9
- package/src/tools/backlogs.ts +8 -8
- package/src/tools/builds.ts +8 -8
- package/src/tools/catalogue.test.ts +31 -5
- package/src/tools/catalogue.ts +1 -1
- package/src/tools/context.ts +278 -0
- package/src/tools/documents.ts +7 -6
- package/src/tools/errors.ts +50 -3
- package/src/tools/index.ts +7 -1
- package/src/tools/inputs.ts +32 -0
- package/src/tools/instructions.ts +10 -2
- package/src/tools/markdown.test.ts +83 -0
- package/src/tools/markdown.ts +82 -0
- package/src/tools/products.ts +91 -41
- package/src/tools/prompts.ts +264 -0
- package/src/tools/resources.ts +216 -0
- package/src/tools/sections.test.ts +18 -1
- package/src/tools/sections.ts +17 -0
- package/src/tools/summarize.ts +5 -0
- package/taiga-ai-mcp-server-0.9.0-rc.403.tgz +0 -0
- package/taiga-ai-mcp-server-0.7.0-dev.396.tgz +0 -0
package/src/tools/backlogs.ts
CHANGED
|
@@ -2,11 +2,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { TaigaClient } from '../client.js';
|
|
4
4
|
import { READ_ONLY } from './annotations.js';
|
|
5
|
+
import { pagination, productId } from './inputs.js';
|
|
5
6
|
import { runTool, toolResult } from './errors.js';
|
|
6
|
-
import { pickFields } from './summarize.js';
|
|
7
|
+
import { page, pickFields } from './summarize.js';
|
|
7
8
|
|
|
8
9
|
/** What a list row needs to say for the model to pick an item to read in full. */
|
|
9
|
-
const BACKLOG_SUMMARY_FIELDS = [
|
|
10
|
+
export const BACKLOG_SUMMARY_FIELDS = [
|
|
10
11
|
'id',
|
|
11
12
|
'title',
|
|
12
13
|
'category',
|
|
@@ -35,10 +36,7 @@ export function registerBacklogTools(server: McpServer, client: TaigaClient) {
|
|
|
35
36
|
'read its acceptance criteria and technical context with taiga_get_backlog_item. ' +
|
|
36
37
|
'Archived items are left out unless asked for.',
|
|
37
38
|
inputSchema: {
|
|
38
|
-
productId
|
|
39
|
-
.string()
|
|
40
|
-
.uuid()
|
|
41
|
-
.describe('Product ID -- get from taiga_resolve_product (from a git remote) or taiga_list_products'),
|
|
39
|
+
productId,
|
|
42
40
|
status: z
|
|
43
41
|
.enum(['backlog', 'in_progress', 'done', 'canceled'])
|
|
44
42
|
.optional()
|
|
@@ -49,14 +47,16 @@ export function registerBacklogTools(server: McpServer, client: TaigaClient) {
|
|
|
49
47
|
.describe('Only items whose plan is in this state'),
|
|
50
48
|
category: z.enum(['feature', 'bug', 'tech_debt', 'improvement']).optional().describe('Only this category'),
|
|
51
49
|
includeArchived: z.boolean().optional().describe('Include archived items (default false)'),
|
|
52
|
-
|
|
50
|
+
...pagination,
|
|
53
51
|
},
|
|
54
52
|
annotations: READ_ONLY,
|
|
55
53
|
},
|
|
56
54
|
(params) =>
|
|
57
55
|
runTool(async () => {
|
|
58
56
|
const result = await client.listBacklogItems(params.productId, params);
|
|
59
|
-
return toolResult(
|
|
57
|
+
return toolResult(
|
|
58
|
+
page(pickFields(result.data as Record<string, unknown>[], BACKLOG_SUMMARY_FIELDS), result.nextCursor)
|
|
59
|
+
);
|
|
60
60
|
})
|
|
61
61
|
);
|
|
62
62
|
|
package/src/tools/builds.ts
CHANGED
|
@@ -2,11 +2,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { TaigaClient } from '../client.js';
|
|
4
4
|
import { READ_ONLY } from './annotations.js';
|
|
5
|
+
import { pagination, productId } from './inputs.js';
|
|
5
6
|
import { runTool, toolResult } from './errors.js';
|
|
6
|
-
import { pickFields } from './summarize.js';
|
|
7
|
+
import { page, pickFields } from './summarize.js';
|
|
7
8
|
|
|
8
9
|
/** What a list row needs to say for the model to pick a run to read in full. */
|
|
9
|
-
const BUILD_SUMMARY_FIELDS = [
|
|
10
|
+
export const BUILD_SUMMARY_FIELDS = [
|
|
10
11
|
'id',
|
|
11
12
|
'runNumber',
|
|
12
13
|
'backlogItemId',
|
|
@@ -42,23 +43,22 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
42
43
|
'progress. Use this to see what Taiga already built or is building, then read one run with ' +
|
|
43
44
|
'taiga_get_build. Filter by backlog item to follow a single initiative.',
|
|
44
45
|
inputSchema: {
|
|
45
|
-
productId
|
|
46
|
-
.string()
|
|
47
|
-
.uuid()
|
|
48
|
-
.describe('Product ID -- get from taiga_resolve_product (from a git remote) or taiga_list_products'),
|
|
46
|
+
productId,
|
|
49
47
|
backlogItemId: z
|
|
50
48
|
.string()
|
|
51
49
|
.uuid()
|
|
52
50
|
.optional()
|
|
53
51
|
.describe("Only this backlog item's runs -- get from taiga_list_backlog_items"),
|
|
54
|
-
|
|
52
|
+
...pagination,
|
|
55
53
|
},
|
|
56
54
|
annotations: READ_ONLY,
|
|
57
55
|
},
|
|
58
56
|
(params) =>
|
|
59
57
|
runTool(async () => {
|
|
60
58
|
const result = await client.listBuildRuns(params.productId, params);
|
|
61
|
-
return toolResult(
|
|
59
|
+
return toolResult(
|
|
60
|
+
page(pickFields(result.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS), result.nextCursor)
|
|
61
|
+
);
|
|
62
62
|
})
|
|
63
63
|
);
|
|
64
64
|
|
|
@@ -30,7 +30,7 @@ const ALL_CAPABILITIES = [
|
|
|
30
30
|
'backlog:read',
|
|
31
31
|
];
|
|
32
32
|
|
|
33
|
-
async function
|
|
33
|
+
async function connected() {
|
|
34
34
|
vi.stubGlobal(
|
|
35
35
|
'fetch',
|
|
36
36
|
vi.fn().mockResolvedValue({
|
|
@@ -49,7 +49,22 @@ async function allTools() {
|
|
|
49
49
|
const client = new Client({ name: 'c', version: '0' });
|
|
50
50
|
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
51
51
|
await Promise.all([server.connect(st), client.connect(ct)]);
|
|
52
|
-
return
|
|
52
|
+
return client;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function allTools() {
|
|
56
|
+
return (await (await connected()).listTools()).tools;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Every description a client sees: tools, resource templates, prompts and their arguments. */
|
|
60
|
+
async function everyDescription(): Promise<string> {
|
|
61
|
+
const client = await connected();
|
|
62
|
+
const [tools, templates, prompts] = await Promise.all([
|
|
63
|
+
client.listTools(),
|
|
64
|
+
client.listResourceTemplates(),
|
|
65
|
+
client.listPrompts(),
|
|
66
|
+
]);
|
|
67
|
+
return JSON.stringify([tools.tools, templates.resourceTemplates, prompts.prompts]);
|
|
53
68
|
}
|
|
54
69
|
|
|
55
70
|
/** Every `policy_*` token in a text. */
|
|
@@ -58,15 +73,26 @@ const policyTokens = (text: string) => [...new Set(text.match(/\bpolicy_[a-z_]+\
|
|
|
58
73
|
describe('tool descriptions against the catalogue', () => {
|
|
59
74
|
beforeEach(() => vi.restoreAllMocks());
|
|
60
75
|
|
|
61
|
-
it('names no policy type the catalogue lacks, in any description or
|
|
62
|
-
const
|
|
63
|
-
const text = JSON.stringify(tools) + SERVER_INSTRUCTIONS;
|
|
76
|
+
it('names no policy type the catalogue lacks, in any description, schema, template or prompt', async () => {
|
|
77
|
+
const text = (await everyDescription()) + SERVER_INSTRUCTIONS;
|
|
64
78
|
|
|
65
79
|
for (const token of policyTokens(text)) {
|
|
66
80
|
expect(TENANT_DOCUMENT_TYPES as readonly string[], token).toContain(token);
|
|
67
81
|
}
|
|
68
82
|
});
|
|
69
83
|
|
|
84
|
+
it('names no tool that is not registered, in any description, template, prompt or the instructions', async () => {
|
|
85
|
+
const client = await connected();
|
|
86
|
+
const registered = new Set((await client.listTools()).tools.map((t) => t.name));
|
|
87
|
+
const text = (await everyDescription()) + SERVER_INSTRUCTIONS;
|
|
88
|
+
|
|
89
|
+
for (const name of new Set(text.match(/\btaiga_[a-z_]+\b/g) ?? [])) {
|
|
90
|
+
// Prompts carry the same prefix; they are registered too.
|
|
91
|
+
const prompts = new Set((await client.listPrompts()).prompts.map((p) => p.name));
|
|
92
|
+
expect(registered.has(name) || prompts.has(name), name).toBe(true);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
70
96
|
it('names every policy where the company documents are explained', async () => {
|
|
71
97
|
const tools = await allTools();
|
|
72
98
|
const list = tools.find((t) => t.name === 'taiga_list_documents')!;
|
package/src/tools/catalogue.ts
CHANGED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { isSettledOrArchived } from '@taiga/types';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
6
|
+
import { READ_ONLY } from './annotations.js';
|
|
7
|
+
import { BACKLOG_SUMMARY_FIELDS } from './backlogs.js';
|
|
8
|
+
import { BUILD_SUMMARY_FIELDS } from './builds.js';
|
|
9
|
+
import { POLICY_TYPES, defaultOriginFor, isImportedProduct, type ProductSettingsView } from './catalogue.js';
|
|
10
|
+
import { apiFailureText, isNetworkFailure, networkFailureDetail, runTool, toolError, toolResult } from './errors.js';
|
|
11
|
+
import { productId, remoteUrl, slug } from './inputs.js';
|
|
12
|
+
import { PRODUCT_SUMMARY_FIELDS, resolveProductRef } from './products.js';
|
|
13
|
+
import { instructionsText, sectionsOf, stripProvenance } from './sections.js';
|
|
14
|
+
import { pickFields } from './summarize.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One call at the start of a coding session: everything the model would
|
|
18
|
+
* otherwise collect in eight, fanned out here. Each part stands on its own
|
|
19
|
+
* (Promise.allSettled): a part the API refuses or fails is named under
|
|
20
|
+
* `unavailable` with what the API said, a part the key's capabilities do not
|
|
21
|
+
* cover is named under `notGranted`, and neither is ever shown as empty. A
|
|
22
|
+
* failure that is not the API's (a bug in this code) is not downgraded; it
|
|
23
|
+
* fails the call so it surfaces.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The two documents a coding session starts from; the rest are a read away. */
|
|
27
|
+
const CONTEXT_DOCUMENT_TYPES = ['specification', 'architecture'] as const;
|
|
28
|
+
|
|
29
|
+
const REPOSITORY_SUMMARY_FIELDS = [
|
|
30
|
+
'id',
|
|
31
|
+
'provider',
|
|
32
|
+
'repositoryName',
|
|
33
|
+
'repositoryUrl',
|
|
34
|
+
'effectiveBranch',
|
|
35
|
+
'isManaged',
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
/** How many of the latest runs to carry; taiga_list_builds pages the rest. */
|
|
39
|
+
const BUILDS_IN_CONTEXT = 10;
|
|
40
|
+
|
|
41
|
+
/** Pages of 100 the backlog part follows before it stops and says so. */
|
|
42
|
+
const BACKLOG_PAGE_LIMIT = 10;
|
|
43
|
+
|
|
44
|
+
const PARTS = {
|
|
45
|
+
factory: 'factories:read',
|
|
46
|
+
documents: 'products:read',
|
|
47
|
+
policies: 'tenant:documents:read',
|
|
48
|
+
backlog: 'backlog:read',
|
|
49
|
+
repositories: 'products:read',
|
|
50
|
+
builds: 'products:read',
|
|
51
|
+
} as const;
|
|
52
|
+
type Part = keyof typeof PARTS;
|
|
53
|
+
|
|
54
|
+
/** What the API said when a part failed, in the words the bundle reports. */
|
|
55
|
+
function unavailableReason(reason: unknown): string {
|
|
56
|
+
if (reason instanceof TaigaApiError) return apiFailureText(reason);
|
|
57
|
+
return `the API could not be reached: ${networkFailureDetail(reason as TypeError)}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether a failed part is the API's doing; anything else is ours and propagates. */
|
|
61
|
+
function isApiFailure(reason: unknown): boolean {
|
|
62
|
+
return reason instanceof TaigaApiError || isNetworkFailure(reason);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isNotFound(reason: unknown): boolean {
|
|
66
|
+
return reason instanceof TaigaApiError && reason.status === 404;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The factory's standing guidance in full; null, with a note, when none is published or it is not section-shaped. */
|
|
70
|
+
async function factoryPart(client: TaigaClient, factoryId: string) {
|
|
71
|
+
const factory = (await client.getFactory(factoryId)).data as Record<string, unknown>;
|
|
72
|
+
const base = { id: factory.id, name: factory.name, slug: factory.slug };
|
|
73
|
+
try {
|
|
74
|
+
const doc = (await client.getFactoryDocument(factoryId, 'factory_instructions')).data;
|
|
75
|
+
const sections = sectionsOf(doc.data);
|
|
76
|
+
if (!sections) {
|
|
77
|
+
return {
|
|
78
|
+
...base,
|
|
79
|
+
instructions: null,
|
|
80
|
+
instructionsVersion: doc.version,
|
|
81
|
+
note: 'The published instructions are not made of sections; read them with taiga_get_factory_document.',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// Every section, as the API injects them into the agents; a factory
|
|
85
|
+
// whose instructions grew a second section is not cut to the first.
|
|
86
|
+
return { ...base, instructions: instructionsText(sections), instructionsVersion: doc.version };
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (!isNotFound(error)) throw error;
|
|
89
|
+
return {
|
|
90
|
+
...base,
|
|
91
|
+
instructions: null,
|
|
92
|
+
instructionsVersion: null,
|
|
93
|
+
note: 'This factory has not published instructions yet.',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The specification and architecture from the product's own timeline: in full
|
|
100
|
+
* by default, or as section ids when the caller wants a small bundle. Null,
|
|
101
|
+
* with a note, where unpublished.
|
|
102
|
+
*/
|
|
103
|
+
async function documentsPart(client: TaigaClient, product: Record<string, unknown>, full: boolean) {
|
|
104
|
+
const productId = String(product.id);
|
|
105
|
+
const origin = defaultOriginFor(product as ProductSettingsView);
|
|
106
|
+
const reads = await Promise.allSettled(
|
|
107
|
+
CONTEXT_DOCUMENT_TYPES.map((type) => client.getProductDocument(productId, type, origin))
|
|
108
|
+
);
|
|
109
|
+
const out: Record<string, unknown> = { origin };
|
|
110
|
+
const notes: string[] = [];
|
|
111
|
+
CONTEXT_DOCUMENT_TYPES.forEach((type, index) => {
|
|
112
|
+
const read = reads[index]!;
|
|
113
|
+
if (read.status === 'fulfilled') {
|
|
114
|
+
const doc = stripProvenance(read.value.data as Record<string, unknown>);
|
|
115
|
+
if (full) out[type] = doc;
|
|
116
|
+
else {
|
|
117
|
+
const sections = sectionsOf((doc.data as Record<string, unknown>) ?? {});
|
|
118
|
+
if (sections) out[type] = { version: doc.version, sections: Object.keys(sections) };
|
|
119
|
+
else {
|
|
120
|
+
out[type] = { version: doc.version, sections: null };
|
|
121
|
+
notes.push(`The ${origin} ${type} is not made of sections; read it with taiga_get_product_document.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} else if (isNotFound(read.reason)) {
|
|
125
|
+
out[type] = null;
|
|
126
|
+
notes.push(`No published ${origin} ${type}; taiga_list_product_documents shows what the product has.`);
|
|
127
|
+
} else throw read.reason;
|
|
128
|
+
});
|
|
129
|
+
return { ...out, notes };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Every published policy as its section ids, so the model knows which one to read with taiga_get_document. */
|
|
133
|
+
async function policiesPart(client: TaigaClient) {
|
|
134
|
+
const rows = (await client.listTenantDocuments({ limit: 100 })).data as Record<string, unknown>[];
|
|
135
|
+
return rows
|
|
136
|
+
.filter((row) => (POLICY_TYPES as readonly string[]).includes(String(row.type)) && row.isDraft === false)
|
|
137
|
+
.map((row) => ({
|
|
138
|
+
type: row.type,
|
|
139
|
+
name: row.name,
|
|
140
|
+
version: row.version,
|
|
141
|
+
sections: Object.keys(sectionsOf((row.data as Record<string, unknown>) ?? {}) ?? {}),
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The statuses an item is open in; the API's filter takes one at a time. */
|
|
146
|
+
const OPEN_BACKLOG_STATUSES = ['backlog', 'in_progress'] as const;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The items still open: one status-filtered read per open status (the API
|
|
150
|
+
* leaves archived items out), each paged in full up to a cap the result
|
|
151
|
+
* reports. The list is oldest first and the open items are the newest, so a
|
|
152
|
+
* single page would miss exactly the ones that matter. Rows carrying a
|
|
153
|
+
* retired status (readable for a rolling deploy) match neither filter;
|
|
154
|
+
* the settled check below keeps the shape honest for whatever comes back.
|
|
155
|
+
*/
|
|
156
|
+
async function backlogPart(client: TaigaClient, productId: string) {
|
|
157
|
+
// Keyed by id: an item that moves from backlog to in_progress between the
|
|
158
|
+
// two reads would otherwise appear twice.
|
|
159
|
+
const open = new Map<string, Record<string, unknown>>();
|
|
160
|
+
let truncated = false;
|
|
161
|
+
for (const status of OPEN_BACKLOG_STATUSES) {
|
|
162
|
+
let cursor: string | undefined;
|
|
163
|
+
for (let pageIndex = 0; ; pageIndex += 1) {
|
|
164
|
+
if (pageIndex === BACKLOG_PAGE_LIMIT) {
|
|
165
|
+
truncated = true;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
const result = await client.listBacklogItems(productId, { status, limit: 100, cursor });
|
|
169
|
+
for (const row of result.data as Record<string, unknown>[]) {
|
|
170
|
+
if (
|
|
171
|
+
!isSettledOrArchived({ status: String(row.status), archivedAt: (row.archivedAt as string | null) ?? null })
|
|
172
|
+
) {
|
|
173
|
+
open.set(String(row.id), row);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (!result.nextCursor) break;
|
|
177
|
+
cursor = result.nextCursor;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { items: pickFields([...open.values()], BACKLOG_SUMMARY_FIELDS), truncated };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The linked repositories. The route serves one page of 100 and takes no
|
|
185
|
+
* cursor, so past that the bundle says the list is cut rather than complete.
|
|
186
|
+
*/
|
|
187
|
+
async function repositoriesPart(client: TaigaClient, productId: string) {
|
|
188
|
+
const result = await client.listProductRepositories(productId);
|
|
189
|
+
return {
|
|
190
|
+
items: pickFields(result.data as Record<string, unknown>[], REPOSITORY_SUMMARY_FIELDS),
|
|
191
|
+
truncated: result.nextCursor !== null,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function buildsPart(client: TaigaClient, productId: string) {
|
|
196
|
+
const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data as Record<string, unknown>[];
|
|
197
|
+
return pickFields(rows, BUILD_SUMMARY_FIELDS);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function productContext(
|
|
201
|
+
client: TaigaClient,
|
|
202
|
+
product: Record<string, unknown>,
|
|
203
|
+
options: { fullDocuments: boolean }
|
|
204
|
+
): Promise<CallToolResult> {
|
|
205
|
+
const productId = String(product.id);
|
|
206
|
+
const granted = (part: Part) => client.hasCapability(PARTS[part]);
|
|
207
|
+
const notGranted = (Object.keys(PARTS) as Part[]).filter((part) => !granted(part));
|
|
208
|
+
|
|
209
|
+
const fetchers: Record<Part, () => Promise<unknown>> = {
|
|
210
|
+
factory: () => factoryPart(client, String(product.factoryId)),
|
|
211
|
+
documents: () => documentsPart(client, product, options.fullDocuments),
|
|
212
|
+
policies: () => policiesPart(client),
|
|
213
|
+
backlog: () => backlogPart(client, productId),
|
|
214
|
+
repositories: () => repositoriesPart(client, productId),
|
|
215
|
+
builds: () => buildsPart(client, productId),
|
|
216
|
+
};
|
|
217
|
+
const parts = (Object.keys(fetchers) as Part[]).filter(granted);
|
|
218
|
+
const settled = await Promise.allSettled(parts.map((part) => fetchers[part]()));
|
|
219
|
+
|
|
220
|
+
const bundle: Record<string, unknown> = {
|
|
221
|
+
product: {
|
|
222
|
+
...pickFields([product], PRODUCT_SUMMARY_FIELDS)[0],
|
|
223
|
+
projectType: isImportedProduct(product as ProductSettingsView) ? 'brownfield' : 'greenfield',
|
|
224
|
+
defaultOrigin: defaultOriginFor(product as ProductSettingsView),
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
const unavailable: Record<string, string> = {};
|
|
228
|
+
parts.forEach((part, index) => {
|
|
229
|
+
const result = settled[index]!;
|
|
230
|
+
if (result.status === 'fulfilled') bundle[part] = result.value;
|
|
231
|
+
else if (isApiFailure(result.reason)) unavailable[part] = unavailableReason(result.reason);
|
|
232
|
+
else throw result.reason;
|
|
233
|
+
});
|
|
234
|
+
return toolResult({
|
|
235
|
+
...bundle,
|
|
236
|
+
notGranted: notGranted.map((part) => `${part} (needs ${PARTS[part]})`),
|
|
237
|
+
unavailable,
|
|
238
|
+
readNext:
|
|
239
|
+
'Read a policy in full with taiga_get_document, another product document with taiga_get_product_document, ' +
|
|
240
|
+
'a backlog item with taiga_get_backlog_item and a run with taiga_get_build.',
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function registerContextTools(server: McpServer, client: TaigaClient) {
|
|
245
|
+
if (!client.hasCapability('products:read')) return;
|
|
246
|
+
|
|
247
|
+
server.registerTool(
|
|
248
|
+
'taiga_get_product_context',
|
|
249
|
+
{
|
|
250
|
+
title: 'Get product context',
|
|
251
|
+
description:
|
|
252
|
+
'Everything a coding session starts from, in one call: the product and which document timeline is its ' +
|
|
253
|
+
'own, its factory with the instructions in full, the specification and architecture from that timeline, ' +
|
|
254
|
+
'every published policy as its section ids, the open backlog items, the linked repositories and the ' +
|
|
255
|
+
'latest runs. Name the product by ID, slug or the git remote URL of the checkout. A part the API refused ' +
|
|
256
|
+
'or failed is named under unavailable with what it said; a part the key cannot read is named under ' +
|
|
257
|
+
'notGranted; neither is shown as empty. The two documents come in full, which on a large product is ' +
|
|
258
|
+
'several thousand tokens; pass fullDocuments: false for their section ids only. Call this first, then ' +
|
|
259
|
+
'read what you need in full.',
|
|
260
|
+
inputSchema: {
|
|
261
|
+
productId: productId.optional(),
|
|
262
|
+
slug: slug.optional(),
|
|
263
|
+
remoteUrl: remoteUrl.optional(),
|
|
264
|
+
fullDocuments: z
|
|
265
|
+
.boolean()
|
|
266
|
+
.default(true)
|
|
267
|
+
.describe('Carry the specification and architecture in full (default) or as section ids only'),
|
|
268
|
+
},
|
|
269
|
+
annotations: READ_ONLY,
|
|
270
|
+
},
|
|
271
|
+
(params) =>
|
|
272
|
+
runTool(async () => {
|
|
273
|
+
const resolved = await resolveProductRef(client, params, ['productId', 'slug', 'remoteUrl']);
|
|
274
|
+
if ('refusal' in resolved) return toolError(resolved.refusal.code, resolved.refusal.message);
|
|
275
|
+
return productContext(client, resolved.product, { fullDocuments: params.fullDocuments });
|
|
276
|
+
})
|
|
277
|
+
);
|
|
278
|
+
}
|
package/src/tools/documents.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { TaigaApiError, type TaigaClient, type TaigaDocument } from '../client.js';
|
|
4
4
|
import { READ_ONLY } from './annotations.js';
|
|
5
|
+
import { pagination } from './inputs.js';
|
|
5
6
|
import {
|
|
6
7
|
FACTORY_DOCUMENT_TYPES,
|
|
7
8
|
OTHER_TENANT_DOCUMENT_TYPES,
|
|
@@ -11,7 +12,7 @@ import {
|
|
|
11
12
|
} from './catalogue.js';
|
|
12
13
|
import { runTool, toolError, toolResult } from './errors.js';
|
|
13
14
|
import { applySectionEdit, sectionContent, sectionsOf, stripProvenance } from './sections.js';
|
|
14
|
-
import { pickFields } from './summarize.js';
|
|
15
|
+
import { page, pickFields } from './summarize.js';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* What a document row needs to say for the model to pick one and read it in
|
|
@@ -136,14 +137,14 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
|
|
|
136
137
|
`documents (${named(OTHER_TENANT_DOCUMENT_TYPES)}) carry goals, standards, voice and standing ` +
|
|
137
138
|
'instructions. Use taiga_get_document with the type to read one in full.',
|
|
138
139
|
inputSchema: {
|
|
139
|
-
|
|
140
|
+
...pagination,
|
|
140
141
|
},
|
|
141
142
|
annotations: READ_ONLY,
|
|
142
143
|
},
|
|
143
144
|
(params) =>
|
|
144
145
|
runTool(async () => {
|
|
145
146
|
const result = await client.listTenantDocuments(params);
|
|
146
|
-
return toolResult(summaries(result.data));
|
|
147
|
+
return toolResult(page(summaries(result.data), result.nextCursor));
|
|
147
148
|
})
|
|
148
149
|
);
|
|
149
150
|
|
|
@@ -260,14 +261,14 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
|
|
|
260
261
|
'Use taiga_get_factory_document to read the full content.',
|
|
261
262
|
inputSchema: {
|
|
262
263
|
factoryId: z.string().uuid().describe('Factory ID -- get from taiga_list_factories'),
|
|
263
|
-
|
|
264
|
+
...pagination,
|
|
264
265
|
},
|
|
265
266
|
annotations: READ_ONLY,
|
|
266
267
|
},
|
|
267
268
|
(params) =>
|
|
268
269
|
runTool(async () => {
|
|
269
|
-
const result = await client.listFactoryDocuments(params.factoryId,
|
|
270
|
-
return toolResult(summaries(result.data));
|
|
270
|
+
const result = await client.listFactoryDocuments(params.factoryId, params);
|
|
271
|
+
return toolResult(page(summaries(result.data), result.nextCursor));
|
|
271
272
|
})
|
|
272
273
|
);
|
|
273
274
|
|
package/src/tools/errors.ts
CHANGED
|
@@ -15,6 +15,21 @@ export function toolError(error: string, message: string): CallToolResult {
|
|
|
15
15
|
return { content: [{ type: 'text', text: JSON.stringify({ error, message }) }], isError: true };
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* fetch rejects with a TypeError carrying a `cause` when the API cannot be
|
|
20
|
+
* reached; a TypeError from our own code carries none and is a bug to surface.
|
|
21
|
+
*/
|
|
22
|
+
export function isNetworkFailure(error: unknown): error is TypeError {
|
|
23
|
+
return error instanceof TypeError && 'cause' in error;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What went wrong on the wire: undici's message is the constant "fetch failed", the cause says why. */
|
|
27
|
+
export function networkFailureDetail(error: TypeError): string {
|
|
28
|
+
const cause = error.cause as { code?: unknown; message?: unknown } | undefined;
|
|
29
|
+
const why = typeof cause?.code === 'string' ? cause.code : typeof cause?.message === 'string' ? cause.message : null;
|
|
30
|
+
return why ? `${error.message} (${why})` : error.message;
|
|
31
|
+
}
|
|
32
|
+
|
|
18
33
|
const CODE_BY_STATUS: Record<number, string> = {
|
|
19
34
|
400: 'VALIDATION_FAILED',
|
|
20
35
|
401: 'UNAUTHENTICATED',
|
|
@@ -31,6 +46,21 @@ const CODE_BY_STATUS: Record<number, string> = {
|
|
|
31
46
|
* which is what the model needs to fix the draft. A failed connection is
|
|
32
47
|
* structured too, so every failure the model sees has the same shape.
|
|
33
48
|
*/
|
|
49
|
+
/** What the API said, in one phrase: the status, the code it sent (or the one its status implies), its message. */
|
|
50
|
+
export function apiFailureText(error: TaigaApiError): string {
|
|
51
|
+
return `${error.status} ${apiFailureCode(error)}${error.apiMessage ? `: ${error.apiMessage}` : ''}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The code the API sent, or the one its status implies: the code a model branches on. */
|
|
55
|
+
export function apiFailureCode(error: TaigaApiError): string {
|
|
56
|
+
return error.code ?? CODE_BY_STATUS[error.status] ?? 'API_ERROR';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Whether a failure is the API's refusal or absence of a thing, as opposed to the API failing. */
|
|
60
|
+
export function isMissOrRefusal(error: unknown): error is TaigaApiError {
|
|
61
|
+
return error instanceof TaigaApiError && (error.status === 404 || error.status === 403);
|
|
62
|
+
}
|
|
63
|
+
|
|
34
64
|
export async function runTool(handler: () => Promise<CallToolResult>): Promise<CallToolResult> {
|
|
35
65
|
try {
|
|
36
66
|
return await handler();
|
|
@@ -39,10 +69,27 @@ export async function runTool(handler: () => Promise<CallToolResult>): Promise<C
|
|
|
39
69
|
const code = error.code ?? CODE_BY_STATUS[error.status] ?? 'API_ERROR';
|
|
40
70
|
return toolError(code, error.apiMessage ?? `Taiga API answered ${error.status}.`);
|
|
41
71
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
72
|
+
if (isNetworkFailure(error)) {
|
|
73
|
+
return toolError(
|
|
74
|
+
'NETWORK_ERROR',
|
|
75
|
+
`Could not reach the Taiga API: ${networkFailureDetail(error)}. Check TAIGA_API_URL.`
|
|
76
|
+
);
|
|
45
77
|
}
|
|
46
78
|
throw error;
|
|
47
79
|
}
|
|
48
80
|
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A refusal a resource or prompt handler throws. The protocol layer sends
|
|
84
|
+
* `code` and `message` as they are; McpError would prefix the message with
|
|
85
|
+
* "MCP error -32602:" and the client would prefix it again.
|
|
86
|
+
*/
|
|
87
|
+
export class RequestError extends Error {
|
|
88
|
+
constructor(
|
|
89
|
+
readonly code: number,
|
|
90
|
+
message: string
|
|
91
|
+
) {
|
|
92
|
+
super(message);
|
|
93
|
+
this.name = 'RequestError';
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/tools/index.ts
CHANGED
|
@@ -4,9 +4,12 @@ import { registerProductTools } from './products.js';
|
|
|
4
4
|
import { registerDocumentTools } from './documents.js';
|
|
5
5
|
import { registerBacklogTools } from './backlogs.js';
|
|
6
6
|
import { registerBuildTools } from './builds.js';
|
|
7
|
+
import { registerContextTools } from './context.js';
|
|
8
|
+
import { registerPrompts } from './prompts.js';
|
|
9
|
+
import { registerResources } from './resources.js';
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
|
-
* Register all MCP tools based on the API key's effective capabilities.
|
|
12
|
+
* Register all MCP tools, resources and prompts based on the API key's effective capabilities.
|
|
10
13
|
* Only tools matching the key's scopes are registered -- the AI assistant
|
|
11
14
|
* never sees tools it can't use.
|
|
12
15
|
*/
|
|
@@ -15,4 +18,7 @@ export function registerAllTools(server: McpServer, client: TaigaClient): void {
|
|
|
15
18
|
registerDocumentTools(server, client);
|
|
16
19
|
registerBacklogTools(server, client);
|
|
17
20
|
registerBuildTools(server, client);
|
|
21
|
+
registerContextTools(server, client);
|
|
22
|
+
registerResources(server, client);
|
|
23
|
+
registerPrompts(server, client);
|
|
18
24
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/** The inputs several tools share, so a product or a page is named the same way everywhere. */
|
|
4
|
+
|
|
5
|
+
export const productId = z
|
|
6
|
+
.string()
|
|
7
|
+
.uuid()
|
|
8
|
+
.describe('Product ID -- taiga_get_product_context, taiga_resolve_product or taiga_list_products give it');
|
|
9
|
+
|
|
10
|
+
/** The page inputs every list tool takes; `nextCursor` of one page is the `cursor` of the next. */
|
|
11
|
+
export const pagination = {
|
|
12
|
+
limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
|
|
13
|
+
cursor: z
|
|
14
|
+
.string()
|
|
15
|
+
.max(256)
|
|
16
|
+
.optional()
|
|
17
|
+
.describe('The nextCursor of the previous page, to read the next one; omit for the first page'),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const slug = z
|
|
21
|
+
.string()
|
|
22
|
+
.max(63)
|
|
23
|
+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'A slug is lowercase words joined by hyphens')
|
|
24
|
+
.describe('Product slug, as it appears in Taiga URLs');
|
|
25
|
+
|
|
26
|
+
export const remoteUrl = z
|
|
27
|
+
.string()
|
|
28
|
+
.min(1)
|
|
29
|
+
.max(2048)
|
|
30
|
+
.describe(
|
|
31
|
+
'The git remote URL of a checkout (git remote get-url origin): https://, git@host:owner/repo.git or ssh://'
|
|
32
|
+
);
|
|
@@ -53,11 +53,19 @@ export const SERVER_INSTRUCTIONS = [
|
|
|
53
53
|
'`priority: core` marks the sections every organization keeps. Neither changes what the section',
|
|
54
54
|
'requires of you.',
|
|
55
55
|
'',
|
|
56
|
-
'Workflow:
|
|
57
|
-
'
|
|
56
|
+
'Workflow: start with taiga_get_product_context, naming the product by its git remote URL inside a checkout',
|
|
57
|
+
'(git remote get-url origin), else by slug or ID from taiga_list_products. It returns the product, the',
|
|
58
|
+
'factory instructions, the specification and architecture, every policy as its section ids, the open backlog',
|
|
59
|
+
'items, the repositories and the latest runs in one call, and names any part it could not read. Then read',
|
|
60
|
+
'what you need in full: a policy with taiga_get_document, a backlog item with taiga_get_backlog_item.',
|
|
61
|
+
'Without it, use taiga_list_factories and taiga_list_products, then read the factory instructions,',
|
|
58
62
|
"the product's specification and architecture, and the backlog item you are working on. A document",
|
|
59
63
|
'read that finds nothing tells you what does exist and which tool reads or writes it.',
|
|
60
64
|
'',
|
|
65
|
+
'The same documents are resources (taiga://documents/{type}, taiga://products/{productId}/documents/{type},',
|
|
66
|
+
'taiga://factories/{factoryId}/instructions, taiga://backlog/{itemId}) for clients that attach them, and three',
|
|
67
|
+
'prompts open a session: taiga_start_session, taiga_implement_backlog_item, taiga_review_against_policies.',
|
|
68
|
+
'',
|
|
61
69
|
'When you notice a policy or factory instructions are outdated, fix the section with',
|
|
62
70
|
'taiga_update_document or taiga_update_factory_document. That saves a draft nobody follows yet;',
|
|
63
71
|
'publish it with taiga_publish_document or taiga_publish_factory_document only when the person asks.',
|