mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-11 10:08:27 +00:00
Feat: mcp proxy, new linear integration
This commit is contained in:
parent
769c79f773
commit
4de1d29fe4
@ -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}
|
||||
/>
|
||||
|
||||
113
apps/webapp/app/routes/api.v1.mcp.$slug.tsx
Normal file
113
apps/webapp/app/routes/api.v1.mcp.$slug.tsx
Normal 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 };
|
||||
@ -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,
|
||||
|
||||
100
apps/webapp/app/routes/api.v1.oauth.callback.mcp.tsx
Normal file
100
apps/webapp/app/routes/api.v1.oauth.callback.mcp.tsx
Normal 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",
|
||||
)}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
14
apps/webapp/app/services/integrationAccount.server.ts
Normal file
14
apps/webapp/app/services/integrationAccount.server.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
22
integrations/linear/.prettierrc
Normal file
22
integrations/linear/.prettierrc
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
19
integrations/linear/backend/account-create.ts
Normal file
19
integrations/linear/backend/account-create.ts
Normal 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;
|
||||
}
|
||||
58
integrations/linear/backend/index.ts
Normal file
58
integrations/linear/backend/index.ts
Normal 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();
|
||||
599
integrations/linear/backend/schedule.ts
Normal file
599
integrations/linear/backend/schedule.ts
Normal 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);
|
||||
}
|
||||
98
integrations/linear/eslint.config.js
Normal file
98
integrations/linear/eslint.config.js
Normal 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',
|
||||
},
|
||||
},
|
||||
];
|
||||
71
integrations/linear/package.json
Normal file
71
integrations/linear/package.json
Normal 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
13881
integrations/linear/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
71
integrations/linear/rollup.config.mjs
Normal file
71
integrations/linear/rollup.config.mjs
Normal 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(),
|
||||
],
|
||||
},
|
||||
];
|
||||
19
integrations/linear/spec.json
Normal file
19
integrations/linear/spec.json
Normal 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"
|
||||
}
|
||||
}
|
||||
32
integrations/linear/tsconfig.frontend.json
Normal file
32
integrations/linear/tsconfig.frontend.json
Normal 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"]
|
||||
}
|
||||
31
integrations/linear/tsconfig.json
Normal file
31
integrations/linear/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@sol/slack",
|
||||
"name": "@core/slack",
|
||||
"version": "0.1.2",
|
||||
"description": "slack extension for Sol",
|
||||
"main": "./bin/index.js",
|
||||
|
||||
@ -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
3
packages/mcp-proxy/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
dist/
|
||||
|
||||
node_modules/
|
||||
163
packages/mcp-proxy/README.md
Normal file
163
packages/mcp-proxy/README.md
Normal 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
|
||||
46
packages/mcp-proxy/package.json
Normal file
46
packages/mcp-proxy/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
403
packages/mcp-proxy/src/core/mcp-remote-client.ts
Normal file
403
packages/mcp-proxy/src/core/mcp-remote-client.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
51
packages/mcp-proxy/src/index.ts
Normal file
51
packages/mcp-proxy/src/index.ts
Normal 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
|
||||
314
packages/mcp-proxy/src/lib/coordination.ts
Normal file
314
packages/mcp-proxy/src/lib/coordination.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
278
packages/mcp-proxy/src/lib/in-memory-auth-storage.ts
Normal file
278
packages/mcp-proxy/src/lib/in-memory-auth-storage.ts
Normal 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();
|
||||
401
packages/mcp-proxy/src/lib/node-oauth-client-provider.ts
Normal file
401
packages/mcp-proxy/src/lib/node-oauth-client-provider.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
311
packages/mcp-proxy/src/lib/utils.ts
Normal file
311
packages/mcp-proxy/src/lib/utils.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
14
packages/mcp-proxy/src/types/index.ts
Normal file
14
packages/mcp-proxy/src/types/index.ts
Normal 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'
|
||||
267
packages/mcp-proxy/src/types/remote-client.ts
Normal file
267
packages/mcp-proxy/src/types/remote-client.ts
Normal 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>;
|
||||
75
packages/mcp-proxy/src/utils/auth-provider-factory.ts
Normal file
75
packages/mcp-proxy/src/utils/auth-provider-factory.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
30
packages/mcp-proxy/src/utils/errors.ts
Normal file
30
packages/mcp-proxy/src/utils/errors.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
3
packages/mcp-proxy/src/utils/index.ts
Normal file
3
packages/mcp-proxy/src/utils/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./mcp-transport-bridge";
|
||||
export * from "./mcp-transport";
|
||||
export * from "./auth-provider-factory";
|
||||
116
packages/mcp-proxy/src/utils/mcp-transport-bridge.ts
Normal file
116
packages/mcp-proxy/src/utils/mcp-transport-bridge.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
87
packages/mcp-proxy/src/utils/mcp-transport.ts
Normal file
87
packages/mcp-proxy/src/utils/mcp-transport.ts
Normal 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 = () => {};
|
||||
}
|
||||
44
packages/mcp-proxy/tsconfig.json
Normal file
44
packages/mcp-proxy/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
22
packages/mcp-proxy/tsup.config.ts
Normal file
22
packages/mcp-proxy/tsup.config.ts
Normal 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
137
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user