@taiga-ai/mcp-server 0.10.0 → 0.11.0-rc.408
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 +14626 -60
- package/package.json +4 -1
- package/src/client.test.ts +41 -1
- package/src/client.ts +49 -8
- package/src/server.integration.test.ts +120 -2
- package/src/tools/backlogs.ts +39 -8
- package/src/tools/markdown.test.ts +167 -1
- package/src/tools/markdown.ts +62 -1
- package/taiga-ai-mcp-server-0.11.0-rc.408.tgz +0 -0
- package/taiga-ai-mcp-server-0.10.0.tgz +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taiga-ai/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0-rc.408",
|
|
4
4
|
"description": "MCP server for Taiga -- gives AI assistants access to documents and products",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -27,7 +27,10 @@
|
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@tsconfig/strictest": "^2.0.8",
|
|
30
|
+
"@types/mdast": "^4.0.4",
|
|
30
31
|
"@types/node": "^22.20.1",
|
|
32
|
+
"mdast-util-from-markdown": "^2.1.0",
|
|
33
|
+
"mdast-util-to-markdown": "^2.2.0",
|
|
31
34
|
"rolldown": "^1.2.3",
|
|
32
35
|
"typescript": "^7.0.2",
|
|
33
36
|
"vitest": "^5.0.0"
|
package/src/client.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { TaigaApiError, TaigaClient } from './client.js';
|
|
3
|
+
import { TaigaApiError, TaigaClient, withoutTenant } from './client.js';
|
|
4
4
|
|
|
5
5
|
// Read independently of src/version.ts, so the test checks the real file.
|
|
6
6
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string };
|
|
@@ -150,6 +150,46 @@ describe('TaigaClient', () => {
|
|
|
150
150
|
// Introspection -- the foundation for capability-gated tool registration
|
|
151
151
|
// -------------------------------------------------------------------------
|
|
152
152
|
|
|
153
|
+
describe('withoutTenant', () => {
|
|
154
|
+
it('drops tenantId at every depth and keeps an own __proto__ key as data', () => {
|
|
155
|
+
const parsed = JSON.parse(
|
|
156
|
+
'{"__proto__": {"x": 1}, "tenantId": "t", "a": {"tenantId": "t", "b": [{"tenantId": "t", "c": 1}, 2, null]}, "d": "s"}'
|
|
157
|
+
) as Record<string, unknown>;
|
|
158
|
+
|
|
159
|
+
const out = withoutTenant(parsed) as Record<string, unknown>;
|
|
160
|
+
|
|
161
|
+
expect(JSON.stringify(out)).not.toContain('tenantId');
|
|
162
|
+
expect(out.a).toEqual({ b: [{ c: 1 }, 2, null] });
|
|
163
|
+
expect(out.d).toBe('s');
|
|
164
|
+
expect(Object.prototype.hasOwnProperty.call(out, '__proto__')).toBe(true);
|
|
165
|
+
expect(Object.getPrototypeOf(out)).toBe(Object.prototype);
|
|
166
|
+
expect((out as { x?: unknown }).x).toBeUndefined();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('walks a body nested deeper than a recursive copy could', () => {
|
|
170
|
+
const depth = 20_000;
|
|
171
|
+
const nested = JSON.parse(`${'{"a":['.repeat(depth)}{"tenantId":"t","v":1}${']}'.repeat(depth)}`) as unknown;
|
|
172
|
+
|
|
173
|
+
const out = withoutTenant(nested);
|
|
174
|
+
|
|
175
|
+
// Walked, not stringified: JSON.stringify recurses and would overflow where the copy did not.
|
|
176
|
+
let cursor: unknown = out;
|
|
177
|
+
for (let level = 0; level < depth; level += 1) {
|
|
178
|
+
expect(Object.keys(cursor as object)).toEqual(['a']);
|
|
179
|
+
cursor = (cursor as { a: unknown[] }).a[0];
|
|
180
|
+
}
|
|
181
|
+
expect(cursor).toEqual({ v: 1 });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('keeps key order, which is the order sections render in', () => {
|
|
185
|
+
const out = withoutTenant({ z: 1, tenantId: 't', a: { y: 2, x: [3, { tenantId: 't', w: 4 }] }, m: 5 });
|
|
186
|
+
|
|
187
|
+
expect(Object.keys(out)).toEqual(['z', 'a', 'm']);
|
|
188
|
+
expect(Object.keys(out.a)).toEqual(['y', 'x']);
|
|
189
|
+
expect(out.a.x).toEqual([3, { w: 4 }]);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
153
193
|
describe('introspection', () => {
|
|
154
194
|
it('should return and store the introspection result', async () => {
|
|
155
195
|
const expected = {
|
package/src/client.ts
CHANGED
|
@@ -2,11 +2,45 @@ import type { ApiKeyIntrospection } from '@taiga/types';
|
|
|
2
2
|
import { SERVER_VERSION } from './version.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* The same value without any `tenantId`, at any depth. Build run and step
|
|
6
|
+
* rows carry one; the key is scoped to one tenant already, so the id is
|
|
7
|
+
* never something the model acts on. Applied to every response here, once,
|
|
8
|
+
* so no tool, resource or prompt can hand it over by forgetting to; only
|
|
9
|
+
* the key's own introspection keeps it, typed and never handed over.
|
|
10
|
+
*
|
|
11
|
+
* An explicit stack rather than recursion: a document body a person stored
|
|
12
|
+
* can nest as deep as JSON.parse allows, and a walk must not overflow where
|
|
13
|
+
* the parse did not. Every key is defined as a data property, so an own
|
|
14
|
+
* "__proto__" key stays a key instead of becoming the copy's prototype.
|
|
9
15
|
*/
|
|
16
|
+
export function withoutTenant<T>(value: T): T {
|
|
17
|
+
const root: { out?: unknown } = {};
|
|
18
|
+
// Siblings keep their order and nested values are copied in full: see the loop.
|
|
19
|
+
const pending: { source: unknown; target: object; key: string | number }[] = [
|
|
20
|
+
{ source: value, target: root, key: 'out' },
|
|
21
|
+
];
|
|
22
|
+
while (pending.length > 0) {
|
|
23
|
+
const { source, target, key } = pending.pop()!;
|
|
24
|
+
let copy: unknown = source;
|
|
25
|
+
// Children are pushed last-first so they pop first-first: key order is
|
|
26
|
+
// meaning in a section map (the order sections render in) and must survive.
|
|
27
|
+
if (Array.isArray(source)) {
|
|
28
|
+
copy = new Array(source.length);
|
|
29
|
+
for (let index = source.length - 1; index >= 0; index -= 1) {
|
|
30
|
+
pending.push({ source: source[index], target: copy as object, key: index });
|
|
31
|
+
}
|
|
32
|
+
} else if (source !== null && typeof source === 'object') {
|
|
33
|
+
copy = {};
|
|
34
|
+
const entries = Object.entries(source as Record<string, unknown>).filter(([inner]) => inner !== 'tenantId');
|
|
35
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
36
|
+
pending.push({ source: entries[index]![1], target: copy as object, key: entries[index]![0] });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
Object.defineProperty(target, key, { value: copy, enumerable: true, writable: true, configurable: true });
|
|
40
|
+
}
|
|
41
|
+
return root.out as T;
|
|
42
|
+
}
|
|
43
|
+
|
|
10
44
|
export class TaigaApiError extends Error {
|
|
11
45
|
readonly status: number;
|
|
12
46
|
readonly code: string | undefined;
|
|
@@ -87,7 +121,7 @@ export class TaigaClient {
|
|
|
87
121
|
this.apiKey = opts.apiKey;
|
|
88
122
|
}
|
|
89
123
|
|
|
90
|
-
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
124
|
+
private async request<T>(method: string, path: string, body?: unknown, keep?: { tenantId: true }): Promise<T> {
|
|
91
125
|
const url = `${this.baseUrl}/v1${path}`;
|
|
92
126
|
const headers: Record<string, string> = {
|
|
93
127
|
Authorization: `Bearer ${this.apiKey}`,
|
|
@@ -108,7 +142,8 @@ export class TaigaClient {
|
|
|
108
142
|
throw new TaigaApiError(method, path, response.status, text);
|
|
109
143
|
}
|
|
110
144
|
|
|
111
|
-
|
|
145
|
+
const parsed = (await response.json()) as T;
|
|
146
|
+
return keep ? parsed : withoutTenant(parsed);
|
|
112
147
|
}
|
|
113
148
|
|
|
114
149
|
// ---------------------------------------------------------------------------
|
|
@@ -116,7 +151,10 @@ export class TaigaClient {
|
|
|
116
151
|
// ---------------------------------------------------------------------------
|
|
117
152
|
|
|
118
153
|
async introspect(): Promise<ApiKeyIntrospection> {
|
|
119
|
-
|
|
154
|
+
// The key's own identity keeps its tenant: typed so, and never handed to the model.
|
|
155
|
+
const result = await this.request<{ data: ApiKeyIntrospection }>('GET', '/auth/introspect', undefined, {
|
|
156
|
+
tenantId: true,
|
|
157
|
+
});
|
|
120
158
|
this.introspection = result.data;
|
|
121
159
|
return result.data;
|
|
122
160
|
}
|
|
@@ -312,7 +350,10 @@ export class TaigaClient {
|
|
|
312
350
|
}
|
|
313
351
|
|
|
314
352
|
/** A person's move on the line board: the column dropped into, and what to follow there. */
|
|
315
|
-
async transitionInitiative(
|
|
353
|
+
async transitionInitiative(
|
|
354
|
+
itemId: string,
|
|
355
|
+
body: { state: string; afterId?: string | null | undefined; archived?: boolean | undefined }
|
|
356
|
+
) {
|
|
316
357
|
return this.request<{ data: unknown }>('PUT', `/backlog/${seg(itemId)}/state`, body);
|
|
317
358
|
}
|
|
318
359
|
|
|
@@ -1073,6 +1073,30 @@ describe('MCP server integration', () => {
|
|
|
1073
1073
|
expect(parseToolResult(result)).toEqual({ ...run, steps: [step] });
|
|
1074
1074
|
});
|
|
1075
1075
|
|
|
1076
|
+
it('should drop tenantId from every response at the client, so a run, its steps, an item and a list row never carry it', async () => {
|
|
1077
|
+
const tenanted = { ...run, tenantId: 'tenant-1' };
|
|
1078
|
+
const tenantedStep = { ...step, tenantId: 'tenant-1', verification: { tenantId: 'tenant-1', ok: true } };
|
|
1079
|
+
const { client } = await bootMcpPair({
|
|
1080
|
+
capabilities: ['products:read', 'backlog:read'],
|
|
1081
|
+
routes: {
|
|
1082
|
+
[`/v1/builds/${BUILD_ID}`]: { data: tenanted },
|
|
1083
|
+
[`/v1/builds/${BUILD_ID}/steps`]: { data: [tenantedStep] },
|
|
1084
|
+
[`/v1/backlog/${ITEM_ID}`]: { data: { ...item, tenantId: 'tenant-1' } },
|
|
1085
|
+
},
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
const build = parseToolResult(
|
|
1089
|
+
await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID } })
|
|
1090
|
+
);
|
|
1091
|
+
expect(build).toEqual({ ...run, steps: [{ ...step, verification: { ok: true } }] });
|
|
1092
|
+
expect(JSON.stringify(build)).not.toContain('tenantId');
|
|
1093
|
+
|
|
1094
|
+
const got = parseToolResult(
|
|
1095
|
+
await client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } })
|
|
1096
|
+
);
|
|
1097
|
+
expect(got).toEqual(item);
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1076
1100
|
it('should refuse a fractional limit before any request, as the API would after one', async () => {
|
|
1077
1101
|
const { client, fetchSpy } = await bootMcpPair({ capabilities: ['backlog:read', 'products:read'] });
|
|
1078
1102
|
const before = fetchSpy.mock.calls.length;
|
|
@@ -2638,7 +2662,24 @@ describe('MCP server integration', () => {
|
|
|
2638
2662
|
backlogItemId: ITEM_ID,
|
|
2639
2663
|
version: 1,
|
|
2640
2664
|
status: 'active',
|
|
2641
|
-
|
|
2665
|
+
tenantId: 'tenant-1',
|
|
2666
|
+
steps: [
|
|
2667
|
+
{
|
|
2668
|
+
id: 'step-1',
|
|
2669
|
+
planId: PLAN_ID,
|
|
2670
|
+
tenantId: 'tenant-1',
|
|
2671
|
+
orderIndex: 1,
|
|
2672
|
+
title: 'Add the route',
|
|
2673
|
+
description: 'Three paragraphs of planner prose the summary must not carry',
|
|
2674
|
+
layer: 'api',
|
|
2675
|
+
dependencies: [],
|
|
2676
|
+
filePaths: ['src/routes/login.ts'],
|
|
2677
|
+
writes: ['src/routes/login.ts'],
|
|
2678
|
+
reads: [],
|
|
2679
|
+
createsResources: ['aws_rds_cluster.main'],
|
|
2680
|
+
createdAt: '2026-10-08T00:00:00.000Z',
|
|
2681
|
+
},
|
|
2682
|
+
],
|
|
2642
2683
|
};
|
|
2643
2684
|
const run = {
|
|
2644
2685
|
id: BUILD_ID,
|
|
@@ -2876,6 +2917,58 @@ describe('MCP server integration', () => {
|
|
|
2876
2917
|
expect(outcomeOf(result)).toMatchObject({ state: 'todo' });
|
|
2877
2918
|
});
|
|
2878
2919
|
|
|
2920
|
+
it('should archive an initiative where it stands and bring it back, through the same move', async () => {
|
|
2921
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
2922
|
+
capabilities: ALL_CAPABILITIES,
|
|
2923
|
+
routes: {
|
|
2924
|
+
[`PUT /v1/backlog/${ITEM_ID}/state`]: {
|
|
2925
|
+
data: { ...item, state: 'canceled', status: 'canceled', archivedAt: '2026-10-08T06:00:00.000Z' },
|
|
2926
|
+
},
|
|
2927
|
+
},
|
|
2928
|
+
});
|
|
2929
|
+
|
|
2930
|
+
const filed = await client.callTool({
|
|
2931
|
+
name: 'taiga_move_backlog_item',
|
|
2932
|
+
arguments: { itemId: ITEM_ID, state: 'canceled', archived: true },
|
|
2933
|
+
});
|
|
2934
|
+
|
|
2935
|
+
expect(filed.isError).toBeFalsy();
|
|
2936
|
+
expect(sentBody(fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'canceled', archived: true });
|
|
2937
|
+
expect(outcomeOf(filed)).toMatchObject({ state: 'canceled', archivedAt: '2026-10-08T06:00:00.000Z' });
|
|
2938
|
+
|
|
2939
|
+
fetchSpy.mockClear();
|
|
2940
|
+
vi.stubGlobal('fetch', fetchSpy);
|
|
2941
|
+
await client.callTool({
|
|
2942
|
+
name: 'taiga_move_backlog_item',
|
|
2943
|
+
arguments: { itemId: ITEM_ID, state: 'todo', archived: false },
|
|
2944
|
+
});
|
|
2945
|
+
expect(sentBody(fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'todo', archived: false });
|
|
2946
|
+
});
|
|
2947
|
+
|
|
2948
|
+
it("should pass on the API's refusal to archive an initiative that is on the line", async () => {
|
|
2949
|
+
const { client } = await bootMcpPair({
|
|
2950
|
+
capabilities: ALL_CAPABILITIES,
|
|
2951
|
+
routes: {
|
|
2952
|
+
[`PUT /v1/backlog/${ITEM_ID}/state`]: refusal(
|
|
2953
|
+
409,
|
|
2954
|
+
'CONFLICT',
|
|
2955
|
+
'Take this initiative off the line before archiving it.'
|
|
2956
|
+
),
|
|
2957
|
+
},
|
|
2958
|
+
});
|
|
2959
|
+
|
|
2960
|
+
const result = await client.callTool({
|
|
2961
|
+
name: 'taiga_move_backlog_item',
|
|
2962
|
+
arguments: { itemId: ITEM_ID, state: 'done', archived: true },
|
|
2963
|
+
});
|
|
2964
|
+
|
|
2965
|
+
expect(result.isError).toBe(true);
|
|
2966
|
+
expect(outcomeOf(result)).toEqual({
|
|
2967
|
+
error: 'CONFLICT',
|
|
2968
|
+
message: 'Take this initiative off the line before archiving it.',
|
|
2969
|
+
});
|
|
2970
|
+
});
|
|
2971
|
+
|
|
2879
2972
|
it('should offer exactly the columns a person may drop into, and refuse build', async () => {
|
|
2880
2973
|
const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
|
|
2881
2974
|
|
|
@@ -2936,7 +3029,32 @@ describe('MCP server integration', () => {
|
|
|
2936
3029
|
});
|
|
2937
3030
|
|
|
2938
3031
|
const found = await client.callTool({ name: 'taiga_get_plan', arguments: { itemId: ITEM_ID } });
|
|
2939
|
-
|
|
3032
|
+
// A summary by default: the step's prose, timestamps and planId stay behind.
|
|
3033
|
+
expect(outcomeOf(found)).toEqual({
|
|
3034
|
+
id: PLAN_ID,
|
|
3035
|
+
backlogItemId: ITEM_ID,
|
|
3036
|
+
version: 1,
|
|
3037
|
+
status: 'active',
|
|
3038
|
+
steps: [
|
|
3039
|
+
{
|
|
3040
|
+
id: 'step-1',
|
|
3041
|
+
orderIndex: 1,
|
|
3042
|
+
title: 'Add the route',
|
|
3043
|
+
layer: 'api',
|
|
3044
|
+
dependencies: [],
|
|
3045
|
+
writes: ['src/routes/login.ts'],
|
|
3046
|
+
reads: [],
|
|
3047
|
+
createsResources: ['aws_rds_cluster.main'],
|
|
3048
|
+
},
|
|
3049
|
+
],
|
|
3050
|
+
});
|
|
3051
|
+
|
|
3052
|
+
const full = await client.callTool({ name: 'taiga_get_plan', arguments: { itemId: ITEM_ID, full: true } });
|
|
3053
|
+
const { tenantId: _t, ...planWithoutTenant } = plan;
|
|
3054
|
+
expect(outcomeOf(full)).toEqual({
|
|
3055
|
+
...planWithoutTenant,
|
|
3056
|
+
steps: plan.steps.map(({ tenantId: _s, ...step }) => step),
|
|
3057
|
+
});
|
|
2940
3058
|
|
|
2941
3059
|
const none = await client.callTool({ name: 'taiga_get_plan', arguments: { itemId: OTHER_ITEM } });
|
|
2942
3060
|
expect(none.isError).toBe(true);
|
package/src/tools/backlogs.ts
CHANGED
|
@@ -38,11 +38,25 @@ const ITEM_OUTCOME_FIELDS = [
|
|
|
38
38
|
'planningStatus',
|
|
39
39
|
'needsHumanCause',
|
|
40
40
|
'needsHumanDetail',
|
|
41
|
+
'archivedAt',
|
|
41
42
|
'lifecycle',
|
|
42
43
|
] as const;
|
|
43
44
|
|
|
44
45
|
const itemId = z.string().uuid().describe('Backlog item ID -- get from taiga_list_backlog_items');
|
|
45
46
|
|
|
47
|
+
/** The plan without its steps' prose: enough to approve it or say what a run will do. */
|
|
48
|
+
const PLAN_SUMMARY_FIELDS = ['id', 'backlogItemId', 'version', 'status', 'createdAt'] as const;
|
|
49
|
+
const PLAN_STEP_SUMMARY_FIELDS = [
|
|
50
|
+
'id',
|
|
51
|
+
'orderIndex',
|
|
52
|
+
'title',
|
|
53
|
+
'layer',
|
|
54
|
+
'dependencies',
|
|
55
|
+
'writes',
|
|
56
|
+
'reads',
|
|
57
|
+
'createsResources',
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
46
60
|
const outcome = (item: unknown) => toolResult(pickFields([item as Record<string, unknown>], ITEM_OUTCOME_FIELDS)[0]);
|
|
47
61
|
|
|
48
62
|
/**
|
|
@@ -125,17 +139,27 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
|
|
|
125
139
|
{
|
|
126
140
|
title: 'Get the active plan',
|
|
127
141
|
description:
|
|
128
|
-
"Get an initiative's active plan with its steps:
|
|
129
|
-
'
|
|
130
|
-
'
|
|
131
|
-
'
|
|
132
|
-
|
|
142
|
+
"Get an initiative's active plan with its steps, in order: each step's title, layer, dependencies " +
|
|
143
|
+
'(the orderIndex of the steps it builds on), the files it writes and reads and the resources it creates. ' +
|
|
144
|
+
'That is enough to ' +
|
|
145
|
+
'approve or reject it (taiga_approve_plan, taiga_reject_plan) and to say what a run will do; pass ' +
|
|
146
|
+
"full: true for each step's complete instructions, several thousand tokens on a large plan. NOT_FOUND " +
|
|
147
|
+
'when the initiative has no active plan yet (planningStatus says whether one is being generated).',
|
|
148
|
+
inputSchema: {
|
|
149
|
+
itemId,
|
|
150
|
+
full: z.boolean().optional().describe("Carry each step's complete instructions (default false: a summary)"),
|
|
151
|
+
},
|
|
133
152
|
annotations: READ_ONLY,
|
|
134
153
|
},
|
|
135
154
|
(params) =>
|
|
136
155
|
runTool(async () => {
|
|
137
|
-
const
|
|
138
|
-
return toolResult(
|
|
156
|
+
const plan = (await client.getActivePlan(params.itemId)).data as Record<string, unknown>;
|
|
157
|
+
if (params.full) return toolResult(plan);
|
|
158
|
+
const steps = Array.isArray(plan.steps) ? (plan.steps as Record<string, unknown>[]) : [];
|
|
159
|
+
return toolResult({
|
|
160
|
+
...pickFields([plan], PLAN_SUMMARY_FIELDS)[0],
|
|
161
|
+
steps: pickFields(steps, PLAN_STEP_SUMMARY_FIELDS),
|
|
162
|
+
});
|
|
139
163
|
})
|
|
140
164
|
);
|
|
141
165
|
}
|
|
@@ -186,7 +210,10 @@ function registerBacklogWrites(server: McpServer, client: TaigaClient) {
|
|
|
186
210
|
'initiative state machine refuses a move that is not allowed (CONFLICT: build cannot be dropped ' +
|
|
187
211
|
'into, an initiative being planned cannot leave plan). A move into queue or plan starts paid work and ' +
|
|
188
212
|
'needs products:write on the key; a key without it is refused here. Ask the person before a move that ' +
|
|
189
|
-
`starts work (queue, plan) or closes a pull request (${PR_CLOSING_STATES.join(', ')} from build)
|
|
213
|
+
`starts work (queue, plan) or closes a pull request (${PR_CLOSING_STATES.join(', ')} from build). ` +
|
|
214
|
+
'archived: true files the initiative where it stands (pass its current column as state; from any column ' +
|
|
215
|
+
'off the line, never from plan or build) and archived: false brings it back; an archived initiative is left ' +
|
|
216
|
+
'out of lists unless includeArchived asks for it.',
|
|
190
217
|
inputSchema: {
|
|
191
218
|
itemId,
|
|
192
219
|
state: z.enum(INITIATIVE_TRANSITION_STATES).describe('The column to move into'),
|
|
@@ -196,6 +223,10 @@ function registerBacklogWrites(server: McpServer, client: TaigaClient) {
|
|
|
196
223
|
.nullable()
|
|
197
224
|
.optional()
|
|
198
225
|
.describe('The initiative to follow in that column; null for the head; omit to keep rank order'),
|
|
226
|
+
archived: z
|
|
227
|
+
.boolean()
|
|
228
|
+
.optional()
|
|
229
|
+
.describe('true files the initiative where it stands, false brings it back; omit to leave as is'),
|
|
199
230
|
},
|
|
200
231
|
annotations: OPERATE,
|
|
201
232
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { fence, renderDocument } from './markdown.js';
|
|
2
|
+
import { fence, nestHeadings, renderDocument } from './markdown.js';
|
|
3
3
|
|
|
4
4
|
describe('fence', () => {
|
|
5
5
|
it('is one backtick longer than the longest run inside the body, four at least', () => {
|
|
@@ -48,6 +48,172 @@ describe('renderDocument', () => {
|
|
|
48
48
|
);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
+
it('tracks fences as CommonMark does: indented, longer closers, info strings, and headings indented up to three spaces', () => {
|
|
52
|
+
const body = [
|
|
53
|
+
'````',
|
|
54
|
+
'```',
|
|
55
|
+
'# inside a four-tick fence: a three-tick line does not close it',
|
|
56
|
+
'`````',
|
|
57
|
+
'## after a five-tick closer',
|
|
58
|
+
' ```sh',
|
|
59
|
+
'# indented opener',
|
|
60
|
+
' ```',
|
|
61
|
+
' ## indented heading',
|
|
62
|
+
' # four spaces is code, not a heading',
|
|
63
|
+
'```',
|
|
64
|
+
'```sh',
|
|
65
|
+
'# an opener-with-info inside an open fence is not its closer',
|
|
66
|
+
'```',
|
|
67
|
+
'## out again',
|
|
68
|
+
'```json``` is not a fence opener',
|
|
69
|
+
'## still a heading',
|
|
70
|
+
'~~~',
|
|
71
|
+
'```',
|
|
72
|
+
'# backticks inside a tilde fence',
|
|
73
|
+
'~~~',
|
|
74
|
+
'## last',
|
|
75
|
+
].join('\n');
|
|
76
|
+
|
|
77
|
+
expect(nestHeadings(body)).toBe(
|
|
78
|
+
[
|
|
79
|
+
'````',
|
|
80
|
+
'```',
|
|
81
|
+
'# inside a four-tick fence: a three-tick line does not close it',
|
|
82
|
+
'`````',
|
|
83
|
+
'### after a five-tick closer',
|
|
84
|
+
' ```sh',
|
|
85
|
+
'# indented opener',
|
|
86
|
+
' ```',
|
|
87
|
+
' ### indented heading',
|
|
88
|
+
' # four spaces is code, not a heading',
|
|
89
|
+
'```',
|
|
90
|
+
'```sh',
|
|
91
|
+
'# an opener-with-info inside an open fence is not its closer',
|
|
92
|
+
'```',
|
|
93
|
+
'### out again',
|
|
94
|
+
'```json``` is not a fence opener',
|
|
95
|
+
'### still a heading',
|
|
96
|
+
'~~~',
|
|
97
|
+
'```',
|
|
98
|
+
'# backticks inside a tilde fence',
|
|
99
|
+
'~~~',
|
|
100
|
+
'### last',
|
|
101
|
+
].join('\n')
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("edits by the parser's offsets: CRLF and lone CR line endings, a tab after a container marker, an empty heading, an HTML block, deep nesting", () => {
|
|
106
|
+
expect(nestHeadings('```\r\n# in\r\n```\r\n# out')).toBe('```\r\n# in\r\n```\r\n### out');
|
|
107
|
+
expect(nestHeadings('x\ry\n# h\n## i')).toBe('x\ry\n### h\n### i');
|
|
108
|
+
expect(nestHeadings('>\t# q\n-\t# x')).toBe('>\t### q\n-\t### x');
|
|
109
|
+
expect(nestHeadings('#\n##\n# x')).toBe('###\n###\n### x');
|
|
110
|
+
// A heading-shaped line inside an HTML block is HTML to a CommonMark reader, so it stays.
|
|
111
|
+
expect(nestHeadings('<details>\n<summary>s</summary>\n## inside\n</details>\n\n## after')).toBe(
|
|
112
|
+
'<details>\n<summary>s</summary>\n## inside\n</details>\n\n### after'
|
|
113
|
+
);
|
|
114
|
+
// Deeper than a recursive walk could take: nested quotes, which the
|
|
115
|
+
// parser handles in milliseconds (nested list markers it does not).
|
|
116
|
+
const deep = `${'> '.repeat(20_000)}# deep`;
|
|
117
|
+
expect(nestHeadings(deep)).toBe(`${'> '.repeat(20_000)}### deep`);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('turns a setext heading into the ATX heading one level down, inside a quote too, and leaves a thematic break alone', () => {
|
|
121
|
+
expect(nestHeadings('> Quoted\n> ---\ntext\n\n---\n\nAfter\n===')).toBe('> ### Quoted\ntext\n\n---\n\n### After');
|
|
122
|
+
// Serialized from the parsed content: a quote's prefix on a continuation
|
|
123
|
+
// line, a hard line break, a leading # that is not an ATX marker, a
|
|
124
|
+
// trailing # run that would read as a closing sequence, inline markup.
|
|
125
|
+
expect(nestHeadings('> A\n> B\n> ---')).toBe('> ### A B');
|
|
126
|
+
expect(nestHeadings('> > A\n> > B\n> > ===')).toBe('> > ### A B');
|
|
127
|
+
expect(nestHeadings('Title\\\ntwo\n---')).toBe('### Title two');
|
|
128
|
+
expect(nestHeadings('#1 priority\n===')).toBe('### #1 priority');
|
|
129
|
+
expect(nestHeadings('Title ##\n---')).toBe('### Title #\\#');
|
|
130
|
+
expect(nestHeadings('*em* and `code` [l](u)\n===')).toBe('### *em* and `code` [l](u)');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('reads containers as CommonMark does: a fence in a list item, headings in list items and quotes, a setext heading', () => {
|
|
134
|
+
const body = [
|
|
135
|
+
'- ```md',
|
|
136
|
+
' # literal inside a fence that a list item opened',
|
|
137
|
+
' ```',
|
|
138
|
+
'- ## in a list item',
|
|
139
|
+
'> ## quoted',
|
|
140
|
+
'> # indented code inside a quote',
|
|
141
|
+
'Setext',
|
|
142
|
+
'======',
|
|
143
|
+
'Two line',
|
|
144
|
+
'setext two',
|
|
145
|
+
'---',
|
|
146
|
+
'## atx after',
|
|
147
|
+
].join('\n');
|
|
148
|
+
|
|
149
|
+
expect(nestHeadings(body)).toBe(
|
|
150
|
+
[
|
|
151
|
+
'- ```md',
|
|
152
|
+
' # literal inside a fence that a list item opened',
|
|
153
|
+
' ```',
|
|
154
|
+
'- ### in a list item',
|
|
155
|
+
'> ### quoted',
|
|
156
|
+
'> # indented code inside a quote',
|
|
157
|
+
'### Setext',
|
|
158
|
+
'### Two line setext two',
|
|
159
|
+
'### atx after',
|
|
160
|
+
].join('\n')
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("nests a section body's own headings under the section heading, leaving fenced code alone", () => {
|
|
165
|
+
const rendered = renderDocument({
|
|
166
|
+
name: 'Threat Model',
|
|
167
|
+
type: 'threat_model',
|
|
168
|
+
data: {
|
|
169
|
+
sections: {
|
|
170
|
+
overview: {
|
|
171
|
+
content: [
|
|
172
|
+
'# Overview',
|
|
173
|
+
'Text.',
|
|
174
|
+
'## Attack vectors',
|
|
175
|
+
'##### Deep',
|
|
176
|
+
'###### Deepest stays',
|
|
177
|
+
'```md',
|
|
178
|
+
'# not a heading',
|
|
179
|
+
'```',
|
|
180
|
+
'~~~',
|
|
181
|
+
'## nor this',
|
|
182
|
+
'~~~',
|
|
183
|
+
'## After the fences',
|
|
184
|
+
'#hashtag is not a heading',
|
|
185
|
+
].join('\n'),
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(rendered.text).toBe(
|
|
192
|
+
[
|
|
193
|
+
'# Threat Model',
|
|
194
|
+
'',
|
|
195
|
+
'type: threat_model',
|
|
196
|
+
'',
|
|
197
|
+
'## overview',
|
|
198
|
+
'',
|
|
199
|
+
'### Overview',
|
|
200
|
+
'Text.',
|
|
201
|
+
'### Attack vectors',
|
|
202
|
+
'###### Deep',
|
|
203
|
+
'###### Deepest stays',
|
|
204
|
+
'```md',
|
|
205
|
+
'# not a heading',
|
|
206
|
+
'```',
|
|
207
|
+
'~~~',
|
|
208
|
+
'## nor this',
|
|
209
|
+
'~~~',
|
|
210
|
+
'### After the fences',
|
|
211
|
+
'#hashtag is not a heading',
|
|
212
|
+
'',
|
|
213
|
+
].join('\n')
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
51
217
|
it('carries what sits beside the sections as JSON, so nothing is lost', () => {
|
|
52
218
|
const rendered = renderDocument({
|
|
53
219
|
name: 'Design Standards',
|
package/src/tools/markdown.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
2
|
+
import { toMarkdown } from 'mdast-util-to-markdown';
|
|
3
|
+
import type { Heading, Node, Parent, PhrasingContent } from 'mdast';
|
|
1
4
|
import { isRecord, sectionContent, sectionsOf } from './sections.js';
|
|
2
5
|
|
|
3
6
|
/**
|
|
@@ -29,7 +32,7 @@ export function renderDocument(doc: Record<string, unknown>): { mimeType: string
|
|
|
29
32
|
for (const id of Object.keys(sections)) {
|
|
30
33
|
const content = sectionContent(sections, id);
|
|
31
34
|
if (content === null) continue;
|
|
32
|
-
lines.push('', `## ${sectionTitle(sections[id], id)}`, '', content.trim());
|
|
35
|
+
lines.push('', `## ${sectionTitle(sections[id], id)}`, '', nestHeadings(content.trim()));
|
|
33
36
|
}
|
|
34
37
|
const { sections: _sections, ...rest } = data;
|
|
35
38
|
const other = Object.fromEntries(Object.entries(rest).filter(([, value]) => !isEmpty(value)));
|
|
@@ -57,6 +60,64 @@ const isEmpty = (value: unknown) =>
|
|
|
57
60
|
(Array.isArray(value) && value.length === 0) ||
|
|
58
61
|
(isRecord(value) && Object.keys(value).length === 0);
|
|
59
62
|
|
|
63
|
+
/**
|
|
64
|
+
* A section body's own headings, one level down, so they sit under the
|
|
65
|
+
* section heading instead of beside it: a derived document writes each
|
|
66
|
+
* section with its own H2. A CommonMark parser says what is a heading, so
|
|
67
|
+
* fenced code, indented code, list items and block quotes are read as a
|
|
68
|
+
* reader's renderer reads them, and a heading-shaped line inside an HTML
|
|
69
|
+
* block is HTML, not a heading, and stays. Every heading lands at least one
|
|
70
|
+
* level under the section's H2, so an H1 and an H2 both become H3 and the
|
|
71
|
+
* rest move one down, H6 at most: an ATX heading (the # kind, an empty one
|
|
72
|
+
* included) takes the marks it needs; a setext heading (underlined)
|
|
73
|
+
* becomes the ATX heading of that depth, serialized from its parsed
|
|
74
|
+
* content (so a quote's prefix, a hard line break or a trailing # run are
|
|
75
|
+
* handled as Markdown, not as text). The edits go by the parser's
|
|
76
|
+
* character offsets, so no line counting of ours can disagree with its own.
|
|
77
|
+
*/
|
|
78
|
+
export function nestHeadings(body: string): string {
|
|
79
|
+
const edits: { start: number; end: number; text: string }[] = [];
|
|
80
|
+
// An explicit stack, and no spread into push: a body of thousands of
|
|
81
|
+
// nested quotes, or a list of very many items, must not overflow a walk.
|
|
82
|
+
const pending: Node[] = [fromMarkdown(body)];
|
|
83
|
+
while (pending.length > 0) {
|
|
84
|
+
const node = pending.pop()!;
|
|
85
|
+
if (
|
|
86
|
+
node.type === 'heading' &&
|
|
87
|
+
node.position?.start.offset !== undefined &&
|
|
88
|
+
node.position.end.offset !== undefined
|
|
89
|
+
) {
|
|
90
|
+
const start = node.position.start.offset;
|
|
91
|
+
const end = node.position.end.offset;
|
|
92
|
+
const heading = node as Heading;
|
|
93
|
+
const target = nestedDepth(heading.depth);
|
|
94
|
+
const atx = /^#{1,6}(\s|$)/.test(body.slice(start, start + 7));
|
|
95
|
+
if (atx && target > heading.depth) edits.push({ start, end: start, text: '#'.repeat(target - heading.depth) });
|
|
96
|
+
else if (!atx) edits.push({ start, end, text: atxOf(target, heading.children) });
|
|
97
|
+
}
|
|
98
|
+
if ('children' in node) for (const child of (node as Parent).children) pending.push(child);
|
|
99
|
+
}
|
|
100
|
+
let out = body;
|
|
101
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) {
|
|
102
|
+
out = `${out.slice(0, edit.start)}${edit.text}${out.slice(edit.end)}`;
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Under the section's H2: at least H3, one level down otherwise, H6 at most. */
|
|
108
|
+
const nestedDepth = (depth: number): Heading['depth'] => Math.min(6, Math.max(3, depth + 1)) as Heading['depth'];
|
|
109
|
+
|
|
110
|
+
/** An ATX heading of this depth from parsed content, its line breaks turned into spaces: an ATX heading is one line. */
|
|
111
|
+
function atxOf(depth: Heading['depth'], children: PhrasingContent[]): string {
|
|
112
|
+
const oneLine = (nodes: PhrasingContent[]): PhrasingContent[] =>
|
|
113
|
+
nodes.map((child) => {
|
|
114
|
+
if (child.type === 'break') return { type: 'text', value: ' ' };
|
|
115
|
+
if (child.type === 'text') return { ...child, value: child.value.replace(/\r\n|\r|\n/g, ' ') };
|
|
116
|
+
return 'children' in child ? { ...child, children: oneLine(child.children as PhrasingContent[]) } : child;
|
|
117
|
+
});
|
|
118
|
+
return toMarkdown({ type: 'heading', depth, children: oneLine(children) }).trimEnd();
|
|
119
|
+
}
|
|
120
|
+
|
|
60
121
|
/** The section's title when it carries one, else its id. */
|
|
61
122
|
function sectionTitle(section: unknown, id: string): string {
|
|
62
123
|
return isRecord(section) && typeof section.title === 'string' && section.title.trim() ? section.title.trim() : id;
|
|
Binary file
|
|
Binary file
|