MCP Feature added

This commit is contained in:
Louis Whittington 2026-03-30 19:08:26 -05:00
parent e70f395772
commit b78c91d6c1
6 changed files with 511 additions and 1 deletions

30
api/data_snapshot.php Normal file
View File

@ -0,0 +1,30 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
sendJson(['success' => 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]);

View File

@ -17,4 +17,10 @@ APP_URL=http://localhost/family-hub
# Export Settings
EXPORT_FREQUENCY=daily
EXPORT_RETENTION_DAYS=30
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=

View File

@ -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<string, mixed>
*/

View File

@ -0,0 +1,59 @@
<?php
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/persona.php';
/**
* If MCP_API_TOKEN is set in .env and the request sends a matching Bearer token,
* establish session as MCP_ACTOR_PERSON_ID (must be an existing Head of household).
* Browser requests without Authorization are unchanged.
*/
function familyHubApplyMcpTokenAuthIfConfigured(): void {
$configured = Env::get('MCP_API_TOKEN', '');
if ($configured === '') {
return;
}
$header = familyHubAuthorizationHeader();
if ($header === null || !preg_match('/^\s*Bearer\s+(\S+)\s*$/i', $header, $matches)) {
return;
}
$presented = $matches[1];
if (!hash_equals($configured, $presented)) {
return;
}
$actorId = trim((string) Env::get('MCP_ACTOR_PERSON_ID', ''));
if ($actorId === '') {
return;
}
$people = normalizePeopleList(readJsonFile('people.json'));
$person = findPersonById($people, $actorId);
if ($person === null || ($person['role'] ?? '') !== ROLE_HEAD) {
return;
}
setSessionPerson($actorId, true);
}
function familyHubAuthorizationHeader(): ?string {
if (isset($_SERVER['HTTP_AUTHORIZATION']) && is_string($_SERVER['HTTP_AUTHORIZATION']) && $_SERVER['HTTP_AUTHORIZATION'] !== '') {
return $_SERVER['HTTP_AUTHORIZATION'];
}
if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION']) && is_string($_SERVER['REDIRECT_HTTP_AUTHORIZATION']) && $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] !== '') {
return $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
}
if (function_exists('apache_request_headers')) {
$headers = apache_request_headers();
if (is_array($headers)) {
foreach ($headers as $name => $value) {
if (strcasecmp((string) $name, 'Authorization') === 0 && is_string($value) && $value !== '') {
return $value;
}
}
}
}
return null;
}

381
mcp/familyhub/server.php Normal file
View File

@ -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);
}

View File

@ -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 <MCP_API_TOKEN>`; 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 hubs **`…/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