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
2 changes: 1 addition & 1 deletion docs/proposals/web-mcp-shadow-envs.md
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,7 @@ Behaviour:
- Rejects with what `registerTool()` rejected with -- a `NotAllowedError` under a Permissions Policy that disables `tools` is the caller's business to handle, and swallowing it would hide a deployment mistake.
- Registers every tool with one internal `AbortController` whose signal is chained to `options.signal`; `dispose()` aborts it. Registration is all-or-nothing: a rejection midway aborts the controller so that the tools already registered are taken back.
- A second call while the first handle is live registers a second, independent set; with the same prefix `registerTool()` rejects on the duplicate name, and the rejection is passed through. The first set stays intact. The docs say to keep one handle.
- *As built, 2026-09-06:* one registration per model context and prefix, shared -- every call and every `<shae-worker expose-to-model-context>` is a share of it, the first opens it, a later one joins instead of failing on the duplicate name, `dispose()` takes one share back and the tools leave with the last. `namespaces` (`NamespaceRule`) is the new option; the tools see the union of the shares' namespaces and apply the union of their `redactProps`, both read at the start of every call. `limits` and `exposedTo` are the opener's.
- *As built, 2026-09-06:* one registration per model context and prefix, shared -- every call and every `<shae-worker expose-to-model-context>` is a share of it, the first opens it, a later one joins instead of failing on the duplicate name, `dispose()` takes one share back and the tools leave with the last. `namespaces` (`NamespaceRule`) is the new option; the tools see the union of the shares' namespaces and apply the union of their `redactProps`, both read at the start of every call. `limits` and `exposedTo` are the opener's. *Amended 2026-10-01:* the join compares only what a share actually passed -- a share that leaves `limits` and `exposedTo` out, as every `<shae-worker expose-to-model-context>` does, accepts what is in effect, and only a value of its own that differs is reported.
- *As built:* a `signal` already aborted at call time registers nothing and resolves `{available: true, tools: []}`; a signal that aborts midway stops the loop the same way. `dispose()` also removes the listener from the caller's signal. A successful registration logs one `info` line with the count, a rejected one logs one `error` line before the rejection is passed through.

`redactProps` is the one knob this proposal adds for privacy; §12 explains why it is an option and not a default. *As built* it covers property values on both halves of the snapshot and nothing else: an Entity Context value is not redacted, and the `routes` flag of a redacted property stays. The reference and the best-practices page say so. Alongside the function, the subpath exports `DefaultToolPrefix`, `findModelContext()`, `isModelContextLike()` and the types `RedactRule`, `EnvSummary`, `EntityMatchEntry`, `FindEntitiesEntry` and `RegistryEntry` -- the per-tool output entries an agent-side consumer types against.
Expand Down
6 changes: 6 additions & 0 deletions packages/shadow-objects/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

> Build-system, monorepo, lint/format, and dev-workflow changes that don't affect the shipped package are tracked in the top-level [`CHANGELOG.md`](https://github.com/spearwolf/shadow-objects/blob/main/CHANGELOG.md).

## [Unreleased]

### Bugfixes

- **Fixed (model-context):** a `<shae-worker expose-to-model-context>` that joins a registration opened with `limits` or `exposedTo` no longer reports `the tools under "shae-" are already registered with other limits or exposedTo`. The join compared the settings by value, and `exposeShadowEnvsToModelContext()` normalised a missing `limits` to `{}` before it, so a share that asked for nothing read as a conflict with whatever the opener had set — once per exposing element, and with no way for the element to opt out. A setting left out now accepts what the registration runs with; only a value the caller actually passed and that differs from the opener's is reported, and the report's `ignored` payload names just that. The opener's semantics are unchanged. Documented in `docs/api-reference.md` and `docs/best-practices.md`.

## [0.35.2] - 2026-10-01

> **Patch, and nothing breaks.** One bugfix and no new API — an application written against
Expand Down
2 changes: 1 addition & 1 deletion packages/shadow-objects/docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1986,7 +1986,7 @@ A subpath rather than an `index.ts` export, so that the module stays out of the

The promise resolves with `{available: false, tools: [], dispose}` where the platform has no model context -- a worker, Node, a browser without WebMCP, a plain `http://` origin outside `localhost` -- and logs one `info` line through a `ConsoleLogger` named `ModelContext`. It rejects with what `registerTool()` rejected with, a `NotAllowedError` under a Permissions Policy that disables `tools` among them. Registration is all-or-nothing -- a rejection midway takes back what was registered before it, and the next call opens afresh.

**One registration per model context and prefix, shared.** Every call of this function and every [`<shae-worker expose-to-model-context>`](#shae-worker) is a *share* of it: the first share opens the registration, a later one joins without registering anything, and the tools leave with the last share. The tools answer from the union over the shares -- the namespaces every share exposes, the properties every share hides -- read at the start of every call, so a share joining or leaving is seen by the next call. `limits` and `exposedTo` are the opener's: a later share with other values is reported through the `ModelContext` logger and joins under the opener's. Another prefix or another model context is another registration.
**One registration per model context and prefix, shared.** Every call of this function and every [`<shae-worker expose-to-model-context>`](#shae-worker) is a *share* of it: the first share opens the registration, a later one joins without registering anything, and the tools leave with the last share. The tools answer from the union over the shares -- the namespaces every share exposes, the properties every share hides -- read at the start of every call, so a share joining or leaving is seen by the next call. `limits` and `exposedTo` are the opener's: a later share that asks for other values is reported through the `ModelContext` logger and joins under the opener's, while a share that leaves them out -- `<shae-worker expose-to-model-context>` never sets them -- accepts what is in effect, without a report. Another prefix or another model context is another registration.

`ExposeHandle` carries `available`, `tools` (the names of the shared registration, with the prefix) and `dispose()`, which takes this share back and is idempotent.

Expand Down
2 changes: 1 addition & 1 deletion packages/shadow-objects/docs/best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,6 @@ if (import.meta.env.DEV) {

**Name the properties that must not leave the page.** `redactProps` replaces their values by `{$type: 'redacted'}` in every answer, on the Kernel's and the View's side. It takes a list of names or a predicate over name and Entity uuid. There is no default list on purpose: a guess would suggest a coverage it cannot have, and a property that carries a secret in one application is harmless in the next. Entity Context values are not covered; a secret that travels as a context is a secret the agent sees.

**Shares add up.** A second call under the same prefix joins the registration of the first: one set of tools, the union of both calls' `namespaces` and `redactProps`, and the tools stay until the last share -- a call's `dispose()`, or the last `<shae-worker expose-to-model-context>` leaving -- is gone. `limits` and `exposedTo` are the opener's; a later call with other values is reported and joins under them. A page that opens the tools on a route should still close its share on leaving it.
**Shares add up.** A second call under the same prefix joins the registration of the first: one set of tools, the union of both calls' `namespaces` and `redactProps`, and the tools stay until the last share -- a call's `dispose()`, or the last `<shae-worker expose-to-model-context>` leaving -- is gone. `limits` and `exposedTo` are the opener's; a later call that passes other values is reported and joins under them, and a call -- or an element -- that passes neither accepts what is in effect, so setting the limits once and letting the elements join stays quiet. A page that opens the tools on a route should still close its share on leaving it.

**Set `limits` for the agent, not for yourself.** An agent's context window is the budget. The defaults -- depth 4, 250 nodes, values cut at depth 3 -- are conservative; `shae-find-entities` exists so that a search does not ship the tree, and `shae-list-envs` carries counts only. Loosen them for a small scene, tighten them for a large one; the agent's own input wins over them field by field.
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,30 @@ describe('<shae-worker expose-to-model-context>', () => {
const el = mount(`ns="${nextNs()}" ${ATTR_EXPOSE_TO_MODEL_CONTEXT}`);
expect(await el.modelContextExposure).toMatchObject({available: false, tools: []});
});

it('joins a registration that was opened with limits and exposedTo, without a warning', async () => {
const fake = installFakeModelContext();
const opener = await exposeShadowEnvsToModelContext({
namespaces: [],
limits: {include: ['shadowObjects', 'props']},
exposedTo: ['https://agent.example'],
});

const warnings: unknown[][] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => warnings.push(args);
try {
const el = mount(`ns="${nextNs()}" ${ATTR_EXPOSE_TO_MODEL_CONTEXT}`);
await el.modelContextExposure;

expect(warnings, 'the element asks for no settings of its own').toEqual([]);
expect(fake.registrations, 'one registration, joined').toBe(5);

opener.dispose();
el.removeAttribute(ATTR_EXPOSE_TO_MODEL_CONTEXT);
expect(fake.tools.size).toBe(0);
} finally {
console.warn = originalWarn;
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -404,4 +404,78 @@ describe('exposeShadowEnvsToModelContext', () => {
scene.dispose();
}
});

it('a later share that leaves limits and exposedTo out accepts what is in effect, without a warning', async () => {
const mc = fakeModelContext();
const scene = await makeEnv('mc-accept');
new ViewComponent('thing', {parent: scene.root, context: ComponentContext.get('mc-accept')});
await scene.env.syncWait();

const warnings: unknown[][] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => warnings.push(args);
try {
const opener = await exposeShadowEnvsToModelContext({
modelContext: mc,
namespaces: [],
limits: {include: ['shadowObjects', 'props']},
exposedTo: ['https://a.example'],
});
// the shape of an element join: namespaces and redactProps, no settings of its own
const joiner = await exposeShadowEnvsToModelContext({modelContext: mc});

expect(warnings, 'nothing to report: the joiner asked for nothing').toEqual([]);
expect(mc.calls[0]?.exposedTo).toEqual(['https://a.example']);
const root = (await execute(mc, 'shae-get-entity-tree', {namespace: 'mc-accept'})).envs[0].kernel.roots[0];
expect(root.props, "the opener's limits apply: props is included").toBeDefined();
expect(root.contexts, "the opener's limits apply: contexts is not included").toBeUndefined();

joiner.dispose();
opener.dispose();
const after = await exposeShadowEnvsToModelContext({modelContext: mc});
const fresh = (await execute(mc, 'shae-get-entity-tree', {namespace: 'mc-accept'})).envs[0].kernel.roots[0];
expect(fresh.contexts, 'the limit left with the opener, so the joiner had not asked for it').toBeDefined();
after.dispose();
} finally {
console.warn = originalWarn;
scene.dispose();
}
});

it('a later share reports only the settings it actually passed, and only when they differ', async () => {
const mc = fakeModelContext();

const warnings: unknown[][] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => warnings.push(args);
try {
const opener = await exposeShadowEnvsToModelContext({
modelContext: mc,
limits: {maxDepth: 0},
exposedTo: ['https://a.example'],
});

const sameExposedTo = await exposeShadowEnvsToModelContext({modelContext: mc, exposedTo: ['https://a.example']});
expect(warnings, 'the value asked for is the value in effect').toEqual([]);

const otherExposedTo = await exposeShadowEnvsToModelContext({modelContext: mc, exposedTo: ['https://b.example']});
expect(warnings, 'exposedTo was asked for and differs').toHaveLength(1);
expect(warnings[0]?.at(-1)).toMatchObject({ignored: {exposedTo: ['https://b.example']}});

const sameLimits = await exposeShadowEnvsToModelContext({modelContext: mc, limits: {maxDepth: 0}});
expect(warnings, 'still only the one').toHaveLength(1);

const emptyLimits = await exposeShadowEnvsToModelContext({modelContext: mc, limits: {}});
expect(warnings, 'an explicit {} asks for no limits and differs from the opener’s').toHaveLength(2);
expect(warnings[1]?.at(-1)).toMatchObject({ignored: {limits: {}}});

emptyLimits.dispose();
sameLimits.dispose();
otherExposedTo.dispose();
sameExposedTo.dispose();
opener.dispose();
} finally {
console.warn = originalWarn;
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ export async function exposeShadowEnvsToModelContext(options: ExposeOptions = {}
const {signal} = options;
if (signal?.aborted) return {available: true, tools: [], dispose() {}};

const settings = {limits: options.limits ?? {}, ...(options.exposedTo !== undefined ? {exposedTo: options.exposedTo} : {})};
// what this call asks for, and nothing more: a setting left out accepts whatever the registration already runs with
const settings = {
...(options.limits !== undefined ? {limits: options.limits} : {}),
...(options.exposedTo !== undefined ? {exposedTo: options.exposedTo} : {}),
};
const membership = joinSharedExposure(
modelContext,
options.toolPrefix ?? DefaultToolPrefix,
Expand Down
24 changes: 17 additions & 7 deletions packages/shadow-objects/src/model-context/sharedExposure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export interface ExposureSettings {
exposedTo?: string[];
}

/** What one member asked for. A field left out accepts whatever the registration already has. */
export type RequestedExposureSettings = ExposureSettings | {limits?: Partial<InspectRequest>; exposedTo?: string[]};

export interface ExposureMembership {
/**
* The names the registration ended with -- empty when it was closed while it was still
Expand Down Expand Up @@ -103,7 +106,11 @@ const registerAll = async (modelContext: ModelContextLike, ctx: ToolContext, exp
return registered;
};

const open = (modelContext: ModelContextLike, prefix: string, settings: ExposureSettings): Exposure => {
const open = (modelContext: ModelContextLike, prefix: string, requested: RequestedExposureSettings): Exposure => {
const settings: ExposureSettings = {
limits: requested.limits ?? {},
...(requested.exposedTo !== undefined ? {exposedTo: requested.exposedTo} : {}),
};
const members = new Set<ExposureMember>();
const exposure: Exposure = {
members,
Expand Down Expand Up @@ -138,20 +145,23 @@ const close = (modelContext: ModelContextLike, prefix: string, exposure: Exposur
if (byPrefix?.get(prefix) === exposure) byPrefix.delete(prefix);
};

const sameSettings = (a: ExposureSettings, b: ExposureSettings): boolean =>
JSON.stringify(a.limits) === JSON.stringify(b.limits) && JSON.stringify(a.exposedTo) === JSON.stringify(b.exposedTo);
/** What the joiner asked for and the registration does not run with. A setting left out accepts what is in effect. */
const conflicts = (inEffect: ExposureSettings, requested: RequestedExposureSettings): boolean =>
(requested.limits !== undefined && JSON.stringify(requested.limits) !== JSON.stringify(inEffect.limits)) ||
(requested.exposedTo !== undefined && JSON.stringify(requested.exposedTo) !== JSON.stringify(inEffect.exposedTo));

/**
* Joins the registration under `prefix` on `modelContext`, opening it when there is none. The
* member's rules count from the next tool call on; `settings` count only for the member that
* opens -- a later member with other values is reported and joins under the opener's, because
* `exposedTo` has gone to the platform by then and one set of `limits` is all a tool has.
* opens -- a later member that asks for other values is reported and joins under the opener's,
* because `exposedTo` has gone to the platform by then and one set of `limits` is all a tool
* has. A setting the later member leaves out accepts what the registration already runs with.
*/
export function joinSharedExposure(
modelContext: ModelContextLike,
prefix: string,
member: ExposureMember,
settings: ExposureSettings,
settings: RequestedExposureSettings,
): ExposureMembership {
let byPrefix = registry.get(modelContext);
if (byPrefix === undefined) {
Expand All @@ -163,7 +173,7 @@ export function joinSharedExposure(
if (exposure === undefined) {
exposure = open(modelContext, prefix, settings);
byPrefix.set(prefix, exposure);
} else if (!sameSettings(exposure.settings, settings)) {
} else if (conflicts(exposure.settings, settings)) {
logger.warn(
`the tools under "${prefix}" are already registered with other limits or exposedTo; the values of the first call apply`,
{inEffect: exposure.settings, ignored: settings},
Expand Down
Loading