From c53aba527cbd8679780f0fe7679d658df2b5a5b5 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Sun, 24 Aug 2025 12:19:03 +0530 Subject: [PATCH] Feat: add MCP logs --- apps/webapp/app/components/icon-utils.tsx | 19 +- .../components/mcp/mcp-sessions-filters.tsx | 127 ++++++++ .../app/components/mcp/mcp-sources-stats.tsx | 105 +++++++ .../mcp/virtual-mcp-sessions-list.tsx | 273 ++++++++++++++++++ apps/webapp/app/hooks/use-mcp-sessions.tsx | 116 ++++++++ .../webapp/app/routes/api.v1.mcp.sessions.tsx | 81 ++++++ .../app/routes/home.integration.$slug.tsx | 2 +- apps/webapp/app/routes/oauth.authorize.tsx | 2 +- apps/webapp/app/routes/onboarding.tsx | 2 +- apps/webapp/app/routes/settings.mcp.tsx | 93 ++++++ apps/webapp/app/routes/settings.tsx | 14 +- apps/webapp/app/services/mcp.server.ts | 31 +- apps/webapp/app/utils/mcp/session-manager.ts | 16 +- apps/webapp/openapi-docs.yaml | 82 ------ apps/webapp/openapi.yaml | 82 ------ apps/webapp/server.ts | 36 ++- .../migration.sql | 5 + packages/database/prisma/schema.prisma | 4 + 18 files changed, 884 insertions(+), 206 deletions(-) create mode 100644 apps/webapp/app/components/mcp/mcp-sessions-filters.tsx create mode 100644 apps/webapp/app/components/mcp/mcp-sources-stats.tsx create mode 100644 apps/webapp/app/components/mcp/virtual-mcp-sessions-list.tsx create mode 100644 apps/webapp/app/hooks/use-mcp-sessions.tsx create mode 100644 apps/webapp/app/routes/api.v1.mcp.sessions.tsx create mode 100644 apps/webapp/app/routes/settings.mcp.tsx create mode 100644 packages/database/prisma/migrations/20250824063030_add_mcp_session_to_workspace/migration.sql diff --git a/apps/webapp/app/components/icon-utils.tsx b/apps/webapp/app/components/icon-utils.tsx index 7822c8f..03f0085 100644 --- a/apps/webapp/app/components/icon-utils.tsx +++ b/apps/webapp/app/components/icon-utils.tsx @@ -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 {name}; + return ( + {name} + ); } 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 ; + return ; } - return ; + return ; }; diff --git a/apps/webapp/app/components/mcp/mcp-sessions-filters.tsx b/apps/webapp/app/components/mcp/mcp-sessions-filters.tsx new file mode 100644 index 0000000..c0b89b5 --- /dev/null +++ b/apps/webapp/app/components/mcp/mcp-sessions-filters.tsx @@ -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("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 ( +
+ { + setPopoverOpen(open); + if (!open) setStep("main"); + }} + > + + + + + + {step === "main" && ( +
+ +
+ )} + + {step === "source" && ( +
+ + {limitedSources.map((source) => ( + + ))} +
+ )} +
+
+
+ + {/* Active Filters */} + {hasFilters && ( +
+ {selectedSource && ( + + {selectedSourceName} + onSourceChange(undefined)} + /> + + )} +
+ )} +
+ ); +} diff --git a/apps/webapp/app/components/mcp/mcp-sources-stats.tsx b/apps/webapp/app/components/mcp/mcp-sources-stats.tsx new file mode 100644 index 0000000..f1ffab6 --- /dev/null +++ b/apps/webapp/app/components/mcp/mcp-sources-stats.tsx @@ -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 ( + + + Top Sources + + +
+ +
+
+
+ ); + } + + const totalSessions = sources.reduce((sum, source) => sum + source.count, 0); + + return ( +
+ + + Top Sources + + + {sources.length === 0 ? ( +

No sources found

+ ) : ( +
+ {sources.slice(0, 5).map((source) => { + const percentage = + totalSessions > 0 ? (source.count / totalSessions) * 100 : 0; + return ( +
+
+ {getIconForAuthorise(source.name.toLowerCase(), 16)} + {source.name} + + {source.count} + +
+
+
+
+
+ + {percentage.toFixed(0)}% + +
+
+ ); + })} +
+ )} + + + + + + Current Active + + + {activeSources.length === 0 ? ( +

No active sources

+ ) : ( +
+ {activeSources.map((source) => ( + + {getIconForAuthorise(source.toLowerCase(), 12)} + + {source} + + ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/webapp/app/components/mcp/virtual-mcp-sessions-list.tsx b/apps/webapp/app/components/mcp/virtual-mcp-sessions-list.tsx new file mode 100644 index 0000000..c089240 --- /dev/null +++ b/apps/webapp/app/components/mcp/virtual-mcp-sessions-list.tsx @@ -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 ( + + +
+
+
+ {(["Claude", "Cursor", "Other"] as const).map((source) => ( + + ))} +
+ +
+ + +
+
+
+
+
+ ); +} + +function McpSessionItemRenderer( + props: ListRowProps, + sessions: McpSessionItem[], + cache: CellMeasurerCache, +) { + const { index, key, style, parent } = props; + const session = sessions[index]; + + if (!session) { + return ( + +
+
+
+ + ); + } + + const createdAt = new Date(session.createdAt); + const deleted = !!session.deleted; + + return ( + +
+
+
+
+
+
+ {getIconForAuthorise(session.source.toLowerCase(), 18)} + +
+
+ {session.source} +
+
+
+ +
+
+ {!deleted && ( + + Active + + )} +
+ {createdAt.toLocaleString()} +
+ + {session.integrations.length > 0 && ( +
+ {session.integrations.map((integration) => ( + + {getIconForAuthorise(integration, 12)} + {integration} + + ))} +
+ )} +
+
+
+
+
+
+
+
+ ); +} + +export function VirtualMcpSessionsList({ + sessions, + hasMore, + loadMore, + isLoading, +}: VirtualMcpSessionsListProps) { + // Create a CellMeasurerCache instance using useRef to prevent recreation + const cacheRef = useRef(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 ( +
+ + {({ width, height: autoHeight }) => ( + + {({ onRowsRendered, registerChild }) => ( + + )} + + )} + + + {isLoading && ( +
+ Loading more sessions... +
+ )} +
+ ); +} diff --git a/apps/webapp/app/hooks/use-mcp-sessions.tsx b/apps/webapp/app/hooks/use-mcp-sessions.tsx new file mode 100644 index 0000000..0161112 --- /dev/null +++ b/apps/webapp/app/hooks/use-mcp-sessions.tsx @@ -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(); + const [sessions, setSessions] = useState([]); + 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([]); + 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, + }; +} diff --git a/apps/webapp/app/routes/api.v1.mcp.sessions.tsx b/apps/webapp/app/routes/api.v1.mcp.sessions.tsx new file mode 100644 index 0000000..c806576 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.mcp.sessions.tsx @@ -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 }; diff --git a/apps/webapp/app/routes/home.integration.$slug.tsx b/apps/webapp/app/routes/home.integration.$slug.tsx index 1d99356..8640d4e 100644 --- a/apps/webapp/app/routes/home.integration.$slug.tsx +++ b/apps/webapp/app/routes/home.integration.$slug.tsx @@ -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 () => { diff --git a/apps/webapp/app/routes/oauth.authorize.tsx b/apps/webapp/app/routes/oauth.authorize.tsx index 66271c3..0a6192f 100644 --- a/apps/webapp/app/routes/oauth.authorize.tsx +++ b/apps/webapp/app/routes/oauth.authorize.tsx @@ -233,7 +233,7 @@ export default function OAuthAuthorize() {
- {getIconForAuthorise(client.name, client.logoUrl)} + {getIconForAuthorise(client.name, 40, client.logoUrl)}
diff --git a/apps/webapp/app/routes/onboarding.tsx b/apps/webapp/app/routes/onboarding.tsx index 339d480..7b8caa9 100644 --- a/apps/webapp/app/routes/onboarding.tsx +++ b/apps/webapp/app/routes/onboarding.tsx @@ -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}`; }; diff --git a/apps/webapp/app/routes/settings.mcp.tsx b/apps/webapp/app/routes/settings.mcp.tsx new file mode 100644 index 0000000..1e0944b --- /dev/null +++ b/apps/webapp/app/routes/settings.mcp.tsx @@ -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(); + + const { + sessions, + hasMore, + loadMore, + availableSources, + activeSources, + isLoading, + isInitialLoad, + } = useMcpSessions({ + endpoint: "/api/v1/mcp/sessions", + source: selectedSource, + }); + + return ( +
+ +
+ {/* Top Sources Stats */} +
+ + +
+ + {isInitialLoad ? ( +
+ +
+ ) : ( + <> + {/* Filters */} + + + {/* Sessions List */} +
+ {sessions.length === 0 ? ( + + +
+ +

+ No MCP sessions found +

+

+ {selectedSource + ? "Try adjusting your filters to see more results." + : "No MCP sessions are available yet."} +

+
+
+
+ ) : ( + + )} +
+ + )} +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/settings.tsx b/apps/webapp/app/routes/settings.tsx index a84a184..433c20e 100644 --- a/apps/webapp/app/routes/settings.tsx +++ b/apps/webapp/app/routes/settings.tsx @@ -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(); diff --git a/apps/webapp/app/services/mcp.server.ts b/apps/webapp/app/services/mcp.server.ts index 925d181..ae51763 100644 --- a/apps/webapp/app/services/mcp.server.ts +++ b/apps/webapp/app/services/mcp.server.ts @@ -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) { diff --git a/apps/webapp/app/utils/mcp/session-manager.ts b/apps/webapp/app/utils/mcp/session-manager.ts index 675c4fd..a7dfa1a 100644 --- a/apps/webapp/app/utils/mcp/session-manager.ts +++ b/apps/webapp/app/utils/mcp/session-manager.ts @@ -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 { @@ -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 { + static async cleanupOldSessions(workspaceId: string): Promise { 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 { + static async isSessionActive( + sessionId: string, + workspaceId: string, + ): Promise { const session = await prisma.mCPSession.findUnique({ - where: { id: sessionId }, + where: { id: sessionId, workspaceId }, select: { deleted: true }, }); diff --git a/apps/webapp/openapi-docs.yaml b/apps/webapp/openapi-docs.yaml index 10dd2b0..8864b69 100644 --- a/apps/webapp/openapi-docs.yaml +++ b/apps/webapp/openapi-docs.yaml @@ -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: diff --git a/apps/webapp/openapi.yaml b/apps/webapp/openapi.yaml index 26b03e1..c2e2c90 100644 --- a/apps/webapp/openapi.yaml +++ b/apps/webapp/openapi.yaml @@ -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: diff --git a/apps/webapp/server.ts b/apps/webapp/server.ts index a22a20c..85303a4 100644 --- a/apps/webapp/server.ts +++ b/apps/webapp/server.ts @@ -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) => { diff --git a/packages/database/prisma/migrations/20250824063030_add_mcp_session_to_workspace/migration.sql b/packages/database/prisma/migrations/20250824063030_add_mcp_session_to_workspace/migration.sql new file mode 100644 index 0000000..b5cc598 --- /dev/null +++ b/packages/database/prisma/migrations/20250824063030_add_mcp_session_to_workspace/migration.sql @@ -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; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 5434a96..fde5c0e 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -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 {