@taiga-ai/mcp-server 0.16.0-rc.431 → 0.17.0-dev.439

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.
@@ -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, or one.',
110
- inputSchema: z.object({ productId: productId.optional(), ...pagination }),
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 result = await client.listSetupTasks(params);
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: z.string().uuid().describe('Action Required item ID -- taiga_list_action_required gives it'),
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('VALIDATION_FAILED', 'Say what became of the item (status) or until when to snooze it.');
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 } : {}),
@@ -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. A plain object schema
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
- return register(name, schema ? { ...config, inputSchema: schema.strict() } : config, callback);
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
  }
@@ -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
- export const productId = z
6
- .string()
7
- .uuid()
8
- .describe('Product ID -- taiga_get_product_context, taiga_resolve_product or taiga_list_products give it');
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: 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.
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 SERVER_INSTRUCTIONS = [
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
- 'Workflow: start with taiga_get_product_context, naming the product by its git remote URL inside a checkout',
114
- '(git remote get-url origin), else by slug or ID from taiga_list_products. It returns the product, the',
115
- 'factory instructions, the specification and architecture as the whole sections that fit (the rest named for',
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: z.string().uuid().describe('Agent job ID -- a request tool returns it') }),
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)))
@@ -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 = z.string().uuid().describe('Knowledge file ID -- taiga_list_knowledge gives it');
37
+ const knowledgeId = idInput('knowledge file', 'taiga_list_knowledge gives it');
38
38
 
39
39
  const scopeInputs = {
40
- factoryId: z.string().uuid().optional().describe("A factory's knowledge"),
41
- productId: z.string().uuid().optional().describe("A product's knowledge"),
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('VALIDATION_FAILED', twoLevels);
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('VALIDATION_FAILED', twoLevels);
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,
@@ -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 = z.string().uuid().describe('Finding ID -- taiga_list_findings gives it');
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;
@@ -100,7 +100,10 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
100
100
  client.listMaintenanceSweeps(params.productId, { limit: 3 }),
101
101
  ]);
102
102
  const latest = pickFields(sweeps.data as Row[], SWEEP_FIELDS);
103
- const { lastRun: _lastRun, ...standing } = summary.data as Row;
103
+ // The sweeps are reported as `latestSweeps`, narrowed to SWEEP_FIELDS;
104
+ // the summary's own run rows (PRO-2527: one per repository) would
105
+ // repeat them with every column.
106
+ const { lastRun: _lastRun, lastRuns: _lastRuns, ...standing } = summary.data as Row;
104
107
  return toolResult({
105
108
  ...standing,
106
109
  latestSweeps: latest,
@@ -167,7 +170,8 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
167
170
  "Where a product stands against the organization's own published policies: the controls it violates, " +
168
171
  "each with its violations (critical and high only, each citing the policy's sentence and the document " +
169
172
  '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. A control with no violation ' +
173
+ 'awaiting verification), and when the documents were derived and assessed, led by a summary of the ' +
174
+ 'counts. A control with no violation ' +
171
175
  'is not listed, and it is not "passing" either: there is no such state. Pass full: true for every ' +
172
176
  "violation's evidence and the resolved ones. Fix a control with taiga_fix_policy_violation.",
173
177
  inputSchema: z.object({
@@ -182,23 +186,42 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
182
186
  (params) =>
183
187
  runTool(async () => {
184
188
  const standing = (await client.getPolicyStanding(params.productId)).data as Row;
185
- if (params.full) return toolResult(standing);
186
189
  const controls = Array.isArray(standing.controls) ? (standing.controls as Row[]) : [];
187
190
  const violations = Array.isArray(standing.divergences) ? (standing.divergences as Row[]) : [];
188
191
  const resolved = Array.isArray(standing.resolvedDivergences) ? standing.resolvedDivergences.length : 0;
192
+ const listed = controls.filter((c) => Number(c.divergenceCount ?? 0) > 0 || Number(c.findingCount ?? 0) > 0);
193
+ const bySeverity: Record<string, number> = {};
194
+ for (const violation of violations) {
195
+ const severity = String(violation.severity ?? 'unknown');
196
+ bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
197
+ }
198
+ // The counts first, on both answers: the lists run to thousands of
199
+ // tokens, and "how bad is it" is the first question.
200
+ const summary = {
201
+ controls: listed.length,
202
+ violations: violations.length,
203
+ bySeverity,
204
+ resolvedViolations: resolved,
205
+ };
206
+ if (params.full) return toolResult({ summary, ...standing });
189
207
  return toolResult({
208
+ summary,
190
209
  productId: standing.productId,
191
210
  hasPolicies: standing.hasPolicies,
192
211
  assessment: standing.assessment,
193
212
  documents: standing.documents,
194
213
  // Only the controls something is recorded against; every other
195
214
  // control is simply not reported, as the page does.
196
- controls: pickFields(
197
- controls.filter((c) => Number(c.divergenceCount ?? 0) > 0 || Number(c.findingCount ?? 0) > 0),
198
- ['controlId', 'policyType', 'text', 'state', 'divergenceCount', 'findingCount', 'plannedCount']
199
- ),
215
+ controls: pickFields(listed, [
216
+ 'controlId',
217
+ 'policyType',
218
+ 'text',
219
+ 'state',
220
+ 'divergenceCount',
221
+ 'findingCount',
222
+ 'plannedCount',
223
+ ]),
200
224
  violations: pickFields(violations, VIOLATION_FIELDS),
201
- resolvedViolations: resolved,
202
225
  });
203
226
  })
204
227
  );
@@ -233,7 +256,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
233
256
  (params) =>
234
257
  runTool(async () => {
235
258
  if (params.ignored && params.reason === undefined) {
236
- return toolError('VALIDATION_FAILED', 'Say why the finding is ignored (reason).');
259
+ return toolError('VALIDATION_ERROR', 'Say why the finding is ignored (reason).');
237
260
  }
238
261
  const body = params.ignored
239
262
  ? {
@@ -315,7 +338,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
315
338
  inputSchema: z.object({
316
339
  productId,
317
340
  controlId: z.string().min(1).max(40).optional().describe("The control's identifier, e.g. SEC-3.4"),
318
- violationId: z.string().uuid().optional().describe('One violation -- taiga_get_policy_standing gives it'),
341
+ violationId: idInput('violation', 'taiga_get_policy_standing gives it').optional(),
319
342
  }),
320
343
  annotations: OPERATE,
321
344
  },
@@ -323,7 +346,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
323
346
  runTool(async () => {
324
347
  if ((params.controlId === undefined) === (params.violationId === undefined)) {
325
348
  return toolError(
326
- 'VALIDATION_FAILED',
349
+ 'VALIDATION_ERROR',
327
350
  'Name either a control (controlId) or one violation (violationId), not both or neither.'
328
351
  );
329
352
  }
@@ -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 { pagination, productId } from './inputs.js';
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 = z.string().uuid().describe('Environment ID -- taiga_get_deployments lists them');
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('VALIDATION_FAILED', 'windowDays is 30, 60 or 90.');
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) return toolResult({ health: health.data });
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('VALIDATION_FAILED', 'windowDays is 30, 60 or 90, the windows the page offers.');
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: (health.data as Row[]).filter((row) => row.environmentId === env),
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('VALIDATION_FAILED', 'Change at least one of name, type or url.');
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]);