MCP Feature added
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
<?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);
|
||||
}
|
||||
Reference in New Issue
Block a user