Skip to main content

tailscale_mcp/
completion.rs

1//! `completion/complete`: values for the slots whose set the server can know.
2//!
3//! The protocol completes two things and only two: a prompt's argument
4//! (`ref/prompt`) and a variable in a resource template (`ref/resource`).
5//! **Tool arguments are not completable** — there is no reference type for
6//! one — so this cannot help the hundred-odd tools that take a device. What it
7//! can do is the four slots the prompts and the one template expose, and the
8//! device slot is the one that carries its weight: the template asks for the
9//! same identifier the tools do, and a caller who can pick a device from a
10//! list is a caller who never has to guess at a node id.
11//!
12//! Three properties hold everywhere below, and each is a decision rather than
13//! an accident:
14//!
15//! - **It never fails.** Every source can be unreachable — no credential, a
16//!   node that is not running, a control plane answering 429 — and a completion
17//!   is an autocomplete popup, not an operation. An empty list is what a
18//!   client can render; an error is what it has to explain. What went wrong
19//!   goes to the log instead.
20//! - **Every value it offers resolves.** The device slot offers full MagicDNS
21//!   names, not hostnames, because a hostname can name two machines and
22//!   resolution refuses those. Completing to a value the next call would
23//!   reject is worse than completing to nothing.
24//! - **It is rate limited.** The specification says servers SHOULD limit this
25//!   method and its security section says MUST; a keystroke-driven method
26//!   reaching a rate-limited control plane is the reason why.
27
28use std::sync::{Arc, Mutex};
29use std::time::Instant;
30
31use rmcp::model::{CompleteRequestParams, CompleteResult, CompletionInfo, Reference};
32
33use crate::context::ToolContext;
34use crate::gating::Gate;
35use crate::meta::Surface;
36
37/// The device resource template, whose one variable is a device.
38pub const DEVICE_TEMPLATE: &str = "tailnet://device/{device_id}";
39
40/// A slot this server can offer values for.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42enum Slot {
43    /// `tailnet://device/{device_id}` — every device in the tailnet.
44    Device,
45    /// `diagnose_connectivity`'s `peer` — every peer this node can see.
46    Peer,
47    /// `audit_tailnet_access`'s `subject` — the users and tags of the tailnet.
48    Subject,
49}
50
51/// Which slot a request names, if any.
52///
53/// A reference this server does not serve, or an argument name that is not the
54/// one the prompt declares, resolves to nothing and is answered with an empty
55/// list. That includes `review_policy_change`'s `goal`, which is deliberate:
56/// the argument is a sentence saying what a policy change is meant to achieve,
57/// and there is no set of those to draw from. A slot that returned guesses
58/// there would be inventing intent.
59fn slot_for(reference: &Reference, argument: &str) -> Option<Slot> {
60    match reference {
61        Reference::Resource(template) if template.uri == DEVICE_TEMPLATE => {
62            (argument == "device_id").then_some(Slot::Device)
63        }
64        Reference::Prompt(prompt) => match (prompt.name.as_str(), argument) {
65            ("diagnose_connectivity", "peer") => Some(Slot::Peer),
66            ("audit_tailnet_access", "subject") => Some(Slot::Subject),
67            _ => None,
68        },
69        Reference::Resource(_) => None,
70        _ => None,
71    }
72}
73
74impl Slot {
75    /// The surface the slot's values come from, and so the one whose absence
76    /// makes it empty. Asked before the source is, so that a session without a
77    /// credential does not attempt a request it knows will fail.
78    const fn surface(self) -> Surface {
79        match self {
80            Self::Device | Self::Subject => Surface::Tailnet,
81            Self::Peer => Surface::Local,
82        }
83    }
84}
85
86/// What sort of thing a candidate is.
87///
88/// Only the `subject` slot mixes them, and there it decides which get cut when
89/// there are more matches than the protocol's hundred: a tailnet has a handful
90/// of users and tags and can have thousands of devices, so ordering by name
91/// alone would answer an empty `subject` with a hundred devices beginning with
92/// `a` and never show a user at all. Declared in the order they should appear.
93#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
94enum Kind {
95    User,
96    Tag,
97    Device,
98}
99
100/// One completable thing: the value offered, and everything it answers to.
101///
102/// The distinction is the point. A device is found by its hostname or its
103/// address but offered as its MagicDNS name, because what a client inserts is
104/// exactly the string in `values` — the protocol has no display label — and
105/// the string that goes back to the server has to be one the server accepts.
106#[derive(Debug)]
107struct Candidate {
108    value: String,
109    kind: Kind,
110    /// Lowercased, including the value itself.
111    known_as: Vec<String>,
112}
113
114impl Candidate {
115    fn new(value: impl Into<String>, kind: Kind, also: impl IntoIterator<Item = String>) -> Self {
116        let value = value.into();
117        let mut known_as = vec![normalise(&value)];
118        known_as.extend(
119            also.into_iter()
120                .map(|other| normalise(&other))
121                .filter(|other| !other.is_empty()),
122        );
123        known_as.sort_unstable();
124        known_as.dedup();
125        Self {
126            value,
127            kind,
128            known_as,
129        }
130    }
131
132    /// How well the candidate answers to what has been typed, smaller being
133    /// better: an exact match, then something starting with it, then something
134    /// merely containing it. Substring rather than prefix because the names
135    /// worth completing are compound — someone typing `laptop` is looking for
136    /// `alices-laptop`, and a prefix match would find nothing.
137    fn rank(&self, typed: &str) -> Option<u8> {
138        if typed.is_empty() {
139            return Some(2);
140        }
141        self.known_as
142            .iter()
143            .filter_map(|known| {
144                if known == typed {
145                    Some(0)
146                } else if known.starts_with(typed) {
147                    Some(1)
148                } else if known.contains(typed) {
149                    Some(2)
150                } else {
151                    None
152                }
153            })
154            .min()
155    }
156}
157
158/// One spelling of a name, for comparing against another.
159///
160/// Case folded, and the root label taken off: `DNSName` is fully qualified and
161/// carries a trailing dot, which is a thing nobody types but a thing anybody
162/// might paste.
163fn normalise(name: &str) -> String {
164    name.trim().trim_end_matches('.').to_ascii_lowercase()
165}
166
167/// How often a session may ask.
168///
169/// A token bucket rather than a fixed window: completion arrives in bursts,
170/// one per keystroke, and a window that refused the tail of every burst would
171/// be worse than useless. Twenty in reserve absorbs a typed word; the refill
172/// is what a sustained hold gets.
173#[derive(Debug)]
174struct Bucket {
175    tokens: f64,
176    last: Instant,
177}
178
179/// The state a session keeps: the bucket, and nothing else. The values
180/// themselves are cached where they are read from — the device listing on
181/// [`ToolContext`], the rest not at all, being one command or one request.
182#[derive(Clone, Debug)]
183pub struct Limiter {
184    bucket: Arc<Mutex<Bucket>>,
185}
186
187impl Default for Limiter {
188    fn default() -> Self {
189        Self::new()
190    }
191}
192
193impl Limiter {
194    /// Requests held in reserve, and the sustained rate in requests a second.
195    const BURST: f64 = 20.0;
196    const PER_SECOND: f64 = 20.0;
197
198    pub fn new() -> Self {
199        Self {
200            bucket: Arc::new(Mutex::new(Bucket {
201                tokens: Self::BURST,
202                last: Instant::now(),
203            })),
204        }
205    }
206
207    /// Take a token, or say there was none.
208    ///
209    /// A poisoned lock answers yes. The alternative is a session whose
210    /// completion is permanently off because a request panicked once, and this
211    /// mutex guards two numbers.
212    fn allow(&self) -> bool {
213        let Ok(mut bucket) = self.bucket.lock() else {
214            return true;
215        };
216        let now = Instant::now();
217        let earned = now.duration_since(bucket.last).as_secs_f64() * Self::PER_SECOND;
218        bucket.tokens = (bucket.tokens + earned).min(Self::BURST);
219        bucket.last = now;
220        if bucket.tokens < 1.0 {
221            return false;
222        }
223        bucket.tokens -= 1.0;
224        true
225    }
226}
227
228/// Answer a `completion/complete`.
229///
230/// Infallible by construction: the return type has no error arm, because every
231/// way this can go wrong is a way that should still render an empty popup.
232pub async fn complete(
233    ctx: &ToolContext,
234    gate: &Gate,
235    limiter: &Limiter,
236    request: &CompleteRequestParams,
237) -> CompleteResult {
238    let Some(slot) = slot_for(&request.r#ref, &request.argument.name) else {
239        return nothing();
240    };
241    if !gate.offers(slot.surface()) {
242        return nothing();
243    }
244    if !limiter.allow() {
245        tracing::debug!(
246            slot = ?slot,
247            "completion refused: this session is asking faster than the limit"
248        );
249        return nothing();
250    }
251
252    let candidates = match gather(ctx, slot).await {
253        Ok(candidates) => candidates,
254        Err(why) => {
255            // Logged, not returned: see the module note. `why` is already a
256            // tool error, so it has been through the redactor.
257            tracing::debug!(slot = ?slot, %why, "completion found nothing to offer");
258            return nothing();
259        }
260    };
261    answer(ctx, &candidates, &request.argument.value)
262}
263
264/// Rank, order and cut, and say honestly how much was left out.
265fn answer(ctx: &ToolContext, candidates: &[Candidate], typed: &str) -> CompleteResult {
266    // The same normalisation the names get, so that a caller who pasted a
267    // fully qualified name out of `tailscale status` — root label and all —
268    // is matched rather than told there is nothing.
269    let typed = normalise(typed);
270    let mut matched: Vec<(u8, Kind, &str)> = candidates
271        .iter()
272        .filter_map(|candidate| {
273            Some((
274                candidate.rank(&typed)?,
275                candidate.kind,
276                candidate.value.as_str(),
277            ))
278        })
279        .collect();
280    // How well it matches first, so that typing a device's name puts that
281    // device above a user the letters merely appear in. What sort of thing it
282    // is second, which only decides between equally good matches — and so
283    // decides the whole of an empty input, where everything matches equally
284    // and the cut below would otherwise fall wherever the alphabet put it.
285    // Alphabetically last, so the same input gives the same order every time:
286    // a popup that reshuffles between keystrokes is one nobody can click.
287    matched.sort_unstable();
288
289    let total = matched.len();
290    let values: Vec<String> = matched
291        .into_iter()
292        .take(CompletionInfo::MAX_VALUES)
293        .map(|(_, _, value)| ctx.redactor.apply(value).into_owned())
294        .collect();
295    let has_more = total > values.len();
296    let mut completion = CompletionInfo::new(values).unwrap_or_default();
297    completion.total = u32::try_from(total).ok();
298    completion.has_more = Some(has_more);
299    CompleteResult::new(completion)
300}
301
302fn nothing() -> CompleteResult {
303    CompleteResult::new(CompletionInfo::default())
304}
305
306/// Everything the slot could be, unranked.
307async fn gather(ctx: &ToolContext, slot: Slot) -> crate::error::ToolResult<Vec<Candidate>> {
308    match slot {
309        Slot::Device => devices(ctx).await,
310        Slot::Peer => peers(ctx).await,
311        Slot::Subject => subjects(ctx).await,
312    }
313}
314
315/// Every device, offered as its MagicDNS name and found by any of them.
316///
317/// Shares the listing the tools resolve against, so a session that has just
318/// named a device completes without a second request.
319async fn devices(ctx: &ToolContext) -> crate::error::ToolResult<Vec<Candidate>> {
320    Ok(ctx
321        .tailnet_devices()
322        .await?
323        .iter()
324        .filter(|device| !device.name.is_empty())
325        .map(|device| {
326            let mut also = vec![
327                device.hostname.clone(),
328                device.short_name().to_owned(),
329                device.node_id.clone(),
330            ];
331            also.extend(device.addresses.iter().cloned());
332            Candidate::new(device.name.clone(), Kind::Device, also)
333        })
334        .collect())
335}
336
337/// Every peer this node can see, offered by the name a person would type.
338///
339/// Offline peers are included, and that is the case the prompt exists for:
340/// `diagnose_connectivity` is asked about a peer that cannot be reached, so a
341/// list that dropped the unreachable ones would omit every useful answer.
342/// This node is excluded, for the same reason — it can always reach itself.
343async fn peers(ctx: &ToolContext) -> crate::error::ToolResult<Vec<Candidate>> {
344    // The same reading of `status` that startup does, and the same tolerance:
345    // a node that is not running answers nothing rather than an error, which
346    // is what this wants anyway.
347    let Some(status) = crate::cli::status_document(&*ctx.local).await else {
348        return Ok(Vec::new());
349    };
350    let Some(peers) = status["Peer"].as_object() else {
351        return Ok(Vec::new());
352    };
353    Ok(peers.values().filter_map(peer_candidate).collect())
354}
355
356/// One peer, as something completable — or nothing, for a peer with no name
357/// at all.
358fn peer_candidate(peer: &serde_json::Value) -> Option<Candidate> {
359    let dns = peer["DNSName"].as_str().unwrap_or_default();
360    let hostname = peer["HostName"].as_str().unwrap_or_default();
361    // A peer with no MagicDNS name has an empty `DNSName`, in which case its
362    // hostname is the only name it has. Otherwise the short form is what a
363    // person types, and what `tailscale ping` is usually given.
364    let short = dns.split('.').next().unwrap_or_default();
365    let value = if short.is_empty() { hostname } else { short };
366    if value.is_empty() {
367        return None;
368    }
369    let mut also = vec![hostname.to_owned(), dns.to_owned()];
370    if let Some(addresses) = peer["TailscaleIPs"].as_array() {
371        also.extend(
372            addresses
373                .iter()
374                .filter_map(|address| Some(address.as_str()?.to_owned())),
375        );
376    }
377    Some(Candidate::new(value, Kind::Device, also))
378}
379
380/// Who and what an audit can be narrowed to: the tailnet's users, and its tags.
381///
382/// Tags come from both ends, because neither alone is the answer. The policy
383/// file's `tagOwners` declares every tag that may be applied, including ones
384/// nothing wears yet; the device listing shows the tags actually in use,
385/// including — on a tailnet whose policy this credential cannot read — the only
386/// ones visible at all.
387async fn subjects(ctx: &ToolContext) -> crate::error::ToolResult<Vec<Candidate>> {
388    let client = ctx.tailnet()?;
389    let mut found: Vec<(Kind, String)> = Vec::new();
390
391    let users = client
392        .get(client.tailnet_path(None, "/users"))
393        .send_as::<serde_json::Value>()
394        .await?;
395    if let Some(listed) = users["users"].as_array() {
396        found.extend(
397            listed
398                .iter()
399                .filter_map(|user| Some((Kind::User, user["loginName"].as_str()?.to_owned()))),
400        );
401    }
402
403    // The policy file is the one source here that a read-only credential may
404    // still be refused, and losing it should cost the tags it declares rather
405    // than the users listed above.
406    match client
407        .get(client.tailnet_path(None, "/acl"))
408        .send_as::<serde_json::Value>()
409        .await
410    {
411        Ok(policy) => {
412            if let Some(owners) = policy["tagOwners"].as_object() {
413                found.extend(owners.keys().map(|tag| (Kind::Tag, tag.clone())));
414            }
415        }
416        Err(why) => tracing::debug!(%why, "completion could not read the policy file for its tags"),
417    }
418
419    // Devices carry both of the remaining kinds: the tags they actually wear,
420    // which the policy above may not declare, and their own names — because
421    // the argument says "a user, tag or device" and a caller who reads that
422    // and types a device name should be met with the device rather than with
423    // nothing. They come last in `Kind` for the reason given there.
424    if let Ok(devices) = ctx.tailnet_devices().await {
425        for device in devices.iter() {
426            found.extend(device.tags.iter().map(|tag| (Kind::Tag, tag.clone())));
427            if !device.name.is_empty() {
428                found.push((Kind::Device, device.name.clone()));
429            }
430        }
431    }
432
433    // By value first, so that the same name from two sources lands adjacent
434    // and `dedup_by` can see it: a tag the policy declares and the same tag
435    // worn by a device are one candidate. Ties break on `Kind`, so the
436    // survivor is the earliest-declared label for that name. The order here is
437    // not the order offered — `answer` sorts what matches — so this exists
438    // only to make the deduplication correct.
439    found.sort_unstable_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
440    found.dedup_by(|a, b| a.1 == b.1);
441    Ok(found
442        .into_iter()
443        .filter(|(_, subject)| !subject.is_empty())
444        .map(|(kind, subject)| Candidate::new(subject, kind, []))
445        .collect())
446}
447
448#[cfg(test)]
449mod tests {
450    use std::time::Duration;
451
452    use super::*;
453
454    /// And comes back, at the rate it says.
455    ///
456    /// The clock is moved rather than waited on: a test that sleeps for a
457    /// second to watch a bucket refill is a second every run pays for.
458    #[test]
459    fn the_reserve_refills() {
460        let limiter = Limiter::new();
461        while limiter.allow() {}
462        {
463            let mut bucket = limiter.bucket.lock().expect("the bucket");
464            bucket.last -= Duration::from_secs(1);
465        }
466        assert!(
467            limiter.allow(),
468            "a second's worth of refill is {} requests",
469            Limiter::PER_SECOND
470        );
471    }
472
473    /// What a peer answers to, and what it is offered as.
474    ///
475    /// The two differ, and the difference is the point: `DNSName` arrives
476    /// fully qualified, and a name carrying the root label is one no client
477    /// should insert and no caller should have to type.
478    #[test]
479    fn a_peer_answers_to_every_spelling_and_is_offered_as_the_short_one() {
480        let peer = serde_json::json!({
481            "HostName": "laptop-1",
482            "DNSName": "laptop.example-tailnet.ts.net.",
483            "TailscaleIPs": ["100.64.0.2"]
484        });
485        let candidate = peer_candidate(&peer).expect("a named peer");
486        assert_eq!(candidate.value, "laptop");
487        assert_eq!(
488            candidate.known_as,
489            vec![
490                "100.64.0.2",
491                "laptop",
492                "laptop-1",
493                "laptop.example-tailnet.ts.net"
494            ],
495            "no spelling should carry the root label"
496        );
497        // Typed either way, because a caller may well have pasted it.
498        for typed in [
499            "laptop.example-tailnet.ts.net",
500            "laptop.example-tailnet.ts.net.",
501        ] {
502            assert_eq!(
503                candidate.rank(&normalise(typed)),
504                Some(0),
505                "`{typed}` names this peer exactly"
506            );
507        }
508    }
509
510    /// A peer with no name at all is not offered, there being nothing to offer.
511    #[test]
512    fn a_nameless_peer_is_not_offered() {
513        assert!(peer_candidate(&serde_json::json!({"TailscaleIPs": ["100.64.0.9"]})).is_none());
514    }
515}