umtri-mcp 1.0.2 → 1.1.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.
@@ -0,0 +1,12 @@
1
+ // MCP initialize 응답의 instructions — 클라이언트가 세션 시작에 모델에게 그대로
2
+ // 전달한다. 서버가 먼저 말을 걸 수 있는 유일한 자리다(리소스·도구는 불러야 열린다).
3
+ //
4
+ // 그래서 여기엔 "언젠가 읽으면 좋은 것"이 아니라 **읽지 않으면 조용히 어긋나는 것**만
5
+ // 둔다. 매 세션 컨텍스트에 실리므로 길어지면 그 자체가 비용이고, 길수록 안 읽힌다.
6
+ // 상세는 전부 umtri://rules/* 리소스로 넘긴다.
7
+
8
+ export const SERVER_INSTRUCTIONS = `Umtri maps a project's structure as a tree (trunk → limb → twig → leaf → vein) and tracks how it changes over seasons. Start with list_projects, then get_graph.
9
+
10
+ Umtri never reads git. A commit reaches the ground only because something called create_node / update_node / record_commit — so the tree drifts behind the code unless the repo has a habit that says otherwise. If this repo's own rules file (CLAUDE.md, AGENTS.md, …) has no commit-sync policy, read umtri://rules/commit-sync and offer the human a short one to paste in.
11
+
12
+ Before creating or reclassifying nodes, read umtri://rules/vocabulary. Seasons are human-only (umtri://rules/seasons-human-only).`;
@@ -0,0 +1,55 @@
1
+ # Recommendation: Keep a Commit-Sync Policy in the Repo
2
+
3
+ **Umtri never reads your git history by itself.** It has no job runner and no
4
+ repo access — a node learns about a commit only because something called
5
+ `record_commit`, and it learns about a *new* file only because something called
6
+ `create_node`. Nothing in this server can interrupt you at commit time.
7
+
8
+ That is why the tree drifts: code moves forward, the ground stays where it was,
9
+ and no one finds out until a person compares the two by hand.
10
+
11
+ ## What to do about it
12
+
13
+ Write a short commit-sync policy into **the repo's own agent rules file** — the
14
+ one every session already loads (`CLAUDE.md`, `AGENTS.md`, `.cursorrules`,
15
+ whatever that project uses). That file is read before the work starts, by the
16
+ same agent that will do the committing. This resource cannot be, because MCP
17
+ resources are pull-only: they are read when someone asks for them, which is
18
+ exactly the moment that never arrives on its own.
19
+
20
+ This is a recommendation, not a gate. Nothing here blocks a commit, and a
21
+ project that ignores it still works — the ground simply ages faster than the
22
+ code.
23
+
24
+ ## Snippet to adapt
25
+
26
+ Paste into the repo's rules file and edit to fit the project (translate it if
27
+ the file is in another language — the wording matters less than the trigger):
28
+
29
+ ```markdown
30
+ ### Umtri sync (before committing)
31
+
32
+ Umtri does not read git. When a commit is about to be made, check whether the
33
+ change also belongs in the ground:
34
+
35
+ 1. Changed paths vs `metadata.implements` — does every changed file map to a node?
36
+ - New unit of the system (route, screen, table, integration) → `create_node`,
37
+ with `metadata.implements` set. Then wire its connections
38
+ (`create_edge` / `create_api`) — an unconnected node is invisible to impact.
39
+ - File moved or renamed → `update_node` on the affected nodes' `implements`.
40
+ Directory moves are the expensive case: one `git mv` can strand dozens.
41
+ - Behavior changed but structure did not → nothing to do.
42
+ 2. Does the commit close a tracked bug? → `update_bug` (status, and `solution`
43
+ describing what was actually done).
44
+ 3. Then `record_commit` with the SHA and the changed paths.
45
+
46
+ Ask the human before adding nodes for anything ambiguous — a wrong node is
47
+ harder to notice later than a missing one.
48
+ ```
49
+
50
+ ## Where automation fits
51
+
52
+ CI (a GitHub Action on push) is a good place for step 3 — it never forgets a
53
+ SHA. It is a poor place for steps 1 and 2: by then the commit exists, the log
54
+ is unread, and the run is green either way. Detection belongs in CI; the
55
+ decision belongs where a human or agent is still in the loop.
package/core/resources.js CHANGED
@@ -43,6 +43,13 @@ const RESOURCES = [
43
43
  title: 'Plan nodes are a node-based brief',
44
44
  description: 'Nodes marked plan:true are the human\'s structural brief (intent expressed as nodes, not a prompt). Read them as instructions, realize the code, attach metadata.implements (required), keep new detail as plan, and leave committing to the human. Read when get_graph shows any plan:true node.',
45
45
  },
46
+ {
47
+ name: 'commit-sync',
48
+ uri: 'umtri://rules/commit-sync',
49
+ file: 'commit-sync.md',
50
+ title: 'Keep the ground in step with commits',
51
+ description: 'Umtri never reads git — nodes drift unless something records the change. Recommends writing a short commit-sync policy into the repo\'s own agent rules file (CLAUDE.md/AGENTS.md), with a snippet to adapt. Read when setting Umtri up in a repo, or when a commit turns out to have left the tree behind.',
52
+ },
46
53
  {
47
54
  name: 'system-structure',
48
55
  uri: 'umtri://rules/system-structure',
package/core/tools.js CHANGED
@@ -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 컨텍스트엔 무의미.
@@ -323,12 +339,12 @@ export function registerTools(server, { api }) {
323
339
  slug: z.string().min(1).describe('Ground slug (from list_projects).'),
324
340
  view: z.enum(['summary', 'full']).optional().describe('summary (default) for compact nodes; full for raw shape with all fields.'),
325
341
  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.'),
342
+ 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
343
  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
344
  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
345
  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
346
  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.'),
347
+ 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
348
  }),
333
349
  },
334
350
  async ({ slug, view, rootId, depth, maxType, role, season, descriptions, bugStatus }) => {
@@ -367,7 +383,7 @@ export function registerTools(server, { api }) {
367
383
  node: z.string().optional().describe('Start node id. Provide this OR bug.'),
368
384
  bug: z.string().optional().describe('Start from this bug\'s target. Provide this OR node.'),
369
385
  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.'),
386
+ maxDepth: z.coerce.number().int().min(0).optional().describe('Max hops to traverse. Omit for unbounded.'),
371
387
  }),
372
388
  },
373
389
  async ({ slug, node, bug, direction, maxDepth }) => {
@@ -388,19 +404,37 @@ export function registerTools(server, { api }) {
388
404
  'list_bugs',
389
405
  {
390
406
  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.',
407
+ 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). 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
408
  inputSchema: z.object({
393
409
  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.'),
410
+ status: z.enum(['active', 'wild', 'chasing', 'catched', 'all', 'open', 'in_progress', 'resolved', 'closed']).optional()
411
+ .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.'),
412
+ limit: z.coerce.number().int().positive().optional().describe('Return at most this many bugs (applied after the status filter). Omit for all matching.'),
413
+ 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
414
  view: z.enum(['summary', 'full']).optional().describe('summary (default) for a compact list; full for verbatim descriptions + metadata.'),
397
415
  }),
398
416
  },
399
- async ({ slug, status, view }) => {
417
+ async ({ slug, status, limit, order, view }) => {
400
418
  try {
419
+ const want = status || 'active';
401
420
  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('&')}`);
421
+ if (order) qs.push(`order=${encodeURIComponent(order)}`);
422
+ // status·limit을 서버로 넘기는 건 REST 필터가 원하는 집합과 **정확히** 같을 때만이다.
423
+ // wild(순수 open)·catched(resolved+closed)는 REST에 대응 값이 없어 여기서 거른다 —
424
+ // 그때 limit까지 넘기면 걸러지기 전 개수로 잘려 결과가 모자란다.
425
+ const REST_EXACT = {
426
+ active: 'open', open: 'open', // REST의 open = open + in_progress
427
+ chasing: 'in_progress', in_progress: 'in_progress',
428
+ resolved: 'resolved', closed: 'closed',
429
+ };
430
+ const exact = REST_EXACT[want] || null; // wild·catched는 REST에 대응 값이 없다
431
+ if (exact) qs.push(`status=${exact}`);
432
+ if (exact || want === 'all') { if (limit) qs.push(`limit=${limit}`); }
433
+ let bugs = await api.get(`/api/projects/${encodeURIComponent(slug)}/bugs?${qs.join('&')}`);
434
+ if (!exact && want !== 'all') {
435
+ bugs = filterBugsByStatus(bugs, want);
436
+ if (limit) bugs = bugs.slice(0, limit);
437
+ }
404
438
  return jsonResult(view === 'full' ? bugs : compressBugs(bugs));
405
439
  } catch (e) { return errorResult(e); }
406
440
  },
@@ -410,10 +444,10 @@ export function registerTools(server, { api }) {
410
444
  'get_bug',
411
445
  {
412
446
  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.',
447
+ 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
448
  inputSchema: z.object({
415
449
  slug: z.string().min(1).describe('Ground slug.'),
416
- ref: z.union([z.string().min(1), z.number().int().positive()])
450
+ ref: z.union([z.string().min(1), z.coerce.number().int().positive()])
417
451
  .describe('Bug number (seq, e.g. 14) or internal id (bug-<uuid>).'),
418
452
  }),
419
453
  },
@@ -452,7 +486,7 @@ export function registerTools(server, { api }) {
452
486
  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
487
  inputSchema: z.object({
454
488
  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).'),
489
+ limit: z.coerce.number().int().positive().max(200).optional().describe('Max events to return (default 50, cap 200).'),
456
490
  before: z.string().optional().describe('ISO timestamp — return events strictly older than this (keyset pagination).'),
457
491
  entityType: z.enum(['node', 'edge', 'api', 'bug']).optional().describe('Filter to one entity type.'),
458
492
  entityId: z.string().optional().describe('Filter to one entity\'s timeline (usually paired with entityType).'),
@@ -497,7 +531,7 @@ export function registerTools(server, { api }) {
497
531
  }).describe('Bug target. Use {kind:"ground"} when the bug is about the project as a whole.'),
498
532
  description: z.string().optional().describe('Longer details on what is wrong (markdown allowed).'),
499
533
  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.'),
534
+ 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
535
  status: z.enum(['open', 'in_progress', 'resolved', 'closed']).optional()
502
536
  .describe('Defaults to "open".'),
503
537
  }),
@@ -751,7 +785,7 @@ export function registerTools(server, { api }) {
751
785
  'record_commit',
752
786
  {
753
787
  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.',
788
+ 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
789
  inputSchema: z.object({
756
790
  slug: z.string().min(1).describe('Ground slug.'),
757
791
  sha: z.string().min(1).describe('Commit SHA (short or full).'),
@@ -905,7 +939,7 @@ export function registerTools(server, { api }) {
905
939
  id: z.string().min(1).describe('Bug id.'),
906
940
  patch: z.object({
907
941
  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.'),
942
+ score: z.coerce.number().int().min(0).max(8).optional().describe('Change risk 0–8 (8 = riskiest). See create_bug for the rubric.'),
909
943
  title: z.string().min(1).max(200).optional(),
910
944
  description: z.string().nullable().optional().describe('What is wrong.'),
911
945
  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.'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "umtri-mcp",
3
- "version": "1.0.2",
3
+ "version": "1.1.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": [
package/server.js CHANGED
@@ -19,6 +19,7 @@ import { dirname, join } from 'node:path';
19
19
  import { createApiClient } from './core/api-client.js';
20
20
  import { registerTools } from './core/tools.js';
21
21
  import { registerResources } from './core/resources.js';
22
+ import { SERVER_INSTRUCTIONS } from './core/instructions.js';
22
23
 
23
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
25
  const RESOURCES_DIR = join(__dirname, 'core', 'resources');
@@ -44,7 +45,7 @@ const api = createApiClient({
44
45
  const server = new McpServer({
45
46
  name: 'umtri-mcp',
46
47
  version: VERSION,
47
- });
48
+ }, { instructions: SERVER_INSTRUCTIONS });
48
49
 
49
50
  registerTools(server, { api });
50
51
  registerResources(server, { resourcesDir: RESOURCES_DIR });