development
rs 336 lines 11.9 KB
Raw
1 //! MCP (Model Context Protocol) server interface.
2 //!
3 //! When `onde` is started with `--mcp`, it runs as an MCP server over stdio
4 //! (see <https://modelcontextprotocol.io/specification/draft/basic/transports/stdio>)
5 //! instead of launching the TUI. The server exposes the same Onde account and
6 //! model-catalog operations the TUI drives — listing/creating apps, browsing the
7 //! model catalog, registering a fine-tuned model, and assigning a model to an
8 //! app — as MCP tools built on the official `rmcp` SDK.
9 //!
10 //! Tools call the same library functions the TUI uses (`crate::gresiq`,
11 //! `smbcloud_auth_sdk`) but return structured JSON rather than rendering the
12 //! ratatui screens. stdout is the JSON-RPC channel and must stay free of console
13 //! output, so — unlike the TUI path — `main` deliberately does **not** redirect
14 //! the process's stdout/stderr when `--mcp` is active.
15 //!
16 //! Authentication reuses the token persisted by a TUI sign-in (or the `login`
17 //! tool below), stored at the same path `crate::token` reads, so an MCP session
18 //! and a terminal session share credentials.
19
20 use {
21 crate::{gresiq, token},
22 anyhow::{Result, anyhow},
23 rmcp::{
24 ErrorData, ServiceExt,
25 handler::server::{ServerHandler, wrapper::Parameters},
26 model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo},
27 tool, tool_handler, tool_router,
28 transport::stdio,
29 },
30 schemars::JsonSchema,
31 serde::Deserialize,
32 smbcloud_auth_sdk::{
33 client_credentials::ClientCredentials, login::login_with_client,
34 logout::logout_with_client, me::me_with_client,
35 },
36 smbcloud_model::login::AccountStatus,
37 smbcloud_network::environment::Environment,
38 };
39
40 /// The Onde MCP server. Onde only talks to the Production API, so there is no
41 /// per-session state to hold; the `rmcp` `#[tool_router]`/`#[tool_handler]`
42 /// macros carry the tool routing.
43 pub struct OndeMcpServer;
44
45 /// The baked-in Auth client credentials identifying the CLI to the backend.
46 fn credentials() -> ClientCredentials<'static> {
47 ClientCredentials {
48 app_id: crate::app::ONDE_APP_ID,
49 app_secret: crate::app::ONDE_APP_SECRET,
50 }
51 }
52
53 impl OndeMcpServer {
54 pub fn new() -> Self {
55 Self
56 }
57
58 /// Resolve the stored auth token, mapping "not logged in" to an MCP error.
59 /// Every tool that hits the account API goes through this.
60 fn access_token(&self) -> Result<String, ErrorData> {
61 token::load().ok_or_else(|| {
62 ErrorData::invalid_request(
63 "Not logged in. Sign in with the `login` tool or run `onde` and sign in.",
64 None,
65 )
66 })
67 }
68 }
69
70 impl Default for OndeMcpServer {
71 fn default() -> Self {
72 Self::new()
73 }
74 }
75
76 /// Serialize any JSON-able value into a single-content successful tool result.
77 fn json_result<T: serde::Serialize>(value: &T) -> Result<CallToolResult, ErrorData> {
78 Ok(CallToolResult::success(vec![ContentBlock::json(value)?]))
79 }
80
81 fn text_result(message: impl Into<String>) -> Result<CallToolResult, ErrorData> {
82 Ok(CallToolResult::success(vec![ContentBlock::text(
83 message.into(),
84 )]))
85 }
86
87 fn to_error_data(error: impl std::fmt::Display) -> ErrorData {
88 ErrorData::internal_error(error.to_string(), None)
89 }
90
91 // ── Tool argument schemas ────────────────────────────────────────────────────
92
93 #[derive(Debug, Deserialize, JsonSchema)]
94 struct LoginArgs {
95 /// Account email address.
96 email: String,
97 /// Account password.
98 password: String,
99 }
100
101 #[derive(Debug, Deserialize, JsonSchema)]
102 struct AppCreateArgs {
103 /// Name for the new Onde app.
104 name: String,
105 }
106
107 #[derive(Debug, Deserialize, JsonSchema)]
108 struct AppRenameArgs {
109 /// The Onde app ID to rename.
110 app_id: String,
111 /// The new name for the app.
112 name: String,
113 }
114
115 #[derive(Debug, Deserialize, JsonSchema)]
116 struct ModelRegisterArgs {
117 /// Hugging Face repo ID the GGUF was uploaded to (e.g. `user/model-gguf`).
118 hf_repo_id: String,
119 /// Display name for the catalog entry.
120 name: String,
121 /// Model family (e.g. `qwen3`, `qwen2.5`).
122 family: String,
123 /// Parameter class label (e.g. `0.6B`, `3B`).
124 parameter_class: String,
125 /// Specific GGUF filename within the repo, if the repo has several.
126 #[serde(default)]
127 gguf_file: Option<String>,
128 /// Approximate on-disk size of the GGUF in bytes, if known.
129 #[serde(default)]
130 approx_size_bytes: Option<i64>,
131 }
132
133 #[derive(Debug, Deserialize, JsonSchema)]
134 struct ModelAssignArgs {
135 /// The Onde app ID to assign the model to.
136 app_id: String,
137 /// The catalog model ID to assign.
138 model_id: String,
139 }
140
141 #[derive(Debug, Deserialize, JsonSchema)]
142 struct HfSearchArgs {
143 /// Search query for the Hugging Face Hub (up to 20 results).
144 query: String,
145 }
146
147 // ── Tools ────────────────────────────────────────────────────────────────────
148
149 #[tool_router]
150 impl OndeMcpServer {
151 #[tool(
152 description = "Sign in to Onde with email and password and persist the access token \
153 to the same location the TUI uses. Returns the signed-in user as JSON."
154 )]
155 async fn login(
156 &self,
157 Parameters(args): Parameters<LoginArgs>,
158 ) -> Result<CallToolResult, ErrorData> {
159 let status = login_with_client(
160 Environment::Production,
161 credentials(),
162 args.email,
163 args.password,
164 )
165 .await
166 .map_err(|e| ErrorData::invalid_request(format!("{e:?}"), None))?;
167
168 match status {
169 AccountStatus::Ready { access_token } => {
170 token::save(&access_token).map_err(to_error_data)?;
171 let user = me_with_client(Environment::Production, credentials(), &access_token)
172 .await
173 .map_err(|e| to_error_data(format!("{e:?}")))?;
174 json_result(&user)
175 }
176 AccountStatus::Incomplete { .. } => Err(ErrorData::invalid_request(
177 "Check your email first — confirm your account before signing in.",
178 None,
179 )),
180 AccountStatus::NotFound => Err(ErrorData::invalid_request(
181 "That email isn't in our system.",
182 None,
183 )),
184 }
185 }
186
187 #[tool(description = "Sign out: revoke and clear the stored Onde access token.")]
188 async fn logout(&self) -> Result<CallToolResult, ErrorData> {
189 if let Some(t) = token::load() {
190 let _ = logout_with_client(Environment::Production, credentials(), t).await;
191 }
192 token::clear();
193 text_result("Signed out.")
194 }
195
196 #[tool(
197 description = "Get the authenticated Onde user's account info. Requires a prior \
198 `login`; returns the user as JSON."
199 )]
200 async fn me(&self) -> Result<CallToolResult, ErrorData> {
201 let token = self.access_token()?;
202 let user = me_with_client(Environment::Production, credentials(), &token)
203 .await
204 .map_err(|e| to_error_data(format!("{e:?}")))?;
205 json_result(&user)
206 }
207
208 #[tool(description = "List the authenticated user's Onde apps as a JSON array.")]
209 async fn apps_list(&self) -> Result<CallToolResult, ErrorData> {
210 let token = self.access_token()?;
211 let apps = gresiq::load_apps(&token).await.map_err(to_error_data)?;
212 json_result(&apps)
213 }
214
215 #[tool(
216 description = "Create a new Onde app with the given name. Returns the created app as JSON."
217 )]
218 async fn app_create(
219 &self,
220 Parameters(args): Parameters<AppCreateArgs>,
221 ) -> Result<CallToolResult, ErrorData> {
222 let token = self.access_token()?;
223 let app = gresiq::create_app(&token, &args.name)
224 .await
225 .map_err(to_error_data)?;
226 json_result(&app)
227 }
228
229 #[tool(description = "Rename an Onde app by ID. Returns the updated app as JSON.")]
230 async fn app_rename(
231 &self,
232 Parameters(args): Parameters<AppRenameArgs>,
233 ) -> Result<CallToolResult, ErrorData> {
234 let token = self.access_token()?;
235 let app = gresiq::rename_app(&token, &args.app_id, &args.name)
236 .await
237 .map_err(to_error_data)?;
238 json_result(&app)
239 }
240
241 #[tool(
242 description = "List the Onde model catalog (including this user's custom models) as a JSON array."
243 )]
244 async fn models_list(&self) -> Result<CallToolResult, ErrorData> {
245 let token = self.access_token()?;
246 let models = gresiq::load_models(&token).await.map_err(to_error_data)?;
247 json_result(&models)
248 }
249
250 #[tool(
251 description = "Register a fine-tuned GGUF model (already uploaded to Hugging Face) into \
252 the catalog, private to this account, so it can be assigned to an app. \
253 Returns the created catalog model as JSON."
254 )]
255 async fn model_register(
256 &self,
257 Parameters(args): Parameters<ModelRegisterArgs>,
258 ) -> Result<CallToolResult, ErrorData> {
259 let token = self.access_token()?;
260 let model = gresiq::create_model(
261 &token,
262 &args.hf_repo_id,
263 &args.name,
264 &args.family,
265 &args.parameter_class,
266 args.gguf_file.as_deref(),
267 args.approx_size_bytes,
268 )
269 .await
270 .map_err(to_error_data)?;
271 json_result(&model)
272 }
273
274 #[tool(
275 description = "Assign a catalog model to an Onde app by their IDs. The end app then \
276 loads this model through the Onde SDK."
277 )]
278 async fn model_assign(
279 &self,
280 Parameters(args): Parameters<ModelAssignArgs>,
281 ) -> Result<CallToolResult, ErrorData> {
282 let token = self.access_token()?;
283 gresiq::assign_model(&token, &args.app_id, &args.model_id)
284 .await
285 .map_err(to_error_data)?;
286 text_result("Model assigned.")
287 }
288
289 #[tool(
290 description = "Search the public Hugging Face Hub for models matching a query \
291 (up to 20 results). Returns compact model info as a JSON array. \
292 No Onde login required."
293 )]
294 async fn hf_search(
295 &self,
296 Parameters(args): Parameters<HfSearchArgs>,
297 ) -> Result<CallToolResult, ErrorData> {
298 let results = crate::hf_search::search_hf(&args.query)
299 .await
300 .map_err(to_error_data)?;
301 json_result(&results)
302 }
303 }
304
305 #[tool_handler]
306 impl ServerHandler for OndeMcpServer {
307 fn get_info(&self) -> ServerInfo {
308 // `Implementation` is `#[non_exhaustive]`, so start from the build-env
309 // default and override the identity fields to report `onde`, not `rmcp`.
310 let mut server_info = Implementation::from_build_env();
311 server_info.name = "onde".to_string();
312 server_info.version = env!("CARGO_PKG_VERSION").to_string();
313
314 ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
315 .with_server_info(server_info)
316 .with_instructions(
317 "Onde CLI exposed as MCP tools. Authentication uses the token stored by the TUI \
318 sign-in or the `login` tool; tools run non-interactively and return JSON. Typical \
319 flow: `login` → `apps_list` / `models_list` → `model_register` (after uploading a \
320 GGUF to Hugging Face) → `model_assign`.",
321 )
322 }
323 }
324
325 /// Run the MCP server over stdio until the client disconnects.
326 pub async fn serve() -> Result<()> {
327 let running = OndeMcpServer::new()
328 .serve(stdio())
329 .await
330 .map_err(|e| anyhow!("Failed to start MCP server: {e}"))?;
331 running
332 .waiting()
333 .await
334 .map_err(|e| anyhow!("MCP server stopped unexpectedly: {e}"))?;
335 Ok(())
336 }