@skills-hub-ai/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.
- package/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-test.log +15 -0
- package/dist/discover.d.ts +6 -0
- package/dist/discover.d.ts.map +1 -0
- package/dist/discover.js +61 -0
- package/dist/discover.js.map +1 -0
- package/dist/discover.test.d.ts +2 -0
- package/dist/discover.test.d.ts.map +1 -0
- package/dist/discover.test.js +129 -0
- package/dist/discover.test.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +54 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +138 -0
- package/dist/index.test.js.map +1 -0
- package/package.json +33 -0
- package/src/discover.test.ts +156 -0
- package/src/discover.ts +67 -0
- package/src/index.test.ts +173 -0
- package/src/index.ts +74 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
|
|
2
|
+
> @skills-hub-ai/mcp@0.1.0 test /Users/thole/personal/skills-hub/apps/mcp
|
|
3
|
+
> vitest run --passWithNoTests
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
RUN v2.1.9 /Users/thole/personal/skills-hub/apps/mcp
|
|
7
|
+
|
|
8
|
+
✓ src/index.test.ts (6 tests) 582ms
|
|
9
|
+
✓ src/discover.test.ts (8 tests) 8ms
|
|
10
|
+
|
|
11
|
+
Test Files 2 passed (2)
|
|
12
|
+
Tests 14 passed (14)
|
|
13
|
+
Start at 17:33:09
|
|
14
|
+
Duration 3.22s (transform 1.41s, setup 0ms, collect 1.91s, tests 590ms, environment 0ms, prepare 611ms)
|
|
15
|
+
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type ParsedSkill } from "@skills-hub/skill-parser";
|
|
2
|
+
/** Discover all installed skills, cached for 30s */
|
|
3
|
+
export declare function discoverSkills(): Map<string, ParsedSkill>;
|
|
4
|
+
/** Clear the discovery cache (for testing) */
|
|
5
|
+
export declare function clearCache(): void;
|
|
6
|
+
//# sourceMappingURL=discover.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../src/discover.ts"],"names":[],"mappings":"AAGA,OAAO,EAAgB,KAAK,WAAW,EAAE,MAAM,0BAA0B,CAAC;AA0C1E,oDAAoD;AACpD,wBAAgB,cAAc,IAAI,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAczD;AAED,8CAA8C;AAC9C,wBAAgB,UAAU,IAAI,IAAI,CAGjC"}
|
package/dist/discover.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { parseSkillMd } from "@skills-hub/skill-parser";
|
|
5
|
+
const CACHE_TTL_MS = 30_000;
|
|
6
|
+
let cache = null;
|
|
7
|
+
let cacheTime = 0;
|
|
8
|
+
/** Directories where skills may be installed */
|
|
9
|
+
function getSkillPaths() {
|
|
10
|
+
const home = homedir();
|
|
11
|
+
return [
|
|
12
|
+
join(home, ".claude", "skills"),
|
|
13
|
+
join(home, ".cursor", "skills"),
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
/** Scan a single skills directory and collect parsed skills */
|
|
17
|
+
function scanDir(dir, out) {
|
|
18
|
+
if (!existsSync(dir))
|
|
19
|
+
return;
|
|
20
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
if (!entry.isDirectory())
|
|
23
|
+
continue;
|
|
24
|
+
const slug = entry.name;
|
|
25
|
+
if (out.has(slug))
|
|
26
|
+
continue; // first-found wins (claude > cursor)
|
|
27
|
+
const skillFile = join(dir, slug, "SKILL.md");
|
|
28
|
+
if (!existsSync(skillFile))
|
|
29
|
+
continue;
|
|
30
|
+
try {
|
|
31
|
+
const content = readFileSync(skillFile, "utf-8");
|
|
32
|
+
const result = parseSkillMd(content);
|
|
33
|
+
if (result.success && result.skill) {
|
|
34
|
+
out.set(slug, result.skill);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Skip malformed files
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Discover all installed skills, cached for 30s */
|
|
43
|
+
export function discoverSkills() {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
if (cache && now - cacheTime < CACHE_TTL_MS) {
|
|
46
|
+
return cache;
|
|
47
|
+
}
|
|
48
|
+
const skills = new Map();
|
|
49
|
+
for (const dir of getSkillPaths()) {
|
|
50
|
+
scanDir(dir, skills);
|
|
51
|
+
}
|
|
52
|
+
cache = skills;
|
|
53
|
+
cacheTime = now;
|
|
54
|
+
return skills;
|
|
55
|
+
}
|
|
56
|
+
/** Clear the discovery cache (for testing) */
|
|
57
|
+
export function clearCache() {
|
|
58
|
+
cache = null;
|
|
59
|
+
cacheTime = 0;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=discover.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discover.js","sourceRoot":"","sources":["../src/discover.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAChE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,YAAY,EAAoB,MAAM,0BAA0B,CAAC;AAE1E,MAAM,YAAY,GAAG,MAAM,CAAC;AAE5B,IAAI,KAAK,GAAoC,IAAI,CAAC;AAClD,IAAI,SAAS,GAAG,CAAC,CAAC;AAElB,gDAAgD;AAChD,SAAS,aAAa;IACpB,MAAM,IAAI,GAAG,OAAO,EAAE,CAAC;IACvB,OAAO;QACL,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,CAAC;QAC/B,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,CAAC;KAChC,CAAC;AACJ,CAAC;AAED,+DAA+D;AAC/D,SAAS,OAAO,CAAC,GAAW,EAAE,GAA6B;IACzD,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO;IAE7B,MAAM,OAAO,GAAG,WAAW,CAAC,GAAG,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAC;IAC1D,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;QAC5B,IAAI,CAAC,KAAK,CAAC,WAAW,EAAE;YAAE,SAAS;QAEnC,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC;QACxB,IAAI,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC;YAAE,SAAS,CAAC,qCAAqC;QAElE,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,IAAI,EAAE,UAAU,CAAC,CAAC;QAC9C,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC;YAAE,SAAS;QAErC,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,YAAY,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;YACjD,MAAM,MAAM,GAAG,YAAY,CAAC,OAAO,CAAC,CAAC;YACrC,IAAI,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;gBACnC,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;YAC9B,CAAC;QACH,CAAC;QAAC,MAAM,CAAC;YACP,uBAAuB;QACzB,CAAC;IACH,CAAC;AACH,CAAC;AAED,oDAAoD;AACpD,MAAM,UAAU,cAAc;IAC5B,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACvB,IAAI,KAAK,IAAI,GAAG,GAAG,SAAS,GAAG,YAAY,EAAE,CAAC;QAC5C,OAAO,KAAK,CAAC;IACf,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,GAAG,EAAuB,CAAC;IAC9C,KAAK,MAAM,GAAG,IAAI,aAAa,EAAE,EAAE,CAAC;QAClC,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IACvB,CAAC;IAED,KAAK,GAAG,MAAM,CAAC;IACf,SAAS,GAAG,GAAG,CAAC;IAChB,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,8CAA8C;AAC9C,MAAM,UAAU,UAAU;IACxB,KAAK,GAAG,IAAI,CAAC;IACb,SAAS,GAAG,CAAC,CAAC;AAChB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discover.test.d.ts","sourceRoot":"","sources":["../src/discover.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
vi.mock("node:fs", () => ({
|
|
4
|
+
existsSync: vi.fn(() => false),
|
|
5
|
+
readdirSync: vi.fn(() => []),
|
|
6
|
+
readFileSync: vi.fn(() => ""),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock("node:os", () => ({
|
|
9
|
+
homedir: vi.fn(() => "/home/user"),
|
|
10
|
+
}));
|
|
11
|
+
import { discoverSkills, clearCache } from "./discover.js";
|
|
12
|
+
const mockExistsSync = vi.mocked(existsSync);
|
|
13
|
+
const mockReaddirSync = vi.mocked(readdirSync);
|
|
14
|
+
const mockReadFileSync = vi.mocked(readFileSync);
|
|
15
|
+
const validSkillMd = `---
|
|
16
|
+
name: Code Review
|
|
17
|
+
description: Automated code review for quality and best practices
|
|
18
|
+
version: 1.0.0
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
Review the code carefully and suggest improvements.
|
|
22
|
+
`;
|
|
23
|
+
const anotherSkillMd = `---
|
|
24
|
+
name: Security Scan
|
|
25
|
+
description: Scan code for security vulnerabilities and common exploits
|
|
26
|
+
version: 2.0.0
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
Check for OWASP top 10 vulnerabilities.
|
|
30
|
+
`;
|
|
31
|
+
function makeDirent(name) {
|
|
32
|
+
return { name, isDirectory: () => true, isFile: () => false };
|
|
33
|
+
}
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
vi.clearAllMocks();
|
|
36
|
+
clearCache();
|
|
37
|
+
});
|
|
38
|
+
describe("discoverSkills", () => {
|
|
39
|
+
it("discovers skills from claude skills directory", () => {
|
|
40
|
+
mockExistsSync.mockImplementation((p) => {
|
|
41
|
+
const s = String(p);
|
|
42
|
+
return s === "/home/user/.claude/skills" || s.endsWith("SKILL.md");
|
|
43
|
+
});
|
|
44
|
+
mockReaddirSync.mockReturnValue([makeDirent("code-review")]);
|
|
45
|
+
mockReadFileSync.mockReturnValue(validSkillMd);
|
|
46
|
+
const skills = discoverSkills();
|
|
47
|
+
expect(skills.size).toBe(1);
|
|
48
|
+
expect(skills.has("code-review")).toBe(true);
|
|
49
|
+
expect(skills.get("code-review").name).toBe("Code Review");
|
|
50
|
+
expect(skills.get("code-review").instructions).toContain("Review the code carefully");
|
|
51
|
+
});
|
|
52
|
+
it("discovers skills from cursor skills directory", () => {
|
|
53
|
+
mockExistsSync.mockImplementation((p) => {
|
|
54
|
+
const s = String(p);
|
|
55
|
+
return s === "/home/user/.cursor/skills" || s.endsWith("SKILL.md");
|
|
56
|
+
});
|
|
57
|
+
mockReaddirSync.mockReturnValue([makeDirent("security-scan")]);
|
|
58
|
+
mockReadFileSync.mockReturnValue(anotherSkillMd);
|
|
59
|
+
const skills = discoverSkills();
|
|
60
|
+
expect(skills.size).toBe(1);
|
|
61
|
+
expect(skills.has("security-scan")).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
it("scans both directories and deduplicates by slug", () => {
|
|
64
|
+
mockExistsSync.mockReturnValue(true);
|
|
65
|
+
mockReaddirSync.mockImplementation((dir) => {
|
|
66
|
+
const s = String(dir);
|
|
67
|
+
if (s.includes(".claude"))
|
|
68
|
+
return [makeDirent("code-review")];
|
|
69
|
+
if (s.includes(".cursor"))
|
|
70
|
+
return [makeDirent("code-review"), makeDirent("security-scan")];
|
|
71
|
+
return [];
|
|
72
|
+
});
|
|
73
|
+
mockReadFileSync.mockImplementation((p) => {
|
|
74
|
+
const s = String(p);
|
|
75
|
+
if (s.includes("code-review"))
|
|
76
|
+
return validSkillMd;
|
|
77
|
+
if (s.includes("security-scan"))
|
|
78
|
+
return anotherSkillMd;
|
|
79
|
+
return "";
|
|
80
|
+
});
|
|
81
|
+
const skills = discoverSkills();
|
|
82
|
+
// code-review from claude wins (first-found), security-scan from cursor
|
|
83
|
+
expect(skills.size).toBe(2);
|
|
84
|
+
expect(skills.has("code-review")).toBe(true);
|
|
85
|
+
expect(skills.has("security-scan")).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
it("handles missing skills directories", () => {
|
|
88
|
+
mockExistsSync.mockReturnValue(false);
|
|
89
|
+
const skills = discoverSkills();
|
|
90
|
+
expect(skills.size).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
it("skips directories without SKILL.md", () => {
|
|
93
|
+
mockExistsSync.mockImplementation((p) => {
|
|
94
|
+
const s = String(p);
|
|
95
|
+
// Directory exists but SKILL.md does not
|
|
96
|
+
return s === "/home/user/.claude/skills";
|
|
97
|
+
});
|
|
98
|
+
mockReaddirSync.mockReturnValue([makeDirent("orphan-dir")]);
|
|
99
|
+
const skills = discoverSkills();
|
|
100
|
+
expect(skills.size).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
it("skips malformed SKILL.md files", () => {
|
|
103
|
+
mockExistsSync.mockReturnValue(true);
|
|
104
|
+
mockReaddirSync.mockReturnValue([makeDirent("bad-skill")]);
|
|
105
|
+
mockReadFileSync.mockReturnValue("this is not valid frontmatter");
|
|
106
|
+
const skills = discoverSkills();
|
|
107
|
+
expect(skills.size).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
it("caches results for subsequent calls", () => {
|
|
110
|
+
mockExistsSync.mockReturnValue(true);
|
|
111
|
+
mockReaddirSync.mockReturnValue([makeDirent("code-review")]);
|
|
112
|
+
mockReadFileSync.mockReturnValue(validSkillMd);
|
|
113
|
+
discoverSkills();
|
|
114
|
+
discoverSkills();
|
|
115
|
+
// readdirSync called once per directory on first call, not on second
|
|
116
|
+
expect(mockReaddirSync).toHaveBeenCalledTimes(2); // once for claude, once for cursor
|
|
117
|
+
});
|
|
118
|
+
it("re-scans after cache is cleared", () => {
|
|
119
|
+
mockExistsSync.mockReturnValue(true);
|
|
120
|
+
mockReaddirSync.mockReturnValue([makeDirent("code-review")]);
|
|
121
|
+
mockReadFileSync.mockReturnValue(validSkillMd);
|
|
122
|
+
discoverSkills();
|
|
123
|
+
clearCache();
|
|
124
|
+
discoverSkills();
|
|
125
|
+
// 2 dirs × 2 scans = 4 calls
|
|
126
|
+
expect(mockReaddirSync).toHaveBeenCalledTimes(4);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
//# sourceMappingURL=discover.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discover.test.js","sourceRoot":"","sources":["../src/discover.test.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { ListPromptsRequestSchema, GetPromptRequestSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { discoverSkills } from "./discover.js";
|
|
6
|
+
const server = new Server({ name: "skills-hub", version: "0.1.0" }, { capabilities: { prompts: { listChanged: true } } });
|
|
7
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
8
|
+
const skills = discoverSkills();
|
|
9
|
+
return {
|
|
10
|
+
prompts: Array.from(skills.entries()).map(([slug, skill]) => ({
|
|
11
|
+
name: slug,
|
|
12
|
+
description: skill.description,
|
|
13
|
+
arguments: [
|
|
14
|
+
{
|
|
15
|
+
name: "input",
|
|
16
|
+
description: "Context or instructions for the skill",
|
|
17
|
+
required: false,
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
})),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
24
|
+
const { name, arguments: args } = request.params;
|
|
25
|
+
const skills = discoverSkills();
|
|
26
|
+
const skill = skills.get(name);
|
|
27
|
+
if (!skill) {
|
|
28
|
+
throw new McpError(ErrorCode.InvalidRequest, `Unknown skill: ${name}. Run "skills-hub install ${name}" to install it.`);
|
|
29
|
+
}
|
|
30
|
+
let text = skill.instructions;
|
|
31
|
+
if (args?.input) {
|
|
32
|
+
text += `\n\n${args.input}`;
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
description: skill.description,
|
|
36
|
+
messages: [
|
|
37
|
+
{
|
|
38
|
+
role: "user",
|
|
39
|
+
content: { type: "text", text },
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
async function main() {
|
|
45
|
+
const transport = new StdioServerTransport();
|
|
46
|
+
await server.connect(transport);
|
|
47
|
+
// stderr only — stdout is the MCP JSON-RPC stream
|
|
48
|
+
console.error("skills-hub MCP server running");
|
|
49
|
+
}
|
|
50
|
+
main().catch((err) => {
|
|
51
|
+
console.error("Fatal:", err);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
});
|
|
54
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAEA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AACnE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EACL,wBAAwB,EACxB,sBAAsB,EACtB,SAAS,EACT,QAAQ,GACT,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAE/C,MAAM,MAAM,GAAG,IAAI,MAAM,CACvB,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,EAAE,OAAO,EAAE,EACxC,EAAE,YAAY,EAAE,EAAE,OAAO,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,EAAE,EAAE,CACrD,CAAC;AAEF,MAAM,CAAC,iBAAiB,CAAC,wBAAwB,EAAE,KAAK,IAAI,EAAE;IAC5D,MAAM,MAAM,GAAG,cAAc,EAAE,CAAC;IAEhC,OAAO;QACL,OAAO,EAAE,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC;YAC5D,IAAI,EAAE,IAAI;YACV,WAAW,EAAE,KAAK,CAAC,WAAW;YAC9B,SAAS,EAAE;gBACT;oBACE,IAAI,EAAE,OAAO;oBACb,WAAW,EAAE,uCAAuC;oBACpD,QAAQ,EAAE,KAAK;iBAChB;aACF;SACF,CAAC,CAAC;KACJ,CAAC;AACJ,CAAC,CAAC,CAAC;AAEH,MAAM,CAAC,iBAAiB,CAAC,sBAAsB,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE;IACjE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC;IACjD,MAAM,MAAM,GAAG,cAAc,EAAE,CAAC;IAChC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAE/B,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,MAAM,IAAI,QAAQ,CAChB,SAAS,CAAC,cAAc,EACxB,kBAAkB,IAAI,6BAA6B,IAAI,kBAAkB,CAC1E,CAAC;IACJ,CAAC;IAED,IAAI,IAAI,GAAG,KAAK,CAAC,YAAY,CAAC;IAC9B,IAAI,IAAI,EAAE,KAAK,EAAE,CAAC;QAChB,IAAI,IAAI,OAAO,IAAI,CAAC,KAAK,EAAE,CAAC;IAC9B,CAAC;IAED,OAAO;QACL,WAAW,EAAE,KAAK,CAAC,WAAW;QAC9B,QAAQ,EAAE;YACR;gBACE,IAAI,EAAE,MAAe;gBACrB,OAAO,EAAE,EAAE,IAAI,EAAE,MAAe,EAAE,IAAI,EAAE;aACzC;SACF;KACF,CAAC;AACJ,CAAC,CAAC,CAAC;AAEH,KAAK,UAAU,IAAI;IACjB,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;IAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IAChC,kDAAkD;IAClD,OAAO,CAAC,KAAK,CAAC,+BAA+B,CAAC,CAAC;AACjD,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACnB,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;IAC7B,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
vi.mock("./discover.js", () => ({
|
|
3
|
+
discoverSkills: vi.fn(),
|
|
4
|
+
}));
|
|
5
|
+
// Mock the MCP SDK — we test the handler logic, not the transport
|
|
6
|
+
vi.mock("@modelcontextprotocol/sdk/server/index.js", () => {
|
|
7
|
+
const handlers = new Map();
|
|
8
|
+
return {
|
|
9
|
+
Server: vi.fn().mockImplementation(() => ({
|
|
10
|
+
setRequestHandler: vi.fn((schema, handler) => {
|
|
11
|
+
handlers.set(schema.method, handler);
|
|
12
|
+
}),
|
|
13
|
+
connect: vi.fn(),
|
|
14
|
+
_handlers: handlers,
|
|
15
|
+
})),
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({
|
|
19
|
+
StdioServerTransport: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("@modelcontextprotocol/sdk/types.js", () => ({
|
|
22
|
+
ListPromptsRequestSchema: { method: "prompts/list" },
|
|
23
|
+
GetPromptRequestSchema: { method: "prompts/get" },
|
|
24
|
+
ErrorCode: { InvalidRequest: -32600 },
|
|
25
|
+
McpError: class McpError extends Error {
|
|
26
|
+
code;
|
|
27
|
+
constructor(code, message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.code = code;
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
import { discoverSkills } from "./discover.js";
|
|
34
|
+
const mockDiscoverSkills = vi.mocked(discoverSkills);
|
|
35
|
+
function makeSkill(name, description, instructions) {
|
|
36
|
+
return {
|
|
37
|
+
name,
|
|
38
|
+
description,
|
|
39
|
+
version: "1.0.0",
|
|
40
|
+
category: undefined,
|
|
41
|
+
platforms: [],
|
|
42
|
+
instructions,
|
|
43
|
+
raw: "",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
let handlers;
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
vi.clearAllMocks();
|
|
49
|
+
// Re-import to trigger module-level server creation
|
|
50
|
+
vi.resetModules();
|
|
51
|
+
// Re-mock after resetModules
|
|
52
|
+
vi.doMock("./discover.js", () => ({
|
|
53
|
+
discoverSkills: mockDiscoverSkills,
|
|
54
|
+
}));
|
|
55
|
+
vi.doMock("@modelcontextprotocol/sdk/server/index.js", () => {
|
|
56
|
+
const h = new Map();
|
|
57
|
+
handlers = h;
|
|
58
|
+
return {
|
|
59
|
+
Server: vi.fn().mockImplementation(() => ({
|
|
60
|
+
setRequestHandler: vi.fn((schema, handler) => {
|
|
61
|
+
h.set(schema.method, handler);
|
|
62
|
+
}),
|
|
63
|
+
connect: vi.fn(),
|
|
64
|
+
})),
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
vi.doMock("@modelcontextprotocol/sdk/server/stdio.js", () => ({
|
|
68
|
+
StdioServerTransport: vi.fn(),
|
|
69
|
+
}));
|
|
70
|
+
vi.doMock("@modelcontextprotocol/sdk/types.js", () => ({
|
|
71
|
+
ListPromptsRequestSchema: { method: "prompts/list" },
|
|
72
|
+
GetPromptRequestSchema: { method: "prompts/get" },
|
|
73
|
+
ErrorCode: { InvalidRequest: -32600 },
|
|
74
|
+
McpError: class McpError extends Error {
|
|
75
|
+
code;
|
|
76
|
+
constructor(code, message) {
|
|
77
|
+
super(message);
|
|
78
|
+
this.code = code;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
}));
|
|
82
|
+
// Suppress console.error from main()
|
|
83
|
+
vi.spyOn(console, "error").mockImplementation(() => { });
|
|
84
|
+
await import("./index.js");
|
|
85
|
+
});
|
|
86
|
+
describe("MCP server", () => {
|
|
87
|
+
it("registers prompts/list and prompts/get handlers", () => {
|
|
88
|
+
expect(handlers.has("prompts/list")).toBe(true);
|
|
89
|
+
expect(handlers.has("prompts/get")).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
it("listPrompts returns discovered skills", async () => {
|
|
92
|
+
const skills = new Map();
|
|
93
|
+
skills.set("code-review", makeSkill("Code Review", "Review code quality", "Review carefully"));
|
|
94
|
+
skills.set("security-scan", makeSkill("Security Scan", "Find vulnerabilities", "Check OWASP"));
|
|
95
|
+
mockDiscoverSkills.mockReturnValue(skills);
|
|
96
|
+
const handler = handlers.get("prompts/list");
|
|
97
|
+
const result = (await handler({}));
|
|
98
|
+
expect(result.prompts).toHaveLength(2);
|
|
99
|
+
expect(result.prompts[0].name).toBe("code-review");
|
|
100
|
+
expect(result.prompts[0].description).toBe("Review code quality");
|
|
101
|
+
expect(result.prompts[1].name).toBe("security-scan");
|
|
102
|
+
});
|
|
103
|
+
it("listPrompts returns empty array when no skills installed", async () => {
|
|
104
|
+
mockDiscoverSkills.mockReturnValue(new Map());
|
|
105
|
+
const handler = handlers.get("prompts/list");
|
|
106
|
+
const result = (await handler({}));
|
|
107
|
+
expect(result.prompts).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
it("getPrompt returns skill instructions", async () => {
|
|
110
|
+
const skills = new Map();
|
|
111
|
+
skills.set("code-review", makeSkill("Code Review", "Review code quality", "Review carefully"));
|
|
112
|
+
mockDiscoverSkills.mockReturnValue(skills);
|
|
113
|
+
const handler = handlers.get("prompts/get");
|
|
114
|
+
const result = (await handler({
|
|
115
|
+
params: { name: "code-review", arguments: {} },
|
|
116
|
+
}));
|
|
117
|
+
expect(result.description).toBe("Review code quality");
|
|
118
|
+
expect(result.messages).toHaveLength(1);
|
|
119
|
+
expect(result.messages[0].role).toBe("user");
|
|
120
|
+
expect(result.messages[0].content.text).toBe("Review carefully");
|
|
121
|
+
});
|
|
122
|
+
it("getPrompt appends input argument to instructions", async () => {
|
|
123
|
+
const skills = new Map();
|
|
124
|
+
skills.set("code-review", makeSkill("Code Review", "Review code quality", "Review carefully"));
|
|
125
|
+
mockDiscoverSkills.mockReturnValue(skills);
|
|
126
|
+
const handler = handlers.get("prompts/get");
|
|
127
|
+
const result = (await handler({
|
|
128
|
+
params: { name: "code-review", arguments: { input: "Focus on error handling" } },
|
|
129
|
+
}));
|
|
130
|
+
expect(result.messages[0].content.text).toBe("Review carefully\n\nFocus on error handling");
|
|
131
|
+
});
|
|
132
|
+
it("getPrompt throws McpError for unknown skill", async () => {
|
|
133
|
+
mockDiscoverSkills.mockReturnValue(new Map());
|
|
134
|
+
const handler = handlers.get("prompts/get");
|
|
135
|
+
await expect(handler({ params: { name: "nonexistent", arguments: {} } })).rejects.toThrow('Unknown skill: nonexistent');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
//# sourceMappingURL=index.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.test.js","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@skills-hub-ai/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for skills-hub.ai — serve installed skills as prompts in any MCP-compatible AI tool",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": ["mcp", "model-context-protocol", "claude-code", "cursor", "skills", "ai", "prompts"],
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/tinh2/skills-hub.git",
|
|
10
|
+
"directory": "apps/mcp"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://skills-hub.ai",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"bin": {
|
|
15
|
+
"skills-hub-mcp": "dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"dev": "tsx src/index.ts",
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"test": "vitest run --passWithNoTests",
|
|
21
|
+
"lint": "tsc --noEmit"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
25
|
+
"@skills-hub/skill-parser": "workspace:*"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^22.0.0",
|
|
29
|
+
"tsx": "^4.0.0",
|
|
30
|
+
"typescript": "^5.7.0",
|
|
31
|
+
"vitest": "^2.0.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
vi.mock("node:fs", () => ({
|
|
5
|
+
existsSync: vi.fn(() => false),
|
|
6
|
+
readdirSync: vi.fn(() => []),
|
|
7
|
+
readFileSync: vi.fn(() => ""),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("node:os", () => ({
|
|
11
|
+
homedir: vi.fn(() => "/home/user"),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import { discoverSkills, clearCache } from "./discover.js";
|
|
15
|
+
|
|
16
|
+
const mockExistsSync = vi.mocked(existsSync);
|
|
17
|
+
const mockReaddirSync = vi.mocked(readdirSync);
|
|
18
|
+
const mockReadFileSync = vi.mocked(readFileSync);
|
|
19
|
+
|
|
20
|
+
const validSkillMd = `---
|
|
21
|
+
name: Code Review
|
|
22
|
+
description: Automated code review for quality and best practices
|
|
23
|
+
version: 1.0.0
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
Review the code carefully and suggest improvements.
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
const anotherSkillMd = `---
|
|
30
|
+
name: Security Scan
|
|
31
|
+
description: Scan code for security vulnerabilities and common exploits
|
|
32
|
+
version: 2.0.0
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
Check for OWASP top 10 vulnerabilities.
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
function makeDirent(name: string): ReturnType<typeof readdirSync>[0] {
|
|
39
|
+
return { name, isDirectory: () => true, isFile: () => false } as unknown as ReturnType<typeof readdirSync>[0];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.clearAllMocks();
|
|
44
|
+
clearCache();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("discoverSkills", () => {
|
|
48
|
+
it("discovers skills from claude skills directory", () => {
|
|
49
|
+
mockExistsSync.mockImplementation((p) => {
|
|
50
|
+
const s = String(p);
|
|
51
|
+
return s === "/home/user/.claude/skills" || s.endsWith("SKILL.md");
|
|
52
|
+
});
|
|
53
|
+
mockReaddirSync.mockReturnValue([makeDirent("code-review")]);
|
|
54
|
+
mockReadFileSync.mockReturnValue(validSkillMd);
|
|
55
|
+
|
|
56
|
+
const skills = discoverSkills();
|
|
57
|
+
|
|
58
|
+
expect(skills.size).toBe(1);
|
|
59
|
+
expect(skills.has("code-review")).toBe(true);
|
|
60
|
+
expect(skills.get("code-review")!.name).toBe("Code Review");
|
|
61
|
+
expect(skills.get("code-review")!.instructions).toContain("Review the code carefully");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("discovers skills from cursor skills directory", () => {
|
|
65
|
+
mockExistsSync.mockImplementation((p) => {
|
|
66
|
+
const s = String(p);
|
|
67
|
+
return s === "/home/user/.cursor/skills" || s.endsWith("SKILL.md");
|
|
68
|
+
});
|
|
69
|
+
mockReaddirSync.mockReturnValue([makeDirent("security-scan")]);
|
|
70
|
+
mockReadFileSync.mockReturnValue(anotherSkillMd);
|
|
71
|
+
|
|
72
|
+
const skills = discoverSkills();
|
|
73
|
+
|
|
74
|
+
expect(skills.size).toBe(1);
|
|
75
|
+
expect(skills.has("security-scan")).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("scans both directories and deduplicates by slug", () => {
|
|
79
|
+
mockExistsSync.mockReturnValue(true);
|
|
80
|
+
mockReaddirSync.mockImplementation((dir) => {
|
|
81
|
+
const s = String(dir);
|
|
82
|
+
if (s.includes(".claude")) return [makeDirent("code-review")];
|
|
83
|
+
if (s.includes(".cursor")) return [makeDirent("code-review"), makeDirent("security-scan")];
|
|
84
|
+
return [];
|
|
85
|
+
});
|
|
86
|
+
mockReadFileSync.mockImplementation((p) => {
|
|
87
|
+
const s = String(p);
|
|
88
|
+
if (s.includes("code-review")) return validSkillMd;
|
|
89
|
+
if (s.includes("security-scan")) return anotherSkillMd;
|
|
90
|
+
return "";
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const skills = discoverSkills();
|
|
94
|
+
|
|
95
|
+
// code-review from claude wins (first-found), security-scan from cursor
|
|
96
|
+
expect(skills.size).toBe(2);
|
|
97
|
+
expect(skills.has("code-review")).toBe(true);
|
|
98
|
+
expect(skills.has("security-scan")).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("handles missing skills directories", () => {
|
|
102
|
+
mockExistsSync.mockReturnValue(false);
|
|
103
|
+
|
|
104
|
+
const skills = discoverSkills();
|
|
105
|
+
|
|
106
|
+
expect(skills.size).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("skips directories without SKILL.md", () => {
|
|
110
|
+
mockExistsSync.mockImplementation((p) => {
|
|
111
|
+
const s = String(p);
|
|
112
|
+
// Directory exists but SKILL.md does not
|
|
113
|
+
return s === "/home/user/.claude/skills";
|
|
114
|
+
});
|
|
115
|
+
mockReaddirSync.mockReturnValue([makeDirent("orphan-dir")]);
|
|
116
|
+
|
|
117
|
+
const skills = discoverSkills();
|
|
118
|
+
|
|
119
|
+
expect(skills.size).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("skips malformed SKILL.md files", () => {
|
|
123
|
+
mockExistsSync.mockReturnValue(true);
|
|
124
|
+
mockReaddirSync.mockReturnValue([makeDirent("bad-skill")]);
|
|
125
|
+
mockReadFileSync.mockReturnValue("this is not valid frontmatter");
|
|
126
|
+
|
|
127
|
+
const skills = discoverSkills();
|
|
128
|
+
|
|
129
|
+
expect(skills.size).toBe(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("caches results for subsequent calls", () => {
|
|
133
|
+
mockExistsSync.mockReturnValue(true);
|
|
134
|
+
mockReaddirSync.mockReturnValue([makeDirent("code-review")]);
|
|
135
|
+
mockReadFileSync.mockReturnValue(validSkillMd);
|
|
136
|
+
|
|
137
|
+
discoverSkills();
|
|
138
|
+
discoverSkills();
|
|
139
|
+
|
|
140
|
+
// readdirSync called once per directory on first call, not on second
|
|
141
|
+
expect(mockReaddirSync).toHaveBeenCalledTimes(2); // once for claude, once for cursor
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("re-scans after cache is cleared", () => {
|
|
145
|
+
mockExistsSync.mockReturnValue(true);
|
|
146
|
+
mockReaddirSync.mockReturnValue([makeDirent("code-review")]);
|
|
147
|
+
mockReadFileSync.mockReturnValue(validSkillMd);
|
|
148
|
+
|
|
149
|
+
discoverSkills();
|
|
150
|
+
clearCache();
|
|
151
|
+
discoverSkills();
|
|
152
|
+
|
|
153
|
+
// 2 dirs × 2 scans = 4 calls
|
|
154
|
+
expect(mockReaddirSync).toHaveBeenCalledTimes(4);
|
|
155
|
+
});
|
|
156
|
+
});
|
package/src/discover.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { parseSkillMd, type ParsedSkill } from "@skills-hub/skill-parser";
|
|
5
|
+
|
|
6
|
+
const CACHE_TTL_MS = 30_000;
|
|
7
|
+
|
|
8
|
+
let cache: Map<string, ParsedSkill> | null = null;
|
|
9
|
+
let cacheTime = 0;
|
|
10
|
+
|
|
11
|
+
/** Directories where skills may be installed */
|
|
12
|
+
function getSkillPaths(): string[] {
|
|
13
|
+
const home = homedir();
|
|
14
|
+
return [
|
|
15
|
+
join(home, ".claude", "skills"),
|
|
16
|
+
join(home, ".cursor", "skills"),
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Scan a single skills directory and collect parsed skills */
|
|
21
|
+
function scanDir(dir: string, out: Map<string, ParsedSkill>): void {
|
|
22
|
+
if (!existsSync(dir)) return;
|
|
23
|
+
|
|
24
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
if (!entry.isDirectory()) continue;
|
|
27
|
+
|
|
28
|
+
const slug = entry.name;
|
|
29
|
+
if (out.has(slug)) continue; // first-found wins (claude > cursor)
|
|
30
|
+
|
|
31
|
+
const skillFile = join(dir, slug, "SKILL.md");
|
|
32
|
+
if (!existsSync(skillFile)) continue;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const content = readFileSync(skillFile, "utf-8");
|
|
36
|
+
const result = parseSkillMd(content);
|
|
37
|
+
if (result.success && result.skill) {
|
|
38
|
+
out.set(slug, result.skill);
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// Skip malformed files
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Discover all installed skills, cached for 30s */
|
|
47
|
+
export function discoverSkills(): Map<string, ParsedSkill> {
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
if (cache && now - cacheTime < CACHE_TTL_MS) {
|
|
50
|
+
return cache;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const skills = new Map<string, ParsedSkill>();
|
|
54
|
+
for (const dir of getSkillPaths()) {
|
|
55
|
+
scanDir(dir, skills);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
cache = skills;
|
|
59
|
+
cacheTime = now;
|
|
60
|
+
return skills;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Clear the discovery cache (for testing) */
|
|
64
|
+
export function clearCache(): void {
|
|
65
|
+
cache = null;
|
|
66
|
+
cacheTime = 0;
|
|
67
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import type { ParsedSkill } from "@skills-hub/skill-parser";
|
|
3
|
+
|
|
4
|
+
vi.mock("./discover.js", () => ({
|
|
5
|
+
discoverSkills: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
// Mock the MCP SDK — we test the handler logic, not the transport
|
|
9
|
+
vi.mock("@modelcontextprotocol/sdk/server/index.js", () => {
|
|
10
|
+
const handlers = new Map<string, (req: unknown) => Promise<unknown>>();
|
|
11
|
+
return {
|
|
12
|
+
Server: vi.fn().mockImplementation(() => ({
|
|
13
|
+
setRequestHandler: vi.fn((schema: { method: string }, handler: (req: unknown) => Promise<unknown>) => {
|
|
14
|
+
handlers.set(schema.method, handler);
|
|
15
|
+
}),
|
|
16
|
+
connect: vi.fn(),
|
|
17
|
+
_handlers: handlers,
|
|
18
|
+
})),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({
|
|
23
|
+
StdioServerTransport: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock("@modelcontextprotocol/sdk/types.js", () => ({
|
|
27
|
+
ListPromptsRequestSchema: { method: "prompts/list" },
|
|
28
|
+
GetPromptRequestSchema: { method: "prompts/get" },
|
|
29
|
+
ErrorCode: { InvalidRequest: -32600 },
|
|
30
|
+
McpError: class McpError extends Error {
|
|
31
|
+
code: number;
|
|
32
|
+
constructor(code: number, message: string) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.code = code;
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
import { discoverSkills } from "./discover.js";
|
|
40
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
41
|
+
|
|
42
|
+
const mockDiscoverSkills = vi.mocked(discoverSkills);
|
|
43
|
+
|
|
44
|
+
function makeSkill(name: string, description: string, instructions: string): ParsedSkill {
|
|
45
|
+
return {
|
|
46
|
+
name,
|
|
47
|
+
description,
|
|
48
|
+
version: "1.0.0",
|
|
49
|
+
category: undefined,
|
|
50
|
+
platforms: [],
|
|
51
|
+
instructions,
|
|
52
|
+
raw: "",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let handlers: Map<string, (req: unknown) => Promise<unknown>>;
|
|
57
|
+
|
|
58
|
+
beforeEach(async () => {
|
|
59
|
+
vi.clearAllMocks();
|
|
60
|
+
|
|
61
|
+
// Re-import to trigger module-level server creation
|
|
62
|
+
vi.resetModules();
|
|
63
|
+
|
|
64
|
+
// Re-mock after resetModules
|
|
65
|
+
vi.doMock("./discover.js", () => ({
|
|
66
|
+
discoverSkills: mockDiscoverSkills,
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
vi.doMock("@modelcontextprotocol/sdk/server/index.js", () => {
|
|
70
|
+
const h = new Map<string, (req: unknown) => Promise<unknown>>();
|
|
71
|
+
handlers = h;
|
|
72
|
+
return {
|
|
73
|
+
Server: vi.fn().mockImplementation(() => ({
|
|
74
|
+
setRequestHandler: vi.fn((schema: { method: string }, handler: (req: unknown) => Promise<unknown>) => {
|
|
75
|
+
h.set(schema.method, handler);
|
|
76
|
+
}),
|
|
77
|
+
connect: vi.fn(),
|
|
78
|
+
})),
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
vi.doMock("@modelcontextprotocol/sdk/server/stdio.js", () => ({
|
|
83
|
+
StdioServerTransport: vi.fn(),
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
vi.doMock("@modelcontextprotocol/sdk/types.js", () => ({
|
|
87
|
+
ListPromptsRequestSchema: { method: "prompts/list" },
|
|
88
|
+
GetPromptRequestSchema: { method: "prompts/get" },
|
|
89
|
+
ErrorCode: { InvalidRequest: -32600 },
|
|
90
|
+
McpError: class McpError extends Error {
|
|
91
|
+
code: number;
|
|
92
|
+
constructor(code: number, message: string) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.code = code;
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
// Suppress console.error from main()
|
|
100
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
101
|
+
|
|
102
|
+
await import("./index.js");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("MCP server", () => {
|
|
106
|
+
it("registers prompts/list and prompts/get handlers", () => {
|
|
107
|
+
expect(handlers.has("prompts/list")).toBe(true);
|
|
108
|
+
expect(handlers.has("prompts/get")).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("listPrompts returns discovered skills", async () => {
|
|
112
|
+
const skills = new Map<string, ParsedSkill>();
|
|
113
|
+
skills.set("code-review", makeSkill("Code Review", "Review code quality", "Review carefully"));
|
|
114
|
+
skills.set("security-scan", makeSkill("Security Scan", "Find vulnerabilities", "Check OWASP"));
|
|
115
|
+
mockDiscoverSkills.mockReturnValue(skills);
|
|
116
|
+
|
|
117
|
+
const handler = handlers.get("prompts/list")!;
|
|
118
|
+
const result = (await handler({})) as { prompts: Array<{ name: string; description: string }> };
|
|
119
|
+
|
|
120
|
+
expect(result.prompts).toHaveLength(2);
|
|
121
|
+
expect(result.prompts[0].name).toBe("code-review");
|
|
122
|
+
expect(result.prompts[0].description).toBe("Review code quality");
|
|
123
|
+
expect(result.prompts[1].name).toBe("security-scan");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("listPrompts returns empty array when no skills installed", async () => {
|
|
127
|
+
mockDiscoverSkills.mockReturnValue(new Map());
|
|
128
|
+
|
|
129
|
+
const handler = handlers.get("prompts/list")!;
|
|
130
|
+
const result = (await handler({})) as { prompts: unknown[] };
|
|
131
|
+
|
|
132
|
+
expect(result.prompts).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("getPrompt returns skill instructions", async () => {
|
|
136
|
+
const skills = new Map<string, ParsedSkill>();
|
|
137
|
+
skills.set("code-review", makeSkill("Code Review", "Review code quality", "Review carefully"));
|
|
138
|
+
mockDiscoverSkills.mockReturnValue(skills);
|
|
139
|
+
|
|
140
|
+
const handler = handlers.get("prompts/get")!;
|
|
141
|
+
const result = (await handler({
|
|
142
|
+
params: { name: "code-review", arguments: {} },
|
|
143
|
+
})) as { description: string; messages: Array<{ role: string; content: { type: string; text: string } }> };
|
|
144
|
+
|
|
145
|
+
expect(result.description).toBe("Review code quality");
|
|
146
|
+
expect(result.messages).toHaveLength(1);
|
|
147
|
+
expect(result.messages[0].role).toBe("user");
|
|
148
|
+
expect(result.messages[0].content.text).toBe("Review carefully");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("getPrompt appends input argument to instructions", async () => {
|
|
152
|
+
const skills = new Map<string, ParsedSkill>();
|
|
153
|
+
skills.set("code-review", makeSkill("Code Review", "Review code quality", "Review carefully"));
|
|
154
|
+
mockDiscoverSkills.mockReturnValue(skills);
|
|
155
|
+
|
|
156
|
+
const handler = handlers.get("prompts/get")!;
|
|
157
|
+
const result = (await handler({
|
|
158
|
+
params: { name: "code-review", arguments: { input: "Focus on error handling" } },
|
|
159
|
+
})) as { messages: Array<{ content: { text: string } }> };
|
|
160
|
+
|
|
161
|
+
expect(result.messages[0].content.text).toBe("Review carefully\n\nFocus on error handling");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("getPrompt throws McpError for unknown skill", async () => {
|
|
165
|
+
mockDiscoverSkills.mockReturnValue(new Map());
|
|
166
|
+
|
|
167
|
+
const handler = handlers.get("prompts/get")!;
|
|
168
|
+
|
|
169
|
+
await expect(
|
|
170
|
+
handler({ params: { name: "nonexistent", arguments: {} } }),
|
|
171
|
+
).rejects.toThrow('Unknown skill: nonexistent');
|
|
172
|
+
});
|
|
173
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import {
|
|
6
|
+
ListPromptsRequestSchema,
|
|
7
|
+
GetPromptRequestSchema,
|
|
8
|
+
ErrorCode,
|
|
9
|
+
McpError,
|
|
10
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
11
|
+
import { discoverSkills } from "./discover.js";
|
|
12
|
+
|
|
13
|
+
const server = new Server(
|
|
14
|
+
{ name: "skills-hub", version: "0.1.0" },
|
|
15
|
+
{ capabilities: { prompts: { listChanged: true } } },
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
19
|
+
const skills = discoverSkills();
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
prompts: Array.from(skills.entries()).map(([slug, skill]) => ({
|
|
23
|
+
name: slug,
|
|
24
|
+
description: skill.description,
|
|
25
|
+
arguments: [
|
|
26
|
+
{
|
|
27
|
+
name: "input",
|
|
28
|
+
description: "Context or instructions for the skill",
|
|
29
|
+
required: false,
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
})),
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
37
|
+
const { name, arguments: args } = request.params;
|
|
38
|
+
const skills = discoverSkills();
|
|
39
|
+
const skill = skills.get(name);
|
|
40
|
+
|
|
41
|
+
if (!skill) {
|
|
42
|
+
throw new McpError(
|
|
43
|
+
ErrorCode.InvalidRequest,
|
|
44
|
+
`Unknown skill: ${name}. Run "skills-hub install ${name}" to install it.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let text = skill.instructions;
|
|
49
|
+
if (args?.input) {
|
|
50
|
+
text += `\n\n${args.input}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
description: skill.description,
|
|
55
|
+
messages: [
|
|
56
|
+
{
|
|
57
|
+
role: "user" as const,
|
|
58
|
+
content: { type: "text" as const, text },
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
async function main() {
|
|
65
|
+
const transport = new StdioServerTransport();
|
|
66
|
+
await server.connect(transport);
|
|
67
|
+
// stderr only — stdout is the MCP JSON-RPC stream
|
|
68
|
+
console.error("skills-hub MCP server running");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
main().catch((err) => {
|
|
72
|
+
console.error("Fatal:", err);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
});
|