solidx-agenthub-runtime 0.1.0__py3-none-any.whl
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.
- agenthub/__init__.py +3 -0
- agenthub/bridge/__init__.py +1 -0
- agenthub/bridge/audit.py +72 -0
- agenthub/bridge/auth.py +98 -0
- agenthub/bridge/local.py +123 -0
- agenthub/bridge/router.py +62 -0
- agenthub/bridge/routes.py +120 -0
- agenthub/bridge/server.py +215 -0
- agenthub/bridge/service.py +83 -0
- agenthub/bridge/sessions.py +80 -0
- agenthub/bridge/signing.py +59 -0
- agenthub/bridge/tool_session.py +69 -0
- agenthub/builtin_catalog.json +130 -0
- agenthub/cli.py +140 -0
- agenthub/config/__init__.py +3 -0
- agenthub/config/source_db.py +79 -0
- agenthub/config/source_disk.py +59 -0
- agenthub/config/spec.py +103 -0
- agenthub/events/__init__.py +45 -0
- agenthub/events/envelope.py +60 -0
- agenthub/identity/__init__.py +129 -0
- agenthub/inputs.py +68 -0
- agenthub/llm/__init__.py +207 -0
- agenthub/loop/__init__.py +217 -0
- agenthub/manager/__init__.py +1 -0
- agenthub/manager/app.py +489 -0
- agenthub/manager/secrets.py +66 -0
- agenthub/media.py +262 -0
- agenthub/persistence/__init__.py +9 -0
- agenthub/persistence/models/__init__.py +23 -0
- agenthub/persistence/models/agent_event.py +28 -0
- agenthub/persistence/models/agent_human_request.py +27 -0
- agenthub/persistence/models/agent_job.py +34 -0
- agenthub/persistence/models/agent_process.py +31 -0
- agenthub/persistence/models/agent_registry.py +22 -0
- agenthub/persistence/models/agent_role.py +15 -0
- agenthub/persistence/models/agent_secret.py +16 -0
- agenthub/persistence/models/agent_session.py +35 -0
- agenthub/persistence/models/agent_session_checkpoint.py +18 -0
- agenthub/persistence/models/agent_skill.py +15 -0
- agenthub/persistence/models/agent_skill_registry.py +17 -0
- agenthub/persistence/models/agent_tool.py +15 -0
- agenthub/persistence/models/agent_tool_registry.py +22 -0
- agenthub/persistence/models/base.py +62 -0
- agenthub/persistence/models/mcp_audit_log.py +33 -0
- agenthub/persistence/models/secret.py +27 -0
- agenthub/persistence/repository.py +394 -0
- agenthub/persistence/seed.py +61 -0
- agenthub/process/__init__.py +1 -0
- agenthub/process/agent_api.py +306 -0
- agenthub/process/agent_tokens.py +78 -0
- agenthub/process/app.py +578 -0
- agenthub/process/bridge_api.py +149 -0
- agenthub/process/local.py +276 -0
- agenthub/runtime_prompt.py +64 -0
- agenthub/sdk/__init__.py +10 -0
- agenthub/sdk/context.py +59 -0
- agenthub/sdk/project.py +14 -0
- agenthub/sdk/tool.py +164 -0
- agenthub/skills/__init__.py +25 -0
- agenthub/skills/builtin/db_query_assistant.md +78 -0
- agenthub/skills/builtin/filesystem.md +170 -0
- agenthub/skills/builtin/quality_checks.md +24 -0
- agenthub/skills/builtin/solidctl.md +68 -0
- agenthub/skills/builtin/web_research.md +11 -0
- agenthub/tools/__init__.py +1 -0
- agenthub/tools/builtin/__init__.py +1 -0
- agenthub/tools/builtin/bash.py +45 -0
- agenthub/tools/builtin/filesystem.py +298 -0
- agenthub/tools/builtin/list_widgets.py +12 -0
- agenthub/tools/builtin/load_skill.py +12 -0
- agenthub/tools/builtin/request_human_input.py +14 -0
- agenthub/tools/builtin/run_read_only_sql.py +79 -0
- agenthub/tools/builtin/web_search.py +129 -0
- agenthub/tools/executor.py +102 -0
- agenthub/tools/guard.py +49 -0
- agenthub/tools/loader.py +73 -0
- agenthub/tools/validate.py +52 -0
- agenthub/widgets.py +34 -0
- solidx_agenthub_runtime-0.1.0.dist-info/METADATA +25 -0
- solidx_agenthub_runtime-0.1.0.dist-info/RECORD +84 -0
- solidx_agenthub_runtime-0.1.0.dist-info/WHEEL +5 -0
- solidx_agenthub_runtime-0.1.0.dist-info/entry_points.txt +2 -0
- solidx_agenthub_runtime-0.1.0.dist-info/top_level.txt +1 -0
agenthub/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LLM-free, session-scoped MCP access to AgentHub tools."""
|
agenthub/bridge/audit.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Bounded, best-effort audit writes; never capture HTTP streams."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import delete
|
|
10
|
+
|
|
11
|
+
from agenthub.persistence.models import McpAuditLog, naive_now
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def payload(value):
|
|
18
|
+
if value is None or os.getenv("AGENTHUB_MCP_AUDIT_PAYLOADS", "true").lower() != "true":
|
|
19
|
+
return None
|
|
20
|
+
text = value if isinstance(value, str) else json.dumps(value, default=str)
|
|
21
|
+
limit = max(0, int(os.getenv("AGENTHUB_MCP_AUDIT_MAX_CHARS", "100000")))
|
|
22
|
+
return text if len(text) <= limit else text[:limit] + "...[truncated]"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuditWriter:
|
|
26
|
+
def __init__(self, repo):
|
|
27
|
+
self.repo = repo
|
|
28
|
+
self.tasks = set()
|
|
29
|
+
|
|
30
|
+
def write(self, values):
|
|
31
|
+
# Map the bridge's audit fields onto Core's ``ss_mcp_audit_log`` columns.
|
|
32
|
+
code, message = values.get("error_code"), values.get("error_message")
|
|
33
|
+
if code is not None and not str(code).isdigit():
|
|
34
|
+
message, code = message or str(code), None
|
|
35
|
+
user_id = values.get("user_id")
|
|
36
|
+
row = McpAuditLog(
|
|
37
|
+
user_id=int(user_id) if str(user_id).isdigit() else None,
|
|
38
|
+
username=str(values["username"])[:128] if values.get("username") else None,
|
|
39
|
+
transport="streamable-http", mcp_session_id=values.get("session_id"),
|
|
40
|
+
client_addr=values.get("client_addr"), method=values["rpc_method"],
|
|
41
|
+
request_id=values.get("rpc_request_id"), tool_name=values.get("tool_name"),
|
|
42
|
+
request_params=values.get("request_params"), status=values["status"],
|
|
43
|
+
response_result=values.get("response_result"),
|
|
44
|
+
error_code=int(code) if code is not None else None, error_message=message,
|
|
45
|
+
duration_ms=values.get("duration_ms"))
|
|
46
|
+
with self.repo.transaction() as db:
|
|
47
|
+
db.add(row)
|
|
48
|
+
|
|
49
|
+
async def _write(self, values):
|
|
50
|
+
try:
|
|
51
|
+
await asyncio.to_thread(self.write, values)
|
|
52
|
+
except Exception:
|
|
53
|
+
logger.warning("MCP audit write failed")
|
|
54
|
+
|
|
55
|
+
def log(self, **values):
|
|
56
|
+
# Back pressure drops audit entries, not tool responses.
|
|
57
|
+
if len(self.tasks) >= 1000:
|
|
58
|
+
logger.warning("MCP audit queue full")
|
|
59
|
+
return
|
|
60
|
+
task = asyncio.create_task(self._write(values))
|
|
61
|
+
self.tasks.add(task)
|
|
62
|
+
task.add_done_callback(self.tasks.discard)
|
|
63
|
+
|
|
64
|
+
async def close(self):
|
|
65
|
+
if self.tasks:
|
|
66
|
+
await asyncio.gather(*list(self.tasks))
|
|
67
|
+
|
|
68
|
+
def purge(self):
|
|
69
|
+
days = int(os.getenv("AGENTHUB_MCP_AUDIT_RETENTION_DAYS", "0"))
|
|
70
|
+
if days > 0:
|
|
71
|
+
with self.repo.transaction() as db:
|
|
72
|
+
db.execute(delete(McpAuditLog).where(McpAuditLog.created_at < naive_now() - timedelta(days=days)))
|
agenthub/bridge/auth.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Bearer authentication and workspace binding for each HTTP request."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from starlette.responses import JSONResponse
|
|
9
|
+
|
|
10
|
+
from agenthub.bridge.sessions import resolve
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class BridgeContext:
|
|
15
|
+
agent_id: int | str
|
|
16
|
+
principal: object
|
|
17
|
+
session: object
|
|
18
|
+
explicit: bool = False
|
|
19
|
+
credential_kind: str = "jwt"
|
|
20
|
+
credential_fingerprint: str = ""
|
|
21
|
+
client_addr: str | None = None
|
|
22
|
+
process_id: str | None = None
|
|
23
|
+
client_info: dict = field(default_factory=dict)
|
|
24
|
+
audit_arguments: object = None
|
|
25
|
+
audit_response: object = None
|
|
26
|
+
human_request_id: str | None = None
|
|
27
|
+
|
|
28
|
+
def clean(self, value):
|
|
29
|
+
import json
|
|
30
|
+
text = json.dumps(value, default=str)
|
|
31
|
+
for credential in (self.principal.access_token, self.principal.api_key):
|
|
32
|
+
if credential:
|
|
33
|
+
text = text.replace(credential, "[REDACTED]")
|
|
34
|
+
return json.loads(text)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BridgeAuth:
|
|
38
|
+
def __init__(self, app, manager, audit):
|
|
39
|
+
self.app, self.manager, self.audit = app, manager, audit
|
|
40
|
+
|
|
41
|
+
async def __call__(self, scope, receive, send):
|
|
42
|
+
if scope["type"] != "http":
|
|
43
|
+
await self.app(scope, receive, send)
|
|
44
|
+
return
|
|
45
|
+
match = re.fullmatch(r"/mcp/agents/([^/]+)(?:/sessions/([^/]+))?/?", scope["path"])
|
|
46
|
+
if not match:
|
|
47
|
+
await JSONResponse({"error": "MCP endpoint not found"}, 404)(scope, receive, send)
|
|
48
|
+
return
|
|
49
|
+
agent_id, sid = match.groups()
|
|
50
|
+
try:
|
|
51
|
+
agent_id = int(agent_id)
|
|
52
|
+
except ValueError:
|
|
53
|
+
await JSONResponse({"error": "MCP endpoint not found"}, 404)(scope, receive, send)
|
|
54
|
+
return
|
|
55
|
+
headers = dict(scope.get("headers", ()))
|
|
56
|
+
auth = headers.get(b"authorization", b"").decode("latin-1")
|
|
57
|
+
api_key = headers.get(b"solidx-api-key", b"").decode("latin-1") or None
|
|
58
|
+
token = auth[7:].strip() if auth.startswith("Bearer ") else None
|
|
59
|
+
principal = None
|
|
60
|
+
kind = "api_key" if api_key else "jwt"
|
|
61
|
+
status, message = 401, "Invalid credentials"
|
|
62
|
+
try:
|
|
63
|
+
if token and not api_key:
|
|
64
|
+
try:
|
|
65
|
+
principal = await asyncio.to_thread(self.manager.identity.validate, token=token)
|
|
66
|
+
except Exception:
|
|
67
|
+
# Bearer API keys are a compatibility extension, not OAuth.
|
|
68
|
+
principal = await asyncio.to_thread(self.manager.identity.validate, api_key=token)
|
|
69
|
+
kind = "api_key"
|
|
70
|
+
elif api_key and not token:
|
|
71
|
+
principal = await asyncio.to_thread(self.manager.identity.validate, api_key=api_key)
|
|
72
|
+
else:
|
|
73
|
+
raise PermissionError("Missing or ambiguous credential")
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
if principal:
|
|
77
|
+
try:
|
|
78
|
+
session = await asyncio.to_thread(resolve, self.manager.repo, agent_id, principal, sid)
|
|
79
|
+
except PermissionError:
|
|
80
|
+
status, message = 403, "Agent or session access denied"
|
|
81
|
+
except LookupError:
|
|
82
|
+
status, message = 404, "Agent or session unavailable"
|
|
83
|
+
else:
|
|
84
|
+
scoped = BridgeContext(
|
|
85
|
+
agent_id, principal, session, bool(sid), kind,
|
|
86
|
+
hashlib.sha256((api_key or token).encode()).hexdigest()[:16],
|
|
87
|
+
(scope.get("client") or (None,))[0],
|
|
88
|
+
)
|
|
89
|
+
inner = dict(scope)
|
|
90
|
+
inner["bridge_context"] = scoped
|
|
91
|
+
inner["path"], inner["raw_path"], inner["root_path"] = "/", b"/", ""
|
|
92
|
+
await self.app(inner, receive, send)
|
|
93
|
+
return
|
|
94
|
+
self.audit.log(agent_id=agent_id, user_id=principal.id if principal else None,
|
|
95
|
+
rpc_method="auth.denied", status="denied", error_code=str(status),
|
|
96
|
+
error_message=message, client_addr=(scope.get("client") or (None,))[0])
|
|
97
|
+
await JSONResponse({"error": message}, status, headers={"WWW-Authenticate": "Bearer"} if status == 401
|
|
98
|
+
else {})(scope, receive, send)
|
agenthub/bridge/local.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Disk-mode MCP backend: one local user, in-process tools, prompt-only approvals."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from threading import RLock
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from agenthub.bridge.auth import BridgeContext
|
|
12
|
+
from agenthub.bridge.tool_session import PROMPT_APPROVALS, ToolSession, catalog
|
|
13
|
+
from agenthub.tools.executor import ParkSession
|
|
14
|
+
from agenthub.tools.loader import ToolRegistry
|
|
15
|
+
|
|
16
|
+
LOCAL_PRINCIPAL = SimpleNamespace(id="local", username="local", role_ids=(), access_token=None, api_key=None)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LocalAudit:
|
|
20
|
+
def __init__(self, store=None):
|
|
21
|
+
self.store = store
|
|
22
|
+
if store:
|
|
23
|
+
with store.lock, store.db:
|
|
24
|
+
store.db.execute("CREATE TABLE IF NOT EXISTS mcp_calls "
|
|
25
|
+
"(id INTEGER PRIMARY KEY, session_id TEXT, data TEXT, created_at TEXT)")
|
|
26
|
+
|
|
27
|
+
def log(self, **values):
|
|
28
|
+
if not self.store:
|
|
29
|
+
return
|
|
30
|
+
with self.store.lock, self.store.db:
|
|
31
|
+
self.store.db.execute("INSERT INTO mcp_calls (session_id, data, created_at) VALUES (?, ?, ?)",
|
|
32
|
+
(values.get("session_id"), json.dumps(values, default=str),
|
|
33
|
+
datetime.now(timezone.utc).isoformat()))
|
|
34
|
+
|
|
35
|
+
async def close(self):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LocalBridge:
|
|
40
|
+
# No AgentHub UI exists in disk mode, so approvals must arrive through MCP elicitation.
|
|
41
|
+
out_of_band_approvals = False
|
|
42
|
+
|
|
43
|
+
def __init__(self, spec, store=None):
|
|
44
|
+
self.spec = spec
|
|
45
|
+
self.registry = ToolRegistry(spec.tools)
|
|
46
|
+
self.audit = LocalAudit(store)
|
|
47
|
+
self.lock = RLock()
|
|
48
|
+
self.pending = {}
|
|
49
|
+
self.session = self._new_session()
|
|
50
|
+
|
|
51
|
+
def _new_session(self):
|
|
52
|
+
return ToolSession(self.spec, self.registry, str(uuid4()))
|
|
53
|
+
|
|
54
|
+
def _principal(self):
|
|
55
|
+
return {"id": LOCAL_PRINCIPAL.id, "username": LOCAL_PRINCIPAL.username, "role_ids": ()}
|
|
56
|
+
|
|
57
|
+
def scope(self, ctx):
|
|
58
|
+
# The audit middleware and handlers must share one mutable scope per request.
|
|
59
|
+
scope = getattr(ctx, "_agenthub_scope", None)
|
|
60
|
+
if scope is None:
|
|
61
|
+
scope = BridgeContext(self.spec.agent_id, LOCAL_PRINCIPAL, SimpleNamespace(id=self.session.id),
|
|
62
|
+
credential_kind="local")
|
|
63
|
+
ctx._agenthub_scope = scope
|
|
64
|
+
return scope
|
|
65
|
+
|
|
66
|
+
async def catalog(self, scope):
|
|
67
|
+
return self.session.redact(catalog(self.spec, self.registry, PROMPT_APPROVALS), {})
|
|
68
|
+
|
|
69
|
+
async def call(self, scope, name, arguments):
|
|
70
|
+
session = self.session
|
|
71
|
+
action = {"tool_name": name, "arguments": arguments, "tool_call_id": str(uuid4())}
|
|
72
|
+
redacted = session.redact(arguments, self._principal())
|
|
73
|
+
scope.audit_arguments = redacted
|
|
74
|
+
try:
|
|
75
|
+
output = await asyncio.to_thread(session.call, action, self._principal())
|
|
76
|
+
except ParkSession as pause:
|
|
77
|
+
rid = str(uuid4())
|
|
78
|
+
ttl = int(os.getenv("AGENTHUB_MCP_APPROVAL_TTL", "900"))
|
|
79
|
+
with self.lock:
|
|
80
|
+
self.pending = {k: v for k, v in self.pending.items()
|
|
81
|
+
if v["expires_at"] > datetime.now(timezone.utc)}
|
|
82
|
+
self.pending[rid] = {"session": session, "action": action, "status": "pending",
|
|
83
|
+
"expires_at": datetime.now(timezone.utc) + timedelta(seconds=ttl)}
|
|
84
|
+
scope.human_request_id = rid
|
|
85
|
+
return {"status": "approval_required", "request_id": rid, "question": pause.question}
|
|
86
|
+
return {"status": "error" if output["returncode"] else "ok", "output": output["output"]}
|
|
87
|
+
|
|
88
|
+
async def answer(self, scope, rid, approve):
|
|
89
|
+
with self.lock:
|
|
90
|
+
request = self.pending.get(rid)
|
|
91
|
+
if not request or request["session"] is not self.session or request["status"] != "pending":
|
|
92
|
+
raise PermissionError("Invalid approval request")
|
|
93
|
+
request["status"] = "approved" if approve else "rejected"
|
|
94
|
+
|
|
95
|
+
async def resume(self, scope, rid):
|
|
96
|
+
scope.human_request_id = rid
|
|
97
|
+
with self.lock:
|
|
98
|
+
request = self.pending.get(rid)
|
|
99
|
+
if not request or request["session"] is not self.session:
|
|
100
|
+
return {"status": "error", "output": '{"error":"Request not found"}'}
|
|
101
|
+
if "result" in request:
|
|
102
|
+
return request["result"]
|
|
103
|
+
if request["status"] == "pending" and datetime.now(timezone.utc) >= request["expires_at"]:
|
|
104
|
+
request["status"] = "expired"
|
|
105
|
+
if request["status"] != "approved":
|
|
106
|
+
return {"status": request["status"]}
|
|
107
|
+
request["status"] = "executing"
|
|
108
|
+
result = await asyncio.to_thread(request["session"].call, request["action"], self._principal(),
|
|
109
|
+
approved=True)
|
|
110
|
+
output = {"status": "error" if result["returncode"] else "ok", "output": result["output"]}
|
|
111
|
+
with self.lock:
|
|
112
|
+
request["status"], request["result"] = "executed", output
|
|
113
|
+
return output
|
|
114
|
+
|
|
115
|
+
async def rotate(self, scope):
|
|
116
|
+
with self.lock:
|
|
117
|
+
self.session = self._new_session()
|
|
118
|
+
self.pending.clear()
|
|
119
|
+
scope.session = SimpleNamespace(id=self.session.id)
|
|
120
|
+
return {"status": "ok", "output": json.dumps({"session_id": self.session.id})}
|
|
121
|
+
|
|
122
|
+
async def close(self):
|
|
123
|
+
return None
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Route tools to secret-bearing agent processes without retrying mutations."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from time import monotonic
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from sqlalchemy import select
|
|
11
|
+
|
|
12
|
+
from agenthub.bridge.signing import agent_key, sign
|
|
13
|
+
from agenthub.persistence.models import AgentProcess
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProcessRouter:
|
|
17
|
+
def __init__(self, manager):
|
|
18
|
+
self.manager = manager
|
|
19
|
+
self.client = httpx.AsyncClient(timeout=int(os.getenv("AGENTHUB_TOOL_TIMEOUT", "120")) + 30,
|
|
20
|
+
trust_env=False)
|
|
21
|
+
self.catalogs = {}
|
|
22
|
+
|
|
23
|
+
def process(self, agent_id, version):
|
|
24
|
+
with self.manager.repo.sessions() as db:
|
|
25
|
+
row = db.scalar(select(AgentProcess).where(
|
|
26
|
+
AgentProcess.agent_id == agent_id, AgentProcess.status == "ready", AgentProcess.config_version == version
|
|
27
|
+
).order_by(AgentProcess.running_turns, AgentProcess.id))
|
|
28
|
+
return row or self.manager.spawn(agent_id)
|
|
29
|
+
|
|
30
|
+
async def rpc(self, scope, method, path, data=None):
|
|
31
|
+
process = await asyncio.to_thread(self.process, scope.agent_id, scope.session.config_version)
|
|
32
|
+
scope.process_id = process.process_id
|
|
33
|
+
body = json.dumps(data, separators=(",", ":")).encode() if data is not None else b""
|
|
34
|
+
key = agent_key(os.environ["AGENTHUB_MANAGER_INTERNAL_TOKEN"], scope.agent_id)
|
|
35
|
+
response = await self.client.request(
|
|
36
|
+
method, f"http://127.0.0.1:{process.port}{path}", content=body,
|
|
37
|
+
headers={"X-AgentHub-Bridge": sign(key, method, path, body), "Content-Type": "application/json"}
|
|
38
|
+
)
|
|
39
|
+
response.raise_for_status()
|
|
40
|
+
return response.json()
|
|
41
|
+
|
|
42
|
+
async def catalog(self, scope):
|
|
43
|
+
key = (scope.agent_id, scope.session.config_version)
|
|
44
|
+
cached = self.catalogs.get(key)
|
|
45
|
+
if cached and cached[0] > monotonic():
|
|
46
|
+
return cached[1]
|
|
47
|
+
value = await self.rpc(scope, "GET", "/internal/bridge/catalog")
|
|
48
|
+
if value["config_version"] != scope.session.config_version:
|
|
49
|
+
raise RuntimeError("Catalog configuration changed")
|
|
50
|
+
self.catalogs[key] = (monotonic() + 30, value)
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
async def call(self, scope, name, arguments):
|
|
54
|
+
return await self.rpc(scope, "POST", f"/internal/bridge/sessions/{scope.session.id}/call", {
|
|
55
|
+
"tool": name, "arguments": arguments, "call_id": str(uuid4()),
|
|
56
|
+
"principal": scope.principal.snapshot(), "client": scope.client_info,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
async def resume(self, scope, request_id):
|
|
60
|
+
return await self.rpc(scope, "POST", f"/internal/bridge/requests/{request_id}/execute", {
|
|
61
|
+
"principal": scope.principal.snapshot(), "session_id": scope.session.id,
|
|
62
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Manager REST discovery, explicit workspace creation, and restricted audit reads."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, HTTPException
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
|
|
10
|
+
from agenthub.bridge.auth import BridgeAuth
|
|
11
|
+
from agenthub.bridge.server import build_server, http_app
|
|
12
|
+
from agenthub.bridge.service import BridgeService
|
|
13
|
+
from agenthub.bridge.sessions import access, expired, resolve
|
|
14
|
+
from agenthub.persistence.models import AgentRegistry, McpAuditLog, AgentSession, now
|
|
15
|
+
from agenthub.persistence.repository import session_by_uid
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def attach_manager_bridge(app, manager, principal, require_admin):
|
|
19
|
+
master = os.environ["AGENTHUB_MANAGER_INTERNAL_TOKEN"]
|
|
20
|
+
if len(master.encode()) < 32:
|
|
21
|
+
# The SDK derives its request-state key from this token and rejects shorter keys.
|
|
22
|
+
raise RuntimeError("AGENTHUB_MANAGER_INTERNAL_TOKEN must be at least 32 bytes to enable the MCP bridge")
|
|
23
|
+
service = BridgeService(manager)
|
|
24
|
+
app.state.bridge = service
|
|
25
|
+
sdk_app = http_app(build_server(service, state_key=master))
|
|
26
|
+
app.mount("/mcp", BridgeAuth(sdk_app, manager, service.audit))
|
|
27
|
+
|
|
28
|
+
def url(agent_id, sid=None):
|
|
29
|
+
base = os.getenv("AGENTHUB_MCP_PUBLIC_URL", "http://127.0.0.1:9000").rstrip("/")
|
|
30
|
+
path = f"{base}/mcp/agents/{quote(str(agent_id), safe='')}"
|
|
31
|
+
return path + (f"/sessions/{sid}" if sid else "")
|
|
32
|
+
|
|
33
|
+
def allowed(agent_id, user):
|
|
34
|
+
try:
|
|
35
|
+
with manager.repo.sessions() as db:
|
|
36
|
+
return access(db, agent_id, user)
|
|
37
|
+
except PermissionError:
|
|
38
|
+
raise HTTPException(403, "Agent role is required") from None
|
|
39
|
+
except LookupError:
|
|
40
|
+
raise HTTPException(404, "Agent not active") from None
|
|
41
|
+
|
|
42
|
+
@app.get("/api/mcp/agents")
|
|
43
|
+
def agents(user=Depends(principal)):
|
|
44
|
+
with manager.repo.sessions() as db:
|
|
45
|
+
rows = db.scalars(select(AgentRegistry).where(AgentRegistry.status == "active", AgentRegistry.deleted_at.is_(None))
|
|
46
|
+
.order_by(AgentRegistry.name)).all()
|
|
47
|
+
result = []
|
|
48
|
+
for row in rows:
|
|
49
|
+
try:
|
|
50
|
+
access(db, row.id, user)
|
|
51
|
+
except PermissionError:
|
|
52
|
+
continue
|
|
53
|
+
result.append({"id": row.id, "name": row.name, "title": row.title, "mcpUrl": url(row.id)})
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
@app.post("/api/agents/{agent_id}/mcp-sessions")
|
|
57
|
+
def create_session(agent_id: int, user=Depends(principal)):
|
|
58
|
+
allowed(agent_id, user)
|
|
59
|
+
row = resolve(manager.repo, agent_id, user, explicit=True)
|
|
60
|
+
return {"sessionId": row.id, "mcpUrl": url(agent_id, row.id)}
|
|
61
|
+
|
|
62
|
+
@app.get("/api/mcp/agents/{agent_id}/client-config")
|
|
63
|
+
def client_config(agent_id: int, client: str = "codex", user=Depends(principal)):
|
|
64
|
+
allowed(agent_id, user)
|
|
65
|
+
endpoint = url(agent_id)
|
|
66
|
+
if client == "codex":
|
|
67
|
+
return {"format": "toml", "config": f'[mcp_servers.agenthub]\nurl = "{endpoint}"\n'
|
|
68
|
+
'bearer_token_env_var = "AGENTHUB_MCP_TOKEN"\ntool_timeout_sec = 150\n'}
|
|
69
|
+
if client == "claude-desktop":
|
|
70
|
+
return {"format": "json", "config": {"mcpServers": {"agenthub": {
|
|
71
|
+
"command": "npx", "args": ["mcp-remote", endpoint, "--header",
|
|
72
|
+
"Authorization: Bearer ${AGENTHUB_MCP_TOKEN}"]}}},
|
|
73
|
+
"note": "Set AGENTHUB_MCP_TOKEN in the desktop process environment. OAuth is not available."}
|
|
74
|
+
if client in {"claude-code", "cursor", "copilot", "vscode"}:
|
|
75
|
+
return {"format": "connection", "url": endpoint, "transport": "streamable-http",
|
|
76
|
+
"header": "Authorization", "credential_env": "AGENTHUB_MCP_TOKEN",
|
|
77
|
+
"note": "Configure an env-backed bearer header using the client's supported syntax. "
|
|
78
|
+
"Never paste credentials into checked-in files."}
|
|
79
|
+
raise HTTPException(422, "Unknown MCP client")
|
|
80
|
+
|
|
81
|
+
def calls(user, sid=None, agent=None, user_id=None, after=0):
|
|
82
|
+
query = select(McpAuditLog).where(McpAuditLog.id > after)
|
|
83
|
+
if sid:
|
|
84
|
+
query = query.where(McpAuditLog.mcp_session_id == sid)
|
|
85
|
+
if agent:
|
|
86
|
+
# Core's audit table has no agent column; the MCP session carries it.
|
|
87
|
+
query = query.where(McpAuditLog.mcp_session_id.in_(
|
|
88
|
+
select(AgentSession.session_id).where(AgentSession.agent_id == agent)))
|
|
89
|
+
if user_id:
|
|
90
|
+
query = query.where(McpAuditLog.user_id == user_id)
|
|
91
|
+
with manager.repo.sessions() as db:
|
|
92
|
+
rows = db.scalars(query.order_by(McpAuditLog.id).limit(200)).all()
|
|
93
|
+
return [{column.name: getattr(row, column.name) for column in McpAuditLog.__table__.columns} for row in rows]
|
|
94
|
+
|
|
95
|
+
@app.get("/api/mcp/calls")
|
|
96
|
+
def admin_calls(agent: int | None = None, user: int | None = None, session: str | None = None,
|
|
97
|
+
after: int = 0, caller=Depends(require_admin)):
|
|
98
|
+
return calls(caller, session, agent, user, after)
|
|
99
|
+
|
|
100
|
+
@app.get("/api/sessions/{session_id}/mcp-calls")
|
|
101
|
+
def session_calls(session_id: str, after: int = 0, user=Depends(principal)):
|
|
102
|
+
with manager.repo.sessions() as db:
|
|
103
|
+
row = session_by_uid(db, session_id)
|
|
104
|
+
if not row or row.trigger != "mcp":
|
|
105
|
+
raise HTTPException(404, "MCP session not found")
|
|
106
|
+
if str(row.user_id) != user.id:
|
|
107
|
+
raise HTTPException(403, "Session belongs to another user")
|
|
108
|
+
return calls(user, session_id, after=after)
|
|
109
|
+
|
|
110
|
+
return service, sdk_app
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def expire_sessions(repo):
|
|
114
|
+
with repo.transaction() as db:
|
|
115
|
+
rows = db.scalars(select(AgentSession).where(AgentSession.trigger == "mcp",
|
|
116
|
+
AgentSession.status.in_(("active", "awaiting_approval")))).all()
|
|
117
|
+
for row in rows:
|
|
118
|
+
if expired(row):
|
|
119
|
+
row.status, row.ended_at = "expired", now()
|
|
120
|
+
return now() + timedelta(days=1)
|