main
rs 914 lines 29.2 KB
Raw
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 }