@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.
@@ -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
+ }