Skip to content

Commit 6094c89

Browse files
committed
Merge origin/main (6cf1154) into claude/issue-21520-family-body-boundary
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude <noreply@anthropic.com>
2 parents d111f49 + 6cf1154 commit 6094c89

21 files changed

Lines changed: 907 additions & 30 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
A page's `requires` is accepted only on the kinds whose source is compiled at save: `html` and its deprecated alias `jsx`. On a `react`, `full` or `slotted` page, and on a page that omits `kind` (which is `full`), it is refused at parse.
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: registered page-requires-non-compiled-kind-removed, page-requires-non-compiled-kind-refused -->
10+
11+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
**Why.** `requires` is the list of plugin namespaces a page's source uses (ADR-0080 §5). It is derived from the source at save, and its describe has always said "omit it". On an html page, on a server that has the deployment's SDUI component manifest, the metadata save door compiles the source, stores the namespaces it uses as `requires`, and refuses a written list that disagrees. A `react` source is executed at render and never compiled at save, and `full` and `slotted` pages have no source. So on those three kinds nothing derived the key, the Studio page editor dropped it on every save, and its one reader was a load-time warning. `PageSchema` still accepted it there and never told the author it did nothing. The maintainer ruled that the key is accepted only on the compiled kinds.
14+
15+
**What is refused.** `requires` on a page whose `kind` is `react`, `full` or `slotted`, or a page with no `kind`, at the `requires` path. An empty list is refused too, because the key is what is refused, not its contents. The issue's `code` is `custom`, and its message names the key, the page's kind and the compiled kinds. That covers `definePage()`, `PageSchema`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `pages.N.requires`), `os validate`, which runs the same stack parse, and the metadata save door (`422 INVALID_METADATA`).
16+
17+
**What stays accepted.** `requires` on an `html` or `jsx` page, byte for byte. The save door still derives it, stores it, and refuses a written list that disagrees. Every page that omits `requires` parses as before, on every kind.
18+
19+
## FROM → TO
20+
21+
| you wrote | write instead |
22+
|:--|:--|
23+
| `requires: [...]` on a `kind: 'react'` page | nothing: delete the key. Nothing derived or enforced it |
24+
| `requires: [...]` on a `kind: 'full'` or `kind: 'slotted'` page, or on a page with no `kind` | nothing: delete the key |
25+
| `requires: [...]` on a `kind: 'html'` or `kind: 'jsx'` page | unchanged. The platform derives it from the source at save, so omitting it is still the intended authoring |
26+
27+
**The one-line fix: delete `requires` from every page whose `kind` is not `html` or `jsx`.** `os migrate meta --from 17` lists the mechanical edits for existing sources. Stored pages and built artifacts are converted when they are read.
28+
29+
**Who is affected, measured.** No page body authors `requires` on a `react`, `full` or `slotted` page in this repository at `c98a72d69e` (`examples/**`, `packages/apps/**`, `content/docs/**`, `skills/**`, tests and fixtures). Every `requires:` there is the stack-level capability list or an html page in a save-door test. The same holds in cloud (`c5a4c9e6cb`), hotcrm (`5ae524916d`) and objectui (`8366accd13`), per the ruling's census. Deployed metadata was not measured.
30+
31+
### The retirement kit
32+
33+
- **The refusal.** `checkPageRequiresKind`, an exported object-level check attached to `PageSchema` beside `checkPageSourceCompleteness` (`@objectstack/spec/ui`), with `COMPILED_PAGE_KINDS` (`['html', 'jsx']`) as its vocabulary. A downstream mirror that derives its schema from `PageSchema.shape` re-attaches it with `.superRefine(checkPageRequiresKind)`. There is no tombstone and no `RETIRED_KEYS_BY_MAJOR` row, because the key stays live on html pages.
34+
- **The conversion.** `page-requires-non-compiled-kind-removed` (protocol 18) deletes the key from `react`, `full`, `slotted` and kind-less pages. It is a lossless delete: on those kinds the list never had an effect. It is retired from the load path, so authored sources are refused at parse, while stored rows, built artifacts and `os migrate meta` replay it. Its D3 record is the semantic entry `page-requires-non-compiled-kind-refused`.
35+
- **The ledgers.** The `requires` describe, its liveness row (`liveness/page.json`) and its form-reconciliation row now say the key exists only on html and jsx pages.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/mcp': patch
3+
---
4+
5+
The MCP server's `serverInfo.version` is the package version unless you set one, as `MCPServerPluginOptions.version` always documented ("Defaults to package version") (#21532).
6+
7+
Clause-②: no
8+
9+
- 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.
10+
- An explicit `version` option (`MCPServerPluginOptions.version`, `MCPServerRuntimeConfig.version`) is still answered as given.
11+
- `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.
12+
- Pass `version` yourself to keep reporting a fixed string.

‎content/docs/references/ui/page.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ View filter rule
184184
| **kind** | `Enum<'full' \| 'slotted' \| 'html' \| 'react' \| 'jsx'>` | optional (default: `"full"`) | Page override mode. full \| slotted = structured authoring; html = author-written constrained JSX compiled (parsed, never executed) to the tree (ADR-0080; the legacy value 'jsx' is a deprecated alias), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors; react = real-React source executed at render by the runtime (ADR-0081), styled by inline `style` with the same token colors; it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via the OS_PAGE_REACT=off env toggle. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080 amendment 2026-06-30). |
185185
| **slots** | `{ header?: object \| object[]; actions?: object \| object[]; alerts?: object \| object[]; highlights?: object \| object[]; … }` | optional | Slot override map for slotted pages |
186186
| **source** | `string` | optional | Page source text. For kind==='html' (alias 'jsx') it is constrained JSX compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors. For kind==='react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier), styled by inline `style` with the same token colors. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080 amendment 2026-06-30). Authoritative over `regions` in both. |
187-
| **requires** | `string[]` | optional | Plugin namespaces the page's source uses, derived from the source at save — omit it. On a server that has the deployment's SDUI component manifest, saving a kind==='html' page (alias 'jsx') compiles its source and stores the namespaces it uses here; a written list that disagrees with the source is refused (422 INVALID_METADATA, page-requires-disagrees-with-source) — on a draft save it is kept until the draft's publish, which refuses it. At load, a stored page whose list names a plugin no component in that manifest carries is reported, page and plugin named, and is still served. A server with no manifest checks neither and says so once at boot. |
187+
| **requires** | `string[]` | optional | Plugin namespaces the page's source uses, derived from the source at save — omit it. The key exists only on a kind==='html' page (alias 'jsx'), the kinds whose source is compiled at save; on a 'react', 'full' or 'slotted' page — and a page that omits kind, which is 'full' — it is refused at parse. On a server that has the deployment's SDUI component manifest, saving an html page compiles its source and stores the namespaces it uses here; a written list that disagrees with the source is refused (422 INVALID_METADATA, page-requires-disagrees-with-source) — on a draft save it is kept until the draft's publish, which refuses it. At load, a stored page whose list names a plugin no component in that manifest carries is reported, page and plugin named, and is still served. A server with no manifest checks neither and says so once at boot. |
188188
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
189189
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
190190
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |

‎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
};

0 commit comments

Comments
 (0)