From ca461c8b85a6537ee26910928ba95bba6157117c Mon Sep 17 00:00:00 2001 From: Adam Shedivy Date: Wed, 24 Jun 2026 10:19:06 -0500 Subject: [PATCH] fix(config): use glob cwd option so directory tool sources load on Windows resolveDirectoryPaths built its glob pattern with path.join(), producing backslash separators on Windows (e.g. c:\users\...\tools\**\*.{yaml,yml}). glob v10 treats backslashes as escape characters rather than path separators, so the pattern matched nothing and no tools loaded when --tools pointed at a directory. Pass the resolved directory via glob's cwd option (a filesystem path, where separators are fine) and keep the pattern forward-slash only. Apply the same fix to resolveGlobPaths, which had the identical join()-into-pattern bug when baseDir was set. Adds path-resolution tests covering directory and glob sources, including nested files and non-YAML exclusion. Fixes #150 Signed-off-by: Adam Shedivy --- .../utils/config/toolConfigBuilder.ts | 17 ++-- .../toolConfigBuilder.pathResolution.test.ts | 87 +++++++++++++++++++ 2 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 packages/server/tests/ibmi-mcp-server/utils/config/toolConfigBuilder.pathResolution.test.ts diff --git a/packages/server/src/ibmi-mcp-server/utils/config/toolConfigBuilder.ts b/packages/server/src/ibmi-mcp-server/utils/config/toolConfigBuilder.ts index 121544d..16ca71a 100644 --- a/packages/server/src/ibmi-mcp-server/utils/config/toolConfigBuilder.ts +++ b/packages/server/src/ibmi-mcp-server/utils/config/toolConfigBuilder.ts @@ -7,7 +7,7 @@ */ import { z } from "zod"; -import { resolve, join } from "path"; +import { resolve } from "path"; import { existsSync } from "fs"; import { glob } from "glob"; import { @@ -727,8 +727,11 @@ export class ToolConfigBuilder { ); } - const pattern = join(resolvedDir, "**/*.{yaml,yml}"); - return glob.sync(pattern, { absolute: true }); + // glob requires forward-slash patterns even on Windows; backslashes are + // interpreted as escape characters, not path separators. Pass the resolved + // directory via `cwd` (a filesystem path, so separators are fine there) and + // keep the pattern forward-slash only so directory sources work on Windows. + return glob.sync("**/*.{yaml,yml}", { cwd: resolvedDir, absolute: true }); } /** @@ -736,13 +739,15 @@ export class ToolConfigBuilder { * @private */ private resolveGlobPaths(pattern: string, baseDir?: string): string[] { - const searchPattern = baseDir ? join(baseDir, pattern) : pattern; - const paths = glob.sync(searchPattern, { absolute: true }); + // Resolve the pattern relative to baseDir via glob's `cwd` option rather + // than join(), which would corrupt the forward-slash glob pattern into + // backslashes on Windows (glob treats backslashes as escape characters). + const paths = glob.sync(pattern, { absolute: true, cwd: baseDir }); if (paths.length === 0) { throw new McpError( JsonRpcErrorCode.ConfigurationError, - `No files found matching pattern: ${searchPattern}`, + `No files found matching pattern: ${baseDir ? `${baseDir} :: ${pattern}` : pattern}`, ); } diff --git a/packages/server/tests/ibmi-mcp-server/utils/config/toolConfigBuilder.pathResolution.test.ts b/packages/server/tests/ibmi-mcp-server/utils/config/toolConfigBuilder.pathResolution.test.ts new file mode 100644 index 0000000..e2d06f3 --- /dev/null +++ b/packages/server/tests/ibmi-mcp-server/utils/config/toolConfigBuilder.pathResolution.test.ts @@ -0,0 +1,87 @@ +/** + * @fileoverview Path-resolution tests for ToolConfigBuilder. + * + * Covers the directory and glob source resolution used when `--tools` points at + * a directory (or a glob) rather than a single YAML file. + * + * Regression guard for the Windows bug where a glob pattern was built with + * path.join(), producing backslashes that glob (v10+) treats as escape + * characters rather than path separators, so no tool files were discovered. + * The fix passes the directory via glob's `cwd` option and keeps the pattern + * forward-slash only. See IBM/ibmi-mcp-server#150. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join, resolve } from "path"; + +// Block the scheduler singleton (loaded transitively via the utils barrel). +vi.mock("../../../../src/utils/scheduling/index.js", () => ({ + SchedulerService: { getInstance: vi.fn() }, + schedulerService: {}, +})); + +import { ToolConfigBuilder } from "../../../../src/ibmi-mcp-server/utils/config/toolConfigBuilder.js"; + +// resolveDirectoryPaths / resolveGlobPaths are private; exercise them directly +// via the singleton instance to keep the test focused on path resolution +// without standing up the full YAML-parsing pipeline. +type PathResolver = { + resolveDirectoryPaths(directoryPath: string): string[]; + resolveGlobPaths(pattern: string, baseDir?: string): string[]; +}; + +describe("ToolConfigBuilder path resolution", () => { + let tmpRoot: string; + let builder: PathResolver; + + beforeAll(() => { + builder = ToolConfigBuilder.getInstance() as unknown as PathResolver; + + tmpRoot = mkdtempSync(join(tmpdir(), "ibmi-tools-")); + mkdirSync(join(tmpRoot, "nested"), { recursive: true }); + + writeFileSync(join(tmpRoot, "a.yaml"), "tools: {}\n"); + writeFileSync(join(tmpRoot, "b.yml"), "tools: {}\n"); + writeFileSync(join(tmpRoot, "nested", "c.yaml"), "tools: {}\n"); + // Non-YAML files must be ignored. + writeFileSync(join(tmpRoot, "README.md"), "ignore me\n"); + }); + + afterAll(() => { + rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it("finds all .yaml and .yml files in a directory (including nested)", () => { + const paths = builder.resolveDirectoryPaths(tmpRoot); + + expect(paths).toHaveLength(3); + // Returned paths are absolute and forward-slash-comparable. + const names = paths.map((p) => p.split(/[\\/]/).pop()).sort(); + expect(names).toEqual(["a.yaml", "b.yml", "c.yaml"]); + for (const p of paths) { + expect(resolve(p)).toBe(p); + } + }); + + it("throws a configuration error for a missing directory", () => { + expect(() => + builder.resolveDirectoryPaths(join(tmpRoot, "does-not-exist")), + ).toThrow(/Configuration directory not found/); + }); + + it("resolves a relative glob pattern against baseDir", () => { + const paths = builder.resolveGlobPaths("**/*.{yaml,yml}", tmpRoot); + + expect(paths).toHaveLength(3); + const names = paths.map((p) => p.split(/[\\/]/).pop()).sort(); + expect(names).toEqual(["a.yaml", "b.yml", "c.yaml"]); + }); + + it("throws when a glob pattern matches nothing", () => { + expect(() => builder.resolveGlobPaths("**/*.json", tmpRoot)).toThrow( + /No files found matching pattern/, + ); + }); +});