use std::sync::Arc;
use rmcp::model::{
Prompt, PromptArgument, PromptMessage, ReadResourceResult, Resource, ResourceContents,
ResourceTemplate, Role,
};
use serde_json::Value;
use tailscale_cli::Invocation;
use crate::context::ToolContext;
use crate::error::{ToolError, ToolResult};
use crate::meta::Surface;
const JSON: &str = "application/json";
const HUJSON: &str = "application/hujson";
pub struct ResourceEntry {
pub uri: &'static str,
pub name: &'static str,
pub title: &'static str,
pub description: &'static str,
pub mime_type: &'static str,
pub surface: Surface,
pub templated: bool,
read: fn(Arc<ToolContext>, String) -> tailscale_cli::BoxFuture<'static, ToolResult<String>>,
}
impl std::fmt::Debug for ResourceEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResourceEntry")
.field("uri", &self.uri)
.field("surface", &self.surface)
.finish_non_exhaustive()
}
}
impl ResourceEntry {
pub fn describe(&self) -> Resource {
Resource::new(self.uri, self.name)
.with_title(self.title)
.with_description(self.description)
.with_mime_type(self.mime_type)
}
pub fn describe_template(&self) -> ResourceTemplate {
ResourceTemplate::new(self.uri, self.name)
.with_title(self.title)
.with_description(self.description)
.with_mime_type(self.mime_type)
}
fn captures(&self, uri: &str) -> Option<String> {
if !self.templated {
return (uri == self.uri).then(String::new);
}
let prefix = self.uri.split_once('{')?.0;
uri.strip_prefix(prefix)
.filter(|rest| !rest.is_empty() && !rest.contains('/'))
.map(str::to_owned)
}
}
macro_rules! resources {
($(
$uri:literal => $name:literal, $title:literal, $mime:expr, $surface:ident,
templated: $templated:literal,
$description:literal,
$read:expr;
)*) => {
pub fn all() -> Vec<ResourceEntry> {
vec![$(
ResourceEntry {
uri: $uri,
name: $name,
title: $title,
description: $description,
mime_type: $mime,
surface: Surface::$surface,
templated: $templated,
read: |ctx, id| Box::pin($read(ctx, id)),
},
)*]
}
};
}
resources! {
"tailscale://status" => "status", "Node status", JSON, Local,
templated: false,
"This node and the peers it knows about, as `tailscale status --json` \
reports them: backend state, addresses, MagicDNS names and who is \
online.",
|ctx, _id| local_json(ctx, ["status", "--json"]);
"tailscale://prefs" => "prefs", "Node preferences", JSON, Local,
templated: false,
"How this node is configured: routes it advertises, whether it is an \
exit node, DNS and subnet-route acceptance, and the rest of what \
`tailscale set` writes.",
|ctx, _id| local_json(ctx, ["get", "--json"]);
"tailscale://netcheck" => "netcheck", "Connectivity report", JSON, Local,
templated: false,
"What this node can reach: DERP latencies, whether UDP works, the \
NAT mapping it sees and whether it has IPv6.",
|ctx, _id| local_json(ctx, ["netcheck", "--format=json"]);
"tailscale://lock" => "lock", "Tailnet lock status", JSON, Local,
templated: false,
"Whether tailnet lock is on for this tailnet, this node's own lock \
key, and the signing nodes it trusts.",
|ctx, _id| local_json(ctx, ["lock", "status", "--json"]);
"tailnet://policy" => "policy", "Policy file", HUJSON, Tailnet,
templated: false,
"The tailnet policy file as written, comments and all. HuJSON, not \
JSON: `tailnet_policy_get` with `format: \"json\"` parses it, and \
loses the comments doing so.",
|ctx, _id| policy(ctx);
"tailnet://devices" => "devices", "Tailnet devices", JSON, Tailnet,
templated: false,
"Every device in the tailnet, as the control plane lists them.",
|ctx, _id| tailnet_json(ctx, "/devices");
"tailnet://device/{device_id}" => "device", "One device", JSON, Tailnet,
templated: true,
"One device by its node id (`n1234567CNTRL`) or its numeric id, as \
`tailnet://devices` reports them.",
device;
"tailnet://dns" => "dns", "Tailnet DNS", JSON, Tailnet,
templated: false,
"The tailnet's whole DNS configuration: nameservers, split DNS, \
search paths and MagicDNS.",
|ctx, _id| tailnet_json(ctx, "/dns/configuration");
"tailnet://settings" => "settings", "Tailnet settings", JSON, Tailnet,
templated: false,
"Tailnet-wide settings: device and user approval, key durations, \
automatic updates and network flow logging.",
|ctx, _id| tailnet_json(ctx, "/settings");
}
async fn local_json<const N: usize>(ctx: Arc<ToolContext>, argv: [&str; N]) -> ToolResult<String> {
let output = ctx
.local
.run(Invocation::read(argv.map(str::to_owned)))
.await
.map_err(|error| {
ToolError::backend_unavailable("the `tailscale` command", &error.to_string())
})?;
if !output.success() {
return Err(ToolError::cli_failed(
"tailscale",
output.exit_code,
&output.stderr,
));
}
Ok(output.stdout_str().trim().to_owned())
}
async fn tailnet_json(ctx: Arc<ToolContext>, rest: &str) -> ToolResult<String> {
let client = ctx.tailnet()?;
let answer = client
.get(client.tailnet_path(None, rest))
.send_as::<Value>()
.await?;
Ok(crate::tools::common::pretty(&answer))
}
async fn device(ctx: Arc<ToolContext>, device_id: String) -> ToolResult<String> {
let resolved = crate::tools::tailnet_devices::resolve(&ctx, &device_id).await?;
let client = ctx.tailnet()?;
let answer = client
.get(crate::tools::tailnet_devices::device_path(&resolved, "")?)
.send_as::<Value>()
.await?;
Ok(crate::tools::common::pretty(&answer))
}
async fn policy(ctx: Arc<ToolContext>) -> ToolResult<String> {
let client = ctx.tailnet()?;
let body = client
.get(client.tailnet_path(None, "/acl"))
.header("Accept", HUJSON)
.send_text()
.await?;
Ok(body.text)
}
pub async fn read(
ctx: &Arc<ToolContext>,
offers: impl Fn(Surface) -> bool,
uri: &str,
) -> ToolResult<ReadResourceResult> {
for entry in all() {
let Some(id) = entry.captures(uri) else {
continue;
};
if !offers(entry.surface) {
return Err(ToolError::not_found(&format!(
"the resource `{uri}`, because this server has no {} surface",
entry.surface.as_str()
)));
}
let body = (entry.read)(Arc::clone(ctx), id).await?;
let body = ctx.redactor.apply(&body).into_owned();
if body.len() > ctx.max_result_bytes {
return Err(ToolError::result_too_large(
body.len(),
ctx.max_result_bytes,
));
}
return Ok(ReadResourceResult::new(vec![
ResourceContents::text(body, uri).with_mime_type(entry.mime_type),
]));
}
Err(ToolError::not_found(&format!("the resource `{uri}`")))
}
#[derive(Clone, Copy, Debug)]
pub struct Surfaces {
local: bool,
tailnet: bool,
}
impl Surfaces {
pub fn new(offers: impl Fn(Surface) -> bool) -> Self {
Self {
local: offers(Surface::Local),
tailnet: offers(Surface::Tailnet),
}
}
pub const fn has(self, surface: Surface) -> bool {
match surface {
Surface::Local => self.local,
Surface::Tailnet => self.tailnet,
}
}
}
pub struct PromptEntry {
pub name: &'static str,
pub title: &'static str,
pub description: &'static str,
pub argument: (&'static str, &'static str),
pub surface: Surface,
expand: fn(Option<&str>, Surfaces) -> String,
}
impl std::fmt::Debug for PromptEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PromptEntry")
.field("name", &self.name)
.finish_non_exhaustive()
}
}
impl PromptEntry {
pub fn describe(&self) -> Prompt {
let (name, about) = self.argument;
Prompt::new(
self.name,
Some(self.description),
Some(vec![
PromptArgument::new(name)
.with_description(about)
.with_required(false),
]),
)
.with_title(self.title)
}
pub fn expand(&self, argument: Option<&str>, surfaces: Surfaces) -> Vec<PromptMessage> {
vec![PromptMessage::new_text(
Role::User,
(self.expand)(argument, surfaces),
)]
}
}
pub fn prompts() -> Vec<PromptEntry> {
vec![
PromptEntry {
name: "diagnose_connectivity",
title: "Diagnose connectivity",
description: "Work out why this node cannot reach something, using only reads.",
argument: (
"peer",
"The peer that cannot be reached, by name or address.",
),
surface: Surface::Local,
expand: |peer, surfaces| {
let subject = match peer {
Some(peer) => format!("the peer `{peer}`"),
None => "the tailnet in general".to_owned(),
};
let control_plane = if surfaces.has(Surface::Tailnet) {
"4. `tailnet_device_list` — does the control plane agree the peer exists, \
is it authorised, and has its key expired?\n\
5. `tailnet_policy_preview` — would the policy in force let these two \
talk?\n"
} else {
""
};
format!(
"Diagnose connectivity between this node and {subject}, using read-only \
tools only. Work outwards:\n\
\n\
1. `tailscale_status` — is the backend running, and is the peer known and \
online?\n\
2. `tailscale_netcheck` — can this node reach a DERP relay, does UDP work, \
and what NAT does it see?\n\
3. `tailscale_ping` — does traffic actually arrive, and does it go direct \
or over a relay?\n\
{control_plane}\
\n\
Report what you found at each step and name the first one that explains the \
failure. Do not change anything: every tool above is a read, and a fix is \
the operator's to approve."
)
},
},
PromptEntry {
name: "review_policy_change",
title: "Review a policy change",
description: "Read, validate and preview a policy change before anyone writes it.",
argument: (
"goal",
"What the change is meant to achieve, in a sentence.",
),
surface: Surface::Tailnet,
expand: |goal, _| {
let purpose = match goal {
Some(goal) => format!("The change is meant to: {goal}\n\n"),
None => String::new(),
};
format!(
"{purpose}Review a change to the tailnet policy file. In this order, and \
without writing anything:\n\
\n\
1. `tailnet_policy_get` — read the policy as it stands, and keep the `etag` \
it answers with. Every later step is about *this* version.\n\
2. `tailnet_policy_validate` — send the proposed document and read back the \
warnings and errors. A document that does not validate is not a change \
worth discussing.\n\
3. `tailnet_policy_preview` — for each access the change is supposed to \
grant or remove, ask what the rule actually does. A rule that reads \
correctly and matches nothing is the commonest mistake.\n\
4. Say what the change does that the goal did not ask for. Widened tags, \
an `autogroup:member` where a group was meant, an `accept` that shadows \
a later rule.\n\
\n\
Then stop and report. Writing the policy is `tailnet_policy_set`, it needs \
the `etag` from step 1, and it is the operator's call — not this review's."
)
},
},
PromptEntry {
name: "audit_tailnet_access",
title: "Audit tailnet access",
description: "Survey who and what can reach the tailnet, using only reads.",
argument: (
"subject",
"A user, tag or device to audit rather than the whole tailnet.",
),
surface: Surface::Tailnet,
expand: |subject, _| {
let scope = match subject {
Some(subject) => format!("Limit the audit to `{subject}`.\n\n"),
None => String::new(),
};
format!(
"{scope}Audit who can reach what in this tailnet, using read-only tools \
only:\n\
\n\
1. `tailnet_user_list` — who has an account, what role each holds, and who \
is suspended or waiting for approval.\n\
2. `tailnet_key_list` — which credentials exist, which are close to \
expiring, and which have capabilities wider than their purpose.\n\
3. `tailnet_device_list` — which devices are unauthorised, which have key \
expiry disabled, and which are tagged.\n\
4. `tailnet_policy_get`, then `tailnet_policy_preview` for the accesses \
that matter — what the rules grant, rather than what they appear to.\n\
5. `tailnet_settings_get` — is device approval on, is user approval on, and \
how long may a key live?\n\
\n\
Report by risk, worst first, and say for each what you read that shows it. \
Do not change anything."
)
},
},
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_policy_is_the_one_resource_that_is_not_json() {
let all = all();
let odd: Vec<_> = all.iter().filter(|r| r.mime_type != JSON).collect();
assert_eq!(odd.len(), 1);
assert_eq!(odd[0].uri, "tailnet://policy");
assert_eq!(odd[0].mime_type, HUJSON);
}
#[test]
fn the_template_captures_an_identifier_and_nothing_that_is_not_one() {
let all = all();
let device = all
.iter()
.find(|r| r.uri == "tailnet://device/{device_id}")
.expect("declared");
assert_eq!(
device.captures("tailnet://device/n1111111CNTRL").as_deref(),
Some("n1111111CNTRL")
);
assert_eq!(device.captures("tailnet://device/"), None);
assert_eq!(device.captures("tailnet://device/n1/routes"), None);
assert_eq!(device.captures("tailnet://devices"), None);
let devices = all
.iter()
.find(|r| r.uri == "tailnet://devices")
.expect("declared");
assert_eq!(devices.captures("tailnet://devices").as_deref(), Some(""));
assert_eq!(devices.captures("tailnet://devices/n1"), None);
}
fn both() -> Surfaces {
Surfaces::new(|_| true)
}
#[test]
fn every_prompt_expands_with_and_without_its_argument() {
for prompt in prompts() {
let (name, _) = prompt.argument;
let without = prompt.expand(None, both());
let with = prompt.expand(Some("example"), both());
assert_eq!(without.len(), 1);
assert_ne!(
format!("{with:?}"),
format!("{without:?}"),
"`{}`'s `{name}` should change what it expands to",
prompt.name
);
assert!(
format!("{with:?}").contains("example"),
"`{}` should use the argument it was given",
prompt.name
);
let described = prompt.describe();
let arguments = described.arguments.expect("one argument");
assert_eq!(arguments.len(), 1);
assert_eq!(arguments[0].required, Some(false));
}
}
#[test]
fn no_prompt_asks_for_a_tool_that_needs_more_than_the_read_tier() {
let table = crate::tools::entries();
for prompt in prompts() {
let text = format!("{:?}", prompt.expand(Some("example"), both()));
for entry in &table {
if entry.meta.tier != crate::meta::Tier::Read && text.contains(entry.meta.name) {
assert_eq!(
entry.meta.name, "tailnet_policy_set",
"`{}` names `{}`, which needs more than the read tier",
prompt.name, entry.meta.name
);
}
}
}
}
#[test]
fn no_prompt_names_a_tool_from_a_surface_the_session_lacks() {
let table = crate::tools::entries();
for present in [Surface::Local, Surface::Tailnet] {
let only = Surfaces::new(|surface| surface == present);
for prompt in prompts() {
if prompt.surface != present {
continue;
}
let text = format!("{:?}", prompt.expand(Some("example"), only));
for entry in &table {
let named = entry.meta.surface() != present && text.contains(entry.meta.name);
assert!(
!named || entry.meta.name == "tailnet_policy_set",
"with only the {} surface, `{}` still names `{}`",
present.as_str(),
prompt.name,
entry.meta.name
);
}
}
}
}
}