ltcai 1.2.0 → 1.3.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
@@ -36,6 +36,18 @@ Automatic knowledge graph
36
36
  Graph-aware chat, snapshots, memory, agents, workflows, skills, and timeline
37
37
  ```
38
38
 
39
+ ### New in 1.3.0: Server App Decomposition
40
+
41
+ - **server_app.py decomposition** — model/engine and MCP/skills/plugins
42
+ endpoints extracted into `latticeai/api/models.py` and `latticeai/api/mcp.py`
43
+ (~5,948 → ~5,382 lines)
44
+ - **Safety validation suite** — a route-compatibility snapshot (209 paths) plus
45
+ import/startup, streaming-contract, and model/MCP/KG checks, built before the
46
+ move so no endpoint can silently change
47
+ - **Compatibility preserved** — all API paths, request/response schemas, the
48
+ `server:app` import path, CLI, UI, KG/Admin/Security routers, and VS Code
49
+ integration are unchanged
50
+
39
51
  ### New in 1.2.0: Server App Modularization
40
52
 
41
53
  - **server_app.py modularized** — Workspace/Organization and health/engine
package/docs/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.0] - 2026-05-31
4
+
5
+ > Server app decomposition (phase 3) — safety-net suite first, then model & MCP router extraction.
6
+
7
+ ### Added
8
+
9
+ - **Route-compatibility safety net** — `tests/unit/test_route_compatibility.py`
10
+ freezes the full public route surface (209 paths) plus import/startup,
11
+ streaming-contract, model/engine, and MCP/KG presence checks. Any dropped or
12
+ renamed endpoint, broken import, or removed `StreamingResponse` now fails the
13
+ suite immediately. This was built **before** moving code, per the decomposition
14
+ plan.
15
+ - **Model / engine router** — `latticeai/api/models.py` (`create_models_router`)
16
+ now owns `/models*`, `/engines*` (install / verify-cloud / pull-model /
17
+ prepare-model[/stream]) and `/setup/set-api-key`. Heavy provider/runtime
18
+ helpers remain injected from server_app (no import cycle, no new import-time
19
+ side effects).
20
+ - **MCP / skills / plugins router** — `latticeai/api/mcp.py` (`create_mcp_router`)
21
+ now owns `/mcp/*`, `/skills/*`, `/plugins/directory*`, and `/mcp/call`.
22
+ Registry/tool symbols are imported directly from `mcp_registry` / `tools` /
23
+ `tool_registry`; server_app-defined helpers are injected.
24
+
25
+ ### Changed
26
+
27
+ - **server_app.py decomposition** — reduced from ~5,948 to ~5,382 lines by
28
+ extracting the model/engine and MCP/skills/plugins clusters (and their
29
+ request models) into the routers above. All API paths, request/response
30
+ schemas, the `server:app` import path, CLI, UI, KG/Admin/Security routers, and
31
+ VS Code integration are unchanged (asserted by the route snapshot test).
32
+ - Release metadata aligned to `1.3.0`; `/health` reports `1.3.0`.
33
+
34
+ ### Notes
35
+
36
+ - The chat/streaming cluster, the `/tools/*` · `/cu/*` · `/local/*` ·
37
+ `/upload` · `/permissions` clusters, and the ~1,700-line model/engine
38
+ *provider helper* block remain in server_app and are scheduled for the next
39
+ decomposition pass (the safety net now de-risks those moves). server_app.py is
40
+ not yet under the 2,000-line target.
41
+ - CI hardening from 1.0.1/1.1.0 retained (VSIX compile guard, Node.js 24,
42
+ version-scoped artifact validation — no `dist/*` glob).
43
+
3
44
  ## [1.2.0] - 2026-05-31
4
45
 
5
46
  > Server app modularization (routers + service layer) and workspace/org guardrail hardening.
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "1.2.0"
3
+ __version__ = "1.3.0"
@@ -0,0 +1,386 @@
1
+ """MCP / skills / plugins API router.
2
+
3
+ Extracted from ``server_app.py`` in v1.3.0. Paths and schemas unchanged:
4
+ ``/mcp/*``, ``/skills/*``, ``/plugins/directory*``, and ``/mcp/call``.
5
+
6
+ Registry/tool symbols are imported directly from their owning modules
7
+ (``mcp_registry``, ``tools``, ``latticeai.core.tool_registry``); server_app-defined
8
+ helpers (auth, audit, tool governance/dispatch, KG) are injected, so there is no
9
+ import cycle.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any, Callable, Dict, List, Optional
19
+
20
+ from fastapi import APIRouter, HTTPException, Request
21
+ from pydantic import BaseModel
22
+
23
+ import mcp_registry
24
+ from mcp_registry import (
25
+ _get_combined_registry,
26
+ _fetch_skills_marketplace,
27
+ _fetch_plugin_directory,
28
+ install_skill,
29
+ SKILLS_DIR,
30
+ )
31
+ from latticeai.core.tool_registry import MCP_TOOL_DESCRIPTIONS
32
+ from tools import AGENT_ROOT, execute_tool
33
+
34
+
35
+ class McpRecommendRequest(BaseModel):
36
+ query: str
37
+ limit: int = 5
38
+
39
+
40
+ class McpInstallRequest(BaseModel):
41
+ mcp_id: str
42
+
43
+
44
+ class McpCustomRequest(BaseModel):
45
+ name: str
46
+ package: str
47
+ description: str = ""
48
+ category: str = "custom"
49
+ icon: str = "🔌"
50
+ env_vars: List[Dict] = []
51
+
52
+
53
+ class SkillInstallRequest(BaseModel):
54
+ plugin: str
55
+ skill: str
56
+
57
+
58
+ class McpCallRequest(BaseModel):
59
+ action: str
60
+ args: Dict = {}
61
+
62
+
63
+ def create_mcp_router(
64
+ *,
65
+ require_user: Callable[[Request], str],
66
+ require_admin: Callable[[Request], Any],
67
+ append_audit_event: Callable[..., None],
68
+ load_mcp_installs: Callable[[], Dict],
69
+ recommend_mcps: Callable[..., Any],
70
+ install_mcp: Callable[..., Any],
71
+ mcp_public_item: Callable[[Dict, Dict], Dict],
72
+ get_tool_permission: Callable[..., Any],
73
+ tool_governance: Dict,
74
+ tool_governance_default: Any,
75
+ check_tool_role: Callable[[str, str], None],
76
+ tool_response: Callable[..., Any],
77
+ require_graph: Callable[[], Any],
78
+ knowledge_graph: Any,
79
+ data_dir: Path,
80
+ ) -> APIRouter:
81
+ router = APIRouter()
82
+
83
+ # Bind injected deps to the names the moved handler bodies expect.
84
+ TOOL_GOVERNANCE = tool_governance
85
+ _TOOL_GOVERNANCE_DEFAULT = tool_governance_default
86
+ _check_tool_role = check_tool_role
87
+ _tool_response = tool_response
88
+ _require_graph = require_graph
89
+ KNOWLEDGE_GRAPH = knowledge_graph
90
+
91
+ _CUSTOM_MCP_FILE = data_dir / "custom_mcps.json"
92
+
93
+ def _load_custom_mcps() -> List[Dict]:
94
+ if not _CUSTOM_MCP_FILE.exists():
95
+ return []
96
+ try:
97
+ with open(_CUSTOM_MCP_FILE, "r", encoding="utf-8") as f:
98
+ return json.load(f)
99
+ except Exception:
100
+ return []
101
+
102
+ def _save_custom_mcps(items: List[Dict]):
103
+ with open(_CUSTOM_MCP_FILE, "w", encoding="utf-8") as f:
104
+ json.dump(items, f, ensure_ascii=False, indent=2)
105
+
106
+ @router.get("/mcp/tools")
107
+ async def mcp_tools():
108
+ installed = load_mcp_installs().get("installed", {})
109
+ registry = await _get_combined_registry()
110
+ tools = []
111
+ for name, description in MCP_TOOL_DESCRIPTIONS.items():
112
+ policy = TOOL_GOVERNANCE.get(name, _TOOL_GOVERNANCE_DEFAULT)
113
+ tools.append({
114
+ "name": name,
115
+ "description": description,
116
+ "permission": get_tool_permission(name),
117
+ "governance": {
118
+ "risk": policy["risk"],
119
+ "destructive": policy["destructive"],
120
+ "shell": policy["shell"],
121
+ "network": policy["network"],
122
+ "auto_approve": policy["auto_approve"],
123
+ "sandbox": policy["sandbox"],
124
+ "rollback": policy["rollback"],
125
+ },
126
+ })
127
+ return {
128
+ "status": "ok",
129
+ "workspace": str(AGENT_ROOT),
130
+ "installed_mcps": [mcp_public_item(item, installed) for item in registry],
131
+ "tools": tools,
132
+ }
133
+
134
+ @router.post("/mcp/recommend")
135
+ async def mcp_recommend(req: McpRecommendRequest, request: Request):
136
+ require_user(request)
137
+ return {"recommendations": await recommend_mcps(req.query, req.limit)}
138
+
139
+ @router.post("/mcp/install")
140
+ async def mcp_install(req: McpInstallRequest, request: Request):
141
+ admin_email, _ = require_admin(request)
142
+ append_audit_event("mcp_install", user_email=admin_email, mcp_id=req.mcp_id)
143
+ return await install_mcp(req.mcp_id)
144
+
145
+ @router.get("/mcp/installed")
146
+ async def mcp_installed(request: Request):
147
+ require_user(request)
148
+ installed = load_mcp_installs().get("installed", {})
149
+ registry = await _get_combined_registry()
150
+ return {"installed": [mcp_public_item(item, installed) for item in registry]}
151
+
152
+ @router.get("/mcp/connectors/{mcp_id}")
153
+ async def mcp_connector(mcp_id: str, request: Request):
154
+ require_user(request)
155
+ registry = await _get_combined_registry()
156
+ item = next((e for e in registry if e["id"] == mcp_id), None)
157
+ if not item or item.get("install_mode") != "connector":
158
+ raise HTTPException(status_code=404, detail="커넥터를 찾을 수 없습니다.")
159
+ installed = load_mcp_installs().get("installed", {})
160
+ public = mcp_public_item(item, installed)
161
+ public["instructions"] = [
162
+ "Codex 또는 ChatGPT 앱의 Connectors 설정을 엽니다.",
163
+ f"{item['name']} 항목을 선택하고 계정을 인증합니다.",
164
+ "인증 후 Lattice AI에서 이 MCP를 다시 활성화하면 작업에 사용할 수 있습니다.",
165
+ ]
166
+ return public
167
+
168
+ @router.post("/mcp/registry/refresh")
169
+ async def mcp_registry_refresh(request: Request):
170
+ require_user(request)
171
+ mcp_registry._REMOTE_REGISTRY_FETCHED_AT = None
172
+ registry = await _get_combined_registry()
173
+ return {"status": "ok", "total": len(registry), "remote": len(mcp_registry._REMOTE_REGISTRY_CACHE)}
174
+
175
+ @router.get("/mcp/claude-code-servers")
176
+ async def mcp_claude_code_servers(request: Request):
177
+ """Read ~/.claude/settings.json mcpServers and return them as Lattice MCP items."""
178
+ require_user(request)
179
+ settings_path = Path.home() / ".claude" / "settings.json"
180
+ if not settings_path.exists():
181
+ return {"servers": []}
182
+ try:
183
+ with open(settings_path, "r", encoding="utf-8") as f:
184
+ settings = json.load(f)
185
+ mcp_servers = settings.get("mcpServers", {})
186
+ servers = []
187
+ for name, cfg in mcp_servers.items():
188
+ cmd = cfg.get("command", "")
189
+ args = cfg.get("args", [])
190
+ package = " ".join([cmd] + args) if args else cmd
191
+ env = cfg.get("env", {})
192
+ env_vars = [{"name": k, "value": v} for k, v in env.items()]
193
+ servers.append({
194
+ "id": f"claude-code:{name}",
195
+ "name": name,
196
+ "description": f"Claude Code MCP: {package}",
197
+ "package": package,
198
+ "icon": "🤖",
199
+ "category": "Claude Code",
200
+ "source": "claude-code",
201
+ "installed": True,
202
+ "env_vars": env_vars,
203
+ })
204
+ return {"servers": servers}
205
+ except Exception as e:
206
+ logging.warning("mcp_claude_code_servers failed: %s", e)
207
+ return {"servers": []}
208
+
209
+ @router.get("/mcp/custom")
210
+ async def mcp_custom_list(request: Request):
211
+ """Return user-added custom MCP entries."""
212
+ require_user(request)
213
+ return {"custom": _load_custom_mcps()}
214
+
215
+ @router.post("/mcp/custom")
216
+ async def mcp_custom_add(req: McpCustomRequest, request: Request):
217
+ """Save a custom MCP entry (admin-only)."""
218
+ admin_email, _ = require_admin(request)
219
+ append_audit_event("mcp_custom_add", user_email=admin_email, name=req.name, package=req.package)
220
+ if not req.name.strip():
221
+ raise HTTPException(status_code=400, detail="name은 필수입니다.")
222
+ if not req.package.strip():
223
+ raise HTTPException(status_code=400, detail="package는 필수입니다.")
224
+ items = _load_custom_mcps()
225
+ entry = {
226
+ "id": f"custom:{req.name.strip().lower().replace(' ', '-')}",
227
+ "name": req.name.strip(),
228
+ "package": req.package.strip(),
229
+ "description": req.description.strip(),
230
+ "category": req.category or "custom",
231
+ "icon": req.icon or "🔌",
232
+ "env_vars": req.env_vars or [],
233
+ "install_mode": "npm",
234
+ "source": "custom",
235
+ "installed": False,
236
+ "added_at": datetime.now().isoformat(),
237
+ }
238
+ items = [e for e in items if e["id"] != entry["id"]]
239
+ items.append(entry)
240
+ _save_custom_mcps(items)
241
+ return {"status": "ok", "entry": entry}
242
+
243
+ @router.delete("/mcp/custom/{mcp_id:path}")
244
+ async def mcp_custom_delete(mcp_id: str, request: Request):
245
+ """Remove a custom MCP entry."""
246
+ require_admin(request)
247
+ items = _load_custom_mcps()
248
+ before = len(items)
249
+ items = [e for e in items if e["id"] != mcp_id]
250
+ if len(items) == before:
251
+ raise HTTPException(status_code=404, detail="항목을 찾을 수 없습니다.")
252
+ _save_custom_mcps(items)
253
+ return {"status": "ok"}
254
+
255
+ # ── Skills & Plugin Directory ─────────────────────────────────────────
256
+
257
+ @router.get("/skills/marketplace")
258
+ async def skills_marketplace(request: Request, category: Optional[str] = None, author: Optional[str] = None):
259
+ require_user(request)
260
+ skills = await _fetch_skills_marketplace()
261
+ installed_names = {d.name for d in SKILLS_DIR.iterdir() if d.is_dir()} if SKILLS_DIR.exists() else set()
262
+ filtered = skills
263
+ if category:
264
+ filtered = [s for s in filtered if s.get("category", "").lower() == category.lower()]
265
+ if author:
266
+ filtered = [s for s in filtered if s.get("author", "").lower() == author.lower()]
267
+ return {
268
+ "skills": [{**s, "installed": s["skill"] in installed_names} for s in filtered],
269
+ "total": len(filtered),
270
+ "authors": sorted({s["author"] for s in skills}),
271
+ "categories": sorted({s["category"] for s in skills}),
272
+ }
273
+
274
+ @router.post("/skills/install")
275
+ async def skills_install(req: SkillInstallRequest, request: Request):
276
+ admin_email, _ = require_admin(request)
277
+ append_audit_event("skill_install", user_email=admin_email, plugin=req.plugin, skill=req.skill)
278
+ return await install_skill(req.plugin, req.skill)
279
+
280
+ @router.get("/skills/list")
281
+ async def skills_list(request: Request):
282
+ require_user(request)
283
+ if not SKILLS_DIR.exists():
284
+ return {"skills": []}
285
+ skills = []
286
+ for skill_dir in sorted(SKILLS_DIR.iterdir()):
287
+ if not skill_dir.is_dir():
288
+ continue
289
+ skill_md = skill_dir / "SKILL.md"
290
+ if not skill_md.exists():
291
+ continue
292
+ lines = skill_md.read_text(encoding="utf-8").splitlines()
293
+ desc = next((l.split(":", 1)[1].strip() for l in lines if l.startswith("description:")), "")
294
+ comment = lines[0] if lines else ""
295
+ if "anthropics/claude-plugins-official" in comment:
296
+ source = "anthropic"
297
+ elif "Source:" in comment:
298
+ source = "third-party"
299
+ else:
300
+ source = "local"
301
+ skills.append({"name": skill_dir.name, "description": desc, "source": source})
302
+ return {"skills": skills, "total": len(skills)}
303
+
304
+ @router.post("/skills/marketplace/refresh")
305
+ async def skills_marketplace_refresh(request: Request):
306
+ require_user(request)
307
+ mcp_registry._SKILLS_MARKETPLACE_FETCHED_AT = None
308
+ skills = await _fetch_skills_marketplace()
309
+ by_author = {}
310
+ for s in skills:
311
+ by_author[s["author"]] = by_author.get(s["author"], 0) + 1
312
+ return {"status": "ok", "total": len(skills), "by_author": by_author}
313
+
314
+ @router.get("/plugins/directory")
315
+ async def plugins_directory(
316
+ request: Request,
317
+ category: Optional[str] = None,
318
+ license: Optional[str] = None,
319
+ q: Optional[str] = None,
320
+ ):
321
+ require_user(request)
322
+ plugins = await _fetch_plugin_directory()
323
+ filtered = plugins
324
+ if category:
325
+ filtered = [p for p in filtered if p.get("category", "").lower() == category.lower()]
326
+ if license:
327
+ filtered = [p for p in filtered if p.get("license", "").lower() == license.lower()]
328
+ if q:
329
+ q_lower = q.lower()
330
+ filtered = [
331
+ p for p in filtered
332
+ if q_lower in p.get("name", "").lower()
333
+ or q_lower in p.get("description", "").lower()
334
+ or q_lower in p.get("author", "").lower()
335
+ ]
336
+ return {
337
+ "plugins": filtered,
338
+ "total": len(filtered),
339
+ "categories": sorted({p["category"] for p in plugins if p.get("category")}),
340
+ "licenses": sorted({p["license"] for p in plugins if p.get("license")}),
341
+ }
342
+
343
+ @router.post("/plugins/directory/refresh")
344
+ async def plugins_directory_refresh(request: Request):
345
+ require_user(request)
346
+ mcp_registry._PLUGIN_DIRECTORY_FETCHED_AT = None
347
+ plugins = await _fetch_plugin_directory()
348
+ by_license = {}
349
+ for p in plugins:
350
+ lic = p.get("license", "unknown")
351
+ by_license[lic] = by_license.get(lic, 0) + 1
352
+ return {"status": "ok", "total": len(plugins), "by_license": by_license}
353
+
354
+ @router.post("/mcp/call")
355
+ async def mcp_call(req: McpCallRequest, request: Request):
356
+ current_user = require_user(request)
357
+ args = req.args or {}
358
+ if req.action == "knowledge_graph_ingest":
359
+ _require_graph()
360
+ return KNOWLEDGE_GRAPH.ingest_message(
361
+ args.get("role") or ("assistant" if args.get("type") == "ai_response" else "user"),
362
+ args.get("content") or "",
363
+ user_email=args.get("user_email") or current_user,
364
+ user_nickname=args.get("user_nickname"),
365
+ source=args.get("source") or "mcp",
366
+ conversation_id=args.get("conversation_id"),
367
+ raw=args,
368
+ )
369
+ if req.action == "knowledge_graph_search":
370
+ _require_graph()
371
+ return KNOWLEDGE_GRAPH.search(args.get("query") or args.get("q") or "", args.get("limit", 30))
372
+ if req.action == "knowledge_graph_graph":
373
+ _require_graph()
374
+ return KNOWLEDGE_GRAPH.graph(args.get("limit", 300))
375
+ if req.action == "knowledge_graph_context":
376
+ _require_graph()
377
+ return {
378
+ "context": KNOWLEDGE_GRAPH.context_for_query(
379
+ args.get("query") or args.get("q") or "",
380
+ args.get("limit", 6),
381
+ )
382
+ }
383
+ _check_tool_role(req.action, current_user)
384
+ return _tool_response(execute_tool, req.action, req.args or {})
385
+
386
+ return router