Skip to content

Commit 6d49f63

Browse files
committed
fix(mcp): serverInfo.version defaults to the package version, not a literal 1.0.0
Both literal defaults (the plugin's class field and init(), and MCPServerRuntime's constructor) now read one value taken from the package's own manifest; an explicit version option still overrides it. The CJS build needs shims for the read, so the package builds with its own tsup config. Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 4916168 commit 6d49f63

7 files changed

Lines changed: 254 additions & 6 deletions

File tree

‎packages/mcp/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
}
2020
},
2121
"scripts": {
22-
"build": "tsup --config ../../tsup.config.ts && node ../../scripts/check-dts-emitted.mjs",
22+
"build": "tsup && node ../../scripts/check-dts-emitted.mjs",
2323
"check:test-typecheck": "tsx ../../scripts/check-test-typecheck.mts --self-test && tsx ../../scripts/check-test-typecheck.mts --package packages/mcp --project tsconfig.test.json",
2424
"gen:test-typecheck-debt": "tsx ../../scripts/check-test-typecheck.mts --update --package packages/mcp --project tsconfig.test.json",
2525
"test": "vitest run",

‎packages/mcp/src/__tests__/plugin.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ describe('MCPServerPlugin', () => {
9898
it('should have correct plugin metadata', () => {
9999
const plugin = new MCPServerPlugin();
100100
expect(plugin.name).toBe('com.objectstack.mcp');
101-
expect(plugin.version).toBe('1.0.0');
101+
// `version` is the package manifest's, pinned in `../mcp-server-info-version.test.ts`.
102102
expect(plugin.type).toBe('standard');
103103
});
104104
});
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `serverInfo.version` — what an MCP server answers in `initialize`.
5+
*
6+
* `MCPServerPluginOptions.version` is published as "Defaults to package
7+
* version". Two literal `'1.0.0'` defaults (the plugin's, and
8+
* `MCPServerRuntime`'s) made every deployment built without the option answer
9+
* `1.0.0` while the package was elsewhere, so the registry listing in
10+
* `server.json` and every running server disagreed. Both now read the
11+
* package's own manifest; an explicit `version` still overrides it.
12+
*
13+
* The expected value is read from `package.json` HERE, never written down: a
14+
* literal in this file would rot at the next release, and would let a
15+
* regression that re-introduces some other constant pass on the day the two
16+
* happen to agree.
17+
*/
18+
19+
import { readFileSync } from 'node:fs';
20+
import { afterEach, describe, expect, it, vi } from 'vitest';
21+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
22+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
23+
import { LiteKernel } from '@objectstack/core';
24+
25+
import { MCPServerPlugin } from './plugin.js';
26+
import { MCPServerRuntime } from './mcp-server-runtime.js';
27+
28+
const MANIFEST_VERSION: string = JSON.parse(
29+
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
30+
).version;
31+
32+
const OVERRIDE = '9.9.9-override.1';
33+
34+
const INITIALIZE = {
35+
jsonrpc: '2.0',
36+
id: 0,
37+
method: 'initialize',
38+
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'pin', version: '0' } },
39+
};
40+
41+
/** `initialize` over the Streamable HTTP door (`POST /api/v1/mcp`). */
42+
async function serverInfoOverHttp(runtime: MCPServerRuntime): Promise<{ name: string; version: string }> {
43+
const res = await runtime.handleHttpRequest(
44+
new Request('http://localhost/api/v1/mcp', {
45+
method: 'POST',
46+
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
47+
body: JSON.stringify(INITIALIZE),
48+
}),
49+
{ parsedBody: INITIALIZE },
50+
);
51+
const json = (await res.json()) as { result: { serverInfo: { name: string; version: string } } };
52+
return json.result.serverInfo;
53+
}
54+
55+
/** `initialize` against the long-lived server (the stdio composition), over an in-memory pair. */
56+
async function serverVersionOnLongLivedServer(runtime: MCPServerRuntime): Promise<string | undefined> {
57+
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
58+
const client = new Client({ name: 'pin', version: '0' });
59+
await runtime.server.connect(serverSide);
60+
await client.connect(clientSide);
61+
try {
62+
return client.getServerVersion()?.version;
63+
} finally {
64+
await client.close();
65+
}
66+
}
67+
68+
function pluginContext() {
69+
const services = new Map<string, unknown>();
70+
return {
71+
services,
72+
ctx: {
73+
registerService: vi.fn((name: string, service: unknown) => {
74+
services.set(name, service);
75+
}),
76+
getService: vi.fn((name: string) => {
77+
if (!services.has(name)) throw new Error(`Service "${name}" not found`);
78+
return services.get(name);
79+
}),
80+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
81+
},
82+
};
83+
}
84+
85+
async function runtimeOf(plugin: MCPServerPlugin): Promise<MCPServerRuntime> {
86+
const { ctx, services } = pluginContext();
87+
await plugin.init(ctx as never);
88+
return services.get('mcp') as MCPServerRuntime;
89+
}
90+
91+
describe('serverInfo.version — the default is the package version', () => {
92+
it('MCPServerRuntime built with no config answers the package version', async () => {
93+
expect((await serverInfoOverHttp(new MCPServerRuntime())).version).toBe(MANIFEST_VERSION);
94+
});
95+
96+
it('MCPServerPlugin built with no options answers the package version (the `os serve` auto-registration shape)', async () => {
97+
const runtime = await runtimeOf(new MCPServerPlugin());
98+
expect((await serverInfoOverHttp(runtime)).version).toBe(MANIFEST_VERSION);
99+
});
100+
101+
it('the long-lived (stdio) server answers the package version too', async () => {
102+
expect(await serverVersionOnLongLivedServer(new MCPServerRuntime())).toBe(MANIFEST_VERSION);
103+
expect(await serverVersionOnLongLivedServer(await runtimeOf(new MCPServerPlugin()))).toBe(MANIFEST_VERSION);
104+
});
105+
106+
it("the kernel plugin's own `version` is the same one value", () => {
107+
expect(new MCPServerPlugin().version).toBe(MANIFEST_VERSION);
108+
});
109+
});
110+
111+
describe('serverInfo.version — an explicit override is answered as given', () => {
112+
it('MCPServerRuntime config.version', async () => {
113+
const runtime = new MCPServerRuntime({ version: OVERRIDE });
114+
expect((await serverInfoOverHttp(runtime)).version).toBe(OVERRIDE);
115+
expect(await serverVersionOnLongLivedServer(new MCPServerRuntime({ version: OVERRIDE }))).toBe(OVERRIDE);
116+
});
117+
118+
it('MCPServerPlugin options.version', async () => {
119+
const runtime = await runtimeOf(new MCPServerPlugin({ version: OVERRIDE }));
120+
expect((await serverInfoOverHttp(runtime)).version).toBe(OVERRIDE);
121+
});
122+
});
123+
124+
describe('an unreadable manifest degrades to an honest answer, never to a plugin the kernel refuses', () => {
125+
afterEach(() => {
126+
vi.doUnmock('node:module');
127+
vi.resetModules();
128+
});
129+
130+
it("serverInfo.version says 'unknown' and the plugin still loads", async () => {
131+
// A bundle with no `package.json` beside it: `createRequire(...)('../package.json')` throws.
132+
vi.resetModules();
133+
vi.doMock('node:module', async (importOriginal) => {
134+
const actual = await importOriginal<typeof import('node:module')>();
135+
return {
136+
...actual,
137+
createRequire: () => {
138+
throw new Error('ENOENT: no manifest beside this bundle');
139+
},
140+
};
141+
});
142+
const { MCPServerPlugin: BlindPlugin } = await import('./plugin.js');
143+
const plugin = new BlindPlugin();
144+
145+
// The wire answer is a free string: it says it does not know.
146+
const { ctx, services } = pluginContext();
147+
await plugin.init(ctx as never);
148+
expect((await serverInfoOverHttp(services.get('mcp') as MCPServerRuntime)).version).toBe('unknown');
149+
150+
// The kernel plugin's `version` has a grammar (SemVer 2.0.0): a placeholder string there would
151+
// be refused by both kernels. `use()` throws on a refused contract.
152+
const kernel = new LiteKernel({ logger: { level: 'silent' } });
153+
expect(() => kernel.use(plugin)).not.toThrow();
154+
});
155+
});

‎packages/mcp/src/mcp-server-runtime.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
METADATA_UNAVAILABLE_CODE,
1919
metadataPartialListingSentence,
2020
} from './metadata-completeness.js';
21+
import { DEFAULT_SERVER_VERSION } from './package-version.js';
2122
import { protocolStdout } from './protocol-stdout.js';
2223
import { renderSkillMarkdown, type RenderSkillOptions } from './skill-md.js';
2324
import {
@@ -34,7 +35,7 @@ import { z } from 'zod';
3435
export interface MCPServerRuntimeConfig {
3536
/** Human-readable server name. */
3637
name?: string;
37-
/** Server version (semver). */
38+
/** Server version (semver). Defaults to the `@objectstack/mcp` package version. */
3839
version?: string;
3940
/** Optional instructions describing how to use the server. */
4041
instructions?: string;
@@ -959,7 +960,7 @@ export class MCPServerRuntime {
959960
constructor(config: MCPServerRuntimeConfig = {}) {
960961
this.config = {
961962
name: 'objectstack',
962-
version: '1.0.0',
963+
version: DEFAULT_SERVER_VERSION,
963964
transport: 'stdio',
964965
...config,
965966
};
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The one place `@objectstack/mcp` learns its own version.
5+
*
6+
* `MCPServerPluginOptions.version` is published as "Defaults to package
7+
* version", and the MCP `initialize` answer carries that string as
8+
* `serverInfo.version`. Both defaults used to be the literal `'1.0.0'` (the
9+
* plugin's own, and `MCPServerRuntime`'s), so every deployment that built the
10+
* plugin without options answered `1.0.0` while the package was somewhere
11+
* else entirely, and the registry listing in `server.json` disagreed with
12+
* every running server. Every default now reads this one value; an explicit
13+
* `version` option still overrides it.
14+
*
15+
* The read is the same shape `@objectstack/runtime`
16+
* (`packages/runtime/src/runtime-version.ts`) and `@objectstack/metadata-protocol`
17+
* (`packages/metadata-protocol/src/discovery-version.ts`) already use for their
18+
* own manifests: `createRequire(import.meta.url)` against `../package.json`,
19+
* which sits one directory above both `src/` (vitest) and the bundled
20+
* `dist/index.{js,cjs}` (tsup, single entry, `splitting: false`), so one path
21+
* resolves the same manifest from every shape. Its CJS half rests on
22+
* `shims: true` in this package's `tsup.config.ts` (see the comment there).
23+
*/
24+
25+
import { createRequire } from 'node:module';
26+
27+
function readOwnVersion(): string | undefined {
28+
try {
29+
const require = createRequire(import.meta.url);
30+
const pkg = require('../package.json') as { version?: unknown };
31+
return typeof pkg.version === 'string' && pkg.version.length > 0 ? pkg.version : undefined;
32+
} catch {
33+
return undefined;
34+
}
35+
}
36+
37+
/**
38+
* `@objectstack/mcp`'s own installed version, from its `package.json`;
39+
* `undefined` when the manifest cannot be read (a bundle that no longer has the
40+
* file beside it).
41+
*
42+
* This is what the kernel plugin's own `version` takes, and the reason it stays
43+
* `undefined` rather than becoming a placeholder string: both kernels refuse a
44+
* plugin whose `version` is not SemVer 2.0.0, so any non-version placeholder
45+
* would turn an unreadable manifest into an MCP plugin that never loads. An
46+
* absent `version` is accepted, and the kernel supplies its own `0.0.0`.
47+
*/
48+
export const PACKAGE_VERSION: string | undefined = readOwnVersion();
49+
50+
/**
51+
* What an MCP server reports as `serverInfo.version` when the caller names none:
52+
* the package version, or `'unknown'` when the manifest cannot be read —
53+
* honest about not knowing, rather than a plausible-looking version a client
54+
* could mistake for real identity. `serverInfo.version` is a free string on the
55+
* wire, so unlike {@link PACKAGE_VERSION} it has no grammar to satisfy.
56+
*/
57+
export const DEFAULT_SERVER_VERSION: string = PACKAGE_VERSION ?? 'unknown';

‎packages/mcp/src/plugin.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type { ExecutionContext } from '@objectstack/spec/kernel';
2323
import type { TenancyPosture } from '@objectstack/spec/security';
2424
import type { IAIService, IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
2525
import { MCPServerRuntime } from './mcp-server-runtime.js';
26+
import { PACKAGE_VERSION, DEFAULT_SERVER_VERSION } from './package-version.js';
2627
import type { MCPServerRuntimeConfig, McpMergedMetadataRead } from './mcp-server-runtime.js';
2728
import type { ToolRegistry } from './types.js';
2829
import {
@@ -265,7 +266,7 @@ export class MCPServerPlugin implements Plugin {
265266
* kernel name this plugin when a consumer requires one before it inits.
266267
*/
267268
providesServices = ['mcp'];
268-
version = '1.0.0';
269+
version = PACKAGE_VERSION;
269270
type = 'standard' as const;
270271
dependencies: string[] = [];
271272

@@ -279,7 +280,7 @@ export class MCPServerPlugin implements Plugin {
279280
async init(ctx: PluginContext): Promise<void> {
280281
const config: MCPServerRuntimeConfig = {
281282
name: readEnvWithDeprecation('OS_MCP_SERVER_NAME', 'MCP_SERVER_NAME', { silent: true }) ?? this.options.name ?? 'objectstack',
282-
version: this.options.version ?? '1.0.0',
283+
version: this.options.version ?? DEFAULT_SERVER_VERSION,
283284
transport: (readEnvWithDeprecation('OS_MCP_SERVER_TRANSPORT', 'MCP_SERVER_TRANSPORT', { silent: true }) as 'stdio' | 'http') ?? this.options.transport ?? 'stdio',
284285
instructions: this.options.instructions,
285286
logger: ctx.logger,

‎packages/mcp/tsup.config.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { defineConfig } from 'tsup';
4+
5+
import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs';
6+
7+
export default defineConfig({
8+
entry: ['src/index.ts'],
9+
splitting: false,
10+
sourcemap: true,
11+
clean: true,
12+
dts: !process.env.OS_SKIP_DTS,
13+
format: ['esm', 'cjs'],
14+
target: 'es2020',
15+
// LOAD-BEARING, and measured rather than assumed. `package-version.ts` reads
16+
// this package's own `package.json` via `createRequire(import.meta.url)` —
17+
// correct as written for the ESM output. The shared `tsup.config.ts` this
18+
// package built with before has no `shims`, and there esbuild EMPTIES
19+
// `import.meta` in the CJS bundle (`var import_meta = {}`), so
20+
// `createRequire(undefined)` throws, the resolver's `catch` swallows it, and
21+
// `require('@objectstack/mcp')` answers `serverInfo.version` `'unknown'`
22+
// while the ESM build answers the manifest version: the two formats disagree
23+
// and nothing fails at load. `shims: true` makes tsup rewrite `import.meta.url`
24+
// in the CJS build to a real `__filename`-derived value (its
25+
// `assets/cjs_shims.js`), so both formats resolve the SAME package.json.
26+
// `packages/runtime/tsup.config.ts` and `packages/metadata-protocol/tsup.config.ts`
27+
// carry it for the same reason. Do not drop this line while
28+
// `package-version.ts` exists.
29+
//
30+
// Need-based injection — nothing else here references `__dirname` /
31+
// `__filename`, so the ESM build's shim path is a no-op.
32+
shims: true,
33+
esbuildOptions: dropSourcesContent,
34+
});

0 commit comments

Comments
 (0)