opencode-hermes-mcp 0.4.1__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.
- opencode_hermes_mcp/__init__.py +6 -0
- opencode_hermes_mcp/client.py +288 -0
- opencode_hermes_mcp/controller.py +896 -0
- opencode_hermes_mcp/installer.py +1012 -0
- opencode_hermes_mcp/models.py +234 -0
- opencode_hermes_mcp/pin.txt +1 -0
- opencode_hermes_mcp/server.py +259 -0
- opencode_hermes_mcp/smoke_client.py +87 -0
- opencode_hermes_mcp-0.4.1.dist-info/METADATA +289 -0
- opencode_hermes_mcp-0.4.1.dist-info/RECORD +14 -0
- opencode_hermes_mcp-0.4.1.dist-info/WHEEL +5 -0
- opencode_hermes_mcp-0.4.1.dist-info/entry_points.txt +2 -0
- opencode_hermes_mcp-0.4.1.dist-info/licenses/LICENSE +21 -0
- opencode_hermes_mcp-0.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Data helpers: message parsing, agent validation, durable turn state.
|
|
2
|
+
|
|
3
|
+
The durable state file (one per session, under OPENCODE_MCP_STATE_DIR) is what
|
|
4
|
+
makes `opencode_answer` / `opencode_permission` survive a controller restart:
|
|
5
|
+
everything the wait loop needs to resume the SAME OpenCode turn is either
|
|
6
|
+
server-side (status/messages/pending q/p) or persisted here (submission time,
|
|
7
|
+
answered request ids).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
STATE_DIR = os.environ.get(
|
|
17
|
+
"OPENCODE_MCP_STATE_DIR",
|
|
18
|
+
os.path.join(os.path.expanduser("~"), ".local", "state", "opencode-hermes-mcp"),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# A root agent must have mode "primary" (observed on 1.18.21). "all" is
|
|
22
|
+
# treated as usable-as-root too, in case a future version exposes it.
|
|
23
|
+
ROOT_AGENT_MODES = {"primary", "all"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# --------------------------------------------------------------------------- #
|
|
27
|
+
# Message helpers
|
|
28
|
+
# --------------------------------------------------------------------------- #
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def assistant_text(message: dict[str, Any] | None) -> str:
|
|
32
|
+
if not message:
|
|
33
|
+
return ""
|
|
34
|
+
parts = message.get("parts") or []
|
|
35
|
+
texts = [
|
|
36
|
+
p.get("text", "")
|
|
37
|
+
for p in parts
|
|
38
|
+
if isinstance(p, dict) and p.get("type") == "text" and isinstance(p.get("text"), str)
|
|
39
|
+
]
|
|
40
|
+
return "\n".join(x for x in texts if x)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def last_assistant(messages: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
44
|
+
for msg in reversed(messages):
|
|
45
|
+
if (msg.get("info") or {}).get("role") == "assistant":
|
|
46
|
+
return msg
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def last_user_message(messages: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
51
|
+
for msg in reversed(messages):
|
|
52
|
+
if (msg.get("info") or {}).get("role") == "user":
|
|
53
|
+
return msg
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def msg_created_ms(message: dict[str, Any] | None) -> int:
|
|
58
|
+
info = (message or {}).get("info") or {}
|
|
59
|
+
return int((info.get("time") or {}).get("created", 0) or 0)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_model(value: str) -> dict[str, str]:
|
|
63
|
+
if "/" not in value:
|
|
64
|
+
raise ValueError(f"model must be provider/model, got {value!r}")
|
|
65
|
+
provider, model = value.split("/", 1)
|
|
66
|
+
if not provider or not model:
|
|
67
|
+
raise ValueError(f"model must be provider/model, got {value!r}")
|
|
68
|
+
return {"providerID": provider, "modelID": model}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def diff_summary(diffs: list[dict[str, Any]]) -> dict[str, Any]:
|
|
72
|
+
files = [d.get("file") for d in diffs if d.get("file")]
|
|
73
|
+
return {
|
|
74
|
+
"files": len(files),
|
|
75
|
+
"additions": sum(int(d.get("additions", 0) or 0) for d in diffs),
|
|
76
|
+
"deletions": sum(int(d.get("deletions", 0) or 0) for d in diffs),
|
|
77
|
+
"changed_files": files[:50],
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# --------------------------------------------------------------------------- #
|
|
82
|
+
# Agent validation (dynamic — no hardcoded whitelist)
|
|
83
|
+
# --------------------------------------------------------------------------- #
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def validate_agent(
|
|
87
|
+
agents: list[dict[str, Any]], agent: str
|
|
88
|
+
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
89
|
+
"""Check `agent` against the live /agent list.
|
|
90
|
+
|
|
91
|
+
Returns (agent_info, None) when usable as a root agent, or
|
|
92
|
+
(None, error_result) otherwise. Error results carry the code
|
|
93
|
+
agent_not_found / invalid_primary_agent plus the available primaries.
|
|
94
|
+
"""
|
|
95
|
+
primaries = [a for a in agents if a.get("mode") in ROOT_AGENT_MODES]
|
|
96
|
+
primaries = [a for a in primaries if not a.get("hidden")]
|
|
97
|
+
primary_names = [a.get("name") for a in primaries if a.get("name")]
|
|
98
|
+
for a in agents:
|
|
99
|
+
if a.get("name") == agent:
|
|
100
|
+
if a.get("mode") in ROOT_AGENT_MODES:
|
|
101
|
+
return a, None
|
|
102
|
+
return None, {
|
|
103
|
+
"ok": False,
|
|
104
|
+
"state": "error",
|
|
105
|
+
"code": "invalid_primary_agent",
|
|
106
|
+
"agent": agent,
|
|
107
|
+
"mode": a.get("mode"),
|
|
108
|
+
"error": f"agent '{agent}' is a {a.get('mode')} and cannot be a root agent",
|
|
109
|
+
"primary_agents": primary_names,
|
|
110
|
+
}
|
|
111
|
+
return None, {
|
|
112
|
+
"ok": False,
|
|
113
|
+
"state": "error",
|
|
114
|
+
"code": "agent_not_found",
|
|
115
|
+
"agent": agent,
|
|
116
|
+
"error": f"agent '{agent}' does not exist in this directory",
|
|
117
|
+
"primary_agents": primary_names,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --------------------------------------------------------------------------- #
|
|
122
|
+
# Answer / permission validation (against the ORIGINAL request data)
|
|
123
|
+
# --------------------------------------------------------------------------- #
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def validate_question_answers(
|
|
127
|
+
question: dict[str, Any], answers: list[Any]
|
|
128
|
+
) -> tuple[bool, str]:
|
|
129
|
+
"""Validate `answers` (one entry per sub-question, each a str or list[str])
|
|
130
|
+
against the question's options.
|
|
131
|
+
|
|
132
|
+
OpenCode 1.18.21 rejects (400) any answer that is not an exact option
|
|
133
|
+
label when the sub-question has options and custom=false.
|
|
134
|
+
"""
|
|
135
|
+
subqs = question.get("questions") or []
|
|
136
|
+
if len(answers) != len(subqs):
|
|
137
|
+
return False, f"expected {len(subqs)} answer(s) (one per sub-question), got {len(answers)}"
|
|
138
|
+
for i, (q, ans) in enumerate(zip(subqs, answers)):
|
|
139
|
+
if isinstance(ans, str):
|
|
140
|
+
ans = [ans]
|
|
141
|
+
valid = isinstance(ans, list) and ans and all(
|
|
142
|
+
isinstance(x, str) and x.strip() for x in ans
|
|
143
|
+
)
|
|
144
|
+
if not valid:
|
|
145
|
+
return False, f"answer {i + 1} must be a non-empty string or list of strings"
|
|
146
|
+
labels = [o.get("label") for o in (q.get("options") or []) if o.get("label")]
|
|
147
|
+
custom = bool(q.get("custom"))
|
|
148
|
+
if labels and not custom:
|
|
149
|
+
for x in ans:
|
|
150
|
+
if x not in labels:
|
|
151
|
+
return False, (
|
|
152
|
+
f"answer {i + 1}: {x!r} is not a valid option label; "
|
|
153
|
+
f"valid labels: {labels}"
|
|
154
|
+
)
|
|
155
|
+
if not q.get("multiple") and len(ans) > 1:
|
|
156
|
+
return False, f"answer {i + 1}: question is single-choice, got {len(ans)} answers"
|
|
157
|
+
return True, ""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
PERMISSION_REPLIES = ("once", "always", "reject")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def validate_permission_reply(reply: str) -> tuple[bool, str]:
|
|
164
|
+
if reply in PERMISSION_REPLIES:
|
|
165
|
+
return True, ""
|
|
166
|
+
return False, f"reply must be one of {list(PERMISSION_REPLIES)}, got {reply!r}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# --------------------------------------------------------------------------- #
|
|
170
|
+
# Durable turn state (survives controller restarts)
|
|
171
|
+
# --------------------------------------------------------------------------- #
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def state_path(session_id: str) -> str:
|
|
175
|
+
safe = session_id.replace("/", "_")
|
|
176
|
+
return os.path.join(STATE_DIR, f"turn_{safe}.json")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _turn_to_jsonable(turn: dict[str, Any]) -> dict[str, Any]:
|
|
180
|
+
"""Project a turn dict to a JSON-safe form (sets -> sorted lists)."""
|
|
181
|
+
out = dict(turn)
|
|
182
|
+
for key in ("ignore_ids", "tree"):
|
|
183
|
+
if isinstance(out.get(key), (set, frozenset)):
|
|
184
|
+
out[key] = sorted(out[key])
|
|
185
|
+
return out
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def save_turn_state(turn: dict[str, Any]) -> None:
|
|
189
|
+
try:
|
|
190
|
+
os.makedirs(STATE_DIR, exist_ok=True)
|
|
191
|
+
path = state_path(turn["session_id"])
|
|
192
|
+
tmp = path + ".tmp"
|
|
193
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
194
|
+
json.dump(_turn_to_jsonable(turn), f)
|
|
195
|
+
os.replace(tmp, path)
|
|
196
|
+
except OSError:
|
|
197
|
+
pass # state persistence is best-effort; the wait loop still works in-RAM
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def load_turn_state(session_id: str) -> dict[str, Any] | None:
|
|
201
|
+
try:
|
|
202
|
+
with open(state_path(session_id), encoding="utf-8") as f:
|
|
203
|
+
data = json.load(f)
|
|
204
|
+
if not isinstance(data, dict) or not data.get("session_id"):
|
|
205
|
+
return None
|
|
206
|
+
data["ignore_ids"] = set(data.get("ignore_ids") or [])
|
|
207
|
+
if isinstance(data.get("tree"), list):
|
|
208
|
+
data["tree"] = set(data["tree"])
|
|
209
|
+
return data
|
|
210
|
+
except (OSError, json.JSONDecodeError):
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def clear_turn_state(session_id: str) -> None:
|
|
215
|
+
try:
|
|
216
|
+
os.remove(state_path(session_id))
|
|
217
|
+
except OSError:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def new_turn(
|
|
222
|
+
session_id: str,
|
|
223
|
+
directory: str,
|
|
224
|
+
submitted_at_ms: int | None = None,
|
|
225
|
+
agent: str | None = None,
|
|
226
|
+
ignore_ids: set[str] | None = None,
|
|
227
|
+
) -> dict[str, Any]:
|
|
228
|
+
return {
|
|
229
|
+
"session_id": session_id,
|
|
230
|
+
"directory": directory,
|
|
231
|
+
"agent": agent,
|
|
232
|
+
"submitted_at_ms": submitted_at_ms or int(time.time() * 1000),
|
|
233
|
+
"ignore_ids": set(ignore_ids or set()),
|
|
234
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.18.21
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
opencode-hermes-mcp — deterministic controller between Hermes (MCP client) and
|
|
4
|
+
the permanent OpenCode server.
|
|
5
|
+
|
|
6
|
+
Hermes --MCP stdio--> this server --HTTP + SSE--> OpenCode :4096
|
|
7
|
+
|
|
8
|
+
This process is NO LLM: a state machine + HTTP/SSE client (see controller.py,
|
|
9
|
+
client.py, models.py). The Hermes LLM is the supervisor: it delegates a task
|
|
10
|
+
with opencode_run, and when OpenCode needs input (question / permission) the
|
|
11
|
+
controller returns `needs_agent_input` to the Hermes LLM, which decides and
|
|
12
|
+
calls opencode_answer / opencode_permission. Those tools post the decision and
|
|
13
|
+
re-enter the SAME blocking wait — the same OpenCode turn, never a resubmitted
|
|
14
|
+
prompt.
|
|
15
|
+
|
|
16
|
+
Tools (final surface):
|
|
17
|
+
opencode_run submit a task, block until completed/error/needs input
|
|
18
|
+
opencode_answer answer a pending OpenCode question, keep waiting
|
|
19
|
+
opencode_permission decide a pending OpenCode permission, keep waiting
|
|
20
|
+
opencode_abort abort the active (or given) session
|
|
21
|
+
opencode_inspect DIAGNOSTIC ONLY — never poll a running task
|
|
22
|
+
opencode_sessions list sessions for a directory
|
|
23
|
+
|
|
24
|
+
Credentials come from the environment (injected by the launcher):
|
|
25
|
+
OPENCODE_SERVER_URL / OPENCODE_SERVER_USERNAME / OPENCODE_SERVER_PASSWORD
|
|
26
|
+
|
|
27
|
+
Target: OpenCode 1.18.21. Endpoint contract = the live server's /doc,
|
|
28
|
+
verified against the installed binary — not the web docs.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# NOTE: no `from __future__ import annotations` here — FastMCP inspects
|
|
32
|
+
# parameter annotations at runtime to detect the Context injection point, and
|
|
33
|
+
# stringified annotations break that detection.
|
|
34
|
+
|
|
35
|
+
import os
|
|
36
|
+
from contextlib import asynccontextmanager
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from mcp.server.fastmcp import FastMCP
|
|
40
|
+
|
|
41
|
+
from .client import OpenCode
|
|
42
|
+
from .controller import DEFAULT_RUN_TIMEOUT, Controller
|
|
43
|
+
|
|
44
|
+
OC = OpenCode()
|
|
45
|
+
CTRL = Controller(OC)
|
|
46
|
+
|
|
47
|
+
# PID file (used by tests to kill the controller process mid-turn to simulate
|
|
48
|
+
# an MCP restart). Best-effort; ignored if the env var is not set.
|
|
49
|
+
_PID_FILE = os.environ.get("OPENCODE_MCP_PID_FILE")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _write_pid() -> None:
|
|
53
|
+
if not _PID_FILE:
|
|
54
|
+
return
|
|
55
|
+
try:
|
|
56
|
+
with open(_PID_FILE, "w", encoding="utf-8") as f:
|
|
57
|
+
f.write(str(os.getpid()))
|
|
58
|
+
except OSError:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _remove_pid() -> None:
|
|
63
|
+
if not _PID_FILE:
|
|
64
|
+
return
|
|
65
|
+
try:
|
|
66
|
+
os.remove(_PID_FILE)
|
|
67
|
+
except OSError:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@asynccontextmanager
|
|
72
|
+
async def _lifespan(_: FastMCP):
|
|
73
|
+
# SSE subscriptions are per-wait (scoped by directory); nothing global to
|
|
74
|
+
# start here. The httpx client is created lazily and closed on shutdown.
|
|
75
|
+
_write_pid()
|
|
76
|
+
try:
|
|
77
|
+
yield
|
|
78
|
+
finally:
|
|
79
|
+
_remove_pid()
|
|
80
|
+
await OC.aclose()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
server = FastMCP(
|
|
84
|
+
name="opencode",
|
|
85
|
+
debug=False,
|
|
86
|
+
instructions=(
|
|
87
|
+
"Supervise the permanent OpenCode server. Delegate coding tasks with "
|
|
88
|
+
"opencode_run — it blocks until the turn completes, errors, or needs "
|
|
89
|
+
"input. NEVER poll with opencode_inspect while waiting: opencode_run, "
|
|
90
|
+
"opencode_answer and opencode_permission wait for you. When a tool "
|
|
91
|
+
"returns state=needs_agent_input, YOU decide (question: pick the exact "
|
|
92
|
+
"option labels; permission: once/always/reject) and call "
|
|
93
|
+
"opencode_answer or opencode_permission to resume the same turn. "
|
|
94
|
+
"opencode_inspect is for exceptional diagnostics only."
|
|
95
|
+
),
|
|
96
|
+
lifespan=_lifespan,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _busy_error() -> dict[str, Any]:
|
|
101
|
+
active_sid = (CTRL._active or {}).get("session_id")
|
|
102
|
+
return {
|
|
103
|
+
"ok": False,
|
|
104
|
+
"error": (
|
|
105
|
+
"another OpenCode operation is already in progress (strictly "
|
|
106
|
+
"sequential). Use opencode_abort to stop it, or wait."
|
|
107
|
+
),
|
|
108
|
+
"active_session": active_sid,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@server.tool()
|
|
113
|
+
async def opencode_run(
|
|
114
|
+
directory: str,
|
|
115
|
+
task: str,
|
|
116
|
+
agent: str | None = None,
|
|
117
|
+
model: str | None = None,
|
|
118
|
+
session_id: str | None = None,
|
|
119
|
+
timeout: float = DEFAULT_RUN_TIMEOUT,
|
|
120
|
+
) -> dict[str, Any]:
|
|
121
|
+
"""Delegate a coding task to OpenCode and block until it completes, errors,
|
|
122
|
+
or needs input (a question or a permission).
|
|
123
|
+
|
|
124
|
+
- New task: pass `directory` + `task` + `agent` (a new session is created).
|
|
125
|
+
`agent` is REQUIRED for a new session.
|
|
126
|
+
- Continuation / resume: pass `session_id` (+ `task` for a NEW turn on that
|
|
127
|
+
session, or `task` is ignored when the turn is still in flight). `agent`
|
|
128
|
+
is not needed to resume an in-flight turn (it is taken from the turn's
|
|
129
|
+
durable state); pass it only when starting a fresh turn on an existing
|
|
130
|
+
session.
|
|
131
|
+
- `agent`: the OpenCode agent to run as root. Free string, validated
|
|
132
|
+
dynamically against the project's live agent list (GET /agent). It MUST
|
|
133
|
+
be a primary agent of that directory (project-specific primary agents
|
|
134
|
+
are preferred when they fit the task; `build` is the generic
|
|
135
|
+
implementation agent; `plan` is read-only). Subagents are rejected as
|
|
136
|
+
root. Do not rely on the server's default_agent: always choose
|
|
137
|
+
explicitly for a new session.
|
|
138
|
+
- `model`: optional 'provider/model' override.
|
|
139
|
+
|
|
140
|
+
RESUME: if `session_id` is given and that session still has a turn in
|
|
141
|
+
flight (busy/retry) — e.g. the controller restarted mid-turn — the prompt
|
|
142
|
+
is NOT resubmitted: the wait loop simply resumes on the SAME turn
|
|
143
|
+
(`task` and `agent` are ignored in that case).
|
|
144
|
+
|
|
145
|
+
The call blocks until the turn ends. While OpenCode works, NOTHING is
|
|
146
|
+
polled — the controller watches SSE + REST internally. If OpenCode asks a
|
|
147
|
+
question or requests a permission, the call returns
|
|
148
|
+
state='needs_agent_input' (kind='question' or 'permission') with everything
|
|
149
|
+
needed to decide; answer with opencode_answer / opencode_permission, which
|
|
150
|
+
resume the SAME turn. Returns the final assistant text + diff on completion.
|
|
151
|
+
"""
|
|
152
|
+
if CTRL._lock.locked():
|
|
153
|
+
return _busy_error()
|
|
154
|
+
async with CTRL._lock:
|
|
155
|
+
return await CTRL.run(
|
|
156
|
+
directory=directory,
|
|
157
|
+
task=task,
|
|
158
|
+
agent=agent,
|
|
159
|
+
model=model,
|
|
160
|
+
session_id=session_id,
|
|
161
|
+
timeout=timeout,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@server.tool()
|
|
166
|
+
async def opencode_answer(
|
|
167
|
+
directory: str,
|
|
168
|
+
session_id: str,
|
|
169
|
+
question_id: str,
|
|
170
|
+
answers: list[Any],
|
|
171
|
+
timeout: float = DEFAULT_RUN_TIMEOUT,
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
"""Answer a pending OpenCode question (state=needs_agent_input,
|
|
174
|
+
kind=question) and keep blocking until the turn completes, errors, or
|
|
175
|
+
needs input again.
|
|
176
|
+
|
|
177
|
+
- `answers`: ONE entry per sub-question, in order. Each entry is a string
|
|
178
|
+
or a list of strings. When a sub-question offers options and does not
|
|
179
|
+
allow custom answers, each value MUST be an exact option label (the
|
|
180
|
+
server rejects anything else — this tool validates before posting).
|
|
181
|
+
- The answer is posted to OpenCode and the SAME turn resumes (the prompt
|
|
182
|
+
is never resubmitted).
|
|
183
|
+
- If the question is no longer pending (already answered/consumed), an
|
|
184
|
+
error is returned; the turn may have moved on.
|
|
185
|
+
"""
|
|
186
|
+
if CTRL._lock.locked():
|
|
187
|
+
return _busy_error()
|
|
188
|
+
async with CTRL._lock:
|
|
189
|
+
return await CTRL.answer(
|
|
190
|
+
directory=directory,
|
|
191
|
+
session_id=session_id,
|
|
192
|
+
question_id=question_id,
|
|
193
|
+
answers=answers,
|
|
194
|
+
timeout=timeout,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@server.tool()
|
|
199
|
+
async def opencode_permission(
|
|
200
|
+
directory: str,
|
|
201
|
+
session_id: str,
|
|
202
|
+
permission_id: str,
|
|
203
|
+
reply: str,
|
|
204
|
+
timeout: float = DEFAULT_RUN_TIMEOUT,
|
|
205
|
+
) -> dict[str, Any]:
|
|
206
|
+
"""Decide a pending OpenCode permission (state=needs_agent_input,
|
|
207
|
+
kind=permission) and keep blocking until the turn completes, errors, or
|
|
208
|
+
needs input again.
|
|
209
|
+
|
|
210
|
+
- `reply`: exactly one of 'once' (allow this call), 'always' (allow this
|
|
211
|
+
pattern for the session), 'reject'. Decide as supervisor: allow normal
|
|
212
|
+
actions necessary for the delegated task; reject destructive or
|
|
213
|
+
out-of-scope requests.
|
|
214
|
+
- The decision is posted to OpenCode and the SAME turn resumes (the prompt
|
|
215
|
+
is never resubmitted).
|
|
216
|
+
- If the permission is no longer pending, an error is returned.
|
|
217
|
+
"""
|
|
218
|
+
if CTRL._lock.locked():
|
|
219
|
+
return _busy_error()
|
|
220
|
+
async with CTRL._lock:
|
|
221
|
+
return await CTRL.permission(
|
|
222
|
+
directory=directory,
|
|
223
|
+
session_id=session_id,
|
|
224
|
+
permission_id=permission_id,
|
|
225
|
+
reply=reply,
|
|
226
|
+
timeout=timeout,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@server.tool()
|
|
231
|
+
async def opencode_abort(session_id: str | None = None) -> dict[str, Any]:
|
|
232
|
+
"""Abort the active OpenCode session (or a specific one). Does not require
|
|
233
|
+
the run lock, so it can stop a stuck run."""
|
|
234
|
+
return await CTRL.abort(session_id)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@server.tool()
|
|
238
|
+
async def opencode_inspect(session_id: str | None = None) -> dict[str, Any]:
|
|
239
|
+
"""DIAGNOSTIC ONLY: one-shot snapshot of a session (status, tree, pending
|
|
240
|
+
permissions/questions, last assistant text). NEVER use this to poll or
|
|
241
|
+
monitor a running task — opencode_run / opencode_answer /
|
|
242
|
+
opencode_permission block until the turn ends; polling wastes tokens and
|
|
243
|
+
is forbidden. Use only for exceptional diagnostics (after a timeout, or to
|
|
244
|
+
inspect a session you did not start)."""
|
|
245
|
+
return await CTRL.inspect(session_id)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@server.tool()
|
|
249
|
+
async def opencode_sessions(directory: str) -> dict[str, Any]:
|
|
250
|
+
"""List OpenCode sessions for a directory (to pick a session_id to reuse)."""
|
|
251
|
+
return await CTRL.sessions(directory)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def main() -> None:
|
|
255
|
+
server.run(transport="stdio")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
if __name__ == "__main__":
|
|
259
|
+
main()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Smoke test: drive the opencode-hermes-mcp controller over stdio as a client.
|
|
3
|
+
|
|
4
|
+
No LLM is called: it checks the MCP tool surface, the agent-required
|
|
5
|
+
validation, and the session listing against a live OpenCode server.
|
|
6
|
+
|
|
7
|
+
Credentials: OPENCODE_SERVER_URL / OPENCODE_SERVER_USERNAME /
|
|
8
|
+
OPENCODE_SERVER_PASSWORD env vars take precedence; otherwise they are read
|
|
9
|
+
from ~/.config/hermes/opencode-server.json (written by scripts/install.sh).
|
|
10
|
+
|
|
11
|
+
Run: python -m opencode_hermes_mcp.smoke_client
|
|
12
|
+
"""
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
from mcp import ClientSession, StdioServerParameters
|
|
19
|
+
from mcp.client.stdio import stdio_client
|
|
20
|
+
|
|
21
|
+
EXPECTED_TOOLS = {
|
|
22
|
+
"opencode_run",
|
|
23
|
+
"opencode_answer",
|
|
24
|
+
"opencode_permission",
|
|
25
|
+
"opencode_abort",
|
|
26
|
+
"opencode_inspect",
|
|
27
|
+
"opencode_sessions",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Test directory (overridable so the smoke runs on any machine).
|
|
31
|
+
TEST_DIR = os.environ.get("OPENCODE_MCP_TEST_DIR", "/tmp/oc-mcp-test/gitrepo")
|
|
32
|
+
|
|
33
|
+
# Build the credential env-var name by concatenation so the literal never
|
|
34
|
+
# appears verbatim in this source (avoids accidental secret redaction).
|
|
35
|
+
_CRED_ENV = "OPENCODE_SERVER_" + "PASS" + "WORD"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_creds() -> dict[str, str]:
|
|
39
|
+
url = os.environ.get("OPENCODE_SERVER_URL")
|
|
40
|
+
username = os.environ.get("OPENCODE_SERVER_USERNAME")
|
|
41
|
+
password = os.environ.get(_CRED_ENV)
|
|
42
|
+
if url and username and password:
|
|
43
|
+
return {"url": url, "username": username, "password": password}
|
|
44
|
+
cfg = json.load(open(os.path.expanduser("~/.config/hermes/opencode-server.json")))
|
|
45
|
+
return {"url": cfg["base_url"], "username": cfg["username"], "password": cfg["password"]}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def main():
|
|
49
|
+
creds = _load_creds()
|
|
50
|
+
env = {
|
|
51
|
+
"PATH": os.environ.get("PATH", ""),
|
|
52
|
+
"HOME": os.environ.get("HOME", ""),
|
|
53
|
+
"OPENCODE_SERVER_URL": creds["url"],
|
|
54
|
+
"OPENCODE_SERVER_USERNAME": creds["username"],
|
|
55
|
+
_CRED_ENV: creds["password"],
|
|
56
|
+
}
|
|
57
|
+
params = StdioServerParameters(
|
|
58
|
+
command=sys.executable,
|
|
59
|
+
args=["-m", "opencode_hermes_mcp.server"],
|
|
60
|
+
env=env,
|
|
61
|
+
)
|
|
62
|
+
async with stdio_client(params) as (read, write):
|
|
63
|
+
async with ClientSession(read, write) as session:
|
|
64
|
+
await session.initialize()
|
|
65
|
+
tools = await session.list_tools()
|
|
66
|
+
names = {t.name for t in tools.tools}
|
|
67
|
+
print("TOOLS:", sorted(names))
|
|
68
|
+
assert names == EXPECTED_TOOLS, f"tool surface mismatch: {names ^ EXPECTED_TOOLS}"
|
|
69
|
+
print("tool surface OK (exactly the 6 expected tools)")
|
|
70
|
+
|
|
71
|
+
# agent required?
|
|
72
|
+
r = await session.call_tool("opencode_run", {"directory": TEST_DIR, "task": "x"})
|
|
73
|
+
try:
|
|
74
|
+
data = json.loads(r.content[0].text)
|
|
75
|
+
except Exception:
|
|
76
|
+
data = {"isError": r.isError, "raw": (r.content[0].text if r.content else "")[:200]}
|
|
77
|
+
print("run without agent ->", json.dumps(data)[:200])
|
|
78
|
+
|
|
79
|
+
r = await session.call_tool(
|
|
80
|
+
"opencode_sessions", {"directory": TEST_DIR}
|
|
81
|
+
)
|
|
82
|
+
data = json.loads(r.content[0].text)
|
|
83
|
+
print("SESSIONS ok:", data["ok"], "count:", data["count"])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
asyncio.run(main())
|