From 76f678df151ee5a6299e74408bf9e09120523f85 Mon Sep 17 00:00:00 2001 From: Alexey Nikandrov Date: Tue, 15 Sep 2026 11:13:00 +0300 Subject: [PATCH 1/2] feat: add RFC MCP server Add a read-only JSON-RPC MCP server over stdio with RFC listing, viewing, status, dependency, and ping tools. Register the server in .mcp.json during init, document the integration, add protocol and integration tests, and release as v0.1.6. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .mcp.json | 6 + CHANGELOG.md | 8 + Cargo.lock | 2 +- Cargo.toml | 2 +- Makefile | 3 +- README.md | 35 ++- README_ru.md | 35 ++- docs/rfcs/.index.json | 18 +- docs/rfcs/0010.md | 121 +++++++++ src/cli.rs | 2 + src/commands/init.rs | 45 ++++ src/commands/list.rs | 8 +- src/commands/mcp.rs | 555 ++++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/main.rs | 1 + tests/integration_test.rs | 82 ++++++ 16 files changed, 911 insertions(+), 13 deletions(-) create mode 100644 docs/rfcs/0010.md create mode 100644 src/commands/mcp.rs 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..2838e0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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.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..af7cca9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -239,7 +239,7 @@ dependencies = [ [[package]] name = "rfc-cli" -version = "0.1.5" +version = "0.1.6" dependencies = [ "clap", "clap_builder", diff --git a/Cargo.toml b/Cargo.toml index 6de0ae1..42d626e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rfc-cli" -version = "0.1.5" +version = "0.1.6" 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..366c2e9 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,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 +337,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 +438,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 +456,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..907c45d 100644 --- a/README_ru.md +++ b/README_ru.md @@ -274,6 +274,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 +329,7 @@ rfc-cli reindex | `deps [--reverse]` | Дерево зависимостей | | `check [N]` | Валидация формата | | `doctor [--stale-days N]` | Диагностика здоровья | +| `mcp` | Read-only MCP-сервер через stdio | | `reindex` | Пересборка индекса | ## RFC-процесс @@ -399,6 +430,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 +448,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..7290fe4 100644 --- a/docs/rfcs/.index.json +++ b/docs/rfcs/.index.json @@ -168,8 +168,24 @@ "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": "1789459953", + "content_hash": "5ed22f940a64026b804fc74469b6608298628c8d0f26acbb97863b54a8dcdfea" } ] } \ 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/src/cli.rs b/src/cli.rs index 5e2896c..2c09917 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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..580d4d3 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,38 @@ 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_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); } From 42939eea601884a4629342eacd484fcc0e733aa5 Mon Sep 17 00:00:00 2001 From: Alexey Nikandrov Date: Tue, 15 Sep 2026 11:18:04 +0300 Subject: [PATCH 2/2] feat: add CLI version flags Add standard --version and -V options backed by the package version, with documentation, RFC tracking, and integration coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 5 ++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 7 +++++ README_ru.md | 7 +++++ docs/rfcs/.index.json | 15 +++++++++- docs/rfcs/0011.md | 58 +++++++++++++++++++++++++++++++++++++++ src/cli.rs | 2 +- tests/integration_test.rs | 22 +++++++++++++++ 9 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 docs/rfcs/0011.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2838e0b..728cfd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ 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 diff --git a/Cargo.lock b/Cargo.lock index af7cca9..406bfa2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -239,7 +239,7 @@ dependencies = [ [[package]] name = "rfc-cli" -version = "0.1.6" +version = "0.1.7" dependencies = [ "clap", "clap_builder", diff --git a/Cargo.toml b/Cargo.toml index 42d626e..b473cdb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rfc-cli" -version = "0.1.6" +version = "0.1.7" edition = "2021" [dependencies] diff --git a/README.md b/README.md index 366c2e9..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: diff --git a/README_ru.md b/README_ru.md index 907c45d..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 diff --git a/docs/rfcs/.index.json b/docs/rfcs/.index.json index 7290fe4..75f0fda 100644 --- a/docs/rfcs/.index.json +++ b/docs/rfcs/.index.json @@ -184,8 +184,21 @@ "src/main.rs", "tests/integration_test.rs" ], - "mtime": "1789459953", + "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/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 2c09917..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, diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 580d4d3..049c337 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1786,6 +1786,28 @@ fn test_help_shows_new_commands() { 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");