*/ 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); }