# ADR-0021: MCP Server via Second Binary with `rmcp`

## Status

✅ Accepted

## Context

omni-dev has well-separated business logic: git analysis under `src/git/`,
AI-powered commit/PR workflows under `src/claude/`, and Atlassian JIRA/Confluence
integration under `src/atlassian/`. All three domains already serialise results
to YAML or structured types via `src/data/`, and all run under a single tokio
runtime established by `#[tokio::main]` in `src/main.rs`.

AI assistants (Claude Desktop, Claude Code, Cursor, others) consume external
capabilities through the Model Context Protocol (MCP) — a JSON-RPC-based
protocol where a "server" advertises tools and resources that a "client"
(typically an AI assistant) can discover and invoke. Exposing omni-dev's
existing business logic as MCP tools lets assistants use git analysis, commit
improvement, and Atlassian integration without shelling out and parsing CLI
output.

Four implementation shapes were evaluated:

- **Single binary with a `--mcp` flag.** Add an `omni-dev mcp-server`
  subcommand that detaches from clap's normal output and speaks MCP on stdio.
  The MCP dependency (`rmcp`) is linked into every invocation, including
  `omni-dev git commit message view`, growing the default binary and compile
  time for users who never use MCP. Startup semantics also diverge sharply: MCP
  mode must never write to stdout before handshake, while CLI mode does so
  immediately. One binary carrying two behaviours is a recipe for contention.

- **Hand-rolled JSON-RPC server.** Implement the MCP wire format directly on
  top of `tokio::io::stdin`/`stdout`. This avoids the `rmcp` dependency but
  re-implements request routing, schema validation, cancellation, and
  capability advertisement. The MCP specification evolves (`V_2024_11_05`,
  newer revisions); tracking it by hand is a perpetual maintenance cost with
  no offsetting benefit.

- **Grouped tools** (`git`, `jira`, `confluence`) **with a `command`
  parameter.** Expose a single tool per domain whose parameter schema enumerates
  sub-operations (e.g., `jira.command = "read" | "search" | "create"`). The
  tool count stays small but each tool schema becomes a union that assistants
  must parse to understand what the tool actually does. Tool discovery and
  selection work best when each tool has a descriptive name and a focused
  parameter schema — the assistant can pick the right tool from its
  description rather than reading a switch statement.

- **Separate binary (`omni-dev-mcp`) using the `rmcp` crate.** Add a second
  binary target that links `rmcp` and exposes each domain operation as its own
  MCP tool. The CLI binary stays unaffected for users who never use MCP.
  `rmcp` is the official Rust SDK from the MCP project, provides `#[tool]` and
  `#[tool_router]` proc macros that generate routing and JSON schemas, and
  ships stdio transport out of the box. The dependency is gated behind an
  `mcp` Cargo feature so `cargo build` (default) does not pull it in.

## Decision

We will add MCP server support as a second binary target, `omni-dev-mcp`,
gated behind a `mcp` Cargo feature, using the `rmcp` crate for the protocol
implementation.

### Crate dependency

```toml
[dependencies]
rmcp = { version = "1.5", features = ["server", "transport-io", "macros"], optional = true }

[features]
mcp = ["dep:rmcp"]
```

The `mcp` feature is off by default so that `cargo build` and `cargo install
omni-dev` produce only the CLI binary with no MCP-related compile overhead.

### Binary target

```toml
[[bin]]
name = "omni-dev-mcp"
path = "src/mcp_server.rs"
required-features = ["mcp"]
```

`required-features` tells cargo to skip the binary entirely when the feature
is not enabled, so it does not appear in `cargo build` output or `cargo
install` targets.

### Module layout

Per STYLE-0004 (named-file layout):

```
src/
├── mcp.rs               # module root, declares submodules
├── mcp/
│   ├── server.rs        # OmniDevServer struct, tool_router, ServerHandler
│   ├── git_tools.rs     # git tool handlers
│   └── error.rs         # anyhow::Error → rmcp::ErrorData mapping
└── mcp_server.rs        # binary entry point
```

The entire `src/mcp/` tree is `#[cfg(feature = "mcp")]` so the main CLI build
does not compile any of it.

### Tool granularity

One MCP tool per operation, named with a `<domain>_<verb>` pattern:
`git_view_commits`, `git_branch_info`, `jira_read`, `confluence_search`, etc.
Each tool has its own typed parameter struct deriving `Deserialize` and
`schemars::JsonSchema`, so the JSON schema advertised to clients matches the
Rust signature exactly. Grouped tools with a `command` parameter are rejected
as argued in Context.

### Wiring to existing logic

Tool handlers call existing `run_*` functions (STYLE-0025) rather than
re-implementing logic. Where a CLI command currently ends in `println!`, the
underlying orchestration is extracted into a `run_*` function that returns a
`String` (or structured type), and `execute` becomes `println!("{}", run_*())`.
The MCP handler calls the same `run_*` and wraps the result in
`CallToolResult::success(Content::text(...))`. This keeps CLI and MCP on a
shared implementation with no duplicated logic.

### Transport

Stdio only in the initial implementation. HTTP/SSE transports (`rmcp` supports
them via additional feature flags) are deferred until a concrete client needs
them; the Claude Desktop and Claude Code clients both launch MCP servers as
child processes over stdio.

### Error mapping

A single helper in `src/mcp/error.rs` converts `anyhow::Error` chains into
`rmcp::ErrorData` with the error chain preserved as a string. Domain errors
(those implementing `std::error::Error`) propagate through `anyhow::Error`
unchanged; the MCP boundary flattens them at the point of return so the
client sees a single tool-error payload.

### Tracing

`mcp_server.rs` initialises `tracing_subscriber` with writer set to
`std::io::stderr` (matching `main.rs`). Tool invocations are logged at
`debug` level; stdout is reserved exclusively for MCP protocol frames.

## Consequences

**Positive:**

- **CLI users pay nothing.** No `rmcp` in the default build. `cargo install
  omni-dev` produces the same binary it does today.

- **Runtime isolation.** A crashed tool handler in `omni-dev-mcp` cannot take
  down `omni-dev`. Startup behaviour (stdout silence before handshake versus
  immediate stdout) is cleanly separated between binaries.

- **Shared logic.** `run_*` extractions benefit both transports — any test
  coverage added for the CLI also covers the MCP surface, and vice versa.

- **Schema is generated, not hand-written.** `rmcp`'s `#[tool]` macro turns
  parameter structs into JSON schemas at compile time; there is no separate
  schema document to keep in sync.

- **Feature flag is an on-ramp, not a fork.** The `mcp` feature is additive.
  Users who want the MCP binary run `cargo install omni-dev --features mcp`
  or `cargo build --features mcp` without changing the CLI surface.

**Negative:**

- **Two binary artefacts to publish.** Release automation must build both
  binaries and ship both. CI must run `cargo build --features mcp` in
  addition to the default build.

- **`rmcp` is a relatively young dependency.** Version 1.5 is the current
  release line (as of 2026-04); API stability within 1.x is expected but not
  yet proven over long periods. Pinning to a minor version (`"1.5"`) and
  revisiting at each upgrade is the mitigation.

- **Feature-gated code is easy to drift.** `#[cfg(feature = "mcp")]` code is
  invisible to default `cargo check` / `cargo clippy`. CI must run both
  configurations to catch regressions.

**Neutral:**

- **Tool count grows over time.** Phases defined in the implementation issue
  add git (5), Atlassian (15), and AI/config (3) tools. Each remains small
  and focused.

- **`rmcp` abstracts the protocol version.** Callers select `ProtocolVersion`
  in `get_info()`; changing it is a one-line edit.
