feat(mcp): add streamable-http endpoint skeleton
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.
This commit is contained in:
parent
49934adaaf
commit
3ec2d89543
11
go.mod
11
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
|
||||
|
|
|
|||
18
go.sum
18
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=
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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())
|
||||
}
|
||||
Loading…
Reference in New Issue