Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# @focus-mcp/cli

## 2.4.1

### Patch Changes

- 5131bb7: feat(source): implement readPackageVersion() on FilesystemBrickSource

Adds the optional BrickSource.readPackageVersion() hook (introduced in @focus-mcp/core) so the brick loader can inject the package.json version into the manifest when mcp-brick.json doesn't carry a "version" field. Without this, every brick load throws INVALID_VERSION because the disk manifest and the JS module's exported manifest both lack a SemVer version field.

Resolution mirrors readManifest(): prefer require.resolve('@focus-mcp/brick-<name>/package.json'), fall back to walking up from the package main entry if /package.json isn't in the exports field, and return undefined when no version can be found (loader keeps its existing INVALID_VERSION behaviour as a last resort).

## 2.4.0

### Minor Changes
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@focus-mcp/cli",
"version": "2.4.0",
"version": "2.4.1",
"private": false,
"description": "Focus your AI agents on what matters. 68+ bricks, 1 MCP server, modular context — from 200k to 2k tokens. Works with Claude Code, Cursor, Codex.",
"license": "MIT",
Expand Down
121 changes: 121 additions & 0 deletions src/source/filesystem-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,4 +369,125 @@ describe('FilesystemBrickSource', () => {
const result = await source.readManifest('x');
expect(result).toEqual(manifest);
});

// ---------- readPackageVersion ----------

it('readPackageVersion() returns the version from package.json (direct resolve)', async () => {
const { FilesystemBrickSource } = await import('./filesystem-source.ts');

mockResolve.mockReturnValue('/fake/bricks/brick-a/package.json');
mockReadFile.mockResolvedValue(
JSON.stringify({ name: '@focus-mcp/brick-a', version: '1.2.3' }),
);

const source = new FilesystemBrickSource({
centerJson: { bricks: {} },
bricksDir: '/fake/bricks',
});

const result = await source.readPackageVersion('brick-a');

expect(mockResolve).toHaveBeenCalledWith('@focus-mcp/brick-brick-a/package.json');
expect(result).toBe('1.2.3');
});

it('readPackageVersion() falls back to walking up from main when /package.json is not exported', async () => {
const { FilesystemBrickSource } = await import('./filesystem-source.ts');

// 1st call (/package.json): throws ERR_PACKAGE_PATH_NOT_EXPORTED.
// 2nd call (the package main): returns dist entry path.
const notExported = Object.assign(new Error('not exported'), {
code: 'ERR_PACKAGE_PATH_NOT_EXPORTED',
});
mockResolve
.mockImplementationOnce(() => {
throw notExported;
})
.mockReturnValueOnce('/fake/bricks/brick-a/dist/index.js');
mockReadFile.mockResolvedValue(JSON.stringify({ version: '2.0.0' }));

const source = new FilesystemBrickSource({
centerJson: { bricks: {} },
bricksDir: '/fake/bricks',
});

const result = await source.readPackageVersion('brick-a');

expect(result).toBe('2.0.0');
});

it('readPackageVersion() returns undefined when package.json has no version field', async () => {
const { FilesystemBrickSource } = await import('./filesystem-source.ts');

mockResolve.mockReturnValue('/fake/bricks/brick-a/package.json');
mockReadFile.mockResolvedValue(JSON.stringify({ name: '@focus-mcp/brick-a' }));

const source = new FilesystemBrickSource({
centerJson: { bricks: {} },
bricksDir: '/fake/bricks',
});

const result = await source.readPackageVersion('brick-a');
expect(result).toBeUndefined();
});

it('readPackageVersion() returns undefined when neither direct resolve nor walk-up find a package.json', async () => {
const { FilesystemBrickSource } = await import('./filesystem-source.ts');

const notFound = Object.assign(new Error('module not found'), { code: 'MODULE_NOT_FOUND' });
// 1st call throws, 2nd call returns a path so we enter the walk-up loop.
mockResolve
.mockImplementationOnce(() => {
throw notFound;
})
.mockReturnValueOnce('/fake/bricks/x/y/z/index.js');
// Every readFile during walk-up throws (no package.json found).
mockReadFile.mockRejectedValue(new Error('ENOENT'));

const source = new FilesystemBrickSource({
centerJson: { bricks: {} },
bricksDir: '/fake/bricks',
});

const result = await source.readPackageVersion('brick-a');
expect(result).toBeUndefined();
});

it('readPackageVersion() returns undefined when the package itself is not installed', async () => {
const { FilesystemBrickSource } = await import('./filesystem-source.ts');

// Both the /package.json resolve AND the main-entry resolve throw
// MODULE_NOT_FOUND — the package is genuinely absent. JSDoc says
// we should return undefined rather than propagating.
const notFound = Object.assign(new Error('module not found'), { code: 'MODULE_NOT_FOUND' });
mockResolve.mockImplementation(() => {
throw notFound;
});

const source = new FilesystemBrickSource({
centerJson: { bricks: {} },
bricksDir: '/fake/bricks',
});

const result = await source.readPackageVersion('brick-a');
expect(result).toBeUndefined();
});

it('readPackageVersion() throws if resolved path escapes bricksDir (symlink attack)', async () => {
const { FilesystemBrickSource } = await import('./filesystem-source.ts');

// Resolver returns a path outside bricksDir — assertWithinBricksDir
// must throw to prevent symlink/alias escapes (same protection as
// readManifest / loadModule).
mockResolve.mockReturnValue('/etc/passwd/package.json');
mockRealpathSync.mockImplementation((p: string) => p);
mockReadFile.mockResolvedValue(JSON.stringify({ version: '1.0.0' }));

const source = new FilesystemBrickSource({
centerJson: { bricks: {} },
bricksDir: '/fake/bricks',
});

await expect(source.readPackageVersion('brick-a')).rejects.toThrow(/escapes bricksDir/);
});
});
81 changes: 81 additions & 0 deletions src/source/filesystem-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,85 @@ export class FilesystemBrickSource implements BrickSource {
const cacheBuster = `?t=${Date.now()}`;
return import(`${pathToFileURL(entry).href}${cacheBuster}`);
}

/**
* Read the brick's package.json `version` field so the core loader can
* inject it into the manifest when `mcp-brick.json` itself doesn't carry
* a `version` (the current shape of all `@focus-mcp/brick-*` manifests).
*
* Returns `undefined` if the package.json can't be found or is missing
* a string `version` field — the loader is free to fall back to its
* existing INVALID_VERSION behaviour in that case.
*/
async readPackageVersion(name: string): Promise<string | undefined> {
const brickName = safeBrickName(name);
const require = createRequire(pathToFileURL(`${this.#bricksDir}/`).href);
const pkgPath = await resolvePackageJsonPath(require, brickName);
if (pkgPath === undefined) return undefined;
assertWithinBricksDir(pkgPath, this.#bricksDir);
const raw = await readFile(pkgPath, 'utf-8');
return extractVersion(raw);
}
}

/**
* Resolve the path to `@focus-mcp/brick-<name>/package.json`, with a fallback
* walk-up from the package main entry. Returns `undefined` only when the
* package is genuinely absent or has no package.json reachable from its main.
*/
async function resolvePackageJsonPath(
require: NodeJS.Require,
brickName: string,
): Promise<string | undefined> {
try {
// Preferred: `./package.json` is unconditionally resolvable since
// Node 14, even when an `exports` field restricts other paths.
return 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 main entry.
const pkgMain = resolvePackageMain(require, brickName);
if (pkgMain === undefined) return undefined;
return await walkUpForPackageJson(dirname(pkgMain));
}

function resolvePackageMain(require: NodeJS.Require, brickName: string): string | undefined {
try {
return require.resolve(`@focus-mcp/brick-${brickName}`);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'MODULE_NOT_FOUND') return undefined;
throw err;
}
}

async function walkUpForPackageJson(startDir: string): Promise<string | undefined> {
let dir = startDir;
while (true) {
const candidate = join(dir, 'package.json');
try {
await readFile(candidate, 'utf-8');
return candidate;
} catch {
const parent = dirname(dir);
if (parent === dir) return undefined;
dir = parent;
}
}
}

function extractVersion(raw: string): string | undefined {
const parsed: unknown = JSON.parse(raw);
if (
parsed !== null &&
typeof parsed === 'object' &&
'version' in parsed &&
typeof (parsed as { version: unknown }).version === 'string'
) {
return (parsed as { version: string }).version;
}
return undefined;
}
Loading