Skip to content

Commit efcf64b

Browse files
Merge branch 'main' into feat/oss-product-hardening
2 parents 3bfda81 + 2b05008 commit efcf64b

3 files changed

Lines changed: 180 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ coverage/
3232
*.swo
3333
*~
3434
.DS_Store
35+
._*
3536

3637
# Runtime
3738
*.pid

src/server-version.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { readFileSync } from 'node:fs';
2+
import { tmpdir } from 'node:os';
3+
import { dirname, join } from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
import { describe, expect, it, vi } from 'vitest';
6+
import { parsePackageJsonVersion, resolveServerVersion, SERVER_VERSION } from './server-version.js';
7+
8+
function readRootPackageJson(): string {
9+
const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
10+
return readFileSync(packageJsonPath, 'utf8');
11+
}
12+
13+
/**
14+
* True when the server-reported version matches the package.json version.
15+
* Returns false when the two strings differ (stale hardcoding or wrong file).
16+
*/
17+
function isServerVersionAligned(serverVersion: string, packageVersion: string): boolean {
18+
return serverVersion === packageVersion;
19+
}
20+
21+
/** Synthetic package.json bodies — only the `version` field matters for parsing. */
22+
const PACKAGE_JSON_FIXTURES: readonly string[] = [
23+
JSON.stringify({ name: 'a', version: '0.1.0' }),
24+
JSON.stringify({ name: 'b', version: '1.0.0' }),
25+
JSON.stringify({ version: '0.1.6', type: 'module' }),
26+
JSON.stringify({ version: '2.3.4', private: true }),
27+
];
28+
29+
describe('parsePackageJsonVersion', () => {
30+
it('extracts version from several package.json shapes', () => {
31+
expect(parsePackageJsonVersion(PACKAGE_JSON_FIXTURES[0])).toBe('0.1.0');
32+
expect(parsePackageJsonVersion(PACKAGE_JSON_FIXTURES[1])).toBe('1.0.0');
33+
expect(parsePackageJsonVersion(PACKAGE_JSON_FIXTURES[2])).toBe('0.1.6');
34+
expect(parsePackageJsonVersion(PACKAGE_JSON_FIXTURES[3])).toBe('2.3.4');
35+
});
36+
37+
it('trims surrounding whitespace on version', () => {
38+
expect(parsePackageJsonVersion(JSON.stringify({ version: ' 1.2.3 ' }))).toBe('1.2.3');
39+
});
40+
41+
it('returns default when version is only whitespace', () => {
42+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
43+
try {
44+
expect(parsePackageJsonVersion(JSON.stringify({ version: ' ' }))).toBe('0.0.1');
45+
expect(errSpy).toHaveBeenCalled();
46+
} finally {
47+
errSpy.mockRestore();
48+
}
49+
});
50+
51+
it('returns default when version is not a string', () => {
52+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
53+
try {
54+
expect(parsePackageJsonVersion(JSON.stringify({ version: 1 }))).toBe('0.0.1');
55+
expect(errSpy).toHaveBeenCalled();
56+
} finally {
57+
errSpy.mockRestore();
58+
}
59+
});
60+
});
61+
62+
describe('isServerVersionAligned', () => {
63+
it('returns true when server and package versions are the same string', () => {
64+
for (const raw of PACKAGE_JSON_FIXTURES) {
65+
const v = parsePackageJsonVersion(raw);
66+
expect(isServerVersionAligned(v, v)).toBe(true);
67+
}
68+
});
69+
70+
it('returns false when server and package versions differ', () => {
71+
expect(isServerVersionAligned('0.1.0', '0.2.0')).toBe(false);
72+
expect(isServerVersionAligned('1.0.0', '2.0.0')).toBe(false);
73+
});
74+
});
75+
76+
describe('SERVER_VERSION', () => {
77+
it('matches the root package.json version (live module read)', () => {
78+
const packageVersion = parsePackageJsonVersion(readRootPackageJson());
79+
expect(isServerVersionAligned(SERVER_VERSION, packageVersion)).toBe(true);
80+
expect(SERVER_VERSION).toBe(packageVersion);
81+
});
82+
});
83+
84+
describe('resolveServerVersion', () => {
85+
it('returns default version when package manifest path does not exist', () => {
86+
const missing = join(tmpdir(), `no-package-json-${Date.now()}.json`);
87+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
88+
try {
89+
expect(resolveServerVersion(missing)).toBe('0.0.1');
90+
expect(errSpy).toHaveBeenCalled();
91+
} finally {
92+
errSpy.mockRestore();
93+
}
94+
});
95+
96+
it('returns default version when path is missing even if npm_package_version is set', () => {
97+
const missing = join(tmpdir(), `no-package-json-env-${Date.now()}.json`);
98+
const prev = process.env.npm_package_version;
99+
process.env.npm_package_version = '9.9.9-test';
100+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
101+
try {
102+
expect(resolveServerVersion(missing)).toBe('0.0.1');
103+
expect(errSpy).toHaveBeenCalled();
104+
} finally {
105+
errSpy.mockRestore();
106+
if (prev !== undefined) process.env.npm_package_version = prev;
107+
else delete process.env.npm_package_version;
108+
}
109+
});
110+
});

src/server-version.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* MCP server version — read from package.json next to the compiled output.
3+
*/
4+
import { existsSync, readFileSync } from 'node:fs';
5+
import { dirname, join } from 'node:path';
6+
import { fileURLToPath } from 'node:url';
7+
8+
const DEFAULT_SERVER_VERSION = '0.0.1';
9+
10+
const __dirname = dirname(fileURLToPath(import.meta.url));
11+
const packageJsonPath = join(__dirname, '..', 'package.json');
12+
13+
/**
14+
* Read `version` from package.json text (same rules as the live server).
15+
* On invalid JSON, missing `version`, or invalid type, writes to stderr and returns {@link DEFAULT_SERVER_VERSION}.
16+
*/
17+
export function parsePackageJsonVersion(raw: string, pathForErrors = 'package.json'): string {
18+
try {
19+
const parsed = JSON.parse(raw) as { version?: unknown };
20+
if (typeof parsed.version !== 'string') {
21+
console.error(
22+
`[server-version] invalid or missing "version" in ${pathForErrors}; using default ${DEFAULT_SERVER_VERSION}`
23+
);
24+
return DEFAULT_SERVER_VERSION;
25+
}
26+
const version = parsed.version.trim();
27+
if (version.length === 0) {
28+
console.error(
29+
`[server-version] empty "version" in ${pathForErrors}; using default ${DEFAULT_SERVER_VERSION}`
30+
);
31+
return DEFAULT_SERVER_VERSION;
32+
}
33+
return version;
34+
} catch (err) {
35+
const detail = err instanceof Error ? err.message : String(err);
36+
console.error(
37+
`[server-version] could not parse ${pathForErrors} (${detail}); using default ${DEFAULT_SERVER_VERSION}`
38+
);
39+
return DEFAULT_SERVER_VERSION;
40+
}
41+
}
42+
43+
/**
44+
* Resolve the MCP server version from the package manifest on disk (by default,
45+
* `package.json` one directory above this module).
46+
*
47+
* @param overridePath - For tests; otherwise the repo root `package.json` next to compiled output.
48+
*/
49+
export function resolveServerVersion(overridePath?: string): string {
50+
const packagePath = overridePath ?? packageJsonPath;
51+
if (!existsSync(packagePath)) {
52+
console.error(
53+
`[server-version] package.json not found at ${packagePath}; using default ${DEFAULT_SERVER_VERSION}`
54+
);
55+
return DEFAULT_SERVER_VERSION;
56+
}
57+
try {
58+
const raw = readFileSync(packagePath, 'utf8');
59+
return parsePackageJsonVersion(raw, packagePath);
60+
} catch (err) {
61+
const detail = err instanceof Error ? err.message : String(err);
62+
console.error(
63+
`[server-version] could not read ${packagePath} (${detail}); using default ${DEFAULT_SERVER_VERSION}`
64+
);
65+
return DEFAULT_SERVER_VERSION;
66+
}
67+
}
68+
69+
export const SERVER_VERSION = resolveServerVersion();

0 commit comments

Comments
 (0)