@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,46 @@
1
+ /**
2
+ * MCP tool registration for skill-related tools.
3
+ *
4
+ * - skill: Load and activate a skill by name (returns SKILL.md content)
5
+ * - skill-resource: Read files within a skill directory (scripts/, references/, assets/, etc.)
6
+ *
7
+ * Tools reference a shared SkillState object to support dynamic skill updates
8
+ * when MCP roots change.
9
+ */
10
+ import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { SkillMetadata } from "./skill-discovery.js";
12
+ /**
13
+ * Shared state for dynamic skill management.
14
+ * Tools reference this state object, allowing updates when roots change.
15
+ */
16
+ export interface SkillState {
17
+ skillMap: Map<string, SkillMetadata>;
18
+ }
19
+ /**
20
+ * Register the "skill" tool with the MCP server.
21
+ *
22
+ * The tool description includes the full skill discovery instructions (same format as
23
+ * server instructions) to enable dynamic updates via tools/listChanged notifications.
24
+ *
25
+ * @param server - The McpServer instance
26
+ * @param skillState - Shared state object (allows dynamic updates)
27
+ * @returns The registered tool, which can be updated when skills change
28
+ */
29
+ /**
30
+ * Generate the full tool description including usage guidance and skill list.
31
+ * Exported so index.ts can use it when refreshing skills.
32
+ */
33
+ export declare function getToolDescription(skillState: SkillState): string;
34
+ export declare function registerSkillTool(server: McpServer, skillState: SkillState): RegisteredTool;
35
+ export declare const MAX_FILE_SIZE: number;
36
+ export declare const MAX_DIRECTORY_DEPTH = 10;
37
+ /**
38
+ * Check if a path is within the allowed base directory.
39
+ * Uses fs.realpathSync to resolve symlinks and prevent symlink escape attacks.
40
+ */
41
+ export declare function isPathWithinBase(targetPath: string, baseDir: string): boolean;
42
+ /**
43
+ * List files in a skill directory for discovery.
44
+ * Limits recursion depth to prevent DoS from deeply nested directories.
45
+ */
46
+ export declare function listSkillFiles(skillDir: string, subPath?: string, depth?: number): string[];
@@ -0,0 +1,362 @@
1
+ /**
2
+ * MCP tool registration for skill-related tools.
3
+ *
4
+ * - skill: Load and activate a skill by name (returns SKILL.md content)
5
+ * - skill-resource: Read files within a skill directory (scripts/, references/, assets/, etc.)
6
+ *
7
+ * Tools reference a shared SkillState object to support dynamic skill updates
8
+ * when MCP roots change.
9
+ */
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import { z } from "zod";
13
+ import { loadSkillContent, generateInstructions } from "./skill-discovery.js";
14
+ /**
15
+ * Input schema for the skill tool.
16
+ */
17
+ const SkillSchema = z.object({
18
+ name: z.string().describe("Skill name from <available_skills>"),
19
+ });
20
+ /**
21
+ * Register the "skill" tool with the MCP server.
22
+ *
23
+ * The tool description includes the full skill discovery instructions (same format as
24
+ * server instructions) to enable dynamic updates via tools/listChanged notifications.
25
+ *
26
+ * @param server - The McpServer instance
27
+ * @param skillState - Shared state object (allows dynamic updates)
28
+ * @returns The registered tool, which can be updated when skills change
29
+ */
30
+ /**
31
+ * Generate the full tool description including usage guidance and skill list.
32
+ * Exported so index.ts can use it when refreshing skills.
33
+ */
34
+ export function getToolDescription(skillState) {
35
+ const usage = "Load a skill's full instructions. Returns the complete SKILL.md content " +
36
+ "with step-by-step guidance, examples, and file references to follow.\n\n";
37
+ const skills = Array.from(skillState.skillMap.values());
38
+ return usage + generateInstructions(skills);
39
+ }
40
+ export function registerSkillTool(server, skillState) {
41
+ const skillTool = server.registerTool("skill", {
42
+ title: "Activate Skill",
43
+ description: getToolDescription(skillState),
44
+ inputSchema: SkillSchema,
45
+ annotations: {
46
+ readOnlyHint: true,
47
+ destructiveHint: false,
48
+ idempotentHint: true,
49
+ openWorldHint: false,
50
+ },
51
+ }, async (args) => {
52
+ const { name } = SkillSchema.parse(args);
53
+ const skill = skillState.skillMap.get(name);
54
+ if (!skill) {
55
+ const availableSkills = Array.from(skillState.skillMap.keys()).join(", ");
56
+ return {
57
+ content: [
58
+ {
59
+ type: "text",
60
+ text: `Skill "${name}" not found. Available skills: ${availableSkills || "none"}`,
61
+ },
62
+ ],
63
+ isError: true,
64
+ };
65
+ }
66
+ try {
67
+ const content = loadSkillContent(skill.path);
68
+ return {
69
+ content: [
70
+ {
71
+ type: "text",
72
+ text: content,
73
+ },
74
+ ],
75
+ };
76
+ }
77
+ catch (error) {
78
+ const message = error instanceof Error ? error.message : String(error);
79
+ return {
80
+ content: [
81
+ {
82
+ type: "text",
83
+ text: `Failed to load skill "${name}": ${message}`,
84
+ },
85
+ ],
86
+ isError: true,
87
+ };
88
+ }
89
+ });
90
+ // Register the skill-resource tool
91
+ registerSkillResourceTool(server, skillState);
92
+ return skillTool;
93
+ }
94
+ /**
95
+ * Input schema for the skill-resource tool.
96
+ *
97
+ * Per the Agent Skills spec, file references use relative paths from the skill root.
98
+ * Common directories: scripts/, references/, assets/
99
+ */
100
+ const SkillResourceSchema = z.object({
101
+ skill: z.string().describe("Skill name"),
102
+ path: z
103
+ .string()
104
+ .describe("Relative path to file or directory. Examples: 'snippets/tool.ts' (single file), 'templates' (all files in directory), '' (list available files)."),
105
+ });
106
+ // Security constants (exported for reuse in skill-resources.ts)
107
+ const DEFAULT_MAX_FILE_SIZE_MB = 1;
108
+ const maxFileSizeMB = parseInt(process.env.MAX_FILE_SIZE_MB || "", 10) || DEFAULT_MAX_FILE_SIZE_MB;
109
+ export const MAX_FILE_SIZE = maxFileSizeMB * 1024 * 1024; // Configurable via MAX_FILE_SIZE_MB env var
110
+ export const MAX_DIRECTORY_DEPTH = 10; // Prevent deeply nested traversal
111
+ /**
112
+ * Check if a path is within the allowed base directory.
113
+ * Uses fs.realpathSync to resolve symlinks and prevent symlink escape attacks.
114
+ */
115
+ export function isPathWithinBase(targetPath, baseDir) {
116
+ try {
117
+ // Resolve symlinks to get the real paths
118
+ const realBase = fs.realpathSync(baseDir);
119
+ const realTarget = fs.realpathSync(targetPath);
120
+ const normalizedBase = realBase + path.sep;
121
+ return realTarget === realBase || realTarget.startsWith(normalizedBase);
122
+ }
123
+ catch {
124
+ // If realpathSync fails (e.g., file doesn't exist), fall back to resolve check
125
+ // This is safe because we'll get an error when trying to read anyway
126
+ const normalizedBase = path.resolve(baseDir) + path.sep;
127
+ const normalizedPath = path.resolve(targetPath);
128
+ return normalizedPath.startsWith(normalizedBase);
129
+ }
130
+ }
131
+ /**
132
+ * List files in a skill directory for discovery.
133
+ * Limits recursion depth to prevent DoS from deeply nested directories.
134
+ */
135
+ export function listSkillFiles(skillDir, subPath = "", depth = 0) {
136
+ // Prevent excessive recursion
137
+ if (depth > MAX_DIRECTORY_DEPTH) {
138
+ return [];
139
+ }
140
+ const files = [];
141
+ const dirPath = path.join(skillDir, subPath);
142
+ if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
143
+ return files;
144
+ }
145
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
146
+ for (const entry of entries) {
147
+ const relativePath = path.join(subPath, entry.name);
148
+ // Skip symlinks to prevent escape and infinite loops
149
+ if (entry.isSymbolicLink()) {
150
+ continue;
151
+ }
152
+ if (entry.isDirectory()) {
153
+ // Skip node_modules and hidden directories
154
+ if (entry.name !== "node_modules" && !entry.name.startsWith(".")) {
155
+ files.push(...listSkillFiles(skillDir, relativePath, depth + 1));
156
+ }
157
+ }
158
+ else {
159
+ // Skip SKILL.md (use skill tool for that) and common non-resource files
160
+ if (entry.name !== "SKILL.md" && entry.name !== "skill.md") {
161
+ files.push(relativePath.replace(/\\/g, "/"));
162
+ }
163
+ }
164
+ }
165
+ return files;
166
+ }
167
+ /**
168
+ * Register the "skill-resource" tool with the MCP server.
169
+ *
170
+ * This tool provides access to files within a skill's directory structure,
171
+ * following the Agent Skills spec for progressive disclosure of resources.
172
+ *
173
+ * @param server - The McpServer instance
174
+ * @param skillState - Shared state object (allows dynamic updates)
175
+ */
176
+ function registerSkillResourceTool(server, skillState) {
177
+ server.registerTool("skill-resource", {
178
+ title: "Read Skill File",
179
+ description: "Read files referenced by skill instructions (scripts, snippets, templates). " +
180
+ "Use when skill instructions mention specific files to read or copy. " +
181
+ "Pass a directory path (e.g., 'templates') to read all files in that directory at once.",
182
+ inputSchema: SkillResourceSchema,
183
+ annotations: {
184
+ readOnlyHint: true,
185
+ destructiveHint: false,
186
+ idempotentHint: true,
187
+ openWorldHint: false,
188
+ },
189
+ }, async (args) => {
190
+ const { skill: skillName, path: resourcePath } = SkillResourceSchema.parse(args);
191
+ const skill = skillState.skillMap.get(skillName);
192
+ if (!skill) {
193
+ const availableSkills = Array.from(skillState.skillMap.keys()).join(", ");
194
+ return {
195
+ content: [
196
+ {
197
+ type: "text",
198
+ text: `Skill "${skillName}" not found. Available skills: ${availableSkills || "none"}`,
199
+ },
200
+ ],
201
+ isError: true,
202
+ };
203
+ }
204
+ // Get the skill directory (parent of SKILL.md)
205
+ const skillDir = path.dirname(skill.path);
206
+ // If path is empty, list available files
207
+ if (!resourcePath || resourcePath.trim() === "") {
208
+ const files = listSkillFiles(skillDir);
209
+ if (files.length === 0) {
210
+ return {
211
+ content: [
212
+ {
213
+ type: "text",
214
+ text: `No resource files found in skill "${skillName}". The skill only contains SKILL.md.`,
215
+ },
216
+ ],
217
+ };
218
+ }
219
+ return {
220
+ content: [
221
+ {
222
+ type: "text",
223
+ text: `Available resources in skill "${skillName}":\n\n${files.map((f) => `- ${f}`).join("\n")}`,
224
+ },
225
+ ],
226
+ };
227
+ }
228
+ // Resolve the full path and validate it's within the skill directory
229
+ const fullPath = path.resolve(skillDir, resourcePath);
230
+ if (!isPathWithinBase(fullPath, skillDir)) {
231
+ return {
232
+ content: [
233
+ {
234
+ type: "text",
235
+ text: `Invalid path: "${resourcePath}" is outside the skill directory. Use relative paths like "scripts/example.py" or "references/guide.md".`,
236
+ },
237
+ ],
238
+ isError: true,
239
+ };
240
+ }
241
+ // Check if file exists
242
+ if (!fs.existsSync(fullPath)) {
243
+ const files = listSkillFiles(skillDir);
244
+ const suggestions = files.slice(0, 10).join("\n- ");
245
+ return {
246
+ content: [
247
+ {
248
+ type: "text",
249
+ text: `Resource "${resourcePath}" not found in skill "${skillName}".\n\nAvailable files:\n- ${suggestions}${files.length > 10 ? `\n... and ${files.length - 10} more` : ""}`,
250
+ },
251
+ ],
252
+ isError: true,
253
+ };
254
+ }
255
+ // Check file stats
256
+ const stat = fs.statSync(fullPath);
257
+ // Reject symlinks that point outside (defense in depth)
258
+ if (stat.isSymbolicLink()) {
259
+ return {
260
+ content: [
261
+ {
262
+ type: "text",
263
+ text: `Cannot read symlink "${resourcePath}". Only regular files within the skill directory are accessible.`,
264
+ },
265
+ ],
266
+ isError: true,
267
+ };
268
+ }
269
+ // Handle directories - return all file contents
270
+ if (stat.isDirectory()) {
271
+ const files = listSkillFiles(skillDir, resourcePath);
272
+ if (files.length === 0) {
273
+ return {
274
+ content: [
275
+ {
276
+ type: "text",
277
+ text: `Directory "${resourcePath}" is empty or contains no readable files.`,
278
+ },
279
+ ],
280
+ };
281
+ }
282
+ // Read all files and return as multiple content items
283
+ const contents = [];
284
+ for (const file of files) {
285
+ const filePath = path.join(skillDir, file);
286
+ try {
287
+ const fileStat = fs.statSync(filePath);
288
+ if (fileStat.size > MAX_FILE_SIZE) {
289
+ contents.push({
290
+ type: "text",
291
+ text: `--- ${file} ---\n[File too large: ${(fileStat.size / 1024 / 1024).toFixed(2)}MB]`,
292
+ });
293
+ }
294
+ else {
295
+ const fileContent = fs.readFileSync(filePath, "utf-8");
296
+ contents.push({
297
+ type: "text",
298
+ text: `--- ${file} ---\n${fileContent}`,
299
+ });
300
+ }
301
+ }
302
+ catch (error) {
303
+ contents.push({
304
+ type: "text",
305
+ text: `--- ${file} ---\n[Error reading file: ${error instanceof Error ? error.message : "unknown error"}]`,
306
+ });
307
+ }
308
+ }
309
+ return { content: contents };
310
+ }
311
+ // Check file size to prevent memory exhaustion
312
+ if (stat.size > MAX_FILE_SIZE) {
313
+ const sizeMB = (stat.size / 1024 / 1024).toFixed(2);
314
+ const maxMB = (MAX_FILE_SIZE / 1024 / 1024).toFixed(0);
315
+ return {
316
+ content: [
317
+ {
318
+ type: "text",
319
+ text: `File "${resourcePath}" is too large (${sizeMB}MB). Maximum allowed size is ${maxMB}MB.`,
320
+ },
321
+ ],
322
+ isError: true,
323
+ };
324
+ }
325
+ // Final symlink check using realpath (defense in depth)
326
+ if (!isPathWithinBase(fullPath, skillDir)) {
327
+ return {
328
+ content: [
329
+ {
330
+ type: "text",
331
+ text: `Access denied: "${resourcePath}" resolves to a location outside the skill directory.`,
332
+ },
333
+ ],
334
+ isError: true,
335
+ };
336
+ }
337
+ // Read and return the file content
338
+ try {
339
+ const content = fs.readFileSync(fullPath, "utf-8");
340
+ return {
341
+ content: [
342
+ {
343
+ type: "text",
344
+ text: content,
345
+ },
346
+ ],
347
+ };
348
+ }
349
+ catch (error) {
350
+ const message = error instanceof Error ? error.message : String(error);
351
+ return {
352
+ content: [
353
+ {
354
+ type: "text",
355
+ text: `Failed to read resource "${resourcePath}": ${message}`,
356
+ },
357
+ ],
358
+ isError: true,
359
+ };
360
+ }
361
+ });
362
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Resource subscription management with file watching.
3
+ *
4
+ * Tracks client subscriptions to resource URIs and watches underlying files
5
+ * using chokidar. When files change, sends notifications/resources/updated
6
+ * to subscribed clients.
7
+ *
8
+ * URI patterns supported:
9
+ * - skill:// → Watch all skill directories
10
+ * - skill://{name} → Watch that skill's SKILL.md
11
+ * - skill://{name}/ → Watch entire skill directory (directory collection)
12
+ * - skill://{name}/{path} → Watch specific file
13
+ */
14
+ import { FSWatcher } from "chokidar";
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { SkillState } from "./skill-tool.js";
17
+ /**
18
+ * Manages active subscriptions and their associated file watchers.
19
+ */
20
+ export interface SubscriptionManager {
21
+ /** URI -> Set of file paths being watched for this URI */
22
+ uriToFilePaths: Map<string, Set<string>>;
23
+ /** File path -> Set of URIs that depend on this file */
24
+ filePathToUris: Map<string, Set<string>>;
25
+ /** File path -> chokidar watcher instance */
26
+ watchers: Map<string, FSWatcher>;
27
+ /** Pending notification timeouts for debouncing (URI -> timeout) */
28
+ pendingNotifications: Map<string, NodeJS.Timeout>;
29
+ }
30
+ /**
31
+ * Create a new subscription manager.
32
+ */
33
+ export declare function createSubscriptionManager(): SubscriptionManager;
34
+ /**
35
+ * Resolve a skill:// URI to the file paths it depends on.
36
+ *
37
+ * @param uri - The resource URI
38
+ * @param skillState - Current skill state for lookups
39
+ * @returns Array of absolute file paths to watch
40
+ */
41
+ export declare function resolveUriToFilePaths(uri: string, skillState: SkillState): string[];
42
+ /**
43
+ * Add a subscription for a URI.
44
+ *
45
+ * @param manager - The subscription manager
46
+ * @param uri - The resource URI to subscribe to
47
+ * @param skillState - Current skill state for resolving URIs
48
+ * @param onNotify - Callback to send notification when file changes
49
+ * @returns True if subscription was added, false if URI couldn't be resolved
50
+ */
51
+ export declare function subscribe(manager: SubscriptionManager, uri: string, skillState: SkillState, onNotify: (uri: string) => void): boolean;
52
+ /**
53
+ * Remove a subscription for a URI.
54
+ *
55
+ * @param manager - The subscription manager
56
+ * @param uri - The resource URI to unsubscribe from
57
+ */
58
+ export declare function unsubscribe(manager: SubscriptionManager, uri: string): void;
59
+ /**
60
+ * Update subscriptions when skills change.
61
+ *
62
+ * Re-resolves all existing URIs with the new skill state and updates
63
+ * watchers accordingly. Sends notifications for any URIs whose underlying
64
+ * files have changed.
65
+ *
66
+ * @param manager - The subscription manager
67
+ * @param skillState - Updated skill state
68
+ * @param onNotify - Callback to send notification
69
+ */
70
+ export declare function refreshSubscriptions(manager: SubscriptionManager, skillState: SkillState, onNotify: (uri: string) => void): void;
71
+ /**
72
+ * Register subscribe/unsubscribe request handlers with the server.
73
+ *
74
+ * @param server - The MCP server instance
75
+ * @param skillState - Shared skill state
76
+ * @param manager - The subscription manager
77
+ */
78
+ export declare function registerSubscriptionHandlers(server: McpServer, skillState: SkillState, manager: SubscriptionManager): void;