@taiga-ai/mcp-server 0.4.0 → 0.5.0-dev.390
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 +393 -153
- package/package.json +2 -2
- package/src/client.test.ts +78 -16
- package/src/client.ts +124 -21
- package/src/config.test.ts +24 -0
- package/src/config.ts +25 -0
- package/src/index.ts +7 -9
- package/src/server.integration.test.ts +407 -34
- package/src/tools/documents.ts +245 -97
- package/src/tools/errors.ts +48 -0
- package/src/tools/inputs.test.ts +19 -0
- package/src/tools/inputs.ts +13 -0
- package/src/tools/products.ts +87 -73
- package/src/tools/sections.test.ts +58 -0
- package/src/tools/sections.ts +38 -0
- package/taiga-ai-mcp-server-0.5.0-dev.390.tgz +0 -0
- package/tsconfig.json +8 -5
- package/taiga-ai-mcp-server-0.4.0.tgz +0 -0
package/dist/index.js
CHANGED
|
@@ -7,6 +7,49 @@ const SERVER_VERSION = createRequire(import.meta.url)("../package.json").version
|
|
|
7
7
|
//#endregion
|
|
8
8
|
//#region src/client.ts
|
|
9
9
|
/**
|
|
10
|
+
* A non-2xx answer from the Taiga API. Carries the status and the API's own
|
|
11
|
+
* `code` and `message` so a tool can hand the model something it can act on
|
|
12
|
+
* (a 400 from the publish gate names the section that still holds template
|
|
13
|
+
* residue) instead of one opaque string.
|
|
14
|
+
*/
|
|
15
|
+
var TaigaApiError = class extends Error {
|
|
16
|
+
status;
|
|
17
|
+
code;
|
|
18
|
+
apiMessage;
|
|
19
|
+
constructor(method, path, status, body) {
|
|
20
|
+
const parsed = parseErrorBody(body);
|
|
21
|
+
super(`Taiga API ${method} ${path} failed (${status}): ${parsed.message ?? body}`);
|
|
22
|
+
this.name = "TaigaApiError";
|
|
23
|
+
this.status = status;
|
|
24
|
+
this.code = parsed.code;
|
|
25
|
+
this.apiMessage = parsed.message;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* One path segment. Every id and type a tool passes is validated at the
|
|
30
|
+
* tool boundary; this is the second wall. Encoding keeps a slash inside the
|
|
31
|
+
* segment, and the dot segments are refused outright: encodeURIComponent
|
|
32
|
+
* leaves "." and ".." as they are, and the URL parser in fetch would then
|
|
33
|
+
* collapse `/documents/../publish` into a different route.
|
|
34
|
+
*/
|
|
35
|
+
function seg(value) {
|
|
36
|
+
if (value === "" || value === "." || value === "..") throw new TypeError(`Invalid path segment: ${JSON.stringify(value)}`);
|
|
37
|
+
return encodeURIComponent(value);
|
|
38
|
+
}
|
|
39
|
+
function parseErrorBody(body) {
|
|
40
|
+
try {
|
|
41
|
+
const json = JSON.parse(body);
|
|
42
|
+
const message = typeof json.message === "string" ? json.message : void 0;
|
|
43
|
+
const details = Array.isArray(json.details) ? json.details.map((d) => [d.instancePath, d.message].filter((x) => typeof x === "string" && x).join(" ")).filter(Boolean) : [];
|
|
44
|
+
return {
|
|
45
|
+
...typeof json.code === "string" ? { code: json.code } : {},
|
|
46
|
+
...message ? { message: details.length ? `${message}: ${details.join("; ")}` : message } : {}
|
|
47
|
+
};
|
|
48
|
+
} catch {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
10
53
|
* HTTP client for the Taiga API.
|
|
11
54
|
* Used by the MCP server to proxy tool calls to the backend.
|
|
12
55
|
*/
|
|
@@ -22,17 +65,17 @@ var TaigaClient = class {
|
|
|
22
65
|
const url = `${this.baseUrl}/v1${path}`;
|
|
23
66
|
const headers = {
|
|
24
67
|
Authorization: `Bearer ${this.apiKey}`,
|
|
25
|
-
"Content-Type": "application/json",
|
|
26
68
|
"User-Agent": `taiga-ai-mcp-server/${SERVER_VERSION}`
|
|
27
69
|
};
|
|
70
|
+
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
28
71
|
const response = await fetch(url, {
|
|
29
72
|
method,
|
|
30
73
|
headers,
|
|
31
|
-
body: body ? JSON.stringify(body)
|
|
74
|
+
body: body === void 0 ? null : JSON.stringify(body)
|
|
32
75
|
});
|
|
33
76
|
if (!response.ok) {
|
|
34
77
|
const text = await response.text().catch(() => "");
|
|
35
|
-
throw new
|
|
78
|
+
throw new TaigaApiError(method, path, response.status, text);
|
|
36
79
|
}
|
|
37
80
|
return response.json();
|
|
38
81
|
}
|
|
@@ -57,7 +100,7 @@ var TaigaClient = class {
|
|
|
57
100
|
return this.request("GET", `/products${qs ? `?${qs}` : ""}`);
|
|
58
101
|
}
|
|
59
102
|
async getProduct(id) {
|
|
60
|
-
return this.request("GET", `/products/${id}`);
|
|
103
|
+
return this.request("GET", `/products/${seg(id)}`);
|
|
61
104
|
}
|
|
62
105
|
async listTenantDocuments(opts) {
|
|
63
106
|
const params = new URLSearchParams();
|
|
@@ -66,31 +109,45 @@ var TaigaClient = class {
|
|
|
66
109
|
return this.request("GET", `/tenants/current/documents${qs ? `?${qs}` : ""}`);
|
|
67
110
|
}
|
|
68
111
|
async getTenantDocument(type) {
|
|
69
|
-
return this.request("GET", `/tenants/current/documents/${type}`);
|
|
112
|
+
return this.request("GET", `/tenants/current/documents/${seg(type)}`);
|
|
113
|
+
}
|
|
114
|
+
/** The shared draft for a document type, or `{ data: null }` when none exists. */
|
|
115
|
+
async getTenantDocumentDraft(type) {
|
|
116
|
+
return this.request("GET", `/tenants/current/documents/${seg(type)}/draft`);
|
|
70
117
|
}
|
|
71
118
|
async saveTenantDocumentDraft(type, body) {
|
|
72
|
-
return this.request("PUT", `/tenants/current/documents/${type}/draft`, body);
|
|
119
|
+
return this.request("PUT", `/tenants/current/documents/${seg(type)}/draft`, body);
|
|
120
|
+
}
|
|
121
|
+
async publishTenantDocument(type) {
|
|
122
|
+
return this.request("POST", `/tenants/current/documents/${seg(type)}/publish`);
|
|
73
123
|
}
|
|
74
124
|
async listFactoryDocuments(factoryId, opts) {
|
|
75
125
|
const params = new URLSearchParams();
|
|
76
126
|
if (opts?.limit) params.set("limit", String(opts.limit));
|
|
77
127
|
const qs = params.toString();
|
|
78
|
-
return this.request("GET", `/factories/${factoryId}/documents${qs ? `?${qs}` : ""}`);
|
|
128
|
+
return this.request("GET", `/factories/${seg(factoryId)}/documents${qs ? `?${qs}` : ""}`);
|
|
79
129
|
}
|
|
80
130
|
async getFactoryDocument(factoryId, type) {
|
|
81
|
-
return this.request("GET", `/factories/${factoryId}/documents/${type}`);
|
|
131
|
+
return this.request("GET", `/factories/${seg(factoryId)}/documents/${seg(type)}`);
|
|
132
|
+
}
|
|
133
|
+
/** The shared draft for a factory document type, or `{ data: null }` when none exists. */
|
|
134
|
+
async getFactoryDocumentDraft(factoryId, type) {
|
|
135
|
+
return this.request("GET", `/factories/${seg(factoryId)}/documents/${seg(type)}/draft`);
|
|
82
136
|
}
|
|
83
137
|
async saveFactoryDocumentDraft(factoryId, type, body) {
|
|
84
|
-
return this.request("PUT", `/factories/${factoryId}/documents/${type}/draft`, body);
|
|
138
|
+
return this.request("PUT", `/factories/${seg(factoryId)}/documents/${seg(type)}/draft`, body);
|
|
139
|
+
}
|
|
140
|
+
async publishFactoryDocument(factoryId, type) {
|
|
141
|
+
return this.request("POST", `/factories/${seg(factoryId)}/documents/${seg(type)}/publish`);
|
|
85
142
|
}
|
|
86
143
|
async listProductDocuments(productId, opts) {
|
|
87
144
|
const params = new URLSearchParams();
|
|
88
145
|
if (opts?.limit) params.set("limit", String(opts.limit));
|
|
89
146
|
const qs = params.toString();
|
|
90
|
-
return this.request("GET", `/products/${productId}/documents${qs ? `?${qs}` : ""}`);
|
|
147
|
+
return this.request("GET", `/products/${seg(productId)}/documents${qs ? `?${qs}` : ""}`);
|
|
91
148
|
}
|
|
92
149
|
async getProductDocument(productId, type) {
|
|
93
|
-
return this.request("GET", `/products/${productId}/documents/${type}`);
|
|
150
|
+
return this.request("GET", `/products/${seg(productId)}/documents/${seg(type)}`);
|
|
94
151
|
}
|
|
95
152
|
/**
|
|
96
153
|
* Fetch the full context for a product in parallel: product metadata,
|
|
@@ -98,7 +155,7 @@ var TaigaClient = class {
|
|
|
98
155
|
* Returns a structured bundle optimized for coding agent consumption.
|
|
99
156
|
*/
|
|
100
157
|
async getProductContext(productId) {
|
|
101
|
-
const product = await this.request("GET", `/products/${productId}`);
|
|
158
|
+
const product = await this.request("GET", `/products/${seg(productId)}`);
|
|
102
159
|
const factoryId = product.data.factoryId;
|
|
103
160
|
const [productDocs, factoryDocs] = await Promise.all([this.listProductDocuments(productId, { limit: 20 }).catch(() => ({ data: [] })), factoryId ? this.listFactoryDocuments(factoryId, { limit: 20 }).catch(() => ({ data: [] })) : Promise.resolve({ data: [] })]);
|
|
104
161
|
const keyProductDocTypes = [
|
|
@@ -151,13 +208,13 @@ var TaigaClient = class {
|
|
|
151
208
|
const params = new URLSearchParams();
|
|
152
209
|
if (opts?.limit) params.set("limit", String(opts.limit));
|
|
153
210
|
const qs = params.toString();
|
|
154
|
-
return this.request("GET", `/products/${productId}/backlog${qs ? `?${qs}` : ""}`);
|
|
211
|
+
return this.request("GET", `/products/${seg(productId)}/backlog${qs ? `?${qs}` : ""}`);
|
|
155
212
|
}
|
|
156
213
|
async getBacklogItem(itemId) {
|
|
157
|
-
return this.request("GET", `/backlog/${itemId}`);
|
|
214
|
+
return this.request("GET", `/backlog/${seg(itemId)}`);
|
|
158
215
|
}
|
|
159
216
|
async listEnvironments(productId) {
|
|
160
|
-
return this.request("GET", `/products/${productId}/environments`);
|
|
217
|
+
return this.request("GET", `/products/${seg(productId)}/environments`);
|
|
161
218
|
}
|
|
162
219
|
async listFactories(opts) {
|
|
163
220
|
const params = new URLSearchParams();
|
|
@@ -167,6 +224,82 @@ var TaigaClient = class {
|
|
|
167
224
|
}
|
|
168
225
|
};
|
|
169
226
|
//#endregion
|
|
227
|
+
//#region src/config.ts
|
|
228
|
+
const envSchema = z.object({
|
|
229
|
+
TAIGA_API_KEY: z.string().min(1),
|
|
230
|
+
TAIGA_API_URL: z.string().default("https://api.taiga.app")
|
|
231
|
+
});
|
|
232
|
+
/** Null when TAIGA_API_KEY is missing or empty: it is the one setting with no default. */
|
|
233
|
+
function readConfig() {
|
|
234
|
+
const parsed = envSchema.safeParse(process.env);
|
|
235
|
+
if (!parsed.success) return null;
|
|
236
|
+
return {
|
|
237
|
+
apiKey: parsed.data.TAIGA_API_KEY,
|
|
238
|
+
apiUrl: parsed.data.TAIGA_API_URL
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
//#endregion
|
|
242
|
+
//#region src/tools/errors.ts
|
|
243
|
+
/** A successful tool result: one JSON text block the model reads. */
|
|
244
|
+
function toolResult(value) {
|
|
245
|
+
return { content: [{
|
|
246
|
+
type: "text",
|
|
247
|
+
text: JSON.stringify(value, null, 2)
|
|
248
|
+
}] };
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* A failed tool result the model can branch on: a stable code and a message
|
|
252
|
+
* that says what to do next. Returned, not thrown, so the client sees
|
|
253
|
+
* `isError` rather than a protocol error.
|
|
254
|
+
*/
|
|
255
|
+
function toolError(error, message) {
|
|
256
|
+
return {
|
|
257
|
+
content: [{
|
|
258
|
+
type: "text",
|
|
259
|
+
text: JSON.stringify({
|
|
260
|
+
error,
|
|
261
|
+
message
|
|
262
|
+
})
|
|
263
|
+
}],
|
|
264
|
+
isError: true
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const CODE_BY_STATUS = {
|
|
268
|
+
400: "VALIDATION_FAILED",
|
|
269
|
+
401: "UNAUTHENTICATED",
|
|
270
|
+
403: "FORBIDDEN",
|
|
271
|
+
404: "NOT_FOUND",
|
|
272
|
+
409: "CONFLICT",
|
|
273
|
+
429: "RATE_LIMITED"
|
|
274
|
+
};
|
|
275
|
+
/**
|
|
276
|
+
* Run a tool handler and turn a failure into a structured error. An API
|
|
277
|
+
* refusal keeps the API's own code when it sent one and its message whole:
|
|
278
|
+
* on a refused publish that names the section holding template residue,
|
|
279
|
+
* which is what the model needs to fix the draft. A failed connection is
|
|
280
|
+
* structured too, so every failure the model sees has the same shape.
|
|
281
|
+
*/
|
|
282
|
+
async function runTool(handler) {
|
|
283
|
+
try {
|
|
284
|
+
return await handler();
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (error instanceof TaigaApiError) return toolError(error.code ?? CODE_BY_STATUS[error.status] ?? "API_ERROR", error.apiMessage ?? `Taiga API answered ${error.status}.`);
|
|
287
|
+
if (error instanceof TypeError) return toolError("NETWORK_ERROR", `Could not reach the Taiga API: ${error.message}. Check TAIGA_API_URL.`);
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
//#endregion
|
|
292
|
+
//#region src/tools/inputs.ts
|
|
293
|
+
/**
|
|
294
|
+
* A document type as the API names one: policy_security, factory_instructions,
|
|
295
|
+
* specification. An identifier, never a path: the value is interpolated into
|
|
296
|
+
* the route, and a type that carried a slash would reach a different
|
|
297
|
+
* document's route than the tool's description promises. The API's own enum
|
|
298
|
+
* stays the source of truth for which identifiers exist (it answers 400 with
|
|
299
|
+
* the valid list); this only rules out what could not be a type at all.
|
|
300
|
+
*/
|
|
301
|
+
const documentType = z.string().regex(/^[a-z][a-z0-9_]{0,63}$/, "A document type is a lowercase identifier such as policy_security");
|
|
302
|
+
//#endregion
|
|
170
303
|
//#region src/tools/summarize.ts
|
|
171
304
|
/**
|
|
172
305
|
* Strip heavy content from list responses to avoid context window bloat.
|
|
@@ -196,150 +329,257 @@ function summarizeList(items) {
|
|
|
196
329
|
}
|
|
197
330
|
//#endregion
|
|
198
331
|
//#region src/tools/products.ts
|
|
332
|
+
const READ_ONLY$1 = {
|
|
333
|
+
readOnlyHint: true,
|
|
334
|
+
destructiveHint: false,
|
|
335
|
+
idempotentHint: true,
|
|
336
|
+
openWorldHint: false
|
|
337
|
+
};
|
|
199
338
|
function registerProductTools(server, client) {
|
|
200
|
-
if (client.hasCapability("factories:read")) server.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}] };
|
|
209
|
-
});
|
|
339
|
+
if (client.hasCapability("factories:read")) server.registerTool("taiga_list_factories", {
|
|
340
|
+
title: "List factories",
|
|
341
|
+
description: "List all factories in the organization. A factory is the group that owns a set of products. Use this to find factory IDs for reading factory instructions. Factory instructions carry the factory's standing guidance: domain context, tech preferences, and the conventions its products follow.",
|
|
342
|
+
inputSchema: { limit: z.number().min(1).max(100).optional().describe("Max results (default 25)") },
|
|
343
|
+
annotations: READ_ONLY$1
|
|
344
|
+
}, (params) => runTool(async () => {
|
|
345
|
+
return toolResult((await client.listFactories(params)).data);
|
|
346
|
+
}));
|
|
210
347
|
if (client.hasCapability("products:read")) {
|
|
211
|
-
server.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
"
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}, async (
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
const result = await client.getProductDocument(params.productId, params.type);
|
|
250
|
-
return { content: [{
|
|
251
|
-
type: "text",
|
|
252
|
-
text: JSON.stringify(result.data, null, 2)
|
|
253
|
-
}] };
|
|
254
|
-
});
|
|
348
|
+
server.registerTool("taiga_list_products", {
|
|
349
|
+
title: "List products",
|
|
350
|
+
description: "List products in the organization. Use this to find the product you are working on so you can read its specification, architecture, and threat model. Can filter by factory; a status filter also exists but no longer distinguishes anything (see its description).",
|
|
351
|
+
inputSchema: {
|
|
352
|
+
factoryId: z.string().uuid().optional().describe("Filter by factory ID -- get factory IDs from taiga_list_factories"),
|
|
353
|
+
status: z.enum([
|
|
354
|
+
"draft",
|
|
355
|
+
"active",
|
|
356
|
+
"archived"
|
|
357
|
+
]).optional().describe("Filter by status. Taiga no longer surfaces product status in its UI (PRO-543), so every product is currently \"draft\" and filtering by \"active\" or \"archived\" returns nothing. Omit this unless you are working with data set through the API directly."),
|
|
358
|
+
limit: z.number().min(1).max(100).optional().describe("Max results (default 25)")
|
|
359
|
+
},
|
|
360
|
+
annotations: READ_ONLY$1
|
|
361
|
+
}, (params) => runTool(async () => {
|
|
362
|
+
return toolResult((await client.listProducts(params)).data);
|
|
363
|
+
}));
|
|
364
|
+
server.registerTool("taiga_list_product_documents", {
|
|
365
|
+
title: "List product documents",
|
|
366
|
+
description: "List documents for a specific product (names and types only). Product documents are generated during intake: specification (requirements), architecture (system design), technology decisions, data flow, privacy assessment, threat model (security risks), risk register, user flow, and service blueprint. Use taiga_get_product_document to read the full content.",
|
|
367
|
+
inputSchema: {
|
|
368
|
+
productId: z.string().uuid().describe("Product ID -- get from taiga_list_products"),
|
|
369
|
+
limit: z.number().min(1).max(100).optional().describe("Max results (default 25)")
|
|
370
|
+
},
|
|
371
|
+
annotations: READ_ONLY$1
|
|
372
|
+
}, (params) => runTool(async () => {
|
|
373
|
+
return toolResult(summarizeList((await client.listProductDocuments(params.productId, { limit: params.limit })).data));
|
|
374
|
+
}));
|
|
375
|
+
server.registerTool("taiga_get_product_document", {
|
|
376
|
+
title: "Get product document",
|
|
377
|
+
description: "Get the full content of a specific product document. Use this when you need to understand what to build (specification), how the system is designed (architecture), what security risks exist (threat model), or what technology choices were made (technology_decisions). Types: specification, architecture, technology_decisions, data_flow, privacy_assessment, threat_model, risk_register, user_flow, service_blueprint, ui_direction, codebase_snapshot, and product_instructions (the product's standing instructions).",
|
|
378
|
+
inputSchema: {
|
|
379
|
+
productId: z.string().uuid().describe("Product ID -- get from taiga_list_products"),
|
|
380
|
+
type: documentType.describe("Document type -- get available types from taiga_list_product_documents. Common: specification, architecture, threat_model, technology_decisions")
|
|
381
|
+
},
|
|
382
|
+
annotations: READ_ONLY$1
|
|
383
|
+
}, (params) => runTool(async () => {
|
|
384
|
+
return toolResult((await client.getProductDocument(params.productId, params.type)).data);
|
|
385
|
+
}));
|
|
255
386
|
}
|
|
256
387
|
}
|
|
257
388
|
//#endregion
|
|
389
|
+
//#region src/tools/sections.ts
|
|
390
|
+
function isRecord(value) {
|
|
391
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
392
|
+
}
|
|
393
|
+
/** The `sections` map of a document, or null when the document is not section-shaped. */
|
|
394
|
+
function sectionsOf(data) {
|
|
395
|
+
return isRecord(data.sections) ? data.sections : null;
|
|
396
|
+
}
|
|
397
|
+
/** The text of one section, or null when the section does not exist. */
|
|
398
|
+
function sectionContent(sections, sectionId) {
|
|
399
|
+
const section = sections[sectionId];
|
|
400
|
+
if (section === void 0 || section === null) return null;
|
|
401
|
+
if (typeof section === "string") return section;
|
|
402
|
+
return isRecord(section) && typeof section.content === "string" ? section.content : "";
|
|
403
|
+
}
|
|
404
|
+
/** The map with one section's text replaced (or the section added); every other section untouched. */
|
|
405
|
+
function applySectionEdit(sections, sectionId, content) {
|
|
406
|
+
const prior = sections[sectionId];
|
|
407
|
+
return {
|
|
408
|
+
...sections,
|
|
409
|
+
[sectionId]: isRecord(prior) ? {
|
|
410
|
+
...prior,
|
|
411
|
+
content
|
|
412
|
+
} : { content }
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
//#endregion
|
|
258
416
|
//#region src/tools/documents.ts
|
|
417
|
+
const READ_ONLY = {
|
|
418
|
+
readOnlyHint: true,
|
|
419
|
+
destructiveHint: false,
|
|
420
|
+
idempotentHint: true,
|
|
421
|
+
openWorldHint: false
|
|
422
|
+
};
|
|
423
|
+
const DRAFT_WRITE = {
|
|
424
|
+
readOnlyHint: false,
|
|
425
|
+
destructiveHint: false,
|
|
426
|
+
idempotentHint: true,
|
|
427
|
+
openWorldHint: false
|
|
428
|
+
};
|
|
429
|
+
const PUBLISH = {
|
|
430
|
+
readOnlyHint: false,
|
|
431
|
+
destructiveHint: true,
|
|
432
|
+
idempotentHint: false,
|
|
433
|
+
openWorldHint: false
|
|
434
|
+
};
|
|
435
|
+
const sectionId = z.string().min(1).describe("Section id -- the keys of `data.sections` in taiga_get_document, e.g. summary, scope, apiDesign. An id that does not exist yet adds a section.");
|
|
436
|
+
const sectionContentInput = z.string().describe("The full new Markdown text of the section; it replaces the old text");
|
|
437
|
+
const tenantDocumentType = documentType.describe("Document type (e.g. policy_security, policy_sdlc) -- see taiga_list_documents");
|
|
438
|
+
/** Everything the model needs back from a write, without the content it already holds. */
|
|
439
|
+
function documentSummary(doc) {
|
|
440
|
+
const { data, ...rest } = doc;
|
|
441
|
+
return {
|
|
442
|
+
...rest,
|
|
443
|
+
sections: Object.keys(sectionsOf(data) ?? {})
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* The document a section edit starts from: the draft when one exists, else
|
|
448
|
+
* the published version, else (instructions documents start empty and
|
|
449
|
+
* unpublished, PRO-502) an empty section map. The API seeds the draft the
|
|
450
|
+
* same way on the first save, so the merge happens on what it would hold.
|
|
451
|
+
*/
|
|
452
|
+
async function editBase(scope) {
|
|
453
|
+
const draft = (await scope.getDraft()).data;
|
|
454
|
+
if (draft) return draft.data;
|
|
455
|
+
try {
|
|
456
|
+
return (await scope.getPublished()).data.data;
|
|
457
|
+
} catch (error) {
|
|
458
|
+
if (error instanceof TaigaApiError && error.status === 404) return { sections: {} };
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Edit one section of a document's draft; the edit keeps every other section
|
|
464
|
+
* and the section's own metadata, see sections.ts.
|
|
465
|
+
*/
|
|
466
|
+
async function updateSection(scope, type, id, content) {
|
|
467
|
+
const base = await editBase(scope);
|
|
468
|
+
const sections = sectionsOf(base);
|
|
469
|
+
if (!sections) return toolError("UNSUPPORTED_DOCUMENT", `Document ${type} is not made of sections and cannot be edited section by section. Edit it in the Taiga app.`);
|
|
470
|
+
const saved = await scope.saveDraft({
|
|
471
|
+
...base,
|
|
472
|
+
sections: applySectionEdit(sections, id, content)
|
|
473
|
+
});
|
|
474
|
+
return toolResult({
|
|
475
|
+
...documentSummary(saved.data),
|
|
476
|
+
section: {
|
|
477
|
+
id,
|
|
478
|
+
content: sectionContent(sectionsOf(saved.data.data) ?? {}, id)
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
/** Publish the draft; the API answers 404 when there is none and 400 when the residue gate refuses it. */
|
|
483
|
+
async function publishDraft(scope) {
|
|
484
|
+
return toolResult(documentSummary((await scope.publish()).data));
|
|
485
|
+
}
|
|
259
486
|
function registerDocumentTools(server, client) {
|
|
260
487
|
if (client.hasCapability("tenant:documents:read")) {
|
|
261
|
-
server.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}, async (
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
488
|
+
server.registerTool("taiga_list_documents", {
|
|
489
|
+
title: "List company documents",
|
|
490
|
+
description: "List company-level policy documents (names and types only). Policies define security, data privacy, SDLC, operations, architecture, and compliance rules that apply across all factories and products. Use taiga_get_document with the document type to read the full policy content.",
|
|
491
|
+
inputSchema: { limit: z.number().min(1).max(100).optional().describe("Max results (default 25)") },
|
|
492
|
+
annotations: READ_ONLY
|
|
493
|
+
}, (params) => runTool(async () => {
|
|
494
|
+
return toolResult(summarizeList((await client.listTenantDocuments(params)).data));
|
|
495
|
+
}));
|
|
496
|
+
server.registerTool("taiga_get_document", {
|
|
497
|
+
title: "Get company document",
|
|
498
|
+
description: "Get the full content of a company policy document. Use this when you need to understand security requirements, data handling rules, architecture standards, or compliance controls that apply to your code. Types: policy_security, policy_data, policy_sdlc, policy_ops, policy_tic, policy_risk, policy_architecture, policy_supply_chain, policy_cloud_governance, policy_acceptable_use, policy_ip.",
|
|
499
|
+
inputSchema: { type: documentType.describe("Document type -- get available types from taiga_list_documents. Common: policy_security, policy_data, policy_sdlc, policy_architecture") },
|
|
500
|
+
annotations: READ_ONLY
|
|
501
|
+
}, (params) => runTool(async () => {
|
|
502
|
+
return toolResult((await client.getTenantDocument(params.type)).data);
|
|
503
|
+
}));
|
|
504
|
+
}
|
|
505
|
+
if (client.hasCapability("tenant:documents:read") && client.hasCapability("tenant:documents:write")) {
|
|
506
|
+
const tenantScope = (type) => ({
|
|
507
|
+
getDraft: () => client.getTenantDocumentDraft(type),
|
|
508
|
+
getPublished: () => client.getTenantDocument(type),
|
|
509
|
+
saveDraft: (data) => client.saveTenantDocumentDraft(type, { data }),
|
|
510
|
+
publish: () => client.publishTenantDocument(type)
|
|
280
511
|
});
|
|
512
|
+
server.registerTool("taiga_update_document", {
|
|
513
|
+
title: "Update a section of a company document draft",
|
|
514
|
+
description: "Replace the text of one section of a company policy document, in its draft. Every other section stays as it is. Does not publish: people and agents keep reading the published version until taiga_publish_document is called. Read the document with taiga_get_document first to see its sections and their current text. Use this when you notice a policy is outdated or needs corrections while reviewing code.",
|
|
515
|
+
inputSchema: {
|
|
516
|
+
type: tenantDocumentType,
|
|
517
|
+
sectionId,
|
|
518
|
+
content: sectionContentInput
|
|
519
|
+
},
|
|
520
|
+
annotations: DRAFT_WRITE
|
|
521
|
+
}, (params) => runTool(() => updateSection(tenantScope(params.type), params.type, params.sectionId, params.content)));
|
|
522
|
+
server.registerTool("taiga_publish_document", {
|
|
523
|
+
title: "Publish a company document draft",
|
|
524
|
+
description: "Publish a company policy document's draft as its next version, the one every agent and person reads from then on. Only publish when the person asked for it; save edits with taiga_update_document first. A refused publish names what to fix in the draft.",
|
|
525
|
+
inputSchema: { type: tenantDocumentType },
|
|
526
|
+
annotations: PUBLISH
|
|
527
|
+
}, (params) => runTool(() => publishDraft(tenantScope(params.type))));
|
|
281
528
|
}
|
|
282
|
-
if (client.hasCapability("tenant:documents:write")) server.tool("taiga_update_document", "Update the draft content of a company policy document. Only updates the draft -- does not publish. Use this when you notice a policy is outdated or needs corrections while reviewing code.", {
|
|
283
|
-
type: z.string().describe("Document type (e.g. policy_security, policy_sdlc)"),
|
|
284
|
-
data: z.record(z.string(), z.unknown()).describe("Document content to save as JSON")
|
|
285
|
-
}, {
|
|
286
|
-
readOnlyHint: false,
|
|
287
|
-
destructiveHint: false,
|
|
288
|
-
idempotentHint: true,
|
|
289
|
-
openWorldHint: false
|
|
290
|
-
}, async (params) => {
|
|
291
|
-
const { type, ...body } = params;
|
|
292
|
-
const result = await client.saveTenantDocumentDraft(type, body);
|
|
293
|
-
return { content: [{
|
|
294
|
-
type: "text",
|
|
295
|
-
text: JSON.stringify(result.data, null, 2)
|
|
296
|
-
}] };
|
|
297
|
-
});
|
|
298
529
|
if (client.hasCapability("factories:read")) {
|
|
299
|
-
server.
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}, async (
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
530
|
+
server.registerTool("taiga_list_factory_documents", {
|
|
531
|
+
title: "List factory documents",
|
|
532
|
+
description: "List documents for a specific factory (names and types only). A factory has one document: its instructions, the factory's free-form standing guidance (domain context, tech preferences, conventions) that applies to every product it owns. Use taiga_get_factory_document to read the full content.",
|
|
533
|
+
inputSchema: {
|
|
534
|
+
factoryId: z.string().uuid().describe("Factory ID -- get from taiga_list_factories"),
|
|
535
|
+
limit: z.number().min(1).max(100).optional().describe("Max results (default 25)")
|
|
536
|
+
},
|
|
537
|
+
annotations: READ_ONLY
|
|
538
|
+
}, (params) => runTool(async () => {
|
|
539
|
+
return toolResult(summarizeList((await client.listFactoryDocuments(params.factoryId, { limit: params.limit })).data));
|
|
540
|
+
}));
|
|
541
|
+
server.registerTool("taiga_get_factory_document", {
|
|
542
|
+
title: "Get factory instructions",
|
|
543
|
+
description: "Get the full content of a factory's instructions. Use this when you need the domain context, tech preferences, or conventions the factory expects every product it owns to follow.",
|
|
544
|
+
inputSchema: {
|
|
545
|
+
factoryId: z.string().uuid().describe("Factory ID -- get from taiga_list_factories"),
|
|
546
|
+
type: documentType.describe("Document type -- get available types from taiga_list_factory_documents. The factory instructions document is `factory_instructions`.")
|
|
547
|
+
},
|
|
548
|
+
annotations: READ_ONLY
|
|
549
|
+
}, (params) => runTool(async () => {
|
|
550
|
+
return toolResult((await client.getFactoryDocument(params.factoryId, params.type)).data);
|
|
551
|
+
}));
|
|
552
|
+
}
|
|
553
|
+
if (client.hasCapability("factories:read") && client.hasCapability("factories:documents:write")) {
|
|
554
|
+
const factoryScope = (factoryId, type) => ({
|
|
555
|
+
getDraft: () => client.getFactoryDocumentDraft(factoryId, type),
|
|
556
|
+
getPublished: () => client.getFactoryDocument(factoryId, type),
|
|
557
|
+
saveDraft: (data) => client.saveFactoryDocumentDraft(factoryId, type, { data }),
|
|
558
|
+
publish: () => client.publishFactoryDocument(factoryId, type)
|
|
324
559
|
});
|
|
560
|
+
const factoryId = z.string().uuid().describe("Factory ID -- get from taiga_list_factories");
|
|
561
|
+
const factoryDocumentType = documentType.describe("Document type (`factory_instructions`)");
|
|
562
|
+
server.registerTool("taiga_update_factory_document", {
|
|
563
|
+
title: "Update a section of factory instructions",
|
|
564
|
+
description: "Replace the text of one section of a factory's instructions, in its draft. The instructions document has one section, `instructions`. Does not publish: products keep following the published version until taiga_publish_factory_document is called. Read the current text with taiga_get_factory_document first. Use this when the factory conventions, domain context or tech preferences need updating while working on the codebase.",
|
|
565
|
+
inputSchema: {
|
|
566
|
+
factoryId,
|
|
567
|
+
type: factoryDocumentType,
|
|
568
|
+
sectionId: sectionId.default("instructions"),
|
|
569
|
+
content: sectionContentInput
|
|
570
|
+
},
|
|
571
|
+
annotations: DRAFT_WRITE
|
|
572
|
+
}, (params) => runTool(() => updateSection(factoryScope(params.factoryId, params.type), params.type, params.sectionId, params.content)));
|
|
573
|
+
server.registerTool("taiga_publish_factory_document", {
|
|
574
|
+
title: "Publish factory instructions draft",
|
|
575
|
+
description: "Publish a factory's instructions draft as the next version every product of the factory follows. Only publish when the person asked for it; save edits with taiga_update_factory_document first.",
|
|
576
|
+
inputSchema: {
|
|
577
|
+
factoryId,
|
|
578
|
+
type: factoryDocumentType
|
|
579
|
+
},
|
|
580
|
+
annotations: PUBLISH
|
|
581
|
+
}, (params) => runTool(() => publishDraft(factoryScope(params.factoryId, params.type))));
|
|
325
582
|
}
|
|
326
|
-
if (client.hasCapability("factories:read") && client.hasCapability("tenant:documents:write")) server.tool("taiga_update_factory_document", "Update the draft of a factory's instructions. Only updates the draft -- does not publish. Use this when you notice the factory conventions, domain context, or tech preferences need updating while working on the codebase.", {
|
|
327
|
-
factoryId: z.string().uuid().describe("Factory ID -- get from taiga_list_factories"),
|
|
328
|
-
type: z.string().describe("Document type (`factory_instructions`)"),
|
|
329
|
-
data: z.record(z.string(), z.unknown()).describe("Document content to save as JSON")
|
|
330
|
-
}, {
|
|
331
|
-
readOnlyHint: false,
|
|
332
|
-
destructiveHint: false,
|
|
333
|
-
idempotentHint: true,
|
|
334
|
-
openWorldHint: false
|
|
335
|
-
}, async (params) => {
|
|
336
|
-
const { factoryId, type, ...body } = params;
|
|
337
|
-
const result = await client.saveFactoryDocumentDraft(factoryId, type, body);
|
|
338
|
-
return { content: [{
|
|
339
|
-
type: "text",
|
|
340
|
-
text: JSON.stringify(result.data, null, 2)
|
|
341
|
-
}] };
|
|
342
|
-
});
|
|
343
583
|
}
|
|
344
584
|
//#endregion
|
|
345
585
|
//#region src/tools/index.ts
|
|
@@ -357,17 +597,16 @@ function registerAllTools(server, client) {
|
|
|
357
597
|
}
|
|
358
598
|
//#endregion
|
|
359
599
|
//#region src/index.ts
|
|
360
|
-
const DEFAULT_API_URL = "https://api.taiga.app";
|
|
361
600
|
async function main() {
|
|
362
|
-
const
|
|
363
|
-
if (!
|
|
601
|
+
const config = readConfig();
|
|
602
|
+
if (!config) {
|
|
364
603
|
console.error("Error: TAIGA_API_KEY environment variable is required.");
|
|
365
604
|
console.error("Create an API key at https://app.taiga.app/settings/api-keys");
|
|
366
605
|
process.exit(1);
|
|
367
606
|
}
|
|
368
607
|
const client = new TaigaClient({
|
|
369
|
-
baseUrl:
|
|
370
|
-
apiKey
|
|
608
|
+
baseUrl: config.apiUrl,
|
|
609
|
+
apiKey: config.apiKey
|
|
371
610
|
});
|
|
372
611
|
const introspection = await client.introspect().catch((error) => {
|
|
373
612
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -398,8 +637,9 @@ async function main() {
|
|
|
398
637
|
"For product-specific work, use taiga_list_products to find the product,",
|
|
399
638
|
"then read its specification and architecture.",
|
|
400
639
|
"",
|
|
401
|
-
"When you notice a policy or factory instructions are outdated,
|
|
402
|
-
"
|
|
640
|
+
"When you notice a policy or factory instructions are outdated, fix the section with",
|
|
641
|
+
"taiga_update_document or taiga_update_factory_document. That saves a draft nobody follows yet;",
|
|
642
|
+
"publish it with taiga_publish_document or taiga_publish_factory_document only when the person asks.",
|
|
403
643
|
"This keeps the knowledge base accurate for everyone."
|
|
404
644
|
].join("\n") });
|
|
405
645
|
registerAllTools(server, client);
|