Files
TREK/server/tests/unit/plugins/mcp-session-invalidation.test.ts
Renzo Beux 65a6a46d70 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.
2026-08-29 12:22:59 +02:00

88 lines
3.3 KiB
TypeScript

/**
* MCP session invalidation on plugin lifecycle changes — MCPINV-001 to MCPINV-007.
*
* The advertised tool surface is frozen at initialize (no event store is
* configured, and tools/list_changed would not help), so anything that changes
* which plugin tools exist has to end the sessions that were built before it.
*
* Unconditional by design: the surface is per-session, because it depends on the
* caller's scopes and the plugin's grants, so there is no ctx-free set to diff.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { PluginsController } from '../../../src/nest/plugins/plugins.controller';
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
const invalidate = vi.fn();
vi.mock('../../../src/mcp/sessionManager', () => ({ invalidateMcpSessions: () => invalidate() }));
/* eslint-disable @typescript-eslint/no-explicit-any */
const registry = { recomputeUpdateHold: vi.fn(async () => {}) } as any;
const ctrl = (runtime: any) =>
new PluginsController({} as any, runtime, registry, { isManaged: () => false } as unknown as RuntimeEnvService);
const runtime = () =>
({
activate: vi.fn(async () => {}),
isActive: vi.fn(() => true),
deactivateWithDependents: vi.fn(async () => {}),
update: vi.fn(async () => ({ version: '1.1.0' })),
retrust: vi.fn(async () => ({ ok: true })),
uninstall: vi.fn(async () => {}),
}) as any;
/* eslint-enable @typescript-eslint/no-explicit-any */
beforeEach(() => {
invalidate.mockClear();
process.env.TREK_PLUGINS_ENABLED = 'true';
});
describe('plugin lifecycle invalidates MCP sessions', () => {
it('MCPINV-001: activate', async () => {
await ctrl(runtime()).activate('weather', { consent: true } as never);
expect(invalidate).toHaveBeenCalledTimes(1);
});
it('MCPINV-002: deactivate', async () => {
await ctrl(runtime()).deactivate('weather');
expect(invalidate).toHaveBeenCalledTimes(1);
});
it('MCPINV-003: update', async () => {
// A version bump can change the declared tools, so it is a surface change
// even though the plugin stays active.
await ctrl(runtime()).update('weather', {} as never);
expect(invalidate).toHaveBeenCalledTimes(1);
});
it('MCPINV-004: uninstall', async () => {
await ctrl(runtime()).uninstall('weather', {} as never);
expect(invalidate).toHaveBeenCalledTimes(1);
});
it('MCPINV-005: retrust', async () => {
// Retrust installs a version, so it is an update by another name.
await ctrl(runtime()).retrust(
'weather',
{ version: '1.1.0', publicKey: 'k' } as never,
{ id: 1 },
{ headers: {}, socket: {} } as never,
);
expect(invalidate).toHaveBeenCalledTimes(1);
});
it('MCPINV-006: a failed activate does not invalidate', async () => {
// Nothing changed, so nobody's session should be dropped.
const r = runtime();
r.activate = vi.fn(async () => { throw new Error('boom'); });
await expect(ctrl(r).activate('weather', {} as never)).rejects.toThrow();
expect(invalidate).not.toHaveBeenCalled();
});
it('MCPINV-007: a failed update does not invalidate', async () => {
const r = runtime();
r.update = vi.fn(async () => { throw new Error('boom'); });
await expect(ctrl(r).update('weather', {} as never)).rejects.toThrow();
expect(invalidate).not.toHaveBeenCalled();
});
});