Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/tempyr-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
82 changes: 79 additions & 3 deletions crates/tempyr-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod commands;
mod config;

use clap::{Parser, Subcommand};
use std::ffi::{OsStr, OsString};
use std::path::PathBuf;

#[derive(Parser)]
Expand Down Expand Up @@ -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<I, S>(args: I) -> LaunchMode
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
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);
}
Expand Down Expand Up @@ -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
);
}
}
37 changes: 37 additions & 0 deletions crates/tempyr-cli/tests/integration.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 1 addition & 4 deletions crates/tempyr-mcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
6 changes: 6 additions & 0 deletions crates/tempyr-mcp/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions crates/tempyr-mcp/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
13 changes: 0 additions & 13 deletions crates/tempyr-mcp/src/main.rs

This file was deleted.

14 changes: 6 additions & 8 deletions docs/graphspec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1036,8 +1036,7 @@ COMMANDS:
import <file> Import unstructured text and propose nodes

# ─── MCP Server ──────────────────────────
serve Start the MCP server (for Claude Code / other clients)
--port <port> TCP port (default: stdio for MCP)
--mcp Start the MCP server on stdio (for Claude Code / other clients)

OPTIONS:
--graph-dir <path> Path to graph directory (default: ./graph)
Expand Down Expand Up @@ -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`
```

---
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down