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
59 changes: 59 additions & 0 deletions .changeset/ui-mcp-connect-agent-unknown-keys-refused.md
Original file line numberDiff line numberDiff line change
@@ -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 (`() => <ConnectAgent />`) 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).

<!-- adr-0087: registered ui-mcp-connect-agent-unknown-keys-refused -->
39 changes: 39 additions & 0 deletions packages/lint/src/validate-component-props.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 (`() => <ConnectAgent />`) 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([]);
});
});
73 changes: 21 additions & 52 deletions packages/mcp/src/canonical-expression-envelopes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand All@@ -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
Expand All@@ -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: [],
},
];

Expand DownExpand Up@@ -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'),
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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 '
+ '(`() => <ConnectAgent />`) 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.',
};
31 changes: 31 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 '
+ '(`() => <ConnectAgent />`) 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` / '
Expand Down
30 changes: 30 additions & 0 deletions packages/spec/src/ui/component.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 — `() => <ConnectAgent />` — 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');
}
});
});
});

// ---------------------------------------------------------------------------
Expand Down
Loading
Loading