@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.
@@ -1,15 +1,43 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
- import type { TaigaClient } from '../client.js';
3
+ import { TaigaApiError, type TaigaClient } from '../client.js';
4
4
  import { READ_ONLY } from './annotations.js';
5
+ import {
6
+ ORIGIN_MEANING,
7
+ PRODUCT_DOCUMENT_ORIGINS,
8
+ PRODUCT_DOCUMENT_TYPES,
9
+ PRODUCT_STATUSES,
10
+ defaultOriginFor,
11
+ emptyOriginNote,
12
+ isImportedProduct,
13
+ productDocumentName,
14
+ type ProductDocumentOrigin,
15
+ type ProductSettingsView,
16
+ } from './catalogue.js';
5
17
  import { runTool, toolError, toolResult } from './errors.js';
6
- import { documentType } from './inputs.js';
7
18
  import { parseRemote } from './remote.js';
8
- import { pickFields, summarizeList } from './summarize.js';
19
+ import { stripProvenance } from './sections.js';
20
+ import { pickFields } from './summarize.js';
9
21
 
10
22
  /** What a product row needs to say for the model to pick one and read it in full. */
11
23
  const PRODUCT_SUMMARY_FIELDS = ['id', 'name', 'slug', 'status', 'factoryId', 'kind', 'discoveryStep'] as const;
12
24
 
25
+ /**
26
+ * What a document row needs to say for the model to pick one and read it in
27
+ * full: which document, on which timeline, how current. Not the derived-from
28
+ * tree, the data key count or the reviewer's name.
29
+ */
30
+ const DOCUMENT_SUMMARY_FIELDS = [
31
+ 'type',
32
+ 'origin',
33
+ 'version',
34
+ 'isDraft',
35
+ 'hasDraft',
36
+ 'stage',
37
+ 'reviewedAt',
38
+ 'updatedAt',
39
+ ] as const;
40
+
13
41
  /** The API's page ceiling (paginationSchema in apps/api). */
14
42
  const PAGE_SIZE = 100;
15
43
 
@@ -20,6 +48,20 @@ const PAGE_SIZE = 100;
20
48
  */
21
49
  const RESOLVE_PAGE_LIMIT = 10;
22
50
 
51
+ const productId = z
52
+ .string()
53
+ .uuid()
54
+ .describe('Product ID -- get from taiga_resolve_product (from a git remote) or taiga_list_products');
55
+
56
+ const originInput = z
57
+ .enum(PRODUCT_DOCUMENT_ORIGINS)
58
+ .optional()
59
+ .describe(
60
+ `Which timeline. ${ORIGIN_MEANING.plan} ${ORIGIN_MEANING.reality} ` +
61
+ "Omit it to read the product's own timeline: reality for an imported product, plan for every other, " +
62
+ 'and always plan for the documents a person writes (Look & Feel, Product Instructions).'
63
+ );
64
+
23
65
  /**
24
66
  * Every product linked to the repository, page by page, until the API runs
25
67
  * out or the cap is hit. A page that fails fails the call, like every other
@@ -37,6 +79,128 @@ async function linkedProducts(client: TaigaClient, repository: string, repositor
37
79
  return { products, truncated: true };
38
80
  }
39
81
 
82
+ /** A document list row as the model sees it: the summary fields plus a display name and the staleness fact. */
83
+ function documentSummaries(rows: unknown[]): Record<string, unknown>[] {
84
+ const typed = rows as Record<string, unknown>[];
85
+ return pickFields(typed, DOCUMENT_SUMMARY_FIELDS).map((picked, index) => {
86
+ // `outdated` on the wire is the list of sources that moved on; the model
87
+ // needs the fact, not the tree. Published plan rows carry it: null when
88
+ // current, a source list when not. A draft has nothing published to be
89
+ // outdated (the API stamps it null too) and reality rows carry no such
90
+ // field: no fact for either.
91
+ const outdated = typed[index]!.outdated as { sources?: unknown[] } | null | undefined;
92
+ const fact =
93
+ picked.isDraft !== false
94
+ ? undefined
95
+ : outdated === null
96
+ ? false
97
+ : Array.isArray(outdated?.sources)
98
+ ? outdated.sources.length > 0
99
+ : undefined;
100
+ return {
101
+ type: picked.type,
102
+ name: productDocumentName(String(picked.type)),
103
+ ...picked,
104
+ ...(fact === undefined ? {} : { outdated: fact }),
105
+ };
106
+ });
107
+ }
108
+
109
+ interface Timelines {
110
+ projectType: 'greenfield' | 'brownfield';
111
+ defaultOrigin: ProductDocumentOrigin;
112
+ /** The side's summaries, or null when the API could not list it (see `unlistable`). */
113
+ plan: Record<string, unknown>[] | null;
114
+ reality: Record<string, unknown>[] | null;
115
+ /** Why a null side is null, in the words the notes and a miss message use. */
116
+ unlistable: Partial<Record<ProductDocumentOrigin, string>>;
117
+ notes: string[];
118
+ }
119
+
120
+ /** The one sentence for a side the API would not list, with what it said. */
121
+ function unlistableNote(origin: ProductDocumentOrigin, reason: unknown): string {
122
+ const said = reason instanceof TaigaApiError ? `${reason.status} ${reason.code ?? ''}`.trim() : 'the request failed';
123
+ return `The ${origin} side could not be listed (${said}).`;
124
+ }
125
+
126
+ /**
127
+ * Both timelines of a product's documents, each a list of summaries, with
128
+ * which one is the product's own and a note for an empty side. The product
129
+ * decides what an empty side means (imported or not, how far its import got),
130
+ * so a caller that already holds it passes it in.
131
+ */
132
+ async function documentTimelines(client: TaigaClient, id: string, product?: ProductSettingsView): Promise<Timelines> {
133
+ const loaded = product ?? ((await client.getProduct(id)).data as ProductSettingsView);
134
+ // Each side on its own: one side the API cannot list is reported as
135
+ // unreadable, not as empty, and never hides the side that did answer.
136
+ // Both failing is the API failing, and that is an error like any other.
137
+ const settled = await Promise.allSettled(
138
+ PRODUCT_DOCUMENT_ORIGINS.map((o) => client.listProductDocuments(id, { origin: o, limit: PAGE_SIZE }))
139
+ );
140
+ if (settled.every((result) => result.status === 'rejected')) throw settled[0]!.reason;
141
+ const sides: Partial<Record<ProductDocumentOrigin, Record<string, unknown>[] | null>> = {};
142
+ const unlistable: Partial<Record<ProductDocumentOrigin, string>> = {};
143
+ PRODUCT_DOCUMENT_ORIGINS.forEach((o, index) => {
144
+ const result = settled[index]!;
145
+ if (result.status === 'fulfilled') sides[o] = documentSummaries(result.value.data);
146
+ else {
147
+ sides[o] = null;
148
+ unlistable[o] = unlistableNote(o, result.reason);
149
+ }
150
+ });
151
+ const notes = PRODUCT_DOCUMENT_ORIGINS.flatMap((o) => {
152
+ const side = sides[o];
153
+ if (!side) return [unlistable[o]!];
154
+ return side.length === 0 ? [emptyOriginNote(o, loaded)] : [];
155
+ });
156
+ return {
157
+ projectType: isImportedProduct(loaded) ? 'brownfield' : 'greenfield',
158
+ defaultOrigin: defaultOriginFor(loaded),
159
+ plan: sides.plan ?? null,
160
+ reality: sides.reality ?? null,
161
+ unlistable,
162
+ notes,
163
+ };
164
+ }
165
+
166
+ const isPublished = (row: Record<string, unknown>) => row.isDraft === false;
167
+
168
+ /** `specification (v2), architecture (draft)` for one side. */
169
+ function sideList(rows: Record<string, unknown>[]): string {
170
+ return rows
171
+ .map((row) => `${String(row.type)} (${isPublished(row) ? `v${String(row.version)}` : 'draft'})`)
172
+ .join(', ');
173
+ }
174
+
175
+ /** What a missed document read should say: where the type does live, and what each side holds. */
176
+ function describeMiss(
177
+ type: string,
178
+ wanted: ProductDocumentOrigin,
179
+ timelines: Timelines,
180
+ product: ProductSettingsView
181
+ ): string {
182
+ const name = productDocumentName(type);
183
+ const other: ProductDocumentOrigin = wanted === 'plan' ? 'reality' : 'plan';
184
+ const parts = [`No published ${wanted} ${name} (${type}) for this product.`];
185
+ const rows = (side: ProductDocumentOrigin) => timelines[side] ?? [];
186
+ if (rows(other).some((row) => row.type === type && isPublished(row))) {
187
+ parts.push(`It is published on the ${other} side: call again with origin '${other}'.`);
188
+ } else {
189
+ for (const side of [wanted, other]) {
190
+ if (rows(side).some((row) => row.type === type && !isPublished(row))) {
191
+ parts.push(`A draft of it exists on the ${side} side, unpublished.`);
192
+ }
193
+ }
194
+ }
195
+ for (const o of PRODUCT_DOCUMENT_ORIGINS) {
196
+ const side = timelines[o];
197
+ const label = `${o[0]!.toUpperCase()}${o.slice(1)} documents`;
198
+ if (side === null) parts.push(timelines.unlistable[o]!);
199
+ else parts.push(side.length > 0 ? `${label}: ${sideList(side)}.` : emptyOriginNote(o, product));
200
+ }
201
+ return parts.join(' ');
202
+ }
203
+
40
204
  export function registerProductTools(server: McpServer, client: TaigaClient) {
41
205
  if (client.hasCapability('factories:read')) {
42
206
  server.registerTool(
@@ -67,9 +231,9 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
67
231
  {
68
232
  title: 'List products',
69
233
  description:
70
- 'List products in the organization. Use this to find a product by name; inside a git checkout, ' +
71
- 'taiga_resolve_product finds it from the remote URL instead. ' +
72
- 'Can filter by factory; a status filter also exists but no longer distinguishes anything (see its description).',
234
+ 'List products in the organization, as summaries (id, name, slug, status, factory, kind). ' +
235
+ 'Use this to find a product by name; inside a git checkout, taiga_resolve_product finds it from ' +
236
+ 'the remote URL instead. taiga_get_product reads one in full.',
73
237
  inputSchema: {
74
238
  factoryId: z
75
239
  .string()
@@ -77,12 +241,11 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
77
241
  .optional()
78
242
  .describe('Filter by factory ID -- get factory IDs from taiga_list_factories'),
79
243
  status: z
80
- .enum(['draft', 'active', 'archived'])
244
+ .enum(PRODUCT_STATUSES)
81
245
  .optional()
82
246
  .describe(
83
- 'Filter by status. Taiga no longer surfaces product status in its UI (PRO-543), so every ' +
84
- 'product is currently "draft" and filtering by "active" or "archived" returns nothing. ' +
85
- 'Omit this unless you are working with data set through the API directly.'
247
+ 'Filter by status: draft until the product finishes setup in Taiga, active after that, ' +
248
+ 'archived when retired.'
86
249
  ),
87
250
  limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
88
251
  },
@@ -100,21 +263,37 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
100
263
  {
101
264
  title: 'Get product',
102
265
  description:
103
- 'Get one product in full: name, slug, factory, what Taiga owns for it (application, infrastructure, ' +
104
- 'deployment) and its settings; the list and resolve tools carry summaries. Use taiga_list_product_documents for its specification and design, ' +
266
+ 'Get one product in full, by ID or by slug: name, slug, factory, what Taiga owns for it ' +
267
+ '(application, infrastructure, deployment) and its settings; the list and resolve tools carry ' +
268
+ 'summaries. Use taiga_list_product_documents for its specification and design, ' +
105
269
  'taiga_list_backlog_items for its work and taiga_list_builds for what the coding agent did.',
106
270
  inputSchema: {
107
- productId: z
271
+ productId: productId.optional(),
272
+ slug: z
108
273
  .string()
109
- .uuid()
110
- .describe('Product ID -- get from taiga_resolve_product (from a git remote) or taiga_list_products'),
274
+ .max(63)
275
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'A slug is lowercase words joined by hyphens')
276
+ .optional()
277
+ .describe('Product slug, as it appears in Taiga URLs -- an alternative to productId'),
111
278
  },
112
279
  annotations: READ_ONLY,
113
280
  },
114
281
  (params) =>
115
282
  runTool(async () => {
116
- const result = await client.getProduct(params.productId);
117
- return toolResult(result.data);
283
+ if (params.productId && params.slug) {
284
+ return toolError('VALIDATION_FAILED', 'Pass productId or slug, not both: they could name two products.');
285
+ }
286
+ if (params.productId) return toolResult((await client.getProduct(params.productId)).data);
287
+ if (!params.slug) return toolError('VALIDATION_FAILED', 'Pass productId or slug.');
288
+ const result = await client.listProducts({ slug: params.slug, limit: 1 });
289
+ const [product] = result.data;
290
+ if (!product) {
291
+ return toolError(
292
+ 'NOT_FOUND',
293
+ `No product you can see has the slug "${params.slug}". taiga_list_products shows the ones you can.`
294
+ );
295
+ }
296
+ return toolResult(product);
118
297
  })
119
298
  );
120
299
 
@@ -168,22 +347,20 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
168
347
  {
169
348
  title: 'List product documents',
170
349
  description:
171
- 'List documents for a specific product (names and types only). ' +
172
- 'Product documents are generated during intake: specification (requirements), ' +
173
- 'architecture (system design), technology decisions, data flow, privacy assessment, ' +
174
- 'threat model (security risks), risk register, user flow, and service blueprint. ' +
175
- 'Use taiga_get_product_document to read the full content.',
176
- inputSchema: {
177
- productId: z.string().uuid().describe('Product ID -- get from taiga_list_products'),
178
- limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
179
- },
350
+ "List a product's documents on both timelines, as summaries (type, name, version, draft state, " +
351
+ "review and update times), and say which timeline is the product's own (defaultOrigin). " +
352
+ `Two timelines: ${ORIGIN_MEANING.plan} ${ORIGIN_MEANING.reality} ` +
353
+ 'An empty side comes with a note saying why; a side the API could not list is null, with a note. ' +
354
+ 'Use taiga_get_product_document with the type and origin to read one in full.',
355
+ inputSchema: { productId },
180
356
  annotations: READ_ONLY,
181
357
  },
182
358
  (params) =>
183
359
  runTool(async () => {
184
- const result = await client.listProductDocuments(params.productId, { limit: params.limit });
185
- const summaries = summarizeList(result.data as Record<string, unknown>[]);
186
- return toolResult(summaries);
360
+ // `unlistable` is how a miss message is built; the list says the
361
+ // same in its notes and a null side.
362
+ const { unlistable: _unlistable, ...timelines } = await documentTimelines(client, params.productId);
363
+ return toolResult(timelines);
187
364
  })
188
365
  );
189
366
 
@@ -192,26 +369,40 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
192
369
  {
193
370
  title: 'Get product document',
194
371
  description:
195
- 'Get the full content of a specific product document. ' +
196
- 'Use this when you need to understand what to build (specification), ' +
197
- 'how the system is designed (architecture), what security risks exist (threat model), ' +
198
- 'or what technology choices were made (technology_decisions). ' +
199
- 'Types: specification, architecture, technology_decisions, data_flow, ' +
200
- 'privacy_assessment, threat_model, risk_register, user_flow, service_blueprint, ' +
201
- "ui_direction, codebase_snapshot, and product_instructions (the product's standing instructions).",
372
+ 'Get the full content of one product document from one timeline. ' +
373
+ 'Read the plan to learn what to build: specification (requirements), architecture (design), ' +
374
+ 'threat_model (security risks), technology_decisions. Read reality, when the product has it, to ' +
375
+ "learn what the code actually does before changing it. Omit origin for the product's own timeline. " +
376
+ 'A read that finds nothing says which documents and timelines the product does have.',
202
377
  inputSchema: {
203
- productId: z.string().uuid().describe('Product ID -- get from taiga_list_products'),
204
- type: documentType.describe(
205
- 'Document type -- get available types from taiga_list_product_documents. ' +
206
- 'Common: specification, architecture, threat_model, technology_decisions'
207
- ),
378
+ productId,
379
+ type: z
380
+ .enum(PRODUCT_DOCUMENT_TYPES)
381
+ .describe('Document type -- taiga_list_product_documents shows which ones the product has'),
382
+ origin: originInput,
208
383
  },
209
384
  annotations: READ_ONLY,
210
385
  },
211
386
  (params) =>
212
387
  runTool(async () => {
213
- const result = await client.getProductDocument(params.productId, params.type);
214
- return toolResult(result.data);
388
+ // The product's own timeline when none is asked for; the API's
389
+ // default is plan, which is the wrong side of an imported product.
390
+ let product: ProductSettingsView | undefined;
391
+ if (!params.origin) product = (await client.getProduct(params.productId)).data as ProductSettingsView;
392
+ const origin = params.origin ?? defaultOriginFor(product, params.type);
393
+ try {
394
+ const result = await client.getProductDocument(params.productId, params.type, origin);
395
+ return toolResult(stripProvenance(result.data as Record<string, unknown>));
396
+ } catch (error) {
397
+ if (!(error instanceof TaigaApiError) || error.status !== 404) throw error;
398
+ // A product the key cannot see is a product miss, not a document
399
+ // miss, so the product read propagates. The lists after it are
400
+ // best effort per side (documentTimelines); only both failing
401
+ // propagates, as the API failing.
402
+ product ??= (await client.getProduct(params.productId)).data as ProductSettingsView;
403
+ const timelines = await documentTimelines(client, params.productId, product);
404
+ return toolError('NOT_FOUND', describeMiss(params.type, origin, timelines, product));
405
+ }
215
406
  })
216
407
  );
217
408
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { applySectionEdit, sectionContent, sectionsOf } from './sections.js';
2
+ import { applySectionEdit, sectionContent, sectionsOf, stripProvenance } from './sections.js';
3
3
 
4
4
  describe('sectionsOf', () => {
5
5
  it('returns the sections map of a section-shaped document', () => {
@@ -56,3 +56,36 @@ describe('applySectionEdit', () => {
56
56
  expect(sections.summary.content).toBe('old');
57
57
  });
58
58
  });
59
+
60
+ describe('stripProvenance', () => {
61
+ it('drops the derived-from tree and outdated sources at the top and in every section', () => {
62
+ const doc = {
63
+ id: 'd1',
64
+ type: 'policy_security',
65
+ derivedFrom: { sources: [{ type: 'business_goals', version: 2 }] },
66
+ outdated: { sources: [] },
67
+ data: {
68
+ version: '1.0.0',
69
+ sections: {
70
+ summary: { source: 'template', priority: 'core', content: 'x', derivedFrom: ['business_goals'] },
71
+ legacy: 'bare text',
72
+ },
73
+ },
74
+ };
75
+
76
+ expect(stripProvenance(doc)).toEqual({
77
+ id: 'd1',
78
+ type: 'policy_security',
79
+ data: {
80
+ version: '1.0.0',
81
+ sections: { summary: { source: 'template', priority: 'core', content: 'x' }, legacy: 'bare text' },
82
+ },
83
+ });
84
+ });
85
+
86
+ it('leaves a document that is not made of sections alone', () => {
87
+ const doc = { id: 'd2', type: 'ui_direction', data: { picks: { palette: 'ocean' } } };
88
+
89
+ expect(stripProvenance(doc)).toEqual(doc);
90
+ });
91
+ });
@@ -14,7 +14,7 @@
14
14
  /** A section as stored: an object with `content` and metadata, or (older rows) a bare string. */
15
15
  export type Sections = Record<string, unknown>;
16
16
 
17
- function isRecord(value: unknown): value is Record<string, unknown> {
17
+ export function isRecord(value: unknown): value is Record<string, unknown> {
18
18
  return typeof value === 'object' && value !== null && !Array.isArray(value);
19
19
  }
20
20
 
@@ -36,3 +36,25 @@ export function applySectionEdit(sections: Sections, sectionId: string, content:
36
36
  const prior = sections[sectionId];
37
37
  return { ...sections, [sectionId]: isRecord(prior) ? { ...prior, content } : { content } };
38
38
  }
39
+
40
+ /**
41
+ * A document for reading, without its provenance: the top-level `derivedFrom`
42
+ * tree and `outdated` source list (PRO-2359), and each section's own
43
+ * `derivedFrom` (PRO-2158). They say which documents shaped this one, which
44
+ * the editor needs and a reader does not; `source` and `priority` stay, the
45
+ * server instructions explain them.
46
+ */
47
+ export function stripProvenance(doc: Record<string, unknown>): Record<string, unknown> {
48
+ const { derivedFrom: _derivedFrom, outdated: _outdated, ...rest } = doc;
49
+ if (!isRecord(rest.data)) return rest;
50
+ const sections = sectionsOf(rest.data);
51
+ if (!sections) return rest;
52
+ const stripped = Object.fromEntries(
53
+ Object.entries(sections).map(([id, section]) => {
54
+ if (!isRecord(section)) return [id, section];
55
+ const { derivedFrom: _sectionDerivedFrom, ...keep } = section;
56
+ return [id, keep];
57
+ })
58
+ );
59
+ return { ...rest, data: { ...rest.data, sections: stripped } };
60
+ }
@@ -1,38 +1,7 @@
1
1
  /**
2
- * Strip heavy content from list responses to avoid context window bloat.
3
- *
4
- * List endpoints return summaries (id, name, description, metadata).
5
- * Get endpoints return the full content.
6
- *
7
- * This keeps list calls cheap (~100 tokens per item) and lets the AI
8
- * selectively load full content only for the items it needs.
9
- */
10
-
11
- /** Fields to strip from list responses -- these contain the bulk content. */
12
- const HEAVY_FIELDS = ['content', 'data', 'sections'];
13
-
14
- /**
15
- * Remove heavy content fields from each item in an array.
16
- * Preserves all metadata (id, name, description, type, dates, etc).
17
- */
18
- export function summarizeList<T extends Record<string, unknown>>(
19
- items: T[]
20
- ): Omit<T, 'content' | 'data' | 'sections'>[] {
21
- return items.map((item) => {
22
- const summary = { ...item };
23
- for (const field of HEAVY_FIELDS) {
24
- if (field in summary) {
25
- delete summary[field];
26
- }
27
- }
28
- return summary;
29
- });
30
- }
31
-
32
- /**
33
- * Keep only the named fields of each item. For lists whose rows carry prose
34
- * (a backlog item's description, a run's metrics) that `summarizeList`'s
35
- * fixed field names would not catch.
2
+ * Keep only the named fields of each item. List tools return summaries (the
3
+ * fields a model needs to pick an item) and get tools return everything;
4
+ * this is how a list drops the prose and the provenance trees.
36
5
  */
37
6
  export function pickFields<T extends Record<string, unknown>>(
38
7
  items: T[],
@@ -1,19 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { FACTORY_DOCUMENT_TYPES, PRODUCT_DOCUMENT_TYPES, TENANT_DOCUMENT_TYPES } from '@taiga/types';
3
- import { documentType } from './inputs.js';
4
-
5
- describe('documentType', () => {
6
- it('accepts every document type the shared types package names', () => {
7
- // The identifier shape is a wall against path-shaped input, not a copy of
8
- // the enums; this is what keeps it from silently rejecting a real type.
9
- for (const type of [...TENANT_DOCUMENT_TYPES, ...FACTORY_DOCUMENT_TYPES, ...PRODUCT_DOCUMENT_TYPES]) {
10
- expect(documentType.safeParse(type).success, type).toBe(true);
11
- }
12
- });
13
-
14
- it('rejects anything that could reach another route', () => {
15
- for (const value of ['../x', 'a/b', '.', '..', '', 'policy security', 'Policy', 'a?x=1', 'a#b']) {
16
- expect(documentType.safeParse(value).success, JSON.stringify(value)).toBe(false);
17
- }
18
- });
19
- });
@@ -1,13 +0,0 @@
1
- import { z } from 'zod';
2
-
3
- /**
4
- * A document type as the API names one: policy_security, factory_instructions,
5
- * specification. An identifier, never a path: the value is interpolated into
6
- * the route, and a type that carried a slash would reach a different
7
- * document's route than the tool's description promises. The API's own enum
8
- * stays the source of truth for which identifiers exist (it answers 400 with
9
- * the valid list); this only rules out what could not be a type at all.
10
- */
11
- export const documentType = z
12
- .string()
13
- .regex(/^[a-z][a-z0-9_]{0,63}$/, 'A document type is a lowercase identifier such as policy_security');