diff --git a/.mcp.json b/.mcp.json index 61f1b67..c66928c 100644 --- a/.mcp.json +++ b/.mcp.json @@ -5,6 +5,12 @@ "mcp" ], "command": "mem-cli" + }, + "rfc-cli": { + "args": [ + "mcp" + ], + "command": "rfc-cli" } } } diff --git a/CHANGELOG.md b/CHANGELOG.md index db167fd..728cfd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.7] + +### Added +- `--version` and `-V` flags for displaying the installed `rfc-cli` version. + +## [0.1.6] + +### Added +- Read-only MCP server via `rfc-cli mcp` over stdio with RFC tools for listing, + viewing, checking status, and inspecting dependencies. +- Automatic `rfc-cli` MCP server configuration in `.mcp.json`, preserving other + configured servers. + ## [0.1.5] ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 4ffc752..406bfa2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -239,7 +239,7 @@ dependencies = [ [[package]] name = "rfc-cli" -version = "0.1.5" +version = "0.1.7" dependencies = [ "clap", "clap_builder", diff --git a/Cargo.toml b/Cargo.toml index 6de0ae1..b473cdb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rfc-cli" -version = "0.1.5" +version = "0.1.7" edition = "2021" [dependencies] diff --git a/Makefile b/Makefile index 7284d66..9550985 100644 --- a/Makefile +++ b/Makefile @@ -16,4 +16,5 @@ test: ## Run all tests clean: ## Remove build artifacts cargo clean -install: release ## Install binary to ~/.cargo +install: release ## Install binary to ~/.local/bin + install -Dm755 target/release/rfc-cli ~/.local/bin/rfc-cli diff --git a/README.md b/README.md index 610025f..f592e09 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,13 @@ For convenience, you can copy it to your PATH: cp target/release/rfc-cli ~/.local/bin/ ``` +Check the installed version: + +```sh +rfc-cli --version +# rfc-cli 0.1.7 +``` + ## Shell Completion Generate and install completion scripts: @@ -282,6 +289,36 @@ RFC-0005 (logging): Summary: 3 error(s), 1 warning(s) across 2 RFC(s). ``` +### `mcp` — MCP server + +Runs a read-only Model Context Protocol server over `stdio`. The server supports +JSON-RPC 2.0 and exposes RFC tools for MCP clients such as Copilot or Claude +Desktop: + +- `list_rfcs` +- `view_rfc` +- `get_rfc_status` +- `get_rfc_dependencies` +- `ping` + +```sh +rfc-cli mcp +``` + +`rfc-cli init` adds the following entry to `.mcp.json` while preserving other +configured servers: + +```json +{ + "mcpServers": { + "rfc-cli": { + "command": "rfc-cli", + "args": ["mcp"] + } + } +} +``` + ### `reindex` — rebuild index Completely rebuilds `.index.json` from the RFC files on disk. Use when the index is corrupted or out of sync. @@ -307,6 +344,7 @@ rfc-cli reindex | `deps [--reverse]` | Dependency tree | | `check [N]` | Format validation | | `doctor [--stale-days N]` | Health diagnostics | +| `mcp` | Run read-only MCP server over stdio | | `reindex` | Rebuild index | ## RFC Process @@ -407,6 +445,7 @@ src/ │ ├── deps.rs # rfc-cli deps │ ├── check.rs # rfc-cli check │ ├── doctor.rs # rfc-cli doctor +│ ├── mcp.rs # rfc-cli mcp │ └── reindex.rs # rfc-cli reindex └── rfclib/ ├── mod.rs @@ -424,7 +463,8 @@ docs/rfcs/ ├── 0003.md # RFC-0003: implement list, view, status, edit commands ├── 0004.md # RFC-0004: implement set, check, reindex commands ├── 0005.md # RFC-0005: implement link, unlink, deps commands -└── 0006.md # RFC-0006: implement doctor command +├── 0006.md # RFC-0006: implement doctor command +└── 0010.md # RFC-0010: MCP server for RFC tools ``` ## License diff --git a/README_ru.md b/README_ru.md index 3746d78..8776fef 100644 --- a/README_ru.md +++ b/README_ru.md @@ -23,6 +23,13 @@ cargo build --release cp target/release/rfc-cli ~/.local/bin/ ``` +Проверить установленную версию: + +```sh +rfc-cli --version +# rfc-cli 0.1.7 +``` + ## Быстрый старт ```sh @@ -274,6 +281,36 @@ RFC-0005 (logging): Summary: 3 error(s), 1 warning(s) across 2 RFC(s). ``` +### `mcp` — MCP-сервер + +Запускает read-only Model Context Protocol сервер через `stdio`. Сервер +поддерживает JSON-RPC 2.0 и предоставляет MCP-клиентам (например, Copilot или +Claude Desktop) инструменты: + +- `list_rfcs` +- `view_rfc` +- `get_rfc_status` +- `get_rfc_dependencies` +- `ping` + +```sh +rfc-cli mcp +``` + +Команда `rfc-cli init` добавляет запись `rfc-cli` в `.mcp.json`, сохраняя другие +настроенные серверы: + +```json +{ + "mcpServers": { + "rfc-cli": { + "command": "rfc-cli", + "args": ["mcp"] + } + } +} +``` + ### `reindex` — пересборка индекса Полностью перестраивает `.index.json` из RFC-файлов на диске. Используйте, если индекс повреждён или рассинхронизирован. @@ -299,6 +336,7 @@ rfc-cli reindex | `deps [--reverse]` | Дерево зависимостей | | `check [N]` | Валидация формата | | `doctor [--stale-days N]` | Диагностика здоровья | +| `mcp` | Read-only MCP-сервер через stdio | | `reindex` | Пересборка индекса | ## RFC-процесс @@ -399,6 +437,7 @@ src/ │ ├── deps.rs # rfc-cli deps │ ├── check.rs # rfc-cli check │ ├── doctor.rs # rfc-cli doctor +│ ├── mcp.rs # rfc-cli mcp │ └── reindex.rs # rfc-cli reindex └── rfclib/ ├── mod.rs @@ -416,7 +455,8 @@ docs/rfcs/ ├── 0003.md # RFC-0003: реализация команд list, view, status и edit ├── 0004.md # RFC-0004: реализация команд set, check и reindex ├── 0005.md # RFC-0005: реализация команд link, unlink и deps -└── 0006.md # RFC-0006: реализация команды doctor +├── 0006.md # RFC-0006: реализация команды doctor +└── 0010.md # RFC-0010: MCP-сервер для инструментов RFC ``` ## Лицензия diff --git a/docs/rfcs/.index.json b/docs/rfcs/.index.json index 036cc4f..75f0fda 100644 --- a/docs/rfcs/.index.json +++ b/docs/rfcs/.index.json @@ -168,8 +168,37 @@ "src/main.rs", "tests/integration_test.rs" ], - "mtime": "1786338690", + "mtime": "1786338706", "content_hash": "57258823363c37be4463a51b3fe9b4dc23794f457c3f4c80392bc227335574d7" + }, + { + "number": "0010", + "title": "RFC-0010: MCP-сервер для инструментов RFC", + "status": "implemented", + "dependencies": [], + "superseded_by": null, + "links": [ + "src/commands/mcp.rs", + "src/commands/init.rs", + "src/cli.rs", + "src/main.rs", + "tests/integration_test.rs" + ], + "mtime": "1789459989", + "content_hash": "5ed22f940a64026b804fc74469b6608298628c8d0f26acbb97863b54a8dcdfea" + }, + { + "number": "0011", + "title": "RFC-0011: вывод версии CLI", + "status": "implemented", + "dependencies": [], + "superseded_by": null, + "links": [ + "src/cli.rs", + "tests/integration_test.rs" + ], + "mtime": "1789460268", + "content_hash": "311a7361030a9843a64bb74332cc1a816896dbb1e70add77a5e992ec7a4fce75" } ] } \ No newline at end of file diff --git a/docs/rfcs/0010.md b/docs/rfcs/0010.md new file mode 100644 index 0000000..12c14d4 --- /dev/null +++ b/docs/rfcs/0010.md @@ -0,0 +1,121 @@ +--- +title: "RFC-0010: MCP-сервер для инструментов RFC" +status: implemented +dependencies: [] +superseded_by: null +links: + - src/commands/mcp.rs + - src/commands/init.rs + - src/cli.rs + - src/main.rs + - tests/integration_test.rs +--- + +## Problem + +Сейчас `rfc-cli` доступен AI-клиентам только через запуск shell-команд и +разбор текстового вывода. Это требует от каждого клиента знать CLI-синтаксис и +формат таблиц, а также не даёт стандартного интерфейса Model Context Protocol. + +## Goal + +Добавить подкоманду `rfc-cli mcp`, которая предоставляет read-only операции с +RFC как MCP tools через локальный `stdio`-транспорт. Существующий CLI остаётся +основным пользовательским интерфейсом. + +## Requirements + +1. Команда `rfc-cli mcp` запускает MCP-сервер через `stdio`. +2. Сервер поддерживает JSON-RPC 2.0 и принимает как newline-delimited JSON, + так и сообщения с заголовком `Content-Length`. +3. Сервер не пишет обычные логи в stdout, чтобы не загрязнять MCP-транспорт. +4. Сервер реализует MCP lifecycle: + - `initialize`; + - `notifications/initialized`; + - `ping`; + - `tools/list`; + - `tools/call`. +5. В первой версии доступны read-only tools: + - `ping`; + - `list_rfcs` с необязательным фильтром `status`; + - `view_rfc` с аргументом `number`; + - `get_rfc_status` с аргументом `number`; + - `get_rfc_dependencies` с аргументами `number` и `reverse`. +6. Результаты tools возвращаются как JSON-объекты в `structuredContent` и как + JSON-текст в `content`. +7. Некорректные аргументы возвращают JSON-RPC ошибку `-32602`, неизвестный + метод — `-32601`, внутренние ошибки — `-32603`. +8. MCP tools используют те же RFC-файлы и индекс, что и обычные команды, но + read-only операции не должны изменять `.index.json`. +9. `rfc-cli init` создаёт или обновляет запись `rfc-cli` в `.mcp.json`, + сохраняя другие MCP-серверы: + + ```json + { + "mcpServers": { + "rfc-cli": { + "command": "rfc-cli", + "args": ["mcp"] + } + } + } + ``` + +## Design + +### CLI + +Добавить подкоманду: + +```rust +/// Run MCP server over stdio +Mcp +``` + +`main.rs` передаёт в MCP-сервер уже определённый `project_root`, поэтому MCP и +CLI используют одинаковое разрешение `RFC_HOME`. + +### MCP frontend + +Новый модуль `src/commands/mcp.rs` содержит: + +- stdio reader/writer; +- JSON-RPC dispatch; +- MCP tool schemas; +- преобразование ошибок в JSON-RPC; +- read-only обработчики tools. + +Обработчики напрямую используют `rfclib::index` и `rfclib::rfc`, не запускают +другие CLI-команды и не парсят их человекочитаемый stdout. + +### MCP configuration + +`init` читает существующий `.mcp.json`, проверяет, что корень документа и +`mcpServers` являются JSON-объектами, добавляет запись `rfc-cli` и сохраняет +остальные записи без изменений. Повторный запуск идемпотентен. + +## Alternatives + +1. **Оставить только shell-вызовы CLI** + Не требует изменений, но сохраняет привязку клиентов к текстовому выводу. + +2. **Добавить MCP через отдельный прокси-проект** + Усложняет установку и может привести к расхождению поведения с `rfc-cli`. + +3. **Сразу добавить изменяющие tools** + Увеличивает риск случайной модификации RFC через AI-клиента. Изменяющие + операции можно добавить отдельным RFC после подтверждения схемы разрешений. + +## Voting + +Открытые вопросы для review: + +1. Достаточен ли read-only набор tools для первой версии? +2. Нужны ли resources в следующей версии? +3. Нужны ли изменяющие tools после отдельного согласования безопасности? + +## Migration + +Миграция RFC-файлов и индекса не требуется. После обновления достаточно +запустить `rfc-cli init`, чтобы добавить запись сервера в `.mcp.json`. Старые +конфигурации MCP и существующие CLI-команды не изменяются. diff --git a/docs/rfcs/0011.md b/docs/rfcs/0011.md new file mode 100644 index 0000000..1dad395 --- /dev/null +++ b/docs/rfcs/0011.md @@ -0,0 +1,58 @@ +--- +title: "RFC-0011: вывод версии CLI" +status: implemented +dependencies: [] +superseded_by: null +links: + - src/cli.rs + - tests/integration_test.rs +--- + +## Problem + +Сейчас `rfc-cli` не показывает собственную версию: параметры `--version` и +`-V` отсутствуют, потому что версия не включена в конфигурацию `clap`. +Пользователь не может быстро проверить, какая сборка установлена. + +## Goal + +Добавить стандартный вывод версии через `--version` и `-V`, используя версию +пакета из `Cargo.toml`. + +## Requirements + +1. `rfc-cli --version` выводит имя программы и текущую версию пакета. +2. `rfc-cli -V` является коротким эквивалентом `--version`. +3. Версия берётся автоматически из `CARGO_PKG_VERSION`, без дублирования в + исходном коде. +4. Поведение существующих команд не меняется. + +## Design + +В атрибут `#[command]` структуры `Cli` добавить `version`: + +```rust +#[command(name = "rfc-cli", about = "Manage RFC documents", version)] +``` + +`clap` сгенерирует параметры `--version` и `-V` на основании +`CARGO_PKG_VERSION`. + +Добавить интеграционный тест, проверяющий оба варианта и соответствие версии +пакета. + +## Alternatives + +1. **Добавить отдельную подкоманду `version`** + Не соответствует стандартному CLI-поведению и требует лишнего синтаксиса. + +2. **Хранить версию отдельной константой** + Создаёт риск рассинхронизации с версией в `Cargo.toml`. + +## Voting + +Решение принято: использовать встроенную поддержку версии в `clap`. + +## Migration + +Миграция не требуется. Добавляется только стандартный глобальный параметр CLI. diff --git a/src/cli.rs b/src/cli.rs index 5e2896c..2f64a97 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,7 +2,7 @@ use clap::{Parser, Subcommand, ValueEnum}; use clap_complete::Shell; #[derive(Parser)] -#[command(name = "rfc-cli", about = "Manage RFC documents")] +#[command(name = "rfc-cli", about = "Manage RFC documents", version)] pub struct Cli { #[command(subcommand)] pub command: Commands, @@ -111,4 +111,6 @@ pub enum Commands { #[arg(long, value_enum, default_value = "git")] drift: DriftStrategy, }, + /// Run MCP server over stdio + Mcp, } diff --git a/src/commands/init.rs b/src/commands/init.rs index ceec533..09961f0 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -28,5 +28,50 @@ pub fn execute(project_root: &Path) -> Result<(), String> { println!("Already initialized."); } + update_mcp_json(project_root)?; + Ok(()) } + +fn update_mcp_json(project_root: &Path) -> Result<(), String> { + use serde_json::{json, Value}; + + let path = project_root.join(".mcp.json"); + let mut config: Value = match fs::read_to_string(&path) { + Ok(content) if !content.trim().is_empty() => serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?, + Ok(_) => json!({}), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => json!({}), + Err(e) => return Err(format!("Failed to read {}: {}", path.display(), e)), + }; + + if !config.is_object() { + return Err(format!("{} must contain a JSON object", path.display())); + } + + let servers = config + .as_object_mut() + .expect("checked that MCP config is an object") + .entry("mcpServers") + .or_insert_with(|| json!({})); + + if !servers.is_object() { + return Err(format!( + "`mcpServers` in {} must be a JSON object", + path.display() + )); + } + + servers + .as_object_mut() + .expect("checked that mcpServers is an object") + .insert( + "rfc-cli".to_string(), + json!({ "command": "rfc-cli", "args": ["mcp"] }), + ); + + let mut content = serde_json::to_string_pretty(&config) + .map_err(|e| format!("Failed to serialize {}: {}", path.display(), e))?; + content.push('\n'); + fs::write(&path, content).map_err(|e| format!("Failed to write {}: {}", path.display(), e)) +} diff --git a/src/commands/list.rs b/src/commands/list.rs index fc94b64..09b558b 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -31,13 +31,7 @@ pub fn execute(project_root: &Path, status_filter: Option<&str>) -> Result<(), S .max(6); // Print header - println!( - " {:<6} {: Result<(), String> { + serve_stdio(project_root) +} + +fn serve_stdio(project_root: &Path) -> Result<(), String> { + let stdin = io::stdin(); + let mut reader = BufReader::new(stdin.lock()); + let stdout = io::stdout(); + let mut writer = io::BufWriter::new(stdout.lock()); + + while let Some(request) = read_message(&mut reader)? { + if let Some(response) = handle_request(project_root, &request)? { + write_message(&mut writer, &response)?; + writer + .flush() + .map_err(|e| format!("Failed to flush stdout: {}", e))?; + } + } + + Ok(()) +} + +fn read_message(reader: &mut R) -> Result, String> { + let first_non_whitespace = loop { + let buffer = reader + .fill_buf() + .map_err(|e| format!("Failed to read MCP message: {}", e))?; + if buffer.is_empty() { + return Ok(None); + } + + let mut consumed = 0usize; + while consumed < buffer.len() && matches!(buffer[consumed], b' ' | b'\t' | b'\r' | b'\n') { + consumed += 1; + } + + if consumed > 0 { + reader.consume(consumed); + continue; + } + + break buffer[0]; + }; + + if first_non_whitespace == b'{' || first_non_whitespace == b'[' { + let mut line = String::new(); + let bytes = reader + .read_line(&mut line) + .map_err(|e| format!("Failed to read MCP JSON line: {}", e))?; + if bytes == 0 { + return Ok(None); + } + let message = serde_json::from_str(line.trim()) + .map_err(|e| format!("Failed to parse MCP JSON message: {}", e))?; + return Ok(Some(message)); + } + + let mut content_length: Option = None; + + loop { + let mut line = String::new(); + let bytes = reader + .read_line(&mut line) + .map_err(|e| format!("Failed to read MCP header line: {}", e))?; + if bytes == 0 { + return Ok(None); + } + + let line = line.trim_end_matches(['\r', '\n']); + if line.is_empty() { + break; + } + + if let Some((name, value)) = line.split_once(':') { + if name.eq_ignore_ascii_case("content-length") { + content_length = Some(value.trim().parse::().map_err(|e| { + format!("Invalid Content-Length value '{}': {}", value.trim(), e) + })?); + } + } + } + + let length = content_length.ok_or_else(|| "Missing Content-Length header".to_string())?; + let mut body = vec![0u8; length]; + reader + .read_exact(&mut body) + .map_err(|e| format!("Failed to read MCP message body: {}", e))?; + + serde_json::from_slice(&body) + .map(Some) + .map_err(|e| format!("Failed to parse MCP JSON message: {}", e)) +} + +fn write_message(writer: &mut W, message: &Value) -> Result<(), String> { + let body = serde_json::to_vec(message) + .map_err(|e| format!("Failed to serialize MCP response: {}", e))?; + writer + .write_all(&body) + .map_err(|e| format!("Failed to write MCP response: {}", e))?; + writer + .write_all(b"\n") + .map_err(|e| format!("Failed to write MCP response newline: {}", e))?; + Ok(()) +} + +fn handle_request(project_root: &Path, request: &Value) -> Result, String> { + let method = request + .get("method") + .and_then(Value::as_str) + .ok_or_else(|| "MCP message missing method".to_string())?; + let Some(id) = request.get("id").cloned() else { + return Ok(None); + }; + let params = request.get("params").cloned().unwrap_or_else(|| json!({})); + + let response = match method { + "initialize" => success( + id, + json!({ + "protocolVersion": MCP_PROTOCOL_VERSION, + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "rfc-cli", + "version": env!("CARGO_PKG_VERSION") + } + }), + ), + "ping" => success(id, json!({})), + "tools/list" => success(id, json!({ "tools": tool_specs() })), + "tools/call" => handle_tool_call(project_root, id, ¶ms), + _ => error(id, -32601, &format!("Method not found: {}", method)), + }; + + Ok(Some(response)) +} + +fn handle_tool_call(project_root: &Path, id: Value, params: &Value) -> Value { + let name = match params.get("name").and_then(Value::as_str) { + Some(name) if !name.is_empty() => name, + _ => return error(id, -32602, "tools/call requires non-empty field: name"), + }; + let arguments = params + .get("arguments") + .cloned() + .unwrap_or_else(|| json!({})); + + match execute_tool(project_root, name, &arguments) { + Ok(structured) => { + let text = match serde_json::to_string(&structured) { + Ok(text) => text, + Err(e) => return error(id, -32603, &format!("Failed to serialize result: {}", e)), + }; + success( + id, + json!({ + "content": [{ "type": "text", "text": text }], + "structuredContent": structured, + "isError": false + }), + ) + } + Err(ToolError::InvalidParams(message)) => error(id, -32602, &message), + Err(ToolError::Internal(message)) => error(id, -32603, &message), + } +} + +#[derive(Debug)] +enum ToolError { + InvalidParams(String), + Internal(String), +} + +fn execute_tool(project_root: &Path, name: &str, arguments: &Value) -> Result { + match name { + "ping" => Ok(json!({"ok": true, "message": "pong"})), + "list_rfcs" => list_rfcs(project_root, arguments), + "view_rfc" => view_rfc(project_root, arguments), + "get_rfc_status" => get_rfc_status(project_root, arguments), + "get_rfc_dependencies" => get_rfc_dependencies(project_root, arguments), + _ => Err(invalid_params(format!("Unknown tool: {}", name))), + } +} + +fn tool_specs() -> Vec { + vec![ + json!({ + "name": "ping", + "description": "Health check for the RFC MCP server.", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }), + json!({ + "name": "list_rfcs", + "description": "List RFCs, optionally filtered by status.", + "inputSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": rfc::VALID_STATUSES + } + }, + "additionalProperties": false + } + }), + json!({ + "name": "view_rfc", + "description": "Read the complete Markdown content of an RFC.", + "inputSchema": { + "type": "object", + "properties": { + "number": {"type": "string"} + }, + "required": ["number"], + "additionalProperties": false + } + }), + json!({ + "name": "get_rfc_status", + "description": "Get the current status of an RFC.", + "inputSchema": { + "type": "object", + "properties": { + "number": {"type": "string"} + }, + "required": ["number"], + "additionalProperties": false + } + }), + json!({ + "name": "get_rfc_dependencies", + "description": "Get forward or reverse dependencies of an RFC.", + "inputSchema": { + "type": "object", + "properties": { + "number": {"type": "string"}, + "reverse": {"type": "boolean"} + }, + "required": ["number"], + "additionalProperties": false + } + }), + ] +} + +fn list_rfcs(project_root: &Path, arguments: &Value) -> Result { + let arguments = arg_object(arguments)?; + let status = get_optional_string(arguments, "status")?; + let index = load_readonly_index(project_root)?; + + let rfcs = index + .rfcs + .iter() + .filter(|entry| status.map(|filter| entry.status == filter).unwrap_or(true)) + .map(index_entry_to_json) + .collect::>(); + + Ok(json!({"rfcs": rfcs})) +} + +fn view_rfc(project_root: &Path, arguments: &Value) -> Result { + let arguments = arg_object(arguments)?; + let number = get_required_string(arguments, "number")?; + let normalized = rfc::normalize_number(number).map_err(invalid_params)?; + let path = rfc::rfc_path(project_root, number).map_err(internal)?; + + if !path.exists() { + return Err(invalid_params(format!("RFC-{} not found.", normalized))); + } + + let content = std::fs::read_to_string(&path) + .map_err(|e| internal(format!("Failed to read {}: {}", path.display(), e)))?; + Ok(json!({"number": normalized, "content": content})) +} + +fn get_rfc_status(project_root: &Path, arguments: &Value) -> Result { + let arguments = arg_object(arguments)?; + let number = get_required_string(arguments, "number")?; + let normalized = rfc::normalize_number(number).map_err(invalid_params)?; + let index = load_readonly_index(project_root)?; + let entry = index + .rfcs + .iter() + .find(|entry| entry.number == normalized) + .ok_or_else(|| invalid_params(format!("RFC-{} not found.", normalized)))?; + + Ok(json!({"number": normalized, "status": entry.status})) +} + +fn get_rfc_dependencies(project_root: &Path, arguments: &Value) -> Result { + let arguments = arg_object(arguments)?; + let number = get_required_string(arguments, "number")?; + let reverse = get_optional_bool(arguments, "reverse")?.unwrap_or(false); + let normalized = rfc::normalize_number(number).map_err(invalid_params)?; + let index = load_readonly_index(project_root)?; + + let entry = index + .rfcs + .iter() + .find(|entry| entry.number == normalized) + .ok_or_else(|| invalid_params(format!("RFC-{} not found.", normalized)))?; + + if reverse { + let reference = format!("RFC-{}", normalized); + let dependencies = index + .rfcs + .iter() + .filter(|candidate| candidate.dependencies.contains(&reference)) + .map(index_entry_to_dependency_json) + .collect::>(); + return Ok(json!({ + "number": normalized, + "reverse": true, + "dependencies": dependencies + })); + } + + let dependencies = entry + .dependencies + .iter() + .map(|reference| { + let dependency_number = reference.strip_prefix("RFC-").unwrap_or(reference); + match rfc::normalize_number(dependency_number) { + Ok(normalized_dependency) => match index + .rfcs + .iter() + .find(|candidate| candidate.number == normalized_dependency) + { + Some(candidate) => index_entry_to_dependency_json(candidate), + None => json!({ + "reference": reference, + "number": normalized_dependency, + "not_found": true + }), + }, + Err(_) => json!({ + "reference": reference, + "invalid": true + }), + } + }) + .collect::>(); + + Ok(json!({ + "number": normalized, + "reverse": false, + "dependencies": dependencies + })) +} + +fn load_readonly_index(project_root: &Path) -> Result { + let rfcs_dir = project_root.join("docs/rfcs"); + if !rfcs_dir.exists() { + return Err(internal( + "docs/rfcs/ not found. Run \"rfc-cli init\" first.".to_string(), + )); + } + + let mut index = index::load_index(project_root).map_err(internal)?; + index::refresh_index_readonly(project_root, &mut index).map_err(internal)?; + Ok(index) +} + +fn index_entry_to_json(entry: &index::IndexEntry) -> Value { + json!({ + "number": entry.number, + "title": entry.title, + "status": entry.status, + "dependencies": entry.dependencies, + "superseded_by": entry.superseded_by, + "links": entry.links + }) +} + +fn index_entry_to_dependency_json(entry: &index::IndexEntry) -> Value { + json!({ + "number": entry.number, + "title": entry.title, + "status": entry.status + }) +} + +fn arg_object(arguments: &Value) -> Result<&Map, ToolError> { + arguments + .as_object() + .ok_or_else(|| invalid_params("Tool arguments must be an object")) +} + +fn get_required_string<'a>( + arguments: &'a Map, + field: &str, +) -> Result<&'a str, ToolError> { + arguments + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| invalid_params(format!("Missing or invalid field: {}", field))) +} + +fn get_optional_string<'a>( + arguments: &'a Map, + field: &str, +) -> Result, ToolError> { + let Some(value) = arguments.get(field) else { + return Ok(None); + }; + + value + .as_str() + .filter(|value| !value.is_empty()) + .map(Some) + .ok_or_else(|| invalid_params(format!("Invalid field: {}", field))) +} + +fn get_optional_bool( + arguments: &Map, + field: &str, +) -> Result, ToolError> { + let Some(value) = arguments.get(field) else { + return Ok(None); + }; + + value + .as_bool() + .map(Some) + .ok_or_else(|| invalid_params(format!("Invalid field: {}", field))) +} + +fn invalid_params(message: impl Into) -> ToolError { + ToolError::InvalidParams(message.into()) +} + +fn internal(message: impl Into) -> ToolError { + ToolError::Internal(message.into()) +} + +fn success(id: Value, result: Value) -> Value { + json!({ + "jsonrpc": JSONRPC_VERSION, + "id": id, + "result": result + }) +} + +fn error(id: Value, code: i64, message: &str) -> Value { + json!({ + "jsonrpc": JSONRPC_VERSION, + "id": id, + "error": { + "code": code, + "message": message + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + use std::path::PathBuf; + + fn temp_project(name: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("rfc_cli_mcp_{}", name)); + if root.exists() { + std::fs::remove_dir_all(&root).unwrap(); + } + std::fs::create_dir_all(root.join("docs/rfcs")).unwrap(); + std::fs::write(root.join("docs/rfcs/.index.json"), r#"{"rfcs":[]}"#).unwrap(); + std::fs::write( + root.join("docs/rfcs/0001.md"), + "---\ntitle: \"RFC-0001: test\"\nstatus: draft\ndependencies: []\nsuperseded_by: null\nlinks: []\n---\n\n## Problem\n", + ) + .unwrap(); + root + } + + #[test] + fn tools_list_contains_read_only_tools() { + let request = json!({"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}); + let response = handle_request(Path::new("."), &request).unwrap().unwrap(); + let tools = response["result"]["tools"].as_array().unwrap(); + + assert!(tools.iter().any(|tool| tool["name"] == "list_rfcs")); + assert!(tools.iter().any(|tool| tool["name"] == "view_rfc")); + assert!(tools.iter().any(|tool| tool["name"] == "get_rfc_status")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "get_rfc_dependencies")); + } + + #[test] + fn list_tool_returns_structured_content_without_writing_index() { + let root = temp_project("list"); + let index_path = root.join("docs/rfcs/.index.json"); + let before = std::fs::read_to_string(&index_path).unwrap(); + let request = json!({ + "jsonrpc":"2.0", + "id":1, + "method":"tools/call", + "params":{"name":"list_rfcs","arguments":{}} + }); + + let response = handle_request(&root, &request).unwrap().unwrap(); + assert_eq!( + response["result"]["structuredContent"]["rfcs"][0]["number"], + "0001" + ); + assert!(response["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("\"0001\"")); + assert_eq!(std::fs::read_to_string(index_path).unwrap(), before); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn read_message_supports_raw_json_and_content_length() { + let request = json!({"jsonrpc":"2.0","id":1,"method":"ping"}); + let body = serde_json::to_string(&request).unwrap(); + + let mut raw_reader = BufReader::new(Cursor::new(format!("{}\n", body))); + assert_eq!(read_message(&mut raw_reader).unwrap().unwrap(), request); + + let framed = format!("Content-Length: {}\r\n\r\n{}", body.len(), body); + let mut framed_reader = BufReader::new(Cursor::new(framed)); + assert_eq!(read_message(&mut framed_reader).unwrap().unwrap(), request); + } + + #[test] + fn write_message_uses_newline_delimited_json() { + let message = json!({"jsonrpc":"2.0","id":1,"result":{}}); + let mut output = Vec::new(); + write_message(&mut output, &message).unwrap(); + let text = String::from_utf8(output).unwrap(); + + assert!(!text.contains("Content-Length")); + assert_eq!(serde_json::from_str::(text.trim()).unwrap(), message); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 01cd0e4..9087abc 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod edit; pub mod init; pub mod link; pub mod list; +pub mod mcp; pub mod new; pub mod reindex; pub mod set; diff --git a/src/main.rs b/src/main.rs index 113c397..4ffd342 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,6 +52,7 @@ fn main() { Commands::Doctor { stale_days, drift } => { commands::doctor::execute(&project_root, stale_days, drift) } + Commands::Mcp => commands::mcp::execute(&project_root), }; if let Err(e) = result { diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 2eeb894..049c337 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1,4 +1,5 @@ use std::fs; +use std::io::Write; use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::process::Output; @@ -52,6 +53,31 @@ fn run_rfc_cli_without_editor(project_dir: &Path, args: &[&str]) -> Output { .expect("Failed to execute rfc-cli") } +// Helper: run the MCP server with newline-delimited JSON input +fn run_rfc_cli_mcp(project_dir: &Path, input: &str) -> Output { + let binary = env!("CARGO_BIN_EXE_rfc-cli"); + let mut child = std::process::Command::new(binary) + .arg("mcp") + .env("RFC_HOME", project_dir.as_os_str()) + .env_remove("RFC_VIEWER") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("Failed to execute rfc-cli mcp"); + + child + .stdin + .take() + .expect("MCP stdin should be available") + .write_all(input.as_bytes()) + .expect("Failed to write MCP input"); + + child + .wait_with_output() + .expect("Failed to wait for rfc-cli mcp") +} + // Helper: run rfc-cli with a custom $RFC_VIEWER fn run_rfc_cli_with_viewer(project_dir: &Path, args: &[&str], viewer: &str) -> Output { let binary = env!("CARGO_BIN_EXE_rfc-cli"); @@ -211,6 +237,10 @@ fn test_init_creates_directory_and_index() { let index_content = fs::read_to_string(&index_path).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&index_content).unwrap(); assert_eq!(parsed["rfcs"], serde_json::json!([])); + let mcp_config: serde_json::Value = + serde_json::from_str(&fs::read_to_string(dir.join(".mcp.json")).unwrap()).unwrap(); + assert_eq!(mcp_config["mcpServers"]["rfc-cli"]["command"], "rfc-cli"); + assert_eq!(mcp_config["mcpServers"]["rfc-cli"]["args"][0], "mcp"); assert!( stdout.contains("Created"), @@ -261,6 +291,26 @@ fn test_init_creates_nested_docs_directory() { cleanup(&dir); } +#[test] +fn test_init_preserves_existing_mcp_servers() { + let dir = create_temp_dir("init_mcp_merge"); + fs::write( + dir.join(".mcp.json"), + r#"{"mcpServers":{"mem-cli":{"command":"mem-cli","args":["mcp"]}}}"#, + ) + .unwrap(); + + let output = run_rfc_cli(&dir, &["init"]); + assert!(output.status.success()); + + let config: serde_json::Value = + serde_json::from_str(&fs::read_to_string(dir.join(".mcp.json")).unwrap()).unwrap(); + assert_eq!(config["mcpServers"]["mem-cli"]["command"], "mem-cli"); + assert_eq!(config["mcpServers"]["rfc-cli"]["args"][0], "mcp"); + + cleanup(&dir); +} + // ============================================================ // Tests for `new` command // ============================================================ @@ -1731,6 +1781,60 @@ fn test_help_shows_new_commands() { assert!(stdout.contains("link"), "help should mention link"); assert!(stdout.contains("unlink"), "help should mention unlink"); assert!(stdout.contains("deps"), "help should mention deps"); + assert!(stdout.contains("mcp"), "help should mention mcp"); + + cleanup(&dir); +} + +#[test] +fn test_version_flags_show_package_version() { + let dir = create_temp_dir("version_flags"); + + let long_output = run_rfc_cli(&dir, &["--version"]); + let short_output = run_rfc_cli(&dir, &["-V"]); + let expected = format!("rfc-cli {}", env!("CARGO_PKG_VERSION")); + + assert!(long_output.status.success()); + assert!(short_output.status.success()); + assert_eq!( + String::from_utf8_lossy(&long_output.stdout).trim(), + expected + ); + assert_eq!( + String::from_utf8_lossy(&short_output.stdout).trim(), + expected + ); + + cleanup(&dir); +} + +#[test] +fn test_mcp_initialize_and_list_rfcs() { + let dir = create_temp_dir("mcp_list"); + run_rfc_cli(&dir, &["init"]); + run_rfc_cli(&dir, &["new", "MCP RFC"]); + + let input = concat!( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n", + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n", + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":", + "{\"name\":\"list_rfcs\",\"arguments\":{}}}\n" + ); + let output = run_rfc_cli_mcp(&dir, input); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let responses = stdout + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + + assert_eq!(responses.len(), 2); + assert_eq!(responses[0]["result"]["serverInfo"]["name"], "rfc-cli"); + assert_eq!( + responses[1]["result"]["structuredContent"]["rfcs"][0]["number"], + "0001" + ); cleanup(&dir); }