Skip to content
Draft
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
11 changes: 8 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ 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/jsonschema-go v0.4.3
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
Expand All @@ -60,6 +62,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
Expand All @@ -79,7 +82,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
Expand Down Expand Up @@ -144,7 +147,6 @@ require (
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang/snappy v0.0.4 // 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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
18 changes: 14 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
30 changes: 30 additions & 0 deletions pkg/db/fixtures/api_tokens.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,33 @@
owner_id: 13
created: 2024-01-01 00:00:00
# token in plaintext is tk_feeds_access_token_user_0013_feed0013
- id: 9
title: 'mcp access token for user 1'
token_salt: mCpAcCs9R1
token_hash: d57d7084733dee8e76c81ed4220bb4f9147e39b7966c7c435ced7437b2e4e09c9d4595d544b9dcd613c179e9866074f64a87
token_last_eight: 0mcp0001
permissions: '{"mcp":["access"]}'
expires_at: 2099-01-01 00:00:00
owner_id: 1
created: 2024-01-01 00:00:00
# token in plaintext is tk_mcp_access_token_test_0000000000mcp0001
- id: 10
title: 'mcp access token with mixed scopes for user 1'
token_salt: mCpMxSc8R2
token_hash: 8c34b5ca2154ee6515900650600d260c1246b98c28e7d56ab6f247aeea81b0fd65d433a4fd8c162149ebe2ff751e020bd8c8
token_last_eight: pmixed02
permissions: '{"mcp":["access"],"projects":["read_one","read_all"]}'
expires_at: 2099-01-01 00:00:00
owner_id: 1
created: 2024-01-01 00:00:00
# token in plaintext is tk_mcp_mixed_scope_token_test_00mcpmixed02
- id: 11
title: 'mcp access token with full project scopes for user 1'
token_salt: mCpFullSc9R3
token_hash: 3b530a9f7564d062a526537f06ea8b570e2ac1ca1d69f59b04cd7abdbb9c5804517a639a88613940fb427c71ee4c6e800fc9
token_last_eight: fullp003
permissions: '{"mcp":["access"],"projects":["create","read_one","read_all","update","delete"],"tasks":["create","read_one","update","delete"],"labels":["create","read_one","read_all","update","delete"],"teams":["create","read_one","read_all","update","delete"],"tasks_comments":["create","read_one","read_all","update","delete"],"tasks_assignees":["create","read_all","delete"]}'
expires_at: 2099-01-01 00:00:00
owner_id: 1
created: 2024-01-01 00:00:00
# token in plaintext is tk_mcp_full_projects_token_test_0fullp003
12 changes: 12 additions & 0 deletions pkg/models/api_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ func init() {
Method: "GET",
},
}
// The MCP endpoint serves the streamable-HTTP transport, which uses
// POST, GET and DELETE on the same path. CanDoAPIRoute only matches one
// (method, path) pair per RouteDetail, so the actual gate lives behind
// skipRouteCheck + an inline HasMCPAccess() call in the MCP handler.
// This entry only exists so the scope appears in GET /api/v1/routes
// and PermissionsAreValid accepts it.
apiTokenRoutes["mcp"] = APITokenRoute{
"access": &RouteDetail{
Path: "/api/v1/mcp",
Method: "ANY",
},
}
}

type APITokenRoute map[string]*RouteDetail
Expand Down
18 changes: 18 additions & 0 deletions pkg/models/api_routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ import (
"github.com/stretchr/testify/require"
)

func TestAPITokenRoutes_MCPAccessRegistered(t *testing.T) {
routes := GetAPITokenRoutes()

group, has := routes["mcp"]
require.True(t, has, "mcp scope group should be registered")

detail, has := group["access"]
require.True(t, has, "mcp.access permission should be registered")
require.NotNil(t, detail, "mcp.access RouteDetail should not be nil")
assert.NotEmpty(t, detail.Path, "mcp.access path should not be empty")
assert.NotEmpty(t, detail.Method, "mcp.access method should not be empty")
}

func TestPermissionsAreValid_MCPAccess(t *testing.T) {
err := PermissionsAreValid(APIPermissions{"mcp": {"access"}})
require.NoError(t, err)
}

func TestCanDoAPIRoute_BulkLabelTask(t *testing.T) {
// Reset apiTokenRoutes to isolate this test
apiTokenRoutes = make(map[string]APITokenRoute)
Expand Down
12 changes: 12 additions & 0 deletions pkg/models/api_tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,18 @@ func (t *APIToken) HasFeedsAccess() bool {
return slices.Contains(perms, "access")
}

// HasMCPAccess checks whether the token has the mcp access permission.
// The MCP endpoint uses POST, GET, and DELETE on the same path (streamable-HTTP
// transport), so CanDoAPIRoute can't gate it — the MCP entry handler calls
// this directly after the middleware skips the route check.
func (t *APIToken) HasMCPAccess() bool {
perms, has := t.APIPermissions["mcp"]
if !has {
return false
}
return slices.Contains(perms, "access")
}

// GetTokenFromTokenString returns the full token object from the original token string.
func GetTokenFromTokenString(s *xorm.Session, token string) (apiToken *APIToken, err error) {
lastEight := token[len(token)-8:]
Expand Down
37 changes: 35 additions & 2 deletions pkg/models/api_tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,14 @@ func TestAPIToken_ReadAll(t *testing.T) {
require.NoError(t, err)
tokens, is := result.([]*APIToken)
assert.Truef(t, is, "tokens are not of type []*APIToken")
assert.Len(t, tokens, 2)
assert.Len(t, tokens, 5)
assert.Len(t, tokens, count)
assert.Equal(t, int64(2), total)
assert.Equal(t, int64(5), total)
assert.Equal(t, int64(1), tokens[0].ID)
assert.Equal(t, int64(2), tokens[1].ID)
assert.Equal(t, int64(9), tokens[2].ID)
assert.Equal(t, int64(10), tokens[3].ID)
assert.Equal(t, int64(11), tokens[4].ID)
}

func TestAPIToken_CanDelete(t *testing.T) {
Expand Down Expand Up @@ -155,6 +158,36 @@ func TestAPIToken_HasFeedsAccess(t *testing.T) {
})
}

func TestAPIToken_HasMCPAccess(t *testing.T) {
t.Run("has mcp access", func(t *testing.T) {
token := &APIToken{
APIPermissions: APIPermissions{"mcp": {"access"}},
}
assert.True(t, token.HasMCPAccess())
})
t.Run("no mcp group", func(t *testing.T) {
token := &APIToken{
APIPermissions: APIPermissions{"tasks": {"read_all"}},
}
assert.False(t, token.HasMCPAccess())
})
t.Run("mcp group but wrong permission", func(t *testing.T) {
token := &APIToken{
APIPermissions: APIPermissions{"mcp": {"read_all"}},
}
assert.False(t, token.HasMCPAccess())
})
t.Run("mcp access among other permissions", func(t *testing.T) {
token := &APIToken{
APIPermissions: APIPermissions{
"tasks": {"read_all", "update"},
"mcp": {"access"},
},
}
assert.True(t, token.HasMCPAccess())
})
}

func TestAPIToken_GetTokenFromTokenString(t *testing.T) {
t.Run("valid token", func(t *testing.T) {
s := db.NewSession()
Expand Down
61 changes: 61 additions & 0 deletions pkg/modules/mcp/context.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading