@olaservo/skill-jack-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Skill discovery and metadata parsing module.
3
+ *
4
+ * Discovers Agent Skills from a directory, parses YAML frontmatter,
5
+ * and generates server instructions XML.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { parse as parseYaml } from "yaml";
10
+ /**
11
+ * Find the SKILL.md file in a skill directory.
12
+ * Prefers SKILL.md (uppercase) but accepts skill.md (lowercase).
13
+ */
14
+ function findSkillMd(skillDir) {
15
+ for (const name of ["SKILL.md", "skill.md"]) {
16
+ const filePath = path.join(skillDir, name);
17
+ if (fs.existsSync(filePath)) {
18
+ return filePath;
19
+ }
20
+ }
21
+ return null;
22
+ }
23
+ /**
24
+ * Parse YAML frontmatter from SKILL.md content.
25
+ * Returns the parsed metadata and the markdown body.
26
+ */
27
+ function parseFrontmatter(content) {
28
+ if (!content.startsWith("---")) {
29
+ throw new Error("SKILL.md must start with YAML frontmatter (---)");
30
+ }
31
+ const parts = content.split("---");
32
+ if (parts.length < 3) {
33
+ throw new Error("SKILL.md frontmatter not properly closed with ---");
34
+ }
35
+ const frontmatterStr = parts[1];
36
+ const body = parts.slice(2).join("---").trim();
37
+ const metadata = parseYaml(frontmatterStr);
38
+ if (typeof metadata !== "object" || metadata === null) {
39
+ throw new Error("SKILL.md frontmatter must be a YAML mapping");
40
+ }
41
+ return { metadata, body };
42
+ }
43
+ /**
44
+ * Discover all skills in a directory.
45
+ * Scans for subdirectories containing SKILL.md files.
46
+ */
47
+ export function discoverSkills(skillsDir) {
48
+ const skills = [];
49
+ if (!fs.existsSync(skillsDir)) {
50
+ console.error(`Skills directory not found: ${skillsDir}`);
51
+ return skills;
52
+ }
53
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
54
+ for (const entry of entries) {
55
+ if (!entry.isDirectory())
56
+ continue;
57
+ const skillDir = path.join(skillsDir, entry.name);
58
+ const skillMdPath = findSkillMd(skillDir);
59
+ if (!skillMdPath)
60
+ continue;
61
+ try {
62
+ const content = fs.readFileSync(skillMdPath, "utf-8");
63
+ const { metadata } = parseFrontmatter(content);
64
+ const name = metadata.name;
65
+ const description = metadata.description;
66
+ if (typeof name !== "string" || !name.trim()) {
67
+ console.error(`Skill at ${skillDir}: missing or invalid 'name' field`);
68
+ continue;
69
+ }
70
+ if (typeof description !== "string" || !description.trim()) {
71
+ console.error(`Skill at ${skillDir}: missing or invalid 'description' field`);
72
+ continue;
73
+ }
74
+ skills.push({
75
+ name: name.trim(),
76
+ description: description.trim(),
77
+ path: skillMdPath,
78
+ });
79
+ }
80
+ catch (error) {
81
+ console.error(`Failed to parse skill at ${skillDir}:`, error);
82
+ }
83
+ }
84
+ return skills;
85
+ }
86
+ /**
87
+ * Escape special XML characters.
88
+ */
89
+ function escapeXml(str) {
90
+ return str
91
+ .replace(/&/g, "&amp;")
92
+ .replace(/</g, "&lt;")
93
+ .replace(/>/g, "&gt;")
94
+ .replace(/"/g, "&quot;")
95
+ .replace(/'/g, "&apos;");
96
+ }
97
+ /**
98
+ * Generate the server instructions with available skills.
99
+ * Includes a brief preamble about skill usage following the Agent Skills spec.
100
+ */
101
+ export function generateInstructions(skills) {
102
+ const preamble = `# Skills
103
+
104
+ When a user's task matches a skill description below: 1) activate it, 2) follow its instructions completely.
105
+
106
+ `;
107
+ if (skills.length === 0) {
108
+ return preamble + "<available_skills>\n</available_skills>";
109
+ }
110
+ const lines = ["<available_skills>"];
111
+ for (const skill of skills) {
112
+ lines.push("<skill>");
113
+ lines.push(`<name>${escapeXml(skill.name)}</name>`);
114
+ lines.push(`<description>${escapeXml(skill.description)}</description>`);
115
+ lines.push(`<location>${escapeXml(skill.path)}</location>`);
116
+ lines.push("</skill>");
117
+ }
118
+ lines.push("</available_skills>");
119
+ return preamble + lines.join("\n");
120
+ }
121
+ /**
122
+ * Load the full content of a skill's SKILL.md file.
123
+ */
124
+ export function loadSkillContent(skillPath) {
125
+ return fs.readFileSync(skillPath, "utf-8");
126
+ }
127
+ /**
128
+ * Create a map from skill name to skill metadata for fast lookup.
129
+ */
130
+ export function createSkillMap(skills) {
131
+ const map = new Map();
132
+ for (const skill of skills) {
133
+ map.set(skill.name, skill);
134
+ }
135
+ return map;
136
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * MCP Resource registration for skill-based resources.
3
+ *
4
+ * Resources provide application-controlled access to skill content,
5
+ * complementing the model-controlled tool access.
6
+ *
7
+ * All resources use templates with dynamic list callbacks to support
8
+ * skill updates when MCP roots change.
9
+ *
10
+ * URI Scheme:
11
+ * skill:// -> Collection: all SKILL.md contents
12
+ * skill://{skillName} -> SKILL.md content (template)
13
+ * skill://{skillName}/ -> Collection: all files in skill
14
+ * skill://{skillName}/{path} -> File within skill directory (template)
15
+ */
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import { SkillState } from "./skill-tool.js";
18
+ /**
19
+ * Register skill resources with the MCP server.
20
+ *
21
+ * All resources use templates with dynamic list callbacks to support
22
+ * skill updates when MCP roots change.
23
+ *
24
+ * @param server - The McpServer instance
25
+ * @param skillState - Shared state object (allows dynamic updates)
26
+ */
27
+ export declare function registerSkillResources(server: McpServer, skillState: SkillState): void;
@@ -0,0 +1,239 @@
1
+ /**
2
+ * MCP Resource registration for skill-based resources.
3
+ *
4
+ * Resources provide application-controlled access to skill content,
5
+ * complementing the model-controlled tool access.
6
+ *
7
+ * All resources use templates with dynamic list callbacks to support
8
+ * skill updates when MCP roots change.
9
+ *
10
+ * URI Scheme:
11
+ * skill:// -> Collection: all SKILL.md contents
12
+ * skill://{skillName} -> SKILL.md content (template)
13
+ * skill://{skillName}/ -> Collection: all files in skill
14
+ * skill://{skillName}/{path} -> File within skill directory (template)
15
+ */
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
19
+ import { loadSkillContent } from "./skill-discovery.js";
20
+ import { isPathWithinBase, listSkillFiles, MAX_FILE_SIZE } from "./skill-tool.js";
21
+ /**
22
+ * Get MIME type based on file extension.
23
+ */
24
+ function getMimeType(filePath) {
25
+ const ext = path.extname(filePath).toLowerCase();
26
+ const mimeTypes = {
27
+ ".md": "text/markdown",
28
+ ".ts": "text/typescript",
29
+ ".js": "text/javascript",
30
+ ".json": "application/json",
31
+ ".yaml": "text/yaml",
32
+ ".yml": "text/yaml",
33
+ ".txt": "text/plain",
34
+ ".sh": "text/x-shellscript",
35
+ ".py": "text/x-python",
36
+ ".css": "text/css",
37
+ ".html": "text/html",
38
+ ".xml": "application/xml",
39
+ };
40
+ return mimeTypes[ext] || "text/plain";
41
+ }
42
+ /**
43
+ * Register skill resources with the MCP server.
44
+ *
45
+ * All resources use templates with dynamic list callbacks to support
46
+ * skill updates when MCP roots change.
47
+ *
48
+ * @param server - The McpServer instance
49
+ * @param skillState - Shared state object (allows dynamic updates)
50
+ */
51
+ export function registerSkillResources(server, skillState) {
52
+ // Register collection resource for all skills
53
+ registerAllSkillsCollection(server, skillState);
54
+ // Register template for individual skill SKILL.md files
55
+ registerSkillTemplate(server, skillState);
56
+ // Register resource template for skill files
57
+ registerSkillFileTemplate(server, skillState);
58
+ }
59
+ /**
60
+ * Register a collection resource that returns all skills at once.
61
+ *
62
+ * URI: skill://
63
+ *
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.
66
+ */
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 () => {
72
+ const contents = [];
73
+ for (const [name, skill] of skillState.skillMap) {
74
+ try {
75
+ const content = loadSkillContent(skill.path);
76
+ contents.push({
77
+ uri: `skill://${encodeURIComponent(name)}`,
78
+ mimeType: "text/markdown",
79
+ text: content,
80
+ });
81
+ }
82
+ catch (error) {
83
+ // Skip skills that fail to load, but continue with others
84
+ console.error(`Failed to load skill "${name}":`, error);
85
+ }
86
+ }
87
+ return { contents };
88
+ });
89
+ }
90
+ /**
91
+ * Register a template for individual skill SKILL.md resources.
92
+ *
93
+ * URI Pattern: skill://{skillName}
94
+ *
95
+ * Uses a template with a list callback to dynamically return available skills.
96
+ */
97
+ function registerSkillTemplate(server, skillState) {
98
+ server.registerResource("Skill", new ResourceTemplate("skill://{skillName}", {
99
+ list: async () => {
100
+ // Dynamically return current skills
101
+ const resources = [];
102
+ for (const [name, skill] of skillState.skillMap) {
103
+ resources.push({
104
+ uri: `skill://${encodeURIComponent(name)}`,
105
+ name,
106
+ mimeType: "text/markdown",
107
+ description: skill.description,
108
+ });
109
+ }
110
+ return { resources };
111
+ },
112
+ complete: {
113
+ skillName: (value) => {
114
+ const names = Array.from(skillState.skillMap.keys());
115
+ return names.filter((name) => name.toLowerCase().startsWith(value.toLowerCase()));
116
+ },
117
+ },
118
+ }), {
119
+ mimeType: "text/markdown",
120
+ description: "SKILL.md content for a skill",
121
+ }, async (resourceUri) => {
122
+ // Extract skill name from URI
123
+ const uriStr = resourceUri.toString();
124
+ const match = uriStr.match(/^skill:\/\/([^/]+)$/);
125
+ if (!match) {
126
+ throw new Error(`Invalid skill URI: ${uriStr}`);
127
+ }
128
+ const skillName = decodeURIComponent(match[1]);
129
+ const skill = skillState.skillMap.get(skillName);
130
+ if (!skill) {
131
+ const available = Array.from(skillState.skillMap.keys()).join(", ");
132
+ throw new Error(`Skill "${skillName}" not found. Available: ${available || "none"}`);
133
+ }
134
+ try {
135
+ const content = loadSkillContent(skill.path);
136
+ return {
137
+ contents: [
138
+ {
139
+ uri: uriStr,
140
+ mimeType: "text/markdown",
141
+ text: content,
142
+ },
143
+ ],
144
+ };
145
+ }
146
+ catch (error) {
147
+ const message = error instanceof Error ? error.message : String(error);
148
+ throw new Error(`Failed to load skill "${skillName}": ${message}`);
149
+ }
150
+ });
151
+ }
152
+ /**
153
+ * Register the resource template for accessing files within skills.
154
+ *
155
+ * URI Pattern: skill://{skillName}/{filePath}
156
+ */
157
+ function registerSkillFileTemplate(server, skillState) {
158
+ server.registerResource("Skill File", new ResourceTemplate("skill://{skillName}/{+filePath}", {
159
+ list: async () => {
160
+ // Return all listable skill files (dynamic based on current skillMap)
161
+ const resources = [];
162
+ for (const [name, skill] of skillState.skillMap) {
163
+ const skillDir = path.dirname(skill.path);
164
+ const files = listSkillFiles(skillDir);
165
+ for (const file of files) {
166
+ const uri = `skill://${encodeURIComponent(name)}/${file}`;
167
+ resources.push({
168
+ uri,
169
+ name: `${name}/${file}`,
170
+ mimeType: getMimeType(file),
171
+ });
172
+ }
173
+ }
174
+ return { resources };
175
+ },
176
+ complete: {
177
+ skillName: (value) => {
178
+ const names = Array.from(skillState.skillMap.keys());
179
+ return names.filter((name) => name.toLowerCase().startsWith(value.toLowerCase()));
180
+ },
181
+ },
182
+ }), {
183
+ mimeType: "text/plain",
184
+ description: "Files within a skill directory (scripts, snippets, assets, etc.)",
185
+ }, async (resourceUri, variables) => {
186
+ // Extract skill name and file path from URI
187
+ const uriStr = resourceUri.toString();
188
+ const match = uriStr.match(/^skill:\/\/([^/]+)\/(.+)$/);
189
+ if (!match) {
190
+ throw new Error(`Invalid skill file URI: ${uriStr}`);
191
+ }
192
+ const skillName = decodeURIComponent(match[1]);
193
+ const filePath = match[2];
194
+ const skill = skillState.skillMap.get(skillName);
195
+ if (!skill) {
196
+ const available = Array.from(skillState.skillMap.keys()).join(", ");
197
+ throw new Error(`Skill "${skillName}" not found. Available: ${available || "none"}`);
198
+ }
199
+ const skillDir = path.dirname(skill.path);
200
+ const fullPath = path.resolve(skillDir, filePath);
201
+ // Security: Validate path is within skill directory
202
+ if (!isPathWithinBase(fullPath, skillDir)) {
203
+ throw new Error(`Path "${filePath}" is outside the skill directory`);
204
+ }
205
+ // Check file exists
206
+ if (!fs.existsSync(fullPath)) {
207
+ const files = listSkillFiles(skillDir).slice(0, 10);
208
+ throw new Error(`File "${filePath}" not found in skill "${skillName}". ` +
209
+ `Available: ${files.join(", ")}${files.length >= 10 ? "..." : ""}`);
210
+ }
211
+ const stat = fs.statSync(fullPath);
212
+ // Reject symlinks
213
+ if (stat.isSymbolicLink()) {
214
+ throw new Error(`Cannot read symlink "${filePath}"`);
215
+ }
216
+ // Reject directories
217
+ if (stat.isDirectory()) {
218
+ const files = listSkillFiles(skillDir, filePath);
219
+ throw new Error(`"${filePath}" is a directory. Files within: ${files.join(", ")}`);
220
+ }
221
+ // Check file size
222
+ if (stat.size > MAX_FILE_SIZE) {
223
+ const sizeMB = (stat.size / 1024 / 1024).toFixed(2);
224
+ throw new Error(`File too large (${sizeMB}MB). Maximum: 10MB`);
225
+ }
226
+ // Read and return content
227
+ const content = fs.readFileSync(fullPath, "utf-8");
228
+ const mimeType = getMimeType(fullPath);
229
+ return {
230
+ contents: [
231
+ {
232
+ uri: uriStr,
233
+ mimeType,
234
+ text: content,
235
+ },
236
+ ],
237
+ };
238
+ });
239
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * MCP tool registration for skill-related tools.
3
+ *
4
+ * - skill: Load and activate a skill by name (returns SKILL.md content)
5
+ * - skill-resource: Read files within a skill directory (scripts/, references/, assets/, etc.)
6
+ *
7
+ * Tools reference a shared SkillState object to support dynamic skill updates
8
+ * when MCP roots change.
9
+ */
10
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { SkillMetadata } from "./skill-discovery.js";
12
+ /**
13
+ * Shared state for dynamic skill management.
14
+ * Tools reference this state object, allowing updates when roots change.
15
+ */
16
+ export interface SkillState {
17
+ skillMap: Map<string, SkillMetadata>;
18
+ instructions: string;
19
+ }
20
+ /**
21
+ * Register the "skill" tool with the MCP server.
22
+ *
23
+ * @param server - The McpServer instance
24
+ * @param skillState - Shared state object (allows dynamic updates)
25
+ */
26
+ export declare function registerSkillTool(server: McpServer, skillState: SkillState): void;
27
+ export declare const MAX_FILE_SIZE: number;
28
+ export declare const MAX_DIRECTORY_DEPTH = 10;
29
+ /**
30
+ * Check if a path is within the allowed base directory.
31
+ * Uses fs.realpathSync to resolve symlinks and prevent symlink escape attacks.
32
+ */
33
+ export declare function isPathWithinBase(targetPath: string, baseDir: string): boolean;
34
+ /**
35
+ * List files in a skill directory for discovery.
36
+ * Limits recursion depth to prevent DoS from deeply nested directories.
37
+ */
38
+ export declare function listSkillFiles(skillDir: string, subPath?: string, depth?: number): string[];