Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions frontend/src/components/app-config/mcp-config.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* Copyright 2024 Marimo. All rights reserved. */

import { CheckSquareIcon } from "lucide-react";
import { CheckSquareIcon, Loader2, RefreshCwIcon } from "lucide-react";
import React from "react";
import type { UseFormReturn } from "react-hook-form";
import {
Expand All @@ -12,6 +12,8 @@ import {
} from "@/components/ui/card";
import { FormField, FormItem } from "@/components/ui/form";
import type { UserConfig } from "@/core/config/config-schema";
import { useMCPRefresh, useMCPStatus } from "../mcp/hooks";
import { McpStatusText } from "../mcp/mcp-status-indicator";
import { Button } from "../ui/button";
import { Kbd } from "../ui/kbd";
import { SettingSubtitle } from "./common";
Expand Down Expand Up @@ -45,10 +47,48 @@ const PRESET_CONFIGS: PresetConfig[] = [

export const MCPConfig: React.FC<MCPConfigProps> = ({ form, onSubmit }) => {
const { handleClick } = useOpenSettingsToTab();
const { data: status, refetch, isFetching } = useMCPStatus();
const { refresh, isRefreshing } = useMCPRefresh();

const handleRefresh = async () => {
await refresh();
refetch();
};

return (
<div className="flex flex-col gap-4">
<SettingSubtitle>MCP Servers</SettingSubtitle>
<div className="flex items-center justify-between">
<SettingSubtitle>MCP Servers</SettingSubtitle>
<div className="flex items-center gap-2">
{status && <McpStatusText status={status.status} />}
<Button
variant="outline"
size="xs"
onClick={handleRefresh}
disabled={isRefreshing || isFetching}
>
{isRefreshing || isFetching ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCwIcon className="h-3 w-3" />
)}
</Button>
</div>
</div>
{status?.error && (
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded">
{status.error}
</div>
)}
{status?.servers && (
<div className="text-xs text-muted-foreground">
{Object.entries(status.servers).map(([server, status]) => (
<div key={server}>
{server}: <McpStatusText status={status} />
</div>
))}
</div>
)}
<p className="text-sm text-muted-foreground">
Enable Model Context Protocol (MCP) servers to provide additional
capabilities and data sources for AI features.
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/chat/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
} from "../editor/ai/completion-utils";
import { PanelEmptyState } from "../editor/chrome/panels/empty-state";
import { CopyClipboardIcon } from "../icons/copy-icon";
import { MCPStatusIndicator } from "../mcp/mcp-status-indicator";
import { Input } from "../ui/input";
import { Tooltip, TooltipProvider } from "../ui/tooltip";
import { toast } from "../ui/use-toast";
Expand Down Expand Up @@ -120,6 +121,7 @@ const ChatHeader: React.FC<ChatHeaderProps> = ({
</Button>
</Tooltip>
<div className="flex items-center gap-2">
<MCPStatusIndicator />
<Tooltip content="AI Settings">
<Button
variant="text"
Expand Down
12 changes: 2 additions & 10 deletions frontend/src/components/editor/database/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,7 @@ function passwordField() {

function tokenField(label?: string, required?: boolean) {
let field: z.ZodString | z.ZodOptional<z.ZodString> = z.string();
if (required) {
field = field.nonempty();
} else {
field = field.optional();
}
field = required ? field.nonempty() : field.optional();

field = field.describe(
FieldOptions.of({
Expand Down Expand Up @@ -50,11 +46,7 @@ function warehouseNameField() {

function uriField(label?: string, required?: boolean) {
let field: z.ZodString | z.ZodOptional<z.ZodString> = z.string();
if (required) {
field = field.nonempty();
} else {
field = field.optional();
}
field = required ? field.nonempty() : field.optional();

return field.describe(
FieldOptions.of({ label: label || "URI", optionRegex: ".*uri.*" }),
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/forms/__tests__/form-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ describe("getDefaults", () => {
});
const result = getDefaults(schema) as { map: Map<string, number> };
expect(result.map instanceof Map).toBe(true);
expect(Array.from(result.map.entries())).toEqual([["a", 1]]);
expect([...result.map.entries()]).toEqual([["a", 1]]);
});

it("should handle ZodSet with default", () => {
Expand All @@ -200,7 +200,7 @@ describe("getDefaults", () => {
});
const result = getDefaults(schema) as { set: Set<string> };
expect(result.set instanceof Set).toBe(true);
expect(Array.from(result.set)).toEqual(["a", "b"]);
expect([...result.set]).toEqual(["a", "b"]);
});

it("should handle deeply nested defaults", () => {
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/components/mcp/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/* Copyright 2024 Marimo. All rights reserved. */

import type { components } from "@marimo-team/marimo-api";
import { useState } from "react";
import { API } from "@/core/network/api";
import { useAsyncData } from "@/hooks/useAsyncData";
import { toast } from "../ui/use-toast";

export type MCPStatus = components["schemas"]["MCPStatusResponse"];
export type MCPRefreshResponse = components["schemas"]["MCPRefreshResponse"];

/**
* Hook to fetch MCP server status
*/
export function useMCPStatus() {
return useAsyncData<MCPStatus>(async () => {
return API.get<MCPStatus>("/ai/mcp/status");
}, []);
}

/**
* Hook to refresh MCP server configuration
*/
export function useMCPRefresh() {
const [isRefreshing, setIsRefreshing] = useState(false);

const refresh = async () => {
setIsRefreshing(true);
try {
await API.post<object, MCPRefreshResponse>("/ai/mcp/refresh", {});
toast({
title: "MCP refreshed",
description: "MCP server configuration has been refreshed successfully",
});
} catch (error) {
toast({
title: "Refresh failed",
description:
error instanceof Error ? error.message : "Failed to refresh MCP",
variant: "danger",
});
} finally {
setIsRefreshing(false);
}
};

return { refresh, isRefreshing };
}
144 changes: 144 additions & 0 deletions frontend/src/components/mcp/mcp-status-indicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/* Copyright 2024 Marimo. All rights reserved. */

import { Loader2, PlugIcon, RefreshCwIcon } from "lucide-react";
import { API } from "@/core/network/api";
import { cn } from "@/utils/cn";
import { Button } from "../ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Tooltip } from "../ui/tooltip";
import { toast } from "../ui/use-toast";
import { useMCPStatus } from "./hooks";

/**
* MCP Status indicator component
* Shows a small icon with status color and a popover with detailed information
*/
export const MCPStatusIndicator: React.FC = () => {
const { data: status, refetch, isFetching } = useMCPStatus();

const handleRefresh = async () => {
try {
await API.post<object, { success: boolean }>("/ai/mcp/refresh", {});
toast({
title: "MCP refreshed",
description: "MCP server configuration has been refreshed",
});
refetch();
} catch (error) {
toast({
title: "Refresh failed",
description:
error instanceof Error ? error.message : "Failed to refresh MCP",
variant: "danger",
});
}
};

const servers = status?.servers || {};
const hasServers = Object.keys(servers).length > 0;

return (
<Popover>
<Tooltip content="MCP Status">
<PopoverTrigger asChild={true}>
<Button variant="text" size="icon">
<PlugIcon
className={cn(
"h-4 w-4",
status?.status === "ok" && "text-green-500",
status?.status === "partial" && "text-yellow-500",
status?.status === "error" && hasServers && "text-red-500",
)}
/>
</Button>
</PopoverTrigger>
</Tooltip>
<PopoverContent className="w-[320px]" align="start" side="right">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-medium text-sm">MCP Server Status</h4>
<Button
variant="ghost"
size="xs"
onClick={handleRefresh}
disabled={isFetching}
>
{isFetching ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCwIcon className="h-3 w-3" />
)}
</Button>
</div>
{status && (
<div className="text-xs space-y-2">
{hasServers && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Overall:</span>
<McpStatusText status={status.status} />
</div>
)}
{status.error && (
<div className="text-xs text-red-500 bg-red-50 dark:bg-red-950/20 p-2 rounded">
{status.error}
</div>
)}
{hasServers && (
<div className="space-y-1">
<div className="text-muted-foreground font-medium">
Servers:
</div>
{Object.entries(servers).map(([name, serverStatus]) => (
<div
key={name}
className="flex justify-between items-center pl-2"
>
<span className="text-muted-foreground truncate max-w-[180px]">
{name}
</span>
<McpStatusText status={serverStatus} />
</div>
))}
</div>
)}
{!hasServers && (
<div className="text-muted-foreground text-center py-2">
No MCP servers configured. <br /> Configure under{" "}
<b>Settings &gt; AI &gt; MCP</b>
</div>
)}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
};

export const McpStatusText: React.FC<{
status:
| "ok"
| "partial"
| "error"
| "failed"
| "disconnected"
| "pending"
| "connected";
}> = ({ status }) => {
return (
<span
className={cn(
"text-xs font-medium",
status === "ok" && "text-green-500",
status === "partial" && "text-yellow-500",
status === "error" && "text-red-500",
status === "failed" && "text-red-500",
status === "disconnected" && "text-gray-500",
status === "pending" && "text-yellow-500",
status === "connected" && "text-green-500",
)}
>
{status}
</span>
);
};
4 changes: 2 additions & 2 deletions frontend/src/core/network/CachingRequestRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ export class CachingRequestRegistry<REQ, RES> {

const promise = this.delegate.request(req);
this.cache.set(key, promise);
return promise.catch((err) => {
return promise.catch((error) => {
this.cache.delete(key);
throw err;
throw error;
});
}

Expand Down
2 changes: 2 additions & 0 deletions marimo/_cli/development/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ def _generate_server_api_schema() -> dict[str, Any]:
models.UpdateComponentValuesRequest,
models.InvokeAiToolRequest,
models.InvokeAiToolResponse,
models.MCPStatusResponse,
models.MCPRefreshResponse,
requests.CodeCompletionRequest,
requests.DeleteCellRequest,
requests.HTTPRequest,
Expand Down
Loading
Loading