umtri-mcp 1.0.2 → 1.2.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/core/tools.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // - api: createApiClient(...) 결과 — get/post/put/patch/delete
6
6
 
7
7
  import { z } from 'zod';
8
- import { validateNode, validateEdge, validateUpdate, validateBugUpdate } from './policy.js';
8
+ import { validateNode, validateEdge, validateUpdate, validateBugUpdate, wikiLogShapeCheck, toolMarkupCheck } from './policy.js';
9
9
 
10
10
  // LLM이 소비하는 결과 — 들여쓰기는 순수 토큰 낭비라 minify. (큰 그래프에서 ~19% 절감)
11
11
  function jsonResult(data) {
@@ -64,12 +64,28 @@ function computeDormantSet(nodes) {
64
64
  return dormant;
65
65
  }
66
66
 
67
- // 버그 status 필터. 기본(active)은 open/in_progress만 — 해결된(resolved/closed) 침식은
68
- // 트리에서 노이즈. 'all'이면 전부, 특정 status면 그것만.
67
+ // 버그 필터 어휘 → DB status 집합. 도구 표면은 제품 어휘(wild/chasing/catched)를 쓴다 —
68
+ // UI·vocabulary.md가 쓰는 말과 같아야 사람이 본 화면과 도구 응답이 같은 것을 가리킨다.
69
+ // DB 상태값(open/in_progress/…)도 계속 받는다: 기존 호출자를 깨지 않기 위해서다.
70
+ //
71
+ // 'open'은 여기서 **wild와 다르다.** REST의 status=open은 store에서 open+in_progress로
72
+ // 번역되고(앱 대시보드의 Bugs Alive가 그 의미에 기대고 있다) 그래서 REST로는 순수 wild를
73
+ // 뽑을 수 없다. 그 갈래는 이 표에서만 갈린다.
74
+ const BUG_STATUS_SETS = {
75
+ active: ['open', 'in_progress'],
76
+ wild: ['open'],
77
+ chasing: ['in_progress'],
78
+ catched: ['resolved', 'closed'],
79
+ open: ['open', 'in_progress'], // REST status=open과 같은 의미 (하위호환)
80
+ in_progress: ['in_progress'],
81
+ resolved: ['resolved'],
82
+ closed: ['closed'],
83
+ };
84
+
69
85
  function filterBugsByStatus(bugs, bugStatus) {
70
86
  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');
87
+ const allowed = BUG_STATUS_SETS[bugStatus] || BUG_STATUS_SETS.active;
88
+ return bugs.filter((b) => allowed.includes(b.status));
73
89
  }
74
90
 
75
91
  // project.metadata에서 UI 전용 무거운 키(아이콘 base64 등)를 제거 — LLM 컨텍스트엔 무의미.
@@ -137,7 +153,7 @@ function compressGraph(graph, { descMode = 'excerpt' } = {}) {
137
153
  }
138
154
 
139
155
  // list_projects summary — 슬라이드 식별·탐색에 필요한 핵심만. icon base64(metadata)·
140
- // preNotes·seedMeta·typeCounts 등 무거운 필드는 생략. description은 descMode로 제어.
156
+ // seedMeta·typeCounts 등 무거운 필드는 생략. description은 descMode로 제어.
141
157
  function compressProjects(projects, { descMode = 'excerpt' } = {}) {
142
158
  if (!Array.isArray(projects)) return projects;
143
159
  return projects.map((p) => {
@@ -150,6 +166,8 @@ function compressProjects(projects, { descMode = 'excerpt' } = {}) {
150
166
  openBugs: p.openBugs,
151
167
  seasonCount: p.seasonCount,
152
168
  };
169
+ // 문서가 있다는 사실만 흘린다. 0이면 생략 — 대부분의 ground가 0이라 매번 실으면 잡음이다.
170
+ if (p.wikiCount) out.wikiCount = p.wikiCount;
153
171
  if (p.nowSeasonLabel) out.nowSeasonLabel = p.nowSeasonLabel;
154
172
  if (p.latestActivityAt) out.latestActivityAt = p.latestActivityAt;
155
173
  const d = pickDesc(p.description, descMode);
@@ -171,6 +189,8 @@ function compressBugs(bugs, { descMode = 'excerpt' } = {}) {
171
189
  status: b.status,
172
190
  };
173
191
  if (b.impactCount != null) out.impactCount = b.impactCount;
192
+ // 체크리스트 진행도 — "남은 일이 몇 개냐"에 본문을 열 필요가 없게. 항목이 없으면 생략.
193
+ if (b.solutionProgress) out.solutionProgress = `${b.solutionProgress.done}/${b.solutionProgress.total}`;
174
194
  if (b.createdAt) out.createdAt = b.createdAt;
175
195
  if (b.resolvedAt) out.resolvedAt = b.resolvedAt;
176
196
  const d = pickDesc(b.description, descMode);
@@ -298,7 +318,7 @@ export function registerTools(server, { api }) {
298
318
  'list_projects',
299
319
  {
300
320
  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).',
321
+ 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. A ground with wiki pages also carries wikiCount — prose the tree cannot hold (conventions, decisions, glossary); read it with list_wiki. Pass view="full" for all fields (seedMeta — the ground\'s human-written intent: goal, endDate, budget, audience — plus typeCounts, raw metadata, timestamps); the icon base64 in metadata is always stripped (UI-only).',
302
322
  inputSchema: z.object({
303
323
  view: z.enum(['summary', 'full']).optional().describe('summary (default) for a compact list; full for every field.'),
304
324
  }),
@@ -318,17 +338,17 @@ export function registerTools(server, { api }) {
318
338
  'get_graph',
319
339
  {
320
340
  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.',
341
+ 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. project.seedMeta is the ground\'s intent when the human filled it in — goal, endDate, budget, audience. No tool writes it, so it is the human\'s own brief for what this tree is for: read it before you propose or reshape structure, and let it settle what belongs in the tree and what does not.',
322
342
  inputSchema: z.object({
323
343
  slug: z.string().min(1).describe('Ground slug (from list_projects).'),
324
344
  view: z.enum(['summary', 'full']).optional().describe('summary (default) for compact nodes; full for raw shape with all fields.'),
325
345
  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.'),
346
+ depth: z.coerce.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
347
  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
348
  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
349
  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
350
  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.'),
351
+ bugStatus: z.enum(['active', 'wild', 'chasing', 'catched', 'all', 'open', 'in_progress', 'resolved', 'closed']).optional().describe('Which bugs to include. Default "active" = wild + chasing (open + in_progress) — catched ones are healed erosion, noise on the tree. "wild" for the untouched only, "catched" for healed, "all" for every bug. Raw DB statuses still work. Use list_bugs for richer bug queries (limit/order), get_bug for one you can name.'),
332
352
  }),
333
353
  },
334
354
  async ({ slug, view, rootId, depth, maxType, role, season, descriptions, bugStatus }) => {
@@ -367,7 +387,7 @@ export function registerTools(server, { api }) {
367
387
  node: z.string().optional().describe('Start node id. Provide this OR bug.'),
368
388
  bug: z.string().optional().describe('Start from this bug\'s target. Provide this OR node.'),
369
389
  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.'),
390
+ maxDepth: z.coerce.number().int().min(0).optional().describe('Max hops to traverse. Omit for unbounded.'),
371
391
  }),
372
392
  },
373
393
  async ({ slug, node, bug, direction, maxDepth }) => {
@@ -388,19 +408,37 @@ export function registerTools(server, { api }) {
388
408
  'list_bugs',
389
409
  {
390
410
  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.',
411
+ description: 'Scans the bugs (issues eroding the ground) of one ground. Looking at ONE bug you already know the number/id of? Call get_bug instead — no need to list. Bugs default to status="active" (wild + chasing): a healed bug is history, and history is not what a scan is for. Ask for "catched" or "all" when you actually want it. Status speaks the product\'s own words — wild (found, nobody on it) → chasing (someone is fixing it) → catched (it landed) — the same words the UI shows. Each bug has a score (0–8 change-risk; 8 = riskiest), and any bug whose solution carries a checklist also carries solutionProgress ("2/5" — ticked/total), so you can see what is left without opening a single body. Targets: a node, an api, or the ground itself. By default returns a summary view — each bug carries id, seq (the number a human says, "#14"), 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. Use limit + order to bound a scan (e.g. the 5 newest) instead of pulling every bug.',
392
412
  inputSchema: z.object({
393
413
  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.'),
414
+ status: z.enum(['active', 'wild', 'chasing', 'catched', 'all', 'open', 'in_progress', 'resolved', 'closed']).optional()
415
+ .describe('Which bugs to include. Default "active" = wild + chasing (open + in_progress). "wild" = found but untouched, "chasing" = someone is on it, "catched" = healed (resolved + closed), "all" = every bug. The raw DB statuses are accepted too; note "open" means wild + chasing there (REST semantics), so pass "wild" when you want only the untouched ones.'),
416
+ limit: z.coerce.number().int().positive().optional().describe('Return at most this many bugs (applied after the status filter). Omit for all matching.'),
417
+ order: z.enum(['asc', 'desc']).optional().describe('By creation time: "asc" oldest first (default), "desc" newest first. Pair with limit for "the N newest".'),
396
418
  view: z.enum(['summary', 'full']).optional().describe('summary (default) for a compact list; full for verbatim descriptions + metadata.'),
397
419
  }),
398
420
  },
399
- async ({ slug, status, view }) => {
421
+ async ({ slug, status, limit, order, view }) => {
400
422
  try {
423
+ const want = status || 'active';
401
424
  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('&')}`);
425
+ if (order) qs.push(`order=${encodeURIComponent(order)}`);
426
+ // status·limit을 서버로 넘기는 건 REST 필터가 원하는 집합과 **정확히** 같을 때만이다.
427
+ // wild(순수 open)·catched(resolved+closed)는 REST에 대응 값이 없어 여기서 거른다 —
428
+ // 그때 limit까지 넘기면 걸러지기 전 개수로 잘려 결과가 모자란다.
429
+ const REST_EXACT = {
430
+ active: 'open', open: 'open', // REST의 open = open + in_progress
431
+ chasing: 'in_progress', in_progress: 'in_progress',
432
+ resolved: 'resolved', closed: 'closed',
433
+ };
434
+ const exact = REST_EXACT[want] || null; // wild·catched는 REST에 대응 값이 없다
435
+ if (exact) qs.push(`status=${exact}`);
436
+ if (exact || want === 'all') { if (limit) qs.push(`limit=${limit}`); }
437
+ let bugs = await api.get(`/api/projects/${encodeURIComponent(slug)}/bugs?${qs.join('&')}`);
438
+ if (!exact && want !== 'all') {
439
+ bugs = filterBugsByStatus(bugs, want);
440
+ if (limit) bugs = bugs.slice(0, limit);
441
+ }
404
442
  return jsonResult(view === 'full' ? bugs : compressBugs(bugs));
405
443
  } catch (e) { return errorResult(e); }
406
444
  },
@@ -410,17 +448,24 @@ export function registerTools(server, { api }) {
410
448
  'get_bug',
411
449
  {
412
450
  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.',
451
+ description: 'Returns a single bug by reference — the cheap path when you already know which bug you mean. `ref` accepts either the human-facing number shown in the UI (seq — "14" or #14) or the internal id (bug-<uuid>), so a human saying "#8" needs no listing at all. Unlike list_bugs this ignores status: a catched bug is still readable by number, which is how you review what a past fix actually did. Use list_bugs only to scan for bugs you cannot yet name. 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
452
  inputSchema: z.object({
415
453
  slug: z.string().min(1).describe('Ground slug.'),
416
- ref: z.union([z.string().min(1), z.number().int().positive()])
454
+ ref: z.union([z.string().min(1), z.coerce.number().int().positive()])
417
455
  .describe('Bug number (seq, e.g. 14) or internal id (bug-<uuid>).'),
456
+ descriptions: z.enum(['none', 'excerpt', 'full']).optional()
457
+ .describe('How much of description to return. Default "full". Use "none"/"excerpt" when you only came for the solution checklist.'),
458
+ solution: z.enum(['none', 'open', 'full']).optional()
459
+ .describe('How much of solution to return. Default "full". "open" returns only the unchecked items and drops the prose — the cheap answer to "what is left?".'),
418
460
  }),
419
461
  },
420
- async ({ slug, ref }) => {
462
+ async ({ slug, ref, descriptions, solution }) => {
421
463
  try {
464
+ const qs = ['impact=true'];
465
+ if (descriptions) qs.push(`descriptions=${descriptions}`);
466
+ if (solution) qs.push(`solution=${solution}`);
422
467
  const bug = await api.get(
423
- `/api/projects/${encodeURIComponent(slug)}/bugs/${encodeURIComponent(String(ref))}?impact=true`,
468
+ `/api/projects/${encodeURIComponent(slug)}/bugs/${encodeURIComponent(String(ref))}?${qs.join('&')}`,
424
469
  );
425
470
  return jsonResult(bug);
426
471
  } catch (e) { return errorResult(e); }
@@ -431,7 +476,7 @@ export function registerTools(server, { api }) {
431
476
  'list_seasons',
432
477
  {
433
478
  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.',
479
+ description: 'Returns the seasons (time epochs) of a ground in chronological order. A season is the human\'s declaration that a chapter closed. You may create one with create_season, but only after the user confirms it — see umtri://rules/seasons. By default returns a summary view (id, label, state, startedAt, grownAt). Pass view="full" to also include metadata.',
435
480
  inputSchema: z.object({
436
481
  slug: z.string().min(1).describe('Ground slug.'),
437
482
  view: z.enum(['summary', 'full']).optional().describe('summary (default) drops UI-only metadata; full includes it.'),
@@ -452,7 +497,7 @@ export function registerTools(server, { api }) {
452
497
  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
498
  inputSchema: z.object({
454
499
  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).'),
500
+ limit: z.coerce.number().int().positive().max(200).optional().describe('Max events to return (default 50, cap 200).'),
456
501
  before: z.string().optional().describe('ISO timestamp — return events strictly older than this (keyset pagination).'),
457
502
  entityType: z.enum(['node', 'edge', 'api', 'bug']).optional().describe('Filter to one entity type.'),
458
503
  entityId: z.string().optional().describe('Filter to one entity\'s timeline (usually paired with entityType).'),
@@ -484,7 +529,7 @@ export function registerTools(server, { api }) {
484
529
  'target.kind = "ground" attaches the bug to the project itself (no id).',
485
530
  RISK_RUBRIC,
486
531
  '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.',
532
+ 'solution is the fix, and it belongs to agents — the app gives people no way to edit it, so a human\'s own proposed fix arrives in description instead. At report time solution holds the plan, 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. Write what remains as "- [ ] item" lines so the app can render them as cards and later moves cost one small op (update_bug patch:{solutionItem}). Leave it empty rather than guessing.',
488
533
  '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
534
  'Requires a write-scope token. See umtri://rules/vocabulary for bug semantics.',
490
535
  ].join(' '),
@@ -497,13 +542,15 @@ export function registerTools(server, { api }) {
497
542
  }).describe('Bug target. Use {kind:"ground"} when the bug is about the project as a whole.'),
498
543
  description: z.string().optional().describe('Longer details on what is wrong (markdown allowed).'),
499
544
  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.'),
545
+ score: z.coerce.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
546
  status: z.enum(['open', 'in_progress', 'resolved', 'closed']).optional()
502
547
  .describe('Defaults to "open".'),
503
548
  }),
504
549
  },
505
550
  async ({ slug, title, target, description, solution, score, status }) => {
506
551
  try {
552
+ // 인자가 필드로 쪼개지지 못한 흔적 — 저장은 되지만 다음 필드가 비어 있다.
553
+ const markupWarnings = toolMarkupCheck({ description, solution });
507
554
  const body = { title, target };
508
555
  if (description !== undefined) body.description = description;
509
556
  if (solution !== undefined) body.solution = solution;
@@ -523,7 +570,7 @@ export function registerTools(server, { api }) {
523
570
  };
524
571
  } catch { /* 영향권 부가 정보 실패는 무시 */ }
525
572
  }
526
- return jsonResult(created);
573
+ return jsonResult(markupWarnings.length ? { ...created, warnings: markupWarnings } : created);
527
574
  } catch (e) { return errorResult(e); }
528
575
  },
529
576
  );
@@ -542,10 +589,48 @@ export function registerTools(server, { api }) {
542
589
  },
543
590
  async ({ slug, name, description, visibility }) => {
544
591
  try {
592
+ const markupWarnings = toolMarkupCheck({ description });
545
593
  const body = { slug, name };
546
594
  if (description !== undefined) body.description = description;
547
595
  if (visibility !== undefined) body.visibility = visibility;
548
- return jsonResult(await api.post('/api/projects', body));
596
+ const created = await api.post('/api/projects', body);
597
+ return jsonResult(markupWarnings.length ? { ...created, warnings: markupWarnings } : created);
598
+ } catch (e) { return errorResult(e); }
599
+ },
600
+ );
601
+
602
+ // 시즌은 사람의 선언이지만, 옮겨심기처럼 사람이 에이전트에게 맡기고 싶은 자리도 있다.
603
+ // 그래서 도구는 열되 확인 게이트를 둔다 — 첫 호출은 만들지 않고 "무엇이 봉인되는지"를 돌려주고,
604
+ // 사람이 답한 뒤 confirm:true로 다시 부를 때만 만든다. 설명만으로는 강제되지 않아 입력으로 받는다.
605
+ server.registerTool(
606
+ 'create_season',
607
+ {
608
+ title: 'Create a new season',
609
+ description: 'Creates a new season and makes it the "now" season. This is not an additive act: the ground\'s current now-season is sealed as past and every node in it is marked grown, which locks that node\'s parent/season/type/sproutedAt. CONFIRMATION GATE: call once WITHOUT confirm to get back what would be sealed, put that to the user in your own words, and call again with confirm:true only after they say yes. Never pass confirm:true on your own judgment — a season is the human\'s declaration that one chapter closed and the next began, and inventing one makes the ground\'s timeline meaningless. Unsure which season a node belongs to? Attach it to the existing now-season instead of opening a new one. See umtri://rules/seasons.',
610
+ inputSchema: z.object({
611
+ slug: z.string().min(1).describe('Ground slug.'),
612
+ label: z.string().min(1).describe('Season label the human chose — a version, phase, or period (e.g. "1.0.0", "PoC", "Q3 2026").'),
613
+ confirm: z.boolean().optional().describe('Pass true ONLY after the user has agreed to this exact season. Omit it on the first call to receive the confirmation payload instead.'),
614
+ }),
615
+ },
616
+ async ({ slug, label, confirm }) => {
617
+ try {
618
+ if (confirm !== true) {
619
+ const seasons = await api.get(`/api/projects/${encodeURIComponent(slug)}/seasons`);
620
+ const now = Array.isArray(seasons) ? seasons.find((x) => x.state === 'now') : null;
621
+ return jsonResult({
622
+ status: 'confirmation_required',
623
+ created: false,
624
+ wouldCreate: label,
625
+ wouldSeal: now ? { id: now.id, label: now.label } : null,
626
+ effect: now
627
+ ? `Season "${now.label}" would be sealed as past and its nodes locked (parent/season/type/sproutedAt).`
628
+ : 'This ground has no season yet, so nothing would be sealed.',
629
+ next: 'Ask the user to confirm this season, then call create_season again with confirm: true.',
630
+ });
631
+ }
632
+ const res = await api.post(`/api/projects/${encodeURIComponent(slug)}/seasons`, { label });
633
+ return jsonResult(res);
549
634
  } catch (e) { return errorResult(e); }
550
635
  },
551
636
  );
@@ -563,7 +648,7 @@ export function registerTools(server, { api }) {
563
648
  '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
649
  '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
650
  '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.',
651
+ 'Do not open a new season just because none fits — attach to the now-season, or ask the user. create_season needs their confirmation (umtri://rules/seasons).',
567
652
  '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
653
  ].join(' '),
569
654
  inputSchema: z.object({
@@ -606,7 +691,8 @@ export function registerTools(server, { api }) {
606
691
  if (sproutedAt !== undefined) body.sproutedAt = sproutedAt;
607
692
 
608
693
  const created = await api.post(`/api/projects/${encodeURIComponent(slug)}/nodes`, body);
609
- const out = warnings.length ? { ...created, warnings } : { ...created };
694
+ const allWarnings = warnings.concat(toolMarkupCheck({ description }));
695
+ const out = allWarnings.length ? { ...created, warnings: allWarnings } : { ...created };
610
696
  // 연결 고민 넛지 — leaf/vein은 보통 다른 노드를 호출/의존하므로 매번 확인 유도.
611
697
  if (type === 'leaf' || type === 'vein') {
612
698
  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).';
@@ -642,6 +728,7 @@ export function registerTools(server, { api }) {
642
728
 
643
729
  const patchResult = validateUpdate({ patch });
644
730
  warnings = warnings.concat(patchResult.warnings);
731
+ warnings = warnings.concat(toolMarkupCheck({ description: patch.description }));
645
732
 
646
733
  if (patch.type !== undefined || patch.parent !== undefined || patch.label !== undefined) {
647
734
  const graph = await api.get(`/api/projects/${encodeURIComponent(slug)}/graph`);
@@ -712,23 +799,6 @@ export function registerTools(server, { api }) {
712
799
  },
713
800
  );
714
801
 
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
802
  server.registerTool(
733
803
  'commit_plan',
734
804
  {
@@ -751,7 +821,7 @@ export function registerTools(server, { api }) {
751
821
  'record_commit',
752
822
  {
753
823
  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.',
824
+ 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. Recording a commit is the LAST step: first make sure the change is in the tree (new unit → create_node with metadata.implements; moved file → update_node), otherwise the sha lands on the nodes that happen to exist and the new ones stay invisible. A repo that keeps forgetting should write the habit down — see umtri://rules/commit-sync. See also umtri://rules/plan.',
755
825
  inputSchema: z.object({
756
826
  slug: z.string().min(1).describe('Ground slug.'),
757
827
  sha: z.string().min(1).describe('Commit SHA (short or full).'),
@@ -832,12 +902,14 @@ export function registerTools(server, { api }) {
832
902
  const { ok, rejectReason } = validateEdge({ sourceId: start, targetId: end });
833
903
  if (!ok) throw new Error(`Rejected by protocol: ${rejectReason}`);
834
904
 
905
+ const markupWarnings = toolMarkupCheck({ label, description });
835
906
  const body = { start, end };
836
907
  if (label !== undefined) body.label = label;
837
908
  if (description !== undefined) body.description = description;
838
909
  if (metadata !== undefined) body.metadata = metadata;
839
910
 
840
- return jsonResult(await api.post(`/api/projects/${encodeURIComponent(slug)}/apis`, body));
911
+ const created = await api.post(`/api/projects/${encodeURIComponent(slug)}/apis`, body);
912
+ return jsonResult(markupWarnings.length ? { ...created, warnings: markupWarnings } : created);
841
913
  } catch (e) { return errorResult(e); }
842
914
  },
843
915
  );
@@ -864,7 +936,9 @@ export function registerTools(server, { api }) {
864
936
  if (patch.start && patch.end && patch.start === patch.end) {
865
937
  throw new Error('Rejected by protocol: API start and end cannot be the same node.');
866
938
  }
867
- return jsonResult(await api.patch(`/api/projects/${encodeURIComponent(slug)}/apis/${encodeURIComponent(id)}`, patch));
939
+ const markupWarnings = toolMarkupCheck({ label: patch.label, description: patch.description });
940
+ const updated = await api.patch(`/api/projects/${encodeURIComponent(slug)}/apis/${encodeURIComponent(id)}`, patch);
941
+ return jsonResult(markupWarnings.length ? { ...updated, warnings: markupWarnings } : updated);
868
942
  } catch (e) { return errorResult(e); }
869
943
  },
870
944
  );
@@ -892,10 +966,13 @@ export function registerTools(server, { api }) {
892
966
  title: 'Update a bug',
893
967
  description: [
894
968
  'Partial update of a bug. Most common use: status transition.',
969
+ 'Send only the fields that actually change. A status transition is patch:{status} alone — do not re-send an unchanged solution, description, or metadata. Those fields carry prose, and re-emitting them costs far more time than the update itself and risks a malformed-argument retry.',
895
970
  'Follow the lifecycle one step at a time: open (wild) → in_progress (chasing) → resolved (catched).',
896
971
  '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
972
  'Skipping straight from open to resolved returns a warning (not a rejection) — acceptable when the fix was genuinely instant.',
898
973
  'Other patchable fields: title, description, solution, score (0–8 change-risk), metadata.',
974
+ 'solution is yours to maintain — the app does not let people edit it, so what you write is what the human reads. Their own proposed fix arrives in description instead.',
975
+ 'Keep solution as a checklist: prose for what you learned, "- [ ] item" lines for what remains. To move one item use patch:{solutionItem:{…}} instead of re-sending the whole field — add: "text", or check/uncheck/remove: <1-based item number>. Pass expect:"<start of that item text>" alongside a number so a shifted list fails loudly instead of ticking the wrong line. This is the cheap path: one short op, not a re-emitted body.',
899
976
  '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
977
  'To mark a bug as fixed, prefer status="resolved" over delete — that preserves the history of what eroded the tree.',
901
978
  '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.',
@@ -905,10 +982,17 @@ export function registerTools(server, { api }) {
905
982
  id: z.string().min(1).describe('Bug id.'),
906
983
  patch: z.object({
907
984
  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.'),
985
+ score: z.coerce.number().int().min(0).max(8).optional().describe('Change risk 0–8 (8 = riskiest). See create_bug for the rubric.'),
909
986
  title: z.string().min(1).max(200).optional(),
910
987
  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.'),
988
+ solution: z.string().nullable().optional().describe('How it is being / was fixed — prose plus "- [ ] item" lines. Rewrite it to the actually-applied fix when you set status="resolved"; a stale plan left behind is worse than an empty field. null clears it. Replaces the whole field — to move a single item use solutionItem.'),
989
+ solutionItem: z.object({
990
+ add: z.string().min(1).optional().describe('Append a new unchecked item.'),
991
+ check: z.coerce.number().int().positive().optional().describe('Tick item #N (1-based, counting items only).'),
992
+ uncheck: z.coerce.number().int().positive().optional().describe('Untick item #N.'),
993
+ remove: z.coerce.number().int().positive().optional().describe('Delete item #N.'),
994
+ expect: z.string().optional().describe('Guard for check/uncheck/remove: the start of that item\'s text. Mismatch is rejected, so a reordered list cannot silently tick the wrong line.'),
995
+ }).optional().describe('Move ONE checklist item without re-sending solution. Cannot be combined with solution.'),
912
996
  metadata: z.record(z.any()).optional(),
913
997
  }),
914
998
  }),
@@ -926,7 +1010,12 @@ export function registerTools(server, { api }) {
926
1010
  }
927
1011
  const updated = await api.patch(`/api/projects/${encodeURIComponent(slug)}/bugs/${encodeURIComponent(id)}`, patch);
928
1012
  const { warnings } = validateBugUpdate({ currentStatus, patch });
929
- return jsonResult(warnings.length ? { ...updated, warnings } : updated);
1013
+ const allWarnings = warnings.concat(toolMarkupCheck({
1014
+ description: patch?.description,
1015
+ solution: patch?.solution,
1016
+ solutionItem: patch?.solutionItem?.add,
1017
+ }));
1018
+ return jsonResult(allWarnings.length ? { ...updated, warnings: allWarnings } : updated);
930
1019
  } catch (e) { return errorResult(e); }
931
1020
  },
932
1021
  );
@@ -947,4 +1036,253 @@ export function registerTools(server, { api }) {
947
1036
  } catch (e) { return errorResult(e); }
948
1037
  },
949
1038
  );
1039
+
1040
+ // ── Feedback — Umtri 제품 자체에 대한 건의 ────────────────────────────
1041
+ // create_bug과 헷갈리면 남의 프로젝트 기록이 오염된다. 그래서 세 도구 설명 모두
1042
+ // 첫 문장에서 "about Umtri itself, not about your project"를 못박는다.
1043
+ // 사람이 시켰을 때만 부르라는 조건도 여기 둔다 — 에이전트가 자율 발송하면
1044
+ // 자기가 잘 못 쓴 도구를 전부 개선요구로 쏟아내 접수함이 먼저 죽는다.
1045
+ //
1046
+ // 단방향 채널이다: send_feedback만 누구나 쓰고, list/update는 Umtri 운영 계정
1047
+ // 전용(서버가 403). 도구 설명에 그걸 적어 두는 게 비용이 싸다 — 안 적으면
1048
+ // 일반 사용자의 에이전트가 403을 받고 재시도하며 헤맨다.
1049
+
1050
+ server.registerTool(
1051
+ 'send_feedback',
1052
+ {
1053
+ title: 'Send feedback about Umtri itself',
1054
+ description: [
1055
+ 'Files a suggestion or a defect report about **Umtri the product** — this MCP server, its tools, the app, the docs.',
1056
+ 'This is NOT for issues in the user\'s own project: those are bugs on a ground (create_bug).',
1057
+ 'Call this ONLY when the human explicitly asks to leave feedback ("건의해줘", "report this to Umtri"). Do not file feedback on your own initiative — an inbox filled by agents stops being read.',
1058
+ 'kind: "bug" = something in Umtri is broken or wrong; "improvement" = a suggestion / wish (default).',
1059
+ 'source: where it surfaced — the ground slug you were working in, the tool name that felt wrong, the screen. It is free text and is not validated, but it is what makes a report actionable later.',
1060
+ 'One-way: this files the report, and there is no reading it back — do not promise the human a status thread. Requires a write-scope token.',
1061
+ ].join(' '),
1062
+ inputSchema: z.object({
1063
+ kind: z.enum(['bug', 'improvement']).optional()
1064
+ .describe('"bug" for something broken in Umtri, "improvement" for a suggestion. Defaults to "improvement".'),
1065
+ title: z.string().min(1).max(200).describe('One-line summary. Required.'),
1066
+ body: z.string().optional().describe('Details — what you expected, what happened, why it matters (markdown allowed).'),
1067
+ source: z.string().max(200).optional().describe('Where it came up: ground slug, tool name, or screen.'),
1068
+ }),
1069
+ },
1070
+ async ({ kind, title, body, source }) => {
1071
+ try {
1072
+ const markupWarnings = toolMarkupCheck({ title, body });
1073
+ const payload = { title };
1074
+ if (kind !== undefined) payload.kind = kind;
1075
+ if (body !== undefined) payload.body = body;
1076
+ if (source !== undefined) payload.source = source;
1077
+ const filed = await api.post('/api/feedback', payload);
1078
+ return jsonResult(markupWarnings.length ? { ...filed, warnings: markupWarnings } : filed);
1079
+ } catch (e) { return errorResult(e); }
1080
+ },
1081
+ );
1082
+
1083
+ server.registerTool(
1084
+ 'list_feedback',
1085
+ {
1086
+ title: 'List feedback filed about Umtri',
1087
+ description: 'Lists feedback filed about **Umtri the product** (see send_feedback) — not the bugs of any ground. Umtri operators only: every other account gets 403, so do not call this to check on feedback you filed. Newest first. Status: open (untouched) → done (handled) / wontfix (declined, with the reason in note). Defaults to every status; pass status to narrow. Use limit to bound a scan.',
1088
+ inputSchema: z.object({
1089
+ status: z.enum(['open', 'done', 'wontfix']).optional().describe('Filter by status. Omit for all.'),
1090
+ kind: z.enum(['bug', 'improvement']).optional().describe('Filter by kind. Omit for both.'),
1091
+ limit: z.coerce.number().int().positive().optional().describe('Return at most this many.'),
1092
+ order: z.enum(['asc', 'desc']).optional().describe('By creation time. Default "desc" (newest first).'),
1093
+ }),
1094
+ },
1095
+ async ({ status, kind, limit, order }) => {
1096
+ try {
1097
+ const qs = [];
1098
+ if (status) qs.push(`status=${encodeURIComponent(status)}`);
1099
+ if (kind) qs.push(`kind=${encodeURIComponent(kind)}`);
1100
+ if (limit) qs.push(`limit=${limit}`);
1101
+ if (order) qs.push(`order=${encodeURIComponent(order)}`);
1102
+ return jsonResult(await api.get(`/api/feedback${qs.length ? `?${qs.join('&')}` : ''}`));
1103
+ } catch (e) { return errorResult(e); }
1104
+ },
1105
+ );
1106
+
1107
+ server.registerTool(
1108
+ 'update_feedback',
1109
+ {
1110
+ title: 'Update filed feedback',
1111
+ description: 'Updates one feedback record about **Umtri the product** — typically closing it: status="done" once it shipped, or "wontfix" when it was declined. Put what was actually done (or why not) in note; that is the part worth reading a year later. Umtri operators only: every other account gets 403. Send only the fields that change. Requires a write-scope token.',
1112
+ inputSchema: z.object({
1113
+ id: z.coerce.number().int().positive().describe('Feedback number, as returned by send_feedback / list_feedback.'),
1114
+ status: z.enum(['open', 'done', 'wontfix']).optional().describe('open → done (handled) / wontfix (declined).'),
1115
+ note: z.string().optional().describe('What was done about it, or why it was declined.'),
1116
+ kind: z.enum(['bug', 'improvement']).optional(),
1117
+ title: z.string().min(1).max(200).optional(),
1118
+ body: z.string().optional(),
1119
+ source: z.string().max(200).optional(),
1120
+ }),
1121
+ },
1122
+ async ({ id, ...fields }) => {
1123
+ try {
1124
+ const patch = {};
1125
+ for (const [k, v] of Object.entries(fields)) if (v !== undefined) patch[k] = v;
1126
+ if (Object.keys(patch).length === 0) {
1127
+ return errorResult(new Error('Nothing to update — pass at least one field.'));
1128
+ }
1129
+ const markupWarnings = toolMarkupCheck({ title: patch.title, body: patch.body, note: patch.note });
1130
+ const updated = await api.patch(`/api/feedback/${id}`, patch);
1131
+ return jsonResult(markupWarnings.length ? { ...updated, warnings: markupWarnings } : updated);
1132
+ } catch (e) { return errorResult(e); }
1133
+ },
1134
+ );
1135
+
1136
+ // ── Wiki — ground별 지식 저장소 ───────────────────────────────────────
1137
+ // 트리는 구조를 담지 "합의·용어·결정·조사 결과"를 담지 않는다. 그게 갈 곳이 없어
1138
+ // 지금까지 ground description과 대상 없는 버그로 샜다. 문서는 노드에 붙을 수 있되
1139
+ // 독립적으로도 산다 — 붙일 노드가 없다고 못 적게 하면 정작 필요한 초기에 못 쓴다.
1140
+ // 아직 사람용 편집 화면이 없다: 지금은 이 도구들이 유일한 쓰기 경로다.
1141
+
1142
+ server.registerTool(
1143
+ 'list_wiki',
1144
+ {
1145
+ title: 'List wiki pages of a ground',
1146
+ description: 'Lists the ground\'s wiki — the prose the tree cannot hold: conventions, decisions and why they were made, glossary, onboarding. Reference entries describing how things work, not a log of what happened: the change record is list_events, the page\'s own past is in its revisions. Not the structure itself (get_graph) and not defects (list_bugs). Bodies come back as 200-char excerpts by default so a scan stays cheap; pass body="full" only when you mean to read them, or body="none" for a pure index. A page may hang off a node (nodeId) or float free — pass node=<id> for one node\'s pages, or node="none" for the pages not yet attached to anything, which is the list worth reviewing when the tree has grown.',
1147
+ inputSchema: z.object({
1148
+ slug: z.string().min(1).describe('Ground slug.'),
1149
+ node: z.string().optional().describe('Node id to filter by, or "none" for pages attached to nothing yet. Omit for all.'),
1150
+ body: z.enum(['none', 'excerpt', 'full']).optional().describe('How much body text per page. Default "excerpt" (200 chars).'),
1151
+ limit: z.coerce.number().int().positive().optional(),
1152
+ order: z.enum(['asc', 'desc']).optional().describe('By last update. Default "desc" (most recently touched first).'),
1153
+ }),
1154
+ },
1155
+ async ({ slug, node, body, limit, order }) => {
1156
+ try {
1157
+ const qs = [];
1158
+ if (node) qs.push(`node=${encodeURIComponent(node)}`);
1159
+ if (body) qs.push(`body=${body}`);
1160
+ if (limit) qs.push(`limit=${limit}`);
1161
+ if (order) qs.push(`order=${order}`);
1162
+ return jsonResult(await api.get(`/api/projects/${encodeURIComponent(slug)}/wiki${qs.length ? `?${qs.join('&')}` : ''}`));
1163
+ } catch (e) { return errorResult(e); }
1164
+ },
1165
+ );
1166
+
1167
+ server.registerTool(
1168
+ 'get_wiki',
1169
+ {
1170
+ title: 'Read one wiki page',
1171
+ description: 'Returns one wiki page in full. `ref` takes either the page slug (what a human says) or the internal id (wiki-<uuid>) — renaming a page changes its slug but never its id, so ids stay safe to store. Read the page before rewriting it: write_wiki with mode="replace" overwrites the body wholesale. Pass rev=<n> to read an older revision of the page instead of the current one (list them with list_wiki_revisions); to put an old revision back, do not paste its body into a write — use write_wiki mode="restore", which cannot mangle the prose in transit.',
1172
+ inputSchema: z.object({
1173
+ slug: z.string().min(1).describe('Ground slug.'),
1174
+ ref: z.string().min(1).describe('Page slug (e.g. "deploy-notes") or id (wiki-<uuid>).'),
1175
+ rev: z.coerce.number().int().positive().optional().describe('Revision number to read. Omit for the current page.'),
1176
+ }),
1177
+ },
1178
+ async ({ slug, ref, rev }) => {
1179
+ try {
1180
+ const base = `/api/projects/${encodeURIComponent(slug)}/wiki/${encodeURIComponent(ref)}`;
1181
+ return jsonResult(await api.get(rev ? `${base}/revisions/${rev}` : base));
1182
+ } catch (e) { return errorResult(e); }
1183
+ },
1184
+ );
1185
+
1186
+ server.registerTool(
1187
+ 'list_wiki_revisions',
1188
+ {
1189
+ title: 'List the revision history of a wiki page',
1190
+ description: [
1191
+ 'Returns what this page said before, newest first — rev number, who wrote it, when, and the body length. Bodies are not included (a history of twenty full documents is expensive to skim); read one with get_wiki rev=<n>.',
1192
+ 'Use it to answer "what did this say last week", to see whether a write of yours dropped something, and to find the rev to hand back to write_wiki mode="restore".',
1193
+ 'Consecutive writes by the same actor within 30 minutes fold into one revision rather than piling up, so the list stays readable — a working session is one entry, not eight. The practical guarantee: whatever an agent does to a page in one session, the body from before that session is still a separate revision.',
1194
+ 'Revisions start at the moment the ground gained this feature; edits older than that were never recorded and cannot be recovered.',
1195
+ ].join(' '),
1196
+ inputSchema: z.object({
1197
+ slug: z.string().min(1).describe('Ground slug.'),
1198
+ ref: z.string().min(1).describe('Page slug or id.'),
1199
+ limit: z.coerce.number().int().positive().optional(),
1200
+ order: z.enum(['asc', 'desc']).optional().describe('By revision number. Default "desc" (newest first).'),
1201
+ }),
1202
+ },
1203
+ async ({ slug, ref, limit, order }) => {
1204
+ try {
1205
+ const qs = [];
1206
+ if (limit) qs.push(`limit=${limit}`);
1207
+ if (order) qs.push(`order=${order}`);
1208
+ const path = `/api/projects/${encodeURIComponent(slug)}/wiki/${encodeURIComponent(ref)}/revisions`;
1209
+ return jsonResult(await api.get(`${path}${qs.length ? `?${qs.join('&')}` : ''}`));
1210
+ } catch (e) { return errorResult(e); }
1211
+ },
1212
+ );
1213
+
1214
+ server.registerTool(
1215
+ 'write_wiki',
1216
+ {
1217
+ title: 'Create or update a wiki page',
1218
+ description: [
1219
+ 'Writes one wiki page. Creates it if the page slug is new, updates it otherwise — you do not need to check first.',
1220
+ 'Write what the tree cannot hold and what a maintainer would otherwise have to reconstruct: why a decision went the way it did, the vocabulary this project uses, what an investigation concluded. Do not restate the structure — that is what nodes are for.',
1221
+ '**A page is a description, not a log.** Write it in the present tense, as a reference entry for the thing: how it works now, and the reasons that still constrain it. Do not append what you just did, do not add dated entries or a change-history section, do not keep progress checklists here — those go to bugs, plan nodes, list_events, and record_commit, and the page\'s own past is already kept in its revisions. When something changes, rewrite the sentence that is now wrong rather than adding a sentence saying it changed.',
1222
+ 'mode="replace" (default) overwrites the whole body — read the page first, or you will drop what was there; this is the normal way to update a page. mode="append" is the narrow case: adding a section the page genuinely lacked, never tacking on this session\'s summary. mode="restore" puts an earlier revision back: pass rev=<n> and nothing else, and the page returns to what it said then.',
1223
+ 'node attaches the page to a node; omit it and the page floats free, which is fine and common early on. Attach it later once the tree has a place for it. Deleting that node detaches the page rather than deleting it.',
1224
+ 'Every write is kept as a revision, so a replace that drops prose can be undone (list_wiki_revisions, then mode="restore"). Consecutive writes by the same actor within 30 minutes fold into one revision instead of piling up, which means the body from before your session stays recoverable as its own revision no matter how many times you rewrite the page now.',
1225
+ 'Pass newRevision=true when the page moves to a new stage rather than being polished further — most importantly when you first write back what you actually built over a page that until now held the plan. Folding is judged by clock and author, which cannot tell "still drafting" from "the plan became the thing"; only you know that, and without the flag the plan is quietly overwritten by its own outcome.',
1226
+ 'rename moves the page to a new slug, keeping its id and its whole revision history — do not create a new page and delete the old one, that throws the history away.',
1227
+ 'The response tells you what the write did: rev is the revision it landed in, coalesced says whether it folded into your previous one, and previousBodyChars is how long the body was before you wrote. If previousBodyChars is much larger than what you just wrote, you replaced someone\'s work — check list_wiki_revisions before moving on.',
1228
+ 'baseRev is optional and is how you avoid clobbering a concurrent edit: pass the rev you read, and the write is refused if the page moved on since. Use it when you read, think, then write.',
1229
+ 'The change also lands in the ground\'s history (list_events, entityType="wiki"), where the body is recorded as a length delta rather than a copy of the prose — the prose itself lives in the revisions.',
1230
+ 'Requires a write-scope token.',
1231
+ ].join(' '),
1232
+ inputSchema: z.object({
1233
+ slug: z.string().min(1).describe('Ground slug.'),
1234
+ page: z.string().regex(/^[a-z0-9][a-z0-9-]{0,79}$/).describe('Page slug — lowercase letters, digits, hyphens. The name humans use, e.g. "deploy-notes".'),
1235
+ title: z.string().min(1).max(200).optional().describe('Display title. Required when creating a page.'),
1236
+ body: z.string().optional().describe('Markdown body. With mode="append" this is the text to add. Ignored by mode="restore".'),
1237
+ node: z.string().nullable().optional().describe('Node id to attach to. null detaches. Omit to leave the current attachment untouched.'),
1238
+ mode: z.enum(['replace', 'append', 'restore']).optional().describe('"replace" (default) overwrites the body — the normal way to update a page; "append" adds a missing section to the end (not a session summary); "restore" brings back the revision named by rev.'),
1239
+ rev: z.coerce.number().int().positive().optional().describe('With mode="restore": the revision number to bring back (from list_wiki_revisions).'),
1240
+ baseRev: z.coerce.number().int().positive().optional().describe('The revision you based this write on. If the page has moved past it, the write is refused instead of overwriting.'),
1241
+ // 문자열도 받는다. 어떤 MCP 클라이언트는 boolean 인자를 "true"로 직렬화해서
1242
+ // 보내는데, 그 경우 z.boolean()은 통째로 거부한다 — 계획이 덮이는 걸 막으라고
1243
+ // 만든 플래그가 정작 클라이언트마다 안 먹으면 없느니만 못하다.
1244
+ // z.coerce.boolean()은 쓰면 안 된다: "false"가 true가 된다.
1245
+ newRevision: z.union([z.boolean(), z.enum(['true', 'false']).transform(v => v === 'true')])
1246
+ .optional().describe('Force a new revision instead of folding into the last one. Use when this write ends one stage of the page and begins another — above all when replacing a plan with what was actually built.'),
1247
+ rename: z.string().regex(/^[a-z0-9][a-z0-9-]{0,79}$/).optional().describe('New page slug. Keeps the id and the revision history; refused if that name is taken.'),
1248
+ }),
1249
+ },
1250
+ async ({ slug, page, title, body, node, mode, rev, baseRev, newRevision, rename }) => {
1251
+ try {
1252
+ const payload = { slug: page };
1253
+ if (title !== undefined) payload.title = title;
1254
+ if (body !== undefined) payload.body = body;
1255
+ if (node !== undefined) payload.node = node;
1256
+ if (mode !== undefined) payload.mode = mode;
1257
+ if (rev !== undefined) payload.rev = rev;
1258
+ if (baseRev !== undefined) payload.baseRev = baseRev;
1259
+ if (newRevision !== undefined) payload.newRevision = newRevision;
1260
+ if (rename !== undefined) payload.rename = rename;
1261
+ const written = await api.put(`/api/projects/${encodeURIComponent(slug)}/wiki`, payload);
1262
+ // 로그 형태 경고는 쓰기를 막지 않는다 — 이미 저장된 뒤에 붙인다. 판정이 휴리스틱이라
1263
+ // 거부하면 정당한 문서까지 막히고, 어차피 리비전이 있어 되돌릴 수 있다.
1264
+ // 검사 대상은 저장된 본문이다: append면 이어붙인 결과 전체를 봐야 한다.
1265
+ const warnings = wikiLogShapeCheck(written?.body)
1266
+ .concat(toolMarkupCheck({ title, body: written?.body }));
1267
+ return jsonResult(warnings.length ? { ...written, warnings } : written);
1268
+ } catch (e) { return errorResult(e); }
1269
+ },
1270
+ );
1271
+
1272
+ server.registerTool(
1273
+ 'delete_wiki',
1274
+ {
1275
+ title: 'Delete a wiki page',
1276
+ description: 'Permanently removes a wiki page **and its whole revision history** — unlike an overwrite, this cannot be undone, and only the history entry (list_events) remains to say it happened. Prefer rewriting a page that went stale over deleting it: a page that records a decision you have since reversed is worth keeping with the reversal written in. Deleting is for a page that should never have existed, not for one that is out of date.',
1277
+ inputSchema: z.object({
1278
+ slug: z.string().min(1).describe('Ground slug.'),
1279
+ ref: z.string().min(1).describe('Page slug or id.'),
1280
+ }),
1281
+ },
1282
+ async ({ slug, ref }) => {
1283
+ try {
1284
+ return jsonResult(await api.delete(`/api/projects/${encodeURIComponent(slug)}/wiki/${encodeURIComponent(ref)}`));
1285
+ } catch (e) { return errorResult(e); }
1286
+ },
1287
+ );
950
1288
  }