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
33 changes: 33 additions & 0 deletions backend/__tests__/unit/routes/hostedMcpConnect.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
42 changes: 42 additions & 0 deletions backend/__tests__/unit/services/hostedMcpVisibility.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
27 changes: 27 additions & 0 deletions backend/__tests__/unit/services/installableCatalogService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
37 changes: 34 additions & 3 deletions backend/integrations/hostedMcp/visibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,37 @@ 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<string, string[]> => {
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);
const hiddenEntryIds = hiddenHostedMcpEntryIds(env);
if (!entries.every(([entry, users]) => (
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 {};
return value as Record<string, string[]>;
} 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);
};
2 changes: 1 addition & 1 deletion backend/routes/hostedMcpConnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
5 changes: 2 additions & 3 deletions backend/services/installable/installableCatalogService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down Expand Up @@ -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);
Expand Down
38 changes: 38 additions & 0 deletions docs/runbooks/hosted-mcp-hidden-entry-walks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 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. 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
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.
2 changes: 2 additions & 0 deletions k8s/helm/commonly/templates/core/backend-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions k8s/helm/commonly/templates/ops/ops-runner-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions k8s/helm/commonly/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading