1use std::sync::Arc;
4
5use rmcp::handler::server::router::tool::ToolRouter;
6use rmcp::handler::server::wrapper::Parameters;
7use rmcp::model::{CallToolResult, Implementation, ServerCapabilities, ServerConfig};
8use rmcp::ServerHandler;
9
10use crate::clock::Clock;
11use crate::engine::Engine;
12use crate::tools::*;
13use crate::transport::Transport;
14
15pub struct OpencodeCtlServer<T: Transport, C: Clock> {
16 engine: Arc<Engine<T, C>>,
17 tool_router: ToolRouter<Self>,
19}
20
21impl<T: Transport, C: Clock> OpencodeCtlServer<T, C> {
22 pub fn new(engine: Arc<Engine<T, C>>) -> Self {
23 Self {
24 engine,
25 tool_router: Self::tool_router(),
26 }
27 }
28}
29
30pub fn tool_descriptions() -> Vec<(&'static str, &'static str)> {
32 vec![
33 ("service_status", "Report whether the opencode service is running and reachable (urls, pid, version) and the pairing of this server. Never starts the service. Spends worker model tokens: no."),
34 ("service_start", "Start the opencode service if it is not running (idempotent) and return the same shape as service_status. Spends worker model tokens: no."),
35 ("service_stop", "Stop the opencode service; needs confirm: true; refused on the OpenCode host. This stops the daemon for EVERY session on this machine; any later session tool restarts it; to stop one turn use session_interrupt. Spends worker model tokens: no."),
36 ("service_restart", "Restart the opencode service and return service_status; needs confirm: true; refused on the OpenCode host. Pending permission requests are lost. Spends worker model tokens: no."),
37 ("session_list", "List sessions (newest first) for the paired or current directory, or all with all: true. Returns {sessions: [{id, title, directory, outcome, updated, idle, tokens, cost}], next_cursor}. Spends worker model tokens: no."),
38 ("session_create", "Create a session in a directory, optionally with title, agent, model (provider/model[#variant]), permissions and parent_id. Returns {session_id, directory, agent, model}. Spends worker model tokens: no."),
39 ("session_get", "Read a session's state plus its pending permission requests and forms. Spends worker model tokens: no."),
40 ("session_prompt", "Send a prompt to a session; then call session_wait with after=<message_id> on EVERY slice. Spends worker model tokens: yes."),
41 ("session_wait", "Wait one slice for a session to settle after a prompt; call again with the same after while the state is running. Spends worker model tokens: no."),
42 ("session_messages", "Read a session's messages, compacted. Spends worker model tokens: no."),
43 ("session_changes", "Read the file changes a session made (diff over a message range; paths and patches are engine-side filters, patches: false still pays the full fetch). Spends worker model tokens: no."),
44 ("workspace_status", "Read the version-control status of a workspace directory. Spends worker model tokens: no."),
45 ("session_interrupt", "Interrupt the running turn of a session; {interrupted: false} means it was idle. Spends worker model tokens: no."),
46 ("session_delete", "Delete a session; needs session_id and confirm: true and never defaults to the paired session. Spends worker model tokens: no."),
47 ("permission_reply", "Answer a pending permission request with once, always or reject; always needs confirm: true. Spends worker model tokens: no."),
48 ("form_reply", "Answer a pending form (the worker's question). Spends worker model tokens: no."),
49 ("saved_permissions", "List saved permission rules, or remove one with remove_id and confirm: true. Spends worker model tokens: no."),
50 ("opencode_operations", "List the operations of the opencode API (id, method, path, summary, mutating), optionally filtered. Spends worker model tokens: no."),
51 ("opencode_api", "Call any opencode API operation by operationId (the escape hatch); mutations need allow_mutation and are gated. Spends worker model tokens: depends."),
52 ("pair_status", "Read the pair record of this controller with the tui and claude liveness and the generation. Spends worker model tokens: no."),
53 ]
54}
55
56#[rmcp::tool_router]
57impl<T: Transport, C: Clock> OpencodeCtlServer<T, C> {
58 #[rmcp::tool(
59 description = "Report whether the opencode service is running and reachable (urls, pid, version) and the pairing of this server. Never starts the service. Spends worker model tokens: no."
60 )]
61 async fn service_status(
62 &self,
63 Parameters(args): Parameters<ServiceStatusArgs>,
64 ) -> Result<CallToolResult, rmcp::ErrorData> {
65 Ok(self.engine.render(self.engine.service_status(args).await))
66 }
67
68 #[rmcp::tool(
69 description = "Start the opencode service if it is not running (idempotent) and return the same shape as service_status. Spends worker model tokens: no."
70 )]
71 async fn service_start(
72 &self,
73 Parameters(args): Parameters<ServiceStartArgs>,
74 ) -> Result<CallToolResult, rmcp::ErrorData> {
75 Ok(self.engine.render(self.engine.service_start(args).await))
76 }
77
78 #[rmcp::tool(
79 description = "Stop the opencode service; needs confirm: true; refused on the OpenCode host. This stops the daemon for EVERY session on this machine; any later session tool restarts it; to stop one turn use session_interrupt. Spends worker model tokens: no."
80 )]
81 async fn service_stop(
82 &self,
83 Parameters(args): Parameters<ConfirmArgs>,
84 ) -> Result<CallToolResult, rmcp::ErrorData> {
85 Ok(self.engine.render(self.engine.service_stop(args).await))
86 }
87
88 #[rmcp::tool(
89 description = "Restart the opencode service and return service_status; needs confirm: true; refused on the OpenCode host. Pending permission requests are lost. Spends worker model tokens: no."
90 )]
91 async fn service_restart(
92 &self,
93 Parameters(args): Parameters<ConfirmArgs>,
94 ) -> Result<CallToolResult, rmcp::ErrorData> {
95 Ok(self.engine.render(self.engine.service_restart(args).await))
96 }
97
98 #[rmcp::tool(
99 description = "List sessions (newest first) for the paired or current directory, or all with all: true. Returns {sessions: [{id, title, directory, outcome, updated, idle, tokens, cost}], next_cursor}. Spends worker model tokens: no."
100 )]
101 async fn session_list(
102 &self,
103 Parameters(args): Parameters<SessionListArgs>,
104 ) -> Result<CallToolResult, rmcp::ErrorData> {
105 Ok(self.engine.render(self.engine.session_list(args).await))
106 }
107
108 #[rmcp::tool(
109 description = "Create a session in a directory, optionally with title, agent, model (provider/model[#variant]), permissions and parent_id. Returns {session_id, directory, agent, model}. Spends worker model tokens: no."
110 )]
111 async fn session_create(
112 &self,
113 Parameters(args): Parameters<SessionCreateArgs>,
114 ) -> Result<CallToolResult, rmcp::ErrorData> {
115 Ok(self.engine.render(self.engine.session_create(args).await))
116 }
117
118 #[rmcp::tool(
119 description = "Read a session's state plus its pending permission requests and forms. Spends worker model tokens: no."
120 )]
121 async fn session_get(
122 &self,
123 Parameters(args): Parameters<SessionGetArgs>,
124 ) -> Result<CallToolResult, rmcp::ErrorData> {
125 Ok(self.engine.render(self.engine.session_get(args).await))
126 }
127
128 #[rmcp::tool(
129 description = "Send a prompt to a session; then call session_wait with after=<message_id> on EVERY slice. Spends worker model tokens: yes."
130 )]
131 async fn session_prompt(
132 &self,
133 Parameters(args): Parameters<SessionPromptArgs>,
134 ) -> Result<CallToolResult, rmcp::ErrorData> {
135 Ok(self.engine.render(self.engine.session_prompt(args).await))
136 }
137
138 #[rmcp::tool(
139 description = "Wait one slice for a session to settle after a prompt; call again with the same after while the state is running. Spends worker model tokens: no."
140 )]
141 async fn session_wait(
142 &self,
143 Parameters(args): Parameters<SessionWaitArgs>,
144 ) -> Result<CallToolResult, rmcp::ErrorData> {
145 Ok(self.engine.render(self.engine.session_wait(args).await))
146 }
147
148 #[rmcp::tool(
149 description = "Read a session's messages, compacted. Spends worker model tokens: no."
150 )]
151 async fn session_messages(
152 &self,
153 Parameters(args): Parameters<SessionMessagesArgs>,
154 ) -> Result<CallToolResult, rmcp::ErrorData> {
155 Ok(self.engine.render(self.engine.session_messages(args).await))
156 }
157
158 #[rmcp::tool(
159 description = "Read the file changes a session made (diff over a message range; paths and patches are engine-side filters, patches: false still pays the full fetch). Spends worker model tokens: no."
160 )]
161 async fn session_changes(
162 &self,
163 Parameters(args): Parameters<SessionChangesArgs>,
164 ) -> Result<CallToolResult, rmcp::ErrorData> {
165 Ok(self.engine.render(self.engine.session_changes(args).await))
166 }
167
168 #[rmcp::tool(
169 description = "Read the version-control status of a workspace directory. Spends worker model tokens: no."
170 )]
171 async fn workspace_status(
172 &self,
173 Parameters(args): Parameters<WorkspaceStatusArgs>,
174 ) -> Result<CallToolResult, rmcp::ErrorData> {
175 Ok(self.engine.render(self.engine.workspace_status(args).await))
176 }
177
178 #[rmcp::tool(
179 description = "Interrupt the running turn of a session; {interrupted: false} means it was idle. Spends worker model tokens: no."
180 )]
181 async fn session_interrupt(
182 &self,
183 Parameters(args): Parameters<SessionInterruptArgs>,
184 ) -> Result<CallToolResult, rmcp::ErrorData> {
185 Ok(self
186 .engine
187 .render(self.engine.session_interrupt(args).await))
188 }
189
190 #[rmcp::tool(
191 description = "Delete a session; needs session_id and confirm: true and never defaults to the paired session. Spends worker model tokens: no."
192 )]
193 async fn session_delete(
194 &self,
195 Parameters(args): Parameters<SessionDeleteArgs>,
196 ) -> Result<CallToolResult, rmcp::ErrorData> {
197 Ok(self.engine.render(self.engine.session_delete(args).await))
198 }
199
200 #[rmcp::tool(
201 description = "Answer a pending permission request with once, always or reject; always needs confirm: true. Spends worker model tokens: no."
202 )]
203 async fn permission_reply(
204 &self,
205 Parameters(args): Parameters<PermissionReplyArgs>,
206 ) -> Result<CallToolResult, rmcp::ErrorData> {
207 Ok(self.engine.render(self.engine.permission_reply(args).await))
208 }
209
210 #[rmcp::tool(
211 description = "Answer a pending form (the worker's question). Spends worker model tokens: no."
212 )]
213 async fn form_reply(
214 &self,
215 Parameters(args): Parameters<FormReplyArgs>,
216 ) -> Result<CallToolResult, rmcp::ErrorData> {
217 Ok(self.engine.render(self.engine.form_reply(args).await))
218 }
219
220 #[rmcp::tool(
221 description = "List saved permission rules, or remove one with remove_id and confirm: true. Spends worker model tokens: no."
222 )]
223 async fn saved_permissions(
224 &self,
225 Parameters(args): Parameters<SavedPermissionsArgs>,
226 ) -> Result<CallToolResult, rmcp::ErrorData> {
227 Ok(self
228 .engine
229 .render(self.engine.saved_permissions(args).await))
230 }
231
232 #[rmcp::tool(
233 description = "List the operations of the opencode API (id, method, path, summary, mutating), optionally filtered. Spends worker model tokens: no."
234 )]
235 async fn opencode_operations(
236 &self,
237 Parameters(args): Parameters<OpencodeOperationsArgs>,
238 ) -> Result<CallToolResult, rmcp::ErrorData> {
239 Ok(self
240 .engine
241 .render(self.engine.opencode_operations(args).await))
242 }
243
244 #[rmcp::tool(
245 description = "Call any opencode API operation by operationId (the escape hatch); mutations need allow_mutation and are gated. Spends worker model tokens: depends."
246 )]
247 async fn opencode_api(
248 &self,
249 Parameters(args): Parameters<OpencodeApiArgs>,
250 ) -> Result<CallToolResult, rmcp::ErrorData> {
251 Ok(self.engine.render(self.engine.opencode_api(args).await))
252 }
253
254 #[rmcp::tool(
255 description = "Read the pair record of this controller with the tui and claude liveness and the generation. Spends worker model tokens: no."
256 )]
257 async fn pair_status(
258 &self,
259 Parameters(args): Parameters<PairStatusArgs>,
260 ) -> Result<CallToolResult, rmcp::ErrorData> {
261 Ok(self.engine.render(self.engine.pair_status(args).await))
262 }
263}
264
265#[rmcp::tool_handler(router = self.tool_router)]
266impl<T: Transport, C: Clock> ServerHandler for OpencodeCtlServer<T, C> {
267 fn get_info(&self) -> ServerConfig {
268 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
269 .with_server_info(Implementation::new(
270 self.engine.host.binary_name(),
271 env!("CARGO_PKG_VERSION"),
272 ))
273 .with_instructions(
274 "Drives an opencode service as a worker: service_status first, then session_create \
275 or session_list, session_prompt, and session_wait on every slice until the state \
276 settles. Read session_changes to verify the work. Every result is JSON; failures \
277 are {error: {code, message, hint?}}.",
278 )
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::clock::SystemClock;
286 use crate::transport::NotFoundTransport;
287
288 #[test]
289 fn descriptions_table_matches_the_router() {
290 let router = OpencodeCtlServer::<NotFoundTransport, SystemClock>::tool_router();
291 let listed = router.list_all();
292 let table = tool_descriptions();
293 assert_eq!(listed.len(), table.len());
294 for (name, desc) in table {
295 let t = listed.iter().find(|t| t.name == name).expect(name);
296 assert_eq!(t.description.as_deref(), Some(desc), "{name}");
297 }
298 }
299}