diff --git a/.gitignore b/.gitignore index 8f2173a..7839956 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ coverage/ *.swo *~ .DS_Store +._* # Runtime *.pid diff --git a/src/constants.ts b/src/constants.ts index 01122ab..5d0d03b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -32,7 +32,7 @@ export const MAX_QUERY_DOCUMENTS_TOP_K = 20; export const QUERY_DOCUMENTS_MAX_CHUNKS = 500; export const SERVER_NAME = 'Pinecone Read-Only MCP'; -export const SERVER_VERSION = '0.1.0'; +export { SERVER_VERSION } from './server-version.js'; export const SERVER_INSTRUCTIONS = `A semantic search server that provides hybrid search capabilities over Pinecone vector indexes with automatic namespace discovery. diff --git a/src/server-version.test.ts b/src/server-version.test.ts new file mode 100644 index 0000000..7a11d57 --- /dev/null +++ b/src/server-version.test.ts @@ -0,0 +1,110 @@ +import { readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; +import { parsePackageJsonVersion, resolveServerVersion, SERVER_VERSION } from './server-version.js'; + +function readRootPackageJson(): string { + const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'); + return readFileSync(packageJsonPath, 'utf8'); +} + +/** + * True when the server-reported version matches the package.json version. + * Returns false when the two strings differ (stale hardcoding or wrong file). + */ +function isServerVersionAligned(serverVersion: string, packageVersion: string): boolean { + return serverVersion === packageVersion; +} + +/** Synthetic package.json bodies — only the `version` field matters for parsing. */ +const PACKAGE_JSON_FIXTURES: readonly string[] = [ + JSON.stringify({ name: 'a', version: '0.1.0' }), + JSON.stringify({ name: 'b', version: '1.0.0' }), + JSON.stringify({ version: '0.1.6', type: 'module' }), + JSON.stringify({ version: '2.3.4', private: true }), +]; + +describe('parsePackageJsonVersion', () => { + it('extracts version from several package.json shapes', () => { + expect(parsePackageJsonVersion(PACKAGE_JSON_FIXTURES[0])).toBe('0.1.0'); + expect(parsePackageJsonVersion(PACKAGE_JSON_FIXTURES[1])).toBe('1.0.0'); + expect(parsePackageJsonVersion(PACKAGE_JSON_FIXTURES[2])).toBe('0.1.6'); + expect(parsePackageJsonVersion(PACKAGE_JSON_FIXTURES[3])).toBe('2.3.4'); + }); + + it('trims surrounding whitespace on version', () => { + expect(parsePackageJsonVersion(JSON.stringify({ version: ' 1.2.3 ' }))).toBe('1.2.3'); + }); + + it('returns default when version is only whitespace', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(parsePackageJsonVersion(JSON.stringify({ version: ' ' }))).toBe('0.0.1'); + expect(errSpy).toHaveBeenCalled(); + } finally { + errSpy.mockRestore(); + } + }); + + it('returns default when version is not a string', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(parsePackageJsonVersion(JSON.stringify({ version: 1 }))).toBe('0.0.1'); + expect(errSpy).toHaveBeenCalled(); + } finally { + errSpy.mockRestore(); + } + }); +}); + +describe('isServerVersionAligned', () => { + it('returns true when server and package versions are the same string', () => { + for (const raw of PACKAGE_JSON_FIXTURES) { + const v = parsePackageJsonVersion(raw); + expect(isServerVersionAligned(v, v)).toBe(true); + } + }); + + it('returns false when server and package versions differ', () => { + expect(isServerVersionAligned('0.1.0', '0.2.0')).toBe(false); + expect(isServerVersionAligned('1.0.0', '2.0.0')).toBe(false); + }); +}); + +describe('SERVER_VERSION', () => { + it('matches the root package.json version (live module read)', () => { + const packageVersion = parsePackageJsonVersion(readRootPackageJson()); + expect(isServerVersionAligned(SERVER_VERSION, packageVersion)).toBe(true); + expect(SERVER_VERSION).toBe(packageVersion); + }); +}); + +describe('resolveServerVersion', () => { + it('returns default version when package manifest path does not exist', () => { + const missing = join(tmpdir(), `no-package-json-${Date.now()}.json`); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(resolveServerVersion(missing)).toBe('0.0.1'); + expect(errSpy).toHaveBeenCalled(); + } finally { + errSpy.mockRestore(); + } + }); + + it('returns default version when path is missing even if npm_package_version is set', () => { + const missing = join(tmpdir(), `no-package-json-env-${Date.now()}.json`); + const prev = process.env.npm_package_version; + process.env.npm_package_version = '9.9.9-test'; + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(resolveServerVersion(missing)).toBe('0.0.1'); + expect(errSpy).toHaveBeenCalled(); + } finally { + errSpy.mockRestore(); + if (prev !== undefined) process.env.npm_package_version = prev; + else delete process.env.npm_package_version; + } + }); +}); diff --git a/src/server-version.ts b/src/server-version.ts new file mode 100644 index 0000000..749ed3a --- /dev/null +++ b/src/server-version.ts @@ -0,0 +1,69 @@ +/** + * MCP server version — read from package.json next to the compiled output. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DEFAULT_SERVER_VERSION = '0.0.1'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packageJsonPath = join(__dirname, '..', 'package.json'); + +/** + * Read `version` from package.json text (same rules as the live server). + * On invalid JSON, missing `version`, or invalid type, writes to stderr and returns {@link DEFAULT_SERVER_VERSION}. + */ +export function parsePackageJsonVersion(raw: string, pathForErrors = 'package.json'): string { + try { + const parsed = JSON.parse(raw) as { version?: unknown }; + if (typeof parsed.version !== 'string') { + console.error( + `[server-version] invalid or missing "version" in ${pathForErrors}; using default ${DEFAULT_SERVER_VERSION}` + ); + return DEFAULT_SERVER_VERSION; + } + const version = parsed.version.trim(); + if (version.length === 0) { + console.error( + `[server-version] empty "version" in ${pathForErrors}; using default ${DEFAULT_SERVER_VERSION}` + ); + return DEFAULT_SERVER_VERSION; + } + return version; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + console.error( + `[server-version] could not parse ${pathForErrors} (${detail}); using default ${DEFAULT_SERVER_VERSION}` + ); + return DEFAULT_SERVER_VERSION; + } +} + +/** + * Resolve the MCP server version from the package manifest on disk (by default, + * `package.json` one directory above this module). + * + * @param overridePath - For tests; otherwise the repo root `package.json` next to compiled output. + */ +export function resolveServerVersion(overridePath?: string): string { + const packagePath = overridePath ?? packageJsonPath; + if (!existsSync(packagePath)) { + console.error( + `[server-version] package.json not found at ${packagePath}; using default ${DEFAULT_SERVER_VERSION}` + ); + return DEFAULT_SERVER_VERSION; + } + try { + const raw = readFileSync(packagePath, 'utf8'); + return parsePackageJsonVersion(raw, packagePath); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + console.error( + `[server-version] could not read ${packagePath} (${detail}); using default ${DEFAULT_SERVER_VERSION}` + ); + return DEFAULT_SERVER_VERSION; + } +} + +export const SERVER_VERSION = resolveServerVersion();