@cubis/foundry 0.3.62 → 0.3.63

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.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Cubis Foundry MCP Server – skill_get_reference tool.
3
+ *
4
+ * Returns a single validated markdown sidecar file for a skill.
5
+ */
6
+
7
+ import { z } from "zod";
8
+ import type { VaultManifest } from "../vault/types.js";
9
+ import { readSkillReferenceFile } from "../vault/manifest.js";
10
+ import {
11
+ buildSkillToolMetrics,
12
+ estimateTokensFromText,
13
+ } from "../telemetry/tokenBudget.js";
14
+ import { invalidInput, notFound } from "../utils/errors.js";
15
+
16
+ export const skillGetReferenceName = "skill_get_reference";
17
+
18
+ export const skillGetReferenceDescription =
19
+ "Get one validated markdown reference file for a skill by exact relative path.";
20
+
21
+ export const skillGetReferenceSchema = z.object({
22
+ id: z.string().describe("The exact skill ID (directory name)"),
23
+ path: z
24
+ .string()
25
+ .describe("Exact relative markdown reference path exposed by skill_validate"),
26
+ });
27
+
28
+ function assertConcreteSkillId(id: string) {
29
+ if (id.startsWith("workflow-") || id.startsWith("agent-")) {
30
+ invalidInput(
31
+ `Skill id "${id}" appears to be a wrapper id. Use workflow/agent routing (for example $workflow-implement-track or $agent-backend-specialist) and call skill_get only for concrete skill ids.`,
32
+ );
33
+ }
34
+ }
35
+
36
+ export async function handleSkillGetReference(
37
+ args: z.infer<typeof skillGetReferenceSchema>,
38
+ manifest: VaultManifest,
39
+ charsPerToken: number,
40
+ ) {
41
+ const { id, path } = args;
42
+ assertConcreteSkillId(id);
43
+
44
+ const skill = manifest.skills.find((entry) => entry.id === id);
45
+ if (!skill) {
46
+ notFound("Skill", id);
47
+ }
48
+
49
+ let reference;
50
+ try {
51
+ reference = await readSkillReferenceFile(skill.path, path);
52
+ } catch (error) {
53
+ invalidInput(error instanceof Error ? error.message : String(error));
54
+ }
55
+
56
+ const metrics = buildSkillToolMetrics({
57
+ charsPerToken,
58
+ fullCatalogEstimatedTokens: manifest.fullCatalogEstimatedTokens,
59
+ responseEstimatedTokens: estimateTokensFromText(
60
+ reference.content,
61
+ charsPerToken,
62
+ ),
63
+ responseCharacterCount: reference.content.length,
64
+ });
65
+
66
+ return {
67
+ content: [{ type: "text" as const, text: reference.content }],
68
+ structuredContent: {
69
+ skillId: id,
70
+ path: reference.relativePath,
71
+ metrics,
72
+ },
73
+ _meta: {
74
+ metrics,
75
+ },
76
+ };
77
+ }
@@ -6,8 +6,10 @@ import type { VaultManifest } from "../vault/types.js";
6
6
  import { handleSkillBrowseCategory } from "./skillBrowseCategory.js";
7
7
  import { handleSkillBudgetReport } from "./skillBudgetReport.js";
8
8
  import { handleSkillGet } from "./skillGet.js";
9
+ import { handleSkillGetReference } from "./skillGetReference.js";
9
10
  import { handleSkillListCategories } from "./skillListCategories.js";
10
11
  import { handleSkillSearch } from "./skillSearch.js";
12
+ import { handleSkillValidate } from "./skillValidate.js";
11
13
 
12
14
  function payload(result: { content: Array<{ text: string }> }): Record<string, unknown> {
13
15
  return JSON.parse(result.content[0].text) as Record<string, unknown>;
@@ -147,6 +149,76 @@ describe("skill tools", () => {
147
149
  expect(toolMetrics.loadedSkillEstimatedTokens).toBeGreaterThan(0);
148
150
  });
149
151
 
152
+ it("validates an exact skill id and exposes alias/reference metadata", async () => {
153
+ const dir = mkdtempSync(path.join(os.tmpdir(), "mcp-skill-validate-"));
154
+ const skillFile = path.join(dir, "SKILL.md");
155
+ const referencesDir = path.join(dir, "references");
156
+ mkdirSync(referencesDir, { recursive: true });
157
+ writeFileSync(
158
+ skillFile,
159
+ [
160
+ "---",
161
+ "name: deprecated-skill",
162
+ "description: compatibility shim",
163
+ "metadata:",
164
+ " deprecated: true",
165
+ " replaced_by: canonical-skill",
166
+ "---",
167
+ "# Skill",
168
+ ].join("\n"),
169
+ "utf8",
170
+ );
171
+ writeFileSync(path.join(referencesDir, "guide.md"), "# Guide", "utf8");
172
+
173
+ const skillBytes = statSync(skillFile).size;
174
+ const manifest: VaultManifest = {
175
+ categories: ["general"],
176
+ skills: [
177
+ {
178
+ id: "deprecated-skill",
179
+ category: "general",
180
+ path: skillFile,
181
+ fileBytes: skillBytes,
182
+ },
183
+ ],
184
+ fullCatalogBytes: skillBytes,
185
+ fullCatalogEstimatedTokens: Math.ceil(skillBytes / 4),
186
+ };
187
+
188
+ const result = await handleSkillValidate(
189
+ { id: "deprecated-skill" },
190
+ manifest,
191
+ 4,
192
+ );
193
+ const data = payload(result);
194
+ expect(data).toMatchObject({
195
+ id: "deprecated-skill",
196
+ exists: true,
197
+ canonicalId: "canonical-skill",
198
+ isAlias: true,
199
+ replacementId: "canonical-skill",
200
+ });
201
+ expect(data.availableReferences).toContain("references/guide.md");
202
+ });
203
+
204
+ it("returns exists=false for unknown exact skill ids", async () => {
205
+ const manifest = createManifest();
206
+ const result = await handleSkillValidate({ id: "missing" }, manifest, 4);
207
+ const data = payload(result);
208
+ expect(data).toMatchObject({
209
+ id: "missing",
210
+ exists: false,
211
+ canonicalId: null,
212
+ });
213
+ });
214
+
215
+ it("throws a wrapper guidance error when skill_validate receives workflow id", async () => {
216
+ const manifest = createManifest();
217
+ await expect(
218
+ handleSkillValidate({ id: "workflow-implement-track" }, manifest, 4),
219
+ ).rejects.toThrow("appears to be a wrapper id");
220
+ });
221
+
150
222
  it("includes referenced markdown files in skill_get by default", async () => {
151
223
  const dir = mkdtempSync(path.join(os.tmpdir(), "mcp-skill-ref-default-"));
152
224
  const skillFile = path.join(dir, "SKILL.md");
@@ -296,6 +368,90 @@ describe("skill tools", () => {
296
368
  ).rejects.toThrow("appears to be a wrapper id");
297
369
  });
298
370
 
371
+ it("returns one validated reference file for skill_get_reference", async () => {
372
+ const dir = mkdtempSync(path.join(os.tmpdir(), "mcp-skill-ref-single-"));
373
+ const skillFile = path.join(dir, "SKILL.md");
374
+ const referencesDir = path.join(dir, "references");
375
+ mkdirSync(referencesDir, { recursive: true });
376
+ writeFileSync(
377
+ skillFile,
378
+ [
379
+ "---",
380
+ "name: single-ref-skill",
381
+ "description: one ref",
382
+ "---",
383
+ "# Skill",
384
+ "See [Guide](references/guide.md).",
385
+ ].join("\n"),
386
+ "utf8",
387
+ );
388
+ writeFileSync(
389
+ path.join(referencesDir, "guide.md"),
390
+ "# Guide\nOne file only",
391
+ "utf8",
392
+ );
393
+
394
+ const skillBytes = statSync(skillFile).size;
395
+ const manifest: VaultManifest = {
396
+ categories: ["general"],
397
+ skills: [
398
+ {
399
+ id: "single-ref-skill",
400
+ category: "general",
401
+ path: skillFile,
402
+ fileBytes: skillBytes,
403
+ },
404
+ ],
405
+ fullCatalogBytes: skillBytes,
406
+ fullCatalogEstimatedTokens: Math.ceil(skillBytes / 4),
407
+ };
408
+
409
+ const result = await handleSkillGetReference(
410
+ { id: "single-ref-skill", path: "references/guide.md" },
411
+ manifest,
412
+ 4,
413
+ );
414
+ expect(result.content[0].text).toBe("# Guide\nOne file only");
415
+ expect(result.structuredContent).toMatchObject({
416
+ skillId: "single-ref-skill",
417
+ path: "references/guide.md",
418
+ });
419
+ });
420
+
421
+ it("rejects invalid reference paths for skill_get_reference", async () => {
422
+ const dir = mkdtempSync(path.join(os.tmpdir(), "mcp-skill-ref-invalid-"));
423
+ const skillFile = path.join(dir, "SKILL.md");
424
+ writeFileSync(
425
+ skillFile,
426
+ ["---", "name: invalid-ref-skill", "description: invalid", "---", "# Skill"].join(
427
+ "\n",
428
+ ),
429
+ "utf8",
430
+ );
431
+ const skillBytes = statSync(skillFile).size;
432
+ const manifest: VaultManifest = {
433
+ categories: ["general"],
434
+ skills: [
435
+ {
436
+ id: "invalid-ref-skill",
437
+ category: "general",
438
+ path: skillFile,
439
+ fileBytes: skillBytes,
440
+ },
441
+ ],
442
+ fullCatalogBytes: skillBytes,
443
+ fullCatalogEstimatedTokens: Math.ceil(skillBytes / 4),
444
+ };
445
+
446
+ await expect(
447
+ handleSkillGetReference(
448
+ { id: "invalid-ref-skill", path: "../secret.md" },
449
+ manifest,
450
+ 4,
451
+ ),
452
+ ).rejects.toThrow("Reference path");
453
+ });
454
+
299
455
  it("returns consolidated budget rollup for selected and loaded skills", () => {
300
456
  const manifest = createManifest();
301
457
  const result = handleSkillBudgetReport(
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Cubis Foundry MCP Server – skill_validate tool.
3
+ *
4
+ * Validates an exact skill ID before skill_get and exposes alias/reference info.
5
+ */
6
+
7
+ import { z } from "zod";
8
+ import type { VaultManifest } from "../vault/types.js";
9
+ import {
10
+ listReferencedMarkdownPaths,
11
+ readSkillFrontmatter,
12
+ } from "../vault/manifest.js";
13
+ import {
14
+ buildSkillToolMetrics,
15
+ estimateTokensFromText,
16
+ } from "../telemetry/tokenBudget.js";
17
+ import { invalidInput } from "../utils/errors.js";
18
+
19
+ export const skillValidateName = "skill_validate";
20
+
21
+ export const skillValidateDescription =
22
+ "Validate an exact skill ID before loading it. Returns alias metadata and discoverable reference markdown paths.";
23
+
24
+ export const skillValidateSchema = z.object({
25
+ id: z.string().describe("The exact skill ID (directory name) to validate"),
26
+ });
27
+
28
+ function assertConcreteSkillId(id: string) {
29
+ if (id.startsWith("workflow-") || id.startsWith("agent-")) {
30
+ invalidInput(
31
+ `Skill id "${id}" appears to be a wrapper id. Use workflow/agent routing (for example $workflow-implement-track or $agent-backend-specialist) and call skill_get only for concrete skill ids.`,
32
+ );
33
+ }
34
+ }
35
+
36
+ export async function handleSkillValidate(
37
+ args: z.infer<typeof skillValidateSchema>,
38
+ manifest: VaultManifest,
39
+ charsPerToken: number,
40
+ ) {
41
+ const { id } = args;
42
+ assertConcreteSkillId(id);
43
+
44
+ const skill = manifest.skills.find((entry) => entry.id === id);
45
+ if (!skill) {
46
+ const payload = {
47
+ id,
48
+ exists: false,
49
+ canonicalId: null,
50
+ category: null,
51
+ description: null,
52
+ isWrapper: false,
53
+ isAlias: false,
54
+ replacementId: null,
55
+ availableReferences: [],
56
+ };
57
+ const text = JSON.stringify(payload, null, 2);
58
+ const metrics = buildSkillToolMetrics({
59
+ charsPerToken,
60
+ fullCatalogEstimatedTokens: manifest.fullCatalogEstimatedTokens,
61
+ responseEstimatedTokens: estimateTokensFromText(text, charsPerToken),
62
+ responseCharacterCount: text.length,
63
+ });
64
+
65
+ return {
66
+ content: [{ type: "text" as const, text }],
67
+ structuredContent: { ...payload, metrics },
68
+ _meta: { metrics },
69
+ };
70
+ }
71
+
72
+ const frontmatter = await readSkillFrontmatter(skill.path);
73
+ const replacementId =
74
+ frontmatter.metadata.replaced_by || frontmatter.metadata.alias_of || null;
75
+ const isAlias = Boolean(replacementId || frontmatter.metadata.deprecated);
76
+ const availableReferences = await listReferencedMarkdownPaths(skill.path);
77
+ const payload = {
78
+ id,
79
+ exists: true,
80
+ canonicalId: replacementId || skill.id,
81
+ category: skill.category,
82
+ description: frontmatter.description || skill.description || null,
83
+ isWrapper: false,
84
+ isAlias,
85
+ replacementId,
86
+ availableReferences,
87
+ };
88
+ const text = JSON.stringify(payload, null, 2);
89
+ const metrics = buildSkillToolMetrics({
90
+ charsPerToken,
91
+ fullCatalogEstimatedTokens: manifest.fullCatalogEstimatedTokens,
92
+ responseEstimatedTokens: estimateTokensFromText(text, charsPerToken),
93
+ responseCharacterCount: text.length,
94
+ });
95
+
96
+ return {
97
+ content: [{ type: "text" as const, text }],
98
+ structuredContent: { ...payload, metrics },
99
+ _meta: { metrics },
100
+ };
101
+ }
@@ -6,9 +6,13 @@ import {
6
6
  buildManifest,
7
7
  enrichWithDescriptions,
8
8
  extractDescription,
9
+ listReferencedMarkdownPaths,
9
10
  parseDescriptionFromFrontmatter,
11
+ parseSkillFrontmatter,
12
+ readSkillFrontmatter,
10
13
  readFullSkillContent,
11
14
  readSkillContentWithReferences,
15
+ readSkillReferenceFile,
12
16
  } from "./manifest.js";
13
17
 
14
18
  const tempDirs: string[] = [];
@@ -60,6 +64,27 @@ describe("frontmatter description parsing", () => {
60
64
  const content = ["---", "name: no-description", "---", "# Body"].join("\n");
61
65
  expect(parseDescriptionFromFrontmatter(content, 100)).toBeUndefined();
62
66
  });
67
+
68
+ it("extracts metadata from skill frontmatter", () => {
69
+ const content = [
70
+ "---",
71
+ "name: alias-skill",
72
+ "description: Compatibility alias",
73
+ "metadata:",
74
+ " deprecated: true",
75
+ " replaced_by: canonical-skill",
76
+ "---",
77
+ "# Alias",
78
+ ].join("\n");
79
+
80
+ expect(parseSkillFrontmatter(content)).toEqual({
81
+ description: "Compatibility alias",
82
+ metadata: {
83
+ deprecated: "true",
84
+ replaced_by: "canonical-skill",
85
+ },
86
+ });
87
+ });
63
88
  });
64
89
 
65
90
  describe("skill content IO", () => {
@@ -92,6 +117,31 @@ describe("skill content IO", () => {
92
117
  await expect(readFullSkillContent(file)).resolves.toBe(body);
93
118
  });
94
119
 
120
+ it("reads frontmatter metadata from a skill file", async () => {
121
+ const dir = createTempDir("mcp-frontmatter-read-");
122
+ const file = path.join(dir, "SKILL.md");
123
+ writeFileSync(
124
+ file,
125
+ [
126
+ "---",
127
+ "name: frontmatter-read",
128
+ "description: Metadata body",
129
+ "metadata:",
130
+ " alias_of: canonical-skill",
131
+ "---",
132
+ "# Content",
133
+ ].join("\n"),
134
+ "utf8",
135
+ );
136
+
137
+ await expect(readSkillFrontmatter(file)).resolves.toEqual({
138
+ description: "Metadata body",
139
+ metadata: {
140
+ alias_of: "canonical-skill",
141
+ },
142
+ });
143
+ });
144
+
95
145
  it("loads direct local markdown references from SKILL.md", async () => {
96
146
  const dir = createTempDir("mcp-refs-read-");
97
147
  const file = path.join(dir, "SKILL.md");
@@ -160,6 +210,57 @@ describe("skill content IO", () => {
160
210
  { relativePath: "overview.md", content: "# Overview\nSibling content" },
161
211
  ]);
162
212
  });
213
+
214
+ it("lists available reference paths from explicit and sibling markdown files", async () => {
215
+ const dir = createTempDir("mcp-refs-list-");
216
+ const file = path.join(dir, "SKILL.md");
217
+ const refsDir = path.join(dir, "references");
218
+ mkdirSync(refsDir, { recursive: true });
219
+ writeFileSync(
220
+ file,
221
+ [
222
+ "---",
223
+ "name: listed-refs",
224
+ "description: refs",
225
+ "---",
226
+ "# Skill",
227
+ "See [Guide](references/guide.md).",
228
+ ].join("\n"),
229
+ "utf8",
230
+ );
231
+ writeFileSync(path.join(refsDir, "guide.md"), "# Guide", "utf8");
232
+ writeFileSync(path.join(dir, "overview.md"), "# Overview", "utf8");
233
+
234
+ await expect(listReferencedMarkdownPaths(file)).resolves.toEqual([
235
+ "overview.md",
236
+ "references/guide.md",
237
+ ]);
238
+ });
239
+
240
+ it("reads one validated skill reference file", async () => {
241
+ const dir = createTempDir("mcp-ref-file-read-");
242
+ const file = path.join(dir, "SKILL.md");
243
+ const refsDir = path.join(dir, "references");
244
+ mkdirSync(refsDir, { recursive: true });
245
+ writeFileSync(
246
+ file,
247
+ [
248
+ "---",
249
+ "name: ref-reader",
250
+ "description: refs",
251
+ "---",
252
+ "# Skill",
253
+ "See [Guide](references/guide.md).",
254
+ ].join("\n"),
255
+ "utf8",
256
+ );
257
+ writeFileSync(path.join(refsDir, "guide.md"), "# Guide\nReference body", "utf8");
258
+
259
+ await expect(readSkillReferenceFile(file, "references/guide.md")).resolves.toEqual({
260
+ relativePath: "references/guide.md",
261
+ content: "# Guide\nReference body",
262
+ });
263
+ });
163
264
  });
164
265
 
165
266
  describe("manifest enrichment", () => {
@@ -101,6 +101,48 @@ export interface ReferencedSkillFile {
101
101
  content: string;
102
102
  }
103
103
 
104
+ export interface SkillFrontmatter {
105
+ description?: string;
106
+ metadata: Record<string, string>;
107
+ }
108
+
109
+ function parseFrontmatter(content: string): { raw: string; body: string } {
110
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
111
+ if (!match) return { raw: "", body: content };
112
+ return { raw: match[1], body: content.slice(match[0].length) };
113
+ }
114
+
115
+ function parseMetadataFromFrontmatter(
116
+ frontmatter: string,
117
+ ): Record<string, string> {
118
+ const lines = frontmatter.split(/\r?\n/);
119
+ const metadata: Record<string, string> = {};
120
+ let inMetadata = false;
121
+
122
+ for (const line of lines) {
123
+ if (/^metadata\s*:\s*$/.test(line)) {
124
+ inMetadata = true;
125
+ continue;
126
+ }
127
+ if (!inMetadata) continue;
128
+ if (!line.trim()) continue;
129
+ if (!/^\s+/.test(line)) break;
130
+ const kv = line.match(/^\s+([A-Za-z0-9_-]+)\s*:\s*(.+)\s*$/);
131
+ if (!kv) continue;
132
+ metadata[kv[1]] = kv[2].trim().replace(/^['"]|['"]$/g, "");
133
+ }
134
+
135
+ return metadata;
136
+ }
137
+
138
+ export function parseSkillFrontmatter(content: string): SkillFrontmatter {
139
+ const { raw } = parseFrontmatter(content);
140
+ return {
141
+ description: parseDescriptionFromFrontmatter(content, Number.MAX_SAFE_INTEGER),
142
+ metadata: parseMetadataFromFrontmatter(raw),
143
+ };
144
+ }
145
+
104
146
  function normalizeLinkTarget(rawTarget: string): string | null {
105
147
  let target = String(rawTarget || "").trim();
106
148
  if (!target) return null;
@@ -218,6 +260,47 @@ async function collectSiblingMarkdownTargets(
218
260
  return targets;
219
261
  }
220
262
 
263
+ export async function listReferencedMarkdownPaths(
264
+ skillPath: string,
265
+ skillContent?: string,
266
+ ): Promise<string[]> {
267
+ const source = skillContent ?? (await readFullSkillContent(skillPath));
268
+ const skillDir = path.dirname(skillPath);
269
+ const explicitTargets = collectReferencedMarkdownTargets(source);
270
+ const siblingTargets = await collectSiblingMarkdownTargets(skillDir);
271
+ const merged = new Set<string>([...explicitTargets, ...siblingTargets]);
272
+ return [...merged].sort((a, b) => a.localeCompare(b)).slice(0, MAX_REFERENCED_FILES);
273
+ }
274
+
275
+ export async function readSkillReferenceFile(
276
+ skillPath: string,
277
+ relativePath: string,
278
+ ): Promise<ReferencedSkillFile> {
279
+ const normalized = String(relativePath || "").trim();
280
+ if (!normalized || !normalized.toLowerCase().endsWith(".md")) {
281
+ throw new Error("Reference path must be a non-empty relative markdown file.");
282
+ }
283
+
284
+ const available = await listReferencedMarkdownPaths(skillPath);
285
+ if (!available.includes(normalized)) {
286
+ throw new Error(
287
+ `Reference path "${normalized}" is not available for this skill.`,
288
+ );
289
+ }
290
+
291
+ const skillDir = path.dirname(skillPath);
292
+ const resolved = path.resolve(skillDir, normalized);
293
+ const relative = path.relative(skillDir, resolved);
294
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
295
+ throw new Error(`Reference path "${normalized}" escapes the skill directory.`);
296
+ }
297
+
298
+ return {
299
+ relativePath: relative.split(path.sep).join("/"),
300
+ content: await readFile(resolved, "utf8"),
301
+ };
302
+ }
303
+
221
304
  /**
222
305
  * Read full SKILL.md content and any direct local markdown references declared
223
306
  * in the skill document. Reference discovery is one-hop (no recursive crawling).
@@ -235,6 +318,13 @@ export async function readSkillContentWithReferences(
235
318
  return { skillContent, references };
236
319
  }
237
320
 
321
+ export async function readSkillFrontmatter(
322
+ skillPath: string,
323
+ ): Promise<SkillFrontmatter> {
324
+ const content = await readFullSkillContent(skillPath);
325
+ return parseSkillFrontmatter(content);
326
+ }
327
+
238
328
  /**
239
329
  * Enrich skill pointers with descriptions (for browse/search results).
240
330
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubis/foundry",
3
- "version": "0.3.62",
3
+ "version": "0.3.63",
4
4
  "description": "Cubis Foundry CLI for workflow-first AI agent environments",
5
5
  "type": "module",
6
6
  "bin": {