From 3ec2d89543d91edd3457fe737e710de5cdf6e691 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 26 May 2026 23:08:45 +0200 Subject: [PATCH] feat(mcp): add streamable-http endpoint skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mount /api/v1/mcp (and /api/v1/mcp/*) inside the authenticated route group. Reject JWT-authed requests with 401 (token-only policy), reject API tokens without the mcp:access scope with 403, and propagate the authed *user.User + *models.APIToken to r.Context() via typed keys so downstream tool handlers can pull them out without depending on Echo. The MCP protocol — JSON-RPC framing, Mcp-Session-Id management, SSE streaming — is delegated to github.com/modelcontextprotocol/go-sdk v1.6.1. tools/list returns {"tools": []} since no tools are registered yet. --- go.mod | 11 ++- go.sum | 18 +++- pkg/modules/mcp/context.go | 61 ++++++++++++ pkg/modules/mcp/mcp.go | 114 +++++++++++++++++++++ pkg/routes/routes.go | 9 ++ pkg/webtests/mcp_test.go | 197 +++++++++++++++++++++++++++++++++++++ 6 files changed, 403 insertions(+), 7 deletions(-) create mode 100644 pkg/modules/mcp/context.go create mode 100644 pkg/modules/mcp/mcp.go create mode 100644 pkg/webtests/mcp_test.go 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()) +}