diff --git a/api/data_snapshot.php b/api/data_snapshot.php new file mode 100644 index 0000000..6475dbb --- /dev/null +++ b/api/data_snapshot.php @@ -0,0 +1,30 @@ + false, 'error' => 'Method not allowed'], 405); +} + +$people = normalizePeopleList(readJsonFile('people.json')); +requireActivePerson($people); + +$allowed = [ + 'people' => 'people.json', + 'chores' => 'chores.json', + 'stores' => 'stores.json', + 'grocery_lists' => 'grocery_lists.json', + 'grocery_catalog' => 'grocery_catalog.json', + 'meals' => 'meals.json', + 'meal_plans' => 'meal_plans.json', + 'family_settings' => 'family_settings.json', + 'expenses' => 'expenses.json', +]; + +$type = isset($_GET['type']) ? trim((string) $_GET['type']) : ''; +if ($type === '' || !isset($allowed[$type])) { + sendJson(['success' => false, 'error' => 'type must be one of: ' . implode(', ', array_keys($allowed))], 400); +} + +$data = readJsonFile($allowed[$type]); +sendJson(['success' => true, 'type' => $type, 'data' => $data]); diff --git a/env.example b/env.example index 8438f33..76e34f6 100644 --- a/env.example +++ b/env.example @@ -17,4 +17,10 @@ APP_URL=http://localhost/family-hub # Export Settings EXPORT_FREQUENCY=daily -EXPORT_RETENTION_DAYS=30 \ No newline at end of file +EXPORT_RETENTION_DAYS=30 + +# MCP / machine clients (optional). Leave MCP_API_TOKEN empty to disable Bearer auth. +# Generate MCP_API_TOKEN with: openssl rand -hex 32 +MCP_API_TOKEN= +# people.json id of the Head of household this token should act as (must be role head_of_household) +MCP_ACTOR_PERSON_ID= \ No newline at end of file diff --git a/includes/api_bootstrap.php b/includes/api_bootstrap.php index 032fa79..765b5eb 100644 --- a/includes/api_bootstrap.php +++ b/includes/api_bootstrap.php @@ -4,6 +4,7 @@ require_once __DIR__ . '/../config/config.php'; require_once __DIR__ . '/db.php'; require_once __DIR__ . '/utils.php'; require_once __DIR__ . '/persona.php'; +require_once __DIR__ . '/mcp_token_auth.php'; header('Content-Type: application/json; charset=utf-8'); @@ -11,6 +12,8 @@ if (session_status() === PHP_SESSION_NONE) { session_start(); } +familyHubApplyMcpTokenAuthIfConfigured(); + /** * @return array */ diff --git a/includes/mcp_token_auth.php b/includes/mcp_token_auth.php new file mode 100644 index 0000000..6398e3d --- /dev/null +++ b/includes/mcp_token_auth.php @@ -0,0 +1,59 @@ + $value) { + if (strcasecmp((string) $name, 'Authorization') === 0 && is_string($value) && $value !== '') { + return $value; + } + } + } + } + return null; +} diff --git a/mcp/familyhub/server.php b/mcp/familyhub/server.php new file mode 100644 index 0000000..f9b89d9 --- /dev/null +++ b/mcp/familyhub/server.php @@ -0,0 +1,381 @@ + */ +const FAMILYHUB_MCP_POST_ALLOWLIST = [ + 'family_settings_save.php', + 'meal_ingredient_to_grocery.php', + 'meal_push_grocery.php', + 'meal_plan_set_slot.php', + 'meal_plan_set_week.php', + 'meal_delete.php', + 'meal_save.php', + 'grocery_item_create.php', + 'grocery_item_review.php', + 'grocery_item_delete.php', + 'grocery_item_purchase.php', + 'store_delete.php', + 'store_update.php', + 'store_create.php', + 'chore_save.php', + 'expense_create.php', + 'chore_review.php', + 'chore_submit.php', + 'chore_delete.php', + 'people_create_first.php', + 'people_update.php', + 'people_create.php', + 'people_reset_pin.php', + 'people_delete.php', + 'switch_person.php', +]; + +/** @var list */ +const FAMILYHUB_MCP_SNAPSHOT_TYPES = [ + 'people', + 'chores', + 'stores', + 'grocery_lists', + 'grocery_catalog', + 'meals', + 'meal_plans', + 'family_settings', + 'expenses', +]; + +/** + * @return array{base: string, token: string} + */ +function familyHubMcpEnv(): array { + $base = rtrim((string) getenv('FAMILYHUB_BASE_URL'), '/'); + $token = (string) getenv('FAMILYHUB_MCP_TOKEN'); + return ['base' => $base, 'token' => $token]; +} + +/** + * @param 'GET'|'POST' $method + * @return array{ok: bool, status: int, body: string} + */ +function familyHubMcpHttp(string $method, string $pathAndQuery, ?array $jsonBody): array { + $c = familyHubMcpEnv(); + if ($c['base'] === '' || $c['token'] === '') { + return ['ok' => false, 'status' => 0, 'body' => 'Set FAMILYHUB_BASE_URL and FAMILYHUB_MCP_TOKEN in the MCP server environment.']; + } + $path = ltrim($pathAndQuery, '/'); + if ($path === '' || strpos($path, '..') !== false) { + return ['ok' => false, 'status' => 0, 'body' => 'Invalid path.']; + } + $url = $c['base'] . '/' . $path; + + $headerLines = ['Authorization: Bearer ' . $c['token']]; + $content = ''; + if ($method === 'POST' && $jsonBody !== null) { + $headerLines[] = 'Content-Type: application/json; charset=utf-8'; + $enc = json_encode($jsonBody, JSON_UNESCAPED_UNICODE); + $content = $enc !== false ? $enc : '{}'; + } + + $ctx = stream_context_create([ + 'http' => [ + 'method' => $method, + 'header' => implode("\r\n", $headerLines), + 'content' => $content, + 'timeout' => 120, + 'ignore_errors' => true, + ], + ]); + + $result = @file_get_contents($url, false, $ctx); + $status = 0; + if (isset($http_response_header) && is_array($http_response_header)) { + foreach ($http_response_header as $line) { + if (preg_match('#^HTTP/\S+\s+(\d+)#', $line, $m)) { + $status = (int) $m[1]; + break; + } + } + } + if ($result === false) { + return ['ok' => false, 'status' => $status, 'body' => 'HTTP request failed for ' . $url]; + } + return ['ok' => $status >= 200 && $status < 300, 'status' => $status, 'body' => $result]; +} + +function familyHubMcpWriteJsonrpc(array $payload): void { + $json = json_encode($payload, JSON_UNESCAPED_UNICODE); + if ($json === false) { + return; + } + $out = 'Content-Length: ' . strlen($json) . "\r\n\r\n" . $json; + fwrite(STDOUT, $out); + fflush(STDOUT); +} + +/** + * @return array|null + */ +function familyHubMcpReadMessage(): ?array { + $headerLines = []; + while (true) { + $line = fgets(STDIN); + if ($line === false) { + return null; + } + $line = rtrim($line, "\r\n"); + if ($line === '') { + break; + } + $headerLines[] = $line; + } + $contentLength = 0; + foreach ($headerLines as $hl) { + if (stripos($hl, 'Content-Length:') === 0) { + $contentLength = (int) trim(substr($hl, strlen('Content-Length:'))); + } + } + if ($contentLength <= 0) { + return null; + } + $body = ''; + while (strlen($body) < $contentLength) { + $chunk = fread(STDIN, $contentLength - strlen($body)); + if ($chunk === false || $chunk === '') { + return null; + } + $body .= $chunk; + } + $decoded = json_decode($body, true); + return is_array($decoded) ? $decoded : null; +} + +/** + * @return array> + */ +function familyHubMcpToolList(): array { + $typesEnum = ['type' => 'string', 'enum' => FAMILYHUB_MCP_SNAPSHOT_TYPES]; + $postEnum = ['type' => 'string', 'enum' => FAMILYHUB_MCP_POST_ALLOWLIST]; + + return [ + [ + 'name' => 'familyhub_health', + 'description' => 'Check MCP configuration and API reachability via family_settings snapshot.', + 'inputSchema' => ['type' => 'object', 'properties' => new stdClass()], + ], + [ + 'name' => 'familyhub_get_snapshot', + 'description' => 'Read allowlisted JSON domain data (people, chores, stores, grocery_lists, grocery_catalog, meals, meal_plans, family_settings, expenses).', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => ['type' => $typesEnum], + 'required' => ['type'], + ], + ], + [ + 'name' => 'familyhub_calendar_events', + 'description' => 'GET api/calendar_events.php — agenda for a date range. Omit from/to for server default window.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'from' => ['type' => 'string', 'description' => 'YYYY-MM-DD'], + 'to' => ['type' => 'string', 'description' => 'YYYY-MM-DD'], + ], + ], + ], + [ + 'name' => 'familyhub_post_api', + 'description' => 'POST JSON to an allowlisted api/*.php endpoint (same contract as the web UI). Body is the JSON object sent as the POST body.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'endpoint' => $postEnum, + 'body' => ['type' => 'object', 'description' => 'JSON object payload'], + ], + 'required' => ['endpoint', 'body'], + ], + ], + ]; +} + +/** + * @param array $args + * @return array{isError?: bool, content: list} + */ +function familyHubMcpRunTool(string $name, array $args): array { + if ($name === 'familyhub_health') { + $r = familyHubMcpHttp('GET', 'data_snapshot.php?type=family_settings', null); + $text = $r['ok'] + ? $r['body'] + : 'HTTP ' . $r['status'] . ': ' . $r['body']; + return ['content' => [['type' => 'text', 'text' => $text], ], 'isError' => !$r['ok']]; + } + if ($name === 'familyhub_get_snapshot') { + $type = isset($args['type']) ? trim((string) $args['type']) : ''; + if (!in_array($type, FAMILYHUB_MCP_SNAPSHOT_TYPES, true)) { + return [ + 'content' => [['type' => 'text', 'text' => 'type must be one of: ' . implode(', ', FAMILYHUB_MCP_SNAPSHOT_TYPES)]], + 'isError' => true, + ]; + } + $r = familyHubMcpHttp('GET', 'data_snapshot.php?type=' . rawurlencode($type), null); + return [ + 'content' => [['type' => 'text', 'text' => $r['body']]], + 'isError' => !$r['ok'], + ]; + } + if ($name === 'familyhub_calendar_events') { + $from = isset($args['from']) ? trim((string) $args['from']) : ''; + $to = isset($args['to']) ? trim((string) $args['to']) : ''; + $q = 'calendar_events.php'; + if ($from !== '' && $to !== '') { + $q .= '?from=' . rawurlencode($from) . '&to=' . rawurlencode($to); + } + $r = familyHubMcpHttp('GET', $q, null); + return [ + 'content' => [['type' => 'text', 'text' => $r['body']]], + 'isError' => !$r['ok'], + ]; + } + if ($name === 'familyhub_post_api') { + $endpoint = isset($args['endpoint']) ? trim((string) $args['endpoint']) : ''; + if (!in_array($endpoint, FAMILYHUB_MCP_POST_ALLOWLIST, true)) { + return [ + 'content' => [['type' => 'text', 'text' => 'endpoint not allowlisted']], + 'isError' => true, + ]; + } + $body = $args['body'] ?? []; + if (!is_array($body)) { + $body = []; + } + $r = familyHubMcpHttp('POST', $endpoint, $body); + return [ + 'content' => [['type' => 'text', 'text' => $r['body']]], + 'isError' => !$r['ok'], + ]; + } + return [ + 'content' => [['type' => 'text', 'text' => 'Unknown tool']], + 'isError' => true, + ]; +} + +/** + * @param array $msg + */ +function familyHubMcpHandleMessage(array $msg): void { + $method = isset($msg['method']) ? (string) $msg['method'] : ''; + $isNotification = !array_key_exists('id', $msg); + $id = !$isNotification ? $msg['id'] : null; + + if ($method === 'notifications/initialized' || $method === 'notifications/cancelled') { + return; + } + + if ($method === 'initialize') { + familyHubMcpWriteJsonrpc([ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => [ + 'protocolVersion' => FAMILYHUB_MCP_PROTOCOL_VERSION, + 'capabilities' => ['tools' => new stdClass()], + 'serverInfo' => ['name' => 'familyhub', 'version' => '1.0.0'], + ], + ]); + return; + } + + if ($method === 'ping') { + if (!$isNotification) { + familyHubMcpWriteJsonrpc([ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => new stdClass(), + ]); + } + return; + } + + if ($method === 'tools/list') { + if (!$isNotification) { + familyHubMcpWriteJsonrpc([ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => ['tools' => familyHubMcpToolList()], + ]); + } + return; + } + + if ($method === 'resources/list') { + if (!$isNotification) { + familyHubMcpWriteJsonrpc([ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => ['resources' => []], + ]); + } + return; + } + + if ($method === 'prompts/list') { + if (!$isNotification) { + familyHubMcpWriteJsonrpc([ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => ['prompts' => []], + ]); + } + return; + } + + if ($method === 'tools/call') { + if ($isNotification) { + return; + } + $params = $msg['params'] ?? []; + if (!is_array($params)) { + $params = []; + } + $toolName = isset($params['name']) ? (string) $params['name'] : ''; + $arguments = $params['arguments'] ?? []; + if (!is_array($arguments)) { + $arguments = []; + } + $run = familyHubMcpRunTool($toolName, $arguments); + familyHubMcpWriteJsonrpc([ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => $run, + ]); + return; + } + + if ($isNotification) { + return; + } + + familyHubMcpWriteJsonrpc([ + 'jsonrpc' => '2.0', + 'id' => $id, + 'error' => ['code' => -32601, 'message' => 'Method not found: ' . $method], + ]); +} + +if (PHP_SAPI !== 'cli') { + fwrite(STDERR, "familyhub MCP server: run from CLI.\n"); + exit(1); +} + +while (($msg = familyHubMcpReadMessage()) !== null) { + familyHubMcpHandleMessage($msg); +} diff --git a/readme.md b/readme.md index 2da6990..d99303e 100644 --- a/readme.md +++ b/readme.md @@ -72,6 +72,36 @@ The **Calendar** tab embeds your family calendar in the hub using these values ( | **`EXPORT_FREQUENCY`** | How often exports are expected to run | Your choice (e.g. `daily`). Match how you schedule **`scripts/daily_export.php`** in cron. | | **`EXPORT_RETENTION_DAYS`** | How long to keep export files (interpreted by app logic) | Integer number of days (e.g. `30`). | +### MCP / AI automation (optional) + +These enable **machine-to-machine** access to the same JSON APIs the browser uses. When `MCP_API_TOKEN` is non-empty, API requests may send `Authorization: Bearer `; the app then treats the request as the Head of household identified by `MCP_ACTOR_PERSON_ID` (PIN-verified semantics), without a browser session. + +| Variable | What it is | Where to find it | +| -------- | ----------- | ---------------- | +| **`MCP_API_TOKEN`** | Shared secret for automation (Cursor MCP, scripts, etc.) | Generate locally (treat like a password). Example: `openssl rand -hex 32`. Paste into `.env`. Never commit the real value. If left **empty**, Bearer impersonation is disabled and only normal browser sessions work. | +| **`MCP_ACTOR_PERSON_ID`** | Which person the token impersonates | Open **`data/people.json`** (after setup) and copy the **`id`** field for your **Head of household** profile. Must match a person whose **`role`** is `head_of_household`. Wrong or non-head IDs are ignored and the token will not authenticate. | + +**Security:** The token grants the same power as a logged-in, HoH-verified session (create chores, manage people, grocery data, etc.). Use **HTTPS** in production. Rotate the token by changing `.env` and restarting anything that caches env. + +**Cursor MCP:** The MCP server is plain PHP (no Node): **`mcp/familyhub/server.php`**. Register it in Cursor **Settings → MCP** with the absolute path to PHP and the script, and set environment variables `FAMILYHUB_BASE_URL` (your hub’s **`…/api`** URL, e.g. `http://localhost/family-hub/api`) and `FAMILYHUB_MCP_TOKEN` (same value as **`MCP_API_TOKEN`**). Tools exposed: `familyhub_health`, `familyhub_get_snapshot`, `familyhub_calendar_events`, `familyhub_post_api` (allowlisted POST endpoints only). + +Example `mcp.json` server entry (adjust paths and env): + +```json +{ + "mcpServers": { + "familyhub": { + "command": "php", + "args": ["/full/path/to/familyHub/mcp/familyhub/server.php"], + "env": { + "FAMILYHUB_BASE_URL": "http://localhost/family-hub/api", + "FAMILYHUB_MCP_TOKEN": "paste-the-same-secret-as-MCP_API_TOKEN" + } + } + } +} +``` + ## Calendar preferences (family settings) These are **not** in `.env`; they live in `data/family_settings.json` and are edited under **Family settings → Calendar and dates**. @@ -116,6 +146,7 @@ familyHub/ ├── exports/ # Export output (not tracked in git) ├── scripts/ # Cron / CLI helpers │ └── daily_export.php +├── mcp/familyhub/ # Optional MCP stdio server (PHP only): `server.php` ├── tabs/ # Tab views included by `index.php` │ ├── chores.php │ ├── groceries.php