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