@skilljack/mcp 0.3.0
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/LICENSE +21 -0
- package/README.md +260 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +271 -0
- package/dist/roots-handler.d.ts +49 -0
- package/dist/roots-handler.js +199 -0
- package/dist/skill-discovery.d.ts +33 -0
- package/dist/skill-discovery.js +144 -0
- package/dist/skill-resources.d.ts +26 -0
- package/dist/skill-resources.js +286 -0
- package/dist/skill-tool.d.ts +46 -0
- package/dist/skill-tool.js +362 -0
- package/dist/subscriptions.d.ts +78 -0
- package/dist/subscriptions.js +285 -0
- package/package.json +54 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Roots handler for dynamic skill discovery.
|
|
3
|
+
*
|
|
4
|
+
* Requests roots from the client, scans for skills in each root,
|
|
5
|
+
* and handles root change notifications.
|
|
6
|
+
*
|
|
7
|
+
* Pattern adapted from:
|
|
8
|
+
* - .claude/skills/mcp-server-ts/snippets/server/index.ts (oninitialized, syncRoots)
|
|
9
|
+
* - .claude/skills/mcp-client-ts/snippets/handlers/roots.ts (URI conversion)
|
|
10
|
+
*/
|
|
11
|
+
import { RootsListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { discoverSkills, generateInstructions, createSkillMap, } from "./skill-discovery.js";
|
|
16
|
+
/**
|
|
17
|
+
* Skill discovery locations within each root.
|
|
18
|
+
*/
|
|
19
|
+
export const SKILL_SUBDIRS = [".claude/skills", "skills"];
|
|
20
|
+
/**
|
|
21
|
+
* Convert a file:// URI to a filesystem path.
|
|
22
|
+
* Adapted from mcp-client-ts roots.ts pathToRoot() (reverse direction).
|
|
23
|
+
*/
|
|
24
|
+
function uriToPath(uri) {
|
|
25
|
+
return fileURLToPath(new URL(uri));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Discover skills from all roots provided by the client.
|
|
29
|
+
*
|
|
30
|
+
* Scans each root for skill directories (.claude/skills/, skills/)
|
|
31
|
+
* and handles naming conflicts by prefixing with root name.
|
|
32
|
+
*
|
|
33
|
+
* @param roots - Array of Root objects from client's roots/list response
|
|
34
|
+
* @returns Object containing discovered skills
|
|
35
|
+
*/
|
|
36
|
+
export function discoverSkillsFromRoots(roots) {
|
|
37
|
+
const allSkills = [];
|
|
38
|
+
const rootSources = new Map(); // skill path -> root name
|
|
39
|
+
const nameCount = new Map(); // track duplicates
|
|
40
|
+
for (const root of roots) {
|
|
41
|
+
let rootPath;
|
|
42
|
+
try {
|
|
43
|
+
rootPath = uriToPath(root.uri);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
console.error(`Failed to parse root URI "${root.uri}":`, error);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const rootName = root.name || path.basename(rootPath);
|
|
50
|
+
for (const subdir of SKILL_SUBDIRS) {
|
|
51
|
+
const skillsDir = path.join(rootPath, subdir);
|
|
52
|
+
if (fs.existsSync(skillsDir)) {
|
|
53
|
+
try {
|
|
54
|
+
const skills = discoverSkills(skillsDir);
|
|
55
|
+
for (const skill of skills) {
|
|
56
|
+
// Track which root this skill came from
|
|
57
|
+
rootSources.set(skill.path, rootName);
|
|
58
|
+
// Count occurrences of each name
|
|
59
|
+
const count = (nameCount.get(skill.name) || 0) + 1;
|
|
60
|
+
nameCount.set(skill.name, count);
|
|
61
|
+
allSkills.push(skill);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
console.error(`Failed to discover skills in "${skillsDir}":`, error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Handle naming conflicts by prefixing duplicates with root name
|
|
71
|
+
for (const skill of allSkills) {
|
|
72
|
+
if (nameCount.get(skill.name) > 1) {
|
|
73
|
+
const rootName = rootSources.get(skill.path);
|
|
74
|
+
skill.name = `${rootName}:${skill.name}`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { skills: allSkills, rootSources };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Discover skills from a directory, checking both the directory itself
|
|
81
|
+
* and SKILL_SUBDIRS subdirectories.
|
|
82
|
+
*/
|
|
83
|
+
function discoverSkillsFromDirectory(skillsDir) {
|
|
84
|
+
const allSkills = [];
|
|
85
|
+
// Check if the directory itself contains skills
|
|
86
|
+
const directSkills = discoverSkills(skillsDir);
|
|
87
|
+
allSkills.push(...directSkills);
|
|
88
|
+
// Also check SKILL_SUBDIRS subdirectories
|
|
89
|
+
for (const subdir of SKILL_SUBDIRS) {
|
|
90
|
+
const subPath = path.join(skillsDir, subdir);
|
|
91
|
+
if (fs.existsSync(subPath)) {
|
|
92
|
+
const subdirSkills = discoverSkills(subPath);
|
|
93
|
+
allSkills.push(...subdirSkills);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return allSkills;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Sync skills from roots or configured skills directory.
|
|
100
|
+
*
|
|
101
|
+
* Pattern from mcp-server-ts snippets/server/index.ts:
|
|
102
|
+
* - Check client capabilities
|
|
103
|
+
* - Request roots if supported
|
|
104
|
+
* - Use skills directory if not
|
|
105
|
+
*
|
|
106
|
+
* @param server - The McpServer instance
|
|
107
|
+
* @param skillsDir - Optional skills directory if client doesn't support roots
|
|
108
|
+
* @param onSkillsChanged - Callback when skills are updated
|
|
109
|
+
*/
|
|
110
|
+
export async function syncSkills(server, skillsDir, onSkillsChanged) {
|
|
111
|
+
const capabilities = server.server.getClientCapabilities();
|
|
112
|
+
const allSkills = [];
|
|
113
|
+
const seenNames = new Set();
|
|
114
|
+
// Always discover from configured skills directory first
|
|
115
|
+
if (skillsDir) {
|
|
116
|
+
const dirSkills = discoverSkillsFromDirectory(skillsDir);
|
|
117
|
+
console.error(`Discovered ${dirSkills.length} skill(s) from skills directory`);
|
|
118
|
+
for (const skill of dirSkills) {
|
|
119
|
+
if (!seenNames.has(skill.name)) {
|
|
120
|
+
seenNames.add(skill.name);
|
|
121
|
+
allSkills.push(skill);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Also discover from roots if client supports them
|
|
126
|
+
if (capabilities?.roots) {
|
|
127
|
+
console.error("Client supports roots, requesting workspace roots...");
|
|
128
|
+
try {
|
|
129
|
+
const { roots } = await server.server.listRoots();
|
|
130
|
+
console.error(`Received ${roots.length} root(s) from client`);
|
|
131
|
+
const { skills: rootSkills } = discoverSkillsFromRoots(roots);
|
|
132
|
+
console.error(`Discovered ${rootSkills.length} skill(s) from roots`);
|
|
133
|
+
// Add roots skills, skipping duplicates (skillsDir takes precedence)
|
|
134
|
+
for (const skill of rootSkills) {
|
|
135
|
+
if (!seenNames.has(skill.name)) {
|
|
136
|
+
seenNames.add(skill.name);
|
|
137
|
+
allSkills.push(skill);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Listen for roots changes if client supports listChanged
|
|
141
|
+
if (capabilities.roots.listChanged) {
|
|
142
|
+
setupRootsChangeHandler(server, skillsDir, onSkillsChanged);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
console.error("Failed to get roots from client:", error);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
console.error("Client does not support roots");
|
|
151
|
+
}
|
|
152
|
+
console.error(`Total skills available: ${allSkills.length}`);
|
|
153
|
+
const skillMap = createSkillMap(allSkills);
|
|
154
|
+
const instructions = generateInstructions(allSkills);
|
|
155
|
+
onSkillsChanged(skillMap, instructions);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Set up handler for roots/list_changed notifications.
|
|
159
|
+
*/
|
|
160
|
+
function setupRootsChangeHandler(server, skillsDir, onSkillsChanged) {
|
|
161
|
+
server.server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
|
|
162
|
+
console.error("Roots changed notification received, re-discovering skills...");
|
|
163
|
+
try {
|
|
164
|
+
const allSkills = [];
|
|
165
|
+
const seenNames = new Set();
|
|
166
|
+
// Always include skills from configured directory first
|
|
167
|
+
if (skillsDir) {
|
|
168
|
+
const dirSkills = discoverSkillsFromDirectory(skillsDir);
|
|
169
|
+
for (const skill of dirSkills) {
|
|
170
|
+
if (!seenNames.has(skill.name)) {
|
|
171
|
+
seenNames.add(skill.name);
|
|
172
|
+
allSkills.push(skill);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Add skills from roots
|
|
177
|
+
const { roots } = await server.server.listRoots();
|
|
178
|
+
const { skills: rootSkills } = discoverSkillsFromRoots(roots);
|
|
179
|
+
console.error(`Re-discovered ${rootSkills.length} skill(s) from updated roots`);
|
|
180
|
+
for (const skill of rootSkills) {
|
|
181
|
+
if (!seenNames.has(skill.name)) {
|
|
182
|
+
seenNames.add(skill.name);
|
|
183
|
+
allSkills.push(skill);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
console.error(`Total skills available: ${allSkills.length}`);
|
|
187
|
+
const skillMap = createSkillMap(allSkills);
|
|
188
|
+
const instructions = generateInstructions(allSkills);
|
|
189
|
+
onSkillsChanged(skillMap, instructions);
|
|
190
|
+
// Notify client that resources have changed
|
|
191
|
+
await server.server.notification({
|
|
192
|
+
method: "notifications/resources/list_changed",
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
console.error("Failed to re-discover skills after roots change:", error);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill discovery and metadata parsing module.
|
|
3
|
+
*
|
|
4
|
+
* Discovers Agent Skills from a directory, parses YAML frontmatter,
|
|
5
|
+
* and generates server instructions XML.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Metadata extracted from a skill's SKILL.md frontmatter.
|
|
9
|
+
*/
|
|
10
|
+
export interface SkillMetadata {
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
path: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Discover all skills in a directory.
|
|
17
|
+
* Scans for subdirectories containing SKILL.md files.
|
|
18
|
+
*/
|
|
19
|
+
export declare function discoverSkills(skillsDir: string): SkillMetadata[];
|
|
20
|
+
/**
|
|
21
|
+
* Generate the server instructions with available skills.
|
|
22
|
+
* Includes a brief preamble about skill usage following the Agent Skills spec.
|
|
23
|
+
*/
|
|
24
|
+
export declare function generateInstructions(skills: SkillMetadata[]): string;
|
|
25
|
+
/**
|
|
26
|
+
* Load the full content of a skill's SKILL.md file.
|
|
27
|
+
*/
|
|
28
|
+
export declare function loadSkillContent(skillPath: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* Create a map from skill name to skill metadata for fast lookup.
|
|
31
|
+
* Uses first-wins behavior: if duplicate names exist, the first occurrence is kept.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createSkillMap(skills: SkillMetadata[]): Map<string, SkillMetadata>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill discovery and metadata parsing module.
|
|
3
|
+
*
|
|
4
|
+
* Discovers Agent Skills from a directory, parses YAML frontmatter,
|
|
5
|
+
* and generates server instructions XML.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { parse as parseYaml } from "yaml";
|
|
10
|
+
/**
|
|
11
|
+
* Find the SKILL.md file in a skill directory.
|
|
12
|
+
* Prefers SKILL.md (uppercase) but accepts skill.md (lowercase).
|
|
13
|
+
*/
|
|
14
|
+
function findSkillMd(skillDir) {
|
|
15
|
+
for (const name of ["SKILL.md", "skill.md"]) {
|
|
16
|
+
const filePath = path.join(skillDir, name);
|
|
17
|
+
if (fs.existsSync(filePath)) {
|
|
18
|
+
return filePath;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Parse YAML frontmatter from SKILL.md content.
|
|
25
|
+
* Returns the parsed metadata and the markdown body.
|
|
26
|
+
*/
|
|
27
|
+
function parseFrontmatter(content) {
|
|
28
|
+
if (!content.startsWith("---")) {
|
|
29
|
+
throw new Error("SKILL.md must start with YAML frontmatter (---)");
|
|
30
|
+
}
|
|
31
|
+
const parts = content.split("---");
|
|
32
|
+
if (parts.length < 3) {
|
|
33
|
+
throw new Error("SKILL.md frontmatter not properly closed with ---");
|
|
34
|
+
}
|
|
35
|
+
const frontmatterStr = parts[1];
|
|
36
|
+
const body = parts.slice(2).join("---").trim();
|
|
37
|
+
const metadata = parseYaml(frontmatterStr);
|
|
38
|
+
if (typeof metadata !== "object" || metadata === null) {
|
|
39
|
+
throw new Error("SKILL.md frontmatter must be a YAML mapping");
|
|
40
|
+
}
|
|
41
|
+
return { metadata, body };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Discover all skills in a directory.
|
|
45
|
+
* Scans for subdirectories containing SKILL.md files.
|
|
46
|
+
*/
|
|
47
|
+
export function discoverSkills(skillsDir) {
|
|
48
|
+
const skills = [];
|
|
49
|
+
if (!fs.existsSync(skillsDir)) {
|
|
50
|
+
console.error(`Skills directory not found: ${skillsDir}`);
|
|
51
|
+
return skills;
|
|
52
|
+
}
|
|
53
|
+
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
if (!entry.isDirectory())
|
|
56
|
+
continue;
|
|
57
|
+
const skillDir = path.join(skillsDir, entry.name);
|
|
58
|
+
const skillMdPath = findSkillMd(skillDir);
|
|
59
|
+
if (!skillMdPath)
|
|
60
|
+
continue;
|
|
61
|
+
try {
|
|
62
|
+
const content = fs.readFileSync(skillMdPath, "utf-8");
|
|
63
|
+
const { metadata } = parseFrontmatter(content);
|
|
64
|
+
const name = metadata.name;
|
|
65
|
+
const description = metadata.description;
|
|
66
|
+
if (typeof name !== "string" || !name.trim()) {
|
|
67
|
+
console.error(`Skill at ${skillDir}: missing or invalid 'name' field`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (typeof description !== "string" || !description.trim()) {
|
|
71
|
+
console.error(`Skill at ${skillDir}: missing or invalid 'description' field`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
skills.push({
|
|
75
|
+
name: name.trim(),
|
|
76
|
+
description: description.trim(),
|
|
77
|
+
path: skillMdPath,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
console.error(`Failed to parse skill at ${skillDir}:`, error);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return skills;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Escape special XML characters.
|
|
88
|
+
*/
|
|
89
|
+
function escapeXml(str) {
|
|
90
|
+
return str
|
|
91
|
+
.replace(/&/g, "&")
|
|
92
|
+
.replace(/</g, "<")
|
|
93
|
+
.replace(/>/g, ">")
|
|
94
|
+
.replace(/"/g, """)
|
|
95
|
+
.replace(/'/g, "'");
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Generate the server instructions with available skills.
|
|
99
|
+
* Includes a brief preamble about skill usage following the Agent Skills spec.
|
|
100
|
+
*/
|
|
101
|
+
export function generateInstructions(skills) {
|
|
102
|
+
const preamble = `# Skills
|
|
103
|
+
|
|
104
|
+
When a user's task matches a skill description below: 1) activate it, 2) follow its instructions completely.
|
|
105
|
+
|
|
106
|
+
`;
|
|
107
|
+
if (skills.length === 0) {
|
|
108
|
+
return preamble + "<available_skills>\n</available_skills>";
|
|
109
|
+
}
|
|
110
|
+
const lines = ["<available_skills>"];
|
|
111
|
+
for (const skill of skills) {
|
|
112
|
+
lines.push("<skill>");
|
|
113
|
+
lines.push(`<name>${escapeXml(skill.name)}</name>`);
|
|
114
|
+
lines.push(`<description>${escapeXml(skill.description)}</description>`);
|
|
115
|
+
lines.push(`<location>${escapeXml(skill.path)}</location>`);
|
|
116
|
+
lines.push("</skill>");
|
|
117
|
+
}
|
|
118
|
+
lines.push("</available_skills>");
|
|
119
|
+
return preamble + lines.join("\n");
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Load the full content of a skill's SKILL.md file.
|
|
123
|
+
*/
|
|
124
|
+
export function loadSkillContent(skillPath) {
|
|
125
|
+
return fs.readFileSync(skillPath, "utf-8");
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Create a map from skill name to skill metadata for fast lookup.
|
|
129
|
+
* Uses first-wins behavior: if duplicate names exist, the first occurrence is kept.
|
|
130
|
+
*/
|
|
131
|
+
export function createSkillMap(skills) {
|
|
132
|
+
const map = new Map();
|
|
133
|
+
for (const skill of skills) {
|
|
134
|
+
if (map.has(skill.name)) {
|
|
135
|
+
const existing = map.get(skill.name);
|
|
136
|
+
console.error(`Warning: Duplicate skill name "${skill.name}" found at ${skill.path} - ` +
|
|
137
|
+
`keeping first occurrence from ${existing.path}`);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
map.set(skill.name, skill);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return map;
|
|
144
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Resource registration for skill-based resources.
|
|
3
|
+
*
|
|
4
|
+
* Resources provide application-controlled access to skill content,
|
|
5
|
+
* complementing the model-controlled tool access.
|
|
6
|
+
*
|
|
7
|
+
* All resources use templates with dynamic list callbacks to support
|
|
8
|
+
* skill updates when MCP roots change.
|
|
9
|
+
*
|
|
10
|
+
* URI Scheme:
|
|
11
|
+
* skill://{skillName} -> SKILL.md content (template)
|
|
12
|
+
* skill://{skillName}/ -> Collection: all files in skill directory
|
|
13
|
+
* skill://{skillName}/{path} -> File within skill directory (template)
|
|
14
|
+
*/
|
|
15
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
+
import { SkillState } from "./skill-tool.js";
|
|
17
|
+
/**
|
|
18
|
+
* Register skill resources with the MCP server.
|
|
19
|
+
*
|
|
20
|
+
* All resources use templates with dynamic list callbacks to support
|
|
21
|
+
* skill updates when MCP roots change.
|
|
22
|
+
*
|
|
23
|
+
* @param server - The McpServer instance
|
|
24
|
+
* @param skillState - Shared state object (allows dynamic updates)
|
|
25
|
+
*/
|
|
26
|
+
export declare function registerSkillResources(server: McpServer, skillState: SkillState): void;
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Resource registration for skill-based resources.
|
|
3
|
+
*
|
|
4
|
+
* Resources provide application-controlled access to skill content,
|
|
5
|
+
* complementing the model-controlled tool access.
|
|
6
|
+
*
|
|
7
|
+
* All resources use templates with dynamic list callbacks to support
|
|
8
|
+
* skill updates when MCP roots change.
|
|
9
|
+
*
|
|
10
|
+
* URI Scheme:
|
|
11
|
+
* skill://{skillName} -> SKILL.md content (template)
|
|
12
|
+
* skill://{skillName}/ -> Collection: all files in skill directory
|
|
13
|
+
* skill://{skillName}/{path} -> File within skill directory (template)
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
|
+
import { loadSkillContent } from "./skill-discovery.js";
|
|
19
|
+
import { isPathWithinBase, listSkillFiles, MAX_FILE_SIZE } from "./skill-tool.js";
|
|
20
|
+
/**
|
|
21
|
+
* Get MIME type based on file extension.
|
|
22
|
+
*/
|
|
23
|
+
function getMimeType(filePath) {
|
|
24
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
25
|
+
const mimeTypes = {
|
|
26
|
+
".md": "text/markdown",
|
|
27
|
+
".ts": "text/typescript",
|
|
28
|
+
".js": "text/javascript",
|
|
29
|
+
".json": "application/json",
|
|
30
|
+
".yaml": "text/yaml",
|
|
31
|
+
".yml": "text/yaml",
|
|
32
|
+
".txt": "text/plain",
|
|
33
|
+
".sh": "text/x-shellscript",
|
|
34
|
+
".py": "text/x-python",
|
|
35
|
+
".css": "text/css",
|
|
36
|
+
".html": "text/html",
|
|
37
|
+
".xml": "application/xml",
|
|
38
|
+
};
|
|
39
|
+
return mimeTypes[ext] || "text/plain";
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Register skill resources with the MCP server.
|
|
43
|
+
*
|
|
44
|
+
* All resources use templates with dynamic list callbacks to support
|
|
45
|
+
* skill updates when MCP roots change.
|
|
46
|
+
*
|
|
47
|
+
* @param server - The McpServer instance
|
|
48
|
+
* @param skillState - Shared state object (allows dynamic updates)
|
|
49
|
+
*/
|
|
50
|
+
export function registerSkillResources(server, skillState) {
|
|
51
|
+
// Register template for individual skill SKILL.md files
|
|
52
|
+
registerSkillTemplate(server, skillState);
|
|
53
|
+
// Register collection resource for skill directories (must be before file template)
|
|
54
|
+
registerSkillDirectoryCollection(server, skillState);
|
|
55
|
+
// Register resource template for skill files
|
|
56
|
+
registerSkillFileTemplate(server, skillState);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Register a collection resource for skill directories.
|
|
60
|
+
*
|
|
61
|
+
* URI Pattern: skill://{skillName}/
|
|
62
|
+
*
|
|
63
|
+
* Returns all files in the skill directory (excluding SKILL.md) in a single request.
|
|
64
|
+
* This allows clients to fetch all resource files for a skill at once.
|
|
65
|
+
*/
|
|
66
|
+
function registerSkillDirectoryCollection(server, skillState) {
|
|
67
|
+
server.registerResource("Skill Directory", new ResourceTemplate("skill://{skillName}/", {
|
|
68
|
+
list: async () => {
|
|
69
|
+
// Return one entry per skill (the directory collection)
|
|
70
|
+
const resources = [];
|
|
71
|
+
for (const [name, skill] of skillState.skillMap) {
|
|
72
|
+
resources.push({
|
|
73
|
+
uri: `skill://${encodeURIComponent(name)}/`,
|
|
74
|
+
name: `${name}/`,
|
|
75
|
+
mimeType: "text/plain",
|
|
76
|
+
description: `All files in ${name} skill directory`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return { resources };
|
|
80
|
+
},
|
|
81
|
+
complete: {
|
|
82
|
+
skillName: (value) => {
|
|
83
|
+
const names = Array.from(skillState.skillMap.keys());
|
|
84
|
+
return names.filter((n) => n.toLowerCase().startsWith(value.toLowerCase()));
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
}), {
|
|
88
|
+
mimeType: "text/plain",
|
|
89
|
+
description: "Collection of all files in a skill directory (excluding SKILL.md)",
|
|
90
|
+
}, async (resourceUri) => {
|
|
91
|
+
// Extract skill name from URI
|
|
92
|
+
const uriStr = resourceUri.toString();
|
|
93
|
+
const match = uriStr.match(/^skill:\/\/([^/]+)\/$/);
|
|
94
|
+
if (!match) {
|
|
95
|
+
throw new Error(`Invalid skill directory URI: ${uriStr}`);
|
|
96
|
+
}
|
|
97
|
+
const skillName = decodeURIComponent(match[1]);
|
|
98
|
+
const skill = skillState.skillMap.get(skillName);
|
|
99
|
+
if (!skill) {
|
|
100
|
+
const available = Array.from(skillState.skillMap.keys()).join(", ");
|
|
101
|
+
throw new Error(`Skill "${skillName}" not found. Available: ${available || "none"}`);
|
|
102
|
+
}
|
|
103
|
+
const skillDir = path.dirname(skill.path);
|
|
104
|
+
const files = listSkillFiles(skillDir);
|
|
105
|
+
const contents = [];
|
|
106
|
+
for (const file of files) {
|
|
107
|
+
const fullPath = path.join(skillDir, file);
|
|
108
|
+
// Security: Validate path is within skill directory
|
|
109
|
+
if (!isPathWithinBase(fullPath, skillDir)) {
|
|
110
|
+
continue; // Skip files outside skill directory
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const stat = fs.statSync(fullPath);
|
|
114
|
+
// Skip symlinks and directories
|
|
115
|
+
if (stat.isSymbolicLink() || stat.isDirectory()) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
// Check file size
|
|
119
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
120
|
+
continue; // Skip large files
|
|
121
|
+
}
|
|
122
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
123
|
+
contents.push({
|
|
124
|
+
uri: `skill://${encodeURIComponent(skillName)}/${file}`,
|
|
125
|
+
mimeType: getMimeType(file),
|
|
126
|
+
text: content,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
// Skip files that fail to load
|
|
131
|
+
console.error(`Failed to load file "${file}" in skill "${skillName}":`, error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { contents };
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Register a template for individual skill SKILL.md resources.
|
|
139
|
+
*
|
|
140
|
+
* URI Pattern: skill://{skillName}
|
|
141
|
+
*
|
|
142
|
+
* Uses a template with a list callback to dynamically return available skills.
|
|
143
|
+
*/
|
|
144
|
+
function registerSkillTemplate(server, skillState) {
|
|
145
|
+
server.registerResource("Skill", new ResourceTemplate("skill://{skillName}", {
|
|
146
|
+
list: async () => {
|
|
147
|
+
// Dynamically return current skills
|
|
148
|
+
const resources = [];
|
|
149
|
+
for (const [name, skill] of skillState.skillMap) {
|
|
150
|
+
resources.push({
|
|
151
|
+
uri: `skill://${encodeURIComponent(name)}`,
|
|
152
|
+
name,
|
|
153
|
+
mimeType: "text/markdown",
|
|
154
|
+
description: skill.description,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return { resources };
|
|
158
|
+
},
|
|
159
|
+
complete: {
|
|
160
|
+
skillName: (value) => {
|
|
161
|
+
const names = Array.from(skillState.skillMap.keys());
|
|
162
|
+
return names.filter((name) => name.toLowerCase().startsWith(value.toLowerCase()));
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
}), {
|
|
166
|
+
mimeType: "text/markdown",
|
|
167
|
+
description: "SKILL.md content for a skill",
|
|
168
|
+
}, async (resourceUri) => {
|
|
169
|
+
// Extract skill name from URI
|
|
170
|
+
const uriStr = resourceUri.toString();
|
|
171
|
+
const match = uriStr.match(/^skill:\/\/([^/]+)$/);
|
|
172
|
+
if (!match) {
|
|
173
|
+
throw new Error(`Invalid skill URI: ${uriStr}`);
|
|
174
|
+
}
|
|
175
|
+
const skillName = decodeURIComponent(match[1]);
|
|
176
|
+
const skill = skillState.skillMap.get(skillName);
|
|
177
|
+
if (!skill) {
|
|
178
|
+
const available = Array.from(skillState.skillMap.keys()).join(", ");
|
|
179
|
+
throw new Error(`Skill "${skillName}" not found. Available: ${available || "none"}`);
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const content = loadSkillContent(skill.path);
|
|
183
|
+
return {
|
|
184
|
+
contents: [
|
|
185
|
+
{
|
|
186
|
+
uri: uriStr,
|
|
187
|
+
mimeType: "text/markdown",
|
|
188
|
+
text: content,
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
195
|
+
throw new Error(`Failed to load skill "${skillName}": ${message}`);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Register the resource template for accessing files within skills.
|
|
201
|
+
*
|
|
202
|
+
* URI Pattern: skill://{skillName}/{filePath}
|
|
203
|
+
*/
|
|
204
|
+
function registerSkillFileTemplate(server, skillState) {
|
|
205
|
+
server.registerResource("Skill File", new ResourceTemplate("skill://{skillName}/{+filePath}", {
|
|
206
|
+
list: async () => {
|
|
207
|
+
// Return all listable skill files (dynamic based on current skillMap)
|
|
208
|
+
const resources = [];
|
|
209
|
+
for (const [name, skill] of skillState.skillMap) {
|
|
210
|
+
const skillDir = path.dirname(skill.path);
|
|
211
|
+
const files = listSkillFiles(skillDir);
|
|
212
|
+
for (const file of files) {
|
|
213
|
+
const uri = `skill://${encodeURIComponent(name)}/${file}`;
|
|
214
|
+
resources.push({
|
|
215
|
+
uri,
|
|
216
|
+
name: `${name}/${file}`,
|
|
217
|
+
mimeType: getMimeType(file),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { resources };
|
|
222
|
+
},
|
|
223
|
+
complete: {
|
|
224
|
+
skillName: (value) => {
|
|
225
|
+
const names = Array.from(skillState.skillMap.keys());
|
|
226
|
+
return names.filter((name) => name.toLowerCase().startsWith(value.toLowerCase()));
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
}), {
|
|
230
|
+
mimeType: "text/plain",
|
|
231
|
+
description: "Files within a skill directory (scripts, snippets, assets, etc.)",
|
|
232
|
+
}, async (resourceUri, variables) => {
|
|
233
|
+
// Extract skill name and file path from URI
|
|
234
|
+
const uriStr = resourceUri.toString();
|
|
235
|
+
const match = uriStr.match(/^skill:\/\/([^/]+)\/(.+)$/);
|
|
236
|
+
if (!match) {
|
|
237
|
+
throw new Error(`Invalid skill file URI: ${uriStr}`);
|
|
238
|
+
}
|
|
239
|
+
const skillName = decodeURIComponent(match[1]);
|
|
240
|
+
const filePath = match[2];
|
|
241
|
+
const skill = skillState.skillMap.get(skillName);
|
|
242
|
+
if (!skill) {
|
|
243
|
+
const available = Array.from(skillState.skillMap.keys()).join(", ");
|
|
244
|
+
throw new Error(`Skill "${skillName}" not found. Available: ${available || "none"}`);
|
|
245
|
+
}
|
|
246
|
+
const skillDir = path.dirname(skill.path);
|
|
247
|
+
const fullPath = path.resolve(skillDir, filePath);
|
|
248
|
+
// Security: Validate path is within skill directory
|
|
249
|
+
if (!isPathWithinBase(fullPath, skillDir)) {
|
|
250
|
+
throw new Error(`Path "${filePath}" is outside the skill directory`);
|
|
251
|
+
}
|
|
252
|
+
// Check file exists
|
|
253
|
+
if (!fs.existsSync(fullPath)) {
|
|
254
|
+
const files = listSkillFiles(skillDir).slice(0, 10);
|
|
255
|
+
throw new Error(`File "${filePath}" not found in skill "${skillName}". ` +
|
|
256
|
+
`Available: ${files.join(", ")}${files.length >= 10 ? "..." : ""}`);
|
|
257
|
+
}
|
|
258
|
+
const stat = fs.statSync(fullPath);
|
|
259
|
+
// Reject symlinks
|
|
260
|
+
if (stat.isSymbolicLink()) {
|
|
261
|
+
throw new Error(`Cannot read symlink "${filePath}"`);
|
|
262
|
+
}
|
|
263
|
+
// Reject directories
|
|
264
|
+
if (stat.isDirectory()) {
|
|
265
|
+
const files = listSkillFiles(skillDir, filePath);
|
|
266
|
+
throw new Error(`"${filePath}" is a directory. Files within: ${files.join(", ")}`);
|
|
267
|
+
}
|
|
268
|
+
// Check file size
|
|
269
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
270
|
+
const sizeMB = (stat.size / 1024 / 1024).toFixed(2);
|
|
271
|
+
throw new Error(`File too large (${sizeMB}MB). Maximum: 10MB`);
|
|
272
|
+
}
|
|
273
|
+
// Read and return content
|
|
274
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
275
|
+
const mimeType = getMimeType(fullPath);
|
|
276
|
+
return {
|
|
277
|
+
contents: [
|
|
278
|
+
{
|
|
279
|
+
uri: uriStr,
|
|
280
|
+
mimeType,
|
|
281
|
+
text: content,
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
}
|