@taiga-ai/mcp-server 0.4.0-rc.345 → 0.5.0-dev.388
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.388.tgz +0 -0
- package/tsconfig.json +8 -5
- package/taiga-ai-mcp-server-0.4.0-rc.345.tgz +0 -0
package/src/tools/documents.ts
CHANGED
|
@@ -1,148 +1,296 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import type
|
|
3
|
+
import { TaigaApiError, type TaigaClient, type TaigaDocument } from '../client.js';
|
|
4
|
+
import { runTool, toolError, toolResult } from './errors.js';
|
|
5
|
+
import { applySectionEdit, sectionContent, sectionsOf } from './sections.js';
|
|
6
|
+
import { documentType } from './inputs.js';
|
|
4
7
|
import { summarizeList } from './summarize.js';
|
|
5
8
|
|
|
9
|
+
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;
|
|
10
|
+
const DRAFT_WRITE = {
|
|
11
|
+
readOnlyHint: false,
|
|
12
|
+
destructiveHint: false,
|
|
13
|
+
idempotentHint: true,
|
|
14
|
+
openWorldHint: false,
|
|
15
|
+
} as const;
|
|
16
|
+
// Publishing replaces the version every agent and person reads, and there is
|
|
17
|
+
// no unpublish: destructive so a host that confirms destructive tools asks
|
|
18
|
+
// first. Not idempotent: a second call publishes the same draft once more.
|
|
19
|
+
const PUBLISH = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false } as const;
|
|
20
|
+
|
|
21
|
+
const sectionId = z
|
|
22
|
+
.string()
|
|
23
|
+
.min(1)
|
|
24
|
+
.describe(
|
|
25
|
+
'Section id -- the keys of `data.sections` in taiga_get_document, e.g. summary, scope, apiDesign. ' +
|
|
26
|
+
'An id that does not exist yet adds a section.'
|
|
27
|
+
);
|
|
28
|
+
const sectionContentInput = z.string().describe('The full new Markdown text of the section; it replaces the old text');
|
|
29
|
+
const tenantDocumentType = documentType.describe(
|
|
30
|
+
'Document type (e.g. policy_security, policy_sdlc) -- see taiga_list_documents'
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The read, save and publish calls for one document scope. The tools are the
|
|
35
|
+
* same for a company policy and for factory instructions; only the routes and
|
|
36
|
+
* the capability gate differ.
|
|
37
|
+
*/
|
|
38
|
+
interface DocumentScope {
|
|
39
|
+
getDraft(): Promise<{ data: TaigaDocument | null }>;
|
|
40
|
+
getPublished(): Promise<{ data: TaigaDocument }>;
|
|
41
|
+
saveDraft(data: Record<string, unknown>): Promise<{ data: TaigaDocument }>;
|
|
42
|
+
publish(): Promise<{ data: TaigaDocument }>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Everything the model needs back from a write, without the content it already holds. */
|
|
46
|
+
function documentSummary(doc: TaigaDocument) {
|
|
47
|
+
const { data, ...rest } = doc;
|
|
48
|
+
return { ...rest, sections: Object.keys(sectionsOf(data) ?? {}) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The document a section edit starts from: the draft when one exists, else
|
|
53
|
+
* the published version, else (instructions documents start empty and
|
|
54
|
+
* unpublished, PRO-502) an empty section map. The API seeds the draft the
|
|
55
|
+
* same way on the first save, so the merge happens on what it would hold.
|
|
56
|
+
*/
|
|
57
|
+
async function editBase(scope: DocumentScope): Promise<Record<string, unknown>> {
|
|
58
|
+
const draft = (await scope.getDraft()).data;
|
|
59
|
+
if (draft) return draft.data;
|
|
60
|
+
try {
|
|
61
|
+
return (await scope.getPublished()).data.data;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error instanceof TaigaApiError && error.status === 404) return { sections: {} };
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Edit one section of a document's draft; the edit keeps every other section
|
|
70
|
+
* and the section's own metadata, see sections.ts.
|
|
71
|
+
*/
|
|
72
|
+
async function updateSection(scope: DocumentScope, type: string, id: string, content: string) {
|
|
73
|
+
const base = await editBase(scope);
|
|
74
|
+
const sections = sectionsOf(base);
|
|
75
|
+
if (!sections) {
|
|
76
|
+
return toolError(
|
|
77
|
+
'UNSUPPORTED_DOCUMENT',
|
|
78
|
+
`Document ${type} is not made of sections and cannot be edited section by section. Edit it in the Taiga app.`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const saved = await scope.saveDraft({ ...base, sections: applySectionEdit(sections, id, content) });
|
|
82
|
+
return toolResult({
|
|
83
|
+
...documentSummary(saved.data),
|
|
84
|
+
section: { id, content: sectionContent(sectionsOf(saved.data.data) ?? {}, id) },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Publish the draft; the API answers 404 when there is none and 400 when the residue gate refuses it. */
|
|
89
|
+
async function publishDraft(scope: DocumentScope) {
|
|
90
|
+
const published = await scope.publish();
|
|
91
|
+
return toolResult(documentSummary(published.data));
|
|
92
|
+
}
|
|
93
|
+
|
|
6
94
|
export function registerDocumentTools(server: McpServer, client: TaigaClient) {
|
|
7
95
|
// ── Company-level policy documents ──────────────────────────────────
|
|
8
96
|
|
|
9
97
|
if (client.hasCapability('tenant:documents:read')) {
|
|
10
|
-
server.
|
|
98
|
+
server.registerTool(
|
|
11
99
|
'taiga_list_documents',
|
|
12
|
-
'List company-level policy documents (names and types only). ' +
|
|
13
|
-
'Policies define security, data privacy, SDLC, operations, architecture, and compliance rules ' +
|
|
14
|
-
'that apply across all factories and products. ' +
|
|
15
|
-
'Use taiga_get_document with the document type to read the full policy content.',
|
|
16
100
|
{
|
|
17
|
-
|
|
101
|
+
title: 'List company documents',
|
|
102
|
+
description:
|
|
103
|
+
'List company-level policy documents (names and types only). ' +
|
|
104
|
+
'Policies define security, data privacy, SDLC, operations, architecture, and compliance rules ' +
|
|
105
|
+
'that apply across all factories and products. ' +
|
|
106
|
+
'Use taiga_get_document with the document type to read the full policy content.',
|
|
107
|
+
inputSchema: {
|
|
108
|
+
limit: z.number().min(1).max(100).optional().describe('Max results (default 25)'),
|
|
109
|
+
},
|
|
110
|
+
annotations: READ_ONLY,
|
|
18
111
|
},
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
};
|
|
26
|
-
}
|
|
112
|
+
(params) =>
|
|
113
|
+
runTool(async () => {
|
|
114
|
+
const result = await client.listTenantDocuments(params);
|
|
115
|
+
const summaries = summarizeList(result.data as Record<string, unknown>[]);
|
|
116
|
+
return toolResult(summaries);
|
|
117
|
+
})
|
|
27
118
|
);
|
|
28
119
|
|
|
29
|
-
server.
|
|
120
|
+
server.registerTool(
|
|
30
121
|
'taiga_get_document',
|
|
31
|
-
'Get the full content of a company policy document. ' +
|
|
32
|
-
'Use this when you need to understand security requirements, data handling rules, ' +
|
|
33
|
-
'architecture standards, or compliance controls that apply to your code. ' +
|
|
34
|
-
'Types: policy_security, policy_data, policy_sdlc, policy_ops, policy_tic, ' +
|
|
35
|
-
'policy_risk, policy_architecture, policy_supply_chain, policy_cloud_governance, policy_acceptable_use, policy_ip.',
|
|
36
122
|
{
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
.
|
|
123
|
+
title: 'Get company document',
|
|
124
|
+
description:
|
|
125
|
+
'Get the full content of a company policy document. ' +
|
|
126
|
+
'Use this when you need to understand security requirements, data handling rules, ' +
|
|
127
|
+
'architecture standards, or compliance controls that apply to your code. ' +
|
|
128
|
+
'Types: policy_security, policy_data, policy_sdlc, policy_ops, policy_tic, ' +
|
|
129
|
+
'policy_risk, policy_architecture, policy_supply_chain, policy_cloud_governance, policy_acceptable_use, policy_ip.',
|
|
130
|
+
inputSchema: {
|
|
131
|
+
type: documentType.describe(
|
|
40
132
|
'Document type -- get available types from taiga_list_documents. ' +
|
|
41
133
|
'Common: policy_security, policy_data, policy_sdlc, policy_architecture'
|
|
42
134
|
),
|
|
135
|
+
},
|
|
136
|
+
annotations: READ_ONLY,
|
|
43
137
|
},
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
};
|
|
50
|
-
}
|
|
138
|
+
(params) =>
|
|
139
|
+
runTool(async () => {
|
|
140
|
+
const result = await client.getTenantDocument(params.type);
|
|
141
|
+
return toolResult(result.data);
|
|
142
|
+
})
|
|
51
143
|
);
|
|
52
144
|
}
|
|
53
145
|
|
|
54
|
-
|
|
55
|
-
|
|
146
|
+
// The edit reads the draft and the published version before it writes,
|
|
147
|
+
// and those routes need tenant:documents:read; a write-only scoped key
|
|
148
|
+
// would get a tool that 403s on every call.
|
|
149
|
+
if (client.hasCapability('tenant:documents:read') && client.hasCapability('tenant:documents:write')) {
|
|
150
|
+
const tenantScope = (type: string): DocumentScope => ({
|
|
151
|
+
getDraft: () => client.getTenantDocumentDraft(type),
|
|
152
|
+
getPublished: () => client.getTenantDocument(type),
|
|
153
|
+
saveDraft: (data) => client.saveTenantDocumentDraft(type, { data }),
|
|
154
|
+
publish: () => client.publishTenantDocument(type),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
server.registerTool(
|
|
56
158
|
'taiga_update_document',
|
|
57
|
-
'Update the draft content of a company policy document. ' +
|
|
58
|
-
'Only updates the draft -- does not publish. Use this when you notice a policy ' +
|
|
59
|
-
'is outdated or needs corrections while reviewing code.',
|
|
60
159
|
{
|
|
61
|
-
|
|
62
|
-
|
|
160
|
+
title: 'Update a section of a company document draft',
|
|
161
|
+
description:
|
|
162
|
+
'Replace the text of one section of a company policy document, in its draft. ' +
|
|
163
|
+
'Every other section stays as it is. Does not publish: people and agents keep reading the ' +
|
|
164
|
+
'published version until taiga_publish_document is called. Read the document with ' +
|
|
165
|
+
'taiga_get_document first to see its sections and their current text. ' +
|
|
166
|
+
'Use this when you notice a policy is outdated or needs corrections while reviewing code.',
|
|
167
|
+
inputSchema: {
|
|
168
|
+
type: tenantDocumentType,
|
|
169
|
+
sectionId,
|
|
170
|
+
content: sectionContentInput,
|
|
171
|
+
},
|
|
172
|
+
annotations: DRAFT_WRITE,
|
|
63
173
|
},
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
174
|
+
(params) => runTool(() => updateSection(tenantScope(params.type), params.type, params.sectionId, params.content))
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
server.registerTool(
|
|
178
|
+
'taiga_publish_document',
|
|
179
|
+
{
|
|
180
|
+
title: 'Publish a company document draft',
|
|
181
|
+
description:
|
|
182
|
+
"Publish a company policy document's draft as its next version, the one every agent and person " +
|
|
183
|
+
'reads from then on. Only publish when the person asked for it; save edits with ' +
|
|
184
|
+
'taiga_update_document first. A refused publish names what to fix in the draft.',
|
|
185
|
+
inputSchema: {
|
|
186
|
+
type: tenantDocumentType,
|
|
187
|
+
},
|
|
188
|
+
annotations: PUBLISH,
|
|
189
|
+
},
|
|
190
|
+
(params) => runTool(() => publishDraft(tenantScope(params.type)))
|
|
72
191
|
);
|
|
73
192
|
}
|
|
74
193
|
|
|
75
194
|
// ── Factory instructions ────────────────────────────────────────────
|
|
76
195
|
|
|
77
196
|
if (client.hasCapability('factories:read')) {
|
|
78
|
-
server.
|
|
197
|
+
server.registerTool(
|
|
79
198
|
'taiga_list_factory_documents',
|
|
80
|
-
'List documents for a specific factory (names and types only). ' +
|
|
81
|
-
"A factory has one document: its instructions, the factory's free-form standing guidance " +
|
|
82
|
-
'(domain context, tech preferences, conventions) that applies to every product it owns. ' +
|
|
83
|
-
'Use taiga_get_factory_document to read the full content.',
|
|
84
199
|
{
|
|
85
|
-
|
|
86
|
-
|
|
200
|
+
title: 'List factory documents',
|
|
201
|
+
description:
|
|
202
|
+
'List documents for a specific factory (names and types only). ' +
|
|
203
|
+
"A factory has one document: its instructions, the factory's free-form standing guidance " +
|
|
204
|
+
'(domain context, tech preferences, conventions) that applies to every product it owns. ' +
|
|
205
|
+
'Use taiga_get_factory_document to read the full content.',
|
|
206
|
+
inputSchema: {
|
|
207
|
+
factoryId: z.string().uuid().describe('Factory ID -- get from taiga_list_factories'),
|
|
208
|
+
limit: z.number().min(1).max(100).optional().describe('Max results (default 25)'),
|
|
209
|
+
},
|
|
210
|
+
annotations: READ_ONLY,
|
|
87
211
|
},
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
};
|
|
95
|
-
}
|
|
212
|
+
(params) =>
|
|
213
|
+
runTool(async () => {
|
|
214
|
+
const result = await client.listFactoryDocuments(params.factoryId, { limit: params.limit });
|
|
215
|
+
const summaries = summarizeList(result.data as Record<string, unknown>[]);
|
|
216
|
+
return toolResult(summaries);
|
|
217
|
+
})
|
|
96
218
|
);
|
|
97
219
|
|
|
98
|
-
server.
|
|
220
|
+
server.registerTool(
|
|
99
221
|
'taiga_get_factory_document',
|
|
100
|
-
"Get the full content of a factory's instructions. " +
|
|
101
|
-
'Use this when you need the domain context, tech preferences, or conventions ' +
|
|
102
|
-
'the factory expects every product it owns to follow.',
|
|
103
222
|
{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
.
|
|
107
|
-
|
|
223
|
+
title: 'Get factory instructions',
|
|
224
|
+
description:
|
|
225
|
+
"Get the full content of a factory's instructions. " +
|
|
226
|
+
'Use this when you need the domain context, tech preferences, or conventions ' +
|
|
227
|
+
'the factory expects every product it owns to follow.',
|
|
228
|
+
inputSchema: {
|
|
229
|
+
factoryId: z.string().uuid().describe('Factory ID -- get from taiga_list_factories'),
|
|
230
|
+
type: documentType.describe(
|
|
108
231
|
'Document type -- get available types from taiga_list_factory_documents. ' +
|
|
109
232
|
'The factory instructions document is `factory_instructions`.'
|
|
110
233
|
),
|
|
234
|
+
},
|
|
235
|
+
annotations: READ_ONLY,
|
|
111
236
|
},
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
};
|
|
118
|
-
}
|
|
237
|
+
(params) =>
|
|
238
|
+
runTool(async () => {
|
|
239
|
+
const result = await client.getFactoryDocument(params.factoryId, params.type);
|
|
240
|
+
return toolResult(result.data);
|
|
241
|
+
})
|
|
119
242
|
);
|
|
120
243
|
}
|
|
121
244
|
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
245
|
+
// Writing factory instructions needs factories:read (to find the factory) and
|
|
246
|
+
// factories:documents:write, which is what the API routes require: members
|
|
247
|
+
// hold it for their own factories, and tenant:documents:write (company
|
|
248
|
+
// policies) does not stand in for it.
|
|
249
|
+
if (client.hasCapability('factories:read') && client.hasCapability('factories:documents:write')) {
|
|
250
|
+
const factoryScope = (factoryId: string, type: string): DocumentScope => ({
|
|
251
|
+
getDraft: () => client.getFactoryDocumentDraft(factoryId, type),
|
|
252
|
+
getPublished: () => client.getFactoryDocument(factoryId, type),
|
|
253
|
+
saveDraft: (data) => client.saveFactoryDocumentDraft(factoryId, type, { data }),
|
|
254
|
+
publish: () => client.publishFactoryDocument(factoryId, type),
|
|
255
|
+
});
|
|
256
|
+
const factoryId = z.string().uuid().describe('Factory ID -- get from taiga_list_factories');
|
|
257
|
+
const factoryDocumentType = documentType.describe('Document type (`factory_instructions`)');
|
|
258
|
+
|
|
259
|
+
server.registerTool(
|
|
125
260
|
'taiga_update_factory_document',
|
|
126
|
-
"Update the draft of a factory's instructions. " +
|
|
127
|
-
'Only updates the draft -- does not publish. Use this when you notice the factory conventions, ' +
|
|
128
|
-
'domain context, or tech preferences need updating while working on the codebase.',
|
|
129
261
|
{
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
262
|
+
title: 'Update a section of factory instructions',
|
|
263
|
+
description:
|
|
264
|
+
"Replace the text of one section of a factory's instructions, in its draft. The instructions " +
|
|
265
|
+
'document has one section, `instructions`. Does not publish: products keep following the ' +
|
|
266
|
+
'published version until taiga_publish_factory_document is called. Read the current text with ' +
|
|
267
|
+
'taiga_get_factory_document first. Use this when the factory conventions, domain context or tech ' +
|
|
268
|
+
'preferences need updating while working on the codebase.',
|
|
269
|
+
inputSchema: {
|
|
138
270
|
factoryId,
|
|
139
|
-
type,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
271
|
+
type: factoryDocumentType,
|
|
272
|
+
sectionId: sectionId.default('instructions'),
|
|
273
|
+
content: sectionContentInput,
|
|
274
|
+
},
|
|
275
|
+
annotations: DRAFT_WRITE,
|
|
276
|
+
},
|
|
277
|
+
(params) =>
|
|
278
|
+
runTool(() =>
|
|
279
|
+
updateSection(factoryScope(params.factoryId, params.type), params.type, params.sectionId, params.content)
|
|
280
|
+
)
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
server.registerTool(
|
|
284
|
+
'taiga_publish_factory_document',
|
|
285
|
+
{
|
|
286
|
+
title: 'Publish factory instructions draft',
|
|
287
|
+
description:
|
|
288
|
+
"Publish a factory's instructions draft as the next version every product of the factory follows. " +
|
|
289
|
+
'Only publish when the person asked for it; save edits with taiga_update_factory_document first.',
|
|
290
|
+
inputSchema: { factoryId, type: factoryDocumentType },
|
|
291
|
+
annotations: PUBLISH,
|
|
292
|
+
},
|
|
293
|
+
(params) => runTool(() => publishDraft(factoryScope(params.factoryId, params.type)))
|
|
146
294
|
);
|
|
147
295
|
}
|
|
148
296
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { TaigaApiError } from '../client.js';
|
|
3
|
+
|
|
4
|
+
/** A successful tool result: one JSON text block the model reads. */
|
|
5
|
+
export function toolResult(value: unknown): CallToolResult {
|
|
6
|
+
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A failed tool result the model can branch on: a stable code and a message
|
|
11
|
+
* that says what to do next. Returned, not thrown, so the client sees
|
|
12
|
+
* `isError` rather than a protocol error.
|
|
13
|
+
*/
|
|
14
|
+
export function toolError(error: string, message: string): CallToolResult {
|
|
15
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error, message }) }], isError: true };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const CODE_BY_STATUS: Record<number, string> = {
|
|
19
|
+
400: 'VALIDATION_FAILED',
|
|
20
|
+
401: 'UNAUTHENTICATED',
|
|
21
|
+
403: 'FORBIDDEN',
|
|
22
|
+
404: 'NOT_FOUND',
|
|
23
|
+
409: 'CONFLICT',
|
|
24
|
+
429: 'RATE_LIMITED',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Run a tool handler and turn a failure into a structured error. An API
|
|
29
|
+
* refusal keeps the API's own code when it sent one and its message whole:
|
|
30
|
+
* on a refused publish that names the section holding template residue,
|
|
31
|
+
* which is what the model needs to fix the draft. A failed connection is
|
|
32
|
+
* structured too, so every failure the model sees has the same shape.
|
|
33
|
+
*/
|
|
34
|
+
export async function runTool(handler: () => Promise<CallToolResult>): Promise<CallToolResult> {
|
|
35
|
+
try {
|
|
36
|
+
return await handler();
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error instanceof TaigaApiError) {
|
|
39
|
+
const code = error.code ?? CODE_BY_STATUS[error.status] ?? 'API_ERROR';
|
|
40
|
+
return toolError(code, error.apiMessage ?? `Taiga API answered ${error.status}.`);
|
|
41
|
+
}
|
|
42
|
+
// fetch rejects with a TypeError when the API cannot be reached.
|
|
43
|
+
if (error instanceof TypeError) {
|
|
44
|
+
return toolError('NETWORK_ERROR', `Could not reach the Taiga API: ${error.message}. Check TAIGA_API_URL.`);
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { FACTORY_DOCUMENT_TYPES, PRODUCT_DOCUMENT_TYPES, TENANT_DOCUMENT_TYPES } from '@taiga/types';
|
|
3
|
+
import { documentType } from './inputs.js';
|
|
4
|
+
|
|
5
|
+
describe('documentType', () => {
|
|
6
|
+
it('accepts every document type the shared types package names', () => {
|
|
7
|
+
// The identifier shape is a wall against path-shaped input, not a copy of
|
|
8
|
+
// the enums; this is what keeps it from silently rejecting a real type.
|
|
9
|
+
for (const type of [...TENANT_DOCUMENT_TYPES, ...FACTORY_DOCUMENT_TYPES, ...PRODUCT_DOCUMENT_TYPES]) {
|
|
10
|
+
expect(documentType.safeParse(type).success, type).toBe(true);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('rejects anything that could reach another route', () => {
|
|
15
|
+
for (const value of ['../x', 'a/b', '.', '..', '', 'policy security', 'Policy', 'a?x=1', 'a#b']) {
|
|
16
|
+
expect(documentType.safeParse(value).success, JSON.stringify(value)).toBe(false);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A document type as the API names one: policy_security, factory_instructions,
|
|
5
|
+
* specification. An identifier, never a path: the value is interpolated into
|
|
6
|
+
* the route, and a type that carried a slash would reach a different
|
|
7
|
+
* document's route than the tool's description promises. The API's own enum
|
|
8
|
+
* stays the source of truth for which identifiers exist (it answers 400 with
|
|
9
|
+
* the valid list); this only rules out what could not be a type at all.
|
|
10
|
+
*/
|
|
11
|
+
export const documentType = z
|
|
12
|
+
.string()
|
|
13
|
+
.regex(/^[a-z][a-z0-9_]{0,63}$/, 'A document type is a lowercase identifier such as policy_security');
|