@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taiga-ai/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-dev.388",
|
|
4
4
|
"description": "MCP server for Taiga -- gives AI assistants access to documents and products",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "tsc --noEmit && node scripts/build.mjs",
|
|
18
18
|
"typecheck": "tsc --noEmit",
|
|
19
|
-
"dev": "tsc --watch",
|
|
20
19
|
"lint": "oxlint src",
|
|
21
20
|
"format": "oxfmt --check \"**/*.{ts,tsx,js,jsx,mjs,cjs,json,md}\"",
|
|
22
21
|
"format:fix": "oxfmt \"**/*.{ts,tsx,js,jsx,mjs,cjs,json,md}\"",
|
|
@@ -27,6 +26,7 @@
|
|
|
27
26
|
"zod": "^4.4.3"
|
|
28
27
|
},
|
|
29
28
|
"devDependencies": {
|
|
29
|
+
"@tsconfig/strictest": "^2.0.8",
|
|
30
30
|
"@types/node": "^22.20.1",
|
|
31
31
|
"rolldown": "^1.2.3",
|
|
32
32
|
"typescript": "^7.0.2",
|
package/src/client.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { TaigaClient } from './client.js';
|
|
3
|
+
import { TaigaApiError, TaigaClient } from './client.js';
|
|
4
4
|
|
|
5
5
|
// Read independently of src/version.ts, so the test checks the real file.
|
|
6
6
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string };
|
|
@@ -57,7 +57,7 @@ describe('TaigaClient', () => {
|
|
|
57
57
|
await client.listProducts();
|
|
58
58
|
|
|
59
59
|
// Assert
|
|
60
|
-
const headers = spy.mock.calls[0][1].headers;
|
|
60
|
+
const headers = spy.mock.calls[0]![1].headers;
|
|
61
61
|
expect(headers.Authorization).toBe('Bearer taiga_sk_live_secret42');
|
|
62
62
|
});
|
|
63
63
|
|
|
@@ -66,7 +66,7 @@ describe('TaigaClient', () => {
|
|
|
66
66
|
|
|
67
67
|
await makeClient().listProducts();
|
|
68
68
|
|
|
69
|
-
expect(spy.mock.calls[0][1].headers['User-Agent']).toBe(`taiga-ai-mcp-server/${pkg.version}`);
|
|
69
|
+
expect(spy.mock.calls[0]![1].headers['User-Agent']).toBe(`taiga-ai-mcp-server/${pkg.version}`);
|
|
70
70
|
});
|
|
71
71
|
});
|
|
72
72
|
|
|
@@ -113,7 +113,7 @@ describe('TaigaClient', () => {
|
|
|
113
113
|
|
|
114
114
|
await client.listProducts();
|
|
115
115
|
|
|
116
|
-
expect(spy.mock.calls[0][0]).toBe('https://api.test.com/v1/products');
|
|
116
|
+
expect(spy.mock.calls[0]![0]).toBe('https://api.test.com/v1/products');
|
|
117
117
|
});
|
|
118
118
|
|
|
119
119
|
it('should omit query string when no optional params are provided', async () => {
|
|
@@ -121,7 +121,7 @@ describe('TaigaClient', () => {
|
|
|
121
121
|
|
|
122
122
|
await makeClient().listProducts();
|
|
123
123
|
|
|
124
|
-
expect(spy.mock.calls[0][0]).toBe('https://api.test.com/v1/products');
|
|
124
|
+
expect(spy.mock.calls[0]![0]).toBe('https://api.test.com/v1/products');
|
|
125
125
|
});
|
|
126
126
|
|
|
127
127
|
it('should include all optional filters as query params', async () => {
|
|
@@ -129,7 +129,7 @@ describe('TaigaClient', () => {
|
|
|
129
129
|
|
|
130
130
|
await makeClient().listProducts({ factoryId: 't1', status: 'active', limit: 10 });
|
|
131
131
|
|
|
132
|
-
const url = spy.mock.calls[0][0] as string;
|
|
132
|
+
const url = spy.mock.calls[0]![0] as string;
|
|
133
133
|
expect(url).toContain('factoryId=t1');
|
|
134
134
|
expect(url).toContain('status=active');
|
|
135
135
|
expect(url).toContain('limit=10');
|
|
@@ -140,7 +140,7 @@ describe('TaigaClient', () => {
|
|
|
140
140
|
|
|
141
141
|
await makeClient().listBacklogItems('proj-1', { limit: 20 });
|
|
142
142
|
|
|
143
|
-
const url = spy.mock.calls[0][0] as string;
|
|
143
|
+
const url = spy.mock.calls[0]![0] as string;
|
|
144
144
|
expect(url).toContain('/v1/products/proj-1/backlog');
|
|
145
145
|
expect(url).toContain('limit=20');
|
|
146
146
|
});
|
|
@@ -150,7 +150,7 @@ describe('TaigaClient', () => {
|
|
|
150
150
|
|
|
151
151
|
await makeClient().listEnvironments('proj-1');
|
|
152
152
|
|
|
153
|
-
expect(spy.mock.calls[0][0]).toBe('https://api.test.com/v1/products/proj-1/environments');
|
|
153
|
+
expect(spy.mock.calls[0]![0]).toBe('https://api.test.com/v1/products/proj-1/environments');
|
|
154
154
|
});
|
|
155
155
|
});
|
|
156
156
|
|
|
@@ -211,7 +211,7 @@ describe('TaigaClient', () => {
|
|
|
211
211
|
|
|
212
212
|
const result = await makeClient().getProduct('p1');
|
|
213
213
|
|
|
214
|
-
expect(spy.mock.calls[0][0]).toBe('https://api.test.com/v1/products/p1');
|
|
214
|
+
expect(spy.mock.calls[0]![0]).toBe('https://api.test.com/v1/products/p1');
|
|
215
215
|
expect(result.data).toEqual({ id: 'p1', name: 'Alpha' });
|
|
216
216
|
});
|
|
217
217
|
|
|
@@ -220,7 +220,7 @@ describe('TaigaClient', () => {
|
|
|
220
220
|
|
|
221
221
|
await makeClient().getTenantDocument('security_policy');
|
|
222
222
|
|
|
223
|
-
expect(spy.mock.calls[0][0]).toBe('https://api.test.com/v1/tenants/current/documents/security_policy');
|
|
223
|
+
expect(spy.mock.calls[0]![0]).toBe('https://api.test.com/v1/tenants/current/documents/security_policy');
|
|
224
224
|
});
|
|
225
225
|
|
|
226
226
|
it('should PUT a document draft with the provided body', async () => {
|
|
@@ -228,11 +228,73 @@ describe('TaigaClient', () => {
|
|
|
228
228
|
|
|
229
229
|
await makeClient().saveTenantDocumentDraft('security_policy', { data: { title: 'New' } });
|
|
230
230
|
|
|
231
|
-
expect(spy.mock.calls[0][1].method).toBe('PUT');
|
|
232
|
-
expect(spy.mock.calls[0][0]).toContain('/tenants/current/documents/security_policy/draft');
|
|
233
|
-
expect(JSON.parse(spy.mock.calls[0][1].body)).toEqual({
|
|
231
|
+
expect(spy.mock.calls[0]![1].method).toBe('PUT');
|
|
232
|
+
expect(spy.mock.calls[0]![0]).toContain('/tenants/current/documents/security_policy/draft');
|
|
233
|
+
expect(JSON.parse(spy.mock.calls[0]![1].body)).toEqual({
|
|
234
234
|
data: { title: 'New' },
|
|
235
235
|
});
|
|
236
|
+
expect(spy.mock.calls[0]![1].headers['Content-Type']).toBe('application/json');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should keep every path segment inside its route', async () => {
|
|
240
|
+
const spy = stubFetch(200, { data: {} });
|
|
241
|
+
const client = makeClient();
|
|
242
|
+
|
|
243
|
+
await client.getTenantDocument('../products/x');
|
|
244
|
+
await client.getFactoryDocument('f/1', 'a b');
|
|
245
|
+
await client.getProduct('p/1');
|
|
246
|
+
await client.getBacklogItem('b/1');
|
|
247
|
+
await client.listEnvironments('e/1');
|
|
248
|
+
await client.getProductDocument('p/1', 'spec/x');
|
|
249
|
+
|
|
250
|
+
const paths = spy.mock.calls.map((c) => new URL(c[0] as string).pathname);
|
|
251
|
+
expect(paths).toEqual([
|
|
252
|
+
'/v1/tenants/current/documents/..%2Fproducts%2Fx',
|
|
253
|
+
'/v1/factories/f%2F1/documents/a%20b',
|
|
254
|
+
'/v1/products/p%2F1',
|
|
255
|
+
'/v1/backlog/b%2F1',
|
|
256
|
+
'/v1/products/e%2F1/environments',
|
|
257
|
+
'/v1/products/p%2F1/documents/spec%2Fx',
|
|
258
|
+
]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should refuse the dot segments encodeURIComponent cannot neutralise', async () => {
|
|
262
|
+
// `/documents/../publish` would collapse to `/tenants/current/publish`.
|
|
263
|
+
const spy = stubFetch(200, { data: {} });
|
|
264
|
+
const client = makeClient();
|
|
265
|
+
|
|
266
|
+
await expect(client.publishTenantDocument('..')).rejects.toThrow(/Invalid path segment/);
|
|
267
|
+
await expect(client.getTenantDocumentDraft('.')).rejects.toThrow(/Invalid path segment/);
|
|
268
|
+
await expect(client.getTenantDocument('')).rejects.toThrow(/Invalid path segment/);
|
|
269
|
+
expect(spy).not.toHaveBeenCalled();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should POST a publish with no body and no JSON content type', async () => {
|
|
273
|
+
// Fastify answers 400 "Body cannot be empty" to a JSON content type
|
|
274
|
+
// without a body, which is what the publish routes would otherwise get.
|
|
275
|
+
const spy = stubFetch(200, { data: { id: 'd1', version: 2 } });
|
|
276
|
+
|
|
277
|
+
await makeClient().publishTenantDocument('policy_security');
|
|
278
|
+
|
|
279
|
+
expect(spy.mock.calls[0]![1].method).toBe('POST');
|
|
280
|
+
expect(spy.mock.calls[0]![0]).toContain('/tenants/current/documents/policy_security/publish');
|
|
281
|
+
expect(spy.mock.calls[0]![1].body).toBeNull();
|
|
282
|
+
expect(spy.mock.calls[0]![1].headers).not.toHaveProperty('Content-Type');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should expose the API status, code and message on a failed request', async () => {
|
|
286
|
+
stubFetch(400, { error: 'Bad Request', code: 'VALIDATION_ERROR', message: 'Draft contains template residue' });
|
|
287
|
+
|
|
288
|
+
const error = await makeClient()
|
|
289
|
+
.publishTenantDocument('policy_security')
|
|
290
|
+
.catch((e: unknown) => e as TaigaApiError);
|
|
291
|
+
|
|
292
|
+
expect(error).toBeInstanceOf(TaigaApiError);
|
|
293
|
+
expect(error).toMatchObject({
|
|
294
|
+
status: 400,
|
|
295
|
+
code: 'VALIDATION_ERROR',
|
|
296
|
+
apiMessage: 'Draft contains template residue',
|
|
297
|
+
});
|
|
236
298
|
});
|
|
237
299
|
|
|
238
300
|
it('should GET a single backlog item by id (flat path)', async () => {
|
|
@@ -240,7 +302,7 @@ describe('TaigaClient', () => {
|
|
|
240
302
|
|
|
241
303
|
await makeClient().getBacklogItem('item-1');
|
|
242
304
|
|
|
243
|
-
expect(spy.mock.calls[0][0]).toBe('https://api.test.com/v1/backlog/item-1');
|
|
305
|
+
expect(spy.mock.calls[0]![0]).toBe('https://api.test.com/v1/backlog/item-1');
|
|
244
306
|
});
|
|
245
307
|
|
|
246
308
|
it('should GET factories with optional limit', async () => {
|
|
@@ -248,7 +310,7 @@ describe('TaigaClient', () => {
|
|
|
248
310
|
|
|
249
311
|
await makeClient().listFactories({ limit: 50 });
|
|
250
312
|
|
|
251
|
-
expect(spy.mock.calls[0][0]).toContain('/v1/factories?limit=50');
|
|
313
|
+
expect(spy.mock.calls[0]![0]).toContain('/v1/factories?limit=50');
|
|
252
314
|
});
|
|
253
315
|
|
|
254
316
|
it('should GET documents from tenant path with optional limit', async () => {
|
|
@@ -256,7 +318,7 @@ describe('TaigaClient', () => {
|
|
|
256
318
|
|
|
257
319
|
await makeClient().listTenantDocuments({ limit: 5 });
|
|
258
320
|
|
|
259
|
-
expect(spy.mock.calls[0][0]).toContain('/v1/tenants/current/documents?limit=5');
|
|
321
|
+
expect(spy.mock.calls[0]![0]).toContain('/v1/tenants/current/documents?limit=5');
|
|
260
322
|
});
|
|
261
323
|
});
|
|
262
324
|
});
|
package/src/client.ts
CHANGED
|
@@ -1,6 +1,78 @@
|
|
|
1
1
|
import type { ApiKeyIntrospection } from '@taiga/types';
|
|
2
2
|
import { SERVER_VERSION } from './version.js';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* A non-2xx answer from the Taiga API. Carries the status and the API's own
|
|
6
|
+
* `code` and `message` so a tool can hand the model something it can act on
|
|
7
|
+
* (a 400 from the publish gate names the section that still holds template
|
|
8
|
+
* residue) instead of one opaque string.
|
|
9
|
+
*/
|
|
10
|
+
export class TaigaApiError extends Error {
|
|
11
|
+
readonly status: number;
|
|
12
|
+
readonly code: string | undefined;
|
|
13
|
+
readonly apiMessage: string | undefined;
|
|
14
|
+
|
|
15
|
+
constructor(method: string, path: string, status: number, body: string) {
|
|
16
|
+
const parsed = parseErrorBody(body);
|
|
17
|
+
super(`Taiga API ${method} ${path} failed (${status}): ${parsed.message ?? body}`);
|
|
18
|
+
this.name = 'TaigaApiError';
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.code = parsed.code;
|
|
21
|
+
this.apiMessage = parsed.message;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One path segment. Every id and type a tool passes is validated at the
|
|
27
|
+
* tool boundary; this is the second wall. Encoding keeps a slash inside the
|
|
28
|
+
* segment, and the dot segments are refused outright: encodeURIComponent
|
|
29
|
+
* leaves "." and ".." as they are, and the URL parser in fetch would then
|
|
30
|
+
* collapse `/documents/../publish` into a different route.
|
|
31
|
+
*/
|
|
32
|
+
function seg(value: string): string {
|
|
33
|
+
if (value === '' || value === '.' || value === '..') {
|
|
34
|
+
throw new TypeError(`Invalid path segment: ${JSON.stringify(value)}`);
|
|
35
|
+
}
|
|
36
|
+
return encodeURIComponent(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseErrorBody(body: string): { code?: string; message?: string } {
|
|
40
|
+
try {
|
|
41
|
+
const json = JSON.parse(body) as { code?: unknown; message?: unknown; details?: unknown };
|
|
42
|
+
const message = typeof json.message === 'string' ? json.message : undefined;
|
|
43
|
+
// A 400 from route validation says only "Request validation failed";
|
|
44
|
+
// the field and the reason are in `details`, and the model needs them.
|
|
45
|
+
const details = Array.isArray(json.details)
|
|
46
|
+
? json.details
|
|
47
|
+
.map((d: { instancePath?: unknown; message?: unknown }) =>
|
|
48
|
+
[d.instancePath, d.message].filter((x) => typeof x === 'string' && x).join(' ')
|
|
49
|
+
)
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
: [];
|
|
52
|
+
return {
|
|
53
|
+
...(typeof json.code === 'string' ? { code: json.code } : {}),
|
|
54
|
+
...(message ? { message: details.length ? `${message}: ${details.join('; ')}` : message } : {}),
|
|
55
|
+
};
|
|
56
|
+
} catch {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A tenant or factory document as the API returns it: the fields the tools
|
|
63
|
+
* read. `data` holds the content; policies and instructions keep it under
|
|
64
|
+
* `sections` (see tools/sections.ts).
|
|
65
|
+
*/
|
|
66
|
+
export interface TaigaDocument {
|
|
67
|
+
id: string;
|
|
68
|
+
type: string;
|
|
69
|
+
name: string;
|
|
70
|
+
version: number | null;
|
|
71
|
+
isDraft: boolean;
|
|
72
|
+
data: Record<string, unknown>;
|
|
73
|
+
[key: string]: unknown;
|
|
74
|
+
}
|
|
75
|
+
|
|
4
76
|
/**
|
|
5
77
|
* HTTP client for the Taiga API.
|
|
6
78
|
* Used by the MCP server to proxy tool calls to the backend.
|
|
@@ -19,19 +91,21 @@ export class TaigaClient {
|
|
|
19
91
|
const url = `${this.baseUrl}/v1${path}`;
|
|
20
92
|
const headers: Record<string, string> = {
|
|
21
93
|
Authorization: `Bearer ${this.apiKey}`,
|
|
22
|
-
'Content-Type': 'application/json',
|
|
23
94
|
'User-Agent': `taiga-ai-mcp-server/${SERVER_VERSION}`,
|
|
24
95
|
};
|
|
96
|
+
// Only with a body: Fastify refuses an empty body under a JSON
|
|
97
|
+
// content type, which is what a bodiless POST (publish) would send.
|
|
98
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
25
99
|
|
|
26
100
|
const response = await fetch(url, {
|
|
27
101
|
method,
|
|
28
102
|
headers,
|
|
29
|
-
body: body ? JSON.stringify(body)
|
|
103
|
+
body: body === undefined ? null : JSON.stringify(body),
|
|
30
104
|
});
|
|
31
105
|
|
|
32
106
|
if (!response.ok) {
|
|
33
107
|
const text = await response.text().catch(() => '');
|
|
34
|
-
throw new
|
|
108
|
+
throw new TaigaApiError(method, path, response.status, text);
|
|
35
109
|
}
|
|
36
110
|
|
|
37
111
|
return response.json() as Promise<T>;
|
|
@@ -60,7 +134,11 @@ export class TaigaClient {
|
|
|
60
134
|
// Products
|
|
61
135
|
// ---------------------------------------------------------------------------
|
|
62
136
|
|
|
63
|
-
async listProducts(opts?: {
|
|
137
|
+
async listProducts(opts?: {
|
|
138
|
+
factoryId?: string | undefined;
|
|
139
|
+
status?: string | undefined;
|
|
140
|
+
limit?: number | undefined;
|
|
141
|
+
}) {
|
|
64
142
|
const params = new URLSearchParams();
|
|
65
143
|
if (opts?.factoryId) params.set('factoryId', opts.factoryId);
|
|
66
144
|
if (opts?.status) params.set('status', opts.status);
|
|
@@ -70,14 +148,14 @@ export class TaigaClient {
|
|
|
70
148
|
}
|
|
71
149
|
|
|
72
150
|
async getProduct(id: string) {
|
|
73
|
-
return this.request<{ data: unknown }>('GET', `/products/${id}`);
|
|
151
|
+
return this.request<{ data: unknown }>('GET', `/products/${seg(id)}`);
|
|
74
152
|
}
|
|
75
153
|
|
|
76
154
|
// ---------------------------------------------------------------------------
|
|
77
155
|
// Documents (tenant-level policies)
|
|
78
156
|
// ---------------------------------------------------------------------------
|
|
79
157
|
|
|
80
|
-
async listTenantDocuments(opts?: { limit?: number }) {
|
|
158
|
+
async listTenantDocuments(opts?: { limit?: number | undefined }) {
|
|
81
159
|
const params = new URLSearchParams();
|
|
82
160
|
if (opts?.limit) params.set('limit', String(opts.limit));
|
|
83
161
|
const qs = params.toString();
|
|
@@ -88,51 +166,76 @@ export class TaigaClient {
|
|
|
88
166
|
}
|
|
89
167
|
|
|
90
168
|
async getTenantDocument(type: string) {
|
|
91
|
-
return this.request<{ data:
|
|
169
|
+
return this.request<{ data: TaigaDocument }>('GET', `/tenants/current/documents/${seg(type)}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The shared draft for a document type, or `{ data: null }` when none exists. */
|
|
173
|
+
async getTenantDocumentDraft(type: string) {
|
|
174
|
+
return this.request<{ data: TaigaDocument | null }>('GET', `/tenants/current/documents/${seg(type)}/draft`);
|
|
92
175
|
}
|
|
93
176
|
|
|
94
177
|
async saveTenantDocumentDraft(type: string, body: { data: Record<string, unknown> }) {
|
|
95
|
-
return this.request<{ data:
|
|
178
|
+
return this.request<{ data: TaigaDocument }>('PUT', `/tenants/current/documents/${seg(type)}/draft`, body);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async publishTenantDocument(type: string) {
|
|
182
|
+
return this.request<{ data: TaigaDocument }>('POST', `/tenants/current/documents/${seg(type)}/publish`);
|
|
96
183
|
}
|
|
97
184
|
|
|
98
185
|
// ---------------------------------------------------------------------------
|
|
99
186
|
// Factory Documents
|
|
100
187
|
// ---------------------------------------------------------------------------
|
|
101
188
|
|
|
102
|
-
async listFactoryDocuments(factoryId: string, opts?: { limit?: number }) {
|
|
189
|
+
async listFactoryDocuments(factoryId: string, opts?: { limit?: number | undefined }) {
|
|
103
190
|
const params = new URLSearchParams();
|
|
104
191
|
if (opts?.limit) params.set('limit', String(opts.limit));
|
|
105
192
|
const qs = params.toString();
|
|
106
193
|
return this.request<{ data: unknown[]; nextCursor: string | null }>(
|
|
107
194
|
'GET',
|
|
108
|
-
`/factories/${factoryId}/documents${qs ? `?${qs}` : ''}`
|
|
195
|
+
`/factories/${seg(factoryId)}/documents${qs ? `?${qs}` : ''}`
|
|
109
196
|
);
|
|
110
197
|
}
|
|
111
198
|
|
|
112
199
|
async getFactoryDocument(factoryId: string, type: string) {
|
|
113
|
-
return this.request<{ data:
|
|
200
|
+
return this.request<{ data: TaigaDocument }>('GET', `/factories/${seg(factoryId)}/documents/${seg(type)}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The shared draft for a factory document type, or `{ data: null }` when none exists. */
|
|
204
|
+
async getFactoryDocumentDraft(factoryId: string, type: string) {
|
|
205
|
+
return this.request<{ data: TaigaDocument | null }>(
|
|
206
|
+
'GET',
|
|
207
|
+
`/factories/${seg(factoryId)}/documents/${seg(type)}/draft`
|
|
208
|
+
);
|
|
114
209
|
}
|
|
115
210
|
|
|
116
211
|
async saveFactoryDocumentDraft(factoryId: string, type: string, body: { data: Record<string, unknown> }) {
|
|
117
|
-
return this.request<{ data:
|
|
212
|
+
return this.request<{ data: TaigaDocument }>(
|
|
213
|
+
'PUT',
|
|
214
|
+
`/factories/${seg(factoryId)}/documents/${seg(type)}/draft`,
|
|
215
|
+
body
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async publishFactoryDocument(factoryId: string, type: string) {
|
|
220
|
+
return this.request<{ data: TaigaDocument }>('POST', `/factories/${seg(factoryId)}/documents/${seg(type)}/publish`);
|
|
118
221
|
}
|
|
119
222
|
|
|
120
223
|
// ---------------------------------------------------------------------------
|
|
121
224
|
// Product Documents
|
|
122
225
|
// ---------------------------------------------------------------------------
|
|
123
226
|
|
|
124
|
-
async listProductDocuments(productId: string, opts?: { limit?: number }) {
|
|
227
|
+
async listProductDocuments(productId: string, opts?: { limit?: number | undefined }) {
|
|
125
228
|
const params = new URLSearchParams();
|
|
126
229
|
if (opts?.limit) params.set('limit', String(opts.limit));
|
|
127
230
|
const qs = params.toString();
|
|
128
231
|
return this.request<{ data: unknown[]; nextCursor: string | null }>(
|
|
129
232
|
'GET',
|
|
130
|
-
`/products/${productId}/documents${qs ? `?${qs}` : ''}`
|
|
233
|
+
`/products/${seg(productId)}/documents${qs ? `?${qs}` : ''}`
|
|
131
234
|
);
|
|
132
235
|
}
|
|
133
236
|
|
|
134
237
|
async getProductDocument(productId: string, type: string) {
|
|
135
|
-
return this.request<{ data: unknown }>('GET', `/products/${productId}/documents/${type}`);
|
|
238
|
+
return this.request<{ data: unknown }>('GET', `/products/${seg(productId)}/documents/${seg(type)}`);
|
|
136
239
|
}
|
|
137
240
|
|
|
138
241
|
// ---------------------------------------------------------------------------
|
|
@@ -146,7 +249,7 @@ export class TaigaClient {
|
|
|
146
249
|
*/
|
|
147
250
|
async getProductContext(productId: string): Promise<Record<string, unknown>> {
|
|
148
251
|
// First fetch the product to get its factory
|
|
149
|
-
const product = await this.request<{ data: Record<string, unknown> }>('GET', `/products/${productId}`);
|
|
252
|
+
const product = await this.request<{ data: Record<string, unknown> }>('GET', `/products/${seg(productId)}`);
|
|
150
253
|
const factoryId = product.data.factoryId as string | undefined;
|
|
151
254
|
|
|
152
255
|
// Fetch the document lists in parallel.
|
|
@@ -214,18 +317,18 @@ export class TaigaClient {
|
|
|
214
317
|
// Backlog
|
|
215
318
|
// ---------------------------------------------------------------------------
|
|
216
319
|
|
|
217
|
-
async listBacklogItems(productId: string, opts?: { limit?: number }) {
|
|
320
|
+
async listBacklogItems(productId: string, opts?: { limit?: number | undefined }) {
|
|
218
321
|
const params = new URLSearchParams();
|
|
219
322
|
if (opts?.limit) params.set('limit', String(opts.limit));
|
|
220
323
|
const qs = params.toString();
|
|
221
324
|
return this.request<{ data: unknown[]; nextCursor: string | null }>(
|
|
222
325
|
'GET',
|
|
223
|
-
`/products/${productId}/backlog${qs ? `?${qs}` : ''}`
|
|
326
|
+
`/products/${seg(productId)}/backlog${qs ? `?${qs}` : ''}`
|
|
224
327
|
);
|
|
225
328
|
}
|
|
226
329
|
|
|
227
330
|
async getBacklogItem(itemId: string) {
|
|
228
|
-
return this.request<{ data: unknown }>('GET', `/backlog/${itemId}`);
|
|
331
|
+
return this.request<{ data: unknown }>('GET', `/backlog/${seg(itemId)}`);
|
|
229
332
|
}
|
|
230
333
|
|
|
231
334
|
// ---------------------------------------------------------------------------
|
|
@@ -233,14 +336,14 @@ export class TaigaClient {
|
|
|
233
336
|
// ---------------------------------------------------------------------------
|
|
234
337
|
|
|
235
338
|
async listEnvironments(productId: string) {
|
|
236
|
-
return this.request<{ data: unknown[] }>('GET', `/products/${productId}/environments`);
|
|
339
|
+
return this.request<{ data: unknown[] }>('GET', `/products/${seg(productId)}/environments`);
|
|
237
340
|
}
|
|
238
341
|
|
|
239
342
|
// ---------------------------------------------------------------------------
|
|
240
343
|
// Factories
|
|
241
344
|
// ---------------------------------------------------------------------------
|
|
242
345
|
|
|
243
|
-
async listFactories(opts?: { limit?: number }) {
|
|
346
|
+
async listFactories(opts?: { limit?: number | undefined }) {
|
|
244
347
|
const params = new URLSearchParams();
|
|
245
348
|
if (opts?.limit) params.set('limit', String(opts.limit));
|
|
246
349
|
const qs = params.toString();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { readConfig } from './config.js';
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.unstubAllEnvs();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe('readConfig', () => {
|
|
10
|
+
it('needs an API key', () => {
|
|
11
|
+
vi.stubEnv('TAIGA_API_KEY', undefined);
|
|
12
|
+
expect(readConfig()).toBeNull();
|
|
13
|
+
vi.stubEnv('TAIGA_API_KEY', '');
|
|
14
|
+
expect(readConfig()).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('defaults the API URL', () => {
|
|
18
|
+
vi.stubEnv('TAIGA_API_KEY', 'taiga_sk_live_test');
|
|
19
|
+
vi.stubEnv('TAIGA_API_URL', undefined);
|
|
20
|
+
expect(readConfig()).toEqual({ apiKey: 'taiga_sk_live_test', apiUrl: 'https://api.taiga.app' });
|
|
21
|
+
vi.stubEnv('TAIGA_API_URL', 'http://localhost:3001');
|
|
22
|
+
expect(readConfig()?.apiUrl).toBe('http://localhost:3001');
|
|
23
|
+
});
|
|
24
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one place the MCP server reads its environment (PRO-2511), parsed once
|
|
5
|
+
* at startup.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const DEFAULT_API_URL = 'https://api.taiga.app';
|
|
9
|
+
|
|
10
|
+
const envSchema = z.object({
|
|
11
|
+
TAIGA_API_KEY: z.string().min(1),
|
|
12
|
+
TAIGA_API_URL: z.string().default(DEFAULT_API_URL),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export interface ServerConfig {
|
|
16
|
+
readonly apiKey: string;
|
|
17
|
+
readonly apiUrl: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Null when TAIGA_API_KEY is missing or empty: it is the one setting with no default. */
|
|
21
|
+
export function readConfig(): ServerConfig | null {
|
|
22
|
+
const parsed = envSchema.safeParse(process.env);
|
|
23
|
+
if (!parsed.success) return null;
|
|
24
|
+
return { apiKey: parsed.data.TAIGA_API_KEY, apiUrl: parsed.data.TAIGA_API_URL };
|
|
25
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,22 +3,19 @@
|
|
|
3
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
4
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
5
|
import { TaigaClient } from './client.js';
|
|
6
|
+
import { readConfig } from './config.js';
|
|
6
7
|
import { registerAllTools } from './tools/index.js';
|
|
7
8
|
import { SERVER_VERSION } from './version.js';
|
|
8
9
|
|
|
9
|
-
const DEFAULT_API_URL = 'https://api.taiga.app';
|
|
10
|
-
|
|
11
10
|
async function main() {
|
|
12
|
-
const
|
|
13
|
-
if (!
|
|
11
|
+
const config = readConfig();
|
|
12
|
+
if (!config) {
|
|
14
13
|
console.error('Error: TAIGA_API_KEY environment variable is required.');
|
|
15
14
|
console.error('Create an API key at https://app.taiga.app/settings/api-keys');
|
|
16
15
|
process.exit(1);
|
|
17
16
|
}
|
|
18
17
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
const client = new TaigaClient({ baseUrl: apiUrl, apiKey });
|
|
18
|
+
const client = new TaigaClient({ baseUrl: config.apiUrl, apiKey: config.apiKey });
|
|
22
19
|
|
|
23
20
|
// Introspect the API key to discover available capabilities
|
|
24
21
|
const introspection = await client.introspect().catch((error) => {
|
|
@@ -55,8 +52,9 @@ async function main() {
|
|
|
55
52
|
'For product-specific work, use taiga_list_products to find the product,',
|
|
56
53
|
'then read its specification and architecture.',
|
|
57
54
|
'',
|
|
58
|
-
'When you notice a policy or factory instructions are outdated,
|
|
59
|
-
'
|
|
55
|
+
'When you notice a policy or factory instructions are outdated, fix the section with',
|
|
56
|
+
'taiga_update_document or taiga_update_factory_document. That saves a draft nobody follows yet;',
|
|
57
|
+
'publish it with taiga_publish_document or taiga_publish_factory_document only when the person asks.',
|
|
60
58
|
'This keeps the knowledge base accurate for everyone.',
|
|
61
59
|
].join('\n'),
|
|
62
60
|
}
|