Feat: added integration connect and mcp oAuth (#20)

* Feat: added integration connect and mcp oAuth

* Feat: add mcp support to chat

* Fix: UI for integrations and logs

* Fix: ui

* Fix: proxy server

* Feat: enhance MCP tool integration and loading functionality

* Fix: added header

* Fix: Linear integration sync

---------

Co-authored-by: Manoj K <saimanoj58@gmail.com>
This commit is contained in:
Harshith Mullapudi 2025-07-17 12:41:32 +05:30 committed by GitHub
parent ff8fd1c985
commit 038acea669
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
82 changed files with 3329 additions and 11488 deletions

View File

@ -0,0 +1,137 @@
import { useNavigate } from "@remix-run/react";
import { Button } from "~/components/ui/button";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { SidebarTrigger } from "~/components/ui/sidebar";
export interface BreadcrumbItem {
label: string;
href?: string;
}
export interface PageHeaderAction {
label: string;
icon?: React.ReactNode;
onClick: () => void;
variant?: "default" | "secondary" | "outline" | "ghost";
}
export interface PageHeaderTab {
label: string;
value: string;
isActive: boolean;
onClick: () => void;
}
export interface PageHeaderProps {
title: string;
breadcrumbs?: BreadcrumbItem[];
actions?: PageHeaderAction[];
tabs?: PageHeaderTab[];
showBackForward?: boolean;
}
// Back and Forward navigation component
function NavigationBackForward() {
const navigate = useNavigate();
return (
<div className="mr-1 flex items-center gap-1">
<Button
variant="ghost"
size="xs"
aria-label="Back"
onClick={() => navigate(-1)}
className="rounded"
type="button"
>
<ArrowLeft size={16} />
</Button>
<Button
variant="ghost"
size="xs"
aria-label="Forward"
onClick={() => navigate(1)}
className="rounded"
type="button"
>
<ArrowRight size={16} />
</Button>
</div>
);
}
export function PageHeader({
title,
breadcrumbs,
actions,
tabs,
showBackForward = true,
}: PageHeaderProps) {
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b border-gray-300 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center justify-between gap-1 px-4 pr-2 lg:gap-2">
<div className="-ml-1 flex items-center gap-1">
{/* Back/Forward navigation before SidebarTrigger */}
{showBackForward && <NavigationBackForward />}
<SidebarTrigger className="mr-1" />
{/* Breadcrumbs */}
{breadcrumbs && breadcrumbs.length > 0 ? (
<nav className="mt-0.5 flex items-center space-x-1">
{breadcrumbs.map((breadcrumb, index) => (
<div key={index} className="flex items-center">
{index > 0 && (
<span className="text-muted-foreground mx-1">/</span>
)}
{breadcrumb.href ? (
<a href={breadcrumb.href}>{breadcrumb.label}</a>
) : (
<span className="text-gray-900">{breadcrumb.label}</span>
)}
</div>
))}
</nav>
) : (
<h1 className="text-base">{title}</h1>
)}
{/* Tabs */}
{tabs && tabs.length > 0 && (
<div className="ml-2 flex items-center gap-0.5">
{tabs.map((tab) => (
<Button
key={tab.value}
size="sm"
variant="secondary"
className="rounded"
isActive={tab.isActive}
onClick={tab.onClick}
aria-current={tab.isActive ? "page" : undefined}
>
{tab.label}
</Button>
))}
</div>
)}
</div>
{/* Actions */}
{actions && actions.length > 0 && (
<div className="flex items-center gap-2">
{actions.map((action, index) => (
<Button
key={index}
onClick={action.onClick}
variant={action.variant || "secondary"}
className="gap-2"
>
{action.icon}
{action.label}
</Button>
))}
</div>
)}
</div>
</header>
);
}

View File

@ -5,6 +5,7 @@ import { UserTypeEnum } from "@core/types";
import { type ConversationHistory } from "@core/database";
import { cn } from "~/lib/utils";
import { extensionsForConversation } from "./editor-extensions";
import { skillExtension } from "../editor/skill-extension";
interface AIConversationItemProps {
conversationHistory: ConversationHistory;
@ -20,7 +21,7 @@ export const ConversationItem = ({
const id = `a${conversationHistory.id.replace(/-/g, "")}`;
const editor = useEditor({
extensions: [...extensionsForConversation],
extensions: [...extensionsForConversation, skillExtension],
editable: false,
content: conversationHistory.message,
});

View File

@ -142,7 +142,7 @@ export const ConversationList = ({
className={cn(
"border-border h-auto w-full justify-start rounded p-2 py-1 text-left",
currentConversationId === conversation.id &&
"bg-accent text-accent-foreground font-semibold",
"bg-accent font-semibold",
)}
onClick={() => {
navigate(`/home/conversation/${conversation.id}`);
@ -155,7 +155,7 @@ export const ConversationList = ({
<div className="flex w-full items-start space-x-3">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className={cn("truncate font-normal")}>
<p className={cn("text-foreground truncate font-normal")}>
{conversation.title || "Untitled Conversation"}
</p>
</div>

View File

@ -8,11 +8,6 @@ import { History } from "@tiptap/extension-history";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { Button } from "../ui";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "../ui/resizable";
export const ConversationNew = ({
user,
@ -45,106 +40,96 @@ export const ConversationNew = ({
);
return (
<ResizablePanelGroup direction="horizontal" className="rounded-md">
<ResizableHandle className="w-1" />
<Form
action="/home/conversation"
method="post"
onSubmit={(e) => submitForm(e)}
className="h-[calc(100vh_-_56px)] pt-2"
>
<div className={cn("flex h-[calc(100vh_-_56px)] flex-col")}>
<div className="flex h-full w-full flex-col items-start justify-start overflow-y-auto p-4">
<div className="flex w-full flex-col items-center">
<div className="w-full max-w-[90ch]">
<h1 className="mx-1 text-left text-[32px] font-medium">
Hello <span className="text-primary">{user.name}</span>
</h1>
<ResizablePanel
collapsible
collapsedSize={0}
className="flex h-[calc(100vh_-_24px)] w-full flex-col"
>
<Form
action="/home/conversation"
method="post"
onSubmit={(e) => submitForm(e)}
className="pt-2"
>
<div className={cn("flex h-[calc(100vh_-_56px)] flex-col")}>
<div className="flex h-full w-full flex-col items-start justify-start overflow-y-auto p-4">
<div className="flex w-full flex-col items-center">
<div className="w-full max-w-[90ch]">
<h1 className="mx-1 text-left text-[32px] font-medium">
Hello <span className="text-primary">{user.name}</span>
</h1>
<p className="text-muted-foreground mx-1 mb-4">
Demo UI: basic conversation to showcase memory integration.
</p>
<div className="bg-background-3 rounded-lg border-1 border-gray-300 py-2">
<EditorRoot>
<EditorContent
ref={editorRef}
autofocus
extensions={[
Placeholder.configure({
placeholder: () => {
return "Ask CORE ...";
},
includeChildren: true,
}),
Document,
Paragraph,
Text,
HardBreak.configure({
keepMarks: true,
}),
History,
]}
editorProps={{
attributes: {
class: `prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full`,
},
handleKeyDown: (_view: any, event: KeyboardEvent) => {
// This is the ProseMirror event, not React's
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
<p className="text-muted-foreground mx-1 mb-4">
Demo UI: basic conversation to showcase memory integration.
</p>
<div className="bg-background-3 rounded-lg border-1 border-gray-300 py-2">
<EditorRoot>
<EditorContent
ref={editorRef}
autofocus
extensions={[
Placeholder.configure({
placeholder: () => {
return "Ask CORE ...";
},
includeChildren: true,
}),
Document,
Paragraph,
Text,
HardBreak.configure({
keepMarks: true,
}),
History,
]}
editorProps={{
attributes: {
class: `prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full`,
},
handleKeyDown: (_view: any, event: KeyboardEvent) => {
// This is the ProseMirror event, not React's
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (content) {
submit(
{ message: content, title: content },
{
action: "/home/conversation",
method: "post",
},
);
if (content) {
submit(
{ message: content, title: content },
{
action: "/home/conversation",
method: "post",
},
);
setContent("");
setTitle("");
}
return true;
}
return false;
},
}}
immediatelyRender={false}
className={cn(
"editor-container text-md max-h-[400px] min-h-[30px] w-full min-w-full overflow-auto px-3 pt-1 sm:rounded-lg",
)}
onUpdate={({ editor }: { editor: any }) => {
const html = editor.getHTML();
const text = editor.getText();
setContent(html);
setTitle(text);
}}
/>
</EditorRoot>
<div className="mb-1 flex justify-end px-3">
<Button
variant="default"
className="gap-1 shadow-none transition-all duration-500 ease-in-out"
type="submit"
size="lg"
>
Chat
</Button>
</div>
</div>
setContent("");
setTitle("");
}
return true;
}
return false;
},
}}
immediatelyRender={false}
className={cn(
"editor-container text-md max-h-[400px] min-h-[30px] w-full min-w-full overflow-auto px-3 pt-1 sm:rounded-lg",
)}
onUpdate={({ editor }: { editor: any }) => {
const html = editor.getHTML();
const text = editor.getText();
setContent(html);
setTitle(text);
}}
/>
</EditorRoot>
<div className="mb-1 flex justify-end px-3">
<Button
variant="default"
className="gap-1 shadow-none transition-all duration-500 ease-in-out"
type="submit"
size="lg"
>
Chat
</Button>
</div>
</div>
</div>
</div>
</Form>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</Form>
);
};

View File

@ -0,0 +1,14 @@
import React from "react";
interface ConversationContextInterface {
conversationHistoryId: string;
// Used just in streaming
streaming?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
actionMessages?: Record<string, any>;
}
export const ConversationContext = React.createContext<
ConversationContextInterface | undefined
>(undefined);

View File

@ -0,0 +1 @@
export * from './skill-extension';

View File

@ -0,0 +1,58 @@
import { NodeViewWrapper } from "@tiptap/react";
import React from "react";
import { getIcon as iconUtil, type IconType } from "../../icon-utils";
import { ChevronDown, ChevronRight } from "lucide-react";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const SkillComponent = (props: any) => {
const id = props.node.attrs.id;
const name = props.node.attrs.name;
const agent = props.node.attrs.agent;
const [open, setOpen] = React.useState(false);
if (id === "undefined" || id === undefined || !name) {
return null;
}
const getIcon = () => {
const Icon = iconUtil(agent as IconType);
return <Icon size={18} className="rounded-sm" />;
};
const snakeToTitleCase = (input: string): string => {
if (!input) {
return "";
}
const words = input.split("_");
// First word: capitalize first letter
const firstWord =
words[0].charAt(0).toUpperCase() + words[0].slice(1).toLowerCase();
// Rest of the words: all lowercase
const restWords = words.slice(1).map((word) => word.toLowerCase());
// Join with spaces
return [firstWord, ...restWords].join(" ");
};
const getComponent = () => {
return (
<>
<div className="bg-grayAlpha-100 text-sm-md mt-0.5 flex w-fit items-center gap-2 rounded p-2">
{getIcon()}
<span className="font-mono text-sm">{snakeToTitleCase(name)}</span>
</div>
</>
);
};
return (
<NodeViewWrapper className="inline w-fit">{getComponent()}</NodeViewWrapper>
);
};

View File

@ -0,0 +1,47 @@
import { ReactNodeViewRenderer, Node, mergeAttributes } from "@tiptap/react";
import { SkillComponent } from "./skill-component";
export const skillExtension = Node.create({
name: "skill",
group: "block",
atom: true,
selectable: false,
addAttributes() {
return {
id: {
default: undefined,
},
name: {
default: undefined,
},
agent: {
default: undefined,
},
};
},
parseHTML() {
return [
{
tag: "skill",
getAttrs: (element) => {
return {
id: element.getAttribute("id"),
name: element.getAttribute("name"),
agent: element.getAttribute("agent"),
};
},
},
];
},
renderHTML({ HTMLAttributes }) {
return ["skill", mergeAttributes(HTMLAttributes)];
},
addNodeView() {
return ReactNodeViewRenderer(SkillComponent);
},
});

View File

@ -5,14 +5,16 @@ import {
RiSlackFill,
} from "@remixicon/react";
import { LayoutGrid } from "lucide-react";
import { LinearIcon, SlackIcon } from "./icons";
export const ICON_MAPPING = {
slack: RiSlackFill,
slack: SlackIcon,
email: RiMailFill,
discord: RiDiscordFill,
github: RiGithubFill,
gmail: RiMailFill,
linear: LinearIcon,
// Default icon
integration: LayoutGrid,

View File

@ -0,0 +1,2 @@
export * from "./slack-icon";
export * from "./linear-icon";

View File

@ -0,0 +1,23 @@
import type { IconProps } from "./types";
export function LinearIcon({ size = 18, className }: IconProps) {
return (
<svg
width={size}
height={size}
className={className}
viewBox="0 0 256 255.999966"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid"
>
<title>Linear</title>
<g>
<path
d="M8.17351507,102.61269 L153.38731,247.826434 C155.506492,249.945871 154.484163,253.546047 151.537796,254.096394 C146.624112,255.014493 141.611832,255.651912 136.517857,255.991749 C135.430224,256.064224 134.367944,255.654216 133.5971,254.883372 L1.11657404,122.4029 C0.345783548,121.632056 -0.0643290124,120.569776 0.0082341699,119.482143 C0.348078154,114.388168 0.985591977,109.375888 1.9035369,104.462204 C2.45395044,101.515837 6.05412868,100.493508 8.17351507,102.61269 Z M4.08163806,161.409157 C3.11270474,157.795152 7.38226007,155.515399 10.0279718,158.161111 L97.8388892,245.972054 C100.484601,248.617766 98.2048481,252.887372 94.5908433,251.918311 C50.5602697,240.113381 15.8867212,205.43973 4.08163806,161.409157 Z M16.8085843,64.1644626 C18.0417047,62.028635 20.9559826,61.7013463 22.6999089,63.4453494 L192.554651,233.299989 C194.298654,235.043992 193.971621,237.958347 191.835537,239.191441 C188.24202,241.266062 184.538382,243.171405 180.73538,244.896457 C179.304068,245.545401 177.625881,245.218112 176.514687,244.106918 L11.8930308,79.4853127 C10.7818114,78.3741189 10.4544971,76.695932 11.1036452,75.2646202 C12.828595,71.4616177 14.7338864,67.7579799 16.8085843,64.1644626 Z M127.860072,0 C198.629979,0 256,57.3702771 256,128.139928 C256,165.709238 239.831733,199.502437 214.07401,222.940712 C212.587126,224.293659 210.305837,224.204026 208.884257,222.782702 L33.2172979,47.1157434 C31.7959738,45.6941631 31.7063407,43.4128738 33.0592877,41.9259895 C56.4975633,16.1681897 90.2907616,0 127.860072,0 Z"
fill="#222326"
></path>
</g>
</svg>
);
}

View File

@ -0,0 +1,30 @@
import type { IconProps } from "./types";
export function SlackIcon({ size = 18, className }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 127 127"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z"
fill="#E01E5A"
/>
<path
d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z"
fill="#36C5F0"
/>
<path
d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z"
fill="#2EB67D"
/>
<path
d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z"
fill="#ECB22E"
/>
</svg>
);
}

View File

@ -0,0 +1,6 @@
export interface IconProps {
size?: number;
className?: string;
color?: string;
onClick?: (event: MouseEvent) => void;
}

View File

@ -1,248 +0,0 @@
import React, { useState, useCallback } from "react";
import { useFetcher } from "@remix-run/react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { FormButtons } from "~/components/ui/FormButtons";
interface IntegrationAuthDialogProps {
integration: {
id: string;
name: string;
description?: string;
spec: any;
};
children: React.ReactNode;
onOpenChange?: (open: boolean) => void;
}
function parseSpec(spec: any) {
if (!spec) return {};
if (typeof spec === "string") {
try {
return JSON.parse(spec);
} catch {
return {};
}
}
return spec;
}
export function IntegrationAuthDialog({
integration,
children,
onOpenChange,
}: IntegrationAuthDialogProps) {
const [apiKey, setApiKey] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [isMCPConnecting, setIsMCPConnecting] = useState(false);
const apiKeyFetcher = useFetcher();
const oauthFetcher = useFetcher<{ redirectURL: string }>();
const mcpFetcher = useFetcher<{ redirectURL: string }>();
const specData = parseSpec(integration.spec);
const hasApiKey = !!specData?.auth?.api_key;
const hasOAuth2 = !!specData?.auth?.OAuth2;
const hasMCPAuth = !!specData?.mcpAuth;
const handleApiKeyConnect = useCallback(() => {
if (!apiKey.trim()) return;
setIsLoading(true);
apiKeyFetcher.submit(
{
integrationDefinitionId: integration.id,
apiKey,
},
{
method: "post",
action: "/api/v1/integration_account",
encType: "application/json",
},
);
}, [integration.id, apiKey, apiKeyFetcher]);
const handleOAuthConnect = useCallback(() => {
setIsConnecting(true);
oauthFetcher.submit(
{
integrationDefinitionId: integration.id,
redirectURL: window.location.href,
},
{
method: "post",
action: "/api/v1/oauth",
encType: "application/json",
},
);
}, [integration.id, oauthFetcher]);
const handleMCPConnect = useCallback(() => {
setIsMCPConnecting(true);
mcpFetcher.submit(
{
integrationDefinitionId: integration.id,
redirectURL: window.location.href,
mcp: true,
},
{
method: "post",
action: "/api/v1/oauth",
encType: "application/json",
},
);
}, [integration.id, mcpFetcher]);
// Watch for fetcher completion
React.useEffect(() => {
if (apiKeyFetcher.state === "idle" && isLoading) {
if (apiKeyFetcher.data !== undefined) {
window.location.reload();
}
}
}, [apiKeyFetcher.state, apiKeyFetcher.data, isLoading]);
React.useEffect(() => {
if (oauthFetcher.state === "idle" && isConnecting) {
if (oauthFetcher.data?.redirectURL) {
window.location.href = oauthFetcher.data.redirectURL;
} else {
setIsConnecting(false);
}
}
}, [oauthFetcher.state, oauthFetcher.data, isConnecting]);
React.useEffect(() => {
if (mcpFetcher.state === "idle" && isMCPConnecting) {
if (mcpFetcher.data?.redirectURL) {
window.location.href = mcpFetcher.data.redirectURL;
} else {
setIsMCPConnecting(false);
}
}
}, [mcpFetcher.state, mcpFetcher.data, isMCPConnecting]);
return (
<Dialog
onOpenChange={(open) => {
if (open) {
setApiKey("");
}
onOpenChange?.(open);
}}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="p-4 sm:max-w-md">
<DialogHeader>
<DialogTitle>Connect to {integration.name}</DialogTitle>
<DialogDescription>
{integration.description ||
`Connect your ${integration.name} account to enable integration.`}
</DialogDescription>
</DialogHeader>
{/* API Key Authentication */}
{hasApiKey && (
<div className="space-y-4 py-4">
<div className="space-y-2">
<label htmlFor="apiKey" className="text-sm font-medium">
{specData?.auth?.api_key?.label || "API Key"}
</label>
<Input
id="apiKey"
type="password"
placeholder="Enter your API key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
{specData?.auth?.api_key?.description && (
<p className="text-muted-foreground text-xs">
{specData.auth.api_key.description}
</p>
)}
</div>
<FormButtons
confirmButton={
<Button
type="button"
variant="default"
disabled={isLoading || !apiKey.trim()}
onClick={handleApiKeyConnect}
>
{isLoading || apiKeyFetcher.state === "submitting"
? "Connecting..."
: "Connect"}
</Button>
}
/>
</div>
)}
{/* OAuth Authentication */}
{hasOAuth2 && (
<div className="flex justify-center py-4">
<Button
type="button"
variant="default"
size="lg"
disabled={isConnecting || oauthFetcher.state === "submitting"}
onClick={handleOAuthConnect}
>
{isConnecting || oauthFetcher.state === "submitting"
? "Connecting..."
: `Connect to ${integration.name}`}
</Button>
</div>
)}
{/* MCP Authentication */}
{hasMCPAuth && (
<div className="space-y-4 py-4">
<div className="border-t pt-4">
<h4 className="text-sm font-medium mb-2">MCP Authentication</h4>
<p className="text-muted-foreground text-xs mb-4">
This integration requires MCP (Model Context Protocol) authentication.
</p>
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
disabled={isMCPConnecting || mcpFetcher.state === "submitting"}
onClick={handleMCPConnect}
>
{isMCPConnecting || mcpFetcher.state === "submitting"
? "Connecting..."
: `Connect via MCP`}
</Button>
</div>
</div>
)}
{/* No authentication method found */}
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
<div className="text-muted-foreground py-4 text-center">
This integration doesn't specify an authentication method.
</div>
)}
<DialogFooter className="sm:justify-start">
<div className="text-muted-foreground w-full text-xs">
By connecting, you agree to the {integration.name} terms of service.
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,109 @@
import React, { useState, useCallback } from "react";
import { useFetcher } from "@remix-run/react";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
interface ApiKeyAuthSectionProps {
integration: {
id: string;
name: string;
};
specData: any;
activeAccount: any;
}
export function ApiKeyAuthSection({
integration,
specData,
activeAccount,
}: ApiKeyAuthSectionProps) {
const [apiKey, setApiKey] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [showApiKeyForm, setShowApiKeyForm] = useState(false);
const apiKeyFetcher = useFetcher();
const handleApiKeyConnect = useCallback(() => {
if (!apiKey.trim()) return;
setIsLoading(true);
apiKeyFetcher.submit(
{
integrationDefinitionId: integration.id,
apiKey,
},
{
method: "post",
action: "/api/v1/integration_account",
encType: "application/json",
},
);
}, [integration.id, apiKey, apiKeyFetcher]);
React.useEffect(() => {
if (apiKeyFetcher.state === "idle" && isLoading) {
if (apiKeyFetcher.data !== undefined) {
window.location.reload();
}
}
}, [apiKeyFetcher.state, apiKeyFetcher.data, isLoading]);
if (activeAccount || !specData?.auth?.api_key) {
return null;
}
return (
<div className="bg-background-3 space-y-4 rounded-lg p-4">
<h4 className="font-medium">API Key Authentication</h4>
{!showApiKeyForm ? (
<Button
variant="secondary"
onClick={() => setShowApiKeyForm(true)}
className="w-full"
>
Connect with API Key
</Button>
) : (
<div className="space-y-3">
<div className="space-y-2">
<label htmlFor="apiKey" className="text-sm font-medium">
{specData?.auth?.api_key?.label || "API Key"}
</label>
<Input
id="apiKey"
placeholder="Enter your API key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
{specData?.auth?.api_key?.description && (
<p className="text-muted-foreground text-xs">
{specData.auth.api_key.description}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={() => {
setShowApiKeyForm(false);
setApiKey("");
}}
>
Cancel
</Button>
<Button
type="button"
variant="default"
disabled={isLoading || !apiKey.trim()}
onClick={handleApiKeyConnect}
>
{isLoading || apiKeyFetcher.state === "submitting"
? "Connecting..."
: "Connect"}
</Button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,68 @@
import React, { useCallback } from "react";
import { useFetcher } from "@remix-run/react";
import { Button } from "~/components/ui/button";
import { Check } from "lucide-react";
interface ConnectedAccountSectionProps {
activeAccount?: {
id: string;
createdAt: string;
};
}
export function ConnectedAccountSection({
activeAccount,
}: ConnectedAccountSectionProps) {
const disconnectFetcher = useFetcher();
const handleDisconnect = useCallback(() => {
if (!activeAccount?.id) return;
disconnectFetcher.submit(
{
integrationAccountId: activeAccount.id,
},
{
method: "post",
action: "/api/v1/integration_account/disconnect",
encType: "application/json",
},
);
}, [activeAccount?.id, disconnectFetcher]);
React.useEffect(() => {
if (disconnectFetcher.state === "idle" && disconnectFetcher.data) {
window.location.reload();
}
}, [disconnectFetcher.state, disconnectFetcher.data]);
if (!activeAccount) return null;
return (
<div className="mt-6 space-y-2">
<h3 className="text-lg font-medium">Connected Account</h3>
<div className="bg-background-3 rounded-lg p-4">
<div className="text-sm">
<p className="inline-flex items-center gap-2 font-medium">
<Check size={16} /> Account ID: {activeAccount.id}
</p>
<p className="text-muted-foreground mb-3">
Connected on{" "}
{new Date(activeAccount.createdAt).toLocaleDateString()}
</p>
<div className="flex w-full justify-end">
<Button
variant="destructive"
disabled={disconnectFetcher.state === "submitting"}
onClick={handleDisconnect}
>
{disconnectFetcher.state === "submitting"
? "Disconnecting..."
: "Disconnect"}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,80 @@
import React, { useState, useCallback } from "react";
import { useFetcher } from "@remix-run/react";
import { Button } from "~/components/ui/button";
import { Textarea } from "~/components/ui/textarea";
interface IngestionRuleSectionProps {
ingestionRule: {
id: string;
text: string;
} | null;
activeAccount: any;
}
export function IngestionRuleSection({
ingestionRule,
activeAccount,
}: IngestionRuleSectionProps) {
const [ingestionRuleText, setIngestionRuleText] = useState(
ingestionRule?.text || "",
);
const ingestionRuleFetcher = useFetcher();
const handleIngestionRuleUpdate = useCallback(() => {
ingestionRuleFetcher.submit(
{
ingestionRule: ingestionRuleText,
},
{
method: "post",
},
);
}, [ingestionRuleText, ingestionRuleFetcher]);
React.useEffect(() => {
if (ingestionRuleFetcher.state === "idle") {
// Optionally show success message or refresh
}
}, [ingestionRuleFetcher.state, ingestionRuleFetcher.data]);
if (!activeAccount) {
return null;
}
return (
<div className="mt-6 space-y-4">
<h3 className="text-lg font-medium">Ingestion Rule</h3>
<div className="bg-background-3 space-y-4 rounded-lg p-4">
<div className="space-y-2">
<label htmlFor="ingestionRule" className="text-sm font-medium">
Rule Description
</label>
<Textarea
id="ingestionRule"
placeholder={`Example for Gmail: "Only ingest emails from the last 24 hours that contain the word 'urgent' or 'important' in the subject line or body. Skip promotional emails and newsletters. Focus on emails from known contacts or business domains."`}
value={ingestionRuleText}
onChange={(e) => setIngestionRuleText(e.target.value)}
className="min-h-[100px]"
/>
<p className="text-muted-foreground text-xs">
Describe what data should be ingested from this integration
</p>
</div>
<div className="flex justify-end">
<Button
variant="secondary"
disabled={
!ingestionRuleText.trim() ||
ingestionRuleFetcher.state === "submitting"
}
onClick={handleIngestionRuleUpdate}
>
{ingestionRuleFetcher.state === "submitting"
? "Updating..."
: "Update Rule"}
</Button>
</div>
</div>
</div>
);
}

View File

@ -9,6 +9,7 @@ import {
CardTitle,
} from "~/components/ui/card";
import { getIcon, type IconType } from "~/components/icon-utils";
import { Badge } from "../ui/badge";
interface IntegrationCardProps {
integration: {
@ -19,26 +20,20 @@ interface IntegrationCardProps {
slug?: string;
};
isConnected: boolean;
onClick?: () => void;
showDetail?: boolean;
}
export function IntegrationCard({
integration,
isConnected,
onClick,
showDetail = false,
}: IntegrationCardProps) {
const Component = getIcon(integration.icon as IconType);
const CardWrapper = showDetail ? Link : "div";
const cardProps = showDetail
? { to: `/home/integration/${integration.slug || integration.id}` }
: { onClick, className: "cursor-pointer" };
return (
<CardWrapper {...cardProps}>
<Card className="transition-all hover:shadow-md">
<Link
to={`/home/integration/${integration.slug || integration.id}`}
className="bg-background-3 h-full rounded-lg"
>
<Card className="transition-all">
<CardHeader className="p-4">
<div className="bg-background-2 mb-2 flex h-6 w-6 items-center justify-center rounded">
<Component size={18} />
@ -51,13 +46,13 @@ export function IntegrationCard({
{isConnected && (
<CardFooter className="p-3">
<div className="flex w-full items-center justify-end">
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-800">
<Badge className="h-6 rounded bg-green-100 p-2 text-xs text-green-800">
Connected
</span>
</Badge>
</div>
</CardFooter>
)}
</Card>
</CardWrapper>
</Link>
);
}
}

View File

@ -1,7 +1,6 @@
import React, { useMemo } from "react";
import { Search } from "lucide-react";
import { IntegrationCard } from "./IntegrationCard";
import { IntegrationAuthDialog } from "./IntegrationAuthDialog";
import { IntegrationCard } from "./integration-card";
interface IntegrationGridProps {
integrations: Array<{
@ -19,7 +18,6 @@ interface IntegrationGridProps {
export function IntegrationGrid({
integrations,
activeAccountIds,
showDetail = false,
}: IntegrationGridProps) {
const hasActiveAccount = (integrationDefinitionId: string) =>
activeAccountIds.has(integrationDefinitionId);
@ -34,33 +32,17 @@ export function IntegrationGrid({
}
return (
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{integrations.map((integration) => {
const isConnected = hasActiveAccount(integration.id);
if (showDetail) {
return (
<IntegrationCard
key={integration.id}
integration={integration}
isConnected={isConnected}
showDetail={true}
/>
);
}
return (
<IntegrationAuthDialog
key={integration.id}
<IntegrationCard
integration={integration}
>
<IntegrationCard
integration={integration}
isConnected={isConnected}
/>
</IntegrationAuthDialog>
isConnected={isConnected}
/>
);
})}
</div>
);
}
}

View File

@ -0,0 +1,133 @@
import React, { useCallback, useState } from "react";
import { useFetcher } from "@remix-run/react";
import { Button } from "~/components/ui/button";
import { Check } from "lucide-react";
interface MCPAuthSectionProps {
integration: {
id: string;
name: string;
};
activeAccount?: {
id: string;
integrationConfiguration?: {
mcp?: any;
};
};
hasMCPAuth: boolean;
}
export function MCPAuthSection({
integration,
activeAccount,
hasMCPAuth,
}: MCPAuthSectionProps) {
const [isMCPConnecting, setIsMCPConnecting] = useState(false);
const mcpFetcher = useFetcher<{ redirectURL: string }>();
const disconnectMcpFetcher = useFetcher();
const isMCPConnected = activeAccount?.integrationConfiguration?.mcp;
const handleMCPConnect = useCallback(() => {
setIsMCPConnecting(true);
mcpFetcher.submit(
{
integrationDefinitionId: integration.id,
redirectURL: window.location.href,
integrationAccountId: activeAccount?.id as string,
mcp: true,
},
{
method: "post",
action: "/api/v1/oauth",
encType: "application/json",
},
);
}, [integration.id, mcpFetcher]);
const handleMCPDisconnect = useCallback(() => {
if (!activeAccount?.id) return;
disconnectMcpFetcher.submit(
{
integrationAccountId: activeAccount.id,
},
{
method: "post",
action: "/api/v1/integration_account/disconnect_mcp",
encType: "application/json",
},
);
}, [activeAccount?.id, disconnectMcpFetcher]);
// Watch for fetcher completion
React.useEffect(() => {
if (mcpFetcher.state === "idle" && isMCPConnecting) {
if (mcpFetcher.data?.redirectURL) {
window.location.href = mcpFetcher.data.redirectURL;
} else {
setIsMCPConnecting(false);
}
}
}, [mcpFetcher.state, mcpFetcher.data, isMCPConnecting]);
React.useEffect(() => {
if (disconnectMcpFetcher.state === "idle" && disconnectMcpFetcher.data) {
window.location.reload();
}
}, [disconnectMcpFetcher.state, disconnectMcpFetcher.data]);
if (!hasMCPAuth || !activeAccount) return null;
return (
<div className="mt-6 space-y-2">
<h3 className="text-lg font-medium">MCP Authentication</h3>
{isMCPConnected ? (
<div className="bg-background-3 rounded-lg p-4">
<div className="text-sm">
<p className="inline-flex items-center gap-2 font-medium">
<Check size={16} /> MCP Connected
</p>
<p className="text-muted-foreground mb-3">
MCP (Model Context Protocol) authentication is active
</p>
<div className="flex w-full justify-end">
<Button
variant="destructive"
disabled={disconnectMcpFetcher.state === "submitting"}
onClick={handleMCPDisconnect}
>
{disconnectMcpFetcher.state === "submitting"
? "Disconnecting..."
: "Disconnect"}
</Button>
</div>
</div>
</div>
) : activeAccount ? (
<div className="bg-background-3 rounded-lg p-4">
<h4 className="text-md mb-1 font-medium">
MCP (Model Context Protocol) Authentication
</h4>
<p className="text-muted-foreground mb-3 text-sm">
This integration requires MCP (Model Context Protocol)
authentication. Please provide the required MCP credentials in
addition to any other authentication method.
</p>
<div className="flex w-full justify-end">
<Button
variant="secondary"
disabled={isMCPConnecting || mcpFetcher.state === "submitting"}
onClick={handleMCPConnect}
>
{isMCPConnecting || mcpFetcher.state === "submitting"
? "Connecting..."
: `Connect for MCP`}
</Button>
</div>
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,68 @@
import React, { useState, useCallback } from "react";
import { useFetcher } from "@remix-run/react";
import { Button } from "~/components/ui/button";
interface OAuthAuthSectionProps {
integration: {
id: string;
name: string;
};
specData: any;
activeAccount: any;
}
export function OAuthAuthSection({
integration,
specData,
activeAccount,
}: OAuthAuthSectionProps) {
const [isConnecting, setIsConnecting] = useState(false);
const oauthFetcher = useFetcher<{ redirectURL: string }>();
const handleOAuthConnect = useCallback(() => {
setIsConnecting(true);
oauthFetcher.submit(
{
integrationDefinitionId: integration.id,
redirectURL: window.location.href,
},
{
method: "post",
action: "/api/v1/oauth",
encType: "application/json",
},
);
}, [integration.id, oauthFetcher]);
React.useEffect(() => {
if (oauthFetcher.state === "idle" && isConnecting) {
if (oauthFetcher.data?.redirectURL) {
window.location.href = oauthFetcher.data.redirectURL;
} else {
setIsConnecting(false);
}
}
}, [oauthFetcher.state, oauthFetcher.data, isConnecting]);
if (activeAccount || !specData?.auth?.OAuth2) {
return null;
}
return (
<div className="bg-background-3 rounded-lg p-4">
<h4 className="mb-3 font-medium">OAuth 2.0 Authentication</h4>
<Button
type="button"
variant="secondary"
size="lg"
disabled={isConnecting || oauthFetcher.state === "submitting"}
onClick={handleOAuthConnect}
className="w-full"
>
{isConnecting || oauthFetcher.state === "submitting"
? "Connecting..."
: `Connect to ${integration.name}`}
</Button>
</div>
);
}

View File

@ -0,0 +1,33 @@
interface SectionProps {
icon?: React.ReactNode;
title: string;
description: string;
metadata?: React.ReactNode;
children: React.ReactNode;
}
export function Section({
icon,
title,
description,
metadata,
children,
}: SectionProps) {
return (
<div className="flex h-full gap-6">
<div className="flex w-[400px] shrink-0 flex-col">
{icon && <>{icon}</>}
<h3 className="text-lg"> {title} </h3>
<p className="text-muted-foreground">{description}</p>
{metadata ? metadata : null}
</div>
<div className="grow">
<div className="flex h-full w-full justify-end overflow-auto">
<div className="flex h-full max-w-[76ch] grow flex-col gap-2">
{children}
</div>
</div>
</div>
</div>
);
}

View File

@ -1,8 +1,6 @@
import { useEffect, useRef, useState } from "react";
import {
List,
InfiniteLoader,
WindowScroller,
AutoSizer,
CellMeasurer,
CellMeasurerCache,
@ -12,10 +10,97 @@ import {
import { type LogItem } from "~/hooks/use-logs";
import { Badge } from "~/components/ui/badge";
import { Card, CardContent } from "~/components/ui/card";
import { AlertCircle, CheckCircle, Clock, XCircle } from "lucide-react";
import { AlertCircle } from "lucide-react";
import { cn } from "~/lib/utils";
import { ScrollManagedList } from "../virtualized-list";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
import { Button } from "../ui";
// --- LogTextCollapse component ---
function LogTextCollapse({ text, error }: { text?: string; error?: string }) {
const [dialogOpen, setDialogOpen] = useState(false);
// Show collapse if text is long (by word count)
const COLLAPSE_WORD_LIMIT = 30;
if (!text) {
return (
<div className="text-muted-foreground mb-2 text-xs italic">
No log details.
</div>
);
}
// Split by words for word count
const words = text.split(/\s+/);
const isLong = words.length > COLLAPSE_WORD_LIMIT;
let displayText: string;
if (isLong) {
displayText = words.slice(0, COLLAPSE_WORD_LIMIT).join(" ") + " ...";
} else {
displayText = text;
}
return (
<>
<div className="mb-2">
<p
className={cn(
"whitespace-p-wrap pt-2 text-sm break-words",
isLong ? "max-h-16 overflow-hidden" : "",
)}
style={{ lineHeight: "1.5" }}
>
{displayText}
</p>
{isLong && (
<>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl p-4">
<DialogHeader>
<DialogTitle className="flex w-full items-center justify-between">
<span>Log Details</span>
</DialogTitle>
</DialogHeader>
<div className="max-h-[70vh] overflow-auto p-0">
<p
className="px-3 py-2 text-sm break-words whitespace-pre-wrap"
style={{ lineHeight: "1.5" }}
>
{text}
</p>
</div>
</DialogContent>
</Dialog>
</>
)}
</div>
<div
className={cn(
"text-muted-foreground flex items-center justify-end text-xs",
isLong && "justify-between",
)}
>
{isLong && (
<Button variant="ghost" size="sm" className="-ml-2 rounded">
See full
</Button>
)}
{error && (
<div className="flex items-center gap-1 text-red-600">
<AlertCircle className="h-3 w-3" />
<span className="max-w-[200px] truncate" title={error}>
{error}
</span>
</div>
)}
</div>
</>
);
}
interface VirtualLogsListProps {
logs: LogItem[];
hasMore: boolean;
@ -48,37 +133,20 @@ function LogItemRenderer(
);
}
const getStatusIcon = (status: string) => {
switch (status) {
case "PROCESSING":
return <Clock className="h-4 w-4 text-blue-500" />;
case "PENDING":
return <Clock className="h-4 w-4 text-yellow-500" />;
case "COMPLETED":
return <CheckCircle className="h-4 w-4 text-green-500" />;
case "FAILED":
return <XCircle className="h-4 w-4 text-red-500" />;
case "CANCELLED":
return <XCircle className="h-4 w-4 text-gray-500" />;
default:
return <AlertCircle className="h-4 w-4 text-gray-500" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "PROCESSING":
return "bg-blue-100 text-blue-800";
return "bg-blue-100 text-blue-800 hover:bg-blue-100 hover:text-blue-800";
case "PENDING":
return "bg-yellow-100 text-yellow-800";
return "bg-yellow-100 text-yellow-800 hover:bg-yellow-100 hover:text-yellow-800";
case "COMPLETED":
return "bg-green-100 text-green-800";
return "bg-green-100 text-green-800 hover:bg-green-100 hover:text-green-800";
case "FAILED":
return "bg-red-100 text-red-800";
return "bg-red-100 text-red-800 hover:bg-red-100 hover:text-red-800";
case "CANCELLED":
return "bg-gray-100 text-gray-800";
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
default:
return "bg-gray-100 text-gray-800";
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
}
};
@ -95,13 +163,18 @@ function LogItemRenderer(
<CardContent className="p-4">
<div className="mb-2 flex items-start justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
<Badge variant="secondary" className="rounded text-xs">
{log.source}
</Badge>
<div className="flex items-center gap-1">
{getStatusIcon(log.status)}
<Badge className={cn("text-xs", getStatusColor(log.status))}>
{log.status.toLowerCase()}
<Badge
className={cn(
"rounded text-xs",
getStatusColor(log.status),
)}
>
{log.status.charAt(0).toUpperCase() +
log.status.slice(1).toLowerCase()}
</Badge>
</div>
</div>
@ -110,38 +183,7 @@ function LogItemRenderer(
</div>
</div>
<div className="mb-2">
<p className="text-sm text-gray-700">{log.ingestText}</p>
</div>
<div className="text-muted-foreground flex items-center justify-between text-xs">
<div className="flex items-center gap-4">
{log.sourceURL && (
<a
href={log.sourceURL}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline hover:text-blue-800"
>
Source URL
</a>
)}
{log.processedAt && (
<span>
Processed: {new Date(log.processedAt).toLocaleString()}
</span>
)}
</div>
{log.error && (
<div className="flex items-center gap-1 text-red-600">
<AlertCircle className="h-3 w-3" />
<span className="max-w-[200px] truncate" title={log.error}>
{log.error}
</span>
</div>
)}
</div>
<LogTextCollapse text={log.ingestText} error={log.error} />
</CardContent>
</Card>
</div>

View File

@ -52,7 +52,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<div className="mt-1 flex w-full items-center justify-start gap-2">
<div className="mt-1 ml-1 flex w-full items-center justify-start gap-2">
<Logo width={20} height={20} />
C.O.R.E.
</div>

View File

@ -30,7 +30,7 @@ export const NavMain = ({
<Button
isActive={location.pathname.includes(item.url)}
className={cn(
"bg-grayAlpha-100 w-fit gap-1 !rounded-md",
"bg-grayAlpha-100 text-foreground w-fit gap-1 !rounded-md",
location.pathname.includes(item.url) &&
"!bg-accent !text-accent-foreground",
)}

View File

@ -8,10 +8,10 @@ const badgeVariants = cva(
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-none bg-grayAlpha-100",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground bg-background",
},
},

View File

@ -1,8 +1,8 @@
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from '@radix-ui/react-icons';
import React from 'react';
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "@radix-ui/react-icons";
import React from "react";
import { cn } from '../../lib/utils';
import { cn } from "../../lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
@ -11,13 +11,13 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'checkbox peer h-4 w-4 shrink-0 rounded-sm border-1 border-border-dark focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground',
"checkbox peer border-border-dark focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground h-4 w-4 shrink-0 rounded-[5px] border-1 focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('flex items-center text-white justify-center')}
className={cn("flex items-center justify-center text-white")}
>
<CheckIcon className="h-3 w-3" />
</CheckboxPrimitive.Indicator>

View File

@ -1,13 +1,13 @@
import { useLocation, useNavigate } from "@remix-run/react";
import { Button } from "./button";
import { Plus } from "lucide-react";
import { ArrowLeft, ArrowRight, Plus } from "lucide-react";
import { SidebarTrigger } from "./sidebar";
import React from "react";
const PAGE_TITLES: Record<string, string> = {
"/home/dashboard": "Memory graph",
"/home/conversation": "Conversation",
"/home/integrations": "Integrations",
"/home/integration": "Integrations",
"/home/logs": "Logs",
};
@ -50,6 +50,36 @@ function getLogsTab(pathname: string): "all" | "activity" {
return "all";
}
// Back and Forward navigation component
function NavigationBackForward() {
const navigate = useNavigate();
return (
<div className="mr-1 flex items-center gap-1">
<Button
variant="ghost"
size="xs"
aria-label="Back"
onClick={() => navigate(-1)}
className="rounded"
type="button"
>
<ArrowLeft size={16} />
</Button>
<Button
variant="ghost"
size="xs"
aria-label="Forward"
onClick={() => navigate(1)}
className="rounded"
type="button"
>
<ArrowRight size={16} />
</Button>
</div>
);
}
export function SiteHeader() {
const location = useLocation();
const navigate = useNavigate();
@ -72,8 +102,10 @@ export function SiteHeader() {
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b border-gray-300 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center justify-between gap-1 px-4 pr-2 lg:gap-2">
<div className="flex items-center gap-1">
<SidebarTrigger className="-ml-1" />
<div className="-ml-1 flex items-center gap-1">
{/* Back/Forward navigation before SidebarTrigger */}
<NavigationBackForward />
<SidebarTrigger className="mr-1" />
<h1 className="text-base">{title}</h1>

View File

@ -0,0 +1,51 @@
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { requireUserId } from "~/services/session.server";
import { logger } from "~/services/logger.service";
import { prisma } from "~/db.server";
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") {
return json({ error: "Method not allowed" }, { status: 405 });
}
try {
const userId = await requireUserId(request);
const body = await request.json();
const { integrationAccountId } = body;
if (!integrationAccountId) {
return json(
{ error: "Integration account ID is required" },
{ status: 400 },
);
}
// Soft delete the integration account by setting deletedAt
const updatedAccount = await prisma.integrationAccount.delete({
where: {
id: integrationAccountId,
deleted: null,
},
});
logger.info("Integration account disconnected (soft deleted)", {
integrationAccountId,
userId,
});
return json({
success: true,
message: "Integration account disconnected successfully",
});
} catch (error) {
logger.error("Failed to disconnect integration account", {
error: error instanceof Error ? error.message : "Unknown error",
});
return json(
{ error: "Failed to disconnect integration account" },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,75 @@
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { requireUserId } from "~/services/session.server";
import { logger } from "~/services/logger.service";
import { prisma } from "~/db.server";
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") {
return json({ error: "Method not allowed" }, { status: 405 });
}
try {
const userId = await requireUserId(request);
const body = await request.json();
const { integrationAccountId } = body;
if (!integrationAccountId) {
return json(
{ error: "Integration account ID is required" },
{ status: 400 },
);
}
// Get the current integration account
const currentAccount = await prisma.integrationAccount.findUnique({
where: {
id: integrationAccountId,
deleted: null,
},
});
if (!currentAccount) {
return json({ error: "Integration account not found" }, { status: 404 });
}
// Parse the current configuration
const currentConfig =
(currentAccount.integrationConfiguration as any) || {};
// Remove the mcp key from the configuration
const updatedConfig = { ...currentConfig };
delete updatedConfig.mcp;
// Update the integration account
const updatedAccount = await prisma.integrationAccount.update({
where: {
id: integrationAccountId,
deleted: null,
},
data: {
integrationConfiguration: updatedConfig,
},
});
logger.info("MCP configuration disconnected", {
integrationAccountId,
userId,
});
return json({
success: true,
message: "MCP configuration disconnected successfully",
account: updatedAccount,
});
} catch (error) {
logger.error("Failed to disconnect MCP configuration", {
error: error instanceof Error ? error.message : "Unknown error",
});
return json(
{ error: "Failed to disconnect MCP configuration" },
{ status: 500 },
);
}
}

View File

@ -1,11 +1,13 @@
import { json } from "@remix-run/node";
import { z } from "zod";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { createIntegrationAccount } from "~/services/integrationAccount.server";
import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { IntegrationEventType } from "@core/types";
import { runIntegrationTrigger } from "~/services/integration.server";
import { getIntegrationDefinitionWithId } from "~/services/integrationDefinition.server";
import { logger } from "~/services/logger.service";
import { getWorkspaceByUser } from "~/models/workspace.server";
import { tasks } from "@trigger.dev/sdk";
import { scheduler } from "~/trigger/integrations/scheduler";
// Schema for creating an integration account with API key
const IntegrationAccountBodySchema = z.object({
@ -14,30 +16,30 @@ const IntegrationAccountBodySchema = z.object({
});
// Route for creating an integration account directly with an API key
const { action, loader } = createActionApiRoute(
const { action, loader } = createHybridActionApiRoute(
{
body: IntegrationAccountBodySchema,
allowJWT: true,
authorization: {
action: "create",
subject: "IntegrationAccount",
action: "integrationaccount:create",
},
corsStrategy: "all",
},
async ({ body, authentication }) => {
const { integrationDefinitionId, apiKey } = body;
const { userId } = authentication;
const workspace = await getWorkspaceByUser(authentication.userId);
try {
// Get the integration definition
const integrationDefinition = await getIntegrationDefinitionWithId(
integrationDefinitionId
integrationDefinitionId,
);
if (!integrationDefinition) {
return json(
{ error: "Integration definition not found" },
{ status: 404 }
{ status: 404 },
);
}
@ -50,26 +52,22 @@ const { action, loader } = createActionApiRoute(
apiKey,
},
},
userId
userId,
workspace?.id,
);
if (!setupResult || !setupResult.accountId) {
return json(
{ error: "Failed to setup integration with the provided API key" },
{ status: 400 }
{ status: 400 },
);
}
// Create the integration account
const integrationAccount = await createIntegrationAccount({
accountId: setupResult.accountId,
integrationDefinitionId,
userId,
config: setupResult.config || {},
settings: setupResult.settings || {},
await tasks.trigger<typeof scheduler>("scheduler", {
integrationAccountId: setupResult?.id,
});
return json({ success: true, integrationAccount });
return json({ success: true, setupResult });
} catch (error) {
logger.error("Error creating integration account", {
error,
@ -78,10 +76,10 @@ const { action, loader } = createActionApiRoute(
});
return json(
{ error: "Failed to create integration account" },
{ status: 500 }
{ status: 500 },
);
}
}
},
);
export { action, loader };
export { action, loader };

View File

@ -109,6 +109,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
const integrationDef =
log.activity?.integrationAccount?.integrationDefinition;
const logData = log.data as any;
return {
id: log.id,
source: integrationDef?.name || logData?.source || "Unknown",

View File

@ -1,6 +1,7 @@
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { createMCPProxy } from "@core/mcp-proxy";
import { getIntegrationDefinitionWithSlug } from "~/services/integrationDefinition.server";
import { proxyRequest } from "~/utils/proxy.server";
import { z } from "zod";
import { getIntegrationAccount } from "~/services/integrationAccount.server";
@ -59,49 +60,35 @@ const { action, loader } = createActionApiRoute(
);
}
const { serverUrl, transportStrategy } = spec.mcpAuth;
const { serverUrl } = spec.mcpAuth;
const mcpProxy = createMCPProxy(
{
serverUrl,
timeout: 30000,
debug: true,
transportStrategy: transportStrategy || "sse-first",
// Fix this
redirectUrl: "",
},
// 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),
};
},
// Find the integration account for this user and integration
const integrationAccount = await getIntegrationAccount(
integrationDefinition.id,
authentication.userId,
);
return await mcpProxy(request, "");
const integrationConfig =
integrationAccount?.integrationConfiguration as any;
if (!integrationAccount || !integrationConfig || !integrationConfig.mcp) {
return new Response(
JSON.stringify({
error: "No integration account with mcp config",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
// Proxy the request to the serverUrl
return await proxyRequest(
request,
serverUrl,
integrationConfig.mcp.tokens.access_token,
);
} catch (error: any) {
console.error("MCP Proxy Error:", error);
return new Response(JSON.stringify({ error: error.message }), {

View File

@ -6,6 +6,10 @@ import { createMCPAuthClient } from "@core/mcp-proxy";
import { logger } from "~/services/logger.service";
import { env } from "~/env.server";
import { getIntegrationDefinitionForState } from "~/services/oauth/oauth.server";
import {
getIntegrationAccount,
getIntegrationAccountForId,
} from "~/services/integrationAccount.server";
const CALLBACK_URL = `${env.APP_ORIGIN}/api/v1/oauth/callback`;
const MCP_CALLBACK_URL = `${CALLBACK_URL}/mcp`;
@ -26,8 +30,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
});
}
const { integrationDefinitionId, redirectURL } =
await getIntegrationDefinitionForState(state);
const {
integrationDefinitionId,
redirectURL,
userId,
workspaceId,
integrationAccountId,
} = await getIntegrationDefinitionForState(state);
try {
// For now, we'll assume Linear integration - in the future this should be derived from state
@ -35,6 +44,9 @@ export async function loader({ request }: LoaderFunctionArgs) {
integrationDefinitionId,
);
const integrationAccount =
await getIntegrationAccountForId(integrationAccountId);
if (!integrationDefinition) {
throw new Error("Integration definition not found");
}
@ -71,11 +83,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
state,
redirect_uri: MCP_CALLBACK_URL,
},
integrationDefinition,
mcp: true,
},
},
// We need to get userId from somewhere - for now using undefined
undefined,
userId,
workspaceId,
integrationAccount ?? undefined,
);
return new Response(null, {

View File

@ -14,7 +14,7 @@ const { loader } = createActionApiRoute(
for (const [key, value] of url.searchParams.entries()) {
params[key] = value;
}
return await callbackHandler(params, request);
return await callbackHandler(params);
},
);

View File

@ -4,7 +4,7 @@ import {
} from "@remix-run/server-runtime";
import { sort } from "fast-sort";
import { useParams, useRevalidator } from "@remix-run/react";
import { useParams, useRevalidator, useNavigate } from "@remix-run/react";
import {
requireUser,
requireUserId,
@ -25,6 +25,8 @@ import {
import { useTypedLoaderData } from "remix-typedjson";
import React from "react";
import { ScrollAreaWithAutoScroll } from "~/components/use-auto-scroll";
import { PageHeader } from "~/components/common/page-header";
import { Plus } from "lucide-react";
import { json } from "@remix-run/node";
import { env } from "~/env.server";
@ -84,6 +86,8 @@ export default function SingleConversation() {
const { conversationId } = useParams();
const revalidator = useRevalidator();
const navigate = useNavigate();
React.useEffect(() => {
if (run) {
setConversationResponse(run);
@ -129,49 +133,57 @@ export default function SingleConversation() {
}
return (
<ResizablePanelGroup direction="horizontal" className="!rounded-md">
<ResizableHandle className="w-1" />
<>
<PageHeader
title="Conversation"
breadcrumbs={[
{ label: "Conversations", href: "/home/conversation" },
{ label: conversation.title || "Untitled" },
]}
actions={[
{
label: "New conversation",
icon: <Plus size={14} />,
onClick: () => navigate("/home/conversation"),
variant: "secondary",
},
]}
/>
<ResizablePanel
collapsible
collapsedSize={0}
className="flex w-full flex-col"
>
<div className="relative flex h-[calc(100vh_-_70px)] w-full flex-col items-center justify-center overflow-auto">
<div className="flex h-[calc(100vh_-_56px)] w-full flex-col justify-end overflow-hidden">
<ScrollAreaWithAutoScroll>
{getConversations()}
{conversationResponse && (
<StreamingConversation
runId={conversationResponse.id}
token={conversationResponse.token}
afterStreaming={() => {
setConversationResponse(undefined);
revalidator.revalidate();
}}
apiURL={apiURL}
<div className="relative flex h-[calc(100vh_-_56px)] w-full flex-col items-center justify-center overflow-auto">
<div className="flex h-[calc(100vh_-_80px)] w-full flex-col justify-end overflow-hidden">
<ScrollAreaWithAutoScroll>
{getConversations()}
{conversationResponse && (
<StreamingConversation
runId={conversationResponse.id}
token={conversationResponse.token}
afterStreaming={() => {
setConversationResponse(undefined);
revalidator.revalidate();
}}
apiURL={apiURL}
/>
)}
</ScrollAreaWithAutoScroll>
<div className="flex w-full flex-col items-center">
<div className="w-full max-w-[97ch] px-1 pr-2">
{conversation?.status !== "need_approval" && (
<ConversationTextarea
conversationId={conversationId as string}
className="bg-background-3 w-full border-1 border-gray-300"
isLoading={
!!conversationResponse ||
conversation?.status === "running" ||
stopLoading
}
/>
)}
</ScrollAreaWithAutoScroll>
<div className="flex w-full flex-col items-center">
<div className="w-full max-w-[97ch] px-1 pr-2">
{conversation?.status !== "need_approval" && (
<ConversationTextarea
conversationId={conversationId as string}
className="bg-background-3 w-full border-1 border-gray-300"
isLoading={
!!conversationResponse ||
conversation?.status === "running" ||
stopLoading
}
/>
)}
</div>
</div>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</>
);
}

View File

@ -17,6 +17,7 @@ import {
CreateConversationSchema,
} from "~/services/conversation.server";
import { json } from "@remix-run/node";
import { PageHeader } from "~/components/common/page-header";
export async function loader({ request }: LoaderFunctionArgs) {
// Only return userId, not the heavy nodeLinks
@ -67,6 +68,9 @@ export default function Chat() {
const { user } = useTypedLoaderData<typeof loader>();
return (
<>{typeof window !== "undefined" && <ConversationNew user={user} />}</>
<>
<PageHeader title="Conversation" />
{typeof window !== "undefined" && <ConversationNew user={user} />}
</>
);
}

View File

@ -14,6 +14,7 @@ import { SearchBodyRequest } from "./search";
import { SearchService } from "~/services/search.server";
import { GraphVisualizationClient } from "~/components/graph/graph-client";
import { LoaderCircle } from "lucide-react";
import { PageHeader } from "~/components/common/page-header";
export async function action({ request }: ActionFunctionArgs) {
const userId = await requireUserId(request);
@ -84,18 +85,21 @@ export default function Dashboard() {
}, [userId]);
return (
<div className="home flex h-[calc(100vh_-_56px)] flex-col overflow-y-auto p-3 text-base">
<div className="flex grow items-center justify-center rounded">
{loading ? (
<div className="flex h-full w-full flex-col items-center justify-center">
<LoaderCircle size={18} className="mr-1 animate-spin" />
<span className="text-muted-foreground">Loading graph...</span>
</div>
) : (
typeof window !== "undefined" &&
nodeLinks && <GraphVisualizationClient triplets={nodeLinks} />
)}
<>
<PageHeader title="Memory graph" />
<div className="home flex h-[calc(100vh_-_56px)] flex-col overflow-y-auto p-3 text-base">
<div className="flex grow items-center justify-center rounded">
{loading ? (
<div className="flex h-full w-full flex-col items-center justify-center">
<LoaderCircle size={18} className="mr-1 animate-spin" />
<span className="text-muted-foreground">Loading graph...</span>
</div>
) : (
typeof window !== "undefined" &&
nodeLinks && <GraphVisualizationClient triplets={nodeLinks} />
)}
</div>
</div>
</div>
</>
);
}

View File

@ -1,14 +1,27 @@
import React, { useMemo } from "react";
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, Link } from "@remix-run/react";
import {
json,
type LoaderFunctionArgs,
type ActionFunctionArgs,
} from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { requireUserId, requireWorkpace } from "~/services/session.server";
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
import { IntegrationAuthDialog } from "~/components/integrations/IntegrationAuthDialog";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { getIcon, type IconType } from "~/components/icon-utils";
import { ArrowLeft, ExternalLink } from "lucide-react";
import { Checkbox } from "~/components/ui/checkbox";
import { MCPAuthSection } from "~/components/integrations/mcp-auth-section";
import { ConnectedAccountSection } from "~/components/integrations/connected-account-section";
import { IngestionRuleSection } from "~/components/integrations/ingestion-rule-section";
import { ApiKeyAuthSection } from "~/components/integrations/api-key-auth-section";
import { OAuthAuthSection } from "~/components/integrations/oauth-auth-section";
import {
getIngestionRuleBySource,
upsertIngestionRule,
} from "~/services/ingestionRule.server";
import { Section } from "~/components/integrations/section";
import { PageHeader } from "~/components/common/page-header";
import { Plus } from "lucide-react";
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
@ -21,20 +34,79 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
]);
const integration = integrationDefinitions.find(
(def) => def.slug === slug || def.id === slug
(def) => def.slug === slug || def.id === slug,
);
if (!integration) {
throw new Response("Integration not found", { status: 404 });
}
const activeAccount = integrationAccounts.find(
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive,
);
let ingestionRule = null;
if (activeAccount) {
ingestionRule = await getIngestionRuleBySource(
activeAccount.id,
workspace.id,
);
}
return json({
integration,
integrationAccounts,
userId,
ingestionRule,
});
}
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const workspace = await requireWorkpace(request);
const { slug } = params;
const formData = await request.formData();
const ingestionRuleText = formData.get("ingestionRule") as string;
if (!ingestionRuleText) {
return json({ error: "Ingestion rule is required" }, { status: 400 });
}
const [integrationDefinitions, integrationAccounts] = await Promise.all([
getIntegrationDefinitions(workspace.id),
getIntegrationAccounts(userId),
]);
const integration = integrationDefinitions.find(
(def) => def.slug === slug || def.id === slug,
);
if (!integration) {
throw new Response("Integration not found", { status: 404 });
}
const activeAccount = integrationAccounts.find(
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive,
);
if (!activeAccount) {
return json(
{ error: "No active integration account found" },
{ status: 400 },
);
}
await upsertIngestionRule({
text: ingestionRuleText,
source: activeAccount.id,
workspaceId: workspace.id,
userId,
});
return json({ success: true });
}
function parseSpec(spec: any) {
if (!spec) return {};
if (typeof spec === "string") {
@ -48,84 +120,75 @@ function parseSpec(spec: any) {
}
export default function IntegrationDetail() {
const { integration, integrationAccounts } = useLoaderData<typeof loader>();
const { integration, integrationAccounts, ingestionRule } =
useLoaderData<typeof loader>();
const activeAccount = useMemo(
() =>
integrationAccounts.find(
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive,
),
[integrationAccounts, integration.id]
[integrationAccounts, integration.id],
);
const specData = useMemo(() => parseSpec(integration.spec), [integration.spec]);
const specData = useMemo(
() => parseSpec(integration.spec),
[integration.spec],
);
const hasApiKey = !!specData?.auth?.api_key;
const hasOAuth2 = !!specData?.auth?.OAuth2;
const hasMCPAuth = !!specData?.mcpAuth;
const Component = getIcon(integration.icon as IconType);
return (
<div className="home flex h-full flex-col overflow-y-auto p-4 px-5">
{/* Header */}
<div className="mb-6 flex items-center gap-4">
<Link
to="/home/integrations"
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
<div className="flex h-full flex-col overflow-hidden">
<PageHeader
title="Integrations"
breadcrumbs={[
{ label: "Integrations", href: "/home/integrations" },
{ label: integration?.name || "Untitled" },
]}
actions={[
{
label: "Request New Integration",
icon: <Plus size={14} />,
onClick: () =>
window.open(
"https://github.com/redplanethq/core/issues/new",
"_blank",
),
variant: "secondary",
},
]}
/>
<div className="h-[calc(100vh_-_56px)] overflow-hidden p-4 px-5">
<Section
title={integration.name}
description={integration.description}
icon={
<div className="bg-grayAlpha-100 flex h-12 w-12 items-center justify-center rounded">
<Component size={24} />
</div>
}
>
<ArrowLeft size={16} />
Back to Integrations
</Link>
</div>
{/* Integration Details */}
<div className="mx-auto max-w-2xl space-y-6">
<Card>
<CardHeader>
<div className="flex items-start gap-4">
<div className="bg-background-2 flex h-12 w-12 items-center justify-center rounded">
<Component size={24} />
</div>
<div className="flex-1">
<CardTitle className="text-2xl">{integration.name}</CardTitle>
<CardDescription className="mt-2 text-base">
{integration.description || `Connect to ${integration.name}`}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{/* Connection Status */}
<div className="mb-6 flex items-center gap-3">
<span className="text-sm font-medium">Status:</span>
{activeAccount ? (
<span className="rounded-full bg-green-100 px-3 py-1 text-sm text-green-800">
Connected
</span>
) : (
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700">
Not Connected
</span>
)}
</div>
<div>
{/* Authentication Methods */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Authentication Methods</h3>
<div className="space-y-2">
{hasApiKey && (
<div className="flex items-center gap-2">
<span className="text-sm"> API Key authentication</span>
<span className="inline-flex items-center gap-2 text-sm">
<Checkbox checked /> API Key authentication
</span>
</div>
)}
{hasOAuth2 && (
<div className="flex items-center gap-2">
<span className="text-sm"> OAuth 2.0 authentication</span>
</div>
)}
{hasMCPAuth && (
<div className="flex items-center gap-2">
<span className="text-sm"> MCP (Model Context Protocol) authentication</span>
<span className="inline-flex items-center gap-2 text-sm">
<Checkbox checked />
OAuth 2.0 authentication
</span>
</div>
)}
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
@ -136,46 +199,47 @@ export default function IntegrationDetail() {
</div>
</div>
{/* Connect Button */}
{!activeAccount && (hasApiKey || hasOAuth2 || hasMCPAuth) && (
<div className="mt-6 flex justify-center">
<IntegrationAuthDialog integration={integration}>
<Button size="lg" className="px-8">
Connect to {integration.name}
</Button>
</IntegrationAuthDialog>
{/* Connect Section */}
{!activeAccount && (hasApiKey || hasOAuth2) && (
<div className="mt-6 space-y-4">
<h3 className="text-lg font-medium">
Connect to {integration.name}
</h3>
{/* API Key Authentication */}
<ApiKeyAuthSection
integration={integration}
specData={specData}
activeAccount={activeAccount}
/>
{/* OAuth Authentication */}
<OAuthAuthSection
integration={integration}
specData={specData}
activeAccount={activeAccount}
/>
</div>
)}
{/* Connected Account Info */}
{activeAccount && (
<div className="mt-6 space-y-4">
<h3 className="text-lg font-medium">Connected Account</h3>
<div className="rounded-lg border bg-green-50 p-4">
<div className="text-sm text-green-800">
<p className="font-medium">Account ID: {activeAccount.id}</p>
<p className="text-muted-foreground">
Connected on {new Date(activeAccount.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</div>
)}
<ConnectedAccountSection activeAccount={activeAccount} />
{/* Integration Spec Details */}
{specData && Object.keys(specData).length > 0 && (
<div className="mt-6 space-y-4">
<h3 className="text-lg font-medium">Integration Details</h3>
<div className="rounded-lg border bg-gray-50 p-4">
<pre className="text-sm text-gray-700">
{JSON.stringify(specData, null, 2)}
</pre>
</div>
</div>
)}
</CardContent>
</Card>
{/* MCP Authentication Section */}
<MCPAuthSection
integration={integration}
activeAccount={activeAccount as any}
hasMCPAuth={hasMCPAuth}
/>
{/* Ingestion Rule Section */}
<IngestionRuleSection
ingestionRule={ingestionRule}
activeAccount={activeAccount}
/>
</div>
</Section>
</div>
</div>
);
}
}

View File

@ -5,7 +5,9 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { requireUserId, requireWorkpace } from "~/services/session.server";
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
import { IntegrationGrid } from "~/components/integrations/IntegrationGrid";
import { IntegrationGrid } from "~/components/integrations/integration-grid";
import { PageHeader } from "~/components/common/page-header";
import { Plus } from "lucide-react";
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
@ -38,15 +40,28 @@ export default function Integrations() {
);
return (
<div className="home flex h-full flex-col overflow-y-auto p-4 px-5">
<div className="space-y-1 text-base">
<p className="text-muted-foreground">Connect your tools and services</p>
</div>
<IntegrationGrid
integrations={integrationDefinitions}
activeAccountIds={activeAccountIds}
<div className="flex h-full flex-col">
<PageHeader
title="Integrations"
actions={[
{
label: "Request New Integration",
icon: <Plus size={14} />,
onClick: () =>
window.open(
"https://github.com/redplanethq/core/issues/new",
"_blank",
),
variant: "secondary",
},
]}
/>
<div className="home flex h-[calc(100vh_-_56px)] flex-col overflow-y-auto p-4 px-5">
<IntegrationGrid
integrations={integrationDefinitions}
activeAccountIds={activeAccountIds}
/>
</div>
</div>
);
}

View File

@ -1,35 +1,37 @@
import { useState } from "react";
import { useNavigate } from "@remix-run/react";
import { useLogs } from "~/hooks/use-logs";
import { LogsFilters } from "~/components/logs/logs-filters";
import { VirtualLogsList } from "~/components/logs/virtual-logs-list";
import { AppContainer, PageContainer, PageBody } from "~/components/layout/app-layout";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { AppContainer, PageContainer } from "~/components/layout/app-layout";
import { Card, CardContent } from "~/components/ui/card";
import { Activity } from "lucide-react";
import { PageHeader } from "~/components/common/page-header";
export default function LogsActivity() {
const navigate = useNavigate();
const [selectedSource, setSelectedSource] = useState<string | undefined>();
const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
const {
logs,
hasMore,
loadMore,
availableSources,
isLoading,
isInitialLoad
} = useLogs({
endpoint: '/api/v1/logs/activity',
source: selectedSource,
status: selectedStatus
const {
logs,
hasMore,
loadMore,
availableSources,
isLoading,
isInitialLoad,
} = useLogs({
endpoint: "/api/v1/logs/activity",
source: selectedSource,
status: selectedStatus,
});
if (isInitialLoad) {
return (
<AppContainer>
<PageContainer>
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<div className="flex h-64 items-center justify-center">
<div className="border-primary h-8 w-8 animate-spin rounded-full border-b-2"></div>
</div>
</PageContainer>
</AppContainer>
@ -37,80 +39,64 @@ export default function LogsActivity() {
}
return (
<AppContainer>
<PageContainer>
<PageBody>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Activity className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-bold">Activity Ingestion Logs</h1>
<div className="flex h-full flex-col">
<PageHeader
title="Logs"
tabs={[
{
label: "All",
value: "all",
isActive: false,
onClick: () => navigate("/home/logs/all"),
},
{
label: "Activity",
value: "activity",
isActive: true,
onClick: () => navigate("/home/logs/activity"),
},
]}
/>
<div className="flex h-[calc(100vh_-_56px)] flex-col space-y-6 p-4 px-5">
{logs.length > 0 && (
<LogsFilters
availableSources={availableSources}
selectedSource={selectedSource}
selectedStatus={selectedStatus}
onSourceChange={setSelectedSource}
onStatusChange={setSelectedStatus}
/>
)}
{/* Logs List */}
<div className="space-y-4">
{logs.length === 0 ? (
<Card>
<CardContent className="bg-background-2 flex items-center justify-center py-16">
<div className="text-center">
<Activity className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold">
No activity logs found
</h3>
<p className="text-muted-foreground">
View ingestion logs for activities from connected integrations
{selectedSource || selectedStatus
? "Try adjusting your filters to see more results."
: "No activity ingestion logs are available yet."}
</p>
</div>
</div>
<Badge variant="outline" className="text-sm">
{logs.length} activity logs loaded
</Badge>
</div>
{/* Filters */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Filters</CardTitle>
</CardHeader>
<CardContent>
<LogsFilters
availableSources={availableSources}
selectedSource={selectedSource}
selectedStatus={selectedStatus}
onSourceChange={setSelectedSource}
onStatusChange={setSelectedStatus}
/>
</CardContent>
</Card>
{/* Logs List */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Activity Ingestion Queue</h2>
{hasMore && (
<span className="text-sm text-muted-foreground">
Scroll to load more...
</span>
)}
</div>
{logs.length === 0 ? (
<Card>
<CardContent className="flex items-center justify-center py-16">
<div className="text-center">
<Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">No activity logs found</h3>
<p className="text-muted-foreground">
{selectedSource || selectedStatus
? 'Try adjusting your filters to see more results.'
: 'No activity ingestion logs are available yet.'}
</p>
</div>
</CardContent>
</Card>
) : (
<VirtualLogsList
logs={logs}
hasMore={hasMore}
loadMore={loadMore}
isLoading={isLoading}
height={600}
/>
)}
</div>
</div>
</PageBody>
</PageContainer>
</AppContainer>
) : (
<VirtualLogsList
logs={logs}
hasMore={hasMore}
loadMore={loadMore}
isLoading={isLoading}
height={600}
/>
)}
</div>
</div>
</div>
);
}

View File

@ -1,12 +1,15 @@
import { useState } from "react";
import { useNavigate } from "@remix-run/react";
import { useLogs } from "~/hooks/use-logs";
import { LogsFilters } from "~/components/logs/logs-filters";
import { VirtualLogsList } from "~/components/logs/virtual-logs-list";
import { AppContainer, PageContainer } from "~/components/layout/app-layout";
import { Card, CardContent } from "~/components/ui/card";
import { Database } from "lucide-react";
import { PageHeader } from "~/components/common/page-header";
export default function LogsAll() {
const navigate = useNavigate();
const [selectedSource, setSelectedSource] = useState<string | undefined>();
const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
@ -36,42 +39,63 @@ export default function LogsAll() {
}
return (
<div className="space-y-6 p-4 px-5">
{/* Filters */}
<LogsFilters
availableSources={availableSources}
selectedSource={selectedSource}
selectedStatus={selectedStatus}
onSourceChange={setSelectedSource}
onStatusChange={setSelectedStatus}
<>
<PageHeader
title="Logs"
tabs={[
{
label: "All",
value: "all",
isActive: true,
onClick: () => navigate("/home/logs/all"),
},
{
label: "Activity",
value: "activity",
isActive: false,
onClick: () => navigate("/home/logs/activity"),
},
]}
/>
{/* Logs List */}
<div className="space-y-4">
{logs.length === 0 ? (
<Card>
<CardContent className="bg-background-2 flex items-center justify-center py-16">
<div className="text-center">
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold">No logs found</h3>
<p className="text-muted-foreground">
{selectedSource || selectedStatus
? "Try adjusting your filters to see more results."
: "No ingestion logs are available yet."}
</p>
</div>
</CardContent>
</Card>
) : (
<VirtualLogsList
logs={logs}
hasMore={hasMore}
loadMore={loadMore}
isLoading={isLoading}
height={600}
<div className="space-y-6 p-4 px-5">
{/* Filters */}
{logs.length > 0 && (
<LogsFilters
availableSources={availableSources}
selectedSource={selectedSource}
selectedStatus={selectedStatus}
onSourceChange={setSelectedSource}
onStatusChange={setSelectedStatus}
/>
)}
{/* Logs List */}
<div className="space-y-4">
{logs.length === 0 ? (
<Card>
<CardContent className="bg-background-2 flex items-center justify-center py-16">
<div className="text-center">
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold">No logs found</h3>
<p className="text-muted-foreground">
{selectedSource || selectedStatus
? "Try adjusting your filters to see more results."
: "No ingestion logs are available yet."}
</p>
</div>
</CardContent>
</Card>
) : (
<VirtualLogsList
logs={logs}
hasMore={hasMore}
loadMore={loadMore}
isLoading={isLoading}
height={600}
/>
)}
</div>
</div>
</div>
</>
);
}

View File

@ -7,7 +7,6 @@ import { clearRedirectTo, commitSession } from "~/services/redirectTo.server";
import { AppSidebar } from "~/components/sidebar/app-sidebar";
import { SidebarInset, SidebarProvider } from "~/components/ui/sidebar";
import { SiteHeader } from "~/components/ui/header";
import { FloatingIngestionStatus } from "~/components/ingestion/floating-ingestion-status";
export const loader = async ({ request }: LoaderFunctionArgs) => {
@ -40,8 +39,7 @@ export default function Home() {
>
<AppSidebar variant="inset" />
<SidebarInset className="bg-background-2 h-full rounded pr-0">
<SiteHeader />
<div className="flex h-[calc(100vh_-_56px)] flex-col rounded">
<div className="flex h-full flex-col rounded">
<div className="@container/main flex h-full flex-col gap-2">
<div className="flex h-full flex-col">
<Outlet />

View File

@ -0,0 +1,56 @@
import { prisma } from "~/db.server";
export async function getIngestionRuleBySource(
source: string,
workspaceId: string,
) {
return await prisma.ingestionRule.findFirst({
where: {
source,
workspaceId,
},
});
}
// Need to fix this later
export async function upsertIngestionRule({
text,
source,
workspaceId,
userId,
}: {
text: string;
source: string;
workspaceId: string;
userId: string;
}) {
// Find existing rule first
const existingRule = await prisma.ingestionRule.findFirst({
where: {
source,
workspaceId,
},
});
if (existingRule) {
// Update existing rule
return await prisma.ingestionRule.update({
where: {
id: existingRule.id,
},
data: {
text,
},
});
} else {
// Create new rule
return await prisma.ingestionRule.create({
data: {
text,
source,
workspaceId,
userId,
},
});
}
}

View File

@ -1,39 +1,12 @@
import { tasks } from "@trigger.dev/sdk/v3";
import { getOrCreatePersonalAccessToken } from "./personalAccessToken.server";
import { logger } from "./logger.service";
import { type integrationRun } from "~/trigger/integrations/integration-run";
import type { IntegrationDefinitionV2 } from "@core/database";
/**
* Prepares the parameters for triggering an integration.
* If userId is provided, gets or creates a personal access token for the user.
*/
async function prepareIntegrationTrigger(
integrationDefinition: IntegrationDefinitionV2,
userId?: string,
) {
logger.info(`Loading integration ${integrationDefinition.slug}`);
let pat = "";
let patId = "";
if (userId) {
// Use the integration slug as the token name for uniqueness
const tokenResult = await getOrCreatePersonalAccessToken({
name: integrationDefinition.slug ?? "integration",
userId,
});
pat = tokenResult.token ?? "";
patId = tokenResult.id ?? "";
}
return {
integrationDefinition,
pat,
patId,
};
}
import type {
IntegrationAccount,
IntegrationDefinitionV2,
} from "@core/database";
/**
* Triggers an integration run asynchronously.
@ -43,11 +16,24 @@ export async function runIntegrationTriggerAsync(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: any,
userId?: string,
workspaceId?: string,
) {
const params = await prepareIntegrationTrigger(integrationDefinition, userId);
logger.info(
`Triggering async integration run for ${integrationDefinition.slug}`,
{
integrationId: integrationDefinition.id,
event: event.event,
userId,
workspaceId,
},
);
return await tasks.trigger<typeof integrationRun>("integration-run", {
...params,
event,
integrationDefinition,
event: event.event,
eventBody: event.eventBody,
integrationAccount: event.integrationAccount,
workspaceId,
});
}
@ -59,14 +45,26 @@ export async function runIntegrationTrigger(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: any,
userId?: string,
workspaceId?: string,
integrationAccount?: IntegrationAccount,
) {
const params = await prepareIntegrationTrigger(integrationDefinition, userId);
logger.info(
`Triggering sync integration run for ${integrationDefinition.slug}`,
{
integrationId: integrationDefinition.id,
event: event.event,
userId,
workspaceId,
},
);
const response = await tasks.triggerAndPoll<typeof integrationRun>(
"integration-run",
{
...params,
integrationAccount: event.integrationAccount,
integrationDefinition,
integrationAccount,
workspaceId,
userId,
event: event.event,
eventBody: event.eventBody,
},

View File

@ -13,27 +13,10 @@ export const getIntegrationAccount = async (
});
};
export const createIntegrationAccount = async ({
integrationDefinitionId,
userId,
accountId,
config,
settings,
}: {
integrationDefinitionId: string;
userId: string;
accountId: string;
config?: Record<string, any>;
settings?: Record<string, any>;
}) => {
return prisma.integrationAccount.create({
data: {
accountId,
integrationDefinitionId,
integratedById: userId,
config: config || {},
settings: settings || {},
isActive: true,
export const getIntegrationAccountForId = async (id: string) => {
return await prisma.integrationAccount.findUnique({
where: {
id,
},
});
};
@ -49,3 +32,13 @@ export const getIntegrationAccounts = async (userId: string) => {
},
});
};
export const getIntegrationAccountForSlug = async (slug: string) => {
return await prisma.integrationAccount.findFirst({
where: {
integrationDefinition: {
slug,
},
},
});
};

View File

@ -1,5 +1,5 @@
import { type OAuth2Params } from "@core/types";
import { IsBoolean, IsString } from "class-validator";
import { IsBoolean, IsOptional, IsString } from "class-validator";
import type { IntegrationDefinitionV2 } from "@core/database";
import { z } from "zod";
@ -25,12 +25,17 @@ export class OAuthBodyInterface {
@IsString()
integrationDefinitionId: string;
@IsString()
@IsOptional()
integrationAccountId?: string;
}
export const OAuthBodySchema = z.object({
redirectURL: z.string(),
integrationDefinitionId: z.string(),
mcp: z.boolean().optional().default(false),
integrationAccountId: z.string().optional(),
});
export type CallbackParams = Record<string, string>;

View File

@ -24,17 +24,20 @@ const MCP_CALLBACK_URL = `${CALLBACK_URL}/mcp`;
const session: Record<string, SessionRecord> = {};
const mcpSession: Record<
string,
{ integrationDefinitionId: string; redirectURL: string }
{
integrationDefinitionId: string;
redirectURL: string;
workspaceId: string;
userId: string;
integrationAccountId: string;
}
> = {};
export type CallbackParams = Record<string, string>;
// Remix-style callback handler
// Accepts a Remix LoaderFunctionArgs-like object: { request }
export async function callbackHandler(
params: CallbackParams,
request: Request,
) {
export async function callbackHandler(params: CallbackParams) {
if (!params.state) {
throw new Error("No state found");
}
@ -134,14 +137,14 @@ export async function callbackHandler(
...params,
redirect_uri: CALLBACK_URL,
},
integrationDefinition,
},
},
sessionRecord.userId,
sessionRecord.workspaceId,
);
await tasks.trigger<typeof scheduler>("scheduler", {
integrationAccountId: integrationAccount.id,
integrationAccountId: integrationAccount?.id,
});
return new Response(null, {
@ -253,7 +256,7 @@ export async function getRedirectURLForMCP(
userId: string,
workspaceId?: string,
) {
const { integrationDefinitionId } = oAuthBody;
const { integrationDefinitionId, integrationAccountId } = oAuthBody;
logger.info(
`We got OAuth request for ${workspaceId}: ${userId}: ${integrationDefinitionId}`,
@ -265,6 +268,10 @@ export async function getRedirectURLForMCP(
integrationDefinitionId,
);
if (!integrationAccountId) {
throw new Error("No integration account found");
}
if (!integrationDefinition) {
throw new Error("No integration definition found");
}
@ -290,6 +297,9 @@ export async function getRedirectURLForMCP(
mcpSession[state] = {
integrationDefinitionId: integrationDefinition.id,
redirectURL,
userId,
workspaceId: workspaceId as string,
integrationAccountId,
};
return {

View File

@ -42,7 +42,7 @@ export class WebhookService {
if (integrationDefinition) {
try {
const accountIdResponse = await runIntegrationTrigger(
const identifyResponse = await runIntegrationTrigger(
integrationDefinition,
{
event: IntegrationEventType.IDENTIFY,
@ -55,12 +55,39 @@ export class WebhookService {
let accountId: string | undefined;
if (
accountIdResponse?.message?.startsWith("The event payload type is")
) {
accountId = undefined;
// Handle new CLI message format response
if (identifyResponse?.success && identifyResponse?.result) {
// Check if there are identifiers in the response
if (
identifyResponse.result.identifiers &&
identifyResponse.result.identifiers.length > 0
) {
accountId = identifyResponse.result.identifiers[0].id;
} else if (
identifyResponse.result.activities &&
identifyResponse.result.activities.length > 0
) {
// Sometimes the account ID might be in activities data
const firstActivity = identifyResponse.result.activities[0];
accountId = firstActivity.accountId || firstActivity.id;
} else {
// Check raw output for backward compatibility
accountId = identifyResponse.rawOutput?.trim();
}
} else if (identifyResponse?.error) {
logger.warn("Integration IDENTIFY command failed", {
error: identifyResponse.error,
sourceName,
});
} else {
accountId = accountIdResponse;
// Handle legacy response format for backward compatibility
if (
identifyResponse?.message?.startsWith("The event payload type is")
) {
accountId = undefined;
} else {
accountId = identifyResponse;
}
}
if (accountId) {
@ -68,6 +95,17 @@ export class WebhookService {
where: { accountId },
include: { integrationDefinition: true },
});
logger.info("Found integration account for webhook", {
accountId,
integrationAccountId: integrationAccount?.id,
sourceName,
});
} else {
logger.warn("No account ID found from IDENTIFY command", {
sourceName,
response: identifyResponse,
});
}
} catch (error) {
logger.error("Failed to identify integration account", {
@ -85,22 +123,38 @@ export class WebhookService {
if (integrationAccount) {
try {
await runIntegrationTrigger(
logger.info(`Processing webhook for ${sourceName}`, {
integrationAccountId: integrationAccount.id,
integrationSlug: integrationAccount.integrationDefinition.slug,
});
const processResponse = await runIntegrationTrigger(
integrationAccount.integrationDefinition,
{
event: IntegrationEventType.PROCESS,
integrationAccount,
eventBody: {
eventHeaders,
eventData: { ...eventBody },
},
},
integrationAccount.integratedById,
integrationAccount.workspaceId,
integrationAccount,
);
logger.log(`Successfully processed webhook for ${sourceName}`, {
integrationAccountId: integrationAccount.id,
});
if (processResponse?.success) {
logger.log(`Successfully processed webhook for ${sourceName}`, {
integrationAccountId: integrationAccount.id,
activitiesCreated: processResponse.result?.activities?.length || 0,
messagesProcessed: processResponse.messages?.length || 0,
});
} else {
logger.warn(`Webhook processing had issues for ${sourceName}`, {
integrationAccountId: integrationAccount.id,
error: processResponse?.error,
success: processResponse?.success,
});
}
} catch (error) {
logger.error(`Failed to process webhook for ${sourceName}`, {
error,

View File

@ -107,10 +107,31 @@ const websearchTool = tool({
parameters: WebSearchSchema,
});
const loadMCPTools = tool({
description:
"Load tools for a specific integration. Call this when you need to use a third-party service.",
parameters: jsonSchema({
type: "object",
properties: {
integration: {
type: "array",
items: {
type: "string",
},
description:
'Array of integration names to load (e.g., ["github", "linear", "slack"])',
},
},
required: ["integration"],
additionalProperties: false,
}),
});
const internalTools = [
"core--progress_update",
"core--search_memory",
"core--add_memory",
"core--load_mcp",
];
async function addResources(messages: CoreMessage[], resources: Resource[]) {
@ -198,6 +219,7 @@ async function makeNextCall(
TOOLS: ToolSet,
totalCost: TotalCost,
guardLoop: number,
mcpServers: string[],
): Promise<LLMOutputInterface> {
const { context, history, previousHistory } = executionState;
@ -205,6 +227,7 @@ async function makeNextCall(
USER_MESSAGE: executionState.query,
CONTEXT: context,
USER_MEMORY: executionState.userMemoryContext,
AVAILABLE_MCP_TOOLS: mcpServers.join(", "),
};
let messages: CoreMessage[] = [];
@ -257,15 +280,19 @@ export async function* run(
previousHistory: CoreMessage[],
mcp: MCP,
stepHistory: HistoryStep[],
mcpServers: string[],
mcpHeaders: any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): AsyncGenerator<AgentMessage, any, any> {
let guardLoop = 0;
let tools = {
...(await mcp.allTools()),
"core--progress_update": progressUpdateTool,
"core--search_memory": searchMemoryTool,
"core--add_memory": addMemoryTool,
"core--websearch": websearchTool,
"core--load_mcp": loadMCPTools,
};
logger.info("Tools have been formed");
@ -301,6 +328,7 @@ export async function* run(
tools,
totalCost,
guardLoop,
mcpServers,
);
let toolCallInfo;
@ -532,6 +560,14 @@ export async function* run(
result =
"Web search failed - please check your search configuration";
}
} else if (toolName === "load_mcp") {
// Load MCP integration and update available tools
await mcp.load(skillInput.integration, mcpHeaders);
tools = {
...tools,
...(await mcp.allTools()),
};
result = "MCP integration loaded successfully";
}
}
// Handle other MCP tools

View File

@ -37,9 +37,12 @@ export const chat = task({
const { previousHistory, ...otherData } = payload.context;
const { agents = [] } = payload.context;
// Initialise mcp
const mcpHeaders = { Authorization: `Bearer ${init?.token}` };
const mcp = new MCP();
await mcp.init();
await mcp.load(agents, mcpHeaders);
// Prepare context with additional metadata
const context = {
@ -75,6 +78,8 @@ export const chat = task({
previousExecutionHistory,
mcp,
stepHistory,
init?.mcpServers ?? [],
mcpHeaders,
);
const stream = await metadata.stream("messages", llmResponse);

View File

@ -98,6 +98,15 @@ MEMORY USAGE:
If memory access is unavailable, proceed to web search or rely on current conversation
</memory>
<external_services>
- Available integrations: {{AVAILABLE_MCP_TOOLS}}
- To use: load_mcp with EXACT integration name from the available list
- Can load multiple at once with an array
- Only load when tools are NOT already available in your current toolset
- If a tool is already available, use it directly without load_mcp
- If requested integration unavailable: inform user politely
</external_services>
<tool_calling>
You have tools at your disposal to assist users:

View File

@ -3,8 +3,6 @@ import { IntegrationEventType } from "@core/types";
import { logger, schedules, tasks } from "@trigger.dev/sdk/v3";
import { type integrationRun } from "./integration-run";
import { getOrCreatePersonalAccessToken } from "../utils/utils";
import { nanoid } from "nanoid";
const prisma = new PrismaClient();
@ -27,7 +25,7 @@ export const integrationRunSchedule = schedules.task({
if (!integrationAccount) {
const deletedSchedule = await schedules.del(externalId);
logger.info("No integration account found");
logger.info("No integration account found, deleting schedule");
return deletedSchedule;
}
@ -36,22 +34,20 @@ export const integrationRunSchedule = schedules.task({
return null;
}
const pat = await getOrCreatePersonalAccessToken({
name: `integration_scheduled_${nanoid(10)}`,
userId: integrationAccount.workspace.userId as string,
logger.info("Triggering scheduled integration run", {
integrationId: integrationAccount.integrationDefinition.id,
integrationSlug: integrationAccount.integrationDefinition.slug,
accountId: integrationAccount.id,
});
if (!pat || !pat.token) {
logger.info("No pat token found");
return null;
}
return await tasks.trigger<typeof integrationRun>("integration-run", {
event: IntegrationEventType.SYNC,
pat: pat.token,
patId: pat.id,
integrationAccount,
integrationDefinition: integrationAccount.integrationDefinition,
eventBody: {
scheduled: true,
scheduledAt: new Date().toISOString(),
},
});
},
});

View File

@ -1,100 +1,435 @@
import createLoadRemoteModule, {
createRequires,
} from "@paciolan/remote-module-loader";
import { logger, task } from "@trigger.dev/sdk/v3";
import axios from "axios";
import { spawn } from "child_process";
import {
writeFileSync,
unlinkSync,
mkdtempSync,
existsSync,
readFileSync,
} from "fs";
import { join, isAbsolute, resolve } from "path";
import { tmpdir } from "os";
import {
type IntegrationDefinitionV2,
type IntegrationAccount,
} from "@core/database";
import { deletePersonalAccessToken } from "../utils/utils";
import { type IntegrationEventType } from "@core/types";
import { IntegrationEventType, type Message } from "@core/types";
import { extractMessagesFromOutput } from "../utils/cli-message-handler";
import {
createActivities,
createIntegrationAccount,
saveIntegrationAccountState,
saveMCPConfig,
} from "../utils/message-utils";
const fetcher = async (url: string) => {
// Handle remote URLs with axios
const response = await axios.get(url);
return response.data;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loadRemoteModule = async (requires: any) =>
createLoadRemoteModule({ fetcher, requires });
function createAxiosInstance(token: string) {
const instance = axios.create();
instance.interceptors.request.use((config) => {
// Check if URL starts with /api and doesn't have a full host
if (config.url?.startsWith("/api")) {
config.url = `${process.env.BACKEND_HOST}${config.url.replace("/api/", "/")}`;
}
if (
config.url?.includes(process.env.FRONTEND_HOST || "") ||
config.url?.includes(process.env.BACKEND_HOST || "")
) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
return instance;
/**
* Determines if a string is a URL.
*/
function isUrl(str: string): boolean {
try {
// Accepts http, https, file, etc.
const url = new URL(str);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getRequires = (axios: any) => createRequires({ axios });
/**
* Loads integration file from a URL or a local path.
*/
const loadIntegrationSource = async (source: string): Promise<string> => {
if (!source) {
throw new Error("Integration source is not provided");
}
// If it's a URL, fetch it
if (isUrl(source)) {
try {
const response = await axios.get(source);
return response.data;
} catch (error) {
throw new Error(
`Failed to fetch integration file from ${source}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
// Otherwise, treat as a local file path (absolute or relative)
let filePath = source;
if (!isAbsolute(filePath)) {
filePath = resolve(process.cwd(), filePath);
}
if (existsSync(filePath)) {
try {
return readFileSync(filePath, "utf8");
} catch (error) {
throw new Error(
`Failed to read integration file from path ${filePath}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
throw new Error(`Integration source is not found: ${source}`);
};
/**
* Executes integration CLI command with integration file
*/
const executeCLICommand = async (
integrationFile: string,
eventType: IntegrationEventType,
eventBody?: any,
config?: any,
integrationDefinition?: IntegrationDefinitionV2,
state?: any,
): Promise<string> => {
return new Promise((resolve, reject) => {
// Create temporary directory for the integration file
const tempDir = mkdtempSync(join(tmpdir(), "integration-"));
const integrationPath = join(tempDir, "integration.js");
try {
// Write integration file to temporary location
writeFileSync(integrationPath, integrationFile);
// Build command arguments based on event type and integration-cli spec
const args = [integrationPath];
switch (eventType) {
case IntegrationEventType.SETUP:
args.push("setup");
args.push("--event-body", JSON.stringify(eventBody || {}));
args.push(
"--integration-definition",
JSON.stringify(integrationDefinition || {}),
);
break;
case IntegrationEventType.IDENTIFY:
args.push("identify");
args.push("--webhook-data", JSON.stringify(eventBody || {}));
break;
case IntegrationEventType.PROCESS:
args.push("process");
args.push(
"--event-data",
JSON.stringify(eventBody?.eventData || eventBody || {}),
);
args.push("--config", JSON.stringify(config || {}));
break;
case IntegrationEventType.SYNC:
args.push("sync");
args.push("--config", JSON.stringify(config || {}));
args.push("--state", JSON.stringify(state || {}));
break;
default:
throw new Error(`Unsupported event type: ${eventType}`);
}
// Use node to execute the integration file
const childProcess = spawn("node", args, {
env: process.env,
cwd: tempDir,
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
childProcess.stdout.on("data", (data) => {
stdout += data.toString();
});
childProcess.stderr.on("data", (data) => {
stderr += data.toString();
});
childProcess.on("close", (code) => {
try {
// Clean up temporary file
unlinkSync(integrationPath);
} catch (cleanupError) {
logger.warn("Failed to cleanup temporary file", {
error: cleanupError,
});
}
if (code === 0) {
resolve(stdout);
} else {
reject(
new Error(
`Integration CLI failed with exit code ${code}: ${stderr}`,
),
);
}
});
childProcess.on("error", (error) => {
try {
unlinkSync(integrationPath);
} catch (cleanupError) {
logger.warn("Failed to cleanup temporary file", {
error: cleanupError,
});
}
reject(error);
});
} catch (error) {
try {
unlinkSync(integrationPath);
} catch (cleanupError) {
logger.warn("Failed to cleanup temporary file", {
error: cleanupError,
});
}
reject(error);
}
});
};
async function handleActivityMessage(
messages: Message[],
integrationAccountId: string,
userId: string,
): Promise<any> {
return createActivities({ integrationAccountId, messages, userId });
}
async function handleStateMessage(
messages: Message[],
integrationAccountId: string,
): Promise<any> {
// TODO: Implement state message handling
return saveIntegrationAccountState({ messages, integrationAccountId });
}
async function handleIdentifierMessage(message: Message): Promise<any> {
return message.data;
}
async function handleAccountMessage(
messages: Message[],
integrationDefinition: IntegrationDefinitionV2,
workspaceId: string,
userId: string,
integrationAccountId: string,
): Promise<any> {
const message = messages[0];
const mcp = message.data.mcp;
if (mcp) {
return await saveMCPConfig({
integrationAccountId,
config: message.data.config,
});
}
// Handle only one messages since account gets created only for one
const {
data: { settings, config, accountId },
} = messages[0];
return await createIntegrationAccount({
integrationDefinitionId: integrationDefinition.id,
workspaceId,
settings,
config,
accountId,
userId,
});
}
/**
* Handles CLI messages array and performs necessary actions based on message types
*/
async function handleMessageResponse(
messages: Message[],
integrationDefinition: IntegrationDefinitionV2,
workspaceId: string,
userId: string,
integrationAccountId?: string,
): Promise<any> {
try {
logger.info("Handling CLI message response", {
integrationId: integrationDefinition.id,
messageCount: messages.length,
messageTypes: messages.map((m) => m.type),
});
// Group messages by type
const grouped: Record<string, Message[]> = {};
for (const message of messages) {
if (!grouped[message.type]) {
grouped[message.type] = [];
}
grouped[message.type].push(message);
}
// Handle "activity" messages
if (grouped["activity"]) {
return await handleActivityMessage(
grouped["activity"],
integrationAccountId as string,
userId,
);
}
// Handle "state" messages
if (grouped["state"]) {
return await handleStateMessage(
grouped["state"],
integrationAccountId as string,
);
}
// Handle "identifier" messages
if (grouped["identifier"]) {
return await handleIdentifierMessage(grouped["identifier"][0]);
}
// Handle "account" messages (these may involve Prisma writes)
if (grouped["account"]) {
return await handleAccountMessage(
grouped["account"],
integrationDefinition,
workspaceId,
userId,
integrationAccountId as string,
);
}
// Warn for unknown message types
for (const type of Object.keys(grouped)) {
if (!["activity", "state", "identifier", "account"].includes(type)) {
for (const message of grouped[type]) {
logger.warn("Unknown message type", {
messageType: type,
message,
});
}
}
}
} catch (error) {
logger.error("Failed to handle CLI message response", {
error: error instanceof Error ? error.message : "Unknown error",
integrationId: integrationDefinition.id,
messages,
});
throw error;
}
}
// Remove old event-based handlers as they are replaced by message-type handlers above
export const integrationRun = task({
id: "integration-run",
run: async ({
pat,
patId,
eventBody,
integrationAccount,
integrationDefinition,
event,
workspaceId,
userId,
}: {
pat: string;
patId: string;
// This is the event you want to pass to the integration
event: IntegrationEventType;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eventBody?: any;
integrationDefinition: IntegrationDefinitionV2;
integrationAccount?: IntegrationAccount;
workspaceId?: string;
userId?: string;
}) => {
const remoteModuleLoad = await loadRemoteModule(
getRequires(createAxiosInstance(pat)),
);
try {
logger.info(
`Starting integration run for ${integrationDefinition.slug}`,
{
event,
integrationId: integrationDefinition.id,
},
);
logger.info(
`${integrationDefinition.url}/${integrationDefinition.version}/index.cjs`,
);
// Load the integration file from a URL or a local path
const integrationSource = integrationDefinition.url as string;
const integrationFile = await loadIntegrationSource(integrationSource);
logger.info(`Loaded integration file from ${integrationSource}`);
const integrationFunction = await remoteModuleLoad(
`${integrationDefinition.url}/${integrationDefinition.version}/index.cjs`,
);
// Prepare enhanced event body based on event type
let enhancedEventBody = eventBody;
// const integrationFunction = await remoteModuleLoad(
// `${integrationDefinition.url}`,
// );
// For SETUP events, include OAuth response and parameters
if (event === IntegrationEventType.SETUP) {
enhancedEventBody = {
...eventBody,
};
}
// Construct the proper IntegrationEventPayload structure
const integrationEventPayload = {
event,
eventBody: { ...eventBody, integrationDefinition },
config: integrationAccount?.integrationConfiguration || {},
};
// For PROCESS events, ensure eventData is properly structured
if (event === IntegrationEventType.PROCESS) {
enhancedEventBody = {
eventData: eventBody,
};
}
const result = await integrationFunction.run(integrationEventPayload);
logger.info(`Executing integration CLI`, {
event,
integrationId: integrationDefinition.id,
hasConfig: !!integrationAccount?.integrationConfiguration,
});
await deletePersonalAccessToken(patId);
const settings = integrationAccount?.settings as any;
logger.info("Personal access token deleted");
// Execute the CLI command using node
const output = await executeCLICommand(
integrationFile,
event,
enhancedEventBody,
integrationAccount?.integrationConfiguration,
integrationDefinition,
settings?.state,
);
return result;
logger.info("Integration CLI executed successfully");
// Process the output messages
const messages = extractMessagesFromOutput(output);
logger.info("Integration run completed", {
messageCount: messages.length,
messageTypes: messages.map((m) => m.type),
});
// Handle all CLI messages through the generic handler
return await handleMessageResponse(
messages,
integrationDefinition,
workspaceId as string,
userId as string,
integrationAccount?.id,
);
} catch (error) {
const errorMessage = `Integration run failed: ${error instanceof Error ? error.message : "Unknown error"}`;
logger.error(errorMessage, {
integrationId: integrationDefinition.id,
event,
error,
});
// For SETUP commands, we need to throw the error so OAuth callback can handle it
if (event === IntegrationEventType.SETUP) {
throw error;
}
// For other commands, return error in appropriate format
return {
error: errorMessage,
errors: [errorMessage],
};
}
},
});

View File

@ -0,0 +1,45 @@
import { type Message } from "@core/types";
/**
* Validates if a message has the correct structure
* @param message - Message to validate
* @returns True if valid, false otherwise
*/
export function isValidMessage(message: any): message is Message {
return (
typeof message === "object" &&
message !== null &&
typeof message.type === "string" &&
message.data !== undefined &&
["spec", "activity", "state", "identifier", "account"].includes(
message.type,
)
);
}
/**
* Extracts and validates messages from CLI output
* @param output - Raw CLI output string
* @returns Array of valid messages
*/
export function extractMessagesFromOutput(output: string): Message[] {
const messages: Message[] = [];
const lines = output.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed);
if (isValidMessage(parsed)) {
messages.push(parsed);
}
} catch (error) {
// Line is not JSON, skip it
continue;
}
}
return messages;
}

View File

@ -3,17 +3,15 @@ import { logger } from "@trigger.dev/sdk/v3";
import { jsonSchema, tool, type ToolSet } from "ai";
import { type MCPTool } from "./types";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
export class MCP {
private Client: any;
private clients: Record<string, any> = {};
private StdioTransport: any;
constructor() {}
public async init() {
this.Client = await MCP.importClient();
this.StdioTransport = await MCP.importStdioTransport();
}
private static async importClient() {
@ -23,24 +21,18 @@ export class MCP {
return Client;
}
async load(agents: string[], mcpConfig: any) {
async load(agents: string[], headers: any) {
await Promise.all(
agents.map(async (agent) => {
const mcp = mcpConfig.mcpServers[agent];
return await this.connectToServer(agent, mcp.command, mcp.args, {
...mcp.env,
DATABASE_URL: mcp.env?.DATABASE_URL ?? "",
});
return await this.connectToServer(
agent,
`${process.env.API_BASE_URL}/api/v1/mcp/${agent}`,
headers,
);
}),
);
}
private static async importStdioTransport() {
const { StdioClientTransport } = await import("./stdio");
return StdioClientTransport;
}
async allTools(): Promise<ToolSet> {
const clientEntries = Object.entries(this.clients);
@ -113,12 +105,7 @@ export class MCP {
return response;
}
async connectToServer(
name: string,
command: string,
args: string[],
env: any,
) {
async connectToServer(name: string, url: string, headers: any) {
try {
const client = new this.Client(
{
@ -130,12 +117,9 @@ export class MCP {
},
);
// Conf
// igure the transport for MCP server
const transport = new this.StdioTransport({
command,
args,
env,
// Configure the transport for MCP server
const transport = new StreamableHTTPClientTransport(new URL(url), {
requestInit: { headers },
});
// Connect to the MCP server

View File

@ -0,0 +1,130 @@
import { PrismaClient } from "@prisma/client";
import { type Message } from "@core/types";
const prisma = new PrismaClient();
export const createIntegrationAccount = async ({
integrationDefinitionId,
userId,
accountId,
config,
settings,
workspaceId,
}: {
integrationDefinitionId: string;
userId: string;
accountId: string;
workspaceId: string;
config?: Record<string, any>;
settings?: Record<string, any>;
}) => {
return prisma.integrationAccount.create({
data: {
accountId,
integrationDefinitionId,
integratedById: userId,
integrationConfiguration: config || {},
settings: settings || {},
isActive: true,
workspaceId,
},
});
};
export const saveMCPConfig = async ({
integrationAccountId,
config,
}: {
integrationAccountId: string;
config: any;
}) => {
const integrationAccount = await prisma.integrationAccount.findUnique({
where: {
id: integrationAccountId,
},
});
if (!integrationAccount) {
return [];
}
const integrationConfig = integrationAccount.integrationConfiguration as any;
return prisma.integrationAccount.update({
where: {
id: integrationAccountId,
},
data: {
integrationConfiguration: {
...integrationConfig,
mcp: config,
},
},
});
};
export const saveIntegrationAccountState = async ({
messages,
integrationAccountId,
}: {
messages: Message[];
integrationAccountId: string;
}) => {
const integrationAccount = await prisma.integrationAccount.findUnique({
where: {
id: integrationAccountId,
},
});
const settings = integrationAccount?.settings as any;
const state = settings.state;
return Promise.all(
messages.map(async (message) => {
return await prisma.integrationAccount.update({
where: {
id: integrationAccountId,
},
data: {
settings: {
...settings,
state: {
...state,
...message.data,
},
},
},
});
}),
);
};
export const createActivities = async ({
integrationAccountId,
messages,
}: {
integrationAccountId: string;
messages: Message[];
userId: string;
}) => {
const integrationAccount = await prisma.integrationAccount.findUnique({
where: {
id: integrationAccountId,
},
});
if (!integrationAccount) {
return [];
}
return await prisma.activity.createMany({
data: messages.map((message) => {
return {
text: message.data.text,
sourceURL: message.data.sourceURL,
integrationAccountId,
workspaceId: integrationAccount?.workspaceId,
};
}),
});
};

View File

@ -197,74 +197,16 @@ export const init = async ({ payload }: { payload: InitChatPayload }) => {
return config;
});
// Create MCP server configurations for each integration account
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const integrationMCPServers: Record<string, any> = {};
for (const account of integrationAccounts) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const spec = account.integrationDefinition?.spec as any;
if (spec.mcp) {
const mcpSpec = spec.mcp;
const configuredMCP = { ...mcpSpec };
// Replace config placeholders in environment variables
if (configuredMCP.env) {
for (const [key, value] of Object.entries(configuredMCP.env)) {
if (typeof value === "string" && value.includes("${config:")) {
// Extract the config key from the placeholder
const configKey = value.match(/\$\{config:(.*?)\}/)?.[1];
if (
configKey &&
account.integrationConfiguration &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(account.integrationConfiguration as any)[configKey]
) {
configuredMCP.env[key] = value.replace(
`\${config:${configKey}}`,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(account.integrationConfiguration as any)[configKey],
);
}
}
if (
typeof value === "string" &&
value.includes("${integrationConfig:")
) {
// Extract the config key from the placeholder
const configKey = value.match(
/\$\{integrationConfig:(.*?)\}/,
)?.[1];
if (
configKey &&
account.integrationDefinition.config &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(account.integrationDefinition.config as any)[configKey]
) {
configuredMCP.env[key] = value.replace(
`\${integrationConfig:${configKey}}`,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(account.integrationDefinition.config as any)[configKey],
);
}
}
}
}
// Add to the MCP servers collection
integrationMCPServers[account.integrationDefinition.slug] =
configuredMCP;
// Create MCP server for each integration account
const mcpServers: string[] = integrationAccounts
.map((account) => {
const integrationConfig = account.integrationConfiguration as any;
if (integrationConfig.mcp) {
return account.integrationDefinition.slug;
}
} catch (error) {
logger.error(
`Failed to configure MCP for ${account.integrationDefinition?.slug}:`,
{ error },
);
}
}
return undefined;
})
.filter((slug): slug is string => slug !== undefined);
return {
conversation,
@ -273,6 +215,7 @@ export const init = async ({ payload }: { payload: InitChatPayload }) => {
token: pat.token,
userId: user?.id,
userName: user?.name,
mcpServers,
};
};

View File

@ -0,0 +1,73 @@
export async function proxyRequest(
request: Request,
targetUrl: string,
token: string,
): Promise<Response> {
try {
const targetURL = new URL(targetUrl);
const headers = new Headers();
// Copy relevant headers from the original request
const headersToProxy = [
"content-type",
"user-agent",
"accept",
"accept-language",
"accept-encoding",
"mcp-session-id",
"last-event-id",
];
headersToProxy.forEach((headerName) => {
const value = request.headers.get(headerName);
if (value) {
headers.set(headerName, value);
}
});
headers.set("Authorization", `Bearer ${token}`);
const body =
request.method !== "GET" && request.method !== "HEAD"
? await request.arrayBuffer()
: undefined;
const response = await fetch(targetURL.toString(), {
method: request.method,
headers,
body,
});
// Create response headers, excluding hop-by-hop headers
const responseHeaders = new Headers();
const headersToExclude = [
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
];
response.headers.forEach((value, key) => {
if (!headersToExclude.includes(key.toLowerCase())) {
responseHeaders.set(key, value);
}
});
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
} catch (error) {
console.error("Proxy request failed:", error);
return new Response(JSON.stringify({ error: "Proxy request failed" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}

View File

@ -27,8 +27,8 @@ export default defineConfig({
build: {
extensions: [
syncEnvVars(() => ({
DATABASE_URL: process.env.DATABASE_URL,
BACKEND_HOST: process.env.BACKEND_HOST,
DATABASE_URL: process.env.DATABASE_URL as string,
API_BASE_URL: process.env.API_BASE_URL as string,
})),
prismaExtension({
schema: "prisma/schema.prisma",

3
integrations/linear/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
bin
node_modules

View File

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

View File

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

View File

@ -23,10 +23,6 @@
},
"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",
@ -37,10 +33,6 @@
"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",
@ -66,6 +58,6 @@
"commander": "^12.0.0",
"openai": "^4.0.0",
"react-query": "^3.39.3",
"@redplanethq/sdk": "0.1.0"
"@redplanethq/sdk": "0.1.1"
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,66 @@
export async function integrationCreate({ apiKey }: { apiKey: string }) {
// Fetch the Linear user info using the GraphQL API
const response = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
Authorization: `${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'query { viewer { id name email } }',
}),
});
if (!response.ok) {
throw new Error(`Failed to fetch Linear user: ${response.status} ${response.statusText}`);
}
const result = await response.json();
const viewer = result?.data?.viewer;
const userId = viewer?.id;
if (!userId) {
throw new Error('Could not extract userId from Linear GraphQL API response');
}
return [
{
type: 'account',
data: {
settings: {
user: {
id: viewer.id,
name: viewer.name,
email: viewer.email,
},
},
accountId: userId,
config: { apiKey },
},
},
];
}
interface MCPIntegrationCreateData {
oauthResponse: {
access_token: string;
token_type?: string;
expires_in?: number;
refresh_token?: string;
scope?: string;
[key: string]: any;
};
mcp: boolean;
}
export async function integrationCreateForMCP(data: MCPIntegrationCreateData) {
return [
{
type: 'account',
data: {
mcp: true,
config: data.oauthResponse,
},
},
];
}

View File

@ -1,5 +1,5 @@
import { handleSchedule } from 'schedule';
import { integrationCreate } from './account-create';
import { handleSchedule } from './schedule';
import { integrationCreate, integrationCreateForMCP } from './account-create';
import {
IntegrationCLI,
@ -11,10 +11,12 @@ import {
export async function run(eventPayload: IntegrationEventPayload) {
switch (eventPayload.event) {
case IntegrationEventType.SETUP:
return await integrationCreate(eventPayload.eventBody, eventPayload.integrationDefinition);
return eventPayload.eventBody.mcp
? await integrationCreateForMCP(eventPayload.eventBody)
: await integrationCreate(eventPayload.eventBody);
case IntegrationEventType.SYNC:
return await handleSchedule(eventPayload.config);
return await handleSchedule(eventPayload.config, eventPayload.state);
default:
return { message: `The event payload type is ${eventPayload.event}` };

View File

@ -0,0 +1,522 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import axios from 'axios';
interface LinearActivityCreateParams {
text: string;
sourceURL: string;
}
interface LinearSettings {
lastIssuesSync?: string;
lastCommentsSync?: string;
lastUserActionsSync?: string;
}
/**
* Creates an activity message based on Linear data
*/
function createActivityMessage(params: LinearActivityCreateParams) {
return {
type: 'activity',
data: {
text: params.text,
sourceURL: params.sourceURL,
},
};
}
/**
* 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: accessToken,
},
},
);
return response.data.data.viewer;
} catch (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: DateTimeOrDuration) {
issues(
filter: {
updatedAt: { gt: $lastSyncTime }
},
first: 50,
orderBy: updatedAt
) {
nodes {
id
identifier
title
url
createdAt
updatedAt
state {
id
name
type
}
team {
id
name
}
assignee {
id
name
displayName
}
creator {
id
name
displayName
}
subscribers {
nodes {
id
name
displayName
}
}
priority
history {
nodes {
id
createdAt
updatedAt
fromStateId
toStateId
fromAssigneeId
toAssigneeId
fromPriority
toPriority
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
const response = await axios.post(
'https://api.linear.app/graphql',
{
query,
variables: {
lastSyncTime,
},
},
{
headers: {
'Content-Type': 'application/json',
Authorization: accessToken,
},
},
);
return response.data.data.issues;
} catch (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: DateTimeOrDuration) {
comments(
filter: {
updatedAt: { gt: $lastSyncTime }
},
first: 50,
orderBy: updatedAt
) {
nodes {
id
body
createdAt
updatedAt
user {
id
name
displayName
}
issue {
id
identifier
title
url
creator {
id
name
displayName
}
assignee {
id
name
displayName
}
subscribers {
nodes {
id
name
displayName
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
const response = await axios.post(
'https://api.linear.app/graphql',
{
query,
variables: {
lastSyncTime,
},
},
{
headers: {
'Content-Type': 'application/json',
Authorization: accessToken,
},
},
);
return response.data.data.comments;
} catch (error) {
throw error;
}
}
/**
* Process issue activities and create appropriate activity records
*/
async function processIssueActivities(issues: any[], userId: string) {
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 && !isSubscribed) {
continue;
}
// Process history to determine what actually changed
let activityCreated = false;
// Process assignment changes first (highest priority)
if (issue.history && issue.history.nodes) {
for (const historyItem of issue.history.nodes) {
if (historyItem.toAssigneeId && historyItem.fromAssigneeId !== historyItem.toAssigneeId) {
if (historyItem.toAssigneeId === userId) {
activities.push(
createActivityMessage({
text: `${issue.identifier} (${issue.title}) Issue assigned to you`,
sourceURL: issue.url,
}),
);
activityCreated = true;
break;
} else if (isCreatedByUser && historyItem.fromAssigneeId === userId) {
activities.push(
createActivityMessage({
text: `${issue.identifier} (${issue.title}) Issue unassigned from you`,
sourceURL: issue.url,
}),
);
activityCreated = true;
break;
}
}
}
}
// If no assignment change, check for status changes
if (!activityCreated && issue.history && issue.history.nodes) {
for (const historyItem of issue.history.nodes) {
if (historyItem.toStateId && historyItem.fromStateId !== historyItem.toStateId) {
if (!isAssignee && !isCreatedByUser && !isSubscribed) {
continue;
}
const stateType = issue.state?.type;
let statusText = `moved to ${issue.state?.name || 'a new status'}`;
if (stateType === 'completed') {
statusText = 'completed';
} else if (stateType === 'canceled') {
statusText = 'canceled';
}
let title;
if (isCreatedByUser || isAssignee) {
title = `${issue.identifier} (${issue.title}) Issue ${statusText}`;
} else {
title = `${issue.identifier} (${issue.title}) Issue ${statusText}`;
}
activities.push(
createActivityMessage({
text: title,
sourceURL: issue.url,
}),
);
activityCreated = true;
break;
}
}
}
// If no history changes, check if it's a new issue creation
if (!activityCreated && isCreatedByUser) {
// Only create activity if issue was created recently (within sync window)
const createdAt = new Date(issue.createdAt);
const updatedAt = new Date(issue.updatedAt);
// If created and updated times are very close, it's likely a new issue
if (Math.abs(createdAt.getTime() - updatedAt.getTime()) < 60000) {
// within 1 minute
activities.push(
createActivityMessage({
text: `${issue.identifier} (${issue.title}) Issue created`,
sourceURL: issue.url,
}),
);
activityCreated = true;
}
}
} catch (error) {
// Silently ignore errors to prevent stdout pollution
}
}
return activities;
}
/**
* Process comment activities and create appropriate activity records
*/
async function processCommentActivities(comments: any[], userId: string, userInfo: any) {
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;
// Check for mentions in the comment body
const isMentioned = checkForUserMentions(comment.body, userInfo);
// Skip if not relevant to user
if (!isCommenter && !isIssueCreator && !isAssignee && !isSubscribed && !isMentioned) {
continue;
}
let title;
if (isCommenter) {
// Comment created by the user
title = `You commented on issue ${comment.issue.identifier}: ${truncateText(comment.body, 100)}`;
} else if (isMentioned) {
// User was mentioned in the comment
title = `${comment.user?.name || 'Someone'} mentioned you in issue ${comment.issue.identifier}: ${truncateText(comment.body, 100)}`;
} 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)}`;
}
if (title) {
activities.push(
createActivityMessage({
text: title,
sourceURL: `${comment.issue.url}#comment-${comment.id}`,
}),
);
}
} catch (error) {
// Silently ignore errors to prevent stdout pollution
}
}
return activities;
}
/**
* Helper function to check for user mentions in text
*/
function checkForUserMentions(text: string, userInfo: any): boolean {
if (!text || !userInfo) return false;
const lowerText = text.toLowerCase();
// Check for @username, @display name, or @email mentions
const mentionPatterns = [
userInfo.name && `@${userInfo.name.toLowerCase()}`,
userInfo.displayName && `@${userInfo.displayName.toLowerCase()}`,
userInfo.email && `@${userInfo.email.toLowerCase()}`,
].filter(Boolean);
return mentionPatterns.some((pattern) => lowerText.includes(pattern));
}
/**
* 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(config: any, state: any) {
try {
const integrationConfiguration = config;
// Check if we have a valid access token
if (!integrationConfiguration?.accessToken) {
return [];
}
// Get settings or initialize if not present
let settings = (state || {}) 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
let user;
try {
user = await fetchUserInfo(integrationConfiguration.accessToken);
} catch (error) {
return [];
}
if (!user || !user.id) {
return [];
}
// Collect all messages
const messages = [];
// Process all issue activities (created, assigned, updated, etc.)
try {
const issues = await fetchRecentIssues(integrationConfiguration.accessToken, lastIssuesSync);
if (issues && issues.nodes) {
const issueActivities = await processIssueActivities(issues.nodes, user.id);
messages.push(...issueActivities);
}
} catch (error) {
// Silently ignore errors to prevent stdout pollution
}
// Process all comment activities
try {
const comments = await fetchRecentComments(
integrationConfiguration.accessToken,
lastCommentsSync,
);
if (comments && comments.nodes) {
const commentActivities = await processCommentActivities(comments.nodes, user.id, user);
messages.push(...commentActivities);
}
} catch (error) {
// Silently ignore errors to prevent stdout pollution
}
// Update last sync times
const newSyncTime = new Date().toISOString();
// Add state message for saving settings
messages.push({
type: 'state',
data: {
...settings,
lastIssuesSync: newSyncTime,
lastCommentsSync: newSyncTime,
},
});
return messages;
} catch (error) {
return [];
}
}
/**
* The main handler for the scheduled sync event
*/
export async function scheduleHandler(config: any, state: any) {
return handleSchedule(config, state);
}

View File

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

View File

@ -1,17 +1,19 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["dom", "dom.iterable", "esnext"],
"baseUrl": "backend",
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "./src",
"lib": ["dom", "dom.iterable", "esnext"],
"baseUrl": "frontend",
"allowJs": false,
"allowSyntheticDefaultImports": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"strictNullChecks": true,
"removeComments": true,
@ -25,7 +27,7 @@
"noFallthroughCasesInSwitch": true,
"useUnknownInCatchVariables": false
},
"include": ["backend/**/*"],
"include": ["src/**/*"],
"exclude": ["node_modules", "build", "dist", "scripts", "acceptance-tests", "webpack", "jest"],
"types": ["typePatches"]
}

View File

@ -0,0 +1,20 @@
import { defineConfig } from 'tsup';
import { dependencies } from './package.json';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs'], // or esm if you're using that
bundle: true,
target: 'node16',
outDir: 'bin',
splitting: false,
shims: true,
clean: true,
name: 'linear',
platform: 'node',
legacyOutput: false,
noExternal: Object.keys(dependencies || {}), // ⬅️ bundle all deps
treeshake: {
preset: 'recommended',
},
});

View File

@ -23,10 +23,6 @@
},
"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",
@ -37,10 +33,6 @@
"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",

View File

@ -1,7 +1,6 @@
{
"compilerOptions": {
"target": "ES2022",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
@ -9,16 +8,12 @@
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "./src",
"lib": ["dom", "dom.iterable", "esnext"],
"baseUrl": "frontend",
"allowJs": false,
"allowSyntheticDefaultImports": true,
"module": "esnext",
"moduleResolution": "node",
"isolatedModules": true,
"strictNullChecks": true,
"removeComments": true,

View File

@ -82,12 +82,17 @@ export function createMCPProxy(
);
}
// Extract session ID and last event ID from incoming request
const clientSessionId = request.headers.get("Mcp-Session-Id");
const lastEventId = request.headers.get("Last-Event-Id");
// Create remote transport (connects to the MCP server) FIRST
const serverTransport = await createRemoteTransport(
credentials.serverUrl,
credentials,
config.redirectUrl,
config.transportStrategy || "sse-first"
config.transportStrategy || "sse-first",
{ sessionId: clientSessionId, lastEventId } // Pass both session and event IDs
);
// Start server transport and wait for connection
@ -116,33 +121,8 @@ export function createMCPProxy(
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);
@ -171,47 +151,40 @@ export function createMCPProxy(
serverUrl: string,
credentials: StoredCredentials,
redirectUrl: string,
transportStrategy: TransportStrategy = "sse-first"
transportStrategy: TransportStrategy = "sse-first",
clientHeaders?: { sessionId?: string | null; lastEventId?: string | null }
): 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 = {
const headers: Record<string, string> = {
Authorization: `Bearer ${credentials.tokens.access_token}`,
"Content-Type": "application/json",
...config.headers,
};
// Add session and event headers if provided
if (clientHeaders?.sessionId) {
headers["Mcp-Session-Id"] = clientHeaders.sessionId;
}
if (clientHeaders?.lastEventId) {
headers["Last-Event-Id"] = clientHeaders.lastEventId;
}
// 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;
@ -222,12 +195,10 @@ export function createMCPProxy(
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 },
});
}
@ -237,7 +208,6 @@ export function createMCPProxy(
// Try HTTP first, fallback to SSE on error
try {
transport = new StreamableHTTPClientTransport(url, {
authProvider,
requestInit: { headers },
});
} catch (error) {
@ -245,7 +215,6 @@ export function createMCPProxy(
transport = new SSEClientTransport(url, {
authProvider,
requestInit: { headers },
eventSourceInit,
});
}
break;

View File

@ -180,6 +180,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
authorizationUrl.searchParams.set("resource", this.authorizeResource);
}
// Keep it
console.log(this.authorizationUrl);
// Store the URL instead of opening browser

View File

@ -9,8 +9,8 @@ export function createMCPTransportBridge(
serverTransport: Transport,
options: {
debug?: boolean;
onMessage?: (direction: 'client-to-server' | 'server-to-client', message: any) => void;
onError?: (error: Error, source: 'client' | 'server') => void;
onMessage?: (direction: "client-to-server" | "server-to-client", message: any) => void;
onError?: (error: Error, source: "client" | "server") => void;
} = {}
) {
let clientClosed = false;
@ -22,24 +22,41 @@ export function createMCPTransportBridge(
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');
clientTransport.onmessage = (message: any, extra: any) => {
console.log(JSON.stringify(message));
log("[Client→Server]", message.method || message.id);
onMessage?.("client-to-server", message);
// Forward any extra parameters (like resumption tokens) to the server
const serverOptions: any = {};
if (extra?.relatedRequestId) {
serverOptions.relatedRequestId = extra.relatedRequestId;
}
serverTransport.send(message, serverOptions).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');
serverTransport.onmessage = (message: any, extra: any) => {
console.log(JSON.stringify(message), JSON.stringify(extra));
log("[Server→Client]", message.method || message.id);
onMessage?.("server-to-client", message);
// Forward the server's session ID as resumption token to client
const clientOptions: any = {};
if (serverTransport.sessionId) {
clientOptions.resumptionToken = serverTransport.sessionId;
}
if (extra?.relatedRequestId) {
clientOptions.relatedRequestId = extra.relatedRequestId;
}
clientTransport.send(message, clientOptions).catch((error) => {
logError("Error sending to client:", error);
onError?.(error, "client");
});
};
@ -47,30 +64,31 @@ export function createMCPTransportBridge(
clientTransport.onclose = () => {
if (serverClosed) return;
clientClosed = true;
log('Client transport closed, closing server transport');
serverTransport.close().catch(error => {
logError('Error closing server transport:', error);
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);
console.log("closing");
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');
logError("Client transport error:", error);
onError?.(error, "client");
};
serverTransport.onerror = (error: Error) => {
logError('Server transport error:', error);
onError?.(error, 'server');
logError("Server transport error:", error);
onError?.(error, "server");
};
return {
@ -79,13 +97,10 @@ export function createMCPTransportBridge(
*/
start: async () => {
try {
await Promise.all([
clientTransport.start(),
serverTransport.start()
]);
log('MCP transport bridge started successfully');
await Promise.all([clientTransport.start(), serverTransport.start()]);
log("MCP transport bridge started successfully");
} catch (error) {
logError('Error starting transport bridge:', error);
logError("Error starting transport bridge:", error);
throw error;
}
},
@ -95,13 +110,10 @@ export function createMCPTransportBridge(
*/
close: async () => {
try {
await Promise.all([
clientTransport.close(),
serverTransport.close()
]);
log('MCP transport bridge closed successfully');
await Promise.all([clientTransport.close(), serverTransport.close()]);
log("MCP transport bridge closed successfully");
} catch (error) {
logError('Error closing transport bridge:', error);
logError("Error closing transport bridge:", error);
throw error;
}
},
@ -111,6 +123,6 @@ export function createMCPTransportBridge(
*/
get isClosed() {
return clientClosed || serverClosed;
}
},
};
}
}

View File

@ -12,6 +12,7 @@ export class RemixMCPTransport implements Transport {
private request: Request,
private sendResponse: (response: Response) => void
) {}
sessionId?: string;
setProtocolVersion?: (version: string) => void;
@ -55,15 +56,18 @@ export class RemixMCPTransport implements Transport {
throw new Error("Transport is closed");
}
// Prepare headers
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
// 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",
},
headers,
});
this.sendResponse(response);
@ -83,5 +87,7 @@ export class RemixMCPTransport implements Transport {
onmessage: (message: any) => void = () => {};
onclose: () => void = () => {};
onerror: (error: Error) => void = () => {};
async onerror(error: Error) {
console.log(error);
}
}

View File

@ -1,6 +1,6 @@
{
"name": "@redplanethq/sdk",
"version": "0.1.0",
"version": "0.1.1",
"description": "CORE Node.JS SDK",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",

View File

@ -1 +1 @@
export { IntegrationCLI } from './integration_cli';
export { IntegrationCLI } from './integration-cli';

View File

@ -56,7 +56,7 @@ export abstract class IntegrationCLI {
});
for (const message of messages) {
console.log(JSON.stringify(message, null, 2));
console.log(JSON.stringify(message));
}
} catch (error) {
console.error('Error during setup:', error);
@ -83,7 +83,7 @@ export abstract class IntegrationCLI {
});
for (const message of messages) {
console.log(JSON.stringify(message, null, 2));
console.log(JSON.stringify(message));
}
} catch (error) {
console.error('Error processing data:', error);
@ -105,7 +105,7 @@ export abstract class IntegrationCLI {
});
for (const message of messages) {
console.log(JSON.stringify(message, null, 2));
console.log(JSON.stringify(message));
}
} catch (error) {
console.error('Error identifying account:', error);
@ -126,7 +126,7 @@ export abstract class IntegrationCLI {
data: spec,
};
// For spec, we keep the single message output for compatibility
console.log(JSON.stringify(message, null, 2));
console.log(JSON.stringify(message));
} catch (error) {
console.error('Error getting spec:', error);
process.exit(1);
@ -153,7 +153,7 @@ export abstract class IntegrationCLI {
});
for (const message of messages) {
console.log(JSON.stringify(message, null, 2));
console.log(JSON.stringify(message));
}
} catch (error) {
console.error('Error during sync:', error);

View File

@ -56,7 +56,7 @@ export interface Identifier {
type?: string;
}
export type MessageType = "spec" | "activity" | "state" | "identifier";
export type MessageType = "spec" | "activity" | "state" | "identifier" | "account";
export interface Message {
type: MessageType;