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}