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:
kolaente 2026-05-26 23:54:02 +02:00
parent e423167ce1
commit 8fbc6b62a2
8 changed files with 508 additions and 56 deletions

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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:
//

View File

@ -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")
}
}

48
pkg/modules/mcp/scope.go Normal file
View File

@ -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())
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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) {