382 lines
12 KiB
PHP
382 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_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<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);
|
|
}
|