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/services/installableCatalogService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -433,3 +433,56 @@ it('offers a hidden hosted entry only to its configured walk user', async () =>
else process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST = allowBefore;
}
});

// Which connect route the page calls is the entry's fact. An operator-key entry's
// OAuth start route answers 404 `unknown_entry`, so the row has to say so; an
// OAuth entry's row stays exactly as it was, with no field.
it("a hosted entry's row says when it takes a pasted key, and only then", async () => {
const keyed = {
id: 'keyed',
title: 'Keyed',
description: 'A vendor that takes a pasted key.',
connectNotice: 'Create a key with read scopes only.',
accessPage: 'https://keyed.example/keys',
resource: 'https://mcp.keyed.example/mcp',
credentialMode: 'operator_key',
revoke: { page: 'https://keyed.example/keys' },
tools: [{
name: 'list_things', upstreamName: 'list_things', description: 'List things',
class: 'read', inputSchema: { type: 'object' }, annotations: { readOnlyHint: true },
}],
};
const shipped = [...HOSTED_MCP_ENTRIES];
HOSTED_MCP_ENTRIES.push(keyed);
try {
const linearEntry = HOSTED_MCP_ENTRIES.find((entry) => entry.id === 'linear');
mockInstallables([], [buildHostedMcpToolInstallable(linearEntry), buildHostedMcpToolInstallable(keyed)]);
InstallableInstallation.find.mockReturnValue(lean([]));
Integration.find.mockReturnValue(lean([{
_id: 'conn-keyed', type: 'hosted-mcp', status: 'connected', createdBy: userId,
createdAt: new Date('2026-11-01T09:00:00Z'),
config: { entryId: 'keyed', intake: 'operator_key', agentId: '6a0000000000000000000009', credentialRef: 'ref-keyed-secret' },
}]));

const rows = (await catalogFor(userId)).installables.filter((entry) => entry.list === 'tools');
const keyedRow = rows.find((entry) => entry.installableId === 'keyed');
const linear = rows.find((entry) => entry.installableId === 'linear');

expect(keyedRow).toMatchObject({
connectionType: 'hosted-mcp',
entryId: 'keyed',
credentialMode: 'operator_key',
connectNotice: keyed.connectNotice,
accessPage: keyed.accessPage,
available: true,
});
expect(keyedRow.connections).toEqual([{
connectionId: 'conn-keyed', owner: '', repo: '', connectedAt: '2026-11-01T09:00:00.000Z',
agentId: '6a0000000000000000000009',
}]);
expect(Object.prototype.hasOwnProperty.call(linear, 'credentialMode')).toBe(false);
expect(JSON.stringify(rows)).not.toContain('ref-keyed-secret');
} finally {
HOSTED_MCP_ENTRIES.splice(0, HOSTED_MCP_ENTRIES.length, ...shipped);
}
});
7 changes: 6 additions & 1 deletion backend/services/installable/installableCatalogService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ const publicConnection = (integration: any) => ({
* is per ENTRY (`/api/integrations/connect/hosted-mcp/:entryId/start`, §4), and
* the page has no other way to learn an entry id — an Installable id is the
* entry id by construction, but reading that as a URL would be a second,
* unwitnessed spelling of the same fact.
* unwitnessed spelling of the same fact. `credentialMode` rides beside them
* for the same reason: which connect route the page calls is the entry's fact.
*/
const toolEntriesFor = async (userId: string): Promise<{ entries: unknown[]; hiddenConnections: unknown[] }> => {
const metas = toolInstallableMetas();
Expand Down Expand Up @@ -187,6 +188,10 @@ const toolEntriesFor = async (userId: string): Promise<{ entries: unknown[]; hid
...(hostedEntry?.connectNotice ? { connectNotice: hostedEntry.connectNotice } : {}),
...(hostedEntry?.accessPage ? { accessPage: hostedEntry.accessPage } : {}),
...(hostedEntry?.revoke?.page ? { revokePage: hostedEntry.revoke.page } : {}),
// How a person connects: absent means the OAuth start route; an
// operator-key entry takes a pasted key per agent at its own route, and
// its start route answers 404, so the page must know before it offers.
...(hostedEntry?.credentialMode === 'operator_key' ? { credentialMode: 'operator_key' } : {}),
connectionType: meta.connectionType,
...(meta.entryId ? { entryId: meta.entryId } : {}),
available: readiness.available,
Expand Down
1 change: 1 addition & 0 deletions frontend/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ module.exports = {
'src/v2/connectors/ConnectorPopover.tsx',
'src/v2/connectors/ConnectorTile.tsx',
'src/v2/connectors/ConnectorsPage.tsx',
'src/v2/connectors/OperatorKeyForm.tsx',
'src/v2/connectors/ToolPanels.tsx',
'src/v2/utils/localizeRelativeTime.ts',
],
Expand Down
24 changes: 23 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2794,7 +2794,29 @@
"connectionSeparator": " · ",
"connectionSignedIn": "Signed in",
"connectionKey": "Key",
"disconnectConnectionLabel": "Disconnect {{label}}: {{connection}}"
"disconnectConnectionLabel": "Disconnect {{label}}: {{connection}}",
"operatorKey": {
"note": "add a key for one of your agents",
"noAgents": "no agent of yours to add a key for yet",
"agent": "agent",
"key": "key",
"submit": "Add key",
"checking": "Checking…",
"createKey": "Create a key at {{label}}",
"errors": {
"invalidAgent": "Pick one of your agents.",
"agentNotOwned": "You can add a key only for an agent you alone installed.",
"unknownEntry": "This connector does not take a key here.",
"keyMissing": "Paste a key first.",
"scopeRefused": "This key has scopes Commonly does not allow. Create one without them.",
"vendorRejected": "{{label}} did not accept this key.",
"vendorUnreachable": "Could not reach {{label}} to check the key. Try again.",
"connectionChanged": "The connection changed while saving. Paste the key again.",
"keyStoreFailed": "Could not save the key. Try again.",
"generic": "Could not add the key. Try again."
},
"agentsUnavailable": "couldn't read your agents just now · reload to try again"
}
},
"agentManage": {
"runtime": {
Expand Down
24 changes: 23 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2795,7 +2795,29 @@
"connectionSeparator": " · ",
"connectionSignedIn": "已登录",
"connectionKey": "密钥",
"disconnectConnectionLabel": "断开 {{label}}:{{connection}}"
"disconnectConnectionLabel": "断开 {{label}}:{{connection}}",
"operatorKey": {
"note": "为你的智能体添加密钥",
"noAgents": "你还没有可以添加密钥的智能体",
"agent": "智能体",
"key": "密钥",
"submit": "添加密钥",
"checking": "验证中…",
"createKey": "去 {{label}} 创建密钥",
"errors": {
"invalidAgent": "请先选择一个智能体。",
"agentNotOwned": "只能为仅由你安装的智能体添加密钥。",
"unknownEntry": "此连接器不支持在这里添加密钥。",
"keyMissing": "请先粘贴密钥。",
"scopeRefused": "这个密钥包含 Commonly 不允许的权限范围,请新建一个不含这些权限的密钥。",
"vendorRejected": "{{label}} 拒绝了这个密钥。",
"vendorUnreachable": "无法连接 {{label}} 来验证密钥,请稍后重试。",
"connectionChanged": "保存时连接发生了变化,请重新粘贴密钥。",
"keyStoreFailed": "无法保存密钥,请重试。",
"generic": "无法添加密钥,请重试。"
},
"agentsUnavailable": "暂时无法读取你的智能体,请刷新后重试"
}
},
"agentManage": {
"runtime": {
Expand Down
Loading
Loading