From 257a02166979677ebb78ffff199ee54d5e8b3622 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 23 Mar 2026 12:36:08 -0400 Subject: [PATCH] feat: add create_entry MCP tool for direct AI-to-inou writing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a write-capable create_entry tool to the MCP server, enabling Claude (Opus/Sonnet/Haiku) to save health insights directly into a dossier instead of losing them in chat. Tool: create_entry - Required: dossier (16-char hex), category (e.g. 'supplement', 'nutrition', 'tracker'), value (short label) - Optional: type, summary, data (JSON), parent, timestamp (default: now) - RBAC-enforced via EntryWrite — only writable dossiers accepted - Category validated against CategoryFromString; unknown categories return a helpful error listing valid options - data field validated as JSON before write Use cases: - Claude analyzing supplements: 'Create a supplement entry for Vitamin D3 5000 IU with notes on dosing rationale' - Nutrition logs from a meal conversation - Tracker observations or AI-generated insights with provenance The MCP server was previously read-only (all tools had readOnlyHint). create_entry intentionally omits readOnlyHint as it mutates state. --- portal/mcp_http.go | 43 ++++++++++++++++++++++++++++++++++++ portal/mcp_tools.go | 54 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/portal/mcp_http.go b/portal/mcp_http.go index 91983a8..bee157d 100644 --- a/portal/mcp_http.go +++ b/portal/mcp_http.go @@ -415,6 +415,25 @@ func handleMCPToolsList(w http.ResponseWriter, req mcpRequest) { "inputSchema": map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, "annotations": readOnly, }, + { + "name": "create_entry", + "title": "Create Entry", + "description": "Write a new health entry directly into a dossier. Use for saving AI-generated insights, supplement notes, nutrition logs, or any health observation. Requires write permission on the dossier. Category must be one of the values returned by list_categories (e.g. 'supplement', 'nutrition', 'tracker'). Type is a sub-type within the category (e.g. 'vitamin', 'meal', 'note'). Value is a short human-readable label. Summary is a one-line description. Data is optional structured JSON for additional fields.", + "inputSchema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"}, + "category": map[string]interface{}{"type": "string", "description": "Category name (e.g. 'supplement', 'nutrition', 'tracker')"}, + "type": map[string]interface{}{"type": "string", "description": "Entry type within category (e.g. 'vitamin', 'meal', 'note')"}, + "value": map[string]interface{}{"type": "string", "description": "Short label or title for the entry"}, + "summary": map[string]interface{}{"type": "string", "description": "One-line description or AI-generated insight"}, + "data": map[string]interface{}{"type": "string", "description": "Optional JSON string with additional structured fields"}, + "parent": map[string]interface{}{"type": "string", "description": "Optional parent entry ID for hierarchical entries"}, + "timestamp": map[string]interface{}{"type": "number", "description": "Optional Unix timestamp (defaults to now)"}, + }, + "required": []string{"dossier", "category", "value"}, + }, + }, } sendMCPResult(w, req.ID, map[string]interface{}{"tools": tools}) @@ -530,6 +549,30 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, dossierID string) case "get_version": sendMCPResult(w, req.ID, mcpTextContent(fmt.Sprintf("Server: %s v%s", mcpServerName, mcpServerVersion))) + case "create_entry": + dossier, _ := params.Arguments["dossier"].(string) + category, _ := params.Arguments["category"].(string) + typ, _ := params.Arguments["type"].(string) + value, _ := params.Arguments["value"].(string) + summary, _ := params.Arguments["summary"].(string) + data, _ := params.Arguments["data"].(string) + parent, _ := params.Arguments["parent"].(string) + var ts int64 + if tsRaw, ok := params.Arguments["timestamp"]; ok { + switch v := tsRaw.(type) { + case float64: + ts = int64(v) + case int64: + ts = v + } + } + result, err := mcpCreateEntry(dossierID, dossier, category, typ, value, summary, data, parent, ts) + if err != nil { + sendMCPError(w, req.ID, -32000, err.Error()) + return + } + sendMCPResult(w, req.ID, mcpTextContent(result)) + default: sendMCPError(w, req.ID, -32601, "Unknown tool: "+params.Name) } diff --git a/portal/mcp_tools.go b/portal/mcp_tools.go index 3980c93..7372865 100644 --- a/portal/mcp_tools.go +++ b/portal/mcp_tools.go @@ -7,6 +7,7 @@ import ( "net/http" "strconv" "strings" + "time" "inou/lib" ) @@ -372,3 +373,56 @@ func docToTranslation(e *lib.Entry, data map[string]interface{}) (string, error) } return result.Content[0].Text, nil } + +// mcpCreateEntry writes a new entry to the specified dossier. +// accessorID is the MCP session's dossier ID (the authenticated user). +// dossier is the target dossier (may differ for shared family dossiers). +func mcpCreateEntry(accessorID, dossier, category, typ, value, summary, data, parent string, ts int64) (string, error) { + if dossier == "" { + return "", fmt.Errorf("dossier required") + } + if value == "" { + return "", fmt.Errorf("value required") + } + + // Resolve category int + catInt, ok := lib.CategoryFromString[category] + if !ok { + // Build a helpful error listing valid categories + var valid []string + for k := range lib.CategoryFromString { + valid = append(valid, k) + } + return "", fmt.Errorf("unknown category %q — valid: %s", category, strings.Join(valid, ", ")) + } + + // Validate data JSON if provided + if data != "" { + var probe interface{} + if err := json.Unmarshal([]byte(data), &probe); err != nil { + return "", fmt.Errorf("data must be valid JSON: %v", err) + } + } + + if ts == 0 { + ts = time.Now().Unix() + } + + e := &lib.Entry{ + DossierID: dossier, + Category: catInt, + Type: typ, + Value: value, + Summary: summary, + Data: data, + ParentID: parent, + Timestamp: ts, + } + + if err := lib.EntryWrite(accessorID, e); err != nil { + return "", fmt.Errorf("write failed: %v", err) + } + + return fmt.Sprintf("Entry created: id=%s category=%s type=%s value=%q timestamp=%d", + e.EntryID, category, typ, value, ts), nil +}