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
+}