diff --git a/AGENTS.md b/AGENTS.md index 410089e..7836ff8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ The project is a **Rust workspace** with seven crates: | `tempyr-render` | Document rendering: TOML template parsing, graph collection, markdown output | | `tempyr-linear` | Linear integration: push/pull sync, status mapping, context generation | | `tempyr-cli` | CLI binary (`tempyr`): clap-based, all user-facing commands | -| `tempyr-mcp` | MCP server binary: exposes graph operations as tools for Claude Code | +| `tempyr-mcp` | MCP server library used by `tempyr --mcp`: exposes graph operations as tools for Claude Code | Crate dependency order: `core` <- `index` <- `interview`/`render`/`linear` <- `cli`/`mcp`. diff --git a/CLAUDE.md b/CLAUDE.md index b5a3aec..6ce28bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ The project is a **Rust workspace** with seven crates: | `tempyr-render` | Document rendering: TOML template parsing, graph collection, markdown output | | `tempyr-linear` | Linear integration: push/pull sync, status mapping, context generation | | `tempyr-cli` | CLI binary (`tempyr`): clap-based, all user-facing commands | -| `tempyr-mcp` | MCP server binary: exposes graph operations as tools for Claude Code | +| `tempyr-mcp` | MCP server library used by `tempyr --mcp`: exposes graph operations as tools for Claude Code | Crate dependency order: `core` ← `index` ← `interview`/`render`/`linear` ← `cli`/`mcp`. diff --git a/Cargo.lock b/Cargo.lock index 020c0c1..6e57e43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2937,6 +2937,7 @@ dependencies = [ "tempyr-index", "tempyr-interview", "tempyr-linear", + "tempyr-mcp", "tempyr-render", "tokio", "toml", @@ -3013,6 +3014,7 @@ dependencies = [ name = "tempyr-mcp" version = "0.1.0" dependencies = [ + "anyhow", "rmcp", "schemars", "serde", diff --git a/crates/tempyr-cli/Cargo.toml b/crates/tempyr-cli/Cargo.toml index 6e57ad4..e63a738 100644 --- a/crates/tempyr-cli/Cargo.toml +++ b/crates/tempyr-cli/Cargo.toml @@ -13,6 +13,7 @@ tempyr-index = { path = "../tempyr-index" } tempyr-interview = { path = "../tempyr-interview" } tempyr-render = { path = "../tempyr-render" } tempyr-linear = { path = "../tempyr-linear" } +tempyr-mcp = { path = "../tempyr-mcp" } anyhow = "1" blake3 = { workspace = true } clap = { workspace = true } diff --git a/crates/tempyr-cli/src/main.rs b/crates/tempyr-cli/src/main.rs index 2c6fe2c..ec9ae24 100644 --- a/crates/tempyr-cli/src/main.rs +++ b/crates/tempyr-cli/src/main.rs @@ -2,6 +2,7 @@ mod commands; mod config; use clap::{Parser, Subcommand}; +use std::ffi::{OsStr, OsString}; use std::path::PathBuf; #[derive(Parser)] @@ -325,10 +326,48 @@ pub enum LinearAction { }, } -fn main() { - let cli = Cli::parse(); +#[derive(Debug, PartialEq, Eq)] +enum LaunchMode { + Cli, + Mcp, + InvalidMcpArgs, +} + +fn detect_launch_mode_from_args(args: I) -> LaunchMode +where + I: IntoIterator, + S: Into, +{ + let mut args = args.into_iter().map(Into::into); + let _program = args.next(); + + match args.next() { + Some(arg) if arg == OsStr::new("--mcp") => { + if args.next().is_none() { + LaunchMode::Mcp + } else { + LaunchMode::InvalidMcpArgs + } + } + _ => LaunchMode::Cli, + } +} + +fn mcp_args_error() -> anyhow::Error { + anyhow::anyhow!( + "`--mcp` must be the first and only argument. Launch the MCP server with `tempyr --mcp`." + ) +} - if let Err(e) = run(cli) { +#[tokio::main] +async fn main() { + let result = match detect_launch_mode_from_args(std::env::args_os()) { + LaunchMode::Cli => run(Cli::parse()), + LaunchMode::Mcp => tempyr_mcp::serve_stdio().await, + LaunchMode::InvalidMcpArgs => Err(mcp_args_error()), + }; + + if let Err(e) = result { eprintln!("Error: {e}"); std::process::exit(1); } @@ -462,3 +501,40 @@ fn run(cli: Cli) -> anyhow::Result<()> { Commands::Update { check, force } => commands::update::run(check, force), } } + +#[cfg(test)] +mod tests { + use super::{LaunchMode, detect_launch_mode_from_args}; + + #[test] + fn detect_mcp_mode_when_flag_is_first_arg() { + assert_eq!( + detect_launch_mode_from_args(["tempyr", "--mcp"]), + LaunchMode::Mcp + ); + } + + #[test] + fn detect_cli_mode_for_normal_commands() { + assert_eq!( + detect_launch_mode_from_args(["tempyr", "validate"]), + LaunchMode::Cli + ); + } + + #[test] + fn reject_extra_args_after_mcp_flag() { + assert_eq!( + detect_launch_mode_from_args(["tempyr", "--mcp", "validate"]), + LaunchMode::InvalidMcpArgs + ); + } + + #[test] + fn keep_cli_mode_for_existing_global_flags() { + assert_eq!( + detect_launch_mode_from_args(["tempyr", "--json", "validate"]), + LaunchMode::Cli + ); + } +} diff --git a/crates/tempyr-cli/tests/integration.rs b/crates/tempyr-cli/tests/integration.rs index 7f00d97..5ef5a54 100644 --- a/crates/tempyr-cli/tests/integration.rs +++ b/crates/tempyr-cli/tests/integration.rs @@ -1,6 +1,9 @@ use assert_cmd::Command; use predicates::prelude::*; use std::fs; +use std::process::{Command as ProcessCommand, Stdio}; +use std::thread; +use std::time::Duration; use tempfile::TempDir; fn tempyr() -> Command { @@ -50,6 +53,40 @@ fn test_init_fails_if_already_initialized() { .stderr(predicate::str::contains("Already initialized")); } +#[test] +fn test_mcp_flag_rejects_extra_args() { + let tmp = TempDir::new().unwrap(); + + tempyr() + .current_dir(tmp.path()) + .args(["--mcp", "validate"]) + .assert() + .failure() + .stderr(predicate::str::contains("`--mcp` must be the first and only argument")); +} + +#[test] +fn test_mcp_mode_starts_on_stdio() { + let tempyr_bin = assert_cmd::cargo::cargo_bin("tempyr"); + let mut child = ProcessCommand::new(tempyr_bin) + .arg("--mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + + thread::sleep(Duration::from_millis(300)); + + if let Some(status) = child.try_wait().unwrap() { + panic!("tempyr --mcp exited early with status {status}"); + } + + child.kill().unwrap(); + let output = child.wait_with_output().unwrap(); + assert!(output.stdout.is_empty()); +} + #[test] fn test_validate_empty_graph() { let tmp = TempDir::new().unwrap(); diff --git a/crates/tempyr-mcp/Cargo.toml b/crates/tempyr-mcp/Cargo.toml index 54ef0e7..bff85f9 100644 --- a/crates/tempyr-mcp/Cargo.toml +++ b/crates/tempyr-mcp/Cargo.toml @@ -3,11 +3,8 @@ name = "tempyr-mcp" version.workspace = true edition.workspace = true -[[bin]] -name = "tempyr-mcp" -path = "src/main.rs" - [dependencies] +anyhow = "1" tempyr-core = { path = "../tempyr-core" } tempyr-index = { path = "../tempyr-index" } tempyr-interview = { path = "../tempyr-interview" } diff --git a/crates/tempyr-mcp/src/handler.rs b/crates/tempyr-mcp/src/handler.rs index a498f30..0da3ca1 100644 --- a/crates/tempyr-mcp/src/handler.rs +++ b/crates/tempyr-mcp/src/handler.rs @@ -1359,6 +1359,12 @@ impl TempyrServer { } } +impl Default for TempyrServer { + fn default() -> Self { + Self::new() + } +} + #[tool_handler] impl ServerHandler for TempyrServer { fn get_info(&self) -> ServerInfo { diff --git a/crates/tempyr-mcp/src/lib.rs b/crates/tempyr-mcp/src/lib.rs new file mode 100644 index 0000000..4781d8d --- /dev/null +++ b/crates/tempyr-mcp/src/lib.rs @@ -0,0 +1,13 @@ +pub mod handler; + +use anyhow::Result; +use rmcp::{ServiceExt, transport::stdio}; + +pub use handler::TempyrServer; + +pub async fn serve_stdio() -> Result<()> { + let service = TempyrServer::default().serve(stdio()).await?; + service.waiting().await?; + + Ok(()) +} diff --git a/crates/tempyr-mcp/src/main.rs b/crates/tempyr-mcp/src/main.rs deleted file mode 100644 index 58710f4..0000000 --- a/crates/tempyr-mcp/src/main.rs +++ /dev/null @@ -1,13 +0,0 @@ -mod handler; - -use rmcp::{ServiceExt, transport::stdio}; - -#[tokio::main] -async fn main() -> Result<(), Box> { - eprintln!("tempyr-mcp: MCP server starting on stdio"); - - let service = handler::TempyrServer::new().serve(stdio()).await?; - service.waiting().await?; - - Ok(()) -} diff --git a/docs/graphspec.md b/docs/graphspec.md index d0c5257..db1217f 100644 --- a/docs/graphspec.md +++ b/docs/graphspec.md @@ -144,9 +144,9 @@ tempyr/ # Rust workspace root │ │ ├── src/ │ │ │ └── main.rs │ │ └── Cargo.toml -│ └── tempyr-mcp/ # MCP server binary +│ └── tempyr-mcp/ # MCP server library (used by `tempyr --mcp`) │ ├── src/ -│ │ └── main.rs +│ │ └── lib.rs │ └── Cargo.toml ├── schema/ │ └── default-schema.toml # Default schema shipped with the tool @@ -1036,8 +1036,7 @@ COMMANDS: import Import unstructured text and propose nodes # ─── MCP Server ────────────────────────── - serve Start the MCP server (for Claude Code / other clients) - --port TCP port (default: stdio for MCP) + --mcp Start the MCP server on stdio (for Claude Code / other clients) OPTIONS: --graph-dir Path to graph directory (default: ./graph) @@ -1239,8 +1238,7 @@ auto_advance_phases = true # auto-transition when gaps are filled session_timeout_hours = 168 # sessions expire after 7 days [mcp] -transport = "stdio" # stdio | tcp -# tcp_port = 3000 # only if transport = tcp +transport = "stdio" # stdio transport for `tempyr --mcp` ``` --- @@ -1267,7 +1265,7 @@ transport = "stdio" # stdio | tcp **Deliverables**: - `tempyr-core`: Node/edge parsing, schema validation, in-memory graph construction -- `tempyr-mcp`: MCP server with tools: `graph_get_node`, `graph_add_node`, `graph_add_edge`, `graph_search` (basic grep-based before FTS5), `graph_validate`, `graph_traverse` +- `tempyr-mcp` library, launched via `tempyr --mcp`: MCP server with tools `graph_get_node`, `graph_add_node`, `graph_add_edge`, `graph_search` (basic grep-based before FTS5), `graph_validate`, `graph_traverse` - Basic `interview_start` and `interview_answer` (gap detection against schema, LLM calls for extraction) - `interview_commit` writes files - Session persistence to JSON files @@ -1429,7 +1427,7 @@ an edge, both source and target files must be updated. These are decisions that should be made during implementation, not before: -1. **MCP transport**: Should the MCP server use stdio (simplest, works with Claude Code directly) or TCP (supports multiple clients simultaneously)? Start with stdio, add TCP later if needed. +1. **MCP transport**: Keep the MCP server stdio-only in v1 via `tempyr --mcp`. Revisit TCP only if multi-client support becomes necessary. 2. **Embedding model choice**: Anthropic's voyage-3 vs OpenAI's text-embedding-3-small vs a local model via `fastembed-rs`. Start with API-based, switch to local if latency or cost becomes an issue.