From d712eba37ace12bc40336b9f71fe1fec63beb590 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Mon, 28 Sep 2026 07:17:08 -0700 Subject: [PATCH 1/3] feat: add MCP endpoint for sending messages Expose a stateless Streamable HTTP Model Context Protocol endpoint at /mcp which provides a send_message tool to AI agents. The endpoint only accepts application tokens and can be disabled with GOTIFY_SERVER_MCP_ENABLED=false. --- README.md | 25 ++++++ api/mcp.go | 115 ++++++++++++++++++++++++++ api/message.go | 19 +++-- config/config.go | 9 +++ config/config_test.go | 1 + config/keys.go | 1 + go.mod | 6 ++ go.sum | 16 ++++ gotify-server.env.example | 6 ++ router/mcp_test.go | 164 ++++++++++++++++++++++++++++++++++++++ router/router.go | 5 ++ 11 files changed, 362 insertions(+), 5 deletions(-) create mode 100644 api/mcp.go create mode 100644 router/mcp_test.go diff --git a/README.md b/README.md index 6aa8d627c..2e43b71b4 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ We wanted a simple server for sending and receiving messages (in real time per W Gotify UI screenshot * send messages via REST-API +* send messages from AI agents via [MCP](#mcp) * receive messages via WebSocket * manage users, clients and applications * [Plugins](https://gotify.net/docs/plugin) @@ -53,6 +54,30 @@ We wanted a simple server for sending and receiving messages (in real time per W [REST-API](https://gotify.net/api-docs) ᛫ [Setup Dev Environment](https://gotify.net/docs/dev-setup) +## MCP + +Gotify provides a [Model Context Protocol](https://modelcontextprotocol.io/) endpoint at `/mcp` (Streamable HTTP, stateless), +which exposes a `send_message` tool to AI agents. Authenticate with an application token via the `Authorization: Bearer ` or +`X-Gotify-Key` header, or the `token` query parameter. The endpoint can be disabled with `GOTIFY_SERVER_MCP_ENABLED=false`. + +Claude Code: +```sh +claude mcp add gotify --transport http https://gotify.example.com/mcp --header "Authorization: Bearer " +``` + +VS Code / Cherry Studio and other clients: +```json +{ + "servers": { + "gotify": { + "type": "http", + "url": "https://gotify.example.com/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + ## Contributing We welcome all kinds of contribution, including bug reports, feature requests, documentation improvements, UI refinements, etc. Check out [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. diff --git a/api/mcp.go b/api/mcp.go new file mode 100644 index 000000000..90d05aa0b --- /dev/null +++ b/api/mcp.go @@ -0,0 +1,115 @@ +package api + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gotify/server/v3/auth" + "github.com/gotify/server/v3/model" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/rs/zerolog/log" +) + +type mcpAppKey struct{} + +// MCPAPI provides a Model Context Protocol endpoint which lets AI agents send messages +// with an application token. +type MCPAPI struct { + messages *MessageAPI + version string + schemaCache *mcp.SchemaCache + handler http.Handler +} + +// SendMessageInput holds the arguments of the send_message tool. +type SendMessageInput struct { + Message string `json:"message" jsonschema:"The message content."` + Title string `json:"title,omitempty" jsonschema:"The message title. Defaults to the application name."` + Priority *int `json:"priority,omitempty" jsonschema:"The message priority. Higher values are more urgent: 0 is silent, 1-3 low, 4-7 normal, 8-10 high. Defaults to the application default priority."` + Markdown bool `json:"markdown,omitempty" jsonschema:"Whether the message content should be rendered as Markdown."` + ClickURL string `json:"click_url,omitempty" jsonschema:"A URL to open when the notification is clicked."` + BigImageURL string `json:"big_image_url,omitempty" jsonschema:"A URL of an image to show in the notification."` +} + +// NewMCP creates a new MCPAPI. +func NewMCP(messages *MessageAPI, version string) *MCPAPI { + a := &MCPAPI{messages: messages, version: version, schemaCache: mcp.NewSchemaCache()} + a.handler = mcp.NewStreamableHTTPHandler(a.serverForRequest, &mcp.StreamableHTTPOptions{ + Stateless: true, + JSONResponse: true, + // Authentication is done via application tokens, and gotify is commonly run behind a reverse proxy on localhost. + DisableLocalhostProtection: true, + }) + return a +} + +// Handle serves the MCP endpoint, the request must be authenticated with an application token. +func (a *MCPAPI) Handle(ctx *gin.Context) { + app := auth.GetApplication(ctx) + if app == nil { + ctx.AbortWithError(403, errors.New("the mcp endpoint requires an application token")) + return + } + req := ctx.Request.WithContext(context.WithValue(ctx.Request.Context(), mcpAppKey{}, app)) + a.handler.ServeHTTP(ctx.Writer, req) +} + +func (a *MCPAPI) serverForRequest(req *http.Request) *mcp.Server { + app, ok := req.Context().Value(mcpAppKey{}).(*model.Application) + if !ok { + return nil + } + server := mcp.NewServer(&mcp.Implementation{Name: "gotify", Version: a.version}, &mcp.ServerOptions{SchemaCache: a.schemaCache}) + mcp.AddTool(server, &mcp.Tool{ + Name: "send_message", + Description: fmt.Sprintf("Send a push notification via Gotify as the application %q.", app.Name), + Annotations: &mcp.ToolAnnotations{Title: "Send message", DestructiveHint: new(bool)}, + }, func(_ context.Context, _ *mcp.CallToolRequest, in SendMessageInput) (*mcp.CallToolResult, any, error) { + return a.sendMessage(app, in) + }) + return server +} + +func (a *MCPAPI) sendMessage(app *model.Application, in SendMessageInput) (*mcp.CallToolResult, any, error) { + if in.Message == "" { + return nil, nil, errors.New("message must not be empty") + } + msg := &model.CreateMessage{ + Title: in.Title, + Message: in.Message, + Priority: in.Priority, + Extras: mcpExtras(in), + } + created, err := a.messages.createMessage(app, msg) + if err != nil { + log.Error().Err(err).Uint("app_id", app.ID).Msg("MCP: could not create message") + return nil, nil, errors.New("failed to send message") + } + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Message sent (id %d).", created.ID)}}, + }, nil, nil +} + +func mcpExtras(in SendMessageInput) map[string]any { + extras := map[string]any{} + if in.Markdown { + extras["client::display"] = map[string]any{"contentType": "text/markdown"} + } + notification := map[string]any{} + if in.ClickURL != "" { + notification["click"] = map[string]any{"url": in.ClickURL} + } + if in.BigImageURL != "" { + notification["bigImageUrl"] = in.BigImageURL + } + if len(notification) > 0 { + extras["client::notification"] = notification + } + if len(extras) == 0 { + return nil + } + return extras +} diff --git a/api/message.go b/api/message.go index a765d2f19..849de197e 100644 --- a/api/message.go +++ b/api/message.go @@ -383,6 +383,15 @@ func (a *MessageAPI) CreateMessage(ctx *gin.Context) { app = fetchedApp } + created, err := a.createMessage(app, &message) + if success := successOrAbort(ctx, 500, err); !success { + return + } + ctx.JSON(200, created) +} + +// createMessage fills in defaults from the application, stores the message and notifies the user. +func (a *MessageAPI) createMessage(app *model.Application, message *model.CreateMessage) (*model.MessageExternal, error) { message.ApplicationID = app.ID if strings.TrimSpace(message.Title) == "" { message.Title = app.Name @@ -392,12 +401,12 @@ func (a *MessageAPI) CreateMessage(ctx *gin.Context) { message.Priority = &app.DefaultPriority } - msgInternal := toInternalMessage(&message) - if success := successOrAbort(ctx, 500, a.DB.CreateMessage(msgInternal)); !success { - return + msgInternal := toInternalMessage(message) + if err := a.DB.CreateMessage(msgInternal); err != nil { + return nil, err } - a.Notifier.Notify(auth.GetUserID(ctx), toExternalMessage(msgInternal)) - ctx.JSON(200, toExternalMessage(msgInternal)) + a.Notifier.Notify(app.UserID, toExternalMessage(msgInternal)) + return toExternalMessage(msgInternal), nil } func toInternalMessage(msg *model.CreateMessage) *model.Message { diff --git a/config/config.go b/config/config.go index ffa7c16b0..637c74000 100644 --- a/config/config.go +++ b/config/config.go @@ -36,6 +36,10 @@ type Cors struct { AllowHeaders []string } +type MCP struct { + Enabled bool +} + type Server struct { KeepAlivePeriodSeconds int ListenAddr string @@ -46,6 +50,7 @@ type Server struct { Cors Cors TrustedProxies []string SecureCookie bool + MCP MCP } type Database struct { @@ -106,6 +111,9 @@ func Get() (*Configuration, []FutureLog) { Stream: Stream{ PingPeriodSeconds: 45, }, + MCP: MCP{ + Enabled: true, + }, }, Database: Database{ Dialect: "sqlite3", @@ -166,6 +174,7 @@ func Get() (*Configuration, []FutureLog) { add(parseList(&c.Server.TrustedProxies, EnvServerTrustedProxies)) add(parseBool(&c.Server.SecureCookie, EnvServerSecureCookie)) + add(parseBool(&c.Server.MCP.Enabled, EnvServerMCPEnabled)) add(parseString(&c.Database.Dialect, EnvDatabaseDialect)) add(parseString(&c.Database.Connection, EnvDatabaseConnection)) diff --git a/config/config_test.go b/config/config_test.go index ea00d170e..d06c70071 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -38,6 +38,7 @@ func TestConfigEnv(t *testing.T) { assert.Equal(t, "Company XYZ SSO", conf.OIDC.IDPName) assert.Equal(t, []string{}, conf.OIDC.Prompt) assert.Equal(t, []string{"openid", "profile", "email"}, conf.OIDC.Scopes) + assert.True(t, conf.Server.MCP.Enabled, "should enable mcp by default") } func TestLocalAuthDisabled(t *testing.T) { diff --git a/config/keys.go b/config/keys.go index e56919fc3..1d4d6bd39 100644 --- a/config/keys.go +++ b/config/keys.go @@ -24,6 +24,7 @@ const ( EnvServerCorsAllowHeaders = "GOTIFY_SERVER_CORS_ALLOWHEADERS" EnvServerTrustedProxies = "GOTIFY_SERVER_TRUSTEDPROXIES" EnvServerSecureCookie = "GOTIFY_SERVER_SECURECOOKIE" + EnvServerMCPEnabled = "GOTIFY_SERVER_MCP_ENABLED" EnvDatabaseDialect = "GOTIFY_DATABASE_DIALECT" EnvDatabaseConnection = "GOTIFY_DATABASE_CONNECTION" EnvDefaultUserName = "GOTIFY_DEFAULTUSER_NAME" diff --git a/go.mod b/go.mod index 4461611f7..1e5b7909c 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/h2non/filetype v1.1.3 github.com/joho/godotenv v1.5.1 github.com/mattn/go-isatty v0.0.24 + github.com/modelcontextprotocol/go-sdk v1.8.0 github.com/robfig/cron v1.2.0 github.com/rs/zerolog v1.35.1 github.com/stretchr/testify v1.12.1 @@ -41,6 +42,7 @@ require ( github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -60,8 +62,11 @@ require ( github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.60.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zitadel/schema v1.3.2 // indirect go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -75,6 +80,7 @@ require ( golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect + golang.org/x/time v0.15.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 39acb6fe0..6c7c99388 100644 --- a/go.sum +++ b/go.sum @@ -52,12 +52,16 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= @@ -104,6 +108,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= +github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -131,6 +137,10 @@ github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -148,6 +158,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2 github.com/ugorji/go/codec v0.0.0-20181209151446-772ced7fd4c2/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/zitadel/oidc/v3 v3.51.3 h1:jkEQ2k60amW6vwvzMwrGO7mw7vGxNp6BSsEH0AH6Gas= github.com/zitadel/oidc/v3 v3.51.3/go.mod h1:KQmYte+zOePAeVwR3LM153dxWBC9orWZAIa4w9r1ZhU= github.com/zitadel/schema v1.3.2 h1:gfJvt7dOMfTmxzhscZ9KkapKo3Nei3B6cAxjav+lyjI= @@ -183,6 +195,10 @@ golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/gotify-server.env.example b/gotify-server.env.example index 3912d1698..f89169d8d 100644 --- a/gotify-server.env.example +++ b/gotify-server.env.example @@ -133,6 +133,12 @@ # Type: boolean # GOTIFY_SERVER_SECURECOOKIE=false +# Enable the Model Context Protocol endpoint at /mcp, allowing AI agents to +# send messages with an application token. +# +# Type: boolean +# GOTIFY_SERVER_MCP_ENABLED=true + # Allowed origins (regex) for cross-origin requests. Setting any CORS_* value # enables CORS handling. # diff --git a/router/mcp_test.go b/router/mcp_test.go new file mode 100644 index 000000000..50d010327 --- /dev/null +++ b/router/mcp_test.go @@ -0,0 +1,164 @@ +package router + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gotify/server/v3/config" + "github.com/gotify/server/v3/mode" + "github.com/gotify/server/v3/model" + "github.com/gotify/server/v3/test/testdb" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type headerTransport struct { + header string + value string +} + +func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.Header.Set(t.header, t.value) + return http.DefaultTransport.RoundTrip(req) +} + +func startMCPServer(t *testing.T, enabled bool) (*testdb.Database, *httptest.Server) { + mode.Set(mode.TestDev) + db := testdb.NewDBWithDefaultUser(t) + g, closable := Create( + db.GormDatabase, + &model.VersionInfo{Version: "1.0.0"}, + &config.Configuration{PassStrength: 5, LocalAuthEnabled: true, Server: config.Server{MCP: config.MCP{Enabled: enabled}}}, + ) + server := httptest.NewServer(g) + t.Cleanup(func() { + server.Close() + closable() + db.Close() + }) + return db, server +} + +func connectMCP(t *testing.T, url, header, value string) (*mcp.ClientSession, error) { + c := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1.0.0"}, nil) + return c.Connect(context.Background(), &mcp.StreamableClientTransport{ + Endpoint: url, + HTTPClient: &http.Client{Transport: &headerTransport{header: header, value: value}}, + DisableStandaloneSSE: true, + }, nil) +} + +func TestMCP_SendMessage(t *testing.T) { + db, server := startMCPServer(t, true) + app := db.User(5).NewAppWithTokenAndDefaultPriority(3, "apptoken", 4) + + session, err := connectMCP(t, server.URL+"/mcp", "Authorization", "Bearer apptoken") + require.NoError(t, err) + defer session.Close() + + tools, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, tools.Tools, 1) + assert.Equal(t, "send_message", tools.Tools[0].Name) + + res, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "send_message", + Arguments: map[string]any{ + "message": "**backup** done", + "markdown": true, + "click_url": "https://example.com", + "big_image_url": "https://example.com/image.png", + }, + }) + require.NoError(t, err) + assert.False(t, res.IsError) + assert.Equal(t, "Message sent (id 1).", res.Content[0].(*mcp.TextContent).Text) + + msgs, err := db.GetMessagesByApplication(app.ID) + require.NoError(t, err) + require.Len(t, msgs, 1) + msg := msgs[0] + assert.Equal(t, "**backup** done", msg.Message) + assert.Equal(t, app.Name, msg.Title) + assert.Equal(t, 4, msg.Priority) + assert.JSONEq(t, `{ + "client::display": {"contentType": "text/markdown"}, + "client::notification": {"click": {"url": "https://example.com"}, "bigImageUrl": "https://example.com/image.png"} + }`, string(msg.Extras)) + + res, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "send_message", + Arguments: map[string]any{"message": "plain", "title": "custom", "priority": 8}, + }) + require.NoError(t, err) + assert.False(t, res.IsError) + + msgs, err = db.GetMessagesByApplication(app.ID) + require.NoError(t, err) + require.Len(t, msgs, 2) + msg = msgs[0] + assert.Equal(t, "plain", msg.Message) + assert.Equal(t, "custom", msg.Title) + assert.Equal(t, 8, msg.Priority) + assert.Empty(t, msg.Extras) +} + +func TestMCP_EmptyMessage(t *testing.T) { + db, server := startMCPServer(t, true) + db.User(5).AppWithToken(3, "apptoken") + + session, err := connectMCP(t, server.URL+"/mcp?token=apptoken", "X-Ignored", "") + require.NoError(t, err) + defer session.Close() + + res, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "send_message", + Arguments: map[string]any{"message": ""}, + }) + require.NoError(t, err) + assert.True(t, res.IsError) + db.AssertMessageNotExist(1) +} + +func TestMCP_RequiresApplicationToken(t *testing.T) { + db, server := startMCPServer(t, true) + db.User(5).ClientWithToken(1, "clienttoken") + + for _, token := range []string{"clienttoken", "unknown"} { + _, err := connectMCP(t, server.URL+"/mcp", "X-Gotify-Key", token) + assert.Error(t, err, token) + } + + for token, code := range map[string]int{"clienttoken": 401, "": 401} { + req, err := http.NewRequest("POST", server.URL+"/mcp", strings.NewReader(`{}`)) + require.NoError(t, err) + req.Header.Set("X-Gotify-Key", token) + res, err := client.Do(req) + require.NoError(t, err) + assert.Equal(t, code, res.StatusCode, token) + } + + req, err := http.NewRequest("POST", server.URL+"/mcp", strings.NewReader(`{}`)) + require.NoError(t, err) + req.SetBasicAuth("admin", "pw") + res, err := client.Do(req) + require.NoError(t, err) + assert.Equal(t, 403, res.StatusCode) +} + +func TestMCP_Disabled(t *testing.T) { + db, server := startMCPServer(t, false) + db.User(5).AppWithToken(3, "apptoken") + + req, err := http.NewRequest("POST", server.URL+"/mcp", strings.NewReader(`{}`)) + require.NoError(t, err) + req.Header.Set("X-Gotify-Key", "apptoken") + res, err := client.Do(req) + require.NoError(t, err) + assert.Equal(t, 404, res.StatusCode) +} diff --git a/router/router.go b/router/router.go index 57719f89e..166f407fb 100644 --- a/router/router.go +++ b/router/router.go @@ -203,6 +203,11 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co g.Group("/").Use(authentication.RequireApplicationOrClient).POST("/message", messageHandler.CreateMessage) + if conf.Server.MCP.Enabled { + mcpHandler := api.NewMCP(&messageHandler, vInfo.Version) + g.Match([]string{http.MethodGet, http.MethodPost, http.MethodDelete}, "/mcp", authentication.RequireApplicationToken, mcpHandler.Handle) + } + clientAuth := g.Group("") { clientAuth.Use(authentication.RequireClient) From b72aa85fb72fe60db66e9b2d39d05cad54325f64 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Mon, 5 Oct 2026 06:04:13 -0700 Subject: [PATCH 2/3] docs: note click_url and big_image_url are Android-only --- api/mcp.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/mcp.go b/api/mcp.go index 90d05aa0b..6a09e40ae 100644 --- a/api/mcp.go +++ b/api/mcp.go @@ -30,8 +30,8 @@ type SendMessageInput struct { Title string `json:"title,omitempty" jsonschema:"The message title. Defaults to the application name."` Priority *int `json:"priority,omitempty" jsonschema:"The message priority. Higher values are more urgent: 0 is silent, 1-3 low, 4-7 normal, 8-10 high. Defaults to the application default priority."` Markdown bool `json:"markdown,omitempty" jsonschema:"Whether the message content should be rendered as Markdown."` - ClickURL string `json:"click_url,omitempty" jsonschema:"A URL to open when the notification is clicked."` - BigImageURL string `json:"big_image_url,omitempty" jsonschema:"A URL of an image to show in the notification."` + ClickURL string `json:"click_url,omitempty" jsonschema:"A URL to open when the notification is clicked. Only supported by the Android client."` + BigImageURL string `json:"big_image_url,omitempty" jsonschema:"A URL of an image to show in the notification. Only supported by the Android client."` } // NewMCP creates a new MCPAPI. From 294c1bff347fcd45de37c5749b0e8d1383bb6ee2 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Mon, 5 Oct 2026 09:12:08 -0700 Subject: [PATCH 3/3] test: cover MCP auth guard and database error paths --- api/mcp_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 api/mcp_test.go diff --git a/api/mcp_test.go b/api/mcp_test.go new file mode 100644 index 000000000..b28293c7c --- /dev/null +++ b/api/mcp_test.go @@ -0,0 +1,41 @@ +package api + +import ( + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gotify/server/v3/mode" + "github.com/gotify/server/v3/test/testdb" + "github.com/stretchr/testify/assert" +) + +func TestMCP_HandleWithoutApplication(t *testing.T) { + mode.Set(mode.TestDev) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("POST", "/mcp", nil) + + NewMCP(&MessageAPI{}, "1.0.0").Handle(ctx) + + assert.Equal(t, 403, recorder.Code) + assert.Len(t, ctx.Errors, 1) +} + +func TestMCP_ServerForRequestWithoutApplication(t *testing.T) { + a := NewMCP(&MessageAPI{}, "1.0.0") + assert.Nil(t, a.serverForRequest(httptest.NewRequest("POST", "/mcp", nil))) +} + +func TestMCP_SendMessageDatabaseError(t *testing.T) { + mode.Set(mode.TestDev) + db := testdb.NewDBWithDefaultUser(t) + app := db.User(5).NewAppWithToken(3, "apptoken") + db.Close() + + a := NewMCP(&MessageAPI{DB: db}, "1.0.0") + res, _, err := a.sendMessage(app, SendMessageInput{Message: "hi"}) + + assert.Nil(t, res) + assert.EqualError(t, err, "failed to send message") +}