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,