@taiga-ai/mcp-server 0.8.0-dev.397 → 0.9.0-rc.403
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 +397 -14
- package/package.json +1 -1
- package/src/server.integration.test.ts +443 -0
- package/src/tools/catalogue.test.ts +31 -5
- package/src/tools/catalogue.ts +1 -1
- package/src/tools/context.ts +3 -5
- package/src/tools/errors.ts +30 -0
- package/src/tools/index.ts +5 -1
- package/src/tools/instructions.ts +4 -0
- package/src/tools/markdown.test.ts +83 -0
- package/src/tools/markdown.ts +82 -0
- package/src/tools/products.ts +38 -36
- package/src/tools/prompts.ts +264 -0
- package/src/tools/resources.ts +216 -0
- package/taiga-ai-mcp-server-0.9.0-rc.403.tgz +0 -0
- package/taiga-ai-mcp-server-0.8.0-dev.397.tgz +0 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { ErrorCode, type ReadResourceResult } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
5
|
+
import { RequestError, apiFailureText, isMissOrRefusal } from './errors.js';
|
|
6
|
+
import {
|
|
7
|
+
FACTORY_DOCUMENT_TYPES,
|
|
8
|
+
PRODUCT_DOCUMENT_ORIGINS,
|
|
9
|
+
PRODUCT_DOCUMENT_TYPES,
|
|
10
|
+
TENANT_DOCUMENT_TYPES,
|
|
11
|
+
defaultOriginFor,
|
|
12
|
+
productDocumentName,
|
|
13
|
+
type ProductDocumentOrigin,
|
|
14
|
+
type ProductDocumentType,
|
|
15
|
+
type ProductSettingsView,
|
|
16
|
+
} from './catalogue.js';
|
|
17
|
+
import { productId as productIdInput } from './inputs.js';
|
|
18
|
+
import { mimeTypeOf, renderDocument } from './markdown.js';
|
|
19
|
+
import { stripProvenance } from './sections.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The knowledge base as resources, so an IDE can attach a document with `@`
|
|
23
|
+
* the way it attaches a file. Every resource reads through the same routes
|
|
24
|
+
* and capability gates as the tool that reads it, and returns the same
|
|
25
|
+
* stripped content, rendered as Markdown where the document is a section
|
|
26
|
+
* map. `resources/list` names what is published; the templates say how to
|
|
27
|
+
* address the rest.
|
|
28
|
+
*
|
|
29
|
+
* A resource has no `isError` result: a refusal or a miss is a JSON-RPC
|
|
30
|
+
* error, InvalidParams (the caller named something that does not exist)
|
|
31
|
+
* with a message that says what does.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** One text resource, rendered. */
|
|
35
|
+
function contents(uri: string, doc: Record<string, unknown>): ReadResourceResult {
|
|
36
|
+
const rendered = renderDocument(stripProvenance(doc));
|
|
37
|
+
return { contents: [{ uri, mimeType: rendered.mimeType, text: rendered.text }] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function invalid(message: string): never {
|
|
41
|
+
throw new RequestError(ErrorCode.InvalidParams, message);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function requireOneOf<T extends string>(what: string, value: string, allowed: readonly T[]): T {
|
|
45
|
+
if (!(allowed as readonly string[]).includes(value))
|
|
46
|
+
invalid(`${what} "${value}" is not one of: ${allowed.join(', ')}`);
|
|
47
|
+
return value as T;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function requireId(what: string, value: string): string {
|
|
51
|
+
if (!productIdInput.safeParse(value).success) invalid(`${what} "${value}" is not an ID`);
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The read, with a miss or a refusal turned into guidance; anything else is the API failing and propagates. */
|
|
56
|
+
async function served<T>(read: Promise<T>, miss: string): Promise<T> {
|
|
57
|
+
try {
|
|
58
|
+
return await read;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (!isMissOrRefusal(error)) throw error;
|
|
61
|
+
return invalid(`${miss} (${apiFailureText(error)})`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function registerResources(server: McpServer, client: TaigaClient) {
|
|
66
|
+
if (client.hasCapability('tenant:documents:read')) {
|
|
67
|
+
const published = async () =>
|
|
68
|
+
((await client.listTenantDocuments({ limit: 100 })).data as Record<string, unknown>[]).filter(
|
|
69
|
+
(row) => row.isDraft === false
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
server.registerResource(
|
|
73
|
+
'organization-document',
|
|
74
|
+
new ResourceTemplate('taiga://documents/{type}', {
|
|
75
|
+
// What is published, so the client can offer it without guessing.
|
|
76
|
+
// A tenant list the API would not serve must not empty every other
|
|
77
|
+
// resource from the client's picker: this one lists nothing then.
|
|
78
|
+
list: async () => ({
|
|
79
|
+
resources: (await published().catch(() => [])).map((row) => ({
|
|
80
|
+
uri: `taiga://documents/${String(row.type)}`,
|
|
81
|
+
name: String(row.name ?? row.type),
|
|
82
|
+
description: typeof row.description === 'string' ? row.description : undefined,
|
|
83
|
+
mimeType: mimeTypeOf(row),
|
|
84
|
+
})),
|
|
85
|
+
}),
|
|
86
|
+
}),
|
|
87
|
+
{
|
|
88
|
+
title: 'Organization document',
|
|
89
|
+
description:
|
|
90
|
+
'A published organization document: a policy, the business goals, the design or brand standards, the ' +
|
|
91
|
+
'tone of voice or the standing instructions. Sections rendered as headings.',
|
|
92
|
+
},
|
|
93
|
+
async (uri, variables) => {
|
|
94
|
+
const type = requireOneOf('Document type', String(variables.type), TENANT_DOCUMENT_TYPES);
|
|
95
|
+
try {
|
|
96
|
+
return contents(uri.href, (await client.getTenantDocument(type)).data);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (!isMissOrRefusal(error)) throw error;
|
|
99
|
+
// The follow-up list is best effort, but a failed one is said to
|
|
100
|
+
// have failed, not read as "nothing is published".
|
|
101
|
+
const rest = await published().then(
|
|
102
|
+
(rows) => {
|
|
103
|
+
const names = rows.map((row) => String(row.type));
|
|
104
|
+
return names.length > 0 ? `Published documents: ${names.join(', ')}.` : 'Nothing is published yet.';
|
|
105
|
+
},
|
|
106
|
+
(reason: unknown) =>
|
|
107
|
+
`The published list could not be read (${reason instanceof TaigaApiError ? apiFailureText(reason) : 'the request failed'}).`
|
|
108
|
+
);
|
|
109
|
+
return invalid(`No published ${type} the key can read (${apiFailureText(error)}). ${rest}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (client.hasCapability('factories:read')) {
|
|
116
|
+
server.registerResource(
|
|
117
|
+
'factory-instructions',
|
|
118
|
+
new ResourceTemplate('taiga://factories/{factoryId}/instructions', { list: undefined }),
|
|
119
|
+
{
|
|
120
|
+
title: 'Factory instructions',
|
|
121
|
+
description:
|
|
122
|
+
"A factory's published standing guidance; taiga_list_factories gives the factory ID. Markdown for a " +
|
|
123
|
+
'section map, JSON otherwise (the read says which).',
|
|
124
|
+
},
|
|
125
|
+
async (uri, variables) => {
|
|
126
|
+
const factoryId = requireId('Factory ID', String(variables.factoryId));
|
|
127
|
+
const doc = await served(
|
|
128
|
+
client.getFactoryDocument(factoryId, FACTORY_DOCUMENT_TYPES[0]),
|
|
129
|
+
'This factory has not published instructions, or the key cannot see the factory'
|
|
130
|
+
);
|
|
131
|
+
return contents(uri.href, doc.data);
|
|
132
|
+
}
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (client.hasCapability('products:read')) {
|
|
137
|
+
const documentType = (value: string): ProductDocumentType => {
|
|
138
|
+
if (value.includes('?')) invalid(`Name the timeline as ?origin=plan or ?origin=reality, not "${value}"`);
|
|
139
|
+
return requireOneOf('Document type', value, PRODUCT_DOCUMENT_TYPES);
|
|
140
|
+
};
|
|
141
|
+
const read = async (uri: URL, productId: string, type: ProductDocumentType, origin: ProductDocumentOrigin) => {
|
|
142
|
+
const result = await served(
|
|
143
|
+
client.getProductDocument(productId, type, origin),
|
|
144
|
+
`No published ${origin} ${type} for this product, or the key cannot see it; taiga_list_product_documents shows what exists`
|
|
145
|
+
);
|
|
146
|
+
return contents(uri.href, { name: productDocumentName(type), ...(result.data as Record<string, unknown>) });
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// The timeline template first: the SDK tries templates in registration
|
|
150
|
+
// order, and a URI with ?origin= would otherwise bind the query into {type}.
|
|
151
|
+
server.registerResource(
|
|
152
|
+
'product-document-on-timeline',
|
|
153
|
+
new ResourceTemplate('taiga://products/{productId}/documents/{type}{?origin}', { list: undefined }),
|
|
154
|
+
{
|
|
155
|
+
title: 'Product document on a timeline',
|
|
156
|
+
description:
|
|
157
|
+
'A product document from the named timeline: origin=plan (discovery) or origin=reality (derived from the code). ' +
|
|
158
|
+
'Markdown for a section map, JSON otherwise (the read says which).',
|
|
159
|
+
},
|
|
160
|
+
async (uri, variables) => {
|
|
161
|
+
const productId = requireId('Product ID', String(variables.productId));
|
|
162
|
+
const type = documentType(String(variables.type));
|
|
163
|
+
const origin = requireOneOf('Origin', String(variables.origin), PRODUCT_DOCUMENT_ORIGINS);
|
|
164
|
+
return read(uri, productId, type, origin);
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
server.registerResource(
|
|
169
|
+
'product-document',
|
|
170
|
+
new ResourceTemplate('taiga://products/{productId}/documents/{type}', { list: undefined }),
|
|
171
|
+
{
|
|
172
|
+
title: 'Product document',
|
|
173
|
+
description:
|
|
174
|
+
"A product document from the product's own timeline (reality for an imported product, plan otherwise; " +
|
|
175
|
+
'the documents a person writes are always on the plan side). taiga_list_product_documents shows what ' +
|
|
176
|
+
'exists; add ?origin=plan or ?origin=reality to read the other side. Markdown for a section map, JSON ' +
|
|
177
|
+
'otherwise (the read says which).',
|
|
178
|
+
},
|
|
179
|
+
async (uri, variables) => {
|
|
180
|
+
const productId = requireId('Product ID', String(variables.productId));
|
|
181
|
+
const type = documentType(String(variables.type));
|
|
182
|
+
const product = (
|
|
183
|
+
await served(
|
|
184
|
+
client.getProduct(productId),
|
|
185
|
+
'No product with that ID the key can see; taiga_list_products shows them'
|
|
186
|
+
)
|
|
187
|
+
).data as ProductSettingsView;
|
|
188
|
+
return read(uri, productId, type, defaultOriginFor(product, type));
|
|
189
|
+
}
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (client.hasCapability('backlog:read')) {
|
|
194
|
+
server.registerResource(
|
|
195
|
+
'backlog-item',
|
|
196
|
+
new ResourceTemplate('taiga://backlog/{itemId}', { list: undefined }),
|
|
197
|
+
{
|
|
198
|
+
title: 'Backlog item',
|
|
199
|
+
description:
|
|
200
|
+
'An initiative in full: description, acceptance criteria, technical context. taiga_list_backlog_items ' +
|
|
201
|
+
'gives the item ID.',
|
|
202
|
+
mimeType: 'application/json',
|
|
203
|
+
},
|
|
204
|
+
async (uri, variables) => {
|
|
205
|
+
const itemId = requireId('Backlog item ID', String(variables.itemId));
|
|
206
|
+
const item = (
|
|
207
|
+
await served(
|
|
208
|
+
client.getBacklogItem(itemId),
|
|
209
|
+
'No backlog item with that ID the key can see; taiga_list_backlog_items shows what exists'
|
|
210
|
+
)
|
|
211
|
+
).data;
|
|
212
|
+
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(item, null, 2) }] };
|
|
213
|
+
}
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
Binary file
|
|
Binary file
|