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