diff --git a/.changeset/internal-mcp-server-portals.md b/.changeset/internal-mcp-server-portals.md new file mode 100644 index 0000000000..c1da993e41 --- /dev/null +++ b/.changeset/internal-mcp-server-portals.md @@ -0,0 +1,13 @@ +--- +"server": minor +"dashboard": minor +--- + +Adds **Internal MCP Server Portals** — a per-project, org-internal catalogue page reachable at `app.gram.dev/portal/{project-slug}` that lists every MCP server in the project as cards (server name, description, tool count, link to the existing per-server install page). Project admins toggle the portal on in project settings and brand it with a logo, display name, and tagline. + +Surface area: + +- New `project_portals` table; one row per project, `enabled = false` by default so no existing project starts serving a portal silently. +- New `portals` Goa service: `getPortal` (org-member read; returns the resolved config plus enriched server cards) and `updatePortal` (project-admin write; read-then-merge partial-update semantics — `nil` preserves, `""` clears, non-empty sets). Disabled portals return 404 to org members; project admins can preview via `?preview=true`. +- Dashboard route `/portal/:projectSlug` (lazy-loaded, auth-gated by `LoginCheck`, 404s uniformly on any failure) plus a new "Internal MCP Portal" section embedded in project settings. +- No new RBAC scopes — reuses `ScopeProjectRead` / `ScopeProjectWrite`. diff --git a/.speakeasy/out.openapi.yaml b/.speakeasy/out.openapi.yaml index b55a34dd3f..1e6bddaff7 100644 --- a/.speakeasy/out.openapi.yaml +++ b/.speakeasy/out.openapi.yaml @@ -14991,6 +14991,217 @@ paths: x-speakeasy-name-override: updatePluginServer x-speakeasy-react-hook: name: UpdatePluginServer + /rpc/portals.get: + get: + description: Get the portal configuration and server cards for a project. Returns 404 when the portal does not exist or is disabled, unless preview=true and the caller has project:write. + operationId: getPortal + parameters: + - allowEmptyValue: true + description: Bypass the disabled-portal 404 for project admins (for the in-settings preview). + in: query + name: preview + schema: + description: Bypass the disabled-portal 404 for project admins (for the in-settings preview). + type: boolean + - allowEmptyValue: true + description: Session header + in: header + name: Gram-Session + schema: + description: Session header + type: string + - allowEmptyValue: true + description: API Key header + in: header + name: Gram-Key + schema: + description: API Key header + type: string + - allowEmptyValue: true + description: project header + in: header + name: Gram-Project + schema: + description: project header + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/Portal' + description: OK response. + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'bad_request: request is invalid' + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'unauthorized: unauthorized access' + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'forbidden: permission denied' + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'not_found: resource not found' + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'conflict: resource already exists' + "415": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'unsupported_media: unsupported media type' + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'invalid: request contains one or more invalidation fields' + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'unexpected: an unexpected error occurred' + "502": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'gateway_error: an unexpected error occurred' + security: + - project_slug_header_Gram-Project: [] + session_header_Gram-Session: [] + - apikey_header_Gram-Key: [] + project_slug_header_Gram-Project: [] + - {} + summary: getPortal portals + tags: + - portals + x-speakeasy-name-override: read + x-speakeasy-react-hook: + name: Portal + /rpc/portals.update: + post: + description: Create or update the portal configuration for a project. + operationId: updatePortal + parameters: + - allowEmptyValue: true + description: Session header + in: header + name: Gram-Session + schema: + description: Session header + type: string + - allowEmptyValue: true + description: API Key header + in: header + name: Gram-Key + schema: + description: API Key header + type: string + - allowEmptyValue: true + description: project header + in: header + name: Gram-Project + schema: + description: project header + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePortalForm' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/Portal' + description: OK response. + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'bad_request: request is invalid' + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'unauthorized: unauthorized access' + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'forbidden: permission denied' + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'not_found: resource not found' + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'conflict: resource already exists' + "415": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'unsupported_media: unsupported media type' + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'invalid: request contains one or more invalidation fields' + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'unexpected: an unexpected error occurred' + "502": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: 'gateway_error: an unexpected error occurred' + security: + - project_slug_header_Gram-Project: [] + session_header_Gram-Session: [] + - apikey_header_Gram-Key: [] + project_slug_header_Gram-Project: [] + - {} + summary: updatePortal portals + tags: + - portals + x-speakeasy-name-override: update + x-speakeasy-react-hook: + name: UpdatePortal /rpc/productFeatures.get: get: description: Get the current state of all product feature flags. @@ -34735,6 +34946,59 @@ components: - policy - sort_order - created_at + Portal: + type: object + properties: + display_name: + type: string + description: Resolved display name (override → project.name). + enabled: + type: boolean + description: Whether the portal is enabled. + default: false + logo_url: + type: string + description: Resolved logo URL or empty when none. + project_slug: + type: string + description: The project's slug. + servers: + type: array + items: + $ref: '#/components/schemas/PortalServer' + description: Cards to render on the portal. + tagline: + type: string + description: Tagline if set. + required: + - enabled + - project_slug + - display_name + - servers + PortalServer: + type: object + properties: + description: + type: string + description: Server or toolset description. + install_url: + type: string + description: URL of the per-server install page. + name: + type: string + description: Server name. + slug: + type: string + description: Endpoint slug. + tool_count: + type: integer + description: Number of tools exposed by this server. + format: int64 + required: + - slug + - name + - tool_count + - install_url Project: type: object properties: @@ -38496,6 +38760,21 @@ components: - id - plugin_id - display_name + UpdatePortalForm: + type: object + properties: + display_name: + type: string + description: Override for the portal's display name. Empty string clears the override. + enabled: + type: boolean + description: Whether the portal is publicly reachable to org members. + logo_asset_id: + type: string + description: UUID of an asset to use as the logo. Empty string clears the override. + tagline: + type: string + description: Short tagline shown under the title. Empty string clears. UpdatePromptTemplateForm: type: object properties: @@ -39614,6 +39893,8 @@ tags: description: Manages packages in Gram. - name: plugins description: Manage distributable plugin bundles of MCP servers and hooks. + - name: portals + description: Manages per-project Internal MCP Server Portals. - name: features description: Manage product level feature controls. - name: projects diff --git a/.speakeasy/workflow.lock b/.speakeasy/workflow.lock index 588a128c56..4218f753d7 100644 --- a/.speakeasy/workflow.lock +++ b/.speakeasy/workflow.lock @@ -2,8 +2,8 @@ speakeasyVersion: 1.761.5 sources: Gram-Internal: sourceNamespace: gram-api-description - sourceRevisionDigest: sha256:95e38a071ee9ed804a8224b8502b13d918d7264a9ac7b48197e6bb4e13d0f4aa - sourceBlobDigest: sha256:ae6428c92b7b380de9fdf336952b52cb4151e06075cce326b9e49225f025b6bd + sourceRevisionDigest: sha256:cb0301f0b92302fda735db9883fbf7176c0d7ea85220336cc8d3f47a0a17830a + sourceBlobDigest: sha256:217d23a20e1746d28b79933a87a932ac94fef742cb9989f0abd09f248ae59362 tags: - latest - 0.0.1 @@ -11,10 +11,10 @@ targets: gram-internal: source: Gram-Internal sourceNamespace: gram-api-description - sourceRevisionDigest: sha256:95e38a071ee9ed804a8224b8502b13d918d7264a9ac7b48197e6bb4e13d0f4aa - sourceBlobDigest: sha256:ae6428c92b7b380de9fdf336952b52cb4151e06075cce326b9e49225f025b6bd + sourceRevisionDigest: sha256:cb0301f0b92302fda735db9883fbf7176c0d7ea85220336cc8d3f47a0a17830a + sourceBlobDigest: sha256:217d23a20e1746d28b79933a87a932ac94fef742cb9989f0abd09f248ae59362 codeSamplesNamespace: gram-api-description-typescript-code-samples - codeSamplesRevisionDigest: sha256:26ec6929fdf5f8190c52aba53bcbdd2e1eadb011093b43fd88d51f1ab4e0a338 + codeSamplesRevisionDigest: sha256:36035ba46a5920ea50d010c0091caaaf24384a3f83354f4498271178d693f376 workflow: workflowVersion: 1.0.0 speakeasyVersion: pinned diff --git a/client/dashboard/src/pages/portal/PortalCard.tsx b/client/dashboard/src/pages/portal/PortalCard.tsx new file mode 100644 index 0000000000..30f23a05aa --- /dev/null +++ b/client/dashboard/src/pages/portal/PortalCard.tsx @@ -0,0 +1,29 @@ +import { Type } from "@/components/ui/type"; +import { DotCard } from "@/components/ui/dot-card"; +import type { PortalServer } from "@gram/client/models/components"; + +interface Props { + server: PortalServer; +} + +export function PortalCard({ server }: Props) { + return ( + + +
+ + {server.name} + + {server.description ? ( + + {server.description} + + ) : null} + + {server.toolCount} {server.toolCount === 1 ? "tool" : "tools"} + +
+
+
+ ); +} diff --git a/client/dashboard/src/pages/portal/PortalHeader.tsx b/client/dashboard/src/pages/portal/PortalHeader.tsx new file mode 100644 index 0000000000..297e6161fc --- /dev/null +++ b/client/dashboard/src/pages/portal/PortalHeader.tsx @@ -0,0 +1,29 @@ +import { Heading } from "@/components/ui/heading"; +import { Type } from "@/components/ui/type"; +import type { Portal } from "@gram/client/models/components"; + +interface Props { + portal: Portal; +} + +export function PortalHeader({ portal }: Props) { + return ( +
+ {portal.logoUrl ? ( + + ) : null} +
+ {portal.displayName} + {portal.tagline ? ( + + {portal.tagline} + + ) : null} +
+
+ ); +} diff --git a/client/dashboard/src/pages/portal/PortalPage.tsx b/client/dashboard/src/pages/portal/PortalPage.tsx new file mode 100644 index 0000000000..4d0d2d5054 --- /dev/null +++ b/client/dashboard/src/pages/portal/PortalPage.tsx @@ -0,0 +1,114 @@ +import { Heading } from "@/components/ui/heading"; +import { Type } from "@/components/ui/type"; +import { useSession } from "@/contexts/Auth"; +import { usePortal } from "@gram/client/react-query/portal"; +import { Stack } from "@speakeasy-api/moonshine"; +import { Link, useParams, useSearchParams } from "react-router"; +import { PortalCard } from "./PortalCard"; +import { PortalHeader } from "./PortalHeader"; + +export function PortalPage() { + const { projectSlug = "" } = useParams(); + const [search] = useSearchParams(); + const preview = search.get("preview") === "1"; + + const { + data: portal, + error, + isLoading, + } = usePortal( + { gramProject: projectSlug, preview: preview || undefined }, + undefined, + { + enabled: !!projectSlug, + // A 404 here is expected (portal disabled, project not found, cross-org). + // The default QueryClient throws everything except 403 to the global + // error boundary; opt out so PortalPage's own "Portal not found" UI shows. + throwOnError: false, + }, + ); + + if (isLoading) { + return ; + } + + if (error || !portal) { + return ; + } + + return ( + + + {portal.servers.length === 0 ? ( + + ) : ( +
+ {portal.servers.map((s) => ( + + ))} +
+ )} + +
+ ); +} + +function PortalLoading() { + return ( +
+ Loading… +
+ ); +} + +function PortalNotFound() { + return ( +
+ Portal not found + + This portal does not exist or has not been published. + +
+ ); +} + +function PortalEmptyState({ projectSlug }: { projectSlug: string }) { + const session = useSession(); + const orgSlug = session.organization.slug; + const catalogHref = + orgSlug && projectSlug + ? `/${orgSlug}/projects/${projectSlug}/catalog` + : undefined; + + return ( +
+ + No MCP servers in this project yet.{" "} + {catalogHref ? ( + <> + Add servers from the{" "} + + catalog + {" "} + to populate the portal. + + ) : ( + "Add servers from the catalog to populate the portal." + )} + +
+ ); +} + +function PortalFooter() { + return ( +
+ + Powered by Gram + +
+ ); +} diff --git a/client/dashboard/src/pages/portal/PortalPreview.tsx b/client/dashboard/src/pages/portal/PortalPreview.tsx new file mode 100644 index 0000000000..3c7b2f07c0 --- /dev/null +++ b/client/dashboard/src/pages/portal/PortalPreview.tsx @@ -0,0 +1,16 @@ +import { memo } from "react"; + +interface Props { + orgSlug: string; + projectSlug: string; + className?: string; +} + +export const PortalPreview = memo(function PortalPreview({ + orgSlug, + projectSlug, + className, +}: Props) { + const src = `/${orgSlug}/projects/${projectSlug}/portal?preview=1`; + return