Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Binaries
umami-mcp
umami-mcp.exe
umami-mcp-server
umami-mcp-server.exe

llms*

Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ Connect your Umami Analytics to any MCP client - Claude Desktop, VS Code, Cursor
- "What devices and browsers are my users using?"
- "Show me the user journey - what pages do visitors typically view in sequence?"

### Sessions & Replay

- "How many sessions were recorded last month? List the most active ones"
- "Walk me through what session <id> did — the pages and events in order"
- "Which recorded sessions came from mobile in Sweden?"

### Real-time Monitoring

- "How many people are on my website right now? What pages are they viewing?"
Expand Down Expand Up @@ -386,6 +392,9 @@ For clients that use a `command` field (Claude Desktop, Cursor, etc.):
| `get_pageviews` | Pageview and session counts grouped by time unit |
| `get_metrics` | Breakdown by page, referrer, browser, OS, device, country, etc. |
| `get_active` | Current active visitor count in real-time |
| `get_sessions` | List individual visitor sessions, with total count — the sessions session replay records |
| `get_session_stats` | Aggregated session totals — pageviews, visitors, visits, countries, events |
| `get_session_activity` | Ordered pageview/event timeline for a single session |

## Configuration

Expand Down
113 changes: 101 additions & 12 deletions handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,18 @@ func (s *MCPServer) execGetWebsites(args json.RawMessage) (any, *Error) {
return map[string]any{"content": content}, nil
}

func (s *MCPServer) execGetStats(args json.RawMessage) (any, *Error) {
func textContent(result any) map[string]any {
data, _ := json.MarshalIndent(result, "", " ")
return map[string]any{"content": []map[string]string{{
"type": "text",
"text": string(data),
}}}
}

func (s *MCPServer) dateRangeQuery(
args json.RawMessage, errLabel string,
query func(websiteID, startDate, endDate string) (any, error),
) (any, *Error) {
var params struct {
WebsiteID string `json:"website_id"`
StartDate string `json:"start_date"`
Expand All @@ -43,21 +54,18 @@ func (s *MCPServer) execGetStats(args json.RawMessage) (any, *Error) {
return nil, &Error{Code: -32602, Message: "Invalid website_id"}
}

params.StartDate = normalizeDate(params.StartDate)
params.EndDate = normalizeDate(params.EndDate)

stats, err := s.client.GetStats(params.WebsiteID, params.StartDate, params.EndDate)
result, err := query(params.WebsiteID, normalizeDate(params.StartDate), normalizeDate(params.EndDate))
if err != nil {
return nil, &Error{Code: -32603, Message: fmt.Sprintf("Failed to get stats: %v", err)}
return nil, &Error{Code: -32603, Message: fmt.Sprintf("Failed to get %s: %v", errLabel, err)}
}

data, _ := json.MarshalIndent(stats, "", " ")
content := []map[string]string{{
"type": "text",
"text": string(data),
}}
return textContent(result), nil
}

return map[string]any{"content": content}, nil
func (s *MCPServer) execGetStats(args json.RawMessage) (any, *Error) {
return s.dateRangeQuery(args, "stats", func(id, start, end string) (any, error) {
return s.client.GetStats(id, start, end)
})
}

func (s *MCPServer) execGetPageViews(args json.RawMessage) (any, *Error) {
Expand Down Expand Up @@ -163,3 +171,84 @@ func (s *MCPServer) execGetActive(args json.RawMessage) (any, *Error) {

return map[string]any{"content": content}, nil
}

func (s *MCPServer) execGetSessions(args json.RawMessage) (any, *Error) {
var params struct {
WebsiteID string `json:"website_id"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Search string `json:"search"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}

if err := json.Unmarshal(args, &params); err != nil {
return nil, &Error{Code: -32602, Message: "Invalid arguments"}
}

if err := validateWebsiteID(params.WebsiteID); err != nil {
return nil, &Error{Code: -32602, Message: "Invalid website_id"}
}

params.StartDate = normalizeDate(params.StartDate)
params.EndDate = normalizeDate(params.EndDate)

sessions, err := s.client.GetSessions(
params.WebsiteID, params.StartDate, params.EndDate, params.Search, params.Page, params.PageSize,
)
if err != nil {
return nil, &Error{Code: -32603, Message: fmt.Sprintf("Failed to get sessions: %v", err)}
}

data, _ := json.MarshalIndent(sessions, "", " ")
content := []map[string]string{{
"type": "text",
"text": string(data),
}}

return map[string]any{"content": content}, nil
}

func (s *MCPServer) execGetSessionStats(args json.RawMessage) (any, *Error) {
return s.dateRangeQuery(args, "session stats", func(id, start, end string) (any, error) {
return s.client.GetSessionStats(id, start, end)
})
}

func (s *MCPServer) execGetSessionActivity(args json.RawMessage) (any, *Error) {
var params struct {
WebsiteID string `json:"website_id"`
SessionID string `json:"session_id"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
}

if err := json.Unmarshal(args, &params); err != nil {
return nil, &Error{Code: -32602, Message: "Invalid arguments"}
}

if err := validateWebsiteID(params.WebsiteID); err != nil {
return nil, &Error{Code: -32602, Message: "Invalid website_id"}
}
if err := validateSessionID(params.SessionID); err != nil {
return nil, &Error{Code: -32602, Message: "Invalid session_id"}
}

params.StartDate = normalizeDate(params.StartDate)
params.EndDate = normalizeDate(params.EndDate)

activity, err := s.client.GetSessionActivity(
params.WebsiteID, params.SessionID, params.StartDate, params.EndDate,
)
if err != nil {
return nil, &Error{Code: -32603, Message: fmt.Sprintf("Failed to get session activity: %v", err)}
}

data, _ := json.MarshalIndent(activity, "", " ")
content := []map[string]string{{
"type": "text",
"text": string(data),
}}

return map[string]any{"content": content}, nil
}
8 changes: 4 additions & 4 deletions http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,16 +243,16 @@ func TestHTTP_ServerCard(t *testing.T) {
if err := json.Unmarshal(card["tools"], &tools); err != nil {
t.Fatalf("Failed to parse tools: %v", err)
}
if len(tools) != 5 {
t.Errorf("Expected 5 tools, got %d", len(tools))
if len(tools) != 8 {
t.Errorf("Expected 8 tools, got %d", len(tools))
}

var prompts []json.RawMessage
if err := json.Unmarshal(card["prompts"], &prompts); err != nil {
t.Fatalf("Failed to parse prompts: %v", err)
}
if len(prompts) != 4 {
t.Errorf("Expected 4 prompts, got %d", len(prompts))
if len(prompts) != 5 {
t.Errorf("Expected 5 prompts, got %d", len(prompts))
}
}

Expand Down
20 changes: 18 additions & 2 deletions mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ func (s *MCPServer) processToolCall(rawParams json.RawMessage) (any, *Error) {
return s.execGetMetrics(params.Arguments)
case "get_active":
return s.execGetActive(params.Arguments)
case "get_sessions":
return s.execGetSessions(params.Arguments)
case "get_session_stats":
return s.execGetSessionStats(params.Arguments)
case "get_session_activity":
return s.execGetSessionActivity(params.Arguments)
default:
return nil, &Error{Code: -32602, Message: fmt.Sprintf("Unknown tool: %s", params.Name)}
}
Expand Down Expand Up @@ -192,13 +198,23 @@ var promptTemplates = map[string]string{
"realtime-check": "First call get_websites to find the target website. " +
"Then call get_active to check the current number of active visitors. " +
"Report the real-time visitor count.",

"session-insights": "First call get_websites to find the target website. " +
"Then summarize recorded sessions over the last {days} days:\n" +
"- get_session_stats for the totals (sessions, pageviews, visitors, events)\n" +
"- get_sessions (raise page_size) to list sessions with their device, " +
"country, and view counts; note the total 'count'\n" +
"- for any notably long or active session, get_session_activity with its " +
"id to trace the page/event sequence\n\n" +
"Summarize engagement and call out standout sessions.",
}

var promptDefaults = map[string]map[string]string{
"analytics-report": {"days": "30"},
"top-pages": {"days": "7", "limit": "10"},
"visitor-insights": {"days": "30"},
"realtime-check": {},
"visitor-insights": {"days": "30"},
"realtime-check": {},
"session-insights": {"days": "30"},
}

func (s *MCPServer) processPromptsGet(rawParams json.RawMessage) (any, *Error) {
Expand Down
19 changes: 11 additions & 8 deletions mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,14 @@ func TestMCPServer_HandleToolsList(t *testing.T) {
t.Fatal("Tools is not []map[string]any")
}

if len(toolsInterface) != 5 {
t.Fatalf("Expected 5 tools, got %d", len(toolsInterface))
if len(toolsInterface) != 8 {
t.Fatalf("Expected 8 tools, got %d", len(toolsInterface))
}

expectedTools := []string{"get_websites", "get_stats", "get_pageviews", "get_metrics", "get_active"}
expectedTools := []string{
"get_websites", "get_stats", "get_pageviews", "get_metrics", "get_active",
"get_sessions", "get_session_stats", "get_session_activity",
}
for i, tool := range toolsInterface {
name, ok := tool["name"].(string)
if !ok {
Expand Down Expand Up @@ -104,8 +107,8 @@ func TestMCPServer_ToolsJSONValidity(t *testing.T) {
t.Fatalf("Failed to parse tools JSON: %v", err)
}

if len(tools) != 5 {
t.Fatalf("Expected 5 tools, got %d", len(tools))
if len(tools) != 8 {
t.Fatalf("Expected 8 tools, got %d", len(tools))
}

for i, tool := range tools {
Expand Down Expand Up @@ -137,11 +140,11 @@ func TestMCPServer_HandlePromptsList(t *testing.T) {
t.Fatal("Prompts is not []map[string]any")
}

if len(prompts) != 4 {
t.Fatalf("Expected 4 prompts, got %d", len(prompts))
if len(prompts) != 5 {
t.Fatalf("Expected 5 prompts, got %d", len(prompts))
}

expectedPrompts := []string{"analytics-report", "top-pages", "visitor-insights", "realtime-check"}
expectedPrompts := []string{"analytics-report", "top-pages", "visitor-insights", "realtime-check", "session-insights"}
for i, prompt := range prompts {
name, ok := prompt["name"].(string)
if !ok {
Expand Down
7 changes: 7 additions & 0 deletions prompts.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,12 @@
"name": "realtime-check",
"description": "Check current active visitors on a website",
"arguments": []
},
{
"name": "session-insights",
"description": "Summarize recorded visitor sessions over a period",
"arguments": [
{ "name": "days", "description": "Number of days to analyze (default: 30)", "required": false }
]
}
]
84 changes: 84 additions & 0 deletions tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,5 +108,89 @@
},
"required": ["website_id"]
}
},
{
"name": "get_sessions",
"description": "List individual visitor sessions for a website over a date range, with pagination. Returns an object with 'data' (array of sessions, each with id, browser, os, device, country, region, city, language, screen, visits, views, firstAt, lastAt) plus 'count' (total sessions matching), 'page', and 'pageSize'. Each session id is what session replay (the recorder.js feature) records — use 'count' to see how many sessions/recordings exist, and pass a session 'id' to get_session_activity to inspect what happened in it. Check the website createdAt first; requesting before creation returns an empty list.",
"inputSchema": {
"type": "object",
"properties": {
"website_id": {
"type": "string",
"description": "The website ID from get_websites"
},
"start_date": {
"type": "string",
"description": "Start date. Accepts ISO 8601 date strings (e.g. '2026-03-23') or Unix timestamps in milliseconds. ISO dates are RECOMMENDED. MUST be after website createdAt."
},
"end_date": {
"type": "string",
"description": "End date. Accepts ISO 8601 date strings (e.g. '2026-03-23') or Unix timestamps in milliseconds. ISO dates are RECOMMENDED. Must be after start_date."
},
"search": {
"type": "string",
"description": "Optional free-text filter (matches session fields such as country, browser, os, etc.)."
},
"page": {
"type": "integer",
"description": "Page number for pagination (1-based).",
"default": 1
},
"page_size": {
"type": "integer",
"description": "Sessions per page. Increase (e.g. 50-100) to retrieve more in one call.",
"default": 20
}
},
"required": ["website_id", "start_date", "end_date"]
}
},
{
"name": "get_session_stats",
"description": "Get aggregated session totals for a website over a date range. Returns flat numeric fields: pageviews, visitors, visits, countries, and events. Useful as a quick scope check before paging through get_sessions. Check the website createdAt first.",
"inputSchema": {
"type": "object",
"properties": {
"website_id": {
"type": "string",
"description": "The website ID from get_websites"
},
"start_date": {
"type": "string",
"description": "Start date. Accepts ISO 8601 date strings (e.g. '2026-03-23') or Unix timestamps in milliseconds. ISO dates are RECOMMENDED. MUST be after website createdAt."
},
"end_date": {
"type": "string",
"description": "End date. Accepts ISO 8601 date strings (e.g. '2026-03-23') or Unix timestamps in milliseconds. ISO dates are RECOMMENDED. Must be after start_date."
}
},
"required": ["website_id", "start_date", "end_date"]
}
},
{
"name": "get_session_activity",
"description": "Get the ordered activity timeline (pageviews + events) for a single session, identified by a session id from get_sessions. Returns an array of entries with createdAt, urlPath, urlQuery, referrerDomain, eventType (1 = pageview, 2 = custom event), eventName, and visitId. This is the closest data-level view of what a session replay shows — the sequence of pages and actions during the visit. Optionally bound it to a time window.",
"inputSchema": {
"type": "object",
"properties": {
"website_id": {
"type": "string",
"description": "The website ID from get_websites"
},
"session_id": {
"type": "string",
"description": "The session id from get_sessions (the 'id' field of a session)."
},
"start_date": {
"type": "string",
"description": "Optional. Start of the activity window (ISO 8601 date or Unix ms). Defaults to the beginning of time, so the whole session is returned. Pass the session's firstAt to scope tightly."
},
"end_date": {
"type": "string",
"description": "Optional. End of the activity window (ISO 8601 date or Unix ms). Defaults to now. Pass the session's lastAt to scope tightly."
}
},
"required": ["website_id", "session_id"]
}
}
]
Loading
Loading