diff --git a/.changeset/21532-mcp-server-info-version-default.md b/.changeset/21532-mcp-server-info-version-default.md new file mode 100644 index 00000000000..5babccd242b --- /dev/null +++ b/.changeset/21532-mcp-server-info-version-default.md @@ -0,0 +1,12 @@ +--- +'@objectstack/mcp': patch +--- + +The MCP server's `serverInfo.version` is the package version unless you set one, as `MCPServerPluginOptions.version` always documented ("Defaults to package version") (#21532). + +Clause-②: no + +- Before, `MCPServerPlugin` and `MCPServerRuntime` each defaulted to the literal `1.0.0`, so every deployment built without the option, `os serve`'s auto-registration included, answered `initialize` with `serverInfo.version` `1.0.0` whatever the installed `@objectstack/mcp` was. Both defaults now read the version from the package's own `package.json`, ESM and CJS alike. +- An explicit `version` option (`MCPServerPluginOptions.version`, `MCPServerRuntimeConfig.version`) is still answered as given. +- `new MCPServerPlugin().version`, the kernel plugin's own version, is the package version too, where it was `1.0.0`. Its declared type is now `string | undefined`: if the manifest cannot be read (a bundle with no `package.json` beside it), `serverInfo.version` says `unknown` and the plugin's own `version` is left unset, which both kernels accept, instead of a placeholder they would refuse. +- Pass `version` yourself to keep reporting a fixed string. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 49e5d5c6b8a..2943fd02063 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -19,7 +19,7 @@ } }, "scripts": { - "build": "tsup --config ../../tsup.config.ts && node ../../scripts/check-dts-emitted.mjs", + "build": "tsup && node ../../scripts/check-dts-emitted.mjs", "check:test-typecheck": "tsx ../../scripts/check-test-typecheck.mts --self-test && tsx ../../scripts/check-test-typecheck.mts --package packages/mcp --project tsconfig.test.json", "gen:test-typecheck-debt": "tsx ../../scripts/check-test-typecheck.mts --update --package packages/mcp --project tsconfig.test.json", "test": "vitest run", diff --git a/packages/mcp/src/__tests__/plugin.test.ts b/packages/mcp/src/__tests__/plugin.test.ts index 03c78778cad..582b6b996e2 100644 --- a/packages/mcp/src/__tests__/plugin.test.ts +++ b/packages/mcp/src/__tests__/plugin.test.ts @@ -98,7 +98,7 @@ describe('MCPServerPlugin', () => { it('should have correct plugin metadata', () => { const plugin = new MCPServerPlugin(); expect(plugin.name).toBe('com.objectstack.mcp'); - expect(plugin.version).toBe('1.0.0'); + // `version` is the package manifest's, pinned in `../mcp-server-info-version.test.ts`. expect(plugin.type).toBe('standard'); }); }); diff --git a/packages/mcp/src/mcp-server-info-version.test.ts b/packages/mcp/src/mcp-server-info-version.test.ts new file mode 100644 index 00000000000..1f3c639b244 --- /dev/null +++ b/packages/mcp/src/mcp-server-info-version.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `serverInfo.version` — what an MCP server answers in `initialize`. + * + * `MCPServerPluginOptions.version` is published as "Defaults to package + * version". Two literal `'1.0.0'` defaults (the plugin's, and + * `MCPServerRuntime`'s) made every deployment built without the option answer + * `1.0.0` while the package was elsewhere, so the registry listing in + * `server.json` and every running server disagreed. Both now read the + * package's own manifest; an explicit `version` still overrides it. + * + * The expected value is read from `package.json` HERE, never written down: a + * literal in this file would rot at the next release, and would let a + * regression that re-introduces some other constant pass on the day the two + * happen to agree. + */ + +import { readFileSync } from 'node:fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { LiteKernel } from '@objectstack/core'; + +import { MCPServerPlugin } from './plugin.js'; +import { MCPServerRuntime } from './mcp-server-runtime.js'; + +const MANIFEST_VERSION: string = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +).version; + +const OVERRIDE = '9.9.9-override.1'; + +const INITIALIZE = { + jsonrpc: '2.0', + id: 0, + method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'pin', version: '0' } }, +}; + +/** `initialize` over the Streamable HTTP door (`POST /api/v1/mcp`). */ +async function serverInfoOverHttp(runtime: MCPServerRuntime): Promise<{ name: string; version: string }> { + const res = await runtime.handleHttpRequest( + new Request('http://localhost/api/v1/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(INITIALIZE), + }), + { parsedBody: INITIALIZE }, + ); + const json = (await res.json()) as { result: { serverInfo: { name: string; version: string } } }; + return json.result.serverInfo; +} + +/** `initialize` against the long-lived server (the stdio composition), over an in-memory pair. */ +async function serverVersionOnLongLivedServer(runtime: MCPServerRuntime): Promise { + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'pin', version: '0' }); + await runtime.server.connect(serverSide); + await client.connect(clientSide); + try { + return client.getServerVersion()?.version; + } finally { + await client.close(); + } +} + +function pluginContext() { + const services = new Map(); + return { + services, + ctx: { + registerService: vi.fn((name: string, service: unknown) => { + services.set(name, service); + }), + getService: vi.fn((name: string) => { + if (!services.has(name)) throw new Error(`Service "${name}" not found`); + return services.get(name); + }), + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + }, + }; +} + +async function runtimeOf(plugin: MCPServerPlugin): Promise { + const { ctx, services } = pluginContext(); + await plugin.init(ctx as never); + return services.get('mcp') as MCPServerRuntime; +} + +describe('serverInfo.version — the default is the package version', () => { + it('MCPServerRuntime built with no config answers the package version', async () => { + expect((await serverInfoOverHttp(new MCPServerRuntime())).version).toBe(MANIFEST_VERSION); + }); + + it('MCPServerPlugin built with no options answers the package version (the `os serve` auto-registration shape)', async () => { + const runtime = await runtimeOf(new MCPServerPlugin()); + expect((await serverInfoOverHttp(runtime)).version).toBe(MANIFEST_VERSION); + }); + + it('the long-lived (stdio) server answers the package version too', async () => { + expect(await serverVersionOnLongLivedServer(new MCPServerRuntime())).toBe(MANIFEST_VERSION); + expect(await serverVersionOnLongLivedServer(await runtimeOf(new MCPServerPlugin()))).toBe(MANIFEST_VERSION); + }); + + it("the kernel plugin's own `version` is the same one value", () => { + expect(new MCPServerPlugin().version).toBe(MANIFEST_VERSION); + }); +}); + +describe('serverInfo.version — an explicit override is answered as given', () => { + it('MCPServerRuntime config.version', async () => { + const runtime = new MCPServerRuntime({ version: OVERRIDE }); + expect((await serverInfoOverHttp(runtime)).version).toBe(OVERRIDE); + expect(await serverVersionOnLongLivedServer(new MCPServerRuntime({ version: OVERRIDE }))).toBe(OVERRIDE); + }); + + it('MCPServerPlugin options.version', async () => { + const runtime = await runtimeOf(new MCPServerPlugin({ version: OVERRIDE })); + expect((await serverInfoOverHttp(runtime)).version).toBe(OVERRIDE); + }); +}); + +describe('an unreadable manifest degrades to an honest answer, never to a plugin the kernel refuses', () => { + afterEach(() => { + vi.doUnmock('node:module'); + vi.resetModules(); + }); + + it("serverInfo.version says 'unknown' and the plugin still loads", async () => { + // A bundle with no `package.json` beside it: `createRequire(...)('../package.json')` throws. + vi.resetModules(); + vi.doMock('node:module', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createRequire: () => { + throw new Error('ENOENT: no manifest beside this bundle'); + }, + }; + }); + const { MCPServerPlugin: BlindPlugin } = await import('./plugin.js'); + const plugin = new BlindPlugin(); + + // The wire answer is a free string: it says it does not know. + const { ctx, services } = pluginContext(); + await plugin.init(ctx as never); + expect((await serverInfoOverHttp(services.get('mcp') as MCPServerRuntime)).version).toBe('unknown'); + + // The kernel plugin's `version` has a grammar (SemVer 2.0.0): a placeholder string there would + // be refused by both kernels. `use()` throws on a refused contract. + const kernel = new LiteKernel({ logger: { level: 'silent' } }); + expect(() => kernel.use(plugin)).not.toThrow(); + }); +}); diff --git a/packages/mcp/src/mcp-server-runtime.ts b/packages/mcp/src/mcp-server-runtime.ts index 9f36a33611a..6a9c1c49323 100644 --- a/packages/mcp/src/mcp-server-runtime.ts +++ b/packages/mcp/src/mcp-server-runtime.ts @@ -18,6 +18,7 @@ import { METADATA_UNAVAILABLE_CODE, metadataPartialListingSentence, } from './metadata-completeness.js'; +import { DEFAULT_SERVER_VERSION } from './package-version.js'; import { protocolStdout } from './protocol-stdout.js'; import { renderSkillMarkdown, type RenderSkillOptions } from './skill-md.js'; import { @@ -34,7 +35,7 @@ import { z } from 'zod'; export interface MCPServerRuntimeConfig { /** Human-readable server name. */ name?: string; - /** Server version (semver). */ + /** Server version (semver). Defaults to the `@objectstack/mcp` package version. */ version?: string; /** Optional instructions describing how to use the server. */ instructions?: string; @@ -959,7 +960,7 @@ export class MCPServerRuntime { constructor(config: MCPServerRuntimeConfig = {}) { this.config = { name: 'objectstack', - version: '1.0.0', + version: DEFAULT_SERVER_VERSION, transport: 'stdio', ...config, }; diff --git a/packages/mcp/src/package-version.ts b/packages/mcp/src/package-version.ts new file mode 100644 index 00000000000..7c1fdebcc79 --- /dev/null +++ b/packages/mcp/src/package-version.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The one place `@objectstack/mcp` learns its own version. + * + * `MCPServerPluginOptions.version` is published as "Defaults to package + * version", and the MCP `initialize` answer carries that string as + * `serverInfo.version`. Both defaults used to be the literal `'1.0.0'` (the + * plugin's own, and `MCPServerRuntime`'s), so every deployment that built the + * plugin without options answered `1.0.0` while the package was somewhere + * else entirely, and the registry listing in `server.json` disagreed with + * every running server. Every default now reads this one value; an explicit + * `version` option still overrides it. + * + * The read is the same shape `@objectstack/runtime` + * (`packages/runtime/src/runtime-version.ts`) and `@objectstack/metadata-protocol` + * (`packages/metadata-protocol/src/discovery-version.ts`) already use for their + * own manifests: `createRequire(import.meta.url)` against `../package.json`, + * which sits one directory above both `src/` (vitest) and the bundled + * `dist/index.{js,cjs}` (tsup, single entry, `splitting: false`), so one path + * resolves the same manifest from every shape. Its CJS half rests on + * `shims: true` in this package's `tsup.config.ts` (see the comment there). + */ + +import { createRequire } from 'node:module'; + +function readOwnVersion(): string | undefined { + try { + const require = createRequire(import.meta.url); + const pkg = require('../package.json') as { version?: unknown }; + return typeof pkg.version === 'string' && pkg.version.length > 0 ? pkg.version : undefined; + } catch { + return undefined; + } +} + +/** + * `@objectstack/mcp`'s own installed version, from its `package.json`; + * `undefined` when the manifest cannot be read (a bundle that no longer has the + * file beside it). + * + * This is what the kernel plugin's own `version` takes, and the reason it stays + * `undefined` rather than becoming a placeholder string: both kernels refuse a + * plugin whose `version` is not SemVer 2.0.0, so any non-version placeholder + * would turn an unreadable manifest into an MCP plugin that never loads. An + * absent `version` is accepted, and the kernel supplies its own `0.0.0`. + */ +export const PACKAGE_VERSION: string | undefined = readOwnVersion(); + +/** + * What an MCP server reports as `serverInfo.version` when the caller names none: + * the package version, or `'unknown'` when the manifest cannot be read — + * honest about not knowing, rather than a plausible-looking version a client + * could mistake for real identity. `serverInfo.version` is a free string on the + * wire, so unlike {@link PACKAGE_VERSION} it has no grammar to satisfy. + */ +export const DEFAULT_SERVER_VERSION: string = PACKAGE_VERSION ?? 'unknown'; diff --git a/packages/mcp/src/plugin.ts b/packages/mcp/src/plugin.ts index 0092d820396..cd63b42fb96 100644 --- a/packages/mcp/src/plugin.ts +++ b/packages/mcp/src/plugin.ts @@ -23,6 +23,7 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { TenancyPosture } from '@objectstack/spec/security'; import type { IAIService, IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; import { MCPServerRuntime } from './mcp-server-runtime.js'; +import { PACKAGE_VERSION, DEFAULT_SERVER_VERSION } from './package-version.js'; import type { MCPServerRuntimeConfig, McpMergedMetadataRead } from './mcp-server-runtime.js'; import type { ToolRegistry } from './types.js'; import { @@ -265,7 +266,7 @@ export class MCPServerPlugin implements Plugin { * kernel name this plugin when a consumer requires one before it inits. */ providesServices = ['mcp']; - version = '1.0.0'; + version = PACKAGE_VERSION; type = 'standard' as const; dependencies: string[] = []; @@ -279,7 +280,7 @@ export class MCPServerPlugin implements Plugin { async init(ctx: PluginContext): Promise { const config: MCPServerRuntimeConfig = { name: readEnvWithDeprecation('OS_MCP_SERVER_NAME', 'MCP_SERVER_NAME', { silent: true }) ?? this.options.name ?? 'objectstack', - version: this.options.version ?? '1.0.0', + version: this.options.version ?? DEFAULT_SERVER_VERSION, transport: (readEnvWithDeprecation('OS_MCP_SERVER_TRANSPORT', 'MCP_SERVER_TRANSPORT', { silent: true }) as 'stdio' | 'http') ?? this.options.transport ?? 'stdio', instructions: this.options.instructions, logger: ctx.logger, diff --git a/packages/mcp/tsup.config.ts b/packages/mcp/tsup.config.ts new file mode 100644 index 00000000000..e673f88214c --- /dev/null +++ b/packages/mcp/tsup.config.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineConfig } from 'tsup'; + +import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs'; + +export default defineConfig({ + entry: ['src/index.ts'], + splitting: false, + sourcemap: true, + clean: true, + dts: !process.env.OS_SKIP_DTS, + format: ['esm', 'cjs'], + target: 'es2020', + // LOAD-BEARING, and measured rather than assumed. `package-version.ts` reads + // this package's own `package.json` via `createRequire(import.meta.url)` — + // correct as written for the ESM output. The shared `tsup.config.ts` this + // package built with before has no `shims`, and there esbuild EMPTIES + // `import.meta` in the CJS bundle (`var import_meta = {}`), so + // `createRequire(undefined)` throws, the resolver's `catch` swallows it, and + // `require('@objectstack/mcp')` answers `serverInfo.version` `'unknown'` + // while the ESM build answers the manifest version: the two formats disagree + // and nothing fails at load. `shims: true` makes tsup rewrite `import.meta.url` + // in the CJS build to a real `__filename`-derived value (its + // `assets/cjs_shims.js`), so both formats resolve the SAME package.json. + // `packages/runtime/tsup.config.ts` and `packages/metadata-protocol/tsup.config.ts` + // carry it for the same reason. Do not drop this line while + // `package-version.ts` exists. + // + // Need-based injection — nothing else here references `__dirname` / + // `__filename`, so the ESM build's shim path is a no-op. + shims: true, + esbuildOptions: dropSourcesContent, +});