Skip to main content

tailscale_mcp/
instructions.rs

1//! What the server tells a model about itself.
2//!
3//! Written per-server rather than as a constant, because the useful half is
4//! what *this* server can do: which surfaces are live, which tier is permitted,
5//! and therefore which of the model's likely next moves will not work. A model
6//! that has been told the tailnet surface is off will stop proposing tailnet
7//! tools; one that has to discover it by being refused will not.
8
9use std::fmt::Write as _;
10
11use crate::context::ToolContext;
12use crate::gating::Gate;
13use crate::meta::{Surface, Tier, Toolset};
14
15/// What is true of this session's own tool table.
16///
17/// The gate answers which toolsets and tier are offered; it cannot answer
18/// whether any *tool* that survived both takes a `confirm` argument, and that
19/// is a different question — a write-tier session that selected only
20/// `local-status` has no confirmable tool in it. Asked once, at the one place
21/// that holds the registry, so that the paragraphs below describe this session
22/// rather than the union of every session. The same shape as
23/// [`crate::resources::Surfaces`], and for the same reason.
24#[derive(Clone, Copy, Debug, Default)]
25pub struct Offered {
26    confirmable: bool,
27}
28
29impl Offered {
30    /// Ask the tool table, through the gate that decides what a session sees.
31    pub fn new(registry: &crate::registry::Registry, gate: &Gate) -> Self {
32        Self {
33            confirmable: registry
34                .visible(gate)
35                .into_iter()
36                .any(|entry| entry.meta.takes_confirmation()),
37        }
38    }
39}
40
41/// Compose the instructions for a server in this configuration.
42pub fn render(gate: &Gate, ctx: &ToolContext, offered: Offered) -> String {
43    let mut out = String::with_capacity(1024);
44    out.push_str(
45        "This server drives Tailscale through two separate surfaces.\n\n\
46         Tools named `tailscale_*` act on the node this server runs on, through its \
47         command-line interface. They can only ever affect that one machine.\n\n\
48         Tools named `tailnet_*` act on the whole tailnet through the control-plane API. \
49         They affect every device and user in it, including this node.\n\n",
50    );
51
52    // What the session can actually do, not what it was asked for. A surface
53    // whose toolsets were selected but whose backend is absent offers nothing,
54    // and telling a model otherwise is worse than telling it nothing: it will
55    // keep proposing tools that are not in the listing.
56    let local = gate.offers(Surface::Local);
57    let tailnet = gate.offers(Surface::Tailnet);
58    if !tailnet {
59        out.push_str(
60            "The tailnet surface is not available in this session, so no `tailnet_*` tool is \
61             offered. Questions about other devices, users, keys or policy cannot be answered \
62             here.\n\n",
63        );
64    }
65    if !local {
66        out.push_str(
67            "The local surface is not available in this session, so no `tailscale_*` tool is \
68             offered. Nothing can be read from or changed on this machine directly.\n\n",
69        );
70    }
71
72    let _ = write!(out, "Permitted tier: {}. ", describe_tier(gate.max_tier()));
73    match gate.max_tier() {
74        Tier::Read => out.push_str(
75            "Only tools that change nothing are offered. Tools that would change or remove \
76             something are hidden, not merely refused, so a tool you cannot see does not exist \
77             for this session. Do not describe a change as done; say what would be needed.\n\n",
78        ),
79        Tier::Write => out.push_str(
80            "Tools that change configuration are offered. Tools that remove something, or \
81             expose it to the public internet, are hidden.\n\n",
82        ),
83        Tier::Destructive => out.push_str(
84            "Every tier is offered, including tools that delete, revoke, or publish to the \
85             public internet. Prefer reading before writing, and say what a call will do before \
86             making it.\n\n",
87        ),
88    }
89
90    // Offered rather than selected: with the local surface switched off this
91    // paragraph introduced `tailscale_run` four lines after the session had
92    // said no `tailscale_*` tool is offered — the very contradiction the
93    // comment below says it exists to prevent.
94    if gate
95        .offered_toolsets()
96        .any(|toolset| toolset == Toolset::LocalPassthrough)
97    {
98        // Without this the two halves of what the session is told contradict
99        // each other: the tier paragraph says which tools are offered, and
100        // `tailscale_run` is annotated destructive at every tier because its
101        // arguments decide what it does and nobody has seen them yet.
102        out.push_str(
103            "`tailscale_run` is the exception to the paragraph above. It runs a `tailscale` \
104             subcommand no other tool covers, so what it does is decided by its arguments \
105             rather than by the tool, and it is annotated as destructive for that reason \
106             alone. It is still held to the permitted tier, command by command, and it \
107             refuses outright the commands this server never runs. Prefer a typed tool \
108             wherever one exists.\n\n",
109        );
110    }
111
112    // Only where one is actually offered. At the read tier no tool takes a
113    // `confirm` argument at all, so every default session was being told how
114    // to use an argument it would never see — the same contradiction the
115    // passthrough paragraph above exists to avoid.
116    if offered.confirmable {
117        out.push_str(
118            "Some tools take a `confirm` argument. They are the ones that cannot be undone, or \
119             that can cut this server off from the tailnet it is managing. They refuse to run \
120             without it. Set it only when the person you are working for has asked for that \
121             specific action; it is not a formality to be filled in.\n\n",
122        );
123    }
124
125    // The first sentence holds on either surface; the rest is about the
126    // tailnet tools by name, and a session that has just been told it has none
127    // should not then be told what they accept. The sentence that used to
128    // close this paragraph — that `-` names the credential's own tailnet and
129    // is almost always right — is gone rather than gated: the only tool in the
130    // table that takes a tailnet is the one that deletes it, whose own
131    // parameter asks for an id or a name, and where "your own" is the last
132    // thing a caller should be nudged towards.
133    if local || tailnet {
134        out.push_str(
135            "Identifiers: a device can be named by its MagicDNS name, the short name before \
136             the first dot of it, its hostname, or one of its Tailscale IP addresses.",
137        );
138        if tailnet {
139            out.push_str(
140                " The `tailnet_*` tools also take the node ID (`n1234567CNTRL`) and the \
141                 numeric id a listing reports, and answer with those; a name is resolved \
142                 against the tailnet's device list, and a name matching more than one device \
143                 is refused rather than guessed at.",
144            );
145        }
146        out.push_str("\n\n");
147    }
148
149    if let Some(version) = ctx.cli_version {
150        let _ = writeln!(
151            out,
152            "The local `tailscale` binary reports version {version}."
153        );
154    }
155    if let Some(name) = ctx.identity.last_known().dns_name.as_deref() {
156        let _ = writeln!(out, "This node is {}.", name.trim_end_matches('.'));
157    }
158
159    let _ = write!(
160        out,
161        "\nToolsets offered: {}.",
162        gate.offered_toolsets()
163            .map(|toolset| toolset.as_str())
164            .collect::<Vec<_>>()
165            .join(", ")
166    );
167    out
168}
169
170const fn describe_tier(tier: Tier) -> &'static str {
171    match tier {
172        Tier::Read => "read",
173        Tier::Write => "read and write",
174        Tier::Destructive => "read, write and destructive",
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use std::collections::BTreeSet;
181    use std::sync::Arc;
182
183    use tailscale_cli::Unavailable;
184
185    use super::*;
186    use crate::context::{Identity, PathPolicy, SelfIdentity};
187    use crate::error::Redactor;
188    use crate::gating::Preset;
189    use crate::meta::Toolset;
190    use crate::version::Version;
191
192    fn context() -> ToolContext {
193        ToolContext {
194            local: Arc::new(Unavailable::default()),
195            tailnet: None,
196            redactor: Redactor::default(),
197            max_result_bytes: 1 << 20,
198            identity: SelfIdentity {
199                dns_name: Some("workstation.example-tailnet.ts.net.".to_owned()),
200                ..SelfIdentity::default()
201            }
202            .into(),
203            cli_version: Some(Version::new(1, 102, 2)),
204            paths: PathPolicy::default(),
205            devices: Default::default(),
206            max_tier: Tier::Destructive,
207        }
208    }
209
210    fn gate(toolsets: BTreeSet<Toolset>, tier: Tier) -> Gate {
211        Gate::unchecked(toolsets, tier, BTreeSet::new())
212    }
213
214    #[test]
215    fn a_surface_that_was_asked_for_and_is_not_there_is_stated_too() {
216        // The case a session actually meets: both surfaces selected, and the
217        // control plane has no credential. Reading the selection alone said
218        // the tailnet surface was present while every tailnet tool was hidden,
219        // which is the one thing these instructions exist to prevent.
220        let both = BTreeSet::from([Toolset::LocalStatus, Toolset::TailnetDevices]);
221        let no_control_plane =
222            Gate::unchecked(both.clone(), Tier::Read, BTreeSet::from([Surface::Tailnet]));
223        let text = render(&no_control_plane, &context(), Offered::default());
224        assert!(text.contains("tailnet surface is not available"), "{text}");
225        assert!(!text.contains("local surface is not available"), "{text}");
226
227        let no_cli = Gate::unchecked(both, Tier::Read, BTreeSet::from([Surface::Local]));
228        let text = render(&no_cli, &context(), Offered::default());
229        assert!(text.contains("local surface is not available"), "{text}");
230    }
231
232    #[test]
233    fn the_tier_is_stated_and_hiding_is_explained() {
234        let text = render(
235            &gate(Preset::Core.toolsets(), Tier::Read),
236            &context(),
237            Offered::default(),
238        );
239        assert!(text.contains("Permitted tier: read."), "{text}");
240        assert!(text.contains("hidden, not merely refused"), "{text}");
241
242        let text = render(
243            &gate(Preset::Core.toolsets(), Tier::Destructive),
244            &context(),
245            Offered::default(),
246        );
247        assert!(text.contains("read, write and destructive"), "{text}");
248    }
249
250    #[test]
251    fn what_was_learned_at_startup_is_passed_on() {
252        let text = render(
253            &gate(Preset::Core.toolsets(), Tier::Read),
254            &context(),
255            Offered::default(),
256        );
257        assert!(text.contains("1.102.2"), "{text}");
258        assert!(
259            text.contains("This node is workstation.example-tailnet.ts.net.\n"),
260            "{text}"
261        );
262        assert!(
263            !text.contains("ts.net.."),
264            "the trailing dot of a fully-qualified name should not be doubled: {text}"
265        );
266    }
267
268    #[test]
269    fn nothing_is_claimed_that_was_not_learned() {
270        let bare = ToolContext {
271            identity: Identity::default(),
272            cli_version: None,
273            paths: PathPolicy::default(),
274            ..context()
275        };
276        let text = render(
277            &gate(Preset::Core.toolsets(), Tier::Read),
278            &bare,
279            Offered::default(),
280        );
281        assert!(!text.contains("reports version"), "{text}");
282        assert!(!text.contains("This node is"), "{text}");
283    }
284}