Skip to content

Show restricted members only the MCP servers their roles reach - #1667

Merged
helpcodeai merged 2 commits into
mainfrom
feat/member-visibility-servers
Nov 13, 2026
Merged

helpcodeai merged 2 commits into
mainfrom
feat/member-visibility-servers

Conversation

@helpcodeai

Copy link
Copy Markdown
Contributor

Summary

Fourth step of "member visibility" (after #1664, #1665 and #1666). In a workspace set to restricted mode, a member who is not an admin now sees on the MCP server routes only the servers their AI client can use: the servers they own, and the servers that hold at least one connector they can see. Inside each server they see only those connectors. The server picker and connection grants follow the same rule, per workspace.

It also fixes a permission bug: a VIEWER could create an MCP server. That is now refused with 403 in every mode, like every other server write.

Admins and open workspaces take today's code path. No workspace has the setting yet and the switch in the UI comes last, so nothing changes on deploy apart from the VIEWER rule.

Changes

Every read handler has the same shape: scopeFor(req.user), and when the scope is not restricted, today's code unchanged.

MCP servers controller (api/mcp-servers):

  • GET /: visible servers only, filtered in the Prisma where (new McpServersService.findAllVisibleByOrg, workspace filter AND serverWhere), so limit and offset page over visible servers. Connector names and _count.connectors count only visible connectors. usage stays: two numbers, no identity.
  • GET /:id: 404 for a hidden server, with exactly the body an unknown id gets (MCP server not found). Connectors and their count filtered (new findVisibleById, one query).
  • GET /:id/activity: same 404 for a hidden server, then today's numbers.
  • These are all the GET routes of the controller.
  • POST / (D10): 403 Viewers cannot modify MCP servers for a VIEWER, in every mode, before the plan check. Same message as the other server writes. EDITOR and ADMIN unchanged. The default server a member gets at sign-up or invite comes from McpServersService.createDefaultForUser, not this route, so it is not affected. The integration spec creates the fixtures' default servers through that method, for a VIEWER too.

MCP connections (api/mcp-connections) and the OAuth consent page:

  • GET /targets: per workspace, by that workspace's own mode and the caller's role there (read from the membership row). A user can be an admin in one workspace and a member in another. In a restricted workspace where the caller is not an admin, only visible active servers are listed, each counting only visible connectors.
  • PUT /:clientId with serverIds: a server the caller cannot see is dropped like an unknown id. If nothing is left the answer is today's { ok: false, reason: "nothing-granted" }, the same as for an unknown id.
  • Both live in McpConnectionGrantService (listSelectableTargets, grantServers). The consent page of the shared /mcp endpoint uses the same two methods (login.controller.ts, maybeAskWhichServers and select-servers), so one change covers the API and the page: a restricted member is not shown hidden servers, and a tampered form that names one grants nothing for it.
  • GET / (the caller's own grants) is unchanged.
  • resolve, which the MCP endpoint calls per request, is unchanged.

Wiring:

  • McpServersModule imports MemberVisibilityModule, never SettingsModule. The File Search worker does not reach McpServersModule.
  • The visibility service is a required Nest dependency of McpServersController and McpConnectionGrantService (no @Optional), so the app does not boot without it. It is the last constructor parameter; existing unit specs that build these classes by hand without it take today's path. Every existing spec passes untouched.
  • One OpenAPI sentence on each changed operation. The POST /api/mcp-servers description says a VIEWER gets 403. The generated CLI operations do not change (--check is clean).

Type

  • Bug fix
  • New feature
  • Documentation
  • Refactoring
  • Performance improvement
  • Other (describe below)

Testing

  • Added/updated unit tests
  • Tested manually (describe steps below)
  • No tests needed (explain why)

New specs:

  • mcp-servers.member-visibility.spec.ts (368 tests): route coverage, so the suite fails when a GET route of the MCP servers or MCP connections controller has no declared rule. Then the matrix: ADMIN / EDITOR / VIEWER x open / restricted x (no whitelist, role with tools, role without tools), over servers the caller owns, other members' servers, and other members' default servers (D9: an empty one stays hidden, one holding a visible connector is visible). 404 bodies are compared with the body an unknown id gets. Pagination over the filtered list, targets across three workspaces (restricted as a member, restricted as an admin, open), grants, D10 for every role and mode, and fail closed. I checked that removing the activity check or the grant filter makes tests fail.
  • mcp-servers.member-visibility.integration.spec.ts (38 tests, real PostgreSQL through AUDIT_TEST_DATABASE_URL, as in CI): the same matrix over HTTP with the real services, RolesService and org_settings rows, including a second workspace where one user is an admin and another a member, the stored grant rows, D10 with real status codes (VIEWER 403, EDITOR and ADMIN 201), pages of a filtered list, and a 500 when the role lookup throws.
  • mcp-servers.module.spec.ts (2 tests): the module provides MemberVisibilityService to the controller and the grant service.

Commands, in the worktree on origin/main 45e0fdf (packages/backend unless noted). The real-DB runs used a database created for this PR on the local 5433 server, migrated with npx prisma migrate deploy and dropped afterwards.

  • Before adding the new specs: npx jest src/mcp-servers src/auth/server-picker.spec.ts src/auth/login.controller --maxWorkers=4: 14 suites, 204 tests passed (existing specs, unchanged).
  • npx jest src/mcp-servers src/auth src/settings src/roles src/connectors src/mcp-server/mcp-endpoint --maxWorkers=4 with AUDIT_TEST_DATABASE_URL set: 181 suites passed, 2 skipped, 1 failed; 4438 tests passed, 11 skipped, 14 failed. All 14 failures are auth/jwt-or-management-key.guard.spec.ts, which needs DATABASE_URL pointing at a migrated database and fails in its setup without it. With DATABASE_URL set to the test database: 14 passed. This PR does not touch that file.
  • npx jest src/common/openapi.spec.ts src/auth/jwt-or-management-key.guard.boot.spec.ts --maxWorkers=4: 6 passed.
  • npx eslint <changed files>: 0 errors, 0 warnings.
  • npx tsc --noEmit -p tsconfig.json: pass.
  • Repo root: node scripts/generate-cli-operations.mjs --check: up to date (213 operations). node scripts/generate-llms-txt.mjs --check: exit 0.
  • Boot: npx nest build, then node dist/src/main.js against the test database: "Nest application successfully started", GET /api/mcp-servers without a token answers 401. Process stopped afterwards.

Cloud and self-hosted: identical, off by default. The VIEWER rule on POST /api/mcp-servers applies in both from the deploy on. On Cloud on 2026-11-13, the six servers viewers own are all the default server created at invite time, not through this route.

Notes for review, not changed here:

  • The server write routes (PUT /:id, DELETE /:id, PUT /:id/connectors) already refuse a VIEWER (403). For a restricted member they answer 403 on a hidden server and 404 on an unknown id, so a write can tell "hidden" from "unknown". Only D10 was approved for writes, so they keep today's rule.
  • PUT /:id/connectors checks only that each connector id is in the workspace. A restricted EDITOR who owns a server can therefore name a connector they cannot see (the MCP endpoint still filters its tools by role).
  • With an RFC 8707 resource on the consent flow, a hidden server is now dropped like an unknown id, so no grant is written for it (today's behaviour for an unknown id).

Checklist

  • My code follows the existing code style
  • I have tested my changes locally
  • I have updated documentation (if applicable) (docs come with the last PR of the series)
  • All existing tests pass (npm test) (see the one environment-dependent suite above)
  • This PR has a descriptive title

Related Issues

Relates to #1664, #1665, #1666.

In a workspace with restricted member visibility, a member who is not an
admin now sees, in GET /api/mcp-servers, GET /api/mcp-servers/:id and
/:id/activity, only the servers they own or that hold a connector they can
see, with only those connectors named and counted. A hidden server answers
the same 404 as an unknown id. The server picker (GET
/api/mcp-connections/targets and the OAuth consent page) applies each
workspace's own mode and the caller's role there, and a grant drops a
hidden server like an unknown id.

A VIEWER can no longer create an MCP server (403), in every mode, like
every other server write. The default server created at sign-up and
invite does not go through this route.

Admins and open workspaces keep today's code path.
@helpcodeai
helpcodeai merged commit 16fb4f0 into main Nov 13, 2026
16 checks passed
@helpcodeai
helpcodeai deleted the feat/member-visibility-servers branch November 13, 2026 17:56
@github-actions github-actions Bot locked and limited conversation to collaborators Nov 13, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant