mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
65a6a46d70
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.
88 lines
3.3 KiB
TypeScript
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();
|
|
});
|
|
});
|