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.
- ags_cli-0.1.0.dist-info/METADATA +332 -0
- ags_cli-0.1.0.dist-info/RECORD +156 -0
- ags_cli-0.1.0.dist-info/WHEEL +5 -0
- ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ags_cli-0.1.0.dist-info/top_level.txt +2 -0
- cli/__init__.py +0 -0
- cli/__main__.py +4 -0
- cli/client.py +145 -0
- cli/commands/__init__.py +0 -0
- cli/commands/adapter.py +33 -0
- cli/commands/artifact.py +21 -0
- cli/commands/ask.py +212 -0
- cli/commands/chat.py +649 -0
- cli/commands/diff.py +49 -0
- cli/commands/doctor.py +93 -0
- cli/commands/gui.py +45 -0
- cli/commands/init.py +20 -0
- cli/commands/mcp.py +50 -0
- cli/commands/memory.py +65 -0
- cli/commands/model.py +73 -0
- cli/commands/policy.py +52 -0
- cli/commands/project.py +122 -0
- cli/commands/quota.py +107 -0
- cli/commands/run.py +84 -0
- cli/commands/serve.py +171 -0
- cli/commands/service.py +236 -0
- cli/commands/session.py +219 -0
- cli/commands/stats.py +96 -0
- cli/commands/status.py +36 -0
- cli/commands/task.py +37 -0
- cli/commands/usage.py +86 -0
- cli/local.py +84 -0
- cli/main.py +149 -0
- harness/__init__.py +4 -0
- harness/adapters/__init__.py +26 -0
- harness/adapters/antigravity.py +301 -0
- harness/adapters/claude_code.py +522 -0
- harness/adapters/local_openai.py +534 -0
- harness/adapters/mock.py +112 -0
- harness/adapters/probe.py +65 -0
- harness/adapters/registry.py +56 -0
- harness/adapters/warm_pool.py +255 -0
- harness/api/__init__.py +0 -0
- harness/api/router.py +38 -0
- harness/api/v1/__init__.py +0 -0
- harness/api/v1/adapters.py +105 -0
- harness/api/v1/approvals.py +173 -0
- harness/api/v1/artifacts.py +44 -0
- harness/api/v1/attachments.py +48 -0
- harness/api/v1/doctor.py +22 -0
- harness/api/v1/health.py +37 -0
- harness/api/v1/mcp.py +131 -0
- harness/api/v1/memory.py +80 -0
- harness/api/v1/policies.py +49 -0
- harness/api/v1/project.py +302 -0
- harness/api/v1/runs.py +98 -0
- harness/api/v1/sessions.py +248 -0
- harness/api/v1/stream.py +110 -0
- harness/api/v1/tasks.py +30 -0
- harness/api/v1/usage.py +58 -0
- harness/bootstrap.py +216 -0
- harness/db.py +164 -0
- harness/deps.py +58 -0
- harness/eventbus/__init__.py +20 -0
- harness/eventbus/inprocess_bus.py +85 -0
- harness/main.py +144 -0
- harness/mcp/__init__.py +22 -0
- harness/mcp/client.py +139 -0
- harness/mcp/config_gen.py +77 -0
- harness/mcp/registry.py +156 -0
- harness/mcp/tools.py +81 -0
- harness/memory/__init__.py +13 -0
- harness/memory/context_budget.py +116 -0
- harness/memory/embed.py +217 -0
- harness/memory/extract.py +74 -0
- harness/memory/ingest.py +106 -0
- harness/memory/namespaces.py +57 -0
- harness/memory/ranking.py +31 -0
- harness/memory/redact.py +37 -0
- harness/memory/service.py +320 -0
- harness/memory/sqlite_vec_backend.py +266 -0
- harness/memory/summarize.py +68 -0
- harness/models/__init__.py +35 -0
- harness/models/adapter_health.py +27 -0
- harness/models/approval.py +37 -0
- harness/models/artifact.py +35 -0
- harness/models/audit_log.py +33 -0
- harness/models/comparison.py +33 -0
- harness/models/enums.py +146 -0
- harness/models/mcp_server_config.py +49 -0
- harness/models/memory.py +76 -0
- harness/models/policy.py +29 -0
- harness/models/provider_config.py +35 -0
- harness/models/run.py +46 -0
- harness/models/run_event.py +35 -0
- harness/models/session.py +50 -0
- harness/models/task.py +30 -0
- harness/models/types.py +63 -0
- harness/models/workspace.py +27 -0
- harness/observability/__init__.py +4 -0
- harness/observability/audit.py +88 -0
- harness/observability/logging.py +47 -0
- harness/observability/metrics.py +43 -0
- harness/observability/tracing.py +38 -0
- harness/orchestrator/__init__.py +19 -0
- harness/orchestrator/engine.py +821 -0
- harness/orchestrator/handoff.py +33 -0
- harness/orchestrator/lifecycle.py +69 -0
- harness/orchestrator/native_resume.py +93 -0
- harness/orchestrator/policies.py +101 -0
- harness/orchestrator/reconcile.py +39 -0
- harness/orchestrator/run_graph.py +62 -0
- harness/orchestrator/snapshot.py +49 -0
- harness/schemas/__init__.py +1 -0
- harness/schemas/adapter.py +26 -0
- harness/schemas/approval.py +25 -0
- harness/schemas/artifact.py +17 -0
- harness/schemas/common.py +20 -0
- harness/schemas/memory.py +50 -0
- harness/schemas/policy.py +39 -0
- harness/schemas/run.py +116 -0
- harness/schemas/session.py +93 -0
- harness/schemas/task.py +24 -0
- harness/security/__init__.py +5 -0
- harness/security/approval_policy.py +107 -0
- harness/security/auth.py +106 -0
- harness/security/permissions.py +59 -0
- harness/security/risk.py +59 -0
- harness/security/secrets.py +49 -0
- harness/services/__init__.py +1 -0
- harness/services/adapters_health.py +212 -0
- harness/services/approvals.py +84 -0
- harness/services/artifacts.py +78 -0
- harness/services/attachments.py +228 -0
- harness/services/comparison.py +345 -0
- harness/services/doctor.py +156 -0
- harness/services/files.py +287 -0
- harness/services/mcp_servers.py +179 -0
- harness/services/project_git.py +101 -0
- harness/services/retention.py +97 -0
- harness/services/runs.py +155 -0
- harness/services/runs_diff.py +201 -0
- harness/services/sessions.py +242 -0
- harness/services/ssh.py +300 -0
- harness/services/stats.py +132 -0
- harness/services/tasks.py +41 -0
- harness/services/workspaces.py +184 -0
- harness/services/worktrees.py +439 -0
- harness/settings.py +186 -0
- harness/skills/__init__.py +11 -0
- harness/skills/manager.py +141 -0
- harness/tools/__init__.py +1 -0
- harness/tools/fs_tools.py +295 -0
- harness/workers/__init__.py +0 -0
- harness/workers/dispatch.py +26 -0
- 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()
|
harness/mcp/__init__.py
ADDED
|
@@ -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)
|
harness/mcp/registry.py
ADDED
|
@@ -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()
|