@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.
@@ -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();
@@ -0,0 +1,3 @@
1
+ import { createRequire as _createRequire } from "module";
2
+ const __require = _createRequire(import.meta.url);
3
+ const fs = __require("fs");
@@ -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();
@@ -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 = path.resolve(filePath);
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 {
@@ -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 content = await fs.readFile(path, 'utf-8');
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
- await fs.writeFile(path, content, 'utf-8');
57
+ const safePath = validatePath(path);
58
+ await fs.writeFile(safePath, content, 'utf-8');
57
59
  return {
58
- content: [{ type: "text", text: `Successfully wrote to ${path}` }]
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 = path.resolve(searchPath || '.');
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 content = await fs.readFile(path, 'utf-8');
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(path, newContent, 'utf-8');
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 ${path}` }]
152
+ content: [{ type: "text", text: `Successfully replaced ${allowMultiple ? matchCount : 1} occurrence(s) in ${safePath}` }]
150
153
  };
151
154
  }
152
155
  catch (error) {
@@ -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 result = await knowledgeGraph.build(path || '.');
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
- const markdown = turndownService.turndown(article.content || "");
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 { promisify } from 'util';
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotza02/sequential-thinking",
3
- "version": "2026.2.9",
3
+ "version": "2026.2.10",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },