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
35 changes: 24 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,15 +273,11 @@ All use similar JSON format as above. Docker and secure prompts work the same wa

A hosted instance is available at `https://umami-mcp.macawls.dev/mcp`. You can connect to it directly from any MCP client that supports HTTP transport — no binary or Docker needed.

Credentials are passed as query parameters:

```
https://umami-mcp.macawls.dev/mcp?umamiHost=https://your-instance.com&umamiUsername=admin&umamiPassword=pass
```
Credentials are passed via `X-Umami-*` headers on the `initialize` request. Query parameters are also supported as a fallback but deprecated.

### Claude Desktop

Add to your config with `type: "url"`:
Claude Desktop does not support custom headers, so credentials are passed as query parameters:

```json
{
Expand All @@ -296,22 +292,27 @@ Add to your config with `type: "url"`:

### VS Code (GitHub Copilot)

Add to `.vscode/mcp.json`:
Add to `.vscode/mcp.json` with credentials in headers:

```json
{
"servers": {
"umami": {
"type": "http",
"url": "https://umami-mcp.macawls.dev/mcp?umamiHost=https://your-instance.com&umamiUsername=admin&umamiPassword=pass"
"url": "https://umami-mcp.macawls.dev/mcp",
"headers": {
"X-Umami-Host": "https://your-instance.com",
"X-Umami-Username": "${input:umami-username}",
"X-Umami-Password": "${input:umami-password}"
}
}
}
}
```

### Other Clients

Any MCP client that supports Streamable HTTP can connect using the URL above.
Any MCP client that supports Streamable HTTP can connect to `https://umami-mcp.macawls.dev/mcp` with credentials in `X-Umami-Host`, `X-Umami-Username`, and `X-Umami-Password` headers.

## Transport Modes

Expand All @@ -329,16 +330,28 @@ The server exposes a `/mcp` endpoint that speaks Streamable HTTP. Use this for s
TRANSPORT=http PORT=9999 ./umami-mcp-server
```

Credentials are passed as query parameters on the `initialize` request:
Credentials are passed via `X-Umami-*` headers on the `initialize` request:

```bash
curl -X POST "http://localhost:9999/mcp?umamiHost=https://analytics.example.com&umamiUsername=admin&umamiPassword=pass" \
curl -X POST "http://localhost:9999/mcp" \
-H "Content-Type: application/json" \
-H "X-Umami-Host: https://analytics.example.com" \
-H "X-Umami-Username: admin" \
-H "X-Umami-Password: pass" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'
```

The response includes a `Mcp-Session-Id` header to use for subsequent requests.

#### Environment Variables

| Variable | Default | Description |
|---|---|---|
| `TRANSPORT` | `stdio` | Transport mode (`stdio` or `http`) |
| `PORT` | `8080` | HTTP server port |
| `ALLOWED_ORIGINS` | `*` | Comma-separated CORS allowed origins |
| `MAX_SESSIONS` | `1000` | Maximum concurrent HTTP sessions |

### Docker

When using Docker, the image defaults to HTTP mode:
Expand Down
16 changes: 16 additions & 0 deletions handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ func (s *MCPServer) execGetStats(args json.RawMessage) (any, *Error) {
return nil, &Error{Code: -32602, Message: "Invalid arguments"}
}

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

stats, err := s.client.GetStats(params.WebsiteID, params.StartDate, params.EndDate)
if err != nil {
return nil, &Error{Code: -32603, Message: fmt.Sprintf("Failed to get stats: %v", err)}
Expand All @@ -65,6 +69,10 @@ func (s *MCPServer) execGetPageViews(args json.RawMessage) (any, *Error) {
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 params.Unit == "" {
params.Unit = "day"
}
Expand Down Expand Up @@ -96,6 +104,10 @@ func (s *MCPServer) execGetMetrics(args json.RawMessage) (any, *Error) {
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 params.Limit == 0 {
params.Limit = 10
}
Expand Down Expand Up @@ -125,6 +137,10 @@ func (s *MCPServer) execGetActive(args json.RawMessage) (any, *Error) {
return nil, &Error{Code: -32602, Message: "Invalid arguments"}
}

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

active, err := s.client.GetActive(params.WebsiteID)
if err != nil {
return nil, &Error{Code: -32603, Message: fmt.Sprintf("Failed to get active visitors: %v", err)}
Expand Down
89 changes: 76 additions & 13 deletions http.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,54 @@ import (
"io"
"log"
"net/http"
"strings"
"sync"
"sync/atomic"
)

const maxBodySize = 1 << 20 // 1 MB

type session struct {
server *MCPServer
}

type HTTPHandler struct {
sessions sync.Map // map[string]*session
sessions sync.Map
sessionCount atomic.Int64
maxSessions int
allowedOrigins []string
}

func NewHTTPHandler() *HTTPHandler {
return &HTTPHandler{}
func NewHTTPHandler(allowedOrigins []string, maxSessions int) *HTTPHandler {
if maxSessions <= 0 {
maxSessions = 1000
}
return &HTTPHandler{
allowedOrigins: allowedOrigins,
maxSessions: maxSessions,
}
}

func setCORS(w http.ResponseWriter) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id")
func (h *HTTPHandler) setCORS(w http.ResponseWriter, r *http.Request) {
if len(h.allowedOrigins) == 0 {
w.Header().Set("Access-Control-Allow-Origin", "*")
} else {
origin := r.Header.Get("Origin")
for _, allowed := range h.allowedOrigins {
if allowed == origin {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
break
}
}
}
w.Header().Set("Access-Control-Allow-Headers",
"Content-Type, Authorization, Mcp-Session-Id, X-Umami-Host, X-Umami-Username, X-Umami-Password")
w.Header().Set("Access-Control-Expose-Headers", "Mcp-Session-Id")
}

func (h *HTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
setCORS(w)
h.setCORS(w, r)

switch r.Method {
case http.MethodOptions:
Expand All @@ -47,11 +72,15 @@ func (h *HTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}

func (h *HTTPHandler) handlePost(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodySize+1))
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
if len(body) > maxBodySize {
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
return
}

var msg struct {
ID any `json:"id"`
Expand Down Expand Up @@ -99,15 +128,33 @@ func (h *HTTPHandler) handlePost(w http.ResponseWriter, r *http.Request) {
}

func (h *HTTPHandler) handleInitialize(w http.ResponseWriter, r *http.Request, req Request) {
query := r.URL.Query()
umamiHost := query.Get("umamiHost")
umamiUsername := query.Get("umamiUsername")
umamiPassword := query.Get("umamiPassword")
umamiHost := r.Header.Get("X-Umami-Host")
umamiUsername := r.Header.Get("X-Umami-Username")
umamiPassword := r.Header.Get("X-Umami-Password")

if umamiHost == "" || umamiUsername == "" || umamiPassword == "" {
query := r.URL.Query()
umamiHost = query.Get("umamiHost")
umamiUsername = query.Get("umamiUsername")
umamiPassword = query.Get("umamiPassword")

if umamiHost != "" || umamiUsername != "" || umamiPassword != "" {
log.Printf("DEPRECATED: credentials in query params — use X-Umami-* headers instead")
}
}

if umamiHost == "" || umamiUsername == "" || umamiPassword == "" {
writeJSONRPCError(w, req.ID, &Error{
Code: -32602,
Message: "Missing required query params: umamiHost, umamiUsername, umamiPassword",
Message: "Missing required credentials: provide X-Umami-Host, X-Umami-Username, X-Umami-Password headers",
})
return
}

if int(h.sessionCount.Load()) >= h.maxSessions {
writeJSONRPCError(w, req.ID, &Error{
Code: -32603,
Message: "Maximum sessions reached",
})
return
}
Expand All @@ -124,6 +171,7 @@ func (h *HTTPHandler) handleInitialize(w http.ResponseWriter, r *http.Request, r
sessionID := generateSessionID()
srv := NewMCPServer(client)
h.sessions.Store(sessionID, &session{server: srv})
h.sessionCount.Add(1)

resp := srv.HandleRequest(req)

Expand All @@ -147,6 +195,7 @@ func (h *HTTPHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
return
}

h.sessionCount.Add(-1)
w.WriteHeader(http.StatusOK)
}

Expand Down Expand Up @@ -194,3 +243,17 @@ func writeJSONRPCError(w http.ResponseWriter, id any, rpcErr *Error) {
data, _ := json.Marshal(resp)
_, _ = w.Write(data)
}

func parseOrigins(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
origins := make([]string, 0, len(parts))
for _, p := range parts {
if trimmed := strings.TrimSpace(p); trimmed != "" {
origins = append(origins, trimmed)
}
}
return origins
}
Loading
Loading