@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.
- package/dist/index.js +1151 -128
- package/package.json +1 -1
- package/src/client.test.ts +19 -0
- package/src/client.ts +11 -3
- package/src/config.test.ts +1 -1
- package/src/config.ts +4 -1
- package/src/index.ts +3 -30
- package/src/server.integration.test.ts +644 -9
- package/src/tools/catalogue.test.ts +109 -0
- package/src/tools/catalogue.ts +165 -0
- package/src/tools/documents.ts +115 -35
- package/src/tools/instructions.ts +65 -0
- package/src/tools/products.ts +234 -43
- package/src/tools/sections.test.ts +34 -1
- package/src/tools/sections.ts +23 -1
- package/src/tools/summarize.ts +3 -34
- package/taiga-ai-mcp-server-0.7.0-dev.395.tgz +0 -0
- package/src/tools/inputs.test.ts +0 -19
- package/src/tools/inputs.ts +0 -13
- package/taiga-ai-mcp-server-0.6.0-rc.393.tgz +0 -0
|
@@ -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
|
+
}
|
package/src/tools/documents.ts
CHANGED
|
@@ -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 {
|
|
8
|
-
|
|
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 =
|
|
30
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
'that apply across all factories and products
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
'Use this when you need
|
|
127
|
-
'
|
|
128
|
-
'
|
|
129
|
-
'
|
|
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:
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
240
|
-
|
|
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 =
|
|
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');
|