From 317cd55d11291513ae386e2f03ff68ccd1a51aa3 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:32:27 -0700 Subject: [PATCH 1/3] feat: scope hidden hosted MCP walks to configured users Co-authored-by: rivet Agent-Model: codex/gpt-6.1-sol/medium --- backend/integrations/hostedMcp/visibility.ts | 35 ++++++++++++++++-- backend/routes/hostedMcpConnect.ts | 2 +- .../installable/installableCatalogService.ts | 5 ++- .../runbooks/hosted-mcp-hidden-entry-walks.md | 36 +++++++++++++++++++ .../templates/core/backend-deployment.yaml | 2 ++ .../templates/ops/ops-runner-deployment.yaml | 2 ++ k8s/helm/commonly/values.yaml | 3 ++ 7 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 docs/runbooks/hosted-mcp-hidden-entry-walks.md diff --git a/backend/integrations/hostedMcp/visibility.ts b/backend/integrations/hostedMcp/visibility.ts index e7c16c5a71..4743cee871 100644 --- a/backend/integrations/hostedMcp/visibility.ts +++ b/backend/integrations/hostedMcp/visibility.ts @@ -9,6 +9,35 @@ export const hiddenHostedMcpEntryIds = (env: NodeJS.ProcessEnv = process.env): S return new Set(ids); }; -export const isHostedMcpEntryHidden = (entryId: string, env: NodeJS.ProcessEnv = process.env): boolean => ( - hiddenHostedMcpEntryIds(env).has(entryId) -); +/** Exceptions use authenticated User IDs, never client-supplied names or query fields. + * A malformed setting grants no exceptions, including otherwise valid entries. + */ +const allowedHiddenEntryUsers = (env: NodeJS.ProcessEnv): Record => { + const raw = env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST; + if (!raw) return {}; + try { + const value: unknown = JSON.parse(raw); + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const entries = Object.entries(value); + if (!entries.every(([entry, users]) => ( + /^[a-z0-9][a-z0-9-]*$/.test(entry) + && Array.isArray(users) + && users.every((id: unknown) => typeof id === 'string' && /^[a-f0-9]{24}$/.test(id)) + ))) return {}; + return value as Record; + } catch { + return {}; + } +}; + +export const isHostedMcpEntryHidden = ( + entryId: string, + env: NodeJS.ProcessEnv = process.env, + userId?: string, +): boolean => { + if (!hiddenHostedMcpEntryIds(env).has(entryId)) return false; + if (typeof userId !== 'string' || !/^[a-f0-9]{24}$/.test(userId)) return true; + const allowlist = allowedHiddenEntryUsers(env); + return !Object.prototype.hasOwnProperty.call(allowlist, entryId) + || !allowlist[entryId].includes(userId); +}; diff --git a/backend/routes/hostedMcpConnect.ts b/backend/routes/hostedMcpConnect.ts index 5aebf8db63..99eef77db8 100644 --- a/backend/routes/hostedMcpConnect.ts +++ b/backend/routes/hostedMcpConnect.ts @@ -248,7 +248,7 @@ const startConnect = async (req: AuthedRequest, res: Response) => { if (!entry) { return res.status(404).json({ error: 'unknown_entry' }); } - if (isHostedMcpEntryHidden(entry.id)) return res.status(404).json({ error: 'unknown_entry' }); + if (isHostedMcpEntryHidden(entry.id, process.env, userId)) return res.status(404).json({ error: 'unknown_entry' }); let client: { clientId: string; clientSecret?: string } | null = null; if (entry.client !== 'dcr') { diff --git a/backend/services/installable/installableCatalogService.ts b/backend/services/installable/installableCatalogService.ts index 3bf18bea51..660d64ffd4 100644 --- a/backend/services/installable/installableCatalogService.ts +++ b/backend/services/installable/installableCatalogService.ts @@ -12,7 +12,7 @@ const { manifests } = require('../../integrations/manifests'); const { mcpComponentOf, projectTools, toolInstallableMetas } = require('./toolInstallables'); // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require const { HOSTED_MCP_ENTRIES, findHostedMcpEntry } = require('../../integrations/hostedMcp/entries'); -const { hiddenHostedMcpEntryIds } = require('../../integrations/hostedMcp/visibility'); +const { isHostedMcpEntryHidden } = require('../../integrations/hostedMcp/visibility'); const { isHostedMcpClientConfigured } = require('../hostedMcpIntakeService'); type ProviderReadiness = { available: boolean; reason?: 'not_configured' }; @@ -133,12 +133,11 @@ const toolEntriesFor = async (userId: string): Promise<{ entries: unknown[]; hid 'components.type': 'mcp-server', }).lean() as any[]).filter((row) => mcpComponentOf(row) && metas[row.installableId]); if (!allRows.length) return { entries: [], hiddenConnections: [] }; - const hiddenIds = hiddenHostedMcpEntryIds(); const isHidden = (row: any): boolean => { const meta = metas[row.installableId]; if (meta.connectionType !== 'hosted-mcp') return false; const entry = findHostedMcpEntry(HOSTED_MCP_ENTRIES, meta.entryId || ''); - return hiddenIds.has(meta.entryId) || !entry || !isHostedMcpClientConfigured(entry); + return isHostedMcpEntryHidden(meta.entryId, process.env, userId) || !entry || !isHostedMcpClientConfigured(entry); }; const rows = allRows.filter((row) => !isHidden(row)); const hiddenRows = allRows.filter(isHidden); diff --git a/docs/runbooks/hosted-mcp-hidden-entry-walks.md b/docs/runbooks/hosted-mcp-hidden-entry-walks.md new file mode 100644 index 0000000000..05af229d6b --- /dev/null +++ b/docs/runbooks/hosted-mcp-hidden-entry-walks.md @@ -0,0 +1,36 @@ +# 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 +walk user, configure an exception alongside the hidden list: + +```yaml +hostedMcp: + hiddenEntries: [sentry] + hiddenEntryUserAllowlist: + sentry: + - "0123456789abcdef01234567" # example only; replace with the walk user's User._id +``` + +Set real IDs only in operator-local deployment values. Both backend and ops-runner +receive the JSON map as `HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST`. The default is +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 +exceptions; one invalid map member invalidates all exceptions. Other users +continue to see no offering and receive `404 unknown_entry` on start. + +An exception does not configure a connector client, grant access to another +person's connections, or change 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 +validates an owner-bound pending OAuth state. + +Before enabling the walk, confirm the selected User ID with the operator. After +the walk, remove that ID and deploy again. Record the revision and verify the +named user can no longer start a new connection, while existing connections can +still be removed. Do not widen the global hidden list just to run one walk. diff --git a/k8s/helm/commonly/templates/core/backend-deployment.yaml b/k8s/helm/commonly/templates/core/backend-deployment.yaml index ee93df84bf..8f4fbdd1db 100644 --- a/k8s/helm/commonly/templates/core/backend-deployment.yaml +++ b/k8s/helm/commonly/templates/core/backend-deployment.yaml @@ -68,6 +68,8 @@ spec: value: {{ .Values.backend.service.port | quote }} - name: HOSTED_MCP_HIDDEN_ENTRIES value: {{ .Values.hostedMcp.hiddenEntries | toJson | quote }} + - name: HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST + value: {{ .Values.hostedMcp.hiddenEntryUserAllowlist | default dict | toJson | quote }} {{- if .Values.errorTracking.enabled }} - name: SENTRY_DSN valueFrom: diff --git a/k8s/helm/commonly/templates/ops/ops-runner-deployment.yaml b/k8s/helm/commonly/templates/ops/ops-runner-deployment.yaml index 38ae12d09e..e5310b0a72 100644 --- a/k8s/helm/commonly/templates/ops/ops-runner-deployment.yaml +++ b/k8s/helm/commonly/templates/ops/ops-runner-deployment.yaml @@ -48,6 +48,8 @@ spec: value: {{ .Values.backend.service.port | quote }} - name: HOSTED_MCP_HIDDEN_ENTRIES value: {{ .Values.hostedMcp.hiddenEntries | toJson | quote }} + - name: HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST + value: {{ .Values.hostedMcp.hiddenEntryUserAllowlist | default dict | toJson | quote }} {{- if .Values.errorTracking.enabled }} - name: SENTRY_DSN valueFrom: diff --git a/k8s/helm/commonly/values.yaml b/k8s/helm/commonly/values.yaml index 5ce7fbe466..2123e898ba 100644 --- a/k8s/helm/commonly/values.yaml +++ b/k8s/helm/commonly/values.yaml @@ -73,6 +73,9 @@ hostedMcp: # Entry IDs omitted from Tools offerings. Existing connections remain listed # for removal; grants and revocation continue to use the pinned entry. hiddenEntries: [] + # Entry ID -> exact authenticated Mongo User IDs permitted to walk hidden entries. + # Empty by default; operator-local values select named walk users. + hiddenEntryUserAllowlist: {} googleCalendar: enabled: false From bd9b770e04e856000871880abe8a63eba9aafe54 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:42:28 -0700 Subject: [PATCH 2/3] fix: refuse unconfigured hidden MCP exception keys Co-authored-by: rivet Agent-Model: codex/gpt-6.1-sol/medium --- backend/integrations/hostedMcp/visibility.ts | 4 +++- docs/runbooks/hosted-mcp-hidden-entry-walks.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/integrations/hostedMcp/visibility.ts b/backend/integrations/hostedMcp/visibility.ts index 4743cee871..a5af344e7d 100644 --- a/backend/integrations/hostedMcp/visibility.ts +++ b/backend/integrations/hostedMcp/visibility.ts @@ -19,8 +19,10 @@ const allowedHiddenEntryUsers = (env: NodeJS.ProcessEnv): Record ( - /^[a-z0-9][a-z0-9-]*$/.test(entry) + hiddenEntryIds.has(entry) + && /^[a-z0-9][a-z0-9-]*$/.test(entry) && Array.isArray(users) && users.every((id: unknown) => typeof id === 'string' && /^[a-f0-9]{24}$/.test(id)) ))) return {}; diff --git a/docs/runbooks/hosted-mcp-hidden-entry-walks.md b/docs/runbooks/hosted-mcp-hidden-entry-walks.md index 05af229d6b..6f55874eaf 100644 --- a/docs/runbooks/hosted-mcp-hidden-entry-walks.md +++ b/docs/runbooks/hosted-mcp-hidden-entry-walks.md @@ -20,7 +20,9 @@ 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 -exceptions; one invalid map member invalidates all exceptions. Other users +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. An exception does not configure a connector client, grant access to another From f9539ade8b8c93e7975c82b1341691acac91fb67 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:45:14 -0700 Subject: [PATCH 3/3] test: cover hidden MCP allowlist policy and consumers Co-authored-by: rivet Agent-Model: codex/gpt-6.1-sol/medium --- .../unit/routes/hostedMcpConnect.test.js | 33 +++++++++++++++ .../unit/services/hostedMcpVisibility.test.js | 42 +++++++++++++++++++ .../installableCatalogService.test.js | 27 ++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 backend/__tests__/unit/services/hostedMcpVisibility.test.js diff --git a/backend/__tests__/unit/routes/hostedMcpConnect.test.js b/backend/__tests__/unit/routes/hostedMcpConnect.test.js index 5f74a2c038..ddeb8cc1af 100644 --- a/backend/__tests__/unit/routes/hostedMcpConnect.test.js +++ b/backend/__tests__/unit/routes/hostedMcpConnect.test.js @@ -162,6 +162,39 @@ describe('hosted-mcp connect: start', () => { else process.env.HOSTED_MCP_HIDDEN_ENTRIES = prior; } }); + it('uses only the authenticated walk user for hidden-entry starts', async () => { + const hiddenBefore = process.env.HOSTED_MCP_HIDDEN_ENTRIES; + const allowBefore = process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST; + const walkUser = '64b64c48c4f37a6b2f34c111'; + process.env.HOSTED_MCP_HIDDEN_ENTRIES = '["linear"]'; + process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST = JSON.stringify({ linear: [walkUser] }); + try { + mockAuthedUser = { id: walkUser }; + expect((await start()).status).toBe(200); + intake.discoverAuthorizationServer.mockClear(); + Integration.findOneAndUpdate.mockClear(); + mockAuthedUser = { id: '64b64c48c4f37a6b2f34c222' }; + const denied = await request(app).post('/connect/hosted-mcp/linear/start') + .query({ userId: walkUser }).send({ userId: walkUser }); + expect(denied.status).toBe(404); + expect(denied.body).toEqual({ error: 'unknown_entry' }); + mockAuthedUser = {}; + expect((await start()).status).toBe(401); + expect(intake.discoverAuthorizationServer).not.toHaveBeenCalled(); + expect(Integration.findOneAndUpdate).not.toHaveBeenCalled(); + mockAuthedUser = { id: walkUser }; + for (const bad of [{ linear: [walkUser], typo: [walkUser] }, { linear: [walkUser], sentry: 'bad' }]) { + process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST = JSON.stringify(bad); + expect((await start()).status).toBe(404); + } + } finally { + if (hiddenBefore === undefined) delete process.env.HOSTED_MCP_HIDDEN_ENTRIES; + else process.env.HOSTED_MCP_HIDDEN_ENTRIES = hiddenBefore; + if (allowBefore === undefined) delete process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST; + else process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST = allowBefore; + } + }); + it('uses and snapshots the one registered DCR client for authorization', async () => { FIXTURE_ENTRY.client = 'dcr'; dcr.ensureDcrClient.mockResolvedValue({ clientId: 'registered-public-client' }); diff --git a/backend/__tests__/unit/services/hostedMcpVisibility.test.js b/backend/__tests__/unit/services/hostedMcpVisibility.test.js new file mode 100644 index 0000000000..0355931bbc --- /dev/null +++ b/backend/__tests__/unit/services/hostedMcpVisibility.test.js @@ -0,0 +1,42 @@ +const { isHostedMcpEntryHidden } = require('../../../integrations/hostedMcp/visibility'); +const walkUser = '64b64c48c4f37a6b2f34c111'; +const otherUser = '64b64c48c4f37a6b2f34c222'; +const envFor = (map) => ({ + HOSTED_MCP_HIDDEN_ENTRIES: '["sentry","airtable"]', + HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST: JSON.stringify(map), +}); + +test('exceptions are exact user and entry pairs; public entries keep their normal policy', () => { + const env = envFor({ sentry: [walkUser] }); + expect(isHostedMcpEntryHidden('sentry', env, walkUser)).toBe(false); + expect(isHostedMcpEntryHidden('sentry', env, otherUser)).toBe(true); + expect(isHostedMcpEntryHidden('airtable', env, walkUser)).toBe(true); + expect(isHostedMcpEntryHidden('linear', env, otherUser)).toBe(false); + expect(isHostedMcpEntryHidden('sentry', { HOSTED_MCP_HIDDEN_ENTRIES: '["sentry"]' }, walkUser)).toBe(true); + expect(isHostedMcpEntryHidden('sentry', envFor({}), walkUser)).toBe(true); +}); + +test.each([undefined, null, '', 'walk-user', '64B64C48C4F37A6B2F34C111', {}, [walkUser]])( + 'missing or noncanonical authenticated identity grants no exception: %p', (id) => { + expect(isHostedMcpEntryHidden('sentry', envFor({ sentry: [walkUser] }), id)).toBe(true); + }, +); + +test.each([ + null, [], 'sentry', 42, + { sentry: [walkUser], typo: [walkUser] }, + { sentry: [walkUser], 'bad key': [] }, + { sentry: [walkUser], airtable: 'bad' }, + { sentry: [walkUser], airtable: [otherUser, null] }, + { sentry: [walkUser], airtable: ['walk-user'] }, + { sentry: [walkUser], airtable: ['64B64C48C4F37A6B2F34C111'] }, +])('one invalid member invalidates all exceptions: %p', (map) => { + expect(isHostedMcpEntryHidden('sentry', envFor(map), walkUser)).toBe(true); +}); + +test('invalid JSON and empty arrays fail closed for hidden entries', () => { + const env = envFor({ sentry: [] }); + expect(isHostedMcpEntryHidden('sentry', env, walkUser)).toBe(true); + env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST = '{'; + expect(isHostedMcpEntryHidden('sentry', env, walkUser)).toBe(true); +}); diff --git a/backend/__tests__/unit/services/installableCatalogService.test.js b/backend/__tests__/unit/services/installableCatalogService.test.js index ac0f05b4c4..f2cb1766b7 100644 --- a/backend/__tests__/unit/services/installableCatalogService.test.js +++ b/backend/__tests__/unit/services/installableCatalogService.test.js @@ -364,3 +364,30 @@ it('validates the instance hidden-entry setting', () => { expect([...hiddenHostedMcpEntryIds({})]).toEqual([]); expect(() => hiddenHostedMcpEntryIds({ HOSTED_MCP_HIDDEN_ENTRIES: 'airtable' })).toThrow(); }); + +it('offers a hidden hosted entry only to its configured walk user', async () => { + const hiddenBefore = process.env.HOSTED_MCP_HIDDEN_ENTRIES; + const allowBefore = process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST; + process.env.HOSTED_MCP_HIDDEN_ENTRIES = '["airtable"]'; + process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST = JSON.stringify({ airtable: [userId] }); + try { + const entry = HOSTED_MCP_ENTRIES.find((row) => row.id === 'airtable'); + mockInstallables([], [buildHostedMcpToolInstallable(entry)]); + InstallableInstallation.find.mockReturnValue(lean([])); + Integration.find.mockReturnValue(lean([])); + const offerings = async (id) => (await catalogFor(id)).installables + .filter((row) => row.list === 'tools').map((row) => row.installableId); + expect(await offerings(userId)).toEqual(['airtable']); + expect(await offerings('64b64c48c4f37a6b2f34c999')).toEqual([]); + expect(await offerings(undefined)).toEqual([]); + for (const bad of [{ airtable: [userId], typo: [userId] }, { airtable: [userId], sentry: null }]) { + process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST = JSON.stringify(bad); + expect(await offerings(userId)).toEqual([]); + } + } finally { + if (hiddenBefore === undefined) delete process.env.HOSTED_MCP_HIDDEN_ENTRIES; + else process.env.HOSTED_MCP_HIDDEN_ENTRIES = hiddenBefore; + if (allowBefore === undefined) delete process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST; + else process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST = allowBefore; + } +});