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/, + ); + }); +});