From 12452776bbe0adebaaa2ef475b6435f76ba77bb5 Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Fri, 23 Oct 2026 15:23:32 +0200 Subject: [PATCH 1/7] feat(mcp): stdio MCP server with closed tool dispatch and doctor --- Cargo.lock | 154 ++++++++++++++++++ crates/permesh-cli/Cargo.toml | 1 + crates/permesh-cli/src/args.rs | 2 + crates/permesh-cli/src/main.rs | 11 ++ crates/permesh-cli/src/mcp/mod.rs | 32 ++++ crates/permesh-cli/src/mcp/server.rs | 91 +++++++++++ crates/permesh-cli/src/mcp/tools.rs | 168 +++++++++++++++++++ crates/permesh-cli/tests/mcp.rs | 235 +++++++++++++++++++++++++++ 8 files changed, 694 insertions(+) create mode 100644 crates/permesh-cli/src/mcp/mod.rs create mode 100644 crates/permesh-cli/src/mcp/server.rs create mode 100644 crates/permesh-cli/src/mcp/tools.rs create mode 100644 crates/permesh-cli/tests/mcp.rs diff --git a/Cargo.lock b/Cargo.lock index 2ae2d92..1791886 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,6 +22,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "annotate-snippets" version = "0.12.16" @@ -377,6 +386,18 @@ dependencies = [ "rand_core", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "cipher" version = "0.5.2" @@ -590,6 +611,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "email-encoding" version = "0.4.2" @@ -1031,6 +1058,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -1143,6 +1194,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", + "serde", + "serde_core", ] [[package]] @@ -1520,6 +1573,12 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1545,6 +1604,7 @@ dependencies = [ "permesh-provider-sdk", "permesh-secrets", "reqwest", + "rmcp", "rpassword", "serde", "serde_json", @@ -1833,6 +1893,26 @@ dependencies = [ "rand_core", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "regex" version = "1.13.1" @@ -1914,6 +1994,28 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ece64e19631fc38758181e637c15509a2c2709ee53bfde1e1d58f9dc991f919e" +dependencies = [ + "chrono", + "futures", + "indexmap", + "libc", + "pastey", + "pin-project-lite", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + [[package]] name = "rpassword" version = "7.5.4" @@ -2069,6 +2171,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.5", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2183,6 +2311,17 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -2475,6 +2614,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2658,6 +2811,7 @@ version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", diff --git a/crates/permesh-cli/Cargo.toml b/crates/permesh-cli/Cargo.toml index 9649cf5..3b33d44 100644 --- a/crates/permesh-cli/Cargo.toml +++ b/crates/permesh-cli/Cargo.toml @@ -36,6 +36,7 @@ rpassword.workspace = true zeroize = { version = "1", features = ["serde"] } tempfile.workspace = true +rmcp = { version = "3.7", default-features = false, features = ["server", "transport-io"] } [dev-dependencies] diff --git a/crates/permesh-cli/src/args.rs b/crates/permesh-cli/src/args.rs index 655974a..311ac81 100644 --- a/crates/permesh-cli/src/args.rs +++ b/crates/permesh-cli/src/args.rs @@ -130,6 +130,8 @@ pub enum Command { #[arg(value_enum, conflicts_with = "json")] shell: clap_complete::Shell, }, + /// Serve read-only answers to a local AI assistant over MCP (stdio). + Mcp, /// Show version and privacy defaults. Version, } diff --git a/crates/permesh-cli/src/main.rs b/crates/permesh-cli/src/main.rs index 0b2453d..268926f 100644 --- a/crates/permesh-cli/src/main.rs +++ b/crates/permesh-cli/src/main.rs @@ -23,6 +23,7 @@ mod guided_add; mod guided_prompt; mod identity_command; mod inventory; +mod mcp; mod offboard; mod orphaned_output; mod output; @@ -95,6 +96,16 @@ async fn main() -> ExitCode { } let schema_version = report::command_schema_version(&cli.command); let blocking = blocking::BlockingPool::new(); + if let args::Command::Mcp = &cli.command { + // stdout belongs to the MCP session; errors only reach stderr. + return match mcp::serve(&cli, &blocking).await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => match output::write_error(&error, false, schema_version) { + Ok(()) => ExitCode::from(error.code), + Err(_) => ExitCode::from(5), + }, + }; + } let result = if let args::Command::Provider { command: args::ProviderCommand::External { command }, } = &cli.command diff --git a/crates/permesh-cli/src/mcp/mod.rs b/crates/permesh-cli/src/mcp/mod.rs new file mode 100644 index 0000000..498124d --- /dev/null +++ b/crates/permesh-cli/src/mcp/mod.rs @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +//! Read-only MCP server over stdio (ADR 0035). Tools run existing commands only. +mod server; +mod tools; +use crate::{args::Cli, blocking::BlockingPool, error::AppError, workspace}; +use rmcp::{ServiceExt, service::ServerInitializeError}; + +pub(crate) async fn serve(cli: &Cli, blocking: &BlockingPool) -> Result<(), AppError> { + let workspace = std::fs::canonicalize(workspace::path(cli)?) + .map_err(|_| AppError::input("Cannot locate workspace configuration"))?; + permesh_config::Config::load(&workspace)?; + let handler = server::Server::new(workspace, blocking.clone()); + let session = tokio::select! { + session = handler.serve(rmcp::transport::stdio()) => session, + _ = tokio::signal::ctrl_c() => return Err(AppError::new(130, "Cancelled")), + }; + let running = match session { + Ok(running) => running, + Err(ServerInitializeError::ConnectionClosed(_)) => return Ok(()), + Err(_) => { + return Err(AppError::input( + "The MCP session could not start. permesh mcp supports MCP protocol 2026-07-28 only; update the assistant or its MCP client.", + )); + } + }; + tokio::select! { + result = running.waiting() => result + .map(|_| ()) + .map_err(|_| AppError::new(5, "The MCP session ended unexpectedly")), + _ = tokio::signal::ctrl_c() => Err(AppError::new(130, "Cancelled")), + } +} diff --git a/crates/permesh-cli/src/mcp/server.rs b/crates/permesh-cli/src/mcp/server.rs new file mode 100644 index 0000000..3031f63 --- /dev/null +++ b/crates/permesh-cli/src/mcp/server.rs @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +use super::tools; +use crate::{ + app, args::Cli, blocking::BlockingPool, cancellation::Cancellation, error::AppError, + report::Outcome, +}; +use clap::Parser; +use rmcp::{ + ErrorData, RoleServer, ServerHandler, + model::{ + CallToolRequestParams, CallToolResponse, Implementation, ListToolsResult, + PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerConfig, + }, + service::RequestContext, +}; +use std::{borrow::Cow, path::PathBuf}; + +pub(crate) const INSTRUCTIONS: &str = "Permesh answers who has access to what, read-only. Every tool result has `complete`: when it is false, some providers could not be fully read and missing access is not proof of absence, so say you cannot tell. Plan tools write a new plan file for a person to review and carry out; Permesh never changes access."; +const VERSIONS: &[ProtocolVersion] = &[ProtocolVersion::V_2026_07_28]; + +pub(crate) struct Server { + workspace: PathBuf, + blocking: BlockingPool, + /// One command at a time: discoveries never share provider sessions or the keyring. + lock: tokio::sync::Mutex<()>, +} +impl Server { + pub(crate) fn new(workspace: PathBuf, blocking: BlockingPool) -> Self { + Self { + workspace, + blocking, + lock: tokio::sync::Mutex::new(()), + } + } + async fn execute( + &self, + tool: &tools::Tool, + stop: impl std::future::Future, + ) -> Result { + let cli = Cli::try_parse_from(tool.argv(&self.workspace)?) + .map_err(|_| AppError::input("Invalid tool arguments"))?; + let cancellation = Cancellation::new(); + let operation = app::run(&cli, &self.blocking, &cancellation); + tokio::pin!(operation); + tokio::select! { + biased; + () = stop => { + // Drain like Ctrl+C in main: providers are terminated and reaped first. + cancellation.cancel(); + match operation.await { + Err(error) if error.code == 5 => Err(error), + _ => Err(AppError::new(130, "Cancelled")), + } + } + result = &mut operation => result, + } + } +} +impl ServerHandler for Server { + fn get_info(&self) -> ServerConfig { + let mut info = ServerConfig::new(ServerCapabilities::builder().enable_tools().build()); + info.server_info = Implementation::new("permesh", env!("CARGO_PKG_VERSION")); + info.instructions = Some(INSTRUCTIONS.into()); + info + } + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(VERSIONS) + } + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListToolsResult::with_all_items(tools::definitions())) + } + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let arguments = request.arguments.unwrap_or_default(); + let outcome = match tools::parse(&request.name, arguments) { + Ok(tool) => { + let _turn = self.lock.lock().await; + self.execute(&tool, context.ct.cancelled()).await + } + Err(message) => Err(AppError::input(message)), + }; + Ok(CallToolResponse::Complete(tools::result(outcome))) + } +} diff --git a/crates/permesh-cli/src/mcp/tools.rs b/crates/permesh-cli/src/mcp/tools.rs new file mode 100644 index 0000000..91995f6 --- /dev/null +++ b/crates/permesh-cli/src/mcp/tools.rs @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: MIT +//! The closed set of MCP tools. Each maps to exactly one existing command. +use crate::{error::AppError, report::Outcome}; +use rmcp::model::{CallToolResult, ContentBlock, Tool as Definition, ToolAnnotations}; +use serde::Deserialize; +use serde_json::{Map, Value, json}; +use std::{ + ffi::{OsStr, OsString}, + path::Path, + sync::Arc, +}; + +pub(crate) const NOTE: &str = + "Some providers could not be fully read. Missing access here is not proof of absence."; + +#[derive(Debug, Deserialize)] +#[serde( + tag = "name", + content = "arguments", + rename_all = "snake_case", + deny_unknown_fields +)] +pub(crate) enum Tool { + Doctor(DoctorArgs), + #[serde(rename = "user_access")] + User(UserArgs), +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct DoctorArgs { + #[serde(default)] + details: bool, +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct UserArgs { + identity: String, +} + +fn schema(value: Value) -> Arc> { + match value { + Value::Object(map) => Arc::new(map), + _ => Arc::new(Map::new()), + } +} +fn query(name: &'static str, description: &'static str, input: Value) -> Definition { + Definition::new(name, description, schema(input)) + .annotate(ToolAnnotations::new().read_only(true).open_world(true)) +} + +pub(crate) fn definitions() -> Vec { + vec![query( + "doctor", + "Check the workspace configuration, credentials and provider connectivity.", + json!({"type": "object", "properties": {"details": {"type": "boolean", "description": "Include curated stage codes and remediation."}}, "additionalProperties": false}), + )] +} + +/// Only listed tools are callable, even when a variant exists for a later tool. +pub(crate) fn parse(name: &str, arguments: Map) -> Result { + if !definitions().iter().any(|tool| tool.name == name) { + return Err("Unknown tool; call tools/list for the available tools".into()); + } + serde_json::from_value(json!({"name": name, "arguments": arguments})) + .map_err(|_| "Invalid tool arguments; check the tool's input schema".into()) +} + +fn option(name: &str, value: impl AsRef) -> OsString { + let mut flag = OsString::from(format!("--{name}=")); + flag.push(value); + flag +} + +impl Tool { + /// Exact argv: values are `--name=value` and positionals follow `--`, so data never becomes a flag. + pub(crate) fn argv(&self, workspace: &Path) -> Result, AppError> { + let mut argv: Vec = vec!["permesh".into(), "--json".into()]; + argv.push(option("config", workspace)); + match self { + Tool::Doctor(args) => { + argv.push("doctor".into()); + if args.details { + argv.push("--details".into()); + } + } + Tool::User(args) => { + argv.extend(["user".into(), "--".into(), (&args.identity).into()]); + } + } + Ok(argv) + } +} + +pub(crate) fn result(outcome: Result) -> CallToolResult { + let (value, failed) = match outcome { + Ok(outcome) => { + let complete = outcome.report.complete; + let mut value = json!({"exit_code": outcome.code, "report": outcome.report}); + if !complete { + value["note"] = json!(NOTE); + } + (value, false) + } + Err(error) => ( + json!({"exit_code": error.code, "error": {"message": error.message}}), + true, + ), + }; + let content = vec![ContentBlock::text(value.to_string())]; + let mut result = if failed { + CallToolResult::error(content) + } else { + CallToolResult::success(content) + }; + result.structured_content = Some(value); + result +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use crate::args::{Cli, Command}; + use clap::Parser; + + fn parsed(tool: &Tool) -> Cli { + Cli::try_parse_from(tool.argv(Path::new("/w/permesh.yaml")).unwrap()).unwrap() + } + fn samples() -> Vec { + vec![ + Tool::Doctor(DoctorArgs { details: true }), + Tool::User(UserArgs { + identity: "a@example.com".into(), + }), + ] + } + + #[test] + fn flag_like_values_stay_positional() { + let tool = Tool::User(UserArgs { + identity: "--config=/x".into(), + }); + let argv = tool.argv(Path::new("/w/permesh.yaml")).unwrap(); + assert_eq!(argv[argv.len() - 3..], ["user", "--", "--config=/x"]); + let cli = parsed(&tool); + assert!(matches!(&cli.command, Command::User { identity } if identity == "--config=/x")); + assert_eq!(cli.config.as_deref(), Some(Path::new("/w/permesh.yaml"))); + assert!(cli.json); + } + + #[test] + fn argv_only_reaches_allowed_commands() { + for tool in samples() { + let cli = parsed(&tool); + assert!( + matches!(cli.command, Command::Doctor { .. } | Command::User { .. }), + "{tool:?}" + ); + } + } + + #[test] + fn hidden_variants_are_not_callable() { + let mut arguments = Map::new(); + arguments.insert("identity".into(), json!("a")); + assert!(parse("user_access", arguments).is_err()); + } +} diff --git a/crates/permesh-cli/tests/mcp.rs b/crates/permesh-cli/tests/mcp.rs new file mode 100644 index 0000000..e72ca24 --- /dev/null +++ b/crates/permesh-cli/tests/mcp.rs @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: MIT +#![allow(clippy::unwrap_used, clippy::expect_used)] +use serde_json::{Value, json}; +use std::{ + io::{BufRead, BufReader, Write}, + path::Path, + process::{Child, ChildStdin, Command, Output, Stdio}, + sync::mpsc::{Receiver, channel}, + time::Duration, +}; + +const VERSION: &str = "2026-07-28"; +const INSTRUCTIONS: &str = "Permesh answers who has access to what, read-only. Every tool result has `complete`: when it is false, some providers could not be fully read and missing access is not proof of absence, so say you cannot tell. Plan tools write a new plan file for a person to review and carry out; Permesh never changes access."; + +fn command(dir: &Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_permesh")); + command + .current_dir(dir) + .env("NO_COLOR", "1") + .env("PERMESH_DATA_DIR", dir.join("state")); + command +} +fn run(dir: &Path, args: &[&str]) -> Output { + command(dir).args(args).output().unwrap() +} +fn demo() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + assert!(run(dir.path(), &["init", "--demo"]).status.success()); + dir +} +fn meta() -> Value { + json!({ + "io.modelcontextprotocol/protocolVersion": VERSION, + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": {"name": "test", "version": "0"} + }) +} + +/// A newline-delimited JSON-RPC client for `permesh mcp`; every stdout line is kept. +struct Client { + child: Child, + stdin: Option, + lines: Receiver, + seen: Vec, + next: u64, +} +impl Client { + fn start(dir: &Path) -> Self { + let mut child = command(dir) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let stdin = child.stdin.take(); + let stdout = child.stdout.take().unwrap(); + let (tx, lines) = channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let Ok(line) = line else { break }; + if tx.send(line).is_err() { + break; + } + } + }); + Self { + child, + stdin, + lines, + seen: vec![], + next: 1, + } + } + fn send(&mut self, message: &Value) { + let stdin = self.stdin.as_mut().unwrap(); + writeln!(stdin, "{message}").unwrap(); + stdin.flush().unwrap(); + } + /// Sends a request with modern `_meta` merged into params; returns its id. + fn request_raw(&mut self, method: &str, params: Value) -> u64 { + let id = self.next; + self.next += 1; + self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + id + } + fn start_request(&mut self, method: &str, mut params: Value) -> u64 { + params["_meta"] = meta(); + self.request_raw(method, params) + } + fn response(&mut self, id: u64) -> Value { + loop { + let line = self + .lines + .recv_timeout(Duration::from_secs(60)) + .expect("no response from permesh mcp"); + self.seen.push(line.clone()); + let value: Value = serde_json::from_str(&line).unwrap(); + if value["id"] == json!(id) { + return value; + } + } + } + fn request(&mut self, method: &str, params: Value) -> Value { + let id = self.start_request(method, params); + self.response(id) + } + fn call(&mut self, name: &str, arguments: Value) -> Value { + let response = self.request("tools/call", json!({"name": name, "arguments": arguments})); + assert!(response["error"].is_null(), "{response}"); + response["result"].clone() + } + fn tool_names(&mut self) -> Vec { + let response = self.request("tools/list", json!({})); + response["result"]["tools"] + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap().to_string()) + .collect() + } + /// Closes stdin and returns the exit code and every stdout line. + fn finish(mut self) -> (Option, Vec) { + drop(self.stdin.take()); + let status = self.child.wait().unwrap(); + while let Ok(line) = self.lines.recv_timeout(Duration::from_secs(5)) { + self.seen.push(line); + } + (status.code(), self.seen) + } +} + +#[test] +fn server_discover_describes_server() { + let dir = demo(); + let mut client = Client::start(dir.path()); + let result = client.request("server/discover", json!({}))["result"].clone(); + assert_eq!(result["supportedVersions"], json!([VERSION]), "{result}"); + assert_eq!( + result["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], + "permesh" + ); + assert_eq!(result["instructions"], INSTRUCTIONS); + assert!(result["capabilities"]["tools"].is_object(), "{result}"); +} + +#[test] +fn tools_list_without_handshake() { + let dir = demo(); + let mut client = Client::start(dir.path()); + assert_eq!(client.tool_names(), ["doctor"]); +} + +#[test] +fn legacy_initialize_is_rejected() { + let dir = demo(); + let mut client = Client::start(dir.path()); + let id = client.request_raw( + "initialize", + json!({"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "old", "version": "0"}}), + ); + let response = client.response(id); + assert_eq!(response["error"]["code"], -32022, "{response}"); + assert_eq!(response["error"]["data"]["supported"], json!([VERSION])); + let (code, lines) = client.finish(); + assert_eq!(code, Some(2)); + assert!( + lines + .iter() + .all(|l| serde_json::from_str::(l).is_ok()) + ); +} + +#[test] +fn missing_meta_is_invalid_params() { + let dir = demo(); + let mut client = Client::start(dir.path()); + let id = client.request_raw("tools/list", json!({})); + assert_eq!(client.response(id)["error"]["code"], -32602); + assert_eq!(client.tool_names(), ["doctor"]); +} + +#[test] +fn doctor_tool_returns_report() { + let dir = demo(); + let mut client = Client::start(dir.path()); + let result = client.call("doctor", json!({})); + assert_eq!(result["isError"], false, "{result}"); + let structured = &result["structuredContent"]; + assert_eq!(structured["exit_code"], 0, "{result}"); + assert_eq!(structured["report"]["command"], "doctor"); + let text: Value = serde_json::from_str(result["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(&text, structured); +} + +#[test] +fn unknown_tool_and_argument_are_errors() { + let dir = demo(); + let mut client = Client::start(dir.path()); + for (name, arguments) in [("nope", json!({})), ("doctor", json!({"detail": true}))] { + let response = client.request("tools/call", json!({"name": name, "arguments": arguments})); + let tool_error = response["result"]["isError"] == true; + let rpc_error = response["error"]["code"] == -32602; + assert!(tool_error || rpc_error, "{response}"); + } + assert_eq!(client.tool_names(), ["doctor"]); +} + +#[test] +fn stdout_is_only_json_rpc() { + let dir = demo(); + let mut client = Client::start(dir.path()); + client.request("server/discover", json!({})); + client.tool_names(); + client.call("doctor", json!({"details": true})); + let (code, lines) = client.finish(); + assert_eq!(code, Some(0)); + assert!(!lines.is_empty()); + for line in lines { + let value: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(value["jsonrpc"], "2.0", "{line}"); + } +} + +#[test] +fn outside_workspace_exits_input() { + let dir = tempfile::tempdir().unwrap(); + let output = command(dir.path()) + .arg("mcp") + .stdin(Stdio::null()) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); +} From 8b7c821d601bc766edb47220410e953fc01ecfa9 Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Fri, 23 Oct 2026 15:25:00 +0200 Subject: [PATCH 2/7] feat(mcp): confine tool file paths to the workspace --- crates/permesh-cli/src/mcp/mod.rs | 3 + crates/permesh-cli/src/mcp/paths.rs | 154 ++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 crates/permesh-cli/src/mcp/paths.rs diff --git a/crates/permesh-cli/src/mcp/mod.rs b/crates/permesh-cli/src/mcp/mod.rs index 498124d..00a43c2 100644 --- a/crates/permesh-cli/src/mcp/mod.rs +++ b/crates/permesh-cli/src/mcp/mod.rs @@ -1,5 +1,8 @@ // SPDX-License-Identifier: MIT //! Read-only MCP server over stdio (ADR 0035). Tools run existing commands only. +// Consumers arrive with the file-taking tools in the next commits. +#[cfg_attr(not(test), allow(dead_code))] +mod paths; mod server; mod tools; use crate::{args::Cli, blocking::BlockingPool, error::AppError, workspace}; diff --git a/crates/permesh-cli/src/mcp/paths.rs b/crates/permesh-cli/src/mcp/paths.rs new file mode 100644 index 0000000..55c6c33 --- /dev/null +++ b/crates/permesh-cli/src/mcp/paths.rs @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: MIT +//! Tool file arguments resolve inside the workspace root, never outside it. +use crate::error::AppError; +use std::path::{Component, Path, PathBuf}; + +const RELATIVE: &str = "Paths must be relative to the workspace"; +const OUTSIDE: &str = "Path is outside the workspace"; +const MISSING: &str = "File not found in the workspace"; +const EXISTS: &str = "Output file already exists"; + +/// Refuses absolute paths and `..` that climbs above the root before touching the file system. +fn lexical(relative: &str) -> Result<&Path, AppError> { + let path = Path::new(relative); + if relative.is_empty() { + return Err(AppError::input(RELATIVE)); + } + let mut depth = 0usize; + for component in path.components() { + match component { + Component::Normal(_) => depth += 1, + Component::CurDir => {} + Component::ParentDir => { + depth = depth + .checked_sub(1) + .ok_or_else(|| AppError::input(OUTSIDE))?; + } + Component::RootDir | Component::Prefix(_) => return Err(AppError::input(RELATIVE)), + } + } + Ok(path) +} +fn canonical_root(root: &Path) -> Result { + std::fs::canonicalize(root).map_err(|_| AppError::input(MISSING)) +} + +/// An input file inside the root; symlinks are followed and must stay inside. +pub(crate) fn existing(root: &Path, relative: &str) -> Result { + let path = lexical(relative)?; + let root = canonical_root(root)?; + let resolved = std::fs::canonicalize(root.join(path)).map_err(|_| AppError::input(MISSING))?; + if resolved.starts_with(&root) { + Ok(resolved) + } else { + Err(AppError::input(OUTSIDE)) + } +} + +/// A file to create inside the root: the parent must exist inside it and the target must not exist. +pub(crate) fn new_file(root: &Path, relative: &str) -> Result { + let path = lexical(relative)?; + let root = canonical_root(root)?; + let joined = root.join(path); + // A dangling symlink counts as existing, so nothing is written through it. + if std::fs::symlink_metadata(&joined).is_ok() { + return Err(AppError::input(EXISTS)); + } + let (Some(parent), Some(name)) = (joined.parent(), joined.file_name()) else { + return Err(AppError::input(EXISTS)); + }; + let parent = std::fs::canonicalize(parent).map_err(|_| AppError::input(MISSING))?; + if parent.starts_with(&root) { + Ok(parent.join(name)) + } else { + Err(AppError::input(OUTSIDE)) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use std::fs; + + fn message(result: Result) -> String { + let error = result.unwrap_err(); + assert_eq!(error.code, 2); + error.message + } + + #[test] + fn accepts_relative_file_inside_root() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("snapshots")).unwrap(); + fs::write(dir.path().join("snapshots/a.json"), "{}").unwrap(); + let root = fs::canonicalize(dir.path()).unwrap(); + assert_eq!( + existing(dir.path(), "snapshots/a.json").unwrap(), + root.join("snapshots/a.json") + ); + } + + #[test] + fn rejects_escaping_and_absolute_paths() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("a")).unwrap(); + for (path, expected) in [ + ("../x", "Path is outside the workspace"), + ("a/../../x", "Path is outside the workspace"), + ("/etc/passwd", "Paths must be relative to the workspace"), + ("", "Paths must be relative to the workspace"), + ("missing.json", "File not found in the workspace"), + ] { + let text = message(existing(dir.path(), path)); + assert_eq!(text, expected, "{path}"); + assert!(path.is_empty() || !text.contains(path), "{path}"); + } + } + + #[cfg(unix)] + #[test] + fn rejects_symlink_out_of_root() { + let outside = tempfile::tempdir().unwrap(); + fs::write(outside.path().join("file"), "secret").unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::os::unix::fs::symlink(outside.path().join("file"), dir.path().join("link")).unwrap(); + std::os::unix::fs::symlink(outside.path(), dir.path().join("out")).unwrap(); + assert_eq!( + message(existing(dir.path(), "link")), + "Path is outside the workspace" + ); + assert_eq!( + message(new_file(dir.path(), "out/new.json")), + "Path is outside the workspace" + ); + } + + #[test] + fn new_file_requires_existing_parent_inside_root_and_absent_target() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("plans")).unwrap(); + let root = fs::canonicalize(dir.path()).unwrap(); + assert_eq!( + new_file(dir.path(), "plans/new.json").unwrap(), + root.join("plans/new.json") + ); + fs::write(dir.path().join("plans/old.json"), "{}").unwrap(); + assert_eq!( + message(new_file(dir.path(), "plans/old.json")), + "Output file already exists" + ); + assert_eq!( + message(new_file(dir.path(), "../new.json")), + "Path is outside the workspace" + ); + assert_eq!( + message(new_file(dir.path(), "nodir/new.json")), + "File not found in the workspace" + ); + assert_eq!( + message(new_file(dir.path(), "plans/..")), + "Output file already exists" + ); + } +} From cd06d2e8f7f2d73df2960c485c3cebeff810a63e Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Fri, 23 Oct 2026 15:27:03 +0200 Subject: [PATCH 3/7] feat(mcp): user, resource, admins, orphaned and profile check tools --- crates/permesh-cli/src/mcp/mod.rs | 2 - crates/permesh-cli/src/mcp/paths.rs | 2 + crates/permesh-cli/src/mcp/tools.rs | 142 ++++++++++-- crates/permesh-cli/tests/mcp.rs | 337 +++++++++++++++++++++++++++- 4 files changed, 465 insertions(+), 18 deletions(-) diff --git a/crates/permesh-cli/src/mcp/mod.rs b/crates/permesh-cli/src/mcp/mod.rs index 00a43c2..72bc5c8 100644 --- a/crates/permesh-cli/src/mcp/mod.rs +++ b/crates/permesh-cli/src/mcp/mod.rs @@ -1,7 +1,5 @@ // SPDX-License-Identifier: MIT //! Read-only MCP server over stdio (ADR 0035). Tools run existing commands only. -// Consumers arrive with the file-taking tools in the next commits. -#[cfg_attr(not(test), allow(dead_code))] mod paths; mod server; mod tools; diff --git a/crates/permesh-cli/src/mcp/paths.rs b/crates/permesh-cli/src/mcp/paths.rs index 55c6c33..7eabf1d 100644 --- a/crates/permesh-cli/src/mcp/paths.rs +++ b/crates/permesh-cli/src/mcp/paths.rs @@ -6,6 +6,7 @@ use std::path::{Component, Path, PathBuf}; const RELATIVE: &str = "Paths must be relative to the workspace"; const OUTSIDE: &str = "Path is outside the workspace"; const MISSING: &str = "File not found in the workspace"; +#[cfg_attr(not(test), allow(dead_code))] // used by plan tools (next commit) const EXISTS: &str = "Output file already exists"; /// Refuses absolute paths and `..` that climbs above the root before touching the file system. @@ -46,6 +47,7 @@ pub(crate) fn existing(root: &Path, relative: &str) -> Result } /// A file to create inside the root: the parent must exist inside it and the target must not exist. +#[cfg_attr(not(test), allow(dead_code))] // used by plan tools (next commit) pub(crate) fn new_file(root: &Path, relative: &str) -> Result { let path = lexical(relative)?; let root = canonical_root(root)?; diff --git a/crates/permesh-cli/src/mcp/tools.rs b/crates/permesh-cli/src/mcp/tools.rs index 91995f6..aea0c52 100644 --- a/crates/permesh-cli/src/mcp/tools.rs +++ b/crates/permesh-cli/src/mcp/tools.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT //! The closed set of MCP tools. Each maps to exactly one existing command. +use super::paths; use crate::{error::AppError, report::Outcome}; use rmcp::model::{CallToolResult, ContentBlock, Tool as Definition, ToolAnnotations}; use serde::Deserialize; @@ -24,6 +25,11 @@ pub(crate) enum Tool { Doctor(DoctorArgs), #[serde(rename = "user_access")] User(UserArgs), + #[serde(rename = "resource_access")] + Resource(ResourceArgs), + Admins(NoArgs), + Orphaned(NoArgs), + ProfileCheck(ProfileCheckArgs), } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] @@ -36,6 +42,23 @@ pub(crate) struct DoctorArgs { pub(crate) struct UserArgs { identity: String, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ResourceArgs { + query: Option, + instance: Option, + id: Option, + snapshot: Option, +} +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct NoArgs {} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ProfileCheckArgs { + snapshot: Option, + policy: Option, +} fn schema(value: Value) -> Arc> { match value { @@ -48,12 +71,65 @@ fn query(name: &'static str, description: &'static str, input: Value) -> Definit .annotate(ToolAnnotations::new().read_only(true).open_world(true)) } +fn object(properties: Value, required: &[&str]) -> Value { + json!({"type": "object", "properties": properties, "required": required, "additionalProperties": false}) +} +const SNAPSHOT: &str = + "Saved snapshot to read offline instead of fresh discovery; a path relative to the workspace."; + pub(crate) fn definitions() -> Vec { - vec![query( - "doctor", - "Check the workspace configuration, credentials and provider connectivity.", - json!({"type": "object", "properties": {"details": {"type": "boolean", "description": "Include curated stage codes and remediation."}}, "additionalProperties": false}), - )] + vec![ + query( + "doctor", + "Check the workspace configuration, credentials and provider connectivity.", + object( + json!({"details": {"type": "boolean", "description": "Include curated stage codes and remediation."}}), + &[], + ), + ), + query( + "user_access", + "Show a person's observed access across providers and the paths (groups, teams, roles) that explain it, each with its certainty.", + object( + json!({"identity": {"type": "string", "description": "Identity such as an email address, or a provider account."}}), + &["identity"], + ), + ), + query( + "resource_access", + "Show who has observed access to one resource and through which paths. Give query (the exact display label), or instance and id.", + object( + json!({ + "query": {"type": "string", "description": "Exact resource display label; ambiguity returns candidates."}, + "instance": {"type": "string", "description": "Provider instance ID, with id."}, + "id": {"type": "string", "description": "Stable resource ID in that instance."}, + "snapshot": {"type": "string", "description": SNAPSHOT} + }), + &[], + ), + ), + query( + "admins", + "Show privileged access, uncertain roles and unresolved group grants.", + object(json!({}), &[]), + ), + query( + "orphaned", + "Review accounts not confidently tied to an active identity in the authoritative directory, including inactive and departed people.", + object(json!({}), &[]), + ), + query( + "profile_check", + "Compare people's declared access profiles with observed and derived access: missing access, and unexplained access nobody's profile justifies.", + object( + json!({ + "snapshot": {"type": "string", "description": SNAPSHOT}, + "policy": {"type": "string", "description": "Policy file whose unexplained_access exceptions apply; a path relative to the workspace."} + }), + &[], + ), + ), + ] } /// Only listed tools are callable, even when a variant exists for a later tool. @@ -74,6 +150,13 @@ fn option(name: &str, value: impl AsRef) -> OsString { impl Tool { /// Exact argv: values are `--name=value` and positionals follow `--`, so data never becomes a flag. pub(crate) fn argv(&self, workspace: &Path) -> Result, AppError> { + let root = workspace.parent().unwrap_or(Path::new(".")); + let file = |name: &str, value: &Option| -> Result, AppError> { + value + .as_deref() + .map(|value| Ok(option(name, paths::existing(root, value)?))) + .transpose() + }; let mut argv: Vec = vec!["permesh".into(), "--json".into()]; argv.push(option("config", workspace)); match self { @@ -86,6 +169,24 @@ impl Tool { Tool::User(args) => { argv.extend(["user".into(), "--".into(), (&args.identity).into()]); } + Tool::Resource(args) => { + argv.push("resource".into()); + argv.extend(file("snapshot", &args.snapshot)?); + match (&args.query, &args.instance, &args.id) { + (Some(query), None, None) => argv.extend(["--".into(), query.into()]), + (None, Some(instance), Some(id)) => { + argv.extend([option("instance", instance), option("id", id)]); + } + _ => return Err(AppError::input("Give query, or instance and id")), + } + } + Tool::Admins(_) => argv.push("admins".into()), + Tool::Orphaned(_) => argv.push("orphaned".into()), + Tool::ProfileCheck(args) => { + argv.extend(["profile".into(), "check".into()]); + argv.extend(file("snapshot", &args.snapshot)?); + argv.extend(file("policy", &args.policy)?); + } } Ok(argv) } @@ -132,6 +233,18 @@ mod tests { Tool::User(UserArgs { identity: "a@example.com".into(), }), + Tool::Resource(ResourceArgs { + query: Some("-x".into()), + instance: None, + id: None, + snapshot: None, + }), + Tool::Admins(NoArgs {}), + Tool::Orphaned(NoArgs {}), + Tool::ProfileCheck(ProfileCheckArgs { + snapshot: None, + policy: None, + }), ] } @@ -153,16 +266,19 @@ mod tests { for tool in samples() { let cli = parsed(&tool); assert!( - matches!(cli.command, Command::Doctor { .. } | Command::User { .. }), + matches!( + cli.command, + Command::Doctor { .. } + | Command::User { .. } + | Command::Resource(_) + | Command::Admins + | Command::Orphaned + | Command::Profile { + command: crate::profile_command::ProfileCommand::Check { .. } + } + ), "{tool:?}" ); } } - - #[test] - fn hidden_variants_are_not_callable() { - let mut arguments = Map::new(); - arguments.insert("identity".into(), json!("a")); - assert!(parse("user_access", arguments).is_err()); - } } diff --git a/crates/permesh-cli/tests/mcp.rs b/crates/permesh-cli/tests/mcp.rs index e72ca24..a969cc8 100644 --- a/crates/permesh-cli/tests/mcp.rs +++ b/crates/permesh-cli/tests/mcp.rs @@ -9,6 +9,14 @@ use std::{ time::Duration, }; +const TOOLS: &[&str] = &[ + "doctor", + "user_access", + "resource_access", + "admins", + "orphaned", + "profile_check", +]; const VERSION: &str = "2026-07-28"; const INSTRUCTIONS: &str = "Permesh answers who has access to what, read-only. Every tool result has `complete`: when it is false, some providers could not be fully read and missing access is not proof of absence, so say you cannot tell. Plan tools write a new plan file for a person to review and carry out; Permesh never changes access."; @@ -148,7 +156,7 @@ fn server_discover_describes_server() { fn tools_list_without_handshake() { let dir = demo(); let mut client = Client::start(dir.path()); - assert_eq!(client.tool_names(), ["doctor"]); + assert_eq!(client.tool_names(), TOOLS); } #[test] @@ -177,7 +185,7 @@ fn missing_meta_is_invalid_params() { let mut client = Client::start(dir.path()); let id = client.request_raw("tools/list", json!({})); assert_eq!(client.response(id)["error"]["code"], -32602); - assert_eq!(client.tool_names(), ["doctor"]); + assert_eq!(client.tool_names(), TOOLS); } #[test] @@ -203,7 +211,7 @@ fn unknown_tool_and_argument_are_errors() { let rpc_error = response["error"]["code"] == -32602; assert!(tool_error || rpc_error, "{response}"); } - assert_eq!(client.tool_names(), ["doctor"]); + assert_eq!(client.tool_names(), TOOLS); } #[test] @@ -233,3 +241,326 @@ fn outside_workspace_exits_input() { assert_eq!(output.status.code(), Some(2)); assert!(output.stdout.is_empty()); } + +const BACKEND: &str = "profile: backend\ngrants:\n - {instance: demo, group: team-10}\n - {instance: demo, role: Read, resource: repo-21}\n"; + +fn json_run(dir: &Path, args: &[&str]) -> Value { + let output = command(dir).args(args).arg("--json").output().unwrap(); + serde_json::from_slice(&output.stdout).unwrap_or_else(|_| panic!("{args:?}: {output:?}")) +} +fn write(dir: &Path, name: &str, body: &str) { + let path = dir.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, body).unwrap(); +} +fn structured(result: &Value) -> &Value { + &result["structuredContent"] +} + +#[test] +fn user_access_matches_cli() { + let dir = demo(); + let cli = json_run(dir.path(), &["user", "alice@example.com"]); + let mut client = Client::start(dir.path()); + let result = client.call("user_access", json!({"identity": "alice@example.com"})); + assert_eq!(result["isError"], false, "{result}"); + let report = &structured(&result)["report"]; + assert_eq!(report["result"], cli["result"]); + assert_eq!(report["schema_version"], 2); + let access = report["result"]["access"].as_array().unwrap(); + assert!(!access.is_empty()); + assert!( + access.iter().all(|path| path["certainty"].is_string()), + "{report}" + ); +} + +#[test] +fn user_access_not_found_is_error_exit_1() { + let dir = demo(); + let mut client = Client::start(dir.path()); + let result = client.call("user_access", json!({"identity": "missing@example.com"})); + assert_eq!(result["isError"], true, "{result}"); + assert_eq!(structured(&result)["exit_code"], 1); +} + +#[test] +fn resource_access_by_query_and_by_id() { + let dir = demo(); + let mut client = Client::start(dir.path()); + let by_id = client.call( + "resource_access", + json!({"instance": "demo", "id": "repo-20"}), + ); + assert_eq!(by_id["isError"], false, "{by_id}"); + let resource = &structured(&by_id)["report"]["result"]["resource"]; + assert_eq!(resource["key"]["id"], "repo-20"); + let name = resource["name"].as_str().unwrap(); + let by_query = client.call("resource_access", json!({"query": name})); + assert_eq!( + structured(&by_query)["report"]["result"]["resource"]["key"]["id"], + "repo-20", + "{by_query}" + ); + let cli = json_run( + dir.path(), + &["resource", "--instance", "demo", "--id", "repo-20"], + ); + let timeless = |value: &Value| { + let mut value = value.clone(); + for key in ["observation_started_at", "observation_completed_at"] { + value.as_object_mut().unwrap().remove(key); + } + value + }; + assert!( + timeless(&structured(&by_id)["report"]["result"]) == timeless(&cli["result"]), + "resource result differs from the CLI" + ); +} + +#[test] +fn resource_access_rejects_both_forms() { + let dir = demo(); + let mut client = Client::start(dir.path()); + for arguments in [ + json!({"query": "x", "instance": "demo", "id": "repo-20"}), + json!({}), + json!({"instance": "demo"}), + ] { + let result = client.call("resource_access", arguments); + assert_eq!(result["isError"], true, "{result}"); + assert_eq!(structured(&result)["exit_code"], 2); + assert_eq!( + structured(&result)["error"]["message"], + "Give query, or instance and id" + ); + } +} + +#[test] +fn admins_and_orphaned_match_cli() { + let dir = demo(); + let mut client = Client::start(dir.path()); + for (tool, command) in [("admins", "admins"), ("orphaned", "orphaned")] { + let cli = json_run(dir.path(), &[command]); + let result = client.call(tool, json!({})); + if cli["error"].is_object() { + assert_eq!(result["isError"], true, "{result}"); + assert_eq!( + structured(&result)["error"]["message"], + cli["error"]["message"] + ); + assert_eq!(structured(&result)["exit_code"], cli["error"]["code"]); + } else { + assert_eq!(result["isError"], false, "{result}"); + assert_eq!(structured(&result)["report"]["result"], cli["result"]); + } + } +} + +#[test] +fn profile_check_reports_unexplained() { + let dir = demo(); + write(dir.path(), "profiles/backend.yaml", BACKEND); + write( + dir.path(), + "people/bob.yaml", + "identity: bob@example.com\nprofiles: [backend]\n", + ); + let cli = json_run(dir.path(), &["profile", "check"]); + let mut client = Client::start(dir.path()); + let result = client.call("profile_check", json!({})); + assert_eq!(result["isError"], false, "{result}"); + assert_eq!(structured(&result)["report"]["result"], cli["result"]); + assert_eq!(structured(&result)["report"]["command"], "profile_check"); +} + +#[test] +fn incomplete_capture_carries_note() { + let dir = demo(); + write(dir.path(), "profiles/backend.yaml", BACKEND); + write( + dir.path(), + "people/bob.yaml", + "identity: bob@example.com\nprofiles: [backend]\n", + ); + assert!( + run(dir.path(), &["snapshot", "create", "--output", "s.json"]) + .status + .success() + ); + let mut snapshot: Value = + serde_json::from_slice(&std::fs::read(dir.path().join("s.json")).unwrap()).unwrap(); + let limitations = json!(["Fixture visibility incomplete"]); + snapshot["providers"][0]["state"] = "partial".into(); + snapshot["providers"][0]["limitations"] = limitations.clone(); + snapshot["providers"][0]["data"]["complete"] = false.into(); + snapshot["providers"][0]["data"]["limitations"] = limitations; + std::fs::write(dir.path().join("partial.json"), snapshot.to_string()).unwrap(); + let mut client = Client::start(dir.path()); + let result = client.call("profile_check", json!({"snapshot": "partial.json"})); + assert_eq!(result["isError"], false, "{result}"); + assert_eq!(structured(&result)["exit_code"], 4); + assert_eq!(structured(&result)["report"]["complete"], false); + assert_eq!( + structured(&result)["note"], + "Some providers could not be fully read. Missing access here is not proof of absence." + ); +} + +#[test] +fn snapshot_outside_workspace_is_input_error() { + let dir = demo(); + let mut client = Client::start(dir.path()); + let result = client.call("profile_check", json!({"snapshot": "../s.json"})); + assert_eq!(result["isError"], true, "{result}"); + assert_eq!(structured(&result)["exit_code"], 2); + assert_eq!( + structured(&result)["error"]["message"], + "Path is outside the workspace" + ); +} + +#[cfg(unix)] +#[test] +fn cancelled_call_drains_and_server_continues() { + use std::time::Instant; + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().canonicalize().unwrap(); + let digest = fixture(&root); + let config = json!({"version":1,"organization":{"name":"Cancellation fixture"}, + "providers":[{"id":"instance","type":"external","external":{"provider":"fixture","sha256":digest,"configuration":{},"credentials":{}}}]}); + std::fs::write( + root.join("permesh.yaml"), + serde_json::to_vec(&config).unwrap(), + ) + .unwrap(); + let review = json_run(&root, &["provider", "external", "review", "instance"]); + let fingerprint = review["result"]["fingerprint"].as_str().unwrap(); + json_run( + &root, + &[ + "provider", + "external", + "approve", + "instance", + "--fingerprint", + fingerprint, + "--accept-risk", + ], + ); + let mut client = Client::start(&root); + let id = client.start_request( + "tools/call", + json!({"name": "user_access", "arguments": {"identity": "missing"}}), + ); + let marker = root.join("ready.pid"); + let deadline = Instant::now() + Duration::from_secs(10); + while !marker.exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + let provider_pid = std::fs::read_to_string(&marker).expect("discovery never started"); + client.send( + &json!({"jsonrpc": "2.0", "method": "notifications/cancelled", + "params": {"requestId": id, "reason": "test", "_meta": meta()}}), + ); + let deadline = Instant::now() + Duration::from_secs(10); + let alive = || { + Command::new("kill") + .args(["-0", provider_pid.trim()]) + .output() + .unwrap() + .status + .success() + }; + while alive() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + let still_alive = alive(); + if still_alive { + let _ = Command::new("kill") + .args(["-KILL", provider_pid.trim()]) + .status(); + } + assert!( + !still_alive, + "cancelled provider was not terminated and reaped" + ); + assert_eq!(client.tool_names().len(), definitions_count()); + let (_, lines) = client.finish(); + for line in lines { + let value: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(value["jsonrpc"], "2.0", "{line}"); + assert!(!line.contains("CANCELLATION_PRIVATE"), "{line}"); + } +} +fn definitions_count() -> usize { + TOOLS.len() +} + +/// A real native provider speaks either legacy protocol, exposes a private +/// observation, then hangs after marking its PID. This synchronizes SIGINT with +/// active discovery rather than relying on startup timing. +#[cfg(unix)] +fn fixture(root: &Path) -> String { + let source = root.join("peer.rs"); + let binary = root.join("peer"); + let code = r##" +use std::io::{BufRead, Write}; +fn main() { + let mut lines = std::io::stdin().lock().lines(); + let handshake = lines.next().unwrap().unwrap(); + let version = if handshake.contains("\"protocol\":2") { 2 } else { 1 }; + println!("{}", r#"{"protocol":VERSION,"id":"handshake","event":"handshake","provider":"fixture","capabilities":["accounts"],"draft":true}"#.replace("VERSION", &version.to_string())); + std::io::stdout().flush().unwrap(); + let discover = lines.next().unwrap().unwrap(); + assert!(discover.contains("\"method\":\"discover\"")); + println!("{}", r#"{"protocol":VERSION,"id":"discover","event":"record","kind":"account","data":{"key":{"provider":"instance","id":"guest"},"login":"CANCELLATION_PRIVATE_OBSERVATION","kind":"human","verified_emails":[]}}"#.replace("VERSION", &version.to_string())); + std::io::stdout().flush().unwrap(); + eprintln!("CANCELLATION_PRIVATE_STDERR"); + let marker = std::path::Path::new(MARKER); + let temporary = marker.with_extension("tmp"); + std::fs::write(&temporary, std::process::id().to_string()).unwrap(); + std::fs::rename(temporary, marker).unwrap(); + // Deliberately ignore cancellation; the host must terminate and reap us. + loop { std::thread::sleep(std::time::Duration::from_secs(60)); } +} +"## + .replace("MARKER", &format!("{:?}", root.join("ready.pid"))); + std::fs::write(&source, code).unwrap(); + assert!( + Command::new("rustc") + .arg(&source) + .arg("-o") + .arg(&binary) + .status() + .unwrap() + .success() + ); + let inspect = json_run( + root, + &["provider", "external", "inspect", binary.to_str().unwrap()], + ); + let digest = inspect["result"]["inspection"]["sha256"] + .as_str() + .unwrap() + .to_owned(); + json_run( + root, + &[ + "provider", + "external", + "trust", + binary.to_str().unwrap(), + "--id", + "fixture", + "--sha256", + &digest, + "--capability", + "accounts", + "--accept-risk", + ], + ); + digest +} From a02602cd4c2b8d6f94c852873432d20ea27c20a8 Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Fri, 23 Oct 2026 15:30:27 +0200 Subject: [PATCH 4/7] feat(mcp): plan and verify tools write only new plan files in the workspace --- crates/permesh-cli/src/mcp/paths.rs | 2 - crates/permesh-cli/src/mcp/tools.rs | 191 ++++++++++++++++++++++++++-- crates/permesh-cli/tests/mcp.rs | 157 +++++++++++++++++++++++ 3 files changed, 340 insertions(+), 10 deletions(-) diff --git a/crates/permesh-cli/src/mcp/paths.rs b/crates/permesh-cli/src/mcp/paths.rs index 7eabf1d..55c6c33 100644 --- a/crates/permesh-cli/src/mcp/paths.rs +++ b/crates/permesh-cli/src/mcp/paths.rs @@ -6,7 +6,6 @@ use std::path::{Component, Path, PathBuf}; const RELATIVE: &str = "Paths must be relative to the workspace"; const OUTSIDE: &str = "Path is outside the workspace"; const MISSING: &str = "File not found in the workspace"; -#[cfg_attr(not(test), allow(dead_code))] // used by plan tools (next commit) const EXISTS: &str = "Output file already exists"; /// Refuses absolute paths and `..` that climbs above the root before touching the file system. @@ -47,7 +46,6 @@ pub(crate) fn existing(root: &Path, relative: &str) -> Result } /// A file to create inside the root: the parent must exist inside it and the target must not exist. -#[cfg_attr(not(test), allow(dead_code))] // used by plan tools (next commit) pub(crate) fn new_file(root: &Path, relative: &str) -> Result { let path = lexical(relative)?; let root = canonical_root(root)?; diff --git a/crates/permesh-cli/src/mcp/tools.rs b/crates/permesh-cli/src/mcp/tools.rs index aea0c52..8b46208 100644 --- a/crates/permesh-cli/src/mcp/tools.rs +++ b/crates/permesh-cli/src/mcp/tools.rs @@ -30,6 +30,10 @@ pub(crate) enum Tool { Admins(NoArgs), Orphaned(NoArgs), ProfileCheck(ProfileCheckArgs), + PlanJoin(JoinArgs), + PlanMove(MoveArgs), + PlanLeave(LeaveArgs), + VerifyPlan(VerifyArgs), } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] @@ -60,6 +64,42 @@ pub(crate) struct ProfileCheckArgs { policy: Option, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct JoinArgs { + person: String, + profiles: Vec, + output: String, + snapshot: Option, + expires_in_hours: Option, +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct MoveArgs { + person: String, + remove: Vec, + #[serde(default)] + add: Vec, + output: String, + snapshot: Option, + expires_in_hours: Option, +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LeaveArgs { + person: String, + annotations: Option, + output: String, + snapshot: Option, + expires_in_hours: Option, +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct VerifyArgs { + plan: String, + snapshot: Option, +} + fn schema(value: Value) -> Arc> { match value { Value::Object(map) => Arc::new(map), @@ -74,6 +114,27 @@ fn query(name: &'static str, description: &'static str, input: Value) -> Definit fn object(properties: Value, required: &[&str]) -> Value { json!({"type": "object", "properties": properties, "required": required, "additionalProperties": false}) } +/// Writes one new local file and never overwrites; no provider is changed. +fn plan(name: &'static str, description: &'static str, input: Value) -> Definition { + Definition::new(name, description, schema(input)).annotate( + ToolAnnotations::new() + .read_only(false) + .destructive(false) + .idempotent(false) + .open_world(true), + ) +} +fn plan_properties(mut properties: Value) -> Value { + properties["person"] = + json!({"type": "string", "description": "The person's identity, as in their people file."}); + properties["output"] = json!({"type": "string", "description": "New plan file to write, relative to the workspace; its folder must exist and the file must not."}); + properties["snapshot"] = json!({"type": "string", "description": SNAPSHOT}); + properties["expires_in_hours"] = json!({"type": "integer", "minimum": 1, "maximum": 168, "default": 24, "description": "Hours the plan stays fresh, from the evidence start."}); + properties +} +fn names(description: &str) -> Value { + json!({"type": "array", "items": {"type": "string"}, "description": description}) +} const SNAPSHOT: &str = "Saved snapshot to read offline instead of fresh discovery; a path relative to the workspace."; @@ -129,6 +190,45 @@ pub(crate) fn definitions() -> Vec { &[], ), ), + plan( + "plan_join", + "Prepare a reviewable plan file of the access a person needs for added profiles, including accounts still to create. Permesh never applies it.", + object( + plan_properties(json!({"profiles": names("Profiles to add.")})), + &["person", "profiles", "output"], + ), + ), + plan( + "plan_move", + "Prepare a reviewable plan file for a profile change: removals only for access no remaining profile justifies, and shared access listed as kept. Permesh never applies it.", + object( + plan_properties( + json!({"remove": names("Profiles to take away."), "add": names("Profiles to add.")}), + ), + &["person", "remove", "output"], + ), + ), + plan( + "plan_leave", + "Prepare a reviewable plan file removing all of a departing person's access, separating expected from unexplained access, with account, ownership and machine-account review steps. Permesh never applies it.", + object( + plan_properties( + json!({"annotations": {"type": "string", "description": "Owner annotations for machine accounts, as in offboard; a path relative to the workspace."}}), + ), + &["person", "output"], + ), + ), + query( + "verify_plan", + "Check each step of a plan file against fresh discovery: done, not done or cannot verify, plus any extra access.", + object( + json!({ + "plan": {"type": "string", "description": "Plan file, relative to the workspace."}, + "snapshot": {"type": "string", "description": SNAPSHOT} + }), + &["plan"], + ), + ), ] } @@ -157,6 +257,16 @@ impl Tool { .map(|value| Ok(option(name, paths::existing(root, value)?))) .transpose() }; + // Flags every plan tool shares; `output` is a new file inside the workspace. + let common = |output: &str, + snapshot: &Option, + hours: Option| + -> Result, AppError> { + let mut flags = vec![option("output", paths::new_file(root, output)?)]; + flags.extend(file("snapshot", snapshot)?); + flags.extend(hours.map(|hours| option("expires-in-hours", hours.to_string()))); + Ok(flags) + }; let mut argv: Vec = vec!["permesh".into(), "--json".into()]; argv.push(option("config", workspace)); match self { @@ -187,6 +297,31 @@ impl Tool { argv.extend(file("snapshot", &args.snapshot)?); argv.extend(file("policy", &args.policy)?); } + Tool::PlanJoin(args) => { + argv.extend(["access".into(), "join".into()]); + argv.extend(args.profiles.iter().map(|p| option("profile", p))); + argv.extend(common(&args.output, &args.snapshot, args.expires_in_hours)?); + argv.extend(["--".into(), (&args.person).into()]); + } + Tool::PlanMove(args) => { + argv.extend(["access".into(), "move".into()]); + argv.extend(args.remove.iter().map(|p| option("remove", p))); + argv.extend(args.add.iter().map(|p| option("add", p))); + argv.extend(common(&args.output, &args.snapshot, args.expires_in_hours)?); + argv.extend(["--".into(), (&args.person).into()]); + } + Tool::PlanLeave(args) => { + argv.extend(["access".into(), "leave".into()]); + argv.extend(file("annotations", &args.annotations)?); + argv.extend(common(&args.output, &args.snapshot, args.expires_in_hours)?); + argv.extend(["--".into(), (&args.person).into()]); + } + Tool::VerifyPlan(args) => { + argv.extend(["access".into(), "verify".into()]); + argv.extend(file("snapshot", &args.snapshot)?); + let plan = paths::existing(root, &args.plan)?; + argv.extend(["--".into(), plan.into()]); + } } Ok(argv) } @@ -221,14 +356,46 @@ pub(crate) fn result(outcome: Result) -> CallToolResult { mod tests { #![allow(clippy::unwrap_used)] use super::*; - use crate::args::{Cli, Command}; + use crate::{ + access::AccessCommand, + args::{Cli, Command}, + }; use clap::Parser; - fn parsed(tool: &Tool) -> Cli { - Cli::try_parse_from(tool.argv(Path::new("/w/permesh.yaml")).unwrap()).unwrap() + fn parsed(root: &Path, tool: &Tool) -> Cli { + Cli::try_parse_from(tool.argv(&root.join("permesh.yaml")).unwrap()).unwrap() } - fn samples() -> Vec { + /// One sample per variant; file arguments exist inside `root`. + fn samples(root: &Path) -> Vec { + std::fs::write(root.join("p.json"), "{}").unwrap(); + let snapshot = || Some("p.json".to_string()); vec![ + Tool::PlanJoin(JoinArgs { + person: "-p".into(), + profiles: vec!["--x".into()], + output: "new.json".into(), + snapshot: snapshot(), + expires_in_hours: Some(48), + }), + Tool::PlanMove(MoveArgs { + person: "-p".into(), + remove: vec!["a".into()], + add: vec!["b".into()], + output: "new.json".into(), + snapshot: None, + expires_in_hours: None, + }), + Tool::PlanLeave(LeaveArgs { + person: "-p".into(), + annotations: snapshot(), + output: "new.json".into(), + snapshot: None, + expires_in_hours: None, + }), + Tool::VerifyPlan(VerifyArgs { + plan: "p.json".into(), + snapshot: snapshot(), + }), Tool::Doctor(DoctorArgs { details: true }), Tool::User(UserArgs { identity: "a@example.com".into(), @@ -255,16 +422,18 @@ mod tests { }); let argv = tool.argv(Path::new("/w/permesh.yaml")).unwrap(); assert_eq!(argv[argv.len() - 3..], ["user", "--", "--config=/x"]); - let cli = parsed(&tool); + let dir = tempfile::tempdir().unwrap(); + let cli = parsed(dir.path(), &tool); assert!(matches!(&cli.command, Command::User { identity } if identity == "--config=/x")); - assert_eq!(cli.config.as_deref(), Some(Path::new("/w/permesh.yaml"))); + assert_eq!(cli.config, Some(dir.path().join("permesh.yaml"))); assert!(cli.json); } #[test] fn argv_only_reaches_allowed_commands() { - for tool in samples() { - let cli = parsed(&tool); + let dir = tempfile::tempdir().unwrap(); + for tool in samples(dir.path()) { + let cli = parsed(dir.path(), &tool); assert!( matches!( cli.command, @@ -276,6 +445,12 @@ mod tests { | Command::Profile { command: crate::profile_command::ProfileCommand::Check { .. } } + | Command::Access { + command: AccessCommand::Join { .. } + | AccessCommand::Move { .. } + | AccessCommand::Leave { .. } + | AccessCommand::Verify { .. } + } ), "{tool:?}" ); diff --git a/crates/permesh-cli/tests/mcp.rs b/crates/permesh-cli/tests/mcp.rs index a969cc8..b35048b 100644 --- a/crates/permesh-cli/tests/mcp.rs +++ b/crates/permesh-cli/tests/mcp.rs @@ -16,6 +16,10 @@ const TOOLS: &[&str] = &[ "admins", "orphaned", "profile_check", + "plan_join", + "plan_move", + "plan_leave", + "verify_plan", ]; const VERSION: &str = "2026-07-28"; const INSTRUCTIONS: &str = "Permesh answers who has access to what, read-only. Every tool result has `complete`: when it is false, some providers could not be fully read and missing access is not proof of absence, so say you cannot tell. Plan tools write a new plan file for a person to review and carry out; Permesh never changes access."; @@ -422,6 +426,159 @@ fn snapshot_outside_workspace_is_input_error() { ); } +fn step_ids(plan: &Value) -> std::collections::BTreeSet { + plan["steps"] + .as_array() + .unwrap() + .iter() + .map(|step| step["id"].as_str().unwrap().to_owned()) + .collect() +} +fn planned() -> tempfile::TempDir { + let dir = demo(); + write(dir.path(), "profiles/backend.yaml", BACKEND); + write( + dir.path(), + "people/bob.yaml", + "identity: bob@example.com\nprofiles: [backend]\n", + ); + std::fs::create_dir(dir.path().join("plans")).unwrap(); + dir +} + +#[test] +fn plan_join_writes_plan_file() { + let dir = planned(); + let mut client = Client::start(dir.path()); + let result = client.call( + "plan_join", + json!({"person": "carol@example.com", "profiles": ["backend"], "output": "plans/carol.json"}), + ); + assert_eq!(result["isError"], false, "{result}"); + assert_eq!(structured(&result)["exit_code"], 0); + let report = &structured(&result)["report"]; + assert_eq!(report["command"], "access_plan"); + let file: Value = + serde_json::from_slice(&std::fs::read(dir.path().join("plans/carol.json")).unwrap()) + .unwrap(); + assert_eq!(file, report["result"]["access_plan"]); + assert_eq!(file["kind"], "join"); + let cli = command(dir.path()) + .args(["access", "verify", "plans/carol.json", "--json"]) + .output() + .unwrap(); + assert_ne!(cli.status.code(), Some(2), "{cli:?}"); +} + +#[test] +fn plan_tool_never_overwrites() { + let dir = planned(); + let mut client = Client::start(dir.path()); + let arguments = json!({"person": "carol@example.com", "profiles": ["backend"], "output": "plans/carol.json"}); + client.call("plan_join", arguments.clone()); + let before = std::fs::read(dir.path().join("plans/carol.json")).unwrap(); + let result = client.call("plan_join", arguments); + assert_eq!(result["isError"], true, "{result}"); + assert_eq!(structured(&result)["exit_code"], 2); + assert_eq!( + structured(&result)["error"]["message"], + "Output file already exists" + ); + assert_eq!( + std::fs::read(dir.path().join("plans/carol.json")).unwrap(), + before + ); +} + +#[test] +fn plan_move_and_leave_match_cli() { + let dir = planned(); + let mut client = Client::start(dir.path()); + let moved = client.call( + "plan_move", + json!({"person": "alice@example.com", "remove": ["backend"], "output": "plans/move.json"}), + ); + assert_eq!(moved["isError"], false, "{moved}"); + let left = client.call( + "plan_leave", + json!({"person": "alice@example.com", "output": "plans/leave.json", "expires_in_hours": 48}), + ); + assert_eq!(left["isError"], false, "{left}"); + let cli_move = json_run( + dir.path(), + &[ + "access", + "move", + "alice@example.com", + "--remove", + "backend", + "--output", + "cli-move.json", + ], + ); + let cli_leave = json_run( + dir.path(), + &[ + "access", + "leave", + "alice@example.com", + "--output", + "cli-leave.json", + ], + ); + let ids = |value: &Value| step_ids(&value["result"]["access_plan"]); + assert!(!ids(&cli_move).is_empty()); + assert_eq!(ids(&structured(&moved)["report"]), ids(&cli_move)); + assert_eq!(ids(&structured(&left)["report"]), ids(&cli_leave)); + assert_eq!( + structured(&left)["report"]["result"]["access_plan"]["kind"], + "leave" + ); +} + +#[test] +fn verify_plan_reports_steps() { + let dir = planned(); + let mut client = Client::start(dir.path()); + client.call( + "plan_join", + json!({"person": "bob@example.com", "profiles": ["backend"], "output": "plans/bob.json"}), + ); + let result = client.call("verify_plan", json!({"plan": "plans/bob.json"})); + let cli = command(dir.path()) + .args(["access", "verify", "plans/bob.json", "--json"]) + .output() + .unwrap(); + let cli_report: Value = serde_json::from_slice(&cli.stdout).unwrap(); + assert_eq!(result["isError"], false, "{result}"); + assert_eq!( + structured(&result)["exit_code"], + json!(cli.status.code().unwrap()) + ); + assert_eq!(structured(&result)["exit_code"], 6); + assert_eq!( + structured(&result)["report"]["result"]["checks"], + cli_report["result"]["checks"] + ); +} + +#[test] +fn plan_output_outside_workspace_is_refused() { + let dir = planned(); + let mut client = Client::start(dir.path()); + let result = client.call( + "plan_join", + json!({"person": "carol@example.com", "profiles": ["backend"], "output": "../x.json"}), + ); + assert_eq!(result["isError"], true, "{result}"); + assert_eq!(structured(&result)["exit_code"], 2); + assert_eq!( + structured(&result)["error"]["message"], + "Path is outside the workspace" + ); + assert!(!dir.path().parent().unwrap().join("x.json").exists()); +} + #[cfg(unix)] #[test] fn cancelled_call_drains_and_server_continues() { From ca0a49b39581f003b3c8b805982ea938f4ef25ea Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Fri, 23 Oct 2026 15:31:32 +0200 Subject: [PATCH 5/7] docs: MCP server, ADR 0035 --- CHANGELOG.md | 6 ++ README.md | 11 +++ docs/README.md | 1 + docs/adr/0035-read-only-mcp-server.md | 52 ++++++++++++++ docs/mcp.md | 97 +++++++++++++++++++++++++++ docs/stability.md | 2 + 6 files changed, 169 insertions(+) create mode 100644 docs/adr/0035-read-only-mcp-server.md create mode 100644 docs/mcp.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 075d544..e85247e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,12 @@ User-visible changes are recorded here. Alpha releases are for evaluation; stabl `status: left`, which `permesh check` counts as departed. The starter gains plan and verify workflows ([access plans](docs/access-plans.md), [ADR 0034](docs/adr/0034-access-plans.md)). +- Add experimental `permesh mcp`: a local, read-only MCP server (protocol + 2026-07-28) whose tools answer user, resource, admin, orphaned and profile + questions, prepare join, move and leave plan files inside the workspace, and + verify plans. Results carry the CLI's report, exit code and completeness. It never + changes access ([AI assistants](docs/mcp.md), + [ADR 0035](docs/adr/0035-read-only-mcp-server.md)). - Guided `provider add` now supports GitLab, Entra ID and AWS Identity Center. - `provider list` reports each external provider's ID and whether it has recorded diff --git a/README.md b/README.md index 89e9ef0..22a2119 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,17 @@ reports what is gone, what remains and what can't be checked. Permesh never trea "not seen" as "removed", and it makes no changes itself. See the [departure workflow](docs/offboarding.md). +## Ask an AI assistant + +`permesh mcp` lets an AI assistant on your machine ask the same questions, +read-only, with the same completeness warnings: + +```bash +claude mcp add permesh -- permesh mcp --config "$PWD/permesh.yaml" +``` + +It can prepare plan files but never changes access. See [AI assistants](docs/mcp.md). + ## Connect your systems In a new directory, separate from the demo: diff --git a/docs/README.md b/docs/README.md index 8debd5c..d96db64 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,6 +28,7 @@ contracts; use its [release notes](releases/0.1.0-alpha.2.md) and the | Compare saved observations offline | [Snapshots and diffs](snapshots.md) | | Declare expected access and find unexplained access | [Access profiles](profiles.md) | | Plan and verify a join, project move or departure | [Joiner, mover and leaver plans](access-plans.md) | +| Ask an AI assistant the same questions (MCP) | [AI assistants](mcp.md) | | Run weekly checks in CI and get notified | [Scheduled checks](check.md), [notifications](notifications.md), [starter](starter.md) | | Assess a departure and verify later observations | [Read-only offboarding](offboarding.md) | | Review one resource or run local rules | [Resource queries and policies](resource-policies.md) | diff --git a/docs/adr/0035-read-only-mcp-server.md b/docs/adr/0035-read-only-mcp-server.md new file mode 100644 index 0000000..379929a --- /dev/null +++ b/docs/adr/0035-read-only-mcp-server.md @@ -0,0 +1,52 @@ +# ADR 0035: Read-only MCP server + +Status: accepted for the unreleased source candidate (experimental) + +## Decision + +`permesh mcp` runs a local [Model Context Protocol](https://modelcontextprotocol.io) +server over stdio, so an AI assistant on the administrator's machine can ask the +same questions as the CLI. It never changes access, including after a later apply +stage ([ADR 0028](0028-separate-opt-in-remediation.md)): applying stays a deliberate +CLI action. + +- **Same commands, same trust.** Each tool is one variant of a closed list and + becomes an exact `permesh --json` argument vector, which the normal parser and + command code run. The server therefore uses the same workspace, approvals and + credentials as the CLI. There is no generic pass-through, so no tool can reach + `check` (it sends notifications), `snapshot save`, `identity map`, `auth`, + `provider`, `init`, `offboard` or `access review`. +- **Tools.** `doctor`, `user_access`, `resource_access`, `admins`, `orphaned`, + `profile_check`, `plan_join`, `plan_move`, `plan_leave` and `verify_plan`. +- **One write.** The plan tools write a new plan file, which a person reviews and + carries out. They never overwrite a file. HTML views are not offered. +- **Paths stay inside the workspace.** File arguments are relative to the workspace + folder. Absolute paths, `..` that leaves it and symlinks that point outside are + input errors, checked before any command runs. Values are passed as `--name=value` + and positionals follow `--`, so a value never becomes a flag. +- **Results carry completeness.** A result holds the exit code and the same report + `--json` prints. When the capture is incomplete it adds a note that missing access + is not proof of absence. Exit codes 4 and 6 are answers, not tool errors. +- **One call at a time.** Calls run in sequence. A cancelled call cancels the + command and waits until provider processes are stopped, as Ctrl+C does. +- **Protocol 2026-07-28 only.** The server uses the official Rust SDK (`rmcp`) and + supports only the stateless MCP revision with `server/discover`. Clients that + start with the older `initialize` handshake get an error naming that version. + +## Why + +A single administrator often has no second person to ask. An assistant that can +answer "who can still reach this repository, and through which group?" helps only +if it reports uncertainty honestly and cannot be talked into changing anything. +Running the existing commands, instead of a second implementation, keeps the +answers identical to the CLI's and inherits every safety rule they already follow. +Supporting one protocol revision keeps the surface small while there are no users +to stay compatible with. + +## Consequences + +The assistant sees the same access data the CLI shows, so it is only as private as +the assistant and its client. Older MCP clients cannot connect until they support +2026-07-28. Each call runs fresh discovery unless a snapshot is given, so answers +can be slow and use provider rate limits. Plan files need an existing folder in the +workspace. diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..73d8bec --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,97 @@ +# AI assistants (MCP) + +`permesh mcp` lets an AI assistant on your machine ask Permesh the questions you +would ask the CLI: who has access to a repository, why someone still has it, what a +move or departure should remove. The assistant gets the same answers as the CLI, +including whether they are complete. It cannot change access. + +The server and its tools are experimental ([stability](stability.md)). + +## Set it up + +The assistant starts `permesh mcp` itself and talks to it over stdin and stdout. +Point it at your workspace file with an absolute path. + +Claude Code: + +```bash +claude mcp add permesh -- permesh mcp --config /path/to/company-access/permesh.yaml +``` + +Other clients take an entry like this in their MCP server configuration: + +```json +{ + "mcpServers": { + "permesh": { + "command": "permesh", + "args": ["mcp", "--config", "/path/to/company-access/permesh.yaml"] + } + } +} +``` + +Set up providers, credentials and external-provider approvals with the CLI first. +The server uses them as they are; it never approves, stores credentials or prompts. + +**Client requirement.** The server speaks MCP protocol version 2026-07-28 only. +A client that starts with the older `initialize` handshake gets error `-32022` +listing `2026-07-28`, and `permesh mcp` exits with code 2. Update the client if +you see that error. + +## Tools + +| Tool | Answers | Runs | +| --- | --- | --- | +| `doctor` | Is the configuration valid and can each provider be reached? | `permesh doctor` | +| `user_access` | What can this person reach, and through which groups and roles? | `permesh user` | +| `resource_access` | Who can reach this resource? Give `query`, or `instance` and `id`. | `permesh resource` | +| `admins` | Who has privileged access, and where is privilege uncertain? | `permesh admins` | +| `orphaned` | Which accounts don't belong to an active person? | `permesh orphaned` | +| `profile_check` | What access is missing, and what does no profile explain? | `permesh profile check` | +| `plan_join` | Write a plan for the access a new profile needs. | `permesh access join` | +| `plan_move` | Write a plan for a profile change. | `permesh access move` | +| `plan_leave` | Write a plan to remove a departing person's access. | `permesh access leave` | +| `verify_plan` | Which plan steps are done, not done, or can't be checked? | `permesh access verify` | + +`resource_access`, `profile_check`, the plan tools and `verify_plan` accept +`snapshot` to read a saved snapshot instead of running fresh discovery. See +[access plans](access-plans.md) for what plans contain. + +Not available through MCP, on purpose: `check` (it sends notifications), +`snapshot save`, `identity map`, `auth`, `provider`, `init`, `offboard` and +`access review`. Use the CLI for those. + +## Reading results + +Each result's structured content is: + +- `exit_code`: the CLI exit code ([exit codes](output-schema.md)). 4 (incomplete) + and 6 (needs action) are answers, not errors. +- `report`: the same report `--json` prints, with `complete`, provider states and, + on every access path, its certainty. +- `note`, when `report.complete` is false: "Some providers could not be fully + read. Missing access here is not proof of absence." + +A failure sets `isError` and gives `error.message` instead of `report`. The same +JSON is also in the text content. + +When `complete` is false, a good assistant says it can't tell, rather than "no +access". The server's instructions tell it so. + +## Files and paths + +File arguments (`snapshot`, `policy`, `annotations`, `plan`, `output`) are paths +relative to the folder that holds `permesh.yaml`. Absolute paths, `..` that leaves +the folder and symlinks that point outside it are refused. + +The plan tools are the only tools that write. They create `output` as a new file; +its folder must already exist (for example `plans/`) and the file must not. Permesh +never overwrites a plan and never carries one out. + +## Privacy + +The assistant sees what the CLI shows: names, accounts, groups and resources. Use +an assistant and client you trust with that data. Each call may run fresh +discovery against your providers, which takes time and uses their rate limits; +give `snapshot` to work from a saved capture instead. diff --git a/docs/stability.md b/docs/stability.md index 69d045a..69c5186 100644 --- a/docs/stability.md +++ b/docs/stability.md @@ -41,6 +41,8 @@ These may change between releases without a migration guide. (`join`, `move`, `leave`, `verify`, `review`), the `permesh_access_plan` v1 file, people files' `status`, and the plan and verify starter workflows. A second stability declaration will freeze these formats. +- [AI assistants](mcp.md): `permesh mcp`, its tool names, arguments and result + shape, and the supported MCP protocol version. ## Provider maturity From ab5d4fefb772f7944930e82dd05c7150f533ceb8 Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Fri, 23 Oct 2026 15:41:34 +0200 Subject: [PATCH 6/7] fix(mcp): keep plan files out of workspace inputs, drain running calls on SIGTERM and session end --- crates/permesh-cli/src/mcp/mod.rs | 37 ++++++++++-- crates/permesh-cli/src/mcp/paths.rs | 53 ++++++++++++++++- crates/permesh-cli/src/mcp/server.rs | 27 +++++++-- crates/permesh-cli/src/mcp/tools.rs | 2 +- crates/permesh-cli/tests/mcp.rs | 82 ++++++++++++++++++++++++++- docs/adr/0035-read-only-mcp-server.md | 9 ++- docs/mcp.md | 9 ++- 7 files changed, 198 insertions(+), 21 deletions(-) diff --git a/crates/permesh-cli/src/mcp/mod.rs b/crates/permesh-cli/src/mcp/mod.rs index 72bc5c8..34b928a 100644 --- a/crates/permesh-cli/src/mcp/mod.rs +++ b/crates/permesh-cli/src/mcp/mod.rs @@ -3,17 +3,38 @@ mod paths; mod server; mod tools; -use crate::{args::Cli, blocking::BlockingPool, error::AppError, workspace}; +use crate::{ + args::Cli, blocking::BlockingPool, cancellation::Cancellation, error::AppError, workspace, +}; use rmcp::{ServiceExt, service::ServerInitializeError}; +use std::sync::Arc; + +/// Resolves on Ctrl+C, or on SIGTERM where it exists (how MCP clients stop servers). +async fn stopped() { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + if let Ok(mut terminate) = signal(SignalKind::terminate()) { + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = terminate.recv() => {}, + } + return; + } + } + let _ = tokio::signal::ctrl_c().await; +} pub(crate) async fn serve(cli: &Cli, blocking: &BlockingPool) -> Result<(), AppError> { let workspace = std::fs::canonicalize(workspace::path(cli)?) .map_err(|_| AppError::input("Cannot locate workspace configuration"))?; permesh_config::Config::load(&workspace)?; - let handler = server::Server::new(workspace, blocking.clone()); + let lock = Arc::new(tokio::sync::Mutex::new(())); + let shutdown = Cancellation::new(); + let handler = server::Server::new(workspace, blocking.clone(), lock.clone(), shutdown.clone()); let session = tokio::select! { session = handler.serve(rmcp::transport::stdio()) => session, - _ = tokio::signal::ctrl_c() => return Err(AppError::new(130, "Cancelled")), + () = stopped() => return Err(AppError::new(130, "Cancelled")), }; let running = match session { Ok(running) => running, @@ -24,10 +45,14 @@ pub(crate) async fn serve(cli: &Cli, blocking: &BlockingPool) -> Result<(), AppE )); } }; - tokio::select! { + let ended = tokio::select! { result = running.waiting() => result .map(|_| ()) .map_err(|_| AppError::new(5, "The MCP session ended unexpectedly")), - _ = tokio::signal::ctrl_c() => Err(AppError::new(130, "Cancelled")), - } + () = stopped() => Err(AppError::new(130, "Cancelled")), + }; + // A running call drains like Ctrl+C in the CLI: providers are stopped and reaped first. + shutdown.cancel(); + let _drained = lock.lock().await; + ended } diff --git a/crates/permesh-cli/src/mcp/paths.rs b/crates/permesh-cli/src/mcp/paths.rs index 55c6c33..ff77b38 100644 --- a/crates/permesh-cli/src/mcp/paths.rs +++ b/crates/permesh-cli/src/mcp/paths.rs @@ -7,6 +7,30 @@ const RELATIVE: &str = "Paths must be relative to the workspace"; const OUTSIDE: &str = "Path is outside the workspace"; const MISSING: &str = "File not found in the workspace"; const EXISTS: &str = "Output file already exists"; +const PLACEMENT: &str = + "Plan files must be .json files outside people/, profiles/ and hidden folders"; + +/// Plan JSON is valid YAML: inside people/ or profiles/ it would break every profile load. +fn plan_placement(path: &Path) -> Result<(), AppError> { + let mut names = path.components().filter_map(|component| match component { + Component::Normal(name) => Some(name.to_string_lossy()), + _ => None, + }); + let first = names.next(); + let reserved = matches!(first.as_deref(), Some("people" | "profiles")); + let hidden = first + .into_iter() + .chain(names) + .any(|name| name.starts_with('.')); + let json = path + .extension() + .is_some_and(|extension| extension == "json"); + if reserved || hidden || !json { + Err(AppError::input(PLACEMENT)) + } else { + Ok(()) + } +} /// Refuses absolute paths and `..` that climbs above the root before touching the file system. fn lexical(relative: &str) -> Result<&Path, AppError> { @@ -45,7 +69,8 @@ pub(crate) fn existing(root: &Path, relative: &str) -> Result } } -/// A file to create inside the root: the parent must exist inside it and the target must not exist. +/// A plan file to create inside the root: a `.json` file outside workspace inputs and hidden +/// folders, whose parent exists inside the root and which does not exist yet. pub(crate) fn new_file(root: &Path, relative: &str) -> Result { let path = lexical(relative)?; let root = canonical_root(root)?; @@ -54,6 +79,7 @@ pub(crate) fn new_file(root: &Path, relative: &str) -> Result if std::fs::symlink_metadata(&joined).is_ok() { return Err(AppError::input(EXISTS)); } + plan_placement(path)?; let (Some(parent), Some(name)) = (joined.parent(), joined.file_name()) else { return Err(AppError::input(EXISTS)); }; @@ -124,6 +150,31 @@ mod tests { ); } + #[test] + fn new_file_refuses_workspace_inputs_and_non_json() { + let dir = tempfile::tempdir().unwrap(); + for folder in ["people", "profiles", ".git", "plans", "plans/.hidden"] { + fs::create_dir_all(dir.path().join(folder)).unwrap(); + } + for path in [ + "people/carol.yaml", + "people/carol.json", + "profiles/x.json", + ".git/x.json", + "plans/.hidden/x.json", + "plans/x.yaml", + "plans/x", + "./people/x.json", + ] { + assert_eq!( + message(new_file(dir.path(), path)), + "Plan files must be .json files outside people/, profiles/ and hidden folders", + "{path}" + ); + } + assert!(new_file(dir.path(), "plans/x.json").is_ok()); + } + #[test] fn new_file_requires_existing_parent_inside_root_and_absent_target() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/permesh-cli/src/mcp/server.rs b/crates/permesh-cli/src/mcp/server.rs index 3031f63..3990cdc 100644 --- a/crates/permesh-cli/src/mcp/server.rs +++ b/crates/permesh-cli/src/mcp/server.rs @@ -13,7 +13,7 @@ use rmcp::{ }, service::RequestContext, }; -use std::{borrow::Cow, path::PathBuf}; +use std::{borrow::Cow, path::PathBuf, sync::Arc}; pub(crate) const INSTRUCTIONS: &str = "Permesh answers who has access to what, read-only. Every tool result has `complete`: when it is false, some providers could not be fully read and missing access is not proof of absence, so say you cannot tell. Plan tools write a new plan file for a person to review and carry out; Permesh never changes access."; const VERSIONS: &[ProtocolVersion] = &[ProtocolVersion::V_2026_07_28]; @@ -22,14 +22,23 @@ pub(crate) struct Server { workspace: PathBuf, blocking: BlockingPool, /// One command at a time: discoveries never share provider sessions or the keyring. - lock: tokio::sync::Mutex<()>, + /// The session takes it after shutdown to wait for the running call's drain. + lock: Arc>, + /// Cancelled when the session ends (stdin closed, Ctrl+C or SIGTERM). + shutdown: Cancellation, } impl Server { - pub(crate) fn new(workspace: PathBuf, blocking: BlockingPool) -> Self { + pub(crate) fn new( + workspace: PathBuf, + blocking: BlockingPool, + lock: Arc>, + shutdown: Cancellation, + ) -> Self { Self { workspace, blocking, - lock: tokio::sync::Mutex::new(()), + lock, + shutdown, } } async fn execute( @@ -39,12 +48,20 @@ impl Server { ) -> Result { let cli = Cli::try_parse_from(tool.argv(&self.workspace)?) .map_err(|_| AppError::input("Invalid tool arguments"))?; + if self.shutdown.is_cancelled() { + return Err(AppError::new(130, "Cancelled")); + } let cancellation = Cancellation::new(); let operation = app::run(&cli, &self.blocking, &cancellation); tokio::pin!(operation); tokio::select! { biased; - () = stop => { + () = async { + tokio::select! { + () = stop => {}, + () = self.shutdown.cancelled() => {}, + } + } => { // Drain like Ctrl+C in main: providers are terminated and reaped first. cancellation.cancel(); match operation.await { diff --git a/crates/permesh-cli/src/mcp/tools.rs b/crates/permesh-cli/src/mcp/tools.rs index 8b46208..3637776 100644 --- a/crates/permesh-cli/src/mcp/tools.rs +++ b/crates/permesh-cli/src/mcp/tools.rs @@ -127,7 +127,7 @@ fn plan(name: &'static str, description: &'static str, input: Value) -> Definiti fn plan_properties(mut properties: Value) -> Value { properties["person"] = json!({"type": "string", "description": "The person's identity, as in their people file."}); - properties["output"] = json!({"type": "string", "description": "New plan file to write, relative to the workspace; its folder must exist and the file must not."}); + properties["output"] = json!({"type": "string", "description": "New .json plan file to write, relative to the workspace and outside people/, profiles/ and hidden folders; its folder must exist and the file must not."}); properties["snapshot"] = json!({"type": "string", "description": SNAPSHOT}); properties["expires_in_hours"] = json!({"type": "integer", "minimum": 1, "maximum": 168, "default": 24, "description": "Hours the plan stays fresh, from the evidence start."}); properties diff --git a/crates/permesh-cli/tests/mcp.rs b/crates/permesh-cli/tests/mcp.rs index b35048b..5ceb299 100644 --- a/crates/permesh-cli/tests/mcp.rs +++ b/crates/permesh-cli/tests/mcp.rs @@ -579,10 +579,9 @@ fn plan_output_outside_workspace_is_refused() { assert!(!dir.path().parent().unwrap().join("x.json").exists()); } +/// A workspace whose only provider hangs in discovery after writing `ready.pid`. #[cfg(unix)] -#[test] -fn cancelled_call_drains_and_server_continues() { - use std::time::Instant; +fn slow_workspace() -> (tempfile::TempDir, std::path::PathBuf) { let temporary = tempfile::tempdir().unwrap(); let root = temporary.path().canonicalize().unwrap(); let digest = fixture(&root); @@ -607,6 +606,83 @@ fn cancelled_call_drains_and_server_continues() { "--accept-risk", ], ); + (temporary, root) +} +#[cfg(unix)] +fn wait_for_provider(root: &Path) -> String { + let marker = root.join("ready.pid"); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !marker.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + std::fs::read_to_string(&marker) + .expect("discovery never started") + .trim() + .to_owned() +} +#[cfg(unix)] +fn alive(pid: &str) -> bool { + Command::new("kill") + .args(["-0", pid]) + .output() + .unwrap() + .status + .success() +} + +#[cfg(unix)] +#[test] +fn sigterm_during_call_stops_providers() { + let (_temporary, root) = slow_workspace(); + let mut client = Client::start(&root); + client.start_request( + "tools/call", + json!({"name": "user_access", "arguments": {"identity": "missing"}}), + ); + let provider = wait_for_provider(&root); + let server = client.child.id().to_string(); + assert!( + Command::new("kill") + .args(["-TERM", &server]) + .status() + .unwrap() + .success() + ); + let (code, lines) = client.finish(); + let survived = alive(&provider); + if survived { + let _ = Command::new("kill").args(["-KILL", &provider]).status(); + } + assert!(!survived, "provider outlived the stopped MCP server"); + assert_eq!(code, Some(130)); + for line in lines { + assert!(serde_json::from_str::(&line).is_ok(), "{line}"); + } +} + +#[cfg(unix)] +#[test] +fn stdin_eof_during_call_stops_providers() { + let (_temporary, root) = slow_workspace(); + let mut client = Client::start(&root); + client.start_request( + "tools/call", + json!({"name": "user_access", "arguments": {"identity": "missing"}}), + ); + let provider = wait_for_provider(&root); + let (_, _) = client.finish(); + let survived = alive(&provider); + if survived { + let _ = Command::new("kill").args(["-KILL", &provider]).status(); + } + assert!(!survived, "provider outlived the closed MCP session"); +} + +#[cfg(unix)] +#[test] +fn cancelled_call_drains_and_server_continues() { + use std::time::Instant; + let (_temporary, root) = slow_workspace(); let mut client = Client::start(&root); let id = client.start_request( "tools/call", diff --git a/docs/adr/0035-read-only-mcp-server.md b/docs/adr/0035-read-only-mcp-server.md index 379929a..d25c339 100644 --- a/docs/adr/0035-read-only-mcp-server.md +++ b/docs/adr/0035-read-only-mcp-server.md @@ -18,8 +18,10 @@ CLI action. `provider`, `init`, `offboard` or `access review`. - **Tools.** `doctor`, `user_access`, `resource_access`, `admins`, `orphaned`, `profile_check`, `plan_join`, `plan_move`, `plan_leave` and `verify_plan`. -- **One write.** The plan tools write a new plan file, which a person reviews and - carries out. They never overwrite a file. HTML views are not offered. +- **One write.** The plan tools write a new `.json` plan file, which a person + reviews and carries out. They never overwrite a file, and never write into + `people/`, `profiles/` or hidden folders, where plan JSON would be read as + workspace YAML. HTML views are not offered. - **Paths stay inside the workspace.** File arguments are relative to the workspace folder. Absolute paths, `..` that leaves it and symlinks that point outside are input errors, checked before any command runs. Values are passed as `--name=value` @@ -28,7 +30,8 @@ CLI action. `--json` prints. When the capture is incomplete it adds a note that missing access is not proof of absence. Exit codes 4 and 6 are answers, not tool errors. - **One call at a time.** Calls run in sequence. A cancelled call cancels the - command and waits until provider processes are stopped, as Ctrl+C does. + command and waits until provider processes are stopped, as Ctrl+C does. Ending the + session (stdin closed, Ctrl+C or SIGTERM) does the same for a running call. - **Protocol 2026-07-28 only.** The server uses the official Rust SDK (`rmcp`) and supports only the stateless MCP revision with `server/discover`. Clients that start with the older `initialize` handshake get an error naming that version. diff --git a/docs/mcp.md b/docs/mcp.md index 73d8bec..d981e96 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -76,6 +76,9 @@ Each result's structured content is: A failure sets `isError` and gives `error.message` instead of `report`. The same JSON is also in the text content. +Ending the session (closing stdin, Ctrl+C or SIGTERM) during a call stops that +call the same way: provider processes are stopped before `permesh mcp` exits. + When `complete` is false, a good assistant says it can't tell, rather than "no access". The server's instructions tell it so. @@ -85,8 +88,10 @@ File arguments (`snapshot`, `policy`, `annotations`, `plan`, `output`) are paths relative to the folder that holds `permesh.yaml`. Absolute paths, `..` that leaves the folder and symlinks that point outside it are refused. -The plan tools are the only tools that write. They create `output` as a new file; -its folder must already exist (for example `plans/`) and the file must not. Permesh +The plan tools are the only tools that write. They create `output` as a new +`.json` file; its folder must already exist (for example `plans/`) and the file +must not. Plans can't be written into `people/`, `profiles/` or hidden folders, +where they would be read as workspace files. Permesh never overwrites a plan and never carries one out. ## Privacy From 76c7eb55fb9cdeb2a597de927ed2ca21973315ba Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Fri, 23 Oct 2026 15:56:59 +0200 Subject: [PATCH 7/7] test(mcp): drop unix-only helper that fails clippy on Windows --- crates/permesh-cli/tests/mcp.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/permesh-cli/tests/mcp.rs b/crates/permesh-cli/tests/mcp.rs index 5ceb299..5db90e7 100644 --- a/crates/permesh-cli/tests/mcp.rs +++ b/crates/permesh-cli/tests/mcp.rs @@ -720,7 +720,7 @@ fn cancelled_call_drains_and_server_continues() { !still_alive, "cancelled provider was not terminated and reaped" ); - assert_eq!(client.tool_names().len(), definitions_count()); + assert_eq!(client.tool_names().len(), TOOLS.len()); let (_, lines) = client.finish(); for line in lines { let value: Value = serde_json::from_str(&line).unwrap(); @@ -728,9 +728,6 @@ fn cancelled_call_drains_and_server_continues() { assert!(!line.contains("CANCELLATION_PRIVATE"), "{line}"); } } -fn definitions_count() -> usize { - TOOLS.len() -} /// A real native provider speaks either legacy protocol, exposes a private /// observation, then hangs after marking its PID. This synchronizes SIGINT with