Skip to content

Commit ce80ec2

Browse files
huangyiireneclaude
andauthored
feat(spec): declare mcp:connect-agent in ComponentPropsMap — undeclared keys refused; retire the mcp door-3 exemption (#12344) (#12467)
* feat(spec): declare mcp:connect-agent in ComponentPropsMap; retire the mcp door-3 exemption (#12344) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy * chore(spec): regenerate migration registry for ui-mcp-connect-agent-unknown-keys-refused Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy * chore(spec): regenerate migration registry after merging origin/main (#12344) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2f665a1 commit ce80ec2

7 files changed

Lines changed: 234 additions & 55 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): declare `mcp:connect-agent` in `ComponentPropsMap` — undeclared keys on the widget are refused (#12344)
6+
7+
**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
8+
launch-window convention ships it as `minor`; the migration prescription is
9+
registered under protocol major 18, where `os migrate meta` users will look).
10+
11+
This was a third instance of the #8691/#8744 silent no-op class (#11575 closed
12+
the previous two): a console-registered widget on `@objectstack/mcp`'s
13+
plugin-shipped Setup page (`CONNECT_AGENT_PAGE`), reachable through the
14+
component type union's open string arm, with a registered renderer but no
15+
`ComponentPropsMap` row — so the #5068 component-props gate's dispatch skipped
16+
it as unregistered, any authored key rode through every validator in silence,
17+
and door 3 of the mcp canonical-envelope gate (#12269) had to carry a standing
18+
exemption for the type (deleted here, with its two guard pins).
19+
20+
The new row is strict and **empty**, measured from the renderer's actual read
21+
points at the objectui pin, not from the registration's declared-input list
22+
(#8691/#8744 record where those diverge — here the two happen to agree): the
23+
registration discards the schema node entirely (`() => <ConnectAgent />`) and
24+
the component function takes no parameters — every value it renders comes from
25+
`/discovery`, i18n and its own state — so the widget accepts **no
26+
configuration at all**, and an authored key is now a publish-time refusal
27+
naming the surface instead of a silent no-op.
28+
29+
**What stays accepted:** the empty bag (`{}`, or `properties` omitted) — the
30+
shape the plugin-shipped page (`connect_agent`) authors today,
31+
byte-identically. Node-level keys (`visibleWhen`, `id`, `style`, …) are
32+
unaffected: they live on the component node, and the refusal's guidance says
33+
so.
34+
35+
## FROM → TO
36+
37+
```ts
38+
// before — parsed green everywhere; the widget reads /discovery on its own
39+
{
40+
type: 'mcp:connect-agent',
41+
properties: { serverUrl: 'https://example.test/mcp' }, // silent no-op: the widget reads nothing
42+
}
43+
44+
// after — any key is a publish-time refusal naming the zero-prop surface;
45+
// write the measured shape
46+
{
47+
type: 'mcp:connect-agent',
48+
properties: {},
49+
}
50+
```
51+
52+
There is deliberately no automatic rewrite: a key authored on the widget
53+
configures nothing and is removed, not renamed — behaviour that seems to need
54+
one is a renderer capability request against objectui, not a metadata key.
55+
`os migrate meta` surfaces the change as a structured TODO (semantic entry
56+
`ui-mcp-connect-agent-unknown-keys-refused`, protocol major 18 — this refusal
57+
is not part of the v17.0.0 cut).
58+
59+
<!-- adr-0087: registered ui-mcp-connect-agent-unknown-keys-refused -->

packages/lint/src/validate-component-props.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,3 +829,42 @@ describe('validateComponentProps — cloud-connection:panel / marketplace:instal
829829
expect(findings).toEqual([]);
830830
});
831831
});
832+
833+
/**
834+
* #12344 — the `@objectstack/mcp` console widget, the same mechanism a third
835+
* instance over.
836+
*
837+
* The pre-fix state this pins against: `mcp:connect-agent` had no
838+
* `ComponentPropsMap` row (an open-string-arm type — never in
839+
* `PageComponentType` — so nothing else judged it either), and the walker's
840+
* unregistered-type skip swallowed the whole props bag: any authored key
841+
* produced ZERO findings from validate/build, and door 3 of the mcp
842+
* canonical-envelope gate (#12269) had to carry a standing exemption for the
843+
* type. Its row is strict and EMPTY — measured from the renderer's read
844+
* points at the `.objectui-sha` pin, where the registration discards the
845+
* schema node (`() => <ConnectAgent />`) and the component function takes no
846+
* parameters — so EVERY authored key is a finding. Remove the map row and
847+
* this loud test goes back to that silence.
848+
*/
849+
describe('validateComponentProps — mcp:connect-agent is dispatched (#12344)', () => {
850+
it('reports any key authored on `mcp:connect-agent`, naming the zero-prop surface', () => {
851+
const findings = validateComponentProps(
852+
stackWith([{
853+
type: 'mcp:connect-agent',
854+
properties: { serverUrl: 'https://example.test/mcp' },
855+
}]),
856+
);
857+
expect(findings).toHaveLength(1);
858+
expect(findings[0].rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY);
859+
expect(findings[0].where).toBe('page "probe_page" · mcp:connect-agent');
860+
expect(findings[0].message).toContain('`serverUrl`');
861+
expect(findings[0].message).toContain('mcp:connect-agent');
862+
});
863+
864+
it('stays silent on the empty bag the plugin-shipped page authors', () => {
865+
const findings = validateComponentProps(
866+
stackWith([{ type: 'mcp:connect-agent', properties: {} }]),
867+
);
868+
expect(findings).toEqual([]);
869+
});
870+
});

packages/mcp/src/canonical-expression-envelopes.test.ts

Lines changed: 21 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -36,24 +36,21 @@
3636
* a different card and a different decision. Copying is what THIS card is.
3737
* Recorded here so the next author inherits the count rather than the habit.
3838
*
39-
* ## The one exempted component type
39+
* ## No standing exemptions (#12344)
4040
*
41-
* `page:header` carries a `ComponentPropsMap` row, so door 3 reads its
42-
* `properties` bag. `mcp:connect-agent` does NOT: it is a console-registered
43-
* widget provided by objectui's app-shell, so door 3 has no schema to read its
44-
* `properties` with — the same standing-exemption shape `cloud-connection`'s
45-
* two widgets were in between #11480 and #11575, before #11575 gave them
46-
* strict, empty rows measured from the renderers' read points at the
47-
* `.objectui-sha` pin. Giving this type its row is that same piece of work and
48-
* belongs on its own card (filed); it is outside this card's declared surface.
49-
*
50-
* Until then the exemption is asserted EXACTLY (a NEW unmapped type reds), and
51-
* it is sound only while the exempted widget authors an EMPTY props bag —
52-
* nothing authored is nothing to serve bare. The moment it grows a real
53-
* authored prop, the emptiness assert reds and forces the decision: give the
54-
* type a `ComponentPropsMap` row, or widen the exemption knowingly. Both halves
55-
* are needed; the exemption alone would be a door-3 blind spot that widens in
56-
* silence.
41+
* `mcp:connect-agent` was exempted here between #12269 and #12344: a
42+
* console-registered widget provided by objectui's app-shell with no
43+
* `ComponentPropsMap` row, so door 3 had no schema to read its `properties`
44+
* with — the same standing-exemption shape `cloud-connection`'s two widgets
45+
* were in between #11480 and #11575. #12344 gave the type its row (strict,
46+
* empty — measured from the renderer's read points at the `.objectui-sha`
47+
* pin, where the registration discards the schema node entirely and the
48+
* component function takes no parameters), so door 3 now reads its bag and
49+
* the exemption list is empty. The machinery stays: the exemption set is
50+
* still asserted EXACTLY, so any NEW unmapped type reds and forces the same
51+
* decision — declare the props schema in `ComponentPropsMap`, or record the
52+
* exemption here with the reason (and then also pin the exempted bag empty
53+
* and the list non-vacuous, as the pre-#12344 revision of this file did).
5754
*/
5855

5956
import { readFileSync, readdirSync } from 'node:fs';
@@ -64,7 +61,6 @@ import type { Page } from '@objectstack/spec/ui';
6461
import {
6562
auditPageExpressionEnvelopes,
6663
renderBareExpressionFindings,
67-
walkPageComponents,
6864
} from '@objectstack/lint';
6965
// The one answer this tree has to "comment, literal, or code". It is a plain
7066
// `.mjs`, but `scripts/js-comment-mask.d.mts` beside it is a hand-written
@@ -86,13 +82,14 @@ const HERE = dirname(fileURLToPath(import.meta.url));
8682

8783
/**
8884
* Every page this package ships, audited by export name — with the unmapped
89-
* component types each page is EXPECTED to report (the exemption above).
85+
* component types each page is EXPECTED to report (none since #12344; see
86+
* the module header).
9087
*/
9188
const AUDITED_PAGES: { exportName: string; page: Page; exemptUnmappedTypes: string[] }[] = [
9289
{
9390
exportName: 'CONNECT_AGENT_PAGE',
9491
page: CONNECT_AGENT_PAGE,
95-
exemptUnmappedTypes: ['mcp:connect-agent'],
92+
exemptUnmappedTypes: [],
9693
},
9794
];
9895

@@ -227,42 +224,14 @@ describe('mcp Page exports serve canonical expression envelopes', () => {
227224
});
228225

229226
it.each(AUDITS)('$exportName: unmapped component types are EXACTLY the recorded exemptions (door 3 precondition)', ({ audit, exemptUnmappedTypes }) => {
230-
// See the header for why `mcp:connect-agent` is exempt. Anything ELSE
227+
// No exemptions stand since #12344 (see the module header). Anything
231228
// unmapped is a new door-3 blind spot: declare the props schema in
232-
// `ComponentPropsMap`, or record the exemption here with the reason — and
233-
// then also pin the exempted bag empty, as the test below does, so the
234-
// exemption cannot quietly cover a growing bag.
229+
// `ComponentPropsMap`, or record the exemption here with the reason —
230+
// and then also pin the exempted bag empty and the exemption list
231+
// non-vacuous, as the pre-#12344 revision of this file did.
235232
expect(audit.unmappedTypes.map(e => e.type).sort()).toEqual([...exemptUnmappedTypes].sort());
236233
});
237234

238-
it.each(AUDITS)('$exportName: every exempted component authors an EMPTY props bag', ({ page, exemptUnmappedTypes }) => {
239-
// The exemption above is only sound while there is nothing authored for
240-
// door 3 to miss. A real key landing in one of these bags must force a
241-
// decision (props schema row, or a conscious wider exemption) — not ride
242-
// through a standing exemption silently.
243-
const offenders = walkPageComponents(page as AnyRec, '')
244-
.filter(w => typeof w.component.type === 'string' && exemptUnmappedTypes.includes(w.component.type))
245-
.filter(w => {
246-
const props = w.component.properties;
247-
return !!props && typeof props === 'object' && Object.keys(props).length > 0;
248-
})
249-
.map(w => `${w.path} [${String(w.component.type)}]`);
250-
expect(offenders.join('\n')).toBe('');
251-
});
252-
253-
it.each(AUDITS)('$exportName: the exemption list is not vacuous — every exempted type is really on this page', ({ page, exemptUnmappedTypes }) => {
254-
// The reverse rot: a type left in the list after it stopped appearing on
255-
// the page (or after it gained a `ComponentPropsMap` row) is an exemption
256-
// covering nothing, and it would keep the EXACT assert above green while
257-
// hiding the fact that the door is now open. Delete it when it goes stale.
258-
const present = new Set(
259-
walkPageComponents(page as AnyRec, '')
260-
.map(w => w.component.type)
261-
.filter((t): t is string => typeof t === 'string'),
262-
);
263-
expect(exemptUnmappedTypes.filter(t => !present.has(t))).toEqual([]);
264-
});
265-
266235
it.each(AUDITS)('$exportName: every authored `properties` bag parses against its props schema (door 3 precondition)', ({ audit }) => {
267236
expect(
268237
audit.unreadableProps.map(e => `${e.path} [${e.type}]: ${e.issues}`).join('\n'),
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'ui-mcp-connect-agent-unknown-keys-refused',
7+
surface: 'page `mcp:connect-agent` component — `properties` (any key at all: the widget '
8+
+ 'declares no props)',
9+
replacement: 'an empty `properties` bag (`{}`), or omit `properties` entirely. The widget '
10+
+ 'reads no prop: the console registration discards the schema node '
11+
+ '(`() => <ConnectAgent />`) and the component function takes no parameters — every '
12+
+ 'value it renders comes from `/discovery`, i18n and its own state — so there is no '
13+
+ 'declared key to move to; a key authored on it configures nothing and is removed, '
14+
+ 'not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component '
15+
+ 'node, where the page runtime reads them.',
16+
reason:
17+
'This was a third instance of the #8691/#8744 class (#11575 closed the previous two): a '
18+
+ 'console-registered widget on `@objectstack/mcp`\'s plugin-shipped Setup page, '
19+
+ 'reachable through the component type union\'s open string arm, with a registered '
20+
+ 'renderer but no `ComponentPropsMap` row — so the #5068 props gate\'s dispatch '
21+
+ 'skipped it as unregistered, any authored key rode through every validator in '
22+
+ 'silence, and door 3 of the mcp canonical-envelope gate (#12269) had to carry a '
23+
+ 'standing exemption for the type. The new row is strict and EMPTY, measured from the '
24+
+ 'renderer\'s actual read points at the objectui pin (not from the registration\'s '
25+
+ 'declared-input list): the registration ignores the component node entirely, so the '
26+
+ 'widget accepts no configuration at all, and an authored key is now a publish-time '
27+
+ 'refusal naming the surface instead of a silent no-op.',
28+
acceptanceCriteria:
29+
'Every `mcp:connect-agent` node authors an empty (or absent) `properties` bag and '
30+
+ 'validates clean — the plugin-shipped page (`connect_agent`) already does; '
31+
+ '`objectstack validate` reports no `component-props-unknown-key` finding for the '
32+
+ 'type. Any remaining authored key on the widget is deleted (it never configured '
33+
+ 'anything), and behaviour that seems to need one is a renderer capability request '
34+
+ 'against objectui, not a metadata key.',
35+
};

packages/spec/src/migrations/registry.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6827,6 +6827,37 @@ const step18: MigrationStep = {
68276827
+ 'next authoring-path save with a prescriptive per-key issue; the author deletes the '
68286828
+ 'key or re-declares the integer they meant.',
68296829
},
6830+
{
6831+
id: 'ui-mcp-connect-agent-unknown-keys-refused',
6832+
surface: 'page `mcp:connect-agent` component — `properties` (any key at all: the widget '
6833+
+ 'declares no props)',
6834+
replacement: 'an empty `properties` bag (`{}`), or omit `properties` entirely. The widget '
6835+
+ 'reads no prop: the console registration discards the schema node '
6836+
+ '(`() => <ConnectAgent />`) and the component function takes no parameters — every '
6837+
+ 'value it renders comes from `/discovery`, i18n and its own state — so there is no '
6838+
+ 'declared key to move to; a key authored on it configures nothing and is removed, '
6839+
+ 'not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component '
6840+
+ 'node, where the page runtime reads them.',
6841+
reason:
6842+
'This was a third instance of the #8691/#8744 class (#11575 closed the previous two): a '
6843+
+ 'console-registered widget on `@objectstack/mcp`\'s plugin-shipped Setup page, '
6844+
+ 'reachable through the component type union\'s open string arm, with a registered '
6845+
+ 'renderer but no `ComponentPropsMap` row — so the #5068 props gate\'s dispatch '
6846+
+ 'skipped it as unregistered, any authored key rode through every validator in '
6847+
+ 'silence, and door 3 of the mcp canonical-envelope gate (#12269) had to carry a '
6848+
+ 'standing exemption for the type. The new row is strict and EMPTY, measured from the '
6849+
+ 'renderer\'s actual read points at the objectui pin (not from the registration\'s '
6850+
+ 'declared-input list): the registration ignores the component node entirely, so the '
6851+
+ 'widget accepts no configuration at all, and an authored key is now a publish-time '
6852+
+ 'refusal naming the surface instead of a silent no-op.',
6853+
acceptanceCriteria:
6854+
'Every `mcp:connect-agent` node authors an empty (or absent) `properties` bag and '
6855+
+ 'validates clean — the plugin-shipped page (`connect_agent`) already does; '
6856+
+ '`objectstack validate` reports no `component-props-unknown-key` finding for the '
6857+
+ 'type. Any remaining authored key on the widget is deleted (it never configured '
6858+
+ 'anything), and behaviour that seems to need one is a renderer capability request '
6859+
+ 'against objectui, not a metadata key.',
6860+
},
68306861
{
68316862
id: 'ui-record-blocks-unknown-keys-refused',
68326863
surface: 'page `record:alert` / `record:quick_actions` / `record:history` / '

packages/spec/src/ui/component.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,6 +1010,36 @@ describe('ComponentPropsMap', () => {
10101010
}
10111011
});
10121012
});
1013+
1014+
// #12344 — the `@objectstack/mcp` console widget, the same mechanism a
1015+
// third instance over. Row exists so the #5068 gate's dispatch reaches it
1016+
// (and so the mcp canonical-envelope gate's door 3 reads its bag instead of
1017+
// carrying a standing exemption); the accepted key set is EMPTY, measured
1018+
// from the renderer's read points at the `.objectui-sha` pin (the
1019+
// registration discards the schema node — `() => <ConnectAgent />` — and
1020+
// the component function takes no parameters).
1021+
describe('mcp console widget (#12344)', () => {
1022+
it('declares a row for mcp:connect-agent', () => {
1023+
expect(ComponentPropsMap['mcp:connect-agent']).toBeDefined();
1024+
});
1025+
1026+
it('accepts the empty bag the shipped page authors', () => {
1027+
expect(() => ComponentPropsMap['mcp:connect-agent'].parse({})).not.toThrow();
1028+
});
1029+
1030+
it('refuses any authored key, naming the surface — the pre-row silent no-op', () => {
1031+
// Before the row, the key below rode through every validator in
1032+
// silence (the widget reads nothing authored). The refusal must name
1033+
// WHICH zero-prop component refused, or the author is left guessing.
1034+
const widget = ComponentPropsMap['mcp:connect-agent'].safeParse({ serverUrl: 'https://x' });
1035+
expect(widget.success).toBe(false);
1036+
if (!widget.success) {
1037+
const message = widget.error.issues.map((i) => i.message).join('\n');
1038+
expect(message).toContain('mcp:connect-agent');
1039+
expect(message).toContain('serverUrl');
1040+
}
1041+
});
1042+
});
10131043
});
10141044

10151045
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)