diff --git a/go.mod b/go.mod
index a0fd3335b..2507b985a 100644
--- a/go.mod
+++ b/go.mod
@@ -45,8 +45,9 @@ require (
github.com/go-sql-driver/mysql v1.9.3
github.com/go-testfixtures/testfixtures/v3 v3.19.0
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a
- github.com/golang-jwt/jwt/v5 v5.3.0
+ github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
+ github.com/gorilla/feeds v1.2.0
github.com/hashicorp/go-version v1.8.0
github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346
github.com/huandu/go-clone/generic v1.7.3
@@ -60,6 +61,7 @@ require (
github.com/magefile/mage v1.15.0
github.com/mattn/go-sqlite3 v1.14.33
github.com/microcosm-cc/bluemonday v1.0.27
+ github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/olekukonko/tablewriter v1.1.3
github.com/pquerna/otp v1.5.0
github.com/prometheus/client_golang v1.23.2
@@ -79,7 +81,7 @@ require (
golang.org/x/crypto v0.48.0
golang.org/x/image v0.38.0
golang.org/x/net v0.50.0
- golang.org/x/oauth2 v0.34.0
+ golang.org/x/oauth2 v0.35.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.41.0
golang.org/x/term v0.40.0
@@ -143,8 +145,8 @@ require (
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang/snappy v0.0.4 // indirect
+ github.com/google/jsonschema-go v0.4.3 // indirect
github.com/gorilla/css v1.0.1 // indirect
- github.com/gorilla/feeds v1.2.0 // indirect
github.com/huandu/go-clone v1.7.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
@@ -177,6 +179,8 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
+ github.com/segmentio/asm v1.1.3 // indirect
+ github.com/segmentio/encoding v0.5.4 // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
@@ -186,6 +190,7 @@ require (
github.com/syndtr/goleveldb v1.0.0 // indirect
github.com/tj/assert v0.0.3 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
diff --git a/go.sum b/go.sum
index a917d62a4..8b01cd0fb 100644
--- a/go.sum
+++ b/go.sum
@@ -208,8 +208,8 @@ github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
-github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
-github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+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-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
@@ -237,6 +237,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
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/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
+github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -398,6 +400,8 @@ github.com/moby/moby/client v0.2.2 h1:Pt4hRMCAIlyjL3cr8M5TrXCwKzguebPAc2do2ur7dE
github.com/moby/moby/client v0.2.2/go.mod h1:2EkIPVNCqR05CMIzL1mfA07t0HvVUUOl85pasRz/GmQ=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
+github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
+github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
@@ -478,6 +482,10 @@ github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeH
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
+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/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
@@ -534,6 +542,8 @@ github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
+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/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 h1:0sw0nJM544SpsihWx1bkXdYLQDlzRflMgFJQ4Yih9ts=
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -616,8 +626,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
-golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
-golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
diff --git a/pkg/modules/mcp/context.go b/pkg/modules/mcp/context.go
new file mode 100644
index 000000000..d0dd63dfa
--- /dev/null
+++ b/pkg/modules/mcp/context.go
@@ -0,0 +1,61 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package mcp
+
+import (
+ "context"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/user"
+)
+
+// Context propagation between the Echo entry handler and downstream tool
+// handlers. The SDK's RequestExtra only carries OAuth TokenInfo + headers —
+// it does not expose *http.Request — so we attach the authenticated user
+// and the API token to r.Context() at the entry boundary and pull them out
+// inside tool handlers via the accessors below.
+//
+// Typed keys (unexported empty structs) avoid collisions with any other
+// package that might write to the same context.
+
+type userCtxKey struct{}
+type tokenCtxKey struct{}
+
+// WithUser returns a new context that carries the authenticated user.
+func WithUser(ctx context.Context, u *user.User) context.Context {
+ return context.WithValue(ctx, userCtxKey{}, u)
+}
+
+// WithToken returns a new context that carries the API token used for the
+// current MCP request.
+func WithToken(ctx context.Context, t *models.APIToken) context.Context {
+ return context.WithValue(ctx, tokenCtxKey{}, t)
+}
+
+// UserFromContext returns the authenticated user attached by the MCP entry
+// handler, or nil if no user is present.
+func UserFromContext(ctx context.Context) *user.User {
+ u, _ := ctx.Value(userCtxKey{}).(*user.User)
+ return u
+}
+
+// TokenFromContext returns the API token attached by the MCP entry handler,
+// or nil if no token is present.
+func TokenFromContext(ctx context.Context) *models.APIToken {
+ t, _ := ctx.Value(tokenCtxKey{}).(*models.APIToken)
+ return t
+}
diff --git a/pkg/modules/mcp/mcp.go b/pkg/modules/mcp/mcp.go
new file mode 100644
index 000000000..dc7e0b62d
--- /dev/null
+++ b/pkg/modules/mcp/mcp.go
@@ -0,0 +1,114 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Package mcp implements the streamable-HTTP MCP endpoint that exposes
+// Vikunja's CRUD API to MCP-aware clients (Claude Desktop, Cursor, etc.).
+//
+// The entry point is Handler, which is mounted by the routes package
+// inside the existing authenticated /api/v1 group. The actual MCP protocol
+// (JSON-RPC framing, session management, SSE streaming) is delegated to
+// github.com/modelcontextprotocol/go-sdk.
+package mcp
+
+import (
+ "net/http"
+
+ "code.vikunja.io/api/pkg/log"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/user"
+ "code.vikunja.io/api/pkg/version"
+
+ "github.com/labstack/echo/v5"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// routePrefix is the URL prefix the MCP endpoint is mounted under. The
+// SDK handler does not care about path — it dispatches on HTTP method
+// alone — so this is only used to strip the prefix before forwarding so
+// the underlying http.Request looks like it was routed to "/".
+const routePrefix = "/api/v1/mcp"
+
+// newServer constructs a fresh *mcp.Server with Vikunja's implementation
+// metadata. The SDK's NewStreamableHTTPHandler accepts a factory
+// (getServer) that may return the same server across sessions; we return
+// a new one per session for now so future per-session state (e.g.
+// scope-filtered tool sets, see Task 6) has a clean place to live.
+func newServer() *mcp.Server {
+ return mcp.NewServer(&mcp.Implementation{
+ Name: "vikunja",
+ Version: version.Version,
+ }, nil)
+}
+
+// streamableHandler is package-level so the SDK can manage its internal
+// session map across requests. The factory returned to the SDK still
+// builds a fresh *mcp.Server per session so we can attach per-session
+// state later without churning the handler.
+var streamableHandler = mcp.NewStreamableHTTPHandler(
+ func(_ *http.Request) *mcp.Server { return newServer() },
+ nil,
+)
+
+// Handler is the Echo entry point for the MCP endpoint. It:
+//
+// 1. Rejects JWT-authed requests with 401 — MCP is token-only because
+// JWT bypasses CanDoAPIRoute (and therefore the mcp:access scope).
+// 2. Pulls the API token from the Echo context and rejects with 403 if
+// it does not have the mcp:access scope.
+// 3. Attaches the authenticated user and token to r.Context() via the
+// typed keys in context.go so tool handlers can pull them out
+// without depending on Echo.
+// 4. Forwards to the SDK's streamable-HTTP handler with the route
+// prefix stripped.
+func Handler(c *echo.Context) error {
+ // JWT-authed requests have a *jwt.Token under "user" and do not have
+ // "api_token" set. The token middleware only populates "api_token"
+ // when it successfully resolves a Bearer tk_… header.
+ tokenAny := c.Get("api_token")
+ if tokenAny == nil {
+ log.Debugf("[mcp] rejecting non-API-token request to %s", c.Request().URL.Path)
+ return echo.NewHTTPError(http.StatusUnauthorized, "MCP requires an API token")
+ }
+
+ token, ok := tokenAny.(*models.APIToken)
+ if !ok || token == nil {
+ log.Errorf("[mcp] api_token in context has unexpected type %T", tokenAny)
+ return echo.NewHTTPError(http.StatusInternalServerError, "invalid token in context")
+ }
+
+ if !token.HasMCPAccess() {
+ log.Debugf("[mcp] API token %d does not have mcp:access scope", token.ID)
+ return echo.NewHTTPError(http.StatusForbidden, "token does not have mcp:access scope")
+ }
+
+ u, ok := c.Get("api_user").(*user.User)
+ if !ok || u == nil {
+ log.Errorf("[mcp] api_user missing from context for token %d", token.ID)
+ return echo.NewHTTPError(http.StatusInternalServerError, "missing user in context")
+ }
+
+ req := c.Request()
+ ctx := WithUser(req.Context(), u)
+ ctx = WithToken(ctx, token)
+ req = req.WithContext(ctx)
+
+ // Strip the mount prefix before forwarding. The SDK's ServeHTTP
+ // dispatches on req.Method, not req.URL.Path, so this is mostly
+ // cosmetic — but it keeps the request looking the way the SDK's own
+ // tests/examples expect (requests served at "/").
+ http.StripPrefix(routePrefix, streamableHandler).ServeHTTP(c.Response(), req)
+ return nil
+}
diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go
index c4ce0a002..79e5c2e26 100644
--- a/pkg/routes/routes.go
+++ b/pkg/routes/routes.go
@@ -68,6 +68,7 @@ import (
backgroundHandler "code.vikunja.io/api/pkg/modules/background/handler"
"code.vikunja.io/api/pkg/modules/background/unsplash"
"code.vikunja.io/api/pkg/modules/background/upload"
+ mcpmodule "code.vikunja.io/api/pkg/modules/mcp"
"code.vikunja.io/api/pkg/modules/migration"
csvmigrator "code.vikunja.io/api/pkg/modules/migration/csv"
migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler"
@@ -501,6 +502,14 @@ func registerAPIRoutes(a *echo.Group) {
u.POST("/bots/:bot", botHandler.UpdateWeb)
u.DELETE("/bots/:bot", botHandler.DeleteWeb)
+ // MCP endpoint. The streamable-HTTP transport uses POST, GET, and
+ // DELETE on the same path; CanDoAPIRoute does an exact (method,
+ // path) match per permission, so the route check is skipped in the
+ // token middleware (see api_tokens.go) and the mcp:access scope is
+ // gated inline inside the handler via APIToken.HasMCPAccess().
+ a.Any("/mcp", mcpmodule.Handler)
+ a.Any("/mcp/*", mcpmodule.Handler)
+
projectHandler := &handler.WebHandler{
EmptyStruct: func() handler.CObject {
return &models.Project{}
diff --git a/pkg/webtests/mcp_test.go b/pkg/webtests/mcp_test.go
new file mode 100644
index 000000000..69020c1df
--- /dev/null
+++ b/pkg/webtests/mcp_test.go
@@ -0,0 +1,197 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package webtests
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/modules/auth"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/labstack/echo/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ // Token 9 has only the mcp:access scope, owned by user 1.
+ mcpOnlyToken = "tk_mcp_access_token_test_0000000000mcp0001"
+ // Token 1 has only {tasks:[read_all, update]} — no mcp scope. Owner: user 1.
+ // (Token 10, mcp + projects:{read_one, read_all}, is reserved for the
+ // scope-filtering tests that land with Task 6.)
+ noMCPToken = "tk_2eef46f40ebab3304919ab2e7e39993f75f29d2e"
+)
+
+// mcpRequest builds an MCP request with the appropriate Accept + Content-Type
+// headers required by the streamable-HTTP transport.
+func mcpRequest(method, body string) *http.Request {
+ req := httptest.NewRequest(method, "/api/v1/mcp", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json, text/event-stream")
+ return req
+}
+
+// readMCPJSON extracts the JSON-RPC payload from an MCP response. The SDK
+// may return either application/json (single object) or a single-event SSE
+// stream depending on negotiation.
+func readMCPJSON(t *testing.T, body string) map[string]any {
+ t.Helper()
+ body = strings.TrimSpace(body)
+ // SSE framing — find the first "data: " line.
+ if strings.HasPrefix(body, "event:") || strings.Contains(body, "data:") {
+ for _, line := range strings.Split(body, "\n") {
+ if strings.HasPrefix(line, "data:") {
+ body = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
+ break
+ }
+ }
+ }
+ var out map[string]any
+ require.NoError(t, json.Unmarshal([]byte(body), &out), "body was: %s", body)
+ return out
+}
+
+func TestMCP_AnonymousRejected(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ req := mcpRequest(http.MethodPost, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`)
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+}
+
+func TestMCP_JWTRejected(t *testing.T) {
+ // MCP is a token-only endpoint. JWT bypasses CanDoAPIRoute entirely, so
+ // without an explicit rejection the scope gate would be moot.
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ s := db.NewSession()
+ defer s.Close()
+ u, err := user.GetUserByID(s, 1)
+ require.NoError(t, err)
+ jwt, err := auth.NewUserJWTAuthtoken(u, "test-session-id")
+ require.NoError(t, err)
+
+ req := mcpRequest(http.MethodPost, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`)
+ req.Header.Set(echo.HeaderAuthorization, "Bearer "+jwt)
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+}
+
+func TestMCP_TokenWithoutMCPScopeRejected(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ req := mcpRequest(http.MethodPost, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`)
+ req.Header.Set(echo.HeaderAuthorization, "Bearer "+noMCPToken)
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusForbidden, rec.Code)
+}
+
+func TestMCP_InitializeWithMCPToken(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ req := mcpRequest(http.MethodPost, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}`)
+ req.Header.Set(echo.HeaderAuthorization, "Bearer "+mcpOnlyToken)
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ payload := readMCPJSON(t, rec.Body.String())
+ result, ok := payload["result"].(map[string]any)
+ require.True(t, ok, "response missing result: %s", rec.Body.String())
+ assert.NotEmpty(t, result["protocolVersion"])
+ serverInfo, ok := result["serverInfo"].(map[string]any)
+ require.True(t, ok, "response missing serverInfo: %s", rec.Body.String())
+ assert.Equal(t, "vikunja", serverInfo["name"])
+
+ // The SDK exposes the session ID via the Mcp-Session-Id header.
+ assert.NotEmpty(t, rec.Header().Get("Mcp-Session-Id"))
+}
+
+func TestMCP_ToolsListEmpty(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ // Step 1: initialize so the SDK opens a session.
+ initReq := mcpRequest(http.MethodPost, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}`)
+ initReq.Header.Set(echo.HeaderAuthorization, "Bearer "+mcpOnlyToken)
+ initRec := httptest.NewRecorder()
+ e.ServeHTTP(initRec, initReq)
+ require.Equal(t, http.StatusOK, initRec.Code, "body: %s", initRec.Body.String())
+ sessionID := initRec.Header().Get("Mcp-Session-Id")
+ require.NotEmpty(t, sessionID)
+
+ // Step 2: send the required "notifications/initialized" client message.
+ initNotifyReq := mcpRequest(http.MethodPost, `{"jsonrpc":"2.0","method":"notifications/initialized"}`)
+ initNotifyReq.Header.Set(echo.HeaderAuthorization, "Bearer "+mcpOnlyToken)
+ initNotifyReq.Header.Set("Mcp-Session-Id", sessionID)
+ initNotifyRec := httptest.NewRecorder()
+ e.ServeHTTP(initNotifyRec, initNotifyReq)
+ require.Less(t, initNotifyRec.Code, 400, "body: %s", initNotifyRec.Body.String())
+
+ // Step 3: ask for tools.
+ listReq := mcpRequest(http.MethodPost, `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`)
+ listReq.Header.Set(echo.HeaderAuthorization, "Bearer "+mcpOnlyToken)
+ listReq.Header.Set("Mcp-Session-Id", sessionID)
+ listRec := httptest.NewRecorder()
+ e.ServeHTTP(listRec, listReq)
+
+ require.Equal(t, http.StatusOK, listRec.Code, "body: %s", listRec.Body.String())
+ payload := readMCPJSON(t, listRec.Body.String())
+ result, ok := payload["result"].(map[string]any)
+ require.True(t, ok, "response missing result: %s", listRec.Body.String())
+ tools, ok := result["tools"].([]any)
+ require.True(t, ok, "response missing tools array: %s", listRec.Body.String())
+ assert.Empty(t, tools, "expected empty tools list, got: %v", tools)
+}
+
+func TestMCP_SessionRoundTrip(t *testing.T) {
+ // Verifies that the Mcp-Session-Id round-trip survives the Echo wrapper.
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ initReq := mcpRequest(http.MethodPost, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}`)
+ initReq.Header.Set(echo.HeaderAuthorization, "Bearer "+mcpOnlyToken)
+ initRec := httptest.NewRecorder()
+ e.ServeHTTP(initRec, initReq)
+ require.Equal(t, http.StatusOK, initRec.Code, "body: %s", initRec.Body.String())
+ sessionID := initRec.Header().Get("Mcp-Session-Id")
+ require.NotEmpty(t, sessionID)
+
+ // A follow-up request with a known session id should be accepted (not
+ // rejected as "session not found").
+ pingReq := mcpRequest(http.MethodPost, `{"jsonrpc":"2.0","id":99,"method":"ping","params":{}}`)
+ pingReq.Header.Set(echo.HeaderAuthorization, "Bearer "+mcpOnlyToken)
+ pingReq.Header.Set("Mcp-Session-Id", sessionID)
+ pingRec := httptest.NewRecorder()
+ e.ServeHTTP(pingRec, pingReq)
+ require.Equal(t, http.StatusOK, pingRec.Code, "body: %s", pingRec.Body.String())
+}