@taiga-ai/mcp-server 0.16.0-dev.437 → 0.17.0-dev.438
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 +451 -146
- package/package.json +1 -1
- package/src/client.ts +1 -0
- package/src/server.integration.test.ts +374 -17
- package/src/tools/autonomy.ts +3 -3
- package/src/tools/backlogs.ts +68 -23
- package/src/tools/builds.ts +44 -10
- package/src/tools/catalogue.test.ts +11 -1
- package/src/tools/catalogue.ts +64 -3
- package/src/tools/context.ts +12 -2
- package/src/tools/documents.ts +17 -8
- package/src/tools/errors.ts +18 -9
- package/src/tools/hints.ts +71 -0
- package/src/tools/inbox.ts +16 -7
- package/src/tools/index.ts +8 -2
- package/src/tools/inputs.ts +16 -4
- package/src/tools/instructions.ts +40 -14
- package/src/tools/jobs.ts +2 -1
- package/src/tools/knowledge.ts +6 -6
- package/src/tools/maintenance.ts +32 -12
- package/src/tools/observe.ts +48 -7
- package/src/tools/products.ts +65 -40
- package/src/tools/prompts.ts +5 -3
- package/src/tools/repositories.ts +11 -14
- package/src/tools/sections.ts +24 -0
- package/src/tools/setup.ts +82 -15
- package/src/tools/summarize.ts +44 -0
- package/src/tools/wait.ts +4 -3
- package/taiga-ai-mcp-server-0.17.0-dev.438.tgz +0 -0
- package/taiga-ai-mcp-server-0.16.0-dev.437.tgz +0 -0
package/src/tools/inbox.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import type { TaigaClient } from '../client.js';
|
|
4
4
|
import { OPERATE, READ_ONLY } from './annotations.js';
|
|
5
5
|
import { runTool, toolError, toolResult } from './errors.js';
|
|
6
|
-
import { pagination, productId } from './inputs.js';
|
|
6
|
+
import { idInput, pagination, productId } from './inputs.js';
|
|
7
7
|
import { page, pickFields } from './summarize.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -19,6 +19,7 @@ type Row = Record<string, unknown>;
|
|
|
19
19
|
const ITEM_FIELDS = [
|
|
20
20
|
'id',
|
|
21
21
|
'productId',
|
|
22
|
+
'productName',
|
|
22
23
|
'category',
|
|
23
24
|
'kind',
|
|
24
25
|
'subjectType',
|
|
@@ -36,6 +37,7 @@ const ITEM_FIELDS = [
|
|
|
36
37
|
const SETUP_TASK_FIELDS = [
|
|
37
38
|
'id',
|
|
38
39
|
'productId',
|
|
40
|
+
'productName',
|
|
39
41
|
'category',
|
|
40
42
|
'phase',
|
|
41
43
|
'target',
|
|
@@ -61,7 +63,7 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
|
|
|
61
63
|
'initiatives, a document, a pull request, maintenance findings, new policy violations), each a decision ' +
|
|
62
64
|
'an agent handed back rather than made, and things to set up that only a person can (a secret, an ' +
|
|
63
65
|
'environment variable, an account, a package registry, a cloud permission). Across every product the key ' +
|
|
64
|
-
'can open, or one product. category gate is the reviews, setup the setup items; resolved: true reads the ' +
|
|
66
|
+
'can open (each row names its product), or one product. category gate is the reviews, setup the setup items; resolved: true reads the ' +
|
|
65
67
|
"archive of done and dismissed ones. The inbox is shared: it is the team's, not one person's. " +
|
|
66
68
|
'An item snoozed until later is left out until then, as the page leaves it out (snoozedHidden counts ' +
|
|
67
69
|
'them). taiga_list_setup_tasks has the setup tasks with what exactly to set.',
|
|
@@ -106,13 +108,20 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
|
|
|
106
108
|
'not store or display), the provider page to do it on, and the phase that says when it blocks you ' +
|
|
107
109
|
'(prereq: before the deploy, which fails without it; wiring: connecting generated values back to ' +
|
|
108
110
|
'providers; followup: billing approvals, extra permissions and the like that can wait until after launch). ' +
|
|
109
|
-
'A build does not wait for any of them. Across every product the key can open
|
|
110
|
-
|
|
111
|
+
'A build does not wait for any of them. Across every product the key can open (each row names its ' +
|
|
112
|
+
'product), or one. The tasks still to do, oldest first, the order they depend on each other; ' +
|
|
113
|
+
'includeDone: true adds the done and dismissed ones.',
|
|
114
|
+
inputSchema: z.object({
|
|
115
|
+
productId: productId.optional(),
|
|
116
|
+
includeDone: z.boolean().optional().describe('Also the tasks already done or dismissed (default false)'),
|
|
117
|
+
...pagination,
|
|
118
|
+
}),
|
|
111
119
|
annotations: READ_ONLY,
|
|
112
120
|
},
|
|
113
121
|
(params) =>
|
|
114
122
|
runTool(async () => {
|
|
115
|
-
const
|
|
123
|
+
const { includeDone, ...rest } = params;
|
|
124
|
+
const result = await client.listSetupTasks({ ...rest, ...(includeDone ? {} : { openOnly: true }) });
|
|
116
125
|
return toolResult(page(pickFields(result.data as Row[], SETUP_TASK_FIELDS), result.nextCursor));
|
|
117
126
|
})
|
|
118
127
|
);
|
|
@@ -133,7 +142,7 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
|
|
|
133
142
|
'document was reviewed, and setting it back to in_progress withdraws that; do it only when the person ' +
|
|
134
143
|
'says they read them. Ask the person before clearing what they have not dealt with.',
|
|
135
144
|
inputSchema: z.object({
|
|
136
|
-
itemId:
|
|
145
|
+
itemId: idInput('Action Required item', 'taiga_list_action_required gives it'),
|
|
137
146
|
status: z.enum(['in_progress', 'done', 'dismissed']).optional().describe('What became of it'),
|
|
138
147
|
snoozedUntil: z
|
|
139
148
|
.string()
|
|
@@ -147,7 +156,7 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
|
|
|
147
156
|
(params) =>
|
|
148
157
|
runTool(async () => {
|
|
149
158
|
if (params.status === undefined && params.snoozedUntil === undefined) {
|
|
150
|
-
return toolError('
|
|
159
|
+
return toolError('VALIDATION_ERROR', 'Say what became of the item (status) or until when to snooze it.');
|
|
151
160
|
}
|
|
152
161
|
const result = await client.updateInboxItem(params.itemId, {
|
|
153
162
|
...(params.status !== undefined ? { status: params.status } : {}),
|
package/src/tools/index.ts
CHANGED
|
@@ -18,10 +18,12 @@ import { registerQueueTools } from './queue.js';
|
|
|
18
18
|
import { registerRepositoryTools } from './repositories.js';
|
|
19
19
|
import { registerResources } from './resources.js';
|
|
20
20
|
import { registerSetupTools } from './setup.js';
|
|
21
|
+
import { notFoundHint, notFoundHints } from './hints.js';
|
|
21
22
|
import { DEFAULT_WAIT_OPTIONS, registerWaitTools, type WaitOptions } from './wait.js';
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
|
-
* Every tool refuses an argument it does not take
|
|
25
|
+
* Every tool refuses an argument it does not take, and every tool's 404 says
|
|
26
|
+
* where the right IDs are (hints.ts). A plain object schema
|
|
25
27
|
* strips one silently: a review note passed to a tool with no place for it
|
|
26
28
|
* vanished and the call still succeeded, and a misspelt option quietly
|
|
27
29
|
* became its default. Strict, the call fails with "Unrecognized key" and the
|
|
@@ -36,7 +38,11 @@ function withStrictInputs(server: McpServer): McpServer {
|
|
|
36
38
|
throw new Error(`Tool ${name}: inputSchema must be a z.object() so unknown arguments can be refused`);
|
|
37
39
|
}
|
|
38
40
|
// .strict() keeps the object's own checks (a refine stays a refine).
|
|
39
|
-
|
|
41
|
+
// The call runs with its not-found hint in scope, for runTool to add to a 404.
|
|
42
|
+
const handler = callback as (args: unknown, ctx: unknown) => unknown;
|
|
43
|
+
const withHint = (args: unknown, ctx: unknown) =>
|
|
44
|
+
notFoundHints.run(notFoundHint(name, args), () => handler(args, ctx));
|
|
45
|
+
return register(name, schema ? { ...config, inputSchema: schema.strict() } : config, withHint);
|
|
40
46
|
}) as unknown as McpServer['registerTool'];
|
|
41
47
|
return server;
|
|
42
48
|
}
|
package/src/tools/inputs.ts
CHANGED
|
@@ -2,10 +2,22 @@ import { z } from 'zod';
|
|
|
2
2
|
|
|
3
3
|
/** The inputs several tools share, so a product or a page is named the same way everywhere. */
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
/**
|
|
6
|
+
* An ID input whose refusal says where the ID comes from. The client shows the
|
|
7
|
+
* schema's message, not the description, when a call fails validation, and
|
|
8
|
+
* "Invalid UUID" alone sends a model that passed a name to guess again.
|
|
9
|
+
*/
|
|
10
|
+
export function idInput(what: string, from: string) {
|
|
11
|
+
return z
|
|
12
|
+
.string()
|
|
13
|
+
.uuid({ message: `A ${what} ID is a UUID, not a name: ${from}` })
|
|
14
|
+
.describe(`${what[0]!.toUpperCase()}${what.slice(1)} ID -- ${from}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const productId = idInput(
|
|
18
|
+
'product',
|
|
19
|
+
'taiga_list_products (query finds one by name), taiga_resolve_product or taiga_get_product_context give it'
|
|
20
|
+
);
|
|
9
21
|
|
|
10
22
|
/** The page inputs every list tool takes; `nextCursor` of one page is the `cursor` of the next. */
|
|
11
23
|
export const pagination = {
|
|
@@ -7,15 +7,47 @@ import {
|
|
|
7
7
|
} from './catalogue.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* What the server tells a client at connect time:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* catalogue the same way it holds the tool descriptions.
|
|
10
|
+
* What the server tells a client at connect time: where to start, when to ask
|
|
11
|
+
* before writing, the words, then the knowledge base, the two document
|
|
12
|
+
* timelines, what section metadata means. Kept as data so a test can hold it
|
|
13
|
+
* to the catalogue the same way it holds the tool descriptions.
|
|
14
|
+
*
|
|
15
|
+
* The order is the point. Clients cut long instructions (Claude Code shows
|
|
16
|
+
* about the first 2,000 characters), so what an agent needs before its first
|
|
17
|
+
* call -- start with the context read, ask before paid or shared writes, what
|
|
18
|
+
* a refusal means, which word means what -- comes first, under
|
|
19
|
+
* INSTRUCTIONS_HEAD_LIMIT, and the reference material after it.
|
|
14
20
|
*/
|
|
15
|
-
export const
|
|
21
|
+
export const INSTRUCTIONS_HEAD = [
|
|
16
22
|
"You have access to Taiga, a policy-aware software development platform: the organization's",
|
|
17
23
|
'engineering knowledge base, its products, their backlog and what the coding agent has built.',
|
|
18
24
|
'',
|
|
25
|
+
'**Start here.** Call taiga_get_product_context first, naming the product by its git remote URL inside a',
|
|
26
|
+
'checkout (git remote get-url origin), else by slug or ID (taiga_list_products finds one by name with',
|
|
27
|
+
'query). It returns the product (and, while it is in setup, nextStep: what it waits for), its factory',
|
|
28
|
+
'instructions, its specification and architecture, every policy as its section ids, the open initiatives,',
|
|
29
|
+
'the repositories and the latest runs. Then read only what you need: the policy sections with',
|
|
30
|
+
'taiga_get_document, an initiative with taiga_get_backlog_item. Check Action Required',
|
|
31
|
+
'(taiga_list_action_required) for what waits on a person.',
|
|
32
|
+
'',
|
|
33
|
+
'**Before you write.** Tell the person what you are about to do and wait for their yes before a tool that',
|
|
34
|
+
'starts paid agent work (queueing, approving, resuming, requesting, importing, generating), interrupts it',
|
|
35
|
+
'(stopping, pausing) or changes what others read (publishing, resolving, settings, repositories). A client',
|
|
36
|
+
'that prompts on destructive tools does part of that for you.',
|
|
37
|
+
'',
|
|
38
|
+
'**Answers.** A refusal carries the API reason: say it as it is. NOT_FOUND names the tool that lists the',
|
|
39
|
+
'right IDs; an ID is a UUID, never a name. An empty result says why when it has a reason.',
|
|
40
|
+
'',
|
|
41
|
+
'**Words.** A backlog item is an initiative; a build is a run, the record of one attempt at it; Action',
|
|
42
|
+
'Required is the inbox; the line is the queue; a factory is the group that owns a set of products.',
|
|
43
|
+
].join('\n');
|
|
44
|
+
|
|
45
|
+
/** The most the head may take: under what a client shows before it cuts. */
|
|
46
|
+
export const INSTRUCTIONS_HEAD_LIMIT = 1900;
|
|
47
|
+
|
|
48
|
+
export const SERVER_INSTRUCTIONS = [
|
|
49
|
+
INSTRUCTIONS_HEAD,
|
|
50
|
+
'',
|
|
19
51
|
'**Company documents** (taiga_list_documents / taiga_get_document):',
|
|
20
52
|
`The ${POLICY_TYPES.length} policies (${named(POLICY_TYPES)}) carry the security, data, SDLC, operations and`,
|
|
21
53
|
`architecture rules that apply to all code. The other documents (${named(OTHER_TENANT_DOCUMENT_TYPES)})`,
|
|
@@ -110,15 +142,9 @@ export const SERVER_INSTRUCTIONS = [
|
|
|
110
142
|
'`priority: core` marks the sections every organization keeps. Neither changes what the section',
|
|
111
143
|
'requires of you.',
|
|
112
144
|
'',
|
|
113
|
-
'
|
|
114
|
-
|
|
115
|
-
'
|
|
116
|
-
'taiga_get_product_document to read by section), every policy as its section ids, the open backlog',
|
|
117
|
-
'items, the repositories and the latest runs in one call, and names any part it could not read. Then read',
|
|
118
|
-
'what you need in full: a policy with taiga_get_document, a backlog item with taiga_get_backlog_item.',
|
|
119
|
-
'Without it, use taiga_list_factories and taiga_list_products, then read the factory instructions,',
|
|
120
|
-
"the product's specification and architecture, and the backlog item you are working on. A document",
|
|
121
|
-
'read that finds nothing tells you what does exist and which tool reads or writes it.',
|
|
145
|
+
'Without taiga_get_product_context, use taiga_list_factories and taiga_list_products, then read the',
|
|
146
|
+
"factory instructions, the product's specification and architecture, and the initiative you are working",
|
|
147
|
+
'on. A document read that finds nothing tells you what does exist and which tool reads or writes it.',
|
|
122
148
|
'',
|
|
123
149
|
'The same documents are resources (taiga://documents/{type}, taiga://products/{productId}/documents/{type},',
|
|
124
150
|
'taiga://factories/{factoryId}/instructions, taiga://backlog/{itemId}) for clients that attach them, and three',
|
package/src/tools/jobs.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { createdInitiativeIdsOf, createdInitiativesOf, isBacklogGenerationEmptyResult } from '@taiga/types';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
+
import { idInput } from './inputs.js';
|
|
4
5
|
import type { TaigaClient } from '../client.js';
|
|
5
6
|
import { READ_ONLY } from './annotations.js';
|
|
6
7
|
import { runTool, toolResult } from './errors.js';
|
|
@@ -81,7 +82,7 @@ export function registerJobTools(server: McpServer, client: TaigaClient) {
|
|
|
81
82
|
'added, or why it added none (already covered by an existing initiative, not readable as work, outside ' +
|
|
82
83
|
'the product, or not allowed by a policy, with the policy, the line it runs into and the way to decide ' +
|
|
83
84
|
'otherwise). taiga_wait_for_job waits for a job to finish.',
|
|
84
|
-
inputSchema: z.object({ jobId:
|
|
85
|
+
inputSchema: z.object({ jobId: idInput('job', 'the tool that started the job returned it') }),
|
|
85
86
|
annotations: READ_ONLY,
|
|
86
87
|
},
|
|
87
88
|
(params) => runTool(async () => toolResult(jobReport((await client.getAgentJob(params.jobId)).data as Row)))
|
package/src/tools/knowledge.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import type { TaigaClient } from '../client.js';
|
|
4
4
|
import { OPERATE, READ_ONLY } from './annotations.js';
|
|
5
5
|
import { runTool, toolError, toolResult } from './errors.js';
|
|
6
|
-
import { pagination } from './inputs.js';
|
|
6
|
+
import { idInput, pagination, productId } from './inputs.js';
|
|
7
7
|
import { page, pickFields } from './summarize.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -34,11 +34,11 @@ const KNOWLEDGE_FIELDS = [
|
|
|
34
34
|
/** Past this many characters a text file's content is cut when read; the download URL has it all. */
|
|
35
35
|
const TEXT_LIMIT = 20_000;
|
|
36
36
|
|
|
37
|
-
const knowledgeId =
|
|
37
|
+
const knowledgeId = idInput('knowledge file', 'taiga_list_knowledge gives it');
|
|
38
38
|
|
|
39
39
|
const scopeInputs = {
|
|
40
|
-
factoryId:
|
|
41
|
-
productId:
|
|
40
|
+
factoryId: idInput('factory', 'taiga_list_factories gives it').optional().describe("A factory's knowledge"),
|
|
41
|
+
productId: productId.optional().describe("A product's knowledge"),
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
/** Bytes of a text file the read takes at most; the download URL has the rest. */
|
|
@@ -113,7 +113,7 @@ export function registerKnowledgeTools(server: McpServer, client: TaigaClient) {
|
|
|
113
113
|
(params) =>
|
|
114
114
|
runTool(async () => {
|
|
115
115
|
const twoLevels = oneLevel(params);
|
|
116
|
-
if (twoLevels) return toolError('
|
|
116
|
+
if (twoLevels) return toolError('VALIDATION_ERROR', twoLevels);
|
|
117
117
|
// One level at a time, as the page shows it: the route reads null as
|
|
118
118
|
// "none at this level", so the organization's files are the ones with
|
|
119
119
|
// no factory and no product, and a factory's the ones with no product.
|
|
@@ -200,7 +200,7 @@ export function registerKnowledgeTools(server: McpServer, client: TaigaClient) {
|
|
|
200
200
|
(params) =>
|
|
201
201
|
runTool(async () => {
|
|
202
202
|
const twoLevels = oneLevel(params);
|
|
203
|
-
if (twoLevels) return toolError('
|
|
203
|
+
if (twoLevels) return toolError('VALIDATION_ERROR', twoLevels);
|
|
204
204
|
const mime = params.fileName.toLowerCase().endsWith('.md') ? 'text/markdown' : 'text/plain';
|
|
205
205
|
const result = await client.addKnowledge({
|
|
206
206
|
fileName: params.fileName,
|
package/src/tools/maintenance.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { z } from 'zod';
|
|
|
9
9
|
import type { TaigaClient } from '../client.js';
|
|
10
10
|
import { OPERATE, READ_ONLY } from './annotations.js';
|
|
11
11
|
import { runTool, toolError, toolResult } from './errors.js';
|
|
12
|
-
import { pagination, productId } from './inputs.js';
|
|
12
|
+
import { idInput, pagination, productId } from './inputs.js';
|
|
13
13
|
import { page, pickFields } from './summarize.js';
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -75,7 +75,7 @@ const VIOLATION_FIELDS = [
|
|
|
75
75
|
'assessedAt',
|
|
76
76
|
] as const;
|
|
77
77
|
|
|
78
|
-
const findingId =
|
|
78
|
+
const findingId = idInput('finding', 'taiga_list_findings gives it');
|
|
79
79
|
|
|
80
80
|
export function registerMaintenanceTools(server: McpServer, client: TaigaClient) {
|
|
81
81
|
if (!client.hasCapability('products:read')) return;
|
|
@@ -167,7 +167,8 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
167
167
|
"Where a product stands against the organization's own published policies: the controls it violates, " +
|
|
168
168
|
"each with its violations (critical and high only, each citing the policy's sentence and the document " +
|
|
169
169
|
'passage that contradicts it), what is being done about each (an initiative raised, a fix in progress, ' +
|
|
170
|
-
'awaiting verification), and when the documents were derived and assessed
|
|
170
|
+
'awaiting verification), and when the documents were derived and assessed, led by a summary of the ' +
|
|
171
|
+
'counts. A control with no violation ' +
|
|
171
172
|
'is not listed, and it is not "passing" either: there is no such state. Pass full: true for every ' +
|
|
172
173
|
"violation's evidence and the resolved ones. Fix a control with taiga_fix_policy_violation.",
|
|
173
174
|
inputSchema: z.object({
|
|
@@ -182,23 +183,42 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
182
183
|
(params) =>
|
|
183
184
|
runTool(async () => {
|
|
184
185
|
const standing = (await client.getPolicyStanding(params.productId)).data as Row;
|
|
185
|
-
if (params.full) return toolResult(standing);
|
|
186
186
|
const controls = Array.isArray(standing.controls) ? (standing.controls as Row[]) : [];
|
|
187
187
|
const violations = Array.isArray(standing.divergences) ? (standing.divergences as Row[]) : [];
|
|
188
188
|
const resolved = Array.isArray(standing.resolvedDivergences) ? standing.resolvedDivergences.length : 0;
|
|
189
|
+
const listed = controls.filter((c) => Number(c.divergenceCount ?? 0) > 0 || Number(c.findingCount ?? 0) > 0);
|
|
190
|
+
const bySeverity: Record<string, number> = {};
|
|
191
|
+
for (const violation of violations) {
|
|
192
|
+
const severity = String(violation.severity ?? 'unknown');
|
|
193
|
+
bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
|
|
194
|
+
}
|
|
195
|
+
// The counts first, on both answers: the lists run to thousands of
|
|
196
|
+
// tokens, and "how bad is it" is the first question.
|
|
197
|
+
const summary = {
|
|
198
|
+
controls: listed.length,
|
|
199
|
+
violations: violations.length,
|
|
200
|
+
bySeverity,
|
|
201
|
+
resolvedViolations: resolved,
|
|
202
|
+
};
|
|
203
|
+
if (params.full) return toolResult({ summary, ...standing });
|
|
189
204
|
return toolResult({
|
|
205
|
+
summary,
|
|
190
206
|
productId: standing.productId,
|
|
191
207
|
hasPolicies: standing.hasPolicies,
|
|
192
208
|
assessment: standing.assessment,
|
|
193
209
|
documents: standing.documents,
|
|
194
210
|
// Only the controls something is recorded against; every other
|
|
195
211
|
// control is simply not reported, as the page does.
|
|
196
|
-
controls: pickFields(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
212
|
+
controls: pickFields(listed, [
|
|
213
|
+
'controlId',
|
|
214
|
+
'policyType',
|
|
215
|
+
'text',
|
|
216
|
+
'state',
|
|
217
|
+
'divergenceCount',
|
|
218
|
+
'findingCount',
|
|
219
|
+
'plannedCount',
|
|
220
|
+
]),
|
|
200
221
|
violations: pickFields(violations, VIOLATION_FIELDS),
|
|
201
|
-
resolvedViolations: resolved,
|
|
202
222
|
});
|
|
203
223
|
})
|
|
204
224
|
);
|
|
@@ -233,7 +253,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
233
253
|
(params) =>
|
|
234
254
|
runTool(async () => {
|
|
235
255
|
if (params.ignored && params.reason === undefined) {
|
|
236
|
-
return toolError('
|
|
256
|
+
return toolError('VALIDATION_ERROR', 'Say why the finding is ignored (reason).');
|
|
237
257
|
}
|
|
238
258
|
const body = params.ignored
|
|
239
259
|
? {
|
|
@@ -315,7 +335,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
315
335
|
inputSchema: z.object({
|
|
316
336
|
productId,
|
|
317
337
|
controlId: z.string().min(1).max(40).optional().describe("The control's identifier, e.g. SEC-3.4"),
|
|
318
|
-
violationId:
|
|
338
|
+
violationId: idInput('violation', 'taiga_get_policy_standing gives it').optional(),
|
|
319
339
|
}),
|
|
320
340
|
annotations: OPERATE,
|
|
321
341
|
},
|
|
@@ -323,7 +343,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
323
343
|
runTool(async () => {
|
|
324
344
|
if ((params.controlId === undefined) === (params.violationId === undefined)) {
|
|
325
345
|
return toolError(
|
|
326
|
-
'
|
|
346
|
+
'VALIDATION_ERROR',
|
|
327
347
|
'Name either a control (controlId) or one violation (violationId), not both or neither.'
|
|
328
348
|
);
|
|
329
349
|
}
|
package/src/tools/observe.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { z } from 'zod';
|
|
|
4
4
|
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
5
5
|
import { OPERATE, OPERATE_SETTING, READ_ONLY } from './annotations.js';
|
|
6
6
|
import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
|
|
7
|
-
import {
|
|
7
|
+
import { withNotFoundHint } from './hints.js';
|
|
8
|
+
import { idInput, pagination, productId } from './inputs.js';
|
|
8
9
|
import { page, pickFields } from './summarize.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -38,7 +39,7 @@ const DEPLOYMENT_FIELDS = [
|
|
|
38
39
|
|
|
39
40
|
const ENVIRONMENT_FIELDS = ['id', 'name', 'slug', 'type', 'ordinal', 'status', 'isProtected', 'url'] as const;
|
|
40
41
|
|
|
41
|
-
const environmentId =
|
|
42
|
+
const environmentId = idInput('environment', 'taiga_get_deployments lists them; a product ID is not one');
|
|
42
43
|
const windowDays = (choices: string) =>
|
|
43
44
|
z.number().int().optional().describe(`Days to look back: ${choices} (default 30)`);
|
|
44
45
|
|
|
@@ -104,6 +105,16 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
104
105
|
part(() => client.getDeploymentSyncStatus(params.productId)),
|
|
105
106
|
]);
|
|
106
107
|
const now = current.data as { current?: Row[]; deploying?: Row[] };
|
|
108
|
+
// An empty answer says why, as the maintenance read does: no
|
|
109
|
+
// environment, or none deployed to yet, is not "nothing is wrong".
|
|
110
|
+
const note =
|
|
111
|
+
environments.rows.length === 0
|
|
112
|
+
? 'This product has no environments yet: an environment is defined with taiga_define_environment, ' +
|
|
113
|
+
'and its deployments are mirrored from the provider once it is.'
|
|
114
|
+
: (history.data as Row[]).length === 0 && !params.cursor
|
|
115
|
+
? 'No deployments recorded yet: none was mirrored from the provider (syncStatus says when it last ' +
|
|
116
|
+
'synced), or nothing has been deployed.'
|
|
117
|
+
: null;
|
|
107
118
|
return toolResult({
|
|
108
119
|
environments: pickFields(environments.rows, ENVIRONMENT_FIELDS),
|
|
109
120
|
...(environments.more ? { moreEnvironments: true } : {}),
|
|
@@ -113,6 +124,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
113
124
|
...('value' in sync
|
|
114
125
|
? { syncStatus: sync.value.data }
|
|
115
126
|
: { syncStatus: null, syncUnavailable: sync.unavailable }),
|
|
127
|
+
...(note ? { note } : {}),
|
|
116
128
|
});
|
|
117
129
|
})
|
|
118
130
|
);
|
|
@@ -136,7 +148,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
136
148
|
runTool(async () => {
|
|
137
149
|
const days = params.windowDays ?? 30;
|
|
138
150
|
if (![30, 60, 90].includes(days)) {
|
|
139
|
-
return toolError('
|
|
151
|
+
return toolError('VALIDATION_ERROR', 'windowDays is 30, 60 or 90.');
|
|
140
152
|
}
|
|
141
153
|
return toolResult((await client.getDeliveryMetrics(params.productId, params.environmentId, days)).data);
|
|
142
154
|
})
|
|
@@ -164,10 +176,21 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
164
176
|
(params) =>
|
|
165
177
|
runTool(async () => {
|
|
166
178
|
const health = await client.getMonitoringHealth(params.productId);
|
|
167
|
-
if (!params.environmentId)
|
|
179
|
+
if (!params.environmentId) {
|
|
180
|
+
return toolResult({
|
|
181
|
+
health: health.data,
|
|
182
|
+
...(health.data.length === 0
|
|
183
|
+
? {
|
|
184
|
+
note:
|
|
185
|
+
'No environment is monitored: monitoring starts when an environment has its App URL ' +
|
|
186
|
+
'(taiga_define_environment, taiga_update_environment). No health is not good health.',
|
|
187
|
+
}
|
|
188
|
+
: {}),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
168
191
|
const days = params.windowDays ?? 30;
|
|
169
192
|
if (![30, 60, 90].includes(days)) {
|
|
170
|
-
return toolError('
|
|
193
|
+
return toolError('VALIDATION_ERROR', 'windowDays is 30, 60 or 90, the windows the page offers.');
|
|
171
194
|
}
|
|
172
195
|
const env = params.environmentId;
|
|
173
196
|
const [uptime, experience, clientHealth] = await Promise.all([
|
|
@@ -175,10 +198,28 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
175
198
|
part(() => client.getMonitoringSignal(params.productId, 'rum', env, days)),
|
|
176
199
|
part(() => client.getMonitoringSignal(params.productId, 'client-health', env, days)),
|
|
177
200
|
]);
|
|
201
|
+
// The API refuses an environment that is not the product's with 404
|
|
202
|
+
// on every signal: that is a wrong ID, not three signals missing.
|
|
203
|
+
if (
|
|
204
|
+
'unavailable' in uptime &&
|
|
205
|
+
[uptime, experience, clientHealth].every((p) => 'unavailable' in p && p.unavailable.startsWith('404'))
|
|
206
|
+
) {
|
|
207
|
+
// The API's own word, with the environmentId hint, as runTool gives any 404.
|
|
208
|
+
const said = uptime.unavailable.replace(/^404:\s*/, '') || 'Environment not found';
|
|
209
|
+
return toolError('NOT_FOUND', withNotFoundHint(said));
|
|
210
|
+
}
|
|
178
211
|
const value = (p: { value: { data: unknown } } | { unavailable: string }) =>
|
|
179
212
|
'value' in p ? p.value.data : { unavailable: p.unavailable };
|
|
213
|
+
const envHealth = (health.data as Row[]).filter((row) => row.environmentId === env);
|
|
180
214
|
return toolResult({
|
|
181
|
-
health:
|
|
215
|
+
health: envHealth,
|
|
216
|
+
...(envHealth.length === 0
|
|
217
|
+
? {
|
|
218
|
+
note:
|
|
219
|
+
'No health for this environment: it has no App URL to check yet (taiga_update_environment ' +
|
|
220
|
+
'sets one). No health is not good health.',
|
|
221
|
+
}
|
|
222
|
+
: {}),
|
|
182
223
|
windowDays: days,
|
|
183
224
|
uptime: value(uptime),
|
|
184
225
|
realUserExperience: value(experience),
|
|
@@ -236,7 +277,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
236
277
|
runTool(async () => {
|
|
237
278
|
const { environmentId: id, ...changes } = params;
|
|
238
279
|
if (Object.values(changes).every((value) => value === undefined)) {
|
|
239
|
-
return toolError('
|
|
280
|
+
return toolError('VALIDATION_ERROR', 'Change at least one of name, type or url.');
|
|
240
281
|
}
|
|
241
282
|
const updated = (await client.updateEnvironment(id, changes)).data as Row;
|
|
242
283
|
return toolResult(pickFields([updated], ENVIRONMENT_FIELDS)[0]);
|