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.
@@ -106,6 +106,8 @@ from latticeai.services.model_service import ModelService
106
106
  from latticeai.services.chat_service import ChatService
107
107
  from latticeai.api.workspace import create_workspace_router
108
108
  from latticeai.api.health import create_health_router
109
+ from latticeai.api.models import create_models_router
110
+ from latticeai.api.mcp import create_mcp_router
109
111
  from latticeai.core.agent import (
110
112
  AgentState,
111
113
  AgentRunContext,
@@ -463,25 +465,7 @@ def save_sso_config(update: Dict[str, object]) -> Dict[str, object]:
463
465
  _sso_discovery_cache_url = ""
464
466
  return current
465
467
 
466
- class McpRecommendRequest(BaseModel):
467
- query: str
468
- limit: int = 5
469
-
470
- class McpInstallRequest(BaseModel):
471
- mcp_id: str
472
-
473
- class McpCustomRequest(BaseModel):
474
- name: str
475
- package: str
476
- description: str = ""
477
- category: str = "custom"
478
- icon: str = "πŸ”Œ"
479
- env_vars: List[Dict] = []
480
-
481
- class SkillInstallRequest(BaseModel):
482
- plugin: str
483
- skill: str
484
-
468
+ # MCP/skill request models moved to latticeai.api.mcp (v1.3.0).
485
469
  DEFAULT_VPC_CONFIG = {
486
470
  "provider": "AWS",
487
471
  "region": "ap-northeast-2",
@@ -1404,36 +1388,7 @@ class ChatRequest(BaseModel):
1404
1388
  image_data: Optional[str] = None # Base64 이미지 데이터 (VLM용)
1405
1389
 
1406
1390
 
1407
- class LoadModelRequest(BaseModel):
1408
- model_id: str # HuggingFace repo id λ˜λŠ” 둜컬 경둜
1409
- adapter_path: Optional[str] = None # LoRA adapter (선택)
1410
- draft_model_id: Optional[str] = None # Speculative decoding draft model (선택)
1411
- engine: Optional[str] = None
1412
- user_email: Optional[str] = None
1413
-
1414
-
1415
- class InstallEngineRequest(BaseModel):
1416
- engine: str
1417
-
1418
-
1419
- class SetApiKeyRequest(BaseModel):
1420
- provider: str
1421
- key: str
1422
- user_email: Optional[str] = None
1423
-
1424
-
1425
- class PullModelRequest(BaseModel):
1426
- model: str
1427
-
1428
- class PrepareModelRequest(BaseModel):
1429
- model: str
1430
- engine: Optional[str] = None
1431
- user_email: Optional[str] = None
1432
-
1433
- class VerifyCloudRequest(BaseModel):
1434
- force: bool = False
1435
- provider: Optional[str] = None
1436
-
1391
+ # Model/engine request models moved to latticeai.api.models (v1.3.0).
1437
1392
 
1438
1393
  # Workspace request models moved to latticeai.api.workspace (v1.2.0 modularization).
1439
1394
 
@@ -1590,10 +1545,6 @@ class LocalWriteRequest(BaseModel):
1590
1545
  approval_token: Optional[str] = None
1591
1546
 
1592
1547
 
1593
- class McpCallRequest(BaseModel):
1594
- action: str
1595
- args: Dict = {}
1596
-
1597
1548
 
1598
1549
  class ToolGitDiffRequest(BaseModel):
1599
1550
  path: Optional[str] = None
@@ -3578,251 +3529,33 @@ app.include_router(create_health_router(
3578
3529
  ))
3579
3530
 
3580
3531
 
3581
- @app.post("/engines/install")
3582
- async def engines_install(req: InstallEngineRequest, request: Request):
3583
- require_user(request)
3584
- return install_engine(req.engine)
3585
-
3586
- @app.post("/engines/verify-cloud")
3587
- async def engines_verify_cloud(req: VerifyCloudRequest, request: Request):
3588
- require_user(request)
3589
- results = await verify_cloud_models(force=req.force, provider_filter=req.provider)
3590
- return {"verified": results, "ttl_seconds": CLOUD_VERIFY_TTL_SECONDS}
3591
-
3592
-
3593
- @app.post("/engines/pull-model")
3594
- async def pull_ollama_model(req: PullModelRequest, request: Request):
3595
- require_user(request)
3596
- model_ref = normalize_local_model_request(req.model, None)
3597
- if not model_ref:
3598
- raise HTTPException(status_code=400, detail="λͺ¨λΈ μ‹λ³„μžκ°€ λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€.")
3599
-
3600
- if ":" in model_ref and model_ref.split(":", 1)[0].strip().lower() in {"ollama", "vllm", "lmstudio", "llamacpp", "local_mlx", "mlx"}:
3601
- provider, model_name = model_ref.split(":", 1)
3602
- provider = provider.strip().lower()
3603
- model_name = model_name.strip()
3604
- else:
3605
- provider, model_name = "local_mlx", model_ref
3606
-
3607
- if not model_name:
3608
- raise HTTPException(status_code=400, detail="λͺ¨λΈ 이름이 λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€.")
3609
-
3610
- if provider == "ollama":
3611
- ensure_ollama_server()
3612
- ollama = local_binary("ollama")
3613
- if not ollama:
3614
- raise HTTPException(status_code=400, detail="Ollamaκ°€ μ„€μΉ˜λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.")
3615
- try:
3616
- completed = subprocess.run(
3617
- [ollama, "pull", model_name],
3618
- capture_output=True, text=True, timeout=900, check=False,
3619
- )
3620
- except subprocess.TimeoutExpired:
3621
- raise HTTPException(status_code=408, detail="λͺ¨λΈ λ‹€μš΄λ‘œλ“œ μ‹œκ°„μ΄ μ΄ˆκ³Όλ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
3622
- if completed.returncode != 0:
3623
- raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or "pull μ‹€νŒ¨")
3624
- return {"provider": provider, "model": model_name, "returncode": completed.returncode}
3625
-
3626
- if provider == "lmstudio":
3627
- raise HTTPException(
3628
- status_code=400,
3629
- detail=(
3630
- "LM Studio λͺ¨λΈμ€ Latticeμ—μ„œ Hugging Face둜 pullν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. "
3631
- "LM Studio μ•±μ—μ„œ λͺ¨λΈμ„ λ‹€μš΄λ‘œλ“œν•˜κ³  Local Serverλ₯Ό μΌ  λ’€ λͺ¨λΈμ„ λ‘œλ“œν•˜μ„Έμš”. "
3632
- "그러면 λͺ¨λΈ 선택창에 μ‹€μ œ /v1/models ν•­λͺ©μ΄ ν‘œμ‹œλ©λ‹ˆλ‹€."
3633
- ),
3634
- )
3635
-
3636
- if provider in {"vllm", "llamacpp", "local_mlx", "mlx"}:
3637
- download_provider = "local_mlx" if provider == "mlx" else provider
3638
- result = download_hf_model(model_name, download_provider)
3639
- return {"provider": provider, "model": model_name, "returncode": 0, **result}
3640
-
3641
- raise HTTPException(status_code=400, detail=f"{provider} μ—”μ§„ λͺ¨λΈ λ‹€μš΄λ‘œλ“œλŠ” 아직 μžλ™ν™”λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.")
3642
-
3643
-
3644
- @app.post("/engines/prepare-model")
3645
- async def engines_prepare_model(req: PrepareModelRequest, request: Request):
3646
- require_user(request)
3647
- return await prepare_and_load_model(
3648
- req.model,
3649
- request,
3650
- engine=req.engine,
3651
- user_email=req.user_email,
3652
- )
3653
-
3654
-
3655
- @app.post("/engines/prepare-model/stream")
3656
- async def engines_prepare_model_stream(req: PrepareModelRequest, request: Request):
3657
- require_user(request)
3658
-
3659
- async def event_stream():
3660
- try:
3661
- async for chunk in prepare_and_load_model_stream(
3662
- req.model,
3663
- request,
3664
- engine=req.engine,
3665
- user_email=req.user_email,
3666
- ):
3667
- yield chunk
3668
- except HTTPException as exc:
3669
- yield sse_event("error", {
3670
- "status_code": exc.status_code,
3671
- "detail": exc.detail or "λͺ¨λΈ 쀀비에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.",
3672
- })
3673
- except Exception as exc:
3674
- logging.exception("model prepare stream failed")
3675
- yield sse_event("error", {
3676
- "status_code": 500,
3677
- "detail": str(exc)[-1000:] or "λͺ¨λΈ 쀀비에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.",
3678
- })
3679
-
3680
- return StreamingResponse(
3681
- event_stream(),
3682
- media_type="text/event-stream",
3683
- headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
3684
- )
3685
-
3686
-
3687
- @app.post("/setup/set-api-key")
3688
- async def set_api_key(req: SetApiKeyRequest, request: Request):
3689
- from llm_router import OPENAI_COMPATIBLE_PROVIDERS
3690
- config = OPENAI_COMPATIBLE_PROVIDERS.get(req.provider)
3691
- if not config:
3692
- raise HTTPException(status_code=400, detail="μ•Œ 수 μ—†λŠ” ν”„λ‘œλ°”μ΄λ”μž…λ‹ˆλ‹€.")
3693
- if not req.key.strip():
3694
- raise HTTPException(status_code=400, detail="API ν‚€κ°€ λΉ„μ–΄μžˆμŠ΅λ‹ˆλ‹€.")
3695
- current_user = get_current_user(request)
3696
- if REQUIRE_AUTH and not current_user:
3697
- raise HTTPException(status_code=401, detail="인증이 ν•„μš”ν•©λ‹ˆλ‹€.")
3698
- # req.user_email 을 ν†΅ν•œ 타 계정 μœ„μ‘°λ₯Ό λ°©μ§€: κ΄€λ¦¬μžκ°€ μ•„λ‹ˆλ©΄ 본인 μ΄λ©”μΌλ§Œ ν—ˆμš©
3699
- if req.user_email and req.user_email != current_user:
3700
- users = load_users()
3701
- if get_user_role(current_user or "", users) != "admin":
3702
- raise HTTPException(status_code=403, detail="λ‹€λ₯Έ μ‚¬μš©μžμ˜ API ν‚€λ₯Ό μ„€μ •ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€.")
3703
- target_email = (req.user_email or current_user or "").strip()
3704
- if not target_email:
3705
- raise HTTPException(status_code=400, detail="μ‚¬μš©μž 식별이 ν•„μš”ν•©λ‹ˆλ‹€. 둜그인 ν›„ λ‹€μ‹œ μ‹œλ„ν•˜μ„Έμš”.")
3706
- set_user_api_key(target_email, req.provider, req.key.strip())
3707
- return {"ok": True, "provider": req.provider, "user_email": target_email, "scope": "user"}
3708
-
3709
-
3710
- def _recommended_with_engine_options(items: List[Dict[str, object]]) -> List[Dict[str, object]]:
3711
- """ν”Όλ“œλ°± #1: μΆ”μ²œ λͺ¨λΈμ— 엔진별 선택지(engine_options)λ₯Ό λΆ™μ—¬ λ‚΄λ €μ€€λ‹€.
3712
-
3713
- ν”„λ‘ νŠΈμ—μ„œ μΆ”μ²œ μΉ΄λ“œλ₯Ό λˆ„λ₯΄λŠ” μˆœκ°„ μ–΄λŠ μ—”μ§„/μ‹€μ œ λͺ¨λΈλ‘œ λ‹€μš΄λ‘œλ“œ/λ‘œλ“œν• μ§€κ°€
3714
- 이미 ν™•μ •λ˜λ„λ‘ ν•œλ‹€.
3715
- """
3716
- out: List[Dict[str, object]] = []
3717
- for item in items:
3718
- base = {
3719
- "id": item["id"],
3720
- "name": item["name"],
3721
- "tag": item["tag"],
3722
- "size": item["size"],
3723
- "display_name": item.get("name") or item.get("id"),
3724
- }
3725
- short_id = str(item["id"]).lower()
3726
- aliases = MODEL_ENGINE_ALIASES.get(short_id) or {}
3727
- options: List[Dict[str, str]] = []
3728
- for engine_name in ("local_mlx", "ollama", "lmstudio", "llamacpp", "vllm"):
3729
- real = aliases.get(engine_name)
3730
- if not real:
3731
- continue
3732
- options.append({
3733
- "engine": engine_name,
3734
- "model_id": real,
3735
- "load_id": real if engine_name == "local_mlx" else f"{engine_name}:{real}",
3736
- })
3737
- # μ–΄λŠ 엔진도 aliasκ°€ μ—†μœΌλ©΄ local_mlx μΉ΄νƒˆλ‘œκ·Έ 자체λ₯Ό μ‚¬μš©ν•œλ‹€.
3738
- if not options:
3739
- options.append({
3740
- "engine": "local_mlx",
3741
- "model_id": item["id"],
3742
- "load_id": item["id"],
3743
- })
3744
- base["engine_options"] = options
3745
- base["recommended_engine"] = options[0]["engine"]
3746
- out.append(base)
3747
- return out
3748
-
3749
-
3750
- @app.get("/models")
3751
- async def list_models():
3752
- """HuggingFace μΆ”μ²œ λͺ¨λΈ λͺ©λ‘ 및 λ‘œλ“œ μƒνƒœ λ°˜ν™˜"""
3753
- recommended = _recommended_with_engine_options(
3754
- list(filter_lower_family_versions(ENGINE_MODEL_CATALOG.get("local_mlx", [])))
3755
- )
3756
- return {
3757
- "recommended": recommended,
3758
- "cloud": router.detected_cloud_models(),
3759
- "engines": await asyncio.to_thread(engine_status),
3760
- "loaded": router.loaded_model_ids,
3761
- "current": router.current_model_id,
3762
- "compat_profiles": _list_compat_profiles(),
3763
- }
3764
-
3765
-
3766
- @app.get("/models/compat-profiles")
3767
- async def list_model_compat_profiles(request: Request):
3768
- """ν”Όλ“œλ°± #3: Model Compatibility Layer μΊμ‹œ μƒνƒœλ₯Ό μ‘°νšŒν•œλ‹€."""
3769
- require_user(request)
3770
- return {"profiles": _list_compat_profiles()}
3771
-
3772
-
3773
- # ── Model Management ───────────────────────────────────────────────────────────
3774
-
3775
- @app.post("/models/load")
3776
- async def load_model(req: LoadModelRequest, request: Request):
3777
- """λͺ¨λΈ λ‘œλ“œ (이미 λ‘œλ“œλμœΌλ©΄ μΊμ‹œμ—μ„œ μ¦‰μ‹œ λ°˜ν™˜)"""
3778
- try:
3779
- model_id = req.model_id
3780
- requested_engine = req.engine or (model_id.split(":", 1)[0] if ":" in model_id else "local_mlx")
3781
- if IS_PUBLIC_MODE and not ALLOW_LOCAL_MODELS and requested_engine in {"local_mlx", "mlx"}:
3782
- raise HTTPException(
3783
- status_code=400,
3784
- detail="Public mode blocks local MLX model loading. Use openai:, openrouter:, groq:, together:, or set LATTICEAI_ALLOW_LOCAL_MODELS=true.",
3785
- )
3786
- return await prepare_and_load_model(
3787
- model_id,
3788
- request,
3789
- engine=req.engine,
3790
- user_email=req.user_email,
3791
- adapter_path=req.adapter_path,
3792
- draft_model_id=req.draft_model_id,
3793
- )
3794
- except HTTPException:
3795
- raise
3796
- except Exception as e:
3797
- raise HTTPException(status_code=500, detail=str(e))
3798
-
3799
-
3800
- @app.post("/models/switch/{model_id:path}")
3801
- async def switch_model(model_id: str, request: Request):
3802
- """이미 λ‘œλ“œλœ λͺ¨λΈ 쀑 ν™œμ„± λͺ¨λΈ μ „ν™˜ (μ¦‰μ‹œ, μž¬λ‘œλ“œ μ—†μŒ)"""
3803
- require_user(request)
3804
- try:
3805
- router.switch_model(model_id)
3806
- return {"status": "ok", "current": router.current_model_id}
3807
- except KeyError:
3808
- raise HTTPException(status_code=404, detail=f"Model '{model_id}' not loaded. Call /models/load first.")
3809
-
3810
-
3811
- @app.delete("/models/unload/{model_id:path}")
3812
- async def unload_model(model_id: str, request: Request):
3813
- """λͺ¨λΈ μ–Έλ‘œλ“œ β†’ λ©”λͺ¨λ¦¬ ν•΄μ œ"""
3814
- require_user(request)
3815
- router.unload_model(model_id)
3816
- return {"status": "ok", "unloaded": model_id}
3817
-
3818
-
3819
- @app.delete("/models/unload-all")
3820
- async def unload_all_models(request: Request):
3821
- """λ‘œλ“œλœ λͺ¨λ“  λͺ¨λΈ μ–Έλ‘œλ“œ β†’ λ©”λͺ¨λ¦¬ ν•΄μ œ"""
3822
- require_user(request)
3823
- unloaded = router.loaded_model_ids
3824
- router.unload_all()
3825
- return {"status": "ok", "unloaded": unloaded}
3532
+ # ── Model / Engine router (latticeai.api.models, v1.3.0) ─────────────────────
3533
+ app.include_router(create_models_router(
3534
+ model_router=router,
3535
+ require_user=require_user,
3536
+ get_current_user=get_current_user,
3537
+ load_users=load_users,
3538
+ get_user_role=get_user_role,
3539
+ install_engine=install_engine,
3540
+ verify_cloud_models=verify_cloud_models,
3541
+ normalize_local_model_request=normalize_local_model_request,
3542
+ download_hf_model=download_hf_model,
3543
+ prepare_and_load_model=prepare_and_load_model,
3544
+ prepare_and_load_model_stream=prepare_and_load_model_stream,
3545
+ sse_event=sse_event,
3546
+ ensure_ollama_server=ensure_ollama_server,
3547
+ local_binary=local_binary,
3548
+ engine_status=engine_status,
3549
+ filter_lower_family_versions=filter_lower_family_versions,
3550
+ list_compat_profiles=_list_compat_profiles,
3551
+ set_user_api_key=set_user_api_key,
3552
+ engine_model_catalog=ENGINE_MODEL_CATALOG,
3553
+ model_engine_aliases=MODEL_ENGINE_ALIASES,
3554
+ cloud_verify_ttl_seconds=CLOUD_VERIFY_TTL_SECONDS,
3555
+ is_public_mode=IS_PUBLIC_MODE,
3556
+ allow_local_models=ALLOW_LOCAL_MODELS,
3557
+ require_auth=REQUIRE_AUTH,
3558
+ ))
3826
3559
 
3827
3560
 
3828
3561
  # ── Chat / Completion ──────────────────────────────────────────────────────────
@@ -5466,324 +5199,24 @@ async def tools_permissions(request: Request):
5466
5199
  return {"status": "ok", "permissions": list_tool_permissions()}
5467
5200
 
5468
5201
 
5469
- @app.get("/mcp/tools")
5470
- async def mcp_tools():
5471
- installed = load_mcp_installs().get("installed", {})
5472
- registry = await _get_combined_registry()
5473
- tools = []
5474
- for name, description in MCP_TOOL_DESCRIPTIONS.items():
5475
- policy = TOOL_GOVERNANCE.get(name, _TOOL_GOVERNANCE_DEFAULT)
5476
- tools.append({
5477
- "name": name,
5478
- "description": description,
5479
- "permission": get_tool_permission(name),
5480
- "governance": {
5481
- "risk": policy["risk"],
5482
- "destructive": policy["destructive"],
5483
- "shell": policy["shell"],
5484
- "network": policy["network"],
5485
- "auto_approve": policy["auto_approve"],
5486
- "sandbox": policy["sandbox"],
5487
- "rollback": policy["rollback"],
5488
- },
5489
- })
5490
- return {
5491
- "status": "ok",
5492
- "workspace": str(AGENT_ROOT),
5493
- "installed_mcps": [mcp_public_item(item, installed) for item in registry],
5494
- "tools": tools,
5495
- }
5496
-
5497
-
5498
- @app.post("/mcp/recommend")
5499
- async def mcp_recommend(req: McpRecommendRequest, request: Request):
5500
- require_user(request)
5501
- return {"recommendations": await recommend_mcps(req.query, req.limit)}
5502
-
5503
-
5504
- @app.post("/mcp/install")
5505
- async def mcp_install(req: McpInstallRequest, request: Request):
5506
- admin_email, _ = require_admin(request)
5507
- append_audit_event("mcp_install", user_email=admin_email, mcp_id=req.mcp_id)
5508
- return await install_mcp(req.mcp_id)
5509
-
5510
-
5511
- @app.get("/mcp/installed")
5512
- async def mcp_installed(request: Request):
5513
- require_user(request)
5514
- installed = load_mcp_installs().get("installed", {})
5515
- registry = await _get_combined_registry()
5516
- return {"installed": [mcp_public_item(item, installed) for item in registry]}
5517
-
5518
-
5519
- @app.get("/mcp/connectors/{mcp_id}")
5520
- async def mcp_connector(mcp_id: str, request: Request):
5521
- require_user(request)
5522
- registry = await _get_combined_registry()
5523
- item = next((e for e in registry if e["id"] == mcp_id), None)
5524
- if not item or item.get("install_mode") != "connector":
5525
- raise HTTPException(status_code=404, detail="컀λ„₯ν„°λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")
5526
- installed = load_mcp_installs().get("installed", {})
5527
- public = mcp_public_item(item, installed)
5528
- public["instructions"] = [
5529
- "Codex λ˜λŠ” ChatGPT μ•±μ˜ Connectors 섀정을 μ—½λ‹ˆλ‹€.",
5530
- f"{item['name']} ν•­λͺ©μ„ μ„ νƒν•˜κ³  계정을 μΈμ¦ν•©λ‹ˆλ‹€.",
5531
- "인증 ν›„ Lattice AIμ—μ„œ 이 MCPλ₯Ό λ‹€μ‹œ ν™œμ„±ν™”ν•˜λ©΄ μž‘μ—…μ— μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€.",
5532
- ]
5533
- return public
5534
-
5535
-
5536
- @app.post("/mcp/registry/refresh")
5537
- async def mcp_registry_refresh(request: Request):
5538
- require_user(request)
5539
- mcp_registry._REMOTE_REGISTRY_FETCHED_AT = None
5540
- registry = await _get_combined_registry()
5541
- return {"status": "ok", "total": len(registry), "remote": len(mcp_registry._REMOTE_REGISTRY_CACHE)}
5542
-
5543
-
5544
- @app.get("/mcp/claude-code-servers")
5545
- async def mcp_claude_code_servers(request: Request):
5546
- """Read ~/.claude/settings.json mcpServers and return them as Lattice MCP items."""
5547
- require_user(request)
5548
- settings_path = Path.home() / ".claude" / "settings.json"
5549
- if not settings_path.exists():
5550
- return {"servers": []}
5551
- try:
5552
- with open(settings_path, "r", encoding="utf-8") as f:
5553
- settings = json.load(f)
5554
- mcp_servers = settings.get("mcpServers", {})
5555
- servers = []
5556
- for name, cfg in mcp_servers.items():
5557
- cmd = cfg.get("command", "")
5558
- args = cfg.get("args", [])
5559
- package = " ".join([cmd] + args) if args else cmd
5560
- env = cfg.get("env", {})
5561
- env_vars = [{"name": k, "value": v} for k, v in env.items()]
5562
- servers.append({
5563
- "id": f"claude-code:{name}",
5564
- "name": name,
5565
- "description": f"Claude Code MCP: {package}",
5566
- "package": package,
5567
- "icon": "πŸ€–",
5568
- "category": "Claude Code",
5569
- "source": "claude-code",
5570
- "installed": True,
5571
- "env_vars": env_vars,
5572
- })
5573
- return {"servers": servers}
5574
- except Exception as e:
5575
- logging.warning("mcp_claude_code_servers failed: %s", e)
5576
- return {"servers": []}
5577
-
5578
-
5579
- _CUSTOM_MCP_FILE = DATA_DIR / "custom_mcps.json"
5580
-
5581
- def _load_custom_mcps() -> List[Dict]:
5582
- if not _CUSTOM_MCP_FILE.exists():
5583
- return []
5584
- try:
5585
- with open(_CUSTOM_MCP_FILE, "r", encoding="utf-8") as f:
5586
- return json.load(f)
5587
- except Exception:
5588
- return []
5589
-
5590
- def _save_custom_mcps(items: List[Dict]):
5591
- with open(_CUSTOM_MCP_FILE, "w", encoding="utf-8") as f:
5592
- json.dump(items, f, ensure_ascii=False, indent=2)
5593
-
5594
-
5595
- @app.get("/mcp/custom")
5596
- async def mcp_custom_list(request: Request):
5597
- """Return user-added custom MCP entries."""
5598
- require_user(request)
5599
- return {"custom": _load_custom_mcps()}
5600
-
5601
-
5602
- @app.post("/mcp/custom")
5603
- async def mcp_custom_add(req: McpCustomRequest, request: Request):
5604
- """Save a custom MCP entry (admin-only)."""
5605
- admin_email, _ = require_admin(request)
5606
- append_audit_event("mcp_custom_add", user_email=admin_email, name=req.name, package=req.package)
5607
- if not req.name.strip():
5608
- raise HTTPException(status_code=400, detail="name은 ν•„μˆ˜μž…λ‹ˆλ‹€.")
5609
- if not req.package.strip():
5610
- raise HTTPException(status_code=400, detail="packageλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.")
5611
- items = _load_custom_mcps()
5612
- entry = {
5613
- "id": f"custom:{req.name.strip().lower().replace(' ', '-')}",
5614
- "name": req.name.strip(),
5615
- "package": req.package.strip(),
5616
- "description": req.description.strip(),
5617
- "category": req.category or "custom",
5618
- "icon": req.icon or "πŸ”Œ",
5619
- "env_vars": req.env_vars or [],
5620
- "install_mode": "npm",
5621
- "source": "custom",
5622
- "installed": False,
5623
- "added_at": datetime.now().isoformat(),
5624
- }
5625
- # overwrite if same id
5626
- items = [e for e in items if e["id"] != entry["id"]]
5627
- items.append(entry)
5628
- _save_custom_mcps(items)
5629
- return {"status": "ok", "entry": entry}
5630
-
5631
-
5632
- @app.delete("/mcp/custom/{mcp_id:path}")
5633
- async def mcp_custom_delete(mcp_id: str, request: Request):
5634
- """Remove a custom MCP entry."""
5635
- require_admin(request)
5636
- items = _load_custom_mcps()
5637
- before = len(items)
5638
- items = [e for e in items if e["id"] != mcp_id]
5639
- if len(items) == before:
5640
- raise HTTPException(status_code=404, detail="ν•­λͺ©μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")
5641
- _save_custom_mcps(items)
5642
- return {"status": "ok"}
5643
-
5644
-
5645
- # ── Skills & Plugin Directory endpoints ───────────────────────────────────────
5646
-
5647
- @app.get("/skills/marketplace")
5648
- async def skills_marketplace(request: Request, category: Optional[str] = None, author: Optional[str] = None):
5649
- """Skills λ§ˆμΌ“ν”Œλ ˆμ΄μŠ€ (Anthropic Apache-2.0 + κ²€μ¦λœ μ„œλ“œνŒŒν‹° MIT/Apache-2.0)"""
5650
- require_user(request)
5651
- skills = await _fetch_skills_marketplace()
5652
- installed_names = {d.name for d in SKILLS_DIR.iterdir() if d.is_dir()} if SKILLS_DIR.exists() else set()
5653
- filtered = skills
5654
- if category:
5655
- filtered = [s for s in filtered if s.get("category", "").lower() == category.lower()]
5656
- if author:
5657
- filtered = [s for s in filtered if s.get("author", "").lower() == author.lower()]
5658
- return {
5659
- "skills": [{**s, "installed": s["skill"] in installed_names} for s in filtered],
5660
- "total": len(filtered),
5661
- "authors": sorted({s["author"] for s in skills}),
5662
- "categories": sorted({s["category"] for s in skills}),
5663
- }
5664
-
5665
-
5666
- @app.post("/skills/install")
5667
- async def skills_install(req: SkillInstallRequest, request: Request):
5668
- """skill을 둜컬 skills 디렉터리에 μ„€μΉ˜ (Apache-2.0 / MIT, κ΄€λ¦¬μž μ „μš©)"""
5669
- admin_email, _ = require_admin(request)
5670
- append_audit_event("skill_install", user_email=admin_email, plugin=req.plugin, skill=req.skill)
5671
- return await install_skill(req.plugin, req.skill)
5672
-
5673
-
5674
- @app.get("/skills/list")
5675
- async def skills_list(request: Request):
5676
- """λ‘œμ»¬μ— μ„€μΉ˜λœ skills λͺ©λ‘"""
5677
- require_user(request)
5678
- if not SKILLS_DIR.exists():
5679
- return {"skills": []}
5680
- skills = []
5681
- for skill_dir in sorted(SKILLS_DIR.iterdir()):
5682
- if not skill_dir.is_dir():
5683
- continue
5684
- skill_md = skill_dir / "SKILL.md"
5685
- if not skill_md.exists():
5686
- continue
5687
- lines = skill_md.read_text(encoding="utf-8").splitlines()
5688
- desc = next((l.split(":", 1)[1].strip() for l in lines if l.startswith("description:")), "")
5689
- comment = lines[0] if lines else ""
5690
- if "anthropics/claude-plugins-official" in comment:
5691
- source = "anthropic"
5692
- elif "Source:" in comment:
5693
- source = "third-party"
5694
- else:
5695
- source = "local"
5696
- skills.append({"name": skill_dir.name, "description": desc, "source": source})
5697
- return {"skills": skills, "total": len(skills)}
5698
-
5699
-
5700
- @app.post("/skills/marketplace/refresh")
5701
- async def skills_marketplace_refresh(request: Request):
5702
- """Skills λ§ˆμΌ“ν”Œλ ˆμ΄μŠ€ μΊμ‹œ κ°•μ œ κ°±μ‹ """
5703
- require_user(request)
5704
- mcp_registry._SKILLS_MARKETPLACE_FETCHED_AT = None
5705
- skills = await _fetch_skills_marketplace()
5706
- by_author = {}
5707
- for s in skills:
5708
- by_author[s["author"]] = by_author.get(s["author"], 0) + 1
5709
- return {"status": "ok", "total": len(skills), "by_author": by_author}
5710
-
5711
-
5712
- @app.get("/plugins/directory")
5713
- async def plugins_directory(
5714
- request: Request,
5715
- category: Optional[str] = None,
5716
- license: Optional[str] = None,
5717
- q: Optional[str] = None,
5718
- ):
5719
- """μ˜€ν”ˆμ†ŒμŠ€ ν”ŒλŸ¬κ·ΈμΈ 디렉터리 (Apache-2.0 / MIT / MIT-0, λŸ°νƒ€μž„ fetch)"""
5720
- require_user(request)
5721
- plugins = await _fetch_plugin_directory()
5722
- filtered = plugins
5723
- if category:
5724
- filtered = [p for p in filtered if p.get("category", "").lower() == category.lower()]
5725
- if license:
5726
- filtered = [p for p in filtered if p.get("license", "").lower() == license.lower()]
5727
- if q:
5728
- q_lower = q.lower()
5729
- filtered = [
5730
- p for p in filtered
5731
- if q_lower in p.get("name", "").lower()
5732
- or q_lower in p.get("description", "").lower()
5733
- or q_lower in p.get("author", "").lower()
5734
- ]
5735
- return {
5736
- "plugins": filtered,
5737
- "total": len(filtered),
5738
- "categories": sorted({p["category"] for p in plugins if p.get("category")}),
5739
- "licenses": sorted({p["license"] for p in plugins if p.get("license")}),
5740
- }
5741
-
5742
-
5743
- @app.post("/plugins/directory/refresh")
5744
- async def plugins_directory_refresh(request: Request):
5745
- """ν”ŒλŸ¬κ·ΈμΈ 디렉터리 μΊμ‹œ κ°•μ œ κ°±μ‹ """
5746
- require_user(request)
5747
- mcp_registry._PLUGIN_DIRECTORY_FETCHED_AT = None
5748
- plugins = await _fetch_plugin_directory()
5749
- by_license = {}
5750
- for p in plugins:
5751
- lic = p.get("license", "unknown")
5752
- by_license[lic] = by_license.get(lic, 0) + 1
5753
- return {"status": "ok", "total": len(plugins), "by_license": by_license}
5754
-
5755
-
5756
- @app.post("/mcp/call")
5757
- async def mcp_call(req: McpCallRequest, request: Request):
5758
- current_user = require_user(request)
5759
- args = req.args or {}
5760
- if req.action == "knowledge_graph_ingest":
5761
- _require_graph()
5762
- return KNOWLEDGE_GRAPH.ingest_message(
5763
- args.get("role") or ("assistant" if args.get("type") == "ai_response" else "user"),
5764
- args.get("content") or "",
5765
- user_email=args.get("user_email") or current_user,
5766
- user_nickname=args.get("user_nickname"),
5767
- source=args.get("source") or "mcp",
5768
- conversation_id=args.get("conversation_id"),
5769
- raw=args,
5770
- )
5771
- if req.action == "knowledge_graph_search":
5772
- _require_graph()
5773
- return KNOWLEDGE_GRAPH.search(args.get("query") or args.get("q") or "", args.get("limit", 30))
5774
- if req.action == "knowledge_graph_graph":
5775
- _require_graph()
5776
- return KNOWLEDGE_GRAPH.graph(args.get("limit", 300))
5777
- if req.action == "knowledge_graph_context":
5778
- _require_graph()
5779
- return {
5780
- "context": KNOWLEDGE_GRAPH.context_for_query(
5781
- args.get("query") or args.get("q") or "",
5782
- args.get("limit", 6),
5783
- )
5784
- }
5785
- _check_tool_role(req.action, current_user)
5786
- return _tool_response(execute_tool, req.action, req.args or {})
5202
+ # ── MCP / skills / plugins router (latticeai.api.mcp, v1.3.0) ────────────────
5203
+ app.include_router(create_mcp_router(
5204
+ require_user=require_user,
5205
+ require_admin=require_admin,
5206
+ append_audit_event=append_audit_event,
5207
+ load_mcp_installs=load_mcp_installs,
5208
+ recommend_mcps=recommend_mcps,
5209
+ install_mcp=install_mcp,
5210
+ mcp_public_item=mcp_public_item,
5211
+ get_tool_permission=get_tool_permission,
5212
+ tool_governance=TOOL_GOVERNANCE,
5213
+ tool_governance_default=_TOOL_GOVERNANCE_DEFAULT,
5214
+ check_tool_role=_check_tool_role,
5215
+ tool_response=_tool_response,
5216
+ require_graph=_require_graph,
5217
+ knowledge_graph=KNOWLEDGE_GRAPH,
5218
+ data_dir=DATA_DIR,
5219
+ ))
5787
5220
 
5788
5221
 
5789
5222
  # ── P-Reinforce Knowledge Gardener ────────────────────────────────────────────