Skip to main content

opencode_ctl/
server.rs

1//! The rmcp server: twenty tools over the engine.
2
3use 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_handler(router = self.tool_router)]` reads it on every call.
18    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
30/// The twenty tool names with their descriptions, in registration order.
31pub 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}