@olaservo/skill-jack-mcp 0.1.1 → 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/README.md +45 -21
- package/dist/index.d.ts +3 -2
- package/dist/index.js +197 -35
- package/dist/skill-discovery.d.ts +1 -0
- package/dist/skill-discovery.js +9 -1
- package/dist/skill-resources.d.ts +1 -2
- package/dist/skill-resources.js +66 -19
- package/dist/skill-tool.d.ts +11 -3
- package/dist/skill-tool.js +21 -5
- package/dist/subscriptions.d.ts +1 -0
- package/dist/subscriptions.js +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
An MCP server that jacks [Agent Skills](https://agentskills.io) directly into your LLM's brain.
|
|
4
4
|
|
|
5
|
-
> **Recommended:** For best results, use an
|
|
5
|
+
> **Recommended:** For best results, use an MCP client that supports `tools/listChanged` notifications (e.g., Claude Code). This enables dynamic skill discovery - when skills are added or modified, the client automatically refreshes its understanding of available skills.
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- **Skill Discovery** -
|
|
10
|
-
- **
|
|
9
|
+
- **Dynamic Skill Discovery** - Watches skill directories and automatically refreshes when skills change
|
|
10
|
+
- **Tool List Changed Notifications** - Sends `tools/listChanged` so clients can refresh available skills
|
|
11
11
|
- **Skill Tool** - Load full skill content on demand (progressive disclosure)
|
|
12
12
|
- **MCP Resources** - Access skills via `skill://` URIs with batch collection support
|
|
13
13
|
- **Resource Subscriptions** - Real-time file watching with `notifications/resources/updated`
|
|
@@ -35,17 +35,22 @@ npm run build
|
|
|
35
35
|
|
|
36
36
|
## Usage
|
|
37
37
|
|
|
38
|
-
Configure
|
|
38
|
+
Configure one or more skills directories containing your Agent Skills:
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
|
-
#
|
|
41
|
+
# Single directory
|
|
42
42
|
skill-jack-mcp /path/to/skills
|
|
43
43
|
|
|
44
|
-
#
|
|
44
|
+
# Multiple directories (separate args or comma-separated)
|
|
45
|
+
skill-jack-mcp /path/to/skills /path/to/more/skills
|
|
46
|
+
skill-jack-mcp /path/to/skills,/path/to/more/skills
|
|
47
|
+
|
|
48
|
+
# Using environment variable (comma-separated for multiple)
|
|
45
49
|
SKILLS_DIR=/path/to/skills skill-jack-mcp
|
|
50
|
+
SKILLS_DIR=/path/to/skills,/path/to/more/skills skill-jack-mcp
|
|
46
51
|
```
|
|
47
52
|
|
|
48
|
-
|
|
53
|
+
Each directory is scanned along with its `.claude/skills/` and `skills/` subdirectories for skills. Duplicate skill names are handled by keeping the first occurrence.
|
|
49
54
|
|
|
50
55
|
**Windows note**: Use forward slashes in paths when using with MCP Inspector:
|
|
51
56
|
```bash
|
|
@@ -54,26 +59,37 @@ skill-jack-mcp "C:/Users/you/skills"
|
|
|
54
59
|
|
|
55
60
|
## How It Works
|
|
56
61
|
|
|
57
|
-
The server implements the [Agent Skills](https://agentskills.io) progressive disclosure pattern:
|
|
62
|
+
The server implements the [Agent Skills](https://agentskills.io) progressive disclosure pattern with dynamic updates:
|
|
58
63
|
|
|
59
|
-
1. **At startup**: Discovers skills from configured
|
|
60
|
-
2. **On connection**:
|
|
61
|
-
3. **On
|
|
64
|
+
1. **At startup**: Discovers skills from configured directories and starts file watchers
|
|
65
|
+
2. **On connection**: Skill tool description includes available skills metadata
|
|
66
|
+
3. **On file change**: Re-discovers skills, updates tool description, sends `tools/listChanged`
|
|
67
|
+
4. **On tool call**: Agent calls `skill` tool to load full SKILL.md content
|
|
68
|
+
5. **As needed**: Agent calls `skill-resource` to load additional files
|
|
62
69
|
|
|
63
70
|
```
|
|
64
71
|
┌─────────────────────────────────────────────────────────┐
|
|
65
72
|
│ Server starts │
|
|
66
|
-
│ • Discovers skills from configured
|
|
67
|
-
│ •
|
|
73
|
+
│ • Discovers skills from configured directories │
|
|
74
|
+
│ • Starts watching for SKILL.md changes │
|
|
68
75
|
│ ↓ │
|
|
69
76
|
│ MCP Client connects │
|
|
70
|
-
│ •
|
|
77
|
+
│ • Skill tool description includes available skills │
|
|
78
|
+
│ ↓ │
|
|
79
|
+
│ LLM sees skill metadata in tool description │
|
|
71
80
|
│ ↓ │
|
|
72
|
-
│
|
|
81
|
+
│ SKILL.md added/modified/removed │
|
|
82
|
+
│ • Server re-discovers skills │
|
|
83
|
+
│ • Updates skill tool description │
|
|
84
|
+
│ • Sends tools/listChanged notification │
|
|
85
|
+
│ • Client refreshes tool definitions │
|
|
73
86
|
│ ↓ │
|
|
74
87
|
│ LLM calls "skill" tool with skill name │
|
|
75
88
|
│ ↓ │
|
|
76
89
|
│ Server returns full SKILL.md content │
|
|
90
|
+
│ ↓ │
|
|
91
|
+
│ LLM calls "skill-resource" for additional files │
|
|
92
|
+
│ • Scripts, snippets, references, assets, etc. │
|
|
77
93
|
└─────────────────────────────────────────────────────────┘
|
|
78
94
|
```
|
|
79
95
|
|
|
@@ -133,7 +149,6 @@ Skills are also accessible via MCP [Resources](https://modelcontextprotocol.io/s
|
|
|
133
149
|
|
|
134
150
|
| URI | Returns |
|
|
135
151
|
|-----|---------|
|
|
136
|
-
| `skill://` | All SKILL.md contents (collection) |
|
|
137
152
|
| `skill://{name}` | Single skill's SKILL.md content |
|
|
138
153
|
| `skill://{name}/` | All files in skill directory (collection) |
|
|
139
154
|
| `skill://{name}/{path}` | Specific file within skill |
|
|
@@ -173,7 +188,7 @@ Clients can subscribe to resources for real-time updates when files change.
|
|
|
173
188
|
|
|
174
189
|
Protections in place:
|
|
175
190
|
- Path traversal prevention (symlink-aware)
|
|
176
|
-
- File size limits (
|
|
191
|
+
- File size limits (1MB default, configurable via `MAX_FILE_SIZE_MB` env var)
|
|
177
192
|
- Directory depth limits
|
|
178
193
|
- Skill content is confined to configured directories
|
|
179
194
|
|
|
@@ -181,9 +196,18 @@ Not protected against:
|
|
|
181
196
|
- Malicious content within trusted skill directories
|
|
182
197
|
- Prompt injection via skill instructions (skills can influence LLM behavior by design)
|
|
183
198
|
|
|
184
|
-
##
|
|
199
|
+
## Dynamic Skill Discovery
|
|
200
|
+
|
|
201
|
+
The server watches skill directories for changes. When SKILL.md files are added, modified, or removed:
|
|
202
|
+
|
|
203
|
+
1. Skills are re-discovered from all configured directories
|
|
204
|
+
2. The `skill` tool's description is updated with current skill names and metadata
|
|
205
|
+
3. `tools/listChanged` notification is sent to connected clients
|
|
206
|
+
4. Clients that support this notification will refresh tool definitions
|
|
207
|
+
|
|
208
|
+
## Skill Metadata Format
|
|
185
209
|
|
|
186
|
-
The
|
|
210
|
+
The `skill` tool description includes metadata for all available skills in XML format:
|
|
187
211
|
|
|
188
212
|
```markdown
|
|
189
213
|
# Skills
|
|
@@ -199,11 +223,11 @@ When a user's task matches a skill description below: 1) activate it, 2) follow
|
|
|
199
223
|
</available_skills>
|
|
200
224
|
```
|
|
201
225
|
|
|
202
|
-
|
|
226
|
+
This metadata is dynamically updated when skills change - clients supporting `tools/listChanged` will automatically refresh.
|
|
203
227
|
|
|
204
228
|
## Skill Discovery
|
|
205
229
|
|
|
206
|
-
Skills are discovered at startup from the configured
|
|
230
|
+
Skills are discovered at startup from the configured directories. For each directory, the server checks:
|
|
207
231
|
- The directory itself for skill subdirectories
|
|
208
232
|
- `.claude/skills/` subdirectory
|
|
209
233
|
- `skills/` subdirectory
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* Provides global skills with tools for progressive disclosure.
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
|
-
* skill-jack-mcp /path/to/skills
|
|
10
|
-
* SKILLS_DIR=/path/to/skills skill-jack-mcp
|
|
9
|
+
* skill-jack-mcp /path/to/skills [/path2 ...] # One or more directories
|
|
10
|
+
* SKILLS_DIR=/path/to/skills skill-jack-mcp # Single directory via env
|
|
11
|
+
* SKILLS_DIR=/path1,/path2 skill-jack-mcp # Multiple (comma-separated)
|
|
11
12
|
*/
|
|
12
13
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -6,36 +6,58 @@
|
|
|
6
6
|
* Provides global skills with tools for progressive disclosure.
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
|
-
* skill-jack-mcp /path/to/skills
|
|
10
|
-
* SKILLS_DIR=/path/to/skills skill-jack-mcp
|
|
9
|
+
* skill-jack-mcp /path/to/skills [/path2 ...] # One or more directories
|
|
10
|
+
* SKILLS_DIR=/path/to/skills skill-jack-mcp # Single directory via env
|
|
11
|
+
* SKILLS_DIR=/path1,/path2 skill-jack-mcp # Multiple (comma-separated)
|
|
11
12
|
*/
|
|
12
13
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
14
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
15
|
+
import chokidar from "chokidar";
|
|
14
16
|
import * as fs from "node:fs";
|
|
15
17
|
import * as path from "node:path";
|
|
16
|
-
import { discoverSkills,
|
|
17
|
-
import { registerSkillTool } from "./skill-tool.js";
|
|
18
|
+
import { discoverSkills, createSkillMap } from "./skill-discovery.js";
|
|
19
|
+
import { registerSkillTool, getToolDescription } from "./skill-tool.js";
|
|
18
20
|
import { registerSkillResources } from "./skill-resources.js";
|
|
19
|
-
import { createSubscriptionManager, registerSubscriptionHandlers, } from "./subscriptions.js";
|
|
21
|
+
import { createSubscriptionManager, registerSubscriptionHandlers, refreshSubscriptions, } from "./subscriptions.js";
|
|
20
22
|
/**
|
|
21
23
|
* Subdirectories to check for skills within the configured directory.
|
|
22
24
|
*/
|
|
23
25
|
const SKILL_SUBDIRS = [".claude/skills", "skills"];
|
|
24
26
|
/**
|
|
25
|
-
*
|
|
27
|
+
* Separator for multiple paths in SKILLS_DIR environment variable.
|
|
28
|
+
* Comma works cross-platform (not valid in file paths on any OS).
|
|
26
29
|
*/
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
const PATH_LIST_SEPARATOR = ",";
|
|
31
|
+
/**
|
|
32
|
+
* Get the skills directories from command line args and/or environment.
|
|
33
|
+
* Returns deduplicated, resolved paths.
|
|
34
|
+
*/
|
|
35
|
+
function getSkillsDirs() {
|
|
36
|
+
const dirs = [];
|
|
37
|
+
// Collect all non-flag command-line arguments (comma-separated supported)
|
|
29
38
|
const args = process.argv.slice(2);
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
for (const arg of args) {
|
|
40
|
+
if (!arg.startsWith("-")) {
|
|
41
|
+
const paths = arg
|
|
42
|
+
.split(PATH_LIST_SEPARATOR)
|
|
43
|
+
.map((p) => p.trim())
|
|
44
|
+
.filter((p) => p.length > 0)
|
|
45
|
+
.map((p) => path.resolve(p));
|
|
46
|
+
dirs.push(...paths);
|
|
47
|
+
}
|
|
32
48
|
}
|
|
33
|
-
//
|
|
49
|
+
// Also check environment variable (comma-separated supported)
|
|
34
50
|
const envDir = process.env.SKILLS_DIR;
|
|
35
51
|
if (envDir) {
|
|
36
|
-
|
|
52
|
+
const envPaths = envDir
|
|
53
|
+
.split(PATH_LIST_SEPARATOR)
|
|
54
|
+
.map((p) => p.trim())
|
|
55
|
+
.filter((p) => p.length > 0)
|
|
56
|
+
.map((p) => path.resolve(p));
|
|
57
|
+
dirs.push(...envPaths);
|
|
37
58
|
}
|
|
38
|
-
|
|
59
|
+
// Deduplicate by resolved path
|
|
60
|
+
return [...new Set(dirs)];
|
|
39
61
|
}
|
|
40
62
|
/**
|
|
41
63
|
* Shared state for skill management.
|
|
@@ -43,44 +65,183 @@ function getSkillsDir() {
|
|
|
43
65
|
*/
|
|
44
66
|
const skillState = {
|
|
45
67
|
skillMap: new Map(),
|
|
46
|
-
instructions: "",
|
|
47
68
|
};
|
|
48
69
|
/**
|
|
49
|
-
* Discover skills from configured
|
|
50
|
-
*
|
|
70
|
+
* Discover skills from multiple configured directories.
|
|
71
|
+
* Each directory is checked along with its standard subdirectories.
|
|
72
|
+
* Handles duplicate skill names by keeping first occurrence.
|
|
51
73
|
*/
|
|
52
|
-
function
|
|
74
|
+
function discoverSkillsFromDirs(skillsDirs) {
|
|
53
75
|
const allSkills = [];
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
76
|
+
const seenNames = new Map(); // name -> source directory
|
|
77
|
+
for (const skillsDir of skillsDirs) {
|
|
78
|
+
if (!fs.existsSync(skillsDir)) {
|
|
79
|
+
console.error(`Warning: Skills directory not found: ${skillsDir}`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
console.error(`Scanning skills directory: ${skillsDir}`);
|
|
83
|
+
// Check if the directory itself contains skills
|
|
84
|
+
const dirSkills = discoverSkills(skillsDir);
|
|
85
|
+
// Also check standard subdirectories
|
|
86
|
+
for (const subdir of SKILL_SUBDIRS) {
|
|
87
|
+
const subPath = path.join(skillsDir, subdir);
|
|
88
|
+
if (fs.existsSync(subPath)) {
|
|
89
|
+
dirSkills.push(...discoverSkills(subPath));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Add skills, checking for duplicates
|
|
93
|
+
for (const skill of dirSkills) {
|
|
94
|
+
if (seenNames.has(skill.name)) {
|
|
95
|
+
console.error(`Warning: Duplicate skill "${skill.name}" found in ${path.dirname(skill.path)} ` +
|
|
96
|
+
`(already loaded from ${seenNames.get(skill.name)})`);
|
|
97
|
+
continue; // Skip duplicate
|
|
98
|
+
}
|
|
99
|
+
seenNames.set(skill.name, path.dirname(skill.path));
|
|
100
|
+
allSkills.push(skill);
|
|
63
101
|
}
|
|
64
102
|
}
|
|
65
103
|
return allSkills;
|
|
66
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Debounce delay for skill directory changes (ms).
|
|
107
|
+
* Multiple rapid changes are coalesced into one refresh.
|
|
108
|
+
*/
|
|
109
|
+
const SKILL_REFRESH_DEBOUNCE_MS = 500;
|
|
110
|
+
/**
|
|
111
|
+
* Refresh skills and notify clients of changes.
|
|
112
|
+
* Called when skill files change on disk.
|
|
113
|
+
*
|
|
114
|
+
* @param skillsDirs - The configured skill directories
|
|
115
|
+
* @param server - The MCP server instance
|
|
116
|
+
* @param skillTool - The registered skill tool to update
|
|
117
|
+
* @param subscriptionManager - For refreshing resource subscriptions
|
|
118
|
+
*/
|
|
119
|
+
function refreshSkills(skillsDirs, server, skillTool, subscriptionManager) {
|
|
120
|
+
console.error("Refreshing skills...");
|
|
121
|
+
// Re-discover all skills
|
|
122
|
+
const skills = discoverSkillsFromDirs(skillsDirs);
|
|
123
|
+
const oldCount = skillState.skillMap.size;
|
|
124
|
+
// Update shared state
|
|
125
|
+
skillState.skillMap = createSkillMap(skills);
|
|
126
|
+
console.error(`Skills refreshed: ${oldCount} -> ${skills.length} skill(s)`);
|
|
127
|
+
// Update the skill tool description with new instructions
|
|
128
|
+
skillTool.update({
|
|
129
|
+
description: getToolDescription(skillState),
|
|
130
|
+
});
|
|
131
|
+
// Refresh resource subscriptions to match new skill state
|
|
132
|
+
refreshSubscriptions(subscriptionManager, skillState, (uri) => {
|
|
133
|
+
server.server.notification({
|
|
134
|
+
method: "notifications/resources/updated",
|
|
135
|
+
params: { uri },
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
// Notify clients that tools have changed
|
|
139
|
+
// This prompts clients to call tools/list again
|
|
140
|
+
server.sendToolListChanged();
|
|
141
|
+
// Also notify that resources have changed
|
|
142
|
+
server.sendResourceListChanged();
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Set up file watchers on skill directories to detect changes.
|
|
146
|
+
* Watches for SKILL.md additions, modifications, and deletions.
|
|
147
|
+
*
|
|
148
|
+
* @param skillsDirs - The configured skill directories
|
|
149
|
+
* @param server - The MCP server instance
|
|
150
|
+
* @param skillTool - The registered skill tool to update
|
|
151
|
+
* @param subscriptionManager - For refreshing subscriptions
|
|
152
|
+
*/
|
|
153
|
+
function watchSkillDirectories(skillsDirs, server, skillTool, subscriptionManager) {
|
|
154
|
+
let refreshTimeout = null;
|
|
155
|
+
const debouncedRefresh = () => {
|
|
156
|
+
if (refreshTimeout) {
|
|
157
|
+
clearTimeout(refreshTimeout);
|
|
158
|
+
}
|
|
159
|
+
refreshTimeout = setTimeout(() => {
|
|
160
|
+
refreshTimeout = null;
|
|
161
|
+
refreshSkills(skillsDirs, server, skillTool, subscriptionManager);
|
|
162
|
+
}, SKILL_REFRESH_DEBOUNCE_MS);
|
|
163
|
+
};
|
|
164
|
+
// Build list of paths to watch
|
|
165
|
+
const watchPaths = [];
|
|
166
|
+
for (const dir of skillsDirs) {
|
|
167
|
+
if (fs.existsSync(dir)) {
|
|
168
|
+
watchPaths.push(dir);
|
|
169
|
+
// Also watch standard subdirectories
|
|
170
|
+
for (const subdir of SKILL_SUBDIRS) {
|
|
171
|
+
const subPath = path.join(dir, subdir);
|
|
172
|
+
if (fs.existsSync(subPath)) {
|
|
173
|
+
watchPaths.push(subPath);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (watchPaths.length === 0) {
|
|
179
|
+
console.error("No skill directories to watch");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
console.error(`Watching for skill changes in: ${watchPaths.join(", ")}`);
|
|
183
|
+
const watcher = chokidar.watch(watchPaths, {
|
|
184
|
+
persistent: true,
|
|
185
|
+
ignoreInitial: true,
|
|
186
|
+
depth: 2, // Watch skill subdirectories but not too deep
|
|
187
|
+
ignored: ["**/node_modules/**", "**/.git/**"],
|
|
188
|
+
awaitWriteFinish: {
|
|
189
|
+
stabilityThreshold: 200,
|
|
190
|
+
pollInterval: 50,
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
// Watch for SKILL.md changes specifically
|
|
194
|
+
watcher.on("add", (filePath) => {
|
|
195
|
+
if (path.basename(filePath).toLowerCase() === "skill.md") {
|
|
196
|
+
console.error(`Skill added: ${filePath}`);
|
|
197
|
+
debouncedRefresh();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
watcher.on("change", (filePath) => {
|
|
201
|
+
if (path.basename(filePath).toLowerCase() === "skill.md") {
|
|
202
|
+
console.error(`Skill modified: ${filePath}`);
|
|
203
|
+
debouncedRefresh();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
watcher.on("unlink", (filePath) => {
|
|
207
|
+
if (path.basename(filePath).toLowerCase() === "skill.md") {
|
|
208
|
+
console.error(`Skill removed: ${filePath}`);
|
|
209
|
+
debouncedRefresh();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
// Also watch for directory additions (new skill folders)
|
|
213
|
+
watcher.on("addDir", (dirPath) => {
|
|
214
|
+
// Check if this might be a new skill directory
|
|
215
|
+
const skillMdPath = path.join(dirPath, "SKILL.md");
|
|
216
|
+
const skillMdPathLower = path.join(dirPath, "skill.md");
|
|
217
|
+
if (fs.existsSync(skillMdPath) || fs.existsSync(skillMdPathLower)) {
|
|
218
|
+
console.error(`Skill directory added: ${dirPath}`);
|
|
219
|
+
debouncedRefresh();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
watcher.on("unlinkDir", (dirPath) => {
|
|
223
|
+
// A skill directory was removed
|
|
224
|
+
console.error(`Directory removed: ${dirPath}`);
|
|
225
|
+
debouncedRefresh();
|
|
226
|
+
});
|
|
227
|
+
}
|
|
67
228
|
/**
|
|
68
229
|
* Subscription manager for resource file watching.
|
|
69
230
|
*/
|
|
70
231
|
const subscriptionManager = createSubscriptionManager();
|
|
71
232
|
async function main() {
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
233
|
+
const skillsDirs = getSkillsDirs();
|
|
234
|
+
if (skillsDirs.length === 0) {
|
|
74
235
|
console.error("No skills directory configured.");
|
|
75
|
-
console.error("Usage: skill-jack-mcp /path/to/skills");
|
|
236
|
+
console.error("Usage: skill-jack-mcp /path/to/skills [/path/to/more/skills ...]");
|
|
76
237
|
console.error(" or: SKILLS_DIR=/path/to/skills skill-jack-mcp");
|
|
238
|
+
console.error(" or: SKILLS_DIR=/path1,/path2 skill-jack-mcp");
|
|
77
239
|
process.exit(1);
|
|
78
240
|
}
|
|
79
|
-
console.error(`Skills
|
|
241
|
+
console.error(`Skills directories: ${skillsDirs.join(", ")}`);
|
|
80
242
|
// Discover skills at startup
|
|
81
|
-
const skills =
|
|
243
|
+
const skills = discoverSkillsFromDirs(skillsDirs);
|
|
82
244
|
skillState.skillMap = createSkillMap(skills);
|
|
83
|
-
skillState.instructions = generateInstructions(skills);
|
|
84
245
|
console.error(`Discovered ${skills.length} skill(s)`);
|
|
85
246
|
// Create the MCP server
|
|
86
247
|
const server = new McpServer({
|
|
@@ -88,16 +249,17 @@ async function main() {
|
|
|
88
249
|
version: "1.0.0",
|
|
89
250
|
}, {
|
|
90
251
|
capabilities: {
|
|
91
|
-
tools: {},
|
|
252
|
+
tools: { listChanged: true },
|
|
92
253
|
resources: { subscribe: true, listChanged: true },
|
|
93
254
|
},
|
|
94
|
-
instructions: skillState.instructions,
|
|
95
255
|
});
|
|
96
256
|
// Register tools and resources
|
|
97
|
-
registerSkillTool(server, skillState);
|
|
257
|
+
const skillTool = registerSkillTool(server, skillState);
|
|
98
258
|
registerSkillResources(server, skillState);
|
|
99
259
|
// Register subscription handlers for resource file watching
|
|
100
260
|
registerSubscriptionHandlers(server, skillState, subscriptionManager);
|
|
261
|
+
// Set up file watchers for skill directory changes
|
|
262
|
+
watchSkillDirectories(skillsDirs, server, skillTool, subscriptionManager);
|
|
101
263
|
// Connect via stdio transport
|
|
102
264
|
const transport = new StdioServerTransport();
|
|
103
265
|
await server.connect(transport);
|
|
@@ -28,5 +28,6 @@ export declare function generateInstructions(skills: SkillMetadata[]): string;
|
|
|
28
28
|
export declare function loadSkillContent(skillPath: string): string;
|
|
29
29
|
/**
|
|
30
30
|
* Create a map from skill name to skill metadata for fast lookup.
|
|
31
|
+
* Uses first-wins behavior: if duplicate names exist, the first occurrence is kept.
|
|
31
32
|
*/
|
|
32
33
|
export declare function createSkillMap(skills: SkillMetadata[]): Map<string, SkillMetadata>;
|
package/dist/skill-discovery.js
CHANGED
|
@@ -126,11 +126,19 @@ export function loadSkillContent(skillPath) {
|
|
|
126
126
|
}
|
|
127
127
|
/**
|
|
128
128
|
* Create a map from skill name to skill metadata for fast lookup.
|
|
129
|
+
* Uses first-wins behavior: if duplicate names exist, the first occurrence is kept.
|
|
129
130
|
*/
|
|
130
131
|
export function createSkillMap(skills) {
|
|
131
132
|
const map = new Map();
|
|
132
133
|
for (const skill of skills) {
|
|
133
|
-
map.
|
|
134
|
+
if (map.has(skill.name)) {
|
|
135
|
+
const existing = map.get(skill.name);
|
|
136
|
+
console.error(`Warning: Duplicate skill name "${skill.name}" found at ${skill.path} - ` +
|
|
137
|
+
`keeping first occurrence from ${existing.path}`);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
map.set(skill.name, skill);
|
|
141
|
+
}
|
|
134
142
|
}
|
|
135
143
|
return map;
|
|
136
144
|
}
|
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
* skill updates when MCP roots change.
|
|
9
9
|
*
|
|
10
10
|
* URI Scheme:
|
|
11
|
-
* skill:// -> Collection: all SKILL.md contents
|
|
12
11
|
* skill://{skillName} -> SKILL.md content (template)
|
|
13
|
-
* skill://{skillName}/ -> Collection: all files in skill
|
|
12
|
+
* skill://{skillName}/ -> Collection: all files in skill directory
|
|
14
13
|
* skill://{skillName}/{path} -> File within skill directory (template)
|
|
15
14
|
*/
|
|
16
15
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
package/dist/skill-resources.js
CHANGED
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
* skill updates when MCP roots change.
|
|
9
9
|
*
|
|
10
10
|
* URI Scheme:
|
|
11
|
-
* skill:// -> Collection: all SKILL.md contents
|
|
12
11
|
* skill://{skillName} -> SKILL.md content (template)
|
|
13
|
-
* skill://{skillName}/ -> Collection: all files in skill
|
|
12
|
+
* skill://{skillName}/ -> Collection: all files in skill directory
|
|
14
13
|
* skill://{skillName}/{path} -> File within skill directory (template)
|
|
15
14
|
*/
|
|
16
15
|
import * as fs from "node:fs";
|
|
@@ -49,39 +48,87 @@ function getMimeType(filePath) {
|
|
|
49
48
|
* @param skillState - Shared state object (allows dynamic updates)
|
|
50
49
|
*/
|
|
51
50
|
export function registerSkillResources(server, skillState) {
|
|
52
|
-
// Register collection resource for all skills
|
|
53
|
-
registerAllSkillsCollection(server, skillState);
|
|
54
51
|
// Register template for individual skill SKILL.md files
|
|
55
52
|
registerSkillTemplate(server, skillState);
|
|
53
|
+
// Register collection resource for skill directories (must be before file template)
|
|
54
|
+
registerSkillDirectoryCollection(server, skillState);
|
|
56
55
|
// Register resource template for skill files
|
|
57
56
|
registerSkillFileTemplate(server, skillState);
|
|
58
57
|
}
|
|
59
58
|
/**
|
|
60
|
-
* Register a collection resource
|
|
59
|
+
* Register a collection resource for skill directories.
|
|
61
60
|
*
|
|
62
|
-
* URI: skill://
|
|
61
|
+
* URI Pattern: skill://{skillName}/
|
|
63
62
|
*
|
|
64
|
-
* Returns
|
|
65
|
-
* This allows clients to fetch all
|
|
63
|
+
* Returns all files in the skill directory (excluding SKILL.md) in a single request.
|
|
64
|
+
* This allows clients to fetch all resource files for a skill at once.
|
|
66
65
|
*/
|
|
67
|
-
function
|
|
68
|
-
server.registerResource("
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
66
|
+
function registerSkillDirectoryCollection(server, skillState) {
|
|
67
|
+
server.registerResource("Skill Directory", new ResourceTemplate("skill://{skillName}/", {
|
|
68
|
+
list: async () => {
|
|
69
|
+
// Return one entry per skill (the directory collection)
|
|
70
|
+
const resources = [];
|
|
71
|
+
for (const [name, skill] of skillState.skillMap) {
|
|
72
|
+
resources.push({
|
|
73
|
+
uri: `skill://${encodeURIComponent(name)}/`,
|
|
74
|
+
name: `${name}/`,
|
|
75
|
+
mimeType: "text/plain",
|
|
76
|
+
description: `All files in ${name} skill directory`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return { resources };
|
|
80
|
+
},
|
|
81
|
+
complete: {
|
|
82
|
+
skillName: (value) => {
|
|
83
|
+
const names = Array.from(skillState.skillMap.keys());
|
|
84
|
+
return names.filter((n) => n.toLowerCase().startsWith(value.toLowerCase()));
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
}), {
|
|
88
|
+
mimeType: "text/plain",
|
|
89
|
+
description: "Collection of all files in a skill directory (excluding SKILL.md)",
|
|
90
|
+
}, async (resourceUri) => {
|
|
91
|
+
// Extract skill name from URI
|
|
92
|
+
const uriStr = resourceUri.toString();
|
|
93
|
+
const match = uriStr.match(/^skill:\/\/([^/]+)\/$/);
|
|
94
|
+
if (!match) {
|
|
95
|
+
throw new Error(`Invalid skill directory URI: ${uriStr}`);
|
|
96
|
+
}
|
|
97
|
+
const skillName = decodeURIComponent(match[1]);
|
|
98
|
+
const skill = skillState.skillMap.get(skillName);
|
|
99
|
+
if (!skill) {
|
|
100
|
+
const available = Array.from(skillState.skillMap.keys()).join(", ");
|
|
101
|
+
throw new Error(`Skill "${skillName}" not found. Available: ${available || "none"}`);
|
|
102
|
+
}
|
|
103
|
+
const skillDir = path.dirname(skill.path);
|
|
104
|
+
const files = listSkillFiles(skillDir);
|
|
72
105
|
const contents = [];
|
|
73
|
-
for (const
|
|
106
|
+
for (const file of files) {
|
|
107
|
+
const fullPath = path.join(skillDir, file);
|
|
108
|
+
// Security: Validate path is within skill directory
|
|
109
|
+
if (!isPathWithinBase(fullPath, skillDir)) {
|
|
110
|
+
continue; // Skip files outside skill directory
|
|
111
|
+
}
|
|
74
112
|
try {
|
|
75
|
-
const
|
|
113
|
+
const stat = fs.statSync(fullPath);
|
|
114
|
+
// Skip symlinks and directories
|
|
115
|
+
if (stat.isSymbolicLink() || stat.isDirectory()) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
// Check file size
|
|
119
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
120
|
+
continue; // Skip large files
|
|
121
|
+
}
|
|
122
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
76
123
|
contents.push({
|
|
77
|
-
uri: `skill://${encodeURIComponent(
|
|
78
|
-
mimeType:
|
|
124
|
+
uri: `skill://${encodeURIComponent(skillName)}/${file}`,
|
|
125
|
+
mimeType: getMimeType(file),
|
|
79
126
|
text: content,
|
|
80
127
|
});
|
|
81
128
|
}
|
|
82
129
|
catch (error) {
|
|
83
|
-
// Skip
|
|
84
|
-
console.error(`Failed to load skill "${
|
|
130
|
+
// Skip files that fail to load
|
|
131
|
+
console.error(`Failed to load file "${file}" in skill "${skillName}":`, error);
|
|
85
132
|
}
|
|
86
133
|
}
|
|
87
134
|
return { contents };
|
package/dist/skill-tool.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Tools reference a shared SkillState object to support dynamic skill updates
|
|
8
8
|
* when MCP roots change.
|
|
9
9
|
*/
|
|
10
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
11
|
import { SkillMetadata } from "./skill-discovery.js";
|
|
12
12
|
/**
|
|
13
13
|
* Shared state for dynamic skill management.
|
|
@@ -15,15 +15,23 @@ import { SkillMetadata } from "./skill-discovery.js";
|
|
|
15
15
|
*/
|
|
16
16
|
export interface SkillState {
|
|
17
17
|
skillMap: Map<string, SkillMetadata>;
|
|
18
|
-
instructions: string;
|
|
19
18
|
}
|
|
20
19
|
/**
|
|
21
20
|
* Register the "skill" tool with the MCP server.
|
|
22
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
|
+
*
|
|
23
25
|
* @param server - The McpServer instance
|
|
24
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.
|
|
25
32
|
*/
|
|
26
|
-
export declare function
|
|
33
|
+
export declare function getToolDescription(skillState: SkillState): string;
|
|
34
|
+
export declare function registerSkillTool(server: McpServer, skillState: SkillState): RegisteredTool;
|
|
27
35
|
export declare const MAX_FILE_SIZE: number;
|
|
28
36
|
export declare const MAX_DIRECTORY_DEPTH = 10;
|
|
29
37
|
/**
|
package/dist/skill-tool.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { z } from "zod";
|
|
13
|
-
import { loadSkillContent } from "./skill-discovery.js";
|
|
13
|
+
import { loadSkillContent, generateInstructions } from "./skill-discovery.js";
|
|
14
14
|
/**
|
|
15
15
|
* Input schema for the skill tool.
|
|
16
16
|
*/
|
|
@@ -20,14 +20,27 @@ const SkillSchema = z.object({
|
|
|
20
20
|
/**
|
|
21
21
|
* Register the "skill" tool with the MCP server.
|
|
22
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
|
+
*
|
|
23
26
|
* @param server - The McpServer instance
|
|
24
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.
|
|
25
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
|
+
}
|
|
26
40
|
export function registerSkillTool(server, skillState) {
|
|
27
|
-
server.registerTool("skill", {
|
|
41
|
+
const skillTool = server.registerTool("skill", {
|
|
28
42
|
title: "Activate Skill",
|
|
29
|
-
description:
|
|
30
|
-
"with step-by-step guidance, examples, and file references to follow.",
|
|
43
|
+
description: getToolDescription(skillState),
|
|
31
44
|
inputSchema: SkillSchema,
|
|
32
45
|
annotations: {
|
|
33
46
|
readOnlyHint: true,
|
|
@@ -76,6 +89,7 @@ export function registerSkillTool(server, skillState) {
|
|
|
76
89
|
});
|
|
77
90
|
// Register the skill-resource tool
|
|
78
91
|
registerSkillResourceTool(server, skillState);
|
|
92
|
+
return skillTool;
|
|
79
93
|
}
|
|
80
94
|
/**
|
|
81
95
|
* Input schema for the skill-resource tool.
|
|
@@ -90,7 +104,9 @@ const SkillResourceSchema = z.object({
|
|
|
90
104
|
.describe("Relative path to file or directory. Examples: 'snippets/tool.ts' (single file), 'templates' (all files in directory), '' (list available files)."),
|
|
91
105
|
});
|
|
92
106
|
// Security constants (exported for reuse in skill-resources.ts)
|
|
93
|
-
|
|
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
|
|
94
110
|
export const MAX_DIRECTORY_DEPTH = 10; // Prevent deeply nested traversal
|
|
95
111
|
/**
|
|
96
112
|
* Check if a path is within the allowed base directory.
|
package/dist/subscriptions.d.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* URI patterns supported:
|
|
9
9
|
* - skill:// → Watch all skill directories
|
|
10
10
|
* - skill://{name} → Watch that skill's SKILL.md
|
|
11
|
+
* - skill://{name}/ → Watch entire skill directory (directory collection)
|
|
11
12
|
* - skill://{name}/{path} → Watch specific file
|
|
12
13
|
*/
|
|
13
14
|
import { FSWatcher } from "chokidar";
|
package/dist/subscriptions.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* URI patterns supported:
|
|
9
9
|
* - skill:// → Watch all skill directories
|
|
10
10
|
* - skill://{name} → Watch that skill's SKILL.md
|
|
11
|
+
* - skill://{name}/ → Watch entire skill directory (directory collection)
|
|
11
12
|
* - skill://{name}/{path} → Watch specific file
|
|
12
13
|
*/
|
|
13
14
|
import chokidar from "chokidar";
|
|
@@ -50,6 +51,13 @@ export function resolveUriToFilePaths(uri, skillState) {
|
|
|
50
51
|
const skill = skillState.skillMap.get(skillName);
|
|
51
52
|
return skill ? [skill.path] : [];
|
|
52
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
|
+
}
|
|
53
61
|
// skill://{skillName}/{path} → Specific file
|
|
54
62
|
const fileMatch = uri.match(/^skill:\/\/([^/]+)\/(.+)$/);
|
|
55
63
|
if (fileMatch) {
|