@dudamel/mcp-agents 2026.918.1 → 2026.923.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -462,33 +462,239 @@ function registerAgentTools(server, client, ctx) {
462
462
 
463
463
  // src/tools/skills.ts
464
464
  import { z as z2 } from "zod";
465
+
466
+ // src/skill-bundle.ts
467
+ import { createHash } from "node:crypto";
468
+ import { mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises";
469
+ import { dirname, join, relative, resolve, sep } from "node:path";
470
+ var EXCLUDED = /* @__PURE__ */ new Set([".git", "node_modules", "__pycache__", ".venv", ".DS_Store"]);
471
+ var MAX_PATH_LENGTH = 512;
472
+ var MAX_FILES = 200;
473
+ var MAX_BUNDLE_BYTES = 2 * 1024 * 1024;
474
+ var SKILL_MD = "SKILL.md";
475
+ function digest(content) {
476
+ return {
477
+ bytes: Buffer.byteLength(content, "utf8"),
478
+ sha256: createHash("sha256").update(content, "utf8").digest("hex")
479
+ };
480
+ }
481
+ function digestFiles(files) {
482
+ return files.map((f) => ({ path: f.path, ...digest(f.content) }));
483
+ }
484
+ function decodeUtf8(raw, relPath, notText) {
485
+ if (raw.includes(0)) {
486
+ notText.push(relPath);
487
+ return void 0;
488
+ }
489
+ try {
490
+ return new TextDecoder("utf-8", { fatal: true }).decode(raw);
491
+ } catch {
492
+ notText.push(relPath);
493
+ return void 0;
494
+ }
495
+ }
496
+ async function readSkillDir(skillDir) {
497
+ const root = resolve(skillDir);
498
+ let realRoot;
499
+ try {
500
+ realRoot = await realpath(root);
501
+ } catch {
502
+ throw new Error(`skillDir not found or not readable: ${root}`);
503
+ }
504
+ let content;
505
+ try {
506
+ content = await readFile(join(realRoot, SKILL_MD), "utf8");
507
+ } catch {
508
+ throw new Error(
509
+ `${SKILL_MD} not found at the root of ${root}. A skill directory must contain ${SKILL_MD}; use download-skill with outDir to get the expected layout.`
510
+ );
511
+ }
512
+ const files = [];
513
+ const notText = [];
514
+ const escaping = [];
515
+ const tooLongPath = [];
516
+ async function walk(dir) {
517
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
518
+ if (EXCLUDED.has(entry.name)) continue;
519
+ const full = join(dir, entry.name);
520
+ const relPath = relative(realRoot, full).split(sep).join("/");
521
+ if (relPath === SKILL_MD) continue;
522
+ let resolved = full;
523
+ if (entry.isSymbolicLink()) {
524
+ try {
525
+ resolved = await realpath(full);
526
+ } catch {
527
+ escaping.push(relPath);
528
+ continue;
529
+ }
530
+ if (resolved !== realRoot && !resolved.startsWith(realRoot + sep)) {
531
+ escaping.push(relPath);
532
+ continue;
533
+ }
534
+ }
535
+ const isDir = entry.isDirectory() || entry.isSymbolicLink() && await isDirectory(resolved);
536
+ if (isDir) {
537
+ await walk(resolved);
538
+ continue;
539
+ }
540
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
541
+ if (relPath.length > MAX_PATH_LENGTH) {
542
+ tooLongPath.push(relPath);
543
+ continue;
544
+ }
545
+ const text = decodeUtf8(await readFile(resolved), relPath, notText);
546
+ if (text !== void 0) files.push({ path: relPath, content: text });
547
+ }
548
+ }
549
+ await walk(realRoot);
550
+ if (notText.length > 0) {
551
+ throw new Error(
552
+ `A skill can only hold text files (the column that stores them is text, not bytes). Not text: ${notText.sort().join(", ")}. Remove them from the directory, or keep them out of the skill entirely.`
553
+ );
554
+ }
555
+ if (escaping.length > 0) {
556
+ throw new Error(
557
+ `These symlinks point outside the skill directory and were refused: ${escaping.sort().join(", ")}. Replace them with real files if they belong to the skill.`
558
+ );
559
+ }
560
+ if (tooLongPath.length > 0) {
561
+ throw new Error(
562
+ `These paths exceed the ${MAX_PATH_LENGTH}-character limit: ${tooLongPath.sort().join(", ")}.`
563
+ );
564
+ }
565
+ if (files.length > MAX_FILES) {
566
+ throw new Error(
567
+ `A skill bundle is capped at ${MAX_FILES} files; ${skillDir} has ${files.length}.`
568
+ );
569
+ }
570
+ const total = Buffer.byteLength(content, "utf8") + files.reduce((n, f) => n + digest(f.content).bytes, 0);
571
+ if (total > MAX_BUNDLE_BYTES) {
572
+ const biggest = digestFiles(files).sort((a, b) => b.bytes - a.bytes).slice(0, 5).map((f) => `${f.path} (${Math.round(f.bytes / 1024)} KB)`);
573
+ throw new Error(
574
+ `The bundle is ${Math.round(total / 1024)} KB, over the ${MAX_BUNDLE_BYTES / 1024 / 1024} MB cap. Largest files: ${biggest.join(", ")}.`
575
+ );
576
+ }
577
+ files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
578
+ return { content, files };
579
+ }
580
+ async function isDirectory(path) {
581
+ try {
582
+ await readdir(path);
583
+ return true;
584
+ } catch {
585
+ return false;
586
+ }
587
+ }
588
+ async function writeSkillFiles(outDir, files, opts) {
589
+ const root = resolve(outDir);
590
+ await mkdir(root, { recursive: true });
591
+ const realRoot = await realpath(root);
592
+ const targets = files.map((file) => {
593
+ const target = resolve(realRoot, file.path);
594
+ if (target !== realRoot && !target.startsWith(realRoot + sep)) {
595
+ throw new Error(`Refusing to write outside outDir: ${file.path}`);
596
+ }
597
+ return { file, target };
598
+ });
599
+ if (!opts.overwrite) {
600
+ const conflicts = [];
601
+ for (const { file, target } of targets) {
602
+ const existing = await readFile(target, "utf8").catch(() => void 0);
603
+ if (existing !== void 0 && existing !== file.content) conflicts.push(file.path);
604
+ }
605
+ if (conflicts.length > 0) {
606
+ throw new Error(
607
+ `These files already exist in ${root} with different content: ${conflicts.sort().join(", ")}. Nothing was written. Pass overwrite: true to replace them.`
608
+ );
609
+ }
610
+ }
611
+ for (const { file, target } of targets) {
612
+ await mkdir(dirname(target), { recursive: true });
613
+ await writeFile(target, file.content, "utf8");
614
+ }
615
+ return digestFiles(files);
616
+ }
617
+
618
+ // src/tools/skills.ts
619
+ function skillMetadata(skill) {
620
+ return {
621
+ id: skill.id,
622
+ name: skill.name,
623
+ description: skill.description ?? null,
624
+ status: skill.status,
625
+ origin: skill.origin,
626
+ currentVersion: skill.currentVersion ?? null,
627
+ updatedAt: skill.updatedAt,
628
+ canEdit: skill.canEdit,
629
+ fileCount: skill.files?.length ?? 0,
630
+ usedBy: skill.usedBy ?? []
631
+ };
632
+ }
633
+ function writeReceipt(skill) {
634
+ return {
635
+ id: skill.id,
636
+ name: skill.name,
637
+ currentVersion: skill.currentVersion ?? null,
638
+ updatedAt: skill.updatedAt,
639
+ content: digest(skill.content ?? ""),
640
+ files: digestFiles(skill.files ?? [])
641
+ };
642
+ }
643
+ async function resolveBundle(args) {
644
+ if (args.skillDir === void 0) return { content: args.content, files: args.files };
645
+ if (args.content !== void 0 || args.files !== void 0) {
646
+ throw new Error(
647
+ "skillDir cannot be combined with content or files: pass the directory alone (its SKILL.md becomes the content and everything else becomes the files), or pass content/files inline."
648
+ );
649
+ }
650
+ return readSkillDir(args.skillDir);
651
+ }
652
+ var FILES_SEMANTICS = "Omitting `files` keeps the existing ones; sending it REPLACES the whole set, so a file missing from the list is deleted (`files: []` deletes all of them). To delete a file, leave it out of the directory (or the array) and upload.";
653
+ var SKILL_DIR_HELP = "Path to a skill directory on disk \u2014 `SKILL.md` at its root becomes the content (frontmatter included) and every other file becomes an embedded file, path relative to the directory. This is the layout download-skill writes with outDir, so the loop is: download to a folder, edit, upload the folder. Prefer this over inline content for anything non-trivial \u2014 it costs the same whether the skill is 1 KB or 1 MB. Text files only; .git/node_modules/__pycache__/.venv are skipped.";
465
654
  function registerSkillTools(server, client, ctx) {
466
655
  server.registerTool(
467
656
  "list-skills",
468
657
  {
469
- description: "List skill definitions (active by default). Returns id, name, description, status, origin, version, and timestamps.",
658
+ description: "List skill definitions (active by default). Metadata only \u2014 id, name, description, status, origin, version, file count and timestamps. Use get-skill or download-skill to read a specific skill.",
470
659
  inputSchema: {
471
- status: z2.enum(["active", "archived"]).optional().describe("Lifecycle filter \u2014 defaults to 'active'; pass 'archived' for archived skills")
660
+ status: z2.enum(["active", "archived"]).optional().describe("Lifecycle filter \u2014 defaults to 'active'; pass 'archived' for archived skills"),
661
+ query: z2.string().optional().describe("Case-insensitive substring match against the name and description")
472
662
  },
473
663
  annotations: { readOnlyHint: true }
474
664
  },
475
- async ({ status }) => {
476
- const query = status ? `?status=${status}` : "";
477
- const page = await client.get(`/api/skills${query}`);
478
- return jsonResult({ items: page.items, total: page.total });
665
+ async ({ status, query }) => {
666
+ const qs = status ? `?status=${status}` : "";
667
+ const page = await client.get(`/api/skills${qs}`);
668
+ const needle = query?.trim().toLowerCase();
669
+ const items = page.items.filter(
670
+ (s) => !needle || s.name.toLowerCase().includes(needle) || (s.description ?? "").toLowerCase().includes(needle)
671
+ ).map(skillMetadata);
672
+ return jsonResult({ items, total: items.length, totalBeforeFilter: page.total });
479
673
  }
480
674
  );
481
675
  server.registerTool(
482
676
  "get-skill",
483
677
  {
484
- description: "Get detailed information about a specific skill, including content, allowed tools, files, and version info.",
678
+ description: "Get detailed information about a specific skill, including content, allowed tools, files, and version info. For a large skill prefer download-skill with outDir, which puts the files on disk instead of in the conversation.",
485
679
  inputSchema: {
486
- id: z2.string().describe("Skill ID (UUID)")
680
+ id: z2.string().describe("Skill ID (UUID)"),
681
+ includeContent: z2.boolean().optional().describe(
682
+ "Defaults to true. With false, returns metadata plus a size and sha256 per file \u2014 enough to check what is stored without pulling the content in."
683
+ )
487
684
  },
488
685
  annotations: { readOnlyHint: true }
489
686
  },
490
- async ({ id }) => {
687
+ async ({ id, includeContent }) => {
491
688
  const skill = await client.get(`/api/skills/${id}`);
689
+ if (includeContent === false) {
690
+ return jsonResult({
691
+ ...skillMetadata(skill),
692
+ model: skill.model ?? null,
693
+ allowedTools: skill.allowedTools ?? [],
694
+ content: digest(skill.content ?? ""),
695
+ files: digestFiles(skill.files ?? [])
696
+ });
697
+ }
492
698
  return jsonResult(skill);
493
699
  }
494
700
  );
@@ -509,18 +715,30 @@ function registerSkillTools(server, client, ctx) {
509
715
  server.registerTool(
510
716
  "download-skill",
511
717
  {
512
- description: "Download a skill as files in the target tool's on-disk layout (e.g. .claude/skills/<name>/SKILL.md for Claude Code). Returns {files: [{path, content}]} \u2014 write each file at its path relative to the current working directory. Iterate locally, then push changes back with update-skill.",
718
+ description: "Download a skill as files in the target tool's on-disk layout (e.g. .claude/skills/<name>/SKILL.md for Claude Code). With `outDir` the files are written to disk and only their paths, sizes and hashes come back \u2014 the way to handle a skill of any size. Without it, the content is returned inline. Then edit the folder and push it back with update-skill + skillDir.",
513
719
  inputSchema: {
514
720
  id: z2.string().describe("Skill ID (UUID)"),
515
721
  format: z2.enum(["claude-code", "codex", "kimi-code", "opencode", "raw"]).optional().describe(
516
722
  "Target tool layout \u2014 defaults to 'claude-code'; 'raw' is the dudamel-native <name>/SKILL.md layout"
723
+ ),
724
+ outDir: z2.string().optional().describe(
725
+ "Directory to write the files into (created if missing). Paths from the chosen format are kept, so outDir is the root the layout hangs from."
726
+ ),
727
+ overwrite: z2.boolean().optional().describe(
728
+ "Defaults to false: if a file already exists with different content the call is rejected and nothing is written, so a re-download cannot erase edits in flight."
517
729
  )
518
730
  },
519
731
  annotations: { readOnlyHint: true }
520
732
  },
521
- async ({ id, format }) => {
522
- const result = await client.get(`/api/skills/${id}/export?format=${format ?? "claude-code"}`);
523
- return jsonResult(result);
733
+ async ({ id, format, outDir, overwrite }) => {
734
+ const result = await client.get(
735
+ `/api/skills/${id}/export?format=${format ?? "claude-code"}`
736
+ );
737
+ if (outDir === void 0) return jsonResult(result);
738
+ const written = await writeSkillFiles(outDir, result.files, {
739
+ overwrite: overwrite ?? false
740
+ });
741
+ return jsonResult({ skillName: result.skillName, outDir, files: written });
524
742
  }
525
743
  );
526
744
  server.registerTool(
@@ -541,10 +759,13 @@ function registerSkillTools(server, client, ctx) {
541
759
  server.registerTool(
542
760
  "create-skill",
543
761
  {
544
- description: "Create a new skill definition. The creator becomes the owner and can edit it and share edition with business roles via set-skill-roles.",
762
+ description: "Create a new skill definition. The creator becomes the owner and can edit it and share edition with business roles via set-skill-roles. Pass `skillDir` to upload a directory from disk instead of inlining every file. Returns the new version plus a size and sha256 per stored file. " + FILES_SEMANTICS,
545
763
  inputSchema: {
546
764
  name: z2.string().describe("Skill name (unique, kebab-case)"),
547
- content: z2.string().describe("Skill content (markdown body)"),
765
+ content: z2.string().optional().describe(
766
+ "Skill content \u2014 the full SKILL.md is fine: its frontmatter (description, version, allowed-tools, model) is honored, and an argument passed explicitly here wins over it. Required unless skillDir is given."
767
+ ),
768
+ skillDir: z2.string().optional().describe(SKILL_DIR_HELP),
548
769
  description: z2.string().optional().describe("Skill description"),
549
770
  disableModelInvocation: z2.boolean().optional().describe("Disable model-triggered invocation"),
550
771
  userInvocable: z2.boolean().optional().describe("Allow user to invoke via slash command"),
@@ -556,13 +777,14 @@ function registerSkillTools(server, client, ctx) {
556
777
  path: z2.string().describe("Relative file path within the skill"),
557
778
  content: z2.string().describe("File content")
558
779
  })
559
- ).optional().describe("Embedded skill files")
780
+ ).optional().describe(`Embedded skill files. ${FILES_SEMANTICS}`)
560
781
  },
561
782
  annotations: { readOnlyHint: false, destructiveHint: false }
562
783
  },
563
784
  async ({
564
785
  name,
565
786
  content,
787
+ skillDir,
566
788
  description,
567
789
  disableModelInvocation,
568
790
  userInvocable,
@@ -571,30 +793,37 @@ function registerSkillTools(server, client, ctx) {
571
793
  currentVersion,
572
794
  files
573
795
  }) => {
796
+ const bundle = await resolveBundle({ content, files, skillDir });
797
+ if (bundle.content === void 0) {
798
+ throw new Error("A new skill needs either content or skillDir.");
799
+ }
574
800
  const result = await client.post("/api/skills", {
575
801
  name,
576
- content,
802
+ content: bundle.content,
577
803
  description,
578
804
  disableModelInvocation,
579
805
  userInvocable,
580
806
  model,
581
807
  allowedTools,
582
808
  currentVersion,
583
- files
809
+ files: bundle.files
584
810
  });
585
- return jsonResult(result);
811
+ return jsonResult(writeReceipt(result));
586
812
  }
587
813
  );
588
814
  server.registerTool(
589
815
  "update-skill",
590
816
  {
591
- description: "Update a skill definition. Creates a new version entry.",
817
+ description: "Update a skill definition. Creates a new version entry. Pass `skillDir` to upload an edited directory from disk in one call instead of retyping every file. Returns the new version plus a size and sha256 per stored file, so the upload can be verified without downloading it back. " + FILES_SEMANTICS,
592
818
  inputSchema: {
593
819
  id: z2.string().describe("Skill ID (UUID)"),
594
820
  name: z2.string().optional().describe(
595
821
  "New skill name (unique, kebab-case) \u2014 rename the skill; agent assignments are by id and survive the rename"
596
822
  ),
597
- content: z2.string().optional().describe("New skill content"),
823
+ content: z2.string().optional().describe(
824
+ "New skill content \u2014 the full SKILL.md is fine: its frontmatter (description, version, allowed-tools, model) is honored, and an argument passed explicitly here wins over it."
825
+ ),
826
+ skillDir: z2.string().optional().describe(SKILL_DIR_HELP),
598
827
  description: z2.string().optional().describe("New description"),
599
828
  disableModelInvocation: z2.boolean().optional().describe("Disable model-triggered invocation"),
600
829
  userInvocable: z2.boolean().optional().describe("Allow user to invoke via slash command"),
@@ -607,13 +836,22 @@ function registerSkillTools(server, client, ctx) {
607
836
  path: z2.string().describe("Relative file path within the skill"),
608
837
  content: z2.string().describe("File content")
609
838
  })
610
- ).optional().describe("Embedded skill files")
839
+ ).optional().describe(`Embedded skill files. ${FILES_SEMANTICS}`)
611
840
  },
612
841
  annotations: { readOnlyHint: false, destructiveHint: false }
613
842
  },
614
- async ({ id, ...updates }) => {
615
- const result = await client.put(`/api/skills/${id}`, updates);
616
- return jsonResult(result);
843
+ async ({ id, skillDir, ...updates }) => {
844
+ const bundle = await resolveBundle({
845
+ content: updates.content,
846
+ files: updates.files,
847
+ skillDir
848
+ });
849
+ const result = await client.put(`/api/skills/${id}`, {
850
+ ...updates,
851
+ content: bundle.content,
852
+ files: bundle.files
853
+ });
854
+ return jsonResult(writeReceipt(result));
617
855
  }
618
856
  );
619
857
  server.registerTool(
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Disk I/O for skill bundles (Plan 53).
3
+ *
4
+ * This MCP server runs as a stdio child of the agent, on the same filesystem, so it can
5
+ * read and write the skill directory itself instead of making the model retype every file
6
+ * inside the tool call. That is the whole point: the cost that hurts is the model's
7
+ * context, not the HTTP request. A 110 KB skill now uploads for the same price as a 1 KB
8
+ * one.
9
+ *
10
+ * Under the coding-agent sandbox the Landlock ruleset is inherited and cannot be relaxed,
11
+ * so a directory inside the workspace works and one outside fails closed — which is the
12
+ * behaviour we want, not a limitation to work around.
13
+ *
14
+ * Everything here fails loudly. A silently skipped file would recreate exactly the failure
15
+ * mode this replaces: believing you uploaded something you did not.
16
+ */
17
+ export interface SkillFile {
18
+ path: string;
19
+ content: string;
20
+ }
21
+ export interface FileDigest {
22
+ path: string;
23
+ bytes: number;
24
+ sha256: string;
25
+ }
26
+ export declare function digest(content: string): {
27
+ bytes: number;
28
+ sha256: string;
29
+ };
30
+ export declare function digestFiles(files: SkillFile[]): FileDigest[];
31
+ /**
32
+ * Read a skill directory into the shape the API expects: `SKILL.md` at the root becomes
33
+ * `content` (frontmatter included — the API parses and honors it), everything else becomes
34
+ * `files` with paths relative to the directory.
35
+ *
36
+ * This is exactly the layout `download-skill` produces, so the cycle closes:
37
+ * download to a folder → edit → upload the folder.
38
+ */
39
+ export declare function readSkillDir(skillDir: string): Promise<{
40
+ content: string;
41
+ files: SkillFile[];
42
+ }>;
43
+ /**
44
+ * Write a downloaded skill to disk and report digests instead of content.
45
+ *
46
+ * Refuses by default to clobber a file whose content differs: re-downloading on top of a
47
+ * folder with edits in flight would erase them with no trace — the same silent loss this
48
+ * plan removes on the upload side. Conflicts are all collected before anything is written,
49
+ * so a rejected call leaves the directory untouched.
50
+ *
51
+ * Files present in `outDir` but absent from the skill are left alone (same "omitting
52
+ * conserves" semantics as uploading).
53
+ */
54
+ export declare function writeSkillFiles(outDir: string, files: SkillFile[], opts: {
55
+ overwrite: boolean;
56
+ }): Promise<FileDigest[]>;
57
+ //# sourceMappingURL=skill-bundle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skill-bundle.d.ts","sourceRoot":"","sources":["../src/skill-bundle.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAgBD,wBAAgB,MAAM,CAAC,OAAO,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAKzE;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,SAAS,EAAE,GAAG,UAAU,EAAE,CAE5D;AAgBD;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAChC,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,SAAS,EAAE,CAAA;CAAE,CAAC,CA4GlD;AAWD;;;;;;;;;;GAUG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,SAAS,EAAE,EAClB,IAAI,EAAE;IAAE,SAAS,EAAE,OAAO,CAAA;CAAE,GAC3B,OAAO,CAAC,UAAU,EAAE,CAAC,CAiCvB"}
@@ -520,33 +520,239 @@ function registerAgentTools(server2, client, ctx2) {
520
520
 
521
521
  // src/tools/skills.ts
522
522
  import { z as z2 } from "zod";
523
+
524
+ // src/skill-bundle.ts
525
+ import { createHash } from "node:crypto";
526
+ import { mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises";
527
+ import { dirname, join, relative, resolve, sep } from "node:path";
528
+ var EXCLUDED = /* @__PURE__ */ new Set([".git", "node_modules", "__pycache__", ".venv", ".DS_Store"]);
529
+ var MAX_PATH_LENGTH = 512;
530
+ var MAX_FILES = 200;
531
+ var MAX_BUNDLE_BYTES = 2 * 1024 * 1024;
532
+ var SKILL_MD = "SKILL.md";
533
+ function digest(content) {
534
+ return {
535
+ bytes: Buffer.byteLength(content, "utf8"),
536
+ sha256: createHash("sha256").update(content, "utf8").digest("hex")
537
+ };
538
+ }
539
+ function digestFiles(files) {
540
+ return files.map((f) => ({ path: f.path, ...digest(f.content) }));
541
+ }
542
+ function decodeUtf8(raw, relPath, notText) {
543
+ if (raw.includes(0)) {
544
+ notText.push(relPath);
545
+ return void 0;
546
+ }
547
+ try {
548
+ return new TextDecoder("utf-8", { fatal: true }).decode(raw);
549
+ } catch {
550
+ notText.push(relPath);
551
+ return void 0;
552
+ }
553
+ }
554
+ async function readSkillDir(skillDir) {
555
+ const root = resolve(skillDir);
556
+ let realRoot;
557
+ try {
558
+ realRoot = await realpath(root);
559
+ } catch {
560
+ throw new Error(`skillDir not found or not readable: ${root}`);
561
+ }
562
+ let content;
563
+ try {
564
+ content = await readFile(join(realRoot, SKILL_MD), "utf8");
565
+ } catch {
566
+ throw new Error(
567
+ `${SKILL_MD} not found at the root of ${root}. A skill directory must contain ${SKILL_MD}; use download-skill with outDir to get the expected layout.`
568
+ );
569
+ }
570
+ const files = [];
571
+ const notText = [];
572
+ const escaping = [];
573
+ const tooLongPath = [];
574
+ async function walk(dir) {
575
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
576
+ if (EXCLUDED.has(entry.name)) continue;
577
+ const full = join(dir, entry.name);
578
+ const relPath = relative(realRoot, full).split(sep).join("/");
579
+ if (relPath === SKILL_MD) continue;
580
+ let resolved = full;
581
+ if (entry.isSymbolicLink()) {
582
+ try {
583
+ resolved = await realpath(full);
584
+ } catch {
585
+ escaping.push(relPath);
586
+ continue;
587
+ }
588
+ if (resolved !== realRoot && !resolved.startsWith(realRoot + sep)) {
589
+ escaping.push(relPath);
590
+ continue;
591
+ }
592
+ }
593
+ const isDir = entry.isDirectory() || entry.isSymbolicLink() && await isDirectory(resolved);
594
+ if (isDir) {
595
+ await walk(resolved);
596
+ continue;
597
+ }
598
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
599
+ if (relPath.length > MAX_PATH_LENGTH) {
600
+ tooLongPath.push(relPath);
601
+ continue;
602
+ }
603
+ const text = decodeUtf8(await readFile(resolved), relPath, notText);
604
+ if (text !== void 0) files.push({ path: relPath, content: text });
605
+ }
606
+ }
607
+ await walk(realRoot);
608
+ if (notText.length > 0) {
609
+ throw new Error(
610
+ `A skill can only hold text files (the column that stores them is text, not bytes). Not text: ${notText.sort().join(", ")}. Remove them from the directory, or keep them out of the skill entirely.`
611
+ );
612
+ }
613
+ if (escaping.length > 0) {
614
+ throw new Error(
615
+ `These symlinks point outside the skill directory and were refused: ${escaping.sort().join(", ")}. Replace them with real files if they belong to the skill.`
616
+ );
617
+ }
618
+ if (tooLongPath.length > 0) {
619
+ throw new Error(
620
+ `These paths exceed the ${MAX_PATH_LENGTH}-character limit: ${tooLongPath.sort().join(", ")}.`
621
+ );
622
+ }
623
+ if (files.length > MAX_FILES) {
624
+ throw new Error(
625
+ `A skill bundle is capped at ${MAX_FILES} files; ${skillDir} has ${files.length}.`
626
+ );
627
+ }
628
+ const total = Buffer.byteLength(content, "utf8") + files.reduce((n, f) => n + digest(f.content).bytes, 0);
629
+ if (total > MAX_BUNDLE_BYTES) {
630
+ const biggest = digestFiles(files).sort((a, b) => b.bytes - a.bytes).slice(0, 5).map((f) => `${f.path} (${Math.round(f.bytes / 1024)} KB)`);
631
+ throw new Error(
632
+ `The bundle is ${Math.round(total / 1024)} KB, over the ${MAX_BUNDLE_BYTES / 1024 / 1024} MB cap. Largest files: ${biggest.join(", ")}.`
633
+ );
634
+ }
635
+ files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
636
+ return { content, files };
637
+ }
638
+ async function isDirectory(path) {
639
+ try {
640
+ await readdir(path);
641
+ return true;
642
+ } catch {
643
+ return false;
644
+ }
645
+ }
646
+ async function writeSkillFiles(outDir, files, opts) {
647
+ const root = resolve(outDir);
648
+ await mkdir(root, { recursive: true });
649
+ const realRoot = await realpath(root);
650
+ const targets = files.map((file) => {
651
+ const target = resolve(realRoot, file.path);
652
+ if (target !== realRoot && !target.startsWith(realRoot + sep)) {
653
+ throw new Error(`Refusing to write outside outDir: ${file.path}`);
654
+ }
655
+ return { file, target };
656
+ });
657
+ if (!opts.overwrite) {
658
+ const conflicts = [];
659
+ for (const { file, target } of targets) {
660
+ const existing = await readFile(target, "utf8").catch(() => void 0);
661
+ if (existing !== void 0 && existing !== file.content) conflicts.push(file.path);
662
+ }
663
+ if (conflicts.length > 0) {
664
+ throw new Error(
665
+ `These files already exist in ${root} with different content: ${conflicts.sort().join(", ")}. Nothing was written. Pass overwrite: true to replace them.`
666
+ );
667
+ }
668
+ }
669
+ for (const { file, target } of targets) {
670
+ await mkdir(dirname(target), { recursive: true });
671
+ await writeFile(target, file.content, "utf8");
672
+ }
673
+ return digestFiles(files);
674
+ }
675
+
676
+ // src/tools/skills.ts
677
+ function skillMetadata(skill) {
678
+ return {
679
+ id: skill.id,
680
+ name: skill.name,
681
+ description: skill.description ?? null,
682
+ status: skill.status,
683
+ origin: skill.origin,
684
+ currentVersion: skill.currentVersion ?? null,
685
+ updatedAt: skill.updatedAt,
686
+ canEdit: skill.canEdit,
687
+ fileCount: skill.files?.length ?? 0,
688
+ usedBy: skill.usedBy ?? []
689
+ };
690
+ }
691
+ function writeReceipt(skill) {
692
+ return {
693
+ id: skill.id,
694
+ name: skill.name,
695
+ currentVersion: skill.currentVersion ?? null,
696
+ updatedAt: skill.updatedAt,
697
+ content: digest(skill.content ?? ""),
698
+ files: digestFiles(skill.files ?? [])
699
+ };
700
+ }
701
+ async function resolveBundle(args) {
702
+ if (args.skillDir === void 0) return { content: args.content, files: args.files };
703
+ if (args.content !== void 0 || args.files !== void 0) {
704
+ throw new Error(
705
+ "skillDir cannot be combined with content or files: pass the directory alone (its SKILL.md becomes the content and everything else becomes the files), or pass content/files inline."
706
+ );
707
+ }
708
+ return readSkillDir(args.skillDir);
709
+ }
710
+ var FILES_SEMANTICS = "Omitting `files` keeps the existing ones; sending it REPLACES the whole set, so a file missing from the list is deleted (`files: []` deletes all of them). To delete a file, leave it out of the directory (or the array) and upload.";
711
+ var SKILL_DIR_HELP = "Path to a skill directory on disk \u2014 `SKILL.md` at its root becomes the content (frontmatter included) and every other file becomes an embedded file, path relative to the directory. This is the layout download-skill writes with outDir, so the loop is: download to a folder, edit, upload the folder. Prefer this over inline content for anything non-trivial \u2014 it costs the same whether the skill is 1 KB or 1 MB. Text files only; .git/node_modules/__pycache__/.venv are skipped.";
523
712
  function registerSkillTools(server2, client, ctx2) {
524
713
  server2.registerTool(
525
714
  "list-skills",
526
715
  {
527
- description: "List skill definitions (active by default). Returns id, name, description, status, origin, version, and timestamps.",
716
+ description: "List skill definitions (active by default). Metadata only \u2014 id, name, description, status, origin, version, file count and timestamps. Use get-skill or download-skill to read a specific skill.",
528
717
  inputSchema: {
529
- status: z2.enum(["active", "archived"]).optional().describe("Lifecycle filter \u2014 defaults to 'active'; pass 'archived' for archived skills")
718
+ status: z2.enum(["active", "archived"]).optional().describe("Lifecycle filter \u2014 defaults to 'active'; pass 'archived' for archived skills"),
719
+ query: z2.string().optional().describe("Case-insensitive substring match against the name and description")
530
720
  },
531
721
  annotations: { readOnlyHint: true }
532
722
  },
533
- async ({ status }) => {
534
- const query = status ? `?status=${status}` : "";
535
- const page = await client.get(`/api/skills${query}`);
536
- return jsonResult({ items: page.items, total: page.total });
723
+ async ({ status, query }) => {
724
+ const qs = status ? `?status=${status}` : "";
725
+ const page = await client.get(`/api/skills${qs}`);
726
+ const needle = query?.trim().toLowerCase();
727
+ const items = page.items.filter(
728
+ (s) => !needle || s.name.toLowerCase().includes(needle) || (s.description ?? "").toLowerCase().includes(needle)
729
+ ).map(skillMetadata);
730
+ return jsonResult({ items, total: items.length, totalBeforeFilter: page.total });
537
731
  }
538
732
  );
539
733
  server2.registerTool(
540
734
  "get-skill",
541
735
  {
542
- description: "Get detailed information about a specific skill, including content, allowed tools, files, and version info.",
736
+ description: "Get detailed information about a specific skill, including content, allowed tools, files, and version info. For a large skill prefer download-skill with outDir, which puts the files on disk instead of in the conversation.",
543
737
  inputSchema: {
544
- id: z2.string().describe("Skill ID (UUID)")
738
+ id: z2.string().describe("Skill ID (UUID)"),
739
+ includeContent: z2.boolean().optional().describe(
740
+ "Defaults to true. With false, returns metadata plus a size and sha256 per file \u2014 enough to check what is stored without pulling the content in."
741
+ )
545
742
  },
546
743
  annotations: { readOnlyHint: true }
547
744
  },
548
- async ({ id }) => {
745
+ async ({ id, includeContent }) => {
549
746
  const skill = await client.get(`/api/skills/${id}`);
747
+ if (includeContent === false) {
748
+ return jsonResult({
749
+ ...skillMetadata(skill),
750
+ model: skill.model ?? null,
751
+ allowedTools: skill.allowedTools ?? [],
752
+ content: digest(skill.content ?? ""),
753
+ files: digestFiles(skill.files ?? [])
754
+ });
755
+ }
550
756
  return jsonResult(skill);
551
757
  }
552
758
  );
@@ -567,18 +773,30 @@ function registerSkillTools(server2, client, ctx2) {
567
773
  server2.registerTool(
568
774
  "download-skill",
569
775
  {
570
- description: "Download a skill as files in the target tool's on-disk layout (e.g. .claude/skills/<name>/SKILL.md for Claude Code). Returns {files: [{path, content}]} \u2014 write each file at its path relative to the current working directory. Iterate locally, then push changes back with update-skill.",
776
+ description: "Download a skill as files in the target tool's on-disk layout (e.g. .claude/skills/<name>/SKILL.md for Claude Code). With `outDir` the files are written to disk and only their paths, sizes and hashes come back \u2014 the way to handle a skill of any size. Without it, the content is returned inline. Then edit the folder and push it back with update-skill + skillDir.",
571
777
  inputSchema: {
572
778
  id: z2.string().describe("Skill ID (UUID)"),
573
779
  format: z2.enum(["claude-code", "codex", "kimi-code", "opencode", "raw"]).optional().describe(
574
780
  "Target tool layout \u2014 defaults to 'claude-code'; 'raw' is the dudamel-native <name>/SKILL.md layout"
781
+ ),
782
+ outDir: z2.string().optional().describe(
783
+ "Directory to write the files into (created if missing). Paths from the chosen format are kept, so outDir is the root the layout hangs from."
784
+ ),
785
+ overwrite: z2.boolean().optional().describe(
786
+ "Defaults to false: if a file already exists with different content the call is rejected and nothing is written, so a re-download cannot erase edits in flight."
575
787
  )
576
788
  },
577
789
  annotations: { readOnlyHint: true }
578
790
  },
579
- async ({ id, format }) => {
580
- const result = await client.get(`/api/skills/${id}/export?format=${format ?? "claude-code"}`);
581
- return jsonResult(result);
791
+ async ({ id, format, outDir, overwrite }) => {
792
+ const result = await client.get(
793
+ `/api/skills/${id}/export?format=${format ?? "claude-code"}`
794
+ );
795
+ if (outDir === void 0) return jsonResult(result);
796
+ const written = await writeSkillFiles(outDir, result.files, {
797
+ overwrite: overwrite ?? false
798
+ });
799
+ return jsonResult({ skillName: result.skillName, outDir, files: written });
582
800
  }
583
801
  );
584
802
  server2.registerTool(
@@ -599,10 +817,13 @@ function registerSkillTools(server2, client, ctx2) {
599
817
  server2.registerTool(
600
818
  "create-skill",
601
819
  {
602
- description: "Create a new skill definition. The creator becomes the owner and can edit it and share edition with business roles via set-skill-roles.",
820
+ description: "Create a new skill definition. The creator becomes the owner and can edit it and share edition with business roles via set-skill-roles. Pass `skillDir` to upload a directory from disk instead of inlining every file. Returns the new version plus a size and sha256 per stored file. " + FILES_SEMANTICS,
603
821
  inputSchema: {
604
822
  name: z2.string().describe("Skill name (unique, kebab-case)"),
605
- content: z2.string().describe("Skill content (markdown body)"),
823
+ content: z2.string().optional().describe(
824
+ "Skill content \u2014 the full SKILL.md is fine: its frontmatter (description, version, allowed-tools, model) is honored, and an argument passed explicitly here wins over it. Required unless skillDir is given."
825
+ ),
826
+ skillDir: z2.string().optional().describe(SKILL_DIR_HELP),
606
827
  description: z2.string().optional().describe("Skill description"),
607
828
  disableModelInvocation: z2.boolean().optional().describe("Disable model-triggered invocation"),
608
829
  userInvocable: z2.boolean().optional().describe("Allow user to invoke via slash command"),
@@ -614,13 +835,14 @@ function registerSkillTools(server2, client, ctx2) {
614
835
  path: z2.string().describe("Relative file path within the skill"),
615
836
  content: z2.string().describe("File content")
616
837
  })
617
- ).optional().describe("Embedded skill files")
838
+ ).optional().describe(`Embedded skill files. ${FILES_SEMANTICS}`)
618
839
  },
619
840
  annotations: { readOnlyHint: false, destructiveHint: false }
620
841
  },
621
842
  async ({
622
843
  name,
623
844
  content,
845
+ skillDir,
624
846
  description,
625
847
  disableModelInvocation,
626
848
  userInvocable,
@@ -629,30 +851,37 @@ function registerSkillTools(server2, client, ctx2) {
629
851
  currentVersion,
630
852
  files
631
853
  }) => {
854
+ const bundle = await resolveBundle({ content, files, skillDir });
855
+ if (bundle.content === void 0) {
856
+ throw new Error("A new skill needs either content or skillDir.");
857
+ }
632
858
  const result = await client.post("/api/skills", {
633
859
  name,
634
- content,
860
+ content: bundle.content,
635
861
  description,
636
862
  disableModelInvocation,
637
863
  userInvocable,
638
864
  model,
639
865
  allowedTools,
640
866
  currentVersion,
641
- files
867
+ files: bundle.files
642
868
  });
643
- return jsonResult(result);
869
+ return jsonResult(writeReceipt(result));
644
870
  }
645
871
  );
646
872
  server2.registerTool(
647
873
  "update-skill",
648
874
  {
649
- description: "Update a skill definition. Creates a new version entry.",
875
+ description: "Update a skill definition. Creates a new version entry. Pass `skillDir` to upload an edited directory from disk in one call instead of retyping every file. Returns the new version plus a size and sha256 per stored file, so the upload can be verified without downloading it back. " + FILES_SEMANTICS,
650
876
  inputSchema: {
651
877
  id: z2.string().describe("Skill ID (UUID)"),
652
878
  name: z2.string().optional().describe(
653
879
  "New skill name (unique, kebab-case) \u2014 rename the skill; agent assignments are by id and survive the rename"
654
880
  ),
655
- content: z2.string().optional().describe("New skill content"),
881
+ content: z2.string().optional().describe(
882
+ "New skill content \u2014 the full SKILL.md is fine: its frontmatter (description, version, allowed-tools, model) is honored, and an argument passed explicitly here wins over it."
883
+ ),
884
+ skillDir: z2.string().optional().describe(SKILL_DIR_HELP),
656
885
  description: z2.string().optional().describe("New description"),
657
886
  disableModelInvocation: z2.boolean().optional().describe("Disable model-triggered invocation"),
658
887
  userInvocable: z2.boolean().optional().describe("Allow user to invoke via slash command"),
@@ -665,13 +894,22 @@ function registerSkillTools(server2, client, ctx2) {
665
894
  path: z2.string().describe("Relative file path within the skill"),
666
895
  content: z2.string().describe("File content")
667
896
  })
668
- ).optional().describe("Embedded skill files")
897
+ ).optional().describe(`Embedded skill files. ${FILES_SEMANTICS}`)
669
898
  },
670
899
  annotations: { readOnlyHint: false, destructiveHint: false }
671
900
  },
672
- async ({ id, ...updates }) => {
673
- const result = await client.put(`/api/skills/${id}`, updates);
674
- return jsonResult(result);
901
+ async ({ id, skillDir, ...updates }) => {
902
+ const bundle = await resolveBundle({
903
+ content: updates.content,
904
+ files: updates.files,
905
+ skillDir
906
+ });
907
+ const result = await client.put(`/api/skills/${id}`, {
908
+ ...updates,
909
+ content: bundle.content,
910
+ files: bundle.files
911
+ });
912
+ return jsonResult(writeReceipt(result));
675
913
  }
676
914
  );
677
915
  server2.registerTool(
@@ -1 +1 @@
1
- {"version":3,"file":"skills.d.ts","sourceRoot":"","sources":["../../src/tools/skills.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAGvD,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,SAAS,EACjB,MAAM,EAAE,gBAAgB,EACxB,GAAG,EAAE,WAAW,GACf,IAAI,CA4RN"}
1
+ {"version":3,"file":"skills.d.ts","sourceRoot":"","sources":["../../src/tools/skills.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AA6FvD,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,SAAS,EACjB,MAAM,EAAE,gBAAgB,EACxB,GAAG,EAAE,WAAW,GACf,IAAI,CA+XN"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dudamel/mcp-agents",
3
- "version": "2026.918.1",
3
+ "version": "2026.923.1",
4
4
  "description": "Dudamel agents MCP — stdio client for Dudamel's agents management surface over its REST API.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "type": "module",