diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 43653028d..2055af21b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2162,6 +2162,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP - in: query name: valueMin schema: @@ -2657,6 +2658,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP externalId: type: string maxLength: 128 @@ -3775,6 +3777,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP activeEnergy: maxItems: 8 type: array @@ -3792,6 +3795,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP walkingRunningDistance: maxItems: 8 type: array @@ -3809,6 +3813,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP flightsClimbed: maxItems: 8 type: array @@ -3826,6 +3831,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP sleep: maxItems: 8 type: array @@ -3843,6 +3849,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP weight: maxItems: 8 type: array @@ -3860,6 +3867,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP bloodPressure: maxItems: 8 type: array @@ -3877,6 +3885,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP pulse: maxItems: 8 type: array @@ -3894,6 +3903,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP bodyFat: maxItems: 8 type: array @@ -3911,6 +3921,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP bodyTemperature: maxItems: 8 type: array @@ -3928,6 +3939,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP spo2: maxItems: 8 type: array @@ -3945,6 +3957,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP hrv: maxItems: 8 type: array @@ -3962,6 +3975,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP restingHeartRate: maxItems: 8 type: array @@ -3979,6 +3993,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP vo2Max: maxItems: 8 type: array @@ -3996,6 +4011,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP skinTemperature: maxItems: 8 type: array @@ -4013,6 +4029,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP respiratoryRate: maxItems: 8 type: array @@ -4030,6 +4047,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP recovery: maxItems: 8 type: array @@ -4047,6 +4065,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP stress: maxItems: 8 type: array @@ -4064,6 +4083,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP metricPriority: type: object properties: @@ -4084,6 +4104,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP activeEnergy: maxItems: 8 type: array @@ -4101,6 +4122,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP walkingRunningDistance: maxItems: 8 type: array @@ -4118,6 +4140,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP flightsClimbed: maxItems: 8 type: array @@ -4135,6 +4158,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP sleep: maxItems: 8 type: array @@ -4152,6 +4176,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP weight: maxItems: 8 type: array @@ -4169,6 +4194,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP bloodPressure: maxItems: 8 type: array @@ -4186,6 +4212,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP pulse: maxItems: 8 type: array @@ -4203,6 +4230,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP bodyFat: maxItems: 8 type: array @@ -4220,6 +4248,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP bodyTemperature: maxItems: 8 type: array @@ -4237,6 +4266,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP spo2: maxItems: 8 type: array @@ -4254,6 +4284,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP hrv: maxItems: 8 type: array @@ -4271,6 +4302,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP restingHeartRate: maxItems: 8 type: array @@ -4288,6 +4320,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP vo2Max: maxItems: 8 type: array @@ -4305,6 +4338,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP skinTemperature: maxItems: 8 type: array @@ -4322,6 +4356,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP respiratoryRate: maxItems: 8 type: array @@ -4339,6 +4374,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP recovery: maxItems: 8 type: array @@ -4356,6 +4392,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP stress: maxItems: 8 type: array @@ -4373,6 +4410,7 @@ paths: - POLAR - OURA - TELEGRAM + - MCP deviceTypePriority: type: object properties: @@ -9029,6 +9067,8 @@ components: type: boolean doctorReport: type: boolean + mcp: + type: boolean additionalProperties: false description: Partial module enable/disable update. Each key is an optional boolean; an omitted key is left untouched, `false` disables the module, `true` (re-)enables it. Only the toggleable "secondary domains" are accepted — a @@ -12237,6 +12277,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP notes: anyOf: - type: string @@ -12960,6 +13001,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP notes: anyOf: - type: string @@ -13203,6 +13245,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP - type: "null" start: type: string @@ -13330,6 +13373,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP - type: "null" start: type: string @@ -13703,6 +13747,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP externalId: anyOf: - type: string @@ -13812,6 +13857,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP externalId: anyOf: - type: string @@ -16005,6 +16051,9 @@ components: doctorReport: type: boolean description: Whether the "doctorReport" module is enabled for this account. + mcp: + type: boolean + description: Whether the "mcp" module is enabled for this account. required: - cycle - mood @@ -16019,6 +16068,7 @@ components: - insights - medications - doctorReport + - mcp additionalProperties: false description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; @@ -16063,6 +16113,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP activeEnergy: maxItems: 8 type: array @@ -16080,6 +16131,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP walkingRunningDistance: maxItems: 8 type: array @@ -16097,6 +16149,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP flightsClimbed: maxItems: 8 type: array @@ -16114,6 +16167,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP sleep: maxItems: 8 type: array @@ -16131,6 +16185,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP weight: maxItems: 8 type: array @@ -16148,6 +16203,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bloodPressure: maxItems: 8 type: array @@ -16165,6 +16221,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP pulse: maxItems: 8 type: array @@ -16182,6 +16239,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bodyFat: maxItems: 8 type: array @@ -16199,6 +16257,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bodyTemperature: maxItems: 8 type: array @@ -16216,6 +16275,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP spo2: maxItems: 8 type: array @@ -16233,6 +16293,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP hrv: maxItems: 8 type: array @@ -16250,6 +16311,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP restingHeartRate: maxItems: 8 type: array @@ -16267,6 +16329,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP vo2Max: maxItems: 8 type: array @@ -16284,6 +16347,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP skinTemperature: maxItems: 8 type: array @@ -16301,6 +16365,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP respiratoryRate: maxItems: 8 type: array @@ -16318,6 +16383,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP recovery: maxItems: 8 type: array @@ -16335,6 +16401,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP stress: maxItems: 8 type: array @@ -16352,6 +16419,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP metricPriority: type: object properties: @@ -16372,6 +16440,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP activeEnergy: maxItems: 8 type: array @@ -16389,6 +16458,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP walkingRunningDistance: maxItems: 8 type: array @@ -16406,6 +16476,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP flightsClimbed: maxItems: 8 type: array @@ -16423,6 +16494,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP sleep: maxItems: 8 type: array @@ -16440,6 +16512,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP weight: maxItems: 8 type: array @@ -16457,6 +16530,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bloodPressure: maxItems: 8 type: array @@ -16474,6 +16548,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP pulse: maxItems: 8 type: array @@ -16491,6 +16566,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bodyFat: maxItems: 8 type: array @@ -16508,6 +16584,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bodyTemperature: maxItems: 8 type: array @@ -16525,6 +16602,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP spo2: maxItems: 8 type: array @@ -16542,6 +16620,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP hrv: maxItems: 8 type: array @@ -16559,6 +16638,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP restingHeartRate: maxItems: 8 type: array @@ -16576,6 +16656,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP vo2Max: maxItems: 8 type: array @@ -16593,6 +16674,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP skinTemperature: maxItems: 8 type: array @@ -16610,6 +16692,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP respiratoryRate: maxItems: 8 type: array @@ -16627,6 +16710,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP recovery: maxItems: 8 type: array @@ -16644,6 +16728,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP stress: maxItems: 8 type: array @@ -16661,6 +16746,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP additionalProperties: false deviceTypePriority: type: object @@ -16727,6 +16813,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP activeEnergy: maxItems: 8 type: array @@ -16744,6 +16831,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP walkingRunningDistance: maxItems: 8 type: array @@ -16761,6 +16849,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP flightsClimbed: maxItems: 8 type: array @@ -16778,6 +16867,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP sleep: maxItems: 8 type: array @@ -16795,6 +16885,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP weight: maxItems: 8 type: array @@ -16812,6 +16903,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bloodPressure: maxItems: 8 type: array @@ -16829,6 +16921,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP pulse: maxItems: 8 type: array @@ -16846,6 +16939,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bodyFat: maxItems: 8 type: array @@ -16863,6 +16957,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bodyTemperature: maxItems: 8 type: array @@ -16880,6 +16975,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP spo2: maxItems: 8 type: array @@ -16897,6 +16993,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP hrv: maxItems: 8 type: array @@ -16914,6 +17011,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP restingHeartRate: maxItems: 8 type: array @@ -16931,6 +17029,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP vo2Max: maxItems: 8 type: array @@ -16948,6 +17047,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP skinTemperature: maxItems: 8 type: array @@ -16965,6 +17065,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP respiratoryRate: maxItems: 8 type: array @@ -16982,6 +17083,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP recovery: maxItems: 8 type: array @@ -16999,6 +17101,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP stress: maxItems: 8 type: array @@ -17016,6 +17119,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP metricPriority: type: object properties: @@ -17036,6 +17140,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP activeEnergy: maxItems: 8 type: array @@ -17053,6 +17158,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP walkingRunningDistance: maxItems: 8 type: array @@ -17070,6 +17176,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP flightsClimbed: maxItems: 8 type: array @@ -17087,6 +17194,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP sleep: maxItems: 8 type: array @@ -17104,6 +17212,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP weight: maxItems: 8 type: array @@ -17121,6 +17230,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bloodPressure: maxItems: 8 type: array @@ -17138,6 +17248,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP pulse: maxItems: 8 type: array @@ -17155,6 +17266,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bodyFat: maxItems: 8 type: array @@ -17172,6 +17284,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP bodyTemperature: maxItems: 8 type: array @@ -17189,6 +17302,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP spo2: maxItems: 8 type: array @@ -17206,6 +17320,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP hrv: maxItems: 8 type: array @@ -17223,6 +17338,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP restingHeartRate: maxItems: 8 type: array @@ -17240,6 +17356,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP vo2Max: maxItems: 8 type: array @@ -17257,6 +17374,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP skinTemperature: maxItems: 8 type: array @@ -17274,6 +17392,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP respiratoryRate: maxItems: 8 type: array @@ -17291,6 +17410,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP recovery: maxItems: 8 type: array @@ -17308,6 +17428,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP stress: maxItems: 8 type: array @@ -17325,6 +17446,7 @@ components: - POLAR - OURA - TELEGRAM + - MCP additionalProperties: false deviceTypePriority: type: object diff --git a/messages/de.json b/messages/de.json index 88c0d24d1..ce28bb09c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4231,6 +4231,10 @@ "title": "Daten & Datenschutz", "subtitle": "Deine Daten, Aufbewahrung, Verschlüsselung und Kontosicherheit an einem Ort.", "description": "Export, Löschung, Aufbewahrung, Verschlüsselungsstatus, Sitzungen und Sicherheitsaktivität." + }, + "mcp": { + "title": "MCP-Connector", + "subtitle": "Verbinde einen externen Assistenten mit deinen eigenen Gesundheitsdaten." } }, "profile": "Profil", @@ -5148,6 +5152,29 @@ "openAi": "KI-Datenschutzmodus", "openResearch": "Forschungsmodus" } + }, + "mcp": { + "enableTitle": "Remote-MCP-Endpunkt", + "enableDescription": "Erlaube einem MCP-fähigen Assistenten (Claude.ai, ChatGPT), deine Gesundheitsdaten über ein begrenztes Token zu lesen. Standardmäßig aus.", + "enabled": "Aktiv", + "disabled": "Inaktiv", + "enableToggleLabel": "MCP-Endpunkt aktivieren", + "endpointLabel": "Connector-URL", + "connectDescription": "Füge diese URL in die Einrichtung des eigenen Connectors deines Assistenten ein und autorisiere dich mit dem Token unten oder melde dich bei Aufforderung an.", + "tokensTitle": "Connector-Tokens", + "tokensDescription": "Erzeuge ein schreibgeschütztes Token, um einen Assistenten zu verbinden. Das Token wird nur einmal angezeigt.", + "scopeNote": "Tokens sind auf health:read beschränkt — nur Lesezugriff auf deine eigenen Daten. Sie können niemals Admin-Funktionen erreichen.", + "writeScopeToggleLabel": "Eintragen erlauben (health:write)", + "writeScopeNote": "Fügt den Scope health:write hinzu, damit der Assistent neue Messwerte und Stimmungseinträge in dein Konto eintragen kann. Jeder Schreibvorgang wird zuvor bestätigt. Das Token kann weiterhin keine vorhandenen Einträge löschen oder ändern, keine Medikamente bearbeiten und keine REST- oder Admin-Funktionen erreichen. Für reinen Lesezugriff ausgeschaltet lassen.", + "connectionsTitle": "Verbundene Assistenten", + "connectionsDescription": "Assistenten, die du über die Anmeldung autorisiert hast. Das Widerrufen einer Verbindung beendet den Zugriff sofort, einschließlich der Möglichkeit zur Erneuerung.", + "noConnections": "Keine verbundenen Assistenten.", + "connectionConnected": "Verbunden", + "connectionLastUsed": "Zuletzt verwendet", + "connectionNeverUsed": "Nie verwendet", + "connectionRevoke": "Trennen", + "connectionRevokeDescription": "Dies beendet den Zugriff des Assistenten sofort und verhindert eine Erneuerung. Für eine erneute Verbindung ist eine neue Autorisierung nötig.", + "connectionRevokeFailed": "Assistent konnte nicht getrennt werden." } }, "admin": { @@ -7119,6 +7146,10 @@ "illness": { "label": "Krankheitstagebuch", "description": "Krankheits- und Beschwerde-Episoden mit täglichem Symptomverlauf festhalten." + }, + "mcp": { + "label": "Externer Assistentenzugriff (MCP)", + "description": "Einem MCP-fähigen Assistenten Lesezugriff auf deine Gesundheitsdaten über ein begrenztes Token erlauben. Standardmäßig aus." } }, "illness": { diff --git a/messages/en.json b/messages/en.json index 17b8c277d..14fa8b085 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4231,6 +4231,10 @@ "title": "Data & Privacy", "subtitle": "Your data, retention, encryption, and account security in one place.", "description": "Export, deletion, retention, encryption status, sessions, and security activity." + }, + "mcp": { + "title": "MCP Connector", + "subtitle": "Connect an external assistant to your own health data." } }, "profile": "Profile", @@ -5148,6 +5152,29 @@ "openAi": "AI privacy mode", "openResearch": "Research mode" } + }, + "mcp": { + "enableTitle": "Remote MCP endpoint", + "enableDescription": "Let an MCP-capable assistant (Claude.ai, ChatGPT) read your health data over a scoped token. Off by default.", + "enabled": "Enabled", + "disabled": "Disabled", + "enableToggleLabel": "Enable the MCP endpoint", + "endpointLabel": "Connector URL", + "connectDescription": "Paste this URL into your assistant's custom-connector setup, then authorize with the token below or sign in when prompted.", + "tokensTitle": "Connector tokens", + "tokensDescription": "Mint a read-only token to connect an assistant. The token is shown once.", + "scopeNote": "Tokens are scoped to health:read — read-only access to your own records. They can never reach admin functions.", + "writeScopeToggleLabel": "Allow logging (health:write)", + "writeScopeNote": "Adds the health:write scope so the assistant can log new measurements and mood entries to your account. Every write is confirmed first. The token still can't delete or edit existing entries, change medications, or reach REST or admin functions. Leave off for read-only access.", + "connectionsTitle": "Connected assistants", + "connectionsDescription": "Assistants you authorized through the sign-in flow. Revoking a connection ends its access immediately, including its ability to refresh.", + "noConnections": "No connected assistants.", + "connectionConnected": "Connected", + "connectionLastUsed": "Last used", + "connectionNeverUsed": "Never used", + "connectionRevoke": "Disconnect", + "connectionRevokeDescription": "This ends the assistant's access immediately and stops it from renewing. It will need to be re-authorized to reconnect.", + "connectionRevokeFailed": "Could not disconnect the assistant." } }, "admin": { @@ -7119,6 +7146,10 @@ "illness": { "label": "Illness journal", "description": "Track illness and condition episodes with a daily symptom timeline." + }, + "mcp": { + "label": "External assistant access (MCP)", + "description": "Let an MCP-capable assistant read your health data over a scoped token. Off by default." } }, "illness": { diff --git a/messages/es.json b/messages/es.json index 1b65e0e68..ce2ee6de9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4231,6 +4231,10 @@ "title": "Datos y privacidad", "subtitle": "Tus datos, su retención, el cifrado y la seguridad de la cuenta en un solo lugar.", "description": "Exportación, eliminación, retención, estado de cifrado, sesiones y actividad de seguridad." + }, + "mcp": { + "title": "Conector MCP", + "subtitle": "Conecta un asistente externo a tus propios datos de salud." } }, "profile": "Perfil", @@ -5148,6 +5152,29 @@ "openAi": "Modo de privacidad de IA", "openResearch": "Modo investigación" } + }, + "mcp": { + "enableTitle": "Endpoint MCP remoto", + "enableDescription": "Permite que un asistente compatible con MCP (Claude.ai, ChatGPT) lea tus datos de salud mediante un token limitado. Desactivado por defecto.", + "enabled": "Activado", + "disabled": "Desactivado", + "enableToggleLabel": "Activar el endpoint MCP", + "endpointLabel": "URL del conector", + "connectDescription": "Pega esta URL en la configuración del conector personalizado de tu asistente y autoriza con el token de abajo o inicia sesión cuando se te solicite.", + "tokensTitle": "Tokens del conector", + "tokensDescription": "Genera un token de solo lectura para conectar un asistente. El token se muestra una sola vez.", + "scopeNote": "Los tokens se limitan a health:read: acceso de solo lectura a tus propios registros. Nunca pueden acceder a funciones de administración.", + "writeScopeToggleLabel": "Permitir registro (health:write)", + "writeScopeNote": "Añade el ámbito health:write para que el asistente pueda registrar nuevas mediciones y entradas de estado de ánimo en tu cuenta. Cada escritura se confirma antes. El token sigue sin poder eliminar ni editar entradas existentes, cambiar medicamentos ni acceder a funciones REST o de administración. Déjalo desactivado para acceso de solo lectura.", + "connectionsTitle": "Asistentes conectados", + "connectionsDescription": "Asistentes que autorizaste mediante el inicio de sesión. Revocar una conexión finaliza su acceso de inmediato, incluida su capacidad de renovación.", + "noConnections": "No hay asistentes conectados.", + "connectionConnected": "Conectado", + "connectionLastUsed": "Último uso", + "connectionNeverUsed": "Nunca usado", + "connectionRevoke": "Desconectar", + "connectionRevokeDescription": "Esto finaliza el acceso del asistente de inmediato e impide su renovación. Será necesario autorizarlo de nuevo para volver a conectarlo.", + "connectionRevokeFailed": "No se pudo desconectar el asistente." } }, "admin": { @@ -7119,6 +7146,10 @@ "illness": { "label": "Diario de enfermedad", "description": "Registra episodios de enfermedad y dolencias con un historial diario de síntomas." + }, + "mcp": { + "label": "Acceso de asistente externo (MCP)", + "description": "Permite que un asistente compatible con MCP lea tus datos de salud mediante un token con alcance limitado. Desactivado de forma predeterminada." } }, "illness": { diff --git a/messages/fr.json b/messages/fr.json index 2f84d4c60..69f085b74 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4231,6 +4231,10 @@ "title": "Données et confidentialité", "subtitle": "Vos données, leur conservation, le chiffrement et la sécurité du compte au même endroit.", "description": "Export, suppression, conservation, état du chiffrement, sessions et activité de sécurité." + }, + "mcp": { + "title": "Connecteur MCP", + "subtitle": "Connectez un assistant externe à vos propres données de santé." } }, "profile": "Profil", @@ -5148,6 +5152,29 @@ "openAi": "Mode confidentialité IA", "openResearch": "Mode recherche" } + }, + "mcp": { + "enableTitle": "Point de terminaison MCP distant", + "enableDescription": "Autorisez un assistant compatible MCP (Claude.ai, ChatGPT) à lire vos données de santé via un jeton restreint. Désactivé par défaut.", + "enabled": "Activé", + "disabled": "Désactivé", + "enableToggleLabel": "Activer le point de terminaison MCP", + "endpointLabel": "URL du connecteur", + "connectDescription": "Collez cette URL dans la configuration du connecteur personnalisé de votre assistant, puis autorisez avec le jeton ci-dessous ou connectez-vous lorsque vous y êtes invité.", + "tokensTitle": "Jetons du connecteur", + "tokensDescription": "Générez un jeton en lecture seule pour connecter un assistant. Le jeton n'est affiché qu'une seule fois.", + "scopeNote": "Les jetons sont limités à health:read : accès en lecture seule à vos propres données. Ils ne peuvent jamais atteindre les fonctions d'administration.", + "writeScopeToggleLabel": "Autoriser l'enregistrement (health:write)", + "writeScopeNote": "Ajoute la portée health:write pour que l'assistant puisse enregistrer de nouvelles mesures et entrées d'humeur dans votre compte. Chaque écriture est d'abord confirmée. Le jeton ne peut toujours pas supprimer ni modifier des entrées existantes, changer les médicaments, ni atteindre les fonctions REST ou d'administration. Laissez désactivé pour un accès en lecture seule.", + "connectionsTitle": "Assistants connectés", + "connectionsDescription": "Assistants que vous avez autorisés via la connexion. Révoquer une connexion met fin à son accès immédiatement, y compris sa capacité à se renouveler.", + "noConnections": "Aucun assistant connecté.", + "connectionConnected": "Connecté", + "connectionLastUsed": "Dernière utilisation", + "connectionNeverUsed": "Jamais utilisé", + "connectionRevoke": "Déconnecter", + "connectionRevokeDescription": "Cela met fin immédiatement à l'accès de l'assistant et l'empêche de se renouveler. Une nouvelle autorisation sera nécessaire pour le reconnecter.", + "connectionRevokeFailed": "Impossible de déconnecter l'assistant." } }, "admin": { @@ -7119,6 +7146,10 @@ "illness": { "label": "Journal de maladie", "description": "Suivez les épisodes de maladie et d'affections avec un historique quotidien des symptômes." + }, + "mcp": { + "label": "Accès assistant externe (MCP)", + "description": "Autorise un assistant compatible MCP à lire vos données de santé via un jeton à portée limitée. Désactivé par défaut." } }, "illness": { diff --git a/messages/it.json b/messages/it.json index 44d04f46a..60e5288d4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4231,6 +4231,10 @@ "title": "Dati e privacy", "subtitle": "I tuoi dati, la conservazione, la cifratura e la sicurezza dell'account in un unico posto.", "description": "Esportazione, eliminazione, conservazione, stato della cifratura, sessioni e attività di sicurezza." + }, + "mcp": { + "title": "Connettore MCP", + "subtitle": "Collega un assistente esterno ai tuoi dati sulla salute." } }, "profile": "Profilo", @@ -5148,6 +5152,29 @@ "openAi": "Modalità privacy IA", "openResearch": "Modalità ricerca" } + }, + "mcp": { + "enableTitle": "Endpoint MCP remoto", + "enableDescription": "Consenti a un assistente compatibile con MCP (Claude.ai, ChatGPT) di leggere i tuoi dati sulla salute tramite un token limitato. Disattivato per impostazione predefinita.", + "enabled": "Attivo", + "disabled": "Inattivo", + "enableToggleLabel": "Attiva l'endpoint MCP", + "endpointLabel": "URL del connettore", + "connectDescription": "Incolla questo URL nella configurazione del connettore personalizzato del tuo assistente e autorizza con il token qui sotto o accedi quando richiesto.", + "tokensTitle": "Token del connettore", + "tokensDescription": "Genera un token in sola lettura per collegare un assistente. Il token viene mostrato una sola volta.", + "scopeNote": "I token sono limitati a health:read: accesso in sola lettura ai tuoi dati. Non possono mai raggiungere funzioni di amministrazione.", + "writeScopeToggleLabel": "Consenti la registrazione (health:write)", + "writeScopeNote": "Aggiunge l'ambito health:write così l'assistente può registrare nuove misurazioni e voci dell'umore nel tuo account. Ogni scrittura viene prima confermata. Il token non può comunque eliminare o modificare voci esistenti, cambiare i farmaci o raggiungere funzioni REST o di amministrazione. Lascia disattivato per l'accesso in sola lettura.", + "connectionsTitle": "Assistenti collegati", + "connectionsDescription": "Assistenti che hai autorizzato tramite l'accesso. Revocare una connessione ne termina subito l'accesso, inclusa la capacità di rinnovarsi.", + "noConnections": "Nessun assistente collegato.", + "connectionConnected": "Collegato", + "connectionLastUsed": "Ultimo utilizzo", + "connectionNeverUsed": "Mai usato", + "connectionRevoke": "Disconnetti", + "connectionRevokeDescription": "Questo termina immediatamente l'accesso dell'assistente e ne impedisce il rinnovo. Per riconnetterlo sarà necessaria una nuova autorizzazione.", + "connectionRevokeFailed": "Impossibile disconnettere l'assistente." } }, "admin": { @@ -7119,6 +7146,10 @@ "illness": { "label": "Diario delle malattie", "description": "Registra episodi di malattia e disturbi con una cronologia giornaliera dei sintomi." + }, + "mcp": { + "label": "Accesso assistente esterno (MCP)", + "description": "Consente a un assistente compatibile con MCP di leggere i tuoi dati sanitari tramite un token con ambito limitato. Disattivato per impostazione predefinita." } }, "illness": { diff --git a/messages/pl.json b/messages/pl.json index 940b85f0a..5631b6f90 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4231,6 +4231,10 @@ "title": "Dane i prywatność", "subtitle": "Twoje dane, retencja, szyfrowanie i bezpieczeństwo konta w jednym miejscu.", "description": "Eksport, usuwanie, retencja, stan szyfrowania, sesje i aktywność zabezpieczeń." + }, + "mcp": { + "title": "Łącznik MCP", + "subtitle": "Połącz zewnętrznego asystenta z własnymi danymi zdrowotnymi." } }, "profile": "Profil", @@ -5148,6 +5152,29 @@ "openAi": "Tryb prywatności AI", "openResearch": "Tryb badawczy" } + }, + "mcp": { + "enableTitle": "Zdalny punkt końcowy MCP", + "enableDescription": "Zezwól asystentowi obsługującemu MCP (Claude.ai, ChatGPT) na odczyt danych zdrowotnych za pomocą ograniczonego tokenu. Domyślnie wyłączone.", + "enabled": "Włączony", + "disabled": "Wyłączony", + "enableToggleLabel": "Włącz punkt końcowy MCP", + "endpointLabel": "Adres URL łącznika", + "connectDescription": "Wklej ten adres URL w konfiguracji własnego łącznika asystenta, a następnie autoryzuj za pomocą poniższego tokenu lub zaloguj się po wyświetleniu monitu.", + "tokensTitle": "Tokeny łącznika", + "tokensDescription": "Wygeneruj token tylko do odczytu, aby połączyć asystenta. Token jest wyświetlany tylko raz.", + "scopeNote": "Tokeny są ograniczone do health:read — dostęp tylko do odczytu własnych danych. Nigdy nie mogą uzyskać dostępu do funkcji administracyjnych.", + "writeScopeToggleLabel": "Zezwól na zapis (health:write)", + "writeScopeNote": "Dodaje zakres health:write, aby asystent mógł zapisywać nowe pomiary i wpisy nastroju na Twoim koncie. Każdy zapis jest najpierw potwierdzany. Token nadal nie może usuwać ani edytować istniejących wpisów, zmieniać leków ani uzyskiwać dostępu do funkcji REST lub administracyjnych. Pozostaw wyłączone, aby uzyskać dostęp tylko do odczytu.", + "connectionsTitle": "Połączeni asystenci", + "connectionsDescription": "Asystenci autoryzowani podczas logowania. Cofnięcie połączenia natychmiast kończy dostęp, w tym możliwość odnawiania.", + "noConnections": "Brak połączonych asystentów.", + "connectionConnected": "Połączono", + "connectionLastUsed": "Ostatnio użyto", + "connectionNeverUsed": "Nigdy nie użyto", + "connectionRevoke": "Rozłącz", + "connectionRevokeDescription": "To natychmiast kończy dostęp asystenta i uniemożliwia odnawianie. Ponowne połączenie będzie wymagać nowej autoryzacji.", + "connectionRevokeFailed": "Nie udało się rozłączyć asystenta." } }, "admin": { @@ -7119,6 +7146,10 @@ "illness": { "label": "Dziennik chorób", "description": "Zapisuj epizody chorób i dolegliwości z dziennym przebiegiem objawów." + }, + "mcp": { + "label": "Dostęp asystenta zewnętrznego (MCP)", + "description": "Pozwól asystentowi obsługującemu MCP czytać Twoje dane zdrowotne za pomocą tokena o ograniczonym zakresie. Domyślnie wyłączone." } }, "illness": { diff --git a/package.json b/package.json index a17f1ac9c..3fca5e743 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "e2e:ui": "playwright test --ui", "openapi:generate": "npx tsx scripts/generate-openapi.ts", "openapi:check": "npx tsx scripts/check-openapi.ts", - "check-env": "npx tsx scripts/check-env.ts" + "check-env": "npx tsx scripts/check-env.ts", + "mcp:stdio": "npx tsx src/cli/mcp-stdio.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.1048.0", @@ -55,6 +56,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.4.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@node-rs/argon2": "^2.0.2", "@parse/node-apn": "^8.1.0", "@prisma/adapter-pg": "^7.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d9e539c5..ed274e89c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@hookform/resolvers': specifier: ^5.4.0 version: 5.4.0(react-hook-form@7.78.0(react@19.2.7)) + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.4.3) '@node-rs/argon2': specifier: ^2.0.2 version: 2.0.2 @@ -7877,6 +7880,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.25) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.25 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@mswjs/interceptors@0.41.9': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -13855,6 +13880,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod-validation-error@4.0.2(zod@4.4.3): dependencies: zod: 4.4.3 diff --git a/prisma/migrations/0206_mcp_phase3_oauth_connection/migration.sql b/prisma/migrations/0206_mcp_phase3_oauth_connection/migration.sql new file mode 100644 index 000000000..8df886ee4 --- /dev/null +++ b/prisma/migrations/0206_mcp_phase3_oauth_connection/migration.sql @@ -0,0 +1,41 @@ +-- MCP Phase 3 security fix (H2) — revocable, theft-detectable refresh chains. +-- +-- The remote MCP OAuth bridge minted stateless `hlrt_` refresh tokens that +-- referenced no server row: they could not be revoked (the settings "revoke" +-- only killed the 60-minute access token) and a replayed, already-rotated +-- refresh token was not treated as theft. This migration adds the persistent +-- anchor the refresh artifact lacked. +-- +-- `mcp_oauth_connections` is the revocable unit: one row per connector +-- connection that requested `offline_access`. `current_jti` is the only refresh +-- `jti` valid at any moment — every rotation advances it, so a presented `jti` +-- that is not `current_jti` is a replay (theft signal) that revokes the row. +-- `revoked_at` is the kill switch the settings card flips; revoking the row +-- stops every future refresh. +CREATE TABLE "mcp_oauth_connections" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "client_id" TEXT NOT NULL, + "client_name" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "resource" TEXT NOT NULL, + "current_jti" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_used_at" TIMESTAMP(3), + "revoked_at" TIMESTAMP(3), + + CONSTRAINT "mcp_oauth_connections_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "mcp_oauth_connections_user_id_idx" ON "mcp_oauth_connections"("user_id"); + +ALTER TABLE "mcp_oauth_connections" ADD CONSTRAINT "mcp_oauth_connections_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Link each OAuth-minted access token back to its connection so a connection +-- revoke kills every access row it issued, and so the connector token list can +-- exclude the transient access rows (they would otherwise flood it — M2). +ALTER TABLE "api_tokens" ADD COLUMN "mcp_connection_id" TEXT; + +CREATE INDEX "api_tokens_mcp_connection_id_idx" ON "api_tokens"("mcp_connection_id"); + +ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_mcp_connection_id_fkey" FOREIGN KEY ("mcp_connection_id") REFERENCES "mcp_oauth_connections"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/0207_mcp_phase5_write_surface/migration.sql b/prisma/migrations/0207_mcp_phase5_write_surface/migration.sql new file mode 100644 index 000000000..c4055770a --- /dev/null +++ b/prisma/migrations/0207_mcp_phase5_write_surface/migration.sql @@ -0,0 +1,14 @@ +-- MCP write provenance. +-- +-- The remote MCP endpoint gains a confirmed write surface (logging a +-- measurement / mood). Rows it writes carry their own `MeasurementSource` +-- value so they are distinguishable from MANUAL / TELEGRAM provenance and +-- get an isolated idempotency namespace `(userId, type, MCP, externalId)`. +-- +-- Purely-additive enum extension; no row touched. `ADD VALUE IF NOT EXISTS` +-- makes a rerun safe. The new value is NOT used elsewhere in this migration, +-- so it is safe to extend the enum in the same step (Postgres only forbids +-- USING a freshly-added value in the same transaction). Mirrors the 0189 +-- source-extension that added TELEGRAM. The enum is `@@map`-ed to the +-- snake_case type name `measurement_source`. +ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'MCP'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 01c290de9..5bbd4cb75 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -584,6 +584,9 @@ model User { medications Medication[] intakeEvents MedicationIntakeEvent[] apiTokens ApiToken[] + // v1.24 — MCP remote OAuth 2.1 connections (1:N). Each row is a granted + // authorization for an MCP client, audience-bound to health:read/health:write. + mcpOAuthConnections McpOAuthConnection[] refreshTokens RefreshToken[] auditLogs AuditLog[] withingsConnection WithingsConnection? @@ -1209,6 +1212,15 @@ enum MeasurementSource { // (server-resolved only); iOS DTO mirrors this spelling so the // /api/sync/changes delta feed decodes the rows. TELEGRAM + // v1.22.0 — measurements logged through the remote MCP write surface. A + // distinct provenance from MANUAL/TELEGRAM: the row was confirmed and + // written by an external assistant over the `/mcp` wire under a + // `health:write`-scoped token, never the cookie/Bearer client write path. + // Its own value gives the MCP writes an isolated idempotency namespace + // `(userId, type, MCP, externalId)`. The client batch + single-POST + // surfaces still reject it (server-resolved only); iOS DTO mirrors this + // spelling so the /api/sync/changes delta feed decodes the rows. + MCP @@map("measurement_source") } @@ -2550,13 +2562,52 @@ model ApiToken { expiresAt DateTime? @map("expires_at") createdAt DateTime @default(now()) @map("created_at") revoked Boolean @default(false) + // The MCP OAuth connection this access token belongs to, when it was minted + // through the `/api/mcp/oauth` bridge. Null for manually-minted tokens and + // native-client tokens. Lets a connection revoke kill every access row it + // ever issued (H2), and lets the connector token list exclude the transient + // 60-minute access rows so it does not flood (M2). + mcpConnectionId String? @map("mcp_connection_id") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull) @@index([userId]) + @@index([mcpConnectionId]) @@map("api_tokens") } +// MCP OAuth connection — the revocable, theft-detectable anchor for a remote +// connector's refresh-token chain (Phase 3 security fix, H2). +// +// The stateless `hlrt_` refresh artifact carries this row's `id` (`cid`) plus +// the rotation `jti`; the row is the persistent server state the artifact alone +// lacks. `currentJti` is the only refresh `jti` valid for the connection at any +// moment — every rotation advances it. A presented refresh whose `jti` is not +// `currentJti` is a replay of an already-rotated token (theft signal) and +// revokes the connection (reuse-detection family revocation, mirroring +// `refresh-token.ts`). `revokedAt` is the kill switch the settings card flips, +// so revoking a connection stops every future refresh and (via the +// `ApiToken.mcpConnectionId` back-link) kills its outstanding access tokens. +model McpOAuthConnection { + id String @id @default(cuid()) + userId String @map("user_id") + clientId String @map("client_id") + clientName String @map("client_name") + scope String + resource String + currentJti String @map("current_jti") + createdAt DateTime @default(now()) @map("created_at") + lastUsedAt DateTime? @map("last_used_at") + revokedAt DateTime? @map("revoked_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + accessTokens ApiToken[] + + @@index([userId]) + @@map("mcp_oauth_connections") +} + // v1.15.20 — registration invite tokens. When the operator has disabled // open registration (`AppSettings.registrationEnabled = false`), a valid, // unexpired invite token still admits a signup. The raw `hlv_` token diff --git a/src/app/.well-known/__tests__/oauth-discovery.test.ts b/src/app/.well-known/__tests__/oauth-discovery.test.ts new file mode 100644 index 000000000..52f9fd35e --- /dev/null +++ b/src/app/.well-known/__tests__/oauth-discovery.test.ts @@ -0,0 +1,59 @@ +/** + * OAuth discovery documents — M1 fail-closed + non-cacheable. + * + * Without a pinned origin the documents would derive the issuer / endpoints / + * audience from the Host header, so the routes 404. When configured they must + * not be `public`-cacheable (a host-derived document keyed on path alone could + * be served cross-tenant). + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +const PRM_URL = "https://health.example/.well-known/oauth-protected-resource"; +const ASM_URL = "https://health.example/.well-known/oauth-authorization-server"; + +let GET_PRM: (r: Request) => Response; +let GET_ASM: (r: Request) => Response; + +beforeEach(async () => { + GET_PRM = (await import("../oauth-protected-resource/route")).GET; + GET_ASM = (await import("../oauth-authorization-server/route")).GET; +}); + +afterEach(() => { + delete process.env.APP_URL; + delete process.env.NEXT_PUBLIC_APP_URL; +}); + +describe("OAuth discovery — M1 origin config", () => { + it("404s the PRM when no origin is configured", () => { + delete process.env.APP_URL; + delete process.env.NEXT_PUBLIC_APP_URL; + const res = GET_PRM(new Request(PRM_URL)); + expect(res.status).toBe(404); + }); + + it("404s the AS metadata when no origin is configured", () => { + delete process.env.APP_URL; + delete process.env.NEXT_PUBLIC_APP_URL; + const res = GET_ASM(new Request(ASM_URL)); + expect(res.status).toBe(404); + }); + + it("serves the PRM with private/no-store when configured", async () => { + process.env.APP_URL = "https://health.example"; + const res = GET_PRM(new Request(PRM_URL)); + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toBe("private, no-store"); + const body = await res.json(); + expect(body.resource).toBe("https://health.example/mcp"); + }); + + it("serves the AS metadata with private/no-store when configured", async () => { + process.env.APP_URL = "https://health.example"; + const res = GET_ASM(new Request(ASM_URL)); + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toBe("private, no-store"); + const body = await res.json(); + expect(body.issuer).toBe("https://health.example"); + }); +}); diff --git a/src/app/.well-known/oauth-authorization-server/route.ts b/src/app/.well-known/oauth-authorization-server/route.ts new file mode 100644 index 000000000..9b22bb096 --- /dev/null +++ b/src/app/.well-known/oauth-authorization-server/route.ts @@ -0,0 +1,29 @@ +/** + * RFC 8414 — OAuth Authorization Server Metadata for the built-in bridge AS. + * + * Public, unauthenticated discovery (added to the proxy `.well-known` allowlist). + * Advertises the authorize / token / register endpoints, `S256`-only PKCE, and + * CIMD support (`client_id_metadata_document_supported: true` + `none` auth) so + * Claude.ai / ChatGPT can self-register and complete the OAuth 2.1 + PKCE flow. + */ +import { authorizationServerMetadata } from "@/lib/mcp/oauth/metadata"; +import { isMcpOriginConfigured } from "@/lib/mcp/oauth/config"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export function GET(request: Request): Response { + // M1 — fail closed when no origin is pinned; never derive the issuer / + // endpoint URLs from the attacker-influenceable Host header. + if (!isMcpOriginConfigured()) { + return Response.json( + { error: "not_found" }, + { status: 404, headers: { "Cache-Control": "no-store" } }, + ); + } + return Response.json(authorizationServerMetadata(request.url), { + // M1 — `private, no-store`: the document is host-derived, so a shared cache + // keyed on path (ignoring Host) must never serve it cross-tenant. + headers: { "Cache-Control": "private, no-store" }, + }); +} diff --git a/src/app/.well-known/oauth-protected-resource/route.ts b/src/app/.well-known/oauth-protected-resource/route.ts new file mode 100644 index 000000000..b91c2910e --- /dev/null +++ b/src/app/.well-known/oauth-protected-resource/route.ts @@ -0,0 +1,30 @@ +/** + * RFC 9728 — OAuth Protected Resource Metadata for the `/mcp` resource. + * + * Public, unauthenticated discovery (added to the proxy `.well-known` allowlist). + * A remote MCP client reads this after the `/mcp` 401 to learn which + * Authorization Server backs the resource and what scopes it accepts. The + * `resource` value is the canonical `/mcp` URI and MUST match the URL the user + * pasted into Claude.ai / ChatGPT. + */ +import { protectedResourceMetadata } from "@/lib/mcp/oauth/metadata"; +import { isMcpOriginConfigured } from "@/lib/mcp/oauth/config"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export function GET(request: Request): Response { + // M1 — fail closed when no origin is pinned; never derive the `resource` / + // authorization-server URLs from the attacker-influenceable Host header. + if (!isMcpOriginConfigured()) { + return Response.json( + { error: "not_found" }, + { status: 404, headers: { "Cache-Control": "no-store" } }, + ); + } + return Response.json(protectedResourceMetadata(request.url), { + // M1 — `private, no-store`: the document is host-derived, so a shared cache + // keyed on path (ignoring Host) must never serve it cross-tenant. + headers: { "Cache-Control": "private, no-store" }, + }); +} diff --git a/src/app/api/mcp/connections/[id]/route.ts b/src/app/api/mcp/connections/[id]/route.ts new file mode 100644 index 000000000..63df749c4 --- /dev/null +++ b/src/app/api/mcp/connections/[id]/route.ts @@ -0,0 +1,41 @@ +/** + * Revoke an MCP OAuth connection (H2). + * + * Revoking a connection terminates its whole refresh chain: it stamps + * `revokedAt` (so every future refresh fails) and revokes every access token + * the connection ever issued. Ownership is enforced inside + * `revokeConnectionForUser`, so a connection id alone cannot revoke another + * user's connection. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { revokeConnectionForUser } from "@/lib/mcp/oauth/connections"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const DELETE = apiHandler( + async (_request: Request, { params }: RouteParams) => { + const { user } = await requireAuth(); + annotate({ action: { name: "mcp.connections.revoke" } }); + + if (!(await isApiGloballyEnabled())) { + return apiError("API is globally disabled", 403); + } + + const { id } = await params; + const revoked = await revokeConnectionForUser(user.id, id); + if (!revoked) { + return apiError("Connection not found", 404); + } + + await auditLog("mcp.connections.revoke", { + userId: user.id, + details: { connectionId: id }, + }); + + return apiSuccess({ revoked: true }); + }, +); diff --git a/src/app/api/mcp/connections/route.ts b/src/app/api/mcp/connections/route.ts new file mode 100644 index 000000000..3bd24e169 --- /dev/null +++ b/src/app/api/mcp/connections/route.ts @@ -0,0 +1,26 @@ +/** + * MCP OAuth connections — list (H2). + * + * A connection is the revocable unit for a remote connector authorized through + * the OAuth bridge (Claude.ai / ChatGPT / a third-party client). Unlike the + * transient 60-minute access-token rows, the connection persists across + * refreshes, so revoking it terminates the whole refresh chain. `userId` is + * always narrowed from the session — never a body field. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { listConnectionsForUser } from "@/lib/mcp/oauth/connections"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "mcp.connections.list" } }); + + if (!(await isApiGloballyEnabled())) { + return apiError("API is globally disabled", 403); + } + + const connections = await listConnectionsForUser(user.id); + return apiSuccess(connections); +}); diff --git a/src/app/api/mcp/oauth/__tests__/authorize.test.ts b/src/app/api/mcp/oauth/__tests__/authorize.test.ts new file mode 100644 index 000000000..7079af430 --- /dev/null +++ b/src/app/api/mcp/oauth/__tests__/authorize.test.ts @@ -0,0 +1,309 @@ +/** + * Authorize endpoint — consent gate + audience + PKCE + redirect-URI binding. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +process.env.API_TOKEN_HMAC_KEY = "x".repeat(48); +process.env.APP_URL = "https://health.example"; + +vi.mock("@/lib/logging/background", () => ({ + withBackgroundEvent: (_n: string, fn: () => unknown) => fn(), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn(async () => ({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + ip: "1.2.3.4", + })), + rateLimitHeaders: vi.fn(() => ({})), +})); +vi.mock("@/lib/app-settings", () => ({ + isApiGloballyEnabled: vi.fn(async () => true), +})); + +import { GET, POST } from "../authorize/route"; +import { getSession } from "@/lib/auth/session"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { registerDcrClient } from "@/lib/mcp/oauth/clients"; +import { s256Challenge } from "@/lib/mcp/oauth/pkce"; + +const REDIRECT = "https://claude.ai/api/mcp/auth_callback"; +const VERIFIER = "a".repeat(64); +const CHALLENGE = s256Challenge(VERIFIER); +const RESOURCE = "https://health.example/mcp"; + +const CLIENT = registerDcrClient({ + clientName: "Claude", + redirectUris: [REDIRECT], +}); + +function authorizeUrl( + overrides: Record = {}, +): string { + const params: Record = { + response_type: "code", + client_id: CLIENT.clientId, + redirect_uri: REDIRECT, + code_challenge: CHALLENGE, + code_challenge_method: "S256", + scope: "health:read", + state: "xyz", + resource: RESOURCE, + ...overrides, + }; + const u = new URL("https://health.example/api/mcp/oauth/authorize"); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined) u.searchParams.set(k, v); + } + return u.toString(); +} + +function getReq(url: string): Request { + return new Request(url, { method: "GET" }); +} + +function postReq(fields: Record): Request { + const body = new URLSearchParams(fields); + return new Request("https://health.example/api/mcp/oauth/authorize", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); +} + +function signedIn() { + vi.mocked(getSession).mockResolvedValue({ + session: { id: "s1", expiresAt: new Date(Date.now() + 1e6) }, + user: { id: "user-1" } as never, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(null); + vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); +}); + +describe("GET /authorize — consent gate", () => { + it("renders a consent screen for an authenticated user (no code issued)", async () => { + signedIn(); + const res = await GET(getReq(authorizeUrl()) as never); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toMatch(/Authorize access/); + expect(body).toMatch(/Claude/); + expect(body).toMatch(/decision/); // the Allow/Deny form + // A GET never mints a code. + expect(body).not.toMatch(/hlac_/); + }); + + it("prompts sign-in (with a return link) when there is no session", async () => { + const res = await GET(getReq(authorizeUrl()) as never); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toMatch(/\/auth\/login/); + expect(body).not.toMatch(/hlac_/); + }); +}); + +describe("GET /authorize — rejected requests", () => { + it("rejects an audience mismatch with 400 invalid_target", async () => { + signedIn(); + const res = await GET( + getReq(authorizeUrl({ resource: "https://evil.example/mcp" })) as never, + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("invalid_target"); + }); + + it("rejects a missing PKCE challenge with 400", async () => { + signedIn(); + const res = await GET( + getReq( + authorizeUrl({ + code_challenge: undefined, + code_challenge_method: undefined, + }), + ) as never, + ); + expect(res.status).toBe(400); + }); + + it("rejects a non-S256 PKCE method with 400", async () => { + signedIn(); + const res = await GET( + getReq(authorizeUrl({ code_challenge_method: "plain" })) as never, + ); + expect(res.status).toBe(400); + }); + + it("rejects an unknown client with 400 invalid_client", async () => { + signedIn(); + const res = await GET( + getReq(authorizeUrl({ client_id: "bogus" })) as never, + ); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_client"); + }); + + it("rejects a redirect_uri not registered to the client", async () => { + signedIn(); + const res = await GET( + getReq( + authorizeUrl({ redirect_uri: "https://evil.example/cb" }), + ) as never, + ); + expect(res.status).toBe(400); + }); +}); + +describe("GET /authorize — consent provenance (L1)", () => { + it("labels a DCR client as unverified (self-asserted name)", async () => { + signedIn(); + const res = await GET(getReq(authorizeUrl()) as never); + const body = await res.text(); + expect(body).toMatch(/Unverified application/); + }); +}); + +describe("authorize — surface availability (M1/M4)", () => { + it("GET returns 503 when the API is globally disabled", async () => { + signedIn(); + vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); + const res = await GET(getReq(authorizeUrl()) as never); + expect(res.status).toBe(503); + }); + + it("POST returns 503 when the API is globally disabled", async () => { + signedIn(); + vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); + const res = await POST( + postReq({ + response_type: "code", + client_id: CLIENT.clientId, + redirect_uri: REDIRECT, + code_challenge: CHALLENGE, + code_challenge_method: "S256", + scope: "health:read", + resource: RESOURCE, + decision: "allow", + }) as never, + ); + expect(res.status).toBe(503); + }); +}); + +describe("POST /authorize — cross-origin guard (L3)", () => { + it("rejects a cross-origin Origin with 403", async () => { + signedIn(); + const body = new URLSearchParams({ + response_type: "code", + client_id: CLIENT.clientId, + redirect_uri: REDIRECT, + code_challenge: CHALLENGE, + code_challenge_method: "S256", + scope: "health:read", + resource: RESOURCE, + decision: "allow", + }); + const req = new Request("https://health.example/api/mcp/oauth/authorize", { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + origin: "https://evil.example", + }, + body: body.toString(), + }); + const res = await POST(req as never); + expect(res.status).toBe(403); + }); + + it("rejects a cross-site Sec-Fetch-Site with 403", async () => { + signedIn(); + const body = new URLSearchParams({ + response_type: "code", + client_id: CLIENT.clientId, + redirect_uri: REDIRECT, + code_challenge: CHALLENGE, + code_challenge_method: "S256", + scope: "health:read", + resource: RESOURCE, + decision: "allow", + }); + const req = new Request("https://health.example/api/mcp/oauth/authorize", { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + "sec-fetch-site": "cross-site", + }, + body: body.toString(), + }); + const res = await POST(req as never); + expect(res.status).toBe(403); + }); +}); + +describe("POST /authorize — decision", () => { + it("requires a session (401 when unauthenticated)", async () => { + const res = await POST( + postReq({ + response_type: "code", + client_id: CLIENT.clientId, + redirect_uri: REDIRECT, + code_challenge: CHALLENGE, + code_challenge_method: "S256", + scope: "health:read", + resource: RESOURCE, + decision: "allow", + }) as never, + ); + expect(res.status).toBe(401); + }); + + it("mints a code and 302s back on allow", async () => { + signedIn(); + const res = await POST( + postReq({ + response_type: "code", + client_id: CLIENT.clientId, + redirect_uri: REDIRECT, + code_challenge: CHALLENGE, + code_challenge_method: "S256", + scope: "health:read", + state: "xyz", + resource: RESOURCE, + decision: "allow", + }) as never, + ); + expect(res.status).toBe(302); + const loc = res.headers.get("location") ?? ""; + expect(loc.startsWith(REDIRECT)).toBe(true); + const code = new URL(loc).searchParams.get("code"); + expect(code).toMatch(/^hlac_/); + expect(new URL(loc).searchParams.get("state")).toBe("xyz"); + }); + + it("302s with access_denied on deny", async () => { + signedIn(); + const res = await POST( + postReq({ + response_type: "code", + client_id: CLIENT.clientId, + redirect_uri: REDIRECT, + code_challenge: CHALLENGE, + code_challenge_method: "S256", + scope: "health:read", + resource: RESOURCE, + decision: "deny", + }) as never, + ); + expect(res.status).toBe(302); + const loc = res.headers.get("location") ?? ""; + expect(new URL(loc).searchParams.get("error")).toBe("access_denied"); + }); +}); diff --git a/src/app/api/mcp/oauth/__tests__/register.test.ts b/src/app/api/mcp/oauth/__tests__/register.test.ts new file mode 100644 index 000000000..9bacbea67 --- /dev/null +++ b/src/app/api/mcp/oauth/__tests__/register.test.ts @@ -0,0 +1,88 @@ +/** + * DCR registration (RFC 7591) — stateless `hlc_` client-id issuance. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +process.env.API_TOKEN_HMAC_KEY = "x".repeat(48); + +vi.mock("@/lib/logging/background", () => ({ + withBackgroundEvent: (_n: string, fn: () => unknown) => fn(), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn(async () => ({ + allowed: true, + remaining: 19, + resetAt: Date.now() + 60_000, + ip: "1.2.3.4", + })), + rateLimitHeaders: vi.fn(() => ({})), +})); +vi.mock("@/lib/app-settings", () => ({ + isApiGloballyEnabled: vi.fn(async () => true), +})); + +import { POST } from "../register/route"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; + +function jsonReq(body: unknown): Request { + return new Request("https://health.example/api/mcp/oauth/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); +}); + +describe("POST /register", () => { + it("registers a public client and returns an hlc_ client_id", async () => { + const res = await POST( + jsonReq({ + client_name: "ChatGPT", + redirect_uris: [ + "https://chatgpt.com/connector_platform_oauth_redirect", + ], + }) as never, + ); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.client_id).toMatch(/^hlc_/); + expect(body.token_endpoint_auth_method).toBe("none"); + expect(body.redirect_uris).toEqual([ + "https://chatgpt.com/connector_platform_oauth_redirect", + ]); + }); + + it("accepts a loopback redirect (Claude Code)", async () => { + const res = await POST( + jsonReq({ redirect_uris: ["http://127.0.0.1:8976/callback"] }) as never, + ); + expect(res.status).toBe(201); + }); + + it("rejects a missing redirect_uris with invalid_client_metadata", async () => { + const res = await POST(jsonReq({ client_name: "x" }) as never); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_client_metadata"); + }); + + it("rejects a non-https, non-loopback redirect_uri", async () => { + const res = await POST( + jsonReq({ redirect_uris: ["http://evil.example/cb"] }) as never, + ); + expect(res.status).toBe(400); + }); + + it("returns 503 when the API is globally disabled (M4)", async () => { + vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); + const res = await POST( + jsonReq({ redirect_uris: ["https://chatgpt.com/cb"] }) as never, + ); + expect(res.status).toBe(503); + expect((await res.json()).error).toBe("temporarily_unavailable"); + }); +}); diff --git a/src/app/api/mcp/oauth/__tests__/token.test.ts b/src/app/api/mcp/oauth/__tests__/token.test.ts new file mode 100644 index 000000000..76e3fbb07 --- /dev/null +++ b/src/app/api/mcp/oauth/__tests__/token.test.ts @@ -0,0 +1,321 @@ +/** + * Token endpoint — PKCE verification, audience binding, single-use codes, + * refresh-token rotation. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +process.env.API_TOKEN_HMAC_KEY = "x".repeat(48); +process.env.APP_URL = "https://health.example"; + +vi.mock("@/lib/logging/background", () => ({ + withBackgroundEvent: (_n: string, fn: () => unknown) => fn(), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/auth/issue-token", () => ({ + issueApiToken: vi.fn(async () => ({ + token: "hlk_" + "a".repeat(64), + expiresAt: new Date(Date.now() + 3_600_000), + tokenId: "tok-1", + name: "MCP connector", + })), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn(async () => ({ + allowed: true, + remaining: 100, + resetAt: Date.now() + 60_000, + ip: "1.2.3.4", + })), + checkRateLimit: vi.fn(async () => ({ + allowed: true, + remaining: 0, + resetAt: Date.now() + 60_000, + })), + rateLimitHeaders: vi.fn(() => ({})), +})); +vi.mock("@/lib/app-settings", () => ({ + isApiGloballyEnabled: vi.fn(async () => true), +})); +vi.mock("@/lib/mcp/oauth/connections", () => ({ + createConnection: vi.fn(async () => "conn-1"), + rotateConnection: vi.fn(async () => ({ ok: true })), +})); + +import { randomUUID } from "node:crypto"; +import { POST } from "../token/route"; +import { issueApiToken } from "@/lib/auth/issue-token"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { + createConnection, + rotateConnection, +} from "@/lib/mcp/oauth/connections"; +import { signArtifact } from "@/lib/mcp/oauth/artifacts"; +import { s256Challenge } from "@/lib/mcp/oauth/pkce"; + +const CLIENT_ID = "https://app.example/client.json"; +const REDIRECT = "https://claude.ai/api/mcp/auth_callback"; +const VERIFIER = "a".repeat(64); +const CHALLENGE = s256Challenge(VERIFIER); +const RESOURCE = "https://health.example/mcp"; + +function authCode(overrides: Record = {}): string { + return signArtifact( + "authCode", + { + jti: randomUUID(), + sub: "user-1", + client_id: CLIENT_ID, + redirect_uri: REDIRECT, + code_challenge: CHALLENGE, + scope: "health:read offline_access", + resource: RESOURCE, + ...overrides, + }, + 120_000, + ); +} + +function tokenReq(fields: Record): Request { + return new Request("https://health.example/api/mcp/oauth/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(fields).toString(), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); + vi.mocked(createConnection).mockResolvedValue("conn-1"); + vi.mocked(rotateConnection).mockResolvedValue({ ok: true }); +}); + +function refreshToken(overrides: Record = {}): string { + return signArtifact( + "refreshToken", + { + jti: randomUUID(), + cid: "conn-1", + sub: "user-1", + client_id: CLIENT_ID, + scope: "health:read offline_access", + resource: RESOURCE, + ...overrides, + }, + 1_000_000, + ); +} + +describe("authorization_code grant", () => { + it("exchanges a valid code + verifier for an access token (+ refresh)", async () => { + const res = await POST( + tokenReq({ + grant_type: "authorization_code", + code: authCode(), + code_verifier: VERIFIER, + client_id: CLIENT_ID, + redirect_uri: REDIRECT, + }) as never, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.token_type).toBe("Bearer"); + expect(body.access_token).toMatch(/^hlk_/); + expect(body.refresh_token).toMatch(/^hlrt_/); + expect(body.scope).toContain("health:read"); + expect(issueApiToken).toHaveBeenCalledWith( + expect.objectContaining({ permissions: ["health:read"] }), + ); + }); + + it("creates a revocable connection anchor and links the access token (H2)", async () => { + await POST( + tokenReq({ + grant_type: "authorization_code", + code: authCode(), + code_verifier: VERIFIER, + client_id: CLIENT_ID, + redirect_uri: REDIRECT, + }) as never, + ); + expect(createConnection).toHaveBeenCalledTimes(1); + expect(issueApiToken).toHaveBeenCalledWith( + expect.objectContaining({ mcpConnectionId: "conn-1" }), + ); + }); + + it("rejects a wrong PKCE verifier with invalid_grant", async () => { + const res = await POST( + tokenReq({ + grant_type: "authorization_code", + code: authCode(), + code_verifier: "b".repeat(64), + client_id: CLIENT_ID, + redirect_uri: REDIRECT, + }) as never, + ); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_grant"); + expect(issueApiToken).not.toHaveBeenCalled(); + }); + + it("rejects a code bound to a different audience", async () => { + const res = await POST( + tokenReq({ + grant_type: "authorization_code", + code: authCode({ resource: "https://evil.example/mcp" }), + code_verifier: VERIFIER, + client_id: CLIENT_ID, + redirect_uri: REDIRECT, + }) as never, + ); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_target"); + }); + + it("rejects a code presented by a different client", async () => { + const res = await POST( + tokenReq({ + grant_type: "authorization_code", + code: authCode(), + code_verifier: VERIFIER, + client_id: "https://other.example/client.json", + redirect_uri: REDIRECT, + }) as never, + ); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_grant"); + }); + + it("rejects a replayed code (single-use jti already claimed)", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await POST( + tokenReq({ + grant_type: "authorization_code", + code: authCode(), + code_verifier: VERIFIER, + client_id: CLIENT_ID, + redirect_uri: REDIRECT, + }) as never, + ); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_grant"); + expect(issueApiToken).not.toHaveBeenCalled(); + }); +}); + +describe("refresh_token grant", () => { + it("rotates: issues a new access + refresh pair against a live connection", async () => { + const res = await POST( + tokenReq({ + grant_type: "refresh_token", + refresh_token: refreshToken(), + client_id: CLIENT_ID, + }) as never, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.access_token).toMatch(/^hlk_/); + expect(body.refresh_token).toMatch(/^hlrt_/); + expect(rotateConnection).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: "conn-1", clientId: CLIENT_ID }), + ); + }); + + it("rejects a refresh whose connection was revoked (H2 — settings revoke)", async () => { + vi.mocked(rotateConnection).mockResolvedValue({ + ok: false, + reason: "revoked", + }); + const res = await POST( + tokenReq({ + grant_type: "refresh_token", + refresh_token: refreshToken(), + client_id: CLIENT_ID, + }) as never, + ); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_grant"); + expect(issueApiToken).not.toHaveBeenCalled(); + }); + + it("rejects a replayed refresh and reports reuse detection (H2)", async () => { + vi.mocked(rotateConnection).mockResolvedValue({ + ok: false, + reason: "reuse_detected", + }); + const res = await POST( + tokenReq({ + grant_type: "refresh_token", + refresh_token: refreshToken(), + client_id: CLIENT_ID, + }) as never, + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("invalid_grant"); + expect(body.error_description).toMatch(/reuse/i); + expect(issueApiToken).not.toHaveBeenCalled(); + }); + + it("rejects a legacy refresh token with no connection binding", async () => { + const legacy = signArtifact( + "refreshToken", + { + jti: randomUUID(), + sub: "user-1", + client_id: CLIENT_ID, + scope: "health:read offline_access", + resource: RESOURCE, + }, + 1_000_000, + ); + const res = await POST( + tokenReq({ + grant_type: "refresh_token", + refresh_token: legacy, + client_id: CLIENT_ID, + }) as never, + ); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_grant"); + expect(rotateConnection).not.toHaveBeenCalled(); + }); +}); + +describe("operator kill-switch + origin config (M1/M4)", () => { + it("refuses to mint when the API is globally disabled", async () => { + vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); + const res = await POST( + tokenReq({ + grant_type: "authorization_code", + code: authCode(), + code_verifier: VERIFIER, + client_id: CLIENT_ID, + redirect_uri: REDIRECT, + }) as never, + ); + expect(res.status).toBe(503); + expect((await res.json()).error).toBe("temporarily_unavailable"); + expect(issueApiToken).not.toHaveBeenCalled(); + }); +}); + +describe("grant validation", () => { + it("rejects an unsupported grant_type", async () => { + const res = await POST(tokenReq({ grant_type: "password" }) as never); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("unsupported_grant_type"); + }); +}); diff --git a/src/app/api/mcp/oauth/authorize/route.ts b/src/app/api/mcp/oauth/authorize/route.ts new file mode 100644 index 000000000..f4c0ccfdc --- /dev/null +++ b/src/app/api/mcp/oauth/authorize/route.ts @@ -0,0 +1,456 @@ +/** + * Authorization endpoint (OAuth 2.1 authorization-code flow + PKCE S256). + * + * This is the consent gate of the bridge AS. It is backed by HealthLog's + * existing Postgres session (passkey / password login) — there is NO parallel + * user store (ADR-006). The flow: + * + * GET — validate the request; if the caller has no session, prompt them to + * sign in (returning here); if they do, render a per-client CONSENT + * screen naming the client + the exact scope. NO code is ever issued on + * a GET (confused-deputy mitigation: a third party cannot silently + * obtain a grant — an authenticated human must click "Allow"). + * POST — the consent decision. Requires a session, re-validates every + * parameter, and on "Allow" mints a single-use, short-lived, + * self-describing authorization code bound to this user + client + + * redirect URI + PKCE challenge + scope + audience, then 302s back. + * + * Mandatory gates, every request: PKCE `S256` (a missing / non-S256 challenge is + * rejected), audience binding (`resource` MUST equal the canonical `/mcp` URI — + * RFC 8707), and an exact redirect-URI match against the resolved client (with + * port-agnostic loopback tolerance). Errors are returned directly (never + * redirected to an unvalidated URI) so the endpoint is not an open redirector. + */ +import { randomUUID } from "node:crypto"; + +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { annotate } from "@/lib/logging/context"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { auditLog } from "@/lib/auth/audit"; +import { getSession } from "@/lib/auth/session"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { + audienceMatches, + AUTH_CODE_TTL_SECONDS, + isMcpOriginConfigured, + resolveBaseOrigin, + SCOPE_HEALTH_READ, + SCOPE_HEALTH_WRITE, +} from "@/lib/mcp/oauth/config"; +import { redirectUriAllowed, resolveClient } from "@/lib/mcp/oauth/clients"; +import { isValidChallenge } from "@/lib/mcp/oauth/pkce"; +import { signArtifact } from "@/lib/mcp/oauth/artifacts"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const AUTHORIZE_LIMIT = 60; +const AUTHORIZE_WINDOW_MS = 15 * 60 * 1000; + +const baseParams = { + response_type: z.string(), + client_id: z.string().min(1).max(4096), + redirect_uri: z.string().min(1).max(2048), + code_challenge: z.string().min(1).max(256).optional(), + code_challenge_method: z.string().optional(), + scope: z.string().max(256).optional(), + state: z.string().max(2048).optional(), + resource: z.string().max(2048).optional(), +}; + +function htmlEscape(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function badRequest(error: string, description: string): Response { + return Response.json( + { error, error_description: description }, + { status: 400 }, + ); +} + +function html(body: string, status = 200): Response { + return new Response( + `HealthLog — Authorize access${body}`, + { + status, + headers: { "content-type": "text/html; charset=utf-8" }, + }, + ); +} + +/** + * The scopes this surface grants for a request. `health:read` is always + * granted (the floor). `health:write` is granted ONLY when explicitly + * requested (RFC 8707 / incremental consent, SEP-835) — it admits the + * confirmed `/mcp` write tools and is surfaced plainly on the consent screen. + * `offline_access` enables refresh continuity. Nothing else is ever granted. + */ +function grantScope(requested: string | undefined): string { + const wants = new Set( + (requested ?? SCOPE_HEALTH_READ).split(/\s+/).filter(Boolean), + ); + const granted = [SCOPE_HEALTH_READ]; + if (wants.has(SCOPE_HEALTH_WRITE)) granted.push(SCOPE_HEALTH_WRITE); + if (wants.has("offline_access")) granted.push("offline_access"); + return granted.join(" "); +} + +interface ValidatedRequest { + clientId: string; + clientName: string; + /** The VERIFIED client origin shown on the consent screen (L1). */ + clientOrigin: string; + /** Registration provenance — drives the trust label on consent (L1). */ + clientSource: "cimd" | "dcr"; + redirectUri: string; + codeChallenge: string; + scope: string; + state?: string; + resource: string; +} + +/** Shared validation for GET + POST. Returns a ready error Response on failure. */ +async function validate( + params: Record, + requestUrl: string, +): Promise<{ ok: true; v: ValidatedRequest } | { ok: false; res: Response }> { + const parsed = z.object(baseParams).safeParse(params); + if (!parsed.success) { + return { + ok: false, + res: badRequest("invalid_request", "Missing or malformed parameters"), + }; + } + const p = parsed.data; + + // 1. Client must resolve (CIMD URL or stateless DCR id). Unknown → cannot + // safely redirect anywhere, so error directly. + const resolved = await resolveClient(p.client_id); + if (!resolved.ok) { + return { + ok: false, + res: badRequest("invalid_client", "Unknown or unresolvable client"), + }; + } + + // 2. Redirect URI MUST match the registration before we trust it. + if (!redirectUriAllowed(p.redirect_uri, resolved.client.redirectUris)) { + return { + ok: false, + res: badRequest( + "invalid_request", + "redirect_uri does not match the registered client", + ), + }; + } + + // 3. response_type — code only. + if (p.response_type !== "code") { + return { + ok: false, + res: badRequest( + "unsupported_response_type", + "Only response_type=code is supported", + ), + }; + } + + // 4. PKCE S256 is MANDATORY. + if ( + p.code_challenge_method !== "S256" || + !isValidChallenge(p.code_challenge) + ) { + return { + ok: false, + res: badRequest( + "invalid_request", + "PKCE with code_challenge_method=S256 is required", + ), + }; + } + + // 5. Audience binding (RFC 8707) — resource MUST equal the canonical /mcp URI. + if (!audienceMatches(p.resource, requestUrl)) { + return { + ok: false, + res: badRequest( + "invalid_target", + "resource must equal this server's MCP endpoint", + ), + }; + } + + // L1 — the VERIFIED origin. For CIMD the `client_id` IS the document URL, so + // its host is cryptographically tied to the fetched-and-matched document; for + // DCR there is no verified origin (anyone can self-register), so the consent + // screen labels it unverified rather than trusting the self-asserted name. + const clientOrigin = + resolved.client.source === "cimd" + ? (() => { + try { + return new URL(p.client_id).host; + } catch { + return p.client_id; + } + })() + : ""; + + return { + ok: true, + v: { + clientId: p.client_id, + clientName: resolved.client.clientName, + clientOrigin, + clientSource: resolved.client.source, + redirectUri: p.redirect_uri, + codeChallenge: p.code_challenge as string, + scope: grantScope(p.scope), + state: p.state, + resource: p.resource as string, + }, + }; +} + +/** + * L3 — same-origin assertion for the consent POST. A non-browser caller sends + * neither `Origin` nor `Sec-Fetch-Site`, so their absence is allowed; a present + * `Origin` that is not our own origin, or a `Sec-Fetch-Site` that is not + * `same-origin`, is refused. Mirrors the `/mcp` DNS-rebinding posture. + */ +function consentOriginAllowed(request: NextRequest): boolean { + const origin = request.headers.get("origin"); + if (origin && origin !== resolveBaseOrigin(request.url)) return false; + const site = request.headers.get("sec-fetch-site"); + if (site && site !== "same-origin") return false; + return true; +} + +function redirectBack( + redirectUri: string, + query: Record, +): Response { + const url = new URL(redirectUri); + for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v); + return Response.redirect(url.toString(), 302); +} + +/** Whether the surface is available (M1 origin pinned + M4 kill-switch off). */ +async function surfaceAvailable(): Promise { + return isMcpOriginConfigured() && (await isApiGloballyEnabled()); +} + +export async function GET(request: NextRequest): Promise { + return withBackgroundEvent("mcp.oauth.authorize", async () => { + if (!(await surfaceAvailable())) { + return Response.json( + { error: "temporarily_unavailable" }, + { status: 503 }, + ); + } + const rl = await checkAuthSurfaceRateLimit( + request, + "mcp:oauth:authorize", + AUTHORIZE_LIMIT, + AUTHORIZE_WINDOW_MS, + ); + if (!rl.allowed) { + return Response.json( + { error: "temporarily_unavailable" }, + { + status: 429, + headers: rateLimitHeaders({ + allowed: false, + remaining: rl.remaining, + resetAt: rl.resetAt, + }), + }, + ); + } + + const url = new URL(request.url); + const params = Object.fromEntries(url.searchParams.entries()); + const result = await validate(params, request.url); + if (!result.ok) { + annotate({ action: { name: "mcp.oauth.authorize.invalid" } }); + return result.res; + } + const { v } = result; + + const session = await getSession(); + if (!session) { + // No identity yet — send the human to sign in, returning to this exact + // request. NO grant is issued without an authenticated decision. + const next = url.pathname + url.search; + const loginHref = `/auth/login?next=${encodeURIComponent(next)}`; + annotate({ action: { name: "mcp.oauth.authorize.login_required" } }); + return html( + `

Sign in to HealthLog

To connect ${htmlEscape(v.clientName)} you must sign in first.

Sign in and continue

`, + ); + } + + annotate({ action: { name: "mcp.oauth.authorize.consent_shown" } }); + // Consent screen — a plain POST form (no inline script/style, CSP-safe). + const hidden = (name: string, value: string) => + ``; + // L1 — show the VERIFIED origin (or an explicit "unverified" label for DCR) + // next to the self-asserted name so the user authorizes who, not just what. + const provenance = + v.clientSource === "cimd" + ? `

Verified origin: ${htmlEscape(v.clientOrigin)}

` + : `

Unverified application (dynamically registered — the name above is self-reported and not verified).

`; + // Plain-language access summary. When write is granted, say so explicitly — + // the app will be able to READ and LOG/WRITE health data, not just read. + const writeGranted = v.scope.split(/\s+/).includes(SCOPE_HEALTH_WRITE); + const accessSummary = writeGranted + ? `

This grants read AND write access: the application will be able to read your own health records and log new measurements and mood entries to your account on your behalf. It cannot delete or change existing entries, edit medications, or reach admin functions.

` + : `

Scope: ${htmlEscape(v.scope)} — read-only access to your own health records.

`; + return html( + `
+

Authorize access

+

${htmlEscape(v.clientName)} is requesting access to your HealthLog data.

+ ${provenance} +

Scope: ${htmlEscape(v.scope)}

+ ${accessSummary} +
+ ${hidden("response_type", "code")} + ${hidden("client_id", v.clientId)} + ${hidden("redirect_uri", v.redirectUri)} + ${hidden("code_challenge", v.codeChallenge)} + ${hidden("code_challenge_method", "S256")} + ${hidden("scope", v.scope)} + ${hidden("resource", v.resource)} + ${v.state !== undefined ? hidden("state", v.state) : ""} + + +
+
`, + ); + }); +} + +export async function POST(request: NextRequest): Promise { + return withBackgroundEvent("mcp.oauth.authorize", async () => { + if (!(await surfaceAvailable())) { + return Response.json( + { error: "temporarily_unavailable" }, + { status: 503 }, + ); + } + + // L3 — defence-in-depth same-origin assertion on the consent decision. The + // session cookie is already SameSite=Lax (a cross-site POST carries none), + // but reject an explicit cross-origin Origin / Sec-Fetch-Site outright so a + // forged "allow" cannot ride a present session. + if (!consentOriginAllowed(request)) { + annotate({ action: { name: "mcp.oauth.authorize.cross_origin" } }); + return Response.json( + { error: "access_denied", error_description: "Cross-origin POST" }, + { status: 403 }, + ); + } + + const rl = await checkAuthSurfaceRateLimit( + request, + "mcp:oauth:authorize", + AUTHORIZE_LIMIT, + AUTHORIZE_WINDOW_MS, + ); + if (!rl.allowed) { + return Response.json( + { error: "temporarily_unavailable" }, + { + status: 429, + headers: rateLimitHeaders({ + allowed: false, + remaining: rl.remaining, + resetAt: rl.resetAt, + }), + }, + ); + } + + // The consent decision MUST come from an authenticated human. + const session = await getSession(); + if (!session) { + annotate({ + action: { name: "mcp.oauth.authorize.decision_unauthenticated" }, + }); + return Response.json( + { error: "access_denied", error_description: "Sign in required" }, + { status: 401 }, + ); + } + + const form = await request.formData(); + const params: Record = {}; + for (const key of [ + "response_type", + "client_id", + "redirect_uri", + "code_challenge", + "code_challenge_method", + "scope", + "state", + "resource", + "decision", + ]) { + const value = form.get(key); + params[key] = typeof value === "string" ? value : undefined; + } + + const result = await validate(params, request.url); + if (!result.ok) { + annotate({ action: { name: "mcp.oauth.authorize.invalid" } }); + return result.res; + } + const { v } = result; + + if (params.decision !== "allow") { + annotate({ action: { name: "mcp.oauth.authorize.denied" } }); + return redirectBack(v.redirectUri, { + error: "access_denied", + ...(v.state !== undefined ? { state: v.state } : {}), + }); + } + + const code = signArtifact( + "authCode", + { + // `jti` makes the code single-use: the token endpoint claims it once + // through the atomic rate-limit upsert, so a replay within the TTL is + // rejected. + jti: randomUUID(), + sub: session.user.id, + client_id: v.clientId, + // Carried so the token endpoint can label the persistent connection + // (H2) without re-resolving the client. + client_name: v.clientName, + redirect_uri: v.redirectUri, + code_challenge: v.codeChallenge, + scope: v.scope, + resource: v.resource, + }, + AUTH_CODE_TTL_SECONDS * 1000, + ); + + await auditLog("mcp.oauth.authorize.granted", { + userId: session.user.id, + details: { client_id: v.clientId, scope: v.scope }, + }); + annotate({ action: { name: "mcp.oauth.authorize.granted" } }); + + return redirectBack(v.redirectUri, { + code, + ...(v.state !== undefined ? { state: v.state } : {}), + }); + }); +} diff --git a/src/app/api/mcp/oauth/register/route.ts b/src/app/api/mcp/oauth/register/route.ts new file mode 100644 index 000000000..513bf1b85 --- /dev/null +++ b/src/app/api/mcp/oauth/register/route.ts @@ -0,0 +1,138 @@ +/** + * Dynamic Client Registration (RFC 7591) — the DCR fallback for clients that + * do not present a CIMD `client_id` URL. + * + * Registration is STATELESS: the issued `client_id` is a signed, self-describing + * `hlc_` artifact carrying the registered `redirect_uris` + name (see + * `clients.ts` / `artifacts.ts`), so no client row is written. The endpoint only + * validates the metadata, enforces a per-IP rate limit (it is unauthenticated), + * and returns the RFC 7591 client-information response for a PUBLIC client + * (`token_endpoint_auth_method: "none"` — PKCE is the proof, there is no secret). + * + * Redirect URIs are shape-validated here; the AUTHORITATIVE redirect-URI match + * happens at `/authorize` against the decoded registration. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { annotate } from "@/lib/logging/context"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { readBodyCapped } from "@/lib/http/read-capped"; +import { + isAllowableRedirectUri, + registerDcrClient, +} from "@/lib/mcp/oauth/clients"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const REGISTER_LIMIT = 20; +const REGISTER_WINDOW_MS = 60 * 60 * 1000; +const MAX_BODY_BYTES = 16 * 1024; + +const registrationSchema = z.object({ + redirect_uris: z + .array(z.string().min(1).max(2048)) + .min(1) + .max(12) + .refine((uris) => uris.every(isAllowableRedirectUri), { + message: "redirect_uris must be HTTPS or http loopback URLs", + }), + client_name: z.string().min(1).max(200).optional(), + // Accepted-and-ignored RFC 7591 fields (we never read them but tolerate them). + grant_types: z.array(z.string()).optional(), + response_types: z.array(z.string()).optional(), + token_endpoint_auth_method: z.string().optional(), + scope: z.string().optional(), +}); + +function oauthError( + error: string, + description: string, + status: number, + extraHeaders: Record = {}, +): Response { + return Response.json( + { error, error_description: description }, + { status, headers: extraHeaders }, + ); +} + +export async function POST(request: NextRequest): Promise { + return withBackgroundEvent("mcp.oauth.register", async () => { + const rl = await checkAuthSurfaceRateLimit( + request, + "mcp:oauth:register", + REGISTER_LIMIT, + REGISTER_WINDOW_MS, + ); + if (!rl.allowed) { + annotate({ action: { name: "mcp.oauth.register.rate_limited" } }); + return Response.json( + { error: "temporarily_unavailable" }, + { + status: 429, + headers: rateLimitHeaders({ + allowed: false, + remaining: rl.remaining, + resetAt: rl.resetAt, + }), + }, + ); + } + + // M4 — the operator API kill-switch covers the OAuth surface too. + if (!(await isApiGloballyEnabled())) { + return oauthError("temporarily_unavailable", "API is disabled", 503); + } + + // M3 — enforce the cap WHILE reading the (unauthenticated) registration + // body so a hostile client cannot buffer an oversized payload first. + const read = await readBodyCapped(request, MAX_BODY_BYTES); + if (!read.ok) { + return oauthError("invalid_client_metadata", "Payload too large", 400); + } + let body: unknown; + try { + body = JSON.parse(read.text); + } catch { + return oauthError("invalid_client_metadata", "Invalid JSON", 400); + } + + const parsed = registrationSchema.safeParse(body); + if (!parsed.success) { + annotate({ action: { name: "mcp.oauth.register.invalid" } }); + return oauthError( + "invalid_client_metadata", + "redirect_uris is required and must contain HTTPS or loopback URLs", + 400, + ); + } + + const client = registerDcrClient({ + clientName: parsed.data.client_name ?? "MCP client", + redirectUris: parsed.data.redirect_uris, + }); + + annotate({ + action: { name: "mcp.oauth.register.issued" }, + meta: { source: "dcr", redirect_uri_count: client.redirectUris.length }, + }); + + // RFC 7591 client-information response for a public client. + return Response.json( + { + client_id: client.clientId, + client_id_issued_at: Math.floor(Date.now() / 1000), + redirect_uris: client.redirectUris, + client_name: client.clientName, + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + }, + { status: 201 }, + ); + }); +} diff --git a/src/app/api/mcp/oauth/token/route.ts b/src/app/api/mcp/oauth/token/route.ts new file mode 100644 index 000000000..c61342fdd --- /dev/null +++ b/src/app/api/mcp/oauth/token/route.ts @@ -0,0 +1,411 @@ +/** + * Token endpoint (OAuth 2.1) — `authorization_code` + `refresh_token` grants. + * + * Exchanges the bridge AS's self-describing artifacts for a REAL `hlk_` + * `ApiToken` (the access token) minted through `issue-token.ts` with EXACTLY + * the scopes the authorization code carried — `health:read`, or + * `health:read health:write` when the user consented to logging (ADR-006). A + * `health:write` token is still audience-bound to `/mcp`. The access token is + * therefore an ordinary + * Bearer the `/mcp` resolver already understands — bound to `:` + * (REQ-SEC-11), revocable, and short-lived. There is no token passthrough: the + * MCP layer never forwards a client-presented upstream credential. + * + * Gates: + * - PKCE — the presented `code_verifier` MUST hash (S256) to the + * `code_challenge` bound into the authorization code. + * - Single use — the code's `jti` is claimed once through the atomic + * rate-limit upsert; a replay within the TTL is rejected. + * - Audience — the code's bound `resource` MUST equal the canonical `/mcp` + * URI (RFC 8707); a `resource` parameter, when sent, must match too. + * - Client binding — the `client_id` presented at `/token` MUST equal the one + * the code was issued to. + * + * Refresh tokens rotate on every use (OAuth 2.1 public-client rotation): a fresh + * access + refresh pair is returned and the presented refresh `jti` is claimed + * so it cannot be reused. + */ +import { randomUUID } from "node:crypto"; + +import { NextRequest } from "next/server"; + +import { annotate } from "@/lib/logging/context"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { auditLog } from "@/lib/auth/audit"; +import { issueApiToken } from "@/lib/auth/issue-token"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { + checkRateLimit, + checkAuthSurfaceRateLimit, + rateLimitHeaders, +} from "@/lib/rate-limit"; +import { + ACCESS_TOKEN_TTL_MINUTES, + audienceMatches, + isMcpOriginConfigured, + REFRESH_TOKEN_TTL_DAYS, + SCOPE_HEALTH_READ, + SCOPE_HEALTH_WRITE, +} from "@/lib/mcp/oauth/config"; +import { signArtifact, verifyArtifact } from "@/lib/mcp/oauth/artifacts"; +import { verifyPkceS256 } from "@/lib/mcp/oauth/pkce"; +import { + createConnection, + rotateConnection, +} from "@/lib/mcp/oauth/connections"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const TOKEN_LIMIT = 120; +const TOKEN_WINDOW_MS = 15 * 60 * 1000; + +function oauthError( + error: string, + description: string, + status = 400, +): Response { + return Response.json( + { error, error_description: description }, + { status, headers: { "Cache-Control": "no-store", Pragma: "no-cache" } }, + ); +} + +function tokenSuccess(body: Record): Response { + return Response.json(body, { + headers: { "Cache-Control": "no-store", Pragma: "no-cache" }, + }); +} + +/** Claim a one-time-use jti through the atomic limiter (limit 1 ⇒ single use). */ +async function claimJti( + kind: string, + jti: string, + ttlMs: number, +): Promise { + if (typeof jti !== "string" || jti.length === 0) return false; + const rl = await checkRateLimit(`oauth:jti:${kind}:${jti}`, 1, ttlMs); + return rl.allowed; +} + +interface AuthCodeClaims { + jti: string; + sub: string; + client_id: string; + client_name?: string; + redirect_uri: string; + code_challenge: string; + scope: string; + resource: string; +} + +interface RefreshClaims { + jti: string; + /** Connection id — the persistent, revocable anchor for the chain (H2). */ + cid?: string; + sub: string; + client_id: string; + scope: string; + resource: string; +} + +/** + * Mint the access token (+ refresh artifact when `offline_access` is granted). + * + * `connectionId` is the persistent anchor for the refresh chain (H2): the access + * token is linked to it (so a connection revoke kills it) and the refresh + * artifact carries it (`cid`). `refreshJti` is the connection's new + * `currentJti` — for the `authorization_code` grant the caller has just created + * the connection seeded with it; for `refresh_token` the caller has just + * advanced the connection to it. + */ +async function mintTokenPair(args: { + userId: string; + clientId: string; + scope: string; + resource: string; + grant: "authorization_code" | "refresh_token"; + connectionId?: string; + refreshJti?: string; +}): Promise { + // Grant EXACTLY the scopes the authorization code carried (read, or + // read+write). `health:read` is always present; `health:write` is added + // only when the user consented to it at the authorize step. The token is + // never widened beyond the approved grant, and `offline_access` is a + // refresh-continuity marker, not an API permission. + const grantedScopes = new Set(args.scope.split(/\s+/).filter(Boolean)); + const permissions = [SCOPE_HEALTH_READ]; + if (grantedScopes.has(SCOPE_HEALTH_WRITE)) { + permissions.push(SCOPE_HEALTH_WRITE); + } + + const access = await issueApiToken({ + userId: args.userId, + name: "MCP connector", + permissions, + expiresInMinutes: ACCESS_TOKEN_TTL_MINUTES, + ...(args.connectionId ? { mcpConnectionId: args.connectionId } : {}), + }); + + const refresh = + args.connectionId && args.refreshJti + ? signArtifact( + "refreshToken", + { + jti: args.refreshJti, + cid: args.connectionId, + sub: args.userId, + client_id: args.clientId, + scope: args.scope, + resource: args.resource, + }, + REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000, + ) + : undefined; + + await auditLog("mcp.oauth.token.issued", { + userId: args.userId, + details: { client_id: args.clientId, grant: args.grant, scope: args.scope }, + }); + annotate({ + action: { name: "mcp.oauth.token.issued" }, + meta: { grant: args.grant }, + }); + + return tokenSuccess({ + access_token: access.token, + token_type: "Bearer", + expires_in: ACCESS_TOKEN_TTL_MINUTES * 60, + scope: args.scope, + ...(refresh ? { refresh_token: refresh } : {}), + }); +} + +export async function POST(request: NextRequest): Promise { + return withBackgroundEvent("mcp.oauth.token", async () => { + // M1 — fail closed without a pinned origin; M4 — honour the operator's + // global API kill-switch. Either off and the bridge mints no tokens. + if (!isMcpOriginConfigured() || !(await isApiGloballyEnabled())) { + return oauthError( + "temporarily_unavailable", + "The MCP OAuth surface is unavailable", + 503, + ); + } + + const rl = await checkAuthSurfaceRateLimit( + request, + "mcp:oauth:token", + TOKEN_LIMIT, + TOKEN_WINDOW_MS, + ); + if (!rl.allowed) { + return Response.json( + { error: "temporarily_unavailable" }, + { + status: 429, + headers: rateLimitHeaders({ + allowed: false, + remaining: rl.remaining, + resetAt: rl.resetAt, + }), + }, + ); + } + + let form: FormData; + try { + form = await request.formData(); + } catch { + return oauthError( + "invalid_request", + "Expected application/x-www-form-urlencoded body", + ); + } + const get = (k: string): string | undefined => { + const v = form.get(k); + return typeof v === "string" ? v : undefined; + }; + + const grantType = get("grant_type"); + + if (grantType === "authorization_code") { + const code = get("code"); + const verifier = get("code_verifier"); + const clientId = get("client_id"); + const redirectUri = get("redirect_uri"); + + if (!code || !verifier || !clientId || !redirectUri) { + return oauthError( + "invalid_request", + "Missing code, code_verifier, client_id, or redirect_uri", + ); + } + + const verified = verifyArtifact("authCode", code); + if (!verified.ok) { + annotate({ action: { name: "mcp.oauth.token.code_rejected" } }); + return oauthError( + "invalid_grant", + "Authorization code is invalid or expired", + ); + } + const claims = verified.claims; + + // Client + redirect binding. + if ( + claims.client_id !== clientId || + claims.redirect_uri !== redirectUri + ) { + return oauthError( + "invalid_grant", + "Authorization code was issued to a different client/redirect", + ); + } + // Audience: a resource parameter, if present, must also match. + const resourceParam = get("resource"); + if (resourceParam && !audienceMatches(resourceParam, request.url)) { + return oauthError( + "invalid_target", + "resource does not match the bound audience", + ); + } + if (!audienceMatches(claims.resource, request.url)) { + return oauthError( + "invalid_target", + "Authorization code is bound to a different audience", + ); + } + // PKCE. + if (!verifyPkceS256(verifier, claims.code_challenge)) { + annotate({ action: { name: "mcp.oauth.token.pkce_failed" } }); + return oauthError("invalid_grant", "PKCE verification failed"); + } + // Single use — claim the code's jti. + if (!(await claimJti("code", claims.jti, 5 * 60 * 1000))) { + annotate({ action: { name: "mcp.oauth.token.code_replayed" } }); + return oauthError( + "invalid_grant", + "Authorization code has already been used", + ); + } + + // H2 — when offline_access is granted, create the persistent, revocable + // connection anchor seeded with the first refresh jti. Without it the + // refresh chain would be stateless and un-revocable. + const offlineGranted = claims.scope + .split(/\s+/) + .includes("offline_access"); + let connectionId: string | undefined; + let refreshJti: string | undefined; + if (offlineGranted) { + refreshJti = randomUUID(); + connectionId = await createConnection({ + userId: claims.sub, + clientId: claims.client_id, + clientName: claims.client_name ?? "MCP client", + scope: claims.scope, + resource: claims.resource, + jti: refreshJti, + }); + } + + return mintTokenPair({ + userId: claims.sub, + clientId: claims.client_id, + scope: claims.scope, + resource: claims.resource, + grant: "authorization_code", + connectionId, + refreshJti, + }); + } + + if (grantType === "refresh_token") { + const refreshToken = get("refresh_token"); + const clientId = get("client_id"); + if (!refreshToken || !clientId) { + return oauthError( + "invalid_request", + "Missing refresh_token or client_id", + ); + } + + const verified = verifyArtifact( + "refreshToken", + refreshToken, + ); + if (!verified.ok) { + return oauthError( + "invalid_grant", + "Refresh token is invalid or expired", + ); + } + const claims = verified.claims; + if (claims.client_id !== clientId) { + return oauthError( + "invalid_grant", + "Refresh token was issued to a different client", + ); + } + if (!audienceMatches(claims.resource, request.url)) { + return oauthError( + "invalid_target", + "Refresh token is bound to a different audience", + ); + } + + // H2 — rotation against the persistent connection anchor. The connection's + // `currentJti` is the single valid refresh jti; presenting any other (an + // already-rotated artifact) is a replay that revokes the whole connection + // (reuse-detection family revocation). A revoked connection always fails, + // so the settings "revoke" terminates the chain. + if (!claims.cid) { + return oauthError( + "invalid_grant", + "Refresh token is not bound to a connection", + ); + } + const newJti = randomUUID(); + const rotation = await rotateConnection({ + connectionId: claims.cid, + presentedJti: claims.jti, + newJti, + clientId: claims.client_id, + userId: claims.sub, + }); + if (!rotation.ok) { + annotate({ + action: { + name: + rotation.reason === "reuse_detected" + ? "mcp.oauth.token.refresh_reuse_detected" + : "mcp.oauth.token.refresh_rejected", + }, + meta: { reason: rotation.reason }, + }); + return oauthError( + "invalid_grant", + rotation.reason === "reuse_detected" + ? "Refresh token reuse detected; the connection was revoked" + : "Refresh token is invalid, revoked, or already used", + ); + } + + return mintTokenPair({ + userId: claims.sub, + clientId: claims.client_id, + scope: claims.scope, + resource: claims.resource, + grant: "refresh_token", + connectionId: claims.cid, + refreshJti: newJti, + }); + } + + return oauthError( + "unsupported_grant_type", + "Only authorization_code and refresh_token are supported", + ); + }); +} diff --git a/src/app/api/mcp/tokens/[id]/route.ts b/src/app/api/mcp/tokens/[id]/route.ts new file mode 100644 index 000000000..03e18f2f6 --- /dev/null +++ b/src/app/api/mcp/tokens/[id]/route.ts @@ -0,0 +1,44 @@ +/** + * Revoke an MCP connector token. Only the owning user can revoke; the scope + * filter keeps this endpoint to `health:read` tokens so it can never revoke a + * differently-scoped credential by id alone. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { prisma } from "@/lib/db"; +import { SCOPE_HEALTH_READ } from "@/lib/mcp/oauth/config"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const DELETE = apiHandler( + async (_request: Request, { params }: RouteParams) => { + const { user } = await requireAuth(); + annotate({ action: { name: "mcp.tokens.revoke" } }); + + if (!(await isApiGloballyEnabled())) { + return apiError("API is globally disabled", 403); + } + + const { id } = await params; + const token = await prisma.apiToken.findUnique({ where: { id } }); + + if ( + !token || + token.userId !== user.id || + !token.permissions.includes(SCOPE_HEALTH_READ) + ) { + return apiError("Token not found", 404); + } + + await prisma.apiToken.update({ where: { id }, data: { revoked: true } }); + await auditLog("mcp.tokens.revoke", { + userId: user.id, + details: { tokenId: id }, + }); + + return apiSuccess({ revoked: true }); + }, +); diff --git a/src/app/api/mcp/tokens/__tests__/route.test.ts b/src/app/api/mcp/tokens/__tests__/route.test.ts new file mode 100644 index 000000000..9455ccc45 --- /dev/null +++ b/src/app/api/mcp/tokens/__tests__/route.test.ts @@ -0,0 +1,95 @@ +/** + * Guard: the MCP connector mint endpoint mints ONLY the two allowed scope + * shapes — `["health:read"]` (default) or `["health:read","health:write"]` — + * and never a wildcard or any other grant. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + apiToken: { findMany: vi.fn() }, + auditLog: { create: vi.fn() }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` })); +vi.mock("@/lib/app-settings", () => ({ isApiGloballyEnabled: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/auth/issue-token", () => ({ + issueApiToken: vi.fn(), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ get: () => undefined })), +})); + +import { POST } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { issueApiToken } from "@/lib/auth/issue-token"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/mcp/tokens", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); + vi.mocked(issueApiToken).mockResolvedValue({ + token: "hlk_abc", + tokenId: "tok-1", + name: "n", + expiresAt: new Date(), + } as never); +}); + +describe("POST /api/mcp/tokens — scope minting", () => { + it("defaults to read-only [health:read]", async () => { + const res = await POST(postReq({ name: "laptop" })); + expect(res.status).toBe(201); + expect(issueApiToken).toHaveBeenCalledWith( + expect.objectContaining({ permissions: ["health:read"] }), + ); + }); + + it("mints read+write when scope=read_write", async () => { + const res = await POST(postReq({ name: "laptop", scope: "read_write" })); + expect(res.status).toBe(201); + expect(issueApiToken).toHaveBeenCalledWith( + expect.objectContaining({ + permissions: ["health:read", "health:write"], + }), + ); + }); + + it("rejects any scope outside the closed set (no wildcard path)", async () => { + for (const bad of ["*", "admin", "health:write", "write", "read_admin"]) { + vi.mocked(issueApiToken).mockClear(); + const res = await POST(postReq({ name: "x", scope: bad })); + expect(res.status).toBe(422); + expect(issueApiToken).not.toHaveBeenCalled(); + } + }); + + it("never mints a wildcard for any input", async () => { + await POST(postReq({ name: "a" })); + await POST(postReq({ name: "b", scope: "read_write" })); + for (const call of vi.mocked(issueApiToken).mock.calls) { + expect(call[0].permissions).not.toContain("*"); + } + }); +}); diff --git a/src/app/api/mcp/tokens/route.ts b/src/app/api/mcp/tokens/route.ts new file mode 100644 index 000000000..dfa644806 --- /dev/null +++ b/src/app/api/mcp/tokens/route.ts @@ -0,0 +1,122 @@ +/** + * MCP connector tokens — list + mint. + * + * The MCP settings card mints a DEDICATED `health:read`-scoped Bearer here (NOT + * the `medication:ingest` scope the generic `/api/tokens` mints, and NEVER the + * `["*"]` wildcard). The raw `hlk_` value is shown once. This is the manual / + * stdio / power-user path; the OAuth bridge (`/api/mcp/oauth/*`) mints the same + * `health:read` scope automatically for cloud connectors. Both surface here so + * the user can see and revoke every credential that can read over MCP. + * + * `userId` is always narrowed from the session — never a body field. The + * caller may choose ONE of exactly two shapes via `scope`: `read` → + * `["health:read"]` (default) or `read_write` → `["health:read", + * "health:write"]`. The `permissions` array is built explicitly from that + * closed choice, so this endpoint can never be coerced into minting a + * wildcard or any other grant (no mass assignment). A `health:write` token is + * still audience-bound to `/mcp` (the resource-server guard refuses it on + * every REST write) — it only admits the confirmed in-process write tools. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { issueApiToken } from "@/lib/auth/issue-token"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { prisma } from "@/lib/db"; +import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "@/lib/mcp/oauth/config"; + +const createSchema = z.object({ + name: z.string().min(1, "Name required").max(100), + expiresInDays: z.number().int().min(1).max(365).optional(), + // The ONLY two scope shapes this endpoint will mint. `read` (default) is the + // least-privilege read token; `read_write` adds `health:write` so the + // confirmed `/mcp` write tools become available. Anything else is rejected + // by the enum — the endpoint can never mint a wildcard or arbitrary grant. + scope: z.enum(["read", "read_write"]).optional().default("read"), +}); + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "mcp.tokens.list" } }); + + if (!(await isApiGloballyEnabled())) { + return apiError("API is globally disabled", 403); + } + + // M2 — list only manually-minted connector tokens. OAuth access rows + // (`mcpConnectionId != null`) are transient 60-minute credentials that would + // flood this list; they are surfaced (and revoked) as connections instead. + const tokens = await prisma.apiToken.findMany({ + where: { + userId: user.id, + permissions: { has: SCOPE_HEALTH_READ }, + mcpConnectionId: null, + }, + select: { + id: true, + name: true, + permissions: true, + lastUsedAt: true, + expiresAt: true, + createdAt: true, + revoked: true, + }, + orderBy: { createdAt: "desc" }, + }); + + return apiSuccess(tokens); +}); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "mcp.tokens.create" } }); + + if (!(await isApiGloballyEnabled())) { + return apiError("API is globally disabled", 403); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = createSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + // Build the permission array explicitly from the closed scope choice — + // never spread, never wildcard. `read` → read-only; `read_write` → read plus + // the `/mcp`-audience-bound write scope. + const permissions = + parsed.data.scope === "read_write" + ? [SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE] + : [SCOPE_HEALTH_READ]; + + const issued = await issueApiToken({ + userId: user.id, + name: parsed.data.name, + permissions, + expiresInDays: parsed.data.expiresInDays ?? 90, + }); + + await auditLog("mcp.tokens.create", { + userId: user.id, + details: { tokenId: issued.tokenId, scope: permissions.join(" ") }, + }); + + // Return the raw token ONCE — it can never be retrieved again. + return apiSuccess( + { token: issued.token, name: issued.name, expiresAt: issued.expiresAt }, + 201, + ); +}); diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts new file mode 100644 index 000000000..286daab63 --- /dev/null +++ b/src/app/mcp/__tests__/route.test.ts @@ -0,0 +1,368 @@ +/** + * Remote MCP endpoint (`/mcp`) — transport + gate tests. + * + * Drives the route handler with Web-standard `Request`s and asserts the + * four front gates plus the read-only / admin-unreachable posture over the + * HTTP wire: + * + * - serves the tool list with a valid token (module on, under budget); + * - rejects a missing / malformed / rejected (revoked / expired / unknown) + * token with one blunt 401; + * - hides the surface (404) when the off-by-default `mcp` module is off; + * - trips 429 when the per-credential rate limit is exhausted; + * - never consults a cookie session, so the cookie-only `requireAdmin()` + * boundary is unreachable here regardless of token scope — a `["*"]` + * wildcard token still sees only the read tools (ADR-003 / ADR-005). + * + * The Bearer validator, module gate, and rate limiter are mocked so the + * test exercises the route's own wiring without a DB; the structural + * cookie-only admin boundary itself is pinned by the sibling + * `src/lib/mcp/__tests__/admin-boundary.test.ts`. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// The surface fails closed without a pinned origin (M1); pin it for the suite. +process.env.APP_URL = "http://localhost"; + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/app-settings", () => ({ + isApiGloballyEnabled: vi.fn(async () => true), +})); +vi.mock("@/lib/auth/bearer", () => ({ + resolveBearerToken: vi.fn(), + BearerAuthError: class BearerAuthError extends Error {}, +})); +vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkMcpRateLimit: vi.fn(), + rateLimitHeaders: vi.fn((r: { remaining: number; resetAt: number }) => ({ + "X-RateLimit-Remaining": String(r.remaining), + "X-RateLimit-Reset": new Date(r.resetAt).toISOString(), + })), +})); +// The factory pulls the Coach retrieval layer through `tools.ts`; stub the +// heavy executor + inventory so importing the route never reaches a DB. The +// list/reject paths never execute a tool, so the stubs are never called. +vi.mock("@/lib/ai/coach/tools/executor", () => ({ executeCoachTool: vi.fn() })); +vi.mock("@/lib/ai/coach/tools/inventory", () => ({ + buildCoachDataInventory: vi.fn(), +})); +// A cookie path must NEVER be reached from the MCP wire. Mock both cookie +// sources so the test can assert they are never consulted. +const getSession = vi.fn(); +vi.mock("@/lib/auth/session", () => ({ getSession })); +const cookiesGet = vi.fn(() => undefined); +const headersGet = vi.fn(() => null); +vi.mock("next/headers", () => ({ + cookies: vi.fn(async () => ({ + get: cookiesGet, + set: () => {}, + delete: () => {}, + })), + headers: vi.fn(async () => ({ get: headersGet })), +})); + +import { POST, GET } from "../route"; +import { resolveBearerToken } from "@/lib/auth/bearer"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { checkMcpRateLimit } from "@/lib/rate-limit"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; + +const READ_TOOLS = [ + "get_correlations", + "get_labs", + "get_medication_compliance", + "get_metric_series", + "list_metrics", + "search", + "fetch", + // Phase 4 — deep-value reads. + "get_correlation", + "compare_metric", + "get_metric_baseline", + "detect_changepoints", +].sort(); + +/** A valid, narrow-scope (`health:read`) token resolution. */ +function validToken(permissions: string[] = ["health:read"]) { + vi.mocked(resolveBearerToken).mockResolvedValue({ + user: { id: "user-1" } as never, + tokenId: "token-1", + permissions, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }); +} + +function postRpc(body: unknown, headers: Record = {}): Request { + return new Request("http://localhost/mcp", { + method: "POST", + headers: { + authorization: "Bearer hlk_" + "a".repeat(64), + "content-type": "application/json", + accept: "application/json, text/event-stream", + ...headers, + }, + body: JSON.stringify(body), + }); +} + +const TOOLS_LIST = { jsonrpc: "2.0", id: 1, method: "tools/list" }; + +beforeEach(() => { + vi.clearAllMocks(); + cookiesGet.mockReturnValue(undefined); + headersGet.mockReturnValue(null); + // Default happy-path: module on, under budget. + vi.mocked(isModuleEnabled).mockResolvedValue(true); + vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); + vi.mocked(checkMcpRateLimit).mockResolvedValue({ + allowed: true, + remaining: 119, + resetAt: Date.now() + 60_000, + }); +}); + +describe("/mcp — happy path", () => { + it("serves the read-tool list over HTTP with a valid token", async () => { + validToken(); + const res = await POST(postRpc(TOOLS_LIST)); + + expect(res.status).toBe(200); + const body = await res.json(); + const names = (body.result.tools as Array<{ name: string }>) + .map((t) => t.name) + .sort(); + expect(names).toEqual(READ_TOOLS); + + // The gate ran in the documented order: auth → rate-limit → module. + expect(resolveBearerToken).toHaveBeenCalledTimes(1); + expect(checkMcpRateLimit).toHaveBeenCalledWith("user-1:token-1"); + expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "mcp"); + }); + + it("completes the initialize handshake with the HealthLog server identity", async () => { + validToken(); + const res = await POST( + postRpc({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "test", version: "1" }, + }, + }), + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.result.serverInfo.name).toBe("healthlog"); + // Tools + resources are advertised; no admin capability exists. + expect(body.result.capabilities.tools).toBeDefined(); + }); +}); + +describe("/mcp — Origin gate (DNS-rebinding defense, spec PR #1439)", () => { + it("rejects a cross-origin browser request with 403 before auth", async () => { + validToken(); + const res = await POST( + postRpc(TOOLS_LIST, { origin: "https://evil.example" }), + ); + expect(res.status).toBe(403); + // The Origin check runs first — the token table is never consulted. + expect(resolveBearerToken).not.toHaveBeenCalled(); + }); + + it("allows a same-origin request", async () => { + validToken(); + const res = await POST(postRpc(TOOLS_LIST, { origin: "http://localhost" })); + expect(res.status).toBe(200); + }); + + it("allows a request with no Origin header (non-browser MCP client)", async () => { + validToken(); + const res = await POST(postRpc(TOOLS_LIST)); + expect(res.status).toBe(200); + }); +}); + +describe("/mcp — operator kill-switch (M4)", () => { + it("hides the surface (404) when the API is globally disabled", async () => { + validToken(); + vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); + const res = await POST(postRpc(TOOLS_LIST)); + expect(res.status).toBe(404); + // Fails closed before the token table is consulted. + expect(resolveBearerToken).not.toHaveBeenCalled(); + }); +}); + +describe("/mcp — 401 carries the RFC 9728 resource_metadata pointer", () => { + it("includes resource_metadata in the WWW-Authenticate challenge", async () => { + const res = await POST( + new Request("http://localhost/mcp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(TOOLS_LIST), + }), + ); + expect(res.status).toBe(401); + const challenge = res.headers.get("www-authenticate") ?? ""; + expect(challenge).toMatch(/Bearer/); + expect(challenge).toMatch( + /resource_metadata="[^"]*\/\.well-known\/oauth-protected-resource"/, + ); + }); +}); + +describe("/mcp — authentication", () => { + it("rejects a request with no Authorization header (401 + challenge)", async () => { + const res = await POST( + new Request("http://localhost/mcp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(TOOLS_LIST), + }), + ); + + expect(res.status).toBe(401); + expect(res.headers.get("www-authenticate")).toMatch(/Bearer/); + // The token table is never consulted when no token is presented. + expect(resolveBearerToken).not.toHaveBeenCalled(); + // No module read, no rate-limit hit before auth fails. + expect(isModuleEnabled).not.toHaveBeenCalled(); + expect(checkMcpRateLimit).not.toHaveBeenCalled(); + }); + + it("rejects a malformed Authorization header (not Bearer) with 401", async () => { + const res = await POST( + postRpc(TOOLS_LIST, { authorization: "Basic abc123" }), + ); + expect(res.status).toBe(401); + expect(resolveBearerToken).not.toHaveBeenCalled(); + }); + + it("rejects a revoked / expired / unknown token with a blunt 401", async () => { + vi.mocked(resolveBearerToken).mockRejectedValue(new Error("revoked")); + const res = await POST(postRpc(TOOLS_LIST)); + + expect(res.status).toBe(401); + const body = await res.json(); + // No oracle: the body never echoes the rejection reason or the token. + expect(JSON.stringify(body)).not.toMatch(/revoked|expired|hlk_/); + // A rejected token never reaches the module gate or the transport. + expect(isModuleEnabled).not.toHaveBeenCalled(); + }); +}); + +describe("/mcp — module gate (off by default)", () => { + it("hides the surface with 404 when the mcp module is off", async () => { + validToken(); + vi.mocked(isModuleEnabled).mockResolvedValue(false); + + const res = await POST(postRpc(TOOLS_LIST)); + expect(res.status).toBe(404); + // Authenticated first (so the module is resolved per-user), then hidden. + expect(resolveBearerToken).toHaveBeenCalledTimes(1); + expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "mcp"); + }); +}); + +describe("/mcp — rate limit", () => { + it("trips 429 once the per-credential budget is exhausted", async () => { + validToken(); + vi.mocked(checkMcpRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 30_000, + }); + + const res = await POST(postRpc(TOOLS_LIST)); + expect(res.status).toBe(429); + expect(res.headers.get("x-ratelimit-remaining")).toBe("0"); + // The limiter is keyed by the `:` binding. + expect(checkMcpRateLimit).toHaveBeenCalledWith("user-1:token-1"); + // A throttled request never reaches the module gate or the transport. + expect(isModuleEnabled).not.toHaveBeenCalled(); + }); +}); + +describe("/mcp — admin unreachable over HTTP (cookie-only boundary)", () => { + it("never consults a cookie session, even with a wildcard token", async () => { + validToken(["*"]); + const res = await POST(postRpc(TOOLS_LIST)); + + expect(res.status).toBe(200); + const body = await res.json(); + const names = (body.result.tools as Array<{ name: string }>).map( + (t) => t.name, + ); + + // A `["*"]` token carries write capability (`tokenAllowsWrite`), so it + // sees the confirmed write tools alongside the read tools — but NO admin + // tool exists, and admin stays unreachable here by construction. + for (const read of READ_TOOLS) { + expect(names).toContain(read); + } + expect(names).toContain("log_measurement"); + expect(names).toContain("log_mood"); + for (const name of names) { + expect(name).not.toMatch(/admin/i); + } + + // The route authenticates purely by Bearer: a cookie session is never + // read, so `requireAdmin()` (cookie-only) can never be reached here. + expect(getSession).not.toHaveBeenCalled(); + expect(cookiesGet).not.toHaveBeenCalled(); + }); +}); + +describe("/mcp — write tools gate on health:write", () => { + it("a health:read token lists NO write tools", async () => { + validToken(["health:read"]); + const res = await POST(postRpc(TOOLS_LIST)); + expect(res.status).toBe(200); + const body = await res.json(); + const names = (body.result.tools as Array<{ name: string }>).map( + (t) => t.name, + ); + expect(names.sort()).toEqual(READ_TOOLS); + expect(names).not.toContain("log_measurement"); + expect(names).not.toContain("log_mood"); + }); + + it("a health:read+write token can list (and thus invoke) the write tools", async () => { + validToken(["health:read", "health:write"]); + const res = await POST(postRpc(TOOLS_LIST)); + expect(res.status).toBe(200); + const body = await res.json(); + const names = (body.result.tools as Array<{ name: string }>).map( + (t) => t.name, + ); + expect(names).toContain("log_measurement"); + expect(names).toContain("log_mood"); + }); +}); + +describe("/mcp — GET transport", () => { + it("is wired for GET (SSE stream open) behind the same gates", async () => { + validToken(); + const res = await GET( + new Request("http://localhost/mcp", { + method: "GET", + headers: { + authorization: "Bearer hlk_" + "a".repeat(64), + accept: "text/event-stream", + }, + }), + ); + // Auth + gates ran; the transport answers (a stream open or a + // method-specific status), never a 401/404/429 on the happy path. + expect([200, 405]).toContain(res.status); + expect(resolveBearerToken).toHaveBeenCalledTimes(1); + expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "mcp"); + // Release any opened stream so the test process can exit cleanly. + await res.body?.cancel().catch(() => {}); + }); +}); diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts new file mode 100644 index 000000000..db3b25980 --- /dev/null +++ b/src/app/mcp/route.ts @@ -0,0 +1,209 @@ +/** + * Remote MCP endpoint — Streamable HTTP transport. + * + * Exposes the SAME read-only tool / resource layer the stdio transport + * serves (`src/lib/mcp/`) over HTTP, so a remote MCP client (Claude.ai / + * ChatGPT / the hosted-or-iOS crowd) can connect with a manually-minted + * `hlk_` Bearer token before full OAuth lands (ADR-002 / ADR-006). The + * wire protocol — JSON-RPC framing, capability negotiation, the + * GET/POST/DELETE method matrix — is the official SDK's + * `WebStandardStreamableHTTPServerTransport`; this route only owns the four + * HealthLog-specific gates in front of it: + * + * 1. AUTH — `Authorization: Bearer hlk_…` resolved by the SAME validator + * the HTTP edge uses (`resolveMcpAuthContext` → `resolveBearerToken`), + * so revoked / expired / scope semantics never drift between the stdio, + * remote, and REST wires. A cookie is never consulted — the wire + * carries none — so the cookie-only `requireAdmin()` boundary is + * unreachable here by construction (ADR-005 / REQ-SEC-7). No token, + * a malformed header, or a rejected token all answer one blunt 401. + * + * 2. RATE LIMIT — the Postgres limiter keyed by the `:` + * binding (`mcp:`), so a single leaked / shared credential + * cannot drain the account's other tokens (REQ-SEC-9 / REQ-SEC-11). + * + * 3. MODULE GATE — the `mcp` module is OPT-IN (off by default). With the + * module off the surface answers 404 and reveals nothing — a + * self-hoster who never opts in gets zero new exposure (ADR-007 / + * REQ-OPS-1). + * + * 4. TRANSPORT — a fresh stateless server + transport per request + * (`sessionIdGenerator: undefined`, `enableJsonResponse: true`), torn + * down once the buffered JSON-RPC response is built. + * + * Write surface: the factory registers the confirmed write tools + * (`log_measurement` / `log_mood`) ONLY for a `health:write`-scoped session; + * a read-only token never sees them. Writes happen in-process here and are + * audience-bound to `/mcp` — a `health:write` MCP token is refused on every + * REST write/delete by the resource-server guard in `api-handler.ts`, so it + * can never become a general REST write credential. `requireAdmin()` stays + * cookie-only, so admin is unreachable over this wire regardless of token + * scope — including `["*"]` (ADR-003 / ADR-005). + * + * Same-origin / no-CORS by construction: no `Access-Control-Allow-Origin` + * header is emitted anywhere here, matching the app-wide posture. + */ +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; + +import { createMcpServer, resolveMcpAuthContext } from "@/lib/mcp"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { annotate } from "@/lib/logging/context"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { checkMcpRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { + isMcpOriginConfigured, + resolveBaseOrigin, +} from "@/lib/mcp/oauth/config"; +import { wwwAuthenticateChallenge } from "@/lib/mcp/oauth/metadata"; +import type { ModuleKey } from "@/lib/modules/registry"; + +// The Bearer resolver + module gate + rate limiter all touch Prisma, so the +// route must run on the Node runtime, never the Edge runtime. +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const MCP_MODULE: ModuleKey = "mcp"; + +/** + * One blunt 401 for every authentication failure class — no oracle. The + * `WWW-Authenticate` carries the RFC 9728 `resource_metadata` pointer so a + * remote client can discover the Authorization Server and start the OAuth flow + * (REQ-T3). An optional `scope` drives incremental consent (SEP-835). + */ +function unauthorized(request: Request, scope?: string): Response { + return Response.json( + { error: "unauthorized" }, + { + status: 401, + headers: { + "WWW-Authenticate": wwwAuthenticateChallenge(request.url, scope), + }, + }, + ); +} + +/** + * DNS-rebinding defense (MCP spec PR #1439): reject a request whose `Origin` + * header does not match this deployment's own origin with a 403. A non-browser + * MCP client (Claude.ai / ChatGPT server-side) sends no `Origin`, so the absence + * of the header is allowed; only a present, mismatched browser `Origin` is + * refused — closing the rebinding hole without breaking the real clients. + */ +function originAllowed(request: Request): boolean { + const origin = request.headers.get("origin"); + if (!origin) return true; + return origin === resolveBaseOrigin(request.url); +} + +function forbiddenOrigin(): Response { + return Response.json({ error: "forbidden_origin" }, { status: 403 }); +} + +/** + * Module off → the surface does not exist for this account. A 404 (not a + * 403) keeps the remote endpoint invisible until the operator / user opts + * in, so a disabled instance leaks nothing about it. + */ +function moduleDisabled(): Response { + return Response.json({ error: "not_found" }, { status: 404 }); +} + +/** 429 with the standard rate-limit headers. */ +function tooManyRequests(resetAt: number, remaining: number): Response { + const response = Response.json({ error: "rate_limited" }, { status: 429 }); + for (const [k, v] of Object.entries( + rateLimitHeaders({ allowed: false, remaining, resetAt }), + )) { + response.headers.set(k, v); + } + return response; +} + +/** Extract the raw token from an `Authorization: Bearer ` header. */ +function readBearer(request: Request): string | null { + const header = request.headers.get("authorization"); + if (!header) return null; + const match = /^Bearer\s+(.+)$/i.exec(header.trim()); + if (!match) return null; + const token = match[1].trim(); + return token.length > 0 ? token : null; +} + +async function handleMcp(request: Request): Promise { + return withBackgroundEvent("mcp.request", async () => { + // 0. ORIGIN — refuse a cross-origin browser request before anything else. + if (!originAllowed(request)) { + annotate({ action: { name: "mcp.origin.rejected" } }); + return forbiddenOrigin(); + } + + // 0a. KILL-SWITCH + ORIGIN CONFIG — M1 fails closed without a pinned + // origin; M4 honours the operator's global API switch. Both answer the + // same invisible 404 as the module-off posture so a disabled instance + // leaks nothing. + if (!isMcpOriginConfigured() || !(await isApiGloballyEnabled())) { + annotate({ action: { name: "mcp.surface.unavailable" } }); + return moduleDisabled(); + } + + // 1. AUTH — Bearer only; a cookie is never consulted (admin unreachable). + const raw = readBearer(request); + if (!raw) { + annotate({ action: { name: "mcp.auth.missing" } }); + return unauthorized(request); + } + + let ctx; + try { + ctx = await resolveMcpAuthContext(raw); + } catch { + // Do not echo the token or the rejection reason — one blunt 401. + annotate({ action: { name: "mcp.auth.rejected" } }); + return unauthorized(request); + } + + // 2. RATE LIMIT — per `:` credential binding. + const rl = await checkMcpRateLimit(ctx.binding); + if (!rl.allowed) { + annotate({ action: { name: "mcp.rate_limited" } }); + return tooManyRequests(rl.resetAt, rl.remaining); + } + + // 3. MODULE GATE — off by default; hide the surface entirely when off. + if (!(await isModuleEnabled(ctx.userId, MCP_MODULE))) { + annotate({ action: { name: "mcp.module.disabled" } }); + return moduleDisabled(); + } + + // 4. TRANSPORT — stateless: one fresh server + transport per request. + annotate({ + action: { name: "mcp.request.served" }, + meta: { method: request.method }, + }); + + const server = createMcpServer(ctx); + const transport = new WebStandardStreamableHTTPServerTransport({ + // Stateless mode — no server-held session; each request stands alone. + sessionIdGenerator: undefined, + // Buffer a single JSON-RPC response instead of opening an SSE stream; + // the read tools return one structured result per call. + enableJsonResponse: true, + }); + + try { + await server.connect(transport); + // In JSON-response stateless mode the returned Response is fully + // buffered before this resolves, so tearing the transport down in the + // `finally` cannot truncate it. + return await transport.handleRequest(request); + } finally { + await transport.close().catch(() => {}); + await server.close().catch(() => {}); + } + }); +} + +export const GET = handleMcp; +export const POST = handleMcp; +export const DELETE = handleMcp; diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 9ce6a1129..ad65b2c60 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -17,6 +17,7 @@ import { IntegrationsSection } from "@/components/settings/integrations-section" import { LabsSection } from "@/components/settings/labs-section"; import { LayoutSection } from "@/components/settings/layout-section"; import { MedicationsSection } from "@/components/settings/medications-section"; +import { McpSection } from "@/components/settings/mcp-section"; import { ModulesSection } from "@/components/settings/modules-section"; import { MoodSection } from "@/components/settings/mood-section"; import { NotificationsSection } from "@/components/settings/notifications-section"; @@ -76,6 +77,7 @@ const SECTION_COMPONENTS: Record< vorsorge: VorsorgeSection, thresholds: ThresholdsSection, api: ApiSection, + mcp: McpSection, gesundheitsakte: GesundheitsakteSection, sharing: SharingSection, export: ExportSection, diff --git a/src/cli/mcp-stdio.ts b/src/cli/mcp-stdio.ts new file mode 100644 index 000000000..880fc1efc --- /dev/null +++ b/src/cli/mcp-stdio.ts @@ -0,0 +1,65 @@ +/** + * Local stdio entrypoint for the HealthLog MCP server. + * + * A power-user runs this in a plain Node process and points an MCP client + * (Claude Desktop, etc.) at it. Authentication is a pasted, scoped `hlk_` + * Bearer token — no OAuth infrastructure (REQ-T1). The token is read from the + * `HEALTHLOG_MCP_TOKEN` environment variable (preferred — keeps the secret out + * of the process argument list) or, as a fallback, the first CLI argument. + * + * Off by default: with no token the server refuses to start. Running it with a + * minted token IS the opt-in. + * + * Run it with tsx (the standalone production image strips tsx, so this is a + * local-only tool): + * + * HEALTHLOG_MCP_TOKEN=hlk_... \ + * API_TOKEN_HMAC_KEY=... DATABASE_URL=... ENCRYPTION_KEYS=... \ + * pnpm dlx tsx src/cli/mcp-stdio.ts + * + * stdout is the JSON-RPC channel — every diagnostic goes to stderr so it can + * never corrupt the protocol stream. + */ +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import { createMcpServer } from "@/lib/mcp/server"; +import { resolveMcpAuthContext } from "@/lib/mcp/auth"; + +function readToken(): string | null { + const fromEnv = process.env.HEALTHLOG_MCP_TOKEN?.trim(); + if (fromEnv) return fromEnv; + const fromArg = process.argv[2]?.trim(); + if (fromArg) return fromArg; + return null; +} + +async function main(): Promise { + const token = readToken(); + if (!token) { + console.error( + "HealthLog MCP: no token. Set HEALTHLOG_MCP_TOKEN (a scoped hlk_ Bearer token) or pass it as the first argument.", + ); + process.exit(1); + } + + let ctx; + try { + ctx = await resolveMcpAuthContext(token); + } catch { + // Do not echo the token or the underlying reason to stderr. + console.error("HealthLog MCP: token rejected."); + process.exit(1); + } + + const server = createMcpServer(ctx); + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error( + `HealthLog MCP server running on stdio (session ${ctx.binding}).`, + ); +} + +main().catch((error) => { + console.error("HealthLog MCP: fatal error during startup.", error); + process.exit(1); +}); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index 259e9ba46..745ed8e45 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -117,6 +117,7 @@ describe("SETTINGS_SECTION_SLUGS", () => { "ai", "coach", "api", + "mcp", "gesundheitsakte", "sharing", "export", diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx new file mode 100644 index 000000000..0b79f081a --- /dev/null +++ b/src/components/settings/mcp-section.tsx @@ -0,0 +1,512 @@ +"use client"; + +/** + * `` — Settings → MCP connector. v1.22.0. + * + * One card co-locating the two things a self-hoster needs to connect an external + * assistant (Claude.ai / ChatGPT) to their own data over the remote MCP + * endpoint: + * + * 1. the off-by-default `mcp` module toggle (the opt-in switch that turns the + * `/mcp` surface on for this account — it answers 404 until then), and + * 2. a dedicated `health:read`-scoped token (mint shown once, copy, list with + * last-used, revoke). This is the manual / stdio path; cloud connectors get + * the same scope automatically through the OAuth flow. + * + * The minted token is NEVER `medication:ingest` and NEVER `["*"]` — it is the + * least-privilege read scope and nothing more. + */ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, Copy, Key, Link2, Loader2, Plug, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useAuth } from "@/hooks/use-auth"; +import { formatDate, formatDateTime } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiFetchRaw, apiGet, apiPatch } from "@/lib/api/api-fetch"; + +interface McpTokenInfo { + id: string; + name: string; + permissions: string[]; + lastUsedAt: string | null; + expiresAt: string | null; + createdAt: string; + revoked: boolean; +} + +interface McpConnectionInfo { + id: string; + clientName: string; + scope: string; + createdAt: string; + lastUsedAt: string | null; +} + +export function McpSection() { + return ( +
+ + + +
+ ); +} + +function McpConnectionsCard() { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + const queryClient = useQueryClient(); + + const { data: connections } = useQuery({ + queryKey: queryKeys.mcpConnections(), + queryFn: async () => apiGet("/api/mcp/connections"), + enabled: isAuthenticated, + }); + + async function handleRevoke(connectionId: string) { + try { + const res = await apiFetchRaw(`/api/mcp/connections/${connectionId}`, { + method: "DELETE", + }); + if (!res.ok) { + toast.error(t("settings.mcp.connectionRevokeFailed")); + return; + } + queryClient.invalidateQueries({ queryKey: queryKeys.mcpConnections() }); + } catch { + toast.error(t("settings.mcp.connectionRevokeFailed")); + } + } + + const list = connections ?? []; + + return ( + + + +
+ {list.length === 0 ? ( +

+ {t("settings.mcp.noConnections")} +

+ ) : ( +
    + {list.map((conn) => ( +
  • +
    +

    + {conn.clientName} +

    + + {t("settings.mcp.connectionConnected")} + +
    +

    + + {t("settings.tokenTablePermissions")}: + {" "} + {conn.scope} +

    +

    + + {t("settings.mcp.connectionLastUsed")}: + {" "} + {conn.lastUsedAt + ? formatDateTime(conn.lastUsedAt) + : t("settings.mcp.connectionNeverUsed")} +

    + + + + + + + + {t("settings.mcp.connectionRevoke")} + + + {t("settings.mcp.connectionRevokeDescription")} + + + + + {t("common.cancel")} + + handleRevoke(conn.id)} + > + {t("settings.mcp.connectionRevoke")} + + + + +
  • + ))} +
+ )} +
+
+ ); +} + +function McpEnableCard() { + const { t } = useTranslations(); + const { user } = useAuth(); + const queryClient = useQueryClient(); + + // The opt-in module is OFF until explicitly enabled — `user.modules.mcp` is + // the resolved state (true only when the user turned it on). + const enabled = user?.modules?.mcp === true; + const endpoint = + typeof window !== "undefined" ? `${window.location.origin}/mcp` : "/mcp"; + + const toggle = useMutation({ + mutationKey: queryKeys.modulesPrefs(), + mutationFn: async (next: boolean) => + apiPatch("/api/auth/me/modules", { mcp: next }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + toast.success(t("settings.sections.modules.saved")); + }, + onError: () => toast.error(t("settings.sections.modules.error")), + }); + + return ( + + + {t("settings.mcp.enabled")} + + ) : ( + {t("settings.mcp.disabled")} + ) + } + /> + +
+
+ + {t("settings.mcp.enableToggleLabel")} + + toggle.mutate(next)} + aria-label={t("settings.mcp.enableToggleLabel")} + /> +
+ +
+

+ {t("settings.mcp.endpointLabel")} +

+ + {endpoint} + +

+ {t("settings.mcp.connectDescription")} +

+
+
+
+ ); +} + +function McpTokensCard() { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + const queryClient = useQueryClient(); + const [newName, setNewName] = useState(""); + const [allowWrite, setAllowWrite] = useState(false); + const [newToken, setNewToken] = useState(null); + const [creating, setCreating] = useState(false); + const [tokenMsg, setTokenMsg] = useState(null); + const [tokenCopied, setTokenCopied] = useState(false); + + const { data: tokens } = useQuery({ + queryKey: queryKeys.mcpTokens(), + queryFn: async () => apiGet("/api/mcp/tokens"), + enabled: isAuthenticated, + }); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + setCreating(true); + setTokenMsg(null); + setNewToken(null); + try { + const res = await apiFetchRaw("/api/mcp/tokens", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: newName.trim(), + // Closed choice — read-only, or read + the audience-bound write scope. + scope: allowWrite ? "read_write" : "read", + }), + }); + const json = await res.json(); + if (res.ok) { + setNewToken(json.data.token); + setNewName(""); + setAllowWrite(false); + queryClient.invalidateQueries({ queryKey: queryKeys.mcpTokens() }); + } else { + setTokenMsg(json.error || t("common.error")); + } + } catch { + setTokenMsg(t("common.networkError")); + } finally { + setCreating(false); + } + } + + async function handleCopyToken() { + if (!newToken) return; + try { + await navigator.clipboard.writeText(newToken); + setTokenCopied(true); + toast.success(t("settings.tokenCopied")); + setTimeout(() => setTokenCopied(false), 2_000); + } catch { + toast.error(t("settings.tokenCopyFailed")); + } + } + + async function handleRevoke(tokenId: string) { + try { + const res = await apiFetchRaw(`/api/mcp/tokens/${tokenId}`, { + method: "DELETE", + }); + if (!res.ok) { + toast.error(t("settings.tokenRevokeFailed")); + return; + } + queryClient.invalidateQueries({ queryKey: queryKeys.mcpTokens() }); + } catch { + toast.error(t("settings.tokenRevokeFailed")); + } + } + + const activeTokens = (tokens ?? []).filter((tok) => !tok.revoked); + + return ( + + + +
+

+ {t("settings.mcp.scopeNote")} +

+ +
+
+ + {t("settings.mcp.writeScopeToggleLabel")} + + +
+

+ {t("settings.mcp.writeScopeNote")} +

+
+ +
+ setNewName(e.target.value)} + placeholder={t("settings.tokenNamePlaceholder")} + maxLength={100} + className="flex-1" + /> + +
+ + {newToken && ( +
+

+ {t("settings.tokenCreated")} +

+
+ + {newToken} + + +
+
+ )} + + {tokenMsg && ( +

+ {tokenMsg} +

+ )} + +
+

+ {t("settings.activeTokensTitle")} +

+ {activeTokens.length === 0 ? ( +

+ {t("settings.noActiveTokens")} +

+ ) : ( +
    + {activeTokens.map((tok) => { + const isExpired = + tok.expiresAt && new Date(tok.expiresAt) < new Date(); + return ( +
  • +
    +

    + {tok.name} +

    + {isExpired ? ( + + {t("settings.tokenExpired")} + + ) : ( + + {t("settings.tokenActive")} + + )} +
    +

    + + {t("settings.tokenTablePermissions")}: + {" "} + {tok.permissions.join(", ")} +

    +

    + + {t("settings.tokenTableCreated")}: + {" "} + {formatDate(tok.createdAt)} +

    +

    + + {t("settings.tokenTableLastUsed")}: + {" "} + {tok.lastUsedAt + ? formatDateTime(tok.lastUsedAt) + : t("settings.tokenNeverUsed")} +

    + + + + + + + + {t("settings.tokenRevoke")} + + + {t("settings.tokenRevokeDescription")} + + + + + {t("common.cancel")} + + handleRevoke(tok.id)} + > + {t("settings.tokenRevoked")} + + + + +
  • + ); + })} +
+ )} +
+
+
+ ); +} diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index 72db906b7..f6c5a5f43 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -34,6 +34,7 @@ import { FileText, MessageCircleHeart, Moon, + Plug, Pill, Smile, Sparkles, @@ -71,6 +72,8 @@ const MODULE_ICONS: Record = { // v1.18.1 (D3) — medications graduated from CORE to a toggleable module. medications: Pill, doctorReport: FileText, + // v1.22.0 — the remote MCP endpoint (opt-in connectivity module). + mcp: Plug, }; export function ModulesSection() { diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index 3b79c3d26..6adc02f26 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -21,6 +21,7 @@ import { FlaskConical, Info, KeyRound, + Plug, Layers, LayoutDashboard, Link2, @@ -63,6 +64,7 @@ const SLUG_ICON: Record = { ai: Sparkles, coach: Bot, api: KeyRound, + mcp: Plug, gesundheitsakte: FileHeart, sharing: Share2, export: Download, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 96d4e325e..d8770abea 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -77,6 +77,11 @@ export const SETTINGS_SECTION_SLUGS = [ // the coach module. The `ai` entry keeps provider / model / BYOK only. "coach", "api", + // v1.22.0 — the remote MCP connector card: enable the opt-in `mcp` module and + // mint / revoke the dedicated `health:read` token used to connect an external + // assistant (Claude.ai / ChatGPT). Sits next to `api` (both are programmatic + // access surfaces). Not nav-gated — the card carries its own enable toggle. + "mcp", "gesundheitsakte", // v1.18.7 — `sharing` (clinician share links) sits directly after // `gesundheitsakte`, before `export`: minting a time-boxed read-only link diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index e79f0370e..7de882f29 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -27,6 +27,7 @@ import { FlaskConical, Info, KeyRound, + Plug, LayoutDashboard, Layers, Link2, @@ -216,6 +217,10 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ moduleGate: "coach", }, { slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound }, + // v1.22.0 — remote MCP connector. Not module-gated: the card carries its own + // enable toggle (mirroring `gesundheitsakte`), so the entry-point stays + // reachable even with the opt-in module off. + { slug: "mcp", titleKey: "settings.sections.mcp.title", icon: Plug }, // v1.18.0 (S5) — the full health record (PDF + FHIR R4 + zip package) // earns its own home, lifted out of Export & Import. // diff --git a/src/lib/__tests__/require-auth-bearer.test.ts b/src/lib/__tests__/require-auth-bearer.test.ts index a3c248933..b175ace28 100644 --- a/src/lib/__tests__/require-auth-bearer.test.ts +++ b/src/lib/__tests__/require-auth-bearer.test.ts @@ -42,7 +42,7 @@ vi.mock("next/headers", () => ({ // --- Imports use the mocked modules above. --- -import { requireAuth, HttpError } from "../api-handler"; +import { requireAuth, HttpError, apiHandler } from "../api-handler"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { hashToken } from "@/lib/auth/hmac"; @@ -270,6 +270,112 @@ describe("requireAuth — Bearer token path", () => { }); }); +describe("requireAuth — H1 MCP health:read audience binding on REST", () => { + // Drive the FULL apiHandler path so the request method is in event context — + // the H1 check reads it to allow safe reads but block writes. + const route = apiHandler(async (req: Request) => { + await requireAuth(); + return Response.json({ ok: true, method: req.method }); + }); + + function call(method: string): Promise { + setBearerHeader(`Bearer ${RAW_TOKEN}`); + return route( + new Request("https://health.example/api/measurements/batch", { + method, + }) as never, + ); + } + + beforeEach(() => { + vi.mocked(getSession).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never); + }); + + function mockHealthReadToken(): void { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "mcp-token", + userId: "user-1", + permissions: ["health:read"], + revoked: false, + expiresAt: null, + } as never); + } + + it("accepts a health:read token on a GET read route", async () => { + mockHealthReadToken(); + const res = await call("GET"); + expect(res.status).toBe(200); + }); + + it("rejects a health:read token on POST with 403", async () => { + mockHealthReadToken(); + const res = await call("POST"); + expect(res.status).toBe(403); + expect(auditLog).toHaveBeenCalledWith( + "auth.bearer.failure", + expect.objectContaining({ + details: expect.objectContaining({ + reason: "mcp_audience_write_blocked", + }), + }), + ); + }); + + it("rejects a health:read token on DELETE with 403", async () => { + mockHealthReadToken(); + const res = await call("DELETE"); + expect(res.status).toBe(403); + }); + + it("does NOT restrict a wildcard token on POST (not MCP-audience)", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "wild", + userId: "user-1", + permissions: ["*"], + revoked: false, + expiresAt: null, + } as never); + const res = await call("POST"); + expect(res.status).toBe(200); + }); + + it("rejects a health:read+write token on POST with 403 (writes are /mcp-only)", async () => { + // A write-scoped MCP token is STILL audience-bound: its `health:write` + // grant admits the confirmed in-process `/mcp` write tools, never a REST + // write/delete. So it gets the same 403 as a read-only MCP token here. + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "rw", + userId: "user-1", + permissions: ["health:read", "health:write"], + revoked: false, + expiresAt: null, + } as never); + const res = await call("POST"); + expect(res.status).toBe(403); + expect(auditLog).toHaveBeenCalledWith( + "auth.bearer.failure", + expect.objectContaining({ + details: expect.objectContaining({ + reason: "mcp_audience_write_blocked", + }), + }), + ); + }); + + it("accepts a health:read+write token on a GET read route", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "rw", + userId: "user-1", + permissions: ["health:read", "health:write"], + revoked: false, + expiresAt: null, + } as never); + const res = await call("GET"); + expect(res.status).toBe(200); + }); +}); + describe("requireAuth — cookie path remains intact", () => { it("returns the session payload without consulting the Bearer header", async () => { const cookieSession = { diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index cb5886e38..2a8386d6e 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -1,16 +1,42 @@ import { NextRequest, NextResponse } from "next/server"; import { headers } from "next/headers"; import type { User } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; import { WideEventBuilder } from "./logging/event-builder"; import { eventStorage, getEvent } from "./logging/context"; import { emitIfSampled } from "./logging/transports"; import { redactOptional, redactSecrets } from "./logging/redact"; import { getSession } from "./auth/session"; -import { hashToken } from "./auth/hmac"; -import { prisma } from "./db"; 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, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config"; + +/** + * HTTP methods a read-only credential may use on the REST surface. A request + * with any other method (POST / PUT / PATCH / DELETE) is a write. + */ +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 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. + */ +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, + ); +} /** * Custom error class for HTTP errors with status codes. @@ -306,110 +332,78 @@ export async function requireAuth( /** * Authenticate a raw Bearer token against `ApiToken`. - * Annotates the Wide Event with `auth_method: "api_key"` and writes audit-log - * entries for both success and failure. + * + * The validation itself lives in the transport-agnostic `resolveBearerToken` + * (`./auth/bearer`) — the single source of truth shared with the MCP wire. This + * wrapper adds the HTTP-edge concerns: the `auth.bearer.*` audit trail for both + * outcomes and the Wide-Event `auth_method: "bearer"` annotation, then maps the + * result onto the `AuthContext` contract (`session.id` carries the token id). + * + * Authorisation contract (unchanged): a route that declares no + * `requiredPermission` accepts any valid token; one that declares a scope + * accepts wildcard (`["*"]`) tokens and narrow-scope tokens that list it. */ async function authenticateBearer( rawToken: string, requiredPermission: string | undefined, ): Promise { - const tokenHashValue = hashToken(rawToken); - - const apiToken = await prisma.apiToken.findUnique({ - where: { tokenHash: tokenHashValue }, - select: { - id: true, - userId: true, - permissions: true, - revoked: true, - expiresAt: true, - }, - }); - - if (!apiToken) { - auditLog("auth.bearer.failure", { - details: { reason: "unknown_token" }, - }).catch(() => {}); - throw new HttpError(401, "Invalid token"); + let resolution; + try { + resolution = await resolveBearerToken(rawToken, requiredPermission); + } catch (err) { + if (err instanceof BearerAuthError) { + auditLog("auth.bearer.failure", { + userId: err.userId ?? null, + details: { + reason: err.reason, + ...(err.tokenId ? { tokenId: err.tokenId } : {}), + ...(err.reason === "insufficient_permissions" + ? { required: requiredPermission } + : {}), + }, + }).catch(() => {}); + const message = + err.statusCode === 403 + ? "Insufficient permissions" + : err.reason === "expired" + ? "Token expired" + : "Invalid token"; + throw new HttpError(err.statusCode, message); + } + throw err; } - if (apiToken.revoked) { - auditLog("auth.bearer.failure", { - userId: apiToken.userId, - details: { reason: "revoked", tokenId: apiToken.id }, - }).catch(() => {}); - throw new HttpError(401, "Invalid token"); + const { user, tokenId, expiresAt, permissions } = resolution; + + // 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 + // client actually holds, not only during the OAuth exchange. + if (isMcpAudienceToken(permissions)) { + const method = (getEvent()?.getHttpMethod() ?? "").toUpperCase(); + if (!READ_HTTP_METHODS.has(method)) { + auditLog("auth.bearer.failure", { + userId: user.id, + details: { + reason: "mcp_audience_write_blocked", + tokenId, + method: method || "unknown", + }, + }).catch(() => {}); + throw new HttpError(403, "Insufficient permissions"); + } } - if (apiToken.expiresAt && apiToken.expiresAt <= new Date()) { - auditLog("auth.bearer.failure", { - userId: apiToken.userId, - details: { reason: "expired", tokenId: apiToken.id }, - }).catch(() => {}); - throw new HttpError(401, "Token expired"); - } - - // `["*"]` is the wildcard scope: granted to the iOS app on login so it - // can call any authenticated route. Narrow scopes (e.g. - // `["medication:ingest"]`) gate specific routes. - // - // Authorisation contract: - // - Route declares no `requiredPermission`: any valid token passes - // (both wildcard and narrow-scope). The route does not gate on - // scope, so authentication alone is sufficient. - // - Route declares a `requiredPermission`: wildcard tokens pass; - // narrow-scope tokens must list the required permission. - // - // v1.4.25 W10 reconcile (code-review H2): the previous "no - // requiredPermission ⇒ wildcard only" rule misread the contract. - // Routes without a declared scope (`/api/measurements/by-external-ids`, - // `/api/personal-records`, `/api/medications/[id]/glp1`, - // `/api/dashboard/glp1`) 403'd every narrow-scope token even though - // those routes did not intend to restrict by scope at all. Allowing - // any authenticated token through when the route does not declare a - // scope unblocks the v1.5 iOS endpoints while keeping the scoped- - // route gating below intact. - const hasWildcardPermission = apiToken.permissions.includes("*"); - - if ( - requiredPermission && - !hasWildcardPermission && - !apiToken.permissions.includes(requiredPermission) - ) { - auditLog("auth.bearer.failure", { - userId: apiToken.userId, - details: { - reason: "insufficient_permissions", - tokenId: apiToken.id, - required: requiredPermission, - }, - }).catch(() => {}); - throw new HttpError(403, "Insufficient permissions"); - } - - const user = await prisma.user.findUnique({ - where: { id: apiToken.userId }, - }); - - if (!user) { - auditLog("auth.bearer.failure", { - userId: apiToken.userId, - details: { reason: "user_missing", tokenId: apiToken.id }, - }).catch(() => {}); - throw new HttpError(401, "Invalid token"); - } - - // Fire-and-forget: refresh lastUsedAt without blocking the request. - prisma.apiToken - .update({ - where: { id: apiToken.id }, - data: { lastUsedAt: new Date() }, - }) - .catch(() => {}); - auditLog("auth.bearer.success", { userId: user.id, - details: { tokenId: apiToken.id }, + details: { tokenId }, }).catch(() => {}); const evt = getEvent(); @@ -421,13 +415,8 @@ async function authenticateBearer( }); } - // Use the token expiry as the session expiry; fall back to a 30-day window if - // the token has no fixed expiry so the contract `{ expiresAt: Date }` holds. - const expiresAt = - apiToken.expiresAt ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); - return { - session: { id: apiToken.id, expiresAt }, + session: { id: tokenId, expiresAt }, user, }; } diff --git a/src/lib/auth/__tests__/bearer.test.ts b/src/lib/auth/__tests__/bearer.test.ts new file mode 100644 index 000000000..f3dc89159 --- /dev/null +++ b/src/lib/auth/__tests__/bearer.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + apiToken: { findUnique: vi.fn(), update: vi.fn() }, + user: { findUnique: vi.fn() }, + }, +})); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() })); + +import { resolveBearerToken, BearerAuthError } from "../bearer"; +import { prisma } from "@/lib/db"; +import { hashToken } from "@/lib/auth/hmac"; + +const FAKE_HASH = "deadbeefcafef00d"; +const RAW_TOKEN = "hlk_" + "a".repeat(64); +const FAKE_USER = { id: "user-1", role: "USER" }; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(hashToken).mockReturnValue(FAKE_HASH); + vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); +}); + +describe("resolveBearerToken", () => { + it("looks the token up by HMAC hash, never the plaintext", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "token-1", + userId: "user-1", + permissions: ["*"], + revoked: false, + expiresAt: new Date(Date.now() + 86_400_000), + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never); + + const res = await resolveBearerToken(RAW_TOKEN); + + expect(hashToken).toHaveBeenCalledWith(RAW_TOKEN); + expect(prisma.apiToken.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { tokenHash: FAKE_HASH } }), + ); + expect(res.user.id).toBe("user-1"); + expect(res.tokenId).toBe("token-1"); + expect(res.permissions).toEqual(["*"]); + expect(prisma.apiToken.update).toHaveBeenCalledWith({ + where: { id: "token-1" }, + data: { lastUsedAt: expect.any(Date) }, + }); + }); + + it("rejects an unknown token (401)", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null as never); + await expect(resolveBearerToken(RAW_TOKEN)).rejects.toMatchObject({ + statusCode: 401, + reason: "unknown_token", + } satisfies Partial); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("rejects a revoked token (401)", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "token-2", + userId: "user-1", + permissions: [], + revoked: true, + expiresAt: null, + } as never); + await expect(resolveBearerToken(RAW_TOKEN)).rejects.toMatchObject({ + statusCode: 401, + reason: "revoked", + }); + }); + + it("rejects an expired token (401)", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "token-3", + userId: "user-1", + permissions: [], + revoked: false, + expiresAt: new Date(Date.now() - 60_000), + } as never); + await expect(resolveBearerToken(RAW_TOKEN)).rejects.toMatchObject({ + statusCode: 401, + reason: "expired", + }); + }); + + it("rejects a narrow-scope token missing the required permission (403)", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "token-4", + userId: "user-1", + permissions: ["something:else"], + revoked: false, + expiresAt: null, + } as never); + await expect( + resolveBearerToken(RAW_TOKEN, "health:write"), + ).rejects.toMatchObject({ + statusCode: 403, + reason: "insufficient_permissions", + }); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("admits any valid token when no permission is required (REQ-SEC-5)", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "token-5", + userId: "user-1", + permissions: ["medication:ingest"], + revoked: false, + expiresAt: null, + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never); + const res = await resolveBearerToken(RAW_TOKEN); + expect(res.user.id).toBe("user-1"); + }); + + it("admits a wildcard token for any required permission", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "token-6", + userId: "user-1", + permissions: ["*"], + revoked: false, + expiresAt: null, + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never); + const res = await resolveBearerToken(RAW_TOKEN, "health:write"); + expect(res.user.id).toBe("user-1"); + }); +}); diff --git a/src/lib/auth/bearer.ts b/src/lib/auth/bearer.ts new file mode 100644 index 000000000..694f27f14 --- /dev/null +++ b/src/lib/auth/bearer.ts @@ -0,0 +1,144 @@ +/** + * Canonical Bearer-token validation — the single source of truth for resolving + * a raw `hlk_` token to a user. + * + * Extracted from `api-handler.ts` so it can be reused outside a Next.js request + * scope (the local MCP stdio transport runs in a plain Node process and must not + * drag `next/server` / `next/headers` / the Wide-Event machinery into its import + * graph). The HTTP edge (`requireAuth` → `authenticateBearer`) and the MCP edge + * both call this one function, so the revoked / expired / scope / `lastUsedAt` + * semantics can never drift between the two wires. + * + * This module is deliberately lean: it depends only on the HMAC hasher and + * Prisma. Audit-log emission + Wide-Event annotation stay with each caller (the + * HTTP path keeps its existing `auth.bearer.*` audit trail), so this layer adds + * no I/O beyond the two reads the validation needs. + */ +import type { User } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { hashToken } from "@/lib/auth/hmac"; + +/** Why a Bearer token was rejected. Stable handles for audit details. */ +export type BearerAuthReason = + | "unknown_token" + | "revoked" + | "expired" + | "insufficient_permissions" + | "user_missing"; + +/** + * Thrown when a token fails validation. Carries the HTTP status the HTTP edge + * should surface plus the machine `reason` (and, when known, the owning + * `userId` / `tokenId`) so a caller can write a faithful audit entry without + * re-deriving them. + */ +export class BearerAuthError extends Error { + constructor( + public readonly statusCode: 401 | 403, + public readonly reason: BearerAuthReason, + public readonly userId?: string, + public readonly tokenId?: string, + ) { + super(reason); + this.name = "BearerAuthError"; + } +} + +/** The validated identity behind a Bearer token. */ +export interface BearerResolution { + user: User; + /** The `ApiToken` row id (the token-id half of the `:` binding). */ + tokenId: string; + /** The token's granted scopes (`["*"]` = wildcard). */ + permissions: string[]; + /** Token expiry, or a 30-day fallback window when the token has no fixed expiry. */ + expiresAt: Date; +} + +/** + * Resolve a raw `hlk_` token to its user, or throw `BearerAuthError`. + * + * Lookups are by HMAC hash only — the plaintext token is never compared against + * a stored value. When `requiredPermission` is set, a non-wildcard token must + * list it; an unset `requiredPermission` admits any valid token (matching the + * "route declares no scope ⇒ authentication alone is sufficient" contract). + * + * `lastUsedAt` is refreshed fire-and-forget on success. + */ +export async function resolveBearerToken( + rawToken: string, + requiredPermission?: string, +): Promise { + const tokenHashValue = hashToken(rawToken); + + const apiToken = await prisma.apiToken.findUnique({ + where: { tokenHash: tokenHashValue }, + select: { + id: true, + userId: true, + permissions: true, + revoked: true, + expiresAt: true, + }, + }); + + if (!apiToken) { + throw new BearerAuthError(401, "unknown_token"); + } + + if (apiToken.revoked) { + throw new BearerAuthError(401, "revoked", apiToken.userId, apiToken.id); + } + + if (apiToken.expiresAt && apiToken.expiresAt <= new Date()) { + throw new BearerAuthError(401, "expired", apiToken.userId, apiToken.id); + } + + // `["*"]` is the wildcard scope. A route that declares no `requiredPermission` + // accepts any valid token; one that declares a scope accepts wildcard tokens + // and narrow-scope tokens that list that scope. + const hasWildcardPermission = apiToken.permissions.includes("*"); + if ( + requiredPermission && + !hasWildcardPermission && + !apiToken.permissions.includes(requiredPermission) + ) { + throw new BearerAuthError( + 403, + "insufficient_permissions", + apiToken.userId, + apiToken.id, + ); + } + + const user = await prisma.user.findUnique({ + where: { id: apiToken.userId }, + }); + + if (!user) { + throw new BearerAuthError( + 401, + "user_missing", + apiToken.userId, + apiToken.id, + ); + } + + // Fire-and-forget: refresh lastUsedAt without blocking the caller. + prisma.apiToken + .update({ + where: { id: apiToken.id }, + data: { lastUsedAt: new Date() }, + }) + .catch(() => {}); + + const expiresAt = + apiToken.expiresAt ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + + return { + user, + tokenId: apiToken.id, + permissions: apiToken.permissions, + expiresAt, + }; +} diff --git a/src/lib/auth/issue-token.ts b/src/lib/auth/issue-token.ts index 214cb8732..291b34342 100644 --- a/src/lib/auth/issue-token.ts +++ b/src/lib/auth/issue-token.ts @@ -23,6 +23,18 @@ export interface IssueTokenOptions { permissions?: string[]; /** Lifetime in days. */ expiresInDays?: number; + /** + * Lifetime in minutes. When set it WINS over `expiresInDays` — used for the + * short-lived OAuth access tokens the MCP bridge mints (the refresh token + * carries continuity). Ignored when `expiresInDays` alone is supplied. + */ + expiresInMinutes?: number; + /** + * Links this access token to an MCP OAuth connection (H2). Set only by the + * `/api/mcp/oauth` bridge so a connection revoke can kill every access row it + * issued and the connector token list can exclude the transient access rows. + */ + mcpConnectionId?: string; } /** @@ -35,9 +47,10 @@ export async function issueApiToken( ): Promise { const rawToken = `hlk_${randomBytes(32).toString("hex")}`; const tokenHashValue = hashToken(rawToken); - const expiresAt = new Date( - Date.now() + (opts.expiresInDays ?? 90) * 24 * 60 * 60 * 1000, - ); + const expiresAt = + opts.expiresInMinutes !== undefined + ? new Date(Date.now() + opts.expiresInMinutes * 60 * 1000) + : new Date(Date.now() + (opts.expiresInDays ?? 90) * 24 * 60 * 60 * 1000); const created = await prisma.apiToken.create({ data: { @@ -46,6 +59,9 @@ export async function issueApiToken( tokenHash: tokenHashValue, permissions: opts.permissions ?? ["*"], expiresAt, + ...(opts.mcpConnectionId + ? { mcpConnectionId: opts.mcpConnectionId } + : {}), }, select: { id: true }, }); diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 72799e24f..d1939cf09 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -83,6 +83,7 @@ function moduleMap( insights: true, medications: true, doctorReport: true, + mcp: true, }; return { ...base, ...overrides }; } diff --git a/src/lib/http/__tests__/read-capped.test.ts b/src/lib/http/__tests__/read-capped.test.ts new file mode 100644 index 000000000..97d0b525f --- /dev/null +++ b/src/lib/http/__tests__/read-capped.test.ts @@ -0,0 +1,68 @@ +/** + * Bounded body reader (M3) — reject oversized bodies WITHOUT buffering them. + */ +import { describe, it, expect } from "vitest"; + +import { readBodyCapped } from "../read-capped"; + +function streamOf(chunks: Uint8Array[]): ReadableStream { + let i = 0; + return new ReadableStream({ + pull(controller) { + if (i < chunks.length) controller.enqueue(chunks[i++]); + else controller.close(); + }, + }); +} + +const enc = (s: string) => new TextEncoder().encode(s); + +describe("readBodyCapped", () => { + it("returns the decoded text under the cap", async () => { + const res = await readBodyCapped( + { headers: new Headers(), body: streamOf([enc("hello "), enc("world")]) }, + 1024, + ); + expect(res).toEqual({ ok: true, text: "hello world" }); + }); + + it("rejects up front on an oversized Content-Length without consuming the body", async () => { + const body = streamOf([enc("x")]); + let getReaderCalled = false; + const original = body.getReader.bind(body); + body.getReader = ((...args: unknown[]) => { + getReaderCalled = true; + // @ts-expect-error — passthrough spy + return original(...args); + }) as typeof body.getReader; + const res = await readBodyCapped( + { headers: new Headers({ "content-length": "999999" }), body }, + 16, + ); + expect(res).toEqual({ ok: false, reason: "too_large" }); + expect(getReaderCalled).toBe(false); + }); + + it("aborts mid-stream the moment the running total exceeds the cap", async () => { + let chunksPulled = 0; + const body = new ReadableStream({ + pull(c) { + chunksPulled++; + // 10 bytes per chunk; cap is 16, so the 2nd chunk overflows. + c.enqueue(enc("0123456789")); + }, + }); + const res = await readBodyCapped({ headers: new Headers(), body }, 16); + expect(res).toEqual({ ok: false, reason: "too_large" }); + // It must stop pulling once it overflows, not drain the (infinite) stream. + expect(chunksPulled).toBeLessThanOrEqual(2); + }); + + it("treats a null body as empty", async () => { + const res = await readBodyCapped( + { headers: new Headers(), body: null }, + 16, + ); + expect(res).toEqual({ ok: true, text: "" }); + }); +}); diff --git a/src/lib/http/read-capped.ts b/src/lib/http/read-capped.ts new file mode 100644 index 000000000..9db36911a --- /dev/null +++ b/src/lib/http/read-capped.ts @@ -0,0 +1,62 @@ +/** + * Bounded body reader (M3). + * + * `await res.text()` / `await request.text()` buffer the WHOLE body before any + * size check runs, so a hostile peer can stream (or decompress) gigabytes into + * memory for the duration of the request timeout before a post-hoc + * `raw.length > MAX` rejection fires. This helper enforces the cap WHILE + * reading: it rejects up front on an oversized `Content-Length`, then streams + * the body and aborts the moment the running byte count exceeds the ceiling. + * + * Returns `{ ok: false }` on overflow (the caller maps it to its own error); + * returns the decoded UTF-8 text on success. + */ +export type CappedReadResult = + | { ok: true; text: string } + | { ok: false; reason: "too_large" }; + +export async function readBodyCapped( + source: { headers: Headers; body: ReadableStream | null }, + maxBytes: number, +): Promise { + // Up-front reject on a declared oversized length — cheap and stops the read + // before a single byte is buffered. + const declared = source.headers.get("content-length"); + if (declared) { + const n = Number(declared); + if (Number.isFinite(n) && n > maxBytes) { + return { ok: false, reason: "too_large" }; + } + } + + const body = source.body; + if (!body) return { ok: true, text: "" }; + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > maxBytes) { + // Stop pulling immediately — do not buffer past the ceiling. + await reader.cancel().catch(() => {}); + return { ok: false, reason: "too_large" }; + } + chunks.push(value); + } + } finally { + reader.releaseLock?.(); + } + + const merged = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.byteLength; + } + return { ok: true, text: new TextDecoder("utf-8").decode(merged) }; +} diff --git a/src/lib/jobs/__tests__/mcp-token-cleanup-queue.test.ts b/src/lib/jobs/__tests__/mcp-token-cleanup-queue.test.ts new file mode 100644 index 000000000..6215ec22f --- /dev/null +++ b/src/lib/jobs/__tests__/mcp-token-cleanup-queue.test.ts @@ -0,0 +1,58 @@ +/** + * MCP Phase 3 (M2) — mcp-token-cleanup queue registration guard. + * + * Source-text-grep guard like the other queue-wiring checks: assert the queue + * is in `allQueues`, scheduled, and wired to a `boss.work` handler. An + * unregistered queue silently never drains (the recurring v1.4.37 dead-queue + * bug), which would leave expired MCP connector access tokens growing unbounded. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const REGISTRAR_PATH = join( + __dirname, + "..", + "reminder", + "register-maintenance.ts", +); +const workerSource = + readFileSync(REGISTRAR_PATH, "utf8") + + readFileSync( + join(__dirname, "..", "reminder", "cleanup-handlers.ts"), + "utf8", + ); + +describe("reminder-worker — mcp-token-cleanup wiring", () => { + it("imports the cleanup handler from the cleanup-handlers module", () => { + expect(workerSource).toMatch(/\bhandleMcpTokenCleanup\b/); + expect(workerSource).toMatch(/\bMcpTokenCleanupPayload\b/); + }); + + it("registers the mcp-token-cleanup queue in the allQueues loop", () => { + const allQueuesMatch = workerSource.match( + /const allQueues\s*=\s*\[([\s\S]*?)\];/, + ); + expect(allQueuesMatch).not.toBeNull(); + expect(allQueuesMatch![1]).toMatch(/\bMCP_TOKEN_CLEANUP_QUEUE\b/); + }); + + it("schedules the mcp-token-cleanup cron", () => { + expect(workerSource).toMatch( + /\[MCP_TOKEN_CLEANUP_QUEUE,\s*MCP_TOKEN_CLEANUP_CRON\]/, + ); + }); + + it("registers a boss.work handler for the queue", () => { + expect(workerSource).toMatch( + /boss\.work[\s\S]{0,200}MCP_TOKEN_CLEANUP_QUEUE[\s\S]{0,200}handleMcpTokenCleanup/, + ); + }); + + it("prunes MCP tokens inside the handler", () => { + expect(workerSource).toMatch( + /handleMcpTokenCleanup[\s\S]{0,400}cleanupExpiredMcpTokens/, + ); + }); +}); diff --git a/src/lib/jobs/mcp-token-cleanup.ts b/src/lib/jobs/mcp-token-cleanup.ts new file mode 100644 index 000000000..d5d5869a9 --- /dev/null +++ b/src/lib/jobs/mcp-token-cleanup.ts @@ -0,0 +1,55 @@ +/** + * Daily cleanup for the MCP OAuth surface (M2). + * + * Every `authorization_code` exchange and every hourly refresh mints a fresh + * 60-minute `hlk_` access-token row through the bridge. Nothing deleted the + * expired/revoked rows, so a single connection refreshing hourly left ~720 dead + * rows per month, forever — unbounded table growth, and (before the connection + * model) a flooded connector list. + * + * This sweep hard-deletes MCP-connector access tokens that are past a short + * grace window after expiry, or revoked. It is scoped to `health:read`-only + * connector rows so it never touches native-client or wildcard tokens. Long- + * revoked connection anchors are pruned too once no access token references + * them, keeping the `mcp_oauth_connections` table bounded. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { SCOPE_HEALTH_READ } from "@/lib/mcp/oauth/config"; + +/** Grace window kept after expiry before a dead access row is deleted. */ +const ACCESS_TOKEN_GRACE_MS = 24 * 60 * 60 * 1000; +/** Revoked connections older than this are pruned (kept briefly for audit). */ +const CONNECTION_PRUNE_MS = 30 * 24 * 60 * 60 * 1000; + +export interface McpTokenCleanupResult { + accessTokensDeleted: number; + connectionsDeleted: number; +} + +export async function cleanupExpiredMcpTokens( + prisma: PrismaClient, +): Promise { + const now = Date.now(); + const expiryCutoff = new Date(now - ACCESS_TOKEN_GRACE_MS); + const connectionCutoff = new Date(now - CONNECTION_PRUNE_MS); + + // Connector access tokens only: permissions is exactly `["health:read"]`. + const access = await prisma.apiToken.deleteMany({ + where: { + permissions: { equals: [SCOPE_HEALTH_READ] }, + OR: [{ revoked: true }, { expiresAt: { lt: expiryCutoff } }], + }, + }); + + // Prune long-revoked connection anchors. SetNull on the access-token FK means + // any lingering linked rows simply lose the pointer; the predicate keeps the + // window generous so an in-flight rotation never races a delete. + const connections = await prisma.mcpOAuthConnection.deleteMany({ + where: { revokedAt: { lt: connectionCutoff } }, + }); + + return { + accessTokensDeleted: access.count, + connectionsDeleted: connections.count, + }; +} diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts index 9ae4568b3..e6530188b 100644 --- a/src/lib/jobs/reminder/cleanup-handlers.ts +++ b/src/lib/jobs/reminder/cleanup-handlers.ts @@ -11,6 +11,7 @@ import { cleanupExpiredWhoopOAuthStates, } from "@/lib/jobs/whoop-oauth-state-cleanup"; import { cleanupExpiredIdempotencyKeys } from "@/lib/jobs/idempotency-cleanup"; +import { cleanupExpiredMcpTokens } from "@/lib/jobs/mcp-token-cleanup"; import { cleanupOldAuditLogs } from "@/lib/jobs/audit-log-cleanup"; import { cleanupOldCoachMessages } from "@/lib/jobs/coach-message-cleanup"; import { cleanupExpiredWithingsOAuthStates } from "@/lib/jobs/withings-oauth-state-cleanup"; @@ -221,6 +222,32 @@ export async function handleCoachMessageCleanup( }); } +export interface McpTokenCleanupPayload { + triggeredAt: string; +} + +export async function handleMcpTokenCleanup( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.mcp_token_cleanup", async (evt) => { + const p = getWorkerPrisma(); + try { + const result = await cleanupExpiredMcpTokens(p); + evt.addMeta( + "mcp_token_cleanup_access_deleted", + result.accessTokensDeleted, + ); + evt.addMeta( + "mcp_token_cleanup_connections_deleted", + result.connectionsDeleted, + ); + } catch (err) { + evt.addWarning(`mcp-token-cleanup failed: ${err}`); + } + }); +} + export async function handleWithingsOAuthStateCleanup( jobs: Job[], ) { diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index 3e3b7c765..ac11b6eec 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -92,6 +92,8 @@ import { handleAuditLogCleanup, CoachMessageCleanupPayload, handleCoachMessageCleanup, + McpTokenCleanupPayload, + handleMcpTokenCleanup, } from "./cleanup-handlers"; import { HostMetricSamplePayload, @@ -188,6 +190,14 @@ const COACH_MESSAGE_CLEANUP_CRON = "50 3 * * *"; // migration converges without a restart. Idempotent + fail-closed per row. const NOTE_ENCRYPTION_BACKFILL_CRON = "55 3 * * *"; +// v1.24 — daily prune of expired/revoked MCP connector access tokens (every +// code exchange + hourly refresh mints a 60-minute row) and long-revoked OAuth +// connection anchors. Slots at 04:00 Europe/Berlin after the note-encryption +// backfill discovery (03:55) so the two don't pile up on the same boss poll. + +const MCP_TOKEN_CLEANUP_QUEUE = "mcp-token-cleanup"; + +const MCP_TOKEN_CLEANUP_CRON = "0 4 * * *"; const allQueues = [ DATA_BACKUP_QUEUE, @@ -239,6 +249,10 @@ const allQueues = [ // corpus to the active key. Without this entry pg-boss never provisions the // queue and the admin trigger silently no-ops. ENCRYPTION_KEY_ROTATE_QUEUE, + // v1.24 — expired/revoked MCP access-token + connection prune. Without this + // entry the daily schedule silently no-ops and the api_tokens table grows + // unbounded with dead 60-minute connector rows. + MCP_TOKEN_CLEANUP_QUEUE, ]; const schedules: ScheduleEntry[] = [ @@ -294,6 +308,8 @@ const schedules: ScheduleEntry[] = [ // The empty cron payload (no `userId`) is the handler's signal to fan out // one per-user job per account still holding a plaintext note. [NOTE_ENCRYPTION_BACKFILL_QUEUE, NOTE_ENCRYPTION_BACKFILL_CRON], + // v1.24 — daily 04:00 Europe/Berlin prune for dead MCP connector tokens. + [MCP_TOKEN_CLEANUP_QUEUE, MCP_TOKEN_CLEANUP_CRON], ]; /** @@ -383,6 +399,13 @@ export async function registerMaintenanceQueues( { localConcurrency: 1 }, handleCoachMessageCleanup, ); + // MCP Phase 3 (M2) — daily prune of dead MCP connector access tokens + + // long-revoked connection anchors. Single-flight like every other cleanup. + await boss.work( + MCP_TOKEN_CLEANUP_QUEUE, + { localConcurrency: 1 }, + handleMcpTokenCleanup, + ); await boss.work( PR_DETECTION_QUEUE, { localConcurrency: PR_DETECTION_CONCURRENCY }, diff --git a/src/lib/logging/event-builder.ts b/src/lib/logging/event-builder.ts index 1b6204d62..26bb8b309 100644 --- a/src/lib/logging/event-builder.ts +++ b/src/lib/logging/event-builder.ts @@ -164,6 +164,11 @@ export class WideEventBuilder { return this.highestLevel; } + /** The HTTP method of the current request, if one was attached. */ + getHttpMethod(): string | undefined { + return this.event.http?.method; + } + getRequestId(): string { return this.event.request_id!; } diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts index 569e11aaa..3f91a101e 100644 --- a/src/lib/logging/redact.ts +++ b/src/lib/logging/redact.ts @@ -89,6 +89,14 @@ export function redactSecrets(input: string): string { // these prefixes (CLAUDE.md headless-client-API patterns); this // is the matching egress guard for log/error surfaces. .replace(/(^|[^A-Za-z0-9])hl[kr]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]") + // v1.22.0 — the MCP OAuth bridge artifacts: the authorization code + // (`hlac_`), refresh token (`hlrt_`), and DCR client id (`hlc_`). All + // are signed, base64url-bodied, and credential-bearing, so they are + // scrubbed at the egress boundary like the native token formats above. + .replace( + /(^|[^A-Za-z0-9])hl(?:ac|rt|c)_[A-Za-z0-9_.-]{16,}/g, + "$1[REDACTED]", + ) // v1.7.0 — `insurance`/`insuranceNumber`/`kvnr` (German statutory- // insurance number) added as defence-in-depth: the value is // encrypted at rest and never deliberately logged, but a stray diff --git a/src/lib/mcp/__tests__/admin-boundary.test.ts b/src/lib/mcp/__tests__/admin-boundary.test.ts new file mode 100644 index 000000000..e1f329aea --- /dev/null +++ b/src/lib/mcp/__tests__/admin-boundary.test.ts @@ -0,0 +1,67 @@ +/** + * Guard: the MCP / Bearer wire can never reach an admin surface (REQ-SEC-7, + * ADR-005). + * + * `requireAdmin()` authorises off a cookie session ONLY. The MCP transports + * authenticate by Bearer token and never mint a cookie session, so in an MCP + * process `getSession()` is always null and `requireAdmin()` always rejects — + * regardless of the token's scope (even `["*"]`). This test pins that structural + * boundary so a future change that softens it fails loudly. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + apiToken: { findUnique: vi.fn(), update: vi.fn() }, + user: { findUnique: vi.fn() }, + }, +})); +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() })); + +const headersGet = vi.fn<(name: string) => string | null>(); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: headersGet })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { requireAdmin } from "@/lib/api-handler"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("requireAdmin is cookie-only — MCP/Bearer cannot elevate", () => { + it("rejects with 401 when there is no cookie session (the MCP reality)", async () => { + vi.mocked(getSession).mockResolvedValue(null); + // Even with a Bearer header present, requireAdmin must not consult it. + headersGet.mockImplementation((name) => + name.toLowerCase() === "authorization" + ? `Bearer hlk_${"a".repeat(64)}` + : null, + ); + + await expect(requireAdmin()).rejects.toMatchObject({ statusCode: 401 }); + // The token table is never touched — admin auth ignores Bearer entirely. + expect(prisma.apiToken.findUnique).not.toHaveBeenCalled(); + }); + + it("rejects a non-admin cookie session with 403", async () => { + vi.mocked(getSession).mockResolvedValue({ + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) }, + user: { id: "user-1", role: "USER" }, + } as never); + + await expect(requireAdmin()).rejects.toMatchObject({ statusCode: 403 }); + }); +}); diff --git a/src/lib/mcp/__tests__/auth.test.ts b/src/lib/mcp/__tests__/auth.test.ts new file mode 100644 index 000000000..c73861031 --- /dev/null +++ b/src/lib/mcp/__tests__/auth.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/auth/bearer", () => ({ + resolveBearerToken: vi.fn(), +})); + +import { resolveMcpAuthContext } from "../auth"; +import { resolveBearerToken } from "@/lib/auth/bearer"; + +const FAKE_USER = { id: "user-1", role: "USER" } as never; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("resolveMcpAuthContext", () => { + it("binds the session to : (REQ-SEC-11) via the canonical Bearer path", async () => { + vi.mocked(resolveBearerToken).mockResolvedValue({ + user: FAKE_USER, + tokenId: "token-9", + permissions: ["health:read"], + expiresAt: new Date(), + }); + + const ctx = await resolveMcpAuthContext(" hlk_abc "); + + // Trimmed token resolved through the shared validator (no requiredPermission for reads). + expect(resolveBearerToken).toHaveBeenCalledWith("hlk_abc"); + expect(ctx.userId).toBe("user-1"); + expect(ctx.tokenId).toBe("token-9"); + expect(ctx.binding).toBe("user-1:token-9"); + expect(ctx.canRead).toBe(true); + expect(ctx.canWrite).toBe(false); + }); + + it("grants write capability only when the token carries health:write", async () => { + vi.mocked(resolveBearerToken).mockResolvedValue({ + user: FAKE_USER, + tokenId: "token-w", + permissions: ["health:write"], + expiresAt: new Date(), + }); + const ctx = await resolveMcpAuthContext("hlk_xyz"); + expect(ctx.canWrite).toBe(true); + }); + + it("rejects an empty token without calling the validator", async () => { + await expect(resolveMcpAuthContext(" ")).rejects.toThrow(); + expect(resolveBearerToken).not.toHaveBeenCalled(); + }); + + it("propagates a rejection from the Bearer validator (invalid / revoked / expired)", async () => { + vi.mocked(resolveBearerToken).mockRejectedValue(new Error("revoked")); + await expect(resolveMcpAuthContext("hlk_bad")).rejects.toThrow(); + }); +}); diff --git a/src/lib/mcp/__tests__/mcp-audience.test.ts b/src/lib/mcp/__tests__/mcp-audience.test.ts new file mode 100644 index 000000000..031623bb4 --- /dev/null +++ b/src/lib/mcp/__tests__/mcp-audience.test.ts @@ -0,0 +1,45 @@ +/** + * 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); + }); +}); diff --git a/src/lib/mcp/__tests__/prompts.test.ts b/src/lib/mcp/__tests__/prompts.test.ts new file mode 100644 index 000000000..92a6b1f74 --- /dev/null +++ b/src/lib/mcp/__tests__/prompts.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/doctor-report-data", () => ({ + collectDoctorReportData: vi.fn(), +})); + +import { MCP_PROMPTS, MCP_PROMPT_NAMES } from "../prompts"; +import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import type { McpAuthContext } from "../auth"; + +const CTX: McpAuthContext = { + userId: "user-1", + tokenId: "token-1", + scopes: ["health:read"], + binding: "user-1:token-1", + canRead: true, + canWrite: false, +}; + +function prompt(name: string) { + const def = MCP_PROMPTS.find((p) => p.name === name); + if (!def) throw new Error(`prompt ${name} not registered`); + return def; +} + +/** Extract the JSON data block the prompt embeds in its user message. */ +function parseData(text: string): Record { + const marker = "JSON):\n"; + const idx = text.indexOf(marker); + if (idx < 0) throw new Error("no data block in prompt message"); + return JSON.parse(text.slice(idx + marker.length)); +} + +/** A minimal but representative doctor-report payload. */ +function reportFixture( + overrides: Record = {}, +): Record { + return { + period: { + days: 90, + since: "2026-03-29T00:00:00.000Z", + start: "2026-03-29T00:00:00.000Z", + end: "2026-06-27T00:00:00.000Z", + }, + patient: { + username: "tester", + fullName: "A Person", + dateOfBirth: "1990-01-01", + gender: "female", + heightCm: 170, + }, + practiceName: null, + measurements: {}, + stats: { + WEIGHT: { avg: 80.4, min: 79, max: 82, count: 30, latest: 80 }, + RESTING_HEART_RATE: { avg: 58, min: 52, max: 66, count: 45, latest: 60 }, + }, + glucoseStats: {}, + glucoseRanges: {}, + glucoseUnit: "mgdl", + glucoseClinical: { stillLearning: true, stillLearningReason: "thin" }, + bmi: 24.1, + compliance: { + Ramipril: { total: 90, taken: 86, skipped: 0, missed: 4 }, + }, + medications: [{ name: "Ramipril", dose: "5 mg", schedules: [] }], + mood: null, + ...overrides, + }; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("MCP prompt registry — surface", () => { + it("registers the doctor_visit_summary prompt with an args schema", () => { + expect([...MCP_PROMPT_NAMES]).toContain("doctor_visit_summary"); + const def = prompt("doctor_visit_summary"); + expect(def.title).toBeTruthy(); + expect(def.description).toBeTruthy(); + expect(def.argsShape).toHaveProperty("window"); + }); +}); + +describe("doctor_visit_summary — grounding + structure", () => { + it("assembles a structured summary from the real doctor-report data", async () => { + vi.mocked(collectDoctorReportData).mockResolvedValue( + reportFixture() as never, + ); + + const res = await prompt("doctor_visit_summary").run(CTX, { + window: "last90days", + }); + + // One user message carrying framing + data. + expect(res.messages).toHaveLength(1); + expect(res.messages[0].role).toBe("user"); + const text = res.messages[0].content.text; + + // The window threads through to the data path (trailing 90 days). + expect(collectDoctorReportData).toHaveBeenCalledWith( + "user-1", + expect.objectContaining({ days: 90 }), + ); + + const data = parseData(text); + const vitals = data.vitals as Array>; + const weight = vitals.find((v) => v.metric === "WEIGHT")!; + const rhr = vitals.find((v) => v.metric === "RESTING_HEART_RATE")!; + + // Units + reference bands ride along, server-side. + expect(weight.unit).toBe("kg"); + expect(weight.referenceBand).toBeNull(); // no universal weight band + expect(rhr.unit).toBe("bpm"); + expect(rhr.referenceBand).toEqual({ low: 50, high: 100 }); + + // Values are the server-computed ones — no fabrication. + expect(weight.latest).toBe(80); + expect(rhr.avg).toBe(58); + + // Compliance is the ledger-derived adherence (86/90 → 96 %). + const compliance = data.compliance as Array>; + expect(compliance[0].adherencePct).toBe(96); + }); + + it("injects the grounding framing — no diagnosis, no invented values", async () => { + vi.mocked(collectDoctorReportData).mockResolvedValue( + reportFixture() as never, + ); + const res = await prompt("doctor_visit_summary").run(CTX, {}); + const text = res.messages[0].content.text; + expect(text).toMatch(/server-side/i); + expect(text).toMatch(/do not.*diagnos/i); + expect(text).toMatch(/do not (invent|estimate|infer)/i); + }); + + it("omits the glucose panel while still learning and mood when absent", async () => { + vi.mocked(collectDoctorReportData).mockResolvedValue( + reportFixture() as never, + ); + const data = parseData( + (await prompt("doctor_visit_summary").run(CTX, {})).messages[0].content + .text, + ); + expect(data).not.toHaveProperty("glucosePanel"); // stillLearning → withheld + expect(data).not.toHaveProperty("mood"); // mood null + }); + + it("includes labs with reference ranges and caps the list (token budget)", async () => { + const labResults = Array.from({ length: 60 }, (_, i) => ({ + panel: null, + analyte: `Analyte-${i}`, + value: i, + valueText: null, + unit: "mg/dL", + referenceLow: 0, + referenceHigh: 100, + takenAt: "2026-06-01T00:00:00.000Z", + count: 1, + })); + vi.mocked(collectDoctorReportData).mockResolvedValue( + reportFixture({ labResults }) as never, + ); + const data = parseData( + (await prompt("doctor_visit_summary").run(CTX, {})).messages[0].content + .text, + ); + const labs = data.labs as Array>; + expect(labs.length).toBe(50); // capped from 60 + expect(labs[0]).toHaveProperty("referenceHigh", 100); + }); + + it("does not leak patient name into the summary", async () => { + vi.mocked(collectDoctorReportData).mockResolvedValue( + reportFixture() as never, + ); + const data = parseData( + (await prompt("doctor_visit_summary").run(CTX, {})).messages[0].content + .text, + ); + const patient = data.patient as Record; + expect(patient).not.toHaveProperty("fullName"); + expect(patient).not.toHaveProperty("username"); + expect(patient.dateOfBirth).toBe("1990-01-01"); + }); +}); diff --git a/src/lib/mcp/__tests__/resources.test.ts b/src/lib/mcp/__tests__/resources.test.ts new file mode 100644 index 000000000..94b25e3f1 --- /dev/null +++ b/src/lib/mcp/__tests__/resources.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + medication: { findMany: vi.fn() }, + }, +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { MCP_RESOURCES, MCP_RESOURCE_URIS } from "../resources"; +import { prisma } from "@/lib/db"; +import type { McpAuthContext } from "../auth"; + +const CTX: McpAuthContext = { + userId: "user-1", + tokenId: "token-1", + scopes: ["health:read"], + binding: "user-1:token-1", + canRead: true, + canWrite: false, +}; + +function resource(uri: string) { + const def = MCP_RESOURCES.find((r) => r.uri === uri); + if (!def) throw new Error(`resource ${uri} not registered`); + return def; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("MCP resource surface", () => { + it("registers exactly the Phase-1 resources, none admin", () => { + expect([...MCP_RESOURCE_URIS].sort()).toEqual([ + "healthlog://medications", + "healthlog://profile", + ]); + for (const uri of MCP_RESOURCE_URIS) { + expect(uri).not.toMatch(/admin/i); + } + }); +}); + +describe("healthlog://profile", () => { + it("returns minimised, health-relevant profile data and omits identifiers", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + heightCm: 180, + dateOfBirth: new Date("1990-01-01T00:00:00Z"), + gender: "MALE", + timezone: "Europe/Berlin", + unitPreference: "metric", + glucoseUnit: "mmol/L", + } as never); + + const result = (await resource("healthlog://profile").read(CTX)) as Record< + string, + unknown + >; + + // userId is narrowed from the session, never a caller argument. + expect(prisma.user.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: "user-1" } }), + ); + expect(result.present).toBe(true); + expect(result.heightCm).toBe(180); + expect(typeof result.ageYears).toBe("number"); + expect(result.glucoseUnit).toBe("mmol/L"); + // No direct identifiers leak to the assistant. + expect(Object.keys(result)).not.toContain("email"); + expect(Object.keys(result)).not.toContain("username"); + expect(Object.keys(result)).not.toContain("role"); + }); + + it("returns { present: false } when the user row is missing", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + const result = (await resource("healthlog://profile").read(CTX)) as { + present: boolean; + }; + expect(result.present).toBe(false); + }); +}); + +describe("healthlog://medications", () => { + it("lists medications + schedules scoped to the session user", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { + name: "Med A", + dose: "10mg", + treatmentClass: "GENERIC", + asNeeded: false, + pausedAt: null, + startsOn: null, + endsOn: null, + schedules: [ + { + label: "Morning", + dose: null, + windowStart: "08:00", + windowEnd: "10:00", + timesOfDay: ["08:00"], + daysOfWeek: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + }, + ], + }, + ] as never); + + const result = (await resource("healthlog://medications").read( + CTX, + )) as Record; + + expect(prisma.medication.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: "user-1" } }), + ); + expect(result.present).toBe(true); + expect(result.count).toBe(1); + expect(Array.isArray(result.medications)).toBe(true); + }); + + it("returns { present: false } when no medications are tracked", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + const result = (await resource("healthlog://medications").read(CTX)) as { + present: boolean; + count: number; + }; + expect(result.present).toBe(false); + expect(result.count).toBe(0); + }); +}); diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts new file mode 100644 index 000000000..077449238 --- /dev/null +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -0,0 +1,366 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// --- Engine mocks hoisted before importing the module under test. --- +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ + readCoachCorrelations: vi.fn(), +})); +vi.mock("@/lib/insights/derived/coach-read", () => ({ + buildCoachReadStrip: vi.fn(), +})); +// Keep the real `aggregateWmyBuckets` (pure linear composition); stub only the +// DB-backed reader so the rich reads compute over a synthetic bucket sequence. +vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, readBestGranularityRollups: vi.fn() }; +}); + +import { + getCorrelation, + compareMetric, + getMetricBaseline, + detectChangepoints, + resolveRichMetric, +} from "../rich-reads"; +import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; +import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; +import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy"; + +const USER = "user-1"; + +/** Build a rollup bucket row with just the fields the rich reads consume. */ +function row(bucketStart: string, mean: number) { + return { + bucketStart: new Date(bucketStart), + count: 1, + mean, + sd: null, + slope: null, + r2: null, + sumValue: null, + minValue: mean, + maxValue: mean, + }; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +// ── metric resolution ──────────────────────────────────────────────── +describe("resolveRichMetric", () => { + it("resolves a friendly alias to the registry type + unit + band", () => { + const m = resolveRichMetric("resting_hr"); + expect(m?.measurementType).toBe("RESTING_HEART_RATE"); + expect(m?.unit).toBe("bpm"); + expect(m?.band).toEqual({ low: 50, high: 100 }); + }); + + it("resolves the headline supplement metrics (weight has no universal band)", () => { + const weight = resolveRichMetric("weight"); + expect(weight?.measurementType).toBe("WEIGHT"); + expect(weight?.unit).toBe("kg"); + expect(weight?.band).toBeNull(); + expect(resolveRichMetric("pulse")?.band).toEqual({ low: 60, high: 100 }); + }); + + it("matches on display name", () => { + expect(resolveRichMetric("heart-rate variability")?.measurementType).toBe( + "HEART_RATE_VARIABILITY", + ); + }); + + it("returns null for an unknown metric (never invents a series)", () => { + expect(resolveRichMetric("bananas")).toBeNull(); + expect(resolveRichMetric("")).toBeNull(); + }); +}); + +// ── 1. get_correlation ─────────────────────────────────────────────── +describe("get_correlation", () => { + const drivers = [ + { + behaviour: "sleep duration", + outcome: "resting heart rate", + direction: "lower" as const, + lagDays: 1, + n: 60, + r: -0.42, + note: "Nights with more sleep tend to precede a lower next-day resting heart rate. Descriptive, not causal.", + }, + { + behaviour: "steps", + outcome: "weight", + direction: "lower" as const, + lagDays: 1, + n: 40, + r: -0.2, + note: "Descriptive only.", + }, + ]; + + it("returns the matched FDR-controlled pair with a descriptive (non-causal) note", async () => { + vi.mocked(readCoachCorrelations).mockResolvedValue({ + present: true, + drivers, + pairsTested: 24, + windowDays: 180, + }); + const res = await getCorrelation(USER, { + metricA: "sleep", + metricB: "resting heart rate", + }); + expect(res.present).toBe(true); + expect(res.pair?.behaviour).toBe("sleep duration"); + expect(res.pair?.outcome).toBe("resting heart rate"); + expect(res.pair?.r).toBe(-0.42); + expect(res.association).toBe("descriptive"); + expect(res.windowDays).toBe(180); + }); + + it("honest-null when the pair has no surviving association", async () => { + vi.mocked(readCoachCorrelations).mockResolvedValue({ + present: true, + drivers, + pairsTested: 24, + windowDays: 180, + }); + const res = await getCorrelation(USER, { + metricA: "glucose", + metricB: "hrv", + }); + expect(res.present).toBe(false); + expect(res.reason).toBe("no_significant_pattern_for_pair"); + expect(res.pair).toBeUndefined(); + }); + + it("honest-null when the engine surfaces nothing (sparse data)", async () => { + vi.mocked(readCoachCorrelations).mockResolvedValue({ + present: false, + reason: "no_significant_pattern", + }); + const res = await getCorrelation(USER, { + metricA: "sleep", + metricB: "weight", + }); + expect(res.present).toBe(false); + expect(res.reason).toBe("no_significant_pattern"); + }); +}); + +// ── 2. compare_metric ──────────────────────────────────────────────── +describe("compare_metric", () => { + it("compares two metrics over the same window and computes a delta when units match", async () => { + vi.mocked(readBestGranularityRollups) + .mockResolvedValueOnce({ + granularity: "DAY", + rows: [row("2026-06-01", 60), row("2026-06-02", 62)], + }) + .mockResolvedValueOnce({ + granularity: "DAY", + rows: [row("2026-06-01", 70), row("2026-06-02", 72)], + }); + const res = await compareMetric(USER, { + metric: "resting_hr", + metricB: "pulse", + window: "last30days", + }); + expect(res.present).toBe(true); + expect(res.mode).toBe("metric_vs_metric"); + expect(res.a?.mean).toBe(61); + expect(res.b?.mean).toBe(71); + // Both bpm → delta computed. + expect(res.delta?.mean).toBe(10); + expect(res.a?.unit).toBe("bpm"); + }); + + it("compares one metric across two trailing windows", async () => { + vi.mocked(readBestGranularityRollups) + .mockResolvedValueOnce({ + granularity: "DAY", + rows: [row("2026-06-01", 80), row("2026-06-02", 80)], + }) + .mockResolvedValueOnce({ + granularity: "WEEK", + rows: [row("2026-04-01", 82), row("2026-04-08", 84)], + }); + const res = await compareMetric(USER, { + metric: "weight", + window: "last30days", + windowB: "last90days", + }); + expect(res.present).toBe(true); + expect(res.mode).toBe("window_vs_window"); + expect(res.delta?.mean).toBe(3); // 83 - 80 + }); + + it("asks for a second metric or window when neither is given", async () => { + const res = await compareMetric(USER, { metric: "weight" }); + expect(res.present).toBe(false); + expect(res.reason).toBe("specify_metricB_or_windowB"); + }); + + it("honest-null on unknown metric", async () => { + const res = await compareMetric(USER, { + metric: "bananas", + metricB: "weight", + }); + expect(res.present).toBe(false); + expect(res.reason).toBe("unknown_metric"); + }); + + it("present:false when a side has no data", async () => { + vi.mocked(readBestGranularityRollups) + .mockResolvedValueOnce({ + granularity: "DAY", + rows: [row("2026-06-01", 80)], + }) + .mockResolvedValueOnce(null); + const res = await compareMetric(USER, { + metric: "weight", + window: "last30days", + windowB: "last90days", + }); + expect(res.present).toBe(false); + expect(res.reason).toBe("no_data"); + }); +}); + +// ── 3. get_metric_baseline ─────────────────────────────────────────── +describe("get_metric_baseline", () => { + it("returns the personal band + today's placement + reference band", async () => { + vi.mocked(buildCoachReadStrip).mockResolvedValue({ + baseline: { + low: 55, + high: 65, + latest: 70, + placement: "above", + sampleDays: 30, + }, + learning: false, + driver: { + note: "Descriptive only.", + behaviour: "sleep duration", + outcome: "resting heart rate", + }, + }); + const res = await getMetricBaseline(USER, { metric: "resting_hr" }); + expect(res.present).toBe(true); + expect(res.baseline).toEqual({ low: 55, high: 65, sampleDays: 30 }); + expect(res.latest).toBe(70); + expect(res.placement).toBe("above"); + expect(res.unit).toBe("bpm"); + expect(res.referenceBand).toEqual({ low: 50, high: 100 }); + }); + + it("honest-null below the learning floor (never a fabricated range)", async () => { + vi.mocked(buildCoachReadStrip).mockResolvedValue({ + baseline: null, + learning: true, + driver: null, + }); + const res = await getMetricBaseline(USER, { metric: "weight" }); + expect(res.present).toBe(false); + expect(res.reason).toBe("insufficient_history"); + // The personal band is absent — no invented numbers. + expect(res.baseline).toBeUndefined(); + }); + + it("honest-null on unknown metric", async () => { + const res = await getMetricBaseline(USER, { metric: "bananas" }); + expect(res.present).toBe(false); + expect(res.reason).toBe("unknown_metric"); + }); +}); + +// ── 4. detect_changepoints ─────────────────────────────────────────── +describe("detect_changepoints", () => { + it("detects a clear level shift over the rollup buckets", async () => { + // Twelve buckets: ~80 for six, then a clean jump to ~90 for six. + const rows = [ + row("2026-03-01", 80), + row("2026-03-02", 81), + row("2026-03-03", 79), + row("2026-03-04", 80), + row("2026-03-05", 81), + row("2026-03-06", 80), + row("2026-03-07", 90), + row("2026-03-08", 91), + row("2026-03-09", 89), + row("2026-03-10", 90), + row("2026-03-11", 91), + row("2026-03-12", 90), + ]; + vi.mocked(readBestGranularityRollups).mockResolvedValue({ + granularity: "DAY", + rows, + }); + const res = await detectChangepoints(USER, { + metric: "weight", + window: "last90days", + }); + expect(res.present).toBe(true); + expect(res.changepoints?.length).toBeGreaterThanOrEqual(1); + const cp = res.changepoints![0]; + expect(cp.direction).toBe("increase"); + expect(cp.at).toBe(new Date("2026-03-07").toISOString()); + expect(res.granularity).toBe("DAY"); + }); + + it("honest-null on a flat (noise-only) series", async () => { + const rows = Array.from({ length: 12 }, (_, i) => + row(`2026-03-${String(i + 1).padStart(2, "0")}`, 80 + (i % 2)), + ); + vi.mocked(readBestGranularityRollups).mockResolvedValue({ + granularity: "DAY", + rows, + }); + const res = await detectChangepoints(USER, { metric: "weight" }); + expect(res.present).toBe(false); + expect(res.reason).toBe("no_changepoint"); + expect(res.bucketsAnalysed).toBe(12); + }); + + it("honest-null when too few buckets exist", async () => { + vi.mocked(readBestGranularityRollups).mockResolvedValue({ + granularity: "DAY", + rows: [row("2026-03-01", 80), row("2026-03-02", 81)], + }); + const res = await detectChangepoints(USER, { metric: "weight" }); + expect(res.present).toBe(false); + expect(res.reason).toBe("insufficient_data"); + }); + + it("honest-null on unknown metric", async () => { + const res = await detectChangepoints(USER, { metric: "bananas" }); + expect(res.present).toBe(false); + expect(res.reason).toBe("unknown_metric"); + }); +}); + +// ── grounding posture ──────────────────────────────────────────────── +describe("grounding / no fabrication", () => { + it("no rich read returns a verdict / diagnosis / advice field", async () => { + vi.mocked(readCoachCorrelations).mockResolvedValue({ present: false }); + vi.mocked(buildCoachReadStrip).mockResolvedValue({ + baseline: null, + learning: true, + driver: null, + }); + vi.mocked(readBestGranularityRollups).mockResolvedValue(null); + + const results = [ + await getCorrelation(USER, { metricA: "sleep", metricB: "weight" }), + await compareMetric(USER, { metric: "weight", windowB: "last90days" }), + await getMetricBaseline(USER, { metric: "weight" }), + await detectChangepoints(USER, { metric: "weight" }), + ]; + for (const res of results) { + const keys = Object.keys(res); + expect(keys).not.toContain("verdict"); + expect(keys).not.toContain("diagnosis"); + expect(keys).not.toContain("advice"); + } + }); +}); diff --git a/src/lib/mcp/__tests__/scopes.test.ts b/src/lib/mcp/__tests__/scopes.test.ts new file mode 100644 index 000000000..0a10753c0 --- /dev/null +++ b/src/lib/mcp/__tests__/scopes.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; + +import { + tokenAllowsWrite, + SCOPE_HEALTH_READ, + SCOPE_HEALTH_WRITE, + SCOPE_WILDCARD, +} from "../scopes"; + +describe("tokenAllowsWrite", () => { + it("is false for a read-only token", () => { + expect(tokenAllowsWrite([SCOPE_HEALTH_READ])).toBe(false); + }); + it("is true for a read+write token", () => { + expect(tokenAllowsWrite([SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE])).toBe( + true, + ); + }); + it("is true for the wildcard token", () => { + expect(tokenAllowsWrite([SCOPE_WILDCARD])).toBe(true); + }); + it("is false for an empty scope set", () => { + expect(tokenAllowsWrite([])).toBe(false); + }); +}); diff --git a/src/lib/mcp/__tests__/search-fetch.test.ts b/src/lib/mcp/__tests__/search-fetch.test.ts new file mode 100644 index 000000000..a9280e939 --- /dev/null +++ b/src/lib/mcp/__tests__/search-fetch.test.ts @@ -0,0 +1,141 @@ +/** + * `search` + `fetch` — the ChatGPT default-mode retrieval pair. + * + * Pins the EXACT wire shapes OpenAI mandates (`{id,title,url}[]` / + * `{id,title,text,url,metadata}`), real deep-link URLs, and the read-only + * annotations. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { z } from "zod/v4"; + +process.env.APP_URL = "https://health.example"; + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/ai/coach/tools/inventory", () => ({ + buildCoachDataInventory: vi.fn(), +})); +vi.mock("@/lib/ai/coach/tools/executor", () => ({ executeCoachTool: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { findMany: vi.fn(), findFirst: vi.fn() }, + labResult: { findMany: vi.fn() }, + }, +})); + +import { MCP_TOOLS } from "../tools"; +import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; +import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; +import { prisma } from "@/lib/db"; +import type { McpAuthContext } from "../auth"; + +const CTX: McpAuthContext = { + userId: "user-1", + tokenId: "token-1", + scopes: ["health:read"], + binding: "user-1:token-1", + canRead: true, + canWrite: false, +}; + +function tool(name: string) { + const def = MCP_TOOLS.find((t) => t.name === name); + if (!def) throw new Error(`tool ${name} not registered`); + return def; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(buildCoachDataInventory).mockResolvedValue({ + entries: [ + { + tool: "get_metric_series", + metric: "weight", + domain: "weight", + present: true, + count: 10, + }, + ], + window: "90d", + restMode: false, + cycleEnabled: false, + } as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { id: "med-1", name: "Ramipril", dose: "5mg" }, + ] as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([ + { analyte: "LDL" }, + ] as never); +}); + +describe("search", () => { + it("returns { results: [{id,title,url}] } with real deep-link URLs", async () => { + const def = tool("search"); + const result = (await def.run(CTX, { query: "" })) as { + results: Array<{ id: string; title: string; url: string }>; + }; + + // Output conforms to the declared schema. + const schema = z.object(def.outputShape!); + expect(schema.safeParse(result).success).toBe(true); + + expect(result.results.length).toBeGreaterThan(0); + for (const r of result.results) { + expect(r.id).toMatch(/^(metric|med|lab|domain):/); + // ChatGPT only cites when `url` is a non-empty absolute URL. + expect(r.url.startsWith("https://health.example/")).toBe(true); + } + // The internal id stays separate from the citation url. + const med = result.results.find((r) => r.id === "med:med-1"); + expect(med?.url).toBe("https://health.example/medications"); + }); + + it("filters by query (case-insensitive substring)", async () => { + const def = tool("search"); + const result = (await def.run(CTX, { query: "ldl" })) as { + results: Array<{ id: string }>; + }; + expect(result.results.map((r) => r.id)).toContain("lab:LDL"); + expect(result.results.some((r) => r.id.startsWith("med:"))).toBe(false); + }); +}); + +describe("fetch", () => { + it("returns { id,title,text,url,metadata } for a metric id", async () => { + vi.mocked(executeCoachTool).mockResolvedValue({ + present: true, + data: { mean: 80 }, + } as never); + const def = tool("fetch"); + const result = (await def.run(CTX, { id: "metric:weight" })) as Record< + string, + unknown + >; + const schema = z.object(def.outputShape!); + expect(schema.safeParse(result).success).toBe(true); + expect(result.id).toBe("metric:weight"); + expect(result.url).toBe("https://health.example/insights"); + expect(typeof result.text).toBe("string"); + }); + + it("returns a not-found shape for an unknown id (still schema-valid)", async () => { + const def = tool("fetch"); + const result = (await def.run(CTX, { id: "bogus" })) as Record< + string, + unknown + >; + const schema = z.object(def.outputShape!); + expect(schema.safeParse(result).success).toBe(true); + expect(result.title).toBe("Not found"); + }); +}); + +describe("annotations", () => { + it("search + fetch are read-only, non-destructive, closed-world", () => { + for (const name of ["search", "fetch"]) { + const a = tool(name).annotations; + expect(a.readOnlyHint).toBe(true); + expect(a.destructiveHint).toBe(false); + expect(a.openWorldHint).toBe(false); + } + }); +}); diff --git a/src/lib/mcp/__tests__/server-write-registration.test.ts b/src/lib/mcp/__tests__/server-write-registration.test.ts new file mode 100644 index 000000000..a78430431 --- /dev/null +++ b/src/lib/mcp/__tests__/server-write-registration.test.ts @@ -0,0 +1,62 @@ +/** + * Guard: the confirmed write tools are advertised ONLY to a write-scoped + * session. A read-only (`health:read`) session must see zero write tools — the + * read-only-by-default posture is structural, not a runtime flag. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { createMcpServer } from "../server"; +import { MCP_WRITE_TOOL_NAMES } from "../write-tools"; +import type { McpAuthContext } from "../auth"; + +function ctx(canWrite: boolean): McpAuthContext { + return { + userId: "u-1", + tokenId: "t-1", + scopes: canWrite ? ["health:read", "health:write"] : ["health:read"], + binding: "u-1:t-1", + canRead: true, + canWrite, + }; +} + +let registered: string[]; + +beforeEach(() => { + registered = []; + vi.spyOn(McpServer.prototype, "registerTool").mockImplementation(function ( + this: unknown, + name: string, + ) { + registered.push(name); + return {} as never; + }); + // Prompts / resources registration is irrelevant here — stub to no-ops. + vi.spyOn(McpServer.prototype, "registerPrompt").mockReturnValue({} as never); + vi.spyOn(McpServer.prototype, "registerResource").mockReturnValue( + {} as never, + ); +}); + +describe("write-tool registration gating", () => { + it("a read-only session sees NO write tools", () => { + createMcpServer(ctx(false)); + for (const name of MCP_WRITE_TOOL_NAMES) { + expect(registered).not.toContain(name); + } + // It still registers read tools. + expect(registered).toContain("list_metrics"); + }); + + it("a write-scoped session sees the write tools", () => { + createMcpServer(ctx(true)); + for (const name of MCP_WRITE_TOOL_NAMES) { + expect(registered).toContain(name); + } + expect(registered).toContain("log_measurement"); + expect(registered).toContain("log_mood"); + }); +}); diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts new file mode 100644 index 000000000..74e276571 --- /dev/null +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// --- Mocks hoisted before importing the module under test. --- +vi.mock("@/lib/ai/coach/tools/executor", () => ({ + executeCoachTool: vi.fn(), +})); +vi.mock("@/lib/ai/coach/tools/inventory", () => ({ + buildCoachDataInventory: vi.fn(), +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); +// v1.22.0 — `search` reads the record directly via Prisma; stub it so the +// registry-wide loops never reach a DB. +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { + findMany: vi.fn(async () => []), + findFirst: vi.fn(async () => null), + }, + labResult: { findMany: vi.fn(async () => []) }, + }, +})); +// Phase 4 — the deep-value reads delegate to the rich-reads engines; stub them +// so the registry-wide loops (surface / annotation / no-verdict) never reach a +// real engine. Their own logic is covered in `rich-reads.test.ts`. +vi.mock("../rich-reads", () => ({ + getCorrelation: vi.fn(async () => ({ present: true })), + compareMetric: vi.fn(async () => ({ present: true })), + getMetricBaseline: vi.fn(async () => ({ present: true })), + detectChangepoints: vi.fn(async () => ({ present: true })), +})); + +import { MCP_TOOLS, MCP_TOOL_NAMES } from "../tools"; +import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; +import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; +import type { McpAuthContext } from "../auth"; + +const CTX: McpAuthContext = { + userId: "user-1", + tokenId: "token-1", + scopes: ["health:read"], + binding: "user-1:token-1", + canRead: true, + canWrite: false, +}; + +function tool(name: string) { + const def = MCP_TOOLS.find((t) => t.name === name); + if (!def) throw new Error(`tool ${name} not registered`); + return def; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("MCP tool registry — surface", () => { + it("registers exactly the read tools (Phase 1 + Phase 4)", () => { + expect([...MCP_TOOL_NAMES].sort()).toEqual( + [ + "get_correlations", + "get_labs", + "get_medication_compliance", + "get_metric_series", + "list_metrics", + // v1.22.0 — the ChatGPT default-mode retrieval pair. + "search", + "fetch", + // Phase 4 — deep-value reads. + "get_correlation", + "compare_metric", + "get_metric_baseline", + "detect_changepoints", + ].sort(), + ); + }); + + it("annotates every tool read-only / closed-world (cloud-connector requirement)", () => { + for (const def of MCP_TOOLS) { + expect(def.annotations.readOnlyHint).toBe(true); + expect(def.annotations.destructiveHint).toBe(false); + expect(def.annotations.openWorldHint).toBe(false); + } + }); + + it("exposes no admin / write tool", () => { + for (const name of MCP_TOOL_NAMES) { + expect(name).not.toMatch(/admin/i); + expect(name).not.toMatch( + /^(log_|create_|update_|delete_|export_|write_)/, + ); + } + }); +}); + +describe("list_metrics", () => { + it("enumerates available metrics with coverage from the inventory", async () => { + vi.mocked(buildCoachDataInventory).mockResolvedValue({ + entries: [ + { + tool: "get_metric_series", + metric: "bp", + domain: "blood pressure", + present: true, + count: 42, + }, + { + tool: "get_metric_series", + metric: "weight", + domain: "weight", + present: false, + }, + ], + restMode: false, + cycleEnabled: false, + window: "last30days", + probeScope: { sources: [], window: "last30days" }, + } as never); + + const result = (await tool("list_metrics").run(CTX, {})) as { + present: boolean; + window: string; + metrics: unknown[]; + }; + + expect(buildCoachDataInventory).toHaveBeenCalledWith("user-1", undefined); + expect(result.present).toBe(true); + expect(result.window).toBe("last30days"); + expect(result.metrics).toHaveLength(2); + }); +}); + +describe("get_metric_series", () => { + it("forwards validated args + session userId to the F1 executor and returns the grounded result", async () => { + vi.mocked(executeCoachTool).mockResolvedValue({ + present: true, + data: { metric: "bp", section: { unit: "mmHg" } }, + grounding: "Population band 120/80 mmHg.", + }); + + const result = (await tool("get_metric_series").run(CTX, { + metric: "bp", + window: "last90days", + })) as { present: boolean; grounding?: string }; + + expect(executeCoachTool).toHaveBeenCalledWith({ + userId: "user-1", + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: "bp", window: "last90days" }), + }); + expect(result.present).toBe(true); + // Units / reference bands ride the result (ADR-004). + expect(result.grounding).toContain("mmHg"); + }); + + it("returns { present: false } for an absent metric (never a silent zero)", async () => { + vi.mocked(executeCoachTool).mockResolvedValue({ + present: false, + reason: "no_data", + }); + const result = (await tool("get_metric_series").run(CTX, { + metric: "weight", + })) as { present: boolean; reason?: string }; + expect(result.present).toBe(false); + expect(result.reason).toBe("no_data"); + }); +}); + +describe("get_medication_compliance", () => { + it("forwards to the F1 executor under its name", async () => { + vi.mocked(executeCoachTool).mockResolvedValue({ + present: true, + data: { compliance: { short: { rate: 90 } } }, + }); + const result = (await tool("get_medication_compliance").run(CTX, {})) as { + present: boolean; + }; + expect(executeCoachTool).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + name: "get_medication_compliance", + }), + ); + expect(result.present).toBe(true); + }); +}); + +describe("get_labs", () => { + it("forwards the optional analyte filter and returns readings with units + bands", async () => { + vi.mocked(executeCoachTool).mockResolvedValue({ + present: true, + data: { + recent: [ + { + analyte: "LDL", + value: 120, + unit: "mg/dL", + referenceHigh: 116, + rangeStatus: "above", + }, + ], + }, + }); + const result = (await tool("get_labs").run(CTX, { analyte: "LDL" })) as { + present: boolean; + }; + expect(executeCoachTool).toHaveBeenCalledWith({ + userId: "user-1", + name: "get_labs", + rawArguments: JSON.stringify({ analyte: "LDL" }), + }); + expect(result.present).toBe(true); + }); +}); + +describe("get_correlations", () => { + it("forwards to the F1 executor and returns FDR-controlled drivers", async () => { + vi.mocked(executeCoachTool).mockResolvedValue({ + present: true, + data: { drivers: [], pairsTested: 20, windowDays: 180 }, + }); + const result = (await tool("get_correlations").run(CTX, {})) as { + present: boolean; + }; + expect(executeCoachTool).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", name: "get_correlations" }), + ); + expect(result.present).toBe(true); + }); +}); + +describe("no prose verdict (ADR-004 / REQ-SEC-2)", () => { + it("every tool result is a structured object without verdict/diagnosis fields", async () => { + vi.mocked(executeCoachTool).mockResolvedValue({ present: true, data: {} }); + vi.mocked(buildCoachDataInventory).mockResolvedValue({ + entries: [], + restMode: false, + cycleEnabled: false, + window: "last30days", + probeScope: { sources: [] }, + } as never); + + for (const def of MCP_TOOLS) { + const args = def.name === "get_metric_series" ? { metric: "bp" } : {}; + const result = await def.run(CTX, args); + expect(typeof result).toBe("object"); + const keys = Object.keys(result as object); + expect(keys).not.toContain("verdict"); + expect(keys).not.toContain("diagnosis"); + expect(keys).not.toContain("advice"); + } + }); +}); diff --git a/src/lib/mcp/__tests__/write-tools.test.ts b/src/lib/mcp/__tests__/write-tools.test.ts new file mode 100644 index 000000000..a4f5f5315 --- /dev/null +++ b/src/lib/mcp/__tests__/write-tools.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const logMcpMeasurement = vi.fn(); +const logMcpMood = vi.fn(); +vi.mock("../writes", () => ({ + logMcpMeasurement: (...a: unknown[]) => logMcpMeasurement(...a), + logMcpMood: (...a: unknown[]) => logMcpMood(...a), +})); + +const checkMcpWriteRateLimit = vi.fn(); +vi.mock("@/lib/rate-limit", () => ({ + checkMcpWriteRateLimit: (...a: unknown[]) => checkMcpWriteRateLimit(...a), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { MCP_WRITE_TOOLS, MCP_WRITE_TOOL_NAMES } from "../write-tools"; +import type { McpAuthContext } from "../auth"; + +const CTX: McpAuthContext = { + userId: "u-1", + tokenId: "t-1", + scopes: ["health:read", "health:write"], + binding: "u-1:t-1", + canRead: true, + canWrite: true, +}; + +function tool(name: string) { + const t = MCP_WRITE_TOOLS.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +} + +beforeEach(() => { + vi.clearAllMocks(); + checkMcpWriteRateLimit.mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 1000, + }); +}); + +describe("write-tool surface", () => { + it("exposes exactly log_measurement and log_mood", () => { + expect([...MCP_WRITE_TOOL_NAMES].sort()).toEqual([ + "log_measurement", + "log_mood", + ]); + }); + + it("annotates writes as non-read-only, non-destructive, idempotent", () => { + for (const t of MCP_WRITE_TOOLS) { + expect(t.annotations).toEqual({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }); + } + }); +}); + +describe("confirm gate — log_measurement", () => { + it("confirm:false returns a preview and writes NOTHING", async () => { + const result = (await tool("log_measurement").run(CTX, { + type: "WEIGHT", + value: 80, + idempotencyKey: "k-1", + })) as Record; + + expect(result.requiresConfirmation).toBe(true); + expect(result.written).toBe(false); + expect(result.preview).toMatchObject({ + type: "WEIGHT", + value: 80, + source: "MCP", + }); + expect(logMcpMeasurement).not.toHaveBeenCalled(); + expect(checkMcpWriteRateLimit).not.toHaveBeenCalled(); + }); + + it("confirm:true executes the core and reports the written record", async () => { + logMcpMeasurement.mockResolvedValue({ + status: "written", + measurement: { + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: "x", + source: "MCP", + }, + }); + const result = (await tool("log_measurement").run(CTX, { + type: "WEIGHT", + value: 80, + confirm: true, + idempotencyKey: "k-1", + })) as Record; + + expect(logMcpMeasurement).toHaveBeenCalledTimes(1); + expect(logMcpMeasurement).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "u-1", + type: "WEIGHT", + value: 80, + idempotencyKey: "k-1", + }), + ); + expect(result.written).toBe(true); + }); + + it("an idempotent replay reports alreadyLogged, not a duplicate write", async () => { + logMcpMeasurement.mockResolvedValue({ + status: "already_logged", + measurement: { + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: "x", + source: "MCP", + }, + }); + const result = (await tool("log_measurement").run(CTX, { + type: "WEIGHT", + value: 80, + confirm: true, + idempotencyKey: "k-1", + })) as Record; + expect(result.written).toBe(false); + expect(result.alreadyLogged).toBe(true); + }); + + it("refuses to commit when the write budget is exhausted", async () => { + checkMcpWriteRateLimit.mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 1000, + }); + const result = (await tool("log_measurement").run(CTX, { + type: "WEIGHT", + value: 80, + confirm: true, + idempotencyKey: "k-1", + })) as Record; + expect(result.written).toBe(false); + expect(result.error).toBe("rate_limited"); + expect(logMcpMeasurement).not.toHaveBeenCalled(); + }); + + it("rejects a non-numeric value before any write", async () => { + const result = (await tool("log_measurement").run(CTX, { + type: "WEIGHT", + value: Number.NaN, + confirm: true, + idempotencyKey: "k-1", + })) as Record; + expect(result.written).toBe(false); + expect(logMcpMeasurement).not.toHaveBeenCalled(); + }); +}); + +describe("confirm gate — log_mood", () => { + it("confirm:false returns a preview and writes NOTHING", async () => { + const result = (await tool("log_mood").run(CTX, { + score: 4, + idempotencyKey: "m-1", + })) as Record; + expect(result.requiresConfirmation).toBe(true); + expect(result.written).toBe(false); + expect(logMcpMood).not.toHaveBeenCalled(); + }); + + it("confirm:true commits the mood entry", async () => { + logMcpMood.mockResolvedValue({ + status: "written", + moodEntry: { + score: 4, + mood: "GUT", + note: null, + date: "2026-06-27", + source: "MCP", + }, + }); + const result = (await tool("log_mood").run(CTX, { + score: 4, + confirm: true, + idempotencyKey: "m-1", + })) as Record; + expect(logMcpMood).toHaveBeenCalledTimes(1); + expect(result.written).toBe(true); + }); + + it("rejects an out-of-band score before any write", async () => { + const result = (await tool("log_mood").run(CTX, { + score: 9, + confirm: true, + idempotencyKey: "m-1", + })) as Record; + expect(result.written).toBe(false); + expect(logMcpMood).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/mcp/__tests__/writes.test.ts b/src/lib/mcp/__tests__/writes.test.ts new file mode 100644 index 000000000..6019d3dfd --- /dev/null +++ b/src/lib/mcp/__tests__/writes.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { measurement, moodEntry } = vi.hoisted(() => ({ + measurement: { + findUnique: vi.fn(), + create: vi.fn(), + }, + moodEntry: { + findUnique: vi.fn(), + create: vi.fn(), + }, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { measurement, moodEntry }, +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: vi.fn(), + invalidateUserMood: vi.fn(), +})); + +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rollups/mood-rollups", () => ({ + recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/moodlog/push", () => ({ + pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined), +})); + +const auditLog = vi.fn().mockResolvedValue(undefined); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: (...args: unknown[]) => auditLog(...args), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: () => ({ addMeta: vi.fn() }), +})); + +import { logMcpMeasurement, logMcpMood } from "../writes"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; + +beforeEach(() => { + vi.clearAllMocks(); + measurement.findUnique.mockResolvedValue(null); + measurement.create.mockResolvedValue({ id: "m-1" }); + moodEntry.findUnique.mockResolvedValue(null); + moodEntry.create.mockResolvedValue({ + date: "2026-06-27", + mood: "GUT", + note: null, + tags: [], + }); +}); + +describe("logMcpMeasurement", () => { + it("writes one row with source MCP for a capturable type", async () => { + const result = await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 80, + idempotencyKey: "key-1", + }); + + expect(result.status).toBe("written"); + expect(measurement.create).toHaveBeenCalledTimes(1); + const data = measurement.create.mock.calls[0][0].data; + expect(data.source).toBe("MCP"); + expect(data.userId).toBe("u-1"); + expect(data.type).toBe("WEIGHT"); + expect(data.value).toBe(80); + expect(data.unit).toBe("kg"); + // externalId is namespaced + hashed (never the raw key). + expect(data.externalId).toMatch(/^mcp:measure:[0-9a-f]{64}$/); + expect(data.externalId).not.toContain("key-1"); + expect(invalidateUserMeasurements).toHaveBeenCalledWith("u-1", { + evict: true, + }); + expect(auditLog).toHaveBeenCalledWith( + "mcp.write.measurement", + expect.objectContaining({ userId: "u-1" }), + ); + }); + + it("refuses a non-capturable / clinical-only type without writing", async () => { + const result = await logMcpMeasurement({ + userId: "u-1", + type: "RECOVERY_SCORE" as never, + value: 50, + idempotencyKey: "key-x", + }); + expect(result.status).toBe("unsupported_type"); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("refuses an out-of-range value without writing", async () => { + const result = await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 99999, + idempotencyKey: "key-y", + }); + expect(result.status).toBe("out_of_range"); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("is idempotent — a second call with the same key writes nothing", async () => { + measurement.findUnique.mockResolvedValue({ + id: "m-1", + value: 80, + unit: "kg", + measuredAt: new Date("2026-06-27T08:00:00Z"), + }); + const result = await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 80, + idempotencyKey: "key-1", + }); + expect(result.status).toBe("already_logged"); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("derives the same externalId for the same idempotencyKey", async () => { + await logMcpMeasurement({ + userId: "u-1", + type: "PULSE", + value: 60, + idempotencyKey: "stable", + }); + const first = measurement.create.mock.calls[0][0].data.externalId; + vi.clearAllMocks(); + measurement.findUnique.mockResolvedValue(null); + measurement.create.mockResolvedValue({ id: "m-2" }); + await logMcpMeasurement({ + userId: "u-1", + type: "PULSE", + value: 61, + idempotencyKey: "stable", + }); + const second = measurement.create.mock.calls[0][0].data.externalId; + expect(first).toBe(second); + }); +}); + +describe("logMcpMood", () => { + it("writes one mood row with source MCP", async () => { + const result = await logMcpMood({ + userId: "u-1", + score: 4, + idempotencyKey: "mood-1", + }); + expect(result.status).toBe("written"); + expect(moodEntry.create).toHaveBeenCalledTimes(1); + const data = moodEntry.create.mock.calls[0][0].data; + expect(data.source).toBe("MCP"); + expect(data.userId).toBe("u-1"); + expect(data.externalId).toMatch(/^mcp:mood:[0-9a-f]{64}$/); + expect(auditLog).toHaveBeenCalledWith( + "mcp.write.mood", + expect.objectContaining({ userId: "u-1" }), + ); + }); + + it("rejects an out-of-band score without writing", async () => { + const result = await logMcpMood({ + userId: "u-1", + score: 9, + idempotencyKey: "mood-bad", + }); + expect(result.status).toBe("invalid_score"); + expect(moodEntry.create).not.toHaveBeenCalled(); + }); + + it("is idempotent on the same key", async () => { + moodEntry.findUnique.mockResolvedValue({ + mood: "GUT", + score: 4, + note: null, + date: "2026-06-27", + }); + const result = await logMcpMood({ + userId: "u-1", + score: 4, + idempotencyKey: "mood-1", + }); + expect(result.status).toBe("already_logged"); + expect(moodEntry.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/mcp/auth.ts b/src/lib/mcp/auth.ts new file mode 100644 index 000000000..7d6fb57dd --- /dev/null +++ b/src/lib/mcp/auth.ts @@ -0,0 +1,60 @@ +/** + * MCP auth context. + * + * Resolves a pasted `hlk_` Bearer token to a session-narrowed identity for + * the MCP transports. Authentication is delegated to the canonical + * `resolveBearerToken` (`@/lib/auth/bearer`) — the SAME validation the HTTP edge + * runs — so revoked / expired / scope semantics are identical across both wires + * (REQ-T1, REQ-SEC-5). + * + * Security properties: + * - The id is narrowed to one `userId`; tools and resources feed it straight + * into the Prisma `where`, never accepting a caller-supplied user id. + * - The session is bound to `:` (REQ-SEC-11), never to a + * cookie session — the MCP wire carries no cookie. + * - Admin is unreachable by construction: `requireAdmin()` is cookie-only and + * this path never mints a cookie session, so no MCP context can elevate + * (REQ-SEC-7 / ADR-005). The guard test pins it. + */ +import { resolveBearerToken } from "@/lib/auth/bearer"; +import { sessionBinding, tokenAllowsRead, tokenAllowsWrite } from "./scopes"; + +export interface McpAuthContext { + /** The single user this session acts as. Feeds every Prisma `where`. */ + userId: string; + /** The presented `ApiToken` id (the token half of the binding). */ + tokenId: string; + /** The token's granted scopes. */ + scopes: string[]; + /** `:` — the session binding (REQ-SEC-11). */ + binding: string; + /** Whether this session may read (always true for a valid token). */ + canRead: boolean; + /** Whether this session may write (requires `health:write`; no write tools yet). */ + canWrite: boolean; +} + +/** + * Resolve a raw Bearer token into an `McpAuthContext`, or throw on an invalid / + * revoked / expired token. Reads require no special scope (the underlying reads + * are unscoped), so no `requiredPermission` is passed. + */ +export async function resolveMcpAuthContext( + rawToken: string, +): Promise { + const trimmed = rawToken.trim(); + if (!trimmed) { + throw new Error("Missing MCP Bearer token"); + } + + const { user, tokenId, permissions } = await resolveBearerToken(trimmed); + + return { + userId: user.id, + tokenId, + scopes: permissions, + binding: sessionBinding(user.id, tokenId), + canRead: tokenAllowsRead(), + canWrite: tokenAllowsWrite(permissions), + }; +} diff --git a/src/lib/mcp/index.ts b/src/lib/mcp/index.ts new file mode 100644 index 000000000..1302cec27 --- /dev/null +++ b/src/lib/mcp/index.ts @@ -0,0 +1,47 @@ +/** + * HealthLog MCP server — barrel. + * + * A read-only-by-default Model Context Protocol surface that re-exports + * HealthLog's existing server-authoritative read paths over the MCP wire, so a + * user can query their own health data from any MCP-capable assistant. The tool + * / resource layer here is transport-agnostic; transports (stdio now, remote + * `/mcp` later) attach to `createMcpServer(ctx)`. + */ +export { createMcpServer, MCP_SERVER_NAME, MCP_SERVER_VERSION } from "./server"; +export { resolveMcpAuthContext, type McpAuthContext } from "./auth"; +export { MCP_TOOLS, MCP_TOOL_NAMES, type McpToolDefinition } from "./tools"; +export { MCP_WRITE_TOOLS, MCP_WRITE_TOOL_NAMES } from "./write-tools"; +export { + logMcpMeasurement, + logMcpMood, + type McpMeasurementResult, + type McpMoodResult, +} from "./writes"; +export { + MCP_RESOURCES, + MCP_RESOURCE_URIS, + type McpResourceDefinition, +} from "./resources"; +export { + MCP_PROMPTS, + MCP_PROMPT_NAMES, + type McpPromptDefinition, + type McpPromptResult, + type McpPromptMessage, +} from "./prompts"; +export { + getCorrelation, + compareMetric, + getMetricBaseline, + detectChangepoints, + resolveRichMetric, + type RichMetric, +} from "./rich-reads"; +export { + SCOPE_HEALTH_READ, + SCOPE_HEALTH_WRITE, + SCOPE_WILDCARD, + tokenAllowsRead, + tokenAllowsWrite, + sessionBinding, +} from "./scopes"; diff --git a/src/lib/mcp/oauth/__tests__/connections.test.ts b/src/lib/mcp/oauth/__tests__/connections.test.ts new file mode 100644 index 000000000..c9223fa9d --- /dev/null +++ b/src/lib/mcp/oauth/__tests__/connections.test.ts @@ -0,0 +1,195 @@ +/** + * MCP OAuth connection anchor — H2 revocation + reuse-detection. + * + * These pin the security contract the stateless refresh artifact alone cannot + * provide: a revoked connection stops every future refresh, a replayed + * (already-rotated) jti revokes the whole connection, and a settings revoke + * terminates the chain and kills the connection's access tokens. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + mcpOAuthConnection: { + create: vi.fn(), + findUnique: vi.fn(), + updateMany: vi.fn(), + findMany: vi.fn(), + }, + apiToken: { + updateMany: vi.fn(), + }, + }, +})); + +import { prisma } from "@/lib/db"; +import { + createConnection, + rotateConnection, + revokeConnectionForUser, +} from "../connections"; + +const CONN = { + id: "conn-1", + userId: "user-1", + clientId: "https://app.example/client.json", + currentJti: "jti-current", + revokedAt: null as Date | null, +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({ + count: 0, + } as never); + vi.mocked(prisma.mcpOAuthConnection.updateMany).mockResolvedValue({ + count: 1, + } as never); +}); + +describe("createConnection", () => { + it("seeds currentJti with the first refresh jti", async () => { + vi.mocked(prisma.mcpOAuthConnection.create).mockResolvedValue({ + id: "conn-1", + } as never); + const id = await createConnection({ + userId: "user-1", + clientId: CONN.clientId, + clientName: "Claude", + scope: "health:read offline_access", + resource: "https://health.example/mcp", + jti: "jti-1", + }); + expect(id).toBe("conn-1"); + expect(prisma.mcpOAuthConnection.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ currentJti: "jti-1" }), + }), + ); + }); +}); + +describe("rotateConnection — happy path", () => { + it("advances currentJti and revokes prior access tokens (L4)", async () => { + vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({ + ...CONN, + } as never); + const out = await rotateConnection({ + connectionId: "conn-1", + presentedJti: "jti-current", + newJti: "jti-next", + clientId: CONN.clientId, + userId: "user-1", + }); + expect(out.ok).toBe(true); + expect(prisma.mcpOAuthConnection.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ currentJti: "jti-current" }), + data: expect.objectContaining({ currentJti: "jti-next" }), + }), + ); + // L4 — prior access tokens revoked on rotation. + expect(prisma.apiToken.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { mcpConnectionId: "conn-1", revoked: false }, + data: { revoked: true }, + }), + ); + }); +}); + +describe("rotateConnection — revoked connection stops refresh", () => { + it("rejects when the connection is revoked", async () => { + vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({ + ...CONN, + revokedAt: new Date(), + } as never); + const out = await rotateConnection({ + connectionId: "conn-1", + presentedJti: "jti-current", + newJti: "jti-next", + clientId: CONN.clientId, + userId: "user-1", + }); + expect(out).toEqual({ ok: false, reason: "revoked" }); + expect(prisma.mcpOAuthConnection.updateMany).not.toHaveBeenCalled(); + }); +}); + +describe("rotateConnection — reuse detection revokes the family", () => { + it("revokes the connection when a stale jti is presented", async () => { + vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({ + ...CONN, + currentJti: "jti-rotated-already", + } as never); + const out = await rotateConnection({ + connectionId: "conn-1", + presentedJti: "jti-old", // not the current jti → replay + newJti: "jti-next", + clientId: CONN.clientId, + userId: "user-1", + }); + expect(out).toEqual({ ok: false, reason: "reuse_detected" }); + // The whole connection is revoked + its access tokens killed. + expect(prisma.mcpOAuthConnection.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "conn-1", revokedAt: null }, + data: expect.objectContaining({ revokedAt: expect.any(Date) }), + }), + ); + expect(prisma.apiToken.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { mcpConnectionId: "conn-1", revoked: false }, + }), + ); + }); + + it("rejects a connection owned by another user", async () => { + vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({ + ...CONN, + userId: "someone-else", + } as never); + const out = await rotateConnection({ + connectionId: "conn-1", + presentedJti: "jti-current", + newJti: "jti-next", + clientId: CONN.clientId, + userId: "user-1", + }); + expect(out).toEqual({ ok: false, reason: "not_found" }); + }); +}); + +describe("revokeConnectionForUser — settings revoke kills the chain", () => { + it("revokes a connection owned by the user and its access tokens", async () => { + vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({ + id: "conn-1", + userId: "user-1", + revokedAt: null, + } as never); + const ok = await revokeConnectionForUser("user-1", "conn-1"); + expect(ok).toBe(true); + expect(prisma.mcpOAuthConnection.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ revokedAt: expect.any(Date) }), + }), + ); + expect(prisma.apiToken.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { mcpConnectionId: "conn-1", revoked: false }, + data: { revoked: true }, + }), + ); + }); + + it("refuses to revoke another user's connection", async () => { + vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({ + id: "conn-1", + userId: "someone-else", + revokedAt: null, + } as never); + const ok = await revokeConnectionForUser("user-1", "conn-1"); + expect(ok).toBe(false); + expect(prisma.mcpOAuthConnection.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts b/src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts new file mode 100644 index 000000000..ce94e51c3 --- /dev/null +++ b/src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts @@ -0,0 +1,245 @@ +/** + * OAuth bridge — library-level unit tests (artifacts, PKCE, clients, metadata, + * audience). These pin the security-load-bearing primitives without a DB. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// A fixed signing key so artifact MACs are deterministic across the run. +process.env.API_TOKEN_HMAC_KEY = "x".repeat(48); +process.env.APP_URL = "https://health.example"; + +vi.mock("@/lib/safe-fetch", () => ({ + safeFetch: vi.fn(), + SafeFetchError: class SafeFetchError extends Error {}, +})); + +import { safeFetch } from "@/lib/safe-fetch"; +import { signArtifact, verifyArtifact } from "../artifacts"; +import { s256Challenge, verifyPkceS256, isValidVerifier } from "../pkce"; +import { + registerDcrClient, + resolveClient, + redirectUriAllowed, +} from "../clients"; +import { + protectedResourceMetadata, + authorizationServerMetadata, + wwwAuthenticateChallenge, +} from "../metadata"; +import { audienceMatches, canonicalResource } from "../config"; + +describe("artifacts — sign / verify / tamper / expiry / domain separation", () => { + it("round-trips claims and enforces expiry", () => { + const token = signArtifact("authCode", { sub: "u1" }, 60_000); + const ok = verifyArtifact<{ sub: string }>("authCode", token); + expect(ok.ok).toBe(true); + if (ok.ok) expect(ok.claims.sub).toBe("u1"); + }); + + it("rejects an expired artifact", () => { + const token = signArtifact("authCode", { sub: "u1" }, -1); + const res = verifyArtifact("authCode", token); + expect(res).toEqual({ ok: false, reason: "expired" }); + }); + + it("rejects a tampered payload (bad signature)", () => { + const token = signArtifact("authCode", { sub: "u1" }, 60_000); + // Flip a character in the payload segment. + const [prefixAndPayload, sig] = [ + token.slice(0, token.lastIndexOf(".")), + token.slice(token.lastIndexOf(".") + 1), + ]; + const tampered = `${prefixAndPayload}A.${sig}`; + const res = verifyArtifact("authCode", tampered); + expect(res.ok).toBe(false); + }); + + it("domain-separates the three artifact classes", () => { + const code = signArtifact("authCode", { sub: "u1" }, 60_000); + // An auth code must not verify as a refresh token or a client id. + expect(verifyArtifact("refreshToken", code).ok).toBe(false); + expect(verifyArtifact("clientId", code).ok).toBe(false); + }); +}); + +describe("PKCE — S256 only", () => { + it("verifies a correct verifier against its challenge", () => { + const verifier = "a".repeat(64); + const challenge = s256Challenge(verifier); + expect(verifyPkceS256(verifier, challenge)).toBe(true); + }); + + it("rejects a wrong verifier", () => { + const challenge = s256Challenge("a".repeat(64)); + expect(verifyPkceS256("b".repeat(64), challenge)).toBe(false); + }); + + it("rejects a too-short verifier (RFC 7636 §4.1)", () => { + expect(isValidVerifier("short")).toBe(false); + expect(verifyPkceS256("short", s256Challenge("short"))).toBe(false); + }); +}); + +describe("clients — DCR (stateless) round-trip", () => { + it("registers and resolves a DCR client with its redirect URIs", async () => { + const reg = registerDcrClient({ + clientName: "Claude", + redirectUris: ["https://claude.ai/api/mcp/auth_callback"], + }); + expect(reg.clientId.startsWith("hlc_")).toBe(true); + + const resolved = await resolveClient(reg.clientId); + expect(resolved.ok).toBe(true); + if (resolved.ok) { + expect(resolved.client.source).toBe("dcr"); + expect(resolved.client.redirectUris).toEqual([ + "https://claude.ai/api/mcp/auth_callback", + ]); + } + }); + + it("rejects an unknown client id", async () => { + const res = await resolveClient("not-a-client"); + expect(res.ok).toBe(false); + }); +}); + +describe("clients — CIMD via safeFetch (SSRF-safe)", () => { + beforeEach(() => vi.clearAllMocks()); + + it("fetches a CIMD document through safeFetch with requirePublicHost", async () => { + const clientId = "https://app.example/mcp-client.json"; + vi.mocked(safeFetch).mockResolvedValue( + new Response( + JSON.stringify({ + client_id: clientId, + client_name: "ChatGPT", + redirect_uris: [ + "https://chatgpt.com/connector_platform_oauth_redirect", + ], + }), + { status: 200 }, + ), + ); + + const res = await resolveClient(clientId); + expect(res.ok).toBe(true); + // The SSRF guard is requested. + const opts = vi.mocked(safeFetch).mock.calls[0][2]; + expect(opts?.requirePublicHost).toBe(true); + }); + + it("rejects a CIMD doc whose client_id does not match its URL (SEP-991)", async () => { + const clientId = "https://app.example/mcp-client.json"; + vi.mocked(safeFetch).mockResolvedValue( + new Response( + JSON.stringify({ + client_id: "https://attacker.example/other.json", + redirect_uris: ["https://app.example/cb"], + }), + { status: 200 }, + ), + ); + const res = await resolveClient(clientId); + expect(res).toEqual({ ok: false, reason: "invalid_metadata" }); + }); + + it("surfaces an SSRF block as ssrf_blocked", async () => { + vi.mocked(safeFetch).mockRejectedValue(new Error("private host")); + const res = await resolveClient("https://169.254.169.254/meta.json"); + expect(res).toEqual({ ok: false, reason: "ssrf_blocked" }); + }); + + it("rejects a CIMD doc with a non-https / non-loopback redirect (L2)", async () => { + const clientId = "https://app.example/mcp-client.json"; + vi.mocked(safeFetch).mockResolvedValue( + new Response( + JSON.stringify({ + client_id: clientId, + client_name: "Sketchy", + redirect_uris: ["http://evil.example/cb"], // non-loopback http + }), + { status: 200 }, + ), + ); + const res = await resolveClient(clientId); + expect(res).toEqual({ ok: false, reason: "invalid_metadata" }); + }); + + it("rejects an oversized CIMD body via the streaming cap (M3)", async () => { + const clientId = "https://app.example/mcp-client.json"; + // Declared Content-Length over the 16 KB cap → rejected before reading. + vi.mocked(safeFetch).mockResolvedValue( + new Response("x".repeat(64), { + status: 200, + headers: { "content-length": String(64 * 1024) }, + }), + ); + const res = await resolveClient(clientId); + expect(res).toEqual({ ok: false, reason: "invalid_metadata" }); + }); +}); + +describe("clients — redirect URI matching", () => { + it("matches an exact registered URI (claude.ai callback)", () => { + const registered = ["https://claude.ai/api/mcp/auth_callback"]; + expect( + redirectUriAllowed("https://claude.ai/api/mcp/auth_callback", registered), + ).toBe(true); + }); + + it("matches loopback port-agnostically", () => { + const registered = ["http://localhost:1234/callback"]; + expect( + redirectUriAllowed("http://localhost:55999/callback", registered), + ).toBe(true); + expect( + redirectUriAllowed("http://127.0.0.1:1/callback", [ + "http://127.0.0.1:9/callback", + ]), + ).toBe(true); + }); + + it("rejects a non-loopback mismatch and a path mismatch", () => { + expect( + redirectUriAllowed("https://evil.example/cb", [ + "https://claude.ai/api/mcp/auth_callback", + ]), + ).toBe(false); + expect( + redirectUriAllowed("http://localhost:1/evil", [ + "http://localhost:2/callback", + ]), + ).toBe(false); + }); +}); + +describe("metadata + audience", () => { + it("PRM names the canonical /mcp resource", () => { + const prm = protectedResourceMetadata(); + expect(prm.resource).toBe("https://health.example/mcp"); + expect(prm.authorization_servers).toEqual(["https://health.example"]); + }); + + it("AS metadata advertises S256-only PKCE + CIMD + none auth", () => { + const as = authorizationServerMetadata(); + expect(as.code_challenge_methods_supported).toEqual(["S256"]); + expect(as.token_endpoint_auth_methods_supported).toEqual(["none"]); + expect(as.client_id_metadata_document_supported).toBe(true); + expect(as.grant_types_supported).toContain("authorization_code"); + expect(as.grant_types_supported).toContain("refresh_token"); + }); + + it("the WWW-Authenticate challenge points at the PRM", () => { + expect(wwwAuthenticateChallenge()).toMatch( + /resource_metadata="https:\/\/health\.example\/\.well-known\/oauth-protected-resource"/, + ); + }); + + it("audience binding accepts the canonical resource and rejects look-alikes", () => { + expect(audienceMatches(canonicalResource())).toBe(true); + expect(audienceMatches("https://health.example/mcp/")).toBe(true); + expect(audienceMatches("https://evil.example/mcp")).toBe(false); + expect(audienceMatches(undefined)).toBe(false); + }); +}); diff --git a/src/lib/mcp/oauth/artifacts.ts b/src/lib/mcp/oauth/artifacts.ts new file mode 100644 index 000000000..510c2688a --- /dev/null +++ b/src/lib/mcp/oauth/artifacts.ts @@ -0,0 +1,128 @@ +/** + * Signed, self-describing OAuth artifacts (stateless bridge). + * + * The minimal AS keeps NO new identity tables (ADR-006). The three short-lived + * OAuth artifacts it mints — the authorization code, the refresh token, and the + * Dynamic-Client-Registration client id — are instead HMAC-signed, tamper-proof, + * self-describing strings. Each carries its own claims and is verified by + * recomputing the signature in constant time; nothing is looked up in a table. + * + * Only the ACCESS token is a real persisted row: it is an ordinary `hlk_` + * `ApiToken` minted through `issue-token.ts`, so the existing revoke / expiry / + * `lastUsedAt` machinery and the `:` binding all apply to it + * unchanged. The artifacts here exist only to carry grant state BETWEEN the + * authorize and token steps without inventing a parallel store. + * + * Signing key: `API_TOKEN_HMAC_KEY` (the same fail-closed key the Bearer hasher + * uses — `hashToken` validates length ≥ 32). Each artifact class is domain- + * separated by a context label folded into the MAC input, so a code can never be + * presented as a refresh token or a client id, and vice-versa. + * + * The signed value is `..`. The + * payload always carries an `exp` (epoch ms) which `verifyArtifact` enforces. + */ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** Artifact classes, each with a stable prefix + domain-separation label. */ +export const ARTIFACT_KINDS = { + authCode: { prefix: "hlac_", context: "mcp.oauth.auth_code.v1" }, + refreshToken: { prefix: "hlrt_", context: "mcp.oauth.refresh.v1" }, + clientId: { prefix: "hlc_", context: "mcp.oauth.client.v1" }, +} as const; + +export type ArtifactKind = keyof typeof ARTIFACT_KINDS; + +function signingKey(): string { + const key = process.env.API_TOKEN_HMAC_KEY; + if (!key || key.length < 32) { + // Fail closed — never sign an OAuth artifact off weak / missing key + // material (mirrors `hashToken`). + throw new Error( + "API_TOKEN_HMAC_KEY must be set and at least 32 characters", + ); + } + return key; +} + +function b64url(input: Buffer | string): string { + return Buffer.from(input) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function b64urlDecode(input: string): Buffer { + return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/"), "base64"); +} + +function mac(context: string, payloadB64: string): string { + return createHmac("sha256", signingKey()) + .update(`${context}.${payloadB64}`) + .digest("hex"); +} + +/** + * Mint a signed artifact. `claims` is merged with `exp` (now + `ttlMs`) and + * serialised; the caller never sets `exp` itself. + */ +export function signArtifact( + kind: ArtifactKind, + claims: Record, + ttlMs: number, +): string { + const { prefix, context } = ARTIFACT_KINDS[kind]; + const payload = { ...claims, exp: Date.now() + ttlMs }; + const payloadB64 = b64url(JSON.stringify(payload)); + // `mac` returns hex; base64url the RAW bytes so `verifyArtifact` can compare + // against `Buffer.from(expectedSig, "hex")` byte-for-byte. + const sig = b64url(Buffer.from(mac(context, payloadB64), "hex")); + return `${prefix}${payloadB64}.${sig}`; +} + +export type VerifyResult = + | { ok: true; claims: T } + | { ok: false; reason: "malformed" | "bad_signature" | "expired" }; + +/** + * Verify + decode a signed artifact. Returns the claims on success, or a stable + * machine reason on failure. The signature is checked in constant time and the + * `exp` claim is enforced. A wrong-kind artifact fails on `malformed` (prefix + * mismatch) or `bad_signature` (domain-separation label mismatch). + */ +export function verifyArtifact>( + kind: ArtifactKind, + value: string, +): VerifyResult { + const { prefix, context } = ARTIFACT_KINDS[kind]; + if (typeof value !== "string" || !value.startsWith(prefix)) { + return { ok: false, reason: "malformed" }; + } + const body = value.slice(prefix.length); + const dot = body.lastIndexOf("."); + if (dot <= 0) return { ok: false, reason: "malformed" }; + + const payloadB64 = body.slice(0, dot); + const sigB64 = body.slice(dot + 1); + + const expectedSig = mac(context, payloadB64); + const presented = b64urlDecode(sigB64); + const expected = Buffer.from(expectedSig, "hex"); + if ( + presented.length !== expected.length || + !timingSafeEqual(presented, expected) + ) { + return { ok: false, reason: "bad_signature" }; + } + + let claims: T & { exp?: number }; + try { + claims = JSON.parse(b64urlDecode(payloadB64).toString("utf8")); + } catch { + return { ok: false, reason: "malformed" }; + } + if (typeof claims.exp !== "number" || claims.exp <= Date.now()) { + return { ok: false, reason: "expired" }; + } + return { ok: true, claims }; +} diff --git a/src/lib/mcp/oauth/clients.ts b/src/lib/mcp/oauth/clients.ts new file mode 100644 index 000000000..01717c1c1 --- /dev/null +++ b/src/lib/mcp/oauth/clients.ts @@ -0,0 +1,254 @@ +/** + * OAuth client resolution — CIMD-first, DCR fallback (Phase 3 of the MCP + * milestone; changes B's DCR-first plan per the deep-value research). + * + * Both Claude.ai and ChatGPT now prefer **CIMD** (Client ID Metadata Documents, + * SEP-991): the `client_id` IS an HTTPS URL that resolves to a JSON metadata + * document describing the client. CIMD removes the per-connection client-row + * explosion that DCR causes — and, here, removes the need for ANY client store. + * A CIMD client is resolved by fetching its document THROUGH `safeFetch` + * (SSRF-safe: `requirePublicHost`, manual redirects, bounded timeout) so a + * malicious `client_id` cannot point the AS at an internal host (R-SEC-6). + * + * For clients that still use **DCR** (RFC 7591) we register them statelessly: + * the issued `client_id` is itself a signed, self-describing `hlc_` artifact + * (see `artifacts.ts`) carrying the registered `redirect_uris` + name. Decoding + * it locally recovers the registration with no table — and tampering with it + * fails the HMAC. + * + * Redirect-URI matching is exact against the registered set, with ONE tolerated + * relaxation: loopback redirect URIs match port-agnostically (Claude Code / + * Desktop bind an ephemeral localhost port), per the connector spec. The + * `https://claude.ai/api/mcp/auth_callback` hosted callback is matched as an + * ordinary registered entry. + */ +import { safeFetch } from "@/lib/safe-fetch"; +import { readBodyCapped } from "@/lib/http/read-capped"; +import { ARTIFACT_KINDS, signArtifact, verifyArtifact } from "./artifacts"; + +export interface ResolvedClient { + clientId: string; + clientName: string; + redirectUris: string[]; + /** CIMD + DCR public clients authenticate with `none` (PKCE is the proof). */ + isPublic: true; + source: "cimd" | "dcr"; +} + +export type ClientResolution = + | { ok: true; client: ResolvedClient } + | { + ok: false; + reason: "unknown_client" | "invalid_metadata" | "ssrf_blocked"; + }; + +const MAX_REDIRECT_URIS = 12; +const CIMD_MAX_BYTES = 16 * 1024; + +function asStringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const out: string[] = []; + for (const v of value) { + if (typeof v !== "string" || v.length === 0 || v.length > 2048) return null; + out.push(v); + } + if (out.length === 0 || out.length > MAX_REDIRECT_URIS) return null; + return out; +} + +const REDIRECT_LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "::1", "[::1]"]; + +/** + * A redirect URI must be an absolute HTTPS URL or an `http` loopback URL. This + * is the single source of truth shared by DCR (the `register` route) and CIMD + * (L2): both registration paths enforce the identical https/loopback floor. + */ +export function isAllowableRedirectUri(value: string): boolean { + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + if (url.protocol === "https:") return true; + if ( + url.protocol === "http:" && + REDIRECT_LOOPBACK_HOSTS.includes(url.hostname) + ) { + return true; + } + return false; +} + +/** A `client_id` that is an HTTPS URL is a CIMD client (SEP-991). */ +export function isCimdClientId(clientId: string): boolean { + try { + return new URL(clientId).protocol === "https:"; + } catch { + return false; + } +} + +/** A `client_id` minted by our stateless DCR endpoint. */ +export function isDcrClientId(clientId: string): boolean { + return clientId.startsWith(ARTIFACT_KINDS.clientId.prefix); +} + +/** + * Register a client via DCR (RFC 7591). The returned `client_id` is a signed + * `hlc_` artifact that encodes the registration — no row is written. DCR clients + * never expire (the artifact has a long horizon) but carry no secret. + */ +export function registerDcrClient(input: { + clientName: string; + redirectUris: string[]; +}): ResolvedClient { + const clientId = signArtifact( + "clientId", + { name: input.clientName, redirect_uris: input.redirectUris }, + // Ten-year horizon: a registration is long-lived but still self-expiring. + 10 * 365 * 24 * 60 * 60 * 1000, + ); + return { + clientId, + clientName: input.clientName, + redirectUris: input.redirectUris, + isPublic: true, + source: "dcr", + }; +} + +function resolveDcrClient(clientId: string): ClientResolution { + const verified = verifyArtifact<{ name?: string; redirect_uris?: unknown }>( + "clientId", + clientId, + ); + if (!verified.ok) return { ok: false, reason: "unknown_client" }; + const redirectUris = asStringArray(verified.claims.redirect_uris); + if (!redirectUris) return { ok: false, reason: "invalid_metadata" }; + return { + ok: true, + client: { + clientId, + clientName: + typeof verified.claims.name === "string" + ? verified.claims.name + : "MCP client", + redirectUris, + isPublic: true, + source: "dcr", + }, + }; +} + +async function resolveCimdClient(clientId: string): Promise { + let res: Response; + try { + // SSRF floor: the metadata host is fully user-controlled, so pin the + // connect-time public-host check + manual redirects + bounded timeout. + res = await safeFetch( + clientId, + { method: "GET", headers: { accept: "application/json" } }, + { requirePublicHost: true, timeoutMs: 8000 }, + ); + } catch { + return { ok: false, reason: "ssrf_blocked" }; + } + if (!res.ok) return { ok: false, reason: "unknown_client" }; + + // M3 — enforce the byte cap WHILE reading: reject up front on an oversized + // Content-Length and abort the stream the moment it overflows, so a hostile + // CIMD host cannot buffer gigabytes into memory before a post-hoc check. + const read = await readBodyCapped(res, CIMD_MAX_BYTES); + if (!read.ok) { + return { ok: false, reason: "invalid_metadata" }; + } + let doc: Record; + try { + doc = JSON.parse(read.text); + } catch { + return { ok: false, reason: "invalid_metadata" }; + } + + // SEP-991: the document's `client_id` MUST equal the URL it was fetched from. + if (doc.client_id !== clientId) { + return { ok: false, reason: "invalid_metadata" }; + } + const redirectUris = asStringArray(doc.redirect_uris); + if (!redirectUris) return { ok: false, reason: "invalid_metadata" }; + // L2 — CIMD redirect URIs get the same https/loopback floor DCR enforces. + if (!redirectUris.every(isAllowableRedirectUri)) { + return { ok: false, reason: "invalid_metadata" }; + } + + return { + ok: true, + client: { + clientId, + clientName: + typeof doc.client_name === "string" ? doc.client_name : "MCP client", + redirectUris, + isPublic: true, + source: "cimd", + }, + }; +} + +/** + * Resolve a `client_id` to its registration. HTTPS URLs are CIMD (fetched + * SSRF-safely); `hlc_` ids are stateless DCR; anything else is unknown. + */ +export async function resolveClient( + clientId: string, +): Promise { + if (typeof clientId !== "string" || clientId.length === 0) { + return { ok: false, reason: "unknown_client" }; + } + if (isCimdClientId(clientId)) return resolveCimdClient(clientId); + if (isDcrClientId(clientId)) return resolveDcrClient(clientId); + return { ok: false, reason: "unknown_client" }; +} + +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +function isLoopback(url: URL): boolean { + return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname); +} + +/** + * Whether `requested` is an allowed redirect URI for `registered`. Exact match + * by default; loopback URIs match port-agnostically (the AS ignores the port for + * `localhost` / `127.0.0.1` / `::1`, per the connector spec) so Claude Code / + * Desktop's ephemeral callback port still matches its registration. + */ +export function redirectUriAllowed( + requested: string, + registered: readonly string[], +): boolean { + if (registered.includes(requested)) return true; + + let req: URL; + try { + req = new URL(requested); + } catch { + return false; + } + if (!isLoopback(req)) return false; + + for (const entry of registered) { + let reg: URL; + try { + reg = new URL(entry); + } catch { + continue; + } + if ( + isLoopback(reg) && + reg.hostname === req.hostname && + reg.pathname === req.pathname + ) { + return true; // ignore the port for loopback + } + } + return false; +} diff --git a/src/lib/mcp/oauth/config.ts b/src/lib/mcp/oauth/config.ts new file mode 100644 index 000000000..876538390 --- /dev/null +++ b/src/lib/mcp/oauth/config.ts @@ -0,0 +1,120 @@ +/** + * OAuth bridge configuration — origin + canonical resource resolution. + * + * The MCP authorization surface is a minimal in-repo Authorization Server that + * BRIDGES onto HealthLog's existing `hlk_` Bearer model (ADR-006) — it keeps no + * parallel identity store. Every metadata document, redirect-URI match, and + * audience check is anchored to ONE canonical origin so a token minted for this + * deployment can never be replayed against another (RFC 8707 audience binding). + * + * Origin precedence mirrors the passkey RP-origin / invite-URL resolution: the + * operator-configured `APP_URL` / `NEXT_PUBLIC_APP_URL` win over the request + * origin (which may be an internal hostname behind the reverse proxy). The + * canonical resource identifier is the `/mcp` endpoint exactly — the same URL a + * user pastes into Claude.ai / ChatGPT — so the Protected Resource Metadata + * `resource` value matches the user-entered URL (a Claude.ai requirement). + */ + +/** The operator-configured origin, env-only — `Host` is never trusted. */ +function configuredOrigin(): string | null { + for (const value of [process.env.APP_URL, process.env.NEXT_PUBLIC_APP_URL]) { + const trimmed = value?.trim(); + if (!trimmed) continue; + try { + return new URL(trimmed).origin; + } catch { + // try the next candidate + } + } + return null; +} + +/** Whether the operator has pinned a canonical origin (M1 fail-closed gate). */ +export function isMcpOriginConfigured(): boolean { + return configuredOrigin() !== null; +} + +/** + * Resolve the public origin of this deployment, e.g. `https://health.example`. + * + * Operator config (`APP_URL` → `NEXT_PUBLIC_APP_URL`) is authoritative. The + * request URL is a last resort retained ONLY for non-security-bearing callers; + * every MCP/OAuth entry point first asserts `isMcpOriginConfigured()` and fails + * closed (M1), so on those paths the env value is always what is returned and + * the `Host`-derived fallback is never reached. + */ +export function resolveBaseOrigin(requestUrl?: string): string { + const candidates = [ + process.env.APP_URL, + process.env.NEXT_PUBLIC_APP_URL, + requestUrl, + ] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + + for (const candidate of candidates) { + try { + return new URL(candidate).origin; + } catch { + // try the next candidate + } + } + return "http://localhost:3000"; +} + +/** + * The canonical MCP resource identifier (RFC 8707 audience). This is the value + * the AS binds every issued token to and the value a client MUST send as + * `resource=` — any other value is rejected. + */ +export function canonicalResource(requestUrl?: string): string { + return `${resolveBaseOrigin(requestUrl)}/mcp`; +} + +/** + * Whether a client-supplied `resource` value matches our canonical resource. + * Trailing slashes are tolerated (`/mcp` vs `/mcp/`) but the origin + path must + * otherwise be exact — no substring / prefix matching that could admit a + * look-alike host (RFC 8707, R-SEC-5). + */ +export function audienceMatches( + resource: string | null | undefined, + requestUrl?: string, +): boolean { + if (!resource) return false; + const want = canonicalResource(requestUrl).replace(/\/+$/, ""); + const got = resource.trim().replace(/\/+$/, ""); + return want === got; +} + +/** The read scope the MCP surface issues — the default posture. */ +export const SCOPE_HEALTH_READ = "health:read"; + +/** + * The write scope. Granted only on explicit request + consent; it admits the + * confirmed `/mcp` write tools. A token carrying it is STILL MCP-audience-bound + * (see `isMcpAudienceToken`) — it can never become a general REST write + * credential. + */ +export const SCOPE_HEALTH_WRITE = "health:write"; + +/** + * Scopes the AS advertises + will issue. `health:write` is advertised so a + * future incremental-consent (SEP-835) challenge can request it, but the read + * surface only ever issues `health:read`. `offline_access` enables refresh + * tokens (OAuth 2.1 public-client rotation) — Claude appends it when advertised. + */ +export const SUPPORTED_SCOPES = [ + SCOPE_HEALTH_READ, + SCOPE_HEALTH_WRITE, + "offline_access", +] as const; + +/** Access-token lifetime (minutes). Short-lived; refresh tokens carry continuity. */ +export const ACCESS_TOKEN_TTL_MINUTES = 60; + +/** Refresh-token lifetime (days). */ +export const REFRESH_TOKEN_TTL_DAYS = 30; + +/** Authorization-code lifetime (seconds). Single-redemption, short window. */ +export const AUTH_CODE_TTL_SECONDS = 120; diff --git a/src/lib/mcp/oauth/connections.ts b/src/lib/mcp/oauth/connections.ts new file mode 100644 index 000000000..091143e01 --- /dev/null +++ b/src/lib/mcp/oauth/connections.ts @@ -0,0 +1,190 @@ +/** + * MCP OAuth connections — persistent, revocable, theft-detectable refresh + * state (Phase 3 security fix, H2). + * + * The bridge AS mints stateless `hlrt_` refresh artifacts (see `artifacts.ts`). + * On their own they reference no server row, so they could not be revoked and a + * replayed (already-rotated) artifact was not treated as theft. This module is + * the anchor the artifact lacked: one `McpOAuthConnection` row per connector + * connection that requested `offline_access`. + * + * - `createConnection` is called on the `authorization_code` exchange. It + * records the granted scope/audience and seeds `currentJti` with the jti of + * the first refresh artifact. + * - `rotateConnection` is called on every `refresh_token` exchange. It is the + * reuse-detection gate: a presented jti that is not the connection's + * `currentJti` is a replay of an already-rotated token → the connection is + * revoked (family revocation, mirroring `refresh-token.ts`) and the refresh + * fails. A revoked connection always fails. The happy path advances + * `currentJti` atomically (a `where currentJti = presented` guard so two + * concurrent refreshes cannot both win) and revokes the prior access tokens + * (L4). + * - `revokeConnection` is the kill switch the settings card flips: it stamps + * `revokedAt` and revokes every access token the connection ever issued, so + * a settings "revoke" terminates the whole refresh chain. + * + * The access tokens themselves stay ordinary `hlk_` `ApiToken` rows, linked + * back through `ApiToken.mcpConnectionId`. + */ +import { prisma } from "@/lib/db"; + +export interface ConnectionRotation { + /** The connection id (`cid`) embedded in the refresh artifact. */ + connectionId: string; + /** The jti presented in the refresh artifact being exchanged. */ + presentedJti: string; + /** The jti to advance the connection to (the new refresh artifact's jti). */ + newJti: string; + /** The client the refresh artifact claims — must match the stored client. */ + clientId: string; + /** The user the refresh artifact claims — must match the stored owner. */ + userId: string; +} + +export type RotationOutcome = + | { ok: true } + | { + ok: false; + reason: "not_found" | "revoked" | "reuse_detected" | "client_mismatch"; + }; + +/** Create the connection anchor for a freshly-granted `offline_access` flow. */ +export async function createConnection(args: { + userId: string; + clientId: string; + clientName: string; + scope: string; + resource: string; + jti: string; +}): Promise { + const row = await prisma.mcpOAuthConnection.create({ + data: { + userId: args.userId, + clientId: args.clientId, + clientName: args.clientName, + scope: args.scope, + resource: args.resource, + currentJti: args.jti, + }, + select: { id: true }, + }); + return row.id; +} + +/** + * Rotate a connection's refresh chain with reuse-detection. + * + * Returns `{ ok: true }` only when the presented jti is the connection's live + * `currentJti` and the connection is not revoked; on success `currentJti` is + * advanced to `newJti` and the connection's prior access tokens are revoked. + * A presented jti that does not match `currentJti` is a replay and revokes the + * connection before returning `reuse_detected`. + */ +export async function rotateConnection( + args: ConnectionRotation, +): Promise { + const connection = await prisma.mcpOAuthConnection.findUnique({ + where: { id: args.connectionId }, + select: { + id: true, + userId: true, + clientId: true, + currentJti: true, + revokedAt: true, + }, + }); + + if (!connection || connection.userId !== args.userId) { + return { ok: false, reason: "not_found" }; + } + if (connection.clientId !== args.clientId) { + return { ok: false, reason: "client_mismatch" }; + } + if (connection.revokedAt) { + return { ok: false, reason: "revoked" }; + } + if (connection.currentJti !== args.presentedJti) { + // Replay of an already-rotated refresh token — treat as theft and revoke + // the whole connection (reuse-detection family revocation). + await revokeConnectionRow(connection.id); + return { ok: false, reason: "reuse_detected" }; + } + + // Advance the chain atomically: only the caller presenting the current jti + // wins, so two concurrent refreshes cannot both rotate the same row. + const advanced = await prisma.mcpOAuthConnection.updateMany({ + where: { + id: connection.id, + currentJti: args.presentedJti, + revokedAt: null, + }, + data: { currentJti: args.newJti, lastUsedAt: new Date() }, + }); + if (advanced.count === 0) { + return { ok: false, reason: "reuse_detected" }; + } + + // L4 — revoke the access tokens minted before this rotation so a leaked + // 60-minute access token cannot outlive the refresh that spawned it. + await prisma.apiToken.updateMany({ + where: { mcpConnectionId: connection.id, revoked: false }, + data: { revoked: true }, + }); + + return { ok: true }; +} + +/** Internal — stamp `revokedAt` and revoke every linked access token. */ +async function revokeConnectionRow(connectionId: string): Promise { + await prisma.mcpOAuthConnection.updateMany({ + where: { id: connectionId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + await prisma.apiToken.updateMany({ + where: { mcpConnectionId: connectionId, revoked: false }, + data: { revoked: true }, + }); +} + +/** + * Revoke a connection on behalf of its owner (settings "revoke"). Ownership is + * enforced so a connection id alone cannot revoke another user's connection. + * Returns true when a live connection owned by the user was revoked. + */ +export async function revokeConnectionForUser( + userId: string, + connectionId: string, +): Promise { + const connection = await prisma.mcpOAuthConnection.findUnique({ + where: { id: connectionId }, + select: { id: true, userId: true, revokedAt: true }, + }); + if (!connection || connection.userId !== userId) return false; + await revokeConnectionRow(connection.id); + return true; +} + +export interface ConnectionSummary { + id: string; + clientName: string; + scope: string; + createdAt: Date; + lastUsedAt: Date | null; +} + +/** The user's live (non-revoked) connector connections for the settings list. */ +export async function listConnectionsForUser( + userId: string, +): Promise { + return prisma.mcpOAuthConnection.findMany({ + where: { userId, revokedAt: null }, + select: { + id: true, + clientName: true, + scope: true, + createdAt: true, + lastUsedAt: true, + }, + orderBy: { createdAt: "desc" }, + }); +} diff --git a/src/lib/mcp/oauth/metadata.ts b/src/lib/mcp/oauth/metadata.ts new file mode 100644 index 000000000..ef2f4412a --- /dev/null +++ b/src/lib/mcp/oauth/metadata.ts @@ -0,0 +1,92 @@ +/** + * OAuth discovery documents — RFC 9728 (Protected Resource Metadata) + RFC 8414 + * (Authorization Server Metadata). + * + * These two JSON documents are how a remote MCP client bootstraps the flow from + * nothing but the pasted `/mcp` URL: + * + * 1. The client calls `/mcp`, gets a `401` whose `WWW-Authenticate` points at + * `/.well-known/oauth-protected-resource` (PRM). + * 2. PRM names this `resource` (the canonical `/mcp` URI — it MUST match the + * user-entered URL) and lists its `authorization_servers`. + * 3. The client fetches the AS metadata and learns the `/authorize` + `/token` + * + `/register` endpoints, that **PKCE S256 is mandatory**, and that the AS + * accepts **CIMD** clients (`client_id_metadata_document_supported: true` + + * `none` auth) with **DCR** as a fallback. + * + * Everything is anchored to the canonical origin (`config.ts`) so the documents + * are deterministic and the audience can be validated against them. + */ +import { + canonicalResource, + resolveBaseOrigin, + SUPPORTED_SCOPES, +} from "./config"; + +export interface ProtectedResourceMetadata { + resource: string; + authorization_servers: string[]; + scopes_supported: string[]; + bearer_methods_supported: string[]; + resource_documentation?: string; +} + +export interface AuthorizationServerMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + registration_endpoint: string; + scopes_supported: string[]; + response_types_supported: string[]; + grant_types_supported: string[]; + token_endpoint_auth_methods_supported: string[]; + code_challenge_methods_supported: string[]; + client_id_metadata_document_supported: boolean; +} + +/** RFC 9728 — the Protected Resource Metadata for the `/mcp` resource. */ +export function protectedResourceMetadata( + requestUrl?: string, +): ProtectedResourceMetadata { + const origin = resolveBaseOrigin(requestUrl); + return { + resource: canonicalResource(requestUrl), + authorization_servers: [origin], + scopes_supported: [...SUPPORTED_SCOPES], + bearer_methods_supported: ["header"], + }; +} + +/** RFC 8414 — the Authorization Server Metadata for the built-in bridge AS. */ +export function authorizationServerMetadata( + requestUrl?: string, +): AuthorizationServerMetadata { + const origin = resolveBaseOrigin(requestUrl); + return { + issuer: origin, + authorization_endpoint: `${origin}/api/mcp/oauth/authorize`, + token_endpoint: `${origin}/api/mcp/oauth/token`, + registration_endpoint: `${origin}/api/mcp/oauth/register`, + scopes_supported: [...SUPPORTED_SCOPES], + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + // Public clients only — PKCE is the proof of possession, not a secret. + token_endpoint_auth_methods_supported: ["none"], + // S256 ONLY — `plain` is structurally unsupported (RFC 7636 + connectors). + code_challenge_methods_supported: ["S256"], + // CIMD is the preferred registration path (SEP-991); DCR is the fallback. + client_id_metadata_document_supported: true, + }; +} + +/** Build the `WWW-Authenticate` value pointing a 401 at the PRM (RFC 9728). */ +export function wwwAuthenticateChallenge( + requestUrl?: string, + scope?: string, +): string { + const origin = resolveBaseOrigin(requestUrl); + const prm = `${origin}/.well-known/oauth-protected-resource`; + const parts = ['Bearer realm="healthlog-mcp"', `resource_metadata="${prm}"`]; + if (scope) parts.push(`scope="${scope}"`); + return parts.join(", "); +} diff --git a/src/lib/mcp/oauth/pkce.ts b/src/lib/mcp/oauth/pkce.ts new file mode 100644 index 000000000..aaf8156a0 --- /dev/null +++ b/src/lib/mcp/oauth/pkce.ts @@ -0,0 +1,56 @@ +/** + * PKCE (RFC 7636) — S256 only. + * + * Both Claude.ai and ChatGPT send `code_challenge_method=S256` on every + * authorization request, and the AS advertises ONLY `S256` in + * `code_challenge_methods_supported`. The `plain` method is structurally + * unsupported here — a missing or non-S256 challenge is rejected at `/authorize`, + * and a verifier that does not hash to the bound challenge is rejected at + * `/token`. PKCE is therefore mandatory on the whole authorization-code flow. + */ +import { createHash, timingSafeEqual } from "node:crypto"; + +/** Base64url-encode without padding (the PKCE wire form). */ +function b64url(buf: Buffer): string { + return buf + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +/** The S256 transform: BASE64URL(SHA256(ASCII(verifier))). */ +export function s256Challenge(verifier: string): string { + return b64url(createHash("sha256").update(verifier, "ascii").digest()); +} + +/** RFC 7636 §4.1 — verifier is 43–128 chars of the unreserved set. */ +export function isValidVerifier(verifier: unknown): verifier is string { + return ( + typeof verifier === "string" && /^[A-Za-z0-9\-._~]{43,128}$/.test(verifier) + ); +} + +/** A non-empty challenge string (the exact length is the client's concern). */ +export function isValidChallenge(challenge: unknown): challenge is string { + return ( + typeof challenge === "string" && + challenge.length >= 43 && + challenge.length <= 128 + ); +} + +/** + * Constant-time check that `verifier` hashes (S256) to `challenge`. Returns + * false for any malformed input rather than throwing. + */ +export function verifyPkceS256(verifier: string, challenge: string): boolean { + if (!isValidVerifier(verifier) || !isValidChallenge(challenge)) { + return false; + } + const computed = Buffer.from(s256Challenge(verifier)); + const expected = Buffer.from(challenge); + return ( + computed.length === expected.length && timingSafeEqual(computed, expected) + ); +} diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts new file mode 100644 index 000000000..f3a403c1a --- /dev/null +++ b/src/lib/mcp/prompts.ts @@ -0,0 +1,262 @@ +/** + * Phase 4 — MCP prompts (user-controlled templated workflows). + * + * Prompts are the slash-command surface a host exposes to the user. Unlike + * tools (model-controlled), a prompt is picked by the user and returns a set of + * messages pre-loaded with REAL, server-retrieved data plus the safety framing + * injected once, centrally (catalogue §5 #2, ADR-004). + * + * `doctor_visit_summary` is the showcase capability: it assembles a grounded, + * structured visit-prep summary from the SAME doctor-report data path + * (`collectDoctorReportData`) that backs the PDF the user already trusts — so + * the numbers match the export byte-for-byte. It carries the identical grounding + * discipline as the Coach: + * - only values the server computed (no fabricated readings); + * - reference / clinical bands are server-side, never invented by the model; + * - DATA + CONTEXT only — never a diagnosis, verdict, or treatment change. + * + * Token budget (R-DEL-2): the prompt SUMMARISES (latest + aggregate per metric, + * latest reading per analyte) — it never dumps the full per-reading history. + */ +import { z } from "zod/v4"; + +import { annotate } from "@/lib/logging/context"; +import { + collectDoctorReportData, + type DoctorReportData, +} from "@/lib/doctor-report-data"; +import { getMetricStatusMeta } from "@/lib/insights/metric-status-registry"; +import { coachScopeWindowSchema } from "@/lib/ai/coach/types"; +import type { CoachScopeWindow } from "@/lib/ai/coach/types"; +import type { McpAuthContext } from "./auth"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** Trailing-day count per window, capped at the doctor-report 365-day ceiling. */ +const WINDOW_DAYS: Record = { + last7days: 7, + last30days: 30, + last90days: 90, + lastYear: 365, + allTime: 365, +}; + +/** A returned prompt message — mirrors the SDK `GetPromptResult` shape. */ +export interface McpPromptMessage { + role: "user" | "assistant"; + content: { type: "text"; text: string }; +} + +export interface McpPromptResult { + description?: string; + messages: McpPromptMessage[]; +} + +/** A registry entry — transport-agnostic, mirroring `McpToolDefinition`. */ +export interface McpPromptDefinition { + name: string; + title: string; + description: string; + /** zod/v4 raw shape handed to the SDK's `registerPrompt` for arg validation. */ + argsShape: z.ZodRawShape; + run: ( + ctx: McpAuthContext, + args: Record, + ) => Promise; +} + +/** + * Fallback units for the headline specialised metric types the generic + * `metric-status-registry` intentionally omits (it carries the synced/additive + * metrics only). The doctor-report stats map is keyed by `MeasurementType`. + */ +const FALLBACK_UNITS: Record = { + WEIGHT: "kg", + BLOOD_PRESSURE_SYS: "mmHg", + BLOOD_PRESSURE_DIA: "mmHg", + PULSE: "bpm", + BODY_MASS_INDEX: "kg/m²", + SLEEP_DURATION: "min", +}; + +function unitAndBandFor(type: string): { + unit: string; + referenceBand: { low: number; high: number } | null; +} { + const meta = getMetricStatusMeta(type); + if (meta) { + return { + unit: meta.unit, + referenceBand: meta.normalRange + ? { low: meta.normalRange.low, high: meta.normalRange.high } + : null, + }; + } + return { unit: FALLBACK_UNITS[type] ?? "", referenceBand: null }; +} + +const round = (n: number): number => Math.round(n * 100) / 100; + +/** + * Reduce the full doctor-report payload to a compact, clinician-oriented + * summary. Latest + aggregate per metric, latest reading per analyte — never + * the full per-reading history (R-DEL-2). Every numeric value is one the server + * computed; every band is server-side. No verdict, no diagnosis. + */ +function summariseForVisit(data: DoctorReportData): Record { + const vitals = Object.entries(data.stats).map(([type, s]) => { + const { unit, referenceBand } = unitAndBandFor(type); + return { + metric: type, + unit, + referenceBand, + latest: round(s.latest), + avg: round(s.avg), + min: round(s.min), + max: round(s.max), + readings: s.count, + }; + }); + + const compliance = Object.entries(data.compliance).map(([name, c]) => ({ + medication: name, + takenDoses: c.taken, + expectedDoses: c.total, + missedDoses: c.missed, + adherencePct: + c.total > 0 ? Math.min(100, Math.round((c.taken / c.total) * 100)) : null, + })); + + const medications = data.medications + .slice(0, 40) + .map((m) => ({ name: m.name, dose: m.dose })); + + const labs = (data.labResults ?? []).slice(0, 50).map((l) => ({ + analyte: l.analyte, + panel: l.panel, + value: l.value, + valueText: l.valueText, + unit: l.unit, + referenceLow: l.referenceLow, + referenceHigh: l.referenceHigh, + takenAt: l.takenAt, + readings: l.count, + })); + + const wellness = (data.wellnessScores ?? []).map((w) => ({ + score: w.type, + latest: round(w.latest), + avg: round(w.avg), + note: "descriptive composite, not a clinical assessment", + })); + + const illness = (data.illnessEpisodes ?? []).map((e) => ({ + label: e.label, + type: e.type, + lifecycle: e.lifecycle, + onsetAt: e.onsetAt, + resolvedAt: e.resolvedAt, + })); + + return { + period: { + days: data.period.days, + start: data.period.start, + end: data.period.end, + }, + patient: { + dateOfBirth: data.patient.dateOfBirth, + sex: data.patient.gender, + heightCm: data.patient.heightCm, + }, + bmi: data.bmi, + vitals, + medications, + compliance, + // Mood is privacy-gated upstream; include only the aggregate when present. + ...(data.mood + ? { + mood: { + avg: round(data.mood.avg), + min: data.mood.min, + max: data.mood.max, + entries: data.mood.count, + }, + } + : {}), + // Glucose clinical panel only when the user logs glucose (stillLearning + // flags a too-thin period — surfaced honestly, never asserted). + ...(data.glucoseClinical && !data.glucoseClinical.stillLearning + ? { glucosePanel: data.glucoseClinical } + : {}), + ...(labs.length > 0 ? { labs } : {}), + ...(wellness.length > 0 ? { wellnessScores: wellness } : {}), + ...(illness.length > 0 ? { illnessEpisodes: illness } : {}), + }; +} + +/** + * The framing block prepended to the assembled data. Injected once, centrally: + * the assistant is told to organise + present, never to diagnose or invent. + */ +const VISIT_FRAMING = [ + "You are preparing a concise, factual summary to help the user get ready for a doctor's visit.", + "Use ONLY the structured data below — every value, unit, and reference band is computed server-side from the user's own records.", + "Do NOT invent, estimate, or infer any reading that is not present. Do NOT provide a diagnosis, clinical verdict, risk score, or treatment/medication change — those are the clinician's role.", + "Organise the data into clear sections (vitals, medications & adherence, labs, notable changes). Where a value sits outside its reference band, state the value and band factually; do not interpret it.", + "If a section is empty, say it was not recorded in this period rather than guessing.", +].join(" "); + +export const MCP_PROMPTS: McpPromptDefinition[] = [ + { + name: "doctor_visit_summary", + title: "Doctor visit summary", + description: + "Assemble a structured, grounded visit-prep summary from the user's own records over a window — vitals (with units + reference bands), medications & adherence, labs (with reference ranges), and notable context. Reuses the doctor-report data path so the numbers match the exported PDF. Data + context only: no diagnosis, no verdict.", + argsShape: { window: coachScopeWindowSchema.optional() }, + async run(ctx, args) { + const window = ( + typeof args.window === "string" ? args.window : "last90days" + ) as CoachScopeWindow; + const days = WINDOW_DAYS[window] ?? 90; + const end = new Date(); + const start = new Date(end.getTime() - days * MS_PER_DAY); + + const data = await collectDoctorReportData(ctx.userId, { + start, + end, + days, + }); + const summary = summariseForVisit(data); + + annotate({ + action: { name: "mcp.prompt.invoked" }, + meta: { + prompt: "doctor_visit_summary", + days, + vitals: Array.isArray(summary.vitals) ? summary.vitals.length : 0, + }, + }); + + return { + description: `Visit-prep summary over the last ${days} days, grounded in your own records.`, + messages: [ + { + role: "user", + content: { + type: "text", + text: `${VISIT_FRAMING}\n\nDATA (server-authoritative, JSON):\n${JSON.stringify( + summary, + )}`, + }, + }, + ], + }; + }, + }, +]; + +/** Stable list of the registered prompt names. */ +export const MCP_PROMPT_NAMES: readonly string[] = MCP_PROMPTS.map( + (p) => p.name, +); diff --git a/src/lib/mcp/resources.ts b/src/lib/mcp/resources.ts new file mode 100644 index 000000000..10c010851 --- /dev/null +++ b/src/lib/mcp/resources.ts @@ -0,0 +1,130 @@ +/** + * Transport-agnostic MCP resource registry. + * + * Resources expose read-only context the assistant can surface alongside tool + * results (REQ-R10). Like the tools, they are server-authoritative reads of the + * canonical tables scoped to the session `userId` — no new analytics, no + * caller-supplied id. Absence is explicit via `{ present: false }` (REQ-SEC-4). + * + * The profile resource is deliberately data-minimised: it ships only the + * health-relevant context an assistant needs (age in whole years, gender, + * height, timezone, unit preferences) and omits direct identifiers (email, + * username, role, insurance number) — narrowing the surface on the + * external-assistant boundary. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import type { McpAuthContext } from "./auth"; + +export interface McpResourceDefinition { + name: string; + /** Fixed resource URI (e.g. `healthlog://profile`). */ + uri: string; + title: string; + description: string; + mimeType: string; + read: (ctx: McpAuthContext) => Promise; +} + +/** Whole-year age from a date of birth, or null when unknown. */ +function ageYears(dateOfBirth: Date | null): number | null { + if (!dateOfBirth) return null; + const now = new Date(); + let age = now.getUTCFullYear() - dateOfBirth.getUTCFullYear(); + const monthDelta = now.getUTCMonth() - dateOfBirth.getUTCMonth(); + if ( + monthDelta < 0 || + (monthDelta === 0 && now.getUTCDate() < dateOfBirth.getUTCDate()) + ) { + age -= 1; + } + return age >= 0 && age < 150 ? age : null; +} + +export const MCP_RESOURCES: McpResourceDefinition[] = [ + { + name: "profile", + uri: "healthlog://profile", + title: "User profile", + description: + "The user's health-relevant profile context: age, gender, height, timezone, and unit preferences. Identifiers are omitted.", + mimeType: "application/json", + async read(ctx) { + const user = await prisma.user.findUnique({ + where: { id: ctx.userId }, + select: { + heightCm: true, + dateOfBirth: true, + gender: true, + timezone: true, + unitPreference: true, + glucoseUnit: true, + }, + }); + annotate({ + action: { name: "mcp.resource.read" }, + meta: { resource: "profile", present: user !== null }, + }); + if (!user) return { present: false }; + return { + present: true, + ageYears: ageYears(user.dateOfBirth), + gender: user.gender ?? null, + heightCm: user.heightCm ?? null, + timezone: user.timezone, + unitPreference: + user.unitPreference === "imperial" ? "imperial" : "metric", + glucoseUnit: user.glucoseUnit ?? "mg/dL", + }; + }, + }, + { + name: "medications", + uri: "healthlog://medications", + title: "Medications", + description: + "The user's tracked medications with their schedules (dose, cadence, time windows). Read-only list; use get_medication_compliance for adherence figures.", + mimeType: "application/json", + async read(ctx) { + const medications = await prisma.medication.findMany({ + where: { userId: ctx.userId }, + include: { schedules: true }, + orderBy: { createdAt: "desc" }, + }); + annotate({ + action: { name: "mcp.resource.read" }, + meta: { resource: "medications", present: medications.length > 0 }, + }); + if (medications.length === 0) return { present: false, count: 0 }; + return { + present: true, + count: medications.length, + medications: medications.map((med) => ({ + name: med.name, + dose: med.dose, + treatmentClass: med.treatmentClass, + asNeeded: med.asNeeded, + paused: med.pausedAt !== null, + startsOn: med.startsOn ? med.startsOn.toISOString() : null, + endsOn: med.endsOn ? med.endsOn.toISOString() : null, + schedules: med.schedules.map((s) => ({ + label: s.label ?? null, + dose: s.dose ?? null, + windowStart: s.windowStart, + windowEnd: s.windowEnd, + timesOfDay: s.timesOfDay, + daysOfWeek: s.daysOfWeek ?? null, + rrule: s.rrule ?? null, + rollingIntervalDays: s.rollingIntervalDays ?? null, + scheduleType: s.scheduleType, + })), + })), + }; + }, + }, +]; + +/** Stable list of the registered resource URIs. */ +export const MCP_RESOURCE_URIS: readonly string[] = MCP_RESOURCES.map( + (r) => r.uri, +); diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts new file mode 100644 index 000000000..0da7d7c66 --- /dev/null +++ b/src/lib/mcp/rich-reads.ts @@ -0,0 +1,671 @@ +/** + * Phase 4 — deep-value MCP read tools (engine glue). + * + * Four "bring data into relation" reads, each a THIN re-export of an existing + * server-authoritative engine — no new statistics, no new business logic + * (REQ-WONT-2 / catalogue §5): + * + * 1. get_correlation — one named driver pair out of the FDR-controlled, + * lag-aware discovery (`readCoachCorrelations`, the + * same engine the Coach `get_correlations` tool and + * the `/api/insights/correlations` route run). + * 2. compare_metric — two trailing-window or two-metric rollup snapshots + * side by side (`readBestGranularityRollups` + + * `aggregateWmyBuckets`) with structured deltas. + * 3. get_metric_baseline — the metric's personal usual range (median ± k·MAD) + * + today's placement, via `buildCoachReadStrip` + * (the same engine the metric-page "Coach read" strip + * renders). + * 4. detect_changepoints — level shifts in a metric over the rollup tier, via + * a minimal in-repo CUSUM binary-segmentation over the + * rollup bucket means (no heavy dependency, high + * firing bar). + * + * Grounding contract (REQ-SEC-2/3/4, ADR-004): every read returns structured + * values + units + reference bands + provenance and uses `{ present: false }` + * for absence / insufficiency — never a silent zero, never a prose verdict or + * diagnosis. Associations are described, never asserted as causal. `userId` is + * the session-narrowed id passed by the caller, never a tool argument + * (REQ-SEC-5). All reads are read-only. + */ +import type { MeasurementType } from "@/generated/prisma/client"; + +import { annotate } from "@/lib/logging/context"; +import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; +import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; +import { + readBestGranularityRollups, + aggregateWmyBuckets, +} from "@/lib/rollups/measurement-read-wmy"; +import { + getMetricStatusMeta, + METRIC_STATUS_IDS, +} from "@/lib/insights/metric-status-registry"; +import type { CoachScopeWindow } from "@/lib/ai/coach/types"; + +/** Trailing-day count for each window the rich reads accept. */ +const WINDOW_DAYS: Record = { + last7days: 7, + last30days: 30, + last90days: 90, + lastYear: 365, + // The rollup tier caps "all time" timelines at one year elsewhere; keep the + // rich reads bounded to the same ceiling so the token budget stays sane. + allTime: 365, +}; + +function windowToDays(window: CoachScopeWindow | undefined): number { + return WINDOW_DAYS[window ?? "last30days"]; +} + +// ── Metric resolution ──────────────────────────────────────────────── +// +// A scalar metric the rich reads can place on a single rollup series, carrying +// its canonical unit + population reference band (when a broadly-accepted one +// exists). Most entries are derived straight from the single-source +// `metric-status-registry`; a small explicit supplement covers the headline +// specialised metrics (weight / pulse / BMI) the registry intentionally omits. + +export interface RichMetric { + /** The DB `MeasurementType` the rollup tier + baseline engine read against. */ + measurementType: MeasurementType; + /** Human label for the resolved metric (provenance / narration). */ + label: string; + /** Canonical storage unit. */ + unit: string; + /** Population reference band, or `null` when no universal band exists. */ + band: { low: number; high: number } | null; +} + +/** Headline specialised metrics the status registry does not carry. */ +const SUPPLEMENT: Record = { + weight: { + measurementType: "WEIGHT", + label: "Weight", + unit: "kg", + // No universal healthy band — body-size dependent; defer to own baseline. + band: null, + }, + pulse: { + measurementType: "PULSE", + label: "Pulse", + unit: "bpm", + band: { low: 60, high: 100 }, + }, + bmi: { + measurementType: "BODY_MASS_INDEX", + label: "Body-mass index", + unit: "kg/m²", + band: { low: 18.5, high: 25 }, + }, +}; + +/** Friendly aliases (Coach source slugs + natural phrasings) → registry id. */ +const ALIASES: Record = { + resting_hr: "RESTING_HEART_RATE", + resting_heart_rate: "RESTING_HEART_RATE", + hrv: "HEART_RATE_VARIABILITY", + spo2: "OXYGEN_SATURATION", + blood_oxygen: "OXYGEN_SATURATION", + vo2_max: "VO2_MAX", + vo2max: "VO2_MAX", + steps: "STEPS", + glucose: "BLOOD_GLUCOSE", + blood_glucose: "BLOOD_GLUCOSE", + sleep: "SLEEP_DURATION", + sleep_duration: "SLEEP_DURATION", + distance: "WALKING_RUNNING_DISTANCE", + active_energy: "ACTIVE_ENERGY", + body_temp: "BODY_TEMPERATURE", + body_temperature: "BODY_TEMPERATURE", + daylight: "TIME_IN_DAYLIGHT", + respiratory_rate: "RESPIRATORY_RATE", + walking_hr: "WALKING_HEART_RATE_AVERAGE", +}; + +function fromRegistry(id: string): RichMetric | null { + const meta = getMetricStatusMeta(id); + if (!meta) return null; + return { + measurementType: meta.measurementType, + label: meta.displayName, + unit: meta.unit, + band: meta.normalRange + ? { low: meta.normalRange.low, high: meta.normalRange.high } + : null, + }; +} + +/** + * Resolve a free-text metric name to a single scalar series. Forgiving for an + * NL assistant (alias, exact id, display-name match) but closed — an + * unresolved name returns `null` so the tool reports `{ present: false }` + * rather than inventing a series. Multi-series metrics (blood pressure) are not + * resolvable here by design; the prompt + `get_metric_series` cover BP. + */ +export function resolveRichMetric(input: string): RichMetric | null { + const raw = input.trim(); + if (!raw) return null; + const key = raw.toLowerCase().replace(/[\s-]+/g, "_"); + + // 1. explicit supplement (weight / pulse / bmi). + if (SUPPLEMENT[key]) return SUPPLEMENT[key]; + + // 2. friendly alias → registry id. + const aliased = ALIASES[key]; + if (aliased) return fromRegistry(aliased); + + // 3. exact registry id (case-insensitive). + const upper = key.toUpperCase(); + if ((METRIC_STATUS_IDS as readonly string[]).includes(upper)) { + const direct = fromRegistry(upper); + if (direct) return direct; + } + + // 4. display-name match (e.g. "heart-rate variability"). + const needle = raw.toLowerCase(); + for (const id of METRIC_STATUS_IDS) { + const meta = getMetricStatusMeta(id); + if (!meta) continue; + const name = meta.displayName.toLowerCase(); + if (name === needle || name.includes(needle) || needle.includes(name)) { + return fromRegistry(id); + } + } + return null; +} + +// ── 1. get_correlation ─────────────────────────────────────────────── + +export interface CorrelationResult { + present: boolean; + reason?: string; + /** The matched driver pair (descriptive, never causal). */ + pair?: { + behaviour: string; + outcome: string; + direction: "higher" | "lower"; + lagDays: number; + n: number; + r: number; + note: string; + }; + /** Honest footer — pairs tested + window the discovery scanned. */ + pairsTested?: number; + windowDays?: number; + /** Constant marker so the model never re-frames the link as causal. */ + association?: "descriptive"; +} + +function norm(label: string): string { + return label.trim().toLowerCase(); +} + +/** True when `a`/`b` (either order) match the driver's behaviour/outcome. */ +function pairMatches( + behaviour: string, + outcome: string, + a: string, + b: string, +): boolean { + const contains = (hay: string, needle: string) => + hay.includes(needle) || needle.includes(hay); + return ( + (contains(behaviour, a) && contains(outcome, b)) || + (contains(behaviour, b) && contains(outcome, a)) + ); +} + +/** + * Return the FDR-controlled, lag-aware association between two named metrics. + * Pure re-export: runs the SAME discovery the Coach `get_correlations` tool and + * the insight route run, then selects the surviving pair (strongest |r|) that + * matches the requested two metrics — never re-computing a relationship the + * engine did not surface. Honest `{ present: false }` when no surviving pair + * matches (sparse data, or the link did not clear the engine's floors). + */ +export async function getCorrelation( + userId: string, + args: { metricA: string; metricB: string }, +): Promise { + const a = norm(args.metricA); + const b = norm(args.metricB); + const result = await readCoachCorrelations(userId); + + if (!result.present || !result.drivers || result.drivers.length === 0) { + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_correlation", present: false }, + }); + return { + present: false, + reason: result.reason ?? "no_significant_pattern", + pairsTested: result.pairsTested, + windowDays: result.windowDays, + }; + } + + const matches = result.drivers.filter((d) => + pairMatches(norm(d.behaviour), norm(d.outcome), a, b), + ); + if (matches.length === 0) { + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_correlation", present: false }, + }); + return { + present: false, + reason: "no_significant_pattern_for_pair", + pairsTested: result.pairsTested, + windowDays: result.windowDays, + }; + } + + const best = matches.reduce((acc, cur) => + Math.abs(cur.r) > Math.abs(acc.r) ? cur : acc, + ); + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_correlation", present: true }, + }); + return { + present: true, + pair: { + behaviour: best.behaviour, + outcome: best.outcome, + direction: best.direction, + lagDays: best.lagDays, + n: best.n, + r: best.r, + note: best.note, + }, + pairsTested: result.pairsTested, + windowDays: result.windowDays, + association: "descriptive", + }; +} + +// ── 2. compare_metric ──────────────────────────────────────────────── + +interface MetricWindowSnapshot { + label: string; + unit: string; + band: { low: number; high: number } | null; + windowDays: number; + granularity: string; + count: number; + mean: number | null; + min: number | null; + max: number | null; +} + +export interface CompareMetricResult { + present: boolean; + reason?: string; + mode?: "metric_vs_metric" | "window_vs_window"; + a?: MetricWindowSnapshot; + b?: MetricWindowSnapshot; + /** Delta b − a, only when both sides share a unit; null otherwise. */ + delta?: { mean: number; pct: number | null } | null; +} + +async function snapshotMetricWindow( + userId: string, + metric: RichMetric, + windowDays: number, +): Promise { + const read = await readBestGranularityRollups( + userId, + metric.measurementType, + windowDays, + ); + if (!read || read.rows.length === 0) return null; + const agg = aggregateWmyBuckets(read.rows); + if (agg.count === 0) return null; + return { + label: metric.label, + unit: metric.unit, + band: metric.band, + windowDays, + granularity: read.granularity, + count: agg.count, + mean: agg.mean, + min: agg.min, + max: agg.max, + }; +} + +/** + * Compare a metric against another metric (same trailing window) OR a single + * metric across two trailing windows. Pure re-export of the WMY rollup reader + + * the linear `aggregateWmyBuckets` composition — no new math. Windows are + * trailing-to-now (e.g. last 30 days vs last 90 days); a delta is only computed + * when both sides carry the same unit, so the result never compares unlike + * scales. `{ present: false }` when neither side has data. + */ +export async function compareMetric( + userId: string, + args: { + metric: string; + metricB?: string; + window?: CoachScopeWindow; + windowB?: CoachScopeWindow; + }, +): Promise { + const metricA = resolveRichMetric(args.metric); + if (!metricA) { + return { present: false, reason: "unknown_metric" }; + } + const daysA = windowToDays(args.window); + + const annotateMiss = () => + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "compare_metric", present: false }, + }); + + let mode: "metric_vs_metric" | "window_vs_window"; + let metricB: RichMetric; + let daysB: number; + + if (args.metricB) { + const resolvedB = resolveRichMetric(args.metricB); + if (!resolvedB) { + annotateMiss(); + return { present: false, reason: "unknown_metric_b" }; + } + mode = "metric_vs_metric"; + metricB = resolvedB; + daysB = daysA; + } else if (args.windowB) { + mode = "window_vs_window"; + metricB = metricA; + daysB = windowToDays(args.windowB); + } else { + annotateMiss(); + return { present: false, reason: "specify_metricB_or_windowB" }; + } + + const [a, b] = await Promise.all([ + snapshotMetricWindow(userId, metricA, daysA), + snapshotMetricWindow(userId, metricB, daysB), + ]); + + if (!a || !b) { + annotateMiss(); + return { + present: false, + reason: "no_data", + mode, + ...(a ? { a } : {}), + ...(b ? { b } : {}), + }; + } + + let delta: { mean: number; pct: number | null } | null = null; + if (a.unit === b.unit && a.mean !== null && b.mean !== null) { + const diff = b.mean - a.mean; + delta = { + mean: diff, + pct: a.mean !== 0 ? (diff / Math.abs(a.mean)) * 100 : null, + }; + } + + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "compare_metric", present: true }, + }); + return { present: true, mode, a, b, delta }; +} + +// ── 3. get_metric_baseline ─────────────────────────────────────────── + +export interface MetricBaselineResult { + present: boolean; + reason?: string; + metric?: string; + unit?: string; + /** The user's personal usual range (median ± k·MAD) + sample transparency. */ + baseline?: { low: number; high: number; sampleDays: number }; + /** Today's latest reading. */ + latest?: number; + /** Where the latest reading sits relative to the personal band. */ + placement?: "within" | "above" | "below"; + /** Population reference band, or `null` when none exists for the metric. */ + referenceBand?: { low: number; high: number } | null; + /** The single strongest lagged driver of this metric, or `null`. */ + driver?: { note: string; behaviour: string; outcome: string } | null; +} + +/** + * Return where today's value sits against the user's own usual range. Pure + * re-export of `buildCoachReadStrip` — the SAME median ± k·MAD baseline engine + * (`computeVitalsBaseline`) + lagged-driver pick the metric page renders. Below + * the engine's 7-day history floor the band is not asserted (`{ present: false, + * reason: "insufficient_history" }`) — never a fabricated range. The population + * reference band rides along as general context even on a miss. + */ +export async function getMetricBaseline( + userId: string, + args: { metric: string }, +): Promise { + const metric = resolveRichMetric(args.metric); + if (!metric) { + return { present: false, reason: "unknown_metric" }; + } + + const strip = await buildCoachReadStrip(userId, metric.measurementType); + + if (!strip.baseline) { + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_metric_baseline", present: false }, + }); + return { + present: false, + reason: strip.learning ? "insufficient_history" : "no_data", + metric: metric.label, + unit: metric.unit, + referenceBand: metric.band, + }; + } + + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_metric_baseline", present: true }, + }); + return { + present: true, + metric: metric.label, + unit: metric.unit, + baseline: { + low: strip.baseline.low, + high: strip.baseline.high, + sampleDays: strip.baseline.sampleDays, + }, + latest: strip.baseline.latest, + placement: strip.baseline.placement, + referenceBand: metric.band, + driver: strip.driver, + }; +} + +// ── 4. detect_changepoints ─────────────────────────────────────────── + +export interface Changepoint { + /** Bucket-start ISO timestamp of the first bucket of the new regime. */ + at: string; + direction: "increase" | "decrease"; + beforeMean: number; + afterMean: number; + delta: number; +} + +export interface ChangepointsResult { + present: boolean; + reason?: string; + metric?: string; + unit?: string; + granularity?: string; + windowDays?: number; + bucketsAnalysed?: number; + changepoints?: Changepoint[]; +} + +const MIN_SEGMENT = 5; +/** Mean shift must clear this many series standard deviations to fire. */ +const SHIFT_SD_FLOOR = 1.5; +const MAX_CHANGEPOINTS = 5; + +function mean(xs: number[]): number { + return xs.reduce((s, x) => s + x, 0) / xs.length; +} + +function stdDev(xs: number[]): number { + if (xs.length < 2) return 0; + const m = mean(xs); + const variance = xs.reduce((s, x) => s + (x - m) ** 2, 0) / (xs.length - 1); + return Math.sqrt(variance); +} + +/** + * Minimal CUSUM binary-segmentation changepoint detector over a 1-D series. + * + * For a segment, the cumulative sum of mean-centred deviations peaks at the most + * likely split. The split fires ONLY when the resulting mean shift clears a + * conservative effect-size floor (≥ SHIFT_SD_FLOOR · series SD) and both halves + * are at least MIN_SEGMENT long — a deliberately high bar so noise does not + * register (honest-null over false positives). Accepted splits recurse on each + * half; the result is capped to the strongest MAX_CHANGEPOINTS shifts by the + * caller. + * + * Indices are into the original series. Pure / dependency-free. + */ +function cusumSegment( + values: number[], + lo: number, + hi: number, + out: Array<{ index: number; beforeMean: number; afterMean: number }>, +): void { + const n = hi - lo; + if (n < 2 * MIN_SEGMENT) return; + + const segment = values.slice(lo, hi); + const segMean = mean(segment); + const sd = stdDev(segment); + if (sd <= 0) return; + + // Cumulative sum of mean-centred deviations; track the extreme position. + let cusum = 0; + let maxAbs = 0; + let splitRel = -1; + for (let i = 0; i < n; i++) { + cusum += segment[i] - segMean; + if (Math.abs(cusum) > maxAbs) { + maxAbs = Math.abs(cusum); + // The new regime starts at the bucket AFTER the accumulation peak. + splitRel = i + 1; + } + } + if (splitRel < MIN_SEGMENT || n - splitRel < MIN_SEGMENT) return; + + const before = segment.slice(0, splitRel); + const after = segment.slice(splitRel); + const beforeMean = mean(before); + const afterMean = mean(after); + if (Math.abs(afterMean - beforeMean) < SHIFT_SD_FLOOR * sd) return; + + out.push({ index: lo + splitRel, beforeMean, afterMean }); + cusumSegment(values, lo, lo + splitRel, out); + cusumSegment(values, lo + splitRel, hi, out); +} + +/** + * Surface level shifts in a metric over the rollup tier. Reads the trailing + * window's rollup buckets (`readBestGranularityRollups` — DAY for ≤90 days, + * coarser for longer windows; the resolved granularity is reported) and runs + * the minimal CUSUM above over the bucket means. High firing bar — returns + * `{ present: false }` when too few buckets exist or no shift clears the floor. + * Re-uses the rollup tier's already-computed bucket means; adds no new + * persisted analytics. + */ +export async function detectChangepoints( + userId: string, + args: { metric: string; window?: CoachScopeWindow }, +): Promise { + const metric = resolveRichMetric(args.metric); + if (!metric) { + return { present: false, reason: "unknown_metric" }; + } + const windowDays = windowToDays(args.window ?? "last90days"); + + const read = await readBestGranularityRollups( + userId, + metric.measurementType, + windowDays, + ); + + const miss = (reason: string): ChangepointsResult => { + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "detect_changepoints", present: false }, + }); + return { + present: false, + reason, + metric: metric.label, + unit: metric.unit, + windowDays, + }; + }; + + if (!read || read.rows.length < 2 * MIN_SEGMENT) { + return miss("insufficient_data"); + } + + const values = read.rows.map((r) => r.mean); + const found: Array<{ index: number; beforeMean: number; afterMean: number }> = + []; + cusumSegment(values, 0, values.length, found); + + if (found.length === 0) { + return { + ...miss("no_changepoint"), + granularity: read.granularity, + bucketsAnalysed: values.length, + }; + } + + const changepoints: Changepoint[] = found + .sort( + (x, y) => + Math.abs(y.afterMean - y.beforeMean) - + Math.abs(x.afterMean - x.beforeMean), + ) + .slice(0, MAX_CHANGEPOINTS) + .map((cp) => ({ + at: read.rows[cp.index].bucketStart.toISOString(), + direction: + cp.afterMean >= cp.beforeMean + ? ("increase" as const) + : ("decrease" as const), + beforeMean: cp.beforeMean, + afterMean: cp.afterMean, + delta: cp.afterMean - cp.beforeMean, + })) + .sort((x, y) => (x.at < y.at ? -1 : 1)); + + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "detect_changepoints", present: true }, + }); + return { + present: true, + metric: metric.label, + unit: metric.unit, + granularity: read.granularity, + windowDays, + bucketsAnalysed: values.length, + changepoints, + }; +} diff --git a/src/lib/mcp/scopes.ts b/src/lib/mcp/scopes.ts new file mode 100644 index 000000000..b04d57d7e --- /dev/null +++ b/src/lib/mcp/scopes.ts @@ -0,0 +1,58 @@ +/** + * MCP scope model. + * + * The MCP wire reuses the existing `hlk_` Bearer tokens as its auth + * substrate (no parallel identity system). Two scope handles frame the + * least-privilege contract: + * + * - `health:read` — read the user's own health data. The default MCP posture. + * - `health:write` — mutate (log a measurement / intake / mood). Reserved for + * the write tools, which are NOT registered in this milestone phase. + * + * Read tools impose no scope requirement of their own: every underlying read + * path is already an unscoped authenticated route, so a narrow-scope token works + * and a wildcard token is never required (REQ-SEC-5). The strong "read-only by + * default" guarantee (REQ-SEC-1 / ADR-003) is structural — only read tools are + * registered — not a runtime flag a future token could flip. Write registration + * (a later phase) gates on `health:write` via `tokenAllowsWrite`. + */ + +/** Wildcard scope — grants every capability the token model can express. */ +export const SCOPE_WILDCARD = "*"; + +/** Read the authenticated user's own health data over MCP. */ +export const SCOPE_HEALTH_READ = "health:read"; + +/** Mutate the authenticated user's data over MCP (reserved; no write tools yet). */ +export const SCOPE_HEALTH_WRITE = "health:write"; + +/** + * Whether a token's scopes admit the read surface. Any valid token qualifies — + * the underlying reads are unscoped — so this is permissive by design and exists + * to document the contract and to give a single place to tighten later. + */ +export function tokenAllowsRead(): boolean { + return true; +} + +/** + * Whether a token's scopes admit the write surface. A token must carry the + * explicit `health:write` scope (or the wildcard). Used to decide whether write + * tools are registered for a session. + */ +export function tokenAllowsWrite(permissions: readonly string[]): boolean { + return ( + permissions.includes(SCOPE_WILDCARD) || + permissions.includes(SCOPE_HEALTH_WRITE) + ); +} + +/** + * The `:` binding (REQ-SEC-11). Identifies a session by the + * user AND the specific token presented — never the user alone — so audit, + * annotation, and (later phases) rate-limit buckets are keyed to the credential + * actually in use. + */ +export function sessionBinding(userId: string, tokenId: string): string { + return `${userId}:${tokenId}`; +} diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts new file mode 100644 index 000000000..67b1d6872 --- /dev/null +++ b/src/lib/mcp/server.ts @@ -0,0 +1,136 @@ +/** + * MCP server factory. + * + * Composes an `McpServer` from the official SDK and registers the read tools + + * resources from the transport-agnostic registries, bound to one resolved + * session context. This factory is intentionally transport-free: the stdio + * adapter (this phase) and the future remote `/mcp` adapter both call + * `createMcpServer(ctx)` and then attach their own transport, so the tool / + * resource surface is identical across wires (ADR-002). + * + * Read tools are always registered. The write tools (`log_measurement` / + * `log_mood`) are registered ONLY for a `health:write`-scoped session + * (`ctx.canWrite`); a read-only session never has them advertised, so the + * read-only-by-default posture is structural (REQ-SEC-1 / ADR-003). There is + * no admin surface here and none can be added structurally — `requireAdmin()` + * is cookie-only and the MCP context carries no cookie (REQ-SEC-7 / ADR-005). + */ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import type { McpAuthContext } from "./auth"; +import { MCP_TOOLS, type McpToolDefinition } from "./tools"; +import { MCP_WRITE_TOOLS } from "./write-tools"; +import { MCP_RESOURCES } from "./resources"; +import { MCP_PROMPTS } from "./prompts"; + +export const MCP_SERVER_NAME = "healthlog"; +export const MCP_SERVER_VERSION = "1.0.0"; + +/** + * Build an MCP server for one authenticated session. The returned server is not + * yet connected to a transport — the caller attaches stdio (or, later, the + * Streamable-HTTP transport). + */ +export function createMcpServer(ctx: McpAuthContext): McpServer { + const server = new McpServer({ + name: MCP_SERVER_NAME, + version: MCP_SERVER_VERSION, + }); + + // Register one tool definition. Shared by the read registry and the + // write-scoped registry so the wire shape can never fork between them. + const registerTool = (tool: McpToolDefinition) => { + server.registerTool( + tool.name, + { + title: tool.title, + description: tool.description, + inputSchema: tool.inputShape, + // Annotations are MANDATORY for the cloud connectors — the ChatGPT + // Apps SDK treats an omitted hint as a validation error. Read tools + // are read-only/non-destructive; write tools advertise + // `readOnlyHint:false` so a host can add human-in-the-loop (ADR-003). + annotations: tool.annotations, + ...(tool.outputShape ? { outputSchema: tool.outputShape } : {}), + }, + async (args: Record) => { + const result = await tool.run(ctx, args ?? {}); + const content = [ + { type: "text" as const, text: JSON.stringify(result) }, + ]; + // When the tool declares an outputSchema (search / fetch), also return + // the object as `structuredContent` so ChatGPT + the model can read the + // typed shape; the JSON-in-`content` keeps backwards compatibility. + if (tool.outputShape) { + return { + content, + structuredContent: result as Record, + }; + } + return { content }; + }, + ); + }; + + for (const tool of MCP_TOOLS) { + registerTool(tool); + } + + // Write tools are advertised ONLY to a write-scoped session. A read-only + // (`health:read`) token never sees them in the capability list — the + // read-only posture is structural, not a runtime flag a tool could flip. + if (ctx.canWrite) { + for (const tool of MCP_WRITE_TOOLS) { + registerTool(tool); + } + } + + for (const prompt of MCP_PROMPTS) { + server.registerPrompt( + prompt.name, + { + title: prompt.title, + description: prompt.description, + argsSchema: prompt.argsShape, + }, + async (args: Record) => { + // The prompt assembles REAL, server-retrieved data + the central + // grounding framing; the result is a set of messages the host inserts + // into the conversation (ADR-004). Read-only, like every Phase-4 + // surface — a prompt never mutates. Returned as a fresh literal so it + // conforms to the SDK's `GetPromptResult` (index-signatured) shape. + const result = await prompt.run(ctx, args ?? {}); + return { + messages: result.messages, + ...(result.description ? { description: result.description } : {}), + }; + }, + ); + } + + for (const resource of MCP_RESOURCES) { + server.registerResource( + resource.name, + resource.uri, + { + title: resource.title, + description: resource.description, + mimeType: resource.mimeType, + }, + async (uri: URL) => { + const data = await resource.read(ctx); + return { + contents: [ + { + uri: uri.href, + mimeType: resource.mimeType, + text: JSON.stringify(data), + }, + ], + }; + }, + ); + } + + return server; +} diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts new file mode 100644 index 000000000..1bd741a3a --- /dev/null +++ b/src/lib/mcp/tools.ts @@ -0,0 +1,536 @@ +/** + * Transport-agnostic MCP read-tool registry. + * + * Each tool is a THIN wrapper over an existing server-authoritative read path — + * the Coach F1 retrieval layer (`@/lib/ai/coach/tools`). No new analytics is + * computed here (REQ-WONT-2); the MCP wire only re-exports what HealthLog + * already computes. The same registry feeds both transports: the stdio adapter + * (this phase) and the remote `/mcp` adapter (a later phase) register from it, + * so the tool contract can never fork between wires (ADR-002). + * + * Grounding contract (REQ-SEC-2/3/4, ADR-004): every tool returns structured + * values + units + reference bands + provenance and uses `{ present: false }` + * for absence — never a silent zero, never a prose verdict or diagnosis. The + * assistant narrates; the server only ships facts it computed. + * + * Read-only contract (REQ-SEC-1, ADR-003): the registry contains read tools + * only. `userId` is always taken from the resolved session context, never from a + * tool argument (REQ-SEC-5). + */ +import { z } from "zod/v4"; + +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; +import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; +import { + coachScopeSourceSchema, + coachScopeWindowSchema, +} from "@/lib/ai/coach/types"; +import { resolveBaseOrigin } from "@/lib/mcp/oauth/config"; +import { + getCorrelation, + compareMetric, + getMetricBaseline, + detectChangepoints, +} from "@/lib/mcp/rich-reads"; +import type { McpAuthContext } from "./auth"; + +/** + * Tool annotations (MCP 2025-11-25). The cloud connectors REQUIRE these on every + * tool — the ChatGPT Apps SDK treats an omitted hint as a validation error, and + * the Claude directory requires a safety hint per tool. Every tool the MCP + * surface exposes is read-only over a closed personal record, so they all share + * the same shape (ADR-003): the read-only guarantee is structural, the + * annotation merely advertises it. + */ +const READ_ONLY_ANNOTATIONS = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +} as const; + +/** + * A registry entry. `inputShape` is a zod/v4 raw shape handed straight to the + * SDK's `registerTool` for argument validation; `run` receives the validated + * arguments and the session context and returns a plain structured object. + */ +export interface McpToolDefinition { + name: string; + title: string; + description: string; + inputShape: z.ZodRawShape; + /** Tool annotations — mandatory for the cloud connectors (see above). */ + annotations: { + readOnlyHint: boolean; + destructiveHint: boolean; + idempotentHint: boolean; + openWorldHint: boolean; + }; + /** + * When set, the tool declares a structured `outputSchema` and its result is + * returned as `structuredContent`. Used by `search` / `fetch` so ChatGPT can + * consume the exact `{id,title,url}[]` / `{id,title,text,url,metadata}` shapes. + */ + outputShape?: z.ZodRawShape; + run: (ctx: McpAuthContext, args: Record) => Promise; +} + +/** + * Run one Coach F1 retrieval tool through its executor. The executor re-parses + * and re-validates the arguments against its own Zod schema, runs the + * server-authoritative snapshot builder scoped to the requested domain, and + * returns the grounded `{ present, reason?, data?, grounding? }` result. It + * never throws and never widens scope. + */ +async function runCoachTool( + ctx: McpAuthContext, + name: string, + args: Record, +): Promise { + const result = await executeCoachTool({ + userId: ctx.userId, + name, + rawArguments: JSON.stringify(args ?? {}), + }); + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: name, present: result.present }, + }); + return result; +} + +/** + * The `search` + `fetch` pair — the de-facto two-tool retrieval convention and, + * critically, the ONLY tools ChatGPT can call in its default (non-Developer) + * mode. Without them HealthLog is invisible in default ChatGPT. The exact wire + * shapes are mandated by OpenAI: + * + * - `search({ query })` → `{ results: [{ id, title, url }] }` + * - `fetch({ id })` → `{ id, title, text, url, metadata? }` + * + * Every result carries a REAL, user-openable HTTPS deep link (`url`) into the + * HealthLog web app — ChatGPT only creates citation metadata when `url` is a + * non-empty string. The internal `id` stays separate from `url`. Both tools are + * thin façades over the SAME server-authoritative read paths the other tools + * use; free-text fields (medication names, lab analytes) are returned as DATA, + * never interpreted as instructions (R-SEC-2). + */ +function searchAndFetchTools(): McpToolDefinition[] { + return [ + { + name: "search", + title: "Search your health records", + description: + "Search the user's own health record — metric domains, medications, and lab biomarkers — for items matching a free-text query. Returns a list of { id, title, url }; pass an id to the `fetch` tool to hydrate it. Each `url` deep-links into the HealthLog web app for citation. Returns an empty list when nothing matches.", + inputShape: { query: z.string().max(200) }, + annotations: READ_ONLY_ANNOTATIONS, + outputShape: { + results: z.array( + z.object({ + id: z.string(), + title: z.string(), + url: z.string(), + }), + ), + }, + async run(ctx, args) { + const query = + typeof args.query === "string" ? args.query.trim().toLowerCase() : ""; + const origin = resolveBaseOrigin(); + const results: Array<{ id: string; title: string; url: string }> = []; + + const inventory = await buildCoachDataInventory(ctx.userId, undefined); + for (const entry of inventory.entries) { + if (!entry.present) continue; + const hay = `${entry.domain} ${entry.metric ?? ""}`.toLowerCase(); + if (query && !hay.includes(query)) continue; + results.push({ + id: entry.metric + ? `metric:${entry.metric}` + : `domain:${entry.domain}`, + title: entry.domain, + url: `${origin}/insights`, + }); + } + + const meds = await prisma.medication.findMany({ + where: { userId: ctx.userId }, + select: { id: true, name: true, dose: true }, + orderBy: { createdAt: "desc" }, + take: 200, + }); + for (const med of meds) { + if (query && !med.name.toLowerCase().includes(query)) continue; + results.push({ + id: `med:${med.id}`, + title: med.dose ? `${med.name} ${med.dose}` : med.name, + url: `${origin}/medications`, + }); + } + + const labs = await prisma.labResult.findMany({ + where: { userId: ctx.userId, deletedAt: null }, + select: { analyte: true }, + distinct: ["analyte"], + take: 200, + }); + for (const lab of labs) { + if (query && !lab.analyte.toLowerCase().includes(query)) continue; + results.push({ + id: `lab:${lab.analyte}`, + title: lab.analyte, + url: `${origin}/labs`, + }); + } + + const capped = results.slice(0, 50); + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "search", present: capped.length > 0 }, + }); + return { results: capped }; + }, + }, + { + name: "fetch", + title: "Fetch one health record", + description: + "Hydrate a single record returned by `search`, by its id (e.g. `metric:weight`, `med:`, `lab:LDL`). Returns { id, title, text, url, metadata } where `text` is a server-authoritative, plain-text summary suitable for citation and `url` deep-links into HealthLog. Returns a not-found message when the id does not resolve.", + inputShape: { id: z.string().min(1).max(200) }, + annotations: READ_ONLY_ANNOTATIONS, + outputShape: { + id: z.string(), + title: z.string(), + text: z.string(), + url: z.string(), + metadata: z.record(z.string(), z.unknown()).optional(), + }, + async run(ctx, args) { + const id = typeof args.id === "string" ? args.id : ""; + const origin = resolveBaseOrigin(); + const sep = id.indexOf(":"); + const kind = sep > 0 ? id.slice(0, sep) : ""; + const rid = sep > 0 ? id.slice(sep + 1) : ""; + + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "fetch", kind: kind || "unknown" }, + }); + + if (kind === "metric" && rid) { + const result = await executeCoachTool({ + userId: ctx.userId, + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: rid }), + }); + return { + id, + title: rid, + text: JSON.stringify(result), + url: `${origin}/insights`, + metadata: { type: "metric", metric: rid }, + }; + } + + if (kind === "lab" && rid) { + const result = await executeCoachTool({ + userId: ctx.userId, + name: "get_labs", + rawArguments: JSON.stringify({ analyte: rid }), + }); + return { + id, + title: rid, + text: JSON.stringify(result), + url: `${origin}/labs`, + metadata: { type: "lab", analyte: rid }, + }; + } + + if (kind === "med" && rid) { + const med = await prisma.medication.findFirst({ + where: { id: rid, userId: ctx.userId }, + select: { + name: true, + dose: true, + treatmentClass: true, + asNeeded: true, + }, + }); + if (!med) { + return { + id, + title: "Not found", + text: "No medication matches this id.", + url: `${origin}/medications`, + metadata: { type: "medication" }, + }; + } + return { + id, + title: med.name, + text: JSON.stringify(med), + url: `${origin}/medications`, + metadata: { type: "medication" }, + }; + } + + return { + id, + title: "Not found", + text: `No record matches the id "${id}".`, + url: `${origin}/insights`, + metadata: { type: "unknown" }, + }; + }, + }, + ]; +} + +// ── Phase 4 deep-value output schemas ──────────────────────────────── +// Every field except `present` is optional so a grounded `{ present: false }` +// miss and a full hit both conform — the SDK validates `structuredContent` +// against these (REQ: structuredContent + outputSchema on the rich reads). + +const bandShape = z + .object({ low: z.number(), high: z.number() }) + .nullable() + .optional(); + +const getCorrelationOutput: z.ZodRawShape = { + present: z.boolean(), + reason: z.string().optional(), + pair: z + .object({ + behaviour: z.string(), + outcome: z.string(), + direction: z.enum(["higher", "lower"]), + lagDays: z.number(), + n: z.number(), + r: z.number(), + note: z.string(), + }) + .optional(), + pairsTested: z.number().optional(), + windowDays: z.number().optional(), + association: z.literal("descriptive").optional(), +}; + +const metricWindowSnapshotSchema = z.object({ + label: z.string(), + unit: z.string(), + band: bandShape, + windowDays: z.number(), + granularity: z.string(), + count: z.number(), + mean: z.number().nullable(), + min: z.number().nullable(), + max: z.number().nullable(), +}); + +const compareMetricOutput: z.ZodRawShape = { + present: z.boolean(), + reason: z.string().optional(), + mode: z.enum(["metric_vs_metric", "window_vs_window"]).optional(), + a: metricWindowSnapshotSchema.optional(), + b: metricWindowSnapshotSchema.optional(), + delta: z + .object({ mean: z.number(), pct: z.number().nullable() }) + .nullable() + .optional(), +}; + +const getMetricBaselineOutput: z.ZodRawShape = { + present: z.boolean(), + reason: z.string().optional(), + metric: z.string().optional(), + unit: z.string().optional(), + baseline: z + .object({ low: z.number(), high: z.number(), sampleDays: z.number() }) + .optional(), + latest: z.number().optional(), + placement: z.enum(["within", "above", "below"]).optional(), + referenceBand: bandShape, + driver: z + .object({ note: z.string(), behaviour: z.string(), outcome: z.string() }) + .nullable() + .optional(), +}; + +const detectChangepointsOutput: z.ZodRawShape = { + present: z.boolean(), + reason: z.string().optional(), + metric: z.string().optional(), + unit: z.string().optional(), + granularity: z.string().optional(), + windowDays: z.number().optional(), + bucketsAnalysed: z.number().optional(), + changepoints: z + .array( + z.object({ + at: z.string(), + direction: z.enum(["increase", "decrease"]), + beforeMean: z.number(), + afterMean: z.number(), + delta: z.number(), + }), + ) + .optional(), +}; + +export const MCP_TOOLS: McpToolDefinition[] = [ + { + name: "list_metrics", + title: "List available metrics", + description: + "Enumerate which of the user's health data exists and how to fetch it: one row per domain with whether data is present, an approximate sample count, and the tool that retrieves it. Call this first to discover what is available before fetching figures.", + inputShape: {}, + annotations: READ_ONLY_ANNOTATIONS, + async run(ctx) { + const inventory = await buildCoachDataInventory(ctx.userId, undefined); + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "list_metrics", present: true }, + }); + return { + present: true, + window: inventory.window, + restMode: inventory.restMode, + cycleEnabled: inventory.cycleEnabled, + metrics: inventory.entries, + }; + }, + }, + { + name: "get_metric_series", + title: "Get a metric time series", + description: + "Fetch the user's own time series for ONE metric (e.g. bp, weight, pulse, hrv, resting_hr, steps, sleep, body composition, vo2_max). Returns an aggregate (count, min, max, mean, slope) plus recent-daily and weekly timelines, with units and population reference bands for safe phrasing. Returns { present: false } when the user has no data for that metric.", + inputShape: { + metric: coachScopeSourceSchema, + window: coachScopeWindowSchema.optional(), + }, + annotations: READ_ONLY_ANNOTATIONS, + run(ctx, args) { + return runCoachTool(ctx, "get_metric_series", args); + }, + }, + { + name: "get_medication_compliance", + title: "Get medication compliance", + description: + "Fetch the user's cadence-aware medication adherence: the dose-weighted compliance rate, expected vs taken/missed counts, current-cycle status, and any GLP-1 titration context. Returns { present: false } when no medications are tracked.", + inputShape: { + window: coachScopeWindowSchema.optional(), + }, + annotations: READ_ONLY_ANNOTATIONS, + run(ctx, args) { + return runCoachTool(ctx, "get_medication_compliance", args); + }, + }, + { + name: "get_labs", + title: "Get lab results", + description: + "Fetch the user's most recent lab results — the latest reading per biomarker over the last 12 months, optionally filtered to one named analyte. Each reading carries its value, unit, reference range, and in-range/below/above status. Returns { present: false } when no labs are on file.", + inputShape: { + analyte: z.string().min(1).max(80).optional(), + }, + annotations: READ_ONLY_ANNOTATIONS, + run(ctx, args) { + return runCoachTool(ctx, "get_labs", args); + }, + }, + { + name: "get_correlations", + title: "Get discovered correlations", + description: + "Fetch the user's statistically-vetted (FDR-controlled) day-to-next-day driver pairs between behaviours (daylight, mood, glucose, blood pressure, steps) and outcomes (sleep, HRV, resting HR, weight), each with direction, lag, sample size, and a descriptive — never causal — note over a fixed trailing window. Returns { present: false } when too little paired data exists.", + inputShape: {}, + annotations: READ_ONLY_ANNOTATIONS, + run(ctx, args) { + return runCoachTool(ctx, "get_correlations", args); + }, + }, + // ── Phase 4 deep-value reads (catalogue §5 #1, #3, #4, #7) ────────── + { + name: "get_correlation", + title: "Get a correlation between two metrics", + description: + "Fetch the statistically-vetted (FDR-controlled), lag-aware association between TWO named metrics the user tracks (e.g. 'sleep' and 'resting heart rate'). Re-exports the same discovery engine the other correlation surfaces run; returns the matched pair's direction, lag, sample size, Pearson r, and a descriptive — never causal — note. Returns { present: false } when no significant pattern links the pair (sparse data or the link did not clear the engine's floors).", + inputShape: { + metricA: z.string().min(1).max(60), + metricB: z.string().min(1).max(60), + }, + annotations: READ_ONLY_ANNOTATIONS, + outputShape: getCorrelationOutput, + run(ctx, args) { + return getCorrelation(ctx.userId, { + metricA: String(args.metricA ?? ""), + metricB: String(args.metricB ?? ""), + }); + }, + }, + { + name: "compare_metric", + title: "Compare metrics or windows", + description: + "Compare one metric against another over the same trailing window, OR a single metric across two trailing windows (e.g. last 30 days vs last 90 days). Returns each side's rollup statistics (count, mean, min, max) with units and reference bands, plus a delta + percent change when both sides share a unit. Windows are trailing-to-now. Returns { present: false } when a side has no data.", + inputShape: { + metric: z.string().min(1).max(60), + metricB: z.string().min(1).max(60).optional(), + window: coachScopeWindowSchema.optional(), + windowB: coachScopeWindowSchema.optional(), + }, + annotations: READ_ONLY_ANNOTATIONS, + outputShape: compareMetricOutput, + run(ctx, args) { + return compareMetric(ctx.userId, { + metric: String(args.metric ?? ""), + metricB: typeof args.metricB === "string" ? args.metricB : undefined, + window: args.window as never, + windowB: args.windowB as never, + }); + }, + }, + { + name: "get_metric_baseline", + title: "Get a metric's personal baseline", + description: + "Fetch where the user's latest reading for ONE metric sits against their own usual range (median ± robust deviation), plus the strongest lagged driver of that metric. Re-exports the same baseline engine the metric page renders. Returns the personal band, today's value + placement (within/above/below), and the population reference band. Returns { present: false } with reason 'insufficient_history' below the 7-day learning floor — never a fabricated range.", + inputShape: { + metric: z.string().min(1).max(60), + }, + annotations: READ_ONLY_ANNOTATIONS, + outputShape: getMetricBaselineOutput, + run(ctx, args) { + return getMetricBaseline(ctx.userId, { + metric: String(args.metric ?? ""), + }); + }, + }, + { + name: "detect_changepoints", + title: "Detect level shifts in a metric", + description: + "Surface points where a metric's level shifted over a trailing window (e.g. 'when did my weight trend change?'). Runs a conservative changepoint scan over the rollup tier's bucket means and reports each shift's date, direction, and before/after means with units. High firing bar — returns { present: false } when too little data exists or no shift clears the threshold.", + inputShape: { + metric: z.string().min(1).max(60), + window: coachScopeWindowSchema.optional(), + }, + annotations: READ_ONLY_ANNOTATIONS, + outputShape: detectChangepointsOutput, + run(ctx, args) { + return detectChangepoints(ctx.userId, { + metric: String(args.metric ?? ""), + window: args.window as never, + }); + }, + }, + ...searchAndFetchTools(), +]; + +/** Stable list of the registered tool names. */ +export const MCP_TOOL_NAMES: readonly string[] = MCP_TOOLS.map((t) => t.name); diff --git a/src/lib/mcp/write-tools.ts b/src/lib/mcp/write-tools.ts new file mode 100644 index 000000000..eb1497ad2 --- /dev/null +++ b/src/lib/mcp/write-tools.ts @@ -0,0 +1,261 @@ +/** + * Transport-agnostic MCP write-tool registry — the confirmed write surface. + * + * Registered ONLY for a `health:write`-scoped session (`ctx.canWrite`); a + * read-only session never sees these tools advertised. Each tool wraps an + * in-process write core (`src/lib/mcp/writes.ts`) that mirrors the proven + * Telegram capture helpers, pinned to the `MCP` provenance. + * + * PROTOCOL-LEVEL CONFIRM GATE. The remote `/mcp` wire is stateless + * (`enableJsonResponse: true`, no SSE) so server→client elicitation is + * impossible. Instead every write tool takes an explicit `confirm` flag: + * + * - `confirm:false` (default) → NOTHING is written. The tool returns the + * exact normalized record it WOULD write plus `requiresConfirmation:true` + * and an instruction telling the assistant to confirm the value with the + * user and re-call with `confirm:true` and the SAME `idempotencyKey`. + * - `confirm:true` → the write executes. The `(userId, …, externalId)` + * idempotency derived from `idempotencyKey` makes a retried call a no-op + * (`alreadyLogged:true`) rather than a duplicate. + * + * DELIBERATE SAFETY BOUNDARY — what is NOT here and must NOT be added without + * a security review: + * - No medication create / intake / edit, no schedule or dose changes. + * - No lab write, no clinical-threshold or reference-range change. + * - No delete or update of any existing row (writes are append-only, + * idempotent inserts of single self-reported readings). + * - No `export_data` / data-export tool. If one is ever added it must be + * write-tier AND never co-registered with any egress on the same surface. + * The write surface is intentionally the smallest possible: a confirmed, + * append-only log of a measurement or a mood the user reports themselves. + */ +import { z } from "zod/v4"; + +import type { MeasurementType } from "@/generated/prisma/client"; +import { annotate } from "@/lib/logging/context"; +import { checkMcpWriteRateLimit } from "@/lib/rate-limit"; +import type { McpToolDefinition } from "./tools"; +import { logMcpMeasurement, logMcpMood } from "./writes"; + +/** + * Write-tool annotations (MCP 2025-11-25). NOT read-only and NOT destructive + * (an append-only, idempotent insert of a single self-reported reading), and + * idempotent so a retried call under the same `idempotencyKey` is safe. A host + * that adds human-in-the-loop confirmation keys off `readOnlyHint:false`. + */ +const WRITE_ANNOTATIONS = { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +} as const; + +/** Shared confirm-gate inputs every write tool carries. */ +const confirmField = z + .boolean() + .default(false) + .describe( + "Set to true ONLY after confirming the value with the user. When false or absent the tool returns a preview and writes nothing.", + ); +const idempotencyKeyField = z + .string() + .min(1) + .max(120) + .describe( + "A stable client-chosen key for this logical write. Re-calling with the same key (after a preview) commits exactly one row; a further retry is a safe no-op.", + ); + +/** The instruction the preview hands back to the assistant. */ +const CONFIRM_INSTRUCTION = + "This writes to the user's own health record. Confirm the value with the user, then call this tool again with confirm:true and the SAME idempotencyKey to commit it."; + +/** + * Enforce the tighter per-credential write budget before a commit. Returns a + * structured refusal when the budget is exhausted so a runaway client cannot + * flood writes (this is ON TOP of the `/mcp` per-binding request limiter). + */ +async function writeBudgetOk(binding: string): Promise { + const rl = await checkMcpWriteRateLimit(binding); + if (!rl.allowed) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "write", status: "rate_limited" }, + }); + } + return rl.allowed; +} + +export const MCP_WRITE_TOOLS: McpToolDefinition[] = [ + { + name: "log_measurement", + title: "Log a measurement", + description: + "Log ONE single-value health measurement to the user's own record (e.g. weight, pulse, blood glucose, body temperature, oxygen saturation, a body-composition value). Requires confirmation: call once to preview the exact record, confirm the value with the user, then call again with confirm:true and the same idempotencyKey. Blood pressure (two values) and computed/clinical-only metrics are not loggable here.", + inputShape: { + type: z + .string() + .min(1) + .max(60) + .describe( + "The measurement type, e.g. WEIGHT, PULSE, BLOOD_GLUCOSE, BODY_TEMPERATURE, OXYGEN_SATURATION, BODY_FAT. Not all types are loggable; an unsupported type is refused.", + ), + value: z + .number() + .describe("The numeric reading in the type's canonical unit."), + unit: z + .string() + .min(1) + .max(20) + .optional() + .describe( + "Optional unit hint; the server uses the type's canonical unit when omitted.", + ), + measuredAt: z.iso + .datetime({ offset: true }) + .optional() + .describe( + "Optional ISO-8601 instant the reading was taken; defaults to now.", + ), + confirm: confirmField, + idempotencyKey: idempotencyKeyField, + }, + annotations: WRITE_ANNOTATIONS, + async run(ctx, args) { + const type = String(args.type ?? "") as MeasurementType; + const value = typeof args.value === "number" ? args.value : Number.NaN; + const unit = typeof args.unit === "string" ? args.unit : undefined; + const measuredAt = + typeof args.measuredAt === "string" + ? new Date(args.measuredAt) + : undefined; + const confirm = args.confirm === true; + const idempotencyKey = + typeof args.idempotencyKey === "string" ? args.idempotencyKey : ""; + + if (!Number.isFinite(value)) { + return { written: false, error: "invalid_number" }; + } + + if (!confirm) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_measurement", status: "preview" }, + }); + return { + requiresConfirmation: true, + written: false, + preview: { + type, + value, + unit: unit ?? null, + measuredAt: measuredAt ? measuredAt.toISOString() : "now", + source: "MCP", + }, + instruction: CONFIRM_INSTRUCTION, + }; + } + + if (!(await writeBudgetOk(ctx.binding))) { + return { written: false, error: "rate_limited" }; + } + + const result = await logMcpMeasurement({ + userId: ctx.userId, + type, + value, + unit, + measuredAt, + idempotencyKey, + }); + + if (result.status === "unsupported_type") { + return { written: false, error: "unsupported_type" }; + } + if (result.status === "out_of_range") { + return { written: false, error: "out_of_range", reason: result.reason }; + } + if (result.status === "already_logged") { + return { + written: false, + alreadyLogged: true, + record: result.measurement, + }; + } + return { written: true, record: result.measurement }; + }, + }, + { + name: "log_mood", + title: "Log a mood entry", + description: + "Log the user's mood for today on a 1-5 scale (1 = very low, 5 = very good) to their own record, with an optional short note. Requires confirmation: call once to preview, confirm with the user, then call again with confirm:true and the same idempotencyKey to commit.", + inputShape: { + score: z + .number() + .int() + .min(1) + .max(5) + .describe("Mood score 1-5 (1 = very low, 5 = very good)."), + note: z + .string() + .max(500) + .optional() + .describe("Optional short free-text note (max 500 chars)."), + confirm: confirmField, + idempotencyKey: idempotencyKeyField, + }, + annotations: WRITE_ANNOTATIONS, + async run(ctx, args) { + const score = typeof args.score === "number" ? args.score : Number.NaN; + const note = typeof args.note === "string" ? args.note : undefined; + const confirm = args.confirm === true; + const idempotencyKey = + typeof args.idempotencyKey === "string" ? args.idempotencyKey : ""; + + if (!Number.isInteger(score) || score < 1 || score > 5) { + return { written: false, error: "invalid_score" }; + } + + if (!confirm) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_mood", status: "preview" }, + }); + return { + requiresConfirmation: true, + written: false, + preview: { score, note: note ?? null, source: "MCP" }, + instruction: CONFIRM_INSTRUCTION, + }; + } + + if (!(await writeBudgetOk(ctx.binding))) { + return { written: false, error: "rate_limited" }; + } + + const result = await logMcpMood({ + userId: ctx.userId, + score, + note, + idempotencyKey, + }); + + if (result.status === "invalid_score") { + return { written: false, error: "invalid_score" }; + } + if (result.status === "already_logged") { + return { + written: false, + alreadyLogged: true, + record: result.moodEntry, + }; + } + return { written: true, record: result.moodEntry }; + }, + }, +]; + +/** Stable list of the registered write-tool names. */ +export const MCP_WRITE_TOOL_NAMES: readonly string[] = MCP_WRITE_TOOLS.map( + (t) => t.name, +); diff --git a/src/lib/mcp/writes.ts b/src/lib/mcp/writes.ts new file mode 100644 index 000000000..0ba6e3838 --- /dev/null +++ b/src/lib/mcp/writes.ts @@ -0,0 +1,333 @@ +/** + * MCP write data cores — in-process measurement + mood logging. + * + * These mirror the proven server-authoritative Telegram capture helpers + * (`src/lib/measurements/create-from-telegram.ts`, + * `src/lib/mood/create-from-telegram.ts`) one-for-one — same range guard, + * same canonical unit, same cache invalidation + rollup recompute — but pin + * `source` to the dedicated `MCP` provenance and use an MCP idempotency + * namespace. The two surfaces are kept as deliberate parallels rather than a + * shared core so a future change to one cannot silently alter the other's + * validation or dedup contract. + * + * `userId` is ALWAYS the resolved session id (`McpAuthContext.userId`), never + * a caller-supplied field. The write tools (`src/lib/mcp/write-tools.ts`) own + * the confirm gate; these cores only execute a confirmed write. + */ +import { createHash } from "node:crypto"; + +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { encryptNote, readNote } from "@/lib/crypto/note-cipher"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { + getUnitForType, + validateMeasurementRange, +} from "@/lib/validations/measurement"; +import { isTelegramCapturableType } from "@/lib/measurements/create-from-telegram"; +import { + invalidateUserMeasurements, + invalidateUserMood, +} from "@/lib/cache/invalidate"; +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; +import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; +import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels"; +import { getScoreForMood } from "@/lib/validations/moodlog"; +import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push"; + +/** The MCP write provenance — distinct from MANUAL / TELEGRAM. */ +const MCP_SOURCE = "MCP" as const; + +/** + * Derive the stable per-write `externalId` from the caller's idempotency key. + * A SHA-256 hash keeps the column bounded and opaque regardless of how long + * the assistant's key is, and gives the MCP writes their own dedup namespace + * `(userId, type, MCP, externalId)` so a key can never collide with a + * Telegram or manual row. + */ +function mcpExternalId( + prefix: "measure" | "mood", + idempotencyKey: string, +): string { + const digest = createHash("sha256").update(idempotencyKey).digest("hex"); + return `mcp:${prefix}:${digest}`; +} + +/** Normalized record echoed back to the assistant (preview + commit result). */ +export interface McpMeasurementRecord { + type: MeasurementType; + value: number; + unit: string; + measuredAt: string; + source: typeof MCP_SOURCE; +} + +export type McpMeasurementResult = + | { status: "unsupported_type" } + | { status: "out_of_range"; reason: string } + | { status: "written"; measurement: McpMeasurementRecord } + | { status: "already_logged"; measurement: McpMeasurementRecord }; + +/** + * Log one single-value measurement on behalf of the resolved MCP session. + * + * Mirrors `logTelegramMeasurement`: gates the type on the SAME + * `isTelegramCapturableType` allowlist (the safe, user-loggable subset that + * excludes COMPUTED / WHOOP / clinical-only types), validates the plausibility + * range, and is idempotent on `(userId, type, MCP, externalId)`. + */ +export async function logMcpMeasurement(input: { + userId: string; + type: MeasurementType; + value: number; + unit?: string; + measuredAt?: Date; + idempotencyKey: string; +}): Promise { + if (!isTelegramCapturableType(input.type)) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_measurement", status: "unsupported_type" }, + }); + return { status: "unsupported_type" }; + } + + const rangeError = validateMeasurementRange(input.type, input.value); + if (rangeError !== null) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_measurement", status: "out_of_range" }, + }); + return { status: "out_of_range", reason: rangeError }; + } + + const unit = input.unit ?? getUnitForType(input.type); + const measuredAt = input.measuredAt ?? new Date(); + const externalId = mcpExternalId("measure", input.idempotencyKey); + + const record: McpMeasurementRecord = { + type: input.type, + value: input.value, + unit, + measuredAt: measuredAt.toISOString(), + source: MCP_SOURCE, + }; + + const existing = await prisma.measurement.findUnique({ + where: { + userId_type_source_externalId: { + userId: input.userId, + type: input.type, + source: MCP_SOURCE, + externalId, + }, + }, + select: { id: true, value: true, unit: true, measuredAt: true }, + }); + if (existing) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_measurement", status: "already_logged" }, + }); + return { + status: "already_logged", + measurement: { + type: input.type, + value: existing.value, + unit: existing.unit ?? unit, + measuredAt: existing.measuredAt.toISOString(), + source: MCP_SOURCE, + }, + }; + } + + // No mass assignment — every column is set explicitly from validated input. + await prisma.measurement.create({ + data: { + userId: input.userId, + type: input.type, + value: input.value, + unit, + source: MCP_SOURCE, + measuredAt, + externalId, + }, + }); + + invalidateUserMeasurements(input.userId, { evict: true }); + + // Best-effort rollup refresh — a cache tier, never a write-path invariant. + try { + await recomputeBucketsForMeasurement(input.userId, input.type, measuredAt); + } catch (rollupErr) { + getEvent()?.addMeta( + "mcp_measurement_rollup_failed", + rollupErr instanceof Error ? rollupErr.message : String(rollupErr), + ); + } + + await auditLog("mcp.write.measurement", { + userId: input.userId, + details: { + type: input.type, + source: MCP_SOURCE, + idempotencyKey: input.idempotencyKey, + }, + }); + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_measurement", status: "written" }, + }); + + return { status: "written", measurement: record }; +} + +/** Normalized mood record echoed back to the assistant. */ +export interface McpMoodRecord { + score: number; + mood: string; + note: string | null; + date: string; + source: typeof MCP_SOURCE; +} + +export type McpMoodResult = + | { status: "invalid_score" } + | { status: "written"; moodEntry: McpMoodRecord } + | { status: "already_logged"; moodEntry: McpMoodRecord }; + +/** + * Log one mood entry on behalf of the resolved MCP session. + * + * Mirrors `logTelegramMood`: maps the 1..5 score to the canonical mood enum, + * anchors the date key in the user's timezone, and is idempotent on the + * NULL-distinct `(userId, source, externalId)` unique with `source = "MCP"`. + */ +export async function logMcpMood(input: { + userId: string; + score: number; + note?: string | null; + tz?: string | null; + idempotencyKey: string; +}): Promise { + const mood = MOOD_ENUM_BY_SCORE[input.score]; + if (!mood) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_mood", status: "invalid_score" }, + }); + return { status: "invalid_score" }; + } + + const score = getScoreForMood(mood); + const note = input.note ? input.note.slice(0, 500) : null; + const moodLoggedAt = new Date(); + const tz = input.tz ?? DEFAULT_TIMEZONE; + const date = moodDateKey(moodLoggedAt, tz); + const externalId = mcpExternalId("mood", input.idempotencyKey); + + const existing = await prisma.moodEntry.findUnique({ + where: { + userId_source_externalId: { + userId: input.userId, + source: MCP_SOURCE, + externalId, + }, + }, + select: { + mood: true, + score: true, + note: true, + noteEncrypted: true, + date: true, + }, + }); + if (existing) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_mood", status: "already_logged" }, + }); + return { + status: "already_logged", + moodEntry: { + score: existing.score, + mood: existing.mood, + note: readNote(existing.noteEncrypted, existing.note), + date: existing.date, + source: MCP_SOURCE, + }, + }; + } + + // No mass assignment — every column is set explicitly from validated input. + // v1.23 — encrypt the note at rest; legacy plaintext column nulled. + const entry = await prisma.moodEntry.create({ + data: { + userId: input.userId, + date, + tz, + mood, + score, + note: null, + noteEncrypted: encryptNote(note), + source: MCP_SOURCE, + externalId, + moodLoggedAt, + }, + select: { + date: true, + mood: true, + note: true, + noteEncrypted: true, + tags: true, + }, + }); + + const savedNote = readNote(entry.noteEncrypted, entry.note); + + invalidateUserMood(input.userId); + + // Best-effort rollup refresh — a cache tier, never a write-path invariant. + try { + await recomputeMoodBucketsForEntry(input.userId, moodLoggedAt); + } catch (rollupErr) { + getEvent()?.addMeta( + "mcp_mood_rollup_failed", + rollupErr instanceof Error ? rollupErr.message : String(rollupErr), + ); + } + + // Reverse-sync to MoodLog (fire-and-forget; never throws). + void pushMoodEntriesToMoodLog(input.userId, [ + { + date: entry.date, + moodLoggedAt, + mood: entry.mood, + note: savedNote, + tags: entry.tags, + source: MCP_SOURCE, + }, + ]).catch(() => {}); + + await auditLog("mcp.write.mood", { + userId: input.userId, + details: { source: MCP_SOURCE, idempotencyKey: input.idempotencyKey }, + }); + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_mood", status: "written" }, + }); + + return { + status: "written", + moodEntry: { + score, + mood: entry.mood, + note: savedNote, + date: entry.date, + source: MCP_SOURCE, + }, + }; +} diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts index 87e83011b..5b76f000c 100644 --- a/src/lib/modules/__tests__/gate.test.ts +++ b/src/lib/modules/__tests__/gate.test.ts @@ -21,6 +21,7 @@ import { import { isCoreDomain, isModuleKey, + isOptInModule, moduleDelegatesTo, MODULE_KEYS, CORE_DOMAIN_KEYS, @@ -309,6 +310,57 @@ describe("resolveModuleEnabled — operator layer (two-layer AND)", () => { }); }); +describe("resolveModuleEnabled — mcp module (opt-in, default-OFF)", () => { + it("is OFF when no preference is recorded (inverse of the default-on siblings)", () => { + expect(resolveModuleEnabled("mcp", inputs(), false, ALL_AVAILABLE)).toBe( + false, + ); + }); + + it("stays OFF on an explicit false", () => { + expect( + resolveModuleEnabled( + "mcp", + inputs({ modulePreferences: { mcp: false } }), + false, + ALL_AVAILABLE, + ), + ).toBe(false); + }); + + it("turns ON only on an explicit true (opt-in)", () => { + expect( + resolveModuleEnabled( + "mcp", + inputs({ modulePreferences: { mcp: true } }), + false, + ALL_AVAILABLE, + ), + ).toBe(true); + }); + + it("operator-off short-circuits even when the user opted in", () => { + expect( + resolveModuleEnabled( + "mcp", + inputs({ modulePreferences: { mcp: true } }), + false, + operator({ mcp: false }), + ), + ).toBe(false); + }); +}); + +describe("registry — opt-in marker", () => { + it("marks only the mcp module opt-in (every other module is default-on)", () => { + expect(isOptInModule("mcp")).toBe(true); + for (const key of MODULE_KEYS) { + if (key === "mcp") continue; + expect(isOptInModule(key)).toBe(false); + } + }); +}); + describe("resolveOperatorAvailability — disabled allowlist (default-available)", () => { it("treats null / non-object / array as all-available", () => { for (const raw of [null, undefined, "nope", ["mood"]]) { diff --git a/src/lib/modules/gate.ts b/src/lib/modules/gate.ts index 34a4797a9..c568356a5 100644 --- a/src/lib/modules/gate.ts +++ b/src/lib/modules/gate.ts @@ -53,7 +53,12 @@ import { getOperatorModuleAvailability, type OperatorModuleAvailability, } from "./operator-availability"; -import { MODULE_KEYS, moduleDelegatesTo, type ModuleKey } from "./registry"; +import { + MODULE_KEYS, + isOptInModule, + moduleDelegatesTo, + type ModuleKey, +} from "./registry"; export { getOperatorModuleAvailability, @@ -142,6 +147,13 @@ export function resolveModuleEnabled( return assistantCoach && !inputs.disableCoach; } + // Opt-in module (e.g. the remote MCP endpoint): OFF until the user + // records an explicit `true`. The inverse of the default-on posture so a + // new external surface ships dark (ADR-007 / REQ-OPS-1). + if (isOptInModule(key)) { + return inputs.modulePreferences[key] === true; + } + // Disabled allowlist: only an explicit `false` turns the module off. return inputs.modulePreferences[key] !== false; } diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 2c6800775..0ae94722a 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -49,7 +49,8 @@ export type ModuleCategory = | "device" // device / passive-sync domains (sleep, workouts, recovery) | "engagement" // gamification (achievements) | "intelligence" // AI-driven surfaces (coach, insights) - | "export"; // outbound reporting (doctor report / FHIR) + | "export" // outbound reporting (doctor report / FHIR) + | "integration"; // external connectivity (the remote MCP endpoint) export interface ModuleDefinition { /** Stable key — the literal used in `modulePreferencesJson` + the API. */ @@ -66,6 +67,17 @@ export interface ModuleDefinition { * there is exactly one source of truth. See `ModuleDelegation`. */ delegatesTo?: ModuleDelegation; + /** + * Inverts the per-user default for this key. Ordinary toggleable modules + * are a DISABLED allowlist (default-on; only an explicit `false` turns + * them off). An `optIn` module is the opposite: OFF until the user + * records an explicit `true`. Used for surfaces that expose a new + * external attack surface and must ship dark — the remote MCP endpoint + * (ADR-007 / REQ-OPS-1). The operator-availability layer still applies + * (an operator can disable it server-wide); this flag only flips the + * per-user default. + */ + optIn?: boolean; /** * For delegated modules only: where the real on/off control lives, so the * Modules hub can render a read-only "managed in X" row that deep-links to @@ -103,6 +115,14 @@ export const MODULE_KEYS = [ // re-enabling finds the rows intact. "medications", "doctorReport", + // v1.22.0 — the remote Model Context Protocol endpoint (`/mcp`). Unlike + // every other module this is OPT-IN (off by default): it exposes a new + // external-assistant attack surface, so it ships dark and the operator / + // user turns it on deliberately (ADR-007 / REQ-OPS-1). The `optIn` marker + // on its registry entry inverts the per-user default; everything else + // (operator-availability layer, PATCH plumbing, the Modules hub toggle) + // reuses the existing module machinery unchanged. + "mcp", ] as const; export type ModuleKey = (typeof MODULE_KEYS)[number]; @@ -236,6 +256,15 @@ export const MODULE_REGISTRY: Readonly> = descriptionKey: "modules.doctorReport.description", category: "export", }, + mcp: { + key: "mcp", + labelKey: "modules.mcp.label", + descriptionKey: "modules.mcp.description", + category: "integration", + // Off by default: exposes a remote external-assistant surface, so it + // must be turned on deliberately (ADR-007 / REQ-OPS-1). + optIn: true, + }, }); const MODULE_KEY_SET: ReadonlySet = new Set(MODULE_KEYS); @@ -251,3 +280,12 @@ export function moduleDelegatesTo( ): ModuleDelegation | undefined { return MODULE_REGISTRY[key].delegatesTo; } + +/** + * True for a module whose per-user default is OFF (opt-in). The gate reads + * such a key as enabled only on an explicit `true`, the inverse of the + * disabled-allowlist default-on posture. See `ModuleDefinition.optIn`. + */ +export function isOptInModule(key: ModuleKey): boolean { + return MODULE_REGISTRY[key].optIn === true; +} diff --git a/src/lib/observability/redact-payload.ts b/src/lib/observability/redact-payload.ts index 8e537041c..ef66fff89 100644 --- a/src/lib/observability/redact-payload.ts +++ b/src/lib/observability/redact-payload.ts @@ -91,6 +91,11 @@ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [ /telegram[_-]?chat[_-]?id/i, /chatid/i, /chat[_-]id/i, + // v1.22.0 — the PKCE `code_verifier` (the proof-of-possession secret the MCP + // OAuth token endpoint receives). `access_token` / `refresh_token` / + // `client_secret` are already covered by `/token/i` + `/secret/i`; the + // verifier is the one secret-shaped key those patterns miss. + /verifier/i, ]; const REDACTED = "[redacted]"; diff --git a/src/lib/query-keys/integrations.ts b/src/lib/query-keys/integrations.ts index 17c459337..361372960 100644 --- a/src/lib/query-keys/integrations.ts +++ b/src/lib/query-keys/integrations.ts @@ -5,6 +5,14 @@ */ export const integrationKeys = { tokens: () => ["tokens"] as const, + // v1.22.0 — MCP connector tokens (the dedicated `health:read` scope minted + // for the remote MCP endpoint). Distinct key from `tokens()` so the MCP + // settings card's mint / revoke mutations invalidate only this list. + mcpTokens: () => ["mcp-tokens"] as const, + // MCP Phase 3 — OAuth connector connections (the revocable unit for a remote + // client authorized through the OAuth bridge). Distinct from `mcpTokens()` so + // a connection revoke invalidates only the connection list. + mcpConnections: () => ["mcp-connections"] as const, telegram: () => ["telegram"] as const, telegramSettings: () => ["telegram", "settings"] as const, withings: () => ["withings"] as const, diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 57e970e8e..d72de6da5 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -87,6 +87,46 @@ export async function checkAnalyticsReadRateLimit( ); } +/** + * v1.22.0 — per-credential ceiling for the remote MCP endpoint (`/mcp`). + * The bucket is keyed by the `:` binding the Bearer + * resolver surfaces — NOT the user alone — so a single leaked / shared + * token cannot exhaust the budget for the account's other tokens, and the + * cap is scoped to the exact credential in use (REQ-SEC-9, REQ-SEC-11). + * An MCP client legitimately fans several tool calls out per turn, so the + * window is generous; it only caps a runaway loop or a scripted drain. + */ +const MCP_LIMIT = 120; +const MCP_WINDOW_MS = 60 * 1000; + +export async function checkMcpRateLimit( + binding: string, +): Promise { + return checkRateLimit(`mcp:${binding}`, MCP_LIMIT, MCP_WINDOW_MS); +} + +/** + * v1.22.0 — tighter per-credential ceiling for the remote MCP WRITE surface + * (the `log_measurement` / `log_mood` tools), applied ON TOP of the general + * `/mcp` per-binding request limiter. Keyed by the same `:` + * binding so a single leaked / shared write-scoped token cannot flood writes + * into the account, and the cap is scoped to the exact credential in use. + * Writes are deliberate, low-frequency events, so this is far tighter than the + * read budget — 60 confirmed writes per 5 minutes only caps a runaway loop. + */ +const MCP_WRITE_LIMIT = 60; +const MCP_WRITE_WINDOW_MS = 5 * 60 * 1000; + +export async function checkMcpWriteRateLimit( + binding: string, +): Promise { + return checkRateLimit( + `mcp-write:${binding}`, + MCP_WRITE_LIMIT, + MCP_WRITE_WINDOW_MS, + ); +} + /** * v1.16.16 — per-user ceiling for the AI-consent surfaces * (`POST /api/consent/ai`, `POST /api/consent/ai/web`, diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 991094226..2c11ba6f0 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -184,6 +184,13 @@ export const measurementSourceEnum = z.enum([ // decode the rows it surfaces; the client-facing write surfaces still // reject it (see `WRITABLE_MEASUREMENT_SOURCES` + the batch allowlist). "TELEGRAM", + // v1.22.0 — measurements logged through the confirmed remote MCP write + // surface under a `health:write`-scoped token. Server-resolved (the row is + // written in-process over `/mcp`, never through the cookie/Bearer client + // write path), so it is excluded from `WRITABLE_MEASUREMENT_SOURCES`; part + // of this enum so the read/response shapes (and the iOS decoder) can decode + // the rows it surfaces. + "MCP", ]); /** diff --git a/src/proxy.ts b/src/proxy.ts index 57edf78aa..0e51bc8e3 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -71,6 +71,13 @@ const PUBLIC_PATHS = [ */ const WELL_KNOWN_PUBLIC_PATHS = new Set([ "/.well-known/apple-app-site-association", + // v1.22.0 — OAuth discovery for the remote MCP connector. A remote client + // (Claude.ai / ChatGPT) reads these unauthenticated to bootstrap the OAuth + // 2.1 + PKCE flow from the pasted `/mcp` URL alone: RFC 9728 Protected + // Resource Metadata + RFC 8414 Authorization Server Metadata. Both are + // public, deterministic, secret-free discovery documents. + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-authorization-server", ]); /** @@ -86,6 +93,16 @@ function isPublicPath(pathname: string): boolean { if (pathname === "/onboarding" || pathname.startsWith("/onboarding/")) { return true; } + // v1.22.0 — the remote MCP endpoint authenticates by `Authorization: + // Bearer hlk_…`, never by a session cookie, so it must skip the page + // cookie-redirect (mirroring the webhook / `/api/c/` header-authed + // surfaces). The route handler resolves the Bearer token itself, gates on + // the off-by-default `mcp` module, and rate-limits per credential. Exact + // `/mcp` plus `/mcp/` only — a `/mcp-something` page can never inherit + // the bypass. + if (pathname === "/mcp" || pathname.startsWith("/mcp/")) { + return true; + } if (WELL_KNOWN_PUBLIC_PATHS.has(pathname)) { return true; } diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index c43a8e404..574237115 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -317,6 +317,7 @@ describe("doctor-report — module enable/disable gating", () => { insights: true, medications: true, doctorReport: true, + mcp: true, } as const; it("includes every module's data when all modules are enabled", async () => {