@sigit / si / commits / 9ee9c1a

Add si CLI MCP server over stdio

Seto Elkahfi committed Sep 3, 2026 at 23:21 UTC 9ee9c1a904576e671dffab1328aaa84193b71e6c
9 files changed +1234 -4
Cargo.lock
+259 -2
@@ -94,6 +94,12 @@ version = "0.22.1"
94 source = "registry+https://github.com/rust-lang/crates.io-index"
95 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
96
97 +[[package]]
98 +name = "base64"
99 +version = "0.23.1"
100 +source = "registry+https://github.com/rust-lang/crates.io-index"
101 +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
102 +
103 [[package]]
104 name = "bitflags"
105 version = "2.13.1"
@@ -251,6 +257,40 @@ dependencies = [
257 "libc",
258 ]
259
260 +[[package]]
261 +name = "darling"
262 +version = "0.24.1"
263 +source = "registry+https://github.com/rust-lang/crates.io-index"
264 +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
265 +dependencies = [
266 + "darling_core",
267 + "darling_macro",
268 +]
269 +
270 +[[package]]
271 +name = "darling_core"
272 +version = "0.24.1"
273 +source = "registry+https://github.com/rust-lang/crates.io-index"
274 +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
275 +dependencies = [
276 + "ident_case",
277 + "proc-macro2",
278 + "quote",
279 + "strsim",
280 + "syn 3.0.3",
281 +]
282 +
283 +[[package]]
284 +name = "darling_macro"
285 +version = "0.24.1"
286 +source = "registry+https://github.com/rust-lang/crates.io-index"
287 +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
288 +dependencies = [
289 + "darling_core",
290 + "quote",
291 + "syn 3.0.3",
292 +]
293 +
294 [[package]]
295 name = "dialoguer"
296 version = "0.11.0"
@@ -296,12 +336,24 @@ dependencies = [
336 "syn 3.0.3",
337 ]
338
339 +[[package]]
340 +name = "dyn-clone"
341 +version = "1.0.20"
342 +source = "registry+https://github.com/rust-lang/crates.io-index"
343 +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
344 +
345 [[package]]
346 name = "encode_unicode"
347 version = "1.0.0"
348 source = "registry+https://github.com/rust-lang/crates.io-index"
349 checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
350
351 +[[package]]
352 +name = "equivalent"
353 +version = "1.0.2"
354 +source = "registry+https://github.com/rust-lang/crates.io-index"
355 +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
356 +
357 [[package]]
358 name = "errno"
359 version = "0.3.14"
@@ -339,6 +391,21 @@ dependencies = [
391 "percent-encoding",
392 ]
393
394 +[[package]]
395 +name = "futures"
396 +version = "0.3.34"
397 +source = "registry+https://github.com/rust-lang/crates.io-index"
398 +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
399 +dependencies = [
400 + "futures-channel",
401 + "futures-core",
402 + "futures-executor",
403 + "futures-io",
404 + "futures-sink",
405 + "futures-task",
406 + "futures-util",
407 +]
408 +
409 [[package]]
410 name = "futures-channel"
411 version = "0.3.34"
@@ -346,6 +413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
413 checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
414 dependencies = [
415 "futures-core",
416 + "futures-sink",
417 ]
418
419 [[package]]
@@ -354,6 +422,40 @@ version = "0.3.34"
422 source = "registry+https://github.com/rust-lang/crates.io-index"
423 checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
424
425 +[[package]]
426 +name = "futures-executor"
427 +version = "0.3.34"
428 +source = "registry+https://github.com/rust-lang/crates.io-index"
429 +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
430 +dependencies = [
431 + "futures-core",
432 + "futures-task",
433 + "futures-util",
434 +]
435 +
436 +[[package]]
437 +name = "futures-io"
438 +version = "0.3.34"
439 +source = "registry+https://github.com/rust-lang/crates.io-index"
440 +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
441 +
442 +[[package]]
443 +name = "futures-macro"
444 +version = "0.3.34"
445 +source = "registry+https://github.com/rust-lang/crates.io-index"
446 +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
447 +dependencies = [
448 + "proc-macro2",
449 + "quote",
450 + "syn 3.0.3",
451 +]
452 +
453 +[[package]]
454 +name = "futures-sink"
455 +version = "0.3.34"
456 +source = "registry+https://github.com/rust-lang/crates.io-index"
457 +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
458 +
459 [[package]]
460 name = "futures-task"
461 version = "0.3.34"
@@ -366,8 +468,13 @@ version = "0.3.34"
468 source = "registry+https://github.com/rust-lang/crates.io-index"
469 checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
470 dependencies = [
471 + "futures-channel",
472 "futures-core",
473 + "futures-io",
474 + "futures-macro",
475 + "futures-sink",
476 "futures-task",
477 + "memchr",
478 "pin-project-lite",
479 "slab",
480 ]
@@ -412,6 +519,12 @@ dependencies = [
519 "url",
520 ]
521
522 +[[package]]
523 +name = "hashbrown"
524 +version = "0.17.1"
525 +source = "registry+https://github.com/rust-lang/crates.io-index"
526 +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
527 +
528 [[package]]
529 name = "heck"
530 version = "0.5.0"
@@ -499,7 +612,7 @@ version = "0.1.20"
612 source = "registry+https://github.com/rust-lang/crates.io-index"
613 checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
614 dependencies = [
502 - "base64",
615 + "base64 0.22.1",
616 "bytes",
617 "futures-channel",
618 "futures-util",
@@ -623,6 +736,12 @@ dependencies = [
736 "zerovec",
737 ]
738
739 +[[package]]
740 +name = "ident_case"
741 +version = "1.0.1"
742 +source = "registry+https://github.com/rust-lang/crates.io-index"
743 +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
744 +
745 [[package]]
746 name = "idna"
747 version = "1.1.0"
@@ -644,6 +763,18 @@ dependencies = [
763 "icu_properties",
764 ]
765
766 +[[package]]
767 +name = "indexmap"
768 +version = "2.14.1"
769 +source = "registry+https://github.com/rust-lang/crates.io-index"
770 +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
771 +dependencies = [
772 + "equivalent",
773 + "hashbrown",
774 + "serde",
775 + "serde_core",
776 +]
777 +
778 [[package]]
779 name = "ipnet"
780 version = "2.12.1"
@@ -860,6 +991,12 @@ dependencies = [
991 "unicode-width",
992 ]
993
994 +[[package]]
995 +name = "pastey"
996 +version = "0.2.3"
997 +source = "registry+https://github.com/rust-lang/crates.io-index"
998 +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
999 +
1000 [[package]]
1001 name = "percent-encoding"
1002 version = "2.3.2"
@@ -1004,6 +1141,26 @@ dependencies = [
1141 "thiserror 2.0.20",
1142 ]
1143
1144 +[[package]]
1145 +name = "ref-cast"
1146 +version = "1.0.27"
1147 +source = "registry+https://github.com/rust-lang/crates.io-index"
1148 +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
1149 +dependencies = [
1150 + "ref-cast-impl",
1151 +]
1152 +
1153 +[[package]]
1154 +name = "ref-cast-impl"
1155 +version = "1.0.27"
1156 +source = "registry+https://github.com/rust-lang/crates.io-index"
1157 +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
1158 +dependencies = [
1159 + "proc-macro2",
1160 + "quote",
1161 + "syn 3.0.3",
1162 +]
1163 +
1164 [[package]]
1165 name = "regex-automata"
1166 version = "0.4.18"
@@ -1027,7 +1184,7 @@ version = "0.12.28"
1184 source = "registry+https://github.com/rust-lang/crates.io-index"
1185 checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
1186 dependencies = [
1030 - "base64",
1187 + "base64 0.22.1",
1188 "bytes",
1189 "futures-core",
1190 "http",
@@ -1073,6 +1230,42 @@ dependencies = [
1230 "windows-sys 0.52.0",
1231 ]
1232
1233 +[[package]]
1234 +name = "rmcp"
1235 +version = "3.2.0"
1236 +source = "registry+https://github.com/rust-lang/crates.io-index"
1237 +checksum = "42b6914fac0be956fe704a38239c3f44a9f841d1b06a5713d2f638065593f5b5"
1238 +dependencies = [
1239 + "base64 0.23.1",
1240 + "chrono",
1241 + "futures",
1242 + "indexmap",
1243 + "pastey",
1244 + "pin-project-lite",
1245 + "rmcp-macros",
1246 + "schemars",
1247 + "serde",
1248 + "serde_json",
1249 + "thiserror 2.0.20",
1250 + "tokio",
1251 + "tokio-util",
1252 + "tracing",
1253 + "uuid",
1254 +]
1255 +
1256 +[[package]]
1257 +name = "rmcp-macros"
1258 +version = "3.2.0"
1259 +source = "registry+https://github.com/rust-lang/crates.io-index"
1260 +checksum = "cdf1c49bd4d52014b94db0877410db273c2008f01628b0252a2e9460ad9b7fda"
1261 +dependencies = [
1262 + "darling",
1263 + "proc-macro2",
1264 + "quote",
1265 + "serde_json",
1266 + "syn 3.0.3",
1267 +]
1268 +
1269 [[package]]
1270 name = "rustc-hash"
1271 version = "2.1.3"
@@ -1160,6 +1353,32 @@ dependencies = [
1353 "windows-sys 0.61.2",
1354 ]
1355
1356 +[[package]]
1357 +name = "schemars"
1358 +version = "1.2.2"
1359 +source = "registry+https://github.com/rust-lang/crates.io-index"
1360 +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
1361 +dependencies = [
1362 + "chrono",
1363 + "dyn-clone",
1364 + "ref-cast",
1365 + "schemars_derive",
1366 + "serde",
1367 + "serde_json",
1368 +]
1369 +
1370 +[[package]]
1371 +name = "schemars_derive"
1372 +version = "1.2.2"
1373 +source = "registry+https://github.com/rust-lang/crates.io-index"
1374 +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
1375 +dependencies = [
1376 + "proc-macro2",
1377 + "quote",
1378 + "serde_derive_internals",
1379 + "syn 3.0.3",
1380 +]
1381 +
1382 [[package]]
1383 name = "security-framework"
1384 version = "3.7.0"
@@ -1213,6 +1432,17 @@ dependencies = [
1432 "syn 3.0.3",
1433 ]
1434
1435 +[[package]]
1436 +name = "serde_derive_internals"
1437 +version = "0.30.0"
1438 +source = "registry+https://github.com/rust-lang/crates.io-index"
1439 +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
1440 +dependencies = [
1441 + "proc-macro2",
1442 + "quote",
1443 + "syn 3.0.3",
1444 +]
1445 +
1446 [[package]]
1447 name = "serde_json"
1448 version = "1.0.151"
@@ -1284,6 +1514,8 @@ dependencies = [
1514 "git2",
1515 "open",
1516 "reqwest",
1517 + "rmcp",
1518 + "schemars",
1519 "serde",
1520 "serde_json",
1521 "sigit-si-api",
@@ -1518,6 +1750,20 @@ dependencies = [
1750 "tokio",
1751 ]
1752
1753 +[[package]]
1754 +name = "tokio-util"
1755 +version = "0.7.19"
1756 +source = "registry+https://github.com/rust-lang/crates.io-index"
1757 +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
1758 +dependencies = [
1759 + "bytes",
1760 + "futures-core",
1761 + "futures-sink",
1762 + "libc",
1763 + "pin-project-lite",
1764 + "tokio",
1765 +]
1766 +
1767 [[package]]
1768 name = "tower"
1769 version = "0.5.3"
@@ -1672,6 +1918,17 @@ version = "0.2.2"
1918 source = "registry+https://github.com/rust-lang/crates.io-index"
1919 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
1920
1921 +[[package]]
1922 +name = "uuid"
1923 +version = "1.26.0"
1924 +source = "registry+https://github.com/rust-lang/crates.io-index"
1925 +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
1926 +dependencies = [
1927 + "getrandom 0.4.3",
1928 + "js-sys",
1929 + "wasm-bindgen",
1930 +]
1931 +
1932 [[package]]
1933 name = "valuable"
1934 version = "0.1.1"
Cargo.toml
+9 -1
@@ -27,12 +27,20 @@ reqwest = { version = "0.12", default-features = false, features = [
27 "json",
28 "rustls-tls-native-roots",
29 ] }
30 +rmcp = { version = "3.2", features = [
31 + "server",
32 + "macros",
33 + "transport-io",
34 + "schemars",
35 +] }
36 +schemars = "1"
37 serde = { version = "1", features = ["derive"] }
38 serde_json = "1"
39 sigit-si-api = { version = "1.0.0", path = "crates/sigit-si-api" }
40 tabled = { version = "0.20", default-features = false, features = ["std"] }
41 thiserror = "2.0"
35 -tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
42 +# `io-std` is what the MCP stdio transport binds to.
43 +tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] }
44 tracing = "0.1"
45 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
46
README.md
+30
@@ -40,6 +40,36 @@ si api repos # raw escape hatch for any endpoint
40
41 Pass `--json` to any read command for machine-readable output.
42
43 +### As an MCP server
44 +
45 +`si mcp` serves these same commands to an AI agent as
46 +[MCP](https://modelcontextprotocol.io) tools over stdio:
47 +
48 +```jsonc
49 +{
50 + "mcpServers": {
51 + "sigit": { "command": "si", "args": ["mcp"] }
52 + }
53 +}
54 +```
55 +
56 +There is nothing to authenticate — the server reuses the token `si auth login`
57 +stored. Point your editor at the directory of a checkout and repository tools
58 +default to the repo you're standing in, so the agent can say "list the open
59 +pull requests" without naming one; `create_pull_request` likewise defaults its
60 +head to the checked-out branch.
61 +
62 +Trim the tool list when an agent doesn't need all of it:
63 +
64 +```sh
65 +si mcp --read-only # drop every tool that writes
66 +si mcp --toolsets repo,issue,pr,code # or SIGIT_TOOLSETS=repo,issue
67 +```
68 +
69 +Toolsets are `account`, `repo`, `issue`, `pr`, `code`, `hook`, `session`, and
70 +`billing`. sigit.si also hosts a remote MCP server at `/api/v1/mcp` for clients
71 +that would rather connect over HTTP than spawn a binary.
72 +
73 ### Environments
74
75 ```sh
crates/cli/Cargo.toml
+2
@@ -25,6 +25,8 @@ dirs = { workspace = true }
25 git2 = { workspace = true }
26 open = { workspace = true }
27 reqwest = { workspace = true }
28 +rmcp = { workspace = true }
29 +schemars = { workspace = true }
30 serde = { workspace = true }
31 serde_json = { workspace = true }
32 sigit-si-api = { workspace = true }
crates/cli/src/cli.rs
+15
@@ -60,6 +60,9 @@ pub enum Command {
60 /// Call the sigit.si API directly. The escape hatch for anything without
61 /// a dedicated command.
62 Api(ApiArgs),
63 +
64 + /// Serve these commands to an AI agent as MCP tools over stdio.
65 + Mcp(McpArgs),
66 }
67
68 #[derive(clap::Subcommand)]
@@ -256,6 +259,18 @@ pub struct BrowseArgs {
259 pub target: Option<String>,
260 }
261
262 +#[derive(clap::Args)]
263 +pub struct McpArgs {
264 + /// Drop every tool that writes, leaving only the ones that read.
265 + #[arg(long)]
266 + pub read_only: bool,
267 +
268 + /// Expose only these tool groups, comma-separated. Defaults to all of
269 + /// them: account, repo, issue, pr, code, hook, session, billing.
270 + #[arg(long, value_delimiter = ',', env = "SIGIT_TOOLSETS")]
271 + pub toolsets: Vec<String>,
272 +}
273 +
274 #[derive(clap::Args)]
275 pub struct ApiArgs {
276 /// Path under /api/v1, e.g. "repos" or "billing".
crates/cli/src/main.rs
+4
@@ -3,6 +3,7 @@ mod commands;
3 mod config;
4 mod context;
5 mod git;
6 +mod mcp;
7 mod ui;
8
9 use {
@@ -49,5 +50,8 @@ async fn run() -> Result<()> {
50 Command::Billing(command) => commands::billing::run(&ctx, command).await,
51 Command::Browse(args) => commands::browse::run(&ctx, args).await,
52 Command::Api(args) => commands::api::run(&ctx, args).await,
53 + // Takes `ctx` by value: the server outlives the dispatch, serving
54 + // tools over stdio until the client hangs up.
55 + Command::Mcp(args) => mcp::run(ctx, args).await,
56 }
57 }
crates/cli/src/mcp.rs new
+914
@@ -0,0 +1,914 @@
1 +//! `si` as an MCP server over stdio.
2 +//!
3 +//! `si mcp` stops being a one-shot command and becomes a long-lived
4 +//! [Model Context Protocol](https://modelcontextprotocol.io) server, so an
5 +//! agent can do what the CLI does: list repos, read and search code, triage
6 +//! issues, open pull requests, drive Cloud Sessions.
7 +//!
8 +//! sigit.si already serves a remote MCP endpoint at `/api/v1/mcp` (it's what
9 +//! [`sigit_si_api::mcp`] calls into), so a local server only earns its keep by
10 +//! doing what a remote one can't:
11 +//!
12 +//! - **No auth to configure.** It reuses the token `si auth login` stored, so
13 +//! an editor points at a binary and is done — no OAuth dance, no PAT pasted
14 +//! into a config file.
15 +//! - **It knows where you're standing.** Every repo-scoped tool takes `repo`
16 +//! as an *optional* argument and falls back to the sigit.si remote of the
17 +//! working directory, the same way the CLI does. `create_pull_request`
18 +//! likewise defaults `head` to the checked-out branch.
19 +//! - **It reaches the whole CLI.** Cloud Sessions, webhooks, billing, and repo
20 +//! creation are plain REST that the remote MCP endpoint doesn't expose.
21 +//!
22 +//! Tools call the same [`sigit_si_api`] methods the command handlers do,
23 +//! skipping the `ui` layer entirely: stdout is the JSON-RPC channel, so
24 +//! nothing here may print to it. Logging already goes to stderr (see `main`).
25 +//!
26 +//! Toolsets and `--read-only` follow the convention GitHub's `github-mcp-server`
27 +//! set — trimming the tool list keeps the model's choice sharp and its context
28 +//! small.
29 +
30 +use {
31 + crate::{
32 + cli::McpArgs,
33 + context::Ctx,
34 + git::{self, RepoSlug},
35 + },
36 + anyhow::{anyhow, Result},
37 + rmcp::{
38 + handler::server::{router::tool::ToolRouter, wrapper::Parameters, ServerHandler},
39 + model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo},
40 + tool, tool_handler, tool_router,
41 + transport::stdio,
42 + ErrorData, ServiceExt,
43 + },
44 + schemars::JsonSchema,
45 + serde::Deserialize,
46 + sigit_si_api::{Client, NewRepository, State},
47 +};
48 +
49 +/// The tool groups `--toolsets` selects from.
50 +const TOOLSETS: [&str; 8] = [
51 + "account", "repo", "issue", "pr", "code", "hook", "session", "billing",
52 +];
53 +
54 +/// The sigit.si MCP server. Holds the same [`Ctx`] the command handlers get,
55 +/// which is where the API client and working-directory repo resolution live.
56 +pub struct SiMcpServer {
57 + ctx: Ctx,
58 + tool_router: ToolRouter<Self>,
59 +}
60 +
61 +impl SiMcpServer {
62 + fn new(ctx: Ctx, tool_router: ToolRouter<Self>) -> Self {
63 + Self { ctx, tool_router }
64 + }
65 +
66 + /// The API client, refusing early when there's no token rather than
67 + /// letting every tool round-trip to a 401.
68 + fn client(&self) -> Result<&Client, ErrorData> {
69 + self.ctx
70 + .require_token()
71 + .map(|()| &self.ctx.client)
72 + .map_err(|e| ErrorData::invalid_request(e.to_string(), None))
73 + }
74 +
75 + /// Resolve a tool's optional `repo` argument, falling back to the repo the
76 + /// server was started in.
77 + fn repo(&self, explicit: Option<String>) -> Result<String, ErrorData> {
78 + self.ctx
79 + .resolve_repo(explicit)
80 + .map(|slug| slug.to_string())
81 + .map_err(|e| ErrorData::invalid_request(e.to_string(), None))
82 + }
83 +}
84 +
85 +/// Serialize a value into a one-block successful tool result.
86 +fn json_result<T: serde::Serialize>(value: &T) -> Result<CallToolResult, ErrorData> {
87 + Ok(CallToolResult::success(vec![ContentBlock::json(value)?]))
88 +}
89 +
90 +fn text_result(message: impl Into<String>) -> Result<CallToolResult, ErrorData> {
91 + Ok(CallToolResult::success(vec![ContentBlock::text(
92 + message.into(),
93 + )]))
94 +}
95 +
96 +fn to_error_data(error: impl std::fmt::Display) -> ErrorData {
97 + ErrorData::internal_error(error.to_string(), None)
98 +}
99 +
100 +/// Which issues or pull requests to list. An enum rather than a string so the
101 +/// advertised schema tells the model what the choices are.
102 +#[derive(Debug, Default, Clone, Copy, Deserialize, JsonSchema)]
103 +#[serde(rename_all = "lowercase")]
104 +enum StateArg {
105 + #[default]
106 + Open,
107 + Closed,
108 + Merged,
109 + All,
110 +}
111 +
112 +impl From<StateArg> for State {
113 + fn from(state: StateArg) -> Self {
114 + match state {
115 + StateArg::Open => State::Open,
116 + StateArg::Closed => State::Closed,
117 + StateArg::Merged => State::Merged,
118 + StateArg::All => State::All,
119 + }
120 + }
121 +}
122 +
123 +/// The `repo` argument every repository-scoped tool shares.
124 +#[derive(Debug, Deserialize, JsonSchema)]
125 +struct RepoArgs {
126 + /// Repository as OWNER/NAME. Defaults to the repository the server is
127 + /// running in.
128 + #[serde(default)]
129 + repo: Option<String>,
130 +}
131 +
132 +// ---------------------------------------------------------------- account --
133 +
134 +#[tool_router(router = account_router)]
135 +impl SiMcpServer {
136 + /// Show the signed-in sigit.si user: name, email, and plan.
137 + #[tool(annotations(read_only_hint = true))]
138 + async fn whoami(&self) -> Result<CallToolResult, ErrorData> {
139 + let user = self.client()?.user().await.map_err(to_error_data)?;
140 + json_result(&user)
141 + }
142 +}
143 +
144 +// ------------------------------------------------------------------- repo --
145 +
146 +#[derive(Debug, Deserialize, JsonSchema)]
147 +struct ListRepositoriesArgs {
148 + /// Case-insensitive substring of the owner or repository name. Omit to
149 + /// list everything visible.
150 + #[serde(default)]
151 + query: Option<String>,
152 + /// How many repositories to return. Defaults to the server's own limit.
153 + #[serde(default)]
154 + limit: Option<u32>,
155 +}
156 +
157 +#[derive(Debug, Deserialize, JsonSchema)]
158 +struct CreateRepositoryArgs {
159 + /// Name for the new repository.
160 + name: String,
161 + /// Whether the repository is private. Defaults to public.
162 + #[serde(default)]
163 + private: bool,
164 + /// Optional one-line description.
165 + #[serde(default)]
166 + description: Option<String>,
167 +}
168 +
169 +#[tool_router(router = repo_router)]
170 +impl SiMcpServer {
171 + /// Search every repository the user can see by an owner/name substring.
172 + /// Use this to find the OWNER/NAME other tools take.
173 + #[tool(annotations(read_only_hint = true))]
174 + async fn list_repositories(
175 + &self,
176 + Parameters(ListRepositoriesArgs { query, limit }): Parameters<ListRepositoriesArgs>,
177 + ) -> Result<CallToolResult, ErrorData> {
178 + let repos = self
179 + .client()?
180 + .search_repos(query.as_deref(), limit)
181 + .await
182 + .map_err(to_error_data)?;
183 + json_result(&repos)
184 + }
185 +
186 + /// List the repositories the signed-in user owns, newest first.
187 + #[tool(annotations(read_only_hint = true))]
188 + async fn list_my_repositories(&self) -> Result<CallToolResult, ErrorData> {
189 + let repos = self.client()?.repos().await.map_err(to_error_data)?;
190 + json_result(&repos)
191 + }
192 +
193 + /// Identify the sigit.si repository and branch the server is running in.
194 + /// Call this to find out what "the current repo" resolves to.
195 + #[tool(annotations(read_only_hint = true))]
196 + async fn current_repository(&self) -> Result<CallToolResult, ErrorData> {
197 + let cwd = std::env::current_dir().map_err(to_error_data)?;
198 + let slug: RepoSlug = self
199 + .ctx
200 + .resolve_repo(None)
201 + .map_err(|e| ErrorData::invalid_request(e.to_string(), None))?;
202 +
203 + json_result(&serde_json::json!({
204 + "repo": slug.to_string(),
205 + "owner": slug.owner,
206 + "name": slug.name,
207 + "branch": git::current_branch(&cwd),
208 + "directory": cwd.display().to_string(),
209 + }))
210 + }
211 +
212 + /// Create a new, empty repository to push to.
213 + #[tool]
214 + async fn create_repository(
215 + &self,
216 + Parameters(CreateRepositoryArgs {
217 + name,
218 + private,
219 + description,
220 + }): Parameters<CreateRepositoryArgs>,
221 + ) -> Result<CallToolResult, ErrorData> {
222 + let new_repo = NewRepository::new(name)
223 + .private(private)
224 + .description(description);
225 + let repo = self
226 + .client()?
227 + .create_repo(&new_repo)
228 + .await
229 + .map_err(to_error_data)?;
230 + json_result(&repo)
231 + }
232 +}
233 +
234 +// ------------------------------------------------------------------ issue --
235 +
236 +#[derive(Debug, Deserialize, JsonSchema)]
237 +struct ListIssuesArgs {
238 + /// Repository as OWNER/NAME. Defaults to the repository the server is
239 + /// running in.
240 + #[serde(default)]
241 + repo: Option<String>,
242 + /// Which issues to list. Defaults to open.
243 + #[serde(default)]
244 + state: StateArg,
245 + /// Optional substring to match against issue titles and bodies.
246 + #[serde(default)]
247 + query: Option<String>,
248 +}
249 +
250 +/// Shared by the issue and pull request tools, which draw on one numbering.
251 +#[derive(Debug, Deserialize, JsonSchema)]
252 +struct NumberArgs {
253 + /// The issue or pull request number.
254 + number: i64,
255 + /// Repository as OWNER/NAME. Defaults to the repository the server is
256 + /// running in.
257 + #[serde(default)]
258 + repo: Option<String>,
259 +}
260 +
261 +#[derive(Debug, Deserialize, JsonSchema)]
262 +struct CreateIssueArgs {
263 + /// Issue title.
264 + title: String,
265 + /// Issue body, in Markdown.
266 + #[serde(default)]
267 + body: Option<String>,
268 + /// Repository as OWNER/NAME. Defaults to the repository the server is
269 + /// running in.
270 + #[serde(default)]
271 + repo: Option<String>,
272 +}
273 +
274 +#[derive(Debug, Deserialize, JsonSchema)]
275 +struct AddCommentArgs {
276 + /// The issue or pull request number to comment on.
277 + number: i64,
278 + /// Comment body, in Markdown.
279 + body: String,
280 + /// Repository as OWNER/NAME. Defaults to the repository the server is
281 + /// running in.
282 + #[serde(default)]
283 + repo: Option<String>,
284 +}
285 +
286 +#[tool_router(router = issue_router)]
287 +impl SiMcpServer {
288 + /// List a repository's issues, optionally filtered by state and a
289 + /// title/body substring.
290 + #[tool(annotations(read_only_hint = true))]
291 + async fn list_issues(
292 + &self,
293 + Parameters(ListIssuesArgs { repo, state, query }): Parameters<ListIssuesArgs>,
294 + ) -> Result<CallToolResult, ErrorData> {
295 + let repo = self.repo(repo)?;
296 + let issues = self
297 + .client()?
298 + .issues(&repo, state.into(), query.as_deref())
299 + .await
300 + .map_err(to_error_data)?;
301 + json_result(&issues)
302 + }
303 +
304 + /// Fetch one issue by number, including its body and comments.
305 + #[tool(annotations(read_only_hint = true))]
306 + async fn get_issue(
307 + &self,
308 + Parameters(NumberArgs { number, repo }): Parameters<NumberArgs>,
309 + ) -> Result<CallToolResult, ErrorData> {
310 + let repo = self.repo(repo)?;
311 + let issue = self
312 + .client()?
313 + .issue(&repo, number)
314 + .await
315 + .map_err(to_error_data)?;
316 + json_result(&issue)
317 + }
318 +
319 + /// Open a new issue.
320 + #[tool]
321 + async fn create_issue(
322 + &self,
323 + Parameters(CreateIssueArgs { title, body, repo }): Parameters<CreateIssueArgs>,
324 + ) -> Result<CallToolResult, ErrorData> {
325 + let repo = self.repo(repo)?;
326 + let created = self
327 + .client()?
328 + .create_issue(&repo, &title, body.as_deref())
329 + .await
330 + .map_err(to_error_data)?;
331 + json_result(&created)
332 + }
333 +
334 + /// Comment on an issue or a pull request — they share one numbering.
335 + #[tool]
336 + async fn add_issue_comment(
337 + &self,
338 + Parameters(AddCommentArgs { number, body, repo }): Parameters<AddCommentArgs>,
339 + ) -> Result<CallToolResult, ErrorData> {
340 + let repo = self.repo(repo)?;
341 + let comment = self
342 + .client()?
343 + .add_comment(&repo, number, &body)
344 + .await
345 + .map_err(to_error_data)?;
346 + json_result(&comment)
347 + }
348 +}
349 +
350 +// --------------------------------------------------------------------- pr --
351 +
352 +#[derive(Debug, Deserialize, JsonSchema)]
353 +struct ListPullsArgs {
354 + /// Repository as OWNER/NAME. Defaults to the repository the server is
355 + /// running in.
356 + #[serde(default)]
357 + repo: Option<String>,
358 + /// Which pull requests to list. Defaults to open.
359 + #[serde(default)]
360 + state: StateArg,
361 +}
362 +
363 +#[derive(Debug, Deserialize, JsonSchema)]
364 +struct CreatePullArgs {
365 + /// Pull request title.
366 + title: String,
367 + /// Branch to merge from. Defaults to the branch checked out where the
368 + /// server is running.
369 + #[serde(default)]
370 + head: Option<String>,
371 + /// Branch to merge into. Defaults to main.
372 + #[serde(default)]
373 + base: Option<String>,
374 + /// Pull request body, in Markdown.
375 + #[serde(default)]
376 + body: Option<String>,
377 + /// Repository as OWNER/NAME. Defaults to the repository the server is
378 + /// running in.
379 + #[serde(default)]
380 + repo: Option<String>,
381 +}
382 +
383 +#[tool_router(router = pr_router)]
384 +impl SiMcpServer {
385 + /// List a repository's pull requests, optionally filtered by state.
386 + #[tool(annotations(read_only_hint = true))]
387 + async fn list_pull_requests(
388 + &self,
389 + Parameters(ListPullsArgs { repo, state }): Parameters<ListPullsArgs>,
390 + ) -> Result<CallToolResult, ErrorData> {
391 + let repo = self.repo(repo)?;
392 + let pulls = self
393 + .client()?
394 + .pulls(&repo, state.into())
395 + .await
396 + .map_err(to_error_data)?;
397 + json_result(&pulls)
398 + }
399 +
400 + /// Fetch one pull request with its comments and unified diff. Diffs past
401 + /// 100 kB come back truncated and flagged.
402 + #[tool(annotations(read_only_hint = true))]
403 + async fn get_pull_request(
404 + &self,
405 + Parameters(NumberArgs { number, repo }): Parameters<NumberArgs>,
406 + ) -> Result<CallToolResult, ErrorData> {
407 + let repo = self.repo(repo)?;
408 + let pull = self
409 + .client()?
410 + .pull(&repo, number)
411 + .await
412 + .map_err(to_error_data)?;
413 + json_result(&pull)
414 + }
415 +
416 + /// Open a pull request from one branch into another.
417 + #[tool]
418 + async fn create_pull_request(
419 + &self,
420 + Parameters(CreatePullArgs {
421 + title,
422 + head,
423 + base,
424 + body,
425 + repo,
426 + }): Parameters<CreatePullArgs>,
427 + ) -> Result<CallToolResult, ErrorData> {
428 + let repo = self.repo(repo)?;
429 + let head = match head {
430 + Some(head) => head,
431 + None => {
432 + let cwd = std::env::current_dir().map_err(to_error_data)?;
433 + git::current_branch(&cwd).ok_or_else(|| {
434 + ErrorData::invalid_request(
435 + "Could not determine the current branch. Pass `head`.",
436 + None,
437 + )
438 + })?
439 + }
440 + };
441 + let base = base.unwrap_or_else(|| "main".to_string());
442 +
443 + let created = self
444 + .client()?
445 + .create_pull(&repo, &title, &head, &base, body.as_deref())
446 + .await
447 + .map_err(to_error_data)?;
448 + json_result(&created)
449 + }
450 +}
451 +
452 +// ------------------------------------------------------------------- code --
453 +
454 +#[derive(Debug, Deserialize, JsonSchema)]
455 +struct FileContentsArgs {
456 + /// Path to the file, relative to the repository root.
457 + path: String,
458 + /// Branch, tag, or commit SHA. Defaults to the repository's default branch.
459 + #[serde(default, rename = "ref")]
460 + git_ref: Option<String>,
461 + /// Repository as OWNER/NAME. Defaults to the repository the server is
462 + /// running in.
463 + #[serde(default)]
464 + repo: Option<String>,
465 +}
466 +
467 +#[derive(Debug, Deserialize, JsonSchema)]
468 +struct SearchCodeArgs {
469 + /// Fixed string to search for. Case-insensitive, not a regex.
470 + query: String,
471 + /// Branch, tag, or commit SHA. Defaults to the repository's default branch.
472 + #[serde(default, rename = "ref")]
473 + git_ref: Option<String>,
474 + /// How many matches to return.
475 + #[serde(default)]
476 + limit: Option<u32>,
477 + /// Repository as OWNER/NAME. Defaults to the repository the server is
478 + /// running in.
479 + #[serde(default)]
480 + repo: Option<String>,
481 +}
482 +
483 +#[tool_router(router = code_router)]
484 +impl SiMcpServer {
485 + /// Read a file's contents at a ref.
486 + #[tool(annotations(read_only_hint = true))]
487 + async fn get_file_contents(
488 + &self,
489 + Parameters(FileContentsArgs {
490 + path,
491 + git_ref,
492 + repo,
493 + }): Parameters<FileContentsArgs>,
494 + ) -> Result<CallToolResult, ErrorData> {
495 + let repo = self.repo(repo)?;
496 + let file = self
497 + .client()?
498 + .file_contents(&repo, &path, git_ref.as_deref())
499 + .await
500 + .map_err(to_error_data)?;
501 + json_result(&file)
502 + }
503 +
504 + /// Search a repository's tracked files for a fixed string, returning
505 + /// matching files with line snippets.
506 + #[tool(annotations(read_only_hint = true))]
507 + async fn search_code(
508 + &self,
509 + Parameters(SearchCodeArgs {
510 + query,
511 + git_ref,
512 + limit,
513 + repo,
514 + }): Parameters<SearchCodeArgs>,
515 + ) -> Result<CallToolResult, ErrorData> {
516 + let repo = self.repo(repo)?;
517 + let hits = self
518 + .client()?
519 + .search_code(&repo, &query, git_ref.as_deref(), limit)
520 + .await
521 + .map_err(to_error_data)?;
522 + json_result(&hits)
523 + }
524 +}
525 +
526 +// ------------------------------------------------------------------- hook --
527 +
528 +#[derive(Debug, Deserialize, JsonSchema)]
529 +struct CreateHookArgs {
530 + /// URL sigit.si should POST to.
531 + url: String,
532 + /// Events to subscribe to. Defaults to push.
533 + #[serde(default)]
534 + events: Option<Vec<String>>,
535 + /// Repository as OWNER/NAME. Defaults to the repository the server is
536 + /// running in.
537 + #[serde(default)]
538 + repo: Option<String>,
539 +}
540 +
541 +#[derive(Debug, Deserialize, JsonSchema)]
542 +struct DeleteHookArgs {
543 + /// The webhook's id.
544 + id: i64,
545 + /// Repository as OWNER/NAME. Defaults to the repository the server is
546 + /// running in.
547 + #[serde(default)]
548 + repo: Option<String>,
549 +}
550 +
551 +#[tool_router(router = hook_router)]
552 +impl SiMcpServer {
553 + /// List a repository's deploy webhooks.
554 + #[tool(annotations(read_only_hint = true))]
555 + async fn list_hooks(
556 + &self,
557 + Parameters(RepoArgs { repo }): Parameters<RepoArgs>,
558 + ) -> Result<CallToolResult, ErrorData> {
559 + let slug = self.slug(repo)?;
560 + let hooks = self
561 + .client()?
562 + .hooks(&slug.owner, &slug.name)
563 + .await
564 + .map_err(to_error_data)?;
565 + json_result(&hooks)
566 + }
567 +
568 + /// Register a webhook. The response carries the signing secret, which is
569 + /// shown once and cannot be retrieved again.
570 + #[tool]
571 + async fn create_hook(
572 + &self,
573 + Parameters(CreateHookArgs { url, events, repo }): Parameters<CreateHookArgs>,
574 + ) -> Result<CallToolResult, ErrorData> {
575 + let slug = self.slug(repo)?;
576 + let events = events.unwrap_or_else(|| vec!["push".to_string()]);
577 + let hook = self
578 + .client()?
579 + .create_hook(&slug.owner, &slug.name, &url, &events)
580 + .await
581 + .map_err(to_error_data)?;
582 + json_result(&hook)
583 + }
584 +
585 + /// Remove a webhook. This cannot be undone.
586 + #[tool(annotations(destructive_hint = true))]
587 + async fn delete_hook(
588 + &self,
589 + Parameters(DeleteHookArgs { id, repo }): Parameters<DeleteHookArgs>,
590 + ) -> Result<CallToolResult, ErrorData> {
591 + let slug = self.slug(repo)?;
592 + self.client()?
593 + .delete_hook(&slug.owner, &slug.name, id)
594 + .await
595 + .map_err(to_error_data)?;
596 + text_result(format!("Deleted webhook {id} from {slug}."))
597 + }
598 +}
599 +
600 +// ---------------------------------------------------------------- session --
601 +
602 +#[derive(Debug, Deserialize, JsonSchema)]
603 +struct SessionIdArgs {
604 + /// The Cloud Session's id.
605 + id: i64,
606 +}
607 +
608 +#[derive(Debug, Deserialize, JsonSchema)]
609 +struct CreateSessionArgs {
610 + /// Title for the session.
611 + #[serde(default)]
612 + title: Option<String>,
613 + /// Model the session runs on. Defaults to the account's default.
614 + #[serde(default)]
615 + model: Option<String>,
616 +}
617 +
618 +#[derive(Debug, Deserialize, JsonSchema)]
619 +struct UpdateSessionArgs {
620 + /// The Cloud Session's id.
621 + id: i64,
622 + /// New title. Omit to leave unchanged.
623 + #[serde(default)]
624 + title: Option<String>,
625 + /// New model. Omit to leave unchanged.
626 + #[serde(default)]
627 + model: Option<String>,
628 +}
629 +
630 +#[derive(Debug, Deserialize, JsonSchema)]
631 +struct AppendMessageArgs {
632 + /// The Cloud Session's id.
633 + id: i64,
634 + /// Who the message is from: user or assistant.
635 + role: String,
636 + /// The message text.
637 + content: String,
638 +}
639 +
640 +#[tool_router(router = session_router)]
641 +impl SiMcpServer {
642 + /// List siGit Code Cloud Sessions, most recently updated first.
643 + #[tool(annotations(read_only_hint = true))]
644 + async fn list_sessions(&self) -> Result<CallToolResult, ErrorData> {
645 + let sessions = self.client()?.sessions().await.map_err(to_error_data)?;
646 + json_result(&sessions)
647 + }
648 +
649 + /// Fetch one Cloud Session with its full transcript.
650 + #[tool(annotations(read_only_hint = true))]
651 + async fn get_session(
652 + &self,
653 + Parameters(SessionIdArgs { id }): Parameters<SessionIdArgs>,
654 + ) -> Result<CallToolResult, ErrorData> {
655 + let session = self.client()?.session(id).await.map_err(to_error_data)?;
656 + json_result(&session)
657 + }
658 +
659 + /// Start a new Cloud Session.
660 + #[tool]
661 + async fn create_session(
662 + &self,
663 + Parameters(CreateSessionArgs { title, model }): Parameters<CreateSessionArgs>,
664 + ) -> Result<CallToolResult, ErrorData> {
665 + let session = self
666 + .client()?
667 + .create_session(title.as_deref(), model.as_deref())
668 + .await
669 + .map_err(to_error_data)?;
670 + json_result(&session)
671 + }
672 +
673 + /// Rename a Cloud Session or change the model it runs on.
674 + #[tool]
675 + async fn update_session(
676 + &self,
677 + Parameters(UpdateSessionArgs { id, title, model }): Parameters<UpdateSessionArgs>,
678 + ) -> Result<CallToolResult, ErrorData> {
679 + let session = self
680 + .client()?
681 + .update_session(id, title.as_deref(), model.as_deref())
682 + .await
683 + .map_err(to_error_data)?;
684 + json_result(&session)
685 + }
686 +
687 + /// Append one message to a Cloud Session's transcript.
688 + #[tool]
689 + async fn append_session_message(
690 + &self,
691 + Parameters(AppendMessageArgs { id, role, content }): Parameters<AppendMessageArgs>,
692 + ) -> Result<CallToolResult, ErrorData> {
693 + let appended = self
694 + .client()?
695 + .append_message(id, &role, &content)
696 + .await
697 + .map_err(to_error_data)?;
698 + json_result(&appended)
699 + }
700 +
701 + /// Delete a Cloud Session and its transcript. This cannot be undone.
702 + #[tool(annotations(destructive_hint = true))]
703 + async fn delete_session(
704 + &self,
705 + Parameters(SessionIdArgs { id }): Parameters<SessionIdArgs>,
706 + ) -> Result<CallToolResult, ErrorData> {
707 + self.client()?
708 + .delete_session(id)
709 + .await
710 + .map_err(to_error_data)?;
711 + text_result(format!("Deleted session {id}."))
712 + }
713 +}
714 +
715 +// ---------------------------------------------------------------- billing --
716 +
717 +#[tool_router(router = billing_router)]
718 +impl SiMcpServer {
719 + /// Show the account's current plan and cloud usage.
720 + #[tool(annotations(read_only_hint = true))]
721 + async fn get_billing(&self) -> Result<CallToolResult, ErrorData> {
722 + let billing = self.client()?.billing().await.map_err(to_error_data)?;
723 + json_result(&billing)
724 + }
725 +}
726 +
727 +#[tool_handler(router = self.tool_router)]
728 +impl ServerHandler for SiMcpServer {
729 + fn get_info(&self) -> ServerInfo {
730 + // `Implementation` is `#[non_exhaustive]`, so start from the build-env
731 + // default and override the identity fields to report `si`, not `rmcp`.
732 + let mut server_info = Implementation::from_build_env();
733 + server_info.name = "si".to_string();
734 + server_info.version = env!("CARGO_PKG_VERSION").to_string();
735 +
736 + ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
737 + .with_server_info(server_info)
738 + .with_instructions(
739 + "The sigit.si CLI exposed as MCP tools. Authentication uses the token stored \
740 + by `si auth login`; tools run non-interactively. Every repository tool takes \
741 + `repo` as OWNER/NAME, and omitting it falls back to the sigit.si repository \
742 + the server was started in — call `current_repository` to see what that \
743 + resolves to. Use `list_repositories` to find the OWNER/NAME of anything else.",
744 + )
745 + }
746 +}
747 +
748 +impl SiMcpServer {
749 + /// [`Self::repo`], parsed back into its owner and name halves for the REST
750 + /// endpoints that take them separately.
751 + fn slug(&self, explicit: Option<String>) -> Result<RepoSlug, ErrorData> {
752 + self.ctx
753 + .resolve_repo(explicit)
754 + .map_err(|e| ErrorData::invalid_request(e.to_string(), None))
755 + }
756 +}
757 +
758 +/// Assemble the router from the requested toolsets, then drop the writers if
759 +/// `--read-only` was passed.
760 +///
761 +/// An unknown toolset name is an error rather than a silent no-op: a typo in an
762 +/// editor's config would otherwise show up as a server that mysteriously
763 +/// offers fewer tools than expected.
764 +fn build_router(toolsets: &[String], read_only: bool) -> Result<ToolRouter<SiMcpServer>> {
765 + let selected: Vec<String> = if toolsets.is_empty() {
766 + TOOLSETS.iter().map(|name| name.to_string()).collect()
767 + } else {
768 + toolsets
769 + .iter()
770 + .map(|name| name.trim().to_ascii_lowercase())
771 + .collect()
772 + };
773 +
774 + let mut router = ToolRouter::new();
775 + for name in &selected {
776 + let group = match name.as_str() {
777 + "account" => SiMcpServer::account_router(),
778 + "repo" => SiMcpServer::repo_router(),
779 + "issue" => SiMcpServer::issue_router(),
780 + "pr" => SiMcpServer::pr_router(),
781 + "code" => SiMcpServer::code_router(),
782 + "hook" => SiMcpServer::hook_router(),
783 + "session" => SiMcpServer::session_router(),
784 + "billing" => SiMcpServer::billing_router(),
785 + other => {
786 + return Err(anyhow!(
787 + "Unknown toolset {other:?}. Expected one of: {}.",
788 + TOOLSETS.join(", ")
789 + ))
790 + }
791 + };
792 + router.merge(group);
793 + }
794 +
795 + if read_only {
796 + let writers: Vec<String> = router
797 + .list_all()
798 + .into_iter()
799 + .filter(|tool| {
800 + tool.annotations
801 + .as_ref()
802 + .and_then(|annotations| annotations.read_only_hint)
803 + != Some(true)
804 + })
805 + .map(|tool| tool.name.to_string())
806 + .collect();
807 +
808 + for name in writers {
809 + router.remove_route(&name);
810 + }
811 + }
812 +
813 + Ok(router)
814 +}
815 +
816 +/// Run the MCP server over stdio until the client disconnects.
817 +pub async fn run(ctx: Ctx, args: McpArgs) -> Result<()> {
818 + let router = build_router(&args.toolsets, args.read_only)?;
819 + tracing::info!(tools = router.list_all().len(), "starting the MCP server");
820 +
821 + let running = SiMcpServer::new(ctx, router)
822 + .serve(stdio())
823 + .await
824 + .map_err(|e| anyhow!("Could not start the MCP server: {e}"))?;
825 +
826 + running
827 + .waiting()
828 + .await
829 + .map_err(|e| anyhow!("The MCP server stopped unexpectedly: {e}"))?;
830 + Ok(())
831 +}
832 +
833 +#[cfg(test)]
834 +mod tests {
835 + use super::*;
836 +
837 + fn tool_names(toolsets: &[String], read_only: bool) -> Vec<String> {
838 + build_router(toolsets, read_only)
839 + .unwrap()
840 + .list_all()
841 + .into_iter()
842 + .map(|tool| tool.name.to_string())
843 + .collect()
844 + }
845 +
846 + #[test]
847 + fn the_default_router_spans_every_toolset() {
848 + let names = tool_names(&[], false);
849 +
850 + for expected in [
851 + "whoami",
852 + "list_repositories",
853 + "list_issues",
854 + "list_pull_requests",
855 + "search_code",
856 + "list_hooks",
857 + "list_sessions",
858 + "get_billing",
859 + ] {
860 + assert!(
861 + names.contains(&expected.to_string()),
862 + "missing {expected:?}"
863 + );
864 + }
865 + }
866 +
867 + #[test]
868 + fn a_toolset_selection_leaves_the_rest_out() {
869 + let names = tool_names(&["issue".to_string(), "pr".to_string()], false);
870 +
871 + assert!(names.contains(&"create_issue".to_string()));
872 + assert!(names.contains(&"get_pull_request".to_string()));
873 + assert!(!names.contains(&"get_billing".to_string()));
874 + assert!(!names.contains(&"list_sessions".to_string()));
875 + }
876 +
877 + #[test]
878 + fn read_only_mode_drops_every_writer() {
879 + let names = tool_names(&[], true);
880 +
881 + assert!(names.contains(&"list_issues".to_string()));
882 + assert!(names.contains(&"get_file_contents".to_string()));
883 +
884 + for writer in [
885 + "create_issue",
886 + "create_pull_request",
887 + "add_issue_comment",
888 + "create_repository",
889 + "create_hook",
890 + "delete_hook",
891 + "create_session",
892 + "delete_session",
893 + ] {
894 + assert!(
895 + !names.contains(&writer.to_string()),
896 + "read-only mode should have dropped {writer:?}"
897 + );
898 + }
899 + }
900 +
901 + #[test]
902 + fn an_unknown_toolset_is_rejected() {
903 + let error = build_router(&["repo".to_string(), "nope".to_string()], false).unwrap_err();
904 + assert!(error.to_string().contains("nope"));
905 + }
906 +
907 + #[test]
908 + fn toolset_names_are_case_and_space_insensitive() {
909 + assert_eq!(
910 + tool_names(&[" Billing ".to_string()], false),
911 + vec!["get_billing".to_string()]
912 + );
913 + }
914 +}
crates/sigit-si-api/src/models.rs
+1 -1
@@ -104,7 +104,7 @@ pub struct CloudMessage {
104 }
105
106 /// `POST /api/v1/sessions/:id/messages`
107 -#[derive(Debug, Clone, Deserialize)]
107 +#[derive(Debug, Clone, Deserialize, Serialize)]
108 pub struct AppendedMessage {
109 pub session: CloudSession,
110 pub message: CloudMessage,
server-si-cli.json