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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

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

35 changes: 18 additions & 17 deletions apps/springtale-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,23 +71,10 @@ pub enum Command {
/// Initialize Springtale (create data directory, vault, config).
/// After setup, optionally links a chat platform and starts the daemon.
///
/// With a `<template>` argument, scaffolds that starter first then
/// runs the vault/DB setup — matches plan §16.4's success-criterion
/// command line: `springtale init cli-runner && springtale run`.
Init {
/// Optional template name — if given, equivalent to running
/// `springtale new <template>` then the interactive init.
template: Option<String>,
},
/// Create a new project from a starter template.
New {
/// Template name. Run `springtale new --help` for the full list, or see
/// `docs/guide/templates.md`. 14 starters ship: telegram-bot,
/// github-monitor, cron-runner, llm-assistant, blank-bot, cli-runner,
/// llm-swarm, discord-bot, matrix-bot, webhook-receiver, file-watcher,
/// research-assistant, code-review-swarm, meeting-summarizer.
template: String,
},
/// This is the one way to start: `springtale init && springtale run`.
/// Anything beyond the bare project comes from a recipe — browse them
/// in the colony UI or with `springtale recipes`.
Init,
/// Log in to springtaled: prompts for the vault passphrase, exchanges
/// it for a long-lived API token, and saves it (mode 0600).
Login,
Expand Down Expand Up @@ -198,6 +185,11 @@ pub enum Command {
#[command(subcommand)]
action: SafetyAction,
},
/// Model Context Protocol — bridge an MCP client onto the daemon.
Mcp {
#[command(subcommand)]
action: McpAction,
},
/// Colony canvas — trees, agents, formations as the UI sees them.
Canvas {
/// Follow live canvas updates instead of printing a snapshot.
Expand Down Expand Up @@ -533,6 +525,15 @@ pub enum RuleAction {
},
}

#[derive(Subcommand, Debug)]
pub enum McpAction {
/// Speak the MCP stdio transport on stdin/stdout, forwarding every
/// message to the running daemon's `/mcp` endpoint. For editors that
/// can only launch a subprocess. Set `SPRINGTALE_API_TOKEN` so the
/// bridge never has to prompt for a token.
Serve,
}

#[derive(Subcommand, Debug)]
pub enum ServerAction {
/// Start springtaled inline.
Expand Down
12 changes: 12 additions & 0 deletions apps/springtale-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,18 @@ impl Client {
Ok(resp)
}

/// Start a request against `path` with the bearer header already
/// applied, for callers that need the raw `reqwest` response rather
/// than a decoded JSON body. The MCP stdio bridge uses it: it needs
/// the response headers (`Mcp-Session-Id`) and an SSE body, and it
/// must not hold a second copy of the API token.
pub fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
// SECURITY: expose needed to set the bearer header.
self.http
.request(method, self.url(path))
.bearer_auth(self.token.expose_secret())
}

fn url(&self, p: &str) -> String {
format!("{}{p}", self.base)
}
Expand Down
154 changes: 154 additions & 0 deletions apps/springtale-cli/src/commands/mcp/bridge.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
//! The stdin → daemon → stdout loop.
//!
//! MCP's stdio transport is newline-delimited JSON-RPC: "messages are
//! delimited by newlines, and MUST NOT contain embedded newlines". This
//! loop reads one, hands it to a [`McpTransport`], and writes back at
//! most one line. Nothing here knows what a tool is.

use std::future::Future;

use anyhow::Result;
use serde_json::Value;
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt};

/// JSON-RPC "Parse error" — the message was not valid JSON.
const PARSE_ERROR: i64 = -32700;
/// JSON-RPC "Internal error" — the daemon could not be reached, or
/// answered with something that is not a message.
const INTERNAL_ERROR: i64 = -32603;

/// Where a stdio message goes. One implementation talks HTTP to
/// springtaled ([`super::DaemonTransport`]); the test uses a stub.
pub trait McpTransport {
/// Forward `message` verbatim and return the daemon's reply body,
/// or `None` when the daemon accepted it without one (its answer to
/// a notification).
fn send(&self, message: String) -> impl Future<Output = Result<Option<String>>> + Send;
}

/// Pump `reader` into `transport` and its replies into `writer` until
/// the reader hits EOF (the client closed the pipe, which is how the
/// stdio transport says "shut down").
pub async fn bridge<R, W, T>(reader: R, mut writer: W, transport: &T) -> Result<()>
where
R: AsyncBufRead + Unpin,
W: AsyncWrite + Unpin,
T: McpTransport,
{
let mut lines = reader.lines();
while let Some(line) = lines.next_line().await? {
let message = line.trim();
if message.is_empty() {
continue;
}

// The only inspection this bridge performs: a message with an
// `id` is a request and is owed exactly one reply; one without
// is a notification and gets none. That is stdio framing, not
// protocol knowledge.
let id = match serde_json::from_str::<Value>(message) {
Ok(value) => value.get("id").cloned(),
Err(err) => {
// Unparseable input has no id to echo, so `null` — the
// JSON-RPC 2.0 rule for a parse error.
let body = error_response(&Value::Null, PARSE_ERROR, &err.to_string());
write_line(&mut writer, &body).await?;
continue;
}
};

let reply = transport.send(message.to_owned()).await;
let Some(id) = id else {
// Notification: never write a response. A failure has
// nowhere to go but stderr, which the client treats as logs.
if let Err(err) = reply {
eprintln!("springtale mcp: notification not delivered: {err:#}");
}
continue;
};

let body = match reply {
Ok(Some(body)) => single_line(&body),
Ok(None) => error_response(
&id,
INTERNAL_ERROR,
"daemon accepted the request without a response",
),
Err(err) => error_response(&id, INTERNAL_ERROR, &format!("{err:#}")),
};
write_line(&mut writer, &body).await?;
}
Ok(())
}

/// Collapse a response body onto one line.
///
/// Raw newlines in JSON are whitespace between tokens — a newline
/// *inside* a string must be escaped as `\n` — so dropping them cannot
/// change the message, and the stdio framing requires it.
fn single_line(body: &str) -> String {
body.replace(['\n', '\r'], "")
}

/// Build a JSON-RPC error response carrying the original request id.
fn error_response(id: &Value, code: i64, message: &str) -> String {
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": code, "message": message },
});
// A `serde_json::Value` built from owned data cannot fail to
// serialize, but the CLI still never unwraps.
serde_json::to_string(&body).unwrap_or_else(|_| {
format!(r#"{{"jsonrpc":"2.0","id":null,"error":{{"code":{INTERNAL_ERROR},"message":"serialization failed"}}}}"#)
})
}

/// Write one message and flush — the client is waiting on this byte.
async fn write_line<W: AsyncWrite + Unpin>(writer: &mut W, body: &str) -> Result<()> {
writer.write_all(body.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

/// Stub daemon: records what it was handed, answers with `reply`.
struct StubTransport {
reply: &'static str,
seen: std::sync::Mutex<Vec<String>>,
}

impl McpTransport for StubTransport {
async fn send(&self, message: String) -> Result<Option<String>> {
if let Ok(mut seen) = self.seen.lock() {
seen.push(message);
}
Ok(Some(self.reply.to_owned()))
}
}

#[tokio::test]
async fn test_bridge_request_forwards_body_and_writes_daemon_response() {
let transport = StubTransport {
reply: r#"{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}"#,
seen: std::sync::Mutex::new(Vec::new()),
};
let request = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\n";
let mut out: Vec<u8> = Vec::new();

bridge(request.as_bytes(), &mut out, &transport)
.await
.expect("bridge runs to EOF");

let forwarded = transport.seen.lock().expect("stub lock");
assert_eq!(forwarded.as_slice(), [request.trim().to_owned()]);
assert_eq!(
String::from_utf8(out).expect("utf-8 stdout"),
format!("{}\n", transport.reply)
);
}
}
18 changes: 18 additions & 0 deletions apps/springtale-cli/src/commands/mcp/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//! `springtale mcp serve` — an MCP stdio bridge onto the daemon.
//!
//! The MCP server itself lives in `springtaled` behind the bearer check
//! (`apps/springtaled/src/api/mcp.rs`). Editors that can only launch a
//! subprocess speak the stdio transport, so this subcommand is the
//! adapter: newline-delimited JSON-RPC on its own stdin/stdout, HTTP to
//! the daemon in between.
//!
//! It holds no protocol logic — no tool list, no method dispatch, no
//! schema. Every message is forwarded verbatim; the only thing read out
//! of a message is whether it carries an `id`, which is framing (does a
//! reply get written?), not protocol.

pub mod bridge;
pub mod run;
pub mod transport;

pub use run::serve;
24 changes: 24 additions & 0 deletions apps/springtale-cli/src/commands/mcp/run.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//! `springtale mcp serve` entry point.

use anyhow::Result;
use tokio::io::BufReader;

use super::bridge::bridge;
use super::transport::DaemonTransport;
use crate::client::Client;

/// Speak the MCP stdio transport on this process's stdin/stdout,
/// forwarding every message to the running daemon.
///
/// Runs until stdin reaches EOF. Nothing is written to stdout except
/// JSON-RPC messages — stdout *is* the transport — so diagnostics go to
/// stderr, which the MCP stdio spec reserves for logging.
pub async fn serve() -> Result<()> {
let transport = DaemonTransport::new(Client::from_config()?);
bridge(
BufReader::new(tokio::io::stdin()),
tokio::io::stdout(),
&transport,
)
.await
}
Loading
Loading