Initial: MCP server for forge-embed
This commit is contained in:
commit
54bec95af1
3 changed files with 261 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
32
README.md
Normal file
32
README.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# forge-embed-mcp
|
||||
|
||||
MCP server для семантического поиска через forge-embed.
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
git clone git@git.ideeealprojects.com:emal/forge-embed-mcp.git ~/forge-embed-mcp
|
||||
chmod +x ~/forge-embed-mcp/server.py
|
||||
|
||||
claude mcp add forge-embed --scope user \
|
||||
--env FORGE_EMBED_URL=https://embed.ideeealprojects.com \
|
||||
--env FORGE_EMBED_TOKEN=<твой_токен> \
|
||||
-- ~/forge-embed-mcp/server.py
|
||||
```
|
||||
|
||||
## Требования
|
||||
|
||||
- uv (для запуска inline script)
|
||||
- Claude Code
|
||||
- JWT token от администратора
|
||||
|
||||
## Инструменты
|
||||
|
||||
- `list_namespaces` — список доступных проектов
|
||||
- `search` — семантический поиск в namespace
|
||||
- `cross_search` — поиск по всем проектам
|
||||
- `record_outcome` — записать outcome задачи (требует write scope)
|
||||
|
||||
## Токен
|
||||
|
||||
Получить у Алексея. Scopes: `read` (по умолчанию), `write`, `admin`.
|
||||
226
server.py
Executable file
226
server.py
Executable file
|
|
@ -0,0 +1,226 @@
|
|||
#!/usr/bin/env -S uv run --quiet --script
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "mcp[cli]>=1.0.0",
|
||||
# "httpx",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
forge-embed MCP server.
|
||||
Exposes semantic search over forge-embed namespaces to Claude Code.
|
||||
|
||||
Configuration (env vars):
|
||||
FORGE_EMBED_URL — forge-embed API URL (default: http://157.90.28.234:9910)
|
||||
"""
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
FORGE_URL = os.environ.get("FORGE_EMBED_URL", "https://embed.ideeealprojects.com")
|
||||
FORGE_TOKEN = os.environ.get("FORGE_EMBED_TOKEN", "")
|
||||
|
||||
HEADERS = {"Authorization": f"Bearer {FORGE_TOKEN}"} if FORGE_TOKEN else {}
|
||||
|
||||
mcp = FastMCP("forge-embed")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def list_namespaces() -> str:
|
||||
"""
|
||||
List all available namespaces in forge-embed with their entry counts.
|
||||
Use this to discover what projects are indexed and available for search.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=10, headers=HEADERS) as client:
|
||||
resp = await client.get(f"{FORGE_URL}/namespaces")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
lines = ["Available namespaces:"]
|
||||
for name, info in sorted(data.items()):
|
||||
entries = info.get("entries", 0)
|
||||
if entries > 0:
|
||||
lines.append(f" {name}: {entries} entries")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def search(
|
||||
namespace: str,
|
||||
query: str,
|
||||
top_k: int = 5,
|
||||
layer: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Semantic search within a single project namespace.
|
||||
|
||||
Args:
|
||||
namespace: Project namespace (e.g. "1c-buh", "cloudpos", "servicebitrix")
|
||||
query: Search query in natural language (any language)
|
||||
top_k: Number of results to return (default 5, max 20)
|
||||
layer: Filter by layer type. Options:
|
||||
- "profile": only object profiles (for "what attributes does X have?" queries)
|
||||
- "code": only code chunks (for "how does X work?" queries)
|
||||
- "relationship": only relationships (for "what uses X?" queries)
|
||||
- None: search all layers (default)
|
||||
|
||||
Returns formatted results with scores, paths, and text snippets.
|
||||
|
||||
Examples:
|
||||
search("1c-buh", "какие реквизиты у документа реализация", layer="profile")
|
||||
search("1c-buh", "как рассчитывается НДС")
|
||||
search("cloudpos", "authentication token dev mode")
|
||||
"""
|
||||
payload = {
|
||||
"namespace": namespace,
|
||||
"query": query,
|
||||
"top_k": min(top_k, 20),
|
||||
}
|
||||
if layer:
|
||||
payload["metadata_filter"] = {"layer": layer}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30, headers=HEADERS) as client:
|
||||
resp = await client.post(f"{FORGE_URL}/search", json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = data.get("results", [])
|
||||
if not results:
|
||||
return f"No results found in '{namespace}' for query: {query}"
|
||||
|
||||
lines = [
|
||||
f"Found {len(results)} results in '{namespace}' (total indexed: {data.get('total_indexed', 0)})",
|
||||
"",
|
||||
]
|
||||
for i, r in enumerate(results, 1):
|
||||
meta = r.get("metadata", {})
|
||||
r_layer = meta.get("layer", "?")
|
||||
score = r.get("score", 0)
|
||||
rid = r.get("id", "")
|
||||
text = r.get("text", "")
|
||||
|
||||
lines.append(f"--- Result {i} ---")
|
||||
lines.append(f"Score: {score:.3f} | Layer: {r_layer}")
|
||||
lines.append(f"ID: {rid}")
|
||||
if path := meta.get("path"):
|
||||
lines.append(f"Path: {path}")
|
||||
if warnings := meta.get("warnings"):
|
||||
lines.append(f"Warnings: {', '.join(warnings)}")
|
||||
lines.append("")
|
||||
lines.append(text[:1500])
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def cross_search(
|
||||
query: str,
|
||||
top_k: int = 5,
|
||||
namespaces: Optional[list[str]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Semantic search across multiple project namespaces.
|
||||
Useful for finding similar issues, patterns, or solutions across projects.
|
||||
|
||||
Args:
|
||||
query: Search query in natural language
|
||||
top_k: Number of results to return (default 5)
|
||||
namespaces: Specific namespaces to search. If None, searches all.
|
||||
|
||||
Example:
|
||||
cross_search("authentication token not returned")
|
||||
cross_search("проведение документа", namespaces=["1c-buh"])
|
||||
"""
|
||||
payload = {
|
||||
"query": query,
|
||||
"top_k": min(top_k, 20),
|
||||
}
|
||||
if namespaces:
|
||||
payload["namespaces"] = namespaces
|
||||
|
||||
async with httpx.AsyncClient(timeout=30, headers=HEADERS) as client:
|
||||
resp = await client.post(f"{FORGE_URL}/cross-search", json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = data.get("results", [])
|
||||
if not results:
|
||||
return f"No results found for query: {query}"
|
||||
|
||||
lines = [f"Cross-project results for: {query}", ""]
|
||||
for i, r in enumerate(results, 1):
|
||||
ns = r.get("namespace", "?")
|
||||
meta = r.get("metadata", {})
|
||||
r_layer = meta.get("layer", "?")
|
||||
score = r.get("score", 0)
|
||||
rid = r.get("id", "")
|
||||
text = r.get("text", "")
|
||||
|
||||
lines.append(f"--- Result {i} [{ns}] ---")
|
||||
lines.append(f"Score: {score:.3f} | Layer: {r_layer}")
|
||||
lines.append(f"ID: {rid}")
|
||||
lines.append("")
|
||||
lines.append(text[:1000])
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def record_outcome(
|
||||
namespace: str,
|
||||
outcome_type: str,
|
||||
task: str,
|
||||
approach: str,
|
||||
result: str,
|
||||
user: str = "",
|
||||
level: str = "",
|
||||
tags: Optional[list[str]] = None,
|
||||
context: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Record a task outcome (win/fail/pivot) in forge-embed for future learning.
|
||||
Call this after completing a task to build a knowledge base of what works.
|
||||
|
||||
Args:
|
||||
namespace: Project namespace
|
||||
outcome_type: "win" (success), "fail" (failure), "pivot" (changed approach)
|
||||
task: Short description of the task
|
||||
approach: What was tried
|
||||
result: What happened
|
||||
user: Who did it (optional)
|
||||
level: User skill level (observer/communicator/operator/architect)
|
||||
tags: List of tags for categorization
|
||||
context: Additional context about the situation
|
||||
|
||||
Example:
|
||||
record_outcome("1c-buh", "win",
|
||||
task="Add new calculation procedure for НДС",
|
||||
approach="Created extension module with ОбщегоНазначения reuse",
|
||||
result="Tests passed, deployed to staging")
|
||||
"""
|
||||
payload = {
|
||||
"namespace": namespace,
|
||||
"outcome_type": outcome_type,
|
||||
"task": task,
|
||||
"approach": approach,
|
||||
"result": result,
|
||||
"user": user,
|
||||
"level": level,
|
||||
"tags": tags or [],
|
||||
"context": context,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30, headers=HEADERS) as client:
|
||||
resp = await client.post(f"{FORGE_URL}/outcome", json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
return f"Outcome recorded: {data.get('id')}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
Loading…
Add table
Reference in a new issue