diff --git a/.changeset/ui-mcp-connect-agent-unknown-keys-refused.md b/.changeset/ui-mcp-connect-agent-unknown-keys-refused.md
new file mode 100644
index 0000000000..bab79821b7
--- /dev/null
+++ b/.changeset/ui-mcp-connect-agent-unknown-keys-refused.md
@@ -0,0 +1,59 @@
+---
+"@objectstack/spec": minor
+---
+
+feat(spec): declare `mcp:connect-agent` in `ComponentPropsMap` — undeclared keys on the widget are refused (#12344)
+
+**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
+launch-window convention ships it as `minor`; the migration prescription is
+registered under protocol major 18, where `os migrate meta` users will look).
+
+This was a third instance of the #8691/#8744 silent no-op class (#11575 closed
+the previous two): a console-registered widget on `@objectstack/mcp`'s
+plugin-shipped Setup page (`CONNECT_AGENT_PAGE`), reachable through the
+component type union's open string arm, with a registered renderer but no
+`ComponentPropsMap` row — so the #5068 component-props gate's dispatch skipped
+it as unregistered, any authored key rode through every validator in silence,
+and door 3 of the mcp canonical-envelope gate (#12269) had to carry a standing
+exemption for the type (deleted here, with its two guard pins).
+
+The new row is strict and **empty**, measured from the renderer's actual read
+points at the objectui pin, not from the registration's declared-input list
+(#8691/#8744 record where those diverge — here the two happen to agree): the
+registration discards the schema node entirely (`() => `) and
+the component function takes no parameters — every value it renders comes from
+`/discovery`, i18n and its own state — so the widget accepts **no
+configuration at all**, and an authored key is now a publish-time refusal
+naming the surface instead of a silent no-op.
+
+**What stays accepted:** the empty bag (`{}`, or `properties` omitted) — the
+shape the plugin-shipped page (`connect_agent`) authors today,
+byte-identically. Node-level keys (`visibleWhen`, `id`, `style`, …) are
+unaffected: they live on the component node, and the refusal's guidance says
+so.
+
+## FROM → TO
+
+```ts
+// before — parsed green everywhere; the widget reads /discovery on its own
+{
+ type: 'mcp:connect-agent',
+ properties: { serverUrl: 'https://example.test/mcp' }, // silent no-op: the widget reads nothing
+}
+
+// after — any key is a publish-time refusal naming the zero-prop surface;
+// write the measured shape
+{
+ type: 'mcp:connect-agent',
+ properties: {},
+}
+```
+
+There is deliberately no automatic rewrite: a key authored on the widget
+configures nothing and is removed, not renamed — behaviour that seems to need
+one is a renderer capability request against objectui, not a metadata key.
+`os migrate meta` surfaces the change as a structured TODO (semantic entry
+`ui-mcp-connect-agent-unknown-keys-refused`, protocol major 18 — this refusal
+is not part of the v17.0.0 cut).
+
+
diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts
index 038bfce505..74c046dbc6 100644
--- a/packages/lint/src/validate-component-props.test.ts
+++ b/packages/lint/src/validate-component-props.test.ts
@@ -829,3 +829,42 @@ describe('validateComponentProps — cloud-connection:panel / marketplace:instal
expect(findings).toEqual([]);
});
});
+
+/**
+ * #12344 — the `@objectstack/mcp` console widget, the same mechanism a third
+ * instance over.
+ *
+ * The pre-fix state this pins against: `mcp:connect-agent` had no
+ * `ComponentPropsMap` row (an open-string-arm type — never in
+ * `PageComponentType` — so nothing else judged it either), and the walker's
+ * unregistered-type skip swallowed the whole props bag: any authored key
+ * produced ZERO findings from validate/build, and door 3 of the mcp
+ * canonical-envelope gate (#12269) had to carry a standing exemption for the
+ * type. Its row is strict and EMPTY — measured from the renderer's read
+ * points at the `.objectui-sha` pin, where the registration discards the
+ * schema node (`() => `) and the component function takes no
+ * parameters — so EVERY authored key is a finding. Remove the map row and
+ * this loud test goes back to that silence.
+ */
+describe('validateComponentProps — mcp:connect-agent is dispatched (#12344)', () => {
+ it('reports any key authored on `mcp:connect-agent`, naming the zero-prop surface', () => {
+ const findings = validateComponentProps(
+ stackWith([{
+ type: 'mcp:connect-agent',
+ properties: { serverUrl: 'https://example.test/mcp' },
+ }]),
+ );
+ expect(findings).toHaveLength(1);
+ expect(findings[0].rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY);
+ expect(findings[0].where).toBe('page "probe_page" · mcp:connect-agent');
+ expect(findings[0].message).toContain('`serverUrl`');
+ expect(findings[0].message).toContain('mcp:connect-agent');
+ });
+
+ it('stays silent on the empty bag the plugin-shipped page authors', () => {
+ const findings = validateComponentProps(
+ stackWith([{ type: 'mcp:connect-agent', properties: {} }]),
+ );
+ expect(findings).toEqual([]);
+ });
+});
diff --git a/packages/mcp/src/canonical-expression-envelopes.test.ts b/packages/mcp/src/canonical-expression-envelopes.test.ts
index 793ba9af7d..aa84f1de66 100644
--- a/packages/mcp/src/canonical-expression-envelopes.test.ts
+++ b/packages/mcp/src/canonical-expression-envelopes.test.ts
@@ -36,24 +36,21 @@
* a different card and a different decision. Copying is what THIS card is.
* Recorded here so the next author inherits the count rather than the habit.
*
- * ## The one exempted component type
+ * ## No standing exemptions (#12344)
*
- * `page:header` carries a `ComponentPropsMap` row, so door 3 reads its
- * `properties` bag. `mcp:connect-agent` does NOT: it is a console-registered
- * widget provided by objectui's app-shell, so door 3 has no schema to read its
- * `properties` with — the same standing-exemption shape `cloud-connection`'s
- * two widgets were in between #11480 and #11575, before #11575 gave them
- * strict, empty rows measured from the renderers' read points at the
- * `.objectui-sha` pin. Giving this type its row is that same piece of work and
- * belongs on its own card (filed); it is outside this card's declared surface.
- *
- * Until then the exemption is asserted EXACTLY (a NEW unmapped type reds), and
- * it is sound only while the exempted widget authors an EMPTY props bag —
- * nothing authored is nothing to serve bare. The moment it grows a real
- * authored prop, the emptiness assert reds and forces the decision: give the
- * type a `ComponentPropsMap` row, or widen the exemption knowingly. Both halves
- * are needed; the exemption alone would be a door-3 blind spot that widens in
- * silence.
+ * `mcp:connect-agent` was exempted here between #12269 and #12344: a
+ * console-registered widget provided by objectui's app-shell with no
+ * `ComponentPropsMap` row, so door 3 had no schema to read its `properties`
+ * with — the same standing-exemption shape `cloud-connection`'s two widgets
+ * were in between #11480 and #11575. #12344 gave the type its row (strict,
+ * empty — measured from the renderer's read points at the `.objectui-sha`
+ * pin, where the registration discards the schema node entirely and the
+ * component function takes no parameters), so door 3 now reads its bag and
+ * the exemption list is empty. The machinery stays: the exemption set is
+ * still asserted EXACTLY, so any NEW unmapped type reds and forces the same
+ * decision — declare the props schema in `ComponentPropsMap`, or record the
+ * exemption here with the reason (and then also pin the exempted bag empty
+ * and the list non-vacuous, as the pre-#12344 revision of this file did).
*/
import { readFileSync, readdirSync } from 'node:fs';
@@ -64,7 +61,6 @@ import type { Page } from '@objectstack/spec/ui';
import {
auditPageExpressionEnvelopes,
renderBareExpressionFindings,
- walkPageComponents,
} from '@objectstack/lint';
// The one answer this tree has to "comment, literal, or code". It is a plain
// `.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));
/**
* Every page this package ships, audited by export name — with the unmapped
- * component types each page is EXPECTED to report (the exemption above).
+ * component types each page is EXPECTED to report (none since #12344; see
+ * the module header).
*/
const AUDITED_PAGES: { exportName: string; page: Page; exemptUnmappedTypes: string[] }[] = [
{
exportName: 'CONNECT_AGENT_PAGE',
page: CONNECT_AGENT_PAGE,
- exemptUnmappedTypes: ['mcp:connect-agent'],
+ exemptUnmappedTypes: [],
},
];
@@ -227,42 +224,14 @@ describe('mcp Page exports serve canonical expression envelopes', () => {
});
it.each(AUDITS)('$exportName: unmapped component types are EXACTLY the recorded exemptions (door 3 precondition)', ({ audit, exemptUnmappedTypes }) => {
- // See the header for why `mcp:connect-agent` is exempt. Anything ELSE
+ // No exemptions stand since #12344 (see the module header). Anything
// unmapped is a new door-3 blind spot: declare the props schema in
- // `ComponentPropsMap`, or record the exemption here with the reason — and
- // then also pin the exempted bag empty, as the test below does, so the
- // exemption cannot quietly cover a growing bag.
+ // `ComponentPropsMap`, or record the exemption here with the reason —
+ // and then also pin the exempted bag empty and the exemption list
+ // non-vacuous, as the pre-#12344 revision of this file did.
expect(audit.unmappedTypes.map(e => e.type).sort()).toEqual([...exemptUnmappedTypes].sort());
});
- it.each(AUDITS)('$exportName: every exempted component authors an EMPTY props bag', ({ page, exemptUnmappedTypes }) => {
- // The exemption above is only sound while there is nothing authored for
- // door 3 to miss. A real key landing in one of these bags must force a
- // decision (props schema row, or a conscious wider exemption) — not ride
- // through a standing exemption silently.
- const offenders = walkPageComponents(page as AnyRec, '')
- .filter(w => typeof w.component.type === 'string' && exemptUnmappedTypes.includes(w.component.type))
- .filter(w => {
- const props = w.component.properties;
- return !!props && typeof props === 'object' && Object.keys(props).length > 0;
- })
- .map(w => `${w.path} [${String(w.component.type)}]`);
- expect(offenders.join('\n')).toBe('');
- });
-
- it.each(AUDITS)('$exportName: the exemption list is not vacuous — every exempted type is really on this page', ({ page, exemptUnmappedTypes }) => {
- // The reverse rot: a type left in the list after it stopped appearing on
- // the page (or after it gained a `ComponentPropsMap` row) is an exemption
- // covering nothing, and it would keep the EXACT assert above green while
- // hiding the fact that the door is now open. Delete it when it goes stale.
- const present = new Set(
- walkPageComponents(page as AnyRec, '')
- .map(w => w.component.type)
- .filter((t): t is string => typeof t === 'string'),
- );
- expect(exemptUnmappedTypes.filter(t => !present.has(t))).toEqual([]);
- });
-
it.each(AUDITS)('$exportName: every authored `properties` bag parses against its props schema (door 3 precondition)', ({ audit }) => {
expect(
audit.unreadableProps.map(e => `${e.path} [${e.type}]: ${e.issues}`).join('\n'),
diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-mcp-connect-agent-unknown-keys-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-mcp-connect-agent-unknown-keys-refused.ts
new file mode 100644
index 0000000000..1100d74d5a
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/18.ui-mcp-connect-agent-unknown-keys-refused.ts
@@ -0,0 +1,35 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+export const entry: SemanticMigration = {
+ id: 'ui-mcp-connect-agent-unknown-keys-refused',
+ surface: 'page `mcp:connect-agent` component — `properties` (any key at all: the widget '
+ + 'declares no props)',
+ replacement: 'an empty `properties` bag (`{}`), or omit `properties` entirely. The widget '
+ + 'reads no prop: the console registration discards the schema node '
+ + '(`() => `) and the component function takes no parameters — every '
+ + 'value it renders comes from `/discovery`, i18n and its own state — so there is no '
+ + 'declared key to move to; a key authored on it configures nothing and is removed, '
+ + 'not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component '
+ + 'node, where the page runtime reads them.',
+ reason:
+ 'This was a third instance of the #8691/#8744 class (#11575 closed the previous two): a '
+ + 'console-registered widget on `@objectstack/mcp`\'s plugin-shipped Setup page, '
+ + 'reachable through the component type union\'s open string arm, with a registered '
+ + 'renderer but no `ComponentPropsMap` row — so the #5068 props gate\'s dispatch '
+ + 'skipped it as unregistered, any authored key rode through every validator in '
+ + 'silence, and door 3 of the mcp canonical-envelope gate (#12269) had to carry a '
+ + 'standing exemption for the type. The new row is strict and EMPTY, measured from the '
+ + 'renderer\'s actual read points at the objectui pin (not from the registration\'s '
+ + 'declared-input list): the registration ignores the component node entirely, so the '
+ + 'widget accepts no configuration at all, and an authored key is now a publish-time '
+ + 'refusal naming the surface instead of a silent no-op.',
+ acceptanceCriteria:
+ 'Every `mcp:connect-agent` node authors an empty (or absent) `properties` bag and '
+ + 'validates clean — the plugin-shipped page (`connect_agent`) already does; '
+ + '`objectstack validate` reports no `component-props-unknown-key` finding for the '
+ + 'type. Any remaining authored key on the widget is deleted (it never configured '
+ + 'anything), and behaviour that seems to need one is a renderer capability request '
+ + 'against objectui, not a metadata key.',
+};
diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts
index a6aad461f9..76794498f9 100644
--- a/packages/spec/src/migrations/registry.ts
+++ b/packages/spec/src/migrations/registry.ts
@@ -6827,6 +6827,37 @@ const step18: MigrationStep = {
+ 'next authoring-path save with a prescriptive per-key issue; the author deletes the '
+ 'key or re-declares the integer they meant.',
},
+ {
+ id: 'ui-mcp-connect-agent-unknown-keys-refused',
+ surface: 'page `mcp:connect-agent` component — `properties` (any key at all: the widget '
+ + 'declares no props)',
+ replacement: 'an empty `properties` bag (`{}`), or omit `properties` entirely. The widget '
+ + 'reads no prop: the console registration discards the schema node '
+ + '(`() => `) and the component function takes no parameters — every '
+ + 'value it renders comes from `/discovery`, i18n and its own state — so there is no '
+ + 'declared key to move to; a key authored on it configures nothing and is removed, '
+ + 'not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component '
+ + 'node, where the page runtime reads them.',
+ reason:
+ 'This was a third instance of the #8691/#8744 class (#11575 closed the previous two): a '
+ + 'console-registered widget on `@objectstack/mcp`\'s plugin-shipped Setup page, '
+ + 'reachable through the component type union\'s open string arm, with a registered '
+ + 'renderer but no `ComponentPropsMap` row — so the #5068 props gate\'s dispatch '
+ + 'skipped it as unregistered, any authored key rode through every validator in '
+ + 'silence, and door 3 of the mcp canonical-envelope gate (#12269) had to carry a '
+ + 'standing exemption for the type. The new row is strict and EMPTY, measured from the '
+ + 'renderer\'s actual read points at the objectui pin (not from the registration\'s '
+ + 'declared-input list): the registration ignores the component node entirely, so the '
+ + 'widget accepts no configuration at all, and an authored key is now a publish-time '
+ + 'refusal naming the surface instead of a silent no-op.',
+ acceptanceCriteria:
+ 'Every `mcp:connect-agent` node authors an empty (or absent) `properties` bag and '
+ + 'validates clean — the plugin-shipped page (`connect_agent`) already does; '
+ + '`objectstack validate` reports no `component-props-unknown-key` finding for the '
+ + 'type. Any remaining authored key on the widget is deleted (it never configured '
+ + 'anything), and behaviour that seems to need one is a renderer capability request '
+ + 'against objectui, not a metadata key.',
+ },
{
id: 'ui-record-blocks-unknown-keys-refused',
surface: 'page `record:alert` / `record:quick_actions` / `record:history` / '
diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts
index cd730eb6a4..d392b8b361 100644
--- a/packages/spec/src/ui/component.test.ts
+++ b/packages/spec/src/ui/component.test.ts
@@ -1010,6 +1010,36 @@ describe('ComponentPropsMap', () => {
}
});
});
+
+ // #12344 — the `@objectstack/mcp` console widget, the same mechanism a
+ // third instance over. Row exists so the #5068 gate's dispatch reaches it
+ // (and so the mcp canonical-envelope gate's door 3 reads its bag instead of
+ // carrying a standing exemption); the accepted key set is EMPTY, measured
+ // from the renderer's read points at the `.objectui-sha` pin (the
+ // registration discards the schema node — `() => ` — and
+ // the component function takes no parameters).
+ describe('mcp console widget (#12344)', () => {
+ it('declares a row for mcp:connect-agent', () => {
+ expect(ComponentPropsMap['mcp:connect-agent']).toBeDefined();
+ });
+
+ it('accepts the empty bag the shipped page authors', () => {
+ expect(() => ComponentPropsMap['mcp:connect-agent'].parse({})).not.toThrow();
+ });
+
+ it('refuses any authored key, naming the surface — the pre-row silent no-op', () => {
+ // Before the row, the key below rode through every validator in
+ // silence (the widget reads nothing authored). The refusal must name
+ // WHICH zero-prop component refused, or the author is left guessing.
+ const widget = ComponentPropsMap['mcp:connect-agent'].safeParse({ serverUrl: 'https://x' });
+ expect(widget.success).toBe(false);
+ if (!widget.success) {
+ const message = widget.error.issues.map((i) => i.message).join('\n');
+ expect(message).toContain('mcp:connect-agent');
+ expect(message).toContain('serverUrl');
+ }
+ });
+ });
});
// ---------------------------------------------------------------------------
diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts
index 0127807907..8af034614f 100644
--- a/packages/spec/src/ui/component.zod.ts
+++ b/packages/spec/src/ui/component.zod.ts
@@ -316,13 +316,14 @@ const COMPONENT_LEVEL_GUIDANCE: readonly KeySetGuidance[] = [
/**
* A component that declares no props at all — `app:launcher`, `nav:menu`,
* `nav:breadcrumb`, `global:search`, `global:notifications`, `user:profile`,
- * `element:divider`, and the two plugin console widgets `cloud-connection:panel`
- * and `marketplace:installed-list` (#11575).
+ * `element:divider`, and the three plugin console widgets
+ * `cloud-connection:panel` and `marketplace:installed-list` (#11575) and
+ * `mcp:connect-agent` (#12344).
*
* A factory rather than one shared `EmptyProps` const, because the surface name
* is the whole value of the rejection here: an empty shape has no candidate
* keys, so the edit-distance fallback can say nothing, and "unrecognized key on
- * this component" would leave the author guessing which of the nine it meant.
+ * this component" would leave the author guessing which of the ten it meant.
* One `strictObject(` call site either way — the ledger counts sites from the
* AST, and this is one.
*
@@ -2743,6 +2744,21 @@ export const ComponentPropsMap = {
// the claim (#8691/#8744 record where those diverge).
'cloud-connection:panel': emptyProps('cloud-connection:panel'),
'marketplace:installed-list': emptyProps('marketplace:installed-list'),
+ // #12344 — the same mechanism a third instance over, on `@objectstack/mcp`'s
+ // plugin-shipped Setup page (`CONNECT_AGENT_PAGE`, `connect-ui.ts`): a
+ // console-registered widget reachable only through the type union's open
+ // string arm, no row here, so the #5068 gate's dispatch skipped it and door 3
+ // of the mcp canonical-envelope gate (#12269) had to carry a standing
+ // exemption for it. Key set measured from the renderer's ACTUAL read points
+ // at the `.objectui-sha` pin (app-shell `console/connect/
+ // ConnectAgentWidget.tsx`): the registration discards the schema node
+ // entirely (`() => `) and the component function takes no
+ // parameters — every value it renders comes from `/discovery`, i18n and its
+ // own state, never from the authored bag — so the accepted key set is EMPTY:
+ // strict, refuses every key. The registration's declared `inputs: []`
+ // happens to agree, but the row is the measurement, not the claim
+ // (#8691/#8744 record where those diverge). The shipped page authors `{}`.
+ 'mcp:connect-agent': emptyProps('mcp:connect-agent'),
// AI
'ai:chat_window': AIChatWindowProps,