Skip to content

feat: initial Remote MCP-backed MCP server UI#2717

Open
bflad wants to merge 4 commits into
mainfrom
bflad/age-2118-initial-remote-mcp-backed-mcp-server-ui
Open

feat: initial Remote MCP-backed MCP server UI#2717
bflad wants to merge 4 commits into
mainfrom
bflad/age-2118-initial-remote-mcp-backed-mcp-server-ui

Conversation

@bflad
Copy link
Copy Markdown
Member

@bflad bflad commented May 11, 2026

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 the mcp_servers row itself plus its mcp_endpoints (optional Gram slug + zero-or-more custom-domain slugs). Gated on the gram-remote-mcp PostHog flag. Closes AGE-2118.

Commits (review in order)

  • feat(mcpservers): name and slug handling — Wires name (required on create, optional-with-non-empty on update) and an auto-generated slug (mirrors remotemcp/slug.go: name + 4-hex ID suffix, recomputed on every update) through mcpServers.create/update. Audit events for create/update/delete now record subject_display_name and subject_slug. The Remote MCP source auto-link hook passes formatRemoteMcpDisplay(remoteMcpServer) as the new mcp_servers.name so the auto-linked row always has a meaningful display name.
  • feat(mcpendpoints): checkSlugAvailability RPC — New mcpEndpoints.checkSlugAvailability RPC. 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 via IS NOT DISTINCT FROM. Returns true when the slug is free (note the inverted semantics vs the legacy toolsets.checkMCPSlugAvailability, which returns true when taken — the dashboard normalises both).
  • feat(mcpservers): get by slug — Extends mcpServers.get to accept either id (UUID) or slug, mirroring remoteMcp.getServer. Lets the new dashboard route use the human-readable slug.
  • feat(dashboard): MCP server management UI — The main feature commit. New /mcp/x/:mcpServerSlug route 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 both toolsets.checkMCPSlugAvailability and the new mcpEndpoints.checkSlugAvailability, plus a Danger Zone delete that lists the endpoints to be soft-deleted). New useCustomDomains() shim and useMcpEndpointUrl() helper in useToolsetUrl.ts. Listing on /mcp renders Remote-MCP-backed mcp_servers inline 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 — Parametrizes MCPTeamAccessTab from { toolset: Toolset } to { resourceId: string; tools?: Tool[] } so both toolset-backed and mcp_servers-backed servers reuse the same component. Both kinds grant under the same mcp:* scope family and the same "mcp" resource kind today (per server/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 on gram-rbac like the Hosted page.

Deliberately out of scope

  • Publishing (collections attach) — organization_mcp_collection_server_attachments is keyed off toolset_id NOT NULL; cannot apply to mcp_servers-backed servers without a backend change. Tracked in AGE-2238.
  • Install-page branding (logo, instructions, install override URL) — mcp_metadata is keyed off toolset_id NOT NULL. The Overview tab ships the resolved install URLs (copy buttons) but no Edit Branding affordance. Tracked in AGE-2239.
  • Custom-domain soft-delete cascade to mcp_endpoints — pre-existing gap that AGE-2118 surfaced; tracked in AGE-2228 and treated as a Remote MCP rollout requirement.
  • Multi-domain support per organizationuseCustomDomains() 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 in MCP.tsx, the new card / table-row data shape, and the existing single-slug + single-customDomainId inputs in MCPDetails.tsx.

Notes

  • All routing for the new details page lives under the experimental x/ namespace (/mcp/x/:mcpServerSlug) to match the existing /x/mcp/{slug} runtime path that already serves these servers.
  • The gram-remote-mcp flag gates both the listing fetch and the details page; visiting the new route with the flag off redirects back to /mcp.
  • Slug availability checks consult both toolsets.checkMCPSlugAvailability and the new mcpEndpoints.checkSlugAvailability because toolset-backed and mcp_servers-backed runtimes coexist today (the AGE-1902 cutover will consolidate them).

@bflad bflad requested review from a team as code owners May 11, 2026 13:35
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 11, 2026

AGE-2118

@vercel
Copy link
Copy Markdown

vercel Bot commented May 11, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
gram-docs-redirect Ready Ready Preview, Comment May 19, 2026 7:18pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 11, 2026

🦋 Changeset detected

Latest commit: f6fbb03

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
dashboard Minor

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

@github-actions github-actions Bot added the preview Spawn a preview environment label May 11, 2026
@speakeasybot
Copy link
Copy Markdown
Collaborator

speakeasybot commented May 11, 2026

🚀 Preview Environment (PR #2717)

Preview URL: https://pr-2717.dev.getgram.ai

Component Status Details Updated (UTC)
✅ Database Ready Existing database reused 2026-05-20 10:57:58.
✅ Images Available Container images ready 2026-05-20 10:57:40.

Gram Preview Bot

@blacksmith-sh

This comment has been minimized.

@adaam2
Copy link
Copy Markdown
Member

adaam2 commented May 12, 2026

@bflad shall we go through this tomorrow and chat it over?

@simplesagar simplesagar requested a review from adaam2 May 12, 2026 15:03
@bflad bflad force-pushed the bflad/age-2118-initial-remote-mcp-backed-mcp-server-ui branch 2 times, most recently from 7e14d26 to 7ca9baa Compare May 13, 2026 19:36
@bflad bflad force-pushed the bflad/age-2118-initial-remote-mcp-backed-mcp-server-ui branch from 7ca9baa to c6de763 Compare May 14, 2026 15:59
@bflad bflad force-pushed the bflad/age-2118-initial-remote-mcp-backed-mcp-server-ui branch from c6de763 to 465d083 Compare May 14, 2026 16:20
Copy link
Copy Markdown
Contributor

@qstearns qstearns left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well considered to reach for the sdkClient here instead of the hooks, but couple things that might be worth considering:

  1. 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;
  1. 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({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +36 to +64
<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>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
<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>

Comment on lines +1344 to +1360
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);
}
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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 } });

bflad added 4 commits May 19, 2026 15:11
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).
@bflad bflad force-pushed the bflad/age-2118-initial-remote-mcp-backed-mcp-server-ui branch from 50289a1 to f6fbb03 Compare May 19, 2026 19:18
@bflad
Copy link
Copy Markdown
Member Author

bflad commented May 19, 2026

@qstearns working through your feedback

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

preview Spawn a preview environment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants