Skip to content
Draft
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
32 changes: 30 additions & 2 deletions apps/api/src/handlers/mcp/__tests__/deployment-mcp-auth.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

66 changes: 64 additions & 2 deletions apps/api/src/handlers/mcp/__tests__/integration-mcp.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 26 additions & 2 deletions apps/api/src/handlers/mcp/deployment-mcp-auth.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { db, eq, taskRuns } from '@roomote/db/server';
import {
db,
eq,
getActiveAutomationRunForPrincipal,
taskRuns,
} from '@roomote/db/server';

import type { Variables } from '../../types';

import {
isRunTokenContext,
isAutomationTokenContext,
McpProxyError,
type McpAuthContext,
} from './proxy-utils';
Expand Down Expand Up @@ -38,12 +44,30 @@ export async function resolveDeploymentMcpAuth(
};
}

if (isAutomationTokenContext(authContext)) {
const run = await getActiveAutomationRunForPrincipal({
automationRunId: authContext.automationRunId,
leaseOwner: authContext.leaseOwner,
policyVersion: authContext.policyVersion,
});
if (!run) {
throw new McpProxyError(403, 'Automation run token is no longer active');
}
return {
userId: null,
tokenType: 'automation',
automationRunId: authContext.automationRunId,
automationLeaseOwner: authContext.leaseOwner,
automationPolicyVersion: authContext.policyVersion,
};
}

if (authContext.tokenType === 'auth') {
return { userId: authContext.userId, tokenType: 'auth' };
}

throw new McpProxyError(
403,
`${providerName} MCP requires a user auth token or task run token for server-side credential access`,
`${providerName} MCP requires a user, task run, or authorized automation token for server-side credential access`,
);
}
6 changes: 5 additions & 1 deletion apps/api/src/handlers/mcp/gbrain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,14 @@ export const GBRAIN_READ_TOOL_NAMES = [
* upstream. Requests are refused unless the integration is enabled and a
* connection (admin-entered or env-pinned) exists.
*/
export function createGbrainMcpProxy(options?: { allowAuthTokens?: boolean }) {
export function createGbrainMcpProxy(options?: {
allowAuthTokens?: boolean;
allowAutomationTokens?: boolean;
}) {
return createMcpProxy({
name: 'Brain',
allowAuthTokens: options?.allowAuthTokens,
allowAutomationTokens: options?.allowAutomationTokens,
allowedToolNames: GBRAIN_READ_TOOL_NAMES,
validateTaskRunToken: async () => null,
resolveCredentials: async () => {
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/handlers/mcp/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ function buildRouterGitHubHeaders(): Record<string, string> {

export function createGithubMcp(options?: {
allowAuthTokens?: boolean;
allowAutomationTokens?: boolean;
allowedToolNames?: readonly string[];
}) {
return createMcpProxy({
name: 'GitHub',
upstream: Env.GITHUB_MCP_SERVER_URL ?? DEFAULT_GITHUB_MCP_URL,
allowAuthTokens: options?.allowAuthTokens,
allowAutomationTokens: options?.allowAutomationTokens,
allowedToolNames: options?.allowedToolNames,
resolveCredentials: async () => {
let githubToken: string;
Expand Down
15 changes: 13 additions & 2 deletions apps/api/src/handlers/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,21 @@ mcp.route('/custom/:serverId', createCustomMcpProxy());
// integration with a custom handler, like snowflake/grafana below. The
// handler 404s per request unless the integration is enabled and a
// connection (admin-entered or R_GBRAIN_* env) exists.
mcp.route('/gbrain', createGbrainMcpProxy({ allowAuthTokens: true }));
mcp.route(
'/gbrain',
createGbrainMcpProxy({
allowAuthTokens: true,
allowAutomationTokens: true,
}),
);

mcp.route('/asana', asanaMcp);
mcp.route('/granola', granolaMcp);
mcp.route('/grafana', grafanaMcp);
mcp.route('/linear', createLinearMcp({ allowAuthTokens: true }));
mcp.route(
'/linear',
createLinearMcp({ allowAuthTokens: true, allowAutomationTokens: true }),
);
mcp.route('/notion', notionMcp);
mcp.route('/snowflake', snowflakeMcp);
mcp.route('/vercel', vercelMcp);
Expand All @@ -90,6 +99,8 @@ for (const integration of MCP_INTEGRATIONS.filter(
...getIntegrationMcpProxyOptions(integration),
allowAuthTokens:
getMcpIntegrationConnectionScope(integration) === 'deployment',
allowAutomationTokens:
getMcpIntegrationConnectionScope(integration) === 'deployment',
}),
);
}
Expand Down
39 changes: 36 additions & 3 deletions apps/api/src/handlers/mcp/integration-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isNull,
mcpConnections,
deploymentMcpEnablements,
getActiveAutomationRunForPrincipal,
} from '@roomote/db/server';
import { decrypt } from '@roomote/db/encryption';
import { getValidAccessToken } from '@roomote/sdk/server';
Expand All @@ -21,6 +22,7 @@ import {
McpProxyError,
resolveActingUserId,
resolveActingUserIdOrNull,
type McpAuthContext,
} from './proxy-utils';

async function resolveUpstreamAccessToken(
Expand Down Expand Up @@ -77,7 +79,10 @@ async function resolveUpstreamAccessToken(
};
}

async function resolveDeploymentToolPolicy(mcpId: string) {
async function resolveDeploymentToolPolicy(
mcpId: string,
auth: McpAuthContext,
) {
const enablement = await db.query.deploymentMcpEnablements.findFirst({
where: and(
eq(deploymentMcpEnablements.mcpId, mcpId),
Expand All @@ -88,16 +93,43 @@ async function resolveDeploymentToolPolicy(mcpId: string) {
},
});

const allowedToolNames = getAllowedIntegrationMcpToolNames(mcpId) ?? null;

if (auth.tokenType === 'automation') {
if (!enablement) {
throw new McpProxyError(
403,
`Automation run cannot use disabled integration ${mcpId}`,
);
}
if (
!auth.automationRunId ||
!auth.automationLeaseOwner ||
!auth.automationPolicyVersion
) {
throw new McpProxyError(403, 'Automation MCP principal is incomplete');
}
const run = await getActiveAutomationRunForPrincipal({
automationRunId: auth.automationRunId,
leaseOwner: auth.automationLeaseOwner,
policyVersion: auth.automationPolicyVersion,
});
if (!run) {
throw new McpProxyError(403, 'Automation run token is no longer active');
}
}

return {
disabledToolNames: enablement?.disabledTools ?? null,
allowedToolNames: getAllowedIntegrationMcpToolNames(mcpId) ?? null,
allowedToolNames,
};
}

export function createIntegrationMcpProxy(
integration: McpIntegration,
options?: {
allowAuthTokens?: boolean;
allowAutomationTokens?: boolean;
allowedToolNames?: readonly string[];
},
) {
Expand All @@ -113,6 +145,7 @@ export function createIntegrationMcpProxy(
name: integration.name,
upstream: upstreamUrl,
allowAuthTokens: options?.allowAuthTokens,
allowAutomationTokens: options?.allowAutomationTokens,
allowedToolNames: options?.allowedToolNames,
// Resend's z.email() tool schemas include regex lookarounds that Azure
// OpenAI rejects. The upstream Resend server still validates tool calls.
Expand All @@ -138,7 +171,7 @@ export function createIntegrationMcpProxy(
actingUserId,
);
accessToken = resolvedConnection.accessToken;
toolPolicy = await resolveDeploymentToolPolicy(integration.id);
toolPolicy = await resolveDeploymentToolPolicy(integration.id, auth);
} catch (error) {
if (error instanceof McpProxyError) {
throw error;
Expand Down
Loading
Loading