umtri-mcp 1.0.0
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/LICENSE +201 -0
- package/README.md +88 -0
- package/core/api-client.js +54 -0
- package/core/policy.js +376 -0
- package/core/resources/plan.md +49 -0
- package/core/resources/seasons-human-only.md +33 -0
- package/core/resources/system-structure.md +229 -0
- package/core/resources/transplant.md +63 -0
- package/core/resources/vision.md +46 -0
- package/core/resources/vocabulary-detailed.md +278 -0
- package/core/resources/vocabulary.md +180 -0
- package/core/resources.js +74 -0
- package/core/tools.js +950 -0
- package/package.json +51 -0
- package/server.js +54 -0
package/core/tools.js
ADDED
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
// MCP tool 등록 — transport-agnostic. stdio CLI과 HTTP 라우터 양쪽에서 호출.
|
|
2
|
+
//
|
|
3
|
+
// 사용: registerTools(mcpServer, { api })
|
|
4
|
+
// - mcpServer: @modelcontextprotocol/sdk McpServer 인스턴스
|
|
5
|
+
// - api: createApiClient(...) 결과 — get/post/put/patch/delete
|
|
6
|
+
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { validateNode, validateEdge, validateUpdate, validateBugUpdate } from './policy.js';
|
|
9
|
+
|
|
10
|
+
// LLM이 소비하는 결과 — 들여쓰기는 순수 토큰 낭비라 minify. (큰 그래프에서 ~19% 절감)
|
|
11
|
+
function jsonResult(data) {
|
|
12
|
+
return { content: [{ type: 'text', text: JSON.stringify(data) }] };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function errorResult(err) {
|
|
16
|
+
return {
|
|
17
|
+
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
|
18
|
+
isError: true,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// description은 보통 50~300자. 200자에서 잘라 LLM 컨텍스트 폭증 방지.
|
|
23
|
+
const DESC_LIMIT = 200;
|
|
24
|
+
function truncate(s, limit = DESC_LIMIT) {
|
|
25
|
+
if (!s || typeof s !== 'string') return s;
|
|
26
|
+
return s.length > limit ? s.slice(0, limit) + '…' : s;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// bug.score 의미 — "변경 위험도(change risk)" = 기능적 영향도 × 수정 난이도.
|
|
30
|
+
// LLM이 점수를 일관되게 매기도록 create_bug/update_bug 설명에 주입.
|
|
31
|
+
const RISK_RUBRIC = [
|
|
32
|
+
'score = change risk on a 0–8 scale (8 = riskiest). It blends functional impact with fix difficulty (a harder fix is likelier to break things, so it ranks higher within a tier).',
|
|
33
|
+
'0: no risk — idea / memo.',
|
|
34
|
+
'1: no risk — copy / wording edit.',
|
|
35
|
+
'2: no functional error, but may change usability.',
|
|
36
|
+
'3: minor functional issue possible — simple fix. 4: minor — complex fix.',
|
|
37
|
+
'5: significant functional issue possible — simple fix. 6: significant — complex fix.',
|
|
38
|
+
'7: critical functional issue possible — simple fix. 8: critical — complex fix.',
|
|
39
|
+
'Defaults to 4. Urgency is intentionally NOT part of this score.',
|
|
40
|
+
].join(' ');
|
|
41
|
+
|
|
42
|
+
// structure 노드(trunk/limb/twig) 중 자손에 leaf/vein이 없으면 dormant.
|
|
43
|
+
// 식물 비유의 "잎 없는 가지" — LLM이 거기에 leaf 채울지 다른 가지 만들지 판단하는 신호.
|
|
44
|
+
function computeDormantSet(nodes) {
|
|
45
|
+
const children = new Map();
|
|
46
|
+
for (const n of nodes) {
|
|
47
|
+
if (!n.parent) continue;
|
|
48
|
+
if (!children.has(n.parent)) children.set(n.parent, []);
|
|
49
|
+
children.get(n.parent).push(n);
|
|
50
|
+
}
|
|
51
|
+
const dormant = new Set();
|
|
52
|
+
for (const n of nodes) {
|
|
53
|
+
if (n.role !== 'structure') continue;
|
|
54
|
+
const stack = [...(children.get(n.id) || [])];
|
|
55
|
+
let hasLeafOrVein = false;
|
|
56
|
+
while (stack.length) {
|
|
57
|
+
const c = stack.pop();
|
|
58
|
+
if (c.role === 'object' || c.role === 'action') { hasLeafOrVein = true; break; }
|
|
59
|
+
const cc = children.get(c.id);
|
|
60
|
+
if (cc) stack.push(...cc);
|
|
61
|
+
}
|
|
62
|
+
if (!hasLeafOrVein) dormant.add(n.id);
|
|
63
|
+
}
|
|
64
|
+
return dormant;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 버그 status 필터. 기본(active)은 open/in_progress만 — 해결된(resolved/closed) 침식은
|
|
68
|
+
// 트리에서 노이즈. 'all'이면 전부, 특정 status면 그것만.
|
|
69
|
+
function filterBugsByStatus(bugs, bugStatus) {
|
|
70
|
+
if (!Array.isArray(bugs) || bugStatus === 'all') return bugs;
|
|
71
|
+
if (bugStatus && bugStatus !== 'active') return bugs.filter((b) => b.status === bugStatus);
|
|
72
|
+
return bugs.filter((b) => b.status !== 'resolved' && b.status !== 'closed');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// project.metadata에서 UI 전용 무거운 키(아이콘 base64 등)를 제거 — LLM 컨텍스트엔 무의미.
|
|
76
|
+
function stripProjectMeta(project) {
|
|
77
|
+
if (!project || !project.metadata || typeof project.metadata !== 'object') return project;
|
|
78
|
+
const { icon, ...rest } = project.metadata;
|
|
79
|
+
if (icon === undefined) return project;
|
|
80
|
+
return { ...project, metadata: rest };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// description은 가변 길이라 응답에서 가장 무거운 필드. descMode로 노출량 제어:
|
|
84
|
+
// 'none' → 아예 제외(가장 가벼운 골격 읽기)
|
|
85
|
+
// 'excerpt' → 200자 발췌(기본)
|
|
86
|
+
// 'full' → 원문 그대로(특정 limb를 파고들 때)
|
|
87
|
+
function pickDesc(s, descMode) {
|
|
88
|
+
if (descMode === 'none') return undefined;
|
|
89
|
+
if (descMode === 'full') return s || undefined;
|
|
90
|
+
return truncate(s);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// graph 응답을 summary 모드로 압축. nodes만 큰 폭으로 줄이고 edges/apis/seasons는 거의 그대로.
|
|
94
|
+
// soft-deleted 노드는 제외(www 시간 슬라이더 전용 — MCP엔 불필요).
|
|
95
|
+
function compressGraph(graph, { descMode = 'excerpt' } = {}) {
|
|
96
|
+
const allNodes = Array.isArray(graph.nodes) ? graph.nodes : [];
|
|
97
|
+
const nodes = allNodes.filter((n) => !n.removedAt);
|
|
98
|
+
const dormant = computeDormantSet(nodes);
|
|
99
|
+
const childCount = new Map();
|
|
100
|
+
for (const n of nodes) {
|
|
101
|
+
if (n.parent != null) childCount.set(n.parent, (childCount.get(n.parent) || 0) + 1);
|
|
102
|
+
}
|
|
103
|
+
const compressedNodes = nodes.map((n) => {
|
|
104
|
+
const out = {
|
|
105
|
+
id: n.id,
|
|
106
|
+
label: n.label,
|
|
107
|
+
type: n.type,
|
|
108
|
+
parent: n.parent ?? null,
|
|
109
|
+
};
|
|
110
|
+
if (n.season) out.season = n.season;
|
|
111
|
+
const desc = pickDesc(n.description, descMode);
|
|
112
|
+
if (desc) out.description = desc;
|
|
113
|
+
if (n.metadata?.implements) out.implements = n.metadata.implements;
|
|
114
|
+
if (n.metadata?.placeholder) out.placeholder = true;
|
|
115
|
+
if (n.metadata?.plan) out.plan = true; // 사람이 그린 브리프(미확정) — umtri://rules/plan
|
|
116
|
+
if (dormant.has(n.id)) out.dormant = true;
|
|
117
|
+
const cc = childCount.get(n.id);
|
|
118
|
+
if (cc) out.childCount = cc;
|
|
119
|
+
return out;
|
|
120
|
+
});
|
|
121
|
+
const compressedBugs = Array.isArray(graph.bugs) ? graph.bugs.map((b) => {
|
|
122
|
+
const out = { ...b };
|
|
123
|
+
const d = pickDesc(b.description, descMode);
|
|
124
|
+
if (d === undefined) delete out.description; else out.description = d;
|
|
125
|
+
return out;
|
|
126
|
+
}) : graph.bugs;
|
|
127
|
+
const iaHints = computeIaHints(nodes, { edges: graph.edges, apis: graph.apis });
|
|
128
|
+
const shape = computeShape(nodes, childCount);
|
|
129
|
+
return {
|
|
130
|
+
...graph,
|
|
131
|
+
project: stripProjectMeta(graph.project),
|
|
132
|
+
shape,
|
|
133
|
+
nodes: compressedNodes,
|
|
134
|
+
bugs: compressedBugs,
|
|
135
|
+
...(iaHints.length > 0 ? { iaHints } : {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// list_projects summary — 슬라이드 식별·탐색에 필요한 핵심만. icon base64(metadata)·
|
|
140
|
+
// preNotes·seedMeta·typeCounts 등 무거운 필드는 생략. description은 descMode로 제어.
|
|
141
|
+
function compressProjects(projects, { descMode = 'excerpt' } = {}) {
|
|
142
|
+
if (!Array.isArray(projects)) return projects;
|
|
143
|
+
return projects.map((p) => {
|
|
144
|
+
const out = {
|
|
145
|
+
slug: p.slug,
|
|
146
|
+
name: p.name,
|
|
147
|
+
isActive: p.isActive,
|
|
148
|
+
transplanting: p.transplanting,
|
|
149
|
+
nodeCount: p.nodeCount,
|
|
150
|
+
openBugs: p.openBugs,
|
|
151
|
+
seasonCount: p.seasonCount,
|
|
152
|
+
};
|
|
153
|
+
if (p.nowSeasonLabel) out.nowSeasonLabel = p.nowSeasonLabel;
|
|
154
|
+
if (p.latestActivityAt) out.latestActivityAt = p.latestActivityAt;
|
|
155
|
+
const d = pickDesc(p.description, descMode);
|
|
156
|
+
if (d) out.description = d;
|
|
157
|
+
return out;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// list_bugs summary — 스캔에 필요한 핵심만. metadata 생략, description은 descMode로 제어.
|
|
162
|
+
function compressBugs(bugs, { descMode = 'excerpt' } = {}) {
|
|
163
|
+
if (!Array.isArray(bugs)) return bugs;
|
|
164
|
+
return bugs.map((b) => {
|
|
165
|
+
const out = {
|
|
166
|
+
id: b.id,
|
|
167
|
+
seq: b.seq, // 사람이 부르는 번호(#14) — get_bug이 이 값으로도 찾는다
|
|
168
|
+
target: b.target,
|
|
169
|
+
title: b.title,
|
|
170
|
+
score: b.score,
|
|
171
|
+
status: b.status,
|
|
172
|
+
};
|
|
173
|
+
if (b.impactCount != null) out.impactCount = b.impactCount;
|
|
174
|
+
if (b.createdAt) out.createdAt = b.createdAt;
|
|
175
|
+
if (b.resolvedAt) out.resolvedAt = b.resolvedAt;
|
|
176
|
+
const d = pickDesc(b.description, descMode);
|
|
177
|
+
if (d) out.description = d;
|
|
178
|
+
// solution도 가변 길이라 description과 같은 descMode 규칙을 태운다.
|
|
179
|
+
const sol = pickDesc(b.solution, descMode);
|
|
180
|
+
if (sol) out.solution = sol;
|
|
181
|
+
return out;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// list_seasons summary — 이미 가벼움(id/label/state/날짜). UI 전용 metadata만 떨궈냄.
|
|
186
|
+
function compressSeasons(seasons) {
|
|
187
|
+
if (!Array.isArray(seasons)) return seasons;
|
|
188
|
+
return seasons.map(({ metadata, ...rest }) => rest);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// IA 형상 진단 힌트 — get_graph 호출자가 트리를 더 잘 그리도록 자동 첨부.
|
|
192
|
+
// (1) missing-implements: leaf/vein 중 metadata.implements 없는 비율이 높을 때
|
|
193
|
+
// (2) sibling-cluster: 과밀한 limb/twig(잎 ≥4) 아래 접두어를 ≥3개 공유하는 잎 (twig 후보)
|
|
194
|
+
// — create_node의 parallel-leaves-need-twig와 임계값·정밀도 동일. "leaf 많음"만으론 발사 안 함
|
|
195
|
+
// (평평하지만 그룹 근거 없는 묶음은 정상). 구버전 junk-drawer-limb(무조건 ≥4)는 오발 잦아 제거.
|
|
196
|
+
export function computeIaHints(nodes, { edges = [], apis = [] } = {}) {
|
|
197
|
+
const hints = [];
|
|
198
|
+
const children = new Map();
|
|
199
|
+
for (const n of nodes) {
|
|
200
|
+
if (!n.parent) continue;
|
|
201
|
+
if (!children.has(n.parent)) children.set(n.parent, []);
|
|
202
|
+
children.get(n.parent).push(n);
|
|
203
|
+
}
|
|
204
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
205
|
+
|
|
206
|
+
// (1) missing-implements (집계 통계로 1번만)
|
|
207
|
+
const leafVein = nodes.filter((n) => (n.type === 'leaf' || n.type === 'vein') && !n.metadata?.placeholder);
|
|
208
|
+
const missing = leafVein.filter((n) => !n.metadata?.implements);
|
|
209
|
+
if (leafVein.length > 0 && missing.length / leafVein.length > 0.3) {
|
|
210
|
+
hints.push({
|
|
211
|
+
rule: 'missing-implements-coverage',
|
|
212
|
+
total: leafVein.length,
|
|
213
|
+
missingCount: missing.length,
|
|
214
|
+
sampleIds: missing.slice(0, 8).map((n) => n.id),
|
|
215
|
+
suggestion: 'Most leaf/vein nodes lack metadata.implements. Backfill source paths so the graph can answer "where does this live?".',
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// (1b) isolated-leaf-vein — edge/api가 하나도 닿지 않는 leaf/vein의 비율이 높을 때.
|
|
220
|
+
// 연결 없는 그래프는 영향 추적(get_impact)이 불가하다 → 의존/호출을 기록하거나 standalone임을
|
|
221
|
+
// 확인하도록 유도. 임계 50%(missing-implements보다 느슨 — 연결은 모든 노드에 있을 필요는 없음).
|
|
222
|
+
const connectedIds = new Set();
|
|
223
|
+
for (const e of edges) { connectedIds.add(e.source); connectedIds.add(e.target); }
|
|
224
|
+
for (const a of apis) { connectedIds.add(a.start); connectedIds.add(a.end); }
|
|
225
|
+
const isolated = leafVein.filter((n) => !connectedIds.has(n.id));
|
|
226
|
+
if (leafVein.length >= 4 && isolated.length / leafVein.length > 0.5) {
|
|
227
|
+
hints.push({
|
|
228
|
+
rule: 'isolated-leaf-vein',
|
|
229
|
+
total: leafVein.length,
|
|
230
|
+
isolatedCount: isolated.length,
|
|
231
|
+
sampleIds: isolated.slice(0, 8).map((n) => n.id),
|
|
232
|
+
suggestion: 'Over half of leaf/vein nodes have no edge or api touching them, so impact/blast-radius tracing (get_impact) can\'t cross to them. Record real dependencies (create_edge) and calls (create_api), or confirm the node is genuinely standalone.',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// (2) sibling-cluster — 과밀한(잎 ≥4) limb/twig 아래에서, 같은 접두어를 ≥3개 공유하는 잎.
|
|
237
|
+
// 한글 도메인 라벨은 자연히 접두어를 공유하므로(예: "예약 생성/취소") 느슨한 기준은 오탐·과도한
|
|
238
|
+
// twig화(over-nesting)를 유발 → junk-drawer 수준으로 과밀할 때만, 묶을 만큼(≥3) 모일 때만 제안.
|
|
239
|
+
for (const [parentId, kids] of children) {
|
|
240
|
+
const parent = byId.get(parentId);
|
|
241
|
+
if (!parent || (parent.type !== 'limb' && parent.type !== 'twig')) continue; // trunk 자식(limb)은 twig로 못 묶음
|
|
242
|
+
const leafKids = kids.filter((k) => k.type === 'leaf');
|
|
243
|
+
if (leafKids.length < 4) continue;
|
|
244
|
+
const groups = new Map(); // prefix → labels[]
|
|
245
|
+
for (const k of leafKids) {
|
|
246
|
+
const lbl = (k.label || '').trim();
|
|
247
|
+
const first = lbl.split(/[\s\-_/]+/)[0];
|
|
248
|
+
if (!first || first.length < 2) continue;
|
|
249
|
+
if (!groups.has(first)) groups.set(first, []);
|
|
250
|
+
groups.get(first).push(lbl);
|
|
251
|
+
}
|
|
252
|
+
for (const [prefix, labels] of groups) {
|
|
253
|
+
if (labels.length < 3) continue;
|
|
254
|
+
hints.push({
|
|
255
|
+
rule: 'sibling-cluster',
|
|
256
|
+
parentId,
|
|
257
|
+
parentLabel: parent.label,
|
|
258
|
+
sharedPrefix: prefix,
|
|
259
|
+
labels,
|
|
260
|
+
suggestion: `${labels.length} leaves under an already-crowded "${parent.label}" share prefix "${prefix}". A twig "${prefix} …" would group them. (Don't over-nest sparse parents — sharing a prefix is fine when there are only a few.)`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return hints;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 트리 형상 진단 — 호출자가 "깊나/넓나/균형 잡혔나"를 재구성 없이 즉시 알도록 응답에 동봉.
|
|
269
|
+
function computeShape(nodes, childCount) {
|
|
270
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
271
|
+
const depthOf = (n) => {
|
|
272
|
+
let d = 0; let p = n.parent; const seen = new Set();
|
|
273
|
+
while (p != null && byId.has(p) && !seen.has(p)) { seen.add(p); d += 1; p = byId.get(p).parent; }
|
|
274
|
+
return d;
|
|
275
|
+
};
|
|
276
|
+
const byLevel = {};
|
|
277
|
+
let maxDepth = 0;
|
|
278
|
+
for (const n of nodes) {
|
|
279
|
+
const d = depthOf(n);
|
|
280
|
+
byLevel[d] = (byLevel[d] || 0) + 1;
|
|
281
|
+
if (d > maxDepth) maxDepth = d;
|
|
282
|
+
}
|
|
283
|
+
const wideBranches = [...childCount.entries()]
|
|
284
|
+
.filter(([, c]) => c >= 6)
|
|
285
|
+
.map(([id, c]) => ({ id, label: byId.get(id)?.label, type: byId.get(id)?.type, childCount: c }))
|
|
286
|
+
.sort((a, b) => b.childCount - a.childCount)
|
|
287
|
+
.slice(0, 8);
|
|
288
|
+
return {
|
|
289
|
+
nodes: nodes.length,
|
|
290
|
+
maxDepth,
|
|
291
|
+
byLevel,
|
|
292
|
+
...(wideBranches.length ? { wideBranches } : {}),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function registerTools(server, { api }) {
|
|
297
|
+
server.registerTool(
|
|
298
|
+
'list_projects',
|
|
299
|
+
{
|
|
300
|
+
title: 'List grounds (projects)',
|
|
301
|
+
description: 'Returns all grounds the authenticated user can access, with latest activity timestamp. Use this first to discover slugs for other tools. By default returns a summary view — each ground carries slug, name, isActive, transplanting, nodeCount, openBugs, seasonCount, nowSeasonLabel, latestActivityAt, plus a 200-char description excerpt. Pass view="full" for all fields (preNotes, seedMeta, typeCounts, raw metadata, timestamps); the icon base64 in metadata is always stripped (UI-only).',
|
|
302
|
+
inputSchema: z.object({
|
|
303
|
+
view: z.enum(['summary', 'full']).optional().describe('summary (default) for a compact list; full for every field.'),
|
|
304
|
+
}),
|
|
305
|
+
},
|
|
306
|
+
async ({ view }) => {
|
|
307
|
+
try {
|
|
308
|
+
const projects = await api.get('/api/projects');
|
|
309
|
+
const out = view === 'full'
|
|
310
|
+
? (Array.isArray(projects) ? projects.map(stripProjectMeta) : projects)
|
|
311
|
+
: compressProjects(projects);
|
|
312
|
+
return jsonResult(out);
|
|
313
|
+
} catch (e) { return errorResult(e); }
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
server.registerTool(
|
|
318
|
+
'get_graph',
|
|
319
|
+
{
|
|
320
|
+
title: 'Get the graph of a ground',
|
|
321
|
+
description: 'Returns the tree(nodes), edges, apis, seasons, and bugs for one ground. The tree is the project\'s information-structure (IA) diagram — each node is a structural element (an object, or an action it performs), not a work-log entry. Node types follow the plant metaphor: trunk → limb → twig → leaf → vein. See resource umtri://rules/vocabulary. By default returns a summary view — nodes carry id/label/type/parent/season, plus a 200-char description excerpt, metadata.implements, placeholder/dormant flags (soft-deleted nodes are omitted). Pass view="full" only when you need timestamps, full descriptions, all metadata keys, or tags — that response is ~3× larger. For a large tree, read it in slices instead of all at once: rootId (+depth) for one trunk or limb; maxType for a layer (e.g. maxType="twig" = the skeleton without leaves/veins — a cheap overview); role for a cross-section; season for what was born in one season. Filters combine. Node ids come from the graph itself, so the standard drill-down is two calls: first get_graph with maxType="trunk" (or "limb") for a cheap skeleton, find the id you want, then call again with rootId set to it to get just that trunk/limb and its subtree. Control the heaviest field with descriptions: "none" drops descriptions for a pure structural overview, "excerpt" (default) gives a 200-char preview, "full" returns them verbatim when you drill into a limb. The summary response also carries shape (nodeCount, maxDepth, nodes-per-level, over-wide branches) so you can judge whether the tree is too deep or too wide without rebuilding it, a childCount on each branch node, and iaHints flagging structural smells. A bushy tree (mass at mid-levels) is healthy — don\'t over-nest sparse parents into twigs. Bugs default to active (open + in_progress) — pass bugStatus="all" to also see healed (resolved/closed) ones. Nodes may carry plan:true — these are the human\'s node-based brief (intent drawn as structure, not a prompt); read them as instructions and realize them (see umtri://rules/plan). When you slice (rootId/maxType/role/season), a connection with only ONE endpoint inside the slice is still returned, marked boundary:true, and its outside endpoint appears as a lightweight stub in externalNodes (id/label/type/role, external:true) — so cross-branch dependencies and calls never silently vanish from a slice. To follow one, call get_graph again with rootId set to that external id. The response also carries project.transplanting — when true the ground is still being transplanted (see umtri://rules/transplant): you may freely add/edit nodes in any season incl. past, and hard-delete import mistakes.',
|
|
322
|
+
inputSchema: z.object({
|
|
323
|
+
slug: z.string().min(1).describe('Ground slug (from list_projects).'),
|
|
324
|
+
view: z.enum(['summary', 'full']).optional().describe('summary (default) for compact nodes; full for raw shape with all fields.'),
|
|
325
|
+
rootId: z.string().optional().describe('Scope to this node and its descendants — read one trunk or limb at a time. Get the id from a prior get_graph (e.g. maxType="trunk" for a cheap skeleton). Bugs are scoped to the subtree; edges/apis that cross the subtree boundary are still returned (boundary:true) with their outside endpoint in externalNodes.'),
|
|
326
|
+
depth: z.number().int().min(0).optional().describe('With rootId: how many levels below the root to include (0 = just the root, 1 = root + direct children). Omit for the whole subtree.'),
|
|
327
|
+
maxType: z.enum(['trunk', 'limb', 'twig', 'leaf', 'vein']).optional().describe('Layer ceiling — return only nodes at this level or higher (trunk is highest). maxType="twig" yields the structural skeleton without leaves/veins. Great for a cheap overview before drilling in with rootId.'),
|
|
328
|
+
role: z.enum(['structure', 'object', 'action']).optional().describe('Cross-section by role (structure=trunk/limb/twig, object=leaf, action=vein). Parents may fall outside the result.'),
|
|
329
|
+
season: z.string().optional().describe('Born-in delta — return only nodes that first appeared in this season (season id from list_seasons): what grew that season.'),
|
|
330
|
+
descriptions: z.enum(['none', 'excerpt', 'full']).optional().describe('How much of each node/bug description to include in summary view: none (drop them — lightest structural read), excerpt (200-char preview, default), or full (verbatim). Ignored when view="full" (always verbatim).'),
|
|
331
|
+
bugStatus: z.enum(['active', 'all', 'open', 'in_progress', 'resolved', 'closed']).optional().describe('Which bugs to include. Default "active" = open + in_progress only (resolved/closed are healed erosion — noise on the tree). "all" for every bug, or a specific status. Use list_bugs for richer bug queries.'),
|
|
332
|
+
}),
|
|
333
|
+
},
|
|
334
|
+
async ({ slug, view, rootId, depth, maxType, role, season, descriptions, bugStatus }) => {
|
|
335
|
+
try {
|
|
336
|
+
const qs = [];
|
|
337
|
+
if (rootId) qs.push(`root=${encodeURIComponent(rootId)}`);
|
|
338
|
+
if (depth != null) qs.push(`depth=${encodeURIComponent(depth)}`);
|
|
339
|
+
if (maxType) qs.push(`maxType=${encodeURIComponent(maxType)}`);
|
|
340
|
+
if (role) qs.push(`role=${encodeURIComponent(role)}`);
|
|
341
|
+
if (season) qs.push(`season=${encodeURIComponent(season)}`);
|
|
342
|
+
qs.push('bugImpact=true'); // 임베디드 버그에 impactCount(blast radius 크기) 자동 첨부
|
|
343
|
+
const q = qs.length ? `?${qs.join('&')}` : '';
|
|
344
|
+
const graph = await api.get(`/api/projects/${encodeURIComponent(slug)}/graph${q}`);
|
|
345
|
+
graph.bugs = filterBugsByStatus(graph.bugs, bugStatus || 'active');
|
|
346
|
+
// 아이콘 base64는 UI 전용 — view와 무관하게 제거. full도 stripProjectMeta 적용.
|
|
347
|
+
const out = view === 'full'
|
|
348
|
+
? { ...graph, project: stripProjectMeta(graph.project) }
|
|
349
|
+
: compressGraph(graph, { descMode: descriptions || 'excerpt' });
|
|
350
|
+
return jsonResult(out);
|
|
351
|
+
} catch (e) { return errorResult(e); }
|
|
352
|
+
},
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
server.registerTool(
|
|
356
|
+
'get_impact',
|
|
357
|
+
{
|
|
358
|
+
title: 'Trace the blast radius of a node or bug',
|
|
359
|
+
description: [
|
|
360
|
+
'Walks the connection graph (edges + apis) from a start node — or a bug\'s target — to find which other nodes a problem would reach. This is the tool for "if this breaks / I change this, what else do I need to check?" and for QA scoping.',
|
|
361
|
+
'Impact does NOT always flow with the arrow. direction="affected" (default) answers "if the start breaks, who is hurt?" and follows: dependency edges backward (target→source), data_flow edges forward (source→target), apis backward (callee→caller). direction="dependsOn" is the reverse ("what does the start rely on?"). direction="both" unions them. See umtri://rules/system-structure (How problems propagate).',
|
|
362
|
+
'Provide exactly one start: node (a node id) or bug (a bug id — starts from its target node, or both endpoints if the bug is on an api; ground-level bugs are rejected). Traversal is over CURRENT (live) structure only.',
|
|
363
|
+
'Returns reached[] (each with hops distance, the node `from` which it was reached, and the connection `via` it came through — so you can reconstruct the chain), bugs[] (active bugs sitting on any reached node/api, worst score first), and coverage. IMPORTANT: the result is a list of nodes to CHECK, not a proven failure set — it is only as complete as the connections recorded. coverage.startsWithoutConnections flags when the start has no connections at all: an empty result then means "nothing recorded," not "nothing affected" — record edges/apis first.',
|
|
364
|
+
].join(' '),
|
|
365
|
+
inputSchema: z.object({
|
|
366
|
+
slug: z.string().min(1).describe('Ground slug.'),
|
|
367
|
+
node: z.string().optional().describe('Start node id. Provide this OR bug.'),
|
|
368
|
+
bug: z.string().optional().describe('Start from this bug\'s target. Provide this OR node.'),
|
|
369
|
+
direction: z.enum(['affected', 'dependsOn', 'both']).optional().describe('affected (default): what breaks if the start breaks. dependsOn: what the start relies on. both: union.'),
|
|
370
|
+
maxDepth: z.number().int().min(0).optional().describe('Max hops to traverse. Omit for unbounded.'),
|
|
371
|
+
}),
|
|
372
|
+
},
|
|
373
|
+
async ({ slug, node, bug, direction, maxDepth }) => {
|
|
374
|
+
try {
|
|
375
|
+
const qs = [];
|
|
376
|
+
if (node) qs.push(`node=${encodeURIComponent(node)}`);
|
|
377
|
+
if (bug) qs.push(`bug=${encodeURIComponent(bug)}`);
|
|
378
|
+
if (direction) qs.push(`direction=${encodeURIComponent(direction)}`);
|
|
379
|
+
if (maxDepth != null) qs.push(`maxDepth=${encodeURIComponent(maxDepth)}`);
|
|
380
|
+
const q = qs.length ? `?${qs.join('&')}` : '';
|
|
381
|
+
const out = await api.get(`/api/projects/${encodeURIComponent(slug)}/impact${q}`);
|
|
382
|
+
return jsonResult(out);
|
|
383
|
+
} catch (e) { return errorResult(e); }
|
|
384
|
+
},
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
server.registerTool(
|
|
388
|
+
'list_bugs',
|
|
389
|
+
{
|
|
390
|
+
title: 'List bugs of a ground',
|
|
391
|
+
description: 'Returns bugs (issues eroding the ground). Optional status filter. Each bug has a score (0–8 change-risk; 8 = riskiest). Targets: a node, an api, or the ground itself. By default returns a summary view — each bug carries id, target, title, score, status, createdAt/resolvedAt, plus a 200-char description excerpt. Each node/api bug also carries impactCount — how many other nodes its target reaches by blast radius (affected direction), so you can spot wide-blast bugs at a glance; call get_impact on that bug for the full reached list. Pass view="full" for full descriptions and metadata.',
|
|
392
|
+
inputSchema: z.object({
|
|
393
|
+
slug: z.string().min(1).describe('Ground slug.'),
|
|
394
|
+
status: z.enum(['open', 'in_progress', 'resolved', 'closed']).optional()
|
|
395
|
+
.describe('Filter by status. Omit to return all.'),
|
|
396
|
+
view: z.enum(['summary', 'full']).optional().describe('summary (default) for a compact list; full for verbatim descriptions + metadata.'),
|
|
397
|
+
}),
|
|
398
|
+
},
|
|
399
|
+
async ({ slug, status, view }) => {
|
|
400
|
+
try {
|
|
401
|
+
const qs = ['impact=true']; // 각 버그에 impactCount(blast radius 크기) 자동 첨부
|
|
402
|
+
if (status) qs.push(`status=${encodeURIComponent(status)}`);
|
|
403
|
+
const bugs = await api.get(`/api/projects/${encodeURIComponent(slug)}/bugs?${qs.join('&')}`);
|
|
404
|
+
return jsonResult(view === 'full' ? bugs : compressBugs(bugs));
|
|
405
|
+
} catch (e) { return errorResult(e); }
|
|
406
|
+
},
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
server.registerTool(
|
|
410
|
+
'get_bug',
|
|
411
|
+
{
|
|
412
|
+
title: 'Get one bug of a ground',
|
|
413
|
+
description: 'Returns a single bug by reference. `ref` accepts either the human-facing number shown in the UI (seq — "14" or #14) or the internal id (bug-<uuid>). Use this when a human names a bug by its number; use list_bugs to scan. The response carries the full description and metadata, plus impact — the blast radius reached from the bug\'s target (reachedCount, the reached nodes with hop distance, and other active bugs sitting in that radius). Numbers are per-ground and never reused, so a deleted bug leaves a gap rather than shifting the others. Returns an error if no bug matches.',
|
|
414
|
+
inputSchema: z.object({
|
|
415
|
+
slug: z.string().min(1).describe('Ground slug.'),
|
|
416
|
+
ref: z.union([z.string().min(1), z.number().int().positive()])
|
|
417
|
+
.describe('Bug number (seq, e.g. 14) or internal id (bug-<uuid>).'),
|
|
418
|
+
}),
|
|
419
|
+
},
|
|
420
|
+
async ({ slug, ref }) => {
|
|
421
|
+
try {
|
|
422
|
+
const bug = await api.get(
|
|
423
|
+
`/api/projects/${encodeURIComponent(slug)}/bugs/${encodeURIComponent(String(ref))}?impact=true`,
|
|
424
|
+
);
|
|
425
|
+
return jsonResult(bug);
|
|
426
|
+
} catch (e) { return errorResult(e); }
|
|
427
|
+
},
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
server.registerTool(
|
|
431
|
+
'list_seasons',
|
|
432
|
+
{
|
|
433
|
+
title: 'List seasons of a ground',
|
|
434
|
+
description: 'Returns the seasons (time epochs) of a ground in chronological order. Seasons are created only by humans — do not attempt to create them via this MCP. See umtri://rules/seasons-human-only. By default returns a summary view (id, label, state, startedAt, grownAt). Pass view="full" to also include metadata.',
|
|
435
|
+
inputSchema: z.object({
|
|
436
|
+
slug: z.string().min(1).describe('Ground slug.'),
|
|
437
|
+
view: z.enum(['summary', 'full']).optional().describe('summary (default) drops UI-only metadata; full includes it.'),
|
|
438
|
+
}),
|
|
439
|
+
},
|
|
440
|
+
async ({ slug, view }) => {
|
|
441
|
+
try {
|
|
442
|
+
const seasons = await api.get(`/api/projects/${encodeURIComponent(slug)}/seasons`);
|
|
443
|
+
return jsonResult(view === 'full' ? seasons : compressSeasons(seasons));
|
|
444
|
+
} catch (e) { return errorResult(e); }
|
|
445
|
+
},
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
server.registerTool(
|
|
449
|
+
'list_events',
|
|
450
|
+
{
|
|
451
|
+
title: 'List change history of a ground',
|
|
452
|
+
description: 'Returns the append-only change history (most recent first) of a ground: node/edge/api/bug create, update (with a field-level diff {from,to}), delete, and plan_commit. Each event carries the actor ("user:<id>" for humans, "token:<id>" for agents/automation), a summary label, and for updates a diff. Use it to answer "what changed, when, and by whom" — e.g. a bug\'s status transitions or a node\'s edits over time. Filter with entityType/entityId to get a single entity\'s timeline. Note: git commits recorded via record_commit live in a node\'s metadata.commits, not here.',
|
|
453
|
+
inputSchema: z.object({
|
|
454
|
+
slug: z.string().min(1).describe('Ground slug.'),
|
|
455
|
+
limit: z.number().int().positive().max(200).optional().describe('Max events to return (default 50, cap 200).'),
|
|
456
|
+
before: z.string().optional().describe('ISO timestamp — return events strictly older than this (keyset pagination).'),
|
|
457
|
+
entityType: z.enum(['node', 'edge', 'api', 'bug']).optional().describe('Filter to one entity type.'),
|
|
458
|
+
entityId: z.string().optional().describe('Filter to one entity\'s timeline (usually paired with entityType).'),
|
|
459
|
+
}),
|
|
460
|
+
},
|
|
461
|
+
async ({ slug, limit, before, entityType, entityId }) => {
|
|
462
|
+
try {
|
|
463
|
+
const qs = [];
|
|
464
|
+
if (limit) qs.push(`limit=${limit}`);
|
|
465
|
+
if (before) qs.push(`before=${encodeURIComponent(before)}`);
|
|
466
|
+
if (entityType) qs.push(`entityType=${encodeURIComponent(entityType)}`);
|
|
467
|
+
if (entityId) qs.push(`entityId=${encodeURIComponent(entityId)}`);
|
|
468
|
+
const path = `/api/projects/${encodeURIComponent(slug)}/events${qs.length ? `?${qs.join('&')}` : ''}`;
|
|
469
|
+
return jsonResult(await api.get(path));
|
|
470
|
+
} catch (e) { return errorResult(e); }
|
|
471
|
+
},
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
// ─── write tools ───
|
|
475
|
+
// Requires a token with scope='write'. Read-scope tokens get a clear 403 message.
|
|
476
|
+
|
|
477
|
+
server.registerTool(
|
|
478
|
+
'create_bug',
|
|
479
|
+
{
|
|
480
|
+
title: 'Report a bug on a ground',
|
|
481
|
+
description: [
|
|
482
|
+
'Creates a new bug (issue eroding the target).',
|
|
483
|
+
'target.kind = "node" or "api" requires target.id of an existing node/api in the ground.',
|
|
484
|
+
'target.kind = "ground" attaches the bug to the project itself (no id).',
|
|
485
|
+
RISK_RUBRIC,
|
|
486
|
+
'status defaults to "open".',
|
|
487
|
+
'solution is the fix: at report time it is the plan ("this is probably how we fix it"), and by the time the bug is resolved it should describe what was actually applied. Same field — overwrite it as understanding changes; project_events keeps the diff. Leave it empty rather than guessing.',
|
|
488
|
+
'For a node/api bug, the response auto-attaches impact (the affected blast radius from the target): reachedCount, the reached nodes with hop distance and the connection each was reached through, other active bugs sitting in that radius, and a coverage note. Use it to scope what else to check/QA. If the target has no recorded connections the radius is empty — that means nothing is recorded, not that nothing is affected (record edges/apis).',
|
|
489
|
+
'Requires a write-scope token. See umtri://rules/vocabulary for bug semantics.',
|
|
490
|
+
].join(' '),
|
|
491
|
+
inputSchema: z.object({
|
|
492
|
+
slug: z.string().min(1).describe('Ground slug.'),
|
|
493
|
+
title: z.string().min(1).max(200).describe('Short summary of the bug. Required.'),
|
|
494
|
+
target: z.object({
|
|
495
|
+
kind: z.enum(['node', 'api', 'ground']).describe('Where the bug attaches.'),
|
|
496
|
+
id: z.string().optional().describe('Required when kind is "node" or "api".'),
|
|
497
|
+
}).describe('Bug target. Use {kind:"ground"} when the bug is about the project as a whole.'),
|
|
498
|
+
description: z.string().optional().describe('Longer details on what is wrong (markdown allowed).'),
|
|
499
|
+
solution: z.string().optional().describe('How to fix it. At report time this is the plan/idea; update it to what was actually applied when you resolve. Markdown allowed. Omit if you do not know yet.'),
|
|
500
|
+
score: z.number().int().min(0).max(8).optional().describe('Change risk 0–8 (8 = riskiest: critical impact × complex fix). Defaults to 4. See tool description for the rubric.'),
|
|
501
|
+
status: z.enum(['open', 'in_progress', 'resolved', 'closed']).optional()
|
|
502
|
+
.describe('Defaults to "open".'),
|
|
503
|
+
}),
|
|
504
|
+
},
|
|
505
|
+
async ({ slug, title, target, description, solution, score, status }) => {
|
|
506
|
+
try {
|
|
507
|
+
const body = { title, target };
|
|
508
|
+
if (description !== undefined) body.description = description;
|
|
509
|
+
if (solution !== undefined) body.solution = solution;
|
|
510
|
+
if (score !== undefined) body.score = score;
|
|
511
|
+
if (status !== undefined) body.status = status;
|
|
512
|
+
const created = await api.post(`/api/projects/${encodeURIComponent(slug)}/bugs`, body);
|
|
513
|
+
// 영향권 자동 첨부 — 이 버그가 어디까지 번지는지(affected). ground 대상은 출발 노드가 없어 생략.
|
|
514
|
+
// 영향권 조회가 실패해도 버그 생성 자체는 성공으로 반환한다(부가 정보이므로).
|
|
515
|
+
if (target?.kind !== 'ground' && created?.id) {
|
|
516
|
+
try {
|
|
517
|
+
const imp = await api.get(`/api/projects/${encodeURIComponent(slug)}/impact?bug=${encodeURIComponent(created.id)}&direction=affected`);
|
|
518
|
+
created.impact = {
|
|
519
|
+
reachedCount: imp.reachedCount,
|
|
520
|
+
reached: imp.reached,
|
|
521
|
+
bugs: imp.bugs,
|
|
522
|
+
coverage: imp.coverage,
|
|
523
|
+
};
|
|
524
|
+
} catch { /* 영향권 부가 정보 실패는 무시 */ }
|
|
525
|
+
}
|
|
526
|
+
return jsonResult(created);
|
|
527
|
+
} catch (e) { return errorResult(e); }
|
|
528
|
+
},
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
server.registerTool(
|
|
532
|
+
'create_project',
|
|
533
|
+
{
|
|
534
|
+
title: 'Create a new ground (project)',
|
|
535
|
+
description: 'Creates a new ground. Slug must match /^[a-z0-9][a-z0-9-]{0,49}$/ and be unique. The authenticated user owns it. Requires a write-scope token.',
|
|
536
|
+
inputSchema: z.object({
|
|
537
|
+
slug: z.string().regex(/^[a-z0-9][a-z0-9-]{0,49}$/).describe('URL slug. Lowercase letters, digits, hyphens; 1–50 chars.'),
|
|
538
|
+
name: z.string().min(1).max(200).describe('Display name.'),
|
|
539
|
+
description: z.string().optional(),
|
|
540
|
+
visibility: z.enum(['private', 'unlisted', 'public']).optional().describe('Defaults to private.'),
|
|
541
|
+
}),
|
|
542
|
+
},
|
|
543
|
+
async ({ slug, name, description, visibility }) => {
|
|
544
|
+
try {
|
|
545
|
+
const body = { slug, name };
|
|
546
|
+
if (description !== undefined) body.description = description;
|
|
547
|
+
if (visibility !== undefined) body.visibility = visibility;
|
|
548
|
+
return jsonResult(await api.post('/api/projects', body));
|
|
549
|
+
} catch (e) { return errorResult(e); }
|
|
550
|
+
},
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
server.registerTool(
|
|
554
|
+
'create_node',
|
|
555
|
+
{
|
|
556
|
+
title: 'Create a node in a ground',
|
|
557
|
+
description: [
|
|
558
|
+
'Creates a node. Follows the plant vocabulary protocol — see umtri://rules/vocabulary.',
|
|
559
|
+
'type ∈ trunk · limb · twig · leaf · vein. Role is derived (structure / object / action).',
|
|
560
|
+
'Build leaf-first: when adding a leaf/vein, create only the missing trunk/limb/twig ancestors on its path, then the leaf — do NOT pre-build every trunk, then every limb, then leaves (see Build order in umtri://rules/vocabulary).',
|
|
561
|
+
'Aim for a faithful, COMPLETE map of the project, not a summary: every meaningful module/screen/endpoint/table/integration should become a node (grouped at the information-unit grain, not one-per-file). A real project yields many leaves — under-capturing to a few nodes is the more common mistake. See Completeness in umtri://rules/system-structure.',
|
|
562
|
+
'parent ∈ existing node id; omit to create a root-level node.',
|
|
563
|
+
'season ∈ existing season id; omit to use the active "now" season. Past seasons are normally rejected, but allowed while the ground is transplanting (project.transplanting=true) — nodes added then are auto-stamped metadata.transplanted=true for audit. See umtri://rules/transplant.',
|
|
564
|
+
'The tool validates against protocol policies. Hierarchy violations are rejected. Soft issues (reserved-domain labels, leaf↔vein heuristic, trunk naming) come back as warnings in the response — reconsider before continuing if warnings appear.',
|
|
565
|
+
'After creating a leaf/vein, consider its connections: if it calls/feeds another node add an api (create_api), if it depends on/is built on another add an edge (create_edge). The response carries a connectionCheck reminder. See umtri://rules/system-structure (Connections).',
|
|
566
|
+
'Creating seasons via MCP is forbidden — see umtri://rules/seasons-human-only.',
|
|
567
|
+
'When realizing a human-drawn plan brief, any detail nodes you add should carry metadata.plan=true and the realized node needs metadata.implements — see umtri://rules/plan.',
|
|
568
|
+
].join(' '),
|
|
569
|
+
inputSchema: z.object({
|
|
570
|
+
slug: z.string().min(1).describe('Ground slug.'),
|
|
571
|
+
type: z.string().min(1).describe('Node type. Use trunk/limb/twig/leaf/vein per the metaphor.'),
|
|
572
|
+
label: z.string().min(1).max(200).describe('Node label, visible in the tree.'),
|
|
573
|
+
parent: z.string().optional().describe('Parent node id. Omit for root-level (a new trunk).'),
|
|
574
|
+
season: z.string().optional().describe('Season id. Omit to use the active "now" season.'),
|
|
575
|
+
description: z.string().optional(),
|
|
576
|
+
tags: z.array(z.string()).optional(),
|
|
577
|
+
metadata: z.record(z.any()).optional().describe('Free-form metadata. For `leaf` and `vein`, set `metadata.implements` to an array of file paths (or `path#identifier` for multi-export files) — this is how the graph maps concept → code and is expected on essentially every leaf/vein. Use `metadata.placeholder=true` for intentionally-empty structure.'),
|
|
578
|
+
sproutedAt: z.string().datetime({ offset: true }).optional().describe('Effective creation time (ISO 8601 with offset, e.g. "2024-03-15T09:00:00Z"). Omit to use the current moment. Use only when back-filling history of a project that existed before Umtri — e.g. importing past commits or migrating a tree. The visualization timeline (sibling order, season visibility, events) follows this value.'),
|
|
579
|
+
}),
|
|
580
|
+
},
|
|
581
|
+
async ({ slug, type, label, parent, season, description, tags, metadata, sproutedAt }) => {
|
|
582
|
+
try {
|
|
583
|
+
let parentType = null;
|
|
584
|
+
let siblingLeafLabels;
|
|
585
|
+
if (parent) {
|
|
586
|
+
const graph = await api.get(`/api/projects/${encodeURIComponent(slug)}/graph`);
|
|
587
|
+
const parentNode = (graph.nodes || []).find(n => n.id === parent);
|
|
588
|
+
if (!parentNode) throw new Error(`Parent node "${parent}" not found in ground "${slug}".`);
|
|
589
|
+
parentType = parentNode.type;
|
|
590
|
+
siblingLeafLabels = (graph.nodes || [])
|
|
591
|
+
.filter(n => n.parent === parent && n.type === 'leaf')
|
|
592
|
+
.map(n => n.label);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const { ok, rejectReason, warnings } = validateNode({
|
|
596
|
+
type, label, parentType, description, metadata, siblingLeafLabels,
|
|
597
|
+
});
|
|
598
|
+
if (!ok) throw new Error(`Rejected by protocol: ${rejectReason}`);
|
|
599
|
+
|
|
600
|
+
const body = { type, label };
|
|
601
|
+
if (parent !== undefined) body.parent = parent;
|
|
602
|
+
if (season !== undefined) body.season = season;
|
|
603
|
+
if (description !== undefined) body.description = description;
|
|
604
|
+
if (tags !== undefined) body.tags = tags;
|
|
605
|
+
if (metadata !== undefined) body.metadata = metadata;
|
|
606
|
+
if (sproutedAt !== undefined) body.sproutedAt = sproutedAt;
|
|
607
|
+
|
|
608
|
+
const created = await api.post(`/api/projects/${encodeURIComponent(slug)}/nodes`, body);
|
|
609
|
+
const out = warnings.length ? { ...created, warnings } : { ...created };
|
|
610
|
+
// 연결 고민 넛지 — leaf/vein은 보통 다른 노드를 호출/의존하므로 매번 확인 유도.
|
|
611
|
+
if (type === 'leaf' || type === 'vein') {
|
|
612
|
+
out.connectionCheck = 'Does this node call/feed another (→ create_api) or depend on another (→ create_edge)? Add the connection now if so; skip if genuinely standalone. See umtri://rules/system-structure (Connections).';
|
|
613
|
+
}
|
|
614
|
+
return jsonResult(out);
|
|
615
|
+
} catch (e) { return errorResult(e); }
|
|
616
|
+
},
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
server.registerTool(
|
|
620
|
+
'update_node',
|
|
621
|
+
{
|
|
622
|
+
title: 'Update a node',
|
|
623
|
+
description: 'Partial update of a node. Patchable fields: label, type, parent, season, description, tags, metadata, sproutedAt. Type change reclassifies role; parent change recomputes the ltree path automatically. Moving a node into a season whose state is `past` is rejected, and grown (past-season) nodes are immutable — UNLESS the ground is transplanting (project.transplanting=true), where both restrictions are lifted so historical structure can be reconstructed (see umtri://rules/transplant). Same protocol validation as create_node — reject on hierarchy violations, warn on soft issues.',
|
|
624
|
+
inputSchema: z.object({
|
|
625
|
+
slug: z.string().min(1).describe('Ground slug.'),
|
|
626
|
+
id: z.string().min(1).describe('Node id.'),
|
|
627
|
+
patch: z.object({
|
|
628
|
+
label: z.string().min(1).max(200).optional(),
|
|
629
|
+
type: z.string().min(1).optional(),
|
|
630
|
+
parent: z.string().nullable().optional().describe('New parent id, or null to detach to root.'),
|
|
631
|
+
season: z.string().nullable().optional().describe('New season id, or null to detach from any season. Past seasons are rejected.'),
|
|
632
|
+
description: z.string().nullable().optional(),
|
|
633
|
+
tags: z.array(z.string()).optional(),
|
|
634
|
+
metadata: z.record(z.any()).optional(),
|
|
635
|
+
sproutedAt: z.string().datetime({ offset: true }).nullable().optional().describe('Effective creation time (ISO 8601 with offset). Set to null to clear and fall back to the system timestamp. Use only for back-filling historical projects.'),
|
|
636
|
+
}).describe('Only the fields you want to change.'),
|
|
637
|
+
}),
|
|
638
|
+
},
|
|
639
|
+
async ({ slug, id, patch }) => {
|
|
640
|
+
try {
|
|
641
|
+
let warnings = [];
|
|
642
|
+
|
|
643
|
+
const patchResult = validateUpdate({ patch });
|
|
644
|
+
warnings = warnings.concat(patchResult.warnings);
|
|
645
|
+
|
|
646
|
+
if (patch.type !== undefined || patch.parent !== undefined || patch.label !== undefined) {
|
|
647
|
+
const graph = await api.get(`/api/projects/${encodeURIComponent(slug)}/graph`);
|
|
648
|
+
const current = (graph.nodes || []).find(n => n.id === id);
|
|
649
|
+
if (!current) throw new Error(`Node "${id}" not found in ground "${slug}".`);
|
|
650
|
+
|
|
651
|
+
const newType = patch.type ?? current.type;
|
|
652
|
+
const newLabel = patch.label ?? current.label;
|
|
653
|
+
const newParentId = patch.parent !== undefined ? patch.parent : current.parent;
|
|
654
|
+
const newDescription = patch.description !== undefined ? patch.description : current.description;
|
|
655
|
+
const newMetadata = patch.metadata !== undefined ? patch.metadata : current.metadata;
|
|
656
|
+
const parentType = newParentId
|
|
657
|
+
? (graph.nodes || []).find(n => n.id === newParentId)?.type ?? null
|
|
658
|
+
: null;
|
|
659
|
+
|
|
660
|
+
const result = validateNode({
|
|
661
|
+
type: newType,
|
|
662
|
+
label: newLabel,
|
|
663
|
+
parentType,
|
|
664
|
+
description: newDescription,
|
|
665
|
+
metadata: newMetadata,
|
|
666
|
+
phase: 'update',
|
|
667
|
+
});
|
|
668
|
+
if (!result.ok) throw new Error(`Rejected by protocol: ${result.rejectReason}`);
|
|
669
|
+
warnings = warnings.concat(result.warnings);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const updated = await api.put(`/api/projects/${encodeURIComponent(slug)}/nodes/${encodeURIComponent(id)}`, patch);
|
|
673
|
+
return jsonResult(warnings.length ? { ...updated, warnings } : updated);
|
|
674
|
+
} catch (e) { return errorResult(e); }
|
|
675
|
+
},
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
server.registerTool(
|
|
679
|
+
'delete_node',
|
|
680
|
+
{
|
|
681
|
+
title: 'Soft-delete a node',
|
|
682
|
+
description: 'Sets removed_at on the node. By policy this tool rejects deletion if the node has any active descendant — delete children explicitly first to avoid accidental cascades. (The underlying REST API would cascade; the MCP layer guards against silent loss.) EXCEPTION — while the ground is transplanting (project.transplanting=true), the active-descendant guard is lifted (subtree cascade allowed) and you may pass hard=true to permanently remove import mistakes, including grown (past-season) nodes. Once the human roots the ground, normal guards return.',
|
|
683
|
+
inputSchema: z.object({
|
|
684
|
+
slug: z.string().min(1).describe('Ground slug.'),
|
|
685
|
+
id: z.string().min(1).describe('Node id.'),
|
|
686
|
+
hard: z.boolean().optional().describe('Permanently delete (incl. descendants via FK cascade) instead of soft-delete. Only honored while the ground is transplanting; irreversible — use for import cleanup.'),
|
|
687
|
+
}),
|
|
688
|
+
},
|
|
689
|
+
async ({ slug, id, hard }) => {
|
|
690
|
+
try {
|
|
691
|
+
const graph = await api.get(`/api/projects/${encodeURIComponent(slug)}/graph`);
|
|
692
|
+
const target = (graph.nodes || []).find(n => n.id === id);
|
|
693
|
+
if (!target) throw new Error(`Node "${id}" not found in ground "${slug}".`);
|
|
694
|
+
|
|
695
|
+
const transplanting = graph.project?.transplanting === true;
|
|
696
|
+
|
|
697
|
+
// 옮겨심는 중이면 cascade 허용(가드 해제). 평상시엔 활성 자손 있으면 거부.
|
|
698
|
+
if (!transplanting) {
|
|
699
|
+
const children = (graph.nodes || []).filter(n => n.parent === id && !n.removedAt);
|
|
700
|
+
if (children.length > 0) {
|
|
701
|
+
const list = children.slice(0, 5).map(c => `${c.id}("${c.label}")`).join(', ');
|
|
702
|
+
const more = children.length > 5 ? ` (+${children.length - 5} more)` : '';
|
|
703
|
+
throw new Error(`Node has ${children.length} active child node(s). Delete them first. Children: ${list}${more}`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const useHard = hard === true && transplanting;
|
|
708
|
+
const path = `/api/projects/${encodeURIComponent(slug)}/nodes/${encodeURIComponent(id)}${useHard ? '?hard=true' : ''}`;
|
|
709
|
+
const res = await api.delete(path);
|
|
710
|
+
return jsonResult(res);
|
|
711
|
+
} catch (e) { return errorResult(e); }
|
|
712
|
+
},
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
server.registerTool(
|
|
716
|
+
'reopen_transplant',
|
|
717
|
+
{
|
|
718
|
+
title: 'Re-open transplant on a ground',
|
|
719
|
+
description: 'Re-opens the transplant window on a rooted ground (sets project.transplanting=true), relaxing guards so historical/structural reconstruction can resume: add/edit nodes in any season incl. past, and hard-delete mistakes. CRITICAL: only call this when the user has EXPLICITLY asked to switch the ground into transplanting (e.g. "put this ground back in transplant", "옮겨심기로 바꿔줘"). Never decide to re-open transplant on your own judgment — it removes safety guardrails. Rooting it back (settling) is human-only via the UI. See umtri://rules/transplant.',
|
|
720
|
+
inputSchema: z.object({
|
|
721
|
+
slug: z.string().min(1).describe('Ground slug.'),
|
|
722
|
+
}),
|
|
723
|
+
},
|
|
724
|
+
async ({ slug }) => {
|
|
725
|
+
try {
|
|
726
|
+
const res = await api.post(`/api/projects/${encodeURIComponent(slug)}/root`, { rooted: false });
|
|
727
|
+
return jsonResult(res);
|
|
728
|
+
} catch (e) { return errorResult(e); }
|
|
729
|
+
},
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
server.registerTool(
|
|
733
|
+
'commit_plan',
|
|
734
|
+
{
|
|
735
|
+
title: 'Commit a realized plan node',
|
|
736
|
+
description: 'Promotes a plan node (metadata.plan=true) to the real tree by clearing the plan flag. VERIFICATION GATE: the node must already carry metadata.implements (the source path(s) you wrote) — without it the commit is rejected, because an uncommitted plan node with no implements is not considered realized. Call this only after you have actually written the code and recorded implements via update_node. See umtri://rules/plan.',
|
|
737
|
+
inputSchema: z.object({
|
|
738
|
+
slug: z.string().min(1).describe('Ground slug.'),
|
|
739
|
+
id: z.string().min(1).describe('Plan node id to commit.'),
|
|
740
|
+
}),
|
|
741
|
+
},
|
|
742
|
+
async ({ slug, id }) => {
|
|
743
|
+
try {
|
|
744
|
+
const res = await api.post(`/api/projects/${encodeURIComponent(slug)}/nodes/${encodeURIComponent(id)}/commit`, {});
|
|
745
|
+
return jsonResult(res);
|
|
746
|
+
} catch (e) { return errorResult(e); }
|
|
747
|
+
},
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
server.registerTool(
|
|
751
|
+
'record_commit',
|
|
752
|
+
{
|
|
753
|
+
title: 'Record a git commit onto the nodes it touched',
|
|
754
|
+
description: 'Configuration record (CI/CD). Given a commit SHA and the files it changed, finds nodes whose metadata.implements include any of those files and appends the commit (sha + timestamp + optional message) to their metadata.commits (deduped by sha). Returns the matched node ids. When one commit touches several nodes that are NOT yet connected, the response also carries coChangeCandidates[] ({a, aLabel, b, bLabel}) — nodes that change together are dependency candidates; review them and add a create_edge/create_api where a real relation exists (not auto-created; suppressed for large multi-node commits). Umtri does not run jobs or read git itself — a GitHub Action / CI step or an agent supplies the sha+files. See umtri://rules/plan.',
|
|
755
|
+
inputSchema: z.object({
|
|
756
|
+
slug: z.string().min(1).describe('Ground slug.'),
|
|
757
|
+
sha: z.string().min(1).describe('Commit SHA (short or full).'),
|
|
758
|
+
files: z.array(z.string()).min(1).describe('Repo-relative paths changed by the commit, matched against nodes\' metadata.implements.'),
|
|
759
|
+
message: z.string().optional().describe('Commit message (optional, stored with the record).'),
|
|
760
|
+
}),
|
|
761
|
+
},
|
|
762
|
+
async ({ slug, sha, files, message }) => {
|
|
763
|
+
try {
|
|
764
|
+
const res = await api.post(`/api/projects/${encodeURIComponent(slug)}/record-commit`, { sha, files, message });
|
|
765
|
+
return jsonResult(res);
|
|
766
|
+
} catch (e) { return errorResult(e); }
|
|
767
|
+
},
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
server.registerTool(
|
|
771
|
+
'create_edge',
|
|
772
|
+
{
|
|
773
|
+
title: 'Create an edge between two nodes',
|
|
774
|
+
description: 'Creates a directed edge (source → target) for a STRUCTURAL relation: type "dependency" (A is built on / needs B) or "data_flow" (data moves A → B outside a request). Use for module/library deps, a route depending on the data store, a job writing a table. For a runtime request/call/integration use create_api instead, not an edge. Reserve connections for relations a maintainer would trace — don\'t wire everything. type ∈ project.edge_types; same source and target is rejected. See umtri://rules/system-structure (Connections).',
|
|
775
|
+
inputSchema: z.object({
|
|
776
|
+
slug: z.string().min(1).describe('Ground slug.'),
|
|
777
|
+
source: z.string().min(1).describe('Source node id.'),
|
|
778
|
+
target: z.string().min(1).describe('Target node id.'),
|
|
779
|
+
type: z.string().optional().describe('Edge type. Backend defaults if omitted.'),
|
|
780
|
+
label: z.string().optional(),
|
|
781
|
+
metadata: z.record(z.any()).optional(),
|
|
782
|
+
}),
|
|
783
|
+
},
|
|
784
|
+
async ({ slug, source, target, type, label, metadata }) => {
|
|
785
|
+
try {
|
|
786
|
+
const { ok, rejectReason } = validateEdge({ sourceId: source, targetId: target });
|
|
787
|
+
if (!ok) throw new Error(`Rejected by protocol: ${rejectReason}`);
|
|
788
|
+
|
|
789
|
+
const body = { source, target };
|
|
790
|
+
if (type !== undefined) body.type = type;
|
|
791
|
+
if (label !== undefined) body.label = label;
|
|
792
|
+
if (metadata !== undefined) body.metadata = metadata;
|
|
793
|
+
|
|
794
|
+
return jsonResult(await api.post(`/api/projects/${encodeURIComponent(slug)}/edges`, body));
|
|
795
|
+
} catch (e) { return errorResult(e); }
|
|
796
|
+
},
|
|
797
|
+
);
|
|
798
|
+
|
|
799
|
+
server.registerTool(
|
|
800
|
+
'delete_edge',
|
|
801
|
+
{
|
|
802
|
+
title: 'Delete an edge',
|
|
803
|
+
description: 'Removes an edge from the ground. Use to retire dependencies/flows that no longer reflect reality.',
|
|
804
|
+
inputSchema: z.object({
|
|
805
|
+
slug: z.string().min(1).describe('Ground slug.'),
|
|
806
|
+
id: z.string().min(1).describe('Edge id.'),
|
|
807
|
+
}),
|
|
808
|
+
},
|
|
809
|
+
async ({ slug, id }) => {
|
|
810
|
+
try {
|
|
811
|
+
return jsonResult(await api.delete(`/api/projects/${encodeURIComponent(slug)}/edges/${encodeURIComponent(id)}`));
|
|
812
|
+
} catch (e) { return errorResult(e); }
|
|
813
|
+
},
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
server.registerTool(
|
|
817
|
+
'create_api',
|
|
818
|
+
{
|
|
819
|
+
title: 'Create an API flow between two nodes',
|
|
820
|
+
description: 'Records an API flow (start → end) for a RUNTIME request / call / data flow: a screen calling an endpoint, an endpoint hitting a table, an external integration (payment, SMS, webhook). Direction is caller → callee. APIs are first-class (apis table), not generic edges — for a build-time/structural reliance use create_edge (dependency) instead. Add the flows a maintainer would trace; don\'t wire everything. start/end must be existing node ids. See umtri://rules/system-structure (Connections).',
|
|
821
|
+
inputSchema: z.object({
|
|
822
|
+
slug: z.string().min(1).describe('Ground slug.'),
|
|
823
|
+
start: z.string().min(1).describe('Source node id.'),
|
|
824
|
+
end: z.string().min(1).describe('Target node id.'),
|
|
825
|
+
label: z.string().optional(),
|
|
826
|
+
description: z.string().optional(),
|
|
827
|
+
metadata: z.record(z.any()).optional(),
|
|
828
|
+
}),
|
|
829
|
+
},
|
|
830
|
+
async ({ slug, start, end, label, description, metadata }) => {
|
|
831
|
+
try {
|
|
832
|
+
const { ok, rejectReason } = validateEdge({ sourceId: start, targetId: end });
|
|
833
|
+
if (!ok) throw new Error(`Rejected by protocol: ${rejectReason}`);
|
|
834
|
+
|
|
835
|
+
const body = { start, end };
|
|
836
|
+
if (label !== undefined) body.label = label;
|
|
837
|
+
if (description !== undefined) body.description = description;
|
|
838
|
+
if (metadata !== undefined) body.metadata = metadata;
|
|
839
|
+
|
|
840
|
+
return jsonResult(await api.post(`/api/projects/${encodeURIComponent(slug)}/apis`, body));
|
|
841
|
+
} catch (e) { return errorResult(e); }
|
|
842
|
+
},
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
server.registerTool(
|
|
846
|
+
'update_api',
|
|
847
|
+
{
|
|
848
|
+
title: 'Update an API entry',
|
|
849
|
+
description: 'Partial update of an API. Patchable fields: label, description, metadata, start, end.',
|
|
850
|
+
inputSchema: z.object({
|
|
851
|
+
slug: z.string().min(1),
|
|
852
|
+
id: z.string().min(1).describe('API id.'),
|
|
853
|
+
patch: z.object({
|
|
854
|
+
label: z.string().optional(),
|
|
855
|
+
description: z.string().nullable().optional(),
|
|
856
|
+
start: z.string().optional(),
|
|
857
|
+
end: z.string().optional(),
|
|
858
|
+
metadata: z.record(z.any()).optional(),
|
|
859
|
+
}),
|
|
860
|
+
}),
|
|
861
|
+
},
|
|
862
|
+
async ({ slug, id, patch }) => {
|
|
863
|
+
try {
|
|
864
|
+
if (patch.start && patch.end && patch.start === patch.end) {
|
|
865
|
+
throw new Error('Rejected by protocol: API start and end cannot be the same node.');
|
|
866
|
+
}
|
|
867
|
+
return jsonResult(await api.patch(`/api/projects/${encodeURIComponent(slug)}/apis/${encodeURIComponent(id)}`, patch));
|
|
868
|
+
} catch (e) { return errorResult(e); }
|
|
869
|
+
},
|
|
870
|
+
);
|
|
871
|
+
|
|
872
|
+
server.registerTool(
|
|
873
|
+
'delete_api',
|
|
874
|
+
{
|
|
875
|
+
title: 'Soft-delete an API entry',
|
|
876
|
+
description: 'Sets removed_at on the API. History is preserved — the API can still be seen in past season views via the time slider.',
|
|
877
|
+
inputSchema: z.object({
|
|
878
|
+
slug: z.string().min(1),
|
|
879
|
+
id: z.string().min(1),
|
|
880
|
+
}),
|
|
881
|
+
},
|
|
882
|
+
async ({ slug, id }) => {
|
|
883
|
+
try {
|
|
884
|
+
return jsonResult(await api.delete(`/api/projects/${encodeURIComponent(slug)}/apis/${encodeURIComponent(id)}`));
|
|
885
|
+
} catch (e) { return errorResult(e); }
|
|
886
|
+
},
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
server.registerTool(
|
|
890
|
+
'update_bug',
|
|
891
|
+
{
|
|
892
|
+
title: 'Update a bug',
|
|
893
|
+
description: [
|
|
894
|
+
'Partial update of a bug. Most common use: status transition.',
|
|
895
|
+
'Follow the lifecycle one step at a time: open (wild) → in_progress (chasing) → resolved (catched).',
|
|
896
|
+
'Set status="in_progress" the moment you start the fix, not after it lands — it is the only marker that someone is already on this bug, so a parallel agent can see the work in flight instead of duplicating it. Then set "resolved" once it ships.',
|
|
897
|
+
'Skipping straight from open to resolved returns a warning (not a rejection) — acceptable when the fix was genuinely instant.',
|
|
898
|
+
'Other patchable fields: title, description, solution, score (0–8 change-risk), metadata.',
|
|
899
|
+
'When resolving, rewrite solution to what you actually applied — at report time it held the plan, and leaving a stale plan there is worse than leaving it empty.',
|
|
900
|
+
'To mark a bug as fixed, prefer status="resolved" over delete — that preserves the history of what eroded the tree.',
|
|
901
|
+
'When resolving, you may record the shipped release in metadata.resolvedVersion (e.g. "v2.3.1"); metadata is replaced wholesale, so include existing keys you want to keep.',
|
|
902
|
+
].join(' '),
|
|
903
|
+
inputSchema: z.object({
|
|
904
|
+
slug: z.string().min(1),
|
|
905
|
+
id: z.string().min(1).describe('Bug id.'),
|
|
906
|
+
patch: z.object({
|
|
907
|
+
status: z.enum(['open', 'in_progress', 'resolved', 'closed']).optional(),
|
|
908
|
+
score: z.number().int().min(0).max(8).optional().describe('Change risk 0–8 (8 = riskiest). See create_bug for the rubric.'),
|
|
909
|
+
title: z.string().min(1).max(200).optional(),
|
|
910
|
+
description: z.string().nullable().optional().describe('What is wrong.'),
|
|
911
|
+
solution: z.string().nullable().optional().describe('How it is being / was fixed. Rewrite this to the actually-applied fix when you set status="resolved" — a stale plan left behind is worse than an empty field. null clears it.'),
|
|
912
|
+
metadata: z.record(z.any()).optional(),
|
|
913
|
+
}),
|
|
914
|
+
}),
|
|
915
|
+
},
|
|
916
|
+
async ({ slug, id, patch }) => {
|
|
917
|
+
try {
|
|
918
|
+
// status를 바꿀 때만 현재 상태를 확인한다 — 라이프사이클 경고는 from을 알아야 낼 수 있다.
|
|
919
|
+
// 조회 실패는 무시하고 진행한다: 경고는 부가 정보이고, 여기서 막으면 정작 갱신이 죽는다.
|
|
920
|
+
let currentStatus = null;
|
|
921
|
+
if (patch && 'status' in patch) {
|
|
922
|
+
try {
|
|
923
|
+
const cur = await api.get(`/api/projects/${encodeURIComponent(slug)}/bugs/${encodeURIComponent(id)}`);
|
|
924
|
+
currentStatus = cur?.status ?? null;
|
|
925
|
+
} catch { /* 상태 확인 실패 → 경고 없이 진행 */ }
|
|
926
|
+
}
|
|
927
|
+
const updated = await api.patch(`/api/projects/${encodeURIComponent(slug)}/bugs/${encodeURIComponent(id)}`, patch);
|
|
928
|
+
const { warnings } = validateBugUpdate({ currentStatus, patch });
|
|
929
|
+
return jsonResult(warnings.length ? { ...updated, warnings } : updated);
|
|
930
|
+
} catch (e) { return errorResult(e); }
|
|
931
|
+
},
|
|
932
|
+
);
|
|
933
|
+
|
|
934
|
+
server.registerTool(
|
|
935
|
+
'delete_bug',
|
|
936
|
+
{
|
|
937
|
+
title: 'Delete a bug (hard delete)',
|
|
938
|
+
description: 'Permanently removes a bug record. Bugs do not have history — once deleted, the record is gone. For "I resolved this", use update_bug with status="resolved" instead, so the tree remembers what was eroded.',
|
|
939
|
+
inputSchema: z.object({
|
|
940
|
+
slug: z.string().min(1),
|
|
941
|
+
id: z.string().min(1),
|
|
942
|
+
}),
|
|
943
|
+
},
|
|
944
|
+
async ({ slug, id }) => {
|
|
945
|
+
try {
|
|
946
|
+
return jsonResult(await api.delete(`/api/projects/${encodeURIComponent(slug)}/bugs/${encodeURIComponent(id)}`));
|
|
947
|
+
} catch (e) { return errorResult(e); }
|
|
948
|
+
},
|
|
949
|
+
);
|
|
950
|
+
}
|