diff --git a/cspell.config.json b/cspell.config.json index 1a1df4e..2e97eaa 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -11,6 +11,7 @@ "onsessionclosed", "patternfly", "rereview", + "rsort", "sparkline", "streamable", "unrepresentable", diff --git a/guidelines/agent_behaviors.md b/guidelines/agent_behaviors.md index 9e238d0..8614af6 100644 --- a/guidelines/agent_behaviors.md +++ b/guidelines/agent_behaviors.md @@ -118,11 +118,15 @@ When implementing tools that interact with the local filesystem, always use `res ```typescript import { resolveLocalPathFunction } from './server.getResources'; // ... -const safePath = await resolveLocalPathFunction(requestedPath, rootDir); +let safePath; -if (!safePath) { +try { + safePath = resolveLocalPathFunction(requestedPath); +} catch { throw new McpError(ErrorCode.InvalidParams, 'Access denied'); } + +// use safePath for subsequent file operations ``` ### 6.2 Plugin Isolation diff --git a/package-lock.json b/package-lock.json index 7c41dd6..dd480d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@patternfly/patternfly-component-schemas": "1.2.0", "fastest-levenshtein": "1.0.16", "pid-port": "2.0.1", + "semver": "7.7.3", "zod": "4.3.6" }, "bin": { @@ -24,6 +25,7 @@ "@cdcabrera/eslint-config-toolkit": "^0.4.0", "@types/jest": "^30.0.0", "@types/node": "^25.0.10", + "@types/semver": "7.7.1", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", "changelog-light": "^3.0.5", @@ -98,6 +100,16 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/eslint-parser": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.6.tgz", @@ -117,6 +129,16 @@ "eslint": "^7.5.0 || ^8.0.0 || ^9.0.0" } }, + "node_modules/@babel/eslint-parser/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", @@ -164,6 +186,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -3575,6 +3607,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -4961,19 +5000,6 @@ "node": ">=20.0.0" } }, - "node_modules/changelog-light/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -5543,19 +5569,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/cspell/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -6162,19 +6175,6 @@ "eslint": ">=6.0.0" } }, - "node_modules/eslint-compat-utils/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", @@ -6328,6 +6328,16 @@ "node": "*" } }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-plugin-jest": { "version": "29.12.1", "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.12.1.tgz", @@ -6393,19 +6403,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-plugin-jsdoc/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-plugin-json": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/eslint-plugin-json/-/eslint-plugin-json-4.0.1.tgz", @@ -6524,19 +6521,6 @@ "node": ">= 4" } }, - "node_modules/eslint-plugin-n/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -6625,6 +6609,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-plugin-unicorn": { "version": "62.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-62.0.0.tgz", @@ -6661,19 +6655,6 @@ "eslint": ">=9.38.0" } }, - "node_modules/eslint-plugin-unicorn/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -8512,19 +8493,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", @@ -9081,19 +9049,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jest-util": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", @@ -9476,19 +9431,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -10892,13 +10834,15 @@ "license": "MIT" }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/send": { @@ -11816,19 +11760,6 @@ "ts-jest": ">=20.0.0" } }, - "node_modules/ts-jest/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/ts-jest/node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", diff --git a/package.json b/package.json index 646bef8..6473018 100644 --- a/package.json +++ b/package.json @@ -59,12 +59,14 @@ "@patternfly/patternfly-component-schemas": "1.2.0", "fastest-levenshtein": "1.0.16", "pid-port": "2.0.1", + "semver": "7.7.3", "zod": "4.3.6" }, "devDependencies": { "@cdcabrera/eslint-config-toolkit": "^0.4.0", "@types/jest": "^30.0.0", "@types/node": "^25.0.10", + "@types/semver": "7.7.1", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", "changelog-light": "^3.0.5", diff --git a/src/__tests__/__snapshots__/options.defaults.test.ts.snap b/src/__tests__/__snapshots__/options.defaults.test.ts.snap index 973ea9f..eefe88c 100644 --- a/src/__tests__/__snapshots__/options.defaults.test.ts.snap +++ b/src/__tests__/__snapshots__/options.defaults.test.ts.snap @@ -32,10 +32,10 @@ exports[`options defaults should return specific properties: defaults 1`] = ` "nodeVersion": 22, "patternflyOptions": { "availableResourceVersions": [ - "v6", + "6.0.0", ], "default": { - "defaultVersion": "v6", + "defaultVersion": "6.0.0", "versionStrategy": "highest", "versionWhitelist": [ "@patternfly/react-core", diff --git a/src/__tests__/__snapshots__/server.toolsUser.test.ts.snap b/src/__tests__/__snapshots__/server.toolsUser.test.ts.snap index b5434c7..c4e3ec3 100644 --- a/src/__tests__/__snapshots__/server.toolsUser.test.ts.snap +++ b/src/__tests__/__snapshots__/server.toolsUser.test.ts.snap @@ -75,7 +75,7 @@ exports[`normalizeFilePackage handles file URL 1`] = ` exports[`normalizeFilePackage handles http URL (not file) 1`] = ` { "fsReadDir": "/", - "isFilePath": true, + "isFilePath": false, "isFileUrl": false, "isUrlLike": true, "normalizedUrl": "/module.mjs", @@ -89,7 +89,7 @@ exports[`normalizeFilePackage handles invalid file URLs, encoding 1`] = ` { "error": "true", "fsReadDir": "/", - "isFilePath": false, + "isFilePath": true, "isFileUrl": true, "isUrlLike": true, "normalizedUrl": "/%E0%A4%A", @@ -184,7 +184,7 @@ exports[`normalizeFileUrl handles invalid file URLs, encoding 1`] = ` { "error": "true", "fsReadDir": "/", - "isFilePath": false, + "isFilePath": true, "isFileUrl": true, "isUrlLike": true, "normalizedUrl": "/%E0%A4%A", diff --git a/src/__tests__/patternFly.helpers.test.ts b/src/__tests__/patternFly.helpers.test.ts new file mode 100644 index 0000000..67dcfd2 --- /dev/null +++ b/src/__tests__/patternFly.helpers.test.ts @@ -0,0 +1,156 @@ +import { findClosestPatternFlyVersion } from '../patternFly.helpers'; +import { readLocalFileFunction } from '../server.getResources'; +import { DEFAULT_OPTIONS } from '../options.defaults'; + +jest.mock('../server.getResources', () => ({ + ...jest.requireActual('../server.getResources'), + readLocalFileFunction: { + memo: jest.fn() + } +})); + +const mockReadLocalFile = readLocalFileFunction.memo as jest.Mock; + +describe('findClosestPatternFlyVersion', () => { + it.each([ + { + description: 'non-existent path', + path: '/mock/package.json', + expected: '6.0.0' + }, + { + description: 'non-string path', + path: 1, + expected: '6.0.0' + } + ])('should return default version if no package.json is found, $description', async ({ path, expected }) => { + const version = await findClosestPatternFlyVersion(path as any); + + expect(version).toBe(expected); + }); + + it.each([ + { + description: 'basic', + deps: { + '@patternfly/react-core': '^5.0.0' + }, + expected: '5.0.0' + }, + { + description: 'greater than or equal, major, minor, patch', + deps: { + '@patternfly/react-core': '<=4.5.5' + }, + expected: '4.0.0' + }, + { + description: 'range, greater than less than equal', + deps: { + '@patternfly/react-core': '>=4.0.0 <=5.0.0' + }, + expected: '5.0.0' + }, + { + description: 'range, inclusive', + deps: { + '@patternfly/react-core': '4.0.0 - 5.0.0' + }, + expected: '5.0.0' + }, + { + description: 'git path', + deps: { + '@patternfly/react-core': 'https://github.com/patternfly/patternfly-mcp.git#v5' + }, + expected: '6.0.0' + }, + { + description: 'unknown local path', + deps: { + '@patternfly/react-core': './patternfly-mcp#v5' + }, + expected: '6.0.0' + }, + { + description: 'mismatched versions', + deps: { + '@patternfly/patternfly': '^4.0.0', + '@patternfly/react-core': '^6.0.0' + }, + expected: '6.0.0' + }, + { + description: 'fuzzy match -next', + deps: { + '@patternfly/react-core-next': '^5.0.0' + }, + expected: '5.0.0' + }, + { + description: 'fuzzy match -rc', + deps: { + '@patternfly/react-core-rc': '^5.0.0' + }, + expected: '5.0.0' + }, + { + description: 'fuzzy match -alpha', + deps: { + '@patternfly/patternfly-alpha': '^5.0.0' + }, + expected: '5.0.0' + }, + { + description: 'fuzzy match -beta', + deps: { + '@patternfly/patternfly-beta': '^5.0.0' + }, + expected: '5.0.0' + }, + { + description: 'attempted fuzzy match scope', + deps: { + '@scope/patternfly-alt': '^5.0.0' + }, + expected: '6.0.0' + }, + { + description: 'attempted false positive fuzzy match', + deps: { + 'patternfly/patternfly': '^5.0.0' + }, + expected: '6.0.0' + }, + { + description: 'wildcard match', + deps: { + '@patternfly/patternfly': '^5.x.x' + }, + expected: '5.0.0' + }, + { + description: 'mismatched versions with expanded available versions', + deps: { + '@patternfly/patternfly': '^4.0.0', + '@patternfly/react-core': '^5.0.0' + }, + expected: '5.0.0' + } + ])('should attempt to match whitelisted packages, $description', async ({ deps, expected }) => { + mockReadLocalFile.mockResolvedValue(JSON.stringify({ + dependencies: { ...deps } + })); + + // Use the PF MCP package.json so we can override with "mockReadLocalFile". Override available resource versions. + const version = await findClosestPatternFlyVersion(process.cwd(), { + ...DEFAULT_OPTIONS, + patternflyOptions: { + ...DEFAULT_OPTIONS.patternflyOptions, + availableResourceVersions: ['4.0.0', '5.0.0', '6.0.0'] + } + }); + + expect(version).toBe(expected); + }); +}); diff --git a/src/__tests__/server.getResources.test.ts b/src/__tests__/server.getResources.test.ts index 42709a9..17d99fb 100644 --- a/src/__tests__/server.getResources.test.ts +++ b/src/__tests__/server.getResources.test.ts @@ -1,5 +1,8 @@ import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { + matchPackageVersion, + findNearestPackageJson, readLocalFileFunction, fetchUrlFunction, processDocsFunction, @@ -24,6 +27,143 @@ jest.mock('../server.caching', () => ({ const mockReadFile = readFile as jest.MockedFunction; +describe('matchPackageVersion', () => { + it.each([ + { + description: 'with semver', + version: '1.2.3', + expectedIndex: 0 + }, + { + description: 'with semver with leading v', + version: 'v1.2.3', + expectedIndex: 0 + }, + { + description: 'with greater than', + version: '>1.0.0', + expectedIndex: 2 + }, + { + description: 'with less than', + version: '<2.0.0', + expectedIndex: 0 + }, + { + description: 'unavailable version', + version: 'v4', + expectedIndex: -1 + }, + { + description: 'with inclusive range', + version: '1.2.3 - 3.0.0', + expectedIndex: 2 + }, + { + description: 'with range greater than and less than', + version: '>1.2.3 <2.0.0', + expectedIndex: -1 + }, + { + description: 'with range reversed greater than and less than', + version: '<2.0.0 >1.2.3', + expectedIndex: -1 + }, + { + description: 'with range greater than and less than equal', + version: '>1.2.3 <=2.0.0', + expectedIndex: 1 + }, + { + description: 'with range greater than equal and less than', + version: '>=1.2.3 =1.2.3', + expectedIndex: 0 + }, + { + description: 'with range reversed, wildcards, greater than and less than equal', + version: '<=2.x.x >1.2.3', + expectedIndex: 1 + }, + { + description: 'with range greater than equal and less than equal', + version: '>=1.2.3 <=3.0.0', + expectedIndex: 2 + }, + { + description: 'with range reversed greater than equal and less than equal', + version: '<=3.0.0 >=1.2.3', + expectedIndex: 2 + } + ])('should match version: $description', ({ version, expectedIndex }) => { + const supportedVersions = ['1.2.3', '2.0.0', '3.0.0']; + const result = matchPackageVersion(version, supportedVersions); + + expect(supportedVersions.indexOf(result?.version as any)).toBe(expectedIndex); + }); +}); + +describe('findNearestPackageJson', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should find the nearest package.json', () => { + // Use the PF MCP package.json + const path = findNearestPackageJson(process.cwd()); + + expect(path).toBe(join(process.cwd(), 'package.json')); + }); + + it.each([ + { + description: 'current working directory with relative made up directory', + packagePath: `${process.cwd()}/./madeUpLoremIpsum/directory` + }, + { + description: 'current working directory with relative made up and one up directory', + packagePath: `${process.cwd()}/../madeUpLoremIpsum/directory` + }, + { + description: 'relative made up directory', + packagePath: './madeUpLoremIpsum/directory' + }, + { + description: 'relative made up and one up directory', + packagePath: '../madeUpLoremIpsum/directory' + }, + { + description: 'Windows relative made up and one up directory', + packagePath: '..\\madeUpLoremIpsum\\directory' + } + ])('should attempt to find the nearest package.json, $description', ({ packagePath }) => { + // Use the PF MCP package.json + const path = findNearestPackageJson(packagePath); + + expect(path).toBeDefined(); + expect(path).not.toContain('madeUpLoremIpsum'); + }); + + it.each([ + { + description: 'absolute made up directory', + packagePath: '/madeUpLoremIpsum/directory' + }, + { + description: 'file URL', + packagePath: 'file://madeUpLoremIpsum/directory' + } + ])('should return undefined if no package.json is found', ({ packagePath }) => { + const path = findNearestPackageJson(packagePath); + + expect(path).toBeUndefined(); + }); +}); + describe('readLocalFileFunction', () => { beforeEach(() => { jest.clearAllMocks(); @@ -114,7 +254,7 @@ describe('resolveLocalPathFunction', () => { path: './subdir/../file.md' } ])('should return a consistent path, $description', ({ path }) => { - const result = resolveLocalPathFunction(path, { ...DEFAULT_OPTIONS, contextPath: '/app/project' }); + const result = resolveLocalPathFunction(path, undefined, { ...DEFAULT_OPTIONS, contextPath: '/app/project' }); expect(result).toMatchSnapshot(); }); @@ -141,7 +281,7 @@ describe('resolveLocalPathFunction', () => { shouldThrow: 'Access denied' } ])('should return a consistent path or throw, $description', ({ path, shouldThrow }) => { - expect(() => resolveLocalPathFunction(path, { ...DEFAULT_OPTIONS, contextPath: '/app/project' })).toThrow(shouldThrow); + expect(() => resolveLocalPathFunction(path, undefined, { ...DEFAULT_OPTIONS, contextPath: '/app/project' })).toThrow(shouldThrow); }); }); diff --git a/src/__tests__/server.helpers.test.ts b/src/__tests__/server.helpers.test.ts index 2c3b27a..10d4bf0 100644 --- a/src/__tests__/server.helpers.test.ts +++ b/src/__tests__/server.helpers.test.ts @@ -6,6 +6,8 @@ import { isPlainObject, isPromise, isReferenceLike, + isUrl, + isPath, mergeObjects, portValid, stringJoin, @@ -551,6 +553,57 @@ describe('isReferenceLike', () => { }); }); +describe('isUrl', () => { + it.each([ + { description: 'http', url: 'http://example.com' }, + { description: 'https', url: 'https://example.com' }, + { description: 'file', url: 'file:///path/to/file.txt' }, + { description: 'node', url: 'node://path/to/file.txt' }, + { description: 'data', url: 'data:text/plain;base64,1234567890==' } + ])('should validate $description', ({ url }) => { + expect(isUrl(url)).toBe(true); + }); + + it.each([ + { description: 'invalid protocol', url: 'ftp://example.com' }, + { description: 'random', url: 'random://example.com' }, + { description: 'null', url: null }, + { description: 'undefined', url: undefined } + ])('should fail, $description', ({ url }) => { + expect(isUrl(url as any)).toBe(false); + }); + + it.each([ + { description: 'http allowed, strict', options: { isStrict: true, allowedProtocols: ['http'] }, expected: false }, + { description: 'http allowed, not strict', options: { isStrict: false, allowedProtocols: ['http'] }, expected: true }, + { description: 'ftp allowed, strict', options: { isStrict: true, allowedProtocols: ['ftp'] }, expected: true }, + { description: 'ftp allowed, not strict', options: { isStrict: false, allowedProtocols: ['ftp'] }, expected: true } + ])('should handle allowedProtocols and strict options, $description', ({ options, expected }) => { + expect(isUrl('ftp://example.com', options)).toBe(expected); + }); +}); + +describe('isPath', () => { + it.each([ + { description: 'absolute path', file: '/path/to/file.txt', expected: true }, + { description: 'absolute path ref no extension', file: '/path/to/another/file', expected: true }, + { description: 'min file extension', file: 'path/to/another/file.sh', options: { isStrict: false }, expected: true }, + { description: 'min file extension', file: 'path/to/another/file.sh', options: { isStrict: true }, expected: false }, + { description: 'potential multiple extensions', file: 'path/to/another/file.test.js', options: { isStrict: false }, expected: true }, + { description: 'potential multiple extensions', file: 'path/to/another/file.test.js', options: { isStrict: true }, expected: false }, + { description: 'current dir ref', file: './path/to/another/file.txt', expected: true }, + { description: 'parent dir ref', file: '../path/to/another/file.txt', expected: true }, + { description: 'file://', file: 'file://path/to/another/file.txt', expected: true }, + { description: 'no file extension or dir ref', file: 'path/to/another/file', options: { isStrict: false }, expected: true }, + { description: 'no file extension or dir ref', file: 'path/to/another/file', options: { isStrict: true }, expected: false }, + { description: 'Windows drive', file: 'C:/path/to/another/file.txt', expected: true }, + { description: 'Windows separator no file extension', file: 'C:\\path\\to\\another\\file', options: { sep: '\\' }, expected: true }, + { description: 'Windows separator file extension', file: 'C:\\path\\to\\another\\file.txt', options: { sep: '\\' }, expected: true } + ])('should validate $description', ({ file, options, expected }) => { + expect(isPath(file, { sep: '/', ...options } as any)).toBe(expected); + }); +}); + describe('mergeObjects', () => { it.each([ { diff --git a/src/__tests__/server.toolsUser.test.ts b/src/__tests__/server.toolsUser.test.ts index 6693ea6..d8f21dc 100644 --- a/src/__tests__/server.toolsUser.test.ts +++ b/src/__tests__/server.toolsUser.test.ts @@ -3,8 +3,6 @@ import { basename, resolve } from 'node:path'; import { z } from 'zod'; import { createMcpTool, - isFilePath, - isUrlLike, normalizeFilePackage, normalizeFilePath, normalizeFileUrl, @@ -98,46 +96,6 @@ describe('sanitizeStaticToolName', () => { }); }); -describe('isFilePath', () => { - it.each([ - { description: 'absolute path', file: '/path/to/file.txt' }, - { description: 'absolute path ref no extension', file: '/path/to/another/file' }, - { description: 'min file extension', file: 'path/to/another/file.y' }, - { description: 'potential multiple extensions', file: 'path/to/another/file.test.js' }, - { description: 'current dir ref', file: './path/to/another/file.txt' }, - { description: 'parent dir ref', file: '../path/to/another/file.txt' } - ])('should validate $description', ({ file }) => { - expect(isFilePath(file)).toBe(true); - }); - - it.each([ - { description: 'no file extension or dir ref', file: 'path/to/another/file' } - ])('should fail, $description', ({ file }) => { - expect(isFilePath(file)).toBe(false); - }); -}); - -describe('isUrlLike', () => { - it.each([ - { description: 'http', url: 'http://example.com' }, - { description: 'https', url: 'https://example.com' }, - { description: 'file', url: 'file:///path/to/file.txt' }, - { description: 'node', url: 'node://path/to/file.txt' }, - { description: 'data', url: 'data:text/plain;base64,1234567890==' } - ])('should validate $description', ({ url }) => { - expect(isUrlLike(url)).toBe(true); - }); - - it.each([ - { description: 'invalid protocol', url: 'ftp://example.com' }, - { description: 'random', url: 'random://example.com' }, - { description: 'null', url: null }, - { description: 'undefined', url: undefined } - ])('should fail, $description', ({ url }) => { - expect(isUrlLike(url as any)).toBe(false); - }); -}); - describe('normalizeTupleSchema', () => { it.each([ { diff --git a/src/options.defaults.ts b/src/options.defaults.ts index d12f663..0b676f5 100644 --- a/src/options.defaults.ts +++ b/src/options.defaults.ts @@ -348,9 +348,9 @@ const LOG_BASENAME = 'pf-mcp:log'; * Default PatternFly-specific options. */ const PATTERNFLY_OPTIONS: PatternFlyOptions = { - availableResourceVersions: ['v6'], + availableResourceVersions: ['6.0.0'], default: { - defaultVersion: 'v6', + defaultVersion: '6.0.0', versionWhitelist: [ '@patternfly/react-core', '@patternfly/patternfly' diff --git a/src/patternFly.helpers.ts b/src/patternFly.helpers.ts new file mode 100644 index 0000000..9be2bdb --- /dev/null +++ b/src/patternFly.helpers.ts @@ -0,0 +1,91 @@ +import semver, { type SemVer } from 'semver'; +import { getOptions } from './options.context'; +import { + findNearestPackageJson, + matchPackageVersion, + readLocalFileFunction +} from './server.getResources'; +import { fuzzySearch } from './server.search'; +import { memo } from './server.caching'; + +/** + * Find the closest PatternFly version used within the project context. + * + * @note In the future the available versions of PatternFly will be determined by the available resources. + * In the short-term we limit the available versions via `patternflyOptions.availableResourceVersions`. + * + * @note In the future consider adding a log.debug to the try/catch block if/when the find closest version + * is integrated into tooling and resources. + * + * Logic: + * 1. Locates the nearest package.json. + * 2. Scans whitelisted dependencies using fuzzy matching. + * 3. Aggregates, filters all detected versions that exist in the documentation catalog. + * 4. Resolves the final version using the optional configured strategy (e.g. target the highest version, target the lowest version). + * + * @param contextPathOverride - Optional override for the context path to search for package.json + * @param options - Global options + * @returns Matched PatternFly semver version (e.g., '6.0.0', '5.0.0', '4.0.0') + */ +const findClosestPatternFlyVersion = async ( + contextPathOverride: string | undefined = undefined, + options = getOptions() +): Promise => { + const availableVersions = options.patternflyOptions.availableResourceVersions; + const { defaultVersion, versionWhitelist, versionStrategy } = options.patternflyOptions.default; + const pkgPath = findNearestPackageJson(contextPathOverride || options.contextPath); + const updatedDefaultVersion = semver.coerce(defaultVersion)?.version || defaultVersion; + + if (!pkgPath) { + return updatedDefaultVersion; + } + + try { + const content = await readLocalFileFunction.memo(pkgPath); + const pkg = JSON.parse(content); + const allDeps = { ...pkg.dependencies, ...pkg.devDependencies } as Record; + const depNames = Object.keys(allDeps); + + const detectedVersions = new Set(); + + for (const pkgName of versionWhitelist) { + // Allow for variations like -next or -alpha with fuzzySearch maxDistance + const matches = fuzzySearch(pkgName, depNames, { + maxDistance: 1, + isFuzzyMatch: true + }); + + for (const match of matches) { + const versionMatch = matchPackageVersion(allDeps[match.item], availableVersions); + + if (versionMatch) { + detectedVersions.add(versionMatch); + } + } + } + + if (detectedVersions.size === 0) { + return updatedDefaultVersion; + } + + if (detectedVersions.size === 1) { + return Array.from(detectedVersions)[0]?.version as string; + } + + const versionsArray = Array.from(detectedVersions); + const maxVersion = versionStrategy === 'highest' + ? semver.maxSatisfying(versionsArray, '*') + : semver.minSatisfying(versionsArray, '*'); + + return maxVersion?.version || updatedDefaultVersion; + } catch { + return updatedDefaultVersion; + } +}; + +/** + * Memoized version of findClosestPatternFlyVersion. + */ +findClosestPatternFlyVersion.memo = memo(findClosestPatternFlyVersion); + +export { findClosestPatternFlyVersion }; diff --git a/src/server.getResources.ts b/src/server.getResources.ts index 8ee751c..f74eac7 100644 --- a/src/server.getResources.ts +++ b/src/server.getResources.ts @@ -1,10 +1,12 @@ import { readFile } from 'node:fs/promises'; -import { isAbsolute, normalize, resolve, sep } from 'node:path'; +import { accessSync } from 'node:fs'; +import { isAbsolute, normalize, resolve, dirname, join, parse, sep } from 'node:path'; +import semver, { type SemVer } from 'semver'; import { getOptions } from './options.context'; import { DEFAULT_OPTIONS } from './options.defaults'; import { memo } from './server.caching'; import { normalizeString } from './server.search'; -import { isUrl } from './server.helpers'; +import { isUrl, isPath } from './server.helpers'; import { log, formatUnknownError } from './logger'; interface ProcessedDoc { @@ -14,6 +16,83 @@ interface ProcessedDoc { isSuccess: boolean; } +/** + * Match a dependency version against a list of supported versions. + * + * @note + * - Ignore URLs + * - Attempt to ignore paths, and aliases (:/.). and avoid `isPath` since semver + * versions could be considered a valid path. + * + * @param value - The dependency semver version string to match + * @param supportedVersions - An array of supported semver version strings + * @param options - Options object + * @param options.sep - Optional path separator. Defaults to `sep` from `path`. + * @returns A matched SemVer object containing a version string or `undefined` if no match is found. + */ +const matchPackageVersion = (value: string | undefined, supportedVersions: string[] = [], { sep: separator = sep } = {}) => { + if ( + supportedVersions.length === 0 || + typeof value !== 'string' || + !value.trim() || + value.includes(separator) || + value.startsWith('.') || + (value.includes('>') && value.includes('<') && !value.includes('=')) || + isUrl(value) + ) { + return undefined; + } + + const updatedSupportedVersions = supportedVersions.map(version => semver.coerce(version)).filter(Boolean) as SemVer[]; + const updatedValue = semver.maxSatisfying(updatedSupportedVersions, value); + + if (updatedValue) { + return updatedValue; + } + + return undefined; +}; + +/** + * Find the nearest package.json by walking up the directory tree. + * + * @note Path lookup behavior has nuance when using relative paths. See unit tests for examples. + * - Relative made-up directories to the working directory will return the closest match. + * - Absolute starting paths with relative working directories will return the closest match. + * + * @note There is subtle behavior around using async `access` and looping. We ended up moving towards + * `accessSync` when combined with the loop because it kept returning false positives. You can alter + * it as-is back to async and witness the unit tests fail. If it is moved back to async, it + * should be thoroughly tested. + * + * @param startPath - Directory to start searching from + * @param options - Options object + * @param options.resolvedPath - Set to `true` to return the absolute path, or `false` to return the relative path. Defaults to `true`. + * @returns The resolved/relative path to the nearest package.json, or `undefined` if none is found. + */ +const findNearestPackageJson = (startPath: string, { resolvedPath = true } = {}) => { + if (typeof startPath !== 'string' || isUrl(startPath) || !isPath(startPath, { isStrict: false })) { + return undefined; + } + + let currentDir = startPath.trim(); + const { root } = parse(currentDir); + + while (currentDir !== root) { + const pkgPath = join(currentDir, 'package.json'); + + try { + accessSync(pkgPath); + + return resolvedPath ? resolve(pkgPath) : pkgPath; + } catch { + currentDir = dirname(currentDir); + } + } + + return undefined; +}; + /** * Read a local file and return its contents as a string * @@ -69,18 +148,20 @@ fetchUrlFunction.memo = memo(fetchUrlFunction, DEFAULT_OPTIONS.resourceMemoOptio * Ensures the resolved path stays within the intended base for security. * * @param path - Path to resolve. If it's relative, it will be resolved against the base directory.' + * @param settings - Optional settings object. + * @param settings.sep - Optional path separator. Defaults to `sep` from `path`. * @param options - Options * @returns Resolved file or URL path. * * @throws {Error} - Throws an error if the resolved path is invalid or outside the allowed base directory. */ -const resolveLocalPathFunction = (path: string, options = getOptions()) => { +const resolveLocalPathFunction = (path: string, { sep: separator = sep } = {}, options = getOptions()) => { const documentationPrefix = options.docsPathSlug; // Safety check: Ensure the path is within the allowed directory - const assertPathWithinBaseAndReturn = (base: string, resolved: string) => { + const confirmThenReturnResolvedBase = (base: string, resolved: string) => { const normalizedBase = normalize(base); - const refinedBase = normalizedBase.endsWith(sep) ? normalizedBase : `${normalizedBase}${sep}`; + const refinedBase = normalizedBase.endsWith(separator) ? normalizedBase : `${normalizedBase}${separator}`; if (!resolved.startsWith(refinedBase) && resolved !== normalizedBase) { throw new Error(`Access denied: path ${path} is outside of allowed directory ${base}`); @@ -89,15 +170,13 @@ const resolveLocalPathFunction = (path: string, options = getOptions()) => { return resolved; }; - // Paths starting with the documentation prefix are resolved relative to the documentation path if (path.startsWith(documentationPrefix)) { const base = options.docsPath; const resolved = resolve(base, path.slice(documentationPrefix.length)); - return assertPathWithinBaseAndReturn(base, resolved); + return confirmThenReturnResolvedBase(base, resolved); } - // URLs are returned as-is if (isUrl(path)) { return path; } @@ -105,7 +184,7 @@ const resolveLocalPathFunction = (path: string, options = getOptions()) => { const base = options.contextPath; const resolved = isAbsolute(path) ? normalize(path) : resolve(base, path); - return assertPathWithinBaseAndReturn(base, resolved); + return confirmThenReturnResolvedBase(base, resolved); }; /** @@ -253,7 +332,9 @@ processDocsFunction.memo = memo(processDocsFunction, DEFAULT_OPTIONS.toolMemoOpt export { fetchUrlFunction, + findNearestPackageJson, loadFileFetch, + matchPackageVersion, processDocsFunction, promiseQueue, readLocalFileFunction, diff --git a/src/server.helpers.ts b/src/server.helpers.ts index bb4da03..02d55e8 100644 --- a/src/server.helpers.ts +++ b/src/server.helpers.ts @@ -1,4 +1,5 @@ import { createHash, type BinaryToTextEncoding } from 'node:crypto'; +import { extname, sep } from 'node:path'; /** * Check if a value is a valid port number. @@ -179,13 +180,38 @@ const isAsync = (obj: unknown) => /^\[object (Async|AsyncFunction)]/.test(Object const isPromise = (obj: unknown) => /^\[object (Promise|Async|AsyncFunction)]/.test(Object.prototype.toString.call(obj)); /** - * Check if a value is a valid URL. + * Check if a value is a valid URL, URL-like. * - * @param str + * @note URL-like validation can be updated to support more URL schemes (e.g. `blob:`). + * Be aware this helper is used to gate-keep tools-as-plugins. Consider additions carefully + * since they may fall outside our use cases. + * + * @param str - String to check + * @param [options] - Options + * @param [options.allowedProtocols] - List of allowed URL protocols. Default: `['file', 'http', 'https', 'data', 'node']` + * @param [options.isStrict] - If `true`, only strict URL validation is performed. Default: `true` + * @returns `true` if the string is a valid URL, URL-like. */ -const isUrl = (str: unknown) => { +const isUrl = (str: unknown, { allowedProtocols = ['file', 'http', 'https', 'data', 'node'], isStrict = true } = {}) => { + if (typeof str !== 'string' || !str.trim()) { + return false; + } + + const isAllowed = allowedProtocols.some(type => str.toLowerCase().startsWith(`${type}:`)); + + // Strict and not allowed protocols + if (isStrict && !isAllowed) { + return false; + } + + // Not strict and allowed protocols + if (!isStrict && isAllowed) { + return true; + } + + // URL validation try { - new URL(str as any); + new URL(str); return true; } catch { @@ -193,6 +219,50 @@ const isUrl = (str: unknown) => { } }; +/** + * Check if a value is a valid path. + * + * @param str - String to check + * @param [options] - Options + * @param [options.allowedPrefixes] - List of allowed path prefixes. Default: `['.', '..', '/', '\\']` + * @param [options.isStrict] - If `true`, only strict path validation is performed. Default: `true` + * @param [options.sep] - Path separator to use. Default: `path.sep` + * @returns `true` if the string is a valid path. + */ +const isPath = (str: unknown, { allowedPrefixes = ['.', '..', '/', '\\'], isStrict = true, sep: separator = sep } = {}) => { + if (typeof str !== 'string' || !str.trim()) { + return false; + } + + // File URLs + if (str.startsWith('file:')) { + return isUrl(str, { isStrict }); + } + + // Windows drive letter paths + if (/^[A-Za-z]:[\\/]/.test(str)) { + return true; + } + + const isAllowed = allowedPrefixes.some(prefix => { + if (prefix === '.' || prefix === '..') { + return str.startsWith(`${prefix}${separator}`); + } + + return str.startsWith(prefix); + }); + + if (isStrict && !isAllowed) { + return false; + } + + if (!isStrict && isAllowed) { + return true; + } + + return isAllowed || str.includes(separator) || extname(str).length >= 2; +}; + /** * Generate a hash from a string * @@ -330,8 +400,8 @@ const generateHash = (anyValue: unknown): string => { * @param settings.filterFalsyValues - If `true`, filter out falsy values before joining (default: `false`) * @returns Joined string, with optional separator */ -const stringJoin = (arr: unknown[], { sep = ' ', filterFalsyValues = false } = {}): string => - (filterFalsyValues ? arr.filter(Boolean).join(sep) : arr.join(sep)); +const stringJoin = (arr: unknown[], { sep: separator = ' ', filterFalsyValues = false } = {}): string => + (filterFalsyValues ? arr.filter(Boolean).join(separator) : arr.join(separator)); /** * Join argument values with a single space separator. @@ -399,6 +469,7 @@ export { hashNormalizeValue, isAsync, isObject, + isPath, isPlainObject, isPromise, isReferenceLike, diff --git a/src/server.toolsUser.ts b/src/server.toolsUser.ts index 6eeb123..04f8a7d 100644 --- a/src/server.toolsUser.ts +++ b/src/server.toolsUser.ts @@ -1,6 +1,6 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; -import { dirname, extname, isAbsolute, resolve } from 'node:path'; -import { isPlainObject, isReferenceLike } from './server.helpers'; +import { dirname, isAbsolute, resolve } from 'node:path'; +import { isPath, isPlainObject, isReferenceLike, isUrl } from './server.helpers'; import { type McpTool } from './server'; import { type GlobalOptions } from './options'; import { memo } from './server.caching'; @@ -342,29 +342,6 @@ const sanitizeStaticToolName = (obj: unknown) => { return undefined; }; -/** - * Check if a string looks like a file path. - * - * @param str - * @returns Confirmation that the string looks like a file path. - */ -const isFilePath = (str: string): boolean => { - if (typeof str !== 'string') { - return false; - } - - return str.startsWith('./') || str.startsWith('../') || str.startsWith('/') || /^[A-Za-z]:[\\/]/.test(str) || extname(str).length >= 2; -}; - -/** - * Check if a string looks like a URL. - * - * @param str - * @returns Confirmation that the string looks like a URL. - */ -const isUrlLike = (str: string) => - /^(file:|https?:|data:|node:)/i.test(str); - /** * Normalize a tuple object with schema into a Zod schema. * @@ -585,7 +562,7 @@ const normalizeFileUrl = (config: unknown): FileEntry | undefined => { return undefined; } - const entry: Partial = { isUrlLike: isUrlLike(config), isFilePath: isFilePath(config) }; + const entry: Partial = { isUrlLike: isUrl(config, { isStrict: false }), isFilePath: isPath(config) }; const err: string[] = []; const isFileUrl = config.startsWith('file:'); const normalizedUrl = config; @@ -636,11 +613,11 @@ const normalizeFilePath = ( contextUrl }: { contextPath?: string, contextUrl?: string } = {} ): FileEntry | undefined => { - if (typeof config !== 'string' || !isFilePath(config) || isUrlLike(config)) { + if (typeof config !== 'string' || !isPath(config) || isUrl(config, { isStrict: false })) { return undefined; } - const entry: Partial = { isUrlLike: isUrlLike(config), isFilePath: isFilePath(config) }; + const entry: Partial = { isUrlLike: isUrl(config, { isStrict: false }), isFilePath: isPath(config) }; const err: string[] = []; let isFileUrl = config.startsWith('file:'); let normalizedUrl = config; @@ -729,8 +706,8 @@ const normalizeFilePackage = ( // Case 3: non-file string -> keep as-is (package name or other URL-like spec) // Note: http(s) module specs are not supported by Node import and will surface as load warnings in the child. return { - isUrlLike: isUrlLike(config), - isFilePath: isFilePath(config), + isUrlLike: isUrl(config, { isStrict: false }), + isFilePath: isPath(config), normalizedUrl: config, fsReadDir: undefined, isFileUrl: false, @@ -919,8 +896,6 @@ const createMcpTool = (config: string | Tool | ToolConfig | ToolCreator | ToolMu export { createMcpTool, - isFilePath, - isUrlLike, normalizeFilePackage, normalizeFileUrl, normalizeFilePath,