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
* 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..6a09e40ae
--- /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. 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.
+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/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")
+}
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)