2026-03-30 21:05:12 -05:00

385 lines
12 KiB
PHP

<?php
/**
* Family Hub MCP server (stdio, JSON-RPC). No Composer or Node.
*
* Env (set in Cursor MCP config):
* FAMILYHUB_BASE_URL e.g. https://example.com/family-hub/api
* FAMILYHUB_MCP_TOKEN same value as MCP_API_TOKEN in .env
*/
declare(strict_types=1);
const FAMILYHUB_MCP_PROTOCOL_VERSION = '2024-11-05';
/** @var list<string> */
const FAMILYHUB_MCP_POST_ALLOWLIST = [
'family_settings_save.php',
'meal_ingredient_to_grocery.php',
'meal_shopping_item_to_grocery.php',
'meal_push_grocery.php',
'meal_plan_set_slot.php',
'meal_plan_set_week.php',
'meal_delete.php',
'meal_export.php',
'meal_import.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<string> */
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<string, mixed>|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<int, array<string, mixed>>
*/
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<string, mixed> $args
* @return array{isError?: bool, content: list<array{type: string, text: string}>}
*/
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<string, mixed> $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);
}