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,