diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 546fd6c..0ecc50c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -33,6 +33,7 @@ make compare # Generate and compare Rust mapping exports make jl-testdata # Regenerate Julia parity test data (requires julia) make cli # Build the pred CLI tool (release mode) make cli-demo # Run closed-loop CLI demo (exercises all commands) +make mcp-test # Run MCP server tests (unit + integration) make run-plan # Execute a plan with Claude autorun make release V=x.y.z # Tag and push a new release (CI publishes to crates.io) ``` diff --git a/.claude/skills/review-implementation/SKILL.md b/.claude/skills/review-implementation/SKILL.md index ed2c880..3e543ac 100644 --- a/.claude/skills/review-implementation/SKILL.md +++ b/.claude/skills/review-implementation/SKILL.md @@ -97,6 +97,10 @@ Read the implementation files and assess: 3. **Example quality** -- Is it tutorial-style? Does it use the instance from the issue? Does the JSON export include both source and target data? 4. **Paper quality** -- Is the reduction-rule statement precise? Is the proof sketch sound? Is the example figure clear? +### Code Quality Principles (applies to both Models and Rules): +1. **DRY (Don't Repeat Yourself)** -- Is there duplicated logic that should be extracted into a shared helper, utility function, or common module? Check for copy-pasted code blocks across files (e.g., similar graph construction, weight handling, or solution extraction patterns). If duplication is found, suggest extracting shared logic. +2. **KISS (Keep It Simple, Stupid)** -- Is the implementation unnecessarily complex? Look for: over-engineered abstractions, convoluted control flow, premature generalization, or layers of indirection that add no value. The implementation should be as simple as possible while remaining correct and maintainable. + ## Output Format Present results as: @@ -119,7 +123,9 @@ Present results as: ### Semantic Review - evaluate() correctness: OK - dims() correctness: OK -- [any issues found] +- DRY compliance: OK / [duplicated logic found in ...] +- KISS compliance: OK / [unnecessary complexity found in ...] +- [any other issues found] ### Summary - X/Y structural checks passed diff --git a/.gitignore b/.gitignore index 56a172d..1903176 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ Cargo.lock # Developer-specific Claude Code settings .claude/settings.local.json +.claude/mcp.json # IDE .idea/ diff --git a/Makefile b/Makefile index a924bd6..a41d69b 100644 --- a/Makefile +++ b/Makefile @@ -144,9 +144,9 @@ endif git push origin main --tags @echo "v$(V) pushed — CI will publish to crates.io" -# Build the pred CLI tool +# Build and install the pred CLI tool cli: - cargo build -p problemreductions-cli --release + cargo install --path problemreductions-cli # Generate Rust mapping JSON exports for all graphs and modes GRAPHS := diamond bull house petersen diff --git a/README.md b/README.md index 5670296..71ed753 100644 --- a/README.md +++ b/README.md @@ -44,13 +44,22 @@ See the [Getting Started](https://codingthrust.github.io/problem-reductions/gett ## MCP Server (AI Integration) -The `pred` CLI includes a built-in [MCP](https://modelcontextprotocol.io/) server for AI assistant integration: +The `pred` CLI includes a built-in [MCP](https://modelcontextprotocol.io/) server for AI assistant integration (Claude Code, Cursor, Windsurf, OpenCode, etc.). + +Add to your client's MCP config file: ```json {"mcpServers": {"problemreductions": {"command": "pred", "args": ["mcp"]}}} ``` -See the [MCP documentation](https://codingthrust.github.io/problem-reductions/mcp.html) for available tools, prompts, and configuration details. +| Client | Config file | +|--------|------------| +| Claude Code / Desktop | `.mcp.json` or `~/.claude/mcp.json` | +| Cursor | `.cursor/mcp.json` | +| Windsurf | `~/.codeium/windsurf/mcp_config.json` | +| OpenCode | `opencode.json` (use `{"mcp": {"problemreductions": {"type": "local", "command": ["pred", "mcp"]}}}`) | + +See the [MCP documentation](https://codingthrust.github.io/problem-reductions/mcp.html) for available tools, prompts, and full configuration details. ## Contributing diff --git a/docs/plans/2026-02-22-mcp-prompts-redesign-design.md b/docs/plans/2026-02-22-mcp-prompts-redesign-design.md new file mode 100644 index 0000000..8ef9a27 --- /dev/null +++ b/docs/plans/2026-02-22-mcp-prompts-redesign-design.md @@ -0,0 +1,134 @@ +# MCP Prompts Redesign: Task-Oriented Prompts + +## Problem + +The current 3 MCP prompts (`analyze_problem`, `reduction_walkthrough`, `explore_graph`) are tool-centric — they list which tools to call rather than expressing what the user wants to accomplish. This makes them disconnected from how researchers, students, and LLM agents actually think about reductions. + +## Design + +Replace the 3 existing prompts with 7 task-oriented prompts. All prompt text frames requests as user questions. No tool names appear in prompt text — the LLM decides which tools to call. + +### Prompt Inventory + +| # | Name | Arguments | User question it answers | +|---|------|-----------|--------------------------| +| 1 | `what_is` | `problem` (req) | "What is MaxCut?" | +| 2 | `model_my_problem` | `description` (req) | "I have a scheduling problem — what maps to it?" | +| 3 | `compare` | `problem_a` (req), `problem_b` (req) | "How do MIS and Vertex Cover relate?" | +| 4 | `reduce` | `source` (req), `target` (req) | "Walk me through reducing MIS to QUBO" | +| 5 | `solve` | `problem_type` (req), `instance` (req) | "Solve this graph for maximum independent set" | +| 6 | `find_reduction` | `source` (req), `target` (req) | "What's the cheapest path from SAT to QUBO?" | +| 7 | `overview` | *(none)* | "Show me the full problem landscape" | + +### Prompt Texts + +#### 1. `what_is` + +**Description:** Explain a problem type: what it models, its variants, and how it connects to other problems + +``` +Explain the "{problem}" problem to me. + +What does it model in the real world? What are its variants (graph types, +weight types)? What other problems can it reduce to, and which problems +reduce to it? + +Give me a concise summary suitable for someone encountering this problem +for the first time, then show the technical details. +``` + +#### 2. `model_my_problem` + +**Description:** Map a real-world problem to the closest NP-hard problem type in the reduction graph + +``` +I have a real-world problem and I need help identifying which NP-hard +problem type it maps to. + +Here's my problem: "{description}" + +Look through the available problem types in the reduction graph and +identify which one(s) best model my problem. Explain why it's a good fit, +what the variables and constraints map to, and suggest how I could encode +my specific instance. +``` + +#### 3. `compare` + +**Description:** Compare two problem types: their relationship, differences, and reduction path between them + +``` +Compare "{problem_a}" and "{problem_b}". + +How are they related? Is there a direct reduction between them, or do they +connect through intermediate problems? What are the key differences in +what they model? If one can be reduced to the other, what is the overhead? +``` + +#### 4. `reduce` + +**Description:** Step-by-step reduction walkthrough: create an instance, reduce it, solve it, and map the solution back + +``` +Walk me through reducing a "{source}" instance to "{target}", step by step. + +1. Find the reduction path and explain the overhead. +2. Create a small, concrete example instance of "{source}". +3. Reduce it to "{target}" and show what the transformed instance looks like. +4. Solve the reduced instance. +5. Explain how the solution maps back to the original problem. + +Use a small example so I can follow each transformation by hand. +``` + +#### 5. `solve` + +**Description:** Create and solve a problem instance, showing the optimal solution + +``` +Create a {problem_type} instance with these parameters: {instance} + +Solve it and show me: +- The problem instance details (size, structure) +- The optimal solution and its objective value +- Why this solution is optimal (briefly) +``` + +#### 6. `find_reduction` + +**Description:** Find the best reduction path between two problems, with cost analysis + +``` +Find the best way to reduce "{source}" to "{target}". + +Show me the cheapest reduction path and explain the cost at each step. +Are there alternative paths? If so, compare them — which is better for +small instances vs. large instances? +``` + +#### 7. `overview` + +**Description:** Explore the full landscape of NP-hard problems and reductions in the graph + +``` +Give me an overview of the NP-hard problem reduction landscape. + +How many problem types are registered? What are the major categories +(graph, SAT, optimization)? Which problems are the most connected hubs? +Which problems can reach the most targets through reductions? + +Summarize the structure so I understand what's available and where to +start exploring. +``` + +## Scope + +- **Changed:** `problemreductions-cli/src/mcp/prompts.rs` (prompt definitions) +- **Changed:** `problemreductions-cli/src/mcp/tests.rs` (prompt tests) +- **Unchanged:** All 10 MCP tools, tool handlers, server infrastructure + +## Testing + +- Unit tests: verify `list_prompts` returns 7 prompts with correct names/arguments +- Unit tests: verify `get_prompt` returns correct message text for each prompt +- Integration test: call each prompt via JSON-RPC and verify response structure diff --git a/docs/plans/2026-02-22-mcp-prompts-redesign.md b/docs/plans/2026-02-22-mcp-prompts-redesign.md new file mode 100644 index 0000000..35f1571 --- /dev/null +++ b/docs/plans/2026-02-22-mcp-prompts-redesign.md @@ -0,0 +1,411 @@ +# MCP Prompts Redesign Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace 3 tool-centric MCP prompts with 7 task-oriented prompts that map to real user journeys. + +**Architecture:** Rewrite `prompts.rs` with new `list_prompts()` and `get_prompt()` functions. Update integration test to match new prompt set. No tool or server changes. + +**Tech Stack:** Rust, rmcp crate (MCP SDK), serde_json + +--- + +### Task 1: Rewrite `list_prompts()` with 7 new prompt definitions + +**Files:** +- Modify: `problemreductions-cli/src/mcp/prompts.rs:4-43` + +**Step 1: Replace `list_prompts()` body** + +Replace the entire `list_prompts()` function body with 7 new `Prompt::new(...)` entries: + +```rust +pub fn list_prompts() -> Vec { + vec![ + Prompt::new( + "what_is", + Some("Explain a problem type: what it models, its variants, and how it connects to other problems"), + Some(vec![PromptArgument { + name: "problem".into(), + title: None, + description: Some("Problem name or alias (e.g., MIS, QUBO, MaxCut)".into()), + required: Some(true), + }]), + ), + Prompt::new( + "model_my_problem", + Some("Map a real-world problem to the closest NP-hard problem type in the reduction graph"), + Some(vec![PromptArgument { + name: "description".into(), + title: None, + description: Some("Free-text description of your real-world problem".into()), + required: Some(true), + }]), + ), + Prompt::new( + "compare", + Some("Compare two problem types: their relationship, differences, and reduction path between them"), + Some(vec![ + PromptArgument { + name: "problem_a".into(), + title: None, + description: Some("First problem name or alias".into()), + required: Some(true), + }, + PromptArgument { + name: "problem_b".into(), + title: None, + description: Some("Second problem name or alias".into()), + required: Some(true), + }, + ]), + ), + Prompt::new( + "reduce", + Some("Step-by-step reduction walkthrough: create an instance, reduce it, solve it, and map the solution back"), + Some(vec![ + PromptArgument { + name: "source".into(), + title: None, + description: Some("Source problem name or alias".into()), + required: Some(true), + }, + PromptArgument { + name: "target".into(), + title: None, + description: Some("Target problem name or alias".into()), + required: Some(true), + }, + ]), + ), + Prompt::new( + "solve", + Some("Create and solve a problem instance, showing the optimal solution"), + Some(vec![ + PromptArgument { + name: "problem_type".into(), + title: None, + description: Some("Problem type (e.g., MIS, SAT, QUBO, MaxCut)".into()), + required: Some(true), + }, + PromptArgument { + name: "instance".into(), + title: None, + description: Some( + "Instance parameters (e.g., \"edges: 0-1,1-2\" or \"clauses: 1,2;-1,3\")".into(), + ), + required: Some(true), + }, + ]), + ), + Prompt::new( + "find_reduction", + Some("Find the best reduction path between two problems, with cost analysis"), + Some(vec![ + PromptArgument { + name: "source".into(), + title: None, + description: Some("Source problem name or alias".into()), + required: Some(true), + }, + PromptArgument { + name: "target".into(), + title: None, + description: Some("Target problem name or alias".into()), + required: Some(true), + }, + ]), + ), + Prompt::new( + "overview", + Some("Explore the full landscape of NP-hard problems and reductions in the graph"), + None, + ), + ] +} +``` + +**Step 2: Run `cargo check` to verify compilation** + +Run: `cargo check -p problemreductions-cli` +Expected: compiles without errors + +**Step 3: Commit** + +```bash +git add problemreductions-cli/src/mcp/prompts.rs +git commit -m "refactor(mcp): replace prompt definitions with 7 task-oriented prompts" +``` + +--- + +### Task 2: Rewrite `get_prompt()` with new prompt texts + +**Files:** +- Modify: `problemreductions-cli/src/mcp/prompts.rs:46-128` + +**Step 1: Replace `get_prompt()` body** + +Replace the entire `get_prompt()` match block with 7 new arms: + +```rust +pub fn get_prompt( + name: &str, + arguments: &serde_json::Map, +) -> Option { + match name { + "what_is" => { + let problem = arguments + .get("problem") + .and_then(|v| v.as_str()) + .unwrap_or("MIS"); + + Some(GetPromptResult { + description: Some(format!("Explain the {} problem", problem)), + messages: vec![PromptMessage::new_text( + PromptMessageRole::User, + format!( + "Explain the \"{problem}\" problem to me.\n\n\ + What does it model in the real world? What are its variants \ + (graph types, weight types)? What other problems can it reduce \ + to, and which problems reduce to it?\n\n\ + Give me a concise summary suitable for someone encountering this \ + problem for the first time, then show the technical details." + ), + )], + }) + } + + "model_my_problem" => { + let description = arguments + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("(no description provided)"); + + Some(GetPromptResult { + description: Some("Map a real-world problem to an NP-hard problem type".into()), + messages: vec![PromptMessage::new_text( + PromptMessageRole::User, + format!( + "I have a real-world problem and I need help identifying which \ + NP-hard problem type it maps to.\n\n\ + Here's my problem: \"{description}\"\n\n\ + Look through the available problem types in the reduction graph \ + and identify which one(s) best model my problem. Explain why it's \ + a good fit, what the variables and constraints map to, and suggest \ + how I could encode my specific instance." + ), + )], + }) + } + + "compare" => { + let problem_a = arguments + .get("problem_a") + .and_then(|v| v.as_str()) + .unwrap_or("MIS"); + let problem_b = arguments + .get("problem_b") + .and_then(|v| v.as_str()) + .unwrap_or("VertexCover"); + + Some(GetPromptResult { + description: Some(format!("Compare {} and {}", problem_a, problem_b)), + messages: vec![PromptMessage::new_text( + PromptMessageRole::User, + format!( + "Compare \"{problem_a}\" and \"{problem_b}\".\n\n\ + How are they related? Is there a direct reduction between them, \ + or do they connect through intermediate problems? What are the \ + key differences in what they model? If one can be reduced to the \ + other, what is the overhead?" + ), + )], + }) + } + + "reduce" => { + let source = arguments + .get("source") + .and_then(|v| v.as_str()) + .unwrap_or("MIS"); + let target = arguments + .get("target") + .and_then(|v| v.as_str()) + .unwrap_or("QUBO"); + + Some(GetPromptResult { + description: Some(format!( + "Reduction walkthrough from {} to {}", + source, target + )), + messages: vec![PromptMessage::new_text( + PromptMessageRole::User, + format!( + "Walk me through reducing a \"{source}\" instance to \"{target}\", \ + step by step.\n\n\ + 1. Find the reduction path and explain the overhead.\n\ + 2. Create a small, concrete example instance of \"{source}\".\n\ + 3. Reduce it to \"{target}\" and show what the transformed instance \ + looks like.\n\ + 4. Solve the reduced instance.\n\ + 5. Explain how the solution maps back to the original problem.\n\n\ + Use a small example so I can follow each transformation by hand." + ), + )], + }) + } + + "solve" => { + let problem_type = arguments + .get("problem_type") + .and_then(|v| v.as_str()) + .unwrap_or("MIS"); + let instance = arguments + .get("instance") + .and_then(|v| v.as_str()) + .unwrap_or("edges: 0-1,1-2,2-0"); + + Some(GetPromptResult { + description: Some(format!("Solve a {} instance", problem_type)), + messages: vec![PromptMessage::new_text( + PromptMessageRole::User, + format!( + "Create a {problem_type} instance with these parameters: {instance}\n\n\ + Solve it and show me:\n\ + - The problem instance details (size, structure)\n\ + - The optimal solution and its objective value\n\ + - Why this solution is optimal (briefly)" + ), + )], + }) + } + + "find_reduction" => { + let source = arguments + .get("source") + .and_then(|v| v.as_str()) + .unwrap_or("SAT"); + let target = arguments + .get("target") + .and_then(|v| v.as_str()) + .unwrap_or("QUBO"); + + Some(GetPromptResult { + description: Some(format!( + "Find reduction path from {} to {}", + source, target + )), + messages: vec![PromptMessage::new_text( + PromptMessageRole::User, + format!( + "Find the best way to reduce \"{source}\" to \"{target}\".\n\n\ + Show me the cheapest reduction path and explain the cost at each \ + step. Are there alternative paths? If so, compare them — which is \ + better for small instances vs. large instances?" + ), + )], + }) + } + + "overview" => Some(GetPromptResult { + description: Some("Overview of the NP-hard problem reduction landscape".into()), + messages: vec![PromptMessage::new_text( + PromptMessageRole::User, + "Give me an overview of the NP-hard problem reduction landscape.\n\n\ + How many problem types are registered? What are the major categories \ + (graph, SAT, optimization)? Which problems are the most connected hubs? \ + Which problems can reach the most targets through reductions?\n\n\ + Summarize the structure so I understand what's available and where to \ + start exploring." + .to_string(), + )], + }), + + _ => None, + } +} +``` + +**Step 2: Run `cargo check` to verify compilation** + +Run: `cargo check -p problemreductions-cli` +Expected: compiles without errors + +**Step 3: Commit** + +```bash +git add problemreductions-cli/src/mcp/prompts.rs +git commit -m "refactor(mcp): rewrite prompt texts to be task-oriented, not tool-centric" +``` + +--- + +### Task 3: Update integration test + +**Files:** +- Modify: `problemreductions-cli/tests/mcp_integration.rs` (the `test_mcp_server_prompts_list` test) + +**Step 1: Update the test assertions** + +Change the prompt count from 3 to 7 and update the name checks: + +```rust +assert_eq!( + prompts.len(), + 7, + "Expected 7 prompts, got {}: {:?}", + prompts.len(), + prompts + .iter() + .map(|p| p["name"].as_str().unwrap_or("?")) + .collect::>() +); + +let prompt_names: Vec<&str> = prompts.iter().filter_map(|p| p["name"].as_str()).collect(); +assert!(prompt_names.contains(&"what_is")); +assert!(prompt_names.contains(&"model_my_problem")); +assert!(prompt_names.contains(&"compare")); +assert!(prompt_names.contains(&"reduce")); +assert!(prompt_names.contains(&"solve")); +assert!(prompt_names.contains(&"find_reduction")); +assert!(prompt_names.contains(&"overview")); +``` + +**Step 2: Run the integration test** + +Run: `cargo test -p problemreductions-cli --test mcp_integration test_mcp_server_prompts_list` +Expected: PASS + +**Step 3: Run full MCP test suite** + +Run: `make mcp-test` +Expected: all tests pass + +**Step 4: Commit** + +```bash +git add problemreductions-cli/tests/mcp_integration.rs +git commit -m "test(mcp): update prompt integration test for 7 task-oriented prompts" +``` + +--- + +### Task 4: Rebuild CLI and manual verification + +**Step 1: Rebuild the CLI** + +Run: `make cli` +Expected: successful install + +**Step 2: Verify prompts list via JSON-RPC** + +Run the MCP server and list prompts to confirm all 7 appear with correct names and argument counts. + +**Step 3: Verify one prompt get** + +Call `prompts/get` for `what_is` with `{"problem": "MaxCut"}` and confirm the response contains the new task-oriented text (no tool names like `show_problem`). + +**Step 4: Verify another prompt get** + +Call `prompts/get` for `solve` with `{"problem_type": "MIS", "instance": "edges: 0-1,1-2"}` and confirm the response text. diff --git a/docs/src/mcp.md b/docs/src/mcp.md index d71aba1..0b3b7b5 100644 --- a/docs/src/mcp.md +++ b/docs/src/mcp.md @@ -1,16 +1,16 @@ # MCP Server -The [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) is an open standard that allows AI assistants to interact with external tools and data sources. The `pred` CLI includes a built-in MCP server that exposes the full reduction graph, problem creation, solving, and reduction capabilities to any MCP-compatible AI assistant (such as Claude Code, Cursor, or Windsurf). +The [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) is an open standard that allows AI assistants to interact with external tools and data sources. The `pred` CLI includes a built-in MCP server that exposes the full reduction graph, problem creation, solving, and reduction capabilities to any MCP-compatible AI assistant (such as Claude Code, Cursor, Windsurf, or OpenCode). ## Installation -Install the `pred` CLI tool: +### Via cargo ```bash cargo install problemreductions-cli ``` -Or build from source: +### From source ```bash git clone https://github.com/CodingThrust/problem-reductions @@ -20,9 +20,9 @@ make cli # builds target/release/pred ## Configuration -### Claude Code +### Claude Code / Claude Desktop -Add the following to your project's `.mcp.json` file (or `~/.claude/mcp.json` for global configuration): +Add to your project's `.mcp.json` (or `~/.claude/mcp.json` for global): ```json { @@ -35,6 +35,51 @@ Add the following to your project's `.mcp.json` file (or `~/.claude/mcp.json` fo } ``` +### Cursor + +Add to `.cursor/mcp.json` in your project root (or `~/.cursor/mcp.json` for global): + +```json +{ + "mcpServers": { + "problemreductions": { + "command": "pred", + "args": ["mcp"] + } + } +} +``` + +### Windsurf + +Add to `~/.codeium/windsurf/mcp_config.json`: + +```json +{ + "mcpServers": { + "problemreductions": { + "command": "pred", + "args": ["mcp"] + } + } +} +``` + +### OpenCode + +Add to `opencode.json` in your project root: + +```json +{ + "mcp": { + "problemreductions": { + "type": "local", + "command": ["pred", "mcp"] + } + } +} +``` + ### Generic MCP Client Any MCP client that supports the stdio transport can connect to the server by running: @@ -79,9 +124,9 @@ The server provides 3 prompt templates that guide the AI assistant through commo | `reduction_walkthrough` | `source` (required), `target` (required) | End-to-end reduction walkthrough: find a path, create an instance, reduce it, and solve the result | | `explore_graph` | *(none)* | Explore the reduction graph: list all problems, export the graph, and analyze its structure | -## Example Usage with Claude Code +## Example Usage -Once configured, you can interact with the reduction graph naturally through conversation: +Once configured, you can interact with the reduction graph naturally through your AI assistant: ``` > What problems can MaximumIndependentSet reduce to? diff --git a/examples/reduction_ksatisfiability_to_satisfiability.rs b/examples/reduction_ksatisfiability_to_satisfiability.rs index c8cd7cc..620e889 100644 --- a/examples/reduction_ksatisfiability_to_satisfiability.rs +++ b/examples/reduction_ksatisfiability_to_satisfiability.rs @@ -38,7 +38,11 @@ pub fn run() { let ksat = KSatisfiability::::new(4, clauses); - println!("Source: KSatisfiability with {} variables, {} clauses", ksat.num_vars(), ksat.num_clauses()); + println!( + "Source: KSatisfiability with {} variables, {} clauses", + ksat.num_vars(), + ksat.num_clauses() + ); for (i, c) in clause_strings.iter().enumerate() { println!(" C{}: {}", i + 1, c); } @@ -75,13 +79,7 @@ pub fn run() { let assignment: Vec = ksat_sol .iter() .enumerate() - .map(|(i, &v)| { - format!( - "x{}={}", - i + 1, - if v == 1 { "T" } else { "F" } - ) - }) + .map(|(i, &v)| format!("x{}={}", i + 1, if v == 1 { "T" } else { "F" })) .collect(); println!(" [{}] -> valid: {}", assignment.join(", "), valid); assert!(valid, "Extracted K-SAT solution must be valid"); diff --git a/examples/reduction_maximumsetpacking_to_maximumindependentset.rs b/examples/reduction_maximumsetpacking_to_maximumindependentset.rs index 3f924df..7e71908 100644 --- a/examples/reduction_maximumsetpacking_to_maximumindependentset.rs +++ b/examples/reduction_maximumsetpacking_to_maximumindependentset.rs @@ -48,7 +48,11 @@ pub fn run() { println!("\nTarget: MaximumIndependentSet"); println!(" Vertices: {}", target.graph().num_vertices()); - println!(" Edges: {} {:?}", target.graph().num_edges(), target.graph().edges()); + println!( + " Edges: {} {:?}", + target.graph().num_edges(), + target.graph().edges() + ); // 3. Solve the target problem let solver = BruteForce::new(); diff --git a/examples/reduction_satisfiability_to_circuitsat.rs b/examples/reduction_satisfiability_to_circuitsat.rs index f217649..823f5b5 100644 --- a/examples/reduction_satisfiability_to_circuitsat.rs +++ b/examples/reduction_satisfiability_to_circuitsat.rs @@ -23,9 +23,9 @@ pub fn run() { let sat = Satisfiability::new( 3, vec![ - CNFClause::new(vec![1, -2, 3]), // x1 v ~x2 v x3 - CNFClause::new(vec![-1, 2]), // ~x1 v x2 - CNFClause::new(vec![2, 3]), // x2 v x3 + CNFClause::new(vec![1, -2, 3]), // x1 v ~x2 v x3 + CNFClause::new(vec![-1, 2]), // ~x1 v x2 + CNFClause::new(vec![2, 3]), // x2 v x3 ], ); @@ -53,10 +53,7 @@ pub fn run() { circuit_sat.num_variables(), circuit_sat.circuit().num_assignments() ); - println!( - " Variables: {:?}", - circuit_sat.variable_names() - ); + println!(" Variables: {:?}", circuit_sat.variable_names()); println!(" Each clause becomes an OR gate; a final AND gate combines them."); // 3. Solve the target CircuitSAT problem (satisfaction problem) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 724af1e..3bb5a2f 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -154,17 +154,25 @@ Examples: #[cfg(feature = "mcp")] #[command(after_help = "\ Start a stdio-based MCP server that exposes problem reduction tools -to AI assistants like Claude Desktop and Claude Code. - -Configuration (Claude Code .mcp.json): - { - \"mcpServers\": { - \"problemreductions\": { - \"command\": \"pred\", - \"args\": [\"mcp\"] - } - } - } +to any MCP-compatible AI assistant. + +Configuration: + + Claude Code / Claude Desktop (.mcp.json or ~/.claude/mcp.json): + { \"mcpServers\": { \"problemreductions\": { + \"command\": \"pred\", \"args\": [\"mcp\"] } } } + + Cursor (.cursor/mcp.json): + { \"mcpServers\": { \"problemreductions\": { + \"command\": \"pred\", \"args\": [\"mcp\"] } } } + + Windsurf (~/.codeium/windsurf/mcp_config.json): + { \"mcpServers\": { \"problemreductions\": { + \"command\": \"pred\", \"args\": [\"mcp\"] } } } + + OpenCode (opencode.json): + { \"mcp\": { \"problemreductions\": { + \"type\": \"local\", \"command\": [\"pred\", \"mcp\"] } } } Test with MCP Inspector: npx @modelcontextprotocol/inspector pred mcp")] diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 8ee6b6f..139bbcc 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -155,15 +155,15 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // Factoring "Factoring" => { let usage = "Usage: pred create Factoring --target 15 --bits-m 4 --bits-n 4"; - let target = args.target.ok_or_else(|| { - anyhow::anyhow!("Factoring requires --target\n\n{usage}") - })?; - let m = args.bits_m.ok_or_else(|| { - anyhow::anyhow!("Factoring requires --bits-m\n\n{usage}") - })?; - let n = args.bits_n.ok_or_else(|| { - anyhow::anyhow!("Factoring requires --bits-n\n\n{usage}") - })?; + let target = args + .target + .ok_or_else(|| anyhow::anyhow!("Factoring requires --target\n\n{usage}"))?; + let m = args + .bits_m + .ok_or_else(|| anyhow::anyhow!("Factoring requires --bits-m\n\n{usage}"))?; + let n = args + .bits_n + .ok_or_else(|| anyhow::anyhow!("Factoring requires --bits-n\n\n{usage}"))?; let variant = BTreeMap::new(); (ser(Factoring::new(m, n, target))?, variant) } diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index 8c366d9..52bcd24 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -66,12 +66,8 @@ pub fn list(out: &OutputConfig) -> Result<()> { }) .collect(); - let color_fns: Vec> = vec![ - Some(crate::output::fmt_problem_name), - None, - None, - None, - ]; + let color_fns: Vec> = + vec![Some(crate::output::fmt_problem_name), None, None, None]; let mut text = String::new(); text.push_str(&crate::output::fmt_section(&summary)); diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs index 1a06352..bdbc1db 100644 --- a/problemreductions-cli/src/commands/solve.rs +++ b/problemreductions-cli/src/commands/solve.rs @@ -45,13 +45,9 @@ pub fn solve(input: &Path, solver_name: &str, timeout: u64, out: &OutputConfig) let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let result = match parsed { - SolveInput::Problem(pj) => solve_problem( - &pj.problem_type, - &pj.variant, - pj.data, - &solver_name, - &out, - ), + SolveInput::Problem(pj) => { + solve_problem(&pj.problem_type, &pj.variant, pj.data, &solver_name, &out) + } SolveInput::Bundle(b) => solve_bundle(b, &solver_name, &out), }; tx.send(result).ok(); diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs index 2ac4160..0bab1cb 100644 --- a/problemreductions-cli/src/main.rs +++ b/problemreductions-cli/src/main.rs @@ -38,9 +38,7 @@ fn main() -> anyhow::Result<()> { Commands::List => commands::graph::list(&out), Commands::Show { problem } => commands::graph::show(&problem, &out), Commands::To { problem, hops } => commands::graph::neighbors(&problem, hops, "out", &out), - Commands::From { problem, hops } => { - commands::graph::neighbors(&problem, hops, "in", &out) - } + Commands::From { problem, hops } => commands::graph::neighbors(&problem, hops, "in", &out), Commands::Path { source, target, diff --git a/problemreductions-cli/src/mcp/prompts.rs b/problemreductions-cli/src/mcp/prompts.rs index 57cbd00..88fbf96 100644 --- a/problemreductions-cli/src/mcp/prompts.rs +++ b/problemreductions-cli/src/mcp/prompts.rs @@ -1,25 +1,62 @@ -use rmcp::model::{ - GetPromptResult, Prompt, PromptArgument, PromptMessage, PromptMessageRole, -}; +use rmcp::model::{GetPromptResult, Prompt, PromptArgument, PromptMessage, PromptMessageRole}; /// Return the list of available MCP prompt templates. pub fn list_prompts() -> Vec { vec![ Prompt::new( - "analyze_problem", - Some("Analyze a problem type: show its definition, variants, size fields, and reductions"), + "what_is", + Some( + "Explain a problem type: what it models, its variants, and how it connects to \ + other problems", + ), Some(vec![PromptArgument { - name: "problem_type".into(), + name: "problem".into(), title: None, description: Some("Problem name or alias (e.g., MIS, QUBO, MaxCut)".into()), required: Some(true), }]), ), Prompt::new( - "reduction_walkthrough", + "model_my_problem", Some( - "End-to-end reduction walkthrough: find a path, create an instance, \ - reduce it, and solve the result", + "Map a real-world problem to the closest NP-hard problem type in the reduction \ + graph", + ), + Some(vec![PromptArgument { + name: "description".into(), + title: None, + description: Some( + "Free-text description of your real-world problem".into(), + ), + required: Some(true), + }]), + ), + Prompt::new( + "compare", + Some( + "Compare two problem types: their relationship, differences, and reduction path \ + between them", + ), + Some(vec![ + PromptArgument { + name: "problem_a".into(), + title: None, + description: Some("First problem name or alias".into()), + required: Some(true), + }, + PromptArgument { + name: "problem_b".into(), + title: None, + description: Some("Second problem name or alias".into()), + required: Some(true), + }, + ]), + ), + Prompt::new( + "reduce", + Some( + "Step-by-step reduction walkthrough: create an instance, reduce it, solve it, \ + and map the solution back", ), Some(vec![ PromptArgument { @@ -37,8 +74,49 @@ pub fn list_prompts() -> Vec { ]), ), Prompt::new( - "explore_graph", - Some("Explore the reduction graph: list all problems, export the graph, and analyze its structure"), + "solve", + Some("Create and solve a problem instance, showing the optimal solution"), + Some(vec![ + PromptArgument { + name: "problem_type".into(), + title: None, + description: Some("Problem name or alias (e.g., MIS, QUBO, MaxCut)".into()), + required: Some(true), + }, + PromptArgument { + name: "instance".into(), + title: None, + description: Some( + "Instance parameters (e.g., \"edges: 0-1,1-2\" or \"clauses: 1,2;-1,3\")" + .into(), + ), + required: Some(true), + }, + ]), + ), + Prompt::new( + "find_reduction", + Some("Find the best reduction path between two problems, with cost analysis"), + Some(vec![ + PromptArgument { + name: "source".into(), + title: None, + description: Some("Source problem name or alias".into()), + required: Some(true), + }, + PromptArgument { + name: "target".into(), + title: None, + description: Some("Target problem name or alias".into()), + required: Some(true), + }, + ]), + ), + Prompt::new( + "overview", + Some( + "Explore the full landscape of NP-hard problems and reductions in the graph", + ), None, ), ] @@ -50,36 +128,138 @@ pub fn get_prompt( arguments: &serde_json::Map, ) -> Option { match name { - "analyze_problem" => { + "what_is" => { + let problem = arguments + .get("problem") + .and_then(|v| v.as_str()) + .unwrap_or("MIS"); + + Some(GetPromptResult { + description: Some(format!("Explain the {} problem", problem)), + messages: vec![PromptMessage::new_text( + PromptMessageRole::User, + format!( + "Explain the \"{problem}\" problem to me.\n\n\ + What does it model in the real world? What are its variants (graph types, \ + weight types)? What other problems can it reduce to, and which problems \ + reduce to it?\n\n\ + Give me a concise summary suitable for someone encountering this problem \ + for the first time, then show the technical details." + ), + )], + }) + } + + "model_my_problem" => { + let description = arguments + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("(no description provided)"); + + Some(GetPromptResult { + description: Some("Map a real-world problem to an NP-hard problem type".into()), + messages: vec![PromptMessage::new_text( + PromptMessageRole::User, + format!( + "I have a real-world problem and I need help identifying which NP-hard \ + problem type it maps to.\n\n\ + Here's my problem: \"{description}\"\n\n\ + Look through the available problem types in the reduction graph and \ + identify which one(s) best model my problem. Explain why it's a good \ + fit, what the variables and constraints map to, and suggest how I could \ + encode my specific instance." + ), + )], + }) + } + + "compare" => { + let problem_a = arguments + .get("problem_a") + .and_then(|v| v.as_str()) + .unwrap_or("MIS"); + let problem_b = arguments + .get("problem_b") + .and_then(|v| v.as_str()) + .unwrap_or("VertexCover"); + + Some(GetPromptResult { + description: Some(format!("Compare {} and {}", problem_a, problem_b)), + messages: vec![PromptMessage::new_text( + PromptMessageRole::User, + format!( + "Compare \"{problem_a}\" and \"{problem_b}\".\n\n\ + How are they related? Is there a direct reduction between them, or do \ + they connect through intermediate problems? What are the key differences \ + in what they model? If one can be reduced to the other, what is the \ + overhead?" + ), + )], + }) + } + + "reduce" => { + let source = arguments + .get("source") + .and_then(|v| v.as_str()) + .unwrap_or("MIS"); + let target = arguments + .get("target") + .and_then(|v| v.as_str()) + .unwrap_or("QUBO"); + + Some(GetPromptResult { + description: Some(format!( + "Step-by-step reduction from {} to {}", + source, target + )), + messages: vec![PromptMessage::new_text( + PromptMessageRole::User, + format!( + "Walk me through reducing a \"{source}\" instance to \"{target}\", step \ + by step.\n\n\ + 1. Find the reduction path and explain the overhead.\n\ + 2. Create a small, concrete example instance of \"{source}\".\n\ + 3. Reduce it to \"{target}\" and show what the transformed instance \ + looks like.\n\ + 4. Solve the reduced instance.\n\ + 5. Explain how the solution maps back to the original problem.\n\n\ + Use a small example so I can follow each transformation by hand." + ), + )], + }) + } + + "solve" => { let problem_type = arguments .get("problem_type") .and_then(|v| v.as_str()) .unwrap_or("MIS"); + let instance = arguments + .get("instance") + .and_then(|v| v.as_str()) + .unwrap_or("edges: 0-1,1-2,2-0"); Some(GetPromptResult { - description: Some(format!("Analyze the {} problem type", problem_type)), + description: Some(format!("Solve a {} instance", problem_type)), messages: vec![PromptMessage::new_text( PromptMessageRole::User, format!( - "I want to understand the \"{problem_type}\" problem type in the \ - reduction graph.\n\n\ - Please:\n\ - 1. Use the `show_problem` tool with \"{problem_type}\" to get its \ - definition, variants, size fields, and reduction edges.\n\ - 2. Use the `neighbors` tool to find which problems it can reduce to \ - (direction: out) and which problems reduce to it (direction: in).\n\ - 3. Summarize the problem: what it models, its variants, and its role in \ - the reduction graph." + "Create a {problem_type} instance with these parameters: {instance}\n\n\ + Solve it and show me:\n\ + - The problem instance details (size, structure)\n\ + - The optimal solution and its objective value\n\ + - Why this solution is optimal (briefly)" ), )], }) } - "reduction_walkthrough" => { + "find_reduction" => { let source = arguments .get("source") .and_then(|v| v.as_str()) - .unwrap_or("MIS"); + .unwrap_or("SAT"); let target = arguments .get("target") .and_then(|v| v.as_str()) @@ -87,40 +267,31 @@ pub fn get_prompt( Some(GetPromptResult { description: Some(format!( - "End-to-end reduction walkthrough from {} to {}", + "Find reduction path from {} to {}", source, target )), messages: vec![PromptMessage::new_text( PromptMessageRole::User, format!( - "Walk me through an end-to-end reduction from \"{source}\" to \ - \"{target}\".\n\n\ - Please:\n\ - 1. Use `find_path` to find the cheapest reduction path from \ - \"{source}\" to \"{target}\".\n\ - 2. Use `create_problem` to create a small example instance of \ - \"{source}\".\n\ - 3. Use `reduce` to transform the instance to \"{target}\".\n\ - 4. Use `solve` to find the optimal solution of the reduced instance.\n\ - 5. Explain each step: how the reduction works, what the overhead is, \ - and how the solution maps back." + "Find the best way to reduce \"{source}\" to \"{target}\".\n\n\ + Show me the cheapest reduction path and explain the cost at each step. \ + Are there alternative paths? If so, compare them — which is better for \ + small instances vs. large instances?" ), )], }) } - "explore_graph" => Some(GetPromptResult { - description: Some("Explore the reduction graph structure".into()), + "overview" => Some(GetPromptResult { + description: Some("Overview of the NP-hard problem reduction landscape".into()), messages: vec![PromptMessage::new_text( PromptMessageRole::User, - "I want to explore the NP-hard problem reduction graph.\n\n\ - Please:\n\ - 1. Use `list_problems` to get all registered problem types.\n\ - 2. Use `export_graph` to get the full reduction graph as JSON.\n\ - 3. Analyze the graph structure: how many problem types are there, how many \ - reductions, which problems are the most connected hubs, and which problems \ - can reach the most targets.\n\ - 4. Identify any interesting clusters or long reduction chains." + "Give me an overview of the NP-hard problem reduction landscape.\n\n\ + How many problem types are registered? What are the major categories (graph, \ + SAT, optimization)? Which problems are the most connected hubs? Which problems \ + can reach the most targets through reductions?\n\n\ + Summarize the structure so I understand what's available and where to start \ + exploring." .to_string(), )], }), diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 2c9ceec..36c51e7 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -1,13 +1,13 @@ use problemreductions::models::graph::{ - KColoring, MaxCut, MaximumClique, MaximumIndependentSet, MaximumMatching, - MinimumDominatingSet, MinimumVertexCover, TravelingSalesman, + KColoring, MaxCut, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, + MinimumVertexCover, TravelingSalesman, }; use problemreductions::models::optimization::{SpinGlass, QUBO}; use problemreductions::models::satisfiability::{CNFClause, KSatisfiability, Satisfiability}; use problemreductions::models::specialized::Factoring; use problemreductions::registry::collect_schemas; use problemreductions::rules::{ - Minimize, MinimizeSteps, ReductionGraph, ReductionPath, TraversalDirection, + CustomCost, MinimizeSteps, ReductionGraph, ReductionPath, TraversalDirection, }; use problemreductions::topology::{Graph, SimpleGraph}; use problemreductions::types::ProblemSize; @@ -302,14 +302,10 @@ impl McpServer { let input_size = ProblemSize::new(vec![]); - enum CostChoice { - Steps, - Field(&'static str), - } - let cost_choice = if cost == "minimize-steps" { - CostChoice::Steps + let cost_field: Option = if cost == "minimize-steps" { + None } else if let Some(field) = cost.strip_prefix("minimize:") { - CostChoice::Field(Box::leak(field.to_string().into_boxed_str())) + Some(field.to_string()) } else { anyhow::bail!( "Unknown cost function: {}. Use 'minimize-steps' or 'minimize:'", @@ -321,8 +317,8 @@ impl McpServer { for sv in &src_resolved { for dv in &dst_resolved { - let found = match cost_choice { - CostChoice::Steps => graph.find_cheapest_path( + let found = match cost_field { + None => graph.find_cheapest_path( &src_spec.name, sv, &dst_spec.name, @@ -330,14 +326,22 @@ impl McpServer { &input_size, &MinimizeSteps, ), - CostChoice::Field(f) => graph.find_cheapest_path( - &src_spec.name, - sv, - &dst_spec.name, - dv, - &input_size, - &Minimize(f), - ), + Some(ref f) => { + let cost_fn = CustomCost( + |overhead: &problemreductions::rules::ReductionOverhead, + size: &ProblemSize| { + overhead.evaluate_output_size(size).get(f).unwrap_or(0) as f64 + }, + ); + graph.find_cheapest_path( + &src_spec.name, + sv, + &dst_spec.name, + dv, + &input_size, + &cost_fn, + ) + } }; if let Some(p) = found { let is_better = best_path.as_ref().is_none_or(|bp| p.len() < bp.len()); @@ -391,61 +395,31 @@ impl McpServer { } let (data, variant) = match canonical.as_str() { - // Graph problems with vertex weights - "MaximumIndependentSet" | "MinimumVertexCover" | "MaximumClique" + "MaximumIndependentSet" + | "MinimumVertexCover" + | "MaximumClique" | "MinimumDominatingSet" => { let (graph, n) = parse_graph_from_params(params)?; let weights = parse_vertex_weights_from_params(params, n)?; - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - let data = match canonical.as_str() { - "MaximumIndependentSet" => ser(MaximumIndependentSet::new(graph, weights))?, - "MinimumVertexCover" => ser(MinimumVertexCover::new(graph, weights))?, - "MaximumClique" => ser(MaximumClique::new(graph, weights))?, - "MinimumDominatingSet" => ser(MinimumDominatingSet::new(graph, weights))?, - _ => unreachable!(), - }; - (data, variant) + ser_vertex_weight_problem(&canonical, graph, weights)? } - // Graph problems with edge weights "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { let (graph, _) = parse_graph_from_params(params)?; let edge_weights = parse_edge_weights_from_params(params, graph.num_edges())?; - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - let data = match canonical.as_str() { - "MaxCut" => ser(MaxCut::new(graph, edge_weights))?, - "MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?, - "TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?, - _ => unreachable!(), - }; - (data, variant) + ser_edge_weight_problem(&canonical, graph, edge_weights)? } - // KColoring "KColoring" => { let (graph, _) = parse_graph_from_params(params)?; let k = params .get("k") .and_then(|v| v.as_u64()) - .map(|v| v as usize); - let variant; - let data; - match k { - Some(2) => { - variant = variant_map(&[("k", "K2"), ("graph", "SimpleGraph")]); - data = ser(KColoring::::new(graph))?; - } - Some(3) => { - variant = variant_map(&[("k", "K3"), ("graph", "SimpleGraph")]); - data = ser(KColoring::::new(graph))?; - } - Some(kv) => { - variant = variant_map(&[("k", "KN"), ("graph", "SimpleGraph")]); - data = ser(KColoring::::with_k(graph, kv))?; - } - None => anyhow::bail!("KColoring requires 'k' parameter (number of colors)"), - } - (data, variant) + .map(|v| v as usize) + .ok_or_else(|| { + anyhow::anyhow!("KColoring requires 'k' parameter (number of colors)") + })?; + ser_kcoloring(graph, k)? } // SAT @@ -466,10 +440,7 @@ impl McpServer { .map(|v| v as usize) .ok_or_else(|| anyhow::anyhow!("KSatisfiability requires 'num_vars'"))?; let clauses = parse_clauses_from_params(params)?; - let k = params - .get("k") - .and_then(|v| v.as_u64()) - .map(|v| v as usize); + let k = params.get("k").and_then(|v| v.as_u64()).map(|v| v as usize); let variant; let data; match k { @@ -536,7 +507,7 @@ impl McpServer { variant, data, }; - Ok(serde_json::to_string_pretty(&serde_json::to_value(&output)?)?) + Ok(serde_json::to_string_pretty(&output)?) } fn create_random_inner( @@ -558,37 +529,22 @@ impl McpServer { if !(0.0..=1.0).contains(&edge_prob) { anyhow::bail!("edge_prob must be between 0.0 and 1.0"); } - let seed = params - .get("seed") - .and_then(|v| v.as_u64()); + let seed = params.get("seed").and_then(|v| v.as_u64()); let graph = create_random_graph(num_vertices, edge_prob, seed); let num_edges = graph.num_edges(); let (data, variant) = match canonical { - "MaximumIndependentSet" | "MinimumVertexCover" | "MaximumClique" + "MaximumIndependentSet" + | "MinimumVertexCover" + | "MaximumClique" | "MinimumDominatingSet" => { let weights = vec![1i32; num_vertices]; - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - let data = match canonical { - "MaximumIndependentSet" => ser(MaximumIndependentSet::new(graph, weights))?, - "MinimumVertexCover" => ser(MinimumVertexCover::new(graph, weights))?, - "MaximumClique" => ser(MaximumClique::new(graph, weights))?, - "MinimumDominatingSet" => ser(MinimumDominatingSet::new(graph, weights))?, - _ => unreachable!(), - }; - (data, variant) + ser_vertex_weight_problem(canonical, graph, weights)? } "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { let edge_weights = vec![1i32; num_edges]; - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - let data = match canonical { - "MaxCut" => ser(MaxCut::new(graph, edge_weights))?, - "MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?, - "TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?, - _ => unreachable!(), - }; - (data, variant) + ser_edge_weight_problem(canonical, graph, edge_weights)? } "SpinGlass" => { let couplings = vec![1i32; num_edges]; @@ -605,23 +561,7 @@ impl McpServer { .and_then(|v| v.as_u64()) .map(|v| v as usize) .unwrap_or(3); - let variant; - let data; - match k { - 2 => { - variant = variant_map(&[("k", "K2"), ("graph", "SimpleGraph")]); - data = ser(KColoring::::new(graph))?; - } - 3 => { - variant = variant_map(&[("k", "K3"), ("graph", "SimpleGraph")]); - data = ser(KColoring::::new(graph))?; - } - _ => { - variant = variant_map(&[("k", "KN"), ("graph", "SimpleGraph")]); - data = ser(KColoring::::with_k(graph, k))?; - } - } - (data, variant) + ser_kcoloring(graph, k)? } _ => anyhow::bail!( "Random generation is not supported for {}. \ @@ -636,7 +576,7 @@ impl McpServer { variant, data, }; - Ok(serde_json::to_string_pretty(&serde_json::to_value(&output)?)?) + Ok(serde_json::to_string_pretty(&output)?) } pub fn inspect_problem_inner(&self, problem_json: &str) -> anyhow::Result { @@ -687,11 +627,7 @@ impl McpServer { Ok(serde_json::to_string_pretty(&result)?) } - pub fn evaluate_inner( - &self, - problem_json: &str, - config: &[usize], - ) -> anyhow::Result { + pub fn evaluate_inner(&self, problem_json: &str, config: &[usize]) -> anyhow::Result { let pj: ProblemJson = serde_json::from_str(problem_json)?; let problem = load_problem(&pj.problem_type, &pj.variant, pj.data)?; @@ -792,7 +728,7 @@ impl McpServer { .collect(), }; - Ok(serde_json::to_string_pretty(&serde_json::to_value(&bundle)?)?) + Ok(serde_json::to_string_pretty(&bundle)?) } pub fn solve_inner( @@ -946,10 +882,7 @@ impl McpServer { name = "evaluate", annotations(read_only_hint = true, open_world_hint = false) )] - fn evaluate( - &self, - Parameters(params): Parameters, - ) -> Result { + fn evaluate(&self, Parameters(params): Parameters) -> Result { self.evaluate_inner(¶ms.problem_json, ¶ms.config) .map_err(|e| e.to_string()) } @@ -959,10 +892,7 @@ impl McpServer { name = "reduce", annotations(read_only_hint = true, open_world_hint = false) )] - fn reduce( - &self, - Parameters(params): Parameters, - ) -> Result { + fn reduce(&self, Parameters(params): Parameters) -> Result { self.reduce_inner(¶ms.problem_json, ¶ms.target) .map_err(|e| e.to_string()) } @@ -972,10 +902,7 @@ impl McpServer { name = "solve", annotations(read_only_hint = true, open_world_hint = false) )] - fn solve( - &self, - Parameters(params): Parameters, - ) -> Result { + fn solve(&self, Parameters(params): Parameters) -> Result { self.solve_inner( ¶ms.problem_json, params.solver.as_deref(), @@ -1032,10 +959,7 @@ impl rmcp::ServerHandler for McpServer { ) -> Result { let args = request.arguments.unwrap_or_default(); super::prompts::get_prompt(&request.name, &args).ok_or_else(|| { - rmcp::ErrorData::invalid_params( - format!("Unknown prompt: {}", request.name), - None, - ) + rmcp::ErrorData::invalid_params(format!("Unknown prompt: {}", request.name), None) }) } } @@ -1096,12 +1020,68 @@ fn variant_map(pairs: &[(&str, &str)]) -> BTreeMap { .collect() } +/// Serialize a vertex-weight graph problem (MIS, MVC, MaxClique, MinDomSet). +fn ser_vertex_weight_problem( + canonical: &str, + graph: SimpleGraph, + weights: Vec, +) -> anyhow::Result<(serde_json::Value, BTreeMap)> { + let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); + let data = match canonical { + "MaximumIndependentSet" => ser(MaximumIndependentSet::new(graph, weights))?, + "MinimumVertexCover" => ser(MinimumVertexCover::new(graph, weights))?, + "MaximumClique" => ser(MaximumClique::new(graph, weights))?, + "MinimumDominatingSet" => ser(MinimumDominatingSet::new(graph, weights))?, + _ => unreachable!(), + }; + Ok((data, variant)) +} + +/// Serialize an edge-weight graph problem (MaxCut, MaximumMatching, TravelingSalesman). +fn ser_edge_weight_problem( + canonical: &str, + graph: SimpleGraph, + edge_weights: Vec, +) -> anyhow::Result<(serde_json::Value, BTreeMap)> { + let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); + let data = match canonical { + "MaxCut" => ser(MaxCut::new(graph, edge_weights))?, + "MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?, + "TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?, + _ => unreachable!(), + }; + Ok((data, variant)) +} + +/// Serialize a KColoring problem with the appropriate K variant. +fn ser_kcoloring( + graph: SimpleGraph, + k: usize, +) -> anyhow::Result<(serde_json::Value, BTreeMap)> { + match k { + 2 => Ok(( + ser(KColoring::::new(graph))?, + variant_map(&[("k", "K2"), ("graph", "SimpleGraph")]), + )), + 3 => Ok(( + ser(KColoring::::new(graph))?, + variant_map(&[("k", "K3"), ("graph", "SimpleGraph")]), + )), + _ => Ok(( + ser(KColoring::::with_k(graph, k))?, + variant_map(&[("k", "KN"), ("graph", "SimpleGraph")]), + )), + } +} + /// Parse `edges` field from JSON params into a SimpleGraph. fn parse_graph_from_params(params: &serde_json::Value) -> anyhow::Result<(SimpleGraph, usize)> { let edges_str = params .get("edges") .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("This problem requires 'edges' parameter (e.g., \"0-1,1-2,2-3\")"))?; + .ok_or_else(|| { + anyhow::anyhow!("This problem requires 'edges' parameter (e.g., \"0-1,1-2,2-3\")") + })?; let edges: Vec<(usize, usize)> = edges_str .split(',') @@ -1179,7 +1159,9 @@ fn parse_clauses_from_params(params: &serde_json::Value) -> anyhow::Result anyhow::Result ( + ChildStdin, + BufReader, + std::process::Child, + ) { + let mut child = Command::new(env!("CARGO_BIN_EXE_pred")) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("Failed to start pred mcp"); + + let stdin = child.stdin.take().expect("Failed to open stdin"); + let stdout = child.stdout.take().expect("Failed to open stdout"); + let reader = BufReader::new(stdout); + (stdin, reader, child) + } - // Helper: send a JSON-RPC message as a single line + newline - let send = |stdin: &mut std::process::ChildStdin, msg: &serde_json::Value| { + /// Send a JSON-RPC message as a single line + newline. + fn send(stdin: &mut ChildStdin, msg: &serde_json::Value) { let line = serde_json::to_string(msg).unwrap(); writeln!(stdin, "{}", line).unwrap(); stdin.flush().unwrap(); - }; + } - // Helper: read a JSON-RPC response line (newline-delimited JSON) - let read_response = |reader: &mut BufReader| -> serde_json::Value { + /// Read a JSON-RPC response line (newline-delimited JSON), skipping non-JSON lines. + fn read_response(reader: &mut BufReader) -> serde_json::Value { loop { let mut line = String::new(); let bytes = reader.read_line(&mut line).expect("Failed to read line"); @@ -40,302 +47,203 @@ fn test_mcp_server_initialize_and_list_tools() { } let trimmed = line.trim(); if trimmed.is_empty() { - continue; // skip empty lines + continue; } - // Try to parse; skip lines that are not valid JSON (e.g., log output) if let Ok(val) = serde_json::from_str::(trimmed) { - // Only return JSON-RPC messages (have "jsonrpc" field) if val.get("jsonrpc").is_some() { return val; } } } - }; - - // ---- Step 1: Send initialize request ---- - let init_req = serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": { - "name": "mcp-integration-test", - "version": "0.1.0" - } - } - }); - send(stdin, &init_req); - - // ---- Step 2: Read initialize response ---- - let init_resp = read_response(&mut reader); - - // Validate it is a successful JSON-RPC response - assert_eq!( - init_resp["jsonrpc"], "2.0", - "Response must be JSON-RPC 2.0" - ); - assert_eq!( - init_resp["id"], 1, - "Response id must match request id" - ); - assert!( - init_resp.get("error").is_none(), - "Initialize should not return an error: {:?}", - init_resp - ); - - // Validate the result contains required fields - let result = &init_resp["result"]; - assert!( - result.get("protocolVersion").is_some(), - "InitializeResult must contain protocolVersion, got: {}", - serde_json::to_string_pretty(&init_resp).unwrap() - ); - assert!( - result.get("capabilities").is_some(), - "InitializeResult must contain capabilities" - ); - assert!( - result.get("serverInfo").is_some(), - "InitializeResult must contain serverInfo" - ); - - // The server should report tools and prompts capabilities - let capabilities = &result["capabilities"]; - assert!( - capabilities.get("tools").is_some(), - "Server should advertise tools capability" - ); - assert!( - capabilities.get("prompts").is_some(), - "Server should advertise prompts capability" - ); - - // Check server info - let server_info = &result["serverInfo"]; - assert_eq!( - server_info["name"], "problemreductions", - "Server name should be 'problemreductions'" - ); - - // ---- Step 3: Send notifications/initialized notification ---- - let initialized_notif = serde_json::json!({ - "jsonrpc": "2.0", - "method": "notifications/initialized" - }); - send(stdin, &initialized_notif); - - // No response expected for notifications — proceed to next request. - - // ---- Step 4: Send tools/list request ---- - let tools_req = serde_json::json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/list", - "params": {} - }); - send(stdin, &tools_req); - - // ---- Step 5: Read tools/list response ---- - let tools_resp = read_response(&mut reader); + } - assert_eq!(tools_resp["jsonrpc"], "2.0"); - assert_eq!(tools_resp["id"], 2); - assert!( - tools_resp.get("error").is_none(), - "tools/list should not return an error: {:?}", - tools_resp - ); + /// Perform the MCP initialize handshake (initialize request + initialized notification). + fn initialize(stdin: &mut ChildStdin, reader: &mut BufReader) { + send( + stdin, + &serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "mcp-integration-test", "version": "0.1.0"} + } + }), + ); - let tools_result = &tools_resp["result"]; - let tools = tools_result["tools"] - .as_array() - .expect("tools/list result should contain a 'tools' array"); + let init_resp = read_response(reader); + assert_eq!(init_resp["jsonrpc"], "2.0"); + assert_eq!(init_resp["id"], 1); + assert!( + init_resp.get("error").is_none(), + "Initialize should not return an error: {:?}", + init_resp + ); - // The server should expose exactly 10 tools - assert_eq!( - tools.len(), - 10, - "Expected 10 tools, got {}: {:?}", - tools.len(), - tools - .iter() - .map(|t| t["name"].as_str().unwrap_or("?")) - .collect::>() - ); + let result = &init_resp["result"]; + assert!(result.get("protocolVersion").is_some()); + assert!(result.get("capabilities").is_some()); + assert!(result.get("serverInfo").is_some()); - // Verify all expected tool names are present - let tool_names: Vec<&str> = tools - .iter() - .filter_map(|t| t["name"].as_str()) - .collect(); + let capabilities = &result["capabilities"]; + assert!(capabilities.get("tools").is_some()); + assert!(capabilities.get("prompts").is_some()); - let expected_tools = [ - "list_problems", - "show_problem", - "neighbors", - "find_path", - "export_graph", - "create_problem", - "inspect_problem", - "evaluate", - "reduce", - "solve", - ]; + assert_eq!(result["serverInfo"]["name"], "problemreductions"); - for expected in &expected_tools { - assert!( - tool_names.contains(expected), - "Expected tool '{}' not found in tool list: {:?}", - expected, - tool_names + send( + stdin, + &serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + }), ); } - // Verify each tool has a description and inputSchema - for tool in tools { - let name = tool["name"].as_str().unwrap_or("?"); + /// Shut down the child process cleanly and assert success. + /// Takes ownership of `stdin` so it is dropped before waiting, triggering EOF. + fn shutdown(stdin: ChildStdin, mut child: std::process::Child) { + drop(stdin); + let status = child.wait().expect("Failed to wait for pred mcp"); assert!( - tool.get("description").is_some(), - "Tool '{}' should have a description", - name - ); - assert!( - tool.get("inputSchema").is_some(), - "Tool '{}' should have an inputSchema", - name + status.success(), + "pred mcp should exit cleanly, got status: {}", + status ); } - // ---- Step 6: Close stdin and wait for process to exit ---- - drop(child.stdin.take()); - let status = child.wait().expect("Failed to wait for pred mcp"); - // The server should exit cleanly when stdin is closed - assert!( - status.success(), - "pred mcp should exit cleanly, got status: {}", - status - ); -} - -#[cfg(feature = "mcp")] -#[test] -fn test_mcp_server_prompts_list() { - use std::io::{BufRead, BufReader, Write}; - use std::process::{Command, Stdio}; - - let mut child = Command::new(env!("CARGO_BIN_EXE_pred")) - .arg("mcp") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("Failed to start pred mcp"); + #[test] + fn test_mcp_server_initialize_and_list_tools() { + let (mut stdin, mut reader, child) = spawn_mcp(); + initialize(&mut stdin, &mut reader); + + // Send tools/list request + send( + &mut stdin, + &serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + }), + ); - let stdin = child.stdin.as_mut().expect("Failed to open stdin"); - let stdout = child.stdout.take().expect("Failed to open stdout"); - let mut reader = BufReader::new(stdout); + let tools_resp = read_response(&mut reader); + assert_eq!(tools_resp["jsonrpc"], "2.0"); + assert_eq!(tools_resp["id"], 2); + assert!( + tools_resp.get("error").is_none(), + "tools/list should not return an error: {:?}", + tools_resp + ); - let send = |stdin: &mut std::process::ChildStdin, msg: &serde_json::Value| { - let line = serde_json::to_string(msg).unwrap(); - writeln!(stdin, "{}", line).unwrap(); - stdin.flush().unwrap(); - }; + let tools = tools_resp["result"]["tools"] + .as_array() + .expect("tools/list result should contain a 'tools' array"); + + assert_eq!( + tools.len(), + 10, + "Expected 10 tools, got {}: {:?}", + tools.len(), + tools + .iter() + .map(|t| t["name"].as_str().unwrap_or("?")) + .collect::>() + ); - let read_response = |reader: &mut BufReader| -> serde_json::Value { - loop { - let mut line = String::new(); - let bytes = reader.read_line(&mut line).expect("Failed to read line"); - if bytes == 0 { - panic!("EOF from pred mcp before receiving a response"); - } - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - if let Ok(val) = serde_json::from_str::(trimmed) { - if val.get("jsonrpc").is_some() { - return val; - } - } + let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect(); + + let expected_tools = [ + "list_problems", + "show_problem", + "neighbors", + "find_path", + "export_graph", + "create_problem", + "inspect_problem", + "evaluate", + "reduce", + "solve", + ]; + + for expected in &expected_tools { + assert!( + tool_names.contains(expected), + "Expected tool '{}' not found in tool list: {:?}", + expected, + tool_names + ); } - }; - - // Initialize handshake - send( - stdin, - &serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "test", "version": "0.1.0"} - } - }), - ); - let _init_resp = read_response(&mut reader); - send( - stdin, - &serde_json::json!({ - "jsonrpc": "2.0", - "method": "notifications/initialized" - }), - ); + for tool in tools { + let name = tool["name"].as_str().unwrap_or("?"); + assert!( + tool.get("description").is_some(), + "Tool '{}' should have a description", + name + ); + assert!( + tool.get("inputSchema").is_some(), + "Tool '{}' should have an inputSchema", + name + ); + } - // Send prompts/list request - send( - stdin, - &serde_json::json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "prompts/list", - "params": {} - }), - ); + shutdown(stdin, child); + } - let prompts_resp = read_response(&mut reader); - assert_eq!(prompts_resp["jsonrpc"], "2.0"); - assert_eq!(prompts_resp["id"], 2); - assert!( - prompts_resp.get("error").is_none(), - "prompts/list should not return an error: {:?}", - prompts_resp - ); + #[test] + fn test_mcp_server_prompts_list() { + let (mut stdin, mut reader, child) = spawn_mcp(); + initialize(&mut stdin, &mut reader); + + // Send prompts/list request + send( + &mut stdin, + &serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "prompts/list", + "params": {} + }), + ); - let prompts = prompts_resp["result"]["prompts"] - .as_array() - .expect("prompts/list result should contain a 'prompts' array"); + let prompts_resp = read_response(&mut reader); + assert_eq!(prompts_resp["jsonrpc"], "2.0"); + assert_eq!(prompts_resp["id"], 2); + assert!( + prompts_resp.get("error").is_none(), + "prompts/list should not return an error: {:?}", + prompts_resp + ); - assert_eq!( - prompts.len(), - 3, - "Expected 3 prompts, got {}: {:?}", - prompts.len(), - prompts - .iter() - .map(|p| p["name"].as_str().unwrap_or("?")) - .collect::>() - ); + let prompts = prompts_resp["result"]["prompts"] + .as_array() + .expect("prompts/list result should contain a 'prompts' array"); + + assert_eq!( + prompts.len(), + 7, + "Expected 7 prompts, got {}: {:?}", + prompts.len(), + prompts + .iter() + .map(|p| p["name"].as_str().unwrap_or("?")) + .collect::>() + ); - let prompt_names: Vec<&str> = prompts - .iter() - .filter_map(|p| p["name"].as_str()) - .collect(); - assert!(prompt_names.contains(&"analyze_problem")); - assert!(prompt_names.contains(&"reduction_walkthrough")); - assert!(prompt_names.contains(&"explore_graph")); + let prompt_names: Vec<&str> = prompts.iter().filter_map(|p| p["name"].as_str()).collect(); + assert!(prompt_names.contains(&"what_is")); + assert!(prompt_names.contains(&"model_my_problem")); + assert!(prompt_names.contains(&"compare")); + assert!(prompt_names.contains(&"reduce")); + assert!(prompt_names.contains(&"solve")); + assert!(prompt_names.contains(&"find_reduction")); + assert!(prompt_names.contains(&"overview")); - // Clean shutdown - drop(child.stdin.take()); - let status = child.wait().expect("Failed to wait for pred mcp"); - assert!( - status.success(), - "pred mcp should exit cleanly, got status: {}", - status - ); + shutdown(stdin, child); + } } diff --git a/src/rules/graph.rs b/src/rules/graph.rs index a1c5c74..9a0af5e 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -856,7 +856,11 @@ impl ReductionGraph { ) -> NeighborTree { let children = node_children .get(&idx) - .map(|cs| cs.iter().map(|&c| build(c, node_children, nodes, graph)).collect()) + .map(|cs| { + cs.iter() + .map(|&c| build(c, node_children, nodes, graph)) + .collect() + }) .unwrap_or_default(); let node = &nodes[graph[idx]]; NeighborTree {