modelbound-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/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/adapters/index.d.ts +13 -0
- package/dist/adapters/index.js +40 -0
- package/dist/adapters/types.d.ts +30 -0
- package/dist/adapters/types.js +20 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +111 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +52 -0
- package/dist/lib/lint.d.ts +26 -0
- package/dist/lib/lint.js +110 -0
- package/dist/proxy.d.ts +7 -0
- package/dist/proxy.js +40 -0
- package/dist/tools/cloud.d.ts +72 -0
- package/dist/tools/cloud.js +66 -0
- package/dist/tools/local.d.ts +271 -0
- package/dist/tools/local.js +212 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ModelBound
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# modelbound-mcp
|
|
2
|
+
|
|
3
|
+
> Local-first MCP server for agent skills. Validate, lint, diff, and convert agent skill files across Cursor, Claude, Kiro, Windsurf, VS Code, and Amazon Q — no account required. Optional cloud sync with [ModelBound](https://modelbound.co).
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/modelbound-mcp)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
## What it does
|
|
9
|
+
|
|
10
|
+
`modelbound-mcp` is a small [Model Context Protocol](https://modelcontextprotocol.io) server you run locally over stdio. It exposes tools to your IDE / agent:
|
|
11
|
+
|
|
12
|
+
**Local (no API key, no network):**
|
|
13
|
+
- `detect_ide_layout` — find which IDE conventions your repo uses
|
|
14
|
+
- `list_local_skills`, `read_local_skill`, `write_local_skill`
|
|
15
|
+
- `lint_skill` — front-matter, token count, broken links, TODO scan
|
|
16
|
+
- `validate_skill_format` — agentskills.io compliance
|
|
17
|
+
- `convert_skill` — translate between IDE formats (e.g. Cursor → Claude)
|
|
18
|
+
|
|
19
|
+
**Cloud (with `MODELBOUND_API_KEY`):**
|
|
20
|
+
- `pull_skill`, `push_skill`, `list_cloud_skills`, `search_cloud`, `diff_skill`
|
|
21
|
+
- `install_marketplace_skill`, `get_context_health`
|
|
22
|
+
|
|
23
|
+
The cloud tools are a thin JSON-RPC proxy to `mcp.modelbound.co`. All business logic stays server-side; this repo never touches your data or secrets.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
No install needed:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx modelbound-mcp
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or install globally:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm i -g modelbound-mcp
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Use as an MCP server
|
|
40
|
+
|
|
41
|
+
### Cursor (`.cursor/mcp.json`)
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"modelbound": {
|
|
47
|
+
"command": "npx",
|
|
48
|
+
"args": ["-y", "modelbound-mcp"],
|
|
49
|
+
"env": { "MODELBOUND_API_KEY": "mb_live_..." }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`MODELBOUND_API_KEY` is optional. Without it, local tools still work.
|
|
56
|
+
|
|
57
|
+
See [`examples/`](./examples) for Claude Desktop, Kiro, Windsurf, and VS Code configs.
|
|
58
|
+
|
|
59
|
+
## Use as a CLI
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
modelbound-mcp detect # which IDE layouts exist here?
|
|
63
|
+
modelbound-mcp ls # list every skill file
|
|
64
|
+
modelbound-mcp lint .cursor/rules/ # lint a directory
|
|
65
|
+
modelbound-mcp validate ./SKILL.md # agentskills.io compliance
|
|
66
|
+
modelbound-mcp convert --from cursor --to claude ./rule.mdc > out.md
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Contributing
|
|
70
|
+
|
|
71
|
+
We want help. Specifically:
|
|
72
|
+
|
|
73
|
+
- **New IDE adapters** — Zed, Aider, Continue, JetBrains AI, Cline. See [CONTRIBUTING.md](CONTRIBUTING.md) for the ~50 line recipe.
|
|
74
|
+
- **Linter rules** — token estimation accuracy, dead-link detection, format-specific gotchas.
|
|
75
|
+
- **Format converters** — fidelity improvements between adapter pairs.
|
|
76
|
+
|
|
77
|
+
Browse [good first issues](https://github.com/ModelBound/modelbound-mcp-server/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) and the [roadmap](ROADMAP.md).
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT © ModelBound
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { IdeAdapter } from "./types.js";
|
|
2
|
+
export { listAdapterFiles } from "./types.js";
|
|
3
|
+
export type { IdeAdapter, CanonicalSkill } from "./types.js";
|
|
4
|
+
export declare const cursor: IdeAdapter;
|
|
5
|
+
export declare const claude: IdeAdapter;
|
|
6
|
+
export declare const kiro: IdeAdapter;
|
|
7
|
+
export declare const windsurf: IdeAdapter;
|
|
8
|
+
export declare const vscode: IdeAdapter;
|
|
9
|
+
export declare const amazonQ: IdeAdapter;
|
|
10
|
+
export declare const agentsMd: IdeAdapter;
|
|
11
|
+
export declare const ALL_ADAPTERS: IdeAdapter[];
|
|
12
|
+
export declare function getAdapter(id: string): IdeAdapter | undefined;
|
|
13
|
+
export declare function detectAdapters(cwd: string): IdeAdapter[];
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
export { listAdapterFiles } from "./types.js";
|
|
5
|
+
const mdAdapter = (id, name, skillsDir, fileExt = ".md", detect) => ({
|
|
6
|
+
id,
|
|
7
|
+
name,
|
|
8
|
+
skillsDir,
|
|
9
|
+
fileExt,
|
|
10
|
+
detect: detect ?? ((cwd) => fs.existsSync(path.join(cwd, skillsDir))),
|
|
11
|
+
toCanonical: (raw) => {
|
|
12
|
+
const parsed = matter(raw);
|
|
13
|
+
return { frontmatter: parsed.data ?? {}, body: parsed.content.trim() };
|
|
14
|
+
},
|
|
15
|
+
fromCanonical: ({ frontmatter, body }) => Object.keys(frontmatter).length
|
|
16
|
+
? matter.stringify(body, frontmatter)
|
|
17
|
+
: body + "\n",
|
|
18
|
+
});
|
|
19
|
+
export const cursor = mdAdapter("cursor", "Cursor", ".cursor/rules", ".mdc", (cwd) => fs.existsSync(path.join(cwd, ".cursor")));
|
|
20
|
+
export const claude = mdAdapter("claude", "Claude Code", ".claude/skills", ".md", (cwd) => fs.existsSync(path.join(cwd, ".claude")));
|
|
21
|
+
export const kiro = mdAdapter("kiro", "Kiro", ".kiro/skills", ".md", (cwd) => fs.existsSync(path.join(cwd, ".kiro")));
|
|
22
|
+
export const windsurf = mdAdapter("windsurf", "Windsurf", ".windsurf/rules", ".md", (cwd) => fs.existsSync(path.join(cwd, ".windsurf")));
|
|
23
|
+
export const vscode = mdAdapter("vscode-copilot", "VS Code / GitHub Copilot", ".github/copilot", ".md", (cwd) => fs.existsSync(path.join(cwd, ".github/copilot")));
|
|
24
|
+
export const amazonQ = mdAdapter("amazon-q", "Amazon Q", ".amazonq/rules", ".md", (cwd) => fs.existsSync(path.join(cwd, ".amazonq")));
|
|
25
|
+
export const agentsMd = mdAdapter("agents-md", "AGENTS.md (generic)", ".", ".md", (cwd) => fs.existsSync(path.join(cwd, "AGENTS.md")));
|
|
26
|
+
export const ALL_ADAPTERS = [
|
|
27
|
+
cursor,
|
|
28
|
+
claude,
|
|
29
|
+
kiro,
|
|
30
|
+
windsurf,
|
|
31
|
+
vscode,
|
|
32
|
+
amazonQ,
|
|
33
|
+
agentsMd,
|
|
34
|
+
];
|
|
35
|
+
export function getAdapter(id) {
|
|
36
|
+
return ALL_ADAPTERS.find((a) => a.id === id);
|
|
37
|
+
}
|
|
38
|
+
export function detectAdapters(cwd) {
|
|
39
|
+
return ALL_ADAPTERS.filter((a) => a.detect(cwd));
|
|
40
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IDE adapter interface. Each adapter knows where a given IDE stores its
|
|
3
|
+
* agent skill / rules files and how to translate that IDE's flavor of a
|
|
4
|
+
* skill file to and from a canonical { frontmatter, body } representation.
|
|
5
|
+
*
|
|
6
|
+
* Adding a new IDE is the single best way to contribute to this project.
|
|
7
|
+
* See CONTRIBUTING.md for a 10-line recipe.
|
|
8
|
+
*/
|
|
9
|
+
export interface CanonicalSkill {
|
|
10
|
+
frontmatter: Record<string, unknown>;
|
|
11
|
+
body: string;
|
|
12
|
+
}
|
|
13
|
+
export interface IdeAdapter {
|
|
14
|
+
/** Stable identifier used in CLI args, e.g. `cursor`, `claude`. */
|
|
15
|
+
id: string;
|
|
16
|
+
/** Human-readable name. */
|
|
17
|
+
name: string;
|
|
18
|
+
/** Returns true if this IDE's project marker exists in `cwd`. */
|
|
19
|
+
detect: (cwd: string) => boolean;
|
|
20
|
+
/** Project-relative directory that holds skill / rule files. */
|
|
21
|
+
skillsDir: string;
|
|
22
|
+
/** File extension this IDE uses for its skill files (e.g. `.mdc`, `.md`). */
|
|
23
|
+
fileExt: string;
|
|
24
|
+
/** Parse a raw file body into the canonical representation. */
|
|
25
|
+
toCanonical: (raw: string) => CanonicalSkill;
|
|
26
|
+
/** Serialize the canonical representation back to this IDE's format. */
|
|
27
|
+
fromCanonical: (skill: CanonicalSkill) => string;
|
|
28
|
+
}
|
|
29
|
+
/** Walk the skills directory and return absolute file paths. */
|
|
30
|
+
export declare function listAdapterFiles(adapter: IdeAdapter, cwd: string): string[];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/** Walk the skills directory and return absolute file paths. */
|
|
4
|
+
export function listAdapterFiles(adapter, cwd) {
|
|
5
|
+
const dir = path.join(cwd, adapter.skillsDir);
|
|
6
|
+
if (!fs.existsSync(dir))
|
|
7
|
+
return [];
|
|
8
|
+
const out = [];
|
|
9
|
+
const walk = (d) => {
|
|
10
|
+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
11
|
+
const full = path.join(d, entry.name);
|
|
12
|
+
if (entry.isDirectory())
|
|
13
|
+
walk(full);
|
|
14
|
+
else if (entry.isFile() && full.endsWith(adapter.fileExt))
|
|
15
|
+
out.push(full);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
walk(dir);
|
|
19
|
+
return out;
|
|
20
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Standalone CLI: `modelbound-mcp <subcommand>`
|
|
4
|
+
*
|
|
5
|
+
* Without arguments, launches the MCP stdio server (this is what IDEs invoke).
|
|
6
|
+
* With a subcommand, runs that subcommand against the filesystem.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { ALL_ADAPTERS, detectAdapters, getAdapter, listAdapterFiles } from "./adapters/index.js";
|
|
11
|
+
import { lintSkill, validateAgentSkillsFormat } from "./lib/lint.js";
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
const cmd = args[0];
|
|
14
|
+
if (!cmd) {
|
|
15
|
+
// No subcommand → boot the MCP server.
|
|
16
|
+
await import("./index.js");
|
|
17
|
+
}
|
|
18
|
+
else if (cmd === "validate") {
|
|
19
|
+
const file = args[1];
|
|
20
|
+
if (!file)
|
|
21
|
+
die("Usage: modelbound-mcp validate <file>");
|
|
22
|
+
const report = validateAgentSkillsFormat(fs.readFileSync(file, "utf8"));
|
|
23
|
+
printReport(file, report);
|
|
24
|
+
process.exit(report.ok ? 0 : 1);
|
|
25
|
+
}
|
|
26
|
+
else if (cmd === "lint") {
|
|
27
|
+
const target = args[1] ?? ".";
|
|
28
|
+
const stat = fs.statSync(target);
|
|
29
|
+
let failed = false;
|
|
30
|
+
const files = stat.isDirectory()
|
|
31
|
+
? walk(target).filter((f) => f.endsWith(".md") || f.endsWith(".mdc"))
|
|
32
|
+
: [target];
|
|
33
|
+
for (const f of files) {
|
|
34
|
+
const report = lintSkill(fs.readFileSync(f, "utf8"));
|
|
35
|
+
printReport(f, report);
|
|
36
|
+
if (!report.ok)
|
|
37
|
+
failed = true;
|
|
38
|
+
}
|
|
39
|
+
process.exit(failed ? 1 : 0);
|
|
40
|
+
}
|
|
41
|
+
else if (cmd === "convert") {
|
|
42
|
+
const fromIdx = args.indexOf("--from");
|
|
43
|
+
const toIdx = args.indexOf("--to");
|
|
44
|
+
const file = args[args.length - 1];
|
|
45
|
+
if (fromIdx < 0 || toIdx < 0 || !file) {
|
|
46
|
+
die("Usage: modelbound-mcp convert --from <ide> --to <ide> <file>");
|
|
47
|
+
}
|
|
48
|
+
const from = getAdapter(args[fromIdx + 1]);
|
|
49
|
+
const to = getAdapter(args[toIdx + 1]);
|
|
50
|
+
if (!from || !to)
|
|
51
|
+
die(`Unknown adapter. Known: ${ALL_ADAPTERS.map((a) => a.id).join(", ")}`);
|
|
52
|
+
const canonical = from.toCanonical(fs.readFileSync(file, "utf8"));
|
|
53
|
+
process.stdout.write(to.fromCanonical(canonical));
|
|
54
|
+
}
|
|
55
|
+
else if (cmd === "detect") {
|
|
56
|
+
const adapters = detectAdapters(process.cwd());
|
|
57
|
+
console.log(adapters.length
|
|
58
|
+
? adapters.map((a) => `${a.id}\t${a.skillsDir}`).join("\n")
|
|
59
|
+
: "No known IDE layouts detected.");
|
|
60
|
+
}
|
|
61
|
+
else if (cmd === "ls") {
|
|
62
|
+
for (const a of detectAdapters(process.cwd())) {
|
|
63
|
+
for (const f of listAdapterFiles(a, process.cwd())) {
|
|
64
|
+
console.log(`${a.id}\t${path.relative(process.cwd(), f)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else if (cmd === "--help" || cmd === "-h") {
|
|
69
|
+
console.log(`modelbound-mcp — local-first MCP server + CLI for agent skills
|
|
70
|
+
|
|
71
|
+
Without arguments, launches the MCP stdio server (used by IDEs).
|
|
72
|
+
|
|
73
|
+
Subcommands:
|
|
74
|
+
detect List IDE layouts found in the current directory
|
|
75
|
+
ls List all skill files across detected layouts
|
|
76
|
+
validate <file> Validate a skill against the agentskills.io standard
|
|
77
|
+
lint <file|dir> Run the full linter (front-matter, size, links, TODOs)
|
|
78
|
+
convert --from X --to Y <file> Convert between IDE formats (writes to stdout)
|
|
79
|
+
|
|
80
|
+
Adapters: ${ALL_ADAPTERS.map((a) => a.id).join(", ")}
|
|
81
|
+
|
|
82
|
+
Set MODELBOUND_API_KEY to unlock cloud sync tools when running as an MCP server.
|
|
83
|
+
`);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
die(`Unknown subcommand: ${cmd}. Try --help.`);
|
|
87
|
+
}
|
|
88
|
+
function die(msg) {
|
|
89
|
+
console.error(msg);
|
|
90
|
+
process.exit(2);
|
|
91
|
+
}
|
|
92
|
+
function walk(dir) {
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
95
|
+
if (entry.name.startsWith(".") && entry.name !== ".cursor" && entry.name !== ".claude" && entry.name !== ".kiro" && entry.name !== ".windsurf")
|
|
96
|
+
continue;
|
|
97
|
+
const full = path.join(dir, entry.name);
|
|
98
|
+
if (entry.isDirectory())
|
|
99
|
+
out.push(...walk(full));
|
|
100
|
+
else if (entry.isFile())
|
|
101
|
+
out.push(full);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
function printReport(file, report) {
|
|
106
|
+
const tag = report.ok ? "OK " : "FAIL";
|
|
107
|
+
console.log(`${tag} ${file} (~${report.tokens} tokens)`);
|
|
108
|
+
for (const i of report.issues) {
|
|
109
|
+
console.log(` ${i.severity.padEnd(5)} ${i.rule.padEnd(22)} ${i.message}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
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 { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { localTools } from "./tools/local.js";
|
|
6
|
+
import { cloudTools } from "./tools/cloud.js";
|
|
7
|
+
import { CloudClient } from "./proxy.js";
|
|
8
|
+
const cwd = process.cwd();
|
|
9
|
+
const cloud = CloudClient.fromEnv();
|
|
10
|
+
const tools = [
|
|
11
|
+
...localTools(cloud),
|
|
12
|
+
...cloudTools(cloud).map((t) => ({
|
|
13
|
+
...t,
|
|
14
|
+
handler: async (args, _ctx) => t.handler(args),
|
|
15
|
+
})),
|
|
16
|
+
];
|
|
17
|
+
const server = new Server({ name: "modelbound-mcp", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
18
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
19
|
+
tools: tools.map((t) => ({
|
|
20
|
+
name: t.name,
|
|
21
|
+
description: t.description,
|
|
22
|
+
inputSchema: t.inputSchema,
|
|
23
|
+
})),
|
|
24
|
+
}));
|
|
25
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
26
|
+
const tool = tools.find((t) => t.name === req.params.name);
|
|
27
|
+
if (!tool) {
|
|
28
|
+
return {
|
|
29
|
+
isError: true,
|
|
30
|
+
content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const result = await tool.handler(req.params.arguments ?? {}, { cwd });
|
|
35
|
+
return {
|
|
36
|
+
content: [
|
|
37
|
+
{
|
|
38
|
+
type: "text",
|
|
39
|
+
text: typeof result === "string" ? result : JSON.stringify(result, null, 2),
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
return {
|
|
46
|
+
isError: true,
|
|
47
|
+
content: [{ type: "text", text: err.message }],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
const transport = new StdioServerTransport();
|
|
52
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Rough token estimate: 1 token ≈ 4 characters of English text. */
|
|
2
|
+
export declare function estimateTokens(text: string): number;
|
|
3
|
+
export interface LintIssue {
|
|
4
|
+
severity: "error" | "warn" | "info";
|
|
5
|
+
rule: string;
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
export interface LintReport {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
tokens: number;
|
|
11
|
+
issues: LintIssue[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Lints a single skill file. Catches: missing required front-matter,
|
|
15
|
+
* suspicious token counts, obviously broken links, and stale TODOs.
|
|
16
|
+
*
|
|
17
|
+
* Pure: no network, no filesystem writes.
|
|
18
|
+
*/
|
|
19
|
+
export declare function lintSkill(raw: string, opts?: {
|
|
20
|
+
maxTokens?: number;
|
|
21
|
+
}): LintReport;
|
|
22
|
+
/**
|
|
23
|
+
* Validates a skill against the agentskills.io standard:
|
|
24
|
+
* https://agentskills.io — SKILL.md + front-matter (name, description, optional version).
|
|
25
|
+
*/
|
|
26
|
+
export declare function validateAgentSkillsFormat(raw: string): LintReport;
|
package/dist/lib/lint.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import matter from "gray-matter";
|
|
2
|
+
/** Rough token estimate: 1 token ≈ 4 characters of English text. */
|
|
3
|
+
export function estimateTokens(text) {
|
|
4
|
+
return Math.ceil(text.length / 4);
|
|
5
|
+
}
|
|
6
|
+
const LINK_RE = /\[[^\]]+\]\((?<href>[^)]+)\)/g;
|
|
7
|
+
const REQUIRED_FRONTMATTER = ["name", "description"];
|
|
8
|
+
/**
|
|
9
|
+
* Lints a single skill file. Catches: missing required front-matter,
|
|
10
|
+
* suspicious token counts, obviously broken links, and stale TODOs.
|
|
11
|
+
*
|
|
12
|
+
* Pure: no network, no filesystem writes.
|
|
13
|
+
*/
|
|
14
|
+
export function lintSkill(raw, opts) {
|
|
15
|
+
const issues = [];
|
|
16
|
+
const maxTokens = opts?.maxTokens ?? 4000;
|
|
17
|
+
let frontmatter = {};
|
|
18
|
+
let body = raw;
|
|
19
|
+
try {
|
|
20
|
+
const parsed = matter(raw);
|
|
21
|
+
frontmatter = parsed.data ?? {};
|
|
22
|
+
body = parsed.content;
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
issues.push({
|
|
26
|
+
severity: "error",
|
|
27
|
+
rule: "frontmatter-parse",
|
|
28
|
+
message: `Front-matter could not be parsed: ${err.message}`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
for (const key of REQUIRED_FRONTMATTER) {
|
|
32
|
+
if (!frontmatter[key] || typeof frontmatter[key] !== "string") {
|
|
33
|
+
issues.push({
|
|
34
|
+
severity: "warn",
|
|
35
|
+
rule: "frontmatter-required",
|
|
36
|
+
message: `Missing recommended front-matter field: \`${key}\``,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const tokens = estimateTokens(body);
|
|
41
|
+
if (tokens > maxTokens) {
|
|
42
|
+
issues.push({
|
|
43
|
+
severity: "warn",
|
|
44
|
+
rule: "size",
|
|
45
|
+
message: `Skill body is ~${tokens} tokens (limit ${maxTokens}). Consider splitting.`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
for (const match of body.matchAll(LINK_RE)) {
|
|
49
|
+
const href = match.groups?.href ?? "";
|
|
50
|
+
if (!href || href.includes(" ") || href === "#") {
|
|
51
|
+
issues.push({
|
|
52
|
+
severity: "info",
|
|
53
|
+
rule: "broken-link",
|
|
54
|
+
message: `Suspicious link target: \`${href}\``,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (/\bTODO\b|\bFIXME\b/i.test(body)) {
|
|
59
|
+
issues.push({
|
|
60
|
+
severity: "info",
|
|
61
|
+
rule: "todo",
|
|
62
|
+
message: "Contains TODO/FIXME markers.",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
ok: !issues.some((i) => i.severity === "error"),
|
|
67
|
+
tokens,
|
|
68
|
+
issues,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Validates a skill against the agentskills.io standard:
|
|
73
|
+
* https://agentskills.io — SKILL.md + front-matter (name, description, optional version).
|
|
74
|
+
*/
|
|
75
|
+
export function validateAgentSkillsFormat(raw) {
|
|
76
|
+
const issues = [];
|
|
77
|
+
let frontmatter = {};
|
|
78
|
+
try {
|
|
79
|
+
frontmatter = matter(raw).data ?? {};
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
issues.push({
|
|
83
|
+
severity: "error",
|
|
84
|
+
rule: "frontmatter-parse",
|
|
85
|
+
message: err.message,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (!frontmatter.name) {
|
|
89
|
+
issues.push({ severity: "error", rule: "agentskills-name", message: "`name` is required." });
|
|
90
|
+
}
|
|
91
|
+
if (!frontmatter.description) {
|
|
92
|
+
issues.push({
|
|
93
|
+
severity: "error",
|
|
94
|
+
rule: "agentskills-description",
|
|
95
|
+
message: "`description` is required.",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (frontmatter.name && String(frontmatter.name).length > 64) {
|
|
99
|
+
issues.push({
|
|
100
|
+
severity: "warn",
|
|
101
|
+
rule: "agentskills-name-length",
|
|
102
|
+
message: "`name` should be 64 characters or fewer.",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
ok: !issues.some((i) => i.severity === "error"),
|
|
107
|
+
tokens: estimateTokens(raw),
|
|
108
|
+
issues,
|
|
109
|
+
};
|
|
110
|
+
}
|
package/dist/proxy.d.ts
ADDED
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin JSON-RPC 2.0 client for the hosted ModelBound MCP server.
|
|
3
|
+
*
|
|
4
|
+
* The URL is hardcoded on purpose: users shouldn't point this at arbitrary
|
|
5
|
+
* servers from inside their IDE. To self-host, fork this file.
|
|
6
|
+
*/
|
|
7
|
+
const MCP_URL = "https://mcp.modelbound.co/mcp?source=oss-mcp";
|
|
8
|
+
let nextId = 1;
|
|
9
|
+
export class CloudClient {
|
|
10
|
+
apiKey;
|
|
11
|
+
constructor(apiKey) {
|
|
12
|
+
this.apiKey = apiKey;
|
|
13
|
+
}
|
|
14
|
+
static fromEnv() {
|
|
15
|
+
const key = process.env.MODELBOUND_API_KEY;
|
|
16
|
+
return key ? new CloudClient(key) : null;
|
|
17
|
+
}
|
|
18
|
+
async call(method, params) {
|
|
19
|
+
const res = await fetch(MCP_URL, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
// MCP Streamable HTTP servers reject without both Accept types.
|
|
24
|
+
Accept: "application/json, text/event-stream",
|
|
25
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method, params }),
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
throw new Error(`ModelBound cloud returned ${res.status}: ${await res.text()}`);
|
|
31
|
+
}
|
|
32
|
+
const body = (await res.json());
|
|
33
|
+
if (body.error)
|
|
34
|
+
throw new Error(body.error.message);
|
|
35
|
+
return body.result;
|
|
36
|
+
}
|
|
37
|
+
async callTool(name, args) {
|
|
38
|
+
return this.call("tools/call", { name, arguments: args });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { CloudClient } from "../proxy.js";
|
|
2
|
+
export declare function cloudTools(client: CloudClient | null): ({
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
inputSchema: {
|
|
6
|
+
type: string;
|
|
7
|
+
properties: {
|
|
8
|
+
slug: {
|
|
9
|
+
type: string;
|
|
10
|
+
};
|
|
11
|
+
title?: undefined;
|
|
12
|
+
body_md?: undefined;
|
|
13
|
+
q?: undefined;
|
|
14
|
+
};
|
|
15
|
+
required: string[];
|
|
16
|
+
};
|
|
17
|
+
handler: (args: {
|
|
18
|
+
slug: string;
|
|
19
|
+
}) => Promise<unknown>;
|
|
20
|
+
} | {
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
inputSchema: {
|
|
24
|
+
type: string;
|
|
25
|
+
properties: {
|
|
26
|
+
slug: {
|
|
27
|
+
type: string;
|
|
28
|
+
};
|
|
29
|
+
title: {
|
|
30
|
+
type: string;
|
|
31
|
+
};
|
|
32
|
+
body_md: {
|
|
33
|
+
type: string;
|
|
34
|
+
};
|
|
35
|
+
q?: undefined;
|
|
36
|
+
};
|
|
37
|
+
required: string[];
|
|
38
|
+
};
|
|
39
|
+
handler: (args: Record<string, unknown>) => Promise<unknown>;
|
|
40
|
+
} | {
|
|
41
|
+
name: string;
|
|
42
|
+
description: string;
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: string;
|
|
45
|
+
properties: {
|
|
46
|
+
slug?: undefined;
|
|
47
|
+
title?: undefined;
|
|
48
|
+
body_md?: undefined;
|
|
49
|
+
q?: undefined;
|
|
50
|
+
};
|
|
51
|
+
required?: undefined;
|
|
52
|
+
};
|
|
53
|
+
handler: () => Promise<unknown>;
|
|
54
|
+
} | {
|
|
55
|
+
name: string;
|
|
56
|
+
description: string;
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: string;
|
|
59
|
+
properties: {
|
|
60
|
+
q: {
|
|
61
|
+
type: string;
|
|
62
|
+
};
|
|
63
|
+
slug?: undefined;
|
|
64
|
+
title?: undefined;
|
|
65
|
+
body_md?: undefined;
|
|
66
|
+
};
|
|
67
|
+
required: string[];
|
|
68
|
+
};
|
|
69
|
+
handler: (args: {
|
|
70
|
+
q: string;
|
|
71
|
+
}) => Promise<unknown>;
|
|
72
|
+
})[];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const requireCloud = (client) => {
|
|
2
|
+
if (!client) {
|
|
3
|
+
throw new Error("This tool requires MODELBOUND_API_KEY. Get one at https://modelbound.co/settings/api-keys");
|
|
4
|
+
}
|
|
5
|
+
return client;
|
|
6
|
+
};
|
|
7
|
+
export function cloudTools(client) {
|
|
8
|
+
return [
|
|
9
|
+
{
|
|
10
|
+
name: "pull_skill",
|
|
11
|
+
description: "Pull a skill from the ModelBound cloud library. Requires MODELBOUND_API_KEY.",
|
|
12
|
+
inputSchema: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: { slug: { type: "string" } },
|
|
15
|
+
required: ["slug"],
|
|
16
|
+
},
|
|
17
|
+
handler: async (args) => requireCloud(client).callTool("get_skill", { slug: args.slug }),
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: "push_skill",
|
|
21
|
+
description: "Create or update a skill in the cloud library. Requires MODELBOUND_API_KEY.",
|
|
22
|
+
inputSchema: {
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
slug: { type: "string" },
|
|
26
|
+
title: { type: "string" },
|
|
27
|
+
body_md: { type: "string" },
|
|
28
|
+
},
|
|
29
|
+
required: ["slug", "body_md"],
|
|
30
|
+
},
|
|
31
|
+
handler: async (args) => requireCloud(client).callTool("sync_file", args),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "list_cloud_skills",
|
|
35
|
+
description: "List skills in the cloud library. Requires MODELBOUND_API_KEY.",
|
|
36
|
+
inputSchema: { type: "object", properties: {} },
|
|
37
|
+
handler: async () => requireCloud(client).callTool("list_skills", {}),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "search_cloud",
|
|
41
|
+
description: "Full-text search across all cloud content. Requires MODELBOUND_API_KEY.",
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: { q: { type: "string" } },
|
|
45
|
+
required: ["q"],
|
|
46
|
+
},
|
|
47
|
+
handler: async (args) => requireCloud(client).callTool("search_all", { query: args.q }),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "install_marketplace_skill",
|
|
51
|
+
description: "Install a public marketplace skill into your library. Requires MODELBOUND_API_KEY.",
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: { slug: { type: "string" } },
|
|
55
|
+
required: ["slug"],
|
|
56
|
+
},
|
|
57
|
+
handler: async (args) => requireCloud(client).callTool("install_skill", { slug: args.slug }),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "get_context_health",
|
|
61
|
+
description: "Get token health scores and staleness for your context library. Requires MODELBOUND_API_KEY.",
|
|
62
|
+
inputSchema: { type: "object", properties: {} },
|
|
63
|
+
handler: async () => requireCloud(client).callTool("get_context_health", {}),
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { CloudClient } from "../proxy.js";
|
|
2
|
+
export declare function localTools(cloud?: CloudClient | null): ({
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
inputSchema: {
|
|
6
|
+
type: string;
|
|
7
|
+
properties: {
|
|
8
|
+
adapter?: undefined;
|
|
9
|
+
path?: undefined;
|
|
10
|
+
contents?: undefined;
|
|
11
|
+
maxTokens?: undefined;
|
|
12
|
+
from?: undefined;
|
|
13
|
+
to?: undefined;
|
|
14
|
+
outPath?: undefined;
|
|
15
|
+
slug?: undefined;
|
|
16
|
+
};
|
|
17
|
+
additionalProperties: boolean;
|
|
18
|
+
required?: undefined;
|
|
19
|
+
};
|
|
20
|
+
handler: (_args: unknown, ctx: {
|
|
21
|
+
cwd: string;
|
|
22
|
+
}) => Promise<{
|
|
23
|
+
cwd: string;
|
|
24
|
+
detected: {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
skillsDir: string;
|
|
28
|
+
fileExt: string;
|
|
29
|
+
}[];
|
|
30
|
+
knownAdapters: string[];
|
|
31
|
+
}>;
|
|
32
|
+
} | {
|
|
33
|
+
name: string;
|
|
34
|
+
description: string;
|
|
35
|
+
inputSchema: {
|
|
36
|
+
type: string;
|
|
37
|
+
properties: {
|
|
38
|
+
adapter: {
|
|
39
|
+
type: string;
|
|
40
|
+
description: string;
|
|
41
|
+
};
|
|
42
|
+
path?: undefined;
|
|
43
|
+
contents?: undefined;
|
|
44
|
+
maxTokens?: undefined;
|
|
45
|
+
from?: undefined;
|
|
46
|
+
to?: undefined;
|
|
47
|
+
outPath?: undefined;
|
|
48
|
+
slug?: undefined;
|
|
49
|
+
};
|
|
50
|
+
additionalProperties: boolean;
|
|
51
|
+
required?: undefined;
|
|
52
|
+
};
|
|
53
|
+
handler: (args: unknown, ctx: {
|
|
54
|
+
cwd: string;
|
|
55
|
+
}) => Promise<{
|
|
56
|
+
count: number;
|
|
57
|
+
files: {
|
|
58
|
+
adapter: string;
|
|
59
|
+
path: string;
|
|
60
|
+
bytes: number;
|
|
61
|
+
}[];
|
|
62
|
+
}>;
|
|
63
|
+
} | {
|
|
64
|
+
name: string;
|
|
65
|
+
description: string;
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: string;
|
|
68
|
+
properties: {
|
|
69
|
+
path: {
|
|
70
|
+
type: string;
|
|
71
|
+
description?: undefined;
|
|
72
|
+
};
|
|
73
|
+
adapter?: undefined;
|
|
74
|
+
contents?: undefined;
|
|
75
|
+
maxTokens?: undefined;
|
|
76
|
+
from?: undefined;
|
|
77
|
+
to?: undefined;
|
|
78
|
+
outPath?: undefined;
|
|
79
|
+
slug?: undefined;
|
|
80
|
+
};
|
|
81
|
+
required: string[];
|
|
82
|
+
additionalProperties: boolean;
|
|
83
|
+
};
|
|
84
|
+
handler: (args: unknown, ctx: {
|
|
85
|
+
cwd: string;
|
|
86
|
+
}) => Promise<{
|
|
87
|
+
path: string;
|
|
88
|
+
contents: string;
|
|
89
|
+
}>;
|
|
90
|
+
} | {
|
|
91
|
+
name: string;
|
|
92
|
+
description: string;
|
|
93
|
+
inputSchema: {
|
|
94
|
+
type: string;
|
|
95
|
+
properties: {
|
|
96
|
+
path: {
|
|
97
|
+
type: string;
|
|
98
|
+
description?: undefined;
|
|
99
|
+
};
|
|
100
|
+
contents: {
|
|
101
|
+
type: string;
|
|
102
|
+
};
|
|
103
|
+
adapter?: undefined;
|
|
104
|
+
maxTokens?: undefined;
|
|
105
|
+
from?: undefined;
|
|
106
|
+
to?: undefined;
|
|
107
|
+
outPath?: undefined;
|
|
108
|
+
slug?: undefined;
|
|
109
|
+
};
|
|
110
|
+
required: string[];
|
|
111
|
+
additionalProperties: boolean;
|
|
112
|
+
};
|
|
113
|
+
handler: (args: unknown, ctx: {
|
|
114
|
+
cwd: string;
|
|
115
|
+
}) => Promise<{
|
|
116
|
+
path: string;
|
|
117
|
+
bytes: number;
|
|
118
|
+
}>;
|
|
119
|
+
} | {
|
|
120
|
+
name: string;
|
|
121
|
+
description: string;
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: string;
|
|
124
|
+
properties: {
|
|
125
|
+
path: {
|
|
126
|
+
type: string;
|
|
127
|
+
description?: undefined;
|
|
128
|
+
};
|
|
129
|
+
maxTokens: {
|
|
130
|
+
type: string;
|
|
131
|
+
};
|
|
132
|
+
adapter?: undefined;
|
|
133
|
+
contents?: undefined;
|
|
134
|
+
from?: undefined;
|
|
135
|
+
to?: undefined;
|
|
136
|
+
outPath?: undefined;
|
|
137
|
+
slug?: undefined;
|
|
138
|
+
};
|
|
139
|
+
required: string[];
|
|
140
|
+
additionalProperties: boolean;
|
|
141
|
+
};
|
|
142
|
+
handler: (args: unknown, ctx: {
|
|
143
|
+
cwd: string;
|
|
144
|
+
}) => Promise<{
|
|
145
|
+
ok: boolean;
|
|
146
|
+
tokens: number;
|
|
147
|
+
issues: import("../lib/lint.js").LintIssue[];
|
|
148
|
+
path: string;
|
|
149
|
+
}>;
|
|
150
|
+
} | {
|
|
151
|
+
name: string;
|
|
152
|
+
description: string;
|
|
153
|
+
inputSchema: {
|
|
154
|
+
type: string;
|
|
155
|
+
properties: {
|
|
156
|
+
path: {
|
|
157
|
+
type: string;
|
|
158
|
+
description?: undefined;
|
|
159
|
+
};
|
|
160
|
+
adapter?: undefined;
|
|
161
|
+
contents?: undefined;
|
|
162
|
+
maxTokens?: undefined;
|
|
163
|
+
from?: undefined;
|
|
164
|
+
to?: undefined;
|
|
165
|
+
outPath?: undefined;
|
|
166
|
+
slug?: undefined;
|
|
167
|
+
};
|
|
168
|
+
required: string[];
|
|
169
|
+
additionalProperties: boolean;
|
|
170
|
+
};
|
|
171
|
+
handler: (args: unknown, ctx: {
|
|
172
|
+
cwd: string;
|
|
173
|
+
}) => Promise<{
|
|
174
|
+
ok: boolean;
|
|
175
|
+
tokens: number;
|
|
176
|
+
issues: import("../lib/lint.js").LintIssue[];
|
|
177
|
+
path: string;
|
|
178
|
+
}>;
|
|
179
|
+
} | {
|
|
180
|
+
name: string;
|
|
181
|
+
description: string;
|
|
182
|
+
inputSchema: {
|
|
183
|
+
type: string;
|
|
184
|
+
properties: {
|
|
185
|
+
path: {
|
|
186
|
+
type: string;
|
|
187
|
+
description?: undefined;
|
|
188
|
+
};
|
|
189
|
+
from: {
|
|
190
|
+
type: string;
|
|
191
|
+
description: string;
|
|
192
|
+
};
|
|
193
|
+
to: {
|
|
194
|
+
type: string;
|
|
195
|
+
description: string;
|
|
196
|
+
};
|
|
197
|
+
outPath: {
|
|
198
|
+
type: string;
|
|
199
|
+
description: string;
|
|
200
|
+
};
|
|
201
|
+
adapter?: undefined;
|
|
202
|
+
contents?: undefined;
|
|
203
|
+
maxTokens?: undefined;
|
|
204
|
+
slug?: undefined;
|
|
205
|
+
};
|
|
206
|
+
required: string[];
|
|
207
|
+
additionalProperties: boolean;
|
|
208
|
+
};
|
|
209
|
+
handler: (args: unknown, ctx: {
|
|
210
|
+
cwd: string;
|
|
211
|
+
}) => Promise<{
|
|
212
|
+
from: string;
|
|
213
|
+
to: string;
|
|
214
|
+
wrote: string;
|
|
215
|
+
bytes: number;
|
|
216
|
+
contents?: undefined;
|
|
217
|
+
} | {
|
|
218
|
+
from: string;
|
|
219
|
+
to: string;
|
|
220
|
+
contents: string;
|
|
221
|
+
wrote?: undefined;
|
|
222
|
+
bytes?: undefined;
|
|
223
|
+
}>;
|
|
224
|
+
} | {
|
|
225
|
+
name: string;
|
|
226
|
+
description: string;
|
|
227
|
+
inputSchema: {
|
|
228
|
+
type: string;
|
|
229
|
+
properties: {
|
|
230
|
+
path: {
|
|
231
|
+
type: string;
|
|
232
|
+
description: string;
|
|
233
|
+
};
|
|
234
|
+
slug: {
|
|
235
|
+
type: string;
|
|
236
|
+
description: string;
|
|
237
|
+
};
|
|
238
|
+
adapter?: undefined;
|
|
239
|
+
contents?: undefined;
|
|
240
|
+
maxTokens?: undefined;
|
|
241
|
+
from?: undefined;
|
|
242
|
+
to?: undefined;
|
|
243
|
+
outPath?: undefined;
|
|
244
|
+
};
|
|
245
|
+
required: string[];
|
|
246
|
+
additionalProperties: boolean;
|
|
247
|
+
};
|
|
248
|
+
handler: (args: unknown, ctx: {
|
|
249
|
+
cwd: string;
|
|
250
|
+
}) => Promise<{
|
|
251
|
+
path: string;
|
|
252
|
+
slug: string;
|
|
253
|
+
local_only: boolean;
|
|
254
|
+
local_bytes: number;
|
|
255
|
+
note: string;
|
|
256
|
+
identical?: undefined;
|
|
257
|
+
remote_bytes?: undefined;
|
|
258
|
+
local?: undefined;
|
|
259
|
+
remote?: undefined;
|
|
260
|
+
} | {
|
|
261
|
+
path: string;
|
|
262
|
+
slug: string;
|
|
263
|
+
identical: boolean;
|
|
264
|
+
local_bytes: number;
|
|
265
|
+
remote_bytes: number;
|
|
266
|
+
local: string;
|
|
267
|
+
remote: any;
|
|
268
|
+
local_only?: undefined;
|
|
269
|
+
note?: undefined;
|
|
270
|
+
}>;
|
|
271
|
+
})[];
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ALL_ADAPTERS, detectAdapters, getAdapter, listAdapterFiles } from "../adapters/index.js";
|
|
5
|
+
import { lintSkill, validateAgentSkillsFormat } from "../lib/lint.js";
|
|
6
|
+
import { CloudClient } from "../proxy.js";
|
|
7
|
+
const inside = (cwd, p) => {
|
|
8
|
+
const abs = path.resolve(cwd, p);
|
|
9
|
+
if (!abs.startsWith(path.resolve(cwd) + path.sep) && abs !== path.resolve(cwd)) {
|
|
10
|
+
throw new Error(`Path escapes working directory: ${p}`);
|
|
11
|
+
}
|
|
12
|
+
return abs;
|
|
13
|
+
};
|
|
14
|
+
export function localTools(cloud = CloudClient.fromEnv()) {
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
name: "detect_ide_layout",
|
|
18
|
+
description: "Detect which IDE skill/rule layouts exist in the current working directory. Returns the list of matching adapters (e.g. cursor, claude, kiro). Local-only, no network.",
|
|
19
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
20
|
+
handler: async (_args, ctx) => {
|
|
21
|
+
const adapters = detectAdapters(ctx.cwd);
|
|
22
|
+
return {
|
|
23
|
+
cwd: ctx.cwd,
|
|
24
|
+
detected: adapters.map((a) => ({
|
|
25
|
+
id: a.id,
|
|
26
|
+
name: a.name,
|
|
27
|
+
skillsDir: a.skillsDir,
|
|
28
|
+
fileExt: a.fileExt,
|
|
29
|
+
})),
|
|
30
|
+
knownAdapters: ALL_ADAPTERS.map((a) => a.id),
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "list_local_skills",
|
|
36
|
+
description: "List all skill / rule files found in the detected IDE directories under the current working directory.",
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: { adapter: { type: "string", description: "Optional adapter id to scope the listing." } },
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
},
|
|
42
|
+
handler: async (args, ctx) => {
|
|
43
|
+
const { adapter } = z.object({ adapter: z.string().optional() }).parse(args ?? {});
|
|
44
|
+
const adapters = adapter ? [getAdapter(adapter)].filter(Boolean) : detectAdapters(ctx.cwd);
|
|
45
|
+
const files = [];
|
|
46
|
+
for (const a of adapters) {
|
|
47
|
+
if (!a)
|
|
48
|
+
continue;
|
|
49
|
+
for (const abs of listAdapterFiles(a, ctx.cwd)) {
|
|
50
|
+
files.push({
|
|
51
|
+
adapter: a.id,
|
|
52
|
+
path: path.relative(ctx.cwd, abs),
|
|
53
|
+
bytes: fs.statSync(abs).size,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { count: files.length, files };
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "read_local_skill",
|
|
62
|
+
description: "Read a local skill file (raw contents). Path must be inside the current working directory.",
|
|
63
|
+
inputSchema: {
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: { path: { type: "string" } },
|
|
66
|
+
required: ["path"],
|
|
67
|
+
additionalProperties: false,
|
|
68
|
+
},
|
|
69
|
+
handler: async (args, ctx) => {
|
|
70
|
+
const { path: p } = z.object({ path: z.string().min(1) }).parse(args);
|
|
71
|
+
const abs = inside(ctx.cwd, p);
|
|
72
|
+
return { path: p, contents: fs.readFileSync(abs, "utf8") };
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "write_local_skill",
|
|
77
|
+
description: "Write a local skill file. Creates parent directories. Path must be inside the current working directory.",
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: "object",
|
|
80
|
+
properties: { path: { type: "string" }, contents: { type: "string" } },
|
|
81
|
+
required: ["path", "contents"],
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
},
|
|
84
|
+
handler: async (args, ctx) => {
|
|
85
|
+
const { path: p, contents } = z
|
|
86
|
+
.object({ path: z.string().min(1), contents: z.string() })
|
|
87
|
+
.parse(args);
|
|
88
|
+
const abs = inside(ctx.cwd, p);
|
|
89
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
90
|
+
fs.writeFileSync(abs, contents, "utf8");
|
|
91
|
+
return { path: p, bytes: Buffer.byteLength(contents, "utf8") };
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: "lint_skill",
|
|
96
|
+
description: "Lint a skill file: front-matter, token count, broken links, TODO markers. Local-only.",
|
|
97
|
+
inputSchema: {
|
|
98
|
+
type: "object",
|
|
99
|
+
properties: { path: { type: "string" }, maxTokens: { type: "number" } },
|
|
100
|
+
required: ["path"],
|
|
101
|
+
additionalProperties: false,
|
|
102
|
+
},
|
|
103
|
+
handler: async (args, ctx) => {
|
|
104
|
+
const { path: p, maxTokens } = z
|
|
105
|
+
.object({ path: z.string().min(1), maxTokens: z.number().optional() })
|
|
106
|
+
.parse(args);
|
|
107
|
+
const abs = inside(ctx.cwd, p);
|
|
108
|
+
const raw = fs.readFileSync(abs, "utf8");
|
|
109
|
+
return { path: p, ...lintSkill(raw, { maxTokens }) };
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: "validate_skill_format",
|
|
114
|
+
description: "Validate a skill file against the agentskills.io standard.",
|
|
115
|
+
inputSchema: {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: { path: { type: "string" } },
|
|
118
|
+
required: ["path"],
|
|
119
|
+
additionalProperties: false,
|
|
120
|
+
},
|
|
121
|
+
handler: async (args, ctx) => {
|
|
122
|
+
const { path: p } = z.object({ path: z.string().min(1) }).parse(args);
|
|
123
|
+
const abs = inside(ctx.cwd, p);
|
|
124
|
+
return { path: p, ...validateAgentSkillsFormat(fs.readFileSync(abs, "utf8")) };
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: "convert_skill",
|
|
129
|
+
description: "Convert a skill file from one IDE format to another (e.g. cursor → claude). Round-trips through a canonical {frontmatter, body} representation.",
|
|
130
|
+
inputSchema: {
|
|
131
|
+
type: "object",
|
|
132
|
+
properties: {
|
|
133
|
+
path: { type: "string" },
|
|
134
|
+
from: { type: "string", description: "Source adapter id (e.g. cursor)" },
|
|
135
|
+
to: { type: "string", description: "Target adapter id (e.g. claude)" },
|
|
136
|
+
outPath: { type: "string", description: "Optional output path. If omitted, returns the converted body without writing." },
|
|
137
|
+
},
|
|
138
|
+
required: ["path", "from", "to"],
|
|
139
|
+
additionalProperties: false,
|
|
140
|
+
},
|
|
141
|
+
handler: async (args, ctx) => {
|
|
142
|
+
const { path: p, from, to, outPath } = z
|
|
143
|
+
.object({
|
|
144
|
+
path: z.string().min(1),
|
|
145
|
+
from: z.string(),
|
|
146
|
+
to: z.string(),
|
|
147
|
+
outPath: z.string().optional(),
|
|
148
|
+
})
|
|
149
|
+
.parse(args);
|
|
150
|
+
const src = getAdapter(from);
|
|
151
|
+
const dst = getAdapter(to);
|
|
152
|
+
if (!src)
|
|
153
|
+
throw new Error(`Unknown source adapter: ${from}`);
|
|
154
|
+
if (!dst)
|
|
155
|
+
throw new Error(`Unknown target adapter: ${to}`);
|
|
156
|
+
const abs = inside(ctx.cwd, p);
|
|
157
|
+
const raw = fs.readFileSync(abs, "utf8");
|
|
158
|
+
const canonical = src.toCanonical(raw);
|
|
159
|
+
const converted = dst.fromCanonical(canonical);
|
|
160
|
+
if (outPath) {
|
|
161
|
+
const outAbs = inside(ctx.cwd, outPath);
|
|
162
|
+
fs.mkdirSync(path.dirname(outAbs), { recursive: true });
|
|
163
|
+
fs.writeFileSync(outAbs, converted, "utf8");
|
|
164
|
+
return { from, to, wrote: outPath, bytes: Buffer.byteLength(converted, "utf8") };
|
|
165
|
+
}
|
|
166
|
+
return { from, to, contents: converted };
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: "diff_skill",
|
|
171
|
+
description: "Diff a local skill file against its cloud counterpart by slug. Local-side diff is computed here; the cloud half requires MODELBOUND_API_KEY.",
|
|
172
|
+
inputSchema: {
|
|
173
|
+
type: "object",
|
|
174
|
+
properties: {
|
|
175
|
+
path: { type: "string", description: "Local skill file path." },
|
|
176
|
+
slug: { type: "string", description: "Cloud skill slug to compare against." },
|
|
177
|
+
},
|
|
178
|
+
required: ["path", "slug"],
|
|
179
|
+
additionalProperties: false,
|
|
180
|
+
},
|
|
181
|
+
handler: async (args, ctx) => {
|
|
182
|
+
const { path: p, slug } = z
|
|
183
|
+
.object({ path: z.string().min(1), slug: z.string().min(1) })
|
|
184
|
+
.parse(args);
|
|
185
|
+
const abs = inside(ctx.cwd, p);
|
|
186
|
+
const local = fs.readFileSync(abs, "utf8");
|
|
187
|
+
if (!cloud) {
|
|
188
|
+
return {
|
|
189
|
+
path: p,
|
|
190
|
+
slug,
|
|
191
|
+
local_only: true,
|
|
192
|
+
local_bytes: Buffer.byteLength(local, "utf8"),
|
|
193
|
+
note: "MODELBOUND_API_KEY not set; returning local file only.",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const remote = await cloud.callTool("get_skill", { slug });
|
|
197
|
+
const remoteBody = typeof remote === "string"
|
|
198
|
+
? remote
|
|
199
|
+
: remote?.body_md ?? JSON.stringify(remote, null, 2);
|
|
200
|
+
return {
|
|
201
|
+
path: p,
|
|
202
|
+
slug,
|
|
203
|
+
identical: local.trim() === String(remoteBody).trim(),
|
|
204
|
+
local_bytes: Buffer.byteLength(local, "utf8"),
|
|
205
|
+
remote_bytes: Buffer.byteLength(String(remoteBody), "utf8"),
|
|
206
|
+
local,
|
|
207
|
+
remote: remoteBody,
|
|
208
|
+
};
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
];
|
|
212
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "modelbound-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local-first MCP server for agent skills. Validate, lint, diff, and convert skills across Cursor, Claude, Kiro, Windsurf, VS Code, and Amazon Q. Optional cloud sync with ModelBound.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"modelbound-mcp": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc -p tsconfig.json && chmod +x dist/cli.js dist/index.js",
|
|
17
|
+
"dev": "tsx src/index.ts",
|
|
18
|
+
"test": "node --test --import tsx ./src/**/*.test.ts",
|
|
19
|
+
"prepublishOnly": "npm run build"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"model-context-protocol",
|
|
24
|
+
"agent-skills",
|
|
25
|
+
"cursor",
|
|
26
|
+
"claude",
|
|
27
|
+
"kiro",
|
|
28
|
+
"windsurf",
|
|
29
|
+
"modelbound"
|
|
30
|
+
],
|
|
31
|
+
"author": "ModelBound",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/ModelBound/modelbound-mcp-server.git"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://modelbound.co/open-source",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/ModelBound/modelbound-mcp-server/issues"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=20"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
46
|
+
"gray-matter": "^4.0.3",
|
|
47
|
+
"zod": "^3.23.8"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^20.14.0",
|
|
51
|
+
"tsx": "^4.19.0",
|
|
52
|
+
"typescript": "^5.5.0"
|
|
53
|
+
}
|
|
54
|
+
}
|