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
75 changes: 75 additions & 0 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.

3 changes: 3 additions & 0 deletions apps/api/src/handlers/mcp/integration-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ export function createIntegrationMcpProxy(
upstream: upstreamUrl,
allowAuthTokens: options?.allowAuthTokens,
allowedToolNames: options?.allowedToolNames,
// Resend's z.email() tool schemas include regex lookarounds that Azure
// OpenAI rejects. The upstream Resend server still validates tool calls.
stripToolSchemaPatterns: integration.id === 'resend',
// Integration OAuth MCPs resolve acting-user credentials directly.
validateTaskRunToken: async () => null,
resolveCredentials: async (auth) => {
Expand Down
49 changes: 43 additions & 6 deletions apps/api/src/handlers/mcp/proxy-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ interface McpProxyConfig {
allowAuthTokens?: boolean;
validateTaskRunToken?: (auth: RunTokenContext) => Promise<Response | null>;
allowedToolNames?: readonly string[];
stripToolSchemaPatterns?: boolean;
timeoutMs?: number;
}

Expand Down Expand Up @@ -359,12 +360,37 @@ function isExpectedProxyDisconnect(error: unknown): error is DOMException {
);
}

/**
* Resend's MCP uses Zod's email schema, which serializes to a JSON Schema
* `pattern` containing regex lookarounds. Azure OpenAI rejects `pattern` in
* tool schemas, while Resend still validates the actual tool call upstream.
* Strip only the model-facing keyword at this proxy boundary.
*/
function stripToolSchemaPatterns(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(stripToolSchemaPatterns);
}

if (!value || typeof value !== 'object') {
return value;
}

return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter(([key]) => key !== 'pattern')
.map(([key, nestedValue]) => [key, stripToolSchemaPatterns(nestedValue)]),
);
}

function filterToolsListPayload(
payload: unknown,
toolPolicy: {
allowedToolNames?: readonly string[];
disabledToolNames?: readonly string[] | null;
},
options?: {
stripToolSchemaPatterns?: boolean;
},
): unknown {
if (!payload || typeof payload !== 'object') {
return payload;
Expand Down Expand Up @@ -393,11 +419,15 @@ function filterToolsListPayload(
),
);

const filteredTools = filterMcpToolDefinitions(namedTools, toolPolicy);

return {
...payload,
result: {
...result,
tools: filterMcpToolDefinitions(namedTools, toolPolicy),
tools: options?.stripToolSchemaPatterns
? stripToolSchemaPatterns(filteredTools)
: filteredTools,
},
};
}
Expand All @@ -411,6 +441,7 @@ export function createMcpProxy(config: McpProxyConfig) {
allowAuthTokens = false,
validateTaskRunToken = verifyTaskRunTokenTargetExists,
allowedToolNames,
stripToolSchemaPatterns: shouldStripToolSchemaPatterns = false,
} = config;

const app = new Hono<{ Variables: Variables }>();
Expand Down Expand Up @@ -652,7 +683,7 @@ export function createMcpProxy(config: McpProxyConfig) {
}

if (
hasToolRestrictions &&
(hasToolRestrictions || shouldStripToolSchemaPatterns) &&
method === 'POST' &&
getJsonRpcMethod(parsedBody) === 'tools/list' &&
upstreamResponse.ok
Expand All @@ -665,10 +696,16 @@ export function createMcpProxy(config: McpProxyConfig) {
if (!payload) {
throw new Error('Unable to parse upstream tools/list payload');
}
const filteredPayload = filterToolsListPayload(payload, {
allowedToolNames: effectiveAllowedToolNames,
disabledToolNames: credentials.disabledToolNames,
});
const filteredPayload = filterToolsListPayload(
payload,
{
allowedToolNames: effectiveAllowedToolNames,
disabledToolNames: credentials.disabledToolNames,
},
{
stripToolSchemaPatterns: shouldStripToolSchemaPatterns,
},
);
const headers = buildProxyResponseHeaders(upstreamResponse.headers);
headers.set('content-type', 'application/json');

Expand Down
1 change: 1 addition & 0 deletions apps/docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@
"integrations/posthog",
"integrations/pylon",
"integrations/railway",
"integrations/resend",
"integrations/sentry",
"integrations/snowflake",
"integrations/supabase",
Expand Down
1 change: 1 addition & 0 deletions apps/docs/integrations/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ from [Personal Settings](/personal-settings).
| <IntegrationName href="/integrations/posthog" icon="posthog" name="PostHog" /> | Product analytics, experiments, and error context | Admin connection once |
| <IntegrationName href="/integrations/pylon" icon="/logo/integrations/pylon.svg" name="Pylon" /> | Customer issue and account context | Admin connection once |
| <IntegrationName href="/integrations/railway" icon="railway" name="Railway" /> | Project and service context from Railway | Admin connection once |
| <IntegrationName href="/integrations/resend" icon="resend" name="Resend" /> | Email delivery and infrastructure management | Admin connection once |
| <IntegrationName href="/integrations/sentry" icon="sentry" name="Sentry" /> | Error and performance investigation | Admin connection once |
| <IntegrationName href="/integrations/snowflake" icon="snowflake" name="Snowflake" /> | Data warehouse exploration | Admin connection once |
| <IntegrationName href="/integrations/supabase" icon="supabase" name="Supabase" /> | Read-only database access in Supabase | Enable first, then teammates link accounts |
Expand Down
45 changes: 45 additions & 0 deletions apps/docs/integrations/resend.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
title: Resend
description: Inspect and manage shared email infrastructure from Roomote tasks.
icon: 'https://api.iconify.design/simple-icons:resend.svg?color=currentColor'
---

Connect Resend when tasks need email delivery status, received messages,
domains, contacts, templates, broadcasts, or other email infrastructure.

## How setup works

An admin connects Resend once from **Settings > Integrations** using OAuth. The
connection is shared across the deployment and requests Resend's
`full_access` scope so Roomote can use inspection and management tools.

## Safer defaults

Roomote initially disables tools that send or reschedule email, create or
remove API credentials, mutate domains or webhooks, mutate or trigger
automations, and mutate contacts. Read operations remain available, and
canceling a pending scheduled email remains enabled as a safety action.

The disabled tools include:

- single, batch, and broadcast sending
- rescheduling a scheduled email
- creating or removing API keys that could bypass Roomote's tool policy
- creating or updating automations, or sending events that trigger them
- updating or removing domains
- creating or updating webhooks
- creating, updating, or removing contacts
- creating, updating, or removing contact properties
- changing contact segment or topic membership
- importing contacts from CSV

An admin can opt in to individual tools from **Settings > Integrations >
Resend > Manage tools**. Disabled tools are hidden from tasks and rejected by
the server if called directly. Admin choices persist when Resend is reconnected
or disabled and enabled again.

## Verify the connection

After connecting Resend, start with a read-only request such as listing recent
emails or checking domain status. Enable only the additional tools your team
expects Roomote to use.
55 changes: 52 additions & 3 deletions apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts

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

Loading
Loading