ki-manager 2.3.0.dev3__py3-none-any.whl → 2.3.1.dev1__py3-none-any.whl

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.
ki_manager/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
 
2
2
  """ki-manager — Knowledge Item MCP server for AI-assisted development."""
3
- __version__ = "2.3.0.dev3"
3
+ __version__ = "2.3.1.dev1"
@@ -32,6 +32,9 @@ def analyze_path(target_path: str, recursive: bool = False):
32
32
 
33
33
  # Resolve absolute path
34
34
  abs_target = os.path.normpath(os.path.join(project_root, target_path))
35
+ if not ki_utils.is_subpath(abs_target, project_root):
36
+ print(f"Error: Path '{target_path}' escapes project root.")
37
+ return
35
38
 
36
39
  if not os.path.exists(abs_target):
37
40
  print(f"Error: Path {target_path} not found.")
@@ -65,7 +65,7 @@ def load_ki_files() -> list:
65
65
  ki_dir = os.path.join(get_knowledge_root(), "knowledge")
66
66
  if not os.path.isdir(ki_dir):
67
67
  return []
68
- return [f for f in os.listdir(ki_dir) if f.startswith("KI_") and f.endswith(".md")]
68
+ return [f for f in os.listdir(ki_dir) if ki_utils.is_ki_filename(f)]
69
69
 
70
70
 
71
71
  # ─── Metrics ──────────────────────────────────────────────────────────────────
@@ -286,7 +286,7 @@ def run_drift_check(
286
286
  return [check_single_ki(target_name, target_path, doc_config, project_root, knowledge_dir)]
287
287
 
288
288
  results = []
289
- for f in sorted(knowledge_dir.glob("KI_*.md")):
289
+ for f in sorted(f for f in knowledge_dir.glob("*.md") if ki_utils.is_ki_filename(f.name)):
290
290
  results.append(check_single_ki(f.name, f, doc_config, project_root, knowledge_dir))
291
291
 
292
292
  return results
@@ -70,7 +70,7 @@ def finalize_scaffolds(dry_run: bool = False) -> None:
70
70
  skipped_clean = 0
71
71
 
72
72
  for fname in sorted(os.listdir(ki_dir)):
73
- if not (fname.startswith("KI_") and fname.endswith(".md")):
73
+ if not ki_utils.is_ki_filename(fname):
74
74
  continue
75
75
 
76
76
  ki_path = os.path.join(ki_dir, fname)
@@ -29,6 +29,8 @@ def get_unmapped_files(target_path: str = ".") -> List[str]:
29
29
 
30
30
  # 2. Scan the target directory
31
31
  abs_target = os.path.abspath(os.path.join(project_root, target_path))
32
+ if not ki_utils.is_subpath(abs_target, project_root):
33
+ return []
32
34
  if not os.path.exists(abs_target):
33
35
  return []
34
36
 
@@ -514,7 +514,7 @@ def print_scaffold_status() -> None:
514
514
  enriched_count = 0
515
515
 
516
516
  for fname in sorted(os.listdir(ki_dir)):
517
- if not (fname.startswith("KI_") and fname.endswith(".md")):
517
+ if not ki_utils.is_ki_filename(fname):
518
518
  continue
519
519
 
520
520
  ki_path = os.path.join(ki_dir, fname)
@@ -0,0 +1,81 @@
1
+ """
2
+ git_diff_secured.py
3
+
4
+ Safely runs git status and git diff for project files within the project sandbox.
5
+ Prevents argument/flag injection and path traversal.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ import argparse
11
+ import subprocess
12
+ from pathlib import Path
13
+ from typing import List
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
16
+ import ki_utils
17
+
18
+
19
+ def git_diff_secured(paths: List[str] = None) -> str:
20
+ project_root = ki_utils.get_project_root()
21
+ if not project_root or not os.path.exists(project_root):
22
+ return "Error: No active project context."
23
+
24
+ safe_paths = []
25
+ if paths:
26
+ for p in paths:
27
+ clean_p = p.strip()
28
+ if not clean_p:
29
+ continue
30
+ if clean_p.startswith("-") or any(char in clean_p for char in [";", "|", "&", ">", "<", "`"]):
31
+ return f"Error: Suspicious argument or path '{clean_p}'."
32
+ abs_p = os.path.abspath(os.path.join(project_root, clean_p))
33
+ if not ki_utils.is_subpath(abs_p, project_root):
34
+ return f"Access Denied: Path '{clean_p}' escapes project root."
35
+ safe_paths.append(os.path.relpath(abs_p, project_root).replace("\\", "/"))
36
+
37
+ try:
38
+ if safe_paths:
39
+ stat_cmd = ["git", "status", "--short", "--"] + safe_paths
40
+ diff_cmd = ["git", "diff", "HEAD", "--"] + safe_paths
41
+ else:
42
+ stat_cmd = ["git", "status", "--short"]
43
+ diff_cmd = ["git", "diff", "HEAD"]
44
+
45
+ stat_res = subprocess.run(
46
+ stat_cmd, cwd=project_root, capture_output=True, text=True, encoding="utf-8"
47
+ )
48
+ diff_res = subprocess.run(
49
+ diff_cmd, cwd=project_root, capture_output=True, text=True, encoding="utf-8"
50
+ )
51
+
52
+ output = []
53
+ if stat_res.stdout.strip():
54
+ output.append("### Git Status:\n" + stat_res.stdout.strip())
55
+ if diff_res.stdout.strip():
56
+ output.append("### Git Diff:\n" + diff_res.stdout.strip())
57
+ if not output:
58
+ output.append("No changes detected in git.")
59
+
60
+ return "\n\n".join(output)
61
+ except Exception as e:
62
+ return f"Git execution error: {str(e)}"
63
+
64
+
65
+ def main():
66
+ if sys.platform == "win32":
67
+ try:
68
+ sys.stdout.reconfigure(encoding="utf-8")
69
+ except Exception:
70
+ pass
71
+
72
+ parser = argparse.ArgumentParser(description="Secured git diff for project files.")
73
+ parser.add_argument("paths", nargs=argparse.REMAINDER, help="File or directory paths relative to project root")
74
+ args = parser.parse_args()
75
+
76
+ result = git_diff_secured(args.paths)
77
+ print(result)
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
@@ -218,7 +218,7 @@ def search_knowledge(
218
218
  return {"error": "No project root found. Run ki_init_project or specify workspace.", "results": []}
219
219
 
220
220
  knowledge_root = ki_utils.get_knowledge_root()
221
- if not knowledge_root or not os.path.exists(knowledge_root) or not os.path.normcase(os.path.abspath(knowledge_root)).startswith(os.path.normcase(os.path.abspath(project_root))):
221
+ if not knowledge_root or not os.path.exists(knowledge_root) or not ki_utils.is_subpath(knowledge_root, project_root):
222
222
  for cand in [
223
223
  os.path.join(project_root, ".ki-base", "knowledge"),
224
224
  os.path.join(project_root, "knowledge"),
@@ -58,6 +58,28 @@ def normalize_path(path_str: str, make_absolute: bool = True) -> str:
58
58
  return path_str
59
59
 
60
60
 
61
+ def is_subpath(target: str, root: str) -> bool:
62
+ """Checks whether target path is strictly within or equal to root directory."""
63
+ if not target or not root:
64
+ return False
65
+ try:
66
+ norm_t = os.path.normcase(os.path.abspath(target))
67
+ norm_r = os.path.normcase(os.path.abspath(root))
68
+ return os.path.commonpath([norm_t, norm_r]) == norm_r
69
+ except (ValueError, OSError):
70
+ return False
71
+
72
+
73
+ def is_ki_filename(name: str, include_overview: bool = False) -> bool:
74
+ """Checks if a file name matches Knowledge Item convention (KI_*.md or *.ki.md)."""
75
+ if not name or not name.endswith(".md"):
76
+ return False
77
+ base = os.path.basename(name)
78
+ if not include_overview and base in ("_OVERVIEW.ki.md", "_OVERVIEW.md"):
79
+ return False
80
+ return base.startswith("KI_") or base.endswith(".ki.md")
81
+
82
+
61
83
  # ─── Registry Management ─────────────────────────────────────────────────────
62
84
 
63
85
  def get_registry_path() -> Path:
@@ -139,7 +161,7 @@ def find_project_by_cwd(cwd=None) -> dict:
139
161
 
140
162
  for proj_root, data in registry["projects"].items():
141
163
  norm_proj = normalize_path(proj_root)
142
- if os.path.normcase(cwd).startswith(os.path.normcase(norm_proj)):
164
+ if is_subpath(cwd, norm_proj):
143
165
  if len(norm_proj) > max_len:
144
166
  max_len = len(norm_proj)
145
167
  best_match = data
@@ -409,10 +431,30 @@ def get_ki_list_table() -> str:
409
431
  items = doc_config.get("knowledge_items", {})
410
432
  if not items:
411
433
  return "No Knowledge Items registered yet."
434
+ jail = get_knowledge_root()
435
+ proj = get_project_root()
436
+ dec_dirs = get_decisions_dirs(proj, jail)
437
+
412
438
  rows = ["| File | Topic / Summary |", "|------|-----------------|"]
413
439
  for name, info in sorted(items.items()):
414
440
  summary = info.get("summary", info.get("description", "—"))
415
- rows.append(f"| `{name}` | {summary} |")
441
+ display_name = name
442
+ if "path" in info and info["path"]:
443
+ display_name = info["path"]
444
+ elif jail or proj:
445
+ # Check if name exists literally
446
+ exists_literal = False
447
+ for root_cand in ([jail] if jail else []) + dec_dirs:
448
+ if os.path.exists(os.path.join(root_cand, name)) or os.path.exists(os.path.join(root_cand, "knowledge", name)):
449
+ exists_literal = True
450
+ break
451
+ if not exists_literal and re.match(r"^(?:ADR|adr)[_\-]", name):
452
+ stripped = re.sub(r"^(?:ADR|adr)[_\-]", "", name)
453
+ for d in dec_dirs:
454
+ if os.path.exists(os.path.join(d, stripped)):
455
+ display_name = stripped
456
+ break
457
+ rows.append(f"| `{display_name}` | {summary} |")
416
458
  return "\n".join(rows)
417
459
 
418
460
 
@@ -201,11 +201,11 @@ class KnowledgeEngine:
201
201
  if not knowledge_dir.exists():
202
202
  return "Knowledge directory not found"
203
203
 
204
- path_pattern = re.compile(r"`((?:app|ui|extensions|\.know|\.agent|scripts|tests)/[a-zA-Z0-9_\-\./]+)`")
204
+ path_pattern = re.compile(r"`([a-zA-Z0-9_\-\.]+/[a-zA-Z0-9_\-\./]+)`")
205
205
 
206
206
  updated_count = 0
207
207
  for filename in os.listdir(knowledge_dir):
208
- if not filename.endswith(".md"):
208
+ if not ki_utils.is_ki_filename(filename, include_overview=True):
209
209
  continue
210
210
 
211
211
  ki_path = knowledge_dir / filename
@@ -215,8 +215,9 @@ class KnowledgeEngine:
215
215
  mentioned = path_pattern.findall(content)
216
216
  valid_paths = set()
217
217
  for p in mentioned:
218
- p = p.rstrip(".,")
219
- if (self.project_root / p).exists():
218
+ p = p.rstrip(".,;:)")
219
+ target_file = (self.project_root / p).resolve()
220
+ if ki_utils.is_subpath(str(target_file), str(self.project_root)) and target_file.exists():
220
221
  valid_paths.add(p.replace("\\", "/"))
221
222
 
222
223
  if filename not in ki_items:
@@ -16,12 +16,16 @@ import ki_utils
16
16
 
17
17
 
18
18
  def resolve_agents_path(agents_path: str = None) -> str:
19
- """Finds the AGENTS.md file in the project."""
20
19
  if agents_path:
21
20
  p = ki_utils.normalize_path(agents_path)
22
- if os.path.exists(p):
23
- return p
24
- return p # let caller handle not found
21
+ pr = ki_utils.get_project_root()
22
+ kr = ki_utils.get_knowledge_root()
23
+ allowed = [pr] if pr else []
24
+ if kr and kr not in allowed:
25
+ allowed.append(kr)
26
+ if allowed and not any(ki_utils.is_subpath(p, r) for r in allowed):
27
+ return ""
28
+ return p
25
29
 
26
30
  pr = ki_utils.get_project_root()
27
31
  kr = ki_utils.get_knowledge_root()
ki_manager/server.py CHANGED
@@ -13,6 +13,7 @@ Usage:
13
13
  import sys
14
14
  import json
15
15
  import os
16
+ import re
16
17
  import subprocess
17
18
  import importlib.metadata
18
19
  from pathlib import Path
@@ -46,7 +47,11 @@ _LOG_DIR = Path.home() / ".ki_base" / "logs"
46
47
  def safe_log(msg: str):
47
48
  try:
48
49
  _LOG_DIR.mkdir(parents=True, exist_ok=True)
49
- log_file = _LOG_DIR / f"mcp_{os.getpid()}.log"
50
+ import datetime
51
+ today_str = datetime.date.today().isoformat()
52
+ log_file = _LOG_DIR / f"mcp_{today_str}.log"
53
+ if len(msg) > 2000:
54
+ msg = msg[:1997] + "..."
50
55
  with open(log_file, "a", encoding="utf-8") as lf:
51
56
  lf.write(msg + "\n")
52
57
  except Exception:
@@ -114,15 +119,20 @@ def get_adr_list(project_root: str, jail: str) -> str:
114
119
 
115
120
  # ─── Security ─────────────────────────────────────────────────────────────────
116
121
 
117
- _FORBIDDEN_WRITE_EXT = {".py", ".pyc", ".bat", ".ps1", ".sh", ".exe", ".cmd", ".dll"}
118
- _FORBIDDEN_WRITE_FILES = {"doc_config.json"} # can only be modified via dedicated tools
122
+ _FORBIDDEN_WRITE_EXT = {
123
+ ".py", ".pyc", ".bat", ".ps1", ".sh", ".exe", ".cmd", ".dll",
124
+ ".js", ".mjs", ".cjs", ".ts", ".vbs", ".wsf", ".bash", ".zsh"
125
+ }
126
+ _FORBIDDEN_WRITE_FILES = {
127
+ "doc_config.json", "ki_config.json", "config.json", "doc_state.json"
128
+ } # can only be modified via dedicated tools
119
129
 
120
130
  NO_WORKSPACE_ERROR = {
121
131
  "isError": True,
122
132
  "content": [{
123
133
  "type": "text",
124
134
  "text": (
125
- "❌ No active workspace detected.\n\n"
135
+ "✖ No active workspace detected.\n\n"
126
136
  "To fix, call ki_status with explicit path:\n"
127
137
  " ki_status({\"path\": \"/absolute/path/to/project\"})\n\n"
128
138
  "Or ensure the MCP client passes workspaceUri in _meta.io.modelcontextprotocol/clientInfo."
@@ -135,7 +145,7 @@ def validate_path(rel_path: str, is_write: bool = False) -> str:
135
145
  jail = get_jail_dir()
136
146
  if not jail:
137
147
  raise PermissionError(
138
- "❌ No active workspace detected. "
148
+ "✖ No active workspace detected. "
139
149
  "Call ki_status({\"path\": \"/absolute/path/to/project\"}) to set it."
140
150
  )
141
151
 
@@ -147,7 +157,7 @@ def validate_path(rel_path: str, is_write: bool = False) -> str:
147
157
  else:
148
158
  target = os.path.abspath(normalized)
149
159
 
150
- if not os.path.normcase(target).startswith(os.path.normcase(jail)):
160
+ if not ki_utils.is_subpath(target, jail):
151
161
  raise PermissionError("Access Denied: jail breach detected.")
152
162
 
153
163
  if is_write:
@@ -155,11 +165,180 @@ def validate_path(rel_path: str, is_write: bool = False) -> str:
155
165
  if ext in _FORBIDDEN_WRITE_EXT:
156
166
  raise PermissionError(f"Access Denied: modifying {ext} files is forbidden.")
157
167
  if os.path.basename(target).lower() in _FORBIDDEN_WRITE_FILES:
158
- raise PermissionError(f"Access Denied: use dedicated tools to modify this file.")
168
+ raise PermissionError("Access Denied: use dedicated tools to modify this file.")
159
169
 
160
170
  return target
161
171
 
162
172
 
173
+ def resolve_know_path(rel_path: str, is_write: bool = False, must_exist: bool = False) -> str:
174
+ """
175
+ Resolves a relative knowledge/ADR path using smart cascading, security validation,
176
+ and tolerance for prefixes like 'ADR_'.
177
+
178
+ If must_exist is True: raises FileNotFoundError if not found.
179
+ If must_exist is False and file doesn't exist: returns the target creation path
180
+ (defaulting flat KI files to <jail>/knowledge/<norm>).
181
+ """
182
+ jail = get_jail_dir()
183
+ if not jail:
184
+ raise PermissionError(
185
+ "✖ No active workspace detected. "
186
+ "Call ki_status({\"path\": \"/absolute/path/to/project\"}) to set it."
187
+ )
188
+
189
+ norm = ki_utils.normalize_path(rel_path, make_absolute=False)
190
+ if not norm:
191
+ raise ValueError("rel_path cannot be empty.")
192
+
193
+ if not os.path.isabs(norm) and norm.startswith(".."):
194
+ raise PermissionError(f"Access Denied: path '{rel_path}' escapes sandbox.")
195
+
196
+ project_root = get_project_root()
197
+ base_name = os.path.basename(norm)
198
+ dir_name = os.path.dirname(norm)
199
+
200
+ # Name variants (tolerance for ADR_ / adr_ prefixes)
201
+ name_variants = [base_name]
202
+ m_adr = re.match(r"^(?:ADR|adr)[_\-](.*)", base_name)
203
+ if m_adr:
204
+ name_variants.append(m_adr.group(1))
205
+ elif re.match(r"^\d+", base_name):
206
+ name_variants.append(f"ADR_{base_name}")
207
+ name_variants.append(f"adr_{base_name}")
208
+
209
+ target = None
210
+
211
+ # 1. Direct path inside jail
212
+ try:
213
+ cand = validate_path(norm)
214
+ if os.path.exists(cand) and os.path.isfile(cand):
215
+ target = cand
216
+ except Exception:
217
+ target = None
218
+
219
+ # 1b. If base_name had ADR_ prefix, try direct path with stripped name in same directory inside jail
220
+ if not target and m_adr and dir_name:
221
+ try:
222
+ cand = validate_path(os.path.join(dir_name, m_adr.group(1)))
223
+ if os.path.exists(cand) and os.path.isfile(cand):
224
+ target = cand
225
+ except Exception:
226
+ target = None
227
+
228
+ # 2. Inside jail/knowledge/ (standard structure: <jail>/knowledge/<file>)
229
+ if not target and jail:
230
+ for nv in name_variants:
231
+ sub = os.path.join(dir_name, nv) if dir_name else nv
232
+ try:
233
+ cand = validate_path(os.path.join("knowledge", sub))
234
+ if os.path.exists(cand) and os.path.isfile(cand):
235
+ target = cand
236
+ break
237
+ except Exception:
238
+ pass
239
+
240
+ # 3. If rel_path starts with the knowledge_root folder name itself (e.g. '.ki-base/...' or 'knowledge/...'),
241
+ # strip prefix and resolve inside jail
242
+ if not target and jail and project_root:
243
+ rel_to_proj = os.path.relpath(jail, project_root).replace("\\", "/")
244
+ if norm.startswith(rel_to_proj + "/"):
245
+ sub_path = norm[len(rel_to_proj) + 1:]
246
+ try:
247
+ cand = validate_path(sub_path)
248
+ if os.path.exists(cand) and os.path.isfile(cand):
249
+ target = cand
250
+ except Exception:
251
+ pass
252
+ if not target:
253
+ try:
254
+ cand = validate_path(os.path.join("knowledge", sub_path))
255
+ if os.path.exists(cand) and os.path.isfile(cand):
256
+ target = cand
257
+ except Exception:
258
+ pass
259
+
260
+ # 4. Decisions directories (ADR resolution)
261
+ if not target and project_root:
262
+ candidates = ki_utils.get_decisions_dirs(project_root, jail)
263
+ for d in candidates:
264
+ if not os.path.exists(d):
265
+ continue
266
+ # Try by name variants
267
+ for nv in name_variants:
268
+ cand_p = os.path.abspath(os.path.join(d, nv))
269
+ if os.path.exists(cand_p) and cand_p.endswith(".md") and os.path.isfile(cand_p):
270
+ target = cand_p
271
+ break
272
+ if target:
273
+ break
274
+
275
+ # If still not found, match by ADR ID prefix (e.g. '002' or '2')
276
+ m_id = re.match(r"^(?:ADR[_\-]?)?(\d+)", base_name, re.IGNORECASE)
277
+ if m_id:
278
+ target_id = m_id.group(1)
279
+ for f in os.listdir(d):
280
+ if f.endswith(".md"):
281
+ m_f = re.match(r"^(?:ADR[_\-]?)?(\d+)", f, re.IGNORECASE)
282
+ if m_f and (m_f.group(1) == target_id or m_f.group(1).lstrip("0") == target_id.lstrip("0")):
283
+ target = os.path.abspath(os.path.join(d, f))
284
+ break
285
+ if target:
286
+ break
287
+
288
+ # Also check relative to project root directly if within decisions or jail
289
+ if not target:
290
+ cand_p = os.path.abspath(os.path.join(project_root, norm))
291
+ if os.path.exists(cand_p) and cand_p.endswith(".md") and os.path.isfile(cand_p):
292
+ valid_prefixes = candidates + ([jail] if jail else [])
293
+ for vp in valid_prefixes:
294
+ if os.path.normcase(cand_p).startswith(os.path.normcase(vp)):
295
+ target = cand_p
296
+ break
297
+
298
+ # 5. Fallback: Search by basename variants in jail and its subfolders
299
+ if not target and jail and os.path.exists(jail):
300
+ for root, _, files in os.walk(jail):
301
+ for nv in name_variants:
302
+ if nv in files:
303
+ cand_p = os.path.abspath(os.path.join(root, nv))
304
+ if cand_p.endswith(".md") and os.path.isfile(cand_p):
305
+ target = cand_p
306
+ break
307
+ if target:
308
+ break
309
+
310
+ # Security check on resolved existing target
311
+ if target:
312
+ valid_roots = [jail]
313
+ if project_root:
314
+ valid_roots.extend(ki_utils.get_decisions_dirs(project_root, jail))
315
+ is_safe = any(ki_utils.is_subpath(target, vr) for vr in valid_roots if vr)
316
+ if not is_safe:
317
+ raise PermissionError("Access Denied: jail breach detected.")
318
+
319
+ if is_write:
320
+ ext = os.path.splitext(target)[1].lower()
321
+ if ext in _FORBIDDEN_WRITE_EXT:
322
+ raise PermissionError(f"Access Denied: modifying {ext} files is forbidden.")
323
+ if os.path.basename(target).lower() in _FORBIDDEN_WRITE_FILES:
324
+ raise PermissionError("Access Denied: use dedicated tools to modify this file.")
325
+
326
+ return target
327
+
328
+ # Target not found
329
+ if must_exist:
330
+ raise FileNotFoundError(f"Knowledge file '{rel_path}' not found.")
331
+
332
+ # Target does not exist, but we are resolving path for creation (e.g. write_know_file)
333
+ # If path is flat (no dir specified) and jail has knowledge folder, default to knowledge/
334
+ if not dir_name:
335
+ know_dir = os.path.join(jail, "knowledge")
336
+ if os.path.exists(know_dir) or base_name.startswith("KI_") or base_name.endswith(".ki.md"):
337
+ return validate_path(os.path.join("knowledge", base_name), is_write=is_write)
338
+
339
+ return validate_path(norm, is_write=is_write)
340
+
341
+
163
342
  # ─── Script runner ────────────────────────────────────────────────────────────
164
343
 
165
344
  def run_script(script_name: str, args: List[str] = None) -> dict:
@@ -344,7 +523,7 @@ MCP_TOOLS = [
344
523
  "inputSchema": {"type": "object", "properties": {}},
345
524
  "annotations": {
346
525
  "title": "Generate Dir Index",
347
- "readOnlyHint": False,
526
+ "readOnlyHint": True,
348
527
  "destructiveHint": False,
349
528
  "idempotentHint": True,
350
529
  },
@@ -361,7 +540,7 @@ MCP_TOOLS = [
361
540
  },
362
541
  "annotations": {
363
542
  "title": "Analyze Dependencies",
364
- "readOnlyHint": False,
543
+ "readOnlyHint": True,
365
544
  "destructiveHint": False,
366
545
  "idempotentHint": True,
367
546
  },
@@ -372,7 +551,7 @@ MCP_TOOLS = [
372
551
  "inputSchema": {"type": "object", "properties": {}},
373
552
  "annotations": {
374
553
  "title": "Analyze All Dependencies",
375
- "readOnlyHint": False,
554
+ "readOnlyHint": True,
376
555
  "destructiveHint": False,
377
556
  "idempotentHint": True,
378
557
  },
@@ -864,6 +1043,9 @@ MCP_TOOLS = [
864
1043
  facade.KI_READ_TOOL,
865
1044
  ]
866
1045
 
1046
+ # Dynamically bind ki_call and ki_mutate schemas to readOnlyHint annotations
1047
+ facade.sync_dispatch_schemas(MCP_TOOLS)
1048
+
867
1049
  _INSTRUCTIONS_TOOL = next(t for t in MCP_TOOLS if t["name"] == "ki_instructions")
868
1050
  COMPACT_TOOLS = [
869
1051
  _INSTRUCTIONS_TOOL,
@@ -1455,82 +1637,12 @@ def tool_read_file(args: dict) -> Any:
1455
1637
  if not rel_path:
1456
1638
  return "Error: 'rel_path' is required."
1457
1639
 
1458
- project_root = get_project_root()
1459
- jail = get_jail_dir()
1460
-
1461
- target = None
1462
- norm = ki_utils.normalize_path(rel_path, make_absolute=False)
1463
- base_name = os.path.basename(norm)
1464
-
1465
- # 1. Try resolving within jail (knowledge root from config, default .ki-base)
1466
- if jail:
1467
- # 1a. Direct path inside jail
1468
- try:
1469
- cand = validate_path(norm)
1470
- if os.path.exists(cand) and os.path.isfile(cand):
1471
- target = cand
1472
- except Exception:
1473
- target = None
1474
-
1475
- # 1b. Try inside jail/knowledge/ (standard structure: <jail>/knowledge/<file>)
1476
- if not target:
1477
- try:
1478
- cand = validate_path(os.path.join("knowledge", norm))
1479
- if os.path.exists(cand) and os.path.isfile(cand):
1480
- target = cand
1481
- except Exception:
1482
- target = None
1483
-
1484
- # 1c. If rel_path starts with the knowledge_root folder name itself (e.g. '.ki-base/...' or 'knowledge/...'),
1485
- # strip the prefix and resolve inside jail
1486
- if not target and project_root:
1487
- rel_to_proj = os.path.relpath(jail, project_root).replace("\\", "/")
1488
- if norm.startswith(rel_to_proj + "/"):
1489
- sub_path = norm[len(rel_to_proj) + 1:]
1490
- try:
1491
- cand = validate_path(sub_path)
1492
- if os.path.exists(cand) and os.path.isfile(cand):
1493
- target = cand
1494
- except Exception:
1495
- pass
1496
- if not target:
1497
- try:
1498
- cand = validate_path(os.path.join("knowledge", sub_path))
1499
- if os.path.exists(cand) and os.path.isfile(cand):
1500
- target = cand
1501
- except Exception:
1502
- pass
1503
-
1504
- # 2. Try resolving ADR or path relative to project_root
1505
- if not target and project_root:
1506
- candidates = ki_utils.get_decisions_dirs(project_root, jail)
1507
- # Check by basename in decisions dirs
1508
- for d in candidates:
1509
- cand_p = os.path.abspath(os.path.join(d, base_name))
1510
- if os.path.exists(cand_p) and cand_p.endswith(".md") and os.path.isfile(cand_p):
1511
- target = cand_p
1512
- break
1513
- if not target:
1514
- # Check relative to project root, ensuring it is within decisions or jail
1515
- cand_p = os.path.abspath(os.path.join(project_root, norm))
1516
- if os.path.exists(cand_p) and cand_p.endswith(".md") and os.path.isfile(cand_p):
1517
- valid_prefixes = candidates + ([jail] if jail else [])
1518
- for vp in valid_prefixes:
1519
- if os.path.normcase(cand_p).startswith(os.path.normcase(vp)):
1520
- target = cand_p
1521
- break
1522
-
1523
- # 3. Fallback: Search by basename in jail (knowledge root) and its subfolders
1524
- if not target and jail and os.path.exists(jail):
1525
- for root, _, files in os.walk(jail):
1526
- if base_name in files:
1527
- cand_p = os.path.abspath(os.path.join(root, base_name))
1528
- if cand_p.endswith(".md") and os.path.isfile(cand_p):
1529
- target = cand_p
1530
- break
1531
-
1532
- if not target or not os.path.exists(target):
1640
+ try:
1641
+ target = resolve_know_path(rel_path, must_exist=True)
1642
+ except FileNotFoundError:
1533
1643
  return f"File not found: '{rel_path}'."
1644
+ except Exception as e:
1645
+ return f"Error: {e}"
1534
1646
 
1535
1647
  try:
1536
1648
  with open(target, "r", encoding="utf-8", errors="replace") as f:
@@ -1584,10 +1696,14 @@ def handle_tool_call(name: str, args: dict) -> Any:
1584
1696
  if doc == "overview":
1585
1697
  return facade.render_instruction(GLOBAL_INSTRUCTIONS, CURRENT_TOOL_MODE, know_name)
1586
1698
  elif doc == "knowledge-items":
1587
- return f"Knowledge Items for this project:\n\n{ki_utils.get_ki_list_table()}"
1699
+ ki_table = ki_utils.get_ki_list_table()
1700
+ adr_table = ki_utils.get_adr_table()
1701
+ if adr_table and not adr_table.startswith("No ADRs found"):
1702
+ return f"Knowledge Items for this project:\n\n{ki_table}\n\nArchitecture Decision Records (ADRs):\n\n{adr_table}"
1703
+ return f"Knowledge Items for this project:\n\n{ki_table}"
1588
1704
  else:
1589
- wf_path = _WORKFLOWS_DIR / f"{doc}.md"
1590
- if wf_path.exists():
1705
+ wf_path = (_WORKFLOWS_DIR / f"{doc}.md").resolve()
1706
+ if ki_utils.is_subpath(str(wf_path), str(_WORKFLOWS_DIR)) and wf_path.exists():
1591
1707
  with open(wf_path, "r", encoding="utf-8") as f:
1592
1708
  raw = f.read()
1593
1709
  return facade.render_instruction(raw, CURRENT_TOOL_MODE, know_name)
@@ -1604,15 +1720,19 @@ def handle_tool_call(name: str, args: dict) -> Any:
1604
1720
  from scaffold import init_project
1605
1721
  return init_project(args)
1606
1722
 
1607
- # ── Registry ──
1723
+ # ─── Registry ───
1608
1724
  if name == "ki_migrate_project":
1725
+ sys.path.insert(0, str(_PACKAGE_DIR / "tools"))
1726
+ import scaffold
1609
1727
  return scaffold.migrate_project(str(get_project_root()))
1610
1728
  if name == "ki_register_project":
1611
1729
  config_path = args.get("config_path", "")
1730
+ workspace = args.get("workspace")
1731
+ inline_config = args.get("inline_config")
1612
1732
  # Accept directory too
1613
1733
  if config_path and os.path.isdir(ki_utils.normalize_path(config_path)):
1614
1734
  config_path = os.path.join(ki_utils.normalize_path(config_path), "ki_config.json")
1615
- ok, msg = ki_utils.register_project(config_path)
1735
+ ok, msg = ki_utils.register_project(config_path=config_path, workspace=workspace, inline_config=inline_config)
1616
1736
  return msg
1617
1737
  if name == "ki_list_projects":
1618
1738
  reg = ki_utils.load_registry()
@@ -1628,37 +1748,85 @@ def handle_tool_call(name: str, args: dict) -> Any:
1628
1748
  if name == "ki_prune_registry":
1629
1749
  reg = ki_utils.load_registry()
1630
1750
  before = len(reg["projects"])
1631
- reg["projects"] = {k: v for k, v in reg["projects"].items()
1632
- if os.path.exists(v["config_path"])}
1751
+ surviving = {}
1752
+ for k, v in reg["projects"].items():
1753
+ cfg_p = v.get("config_path")
1754
+ if cfg_p and os.path.exists(cfg_p):
1755
+ surviving[k] = v
1756
+ elif not cfg_p and os.path.exists(k):
1757
+ surviving[k] = v
1758
+ reg["projects"] = surviving
1633
1759
  ki_utils.save_registry(reg)
1634
1760
  return f"Pruned {before - len(reg['projects'])} stale project(s)."
1635
1761
 
1636
- # ── Coverage / Analysis ──
1762
+ # ─── Coverage / Analysis ───
1637
1763
  if name == "audit_coverage":
1638
1764
  return run_script("audit_coverage.py")
1639
1765
 
1640
1766
  if name == "generate_dir_index":
1641
1767
  return run_script("generate_dir_index.py")
1642
1768
  if name == "update_last_verified":
1643
- import datetime, glob, re as re_mod
1769
+ import datetime
1644
1770
  jail = get_jail_dir()
1645
- if not jail: return "Error: No project."
1771
+ if not jail:
1772
+ return "Error: No project."
1646
1773
  today = datetime.datetime.now().strftime("%Y-%m-%d")
1647
1774
  count = 0
1648
- for filepath in glob.glob(os.path.join(jail, "knowledge", "*.md")):
1649
- with open(filepath, "r", encoding="utf-8") as f:
1650
- content = f.read()
1651
- new_content = re_mod.sub(r'^(last_verified:\s*)"?\d{4}-\d{2}-\d{2}"?', rf'\g<1>"{today}"', content, flags=re_mod.MULTILINE)
1652
- if new_content != content:
1653
- import tempfile
1654
- fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(filepath), text=True)
1655
- try:
1656
- with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
1657
- f.write(new_content)
1658
- os.replace(tmp_path, filepath)
1659
- count += 1
1660
- except Exception:
1661
- if os.path.exists(tmp_path): os.remove(tmp_path)
1775
+ know_dir = os.path.join(jail, "knowledge")
1776
+ if os.path.exists(know_dir):
1777
+ for fname in os.listdir(know_dir):
1778
+ if not ki_utils.is_ki_filename(fname, include_overview=True):
1779
+ continue
1780
+ filepath = os.path.join(know_dir, fname)
1781
+ if not os.path.isfile(filepath):
1782
+ continue
1783
+ with open(filepath, "r", encoding="utf-8") as f:
1784
+ content = f.read()
1785
+
1786
+ new_content = content
1787
+ new_content = re.sub(
1788
+ r'<!--\s*last_verified:\s*"?\d{4}-\d{2}-\d{2}"?\s*-->',
1789
+ f'<!-- last_verified: {today} -->',
1790
+ new_content,
1791
+ flags=re.IGNORECASE
1792
+ )
1793
+ new_content = re.sub(
1794
+ r'^(last_verified:\s*)"?\d{4}-\d{2}-\d{2}"?',
1795
+ rf'\g<1>"{today}"',
1796
+ new_content,
1797
+ flags=re.MULTILINE
1798
+ )
1799
+ new_content = re.sub(
1800
+ r'(\*\*Last verified\*\*:\s*)"?\d{4}-\d{2}-\d{2}"?',
1801
+ rf'\g<1>{today}',
1802
+ new_content,
1803
+ flags=re.IGNORECASE
1804
+ )
1805
+
1806
+ if new_content == content:
1807
+ lines = content.splitlines(keepends=True)
1808
+ res_lines = []
1809
+ inserted = False
1810
+ for line in lines:
1811
+ res_lines.append(line)
1812
+ if not inserted and line.startswith("# "):
1813
+ res_lines.append(f"\n<!-- last_verified: {today} -->\n")
1814
+ inserted = True
1815
+ if not inserted:
1816
+ res_lines.insert(0, f"<!-- last_verified: {today} -->\n\n")
1817
+ new_content = "".join(res_lines)
1818
+
1819
+ if new_content != content:
1820
+ import tempfile
1821
+ fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(filepath), text=True)
1822
+ try:
1823
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
1824
+ f.write(new_content)
1825
+ os.replace(tmp_path, filepath)
1826
+ count += 1
1827
+ except Exception:
1828
+ if os.path.exists(tmp_path):
1829
+ os.remove(tmp_path)
1662
1830
  return f"Updated last_verified in {count} files."
1663
1831
  if name == "analyze_all_dependencies":
1664
1832
  return run_script("ki_dependency_analyzer.py", ["--all"])
@@ -1730,7 +1898,8 @@ def handle_tool_call(name: str, args: dict) -> Any:
1730
1898
 
1731
1899
  # ─── File Ops ───
1732
1900
  if name == "read_know_file":
1733
- with open(validate_path(args["rel_path"]), "r", encoding="utf-8") as f:
1901
+ p = resolve_know_path(args["rel_path"], must_exist=True)
1902
+ with open(p, "r", encoding="utf-8") as f:
1734
1903
  content = f.read()
1735
1904
  offset = 0
1736
1905
  if args.get("offset") is not None:
@@ -1754,7 +1923,7 @@ def handle_tool_call(name: str, args: dict) -> Any:
1754
1923
  return content[offset:]
1755
1924
  return content
1756
1925
  if name == "write_know_file":
1757
- p = validate_path(args["rel_path"], is_write=True)
1926
+ p = resolve_know_path(args["rel_path"], is_write=True, must_exist=False)
1758
1927
  os.makedirs(os.path.dirname(p), exist_ok=True)
1759
1928
  import tempfile
1760
1929
  fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(p), text=True)
@@ -1768,7 +1937,7 @@ def handle_tool_call(name: str, args: dict) -> Any:
1768
1937
  raise e
1769
1938
  return "File written."
1770
1939
  if name == "edit_know_file":
1771
- p = validate_path(args["rel_path"], is_write=True)
1940
+ p = resolve_know_path(args["rel_path"], is_write=True, must_exist=True)
1772
1941
  with open(p, "r", encoding="utf-8") as f:
1773
1942
  content = f.read()
1774
1943
  old_text = args["old_text"].replace("\r\n", "\n")
@@ -1904,6 +2073,15 @@ def _write_ide_instructions() -> None:
1904
2073
  instructions_path.write_text(content, encoding="utf-8")
1905
2074
  safe_log(f"Wrote instructions.md → {instructions_path}")
1906
2075
 
2076
+ active_tool_names = {t["name"] for t in tools_to_write}
2077
+ for existing_json in target_dir.glob("*.json"):
2078
+ if existing_json.stem not in active_tool_names:
2079
+ try:
2080
+ existing_json.unlink()
2081
+ safe_log(f"Removed inactive tool schema: {existing_json.name}")
2082
+ except Exception:
2083
+ pass
2084
+
1907
2085
  # Sync tool schema JSON files if target is an IDE mcp tool directory
1908
2086
  for tool in tools_to_write:
1909
2087
  try:
@@ -1921,7 +2099,7 @@ def _write_ide_instructions() -> None:
1921
2099
 
1922
2100
 
1923
2101
  def _extract_workspace_from_meta(params: dict) -> Optional[str]:
1924
- """Извлекает workspace URI из _meta нового протокола (2026-07-28)."""
2102
+ """Извлекает workspace URI из _meta нового протокола (2026-07-28)."""
1925
2103
  meta = params.get("_meta", {})
1926
2104
  client_info = meta.get("io.modelcontextprotocol/clientInfo", {})
1927
2105
  return client_info.get("workspaceUri") or client_info.get("rootUri")
@@ -1964,6 +2142,7 @@ def main():
1964
2142
  root_uri = roots[0].get("uri")
1965
2143
  if root_uri:
1966
2144
  ki_utils.ACTIVE_WORKSPACE_PATH = ki_utils.normalize_path(root_uri)
2145
+ ki_utils._CACHE.clear()
1967
2146
  safe_log(f"SET workspace via roots/list: {ki_utils.ACTIVE_WORKSPACE_PATH}")
1968
2147
  continue
1969
2148
 
@@ -1971,24 +2150,22 @@ def main():
1971
2150
  method = req.get("method")
1972
2151
  params = req.get("params", {})
1973
2152
 
1974
- # Новый протокол: извлекаем workspace из _meta при каждом запросе
1975
- if not ki_utils.ACTIVE_WORKSPACE_PATH:
1976
- _ws = _extract_workspace_from_meta(params)
1977
- if _ws:
1978
- ki_utils.ACTIVE_WORKSPACE_PATH = ki_utils.normalize_path(_ws)
2153
+ # Извлекаем workspace из _meta при каждом запросе
2154
+ _ws = _extract_workspace_from_meta(params)
2155
+ if _ws:
2156
+ norm_ws = ki_utils.normalize_path(_ws)
2157
+ if norm_ws != ki_utils.ACTIVE_WORKSPACE_PATH:
2158
+ ki_utils.ACTIVE_WORKSPACE_PATH = norm_ws
2159
+ ki_utils._CACHE.clear()
1979
2160
  safe_log(f"SET workspace via _meta: {ki_utils.ACTIVE_WORKSPACE_PATH}")
1980
2161
 
1981
2162
  def send(result_data):
1982
2163
  resp = json.dumps({"jsonrpc": "2.0", "id": rid, "result": result_data})
1983
- safe_log(f"DEBUG: Before write RESP")
1984
2164
  safe_log(f"RESP: {resp}")
1985
2165
  sys.stdout.write(resp + "\n")
1986
- safe_log(f"DEBUG: Before flush RESP")
1987
2166
  sys.stdout.flush()
1988
- safe_log(f"DEBUG: After flush RESP")
1989
2167
 
1990
2168
  if method in ("initialize", "server/discover"):
1991
- safe_log(f"DEBUG: Inside method {method}")
1992
2169
  # Extract workspace URI from any location in params
1993
2170
  root_uri = params.get("rootUri")
1994
2171
  if not root_uri:
@@ -2012,20 +2189,16 @@ def main():
2012
2189
  return d
2013
2190
  root_uri = _find_uri(params)
2014
2191
 
2015
- safe_log(f"DEBUG: After _find_uri. root_uri={root_uri}")
2016
2192
  if root_uri:
2017
2193
  ki_utils.ACTIVE_WORKSPACE_PATH = ki_utils.normalize_path(root_uri)
2194
+ ki_utils._CACHE.clear()
2018
2195
  safe_log(f"SET workspace via initialize: {ki_utils.ACTIVE_WORKSPACE_PATH}")
2019
2196
 
2020
- safe_log(f"DEBUG: Before importlib")
2021
2197
  try:
2022
- server_version = importlib.metadata.version("ki-manager")
2023
- except importlib.metadata.PackageNotFoundError:
2024
- server_version = "2.0.11"
2025
- except Exception as ex:
2026
- safe_log(f"DEBUG: importlib EXCEPTION: {ex}")
2027
- server_version = "2.0.11"
2028
- safe_log(f"DEBUG: After importlib. version={server_version}")
2198
+ import ki_manager
2199
+ server_version = getattr(ki_manager, "__version__", importlib.metadata.version("ki-manager"))
2200
+ except Exception:
2201
+ server_version = "2.3.0"
2029
2202
 
2030
2203
  know_name = os.path.basename(ki_utils.get_knowledge_root()) or ".ki-base"
2031
2204
  resp = {
@@ -2037,7 +2210,6 @@ def main():
2037
2210
  if method == "server/discover":
2038
2211
  resp["versions"] = ["2026-07-28", "2025-11-25", "2024-11-05"]
2039
2212
  send(resp)
2040
- safe_log(f"DEBUG: After send() for {method}")
2041
2213
 
2042
2214
  elif method == "notifications/initialized":
2043
2215
  # Request roots from client for workspace detection
@@ -2047,17 +2219,18 @@ def main():
2047
2219
  sys.stdout.flush()
2048
2220
 
2049
2221
  elif method == "tools/list":
2222
+ facade.sync_dispatch_schemas(MCP_TOOLS)
2050
2223
  tools_list = COMPACT_TOOLS if tool_mode == "compact" else MCP_TOOLS
2051
2224
  send({"tools": tools_list, "ttlMs": 300000, "cacheScope": "global"})
2052
2225
 
2053
2226
  elif method == "tools/call":
2054
2227
  tool_name = params["name"]
2055
2228
  tool_args = params.get("arguments", {})
2056
- # Fallback: пробуем взять workspace из аргументов инструмента
2057
2229
  if not ki_utils.ACTIVE_WORKSPACE_PATH:
2058
2230
  _ws_arg = tool_args.get("path") or tool_args.get("project_path")
2059
2231
  if _ws_arg:
2060
2232
  ki_utils.ACTIVE_WORKSPACE_PATH = ki_utils.normalize_path(_ws_arg)
2233
+ ki_utils._CACHE.clear()
2061
2234
  safe_log(f"SET workspace via tool args: {ki_utils.ACTIVE_WORKSPACE_PATH}")
2062
2235
  result = handle_tool_call(tool_name, tool_args)
2063
2236
  if isinstance(result, dict) and "content" in result:
@@ -2079,10 +2252,15 @@ def main():
2079
2252
  if not match:
2080
2253
  content = "No registered project. Run ki_init_project first."
2081
2254
  else:
2082
- content = f"Knowledge Items for this project:\n\n{ki_utils.get_ki_list_table()}"
2255
+ ki_table = ki_utils.get_ki_list_table()
2256
+ adr_table = ki_utils.get_adr_table()
2257
+ if adr_table and not adr_table.startswith("No ADRs found"):
2258
+ content = f"Knowledge Items for this project:\n\n{ki_table}\n\nArchitecture Decision Records (ADRs):\n\n{adr_table}"
2259
+ else:
2260
+ content = f"Knowledge Items for this project:\n\n{ki_table}"
2083
2261
  else:
2084
- wf_path = _WORKFLOWS_DIR / f"{prompt_name}.md"
2085
- if wf_path.exists():
2262
+ wf_path = (_WORKFLOWS_DIR / f"{prompt_name}.md").resolve()
2263
+ if ki_utils.is_subpath(str(wf_path), str(_WORKFLOWS_DIR)) and wf_path.exists():
2086
2264
  with open(wf_path, "r", encoding="utf-8") as f:
2087
2265
  raw = f.read()
2088
2266
  know_name = os.path.basename(ki_utils.get_knowledge_root()) or ".ki-base"
@@ -2142,7 +2320,12 @@ def main():
2142
2320
  know_name = os.path.basename(ki_utils.get_knowledge_root()) or ".ki-base"
2143
2321
  content = facade.render_instruction(GLOBAL_INSTRUCTIONS, CURRENT_TOOL_MODE, know_name)
2144
2322
  elif uri == "ki://knowledge-items.md":
2145
- content = f"Knowledge Items for this project:\n\n{ki_utils.get_ki_list_table()}"
2323
+ ki_table = ki_utils.get_ki_list_table()
2324
+ adr_table = ki_utils.get_adr_table()
2325
+ if adr_table and not adr_table.startswith("No ADRs found"):
2326
+ content = f"Knowledge Items for this project:\n\n{ki_table}\n\nArchitecture Decision Records (ADRs):\n\n{adr_table}"
2327
+ else:
2328
+ content = f"Knowledge Items for this project:\n\n{ki_table}"
2146
2329
  elif uri == "ki://adr-list.md":
2147
2330
  content = get_adr_list(get_project_root(), jail) if jail else "No active project."
2148
2331
  elif uri in ("ki://workflows/", "ki://workflows"):
@@ -2157,8 +2340,8 @@ def main():
2157
2340
  wf_name = uri.replace("ki://workflows/", "")
2158
2341
  if not wf_name.endswith(".md"):
2159
2342
  wf_name += ".md"
2160
- wf_path = _WORKFLOWS_DIR / wf_name
2161
- if wf_path.exists():
2343
+ wf_path = (_WORKFLOWS_DIR / wf_name).resolve()
2344
+ if ki_utils.is_subpath(str(wf_path), str(_WORKFLOWS_DIR)) and wf_path.exists():
2162
2345
  with open(wf_path, "r", encoding="utf-8") as f:
2163
2346
  raw = f.read()
2164
2347
  know_name = os.path.basename(ki_utils.get_knowledge_root()) or ".ki-base"
@@ -2167,10 +2350,13 @@ def main():
2167
2350
  # Physical resources in jail
2168
2351
  elif jail and uri.startswith("ki://"):
2169
2352
  fname = uri.replace("ki://", "")
2170
- fpath = os.path.join(jail, fname)
2171
- if os.path.exists(fpath):
2172
- with open(fpath, "r", encoding="utf-8") as f:
2173
- content = f.read()
2353
+ try:
2354
+ fpath = validate_path(fname, is_write=False)
2355
+ if os.path.exists(fpath) and os.path.isfile(fpath):
2356
+ with open(fpath, "r", encoding="utf-8") as f:
2357
+ content = f.read()
2358
+ except Exception:
2359
+ content = None
2174
2360
 
2175
2361
  if content is not None:
2176
2362
  send({"contents": [{"uri": uri, "mimeType": "text/plain", "text": content}]})
@@ -244,6 +244,46 @@ KI_MUTATE_TOOL = {
244
244
  }
245
245
 
246
246
 
247
+ # Direct facade tools that cannot be called recursively via ki_call / ki_mutate
248
+ DIRECT_FACADE_TOOLS = {"ki_instructions", "ki_search", "ki_read", "ki_tools", "ki_call", "ki_mutate"}
249
+
250
+
251
+ def sync_dispatch_schemas(all_tools: List[Dict[str, Any]]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
252
+ """Dynamically binds KI_CALL_TOOL and KI_MUTATE_TOOL schemas and descriptions
253
+ directly to the readOnlyHint annotations of all registered tools."""
254
+ ro_tools = []
255
+ mut_tools = []
256
+
257
+ for t in all_tools:
258
+ name = t.get("name")
259
+ if not name or name in DIRECT_FACADE_TOOLS:
260
+ continue
261
+ is_ro = t.get("annotations", {}).get("readOnlyHint", False)
262
+ if is_ro:
263
+ ro_tools.append(name)
264
+ else:
265
+ mut_tools.append(name)
266
+
267
+ ro_tools.sort()
268
+ mut_tools.sort()
269
+
270
+ KI_CALL_TOOL["description"] = (
271
+ "Execute a read-only ki-manager tool. "
272
+ f"Available tools: {', '.join(ro_tools)}. "
273
+ "Use ki_tools(name=...) to check arguments. For mutating operations, use ki_mutate."
274
+ )
275
+ KI_CALL_TOOL["inputSchema"]["properties"]["tool"]["enum"] = ro_tools
276
+
277
+ KI_MUTATE_TOOL["description"] = (
278
+ "Execute a state-modifying ki-manager tool. "
279
+ f"Available tools: {', '.join(mut_tools)}. "
280
+ "Use ki_tools(name=...) to check arguments."
281
+ )
282
+ KI_MUTATE_TOOL["inputSchema"]["properties"]["tool"]["enum"] = mut_tools
283
+
284
+ return KI_CALL_TOOL, KI_MUTATE_TOOL
285
+
286
+
247
287
  # ─── Helper Functions ────────────────────────────────────────────────────────
248
288
 
249
289
  def format_tools_catalog(all_tools: List[Dict[str, Any]], group_filter: Optional[str] = None) -> str:
@@ -280,7 +320,7 @@ def format_tools_catalog(all_tools: List[Dict[str, Any]], group_filter: Optional
280
320
 
281
321
 
282
322
  def validate_args_against_schema(schema: Dict[str, Any], args: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
283
- """Simple schema validator checking required fields and basic types."""
323
+ """Simple schema validator checking required fields, enum choices, and basic types."""
284
324
  properties = schema.get("properties", {})
285
325
  required = schema.get("required", [])
286
326
 
@@ -307,6 +347,10 @@ def validate_args_against_schema(schema: Dict[str, Any], args: Dict[str, Any]) -
307
347
  return False, f"Parameter '{key}' must be integer, got boolean."
308
348
  if not isinstance(val, expected_type):
309
349
  return False, f"Parameter '{key}' must be of type {expected_type_str}, got {type(val).__name__}."
350
+ if "enum" in properties[key]:
351
+ allowed = properties[key]["enum"]
352
+ if val not in allowed:
353
+ return False, f"Parameter '{key}' value '{val}' is not one of {allowed}."
310
354
 
311
355
  return True, None
312
356
 
@@ -42,13 +42,14 @@ KI_CONFIG_TEMPLATE = {
42
42
  "knowledge_system": {
43
43
  "mcp_server": {
44
44
  "name": "ki-manager",
45
- "version": "2.0.0"
45
+ "version": "2.3.0"
46
46
  }
47
47
  },
48
48
  "required_sections": ["Overview", "Key Components", "Related KIs"]
49
49
  }
50
50
 
51
51
  OVERVIEW_KI_TEMPLATE = """\
52
+ <!-- last_verified: {date} -->
52
53
  # _OVERVIEW — Project Overview
53
54
 
54
55
  **Module**: `{project_name}` (entire project)
@@ -126,10 +127,10 @@ def write_text(path: Path, content: str):
126
127
  f.write(content)
127
128
 
128
129
 
129
- def update_gitignore(project_root: Path):
130
- """Append .ki-base/config.json to the project .gitignore if not already present."""
130
+ def update_gitignore(project_root: Path, know_name: str = ".ki-base"):
131
+ """Append <know_name>/config.json to the project .gitignore if not already present."""
131
132
  gitignore = project_root / ".gitignore"
132
- line = ".ki-base/config.json"
133
+ line = f"{know_name}/config.json"
133
134
  if gitignore.exists():
134
135
  content = gitignore.read_text(encoding="utf-8")
135
136
  if line in content:
@@ -221,7 +222,7 @@ def init_project(args: dict) -> str:
221
222
  log.append(f"[+] Created {know_name}/knowledge/_OVERVIEW.ki.md")
222
223
 
223
224
  if cfg_loc != "root":
224
- update_gitignore(project_path)
225
+ update_gitignore(project_path, know_name=know_name)
225
226
 
226
227
  success, msg = ki_utils.register_project(config_path=str(ki_config_path), workspace=str(project_path))
227
228
  if success:
@@ -290,7 +291,7 @@ def migrate_project(project_root: str) -> str:
290
291
  overview_path = knowledge_dir / "_OVERVIEW.ki.md"
291
292
  if not overview_path.exists():
292
293
  try:
293
- today = datetime.date.today().isoformat()
294
+ today = datetime.now().strftime("%Y-%m-%d")
294
295
  project_name = root.name
295
296
  if doc_config_path.exists():
296
297
  try:
@@ -139,10 +139,10 @@ Write the file using `write_know_file` or `edit_know_file`:
139
139
  Register the new KI in `doc_config.json`:
140
140
  <!-- if-compact -->
141
141
  // turbo
142
- `ki_mutate(tool="add_ki_to_config", args={"ki_name": "<filename>.ki.md", "files": ["path/to/tracked/file.py"], "summary": "<Concise summary>"})`
142
+ `ki_mutate(tool="add_ki_to_config", args={"ki_name": "<filename>.ki.md", "description": "<Full description>", "depends_on": ["path/to/tracked/file.py"], "summary": "<Concise summary>"})`
143
143
  <!-- else-compact -->
144
144
  // turbo
145
- `add_ki_to_config(ki_name="<filename>.ki.md", files=["path/to/tracked/file.py"], summary="<Concise summary>")`
145
+ `add_ki_to_config(ki_name="<filename>.ki.md", description="<Full description>", depends_on=["path/to/tracked/file.py"], summary="<Concise summary>")`
146
146
  <!-- /if-compact -->
147
147
 
148
148
  ---
@@ -155,7 +155,7 @@ Automatically identify and link related KIs based on code imports:
155
155
  <!-- if-compact -->
156
156
  `ki_call(tool="analyze_dependencies", args={"ki_name": "<filename>.ki.md", "only_changed": false})`
157
157
  <!-- else-compact -->
158
- `analyze_dependencies(args={"ki_name": "<filename>.ki.md", "only_changed": false})`
158
+ `analyze_dependencies(ki_name="<filename>.ki.md", only_changed=false)`
159
159
  <!-- /if-compact -->
160
160
 
161
161
  ---
@@ -188,7 +188,7 @@ Finalize the expansion by creating a git snapshot of the knowledge state.
188
188
  <!-- if-compact -->
189
189
  `ki_mutate(tool="git_checkpoint", args={"message": "Expand knowledge base: new KI registration"})`
190
190
  <!-- else-compact -->
191
- `git_checkpoint(args={"message": "Expand knowledge base: new KI registration"})`
191
+ `git_checkpoint(message="Expand knowledge base: new KI registration")`
192
192
  <!-- /if-compact -->
193
193
 
194
194
  ---
@@ -100,7 +100,7 @@ Update inter-KI links ONLY for modified KIs to minimize Git noise.
100
100
  <!-- if-compact -->
101
101
  `ki_call(tool="analyze_dependencies", args={"only_changed": true})`
102
102
  <!-- else-compact -->
103
- `analyze_dependencies(args={"only_changed": true})`
103
+ `analyze_dependencies(only_changed=true)`
104
104
  <!-- /if-compact -->
105
105
 
106
106
  > [!NOTE]
@@ -114,5 +114,5 @@ Finalize the synchronization by creating a git snapshot of the knowledge state.
114
114
  <!-- if-compact -->
115
115
  `ki_mutate(tool="git_checkpoint", args={"message": "Sync knowledge system state"})`
116
116
  <!-- else-compact -->
117
- `git_checkpoint(args={"message": "Sync knowledge system state"})`
117
+ `git_checkpoint(message="Sync knowledge system state")`
118
118
  <!-- /if-compact -->
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ki-manager
3
- Version: 2.3.0.dev3
3
+ Version: 2.3.1.dev1
4
4
  Summary: Knowledge Item (KI) management MCP server for AI-assisted development
5
5
  Project-URL: Homepage, https://github.com/Laeryid/KI-base
6
6
  Project-URL: Repository, https://github.com/Laeryid/KI-base
@@ -0,0 +1,31 @@
1
+ ki_manager/__init__.py,sha256=MAN4L7RnYO2ztV2ZzLJdo7eI_mXUiEYRT60LTrll54g,104
2
+ ki_manager/cli.py,sha256=sQZQDYh4Z8GOqQlT4Y2X60PZK27jSnhHhv5tW-RR7f0,4752
3
+ ki_manager/server.py,sha256=tq90-lKx3mBHuM5MAOhbeGp8lAHqyuBVAh_8sYrrRLk,105625
4
+ ki_manager/scripts/add_ki_to_config.py,sha256=v7YhEVRpbD4U2x6FuGKuX3ZDp6CbXp8VnsoqWX9SJh8,2527
5
+ ki_manager/scripts/analyze_module.py,sha256=UX2M4rrilLkztcMkDliOPeBDcRiUDUCo6f3SOqCEbd0,3945
6
+ ki_manager/scripts/audit_coverage.py,sha256=O15QCSmBByB-yMo8C5debq_QGUr5NCA8EMDlzke1Y5s,16585
7
+ ki_manager/scripts/check_ki_drift.py,sha256=3BP5XVWyHH-Np96CTQD_lLI4QRL_ba48RJ3-WhRpB04,14036
8
+ ki_manager/scripts/finalize_ki_scaffolds.py,sha256=0BSr6J0WtMKpywvCzWKfesqkDMDbpHfFN5FYqAo_vDM,5784
9
+ ki_manager/scripts/find_unmapped_files.py,sha256=sNuUiNamQL5lYHXNBogfEjXe-CFJ3j98xURnIIkCKbs,3481
10
+ ki_manager/scripts/generate_dir_index.py,sha256=ODIgA7QxhdBMLViRTk5CK4vv5XrZcqSoCz1KrChTWPo,5381
11
+ ki_manager/scripts/generate_ki_scaffolds.py,sha256=LzvTMu13QySVdxvnOEdGXlVp6xDP0Nxb-VU9ShFdb88,27097
12
+ ki_manager/scripts/git_diff_secured.py,sha256=zTTmqGshPRALwYgU8TIeCU7W4FbhcHz9MlCB-f7cCnk,2662
13
+ ki_manager/scripts/ki_dependency_analyzer.py,sha256=378pSZZX_nAAoh9QTFb_A5yqdKFfp8G7iqis0dJu6eg,9794
14
+ ki_manager/scripts/ki_graph_visualize.py,sha256=3Xg6WrWliREeCUxKjmeivBYT56lFBbC3Jot7uD2e81g,9102
15
+ ki_manager/scripts/ki_search.py,sha256=YAHkPKWx3AVh6n1ivsaHu_0-QowoqpyAwUtZ-LcBrnM,11670
16
+ ki_manager/scripts/ki_utils.py,sha256=L056zbYD88MI789Zj-s8kIUZKGTeF5dbKEy9mbjUAUs,20909
17
+ ki_manager/scripts/knowledge_engine.py,sha256=H81WiwSnQh-fXVEakzAUXNcxs8JhYamBFUxAqdnOTSM,9862
18
+ ki_manager/scripts/sync_agents_md.py,sha256=0Lbx8MidQXmysw4HHrnighXMemsmIp4CAqvnbVY7_WE,5982
19
+ ki_manager/templates/KI_template.md,sha256=s7GKEqt5PlmOZ7V_IytU6h0viTedW_ZKeJ8w_t3wJFA,875
20
+ ki_manager/tools/__init__.py,sha256=8pDeHnkj6ZAFI4grP9Exz-Jah99PVUGnuagiCbcVBQY,49
21
+ ki_manager/tools/facade.py,sha256=gLKMYK4r7dtY5QsTXxemM-_EPX-Br1QfAPTQrb9rO-M,15402
22
+ ki_manager/tools/scaffold.py,sha256=GQNJjZl2-YPAW4ykBZQpj7goE39jcakcODlw6Eqh2uQ,11195
23
+ ki_manager/workflows/create-adr.md,sha256=dx5uX5UFnxuztXXEf13JQU6FEZfMcKWQf2_g1yIPPrQ,5084
24
+ ki_manager/workflows/expand-knowledge.md,sha256=cfa4WOj2rUNlSTVmLmyhij_OHxRp1HUSLvBUeBjjNjs,6448
25
+ ki_manager/workflows/scaffold-knowledge.md,sha256=xhnNQeKu0yzJrPIrnLXzFbSSwk1v8H2CzAymiwqKUNc,8284
26
+ ki_manager/workflows/sync-knowledge.md,sha256=wyYbVeYWy0autJCK34eOlyyPeoW0Ks1hAEkHjvmrTU4,4255
27
+ ki_manager-2.3.1.dev1.dist-info/METADATA,sha256=oa1BgfRjEHnVWOSE4ehi4KbMDPfXBXRtHC7yS9apnZs,11217
28
+ ki_manager-2.3.1.dev1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
29
+ ki_manager-2.3.1.dev1.dist-info/entry_points.txt,sha256=V-NMpOuZiRNc4hDwZ0AcWnEltRkc_T4WD_sUxOQf7TE,94
30
+ ki_manager-2.3.1.dev1.dist-info/licenses/LICENSE,sha256=KBvPtUXGgjjiHBWYvaNazfiZ_U-VYaqisz_yVCUfOBs,1072
31
+ ki_manager-2.3.1.dev1.dist-info/RECORD,,
@@ -1,30 +0,0 @@
1
- ki_manager/__init__.py,sha256=esGi7QFrw4zblHWHQby8EZUYq5yeHJz8PpIoA-tD4f8,104
2
- ki_manager/cli.py,sha256=sQZQDYh4Z8GOqQlT4Y2X60PZK27jSnhHhv5tW-RR7f0,4752
3
- ki_manager/server.py,sha256=-pr0mw2xjq2IR8qX3nOUZe7I9Zqzv4GFCXzaPSBQx4k,97894
4
- ki_manager/scripts/add_ki_to_config.py,sha256=v7YhEVRpbD4U2x6FuGKuX3ZDp6CbXp8VnsoqWX9SJh8,2527
5
- ki_manager/scripts/analyze_module.py,sha256=J8ZhEzRSbnCKvsmn2Ru4iOo4Vb2sA4MdkB11ibcvDns,3804
6
- ki_manager/scripts/audit_coverage.py,sha256=NJkjTVS0ZwI4Uji6EpB1bM4zVDQp_aR925z_DJm5EHY,16600
7
- ki_manager/scripts/check_ki_drift.py,sha256=pRzl86JhHEBR1-SdAbuAeOyebPdd-g5yryE1PZ-Kf5s,13993
8
- ki_manager/scripts/finalize_ki_scaffolds.py,sha256=I0Sn-zL45C_r4aHGNrkFXMjuu8ZcHcTlStbe2eiORpw,5805
9
- ki_manager/scripts/find_unmapped_files.py,sha256=3K3X0MO7DwlAUn_LVVtJYNkWNnN7zgoCh-5uffKNKuA,3405
10
- ki_manager/scripts/generate_dir_index.py,sha256=ODIgA7QxhdBMLViRTk5CK4vv5XrZcqSoCz1KrChTWPo,5381
11
- ki_manager/scripts/generate_ki_scaffolds.py,sha256=FhWarM4LfV-K_tMjqo7_2qZjGP72nnN7ehlcBeW_dnE,27118
12
- ki_manager/scripts/ki_dependency_analyzer.py,sha256=378pSZZX_nAAoh9QTFb_A5yqdKFfp8G7iqis0dJu6eg,9794
13
- ki_manager/scripts/ki_graph_visualize.py,sha256=3Xg6WrWliREeCUxKjmeivBYT56lFBbC3Jot7uD2e81g,9102
14
- ki_manager/scripts/ki_search.py,sha256=EuBIvn_2VGN3jlo99ANJBZ1JmSC3yuIl68d26xr6sWA,11730
15
- ki_manager/scripts/ki_utils.py,sha256=uqSKty5vVnwj60k9Q9V5uQzz5sihkYPsiJAK2nkWgkg,19177
16
- ki_manager/scripts/knowledge_engine.py,sha256=aV8FC2gWnF1Q8HtUDbL4P2uNZcFg01yPR51BzL6ZuMs,9742
17
- ki_manager/scripts/sync_agents_md.py,sha256=zpRzCnitS4o279d4Bqs04ybs8nR4a5TVZ-dKTInK3_w,5828
18
- ki_manager/templates/KI_template.md,sha256=s7GKEqt5PlmOZ7V_IytU6h0viTedW_ZKeJ8w_t3wJFA,875
19
- ki_manager/tools/__init__.py,sha256=8pDeHnkj6ZAFI4grP9Exz-Jah99PVUGnuagiCbcVBQY,49
20
- ki_manager/tools/facade.py,sha256=CGbdHXVX9DG5i5wUxEACUrLJeob7SBd15AijSWeFogQ,13714
21
- ki_manager/tools/scaffold.py,sha256=X_2qQGcsqZWujJNVd3W_NCK1Eos7bIaOP31loBDoXQQ,11105
22
- ki_manager/workflows/create-adr.md,sha256=dx5uX5UFnxuztXXEf13JQU6FEZfMcKWQf2_g1yIPPrQ,5084
23
- ki_manager/workflows/expand-knowledge.md,sha256=jVoABM2YmEMXRv0zA4RlPMGbQ9FMFX6QKtYCgt9Izwc,6390
24
- ki_manager/workflows/scaffold-knowledge.md,sha256=xhnNQeKu0yzJrPIrnLXzFbSSwk1v8H2CzAymiwqKUNc,8284
25
- ki_manager/workflows/sync-knowledge.md,sha256=aaAxOLmdIGIo8HmgBXzpuOeG6GwFCEBZpjIPlJZD_GM,4275
26
- ki_manager-2.3.0.dev3.dist-info/METADATA,sha256=Oz1yB00bXt5UvVzywkg_l9W8f37NGo1Wc3yMscnHMZw,11217
27
- ki_manager-2.3.0.dev3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
28
- ki_manager-2.3.0.dev3.dist-info/entry_points.txt,sha256=V-NMpOuZiRNc4hDwZ0AcWnEltRkc_T4WD_sUxOQf7TE,94
29
- ki_manager-2.3.0.dev3.dist-info/licenses/LICENSE,sha256=KBvPtUXGgjjiHBWYvaNazfiZ_U-VYaqisz_yVCUfOBs,1072
30
- ki_manager-2.3.0.dev3.dist-info/RECORD,,