@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 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 [MCP client](https://modelcontextprotocol.io/clients) that supports server instructions. This allows the LLM to see available skills in its system prompt, enabling automatic skill discovery and activation. Without this support, the model will still be able to call these tools, but you might need to provide more explicit instructions on what skills are available and the intended activation patterns.
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** - Discovers skills from a configured directory at startup
10
- - **Server Instructions** - Injects skill metadata into the system prompt (for clients supporting instructions)
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 a skills directory containing your Agent Skills:
38
+ Configure one or more skills directories containing your Agent Skills:
39
39
 
40
40
  ```bash
41
- # Pass skills directory as argument
41
+ # Single directory
42
42
  skill-jack-mcp /path/to/skills
43
43
 
44
- # Or use environment variable
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
- The server scans the directory and its `.claude/skills/` and `skills/` subdirectories for skills.
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 directory
60
- 2. **On connection**: Server instructions (with skill metadata) are sent in the initialize response
61
- 3. **On tool call**: Agent calls `skill` tool to load full SKILL.md content
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 directory
67
- │ • Generates instructions with skill metadata
73
+ │ • Discovers skills from configured directories
74
+ │ • Starts watching for SKILL.md changes
68
75
  │ ↓ │
69
76
  │ MCP Client connects │
70
- │ • Server instructions included in initialize response
77
+ │ • Skill tool description includes available skills
78
+ │ ↓ │
79
+ │ LLM sees skill metadata in tool description │
71
80
  │ ↓ │
72
- LLM sees skill metadata in system prompt
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 (10MB max)
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
- ## Server Instructions Format
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 server generates [instructions](https://blog.modelcontextprotocol.io/posts/2025-11-03-using-server-instructions/) that include a usage preamble and skill metadata:
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
- These are loaded into the model's system prompt by [clients](https://modelcontextprotocol.io/clients) that support instructions.
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 directory. The server checks:
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 # Skills directory (required)
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 # Skills directory (required)
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, generateInstructions, createSkillMap } from "./skill-discovery.js";
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
- * Get the skills directory from command line args or environment.
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
- function getSkillsDir() {
28
- // Check command line argument first
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
- if (args.length > 0 && args[0] && !args[0].startsWith("-")) {
31
- return path.resolve(args[0]);
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
- // Fall back to environment variable
49
+ // Also check environment variable (comma-separated supported)
34
50
  const envDir = process.env.SKILLS_DIR;
35
51
  if (envDir) {
36
- return path.resolve(envDir);
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
- return null;
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 directory.
50
- * Checks both the directory itself and standard subdirectories.
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 discoverSkillsFromDir(skillsDir) {
74
+ function discoverSkillsFromDirs(skillsDirs) {
53
75
  const allSkills = [];
54
- // Check if the directory itself contains skills
55
- const directSkills = discoverSkills(skillsDir);
56
- allSkills.push(...directSkills);
57
- // Also check standard subdirectories
58
- for (const subdir of SKILL_SUBDIRS) {
59
- const subPath = path.join(skillsDir, subdir);
60
- if (fs.existsSync(subPath)) {
61
- const subdirSkills = discoverSkills(subPath);
62
- allSkills.push(...subdirSkills);
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 skillsDir = getSkillsDir();
73
- if (!skillsDir) {
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 directory: ${skillsDir}`);
241
+ console.error(`Skills directories: ${skillsDirs.join(", ")}`);
80
242
  // Discover skills at startup
81
- const skills = discoverSkillsFromDir(skillsDir);
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>;
@@ -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.set(skill.name, skill);
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";
@@ -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 that returns all skills at once.
59
+ * Register a collection resource for skill directories.
61
60
  *
62
- * URI: skill://
61
+ * URI Pattern: skill://{skillName}/
63
62
  *
64
- * Returns multiple ResourceContents, one per skill, each with its own URI.
65
- * This allows clients to fetch all skill content in a single request.
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 registerAllSkillsCollection(server, skillState) {
68
- server.registerResource("All Skills", "skill://", {
69
- mimeType: "text/markdown",
70
- description: "Collection of all available skills. Returns all SKILL.md contents in one request.",
71
- }, async () => {
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 [name, skill] of skillState.skillMap) {
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 content = loadSkillContent(skill.path);
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(name)}`,
78
- mimeType: "text/markdown",
124
+ uri: `skill://${encodeURIComponent(skillName)}/${file}`,
125
+ mimeType: getMimeType(file),
79
126
  text: content,
80
127
  });
81
128
  }
82
129
  catch (error) {
83
- // Skip skills that fail to load, but continue with others
84
- console.error(`Failed to load skill "${name}":`, error);
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 };
@@ -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 registerSkillTool(server: McpServer, skillState: SkillState): void;
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
  /**
@@ -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: "Load a skill's full instructions. Returns the complete SKILL.md content " +
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
- export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB max file size
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.
@@ -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";
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olaservo/skill-jack-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server that discovers and serves Agent Skills. I know kung fu.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",