Feat: mcp proxy, new linear integration

This commit is contained in:
Harshith Mullapudi 2025-07-14 23:56:37 +05:30
parent 769c79f773
commit 4de1d29fe4
50 changed files with 17938 additions and 83 deletions

View File

@ -101,7 +101,7 @@ export const ConversationList = ({
<div
key={key}
style={style}
className="-mt-2 ml-1 flex items-center justify-start p-0 text-sm"
className="-mt-1 ml-1 hidden items-center justify-start p-0 text-sm group-hover:flex"
>
<Button
variant="link"
@ -179,14 +179,14 @@ export const ConversationList = ({
return (
<div className="flex h-full flex-col pt-1 pl-1">
<div className="grow overflow-hidden">
<div className="group grow overflow-hidden">
<AutoSizer>
{({ height, width }) => (
<List
height={height}
width={width}
rowCount={rowCount}
rowHeight={36} // Slightly taller for better click area
rowHeight={32} // Slightly taller for better click area
rowRenderer={rowRenderer}
overscanRowCount={5}
/>

View File

@ -0,0 +1,113 @@
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { createMCPProxy } from "@core/mcp-proxy";
import { getIntegrationDefinitionWithSlug } from "~/services/integrationDefinition.server";
import { z } from "zod";
import { getIntegrationAccount } from "~/services/integrationAccount.server";
export const integrationSlugSchema = z.object({
slug: z.string(),
});
const { action, loader } = createActionApiRoute(
{
params: integrationSlugSchema,
allowJWT: true,
authorization: {
action: "mcp",
},
corsStrategy: "all",
},
async ({ authentication, request, params }) => {
try {
const slug = params.slug;
if (!slug) {
return new Response(
JSON.stringify({ error: "Integration slug is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
// Fetch integration definition by slug
const integrationDefinition =
await getIntegrationDefinitionWithSlug(slug);
if (!integrationDefinition) {
return new Response(
JSON.stringify({ error: "Integration not found" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
},
);
}
const spec = integrationDefinition.spec as any;
if (!spec.mcpAuth) {
return new Response(
JSON.stringify({
error: "MCP auth configuration not found for this integration",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const { serverUrl, transportStrategy } = spec.mcpAuth;
const mcpProxy = createMCPProxy(
{
serverUrl,
timeout: 30000,
debug: true,
transportStrategy: transportStrategy || "sse-first",
},
// Callback to load credentials from the database
async () => {
// Find the integration account for this user and integration
const integrationAccount = await getIntegrationAccount(
integrationDefinition.id,
authentication.userId,
);
const integrationConfig =
integrationAccount?.integrationConfiguration as any;
if (!integrationAccount || !integrationConfig) {
return null;
}
return {
serverUrl,
tokens: {
access_token: integrationConfig.access_token,
token_type: integrationConfig.token_type || "bearer",
expires_in: integrationConfig.expires_in || 3600,
refresh_token: integrationConfig.refresh_token,
scope: integrationConfig.scope || "read write",
},
expiresAt: integrationConfig.expiresAt
? new Date(integrationConfig.expiresAt)
: new Date(Date.now() + 3600 * 1000),
};
},
);
return await mcpProxy(request, "");
} catch (error: any) {
console.error("MCP Proxy Error:", error);
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
},
);
export { action, loader };

View File

@ -2,7 +2,7 @@ import { json } from "@remix-run/node";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { OAuthBodySchema } from "~/services/oauth/oauth-utils.server";
import { getRedirectURL } from "~/services/oauth/oauth.server";
import { getRedirectURL, getRedirectURLForMCP } from "~/services/oauth/oauth.server";
import { getWorkspaceByUser } from "~/models/workspace.server";
// This route handles the OAuth redirect URL generation, similar to the NestJS controller
@ -17,9 +17,17 @@ const { action, loader } = createActionApiRoute(
},
async ({ body, authentication, request }) => {
const workspace = await getWorkspaceByUser(authentication.userId);
const url = new URL(request.url);
const isMCP = url.searchParams.get("mcp") === "true";
// Call the service to get the redirect URL
const redirectURL = await getRedirectURL(
// Call the appropriate service based on MCP flag
const redirectURL = isMCP
? await getRedirectURLForMCP(
body,
authentication.userId,
workspace?.id,
)
: await getRedirectURL(
body,
authentication.userId,
workspace?.id,

View File

@ -0,0 +1,100 @@
import { type LoaderFunctionArgs } from "@remix-run/node";
import { getIntegrationDefinitionWithId } from "~/services/integrationDefinition.server";
import { runIntegrationTrigger } from "~/services/integration.server";
import { IntegrationEventType } from "@core/types";
import { createMCPAuthClient } from "@core/mcp-proxy";
import { logger } from "~/services/logger.service";
import { env } from "~/env.server";
import { getIntegrationDefinitionForState } from "~/services/oauth/oauth.server";
const MCP_CALLBACK_URL = `${process.env.OAUTH_CALLBACK_URL ?? ""}/mcp`;
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const authorizationCode = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!authorizationCode || !state) {
return new Response(null, {
status: 302,
headers: {
Location: `${env.APP_ORIGIN}/integrations?success=false&error=${encodeURIComponent(
"Missing authorization code or state",
)}`,
},
});
}
const { integrationDefinitionId, redirectURL } =
await getIntegrationDefinitionForState(state);
try {
// For now, we'll assume Linear integration - in the future this should be derived from state
const integrationDefinition = await getIntegrationDefinitionWithId(
integrationDefinitionId,
);
if (!integrationDefinition) {
throw new Error("Integration definition not found");
}
const spec = integrationDefinition.spec as any;
if (!spec.mcpAuth) {
throw new Error("MCP auth configuration not found for this integration");
}
const { transportStrategy, serverUrl } = spec.mcpAuth;
const authClient = createMCPAuthClient({
serverUrl,
transportStrategy: transportStrategy || "sse-first",
redirectUrl: MCP_CALLBACK_URL,
});
const result = await authClient.completeOAuthFlow({
authorizationCode,
state,
scope: "read write",
});
// Run integration trigger with MCP OAuth response
await runIntegrationTrigger(
integrationDefinition,
{
event: IntegrationEventType.SETUP,
eventBody: {
oauthResponse: result,
oauthParams: {
code: authorizationCode,
state,
redirect_uri: MCP_CALLBACK_URL,
},
integrationDefinition,
},
},
// We need to get userId from somewhere - for now using undefined
undefined,
);
return new Response(null, {
status: 302,
headers: {
Location: `${redirectURL}/integrations?success=true&integrationName=${encodeURIComponent(
integrationDefinition.name,
)}`,
},
});
} catch (error: any) {
logger.error("MCP OAuth callback error:", error);
return new Response(null, {
status: 302,
headers: {
Location: `${redirectURL}/integrations?success=false&error=${encodeURIComponent(
error.message || "OAuth callback failed",
)}`,
},
});
}
}

View File

@ -0,0 +1,14 @@
import { prisma } from "~/db.server";
export const getIntegrationAccount = async (
integrationDefinitionId: string,
userId: string,
) => {
return await prisma.integrationAccount.findFirst({
where: {
integrationDefinitionId: integrationDefinitionId,
integratedById: userId,
isActive: true,
},
});
};

View File

@ -22,3 +22,14 @@ export async function getIntegrationDefinitionWithId(
where: { id: integrationDefinitionId },
});
}
/**
* Get a single integration definition by its slug.
*/
export async function getIntegrationDefinitionWithSlug(
slug: string,
) {
return prisma.integrationDefinitionV2.findFirst({
where: { slug },
});
}

View File

@ -16,28 +16,19 @@ export interface SessionRecord {
workspaceId: string;
accountIdentifier?: string;
integrationKeys?: string;
personal: boolean;
userId?: string;
}
export class OAuthBodyInterface {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
config?: any;
@IsString()
redirectURL: string;
@IsBoolean()
personal: boolean = false;
@IsString()
integrationDefinitionId: string;
}
export const OAuthBodySchema = z.object({
config: z.any().optional(),
redirectURL: z.string(),
personal: z.boolean().default(false),
integrationDefinitionId: z.string(),
});

View File

@ -14,12 +14,18 @@ import { logger } from "../logger.service";
import { runIntegrationTrigger } from "../integration.server";
import type { IntegrationDefinitionV2 } from "@core/database";
import { env } from "~/env.server";
import { createMCPAuthClient } from "@core/mcp-proxy";
// Use process.env for config in Remix
const CALLBACK_URL = process.env.OAUTH_CALLBACK_URL ?? "";
const MCP_CALLBACK_URL = `${CALLBACK_URL}/mcp`;
// Session store (in-memory, for single server)
const session: Record<string, SessionRecord> = {};
const mcpSession: Record<
string,
{ integrationDefinitionId: string; redirectURL: string }
> = {};
export type CallbackParams = Record<string, string>;
@ -166,9 +172,9 @@ export async function getRedirectURL(
workspaceId?: string,
specificScopes?: string,
) {
const { integrationDefinitionId, personal } = oAuthBody;
const { integrationDefinitionId } = oAuthBody;
const redirectURL = `${env.APP_ORIGIN}/integrations`;
const redirectURL = oAuthBody.redirectURL ?? `${env.APP_ORIGIN}/integrations`;
logger.info(
`We got OAuth request for ${workspaceId}: ${integrationDefinitionId}`,
@ -212,7 +218,6 @@ export async function getRedirectURL(
workspaceId: workspaceId as string,
config: externalConfig,
userId,
personal,
};
const scopes = [
@ -242,3 +247,66 @@ export async function getRedirectURL(
throw new Error(e.message);
}
}
export async function getRedirectURLForMCP(
oAuthBody: OAuthBodyInterface,
userId: string,
workspaceId?: string,
) {
const { integrationDefinitionId } = oAuthBody;
logger.info(
`We got OAuth request for ${workspaceId}: ${userId}: ${integrationDefinitionId}`,
);
const redirectURL = oAuthBody.redirectURL ?? `${env.APP_ORIGIN}/integrations`;
const integrationDefinition = await getIntegrationDefinitionWithId(
integrationDefinitionId,
);
if (!integrationDefinition) {
throw new Error("No integration definition found");
}
const spec = integrationDefinition.spec as any;
if (!spec.mcpAuth) {
throw new Error("MCP auth configuration not found for this integration");
}
const { serverUrl, transportStrategy } = spec.mcpAuth;
const authClient = createMCPAuthClient({
serverUrl,
transportStrategy: transportStrategy || "sse-first",
redirectUrl: MCP_CALLBACK_URL,
});
const { authUrl, state } = await authClient.getAuthorizationURL({
scope: "read write",
});
mcpSession[state] = {
integrationDefinitionId: integrationDefinition.id,
redirectURL,
};
return {
status: 200,
redirectURL: authUrl,
};
}
export async function getIntegrationDefinitionForState(state: string) {
if (!state) {
throw new Error("No state found");
}
const sessionRecord = mcpSession[state];
// Delete the session once it's used
delete mcpSession[state];
return sessionRecord;
}

View File

@ -25,7 +25,7 @@
"@modelcontextprotocol/sdk": "1.13.2",
"@nichtsam/remix-auth-email-link": "3.0.0",
"@opentelemetry/api": "1.9.0",
"@paciolan/remote-module-loader": "^3.0.3",
"@core/mcp-proxy": "workspace:*",
"@prisma/client": "*",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",

View File

@ -0,0 +1,22 @@
{
"arrowParens": "always",
"tabWidth": 2,
"useTabs": false,
"semi": true,
"bracketSpacing": true,
"jsxBracketSameLine": false,
"requirePragma": false,
"proseWrap": "preserve",
"singleQuote": true,
"formatOnSave": true,
"trailingComma": "all",
"printWidth": 100,
"overrides": [
{
"files": ".prettierrc",
"options": {
"parser": "json"
}
}
]
}

View File

@ -0,0 +1,19 @@
import axios from 'axios';
export async function integrationCreate(data: any, integrationDefinition: any) {
const { api_key } = data;
const integrationConfiguration = {
api_key: api_key,
};
const payload = {
settings: {},
accountId: 'linear-account', // Linear doesn't have a specific account ID
config: integrationConfiguration,
integrationDefinitionId: integrationDefinition.id,
};
const integrationAccount = (await axios.post(`/api/v1/integration_account`, payload)).data;
return integrationAccount;
}

View File

@ -0,0 +1,58 @@
import { handleSchedule } from 'schedule';
import { integrationCreate } from './account-create';
import {
IntegrationCLI,
IntegrationEventPayload,
IntegrationEventType,
Spec,
} from '@redplanethq/sdk';
export async function run(eventPayload: IntegrationEventPayload) {
switch (eventPayload.event) {
case IntegrationEventType.SETUP:
return await integrationCreate(eventPayload.eventBody, eventPayload.integrationDefinition);
case IntegrationEventType.SYNC:
return await handleSchedule(eventPayload.config);
default:
return { message: `The event payload type is ${eventPayload.event}` };
}
}
// CLI implementation that extends the base class
class LinearCLI extends IntegrationCLI {
constructor() {
super('linear', '1.0.0');
}
protected async handleEvent(eventPayload: IntegrationEventPayload): Promise<any> {
return await run(eventPayload);
}
protected async getSpec(): Promise<Spec> {
return {
name: 'Linear extension',
key: 'linear',
description:
'Plan, track, and manage your agile and software development projects in Linear. Customize your workflow, collaborate, and release great software.',
icon: 'linear',
auth: {
api_key: {
header_name: 'Authorization',
format: '',
},
},
};
}
}
// Define a main function and invoke it directly.
// This works after bundling to JS and running with `node index.js`.
function main() {
const linearCLI = new LinearCLI();
linearCLI.parse();
}
main();

View File

@ -0,0 +1,599 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import axios from 'axios';
import { IntegrationAccount } from '@redplanethq/sol-sdk';
interface LinearActivityCreateParams {
url: string;
title: string;
sourceId: string;
sourceURL: string;
integrationAccountId: string;
}
interface LinearSettings {
lastIssuesSync?: string;
lastCommentsSync?: string;
lastUserActionsSync?: string;
}
// Event types to track for user activities
enum LinearEventType {
ISSUE_CREATED = 'issue_created',
ISSUE_UPDATED = 'issue_updated',
ISSUE_COMMENTED = 'issue_commented',
ISSUE_ASSIGNED = 'issue_assigned',
ISSUE_STATUS_CHANGED = 'issue_status_changed',
ISSUE_COMPLETED = 'issue_completed',
ISSUE_REOPENED = 'issue_reopened',
USER_MENTIONED = 'user_mentioned',
REACTION_ADDED = 'reaction_added',
ISSUE_SUBSCRIBED = 'issue_subscribed',
ISSUE_PRIORITY_CHANGED = 'issue_priority_changed',
PROJECT_UPDATED = 'project_updated',
CYCLE_UPDATED = 'cycle_updated',
}
// GraphQL fragments for reuse
const USER_FRAGMENT = `
fragment UserFields on User {
id
name
displayName
}
`;
const ISSUE_FRAGMENT = `
fragment IssueFields on Issue {
id
identifier
title
description
url
createdAt
updatedAt
archivedAt
state {
id
name
type
}
team {
id
name
}
assignee {
...UserFields
}
creator {
...UserFields
}
subscribers {
nodes {
...UserFields
}
}
priority
}
${USER_FRAGMENT}
`;
const COMMENT_FRAGMENT = `
fragment CommentFields on Comment {
id
body
createdAt
updatedAt
user {
...UserFields
}
issue {
...IssueFields
}
}
${USER_FRAGMENT}
${ISSUE_FRAGMENT}
`;
/**
* Creates an activity in the system based on Linear data
*/
async function createActivity(params: LinearActivityCreateParams) {
try {
// This would call the Sol SDK to create an activity
console.log(`Creating activity: ${params.title}`);
// Would be implemented via Sol SDK similar to GitHub integration
} catch (error) {
console.error('Error creating activity:', error);
}
}
/**
* Fetches user information from Linear
*/
async function fetchUserInfo(accessToken: string) {
try {
const query = `
query {
viewer {
id
name
email
}
}
`;
const response = await axios.post(
'https://api.linear.app/graphql',
{ query },
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data.data.viewer;
} catch (error) {
console.error('Error fetching user info:', error);
throw error;
}
}
/**
* Fetches recent issues relevant to the user (created, assigned, or subscribed)
*/
async function fetchRecentIssues(accessToken: string, lastSyncTime: string) {
try {
const query = `
query RecentIssues($lastSyncTime: DateTime) {
issues(
filter: {
updatedAt: { gt: $lastSyncTime }
},
first: 50,
orderBy: updatedAt
) {
nodes {
...IssueFields
history {
nodes {
id
createdAt
updatedAt
fromStateId
toStateId
fromAssigneeId
toAssigneeId
fromPriority
toPriority
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
${ISSUE_FRAGMENT}
`;
const response = await axios.post(
'https://api.linear.app/graphql',
{
query,
variables: {
lastSyncTime,
},
},
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data.data.issues;
} catch (error) {
console.error('Error fetching recent issues:', error);
throw error;
}
}
/**
* Fetches recent comments on issues relevant to the user
*/
async function fetchRecentComments(accessToken: string, lastSyncTime: string) {
try {
const query = `
query RecentComments($lastSyncTime: DateTime) {
comments(
filter: {
updatedAt: { gt: $lastSyncTime }
},
first: 50,
orderBy: updatedAt
) {
nodes {
...CommentFields
}
pageInfo {
hasNextPage
endCursor
}
}
}
${COMMENT_FRAGMENT}
`;
const response = await axios.post(
'https://api.linear.app/graphql',
{
query,
variables: {
lastSyncTime,
},
},
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data.data.comments;
} catch (error) {
console.error('Error fetching recent comments:', error);
throw error;
}
}
/**
* Process issue activities and create appropriate activity records
*/
async function processIssueActivities(
issues: any[],
userId: string,
integrationAccount: IntegrationAccount,
isCreator: boolean = false,
) {
const activities = [];
for (const issue of issues) {
try {
// Skip issues that don't involve the user
const isAssignee = issue.assignee?.id === userId;
const isCreatedByUser = issue.creator?.id === userId;
// Check if user is subscribed to the issue
const isSubscribed =
issue.subscribers?.nodes?.some((subscriber: any) => subscriber.id === userId) || false;
if (!isAssignee && !isCreatedByUser && !isCreator && !isSubscribed) {
continue;
}
// Process new issues created by the user
if (isCreatedByUser) {
activities.push({
url: `https://api.linear.app/issue/${issue.id}`,
title: `You created issue ${issue.identifier}: ${issue.title}`,
sourceId: `linear-issue-created-${issue.id}`,
sourceURL: issue.url,
integrationAccountId: integrationAccount.id,
});
}
// Process issues assigned to the user (if not created by them)
if (isAssignee && !isCreatedByUser) {
activities.push({
url: `https://api.linear.app/issue/${issue.id}`,
title: `${issue.creator?.name || 'Someone'} assigned you issue ${issue.identifier}: ${issue.title}`,
sourceId: `linear-issue-assigned-${issue.id}`,
sourceURL: issue.url,
integrationAccountId: integrationAccount.id,
});
}
// Process issues where the user is subscribed (if not creator or assignee)
if (isSubscribed && !isCreatedByUser && !isAssignee) {
activities.push({
url: `https://api.linear.app/issue/${issue.id}`,
title: `Update on issue ${issue.identifier} you're subscribed to: ${issue.title}`,
sourceId: `linear-issue-subscribed-${issue.id}`,
sourceURL: issue.url,
integrationAccountId: integrationAccount.id,
});
}
// Process status changes
if (issue.history && issue.history.nodes) {
for (const historyItem of issue.history.nodes) {
if (historyItem.toStateId && historyItem.fromStateId !== historyItem.toStateId) {
// Skip if not relevant to the user
if (!isAssignee && !isCreatedByUser && !isSubscribed) {
continue;
}
const stateType = issue.state?.type;
let eventType = LinearEventType.ISSUE_STATUS_CHANGED;
let statusText = `moved to ${issue.state?.name || 'a new status'}`;
// Special handling for completion and reopening
if (stateType === 'completed') {
eventType = LinearEventType.ISSUE_COMPLETED;
statusText = 'marked as completed';
} else if (stateType === 'canceled') {
statusText = 'canceled';
} else if (historyItem.fromStateId && !historyItem.toStateId) {
eventType = LinearEventType.ISSUE_REOPENED;
statusText = 'reopened';
}
let title;
if (isCreatedByUser || isAssignee) {
title = `You ${statusText} issue ${issue.identifier}: ${issue.title}`;
} else if (isSubscribed) {
title = `Issue ${issue.identifier} you're subscribed to was ${statusText}: ${issue.title}`;
} else {
title = `${issue.assignee?.name || 'Someone'} ${statusText} issue ${issue.identifier}: ${issue.title}`;
}
activities.push({
url: `https://api.linear.app/issue/${issue.id}`,
title,
sourceId: `linear-${eventType}-${issue.id}-${historyItem.id}`,
sourceURL: issue.url,
integrationAccountId: integrationAccount.id,
});
}
// Process priority changes
if (historyItem.toPriority && historyItem.fromPriority !== historyItem.toPriority) {
// Skip if not relevant to the user
if (!isAssignee && !isCreatedByUser && !isSubscribed) {
continue;
}
const priorityMap: Record<number, string> = {
0: 'No priority',
1: 'Urgent',
2: 'High',
3: 'Medium',
4: 'Low',
};
const newPriority = priorityMap[historyItem.toPriority] || 'a new priority';
let title;
if (isCreatedByUser) {
title = `You changed priority of issue ${issue.identifier} to ${newPriority}`;
} else if (isAssignee) {
title = `${issue.creator?.name || 'Someone'} changed priority of your assigned issue ${issue.identifier} to ${newPriority}`;
} else if (isSubscribed) {
title = `Priority of issue ${issue.identifier} you're subscribed to changed to ${newPriority}`;
} else {
title = `${issue.creator?.name || 'Someone'} changed priority of issue ${issue.identifier} to ${newPriority}`;
}
activities.push({
url: `https://api.linear.app/issue/${issue.id}`,
title,
sourceId: `linear-issue-priority-${issue.id}-${historyItem.id}`,
sourceURL: issue.url,
integrationAccountId: integrationAccount.id,
});
}
// Process assignment changes
if (historyItem.toAssigneeId && historyItem.fromAssigneeId !== historyItem.toAssigneeId) {
// Only relevant if user is newly assigned or is the creator
if (historyItem.toAssigneeId !== userId && !isCreatedByUser) {
continue;
}
const title =
historyItem.toAssigneeId === userId
? `You were assigned issue ${issue.identifier}: ${issue.title}`
: `You assigned issue ${issue.identifier} to ${issue.assignee?.name || 'someone'}`;
activities.push({
url: `https://api.linear.app/issue/${issue.id}`,
title,
sourceId: `linear-issue-reassigned-${issue.id}-${historyItem.id}`,
sourceURL: issue.url,
integrationAccountId: integrationAccount.id,
});
}
}
}
} catch (error) {
console.error(`Error processing issue ${issue.id}:`, error);
}
}
// Create activities in the system
for (const activity of activities) {
await createActivity(activity);
}
return activities.length;
}
/**
* Process comment activities and create appropriate activity records
*/
async function processCommentActivities(
comments: any[],
userId: string,
integrationAccount: IntegrationAccount,
) {
const activities = [];
for (const comment of comments) {
try {
const isCommenter = comment.user?.id === userId;
const isIssueCreator = comment.issue?.creator?.id === userId;
const isAssignee = comment.issue?.assignee?.id === userId;
// Check if user is subscribed to the issue
const isSubscribed =
comment.issue?.subscribers?.nodes?.some((subscriber: any) => subscriber.id === userId) ||
false;
// Skip if not relevant to user
if (!isCommenter && !isIssueCreator && !isAssignee && !isSubscribed) {
// TODO: Check for mentions in the comment body
continue;
}
let title;
let sourceId;
if (isCommenter) {
// Comment created by the user
title = `You commented on issue ${comment.issue.identifier}: ${truncateText(comment.body, 100)}`;
sourceId = `linear-comment-created-${comment.id}`;
} else if (isAssignee || isIssueCreator || isSubscribed) {
// Comment on issue where user is assignee, creator, or subscriber
let relation = 'an issue';
if (isAssignee) {
relation = 'your assigned issue';
} else if (isIssueCreator) {
relation = 'your issue';
} else if (isSubscribed) {
relation = "an issue you're subscribed to";
}
title = `${comment.user?.name || 'Someone'} commented on ${relation} ${comment.issue.identifier}: ${truncateText(comment.body, 100)}`;
sourceId = `linear-comment-received-${comment.id}`;
}
if (title && sourceId) {
activities.push({
url: `https://api.linear.app/comment/${comment.id}`,
title,
sourceId,
sourceURL: `${comment.issue.url}#comment-${comment.id}`,
integrationAccountId: integrationAccount.id,
});
}
} catch (error) {
console.error(`Error processing comment ${comment.id}:`, error);
}
}
// Create activities in the system
for (const activity of activities) {
await createActivity(activity);
}
return activities.length;
}
/**
* Helper function to truncate text with ellipsis
*/
function truncateText(text: string, maxLength: number): string {
if (!text) return '';
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
}
/**
* Helper function to get default sync time (24 hours ago)
*/
function getDefaultSyncTime(): string {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return yesterday.toISOString();
}
/**
* Main function to handle scheduled sync for Linear integration
*/
export async function handleSchedule(integrationAccount: IntegrationAccount) {
try {
const integrationConfiguration = integrationAccount.integrationConfiguration as any;
// Check if we have a valid access token
if (!integrationConfiguration?.accessToken) {
console.error('No access token found for Linear integration');
return { message: 'No access token found' };
}
// Get settings or initialize if not present
const settings = (integrationAccount.settings || {}) as LinearSettings;
// Default to 24 hours ago if no last sync times
const lastIssuesSync = settings.lastIssuesSync || getDefaultSyncTime();
const lastCommentsSync = settings.lastCommentsSync || getDefaultSyncTime();
// Fetch user info to identify activities relevant to them
const user = await fetchUserInfo(integrationConfiguration.accessToken);
if (!user || !user.id) {
console.error('Failed to fetch user info from Linear');
return { message: 'Failed to fetch user info' };
}
// Process all issue activities (created, assigned, updated, etc.)
let issueCount = 0;
try {
const issues = await fetchRecentIssues(integrationConfiguration.accessToken, lastIssuesSync);
if (issues && issues.nodes) {
issueCount = await processIssueActivities(issues.nodes, user.id, integrationAccount);
}
} catch (error) {
console.error('Error processing issues:', error);
}
// Process all comment activities
let commentCount = 0;
try {
const comments = await fetchRecentComments(
integrationConfiguration.accessToken,
lastCommentsSync,
);
if (comments && comments.nodes) {
commentCount = await processCommentActivities(comments.nodes, user.id, integrationAccount);
}
} catch (error) {
console.error('Error processing comments:', error);
}
// TODO: Implement additional activity types:
// - Reaction tracking
// - PR/Merge request tracking (if supported by Linear)
// - Project and cycle updates
// - Team updates and notifications
// - Mention detection in descriptions and comments
// Update last sync times
const newSyncTime = new Date().toISOString();
// Save new settings
integrationAccount.settings = {
...settings,
lastIssuesSync: newSyncTime,
lastCommentsSync: newSyncTime,
};
return {
message: `Synced ${issueCount} issues and ${commentCount} comments from Linear`,
};
} catch (error) {
console.error('Error in Linear scheduled sync:', error);
return {
message: `Error syncing Linear activities: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
}
/**
* The main handler for the scheduled sync event
*/
export async function scheduleHandler(integrationAccount: IntegrationAccount) {
return handleSchedule(integrationAccount);
}

View File

@ -0,0 +1,98 @@
const eslint = require('@eslint/js');
const tseslint = require('typescript-eslint');
const reactPlugin = require('eslint-plugin-react');
const jestPlugin = require('eslint-plugin-jest');
const importPlugin = require('eslint-plugin-import');
const prettierPlugin = require('eslint-plugin-prettier');
const unusedImportsPlugin = require('eslint-plugin-unused-imports');
const jsxA11yPlugin = require('eslint-plugin-jsx-a11y');
module.exports = [
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
react: reactPlugin,
jest: jestPlugin,
import: importPlugin,
prettier: prettierPlugin,
'unused-imports': unusedImportsPlugin,
'jsx-a11y': jsxA11yPlugin,
},
languageOptions: {
ecmaVersion: 2020,
sourceType: 'module',
parser: tseslint.parser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
'jsx-a11y/label-has-associated-control': 'error',
curly: 'warn',
'dot-location': 'warn',
eqeqeq: 'error',
'prettier/prettier': 'warn',
'unused-imports/no-unused-imports': 'warn',
'no-else-return': 'warn',
'no-lonely-if': 'warn',
'no-inner-declarations': 'off',
'no-unused-vars': 'off',
'no-useless-computed-key': 'warn',
'no-useless-return': 'warn',
'no-var': 'warn',
'object-shorthand': ['warn', 'always'],
'prefer-arrow-callback': 'warn',
'prefer-const': 'warn',
'prefer-destructuring': ['warn', { AssignmentExpression: { array: true } }],
'prefer-object-spread': 'warn',
'prefer-template': 'warn',
'spaced-comment': ['warn', 'always', { markers: ['/'] }],
yoda: 'warn',
'import/order': [
'warn',
{
'newlines-between': 'always',
groups: ['type', 'builtin', 'external', 'internal', ['parent', 'sibling'], 'index'],
pathGroupsExcludedImportTypes: ['builtin'],
pathGroups: [],
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
'@typescript-eslint/array-type': ['warn', { default: 'array-simple' }],
'@typescript-eslint/ban-ts-comment': [
'warn',
{
'ts-expect-error': 'allow-with-description',
},
],
'@typescript-eslint/consistent-indexed-object-style': ['warn', 'record'],
'@typescript-eslint/consistent-type-definitions': ['warn', 'interface'],
'@typescript-eslint/no-unused-vars': 'warn',
'react/function-component-definition': [
'warn',
{
namedComponents: 'arrow-function',
unnamedComponents: 'arrow-function',
},
],
'react/jsx-boolean-value': 'warn',
'react/jsx-curly-brace-presence': 'warn',
'react/jsx-fragments': 'warn',
'react/jsx-no-useless-fragment': ['warn', { allowExpressions: true }],
'react/self-closing-comp': 'warn',
},
},
{
files: ['scripts/**/*'],
rules: {
'@typescript-eslint/no-var-requires': 'off',
},
},
];

View File

@ -0,0 +1,71 @@
{
"name": "@core/linear",
"version": "0.1.2",
"description": "linear extension for CORE",
"main": "./bin/index.js",
"module": "./bin/index.mjs",
"type": "module",
"files": [
"linear",
"bin"
],
"bin": {
"linear": "./bin/index.js"
},
"scripts": {
"build": "rimraf bin && npx tsup",
"lint": "eslint --ext js,ts,tsx backend/ frontend/ --fix",
"prettier": "prettier --config .prettierrc --write ."
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@babel/preset-typescript": "^7.26.0",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-replace": "^5.0.7",
"@types/node": "^18.0.20",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jest": "^27.9.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-unused-imports": "^2.0.0",
"prettier": "^3.4.2",
"rimraf": "^3.0.2",
"rollup": "^4.28.1",
"rollup-plugin-node-polyfills": "^0.2.1",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.34.1",
"tslib": "^2.8.1",
"typescript": "^4.7.2",
"tsup": "^8.0.1",
"ncc": "0.3.6"
},
"publishConfig": {
"access": "public"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"dependencies": {
"axios": "^1.7.9",
"commander": "^12.0.0",
"openai": "^4.0.0",
"react-query": "^3.39.3",
"@redplanethq/sdk": "0.1.0"
}
}

13881
integrations/linear/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,71 @@
import commonjs from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
import resolve from '@rollup/plugin-node-resolve';
import nodePolyfills from 'rollup-plugin-node-polyfills';
import postcss from 'rollup-plugin-postcss';
import { babel } from '@rollup/plugin-babel';
import { terser } from 'rollup-plugin-terser';
import typescript from 'rollup-plugin-typescript2';
const frontendPlugins = [
postcss({
inject: true, // Inject CSS as JS, making it part of the bundle
minimize: true, // Minify CSS
}),
json(),
resolve({ extensions: ['.js', '.jsx', '.ts', '.tsx'] }),
commonjs({
include: /\/node_modules\//,
}),
typescript({
tsconfig: 'tsconfig.frontend.json',
}),
babel({
extensions: ['.js', '.jsx', '.ts', '.tsx'],
presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'],
}),
terser(),
];
export default [
{
input: 'frontend/index.tsx',
external: ['react', 'react-dom', '@tegonhq/ui', 'axios', 'react-query'],
output: [
{
file: 'dist/frontend/index.js',
sourcemap: true,
format: 'cjs',
exports: 'named',
preserveModules: false,
inlineDynamicImports: true,
},
],
plugins: frontendPlugins,
},
{
input: 'backend/index.ts',
external: ['axios'],
output: [
{
file: 'dist/backend/index.js',
sourcemap: true,
format: 'cjs',
exports: 'named',
preserveModules: false,
},
],
plugins: [
nodePolyfills(),
json(),
resolve({ extensions: ['.js', '.ts'] }),
commonjs({
include: /\/node_modules\//,
}),
typescript({
tsconfig: 'tsconfig.json',
}),
terser(),
],
},
];

View File

@ -0,0 +1,19 @@
{
"name": "Linear extension",
"key": "linear",
"description": "Plan, track, and manage your agile and software development projects in Linear. Customize your workflow, collaborate, and release great software.",
"icon": "linear",
"schedule": {
"frequency": "*/5 * * * *"
},
"auth": {
"api_key": {
"type": "string",
"label": "Linear API Key"
}
},
"mcpAuth": {
"serverUrl": "https://mcp.linear.app/sse",
"transportStrategy": "sse-first"
}
}

View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["dom", "dom.iterable", "esnext"],
"baseUrl": "frontend",
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"strictNullChecks": true,
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true,
"noUnusedParameters": true,
"noUnusedLocals": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitAny": true,
"noFallthroughCasesInSwitch": true,
"useUnknownInCatchVariables": false
},
"include": ["frontend/**/*"],
"exclude": ["node_modules", "build", "dist", "scripts", "acceptance-tests", "webpack", "jest"],
"types": ["typePatches"]
}

View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["dom", "dom.iterable", "esnext"],
"baseUrl": "backend",
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"strictNullChecks": true,
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true,
"noUnusedParameters": true,
"noUnusedLocals": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitAny": true,
"noFallthroughCasesInSwitch": true,
"useUnknownInCatchVariables": false
},
"include": ["backend/**/*"],
"exclude": ["node_modules", "build", "dist", "scripts", "acceptance-tests", "webpack", "jest"],
"types": ["typePatches"]
}

View File

@ -1,5 +1,5 @@
{
"name": "@sol/slack",
"name": "@core/slack",
"version": "0.1.2",
"description": "slack extension for Sol",
"main": "./bin/index.js",

View File

@ -1,5 +1,3 @@
// import { IntegrationPayloadEventType } from '@redplanethq/sol-sdk';
import { integrationCreate } from './account-create';
import { createActivityEvent } from './create-activity';
import {

3
packages/mcp-proxy/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
dist/
node_modules/

View File

@ -0,0 +1,163 @@
# MCP Auth Proxy
A simplified, callback-based authentication and proxy library for Model Context Protocol (MCP) servers with OAuth support.
## Features
- **🔐 OAuth Authentication**: Handle OAuth flows for any MCP server
- **📦 In-Memory Processing**: No file storage - everything through callbacks
- **🔄 Generic Server Support**: Works with any MCP server URL
- **⚡ Transport Flexibility**: Supports both SSE and HTTP transports
- **🛡️ Callback-Based Storage**: You control how credentials are saved/loaded
- **🧹 Self-Contained**: No external dependencies on other packages
## Installation
```bash
npm install mcp-auth-proxy
```
## Quick Start
### 1. Authentication (Two-Step Process)
```typescript
import { createMCPAuthClient } from 'mcp-auth-proxy'
const authClient = createMCPAuthClient(
{
serverUrl: 'https://mcp.example.com/sse',
clientName: 'My App'
},
// Callback to save credentials to your database
async (credentials) => {
await db.saveCredentials({
serverUrl: credentials.serverUrl,
accessToken: credentials.tokens.access_token,
refreshToken: credentials.tokens.refresh_token,
expiresAt: credentials.expiresAt
})
}
)
// Step 1: Get authorization URL
const authFlow = await authClient.initiateAuth()
console.log('Redirect user to:', authFlow.authUrl)
// Save authFlow.state - you'll need it for step 2
// Step 2: Complete authentication (in your OAuth callback route)
const result = await authClient.completeAuth({
code: 'code_from_oauth_callback',
state: authFlow.state // Must match from step 1
})
```
### 2. Proxy
```typescript
import { createMCPProxy } from 'mcp-auth-proxy'
const mcpProxy = createMCPProxy(
{
serverUrl: 'https://mcp.example.com/sse'
},
// Callback to load credentials from your database
async (userApiKey, serverUrl) => {
const creds = await db.getCredentials(userApiKey, serverUrl)
return creds ? {
serverUrl: creds.serverUrl,
tokens: {
access_token: creds.accessToken,
refresh_token: creds.refreshToken,
token_type: 'Bearer',
expires_in: Math.floor((creds.expiresAt.getTime() - Date.now()) / 1000)
},
expiresAt: creds.expiresAt
} : null
}
)
// Use in your API route
export async function POST(request: Request) {
const userApiKey = getUserApiKey(request)
return await mcpProxy(request, userApiKey)
}
```
## API Reference
### `createMCPAuthClient(config, onCredentialSave)`
Creates an authentication client for OAuth flows.
**Parameters:**
- `config: MCPRemoteClientConfig` - Configuration for the MCP server
- `onCredentialSave: (credentials: StoredCredentials) => Promise<void>` - Callback to save credentials
**Returns:** `MCPAuthenticationClient`
### `createMCPProxy(config, onCredentialLoad)`
Creates a proxy function for forwarding requests to MCP servers.
**Parameters:**
- `config: ProxyConnectionConfig` - Configuration for the proxy
- `onCredentialLoad: (userApiKey: string, serverUrl: string) => Promise<ProxyCredentials | null>` - Callback to load credentials
**Returns:** `MCPProxyFunction`
## Configuration Options
### MCPRemoteClientConfig
```typescript
interface MCPRemoteClientConfig {
serverUrl: string // MCP server URL
clientName?: string // OAuth client name
callbackPort?: number // OAuth callback port
host?: string // Callback host (default: localhost)
transportStrategy?: TransportStrategy // 'sse-first' | 'http-first' | 'sse-only' | 'http-only'
headers?: Record<string, string> // Additional headers
}
```
### ProxyConnectionConfig
```typescript
interface ProxyConnectionConfig {
serverUrl: string // MCP server URL
transportStrategy?: TransportStrategy // Transport preference
timeout?: number // Request timeout (default: 30000ms)
headers?: Record<string, string> // Additional headers
}
```
## Error Handling
```typescript
import {
MCPAuthProxyError,
InvalidCredentialsError,
OAuthError,
TransportError
} from 'mcp-auth-proxy'
try {
await authClient.authenticate()
} catch (error) {
if (error instanceof OAuthError) {
console.log('OAuth flow failed:', error.message)
} else if (error instanceof TransportError) {
console.log('Connection failed:', error.message)
}
}
```
## Examples
- [Remix Integration](./REMIX_INTEGRATION.md) - Complete Remix.run integration guide
- [Simple Usage](./examples/simple-usage.ts) - Basic usage examples
## License
MIT

View File

@ -0,0 +1,46 @@
{
"name": "@core/mcp-proxy",
"version": "0.1.0",
"description": "Authentication proxy for Model Context Protocol (MCP) servers with OAuth support",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "npm run build:tsup",
"build:tsup": "tsup --dts-resolve",
"dev": "tsup --watch",
"clean": "rm -rf dist",
"prepublishOnly": "npm run clean && npm run build"
},
"keywords": [
"mcp",
"model-context-protocol",
"oauth",
"authentication",
"proxy",
"linear",
"api"
],
"author": "",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/eventsource": "^1.1.12",
"tsup": "^8.0.1",
"typescript": "^5.0.0"
},
"files": [
"dist/**/*",
"README.md",
"package.json"
],
"engines": {
"node": ">=18.0.0"
}
}

View File

@ -0,0 +1,403 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import {
MCPRemoteClientConfig,
AuthenticationResult,
ProxyConnectionConfig,
CredentialSaveCallback,
CredentialLoadCallback,
MCPProxyFunction,
StoredCredentials,
TransportStrategy,
} from "../types/remote-client.js";
import { MCPAuthProxyError, OAuthError } from "../utils/errors.js";
import { NodeOAuthClientProvider } from "../lib/node-oauth-client-provider.js";
import { globalAuthStorage } from "../lib/in-memory-auth-storage.js";
import { getServerUrlHash } from "../lib/utils.js";
import { RemixMCPTransport } from "../utils/mcp-transport.js";
import { createMCPTransportBridge } from "../utils/mcp-transport-bridge.js";
import {
createAuthProviderFromConfig,
createAuthProviderForProxy,
} from "../utils/auth-provider-factory.js";
/**
* Creates an MCP authentication client that handles OAuth flow
* @param config Configuration for the MCP service
* @param onCredentialSave Callback to save credentials to your database
* @returns Authentication client with OAuth capabilities
*/
export function createMCPAuthClient(
config: MCPRemoteClientConfig,
onCredentialSave?: CredentialSaveCallback
): MCPAuthenticationClient {
return new MCPAuthenticationClient(config, onCredentialSave);
}
/**
* Creates an MCP proxy that forwards requests to the remote MCP server
* Consolidates all proxy functionality into a single function
* @param config Configuration for the proxy connection
* @param onCredentialLoad Callback to load credentials from your database
* @returns Proxy function that can be used in your Remix API routes
*/
export function createMCPProxy(
config: ProxyConnectionConfig & {
/** Enable debug logging */
debug?: boolean;
},
onCredentialLoad: CredentialLoadCallback
): MCPProxyFunction {
return async (request: Request, userApiKey: string): Promise<Response> => {
return new Promise<Response>(async (resolve) => {
let bridge: any = null;
try {
// Load credentials for this user and server
const credentials = await onCredentialLoad(userApiKey, config.serverUrl);
if (!credentials) {
return resolve(
new Response(
JSON.stringify({
error: "No credentials found for this service",
}),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
)
);
}
// Check if tokens are expired
if (credentials.expiresAt && credentials.expiresAt < new Date()) {
return resolve(
new Response(
JSON.stringify({
error: "Credentials expired - please re-authenticate",
}),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
)
);
}
// Create remote transport (connects to the MCP server) FIRST
const serverTransport = await createRemoteTransport(
credentials.serverUrl,
credentials,
config.redirectUrl,
config.transportStrategy || "sse-first"
);
// Start server transport and wait for connection
await serverTransport.start();
// Create Remix transport (converts HTTP to MCP messages)
const clientTransport = new RemixMCPTransport(request, resolve);
// Bridge the transports
const bridgeOptions: any = {
debug: config.debug || false,
onError: (error: Error, source: string) => {
console.error(`[MCP Bridge] ${source} error:`, error);
},
};
if (config.debug) {
bridgeOptions.onMessage = (direction: string, message: any) => {
console.log(`[MCP Bridge] ${direction}:`, message.method || message.id);
};
}
bridge = createMCPTransportBridge(
clientTransport as any,
serverTransport as any,
bridgeOptions
);
// Set up timeout
const timeoutId = config.timeout
? setTimeout(() => {
bridge?.close().catch(console.error);
if (!resolve) return;
resolve(
new Response(
JSON.stringify({
error: "Request timeout",
}),
{
status: 408,
headers: { "Content-Type": "application/json" },
}
)
);
}, config.timeout)
: null;
// Start only the client transport (server is already started)
await clientTransport.start();
// Clean up after a reasonable time (since HTTP is request/response)
setTimeout(() => {
if (timeoutId) clearTimeout(timeoutId);
bridge?.close().catch(console.error);
}, 1000);
} catch (error) {
console.error("MCP Transport Proxy Error:", error);
if (bridge) {
bridge.close().catch(console.error);
}
const errorMessage = error instanceof Error ? error.message : String(error);
resolve(
new Response(
JSON.stringify({
error: `Transport proxy error: ${errorMessage}`,
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
);
}
});
};
// Helper function to create remote transport
async function createRemoteTransport(
serverUrl: string,
credentials: StoredCredentials,
redirectUrl: string,
transportStrategy: TransportStrategy = "sse-first"
): Promise<SSEClientTransport | StreamableHTTPClientTransport> {
// Create auth provider with stored credentials using common factory
const authProvider = await createAuthProviderForProxy(serverUrl, credentials, redirectUrl);
const url = new URL(serverUrl);
const headers = {
Authorization: `Bearer ${credentials.tokens.access_token}`,
"Content-Type": "application/json",
...config.headers,
};
// Create transport based on strategy (don't start yet)
let transport: SSEClientTransport | StreamableHTTPClientTransport;
// For SSE, we need eventSourceInit for authentication
const eventSourceInit = {
fetch: (url: string | URL, init?: RequestInit) => {
return fetch(url, {
...init,
headers: {
...(init?.headers as Record<string, string> | undefined),
...headers,
Accept: "text/event-stream",
} as Record<string, string>,
});
},
};
switch (transportStrategy) {
case "sse-only":
transport = new SSEClientTransport(url, {
authProvider,
requestInit: { headers },
eventSourceInit,
});
break;
case "http-only":
transport = new StreamableHTTPClientTransport(url, {
authProvider,
requestInit: { headers },
});
break;
case "sse-first":
// Try SSE first, fallback to HTTP on error
try {
transport = new SSEClientTransport(url, {
authProvider,
requestInit: { headers },
eventSourceInit,
});
} catch (error) {
console.warn("SSE transport failed, falling back to HTTP:", error);
transport = new StreamableHTTPClientTransport(url, {
authProvider,
requestInit: { headers },
});
}
break;
case "http-first":
// Try HTTP first, fallback to SSE on error
try {
transport = new StreamableHTTPClientTransport(url, {
authProvider,
requestInit: { headers },
});
} catch (error) {
console.warn("HTTP transport failed, falling back to SSE:", error);
transport = new SSEClientTransport(url, {
authProvider,
requestInit: { headers },
eventSourceInit,
});
}
break;
default:
throw new Error(`Unknown transport strategy: ${transportStrategy}`);
}
return transport;
}
}
export class MCPAuthenticationClient {
private serverUrlHash: string;
private authProvider: NodeOAuthClientProvider | null = null;
private client: Client | null = null;
constructor(
private config: MCPRemoteClientConfig,
private onCredentialSave?: CredentialSaveCallback
) {
this.serverUrlHash = getServerUrlHash(config.serverUrl);
// Validate configuration
this.validateConfig();
}
/**
* Gets the auth provider instance, creating it if needed
* This method can be called independently to get auth provider for other uses
*/
getAuthProvider(): NodeOAuthClientProvider {
if (!this.authProvider) {
this.authProvider = createAuthProviderFromConfig(this.config);
}
return this.authProvider;
}
private validateConfig(): void {
if (!this.config.serverUrl) {
throw new MCPAuthProxyError("Server URL is required", "INVALID_CONFIG");
}
const url = new URL(this.config.serverUrl);
const isLocalhost =
(url.hostname === "localhost" || url.hostname === "127.0.0.1") && url.protocol === "http:";
if (!(url.protocol === "https:" || isLocalhost)) {
throw new MCPAuthProxyError("Only HTTPS URLs are allowed (except localhost)", "INVALID_URL");
}
}
/**
* Gets authorization URL for OAuth flow (alternative to initiateAuth)
* @param options OAuth options
* @returns Authorization URL string
*/
async getAuthorizationURL(
options: {
scope?: string;
resourceMetadataUrl?: string;
} = {}
): Promise<{ authUrl: string; state: string }> {
try {
const authProvider = this.getAuthProvider();
return await authProvider.authorizationURL(options);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new MCPAuthProxyError(
`Failed to get authorization URL: ${errorMessage}`,
"AUTH_URL_FAILED"
);
}
}
/**
* Completes OAuth flow with authorization code
* Supports both new persistent flow and legacy state validation
* @param options OAuth completion options
* @returns Success status
*/
async completeOAuthFlow(options: {
authorizationCode: string;
state?: string;
scope?: string;
resourceMetadataUrl?: string;
}): Promise<AuthenticationResult> {
try {
const authProvider = this.getAuthProvider();
// State validation (if state is provided - for backward compatibility)
if (options.state) {
const providerState = authProvider.state?.() || "";
if (options.state !== providerState) {
throw new OAuthError("Invalid state parameter - possible CSRF attack");
}
}
// Use the NodeOAuthClientProvider's completeAuth method
await authProvider.completeAuth({
authorizationCode: options.authorizationCode,
...(options.scope && { scope: options.scope }),
...(options.resourceMetadataUrl && {
resourceMetadataUrl: options.resourceMetadataUrl,
}),
});
// Get the saved tokens and client info
const tokens = await authProvider.tokens();
if (!tokens) {
throw new MCPAuthProxyError("No tokens available after OAuth completion", "TOKENS_MISSING");
}
const clientInfo = await authProvider.clientInformation();
const codeVerifier = await authProvider.codeVerifier();
const storedCredentials = clientInfo
? {
serverUrl: this.config.serverUrl,
tokens,
expiresAt: new Date(Date.now() + (tokens.expires_in || 3600) * 1000),
clientInfo,
codeVerifier,
}
: {};
// Clear in-memory storage after successful callback
await globalAuthStorage.clearServerData(this.serverUrlHash);
return {
success: true,
...storedCredentials,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new MCPAuthProxyError(
`Failed to complete OAuth flow: ${errorMessage}`,
"OAUTH_COMPLETION_FAILED"
);
}
}
/**
* Cleanup resources
*/
async cleanup(): Promise<void> {
if (this.client) {
await this.client.close();
}
}
}

View File

@ -0,0 +1,51 @@
// Export types for compatibility
export * from "./types/index.js";
// MCP Remote Client exports (new simplified interface)
export {
createMCPAuthClient,
createMCPProxy,
MCPAuthenticationClient,
} from "./core/mcp-remote-client.js";
export {
type MCPRemoteClientConfig,
type ProxyConnectionConfig,
type TransportStrategy,
type StoredCredentials,
type ProxyCredentials,
type AuthenticationResult,
type CredentialSaveCallback,
type CredentialLoadCallback,
type OAuthFlowResult,
type OAuthCallbackData,
type MCPMessage,
type MCPResponse,
type MCPClientError,
type ConnectionTestResult,
type ProxyHandlerConfig,
type TransportConnection,
type MCPProxyFunction,
} from "./types/remote-client.js";
// Error exports
export {
MCPAuthProxyError,
InvalidCredentialsError,
OAuthError,
ProxyError,
TransportError,
} from "./utils/errors.js";
// Transport utilities for Remix/HTTP integration
export { createMCPTransportBridge } from "./utils/index.js";
// Auth provider utilities - can be used independently
export {
createAuthProvider,
createAuthProviderForProxy,
createAuthProviderFromConfig,
type AuthProviderConfig,
} from "./utils/auth-provider-factory.js";
// Removed createMCPTransportProxy and createSimpleMCPProxy - functionality consolidated into createMCPProxy

View File

@ -0,0 +1,314 @@
import { LockfileData, globalLockManager } from "./in-memory-auth-storage.js";
import { EventEmitter } from "events";
import { log, debugLog, DEBUG } from "./utils.js";
import { Server } from "http";
export type AuthCoordinator = {
initializeAuth: () => Promise<{
server: any;
waitForAuthCode: () => Promise<string>;
skipBrowserAuth: boolean;
}>;
};
/**
* Checks if a process with the given PID is running
*/
export async function isPidRunning(pid: number): Promise<boolean> {
try {
process.kill(pid, 0); // Doesn't kill the process, just checks if it exists
if (DEBUG) debugLog(`Process ${pid} is running`);
return true;
} catch (err) {
if (DEBUG) debugLog(`Process ${pid} is not running`, err);
return false;
}
}
/**
* Checks if a lockfile is valid (process running and endpoint accessible)
*/
export async function isLockValid(lockData: LockfileData): Promise<boolean> {
if (DEBUG) debugLog("Checking if lockfile is valid", lockData);
// Check if the lockfile is too old (over 30 minutes)
const MAX_LOCK_AGE = 30 * 60 * 1000; // 30 minutes
if (Date.now() - lockData.timestamp > MAX_LOCK_AGE) {
log("Lockfile is too old");
if (DEBUG)
debugLog("Lockfile is too old", {
age: Date.now() - lockData.timestamp,
maxAge: MAX_LOCK_AGE,
});
return false;
}
// Check if the process is still running
if (!(await isPidRunning(lockData.pid))) {
log("Process from lockfile is not running");
if (DEBUG)
debugLog("Process from lockfile is not running", { pid: lockData.pid });
return false;
}
// Check if the endpoint is accessible
try {
if (DEBUG)
debugLog("Checking if endpoint is accessible", { port: lockData.port });
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1000);
const response = await fetch(
`http://127.0.0.1:${lockData.port}/wait-for-auth?poll=false`,
{
signal: controller.signal,
}
);
clearTimeout(timeout);
const isValid = response.status === 200 || response.status === 202;
if (DEBUG)
debugLog(`Endpoint check result: ${isValid ? "valid" : "invalid"}`, {
status: response.status,
});
return isValid;
} catch (error) {
log(`Error connecting to auth server: ${(error as Error).message}`);
if (DEBUG) debugLog("Error connecting to auth server", error);
return false;
}
}
/**
* Waits for authentication from another server instance
*/
export async function waitForAuthentication(port: number): Promise<boolean> {
log(`Waiting for authentication from the server on port ${port}...`);
try {
let attempts = 0;
while (true) {
attempts++;
const url = `http://127.0.0.1:${port}/wait-for-auth`;
log(`Querying: ${url}`);
if (DEBUG) debugLog(`Poll attempt ${attempts}`);
try {
const response = await fetch(url);
if (DEBUG) debugLog(`Poll response status: ${response.status}`);
if (response.status === 200) {
// Auth completed, but we don't return the code anymore
log(`Authentication completed by other instance`);
return true;
} else if (response.status === 202) {
// Continue polling
log(`Authentication still in progress`);
if (DEBUG) debugLog(`Will retry in 1s`);
await new Promise((resolve) => setTimeout(resolve, 1000));
} else {
log(`Unexpected response status: ${response.status}`);
return false;
}
} catch (fetchError) {
if (DEBUG) debugLog(`Fetch error during poll`, fetchError);
// If we can't connect, we'll try again after a delay
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
} catch (error) {
log(`Error waiting for authentication: ${(error as Error).message}`);
if (DEBUG) debugLog(`Error waiting for authentication`, error);
return false;
}
}
/**
* Creates a lazy auth coordinator that will only initiate auth when needed
*/
export function createLazyAuthCoordinator(
serverUrlHash: string,
callbackPort: number,
events: EventEmitter
): AuthCoordinator {
let authState: {
server: Server;
waitForAuthCode: () => Promise<string>;
skipBrowserAuth: boolean;
} | null = null;
return {
initializeAuth: async () => {
// If auth has already been initialized, return the existing state
if (authState) {
if (DEBUG) debugLog("Auth already initialized, reusing existing state");
return authState;
}
log("Initializing auth coordination on-demand");
if (DEBUG)
debugLog("Initializing auth coordination on-demand", {
serverUrlHash,
callbackPort,
});
// Initialize auth using the existing coordinateAuth logic
authState = await coordinateAuth(serverUrlHash, callbackPort, events);
if (DEBUG)
debugLog("Auth coordination completed", {
skipBrowserAuth: authState.skipBrowserAuth,
});
return authState;
},
};
}
/**
* Coordinates authentication between multiple instances of the client/proxy
*/
export async function coordinateAuth(
serverUrlHash: string,
callbackPort: number,
events: EventEmitter
): Promise<{
server: Server;
waitForAuthCode: () => Promise<string>;
skipBrowserAuth: boolean;
}> {
if (DEBUG)
debugLog("Coordinating authentication", { serverUrlHash, callbackPort });
// Check for a lockfile (disabled on Windows for the time being)
const lockData =
process.platform === "win32"
? null
: await globalLockManager.checkLockfile(serverUrlHash);
if (DEBUG) {
if (process.platform === "win32") {
debugLog("Skipping lockfile check on Windows");
} else {
debugLog("Lockfile check result", { found: !!lockData, lockData });
}
}
// If there's a valid lockfile, try to use the existing auth process
if (lockData && (await isLockValid(lockData))) {
log(
`Another instance is handling authentication on port ${lockData.port} (pid: ${lockData.pid})`
);
try {
// Try to wait for the authentication to complete
if (DEBUG) debugLog("Waiting for authentication from other instance");
const authCompleted = await waitForAuthentication(lockData.port);
if (authCompleted) {
log(
"Authentication completed by another instance. Using tokens from disk"
);
// OAuth handled externally, no server needed for secondary instance
if (DEBUG) debugLog("Secondary instance, OAuth handled externally");
// This shouldn't actually be called in normal operation, but provide it for API compatibility
const dummyWaitForAuthCode = () => {
log(
"WARNING: waitForAuthCode called in secondary instance - this is unexpected"
);
// Return a promise that never resolves - the client should use the tokens from disk instead
return new Promise<string>(() => {});
};
return {
server: null as any, // OAuth handled externally
waitForAuthCode: dummyWaitForAuthCode,
skipBrowserAuth: true,
};
} else {
log("Taking over authentication process...");
}
} catch (error) {
log(`Error waiting for authentication: ${error}`);
if (DEBUG) debugLog("Error waiting for authentication", error);
}
// If we get here, the other process didn't complete auth successfully
if (DEBUG)
debugLog(
"Other instance did not complete auth successfully, deleting lockfile"
);
await globalLockManager.deleteLockfile(serverUrlHash);
} else if (lockData) {
// Invalid lockfile, delete it
log("Found invalid lockfile, deleting it");
await globalLockManager.deleteLockfile(serverUrlHash);
}
// OAuth callback is handled externally, no need for internal server
if (DEBUG)
debugLog("OAuth handled externally, skipping server setup", {
port: callbackPort,
});
// Use the provided callback port directly
const actualPort = callbackPort;
if (DEBUG)
debugLog("Using external OAuth callback port", { port: actualPort });
log(
`Creating lockfile for server ${serverUrlHash} with process ${process.pid} on port ${actualPort}`
);
await globalLockManager.createLockfile(
serverUrlHash,
process.pid,
actualPort
);
// Dummy function since OAuth callback is handled externally
const waitForAuthCode = (): Promise<string> => {
log("OAuth callback should be handled externally (e.g., by Remix)");
return Promise.reject(
new Error("OAuth callback should be handled externally")
);
};
// Make sure lockfile is deleted on process exit
const cleanupHandler = async () => {
try {
log(`Cleaning up lockfile for server ${serverUrlHash}`);
await globalLockManager.deleteLockfile(serverUrlHash);
} catch (error) {
log(`Error cleaning up lockfile: ${error}`);
if (DEBUG) debugLog("Error cleaning up lockfile", error);
}
};
process.once("exit", () => {
try {
// Synchronous cleanup for in-memory storage
globalLockManager.deleteLockfile(serverUrlHash);
if (DEBUG)
console.error(`[DEBUG] Removed lockfile on exit for: ${serverUrlHash}`);
} catch (error) {
if (DEBUG)
console.error(`[DEBUG] Error removing lockfile on exit:`, error);
}
});
// Also handle SIGINT separately
process.once("SIGINT", async () => {
if (DEBUG) debugLog("Received SIGINT signal, cleaning up");
await cleanupHandler();
});
if (DEBUG)
debugLog("Auth coordination complete, returning primary instance handlers");
return {
server: null as any, // OAuth callback handled externally
waitForAuthCode,
skipBrowserAuth: false,
};
}

View File

@ -0,0 +1,278 @@
import {
OAuthTokens,
OAuthClientInformationFull,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import {
readFileSync,
writeFileSync,
existsSync,
mkdirSync,
unlinkSync,
readdirSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
/**
* In-memory storage for OAuth data during authentication flow
* with automatic persistence to temp files as fallback
*/
export class InMemoryAuthStorage {
private clientInfo = new Map<string, OAuthClientInformationFull>();
private tokens = new Map<string, OAuthTokens>();
private codeVerifiers = new Map<string, string>();
private states = new Map<string, any>();
private tempDir: string;
constructor() {
this.tempDir = join(tmpdir(), "mcp-auth-proxy");
this.ensureTempDir();
}
private ensureTempDir(): void {
if (!existsSync(this.tempDir)) {
mkdirSync(this.tempDir, { recursive: true });
}
}
private getTempFilePath(serverUrlHash: string, type: string): string {
return join(this.tempDir, `${serverUrlHash}_${type}.json`);
}
private saveTempFile(serverUrlHash: string, type: string, data: any): void {
try {
const filePath = this.getTempFilePath(serverUrlHash, type);
writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8");
} catch (error) {
console.warn(`Failed to save temp file for ${type}:`, error);
}
}
private loadTempFile<T>(serverUrlHash: string, type: string): T | null {
try {
const filePath = this.getTempFilePath(serverUrlHash, type);
if (existsSync(filePath)) {
const data = readFileSync(filePath, "utf8");
return JSON.parse(data) as T;
}
} catch (error) {
console.warn(`Failed to load temp file for ${type}:`, error);
}
return null;
}
private deleteTempFile(serverUrlHash: string, type: string): void {
try {
const filePath = this.getTempFilePath(serverUrlHash, type);
if (existsSync(filePath)) {
unlinkSync(filePath);
}
} catch (error) {
console.warn(`Failed to delete temp file for ${type}:`, error);
}
}
// Client Information
async saveClientInformation(
serverUrlHash: string,
clientInformation: OAuthClientInformationFull
): Promise<void> {
this.clientInfo.set(serverUrlHash, clientInformation);
this.saveTempFile(serverUrlHash, "clientInfo", clientInformation);
}
async getClientInformation(
serverUrlHash: string
): Promise<OAuthClientInformationFull | undefined> {
let clientInfo = this.clientInfo.get(serverUrlHash);
if (!clientInfo) {
// Try to load from temp file
clientInfo = this.loadTempFile<OAuthClientInformationFull>(
serverUrlHash,
"clientInfo"
) as any;
if (clientInfo) {
this.clientInfo.set(serverUrlHash, clientInfo);
}
}
return clientInfo || undefined;
}
// OAuth Tokens
async saveTokens(serverUrlHash: string, tokens: OAuthTokens): Promise<void> {
this.tokens.set(serverUrlHash, tokens);
this.saveTempFile(serverUrlHash, "tokens", tokens);
}
async getTokens(serverUrlHash: string): Promise<OAuthTokens | null> {
let tokens = this.tokens.get(serverUrlHash);
if (!tokens) {
// Try to load from temp file
tokens = this.loadTempFile<OAuthTokens>(serverUrlHash, "tokens") as any;
if (tokens) {
this.tokens.set(serverUrlHash, tokens);
}
}
return tokens || null;
}
// Code Verifiers (PKCE)
async saveCodeVerifier(
serverUrlHash: string,
codeVerifier: string
): Promise<void> {
this.codeVerifiers.set(serverUrlHash, codeVerifier);
this.saveTempFile(serverUrlHash, "codeVerifier", codeVerifier);
}
async getCodeVerifier(serverUrlHash: string): Promise<string | null> {
let codeVerifier = this.codeVerifiers.get(serverUrlHash);
if (!codeVerifier) {
// Try to load from temp file
codeVerifier = this.loadTempFile<string>(
serverUrlHash,
"codeVerifier"
) as string;
if (codeVerifier) {
this.codeVerifiers.set(serverUrlHash, codeVerifier);
}
}
return codeVerifier || null;
}
// OAuth States
async saveState(state: string, data: any): Promise<void> {
this.states.set(state, data);
this.saveTempFile(state, "state", data);
}
async getState(state: string): Promise<any | null> {
let stateData = this.states.get(state);
if (!stateData) {
// Try to load from temp file
stateData = this.loadTempFile<any>(state, "state");
if (stateData) {
this.states.set(state, stateData);
}
}
return stateData || null;
}
async deleteState(state: string): Promise<void> {
this.states.delete(state);
this.deleteTempFile(state, "state");
}
// Cleanup methods
async invalidateCredentials(
serverUrlHash: string,
scope: "all" | "client" | "tokens" | "verifier"
): Promise<void> {
switch (scope) {
case "all":
this.clientInfo.delete(serverUrlHash);
this.tokens.delete(serverUrlHash);
this.codeVerifiers.delete(serverUrlHash);
this.deleteTempFile(serverUrlHash, "clientInfo");
this.deleteTempFile(serverUrlHash, "tokens");
this.deleteTempFile(serverUrlHash, "codeVerifier");
break;
case "client":
this.clientInfo.delete(serverUrlHash);
this.deleteTempFile(serverUrlHash, "clientInfo");
break;
case "tokens":
this.tokens.delete(serverUrlHash);
this.deleteTempFile(serverUrlHash, "tokens");
break;
case "verifier":
this.codeVerifiers.delete(serverUrlHash);
this.deleteTempFile(serverUrlHash, "codeVerifier");
break;
}
}
// Get all stored data for a server (useful for the callback)
async getAllDataForServer(serverUrlHash: string): Promise<{
clientInfo: OAuthClientInformationFull | undefined;
tokens: OAuthTokens | undefined;
codeVerifier: string | undefined;
}> {
return {
clientInfo: this.clientInfo.get(serverUrlHash),
tokens: this.tokens.get(serverUrlHash),
codeVerifier: this.codeVerifiers.get(serverUrlHash),
};
}
// Clear all data for a server
async clearServerData(serverUrlHash: string): Promise<void> {
this.clientInfo.delete(serverUrlHash);
this.tokens.delete(serverUrlHash);
this.codeVerifiers.delete(serverUrlHash);
this.deleteTempFile(serverUrlHash, "clientInfo");
this.deleteTempFile(serverUrlHash, "tokens");
this.deleteTempFile(serverUrlHash, "codeVerifier");
}
// Clear all data
async clearAll(): Promise<void> {
this.clientInfo.clear();
this.tokens.clear();
this.codeVerifiers.clear();
this.states.clear();
// Clear all temp files
try {
if (existsSync(this.tempDir)) {
const files = readdirSync(this.tempDir);
for (const file of files) {
if (file.endsWith(".json")) {
unlinkSync(join(this.tempDir, file));
}
}
}
} catch (error) {
console.warn("Failed to clear temp files:", error);
}
}
}
// Lockfile management
export interface LockfileData {
pid: number;
port: number;
timestamp: number;
}
class InMemoryLockManager {
private locks = new Map<string, LockfileData>();
async createLockfile(
serverUrlHash: string,
pid: number,
port: number
): Promise<void> {
this.locks.set(serverUrlHash, {
pid,
port,
timestamp: Date.now(),
});
}
async checkLockfile(serverUrlHash: string): Promise<LockfileData | null> {
return this.locks.get(serverUrlHash) || null;
}
async deleteLockfile(serverUrlHash: string): Promise<void> {
this.locks.delete(serverUrlHash);
}
async clearAll(): Promise<void> {
this.locks.clear();
}
}
// Global instances
export const globalAuthStorage = new InMemoryAuthStorage();
export const globalLockManager = new InMemoryLockManager();

View File

@ -0,0 +1,401 @@
import {
OAuthClientProvider,
discoverOAuthProtectedResourceMetadata,
discoverOAuthMetadata,
startAuthorization,
registerClient,
exchangeAuthorization,
refreshAuthorization,
selectResourceURL,
} from "@modelcontextprotocol/sdk/client/auth.js";
import {
OAuthClientInformationFull,
OAuthClientMetadata,
OAuthTokens,
OAuthMetadata,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { randomUUID } from "node:crypto";
import { getServerUrlHash, log, debugLog, DEBUG, MCP_REMOTE_VERSION } from "./utils.js";
import { globalAuthStorage } from "./in-memory-auth-storage.js";
export interface OAuthProviderOptions {
serverUrl: string;
redirectUrl: string;
clientName?: string;
clientUri?: string;
softwareId?: string;
softwareVersion?: string;
staticOAuthClientMetadata?: Record<string, any> | null;
staticOAuthClientInfo?: Record<string, any> | null;
authorizeResource?: string;
callbackPath?: string;
}
/**
* Implements the OAuthClientProvider interface for Node.js environments.
* Handles OAuth flow and token storage for MCP clients.
*/
export class NodeOAuthClientProvider implements OAuthClientProvider {
private serverUrlHash: string;
redirectUrl: string;
private clientName: string;
private clientUri: string;
private softwareId: string;
private softwareVersion: string;
private staticOAuthClientMetadata: Record<string, any> | null | undefined;
private staticOAuthClientInfo: Record<string, any> | null | undefined;
private authorizeResource: string | undefined;
private _state: string;
constructor(readonly options: OAuthProviderOptions) {
this.serverUrlHash = getServerUrlHash(options.serverUrl);
this.redirectUrl = options.redirectUrl;
this.clientName = options.clientName || "C.O.R.E. MCP";
this.clientUri = options.clientUri || "https://github.com/modelcontextprotocol/mcp-cli";
this.softwareId = options.softwareId || "2e6dc280-f3c3-4e01-99a7-8181dbd1d23d";
this.softwareVersion = options.softwareVersion || MCP_REMOTE_VERSION;
this.staticOAuthClientMetadata = options.staticOAuthClientMetadata;
this.staticOAuthClientInfo = options.staticOAuthClientInfo;
this.authorizeResource = options.authorizeResource;
this._state = randomUUID();
}
get clientMetadata(): OAuthClientMetadata {
return {
redirect_uris: [this.redirectUrl.toString()],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
client_name: this.clientName,
client_uri: this.clientUri,
software_id: this.softwareId,
software_version: this.softwareVersion,
...this.staticOAuthClientMetadata,
};
}
state?(): string {
return this._state;
}
/**
* Gets the client information if it exists
*/
async clientInformation(): Promise<OAuthClientInformationFull | undefined> {
if (DEBUG) debugLog("Reading client info");
if (this.staticOAuthClientInfo) {
if (DEBUG) debugLog("Returning static client info");
return this.staticOAuthClientInfo as OAuthClientInformationFull;
}
const clientInfo = await globalAuthStorage.getClientInformation(this.serverUrlHash);
if (DEBUG) debugLog("Client info result:", clientInfo ? "Found" : "Not found");
return clientInfo || undefined;
}
/**
* Saves client information
*/
async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise<void> {
if (DEBUG)
debugLog("Saving client info", {
client_id: clientInformation.client_id,
});
await globalAuthStorage.saveClientInformation(this.serverUrlHash, clientInformation);
}
/**
* Gets the OAuth tokens if they exist
*/
async tokens(): Promise<OAuthTokens | undefined> {
if (DEBUG) {
debugLog("Reading OAuth tokens");
debugLog("Token request stack trace:", new Error().stack);
}
const tokens = await globalAuthStorage.getTokens(this.serverUrlHash);
if (DEBUG) {
if (tokens) {
const timeLeft = tokens.expires_in || 0;
// Alert if expires_in is invalid
if (typeof tokens.expires_in !== "number" || tokens.expires_in < 0) {
debugLog("⚠️ WARNING: Invalid expires_in detected while reading tokens ⚠️", {
expiresIn: tokens.expires_in,
tokenObject: JSON.stringify(tokens),
stack: new Error("Invalid expires_in value").stack,
});
}
debugLog("Token result:", {
found: true,
hasAccessToken: !!tokens.access_token,
hasRefreshToken: !!tokens.refresh_token,
expiresIn: `${timeLeft} seconds`,
isExpired: timeLeft <= 0,
expiresInValue: tokens.expires_in,
});
} else {
debugLog("Token result: Not found");
}
}
return tokens || undefined;
}
/**
* Saves OAuth tokens
*/
async saveTokens(tokens: OAuthTokens): Promise<void> {
if (DEBUG) {
const timeLeft = tokens.expires_in || 0;
// Alert if expires_in is invalid
if (typeof tokens.expires_in !== "number" || tokens.expires_in < 0) {
debugLog("⚠️ WARNING: Invalid expires_in detected in tokens ⚠️", {
expiresIn: tokens.expires_in,
tokenObject: JSON.stringify(tokens),
stack: new Error("Invalid expires_in value").stack,
});
}
debugLog("Saving tokens", {
hasAccessToken: !!tokens.access_token,
hasRefreshToken: !!tokens.refresh_token,
expiresIn: `${timeLeft} seconds`,
expiresInValue: tokens.expires_in,
});
}
await globalAuthStorage.saveTokens(this.serverUrlHash, tokens);
}
private authorizationUrl: string | null = null;
/**
* Captures the authorization URL instead of opening browser
*/
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
if (this.authorizeResource) {
authorizationUrl.searchParams.set("resource", this.authorizeResource);
}
console.log(this.authorizationUrl);
// Store the URL instead of opening browser
this.authorizationUrl = authorizationUrl.toString();
if (DEBUG) debugLog("Authorization URL captured", authorizationUrl.toString());
// For server-side usage, we don't open the browser
log(`Authorization URL generated: ${authorizationUrl.toString()}`);
}
/**
* Saves the PKCE code verifier
*/
async saveCodeVerifier(codeVerifier: string): Promise<void> {
if (DEBUG) debugLog("Saving code verifier");
await globalAuthStorage.saveCodeVerifier(this.serverUrlHash, codeVerifier);
}
/**
* Gets the PKCE code verifier
*/
async codeVerifier(): Promise<string> {
if (DEBUG) debugLog("Reading code verifier");
const verifier = await globalAuthStorage.getCodeVerifier(this.serverUrlHash);
if (DEBUG) debugLog("Code verifier found:", !!verifier);
if (!verifier) {
throw new Error("No code verifier saved for session");
}
return verifier;
}
/**
* Adds custom client authentication to OAuth token requests.
* Optional method for custom authentication schemes.
*/
async addClientAuthentication(
_headers: Headers,
_params: URLSearchParams,
_url: string | URL,
_metadata?: OAuthMetadata
): Promise<void> {
// Default implementation - no custom authentication
// Subclasses can override this for custom auth schemes
}
/**
* Validates RFC 8707 Resource Indicator.
* If defined, overrides the default validation behavior.
*/
async validateResourceURL(
_serverUrl: string | URL,
_resource?: string
): Promise<URL | undefined> {
// Default implementation - no resource validation
// Subclasses can override this for custom validation
return undefined;
}
/**
* Invalidates the specified credentials
*/
async invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier"): Promise<void> {
if (DEBUG) debugLog(`Invalidating credentials: ${scope}`);
await globalAuthStorage.invalidateCredentials(this.serverUrlHash, scope);
if (DEBUG) debugLog(`${scope} credentials invalidated`);
}
/**
* Gets the authorization URL to initiate OAuth flow
*/
async authorizationURL(
options: {
scope?: string;
resourceMetadataUrl?: string;
} = {}
): Promise<{ authUrl: string; state: string }> {
const { scope, resourceMetadataUrl } = options;
let resourceMetadata;
let authorizationServerUrl: string = this.options.serverUrl;
try {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(this.options.serverUrl, {
resourceMetadataUrl: resourceMetadataUrl as string,
});
if (
resourceMetadata.authorization_servers &&
resourceMetadata.authorization_servers.length > 0
) {
authorizationServerUrl = resourceMetadata.authorization_servers[0] as string;
}
} catch (_) {
// Ignore errors and fall back to /.well-known/oauth-authorization-server
}
const resource = await selectResourceURL(this.options.serverUrl, this, resourceMetadata);
const metadata = await discoverOAuthMetadata(this.options.serverUrl);
// Handle client registration if needed
let clientInformation = await this.clientInformation();
if (!clientInformation) {
if (!this.saveClientInformation) {
throw new Error("OAuth client information must be saveable for dynamic registration");
}
const fullInformation = await registerClient(authorizationServerUrl, {
metadata: metadata as any,
clientMetadata: this.clientMetadata,
});
await this.saveClientInformation(fullInformation);
clientInformation = fullInformation;
}
const state = this.state ? this.state() : randomUUID();
const params: any = {
metadata: metadata as any,
clientInformation,
state: state || "",
redirectUrl: this.redirectUrl,
scope: scope || this.clientMetadata.scope || "",
};
if (resource) {
params.resource = resource;
}
// Start new authorization flow
const { authorizationUrl, codeVerifier } = await startAuthorization(
authorizationServerUrl,
params
);
await this.saveCodeVerifier(codeVerifier);
return { authUrl: authorizationUrl.toString(), state };
}
/**
* Completes the OAuth flow with authorization code
*/
async completeAuth(options: {
authorizationCode: string;
scope?: string;
resourceMetadataUrl?: string;
}): Promise<"AUTHORIZED"> {
const { authorizationCode, resourceMetadataUrl } = options;
let resourceMetadata;
let authorizationServerUrl = this.options.serverUrl;
try {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(this.options.serverUrl, {
resourceMetadataUrl: resourceMetadataUrl as string,
});
if (
resourceMetadata.authorization_servers &&
resourceMetadata.authorization_servers.length > 0
) {
authorizationServerUrl = resourceMetadata.authorization_servers[0] as string;
}
} catch (_) {
// Ignore errors and fall back to /.well-known/oauth-authorization-server
}
const resource = await selectResourceURL(this.options.serverUrl, this, resourceMetadata);
const metadata = await discoverOAuthMetadata(this.options.serverUrl);
// Handle client registration if needed
let clientInformation = await this.clientInformation();
if (!clientInformation) {
throw new Error(
"Existing OAuth client information is required when exchanging an authorization code"
);
}
// Check if we can refresh existing tokens first
const tokens = await this.tokens();
if (tokens?.refresh_token) {
try {
const refreshParams: any = {
metadata: metadata as any,
clientInformation,
refreshToken: tokens.refresh_token,
};
if (resource) {
refreshParams.resource = resource;
}
// Attempt to refresh the token
const newTokens = await refreshAuthorization(authorizationServerUrl, refreshParams);
await this.saveTokens(newTokens);
return "AUTHORIZED";
} catch (_) {
// Could not refresh OAuth tokens, continue with authorization code exchange
}
}
// Exchange authorization code for tokens
const codeVerifier = await this.codeVerifier();
const exchangeParams: any = {
metadata: metadata as any,
clientInformation,
authorizationCode,
codeVerifier,
redirectUri: this.redirectUrl,
};
if (resource) {
exchangeParams.resource = resource;
}
const newTokens = await exchangeAuthorization(authorizationServerUrl, exchangeParams);
await this.saveTokens(newTokens);
return "AUTHORIZED";
}
}

View File

@ -0,0 +1,311 @@
import {
OAuthClientProvider,
UnauthorizedError,
} from "@modelcontextprotocol/sdk/client/auth.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { OAuthError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
import crypto from "crypto";
import net from "net";
// Global debug flag
export let DEBUG = false;
const pid = process.pid;
// Connection constants
export const REASON_AUTH_NEEDED = "authentication-needed";
export const REASON_TRANSPORT_FALLBACK = "falling-back-to-alternate-transport";
// Transport strategy types
export type TransportStrategy =
| "sse-only"
| "http-only"
| "sse-first"
| "http-first";
export const MCP_REMOTE_VERSION = "1.0.0";
// Helper function for timestamp formatting
function getTimestamp(): string {
const now = new Date();
return now.toISOString();
}
// Debug logging function
export function debugLog(message: string, ...args: any[]) {
if (!DEBUG) return;
const formattedMessage = `[${getTimestamp()}][${pid}] ${message}`;
console.error(formattedMessage, ...args);
}
export function log(str: string, ...rest: unknown[]) {
console.error(`[${pid}] ${str}`, ...rest);
if (DEBUG) {
debugLog(str, ...rest);
}
}
/**
* Type for the auth initialization function
*/
export type AuthInitializer = () => Promise<{
waitForAuthCode: () => Promise<string>;
skipBrowserAuth: boolean;
}>;
/**
* Creates and connects to a remote server with OAuth authentication
*/
export async function connectToRemoteServer(
client: Client | null,
serverUrl: string,
authProvider: OAuthClientProvider,
headers: Record<string, string>,
authInitializer: AuthInitializer,
transportStrategy: TransportStrategy = "http-first",
recursionReasons: Set<string> = new Set()
): Promise<Transport> {
log(`[${pid}] Connecting to remote server: ${serverUrl}`);
const url = new URL(serverUrl);
// Create transport with eventSourceInit to pass Authorization header if present
const eventSourceInit = {
fetch: (url: string | URL, init?: RequestInit) => {
return Promise.resolve(authProvider?.tokens?.()).then((tokens) =>
fetch(url, {
...init,
headers: {
...(init?.headers as Record<string, string> | undefined),
...headers,
...(tokens?.access_token
? { Authorization: `Bearer ${tokens.access_token}` }
: {}),
Accept: "text/event-stream",
} as Record<string, string>,
})
);
},
};
log(`Using transport strategy: ${transportStrategy}`);
const shouldAttemptFallback =
transportStrategy === "http-first" || transportStrategy === "sse-first";
// Create transport instance based on the strategy
const sseTransport =
transportStrategy === "sse-only" || transportStrategy === "sse-first";
const transport = sseTransport
? new SSEClientTransport(url, {
authProvider,
requestInit: { headers },
eventSourceInit,
})
: new StreamableHTTPClientTransport(url, {
authProvider,
requestInit: { headers },
});
try {
if (DEBUG)
debugLog("Attempting to connect to remote server", { sseTransport });
if (client) {
if (DEBUG) debugLog("Connecting client to transport");
await client.connect(transport as Transport);
} else {
if (DEBUG) debugLog("Starting transport directly");
await transport.start();
if (!sseTransport) {
if (DEBUG)
debugLog("Creating test transport for HTTP-only connection test");
const testTransport = new StreamableHTTPClientTransport(url, {
authProvider,
requestInit: { headers },
}) as Transport;
const testClient = new Client(
{ name: "mcp-remote-fallback-test", version: "0.0.0" },
{ capabilities: {} }
);
await testClient.connect(testTransport);
}
}
log(`Connected to remote server using ${transport.constructor.name}`);
return transport as Transport;
} catch (error: any) {
// Check if it's a protocol error and we should attempt fallback
if (
error instanceof Error &&
shouldAttemptFallback &&
(error.message.includes("405") ||
error.message.includes("Method Not Allowed") ||
error.message.includes("404") ||
error.message.includes("Not Found"))
) {
log(`Received error: ${error.message}`);
// If we've already tried falling back once, throw an error
if (recursionReasons.has(REASON_TRANSPORT_FALLBACK)) {
const errorMessage = `Already attempted transport fallback. Giving up.`;
log(errorMessage);
throw new Error(errorMessage);
}
log(`Recursively reconnecting for reason: ${REASON_TRANSPORT_FALLBACK}`);
// Add to recursion reasons set
recursionReasons.add(REASON_TRANSPORT_FALLBACK);
// Recursively call connectToRemoteServer with the updated recursion tracking
return connectToRemoteServer(
client,
serverUrl,
authProvider,
headers,
authInitializer,
sseTransport ? "http-only" : "sse-only",
recursionReasons
);
} else if (
error instanceof UnauthorizedError ||
(error instanceof Error && error.message.includes("Unauthorized"))
) {
log("Authentication required. Initializing auth...");
if (DEBUG) {
debugLog("Authentication error detected", {
errorCode: error instanceof OAuthError ? error.errorCode : undefined,
errorMessage: error.message,
stack: error.stack,
});
}
// Initialize authentication on-demand
if (DEBUG) debugLog("Calling authInitializer to start auth flow");
const { waitForAuthCode, skipBrowserAuth } = await authInitializer();
if (skipBrowserAuth) {
log(
"Authentication required but skipping browser auth - using shared auth"
);
} else {
log("Authentication required. Waiting for authorization...");
}
// Wait for the authorization code from the callback
if (DEBUG) debugLog("Waiting for auth code from callback server");
const code = await waitForAuthCode();
if (DEBUG) debugLog("Received auth code from callback server");
try {
log("Completing authorization...");
await transport.finishAuth(code);
if (DEBUG) debugLog("Authorization completed successfully");
if (recursionReasons.has(REASON_AUTH_NEEDED)) {
const errorMessage = `Already attempted reconnection for reason: ${REASON_AUTH_NEEDED}. Giving up.`;
log(errorMessage);
if (DEBUG)
debugLog("Already attempted auth reconnection, giving up", {
recursionReasons: Array.from(recursionReasons),
});
throw new Error(errorMessage);
}
// Track this reason for recursion
recursionReasons.add(REASON_AUTH_NEEDED);
log(`Recursively reconnecting for reason: ${REASON_AUTH_NEEDED}`);
if (DEBUG)
debugLog("Recursively reconnecting after auth", {
recursionReasons: Array.from(recursionReasons),
});
// Recursively call connectToRemoteServer with the updated recursion tracking
return connectToRemoteServer(
client,
serverUrl,
authProvider,
headers,
authInitializer,
transportStrategy,
recursionReasons
);
} catch (authError: any) {
log("Authorization error:", authError);
if (DEBUG)
debugLog("Authorization error during finishAuth", {
errorMessage: authError.message,
stack: authError.stack,
});
throw authError;
}
} else {
log("Connection error:", error);
if (DEBUG)
debugLog("Connection error", {
errorMessage: error.message,
stack: error.stack,
transportType: transport.constructor.name,
});
throw error;
}
}
}
/**
* Finds an available port on the local machine
*/
export async function findAvailablePort(
preferredPort?: number
): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
// If preferred port is in use, get a random port
server.listen(0);
} else {
reject(err);
}
});
server.on("listening", () => {
const { port } = server.address() as net.AddressInfo;
server.close(() => {
resolve(port);
});
});
// Try preferred port first, or get a random port
server.listen(preferredPort || 0);
});
}
/**
* Generates a hash for the server URL to use in filenames
*/
export function getServerUrlHash(serverUrl: string): string {
return crypto.createHash("md5").update(serverUrl).digest("hex");
}
/**
* Sets up signal handlers for graceful shutdown
*/
export function setupSignalHandlers(cleanup: () => Promise<void>) {
process.on("SIGINT", async () => {
log("\nShutting down...");
await cleanup();
process.exit(0);
});
// Keep the process alive
process.stdin.resume();
process.stdin.on("end", async () => {
log("\nShutting down...");
await cleanup();
process.exit(0);
});
}

View File

@ -0,0 +1,14 @@
// Re-export types used by MCPRemoteClient
export type {
MCPRemoteClientConfig,
ProxyConnectionConfig,
TransportStrategy,
StoredCredentials,
ProxyCredentials,
AuthenticationResult,
CredentialSaveCallback,
CredentialLoadCallback,
OAuthFlowResult,
OAuthCallbackData,
MCPProxyFunction
} from './remote-client.js'

View File

@ -0,0 +1,267 @@
import { OAuthTokens, OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js";
/**
* Configuration for MCP Remote Client authentication
*/
export interface MCPRemoteClientConfig {
/** The MCP server URL (e.g., https://mcp.linear.com/sse) */
serverUrl: string;
/** Host for OAuth callback (default: localhost) */
redirectUrl: string;
/** Client name for OAuth registration */
clientName?: string;
/** Additional headers to send with requests */
headers?: Record<string, string>;
/** Transport strategy for connection */
transportStrategy?: TransportStrategy;
/** Static OAuth client metadata if using pre-registered client */
staticOAuthClientMetadata?: StaticOAuthClientMetadata;
/** Static OAuth client information if using pre-registered client */
staticOAuthClientInfo?: StaticOAuthClientInformationFull;
/** Resource to authorize (optional) */
authorizeResource?: string;
}
/**
* Configuration for MCP Proxy connection
*/
export interface ProxyConnectionConfig {
/** The MCP server URL to proxy to */
serverUrl: string;
/** Additional headers to send with requests */
headers?: Record<string, string>;
/** Transport strategy (sse-first, http-first, sse-only, http-only) */
transportStrategy?: TransportStrategy;
redirectUrl: string;
/** Request timeout in milliseconds (default: 30000) */
timeout?: number;
}
/**
* Transport strategy options
*/
export type TransportStrategy = "sse-only" | "http-only" | "sse-first" | "http-first";
/**
* Static OAuth client metadata
*/
export type StaticOAuthClientMetadata = Record<string, any> | null;
/**
* Static OAuth client information
*/
export type StaticOAuthClientInformationFull = Record<string, any> | null;
/**
* Stored credential data that gets passed to the callback
*/
export interface StoredCredentials {
/** The MCP server URL these credentials are for */
serverUrl: string;
/** OAuth tokens */
tokens: OAuthTokens;
/** When the tokens expire */
expiresAt: Date;
/** OAuth client information (optional, for reuse) */
clientInfo?: OAuthClientInformationFull;
/** PKCE code verifier (optional, for debugging) */
codeVerifier?: string;
}
/**
* Minimal credential data needed for proxy operations
*/
export interface ProxyCredentials {
/** The MCP server URL these credentials are for */
serverUrl: string;
/** OAuth tokens */
tokens: OAuthTokens;
/** When the tokens expire */
expiresAt: Date;
}
/**
* Result of authentication process
*/
export interface AuthenticationResult {
/** Whether authentication was successful */
success: boolean;
/** Error message if authentication failed */
error?: string;
serverUrl?: string;
/** OAuth tokens */
tokens?: OAuthTokens;
/** When the tokens expire */
expiresAt?: Date;
/** OAuth client information (optional, for reuse) */
clientInfo?: OAuthClientInformationFull;
/** PKCE code verifier (optional, for debugging) */
codeVerifier?: string;
}
/**
* Callback function type for saving credentials to your database
* @param credentials The credentials to save
* @returns Promise that resolves when credentials are saved
*/
export type CredentialSaveCallback = (credentials: StoredCredentials) => Promise<void>;
/**
* Callback function type for loading credentials from your database
* @param userApiKey The user's API key
* @param serverUrl The MCP server URL to get credentials for
* @returns Promise that resolves to proxy credentials or null if not found
*/
export type CredentialLoadCallback = (
userApiKey: string,
serverUrl: string
) => Promise<ProxyCredentials | null>;
/**
* OAuth flow initiation result
*/
export interface OAuthFlowResult {
/** Authorization URL to redirect user to */
authUrl: string;
/** State parameter for OAuth flow (save this for step 2) */
state: string;
}
/**
* OAuth callback data received from the authorization server
*/
export interface OAuthCallbackData {
/** Authorization code from OAuth callback */
code: string;
/** State parameter from OAuth callback (must match initiation) */
state: string;
}
/**
* MCP message format for transport
*/
export interface MCPMessage {
/** HTTP method */
method: string;
/** Request headers */
headers: Record<string, string>;
/** Request body (parsed JSON) */
body?: any;
/** Request URL */
url: string;
}
/**
* MCP response format
*/
export interface MCPResponse {
/** Response status code */
status?: number;
/** Response headers */
headers?: Record<string, string>;
/** Response body */
body?: any;
}
/**
* Error types that can be thrown by the MCP client
*/
export interface MCPClientError {
/** Error message */
message: string;
/** Error code */
code: string;
/** Original error if available */
originalError?: Error;
}
/**
* Connection test result
*/
export interface ConnectionTestResult {
/** Whether the connection test passed */
success: boolean;
/** Error message if test failed */
error?: string;
/** Additional details about the test */
details?: Record<string, any>;
}
/**
* Proxy handler configuration
*/
export interface ProxyHandlerConfig {
/** Maximum number of concurrent connections */
maxConnections?: number;
/** Connection pool timeout */
poolTimeout?: number;
/** Enable request/response logging */
enableLogging?: boolean;
/** Custom error handler */
errorHandler?: (error: Error, request: Request) => Response;
}
/**
* Transport connection info
*/
export interface TransportConnection {
/** Connection ID */
id: string;
/** Server URL */
serverUrl: string;
/** Transport type used */
transportType: "sse" | "http";
/** When connection was established */
connectedAt: Date;
/** Last activity timestamp */
lastActivity: Date;
/** Connection status */
status: "connected" | "disconnected" | "error";
}
/**
* MCP Proxy function type
*/
export type MCPProxyFunction = (request: Request, userApiKey: string) => Promise<Response>;

View File

@ -0,0 +1,75 @@
import { NodeOAuthClientProvider } from "../lib/node-oauth-client-provider.js";
import { MCPRemoteClientConfig, StoredCredentials } from "../types/remote-client.js";
/**
* Configuration for creating an auth provider
*/
export interface AuthProviderConfig {
serverUrl: string;
redirectUrl: string;
clientName?: string | undefined;
staticOAuthClientMetadata?: any;
staticOAuthClientInfo?: any;
authorizeResource?: string | undefined;
}
/**
* Creates a NodeOAuthClientProvider instance for authentication flows
* This is the common factory used by both MCPAuthenticationClient and createMCPProxy
*/
export function createAuthProvider(config: AuthProviderConfig): NodeOAuthClientProvider {
return new NodeOAuthClientProvider({
serverUrl: config.serverUrl,
redirectUrl: config.redirectUrl,
clientName: config.clientName || "CORE",
staticOAuthClientMetadata: config.staticOAuthClientMetadata || null,
staticOAuthClientInfo: config.staticOAuthClientInfo || null,
...(config.authorizeResource !== undefined
? { authorizeResource: config.authorizeResource }
: {}),
});
}
/**
* Creates an auth provider for proxy use with existing credentials
* This sets up the provider with stored credentials for transport usage
*/
export async function createAuthProviderForProxy(
serverUrl: string,
credentials: StoredCredentials,
redirectUrl: string
): Promise<NodeOAuthClientProvider> {
const authProvider = createAuthProvider({
serverUrl,
redirectUrl,
clientName: "CORE",
});
// Load the existing credentials
await authProvider.saveTokens(credentials.tokens);
if (credentials.clientInfo) {
await authProvider.saveClientInformation(credentials.clientInfo);
}
if (credentials.codeVerifier) {
await authProvider.saveCodeVerifier(credentials.codeVerifier);
}
return authProvider;
}
/**
* Creates an auth provider from MCPRemoteClientConfig
* This is used by MCPAuthenticationClient
*/
export function createAuthProviderFromConfig(
config: MCPRemoteClientConfig
): NodeOAuthClientProvider {
return createAuthProvider({
serverUrl: config.serverUrl,
redirectUrl: config.redirectUrl,
clientName: config.clientName,
staticOAuthClientMetadata: config.staticOAuthClientMetadata,
staticOAuthClientInfo: config.staticOAuthClientInfo,
authorizeResource: config.authorizeResource,
});
}

View File

@ -0,0 +1,30 @@
export class MCPAuthProxyError extends Error {
constructor(message: string, public code: string) {
super(message)
this.name = 'MCPAuthProxyError'
}
}
export class InvalidCredentialsError extends MCPAuthProxyError {
constructor() {
super('Invalid or expired credentials', 'INVALID_CREDENTIALS')
}
}
export class OAuthError extends MCPAuthProxyError {
constructor(message: string) {
super(message, 'OAUTH_ERROR')
}
}
export class ProxyError extends MCPAuthProxyError {
constructor(message: string) {
super(message, 'PROXY_ERROR')
}
}
export class TransportError extends MCPAuthProxyError {
constructor(message: string) {
super(message, 'TRANSPORT_ERROR')
}
}

View File

@ -0,0 +1,3 @@
export * from "./mcp-transport-bridge";
export * from "./mcp-transport";
export * from "./auth-provider-factory";

View File

@ -0,0 +1,116 @@
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
/**
* Creates a bidirectional bridge between two MCP transports
* Similar to the mcpProxy function in mcp-remote but for any transport pair
*/
export function createMCPTransportBridge(
clientTransport: Transport,
serverTransport: Transport,
options: {
debug?: boolean;
onMessage?: (direction: 'client-to-server' | 'server-to-client', message: any) => void;
onError?: (error: Error, source: 'client' | 'server') => void;
} = {}
) {
let clientClosed = false;
let serverClosed = false;
const { debug = false, onMessage, onError } = options;
const log = debug ? console.log : () => {};
const logError = debug ? console.error : () => {};
// Forward messages from client to server
clientTransport.onmessage = (message: any) => {
log('[Client→Server]', message.method || message.id);
onMessage?.('client-to-server', message);
serverTransport.send(message).catch(error => {
logError('Error sending to server:', error);
onError?.(error, 'server');
});
};
// Forward messages from server to client
serverTransport.onmessage = (message: any) => {
log('[Server→Client]', message.method || message.id);
onMessage?.('server-to-client', message);
clientTransport.send(message).catch(error => {
logError('Error sending to client:', error);
onError?.(error, 'client');
});
};
// Handle transport closures
clientTransport.onclose = () => {
if (serverClosed) return;
clientClosed = true;
log('Client transport closed, closing server transport');
serverTransport.close().catch(error => {
logError('Error closing server transport:', error);
});
};
serverTransport.onclose = () => {
if (clientClosed) return;
serverClosed = true;
log('Server transport closed, closing client transport');
clientTransport.close().catch(error => {
logError('Error closing client transport:', error);
});
};
// Error handling
clientTransport.onerror = (error: Error) => {
logError('Client transport error:', error);
onError?.(error, 'client');
};
serverTransport.onerror = (error: Error) => {
logError('Server transport error:', error);
onError?.(error, 'server');
};
return {
/**
* Start both transports
*/
start: async () => {
try {
await Promise.all([
clientTransport.start(),
serverTransport.start()
]);
log('MCP transport bridge started successfully');
} catch (error) {
logError('Error starting transport bridge:', error);
throw error;
}
},
/**
* Close both transports
*/
close: async () => {
try {
await Promise.all([
clientTransport.close(),
serverTransport.close()
]);
log('MCP transport bridge closed successfully');
} catch (error) {
logError('Error closing transport bridge:', error);
throw error;
}
},
/**
* Check if the bridge is closed
*/
get isClosed() {
return clientClosed || serverClosed;
}
};
}

View File

@ -0,0 +1,87 @@
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
/**
* MCP Transport that adapts HTTP Request/Response to MCP Transport interface
* for use in Remix API routes
*/
export class RemixMCPTransport implements Transport {
private _closed = false;
private _started = false;
constructor(
private request: Request,
private sendResponse: (response: Response) => void
) {}
sessionId?: string;
setProtocolVersion?: (version: string) => void;
async start(): Promise<void> {
if (this._started) return;
this._started = true;
// Parse the incoming MCP message from the request
try {
const body = await this.request.text();
if (!body.trim()) {
throw new Error("Empty request body");
}
const message = JSON.parse(body);
// Validate basic MCP message structure
if (!message.jsonrpc || message.jsonrpc !== "2.0") {
throw new Error("Invalid JSON-RPC message");
}
// Emit the message to handler
if (this.onmessage) {
try {
this.onmessage(message);
} catch (error) {
if (this.onerror) {
this.onerror(error as Error);
}
}
}
} catch (error) {
if (this.onerror) {
this.onerror(error as Error);
}
}
}
async send(message: any): Promise<void> {
if (this._closed) {
throw new Error("Transport is closed");
}
// Send the MCP response back as HTTP response
const response = new Response(JSON.stringify(message), {
status: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
this.sendResponse(response);
}
async close(): Promise<void> {
if (this._closed) return;
this._closed = true;
if (this.onclose) {
try {
this.onclose();
} catch (error) {
console.error("Error in close handler:", error);
}
}
}
onmessage: (message: any) => void = () => {};
onclose: () => void = () => {};
onerror: (error: Error) => void = () => {};
}

View File

@ -0,0 +1,44 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceMap": true,
"removeComments": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": false,
"noUncheckedIndexedAccess": true,
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"lib": [
"ES2022",
"DOM"
]
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}

View File

@ -0,0 +1,22 @@
import { Options, defineConfig as defineConfigTSUP } from 'tsup';
const options: Options = {
name: 'main',
config: 'tsconfig.json',
entry: ['./src/index.ts'],
outDir: './dist',
platform: 'node',
format: ['cjs', 'esm'],
legacyOutput: false,
sourcemap: true,
clean: true,
bundle: true,
splitting: false,
dts: true,
treeshake: {
preset: 'recommended',
},
external: ['axios'],
};
export default defineConfigTSUP(options);

137
pnpm-lock.yaml generated
View File

@ -54,6 +54,9 @@ importers:
'@core/database':
specifier: workspace:*
version: link:../../packages/database
'@core/mcp-proxy':
specifier: workspace:*
version: link:../../packages/mcp-proxy
'@core/types':
specifier: workspace:*
version: link:../../packages/types
@ -69,9 +72,6 @@ importers:
'@opentelemetry/api':
specifier: 1.9.0
version: 1.9.0
'@paciolan/remote-module-loader':
specifier: ^3.0.3
version: 3.0.3
'@prisma/client':
specifier: '*'
version: 5.4.1(prisma@5.4.1)
@ -254,7 +254,7 @@ importers:
version: link:../../packages/emails
exa-js:
specifier: ^1.8.20
version: 1.8.20(encoding@0.1.13)
version: 1.8.20(encoding@0.1.13)(ws@8.17.1)
execa:
specifier: ^9.6.0
version: 9.6.0
@ -378,7 +378,7 @@ importers:
devDependencies:
'@remix-run/dev':
specifier: 2.16.7
version: 2.16.7(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/serve@2.16.7(typescript@5.8.3))(@types/node@24.0.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(typescript@5.8.3)(vite@6.3.5(@types/node@24.0.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0))(yaml@2.8.0)
version: 2.16.7(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/serve@2.16.7(typescript@5.8.3))(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(typescript@5.8.3)(vite@6.3.5(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0))(yaml@2.8.0)
'@remix-run/eslint-config':
specifier: 2.16.7
version: 2.16.7(eslint@8.57.1)(react@18.3.1)(typescript@5.8.3)
@ -393,7 +393,7 @@ importers:
version: 0.5.16(tailwindcss@4.1.7)
'@tailwindcss/vite':
specifier: ^4.1.7
version: 4.1.9(vite@6.3.5(@types/node@24.0.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0))
version: 4.1.9(vite@6.3.5(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0))
'@trigger.dev/build':
specifier: ^4.0.0-v4-beta.22
version: 4.0.0-v4-beta.22(typescript@5.8.3)
@ -489,10 +489,10 @@ importers:
version: 5.8.3
vite:
specifier: ^6.0.0
version: 6.3.5(@types/node@24.0.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0)
version: 6.3.5(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0)
vite-tsconfig-paths:
specifier: ^4.2.1
version: 4.3.2(typescript@5.8.3)(vite@6.3.5(@types/node@24.0.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0))
version: 4.3.2(typescript@5.8.3)(vite@6.3.5(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0))
packages/database:
dependencies:
@ -547,6 +547,25 @@ importers:
specifier: 18.2.69
version: 18.2.69
packages/mcp-proxy:
dependencies:
'@modelcontextprotocol/sdk':
specifier: ^1.0.0
version: 1.13.2
devDependencies:
'@types/eventsource':
specifier: ^1.1.12
version: 1.1.15
'@types/node':
specifier: ^20.0.0
version: 20.19.7
tsup:
specifier: ^8.0.1
version: 8.5.0(@swc/core@1.3.101(@swc/helpers@0.5.17))(jiti@2.4.2)(postcss@8.5.5)(typescript@5.8.3)(yaml@2.8.0)
typescript:
specifier: ^5.0.0
version: 5.8.3
packages/sdk:
dependencies:
commander:
@ -579,7 +598,7 @@ importers:
version: 6.0.1
tsup:
specifier: ^8.0.1
version: 8.5.0(@swc/core@1.3.101)(jiti@2.4.2)(postcss@8.5.5)(typescript@5.8.3)(yaml@2.8.0)
version: 8.5.0(@swc/core@1.3.101(@swc/helpers@0.5.17))(jiti@2.4.2)(postcss@8.5.5)(typescript@5.8.3)(yaml@2.8.0)
typescript:
specifier: ^5.3.0
version: 5.8.3
@ -2062,9 +2081,6 @@ packages:
'@oslojs/jwt@0.2.0':
resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==}
'@paciolan/remote-module-loader@3.0.3':
resolution: {integrity: sha512-gwdJcP5QQbO7OUf00FWh+A5DkF3TnIv06JB3aMpm9pbbHcdouFrjo4nEW7HtbWeIs7z3gwGEVd82clAmzVzh3Q==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@ -4157,6 +4173,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/eventsource@1.1.15':
resolution: {integrity: sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA==}
'@types/express-serve-static-core@4.19.6':
resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==}
@ -4220,6 +4239,9 @@ packages:
'@types/node@18.19.115':
resolution: {integrity: sha512-kNrFiTgG4a9JAn1LMQeLOv3MvXIPokzXziohMrMsvpYgLpdEt/mMiVYc4sGKtDfyxM5gIDF4VgrPRyCw4fHOYg==}
'@types/node@20.19.7':
resolution: {integrity: sha512-1GM9z6BJOv86qkPvzh2i6VW5+VVrXxCLknfmTkWEqz+6DqosiY28XUWCTmBcJ0ACzKqx/iwdIREfo1fwExIlkA==}
'@types/node@22.16.0':
resolution: {integrity: sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==}
@ -11768,8 +11790,6 @@ snapshots:
dependencies:
'@oslojs/encoding': 0.4.1
'@paciolan/remote-module-loader@3.0.3': {}
'@pkgjs/parseargs@0.11.0':
optional: true
@ -11854,7 +11874,7 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.2.47
'@types/react-dom': 18.3.7(@types/react@18.2.47)
'@types/react-dom': 18.3.7(@types/react@18.2.69)
'@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -11908,7 +11928,7 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.2.47
'@types/react-dom': 18.3.7(@types/react@18.2.47)
'@types/react-dom': 18.3.7(@types/react@18.2.69)
'@radix-ui/react-collapsible@1.1.11(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -11936,7 +11956,7 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.2.47
'@types/react-dom': 18.3.7(@types/react@18.2.47)
'@types/react-dom': 18.3.7(@types/react@18.2.69)
'@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -12026,7 +12046,7 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.2.47
'@types/react-dom': 18.3.7(@types/react@18.2.47)
'@types/react-dom': 18.3.7(@types/react@18.2.69)
'@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -12077,7 +12097,7 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.2.47
'@types/react-dom': 18.3.7(@types/react@18.2.47)
'@types/react-dom': 18.3.7(@types/react@18.2.69)
'@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -12164,7 +12184,7 @@ snapshots:
react-remove-scroll: 2.5.7(@types/react@18.2.47)(react@18.3.1)
optionalDependencies:
'@types/react': 18.2.47
'@types/react-dom': 18.3.7(@types/react@18.2.47)
'@types/react-dom': 18.3.7(@types/react@18.2.69)
'@radix-ui/react-popover@1.1.14(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -12205,7 +12225,7 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.2.47
'@types/react-dom': 18.3.7(@types/react@18.2.47)
'@types/react-dom': 18.3.7(@types/react@18.2.69)
'@radix-ui/react-popper@1.2.7(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -12233,7 +12253,7 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.2.47
'@types/react-dom': 18.3.7(@types/react@18.2.47)
'@types/react-dom': 18.3.7(@types/react@18.2.69)
'@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -12253,7 +12273,7 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.2.47
'@types/react-dom': 18.3.7(@types/react@18.2.47)
'@types/react-dom': 18.3.7(@types/react@18.2.69)
'@radix-ui/react-presence@1.1.4(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -12272,7 +12292,7 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.2.47
'@types/react-dom': 18.3.7(@types/react@18.2.47)
'@types/react-dom': 18.3.7(@types/react@18.2.69)
'@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -12298,7 +12318,7 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.2.47
'@types/react-dom': 18.3.7(@types/react@18.2.47)
'@types/react-dom': 18.3.7(@types/react@18.2.69)
'@radix-ui/react-roving-focus@1.1.10(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -12458,7 +12478,7 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.2.47
'@types/react-dom': 18.3.7(@types/react@18.2.47)
'@types/react-dom': 18.3.7(@types/react@18.2.69)
'@radix-ui/react-toggle@1.1.0(@types/react-dom@18.3.7(@types/react@18.2.47))(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -12469,7 +12489,7 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.2.47
'@types/react-dom': 18.3.7(@types/react@18.2.47)
'@types/react-dom': 18.3.7(@types/react@18.2.69)
'@radix-ui/react-tooltip@1.1.1(@types/react-dom@18.3.7(@types/react@18.2.47))(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -12489,7 +12509,7 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.2.47
'@types/react-dom': 18.3.7(@types/react@18.2.47)
'@types/react-dom': 18.3.7(@types/react@18.2.69)
'@radix-ui/react-tooltip@1.2.7(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -12619,7 +12639,7 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.2.47
'@types/react-dom': 18.3.7(@types/react@18.2.47)
'@types/react-dom': 18.3.7(@types/react@18.2.69)
'@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -12771,7 +12791,7 @@ snapshots:
transitivePeerDependencies:
- encoding
'@remix-run/dev@2.16.7(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/serve@2.16.7(typescript@5.8.3))(@types/node@24.0.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(typescript@5.8.3)(vite@6.3.5(@types/node@24.0.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0))(yaml@2.8.0)':
'@remix-run/dev@2.16.7(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/serve@2.16.7(typescript@5.8.3))(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(typescript@5.8.3)(vite@6.3.5(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0))(yaml@2.8.0)':
dependencies:
'@babel/core': 7.27.4
'@babel/generator': 7.27.5
@ -12788,7 +12808,7 @@ snapshots:
'@remix-run/router': 1.23.0
'@remix-run/server-runtime': 2.16.7(typescript@5.8.3)
'@types/mdx': 2.0.13
'@vanilla-extract/integration': 6.5.0(@types/node@24.0.0)(lightningcss@1.30.1)(terser@5.42.0)
'@vanilla-extract/integration': 6.5.0(@types/node@20.19.7)(lightningcss@1.30.1)(terser@5.42.0)
arg: 5.0.2
cacache: 17.1.4
chalk: 4.1.2
@ -12828,12 +12848,12 @@ snapshots:
tar-fs: 2.1.3
tsconfig-paths: 4.2.0
valibot: 0.41.0(typescript@5.8.3)
vite-node: 3.2.3(@types/node@24.0.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0)
vite-node: 3.2.3(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0)
ws: 7.5.10
optionalDependencies:
'@remix-run/serve': 2.16.7(typescript@5.8.3)
typescript: 5.8.3
vite: 6.3.5(@types/node@24.0.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0)
vite: 6.3.5(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0)
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@ -13572,12 +13592,12 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 4.1.7
'@tailwindcss/vite@4.1.9(vite@6.3.5(@types/node@24.0.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0))':
'@tailwindcss/vite@4.1.9(vite@6.3.5(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0))':
dependencies:
'@tailwindcss/node': 4.1.9
'@tailwindcss/oxide': 4.1.9
tailwindcss: 4.1.9
vite: 6.3.5(@types/node@24.0.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0)
vite: 6.3.5(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0)
'@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -14089,6 +14109,8 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/eventsource@1.1.15': {}
'@types/express-serve-static-core@4.19.6':
dependencies:
'@types/node': 18.19.115
@ -14158,6 +14180,10 @@ snapshots:
dependencies:
undici-types: 5.26.5
'@types/node@20.19.7':
dependencies:
undici-types: 6.21.0
'@types/node@22.16.0':
dependencies:
undici-types: 6.21.0
@ -14180,10 +14206,6 @@ snapshots:
'@types/range-parser@1.2.7': {}
'@types/react-dom@18.3.7(@types/react@18.2.47)':
dependencies:
'@types/react': 18.2.47
'@types/react-dom@18.3.7(@types/react@18.2.69)':
dependencies:
'@types/react': 18.2.69
@ -14501,7 +14523,7 @@ snapshots:
transitivePeerDependencies:
- babel-plugin-macros
'@vanilla-extract/integration@6.5.0(@types/node@24.0.0)(lightningcss@1.30.1)(terser@5.42.0)':
'@vanilla-extract/integration@6.5.0(@types/node@20.19.7)(lightningcss@1.30.1)(terser@5.42.0)':
dependencies:
'@babel/core': 7.27.4
'@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.27.4)
@ -14514,8 +14536,8 @@ snapshots:
lodash: 4.17.21
mlly: 1.7.4
outdent: 0.8.0
vite: 5.4.19(@types/node@24.0.0)(lightningcss@1.30.1)(terser@5.42.0)
vite-node: 1.6.1(@types/node@24.0.0)(lightningcss@1.30.1)(terser@5.42.0)
vite: 5.4.19(@types/node@20.19.7)(lightningcss@1.30.1)(terser@5.42.0)
vite-node: 1.6.1(@types/node@20.19.7)(lightningcss@1.30.1)(terser@5.42.0)
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@ -16404,11 +16426,11 @@ snapshots:
run-exclusive: 2.2.19
tsafe: 1.8.5
exa-js@1.8.20(encoding@0.1.13):
exa-js@1.8.20(encoding@0.1.13)(ws@8.17.1):
dependencies:
cross-fetch: 4.1.0(encoding@0.1.13)
dotenv: 16.4.7
openai: 5.9.0(zod@3.23.8)
openai: 5.9.0(ws@8.17.1)(zod@3.23.8)
zod: 3.23.8
zod-to-json-schema: 3.24.5(zod@3.23.8)
transitivePeerDependencies:
@ -18614,8 +18636,9 @@ snapshots:
dependencies:
mimic-fn: 4.0.0
openai@5.9.0(zod@3.23.8):
openai@5.9.0(ws@8.17.1)(zod@3.23.8):
optionalDependencies:
ws: 8.17.1
zod: 3.23.8
optionator@0.9.4:
@ -19288,7 +19311,7 @@ snapshots:
'@radix-ui/react-tooltip': 1.1.1(@types/react-dom@18.3.7(@types/react@18.2.47))(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@swc/core': 1.3.101(@swc/helpers@0.5.17)
'@types/react': 18.2.47
'@types/react-dom': 18.3.7(@types/react@18.2.47)
'@types/react-dom': 18.3.7(@types/react@18.2.69)
'@types/webpack': 5.28.5(@swc/core@1.3.101(@swc/helpers@0.5.17))(esbuild@0.19.11)
autoprefixer: 10.4.14(postcss@8.4.38)
chalk: 4.1.2
@ -20551,7 +20574,7 @@ snapshots:
tslib@2.8.1: {}
tsup@8.5.0(@swc/core@1.3.101)(jiti@2.4.2)(postcss@8.5.5)(typescript@5.8.3)(yaml@2.8.0):
tsup@8.5.0(@swc/core@1.3.101(@swc/helpers@0.5.17))(jiti@2.4.2)(postcss@8.5.5)(typescript@5.8.3)(yaml@2.8.0):
dependencies:
bundle-require: 5.1.0(esbuild@0.25.5)
cac: 6.7.14
@ -20943,13 +20966,13 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.2
vite-node@1.6.1(@types/node@24.0.0)(lightningcss@1.30.1)(terser@5.42.0):
vite-node@1.6.1(@types/node@20.19.7)(lightningcss@1.30.1)(terser@5.42.0):
dependencies:
cac: 6.7.14
debug: 4.4.1
pathe: 1.1.2
picocolors: 1.1.1
vite: 5.4.19(@types/node@24.0.0)(lightningcss@1.30.1)(terser@5.42.0)
vite: 5.4.19(@types/node@20.19.7)(lightningcss@1.30.1)(terser@5.42.0)
transitivePeerDependencies:
- '@types/node'
- less
@ -20961,13 +20984,13 @@ snapshots:
- supports-color
- terser
vite-node@3.2.3(@types/node@24.0.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0):
vite-node@3.2.3(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0):
dependencies:
cac: 6.7.14
debug: 4.4.1
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 6.3.5(@types/node@24.0.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0)
vite: 6.3.5(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0)
transitivePeerDependencies:
- '@types/node'
- jiti
@ -20982,29 +21005,29 @@ snapshots:
- tsx
- yaml
vite-tsconfig-paths@4.3.2(typescript@5.8.3)(vite@6.3.5(@types/node@24.0.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0)):
vite-tsconfig-paths@4.3.2(typescript@5.8.3)(vite@6.3.5(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0)):
dependencies:
debug: 4.4.1
globrex: 0.1.2
tsconfck: 3.1.6(typescript@5.8.3)
optionalDependencies:
vite: 6.3.5(@types/node@24.0.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0)
vite: 6.3.5(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0)
transitivePeerDependencies:
- supports-color
- typescript
vite@5.4.19(@types/node@24.0.0)(lightningcss@1.30.1)(terser@5.42.0):
vite@5.4.19(@types/node@20.19.7)(lightningcss@1.30.1)(terser@5.42.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.5
rollup: 4.43.0
optionalDependencies:
'@types/node': 24.0.0
'@types/node': 20.19.7
fsevents: 2.3.3
lightningcss: 1.30.1
terser: 5.42.0
vite@6.3.5(@types/node@24.0.0)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0):
vite@6.3.5(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.42.0)(yaml@2.8.0):
dependencies:
esbuild: 0.25.5
fdir: 6.4.6(picomatch@4.0.2)
@ -21013,7 +21036,7 @@ snapshots:
rollup: 4.43.0
tinyglobby: 0.2.14
optionalDependencies:
'@types/node': 24.0.0
'@types/node': 20.19.7
fsevents: 2.3.3
jiti: 2.4.2
lightningcss: 1.30.1