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.
Files changed (84) hide show
  1. agenthub/__init__.py +3 -0
  2. agenthub/bridge/__init__.py +1 -0
  3. agenthub/bridge/audit.py +72 -0
  4. agenthub/bridge/auth.py +98 -0
  5. agenthub/bridge/local.py +123 -0
  6. agenthub/bridge/router.py +62 -0
  7. agenthub/bridge/routes.py +120 -0
  8. agenthub/bridge/server.py +215 -0
  9. agenthub/bridge/service.py +83 -0
  10. agenthub/bridge/sessions.py +80 -0
  11. agenthub/bridge/signing.py +59 -0
  12. agenthub/bridge/tool_session.py +69 -0
  13. agenthub/builtin_catalog.json +130 -0
  14. agenthub/cli.py +140 -0
  15. agenthub/config/__init__.py +3 -0
  16. agenthub/config/source_db.py +79 -0
  17. agenthub/config/source_disk.py +59 -0
  18. agenthub/config/spec.py +103 -0
  19. agenthub/events/__init__.py +45 -0
  20. agenthub/events/envelope.py +60 -0
  21. agenthub/identity/__init__.py +129 -0
  22. agenthub/inputs.py +68 -0
  23. agenthub/llm/__init__.py +207 -0
  24. agenthub/loop/__init__.py +217 -0
  25. agenthub/manager/__init__.py +1 -0
  26. agenthub/manager/app.py +489 -0
  27. agenthub/manager/secrets.py +66 -0
  28. agenthub/media.py +262 -0
  29. agenthub/persistence/__init__.py +9 -0
  30. agenthub/persistence/models/__init__.py +23 -0
  31. agenthub/persistence/models/agent_event.py +28 -0
  32. agenthub/persistence/models/agent_human_request.py +27 -0
  33. agenthub/persistence/models/agent_job.py +34 -0
  34. agenthub/persistence/models/agent_process.py +31 -0
  35. agenthub/persistence/models/agent_registry.py +22 -0
  36. agenthub/persistence/models/agent_role.py +15 -0
  37. agenthub/persistence/models/agent_secret.py +16 -0
  38. agenthub/persistence/models/agent_session.py +35 -0
  39. agenthub/persistence/models/agent_session_checkpoint.py +18 -0
  40. agenthub/persistence/models/agent_skill.py +15 -0
  41. agenthub/persistence/models/agent_skill_registry.py +17 -0
  42. agenthub/persistence/models/agent_tool.py +15 -0
  43. agenthub/persistence/models/agent_tool_registry.py +22 -0
  44. agenthub/persistence/models/base.py +62 -0
  45. agenthub/persistence/models/mcp_audit_log.py +33 -0
  46. agenthub/persistence/models/secret.py +27 -0
  47. agenthub/persistence/repository.py +394 -0
  48. agenthub/persistence/seed.py +61 -0
  49. agenthub/process/__init__.py +1 -0
  50. agenthub/process/agent_api.py +306 -0
  51. agenthub/process/agent_tokens.py +78 -0
  52. agenthub/process/app.py +578 -0
  53. agenthub/process/bridge_api.py +149 -0
  54. agenthub/process/local.py +276 -0
  55. agenthub/runtime_prompt.py +64 -0
  56. agenthub/sdk/__init__.py +10 -0
  57. agenthub/sdk/context.py +59 -0
  58. agenthub/sdk/project.py +14 -0
  59. agenthub/sdk/tool.py +164 -0
  60. agenthub/skills/__init__.py +25 -0
  61. agenthub/skills/builtin/db_query_assistant.md +78 -0
  62. agenthub/skills/builtin/filesystem.md +170 -0
  63. agenthub/skills/builtin/quality_checks.md +24 -0
  64. agenthub/skills/builtin/solidctl.md +68 -0
  65. agenthub/skills/builtin/web_research.md +11 -0
  66. agenthub/tools/__init__.py +1 -0
  67. agenthub/tools/builtin/__init__.py +1 -0
  68. agenthub/tools/builtin/bash.py +45 -0
  69. agenthub/tools/builtin/filesystem.py +298 -0
  70. agenthub/tools/builtin/list_widgets.py +12 -0
  71. agenthub/tools/builtin/load_skill.py +12 -0
  72. agenthub/tools/builtin/request_human_input.py +14 -0
  73. agenthub/tools/builtin/run_read_only_sql.py +79 -0
  74. agenthub/tools/builtin/web_search.py +129 -0
  75. agenthub/tools/executor.py +102 -0
  76. agenthub/tools/guard.py +49 -0
  77. agenthub/tools/loader.py +73 -0
  78. agenthub/tools/validate.py +52 -0
  79. agenthub/widgets.py +34 -0
  80. solidx_agenthub_runtime-0.1.0.dist-info/METADATA +25 -0
  81. solidx_agenthub_runtime-0.1.0.dist-info/RECORD +84 -0
  82. solidx_agenthub_runtime-0.1.0.dist-info/WHEEL +5 -0
  83. solidx_agenthub_runtime-0.1.0.dist-info/entry_points.txt +2 -0
  84. solidx_agenthub_runtime-0.1.0.dist-info/top_level.txt +1 -0
agenthub/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """SolidX AgentHub runtime."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """LLM-free, session-scoped MCP access to AgentHub tools."""
@@ -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)))
@@ -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)
@@ -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)