@taiga-ai/mcp-server 0.4.0 → 0.5.0-rc.389

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taiga-ai/mcp-server",
3
- "version": "0.4.0",
3
+ "version": "0.5.0-rc.389",
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",
@@ -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) : undefined,
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 Error(`Taiga API ${method} ${path} failed (${response.status}): ${text}`);
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?: { factoryId?: string; status?: string; limit?: number }) {
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: unknown }>('GET', `/tenants/current/documents/${type}`);
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: unknown }>('PUT', `/tenants/current/documents/${type}/draft`, body);
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: unknown }>('GET', `/factories/${factoryId}/documents/${type}`);
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: unknown }>('PUT', `/factories/${factoryId}/documents/${type}/draft`, body);
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 apiKey = process.env.TAIGA_API_KEY;
13
- if (!apiKey) {
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 apiUrl = process.env.TAIGA_API_URL ?? DEFAULT_API_URL;
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, use the update tools',
59
- '(taiga_update_document, taiga_update_factory_document) to fix it.',
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
  }