From 131a41037b6fe1db37dce97defe6ceef7b09c3f1 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 9 Feb 2026 05:21:35 -0500 Subject: [PATCH] fix: update /prompts/respond route to /trackers/respond --- api/api_v1.go | 208 +++++++++++++++++++++++++ lib/journal.go | 359 ++++++++++++++++++++++++++++++++++++++++++++ portal/main.go | 4 +- portal/mcp_http.go | 126 ++++++++++++++++ portal/mcp_tools.go | 110 ++++++++++++++ 5 files changed, 805 insertions(+), 2 deletions(-) create mode 100644 lib/journal.go diff --git a/api/api_v1.go b/api/api_v1.go index 7dd9078..ce23e94 100644 --- a/api/api_v1.go +++ b/api/api_v1.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "net/http" "strconv" "strings" @@ -69,6 +70,23 @@ func v1CanAccess(authID, targetID string) bool { return len(records) > 0 } +func v1CanWrite(authID, targetID string) bool { + if authID == targetID { + return true + } + records, _ := lib.AccessList(&lib.AccessFilter{ + AccessorID: authID, + TargetID: targetID, + Status: intPtr(1), + }) + for _, r := range records { + if r.CanEdit { + return true + } + } + return false +} + func intPtr(i int) *int { return &i } func v1Error(w http.ResponseWriter, msg string, code int) { @@ -728,6 +746,180 @@ func v1Health(w http.ResponseWriter, r *http.Request) { // --- ROUTER --- +// v1ListJournals handles GET /dossiers/{id}/journal +func v1ListJournals(w http.ResponseWriter, r *http.Request, dossierID string) { + actorID, ok := v1AuthRequired(w, r) + if !ok { + return + } + + if !v1CanAccess(actorID, dossierID) { + v1Error(w, "Access denied", http.StatusForbidden) + return + } + + // Parse query parameters + days := 30 // default + if daysStr := r.URL.Query().Get("days"); daysStr != "" { + if d, err := strconv.Atoi(daysStr); err == nil { + days = d + } + } + + var status *int + if statusStr := r.URL.Query().Get("status"); statusStr != "" { + if s, err := strconv.Atoi(statusStr); err == nil { + status = &s + } + } + + journalType := r.URL.Query().Get("type") + + // List journals + journals, err := lib.ListJournals(lib.ListJournalsInput{ + DossierID: dossierID, + Days: days, + Status: status, + Type: journalType, + }) + if err != nil { + v1Error(w, fmt.Sprintf("Failed to list journals: %v", err), http.StatusInternalServerError) + return + } + + v1JSON(w, map[string]interface{}{"journals": journals}) +} + +// v1GetJournal handles GET /dossiers/{id}/journal/{entry_id} +func v1GetJournal(w http.ResponseWriter, r *http.Request, dossierID, entryID string) { + actorID, ok := v1AuthRequired(w, r) + if !ok { + return + } + + if !v1CanAccess(actorID, dossierID) { + v1Error(w, "Access denied", http.StatusForbidden) + return + } + + journal, err := lib.GetJournal(dossierID, entryID) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + v1Error(w, "Access denied", http.StatusForbidden) + } else if strings.Contains(err.Error(), "not found") { + v1Error(w, "Journal entry not found", http.StatusNotFound) + } else { + v1Error(w, fmt.Sprintf("Failed to get journal: %v", err), http.StatusInternalServerError) + } + return + } + + v1JSON(w, journal) +} + +// v1CreateJournal handles POST /dossiers/{id}/journal +func v1CreateJournal(w http.ResponseWriter, r *http.Request, dossierID string) { + actorID, ok := v1AuthRequired(w, r) + if !ok { + return + } + + if !v1CanWrite(actorID, dossierID) { + v1Error(w, "Write access denied", http.StatusForbidden) + return + } + + // Parse request body + var req struct { + Type string `json:"type"` + Title string `json:"title"` + Summary string `json:"summary"` + Content string `json:"content"` + Tags []string `json:"tags"` + Status int `json:"status"` + RelatedEntries []string `json:"related_entries"` + Source string `json:"source"` + Reasoning string `json:"reasoning"` + Metadata map[string]string `json:"metadata"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + v1Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Create journal + entryID, err := lib.CreateJournal(lib.CreateJournalInput{ + DossierID: dossierID, + Type: req.Type, + Title: req.Title, + Summary: req.Summary, + Content: req.Content, + Tags: req.Tags, + Status: req.Status, + RelatedEntries: req.RelatedEntries, + Source: req.Source, + Reasoning: req.Reasoning, + Metadata: req.Metadata, + }) + if err != nil { + v1Error(w, fmt.Sprintf("Failed to create journal: %v", err), http.StatusInternalServerError) + return + } + + // Return created entry + journal, err := lib.GetJournal(dossierID, entryID) + if err != nil { + v1Error(w, "Created but failed to retrieve", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + v1JSON(w, journal) +} + +// v1UpdateJournal handles PATCH /dossiers/{id}/journal/{entry_id} +func v1UpdateJournal(w http.ResponseWriter, r *http.Request, dossierID, entryID string) { + actorID, ok := v1AuthRequired(w, r) + if !ok { + return + } + + if !v1CanWrite(actorID, dossierID) { + v1Error(w, "Write access denied", http.StatusForbidden) + return + } + + // Parse request body + var req struct { + Status *int `json:"status"` + AppendNote string `json:"append_note"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + v1Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Update journal + err := lib.UpdateJournalStatus(lib.UpdateJournalStatusInput{ + DossierID: dossierID, + EntryID: entryID, + Status: req.Status, + AppendNote: req.AppendNote, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + v1Error(w, "Access denied", http.StatusForbidden) + } else { + v1Error(w, fmt.Sprintf("Failed to update journal: %v", err), http.StatusInternalServerError) + } + return + } + + v1JSON(w, map[string]interface{}{"updated": time.Now().Format(time.RFC3339)}) +} + func v1Router(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/v1") parts := strings.Split(strings.Trim(path, "/"), "/") @@ -769,6 +961,22 @@ func v1Router(w http.ResponseWriter, r *http.Request) { case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "trackers" && r.Method == "GET": v1Prompts(w, r, parts[1]) + // GET /dossiers/{id}/journal + case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "journal" && r.Method == "GET": + v1ListJournals(w, r, parts[1]) + + // GET /dossiers/{id}/journal/{entry_id} + case len(parts) == 4 && parts[0] == "dossiers" && parts[2] == "journal" && r.Method == "GET": + v1GetJournal(w, r, parts[1], parts[3]) + + // POST /dossiers/{id}/journal + case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "journal" && r.Method == "POST": + v1CreateJournal(w, r, parts[1]) + + // PATCH /dossiers/{id}/journal/{entry_id} + case len(parts) == 4 && parts[0] == "dossiers" && parts[2] == "journal" && r.Method == "PATCH": + v1UpdateJournal(w, r, parts[1], parts[3]) + // POST /dossiers/{id}/parse case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "parse" && r.Method == "POST": v1Parse(w, r, parts[1]) diff --git a/lib/journal.go b/lib/journal.go new file mode 100644 index 0000000..c18b745 --- /dev/null +++ b/lib/journal.go @@ -0,0 +1,359 @@ +package lib + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// Journal entry types +const ( + JournalTypeProtocol = "protocol" + JournalTypeHypothesis = "hypothesis" + JournalTypeObservation = "observation" + JournalTypeConnection = "connection" + JournalTypeQuestion = "question" + JournalTypeReference = "reference" +) + +// Journal status values +const ( + JournalStatusDraft = 0 + JournalStatusActive = 1 + JournalStatusResolved = 2 + JournalStatusDiscarded = 3 +) + +// JournalData represents the encrypted JSON data stored in entry.Data +type JournalData struct { + Source string `json:"source"` + ConversationID string `json:"conversation_id,omitempty"` + ConversationURL string `json:"conversation_url,omitempty"` + Content string `json:"content"` + RelatedEntries []string `json:"related_entries,omitempty"` + Reasoning string `json:"reasoning,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// CreateJournalInput represents the input for creating a journal entry +type CreateJournalInput struct { + DossierID string + Type string // protocol, hypothesis, observation, etc. + Title string + Summary string // optional - generated if not provided + Content string // full markdown content + Tags []string // optional + Status int // optional - defaults to draft + RelatedEntries []string // optional + Source string // optional - e.g., "opus-4.6" + Reasoning string // optional + Metadata map[string]string +} + +// JournalSummary represents a journal entry summary for list view +type JournalSummary struct { + EntryID string `json:"entry_id"` + Type string `json:"type"` + Title string `json:"title"` + Summary string `json:"summary"` + Date time.Time `json:"date"` + Status int `json:"status"` + Tags []string `json:"tags"` + DossierID string `json:"dossier_id,omitempty"` +} + +// JournalEntry represents a full journal entry +type JournalEntry struct { + EntryID string `json:"entry_id"` + DossierID string `json:"dossier_id"` + Type string `json:"type"` + Title string `json:"title"` + Summary string `json:"summary"` + Content string `json:"content"` + Date time.Time `json:"date"` + Status int `json:"status"` + Tags []string `json:"tags"` + RelatedEntries []string `json:"related_entries,omitempty"` + Source string `json:"source,omitempty"` + Reasoning string `json:"reasoning,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// GenerateJournalSummary uses Gemini to generate a 1-2 sentence summary +func GenerateJournalSummary(title, journalType, content string) (string, error) { + prompt := fmt.Sprintf(`Summarize this health journal entry in 1-2 sentences (max 300 chars). +Focus on: what intervention/observation, for what condition, and key details. + +Title: %s +Type: %s +Content: %s + +Example good summaries: +- "20-25g daily Beluga/Keta targeting phospholipid-DHA → SPM production for aqueduct inflammation." +- "Jan 10 fever may have opened aqueduct drainage. Observing improved tone, eye tracking, head control." +- "Autologous WBC therapy (Utheline/Ricasin/Notokill + ATP) given during active fever." + +Respond with ONLY the summary text, no JSON, no extra formatting.`, title, journalType, content) + + // Override default config to return plain text + plainText := "text/plain" + maxTokens := 150 + temp := 0.3 + config := &GeminiConfig{ + Temperature: &temp, + MaxOutputTokens: &maxTokens, + ResponseMimeType: &plainText, + } + + summary, err := CallGeminiMultimodal([]GeminiPart{{Text: prompt}}, config) + if err != nil { + return "", fmt.Errorf("failed to generate summary: %w", err) + } + + summary = strings.TrimSpace(summary) + if len(summary) > 300 { + summary = summary[:297] + "..." + } + + return summary, nil +} + +// CreateJournal creates a new journal entry +func CreateJournal(input CreateJournalInput) (string, error) { + // Validate required fields + if input.DossierID == "" { + return "", fmt.Errorf("dossier_id required") + } + if input.Type == "" { + return "", fmt.Errorf("type required") + } + if input.Title == "" { + return "", fmt.Errorf("title required") + } + if input.Content == "" { + return "", fmt.Errorf("content required") + } + + // Generate summary if not provided + if input.Summary == "" { + var err error + input.Summary, err = GenerateJournalSummary(input.Title, input.Type, input.Content) + if err != nil { + return "", fmt.Errorf("failed to generate summary: %w", err) + } + } else { + // Validate client-provided summary + if len(input.Summary) > 300 { + return "", fmt.Errorf("summary too long (max 300 chars)") + } + } + + // Build data JSON + data := JournalData{ + Source: input.Source, + Content: input.Content, + RelatedEntries: input.RelatedEntries, + Reasoning: input.Reasoning, + Metadata: input.Metadata, + } + + dataJSON, err := json.Marshal(data) + if err != nil { + return "", fmt.Errorf("failed to marshal data: %w", err) + } + + // Create entry + entry := Entry{ + EntryID: NewID(), + DossierID: input.DossierID, + Category: CategoryNote, + Type: input.Type, + Value: input.Title, + Summary: input.Summary, + Data: string(dataJSON), + Tags: strings.Join(input.Tags, ","), + Timestamp: time.Now().Unix(), + Status: input.Status, // defaults to 0 (draft) if not set + } + + if err := Save("entries", &entry); err != nil { + return "", fmt.Errorf("failed to save entry: %w", err) + } + + return entry.EntryID, nil +} + +// GetJournal retrieves a full journal entry +func GetJournal(dossierID, entryID string) (*JournalEntry, error) { + var entry Entry + if err := Load("entries", entryID, &entry); err != nil { + return nil, fmt.Errorf("failed to load entry: %w", err) + } + + // Verify access + if entry.DossierID != dossierID { + return nil, fmt.Errorf("access denied") + } + + // Verify it's a journal entry + if entry.Category != CategoryNote { + return nil, fmt.Errorf("not a journal entry") + } + + // Parse data JSON + var data JournalData + if entry.Data != "" { + if err := json.Unmarshal([]byte(entry.Data), &data); err != nil { + return nil, fmt.Errorf("failed to parse data: %w", err) + } + } + + // Parse tags + var tags []string + if entry.Tags != "" { + tags = strings.Split(entry.Tags, ",") + } + + return &JournalEntry{ + EntryID: entry.EntryID, + DossierID: entry.DossierID, + Type: entry.Type, + Title: entry.Value, + Summary: entry.Summary, + Content: data.Content, + Date: time.Unix(entry.Timestamp, 0), + Status: entry.Status, + Tags: tags, + RelatedEntries: data.RelatedEntries, + Source: data.Source, + Reasoning: data.Reasoning, + Metadata: data.Metadata, + }, nil +} + +// ListJournalsInput represents filters for listing journals +type ListJournalsInput struct { + DossierID string + Days int // filter to last N days (0 = all) + Status *int // filter by status (nil = all) + Type string // filter by type (empty = all) +} + +// ListJournals retrieves journal summaries with optional filters +func ListJournals(input ListJournalsInput) ([]JournalSummary, error) { + // Build query + query := `SELECT entry_id, dossier_id, type, value, summary, tags, timestamp, status + FROM entries + WHERE dossier_id = ? AND category = ?` + args := []interface{}{input.DossierID, CategoryNote} + + // Add type filter + if input.Type != "" { + query += " AND type = ?" + args = append(args, input.Type) + } + + // Add status filter + if input.Status != nil { + query += " AND status = ?" + args = append(args, *input.Status) + } + + // Add days filter + if input.Days > 0 { + cutoff := time.Now().AddDate(0, 0, -input.Days).Unix() + query += " AND timestamp >= ?" + args = append(args, cutoff) + } + + query += " ORDER BY timestamp DESC" + + // Execute query + var entries []Entry + if err := Query(query, args, &entries); err != nil { + return nil, fmt.Errorf("failed to query entries: %w", err) + } + + // Convert to summaries + summaries := make([]JournalSummary, len(entries)) + for i, entry := range entries { + var tags []string + if entry.Tags != "" { + tags = strings.Split(entry.Tags, ",") + } + + summaries[i] = JournalSummary{ + EntryID: entry.EntryID, + Type: entry.Type, + Title: entry.Value, + Summary: entry.Summary, + Date: time.Unix(entry.Timestamp, 0), + Status: entry.Status, + Tags: tags, + DossierID: entry.DossierID, + } + } + + return summaries, nil +} + +// UpdateJournalStatusInput represents input for updating journal status +type UpdateJournalStatusInput struct { + DossierID string + EntryID string + Status *int // optional: new status + AppendNote string // optional: append update to content +} + +// UpdateJournalStatus updates a journal entry's status or appends a note +func UpdateJournalStatus(input UpdateJournalStatusInput) error { + // Load entry + var entry Entry + if err := Load("entries", input.EntryID, &entry); err != nil { + return fmt.Errorf("failed to load entry: %w", err) + } + + // Verify access + if entry.DossierID != input.DossierID { + return fmt.Errorf("access denied") + } + + // Verify it's a journal entry + if entry.Category != CategoryNote { + return fmt.Errorf("not a journal entry") + } + + // Update status if provided + if input.Status != nil { + entry.Status = *input.Status + } + + // Append note if provided + if input.AppendNote != "" { + var data JournalData + if entry.Data != "" { + if err := json.Unmarshal([]byte(entry.Data), &data); err != nil { + return fmt.Errorf("failed to parse data: %w", err) + } + } + + // Append to content + timestamp := time.Now().Format("2006-01-02") + update := fmt.Sprintf("\n\n---\n**Update %s:** %s", timestamp, input.AppendNote) + data.Content += update + + dataJSON, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal data: %w", err) + } + entry.Data = string(dataJSON) + } + + // Save entry + if err := Save("entries", &entry); err != nil { + return fmt.Errorf("failed to save entry: %w", err) + } + + return nil +} diff --git a/portal/main.go b/portal/main.go index 3e881a5..2f97820 100644 --- a/portal/main.go +++ b/portal/main.go @@ -2007,7 +2007,7 @@ func setupMux() http.Handler { } else if strings.Contains(path, "/access/") { handleEditAccess(w, r) } else if strings.HasSuffix(path, "/prompts") { handleTrackers(w, r) } else if strings.Contains(path, "/prompts/card/") { handleRenderTrackerCard(w, r) - } else if strings.HasSuffix(path, "/prompts/respond") { handleTrackerRespond(w, r) + } else if strings.HasSuffix(path, "/trackers/respond") { handleTrackerRespond(w, r) } else if strings.HasSuffix(path, "/upload") { if r.Method == "POST" { handleUploadPost(w, r) } else { handleUploadPage(w, r) } } else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/delete") { handleDeleteFile(w, r) } else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/update") { handleUpdateFile(w, r) @@ -2028,7 +2028,7 @@ func setupMux() http.Handler { mux.HandleFunc("/api/v1/auth/verify", handleAPIVerify) mux.HandleFunc("/api/v1/dashboard", handleAPIDashboard) mux.HandleFunc("/api/v1/prompts", handleAPIPrompts) - mux.HandleFunc("/api/v1/prompts/respond", handleAPITrackerRespond) + mux.HandleFunc("/api/v1/trackers/respond", handleAPITrackerRespond) mux.HandleFunc("/api", handleAPI) mux.HandleFunc("/api/token/generate", handleAPITokenGenerate) diff --git a/portal/mcp_http.go b/portal/mcp_http.go index c04bf2c..4bc9880 100644 --- a/portal/mcp_http.go +++ b/portal/mcp_http.go @@ -418,6 +418,69 @@ func handleMCPToolsList(w http.ResponseWriter, req mcpRequest) { }, "annotations": readOnly, }, + { + "name": "list_journals", + "description": "List journal entries (protocols, hypotheses, observations) for a dossier. Returns summaries with title, type, date, status. Use get_journal_entry to fetch full content.", + "inputSchema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"}, + "days": map[string]interface{}{"type": "number", "description": "Filter to last N days (0 = all)"}, + "status": map[string]interface{}{"type": "number", "description": "Filter by status (0=draft, 1=active, 2=resolved, 3=discarded)"}, + "type": map[string]interface{}{"type": "string", "description": "Filter by type (protocol, hypothesis, observation, connection, question, reference)"}, + }, + "required": []string{"dossier"}, + }, + "annotations": readOnly, + }, + { + "name": "get_journal_entry", + "description": "Get full journal entry with complete content, reasoning, and metadata.", + "inputSchema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"}, + "entry_id": map[string]interface{}{"type": "string", "description": "Journal entry ID (16-char hex)"}, + }, + "required": []string{"dossier", "entry_id"}, + }, + "annotations": readOnly, + }, + { + "name": "create_journal_entry", + "description": "Create a new journal entry (protocol, hypothesis, observation, etc.). Summary will be auto-generated if not provided.", + "inputSchema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"}, + "type": map[string]interface{}{"type": "string", "description": "Entry type: protocol, hypothesis, observation, connection, question, reference"}, + "title": map[string]interface{}{"type": "string", "description": "Short title (max 200 chars)"}, + "summary": map[string]interface{}{"type": "string", "description": "1-2 sentence summary (max 300 chars, auto-generated if omitted)"}, + "content": map[string]interface{}{"type": "string", "description": "Full markdown content with details, reasoning, etc."}, + "tags": map[string]interface{}{"type": "array", "items": map[string]string{"type": "string"}, "description": "Tags for categorization"}, + "status": map[string]interface{}{"type": "number", "description": "0=draft (default), 1=active, 2=resolved, 3=discarded"}, + "related_entries": map[string]interface{}{"type": "array", "items": map[string]string{"type": "string"}, "description": "Related entry IDs"}, + "source": map[string]interface{}{"type": "string", "description": "Source model (e.g., opus-4.6)"}, + "reasoning": map[string]interface{}{"type": "string", "description": "Why this matters / how we arrived at this"}, + "metadata": map[string]interface{}{"type": "object", "description": "Additional context (weight, age, etc.)"}, + }, + "required": []string{"dossier", "type", "title", "content"}, + }, + }, + { + "name": "update_journal_entry", + "description": "Update journal entry status or append a note to existing entry.", + "inputSchema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"}, + "entry_id": map[string]interface{}{"type": "string", "description": "Journal entry ID (16-char hex)"}, + "status": map[string]interface{}{"type": "number", "description": "New status (0=draft, 1=active, 2=resolved, 3=discarded)"}, + "append_note": map[string]interface{}{"type": "string", "description": "Note to append to content (timestamped)"}, + }, + "required": []string{"dossier", "entry_id"}, + }, + }, { "name": "get_version", "description": "Get bridge and server version info.", @@ -584,6 +647,69 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss fmt.Printf("[MCP] query_genome success: %d bytes\n", len(result)) sendMCPResult(w, req.ID, mcpTextContent(result)) + case "list_journals": + dossier, _ := params.Arguments["dossier"].(string) + if dossier == "" { + sendMCPError(w, req.ID, -32602, "dossier required") + return + } + days, _ := params.Arguments["days"].(float64) + journalType, _ := params.Arguments["type"].(string) + + var status *int + if statusVal, ok := params.Arguments["status"].(float64); ok { + s := int(statusVal) + status = &s + } + + result, err := mcpListJournals(accessToken, dossier, int(days), status, journalType) + if err != nil { + sendMCPError(w, req.ID, -32000, err.Error()) + return + } + sendMCPResult(w, req.ID, mcpTextContent(result)) + + case "get_journal_entry": + dossier, _ := params.Arguments["dossier"].(string) + entryID, _ := params.Arguments["entry_id"].(string) + if dossier == "" || entryID == "" { + sendMCPError(w, req.ID, -32602, "dossier and entry_id required") + return + } + result, err := mcpGetJournalEntry(accessToken, dossier, entryID) + if err != nil { + sendMCPError(w, req.ID, -32000, err.Error()) + return + } + sendMCPResult(w, req.ID, mcpTextContent(result)) + + case "create_journal_entry": + dossier, _ := params.Arguments["dossier"].(string) + if dossier == "" { + sendMCPError(w, req.ID, -32602, "dossier required") + return + } + result, err := mcpCreateJournalEntry(accessToken, dossier, params.Arguments) + if err != nil { + sendMCPError(w, req.ID, -32000, err.Error()) + return + } + sendMCPResult(w, req.ID, mcpTextContent(result)) + + case "update_journal_entry": + dossier, _ := params.Arguments["dossier"].(string) + entryID, _ := params.Arguments["entry_id"].(string) + if dossier == "" || entryID == "" { + sendMCPError(w, req.ID, -32602, "dossier and entry_id required") + return + } + result, err := mcpUpdateJournalEntry(accessToken, dossier, entryID, params.Arguments) + if err != nil { + sendMCPError(w, req.ID, -32000, err.Error()) + return + } + sendMCPResult(w, req.ID, mcpTextContent(result)) + case "get_version": sendMCPResult(w, req.ID, mcpTextContent(fmt.Sprintf("Server: %s v%s", mcpServerName, mcpServerVersion))) diff --git a/portal/mcp_tools.go b/portal/mcp_tools.go index 5d3a909..d82e903 100644 --- a/portal/mcp_tools.go +++ b/portal/mcp_tools.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "encoding/base64" "encoding/json" "fmt" @@ -226,3 +227,112 @@ func mcpQueryGenome(accessToken, dossier, gene, search, category, rsids string, pretty, _ := json.MarshalIndent(data, "", " ") return string(pretty), nil } + +// Journal MCP Tools + +func mcpListJournals(accessToken, dossier string, days int, status *int, journalType string) (string, error) { + params := map[string]string{"dossier": dossier} + if days > 0 { + params["days"] = strconv.Itoa(days) + } + if status != nil { + params["status"] = strconv.Itoa(*status) + } + if journalType != "" { + params["type"] = journalType + } + + body, err := mcpAPICall(accessToken, "/api/v1/dossiers/"+dossier+"/journal", params) + if err != nil { + return "", err + } + var data interface{} + json.Unmarshal(body, &data) + pretty, _ := json.MarshalIndent(data, "", " ") + return string(pretty), nil +} + +func mcpGetJournalEntry(accessToken, dossier, entryID string) (string, error) { + body, err := mcpAPICall(accessToken, "/api/v1/dossiers/"+dossier+"/journal/"+entryID, nil) + if err != nil { + return "", err + } + var data interface{} + json.Unmarshal(body, &data) + pretty, _ := json.MarshalIndent(data, "", " ") + return string(pretty), nil +} + +func mcpCreateJournalEntry(accessToken, dossier string, params map[string]interface{}) (string, error) { + u := apiBaseURL + "/api/v1/dossiers/" + dossier + "/journal" + + jsonData, err := json.Marshal(params) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest("POST", u, io.NopCloser(bytes.NewReader(jsonData))) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != 200 && resp.StatusCode != 201 { + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + var data interface{} + json.Unmarshal(body, &data) + pretty, _ := json.MarshalIndent(data, "", " ") + return string(pretty), nil +} + +func mcpUpdateJournalEntry(accessToken, dossier, entryID string, params map[string]interface{}) (string, error) { + u := apiBaseURL + "/api/v1/dossiers/" + dossier + "/journal/" + entryID + + jsonData, err := json.Marshal(params) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest("PATCH", u, io.NopCloser(bytes.NewReader(jsonData))) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != 200 { + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + var data interface{} + json.Unmarshal(body, &data) + pretty, _ := json.MarshalIndent(data, "", " ") + return string(pretty), nil +}