@taiga-ai/mcp-server 0.6.0-rc.393 → 0.7.0-dev.395

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.
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
5
+ import {
6
+ FACTORY_DOCUMENT_TYPES,
7
+ POLICY_TYPES,
8
+ PRODUCT_DOCUMENT_TYPES,
9
+ PRODUCT_STATUSES,
10
+ TENANT_DOCUMENT_TYPES,
11
+ } from '@taiga/types';
12
+ import { TaigaClient } from '../client.js';
13
+ import { registerAllTools } from './index.js';
14
+ import { SERVER_INSTRUCTIONS } from './instructions.js';
15
+ import { PRODUCT_DOCUMENT_NAMES } from './catalogue.js';
16
+
17
+ /**
18
+ * What the model reads must match what exists. The walk-through that led to
19
+ * this file found two policy types named in a description that no tenant
20
+ * has; a model that follows a description into a type the API refuses learns
21
+ * to stop asking.
22
+ */
23
+
24
+ const ALL_CAPABILITIES = [
25
+ 'products:read',
26
+ 'factories:read',
27
+ 'factories:documents:write',
28
+ 'tenant:documents:read',
29
+ 'tenant:documents:write',
30
+ 'backlog:read',
31
+ ];
32
+
33
+ async function allTools() {
34
+ vi.stubGlobal(
35
+ 'fetch',
36
+ vi.fn().mockResolvedValue({
37
+ ok: true,
38
+ status: 200,
39
+ json: async () => ({
40
+ data: { userId: 'u', tenantId: 't', email: 'e', role: 'owner', capabilities: ALL_CAPABILITIES },
41
+ }),
42
+ text: async () => '',
43
+ })
44
+ );
45
+ const taigaClient = new TaigaClient({ baseUrl: 'https://api.test.com', apiKey: 'k' });
46
+ await taigaClient.introspect();
47
+ const server = new McpServer({ name: 't', version: '0' });
48
+ registerAllTools(server, taigaClient);
49
+ const client = new Client({ name: 'c', version: '0' });
50
+ const [ct, st] = InMemoryTransport.createLinkedPair();
51
+ await Promise.all([server.connect(st), client.connect(ct)]);
52
+ return (await client.listTools()).tools;
53
+ }
54
+
55
+ /** Every `policy_*` token in a text. */
56
+ const policyTokens = (text: string) => [...new Set(text.match(/\bpolicy_[a-z_]+\b/g) ?? [])];
57
+
58
+ describe('tool descriptions against the catalogue', () => {
59
+ beforeEach(() => vi.restoreAllMocks());
60
+
61
+ it('names no policy type the catalogue lacks, in any description or schema', async () => {
62
+ const tools = await allTools();
63
+ const text = JSON.stringify(tools) + SERVER_INSTRUCTIONS;
64
+
65
+ for (const token of policyTokens(text)) {
66
+ expect(TENANT_DOCUMENT_TYPES as readonly string[], token).toContain(token);
67
+ }
68
+ });
69
+
70
+ it('names every policy where the company documents are explained', async () => {
71
+ const tools = await allTools();
72
+ const list = tools.find((t) => t.name === 'taiga_list_documents')!;
73
+ const get = tools.find((t) => t.name === 'taiga_get_document')!;
74
+ const typeEnum = (get.inputSchema.properties as Record<string, { enum?: string[] }>).type!.enum;
75
+
76
+ for (const type of POLICY_TYPES) {
77
+ expect(list.description, type).toContain(type);
78
+ expect(SERVER_INSTRUCTIONS, type).toContain(type);
79
+ }
80
+ expect(typeEnum).toEqual([...TENANT_DOCUMENT_TYPES]);
81
+ });
82
+
83
+ it('offers exactly the product statuses, product document types and origins the API has', async () => {
84
+ const tools = await allTools();
85
+ const props = (name: string) =>
86
+ tools.find((t) => t.name === name)!.inputSchema.properties as Record<string, { enum?: string[] }>;
87
+
88
+ expect(props('taiga_list_products').status!.enum).toEqual([...PRODUCT_STATUSES]);
89
+ expect(props('taiga_get_product_document').type!.enum).toEqual([...PRODUCT_DOCUMENT_TYPES]);
90
+ expect(props('taiga_get_product_document').origin!.enum).toEqual(['plan', 'reality']);
91
+ expect(props('taiga_get_factory_document').type!.enum).toEqual([...FACTORY_DOCUMENT_TYPES]);
92
+ });
93
+
94
+ it('has a display name for every product document type, the one the web uses', () => {
95
+ expect(Object.keys(PRODUCT_DOCUMENT_NAMES).sort()).toEqual([...PRODUCT_DOCUMENT_TYPES].sort());
96
+ // apps/web/src/lib/documentTypes.ts: "Look & Feel", not "UI Direction".
97
+ expect(PRODUCT_DOCUMENT_NAMES.ui_direction).toBe('Look & Feel');
98
+ });
99
+
100
+ it('explains both timelines and the section metadata in the server instructions', () => {
101
+ expect(SERVER_INSTRUCTIONS).toMatch(/plan: .*discovery/);
102
+ expect(SERVER_INSTRUCTIONS).toMatch(/reality: .*merged pull request/);
103
+ for (const term of ['`source`', '`priority: core`', 'overlay-<id>', 'cloud-preset:<providers>', 'brownfield']) {
104
+ expect(SERVER_INSTRUCTIONS).toContain(term);
105
+ }
106
+ // Nothing writes a `user` source: a hand edit keeps the source it had.
107
+ expect(SERVER_INSTRUCTIONS).not.toMatch(/user: edited/);
108
+ });
109
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * What exists, from the one place that knows: the shared types package.
3
+ *
4
+ * Tool descriptions are what the model reads to decide what to call, and a
5
+ * description that names a document type the catalogue lacks sends it down a
6
+ * dead end (the walk-through that led here found `policy_acceptable_use` and
7
+ * `policy_ip` named in a description and nowhere else). Every list of types,
8
+ * origins and statuses a tool shows is derived from these constants, and a
9
+ * test holds the descriptions to them.
10
+ */
11
+ import {
12
+ FACTORY_DOCUMENT_TYPES,
13
+ POLICY_TYPES,
14
+ PRODUCT_DOCUMENT_DISPLAY_NAMES,
15
+ PRODUCT_DOCUMENT_ORIGINS,
16
+ PRODUCT_DOCUMENT_TYPES,
17
+ PRODUCT_STATUSES,
18
+ REALITY_DERIVED_DOCUMENT_TYPES,
19
+ TENANT_DOCUMENT_TYPES,
20
+ type ProductDocumentOrigin,
21
+ type ProductDocumentType,
22
+ type TenantDocumentType,
23
+ } from '@taiga/types';
24
+
25
+ export {
26
+ FACTORY_DOCUMENT_TYPES,
27
+ POLICY_TYPES,
28
+ PRODUCT_DOCUMENT_ORIGINS,
29
+ PRODUCT_DOCUMENT_TYPES,
30
+ PRODUCT_STATUSES,
31
+ TENANT_DOCUMENT_TYPES,
32
+ };
33
+ export type { ProductDocumentOrigin };
34
+
35
+ /**
36
+ * Display names for every product document type. The reviewable nine come
37
+ * from the shared map the web uses; the three the web never asks a person to
38
+ * review are named here, and the record type keeps the two sets complete.
39
+ */
40
+ export const PRODUCT_DOCUMENT_NAMES: Record<ProductDocumentType, string> = {
41
+ ...PRODUCT_DOCUMENT_DISPLAY_NAMES,
42
+ // "Look & Feel" is what the web calls it (apps/web/src/lib/documentTypes.ts);
43
+ // an agent and a person should name the same document the same way.
44
+ ui_direction: 'Look & Feel',
45
+ codebase_snapshot: 'Codebase Snapshot',
46
+ product_instructions: 'Product Instructions',
47
+ };
48
+
49
+ export function productDocumentName(type: string): string {
50
+ return (PRODUCT_DOCUMENT_NAMES as Record<string, string>)[type] ?? type;
51
+ }
52
+
53
+ /** The organization documents that are not policies: goals, standards, voice, instructions. */
54
+ export const OTHER_TENANT_DOCUMENT_TYPES: readonly TenantDocumentType[] = TENANT_DOCUMENT_TYPES.filter(
55
+ (type) => !(POLICY_TYPES as readonly string[]).includes(type)
56
+ );
57
+
58
+ /** `a, b, c` for a description. */
59
+ export function named(types: readonly string[]): string {
60
+ return types.join(', ');
61
+ }
62
+
63
+ /**
64
+ * The two document timelines of a product (ADR-017), in the words the tools
65
+ * and the server instructions use. One place, so the list tool, the read
66
+ * tool and the instructions cannot explain them three different ways.
67
+ */
68
+ export const ORIGIN_MEANING: Record<ProductDocumentOrigin, string> = {
69
+ plan:
70
+ 'plan: the discovery documents, authored intent. Every greenfield product has them; ' +
71
+ 'they say what is meant to be built.',
72
+ reality:
73
+ 'reality: derived from the code on the main branch, what is actually built. A brownfield product ' +
74
+ 'has them from its import; a greenfield product gets them once its first initiative is done (a ' +
75
+ 'merged pull request marks it done) or the repository is pushed to, and they refresh as code lands.',
76
+ };
77
+
78
+ /**
79
+ * Which timeline is a product's own (the API's documentOriginForSettings):
80
+ * an imported product has no plan chain by design, so its documents are on
81
+ * the reality side; everything else starts on the plan side. Derived from the
82
+ * same settings key the API reads, so the two cannot disagree.
83
+ */
84
+ export function isImportedProduct(product: ProductSettingsView | null | undefined): boolean {
85
+ const settings = product?.settings;
86
+ return (
87
+ typeof settings === 'object' &&
88
+ settings !== null &&
89
+ (settings as { projectType?: unknown }).projectType === 'brownfield'
90
+ );
91
+ }
92
+
93
+ /**
94
+ * The document types no derivation ever writes: a person records or writes
95
+ * them, so they live on the plan side of every product, imported or not.
96
+ */
97
+ export const PLAN_ONLY_DOCUMENT_TYPES: readonly ProductDocumentType[] = PRODUCT_DOCUMENT_TYPES.filter(
98
+ (type) => !(REALITY_DERIVED_DOCUMENT_TYPES as readonly string[]).includes(type)
99
+ );
100
+
101
+ /**
102
+ * The timeline a read should default to: the plan side for a type only a
103
+ * person writes, else the product's own side (reality for an imported
104
+ * product, plan for every other).
105
+ */
106
+ export function defaultOriginFor(
107
+ product: ProductSettingsView | null | undefined,
108
+ type?: ProductDocumentType
109
+ ): ProductDocumentOrigin {
110
+ if (type !== undefined && PLAN_ONLY_DOCUMENT_TYPES.includes(type)) return 'plan';
111
+ return isImportedProduct(product) ? 'reality' : 'plan';
112
+ }
113
+
114
+ /** The product settings the notes read: the import marker and where the import got to. */
115
+ export interface ProductSettingsView {
116
+ settings?: unknown;
117
+ }
118
+
119
+ /**
120
+ * Where an imported product's import got to (`settings.importStatus`, written
121
+ * by the API: pending, analyzing, complete, cancelled, failed). Unknown values
122
+ * read as "not known", never as a promise.
123
+ */
124
+ function importStatusOf(product: ProductSettingsView | null | undefined): string | null {
125
+ const settings = product?.settings;
126
+ if (typeof settings !== 'object' || settings === null) return null;
127
+ const status = (settings as { importStatus?: unknown }).importStatus;
128
+ return typeof status === 'string' ? status : null;
129
+ }
130
+
131
+ /** Why a side of the timeline is empty, for a list or a miss that shows one. */
132
+ export function emptyOriginNote(
133
+ origin: ProductDocumentOrigin,
134
+ product: ProductSettingsView | null | undefined
135
+ ): string {
136
+ const imported = isImportedProduct(product);
137
+ if (origin === 'plan') {
138
+ return imported
139
+ ? 'This product was imported, so no discovery chain was generated for it: its derived documents are on ' +
140
+ `the reality side. Only the documents a person writes (${PLAN_ONLY_DOCUMENT_TYPES.map(productDocumentName).join(', ')}) ` +
141
+ 'would appear here.'
142
+ : 'No plan documents are published yet: they are written during discovery in Taiga.';
143
+ }
144
+ if (!imported) {
145
+ return (
146
+ 'No reality documents yet: they are derived from the code once the first initiative is done (a merged ' +
147
+ 'pull request marks it done) or the repository is pushed to. Until then read the plan side.'
148
+ );
149
+ }
150
+ switch (importStatusOf(product)) {
151
+ case 'pending':
152
+ return 'No reality documents yet: the import has not started. Link the repository and run the import in Taiga.';
153
+ // generating_docs is the retired name for the same in-flight state
154
+ // (PRO-1161); rows that predate it can still carry it.
155
+ case 'analyzing':
156
+ case 'generating_docs':
157
+ return 'No reality documents yet: the import is running and will derive them from the code.';
158
+ case 'cancelled':
159
+ return 'No reality documents: the import was cancelled before it derived any. Run it again in Taiga.';
160
+ case 'failed':
161
+ return 'No reality documents: the import failed before it derived any. Run it again in Taiga.';
162
+ default:
163
+ return 'No reality documents: the import derives them from the code, and none have been published.';
164
+ }
165
+ }
@@ -2,10 +2,40 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
3
  import { TaigaApiError, type TaigaClient, type TaigaDocument } from '../client.js';
4
4
  import { READ_ONLY } from './annotations.js';
5
+ import {
6
+ FACTORY_DOCUMENT_TYPES,
7
+ OTHER_TENANT_DOCUMENT_TYPES,
8
+ POLICY_TYPES,
9
+ TENANT_DOCUMENT_TYPES,
10
+ named,
11
+ } from './catalogue.js';
5
12
  import { runTool, toolError, toolResult } from './errors.js';
6
- import { applySectionEdit, sectionContent, sectionsOf } from './sections.js';
7
- import { documentType } from './inputs.js';
8
- import { summarizeList } from './summarize.js';
13
+ import { applySectionEdit, sectionContent, sectionsOf, stripProvenance } from './sections.js';
14
+ import { pickFields } from './summarize.js';
15
+
16
+ /**
17
+ * What a document row needs to say for the model to pick one and read it in
18
+ * full. Not the content, not who wrote it.
19
+ */
20
+ const DOCUMENT_SUMMARY_FIELDS = ['type', 'name', 'description', 'version', 'isDraft', 'hasDraft', 'updatedAt'] as const;
21
+
22
+ const summaries = (rows: unknown[]) => pickFields(rows as Record<string, unknown>[], DOCUMENT_SUMMARY_FIELDS);
23
+
24
+ /** What a lookup behind a miss said when it failed, so an unknown stays unknown. */
25
+ function failedLookup(what: string, reason: unknown): string {
26
+ const said = reason instanceof TaigaApiError ? `${reason.status} ${reason.code ?? ''}`.trim() : 'the request failed';
27
+ return `${what} could not be checked (${said}).`;
28
+ }
29
+
30
+ /** `a (v3), b (v1)` for a not-found message. */
31
+ function publishedList(rows: unknown[]): string {
32
+ // The list routes return the latest draft rows beside the published ones,
33
+ // and a draft named as published sends the model into a second miss.
34
+ return (rows as { type: string; version: unknown; isDraft: boolean }[])
35
+ .filter((row) => row.isDraft === false)
36
+ .map((row) => `${row.type} (v${String(row.version)})`)
37
+ .join(', ');
38
+ }
9
39
 
10
40
  const DRAFT_WRITE = {
11
41
  readOnlyHint: false,
@@ -26,9 +56,9 @@ const sectionId = z
26
56
  'An id that does not exist yet adds a section.'
27
57
  );
28
58
  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
- );
59
+ const tenantDocumentType = z
60
+ .enum(TENANT_DOCUMENT_TYPES)
61
+ .describe(`Document type: a policy (${named(POLICY_TYPES)}) or ${named(OTHER_TENANT_DOCUMENT_TYPES)}`);
32
62
 
33
63
  /**
34
64
  * The read, save and publish calls for one document scope. The tools are the
@@ -100,10 +130,11 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
100
130
  {
101
131
  title: 'List company documents',
102
132
  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.',
133
+ "List the organization's documents as summaries (type, name, description, version, draft state). " +
134
+ `The ${POLICY_TYPES.length} policies (${named(POLICY_TYPES)}) define the security, data privacy, SDLC, operations, ` +
135
+ 'architecture and compliance rules that apply across all factories and products; the other ' +
136
+ `documents (${named(OTHER_TENANT_DOCUMENT_TYPES)}) carry goals, standards, voice and standing ` +
137
+ 'instructions. Use taiga_get_document with the type to read one in full.',
107
138
  inputSchema: {
108
139
  limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
109
140
  },
@@ -112,8 +143,7 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
112
143
  (params) =>
113
144
  runTool(async () => {
114
145
  const result = await client.listTenantDocuments(params);
115
- const summaries = summarizeList(result.data as Record<string, unknown>[]);
116
- return toolResult(summaries);
146
+ return toolResult(summaries(result.data));
117
147
  })
118
148
  );
119
149
 
@@ -122,23 +152,47 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
122
152
  {
123
153
  title: 'Get company document',
124
154
  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.',
155
+ "Get the full content of one of the organization's documents, sections included. " +
156
+ 'Use this when you need the security requirements, data handling rules, architecture standards ' +
157
+ 'or compliance controls that apply to your code, or the goals, standards and voice the ' +
158
+ 'organization has set down. A read that finds nothing says which documents are published and ' +
159
+ 'whether a draft exists.',
130
160
  inputSchema: {
131
- type: documentType.describe(
132
- 'Document type -- get available types from taiga_list_documents. ' +
133
- 'Common: policy_security, policy_data, policy_sdlc, policy_architecture'
134
- ),
161
+ type: tenantDocumentType,
135
162
  },
136
163
  annotations: READ_ONLY,
137
164
  },
138
165
  (params) =>
139
166
  runTool(async () => {
140
- const result = await client.getTenantDocument(params.type);
141
- return toolResult(result.data);
167
+ try {
168
+ const result = await client.getTenantDocument(params.type);
169
+ return toolResult(stripProvenance(result.data));
170
+ } catch (error) {
171
+ if (!(error instanceof TaigaApiError) || error.status !== 404) throw error;
172
+ // Best effort, each part on its own: a failed lookup must not turn
173
+ // a miss into an outage, nor blank the part that did answer.
174
+ const [draft, all] = await Promise.allSettled([
175
+ client.getTenantDocumentDraft(params.type),
176
+ client.listTenantDocuments({ limit: 100 }),
177
+ ]);
178
+ const parts = [`No published ${params.type}.`];
179
+ if (draft.status === 'fulfilled') {
180
+ parts.push(
181
+ draft.value.data
182
+ ? 'A draft exists; taiga_publish_document publishes it once the person asks.'
183
+ : 'There is no draft either.'
184
+ );
185
+ } else {
186
+ parts.push(failedLookup('The draft', draft.reason));
187
+ }
188
+ if (all.status === 'fulfilled') {
189
+ const published = publishedList(all.value.data);
190
+ parts.push(published ? `Published documents: ${published}.` : 'Nothing is published yet.');
191
+ } else {
192
+ parts.push(failedLookup('The published list', all.reason));
193
+ }
194
+ return toolError('NOT_FOUND', parts.join(' '));
195
+ }
142
196
  })
143
197
  );
144
198
  }
@@ -199,9 +253,10 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
199
253
  {
200
254
  title: 'List factory documents',
201
255
  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 " +
256
+ "List a factory's documents, published and draft, as summaries (type, name, version, draft state). " +
257
+ `A factory has one document type, ${named(FACTORY_DOCUMENT_TYPES)}: its free-form standing guidance ` +
204
258
  '(domain context, tech preferences, conventions) that applies to every product it owns. ' +
259
+ 'Drafts are listed too (isDraft true); an empty list means neither a publication nor a draft exists. ' +
205
260
  'Use taiga_get_factory_document to read the full content.',
206
261
  inputSchema: {
207
262
  factoryId: z.string().uuid().describe('Factory ID -- get from taiga_list_factories'),
@@ -212,8 +267,7 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
212
267
  (params) =>
213
268
  runTool(async () => {
214
269
  const result = await client.listFactoryDocuments(params.factoryId, { limit: params.limit });
215
- const summaries = summarizeList(result.data as Record<string, unknown>[]);
216
- return toolResult(summaries);
270
+ return toolResult(summaries(result.data));
217
271
  })
218
272
  );
219
273
 
@@ -224,20 +278,43 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
224
278
  description:
225
279
  "Get the full content of a factory's instructions. " +
226
280
  'Use this when you need the domain context, tech preferences, or conventions ' +
227
- 'the factory expects every product it owns to follow.',
281
+ 'the factory expects every product it owns to follow. A read that finds nothing says whether ' +
282
+ 'a draft exists and how to write one.',
228
283
  inputSchema: {
229
284
  factoryId: z.string().uuid().describe('Factory ID -- get from taiga_list_factories'),
230
- type: documentType.describe(
231
- 'Document type -- get available types from taiga_list_factory_documents. ' +
232
- 'The factory instructions document is `factory_instructions`.'
233
- ),
285
+ type: z.enum(FACTORY_DOCUMENT_TYPES).default('factory_instructions').describe('Document type'),
234
286
  },
235
287
  annotations: READ_ONLY,
236
288
  },
237
289
  (params) =>
238
290
  runTool(async () => {
239
- const result = await client.getFactoryDocument(params.factoryId, params.type);
240
- return toolResult(result.data);
291
+ try {
292
+ const result = await client.getFactoryDocument(params.factoryId, params.type);
293
+ return toolResult(stripProvenance(result.data));
294
+ } catch (error) {
295
+ if (!(error instanceof TaigaApiError) || error.status !== 404) throw error;
296
+ // A factory that does not exist answers the same 404 as one with
297
+ // nothing published, and the draft route does not check the
298
+ // factory for a break-glass key: read the factory first, so its
299
+ // 404 propagates as the factory missing rather than as "nothing
300
+ // published yet".
301
+ await client.getFactory(params.factoryId);
302
+ // The draft route answers 200 with null for a visible factory
303
+ // with no draft, so its 404 is also the factory out of scope and
304
+ // propagates. Any other failure is named.
305
+ const detail = await client
306
+ .getFactoryDocumentDraft(params.factoryId, params.type)
307
+ .then((draft) =>
308
+ draft.data
309
+ ? 'A draft exists; taiga_publish_factory_document publishes it once the person asks.'
310
+ : 'There is no draft either: write one with taiga_update_factory_document, then publish it when the person asks.'
311
+ )
312
+ .catch((reason: unknown) => {
313
+ if (reason instanceof TaigaApiError && reason.status === 404) throw reason;
314
+ return failedLookup('The draft', reason);
315
+ });
316
+ return toolError('NOT_FOUND', `This factory has no published ${params.type} yet. ${detail}`);
317
+ }
241
318
  })
242
319
  );
243
320
  }
@@ -254,7 +331,10 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
254
331
  publish: () => client.publishFactoryDocument(factoryId, type),
255
332
  });
256
333
  const factoryId = z.string().uuid().describe('Factory ID -- get from taiga_list_factories');
257
- const factoryDocumentType = documentType.describe('Document type (`factory_instructions`)');
334
+ const factoryDocumentType = z
335
+ .enum(FACTORY_DOCUMENT_TYPES)
336
+ .default('factory_instructions')
337
+ .describe('Document type');
258
338
 
259
339
  server.registerTool(
260
340
  'taiga_update_factory_document',
@@ -0,0 +1,65 @@
1
+ import {
2
+ OTHER_TENANT_DOCUMENT_TYPES,
3
+ ORIGIN_MEANING,
4
+ POLICY_TYPES,
5
+ PRODUCT_DOCUMENT_NAMES,
6
+ named,
7
+ } from './catalogue.js';
8
+
9
+ /**
10
+ * What the server tells a client at connect time: the knowledge base, the
11
+ * order to read it in, the two document timelines, what section metadata
12
+ * means, and when to write. Kept as data so a test can hold it to the
13
+ * catalogue the same way it holds the tool descriptions.
14
+ */
15
+ export const SERVER_INSTRUCTIONS = [
16
+ "You have access to Taiga, a policy-aware software development platform: the organization's",
17
+ 'engineering knowledge base, its products, their backlog and what the coding agent has built.',
18
+ '',
19
+ '**Company documents** (taiga_list_documents / taiga_get_document):',
20
+ `The ${POLICY_TYPES.length} policies (${named(POLICY_TYPES)}) carry the security, data, SDLC, operations and`,
21
+ `architecture rules that apply to all code. The other documents (${named(OTHER_TENANT_DOCUMENT_TYPES)})`,
22
+ 'carry the business goals, design and brand standards, tone of voice and standing instructions.',
23
+ 'Read the relevant policy BEFORE writing code that touches security, data handling or infrastructure.',
24
+ '',
25
+ '**Factory instructions** (taiga_list_factory_documents / taiga_get_factory_document):',
26
+ "A factory's standing guidance: domain context (ubiquitous language, entities, business rules),",
27
+ 'tech preferences (approved stack, patterns) and conventions. Read these to understand HOW this factory',
28
+ 'builds software. A factory that has not written any yet has none; that is normal.',
29
+ '',
30
+ '**Product documents** (taiga_list_product_documents / taiga_get_product_document):',
31
+ `${Object.entries(PRODUCT_DOCUMENT_NAMES)
32
+ .map(([type, name]) => `${name} (${type})`)
33
+ .join(', ')}.`,
34
+ 'A product has two timelines of these, and the list shows both:',
35
+ `- ${ORIGIN_MEANING.plan}`,
36
+ `- ${ORIGIN_MEANING.reality}`,
37
+ 'An imported (brownfield) product has no generated discovery chain: its derived documents are on the',
38
+ 'reality side, while the documents a person writes (Look & Feel, Product Instructions) are on the plan',
39
+ 'side of every product. Omit origin on a read and the server picks the right side (the list reports the',
40
+ "product's own as defaultOrigin). To understand",
41
+ 'what to build, read the plan where there is one; to understand the code you are changing, read reality',
42
+ 'when it exists, the ground truth for architecture, data flow and threats as built.',
43
+ '',
44
+ '**Backlog and runs** (taiga_list_backlog_items / taiga_get_backlog_item / taiga_list_builds / taiga_get_build):',
45
+ "The product's initiatives with their acceptance criteria, and what Taiga's coding agent already built",
46
+ 'for them: runs, steps, commits and pull requests.',
47
+ '',
48
+ '**Sections.** Policies and instructions are maps of sections. Each section carries its text and two',
49
+ 'provenance fields you can read past: `source` says where the text originally came from (template:',
50
+ "Taiga's standard text; overlay-<id>: a jurisdiction or industry overlay; cloud-preset:<providers>:",
51
+ "rendered for the organization's cloud providers; any other tag names the derivation that wrote the",
52
+ 'text; a hand edit keeps the source it had) and',
53
+ '`priority: core` marks the sections every organization keeps. Neither changes what the section',
54
+ 'requires of you.',
55
+ '',
56
+ 'Workflow: inside a git checkout, start with taiga_resolve_product on the remote URL to find the product.',
57
+ 'Otherwise use taiga_list_factories and taiga_list_products. Then read the factory instructions,',
58
+ "the product's specification and architecture, and the backlog item you are working on. A document",
59
+ 'read that finds nothing tells you what does exist and which tool reads or writes it.',
60
+ '',
61
+ 'When you notice a policy or factory instructions are outdated, fix the section with',
62
+ 'taiga_update_document or taiga_update_factory_document. That saves a draft nobody follows yet;',
63
+ 'publish it with taiga_publish_document or taiga_publish_factory_document only when the person asks.',
64
+ 'This keeps the knowledge base accurate for everyone.',
65
+ ].join('\n');