use std::sync::Arc;
use rmcp::model::{
CallToolRequestParams, CallToolResponse, CallToolResult, CompleteRequestParams, CompleteResult,
GetPromptRequestParams, GetPromptResponse, GetPromptResult, Implementation, InitializeResult,
ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult, ListToolsResult,
PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResponse, ServerCapabilities,
ServerInfo, Tool,
};
use rmcp::service::{RequestContext, RoleServer};
use rmcp::{ErrorData as McpError, ServerHandler};
use serde_json::Value;
use tailscale_cli::{CliBackend, LocalBackend, Unavailable};
use tailscale_rest::Credentials;
use crate::completion;
use crate::config::Config;
use crate::context::{Identity, PathPolicy, SelfIdentity, ToolContext};
use crate::error::{ToolError, ToolResult};
use crate::gating::{ConfigError, Gate};
use crate::meta::Surface;
use crate::registry::{Registry, RegistryError, ToolEntry};
use crate::version::SUPPORTED_FLOOR;
use crate::{cli, instructions, resources};
#[derive(Debug, thiserror::Error)]
pub enum StartupError {
#[error(transparent)]
Config(#[from] ConfigError),
#[error(transparent)]
Registry(#[from] RegistryError),
#[error(transparent)]
ControlPlane(#[from] tailscale_rest::ApiError),
#[error("no tools can be offered. {}", .0.join(" "))]
NoSurface(Vec<String>),
}
#[derive(Debug)]
pub struct Backends {
pub local: Arc<dyn LocalBackend>,
pub local_available: bool,
pub credentials: Option<Credentials>,
}
impl Backends {
pub fn discover(config: &Config) -> Self {
let (local, local_available): (Arc<dyn LocalBackend>, bool) =
if config.is_disabled(Surface::Local) {
(Arc::new(Unavailable::new("disabled by --no-local")), false)
} else {
match CliBackend::discover_with(config.cli_path.as_ref().map(AsRef::as_ref)) {
Ok(backend) => (Arc::new(backend), true),
Err(e) => (Arc::new(Unavailable::new(e.to_string())), false),
}
};
let credentials = if config.is_disabled(Surface::Tailnet) {
None
} else {
Credentials::from_env()
};
Self {
local,
local_available,
credentials,
}
}
}
#[derive(Debug)]
pub struct Startup {
pub server: TailscaleMcpServer,
pub peers: std::collections::HashMap<std::net::IpAddr, String>,
pub notes: Vec<String>,
}
pub async fn build(
config: &Config,
entries: Vec<ToolEntry>,
backends: Backends,
) -> Result<Startup, StartupError> {
let registry = Registry::new(entries)?;
let mut notes = Vec::new();
let mut unavailable = std::collections::BTreeSet::new();
if config.is_disabled(Surface::Local) {
unavailable.insert(Surface::Local);
notes.push(
"The local surface is switched off; the tools that drive this node are hidden."
.to_owned(),
);
} else if !backends.local_available {
unavailable.insert(Surface::Local);
notes.push(
"No `tailscale` binary was found, so the tools that drive this node are hidden. \
Set TAILSCALE_MCP_CLI_PATH or pass --cli-path to point at one."
.to_owned(),
);
}
if config.is_disabled(Surface::Tailnet) {
unavailable.insert(Surface::Tailnet);
notes.push(
"The tailnet surface is switched off; the tools that act on the tailnet are hidden."
.to_owned(),
);
} else if backends.credentials.is_none() {
unavailable.insert(Surface::Tailnet);
notes.push(
"No control-plane credential was found, so the tools that act on the tailnet are \
hidden. Set TAILSCALE_API_KEY, or TAILSCALE_OAUTH_CLIENT_ID and \
TAILSCALE_OAUTH_CLIENT_SECRET."
.to_owned(),
);
}
let local_offered = backends.local_available && !config.is_disabled(Surface::Local);
let (cli_version, identity, peers) = if local_offered {
let version = cli::probe_version(backends.local.as_ref()).await;
match version {
Some(found) if found < SUPPORTED_FLOOR && !found.is_unstable() => notes.push(format!(
"The `tailscale` binary reports {found}, which is older than {SUPPORTED_FLOOR}, \
the oldest release this server is written against. Nothing is hidden; commands \
this build does not have will report the version they need."
)),
None => notes.push(
"Could not read the version of the `tailscale` binary; commands will be attempted \
regardless."
.to_owned(),
),
Some(_) => {}
}
let (identity, peers) = cli::probe_node(backends.local.as_ref()).await;
(
version,
Identity::probed(identity),
peers,
)
} else {
(
None,
Identity::fixed(SelfIdentity::default()),
std::collections::HashMap::new(),
)
};
let tailnet = if unavailable.contains(&Surface::Tailnet) {
None
} else {
tailscale_rest::checked_base_url(&config.api_base_url)?;
match &backends.credentials {
Some(credentials) => {
let mut client = tailscale_rest::ClientConfig::new(credentials.clone());
client.base_url = config.api_base_url.clone();
client.tailnet = config.tailnet.clone();
client.max_response_bytes = config.max_result_bytes;
client.budget = tailscale_cli::DEFAULT_TIMEOUT;
Some(tailscale_rest::Client::new(client)?)
}
None => None,
}
};
let metas = registry.metas();
let gate = match Gate::new(
config.toolsets.clone(),
config.max_tier,
unavailable,
&metas,
) {
Err(ConfigError::NoToolsEnabled)
if Gate::new(
config.toolsets.clone(),
config.max_tier,
std::collections::BTreeSet::new(),
&metas,
)
.is_ok() =>
{
return Err(StartupError::NoSurface(notes));
}
gate => gate?,
};
let ctx = ToolContext {
local: Arc::clone(&backends.local),
tailnet,
redactor: crate::error::Redactor::for_credentials(backends.credentials.as_ref()),
max_result_bytes: config.max_result_bytes,
identity,
cli_version,
paths: PathPolicy::default(),
devices: Default::default(),
max_tier: config.max_tier,
};
let visible = registry.visible(&gate).len();
notes.push(format!(
"Offering {visible} tools: {} at the {} tier and above.",
describe_toolsets(&gate),
gate.max_tier()
));
Ok(Startup {
server: TailscaleMcpServer::new(Arc::new(registry), gate, Arc::new(ctx)),
peers,
notes,
})
}
fn describe_toolsets(gate: &Gate) -> String {
gate.offered_toolsets()
.map(|toolset| toolset.as_str())
.collect::<Vec<_>>()
.join(", ")
}
#[derive(Debug, Clone)]
pub struct TailscaleMcpServer {
registry: Arc<Registry>,
gate: Gate,
ctx: Arc<ToolContext>,
completions: completion::Limiter,
}
impl TailscaleMcpServer {
pub fn new(registry: Arc<Registry>, gate: Gate, ctx: Arc<ToolContext>) -> Self {
Self {
registry,
gate,
ctx,
completions: completion::Limiter::new(),
}
}
pub fn context(&self) -> &Arc<ToolContext> {
&self.ctx
}
pub fn tools(&self) -> Result<Vec<Tool>, McpError> {
self.registry
.visible(&self.gate)
.into_iter()
.map(|entry| {
entry.describe().map_err(|reason| {
McpError::internal_error(
format!("the schema for `{}` is invalid: {reason}", entry.meta.name),
None,
)
})
})
.collect()
}
async fn dispatch(&self, request: CallToolRequestParams) -> ToolResult<Value> {
let arguments = request.arguments.unwrap_or_default();
let (entry, arguments) = self
.registry
.resolve(&request.name, arguments, &self.gate)?;
if entry.meta.surface() == Surface::Local {
if !entry.meta.runs_here() {
return Err(ToolError::unsupported_platform(
entry.meta.name,
std::env::consts::OS,
));
}
cli::version_permits(&self.ctx, &entry.meta)?;
}
let invoke = entry.invoke;
let value = invoke(Arc::clone(&self.ctx), arguments).await?;
self.check_size(&value)?;
Ok(value)
}
fn check_size(&self, value: &Value) -> ToolResult<()> {
let size = serde_json::to_vec(value).map_or(0, |v| v.len());
if size > self.ctx.max_result_bytes {
return Err(ToolError::result_too_large(size, self.ctx.max_result_bytes));
}
Ok(())
}
}
impl ServerHandler for TailscaleMcpServer {
fn get_info(&self) -> ServerInfo {
let mut info = InitializeResult::new(
ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.enable_prompts()
.enable_completions()
.build(),
)
.with_instructions(instructions::render(
&self.gate,
&self.ctx,
instructions::Offered::new(&self.registry, &self.gate),
));
info.server_info = Implementation::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
.with_title("Tailscale")
.with_website_url("https://github.com/tailscale-mcp/tailscale-mcp");
info
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
Ok(ListToolsResult::with_all_items(self.tools()?))
}
fn get_tool(&self, name: &str) -> Option<Tool> {
self.registry
.visible(&self.gate)
.into_iter()
.find(|entry| entry.meta.name == name)
.and_then(|entry| entry.describe().ok())
}
async fn list_resources(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, McpError> {
Ok(ListResourcesResult::with_all_items(
resources::all()
.iter()
.filter(|entry| !entry.templated && self.gate.offers(entry.surface))
.map(resources::ResourceEntry::describe)
.collect(),
))
}
async fn list_resource_templates(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourceTemplatesResult, McpError> {
Ok(ListResourceTemplatesResult::with_all_items(
resources::all()
.iter()
.filter(|entry| entry.templated && self.gate.offers(entry.surface))
.map(resources::ResourceEntry::describe_template)
.collect(),
))
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<ReadResourceResponse, McpError> {
let read = resources::read(&self.ctx, |surface| self.gate.offers(surface), &request.uri);
match read.await {
Ok(result) => Ok(ReadResourceResponse::Complete(result)),
Err(error) => Err(McpError::resource_not_found(
error.message.clone(),
Some(serde_json::json!({"uri": request.uri, "error": error.to_value()})),
)),
}
}
async fn list_prompts(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListPromptsResult, McpError> {
Ok(ListPromptsResult::with_all_items(
resources::prompts()
.iter()
.filter(|entry| self.gate.offers(entry.surface))
.map(resources::PromptEntry::describe)
.collect(),
))
}
async fn get_prompt(
&self,
request: GetPromptRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<GetPromptResponse, McpError> {
let prompts = resources::prompts();
let prompt = prompts
.iter()
.find(|prompt| prompt.name == request.name)
.ok_or_else(|| {
McpError::invalid_params(format!("`{}` is not a prompt", request.name), None)
})?;
if !self.gate.offers(prompt.surface) {
return Err(McpError::invalid_params(
format!(
"`{}` is not offered, because this server has no {} surface",
request.name,
prompt.surface.as_str()
),
None,
));
}
let (argument, _) = prompt.argument;
let given = request
.arguments
.as_ref()
.and_then(|arguments| arguments.get(argument))
.and_then(Value::as_str);
let surfaces = resources::Surfaces::new(|surface| self.gate.offers(surface));
Ok(GetPromptResponse::Complete(
GetPromptResult::new(prompt.expand(given, surfaces))
.with_description(prompt.description),
))
}
async fn complete(
&self,
request: CompleteRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<CompleteResult, McpError> {
Ok(completion::complete(&self.ctx, &self.gate, &self.completions, &request).await)
}
async fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, McpError> {
let result = match self.dispatch(request).await {
Ok(value) => CallToolResult::structured(value),
Err(error) => CallToolResult::structured_error(error.to_value()),
};
Ok(CallToolResponse::Complete(result))
}
}
#[cfg(test)]
mod tests {
use rmcp::schemars::JsonSchema;
use serde::Deserialize;
use serde_json::json;
use tailscale_rest::Secret;
use super::*;
use crate::config::Cli;
use crate::error::ErrorCode;
use crate::testing::{Reply, StubBackend};
mod fixture {
use super::{Deserialize, JsonSchema, ToolContext, ToolResult, Value};
use tailscale_cli::Invocation;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct NoParams {}
crate::tools! {
tailscale_fixture_read => NoParams, run_local,
toolset: LocalStatus, tier: Read, idempotent: true;
tailscale_fixture_new => NoParams, run_local,
toolset: LocalStatus, tier: Read, since: "1.94";
tailscale_fixture_debug => NoParams, run_local,
toolset: LocalDebug, tier: Read;
tailnet_fixture_read => NoParams, run_tailnet,
toolset: TailnetDevices, tier: Read, idempotent: true;
}
async fn run_local(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let text = crate::cli::run_text(
ctx,
&metas::tailscale_fixture_read,
Invocation::read(["fixture"]),
)
.await?;
Ok(serde_json::json!({ "text": text.trim() }))
}
async fn run_tailnet(_ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
Ok(serde_json::json!({ "ok": true }))
}
}
fn config(cli: Cli) -> Config {
Config::resolve_with(cli, |_| None).expect("test configuration resolves")
}
fn backends(local: Option<StubBackend>, credentialled: bool) -> Backends {
let local_available = local.is_some();
Backends {
local: local.map_or_else(
|| Arc::new(Unavailable::default()) as Arc<dyn LocalBackend>,
|b| Arc::new(b) as Arc<dyn LocalBackend>,
),
local_available,
credentials: credentialled
.then(|| Credentials::ApiKey(Secret::new("tskey-api-example"))),
}
}
fn healthy_node() -> StubBackend {
StubBackend::ok("")
.on(["version"], Reply::ok("1.102.2\n"))
.on(
["status", "--json"],
Reply::ok(
json!({
"Self": {
"ID": "n1234567CNTRL",
"PublicKey": "nodekey:aaa",
"TailscaleIPs": ["100.64.0.1", "fd7a::1"],
"DNSName": "workstation.example-tailnet.ts.net."
}
})
.to_string(),
),
)
}
async fn server(cli: Cli, backends: Backends) -> Startup {
build(&config(cli), fixture::entries(), backends)
.await
.expect("the server builds")
}
fn names(startup: &Startup) -> Vec<String> {
startup
.server
.tools()
.expect("tools describe")
.iter()
.map(|t| t.name.to_string())
.collect()
}
async fn call(server: &TailscaleMcpServer, name: &str, arguments: Value) -> CallToolResult {
let arguments = arguments
.as_object()
.cloned()
.expect("arguments must be an object");
let response = server
.respond(CallToolRequestParams::new(name.to_owned()).with_arguments(arguments))
.await;
match response {
CallToolResponse::Complete(result) => result,
other => panic!("expected a completed call, got {other:?}"),
}
}
fn error_of(result: &CallToolResult) -> Value {
assert_eq!(result.is_error, Some(true), "expected a failed call");
result
.structured_content
.clone()
.expect("a failed call carries structured content")
}
#[tokio::test]
async fn without_a_binary_the_local_tools_are_hidden_and_the_tailnet_tools_remain() {
let startup = server(Cli::default(), backends(None, true)).await;
let names = names(&startup);
assert!(
!names.is_empty(),
"the tailnet surface should still be offered"
);
assert!(
names.iter().all(|n| n.starts_with("tailnet_")),
"a local tool survived a missing binary: {names:?}"
);
assert!(
startup
.notes
.iter()
.any(|n| n.contains("`tailscale` binary")),
"{:?}",
startup.notes
);
}
#[tokio::test]
async fn a_surface_switched_off_stays_off_even_when_it_could_have_worked() {
let startup = server(
Cli {
no_tailnet: true,
..Cli::default()
},
backends(Some(healthy_node()), true),
)
.await;
let names = names(&startup);
assert!(!names.is_empty());
assert!(
names.iter().all(|n| n.starts_with("tailscale_")),
"a tailnet tool survived --no-tailnet: {names:?}"
);
}
#[tokio::test]
async fn a_local_surface_switched_off_is_not_probed() {
let backend = Arc::new(healthy_node());
let startup = build(
&config(Cli {
no_local: true,
..Cli::default()
}),
fixture::entries(),
Backends {
local: Arc::clone(&backend) as Arc<dyn LocalBackend>,
local_available: true,
credentials: Some(Credentials::ApiKey(Secret::new("tskey-api-example"))),
},
)
.await
.expect("the tailnet surface still starts");
assert!(backend.calls().is_empty(), "{:?}", backend.calls());
assert_eq!(startup.server.context().cli_version, None);
}
#[tokio::test]
async fn neither_surface_is_a_startup_error() {
let err = build(
&config(Cli::default()),
fixture::entries(),
backends(None, false),
)
.await
.expect_err("a server that can do nothing should not start");
let said = err.to_string();
for repair in ["--cli-path", "TAILSCALE_API_KEY"] {
assert!(said.contains(repair), "{said}");
}
let err = build(
&config(Cli {
toolsets: Some("local-serve".to_owned()),
..Cli::default()
}),
fixture::entries(),
backends(Some(healthy_node()), true),
)
.await
.expect_err("a selection with nothing in it should not start");
assert!(
matches!(err, StartupError::Config(ConfigError::NoToolsEnabled)),
"{err}"
);
}
async fn build_pointed_at(base_url: &str) -> Result<Startup, StartupError> {
let config = Config::resolve_with(Cli::default(), |key| {
(key == crate::config::API_BASE_URL_ENV).then(|| base_url.to_owned())
})
.expect("the configuration itself resolves");
build(
&config,
fixture::entries(),
backends(Some(healthy_node()), true),
)
.await
}
#[tokio::test]
async fn a_base_url_that_is_not_the_control_plane_stops_the_server_starting() {
let error = build_pointed_at("http://control.example.com")
.await
.expect_err("plaintext to another host is refused");
let reported = error.to_string();
assert!(
reported.contains("loopback") && reported.contains("control.example.com"),
"the failure should say what was wrong and with what: {reported}"
);
}
#[tokio::test]
async fn an_unstable_build_is_not_warned_about() {
let unstable = StubBackend::ok("").on(["version"], Reply::ok("1.77.0\n"));
let startup = server(Cli::default(), backends(Some(unstable), true)).await;
assert!(
!startup.notes.iter().any(|n| n.contains("older than")),
"{:?}",
startup.notes
);
}
#[tokio::test]
async fn an_unreadable_version_is_noted_and_blocks_nothing() {
let odd = StubBackend::ok("").on(["version"], Reply::failed(1, "no"));
let startup = server(Cli::default(), backends(Some(odd), true)).await;
assert!(
startup
.notes
.iter()
.any(|n| n.contains("Could not read the version")),
"{:?}",
startup.notes
);
assert!(names(&startup).iter().any(|n| n.starts_with("tailscale_")));
}
#[tokio::test]
async fn without_a_local_surface_we_claim_no_identity() {
let startup = server(Cli::default(), backends(None, true)).await;
let identity = startup.server.context().identity.last_known();
assert!(!identity.matches("n1234567CNTRL"));
assert_eq!(startup.server.context().cli_version, None);
}
#[tokio::test]
async fn a_hidden_tool_is_not_reachable_by_name() {
let startup = server(Cli::default(), backends(Some(healthy_node()), true)).await;
assert!(startup.server.get_tool("tailscale_fixture_debug").is_none());
let error = error_of(&call(&startup.server, "tailscale_fixture_debug", json!({})).await);
assert_eq!(error["code"], ErrorCode::NotPermitted.as_str());
assert!(
error["hint"]
.as_str()
.is_some_and(|h| h.contains("local-debug")),
"the refusal should say what to change: {error}"
);
}
#[tokio::test]
async fn a_tool_newer_than_the_binary_reports_the_version_it_needs() {
let old = StubBackend::ok("")
.on(["version"], Reply::ok("1.80.0\n"))
.on(["status", "--json"], Reply::ok("{}"));
let startup = server(Cli::default(), backends(Some(old), true)).await;
let error = error_of(&call(&startup.server, "tailscale_fixture_new", json!({})).await);
assert_eq!(error["code"], ErrorCode::UnsupportedVersion.as_str());
assert!(
error["message"]
.as_str()
.is_some_and(|m| m.contains("1.94")),
"{error}"
);
}
#[tokio::test]
async fn a_result_larger_than_the_cap_is_refused() {
let startup = server(
Cli {
max_result_bytes: Some(4),
..Cli::default()
},
backends(Some(healthy_node()), true),
)
.await;
let error = error_of(&call(&startup.server, "tailnet_fixture_read", json!({})).await);
assert_eq!(error["code"], ErrorCode::ResultTooLarge.as_str());
}
#[tokio::test]
async fn the_summary_names_no_toolset_that_offers_nothing() {
let startup = server(Cli::default(), backends(Some(healthy_node()), false)).await;
let summary = startup
.notes
.last()
.expect("a summary is always the last note");
assert!(
summary.contains("local-status"),
"the local surface is there and should be named: {summary}"
);
assert!(
!summary.contains("tailnet-"),
"no credential, so no tailnet toolset offers anything; naming one \
contradicts the count in the same sentence: {summary}"
);
}
impl TailscaleMcpServer {
async fn respond(&self, request: CallToolRequestParams) -> CallToolResponse {
CallToolResponse::Complete(match self.dispatch(request).await {
Ok(value) => CallToolResult::structured(value),
Err(error) => CallToolResult::structured_error(error.to_value()),
})
}
}
}