use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use futures_util::StreamExt;
use serde_json::{Value as JsonValue, json};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{Mutex, mpsc};
use crate::client::ensure_valid_token;
use crate::commands::Environment;
use crate::config::Configs;
use crate::consts;
use crate::telemetry;
const AUTH_ERROR_CODE: i64 = -32001;
const MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024;
const LOGIN_HINT: &str = "Not logged in to Railway. Run `railway login` in a terminal, then retry \
— the proxy picks up the new login automatically, no restart needed.";
struct ProxyState {
http: reqwest::Client,
url: String,
configs: Mutex<Configs>,
session: Mutex<SessionMeta>,
link: LinkContext,
}
#[derive(Default)]
struct SessionMeta {
id: Option<String>,
init_request: Option<JsonValue>,
client_name: Option<String>,
tool_params: HashMap<String, HashSet<String>>,
injected_ids: HashSet<String>,
}
#[derive(Clone, Default)]
struct LinkContext {
project_id: Option<String>,
}
const SCOPING_PARAMS: [&str; 4] = ["projectId", "environmentId", "serviceId", "deploymentId"];
const MCP_TRANSPORT_HEADER: &str = "x-railway-mcp-transport";
const MCP_TRANSPORT_VALUE: &str = "cli-proxy";
const MCP_CLIENT_HEADER: &str = "x-railway-mcp-client";
const MCP_INJECTED_HEADER: &str = "x-railway-mcp-injected";
type Out = mpsc::UnboundedSender<String>;
pub async fn serve_proxy() -> Result<()> {
let configs = Configs::new()?;
let url = resolve_mcp_url(&configs)?;
let http = reqwest::Client::builder()
.danger_accept_invalid_certs(matches!(Configs::get_environment_id(), Environment::Dev))
.user_agent(consts::get_user_agent())
.connect_timeout(Duration::from_secs(15))
.redirect(reqwest::redirect::Policy::none())
.build()
.context("Failed to build HTTP client")?;
let link = read_link_context(&configs);
let state = Arc::new(ProxyState {
http,
url,
configs: Mutex::new(configs),
session: Mutex::new(SessionMeta::default()),
link,
});
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
let writer = tokio::spawn(async move {
let mut stdout = tokio::io::stdout();
while let Some(line) = rx.recv().await {
let _ = stdout.write_all(line.as_bytes()).await;
let _ = stdout.write_all(b"\n").await;
let _ = stdout.flush().await;
}
});
let stdin = BufReader::new(tokio::io::stdin());
let mut lines = stdin.lines();
let mut handshake_done = false;
while let Some(line) = lines.next_line().await? {
if line.trim().is_empty() {
continue;
}
let msg: JsonValue = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
eprintln!("railway mcp proxy: ignoring unparseable message: {e}");
continue;
}
};
let mut msg = msg;
if method_of(&msg) == Some("initialize") {
let mut session = state.session.lock().await;
session.init_request = Some(msg.clone());
session.client_name = extract_mcp_client_header(&msg);
} else if method_of(&msg) == Some("tools/call") {
let mut session = state.session.lock().await;
if inject_link_context(&state.link, &session.tool_params, &mut msg) {
for id in ids_of(&msg) {
session.injected_ids.insert(id.to_string());
}
}
}
if handshake_done {
let state = state.clone();
let tx = tx.clone();
tokio::spawn(async move {
handle_message(&state, msg, &tx).await;
});
} else {
let completes_handshake = method_of(&msg) == Some("notifications/initialized");
handle_message(&state, msg, &tx).await;
if completes_handshake {
handshake_done = true;
}
}
}
end_session(&state).await;
drop(tx);
let _ = tokio::time::timeout(Duration::from_secs(5), writer).await;
Ok(())
}
fn method_of(msg: &JsonValue) -> Option<&str> {
msg.get("method").and_then(JsonValue::as_str)
}
fn read_link_context(configs: &Configs) -> LinkContext {
let project_id = Configs::get_railway_project_id()
.or_else(|| {
configs
.get_local_linked_project()
.ok()
.map(|linked| linked.project)
})
.filter(|s| !s.is_empty());
LinkContext { project_id }
}
fn record_tool_params(session: &mut SessionMeta, msg: &JsonValue) {
let Some(tools) = msg.pointer("/result/tools").and_then(JsonValue::as_array) else {
return;
};
for tool in tools {
let Some(name) = tool.get("name").and_then(JsonValue::as_str) else {
continue;
};
let declared = tool
.pointer("/inputSchema/properties")
.and_then(JsonValue::as_object)
.map(|props| props.keys().cloned().collect::<HashSet<String>>())
.unwrap_or_default();
session.tool_params.insert(name.to_string(), declared);
}
}
fn inject_link_context(
link: &LinkContext,
tool_params: &HashMap<String, HashSet<String>>,
msg: &mut JsonValue,
) -> bool {
let Some(project_id) = link.project_id.as_deref() else {
return false;
};
if method_of(msg) != Some("tools/call") {
return false;
}
let Some(tool_name) = msg
.pointer("/params/name")
.and_then(JsonValue::as_str)
.map(str::to_owned)
else {
return false;
};
let Some(declared) = tool_params.get(&tool_name) else {
return false;
};
if !declared.contains("projectId") {
return false;
}
let supplied = |param: &str| {
msg.pointer(&format!("/params/arguments/{param}"))
.is_some_and(|v| !v.is_null())
};
if SCOPING_PARAMS.iter().any(|param| supplied(param)) {
return false;
}
let Some(params) = msg.get_mut("params").and_then(JsonValue::as_object_mut) else {
return false;
};
let arguments = params
.entry("arguments")
.or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
let Some(arguments) = arguments.as_object_mut() else {
return false;
};
arguments.insert(
"projectId".to_string(),
JsonValue::String(project_id.to_string()),
);
true
}
fn ids_of(msg: &JsonValue) -> Vec<JsonValue> {
match msg {
JsonValue::Array(items) => items
.iter()
.filter_map(|m| m.get("id").cloned().filter(|id| !id.is_null()))
.collect(),
_ => msg
.get("id")
.cloned()
.filter(|id| !id.is_null())
.into_iter()
.collect(),
}
}
fn resolve_mcp_url(configs: &Configs) -> Result<String> {
let is_dev = matches!(Configs::get_environment_id(), Environment::Dev);
if let Ok(raw) = std::env::var("RAILWAY_MCP_URL") {
if let Some(url) = validate_mcp_override(&raw, is_dev)? {
return Ok(url);
}
}
Ok(format!("https://mcp.{}", configs.get_host()))
}
fn validate_mcp_override(raw: &str, is_dev: bool) -> Result<Option<String>> {
let url = raw.trim();
if url.is_empty() {
return Ok(None);
}
if !url.starts_with("https://") && !is_dev {
anyhow::bail!(
"RAILWAY_MCP_URL must be an https:// URL (got {url:?}); refusing to send credentials over a non-TLS connection."
);
}
Ok(Some(url.trim_end_matches('/').to_string()))
}
async fn handle_message(state: &ProxyState, msg: JsonValue, out: &Out) {
let ids = ids_of(&msg);
let Some(token) = fresh_token(state).await else {
respond_unauthenticated(&msg, &ids, out);
return;
};
if let Err(e) = forward(state, &msg, &token, out).await {
if ids.is_empty() {
eprintln!("railway mcp proxy: {e:#}");
}
for id in &ids {
send_error(out, id, -32603, &format!("Railway MCP proxy error: {e:#}"));
}
}
}
async fn fresh_token(state: &ProxyState) -> Option<String> {
let mut configs = state.configs.lock().await;
if configs.get_railway_auth_token().is_none() {
if let Err(e) = configs.reload() {
eprintln!("railway mcp proxy: config reload failed: {e:#}");
}
}
if let Err(e) = ensure_valid_token(&mut configs).await {
eprintln!("railway mcp proxy: token refresh failed: {e:#}");
}
configs.get_railway_auth_token()
}
fn respond_unauthenticated(msg: &JsonValue, ids: &[JsonValue], out: &Out) {
let [id] = ids else {
for id in ids {
send_error(out, id, AUTH_ERROR_CODE, LOGIN_HINT);
}
return;
};
if method_of(msg) == Some("initialize") {
let protocol = msg
.pointer("/params/protocolVersion")
.and_then(JsonValue::as_str)
.unwrap_or("2025-03-26");
let result = json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"protocolVersion": protocol,
"capabilities": { "tools": { "listChanged": true } },
"serverInfo": {
"name": "railway",
"version": env!("CARGO_PKG_VERSION"),
},
"instructions": LOGIN_HINT,
}
});
let _ = out.send(result.to_string());
} else {
send_error(out, id, AUTH_ERROR_CODE, LOGIN_HINT);
}
}
async fn forward(state: &ProxyState, msg: &JsonValue, token: &str, out: &Out) -> Result<()> {
let is_initialize = method_of(msg) == Some("initialize");
let session_id = state.session.lock().await.id.clone();
let resp = post_message(
state,
msg,
token,
if is_initialize {
None
} else {
session_id.as_deref()
},
)
.await?;
let status = resp.status();
let can_reinit = { state.session.lock().await.init_request.is_some() };
if !is_initialize && (status == 404 || status == 400) && can_reinit {
let _ = resp.bytes().await;
reinitialize(state, token).await?;
let session_id = state.session.lock().await.id.clone();
let resp = post_message(state, msg, token, session_id.as_deref()).await?;
return consume_response(state, resp, msg, is_initialize, out).await;
}
consume_response(state, resp, msg, is_initialize, out).await
}
async fn post_message(
state: &ProxyState,
msg: &JsonValue,
token: &str,
session_id: Option<&str>,
) -> Result<reqwest::Response> {
let (client_name, injected) = {
let mut session = state.session.lock().await;
let injected = ids_of(msg)
.iter()
.any(|id| session.injected_ids.remove(&id.to_string()));
(session.client_name.clone(), injected)
};
let mut req = state
.http
.post(&state.url)
.header("authorization", format!("Bearer {token}"))
.header("accept", "application/json, text/event-stream")
.header("x-source", consts::get_user_agent())
.header(MCP_TRANSPORT_HEADER, MCP_TRANSPORT_VALUE);
if let Some(client) = client_name.as_deref() {
req = req.header(MCP_CLIENT_HEADER, client);
}
if injected {
req = req.header(MCP_INJECTED_HEADER, "projectId");
}
if let Some(sid) = session_id {
req = req.header("mcp-session-id", sid);
}
req.json(msg)
.send()
.await
.context("failed to reach the remote MCP server")
}
fn extract_mcp_client_header(msg: &JsonValue) -> Option<String> {
let name = msg
.pointer("/params/clientInfo/name")
.and_then(JsonValue::as_str)?;
telemetry::mcp_client_header_value(name)
}
async fn reinitialize(state: &ProxyState, token: &str) -> Result<()> {
let mut session = state.session.lock().await;
let init = session
.init_request
.clone()
.context("no initialize request captured yet")?;
let resp = post_message(state, &init, token, None).await?;
anyhow::ensure!(
resp.status().is_success(),
"re-initialize failed with HTTP {}",
resp.status()
);
session.id = resp
.headers()
.get("mcp-session-id")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let _ = resp.bytes().await;
let session_id = session.id.clone();
drop(session);
let initialized = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
let resp = post_message(state, &initialized, token, session_id.as_deref()).await?;
let _ = resp.bytes().await;
Ok(())
}
async fn consume_response(
state: &ProxyState,
resp: reqwest::Response,
msg: &JsonValue,
is_initialize: bool,
out: &Out,
) -> Result<()> {
let status = resp.status();
if is_initialize {
if let Some(sid) = resp
.headers()
.get("mcp-session-id")
.and_then(|v| v.to_str().ok())
{
state.session.lock().await.id = Some(sid.to_string());
}
}
if status == 401 || status == 403 {
let _ = resp.bytes().await;
for id in &ids_of(msg) {
send_error(
out,
id,
AUTH_ERROR_CODE,
"Railway rejected the CLI's credentials. Run `railway login` and try again.",
);
}
return Ok(());
}
if status == 202 || status == 204 {
return Ok(());
}
if !status.is_success() {
let body = read_body_capped(resp).await.unwrap_or_default();
anyhow::bail!(
"remote MCP server returned HTTP {status}: {}",
truncate(&body, 300)
);
}
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let learn_tools = method_of(msg) == Some("tools/list");
if content_type.starts_with("text/event-stream") {
stream_sse(state, resp, out, learn_tools).await
} else {
let body = read_body_capped(resp).await?;
if let Some(parsed) = emit_json_line(body.trim(), out)
&& learn_tools
{
record_tool_params(&mut *state.session.lock().await, &parsed);
}
Ok(())
}
}
async fn read_body_capped(resp: reqwest::Response) -> Result<String> {
let mut stream = resp.bytes_stream();
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("error reading response from remote MCP server")?;
if buf.len() + chunk.len() > MAX_RESPONSE_BYTES {
anyhow::bail!(
"remote MCP server response exceeded {MAX_RESPONSE_BYTES} bytes; aborting."
);
}
buf.extend_from_slice(&chunk);
}
Ok(String::from_utf8_lossy(&buf).into_owned())
}
async fn stream_sse(
state: &ProxyState,
resp: reqwest::Response,
out: &Out,
learn_tools: bool,
) -> Result<()> {
let mut stream = resp.bytes_stream();
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("error reading SSE stream from remote MCP server")?;
buf.extend_from_slice(&chunk);
while let Some((event_len, boundary_end)) = find_event_boundary(&buf) {
let event: Vec<u8> = buf.drain(..boundary_end).collect();
if let Some(parsed) = emit_sse_event(&event[..event_len], out)
&& learn_tools
{
record_tool_params(&mut *state.session.lock().await, &parsed);
}
}
if buf.len() > MAX_RESPONSE_BYTES {
anyhow::bail!(
"remote MCP server SSE event exceeded {MAX_RESPONSE_BYTES} bytes; aborting."
);
}
}
if !buf.is_empty()
&& let Some(parsed) = emit_sse_event(&buf, out)
&& learn_tools
{
record_tool_params(&mut *state.session.lock().await, &parsed);
}
Ok(())
}
fn find_event_boundary(buf: &[u8]) -> Option<(usize, usize)> {
for i in 0..buf.len() {
if buf[i] != b'\n' {
continue;
}
if buf.get(i + 1) == Some(&b'\n') {
return Some((i, i + 2));
}
if buf.get(i + 1) == Some(&b'\r') && buf.get(i + 2) == Some(&b'\n') {
return Some((i, i + 3));
}
}
None
}
fn emit_sse_event(raw: &[u8], out: &Out) -> Option<JsonValue> {
let text = String::from_utf8_lossy(raw);
let data_lines: Vec<&str> = text
.lines()
.filter_map(|line| line.strip_prefix("data:"))
.map(|rest| rest.strip_prefix(' ').unwrap_or(rest))
.collect();
if data_lines.is_empty() {
return None;
}
emit_json_line(&data_lines.join("\n"), out)
}
fn emit_json_line(payload: &str, out: &Out) -> Option<JsonValue> {
if payload.is_empty() {
return None;
}
let parsed = serde_json::from_str::<JsonValue>(payload).ok();
let line = parsed
.as_ref()
.map(|v| v.to_string())
.unwrap_or_else(|| payload.replace(['\n', '\r'], " "));
let _ = out.send(line);
parsed
}
fn send_error(out: &Out, id: &JsonValue, code: i64, message: &str) {
let err = json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": code, "message": message },
});
let _ = out.send(err.to_string());
}
async fn end_session(state: &ProxyState) {
let (session_id, client_name) = {
let session = state.session.lock().await;
(session.id.clone(), session.client_name.clone())
};
let Some(session_id) = session_id else { return };
let token = { state.configs.lock().await.get_railway_auth_token() };
let Some(token) = token else { return };
let mut req = state
.http
.delete(&state.url)
.header("authorization", format!("Bearer {token}"))
.header("mcp-session-id", session_id)
.header(MCP_TRANSPORT_HEADER, MCP_TRANSPORT_VALUE)
.timeout(Duration::from_secs(5));
if let Some(client) = client_name.as_deref() {
req = req.header(MCP_CLIENT_HEADER, client);
}
let _ = req.send().await;
}
fn truncate(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
let mut out: String = s.chars().take(max_chars).collect();
out.push('…');
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn collect(rx: &mut mpsc::UnboundedReceiver<String>) -> Vec<String> {
let mut out = Vec::new();
while let Ok(line) = rx.try_recv() {
out.push(line);
}
out
}
#[test]
fn mcp_override_rejects_plaintext_outside_dev() {
assert!(validate_mcp_override("http://evil.example/mcp", false).is_err());
assert_eq!(validate_mcp_override(" ", false).unwrap(), None);
assert_eq!(
validate_mcp_override("https://mcp.railway.com/", false).unwrap(),
Some("https://mcp.railway.com".to_string()),
);
assert_eq!(
validate_mcp_override("http://localhost:8080", true).unwrap(),
Some("http://localhost:8080".to_string()),
);
}
#[test]
fn sse_event_boundary_handles_lf_and_crlf() {
assert_eq!(find_event_boundary(b"data: {}\n\nrest"), Some((8, 10)));
assert_eq!(find_event_boundary(b"data: {}\r\n\r\nrest"), Some((9, 12)));
assert_eq!(find_event_boundary(b"data: {}"), None);
}
#[test]
fn sse_event_extracts_data_payload() {
let (tx, mut rx) = mpsc::unbounded_channel();
emit_sse_event(b"event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1}", &tx);
assert_eq!(collect(&mut rx), vec![r#"{"id":1,"jsonrpc":"2.0"}"#]);
}
#[test]
fn sse_event_joins_multiline_data() {
let (tx, mut rx) = mpsc::unbounded_channel();
emit_sse_event(b"data: {\"a\":\ndata: 1}", &tx);
assert_eq!(collect(&mut rx), vec![r#"{"a":1}"#]);
}
#[test]
fn sse_event_without_data_is_dropped() {
let (tx, mut rx) = mpsc::unbounded_channel();
emit_sse_event(b"event: ping\nid: 4", &tx);
assert!(collect(&mut rx).is_empty());
}
#[test]
fn json_lines_are_compacted_to_one_line() {
let (tx, mut rx) = mpsc::unbounded_channel();
emit_json_line("{\n \"jsonrpc\": \"2.0\",\n \"id\": 7\n}", &tx);
let lines = collect(&mut rx);
assert_eq!(lines.len(), 1);
assert!(!lines[0].contains('\n'));
}
#[test]
fn unauthenticated_initialize_fabricates_result() {
let (tx, mut rx) = mpsc::unbounded_channel();
let msg = json!({
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": { "protocolVersion": "2025-06-18" },
});
respond_unauthenticated(&msg, &ids_of(&msg), &tx);
let lines = collect(&mut rx);
assert_eq!(lines.len(), 1);
let parsed: JsonValue = serde_json::from_str(&lines[0]).unwrap();
assert_eq!(
parsed.pointer("/result/protocolVersion").unwrap(),
"2025-06-18"
);
assert!(
parsed
.pointer("/result/instructions")
.unwrap()
.as_str()
.unwrap()
.contains("railway login")
);
}
#[test]
fn unauthenticated_request_gets_actionable_error() {
let (tx, mut rx) = mpsc::unbounded_channel();
let msg = json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/list" });
respond_unauthenticated(&msg, &ids_of(&msg), &tx);
let lines = collect(&mut rx);
assert_eq!(lines.len(), 1);
let parsed: JsonValue = serde_json::from_str(&lines[0]).unwrap();
assert_eq!(parsed.pointer("/error/code").unwrap(), AUTH_ERROR_CODE);
assert!(
parsed
.pointer("/error/message")
.unwrap()
.as_str()
.unwrap()
.contains("railway login")
);
}
#[test]
fn unauthenticated_notification_is_dropped() {
let (tx, mut rx) = mpsc::unbounded_channel();
let msg = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
respond_unauthenticated(&msg, &ids_of(&msg), &tx);
assert!(collect(&mut rx).is_empty());
}
#[test]
fn extracts_known_mcp_client_from_initialize() {
let msg = json!({
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": { "name": "claude-code", "version": "1.0.0" }
}
});
assert_eq!(
extract_mcp_client_header(&msg).as_deref(),
Some("claude_code")
);
}
#[test]
fn extracts_unknown_mcp_client_as_slug() {
let msg = json!({
"jsonrpc": "2.0",
"method": "initialize",
"params": { "clientInfo": { "name": "Totally New IDE" } }
});
assert_eq!(
extract_mcp_client_header(&msg).as_deref(),
Some("mcp_unknown:totally-new-ide")
);
}
#[test]
fn missing_client_info_yields_no_header() {
let msg = json!({
"jsonrpc": "2.0",
"method": "initialize",
"params": { "protocolVersion": "2025-03-26" }
});
assert_eq!(extract_mcp_client_header(&msg), None);
}
#[test]
fn unauthenticated_batch_answers_every_id() {
let (tx, mut rx) = mpsc::unbounded_channel();
let msg = json!([
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" },
{ "jsonrpc": "2.0", "method": "notifications/progress" },
{ "jsonrpc": "2.0", "id": "two", "method": "tools/call" },
]);
let ids = ids_of(&msg);
assert_eq!(ids, vec![json!(1), json!("two")]);
respond_unauthenticated(&msg, &ids, &tx);
let lines = collect(&mut rx);
assert_eq!(lines.len(), 2);
for line in &lines {
let parsed: JsonValue = serde_json::from_str(line).unwrap();
assert_eq!(parsed.pointer("/error/code").unwrap(), AUTH_ERROR_CODE);
}
}
}
#[cfg(test)]
mod link_context_tests {
use super::*;
fn link() -> LinkContext {
LinkContext {
project_id: Some("proj-1".into()),
}
}
fn params_for(tool: &str, declared: &[&str]) -> HashMap<String, HashSet<String>> {
let mut m = HashMap::new();
m.insert(
tool.to_string(),
declared.iter().map(|s| s.to_string()).collect(),
);
m
}
fn call(tool: &str, arguments: JsonValue) -> JsonValue {
json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "name": tool, "arguments": arguments },
})
}
const CTX: &[&str] = &["projectId", "environmentId", "serviceId"];
#[test]
fn fills_the_project_when_the_caller_gave_no_scope_at_all() {
let mut msg = call("list-services", json!({}));
assert!(inject_link_context(
&link(),
¶ms_for("list-services", CTX),
&mut msg
));
assert_eq!(
msg.pointer("/params/arguments/projectId").unwrap(),
"proj-1"
);
}
#[test]
fn never_fills_a_subordinate_id() {
let mut msg = call("list-services", json!({}));
inject_link_context(&link(), ¶ms_for("list-services", CTX), &mut msg);
assert!(msg.pointer("/params/arguments/environmentId").is_none());
assert!(msg.pointer("/params/arguments/serviceId").is_none());
}
#[test]
fn leaves_an_explicit_project_alone() {
let mut msg = call("list-services", json!({ "projectId": "other" }));
assert!(!inject_link_context(
&link(),
¶ms_for("list-services", CTX),
&mut msg
));
assert_eq!(msg.pointer("/params/arguments/projectId").unwrap(), "other");
}
#[test]
fn stays_out_of_a_call_that_named_any_resource() {
for scoped in [
json!({ "serviceId": "svc-from-another-project" }),
json!({ "environmentId": "env-from-another-project" }),
json!({ "deploymentId": "dep-from-another-project" }),
] {
let mut msg = call("get-logs", scoped.clone());
let declared = params_for(
"get-logs",
&["projectId", "environmentId", "serviceId", "deploymentId"],
);
assert!(
!inject_link_context(&link(), &declared, &mut msg),
"should not inject over {scoped}"
);
assert!(msg.pointer("/params/arguments/projectId").is_none());
}
}
#[test]
fn treats_an_explicit_null_scope_as_absent() {
let mut msg = call("list-services", json!({ "projectId": null }));
assert!(inject_link_context(
&link(),
¶ms_for("list-services", CTX),
&mut msg
));
assert_eq!(
msg.pointer("/params/arguments/projectId").unwrap(),
"proj-1"
);
}
#[test]
fn leaves_tools_that_do_not_take_a_project_alone() {
let mut msg = call("search-docs", json!({ "query": "volumes" }));
assert!(!inject_link_context(
&link(),
¶ms_for("search-docs", &["query"]),
&mut msg
));
assert_eq!(
msg.pointer("/params/arguments").unwrap(),
&json!({ "query": "volumes" })
);
}
#[test]
fn does_nothing_before_tools_list_has_been_seen() {
let mut msg = call("list-services", json!({}));
assert!(!inject_link_context(&link(), &HashMap::new(), &mut msg));
assert_eq!(msg.pointer("/params/arguments").unwrap(), &json!({}));
}
#[test]
fn does_nothing_without_a_linked_project() {
let mut msg = call("list-services", json!({}));
assert!(!inject_link_context(
&LinkContext::default(),
¶ms_for("list-services", CTX),
&mut msg
));
assert_eq!(msg.pointer("/params/arguments").unwrap(), &json!({}));
}
#[test]
fn creates_the_arguments_object_when_the_caller_sent_none() {
let mut msg = json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "list-services" },
});
assert!(inject_link_context(
&link(),
¶ms_for("list-services", CTX),
&mut msg
));
assert_eq!(
msg.pointer("/params/arguments/projectId").unwrap(),
"proj-1"
);
}
#[test]
fn ignores_messages_that_are_not_tool_calls() {
let mut msg = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list" });
assert!(!inject_link_context(
&link(),
¶ms_for("list-services", CTX),
&mut msg
));
assert!(msg.pointer("/params").is_none());
}
#[test]
fn does_not_inject_into_a_jsonrpc_batch() {
let mut msg = json!([
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "list-services", "arguments": {} } }
]);
assert!(!inject_link_context(
&link(),
¶ms_for("list-services", CTX),
&mut msg
));
assert_eq!(msg.pointer("/0/params/arguments").unwrap(), &json!({}));
}
#[test]
fn survives_malformed_params_and_arguments() {
let declared = params_for("list-services", CTX);
let mut a = json!({ "method": "tools/call", "params": "nope" });
assert!(!inject_link_context(&link(), &declared, &mut a));
let mut b = json!({
"method": "tools/call",
"params": { "name": "list-services", "arguments": [1, 2] }
});
assert!(!inject_link_context(&link(), &declared, &mut b));
assert_eq!(b.pointer("/params/arguments").unwrap(), &json!([1, 2]));
let mut c = json!({ "method": "tools/call", "params": { "arguments": {} } });
assert!(!inject_link_context(&link(), &declared, &mut c));
}
#[test]
fn resolves_only_a_project_so_there_is_nothing_to_mix() {
let ctx = LinkContext {
project_id: Some("proj-1".into()),
};
assert_eq!(ctx.project_id.as_deref(), Some("proj-1"));
assert_eq!(LinkContext::default().project_id, None);
}
#[test]
fn learns_declared_parameters_from_a_tools_list_result() {
let mut session = SessionMeta::default();
record_tool_params(
&mut session,
&json!({
"jsonrpc": "2.0", "id": 1,
"result": { "tools": [
{ "name": "list-services", "inputSchema": { "properties": {
"projectId": {}, "environmentId": {}
}}},
{ "name": "whoami", "inputSchema": { "properties": {} } }
]}
}),
);
assert!(session.tool_params["list-services"].contains("projectId"));
assert!(session.tool_params["whoami"].is_empty());
}
#[test]
fn ignores_results_that_are_not_tool_listings() {
let mut session = SessionMeta::default();
record_tool_params(&mut session, &json!({ "result": { "content": [] } }));
assert!(session.tool_params.is_empty());
}
}