feat(mcp): enforce per-tool api token scopes
Filter MCP tool visibility and invocation by the requesting API token's (group, permission) scopes. tools/list now returns only the tools the token's APIPermissions authorise; tools/call additionally re-checks the scope in the dispatcher as defence-in-depth, so a session created with one token cannot be reused to invoke tools that token never had access to. The per-session filter runs at session-init via the StreamableHTTPHandler getServer factory (which the SDK calls once per session, before caching the *mcp.Server). The dispatcher check runs on every tools/call and returns ErrScopeDenied, which the AddTool wrapper renders as an IsError tool result.
This commit is contained in:
parent
e423167ce1
commit
8fbc6b62a2
|
|
@ -95,12 +95,13 @@ var crud = defaultCRUD
|
|||
// skip the unmarshal round-trip the SDK has already performed against the
|
||||
// input schema).
|
||||
//
|
||||
// Errors fall into two categories:
|
||||
// - ErrToolNotFound / ErrNoUserInContext / JSON-unmarshal errors are
|
||||
// dispatcher-level failures the caller should translate into an
|
||||
// IsError=true tool result. We return them as errors here (rather than
|
||||
// constructing a *mcp.CallToolResult) so the dispatcher stays
|
||||
// SDK-agnostic; the thin AddTool handler does the wrapping.
|
||||
// Errors fall into three categories:
|
||||
// - ErrToolNotFound / ErrNoUserInContext / ErrScopeDenied /
|
||||
// JSON-unmarshal errors are dispatcher-level failures the caller should
|
||||
// translate into an IsError=true tool result. We return them as errors
|
||||
// here (rather than constructing a *mcp.CallToolResult) so the
|
||||
// dispatcher stays SDK-agnostic; the thin AddTool handler does the
|
||||
// wrapping.
|
||||
// - Errors returned by handler.Do* (model-layer permission denials,
|
||||
// validation failures, etc.) are propagated as-is. The tool handler
|
||||
// wraps them with SetError per the SDK's convention that domain
|
||||
|
|
@ -111,6 +112,16 @@ func Dispatch(ctx context.Context, toolName string, rawArgs json.RawMessage) (an
|
|||
return nil, fmt.Errorf("%w: %s", ErrToolNotFound, toolName)
|
||||
}
|
||||
|
||||
// Scope check first — never allocate a wrapper or touch model state
|
||||
// for a tool the caller isn't authorized to invoke. This guards
|
||||
// against the (rare) case where the per-session tool registration in
|
||||
// newServer registered a tool the current request's token doesn't
|
||||
// have a scope for: the SDK caches the *Server across requests, but
|
||||
// the API token is per-HTTP-request.
|
||||
if !tokenAuthorizes(TokenFromContext(ctx), ref.resource.Name, ref.op) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrScopeDenied, toolName)
|
||||
}
|
||||
|
||||
// Allocate a fresh wrapper for this call so concurrent dispatches
|
||||
// don't share state through the prototype stored in r.Inputs.
|
||||
wrapperProto, ok := ref.resource.Inputs[ref.op]
|
||||
|
|
@ -143,6 +154,11 @@ func DispatchTyped(ctx context.Context, toolName string, wrapper any) (any, erro
|
|||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s", ErrToolNotFound, toolName)
|
||||
}
|
||||
// Scope check mirrors Dispatch — see the comment there for why this
|
||||
// is necessary even when newServer already filtered the tool set.
|
||||
if !tokenAuthorizes(TokenFromContext(ctx), ref.resource.Name, ref.op) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrScopeDenied, toolName)
|
||||
}
|
||||
return dispatchPrepared(ctx, ref, wrapper)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import (
|
|||
"errors"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/api/pkg/web"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
|
@ -119,10 +120,20 @@ func (i *stubInput) ReadAllParams() (string, int, int) {
|
|||
return i.Search, i.Page, i.PerPage
|
||||
}
|
||||
|
||||
// newAuthedCtx returns a context with a test user and an API token that
|
||||
// authorizes every (resource, op) on the "stubs" resource — sufficient for
|
||||
// the dispatcher's wiring tests. Scope-denied scenarios are covered in
|
||||
// scope_test.go with explicitly narrower tokens.
|
||||
func newAuthedCtx(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
u := &user.User{ID: 42}
|
||||
return WithUser(context.Background(), u)
|
||||
token := &models.APIToken{
|
||||
APIPermissions: models.APIPermissions{
|
||||
"stubs": []string{"create", "read_one", "read_all", "update", "delete"},
|
||||
},
|
||||
}
|
||||
ctx := WithUser(context.Background(), u)
|
||||
return WithToken(ctx, token)
|
||||
}
|
||||
|
||||
// installStubCRUD swaps the dispatcher's Do* function set with test doubles
|
||||
|
|
@ -171,7 +182,18 @@ func TestDispatchNoUser(t *testing.T) {
|
|||
Inputs: map[Op]any{OpReadOne: &stubInput{}},
|
||||
}))
|
||||
|
||||
_, err := Dispatch(context.Background(), "stubs_read_one", json.RawMessage(`{"id":1}`))
|
||||
// Attach an authorising token but no user — the scope check passes,
|
||||
// the user lookup inside dispatchPrepared fails. Ordering matters: the
|
||||
// scope check runs first so callers without a token never reach the
|
||||
// user check.
|
||||
token := &models.APIToken{
|
||||
APIPermissions: models.APIPermissions{
|
||||
"stubs": []string{"read_one"},
|
||||
},
|
||||
}
|
||||
ctx := WithToken(context.Background(), token)
|
||||
|
||||
_, err := Dispatch(ctx, "stubs_read_one", json.RawMessage(`{"id":1}`))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrNoUserInContext)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,33 +42,40 @@ import (
|
|||
const routePrefix = "/api/v1/mcp"
|
||||
|
||||
// newServer constructs a fresh *mcp.Server with Vikunja's implementation
|
||||
// metadata and the static set of registered tools. 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.
|
||||
// metadata and the per-session set of registered tools. The SDK calls the
|
||||
// factory passed to NewStreamableHTTPHandler exactly once per session
|
||||
// (when no Mcp-Session-Id matches an existing session, i.e. at the
|
||||
// initialize request); the returned *mcp.Server is cached for the
|
||||
// lifetime of that session.
|
||||
//
|
||||
// RegisterResources is idempotent and is called here so production startup
|
||||
// doesn't need to know about a separate init step — the first incoming MCP
|
||||
// request triggers registration on demand.
|
||||
func newServer() *mcp.Server {
|
||||
// Per-token tool filtering happens here: we pull the API token from the
|
||||
// request context (placed there by the Echo entry handler in Handler) and
|
||||
// register only the tools the token's scopes authorise. tools/list then
|
||||
// returns the filtered subset naturally; tools/call is additionally
|
||||
// re-checked in the dispatcher.
|
||||
//
|
||||
// RegisterResources is idempotent and called here so production startup
|
||||
// doesn't need to know about a separate init step — the first incoming
|
||||
// MCP request triggers registration on demand.
|
||||
func newServer(req *http.Request) *mcp.Server {
|
||||
RegisterResources()
|
||||
srv := mcp.NewServer(&mcp.Implementation{
|
||||
Name: "vikunja",
|
||||
Version: version.Version,
|
||||
}, nil)
|
||||
installTools(srv)
|
||||
// The token may legitimately be nil if a future code path forgets to
|
||||
// attach one — installToolsForToken treats that as "no tools allowed".
|
||||
// In the production flow Handler rejects unauthenticated requests
|
||||
// before reaching the SDK, so this is purely defensive.
|
||||
token := TokenFromContext(req.Context())
|
||||
installToolsForToken(srv, token)
|
||||
return srv
|
||||
}
|
||||
|
||||
// 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,
|
||||
)
|
||||
// session map across requests. The factory returns a fresh *mcp.Server
|
||||
// per session, scoped to the requesting token's permissions.
|
||||
var streamableHandler = mcp.NewStreamableHTTPHandler(newServer, nil)
|
||||
|
||||
// Handler is the Echo entry point for the MCP endpoint. It:
|
||||
//
|
||||
|
|
|
|||
|
|
@ -28,6 +28,17 @@ import (
|
|||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// Approach for scope-filtered tools/list (Task 6): the SDK calls the
|
||||
// getServer factory in NewStreamableHTTPHandler exactly once per session
|
||||
// (at the initialize request, when no Mcp-Session-Id matches an existing
|
||||
// session) and caches the returned *mcp.Server for the lifetime of that
|
||||
// session. There is no filter callback in mcp.ServerOptions, so we build a
|
||||
// per-session *mcp.Server that only registers the tools the requesting
|
||||
// token's APIPermissions allows. tools/list then naturally returns the
|
||||
// allowed subset. The dispatcher additionally re-checks scopes on every
|
||||
// tools/call as a defence-in-depth measure (the same session could in
|
||||
// principle be reused across requests carrying different tokens).
|
||||
|
||||
// resources.go owns the central list of MCP-exposed resources. Each entry
|
||||
// declares: the resource name (matches the API-token scope group), the
|
||||
// model's EmptyStruct, the set of supported ops, and the per-op input
|
||||
|
|
@ -74,19 +85,22 @@ func registerProjects() error {
|
|||
})
|
||||
}
|
||||
|
||||
// installTools walks the registry and binds each enabled (resource, op)
|
||||
// pair to a tool on the given server. Per-op wrapper types are known at
|
||||
// compile time, so a per-resource installer is the cleanest way to keep the
|
||||
// SDK's compile-time type parameter happy while the registry stays
|
||||
// data-driven elsewhere.
|
||||
// installToolsForToken walks the registry and binds each (resource, op)
|
||||
// pair to a tool on the given server, but only if the token authorises that
|
||||
// (group, permission) combination. Per-op wrapper types are known at compile
|
||||
// time, so a per-resource installer is the cleanest way to keep the SDK's
|
||||
// compile-time type parameter happy while the registry stays data-driven
|
||||
// elsewhere.
|
||||
//
|
||||
// Called from newServer (mcp.go); every fresh MCP session gets the full
|
||||
// tool set. Per-token scope filtering is layered on top in Task 6.
|
||||
func installTools(srv *mcp.Server) {
|
||||
installProjectsTools(srv)
|
||||
// Called from newServer (mcp.go) at session-init time. A nil token (which
|
||||
// should never happen in production because the entry handler rejects
|
||||
// unauthenticated requests) yields a server with no tools — defensive, the
|
||||
// dispatcher would also reject the call.
|
||||
func installToolsForToken(srv *mcp.Server, token *models.APIToken) {
|
||||
installProjectsToolsForToken(srv, token)
|
||||
}
|
||||
|
||||
func installProjectsTools(srv *mcp.Server) {
|
||||
func installProjectsToolsForToken(srv *mcp.Server, token *models.APIToken) {
|
||||
r, ok := lookupResource("projects")
|
||||
if !ok {
|
||||
// Defensive: RegisterResources must run before installTools.
|
||||
|
|
@ -95,19 +109,19 @@ func installProjectsTools(srv *mcp.Server) {
|
|||
panic("mcp: projects resource not registered")
|
||||
}
|
||||
|
||||
if r.Ops&OpCreate != 0 {
|
||||
if r.Ops&OpCreate != 0 && tokenAuthorizes(token, r.Name, OpCreate) {
|
||||
addTool[*ProjectCreateInput](srv, r, OpCreate, "Create a new project")
|
||||
}
|
||||
if r.Ops&OpReadOne != 0 {
|
||||
if r.Ops&OpReadOne != 0 && tokenAuthorizes(token, r.Name, OpReadOne) {
|
||||
addTool[*ReadOneInput](srv, r, OpReadOne, "Fetch a single project by id")
|
||||
}
|
||||
if r.Ops&OpReadAll != 0 {
|
||||
if r.Ops&OpReadAll != 0 && tokenAuthorizes(token, r.Name, OpReadAll) {
|
||||
addTool[*ReadAllInput](srv, r, OpReadAll, "List the projects the caller has access to")
|
||||
}
|
||||
if r.Ops&OpUpdate != 0 {
|
||||
if r.Ops&OpUpdate != 0 && tokenAuthorizes(token, r.Name, OpUpdate) {
|
||||
addTool[*ProjectUpdateInput](srv, r, OpUpdate, "Update an existing project")
|
||||
}
|
||||
if r.Ops&OpDelete != 0 {
|
||||
if r.Ops&OpDelete != 0 && tokenAuthorizes(token, r.Name, OpDelete) {
|
||||
addTool[*DeleteInput](srv, r, OpDelete, "Delete a project by id")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
// 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 (
|
||||
"errors"
|
||||
"slices"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
)
|
||||
|
||||
// ErrScopeDenied is returned by the dispatcher when the token attached to
|
||||
// the call context does not have the (resource, op) scope required to invoke
|
||||
// the tool. The AddTool wrapper renders this as an IsError tool result so
|
||||
// the client sees a structured failure rather than a JSON-RPC protocol error.
|
||||
var ErrScopeDenied = errors.New("mcp: tool not authorized for this token")
|
||||
|
||||
// tokenAuthorizes returns true iff the token's APIPermissions map contains
|
||||
// op.Permission() under the given resource's scope group. This is the
|
||||
// (group, permission) lookup that gates both tools/list visibility and
|
||||
// tools/call invocation; it intentionally duplicates rather than shares
|
||||
// CanDoAPIRoute's logic because MCP doesn't have a path/method to match —
|
||||
// the registry already owns the (resource, op) → (group, permission) mapping.
|
||||
//
|
||||
// A nil token or nil APIPermissions returns false (slices.Contains on a nil
|
||||
// slice is also false, so the second case is naturally handled). Defensive
|
||||
// checks here keep the dispatcher's "fail closed" contract even if the entry
|
||||
// handler somehow forgets to attach a token.
|
||||
func tokenAuthorizes(token *models.APIToken, resourceName string, op Op) bool {
|
||||
if token == nil {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(token.APIPermissions[resourceName], op.Permission())
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
// 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 (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTokenAuthorizes_PermissionPresent(t *testing.T) {
|
||||
token := &models.APIToken{
|
||||
APIPermissions: models.APIPermissions{
|
||||
"projects": []string{"read_one", "read_all"},
|
||||
},
|
||||
}
|
||||
|
||||
r := &Resource{Name: "projects"}
|
||||
|
||||
assert.True(t, tokenAuthorizes(token, r.Name, OpReadOne))
|
||||
assert.True(t, tokenAuthorizes(token, r.Name, OpReadAll))
|
||||
}
|
||||
|
||||
func TestTokenAuthorizes_PermissionAbsent(t *testing.T) {
|
||||
token := &models.APIToken{
|
||||
APIPermissions: models.APIPermissions{
|
||||
"projects": []string{"read_one"},
|
||||
},
|
||||
}
|
||||
|
||||
r := &Resource{Name: "projects"}
|
||||
|
||||
assert.False(t, tokenAuthorizes(token, r.Name, OpCreate))
|
||||
assert.False(t, tokenAuthorizes(token, r.Name, OpUpdate))
|
||||
assert.False(t, tokenAuthorizes(token, r.Name, OpDelete))
|
||||
}
|
||||
|
||||
func TestTokenAuthorizes_NoGroup(t *testing.T) {
|
||||
token := &models.APIToken{
|
||||
APIPermissions: models.APIPermissions{
|
||||
"mcp": []string{"access"},
|
||||
},
|
||||
}
|
||||
|
||||
assert.False(t, tokenAuthorizes(token, "projects", OpReadOne))
|
||||
assert.False(t, tokenAuthorizes(token, "projects", OpCreate))
|
||||
}
|
||||
|
||||
func TestTokenAuthorizes_NilPermissionsMap(t *testing.T) {
|
||||
// A token with nil APIPermissions should never authorize anything.
|
||||
token := &models.APIToken{APIPermissions: nil}
|
||||
|
||||
assert.False(t, tokenAuthorizes(token, "projects", OpReadOne))
|
||||
}
|
||||
|
||||
func TestTokenAuthorizes_NilToken(t *testing.T) {
|
||||
// Defensive: a nil token (should never happen in practice because the
|
||||
// entry handler always sets one) must not panic.
|
||||
assert.False(t, tokenAuthorizes(nil, "projects", OpReadOne))
|
||||
}
|
||||
|
||||
func TestTokenAuthorizes_FullScopes(t *testing.T) {
|
||||
token := &models.APIToken{
|
||||
APIPermissions: models.APIPermissions{
|
||||
"projects": []string{"create", "read_one", "read_all", "update", "delete"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, op := range AllOps() {
|
||||
assert.Truef(t, tokenAuthorizes(token, "projects", op), "op %s should be authorized", op.ToolSuffix())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchScopeDenied(t *testing.T) {
|
||||
resetRegistry(t)
|
||||
installStubCRUD(t)
|
||||
tracker := &stubTracker{}
|
||||
require.NoError(t, Register(Resource{
|
||||
Name: "stubs",
|
||||
EmptyStruct: tracker.empty,
|
||||
Ops: OpCreate | OpReadOne,
|
||||
Inputs: map[Op]any{
|
||||
OpCreate: &stubInput{},
|
||||
OpReadOne: &stubInput{},
|
||||
},
|
||||
}))
|
||||
|
||||
// Token has read_one but not create.
|
||||
token := &models.APIToken{
|
||||
APIPermissions: models.APIPermissions{
|
||||
"stubs": []string{"read_one"},
|
||||
},
|
||||
}
|
||||
ctx := WithToken(newAuthedCtx(t), token)
|
||||
|
||||
_, err := Dispatch(ctx, "stubs_create", json.RawMessage(`{"title":"x"}`))
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrScopeDenied)
|
||||
// The denied call must not have invoked Do*.
|
||||
assert.Nil(t, tracker.last, "Do* must not run for a denied scope")
|
||||
}
|
||||
|
||||
func TestDispatchScopeDenied_NoTokenInContext(t *testing.T) {
|
||||
// Without a token in context, the scope check has nothing to authorize
|
||||
// against. The dispatcher should treat a missing token as denied
|
||||
// (defensive — the entry handler always sets one in production).
|
||||
resetRegistry(t)
|
||||
installStubCRUD(t)
|
||||
tracker := &stubTracker{}
|
||||
require.NoError(t, Register(Resource{
|
||||
Name: "stubs",
|
||||
EmptyStruct: tracker.empty,
|
||||
Ops: OpReadOne,
|
||||
Inputs: map[Op]any{OpReadOne: &stubInput{}},
|
||||
}))
|
||||
|
||||
// User in context but no token — the scope check must still deny.
|
||||
u := &user.User{ID: 42}
|
||||
ctx := WithUser(t.Context(), u)
|
||||
_, err := Dispatch(ctx, "stubs_read_one", json.RawMessage(`{"id":1}`))
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrScopeDenied)
|
||||
assert.Nil(t, tracker.last)
|
||||
}
|
||||
|
||||
func TestDispatchTypedScopeDenied(t *testing.T) {
|
||||
// DispatchTyped is the path AddTool handlers take; the same scope check
|
||||
// must apply there.
|
||||
resetRegistry(t)
|
||||
installStubCRUD(t)
|
||||
tracker := &stubTracker{}
|
||||
require.NoError(t, Register(Resource{
|
||||
Name: "stubs",
|
||||
EmptyStruct: tracker.empty,
|
||||
Ops: OpDelete,
|
||||
Inputs: map[Op]any{OpDelete: &stubInput{}},
|
||||
}))
|
||||
|
||||
token := &models.APIToken{
|
||||
APIPermissions: models.APIPermissions{
|
||||
"stubs": []string{"read_one"}, // delete not allowed
|
||||
},
|
||||
}
|
||||
ctx := WithToken(newAuthedCtx(t), token)
|
||||
|
||||
_, err := DispatchTyped(ctx, "stubs_delete", &stubInput{ID: 1})
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrScopeDenied)
|
||||
assert.Nil(t, tracker.last)
|
||||
}
|
||||
|
||||
func TestDispatchScopeAllowed(t *testing.T) {
|
||||
// Positive control: with the right scope, dispatch reaches the stub.
|
||||
resetRegistry(t)
|
||||
installStubCRUD(t)
|
||||
tracker := &stubTracker{}
|
||||
require.NoError(t, Register(Resource{
|
||||
Name: "stubs",
|
||||
EmptyStruct: tracker.empty,
|
||||
Ops: OpReadOne,
|
||||
Inputs: map[Op]any{OpReadOne: &stubInput{}},
|
||||
}))
|
||||
|
||||
token := &models.APIToken{
|
||||
APIPermissions: models.APIPermissions{
|
||||
"stubs": []string{"read_one"},
|
||||
},
|
||||
}
|
||||
ctx := WithToken(newAuthedCtx(t), token)
|
||||
|
||||
_, err := Dispatch(ctx, "stubs_read_one", json.RawMessage(`{"id":1}`))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, tracker.last)
|
||||
assert.Equal(t, "ReadOne", tracker.last.called)
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
// 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 (
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Token 10 has {mcp:access, projects:[read_one, read_all]} — a partial scope
|
||||
// for the scope-filtered tools/list and tools/call tests.
|
||||
const mcpMixedScopeToken = "tk_mcp_mixed_scope_token_test_00mcpmixed02"
|
||||
|
||||
// toolNamesFromList extracts the "name" field from every tool in a tools/list
|
||||
// result payload.
|
||||
func toolNamesFromList(t *testing.T, resp map[string]any) map[string]bool {
|
||||
t.Helper()
|
||||
result, ok := resp["result"].(map[string]any)
|
||||
require.True(t, ok, "response missing result: %v", resp)
|
||||
tools, ok := result["tools"].([]any)
|
||||
require.True(t, ok, "response missing tools array: %v", result)
|
||||
names := make(map[string]bool, len(tools))
|
||||
for _, raw := range tools {
|
||||
tool, isMap := raw.(map[string]any)
|
||||
require.Truef(t, isMap, "tool entry not an object: %v", raw)
|
||||
name, _ := tool["name"].(string)
|
||||
names[name] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestMCP_Scopes_ToolsListMixed(t *testing.T) {
|
||||
// Token 10: projects:[read_one, read_all] — should see exactly those two
|
||||
// project tools and no others.
|
||||
c := newMCPClient(t, mcpMixedScopeToken)
|
||||
resp := c.rpc("tools/list", map[string]any{})
|
||||
names := toolNamesFromList(t, resp)
|
||||
|
||||
assert.Truef(t, names["projects_read_one"], "expected projects_read_one in: %v", names)
|
||||
assert.Truef(t, names["projects_read_all"], "expected projects_read_all in: %v", names)
|
||||
|
||||
assert.Falsef(t, names["projects_create"], "projects_create must be filtered out: %v", names)
|
||||
assert.Falsef(t, names["projects_update"], "projects_update must be filtered out: %v", names)
|
||||
assert.Falsef(t, names["projects_delete"], "projects_delete must be filtered out: %v", names)
|
||||
}
|
||||
|
||||
func TestMCP_Scopes_ToolsListMcpOnly(t *testing.T) {
|
||||
// Token 9: only {mcp:access} — no project scopes, so no project tools
|
||||
// must show in tools/list.
|
||||
c := newMCPClient(t, mcpOnlyToken)
|
||||
resp := c.rpc("tools/list", map[string]any{})
|
||||
names := toolNamesFromList(t, resp)
|
||||
|
||||
for _, want := range []string{
|
||||
"projects_create",
|
||||
"projects_read_one",
|
||||
"projects_read_all",
|
||||
"projects_update",
|
||||
"projects_delete",
|
||||
} {
|
||||
assert.Falsef(t, names[want], "%s must be filtered out for an mcp-only token: %v", want, names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCP_Scopes_ToolsListFullScopes(t *testing.T) {
|
||||
// Token 11: mcp:access + projects:* — should see all five project tools.
|
||||
c := newMCPClient(t, mcpFullProjectsToken)
|
||||
resp := c.rpc("tools/list", map[string]any{})
|
||||
names := toolNamesFromList(t, resp)
|
||||
|
||||
for _, want := range []string{
|
||||
"projects_create",
|
||||
"projects_read_one",
|
||||
"projects_read_all",
|
||||
"projects_update",
|
||||
"projects_delete",
|
||||
} {
|
||||
assert.Truef(t, names[want], "expected %s in: %v", want, names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCP_Scopes_CallCreateForbidden(t *testing.T) {
|
||||
// Token 10 lacks projects:create. Calling projects_create must come back
|
||||
// as an error response without writing to the database. The SDK may
|
||||
// return either a JSON-RPC protocol error (tool not found, because the
|
||||
// tool wasn't registered for this session's server) or a tool result
|
||||
// with isError=true (if the dispatcher's defensive scope check ran).
|
||||
// Both are valid — what matters is that no DB write happened.
|
||||
projectsBefore := countProjects(t)
|
||||
|
||||
c := newMCPClient(t, mcpMixedScopeToken)
|
||||
resp := c.rpc("tools/call", map[string]any{
|
||||
"name": "projects_create",
|
||||
"arguments": map[string]any{"title": "should not be created"},
|
||||
})
|
||||
|
||||
// Either a JSON-RPC error or a tool result with isError=true is
|
||||
// acceptable; what matters is no DB write.
|
||||
if _, hasErr := resp["error"]; !hasErr {
|
||||
result, ok := resp["result"].(map[string]any)
|
||||
require.Truef(t, ok, "missing result: %v", resp)
|
||||
isErr, _ := result["isError"].(bool)
|
||||
assert.Truef(t, isErr, "expected isError for forbidden create: %v", result)
|
||||
}
|
||||
|
||||
projectsAfter := countProjects(t)
|
||||
assert.Equal(t, projectsBefore, projectsAfter, "no project should be created when scope is denied")
|
||||
}
|
||||
|
||||
func TestMCP_Scopes_CallNonexistentTool(t *testing.T) {
|
||||
// An unknown tool name must result in an error tool call result (or a
|
||||
// JSON-RPC error from the SDK saying "tool not found"). Either way, the
|
||||
// caller sees a failure, not a JSON-parse 500.
|
||||
c := newMCPClient(t, mcpFullProjectsToken)
|
||||
resp := c.rpc("tools/call", map[string]any{
|
||||
"name": "nonexistent_tool",
|
||||
"arguments": map[string]any{},
|
||||
})
|
||||
|
||||
if _, hasErr := resp["error"]; hasErr {
|
||||
return // SDK returned a JSON-RPC error — acceptable.
|
||||
}
|
||||
result, ok := resp["result"].(map[string]any)
|
||||
require.Truef(t, ok, "missing both error and result: %v", resp)
|
||||
isErr, _ := result["isError"].(bool)
|
||||
assert.Truef(t, isErr, "expected isError for nonexistent tool: %v", result)
|
||||
}
|
||||
|
||||
// countProjects returns the number of rows in the projects table. Used to
|
||||
// verify that a denied-scope tool call did not mutate the database.
|
||||
func countProjects(t *testing.T) int64 {
|
||||
t.Helper()
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
n, err := s.Count(&models.Project{})
|
||||
require.NoError(t, err)
|
||||
return n
|
||||
}
|
||||
|
|
@ -137,10 +137,10 @@ func TestMCP_InitializeWithMCPToken(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestMCP_ToolsListReturnsRegisteredResources(t *testing.T) {
|
||||
// Task 5 wires the `projects` resource into the registry, so the live
|
||||
// SDK server should now expose its five tools to any token with
|
||||
// mcp:access. Task 6 will add per-token scope filtering and is when
|
||||
// the mcp-only token starts seeing a narrower list.
|
||||
// Per Task 6, an mcp-only token (no projects scope) sees zero project
|
||||
// tools in tools/list — the per-session tool registration filters by
|
||||
// the requesting token's (group, permission) scopes. Tools/list visibility
|
||||
// for tokens with project scopes is covered in mcp_scopes_test.go.
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -174,24 +174,18 @@ func TestMCP_ToolsListReturnsRegisteredResources(t *testing.T) {
|
|||
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())
|
||||
require.Len(t, tools, 5, "expected exactly the 5 project tools, got: %v", tools)
|
||||
|
||||
names := make(map[string]bool, len(tools))
|
||||
// No project tools because the token has no projects:* scopes.
|
||||
projectToolCount := 0
|
||||
for _, raw := range tools {
|
||||
tool, isMap := raw.(map[string]any)
|
||||
require.True(t, isMap, "tool entry should be an object: %v", raw)
|
||||
name, _ := tool["name"].(string)
|
||||
names[name] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
"projects_create",
|
||||
"projects_read_one",
|
||||
"projects_read_all",
|
||||
"projects_update",
|
||||
"projects_delete",
|
||||
} {
|
||||
assert.Truef(t, names[want], "tools/list missing %s; got %v", want, names)
|
||||
if strings.HasPrefix(name, "projects_") {
|
||||
projectToolCount++
|
||||
}
|
||||
}
|
||||
assert.Zero(t, projectToolCount, "mcp-only token must see zero project tools, got %v", tools)
|
||||
}
|
||||
|
||||
func TestMCP_SessionRoundTrip(t *testing.T) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue