| 1 | package mcp |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "bytes" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "strconv" |
| 10 | "strings" |
| 11 | |
| 12 | "github.com/zionboggan/agent-time-ledger/internal/clock" |
| 13 | "github.com/zionboggan/agent-time-ledger/internal/ledger" |
| 14 | ) |
| 15 | |
| 16 | type request struct { |
| 17 | JSONRPC string `json:"jsonrpc"` |
| 18 | ID any `json:"id,omitempty"` |
| 19 | Method string `json:"method"` |
| 20 | Params json.RawMessage `json:"params,omitempty"` |
| 21 | } |
| 22 | |
| 23 | type response struct { |
| 24 | JSONRPC string `json:"jsonrpc"` |
| 25 | ID any `json:"id,omitempty"` |
| 26 | Result any `json:"result,omitempty"` |
| 27 | Error *rpcError `json:"error,omitempty"` |
| 28 | } |
| 29 | |
| 30 | type message struct { |
| 31 | Body []byte |
| 32 | Framed bool |
| 33 | } |
| 34 | |
| 35 | type rpcError struct { |
| 36 | Code int `json:"code"` |
| 37 | Message string `json:"message"` |
| 38 | } |
| 39 | |
| 40 | type toolCallParams struct { |
| 41 | Name string `json:"name"` |
| 42 | Arguments json.RawMessage `json:"arguments,omitempty"` |
| 43 | } |
| 44 | |
| 45 | type initializeParams struct { |
| 46 | ProtocolVersion string `json:"protocolVersion"` |
| 47 | } |
| 48 | |
| 49 | type callResult struct { |
| 50 | Content []content `json:"content"` |
| 51 | IsError bool `json:"isError,omitempty"` |
| 52 | } |
| 53 | |
| 54 | type content struct { |
| 55 | Type string `json:"type"` |
| 56 | Text string `json:"text"` |
| 57 | } |
| 58 | |
| 59 | func Serve(stdin io.Reader, stdout, stderr io.Writer, service *ledger.Service) error { |
| 60 | reader := bufio.NewReader(stdin) |
| 61 | for { |
| 62 | msg, err := readMessage(reader) |
| 63 | if err == io.EOF { |
| 64 | return nil |
| 65 | } |
| 66 | if err != nil { |
| 67 | fmt.Fprintln(stderr, err) |
| 68 | return err |
| 69 | } |
| 70 | if len(bytes.TrimSpace(msg.Body)) == 0 { |
| 71 | continue |
| 72 | } |
| 73 | |
| 74 | var req request |
| 75 | if err := json.Unmarshal(msg.Body, &req); err != nil { |
| 76 | _ = writeMessage(stdout, msg.Framed, response{ |
| 77 | JSONRPC: "2.0", |
| 78 | Error: &rpcError{Code: -32700, Message: "parse error"}, |
| 79 | }) |
| 80 | continue |
| 81 | } |
| 82 | if strings.HasPrefix(req.Method, "notifications/") { |
| 83 | continue |
| 84 | } |
| 85 | resp := handle(req, service) |
| 86 | if err := writeMessage(stdout, msg.Framed, resp); err != nil { |
| 87 | return err |
| 88 | } |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | func handle(req request, service *ledger.Service) response { |
| 93 | resp := response{JSONRPC: "2.0", ID: req.ID} |
| 94 | switch req.Method { |
| 95 | case "initialize": |
| 96 | protocolVersion := "2024-11-05" |
| 97 | var params initializeParams |
| 98 | if len(req.Params) > 0 && json.Unmarshal(req.Params, ¶ms) == nil && params.ProtocolVersion != "" { |
| 99 | protocolVersion = params.ProtocolVersion |
| 100 | } |
| 101 | resp.Result = map[string]any{ |
| 102 | "protocolVersion": protocolVersion, |
| 103 | "capabilities": map[string]any{ |
| 104 | "tools": map[string]any{"listChanged": false}, |
| 105 | }, |
| 106 | "serverInfo": map[string]any{ |
| 107 | "name": "agent-time-ledger", |
| 108 | "version": "0.1.0", |
| 109 | }, |
| 110 | } |
| 111 | case "ping": |
| 112 | resp.Result = map[string]any{} |
| 113 | case "tools/list": |
| 114 | resp.Result = map[string]any{"tools": tools()} |
| 115 | case "tools/call": |
| 116 | result, err := handleToolCall(req.Params, service) |
| 117 | if err != nil { |
| 118 | resp.Result = callResult{ |
| 119 | Content: []content{{Type: "text", Text: err.Error()}}, |
| 120 | IsError: true, |
| 121 | } |
| 122 | } else { |
| 123 | resp.Result = result |
| 124 | } |
| 125 | case "resources/list": |
| 126 | resp.Result = map[string]any{"resources": []any{}} |
| 127 | case "prompts/list": |
| 128 | resp.Result = map[string]any{"prompts": []any{}} |
| 129 | default: |
| 130 | resp.Error = &rpcError{Code: -32601, Message: "method not found"} |
| 131 | } |
| 132 | return resp |
| 133 | } |
| 134 | |
| 135 | func handleToolCall(raw json.RawMessage, service *ledger.Service) (callResult, error) { |
| 136 | var params toolCallParams |
| 137 | if err := json.Unmarshal(raw, ¶ms); err != nil { |
| 138 | return callResult{}, fmt.Errorf("invalid tool call params: %w", err) |
| 139 | } |
| 140 | args := map[string]any{} |
| 141 | if len(params.Arguments) > 0 { |
| 142 | if err := json.Unmarshal(params.Arguments, &args); err != nil { |
| 143 | return callResult{}, fmt.Errorf("invalid tool arguments: %w", err) |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | var result any |
| 148 | var err error |
| 149 | switch params.Name { |
| 150 | case "time_now": |
| 151 | timezone, _ := args["timezone"].(string) |
| 152 | result, err = service.NowResponseIn(timezone) |
| 153 | case "session_status": |
| 154 | result, err = service.SessionStatus() |
| 155 | case "mark_start": |
| 156 | result, err = service.StartMark(requiredString(args, "name")) |
| 157 | case "mark_elapsed": |
| 158 | result, err = service.MarkElapsed(requiredString(args, "name")) |
| 159 | case "stale_check": |
| 160 | result, err = staleCheck(args, service) |
| 161 | case "mark_list": |
| 162 | result, err = service.ListMarks() |
| 163 | case "mark_delete": |
| 164 | name := requiredString(args, "name") |
| 165 | err = service.DeleteMark(name) |
| 166 | result = map[string]any{"ok": err == nil, "name": name, "confidence": clock.ConfidenceWallFallback} |
| 167 | case "ledger_event": |
| 168 | note, _ := args["note"].(string) |
| 169 | err = service.LedgerEvent(note) |
| 170 | result = map[string]any{"ok": err == nil, "confidence": clock.ConfidenceWallFallback} |
| 171 | case "ledger_report": |
| 172 | result, err = service.ReportToday() |
| 173 | default: |
| 174 | return callResult{}, fmt.Errorf("unknown tool %q", params.Name) |
| 175 | } |
| 176 | if err != nil { |
| 177 | return callResult{}, err |
| 178 | } |
| 179 | text, err := json.MarshalIndent(result, "", " ") |
| 180 | if err != nil { |
| 181 | return callResult{}, err |
| 182 | } |
| 183 | return callResult{Content: []content{{Type: "text", Text: string(text)}}}, nil |
| 184 | } |
| 185 | |
| 186 | func staleCheck(args map[string]any, service *ledger.Service) (ledger.StaleResponse, error) { |
| 187 | ttlText := requiredString(args, "ttl") |
| 188 | ttl, err := clock.ParseDuration(ttlText) |
| 189 | if err != nil { |
| 190 | return ledger.StaleResponse{}, err |
| 191 | } |
| 192 | if markName, ok := args["mark"].(string); ok && markName != "" { |
| 193 | return service.StaleFromMark(markName, ttl) |
| 194 | } |
| 195 | timestampText := requiredString(args, "timestamp") |
| 196 | timestamp, err := clock.ParseRFC3339(timestampText) |
| 197 | if err != nil { |
| 198 | return ledger.StaleResponse{}, err |
| 199 | } |
| 200 | return service.StaleFromTimestamp(timestamp, ttl) |
| 201 | } |
| 202 | |
| 203 | func requiredString(args map[string]any, name string) string { |
| 204 | value, _ := args[name].(string) |
| 205 | return value |
| 206 | } |
| 207 | |
| 208 | func tools() []map[string]any { |
| 209 | return []map[string]any{ |
| 210 | tool("time_now", "Return the current time with timezone and UTC offset.", map[string]any{ |
| 211 | "timezone": stringSchema("Optional IANA timezone, such as America/Chicago, America/New_York, or UTC"), |
| 212 | }), |
| 213 | tool("session_status", "Return active session status and elapsed time.", map[string]any{}), |
| 214 | tool("mark_start", "Start or reset a named mark.", map[string]any{ |
| 215 | "name": stringSchema("Mark name"), |
| 216 | }, "name"), |
| 217 | tool("mark_elapsed", "Return elapsed time for a named mark.", map[string]any{ |
| 218 | "name": stringSchema("Mark name"), |
| 219 | }, "name"), |
| 220 | tool("mark_list", "Return all open marks with elapsed time.", map[string]any{}), |
| 221 | tool("mark_delete", "Delete a named mark.", map[string]any{ |
| 222 | "name": stringSchema("Mark name"), |
| 223 | }, "name"), |
| 224 | tool("stale_check", "Check whether a timestamp or mark is stale for a TTL.", map[string]any{ |
| 225 | "timestamp": stringSchema("RFC3339 timestamp"), |
| 226 | "mark": stringSchema("Existing mark name"), |
| 227 | "ttl": stringSchema("Duration such as 15s, 30m, 2h, or 1d"), |
| 228 | }, "ttl"), |
| 229 | tool("ledger_event", "Append a local manual_note ledger event.", map[string]any{ |
| 230 | "note": stringSchema("Short local note"), |
| 231 | }), |
| 232 | tool("ledger_report", "Return today's local ledger report.", map[string]any{}), |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | func tool(name, description string, properties map[string]any, required ...string) map[string]any { |
| 237 | if required == nil { |
| 238 | required = []string{} |
| 239 | } |
| 240 | return map[string]any{ |
| 241 | "name": name, |
| 242 | "description": description, |
| 243 | "inputSchema": map[string]any{ |
| 244 | "type": "object", |
| 245 | "properties": properties, |
| 246 | "required": required, |
| 247 | "additionalProperties": false, |
| 248 | }, |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | func stringSchema(description string) map[string]any { |
| 253 | return map[string]any{"type": "string", "description": description} |
| 254 | } |
| 255 | |
| 256 | func readMessage(reader *bufio.Reader) (message, error) { |
| 257 | line, err := reader.ReadString('\n') |
| 258 | if err != nil { |
| 259 | if err == io.EOF && strings.TrimSpace(line) != "" { |
| 260 | return message{Body: []byte(line)}, nil |
| 261 | } |
| 262 | return message{}, err |
| 263 | } |
| 264 | line = strings.TrimRight(line, "\r\n") |
| 265 | if strings.TrimSpace(line) == "" { |
| 266 | return message{}, nil |
| 267 | } |
| 268 | if !strings.HasPrefix(strings.ToLower(line), "content-length:") { |
| 269 | return message{Body: []byte(line)}, nil |
| 270 | } |
| 271 | |
| 272 | lengthText := strings.TrimSpace(strings.TrimPrefix(strings.ToLower(line), "content-length:")) |
| 273 | length, err := strconv.Atoi(lengthText) |
| 274 | if err != nil { |
| 275 | return message{}, fmt.Errorf("invalid Content-Length: %w", err) |
| 276 | } |
| 277 | for { |
| 278 | header, err := reader.ReadString('\n') |
| 279 | if err != nil { |
| 280 | return message{}, err |
| 281 | } |
| 282 | if strings.TrimSpace(header) == "" { |
| 283 | break |
| 284 | } |
| 285 | } |
| 286 | body := make([]byte, length) |
| 287 | _, err = io.ReadFull(reader, body) |
| 288 | return message{Body: body, Framed: true}, err |
| 289 | } |
| 290 | |
| 291 | func writeMessage(w io.Writer, framed bool, value any) error { |
| 292 | data, err := json.Marshal(value) |
| 293 | if err != nil { |
| 294 | return err |
| 295 | } |
| 296 | if framed { |
| 297 | _, err = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n%s", len(data), data) |
| 298 | return err |
| 299 | } |
| 300 | _, err = fmt.Fprintf(w, "%s\n", data) |
| 301 | return err |
| 302 | } |