mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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.
This commit is contained in:
1 parent
90c8768407
commit
6de61de010
15 files changed
+1263
-34
No files matched your search
+21
-13
@@ -10,7 +10,7 @@ import { auditLog } from "./auth/audit";
|
||||
import { resolveBearerToken, BearerAuthError } from "./auth/bearer";
|
||||
import { AssistantDisabledError } from "./feature-flags";
|
||||
import { ConsentRequiredError } from "./ai/consent-guard";
|
||||
import { SCOPE_HEALTH_READ } from "./mcp/oauth/config";
|
||||
import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config";
|
||||
|
||||
/**
|
||||
* HTTP methods a read-only credential may use on the REST surface. A request
|
||||
@@ -20,15 +20,20 @@ const READ_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
/**
|
||||
* Whether a token is MCP-audience-bound (H1). The MCP OAuth bridge and the
|
||||
* connector settings card mint tokens whose ONLY grant is `health:read`. Such a
|
||||
* token is bound to the read surface: the `/mcp` resolver accepts it, and so do
|
||||
* safe (read) REST methods, but it must never reach a REST write/delete. A token
|
||||
* carrying any broader or legacy grant (`*`, `medication:ingest`, an explicit
|
||||
* `health:write`, …) is NOT MCP-audience-bound and keeps its existing reach.
|
||||
* connector settings card mint tokens whose ONLY grants are `health:read` —
|
||||
* and, when the user consents to logging, `health:read health:write`. Either
|
||||
* shape is bound to the MCP surface: the `/mcp` resolver accepts it and so do
|
||||
* safe (read) REST methods, but it must NEVER reach a REST write/delete. The
|
||||
* `health:write` grant admits writes ONLY in-process over `/mcp` (the confirmed
|
||||
* write tools), never over REST — so a write-scoped MCP token is exactly as
|
||||
* audience-bound on this edge as a read-only one. A token carrying any broader
|
||||
* or legacy grant (`*`, `medication:ingest`, …) is NOT MCP-audience-bound and
|
||||
* keeps its existing reach.
|
||||
*/
|
||||
function isMcpAudienceToken(permissions: readonly string[]): boolean {
|
||||
return (
|
||||
permissions.length > 0 && permissions.every((p) => p === SCOPE_HEALTH_READ)
|
||||
export function isMcpAudienceToken(permissions: readonly string[]): boolean {
|
||||
if (permissions.length === 0) return false;
|
||||
return permissions.every(
|
||||
(p) => p === SCOPE_HEALTH_READ || p === SCOPE_HEALTH_WRITE,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -354,10 +359,13 @@ async function authenticateBearer(
|
||||
|
||||
const { user, tokenId, expiresAt, permissions } = resolution;
|
||||
|
||||
// H1 — audience binding at the resource server. A health:read-only token is
|
||||
// the MCP read credential; it may reach `/mcp` (a separate resolver that
|
||||
// never runs this edge) and safe REST reads, but a write/delete over REST is
|
||||
// outside its audience and is refused. Fail closed when the method is unknown
|
||||
// H1 — audience binding at the resource server. An MCP-audience token
|
||||
// (`health:read`, or `health:read health:write`) is bound to the `/mcp`
|
||||
// surface; it may reach `/mcp` (a separate resolver that never runs this
|
||||
// edge) and safe REST reads, but a write/delete over REST is outside its
|
||||
// audience and is refused — INCLUDING a write-scoped token, whose writes are
|
||||
// confined to the in-process `/mcp` tools and never granted over REST. Fail
|
||||
// closed when the method is unknown
|
||||
// (no event context) since every real REST request runs inside apiHandler,
|
||||
// which always sets the method — an unknown method means we cannot prove a
|
||||
// read, so we deny. This is RFC 8707 audience binding on the credential the
|
||||
|
||||
Reference in new issue
Block a user