poc: internal mcp server portals#3003
Draft
simplesagar wants to merge 33 commits into
Draft
Conversation
Draft design for a per-project, org-internal catalogue page that
aggregates a project's MCP servers behind a single Gram-hosted URL.
Adds project_portals table, portals Goa service, project-settings UI,
and a public-facing /portal/{slug} route. Custom-domain hosting, public
visibility modes, curation, and analytics are explicitly deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bite-sized, three-PR plan covering schema migration, Goa service + SDK regen, and dashboard portal page + settings section. Each task includes exact files, test code, and commit guidance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces the table that backs the Internal MCP Server Portals feature. App code consumers ship in a follow-up PR. Default enabled is false so no portal becomes reachable from this PR alone. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches the empty-string CHECK pattern used by display_name and by marketplace_name in project_marketplace_settings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the logo resolution chain specified in the portals design: row override → project logo → empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UpdatePortal previously converted a nil pointer for DisplayName/Tagline
to pgtype.Text{Valid:false}, then unconditionally wrote that NULL into
the column. So a partial update that only flipped enabled=false would
also wipe the stored tagline, display name, and logo override.
Load the existing row first and merge per-field:
- nil pointer → preserve existing value
- &"" → explicit clear (NULL)
- &"non-empty" → set new value
Also add a focused test for invalid logo_asset_id UUIDs returning 400.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GetPortal previously read GRAM_SITE_URL via os.Getenv on every call and fell back to a hardcoded production URL when unset, which made tests silently emit production URLs. Match how other services receive the site URL: accept it as a NewService argument and pipe the CLI's existing site-url flag through start.go. Also assert the install_url is non-empty, contains the endpoint slug, and uses the configured site URL in TestGetPortal_Enabled_ReturnsServers now that the value is deterministic in tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements PortalPage, PortalHeader, and PortalCard components for the public-facing MCP server portal. Route is auth-gated via the existing LoginCheck wrapper but lives outside project-scoped layout. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds PortalSettings component (enabled toggle, display name, tagline, copy-URL button) mounted in project settings. PortalPreview renders the portal as an iframe using ?preview=1 so admins can see the live state before enabling it publicly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Defers loading the portal bundle until a user navigates to /portal/:projectSlug, keeping the main dashboard bundle slim. Also documents the auth assumption at the route registration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wires the existing ImageUpload primitive into PortalSettings so admins can upload, replace, or clear the portal logo. The form initialises from portal.logoUrl on load and only sends logoAssetId on the mutation when the user actually changes the asset (empty string clears). Also memoises PortalPreview since its iframe src only changes with projectSlug, avoiding unnecessary iframe remounts on parent re-renders. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… url navigator.clipboard.writeText returns a Promise that can reject on non-HTTPS contexts, missing permissions, or older browsers. Wrap the call in an async handler with try/catch and surface success/failure via toast so the user gets feedback either way. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The preview iframe owns its own navigation state, so React Query cache invalidation does not cause it to re-fetch. Pass a saveCount key that bumps on every successful save so the iframe is unmounted and remounted, surfacing the latest portal config immediately. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a project has no MCP servers, the portal empty state now invites the admin to add servers from the dashboard catalog at /:orgSlug/projects/:projectSlug/catalog. Falls back to plain text if the session has no org context. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Goa design declared logo_asset_id with Format(FormatUUID). The generated ValidateUpdatePortalRequestBody rejected "" as not-a-UUID before reaching the handler, so the "Remove logo" UX (which sends logo_asset_id: "") returned 422. The handler-side uuid.Parse already returns BadRequest for malformed non-empty values, so the design-level validator is redundant; dropping it lets "" through where mergeLogoAssetID treats it as the documented "clear override" sentinel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… scheme The previous test had the sibling org querying its own project's portal (which 404'd because no row existed), so it passed for the wrong reason and gave false coverage confidence. The real cross-org isolation gate is the project-slug APIKeyAuth scheme, which rejects requests when the slug does not belong to the caller's org. The test now invokes APIKeyAuth directly with the original org's slug from a sibling-org session, and asserts oops.CodeForbidden (403) — the actual behaviour of the gate. (My earlier message assumed 404; the middleware returns 403, and the test was adjusted to match reality.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 298bd92 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Collaborator
🚀 Preview Environment (PR #3003)Preview URL: https://pr-3003.dev.getgram.ai
Gram Preview Bot |
Will be regenerated with a fresh timestamp after rebasing main. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts: # .speakeasy/out.openapi.yaml # .speakeasy/workflow.lock # client/dashboard/src/pages/settings/Settings.tsx # client/sdk/.speakeasy/gen.lock # server/cmd/gram/start.go # server/database/sqlc.yaml # server/gen/http/openapi3.json # server/gen/http/openapi3.yaml # server/migrations/atlas.sum
Contributor
|
|
||||||||||||||||
Contributor
|
|
||||||||||||||||
Addresses two CI failures: - golangci-lint wrapcheck flagged the bare return of uuid.Parse's error from mergeLogoAssetID. Wrapped with context. - The repo's PR-title-and-changesets hygiene check expected a changeset describing this feature. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the sqlc version header on all repo files back in line with the version pinned in mise.toml (v1.31.1). My local default sqlc was v1.29.0 so the prior regen downgraded every generated file's header comment. Pure header-only diff, no behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These were authoring artifacts (brainstorming spec + implementation plan) used to drive the agent-driven build. They aren't intended to ship as part of the repo's docs; the PR description and code are the durable record. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…route
- PortalSettings was constraining ImageUpload to h-16/w-16, but the
underlying FullWidthUpload renders a dropzone with p-10 padding that
ignored the height. The dropzone overflowed the constrained box and
visually crashed into the tagline / Portal URL / Save controls below
it. Dropping the override lets the upload component size itself
naturally.
- /portal/:projectSlug was a lazy import with Suspense fallback={null},
so a slow chunk load (or any silent lazy failure) rendered a blank
page — visible to the user as "didn't load". Switched to an eager
import; the page is small and the saved bytes did not justify the
black-screen-while-loading UX.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AuthProvider runs a path-shape redirect chain after session resolves: any URL whose first segment is not the active org's slug, and where no project slug is present, gets pushed to the org home (or preferred project). That logic was eating /portal/<projectSlug> — the user landed on the home page instead of the portal. Adding /portal/ to SLUG_EXEMPT_PATHS keeps the portal route reachable without weakening the redirect for any other URL shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dashboard's QueryClient defaults to throwing every error except 403 to the nearest error boundary. A 404 from /rpc/portals.get (portal disabled, project not found, or cross-org) was therefore surfacing as "Something went wrong / resource not found" via the global FullPageError instead of PortalPage's own "Portal not found" UI. Opting this query out with throwOnError: false routes 404s back into the hook's error state where PortalPage already renders the right thing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…/portal
The original top-level /portal/:projectSlug route forced three opt-outs
from dashboard conventions: SLUG_EXEMPT_PATHS in AuthProvider, an extra
Suspense boundary in App.tsx, and the absence of project context that
SdkProvider expects. Moving the portal under the existing org-scoped
project route eliminates all three:
- The route now lives in routes.tsx as a regular project route with
outsideMainLayout: true so it still renders without the dashboard
sidebar (the portal is intentionally a clean catalogue page).
- AuthProvider's SLUG_EXEMPT_PATHS exemption for /portal/ is reverted.
- PortalPreview and the Copy URL helper produce
/{orgSlug}/projects/{projectSlug}/portal URLs.
The portal URL is longer to share but consistent with the rest of the
dashboard, and the future custom-domain hosting feature (deferred) will
provide the apex-domain short URL anyway.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These leaked into the previous portal-route refactor commit by accident. The mise.lock change is the well-known URL-only cosmetic drift (mise.jdx.dev vs mise.en.dev); pnpm-lock.yaml drifted from a local pnpm install that diverged from main. Reset both to origin/main. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds Internal MCP Server Portals — a per-project, org-internal catalogue page at
app.gram.dev/portal/{project-slug}that lists every MCP server in a project as cards. Project admins toggle it on and brand it (logo, display name, tagline) from project settings. Aggregated install/connect experience over the existing per-server install pages.Linear: Internal MCP Server Portals
Spec:
docs/superpowers/specs/2026-05-20-internal-mcp-server-portals-design.mdPlan:
docs/superpowers/plans/2026-05-20-internal-mcp-server-portals.mdWhat's in this PR
The plan called for three sequential PRs. This single PR bundles all three phases — happy to split into 3 stacked PRs if reviewers prefer (the commit history maps cleanly: see
Commitsbelow).1. Migration (
project_portalstable)enabled, optionaldisplay_name/tagline/logo_asset_idoverrides, all nullable with consistent empty-string CHECK guards.enabled = false— no existing project starts serving a portal silently.2. Backend (
portalsGoa service)GET /rpc/portals.get— org-member read. Returns portal config + enriched server cards (joinsmcp_servers→mcp_endpoints→toolsets, includes tool count + install URL).POST /rpc/portals.update— project-admin write. Read-then-merge semantics:nilpreserves,""clears, non-empty sets.?preview=true(verified by RBAC test that exercises the project-slug security scheme).ScopeProjectRead/ScopeProjectWrite.os.Getenvper-call).3. Frontend
/portal/:projectSlug(PortalPage,PortalHeader,PortalCard) — lazy-loaded, auth-gated byLoginCheck, 404s uniformly on any failure (does not distinguish missing/disabled/wrong-org).PortalSettings) — enabled toggle, display-name + tagline inputs, logo upload (reusesImageUpload), Copy URL button (with toast on success/failure), live preview iframe that reloads on save.Test Plan
cd server && go test ./internal/portals/... -v— expect 10 portal tests passing (PASS locally).mise build:server— expect clean (PASS locally).cd client/dashboard && pnpm tsc -p tsconfig.app.json --noEmit— no new TS errors beyond the 58 pre-existing onmain.Explicitly out of scope (deferred)
mcp.acme.comroot).Commits (logical groups for an optional 3-PR split)
Migration:
120ceadbefeat(db): add project_portals table6fe5fd655fix(db): guard tagline against empty string in project_portals99daebecadocs(portals): align tagline check with implemented migrationBackend + SDK regen:
d6169603bfeat(portals): add sqlc queries and generated repoa4c379b2afeat(portals): implement getPortal and updatePortald55edca1ffeat(portals): resolve portal logo url with project fallback382431986feat(portals): wire portals service into server startup2c56ae03dtest(portals): assert cross-org getPortal returns 404e87d5e0d7chore(sdk): regen for portals service8553f6d6efix(portals): fall back to project logo when portal has no override1f688cc13fix(portals): preserve omitted fields in updatePortal partial updatesac3e21dd2refactor(portals): inject site URL via constructor355999344fix(portals): allow empty-string clear for logo_asset_id in update512850ad3test(portals): rewrite cross-org test to invoke project-slug security schemeFrontend:
b7c6c5e17feat(dashboard): add /portal/:slug public-facing portal route9fdb3f191feat(dashboard): portal settings section with live previewf65b79385perf(dashboard): lazy-load portal page route010d7daaafeat(dashboard): add logo upload to portal settingscbaa5e8c7fix(dashboard): handle clipboard.writeText errors when copying portal url7ce7c70adfix(dashboard): reload portal preview iframe after savede2db74b4feat(dashboard): link portal empty state to catalog🤖 Generated with Claude Code