1use base64::Engine;
10use base64::engine::general_purpose::STANDARD;
11use serde_json::{Value, json};
12
13use super::{INVALID_PARAMS, Registry, RpcResult};
14use crate::error::{Error, Result};
15
16const EXTENSION: &str = "io.modelcontextprotocol/skills";
17const SCHEME: &str = "skill://";
18const TTL_MS: u64 = 300_000;
20const MAX_FILES: usize = 512;
22const MAX_BYTES: usize = 16 * 1024 * 1024;
23
24#[derive(Debug, Clone)]
26pub struct Skill {
27 name: String,
28 frontmatter: Value,
30 files: Vec<(String, Vec<u8>)>,
32}
33
34impl Skill {
35 pub fn new<P, B>(files: impl IntoIterator<Item = (P, B)>) -> Result<Self>
38 where
39 P: Into<String>,
40 B: Into<Vec<u8>>,
41 {
42 let mut files: Vec<(String, Vec<u8>)> = files
43 .into_iter()
44 .map(|(p, b)| (p.into(), b.into()))
45 .collect();
46 for (p, _) in &files {
47 if p.is_empty()
48 || p.starts_with('/')
49 || p.ends_with('/')
50 || p.split('/').any(|s| matches!(s, "" | "." | ".."))
51 {
52 return Err(Error::invalid(format!("invalid skill file path {p:?}")));
53 }
54 }
55 files.sort_by(|a, b| (a.0 != "SKILL.md", &a.0).cmp(&(b.0 != "SKILL.md", &b.0)));
56 if files.windows(2).any(|w| w[0].0 == w[1].0) {
57 return Err(Error::invalid("a skill lists a file twice"));
58 }
59 let Some((_, md)) = files.first().filter(|(p, _)| p == "SKILL.md") else {
60 return Err(Error::invalid("a skill needs a SKILL.md"));
61 };
62 let frontmatter = frontmatter(md)?;
63 let name = frontmatter["name"].as_str().unwrap_or_default().to_string();
64 if !valid_name(&name) {
65 return Err(Error::invalid(format!(
66 "skill name {name:?}: 1-64 of a-z, 0-9 and single hyphens inside"
67 )));
68 }
69 if !frontmatter["description"].is_string() {
70 return Err(Error::invalid(format!("skill {name}: no description")));
71 }
72 let bytes: usize = files.iter().map(|(_, b)| b.len()).sum();
73 if files.len() > MAX_FILES || bytes > MAX_BYTES {
74 return Err(Error::invalid(format!(
75 "skill {name}: {} files, {bytes} bytes; hosts only promise {MAX_FILES} and {MAX_BYTES}",
76 files.len()
77 )));
78 }
79 Ok(Self {
80 name,
81 frontmatter,
82 files,
83 })
84 }
85
86 pub fn name(&self) -> &str {
87 &self.name
88 }
89
90 fn root(&self) -> String {
91 format!("{SCHEME}{}", self.name)
92 }
93
94 fn uri(&self, path: &str) -> String {
95 format!("{}/{path}", self.root())
96 }
97
98 fn entry(&self) -> Value {
100 let resources: Vec<Value> = self
101 .files
102 .iter()
103 .map(|(p, b)| json!({"uri": self.uri(p), "digest": digest(b), "size": b.len()}))
104 .collect();
105 json!({
106 "uri": self.uri("SKILL.md"),
107 "frontmatter": self.frontmatter,
108 "resources": resources,
109 })
110 }
111
112 fn resource(&self, path: &str, bytes: &[u8]) -> Value {
115 let mut r = json!({
116 "uri": self.uri(path),
117 "name": path.rsplit('/').next().unwrap_or(path),
118 "mimeType": mime(path, bytes),
119 "size": bytes.len(),
120 });
121 if path == "SKILL.md" {
122 r["name"] = json!(self.name);
123 r["description"] = self.frontmatter["description"].clone();
124 }
125 r
126 }
127}
128
129fn frontmatter(md: &[u8]) -> Result<Value> {
131 let bad = || Error::invalid("SKILL.md must open with YAML frontmatter between --- lines");
132 let text = std::str::from_utf8(md).map_err(|_| bad())?;
133 let rest = text
134 .strip_prefix("---\n")
135 .or_else(|| text.strip_prefix("---\r\n"))
136 .ok_or_else(bad)?;
137 let end = rest
138 .match_indices("\n---")
139 .map(|(i, _)| i)
140 .find(|&i| matches!(rest[i + 4..].chars().next(), None | Some('\n' | '\r')))
141 .ok_or_else(bad)?;
142 let v: Value = serde_yaml_ng::from_str(&rest[..end])
143 .map_err(|e| Error::invalid(format!("SKILL.md frontmatter: {e}")))?;
144 if !v.is_object() {
145 return Err(bad());
146 }
147 Ok(v)
148}
149
150fn valid_name(n: &str) -> bool {
152 (1..=64).contains(&n.len())
153 && n.bytes()
154 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
155 && !n.starts_with('-')
156 && !n.ends_with('-')
157 && !n.contains("--")
158}
159
160fn digest(b: &[u8]) -> String {
161 let d = ring::digest::digest(&ring::digest::SHA256, b);
162 let hex: String = d.as_ref().iter().map(|x| format!("{x:02x}")).collect();
163 format!("sha256:{hex}")
164}
165
166fn mime(path: &str, bytes: &[u8]) -> &'static str {
167 let ext = path.rsplit_once('.').map(|(_, e)| e).unwrap_or_default();
168 match ext {
169 "md" => "text/markdown",
170 "yaml" | "yml" => "application/yaml",
171 "json" => "application/json",
172 "sh" => "text/x-shellscript",
173 "py" => "text/x-python",
174 _ if std::str::from_utf8(bytes).is_ok() => "text/plain",
175 _ => "application/octet-stream",
176 }
177}
178
179impl Registry {
180 pub fn skills(mut self, skills: impl IntoIterator<Item = Skill>) -> Result<Self> {
182 for s in skills {
183 if self.skills.iter().any(|t| t.name == s.name) {
184 return Err(Error::invalid(format!("skill {:?} added twice", s.name)));
185 }
186 self.skills.push(s);
187 }
188 Ok(self)
189 }
190}
191
192pub(super) fn declare(skills: &[Skill], caps: &mut Value) {
195 if !skills.is_empty() {
196 caps["resources"] = json!({"listChanged": false});
197 caps["extensions"] = json!({EXTENSION: {"directoryRead": true}});
198 }
199}
200
201pub(super) fn dispatch(skills: &[Skill], method: &str, params: &Value) -> Option<RpcResult> {
204 if skills.is_empty() {
205 return None;
206 }
207 let bad = |m: String| Err((INVALID_PARAMS, m));
208 let cached = |mut v: Value| {
209 v["resultType"] = json!("complete");
210 v["ttlMs"] = json!(TTL_MS);
211 v["cacheScope"] = json!("public");
212 Ok(v)
213 };
214 let uri = || params.get("uri").and_then(Value::as_str);
215 if params.get("cursor").is_some_and(|c| !c.is_string()) {
217 return Some(bad("cursor must be a string".into()));
218 }
219 Some(match method {
220 "skills/list" => {
221 cached(json!({"skills": skills.iter().map(Skill::entry).collect::<Vec<_>>()}))
222 }
223 "skills/get" => {
224 let Some(u) = uri() else {
225 return Some(bad("skills/get needs a uri".into()));
226 };
227 match skills.iter().find(|s| s.uri("SKILL.md") == u) {
228 Some(s) => cached(json!({"skill": s.entry()})),
229 None => bad(format!("No skill is served at {u}")),
230 }
231 }
232 "resources/list" => {
233 let r: Vec<Value> = skills
234 .iter()
235 .flat_map(|s| s.files.iter().map(move |(p, b)| s.resource(p, b)))
236 .collect();
237 cached(json!({"resources": r}))
238 }
239 "resources/templates/list" => cached(json!({"resourceTemplates": []})),
240 "resources/read" => {
241 let Some(u) = uri() else {
242 return Some(bad("resources/read needs a uri".into()));
243 };
244 let found = skills
245 .iter()
246 .find_map(|s| s.files.iter().find(|(p, _)| s.uri(p) == u));
247 let Some((p, b)) = found else {
248 return Some(bad(format!("no resource at {u}")));
249 };
250 let mut c = json!({"uri": u, "mimeType": mime(p, b)});
251 match std::str::from_utf8(b) {
252 Ok(t) => c["text"] = json!(t),
253 Err(_) => c["blob"] = json!(STANDARD.encode(b)),
254 }
255 cached(json!({"contents": [c]}))
256 }
257 "resources/directory/read" => {
258 let Some(u) = uri() else {
259 return Some(bad("resources/directory/read needs a uri".into()));
260 };
261 match directory(skills, u) {
262 Some(r) => Ok(json!({"resultType": "complete", "resources": r})),
263 None => bad(format!("{u} is not a directory resource")),
264 }
265 }
266 _ => return None,
267 })
268}
269
270fn directory(skills: &[Skill], uri: &str) -> Option<Vec<Value>> {
273 for s in skills {
274 let root = s.root();
275 let dir = if uri == root {
276 ""
277 } else if let Some(d) = uri.strip_prefix(&root).and_then(|d| d.strip_prefix('/')) {
278 d
279 } else {
280 continue;
281 };
282 let prefix = if dir.is_empty() {
283 String::new()
284 } else {
285 format!("{dir}/")
286 };
287 let mut out: Vec<Value> = Vec::new();
288 let mut subdirs: Vec<&str> = Vec::new();
289 for (p, b) in &s.files {
290 let Some(rest) = p.strip_prefix(&prefix) else {
291 continue;
292 };
293 match rest.split_once('/') {
294 None => out.push(s.resource(p, b)),
295 Some((d, _)) if !subdirs.contains(&d) => subdirs.push(d),
296 Some(_) => {}
297 }
298 }
299 if out.is_empty() && subdirs.is_empty() {
300 return None;
301 }
302 out.extend(subdirs.into_iter().map(|d| {
303 json!({"uri": s.uri(&format!("{prefix}{d}")), "name": d, "mimeType": "inode/directory"})
304 }));
305 return Some(out);
306 }
307 None
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 const MD: &str = "---\nname: pdf-processing\ndescription: Extract, fill, and assemble PDF documents\n---\n\n# PDF processing\n\nChoose the matching template from `templates/`.\n";
315
316 fn skill() -> Skill {
317 Skill::new([
318 (
319 "templates/purchase-order.md",
320 "# Purchase order\n\nSupplier:\nItems:\n",
321 ),
322 ("SKILL.md", MD),
323 ("templates/invoice.md", "# Invoice\n\nCustomer:\nAmount:\n"),
324 ])
325 .unwrap()
326 }
327
328 fn call(method: &str, params: Value) -> RpcResult {
329 dispatch(&[skill()], method, ¶ms).expect("a skills method")
330 }
331
332 #[test]
333 fn entry_matches_the_spec_example() {
334 let r = call(
336 "skills/get",
337 json!({"uri": "skill://pdf-processing/SKILL.md"}),
338 )
339 .unwrap();
340 assert_eq!(r["resultType"], "complete");
341 assert_eq!(r["cacheScope"], "public");
342 assert_eq!(
343 r["skill"],
344 json!({
345 "uri": "skill://pdf-processing/SKILL.md",
346 "frontmatter": {"name": "pdf-processing", "description": "Extract, fill, and assemble PDF documents"},
347 "resources": [
348 {"uri": "skill://pdf-processing/SKILL.md", "digest": "sha256:99b737495721155ece826d57521e2d66141ebdc1344a400487481ea2642ab19e", "size": 151},
349 {"uri": "skill://pdf-processing/templates/invoice.md", "digest": "sha256:61f4ea6d2c75fde1b4977219e7e3107d491c3c26aefb6686e84d6281c088d9ee", "size": 29},
350 {"uri": "skill://pdf-processing/templates/purchase-order.md", "digest": "sha256:f2ff774b1737ff3dec81c47946f9976f18a1a9f69dda0a81f22eabd95173c158", "size": 35},
351 ],
352 })
353 );
354 let l = call("skills/list", json!({})).unwrap();
355 assert_eq!(l["skills"], json!([r["skill"]]));
356 assert_eq!(
357 call("skills/get", json!({"uri": "skill://nope/SKILL.md"}))
358 .unwrap_err()
359 .0,
360 INVALID_PARAMS
361 );
362 }
363
364 #[test]
365 fn files_read_as_resources() {
366 let r = call(
367 "resources/read",
368 json!({"uri": "skill://pdf-processing/SKILL.md"}),
369 )
370 .unwrap();
371 assert_eq!(
372 r["contents"],
373 json!([{"uri": "skill://pdf-processing/SKILL.md", "mimeType": "text/markdown", "text": MD}])
374 );
375 assert_eq!(
376 call(
377 "resources/read",
378 json!({"uri": "skill://pdf-processing/x.md"})
379 )
380 .unwrap_err()
381 .0,
382 INVALID_PARAMS
383 );
384 let l = call("resources/list", json!({})).unwrap();
385 let l = l["resources"].as_array().unwrap();
386 assert_eq!(l.len(), 3);
387 assert_eq!(l[0]["name"], "pdf-processing");
388 assert_eq!(
389 l[0]["description"],
390 "Extract, fill, and assemble PDF documents"
391 );
392 assert_eq!(l[1]["name"], "invoice.md");
393 }
394
395 #[test]
396 fn directories_list_direct_children() {
397 let root = call(
398 "resources/directory/read",
399 json!({"uri": "skill://pdf-processing"}),
400 )
401 .unwrap();
402 let names: Vec<_> = root["resources"]
403 .as_array()
404 .unwrap()
405 .iter()
406 .map(|r| (r["name"].as_str().unwrap(), r["mimeType"].as_str().unwrap()))
407 .collect();
408 assert_eq!(
409 names,
410 [
411 ("pdf-processing", "text/markdown"),
412 ("templates", "inode/directory")
413 ]
414 );
415 let t = call(
416 "resources/directory/read",
417 json!({"uri": "skill://pdf-processing/templates"}),
418 )
419 .unwrap();
420 assert_eq!(t["resources"].as_array().unwrap().len(), 2);
421 for not_dir in [
422 "skill://pdf-processing/SKILL.md",
423 "skill://pdf-processing/nope",
424 "skill://pdf",
425 ] {
426 assert_eq!(
427 call("resources/directory/read", json!({"uri": not_dir}))
428 .unwrap_err()
429 .0,
430 INVALID_PARAMS,
431 "{not_dir}"
432 );
433 }
434 }
435
436 #[test]
437 fn frontmatter_passes_through_verbatim() {
438 let md = "---\nname: isb\ndescription: >\n Two\n lines.\nlicense: MIT\nmetadata:\n k: v\n---\nbody\n---\nmore\n";
439 let s = Skill::new([("SKILL.md", md)]).unwrap();
440 assert_eq!(
441 s.entry()["frontmatter"],
442 json!({"name": "isb", "description": "Two lines.\n", "license": "MIT", "metadata": {"k": "v"}})
443 );
444 }
445
446 #[test]
447 fn bad_skills_are_refused() {
448 let ok = "---\nname: a\ndescription: d\n---\n";
449 for files in [
450 vec![("README.md", ok)],
451 vec![("SKILL.md", "no frontmatter")],
452 vec![("SKILL.md", "---\nname: Bad_Name\ndescription: d\n---\n")],
453 vec![("SKILL.md", "---\nname: a--b\ndescription: d\n---\n")],
454 vec![("SKILL.md", "---\nname: a\n---\n")],
455 vec![("SKILL.md", ok), ("../x", "")],
456 vec![("SKILL.md", ok), ("a//b", "")],
457 vec![("SKILL.md", ok), ("SKILL.md", ok)],
458 ] {
459 assert!(Skill::new(files.clone()).is_err(), "{files:?}");
460 }
461 }
462}