feat(source): add readPackageVersion to FilesystemBrickSource#144
Merged
samuelds merged 3 commits intoMay 24, 2026
Conversation
Implements the optional BrickSource.readPackageVersion() hook so the
core brick-loader can inject a brick's package.json version into its
manifest when mcp-brick.json doesn't carry a "version" field (the
current shape of every published @focus-mcp/brick-* package).
Without this, parseManifest() throws INVALID_VERSION at load time
because the disk manifest and the JS module's exported manifest both
lack a SemVer "version".
Implementation mirrors readManifest():
- Preferred: require.resolve('@focus-mcp/brick-<name>/package.json')
which works unconditionally since Node 14 (./package.json is always
exported, regardless of the package's exports field).
- Fallback: walk up from the package's main entry until a
package.json is found (handles older bricks that restrict exports).
- Returns undefined when no package.json is found or it has no string
version field — the loader is then free to fall back to its existing
INVALID_VERSION behaviour.
Adds 4 unit tests covering: direct resolve happy path, walk-up
fallback on ERR_PACKAGE_PATH_NOT_EXPORTED, missing version field, and
total miss.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a CLI-side implementation of the new optional BrickSource.readPackageVersion() hook on FilesystemBrickSource, enabling core to inject a SemVer version into manifests when mcp-brick.json omits it (as with current @focus-mcp/brick-* packages).
Changes:
- Implement
FilesystemBrickSource.readPackageVersion()usingrequire.resolve(.../package.json)with a walk-up fallback from the package main. - Apply the same bricks directory containment check pattern used elsewhere in
FilesystemBrickSource. - Add unit tests covering direct resolve, fallback resolution, and missing/invalid version scenarios.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/source/filesystem-source.ts | Adds readPackageVersion() to resolve and parse package.json version for a brick package. |
| src/source/filesystem-source.test.ts | Adds unit tests validating the new readPackageVersion() behavior across key resolution paths. |
Comments suppressed due to low confidence (2)
src/source/filesystem-source.ts:161
pkgPathis typed asstring | undefined, but it’s passed toassertWithinBricksDir(pkgPath, ...)and then toreadFile(pkgPath, ...), both of which expect astring. Withstrict: truethis won’t type-check, and it also leaves a potential runtime footgun if future edits ever allowpkgPathto remain unset. Consider makingpkgPatha non-optionalstring(e.g., return early on failure) or add an explicitif (!pkgPath) return undefined;guard before using it.
let pkgPath: string | undefined;
try {
// Preferred: package.json is unconditionally resolvable since
// Node 14 (`./package.json` is always exported, even when an
// `exports` field restricts other paths).
pkgPath = require.resolve(`@focus-mcp/brick-${brickName}/package.json`);
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED' && code !== 'MODULE_NOT_FOUND') {
throw err;
}
// Fallback: walk up from the package's main entry until we find
// a package.json (handles older bricks that restrict exports).
const pkgMain = require.resolve(`@focus-mcp/brick-${brickName}`);
let dir = dirname(pkgMain);
while (true) {
const candidate = join(dir, 'package.json');
try {
await readFile(candidate, 'utf-8');
pkgPath = candidate;
break;
} catch {
const parent = dirname(dir);
if (parent === dir) return undefined;
dir = parent;
}
}
}
assertWithinBricksDir(pkgPath, this.#bricksDir);
const raw = await readFile(pkgPath, 'utf-8');
const parsed: unknown = JSON.parse(raw);
src/source/filesystem-source.ts:156
- In the fallback walk-up, the code
readFile(candidate, ...)probes forpackage.jsonbefore verifying thatdir/candidateis withinbricksDir. Ifrequire.resolve()ever returns a path outsidebricksDir(Node resolution can walk up to ancestornode_modules), this can trigger reads outside the intended containment boundary beforeassertWithinBricksDirruns. Consider asserting containment onpkgMainbefore walking, and/or stopping the walk oncedirreachesbricksDir(or asserting eachcandidatebefore reading it).
// Fallback: walk up from the package's main entry until we find
// a package.json (handles older bricks that restrict exports).
const pkgMain = require.resolve(`@focus-mcp/brick-${brickName}`);
let dir = dirname(pkgMain);
while (true) {
const candidate = join(dir, 'package.json');
try {
await readFile(candidate, 'utf-8');
pkgPath = candidate;
break;
} catch {
const parent = dirname(dir);
if (parent === dir) return undefined;
dir = parent;
}
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…curity test Addresses copilot[bot] review on PR #144 plus a biome complexity rule: - Bug: if the package itself isn't installed, the first require.resolve('.../package.json') throws MODULE_NOT_FOUND, the fallback then calls require.resolve('@focus-mcp/brick-<name>') which also throws MODULE_NOT_FOUND — and that throw wasn't caught, breaking the JSDoc contract ("returns undefined if not found"). Now caught. - Security: added a test for assertWithinBricksDir() on readPackageVersion() — symlink/alias escape protection. - Complexity: extracted three helpers (resolvePackageJsonPath, resolvePackageMain, walkUpForPackageJson, extractVersion) so the public method's cyclomatic complexity drops back under Biome's threshold of 15. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why this PR
Core's brick-loader (per the user's diagnostic) parses the manifest via `parseManifest()`, which requires a SemVer `version` field. But every `@focus-mcp/brick-*` package on npm publishes an `mcp-brick.json` without a `version` field, and the JS module's exported manifest is just an import of that same JSON. Result: the loader throws `INVALID_VERSION` at every brick load.
Fix in core (handled separately): `brick-loader.ts` now accepts an optional `BrickSource.readPackageVersion()` hook and injects the result into both the disk manifest and the module manifest before `parseManifest()`.
This PR adds the CLI-side implementation of that hook on `FilesystemBrickSource`.
Implementation
Mirrors the resolution logic already used by `readManifest()`:
Security: same `assertWithinBricksDir()` check as `readManifest()` to prevent symlink/alias escapes.
Tests
4 new unit tests on `FilesystemBrickSource` (22 total, all passing):
Existing tests untouched.
Out of scope
The 5 unrelated pre-existing test failures on develop (`NpmInstallerAdapter`, `makeNodeIO`) — verified they fail identically on develop without this change.
🤖 Generated with Claude Code