Skip to content
Merged
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
53 changes: 53 additions & 0 deletions backend/__tests__/unit/routes/grants.mint.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,59 @@ const withCatalog = async (entries, run) => {
};

describe('POST /api/grants on a hosted-mcp connection', () => {
describe('hidden-entry mint policy', () => {
let previousHidden;
let previousAllowlist;
beforeEach(() => {
previousHidden = process.env.HOSTED_MCP_HIDDEN_ENTRIES;
previousAllowlist = process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST;
process.env.HOSTED_MCP_HIDDEN_ENTRIES = JSON.stringify(['linear']);
delete process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST;
Integration.findById.mockResolvedValue(hostedRow());
});
afterEach(() => {
if (previousHidden === undefined) delete process.env.HOSTED_MCP_HIDDEN_ENTRIES;
else process.env.HOSTED_MCP_HIDDEN_ENTRIES = previousHidden;
if (previousAllowlist === undefined) delete process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST;
else process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST = previousAllowlist;
});

test.each([
['no exception', undefined],
['another user', JSON.stringify({ linear: [SEAT] })],
['invalid JSON', '{broken'],
['invalid user in map', JSON.stringify({ linear: [OWNER, 'invalid'] })],
['unknown key in map', JSON.stringify({ linear: [OWNER], typo: [OWNER] })],
])('a previously connected hidden row refuses mint with %s', async (_, allowlist) => {
if (allowlist !== undefined) process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST = allowlist;
const res = await withCatalog([ENTRY], () => hostedMint({ userId: SEAT, ownerId: SEAT }));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'unknown_entry' });
expect(await RoomGrant.countDocuments({})).toBe(0);
});

test.each([undefined, '', ' '])('a hosted row without an entry ID refuses before mint: %p', async (entryId) => {
Integration.findById.mockResolvedValue(hostedRow({ entryId }));
const res = await withCatalog([ENTRY], () => hostedMint());
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'unknown_entry' });
expect(await RoomGrant.countDocuments({})).toBe(0);
});

test('the exact authenticated allowlisted owner may mint', async () => {
process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST = JSON.stringify({ linear: [OWNER] });
const res = await withCatalog([ENTRY], () => hostedMint());
expect(res.status).toBe(201);
expect(await RoomGrant.countDocuments({ grantId: res.body.grantId })).toBe(1);
});

test('hiding another entry leaves this connected row grantable', async () => {
process.env.HOSTED_MCP_HIDDEN_ENTRIES = JSON.stringify(['acme']);
const res = await withCatalog([ENTRY], () => hostedMint());
expect(res.status).toBe(201);
});
});

test('a connected hosted row is admitted; what stops the mint is the catalogue, not its type', async () => {
Integration.findById.mockResolvedValue(hostedRow());
// The catalogue is emptied for this arm on purpose: since step 7 the shipped
Expand Down
7 changes: 7 additions & 0 deletions backend/routes/grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { cloudflareIpRateLimitKeyGenerator } from '../middleware/ipRateLimit';
import { createHash } from 'crypto';
import { Types } from 'mongoose';
import Integration from '../models/Integration';
import { isHostedMcpEntryHidden } from '../integrations/hostedMcp/visibility';
import Pod from '../models/Pod';
import RoomGrant from '../models/RoomGrant';
import ToolCall from '../models/ToolCall';
Expand Down Expand Up @@ -318,6 +319,12 @@ router.post('/', grantRateLimit, auth, async (req: AuthenticatedRequest, res: ex
: String(connection.installationId || connection.config?.installationId || '').trim();
const isConnected = connection.status === 'connected' && !connection.revokedAt;
if (hosted) {
// Existing connections do not bypass instance visibility policy. Use the
// authenticated owner, matching the catalogue and connection-start route.
const entryId = String(connection.config?.entryId || '').trim();
if (!entryId || isHostedMcpEntryHidden(entryId, process.env, userId)) {
return res.status(404).json({ error: 'unknown_entry' });
}
// A pending row holds nothing secret and must never be grantable, and a
// row with no credential has nothing for the broker to decrypt, so both
// are refused here rather than at first call.
Expand Down
12 changes: 7 additions & 5 deletions docs/runbooks/hosted-mcp-hidden-entry-walks.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Walk a hidden hosted MCP entry

Instance policy `hostedMcp.hiddenEntries` hides entries from the Tools catalogue
and refuses new connect/reconnect starts. To offer one hidden entry to a named
and refuses new connect/reconnect starts and new root grants for existing
connections. To offer one hidden entry to a named
walk user, configure an exception alongside the hidden list:

```yaml
Expand All @@ -18,15 +19,16 @@ an empty map. A deployment is required to change the environment.

The map is entry ID to an array of exact, lowercase, 24-character Mongo User IDs.
Names, email addresses, roles, query parameters and request body fields do not
grant an exception. Catalogue reads and authenticated connect starts use the
server-authenticated user's ID. A missing user or malformed map grants no
grant an exception. Catalogue reads, authenticated connect starts and root-grant
mints use the server-authenticated user's ID. A missing user or malformed map grants no
exceptions; one invalid map member invalidates all exceptions. An exception key must also
name an entry in the configured hidden list; a stale or mistyped key invalidates
the entire exception map. Other users
continue to see no offering and receive `404 unknown_entry` on start.
continue to see no offering and receive `404 unknown_entry` on start or root-grant
mint, including when they own a connected row created before the entry was hidden.

An exception does not configure a connector client, grant access to another
person's connections, or change tool grants. An unconfigured client remains
person's connections, or revoke existing tool grants. An unconfigured client remains
unavailable. Existing hidden connections retain their removal surface and pinned
broker tools as before. Public client metadata and callbacks are not new-offering
checks: the former is fetched by authorization servers, and the latter already
Expand Down
Loading