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
12 changes: 12 additions & 0 deletions .changeset/21532-mcp-server-info-version-default.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/src/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Expand Down
155 changes: 155 additions & 0 deletions packages/mcp/src/mcp-server-info-version.test.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
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<string, unknown>();
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<MCPServerRuntime> {
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<typeof import('node:module')>();
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();
});
});
5 changes: 3 additions & 2 deletions packages/mcp/src/mcp-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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,
};
Expand Down
57 changes: 57 additions & 0 deletions packages/mcp/src/package-version.ts
Original file line number Diff line number Diff line change
@@ -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';
5 changes: 3 additions & 2 deletions packages/mcp/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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[] = [];

Expand All @@ -279,7 +280,7 @@ export class MCPServerPlugin implements Plugin {
async init(ctx: PluginContext): Promise<void> {
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,
Expand Down
34 changes: 34 additions & 0 deletions packages/mcp/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -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,
});
Loading