Skip to content
Open
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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ We wanted a simple server for sending and receiving messages (in real time per W
<img alt="Gotify UI screenshot" src="ui.png" align="right" width="500px"/>

* 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)
Expand All @@ -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 <token>` 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 <apptoken>"
```

VS Code / Cherry Studio and other clients:
```json
{
"servers": {
"gotify": {
"type": "http",
"url": "https://gotify.example.com/mcp",
"headers": { "Authorization": "Bearer <apptoken>" }
}
}
}
```

## 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.
Expand Down
115 changes: 115 additions & 0 deletions api/mcp.go
Original file line number Diff line number Diff line change
@@ -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
}
41 changes: 41 additions & 0 deletions api/mcp_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
19 changes: 14 additions & 5 deletions api/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ type Cors struct {
AllowHeaders []string
}

type MCP struct {
Enabled bool
}

type Server struct {
KeepAlivePeriodSeconds int
ListenAddr string
Expand All @@ -46,6 +50,7 @@ type Server struct {
Cors Cors
TrustedProxies []string
SecureCookie bool
MCP MCP
}

type Database struct {
Expand Down Expand Up @@ -106,6 +111,9 @@ func Get() (*Configuration, []FutureLog) {
Stream: Stream{
PingPeriodSeconds: 45,
},
MCP: MCP{
Enabled: true,
},
},
Database: Database{
Dialect: "sqlite3",
Expand Down Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions config/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 6 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
)

Expand Down
16 changes: 16 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
6 changes: 6 additions & 0 deletions gotify-server.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down
Loading