mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-11 21:38:27 +00:00
Feat: add MCP logs
This commit is contained in:
parent
c8ea027f37
commit
c53aba527c
@ -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} />;
|
||||
};
|
||||
|
||||
127
apps/webapp/app/components/mcp/mcp-sessions-filters.tsx
Normal file
127
apps/webapp/app/components/mcp/mcp-sessions-filters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
apps/webapp/app/components/mcp/mcp-sources-stats.tsx
Normal file
105
apps/webapp/app/components/mcp/mcp-sources-stats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
273
apps/webapp/app/components/mcp/virtual-mcp-sessions-list.tsx
Normal file
273
apps/webapp/app/components/mcp/virtual-mcp-sessions-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
apps/webapp/app/hooks/use-mcp-sessions.tsx
Normal file
116
apps/webapp/app/hooks/use-mcp-sessions.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
81
apps/webapp/app/routes/api.v1.mcp.sessions.tsx
Normal file
81
apps/webapp/app/routes/api.v1.mcp.sessions.tsx
Normal 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 };
|
||||
@ -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 () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}`;
|
||||
};
|
||||
|
||||
|
||||
93
apps/webapp/app/routes/settings.mcp.tsx
Normal file
93
apps/webapp/app/routes/settings.mcp.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user