@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,285 @@
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 chokidar from "chokidar";
15
+ import * as path from "node:path";
16
+ import { SubscribeRequestSchema, UnsubscribeRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
17
+ import { isPathWithinBase } from "./skill-tool.js";
18
+ /** Debounce delay in milliseconds */
19
+ const DEBOUNCE_MS = 100;
20
+ /**
21
+ * Create a new subscription manager.
22
+ */
23
+ export function createSubscriptionManager() {
24
+ return {
25
+ uriToFilePaths: new Map(),
26
+ filePathToUris: new Map(),
27
+ watchers: new Map(),
28
+ pendingNotifications: new Map(),
29
+ };
30
+ }
31
+ /**
32
+ * Resolve a skill:// URI to the file paths it depends on.
33
+ *
34
+ * @param uri - The resource URI
35
+ * @param skillState - Current skill state for lookups
36
+ * @returns Array of absolute file paths to watch
37
+ */
38
+ export function resolveUriToFilePaths(uri, skillState) {
39
+ // skill:// → Watch all skill directories
40
+ if (uri === "skill://") {
41
+ const paths = [];
42
+ for (const skill of skillState.skillMap.values()) {
43
+ paths.push(path.dirname(skill.path)); // Watch entire skill directory
44
+ }
45
+ return paths;
46
+ }
47
+ // skill://{skillName} → Just the SKILL.md file
48
+ const skillMatch = uri.match(/^skill:\/\/([^/]+)$/);
49
+ if (skillMatch) {
50
+ const skillName = decodeURIComponent(skillMatch[1]);
51
+ const skill = skillState.skillMap.get(skillName);
52
+ return skill ? [skill.path] : [];
53
+ }
54
+ // skill://{skillName}/ → Watch entire skill directory (directory collection)
55
+ const dirMatch = uri.match(/^skill:\/\/([^/]+)\/$/);
56
+ if (dirMatch) {
57
+ const skillName = decodeURIComponent(dirMatch[1]);
58
+ const skill = skillState.skillMap.get(skillName);
59
+ return skill ? [path.dirname(skill.path)] : [];
60
+ }
61
+ // skill://{skillName}/{path} → Specific file
62
+ const fileMatch = uri.match(/^skill:\/\/([^/]+)\/(.+)$/);
63
+ if (fileMatch) {
64
+ const skillName = decodeURIComponent(fileMatch[1]);
65
+ const filePath = fileMatch[2];
66
+ const skill = skillState.skillMap.get(skillName);
67
+ if (!skill)
68
+ return [];
69
+ const skillDir = path.dirname(skill.path);
70
+ const fullPath = path.resolve(skillDir, filePath);
71
+ // Security check: ensure path is within skill directory
72
+ if (!isPathWithinBase(fullPath, skillDir))
73
+ return [];
74
+ return [fullPath];
75
+ }
76
+ return [];
77
+ }
78
+ /**
79
+ * Add a subscription for a URI.
80
+ *
81
+ * @param manager - The subscription manager
82
+ * @param uri - The resource URI to subscribe to
83
+ * @param skillState - Current skill state for resolving URIs
84
+ * @param onNotify - Callback to send notification when file changes
85
+ * @returns True if subscription was added, false if URI couldn't be resolved
86
+ */
87
+ export function subscribe(manager, uri, skillState, onNotify) {
88
+ const filePaths = resolveUriToFilePaths(uri, skillState);
89
+ if (filePaths.length === 0) {
90
+ return false;
91
+ }
92
+ // Track URI -> file paths mapping
93
+ manager.uriToFilePaths.set(uri, new Set(filePaths));
94
+ // Track reverse mapping and set up watchers
95
+ for (const filePath of filePaths) {
96
+ // Add to reverse mapping
97
+ let uris = manager.filePathToUris.get(filePath);
98
+ if (!uris) {
99
+ uris = new Set();
100
+ manager.filePathToUris.set(filePath, uris);
101
+ }
102
+ uris.add(uri);
103
+ // Set up watcher if not already watching this path
104
+ if (!manager.watchers.has(filePath)) {
105
+ const watcher = createWatcher(filePath, manager, onNotify);
106
+ manager.watchers.set(filePath, watcher);
107
+ }
108
+ }
109
+ console.error(`Subscribed to ${uri} (watching ${filePaths.length} path(s))`);
110
+ return true;
111
+ }
112
+ /**
113
+ * Remove a subscription for a URI.
114
+ *
115
+ * @param manager - The subscription manager
116
+ * @param uri - The resource URI to unsubscribe from
117
+ */
118
+ export function unsubscribe(manager, uri) {
119
+ const filePaths = manager.uriToFilePaths.get(uri);
120
+ if (!filePaths) {
121
+ return;
122
+ }
123
+ // Remove URI from each file path's set
124
+ for (const filePath of filePaths) {
125
+ const uris = manager.filePathToUris.get(filePath);
126
+ if (uris) {
127
+ uris.delete(uri);
128
+ // If no more URIs depend on this file, stop watching
129
+ if (uris.size === 0) {
130
+ manager.filePathToUris.delete(filePath);
131
+ const watcher = manager.watchers.get(filePath);
132
+ if (watcher) {
133
+ watcher.close();
134
+ manager.watchers.delete(filePath);
135
+ }
136
+ }
137
+ }
138
+ }
139
+ // Remove the URI entry
140
+ manager.uriToFilePaths.delete(uri);
141
+ // Clear any pending notification
142
+ const pending = manager.pendingNotifications.get(uri);
143
+ if (pending) {
144
+ clearTimeout(pending);
145
+ manager.pendingNotifications.delete(uri);
146
+ }
147
+ console.error(`Unsubscribed from ${uri}`);
148
+ }
149
+ /**
150
+ * Create a chokidar watcher for a file or directory.
151
+ */
152
+ function createWatcher(filePath, manager, onNotify) {
153
+ const watcher = chokidar.watch(filePath, {
154
+ persistent: true,
155
+ ignoreInitial: true,
156
+ awaitWriteFinish: {
157
+ stabilityThreshold: DEBOUNCE_MS,
158
+ pollInterval: 50,
159
+ },
160
+ });
161
+ const handleChange = (changedPath) => {
162
+ // Normalize path for consistent lookup
163
+ const normalizedPath = path.normalize(changedPath);
164
+ // Find all URIs affected by this file change
165
+ // For directory watches, check if the changed file is within any watched directory
166
+ for (const [watchedPath, uris] of manager.filePathToUris.entries()) {
167
+ const isMatch = normalizedPath === watchedPath ||
168
+ normalizedPath.startsWith(watchedPath + path.sep);
169
+ if (isMatch) {
170
+ for (const uri of uris) {
171
+ // Debounce: clear existing timeout, set new one
172
+ const existing = manager.pendingNotifications.get(uri);
173
+ if (existing) {
174
+ clearTimeout(existing);
175
+ }
176
+ manager.pendingNotifications.set(uri, setTimeout(() => {
177
+ manager.pendingNotifications.delete(uri);
178
+ console.error(`Resource updated: ${uri}`);
179
+ onNotify(uri);
180
+ }, DEBOUNCE_MS));
181
+ }
182
+ }
183
+ }
184
+ };
185
+ watcher.on("change", handleChange);
186
+ watcher.on("add", handleChange);
187
+ watcher.on("unlink", handleChange);
188
+ return watcher;
189
+ }
190
+ /**
191
+ * Update subscriptions when skills change.
192
+ *
193
+ * Re-resolves all existing URIs with the new skill state and updates
194
+ * watchers accordingly. Sends notifications for any URIs whose underlying
195
+ * files have changed.
196
+ *
197
+ * @param manager - The subscription manager
198
+ * @param skillState - Updated skill state
199
+ * @param onNotify - Callback to send notification
200
+ */
201
+ export function refreshSubscriptions(manager, skillState, onNotify) {
202
+ // Re-resolve each subscribed URI
203
+ for (const uri of manager.uriToFilePaths.keys()) {
204
+ const oldPaths = manager.uriToFilePaths.get(uri);
205
+ const newPaths = new Set(resolveUriToFilePaths(uri, skillState));
206
+ // Find paths that were removed
207
+ for (const oldPath of oldPaths) {
208
+ if (!newPaths.has(oldPath)) {
209
+ // Remove this URI from the old path's set
210
+ const uris = manager.filePathToUris.get(oldPath);
211
+ if (uris) {
212
+ uris.delete(uri);
213
+ if (uris.size === 0) {
214
+ manager.filePathToUris.delete(oldPath);
215
+ const watcher = manager.watchers.get(oldPath);
216
+ if (watcher) {
217
+ watcher.close();
218
+ manager.watchers.delete(oldPath);
219
+ }
220
+ }
221
+ }
222
+ }
223
+ }
224
+ // Find paths that were added
225
+ for (const newPath of newPaths) {
226
+ if (!oldPaths.has(newPath)) {
227
+ // Add this URI to the new path's set
228
+ let uris = manager.filePathToUris.get(newPath);
229
+ if (!uris) {
230
+ uris = new Set();
231
+ manager.filePathToUris.set(newPath, uris);
232
+ }
233
+ uris.add(uri);
234
+ // Start watching if not already
235
+ if (!manager.watchers.has(newPath)) {
236
+ const watcher = createWatcher(newPath, manager, onNotify);
237
+ manager.watchers.set(newPath, watcher);
238
+ }
239
+ }
240
+ }
241
+ // Update the stored paths
242
+ if (newPaths.size === 0) {
243
+ // URI no longer resolves to anything - remove subscription
244
+ manager.uriToFilePaths.delete(uri);
245
+ console.error(`Subscription ${uri} no longer valid (skill removed?)`);
246
+ }
247
+ else {
248
+ manager.uriToFilePaths.set(uri, newPaths);
249
+ }
250
+ }
251
+ }
252
+ /**
253
+ * Register subscribe/unsubscribe request handlers with the server.
254
+ *
255
+ * @param server - The MCP server instance
256
+ * @param skillState - Shared skill state
257
+ * @param manager - The subscription manager
258
+ */
259
+ export function registerSubscriptionHandlers(server, skillState, manager) {
260
+ const sendNotification = (uri) => {
261
+ server.server.notification({
262
+ method: "notifications/resources/updated",
263
+ params: { uri },
264
+ });
265
+ };
266
+ // Handle resources/subscribe requests
267
+ server.server.setRequestHandler(SubscribeRequestSchema, async (request) => {
268
+ const { uri } = request.params;
269
+ // Validate URI scheme
270
+ if (!uri.startsWith("skill://")) {
271
+ throw new Error(`Unsupported URI scheme: ${uri}. Only skill:// URIs are supported.`);
272
+ }
273
+ const success = subscribe(manager, uri, skillState, sendNotification);
274
+ if (!success) {
275
+ throw new Error(`Resource not found: ${uri}`);
276
+ }
277
+ return {};
278
+ });
279
+ // Handle resources/unsubscribe requests
280
+ server.server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
281
+ const { uri } = request.params;
282
+ unsubscribe(manager, uri);
283
+ return {};
284
+ });
285
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@skilljack/mcp",
3
+ "version": "0.3.0",
4
+ "description": "MCP server that discovers and serves Agent Skills. I know kung fu.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "skilljack-mcp": "dist/index.js"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "types": "dist/index.d.ts",
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "author": "Ola Hungerford",
20
+ "license": "MIT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/olaservo/skilljack-mcp.git"
24
+ },
25
+ "homepage": "https://github.com/olaservo/skilljack-mcp#readme",
26
+ "keywords": [
27
+ "mcp",
28
+ "model-context-protocol",
29
+ "agent-skills",
30
+ "claude",
31
+ "ai",
32
+ "cli"
33
+ ],
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc",
39
+ "start": "node dist/index.js",
40
+ "dev": "tsx watch src/index.ts",
41
+ "inspector": "npx @modelcontextprotocol/inspector@latest node dist/index.js"
42
+ },
43
+ "dependencies": {
44
+ "@modelcontextprotocol/sdk": "^1.25.1",
45
+ "chokidar": "^5.0.0",
46
+ "yaml": "^2.7.0",
47
+ "zod": "^3.25.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^22.10.0",
51
+ "tsx": "^4.19.2",
52
+ "typescript": "^5.7.2"
53
+ }
54
+ }