Feat: add MCP logs

This commit is contained in:
Harshith Mullapudi 2025-08-24 12:19:03 +05:30
parent c8ea027f37
commit c53aba527c
18 changed files with 884 additions and 206 deletions

View File

@ -38,9 +38,20 @@ export function getIcon(icon: IconType) {
return ICON_MAPPING["integration"];
}
export const getIconForAuthorise = (name: string, image?: string) => {
export const getIconForAuthorise = (
name: string,
size = 40,
image?: string,
) => {
if (image) {
return <img src={image} alt={name} className="h-[40px] w-[40px] rounded" />;
return (
<img
src={image}
alt={name}
className="rounded"
style={{ height: size, width: size }}
/>
);
}
const lowerName = name.toLowerCase();
@ -48,8 +59,8 @@ export const getIconForAuthorise = (name: string, image?: string) => {
if (lowerName in ICON_MAPPING) {
const IconComponent = ICON_MAPPING[lowerName as IconType];
return <IconComponent size={40} />;
return <IconComponent size={size} />;
}
return <LayoutGrid size={40} />;
return <LayoutGrid size={size} />;
};

View File

@ -0,0 +1,127 @@
import { useState } from "react";
import { ListFilter, X } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Popover,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from "~/components/ui/popover";
import { Badge } from "~/components/ui/badge";
interface McpSessionsFiltersProps {
availableSources: Array<{ name: string; slug: string; count: number }>;
selectedSource?: string;
onSourceChange: (source?: string) => void;
}
type FilterStep = "main" | "source";
export function McpSessionsFilters({
availableSources,
selectedSource,
onSourceChange,
}: McpSessionsFiltersProps) {
const [popoverOpen, setPopoverOpen] = useState(false);
const [step, setStep] = useState<FilterStep>("main");
// Only show first few sources, or "All sources" if none
const limitedSources = availableSources.slice(0, 5);
const selectedSourceName = availableSources.find(
(s) => s.slug === selectedSource,
)?.name;
const hasFilters = selectedSource;
return (
<div className="mb-2 flex w-full items-center justify-start gap-2">
<Popover
open={popoverOpen}
onOpenChange={(open) => {
setPopoverOpen(open);
if (!open) setStep("main");
}}
>
<PopoverTrigger asChild>
<Button
variant="secondary"
role="combobox"
aria-expanded={popoverOpen}
className="justify-between"
>
<ListFilter className="mr-2 h-4 w-4" />
Filter
</Button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent className="w-[220px] p-0" align="start">
{step === "main" && (
<div className="flex flex-col gap-1 p-2">
<Button
variant="ghost"
className="justify-start"
onClick={() => setStep("source")}
>
Source
</Button>
</div>
)}
{step === "source" && (
<div className="flex flex-col gap-1 p-2">
<Button
variant="ghost"
className="w-full justify-start"
onClick={() => {
onSourceChange(undefined);
setPopoverOpen(false);
setStep("main");
}}
>
All sources
</Button>
{limitedSources.map((source) => (
<Button
key={source.slug}
variant="ghost"
className="w-full justify-between"
onClick={() => {
onSourceChange(
source.slug === selectedSource
? undefined
: source.slug,
);
setPopoverOpen(false);
setStep("main");
}}
>
<span>{source.name}</span>
<Badge variant="outline" className="text-xs">
{source.count}
</Badge>
</Button>
))}
</div>
)}
</PopoverContent>
</PopoverPortal>
</Popover>
{/* Active Filters */}
{hasFilters && (
<div className="flex items-center gap-2">
{selectedSource && (
<Badge variant="secondary" className="h-7 gap-1 rounded px-2">
{selectedSourceName}
<X
className="hover:text-destructive h-3.5 w-3.5 cursor-pointer"
onClick={() => onSourceChange(undefined)}
/>
</Badge>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,105 @@
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { Badge } from "../ui/badge";
import { LoaderCircle } from "lucide-react";
import { getIconForAuthorise } from "../icon-utils";
interface McpSourcesStatsProps {
sources: Array<{ name: string; slug: string; count: number }>;
activeSources?: string[];
isLoading?: boolean;
}
export function McpSourcesStats({
sources,
activeSources = [],
isLoading,
}: McpSourcesStatsProps) {
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Top Sources</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<LoaderCircle className="text-primary h-6 w-6 animate-spin" />
</div>
</CardContent>
</Card>
);
}
const totalSessions = sources.reduce((sum, source) => sum + source.count, 0);
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-lg">Top Sources</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-4">
{sources.length === 0 ? (
<p className="text-muted-foreground text-sm">No sources found</p>
) : (
<div className="space-y-3">
{sources.slice(0, 5).map((source) => {
const percentage =
totalSessions > 0 ? (source.count / totalSessions) * 100 : 0;
return (
<div
key={source.slug}
className="flex items-center justify-between"
>
<div className="flex items-center gap-1">
{getIconForAuthorise(source.name.toLowerCase(), 16)}
<span className="mr-1 text-sm">{source.name}</span>
<Badge variant="secondary" className="text-xs">
{source.count}
</Badge>
</div>
<div className="flex items-center gap-2">
<div className="h-2 w-16 overflow-hidden rounded-full bg-gray-200">
<div
className="bg-primary h-full transition-all duration-500"
style={{ width: `${percentage}%` }}
/>
</div>
<span className="text-muted-foreground w-10 text-right text-xs">
{percentage.toFixed(0)}%
</span>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Current Active</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-4">
{activeSources.length === 0 ? (
<p className="text-muted-foreground text-sm">No active sources</p>
) : (
<div className="flex flex-wrap gap-1">
{activeSources.map((source) => (
<Badge
key={source}
variant="secondary"
className="rounded text-xs"
>
{getIconForAuthorise(source.toLowerCase(), 12)}
{source}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,273 @@
import { useEffect, useRef, useState } from "react";
import {
InfiniteLoader,
AutoSizer,
CellMeasurer,
CellMeasurerCache,
type Index,
type ListRowProps,
} from "react-virtualized";
import { type McpSessionItem } from "~/hooks/use-mcp-sessions";
import { ScrollManagedList } from "../virtualized-list";
import { Badge } from "../ui/badge";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
import { Check, Copy } from "lucide-react";
import { cn } from "~/lib/utils";
import { Card, CardContent } from "../ui/card";
import { getIconForAuthorise } from "../icon-utils";
interface VirtualMcpSessionsListProps {
sessions: McpSessionItem[];
hasMore: boolean;
loadMore: () => void;
isLoading: boolean;
height?: number;
}
export function MCPUrlBox() {
const [copied, setCopied] = useState(false);
const [selectedSource, setSelectedSource] = useState<
"Claude" | "Cursor" | "Other"
>("Claude");
const getMcpURL = (source: "Claude" | "Cursor" | "Other") => {
const baseUrl = "https://core.heysol.ai/api/v1/mcp";
return `${baseUrl}?source=${source}`;
};
const mcpURL = getMcpURL(selectedSource);
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(mcpURL);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
return (
<Card className="min-w-[400px] rounded-lg bg-transparent pt-1">
<CardContent className="pt-2 text-base">
<div className="space-y-4">
<div className="space-y-3">
<div className="bg-grayAlpha-100 flex space-x-1 rounded-lg p-1">
{(["Claude", "Cursor", "Other"] as const).map((source) => (
<Button
key={source}
onClick={() => setSelectedSource(source)}
variant="ghost"
className={cn(
"flex-1 rounded-md px-3 py-1.5 transition-all",
selectedSource === source
? "bg-accent text-accent-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{source}
</Button>
))}
</div>
<div className="bg-background-3 flex items-center rounded">
<Input
type="text"
id="mcpURL"
value={mcpURL}
readOnly
className="bg-background-3 block w-full text-base"
/>
<Button
type="button"
variant="link"
size="sm"
onClick={copyToClipboard}
className="px-3"
>
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
function McpSessionItemRenderer(
props: ListRowProps,
sessions: McpSessionItem[],
cache: CellMeasurerCache,
) {
const { index, key, style, parent } = props;
const session = sessions[index];
if (!session) {
return (
<CellMeasurer
key={key}
cache={cache}
columnIndex={0}
parent={parent}
rowIndex={index}
>
<div key={key} style={style} className="p-4">
<div className="h-20 animate-pulse rounded bg-gray-200" />
</div>
</CellMeasurer>
);
}
const createdAt = new Date(session.createdAt);
const deleted = !!session.deleted;
return (
<CellMeasurer
key={key}
cache={cache}
columnIndex={0}
parent={parent}
rowIndex={index}
>
<div key={key} style={style} className="px-0 py-2">
<div className="flex w-full items-center">
<div
className={cn(
"group-hover:bg-grayAlpha-100 flex min-w-[0px] shrink grow items-start gap-2 rounded-md px-2",
)}
>
<div
className={cn(
"border-border flex w-full min-w-[0px] shrink flex-col border-b py-1",
)}
>
<div className="flex w-full items-center justify-between gap-4">
<div className="flex w-full items-center gap-2">
{getIconForAuthorise(session.source.toLowerCase(), 18)}
<div className="inline-flex min-h-[24px] min-w-[0px] shrink cursor-pointer items-center justify-start">
<div className={cn("truncate text-left")}>
{session.source}
</div>
</div>
</div>
<div className="text-muted-foreground flex shrink-0 items-center justify-end text-xs">
<div className="flex items-center">
{!deleted && (
<Badge className="bg-success/20 text-success mr-2 rounded text-xs">
Active
</Badge>
)}
<div className="text-muted-foreground mr-3">
{createdAt.toLocaleString()}
</div>
{session.integrations.length > 0 && (
<div className="flex flex-wrap gap-1">
{session.integrations.map((integration) => (
<Badge
key={integration}
variant="secondary"
className="rounded text-xs"
>
{getIconForAuthorise(integration, 12)}
{integration}
</Badge>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</CellMeasurer>
);
}
export function VirtualMcpSessionsList({
sessions,
hasMore,
loadMore,
isLoading,
}: VirtualMcpSessionsListProps) {
// Create a CellMeasurerCache instance using useRef to prevent recreation
const cacheRef = useRef<CellMeasurerCache | null>(null);
if (!cacheRef.current) {
cacheRef.current = new CellMeasurerCache({
defaultHeight: 100, // Default row height
fixedWidth: true, // Rows have fixed width but dynamic height
});
}
const cache = cacheRef.current;
useEffect(() => {
cache.clearAll();
}, [sessions, cache]);
const isRowLoaded = ({ index }: { index: number }) => {
return !!sessions[index];
};
const loadMoreRows = async () => {
if (hasMore) {
return loadMore();
}
return false;
};
const rowRenderer = (props: ListRowProps) => {
return McpSessionItemRenderer(props, sessions, cache);
};
const rowHeight = ({ index }: Index) => {
return cache.getHeight(index, 0);
};
const itemCount = hasMore ? sessions.length + 1 : sessions.length;
return (
<div className="h-full grow overflow-hidden">
<AutoSizer className="h-full">
{({ width, height: autoHeight }) => (
<InfiniteLoader
isRowLoaded={isRowLoaded}
loadMoreRows={loadMoreRows}
rowCount={itemCount}
threshold={5}
>
{({ onRowsRendered, registerChild }) => (
<ScrollManagedList
ref={registerChild}
className="h-auto overflow-auto"
height={autoHeight}
width={width}
rowCount={itemCount}
rowHeight={rowHeight}
onRowsRendered={onRowsRendered}
rowRenderer={rowRenderer}
deferredMeasurementCache={cache}
overscanRowCount={10}
/>
)}
</InfiniteLoader>
)}
</AutoSizer>
{isLoading && (
<div className="text-muted-foreground p-4 text-center text-sm">
Loading more sessions...
</div>
)}
</div>
);
}

View File

@ -0,0 +1,116 @@
import { useEffect, useState, useCallback } from "react";
import { useFetcher } from "@remix-run/react";
export interface McpSessionItem {
id: string;
source: string;
integrations: string[];
createdAt: string;
deleted: string;
}
export interface McpSessionsResponse {
sessions: McpSessionItem[];
totalCount: number;
page: number;
limit: number;
hasMore: boolean;
availableSources: Array<{ name: string; slug: string; count: number }>;
activeSources: string[];
}
export interface UseMcpSessionsOptions {
endpoint: string;
source?: string;
}
export function useMcpSessions({ endpoint, source }: UseMcpSessionsOptions) {
const fetcher = useFetcher<McpSessionsResponse>();
const [sessions, setSessions] = useState<McpSessionItem[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [availableSources, setAvailableSources] = useState<
Array<{ name: string; slug: string; count: number }>
>([]);
const [activeSources, setActiveSources] = useState<string[]>([]);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const buildUrl = useCallback(
(pageNum: number) => {
const params = new URLSearchParams();
params.set("page", pageNum.toString());
params.set("limit", "10");
if (source) params.set("source", source);
return `${endpoint}?${params.toString()}`;
},
[endpoint, source],
);
const loadMore = useCallback(() => {
if (fetcher.state === "idle" && hasMore) {
fetcher.load(buildUrl(page + 1));
}
}, [hasMore, page, buildUrl]);
const reset = useCallback(() => {
setSessions([]);
setPage(1);
setHasMore(true);
setIsInitialLoad(true);
fetcher.load(buildUrl(1));
}, [buildUrl]);
// Effect to handle fetcher data
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data) {
const {
sessions: newSessions,
hasMore: newHasMore,
page: currentPage,
availableSources: sources,
activeSources: activeSourceNames,
} = fetcher.data;
if (currentPage === 1) {
// First page or reset
setSessions(newSessions);
setIsInitialLoad(false);
} else {
// Append to existing sessions
setSessions((prev) => [...prev, ...newSessions]);
}
setHasMore(newHasMore);
setPage(currentPage);
setAvailableSources(sources);
setActiveSources(activeSourceNames);
}
}, [fetcher.data, fetcher.state]);
// Effect to reset when filters change
useEffect(() => {
setSessions([]);
setPage(1);
setHasMore(true);
setIsInitialLoad(true);
fetcher.load(buildUrl(1));
}, [source, buildUrl]);
// Initial load
useEffect(() => {
if (isInitialLoad) {
fetcher.load(buildUrl(1));
}
}, [isInitialLoad, buildUrl]);
return {
sessions,
hasMore,
loadMore,
reset,
availableSources,
activeSources,
isLoading: fetcher.state === "loading",
isInitialLoad,
};
}

View File

@ -0,0 +1,81 @@
import { z } from "zod";
import { json } from "@remix-run/node";
import { prisma } from "~/db.server";
import { createHybridLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { getWorkspaceByUser } from "~/models/workspace.server";
const SearchParamsSchema = z.object({
page: z.string().optional().default("1"),
limit: z.string().optional().default("10"),
source: z.string().optional(),
});
const loader = createHybridLoaderApiRoute(
{
searchParams: SearchParamsSchema,
findResource: async () => 1,
corsStrategy: "all",
allowJWT: true,
},
async ({ searchParams, authentication }) => {
const page = parseInt(searchParams.page);
const limit = parseInt(searchParams.limit);
const skip = (page - 1) * limit;
const workspace = await getWorkspaceByUser(authentication.userId);
const where = {
workspaceId: workspace?.id,
...(searchParams.source && { source: searchParams.source }),
};
const [sessions, totalCount, sourcesResult, activeSources] =
await Promise.all([
prisma.mCPSession.findMany({
where,
orderBy: { createdAt: "desc" },
skip,
take: limit,
}),
prisma.mCPSession.count({ where }),
prisma.mCPSession.groupBy({
by: ["source"],
where: { workspaceId: workspace?.id },
_count: { source: true },
orderBy: { _count: { source: "desc" } },
}),
// Get distinct active sources (where deleted is null)
prisma.mCPSession.findMany({
where: { deleted: null, workspaceId: workspace?.id },
select: { source: true },
distinct: ["source"],
}),
]);
const hasMore = skip + sessions.length < totalCount;
const availableSources = sourcesResult.map((item) => ({
name: item.source,
slug: item.source,
count: item._count.source,
}));
const activeSourceNames = activeSources.map((item) => item.source);
return json({
sessions: sessions.map((session) => ({
id: session.id,
source: session.source,
integrations: session.integrations,
createdAt: session.createdAt.toISOString(),
deleted: session.deleted?.toISOString(),
})),
totalCount,
page,
limit,
hasMore,
availableSources,
activeSources: activeSourceNames,
});
},
);
export { loader };

View File

@ -134,7 +134,7 @@ function parseSpec(spec: any) {
}
function CustomIntegrationContent({ integration }: { integration: any }) {
const memoryUrl = `https://core.heysol.ai/api/v1/mcp/memory?source=${integration.slug}`;
const memoryUrl = `https://core.heysol.ai/api/v1/mcp?source=${integration.slug}`;
const [copied, setCopied] = useState(false);
const copyToClipboard = async () => {

View File

@ -233,7 +233,7 @@ export default function OAuthAuthorize() {
<Card className="bg-background-3 shadow-1 w-full max-w-md rounded-lg p-5">
<CardContent>
<div className="flex items-center justify-center gap-4">
{getIconForAuthorise(client.name, client.logoUrl)}
{getIconForAuthorise(client.name, 40, client.logoUrl)}
<ArrowRightLeft size={16} />
<Logo width={40} height={40} />
</div>

View File

@ -122,7 +122,7 @@ export default function Onboarding() {
});
const getMemoryUrl = (source: "Claude" | "Cursor" | "Other") => {
const baseUrl = "https://core.heysol.ai/api/v1/mcp/memory";
const baseUrl = "https://core.heysol.ai/api/v1/mcp";
return `${baseUrl}?Source=${source}`;
};

View File

@ -0,0 +1,93 @@
import { useState } from "react";
import { useMcpSessions } from "~/hooks/use-mcp-sessions";
import { McpSessionsFilters } from "~/components/mcp/mcp-sessions-filters";
import {
MCPUrlBox,
VirtualMcpSessionsList,
} from "~/components/mcp/virtual-mcp-sessions-list";
import { McpSourcesStats } from "~/components/mcp/mcp-sources-stats";
import { Card, CardContent } from "~/components/ui/card";
import { Database, LoaderCircle } from "lucide-react";
import { SettingSection } from "~/components/setting-section";
export default function McpSettings() {
const [selectedSource, setSelectedSource] = useState<string | undefined>();
const {
sessions,
hasMore,
loadMore,
availableSources,
activeSources,
isLoading,
isInitialLoad,
} = useMcpSessions({
endpoint: "/api/v1/mcp/sessions",
source: selectedSource,
});
return (
<div className="mx-auto flex h-full w-3xl flex-col gap-4 px-4 pt-6">
<SettingSection
title="MCP Sessions"
description="View and manage Model Context Protocol sessions for integrations."
>
<div className="flex h-[calc(100vh_-_135px)] w-full flex-col items-center space-y-6">
{/* Top Sources Stats */}
<div className="flex w-full flex-col gap-4">
<MCPUrlBox />
<McpSourcesStats
sources={availableSources}
activeSources={activeSources}
isLoading={isInitialLoad}
/>
</div>
{isInitialLoad ? (
<div className="flex items-center justify-center py-8">
<LoaderCircle className="text-primary h-6 w-6 animate-spin" />
</div>
) : (
<>
{/* Filters */}
<McpSessionsFilters
availableSources={availableSources}
selectedSource={selectedSource}
onSourceChange={setSelectedSource}
/>
{/* Sessions List */}
<div className="flex h-full w-full space-y-4">
{sessions.length === 0 ? (
<Card className="bg-background-2 w-full">
<CardContent className="bg-background-2 flex w-full 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 MCP sessions found
</h3>
<p className="text-muted-foreground">
{selectedSource
? "Try adjusting your filters to see more results."
: "No MCP sessions are available yet."}
</p>
</div>
</CardContent>
</Card>
) : (
<VirtualMcpSessionsList
sessions={sessions}
hasMore={hasMore}
loadMore={loadMore}
isLoading={isLoading}
height={600}
/>
)}
</div>
</>
)}
</div>
</SettingSection>
</div>
);
}

View File

@ -1,15 +1,4 @@
import {
ArrowLeft,
Brain,
Building,
Clock,
Code,
User,
Workflow,
Webhook,
} from "lucide-react";
import React from "react";
import { ArrowLeft, Code, Webhook, Cable } from "lucide-react";
import {
Sidebar,
@ -54,6 +43,7 @@ export default function Settings() {
// { name: "Workspace", icon: Building },
{ name: "API", icon: Code },
{ name: "Webhooks", icon: Webhook },
{ name: "MCP", icon: Cable },
],
};
const navigate = useNavigate();

View File

@ -13,6 +13,9 @@ import { IntegrationLoader } from "~/utils/mcp/integration-loader";
import { callMemoryTool, memoryTools } from "~/utils/mcp/memory";
import { logger } from "~/services/logger.service";
import { type Response, type Request } from "express";
import { getUser } from "./session.server";
import { getUserById } from "~/models/user.server";
import { getWorkspaceByUser } from "~/models/workspace.server";
const QueryParams = z.object({
source: z.string().optional(),
@ -99,7 +102,7 @@ async function createTransport(
// Clean up old sessions (24+ hours) during new session initialization
try {
const [dbCleanupCount, memoryCleanupCount] = await Promise.all([
MCPSessionManager.cleanupOldSessions(),
MCPSessionManager.cleanupOldSessions(workspaceId),
TransportManager.cleanupOldSessions(),
]);
if (dbCleanupCount > 0 || memoryCleanupCount > 0) {
@ -112,7 +115,12 @@ async function createTransport(
}
// Store session in database
await MCPSessionManager.upsertSession(sessionId, source, integrations);
await MCPSessionManager.upsertSession(
sessionId,
workspaceId,
source,
integrations,
);
// Store main transport
TransportManager.setMainTransport(sessionId, transport);
@ -164,13 +172,17 @@ export const handleMCPRequest = async (
: [];
const userId = authentication.userId;
const workspaceId = authentication.workspaceId;
const workspace = await getWorkspaceByUser(userId);
const workspaceId = workspace?.id as string;
try {
let transport: StreamableHTTPServerTransport;
let currentSessionId = sessionId;
if (sessionId && (await MCPSessionManager.isSessionActive(sessionId))) {
if (
sessionId &&
(await MCPSessionManager.isSessionActive(sessionId, workspaceId))
) {
// Use existing session
const sessionData = TransportManager.getSessionInfo(sessionId);
if (!sessionData.exists) {
@ -214,10 +226,17 @@ export const handleMCPRequest = async (
}
};
export const handleSessionRequest = async (req: Request, res: Response) => {
export const handleSessionRequest = async (
req: Request,
res: Response,
workspaceId: string,
) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (sessionId && (await MCPSessionManager.isSessionActive(sessionId))) {
if (
sessionId &&
(await MCPSessionManager.isSessionActive(sessionId, workspaceId))
) {
const sessionData = TransportManager.getSessionInfo(sessionId);
if (sessionData.exists) {

View File

@ -6,6 +6,7 @@ export interface MCPSessionData {
integrations: string[];
createdAt: Date;
deleted?: Date;
workspaceId?: string;
}
export class MCPSessionManager {
@ -14,6 +15,7 @@ export class MCPSessionManager {
*/
static async upsertSession(
sessionId: string,
workspaceId: string,
source: string,
integrations: string[],
): Promise<MCPSessionData> {
@ -29,6 +31,7 @@ export class MCPSessionManager {
data: {
source,
integrations,
workspaceId,
},
});
} else {
@ -38,6 +41,7 @@ export class MCPSessionManager {
id: sessionId,
source,
integrations,
workspaceId,
},
});
}
@ -47,6 +51,7 @@ export class MCPSessionManager {
source: session.source,
integrations: session.integrations,
createdAt: session.createdAt,
workspaceId: session.workspaceId as string,
deleted: session.deleted || undefined,
};
}
@ -79,6 +84,7 @@ export class MCPSessionManager {
integrations: session.integrations,
createdAt: session.createdAt,
deleted: session.deleted || undefined,
workspaceId: session.workspaceId || undefined,
};
}
@ -103,13 +109,14 @@ export class MCPSessionManager {
/**
* Clean up old sessions (older than 24 hours)
*/
static async cleanupOldSessions(): Promise<number> {
static async cleanupOldSessions(workspaceId: string): Promise<number> {
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const result = await prisma.mCPSession.updateMany({
where: {
createdAt: { lt: twentyFourHoursAgo },
deleted: null,
workspaceId,
},
data: {
deleted: new Date(),
@ -122,9 +129,12 @@ export class MCPSessionManager {
/**
* Check if session is active (not deleted)
*/
static async isSessionActive(sessionId: string): Promise<boolean> {
static async isSessionActive(
sessionId: string,
workspaceId: string,
): Promise<boolean> {
const session = await prisma.mCPSession.findUnique({
where: { id: sessionId },
where: { id: sessionId, workspaceId },
select: { deleted: true },
});

View File

@ -920,88 +920,6 @@ paths:
"204":
description: Space deleted successfully
# MCP (Model Context Protocol)
/api/v1/mcp/memory:
post:
summary: MCP Memory Operations
description: |
MCP server endpoint for memory operations including:
- ingest: Add data to memory
- search: Search memory content
- get_spaces: Retrieve available spaces
security:
- bearerAuth: []
parameters:
- name: mcp-session-id
in: header
schema:
type: string
description: MCP session identifier
- name: source
in: header
schema:
type: string
description: MCP integration source
requestBody:
content:
application/json:
schema:
type: object
description: MCP request payload
responses:
"200":
description: MCP response
content:
application/json:
schema:
type: object
delete:
summary: MCP Session Cleanup
description: Clean up MCP session resources
security:
- bearerAuth: []
parameters:
- name: mcp-session-id
in: header
required: true
schema:
type: string
responses:
"204":
description: Session cleaned up
/api/v1/mcp/{slug}:
parameters:
- name: slug
in: path
required: true
schema:
type: string
description: Integration slug identifier
post:
summary: MCP Integration Proxy
description: |
Proxy MCP requests to integration-specific servers.
Routes requests based on the slug to appropriate integration.
security:
- bearerAuth: []
parameters:
- name: mcp-session-id
in: header
schema:
type: string
description: MCP session identifier
requestBody:
content:
application/json:
schema:
type: object
responses:
"200":
description: Proxied MCP response
# Integration Management
/api/v1/integrations:
get:

View File

@ -1235,88 +1235,6 @@ paths:
"204":
description: Rule deleted
# MCP (Model Context Protocol)
/api/v1/mcp/memory:
post:
summary: MCP Memory Operations
description: |
MCP server endpoint for memory operations including:
- ingest: Add data to memory
- search: Search memory content
- get_spaces: Retrieve available spaces
security:
- bearerAuth: []
parameters:
- name: mcp-session-id
in: header
schema:
type: string
description: MCP session identifier
- name: source
in: header
schema:
type: string
description: MCP integration source
requestBody:
content:
application/json:
schema:
type: object
description: MCP request payload
responses:
"200":
description: MCP response
content:
application/json:
schema:
type: object
delete:
summary: MCP Session Cleanup
description: Clean up MCP session resources
security:
- bearerAuth: []
parameters:
- name: mcp-session-id
in: header
required: true
schema:
type: string
responses:
"204":
description: Session cleaned up
/api/v1/mcp/{slug}:
parameters:
- name: slug
in: path
required: true
schema:
type: string
description: Integration slug identifier
post:
summary: MCP Integration Proxy
description: |
Proxy MCP requests to integration-specific servers.
Routes requests based on the slug to appropriate integration.
security:
- bearerAuth: []
parameters:
- name: mcp-session-id
in: header
schema:
type: string
description: MCP session identifier
requestBody:
content:
application/json:
schema:
type: object
responses:
"200":
description: Proxied MCP response
# Integration Management
/api/v1/integrations:
get:

View File

@ -24,8 +24,7 @@ async function init() {
? () => viteDevServer.ssrLoadModule("virtual:remix/server-build")
: await import("./build/server/index.js");
const { authenticateHybridRequest, handleMCPRequest, handleSessionRequest } =
build.entry.module;
const module = build.entry?.module;
remixHandler = createRequestHandler({ build });
@ -54,22 +53,28 @@ async function init() {
app.use(morgan("tiny"));
app.get("/api/v1/mcp", async (req, res) => {
const authenticationResult = await authenticateHybridRequest(req as any, {
allowJWT: true,
});
const authenticationResult = await module.authenticateHybridRequest(
req as any,
{
allowJWT: true,
},
);
if (!authenticationResult) {
res.status(401).json({ error: "Authentication required" });
return;
}
await handleSessionRequest(req, res);
await module.handleSessionRequest(req, res);
});
app.post("/api/v1/mcp", async (req, res) => {
const authenticationResult = await authenticateHybridRequest(req as any, {
allowJWT: true,
});
const authenticationResult = await module.authenticateHybridRequest(
req as any,
{
allowJWT: true,
},
);
if (!authenticationResult) {
res.status(401).json({ error: "Authentication required" });
@ -85,7 +90,7 @@ async function init() {
try {
const parsedBody = JSON.parse(body);
const queryParams = req.query; // Get query parameters from the request
await handleMCPRequest(
await module.handleMCPRequest(
req,
res,
parsedBody,
@ -99,16 +104,19 @@ async function init() {
});
app.delete("/api/v1/mcp", async (req, res) => {
const authenticationResult = await authenticateHybridRequest(req as any, {
allowJWT: true,
});
const authenticationResult = await module.authenticateHybridRequest(
req as any,
{
allowJWT: true,
},
);
if (!authenticationResult) {
res.status(401).json({ error: "Authentication required" });
return;
}
await handleSessionRequest(req, res);
await module.handleSessionRequest(req, res);
});
app.options("/api/v1/mcp", (_, res) => {

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "MCPSession" ADD COLUMN "workspaceId" TEXT;
-- AddForeignKey
ALTER TABLE "MCPSession" ADD CONSTRAINT "MCPSession_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -215,6 +215,9 @@ model MCPSession {
source String
integrations String[]
workspace Workspace? @relation(references: [id], fields: [workspaceId])
workspaceId String?
createdAt DateTime @default(now())
deleted DateTime?
}
@ -635,6 +638,7 @@ model Workspace {
OAuthRefreshToken OAuthRefreshToken[]
RecallLog RecallLog[]
Space Space[]
MCPSession MCPSession[]
}
enum AuthenticationMethod {