@gotza02/sequential-thinking 2026.2.9 → 2026.2.10
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 +1 -1
- package/dist/codestore.js +34 -22
- package/dist/codestore.test.js +59 -0
- package/dist/filesystem.test.js +48 -0
- package/dist/graph.js +13 -5
- package/dist/graph.test.js +53 -0
- package/dist/integration.test.js +58 -0
- package/dist/lib.js +29 -4
- package/dist/notes.js +62 -50
- package/dist/notes.test.js +74 -0
- package/dist/registration.test.js +39 -0
- package/dist/repro_dollar.js +30 -0
- package/dist/repro_dollar_simple.js +22 -0
- package/dist/repro_history.js +41 -0
- package/dist/repro_path.js +17 -0
- package/dist/repro_ts_req.js +3 -0
- package/dist/server.test.js +32 -0
- package/dist/stress.test.js +68 -0
- package/dist/test_ts_req.js +46 -0
- package/dist/tools/coding.js +15 -1
- package/dist/tools/filesystem.js +12 -9
- package/dist/tools/graph.js +3 -1
- package/dist/tools/web.js +7 -2
- package/dist/utils.js +103 -2
- package/dist/web_read.test.js +60 -0
- package/package.json +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { registerThinkingTools } from './tools/thinking.js';
|
|
3
|
+
import { registerGraphTools } from './tools/graph.js';
|
|
4
|
+
import { registerNoteTools } from './tools/notes.js';
|
|
5
|
+
import { registerWebTools } from './tools/web.js';
|
|
6
|
+
import { registerFileSystemTools } from './tools/filesystem.js';
|
|
7
|
+
import { registerCodingTools } from './tools/coding.js';
|
|
8
|
+
import { registerCodeDbTools } from './tools/codestore.js';
|
|
9
|
+
describe('Tool Registration', () => {
|
|
10
|
+
it('should register all tools without duplicates', () => {
|
|
11
|
+
const registeredTools = new Set();
|
|
12
|
+
const mockServer = {
|
|
13
|
+
tool: (name, desc, schema, cb) => {
|
|
14
|
+
if (registeredTools.has(name)) {
|
|
15
|
+
throw new Error(`Duplicate tool name: ${name}`);
|
|
16
|
+
}
|
|
17
|
+
registeredTools.add(name);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
// Mock dependencies
|
|
21
|
+
const mockThinking = { processThought: vi.fn(), clearHistory: vi.fn(), archiveHistory: vi.fn() };
|
|
22
|
+
const mockGraph = { build: vi.fn(), getRelationships: vi.fn(), getSummary: vi.fn(), toMermaid: vi.fn() };
|
|
23
|
+
const mockNotes = {};
|
|
24
|
+
const mockCodeDb = {};
|
|
25
|
+
registerThinkingTools(mockServer, mockThinking);
|
|
26
|
+
registerGraphTools(mockServer, mockGraph);
|
|
27
|
+
registerNoteTools(mockServer, mockNotes);
|
|
28
|
+
registerWebTools(mockServer);
|
|
29
|
+
registerFileSystemTools(mockServer);
|
|
30
|
+
registerCodingTools(mockServer, mockGraph);
|
|
31
|
+
registerCodeDbTools(mockServer, mockCodeDb);
|
|
32
|
+
expect(registeredTools.has('sequentialthinking')).toBe(true);
|
|
33
|
+
expect(registeredTools.has('build_project_graph')).toBe(true);
|
|
34
|
+
expect(registeredTools.has('read_file')).toBe(true);
|
|
35
|
+
expect(registeredTools.has('web_search')).toBe(true);
|
|
36
|
+
// ... and so on
|
|
37
|
+
console.log('Registered tools:', Array.from(registeredTools).join(', '));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { registerFileSystemTools } from './tools/filesystem.js';
|
|
3
|
+
// Mock server
|
|
4
|
+
const server = new McpServer({ name: "test", version: "1" });
|
|
5
|
+
// Mock tool registration to capture the handler
|
|
6
|
+
let editHandler;
|
|
7
|
+
server.tool = (name, desc, schema, handler) => {
|
|
8
|
+
if (name === 'edit_file')
|
|
9
|
+
editHandler = handler;
|
|
10
|
+
return undefined;
|
|
11
|
+
};
|
|
12
|
+
registerFileSystemTools(server);
|
|
13
|
+
async function test() {
|
|
14
|
+
console.log("Testing edit_file with dollar signs...");
|
|
15
|
+
const result = await editHandler({
|
|
16
|
+
path: 'test_dollar.txt',
|
|
17
|
+
oldText: 'OLD',
|
|
18
|
+
newText: '$100' // This usually becomes empty or weird if interpreted as regex replacement
|
|
19
|
+
});
|
|
20
|
+
const fs = await import('fs/promises');
|
|
21
|
+
const content = await fs.readFile('test_dollar.txt', 'utf-8');
|
|
22
|
+
console.log("File content:", content.trim());
|
|
23
|
+
if (content.trim() === 'Price: $100') {
|
|
24
|
+
console.log("PASS: $ preserved");
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
console.log("FAIL: $ corrupted");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
test();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
async function test() {
|
|
2
|
+
console.log("Testing edit_file logic...");
|
|
3
|
+
// Simulate the logic in filesystem.ts
|
|
4
|
+
const content = "Price: OLD";
|
|
5
|
+
const oldText = "OLD";
|
|
6
|
+
const newText = "$&"; // Should be "$&" literally, but replace will make it "OLD"
|
|
7
|
+
// Logic from tool
|
|
8
|
+
// If allowMultiple is false, it passes string directly
|
|
9
|
+
const buggedResult = content.replace(oldText, newText);
|
|
10
|
+
console.log(`Original: "${content}"`);
|
|
11
|
+
console.log(`Old: "${oldText}"`);
|
|
12
|
+
console.log(`New: "${newText}"`);
|
|
13
|
+
console.log(`Result: "${buggedResult}"`);
|
|
14
|
+
if (buggedResult === "Price: OLD") {
|
|
15
|
+
console.log("FAIL: $& was interpreted as the matched string");
|
|
16
|
+
}
|
|
17
|
+
else if (buggedResult === "Price: $&") {
|
|
18
|
+
console.log("PASS: $& was preserved literally");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
test();
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { SequentialThinkingServer } from './lib.js';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
async function test() {
|
|
4
|
+
const server = new SequentialThinkingServer('test_history_bug.json');
|
|
5
|
+
await server.clearHistory();
|
|
6
|
+
// Create thoughts: 1, 2, 3, 4
|
|
7
|
+
await server.processThought({ thought: "1", thoughtNumber: 1, totalThoughts: 4, nextThoughtNeeded: true });
|
|
8
|
+
await server.processThought({ thought: "2", thoughtNumber: 2, totalThoughts: 4, nextThoughtNeeded: true });
|
|
9
|
+
await server.processThought({ thought: "3", thoughtNumber: 3, totalThoughts: 4, nextThoughtNeeded: true });
|
|
10
|
+
// Thought 4 branches from 3
|
|
11
|
+
await server.processThought({
|
|
12
|
+
thought: "4",
|
|
13
|
+
thoughtNumber: 4,
|
|
14
|
+
totalThoughts: 4,
|
|
15
|
+
nextThoughtNeeded: false,
|
|
16
|
+
branchFromThought: 3,
|
|
17
|
+
branchId: "test"
|
|
18
|
+
});
|
|
19
|
+
// Verify initial state
|
|
20
|
+
// History: [1, 2, 3, 4(from 3)]
|
|
21
|
+
// Summarize 2-3
|
|
22
|
+
await server.archiveHistory(2, 3, "Summary of 2 and 3");
|
|
23
|
+
// Expected History: [1, Summary(2), 4(3)]
|
|
24
|
+
// Thought 4 should now contain "branchFromThought: 2" (pointing to summary)
|
|
25
|
+
// Or if it broke, it might still say 3 (which is itself!) or not be updated.
|
|
26
|
+
// Read file manually to check JSON content
|
|
27
|
+
const data = JSON.parse(await fs.readFile('test_history_bug.json', 'utf-8'));
|
|
28
|
+
const lastThought = data[data.length - 1];
|
|
29
|
+
console.log("Last Thought:", lastThought);
|
|
30
|
+
if (lastThought.branchFromThought === 3) {
|
|
31
|
+
console.log("FAIL: branchFromThought was NOT updated. It points to 3 (which is now itself).");
|
|
32
|
+
}
|
|
33
|
+
else if (lastThought.branchFromThought === 2) {
|
|
34
|
+
console.log("PASS: branchFromThought was updated to point to Summary.");
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
console.log(`FAIL: branchFromThought is ${lastThought.branchFromThought} (Unknown state)`);
|
|
38
|
+
}
|
|
39
|
+
await fs.unlink('test_history_bug.json');
|
|
40
|
+
}
|
|
41
|
+
test();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
async function test() {
|
|
3
|
+
console.log("Testing path traversal vulnerability...");
|
|
4
|
+
const cwd = process.cwd();
|
|
5
|
+
const maliciousPath = '/etc/passwd'; // Or any file outside cwd
|
|
6
|
+
const resolved = path.resolve(maliciousPath);
|
|
7
|
+
console.log(`CWD: ${cwd}`);
|
|
8
|
+
console.log(`Input: ${maliciousPath}`);
|
|
9
|
+
console.log(`Resolved: ${resolved}`);
|
|
10
|
+
if (!resolved.startsWith(cwd)) {
|
|
11
|
+
console.log("FAIL: Path is outside CWD and would be allowed by current logic.");
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
console.log("PASS: Path is inside CWD.");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
test();
|
package/dist/server.test.js
CHANGED
|
@@ -92,4 +92,36 @@ describe('SequentialThinkingServer', () => {
|
|
|
92
92
|
const content = JSON.parse(result.content[0].text);
|
|
93
93
|
expect(content.totalThoughts).toBe(6);
|
|
94
94
|
});
|
|
95
|
+
it('should clear thought history', async () => {
|
|
96
|
+
await server.processThought({
|
|
97
|
+
thought: "To be forgotten",
|
|
98
|
+
thoughtNumber: 1,
|
|
99
|
+
totalThoughts: 1,
|
|
100
|
+
nextThoughtNeeded: false
|
|
101
|
+
});
|
|
102
|
+
await server.clearHistory();
|
|
103
|
+
// Since we can't easily peek into private state, we'll process a new thought
|
|
104
|
+
// and check if thoughtHistoryLength is 1 (meaning it started over or is just this one)
|
|
105
|
+
const result = await server.processThought({
|
|
106
|
+
thought: "Fresh start",
|
|
107
|
+
thoughtNumber: 1,
|
|
108
|
+
totalThoughts: 1,
|
|
109
|
+
nextThoughtNeeded: false
|
|
110
|
+
});
|
|
111
|
+
const content = JSON.parse(result.content[0].text);
|
|
112
|
+
expect(content.thoughtHistoryLength).toBe(1);
|
|
113
|
+
});
|
|
114
|
+
it('should summarize history correctly', async () => {
|
|
115
|
+
// Add 3 thoughts
|
|
116
|
+
await server.processThought({ thought: "T1", thoughtNumber: 1, totalThoughts: 3, nextThoughtNeeded: true });
|
|
117
|
+
await server.processThought({ thought: "T2", thoughtNumber: 2, totalThoughts: 3, nextThoughtNeeded: true });
|
|
118
|
+
await server.processThought({ thought: "T3", thoughtNumber: 3, totalThoughts: 3, nextThoughtNeeded: false });
|
|
119
|
+
const result = await server.archiveHistory(1, 2, "Summary of T1 and T2");
|
|
120
|
+
expect(result.newHistoryLength).toBe(2); // Summary + T3
|
|
121
|
+
expect(result.summaryInsertedAt).toBe(1);
|
|
122
|
+
});
|
|
123
|
+
it('should throw error when summarizing invalid range', async () => {
|
|
124
|
+
await server.processThought({ thought: "T1", thoughtNumber: 1, totalThoughts: 1, nextThoughtNeeded: false });
|
|
125
|
+
await expect(server.archiveHistory(1, 5, "Invalid")).rejects.toThrow();
|
|
126
|
+
});
|
|
95
127
|
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { SequentialThinkingServer } from './lib.js';
|
|
3
|
+
import { ProjectKnowledgeGraph } from './graph.js';
|
|
4
|
+
import * as fs from 'fs/promises';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
// Mock fs for graph test
|
|
7
|
+
vi.mock('fs/promises', async (importOriginal) => {
|
|
8
|
+
const actual = await importOriginal();
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
readdir: vi.fn(),
|
|
12
|
+
readFile: vi.fn(),
|
|
13
|
+
writeFile: vi.fn(),
|
|
14
|
+
rename: vi.fn(),
|
|
15
|
+
unlink: vi.fn()
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
// Mock existsSync and readFileSync from 'fs' (non-promise) for SequentialThinkingServer
|
|
19
|
+
vi.mock('fs', async () => {
|
|
20
|
+
return {
|
|
21
|
+
existsSync: () => false,
|
|
22
|
+
readFileSync: () => '[]',
|
|
23
|
+
unlinkSync: () => { }
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
describe('Stress Testing', () => {
|
|
27
|
+
it('should handle 1000 sequential thoughts', async () => {
|
|
28
|
+
// We use a real instance but mocked fs
|
|
29
|
+
const server = new SequentialThinkingServer('stress_thoughts.json');
|
|
30
|
+
const startTime = Date.now();
|
|
31
|
+
for (let i = 1; i <= 1000; i++) {
|
|
32
|
+
await server.processThought({
|
|
33
|
+
thought: `Thought ${i}`,
|
|
34
|
+
thoughtNumber: i,
|
|
35
|
+
totalThoughts: 1000,
|
|
36
|
+
nextThoughtNeeded: i < 1000
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const duration = Date.now() - startTime;
|
|
40
|
+
console.log(`Processed 1000 thoughts in ${duration}ms`);
|
|
41
|
+
expect(duration).toBeLessThan(10000); // Should be fast enough (< 10s)
|
|
42
|
+
});
|
|
43
|
+
it('should handle large graph construction', async () => {
|
|
44
|
+
const graph = new ProjectKnowledgeGraph();
|
|
45
|
+
// Mock 1000 files
|
|
46
|
+
const files = Array.from({ length: 1000 }, (_, i) => `file${i}.ts`);
|
|
47
|
+
// Mock readdir to return these files (recursively? no, just flat for stress test)
|
|
48
|
+
// logic in getAllFiles is recursive. We need to mock it to return all at once or handle recursion.
|
|
49
|
+
// Actually, ProjectKnowledgeGraph.getAllFiles calls readdir.
|
|
50
|
+
// Let's mock readdir to return files for root, and then nothing for subdirs.
|
|
51
|
+
fs.readdir.mockImplementation(async (dir) => {
|
|
52
|
+
if (dir === path.resolve('.')) {
|
|
53
|
+
return files.map(f => ({
|
|
54
|
+
name: f,
|
|
55
|
+
isDirectory: () => false
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
return [];
|
|
59
|
+
});
|
|
60
|
+
fs.readFile.mockResolvedValue("import { x } from './file0';");
|
|
61
|
+
const startTime = Date.now();
|
|
62
|
+
const result = await graph.build('.');
|
|
63
|
+
const duration = Date.now() - startTime;
|
|
64
|
+
console.log(`Built graph of ${result.totalFiles} files in ${duration}ms`);
|
|
65
|
+
expect(result.totalFiles).toBe(1000);
|
|
66
|
+
expect(duration).toBeLessThan(5000);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ProjectKnowledgeGraph } from './graph.js';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
async function test() {
|
|
4
|
+
const tempFile = 'temp_req.ts';
|
|
5
|
+
await fs.writeFile(tempFile, 'import fs = require("fs");\nexport = fs;');
|
|
6
|
+
try {
|
|
7
|
+
const graph = new ProjectKnowledgeGraph();
|
|
8
|
+
await graph.build(process.cwd());
|
|
9
|
+
const rel = graph.getRelationships(tempFile);
|
|
10
|
+
console.log("Imports:", rel?.imports);
|
|
11
|
+
// Note: 'fs' is a built-in, so it might not be in 'imports' array unless resolved to a file.
|
|
12
|
+
// But ProjectKnowledgeGraph parser ADDS it to 'imports' list initially.
|
|
13
|
+
// finalizeFileNodes only keeps it if it resolves to a node OR if we assume internal logic keeps it?
|
|
14
|
+
// Wait, 'finalizeFileNodes' logic:
|
|
15
|
+
// for (const importPath of imports) {
|
|
16
|
+
// resolved = resolvePath(...)
|
|
17
|
+
// if (resolved && nodes.has(resolved)) { imports.push(resolved) }
|
|
18
|
+
// }
|
|
19
|
+
// It FILTERS out imports that don't resolve to files in the project!
|
|
20
|
+
// So 'fs' will be DROPPED.
|
|
21
|
+
// This makes verifying the PARSER hard via 'getRelationships'.
|
|
22
|
+
// However, 'symbols' are kept.
|
|
23
|
+
// 'import fs = require...' creates a symbol 'fs'?
|
|
24
|
+
// The parser only pushes to 'imports' or 'symbols'.
|
|
25
|
+
// Let's create a local file 'my_dep.ts' and import THAT.
|
|
26
|
+
await fs.writeFile('my_dep.ts', 'export const x = 1;');
|
|
27
|
+
await fs.writeFile(tempFile, 'import dep = require("./my_dep");');
|
|
28
|
+
await graph.build(process.cwd());
|
|
29
|
+
const rel2 = graph.getRelationships(tempFile);
|
|
30
|
+
console.log("Imports 2:", rel2?.imports);
|
|
31
|
+
if (rel2?.imports.some(i => i.includes('my_dep'))) {
|
|
32
|
+
console.log("PASS: Found 'my_dep' import");
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
console.log("FAIL: 'my_dep' import missing");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
await fs.unlink(tempFile);
|
|
40
|
+
try {
|
|
41
|
+
await fs.unlink('my_dep.ts');
|
|
42
|
+
}
|
|
43
|
+
catch { }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
test();
|
package/dist/tools/coding.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import * as fs from 'fs/promises';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
+
import { validatePath } from "../utils.js";
|
|
4
5
|
export function registerCodingTools(server, graph) {
|
|
5
6
|
// 14. deep_code_analyze
|
|
6
7
|
server.tool("deep_code_analyze", "Generates a 'Codebase Context Document' for a specific file or task. This tool learns from the codebase structure and symbols to provide deep insights before coding.", {
|
|
@@ -8,6 +9,8 @@ export function registerCodingTools(server, graph) {
|
|
|
8
9
|
taskDescription: z.string().optional().describe("Optional description of what you intend to do")
|
|
9
10
|
}, async ({ filePath, taskDescription }) => {
|
|
10
11
|
try {
|
|
12
|
+
// Ensure path is safe before checking graph
|
|
13
|
+
validatePath(filePath);
|
|
11
14
|
const context = graph.getDeepContext(filePath);
|
|
12
15
|
if (!context) {
|
|
13
16
|
return { content: [{ type: "text", text: `Error: File '${filePath}' not found in graph. Run 'build_project_graph' first.` }], isError: true };
|
|
@@ -44,11 +47,22 @@ export function registerCodingTools(server, graph) {
|
|
|
44
47
|
reasoning: z.string().describe("The 'Deepest Thinking' reasoning behind this change")
|
|
45
48
|
}, async ({ path: filePath, oldText, newText, reasoning }) => {
|
|
46
49
|
try {
|
|
47
|
-
const absolutePath =
|
|
50
|
+
const absolutePath = validatePath(filePath);
|
|
48
51
|
const content = await fs.readFile(absolutePath, 'utf-8');
|
|
49
52
|
if (!content.includes(oldText)) {
|
|
50
53
|
return { content: [{ type: "text", text: "Error: Target text not found. Ensure exact match including whitespace." }], isError: true };
|
|
51
54
|
}
|
|
55
|
+
// Safety Check: Ensure unique match
|
|
56
|
+
const occurrences = content.split(oldText).length - 1;
|
|
57
|
+
if (occurrences > 1) {
|
|
58
|
+
return {
|
|
59
|
+
content: [{
|
|
60
|
+
type: "text",
|
|
61
|
+
text: `Error: Ambiguous match. 'oldText' was found ${occurrences} times. Please provide more surrounding context (lines before/after) to identify the unique location.`
|
|
62
|
+
}],
|
|
63
|
+
isError: true
|
|
64
|
+
};
|
|
65
|
+
}
|
|
52
66
|
const newContent = content.replace(oldText, newText);
|
|
53
67
|
await fs.writeFile(absolutePath, newContent, 'utf-8');
|
|
54
68
|
return {
|
package/dist/tools/filesystem.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import * as fs from 'fs/promises';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
-
import { execAsync } from "../utils.js";
|
|
4
|
+
import { execAsync, validatePath } from "../utils.js";
|
|
5
5
|
export function registerFileSystemTools(server) {
|
|
6
6
|
// 3. shell_execute
|
|
7
7
|
server.tool("shell_execute", "Execute a shell command. SECURITY WARNING: Use this ONLY for safe, non-destructive commands. Avoid 'rm -rf /', format, or destructive operations.", {
|
|
@@ -35,7 +35,8 @@ export function registerFileSystemTools(server) {
|
|
|
35
35
|
path: z.string().describe("Path to the file")
|
|
36
36
|
}, async ({ path }) => {
|
|
37
37
|
try {
|
|
38
|
-
const
|
|
38
|
+
const safePath = validatePath(path);
|
|
39
|
+
const content = await fs.readFile(safePath, 'utf-8');
|
|
39
40
|
return {
|
|
40
41
|
content: [{ type: "text", text: content }]
|
|
41
42
|
};
|
|
@@ -53,9 +54,10 @@ export function registerFileSystemTools(server) {
|
|
|
53
54
|
content: z.string().describe("Content to write")
|
|
54
55
|
}, async ({ path, content }) => {
|
|
55
56
|
try {
|
|
56
|
-
|
|
57
|
+
const safePath = validatePath(path);
|
|
58
|
+
await fs.writeFile(safePath, content, 'utf-8');
|
|
57
59
|
return {
|
|
58
|
-
content: [{ type: "text", text: `Successfully wrote to ${
|
|
60
|
+
content: [{ type: "text", text: `Successfully wrote to ${safePath}` }]
|
|
59
61
|
};
|
|
60
62
|
}
|
|
61
63
|
catch (error) {
|
|
@@ -71,7 +73,7 @@ export function registerFileSystemTools(server) {
|
|
|
71
73
|
path: z.string().optional().default('.').describe("Root directory to search")
|
|
72
74
|
}, async ({ pattern, path: searchPath }) => {
|
|
73
75
|
try {
|
|
74
|
-
const resolvedPath =
|
|
76
|
+
const resolvedPath = validatePath(searchPath || '.');
|
|
75
77
|
const stats = await fs.stat(resolvedPath);
|
|
76
78
|
if (stats.isFile()) {
|
|
77
79
|
const content = await fs.readFile(resolvedPath, 'utf-8');
|
|
@@ -125,7 +127,8 @@ export function registerFileSystemTools(server) {
|
|
|
125
127
|
allowMultiple: z.boolean().optional().default(false).describe("Allow replacing multiple occurrences (default: false)")
|
|
126
128
|
}, async ({ path, oldText, newText, allowMultiple }) => {
|
|
127
129
|
try {
|
|
128
|
-
const
|
|
130
|
+
const safePath = validatePath(path);
|
|
131
|
+
const content = await fs.readFile(safePath, 'utf-8');
|
|
129
132
|
// Check occurrences
|
|
130
133
|
// Escape special regex characters in oldText to treat it as literal string
|
|
131
134
|
const escapeRegExp = (string) => string.replace(/[.*+?^${}()|[\\]/g, '\\$&');
|
|
@@ -143,10 +146,10 @@ export function registerFileSystemTools(server) {
|
|
|
143
146
|
isError: true
|
|
144
147
|
};
|
|
145
148
|
}
|
|
146
|
-
const newContent = content.replace(allowMultiple ? regex : oldText, newText);
|
|
147
|
-
await fs.writeFile(
|
|
149
|
+
const newContent = content.replace(allowMultiple ? regex : oldText, () => newText);
|
|
150
|
+
await fs.writeFile(safePath, newContent, 'utf-8');
|
|
148
151
|
return {
|
|
149
|
-
content: [{ type: "text", text: `Successfully replaced ${allowMultiple ? matchCount : 1} occurrence(s) in ${
|
|
152
|
+
content: [{ type: "text", text: `Successfully replaced ${allowMultiple ? matchCount : 1} occurrence(s) in ${safePath}` }]
|
|
150
153
|
};
|
|
151
154
|
}
|
|
152
155
|
catch (error) {
|
package/dist/tools/graph.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { validatePath } from "../utils.js";
|
|
2
3
|
export function registerGraphTools(server, knowledgeGraph) {
|
|
3
4
|
// 6. build_project_graph
|
|
4
5
|
server.tool("build_project_graph", "Scan the directory and build a dependency graph of the project (Analyzing imports/exports).", {
|
|
5
6
|
path: z.string().optional().default('.').describe("Root directory path to scan (default: current dir)")
|
|
6
7
|
}, async ({ path }) => {
|
|
7
8
|
try {
|
|
8
|
-
const
|
|
9
|
+
const safePath = validatePath(path || '.');
|
|
10
|
+
const result = await knowledgeGraph.build(safePath);
|
|
9
11
|
return {
|
|
10
12
|
content: [{ type: "text", text: `Graph built successfully.\nNodes: ${result.nodeCount}\nTotal Scanned Files: ${result.totalFiles}` }]
|
|
11
13
|
};
|
package/dist/tools/web.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { fetchWithRetry } from "../utils.js";
|
|
2
|
+
import { fetchWithRetry, validatePublicUrl } from "../utils.js";
|
|
3
3
|
import { JSDOM } from 'jsdom';
|
|
4
4
|
import { Readability } from '@mozilla/readability';
|
|
5
5
|
import TurndownService from 'turndown';
|
|
@@ -102,6 +102,7 @@ export function registerWebTools(server) {
|
|
|
102
102
|
body: z.string().optional().describe("Request body (for POST/PUT)")
|
|
103
103
|
}, async ({ url, method, headers, body }) => {
|
|
104
104
|
try {
|
|
105
|
+
await validatePublicUrl(url);
|
|
105
106
|
const response = await fetchWithRetry(url, {
|
|
106
107
|
method,
|
|
107
108
|
headers: headers || {},
|
|
@@ -127,6 +128,7 @@ export function registerWebTools(server) {
|
|
|
127
128
|
url: z.string().url().describe("The URL to read")
|
|
128
129
|
}, async ({ url }) => {
|
|
129
130
|
try {
|
|
131
|
+
await validatePublicUrl(url);
|
|
130
132
|
const response = await fetchWithRetry(url);
|
|
131
133
|
const html = await response.text();
|
|
132
134
|
const doc = new JSDOM(html, { url });
|
|
@@ -135,7 +137,10 @@ export function registerWebTools(server) {
|
|
|
135
137
|
if (!article)
|
|
136
138
|
throw new Error("Could not parse article content");
|
|
137
139
|
const turndownService = new TurndownService();
|
|
138
|
-
|
|
140
|
+
let markdown = turndownService.turndown(article.content || "");
|
|
141
|
+
if (markdown.length > 20000) {
|
|
142
|
+
markdown = markdown.substring(0, 20000) + "\n...(truncated)";
|
|
143
|
+
}
|
|
139
144
|
return {
|
|
140
145
|
content: [{
|
|
141
146
|
type: "text",
|
package/dist/utils.js
CHANGED
|
@@ -1,7 +1,108 @@
|
|
|
1
1
|
import { exec } from 'child_process';
|
|
2
|
-
import
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as dns from 'dns/promises';
|
|
4
|
+
import { URL } from 'url';
|
|
3
5
|
import chalk from 'chalk';
|
|
4
|
-
export const execAsync = promisify(exec);
|
|
6
|
+
// export const execAsync = promisify(exec); // Removed simple promisify
|
|
7
|
+
export function execAsync(command, options = {}) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const timeout = options.timeout || 60000; // Default 60s timeout
|
|
10
|
+
const maxBuffer = options.maxBuffer || 1024 * 1024 * 5; // Default 5MB buffer
|
|
11
|
+
exec(command, { timeout, maxBuffer }, (error, stdout, stderr) => {
|
|
12
|
+
if (error) {
|
|
13
|
+
// Attach stdout/stderr to error for better debugging
|
|
14
|
+
error.stdout = stdout;
|
|
15
|
+
error.stderr = stderr;
|
|
16
|
+
reject(error);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
resolve({ stdout, stderr });
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export class AsyncMutex {
|
|
24
|
+
mutex = Promise.resolve();
|
|
25
|
+
lock() {
|
|
26
|
+
let unlock = () => { };
|
|
27
|
+
const nextLock = new Promise(resolve => {
|
|
28
|
+
unlock = resolve;
|
|
29
|
+
});
|
|
30
|
+
// The caller waits for the previous lock to release
|
|
31
|
+
const willLock = this.mutex.then(() => unlock);
|
|
32
|
+
// The next caller will wait for this lock to release
|
|
33
|
+
this.mutex = nextLock;
|
|
34
|
+
return willLock;
|
|
35
|
+
}
|
|
36
|
+
async dispatch(fn) {
|
|
37
|
+
const unlock = await this.lock();
|
|
38
|
+
try {
|
|
39
|
+
return await Promise.resolve(fn());
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
unlock();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function validatePath(requestedPath) {
|
|
47
|
+
const absolutePath = path.resolve(requestedPath);
|
|
48
|
+
const rootDir = process.cwd();
|
|
49
|
+
if (!absolutePath.startsWith(rootDir)) {
|
|
50
|
+
throw new Error(`Access denied: Path '${requestedPath}' is outside the project root.`);
|
|
51
|
+
}
|
|
52
|
+
return absolutePath;
|
|
53
|
+
}
|
|
54
|
+
function isPrivateIP(ip) {
|
|
55
|
+
// IPv4 ranges
|
|
56
|
+
// 127.0.0.0/8
|
|
57
|
+
// 10.0.0.0/8
|
|
58
|
+
// 172.16.0.0/12
|
|
59
|
+
// 192.168.0.0/16
|
|
60
|
+
// 0.0.0.0/8
|
|
61
|
+
const parts = ip.split('.').map(Number);
|
|
62
|
+
if (parts.length === 4) {
|
|
63
|
+
if (parts[0] === 127)
|
|
64
|
+
return true;
|
|
65
|
+
if (parts[0] === 10)
|
|
66
|
+
return true;
|
|
67
|
+
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
|
|
68
|
+
return true;
|
|
69
|
+
if (parts[0] === 192 && parts[1] === 168)
|
|
70
|
+
return true;
|
|
71
|
+
if (parts[0] === 0)
|
|
72
|
+
return true;
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
// IPv6 checks (simple check for loopback/link-local)
|
|
76
|
+
if (ip === '::1' || ip === '::')
|
|
77
|
+
return true;
|
|
78
|
+
if (ip.startsWith('fc') || ip.startsWith('fd'))
|
|
79
|
+
return true; // Unique Local
|
|
80
|
+
if (ip.startsWith('fe80'))
|
|
81
|
+
return true; // Link Local
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
export async function validatePublicUrl(urlString) {
|
|
85
|
+
const parsed = new URL(urlString);
|
|
86
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
87
|
+
throw new Error('Invalid protocol. Only http and https are allowed.');
|
|
88
|
+
}
|
|
89
|
+
// Attempt to resolve hostname
|
|
90
|
+
try {
|
|
91
|
+
const addresses = await dns.lookup(parsed.hostname, { all: true });
|
|
92
|
+
for (const addr of addresses) {
|
|
93
|
+
if (isPrivateIP(addr.address)) {
|
|
94
|
+
throw new Error(`Access denied: Host '${parsed.hostname}' resolves to private IP ${addr.address}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
// If it's the specific access denied error, rethrow
|
|
100
|
+
if (error instanceof Error && error.message.startsWith('Access denied')) {
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
// Ignore DNS errors here, let fetch handle them (or fail safely)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
5
106
|
class Logger {
|
|
6
107
|
level;
|
|
7
108
|
constructor() {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { registerWebTools } from './tools/web.js';
|
|
3
|
+
import * as utils from './utils.js';
|
|
4
|
+
vi.mock('./utils.js', async (importOriginal) => {
|
|
5
|
+
const actual = await importOriginal();
|
|
6
|
+
return {
|
|
7
|
+
...actual,
|
|
8
|
+
fetchWithRetry: vi.fn(),
|
|
9
|
+
validatePublicUrl: vi.fn(),
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
describe('read_webpage tool', () => {
|
|
13
|
+
let mockToolCallback;
|
|
14
|
+
const mockServer = {
|
|
15
|
+
tool: vi.fn((name, desc, schema, callback) => {
|
|
16
|
+
if (name === 'read_webpage') {
|
|
17
|
+
mockToolCallback = callback;
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
};
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
registerWebTools(mockServer);
|
|
24
|
+
});
|
|
25
|
+
it('should convert HTML to Markdown', async () => {
|
|
26
|
+
const mockHtml = `
|
|
27
|
+
<html>
|
|
28
|
+
<head><title>Test Article</title></head>
|
|
29
|
+
<body>
|
|
30
|
+
<h1>Main Header</h1>
|
|
31
|
+
<p>Paragraph <b>bold</b>.</p>
|
|
32
|
+
</body>
|
|
33
|
+
</html>
|
|
34
|
+
`;
|
|
35
|
+
utils.fetchWithRetry.mockResolvedValue({
|
|
36
|
+
ok: true,
|
|
37
|
+
text: async () => mockHtml
|
|
38
|
+
});
|
|
39
|
+
utils.validatePublicUrl.mockResolvedValue(undefined);
|
|
40
|
+
const result = await mockToolCallback({ url: 'https://example.com' });
|
|
41
|
+
expect(result.isError).toBeUndefined();
|
|
42
|
+
const content = result.content[0].text;
|
|
43
|
+
expect(content).toContain("Title: Test Article");
|
|
44
|
+
expect(content).toContain("Main Header");
|
|
45
|
+
expect(content).toContain("**bold**"); // Markdown bold
|
|
46
|
+
});
|
|
47
|
+
it('should handle private URL validation error', async () => {
|
|
48
|
+
utils.validatePublicUrl.mockRejectedValue(new Error("Access denied: Private IP"));
|
|
49
|
+
const result = await mockToolCallback({ url: 'http://localhost' });
|
|
50
|
+
expect(result.isError).toBe(true);
|
|
51
|
+
expect(result.content[0].text).toContain("Access denied");
|
|
52
|
+
});
|
|
53
|
+
it('should handle fetch errors', async () => {
|
|
54
|
+
utils.validatePublicUrl.mockResolvedValue(undefined);
|
|
55
|
+
utils.fetchWithRetry.mockRejectedValue(new Error("Network Error"));
|
|
56
|
+
const result = await mockToolCallback({ url: 'https://example.com' });
|
|
57
|
+
expect(result.isError).toBe(true);
|
|
58
|
+
expect(result.content[0].text).toContain("Network Error");
|
|
59
|
+
});
|
|
60
|
+
});
|