feat: add create_entry MCP tool for direct AI-to-inou writing

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.
This commit is contained in:
James 2026-03-23 12:36:08 -04:00
parent 13e991aa1c
commit 257a021669
2 changed files with 97 additions and 0 deletions

View File

@ -415,6 +415,25 @@ func handleMCPToolsList(w http.ResponseWriter, req mcpRequest) {
"inputSchema": map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, "inputSchema": map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
"annotations": readOnly, "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}) 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": case "get_version":
sendMCPResult(w, req.ID, mcpTextContent(fmt.Sprintf("Server: %s v%s", mcpServerName, mcpServerVersion))) 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: default:
sendMCPError(w, req.ID, -32601, "Unknown tool: "+params.Name) sendMCPError(w, req.ID, -32601, "Unknown tool: "+params.Name)
} }

View File

@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"inou/lib" "inou/lib"
) )
@ -372,3 +373,56 @@ func docToTranslation(e *lib.Entry, data map[string]interface{}) (string, error)
} }
return result.Content[0].Text, nil 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
}