ags-cli 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 (156) hide show
  1. ags_cli-0.1.0.dist-info/METADATA +332 -0
  2. ags_cli-0.1.0.dist-info/RECORD +156 -0
  3. ags_cli-0.1.0.dist-info/WHEEL +5 -0
  4. ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ags_cli-0.1.0.dist-info/top_level.txt +2 -0
  6. cli/__init__.py +0 -0
  7. cli/__main__.py +4 -0
  8. cli/client.py +145 -0
  9. cli/commands/__init__.py +0 -0
  10. cli/commands/adapter.py +33 -0
  11. cli/commands/artifact.py +21 -0
  12. cli/commands/ask.py +212 -0
  13. cli/commands/chat.py +649 -0
  14. cli/commands/diff.py +49 -0
  15. cli/commands/doctor.py +93 -0
  16. cli/commands/gui.py +45 -0
  17. cli/commands/init.py +20 -0
  18. cli/commands/mcp.py +50 -0
  19. cli/commands/memory.py +65 -0
  20. cli/commands/model.py +73 -0
  21. cli/commands/policy.py +52 -0
  22. cli/commands/project.py +122 -0
  23. cli/commands/quota.py +107 -0
  24. cli/commands/run.py +84 -0
  25. cli/commands/serve.py +171 -0
  26. cli/commands/service.py +236 -0
  27. cli/commands/session.py +219 -0
  28. cli/commands/stats.py +96 -0
  29. cli/commands/status.py +36 -0
  30. cli/commands/task.py +37 -0
  31. cli/commands/usage.py +86 -0
  32. cli/local.py +84 -0
  33. cli/main.py +149 -0
  34. harness/__init__.py +4 -0
  35. harness/adapters/__init__.py +26 -0
  36. harness/adapters/antigravity.py +301 -0
  37. harness/adapters/claude_code.py +522 -0
  38. harness/adapters/local_openai.py +534 -0
  39. harness/adapters/mock.py +112 -0
  40. harness/adapters/probe.py +65 -0
  41. harness/adapters/registry.py +56 -0
  42. harness/adapters/warm_pool.py +255 -0
  43. harness/api/__init__.py +0 -0
  44. harness/api/router.py +38 -0
  45. harness/api/v1/__init__.py +0 -0
  46. harness/api/v1/adapters.py +105 -0
  47. harness/api/v1/approvals.py +173 -0
  48. harness/api/v1/artifacts.py +44 -0
  49. harness/api/v1/attachments.py +48 -0
  50. harness/api/v1/doctor.py +22 -0
  51. harness/api/v1/health.py +37 -0
  52. harness/api/v1/mcp.py +131 -0
  53. harness/api/v1/memory.py +80 -0
  54. harness/api/v1/policies.py +49 -0
  55. harness/api/v1/project.py +302 -0
  56. harness/api/v1/runs.py +98 -0
  57. harness/api/v1/sessions.py +248 -0
  58. harness/api/v1/stream.py +110 -0
  59. harness/api/v1/tasks.py +30 -0
  60. harness/api/v1/usage.py +58 -0
  61. harness/bootstrap.py +216 -0
  62. harness/db.py +164 -0
  63. harness/deps.py +58 -0
  64. harness/eventbus/__init__.py +20 -0
  65. harness/eventbus/inprocess_bus.py +85 -0
  66. harness/main.py +144 -0
  67. harness/mcp/__init__.py +22 -0
  68. harness/mcp/client.py +139 -0
  69. harness/mcp/config_gen.py +77 -0
  70. harness/mcp/registry.py +156 -0
  71. harness/mcp/tools.py +81 -0
  72. harness/memory/__init__.py +13 -0
  73. harness/memory/context_budget.py +116 -0
  74. harness/memory/embed.py +217 -0
  75. harness/memory/extract.py +74 -0
  76. harness/memory/ingest.py +106 -0
  77. harness/memory/namespaces.py +57 -0
  78. harness/memory/ranking.py +31 -0
  79. harness/memory/redact.py +37 -0
  80. harness/memory/service.py +320 -0
  81. harness/memory/sqlite_vec_backend.py +266 -0
  82. harness/memory/summarize.py +68 -0
  83. harness/models/__init__.py +35 -0
  84. harness/models/adapter_health.py +27 -0
  85. harness/models/approval.py +37 -0
  86. harness/models/artifact.py +35 -0
  87. harness/models/audit_log.py +33 -0
  88. harness/models/comparison.py +33 -0
  89. harness/models/enums.py +146 -0
  90. harness/models/mcp_server_config.py +49 -0
  91. harness/models/memory.py +76 -0
  92. harness/models/policy.py +29 -0
  93. harness/models/provider_config.py +35 -0
  94. harness/models/run.py +46 -0
  95. harness/models/run_event.py +35 -0
  96. harness/models/session.py +50 -0
  97. harness/models/task.py +30 -0
  98. harness/models/types.py +63 -0
  99. harness/models/workspace.py +27 -0
  100. harness/observability/__init__.py +4 -0
  101. harness/observability/audit.py +88 -0
  102. harness/observability/logging.py +47 -0
  103. harness/observability/metrics.py +43 -0
  104. harness/observability/tracing.py +38 -0
  105. harness/orchestrator/__init__.py +19 -0
  106. harness/orchestrator/engine.py +821 -0
  107. harness/orchestrator/handoff.py +33 -0
  108. harness/orchestrator/lifecycle.py +69 -0
  109. harness/orchestrator/native_resume.py +93 -0
  110. harness/orchestrator/policies.py +101 -0
  111. harness/orchestrator/reconcile.py +39 -0
  112. harness/orchestrator/run_graph.py +62 -0
  113. harness/orchestrator/snapshot.py +49 -0
  114. harness/schemas/__init__.py +1 -0
  115. harness/schemas/adapter.py +26 -0
  116. harness/schemas/approval.py +25 -0
  117. harness/schemas/artifact.py +17 -0
  118. harness/schemas/common.py +20 -0
  119. harness/schemas/memory.py +50 -0
  120. harness/schemas/policy.py +39 -0
  121. harness/schemas/run.py +116 -0
  122. harness/schemas/session.py +93 -0
  123. harness/schemas/task.py +24 -0
  124. harness/security/__init__.py +5 -0
  125. harness/security/approval_policy.py +107 -0
  126. harness/security/auth.py +106 -0
  127. harness/security/permissions.py +59 -0
  128. harness/security/risk.py +59 -0
  129. harness/security/secrets.py +49 -0
  130. harness/services/__init__.py +1 -0
  131. harness/services/adapters_health.py +212 -0
  132. harness/services/approvals.py +84 -0
  133. harness/services/artifacts.py +78 -0
  134. harness/services/attachments.py +228 -0
  135. harness/services/comparison.py +345 -0
  136. harness/services/doctor.py +156 -0
  137. harness/services/files.py +287 -0
  138. harness/services/mcp_servers.py +179 -0
  139. harness/services/project_git.py +101 -0
  140. harness/services/retention.py +97 -0
  141. harness/services/runs.py +155 -0
  142. harness/services/runs_diff.py +201 -0
  143. harness/services/sessions.py +242 -0
  144. harness/services/ssh.py +300 -0
  145. harness/services/stats.py +132 -0
  146. harness/services/tasks.py +41 -0
  147. harness/services/workspaces.py +184 -0
  148. harness/services/worktrees.py +439 -0
  149. harness/settings.py +186 -0
  150. harness/skills/__init__.py +11 -0
  151. harness/skills/manager.py +141 -0
  152. harness/tools/__init__.py +1 -0
  153. harness/tools/fs_tools.py +295 -0
  154. harness/workers/__init__.py +0 -0
  155. harness/workers/dispatch.py +26 -0
  156. harness/workers/inprocess.py +135 -0
@@ -0,0 +1,85 @@
1
+ """In-process pub/sub event bus for this daemonless, SQLite-only setup.
2
+
3
+ The API and the in-process worker share one event loop and process, so events can be
4
+ handed straight from the engine to the SSE endpoint for low-latency live streaming.
5
+ Each run keeps a **buffer** of every event published, so a subscriber that connects
6
+ mid-run (or just after it starts) still receives the full backlog and then the live
7
+ tail — no lost-event race.
8
+
9
+ The durable ``run_events`` ledger remains the source of truth; this bus is purely the
10
+ live channel."""
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ from collections import OrderedDict
16
+ from collections.abc import AsyncIterator
17
+
18
+ # Bound memory: retain channels for the most recent runs only. Runs are sequential and
19
+ # small for a single-user local instance, so this is ample for live + brief reconnects.
20
+ _MAX_RUNS = 32
21
+
22
+
23
+ class _Channel:
24
+ __slots__ = ("buffer", "subscribers", "done")
25
+
26
+ def __init__(self) -> None:
27
+ self.buffer: list[dict] = []
28
+ self.subscribers: set[asyncio.Queue] = set()
29
+ self.done: bool = False
30
+
31
+
32
+ class InProcessEventBus:
33
+ def __init__(self) -> None:
34
+ self._runs: OrderedDict[str, _Channel] = OrderedDict()
35
+
36
+ def _channel(self, run_id: str) -> _Channel:
37
+ ch = self._runs.get(run_id)
38
+ if ch is None:
39
+ ch = _Channel()
40
+ self._runs[run_id] = ch
41
+ while len(self._runs) > _MAX_RUNS:
42
+ self._runs.popitem(last=False) # evict oldest
43
+ else:
44
+ self._runs.move_to_end(run_id)
45
+ return ch
46
+
47
+ async def publish(self, run_id: str, event: dict) -> None:
48
+ ch = self._channel(run_id)
49
+ ch.buffer.append(event)
50
+ for q in ch.subscribers:
51
+ q.put_nowait(event)
52
+
53
+ async def publish_done(self, run_id: str) -> None:
54
+ ch = self._channel(run_id)
55
+ ch.done = True
56
+ for q in ch.subscribers:
57
+ q.put_nowait(None) # terminator sentinel
58
+
59
+ async def subscribe(self, run_id: str) -> AsyncIterator[dict]:
60
+ """Yield the run's buffered backlog, then live events, until done.
61
+
62
+ Backlog snapshot and queue registration happen with no ``await`` between them, so
63
+ they are atomic w.r.t. other coroutines: an event is in exactly one of the two
64
+ (no loss, no duplicate)."""
65
+ ch = self._channel(run_id)
66
+ q: asyncio.Queue = asyncio.Queue()
67
+ backlog = list(ch.buffer)
68
+ ch.subscribers.add(q)
69
+ # If the run already finished, ensure this late subscriber still gets a terminator
70
+ # (publish_done ran before our queue existed).
71
+ if ch.done:
72
+ q.put_nowait(None)
73
+ try:
74
+ for event in backlog:
75
+ yield event
76
+ while True:
77
+ event = await q.get()
78
+ if event is None: # done
79
+ return
80
+ yield event
81
+ finally:
82
+ ch.subscribers.discard(q)
83
+
84
+ async def aclose(self) -> None:
85
+ self._runs.clear()
harness/main.py ADDED
@@ -0,0 +1,144 @@
1
+ """FastAPI application factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import os
8
+ from contextlib import asynccontextmanager
9
+ from pathlib import Path
10
+
11
+ from fastapi import FastAPI
12
+ from fastapi.responses import RedirectResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+
16
+ from harness.api.router import api_router
17
+ from harness.observability.logging import configure_logging, get_logger
18
+ from harness.observability.tracing import setup_tracing
19
+ from harness.settings import get_settings
20
+
21
+ # Keep background startup compaction tasks alive so they aren't GC'd.
22
+ _startup_tasks: set[asyncio.Task[None]] = set()
23
+
24
+
25
+ def _gui_dir() -> Path | None:
26
+ """The built web GUI, if present. Served at /ui so the GUI and API share an origin.
27
+ Looked up in order: ``AGS_GUI_DIR`` env → packaged ``harness/_gui`` (in an installed
28
+ wheel) → repo ``webgui/out`` (dev). Built with ``scripts/build_gui.sh``."""
29
+ here = Path(__file__).resolve().parent
30
+ candidates = []
31
+ if override := os.environ.get("AGS_GUI_DIR"):
32
+ candidates.append(Path(override))
33
+ candidates.append(here / "_gui") # packaged
34
+ candidates.append(here.parent / "webgui" / "out") # repo dev
35
+ for c in candidates:
36
+ if (c / "index.html").is_file():
37
+ return c
38
+ return None
39
+
40
+
41
+ @asynccontextmanager
42
+ async def lifespan(app: FastAPI):
43
+ configure_logging()
44
+ log = get_logger("api")
45
+ settings = get_settings()
46
+ log.info("api_starting", env=settings.env)
47
+ # Ensure the SQLite schema + seed exist so the very first request works with no
48
+ # manual bootstrap. Idempotent.
49
+ from harness.bootstrap import init_local
50
+
51
+ await init_local()
52
+ # Reconcile runs left in non-terminal states by a server crash/restart.
53
+ from harness.db import get_sessionmaker
54
+ from harness.orchestrator.reconcile import reconcile_interrupted
55
+ async with get_sessionmaker()() as db:
56
+ await reconcile_interrupted(db)
57
+ await db.commit()
58
+ # Auto-compact old sessions when retention_days is configured (> 0).
59
+ # Fire-and-forget so a big DELETE doesn't block startup.
60
+ if settings.retention_days > 0:
61
+ async def _run_compact() -> None:
62
+ try:
63
+ from harness.services.retention import compact_sessions
64
+ async with get_sessionmaker()() as db:
65
+ report = await compact_sessions(db, older_than_days=settings.retention_days)
66
+ log.info(
67
+ "retention_compact_done",
68
+ sessions=report.sessions,
69
+ events_deleted=report.events_deleted,
70
+ older_than_days=settings.retention_days,
71
+ )
72
+ except Exception: # noqa: BLE001
73
+ log.warning("retention_compact_failed", exc_info=True)
74
+
75
+ task = asyncio.create_task(_run_compact())
76
+ _startup_tasks.add(task)
77
+ task.add_done_callback(_startup_tasks.discard)
78
+ # Warm CLI pool: start a repeating idle-reaper task and ensure close_all on shutdown.
79
+ warm_reaper_task: asyncio.Task | None = None
80
+ if settings.warm_cli:
81
+ from harness.adapters.warm_pool import get_warm_pool
82
+
83
+ async def _warm_reaper_loop() -> None:
84
+ while True:
85
+ await asyncio.sleep(60)
86
+ try:
87
+ await get_warm_pool().reap_idle(max_idle_s=300)
88
+ except Exception: # noqa: BLE001
89
+ log.warning("warm_reap_failed", exc_info=True)
90
+
91
+ warm_reaper_task = asyncio.create_task(_warm_reaper_loop())
92
+ _startup_tasks.add(warm_reaper_task)
93
+ warm_reaper_task.add_done_callback(_startup_tasks.discard)
94
+
95
+ yield
96
+
97
+ if warm_reaper_task is not None:
98
+ warm_reaper_task.cancel()
99
+ with contextlib.suppress(asyncio.CancelledError):
100
+ await warm_reaper_task
101
+ from harness.adapters.warm_pool import get_warm_pool
102
+
103
+ await get_warm_pool().close_all()
104
+
105
+ log.info("api_stopping")
106
+
107
+
108
+ def create_app() -> FastAPI:
109
+ app = FastAPI(
110
+ title="AgentS",
111
+ version="0.1.0",
112
+ summary="Unified interface over Claude Code, Google CLI (agy), and local LLMs.",
113
+ lifespan=lifespan,
114
+ )
115
+
116
+ # Allow CORS from Tauri apps
117
+ app.add_middleware(
118
+ CORSMiddleware,
119
+ allow_origins=["http://localhost:3000", "tauri://localhost", "http://tauri.localhost"],
120
+ allow_credentials=True,
121
+ allow_methods=["*"],
122
+ allow_headers=["*"],
123
+ )
124
+
125
+ app.include_router(api_router)
126
+ setup_tracing(app)
127
+
128
+ gui_dir = _gui_dir()
129
+ if gui_dir is not None:
130
+ # Serve the built GUI at /ui (same origin as the API) and send / there.
131
+ app.mount("/ui", StaticFiles(directory=str(gui_dir), html=True), name="ui")
132
+
133
+ @app.get("/")
134
+ async def root() -> RedirectResponse:
135
+ return RedirectResponse(url="/ui/")
136
+ else:
137
+ @app.get("/")
138
+ async def root() -> dict:
139
+ return {"service": "ags", "version": "0.1.0", "docs": "/docs", "gui": "not built"}
140
+
141
+ return app
142
+
143
+
144
+ app = create_app()
@@ -0,0 +1,22 @@
1
+ """MCP (Model Context Protocol) tools integration.
2
+
3
+ Lets harness agents call external MCP servers. Two paths share one server registry:
4
+
5
+ * CLI adapters (claude/agy) are their own MCP clients — :mod:`harness.mcp.config_gen`
6
+ emits the native config they consume.
7
+ * the OpenAI-compatible adapter has no MCP — :class:`harness.mcp.registry.McpManager`
8
+ is the in-process client that lists tools and dispatches calls.
9
+ """
10
+
11
+ from harness.mcp.registry import McpManager, resolve_servers, server_to_dict
12
+ from harness.mcp.tools import McpTool, is_mcp_tool, namespaced_name, parse_namespaced
13
+
14
+ __all__ = [
15
+ "McpManager",
16
+ "McpTool",
17
+ "is_mcp_tool",
18
+ "namespaced_name",
19
+ "parse_namespaced",
20
+ "resolve_servers",
21
+ "server_to_dict",
22
+ ]
harness/mcp/client.py ADDED
@@ -0,0 +1,139 @@
1
+ """Async MCP client wrapper over the official ``mcp`` Python SDK.
2
+
3
+ One :class:`McpClient` owns a single live connection to one MCP server (stdio
4
+ subprocess or remote streamable-HTTP). The ``mcp`` SDK is imported lazily inside
5
+ :meth:`connect` so the harness installs and runs without it; a server configured
6
+ when the SDK is absent fails with a clear, actionable error rather than at import.
7
+
8
+ Secrets are resolved here (via ``secret_ref``) into the child env / Authorization
9
+ header and never persisted. stdio servers are launched in their own process group
10
+ by the SDK's ``stdio_client``; closing the client tears the subprocess down.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from contextlib import AsyncExitStack
17
+
18
+ from harness.mcp.tools import McpTool
19
+ from harness.observability.logging import get_logger
20
+ from harness.security.secrets import resolve_secret_ref
21
+
22
+ log = get_logger("mcp-client")
23
+
24
+
25
+ class McpUnavailable(RuntimeError):
26
+ """Raised when an MCP server is configured but cannot be used (SDK missing or
27
+ connection failed). Callers degrade gracefully — the run continues without the
28
+ server's tools."""
29
+
30
+
31
+ class McpClient:
32
+ """Live connection to one MCP server. Use as an async context manager or call
33
+ :meth:`connect` / :meth:`aclose` explicitly."""
34
+
35
+ def __init__(self, server: dict) -> None:
36
+ # ``server`` is a plain dict (see registry.server_to_dict) so this class is
37
+ # decoupled from the ORM and trivially testable.
38
+ self.cfg = server
39
+ self.name: str = server["name"]
40
+ self._stack: AsyncExitStack | None = None
41
+ self._session = None # mcp.ClientSession once connected
42
+
43
+ async def __aenter__(self) -> McpClient:
44
+ await self.connect()
45
+ return self
46
+
47
+ async def __aexit__(self, *exc) -> None:
48
+ await self.aclose()
49
+
50
+ async def connect(self) -> None:
51
+ try:
52
+ from mcp import ClientSession
53
+ except ImportError as e: # SDK not installed
54
+ raise McpUnavailable(
55
+ f"MCP server {self.name!r} configured but the 'mcp' package is not "
56
+ "installed. Add it to the environment to enable MCP tools."
57
+ ) from e
58
+
59
+ stack = AsyncExitStack()
60
+ try:
61
+ read, write = await self._open_transport(stack)
62
+ session = await stack.enter_async_context(ClientSession(read, write))
63
+ await session.initialize()
64
+ except McpUnavailable:
65
+ await stack.aclose()
66
+ raise
67
+ except Exception as e: # connection / handshake failure
68
+ await stack.aclose()
69
+ raise McpUnavailable(f"MCP server {self.name!r} failed to connect: {e}") from e
70
+ self._stack = stack
71
+ self._session = session
72
+
73
+ async def _open_transport(self, stack: AsyncExitStack):
74
+ transport = self.cfg.get("transport", "stdio")
75
+ if transport == "stdio":
76
+ from mcp import StdioServerParameters
77
+ from mcp.client.stdio import stdio_client
78
+
79
+ env = {**os.environ, **(self.cfg.get("env") or {})}
80
+ if (token := resolve_secret_ref(self.cfg.get("secret_ref"))):
81
+ # Convention: expose the resolved secret to a stdio server as MCP_TOKEN
82
+ # unless the server's own env already names it.
83
+ env.setdefault("MCP_TOKEN", token)
84
+ params = StdioServerParameters(
85
+ command=self.cfg["command"],
86
+ args=self.cfg.get("args") or [],
87
+ env=env,
88
+ )
89
+ return await stack.enter_async_context(stdio_client(params))
90
+ if transport == "http":
91
+ from mcp.client.streamable_http import streamablehttp_client
92
+
93
+ headers = dict(self.cfg.get("headers") or {})
94
+ if (token := resolve_secret_ref(self.cfg.get("secret_ref"))):
95
+ headers.setdefault("Authorization", f"Bearer {token}")
96
+ # streamablehttp_client yields (read, write, *_); take the first two.
97
+ ctx = await stack.enter_async_context(
98
+ streamablehttp_client(self.cfg["url"], headers=headers)
99
+ )
100
+ return ctx[0], ctx[1]
101
+ raise McpUnavailable(f"MCP server {self.name!r}: unknown transport {transport!r}")
102
+
103
+ async def list_tools(self) -> list[McpTool]:
104
+ if self._session is None:
105
+ raise McpUnavailable(f"MCP server {self.name!r} not connected")
106
+ resp = await self._session.list_tools()
107
+ return [
108
+ McpTool(
109
+ server=self.name,
110
+ name=t.name,
111
+ description=t.description or "",
112
+ input_schema=getattr(t, "inputSchema", None),
113
+ )
114
+ for t in resp.tools
115
+ ]
116
+
117
+ async def call_tool(self, tool: str, args: dict) -> str:
118
+ if self._session is None:
119
+ raise McpUnavailable(f"MCP server {self.name!r} not connected")
120
+ result = await self._session.call_tool(tool, args or {})
121
+ return _result_text(result)
122
+
123
+ async def aclose(self) -> None:
124
+ if self._stack is not None:
125
+ try:
126
+ await self._stack.aclose()
127
+ except Exception as e: # never let cleanup failures escape
128
+ log.warning("mcp_close_failed", server=self.name, error=str(e))
129
+ self._stack = None
130
+ self._session = None
131
+
132
+
133
+ def _result_text(result) -> str:
134
+ """Flatten an MCP ``CallToolResult`` content list into text for the ledger."""
135
+ parts: list[str] = []
136
+ for block in getattr(result, "content", None) or []:
137
+ text = getattr(block, "text", None)
138
+ parts.append(text if text is not None else str(block))
139
+ return "\n".join(parts)
@@ -0,0 +1,77 @@
1
+ """Generate native MCP config for the CLI adapters (claude / agy).
2
+
3
+ These CLIs are their own MCP clients — the harness only has to hand them a config
4
+ file and the right flags. The builders below are pure (dict in, dict out) so they
5
+ are unit-testable without touching disk; :func:`write_json` persists one into a
6
+ per-run directory the adapter passes via ``--mcp-config``.
7
+
8
+ Secrets ARE resolved into the generated file (the CLI needs the live token), so the
9
+ file is written under a run-scoped dir with 0600 and removed when the run ends.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import json
16
+ import os
17
+ from pathlib import Path
18
+
19
+ from harness.mcp.tools import PREFIX, SEP, namespaced_name
20
+ from harness.security.secrets import resolve_secret_ref
21
+
22
+
23
+ def _entry(server: dict) -> dict:
24
+ """One server's entry in a ``mcpServers`` map (Claude/agy shared shape)."""
25
+ transport = server.get("transport", "stdio")
26
+ token = resolve_secret_ref(server.get("secret_ref"))
27
+ if transport == "http":
28
+ headers = dict(server.get("headers") or {})
29
+ if token:
30
+ headers.setdefault("Authorization", f"Bearer {token}")
31
+ return {"type": "http", "url": server["url"], "headers": headers}
32
+ # stdio
33
+ env = dict(server.get("env") or {})
34
+ if token:
35
+ env.setdefault("MCP_TOKEN", token)
36
+ entry: dict = {"command": server["command"], "args": server.get("args") or []}
37
+ if env:
38
+ entry["env"] = env
39
+ return entry
40
+
41
+
42
+ def claude_mcp_config(servers: list[dict]) -> dict:
43
+ """``.mcp.json`` body for the Claude CLI (``{"mcpServers": {...}}``)."""
44
+ return {"mcpServers": {s["name"]: _entry(s) for s in servers}}
45
+
46
+
47
+ def antigravity_mcp_config(servers: list[dict]) -> dict:
48
+ """MCP config body for agy. agy's exact schema must be verified against the pinned
49
+ CLI; it follows the widespread ``{"mcpServers": {...}}`` convention, so we emit that
50
+ and let the adapter point agy at it via its config mechanism."""
51
+ return claude_mcp_config(servers)
52
+
53
+
54
+ def allowed_tool_patterns(servers: list[dict]) -> list[str]:
55
+ """``--allowedTools`` entries so the CLI may call the tools without prompting.
56
+
57
+ Empty allowlist ⇒ allow the whole server (``mcp__<server>``); otherwise one
58
+ ``mcp__<server>__<tool>`` per allowed tool."""
59
+ patterns: list[str] = []
60
+ for s in servers:
61
+ allow = s.get("tool_allowlist") or []
62
+ if not allow:
63
+ patterns.append(f"{PREFIX}{SEP}{s['name']}")
64
+ else:
65
+ patterns.extend(namespaced_name(s["name"], t) for t in allow)
66
+ return patterns
67
+
68
+
69
+ def write_json(obj: dict, directory: str | Path, filename: str = "mcp.json") -> str:
70
+ """Write ``obj`` as JSON (0600) under ``directory`` and return the path."""
71
+ d = Path(directory)
72
+ d.mkdir(parents=True, exist_ok=True)
73
+ path = d / filename
74
+ path.write_text(json.dumps(obj, indent=2))
75
+ with contextlib.suppress(OSError):
76
+ os.chmod(path, 0o600)
77
+ return str(path)
@@ -0,0 +1,156 @@
1
+ """Resolve which MCP servers apply to a run and drive them as a unit.
2
+
3
+ Two entry points:
4
+
5
+ * :func:`resolve_servers` — DB-backed: given a run's workspace/provider/mode, return
6
+ the plain-dict definitions of the MCP servers that should be offered (applying
7
+ ``enabled``, ``bind``, and read-only gating). The engine attaches these to the
8
+ ``SessionContext`` so every adapter can act on them (CLI adapters generate native
9
+ config; the OpenAI adapter spins up an in-process client).
10
+ * :class:`McpManager` — the in-process MCP client used by the OpenAI-compatible
11
+ adapter: connects the resolved servers, exposes their tools as OpenAI specs, and
12
+ dispatches namespaced tool calls back to the owning server (redacting results).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from sqlalchemy import select
18
+ from sqlalchemy.ext.asyncio import AsyncSession
19
+
20
+ from harness.mcp.client import McpClient, McpUnavailable
21
+ from harness.mcp.tools import McpTool, parse_namespaced, to_openai_specs
22
+ from harness.memory.redact import redact
23
+ from harness.models.mcp_server_config import McpServerConfig
24
+ from harness.observability.logging import get_logger
25
+
26
+ log = get_logger("mcp-registry")
27
+
28
+
29
+ def server_to_dict(row: McpServerConfig) -> dict:
30
+ """Serialize an ORM row to the transport-agnostic dict the client/config-gen use.
31
+ The secret is NOT resolved here — only the ``secret_ref`` travels, so secrets stay
32
+ out of the SessionContext and event ledger."""
33
+ return {
34
+ "name": row.name,
35
+ "transport": row.transport.value,
36
+ "command": row.command,
37
+ "args": row.args or [],
38
+ "env": row.env or {},
39
+ "url": row.url,
40
+ "headers": row.headers or {},
41
+ "secret_ref": row.secret_ref,
42
+ "tool_allowlist": row.tool_allowlist or [],
43
+ "read_only": bool(row.read_only),
44
+ }
45
+
46
+
47
+ def _binds(server: dict, *, workspace_slug: str, provider: str) -> bool:
48
+ bind = server.get("bind") or {}
49
+ ws = bind.get("workspaces") or []
50
+ pv = bind.get("providers") or []
51
+ return (not ws or workspace_slug in ws) and (not pv or provider in pv)
52
+
53
+
54
+ def filter_servers(
55
+ servers: list[dict], *, workspace_slug: str, provider: str, read_only: bool
56
+ ) -> list[dict]:
57
+ """Keep servers bound to this workspace+provider; in a read-only/plan run only
58
+ servers explicitly marked ``read_only`` are kept (write-capable tools are withheld
59
+ until the run is in write mode / the approval gate clears them)."""
60
+ out = []
61
+ for s in servers:
62
+ if not _binds(s, workspace_slug=workspace_slug, provider=provider):
63
+ continue
64
+ if read_only and not s.get("read_only"):
65
+ continue
66
+ out.append(s)
67
+ return out
68
+
69
+
70
+ async def load_enabled_servers(db: AsyncSession) -> list[dict]:
71
+ """All enabled MCP servers as dicts (with ``bind`` attached). Queried once per
72
+ session execution — the engine then filters per node in-memory via
73
+ :func:`filter_servers`, keeping concurrent node fan-out free of DB access."""
74
+ rows = (
75
+ await db.execute(select(McpServerConfig).where(McpServerConfig.enabled.is_(True)))
76
+ ).scalars().all()
77
+ servers = []
78
+ for r in rows:
79
+ s = server_to_dict(r)
80
+ # bind lives on the row; attach it for filter_servers (kept off the transport
81
+ # dict so server_to_dict stays purely about *reaching* the server).
82
+ s["bind"] = r.bind or {}
83
+ servers.append(s)
84
+ return servers
85
+
86
+
87
+ async def resolve_servers(
88
+ db: AsyncSession, *, workspace_slug: str, provider: str, read_only: bool
89
+ ) -> list[dict]:
90
+ """DB-backed resolution of the MCP servers offered to one run."""
91
+ return filter_servers(
92
+ await load_enabled_servers(db),
93
+ workspace_slug=workspace_slug,
94
+ provider=provider,
95
+ read_only=read_only,
96
+ )
97
+
98
+
99
+ class McpManager:
100
+ """In-process MCP client manager for a single run (OpenAI adapter path).
101
+
102
+ Lifecycle is owned by the caller: ``connect()`` once, use ``specs`` / ``dispatch``
103
+ during the agentic loop, then ``aclose()``. Connection failures are non-fatal —
104
+ an unreachable server is logged and simply contributes no tools."""
105
+
106
+ def __init__(self, servers: list[dict]) -> None:
107
+ self._servers = servers
108
+ self._clients: dict[str, McpClient] = {}
109
+ self._allowlist: dict[str, list[str]] = {
110
+ s["name"]: (s.get("tool_allowlist") or []) for s in servers
111
+ }
112
+
113
+ async def connect(self) -> None:
114
+ for s in self._servers:
115
+ client = McpClient(s)
116
+ try:
117
+ await client.connect()
118
+ except McpUnavailable as e:
119
+ log.warning("mcp_server_unavailable", server=s["name"], error=str(e))
120
+ continue
121
+ self._clients[s["name"]] = client
122
+
123
+ async def specs(self) -> list[dict]:
124
+ """OpenAI ``tools`` specs across every connected server (allowlist applied)."""
125
+ tools: list[McpTool] = []
126
+ for name, client in self._clients.items():
127
+ try:
128
+ tools.extend(await client.list_tools())
129
+ except McpUnavailable as e:
130
+ log.warning("mcp_list_tools_failed", server=name, error=str(e))
131
+ return to_openai_specs(tools, self._allowlist)
132
+
133
+ async def dispatch(self, name: str, args: dict) -> tuple[str, bool]:
134
+ """Route a namespaced tool call to its server. Returns ``(output, is_error)``;
135
+ output is redacted before it can reach the ledger."""
136
+ parsed = parse_namespaced(name)
137
+ if parsed is None:
138
+ return f"error: not an MCP tool: {name!r}", True
139
+ server, tool = parsed
140
+ client = self._clients.get(server)
141
+ if client is None:
142
+ return f"error: MCP server {server!r} not connected", True
143
+ if (al := self._allowlist.get(server)) and tool not in al:
144
+ return f"error: tool {tool!r} not in allowlist for {server!r}", True
145
+ try:
146
+ out = await client.call_tool(tool, args)
147
+ except McpUnavailable as e:
148
+ return f"error: {e}", True
149
+ except Exception as e: # tool execution error from the server
150
+ return f"error: MCP tool {name!r} failed: {e}", True
151
+ return redact(out)[0], False
152
+
153
+ async def aclose(self) -> None:
154
+ for client in self._clients.values():
155
+ await client.aclose()
156
+ self._clients.clear()