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
538 changes: 520 additions & 18 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,14 @@ plumb-mcp = { path = "crates/plumb-mcp", version = "0.0.1" }
chromiumoxide = { version = "0.8", default-features = false, features = ["tokio-runtime"] }

# MCP SDK.
rmcp = { version = "1.5", default-features = false, features = ["base64", "macros", "server", "transport-io", "schemars"] }
rmcp = { version = "1.5", default-features = false, features = ["base64", "macros", "server", "transport-io", "transport-streamable-http-server", "schemars"] }

# Async runtime.
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "time", "fs", "process"] }
futures-util = { version = "0.3", default-features = false, features = ["std"] }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
subtle = "2"

# CLI parsing.
clap = { version = "4", features = ["derive", "env", "wrap_help"] }
Expand Down
1 change: 1 addition & 0 deletions crates/plumb-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ scraper = { version = "0.26", default-features = false, features = ["errors", "d
assert_cmd = { workspace = true }
predicates = { workspace = true }
tempfile = { workspace = true }
reqwest = { workspace = true }

[lints]
workspace = true
36 changes: 31 additions & 5 deletions crates/plumb-cli/src/commands/mcp.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,39 @@
//! `plumb mcp` — serve MCP on stdio.
//! `plumb mcp` — serve MCP on stdio or HTTP.

use std::env;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::process::ExitCode;

use anyhow::Result;
use anyhow::{Result, anyhow};

pub async fn run() -> Result<ExitCode> {
tracing::info!("starting mcp stdio server");
pub async fn run(transport: crate::McpTransport, port: u16) -> Result<ExitCode> {
let cwd = env::current_dir()?;
plumb_mcp::run_stdio(cwd).await?;
match transport {
crate::McpTransport::Stdio => {
tracing::info!("starting mcp stdio server");
plumb_mcp::run_stdio(cwd).await?;
}
crate::McpTransport::Http => {
let token = read_http_token()?;
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
tracing::info!(%addr, "starting mcp http server");
plumb_mcp::run_http(cwd, addr, token).await?;
}
}
Ok(ExitCode::SUCCESS)
}

fn read_http_token() -> Result<String> {
match env::var("PLUMB_MCP_TOKEN") {
Ok(token) if token.trim().is_empty() => Err(anyhow!(
"PLUMB_MCP_TOKEN must be set to a non-empty bearer token when --transport http is used"
)),
Ok(token) => Ok(token.trim().to_owned()),
Err(env::VarError::NotPresent) => Err(anyhow!(
"PLUMB_MCP_TOKEN must be set to a non-empty bearer token when --transport http is used"
)),
Err(env::VarError::NotUnicode(_)) => Err(anyhow!(
"PLUMB_MCP_TOKEN must be valid Unicode when --transport http is used"
)),
}
}
21 changes: 18 additions & 3 deletions crates/plumb-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,15 @@ enum Command {
},
/// Emit the JSON Schema for `plumb.toml` on stdout.
Schema,
/// Run the MCP server on stdio.
Mcp,
/// Run the MCP server on stdio or HTTP.
Mcp {
/// MCP transport. Defaults to stdio to preserve existing behavior.
#[arg(long, value_enum, default_value_t = McpTransport::Stdio)]
transport: McpTransport,
/// TCP port for the HTTP transport.
#[arg(long, default_value_t = 4242)]
port: u16,
},
}

#[derive(Debug, Clone, Copy, ValueEnum)]
Expand All @@ -101,6 +108,14 @@ enum Format {
Sarif,
}

#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
enum McpTransport {
/// Serve MCP over stdin/stdout.
Stdio,
/// Serve MCP over Streamable HTTP.
Http,
}

fn main() -> ExitCode {
let cli = Cli::parse();
init_tracing(cli.verbose);
Expand Down Expand Up @@ -146,7 +161,7 @@ fn run(cli: Cli) -> Result<ExitCode> {
Command::Init { force } => commands::init::run(force),
Command::Explain { rule } => commands::explain::run(&rule),
Command::Schema => commands::schema::run(),
Command::Mcp => commands::mcp::run().await,
Command::Mcp { transport, port } => commands::mcp::run(transport, port).await,
}
})
}
Expand Down
158 changes: 158 additions & 0 deletions crates/plumb-cli/tests/mcp_http.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
//! End-to-end HTTP transport tests for `plumb mcp`.

// This harness uses expect/unwrap/panic in test-only setup paths to keep failures explicit.
#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)]

use std::io;
use std::net::TcpListener;
use std::process::{Child, Command, Stdio};
use std::time::Duration;

use assert_cmd::cargo::cargo_bin;
use assert_cmd::prelude::OutputAssertExt;
use predicates::str::contains;

const INIT_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"plumb-test","version":"0.0.0"}}}"#;

fn bin() -> std::path::PathBuf {
cargo_bin("plumb")
}

fn reserve_port() -> io::Result<u16> {
// TOCTOU note: this only reduces collisions for the child process; the short
// local-only test window keeps the risk acceptable until we wire fd handoff.
let listener = TcpListener::bind(("127.0.0.1", 0))?;
let port = listener.local_addr()?.port();
drop(listener);
Ok(port)
}

struct HttpServerChild {
child: Child,
}

impl Drop for HttpServerChild {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}

async fn spawn_http_server(
token: &str,
) -> Result<(HttpServerChild, u16), Box<dyn std::error::Error>> {
let port = reserve_port()?;
let mut child = Command::new(bin())
.args(["mcp", "--transport", "http", "--port", &port.to_string()])
.env("PLUMB_MCP_TOKEN", token)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;

for _ in 0..50 {
if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() {
return Ok((HttpServerChild { child }, port));
}

if let Some(status) = child.try_wait()? {
return Err(
format!("http server exited before accepting connections: {status}").into(),
);
}

tokio::time::sleep(Duration::from_millis(50)).await;
}

Err("timed out waiting for http server to accept connections".into())
}

async fn initialize_request(
port: u16,
authorization: Option<&str>,
) -> Result<reqwest::Response, reqwest::Error> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()?;

let mut request = client
.post(format!("http://127.0.0.1:{port}/"))
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("MCP-Protocol-Version", "2025-03-26")
.body(INIT_BODY);

if let Some(authorization) = authorization {
request = request.header("Authorization", authorization);
}

request.send().await
}

#[test]
fn http_transport_refuses_to_boot_without_token() {
Command::new(bin())
.args(["mcp", "--transport", "http", "--port", "4242"])
.env_remove("PLUMB_MCP_TOKEN")
.assert()
.code(2)
.stderr(contains("PLUMB_MCP_TOKEN"))
.stderr(contains("--transport http"));
}

#[test]
fn http_transport_refuses_to_boot_with_empty_token() {
Command::new(bin())
.args(["mcp", "--transport", "http", "--port", "4242"])
.env("PLUMB_MCP_TOKEN", "")
.assert()
.code(2)
.stderr(contains("PLUMB_MCP_TOKEN"))
.stderr(contains("non-empty bearer token"));
}

#[tokio::test]
async fn http_transport_rejects_requests_without_bearer_token()
-> Result<(), Box<dyn std::error::Error>> {
let (_server, port) = spawn_http_server("secret-token").await?;

let response = initialize_request(port, None).await?;

assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
assert_eq!(response.headers()["www-authenticate"], "Bearer");
Ok(())
}

#[tokio::test]
async fn http_transport_rejects_invalid_bearer_token() -> Result<(), Box<dyn std::error::Error>> {
let (_server, port) = spawn_http_server("secret-token").await?;

let response = initialize_request(port, Some("Bearer wrong-token")).await?;

assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
assert_eq!(response.headers()["www-authenticate"], "Bearer");
Ok(())
}

#[tokio::test]
async fn http_transport_accepts_valid_bearer_token() -> Result<(), Box<dyn std::error::Error>> {
let (_server, port) = spawn_http_server("secret-token").await?;

let response = initialize_request(port, Some("Bearer secret-token")).await?;

assert_eq!(response.status(), reqwest::StatusCode::OK);
assert!(response.headers().contains_key("mcp-session-id"));
Ok(())
}

#[tokio::test]
async fn http_transport_trims_bearer_token_from_environment()
-> Result<(), Box<dyn std::error::Error>> {
let (_server, port) = spawn_http_server(" secret ").await?;

let response = initialize_request(port, Some("Bearer secret")).await?;

assert_eq!(response.status(), reqwest::StatusCode::OK);
assert!(response.headers().contains_key("mcp-session-id"));
Ok(())
}
28 changes: 15 additions & 13 deletions crates/plumb-mcp/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,36 @@

See `/AGENTS.md` for repo-wide rules. This file scopes to `plumb-mcp`.
## Purpose

Model Context Protocol server exposed over stdio. Public surface:
`PlumbServer`, `run_stdio`, `McpError`, `EchoArgs`, `LintUrlArgs`.
Model Context Protocol server exposed over stdio by default, with optional
Streamable HTTP transport. Public surface: `PlumbServer`, `run_stdio`,
`run_http`, `McpError`, `EchoArgs`, `LintUrlArgs`.
Built on `rmcp 0.2.x` with the `#[tool_router]` + `#[tool]` +
`#[tool_handler]` macros.

## Protocol

- Protocol version: `ProtocolVersion::V_2024_11_05`.
- Transport: stdio (`rmcp::transport::stdio`).
- Transport:
- stdio by default (`rmcp::transport::stdio`)
- Streamable HTTP via `run_http`
- Server info: name `plumb`, version from `CARGO_PKG_VERSION`.
- Tool response contract (PRD §14.2):
- `content[0]` — compact human text (one line per finding typical).
- `content[1]` — structured JSON rendered from `plumb-format::mcp_compact`.
- `isError: false` on non-error responses.

## Non-negotiable invariants

- `#![forbid(unsafe_code)]`.
- Every tool method validates its inputs and returns a typed error on
malformed args — no `unwrap`/`expect`.
- `run_http` requires a non-empty bearer token. Missing or invalid
`Authorization: Bearer <token>` headers are rejected with HTTP 401
before the request reaches the MCP transport.
- Response payloads are bounded: `structuredContent` caps at ~10 KB
(aggregate + cap on violations; see PRD §14.2).
- Deterministic output — no wall-clock, no random ordering, no env
read inside a tool call.
- `allow(missing_docs)` is scoped only to the `#[tool_router]` impl
block (the macro synthesizes helpers that can't be doc-commented).
- `allow(missing_docs)` is scoped only to the `#[tool_router]` impl block
(the macro synthesizes helpers that can't be doc-commented).

## Adding a new tool

Expand All @@ -41,8 +44,8 @@ See `.agents/rules/mcp-tool-patterns.md` for the handoff path. Summary:

## Depends on

- `plumb-core` (types; `test-fake` feature enabled so `lint_url` can
serve the canned snapshot for `plumb-fake://` URLs).
- `plumb-core` (types; `test-fake` lets `lint_url` serve the canned
snapshot for `plumb-fake://` URLs).
- `plumb-cdp` (drives Chromium for real `http(s)://` URLs in `lint_url`).
- `plumb-format` (mcp_compact).
- `rmcp` (server + macros + transport-io + schemars features).
Expand All @@ -53,6 +56,5 @@ See `.agents/rules/mcp-tool-patterns.md` for the handoff path. Summary:
- Streaming the full `PlumbSnapshot` back in a tool response. Snapshots
are huge and agent-harmful — always aggregate.
- A tool that mutates shared state. Every call is pure and re-entrant.
- Embedding rule docs verbatim in `explain_rule`. Read from
`docs/src/rules/<slug>.md` so the book and the MCP response stay in
sync.
- Embedding rule docs verbatim in `explain_rule`. Read from `docs/src/rules/<slug>.md`
so the book and the MCP response stay in sync.
2 changes: 2 additions & 0 deletions crates/plumb-mcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ plumb-config = { workspace = true }
plumb-format = { workspace = true }
rmcp = { workspace = true }
tokio = { workspace = true }
axum = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
subtle = { workspace = true }

[dev-dependencies]
plumb-core = { workspace = true, features = ["test-fake"] }
Expand Down
18 changes: 16 additions & 2 deletions crates/plumb-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

Model Context Protocol server for [Plumb](https://plumb.aramhammoudeh.com).

Exposes Plumb's linting capabilities over stdio using the
Exposes Plumb's linting capabilities over the
[MCP](https://modelcontextprotocol.io) protocol, so AI coding agents
(Claude Code, Cursor, Codex, Windsurf) can lint rendered pages and
retrieve rule explanations programmatically.
retrieve rule explanations programmatically. `plumb mcp` uses stdio by
default and can also serve Streamable HTTP.

## Tools

Expand All @@ -15,6 +16,19 @@ retrieve rule explanations programmatically.
| `explain_rule` | Return the docs page for a rule |
| `echo` | Health-check / connectivity test |

## HTTP transport

`plumb mcp --transport http --port 4242` binds the server to
`127.0.0.1:<port>` and requires `PLUMB_MCP_TOKEN` at boot.

Security notes:

- There is no default token and no hardcoded fallback.
- Empty `PLUMB_MCP_TOKEN` values are rejected before the server starts.
- Every HTTP request must send `Authorization: Bearer <token>`.
- Missing or invalid bearer tokens return `401 Unauthorized`.
- The server logs the bind address, never the token value.

## License

Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE)
Expand Down
Loading
Loading