diff --git a/.github/workflows/release-mcp-registry.yml b/.github/workflows/release-mcp-registry.yml new file mode 100644 index 0000000..18b03a9 --- /dev/null +++ b/.github/workflows/release-mcp-registry.yml @@ -0,0 +1,192 @@ +name: MCP Registry Release + +on: + workflow_dispatch: + inputs: + hosted_version: + description: "Override the version in the hosted listing (e.g. 1.2.6); leave blank to publish it as-is" + required: false + skip_cli: + description: "Publish only the hosted server, leaving the CLI listing alone" + type: boolean + default: false + skip_hosted: + description: "Publish only the CLI, leaving the hosted listing alone" + type: boolean + default: false + +# This repo owns two MCP Registry entries, one file each: +# +# server.json si.sigit/cli this CLI, a cargo package run as `si mcp` +# server-sigit.json si.sigit/sigit the hosted server at sigit.si/api/v1/mcp +# +# The CLI is what this repo actually ships, so it holds the conventional +# filename that `mcp-publisher` defaults to. +# +# Both sit under one namespace, so one credential covers them. A domain +# namespace proves ownership through DNS, not GitHub OIDC: the registry checks +# a v=MCPv1 TXT record on sigit.si against a request signed with the matching +# Ed25519 private key. See the MCP Registry doc in the sigit-si repo for the +# one-time key/DNS setup. +permissions: + contents: read + +jobs: + publish: + name: Publish the MCP Registry listings + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Take the CLI listing's version from Cargo.toml + if: ${{ !inputs.skip_cli }} + shell: bash + run: | + # The workspace manifest is the single source of truth: the listing + # names a crates.io version, so a hand-edited number here could point + # at a release that doesn't exist. + crate_version="$(sed -n '/^\[workspace.package\]/,/^\[/s/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1)" + if [ -z "${crate_version}" ]; then + echo "Could not read the workspace version from Cargo.toml." >&2 + exit 1 + fi + echo "CRATE_VERSION=${crate_version}" >> "$GITHUB_ENV" + + jq --arg v "${crate_version}" \ + '.version = $v | .packages |= map(.version = $v)' \ + server.json > server.tmp + mv server.tmp server.json + cat server.json + + - name: Verify the published crate carries the ownership marker + if: ${{ !inputs.skip_cli }} + shell: bash + run: | + # The registry stores metadata only, and validates a package listing + # by fetching the package and looking for a marker naming the server. + # Checking the working tree isn't enough: a version published before + # the marker landed will never carry it, and crates.io versions are + # immutable. The comparison is case-sensitive. + server_name="$(jq -r '.name' server.json)" + crate="$(jq -r '.packages[0].identifier' server.json)" + + for attempt in $(seq 1 20); do + if curl -fsSL -H "User-Agent: getsigit/si release-workflow" \ + "https://crates.io/api/v1/crates/${crate}/${CRATE_VERSION}" >/dev/null; then + echo "${crate} ${CRATE_VERSION} is indexed on crates.io." + break + fi + if [ "${attempt}" -eq 20 ]; then + echo "${crate} ${CRATE_VERSION} is still not on crates.io after 10 minutes." >&2 + echo "Publish the crate first: the listing can only name a version that exists." >&2 + exit 1 + fi + echo "Waiting for crates.io to index ${crate} ${CRATE_VERSION} (attempt ${attempt}/20)..." + sleep 30 + done + + curl -fsSL -H "User-Agent: getsigit/si release-workflow" -o package.crate \ + "https://crates.io/api/v1/crates/${crate}/${CRATE_VERSION}/download" + # `readme` in crates/cli/Cargo.toml points at the repo-root README, + # which cargo copies to the crate root when packaging. + if ! tar -xOzf package.crate "${crate}-${CRATE_VERSION}/README.md" | grep -q "mcp-name: ${server_name}"; then + echo "${crate} ${CRATE_VERSION} README is missing the 'mcp-name: ${server_name}' marker." >&2 + echo "crates.io versions are immutable — cut a new release with the marker in place." >&2 + exit 1 + fi + rm package.crate + + - name: Optionally override the version in the hosted listing + if: ${{ !inputs.skip_hosted }} + shell: bash + run: | + version="${{ inputs.hosted_version }}" + if [ -n "${version}" ]; then + jq --arg v "${version}" '.version = $v' server-sigit.json > server-sigit.tmp + mv server-sigit.tmp server-sigit.json + fi + cat server-sigit.json + + - name: Check the hosted remote URL matches the namespace domain + if: ${{ !inputs.skip_hosted }} + shell: bash + run: | + # The registry rejects a remote server whose URL isn't under the + # namespace's domain. si.sigit -> sigit.si, so every listed remote + # must live on sigit.si (or a subdomain). + server_name="$(jq -r '.name' server-sigit.json)" + domain="$(echo "${server_name%%/*}" | awk -F. '{ for (i=NF; i>=1; i--) printf "%s%s", $i, (i>1 ? "." : "") }')" + echo "Server: ${server_name} Domain: ${domain}" + + bad="$(jq -r --arg d "${domain}" ' + .remotes // [] + | map(.url | sub("^[a-z]+://"; "") | sub("/.*$"; "")) + | map(select(. != $d and (endswith("." + $d) | not))) + | .[]' server-sigit.json)" + if [ -n "${bad}" ]; then + echo "Remote URL host(s) not under ${domain}: ${bad}" >&2 + exit 1 + fi + + - name: Install mcp-publisher + shell: bash + run: | + curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher + + - name: Authenticate to the MCP Registry + shell: bash + env: + MCP_REGISTRY_DNS_PRIVATE_KEY: ${{ secrets.MCP_REGISTRY_DNS_PRIVATE_KEY }} + run: | + if [ -z "${MCP_REGISTRY_DNS_PRIVATE_KEY}" ]; then + echo "MCP_REGISTRY_DNS_PRIVATE_KEY secret is not set. See the MCP Registry doc in the sigit-si repo." >&2 + exit 1 + fi + ./mcp-publisher login dns --domain=sigit.si --private-key="${MCP_REGISTRY_DNS_PRIVATE_KEY}" + + - name: Publish + shell: bash + run: | + # Republishing a version that is already up is not worth failing the + # run over: the other listing may still need to go out. + publish_or_skip() { + local out + out="$(./mcp-publisher publish "$1" 2>&1)" && { echo "${out}"; return 0; } + echo "${out}" + if echo "${out}" | grep -q "duplicate version"; then + echo "$1 is already published at this version — skipping." + return 0 + fi + return 1 + } + + published=0 + if [ "${{ inputs.skip_cli }}" != "true" ]; then + publish_or_skip server.json + published=1 + fi + if [ "${{ inputs.skip_hosted }}" != "true" ]; then + publish_or_skip server-sigit.json + published=1 + fi + if [ "${published}" -eq 0 ]; then + echo "Both listings were skipped, so there was nothing to publish." >&2 + exit 1 + fi + + - name: Verify the servers are listed + shell: bash + run: | + if [ "${{ inputs.skip_cli }}" != "true" ]; then + files="server.json" + fi + if [ "${{ inputs.skip_hosted }}" != "true" ]; then + files="${files} server-sigit.json" + fi + + for file in ${files}; do + server_name="$(jq -r '.name' "${file}")" + echo "== ${server_name} (${file}) ==" + curl -fsSL "https://registry.modelcontextprotocol.io/v0.1/servers?search=${server_name}" | jq . + done diff --git a/Cargo.lock b/Cargo.lock index 03682d4..8c6d9c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" version = "2.13.1" @@ -251,6 +257,40 @@ dependencies = [ "libc", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.3", +] + [[package]] name = "dialoguer" version = "0.11.0" @@ -296,12 +336,24 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "encode_unicode" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -339,6 +391,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -346,6 +413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -354,6 +422,40 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -366,8 +468,13 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -412,6 +519,12 @@ dependencies = [ "url", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -499,7 +612,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -623,6 +736,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -644,6 +763,18 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + [[package]] name = "ipnet" version = "2.12.1" @@ -860,6 +991,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1004,6 +1141,26 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "regex-automata" version = "0.4.18" @@ -1027,7 +1184,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "http", @@ -1073,6 +1230,42 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b6914fac0be956fe704a38239c3f44a9f841d1b06a5713d2f638065593f5b5" +dependencies = [ + "base64 0.23.1", + "chrono", + "futures", + "indexmap", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdf1c49bd4d52014b94db0877410db273c2008f01628b0252a2e9460ad9b7fda" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.3", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -1160,6 +1353,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1213,6 +1432,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -1261,7 +1491,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "sigit-si-api" -version = "1.0.0" +version = "1.1.0" dependencies = [ "chrono", "reqwest", @@ -1273,7 +1503,7 @@ dependencies = [ [[package]] name = "sigit-si-cli" -version = "1.0.0" +version = "1.1.0" dependencies = [ "anyhow", "chrono", @@ -1284,6 +1514,8 @@ dependencies = [ "git2", "open", "reqwest", + "rmcp", + "schemars", "serde", "serde_json", "sigit-si-api", @@ -1518,6 +1750,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -1672,6 +1918,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index bf2d936..4086033 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/cli", "crates/sigit-si-api"] # on without pulling in clap, dialoguer, and the rest of the terminal surface. # `cli` is the thin `si` binary on top of it. [workspace.package] -version = "1.0.0" +version = "1.1.0" edition = "2021" authors = ["Seto Elkahfi "] license = "Apache-2.0" @@ -27,12 +27,20 @@ reqwest = { version = "0.12", default-features = false, features = [ "json", "rustls-tls-native-roots", ] } +rmcp = { version = "3.2", features = [ + "server", + "macros", + "transport-io", + "schemars", +] } +schemars = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" -sigit-si-api = { version = "1.0.0", path = "crates/sigit-si-api" } +sigit-si-api = { version = "1.1.0", path = "crates/sigit-si-api" } tabled = { version = "0.20", default-features = false, features = ["std"] } thiserror = "2.0" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +# `io-std` is what the MCP stdio transport binds to. +tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/README.md b/README.md index 710208c..58c7b36 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,38 @@ si api repos # raw escape hatch for any endpoint Pass `--json` to any read command for machine-readable output. +### As an MCP server + + + +`si mcp` serves these same commands to an AI agent as +[MCP](https://modelcontextprotocol.io) tools over stdio: + +```jsonc +{ + "mcpServers": { + "sigit": { "command": "si", "args": ["mcp"] } + } +} +``` + +There is nothing to authenticate — the server reuses the token `si auth login` +stored. Point your editor at the directory of a checkout and repository tools +default to the repo you're standing in, so the agent can say "list the open +pull requests" without naming one; `create_pull_request` likewise defaults its +head to the checked-out branch. + +Trim the tool list when an agent doesn't need all of it: + +```sh +si mcp --read-only # drop every tool that writes +si mcp --toolsets repo,issue,pr,code # or SIGIT_TOOLSETS=repo,issue +``` + +Toolsets are `account`, `repo`, `issue`, `pr`, `code`, `hook`, `session`, and +`billing`. sigit.si also hosts a remote MCP server at `/api/v1/mcp` for clients +that would rather connect over HTTP than spawn a binary. + ### Environments ```sh @@ -64,6 +96,31 @@ they're served over the same MCP (Model Context Protocol) JSON-RPC endpoint the AI agent uses. `sigit-si-api` speaks that transport internally; it's not visible at the call site. +## MCP Registry listings + +This repo publishes two entries to the +[official MCP Registry](https://registry.modelcontextprotocol.io), one per way +of reaching the same tools, so registry-aware clients can add either in one +click: + +| Listing | File | What it is | +| --- | --- | --- | +| `si.sigit/cli` | `server.json` | This CLI: a cargo package listing that runs `si mcp` over stdio. | +| `si.sigit/sigit` | `server-sigit.json` | The hosted server at `sigit.si/api/v1/mcp`, a remote Streamable-HTTP listing. The endpoint `sigit-si-api` talks to, and the official server siGit Code bakes in. | + +The CLI holds `server.json` because it's what this repo ships, and that's the +filename `mcp-publisher` defaults to. Sibling listings are named after their +own leaf, so the file says which entry it publishes. + +Both go out through the `release-mcp-registry.yml` workflow, and both prove +namespace ownership with a DNS TXT record on `sigit.si` rather than GitHub +OIDC, so one credential covers the pair. A package listing has a second hurdle: +the registry fetches the crate from crates.io and looks for the +`mcp-name: si.sigit/cli` marker in its README, which is why that marker sits in +this file. Crate versions are immutable, so a release that ships without the +marker can never be listed, and fixing it costs a new version. The internal +setup and release runbook live with the server (private `sigit-si` repo). + ## Development ```sh diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index de710b3..c0e9b94 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -25,6 +25,8 @@ dirs = { workspace = true } git2 = { workspace = true } open = { workspace = true } reqwest = { workspace = true } +rmcp = { workspace = true } +schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sigit-si-api = { workspace = true } diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 796ac0d..182b5dd 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -60,6 +60,9 @@ pub enum Command { /// Call the sigit.si API directly. The escape hatch for anything without /// a dedicated command. Api(ApiArgs), + + /// Serve these commands to an AI agent as MCP tools over stdio. + Mcp(McpArgs), } #[derive(clap::Subcommand)] @@ -256,6 +259,18 @@ pub struct BrowseArgs { pub target: Option, } +#[derive(clap::Args)] +pub struct McpArgs { + /// Drop every tool that writes, leaving only the ones that read. + #[arg(long)] + pub read_only: bool, + + /// Expose only these tool groups, comma-separated. Defaults to all of + /// them: account, repo, issue, pr, code, hook, session, billing. + #[arg(long, value_delimiter = ',', env = "SIGIT_TOOLSETS")] + pub toolsets: Vec, +} + #[derive(clap::Args)] pub struct ApiArgs { /// Path under /api/v1, e.g. "repos" or "billing". diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index feea2d5..3b311e5 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -3,6 +3,7 @@ mod commands; mod config; mod context; mod git; +mod mcp; mod ui; use { @@ -49,5 +50,8 @@ async fn run() -> Result<()> { Command::Billing(command) => commands::billing::run(&ctx, command).await, Command::Browse(args) => commands::browse::run(&ctx, args).await, Command::Api(args) => commands::api::run(&ctx, args).await, + // Takes `ctx` by value: the server outlives the dispatch, serving + // tools over stdio until the client hangs up. + Command::Mcp(args) => mcp::run(ctx, args).await, } } diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs new file mode 100644 index 0000000..02e9079 --- /dev/null +++ b/crates/cli/src/mcp.rs @@ -0,0 +1,914 @@ +//! `si` as an MCP server over stdio. +//! +//! `si mcp` stops being a one-shot command and becomes a long-lived +//! [Model Context Protocol](https://modelcontextprotocol.io) server, so an +//! agent can do what the CLI does: list repos, read and search code, triage +//! issues, open pull requests, drive Cloud Sessions. +//! +//! sigit.si already serves a remote MCP endpoint at `/api/v1/mcp` (it's what +//! [`sigit_si_api::mcp`] calls into), so a local server only earns its keep by +//! doing what a remote one can't: +//! +//! - **No auth to configure.** It reuses the token `si auth login` stored, so +//! an editor points at a binary and is done — no OAuth dance, no PAT pasted +//! into a config file. +//! - **It knows where you're standing.** Every repo-scoped tool takes `repo` +//! as an *optional* argument and falls back to the sigit.si remote of the +//! working directory, the same way the CLI does. `create_pull_request` +//! likewise defaults `head` to the checked-out branch. +//! - **It reaches the whole CLI.** Cloud Sessions, webhooks, billing, and repo +//! creation are plain REST that the remote MCP endpoint doesn't expose. +//! +//! Tools call the same [`sigit_si_api`] methods the command handlers do, +//! skipping the `ui` layer entirely: stdout is the JSON-RPC channel, so +//! nothing here may print to it. Logging already goes to stderr (see `main`). +//! +//! Toolsets and `--read-only` follow the convention GitHub's `github-mcp-server` +//! set — trimming the tool list keeps the model's choice sharp and its context +//! small. + +use { + crate::{ + cli::McpArgs, + context::Ctx, + git::{self, RepoSlug}, + }, + anyhow::{anyhow, Result}, + rmcp::{ + handler::server::{router::tool::ToolRouter, wrapper::Parameters, ServerHandler}, + model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo}, + tool, tool_handler, tool_router, + transport::stdio, + ErrorData, ServiceExt, + }, + schemars::JsonSchema, + serde::Deserialize, + sigit_si_api::{Client, NewRepository, State}, +}; + +/// The tool groups `--toolsets` selects from. +const TOOLSETS: [&str; 8] = [ + "account", "repo", "issue", "pr", "code", "hook", "session", "billing", +]; + +/// The sigit.si MCP server. Holds the same [`Ctx`] the command handlers get, +/// which is where the API client and working-directory repo resolution live. +pub struct SiMcpServer { + ctx: Ctx, + tool_router: ToolRouter, +} + +impl SiMcpServer { + fn new(ctx: Ctx, tool_router: ToolRouter) -> Self { + Self { ctx, tool_router } + } + + /// The API client, refusing early when there's no token rather than + /// letting every tool round-trip to a 401. + fn client(&self) -> Result<&Client, ErrorData> { + self.ctx + .require_token() + .map(|()| &self.ctx.client) + .map_err(|e| ErrorData::invalid_request(e.to_string(), None)) + } + + /// Resolve a tool's optional `repo` argument, falling back to the repo the + /// server was started in. + fn repo(&self, explicit: Option) -> Result { + self.ctx + .resolve_repo(explicit) + .map(|slug| slug.to_string()) + .map_err(|e| ErrorData::invalid_request(e.to_string(), None)) + } +} + +/// Serialize a value into a one-block successful tool result. +fn json_result(value: &T) -> Result { + Ok(CallToolResult::success(vec![ContentBlock::json(value)?])) +} + +fn text_result(message: impl Into) -> Result { + Ok(CallToolResult::success(vec![ContentBlock::text( + message.into(), + )])) +} + +fn to_error_data(error: impl std::fmt::Display) -> ErrorData { + ErrorData::internal_error(error.to_string(), None) +} + +/// Which issues or pull requests to list. An enum rather than a string so the +/// advertised schema tells the model what the choices are. +#[derive(Debug, Default, Clone, Copy, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +enum StateArg { + #[default] + Open, + Closed, + Merged, + All, +} + +impl From for State { + fn from(state: StateArg) -> Self { + match state { + StateArg::Open => State::Open, + StateArg::Closed => State::Closed, + StateArg::Merged => State::Merged, + StateArg::All => State::All, + } + } +} + +/// The `repo` argument every repository-scoped tool shares. +#[derive(Debug, Deserialize, JsonSchema)] +struct RepoArgs { + /// Repository as OWNER/NAME. Defaults to the repository the server is + /// running in. + #[serde(default)] + repo: Option, +} + +// ---------------------------------------------------------------- account -- + +#[tool_router(router = account_router)] +impl SiMcpServer { + /// Show the signed-in sigit.si user: name, email, and plan. + #[tool(annotations(read_only_hint = true))] + async fn whoami(&self) -> Result { + let user = self.client()?.user().await.map_err(to_error_data)?; + json_result(&user) + } +} + +// ------------------------------------------------------------------- repo -- + +#[derive(Debug, Deserialize, JsonSchema)] +struct ListRepositoriesArgs { + /// Case-insensitive substring of the owner or repository name. Omit to + /// list everything visible. + #[serde(default)] + query: Option, + /// How many repositories to return. Defaults to the server's own limit. + #[serde(default)] + limit: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct CreateRepositoryArgs { + /// Name for the new repository. + name: String, + /// Whether the repository is private. Defaults to public. + #[serde(default)] + private: bool, + /// Optional one-line description. + #[serde(default)] + description: Option, +} + +#[tool_router(router = repo_router)] +impl SiMcpServer { + /// Search every repository the user can see by an owner/name substring. + /// Use this to find the OWNER/NAME other tools take. + #[tool(annotations(read_only_hint = true))] + async fn list_repositories( + &self, + Parameters(ListRepositoriesArgs { query, limit }): Parameters, + ) -> Result { + let repos = self + .client()? + .search_repos(query.as_deref(), limit) + .await + .map_err(to_error_data)?; + json_result(&repos) + } + + /// List the repositories the signed-in user owns, newest first. + #[tool(annotations(read_only_hint = true))] + async fn list_my_repositories(&self) -> Result { + let repos = self.client()?.repos().await.map_err(to_error_data)?; + json_result(&repos) + } + + /// Identify the sigit.si repository and branch the server is running in. + /// Call this to find out what "the current repo" resolves to. + #[tool(annotations(read_only_hint = true))] + async fn current_repository(&self) -> Result { + let cwd = std::env::current_dir().map_err(to_error_data)?; + let slug: RepoSlug = self + .ctx + .resolve_repo(None) + .map_err(|e| ErrorData::invalid_request(e.to_string(), None))?; + + json_result(&serde_json::json!({ + "repo": slug.to_string(), + "owner": slug.owner, + "name": slug.name, + "branch": git::current_branch(&cwd), + "directory": cwd.display().to_string(), + })) + } + + /// Create a new, empty repository to push to. + #[tool] + async fn create_repository( + &self, + Parameters(CreateRepositoryArgs { + name, + private, + description, + }): Parameters, + ) -> Result { + let new_repo = NewRepository::new(name) + .private(private) + .description(description); + let repo = self + .client()? + .create_repo(&new_repo) + .await + .map_err(to_error_data)?; + json_result(&repo) + } +} + +// ------------------------------------------------------------------ issue -- + +#[derive(Debug, Deserialize, JsonSchema)] +struct ListIssuesArgs { + /// Repository as OWNER/NAME. Defaults to the repository the server is + /// running in. + #[serde(default)] + repo: Option, + /// Which issues to list. Defaults to open. + #[serde(default)] + state: StateArg, + /// Optional substring to match against issue titles and bodies. + #[serde(default)] + query: Option, +} + +/// Shared by the issue and pull request tools, which draw on one numbering. +#[derive(Debug, Deserialize, JsonSchema)] +struct NumberArgs { + /// The issue or pull request number. + number: i64, + /// Repository as OWNER/NAME. Defaults to the repository the server is + /// running in. + #[serde(default)] + repo: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct CreateIssueArgs { + /// Issue title. + title: String, + /// Issue body, in Markdown. + #[serde(default)] + body: Option, + /// Repository as OWNER/NAME. Defaults to the repository the server is + /// running in. + #[serde(default)] + repo: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct AddCommentArgs { + /// The issue or pull request number to comment on. + number: i64, + /// Comment body, in Markdown. + body: String, + /// Repository as OWNER/NAME. Defaults to the repository the server is + /// running in. + #[serde(default)] + repo: Option, +} + +#[tool_router(router = issue_router)] +impl SiMcpServer { + /// List a repository's issues, optionally filtered by state and a + /// title/body substring. + #[tool(annotations(read_only_hint = true))] + async fn list_issues( + &self, + Parameters(ListIssuesArgs { repo, state, query }): Parameters, + ) -> Result { + let repo = self.repo(repo)?; + let issues = self + .client()? + .issues(&repo, state.into(), query.as_deref()) + .await + .map_err(to_error_data)?; + json_result(&issues) + } + + /// Fetch one issue by number, including its body and comments. + #[tool(annotations(read_only_hint = true))] + async fn get_issue( + &self, + Parameters(NumberArgs { number, repo }): Parameters, + ) -> Result { + let repo = self.repo(repo)?; + let issue = self + .client()? + .issue(&repo, number) + .await + .map_err(to_error_data)?; + json_result(&issue) + } + + /// Open a new issue. + #[tool] + async fn create_issue( + &self, + Parameters(CreateIssueArgs { title, body, repo }): Parameters, + ) -> Result { + let repo = self.repo(repo)?; + let created = self + .client()? + .create_issue(&repo, &title, body.as_deref()) + .await + .map_err(to_error_data)?; + json_result(&created) + } + + /// Comment on an issue or a pull request — they share one numbering. + #[tool] + async fn add_issue_comment( + &self, + Parameters(AddCommentArgs { number, body, repo }): Parameters, + ) -> Result { + let repo = self.repo(repo)?; + let comment = self + .client()? + .add_comment(&repo, number, &body) + .await + .map_err(to_error_data)?; + json_result(&comment) + } +} + +// --------------------------------------------------------------------- pr -- + +#[derive(Debug, Deserialize, JsonSchema)] +struct ListPullsArgs { + /// Repository as OWNER/NAME. Defaults to the repository the server is + /// running in. + #[serde(default)] + repo: Option, + /// Which pull requests to list. Defaults to open. + #[serde(default)] + state: StateArg, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct CreatePullArgs { + /// Pull request title. + title: String, + /// Branch to merge from. Defaults to the branch checked out where the + /// server is running. + #[serde(default)] + head: Option, + /// Branch to merge into. Defaults to main. + #[serde(default)] + base: Option, + /// Pull request body, in Markdown. + #[serde(default)] + body: Option, + /// Repository as OWNER/NAME. Defaults to the repository the server is + /// running in. + #[serde(default)] + repo: Option, +} + +#[tool_router(router = pr_router)] +impl SiMcpServer { + /// List a repository's pull requests, optionally filtered by state. + #[tool(annotations(read_only_hint = true))] + async fn list_pull_requests( + &self, + Parameters(ListPullsArgs { repo, state }): Parameters, + ) -> Result { + let repo = self.repo(repo)?; + let pulls = self + .client()? + .pulls(&repo, state.into()) + .await + .map_err(to_error_data)?; + json_result(&pulls) + } + + /// Fetch one pull request with its comments and unified diff. Diffs past + /// 100 kB come back truncated and flagged. + #[tool(annotations(read_only_hint = true))] + async fn get_pull_request( + &self, + Parameters(NumberArgs { number, repo }): Parameters, + ) -> Result { + let repo = self.repo(repo)?; + let pull = self + .client()? + .pull(&repo, number) + .await + .map_err(to_error_data)?; + json_result(&pull) + } + + /// Open a pull request from one branch into another. + #[tool] + async fn create_pull_request( + &self, + Parameters(CreatePullArgs { + title, + head, + base, + body, + repo, + }): Parameters, + ) -> Result { + let repo = self.repo(repo)?; + let head = match head { + Some(head) => head, + None => { + let cwd = std::env::current_dir().map_err(to_error_data)?; + git::current_branch(&cwd).ok_or_else(|| { + ErrorData::invalid_request( + "Could not determine the current branch. Pass `head`.", + None, + ) + })? + } + }; + let base = base.unwrap_or_else(|| "main".to_string()); + + let created = self + .client()? + .create_pull(&repo, &title, &head, &base, body.as_deref()) + .await + .map_err(to_error_data)?; + json_result(&created) + } +} + +// ------------------------------------------------------------------- code -- + +#[derive(Debug, Deserialize, JsonSchema)] +struct FileContentsArgs { + /// Path to the file, relative to the repository root. + path: String, + /// Branch, tag, or commit SHA. Defaults to the repository's default branch. + #[serde(default, rename = "ref")] + git_ref: Option, + /// Repository as OWNER/NAME. Defaults to the repository the server is + /// running in. + #[serde(default)] + repo: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct SearchCodeArgs { + /// Fixed string to search for. Case-insensitive, not a regex. + query: String, + /// Branch, tag, or commit SHA. Defaults to the repository's default branch. + #[serde(default, rename = "ref")] + git_ref: Option, + /// How many matches to return. + #[serde(default)] + limit: Option, + /// Repository as OWNER/NAME. Defaults to the repository the server is + /// running in. + #[serde(default)] + repo: Option, +} + +#[tool_router(router = code_router)] +impl SiMcpServer { + /// Read a file's contents at a ref. + #[tool(annotations(read_only_hint = true))] + async fn get_file_contents( + &self, + Parameters(FileContentsArgs { + path, + git_ref, + repo, + }): Parameters, + ) -> Result { + let repo = self.repo(repo)?; + let file = self + .client()? + .file_contents(&repo, &path, git_ref.as_deref()) + .await + .map_err(to_error_data)?; + json_result(&file) + } + + /// Search a repository's tracked files for a fixed string, returning + /// matching files with line snippets. + #[tool(annotations(read_only_hint = true))] + async fn search_code( + &self, + Parameters(SearchCodeArgs { + query, + git_ref, + limit, + repo, + }): Parameters, + ) -> Result { + let repo = self.repo(repo)?; + let hits = self + .client()? + .search_code(&repo, &query, git_ref.as_deref(), limit) + .await + .map_err(to_error_data)?; + json_result(&hits) + } +} + +// ------------------------------------------------------------------- hook -- + +#[derive(Debug, Deserialize, JsonSchema)] +struct CreateHookArgs { + /// URL sigit.si should POST to. + url: String, + /// Events to subscribe to. Defaults to push. + #[serde(default)] + events: Option>, + /// Repository as OWNER/NAME. Defaults to the repository the server is + /// running in. + #[serde(default)] + repo: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct DeleteHookArgs { + /// The webhook's id. + id: i64, + /// Repository as OWNER/NAME. Defaults to the repository the server is + /// running in. + #[serde(default)] + repo: Option, +} + +#[tool_router(router = hook_router)] +impl SiMcpServer { + /// List a repository's deploy webhooks. + #[tool(annotations(read_only_hint = true))] + async fn list_hooks( + &self, + Parameters(RepoArgs { repo }): Parameters, + ) -> Result { + let slug = self.slug(repo)?; + let hooks = self + .client()? + .hooks(&slug.owner, &slug.name) + .await + .map_err(to_error_data)?; + json_result(&hooks) + } + + /// Register a webhook. The response carries the signing secret, which is + /// shown once and cannot be retrieved again. + #[tool] + async fn create_hook( + &self, + Parameters(CreateHookArgs { url, events, repo }): Parameters, + ) -> Result { + let slug = self.slug(repo)?; + let events = events.unwrap_or_else(|| vec!["push".to_string()]); + let hook = self + .client()? + .create_hook(&slug.owner, &slug.name, &url, &events) + .await + .map_err(to_error_data)?; + json_result(&hook) + } + + /// Remove a webhook. This cannot be undone. + #[tool(annotations(destructive_hint = true))] + async fn delete_hook( + &self, + Parameters(DeleteHookArgs { id, repo }): Parameters, + ) -> Result { + let slug = self.slug(repo)?; + self.client()? + .delete_hook(&slug.owner, &slug.name, id) + .await + .map_err(to_error_data)?; + text_result(format!("Deleted webhook {id} from {slug}.")) + } +} + +// ---------------------------------------------------------------- session -- + +#[derive(Debug, Deserialize, JsonSchema)] +struct SessionIdArgs { + /// The Cloud Session's id. + id: i64, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct CreateSessionArgs { + /// Title for the session. + #[serde(default)] + title: Option, + /// Model the session runs on. Defaults to the account's default. + #[serde(default)] + model: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct UpdateSessionArgs { + /// The Cloud Session's id. + id: i64, + /// New title. Omit to leave unchanged. + #[serde(default)] + title: Option, + /// New model. Omit to leave unchanged. + #[serde(default)] + model: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct AppendMessageArgs { + /// The Cloud Session's id. + id: i64, + /// Who the message is from: user or assistant. + role: String, + /// The message text. + content: String, +} + +#[tool_router(router = session_router)] +impl SiMcpServer { + /// List siGit Code Cloud Sessions, most recently updated first. + #[tool(annotations(read_only_hint = true))] + async fn list_sessions(&self) -> Result { + let sessions = self.client()?.sessions().await.map_err(to_error_data)?; + json_result(&sessions) + } + + /// Fetch one Cloud Session with its full transcript. + #[tool(annotations(read_only_hint = true))] + async fn get_session( + &self, + Parameters(SessionIdArgs { id }): Parameters, + ) -> Result { + let session = self.client()?.session(id).await.map_err(to_error_data)?; + json_result(&session) + } + + /// Start a new Cloud Session. + #[tool] + async fn create_session( + &self, + Parameters(CreateSessionArgs { title, model }): Parameters, + ) -> Result { + let session = self + .client()? + .create_session(title.as_deref(), model.as_deref()) + .await + .map_err(to_error_data)?; + json_result(&session) + } + + /// Rename a Cloud Session or change the model it runs on. + #[tool] + async fn update_session( + &self, + Parameters(UpdateSessionArgs { id, title, model }): Parameters, + ) -> Result { + let session = self + .client()? + .update_session(id, title.as_deref(), model.as_deref()) + .await + .map_err(to_error_data)?; + json_result(&session) + } + + /// Append one message to a Cloud Session's transcript. + #[tool] + async fn append_session_message( + &self, + Parameters(AppendMessageArgs { id, role, content }): Parameters, + ) -> Result { + let appended = self + .client()? + .append_message(id, &role, &content) + .await + .map_err(to_error_data)?; + json_result(&appended) + } + + /// Delete a Cloud Session and its transcript. This cannot be undone. + #[tool(annotations(destructive_hint = true))] + async fn delete_session( + &self, + Parameters(SessionIdArgs { id }): Parameters, + ) -> Result { + self.client()? + .delete_session(id) + .await + .map_err(to_error_data)?; + text_result(format!("Deleted session {id}.")) + } +} + +// ---------------------------------------------------------------- billing -- + +#[tool_router(router = billing_router)] +impl SiMcpServer { + /// Show the account's current plan and cloud usage. + #[tool(annotations(read_only_hint = true))] + async fn get_billing(&self) -> Result { + let billing = self.client()?.billing().await.map_err(to_error_data)?; + json_result(&billing) + } +} + +#[tool_handler(router = self.tool_router)] +impl ServerHandler for SiMcpServer { + fn get_info(&self) -> ServerInfo { + // `Implementation` is `#[non_exhaustive]`, so start from the build-env + // default and override the identity fields to report `si`, not `rmcp`. + let mut server_info = Implementation::from_build_env(); + server_info.name = "si".to_string(); + server_info.version = env!("CARGO_PKG_VERSION").to_string(); + + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(server_info) + .with_instructions( + "The sigit.si CLI exposed as MCP tools. Authentication uses the token stored \ + by `si auth login`; tools run non-interactively. Every repository tool takes \ + `repo` as OWNER/NAME, and omitting it falls back to the sigit.si repository \ + the server was started in — call `current_repository` to see what that \ + resolves to. Use `list_repositories` to find the OWNER/NAME of anything else.", + ) + } +} + +impl SiMcpServer { + /// [`Self::repo`], parsed back into its owner and name halves for the REST + /// endpoints that take them separately. + fn slug(&self, explicit: Option) -> Result { + self.ctx + .resolve_repo(explicit) + .map_err(|e| ErrorData::invalid_request(e.to_string(), None)) + } +} + +/// Assemble the router from the requested toolsets, then drop the writers if +/// `--read-only` was passed. +/// +/// An unknown toolset name is an error rather than a silent no-op: a typo in an +/// editor's config would otherwise show up as a server that mysteriously +/// offers fewer tools than expected. +fn build_router(toolsets: &[String], read_only: bool) -> Result> { + let selected: Vec = if toolsets.is_empty() { + TOOLSETS.iter().map(|name| name.to_string()).collect() + } else { + toolsets + .iter() + .map(|name| name.trim().to_ascii_lowercase()) + .collect() + }; + + let mut router = ToolRouter::new(); + for name in &selected { + let group = match name.as_str() { + "account" => SiMcpServer::account_router(), + "repo" => SiMcpServer::repo_router(), + "issue" => SiMcpServer::issue_router(), + "pr" => SiMcpServer::pr_router(), + "code" => SiMcpServer::code_router(), + "hook" => SiMcpServer::hook_router(), + "session" => SiMcpServer::session_router(), + "billing" => SiMcpServer::billing_router(), + other => { + return Err(anyhow!( + "Unknown toolset {other:?}. Expected one of: {}.", + TOOLSETS.join(", ") + )) + } + }; + router.merge(group); + } + + if read_only { + let writers: Vec = router + .list_all() + .into_iter() + .filter(|tool| { + tool.annotations + .as_ref() + .and_then(|annotations| annotations.read_only_hint) + != Some(true) + }) + .map(|tool| tool.name.to_string()) + .collect(); + + for name in writers { + router.remove_route(&name); + } + } + + Ok(router) +} + +/// Run the MCP server over stdio until the client disconnects. +pub async fn run(ctx: Ctx, args: McpArgs) -> Result<()> { + let router = build_router(&args.toolsets, args.read_only)?; + tracing::info!(tools = router.list_all().len(), "starting the MCP server"); + + let running = SiMcpServer::new(ctx, router) + .serve(stdio()) + .await + .map_err(|e| anyhow!("Could not start the MCP server: {e}"))?; + + running + .waiting() + .await + .map_err(|e| anyhow!("The MCP server stopped unexpectedly: {e}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tool_names(toolsets: &[String], read_only: bool) -> Vec { + build_router(toolsets, read_only) + .unwrap() + .list_all() + .into_iter() + .map(|tool| tool.name.to_string()) + .collect() + } + + #[test] + fn the_default_router_spans_every_toolset() { + let names = tool_names(&[], false); + + for expected in [ + "whoami", + "list_repositories", + "list_issues", + "list_pull_requests", + "search_code", + "list_hooks", + "list_sessions", + "get_billing", + ] { + assert!( + names.contains(&expected.to_string()), + "missing {expected:?}" + ); + } + } + + #[test] + fn a_toolset_selection_leaves_the_rest_out() { + let names = tool_names(&["issue".to_string(), "pr".to_string()], false); + + assert!(names.contains(&"create_issue".to_string())); + assert!(names.contains(&"get_pull_request".to_string())); + assert!(!names.contains(&"get_billing".to_string())); + assert!(!names.contains(&"list_sessions".to_string())); + } + + #[test] + fn read_only_mode_drops_every_writer() { + let names = tool_names(&[], true); + + assert!(names.contains(&"list_issues".to_string())); + assert!(names.contains(&"get_file_contents".to_string())); + + for writer in [ + "create_issue", + "create_pull_request", + "add_issue_comment", + "create_repository", + "create_hook", + "delete_hook", + "create_session", + "delete_session", + ] { + assert!( + !names.contains(&writer.to_string()), + "read-only mode should have dropped {writer:?}" + ); + } + } + + #[test] + fn an_unknown_toolset_is_rejected() { + let error = build_router(&["repo".to_string(), "nope".to_string()], false).unwrap_err(); + assert!(error.to_string().contains("nope")); + } + + #[test] + fn toolset_names_are_case_and_space_insensitive() { + assert_eq!( + tool_names(&[" Billing ".to_string()], false), + vec!["get_billing".to_string()] + ); + } +} diff --git a/crates/sigit-si-api/src/models.rs b/crates/sigit-si-api/src/models.rs index 1112eb4..2d2b4d8 100644 --- a/crates/sigit-si-api/src/models.rs +++ b/crates/sigit-si-api/src/models.rs @@ -104,7 +104,7 @@ pub struct CloudMessage { } /// `POST /api/v1/sessions/:id/messages` -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct AppendedMessage { pub session: CloudSession, pub message: CloudMessage, diff --git a/server-sigit.json b/server-sigit.json new file mode 100644 index 0000000..d093554 --- /dev/null +++ b/server-sigit.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "si.sigit/sigit", + "title": "siGit", + "description": "MCP server for siGit (sigit.si): browse repos, search code, manage PRs/issues, web search.", + "version": "1.2.5", + "websiteUrl": "https://sigit.si", + "repository": { + "url": "https://github.com/getsigit/si", + "source": "github" + }, + "remotes": [ + { + "type": "streamable-http", + "url": "https://sigit.si/api/v1/mcp" + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "categories": ["developer-tools", "version-control", "ai-agents"], + "keywords": [ + "git", + "git hosting", + "mcp server", + "model context protocol", + "code search", + "pull requests", + "issues", + "repositories", + "web search", + "coding agent", + "ai coding agent", + "ai agent tools", + "developer tools", + "software development" + ] + } + } +} diff --git a/server.json b/server.json new file mode 100644 index 0000000..9d5a11b --- /dev/null +++ b/server.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "si.sigit/cli", + "title": "siGit CLI", + "description": "Run sigit.si locally: browse repos, search code, manage PRs, issues, and Cloud Sessions.", + "version": "1.1.0", + "websiteUrl": "https://github.com/getsigit/si", + "repository": { + "url": "https://github.com/getsigit/si", + "source": "github" + }, + "packages": [ + { + "registryType": "cargo", + "registryBaseUrl": "https://crates.io", + "identifier": "sigit-si-cli", + "version": "1.1.0", + "transport": { + "type": "stdio" + }, + "packageArguments": [ + { + "type": "positional", + "value": "mcp", + "valueHint": "mcp", + "description": "Serve the CLI's commands as MCP tools over stdio instead of running one of them.", + "isRequired": true + }, + { + "type": "named", + "name": "--read-only", + "description": "Drop every tool that writes, leaving only the ones that read.", + "isRequired": false + }, + { + "type": "named", + "name": "--toolsets", + "description": "Comma-separated groups to expose, out of account, repo, issue, pr, code, hook, session, and billing. Defaults to all of them.", + "isRequired": false + } + ], + "environmentVariables": [ + { + "name": "SIGIT_TOKEN", + "description": "sigit.si session token. Optional: the server otherwise reuses whatever `si auth login` stored.", + "isRequired": false, + "isSecret": true, + "format": "string" + }, + { + "name": "SIGIT_TOOLSETS", + "description": "The environment form of --toolsets, for clients that pass configuration as variables rather than arguments.", + "isRequired": false, + "format": "string" + } + ] + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "categories": ["developer-tools", "version-control", "ai-agents"], + "keywords": [ + "git", + "git hosting", + "cli", + "mcp server", + "model context protocol", + "code search", + "pull requests", + "issues", + "repositories", + "coding agent", + "ai coding agent", + "ai agent tools", + "developer tools", + "software development" + ] + } + } +}