@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.
- package/LICENSE +21 -0
- package/README.md +260 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +271 -0
- package/dist/roots-handler.d.ts +49 -0
- package/dist/roots-handler.js +199 -0
- package/dist/skill-discovery.d.ts +33 -0
- package/dist/skill-discovery.js +144 -0
- package/dist/skill-resources.d.ts +26 -0
- package/dist/skill-resources.js +286 -0
- package/dist/skill-tool.d.ts +46 -0
- package/dist/skill-tool.js +362 -0
- package/dist/subscriptions.d.ts +78 -0
- package/dist/subscriptions.js +285 -0
- package/package.json +54 -0
|
@@ -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
|
+
}
|