ltcai 1.1.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 +31 -0
- package/docs/CHANGELOG.md +90 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/health.py +45 -0
- package/latticeai/api/mcp.py +386 -0
- package/latticeai/api/models.py +307 -0
- package/latticeai/api/workspace.py +748 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +117 -1321
- package/latticeai/services/__init__.py +6 -0
- package/latticeai/services/chat_service.py +53 -0
- package/latticeai/services/model_service.py +51 -0
- package/latticeai/services/workspace_service.py +117 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,6 +36,37 @@ 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
|
+
|
|
51
|
+
### New in 1.2.0: Server App Modularization
|
|
52
|
+
|
|
53
|
+
- **server_app.py modularized** — Workspace/Organization and health/engine
|
|
54
|
+
endpoints extracted into dedicated routers (`latticeai/api/*`) backed by a
|
|
55
|
+
service layer (`latticeai/services/*`); `server_app` is now app assembly +
|
|
56
|
+
router include (~6,585 → ~5,948 lines)
|
|
57
|
+
- **Routers / services split** — `create_workspace_router`,
|
|
58
|
+
`create_health_router`, `WorkspaceService`, `ModelService`, `ChatService`
|
|
59
|
+
- **Workspace API service layer** — scope resolution and role/permission checks
|
|
60
|
+
centralized in `WorkspaceService`
|
|
61
|
+
- **Workspace / org guardrails** — non-members can't read/write org data,
|
|
62
|
+
viewers can't write, owners/admins manage members; no-auth local owner
|
|
63
|
+
fallback preserved
|
|
64
|
+
- **Health / model / chat modularization** — `/health`, `/mode`,
|
|
65
|
+
`/runtime_features`, `/engines` via the health router; chat trace recording
|
|
66
|
+
via the chat service (streaming behavior unchanged)
|
|
67
|
+
- **Compatibility preserved** — `server:app` import path, all API routes, CLI,
|
|
68
|
+
Knowledge Graph / Admin / Security routers, and VS Code integration unchanged
|
|
69
|
+
|
|
39
70
|
### New in 1.1.0: Organization Workspace Foundation
|
|
40
71
|
|
|
41
72
|
- **Organization Workspace** alongside Personal Workspace — create shared org
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,95 @@
|
|
|
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
|
+
|
|
44
|
+
## [1.2.0] - 2026-05-31
|
|
45
|
+
|
|
46
|
+
> Server app modularization (routers + service layer) and workspace/org guardrail hardening.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- **server_app.py modularization (phase 2)** — reduced
|
|
51
|
+
`latticeai/server_app.py` from ~6,585 to ~5,948 lines by extracting the
|
|
52
|
+
Workspace OS / Organization API and the health/engine-summary endpoints into
|
|
53
|
+
dedicated routers backed by a new service layer. `server_app` now focuses on
|
|
54
|
+
app assembly, lifespan, middleware, and router include. The historical
|
|
55
|
+
`server:app` import path, all API paths, and request/response shapes are
|
|
56
|
+
unchanged.
|
|
57
|
+
- **Workspace/Organization guardrails strengthened** — workspace-scoped reads
|
|
58
|
+
and writes now go through `WorkspaceService`, which gates explicitly-named
|
|
59
|
+
workspaces: non-members cannot read or write organization data, viewers
|
|
60
|
+
cannot write, members can write, and only owners/admins manage members. The
|
|
61
|
+
no-auth local-owner fallback for ownerless org workspaces is preserved, but a
|
|
62
|
+
*named* stranger never bypasses membership. `set_active_workspace` continues
|
|
63
|
+
to enforce membership.
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- **New API routers** — `latticeai/api/workspace.py`
|
|
68
|
+
(`create_workspace_router`) and `latticeai/api/health.py`
|
|
69
|
+
(`create_health_router`), mirroring the existing auth/admin router-factory
|
|
70
|
+
convention (no import cycle: routers receive dependencies, never import the
|
|
71
|
+
app).
|
|
72
|
+
- **New service layer** — `latticeai/services/workspace_service.py`
|
|
73
|
+
(`WorkspaceService`: scope resolution + permission guardrails),
|
|
74
|
+
`latticeai/services/model_service.py` (`ModelService`: health/engine summary
|
|
75
|
+
payloads), and `latticeai/services/chat_service.py` (`ChatService`: history +
|
|
76
|
+
answer-trace seam; the streaming chat path is unchanged and now records traces
|
|
77
|
+
through this façade).
|
|
78
|
+
- **Shared-global areas made explicit** — the local knowledge graph and
|
|
79
|
+
installed skills remain machine-global shared state (not partitioned per
|
|
80
|
+
workspace); this is now surfaced in `WorkspaceService.SHARED_GLOBAL_AREAS`,
|
|
81
|
+
the `/workspace/os` summary (`shared_global_areas`), and code comments.
|
|
82
|
+
- **Startup/modularization tests** — `tests/unit/test_server_app_modularization.py`
|
|
83
|
+
(import path, router registration, key route presence, no import cycle) and
|
|
84
|
+
`tests/unit/test_workspace_service.py` (read/write/member guardrails).
|
|
85
|
+
|
|
86
|
+
### Notes
|
|
87
|
+
|
|
88
|
+
- Release metadata aligned to `1.2.0`; `APP_VERSION` continues to derive from
|
|
89
|
+
`WORKSPACE_OS_VERSION` and `/health` reports `1.2.0`.
|
|
90
|
+
- CI release hardening from 1.0.1/1.1.0 is retained (VSIX compile guard, Node.js
|
|
91
|
+
24, version-scoped artifact validation — no `dist/*` glob).
|
|
92
|
+
|
|
3
93
|
## [1.1.0] - 2026-05-31
|
|
4
94
|
|
|
5
95
|
> Organization Workspace foundation, open-core Enterprise seam, and CI/release hardening.
|
package/latticeai/__init__.py
CHANGED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Health / status / engine-summary router.
|
|
2
|
+
|
|
3
|
+
Extracted from ``server_app.py`` in v1.2.0. Paths unchanged: ``/health``,
|
|
4
|
+
``/mode``, ``/runtime_features``, ``/engines`` (GET). Heavier engine *mutation*
|
|
5
|
+
endpoints (install / verify-cloud / pull-model) stay in server_app for now.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from typing import Any, Callable, List, Optional
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, Request
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_health_router(
|
|
17
|
+
*,
|
|
18
|
+
model_service,
|
|
19
|
+
engine_status: Callable[[], List[dict]],
|
|
20
|
+
get_current_user: Callable[[Request], Optional[str]],
|
|
21
|
+
require_auth: bool,
|
|
22
|
+
app_version: str,
|
|
23
|
+
app_mode: str,
|
|
24
|
+
) -> APIRouter:
|
|
25
|
+
router = APIRouter()
|
|
26
|
+
svc = model_service
|
|
27
|
+
|
|
28
|
+
@router.get("/health")
|
|
29
|
+
async def health(request: Request):
|
|
30
|
+
base = svc.health_base(version=app_version, mode=app_mode)
|
|
31
|
+
if not get_current_user(request) and require_auth:
|
|
32
|
+
return base
|
|
33
|
+
engines = await asyncio.to_thread(engine_status)
|
|
34
|
+
return svc.health_full(base, engines)
|
|
35
|
+
|
|
36
|
+
@router.get("/mode")
|
|
37
|
+
@router.get("/runtime_features")
|
|
38
|
+
async def mode():
|
|
39
|
+
return svc.runtime()
|
|
40
|
+
|
|
41
|
+
@router.get("/engines")
|
|
42
|
+
async def engines():
|
|
43
|
+
return svc.engines_payload(await asyncio.to_thread(engine_status))
|
|
44
|
+
|
|
45
|
+
return router
|
|
@@ -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
|