feat: initial Remote MCP-backed MCP server UI#2717
Conversation
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.
Tip: disable this comment in your organization's Code Review settings.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: f6fbb03 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
🚀 Preview Environment (PR #2717)Preview URL: https://pr-2717.dev.getgram.ai
Gram Preview Bot |
4aa8d18 to
bbce01e
Compare
a2449f2 to
9ebc713
Compare
This comment has been minimized.
This comment has been minimized.
20a4992 to
54f5f17
Compare
|
@bflad shall we go through this tomorrow and chat it over? |
7e14d26 to
7ca9baa
Compare
7ca9baa to
c6de763
Compare
c6de763 to
465d083
Compare
465d083 to
50289a1
Compare
qstearns
left a comment
There was a problem hiding this comment.
This looks like good stuff to me. Feel free to disregard suggestions if there is pain to implementing. I'm happy to do some of these restructurings to return the favor for all the restructurings you're doing for me in the land of go
| currentSlug?: string, | ||
| ): string | null { | ||
| const [error, setError] = useState<string | null>(null); | ||
| const client = useSdkClient(); |
There was a problem hiding this comment.
Well considered to reach for the sdkClient here instead of the hooks, but couple things that might be worth considering:
- use react query to manage asynchronous state like. I haven't done the analysis to see if this solves any lifecycle bugs, but it does make adding additional error handling in the future a lot more tractable:
const formatError = draftSlug === currentSlug ? null : validateSlugFormat(draftSlug);
const shouldCheck =
formatError === null &&
debouncedSlug !== "" &&
debouncedSlug !== currentSlug &&
debouncedSlug === draftSlug;
const { data: available } = useQuery({
queryKey: ["mcpEndpointSlugAvailability", debouncedSlug, customDomainId],
enabled: shouldCheck,
// See comment block at top of file: we hit both RPCs while the toolset and
// mcp_endpoints uniqueness namespaces are still distinct.
queryFn: async () => {
const [toolsetTaken, endpointAvailable] = await Promise.all([
client.toolsets.checkMCPSlugAvailability({ slug: debouncedSlug }),
client.mcpEndpoints.checkSlugAvailability({
slug: debouncedSlug,
customDomainId: customDomainId ?? undefined,
}),
]);
return !toolsetTaken && endpointAvailable;
},
});
if (formatError) return formatError;
if (shouldCheck && available === false) return "This slug is already taken";
return null;- Make the debouncing an input concern rather than a network throttling concern (ie. the state that governs the query firing should itself be debounced). Like:
const DebounceMS = 250;
// ...
const [debouncedSlug, setDebouncedSlug] = useState(draftSlug);
useEffect(() => {
const timer = setTimeout(() => setDebouncedSlug(draftSlug), DebounceMS);
return () => clearTimeout(timer);
}, [draftSlug]);Putting it together, you get something like:
const DebounceMS = 250;
export function useMcpEndpointSlugValidation(
draftSlug: string,
customDomainId: string | null,
currentSlug?: string,
): string | null {
const client = useSdkClient();
const [debouncedSlug, setDebouncedSlug] = useState(draftSlug);
useEffect(() => {
const timer = setTimeout(() => setDebouncedSlug(draftSlug), DebounceMS);
return () => clearTimeout(timer);
}, [draftSlug]);
const formatError = draftSlug === currentSlug ? null : validateSlugFormat(draftSlug);
const shouldCheck =
formatError === null &&
debouncedSlug !== "" &&
debouncedSlug !== currentSlug &&
debouncedSlug === draftSlug;
const { data: available } = useQuery({
queryKey: ["mcpEndpointSlugAvailability", debouncedSlug, customDomainId],
enabled: shouldCheck,
// See comment block at top of file: we hit both RPCs while the toolset and
// mcp_endpoints uniqueness namespaces are still distinct.
queryFn: async () => {
const [toolsetTaken, endpointAvailable] = await Promise.all([
client.toolsets.checkMCPSlugAvailability({ slug: debouncedSlug }),
client.mcpEndpoints.checkSlugAvailability({
slug: debouncedSlug,
customDomainId: customDomainId ?? undefined,
}),
]);
return !toolsetTaken && endpointAvailable;
},
});
if (formatError) return formatError;
if (shouldCheck && available === false) return "This slug is already taken";
return null;
}| ); | ||
| } | ||
|
|
||
| function EndpointsTab({ |
There was a problem hiding this comment.
I'd consider putting each of these tabs in their own components. We don't have a strong convention around this in this code base, but even a tabs directory here with a file for each tab, might help to suggest that these should be individually routable elements.
| // returning a list, callsites that still assume `domains[0]` semantics | ||
| // need an audit pass. | ||
| console.warn( | ||
| "useCustomDomains: useGetDomain returned multiple domains; audit callers assuming single-domain semantics (AGE-2229).", |
There was a problem hiding this comment.
I would send this to console.error such that it gets captured as a RUM event and not gate on dev. If this is assumption is violated it seems worth capturing in our telemetry stack
| <DotCard | ||
| className="cursor-pointer" | ||
| onClick={handleClick} | ||
| icon={<Network className="text-muted-foreground h-8 w-8" />} | ||
| > | ||
| {/* Header row with name */} | ||
| <div className="mb-2 flex items-start justify-between gap-2"> | ||
| <Type | ||
| variant="subheading" | ||
| as="div" | ||
| className="text-md group-hover:text-primary flex-1 truncate transition-colors" | ||
| title={server.name ?? undefined} | ||
| > | ||
| {server.name || "MCP Server"} | ||
| </Type> | ||
| <Badge variant="outline"> | ||
| {endpointCount} {endpointCount === 1 ? "endpoint" : "endpoints"} | ||
| </Badge> | ||
| </div> | ||
|
|
||
| {/* Footer row with status indicator and open link */} | ||
| <div className="mt-auto flex items-center justify-between gap-2 pt-2"> | ||
| <MCPStatusIndicator mcpEnabled={mcpEnabled} mcpIsPublic={mcpIsPublic} /> | ||
| <div className="text-muted-foreground group-hover:text-primary flex items-center gap-1 text-sm transition-colors"> | ||
| <span>Open</span> | ||
| <ArrowRight className="h-3.5 w-3.5" /> | ||
| </div> | ||
| </div> | ||
| </DotCard> |
There was a problem hiding this comment.
I'd consider wapping this with a Link instead of using the onClick handler (not necessarily peak elegance but avoids really messing with the underlying machinery). It should get this to behave as a proper browser link (right contextual menus and stuff).
You'll need to import { Link } from "react-router"; above
| <DotCard | |
| className="cursor-pointer" | |
| onClick={handleClick} | |
| icon={<Network className="text-muted-foreground h-8 w-8" />} | |
| > | |
| {/* Header row with name */} | |
| <div className="mb-2 flex items-start justify-between gap-2"> | |
| <Type | |
| variant="subheading" | |
| as="div" | |
| className="text-md group-hover:text-primary flex-1 truncate transition-colors" | |
| title={server.name ?? undefined} | |
| > | |
| {server.name || "MCP Server"} | |
| </Type> | |
| <Badge variant="outline"> | |
| {endpointCount} {endpointCount === 1 ? "endpoint" : "endpoints"} | |
| </Badge> | |
| </div> | |
| {/* Footer row with status indicator and open link */} | |
| <div className="mt-auto flex items-center justify-between gap-2 pt-2"> | |
| <MCPStatusIndicator mcpEnabled={mcpEnabled} mcpIsPublic={mcpIsPublic} /> | |
| <div className="text-muted-foreground group-hover:text-primary flex items-center gap-1 text-sm transition-colors"> | |
| <span>Open</span> | |
| <ArrowRight className="h-3.5 w-3.5" /> | |
| </div> | |
| </div> | |
| </DotCard> | |
| <Link to={routes.mcp.x.href(mcpServerRouteParam(server))} className="block no-underline focus-visible:rounded-xl"> | |
| <DotCard icon={<Network ... />}> | |
| <div className="mb-2 flex items-start justify-between gap-2"> | |
| <Type | |
| variant="subheading" | |
| as="div" | |
| className="text-md group-hover:text-primary flex-1 truncate transition-colors" | |
| title={server.name ?? undefined} | |
| > | |
| {server.name || "MCP Server"} | |
| </Type> | |
| <Badge variant="outline"> | |
| {endpointCount} {endpointCount === 1 ? "endpoint" : "endpoints"} | |
| </Badge> | |
| </div> | |
| {/* Footer row with status indicator and open link */} | |
| <div className="mt-auto flex items-center justify-between gap-2 pt-2"> | |
| <MCPStatusIndicator mcpEnabled={mcpEnabled} mcpIsPublic={mcpIsPublic} /> | |
| <div className="text-muted-foreground group-hover:text-primary flex items-center gap-1 text-sm transition-colors"> | |
| <span>Open</span> | |
| <ArrowRight className="h-3.5 w-3.5" /> | |
| </div> | |
| </div> | |
| </DotCard> | |
| </DotCard> | |
| </Link> | |
| const remove = useDeleteMcpServerMutation(); | ||
|
|
||
| const handleConfirm = async () => { | ||
| try { | ||
| await remove.mutateAsync({ request: { id: mcpServer.id } }); | ||
| await Promise.all([ | ||
| invalidateAllMcpServers(queryClient, { refetchType: "all" }), | ||
| invalidateAllMcpEndpoints(queryClient, { refetchType: "all" }), | ||
| ]); | ||
| toast.success("MCP server deleted"); | ||
| onSuccess(); | ||
| } catch (error) { | ||
| const message = | ||
| error instanceof Error ? error.message : "Failed to delete MCP server"; | ||
| toast.error(message); | ||
| } | ||
| }; |
There was a problem hiding this comment.
This crops up in a few place sin this file, but we can use the Speakeasy Generated SDK ™️ to express this directly in tanstack query vernacular like so:
| const remove = useDeleteMcpServerMutation(); | |
| const handleConfirm = async () => { | |
| try { | |
| await remove.mutateAsync({ request: { id: mcpServer.id } }); | |
| await Promise.all([ | |
| invalidateAllMcpServers(queryClient, { refetchType: "all" }), | |
| invalidateAllMcpEndpoints(queryClient, { refetchType: "all" }), | |
| ]); | |
| toast.success("MCP server deleted"); | |
| onSuccess(); | |
| } catch (error) { | |
| const message = | |
| error instanceof Error ? error.message : "Failed to delete MCP server"; | |
| toast.error(message); | |
| } | |
| }; | |
| const remove = useDeleteMcpServerMutation({ | |
| onSuccess: async () => { | |
| await Promise.all([ | |
| invalidateAllMcpServers(queryClient, { refetchType: "all" }), | |
| invalidateAllMcpEndpoints(queryClient, { refetchType: "all" }), | |
| ]); | |
| toast.success("MCP server deleted"); | |
| onSuccess(); | |
| }, | |
| onError: (error) => | |
| toast.error(error instanceof Error ? error.message : "Failed to delete MCP server"), | |
| }); | |
| const handleConfirm = () => remove.mutate({ request: { id: mcpServer.id } }); |
https://linear.app/speakeasy/issue/AGE-2118/initial-remote-mcp-backed-mcp-server-ui Add a required `name` field on `mcpServers.create` and an optional `name` on `mcpServers.update`. Slug is auto-generated server-side from the trimmed name plus a 4-hex suffix from the row ID and is recomputed on every update so it tracks the current name. The auto-link flow in the Remote MCP source UI now passes `formatRemoteMcpDisplay(remoteMcpServer)` as the auto-linked mcp_server's display name.
https://linear.app/speakeasy/issue/AGE-2118/initial-remote-mcp-backed-mcp-server-ui Add `mcpEndpoints.checkSlugAvailability` so the dashboard can validate endpoint slug input against the actual `mcp_endpoints` uniqueness constraints. Platform-domain endpoints (`custom_domain_id IS NULL`) and custom-domain endpoints live in independent namespaces enforced by partial unique indexes; the new RPC mirrors that scoping by treating a NULL `custom_domain_id` as a valid match value via `IS NOT DISTINCT FROM`. Returns true when the slug is free. Gated on the existing `mcp:read` scope.
https://linear.app/speakeasy/issue/AGE-2118/initial-remote-mcp-backed-mcp-server-ui Extend `mcpServers.get` to accept either `id` (UUID) or `slug`, with exactly-one-required validation. Mirrors `remoteMcp.getServer`. The dashboard's new Remote MCP details page resolves the `mcp_server_slug` route param into this lookup so URLs use the human-readable slug.
https://linear.app/speakeasy/issue/AGE-2118/initial-remote-mcp-backed-mcp-server-ui Dashboard surface for managing Remote-MCP-backed mcp_servers, gated by the `gram-remote-mcp` feature flag, plus matching cross-links from the Remote MCP Server source detail page. - `/mcp` listing renders mcp_servers (Remote-MCP-backed today) inline with the existing Hosted (toolset-backed) cards via a shared MCPServerCard. - New `/mcp/x/:mcpServerSlug` details page with Overview, Endpoints, Team Access, and Settings tabs. The hero uses the shared DetailHero (dotted background) and exposes a visibility status dropdown in the upper-right modeled on the MCPDetails one. - Endpoints tab combines endpoint management (single optional Gram-hosted slug plus zero or more custom-domain endpoints, with live slug-validation against `toolsets.checkMCPSlugAvailability` and the new `mcpEndpoints.checkSlugAvailability`) with resolved install URLs for sharing. - Settings tab keeps name editing in the shared Block label style and a Danger Zone delete confirmation that lists the endpoints that will be soft-deleted. - Remote MCP Server source detail page gains a MCP Servers tab that reuses MCPServerCard, plus a Sources section linking each MCP server back to its backing source. - `useCustomDomains()` shim and `useMcpEndpointUrl()` helper resolve per-endpoint URLs under the `/x/mcp/<slug>` runtime path. The shim returns a single-element array today, ready for the multi-domain swap tracked under AGE-2227/AGE-2229. - Publishing and install-page branding affordances are deliberately omitted because the underlying `organization_mcp_collection_server_attachments` and `mcp_metadata` tables only key off `toolset_id`. Followups: AGE-2238 (collections) and AGE-2239 (install-page metadata).
50289a1 to
f6fbb03
Compare
|
@qstearns working through your feedback |
Summary
Initial dashboard UI for managing MCP Servers backed by Remote MCP Servers under
/mcp, alongside the existing toolset-backed (Hosted) MCP Server cards. CRUD for themcp_serversrow itself plus itsmcp_endpoints(optional Gram slug + zero-or-more custom-domain slugs). Gated on thegram-remote-mcpPostHog flag. Closes AGE-2118.Commits (review in order)
feat(mcpservers): name and slug handling— Wiresname(required on create, optional-with-non-empty on update) and an auto-generatedslug(mirrorsremotemcp/slug.go: name + 4-hex ID suffix, recomputed on every update) throughmcpServers.create/update. Audit events for create/update/delete now recordsubject_display_nameandsubject_slug. The Remote MCP source auto-link hook passesformatRemoteMcpDisplay(remoteMcpServer)as the newmcp_servers.nameso the auto-linked row always has a meaningful display name.feat(mcpendpoints): checkSlugAvailability RPC— NewmcpEndpoints.checkSlugAvailabilityRPC. Platform-domain endpoints (custom_domain_id IS NULL) and custom-domain endpoints live in independent namespaces enforced by partial unique indexes; the new RPC mirrors that scoping viaIS NOT DISTINCT FROM. Returnstruewhen the slug is free (note the inverted semantics vs the legacytoolsets.checkMCPSlugAvailability, which returnstruewhen taken — the dashboard normalises both).feat(mcpservers): get by slug— ExtendsmcpServers.getto accept eitherid(UUID) orslug, mirroringremoteMcp.getServer. Lets the new dashboard route use the human-readable slug.feat(dashboard): MCP server management UI— The main feature commit. New/mcp/x/:mcpServerSlugroute serves a details page with Overview (resolved endpoint install URLs) and Settings (name, visibility, split Gram + custom-domain endpoint surfaces with live slug-availability validation against bothtoolsets.checkMCPSlugAvailabilityand the newmcpEndpoints.checkSlugAvailability, plus a Danger Zone delete that lists the endpoints to be soft-deleted). NewuseCustomDomains()shim anduseMcpEndpointUrl()helper inuseToolsetUrl.ts. Listing on/mcprenders Remote-MCP-backedmcp_serversinline with Hosted (toolset-backed) cards.chore(dashboard): AGE-1902 breadcrumb at MCPDetails slug + custom-domain inputs— Fourth and final AGE-1902 cutover breadcrumb (the others land in the dashboard feature commit at the listing-merge, new card data shape, and new route declaration).feat(dashboard): Team Access tab on MCP server details— ParametrizesMCPTeamAccessTabfrom{ toolset: Toolset }to{ resourceId: string; tools?: Tool[] }so both toolset-backed andmcp_servers-backed servers reuse the same component. Both kinds grant under the samemcp:*scope family and the same"mcp"resource kind today (perserver/internal/authz/selector.go). When the caller has no Gram-side tool catalog (Remote MCP backend), the per-tool drilldown sheet falls back to the raw tool identifiers from the grant selectors. Adds Team Access as the third tab, gated ongram-rbaclike the Hosted page.Deliberately out of scope
organization_mcp_collection_server_attachmentsis keyed offtoolset_id NOT NULL; cannot apply tomcp_servers-backed servers without a backend change. Tracked in AGE-2238.mcp_metadatais keyed offtoolset_id NOT NULL. The Overview tab ships the resolved install URLs (copy buttons) but no Edit Branding affordance. Tracked in AGE-2239.mcp_endpoints— pre-existing gap that AGE-2118 surfaced; tracked in AGE-2228 and treated as a Remote MCP rollout requirement.useCustomDomains()is shimmed as a single-element array today so the call-sites are ready; tracked in AGE-2227 (migration) and AGE-2229 (code).AGE-1902 cutover breadcrumbs
AGE-1902 cuts over Hosted (toolset-backed) MCP data to
mcp_servers/mcp_endpoints. TODO comments left at the four sites that will collapse with the cutover: the new sub-route declaration, the listing-merge inMCP.tsx, the new card / table-row data shape, and the existing single-slug + single-customDomainIdinputs inMCPDetails.tsx.Notes
x/namespace (/mcp/x/:mcpServerSlug) to match the existing/x/mcp/{slug}runtime path that already serves these servers.gram-remote-mcpflag gates both the listing fetch and the details page; visiting the new route with the flag off redirects back to/mcp.toolsets.checkMCPSlugAvailabilityand the newmcpEndpoints.checkSlugAvailabilitybecause toolset-backed andmcp_servers-backed runtimes coexist today (the AGE-1902 cutover will consolidate them).