Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions pkg/flashduty/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,9 @@ func MarshalResult(v any) *mcp.CallToolResult {

// MarshalResultWithFormat serializes the given value using the specified format
func MarshalResultWithFormat(v any, format OutputFormat) *mcp.CallToolResult {
data, err := sdk.Marshal(v, format)
data, err := sdk.Marshal(humanizeTimestamps(v), format)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err))
}
return mcp.NewToolResultText(string(data))
}

// MarshalledTextResult is the original function that always uses JSON.
// Kept for backward compatibility. New code should use MarshalResult.
func MarshalledTextResult(v any) *mcp.CallToolResult {
data, _ := sdk.Marshal(v, OutputFormatJSON)
return mcp.NewToolResultText(string(data))
}
94 changes: 94 additions & 0 deletions pkg/flashduty/timestamps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package flashduty

import (
"bytes"
"encoding/json"
"strings"
"time"
)

// humanizeTimestamps returns a copy of v with Unix-timestamp fields rendered as
// RFC3339 strings in the local timezone, leaving everything else untouched.
//
// Flashduty's API returns time fields as bare Unix integers, which is opaque to
// an LLM reading tool output. RFC3339 is unambiguous, sortable, and the format
// models are most fluent in. The local timezone is the process timezone (the
// sandbox/environment timezone when the server runs inside an agent sandbox).
//
// Detection is by JSON field name: a field ending in "_time" or "_at", or named
// exactly "timestamp", whose value is an integer large enough to be a real
// timestamp (>= 1e9 seconds, i.e. year 2001+). Millisecond values (>= 1e12) are
// detected by magnitude. ID-like fields (*_by, *_id, *_ids) are never touched.
//
// v is round-tripped through JSON into a generic structure so the same walk
// handles both typed SDK structs and the map[string]any payloads tools build by
// hand. On any marshal/decode error it returns v unchanged — humanization is
// best-effort and never blocks output.
func humanizeTimestamps(v any) any {
b, err := json.Marshal(v)
if err != nil {
return v
}
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber()
var generic any
if err := dec.Decode(&generic); err != nil {
return v
}
return humanizeWalk(generic, "")
}

func humanizeWalk(v any, key string) any {
switch val := v.(type) {
case map[string]any:
for k, child := range val {
val[k] = humanizeWalk(child, k)
}
return val
case []any:
for i, child := range val {
val[i] = humanizeWalk(child, key)
}
return val
case json.Number:
if isTimestampField(key) {
if s, ok := renderTimestamp(val); ok {
return s
}
}
return val
default:
return val
}
}

// isTimestampField reports whether a JSON field name denotes an absolute time.
// ID-like suffixes are excluded first so e.g. "timeline_id" / "updated_by"
// never match.
func isTimestampField(key string) bool {
k := strings.ToLower(key)
if strings.HasSuffix(k, "_id") || strings.HasSuffix(k, "_ids") || strings.HasSuffix(k, "_by") {
return false
}
return k == "timestamp" || strings.HasSuffix(k, "_time") || strings.HasSuffix(k, "_at")
}

// renderTimestamp converts a numeric Unix timestamp to RFC3339 in local time.
// Values below 1e9 are treated as durations/counts, not absolute timestamps,
// and left unconverted; values at/above 1e12 are interpreted as milliseconds.
func renderTimestamp(n json.Number) (string, bool) {
i, err := n.Int64()
if err != nil {
return "", false
}
var t time.Time
switch {
case i >= 1e12:
t = time.UnixMilli(i)
case i >= 1e9:
t = time.Unix(i, 0)
default:
return "", false
}
return t.In(time.Local).Format(time.RFC3339), true
}
122 changes: 122 additions & 0 deletions pkg/flashduty/timestamps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package flashduty

import (
"strings"
"testing"
"time"

"github.com/mark3labs/mcp-go/mcp"
)

// TestMarshalResult_HumanizesTimestamps locks the wiring: every tool result
// routed through MarshalResultWithFormat must have its timestamps humanized, so
// a raw Unix integer never reaches the model.
func TestMarshalResult_HumanizesTimestamps(t *testing.T) {
const ts = 1748419200
res := MarshalResultWithFormat(map[string]any{"start_time": ts}, OutputFormatJSON)
tc, ok := mcp.AsTextContent(res.Content[0])
if !ok {
t.Fatalf("expected text content, got %#v", res.Content[0])
}
if strings.Contains(tc.Text, "1748419200") {
t.Fatalf("raw unix timestamp leaked into tool result: %s", tc.Text)
}
if !strings.Contains(tc.Text, "start_time") {
t.Fatalf("expected start_time key in result: %s", tc.Text)
}
}

func tsInstant(t *testing.T, v any) int64 {
t.Helper()
s, ok := v.(string)
if !ok {
t.Fatalf("expected RFC3339 string, got %T (%v)", v, v)
}
parsed, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatalf("value %q is not RFC3339: %v", s, err)
}
return parsed.Unix()
}

func TestHumanizeTimestamps_ConvertsSecondsAndMillis(t *testing.T) {
const sec = 1748419200
m := humanizeTimestamps(map[string]any{
"start_time": sec,
"created_at": int64(sec) * 1000,
}).(map[string]any)
if inst := tsInstant(t, m["start_time"]); inst != sec {
t.Fatalf("start_time instant = %d, want %d", inst, sec)
}
if inst := tsInstant(t, m["created_at"]); inst != sec {
t.Fatalf("created_at instant = %d, want %d", inst, sec)
}
}

func TestHumanizeTimestamps_DetectsByFieldName(t *testing.T) {
const ts = 1748419200
in := map[string]any{
"ack_time": ts, "close_time": ts, "assigned_at": ts,
"acknowledged_at": ts, "timestamp": ts, "end_time": ts, "trigger_time": ts,
}
m := humanizeTimestamps(in).(map[string]any)
for k := range in {
if inst := tsInstant(t, m[k]); inst != ts {
t.Fatalf("%s instant = %d, want %d", k, inst, ts)
}
}
}

func TestHumanizeTimestamps_LeavesIDAndDurationFields(t *testing.T) {
in := map[string]any{
// Large values that WOULD convert by magnitude — proves the field-name
// exclusion (not just the magnitude guard) is what keeps IDs numeric.
"updated_by": int64(1748419200),
"timeline_id": int64(1748419200),
"channel_ids": []any{int64(1748419200)},
"snooze_time": int64(300), // small => duration, not a 1970 date
"ack_time": 0, // zero => not a timestamp
}
m := humanizeTimestamps(in).(map[string]any)
for k := range in {
if _, isStr := m[k].(string); isStr {
t.Fatalf("%s must not be converted to a date string", k)
}
}
}

func TestHumanizeTimestamps_RecursesNestedAndSlices(t *testing.T) {
const ts = 1748419200
in := map[string]any{
"incidents": []any{
map[string]any{"start_time": ts, "labels": map[string]any{"close_time": ts}},
},
}
m := humanizeTimestamps(in).(map[string]any)
inc := m["incidents"].([]any)[0].(map[string]any)
if inst := tsInstant(t, inc["start_time"]); inst != ts {
t.Fatalf("nested start_time instant = %d, want %d", inst, ts)
}
if inst := tsInstant(t, inc["labels"].(map[string]any)["close_time"]); inst != ts {
t.Fatalf("deeply nested close_time instant = %d, want %d", inst, ts)
}
}

func TestHumanizeTimestamps_ConvertsTypedStruct(t *testing.T) {
type incident struct {
Title string `json:"title"`
StartTime int64 `json:"start_time"`
UpdatedBy int64 `json:"updated_by"`
}
const ts = 1748419200
m := humanizeTimestamps(incident{Title: "db down", StartTime: ts, UpdatedBy: 7}).(map[string]any)
if inst := tsInstant(t, m["start_time"]); inst != ts {
t.Fatalf("struct start_time instant = %d, want %d", inst, ts)
}
if _, isStr := m["updated_by"].(string); isStr {
t.Fatalf("struct updated_by must remain numeric")
}
if m["title"] != "db down" {
t.Fatalf("title = %v, want \"db down\"", m["title"])
}
}
Loading