ltcai 0.1.17 → 0.1.18

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
@@ -14,10 +14,10 @@ Lattice AI는 개인 개발자가 로컬 모델, 클라우드 모델, 에이전
14
14
 
15
15
  ### 현재 배포 버전
16
16
 
17
- - `PyPI`: `ltcai==0.1.17`
18
- - `npm`: `ltcai@0.1.17`
19
- - `VS Code Marketplace`: `parktaesoo.ltcai@0.1.17`
20
- - `Open VSX`: `parktaesoo.ltcai@0.1.17`
17
+ - `PyPI`: `ltcai==0.1.18`
18
+ - `npm`: `ltcai@0.1.18`
19
+ - `VS Code Marketplace`: `parktaesoo.ltcai@0.1.18`
20
+ - `Open VSX`: `parktaesoo.ltcai@0.1.18`
21
21
 
22
22
  ### 왜 Lattice AI인가
23
23
 
@@ -129,6 +129,9 @@ LATTICEAI_TELEGRAM_BOT_TOKEN=your-token LTCAI
129
129
  | **VS Code / Cursor 확장** | 채팅, Edit Selection, Diff 뷰, 파일 첨부 |
130
130
  | **Telegram 봇** | 로컬 AI 미러 + Codex 클라우드 봇 |
131
131
  | **MCP 서버** | Claude Desktop / Cursor에서 직접 도구 사용 |
132
+ | **MCP 레지스트리** | [registry.modelcontextprotocol.io](https://registry.modelcontextprotocol.io) 원격 목록 실시간 통합 — stdio MCP 서버 원클릭 설치 |
133
+ | **Skills 마켓플레이스** | Anthropic + Adobe · Airtable · Auth0 · Expo · Pydantic 공식 skills 77개 (Apache-2.0 / MIT) |
134
+ | **플러그인 디렉터리** | 오픈소스 플러그인 149개 메타데이터 브라우저 (검색 · 카테고리 · 라이선스 필터) |
132
135
  | **MLX 로컬 추론** | Apple Silicon에서 Gemma, Qwen, DeepSeek 등 |
133
136
  | **클라우드 모델** | OpenAI, Groq, Together, OpenRouter |
134
137
  | **Graph RAG** | 채팅·문서를 SQLite 지식 그래프로 자동 구조화 |
@@ -317,7 +320,13 @@ npm install && npm run build && npm run package:vsix
317
320
  | POST | `/agent` | 파일 생성/수정 에이전트 |
318
321
  | GET | `/tools/list_dir` | 디렉토리 목록 |
319
322
  | POST | `/tools/run_command` | 터미널 명령 실행 |
320
- | GET | `/mcp/installed` | 설치된 MCP 목록 |
323
+ | GET | `/mcp/installed` | 설치된 MCP 목록 (로컬 + 원격 레지스트리) |
324
+ | POST | `/mcp/install` | MCP 설치 (bundled / pip / npm / pypi) |
325
+ | POST | `/mcp/registry/refresh` | 원격 MCP 레지스트리 캐시 갱신 |
326
+ | GET | `/skills/marketplace` | Skills 마켓플레이스 (Anthropic + 서드파티, `?category=` `?author=`) |
327
+ | POST | `/skills/install` | Skill 설치 (`{ "plugin": "...", "skill": "..." }`) |
328
+ | GET | `/skills/list` | 로컬 설치 skills 목록 |
329
+ | GET | `/plugins/directory` | 오픈소스 플러그인 디렉터리 149개 (`?q=` `?category=` `?license=`) |
321
330
 
322
331
  자세한 MCP 도구 목록: [docs/mcp-tools.md](docs/mcp-tools.md)
323
332
 
@@ -359,7 +368,7 @@ launchctl load ~/Library/LaunchAgents/com.ltcai.plist
359
368
 
360
369
  ## 릴리스 노트
361
370
 
362
- 현재 버전: **0.1.17** — 자세한 변경 이력은 [docs/CHANGELOG.md](docs/CHANGELOG.md) 참고.
371
+ 현재 버전: **0.1.18** — 자세한 변경 이력은 [docs/CHANGELOG.md](docs/CHANGELOG.md) 참고.
363
372
 
364
373
  ## 라이선스
365
374
 
package/docs/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.18] - 2026-05-23
4
+
5
+ ### MCP Registry 통합
6
+
7
+ - **`GET /mcp/tools` · `GET /mcp/installed`** — 기존 로컬 목록에 [registry.modelcontextprotocol.io](https://registry.modelcontextprotocol.io) 원격 목록을 실시간 병합
8
+ - **`POST /mcp/install`** — `npm` / `pypi` 설치 모드 추가 — 원격 레지스트리 MCP 서버를 클릭 한 번으로 설치 (`npm install -g` / `pip install`)
9
+ - **`POST /mcp/registry/refresh`** — 원격 레지스트리 캐시 강제 갱신
10
+ - `mcp_public_item` 응답에 `package` · `homepage` · `source` 필드 추가
11
+ - 원격 레지스트리는 1시간 TTL 인메모리 캐시, 서버 재시작 없이 최신 목록 유지
12
+ - `connector_info` 함수 인라인화 — `mcp_connector` 엔드포인트에서 combined registry 직접 조회
13
+
14
+ ### Skills 마켓플레이스 (신규)
15
+
16
+ - **`GET /skills/marketplace`** — Apache-2.0 / MIT 검증 skills 목록 (Anthropic 18개 + 서드파티 59개 = 약 77개)
17
+ - `?category=` · `?author=` 필터 파라미터 지원
18
+ - 응답에 `authors` · `categories` 열거 포함
19
+ - **`POST /skills/install`** — `{ "plugin": "...", "skill": "..." }` 로 SKILL.md 런타임 fetch 후 로컬 `skills/` 에 저장
20
+ - 파일 상단에 출처·라이선스 주석 자동 삽입 (`<!-- Source: ..., Apache-2.0 -->`)
21
+ - `risk.json` 없으면 기본값 자동 생성
22
+ - **`GET /skills/list`** — 로컬 설치 skills 목록 (`source`: local / anthropic / third-party 구분)
23
+ - **`POST /skills/marketplace/refresh`** — 캐시 강제 갱신, author별 집계 반환
24
+ - 서드파티 소스 (모두 라이선스 검증 완료): Adobe (Apache-2.0) · Airtable (MIT) · Auth0 (Apache-2.0) · Expo (MIT) · Pydantic/Logfire (MIT)
25
+
26
+ ### 플러그인 디렉터리 (신규)
27
+
28
+ - **`GET /plugins/directory`** — marketplace.json 기반 오픈소스 플러그인 149개 메타데이터 브라우저
29
+ - `?q=` 전문 검색 · `?category=` · `?license=` 필터 지원
30
+ - 응답에 `categories` · `licenses` 열거 포함
31
+ - **`POST /plugins/directory/refresh`** — 캐시 강제 갱신, license별 집계 반환
32
+ - `_KNOWN_REPO_LICENSES` 맵 — GitHub API 호출 없이 검증된 라이선스 즉시 조회
33
+ - 미확인 레포는 GitHub API fallback + 인메모리 per-repo 캐시
34
+ - Apache-2.0 / MIT / MIT-0 / CC-BY-4.0 플러그인만 노출, 라이선스 없는 34개 자동 제외
35
+
36
+ ### Release
37
+ - 배포 버전을 `0.1.18`로 상향
38
+ - 대상 채널: `npm` · `PyPI` · `VS Code Marketplace` · `Open VSX`
39
+
3
40
  ## [0.1.17] - 2026-05-22
4
41
 
5
42
  ### Multi-LLM Pipeline
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Lattice AI local MLX/cloud LLM workspace server",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
package/server.py CHANGED
@@ -104,7 +104,8 @@ try:
104
104
  except Exception:
105
105
  keyring = None
106
106
 
107
- from datetime import datetime
107
+ from datetime import datetime, timedelta
108
+ import httpx
108
109
 
109
110
  def detect_language(text: str) -> str:
110
111
  """Detect language: 'ko' (Korean) or 'en' (English)."""
@@ -371,6 +372,10 @@ class McpRecommendRequest(BaseModel):
371
372
  class McpInstallRequest(BaseModel):
372
373
  mcp_id: str
373
374
 
375
+ class SkillInstallRequest(BaseModel):
376
+ plugin: str
377
+ skill: str
378
+
374
379
  class KnowledgeGraphIngestRequest(BaseModel):
375
380
  type: str
376
381
  content: str = ""
@@ -655,6 +660,341 @@ MCP_REGISTRY = [
655
660
  },
656
661
  ]
657
662
 
663
+ # ── Remote MCP Registry (registry.modelcontextprotocol.io) ───────────────────
664
+ _REMOTE_REGISTRY_CACHE: List[Dict] = []
665
+ _REMOTE_REGISTRY_FETCHED_AT: Optional[datetime] = None
666
+ _REMOTE_REGISTRY_TTL = timedelta(hours=1)
667
+ _REMOTE_REGISTRY_URL = "https://registry.modelcontextprotocol.io/v0/servers"
668
+ _LOCAL_IDS = {e["id"] for e in MCP_REGISTRY}
669
+
670
+ async def _fetch_remote_mcp_registry() -> List[Dict]:
671
+ global _REMOTE_REGISTRY_CACHE, _REMOTE_REGISTRY_FETCHED_AT
672
+ now = datetime.now()
673
+ if _REMOTE_REGISTRY_FETCHED_AT and (now - _REMOTE_REGISTRY_FETCHED_AT) < _REMOTE_REGISTRY_TTL:
674
+ return _REMOTE_REGISTRY_CACHE
675
+ try:
676
+ result: List[Dict] = []
677
+ cursor = None
678
+ async with httpx.AsyncClient(timeout=10.0) as client:
679
+ while True:
680
+ params: Dict = {"limit": 100}
681
+ if cursor:
682
+ params["cursor"] = cursor
683
+ resp = await client.get(_REMOTE_REGISTRY_URL, params=params)
684
+ resp.raise_for_status()
685
+ data = resp.json()
686
+ for s in data.get("servers", []):
687
+ srv = s["server"]
688
+ meta = s.get("_meta", {}).get("io.modelcontextprotocol.registry/official", {})
689
+ if not meta.get("isLatest", True):
690
+ continue
691
+ pkg = next(
692
+ (p for p in srv.get("packages", [])
693
+ if p.get("transport", {}).get("type") == "stdio"
694
+ and p.get("registryType") in ("npm", "pypi")),
695
+ None,
696
+ )
697
+ if not pkg:
698
+ continue
699
+ entry_id = srv["name"].replace("/", "-").replace(".", "-")
700
+ if entry_id in _LOCAL_IDS:
701
+ continue
702
+ result.append({
703
+ "id": entry_id,
704
+ "name": srv.get("title") or srv["name"],
705
+ "category": "MCP Registry",
706
+ "install_mode": pkg["registryType"],
707
+ "package": pkg["identifier"],
708
+ "package_version": pkg.get("version"),
709
+ "description": srv.get("description", ""),
710
+ "keywords": [],
711
+ "capabilities": [],
712
+ "source": "registry",
713
+ "homepage": (srv.get("repository") or {}).get("url"),
714
+ })
715
+ cursor = data.get("nextCursor")
716
+ if not cursor:
717
+ break
718
+ _REMOTE_REGISTRY_CACHE = result
719
+ _REMOTE_REGISTRY_FETCHED_AT = now
720
+ logging.info("Fetched %d stdio MCP servers from remote registry", len(result))
721
+ except Exception as e:
722
+ logging.warning("Failed to fetch remote MCP registry: %s", e)
723
+ return _REMOTE_REGISTRY_CACHE
724
+
725
+ async def _get_combined_registry() -> List[Dict]:
726
+ remote = await _fetch_remote_mcp_registry()
727
+ return MCP_REGISTRY + remote
728
+
729
+ # ── Anthropic Skills Marketplace (Apache 2.0) ─────────────────────────────────
730
+ _MARKETPLACE_RAW = "https://raw.githubusercontent.com/anthropics/claude-plugins-official/main"
731
+ _MARKETPLACE_API = "https://api.github.com/repos/anthropics/claude-plugins-official/contents"
732
+
733
+ # 검증된 서드파티 skills 소스 (Apache-2.0 / MIT)
734
+ _THIRD_PARTY_SKILL_SOURCES: List[Dict] = [
735
+ {
736
+ "plugin": "adobe-for-creativity", "author": "Adobe", "license": "Apache-2.0",
737
+ "repo": "adobe/skills", "branch": "main",
738
+ "plugin_path": "plugins/creative-cloud/adobe-for-creativity",
739
+ "category": "design",
740
+ },
741
+ {
742
+ "plugin": "airtable", "author": "Airtable", "license": "MIT",
743
+ "repo": "Airtable/skills", "branch": "main",
744
+ "plugin_path": "plugins/airtable",
745
+ "category": "productivity",
746
+ },
747
+ {
748
+ "plugin": "auth0", "author": "Auth0", "license": "Apache-2.0",
749
+ "repo": "auth0/agent-skills", "branch": "main",
750
+ "plugin_path": "plugins/auth0",
751
+ "category": "security",
752
+ },
753
+ {
754
+ "plugin": "expo", "author": "Expo", "license": "MIT",
755
+ "repo": "expo/skills", "branch": "main",
756
+ "plugin_path": "plugins/expo",
757
+ "category": "development",
758
+ },
759
+ {
760
+ "plugin": "logfire", "author": "Pydantic", "license": "MIT",
761
+ "repo": "pydantic/skills", "branch": "main",
762
+ "plugin_path": "plugins/logfire",
763
+ "category": "monitoring",
764
+ },
765
+ ]
766
+
767
+ # 검증된 레포 라이선스 맵 (GitHub API 없이 빠르게 조회)
768
+ _KNOWN_REPO_LICENSES: Dict[str, str] = {
769
+ # Apache-2.0
770
+ "adobe/skills": "Apache-2.0", "awslabs/agent-plugins": "Apache-2.0",
771
+ "auth0/agent-skills": "Apache-2.0", "aws/agent-toolkit-for-aws": "Apache-2.0",
772
+ "carta/plugins": "Apache-2.0", "circlefin/skills": "Apache-2.0",
773
+ "clickhouse/clickhouse-docs": "Apache-2.0", "cloudflare/agents": "Apache-2.0",
774
+ "cockroachdb/claude-code": "Apache-2.0", "codspeed-hq/codspeed-claude": "Apache-2.0",
775
+ "DataDog/datadog-claude-code": "Apache-2.0", "datahub-project/datahub-skills": "Apache-2.0",
776
+ "neondatabase/agent-skills": "Apache-2.0", "PagerDuty/pd-ai-agents-plugins": "Apache-2.0",
777
+ "getpostman/postman-mcp-server": "Apache-2.0", "qdrant/qdrant-skills": "Apache-2.0",
778
+ "rootlyhq/rootly-plugins": "Apache-2.0", "snowflake-labs/snowflake-claude": "Apache-2.0",
779
+ "sumup/sumup-claude": "Apache-2.0", "zilliz-labs/zilliz-skills": "Apache-2.0",
780
+ "mercadopago/mercadopago-claude-marketplace": "Apache-2.0",
781
+ # MIT
782
+ "Airtable/skills": "MIT", "endorlabs/ai-plugins": "MIT",
783
+ "apollographql/apollo-claude-skills": "MIT", "appwrite/skills": "MIT",
784
+ "atlan-inc/claude-code-skills": "MIT", "boxer/boxerbox": "MIT",
785
+ "buildkite/claude-code": "MIT", "coderabbitai/coderabbit-skills": "MIT",
786
+ "CrowdStrike/crowdstrike-skills": "MIT", "microsoft/Dataverse-skills": "MIT",
787
+ "duckdb/duckdb-skills": "MIT", "expo/skills": "MIT",
788
+ "intercom/intercom-skills": "MIT", "pydantic/skills": "MIT",
789
+ "mapbox/mapbox-skills": "MIT", "mintlify/mintlify-skills": "MIT",
790
+ "miroapp/miro-ai": "MIT", "netlify/netlify-skills": "MIT",
791
+ "pinecone-io/pinecone-skills": "MIT", "railwayapp/railway-skills": "MIT",
792
+ "resend/resend-skills": "MIT", "sanity-io/sanity-skills": "MIT",
793
+ "getsentry/sentry-ai-skills": "MIT", "Shopify/liquid-skills": "MIT",
794
+ "slackapi/slack-skills": "MIT", "stripe/stripe-skills": "MIT",
795
+ "twilio-labs/twilio-skills": "MIT", "workos/workos-skills": "MIT",
796
+ "zoom/zoom-skills": "MIT", "aws-samples/sample-claude-code-plugins-for-startups": "MIT-0",
797
+ }
798
+
799
+ _SKILLS_MARKETPLACE_CACHE: List[Dict] = []
800
+ _SKILLS_MARKETPLACE_FETCHED_AT: Optional[datetime] = None
801
+ _SKILLS_MARKETPLACE_TTL = timedelta(hours=1)
802
+
803
+ def _extract_skill_desc(skill_md: str, fallback: str) -> str:
804
+ for line in skill_md.splitlines():
805
+ if line.startswith("description:"):
806
+ return line.split(":", 1)[1].strip()
807
+ return fallback
808
+
809
+ async def _fetch_plugin_skills(client: httpx.AsyncClient, source: Dict) -> List[Dict]:
810
+ """단일 소스에서 skill 목록을 fetch해 반환"""
811
+ repo, branch, plugin_path = source["repo"], source["branch"], source["plugin_path"]
812
+ raw_base = f"https://raw.githubusercontent.com/{repo}/{branch}"
813
+ api_base = f"https://api.github.com/repos/{repo}/contents"
814
+ homepage_base = f"https://github.com/{repo}/tree/{branch}"
815
+
816
+ dir_resp = await client.get(f"{api_base}/{plugin_path}/skills")
817
+ if dir_resp.status_code != 200:
818
+ return []
819
+ skill_dirs = [f["name"] for f in dir_resp.json() if f["type"] == "dir"]
820
+
821
+ skills: List[Dict] = []
822
+ for skill_name in skill_dirs:
823
+ skill_md_url = f"{raw_base}/{plugin_path}/skills/{skill_name}/SKILL.md"
824
+ sm_resp = await client.get(skill_md_url)
825
+ if sm_resp.status_code != 200:
826
+ continue
827
+ skills.append({
828
+ "plugin": source["plugin"],
829
+ "skill": skill_name,
830
+ "category": source.get("category", "development"),
831
+ "description": _extract_skill_desc(sm_resp.text, source.get("description", "")),
832
+ "skill_md_url": skill_md_url,
833
+ "homepage": f"{homepage_base}/{plugin_path}/skills/{skill_name}",
834
+ "license": source["license"],
835
+ "author": source["author"],
836
+ })
837
+ return skills
838
+
839
+ async def _fetch_skills_marketplace() -> List[Dict]:
840
+ global _SKILLS_MARKETPLACE_CACHE, _SKILLS_MARKETPLACE_FETCHED_AT
841
+ now = datetime.now()
842
+ if _SKILLS_MARKETPLACE_FETCHED_AT and (now - _SKILLS_MARKETPLACE_FETCHED_AT) < _SKILLS_MARKETPLACE_TTL:
843
+ return _SKILLS_MARKETPLACE_CACHE
844
+ try:
845
+ result: List[Dict] = []
846
+ async with httpx.AsyncClient(timeout=15.0) as client:
847
+ # ── Anthropic 공식 skills (Apache-2.0) ──────────────────────────
848
+ mp_resp = await client.get(f"{_MARKETPLACE_RAW}/.claude-plugin/marketplace.json")
849
+ mp_resp.raise_for_status()
850
+ marketplace_json = mp_resp.json()
851
+ anthropic_plugins = [
852
+ p for p in marketplace_json.get("plugins", [])
853
+ if (p.get("author") or {}).get("name") == "Anthropic"
854
+ and isinstance(p.get("source"), str)
855
+ and p["source"].startswith("./")
856
+ ]
857
+ for plugin in anthropic_plugins:
858
+ plugin_path = plugin["source"].lstrip("./")
859
+ result.extend(await _fetch_plugin_skills(client, {
860
+ "plugin": plugin["name"],
861
+ "author": "Anthropic",
862
+ "license": "Apache-2.0",
863
+ "repo": "anthropics/claude-plugins-official",
864
+ "branch": "main",
865
+ "plugin_path": plugin_path,
866
+ "category": plugin.get("category", "development"),
867
+ "description": plugin.get("description", ""),
868
+ }))
869
+ # ── 검증된 서드파티 skills ────────────────────────────────────────
870
+ for source in _THIRD_PARTY_SKILL_SOURCES:
871
+ result.extend(await _fetch_plugin_skills(client, source))
872
+
873
+ _SKILLS_MARKETPLACE_CACHE = result
874
+ _SKILLS_MARKETPLACE_FETCHED_AT = now
875
+ logging.info("Fetched %d skills from marketplace (%d sources)",
876
+ len(result), len(anthropic_plugins) + len(_THIRD_PARTY_SKILL_SOURCES))
877
+ except Exception as e:
878
+ logging.warning("Failed to fetch skills marketplace: %s", e)
879
+ return _SKILLS_MARKETPLACE_CACHE
880
+
881
+ # ── Plugin Directory ──────────────────────────────────────────────────────────
882
+ _PLUGIN_DIRECTORY_CACHE: List[Dict] = []
883
+ _PLUGIN_DIRECTORY_FETCHED_AT: Optional[datetime] = None
884
+ _PLUGIN_DIRECTORY_TTL = timedelta(hours=1)
885
+ _OPEN_LICENSES = {"Apache-2.0", "MIT", "MIT-0", "CC-BY-4.0"}
886
+ _REPO_LICENSE_CACHE: Dict[str, str] = {}
887
+
888
+ async def _get_repo_license(client: httpx.AsyncClient, repo: str) -> str:
889
+ if repo in _REPO_LICENSE_CACHE:
890
+ return _REPO_LICENSE_CACHE[repo]
891
+ if repo in _KNOWN_REPO_LICENSES:
892
+ _REPO_LICENSE_CACHE[repo] = _KNOWN_REPO_LICENSES[repo]
893
+ return _KNOWN_REPO_LICENSES[repo]
894
+ try:
895
+ r = await client.get(f"https://api.github.com/repos/{repo}", timeout=5.0)
896
+ lic = (r.json().get("license") or {}).get("spdx_id", "") if r.status_code == 200 else ""
897
+ except Exception:
898
+ lic = ""
899
+ _REPO_LICENSE_CACHE[repo] = lic
900
+ return lic
901
+
902
+ async def _fetch_plugin_directory() -> List[Dict]:
903
+ global _PLUGIN_DIRECTORY_CACHE, _PLUGIN_DIRECTORY_FETCHED_AT
904
+ now = datetime.now()
905
+ if _PLUGIN_DIRECTORY_FETCHED_AT and (now - _PLUGIN_DIRECTORY_FETCHED_AT) < _PLUGIN_DIRECTORY_TTL:
906
+ return _PLUGIN_DIRECTORY_CACHE
907
+ try:
908
+ result: List[Dict] = []
909
+ async with httpx.AsyncClient(timeout=15.0) as client:
910
+ mp_resp = await client.get(f"{_MARKETPLACE_RAW}/.claude-plugin/marketplace.json")
911
+ mp_resp.raise_for_status()
912
+ plugins = mp_resp.json().get("plugins", [])
913
+
914
+ for p in plugins:
915
+ author = (p.get("author") or {}).get("name", "")
916
+ src = p.get("source", {})
917
+
918
+ # Anthropic 같은 레포 플러그인 → Apache-2.0 확인됨
919
+ if isinstance(src, str) and src.startswith("./") and author == "Anthropic":
920
+ plugin_path = src.lstrip("./")
921
+ result.append({
922
+ "name": p["name"],
923
+ "description": p.get("description", ""),
924
+ "category": p.get("category", ""),
925
+ "author": author,
926
+ "license": "Apache-2.0",
927
+ "homepage": p.get("homepage") or f"https://github.com/anthropics/claude-plugins-official/tree/main/{plugin_path}",
928
+ "source_type": "anthropic",
929
+ })
930
+ continue
931
+
932
+ # 외부 레포 플러그인 → 라이선스 확인
933
+ if not isinstance(src, dict):
934
+ continue
935
+ repo_url = src.get("url", "").replace("https://github.com/", "").replace(".git", "").split("/tree/")[0]
936
+ if not repo_url:
937
+ continue
938
+ license_id = await _get_repo_license(client, repo_url)
939
+ if license_id not in _OPEN_LICENSES:
940
+ continue
941
+ result.append({
942
+ "name": p["name"],
943
+ "description": p.get("description", ""),
944
+ "category": p.get("category", ""),
945
+ "author": author or repo_url.split("/")[0],
946
+ "license": license_id,
947
+ "homepage": p.get("homepage") or f"https://github.com/{repo_url}",
948
+ "source_type": "third-party",
949
+ })
950
+
951
+ _PLUGIN_DIRECTORY_CACHE = result
952
+ _PLUGIN_DIRECTORY_FETCHED_AT = now
953
+ logging.info("Fetched plugin directory: %d open-source plugins", len(result))
954
+ except Exception as e:
955
+ logging.warning("Failed to fetch plugin directory: %s", e)
956
+ return _PLUGIN_DIRECTORY_CACHE
957
+
958
+ # ─────────────────────────────────────────────────────────────────────────────
959
+
960
+ SKILLS_DIR = Path(__file__).resolve().parent / "skills"
961
+
962
+ async def install_skill(plugin: str, skill: str) -> Dict:
963
+ marketplace = await _fetch_skills_marketplace()
964
+ entry = next((s for s in marketplace if s["plugin"] == plugin and s["skill"] == skill), None)
965
+ if not entry:
966
+ raise HTTPException(status_code=404, detail=f"Skill '{plugin}/{skill}' not found in marketplace")
967
+ skill_dir = SKILLS_DIR / skill
968
+ skill_dir.mkdir(parents=True, exist_ok=True)
969
+ skill_md_path = skill_dir / "SKILL.md"
970
+ async with httpx.AsyncClient(timeout=15.0) as client:
971
+ resp = await client.get(entry["skill_md_url"])
972
+ resp.raise_for_status()
973
+ content = resp.text
974
+ # 출처 표기 (Apache-2.0 / MIT 공통)
975
+ repo_hint = entry.get("homepage", "")
976
+ attribution = f"<!-- Source: {repo_hint}, {entry['license']} -->\n"
977
+ if not content.startswith("<!--"):
978
+ content = attribution + content
979
+ skill_md_path.write_text(content, encoding="utf-8")
980
+ risk_path = skill_dir / "risk.json"
981
+ if not risk_path.exists():
982
+ risk_path.write_text(json.dumps({
983
+ "risk": "read", "destructive": False,
984
+ "shell": False, "network": False,
985
+ "auto_approve": True, "sandbox": "workspace", "rollback": "none"
986
+ }, indent=2), encoding="utf-8")
987
+ return {
988
+ "status": "installed",
989
+ "plugin": plugin,
990
+ "skill": skill,
991
+ "path": str(skill_dir),
992
+ "license": entry["license"],
993
+ "author": entry["author"],
994
+ }
995
+
996
+ # ─────────────────────────────────────────────────────────────────────────────
997
+
658
998
  def load_users():
659
999
  if not os.path.exists(USERS_FILE):
660
1000
  return {}
@@ -705,26 +1045,42 @@ def mcp_public_item(item: Dict, installed_state: Dict) -> Dict:
705
1045
  connector_pending = item["install_mode"] == "connector" and not state.get("authenticated")
706
1046
  authenticated = item["install_mode"] != "connector" or bool(state.get("authenticated"))
707
1047
  return {
708
- **{key: item[key] for key in ["id", "name", "category", "install_mode", "description", "capabilities"]},
1048
+ "id": item["id"],
1049
+ "name": item["name"],
1050
+ "category": item.get("category", ""),
1051
+ "install_mode": item["install_mode"],
1052
+ "description": item.get("description", ""),
1053
+ "capabilities": item.get("capabilities", []),
709
1054
  "connector_url": item.get("connector_url"),
710
1055
  "external_url": item.get("external_url"),
1056
+ "package": item.get("package"),
1057
+ "homepage": item.get("homepage"),
1058
+ "source": item.get("source", "local"),
711
1059
  "installed": installed,
712
1060
  "status": state.get("status") or ("active" if installed and not connector_pending else "needs_auth" if connector_pending else "available"),
713
1061
  "authenticated": authenticated,
714
1062
  "updated_at": state.get("updated_at"),
715
1063
  }
716
1064
 
717
- def recommend_mcps(query: str, limit: int = 5) -> List[Dict]:
1065
+ async def recommend_mcps(query: str, limit: int = 5) -> List[Dict]:
718
1066
  text = (query or "").lower()
719
1067
  installed = load_mcp_installs().get("installed", {})
1068
+ registry = await _get_combined_registry()
720
1069
  scored = []
721
- for item in MCP_REGISTRY:
1070
+ for item in registry:
722
1071
  score = 0
723
1072
  hits = []
724
- for keyword in item["keywords"]:
1073
+ for keyword in item.get("keywords", []):
725
1074
  if keyword.lower() in text:
726
1075
  score += 3 if len(keyword) > 2 else 1
727
1076
  hits.append(keyword)
1077
+ # description 키워드 매칭 (remote 항목 보완)
1078
+ if not hits and text:
1079
+ desc_words = item.get("description", "").lower().split()
1080
+ for word in text.split():
1081
+ if len(word) > 2 and word in desc_words:
1082
+ score += 1
1083
+ hits.append(word)
728
1084
  if item["id"] == "filesystem" and any(word in text for word in ["만들", "구현", "build", "deploy", "코드", "앱"]):
729
1085
  score += 2
730
1086
  if score:
@@ -736,13 +1092,14 @@ def recommend_mcps(query: str, limit: int = 5) -> List[Dict]:
736
1092
  fallback_ids = ["filesystem", "browser", "documents"]
737
1093
  scored = [
738
1094
  {**mcp_public_item(item, installed), "score": 1, "matched_keywords": []}
739
- for item in MCP_REGISTRY
1095
+ for item in registry
740
1096
  if item["id"] in fallback_ids
741
1097
  ]
742
1098
  return sorted(scored, key=lambda item: item["score"], reverse=True)[: max(1, min(limit, 24))]
743
1099
 
744
- def install_mcp(mcp_id: str) -> Dict:
745
- item = next((entry for entry in MCP_REGISTRY if entry["id"] == mcp_id), None)
1100
+ async def install_mcp(mcp_id: str) -> Dict:
1101
+ registry = await _get_combined_registry()
1102
+ item = next((entry for entry in registry if entry["id"] == mcp_id), None)
746
1103
  if not item:
747
1104
  raise HTTPException(status_code=404, detail="MCP를 찾을 수 없습니다.")
748
1105
  data = load_mcp_installs()
@@ -757,15 +1114,33 @@ def install_mcp(mcp_id: str) -> Dict:
757
1114
  for pkg in packages:
758
1115
  completed = subprocess.run(
759
1116
  [sys.executable, "-m", "pip", "install", "--upgrade", pkg],
760
- capture_output=True,
761
- text=True,
762
- timeout=900,
763
- check=False,
1117
+ capture_output=True, text=True, timeout=900, check=False,
764
1118
  )
765
1119
  if completed.returncode != 0:
766
1120
  raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or f"{pkg} 설치 실패")
767
- status = "active"
768
1121
  message = f"필수 패키지 설치 완료: {', '.join(packages)}"
1122
+ elif item["install_mode"] == "pypi":
1123
+ pkg = item.get("package", "")
1124
+ version = item.get("package_version")
1125
+ pkg_str = f"{pkg}=={version}" if version else pkg
1126
+ completed = subprocess.run(
1127
+ [sys.executable, "-m", "pip", "install", pkg_str],
1128
+ capture_output=True, text=True, timeout=300, check=False,
1129
+ )
1130
+ if completed.returncode != 0:
1131
+ raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or f"{pkg} 설치 실패")
1132
+ message = f"pip 패키지 설치 완료: {pkg_str}"
1133
+ elif item["install_mode"] == "npm":
1134
+ pkg = item.get("package", "")
1135
+ version = item.get("package_version")
1136
+ pkg_str = f"{pkg}@{version}" if version else pkg
1137
+ completed = subprocess.run(
1138
+ ["npm", "install", "-g", pkg_str],
1139
+ capture_output=True, text=True, timeout=300, check=False,
1140
+ )
1141
+ if completed.returncode != 0:
1142
+ raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or f"{pkg} 설치 실패")
1143
+ message = f"npm 패키지 설치 완료: {pkg_str}"
769
1144
  state[mcp_id] = {
770
1145
  "installed": True,
771
1146
  "status": status,
@@ -777,19 +1152,6 @@ def install_mcp(mcp_id: str) -> Dict:
777
1152
  public["message"] = message
778
1153
  return public
779
1154
 
780
- def connector_info(mcp_id: str) -> Dict:
781
- item = next((entry for entry in MCP_REGISTRY if entry["id"] == mcp_id), None)
782
- if not item or item.get("install_mode") != "connector":
783
- raise HTTPException(status_code=404, detail="커넥터를 찾을 수 없습니다.")
784
- installed = load_mcp_installs().get("installed", {})
785
- public = mcp_public_item(item, installed)
786
- public["instructions"] = [
787
- "Codex 또는 ChatGPT 앱의 Connectors 설정을 엽니다.",
788
- f"{item['name']} 항목을 선택하고 계정을 인증합니다.",
789
- "인증 후 Lattice AI에서 이 MCP를 다시 활성화하면 작업에 사용할 수 있습니다.",
790
- ]
791
- return public
792
-
793
1155
  _history_lock = threading.Lock()
794
1156
 
795
1157
  def get_audit_log() -> List[Dict]:
@@ -5542,6 +5904,7 @@ async def tools_permissions(request: Request):
5542
5904
  @app.get("/mcp/tools")
5543
5905
  async def mcp_tools():
5544
5906
  installed = load_mcp_installs().get("installed", {})
5907
+ registry = await _get_combined_registry()
5545
5908
  tools = []
5546
5909
  for name, description in _MCP_TOOL_DESCRIPTIONS.items():
5547
5910
  policy = TOOL_GOVERNANCE.get(name, _TOOL_GOVERNANCE_DEFAULT)
@@ -5562,7 +5925,7 @@ async def mcp_tools():
5562
5925
  return {
5563
5926
  "status": "ok",
5564
5927
  "workspace": str(AGENT_ROOT),
5565
- "installed_mcps": [mcp_public_item(item, installed) for item in MCP_REGISTRY],
5928
+ "installed_mcps": [mcp_public_item(item, installed) for item in registry],
5566
5929
  "tools": tools,
5567
5930
  }
5568
5931
 
@@ -5570,26 +5933,159 @@ async def mcp_tools():
5570
5933
  @app.post("/mcp/recommend")
5571
5934
  async def mcp_recommend(req: McpRecommendRequest, request: Request):
5572
5935
  require_user(request)
5573
- return {"recommendations": recommend_mcps(req.query, req.limit)}
5936
+ return {"recommendations": await recommend_mcps(req.query, req.limit)}
5574
5937
 
5575
5938
 
5576
5939
  @app.post("/mcp/install")
5577
5940
  async def mcp_install(req: McpInstallRequest, request: Request):
5578
5941
  require_user(request)
5579
- return install_mcp(req.mcp_id)
5942
+ return await install_mcp(req.mcp_id)
5580
5943
 
5581
5944
 
5582
5945
  @app.get("/mcp/installed")
5583
5946
  async def mcp_installed(request: Request):
5584
5947
  require_user(request)
5585
5948
  installed = load_mcp_installs().get("installed", {})
5586
- return {"installed": [mcp_public_item(item, installed) for item in MCP_REGISTRY]}
5949
+ registry = await _get_combined_registry()
5950
+ return {"installed": [mcp_public_item(item, installed) for item in registry]}
5587
5951
 
5588
5952
 
5589
5953
  @app.get("/mcp/connectors/{mcp_id}")
5590
5954
  async def mcp_connector(mcp_id: str, request: Request):
5591
5955
  require_user(request)
5592
- return connector_info(mcp_id)
5956
+ registry = await _get_combined_registry()
5957
+ item = next((e for e in registry if e["id"] == mcp_id), None)
5958
+ if not item or item.get("install_mode") != "connector":
5959
+ raise HTTPException(status_code=404, detail="커넥터를 찾을 수 없습니다.")
5960
+ installed = load_mcp_installs().get("installed", {})
5961
+ public = mcp_public_item(item, installed)
5962
+ public["instructions"] = [
5963
+ "Codex 또는 ChatGPT 앱의 Connectors 설정을 엽니다.",
5964
+ f"{item['name']} 항목을 선택하고 계정을 인증합니다.",
5965
+ "인증 후 Lattice AI에서 이 MCP를 다시 활성화하면 작업에 사용할 수 있습니다.",
5966
+ ]
5967
+ return public
5968
+
5969
+
5970
+ @app.post("/mcp/registry/refresh")
5971
+ async def mcp_registry_refresh(request: Request):
5972
+ require_user(request)
5973
+ global _REMOTE_REGISTRY_FETCHED_AT
5974
+ _REMOTE_REGISTRY_FETCHED_AT = None
5975
+ registry = await _get_combined_registry()
5976
+ return {"status": "ok", "total": len(registry), "remote": len(_REMOTE_REGISTRY_CACHE)}
5977
+
5978
+
5979
+ # ── Skills & Plugin Directory endpoints ───────────────────────────────────────
5980
+
5981
+ @app.get("/skills/marketplace")
5982
+ async def skills_marketplace(request: Request, category: Optional[str] = None, author: Optional[str] = None):
5983
+ """Skills 마켓플레이스 (Anthropic Apache-2.0 + 검증된 서드파티 MIT/Apache-2.0)"""
5984
+ require_user(request)
5985
+ skills = await _fetch_skills_marketplace()
5986
+ installed_names = {d.name for d in SKILLS_DIR.iterdir() if d.is_dir()} if SKILLS_DIR.exists() else set()
5987
+ filtered = skills
5988
+ if category:
5989
+ filtered = [s for s in filtered if s.get("category", "").lower() == category.lower()]
5990
+ if author:
5991
+ filtered = [s for s in filtered if s.get("author", "").lower() == author.lower()]
5992
+ return {
5993
+ "skills": [{**s, "installed": s["skill"] in installed_names} for s in filtered],
5994
+ "total": len(filtered),
5995
+ "authors": sorted({s["author"] for s in skills}),
5996
+ "categories": sorted({s["category"] for s in skills}),
5997
+ }
5998
+
5999
+
6000
+ @app.post("/skills/install")
6001
+ async def skills_install(req: SkillInstallRequest, request: Request):
6002
+ """skill을 로컬 skills 디렉터리에 설치 (Apache-2.0 / MIT)"""
6003
+ require_user(request)
6004
+ return await install_skill(req.plugin, req.skill)
6005
+
6006
+
6007
+ @app.get("/skills/list")
6008
+ async def skills_list(request: Request):
6009
+ """로컬에 설치된 skills 목록"""
6010
+ require_user(request)
6011
+ if not SKILLS_DIR.exists():
6012
+ return {"skills": []}
6013
+ skills = []
6014
+ for skill_dir in sorted(SKILLS_DIR.iterdir()):
6015
+ if not skill_dir.is_dir():
6016
+ continue
6017
+ skill_md = skill_dir / "SKILL.md"
6018
+ if not skill_md.exists():
6019
+ continue
6020
+ lines = skill_md.read_text(encoding="utf-8").splitlines()
6021
+ desc = next((l.split(":", 1)[1].strip() for l in lines if l.startswith("description:")), "")
6022
+ comment = lines[0] if lines else ""
6023
+ if "anthropics/claude-plugins-official" in comment:
6024
+ source = "anthropic"
6025
+ elif "Source:" in comment:
6026
+ source = "third-party"
6027
+ else:
6028
+ source = "local"
6029
+ skills.append({"name": skill_dir.name, "description": desc, "source": source})
6030
+ return {"skills": skills, "total": len(skills)}
6031
+
6032
+
6033
+ @app.post("/skills/marketplace/refresh")
6034
+ async def skills_marketplace_refresh(request: Request):
6035
+ """Skills 마켓플레이스 캐시 강제 갱신"""
6036
+ require_user(request)
6037
+ global _SKILLS_MARKETPLACE_FETCHED_AT
6038
+ _SKILLS_MARKETPLACE_FETCHED_AT = None
6039
+ skills = await _fetch_skills_marketplace()
6040
+ by_author = {}
6041
+ for s in skills:
6042
+ by_author[s["author"]] = by_author.get(s["author"], 0) + 1
6043
+ return {"status": "ok", "total": len(skills), "by_author": by_author}
6044
+
6045
+
6046
+ @app.get("/plugins/directory")
6047
+ async def plugins_directory(
6048
+ request: Request,
6049
+ category: Optional[str] = None,
6050
+ license: Optional[str] = None,
6051
+ q: Optional[str] = None,
6052
+ ):
6053
+ """오픈소스 플러그인 디렉터리 (Apache-2.0 / MIT / MIT-0, 런타임 fetch)"""
6054
+ require_user(request)
6055
+ plugins = await _fetch_plugin_directory()
6056
+ filtered = plugins
6057
+ if category:
6058
+ filtered = [p for p in filtered if p.get("category", "").lower() == category.lower()]
6059
+ if license:
6060
+ filtered = [p for p in filtered if p.get("license", "").lower() == license.lower()]
6061
+ if q:
6062
+ q_lower = q.lower()
6063
+ filtered = [
6064
+ p for p in filtered
6065
+ if q_lower in p.get("name", "").lower()
6066
+ or q_lower in p.get("description", "").lower()
6067
+ or q_lower in p.get("author", "").lower()
6068
+ ]
6069
+ return {
6070
+ "plugins": filtered,
6071
+ "total": len(filtered),
6072
+ "categories": sorted({p["category"] for p in plugins if p.get("category")}),
6073
+ "licenses": sorted({p["license"] for p in plugins if p.get("license")}),
6074
+ }
6075
+
6076
+
6077
+ @app.post("/plugins/directory/refresh")
6078
+ async def plugins_directory_refresh(request: Request):
6079
+ """플러그인 디렉터리 캐시 강제 갱신"""
6080
+ require_user(request)
6081
+ global _PLUGIN_DIRECTORY_FETCHED_AT
6082
+ _PLUGIN_DIRECTORY_FETCHED_AT = None
6083
+ plugins = await _fetch_plugin_directory()
6084
+ by_license = {}
6085
+ for p in plugins:
6086
+ lic = p.get("license", "unknown")
6087
+ by_license[lic] = by_license.get(lic, 0) + 1
6088
+ return {"status": "ok", "total": len(plugins), "by_license": by_license}
5593
6089
 
5594
6090
 
5595
6091
  @app.post("/mcp/call")