@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.
@@ -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, "&amp;")
92
+ .replace(/</g, "&lt;")
93
+ .replace(/>/g, "&gt;")
94
+ .replace(/"/g, "&quot;")
95
+ .replace(/'/g, "&apos;");
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
+ }