Files
HealthLog/src/lib/mcp/__tests__/mcp-audience.test.ts
T
Marc-André Bombeck 6de61de010 feat(mcp): add a confirmed, audience-bound write surface for logging measurements and mood
Register two write tools — log_measurement and log_mood — only for a
health:write-scoped session; a read-only session never sees them advertised.
Each write mirrors the proven Telegram capture path (same range guard,
canonical unit, cache invalidation and rollup recompute) pinned to the MCP
source, and is idempotent on a hashed externalId derived from a client key, so
a retried call is a no-op rather than a duplicate.

Because the remote wire is stateless (no elicitation), the tools carry a
protocol-level confirm gate: without confirm:true they write nothing and
return the exact record they would write plus an instruction to confirm the
value with the user and re-call with the same idempotency key. Commits run
under a tighter per-credential write budget on top of the per-binding request
limiter.

Harden the resource-server audience binding: a token whose scopes are all in
{health:read, health:write} is bound to the MCP surface and refused on every
REST write/delete. A health:write token therefore admits the in-process MCP
write tools only and can never become a general REST write credential; admin
stays unreachable (cookie-only). Refuse, by construction, any medication, lab,
delete/update, or data-export tool on this surface.
2026-06-27 17:38:42 +02:00

46 lines
1.8 KiB
TypeScript

/**
* Guard: the resource-server audience binding (H1). An MCP-audience token
* (`health:read`, or `health:read health:write`) is bound to the `/mcp`
* surface and refused on every REST write — a `health:write` MCP token can
* never become a general REST write credential. A wildcard or any broader /
* legacy grant is NOT audience-bound and keeps its existing reach.
*/
import { describe, it, expect, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({ get: () => undefined })),
}));
import { isMcpAudienceToken } from "@/lib/api-handler";
describe("isMcpAudienceToken", () => {
it("read-only token IS audience-bound", () => {
expect(isMcpAudienceToken(["health:read"])).toBe(true);
});
it("read+write token IS audience-bound (writes confined to /mcp)", () => {
expect(isMcpAudienceToken(["health:read", "health:write"])).toBe(true);
});
it("write-only token IS audience-bound", () => {
expect(isMcpAudienceToken(["health:write"])).toBe(true);
});
it("wildcard token is NOT audience-bound (keeps its reach)", () => {
expect(isMcpAudienceToken(["*"])).toBe(false);
});
it("a mixed legacy grant is NOT audience-bound", () => {
expect(isMcpAudienceToken(["health:read", "medication:ingest"])).toBe(
false,
);
});
it("an empty scope set is NOT audience-bound", () => {
expect(isMcpAudienceToken([])).toBe(false);
});
});