feat(mcplocal): per-McpToken gate-ungate cache so service tokens survive proxies
All checks were successful
CI/CD / lint (pull_request) Successful in 1m0s
CI/CD / typecheck (pull_request) Successful in 1m51s
CI/CD / test (pull_request) Successful in 1m3s
CI/CD / build (pull_request) Successful in 2m13s
CI/CD / smoke (pull_request) Successful in 4m49s
CI/CD / publish (pull_request) Has been skipped
All checks were successful
CI/CD / lint (pull_request) Successful in 1m0s
CI/CD / typecheck (pull_request) Successful in 1m51s
CI/CD / test (pull_request) Successful in 1m3s
CI/CD / build (pull_request) Successful in 2m13s
CI/CD / smoke (pull_request) Successful in 4m49s
CI/CD / publish (pull_request) Has been skipped
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>
This commit is contained in:
@@ -152,4 +152,76 @@ describe('SessionGate', () => {
|
||||
expect(gate.isGated('s1')).toBe(false);
|
||||
expect(gate.getSession('s2')!.tags).toEqual([]); // s2 untouched
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user