@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.
- package/mcp/README.md +49 -3
- package/mcp/dist/index.js +238 -30
- package/mcp/src/tools/index.ts +14 -0
- package/mcp/src/tools/registry.test.ts +7 -5
- package/mcp/src/tools/registry.ts +38 -0
- package/mcp/src/tools/skillGetReference.ts +77 -0
- package/mcp/src/tools/skillTools.test.ts +156 -0
- package/mcp/src/tools/skillValidate.ts +101 -0
- package/mcp/src/vault/manifest.test.ts +101 -0
- package/mcp/src/vault/manifest.ts +90 -0
- package/package.json +1 -1
- package/workflows/workflows/agent-environment-setup/platforms/antigravity/rules/GEMINI.md +57 -78
- package/workflows/workflows/agent-environment-setup/platforms/codex/rules/AGENTS.md +58 -79
- package/workflows/workflows/agent-environment-setup/platforms/copilot/rules/AGENTS.md +57 -78
- package/workflows/workflows/agent-environment-setup/platforms/copilot/rules/copilot-instructions.md +57 -78
|
@@ -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
|
*/
|