Files
mcpctl/src/mcplocal/tests/session-gate.test.ts

228 lines
7.8 KiB
TypeScript
Raw Normal View History

import { describe, it, expect } from 'vitest';
import { SessionGate } from '../src/gate/session-gate.js';
import type { TagMatchResult, PromptIndexEntry } from '../src/gate/tag-matcher.js';
function makeMatchResult(names: string[]): TagMatchResult {
return {
fullContent: names.map((name) => ({
name,
priority: 5,
summary: null,
chapters: null,
content: `Content of ${name}`,
})),
indexOnly: [],
remaining: [],
};
}
describe('SessionGate', () => {
it('creates a gated session when project is gated', () => {
const gate = new SessionGate();
gate.createSession('s1', true);
expect(gate.isGated('s1')).toBe(true);
});
it('creates an ungated session when project is not gated', () => {
const gate = new SessionGate();
gate.createSession('s1', false);
expect(gate.isGated('s1')).toBe(false);
});
it('unknown sessions are treated as ungated', () => {
const gate = new SessionGate();
expect(gate.isGated('nonexistent')).toBe(false);
});
it('getSession returns null for unknown sessions', () => {
const gate = new SessionGate();
expect(gate.getSession('nonexistent')).toBeNull();
});
it('getSession returns session state', () => {
const gate = new SessionGate();
gate.createSession('s1', true);
const state = gate.getSession('s1');
expect(state).not.toBeNull();
expect(state!.gated).toBe(true);
expect(state!.tags).toEqual([]);
expect(state!.retrievedPrompts.size).toBe(0);
expect(state!.briefing).toBeNull();
});
it('ungate marks session as ungated and records tags', () => {
const gate = new SessionGate();
gate.createSession('s1', true);
gate.ungate('s1', ['zigbee', 'mqtt'], makeMatchResult(['prompt-a', 'prompt-b']));
expect(gate.isGated('s1')).toBe(false);
const state = gate.getSession('s1');
expect(state!.tags).toEqual(['zigbee', 'mqtt']);
expect(state!.retrievedPrompts.has('prompt-a')).toBe(true);
expect(state!.retrievedPrompts.has('prompt-b')).toBe(true);
});
it('ungate appends tags on repeated calls', () => {
const gate = new SessionGate();
gate.createSession('s1', true);
gate.ungate('s1', ['zigbee'], makeMatchResult(['p1']));
gate.ungate('s1', ['mqtt'], makeMatchResult(['p2']));
const state = gate.getSession('s1');
expect(state!.tags).toEqual(['zigbee', 'mqtt']);
expect(state!.retrievedPrompts.has('p1')).toBe(true);
expect(state!.retrievedPrompts.has('p2')).toBe(true);
});
it('ungate is no-op for unknown sessions', () => {
const gate = new SessionGate();
// Should not throw
gate.ungate('nonexistent', ['tag'], makeMatchResult(['p']));
});
it('addRetrievedPrompts records additional prompts', () => {
const gate = new SessionGate();
gate.createSession('s1', true);
gate.ungate('s1', ['zigbee'], makeMatchResult(['p1']));
gate.addRetrievedPrompts('s1', ['mqtt', 'lights'], ['p2', 'p3']);
const state = gate.getSession('s1');
expect(state!.tags).toEqual(['zigbee', 'mqtt', 'lights']);
expect(state!.retrievedPrompts.has('p2')).toBe(true);
expect(state!.retrievedPrompts.has('p3')).toBe(true);
});
it('addRetrievedPrompts is no-op for unknown sessions', () => {
const gate = new SessionGate();
gate.addRetrievedPrompts('nonexistent', ['tag'], ['p']);
});
it('filterAlreadySent removes already-sent prompts', () => {
const gate = new SessionGate();
gate.createSession('s1', true);
gate.ungate('s1', ['zigbee'], makeMatchResult(['p1']));
const prompts: PromptIndexEntry[] = [
{ name: 'p1', priority: 5, summary: 'already sent', chapters: null, content: 'x' },
{ name: 'p2', priority: 5, summary: 'new', chapters: null, content: 'y' },
];
const filtered = gate.filterAlreadySent('s1', prompts);
expect(filtered).toHaveLength(1);
expect(filtered[0]!.name).toBe('p2');
});
it('filterAlreadySent returns all prompts for unknown sessions', () => {
const gate = new SessionGate();
const prompts: PromptIndexEntry[] = [
{ name: 'p1', priority: 5, summary: null, chapters: null, content: 'x' },
];
const filtered = gate.filterAlreadySent('nonexistent', prompts);
expect(filtered).toHaveLength(1);
});
it('removeSession cleans up state', () => {
const gate = new SessionGate();
gate.createSession('s1', true);
expect(gate.getSession('s1')).not.toBeNull();
gate.removeSession('s1');
expect(gate.getSession('s1')).toBeNull();
expect(gate.isGated('s1')).toBe(false);
});
it('removeSession is safe for unknown sessions', () => {
const gate = new SessionGate();
gate.removeSession('nonexistent'); // Should not throw
});
it('manages multiple sessions independently', () => {
const gate = new SessionGate();
gate.createSession('s1', true);
gate.createSession('s2', false);
expect(gate.isGated('s1')).toBe(true);
expect(gate.isGated('s2')).toBe(false);
gate.ungate('s1', ['zigbee'], makeMatchResult(['p1']));
expect(gate.isGated('s1')).toBe(false);
expect(gate.getSession('s2')!.tags).toEqual([]); // s2 untouched
});
feat(mcplocal): per-McpToken gate-ungate cache so service tokens survive proxies Fixes the LiteLLM loop: LiteLLM's /mcp/ proxy doesn't propagate the mcp-session-id header, so every tool call from qwen3 landed on a fresh upstream session, which always started gated, so the only visible tool was begin_session — forever. The session-id gate works fine for Claude Code (stdio, long-lived), but breaks through session-stripping proxies. Identity that DOES survive: the McpToken (always in the Authorization header). So now the gate keys its ungate state on both: - sessionId → per-session (unchanged; Claude Code path) - tokenSha → per-token (NEW; service-token path) Flow for an McpToken caller: 1. first begin_session succeeds → session ungated + tokenSha cached 2. next request lands on a new mcp-session-id (proxy stripped it) 3. SessionGate.createSession sees tokenSha, finds active token entry, starts the new session ungated with the prior tags + retrievedPrompts 4. tools/list on the fresh session returns the full upstream set — no more begin_session loop Plumbing: - AuditCollector.getSessionMcpTokenSha(sessionId) exposes the already- tracked principal. - PluginSessionContext gets getMcpTokenSha() so plugins can read the token identity without knowing about the collector. - SessionGate gains (tokenSha?: string) on createSession/ungate, plus isTokenUngated and revokeToken. TTL defaults to 1hr; tunable via MCPLOCAL_TOKEN_UNGATE_TTL_MS env var. - Gate plugin passes ctx.getMcpTokenSha() at every ungate call site (begin_session, gated-intercept, intercept-fallback). Tests: 7 new cases in session-gate.test.ts covering cross-session persistence, token isolation, STDIO-path unchanged, TTL expiry, revokeToken, and the empty-string edge case. 21/21 pass; 690/690 in mcplocal overall. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:34:28 +01:00
describe('per-McpToken ungate cache', () => {
it('new session from an already-ungated token starts ungated, with prior tags + prompts', () => {
const gate = new SessionGate();
gate.createSession('session-1', true, 'tokA');
expect(gate.isGated('session-1')).toBe(true);
gate.ungate('session-1', ['ops'], makeMatchResult(['runbook']), 'tokA');
expect(gate.isTokenUngated('tokA')).toBe(true);
// LiteLLM semantics: same token, brand-new session-id.
gate.createSession('session-2', true, 'tokA');
expect(gate.isGated('session-2')).toBe(false);
const s2 = gate.getSession('session-2')!;
expect(s2.tags).toContain('ops');
expect(s2.retrievedPrompts.has('runbook')).toBe(true);
});
it('does not persist across tokens', () => {
const gate = new SessionGate();
gate.createSession('s1', true, 'tokA');
gate.ungate('s1', ['ops'], makeMatchResult(['p']), 'tokA');
// Different token → fresh gated session.
gate.createSession('s2', true, 'tokB');
expect(gate.isGated('s2')).toBe(true);
expect(gate.isTokenUngated('tokB')).toBe(false);
});
it('is not triggered when no tokenSha is supplied (STDIO path)', () => {
const gate = new SessionGate();
gate.createSession('s1', true);
gate.ungate('s1', ['ops'], makeMatchResult(['p']));
// A second session with no token starts gated — STDIO semantics preserved.
gate.createSession('s2', true);
expect(gate.isGated('s2')).toBe(true);
});
it('honors the TTL window and expires', () => {
const gate = new SessionGate(50); // 50ms TTL for the test
gate.createSession('s1', true, 'tokA');
gate.ungate('s1', ['ops'], makeMatchResult(['p']), 'tokA');
expect(gate.isTokenUngated('tokA')).toBe(true);
return new Promise<void>((resolve) => setTimeout(() => {
expect(gate.isTokenUngated('tokA')).toBe(false);
gate.createSession('s2', true, 'tokA');
expect(gate.isGated('s2')).toBe(true);
resolve();
}, 70));
});
it('revokeToken clears the ungate entry immediately', () => {
const gate = new SessionGate();
gate.createSession('s1', true, 'tokA');
gate.ungate('s1', ['ops'], makeMatchResult(['p']), 'tokA');
expect(gate.isTokenUngated('tokA')).toBe(true);
gate.revokeToken('tokA');
expect(gate.isTokenUngated('tokA')).toBe(false);
gate.createSession('s2', true, 'tokA');
expect(gate.isGated('s2')).toBe(true);
});
it('empty-string tokenSha does not register an ungate entry', () => {
const gate = new SessionGate();
gate.createSession('s1', true, '');
gate.ungate('s1', ['ops'], makeMatchResult(['p']), '');
expect(gate.isTokenUngated('')).toBe(false);
});
});
});