Skip to content

Commit 7b72430

Browse files
authored
feat: add mcp status and refresh (#6636)
1 parent 3388675 commit 7b72430

File tree

14 files changed

+641
-18
lines changed

14 files changed

+641
-18
lines changed

frontend/src/components/app-config/mcp-config.tsx

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* Copyright 2024 Marimo. All rights reserved. */
22

3-
import { CheckSquareIcon } from "lucide-react";
3+
import { CheckSquareIcon, Loader2, RefreshCwIcon } from "lucide-react";
44
import React from "react";
55
import type { UseFormReturn } from "react-hook-form";
66
import {
@@ -12,6 +12,8 @@ import {
1212
} from "@/components/ui/card";
1313
import { FormField, FormItem } from "@/components/ui/form";
1414
import type { UserConfig } from "@/core/config/config-schema";
15+
import { useMCPRefresh, useMCPStatus } from "../mcp/hooks";
16+
import { McpStatusText } from "../mcp/mcp-status-indicator";
1517
import { Button } from "../ui/button";
1618
import { Kbd } from "../ui/kbd";
1719
import { SettingSubtitle } from "./common";
@@ -45,10 +47,48 @@ const PRESET_CONFIGS: PresetConfig[] = [
4547

4648
export const MCPConfig: React.FC<MCPConfigProps> = ({ form, onSubmit }) => {
4749
const { handleClick } = useOpenSettingsToTab();
50+
const { data: status, refetch, isFetching } = useMCPStatus();
51+
const { refresh, isRefreshing } = useMCPRefresh();
52+
53+
const handleRefresh = async () => {
54+
await refresh();
55+
refetch();
56+
};
4857

4958
return (
5059
<div className="flex flex-col gap-4">
51-
<SettingSubtitle>MCP Servers</SettingSubtitle>
60+
<div className="flex items-center justify-between">
61+
<SettingSubtitle>MCP Servers</SettingSubtitle>
62+
<div className="flex items-center gap-2">
63+
{status && <McpStatusText status={status.status} />}
64+
<Button
65+
variant="outline"
66+
size="xs"
67+
onClick={handleRefresh}
68+
disabled={isRefreshing || isFetching}
69+
>
70+
{isRefreshing || isFetching ? (
71+
<Loader2 className="h-3 w-3 animate-spin" />
72+
) : (
73+
<RefreshCwIcon className="h-3 w-3" />
74+
)}
75+
</Button>
76+
</div>
77+
</div>
78+
{status?.error && (
79+
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded">
80+
{status.error}
81+
</div>
82+
)}
83+
{status?.servers && (
84+
<div className="text-xs text-muted-foreground">
85+
{Object.entries(status.servers).map(([server, status]) => (
86+
<div key={server}>
87+
{server}: <McpStatusText status={status} />
88+
</div>
89+
))}
90+
</div>
91+
)}
5292
<p className="text-sm text-muted-foreground">
5393
Enable Model Context Protocol (MCP) servers to provide additional
5494
capabilities and data sources for AI features.

frontend/src/components/chat/chat-panel.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
} from "../editor/ai/completion-utils";
6464
import { PanelEmptyState } from "../editor/chrome/panels/empty-state";
6565
import { CopyClipboardIcon } from "../icons/copy-icon";
66+
import { MCPStatusIndicator } from "../mcp/mcp-status-indicator";
6667
import { Input } from "../ui/input";
6768
import { Tooltip, TooltipProvider } from "../ui/tooltip";
6869
import { toast } from "../ui/use-toast";
@@ -120,6 +121,7 @@ const ChatHeader: React.FC<ChatHeaderProps> = ({
120121
</Button>
121122
</Tooltip>
122123
<div className="flex items-center gap-2">
124+
<MCPStatusIndicator />
123125
<Tooltip content="AI Settings">
124126
<Button
125127
variant="text"

frontend/src/components/editor/database/schemas.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,7 @@ function passwordField() {
1818

1919
function tokenField(label?: string, required?: boolean) {
2020
let field: z.ZodString | z.ZodOptional<z.ZodString> = z.string();
21-
if (required) {
22-
field = field.nonempty();
23-
} else {
24-
field = field.optional();
25-
}
21+
field = required ? field.nonempty() : field.optional();
2622

2723
field = field.describe(
2824
FieldOptions.of({
@@ -50,11 +46,7 @@ function warehouseNameField() {
5046

5147
function uriField(label?: string, required?: boolean) {
5248
let field: z.ZodString | z.ZodOptional<z.ZodString> = z.string();
53-
if (required) {
54-
field = field.nonempty();
55-
} else {
56-
field = field.optional();
57-
}
49+
field = required ? field.nonempty() : field.optional();
5850

5951
return field.describe(
6052
FieldOptions.of({ label: label || "URI", optionRegex: ".*uri.*" }),

frontend/src/components/forms/__tests__/form-utils.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ describe("getDefaults", () => {
191191
});
192192
const result = getDefaults(schema) as { map: Map<string, number> };
193193
expect(result.map instanceof Map).toBe(true);
194-
expect(Array.from(result.map.entries())).toEqual([["a", 1]]);
194+
expect([...result.map.entries()]).toEqual([["a", 1]]);
195195
});
196196

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

206206
it("should handle deeply nested defaults", () => {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/* Copyright 2024 Marimo. All rights reserved. */
2+
3+
import type { components } from "@marimo-team/marimo-api";
4+
import { useState } from "react";
5+
import { API } from "@/core/network/api";
6+
import { useAsyncData } from "@/hooks/useAsyncData";
7+
import { toast } from "../ui/use-toast";
8+
9+
export type MCPStatus = components["schemas"]["MCPStatusResponse"];
10+
export type MCPRefreshResponse = components["schemas"]["MCPRefreshResponse"];
11+
12+
/**
13+
* Hook to fetch MCP server status
14+
*/
15+
export function useMCPStatus() {
16+
return useAsyncData<MCPStatus>(async () => {
17+
return API.get<MCPStatus>("/ai/mcp/status");
18+
}, []);
19+
}
20+
21+
/**
22+
* Hook to refresh MCP server configuration
23+
*/
24+
export function useMCPRefresh() {
25+
const [isRefreshing, setIsRefreshing] = useState(false);
26+
27+
const refresh = async () => {
28+
setIsRefreshing(true);
29+
try {
30+
await API.post<object, MCPRefreshResponse>("/ai/mcp/refresh", {});
31+
toast({
32+
title: "MCP refreshed",
33+
description: "MCP server configuration has been refreshed successfully",
34+
});
35+
} catch (error) {
36+
toast({
37+
title: "Refresh failed",
38+
description:
39+
error instanceof Error ? error.message : "Failed to refresh MCP",
40+
variant: "danger",
41+
});
42+
} finally {
43+
setIsRefreshing(false);
44+
}
45+
};
46+
47+
return { refresh, isRefreshing };
48+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/* Copyright 2024 Marimo. All rights reserved. */
2+
3+
import { Loader2, PlugIcon, RefreshCwIcon } from "lucide-react";
4+
import { API } from "@/core/network/api";
5+
import { cn } from "@/utils/cn";
6+
import { Button } from "../ui/button";
7+
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
8+
import { Tooltip } from "../ui/tooltip";
9+
import { toast } from "../ui/use-toast";
10+
import { useMCPStatus } from "./hooks";
11+
12+
/**
13+
* MCP Status indicator component
14+
* Shows a small icon with status color and a popover with detailed information
15+
*/
16+
export const MCPStatusIndicator: React.FC = () => {
17+
const { data: status, refetch, isFetching } = useMCPStatus();
18+
19+
const handleRefresh = async () => {
20+
try {
21+
await API.post<object, { success: boolean }>("/ai/mcp/refresh", {});
22+
toast({
23+
title: "MCP refreshed",
24+
description: "MCP server configuration has been refreshed",
25+
});
26+
refetch();
27+
} catch (error) {
28+
toast({
29+
title: "Refresh failed",
30+
description:
31+
error instanceof Error ? error.message : "Failed to refresh MCP",
32+
variant: "danger",
33+
});
34+
}
35+
};
36+
37+
const servers = status?.servers || {};
38+
const hasServers = Object.keys(servers).length > 0;
39+
40+
return (
41+
<Popover>
42+
<Tooltip content="MCP Status">
43+
<PopoverTrigger asChild={true}>
44+
<Button variant="text" size="icon">
45+
<PlugIcon
46+
className={cn(
47+
"h-4 w-4",
48+
status?.status === "ok" && "text-green-500",
49+
status?.status === "partial" && "text-yellow-500",
50+
status?.status === "error" && hasServers && "text-red-500",
51+
)}
52+
/>
53+
</Button>
54+
</PopoverTrigger>
55+
</Tooltip>
56+
<PopoverContent className="w-[320px]" align="start" side="right">
57+
<div className="space-y-3">
58+
<div className="flex items-center justify-between">
59+
<h4 className="font-medium text-sm">MCP Server Status</h4>
60+
<Button
61+
variant="ghost"
62+
size="xs"
63+
onClick={handleRefresh}
64+
disabled={isFetching}
65+
>
66+
{isFetching ? (
67+
<Loader2 className="h-3 w-3 animate-spin" />
68+
) : (
69+
<RefreshCwIcon className="h-3 w-3" />
70+
)}
71+
</Button>
72+
</div>
73+
{status && (
74+
<div className="text-xs space-y-2">
75+
{hasServers && (
76+
<div className="flex justify-between items-center">
77+
<span className="text-muted-foreground">Overall:</span>
78+
<McpStatusText status={status.status} />
79+
</div>
80+
)}
81+
{status.error && (
82+
<div className="text-xs text-red-500 bg-red-50 dark:bg-red-950/20 p-2 rounded">
83+
{status.error}
84+
</div>
85+
)}
86+
{hasServers && (
87+
<div className="space-y-1">
88+
<div className="text-muted-foreground font-medium">
89+
Servers:
90+
</div>
91+
{Object.entries(servers).map(([name, serverStatus]) => (
92+
<div
93+
key={name}
94+
className="flex justify-between items-center pl-2"
95+
>
96+
<span className="text-muted-foreground truncate max-w-[180px]">
97+
{name}
98+
</span>
99+
<McpStatusText status={serverStatus} />
100+
</div>
101+
))}
102+
</div>
103+
)}
104+
{!hasServers && (
105+
<div className="text-muted-foreground text-center py-2">
106+
No MCP servers configured. <br /> Configure under{" "}
107+
<b>Settings &gt; AI &gt; MCP</b>
108+
</div>
109+
)}
110+
</div>
111+
)}
112+
</div>
113+
</PopoverContent>
114+
</Popover>
115+
);
116+
};
117+
118+
export const McpStatusText: React.FC<{
119+
status:
120+
| "ok"
121+
| "partial"
122+
| "error"
123+
| "failed"
124+
| "disconnected"
125+
| "pending"
126+
| "connected";
127+
}> = ({ status }) => {
128+
return (
129+
<span
130+
className={cn(
131+
"text-xs font-medium",
132+
status === "ok" && "text-green-500",
133+
status === "partial" && "text-yellow-500",
134+
status === "error" && "text-red-500",
135+
status === "failed" && "text-red-500",
136+
status === "disconnected" && "text-gray-500",
137+
status === "pending" && "text-yellow-500",
138+
status === "connected" && "text-green-500",
139+
)}
140+
>
141+
{status}
142+
</span>
143+
);
144+
};

frontend/src/core/network/CachingRequestRegistry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,9 @@ export class CachingRequestRegistry<REQ, RES> {
5656

5757
const promise = this.delegate.request(req);
5858
this.cache.set(key, promise);
59-
return promise.catch((err) => {
59+
return promise.catch((error) => {
6060
this.cache.delete(key);
61-
throw err;
61+
throw error;
6262
});
6363
}
6464

marimo/_cli/development/commands.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ def _generate_server_api_schema() -> dict[str, Any]:
209209
models.UpdateComponentValuesRequest,
210210
models.InvokeAiToolRequest,
211211
models.InvokeAiToolResponse,
212+
models.MCPStatusResponse,
213+
models.MCPRefreshResponse,
212214
requests.CodeCompletionRequest,
213215
requests.DeleteCellRequest,
214216
requests.HTTPRequest,

0 commit comments

Comments
 (0)