@skilljack/mcp 0.3.0 → 0.4.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
@@ -9,9 +9,22 @@ An MCP server that jacks [Agent Skills](https://agentskills.io) directly into yo
9
9
  - **Dynamic Skill Discovery** - Watches skill directories and automatically refreshes when skills change
10
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
+ - **MCP Prompts** - Load skills via `/skill` prompt with auto-completion or per-skill prompts
12
13
  - **MCP Resources** - Access skills via `skill://` URIs with batch collection support
13
14
  - **Resource Subscriptions** - Real-time file watching with `notifications/resources/updated`
14
15
 
16
+ ## Motivation
17
+
18
+ This repo demonstrates a way to approach integrating skills using existing MCP primitives.
19
+
20
+ MCP already has the building blocks:
21
+ - **Tools** for on-demand skill loading (the `skill` tool with dynamically updated descriptions)
22
+ - **Resources** for explicit skill access (`skill://` URIs)
23
+ - **Notifications** for real-time updates (`tools/listChanged`, `resources/updated`)
24
+ - **Prompts** for explicitly invoking skills by name (`/my-server-skill`)
25
+
26
+ This approach provides separation of concerns. Rather than every MCP server needing to embed skill handling, the server acts as a dedicated 'skill gateway'. Server authors can bundle skills alongside their MCP servers without modifying the servers themselves. If MCP registries support robust tool discovery, skill tools become discoverable like any other tool.
27
+
15
28
  ## Installation
16
29
 
17
30
  ```bash
@@ -75,16 +88,20 @@ The server implements the [Agent Skills](https://agentskills.io) progressive dis
75
88
  │ ↓ │
76
89
  │ MCP Client connects │
77
90
  │ • Skill tool description includes available skills │
91
+ │ • Prompts registered for each skill │
78
92
  │ ↓ │
79
93
  │ LLM sees skill metadata in tool description │
80
94
  │ ↓ │
81
95
  │ SKILL.md added/modified/removed │
82
96
  │ • Server re-discovers skills │
83
97
  │ • Updates skill tool description │
98
+ │ • Updates prompt list (add/remove/modify) │
84
99
  │ • Sends tools/listChanged notification │
85
- │ • Client refreshes tool definitions
100
+ │ • Sends prompts/listChanged notification
101
+ │ • Client refreshes tool and prompt definitions │
86
102
  │ ↓ │
87
- LLM calls "skill" tool with skill name
103
+ User invokes /skill prompt or /skill-name prompt
104
+ │ OR LLM calls "skill" tool with skill name │
88
105
  │ ↓ │
89
106
  │ Server returns full SKILL.md content │
90
107
  │ ↓ │
@@ -93,14 +110,41 @@ The server implements the [Agent Skills](https://agentskills.io) progressive dis
93
110
  └─────────────────────────────────────────────────────────┘
94
111
  ```
95
112
 
96
- ## Tools vs Resources
113
+ ## Tools vs Resources vs Prompts
97
114
 
98
- This server exposes skills via both **tools** and **resources**:
115
+ This server exposes skills via **tools**, **resources**, and **prompts**:
99
116
 
100
117
  - **Tools** (`skill`, `skill-resource`) - For your agent to use autonomously. The LLM sees available skills in the tool description and calls them as needed.
118
+ - **Prompts** (`/skill`, `/skill-name`) - For explicit user invocation. Use `/skill` with auto-completion or select a skill directly by name.
101
119
  - **Resources** (`skill://` URIs) - For manual selection in apps that support it (e.g., Claude Desktop's resource picker). Useful when you want to explicitly attach a skill to the conversation.
102
120
 
103
- Most users will rely on tools for automatic skill activation. Resources provide an alternative for manual control.
121
+ Most users will rely on tools for automatic skill activation. Prompts provide user-initiated loading with auto-completion. Resources provide an alternative for manual control.
122
+
123
+ ## Progressive Disclosure Design
124
+
125
+ This server implements the [Agent Skills progressive disclosure pattern](https://agentskills.io/specification#progressive-disclosure), which structures skills for efficient context usage:
126
+
127
+ | Level | Tokens | What's loaded | When |
128
+ |-------|--------|---------------|------|
129
+ | **Metadata** | ~100 | `name` and `description` | At startup, for all skills |
130
+ | **Instructions** | < 5000 | Full SKILL.md body | When skill is activated |
131
+ | **Resources** | As needed | Files in `scripts/`, `references/`, `assets/` | On demand via `skill-resource` |
132
+
133
+ ### How it works
134
+
135
+ 1. **Discovery** - Server loads metadata from all skills into the `skill` tool description
136
+ 2. **Activation** - When a skill is loaded (via tool, prompt, or resource), only the SKILL.md content is returned
137
+ 3. **Execution** - SKILL.md references additional files; agent fetches them with `skill-resource` as needed
138
+
139
+ ### Why SKILL.md documents its own resources
140
+
141
+ The server doesn't automatically list all files in a skill directory. Instead, skill authors document available resources directly in their SKILL.md (e.g., "Copy the template from `templates/server.ts`"). This design choice follows the spec because:
142
+
143
+ - **Skill authors know best** - They decide which files are relevant and when to use them
144
+ - **Context efficiency** - Loading everything upfront wastes tokens on files the agent may not need
145
+ - **Natural flow** - SKILL.md guides the agent through resources in a logical order
146
+
147
+ **For skill authors:** Reference files using relative paths from the skill root (e.g., `snippets/tool.ts`, `references/api.md`). Keep your main SKILL.md under 500 lines; move detailed reference material to separate files. See the [Agent Skills specification](https://agentskills.io/specification) for complete authoring guidelines.
104
148
 
105
149
  ## Tools
106
150
 
@@ -150,6 +194,38 @@ Returns all files in the directory as multiple content items.
150
194
 
151
195
  **Security:** Path traversal is prevented - only files within the skill directory can be accessed.
152
196
 
197
+ ## Prompts
198
+
199
+ Skills can be loaded via MCP [Prompts](https://modelcontextprotocol.io/specification/2025-11-05/server/prompts) for explicit user invocation.
200
+
201
+ ### `/skill` Prompt
202
+
203
+ Load a skill by name with auto-completion support.
204
+
205
+ **Arguments:**
206
+ - `name` (string, required) - Skill name with auto-completion
207
+
208
+ The prompt description includes all available skills for discoverability. As you type the skill name, matching skills are suggested.
209
+
210
+ ### Per-Skill Prompts
211
+
212
+ Each discovered skill is also registered as its own prompt (e.g., `/mcp-server-ts`, `/algorithmic-art`).
213
+
214
+ - No arguments needed - just select and invoke
215
+ - Description shows the skill's own description
216
+ - List updates dynamically as skills change
217
+
218
+ **Example:** If you have a skill named `mcp-server-ts`, you can invoke it directly as `/mcp-server-ts`.
219
+
220
+ ### Content Annotations
221
+
222
+ Prompt responses include MCP [content annotations](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts#embedded-resources) for proper handling:
223
+
224
+ - `audience: ["assistant"]` - Content is intended for the LLM, not the user
225
+ - `priority: 1.0` - High priority content that should be included in context
226
+
227
+ Prompts return embedded resources with the skill's `skill://` URI, allowing clients to track the content source.
228
+
153
229
  ## Resources
154
230
 
155
231
  Skills are also accessible via MCP [Resources](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resources) using `skill://` URIs.
@@ -160,7 +236,8 @@ Skills are also accessible via MCP [Resources](https://modelcontextprotocol.io/s
160
236
  |-----|---------|
161
237
  | `skill://{name}` | Single skill's SKILL.md content |
162
238
  | `skill://{name}/` | All files in skill directory (collection) |
163
- | `skill://{name}/{path}` | Specific file within skill |
239
+
240
+ Individual file URIs (`skill://{name}/{path}`) are not listed as resources to reduce noise. Use the `skill-resource` tool to fetch specific files on demand.
164
241
 
165
242
  ### Resource Subscriptions
166
243
 
@@ -211,8 +288,9 @@ The server watches skill directories for changes. When SKILL.md files are added,
211
288
 
212
289
  1. Skills are re-discovered from all configured directories
213
290
  2. The `skill` tool's description is updated with current skill names and metadata
214
- 3. `tools/listChanged` notification is sent to connected clients
215
- 4. Clients that support this notification will refresh tool definitions
291
+ 3. Per-skill prompts are added, removed, or updated accordingly
292
+ 4. `tools/listChanged` and `prompts/listChanged` notifications are sent to connected clients
293
+ 5. Clients that support these notifications will refresh tool and prompt definitions
216
294
 
217
295
  ## Skill Metadata Format
218
296
 
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import * as path from "node:path";
18
18
  import { discoverSkills, createSkillMap } from "./skill-discovery.js";
19
19
  import { registerSkillTool, getToolDescription } from "./skill-tool.js";
20
20
  import { registerSkillResources } from "./skill-resources.js";
21
+ import { registerSkillPrompts, refreshPrompts } from "./skill-prompts.js";
21
22
  import { createSubscriptionManager, registerSubscriptionHandlers, refreshSubscriptions, } from "./subscriptions.js";
22
23
  /**
23
24
  * Subdirectories to check for skills within the configured directory.
@@ -114,9 +115,10 @@ const SKILL_REFRESH_DEBOUNCE_MS = 500;
114
115
  * @param skillsDirs - The configured skill directories
115
116
  * @param server - The MCP server instance
116
117
  * @param skillTool - The registered skill tool to update
118
+ * @param promptRegistry - For refreshing skill prompts
117
119
  * @param subscriptionManager - For refreshing resource subscriptions
118
120
  */
119
- function refreshSkills(skillsDirs, server, skillTool, subscriptionManager) {
121
+ function refreshSkills(skillsDirs, server, skillTool, promptRegistry, subscriptionManager) {
120
122
  console.error("Refreshing skills...");
121
123
  // Re-discover all skills
122
124
  const skills = discoverSkillsFromDirs(skillsDirs);
@@ -128,6 +130,8 @@ function refreshSkills(skillsDirs, server, skillTool, subscriptionManager) {
128
130
  skillTool.update({
129
131
  description: getToolDescription(skillState),
130
132
  });
133
+ // Refresh prompts to match new skill state
134
+ refreshPrompts(server, skillState, promptRegistry);
131
135
  // Refresh resource subscriptions to match new skill state
132
136
  refreshSubscriptions(subscriptionManager, skillState, (uri) => {
133
137
  server.server.notification({
@@ -148,9 +152,10 @@ function refreshSkills(skillsDirs, server, skillTool, subscriptionManager) {
148
152
  * @param skillsDirs - The configured skill directories
149
153
  * @param server - The MCP server instance
150
154
  * @param skillTool - The registered skill tool to update
155
+ * @param promptRegistry - For refreshing skill prompts
151
156
  * @param subscriptionManager - For refreshing subscriptions
152
157
  */
153
- function watchSkillDirectories(skillsDirs, server, skillTool, subscriptionManager) {
158
+ function watchSkillDirectories(skillsDirs, server, skillTool, promptRegistry, subscriptionManager) {
154
159
  let refreshTimeout = null;
155
160
  const debouncedRefresh = () => {
156
161
  if (refreshTimeout) {
@@ -158,7 +163,7 @@ function watchSkillDirectories(skillsDirs, server, skillTool, subscriptionManage
158
163
  }
159
164
  refreshTimeout = setTimeout(() => {
160
165
  refreshTimeout = null;
161
- refreshSkills(skillsDirs, server, skillTool, subscriptionManager);
166
+ refreshSkills(skillsDirs, server, skillTool, promptRegistry, subscriptionManager);
162
167
  }, SKILL_REFRESH_DEBOUNCE_MS);
163
168
  };
164
169
  // Build list of paths to watch
@@ -251,15 +256,17 @@ async function main() {
251
256
  capabilities: {
252
257
  tools: { listChanged: true },
253
258
  resources: { subscribe: true, listChanged: true },
259
+ prompts: { listChanged: true },
254
260
  },
255
261
  });
256
- // Register tools and resources
262
+ // Register tools, resources, and prompts
257
263
  const skillTool = registerSkillTool(server, skillState);
258
264
  registerSkillResources(server, skillState);
265
+ const promptRegistry = registerSkillPrompts(server, skillState);
259
266
  // Register subscription handlers for resource file watching
260
267
  registerSubscriptionHandlers(server, skillState, subscriptionManager);
261
268
  // Set up file watchers for skill directory changes
262
- watchSkillDirectories(skillsDirs, server, skillTool, subscriptionManager);
269
+ watchSkillDirectories(skillsDirs, server, skillTool, promptRegistry, subscriptionManager);
263
270
  // Connect via stdio transport
264
271
  const transport = new StdioServerTransport();
265
272
  await server.connect(transport);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * MCP prompt registration for skill loading.
3
+ *
4
+ * Provides two patterns for loading skills:
5
+ * 1. /skill prompt - Single prompt with name argument + auto-completion
6
+ * 2. Per-skill prompts - Dynamic prompts for each skill (e.g., /mcp-server-ts)
7
+ */
8
+ import { McpServer, RegisteredPrompt } from "@modelcontextprotocol/sdk/server/mcp.js";
9
+ import { SkillState } from "./skill-tool.js";
10
+ /**
11
+ * Track all registered prompts for dynamic updates.
12
+ */
13
+ export interface PromptRegistry {
14
+ skillPrompt: RegisteredPrompt;
15
+ perSkillPrompts: Map<string, RegisteredPrompt>;
16
+ }
17
+ /**
18
+ * Generate the description for the /skill prompt.
19
+ * Includes available skills list for discoverability.
20
+ */
21
+ export declare function getPromptDescription(skillState: SkillState): string;
22
+ /**
23
+ * Register skill prompts with the MCP server.
24
+ *
25
+ * Creates:
26
+ * 1. /skill prompt with name argument + auto-completion
27
+ * 2. Per-skill prompts for each discovered skill
28
+ *
29
+ * @param server - The McpServer instance
30
+ * @param skillState - Shared state object (allows dynamic updates)
31
+ * @returns Registry for tracking and updating prompts
32
+ */
33
+ export declare function registerSkillPrompts(server: McpServer, skillState: SkillState): PromptRegistry;
34
+ /**
35
+ * Refresh prompts when skills change.
36
+ *
37
+ * Updates:
38
+ * - /skill prompt description with new skill list
39
+ * - Disables prompts for removed skills
40
+ * - Adds prompts for new skills
41
+ * - Updates descriptions for modified skills
42
+ *
43
+ * @param server - The McpServer instance
44
+ * @param skillState - Updated skill state
45
+ * @param registry - Prompt registry to update
46
+ */
47
+ export declare function refreshPrompts(server: McpServer, skillState: SkillState, registry: PromptRegistry): void;
@@ -0,0 +1,236 @@
1
+ /**
2
+ * MCP prompt registration for skill loading.
3
+ *
4
+ * Provides two patterns for loading skills:
5
+ * 1. /skill prompt - Single prompt with name argument + auto-completion
6
+ * 2. Per-skill prompts - Dynamic prompts for each skill (e.g., /mcp-server-ts)
7
+ */
8
+ import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
9
+ import { z } from "zod";
10
+ import { loadSkillContent, generateInstructions } from "./skill-discovery.js";
11
+ /**
12
+ * Auto-completion for /skill prompt name argument.
13
+ * Returns skill names that start with the given value (case-insensitive).
14
+ */
15
+ function getSkillNameCompletions(value, skillState) {
16
+ const names = Array.from(skillState.skillMap.keys());
17
+ return names.filter((name) => name.toLowerCase().startsWith(value.toLowerCase()));
18
+ }
19
+ /**
20
+ * Generate the description for the /skill prompt.
21
+ * Includes available skills list for discoverability.
22
+ */
23
+ export function getPromptDescription(skillState) {
24
+ const skills = Array.from(skillState.skillMap.values());
25
+ const usage = "Load a skill by name with auto-completion.\n\n";
26
+ return usage + generateInstructions(skills);
27
+ }
28
+ /**
29
+ * Register skill prompts with the MCP server.
30
+ *
31
+ * Creates:
32
+ * 1. /skill prompt with name argument + auto-completion
33
+ * 2. Per-skill prompts for each discovered skill
34
+ *
35
+ * @param server - The McpServer instance
36
+ * @param skillState - Shared state object (allows dynamic updates)
37
+ * @returns Registry for tracking and updating prompts
38
+ */
39
+ export function registerSkillPrompts(server, skillState) {
40
+ // 1. Register /skill prompt with argument + auto-completion
41
+ const skillPrompt = server.registerPrompt("skill", {
42
+ title: "Load Skill",
43
+ description: getPromptDescription(skillState),
44
+ argsSchema: {
45
+ name: completable(z.string().describe("Skill name"), (value) => getSkillNameCompletions(value, skillState)),
46
+ },
47
+ }, async ({ name }) => {
48
+ const skill = skillState.skillMap.get(name);
49
+ if (!skill) {
50
+ const availableSkills = Array.from(skillState.skillMap.keys()).join(", ");
51
+ return {
52
+ messages: [
53
+ {
54
+ role: "user",
55
+ content: {
56
+ type: "text",
57
+ text: `Skill "${name}" not found. Available skills: ${availableSkills || "none"}`,
58
+ },
59
+ },
60
+ ],
61
+ };
62
+ }
63
+ try {
64
+ const content = loadSkillContent(skill.path);
65
+ return {
66
+ messages: [
67
+ {
68
+ role: "user",
69
+ content: {
70
+ type: "resource",
71
+ resource: {
72
+ uri: `skill://${name}`,
73
+ mimeType: "text/markdown",
74
+ text: content,
75
+ },
76
+ annotations: {
77
+ audience: ["assistant"],
78
+ priority: 1.0,
79
+ },
80
+ },
81
+ },
82
+ ],
83
+ };
84
+ }
85
+ catch (error) {
86
+ const message = error instanceof Error ? error.message : String(error);
87
+ return {
88
+ messages: [
89
+ {
90
+ role: "user",
91
+ content: {
92
+ type: "text",
93
+ text: `Failed to load skill "${name}": ${message}`,
94
+ },
95
+ },
96
+ ],
97
+ };
98
+ }
99
+ });
100
+ // 2. Register per-skill prompts (no arguments needed)
101
+ // Returns embedded resource with skill:// URI (MCP-idiomatic)
102
+ const perSkillPrompts = new Map();
103
+ for (const [name, skill] of skillState.skillMap) {
104
+ // Capture skill info in closure for this specific prompt
105
+ const skillPath = skill.path;
106
+ const skillName = name;
107
+ const prompt = server.registerPrompt(name, {
108
+ title: skill.name,
109
+ description: skill.description,
110
+ // No argsSchema - direct invocation
111
+ }, async () => {
112
+ try {
113
+ const content = loadSkillContent(skillPath);
114
+ return {
115
+ messages: [
116
+ {
117
+ role: "user",
118
+ content: {
119
+ type: "resource",
120
+ resource: {
121
+ uri: `skill://${skillName}`,
122
+ mimeType: "text/markdown",
123
+ text: content,
124
+ },
125
+ annotations: {
126
+ audience: ["assistant"],
127
+ priority: 1.0,
128
+ },
129
+ },
130
+ },
131
+ ],
132
+ };
133
+ }
134
+ catch (error) {
135
+ const message = error instanceof Error ? error.message : String(error);
136
+ return {
137
+ messages: [
138
+ {
139
+ role: "user",
140
+ content: {
141
+ type: "text",
142
+ text: `Failed to load skill "${skillName}": ${message}`,
143
+ },
144
+ },
145
+ ],
146
+ };
147
+ }
148
+ });
149
+ perSkillPrompts.set(name, prompt);
150
+ }
151
+ return { skillPrompt, perSkillPrompts };
152
+ }
153
+ /**
154
+ * Refresh prompts when skills change.
155
+ *
156
+ * Updates:
157
+ * - /skill prompt description with new skill list
158
+ * - Disables prompts for removed skills
159
+ * - Adds prompts for new skills
160
+ * - Updates descriptions for modified skills
161
+ *
162
+ * @param server - The McpServer instance
163
+ * @param skillState - Updated skill state
164
+ * @param registry - Prompt registry to update
165
+ */
166
+ export function refreshPrompts(server, skillState, registry) {
167
+ // Update /skill prompt description with new skill list
168
+ registry.skillPrompt.update({
169
+ description: getPromptDescription(skillState),
170
+ });
171
+ // Disable removed skill prompts
172
+ for (const [name, prompt] of registry.perSkillPrompts) {
173
+ if (!skillState.skillMap.has(name)) {
174
+ prompt.update({ enabled: false });
175
+ registry.perSkillPrompts.delete(name);
176
+ }
177
+ }
178
+ // Add/update per-skill prompts
179
+ for (const [name, skill] of skillState.skillMap) {
180
+ if (registry.perSkillPrompts.has(name)) {
181
+ // Update existing prompt description
182
+ registry.perSkillPrompts.get(name).update({
183
+ description: skill.description,
184
+ });
185
+ }
186
+ else {
187
+ // Register new skill prompt with embedded resource
188
+ const skillPath = skill.path;
189
+ const skillName = name;
190
+ const prompt = server.registerPrompt(name, {
191
+ title: skill.name,
192
+ description: skill.description,
193
+ }, async () => {
194
+ try {
195
+ const content = loadSkillContent(skillPath);
196
+ return {
197
+ messages: [
198
+ {
199
+ role: "user",
200
+ content: {
201
+ type: "resource",
202
+ resource: {
203
+ uri: `skill://${skillName}`,
204
+ mimeType: "text/markdown",
205
+ text: content,
206
+ },
207
+ annotations: {
208
+ audience: ["assistant"],
209
+ priority: 1.0,
210
+ },
211
+ },
212
+ },
213
+ ],
214
+ };
215
+ }
216
+ catch (error) {
217
+ const message = error instanceof Error ? error.message : String(error);
218
+ return {
219
+ messages: [
220
+ {
221
+ role: "user",
222
+ content: {
223
+ type: "text",
224
+ text: `Failed to load skill "${skillName}": ${message}`,
225
+ },
226
+ },
227
+ ],
228
+ };
229
+ }
230
+ });
231
+ registry.perSkillPrompts.set(name, prompt);
232
+ }
233
+ }
234
+ // Notify clients that prompts have changed
235
+ server.sendPromptListChanged();
236
+ }
@@ -8,9 +8,12 @@
8
8
  * skill updates when MCP roots change.
9
9
  *
10
10
  * URI Scheme:
11
- * skill://{skillName} -> SKILL.md content (template)
12
- * skill://{skillName}/ -> Collection: all files in skill directory
13
- * skill://{skillName}/{path} -> File within skill directory (template)
11
+ * skill://{skillName} -> SKILL.md content (template)
12
+ * skill://{skillName}/ -> Collection: all files in skill directory
13
+ *
14
+ * Note: Individual file URIs (skill://{skillName}/{path}) are not listed
15
+ * as resources to reduce noise. Use the skill-resource tool to fetch
16
+ * specific files on demand.
14
17
  */
15
18
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
19
  import { SkillState } from "./skill-tool.js";
@@ -8,9 +8,12 @@
8
8
  * skill updates when MCP roots change.
9
9
  *
10
10
  * URI Scheme:
11
- * skill://{skillName} -> SKILL.md content (template)
12
- * skill://{skillName}/ -> Collection: all files in skill directory
13
- * skill://{skillName}/{path} -> File within skill directory (template)
11
+ * skill://{skillName} -> SKILL.md content (template)
12
+ * skill://{skillName}/ -> Collection: all files in skill directory
13
+ *
14
+ * Note: Individual file URIs (skill://{skillName}/{path}) are not listed
15
+ * as resources to reduce noise. Use the skill-resource tool to fetch
16
+ * specific files on demand.
14
17
  */
15
18
  import * as fs from "node:fs";
16
19
  import * as path from "node:path";
@@ -50,10 +53,11 @@ function getMimeType(filePath) {
50
53
  export function registerSkillResources(server, skillState) {
51
54
  // Register template for individual skill SKILL.md files
52
55
  registerSkillTemplate(server, skillState);
53
- // Register collection resource for skill directories (must be before file template)
56
+ // Register collection resource for skill directories
54
57
  registerSkillDirectoryCollection(server, skillState);
55
- // Register resource template for skill files
56
- registerSkillFileTemplate(server, skillState);
58
+ // Note: Individual file resources (skill://{name}/{path}) are intentionally
59
+ // not registered to reduce noise. Use the skill-resource tool to fetch
60
+ // specific files on demand.
57
61
  }
58
62
  /**
59
63
  * Register a collection resource for skill directories.
@@ -196,91 +200,3 @@ function registerSkillTemplate(server, skillState) {
196
200
  }
197
201
  });
198
202
  }
199
- /**
200
- * Register the resource template for accessing files within skills.
201
- *
202
- * URI Pattern: skill://{skillName}/{filePath}
203
- */
204
- function registerSkillFileTemplate(server, skillState) {
205
- server.registerResource("Skill File", new ResourceTemplate("skill://{skillName}/{+filePath}", {
206
- list: async () => {
207
- // Return all listable skill files (dynamic based on current skillMap)
208
- const resources = [];
209
- for (const [name, skill] of skillState.skillMap) {
210
- const skillDir = path.dirname(skill.path);
211
- const files = listSkillFiles(skillDir);
212
- for (const file of files) {
213
- const uri = `skill://${encodeURIComponent(name)}/${file}`;
214
- resources.push({
215
- uri,
216
- name: `${name}/${file}`,
217
- mimeType: getMimeType(file),
218
- });
219
- }
220
- }
221
- return { resources };
222
- },
223
- complete: {
224
- skillName: (value) => {
225
- const names = Array.from(skillState.skillMap.keys());
226
- return names.filter((name) => name.toLowerCase().startsWith(value.toLowerCase()));
227
- },
228
- },
229
- }), {
230
- mimeType: "text/plain",
231
- description: "Files within a skill directory (scripts, snippets, assets, etc.)",
232
- }, async (resourceUri, variables) => {
233
- // Extract skill name and file path from URI
234
- const uriStr = resourceUri.toString();
235
- const match = uriStr.match(/^skill:\/\/([^/]+)\/(.+)$/);
236
- if (!match) {
237
- throw new Error(`Invalid skill file URI: ${uriStr}`);
238
- }
239
- const skillName = decodeURIComponent(match[1]);
240
- const filePath = match[2];
241
- const skill = skillState.skillMap.get(skillName);
242
- if (!skill) {
243
- const available = Array.from(skillState.skillMap.keys()).join(", ");
244
- throw new Error(`Skill "${skillName}" not found. Available: ${available || "none"}`);
245
- }
246
- const skillDir = path.dirname(skill.path);
247
- const fullPath = path.resolve(skillDir, filePath);
248
- // Security: Validate path is within skill directory
249
- if (!isPathWithinBase(fullPath, skillDir)) {
250
- throw new Error(`Path "${filePath}" is outside the skill directory`);
251
- }
252
- // Check file exists
253
- if (!fs.existsSync(fullPath)) {
254
- const files = listSkillFiles(skillDir).slice(0, 10);
255
- throw new Error(`File "${filePath}" not found in skill "${skillName}". ` +
256
- `Available: ${files.join(", ")}${files.length >= 10 ? "..." : ""}`);
257
- }
258
- const stat = fs.statSync(fullPath);
259
- // Reject symlinks
260
- if (stat.isSymbolicLink()) {
261
- throw new Error(`Cannot read symlink "${filePath}"`);
262
- }
263
- // Reject directories
264
- if (stat.isDirectory()) {
265
- const files = listSkillFiles(skillDir, filePath);
266
- throw new Error(`"${filePath}" is a directory. Files within: ${files.join(", ")}`);
267
- }
268
- // Check file size
269
- if (stat.size > MAX_FILE_SIZE) {
270
- const sizeMB = (stat.size / 1024 / 1024).toFixed(2);
271
- throw new Error(`File too large (${sizeMB}MB). Maximum: 10MB`);
272
- }
273
- // Read and return content
274
- const content = fs.readFileSync(fullPath, "utf-8");
275
- const mimeType = getMimeType(fullPath);
276
- return {
277
- contents: [
278
- {
279
- uri: uriStr,
280
- mimeType,
281
- text: content,
282
- },
283
- ],
284
- };
285
- });
286
- }
@@ -9,7 +9,7 @@
9
9
  * - skill:// → Watch all skill directories
10
10
  * - skill://{name} → Watch that skill's SKILL.md
11
11
  * - skill://{name}/ → Watch entire skill directory (directory collection)
12
- * - skill://{name}/{path} → Watch specific file
12
+ * - skill://{name}/{path} → Watch specific file (subscribable but not listed as resource)
13
13
  */
14
14
  import { FSWatcher } from "chokidar";
15
15
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -9,7 +9,7 @@
9
9
  * - skill:// → Watch all skill directories
10
10
  * - skill://{name} → Watch that skill's SKILL.md
11
11
  * - skill://{name}/ → Watch entire skill directory (directory collection)
12
- * - skill://{name}/{path} → Watch specific file
12
+ * - skill://{name}/{path} → Watch specific file (subscribable but not listed as resource)
13
13
  */
14
14
  import chokidar from "chokidar";
15
15
  import * as path from "node:path";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skilljack/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -1,49 +0,0 @@
1
- /**
2
- * MCP Roots handler for dynamic skill discovery.
3
- *
4
- * Requests roots from the client, scans for skills in each root,
5
- * and handles root change notifications.
6
- *
7
- * Pattern adapted from:
8
- * - .claude/skills/mcp-server-ts/snippets/server/index.ts (oninitialized, syncRoots)
9
- * - .claude/skills/mcp-client-ts/snippets/handlers/roots.ts (URI conversion)
10
- */
11
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
- import { SkillMetadata } from "./skill-discovery.js";
13
- /**
14
- * Skill discovery locations within each root.
15
- */
16
- export declare const SKILL_SUBDIRS: string[];
17
- /**
18
- * Discover skills from all roots provided by the client.
19
- *
20
- * Scans each root for skill directories (.claude/skills/, skills/)
21
- * and handles naming conflicts by prefixing with root name.
22
- *
23
- * @param roots - Array of Root objects from client's roots/list response
24
- * @returns Object containing discovered skills
25
- */
26
- export declare function discoverSkillsFromRoots(roots: Array<{
27
- uri: string;
28
- name?: string;
29
- }>): {
30
- skills: SkillMetadata[];
31
- rootSources: Map<string, string>;
32
- };
33
- /**
34
- * Callback type for when skills are updated.
35
- */
36
- export type SkillsChangedCallback = (skillMap: Map<string, SkillMetadata>, instructions: string) => void;
37
- /**
38
- * Sync skills from roots or configured skills directory.
39
- *
40
- * Pattern from mcp-server-ts snippets/server/index.ts:
41
- * - Check client capabilities
42
- * - Request roots if supported
43
- * - Use skills directory if not
44
- *
45
- * @param server - The McpServer instance
46
- * @param skillsDir - Optional skills directory if client doesn't support roots
47
- * @param onSkillsChanged - Callback when skills are updated
48
- */
49
- export declare function syncSkills(server: McpServer, skillsDir: string | null, onSkillsChanged: SkillsChangedCallback): Promise<void>;
@@ -1,199 +0,0 @@
1
- /**
2
- * MCP Roots handler for dynamic skill discovery.
3
- *
4
- * Requests roots from the client, scans for skills in each root,
5
- * and handles root change notifications.
6
- *
7
- * Pattern adapted from:
8
- * - .claude/skills/mcp-server-ts/snippets/server/index.ts (oninitialized, syncRoots)
9
- * - .claude/skills/mcp-client-ts/snippets/handlers/roots.ts (URI conversion)
10
- */
11
- import { RootsListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
12
- import * as fs from "node:fs";
13
- import * as path from "node:path";
14
- import { fileURLToPath } from "node:url";
15
- import { discoverSkills, generateInstructions, createSkillMap, } from "./skill-discovery.js";
16
- /**
17
- * Skill discovery locations within each root.
18
- */
19
- export const SKILL_SUBDIRS = [".claude/skills", "skills"];
20
- /**
21
- * Convert a file:// URI to a filesystem path.
22
- * Adapted from mcp-client-ts roots.ts pathToRoot() (reverse direction).
23
- */
24
- function uriToPath(uri) {
25
- return fileURLToPath(new URL(uri));
26
- }
27
- /**
28
- * Discover skills from all roots provided by the client.
29
- *
30
- * Scans each root for skill directories (.claude/skills/, skills/)
31
- * and handles naming conflicts by prefixing with root name.
32
- *
33
- * @param roots - Array of Root objects from client's roots/list response
34
- * @returns Object containing discovered skills
35
- */
36
- export function discoverSkillsFromRoots(roots) {
37
- const allSkills = [];
38
- const rootSources = new Map(); // skill path -> root name
39
- const nameCount = new Map(); // track duplicates
40
- for (const root of roots) {
41
- let rootPath;
42
- try {
43
- rootPath = uriToPath(root.uri);
44
- }
45
- catch (error) {
46
- console.error(`Failed to parse root URI "${root.uri}":`, error);
47
- continue;
48
- }
49
- const rootName = root.name || path.basename(rootPath);
50
- for (const subdir of SKILL_SUBDIRS) {
51
- const skillsDir = path.join(rootPath, subdir);
52
- if (fs.existsSync(skillsDir)) {
53
- try {
54
- const skills = discoverSkills(skillsDir);
55
- for (const skill of skills) {
56
- // Track which root this skill came from
57
- rootSources.set(skill.path, rootName);
58
- // Count occurrences of each name
59
- const count = (nameCount.get(skill.name) || 0) + 1;
60
- nameCount.set(skill.name, count);
61
- allSkills.push(skill);
62
- }
63
- }
64
- catch (error) {
65
- console.error(`Failed to discover skills in "${skillsDir}":`, error);
66
- }
67
- }
68
- }
69
- }
70
- // Handle naming conflicts by prefixing duplicates with root name
71
- for (const skill of allSkills) {
72
- if (nameCount.get(skill.name) > 1) {
73
- const rootName = rootSources.get(skill.path);
74
- skill.name = `${rootName}:${skill.name}`;
75
- }
76
- }
77
- return { skills: allSkills, rootSources };
78
- }
79
- /**
80
- * Discover skills from a directory, checking both the directory itself
81
- * and SKILL_SUBDIRS subdirectories.
82
- */
83
- function discoverSkillsFromDirectory(skillsDir) {
84
- const allSkills = [];
85
- // Check if the directory itself contains skills
86
- const directSkills = discoverSkills(skillsDir);
87
- allSkills.push(...directSkills);
88
- // Also check SKILL_SUBDIRS subdirectories
89
- for (const subdir of SKILL_SUBDIRS) {
90
- const subPath = path.join(skillsDir, subdir);
91
- if (fs.existsSync(subPath)) {
92
- const subdirSkills = discoverSkills(subPath);
93
- allSkills.push(...subdirSkills);
94
- }
95
- }
96
- return allSkills;
97
- }
98
- /**
99
- * Sync skills from roots or configured skills directory.
100
- *
101
- * Pattern from mcp-server-ts snippets/server/index.ts:
102
- * - Check client capabilities
103
- * - Request roots if supported
104
- * - Use skills directory if not
105
- *
106
- * @param server - The McpServer instance
107
- * @param skillsDir - Optional skills directory if client doesn't support roots
108
- * @param onSkillsChanged - Callback when skills are updated
109
- */
110
- export async function syncSkills(server, skillsDir, onSkillsChanged) {
111
- const capabilities = server.server.getClientCapabilities();
112
- const allSkills = [];
113
- const seenNames = new Set();
114
- // Always discover from configured skills directory first
115
- if (skillsDir) {
116
- const dirSkills = discoverSkillsFromDirectory(skillsDir);
117
- console.error(`Discovered ${dirSkills.length} skill(s) from skills directory`);
118
- for (const skill of dirSkills) {
119
- if (!seenNames.has(skill.name)) {
120
- seenNames.add(skill.name);
121
- allSkills.push(skill);
122
- }
123
- }
124
- }
125
- // Also discover from roots if client supports them
126
- if (capabilities?.roots) {
127
- console.error("Client supports roots, requesting workspace roots...");
128
- try {
129
- const { roots } = await server.server.listRoots();
130
- console.error(`Received ${roots.length} root(s) from client`);
131
- const { skills: rootSkills } = discoverSkillsFromRoots(roots);
132
- console.error(`Discovered ${rootSkills.length} skill(s) from roots`);
133
- // Add roots skills, skipping duplicates (skillsDir takes precedence)
134
- for (const skill of rootSkills) {
135
- if (!seenNames.has(skill.name)) {
136
- seenNames.add(skill.name);
137
- allSkills.push(skill);
138
- }
139
- }
140
- // Listen for roots changes if client supports listChanged
141
- if (capabilities.roots.listChanged) {
142
- setupRootsChangeHandler(server, skillsDir, onSkillsChanged);
143
- }
144
- }
145
- catch (error) {
146
- console.error("Failed to get roots from client:", error);
147
- }
148
- }
149
- else {
150
- console.error("Client does not support roots");
151
- }
152
- console.error(`Total skills available: ${allSkills.length}`);
153
- const skillMap = createSkillMap(allSkills);
154
- const instructions = generateInstructions(allSkills);
155
- onSkillsChanged(skillMap, instructions);
156
- }
157
- /**
158
- * Set up handler for roots/list_changed notifications.
159
- */
160
- function setupRootsChangeHandler(server, skillsDir, onSkillsChanged) {
161
- server.server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
162
- console.error("Roots changed notification received, re-discovering skills...");
163
- try {
164
- const allSkills = [];
165
- const seenNames = new Set();
166
- // Always include skills from configured directory first
167
- if (skillsDir) {
168
- const dirSkills = discoverSkillsFromDirectory(skillsDir);
169
- for (const skill of dirSkills) {
170
- if (!seenNames.has(skill.name)) {
171
- seenNames.add(skill.name);
172
- allSkills.push(skill);
173
- }
174
- }
175
- }
176
- // Add skills from roots
177
- const { roots } = await server.server.listRoots();
178
- const { skills: rootSkills } = discoverSkillsFromRoots(roots);
179
- console.error(`Re-discovered ${rootSkills.length} skill(s) from updated roots`);
180
- for (const skill of rootSkills) {
181
- if (!seenNames.has(skill.name)) {
182
- seenNames.add(skill.name);
183
- allSkills.push(skill);
184
- }
185
- }
186
- console.error(`Total skills available: ${allSkills.length}`);
187
- const skillMap = createSkillMap(allSkills);
188
- const instructions = generateInstructions(allSkills);
189
- onSkillsChanged(skillMap, instructions);
190
- // Notify client that resources have changed
191
- await server.server.notification({
192
- method: "notifications/resources/list_changed",
193
- });
194
- }
195
- catch (error) {
196
- console.error("Failed to re-discover skills after roots change:", error);
197
- }
198
- });
199
- }