@mrfentmen/rascal-mcp 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -2
- package/dist/config.d.ts +2 -0
- package/dist/config.js +54 -0
- package/dist/core.d.ts +3 -0
- package/dist/core.js +702 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +13 -0
- package/dist/run.d.ts +28 -0
- package/dist/run.js +56 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +8 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.js +1 -0
- package/package.json +42 -4
- package/server.json +20 -0
package/README.md
CHANGED
|
@@ -1,3 +1,61 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Rascal MCP
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Rascal had no dedicated MCP in the checked catalogs. This server gives agents 20 real tools instead of a stub.
|
|
4
|
+
|
|
5
|
+
Requires `the language toolchain` for execution. The source, lint, symbol and scaffold tools work without it.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx -y @mrfentmen/rascal-mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or in an MCP client:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"mcpServers": {
|
|
18
|
+
"rascal-mcp": {
|
|
19
|
+
"command": "npx",
|
|
20
|
+
"args": ["-y", "@mrfentmen/rascal-mcp"]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Tools
|
|
27
|
+
|
|
28
|
+
| Tool | What it does |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `detect_project` | Find language files, project markers and layout in a directory. |
|
|
31
|
+
| `read_source` | Read a source file or a line range. |
|
|
32
|
+
| `write_source` | Write a source file, with overwrite protection. |
|
|
33
|
+
| `syntax_check` | Check brackets, strings and comments; run the parser when configured. |
|
|
34
|
+
| `format_source` | Trim whitespace, normalize line endings and add a final newline. |
|
|
35
|
+
| `lint_source` | Find trailing whitespace, tabs, long lines and unbalanced structure. |
|
|
36
|
+
| `extract_symbols` | List functions, classes, types and other definitions. |
|
|
37
|
+
| `list_imports` | List imports, includes and dependencies. |
|
|
38
|
+
| `list_todos` | Find TODO, FIXME, HACK and XXX comments. |
|
|
39
|
+
| `find_references` | Find every mention of a symbol in one file. |
|
|
40
|
+
| `rename_symbol` | Rename a symbol in one file, with a dry-run default. |
|
|
41
|
+
| `insert_snippet` | Insert a language-specific starter snippet. |
|
|
42
|
+
| `comment_lines` | Comment a line range using the language marker. |
|
|
43
|
+
| `uncomment_lines` | Remove the comment marker from a line range. |
|
|
44
|
+
| `scaffold_project` | Create a small runnable starter project. |
|
|
45
|
+
| `run_source` | Run a file with the installed interpreter or compiler. |
|
|
46
|
+
| `run_tests` | Run the configured test command. |
|
|
47
|
+
| `build_project` | Build a project with the configured compiler command. |
|
|
48
|
+
| `toolchain_status` | Show which local compilers and tools are installed. |
|
|
49
|
+
| `language_reference` | Return a compact syntax and toolchain reference. |
|
|
50
|
+
|
|
51
|
+
## Safety
|
|
52
|
+
|
|
53
|
+
This server reads and writes local source files and runs local compiler commands. Treat project files and commands as trusted input.
|
|
54
|
+
|
|
55
|
+
## Run locally
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npm install
|
|
59
|
+
npm run build
|
|
60
|
+
node dist/index.js
|
|
61
|
+
```
|
package/dist/config.d.ts
ADDED
package/dist/config.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export const config = {
|
|
2
|
+
"slug": "rascal",
|
|
3
|
+
"displayName": "Rascal",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"extensions": [
|
|
6
|
+
".rsc"
|
|
7
|
+
],
|
|
8
|
+
"markers": [],
|
|
9
|
+
"comment": "//",
|
|
10
|
+
"stringQuotes": [
|
|
11
|
+
"\""
|
|
12
|
+
],
|
|
13
|
+
"symbols": [
|
|
14
|
+
{
|
|
15
|
+
"kind": "module",
|
|
16
|
+
"pattern": "^\\s*module\\s+([A-Za-z_][A-Za-z0-9_]*)"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"kind": "function",
|
|
20
|
+
"pattern": "^\\s*(?:public\\s+)?[A-Za-z_][A-Za-z0-9_<>\\[\\]]*\\s+([a-z][A-Za-z0-9_]*)\\s*\\("
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"kind": "data",
|
|
24
|
+
"pattern": "^\\s*data\\s+([A-Za-z_][A-Za-z0-9_]*)"
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"imports": [
|
|
28
|
+
{
|
|
29
|
+
"kind": "import",
|
|
30
|
+
"pattern": "^\\s*import\\s+([A-Za-z_][A-Za-z0-9_:]*)"
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"scaffold": [
|
|
34
|
+
{
|
|
35
|
+
"path": "src/demo.rsc",
|
|
36
|
+
"content": "module demo\n\npublic int add(int a, int b) = a + b;\n"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"path": "tests/run.rsc",
|
|
40
|
+
"content": "module demo\n\npublic int add(int a, int b) = a + b;\n"
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"snippets": {
|
|
44
|
+
"function": {
|
|
45
|
+
"description": "function",
|
|
46
|
+
"body": "module name\n\npublic int value(int a) = a + 1;"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"reference": "Rascal syntax reference\n- Module: module name\n- Function: public int value(int a) = a + 1;\n- Data: data Name = name(int value);\n- Import: import List;\n- Variable: int value = 1;\n- Comment: // line; /* block */\n- Run: rascal file.rsc",
|
|
50
|
+
"blockComment": {
|
|
51
|
+
"start": "/*",
|
|
52
|
+
"end": "*/"
|
|
53
|
+
}
|
|
54
|
+
};
|
package/dist/core.d.ts
ADDED
package/dist/core.js
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { errorMessage, fail, run, text, ToolError } from "./run.js";
|
|
5
|
+
const MAX_INPUT = 2_000_000;
|
|
6
|
+
const DEFAULT_TIMEOUT = 30_000;
|
|
7
|
+
const SKIP_DIRS = new Set([".git", ".mcp-build", "build", "dist", "node_modules", "vendor"]);
|
|
8
|
+
function resolveDir(value) {
|
|
9
|
+
return path.resolve(value && value.length > 0 ? value : process.cwd());
|
|
10
|
+
}
|
|
11
|
+
function resolveFile(file, cwd) {
|
|
12
|
+
if (file.includes("\0"))
|
|
13
|
+
throw new ToolError("Path contains a null byte");
|
|
14
|
+
return path.isAbsolute(file) ? file : path.resolve(cwd, file);
|
|
15
|
+
}
|
|
16
|
+
function escapeRegExp(value) {
|
|
17
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
18
|
+
}
|
|
19
|
+
async function fileExists(file) {
|
|
20
|
+
try {
|
|
21
|
+
await fs.stat(file);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function readFileSafe(file) {
|
|
29
|
+
const stat = await fs.stat(file);
|
|
30
|
+
if (!stat.isFile())
|
|
31
|
+
throw new ToolError(`Not a file: ${file}`);
|
|
32
|
+
if (stat.size > MAX_INPUT)
|
|
33
|
+
throw new ToolError(`File is larger than ${MAX_INPUT} bytes`);
|
|
34
|
+
return fs.readFile(file, "utf8");
|
|
35
|
+
}
|
|
36
|
+
async function loadSource(input) {
|
|
37
|
+
const cwd = resolveDir(input.cwd);
|
|
38
|
+
if (input.path && input.content !== undefined) {
|
|
39
|
+
throw new ToolError("Use path or content, not both");
|
|
40
|
+
}
|
|
41
|
+
if (input.content !== undefined) {
|
|
42
|
+
return { content: input.content, cwd };
|
|
43
|
+
}
|
|
44
|
+
if (!input.path)
|
|
45
|
+
throw new ToolError("Provide a source path or content");
|
|
46
|
+
const file = resolveFile(input.path, cwd);
|
|
47
|
+
return { content: await readFileSafe(file), file, cwd: path.dirname(file) };
|
|
48
|
+
}
|
|
49
|
+
function commandContext(file, cwd, config) {
|
|
50
|
+
const ext = path.extname(file);
|
|
51
|
+
return {
|
|
52
|
+
file,
|
|
53
|
+
dir: cwd,
|
|
54
|
+
build: path.join(cwd, config.buildDir ?? ".mcp-build"),
|
|
55
|
+
stem: path.basename(file, ext),
|
|
56
|
+
ext,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function expandCommand(spec, ctx) {
|
|
60
|
+
const values = {
|
|
61
|
+
file: ctx.file,
|
|
62
|
+
dir: ctx.dir,
|
|
63
|
+
build: ctx.build,
|
|
64
|
+
stem: ctx.stem,
|
|
65
|
+
ext: ctx.ext,
|
|
66
|
+
};
|
|
67
|
+
const replace = (value) => value.replace(/\{(\w+)\}/g, (whole, key) => values[key] ?? whole);
|
|
68
|
+
return {
|
|
69
|
+
command: replace(spec.command),
|
|
70
|
+
args: spec.args.map(replace),
|
|
71
|
+
timeoutMs: spec.timeoutMs,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async function commandAvailable(command) {
|
|
75
|
+
const result = await run("which", [command], { timeoutMs: 5_000 });
|
|
76
|
+
return result.code === 0;
|
|
77
|
+
}
|
|
78
|
+
async function runSteps(steps, ctx, cwd, timeoutMs) {
|
|
79
|
+
let output = "";
|
|
80
|
+
for (const step of steps) {
|
|
81
|
+
const expanded = expandCommand(step, ctx);
|
|
82
|
+
const result = await run(expanded.command, expanded.args, {
|
|
83
|
+
cwd,
|
|
84
|
+
timeoutMs: step.timeoutMs ?? timeoutMs,
|
|
85
|
+
});
|
|
86
|
+
output += result.stdout;
|
|
87
|
+
if (result.stderr.trim().length > 0) {
|
|
88
|
+
if (output.length > 0 && !output.endsWith("\n"))
|
|
89
|
+
output += "\n";
|
|
90
|
+
output += result.stderr;
|
|
91
|
+
}
|
|
92
|
+
if (result.code !== 0) {
|
|
93
|
+
throw new ToolError((result.stderr || result.stdout || `${expanded.command} exited with code ${result.code}`).trim());
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return output;
|
|
97
|
+
}
|
|
98
|
+
async function pickWorkflow(steps) {
|
|
99
|
+
if (steps.length === 0)
|
|
100
|
+
throw new ToolError("No command is configured");
|
|
101
|
+
if (await commandAvailable(steps[0].command))
|
|
102
|
+
return steps;
|
|
103
|
+
throw new ToolError(`${steps[0].command} is not installed or not on PATH. Install one of: ${[
|
|
104
|
+
...new Set(steps.map((step) => step.command)),
|
|
105
|
+
].join(", ")}`);
|
|
106
|
+
}
|
|
107
|
+
async function ensureBuildDir(ctx) {
|
|
108
|
+
await fs.mkdir(ctx.build, { recursive: true });
|
|
109
|
+
}
|
|
110
|
+
function scanStructure(content, config) {
|
|
111
|
+
const issues = [];
|
|
112
|
+
const stack = [];
|
|
113
|
+
const pairs = { ")": "(", "]": "[", "}": "{" };
|
|
114
|
+
const opens = new Set(["(", "[", "{"]);
|
|
115
|
+
const quotes = config.stringQuotes ?? ['"'];
|
|
116
|
+
let inString = null;
|
|
117
|
+
let stringLine = 0;
|
|
118
|
+
let escaped = false;
|
|
119
|
+
let line = 1;
|
|
120
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
121
|
+
const char = content[index];
|
|
122
|
+
const rest = content.slice(index);
|
|
123
|
+
if (inString) {
|
|
124
|
+
if (escaped) {
|
|
125
|
+
escaped = false;
|
|
126
|
+
}
|
|
127
|
+
else if (char === "\\") {
|
|
128
|
+
escaped = true;
|
|
129
|
+
}
|
|
130
|
+
else if (char === inString) {
|
|
131
|
+
inString = null;
|
|
132
|
+
}
|
|
133
|
+
if (char === "\n")
|
|
134
|
+
line += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (config.blockComment && rest.startsWith(config.blockComment.start)) {
|
|
138
|
+
const end = content.indexOf(config.blockComment.end, index + config.blockComment.start.length);
|
|
139
|
+
if (end === -1) {
|
|
140
|
+
issues.push(`Line ${line}: unclosed block comment`);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
const skipped = content.slice(index, end + config.blockComment.end.length);
|
|
144
|
+
line += (skipped.match(/\n/g) ?? []).length;
|
|
145
|
+
index = end + config.blockComment.end.length - 1;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (config.comment && rest.startsWith(config.comment)) {
|
|
149
|
+
const newline = content.indexOf("\n", index);
|
|
150
|
+
if (newline === -1)
|
|
151
|
+
break;
|
|
152
|
+
index = newline;
|
|
153
|
+
line += 1;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (quotes.includes(char)) {
|
|
157
|
+
inString = char;
|
|
158
|
+
stringLine = line;
|
|
159
|
+
escaped = false;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (char === "\n")
|
|
163
|
+
line += 1;
|
|
164
|
+
if (opens.has(char)) {
|
|
165
|
+
stack.push({ char, line });
|
|
166
|
+
}
|
|
167
|
+
else if (char in pairs) {
|
|
168
|
+
const open = stack.pop();
|
|
169
|
+
if (!open) {
|
|
170
|
+
issues.push(`Line ${line}: unexpected ${char}`);
|
|
171
|
+
}
|
|
172
|
+
else if (open.char !== pairs[char]) {
|
|
173
|
+
issues.push(`Line ${line}: ${char} closes ${open.char} from line ${open.line}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (inString)
|
|
178
|
+
issues.push(`Line ${stringLine}: unclosed string`);
|
|
179
|
+
for (const item of stack.slice(-5))
|
|
180
|
+
issues.push(`Line ${item.line}: unclosed ${item.char}`);
|
|
181
|
+
return issues;
|
|
182
|
+
}
|
|
183
|
+
function cleanFormat(content, expandTabs, indentWidth) {
|
|
184
|
+
const normalized = content.replace(/\r\n?/g, "\n");
|
|
185
|
+
const lines = normalized.split("\n").map((line) => {
|
|
186
|
+
const noTrailing = line.replace(/[ \t]+$/, "");
|
|
187
|
+
return expandTabs ? noTrailing.replace(/\t/g, " ".repeat(indentWidth)) : noTrailing;
|
|
188
|
+
});
|
|
189
|
+
const body = lines.join("\n").replace(/\n+$/, "");
|
|
190
|
+
return body.length > 0 ? `${body}\n` : "";
|
|
191
|
+
}
|
|
192
|
+
async function scanProject(dir, config, maxDepth) {
|
|
193
|
+
const scan = { files: 0, byExtension: {}, markers: {}, samples: [] };
|
|
194
|
+
const root = path.resolve(dir);
|
|
195
|
+
async function walk(current, depth) {
|
|
196
|
+
if (depth > maxDepth || scan.files >= 2_000)
|
|
197
|
+
return;
|
|
198
|
+
let entries;
|
|
199
|
+
try {
|
|
200
|
+
entries = await fs.readdir(current, { withFileTypes: true });
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
for (const entry of entries) {
|
|
206
|
+
if (entry.name.startsWith(".") && entry.name !== ".env")
|
|
207
|
+
continue;
|
|
208
|
+
const full = path.join(current, entry.name);
|
|
209
|
+
if (entry.isDirectory()) {
|
|
210
|
+
if (SKIP_DIRS.has(entry.name))
|
|
211
|
+
continue;
|
|
212
|
+
await walk(full, depth + 1);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (!entry.isFile())
|
|
216
|
+
continue;
|
|
217
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
218
|
+
const isMarker = config.markers.includes(entry.name);
|
|
219
|
+
if (!config.extensions.includes(ext) && !isMarker)
|
|
220
|
+
continue;
|
|
221
|
+
scan.files += 1;
|
|
222
|
+
if (ext)
|
|
223
|
+
scan.byExtension[ext] = (scan.byExtension[ext] ?? 0) + 1;
|
|
224
|
+
if (isMarker)
|
|
225
|
+
scan.markers[entry.name] = (scan.markers[entry.name] ?? 0) + 1;
|
|
226
|
+
if (scan.samples.length < 20)
|
|
227
|
+
scan.samples.push(path.relative(root, full));
|
|
228
|
+
if (scan.files >= 2_000)
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
await walk(root, 0);
|
|
233
|
+
return scan;
|
|
234
|
+
}
|
|
235
|
+
export function registerLanguageTools(server, config) {
|
|
236
|
+
const sourceArgs = {
|
|
237
|
+
path: z.string().min(1).max(2_000).optional(),
|
|
238
|
+
content: z.string().max(MAX_INPUT).optional(),
|
|
239
|
+
cwd: z.string().min(1).max(2_000).optional(),
|
|
240
|
+
};
|
|
241
|
+
const timeout = z.number().int().min(1_000).max(120_000).default(DEFAULT_TIMEOUT);
|
|
242
|
+
server.tool("detect_project", `Find ${config.displayName} files, project markers and the toolchain layout in a directory.`, {
|
|
243
|
+
path: z.string().min(1).max(2_000).default("."),
|
|
244
|
+
maxDepth: z.number().int().min(0).max(8).default(3),
|
|
245
|
+
}, async (a) => {
|
|
246
|
+
try {
|
|
247
|
+
const root = resolveDir(a.path);
|
|
248
|
+
const stat = await fs.stat(root);
|
|
249
|
+
if (!stat.isDirectory())
|
|
250
|
+
throw new ToolError(`Not a directory: ${root}`);
|
|
251
|
+
const scan = await scanProject(root, config, a.maxDepth);
|
|
252
|
+
return text(JSON.stringify({
|
|
253
|
+
language: config.displayName,
|
|
254
|
+
root,
|
|
255
|
+
extensions: config.extensions,
|
|
256
|
+
markers: config.markers,
|
|
257
|
+
files: scan.files,
|
|
258
|
+
byExtension: scan.byExtension,
|
|
259
|
+
markerFiles: scan.markers,
|
|
260
|
+
samples: scan.samples,
|
|
261
|
+
}, null, 2));
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
return fail(errorMessage(error));
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
server.tool("read_source", `Read a ${config.displayName} source file, optionally a line range.`, {
|
|
268
|
+
path: z.string().min(1).max(2_000),
|
|
269
|
+
startLine: z.number().int().min(1).default(1),
|
|
270
|
+
endLine: z.number().int().min(1).optional(),
|
|
271
|
+
cwd: z.string().min(1).max(2_000).optional(),
|
|
272
|
+
}, async (a) => {
|
|
273
|
+
try {
|
|
274
|
+
const file = resolveFile(a.path, resolveDir(a.cwd));
|
|
275
|
+
const content = await readFileSafe(file);
|
|
276
|
+
const lines = content.split("\n");
|
|
277
|
+
const end = Math.min(a.endLine ?? lines.length, lines.length);
|
|
278
|
+
const body = lines.slice(a.startLine - 1, end).join("\n");
|
|
279
|
+
return text(body);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
return fail(errorMessage(error));
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
server.tool("write_source", `Write a ${config.displayName} source file. Refuses to overwrite unless overwrite is true.`, {
|
|
286
|
+
path: z.string().min(1).max(2_000),
|
|
287
|
+
content: z.string().max(MAX_INPUT),
|
|
288
|
+
overwrite: z.boolean().default(false),
|
|
289
|
+
createDirs: z.boolean().default(true),
|
|
290
|
+
cwd: z.string().min(1).max(2_000).optional(),
|
|
291
|
+
}, async (a) => {
|
|
292
|
+
try {
|
|
293
|
+
const file = resolveFile(a.path, resolveDir(a.cwd));
|
|
294
|
+
if (!a.overwrite && (await fileExists(file))) {
|
|
295
|
+
throw new ToolError(`File already exists: ${file}. Set overwrite=true to replace it.`);
|
|
296
|
+
}
|
|
297
|
+
if (a.createDirs)
|
|
298
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
299
|
+
await fs.writeFile(file, a.content, "utf8");
|
|
300
|
+
return text(`Wrote ${Buffer.byteLength(a.content)} bytes to ${file}`);
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
return fail(errorMessage(error));
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
server.tool("syntax_check", `Check ${config.displayName} structure and, when configured, run the real parser without executing the program.`, {
|
|
307
|
+
...sourceArgs,
|
|
308
|
+
}, async (a) => {
|
|
309
|
+
try {
|
|
310
|
+
const source = await loadSource(a);
|
|
311
|
+
const issues = scanStructure(source.content, config);
|
|
312
|
+
let parserChecked = false;
|
|
313
|
+
if (config.syntax && source.file) {
|
|
314
|
+
const ctx = commandContext(source.file, source.cwd, config);
|
|
315
|
+
const command = expandCommand(config.syntax, ctx);
|
|
316
|
+
const result = await run(command.command, command.args, {
|
|
317
|
+
cwd: source.cwd,
|
|
318
|
+
timeoutMs: DEFAULT_TIMEOUT,
|
|
319
|
+
});
|
|
320
|
+
parserChecked = result.code === 0;
|
|
321
|
+
if (result.code !== 0) {
|
|
322
|
+
issues.push((result.stderr || result.stdout || "parser rejected the file").trim());
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (issues.length > 0)
|
|
326
|
+
throw new ToolError(issues.join("\n"));
|
|
327
|
+
return text(parserChecked ? "Syntax OK (parser accepted the file)" : "Syntax OK (structure check)");
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
return fail(errorMessage(error));
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
server.tool("format_source", `Trim trailing whitespace, normalize line endings and ensure a final newline in ${config.displayName} code.`, {
|
|
334
|
+
...sourceArgs,
|
|
335
|
+
expandTabs: z.boolean().default(false),
|
|
336
|
+
indentWidth: z.number().int().min(2).max(8).default(2),
|
|
337
|
+
write: z.boolean().default(false),
|
|
338
|
+
}, async (a) => {
|
|
339
|
+
try {
|
|
340
|
+
const source = await loadSource(a);
|
|
341
|
+
const formatted = cleanFormat(source.content, a.expandTabs, a.indentWidth);
|
|
342
|
+
if (a.write && source.file) {
|
|
343
|
+
await fs.writeFile(source.file, formatted, "utf8");
|
|
344
|
+
return text(`Formatted ${source.file}`);
|
|
345
|
+
}
|
|
346
|
+
return text(formatted);
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
return fail(errorMessage(error));
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
server.tool("lint_source", `Check ${config.displayName} code for trailing whitespace, tabs, long lines, missing final newline and unbalanced structure.`, {
|
|
353
|
+
...sourceArgs,
|
|
354
|
+
maxLineLength: z.number().int().min(40).max(400).default(120),
|
|
355
|
+
}, async (a) => {
|
|
356
|
+
try {
|
|
357
|
+
const source = await loadSource(a);
|
|
358
|
+
const issues = [];
|
|
359
|
+
const lines = source.content.replace(/\r\n?/g, "\n").split("\n");
|
|
360
|
+
lines.forEach((line, index) => {
|
|
361
|
+
if (/[ \t]+$/.test(line))
|
|
362
|
+
issues.push(`Line ${index + 1}: trailing whitespace`);
|
|
363
|
+
if (!config.allowTabs && line.includes("\t"))
|
|
364
|
+
issues.push(`Line ${index + 1}: tab character`);
|
|
365
|
+
if (line.length > a.maxLineLength) {
|
|
366
|
+
issues.push(`Line ${index + 1}: ${line.length} characters (max ${a.maxLineLength})`);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
if (source.content.length > 0 && !source.content.endsWith("\n")) {
|
|
370
|
+
issues.push("File does not end with a newline");
|
|
371
|
+
}
|
|
372
|
+
issues.push(...scanStructure(source.content, config));
|
|
373
|
+
if (issues.length === 0)
|
|
374
|
+
return text("No lint issues");
|
|
375
|
+
return text(issues.join("\n"));
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
return fail(errorMessage(error));
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
server.tool("extract_symbols", `List functions, classes, types and other ${config.displayName} definitions with line numbers.`, {
|
|
382
|
+
...sourceArgs,
|
|
383
|
+
}, async (a) => {
|
|
384
|
+
try {
|
|
385
|
+
const source = await loadSource(a);
|
|
386
|
+
const found = [];
|
|
387
|
+
const lines = source.content.split("\n");
|
|
388
|
+
for (const rule of config.symbols) {
|
|
389
|
+
const regex = new RegExp(rule.pattern, rule.flags ?? "i");
|
|
390
|
+
lines.forEach((line, index) => {
|
|
391
|
+
const match = regex.exec(line);
|
|
392
|
+
if (match)
|
|
393
|
+
found.push(`Line ${index + 1}: ${rule.kind} ${match[1] ?? match[0]}`);
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return text(found.length > 0 ? found.join("\n") : "No symbols found");
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
return fail(errorMessage(error));
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
server.tool("list_imports", `List imports, includes and dependencies in a ${config.displayName} source file.`, {
|
|
403
|
+
...sourceArgs,
|
|
404
|
+
}, async (a) => {
|
|
405
|
+
try {
|
|
406
|
+
const source = await loadSource(a);
|
|
407
|
+
const found = [];
|
|
408
|
+
const lines = source.content.split("\n");
|
|
409
|
+
for (const rule of config.imports) {
|
|
410
|
+
const regex = new RegExp(rule.pattern, rule.flags ?? "i");
|
|
411
|
+
lines.forEach((line, index) => {
|
|
412
|
+
const match = regex.exec(line);
|
|
413
|
+
if (match)
|
|
414
|
+
found.push(`Line ${index + 1}: ${rule.kind} ${match[1] ?? match[0]}`);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
return text(found.length > 0 ? found.join("\n") : "No imports found");
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
return fail(errorMessage(error));
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
server.tool("list_todos", `Find TODO, FIXME, HACK and XXX comments in ${config.displayName} code.`, {
|
|
424
|
+
...sourceArgs,
|
|
425
|
+
}, async (a) => {
|
|
426
|
+
try {
|
|
427
|
+
const source = await loadSource(a);
|
|
428
|
+
const found = [];
|
|
429
|
+
source.content.split("\n").forEach((line, index) => {
|
|
430
|
+
if (/\b(TODO|FIXME|HACK|XXX)\b/i.test(line))
|
|
431
|
+
found.push(`Line ${index + 1}: ${line.trim()}`);
|
|
432
|
+
});
|
|
433
|
+
return text(found.length > 0 ? found.join("\n") : "No TODO comments found");
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
return fail(errorMessage(error));
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
server.tool("find_references", `Find every mention of a symbol in one ${config.displayName} source file.`, {
|
|
440
|
+
path: z.string().min(1).max(2_000),
|
|
441
|
+
symbol: z.string().min(1).max(200),
|
|
442
|
+
cwd: z.string().min(1).max(2_000).optional(),
|
|
443
|
+
}, async (a) => {
|
|
444
|
+
try {
|
|
445
|
+
const file = resolveFile(a.path, resolveDir(a.cwd));
|
|
446
|
+
const content = await readFileSafe(file);
|
|
447
|
+
const regex = new RegExp(`(?<![A-Za-z0-9_])${escapeRegExp(a.symbol)}(?![A-Za-z0-9_])`, "g");
|
|
448
|
+
const found = [];
|
|
449
|
+
content.split("\n").forEach((line, index) => {
|
|
450
|
+
const count = [...line.matchAll(regex)].length;
|
|
451
|
+
if (count > 0)
|
|
452
|
+
found.push(`Line ${index + 1}: ${count} reference(s)`);
|
|
453
|
+
});
|
|
454
|
+
return text(found.length > 0 ? found.join("\n") : `No references to ${a.symbol}`);
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
return fail(errorMessage(error));
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
server.tool("rename_symbol", `Rename one symbol in a ${config.displayName} file. Defaults to a dry run.`, {
|
|
461
|
+
path: z.string().min(1).max(2_000),
|
|
462
|
+
oldName: z.string().min(1).max(200),
|
|
463
|
+
newName: z.string().min(1).max(200),
|
|
464
|
+
dryRun: z.boolean().default(true),
|
|
465
|
+
cwd: z.string().min(1).max(2_000).optional(),
|
|
466
|
+
}, async (a) => {
|
|
467
|
+
try {
|
|
468
|
+
if (/[\r\n]/.test(a.newName))
|
|
469
|
+
throw new ToolError("New name cannot contain a newline");
|
|
470
|
+
const file = resolveFile(a.path, resolveDir(a.cwd));
|
|
471
|
+
const content = await readFileSafe(file);
|
|
472
|
+
const regex = new RegExp(`(?<![A-Za-z0-9_])${escapeRegExp(a.oldName)}(?![A-Za-z0-9_])`, "g");
|
|
473
|
+
const count = [...content.matchAll(regex)].length;
|
|
474
|
+
if (count === 0)
|
|
475
|
+
return text(`No references to ${a.oldName}`);
|
|
476
|
+
if (a.dryRun)
|
|
477
|
+
return text(`Dry run: would replace ${count} occurrence(s) in ${file}`);
|
|
478
|
+
await fs.writeFile(file, content.replace(regex, () => a.newName), "utf8");
|
|
479
|
+
return text(`Renamed ${count} occurrence(s) in ${file}`);
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
return fail(errorMessage(error));
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
server.tool("insert_snippet", `Insert a real ${config.displayName} starter snippet into a file. Defaults to a dry run.`, {
|
|
486
|
+
path: z.string().min(1).max(2_000),
|
|
487
|
+
snippet: z.string().min(1).max(80),
|
|
488
|
+
line: z.number().int().min(1).optional(),
|
|
489
|
+
dryRun: z.boolean().default(true),
|
|
490
|
+
cwd: z.string().min(1).max(2_000).optional(),
|
|
491
|
+
}, async (a) => {
|
|
492
|
+
try {
|
|
493
|
+
const file = resolveFile(a.path, resolveDir(a.cwd));
|
|
494
|
+
const snippet = config.snippets[a.snippet];
|
|
495
|
+
if (!snippet) {
|
|
496
|
+
throw new ToolError(`Unknown snippet: ${a.snippet}. Available: ${Object.entries(config.snippets)
|
|
497
|
+
.map(([key, value]) => `${key} (${value.description})`)
|
|
498
|
+
.join(", ")}`);
|
|
499
|
+
}
|
|
500
|
+
const content = await readFileSafe(file);
|
|
501
|
+
const lines = content.split("\n");
|
|
502
|
+
const index = a.line ? Math.min(a.line - 1, lines.length) : lines.length;
|
|
503
|
+
lines.splice(index, 0, ...snippet.body.split("\n"));
|
|
504
|
+
const next = lines.join("\n");
|
|
505
|
+
if (a.dryRun) {
|
|
506
|
+
return text(`Dry run: would insert ${a.snippet} at line ${index + 1} in ${file}`);
|
|
507
|
+
}
|
|
508
|
+
await fs.writeFile(file, next, "utf8");
|
|
509
|
+
return text(`Inserted ${a.snippet} at line ${index + 1} in ${file}`);
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
return fail(errorMessage(error));
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
server.tool("comment_lines", `Comment a line range in ${config.displayName} code using the language comment marker.`, {
|
|
516
|
+
...sourceArgs,
|
|
517
|
+
startLine: z.number().int().min(1).default(1),
|
|
518
|
+
endLine: z.number().int().min(1).optional(),
|
|
519
|
+
write: z.boolean().default(false),
|
|
520
|
+
}, async (a) => {
|
|
521
|
+
try {
|
|
522
|
+
const source = await loadSource(a);
|
|
523
|
+
const lines = source.content.split("\n");
|
|
524
|
+
const end = Math.min(a.endLine ?? lines.length, lines.length);
|
|
525
|
+
let changed = 0;
|
|
526
|
+
for (let index = a.startLine - 1; index < end; index += 1) {
|
|
527
|
+
if (lines[index].trim().length > 0 && !lines[index].trimStart().startsWith(config.comment)) {
|
|
528
|
+
const indent = lines[index].slice(0, lines[index].length - lines[index].trimStart().length);
|
|
529
|
+
lines[index] = `${indent}${config.comment} ${lines[index].slice(indent.length)}`;
|
|
530
|
+
changed += 1;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
const next = lines.join("\n");
|
|
534
|
+
if (a.write && source.file) {
|
|
535
|
+
await fs.writeFile(source.file, next, "utf8");
|
|
536
|
+
return text(`Commented ${changed} line(s) in ${source.file}`);
|
|
537
|
+
}
|
|
538
|
+
return text(next);
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
return fail(errorMessage(error));
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
server.tool("uncomment_lines", `Remove the ${config.displayName} comment marker from a line range.`, {
|
|
545
|
+
...sourceArgs,
|
|
546
|
+
startLine: z.number().int().min(1).default(1),
|
|
547
|
+
endLine: z.number().int().min(1).optional(),
|
|
548
|
+
write: z.boolean().default(false),
|
|
549
|
+
}, async (a) => {
|
|
550
|
+
try {
|
|
551
|
+
const source = await loadSource(a);
|
|
552
|
+
const lines = source.content.split("\n");
|
|
553
|
+
const end = Math.min(a.endLine ?? lines.length, lines.length);
|
|
554
|
+
let changed = 0;
|
|
555
|
+
for (let index = a.startLine - 1; index < end; index += 1) {
|
|
556
|
+
const match = lines[index].match(/^(\s*)(.*)$/);
|
|
557
|
+
if (!match)
|
|
558
|
+
continue;
|
|
559
|
+
const indent = match[1];
|
|
560
|
+
const rest = match[2];
|
|
561
|
+
if (!rest.startsWith(config.comment))
|
|
562
|
+
continue;
|
|
563
|
+
const after = rest.slice(config.comment.length);
|
|
564
|
+
lines[index] = `${indent}${after.startsWith(" ") ? after.slice(1) : after}`;
|
|
565
|
+
changed += 1;
|
|
566
|
+
}
|
|
567
|
+
const next = lines.join("\n");
|
|
568
|
+
if (a.write && source.file) {
|
|
569
|
+
await fs.writeFile(source.file, next, "utf8");
|
|
570
|
+
return text(`Uncommented ${changed} line(s) in ${source.file}`);
|
|
571
|
+
}
|
|
572
|
+
return text(next);
|
|
573
|
+
}
|
|
574
|
+
catch (error) {
|
|
575
|
+
return fail(errorMessage(error));
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
server.tool("scaffold_project", `Create a small runnable ${config.displayName} project with a starter file and test directory.`, {
|
|
579
|
+
path: z.string().min(1).max(2_000).default("."),
|
|
580
|
+
projectName: z.string().min(1).max(120).optional(),
|
|
581
|
+
overwrite: z.boolean().default(false),
|
|
582
|
+
}, async (a) => {
|
|
583
|
+
try {
|
|
584
|
+
const root = resolveDir(a.path);
|
|
585
|
+
const name = a.projectName ?? config.slug;
|
|
586
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(name))
|
|
587
|
+
throw new ToolError("Invalid project name");
|
|
588
|
+
const created = [];
|
|
589
|
+
for (const file of config.scaffold) {
|
|
590
|
+
const relative = file.path.replace(/\{\{name\}\}/g, name);
|
|
591
|
+
const normalized = path.normalize(relative);
|
|
592
|
+
if (path.isAbsolute(normalized) || normalized.startsWith("..")) {
|
|
593
|
+
throw new ToolError(`Unsafe scaffold path: ${relative}`);
|
|
594
|
+
}
|
|
595
|
+
const target = path.join(root, normalized);
|
|
596
|
+
if (!a.overwrite && (await fileExists(target))) {
|
|
597
|
+
throw new ToolError(`File already exists: ${target}`);
|
|
598
|
+
}
|
|
599
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
600
|
+
await fs.writeFile(target, file.content.replace(/\{\{name\}\}/g, name), "utf8");
|
|
601
|
+
created.push(normalized);
|
|
602
|
+
}
|
|
603
|
+
return text(`Created ${created.length} file(s):\n${created.join("\n")}`);
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
return fail(errorMessage(error));
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
server.tool("run_source", `Run a ${config.displayName} file with the installed interpreter or compiler.`, {
|
|
610
|
+
path: z.string().min(1).max(2_000),
|
|
611
|
+
cwd: z.string().min(1).max(2_000).optional(),
|
|
612
|
+
timeoutMs: timeout,
|
|
613
|
+
}, async (a) => {
|
|
614
|
+
try {
|
|
615
|
+
if (!config.runner || config.runner.length === 0) {
|
|
616
|
+
throw new ToolError(`No runner is configured for ${config.displayName}`);
|
|
617
|
+
}
|
|
618
|
+
const file = resolveFile(a.path, resolveDir(a.cwd));
|
|
619
|
+
const cwd = path.dirname(file);
|
|
620
|
+
const ctx = commandContext(file, cwd, config);
|
|
621
|
+
await ensureBuildDir(ctx);
|
|
622
|
+
const workflow = await pickWorkflow(config.runner);
|
|
623
|
+
const output = await runSteps(workflow, ctx, cwd, a.timeoutMs);
|
|
624
|
+
return text(output.trim().length > 0 ? output : "(no output)");
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
return fail(errorMessage(error));
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
server.tool("run_tests", `Run the configured ${config.displayName} test command in a project directory.`, {
|
|
631
|
+
cwd: z.string().min(1).max(2_000).default("."),
|
|
632
|
+
timeoutMs: timeout,
|
|
633
|
+
}, async (a) => {
|
|
634
|
+
try {
|
|
635
|
+
if (!config.test || config.test.length === 0) {
|
|
636
|
+
throw new ToolError(`No test command is configured for ${config.displayName}`);
|
|
637
|
+
}
|
|
638
|
+
const cwd = resolveDir(a.cwd);
|
|
639
|
+
const ctx = commandContext(path.join(cwd, config.slug), cwd, config);
|
|
640
|
+
await ensureBuildDir(ctx);
|
|
641
|
+
const output = await runSteps(config.test, ctx, cwd, a.timeoutMs);
|
|
642
|
+
return text(output.trim().length > 0 ? output : "Tests passed");
|
|
643
|
+
}
|
|
644
|
+
catch (error) {
|
|
645
|
+
return fail(errorMessage(error));
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
server.tool("build_project", `Build a ${config.displayName} project with the configured compiler command.`, {
|
|
649
|
+
cwd: z.string().min(1).max(2_000).default("."),
|
|
650
|
+
path: z.string().min(1).max(2_000).optional(),
|
|
651
|
+
timeoutMs: timeout,
|
|
652
|
+
}, async (a) => {
|
|
653
|
+
try {
|
|
654
|
+
if (!config.build || config.build.length === 0) {
|
|
655
|
+
throw new ToolError(`No build step is configured for ${config.displayName}`);
|
|
656
|
+
}
|
|
657
|
+
const cwd = resolveDir(a.cwd);
|
|
658
|
+
let file = a.path ? resolveFile(a.path, cwd) : "";
|
|
659
|
+
if (!file) {
|
|
660
|
+
const scan = await scanProject(cwd, config, 3);
|
|
661
|
+
const first = scan.samples.find((sample) => config.extensions.includes(path.extname(sample).toLowerCase()));
|
|
662
|
+
if (first)
|
|
663
|
+
file = path.join(cwd, first);
|
|
664
|
+
}
|
|
665
|
+
const ctx = commandContext(file || path.join(cwd, config.slug), cwd, config);
|
|
666
|
+
await ensureBuildDir(ctx);
|
|
667
|
+
const output = await runSteps(config.build, ctx, cwd, a.timeoutMs);
|
|
668
|
+
return text(output.trim().length > 0 ? output : "Build passed");
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
return fail(errorMessage(error));
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
server.tool("toolchain_status", `Show which ${config.displayName} compilers, interpreters, build tools and test commands are installed.`, {}, async () => {
|
|
675
|
+
try {
|
|
676
|
+
const commands = new Set();
|
|
677
|
+
for (const step of [
|
|
678
|
+
...(config.runner ?? []),
|
|
679
|
+
...(config.syntax ? [config.syntax] : []),
|
|
680
|
+
...(config.build ?? []),
|
|
681
|
+
...(config.test ?? []),
|
|
682
|
+
]) {
|
|
683
|
+
commands.add(step.command);
|
|
684
|
+
}
|
|
685
|
+
const lines = [];
|
|
686
|
+
for (const command of [...commands].sort()) {
|
|
687
|
+
if (!(await commandAvailable(command))) {
|
|
688
|
+
lines.push(`${command}: not found`);
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
const version = await run(command, ["--version"], { timeoutMs: 5_000 });
|
|
692
|
+
const first = (version.stdout || version.stderr).split("\n").find((line) => line.trim().length > 0);
|
|
693
|
+
lines.push(`${command}: ${first?.trim() ?? "installed"}`);
|
|
694
|
+
}
|
|
695
|
+
return text(lines.join("\n") || "No toolchain commands configured");
|
|
696
|
+
}
|
|
697
|
+
catch (error) {
|
|
698
|
+
return fail(errorMessage(error));
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
server.tool("language_reference", `Return a compact ${config.displayName} syntax and toolchain reference for agents.`, {}, async () => text(config.reference));
|
|
702
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { createServer } from "./server.js";
|
|
4
|
+
async function main() {
|
|
5
|
+
const server = createServer();
|
|
6
|
+
const transport = new StdioServerTransport();
|
|
7
|
+
await server.connect(transport);
|
|
8
|
+
console.error(`${process.env.npm_package_name ?? "language-mcp"} running on stdio`);
|
|
9
|
+
}
|
|
10
|
+
main().catch((error) => {
|
|
11
|
+
console.error("Fatal error:", error);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
});
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare class ToolError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export interface RunResult {
|
|
4
|
+
stdout: string;
|
|
5
|
+
stderr: string;
|
|
6
|
+
code: number;
|
|
7
|
+
}
|
|
8
|
+
export interface RunOptions {
|
|
9
|
+
input?: string;
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
cwd?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Run a command without a shell and capture both streams. */
|
|
14
|
+
export declare function run(command: string, args: string[], options?: RunOptions): Promise<RunResult>;
|
|
15
|
+
export declare function text(value: string): {
|
|
16
|
+
content: {
|
|
17
|
+
type: "text";
|
|
18
|
+
text: string;
|
|
19
|
+
}[];
|
|
20
|
+
};
|
|
21
|
+
export declare function fail(value: string): {
|
|
22
|
+
content: {
|
|
23
|
+
type: "text";
|
|
24
|
+
text: string;
|
|
25
|
+
}[];
|
|
26
|
+
isError: boolean;
|
|
27
|
+
};
|
|
28
|
+
export declare function errorMessage(error: unknown): string;
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export class ToolError extends Error {
|
|
3
|
+
}
|
|
4
|
+
/** Run a command without a shell and capture both streams. */
|
|
5
|
+
export function run(command, args, options = {}) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const child = spawn(command, args, {
|
|
8
|
+
cwd: options.cwd,
|
|
9
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
10
|
+
});
|
|
11
|
+
let stdout = "";
|
|
12
|
+
let stderr = "";
|
|
13
|
+
let settled = false;
|
|
14
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
15
|
+
const timer = setTimeout(() => {
|
|
16
|
+
if (settled)
|
|
17
|
+
return;
|
|
18
|
+
settled = true;
|
|
19
|
+
child.kill("SIGKILL");
|
|
20
|
+
reject(new ToolError(`${command} timed out after ${timeoutMs}ms`));
|
|
21
|
+
}, timeoutMs);
|
|
22
|
+
child.on("error", (error) => {
|
|
23
|
+
if (settled)
|
|
24
|
+
return;
|
|
25
|
+
settled = true;
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
reject(new ToolError(`${command} could not start: ${error.message}`));
|
|
28
|
+
});
|
|
29
|
+
child.stdout.on("data", (chunk) => {
|
|
30
|
+
stdout += chunk.toString();
|
|
31
|
+
});
|
|
32
|
+
child.stderr.on("data", (chunk) => {
|
|
33
|
+
stderr += chunk.toString();
|
|
34
|
+
});
|
|
35
|
+
child.stdin.on("error", () => { });
|
|
36
|
+
child.on("close", (code) => {
|
|
37
|
+
if (settled)
|
|
38
|
+
return;
|
|
39
|
+
settled = true;
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
resolve({ stdout, stderr, code: code ?? 0 });
|
|
42
|
+
});
|
|
43
|
+
if (options.input !== undefined)
|
|
44
|
+
child.stdin.write(options.input);
|
|
45
|
+
child.stdin.end();
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export function text(value) {
|
|
49
|
+
return { content: [{ type: "text", text: value }] };
|
|
50
|
+
}
|
|
51
|
+
export function fail(value) {
|
|
52
|
+
return { content: [{ type: "text", text: value }], isError: true };
|
|
53
|
+
}
|
|
54
|
+
export function errorMessage(error) {
|
|
55
|
+
return error instanceof Error ? error.message : String(error);
|
|
56
|
+
}
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { config } from "./config.js";
|
|
3
|
+
import { registerLanguageTools } from "./core.js";
|
|
4
|
+
export function createServer() {
|
|
5
|
+
const server = new McpServer({ name: `${config.slug}-mcp`, version: config.version });
|
|
6
|
+
registerLanguageTools(server, config);
|
|
7
|
+
return server;
|
|
8
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface CommandSpec {
|
|
2
|
+
command: string;
|
|
3
|
+
args: string[];
|
|
4
|
+
timeoutMs?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface SymbolRule {
|
|
7
|
+
kind: string;
|
|
8
|
+
pattern: string;
|
|
9
|
+
flags?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ImportRule {
|
|
12
|
+
kind: string;
|
|
13
|
+
pattern: string;
|
|
14
|
+
flags?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface Snippet {
|
|
17
|
+
description: string;
|
|
18
|
+
body: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ScaffoldFile {
|
|
21
|
+
path: string;
|
|
22
|
+
content: string;
|
|
23
|
+
}
|
|
24
|
+
export interface LanguageConfig {
|
|
25
|
+
slug: string;
|
|
26
|
+
displayName: string;
|
|
27
|
+
version: string;
|
|
28
|
+
extensions: string[];
|
|
29
|
+
markers: string[];
|
|
30
|
+
comment: string;
|
|
31
|
+
blockComment?: {
|
|
32
|
+
start: string;
|
|
33
|
+
end: string;
|
|
34
|
+
};
|
|
35
|
+
stringQuotes?: string[];
|
|
36
|
+
allowTabs?: boolean;
|
|
37
|
+
buildDir?: string;
|
|
38
|
+
symbols: SymbolRule[];
|
|
39
|
+
imports: ImportRule[];
|
|
40
|
+
runner?: CommandSpec[];
|
|
41
|
+
syntax?: CommandSpec;
|
|
42
|
+
build?: CommandSpec[];
|
|
43
|
+
test?: CommandSpec[];
|
|
44
|
+
scaffold: ScaffoldFile[];
|
|
45
|
+
snippets: Record<string, Snippet>;
|
|
46
|
+
reference: string;
|
|
47
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrfentmen/rascal-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Rascal MCP: 20 real tools to read, check, lint, scaffold, build and run Rascal code.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"mcpName": "io.github.mrfentmen/rascal-mcp",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mrfentmen/awesome-servers.git"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"rascal-mcp": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"server.json",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.json",
|
|
22
|
+
"start": "node dist/index.js",
|
|
23
|
+
"test": "npm run build && node --test tests/tools.test.mjs"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"mcp",
|
|
27
|
+
"model-context-protocol",
|
|
28
|
+
"rascal",
|
|
29
|
+
"language",
|
|
30
|
+
"toolchain"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
35
|
+
"zod": "^3.23.8"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^22.0.0",
|
|
39
|
+
"typescript": "^5.6.0"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=20"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.mrfentmen/rascal-mcp",
|
|
4
|
+
"description": "Rascal MCP: 20 real tools to read, check, lint, scaffold, build and run Rascal code.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/mrfentmen/awesome-servers/tree/main/mcp/servers/rascal-mcp",
|
|
7
|
+
"source": "github"
|
|
8
|
+
},
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"packages": [
|
|
11
|
+
{
|
|
12
|
+
"registryType": "npm",
|
|
13
|
+
"identifier": "@mrfentmen/rascal-mcp",
|
|
14
|
+
"version": "1.0.0",
|
|
15
|
+
"transport": {
|
|
16
|
+
"type": "stdio"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|