1use serde::{Deserialize, Serialize};
12use serde_json::{Value, json};
13use std::collections::BTreeMap;
14
15pub const DEFAULT_PREFIX: &str = "@skill:";
17pub const SKILL_SCHEME: &str = "skill://";
19pub const SKILL_MIME: &str = "text/x-skill+markdown";
21
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct SkillMeta {
25 pub name: String,
26 #[serde(default)]
27 pub description: String,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub when_to_use: Option<String>,
30 #[serde(default, skip_serializing_if = "Vec::is_empty")]
31 pub arguments: Vec<Value>,
32 pub source: SkillSourceRef,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct SkillSourceRef {
38 pub server: String,
39 #[serde(rename = "kind")]
40 pub kind: SkillSourceKind,
41 #[serde(rename = "ref")]
43 pub reference: String,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "lowercase")]
48pub enum SkillSourceKind {
49 Prompt,
50 Resource,
51}
52
53#[derive(Debug, Clone, PartialEq)]
55pub struct SkillBody {
56 pub name: String,
57 pub hash: String,
58 pub body: String,
59}
60
61pub trait SkillServer {
64 fn server_name(&self) -> String;
65 fn supports_prompts(&self) -> bool;
66 fn supports_resources(&self) -> bool;
67 fn list_prompts(&self) -> Result<Vec<::mcp::wire::Prompt>, String>;
68 fn get_prompt(&self, name: &str, arguments: Option<Value>) -> Result<Vec<Value>, String>;
69 fn list_resources(&self) -> Result<Vec<::mcp::wire::Resource>, String>;
70 fn read_resource(&self, uri: &str) -> Result<String, String>;
71}
72
73impl SkillServer for crate::mcp::client::McpClient {
74 fn server_name(&self) -> String {
75 self.name().to_string()
76 }
77 fn supports_prompts(&self) -> bool {
78 self.capabilities().supports_prompts()
79 }
80 fn supports_resources(&self) -> bool {
81 self.capabilities().supports_resources()
82 }
83 fn list_prompts(&self) -> Result<Vec<::mcp::wire::Prompt>, String> {
84 crate::mcp::client::McpClient::list_prompts(self).map_err(|e| e.to_string())
85 }
86 fn get_prompt(&self, name: &str, arguments: Option<Value>) -> Result<Vec<Value>, String> {
87 crate::mcp::client::McpClient::get_prompt(self, name, arguments)
88 .map(|r| r.messages)
89 .map_err(|e| e.to_string())
90 }
91 fn list_resources(&self) -> Result<Vec<::mcp::wire::Resource>, String> {
92 crate::mcp::client::McpClient::list_resources(self).map_err(|e| e.to_string())
93 }
94 fn read_resource(&self, uri: &str) -> Result<String, String> {
95 crate::mcp::client::McpClient::read_resource(self, uri)
96 .map(|r| r.text())
97 .map_err(|e| e.to_string())
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum Discover {
104 Prompts,
105 Resources,
106 Auto,
107}
108
109#[derive(Debug, Default)]
111pub struct Catalogue {
112 skills: BTreeMap<String, SkillMeta>,
113 bodies: BTreeMap<String, SkillBody>, max_bytes: usize,
115 pub prefix: String,
116 pub errors: BTreeMap<String, String>,
118}
119
120impl Catalogue {
121 pub fn new(prefix: &str, max_bytes: usize) -> Catalogue {
122 Catalogue {
123 prefix: prefix.to_string(),
124 max_bytes,
125 ..Default::default()
126 }
127 }
128
129 pub fn discover(
133 &mut self,
134 server: &dyn SkillServer,
135 mode: Discover,
136 filter: Option<&str>,
137 ) -> Vec<String> {
138 let name = server.server_name();
139 let mut found = Vec::new();
140 let want_prompts =
141 matches!(mode, Discover::Prompts | Discover::Auto) && server.supports_prompts();
142 let want_resources =
143 matches!(mode, Discover::Resources | Discover::Auto) && server.supports_resources();
144 if want_prompts {
145 match server.list_prompts() {
146 Ok(prompts) => {
147 for p in prompts {
148 if !passes(filter, &p.name) {
149 continue;
150 }
151 let (description, when) =
152 split_when(p.description.as_deref().unwrap_or(""));
153 let meta = SkillMeta {
154 name: p.name.clone(),
155 description,
156 when_to_use: when,
157 arguments: p
158 .arguments
159 .iter()
160 .map(|a| serde_json::to_value(a).unwrap_or(Value::Null))
161 .collect(),
162 source: SkillSourceRef {
163 server: name.clone(),
164 kind: SkillSourceKind::Prompt,
165 reference: p.name.clone(),
166 },
167 };
168 if self.insert(meta) {
169 found.push(p.name);
170 }
171 }
172 }
173 Err(e) => {
174 self.errors
175 .insert(name.clone(), format!("prompts/list: {e}"));
176 }
177 }
178 }
179 if want_resources {
180 match server.list_resources() {
181 Ok(resources) => {
182 for r in resources {
183 let is_skill = r.uri.starts_with(SKILL_SCHEME)
184 || r.mime_type.as_deref() == Some(SKILL_MIME);
185 if !is_skill {
186 continue;
187 }
188 let skill_name = r
189 .uri
190 .strip_prefix(SKILL_SCHEME)
191 .map(|s| s.trim_matches('/').to_string())
192 .filter(|s| !s.is_empty())
193 .or_else(|| r.name.clone())
194 .unwrap_or_else(|| r.uri.clone());
195 if skill_name.is_empty() {
197 continue;
198 }
199 if !passes(filter, &skill_name) {
200 continue;
201 }
202 let (description, when) =
203 split_when(r.description.as_deref().unwrap_or(""));
204 let meta = SkillMeta {
205 name: skill_name.clone(),
206 description,
207 when_to_use: when,
208 arguments: Vec::new(),
209 source: SkillSourceRef {
210 server: name.clone(),
211 kind: SkillSourceKind::Resource,
212 reference: r.uri.clone(),
213 },
214 };
215 if self.insert(meta) {
216 found.push(skill_name);
217 }
218 }
219 }
220 Err(e) => {
221 self.errors
222 .entry(name.clone())
223 .and_modify(|m| m.push_str(&format!("; resources/list: {e}")))
224 .or_insert(format!("resources/list: {e}"));
225 }
226 }
227 }
228 found
229 }
230
231 fn insert(&mut self, meta: SkillMeta) -> bool {
232 if let Some(existing) = self.skills.get(&meta.name) {
233 if existing.source == meta.source {
235 self.skills.insert(meta.name.clone(), meta);
236 return true;
237 }
238 return false;
239 }
240 self.skills.insert(meta.name.clone(), meta);
241 true
242 }
243
244 pub fn forget_server(&mut self, server: &str) {
246 self.skills.retain(|_, m| m.source.server != server);
247 self.errors.remove(server);
248 }
249
250 pub fn get(&self, name: &str) -> Option<&SkillMeta> {
251 self.skills.get(name)
252 }
253 pub fn names(&self) -> Vec<String> {
254 self.skills.keys().cloned().collect()
255 }
256 pub fn len(&self) -> usize {
257 self.skills.len()
258 }
259 pub fn is_empty(&self) -> bool {
260 self.skills.is_empty()
261 }
262
263 pub fn list_value(&self) -> Value {
265 json!({
266 "skills": self.skills.values().map(|m| json!({
267 "name": m.name, "description": m.description, "when_to_use": m.when_to_use,
268 "arguments": m.arguments, "source": {"server": m.source.server, "kind": m.source.kind}
269 })).collect::<Vec<_>>(),
270 "errors": self.errors,
271 })
272 }
273
274 pub fn render_catalogue(&self) -> Option<String> {
277 if self.skills.is_empty() {
278 return None;
279 }
280 let mut out = format!(
281 "Available skills (reference one as {}<name> or call skills.load to read its full instructions):\n",
282 self.prefix
283 );
284 for m in self.skills.values() {
285 out.push_str(&format!("- {}: {}", m.name, m.description));
286 if let Some(w) = &m.when_to_use {
287 out.push_str(&format!(" (use when: {w})"));
288 }
289 out.push('\n');
290 }
291 Some(out)
292 }
293
294 pub fn load(
297 &mut self,
298 name: &str,
299 arguments: Option<Value>,
300 servers: &dyn Fn(&str) -> Option<std::sync::Arc<dyn SkillServer>>,
301 ) -> Result<SkillBody, String> {
302 let meta = self
303 .skills
304 .get(name)
305 .cloned()
306 .ok_or_else(|| format!("unknown skill {name:?}"))?;
307 let server = servers(&meta.source.server).ok_or_else(|| {
308 format!(
309 "skill {name:?}: server {:?} is not connected",
310 meta.source.server
311 )
312 })?;
313 let text = match meta.source.kind {
314 SkillSourceKind::Prompt => {
315 let messages = server.get_prompt(&meta.source.reference, arguments)?;
316 prompt_messages_text(&messages)
317 }
318 SkillSourceKind::Resource => server.read_resource(&meta.source.reference)?,
319 };
320 if text.trim().is_empty() {
321 return Err(format!("skill {name:?} has an empty body"));
322 }
323 let text = if text.len() > self.max_bytes {
324 let mut cut = self.max_bytes;
325 while !text.is_char_boundary(cut) {
326 cut -= 1;
327 }
328 format!(
329 "{}\n\n[skill body truncated to skills.max_bytes = {} bytes]",
330 &text[..cut],
331 self.max_bytes
332 )
333 } else {
334 text
335 };
336 let hash = crate::sha::sha256_hex(text.as_bytes());
337 let body = SkillBody {
338 name: name.to_string(),
339 hash: hash.clone(),
340 body: text,
341 };
342 self.bodies.insert(hash, body.clone());
343 Ok(body)
344 }
345
346 pub fn body(&self, hash: &str) -> Option<&SkillBody> {
348 self.bodies.get(hash)
349 }
350
351 pub fn evict_except(&mut self, keep: &[String]) {
353 self.bodies.retain(|h, _| keep.iter().any(|k| k == h));
354 }
355
356 pub fn references(&self, text: &str) -> Vec<String> {
358 find_references(text, &self.prefix)
359 }
360}
361
362pub fn find_references(text: &str, prefix: &str) -> Vec<String> {
365 let mut out: Vec<String> = Vec::new();
366 if prefix.is_empty() {
367 return out;
368 }
369 let mut rest = text;
370 while let Some(pos) = rest.find(prefix) {
371 let after = &rest[pos + prefix.len()..];
372 let name: String = after
373 .chars()
374 .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
375 .collect();
376 let name = name.trim_end_matches(['.', '/']).to_string();
377 let consumed = name.len().min(after.len());
378 if !name.is_empty() && !out.contains(&name) {
379 out.push(name);
380 }
381 rest = &after[consumed..];
382 }
383 out
384}
385
386pub fn prompt_messages_text(messages: &[Value]) -> String {
388 let mut out = String::new();
389 for m in messages {
390 let content = m.get("content").unwrap_or(&Value::Null);
391 let text = match content {
392 Value::String(s) => s.clone(),
393 Value::Object(o) => o
394 .get("text")
395 .and_then(Value::as_str)
396 .unwrap_or("")
397 .to_string(),
398 Value::Array(parts) => parts
399 .iter()
400 .filter_map(|p| p.get("text").and_then(Value::as_str))
401 .collect::<Vec<_>>()
402 .join("\n"),
403 _ => String::new(),
404 };
405 if !text.is_empty() {
406 if !out.is_empty() {
407 out.push_str("\n\n");
408 }
409 out.push_str(&text);
410 }
411 }
412 out
413}
414
415pub fn render_bodies(bodies: &[&SkillBody]) -> Option<String> {
417 if bodies.is_empty() {
418 return None;
419 }
420 let mut out = String::from("Loaded skills — follow these instructions when relevant:\n");
421 for b in bodies {
422 out.push_str(&format!("\n### Skill: {}\n{}\n", b.name, b.body.trim()));
423 }
424 Some(out)
425}
426
427fn passes(filter: Option<&str>, name: &str) -> bool {
428 match filter {
429 None => true,
430 Some(f) => {
431 let f = f.trim();
432 if let Some(prefix) = f.strip_suffix('*') {
433 name.starts_with(prefix)
434 } else {
435 f == name || f.is_empty()
436 }
437 }
438 }
439}
440
441fn split_when(desc: &str) -> (String, Option<String>) {
443 for marker in ["When to use:", "when to use:", "Use when:", "use when:"] {
444 if let Some((a, b)) = desc.split_once(marker) {
445 let w = b.trim();
446 return (
447 a.trim().trim_end_matches('.').to_string(),
448 (!w.is_empty()).then(|| w.to_string()),
449 );
450 }
451 }
452 (desc.trim().to_string(), None)
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458 use ::mcp::wire::{Prompt, PromptArgument, Resource};
459
460 struct Fake {
461 name: String,
462 prompts: Vec<Prompt>,
463 resources: Vec<Resource>,
464 bodies: BTreeMap<String, String>,
465 }
466 impl SkillServer for Fake {
467 fn server_name(&self) -> String {
468 self.name.clone()
469 }
470 fn supports_prompts(&self) -> bool {
471 !self.prompts.is_empty()
472 }
473 fn supports_resources(&self) -> bool {
474 !self.resources.is_empty()
475 }
476 fn list_prompts(&self) -> Result<Vec<Prompt>, String> {
477 Ok(self.prompts.clone())
478 }
479 fn get_prompt(&self, name: &str, arguments: Option<Value>) -> Result<Vec<Value>, String> {
480 let body = self.bodies.get(name).cloned().ok_or("no such prompt")?;
481 let body = match arguments
482 .and_then(|a| a.get("target").and_then(Value::as_str).map(str::to_string))
483 {
484 Some(t) => body.replace("{target}", &t),
485 None => body,
486 };
487 Ok(vec![
488 json!({"role": "user", "content": {"type": "text", "text": body}}),
489 ])
490 }
491 fn list_resources(&self) -> Result<Vec<Resource>, String> {
492 Ok(self.resources.clone())
493 }
494 fn read_resource(&self, uri: &str) -> Result<String, String> {
495 self.bodies
496 .get(uri)
497 .cloned()
498 .ok_or("no such resource".into())
499 }
500 }
501
502 fn fake() -> Fake {
503 Fake {
504 name: "skills".into(),
505 prompts: vec![
506 Prompt {
507 name: "review-pr".into(),
508 title: None,
509 description: Some(
510 "Review a pull request. When to use: any code review request".into(),
511 ),
512 arguments: vec![PromptArgument {
513 name: "target".into(),
514 title: None,
515 description: None,
516 required: Some(false),
517 }],
518 },
519 Prompt {
520 name: "internal-tool".into(),
521 title: None,
522 description: None,
523 arguments: vec![],
524 },
525 ],
526 resources: vec![
527 Resource {
528 uri: "skill://deploy".into(),
529 name: Some("deploy".into()),
530 title: None,
531 description: Some("Deploy safely".into()),
532 mime_type: Some(SKILL_MIME.into()),
533 },
534 Resource {
535 uri: "file:///readme.md".into(),
536 name: None,
537 title: None,
538 description: None,
539 mime_type: Some("text/markdown".into()),
540 },
541 Resource {
542 uri: "notes://x".into(),
543 name: Some("x".into()),
544 title: None,
545 description: None,
546 mime_type: Some(SKILL_MIME.into()),
547 },
548 ],
549 bodies: [
550 (
551 "review-pr".to_string(),
552 "# Review PR\nLook at {target} carefully.".to_string(),
553 ),
554 ("internal-tool".to_string(), "internal".to_string()),
555 (
556 "skill://deploy".to_string(),
557 "# Deploy\n1. plan 2. apply".to_string(),
558 ),
559 ("notes://x".to_string(), "x body".to_string()),
560 ]
561 .into_iter()
562 .collect(),
563 }
564 }
565
566 #[test]
567 fn discovery_over_prompts_and_resources_with_filters() {
568 let f = fake();
569 let mut c = Catalogue::new(DEFAULT_PREFIX, 1024);
570 let found = c.discover(&f, Discover::Auto, None);
571 assert_eq!(found, vec!["review-pr", "internal-tool", "deploy", "x"]);
572 let m = c.get("review-pr").unwrap();
573 assert_eq!(m.description, "Review a pull request");
574 assert_eq!(m.when_to_use.as_deref(), Some("any code review request"));
575 assert_eq!(m.arguments.len(), 1);
576 assert_eq!(
577 c.get("deploy").unwrap().source.kind,
578 SkillSourceKind::Resource
579 );
580 assert!(
581 c.get("readme.md").is_none(),
582 "a plain markdown resource is not a skill"
583 );
584 let cat = c.render_catalogue().unwrap();
585 assert!(
586 cat.contains("- review-pr: Review a pull request (use when: any code review request)"),
587 "{cat}"
588 );
589 let mut c2 = Catalogue::new(DEFAULT_PREFIX, 1024);
591 assert_eq!(
592 c2.discover(&f, Discover::Prompts, Some("review-*")),
593 vec!["review-pr"]
594 );
595 let mut other = fake();
597 other.name = "other".into();
598 assert!(c.discover(&other, Discover::Auto, None).is_empty());
599 assert_eq!(c.get("deploy").unwrap().source.server, "skills");
600 c.forget_server("skills");
601 assert!(c.is_empty());
602 }
603
604 #[test]
605 fn load_caches_by_hash_truncates_and_renders() {
606 let f = std::sync::Arc::new(fake());
607 let mut c = Catalogue::new(DEFAULT_PREFIX, 30);
608 c.discover(&*f, Discover::Auto, None);
609 let f2 = f.clone();
610 let servers = move |n: &str| -> Option<std::sync::Arc<dyn SkillServer>> {
611 (n == "skills").then(|| f2.clone() as std::sync::Arc<dyn SkillServer>)
612 };
613 let b = c
614 .load("review-pr", Some(json!({"target": "PR #7"})), &servers)
615 .unwrap();
616 assert!(b.body.contains("PR #7"));
617 assert!(b.body.contains("truncated to skills.max_bytes"));
618 assert!(c.body(&b.hash).is_some());
619 let d = c.load("deploy", None, &servers).unwrap();
620 assert!(d.body.starts_with("# Deploy"));
621 assert!(c.load("nope", None, &servers).is_err());
622 assert!(
623 c.load("deploy", None, &|_| None).is_err(),
624 "server not connected"
625 );
626 let block = render_bodies(&[&b, &d]).unwrap();
627 assert!(block.contains("### Skill: review-pr") && block.contains("### Skill: deploy"));
628 c.evict_except(std::slice::from_ref(&d.hash));
629 assert!(c.body(&b.hash).is_none());
630 assert!(c.body(&d.hash).is_some());
631 }
632
633 #[test]
634 fn references_are_found_and_deduped() {
635 let refs = find_references(
636 "please @skill:review-pr this, then @skill:deploy. Also @skill:review-pr again and @skill:",
637 "@skill:",
638 );
639 assert_eq!(refs, vec!["review-pr", "deploy"]);
640 assert!(find_references("nothing here", "@skill:").is_empty());
641 assert_eq!(find_references("use +s:x/y.", "+s:"), vec!["x/y"]);
642 assert_eq!(
643 prompt_messages_text(&[
644 json!({"content": "a"}),
645 json!({"content": [{"type": "text", "text": "b"}, {"type": "image"}]})
646 ]),
647 "a\n\nb"
648 );
649 }
650}