Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 0 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,6 @@ jobs:
- name: Run tests with coverage
run: bun run test:coverage

# - name: Prepare coverage report in markdown
# if: github.event_name == 'pull_request'
# id: coverage
# uses: fingerprintjs/action-coverage-report-md@v2
# with:
# textReportPath: './coverage.txt'
# srcBasePath: './src'

# - name: Comment coverage report on PR
# if: github.event_name == 'pull_request'
# uses: marocchino/sticky-pull-request-comment@v2
# with:
# message: ${{ steps.coverage.outputs.markdownReport }}
# header: Coverage Report

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ coverage.txt
.env.production.local

# Data
.sqlite
*.sqlite

# Others
TODO.md
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mcpland",
"version": "0.3.0",
"version": "0.3.1",
"private": false,
"type": "module",
"description": "Building blocks for implementing Model Context Protocol tools.",
Expand Down
81 changes: 54 additions & 27 deletions src/core/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,47 @@ export abstract class McpTool {
return fileText;
}

/**
* Check if a file is likely binary by examining its content
*/
private async isBinaryFile(filePath: string): Promise<boolean> {
const { openSync, readSync, closeSync } = await import('node:fs');
try {
// Read first 8KB only
const fd = openSync(filePath, 'r');
const buffer = Buffer.allocUnsafe(8192);
const bytesRead = readSync(fd, buffer, 0, 8192, 0);
closeSync(fd);
const chunk = buffer.subarray(0, bytesRead);

Comment on lines +196 to +205

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Close the file descriptor on all paths (use finally)
If readSync throws, closeSync isn’t reached, leaking the fd. Wrap closeSync in a finally and guard it.

-	private async isBinaryFile(filePath: string): Promise<boolean> {
-		const { openSync, readSync, closeSync } = await import('node:fs');
-		try {
-			// Read first 8KB only
-			const fd = openSync(filePath, 'r');
-			const buffer = Buffer.allocUnsafe(8192);
-			const bytesRead = readSync(fd, buffer, 0, 8192, 0);
-			closeSync(fd);
-			const chunk = buffer.subarray(0, bytesRead);
+	private async isBinaryFile(filePath: string): Promise<boolean> {
+		const { openSync, readSync, closeSync } = await import('node:fs');
+		let fd: number | undefined;
+		try {
+			// Read first 8KB only
+			fd = openSync(filePath, 'r');
+			const buffer = Buffer.allocUnsafe(8192);
+			const bytesRead = readSync(fd, buffer, 0, 8192, 0);
+			const chunk = buffer.subarray(0, bytesRead);
@@
-		} catch {
-			// If we can't read the file, assume it's binary to be safe
-			return true;
-		}
+		} catch {
+			// If we can't read the file, assume it's binary to be safe
+			return true;
+		} finally {
+			if (fd !== undefined) {
+				try { closeSync(fd); } catch {}
+			}
+		}

Also applies to: 228-231

🤖 Prompt for AI Agents
In src/core/mcp.ts around lines 196 to 205 the file descriptor opened with
openSync may not be closed if readSync throws; change the function to ensure
closeSync is always called by moving closeSync into a finally block (track the
fd variable outside try, check fd !== undefined before closing) so the
descriptor is closed on success and on error. Apply the same pattern to the
related code at lines 228-231 to guard against fd leaks.

// Check for null bytes (common in binary files)
if (chunk.indexOf(0) !== -1) {
return true;
}

// Check ratio of non-printable characters
let nonPrintableCount = 0;
for (let i = 0; i < chunk.length; i++) {
const byte = chunk[i];
// Consider bytes outside printable ASCII range (excluding common whitespace)
/* c8 ignore start */
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
nonPrintableCount++;
} else if (byte > 126) {
nonPrintableCount++;
}
/* c8 ignore stop */
}

// If more than 30% of characters are non-printable, consider it binary
const nonPrintableRatio = nonPrintableCount / chunk.length;
return nonPrintableRatio > 0.3;
} /* c8 ignore next - file might be binary despite our check */ catch {}

/* c8 ignore next */
return true;
}

protected async fetchFromDirectory(): Promise<string> {
// Build context from contextDir (recursive text files) when provided
let docsText: string = '';
Expand All @@ -198,25 +239,9 @@ export abstract class McpTool {
const dirToRead = `${baseDir}/${this.spec.contextDir}`;
const { readdirSync, statSync, readFileSync } = await import('node:fs');
const pathMod = await import('node:path');
const TEXT_EXTS = new Set([
'.txt',
'.md',
'.mdx',
'.markdown',
'.json',
'.yml',
'.yaml',
'.ini',
'.cfg',
'.conf',
'.toml',
'.csv',
'.tsv',
'.html',
'.htm',
]);

const files: string[] = [];
const walk = (dir: string) => {
const walk = async (dir: string) => {
let entries: string[] = [];
try {
entries = readdirSync(dir);
Expand All @@ -227,25 +252,27 @@ export abstract class McpTool {
const full = pathMod.join(dir, entry);
try {
const st = statSync(full);
if (st.isDirectory()) walk(full);
else if (st.isFile()) {
const ext = pathMod.extname(entry).toLowerCase();
if (TEXT_EXTS.has(ext)) files.push(full);
if (st.isDirectory()) {
await walk(full);
} else if (st.isFile()) {
// Skip files that are likely binary
const isBinary = await this.isBinaryFile(full);
if (!isBinary) {
files.push(full);
}
}
// eslint-disable-next-line no-empty
} catch {}
}
};
walk(dirToRead);
await walk(dirToRead);
const pieces: string[] = [];
for (const f of files) {
try {
const rel = pathMod.relative(dirToRead, f);
const content = readFileSync(f, 'utf-8');
pieces.push(`=== ${rel} ===\n\n${content}`);
} catch {
// ignore read errors
}
} /* c8 ignore next - file might be binary despite our check */ catch {}
}
docsText = pieces.join('\n\n');
}
Expand Down Expand Up @@ -309,7 +336,7 @@ export abstract class McpTool {
)
.join('\n\n');

const serverResult:ServerResult = {
const serverResult: ServerResult = {
content: [
{
type: 'text',
Expand Down
4 changes: 2 additions & 2 deletions src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,9 @@ export class SqliteEmbedStore {
idx++;
continue; // Skip if chunk already exists
}
log.warn(`Embedding chunk ${idx}`);
log.warn(`[${mcpId}/${toolId}] Embedding chunk ${idx} of ${chunks.length}`);
const vector = await this.embedText(content);
log.warn(`Inserting chunk ${idx}`);
log.warn(`[${mcpId}/${toolId}] Inserting chunk ${idx} of ${chunks.length}`);
this.insertChunk(source.id, idx, content, String(hash), vector);
idx++;
}
Expand Down
1 change: 0 additions & 1 deletion src/mcps/angular/tools/docs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ const chunkOptions = { maxChars: 1200, overlap: 200 };
const spec: McpToolSpec = {
name: 'docs',
description: 'Angular docs context search tool.',
sourceId: 'angular-llm-context',
contextUrl,
chunkOptions,
schema: z.object({
Expand Down
86 changes: 80 additions & 6 deletions test/src/core/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,18 +113,87 @@ describe('McpTool base class', () => {
const isDirectory = () => /(^|\/)docs(\/)?$/.test(p) || /\/docs\/sub$/.test(p);
return { isFile, isDirectory } as any;
});
const readFileMock = vi.fn((p: string) => {
if (/a\.md$/.test(p)) return 'Content A';
if (/b\.txt$/.test(p)) return 'Content B';
throw new Error('should not read binaries');
const readFileMock = vi.fn((p: string, options?: any) => {
// Handle binary detection reads (with encoding: null)
if (options && options.encoding === null) {
if (/img\.png$/.test(p)) {
// Return a buffer with null bytes to simulate binary content
return Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00]); // PNG header with null byte
}
if (/bin\.bin$/.test(p)) {
// Return a buffer with null bytes to simulate binary content
return Buffer.from([0x00, 0x01, 0x02, 0x03]);
}
// For text files during binary detection
if (/a\.md$/.test(p)) return Buffer.from('Content A', 'utf-8');
if (/b\.txt$/.test(p)) return Buffer.from('Content B', 'utf-8');
} else {
// Handle regular text reads
if (/a\.md$/.test(p)) return 'Content A';
if (/b\.txt$/.test(p)) return 'Content B';
}
throw new Error('should not read this file: ' + p);
});

// Mock path module as well
const pathMock = {
join: (...parts: string[]) => parts.join('/'),
relative: (from: string, to: string) => {
// Simplified relative path calculation for test
if (to.includes('a.md')) return 'a.md';
if (to.includes('b.txt')) return 'sub/b.txt';
return 'unknown';
},
dirname: (path: string) => {
const parts = path.split('/');
return parts.slice(0, -1).join('/');
}
};

// Mock the new fs methods used by isBinaryFile
const openSyncMock = vi.fn((path: string) => {
// Return a fake file descriptor
return 123;
});
const readSyncMock = vi.fn((fd: number, buffer: Buffer, offset: number, length: number, position: number) => {
// Simulate reading file content into buffer based on the current path being tested
// We need to track the path, so we'll use a closure to remember the last opened path
const mockPath = openSyncMock.mock.calls[openSyncMock.mock.calls.length - 1]?.[0] || '';

if (fd === 123) { // Our fake fd
let content: Buffer;
if (/img\.png$/.test(mockPath)) {
content = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00]); // PNG with null byte
} else if (/bin\.bin$/.test(mockPath)) {
content = Buffer.from([0x00, 0x01, 0x02, 0x03]); // Binary with null bytes
} else if (/a\.md$/.test(mockPath)) {
content = Buffer.from('Content A', 'utf-8');
} else if (/b\.txt$/.test(mockPath)) {
content = Buffer.from('Content B', 'utf-8');
} else {
content = Buffer.from('Default content', 'utf-8');
}
const bytesToCopy = Math.min(content.length, length);
content.copy(buffer, offset, 0, bytesToCopy);
return bytesToCopy;
}
return 0;
});
const closeSyncMock = vi.fn(() => {
// No-op for closing file descriptor
});

vi.doMock('node:fs', () => ({
readdirSync: readdirMock,
statSync: statMock,
readFileSync: readFileMock,
openSync: openSyncMock,
readSync: readSyncMock,
closeSync: closeSyncMock,
}));

vi.doMock('node:path', () => pathMock);

Comment on lines 186 to +196

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

doMock after importing McpTool won’t affect it — reset modules and re-import

These tests mock node:fs/node:path with vi.doMock, but McpTool was imported earlier at file top. Since doMock isn’t hoisted, the mocks won’t be applied to the already-loaded module, causing flakiness and leaking real FS. Reset and re-import the SUT, then define classes against that import.

Minimal fix inside each test after doMock:

vi.resetModules();
const { McpTool: LocalMcpTool } = await import('../../../src/core/mcp');
// then: class DirTool extends LocalMcpTool { ... }

Also applies to: 250-255, 289-294, 328-333

🤖 Prompt for AI Agents
In test/src/core/mcp.test.ts around lines 186 to 196, the test calls vi.doMock
after McpTool was already imported so the mocks won’t be applied to the loaded
module; after calling vi.doMock add vi.resetModules() and re-import McpTool from
'../../../src/core/mcp' (e.g. const { McpTool: LocalMcpTool } = await
import(...)) and then define any test-local subclasses against LocalMcpTool;
apply the same change at the other affected ranges (lines 250-255, 289-294,
328-333).

class DirTool extends McpTool {
constructor() {
super({
Expand Down Expand Up @@ -156,15 +225,20 @@ describe('McpTool base class', () => {
expect(arg).toContain('=== sub/b.txt ===');
expect(arg).toContain('Content B');

// Ensure binaries were not read
expect(readFileMock).toHaveBeenCalledTimes(2);
// Ensure binaries were detected and skipped (not read for text content)
// The readFileMock will be called for binary detection (with encoding: null) and text reads
expect(readFileMock).toHaveBeenCalled();

// Ingestion should include dir meta
expect(ingestSpy).toHaveBeenCalledWith(
{ id: 'source-1', meta: { name: 'dirtool', url: undefined, file: undefined, dir: 'docs' } },
['c1', 'c2'],
{ mcpId: 'foo', toolId: 'dirtool' }
);

// Clean up mocks
vi.doUnmock('node:fs');
vi.doUnmock('node:path');
});

it('contextDir handles readdirSync errors gracefully', async () => {
Expand Down
Loading