umtri-mcp 1.5.3 → 1.7.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/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # umtri-mcp
2
2
 
3
+ [![Umtri MCP server](https://mcprush.com/badge/umtri-mcp/card.svg)](https://mcprush.com/to/umtri-mcp?ref=badge)
4
+
3
5
  MCP server for [Umtri](https://umtri.io) — give your AI coding agent a persistent map of your
4
6
  project's structure, dependencies, and bugs.
5
7
 
@@ -62,7 +64,7 @@ appear and then error, re-check the token and its scope — `read` tokens cannot
62
64
 
63
65
  **Plan loop** — `commit_plan`, `record_commit`
64
66
 
65
- **Wiki** — `list_wiki`, `get_wiki`, `list_wiki_revisions`, `write_wiki`, `delete_wiki`. The ground's
67
+ **Wiki** — `list_wiki`, `get_wiki`, `list_wiki_revisions`, `write_wiki`, `edit_wiki`, `delete_wiki`. The ground's
66
68
  encyclopedia: pages form a table of contents (`parent`, `position`) read from an overview down,
67
69
  each has a kind, and every write comes back with warnings when a page strays from the skeleton.
68
70
 
package/core/policy.js CHANGED
@@ -27,7 +27,7 @@ const TRUNK_PATTERN = /^[a-z][a-z0-9-]*(-app|-server|-service|-job|-cdn)$|^[a-z]
27
27
  // Trunk kinds — umtri://rules/trunk-kinds. Recorded in trunk metadata.kind.
28
28
  // A trunk that fits none of these keeps kind unset; the agent asks the human whether to
29
29
  // suggest a new kind via send_feedback (never files it on its own).
30
- export const TRUNK_KINDS = ['front', 'server', 'database', 'library', 'device'];
30
+ export const TRUNK_KINDS = ['front', 'server', 'database', 'library', 'device', 'tooling'];
31
31
 
32
32
  // Suffixes that repeat what the trunk kind already says ("예약 API", "Reservation tables").
33
33
  // Matched at the end of a limb label directly under a trunk.
@@ -132,11 +132,11 @@ export function trunkNamingCheck(type, label) {
132
132
  };
133
133
  }
134
134
 
135
- // trunk에 kind가 없거나 다섯 유형 밖이면 알린다. 거부하지 않는다 — 기존 나무가 대부분 kind 없이 있다.
135
+ // trunk에 kind가 없거나 유형 목록 밖이면 알린다. 거부하지 않는다 — 기존 나무가 대부분 kind 없이 있다.
136
136
  export function trunkKindCheck(type, metadata) {
137
137
  if (type !== 'trunk') return null;
138
138
  const kind = metadata?.kind;
139
- const fallback = 'If none of the five fits, leave kind unset, tell the human which trunk did not fit and why, and offer to suggest a new kind with send_feedback (kind "improvement") — do not file it on your own. See umtri://rules/trunk-kinds.';
139
+ const fallback = 'If none of them fits, leave kind unset, tell the human which trunk did not fit and why, and offer to suggest a new kind with send_feedback (kind "improvement") — do not file it on your own. See umtri://rules/trunk-kinds.';
140
140
  if (kind == null || kind === '') {
141
141
  return {
142
142
  rule: 'trunk-kind-missing',
@@ -179,6 +179,7 @@ export function limbUnderTrunkChecks({ type, label, parentType, parentKind, meta
179
179
  database: 'domains, named like the server',
180
180
  library: 'capabilities or concepts',
181
181
  device: 'physical parts or screen regions',
182
+ tooling: 'what the tools do (build & deploy, measurement & simulation, docs & conventions, dev environment)',
182
183
  }[parentKind] ?? "the trunk kind's axis (umtri://rules/trunk-kinds)";
183
184
  out.push({
184
185
  rule: 'limb-layer-axis',
@@ -238,7 +239,7 @@ export function dormancyHintCheck(type) {
238
239
  return {
239
240
  rule: 'structure-without-children',
240
241
  severity: 'info',
241
- message: `${type} is a structure node — it will render as dormant (dim) until it has a leaf or vein descendant. If this represents a single component or object (one React component, one file, one service), type='leaf' directly under structure is allowed (see PRD-node-roles.md §위계 규칙: trunk→leaf, limb→leaf OK).`,
242
+ message: `${type} is a structure node — it will render as dormant (dim) until it has a leaf or vein descendant. If this represents a single component or object (one React component, one file, one service), type='leaf' directly under a limb is allowed (limb→leaf OK; not trunk→leaf — see umtri://rules/vocabulary).`,
242
243
  };
243
244
  }
244
245
 
@@ -460,6 +461,15 @@ export function hierarchyCheck(parentType, childType) {
460
461
  message: 'A vein is meant to live under a leaf (object), not directly under structure. Consider adding a leaf parent first.',
461
462
  };
462
463
  }
464
+ // trunk 바로 아래는 limb만이다. 객체 하나짜리 도메인도 limb(잎 하나)이거나 상위 개념 limb의 twig·leaf다 —
465
+ // 직속 leaf는 그래프에서 limb처럼 길게 뻗어 trunk의 도메인 목록을 흐린다(umtri://rules/trunk-kinds).
466
+ if (parentType === 'trunk' && childRole === 'object') {
467
+ return {
468
+ rule: 'trunk-direct-leaf',
469
+ severity: 'warn',
470
+ message: 'A trunk holds limbs, not leaves. Put this leaf under the limb of its domain — a one-object domain is still a limb — or under the limb of the parent concept it belongs to. See umtri://rules/trunk-kinds ("What earns a limb").',
471
+ };
472
+ }
463
473
  return null;
464
474
  }
465
475
 
@@ -64,6 +64,13 @@ Keep out:
64
64
  - **Tooling config with no runtime behaviour** — linter/formatter configs,
65
65
  editor settings, `.gitignore`.
66
66
 
67
+ **Tooling that runs is in.** A build or deploy script, a simulator, a docs
68
+ generator, a test that checks the repo's conventions — the team runs these, and
69
+ deleting them changes how the team's work behaves even though the product does
70
+ not. They go into a `tooling` trunk (`umtri://rules/trunk-kinds`), not under the
71
+ app they serve and not out of the tree. The line is *runs* vs *inert*: a config
72
+ file with no behaviour of its own stays out, the script that reads it is in.
73
+
67
74
  Point at documents instead of turning them into nodes: put the reference in the
68
75
  `description` of the node it explains (e.g. "propagation rules:
69
76
  `PRD-impact-analysis.md`"). The document stays findable exactly where someone
@@ -81,7 +88,8 @@ users* vs *read by us*.
81
88
  - `limb` = a category inside a trunk (e.g. `graph`, `table`, `dashboard`).
82
89
  How a trunk splits into limbs depends on its kind (`metadata.kind`): features
83
90
  for a front, domains for a server and its database (same names, no suffix),
84
- capabilities for a library, parts for a device — one common limb per trunk.
91
+ capabilities for a library, parts for a device, what the tools do for tooling —
92
+ one common limb per trunk.
85
93
  See `umtri://rules/trunk-kinds`.
86
94
  - Do **not** create one limb per time period. Time is expressed through
87
95
  `season` and `created_at`, not by carving the tree into temporal slices.
@@ -110,7 +118,7 @@ questions about a ground.
110
118
  | `role_change_at` | type-changed nodes | `string` — ISO date |
111
119
  | `role_change_reason` | type-changed nodes | `string` — short prose explaining the shift |
112
120
  | `placeholder` | intentionally-empty structure | `true` — opts out of dormant cleanup suggestions |
113
- | `kind` | trunks | `front` \| `server` \| `database` \| `library` \| `device` |
121
+ | `kind` | trunks | `front` \| `server` \| `database` \| `library` \| `device` \| `tooling` |
114
122
  | `common` | the one shared limb of a trunk | `true` — see `umtri://rules/trunk-kinds` |
115
123
 
116
124
  ## One file, multiple nodes
@@ -1,6 +1,6 @@
1
1
  # Trunk Kinds — How Each Kind of Trunk Branches
2
2
 
3
- Every trunk is one of five kinds, and each kind has its own rule for what its
3
+ Every trunk is one of six kinds, and each kind has its own rule for what its
4
4
  limbs are. Recording the kind lets the same domain line up across trunks —
5
5
  `Reservations` as a screen area, as endpoints, and as tables — instead of each
6
6
  trunk being carved along a different axis.
@@ -17,11 +17,17 @@ label: the kind is what the MCP reads to check the limbs under it.
17
17
  | `database` | A data store — relational DB, document DB, cache | Domain, **with the same names as the server** | Tables shared by many domains (sessions, audit log) | Tables, labelled by their logical name, with `metadata.table` (the real name) and `metadata.columns` |
18
18
  | `library` | Shared code or a package that other trunks import | Capability or concept (Money, Signal pipeline) | Shared types and constants | Modules |
19
19
  | `device` | Physical hardware, an embedded screen, an enclosure | Physical part or screen region | Device-wide parts (power, communication) | Parts, screen elements |
20
+ | `tooling` | What the team runs but never ships — build and deploy scripts, store upload automation, simulators and measurement tools, docs generators, convention-checking tests, agent skills | What the tools do (build & deploy / measurement & simulation / docs & conventions / dev environment) | Loaders and helpers the scripts share (a `registerBabel`, a shared CLI harness) | Scripts, skills, generators |
20
21
 
21
22
  Static sites and admin panels are `front`. A monorepo package that apps import
22
- is `library`.
23
+ is `library`. Scripts the team runs against the product — a balance simulator,
24
+ a docs generator, a Play upload job, a test that checks the repo's conventions —
25
+ are `tooling`: the mirror image of a library (a library is imported by other
26
+ trunks; tooling imports or executes them). Do not fold them into the app trunk
27
+ they serve — that blurs what the app ships. Inert config and documents are
28
+ still not nodes (see `umtri://rules/system-structure`).
23
29
 
24
- ### When none of the five fits
30
+ ### When none of them fits
25
31
 
26
32
  Do not force it. Leave `metadata.kind` unset, tell the human which trunk did
27
33
  not fit and why, and **offer** to report it with `send_feedback` (kind
@@ -61,6 +67,42 @@ resource too: `Members`, not `Member management`.
61
67
  When adding a limb, look at the other trunks first. If the domain already
62
68
  exists there, reuse its exact label.
63
69
 
70
+ ## What earns a limb
71
+
72
+ A limb is a division the trunk's *user* would make — an area they name and
73
+ walk into on its own. Matching names across trunks is not a reason to give
74
+ every trunk a limb for every domain. Two rules keep limbs honest:
75
+
76
+ 1. **A trunk's direct children are limbs — never leaves.** A one-object
77
+ domain (`Sessions` as one table, `Tokens` as one endpoint group) is still a
78
+ limb holding that one leaf, unless rule 2 gives it a parent concept to live
79
+ under. Do not put the leaf directly under the trunk: there it reads as an
80
+ unnamed domain, and the graph draws it as long as a limb anyway.
81
+ 2. **Small domains that share a parent concept are one limb.** When several
82
+ domains in a trunk are small (two objects or fewer, or the parts of a
83
+ single screen) and the trunk's user would call them one thing, they become
84
+ **one limb named for that thing**, and each absorbed domain stays as a
85
+ **twig that keeps its name** (a domain of one object becomes a leaf of the
86
+ limb). The alignment across trunks is by label — a domain that is a limb
87
+ in the front may be a twig of a bigger limb in the server, and the names
88
+ still line up. Name the parent concept the way the user does, never as a
89
+ layer or a grab-bag:
90
+
91
+ | kind | the parent concept is | example |
92
+ | --- | --- | --- |
93
+ | `server`, `database` | the resource the small domains hang off | `Seasons`, `Bugs`, `Events` routes → twigs/leaves of `Grounds`; `Auth`, `Tokens`, `Orgs` → `Accounts`; admin and public endpoints → `Operations` |
94
+ | `front` | the screen they appear on together | tree layers, season tabs, the time slider → twigs of `Tree`; invite page → twig of `Accounts` |
95
+
96
+ If no honest parent concept exists, leave the small domains as limbs of
97
+ their own, one leaf each — the graph folds a one-leaf limb into a narrow
98
+ slot.
99
+
100
+ Real grounds that skipped rule 2 ended up with half their limbs holding a
101
+ single leaf and a dozen two-leaf limbs for one screen's parts — each one a
102
+ level that says nothing. `get_graph` reports that as the `thin-limb` IA hint
103
+ (per trunk, when half or more of its limbs are thin): look for the parent
104
+ concept those limbs share before adding another.
105
+
64
106
  ## Connections between kinds
65
107
 
66
108
  | From → to | Record as |
@@ -69,6 +111,7 @@ exists there, reuse its exact label.
69
111
  | server endpoint → database table | edge `data_flow` (writes) or `dependency` (reads) |
70
112
  | table → table (foreign key) | edge `dependency` (referencing → referenced) |
71
113
  | any trunk → library module | edge `dependency` |
114
+ | tooling script → the trunk it runs or checks | edge `dependency` (tooling → target) |
72
115
 
73
116
  ## Warnings
74
117
 
@@ -77,8 +120,9 @@ exists there, reuse its exact label.
77
120
 
78
121
  | Rule | Meaning |
79
122
  | --- | --- |
80
- | `trunk-kind-missing` | A trunk has no `metadata.kind`. Pick one of the five, or follow "When none of the five fits". |
81
- | `trunk-kind-unknown` | `metadata.kind` is not one of the five. Same advice. |
123
+ | `trunk-kind-missing` | A trunk has no `metadata.kind`. Pick one of the six, or follow "When none of them fits". |
124
+ | `trunk-kind-unknown` | `metadata.kind` is not one of the six. Same advice. |
125
+ | `trunk-direct-leaf` | A leaf is created or moved directly under a trunk. Give it a limb (rule 1) or put it under the limb of its parent concept (rule 2). |
82
126
  | `limb-domain-suffix` | A limb directly under a trunk ends in a kind suffix (`API`, `응답`, `테이블`, `관리`, `Table(s)`, `Admin`, `Management`). Drop it and reuse the domain name. |
83
127
  | `limb-layer-axis` | A limb is named after a technical layer. Split by the trunk kind's axis instead. |
84
128
  | `common-limb-duplicate` | The trunk already has a limb with `metadata.common: true`. |
@@ -16,9 +16,11 @@ The schema accepts any combination. The MCP warns when violated but lets
16
16
  the data through (legacy migration, experimental use). For new authoring,
17
17
  follow the table strictly.
18
18
 
19
- **Skip-level traversal is allowed within structure**: `trunk → leaf`,
20
- `limb → leaf`, even `trunk → twig` are valid. The full descent
21
- `trunk → limb → twig → leaf → vein` is canonical, not mandatory.
19
+ **Skip-level traversal is allowed below a limb**: `limb → leaf` and
20
+ `trunk → twig` are valid. The full descent `trunk → limb → twig → leaf → vein`
21
+ is canonical, not mandatory — but a trunk's direct children are limbs, never
22
+ leaves (`trunk → leaf` warns with `trunk-direct-leaf`; see
23
+ `umtri://rules/trunk-kinds`, "What earns a limb").
22
24
 
23
25
  ## Trunk = system-unit, not domain
24
26
 
@@ -32,8 +34,8 @@ Domains or feature areas (auth, payments, reservations, dashboard, …) are
32
34
  that spans the user-app and admin trunks" — fine. "Authentication is a
33
35
  trunk" — no.
34
36
 
35
- Record which of the five kinds the trunk is in `metadata.kind` (`front`,
36
- `server`, `database`, `library`, `device`). The kind decides how its limbs
37
+ Record which of the six kinds the trunk is in `metadata.kind` (`front`,
38
+ `server`, `database`, `library`, `device`, `tooling`). The kind decides how its limbs
37
39
  split — see `umtri://rules/trunk-kinds`.
38
40
 
39
41
  Naming convention (recommended, not enforced):
@@ -172,12 +172,18 @@ If you see one of these in the response, read it before proceeding:
172
172
  | `structure-without-children` | New limb/twig will render dormant until a leaf/vein appears. |
173
173
  | `twig-promotion-candidate` | Twig description suggests it should be a sibling limb. |
174
174
  | `redundant-child` | A twig and its only leaf duplicate the same concept. |
175
- | `trunk-kind-missing` / `trunk-kind-unknown` | Trunk has no / an unknown `metadata.kind` (front, server, database, library, device). |
175
+ | `trunk-kind-missing` / `trunk-kind-unknown` | Trunk has no / an unknown `metadata.kind` (front, server, database, library, device, tooling). |
176
176
  | `limb-domain-suffix` | Limb under a trunk ends in `API`/`응답`/`테이블`/`관리`… — name the domain alone. |
177
177
  | `limb-layer-axis` | Limb is a technical layer (State, Utils, Routes, Middleware…). |
178
178
  | `common-limb-duplicate` | The trunk already has its one common limb (`metadata.common`). |
179
179
  | `common-content-outside-common` | Front limb holds common content (design system, shell…) outside the common limb, or a `공통` limb isn't marked. |
180
180
 
181
+ `get_graph` also attaches `iaHints` on the whole tree: `missing-implements-coverage`,
182
+ `isolated-leaf-vein`, `sibling-cluster` (twig candidate), and `thin-limb` — a trunk
183
+ where half or more of the limbs hold a single leaf or nothing: small domains that
184
+ share a parent concept belong in one limb (see `umtri://rules/trunk-kinds`,
185
+ "What earns a limb"). A leaf directly under a trunk warns with `trunk-direct-leaf`.
186
+
181
187
  ## Further reading (read only when relevant)
182
188
 
183
189
  - `umtri://rules/trunk-kinds` — the five trunk kinds and how each splits into limbs
@@ -160,7 +160,7 @@ it." That is not a diary entry, it is a hazard.
160
160
 
161
161
  ## The write tells you what is off
162
162
 
163
- `write_wiki` answers with `warnings[]` when the saved page strays from the
163
+ `write_wiki` and `edit_wiki` answer with `warnings[]` when the saved page strays from the
164
164
  skeleton: no kind, no parent (outside the table of contents), a title that is
165
165
  not a plain noun, no lead, a status banner, a dated heading, a checklist, a
166
166
  change-history section, a top-level section other than Spec/Rules, a missing
@@ -217,11 +217,24 @@ separately. Do not split by document type — `mcp` and `mcp-architecture` are
217
217
  one page at two points in its life. Do not create a page per file, per sprint,
218
218
  or per meeting.
219
219
 
220
+ ## Choosing how to read
221
+
222
+ Pages run long. `get_wiki` returns the whole body by default, and you rarely
223
+ need it.
224
+
225
+ | | |
226
+ | --- | --- |
227
+ | A page you have not seen | `view="outline"` — the lead and the heading tree with sizes |
228
+ | One topic | `section="<heading or path>"` — that section with the footnotes it cites |
229
+ | The exact lines to change | `find="<text>"` — matching lines with context, line-numbered; a block is an `edit_wiki` `oldString` |
230
+ | About to `replace` the body | read it **in full** — an outline is not the body |
231
+
220
232
  ## Choosing how to write
221
233
 
222
234
  | | |
223
235
  | --- | --- |
224
- | The thing changed | `mode="replace"` — **read the page first** (`get_wiki`) |
236
+ | The thing changed | `mode="replace"` — **read the page first** (`get_wiki`, in full) |
237
+ | One passage is wrong on a long page | `edit_wiki` — the exact text and its replacement; it must match once, so copy from `get_wiki` |
225
238
  | The page lacks a whole topic you can add as its own `###` | `mode="replace"` with it folded in; `append` only if it truly stands alone at the end |
226
239
  | Crossing into a new stage | add `newRevision: true` |
227
240
  | A write went wrong | `list_wiki_revisions`, then `mode="restore"` with the rev |
package/core/resources.js CHANGED
@@ -27,7 +27,7 @@ const RESOURCES = [
27
27
  uri: 'umtri://rules/trunk-kinds',
28
28
  file: 'trunk-kinds.md',
29
29
  title: 'Trunk kinds — how each kind of trunk branches',
30
- description: 'Five trunk kinds (front, server, database, library, device) recorded in metadata.kind, the limb axis for each, one common limb per trunk (metadata.common), the same domain name across trunks with no suffix, and how kinds connect. Read before creating a trunk or a limb directly under one, and when restructuring.',
30
+ description: 'Six trunk kinds (front, server, database, library, device, tooling) recorded in metadata.kind, the limb axis for each, one common limb per trunk (metadata.common), the same domain name across trunks with no suffix, and how kinds connect. Read before creating a trunk or a limb directly under one, and when restructuring.',
31
31
  },
32
32
  {
33
33
  name: 'seasons',
package/core/tools.js CHANGED
@@ -230,6 +230,7 @@ function compressSeasons(seasons) {
230
230
  // (2) sibling-cluster: 과밀한 limb/twig(잎 ≥4) 아래 접두어를 ≥3개 공유하는 잎 (twig 후보)
231
231
  // — create_node의 parallel-leaves-need-twig와 임계값·정밀도 동일. "leaf 많음"만으론 발사 안 함
232
232
  // (평평하지만 그룹 근거 없는 묶음은 정상). 구버전 junk-drawer-limb(무조건 ≥4)는 오발 잦아 제거.
233
+ // (3) thin-limb: 한 trunk의 limb 절반 이상이 잎 하나 이하일 때 (trunk 단위 1건)
233
234
  export function computeIaHints(nodes, { edges = [], apis = [] } = {}) {
234
235
  const hints = [];
235
236
  const children = new Map();
@@ -299,6 +300,32 @@ export function computeIaHints(nodes, { edges = [], apis = [] } = {}) {
299
300
  }
300
301
  }
301
302
 
303
+ // (3) thin-limb — trunk 직속 가지(limb)인데 아래가 비었거나 잎 하나뿐인 것. 잎 하나 limb 자체는 허용이고,
304
+ // 같은 상위 개념의 작은 도메인이 limb 하나로 묶였는지 보라는 신호다. 도메인 이름을 trunk마다
305
+ // 맞추다 보면(trunk-kinds "같은 도메인, 같은 이름") 테이블 하나·라우트 하나짜리 limb이 trunk 수만큼
306
+ // 생긴다 — 실제 ground에서 limb의 절반이 이 모양이었다. 앱은 이런 limb을 접어 그리지만, 구조로는
307
+ // 여전히 한 단계가 정보 없이 끼어 있는 것이라 다듬을 후보로 올린다. 하나둘이면 정상(자라는 중)이라
308
+ // trunk 안 limb의 절반 이상일 때만, 그 trunk 단위로 한 번 말한다.
309
+ for (const trunk of nodes.filter((n) => !n.parent)) {
310
+ const limbs = (children.get(trunk.id) || []).filter((l) => l.type === 'limb');
311
+ if (limbs.length < 4) continue;
312
+ const thin = limbs.filter((l) => {
313
+ const kids = children.get(l.id) || [];
314
+ if (kids.length === 0) return !l.metadata?.placeholder;
315
+ return kids.length === 1 && !(children.get(kids[0].id) || []).length;
316
+ });
317
+ if (thin.length * 2 < limbs.length) continue;
318
+ hints.push({
319
+ rule: 'thin-limb',
320
+ trunkId: trunk.id,
321
+ trunkLabel: trunk.label,
322
+ limbCount: limbs.length,
323
+ thinCount: thin.length,
324
+ sampleIds: thin.slice(0, 8).map((l) => l.id),
325
+ suggestion: `${thin.length} of ${limbs.length} limbs under "${trunk.label}" hold a single leaf or nothing. Small domains that share a parent concept belong in one limb named for that concept, each kept as a named twig or leaf; a one-object domain with no parent concept stays a limb of its own. Never move a leaf directly under the trunk. See umtri://rules/trunk-kinds ("What earns a limb").`,
326
+ });
327
+ }
328
+
302
329
  return hints;
303
330
  }
304
331
 
@@ -666,7 +693,7 @@ export function registerTools(server, { api }) {
666
693
  'parent ∈ existing node id; omit to create a root-level node.',
667
694
  '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.',
668
695
  '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.',
669
- 'A trunk carries metadata.kind ∈ front · server · database · library · device, and its limbs follow that kind\'s axis: same domain name across trunks with no suffix ("예약", not "예약 API"), no technical-layer limbs, and at most one common limb (metadata.common=true). Read umtri://rules/trunk-kinds before creating a trunk or a limb under one.',
696
+ 'A trunk carries metadata.kind ∈ front · server · database · library · device · tooling, and its limbs follow that kind\'s axis: same domain name across trunks with no suffix ("예약", not "예약 API"), no technical-layer limbs, and at most one common limb (metadata.common=true). Read umtri://rules/trunk-kinds before creating a trunk or a limb under one.',
670
697
  '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).',
671
698
  '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).',
672
699
  '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. A trunk is never plan: metadata.plan on a trunk is rejected.',
@@ -876,17 +903,21 @@ export function registerTools(server, { api }) {
876
903
  'record_commit',
877
904
  {
878
905
  title: 'Record a git commit onto the nodes it touched',
879
- 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.',
906
+ 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; pass coChange=false to skip them entirely when nobody will review, e.g. a CI step that only records). 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.',
880
907
  inputSchema: z.object({
881
908
  slug: z.string().min(1).describe('Ground slug.'),
882
909
  sha: z.string().min(1).describe('Commit SHA (short or full).'),
883
910
  files: z.array(z.string()).min(1).describe('Repo-relative paths changed by the commit, matched against nodes\' metadata.implements.'),
884
911
  message: z.string().optional().describe('Commit message (optional, stored with the record).'),
912
+ coChange: z.union([z.boolean(), z.enum(['true', 'false']).transform(v => v === 'true')])
913
+ .optional().describe('Default true. false skips coChangeCandidates — for callers that record every commit and never review the suggestions.'),
885
914
  }),
886
915
  },
887
- async ({ slug, sha, files, message }) => {
916
+ async ({ slug, sha, files, message, coChange }) => {
888
917
  try {
889
- const res = await api.post(`/api/projects/${encodeURIComponent(slug)}/record-commit`, { sha, files, message });
918
+ const payload = { sha, files, message };
919
+ if (coChange !== undefined) payload.coChange = coChange;
920
+ const res = await api.post(`/api/projects/${encodeURIComponent(slug)}/record-commit`, payload);
890
921
  return jsonResult(res);
891
922
  } catch (e) { return errorResult(e); }
892
923
  },
@@ -1196,6 +1227,16 @@ export function registerTools(server, { api }) {
1196
1227
  // 독립적으로도 산다 — 붙일 노드가 없다고 못 적게 하면 정작 필요한 초기에 못 쓴다.
1197
1228
  // 아직 사람용 편집 화면이 없다: 지금은 이 도구들이 유일한 쓰기 경로다.
1198
1229
 
1230
+ // 쓰기 응답에서 body를 뺀다. 에이전트는 방금 보낸 본문을 돌려받을 이유가 없고, 수만 자 문서에서는
1231
+ // 이 응답이 전송 타임아웃을 부르는 쪽이었다. 길이(bodyChars)만 남기고, 다시 읽기는 get_wiki로.
1232
+ // REST(PUT /wiki)는 그대로 전체를 돌려준다 — 앱이 쓰는 응답 형태를 MCP 사정으로 바꾸지 않는다.
1233
+ function slimWikiWrite(written, warnings) {
1234
+ if (!written || typeof written !== 'object') return written;
1235
+ const { body, ...rest } = written;
1236
+ const out = { ...rest, bodyChars: (body ?? '').length };
1237
+ return warnings.length ? { ...out, warnings } : out;
1238
+ }
1239
+
1199
1240
  server.registerTool(
1200
1241
  'list_wiki',
1201
1242
  {
@@ -1226,18 +1267,33 @@ export function registerTools(server, { api }) {
1226
1267
  server.registerTool(
1227
1268
  'get_wiki',
1228
1269
  {
1229
- title: 'Read one wiki page',
1230
- 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.',
1270
+ title: 'Read one wiki page — in full, as an outline, one section, or the lines around a phrase',
1271
+ description: [
1272
+ 'Returns one wiki page. `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. Every response carries rev (the current revision, what you pass as baseRev when you write) and bodyChars.',
1273
+ 'By default the whole body comes back. **Pages run long (tens of thousands of characters); do not read one in full to answer a small question.** view="outline" returns the lead paragraph and the heading tree (path like "1.2", level, heading, line, chars per section) — start there on a page you have not seen. section="<heading text or path>" returns just that section (through its sub-headings), plus the footnote definitions it references; it must match exactly one heading, and on an ambiguous match the error lists the candidates with their paths. find="<text>" returns the lines containing the text (case-insensitive, literal) with context lines around each, merged into blocks with line numbers — a block is ready to be the oldString of edit_wiki. find inside one section: pass both.',
1274
+ 'Read in full before write_wiki mode="replace" — it overwrites the body wholesale, and an outline is not the body. For a one-passage fix, find or section the spot and call edit_wiki instead.',
1275
+ 'Pass rev=<n> to read an older revision instead of the current page (list them with list_wiki_revisions; outline/section/find work there too). 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.',
1276
+ ].join(' '),
1231
1277
  inputSchema: z.object({
1232
1278
  slug: z.string().min(1).describe('Ground slug.'),
1233
1279
  ref: z.string().min(1).describe('Page slug (e.g. "deploy-notes") or id (wiki-<uuid>).'),
1234
1280
  rev: z.coerce.number().int().positive().optional().describe('Revision number to read. Omit for the current page.'),
1281
+ view: z.enum(['full', 'outline']).optional().describe('"full" (default) returns the body; "outline" returns the lead and the heading tree with sizes instead. Ignored when section or find is given.'),
1282
+ section: z.string().min(1).optional().describe('One heading, by its text (case-insensitive; backticks/emphasis ignored) or by its outline path ("1.2"). Returns that section and its sub-sections only.'),
1283
+ find: z.string().min(1).optional().describe('Literal text to look for (case-insensitive). Returns matching lines with context, merged into blocks with line numbers. With section, searches inside that section only.'),
1284
+ context: z.coerce.number().int().min(0).max(10).optional().describe('Lines of context around each find match. Default 2.'),
1235
1285
  }),
1236
1286
  },
1237
- async ({ slug, ref, rev }) => {
1287
+ async ({ slug, ref, rev, view, section, find, context }) => {
1238
1288
  try {
1239
1289
  const base = `/api/projects/${encodeURIComponent(slug)}/wiki/${encodeURIComponent(ref)}`;
1240
- return jsonResult(await api.get(rev ? `${base}/revisions/${rev}` : base));
1290
+ const qs = [];
1291
+ if (view) qs.push(`view=${view}`);
1292
+ if (section !== undefined) qs.push(`section=${encodeURIComponent(section)}`);
1293
+ if (find !== undefined) qs.push(`find=${encodeURIComponent(find)}`);
1294
+ if (context !== undefined) qs.push(`context=${context}`);
1295
+ const url = (rev ? `${base}/revisions/${rev}` : base) + (qs.length ? `?${qs.join('&')}` : '');
1296
+ return jsonResult(await api.get(url));
1241
1297
  } catch (e) { return errorResult(e); }
1242
1298
  },
1243
1299
  );
@@ -1279,12 +1335,12 @@ export function registerTools(server, { api }) {
1279
1335
  '**The wiki is the project\'s encyclopedia: one page is one entry, and its body is the current spec and rules of that thing.** It is a tree read front to back: an overview page at the top, chapter pages under it, entries under those — place every page with parent (a page slug or id) and position (its order among siblings), and check list_wiki order="tree" to see where it belongs. For a new wiki, follow the table of contents in umtri://templates/wiki. The title is a plain noun for the thing in the reader\'s language (조직, 배포, Billing) — no dash, colon, parenthetical or sentence; the description goes in the lead. Give every page a kind — overview (the top page and chapter pages that introduce what is under them), concept (a product/domain concept or model), component (how one part of the system works), rule (a convention that must be kept), glossary (the project\'s vocabulary). Write it to this skeleton: a lead paragraph first (1–3 sentences saying what the thing is — not a heading, quote or list), then "## 스펙" / "## Spec" (how it works now; break it down with ###), then "## 규칙" / "## Rules" (invariants, prohibitions, required order). concept and component need Spec; rule needs Rules; overview and glossary are free-form after the lead. No other top-level (##) sections. Background, history, rejected alternatives and side notes go into footnotes — [^name] in the text, [^name]: … at the end — not into the body. A reason stays in the body only if someone changing the code would get it wrong without it.',
1280
1336
  '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.',
1281
1337
  '**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.',
1282
- '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.',
1338
+ 'mode="replace" (default) overwrites the whole body — read the page first, or you will drop what was there; this is the normal way to rewrite a page. **To change one passage of an existing page, use edit_wiki instead** — it takes the exact text and its replacement, so a one-line fix on a long page does not resend the whole body. 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.',
1283
1339
  '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.',
1284
1340
  '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.',
1285
1341
  '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.',
1286
1342
  '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.',
1287
- '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.',
1343
+ '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. The body itself is not echoed back — bodyChars is its length now; re-read with get_wiki if you need the text.',
1288
1344
  '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.',
1289
1345
  'The response carries warnings[] when the page strays from the skeleton or reads like a log (status banners, dated headings, checklists, change-history sections, unmatched footnotes). The write is saved either way — fix what they point at and write again.',
1290
1346
  '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.',
@@ -1330,7 +1386,41 @@ export function registerTools(server, { api }) {
1330
1386
  // 여기선 도구 호출 마크업 유출만 더한다 — 그건 MCP 인자 직렬화 사고라 이 층의 일이다.
1331
1387
  const warnings = (written?.warnings ?? [])
1332
1388
  .concat(toolMarkupCheck({ title, body: written?.body }));
1333
- return jsonResult(warnings.length ? { ...written, warnings } : written);
1389
+ return jsonResult(slimWikiWrite(written, warnings));
1390
+ } catch (e) { return errorResult(e); }
1391
+ },
1392
+ );
1393
+
1394
+ server.registerTool(
1395
+ 'edit_wiki',
1396
+ {
1397
+ title: 'Change one passage of a wiki page',
1398
+ description: [
1399
+ 'Replaces one passage of an existing wiki page without resending the body. Pass the exact text to replace (oldString) and what it becomes (newString) — the same contract as a code editor\'s Edit: oldString must occur **exactly once** on the page. If it is missing, or occurs more than once, nothing is written and the error says which; copy the text verbatim from get_wiki (whitespace and line breaks count) or include more surrounding text to make it unique. newString="" deletes the passage. Matching is literal, not a regex.',
1400
+ 'This is the normal way to fix a sentence, update a value, or correct one rule on a page that already exists. Use write_wiki when you are composing a page or rewriting several sections at once, and mode="append" there when a section is genuinely missing.',
1401
+ 'The edit is a revision like any other write: it folds into your previous write within 30 minutes unless newRevision=true, and baseRev refuses the edit when the page moved on since you read it. The response carries rev, coalesced, previousBodyChars and bodyChars, plus warnings[] when the page as a whole now strays from the skeleton — not the body.',
1402
+ 'Requires a write-scope token.',
1403
+ ].join(' '),
1404
+ inputSchema: z.object({
1405
+ slug: z.string().min(1).describe('Ground slug.'),
1406
+ page: z.string().regex(/^[a-z0-9][a-z0-9-]{0,79}$/).describe('Page slug of an existing page.'),
1407
+ oldString: z.string().min(1).describe('The exact text to replace. Must occur exactly once on the page.'),
1408
+ newString: z.string().describe('The replacement text. "" deletes the passage.'),
1409
+ baseRev: z.coerce.number().int().positive().optional().describe('The revision you read. If the page has moved past it, the edit is refused.'),
1410
+ newRevision: z.union([z.boolean(), z.enum(['true', 'false']).transform(v => v === 'true')])
1411
+ .optional().describe('Force a new revision instead of folding into your last write.'),
1412
+ }),
1413
+ },
1414
+ async ({ slug, page, oldString, newString, baseRev, newRevision }) => {
1415
+ try {
1416
+ const payload = { slug: page, mode: 'edit', oldString, newString };
1417
+ if (baseRev !== undefined) payload.baseRev = baseRev;
1418
+ if (newRevision !== undefined) payload.newRevision = newRevision;
1419
+ const written = await api.put(`/api/projects/${encodeURIComponent(slug)}/wiki`, payload);
1420
+ // 마크업 유출 판정은 새로 들어간 조각에만 건다 — 본문 나머지는 이 호출이 쓴 게 아니다.
1421
+ const warnings = (written?.warnings ?? [])
1422
+ .concat(toolMarkupCheck({ newString }));
1423
+ return jsonResult(slimWikiWrite(written, warnings));
1334
1424
  } catch (e) { return errorResult(e); }
1335
1425
  },
1336
1426
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "umtri-mcp",
3
- "version": "1.5.3",
3
+ "version": "1.7.0",
4
4
  "description": "MCP server for Umtri — give your AI coding agent a persistent map of your project's structure, dependencies, and bugs.",
5
5
  "mcpName": "io.umtri/umtri",
6
6
  "keywords": [