#![deny(missing_docs)]
use anyhow::{Context as _, Result, bail};
use clap::Parser;
use notedthat_mcp::{NotedThatMcp, client::NotedThatClient};
use rmcp::{ServiceExt, transport::stdio};
use tracing_subscriber::EnvFilter;
#[allow(clippy::doc_markdown)]
#[derive(Parser, Debug, Default, Clone)]
#[command(
name = "notedthat-mcp-stdio",
version,
about = "MCP-over-stdio transport for NotedThat",
long_about = "MCP-over-stdio transport for NotedThat.\n\n\
Either setting can be given as the flag or as the environment variable \
beside it; the flag wins when both are set.\n\n\
--token is visible to any user on the host via `ps` and is recorded in \
shell history, so prefer NOTEDTHAT_TOKEN on a shared machine.\n\n\
Stdout carries JSON-RPC and nothing else; all logging goes to stderr."
)]
pub struct StdioCli {
#[arg(long, env = "NOTEDTHAT_URL", value_name = "URL")]
pub url: Option<String>,
#[arg(
long,
env = "NOTEDTHAT_TOKEN",
value_name = "TOKEN",
hide_env_values = true
)]
pub token: Option<String>,
}
pub async fn run() -> Result<()> {
let cli = StdioCli::parse();
init_logging();
let url = require(cli.url, "NOTEDTHAT_URL")?;
let token = require(cli.token, "NOTEDTHAT_TOKEN")?;
let client =
NotedThatClient::new(&url, &token).context("invalid NOTEDTHAT_URL or NOTEDTHAT_TOKEN")?;
tracing::info!(
target: "notedthat_mcp_stdio",
"notedthat-mcp-stdio starting; url = {}",
client.base_url_display()
);
let service = NotedThatMcp::for_stdio(client)
.serve(stdio())
.await
.context("stdio transport failed")?;
service.waiting().await.context("service loop failed")?;
Ok(())
}
fn init_logging() {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.with_writer(std::io::stderr) .with_ansi(false)
.init();
}
fn require(supplied: Option<String>, name: &str) -> Result<String> {
let named = notedthat_core::setting(name);
let Some(value) = supplied else {
bail!("{named} is required but not set");
};
let trimmed = value.trim();
if trimmed.is_empty() {
bail!("{named} is required but is empty");
}
Ok(trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::{StdioCli, require};
use clap::Parser as _;
fn parse(vars: &[(&str, Option<&str>)], args: &[&str]) -> StdioCli {
let command_line: Vec<&str> = std::iter::once("notedthat-mcp-stdio")
.chain(args.iter().copied())
.collect();
temp_env::with_vars(vars, || {
StdioCli::try_parse_from(&command_line).expect("arguments must parse")
})
}
#[test]
fn a_flag_wins_over_the_variable_it_mirrors() {
let cli = parse(
&[("NOTEDTHAT_URL", Some("http://from-env:8080"))],
&["--url", "http://from-flag:8080"],
);
assert_eq!(cli.url.as_deref(), Some("http://from-flag:8080"));
}
#[test]
fn the_variable_is_used_when_no_flag_is_given() {
let cli = parse(&[("NOTEDTHAT_TOKEN", Some("from-env"))], &[]);
assert_eq!(cli.token.as_deref(), Some("from-env"));
}
#[test]
fn an_unsupplied_setting_names_both_forms() {
let error = require(None, "NOTEDTHAT_URL").unwrap_err().to_string();
assert!(error.contains("NOTEDTHAT_URL"), "{error}");
assert!(error.contains("--url"), "{error}");
}
#[test]
fn a_setting_that_is_only_whitespace_is_refused() {
let error = require(Some(" ".to_string()), "NOTEDTHAT_TOKEN")
.unwrap_err()
.to_string();
assert!(error.contains("NOTEDTHAT_TOKEN"), "{error}");
assert!(error.contains("is empty"), "{error}");
}
#[test]
fn a_supplied_setting_is_trimmed() {
let value = require(
Some(" http://localhost:8080 ".to_string()),
"NOTEDTHAT_URL",
)
.expect("a value survives trimming");
assert_eq!(value, "http://localhost:8080");
}
}