fix(plugins): invalidate MCP sessions on lifecycle and grant changes

The advertised tool surface is frozen at initialize, so anything that
changes which plugin tools exist has to end the sessions built before it.
tools/list_changed is not an alternative: no event store is configured,
and the surface would not be re-read anyway.

Unconditional, no diffing. The surface is per-session, since it depends
on the caller's scopes and the plugin's grants, so there is no ctx-free
set to compare against.

Five routes, mirroring what admin.controller.ts already does for addons:
activate, deactivate, update, retrust and uninstall. activate(id,
consent), update and retrust are also the grant-change paths, so "on an
mcp:tools grant change" needs nothing extra. A failed call invalidates
nothing, because nothing changed.

The addon cascade needed its own call. Disabling an addon can stop a
plugin that publishes tools, and the addon doing the cascading is not
necessarily one of MCP_RELEVANT_ADDONS, so mcpAffected does not cover it.
Still gated on a plugin actually stopping, which keeps the #1414 rule
that a config-only save kills nothing.

Imported from sessionManager rather than the src/mcp barrel, which
evaluates readEnv().mcp at module scope.

MCPINV-001 to MCPINV-007.
This commit is contained in:
Renzo Beux
2026-08-28 13:50:30 -03:00
committed by Maurice
parent 21fd04647f
commit 65a6a46d70
3 changed files with 104 additions and 2 deletions
+11 -1
View File
@@ -14,6 +14,10 @@ import { devLinkEnabled } from './dev-link';
import { PluginActivateDto, PluginConfigDto, PluginEgressHostsDto, PluginInstallDto, PluginLinkDto, PluginRetrustDto, PluginUninstallDto, PluginUpdateDto } from './plugins.dto';
import { ManagedForbidden, isManagedBlocked, MANAGED_FORBIDDEN_ERROR } from '../common/managed';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
// Straight from sessionManager, not the src/mcp barrel: that one evaluates
// readEnv().mcp at module scope and installs the sweep interval, which a domain
// module must not drag into every test that mocks app-config partially.
import { invalidateMcpSessions } from '../../mcp/sessionManager';
/**
* Flatten a registry/install failure into the error envelope — CARRYING THE CODE.
@@ -188,6 +192,7 @@ export class PluginsController {
}
throw new HttpException({ error: e instanceof Error ? e.message : 'activation failed' }, 400);
}
invalidateMcpSessions();
return { status: this.runtime.isActive(id) ? 'active' : 'error' };
}
@@ -197,6 +202,7 @@ export class PluginsController {
// Cascade: disabling a plugin also disables everything that depends on it (a
// dependent can't run without its dependency). The client refresh reflects it.
await this.runtime.deactivateWithDependents(id);
invalidateMcpSessions();
return { status: 'inactive' };
}
@@ -236,6 +242,7 @@ export class PluginsController {
// (any path) releases a stale hold. Only after success — a failed update
// changed nothing and must not touch the flag.
await this.registry.recomputeUpdateHold(id, res.version, !!body?.version);
invalidateMcpSessions();
return res;
} catch (e) {
throw registryFailure(e, 'update failed');
@@ -274,7 +281,9 @@ export class PluginsController {
if (!body?.version) throw new HttpException({ error: 'version is required' }, 400);
if (!body?.publicKey) throw new HttpException({ error: 'publicKey is required' }, 400);
try {
return await this.runtime.retrust(id, body.version, body.publicKey, { userId: user?.id ?? null, ip: getClientIp(req) });
const res = await this.runtime.retrust(id, body.version, body.publicKey, { userId: user?.id ?? null, ip: getClientIp(req) });
invalidateMcpSessions();
return res;
} catch (e) {
throw registryFailure(e, 'retrust failed');
}
@@ -284,6 +293,7 @@ export class PluginsController {
@HttpCode(200)
async uninstall(@Param('id') id: string, @Body() body: PluginUninstallDto) {
await this.runtime.uninstall(id, !!body?.deleteData);
invalidateMcpSessions();
return { status: 'uninstalled' };
}