@taiga-ai/mcp-server 0.25.0-rc.474 → 0.26.0-dev.481
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -0
- package/dist/index.js +292 -115
- package/package.json +15 -2
- package/scripts/build.mjs +0 -37
- package/scripts/prepare-publish.cjs +0 -49
- package/src/client.test.ts +0 -424
- package/src/client.ts +0 -977
- package/src/config.test.ts +0 -24
- package/src/config.ts +0 -28
- package/src/index.ts +0 -45
- package/src/modern.integration.test.ts +0 -153
- package/src/server.integration.test.ts +0 -9214
- package/src/server.ts +0 -18
- package/src/tools/annotations.ts +0 -38
- package/src/tools/autonomy.ts +0 -86
- package/src/tools/backlogs.ts +0 -802
- package/src/tools/builds.ts +0 -668
- package/src/tools/catalogue.test.ts +0 -170
- package/src/tools/catalogue.ts +0 -223
- package/src/tools/context.ts +0 -513
- package/src/tools/documents.ts +0 -375
- package/src/tools/errors.ts +0 -172
- package/src/tools/hints.ts +0 -99
- package/src/tools/inbox.ts +0 -546
- package/src/tools/index.ts +0 -239
- package/src/tools/inputs.ts +0 -47
- package/src/tools/instructions.ts +0 -41
- package/src/tools/jobs.ts +0 -90
- package/src/tools/knowledge.ts +0 -249
- package/src/tools/maintenance.ts +0 -547
- package/src/tools/markdown.test.ts +0 -249
- package/src/tools/markdown.ts +0 -151
- package/src/tools/observe.ts +0 -311
- package/src/tools/product-documents.ts +0 -184
- package/src/tools/products.ts +0 -529
- package/src/tools/prompts.ts +0 -265
- package/src/tools/queue.ts +0 -250
- package/src/tools/readiness.ts +0 -177
- package/src/tools/remote.test.ts +0 -44
- package/src/tools/remote.ts +0 -107
- package/src/tools/repositories.ts +0 -360
- package/src/tools/request-prompt.test.ts +0 -32
- package/src/tools/request-prompt.ts +0 -28
- package/src/tools/resources.ts +0 -217
- package/src/tools/sections.test.ts +0 -143
- package/src/tools/sections.ts +0 -287
- package/src/tools/setup.ts +0 -455
- package/src/tools/summarize.test.ts +0 -20
- package/src/tools/summarize.ts +0 -96
- package/src/tools/wait.ts +0 -490
- package/src/version.test.ts +0 -10
- package/src/version.ts +0 -14
- package/taiga-ai-mcp-server-0.25.0-rc.474.tgz +0 -0
- package/tsconfig.json +0 -21
- package/vitest.config.ts +0 -9
package/src/tools/documents.ts
DELETED
|
@@ -1,375 +0,0 @@
|
|
|
1
|
-
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
-
import type { FactoryDocument, TenantDocument } from '@taiga/types';
|
|
3
|
-
import { z } from 'zod';
|
|
4
|
-
import { TaigaApiError, type TaigaClient, type TaigaDocument, type WithoutTenant } from '../client.js';
|
|
5
|
-
import { READ_ONLY } from './annotations.js';
|
|
6
|
-
import { idInput, pagination } from './inputs.js';
|
|
7
|
-
import {
|
|
8
|
-
FACTORY_DOCUMENT_TYPES,
|
|
9
|
-
OTHER_TENANT_DOCUMENT_TYPES,
|
|
10
|
-
POLICY_TYPES,
|
|
11
|
-
TENANT_DOCUMENT_TYPES,
|
|
12
|
-
named,
|
|
13
|
-
} from './catalogue.js';
|
|
14
|
-
import { runTool, toolError, toolResult } from './errors.js';
|
|
15
|
-
import {
|
|
16
|
-
applySectionEdit,
|
|
17
|
-
sectionContent,
|
|
18
|
-
sectionReadResult,
|
|
19
|
-
policySectionsInput,
|
|
20
|
-
sectionsOf,
|
|
21
|
-
stripProvenance,
|
|
22
|
-
} from './sections.js';
|
|
23
|
-
import { page, pickFields } from './summarize.js';
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* What a document row needs to say for the model to pick one and read it in
|
|
27
|
-
* full. Not the content, not who wrote it.
|
|
28
|
-
*/
|
|
29
|
-
const DOCUMENT_SUMMARY_FIELDS = ['type', 'name', 'description', 'version', 'isDraft', 'hasDraft', 'updatedAt'] as const;
|
|
30
|
-
|
|
31
|
-
/** A row of the company or factory document list; product documents list through their own tools. */
|
|
32
|
-
type ListedDocument = WithoutTenant<TenantDocument | FactoryDocument>;
|
|
33
|
-
|
|
34
|
-
const summaries = (rows: ListedDocument[]) => pickFields(rows, DOCUMENT_SUMMARY_FIELDS);
|
|
35
|
-
|
|
36
|
-
/** What a lookup behind a miss said when it failed, so an unknown stays unknown. */
|
|
37
|
-
function failedLookup(what: string, reason: unknown): string {
|
|
38
|
-
const said = reason instanceof TaigaApiError ? `${reason.status} ${reason.code ?? ''}`.trim() : 'the request failed';
|
|
39
|
-
return `${what} could not be checked (${said}).`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** `a (v3), b (v1)` for a not-found message. */
|
|
43
|
-
function publishedList(rows: ListedDocument[]): string {
|
|
44
|
-
// The list routes return the latest draft rows beside the published ones,
|
|
45
|
-
// and a draft named as published sends the model into a second miss.
|
|
46
|
-
return rows
|
|
47
|
-
.filter((row) => row.isDraft === false)
|
|
48
|
-
.map((row) => `${row.type} (v${String(row.version)})`)
|
|
49
|
-
.join(', ');
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const DRAFT_WRITE = {
|
|
53
|
-
readOnlyHint: false,
|
|
54
|
-
destructiveHint: false,
|
|
55
|
-
idempotentHint: true,
|
|
56
|
-
openWorldHint: false,
|
|
57
|
-
} as const;
|
|
58
|
-
// Publishing replaces the version every agent and person reads, and there is
|
|
59
|
-
// no unpublish: destructive so a host that confirms destructive tools asks
|
|
60
|
-
// first. Not idempotent: a second call publishes the same draft once more.
|
|
61
|
-
const PUBLISH = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false } as const;
|
|
62
|
-
|
|
63
|
-
/** A section id, described with the read tool of its own scope, which lists the valid ids. */
|
|
64
|
-
export function sectionIdFrom(readTool: string) {
|
|
65
|
-
return z
|
|
66
|
-
.string()
|
|
67
|
-
.min(1)
|
|
68
|
-
.describe(
|
|
69
|
-
`Section id -- the keys of \`data.sections\` in ${readTool}, e.g. summary, scope, apiDesign. ` +
|
|
70
|
-
'An id that does not exist yet adds a section.'
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
export const sectionContentInput = z
|
|
74
|
-
.string()
|
|
75
|
-
.describe('The full new Markdown text of the section; it replaces the old text');
|
|
76
|
-
const tenantDocumentType = z
|
|
77
|
-
.enum(TENANT_DOCUMENT_TYPES)
|
|
78
|
-
// The enum lists every type; the description only says which are policies.
|
|
79
|
-
.describe('Document type: the policy_* ones are policies');
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* The read, save and publish calls for one document scope. The tools are the
|
|
83
|
-
* same for a company policy and for factory instructions; only the routes and
|
|
84
|
-
* the capability gate differ.
|
|
85
|
-
*/
|
|
86
|
-
export interface DocumentScope {
|
|
87
|
-
getDraft(): Promise<{ data: TaigaDocument | null }>;
|
|
88
|
-
getPublished(): Promise<{ data: TaigaDocument }>;
|
|
89
|
-
saveDraft(data: Record<string, unknown>): Promise<{ data: TaigaDocument }>;
|
|
90
|
-
publish(): Promise<{ data: TaigaDocument; warnings?: unknown }>;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Everything the model needs back from a write, without the content it already holds. */
|
|
94
|
-
function documentSummary(doc: TaigaDocument) {
|
|
95
|
-
const { data, ...rest } = doc;
|
|
96
|
-
return { ...rest, sections: Object.keys(sectionsOf(data) ?? {}) };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* The document a section edit starts from: the draft when one exists, else
|
|
101
|
-
* the published version, else (instructions documents start empty and
|
|
102
|
-
* unpublished, PRO-502) an empty section map. The API seeds the draft the
|
|
103
|
-
* same way on the first save, so the merge happens on what it would hold.
|
|
104
|
-
*/
|
|
105
|
-
async function editBase(scope: DocumentScope): Promise<Record<string, unknown>> {
|
|
106
|
-
const draft = (await scope.getDraft()).data;
|
|
107
|
-
if (draft) return draft.data;
|
|
108
|
-
try {
|
|
109
|
-
return (await scope.getPublished()).data.data;
|
|
110
|
-
} catch (error) {
|
|
111
|
-
if (error instanceof TaigaApiError && error.status === 404) return { sections: {} };
|
|
112
|
-
throw error;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Edit one section of a document's draft; the edit keeps every other section
|
|
118
|
-
* and the section's own metadata, see sections.ts.
|
|
119
|
-
*/
|
|
120
|
-
export async function updateSection(scope: DocumentScope, type: string, id: string, content: string) {
|
|
121
|
-
const base = await editBase(scope);
|
|
122
|
-
const sections = sectionsOf(base);
|
|
123
|
-
if (!sections) {
|
|
124
|
-
return toolError(
|
|
125
|
-
'UNSUPPORTED_DOCUMENT',
|
|
126
|
-
`Document ${type} is not made of sections and cannot be edited section by section. Edit it in the Taiga app.`
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
const saved = await scope.saveDraft({ ...base, sections: applySectionEdit(sections, id, content) });
|
|
130
|
-
return toolResult({
|
|
131
|
-
...documentSummary(saved.data),
|
|
132
|
-
section: { id, content: sectionContent(sectionsOf(saved.data.data) ?? {}, id) },
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Publish the draft; the API answers 404 when there is none and 400 when the
|
|
138
|
-
* residue gate refuses it. A publish can succeed with warnings about this
|
|
139
|
-
* publish (a product document citing a policy control that does not exist),
|
|
140
|
-
* which are kept: they are the only way the publisher hears of them.
|
|
141
|
-
*/
|
|
142
|
-
export async function publishDraft(scope: DocumentScope) {
|
|
143
|
-
const published = await scope.publish();
|
|
144
|
-
return toolResult({
|
|
145
|
-
...documentSummary(published.data),
|
|
146
|
-
...(published.warnings ? { warnings: published.warnings } : {}),
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export function registerDocumentTools(server: McpServer, client: TaigaClient) {
|
|
151
|
-
// ── Company-level policy documents ──────────────────────────────────
|
|
152
|
-
|
|
153
|
-
if (client.hasCapability('tenant:documents:read')) {
|
|
154
|
-
server.registerTool(
|
|
155
|
-
'taiga_get_document',
|
|
156
|
-
{
|
|
157
|
-
title: 'Get company document',
|
|
158
|
-
description:
|
|
159
|
-
"Read one of the organization's documents by type, or without type list them all (type, name, version, " +
|
|
160
|
-
`draft state). The ${POLICY_TYPES.length} policies (${named(POLICY_TYPES)}) set the rules for every ` +
|
|
161
|
-
`product; the others (${named(OTHER_TENANT_DOCUMENT_TYPES)}) carry goals, standards, voice and ` +
|
|
162
|
-
'instructions. Policies are long: pass ' +
|
|
163
|
-
'sections: [] for the outline, then the section ids you need, or a control id such as SEC-5.1; ' +
|
|
164
|
-
'unknownSections names ids it lacks. A miss says which documents are published and whether a draft ' +
|
|
165
|
-
"exists. A section's source (template, overlay-<id>, cloud-preset:<providers>) and priority (core: every " +
|
|
166
|
-
'organization keeps it) do not change what it requires.',
|
|
167
|
-
inputSchema: z.object({
|
|
168
|
-
type: tenantDocumentType.optional(),
|
|
169
|
-
sections: policySectionsInput,
|
|
170
|
-
...pagination,
|
|
171
|
-
}),
|
|
172
|
-
annotations: READ_ONLY,
|
|
173
|
-
},
|
|
174
|
-
(params) =>
|
|
175
|
-
runTool(async () => {
|
|
176
|
-
// One tool for the list and the read (PRO-2800): the catalogue's weight is the cost every review ranks
|
|
177
|
-
// first, and a list tool beside each read was the read without a type.
|
|
178
|
-
if (params.type === undefined) {
|
|
179
|
-
if (params.sections) return toolError('VALIDATION_ERROR', 'Name the document (type) to read its sections.');
|
|
180
|
-
const result = await client.listTenantDocuments({ limit: params.limit, cursor: params.cursor });
|
|
181
|
-
return toolResult(page(summaries(result.data), result.nextCursor));
|
|
182
|
-
}
|
|
183
|
-
const type = params.type;
|
|
184
|
-
if (params.limit !== undefined || params.cursor !== undefined) {
|
|
185
|
-
return toolError(
|
|
186
|
-
'VALIDATION_ERROR',
|
|
187
|
-
'limit and cursor page the list; omit type to list, or drop them to read.'
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
try {
|
|
191
|
-
const result = await client.getTenantDocument(type);
|
|
192
|
-
const doc = stripProvenance(result.data);
|
|
193
|
-
return params.sections
|
|
194
|
-
? sectionReadResult(doc, params.sections, type, {
|
|
195
|
-
controlIds: (POLICY_TYPES as readonly string[]).includes(type),
|
|
196
|
-
})
|
|
197
|
-
: toolResult(doc);
|
|
198
|
-
} catch (error) {
|
|
199
|
-
if (!(error instanceof TaigaApiError) || error.status !== 404) throw error;
|
|
200
|
-
// Best effort, each part on its own: a failed lookup must not turn
|
|
201
|
-
// a miss into an outage, nor blank the part that did answer.
|
|
202
|
-
const [draft, all] = await Promise.allSettled([
|
|
203
|
-
client.getTenantDocumentDraft(type),
|
|
204
|
-
client.listTenantDocuments({ limit: 100 }),
|
|
205
|
-
]);
|
|
206
|
-
const parts = [`No published ${type}.`];
|
|
207
|
-
if (draft.status === 'fulfilled') {
|
|
208
|
-
parts.push(
|
|
209
|
-
draft.value.data
|
|
210
|
-
? 'A draft exists; taiga_publish_document publishes it once the person asks.'
|
|
211
|
-
: 'There is no draft either.'
|
|
212
|
-
);
|
|
213
|
-
} else {
|
|
214
|
-
parts.push(failedLookup('The draft', draft.reason));
|
|
215
|
-
}
|
|
216
|
-
if (all.status === 'fulfilled') {
|
|
217
|
-
const published = publishedList(all.value.data);
|
|
218
|
-
parts.push(published ? `Published documents: ${published}.` : 'Nothing is published yet.');
|
|
219
|
-
} else {
|
|
220
|
-
parts.push(failedLookup('The published list', all.reason));
|
|
221
|
-
}
|
|
222
|
-
return toolError('NOT_FOUND', parts.join(' '));
|
|
223
|
-
}
|
|
224
|
-
})
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// The edit reads the draft and the published version before it writes,
|
|
229
|
-
// and those routes need tenant:documents:read; a write-only scoped key
|
|
230
|
-
// would get a tool that 403s on every call.
|
|
231
|
-
if (client.hasCapability('tenant:documents:read') && client.hasCapability('tenant:documents:write')) {
|
|
232
|
-
const tenantScope = (type: string): DocumentScope => ({
|
|
233
|
-
getDraft: () => client.getTenantDocumentDraft(type),
|
|
234
|
-
getPublished: () => client.getTenantDocument(type),
|
|
235
|
-
saveDraft: (data) => client.saveTenantDocumentDraft(type, { data }),
|
|
236
|
-
publish: () => client.publishTenantDocument(type),
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
server.registerTool(
|
|
240
|
-
'taiga_update_document',
|
|
241
|
-
{
|
|
242
|
-
title: 'Update a section of a company document draft',
|
|
243
|
-
description:
|
|
244
|
-
"Replace one section's text in a company policy document's draft; other sections stay. Read the document " +
|
|
245
|
-
'with taiga_get_document first for its sections. Readers keep the published version until ' +
|
|
246
|
-
'taiga_publish_document. Use it when a policy is outdated or wrong.',
|
|
247
|
-
inputSchema: z.object({
|
|
248
|
-
type: tenantDocumentType,
|
|
249
|
-
sectionId: sectionIdFrom('taiga_get_document'),
|
|
250
|
-
content: sectionContentInput,
|
|
251
|
-
}),
|
|
252
|
-
annotations: DRAFT_WRITE,
|
|
253
|
-
},
|
|
254
|
-
(params) => runTool(() => updateSection(tenantScope(params.type), params.type, params.sectionId, params.content))
|
|
255
|
-
);
|
|
256
|
-
|
|
257
|
-
server.registerTool(
|
|
258
|
-
'taiga_publish_document',
|
|
259
|
-
{
|
|
260
|
-
title: 'Publish a company document draft',
|
|
261
|
-
description:
|
|
262
|
-
"Publish a company policy document's draft as its next version, which every agent and person reads from " +
|
|
263
|
-
'then on. Only when the person asked; save edits first with taiga_update_document. A refusal names what ' +
|
|
264
|
-
'to fix in the draft.',
|
|
265
|
-
inputSchema: z.object({
|
|
266
|
-
type: tenantDocumentType,
|
|
267
|
-
}),
|
|
268
|
-
annotations: PUBLISH,
|
|
269
|
-
},
|
|
270
|
-
(params) => runTool(() => publishDraft(tenantScope(params.type)))
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// ── Factory instructions ────────────────────────────────────────────
|
|
275
|
-
|
|
276
|
-
if (client.hasCapability('factories:read')) {
|
|
277
|
-
server.registerTool(
|
|
278
|
-
'taiga_get_factory_document',
|
|
279
|
-
{
|
|
280
|
-
title: 'Get factory instructions',
|
|
281
|
-
description:
|
|
282
|
-
"Read a factory's instructions: the domain context, tech preferences and conventions its products " +
|
|
283
|
-
'follow. If nothing is published, it says whether a draft exists and how to write one.',
|
|
284
|
-
inputSchema: z.object({
|
|
285
|
-
factoryId: idInput('factory', 'taiga_list_factories (query finds one by name) gives it'),
|
|
286
|
-
type: z.enum(FACTORY_DOCUMENT_TYPES).default('factory_instructions').describe('Document type'),
|
|
287
|
-
}),
|
|
288
|
-
annotations: READ_ONLY,
|
|
289
|
-
},
|
|
290
|
-
(params) =>
|
|
291
|
-
runTool(async () => {
|
|
292
|
-
try {
|
|
293
|
-
const result = await client.getFactoryDocument(params.factoryId, params.type);
|
|
294
|
-
return toolResult(stripProvenance(result.data));
|
|
295
|
-
} catch (error) {
|
|
296
|
-
if (!(error instanceof TaigaApiError) || error.status !== 404) throw error;
|
|
297
|
-
// A factory that does not exist answers the same 404 as one with
|
|
298
|
-
// nothing published, and the draft route does not check the
|
|
299
|
-
// factory for a break-glass key: read the factory first, so its
|
|
300
|
-
// 404 propagates as the factory missing rather than as "nothing
|
|
301
|
-
// published yet".
|
|
302
|
-
await client.getFactory(params.factoryId);
|
|
303
|
-
// The draft route answers 200 with null for a visible factory
|
|
304
|
-
// with no draft, so its 404 is also the factory out of scope and
|
|
305
|
-
// propagates. Any other failure is named.
|
|
306
|
-
const detail = await client
|
|
307
|
-
.getFactoryDocumentDraft(params.factoryId, params.type)
|
|
308
|
-
.then((draft) =>
|
|
309
|
-
draft.data
|
|
310
|
-
? 'A draft exists; taiga_publish_factory_document publishes it once the person asks.'
|
|
311
|
-
: 'There is no draft either: write one with taiga_update_factory_document, then publish it when the person asks.'
|
|
312
|
-
)
|
|
313
|
-
.catch((reason: unknown) => {
|
|
314
|
-
if (reason instanceof TaigaApiError && reason.status === 404) throw reason;
|
|
315
|
-
return failedLookup('The draft', reason);
|
|
316
|
-
});
|
|
317
|
-
return toolError('NOT_FOUND', `This factory has no published ${params.type} yet. ${detail}`);
|
|
318
|
-
}
|
|
319
|
-
})
|
|
320
|
-
);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Writing factory instructions needs factories:read (to find the factory) and
|
|
324
|
-
// factories:documents:write, which is what the API routes require: members
|
|
325
|
-
// hold it for their own factories, and tenant:documents:write (company
|
|
326
|
-
// policies) does not stand in for it.
|
|
327
|
-
if (client.hasCapability('factories:read') && client.hasCapability('factories:documents:write')) {
|
|
328
|
-
const factoryScope = (factoryId: string, type: string): DocumentScope => ({
|
|
329
|
-
getDraft: () => client.getFactoryDocumentDraft(factoryId, type),
|
|
330
|
-
getPublished: () => client.getFactoryDocument(factoryId, type),
|
|
331
|
-
saveDraft: (data) => client.saveFactoryDocumentDraft(factoryId, type, { data }),
|
|
332
|
-
publish: () => client.publishFactoryDocument(factoryId, type),
|
|
333
|
-
});
|
|
334
|
-
const factoryId = idInput('factory', 'taiga_list_factories (query finds one by name) gives it');
|
|
335
|
-
const factoryDocumentType = z
|
|
336
|
-
.enum(FACTORY_DOCUMENT_TYPES)
|
|
337
|
-
.default('factory_instructions')
|
|
338
|
-
.describe('Document type');
|
|
339
|
-
|
|
340
|
-
server.registerTool(
|
|
341
|
-
'taiga_update_factory_document',
|
|
342
|
-
{
|
|
343
|
-
title: 'Update a section of factory instructions',
|
|
344
|
-
description:
|
|
345
|
-
"Replace the text of a factory's instructions draft (its one section, `instructions`). Read it first " +
|
|
346
|
-
'with taiga_get_factory_document. Products follow the published version until ' +
|
|
347
|
-
'taiga_publish_factory_document.',
|
|
348
|
-
inputSchema: z.object({
|
|
349
|
-
factoryId,
|
|
350
|
-
type: factoryDocumentType,
|
|
351
|
-
sectionId: sectionIdFrom('taiga_get_factory_document').default('instructions'),
|
|
352
|
-
content: sectionContentInput,
|
|
353
|
-
}),
|
|
354
|
-
annotations: DRAFT_WRITE,
|
|
355
|
-
},
|
|
356
|
-
(params) =>
|
|
357
|
-
runTool(() =>
|
|
358
|
-
updateSection(factoryScope(params.factoryId, params.type), params.type, params.sectionId, params.content)
|
|
359
|
-
)
|
|
360
|
-
);
|
|
361
|
-
|
|
362
|
-
server.registerTool(
|
|
363
|
-
'taiga_publish_factory_document',
|
|
364
|
-
{
|
|
365
|
-
title: 'Publish factory instructions draft',
|
|
366
|
-
description:
|
|
367
|
-
"Publish a factory's instructions draft as the next version every product in the factory follows. Only " +
|
|
368
|
-
'when the person asked; save edits first with taiga_update_factory_document.',
|
|
369
|
-
inputSchema: z.object({ factoryId, type: factoryDocumentType }),
|
|
370
|
-
annotations: PUBLISH,
|
|
371
|
-
},
|
|
372
|
-
(params) => runTool(() => publishDraft(factoryScope(params.factoryId, params.type)))
|
|
373
|
-
);
|
|
374
|
-
}
|
|
375
|
-
}
|
package/src/tools/errors.ts
DELETED
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
import type { CallToolResult } from '@modelcontextprotocol/server';
|
|
2
|
-
import { TaigaApiError } from '../client.js';
|
|
3
|
-
import { withNotFoundHint } from './hints.js';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The API's names for the two IDs an agent chains most, in the product's
|
|
7
|
-
* words the tools take them in (PRO-2800): a row says initiativeId and runId,
|
|
8
|
-
* as taiga_get_initiative and taiga_get_run ask for them, rather than the
|
|
9
|
-
* storage names backlogItemId and buildRunId.
|
|
10
|
-
*/
|
|
11
|
-
const PRODUCT_WORDS: Record<string, string> = {
|
|
12
|
-
backlogItemId: 'initiativeId',
|
|
13
|
-
buildRunId: 'runId',
|
|
14
|
-
latestBuild: 'latestRun',
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Maps whose keys are content, not API fields: a document's sections are keyed
|
|
19
|
-
* by ids a person or agent chose (a section may be called backlogItemId), and
|
|
20
|
-
* metadata and settings hold whatever their writer put there. Copied as they
|
|
21
|
-
* are, keys and all.
|
|
22
|
-
*/
|
|
23
|
-
const OPAQUE_MAPS = new Set(['sections', 'metadata', 'settings']);
|
|
24
|
-
|
|
25
|
-
/** A copy of an API value with its keys in the product's words, values untouched. */
|
|
26
|
-
/**
|
|
27
|
-
* Run actions only the web offers (PRO-2800): start_over was listed beside what an agent can call, on runs and on
|
|
28
|
-
* every initiative's latest run, while the descriptions said it was web only. Split apart wherever actions appear.
|
|
29
|
-
*/
|
|
30
|
-
const WEB_ONLY_ACTIONS = new Set(['start_over']);
|
|
31
|
-
|
|
32
|
-
export function inProductWords(value: unknown): unknown {
|
|
33
|
-
if (Array.isArray(value)) return value.map(inProductWords);
|
|
34
|
-
if (value === null || typeof value !== 'object') return value;
|
|
35
|
-
const out: Record<string, unknown> = {};
|
|
36
|
-
const actions = (value as Record<string, unknown>)['actions'];
|
|
37
|
-
if (Array.isArray(actions) && actions.some((action) => WEB_ONLY_ACTIONS.has(String(action)))) {
|
|
38
|
-
const webOnly = actions.filter((action) => WEB_ONLY_ACTIONS.has(String(action)));
|
|
39
|
-
value = {
|
|
40
|
-
...(value as Record<string, unknown>),
|
|
41
|
-
actions: actions.filter((action) => !WEB_ONLY_ACTIONS.has(String(action))),
|
|
42
|
-
webOnlyActions: webOnly,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
|
|
46
|
-
Object.defineProperty(out, PRODUCT_WORDS[key] ?? key, {
|
|
47
|
-
value: OPAQUE_MAPS.has(key) ? inner : inProductWords(inner),
|
|
48
|
-
enumerable: true,
|
|
49
|
-
writable: true,
|
|
50
|
-
configurable: true,
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
return out;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** The API's 404 sentence in the product's words: "Backlog item not found" is an initiative not found. */
|
|
57
|
-
export function inProductWording(said: string): string {
|
|
58
|
-
return (
|
|
59
|
-
said
|
|
60
|
-
.replace(/^Backlog item not found/i, 'Initiative not found')
|
|
61
|
-
.replace(/^Build run step not found/i, 'Run step not found')
|
|
62
|
-
.replace(/^Build run not found/i, 'Run not found')
|
|
63
|
-
.replace(/^Custom document not found/i, 'Knowledge file not found')
|
|
64
|
-
// A missing branch names the list of the ones that exist, as every other miss does.
|
|
65
|
-
.replace(
|
|
66
|
-
/^(Branch "[^"]*" does not exist in \S+?)\.?$/i,
|
|
67
|
-
'$1. taiga_list_repository_branches lists the branches it has.'
|
|
68
|
-
)
|
|
69
|
-
// The API's word for a second link names no way out; the tool has two.
|
|
70
|
-
.replace(
|
|
71
|
-
/^This product already has a repository\. Change it instead of linking another\./i,
|
|
72
|
-
'This product already has a repository. Pass replacesRepositoryId to swap it in one step, or ' +
|
|
73
|
-
'taiga_unlink_repository first (taiga_list_product_repositories gives its ID).'
|
|
74
|
-
)
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** A successful tool result: one JSON text block the model reads. */
|
|
79
|
-
export function toolResult(value: unknown): CallToolResult {
|
|
80
|
-
return { content: [{ type: 'text', text: JSON.stringify(inProductWords(value), null, 2) }] };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* A failed tool result the model can branch on: a stable code and a message
|
|
85
|
-
* that says what to do next. Returned, not thrown, so the client sees
|
|
86
|
-
* `isError` rather than a protocol error.
|
|
87
|
-
*/
|
|
88
|
-
export function toolError(error: string, message: string, details?: Record<string, unknown>): CallToolResult {
|
|
89
|
-
return {
|
|
90
|
-
// Details in the product's words too: a conflict naming the run in its way says runId, as the inputs do.
|
|
91
|
-
content: [
|
|
92
|
-
{
|
|
93
|
-
type: 'text',
|
|
94
|
-
text: JSON.stringify(details ? { error, message, details: inProductWords(details) } : { error, message }),
|
|
95
|
-
},
|
|
96
|
-
],
|
|
97
|
-
isError: true,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* fetch rejects with a TypeError carrying a `cause` when the API cannot be
|
|
103
|
-
* reached; a TypeError from our own code carries none and is a bug to surface.
|
|
104
|
-
*/
|
|
105
|
-
export function isNetworkFailure(error: unknown): error is TypeError {
|
|
106
|
-
return error instanceof TypeError && 'cause' in error;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** What went wrong on the wire: undici's message is the constant "fetch failed", the cause says why. */
|
|
110
|
-
export function networkFailureDetail(error: TypeError): string {
|
|
111
|
-
const cause = error.cause as { code?: unknown; message?: unknown } | undefined;
|
|
112
|
-
const why = typeof cause?.code === 'string' ? cause.code : typeof cause?.message === 'string' ? cause.message : null;
|
|
113
|
-
return why ? `${error.message} (${why})` : error.message;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* The code a status implies when the API sent none. 400 is VALIDATION_ERROR,
|
|
118
|
-
* the code the API itself sends, so a refusal by this server and one by the
|
|
119
|
-
* API read the same.
|
|
120
|
-
*/
|
|
121
|
-
const CODE_BY_STATUS: Record<number, string> = {
|
|
122
|
-
400: 'VALIDATION_ERROR',
|
|
123
|
-
401: 'UNAUTHENTICATED',
|
|
124
|
-
403: 'FORBIDDEN',
|
|
125
|
-
404: 'NOT_FOUND',
|
|
126
|
-
409: 'CONFLICT',
|
|
127
|
-
429: 'RATE_LIMITED',
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
/** What the API said, in one phrase: the status, the code it sent (or the one its status implies), its message. */
|
|
131
|
-
export function apiFailureText(error: TaigaApiError): string {
|
|
132
|
-
return `${error.status} ${apiFailureCode(error)}${error.apiMessage ? `: ${error.apiMessage}` : ''}`;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** The code the API sent, or the one its status implies: the code a model branches on. */
|
|
136
|
-
export function apiFailureCode(error: TaigaApiError): string {
|
|
137
|
-
return error.code ?? CODE_BY_STATUS[error.status] ?? 'API_ERROR';
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Whether a failure is the API's refusal or absence of a thing, as opposed to the API failing. */
|
|
141
|
-
export function isMissOrRefusal(error: unknown): error is TaigaApiError {
|
|
142
|
-
return error instanceof TaigaApiError && (error.status === 404 || error.status === 403);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Run a tool handler and turn a failure into a structured error. An API
|
|
147
|
-
* refusal keeps the API's own code when it sent one and its message whole:
|
|
148
|
-
* on a refused publish that names the section holding template residue,
|
|
149
|
-
* which is what the model needs to fix the draft. A failed connection is
|
|
150
|
-
* structured too, so every failure the model sees has the same shape.
|
|
151
|
-
*/
|
|
152
|
-
export async function runTool(handler: () => Promise<CallToolResult>): Promise<CallToolResult> {
|
|
153
|
-
try {
|
|
154
|
-
return await handler();
|
|
155
|
-
} catch (error) {
|
|
156
|
-
if (error instanceof TaigaApiError) {
|
|
157
|
-
const code = error.code ?? CODE_BY_STATUS[error.status] ?? 'API_ERROR';
|
|
158
|
-
const said = error.apiMessage ?? `Taiga API answered ${error.status}.`;
|
|
159
|
-
// A miss says where the right IDs are (hints.ts): the API's own word is bare.
|
|
160
|
-
const worded = inProductWording(said);
|
|
161
|
-
const message = error.status === 404 ? withNotFoundHint(worded) : worded;
|
|
162
|
-
return toolError(code, message, error.details);
|
|
163
|
-
}
|
|
164
|
-
if (isNetworkFailure(error)) {
|
|
165
|
-
return toolError(
|
|
166
|
-
'NETWORK_ERROR',
|
|
167
|
-
`Could not reach the Taiga API: ${networkFailureDetail(error)}. Check TAIGA_API_URL.`
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
throw error;
|
|
171
|
-
}
|
|
172
|
-
}
|
package/src/tools/hints.ts
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The next step after the API answers 404. The API's own word is bare
|
|
5
|
-
* ("Initiative not found", "Repository link not found"), and an agent that
|
|
6
|
-
* hears only that guesses another ID, or a name. Each ID a tool takes names
|
|
7
|
-
* the tool that lists the right ones; the tool's arguments say which apply.
|
|
8
|
-
*/
|
|
9
|
-
const ITEM_ID_HINT =
|
|
10
|
-
"taiga_list_initiatives lists a product's initiatives with their IDs; a run or plan ID is not one.";
|
|
11
|
-
|
|
12
|
-
const BY_ARGUMENT: Record<string, string> = {
|
|
13
|
-
initiativeId: ITEM_ID_HINT,
|
|
14
|
-
runId: 'taiga_list_runs lists the runs, of one product or of every product the key can see.',
|
|
15
|
-
planId: "taiga_get_plan gives the initiative's active plan and its ID.",
|
|
16
|
-
repositoryId:
|
|
17
|
-
"taiga_list_product_repositories lists the product's repository links; a linkable repository id is not a link ID.",
|
|
18
|
-
replacesRepositoryId: "taiga_list_product_repositories lists the product's repository links.",
|
|
19
|
-
linkableRepositoryId:
|
|
20
|
-
'taiga_list_linkable_repositories lists the repositories the GitHub connection reaches, with their ids.',
|
|
21
|
-
installationId: 'taiga_list_linkable_repositories lists the connected GitHub accounts.',
|
|
22
|
-
jobId: 'A job ID is what the tool that started the job returned (taiga_request_initiatives, taiga_start_import).',
|
|
23
|
-
factoryId: 'taiga_list_factories lists the factories the key can see.',
|
|
24
|
-
findingId: "taiga_list_findings lists a product's findings.",
|
|
25
|
-
violationId: "taiga_get_policy_standing lists a product's policy violations.",
|
|
26
|
-
environmentId: "taiga_list_environments lists the product's environments with their IDs; a product ID is not one.",
|
|
27
|
-
knowledgeId: 'taiga_list_knowledge lists the knowledge files.',
|
|
28
|
-
productId:
|
|
29
|
-
'taiga_list_products lists the products the key can see; a name is not an ID (taiga_get_product takes a slug).',
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/** A hint chosen from what the API said, for a tool whose 404 can mean two things. */
|
|
33
|
-
type Hint = (said: string) => string | undefined;
|
|
34
|
-
|
|
35
|
-
/** Where an argument means something else on one tool, or the tool's 404 needs its own word. */
|
|
36
|
-
const BY_TOOL: Record<string, Hint> = {
|
|
37
|
-
taiga_resolve_action: () => 'taiga_list_action_required lists the items with their IDs.',
|
|
38
|
-
// "No active plan" is the plan's miss; anything else is the initiative's,
|
|
39
|
-
// and sending an agent that mistyped an ID to taiga_plan_again would start
|
|
40
|
-
// paid planning to fix a typo.
|
|
41
|
-
taiga_get_plan: (said) =>
|
|
42
|
-
/\bplan\b/i.test(said)
|
|
43
|
-
? 'taiga_get_initiative says whether one is being generated (planningStatus planning) or failed, and ' +
|
|
44
|
-
'taiga_plan_again asks for a new one.'
|
|
45
|
-
: ITEM_ID_HINT,
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* What the API calls the thing each ID names, as its 404 says it ("Environment
|
|
50
|
-
* not found"). A tool given a right product and a wrong environment hinted
|
|
51
|
-
* at both, and the product's hint told the agent its correct product ID was
|
|
52
|
-
* the mistake (PRO-2800): a 404 that names its thing gets that ID's hint only.
|
|
53
|
-
*/
|
|
54
|
-
const MISSING_THING: Record<string, RegExp> = {
|
|
55
|
-
initiativeId: /backlog item|initiative/i,
|
|
56
|
-
runId: /\b(build )?run\b/i,
|
|
57
|
-
planId: /\bplan\b/i,
|
|
58
|
-
repositoryId: /repository link|\blink\b/i,
|
|
59
|
-
replacesRepositoryId: /repository link|\blink\b/i,
|
|
60
|
-
linkableRepositoryId: /github repository/i,
|
|
61
|
-
installationId: /installation|account/i,
|
|
62
|
-
jobId: /\bjob\b/i,
|
|
63
|
-
factoryId: /factory|team/i,
|
|
64
|
-
findingId: /finding/i,
|
|
65
|
-
violationId: /violation/i,
|
|
66
|
-
environmentId: /environment/i,
|
|
67
|
-
knowledgeId: /knowledge|document/i,
|
|
68
|
-
productId: /\b(product|project) not found/i,
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
/** The hint for a 404 from one tool call: the tool's own, else the one for the ID the API says is missing. */
|
|
72
|
-
export function notFoundHint(tool: string, args: unknown): Hint | undefined {
|
|
73
|
-
if (BY_TOOL[tool]) return BY_TOOL[tool];
|
|
74
|
-
if (typeof args !== 'object' || args === null) return undefined;
|
|
75
|
-
const given = Object.keys(args).filter((key) => (args as Record<string, unknown>)[key] != null && BY_ARGUMENT[key]);
|
|
76
|
-
// The product last: a product the key cannot see is answered "Product not
|
|
77
|
-
// found", which already says which ID it was; the others are named first.
|
|
78
|
-
given.sort((a, b) => Number(a === 'productId') - Number(b === 'productId'));
|
|
79
|
-
if (given.length === 0) return undefined;
|
|
80
|
-
return (said) => {
|
|
81
|
-
const named = given.filter((key) => MISSING_THING[key]?.test(said));
|
|
82
|
-
// A 404 that names none of them (or a bare one) still gets them all.
|
|
83
|
-
return (named.length > 0 ? named : given).map((key) => BY_ARGUMENT[key]).join(' ');
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** The hint of the tool call in progress, read by runTool when the API answers 404. */
|
|
88
|
-
export const notFoundHints = new AsyncLocalStorage<Hint | undefined>();
|
|
89
|
-
|
|
90
|
-
/** The API's 404 message with the hint of the call in progress, if it has one. */
|
|
91
|
-
export function withNotFoundHint(said: string): string {
|
|
92
|
-
const hint = notFoundHints.getStore()?.(said);
|
|
93
|
-
return hint ? `${withStop(said)} ${hint}` : said;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** A sentence ends in a stop before the next one is added to it. */
|
|
97
|
-
export function withStop(text: string): string {
|
|
98
|
-
return /[.!?]$/.test(text.trimEnd()) ? text.trimEnd() : `${text.trimEnd()}.`;
|
|
99
|
-
}
|