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
6 changes: 6 additions & 0 deletions apps/docs/integrations/linear.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ priority, or discussion.

## Setup

For self-hosted deployments, create a Linear OAuth application before
connecting the workspace. Configure its callback as
`<R_PUBLIC_URL>/api/mcp-oauth/callback`, then set
`R_LINEAR_CLIENT_ID`, `R_LINEAR_CLIENT_SECRET`, and
`R_LINEAR_WEBHOOK_SECRET` on the Roomote deployment.

<Steps>
<Step title="Connect Linear">
In Roomote, go to **Settings > Integrations** and connect your Linear
Expand Down
14 changes: 11 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.

7 changes: 5 additions & 2 deletions apps/web/src/app/api/mcp-oauth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@roomote/db/server';
import {
getMcpIntegration,
getMcpIntegrationOauthEndpoints,
isDeploymentScopedMcpIntegration,
isSelfServeMcpIntegration,
} from '@roomote/types';
Expand Down Expand Up @@ -302,12 +303,14 @@ export async function GET(request: NextRequest) {
}

failureStage = 'provider_discovery';
const serverMetadata = await discoverOAuthEndpoints(integration.url);
const tokenEndpoint =
getMcpIntegrationOauthEndpoints(integration)?.tokenEndpoint ??
(await discoverOAuthEndpoints(integration.url)).token_endpoint;
const redirectUri = new URL(CALLBACK_PATH, webUrl).toString();

failureStage = 'token_exchange';
const tokens = await exchangeCodeForTokens(
serverMetadata.token_endpoint,
tokenEndpoint,
code,
oauthState.codeVerifier,
clientInfo,
Expand Down

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

44 changes: 38 additions & 6 deletions apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ import {
import type {
OAuthClientMetadata,
OAuthClientInformation,
OAuthServerMetadata,
} from '@roomote/types';
import {
type McpConnectionRole,
getMcpIntegrationAuthorizationParameters,
getMcpIntegrationOauthEndpoints,
getMcpIntegrationOauthScopeMode,
getMcpIntegrationOauthScopeSeparator,
getMcpIntegrationOauthScopes,
getMcpIntegration,
type McpIntegration,
Expand Down Expand Up @@ -99,7 +102,9 @@ function getRequestedScope(
connectionRole,
);
if (explicitScopes?.length) {
return explicitScopes.join(' ');
return explicitScopes.join(
getMcpIntegrationOauthScopeSeparator(integration),
);
}

if (!scopeList?.length) {
Expand All @@ -115,7 +120,31 @@ function getRequestedScope(
new Set(requestedScopes.map((scope) => scope.trim()).filter(Boolean)),
);

return uniqueScopes.length > 0 ? uniqueScopes.join(' ') : undefined;
return uniqueScopes.length > 0
? uniqueScopes.join(getMcpIntegrationOauthScopeSeparator(integration))
: undefined;
}

function getOAuthServerMetadataOverride(
integration: McpIntegration,
): OAuthServerMetadata | undefined {
const endpoints = getMcpIntegrationOauthEndpoints(integration);
if (!endpoints) {
return undefined;
}

return {
issuer: new URL(endpoints.authorizationEndpoint).origin,
authorization_endpoint: endpoints.authorizationEndpoint,
token_endpoint: endpoints.tokenEndpoint,
scopes_supported: getMcpIntegrationOauthScopes(integration),
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: [
'client_secret_post',
'client_secret_basic',
],
};
}

function getStaticClientInformation(
Expand Down Expand Up @@ -185,10 +214,13 @@ export async function GET(
);
}

const [serverMetadata, protectedResourceMetadata] = await Promise.all([
discoverOAuthEndpoints(integration.url),
discoverOAuthProtectedResourceMetadata(integration.url),
]);
const serverMetadataOverride = getOAuthServerMetadataOverride(integration);
const [serverMetadata, protectedResourceMetadata] = serverMetadataOverride
? [serverMetadataOverride, undefined]
: await Promise.all([
discoverOAuthEndpoints(integration.url),
discoverOAuthProtectedResourceMetadata(integration.url),
]);
const redirectUri = new URL('/api/mcp-oauth/callback', webUrl).toString();
const requestedScope = getRequestedScope(
protectedResourceMetadata?.scopes_supported ??
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/lib/server/mcp-static-oauth.test.ts

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

27 changes: 23 additions & 4 deletions apps/web/src/lib/server/mcp-static-oauth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { McpIntegration } from '@roomote/types';
import { MCP_INTEGRATIONS, type McpIntegration } from '@roomote/types';

type StaticOauthClientEnv = NonNullable<McpIntegration['oauthClientEnv']>;
type StaticOauthPairResolution =
Expand All @@ -13,9 +13,28 @@ type StaticOauthPairResolution =
const STATIC_OAUTH_FALLBACKS: Partial<Record<string, StaticOauthClientEnv[]>> =
{};

const STATIC_OAUTH_ENV_KEYS = new Set<string>();

const STATIC_OAUTH_ENV_PARTNERS: Record<string, string> = {};
const STATIC_OAUTH_ENV_PAIRS = MCP_INTEGRATIONS.flatMap((integration) => {
const clientIdEnv = integration.oauthClientEnv?.clientIdEnv;
const clientSecretEnv = integration.oauthClientEnv?.clientSecretEnv;

return clientIdEnv && clientSecretEnv
? [[clientIdEnv, clientSecretEnv] as const]
: [];
});

const STATIC_OAUTH_ENV_KEYS = new Set(
STATIC_OAUTH_ENV_PAIRS.flatMap(([clientIdEnv, clientSecretEnv]) => [
clientIdEnv,
clientSecretEnv,
]),
);

const STATIC_OAUTH_ENV_PARTNERS = Object.fromEntries(
STATIC_OAUTH_ENV_PAIRS.flatMap(([clientIdEnv, clientSecretEnv]) => [
[clientIdEnv, clientSecretEnv],
[clientSecretEnv, clientIdEnv],
]),
);

export function getStaticOauthEnvPartnerKey(key: string): string | undefined {
return STATIC_OAUTH_ENV_PARTNERS[key];
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/trpc/commands/mcp-connections/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,13 @@ export async function connectMcpCommand(
);
}

const missingOauthEnvNames = getMissingStaticOauthEnvNames(integration);
if (missingOauthEnvNames.length > 0) {
throw new Error(
`${integration.name} isn't available on this Roomote deployment yet. Ask the team managing this Roomote deployment to finish the required OAuth setup before connecting it.`,
);
}

if (
input.redirectTo &&
(!input.redirectTo.startsWith('/') || input.redirectTo.startsWith('//'))
Expand Down
Loading
Loading