@ondeinference / cli / commits / 0eb0a7e

Implement MCP server over stdio

Seto Elkahfi committed Jul 22, 2026 at 21:32 UTC 0eb0a7eaad63c1c7e58e83a0cb8a92d2ce3e25b0
8 files changed +407 -4
.agents/AGENTS.md
+1
@@ -7,6 +7,7 @@
7 ```sh
8 cargo build # debug build
9 cargo run # launch the TUI
10 +cargo run -- --mcp # run as an MCP server over stdio (no TUI)
11 cargo test --locked # unit tests (heavy ones are #[ignore])
12 cargo clippy --all-targets -- -D warnings
13 cargo fmt -- --check
Cargo.lock
+44
@@ -3375,6 +3375,8 @@ dependencies = [
3375 "onde",
3376 "ratatui",
3377 "reqwest 0.12.28",
3378 + "rmcp",
3379 + "schemars 1.2.1",
3380 "serde",
3381 "serde_json",
3382 "sha2",
@@ -3730,6 +3732,12 @@ version = "1.0.15"
3732 source = "registry+https://github.com/rust-lang/crates.io-index"
3733 checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
3734
3735 +[[package]]
3736 +name = "pastey"
3737 +version = "0.2.3"
3738 +source = "registry+https://github.com/rust-lang/crates.io-index"
3739 +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
3740 +
3741 [[package]]
3742 name = "percent-encoding"
3743 version = "2.3.2"
@@ -4442,6 +4450,41 @@ dependencies = [
4450 "windows-sys 0.52.0",
4451 ]
4452
4453 +[[package]]
4454 +name = "rmcp"
4455 +version = "2.2.0"
4456 +source = "registry+https://github.com/rust-lang/crates.io-index"
4457 +checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af"
4458 +dependencies = [
4459 + "async-trait",
4460 + "base64 0.22.1",
4461 + "chrono",
4462 + "futures",
4463 + "pastey",
4464 + "pin-project-lite",
4465 + "rmcp-macros",
4466 + "schemars 1.2.1",
4467 + "serde",
4468 + "serde_json",
4469 + "thiserror 2.0.18",
4470 + "tokio",
4471 + "tokio-util",
4472 + "tracing",
4473 +]
4474 +
4475 +[[package]]
4476 +name = "rmcp-macros"
4477 +version = "2.2.0"
4478 +source = "registry+https://github.com/rust-lang/crates.io-index"
4479 +checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3"
4480 +dependencies = [
4481 + "darling 0.23.0",
4482 + "proc-macro2",
4483 + "quote",
4484 + "serde_json",
4485 + "syn 2.0.118",
4486 +]
4487 +
4488 [[package]]
4489 name = "rubato"
4490 version = "0.16.2"
@@ -4701,6 +4744,7 @@ version = "1.2.1"
4744 source = "registry+https://github.com/rust-lang/crates.io-index"
4745 checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
4746 dependencies = [
4747 + "chrono",
4748 "dyn-clone",
4749 "ref-cast",
4750 "schemars_derive",
Cargo.toml
+3 -1
@@ -28,7 +28,9 @@ log = "0.4"
28 ratatui = "0.29"
29 libc = "0.2"
30 reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls-native-roots"] }
31 -tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
31 +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "io-util", "sync", "time"] }
32 +rmcp = { version = "2.2.0", features = ["server", "macros", "transport-io", "schemars"] }
33 +schemars = "1"
34 smbcloud-auth-sdk = "0.3.34"
35 smbcloud-model = "0.3.35"
36 smbcloud-network = "0.3.35"
README.md
+10
@@ -105,6 +105,16 @@ This opens the TUI. You can sign up or sign in right there.
105 | `Ctrl+N` | Go to the new account screen |
106 | `Ctrl+C` | Quit |
107
108 +### MCP server
109 +
110 +Run `onde` as a [Model Context Protocol](https://modelcontextprotocol.io/) server over stdio instead of the TUI:
111 +
112 +```sh
113 +onde --mcp
114 +```
115 +
116 +This exposes Onde account and model-catalog operations as MCP tools — `login`, `me`, `apps_list`, `app_create`, `app_rename`, `models_list`, `model_register`, `model_assign`, `hf_search` — returning structured JSON. stdout is the JSON-RPC channel; tools run non-interactively and reuse the token from a TUI sign-in (or the `login` tool). Point any MCP client at the command `onde --mcp`.
117 +
118 ---
119
120 ## Fine-tuning
nuget/onde-cli/README.md
+2
@@ -1,5 +1,7 @@
1 # Onde CLI for .NET
2
3 +<!-- mcp-name: io.github.ondeinference/onde-cli -->
4 +
5 `onde` is the official command-line interface for [Onde Inference](https://ondeinference.com).
6
7 ## Install
src/hf_search.rs
+3 -3
@@ -6,7 +6,7 @@
6
7 use anyhow::{Context, Result};
8 use futures::StreamExt;
9 -use serde::Deserialize;
9 +use serde::{Deserialize, Serialize};
10 use std::path::PathBuf;
11 use tokio::io::AsyncWriteExt;
12
@@ -15,7 +15,7 @@ use tokio::io::AsyncWriteExt;
15 // ---------------------------------------------------------------------------
16
17 /// Compact model info returned by the HF search API.
18 -#[derive(Debug, Clone, Deserialize)]
18 +#[derive(Debug, Clone, Deserialize, Serialize)]
19 pub struct HfModelInfo {
20 #[serde(rename = "id")]
21 pub model_id: String,
@@ -32,7 +32,7 @@ pub struct HfModelInfo {
32 }
33
34 /// A file entry in a HuggingFace model repository.
35 -#[derive(Debug, Clone, Deserialize)]
35 +#[derive(Debug, Clone, Deserialize, Serialize)]
36 pub struct HfSibling {
37 pub rfilename: String,
38 }
src/main.rs
+8
@@ -7,6 +7,7 @@ mod hf;
7 mod hf_clone;
8 mod hf_search;
9 mod hf_upload;
10 +mod mcp;
11 mod merge;
12 mod project;
13 mod token;
@@ -73,6 +74,13 @@ fn redirect_output() -> Option<std::fs::File> {
74
75 #[tokio::main]
76 async fn main() -> anyhow::Result<()> {
77 + // MCP mode runs a JSON-RPC server over stdio instead of the TUI. stdout is
78 + // the JSON-RPC channel, so — unlike the TUI path below — we must NOT redirect
79 + // stdout/stderr, and we return before touching the terminal at all.
80 + if std::env::args().skip(1).any(|arg| arg == "--mcp") {
81 + return mcp::serve().await;
82 + }
83 +
84 let tty = redirect_output();
85
86 // Build a writer that targets the real terminal. When
src/mcp.rs new
+336
@@ -0,0 +1,336 @@
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 +}