🪦 feat: Allow Scope Overrides To Tombstone Inherited MCP Server Entries

Add tombstone semantics to the config merge layer for record-type
sections that can grow scope-specific deletions. A scope override
with a null value at \`mcpServers.<key>\` now removes that key from
the effective config for the scope instead of either storing null or
no-oping. The admin panel side will follow up by writing this null
value where it previously sent a DELETE that could only remove an
existing override.

TOMBSTONE_SUPPORTED_PARENTS gates the behavior to mcpConfig (the
remapped name for the mcpServers section). Nested null values
beneath an entry key keep their literal meaning, a null at the
section root keeps acting as a normal replacement, and a higher
priority layer can re-assert an entry that a lower priority layer
tombstoned. Tombstoning an entry the base does not define is a
silent no-op.

Six new resolution.spec tests cover those paths.
This commit is contained in:
Dustin Healy 2026-06-01 08:40:47 -07:00
parent fb282a2afa
commit e8287f1d56
2 changed files with 87 additions and 0 deletions

View file

@ -428,6 +428,81 @@ describe('mergeConfigOverrides', () => {
});
});
describe('mergeConfigOverrides — mcpServers tombstones', () => {
const baseWithMcp = {
interfaceConfig: { modelSelect: true },
mcpConfig: {
inherited: { type: 'sse', url: 'https://base.example.com' },
other: { type: 'sse', url: 'https://other.example.com', title: 'Other' },
},
} as unknown as AppConfig;
it('drops an inherited mcpServers entry when the override sets it to null', () => {
const configs = [fakeConfig({ mcpServers: { inherited: null } }, 10)];
const result = mergeConfigOverrides(baseWithMcp, configs) as unknown as Record<string, unknown>;
const mcpConfig = result.mcpConfig as Record<string, unknown>;
expect(mcpConfig.inherited).toBeUndefined();
expect(mcpConfig.other).toEqual({
type: 'sse',
url: 'https://other.example.com',
title: 'Other',
});
});
it('preserves other inherited entries and applies partial overrides alongside a tombstone', () => {
const configs = [
fakeConfig({ mcpServers: { inherited: null, other: { title: 'Edited' } } }, 10),
];
const result = mergeConfigOverrides(baseWithMcp, configs) as unknown as Record<string, unknown>;
const mcpConfig = result.mcpConfig as Record<string, unknown>;
expect(mcpConfig.inherited).toBeUndefined();
expect(mcpConfig.other).toEqual({
type: 'sse',
url: 'https://other.example.com',
title: 'Edited',
});
});
it('lets a higher-priority override re-assert an entry tombstoned at lower priority', () => {
const configs = [
fakeConfig({ mcpServers: { inherited: null } }, 5),
fakeConfig(
{ mcpServers: { inherited: { type: 'sse', url: 'https://reassert.example.com' } } },
10,
),
];
const result = mergeConfigOverrides(baseWithMcp, configs) as unknown as Record<string, unknown>;
const mcpConfig = result.mcpConfig as Record<string, unknown>;
expect(mcpConfig.inherited).toEqual({ type: 'sse', url: 'https://reassert.example.com' });
});
it('treats null at the section root as a normal merge replacement, not a tombstone', () => {
const configs = [fakeConfig({ mcpServers: null }, 10)];
const result = mergeConfigOverrides(baseWithMcp, configs) as unknown as Record<string, unknown>;
expect(result.mcpConfig).toBeNull();
});
it('does not tombstone null values nested beneath an entry key', () => {
const configs = [fakeConfig({ mcpServers: { other: { title: null } } }, 10)];
const result = mergeConfigOverrides(baseWithMcp, configs) as unknown as Record<string, unknown>;
const mcpConfig = result.mcpConfig as Record<string, unknown>;
expect(mcpConfig.other).toEqual({
type: 'sse',
url: 'https://other.example.com',
title: null,
});
});
it('is a no-op tombstone for an entry that does not exist in base', () => {
const configs = [fakeConfig({ mcpServers: { neverExisted: null } }, 10)];
const result = mergeConfigOverrides(baseWithMcp, configs) as unknown as Record<string, unknown>;
const mcpConfig = result.mcpConfig as Record<string, unknown>;
expect('neverExisted' in mcpConfig).toBe(false);
expect(mcpConfig.inherited).toBeDefined();
expect(mcpConfig.other).toBeDefined();
});
});
describe('INTERFACE_PERMISSION_FIELDS', () => {
it('contains all expected permission fields', () => {
const expected = [

View file

@ -20,6 +20,16 @@ const ARRAY_MERGE_KEYS: Record<string, string> = {
'endpoints.custom': 'name',
};
/**
* Record-type sections whose override layer interprets a `null` value at a child
* key as a tombstone: the entry inherited from base (or a lower-priority layer)
* is removed from the effective config for this scope. Without this, a scope
* can only add or amend entries, never subtract inherited ones, because the
* per-field PATCH path has no way to express "for this scope, pretend the
* inherited entry does not exist."
*/
const TOMBSTONE_SUPPORTED_PARENTS = new Set<string>(['mcpConfig']);
/**
* Maps YAML-level override keys (TCustomConfig) to their AppConfig equivalents.
* Overrides are stored with YAML keys but merged into the already-processed AppConfig
@ -122,6 +132,8 @@ function deepMerge<T extends AnyObject>(target: T, source: AnyObject, depth = 0,
depth,
currentPath,
);
} else if (sourceVal === null && TOMBSTONE_SUPPORTED_PARENTS.has(path)) {
delete result[key];
} else {
result[key] = sourceVal;
}