From 5095bf9234399b1eaa1e49fd33236020d0da7da4 Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Mon, 10 Aug 2026 14:02:59 +0700 Subject: [PATCH] Implement to support with claude desktop --- Cargo.lock | 329 +++++++++-- README.md | 49 +- crates/codegraph-mcp/Cargo.toml | 5 + crates/codegraph-mcp/src/http.rs | 24 + crates/codegraph-mcp/src/lib.rs | 362 +++++++----- crates/codegraph-mcp/src/protocol.rs | 48 -- .../codegraph-mcp/src/server-instructions.md | 25 +- crates/codegraph-mcp/src/session.rs | 246 ++++++++ crates/codegraph-mcp/src/stdio.rs | 24 + crates/codegraph-mcp/src/tools.rs | 110 ++-- crates/codegraph/Cargo.toml | 7 - crates/codegraph/src/main.rs | 555 +++--------------- 12 files changed, 965 insertions(+), 819 deletions(-) create mode 100644 crates/codegraph-mcp/src/http.rs delete mode 100644 crates/codegraph-mcp/src/protocol.rs create mode 100644 crates/codegraph-mcp/src/session.rs create mode 100644 crates/codegraph-mcp/src/stdio.rs diff --git a/Cargo.lock b/Cargo.lock index 4bd4715b6..01248d3d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,6 +31,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anes" version = "0.1.6" @@ -157,6 +166,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 = "bincode" version = "1.3.3" @@ -257,6 +272,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -332,16 +359,9 @@ dependencies = [ "anyhow", "camino", "clap", - "codegraph-context", - "codegraph-core", "codegraph-extract", "codegraph-graph", - "codegraph-installer", "codegraph-mcp", - "codegraph-sboxes", - "console 0.15.11", - "dialoguer", - "dirs", "ignore", "indicatif", "notify", @@ -491,6 +511,7 @@ dependencies = [ "codegraph-extract", "codegraph-graph", "codegraph-sboxes", + "rmcp", "serde", "serde_json", "tempfile", @@ -606,19 +627,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width", - "windows-sys 0.59.0", -] - [[package]] name = "console" version = "0.16.4" @@ -651,6 +659,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -895,6 +909,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + +[[package]] +name = "darling_macro" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.3", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -909,19 +957,6 @@ dependencies = [ "parking_lot_core", ] -[[package]] -name = "dialoguer" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" -dependencies = [ - "console 0.15.11", - "shell-words", - "tempfile", - "thiserror 1.0.69", - "zeroize", -] - [[package]] name = "digest" version = "0.10.7" @@ -970,6 +1005,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.16.0" @@ -1113,6 +1154,7 @@ checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -1163,6 +1205,17 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -1181,8 +1234,10 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -1339,6 +1394,30 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1427,6 +1506,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[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" @@ -1482,7 +1567,7 @@ version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ - "console 0.16.4", + "console", "portable-atomic", "unicode-width", "unit-prefix", @@ -1876,6 +1961,12 @@ dependencies = [ "windows-link", ] +[[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" @@ -2042,6 +2133,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "regalloc2" version = "0.11.2" @@ -2126,6 +2237,41 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "rmcp" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8dddc5b1924b9a59fba420166160ca2c4663a4e01803e52eda33070f56d63c8" +dependencies = [ + "base64 0.23.1", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6898e24cd16342b59bfa8a53c2c04b9cf62fc8a2cfea57b9c038b09984bfc521" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.3", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -2180,6 +2326,32 @@ dependencies = [ "winapi-util", ] +[[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 = "scopeguard" version = "1.2.0" @@ -2222,6 +2394,17 @@ dependencies = [ "syn 2.0.117", ] +[[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.150" @@ -2283,12 +2466,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - [[package]] name = "shlex" version = "1.3.0" @@ -2360,7 +2537,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "crc", "crossbeam-queue", @@ -2994,6 +3171,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3178,12 +3366,65 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -3600,12 +3841,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - [[package]] name = "zerotrie" version = "0.2.4" diff --git a/README.md b/README.md index 9c0330c73..022517d96 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ Agents that consult the semantic graph instead of grepping the filesystem make * - **Fast.** Full re-index a 139-file project in ~190 ms (release, parallel rayon). - **Local.** Index lives in `.codegraph/db.sqlite` next to your code. Nothing leaves the machine. - **Full re-index always.** No incremental sync — watcher debounces and re-indexes completely (simpler, no stale state). -- **Multi-agent.** A single `codegraph install` configures Claude Code, Cursor, Codex, opencode, Hermes and Antigravity CLI in one go. -- **11 MCP tools** including `codegraph_flow` (call chain), `codegraph_search_flow` (pattern search), `codegraph_references` (library call consumers). +- **Multi-agent.** One binary serves any MCP client (Claude Code, Cursor, Codex, opencode, Hermes, Antigravity) over stdio — the agent binds the workspace with `codegraph_init` and drives everything through tools. +- **30 MCP tools** including `codegraph_flow` (call chain), `codegraph_search_flow` (pattern search), `codegraph_references` (library call consumers), `codegraph_diff` (MR impact draft), and a behavior sandbox (`codegraph_sandbox`). ## Install @@ -90,35 +90,33 @@ cargo install --git https://github.com/Cleboost/codegraph-rs codegraph ## Quick start ```sh -# 1. Init, index, and configure your agents in one step +# 1. Init and index your project cd ~/code/my-project codegraph init -# 2. Use it -codegraph query UserService -codegraph context "auth middleware" +# 2. Serve it to your agent (Claude Code, Cursor, ...) over MCP +codegraph serve --mcp ``` -Your agent now has tools like `codegraph_search`, `codegraph_symbol`, `codegraph_callers`, `codegraph_flow`, `codegraph_search_flow`, `codegraph_impact`, `codegraph_context` available over MCP. The file watcher debounces changes and triggers full re-indexes while you edit. +The agent then binds the workspace with `codegraph_init {"path": ...}` and gets +tools like `codegraph_search`, `codegraph_symbol`, `codegraph_callers`, +`codegraph_flow`, `codegraph_search_flow`, `codegraph_impact`, +`codegraph_context` — all querying is done **over MCP**, not via CLI commands. +The file watcher debounces changes and triggers full re-indexes while you edit. ## CLI reference +The CLI is deliberately minimal — it only manages the workspace lifecycle and +runs the MCP server. All reading/interacting goes through MCP tools. + | Command | What it does | |---|---| -| `codegraph init [--no-index]` | Create `.codegraph/`, full re-index, and configure agents; `--no-index` skips indexing | -| `codegraph uninit` | Remove `.codegraph/` | -| `codegraph index` | **Full re-index** of the workspace (reset → parse all → ingest) | -| `codegraph status` | Show counts (symbols, chains, edges, files), no schema version | -| `codegraph query ` | Substring search across symbol names (case-insensitive) | -| `codegraph files [path]` | List indexed files under a prefix | -| `codegraph context ` | Build markdown context (symbol + callers + callees + optional source) | +| `codegraph init [--no-index]` | Create `.codegraph/` and full re-index (skip with `--no-index`) | +| `codegraph deinit` | Remove `.codegraph/` | | `codegraph serve --mcp` | Run as MCP server over stdio (used by agents) | -| `codegraph visualize` | Local web UI (2D/3D graph + table) at `http://127.0.0.1:7421` | Global flag `--path ` overrides the workspace root. -`visualize` is enabled by default. For a slimmer binary without the embedded web UI: `cargo build -p codegraph --no-default-features`. - ## Supported languages 14 languages with full tree-sitter extraction + marker/chain walkers: @@ -133,7 +131,10 @@ Each language emits: ## MCP tools -Agents see **11 tools** through the MCP server: +Agents see **30 tools** through the MCP server (search, callers/callees/impact/ +flow, class queries, annotations, dependencies, diff draft/simulation, behavior +sandbox, usage report, plus the session tools `codegraph_init` / +`codegraph_deinit` / `codegraph_index`). Key ones: | Tool | Use case | |---|---| @@ -148,6 +149,10 @@ Agents see **11 tools** through the MCP server: | `codegraph_references` | Functions that call a library call matching `query` (includes unresolved external calls) | | `codegraph_files` | List indexed files under a path prefix | | `codegraph_status` | Index health: symbol/chain/edge/file counts | +| `codegraph_init` | Bind the session to a workspace root (non-blocking, does **not** index by default) | +| `codegraph_index` | Full re-index of the bound workspace | +| `codegraph_sandbox` | Compile a function group to machine code and run it against Rhai mocks | +| `codegraph_diff` | Draft report of what an MR/patch would change in the graph | Read the [server instructions](crates/codegraph-mcp/src/server-instructions.md) that ship with the binary — they tell your agent when to reach for which tool. @@ -182,9 +187,9 @@ crates/ codegraph-graph/ GraphIndex (semgraph): registry + 2 engines (chain Search + name Search) + sqlite storage codegraph-context/ Markdown/JSON context formatter (symbol + callers + callees + source) codegraph-api/ GraphApi wrapper on SharedGraphIndex (async query surface) - codegraph-mcp/ Hand-rolled JSON-RPC 2.0 server (stdio) + 11 tool dispatch + codegraph-mcp/ MCP server on the rmcp SDK (stdio) + 30-tool dispatch, session-driven codegraph-installer/ Agent config targets (Claude/Cursor/Codex/opencode/Hermes) - codegraph/ CLI (clap) + watcher (notify + debounced full re-index) + codegraph/ CLI lifecycle (init/deinit/serve --mcp) + watcher (notify + debounced full re-index) ``` Pipeline: @@ -203,7 +208,7 @@ files → ignore::WalkBuilder → rayon parse pool (tree-sitter, 14 langs) ↓ GraphApi / SharedGraphIndex.ensure_fresh() (version probe) ↓ - MCP server / CLI commands / Web UI + MCP server / CLI lifecycle ``` ## Configuration @@ -264,7 +269,7 @@ Override in `.codegraph/config.toml`: headers = "auto" # "auto" (default), "c", or "cpp" ``` -After changing this setting, run `codegraph index` to re-index headers. +After changing this setting, run `codegraph init` (or call `codegraph_index` over MCP) to re-index headers. ## Why Rust? diff --git a/crates/codegraph-mcp/Cargo.toml b/crates/codegraph-mcp/Cargo.toml index 587dfde82..af9647698 100644 --- a/crates/codegraph-mcp/Cargo.toml +++ b/crates/codegraph-mcp/Cargo.toml @@ -5,6 +5,10 @@ edition.workspace = true license.workspace = true repository.workspace = true +[features] +# Luồng HTTP MCP riêng (session theo mcp-session-id) — chưa implement, xem src/http.rs. +http = [] + [dependencies] codegraph-api = { path = "../codegraph-api" } codegraph-core = { path = "../codegraph-core" } @@ -18,6 +22,7 @@ tokio = { workspace = true } tracing = { workspace = true } anyhow = { workspace = true } camino = { workspace = true } +rmcp = { version = "3.1.2", features = ["transport-io"] } [dev-dependencies] tempfile = "3" diff --git a/crates/codegraph-mcp/src/http.rs b/crates/codegraph-mcp/src/http.rs new file mode 100644 index 000000000..3af1a08e0 --- /dev/null +++ b/crates/codegraph-mcp/src/http.rs @@ -0,0 +1,24 @@ +//! Transport HTTP cho MCP server — **luồng riêng, chưa implement** (stub). +//! +//! Với HTTP session KHÔNG đi theo process: mỗi kết nối được xác định bằng +//! `mcp-session-id` header và session store quản lý MỘT session PER KẾT NỐI +//! (cùng lúc nhiều phiên khác nhau, khác root, không chia sẻ gì ngoài process). +//! +//! Khi làm sẽ dùng rmcp feature `transport-streamable-http-server` (tower/ +//! axum) + một `SessionStore` map `session_id -> Session`, và cần chỉnh +//! `codegraph serve --mcp --http` để mount server này thay vì stdio. Cấu trúc +//! module đã tách sẵn ở đây để không nhiễu vòng đời process-bound của stdio. + +/// Entry điểm cho luồng HTTP (tương lai). Không bật mặc định — cần feature +/// `http` + `transport-streamable-http-server`; hiện tại chỉ báo chưa làm. +/// +/// # Panics +/// Không có — trả `Err` rõ ràng để `codegraph serve --mcp --http` fail với +/// message giải thích thay vì chạy nhầm sang stdio. +#[cfg(feature = "http")] +pub async fn serve_http(_service: S) -> anyhow::Result<()> { + anyhow::bail!( + "codegraph MCP http transport chưa được implement — đây là luồng riêng \ + (session theo mcp-session-id). Dùng `--mcp` (stdio) trước." + ) +} diff --git a/crates/codegraph-mcp/src/lib.rs b/crates/codegraph-mcp/src/lib.rs index e2c73b128..a9a86ab59 100644 --- a/crates/codegraph-mcp/src/lib.rs +++ b/crates/codegraph-mcp/src/lib.rs @@ -1,107 +1,71 @@ -//! MCP server (stdio JSON-RPC 2.0). Hand-rolled, no SDK. +//! MCP server on the `rmcp` SDK. +//! +//! Server start lên rồi **quản lý theo session**: với transport stdio mỗi tiến +//! trình host đúng **1 session slot** — agent gọi `codegraph_init {"path": ...}` +//! để bind session vào workspace root, `codegraph_deinit` để nhả. Mọi tool khác +//! chạy qua session (chưa bind/init → refuse). `--path` lúc khởi động là +//! pre-seed, không bắt buộc. +//! +//! Hai transport module: [`stdio`] (luồng chính, 1 process = 1 session cố định) +//! và [`http`] (luồng riêng — stub, sẽ quản lý session theo session-id header). -mod protocol; +pub mod http; +mod session; +pub mod stdio; mod tools; mod usage; -pub use protocol::{ErrorObj, JsonRpcMessage, Response}; -pub use tools::tool_definitions; +pub use session::{InitOutcome, Session}; +pub use stdio::serve_stdio; -use codegraph_graph::SharedGraphIndex; -use serde_json::{json, Value}; +use std::future::Future; use std::sync::{Arc, Mutex}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use codegraph_api::GraphApi; +use rmcp::handler::server::ServerHandler; +use rmcp::model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation, + ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, +}; +use rmcp::service::{MaybeSendFuture, RequestContext}; +use rmcp::{ErrorData as McpError, RoleServer}; +use serde_json::{json, Value}; + +/// Hướng dẫn sử dụng tools — client render trong instructions sau `initialize`. pub const SERVER_INSTRUCTIONS: &str = include_str!("server-instructions.md"); -pub const PROTOCOL_VERSION: &str = "2024-11-05"; pub const SERVER_NAME: &str = "codegraph"; pub const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); -pub struct McpServer { - /// Workspace root — dùng cho admin tools (`codegraph_init` / `codegraph_index`). - root: camino::Utf8PathBuf, - shared_index: Arc, - /// Telemetry cho `codegraph_query_usage_report`. +/// Server MCP. Transport-agnostic: stdio (1 process = 1 session) mount trực +/// tiếp, http (tương lai) sẽ xoay vòng session store riêng. +pub struct CodegraphServer { + session: Session, usage: Arc>, } -impl McpServer { - pub async fn new(root: camino::Utf8PathBuf, dsn: Option) -> anyhow::Result { - let shared_index = Arc::new(SharedGraphIndex::open(dsn).await?); - Ok(Self { - root, - shared_index, +impl CodegraphServer { + /// Server với session trống — `codegraph_init` sẽ bind root trong phiên. + pub fn new() -> Self { + Self { + session: Session::new(), usage: Arc::new(Mutex::new(usage::UsageStats::default())), - }) - } - - pub async fn run_stdio(self) -> anyhow::Result<()> { - let stdin = tokio::io::stdin(); - let mut reader = BufReader::new(stdin); - let mut stdout = tokio::io::stdout(); - let mut line = String::new(); - - loop { - line.clear(); - let n = reader.read_line(&mut line).await?; - if n == 0 { - break; - } - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - - let msg: JsonRpcMessage = match serde_json::from_str(trimmed) { - Ok(m) => m, - Err(e) => { - write_response( - &mut stdout, - Response::error(Value::Null, -32700, &format!("parse error: {e}")), - ) - .await?; - continue; - } - }; - if msg.id.is_none() { - // notification — no response - continue; - } - let id = msg.id.clone().unwrap_or(Value::Null); - let resp = self.dispatch(msg).await; - let final_resp = match resp { - Ok(v) => Response::ok(id, v), - Err(e) => Response::error(id, -32603, &e.to_string()), - }; - write_response(&mut stdout, final_resp).await?; } - Ok(()) } - async fn dispatch(&self, msg: JsonRpcMessage) -> anyhow::Result { - match msg.method.as_deref() { - Some("initialize") => Ok(json!({ - "protocolVersion": PROTOCOL_VERSION, - "capabilities": { "tools": {} }, - "serverInfo": { "name": SERVER_NAME, "version": SERVER_VERSION }, - "instructions": SERVER_INSTRUCTIONS, - })), - Some("ping") => Ok(json!({})), - Some("tools/list") => Ok(json!({ "tools": tool_definitions() })), - Some("tools/call") => { - self.handle_tool_call(msg.params.unwrap_or(Value::Null)) - .await - } - Some(m) => Err(anyhow::anyhow!("method not found: {m}")), - None => Err(anyhow::anyhow!("missing method")), - } + /// Pre-seed root từ `--path` lúc khởi động (tương đương đã `codegraph_init` + /// với root đó, không index thêm). Giữ CLI/watcher flow không vỡ. + pub async fn with_root(root: camino::Utf8PathBuf) -> anyhow::Result { + Ok(Self { + session: Session::with_root(root).await?, + usage: Arc::new(Mutex::new(usage::UsageStats::default())), + }) } - async fn handle_tool_call(&self, params: Value) -> anyhow::Result { - let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); - let args = params.get("arguments").cloned().unwrap_or(Value::Null); - - // Telemetry tool — đọc/ghi trực tiếp từ usage stats, không qua GraphApi. + /// Dispatch một tool call đã verify tên. Trả [`ToolOutput::Text`] cho thành + /// công, [`ToolOutput::Error`] cho lỗi tool (client thấy `is_error`), + /// [`Err`] cho lỗi protocol (unknown tool đã bị chặn trước ở `call_tool`). + async fn run_tool(&self, name: &str, args: Value) -> Result { + // ── Telemetry — không cần session ── if name == "codegraph_query_usage_report" { let reset = args.get("reset").and_then(|v| v.as_bool()).unwrap_or(false); let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(0) as usize; @@ -110,63 +74,191 @@ impl McpServer { if reset { u.reset(); } - let text = serde_json::to_string_pretty(&report)?; - return Ok(json!({ - "content": [{ "type": "text", "text": text }], - "isError": false, - })); + drop(u); + let text = serde_json::to_string_pretty(&report).map_err(|e| { + McpError::internal_error( + "usage report failed", + Some(json!({"reason": e.to_string()})), + ) + })?; + return Ok(ToolOutput::Text { + text, + source_bytes: 0, + }); } - let api = codegraph_api::GraphApi::new_with_index(self.shared_index.clone()); - // Admin tools (init/index) cần workspace root; sandbox cần root (config + - // mock dirs) + snapshot index — dispatch riêng, không qua GraphApi. - let dispatch = if name == "codegraph_init" || name == "codegraph_index" { - tools::dispatch_admin(&self.root, name, args.clone()).await - } else if name == "codegraph_sandbox" { - tools::dispatch_sandbox(&self.root, self.shared_index.clone(), args.clone()).await - } else if name == "codegraph_diff" { - tools::dispatch_diff(&self.root, self.shared_index.clone(), args.clone()).await - } else if name == "codegraph_diff_simulate" { - tools::dispatch_diff_simulate(&self.root, self.shared_index.clone(), args.clone()).await - } else if name == "codegraph_origin_simulate" { - tools::dispatch_origin_simulate(&self.root, self.shared_index.clone(), args.clone()) - .await - } else { - tools::dispatch_with_api(&api, name, args).await - }; - let text = match dispatch { - Ok(t) => t, - Err(e) => { - self.usage - .lock() - .unwrap() - .record(name, e.to_string().len() as u64, 0, true); - return Err(anyhow::Error::from(e)); + // ── Admin / session lifecycle ── + match name { + "codegraph_init" => { + let Some(path) = args.get("path").and_then(|v| v.as_str()) else { + return Ok(ToolOutput::Error( + "codegraph_init requires `path` — the workspace root to bind this session to, \ + e.g. {\"path\": \"/abs/path/to/project\"}. \ + To index immediately pass {\"path\": ..., \"index\": true}, \ + otherwise call codegraph_index {} afterwards." + .into(), + )); + }; + // Default = KHÔNG index — bind nhanh, không block user. Agent muốn + // data thì chủ động gọi codegraph_index {} (hoặc truyền index=true). + let do_index = args.get("index").and_then(|v| v.as_bool()).unwrap_or(false); + return match self + .session + .init(camino::Utf8PathBuf::from(path), do_index) + .await + { + Ok(out) => { + let mut v = + json!({ "root": out.root.as_str(), "initialized": out.dir.as_str() }); + if let Some(stats) = &out.indexed { + v["indexed"] = session::stats_json(stats); + } + Ok(ToolOutput::json(&v)) + } + Err(e) => Ok(ToolOutput::Error(e.to_string())), + }; } + "codegraph_deinit" => { + return match self.session.deinit().await { + Ok(prev) => { + let v = json!({ + "deinitialized": true, + "root": prev.map(|p| Value::String(p.into_string())).unwrap_or(Value::Null), + }); + Ok(ToolOutput::json(&v)) + } + Err(e) => Ok(ToolOutput::Error(e.to_string())), + }; + } + "codegraph_index" => { + return match self.session.reindex().await { + Ok(stats) => { + let v = session::stats_json(&stats); + Ok(ToolOutput::json(&v)) + } + Err(e) => Ok(ToolOutput::Error(e.to_string())), + }; + } + _ => {} + } + + // ── Query tools — cần session ready ── + let sgi = match self.session.ensure_ready().await { + Ok(sgi) => sgi, + Err(e) => return Ok(ToolOutput::Error(e.to_string())), + }; + let api = GraphApi::new_with_index(sgi.clone()); + // ensure_ready chỉ Ok khi session có root — đây chỉ là phòng hờ. + let Some(root) = self.session.root().await else { + return Ok(ToolOutput::Error("session root unavailable".into())); }; - // Ước lượng source bytes mà answer "thay thế" (file refs trong answer). - let source_bytes = match serde_json::from_str::(&text) { - Ok(v) => usage::estimate_source_bytes(&api, &v).await, - Err(_) => 0, + + let dispatch = match name { + "codegraph_sandbox" => tools::dispatch_sandbox(&root, sgi.clone(), args.clone()).await, + "codegraph_diff" => tools::dispatch_diff(&root, sgi.clone(), args.clone()).await, + "codegraph_diff_simulate" => { + tools::dispatch_diff_simulate(&root, sgi.clone(), args.clone()).await + } + "codegraph_origin_simulate" => { + tools::dispatch_origin_simulate(&root, sgi.clone(), args.clone()).await + } + _ => tools::dispatch_with_api(&api, name, args).await, }; - self.usage - .lock() - .unwrap() - .record(name, text.len() as u64, source_bytes, false); - Ok(json!({ - "content": [{ "type": "text", "text": text }], - "isError": false, - })) + + match dispatch { + Ok(text) => { + // Ước lượng source bytes mà answer "thay thế" (file refs trong answer). + let source_bytes = match serde_json::from_str::(&text) { + Ok(v) => usage::estimate_source_bytes(&api, &v).await, + Err(_) => 0, + }; + Ok(ToolOutput::Text { text, source_bytes }) + } + Err(e) => Ok(ToolOutput::Error(e.to_string())), + } + } +} + +impl Default for CodegraphServer { + fn default() -> Self { + Self::new() + } +} + +/// Kết quả `run_tool` — phân biệt thành công / lỗi tool (client-visible) / +/// lỗi protocol (không dùng ở đây, `call_tool` trả Err trực tiếp). +enum ToolOutput { + Text { text: String, source_bytes: u64 }, + Error(String), +} + +impl ToolOutput { + fn json(v: &Value) -> Self { + match serde_json::to_string_pretty(v) { + Ok(text) => ToolOutput::Text { + text, + source_bytes: 0, + }, + Err(e) => ToolOutput::Error(format!("serialize response: {e}")), + } } } -async fn write_response( - w: &mut W, - r: Response, -) -> anyhow::Result<()> { - let s = serde_json::to_string(&r)?; - w.write_all(s.as_bytes()).await?; - w.write_all(b"\n").await?; - w.flush().await?; - Ok(()) +impl ServerHandler for CodegraphServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new(SERVER_NAME, SERVER_VERSION)) + .with_instructions(SERVER_INSTRUCTIONS.to_string()) + } + + fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + async move { + Ok(ListToolsResult { + tools: tools::rmcp_tools(), + ..Default::default() + }) + } + } + + fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + async move { + let name = request.name.as_ref(); + let args = request.arguments.map(Value::Object).unwrap_or(Value::Null); + + // Tên tool không tồn tại → protocol error (client thấy lỗi JSON-RPC + // method-not-found, không thấy một "tool ảo"). Chặn sớm trước khi + // chạy vào run_tool để không cho nhầm tool lạ chạy nhánh `_`. + if !tools::is_known_tool(name) { + return Err(McpError::method_not_found::< + rmcp::model::CallToolRequestMethod, + >()); + } + + match self.run_tool(name, args).await { + Ok(ToolOutput::Text { text, source_bytes }) => { + self.usage + .lock() + .unwrap() + .record(name, text.len() as u64, source_bytes, false); + Ok(CallToolResult::success(vec![ContentBlock::text(text)]).into()) + } + Ok(ToolOutput::Error(msg)) => { + self.usage + .lock() + .unwrap() + .record(name, msg.len() as u64, 0, true); + Ok(CallToolResult::error(vec![ContentBlock::text(msg)]).into()) + } + Err(e) => Err(e), + } + } + } } diff --git a/crates/codegraph-mcp/src/protocol.rs b/crates/codegraph-mcp/src/protocol.rs deleted file mode 100644 index 7a36a8b1e..000000000 --- a/crates/codegraph-mcp/src/protocol.rs +++ /dev/null @@ -1,48 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Debug, Clone, Deserialize)] -pub struct JsonRpcMessage { - pub jsonrpc: Option, - pub id: Option, - pub method: Option, - pub params: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct Response { - pub jsonrpc: &'static str, - pub id: Value, - #[serde(skip_serializing_if = "Option::is_none")] - pub result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ErrorObj { - pub code: i32, - pub message: String, -} - -impl Response { - pub fn ok(id: Value, result: Value) -> Self { - Self { - jsonrpc: "2.0", - id, - result: Some(result), - error: None, - } - } - pub fn error(id: Value, code: i32, message: &str) -> Self { - Self { - jsonrpc: "2.0", - id, - result: None, - error: Some(ErrorObj { - code, - message: message.into(), - }), - } - } -} diff --git a/crates/codegraph-mcp/src/server-instructions.md b/crates/codegraph-mcp/src/server-instructions.md index 435ab070e..ee383b7cd 100644 --- a/crates/codegraph-mcp/src/server-instructions.md +++ b/crates/codegraph-mcp/src/server-instructions.md @@ -4,6 +4,26 @@ Codegraph is a SQLite semantic graph of every symbol (function/method/class/…) and its call chain in the workspace. Reads are sub-millisecond. Consult it BEFORE writing or editing code, not during. +## Session & workspace selection + +Codegraph MCP manages **one session per process**. Bind it to a workspace root +before querying: + +- `codegraph_init {"path": "/abs/path/to/project"}` — bind the session to + that root and create `.codegraph/` (idempotent) if missing. Binding is fast + and **non-blocking: it does NOT index by default** (`index` defaults to + `false`). After binding, call `codegraph_index {}` to build/refresh the + index (or pass `"index": true` to `codegraph_init` to index immediately). + Re-running with a different `path` re-points the session. +- `codegraph_deinit {}` — release the session (the `.codegraph/` and index files + stay on disk). An unbound session **refuses every query tool** until + `codegraph_init` binds it again. + +Start with `codegraph_init {"path": ...}` for the project you are working on, +then `codegraph_index {}` if the index is empty/stale (check +`codegraph_status`). The `--path` given at server startup, if any, is already +bound. + ## Answer directly — don't delegate exploration For "how does X work", architecture, trace, or where-is-X questions, answer @@ -33,8 +53,9 @@ file-reading sub-task repeats work codegraph already did. | "Show me this symbol by id / exact name." | `codegraph_symbol` | | "What's in directory X?" | `codegraph_files` | | "Is the index ready / what's its size?" | `codegraph_status` | -| "Set up / (re)build the index" | `codegraph_init` (idempotent; index=true by default) | -| "Re-index the workspace" | `codegraph_index` | +| "Bind the session to a project (creates .codegraph/, non-blocking — does NOT index by default)" | `codegraph_init` (`path` required; `index` defaults to `false`) | +| "Build/refresh the index for the bound session" | `codegraph_index` | +| "Release the current session" | `codegraph_deinit` | | "Run an entry function in the behavior sandbox" | `codegraph_sandbox` (per-function Rhai mocks) | | "Diff này (MR/patch/git diff) ảnh hưởng gì tới graph?" | `codegraph_diff` (read-only draft) | | "MR này đổi hành vi flow ra sao (trước vs sau)?" | `codegraph_diff_simulate` (sandbox before/after) | diff --git a/crates/codegraph-mcp/src/session.rs b/crates/codegraph-mcp/src/session.rs new file mode 100644 index 000000000..61c7e574a --- /dev/null +++ b/crates/codegraph-mcp/src/session.rs @@ -0,0 +1,246 @@ +//! Session — quản lý vòng đời index của MCP server. +//! +//! Server start lên rồi quản lý **theo session**. Với MCP transport stdio +//! (1 tiến trình = 1 kết nối) chỉ có đúng **1 session slot** cho mỗi process, +//! và đường dẫn workspace do AGENT chọn ngay trong phiên làm việc: +//! - `codegraph_init { "path": ... }` → bind session vào workspace root đó +//! (tạo `.codegraph/` + config, index tùy chọn) → session `Ready`; +//! - `codegraph_deinit {}` → nhả session (`root = None`), `.codegraph/` và +//! index để nguyên trên đĩa; mọi tool khác bị **refuse** cho tới khi +//! `codegraph_init` bind lại; +//! - `codegraph_index {}` → full re-index của session hiện tại. +//! +//! `--path` lúc khởi động là **pre-seed** (`with_root`): tương đương đã bind +//! sẵn root đó mà không cần tool call — giữ cho CLI/watcher flow cũ không vỡ. +//! Với luồng HTTP (tương lai) session không đi theo process — mỗi kết nối mang +//! `mcp-session-id` riêng và session store quản lý nhiều session song song. + +use anyhow::{anyhow, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use codegraph_extract::{init_project, project_dir, ExtractConfig, ExtractStats, Orchestrator}; +use codegraph_graph::{GraphIndex, SharedGraphIndex}; +use serde_json::{json, Value}; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Trạng thái session. +enum SessionState { + /// Chưa có root nào được bind (hoặc đã `codegraph_deinit`). + Empty, + /// Đã bind vào một workspace root, storage + index dùng chung sẵn sàng. + Ready { + dsn: Option, + shared_index: Arc, + }, +} + +/// Kết quả `codegraph_init` — root vừa bind + dir `.codegraph/` + stats nếu index. +pub struct InitOutcome { + pub root: Utf8PathBuf, + pub dir: Utf8PathBuf, + pub indexed: Option, +} + +/// Session của MCP server (stdio = 1 process = 1 session slot). +pub struct Session { + root: RwLock>, + state: RwLock, +} + +impl Default for Session { + fn default() -> Self { + Self::new() + } +} + +impl Session { + /// Session trống — chưa có root nào; `codegraph_init` sẽ bind. + pub fn new() -> Self { + Self { + root: RwLock::new(None), + state: RwLock::new(SessionState::Empty), + } + } + + /// Pre-seed root lúc khởi động (`--path`). Có `.codegraph/` → load storage + /// ngay (Ready); chưa init → Empty, chờ `codegraph_init` bind lại. + pub async fn with_root(root: Utf8PathBuf) -> Result { + let state = if project_dir(&root).exists() { + let dsn = ExtractConfig::load(&root).storage_dsn(&root); + let shared_index = Arc::new(SharedGraphIndex::open(dsn.clone()).await?); + SessionState::Ready { dsn, shared_index } + } else { + SessionState::Empty + }; + Ok(Self { + root: RwLock::new(Some(root)), + state: RwLock::new(state), + }) + } + + /// Root hiện tại, nếu có (không clone `&Utf8Path` khi root là Option trong + /// RwLock — clone an toàn cho await qua biên). + pub async fn root(&self) -> Option { + self.root.read().await.clone() + } + + /// Workspace hiện tại đã init chưa (có `.codegraph/` không). + pub async fn is_initialized(&self) -> bool { + self.root + .read() + .await + .as_deref() + .map(|r| project_dir(r).exists()) + .unwrap_or(false) + } + + /// `codegraph_init { path, index }`: normalize/validate path, bind root, + /// tạo `.codegraph/` + config, index CHỈ khi `do_index = true` (mặc định + /// không index — bind nhanh, không block user; agent chủ động gọi + /// `codegraph_index {}` khi cần data), rồi load storage theo config vừa + /// tạo → session chuyển sang `Ready`. + pub async fn init(&self, path: Utf8PathBuf, do_index: bool) -> Result { + let root = normalize_root(path)?; + let dir = init_project(&root)?; + let indexed = if do_index { + Some(run_index(&root).await?) + } else { + None + }; + + // Config giờ đã tồn tại → load đúng backend (sqlite/lmdb/redis/...). + let dsn = ExtractConfig::load(&root).storage_dsn(&root); + let shared_index = Arc::new(SharedGraphIndex::open(dsn.clone()).await?); + + // Root set trước state — mọi `ensure_ready` đồng thời đọc root mới sẽ + // tự swap state theo DSN mới (xem `ensure_ready`). + *self.root.write().await = Some(root.clone()); + let mut st = self.state.write().await; + *st = SessionState::Ready { dsn, shared_index }; + Ok(InitOutcome { root, dir, indexed }) + } + + /// `codegraph_deinit`: nhả session — trả root cũ (nếu có). `.codegraph/` + /// và index để nguyên trên đĩa; `codegraph_init` có thể bind lại sau đó. + pub async fn deinit(&self) -> Result> { + let prev = self.root.write().await.take(); + let mut st = self.state.write().await; + *st = SessionState::Empty; + Ok(prev) + } + + /// Index dùng chung — gọi trước mọi tool đọc. Chưa bind root / chưa init → + /// **refuse** với hướng dẫn gọi `codegraph_init`. Khi root đã init, đảm bảo + /// storage được load (swap nếu config đổi backend giữa chừng). + pub async fn ensure_ready(&self) -> Result> { + let root = match self.root.read().await.as_ref() { + Some(r) => r.clone(), + None => { + return Err(anyhow!( + "no session bound — call codegraph_init {{\"path\": \"/abs/path/to/project\"}} first" + )); + } + }; + if !project_dir(&root).exists() { + let mut st = self.state.write().await; + *st = SessionState::Empty; + return Err(anyhow!( + "workspace not initialized at {root} — no CodeGraph index. \ + Call codegraph_init (bind only, non-blocking) first, then \ + codegraph_index {{}} to build the index." + )); + } + let dsn = ExtractConfig::load(&root).storage_dsn(&root); + let mut st = self.state.write().await; + + // Root được init giữa chừng (vd sau khi init() lỗi part-way) → chuyển + // từ Empty sang Ready bằng cách load storage. + let was_empty = matches!(&*st, SessionState::Empty); + if was_empty { + let shared_index = Arc::new(SharedGraphIndex::open(dsn.clone()).await?); + *st = SessionState::Ready { dsn, shared_index }; + } else if let SessionState::Ready { + dsn: cur, + shared_index, + } = &mut *st + { + // Config đổi backend giữa chừng → load lại storage. + if *cur != dsn { + match SharedGraphIndex::open(dsn.clone()).await { + Ok(sgi) => { + *shared_index = Arc::new(sgi); + *cur = dsn; + } + Err(e) => eprintln!("[codegraph] open index for {dsn:?} failed: {e}"), + } + } + } + + match &*st { + SessionState::Ready { shared_index, .. } => Ok(shared_index.clone()), + SessionState::Empty => unreachable!("handled above"), + } + } + + /// `codegraph_index`: full re-index của session hiện tại — chỉ khi đã init. + pub async fn reindex(&self) -> Result { + let root = match self.root.read().await.as_ref() { + Some(r) => r.clone(), + None => { + return Err(anyhow!( + "no session bound — call codegraph_init {{\"path\": ...}} first" + )); + } + }; + if !project_dir(&root).exists() { + return Err(anyhow!( + "workspace not initialized: missing .codegraph/. Run codegraph_init first." + )); + } + run_index(&root).await + } +} + +/// Validate + canonicalize root: phải tồn tại, là directory, không phải `/` +/// (Claude Desktop launch MCP servers từ `/` — từ chối để khỏi index nhầm máy). +fn normalize_root(path: Utf8PathBuf) -> Result { + if !path.is_dir() { + return Err(anyhow!("path is not a directory: {}", path)); + } + let canon = std::fs::canonicalize(path.as_std_path()) + .map_err(|e| anyhow!("cannot resolve {}: {e}", path))?; + let canon = + Utf8PathBuf::from_path_buf(canon).map_err(|p| anyhow!("path is not valid UTF-8: {p:?}"))?; + if canon.as_str() == "/" { + return Err(anyhow!( + "refusing to use `/` as the workspace root \ + (MCP hosts may launch servers from `/`). Pass an absolute project path." + )); + } + Ok(canon) +} + +/// Full re-index: mở index theo backend config → `Orchestrator::index_all` +/// (ingest = full re-index, bump version → snapshot cũ bị `ensure_fresh` thấy +/// stale và rebuild ở lần query kế). +async fn run_index(root: &Utf8Path) -> Result { + let mut idx = match ExtractConfig::load(root).storage_dsn(root) { + Some(dsn) => GraphIndex::open(&dsn).await?, + None => GraphIndex::in_memory(), + }; + Orchestrator::with_registry() + .index_all(root, &mut idx, None) + .await + .map_err(Into::into) +} + +/// JSON thống kê index (dùng cho codegraph_init/codegraph_index response). +pub fn stats_json(s: &ExtractStats) -> Value { + json!({ + "files": s.files, + "symbols": s.symbols, + "chains": s.chains, + "calls": s.calls, + "skipped": s.skipped, + }) +} diff --git a/crates/codegraph-mcp/src/stdio.rs b/crates/codegraph-mcp/src/stdio.rs new file mode 100644 index 000000000..705e96775 --- /dev/null +++ b/crates/codegraph-mcp/src/stdio.rs @@ -0,0 +1,24 @@ +//! Transport stdio cho MCP server. +//! +//! Session đi theo process: một tiến trình = một kết nối = **một session slot** +//! cố định. Server không tự chọn đường dẫn — agent bind session bằng +//! `codegraph_init {"path": ...}` / nhả bằng `codegraph_deinit`. +//! +//! `serve_stdio` mount bất kỳ `ServerHandler` lên stdin/stdout qua +//! `rmcp::transport::io::stdio()` (transport-async-rw). Mọi JSON-RPC framing +//! đều do rmcp xử lý. + +use rmcp::ServiceExt; + +/// Serve `service` qua stdio tới khi kết nối kết thúc (client đóng stdin / +/// gửi shutdown). Lỗi transport (IO/handshake) trả về qua `anyhow`. +pub async fn serve_stdio(service: S) -> anyhow::Result<()> +where + S: rmcp::ServerHandler, +{ + service.serve(rmcp::transport::io::stdio()) + .await? + .waiting() + .await?; + Ok(()) +} diff --git a/crates/codegraph-mcp/src/tools.rs b/crates/codegraph-mcp/src/tools.rs index df81d91aa..cfea47e31 100644 --- a/crates/codegraph-mcp/src/tools.rs +++ b/crates/codegraph-mcp/src/tools.rs @@ -2,13 +2,39 @@ use camino::{Utf8Path, Utf8PathBuf}; use codegraph_api::GraphApi; use codegraph_context::{ContextRequest, Format}; use codegraph_core::{is_marker, Error, Result, Symbol, SymbolKind, SymbolMatch}; -use codegraph_extract::{init_project, project_dir, ExtractConfig, ExtractStats, Orchestrator}; +use codegraph_extract::Orchestrator; use codegraph_graph::{GraphIndex, SharedGraphIndex}; use codegraph_sboxes::{compile_with_mocks, BranchPolicy, SboxConfig}; +use rmcp::model::Tool; use serde_json::{json, Value}; use std::sync::Arc; -pub fn tool_definitions() -> Vec { +/// Định nghĩa một MCP tool — single source of truth cho `tools/list`. +struct ToolDef { + name: &'static str, + desc: &'static str, + schema: Value, +} + +fn tool(name: &'static str, desc: &'static str, schema: Value) -> ToolDef { + ToolDef { name, desc, schema } +} + +/// `tools/list` payload — chuyển mọi định nghĩa ở trên qua `rmcp::model::Tool`. +pub fn rmcp_tools() -> Vec { + tool_defs() + .into_iter() + .map(|d| Tool::new(d.name, d.desc, Arc::new(rmcp::model::object(d.schema)))) + .collect() +} + +/// Tool name có tồn tại trong danh sách không — phân biệt protocol error +/// (unknown tool → `method_not_found`) với tool error (client-visible). +pub fn is_known_tool(name: &str) -> bool { + tool_defs().iter().any(|d| d.name == name) +} + +fn tool_defs() -> Vec { vec![ tool( "codegraph_search", @@ -91,13 +117,19 @@ pub fn tool_definitions() -> Vec { "Index health: symbol / chain / edge / file counts.", json!({ "type": "object", "properties": {} }), ), - // ── Admin tools (init / index) — thao tác trên workspace root của server ── + // ── Admin tools (init / deinit / index) — thao tác trên session slot ── tool( "codegraph_init", - "Initialize the workspace for CodeGraph (idempotent): creates .codegraph/ with .gitignore, version, and config.toml. Pass index=false to skip the full re-index that runs by default.", + "Bind this MCP session to a workspace root (idempotent): creates .codegraph/ with .gitignore, version, and config.toml. Pass path (absolute workspace root) to select the directory for this session. index defaults to false — binding is quick and non-blocking (it does NOT index); call codegraph_index {} afterwards (or pass index=true here) only when you need a fresh index to query. Re-running with a different path re-points the session.", json!({ "type": "object", "properties": { - "index": { "type": "boolean", "default": true } - } }), + "path": { "type": "string", "description": "Absolute path of the workspace root to bind this session to." }, + "index": { "type": "boolean", "default": false } + }, "required": ["path"] }), + ), + tool( + "codegraph_deinit", + "Release this MCP session: unbind the current workspace root (root becomes null). The .codegraph/ directory and index stay on disk — call codegraph_init with a path again to re-bind. Every query tool refuses to run while the session is unbound.", + json!({ "type": "object", "properties": {} }), ), tool( "codegraph_index", @@ -239,10 +271,6 @@ pub fn tool_definitions() -> Vec { ] } -fn tool(name: &str, desc: &str, schema: Value) -> Value { - json!({ "name": name, "description": desc, "inputSchema": schema }) -} - pub async fn dispatch_with_api(api: &GraphApi, name: &str, args: Value) -> Result { match name { "codegraph_search" => { @@ -596,68 +624,6 @@ fn arg_u64(v: &Value, k: &str) -> Result { .ok_or_else(|| Error::Invalid(format!("missing int arg: {k}"))) } -// ── Admin tools (codegraph_init / codegraph_index) ── -// Cần workspace root (không qua GraphApi) — server lưu `root` và gọi hàm này. - -pub async fn dispatch_admin(root: &Utf8Path, name: &str, args: Value) -> Result { - match name { - "codegraph_init" => { - let dir = init_project(root)?; - let do_index = args.get("index").and_then(|v| v.as_bool()).unwrap_or(true); - let mut out = json!({ "initialized": dir.as_str() }); - if do_index { - match run_index(root).await { - Ok(stats) => { - out["indexed"] = stats_json(&stats); - } - Err(e) => { - return Err(Error::Invalid(format!( - "initialized {}, but indexing failed: {e}", - dir - ))); - } - } - } - serde_json::to_string_pretty(&out).map_err(|e| Error::Invalid(e.to_string())) - } - "codegraph_index" => { - if !project_dir(root).exists() { - return Err(Error::Invalid( - "workspace not initialized: missing .codegraph/. Run codegraph_init first." - .into(), - )); - } - let stats = run_index(root).await?; - serde_json::to_string_pretty(&stats_json(&stats)) - .map_err(|e| Error::Invalid(e.to_string())) - } - _ => Err(Error::Invalid(format!("unknown admin tool: {name}"))), - } -} - -/// Full re-index: mở index theo backend config → `Orchestrator::index_all` -/// (ingest = full re-index). Không progress bar — MCP transport là stdout, -/// tránh nhiễu JSON-RPC. -async fn run_index(root: &Utf8Path) -> Result { - let mut idx = match ExtractConfig::load(root).storage_dsn(root) { - Some(dsn) => GraphIndex::open(&dsn).await?, - None => GraphIndex::in_memory(), - }; - Orchestrator::with_registry() - .index_all(root, &mut idx, None) - .await -} - -fn stats_json(s: &ExtractStats) -> Value { - json!({ - "files": s.files, - "symbols": s.symbols, - "chains": s.chains, - "calls": s.calls, - "skipped": s.skipped, - }) -} - // ── Sandbox tool (codegraph_sandbox) ── // Cần workspace root (config.toml `[sandbox]` + mock dirs) và snapshot index, // nên dispatch riêng qua `SharedGraphIndex` — không qua `GraphApi`. diff --git a/crates/codegraph/Cargo.toml b/crates/codegraph/Cargo.toml index e33a15404..1dd2b45b8 100644 --- a/crates/codegraph/Cargo.toml +++ b/crates/codegraph/Cargo.toml @@ -12,13 +12,8 @@ path = "src/main.rs" [dependencies] codegraph-graph = { path = "../codegraph-graph", features = ["sqlite", "lmdb", "bloom-search"] } -codegraph-core = { path = "../codegraph-core" } codegraph-extract = { path = "../codegraph-extract" } -codegraph-context = { path = "../codegraph-context" } codegraph-mcp = { path = "../codegraph-mcp" } -codegraph-installer = { path = "../codegraph-installer" } -codegraph-sboxes = { path = "../codegraph-sboxes" } -dirs = { workspace = true } clap = { workspace = true } tokio = { workspace = true } notify = { workspace = true } @@ -28,8 +23,6 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } anyhow = { workspace = true } camino = { workspace = true } -dialoguer = { workspace = true } -console = "0.15" indicatif = "0.18.6" [features] diff --git a/crates/codegraph/src/main.rs b/crates/codegraph/src/main.rs index 5002092d1..aa8a77071 100644 --- a/crates/codegraph/src/main.rs +++ b/crates/codegraph/src/main.rs @@ -1,13 +1,15 @@ -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, Result}; use camino::{Utf8Path, Utf8PathBuf}; -use clap::{Parser, Subcommand}; +use clap::{ArgAction, Parser, Subcommand}; use codegraph_extract::{ExtractStats, Orchestrator}; use codegraph_graph::GraphIndex; -use codegraph_mcp::McpServer; -use std::sync::Arc; +use codegraph_mcp::CodegraphServer; mod watcher; +/// CLI tối giản: chỉ còn lifecycle (`init`/`deinit`) + MCP server (`serve --mcp`). +/// Mọi query/interact đi qua MCP tools (`codegraph_search`, `codegraph_context`, +/// `codegraph_status`, …) — CLI không lặp lại các lệnh đọc index nữa. #[derive(Parser, Debug)] #[command( name = "codegraph", @@ -21,7 +23,7 @@ struct Cli { path: Option, /// Print version. - #[arg(short = 'v', long = "version", action = clap::ArgAction::Version)] + #[arg(short = 'v', long = "version", action = ArgAction::Version)] version: Option, #[command(subcommand)] @@ -43,61 +45,16 @@ enum Cmd { progress: bool, }, /// Remove the .codegraph/ directory. - Uninit, - /// Full re-index. - Index { - #[arg( - long, - default_value_t = true, - help = "Show live progress bar during indexing" - )] - progress: bool, - }, - /// Show index health. - Status, - /// Search symbols (substring, case-insensitive). - Query { - query: String, - #[arg(long, default_value_t = 20)] - limit: u32, - }, - /// List indexed files under a path prefix. - Files { - /// Path prefix filter (indexed file paths starting with this value). - #[arg(value_name = "PATH")] - prefix: Option, - }, - /// Build markdown context for a symbol. - Context { - target: String, - #[arg(long, default_value_t = 1)] - depth: u32, - #[arg(long)] - source: bool, - }, + Deinit, /// Run as MCP server over stdio. Serve { #[arg(long)] mcp: bool, }, - /// Configure agents (alias for the agent setup step in `init`). - Install, - /// Run a function in the behavior-verification sandbox: compile the - /// function (and its in-group callees) to machine code, bind external - /// callees to Rhai mocks, run it, and print the observed-behavior trace. - Sandbox { - /// Entry function name (substring; first match wins). - function: String, - /// Comma-separated abstract arg values (i64) for the entry function. - #[arg(long, default_value = "")] - args: String, - /// Do not print the trace, only the return value. - #[arg(long, default_value_t = false)] - quiet: bool, - }, } -fn main() -> Result<()> { +#[tokio::main] +async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() @@ -116,101 +73,23 @@ fn main() -> Result<()> { let cmd = match cli.cmd { Some(c) => c, None => { - cmd_default(&root)?; + cmd_default(&root).await?; return Ok(()); } }; match cmd { - Cmd::Init { no_index, progress } => cmd_init(&root, !no_index, progress), - Cmd::Uninit => cmd_uninit(&root), - Cmd::Index { progress } => cmd_index(&root, progress), - Cmd::Status => cmd_status(&root), - Cmd::Query { query, limit } => cmd_query(&root, &query, limit), - Cmd::Files { prefix } => cmd_files(&root, prefix.as_deref()), - Cmd::Context { - target, - depth, - source, - } => cmd_context(&root, &target, depth, source), - Cmd::Serve { mcp } => cmd_serve(&root, mcp), - Cmd::Install => cmd_agents(&root), - Cmd::Sandbox { - function, - args, - quiet, - } => cmd_sandbox(&root, &function, &args, quiet), + Cmd::Init { no_index, progress } => cmd_init(&root, !no_index, progress).await, + Cmd::Deinit => cmd_deinit(&root), + Cmd::Serve { mcp } => cmd_serve(&root, mcp).await, } } -fn cmd_default(root: &Utf8Path) -> Result<()> { - if !is_initialized(root) { - use console::style; - eprintln!(); - eprintln!( - " {} {}", - style("CodeGraph").bold().cyan(), - style(format!("v{}", env!("CARGO_PKG_VERSION"))).dim() - ); - eprintln!(" ━"); - eprintln!( - " ⚠️ {}", - style("Workspace not initialized").bold().yellow() - ); - eprintln!(" No active database found in this directory."); - eprintln!(); - eprintln!( - " {} {}", - style("Root:").dim(), - style(root.as_str()).italic() - ); - eprintln!( - " 👉 Run {} to set up CodeGraph!", - style("codegraph init").bold().green() - ); - eprintln!(); - std::process::exit(1); - } - - use console::style; - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()?; - let s = rt.block_on(async { - let idx = open_index(root).await?; - Ok::<_, anyhow::Error>(idx.stats()) - })?; - eprintln!(); - eprintln!( - " {} {}", - style("CodeGraph").bold().cyan(), - style(format!("v{}", env!("CARGO_PKG_VERSION"))).dim() - ); - eprintln!(" ━"); - eprintln!( - " ✨ {}", - style("Workspace Active & Indexed").bold().green() - ); - eprintln!(); - eprintln!(" 📊 {}", style("Database Statistics:").bold()); - eprintln!(" • {} indexed files", style(s.files).cyan()); - eprintln!(" • {} symbols", style(s.symbols).cyan()); - eprintln!(" • {} chains", style(s.chains).cyan()); - eprintln!(" • {} edges", style(s.edges).cyan()); - eprintln!(); - eprintln!(" 🚀 {}", style("Quick Commands:").bold()); - eprintln!( - " • {} Check status and statistics", - style("codegraph status").green() - ); - eprintln!( - " • {} Search for symbols in the codebase", - style("codegraph query ").green() - ); - eprintln!( - " • {} Configure/install AI agent integrations", - style("codegraph install").green() - ); - eprintln!(); +/// Không có subcommand → in help. Banner console cũ bị bỏ: giao diện chính giờ +/// là MCP (agent dùng `codegraph_init`/`codegraph_status` qua tools). +async fn cmd_default(_root: &Utf8Path) -> Result<()> { + use clap::CommandFactory; + Cli::command().print_help()?; + println!(); Ok(()) } @@ -235,175 +114,47 @@ fn is_initialized(root: &Utf8Path) -> bool { codegraph_extract::project_dir(root).exists() } -fn ensure_initialized(root: &Utf8Path) -> Result<()> { - if !is_initialized(root) { - use console::style; - eprintln!(); - eprintln!( - " {} {}", - style("CodeGraph").bold().cyan(), - style(format!("v{}", env!("CARGO_PKG_VERSION"))).dim() - ); - eprintln!(" ━"); - eprintln!( - " ⚠️ {}", - style("Workspace not initialized").bold().yellow() - ); - eprintln!(" No active database found in this directory."); - eprintln!(); - eprintln!( - " {} {}", - style("Root:").dim(), - style(root.as_str()).italic() - ); - eprintln!( - " 👉 Run {} to set up CodeGraph!", - style("codegraph init").bold().green() - ); - eprintln!(); - std::process::exit(1); - } - Ok(()) -} - -/// Full re-index: mở index theo backend config → `Orchestrator::index_all` -/// (ingest = full re-index). -fn block_on_index(root: &Utf8Path, progress: bool) -> Result { - let root = root.to_path_buf(); - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()?; - rt.block_on(async { - let mut idx = open_index(&root).await?; - // Create progress bar if requested. - let progress_bar = if progress { - let bar = indicatif::ProgressBar::new(0); - bar.set_style( - indicatif::ProgressStyle::default_bar() - .template("[{elapsed_precise}] [{wide_bar}] {pos}/{len} ({percent}%)") - .expect("valid progress bar template") - .progress_chars("#>-"), - ); - Some(std::sync::Arc::new(bar)) - } else { - None - }; - Ok::<_, anyhow::Error>( - Orchestrator::with_registry() - .index_all(&root, &mut idx, progress_bar) - .await?, - ) - }) -} - -fn cmd_init(root: &Utf8Path, do_index: bool, show_progress: bool) -> Result<()> { +/// `codegraph init`: tạo `.codegraph/` + config, index ngay nếu `do_index` +/// (progress bar khi `show_progress`). không gọi installer nữa. +async fn cmd_init(root: &Utf8Path, do_index: bool, show_progress: bool) -> Result<()> { let dir = codegraph_extract::init_project(root)?; eprintln!("initialized {}", dir); if do_index { - let stats = block_on_index(root, show_progress)?; + let stats = index_all(root, show_progress).await?; eprintln!( - "indexed {} files, {} symbols, {} chains, {} edges", - stats.files, stats.symbols, stats.chains, stats.calls + "indexed {} files, {} symbols, {} chains, {} calls (skipped {})", + stats.files, stats.symbols, stats.chains, stats.calls, stats.skipped ); } - - eprintln!(); - cmd_agents(root) + Ok(()) } -fn cmd_agents(root: &Utf8Path) -> Result<()> { - use codegraph_installer::{project_registry, DetectStatus, InstallOpts, InstallReport}; - use console::style; - use dialoguer::{theme::ColorfulTheme, MultiSelect}; - - let bin = std::env::current_exe()?; - let bin = Utf8PathBuf::from_path_buf(bin) - .map_err(|p| anyhow!("non-UTF8 bin path: {}", p.display()))?; - let opts = InstallOpts { - project_root: Some(root.to_path_buf()), - global: false, - binary_path: bin, - home_dir: None, +/// Full re-index: mở index theo backend config → `Orchestrator::index_all` +/// (ingest = full re-index). +async fn index_all(root: &Utf8Path, progress: bool) -> Result { + let mut idx = open_index(root).await?; + // Create progress bar if requested. + let progress_bar = if progress { + let bar = indicatif::ProgressBar::new(0); + bar.set_style( + indicatif::ProgressStyle::default_bar() + .template("[{elapsed_precise}] [{wide_bar}] {pos}/{len} ({percent}%)") + .expect("valid progress bar template") + .progress_chars("#>-"), + ); + Some(std::sync::Arc::new(bar)) + } else { + None }; - - let all_targets = project_registry(); - let statuses: Vec = all_targets.iter().map(|t| t.detect(&opts)).collect(); - - let found_indices: Vec = statuses - .iter() - .enumerate() - .filter(|(_, s)| matches!(s, DetectStatus::Found)) - .map(|(i, _)| i) - .collect(); - - let already_indices: Vec = statuses - .iter() - .enumerate() - .filter(|(_, s)| matches!(s, DetectStatus::AlreadyConfigured)) - .map(|(i, _)| i) - .collect(); - - let not_found_indices: Vec = statuses - .iter() - .enumerate() - .filter(|(_, s)| matches!(s, DetectStatus::NotFound)) - .map(|(i, _)| i) - .collect(); - - if !already_indices.is_empty() { - eprintln!("{}", style("Already configured:").blue()); - for i in &already_indices { - eprintln!(" {}", style(all_targets[*i].label()).blue()); - } - eprintln!(); - } - - if !not_found_indices.is_empty() { - eprintln!("{}", style("Not detected:").dim()); - for i in ¬_found_indices { - eprintln!(" {}", style(all_targets[*i].label()).dim()); - } - eprintln!(); - } - - if found_indices.is_empty() { - return Ok(()); - } - - let labels: Vec = found_indices - .iter() - .map(|&i| all_targets[i].label().to_string()) - .collect(); - - let chosen = MultiSelect::with_theme(&ColorfulTheme::default()) - .with_prompt("Select agents to configure (space = toggle, enter = confirm)") - .items(&labels) - .defaults(&vec![false; found_indices.len()]) - .interact()?; - - if chosen.is_empty() { - return Ok(()); - } - - eprintln!(); - for pos in chosen { - let target = &all_targets[found_indices[pos]]; - let report = target.install(&opts)?; - match report { - InstallReport::Installed(p) | InstallReport::Updated(p) => { - for f in &p { - eprintln!("[{}] wrote {}", target.id(), f); - } - } - InstallReport::Unchanged => eprintln!("[{}] unchanged", target.id()), - InstallReport::Skipped(r) => eprintln!("[{}] skipped: {}", target.id(), r), - } - } - Ok(()) + Orchestrator::with_registry() + .index_all(root, &mut idx, progress_bar) + .await + .map_err(Into::into) } -fn cmd_uninit(root: &Utf8Path) -> Result<()> { +/// `codegraph deinit`: xóa `.codegraph/` (đảo của `init`). +fn cmd_deinit(root: &Utf8Path) -> Result<()> { let dir = codegraph_extract::project_dir(root); if dir.exists() { std::fs::remove_dir_all(&dir)?; @@ -412,196 +163,28 @@ fn cmd_uninit(root: &Utf8Path) -> Result<()> { Ok(()) } -fn cmd_index(root: &Utf8Path, progress: bool) -> Result<()> { - ensure_initialized(root)?; - let stats = block_on_index(root, progress)?; - eprintln!( - "indexed {} files, {} symbols, {} chains, {} calls (skipped {})", - stats.files, stats.symbols, stats.chains, stats.calls, stats.skipped - ); - Ok(()) -} - -fn cmd_status(root: &Utf8Path) -> Result<()> { - ensure_initialized(root)?; - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()?; - let s = rt.block_on(async { - let idx = open_index(root).await?; - Ok::<_, anyhow::Error>(idx.stats()) - })?; - println!("files: {}", s.files); - println!("symbols: {}", s.symbols); - println!("chains: {}", s.chains); - println!("edges: {}", s.edges); - Ok(()) -} - -fn cmd_query(root: &Utf8Path, q: &str, limit: u32) -> Result<()> { - ensure_initialized(root)?; - let q = q.to_string(); - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()?; - let hits = rt.block_on(async { - let idx = open_index(root).await?; - Ok::<_, anyhow::Error>(idx.search_symbol(&q, None, limit as usize).await?) - })?; - for h in hits { - println!( - "[{}] {} {} {}:{}", - h.id, - h.kind.as_str(), - h.name, - h.file, - h.line - ); - } - Ok(()) -} - -fn cmd_files(root: &Utf8Path, prefix: Option<&str>) -> Result<()> { - use std::io::Write; - - ensure_initialized(root)?; - let prefix = prefix.unwrap_or("").to_string(); - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()?; - let files = rt.block_on(async { - let idx = open_index(root).await?; - let all = idx.files(); - Ok::<_, anyhow::Error>(if prefix.is_empty() { - all - } else { - all.into_iter() - .filter(|f| f.path.starts_with(&prefix)) - .collect() - }) - })?; - let mut out = std::io::stdout().lock(); - for f in files { - if writeln!(out, "{} ({})", f.path, f.language).is_err() { - break; - } - } - Ok(()) -} - -fn cmd_context(root: &Utf8Path, target: &str, depth: u32, include_source: bool) -> Result<()> { - ensure_initialized(root)?; - let dsn = storage_dsn(root); - let req = codegraph_context::ContextRequest { - query: target.into(), - depth, - include_source, - limit: 5, - format: codegraph_context::Format::Markdown, - }; - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()?; - let output = rt.block_on(async { - let sgi = Arc::new(codegraph_graph::SharedGraphIndex::open(dsn).await?); - codegraph_context::build(&sgi, &req).await - })?; - print!("{}", output); - Ok(()) -} - -fn cmd_serve(root: &Utf8Path, mcp: bool) -> Result<()> { +/// `codegraph serve --mcp`: chạy MCP server trên stdio. +async fn cmd_serve(root: &Utf8Path, mcp: bool) -> Result<()> { if !mcp { return Err(anyhow!("only --mcp transport supported")); } - ensure_initialized(root).context("init the index before serving")?; - let dsn = storage_dsn(root); - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()?; - rt.block_on(async { - watcher::spawn(root.to_path_buf(), dsn.clone()); - let mcp_server = McpServer::new(root.to_path_buf(), dsn).await?; - mcp_server.run_stdio().await - })?; - Ok(()) -} - -/// `codegraph sandbox ` — compile a function group to machine code, -/// bind external callees to Rhai mocks, run it, and print the observed trace. -fn cmd_sandbox(root: &Utf8Path, function: &str, args: &str, quiet: bool) -> Result<()> { - use codegraph_core::SymbolKind; - use codegraph_sboxes::SboxConfig; - - ensure_initialized(root)?; - let dsn = storage_dsn(root); - let function = function.to_string(); - let args: Vec = args - .split(',') - .filter(|s| !s.trim().is_empty()) - .map(|s| s.trim().parse().map_err(|e| anyhow!("bad arg `{s}`: {e}"))) - .collect::>()?; - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()?; - let (ret, trace, group_names) = rt.block_on(async { - let sgi = Arc::new(codegraph_graph::SharedGraphIndex::open(dsn).await?); - let idx = sgi.ensure_fresh().await; - - // Resolve the entry function (substring, first function match). - let hits = idx - .search_symbol_kinds(&function, &[SymbolKind::Function, SymbolKind::Method], 1) - .await?; - let entry = hits - .first() - .ok_or_else(|| anyhow!("no function matching `{function}`"))?; - let entry_id = entry.id; - - // Build the group: the entry plus every callee in its flow that is a - // known symbol (so those calls compile to real machine code instead of - // a mock). Unresolved/external calls stay mocked. - let flow = idx.flow(entry_id).await?; - let mut ids = vec![entry_id]; - let mut seen = std::collections::HashSet::from([entry_id]); - for &e in &flow.chain { - if codegraph_core::is_marker(e) { - continue; - } - if e != entry_id && idx.symbol_by_id(e).is_some() && seen.insert(e) { - ids.push(e); - } - } - ids.sort_unstable(); - - let config = SboxConfig::load(&root.to_path_buf()).unwrap_or_default(); - let mut module = codegraph_sboxes::compile(&idx, &ids, &config).await?; - let (ret, trace) = module.run(&args); - - let mut names: Vec = ids - .iter() - .filter_map(|id| idx.symbol_by_id(*id)) - .map(|s| s.name) - .collect(); - names.sort(); - Ok::<_, anyhow::Error>((ret, trace, names)) - })?; - - println!("group: {}", group_names.join(", ")); - println!("return: {ret}"); - if quiet { - return Ok(()); - } - for (i, name) in trace.mock_names().iter().enumerate() { - println!(" {i}: call {name}"); - } - for c in &trace.conds { - println!( - " {:>4}: {} -> {}", - c.idx, - c.kind.as_str(), - if c.result { "taken" } else { "skipped" } - ); + // MCP is session-driven: the agent binds a workspace at runtime via + // `codegraph_init {"path": ...}`. The startup `--path` (default: cwd) is + // only a PRE-SEED so the file watcher attaches to a real project. MCP hosts + // like Claude Desktop launch servers with cwd=/ and no `--path` — the root + // resolving to `/` is NOT an error anymore: we just start with an EMPTY + // session and let the agent bind the project path through the tool. + let use_root = root.as_str() != "/"; + let initialized = use_root && is_initialized(root); + let dsn = if initialized { storage_dsn(root) } else { None }; + if initialized { + watcher::spawn(root.to_path_buf(), dsn.clone()); } - Ok(()) -} + let server = if use_root { + CodegraphServer::with_root(root.to_path_buf()).await? + } else { + CodegraphServer::new() + }; + codegraph_mcp::serve_stdio(server).await +} \ No newline at end of file