From 571ed5a0189adaa72b9452e0eb22b331974768db Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Wed, 20 May 2026 16:50:17 -0700 Subject: [PATCH 01/32] docs: spec for Internal MCP Server Portals 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) --- ...5-20-internal-mcp-server-portals-design.md | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-20-internal-mcp-server-portals-design.md diff --git a/docs/superpowers/specs/2026-05-20-internal-mcp-server-portals-design.md b/docs/superpowers/specs/2026-05-20-internal-mcp-server-portals-design.md new file mode 100644 index 0000000000..ed5c85eaba --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-internal-mcp-server-portals-design.md @@ -0,0 +1,201 @@ +# Internal MCP Server Portals — Design + +Date: 2026-05-20 +Linear: [Internal MCP Server Portals](https://linear.app/speakeasy/project/internal-mcp-server-portals-400b2f3f4499/overview) +Status: Draft (awaiting review) + +## Problem + +Customers want a single, internal-facing landing page per project that lists every MCP server the project hosts, with each server's tools and install/connection options accessible from one place. Today, the install/connection experience exists _per_ MCP server (`MCPHostedPage`), but there is no aggregated "cover page" that bundles them. Linear's project brief proposes hosting this at the root of a custom domain (e.g. `mcp.acme.com`); the v1 of this design ships without the custom-domain requirement and serves the portal at a Gram-hosted path. + +## Goals + +- One shareable, org-internal URL per project that lists all of the project's MCP servers as cards. +- Each card surfaces enough information (name, description, tool count) to identify the server and a "View install" affordance that links to the existing per-server install page. +- Design-partner-quality first impression: project branding (logo, display name, tagline) is configurable from project settings. +- Reuse existing primitives wherever possible: `mcp_servers`, `mcp_endpoints`, `toolsets`, `assets`, the existing session/RBAC machinery. + +## Non-goals (deferred) + +- Custom domain attachment so the portal serves at the apex of a customer-owned domain (`mcp.acme.com`). Tracked as a follow-up. +- Public / unlisted visibility modes (anyone-with-link). v1 is org-internal, auth-required only. +- Per-server "publish to portal" toggle, curation, ordering, sectioning. v1 lists _all_ non-deleted MCP servers in the project. +- View analytics (card clicks, page views). +- Audit log entry for portal edits. +- Theme color or layout customization beyond logo + display name + tagline. +- Programmatic discovery (`.well-known/mcp-portal.json`). + +## Decisions (locked in brainstorming) + +| Decision | Choice | Rationale | +| ----------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Audience | Org-internal, auth required | Closest fit to "internal-facing catalogue"; reuses session/IDP. | +| Membership | All MCP servers in the project, automatically | No publish workflow needed for v1; portal is a filtered view of `mcp_servers`. | +| URL | Gram-hosted at `app.gram.dev/portal/{project-slug}` | No custom domain dependency. Custom-domain mode deferred. | +| Branding | Logo, display name, tagline included in v1 | Design-partner quality requires non-generic first impression. | +| Storage | New `project_portals` table | Dedicated home; avoids muddying `project_marketplace_settings` (public marketplace concept). | +| Admin UI | Inside existing **project settings** page (no dedicated nav entry) | The portal is a project property, not its own workspace area. | +| Disabled state | Hard 404 for org members; project admins can preview via `?preview=1` from settings | Simplest external behaviour while keeping the in-settings preview iframe functional before launch. | +| Default `enabled` | `false` for all projects (no backfill) | Opt-in; existing projects do not start serving a portal silently. | +| Card description source | `mcp_servers` description if present, else fall back to `toolsets.description` | No new column required in v1. | + +## Data model + +One new table: + +```sql +CREATE TABLE IF NOT EXISTS project_portals ( + id uuid NOT NULL DEFAULT generate_uuidv7(), + project_id uuid NOT NULL, + enabled boolean NOT NULL DEFAULT false, + display_name TEXT CHECK (display_name IS NULL OR (display_name <> '' AND CHAR_LENGTH(display_name) <= 64)), + tagline TEXT CHECK (tagline IS NULL OR CHAR_LENGTH(tagline) <= 200), + logo_asset_id uuid, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + + CONSTRAINT project_portals_pkey PRIMARY KEY (id), + CONSTRAINT project_portals_project_id_key UNIQUE (project_id), + CONSTRAINT project_portals_project_id_fkey FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + CONSTRAINT project_portals_logo_asset_id_fkey FOREIGN KEY (logo_asset_id) REFERENCES assets (id) ON DELETE SET NULL +); +``` + +Semantics: + +- A row is **created lazily** the first time an admin opens portal settings or calls `updatePortal` — projects without a row are treated as portal-disabled. +- NULL `display_name` / `logo_asset_id` fall back to `projects.name` / `projects.logo_asset_id` at read time. NULL `tagline` renders no tagline. +- `enabled = false` ⇒ the public portal route returns 404; project-settings UI can still read/write the row. + +Generated migration goes in `server/migrations/_create_project_portals.sql` via `mise db:diff create_project_portals`. + +## Backend + +### Goa service + +New service at `server/design/portals/design.go` named `portals`, served under `/rpc/portals.*`: + +| Method | URL | Auth | Result | +| -------------- | ------------------------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `getPortal` | `GET /rpc/portals.get?project_slug=…` | Session, org member of the project's org | Portal config (resolved with fallbacks) **plus** the rendered card list. | +| `updatePortal` | `POST /rpc/portals.update` | Session, project admin scope | Upserts the portal row; returns the updated config. Fields: `enabled`, `display_name`, `tagline`, `logo_asset_id`. All optional. | + +`getPortal` returns: + +``` +{ + portal: { + enabled: bool, + display_name: string, // resolved (overlay falls back to project.name) + tagline: string | null, + logo_url: string | null, // resolved asset URL + project_slug: string, + }, + servers: [ + { + slug: string, + name: string, + description: string | null, // server description, else toolset.description + tool_count: int, + install_url: string, // mcp_endpoints URL for the per-server install page + }, + ... + ] +} +``` + +Card data is built by joining `mcp_servers` → `mcp_endpoints` → `toolsets` and counting tools per toolset. Servers with no `mcp_endpoint` are omitted (no install link to surface). + +### Auth path (handlers) + +1. Resolve project by `project_slug` within the session's org. If the slug belongs to another org or does not exist, return **404** uniformly (never 403) — do not leak project existence to other orgs. +2. For `getPortal`: load the `project_portals` row. If absent **or** `enabled = false`, return **404** — unless the caller has the project-admin scope **and** the request sets `preview=true` (so the in-settings preview iframe works before launch). The public route inherits this behaviour. +3. For `updatePortal`: require the existing project-admin scope (whatever today's project settings page checks). Upsert the row by `project_id`. + +No new RBAC scopes are introduced. All authorization is expressed via the existing project-scope grants and enforced through `authz.Engine.Require`. + +### File layout (server) + +``` +server/design/portals/design.go +server/internal/portals/ + impl.go + queries.sql + repo/ # generated by sqlc + rbac_test.go + getportal_test.go + updateportal_test.go + setup_test.go +server/database/schema.sql # +project_portals +server/migrations/_create_project_portals.sql # generated +``` + +## Frontend + +### Public-facing portal route + +Path: `app.gram.dev/portal/{project-slug}` (the deliverable link). + +- React route registered in `client/dashboard/src/routes.tsx`. +- If unauthenticated, redirect to `/login?return_to=/portal/{slug}` (existing pattern). +- After login, call `getPortal({project_slug})`. +- On 404, render a generic "Portal not found" page (do **not** distinguish "doesn't exist" from "disabled" from "wrong org"). +- On success, render: + - Header band: resolved logo, `display_name`, `tagline`, and a discreet "Powered by Gram" footer. + - Responsive grid: one `PortalCard` per server. + - Empty state if the project has no MCP servers, linking to the catalog. + +### Admin UI (inside project settings) + +Add a "Portal" section to the existing project-settings page. No new top-level nav entry. + +Contents: + +- `Enabled` toggle (default off). When on, a "Copy portal URL" button surfaces. +- Inputs: `display_name`, `tagline`, logo upload (re-uses existing asset upload component). +- Right-side live preview: iframe pointing at `/portal/{project-slug}?preview=1`. While editing (before `enabled` is flipped on), the preview iframe stays usable because `getPortal` honours `preview=1` for project admins. After `enabled` is flipped on, the same URL shows what every org member sees. + +### File layout (frontend) + +``` +client/dashboard/src/pages/portal/ + PortalPage.tsx # /portal/:projectSlug — public-facing portal + PortalCard.tsx # one MCP server card + PortalPreview.tsx # iframe preview shared by admin UI + hooks.ts # SDK hook wrappers +client/dashboard/src/pages/settings/ + PortalSettings.tsx # new section embedded in existing settings page +client/dashboard/src/routes.tsx # register /portal/:projectSlug +``` + +## URL & routing summary + +| Surface | URL | Notes | +| --------------------------- | ------------------------------------------------------------ | --------------------------------------- | +| Portal (the shareable link) | `app.gram.dev/portal/{project-slug}` | Auth-gated React route. | +| Per-server install pages | `app.gram.dev/mcp/{server-slug}` (existing `installPageUrl`) | Unchanged; portal cards deep-link here. | +| Admin (edit portal) | Embedded in existing project settings page | Project-admin scope. | + +`projects.slug` is unique per organization, so collisions across orgs cannot occur at the lookup step (since lookup is always scoped to the requesting session's org). + +## Implementation order + +1. **Migration PR** — `project_portals` table + `mise db:diff` + `mise db:hash`. Ships alone (see CLAUDE.md migration rules). +2. **Backend PR** — Goa service, handlers, SQLc queries, RBAC tests, SDK regen. +3. **Frontend PR** — admin section in project settings, public-facing portal route, preview iframe. Includes UX polish for empty states and error handling. + +Splitting migration first reduces blast radius and matches the project convention. + +## Skills to activate during implementation + +`postgresql` (schema + migration generation), `gram-management-api` (new Goa service wiring), `gram-rbac` (handler-side auth checks), `golang` (handler implementation), `frontend` (React pages, route registration), `pr` (per-PR description). + +## Open questions + +None outstanding; all clarifications from the brainstorming pass are folded into "Decisions" above. + +## Risks / unknowns to verify during implementation + +- The exact name of the existing project-admin scope (verify in `server/internal/authz` during implementation). +- Whether `mcp_endpoints` URLs need any rewriting when surfaced through the portal (custom-domain handling), or if the existing `installPageUrl` resolver already produces the correct absolute URL. +- Whether logo `assets` already expose a public URL helper, or if a new resolution path is needed. From b51d14b3f9a429f0655e151d09d75909cabec6bd Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Wed, 20 May 2026 17:03:49 -0700 Subject: [PATCH 02/32] docs: implementation plan for Internal MCP Server Portals 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) --- .../2026-05-20-internal-mcp-server-portals.md | 1283 +++++++++++++++++ 1 file changed, 1283 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-20-internal-mcp-server-portals.md diff --git a/docs/superpowers/plans/2026-05-20-internal-mcp-server-portals.md b/docs/superpowers/plans/2026-05-20-internal-mcp-server-portals.md new file mode 100644 index 0000000000..e5c37f8e99 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-internal-mcp-server-portals.md @@ -0,0 +1,1283 @@ +# Internal MCP Server Portals Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a per-project, org-internal MCP server catalogue page reachable at `app.gram.dev/portal/{project-slug}`, configurable from project settings. + +**Architecture:** Adds a `project_portals` table that stores `enabled` + branding overrides per project. A new Goa service `portals` exposes `getPortal` (org-member read, returns config + enriched server cards) and `updatePortal` (project-admin write). The dashboard SPA gains a public-facing portal route plus a settings section. No new RBAC scopes; reuses `ScopeProjectRead` / `ScopeProjectWrite`. Card data is joined from `mcp_servers` → `mcp_endpoints` → `toolsets`. + +**Tech Stack:** Go (Goa v3, sqlc, pgx), PostgreSQL (Atlas migrations), React + Vite + TS dashboard, Speakeasy-generated TS SDK. + +**Companion spec:** `docs/superpowers/specs/2026-05-20-internal-mcp-server-portals-design.md` — re-read it before starting if you have any doubt about a decision. + +**Reference patterns to mirror:** + +- Service skeleton: `server/internal/mcpendpoints/impl.go` (NewService + Attach + authz.Require pattern). +- Goa service: `server/design/mcpendpoints/design.go`. +- SQLc queries: `server/internal/mcpendpoints/queries.sql`. +- Per-server install page (for `install_url` resolution): `client/dashboard/src/pages/mcp/MCPHostedPage.tsx` and `useMcpUrl` hook. + +**Three PRs (ship in this order):** + +1. Migration only (Phase 1). +2. Backend service + SDK regen (Phase 2). +3. Frontend + glue (Phase 3). + +--- + +## File Structure + +**Created** + +``` +server/database/schema.sql (modify: +project_portals) +server/migrations/_create_project_portals.sql (generated by mise db:diff) +atlas.sum (regenerated by mise db:hash) + +server/design/portals/design.go (new Goa service) +server/internal/portals/ + impl.go (Service struct, NewService, Attach) + getportal.go (getPortal method + helpers) + updateportal.go (updatePortal method) + queries.sql (SQLc queries) + repo/ (sqlc-generated; do not edit) + setup_test.go (testenv harness) + getportal_test.go + updateportal_test.go + rbac_test.go + +client/dashboard/src/pages/portal/ + PortalPage.tsx (public-facing /portal/:slug route) + PortalCard.tsx (one MCP server card) + PortalHeader.tsx (logo + display_name + tagline) + PortalPreview.tsx (iframe used by settings) + hooks.ts (thin wrappers over SDK hooks) + +client/dashboard/src/pages/settings/PortalSettings.tsx (new section embedded in settings) +``` + +**Modified** + +``` +server/design/gram.go (+ import _ ".../design/portals") +server/cmd/gram/start.go (+ portals.Attach(...)) +client/dashboard/src/routes.tsx (register /portal/:projectSlug) +client/dashboard/src/pages/settings/Settings.tsx (or sibling) (mount PortalSettings) +client/sdk/ (regenerated by mise gen:sdk) +``` + +--- + +# Phase 1 — Migration (PR #1) + +This PR ships **alone**. No app code, no consumers. See CLAUDE.md migration rules. + +### Task 1.1: Add `project_portals` to schema.sql + +**Files:** + +- Modify: `server/database/schema.sql` (append a new `CREATE TABLE` block alongside the other `project_*` tables) + +- [ ] **Step 1: Append the table definition** + +Open `server/database/schema.sql`. Locate the existing `project_marketplace_settings` table (search for `project_marketplace_settings`). Append the following block immediately after that table's closing `;`: + +```sql +CREATE TABLE IF NOT EXISTS project_portals ( + id uuid NOT NULL DEFAULT generate_uuidv7(), + project_id uuid NOT NULL, + enabled boolean NOT NULL DEFAULT false, + display_name TEXT CHECK (display_name IS NULL OR (display_name <> '' AND CHAR_LENGTH(display_name) <= 64)), + tagline TEXT CHECK (tagline IS NULL OR CHAR_LENGTH(tagline) <= 200), + logo_asset_id uuid, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + + CONSTRAINT project_portals_pkey PRIMARY KEY (id), + CONSTRAINT project_portals_project_id_key UNIQUE (project_id), + CONSTRAINT project_portals_project_id_fkey FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + CONSTRAINT project_portals_logo_asset_id_fkey FOREIGN KEY (logo_asset_id) REFERENCES assets (id) ON DELETE SET NULL +); +``` + +### Task 1.2: Generate the migration file + +**Files:** + +- Create: `server/migrations/_create_project_portals.sql` (generated) +- Modify: `atlas.sum` (regenerated) + +- [ ] **Step 1: Confirm no stray untracked migrations exist** + +Run: `git status server/migrations/` +Expected: only modified/new files from THIS branch — nothing untracked (see CLAUDE.md "CRITICAL" note about atlas.sum). If there are stray `.sql` files, move them out of the directory before continuing. + +- [ ] **Step 2: Diff the schema to produce a migration** + +Run: `mise db:diff create_project_portals` +Expected: a new file `server/migrations/_create_project_portals.sql` containing only the `CREATE TABLE project_portals (...)` statement. + +- [ ] **Step 3: Update atlas.sum** + +Run: `mise db:hash` +Expected: `atlas.sum` updated with one new line for the new migration file. + +- [ ] **Step 4: Lint the migrations** + +Run: `mise lint:migrations` +Expected: PASS. If it complains about out-of-order timestamps, delete the new migration, rebase main, and re-run `mise db:diff` (see CLAUDE.md migration rules). + +### Task 1.3: Commit and open PR #1 + +- [ ] **Step 1: Stage and commit** + +```bash +git add server/database/schema.sql server/migrations/_create_project_portals.sql atlas.sum +git commit -m "$(cat <<'EOF' +feat(db): add project_portals table + +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) +EOF +)" +``` + +- [ ] **Step 2: Push and open PR** + +Use the `pr` skill. PR title: `feat(db): add project_portals table`. Wait for review/merge before starting Phase 2. + +--- + +# Phase 2 — Backend service + SDK regen (PR #2) + +> **Branch from `main` after PR #1 is merged.** Do not start until the migration is on `main`. + +### Task 2.1: Write SQLc queries + +**Files:** + +- Create: `server/internal/portals/queries.sql` + +- [ ] **Step 1: Write the queries** + +```sql +-- name: GetPortalByProjectID :one +SELECT * +FROM project_portals +WHERE project_id = @project_id; + +-- name: UpsertPortal :one +INSERT INTO project_portals ( + project_id, + enabled, + display_name, + tagline, + logo_asset_id +) +VALUES ( + @project_id, + @enabled, + @display_name, + @tagline, + @logo_asset_id +) +ON CONFLICT (project_id) DO UPDATE +SET + enabled = EXCLUDED.enabled, + display_name = EXCLUDED.display_name, + tagline = EXCLUDED.tagline, + logo_asset_id = EXCLUDED.logo_asset_id, + updated_at = clock_timestamp() +RETURNING *; + +-- name: ListPortalServerCards :many +-- Returns one row per MCP server in the project that has an mcp_endpoint +-- (i.e. is addressable). Includes toolset description fallback and tool count. +SELECT + ms.id AS server_id, + ms.name AS server_name, + me.slug AS endpoint_slug, + me.custom_domain_id AS endpoint_custom_domain_id, + ts.id AS toolset_id, + ts.name AS toolset_name, + ts.description AS toolset_description, + ( + SELECT COUNT(*) + FROM toolset_versions tv + WHERE tv.toolset_id = ts.id + AND tv.deleted IS FALSE + ) AS tool_count +FROM mcp_servers ms +JOIN mcp_endpoints me ON me.mcp_server_id = ms.id AND me.deleted IS FALSE +LEFT JOIN toolsets ts ON ts.id = ms.toolset_id AND ts.deleted IS FALSE +WHERE ms.project_id = @project_id + AND ms.deleted IS FALSE +ORDER BY ms.created_at ASC; +``` + +> **Note for the engineer**: verify the tool-count subquery against the real toolset structure before merging. The fixture tests in Task 2.7 will catch a mismatch. + +- [ ] **Step 2: Generate Go from SQLc** + +Run: `mise gen:sqlc-server` +Expected: a new `server/internal/portals/repo/` directory containing `db.go`, `models.go`, `queries.sql.go`. + +- [ ] **Step 3: Commit the generated repo** + +```bash +git add server/internal/portals/queries.sql server/internal/portals/repo/ +git commit -m "feat(portals): add sqlc queries and generated repo + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 2.2: Add the Goa service design + +**Files:** + +- Create: `server/design/portals/design.go` +- Modify: `server/design/gram.go` (add import) + +- [ ] **Step 1: Write the design** + +```go +package portals + +import ( + . "goa.design/goa/v3/dsl" + + "github.com/speakeasy-api/gram/server/design/security" + "github.com/speakeasy-api/gram/server/design/shared" +) + +var _ = Service("portals", func() { + Description("Manages per-project Internal MCP Server Portals.") + + Security(security.Session, security.ProjectSlug) + Security(security.ByKey, security.ProjectSlug, func() { + Scope("producer") + }) + shared.DeclareErrorResponses() + + Method("getPortal", func() { + 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.") + + Payload(func() { + security.SessionPayload() + security.ByKeyPayload() + security.ProjectPayload() + Attribute("preview", Boolean, "Bypass the disabled-portal 404 for project admins (for the in-settings preview).") + }) + + Result(Portal) + + HTTP(func() { + GET("/rpc/portals.get") + security.SessionHeader() + security.ByKeyHeader() + security.ProjectHeader() + Param("preview") + Response(StatusOK) + }) + + Meta("openapi:operationId", "getPortal") + Meta("openapi:extension:x-speakeasy-name-override", "read") + Meta("openapi:extension:x-speakeasy-react-hook", `{"name": "Portal"}`) + }) + + Method("updatePortal", func() { + Description("Create or update the portal configuration for a project.") + + Payload(func() { + Extend(UpdatePortalForm) + security.SessionPayload() + security.ByKeyPayload() + security.ProjectPayload() + }) + + Result(Portal) + + HTTP(func() { + POST("/rpc/portals.update") + security.SessionHeader() + security.ByKeyHeader() + security.ProjectHeader() + Response(StatusOK) + }) + + Meta("openapi:operationId", "updatePortal") + Meta("openapi:extension:x-speakeasy-name-override", "update") + Meta("openapi:extension:x-speakeasy-react-hook", `{"name": "UpdatePortal"}`) + }) +}) + +var UpdatePortalForm = Type("UpdatePortalForm", func() { + Attribute("enabled", Boolean, "Whether the portal is publicly reachable to org members.") + Attribute("display_name", String, "Override for the portal's display name. Empty string clears the override.") + Attribute("tagline", String, "Short tagline shown under the title. Empty string clears.") + Attribute("logo_asset_id", String, "UUID of an asset to use as the logo. Empty string clears.", func() { + Format(FormatUUID) + }) +}) + +var Portal = Type("Portal", func() { + Attribute("enabled", Boolean, "Whether the portal is enabled.", func() { Default(false) }) + Attribute("project_slug", String, "The project's slug.") + Attribute("display_name", String, "Resolved display name (override → project.name).") + Attribute("tagline", String, "Tagline if set.") + Attribute("logo_url", String, "Resolved logo URL or empty when none.") + Attribute("servers", ArrayOf(PortalServer), "Cards to render on the portal.") + Required("enabled", "project_slug", "display_name", "servers") +}) + +var PortalServer = Type("PortalServer", func() { + Attribute("slug", String, "Endpoint slug.") + Attribute("name", String, "Server name.") + Attribute("description", String, "Server or toolset description.") + Attribute("tool_count", Int, "Number of tools exposed by this server.") + Attribute("install_url", String, "URL of the per-server install page.") + Required("slug", "name", "tool_count", "install_url") +}) +``` + +- [ ] **Step 2: Register the design** + +Edit `server/design/gram.go`. Locate the block of `_ "github.com/speakeasy-api/gram/server/design/"` imports. Add (keeping alphabetical order): + +```go + _ "github.com/speakeasy-api/gram/server/design/portals" +``` + +- [ ] **Step 3: Generate Goa code** + +Run: `mise gen:goa-server` +Expected: new files under `server/gen/portals/` and `server/gen/http/portals/`. + +- [ ] **Step 4: Verify the server still compiles** + +Run: `mise build:server` +Expected: build succeeds. Compilation will fail later when `start.go` references a not-yet-existing `portals.NewService`, but at this point we have only added generated code so it should still build. If it fails, capture the exact error before continuing. + +- [ ] **Step 5: Commit** + +```bash +git add server/design/portals/ server/design/gram.go server/gen/portals/ server/gen/http/portals/ +git commit -m "feat(portals): add portals Goa service design + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 2.3: Service skeleton (`impl.go`) + +**Files:** + +- Create: `server/internal/portals/impl.go` + +- [ ] **Step 1: Write the skeleton** + +```go +package portals + +import ( + "log/slog" + + "github.com/jackc/pgx/v5/pgxpool" + "go.opentelemetry.io/otel/trace" + goahttp "goa.design/goa/v3/http" + + srv "github.com/speakeasy-api/gram/server/gen/http/portals/server" + gen "github.com/speakeasy-api/gram/server/gen/portals" + "github.com/speakeasy-api/gram/server/internal/attr" + "github.com/speakeasy-api/gram/server/internal/auth" + "github.com/speakeasy-api/gram/server/internal/auth/sessions" + "github.com/speakeasy-api/gram/server/internal/authz" + "github.com/speakeasy-api/gram/server/internal/middleware" +) + +type Service struct { + tracer trace.Tracer + logger *slog.Logger + db *pgxpool.Pool + auth *auth.Auth + authz *authz.Engine +} + +var _ gen.Service = (*Service)(nil) +var _ gen.Auther = (*Service)(nil) + +func NewService( + logger *slog.Logger, + tracerProvider trace.TracerProvider, + db *pgxpool.Pool, + sessions *sessions.Manager, + authzEngine *authz.Engine, +) *Service { + logger = logger.With(attr.SlogComponent("portals")) + return &Service{ + tracer: tracerProvider.Tracer("github.com/speakeasy-api/gram/server/internal/portals"), + logger: logger, + db: db, + auth: auth.New(logger, db, sessions, authzEngine), + authz: authzEngine, + } +} + +func Attach(mux goahttp.Muxer, service *Service) { + endpoints := gen.NewEndpoints(service) + endpoints.Use(middleware.MapErrors()) + endpoints.Use(middleware.TraceMethods(service.tracer)) + srv.Mount( + mux, + srv.New(endpoints, mux, goahttp.RequestDecoder, goahttp.ResponseEncoder, nil, nil), + ) +} +``` + +- [ ] **Step 2: Verify it compiles** (it will fail until handlers exist — that's expected; check the failure is _only_ about missing methods on `*Service`) + +Run: `mise build:server` (or `cd server && go build ./internal/portals/...`) +Expected: compilation errors about missing methods `GetPortal` and `UpdatePortal` on `*Service`. Any _other_ error means something is wrong with the skeleton — fix before continuing. + +### Task 2.4: Implement `getPortal` (TDD) + +**Files:** + +- Create: `server/internal/portals/getportal.go` +- Create: `server/internal/portals/setup_test.go` (testenv harness — copy the structure from `server/internal/mcpendpoints/setup_test.go`, adapting names) +- Create: `server/internal/portals/getportal_test.go` + +- [ ] **Step 1: Write the testenv harness** + +Copy the bones of `server/internal/mcpendpoints/setup_test.go` into `server/internal/portals/setup_test.go`. Rename the constructor / instance fields to point at the portals `Service` and its `NewService`. Use the existing `testenv` package the same way (`testenv.NewInstance`). + +- [ ] **Step 2: Write the failing test — disabled portal returns 404** + +Add to `server/internal/portals/getportal_test.go`: + +```go +package portals_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetPortal_Disabled_Returns404(t *testing.T) { + t.Parallel() + + inst := newTestInstance(t) // helper from setup_test.go + ctx := inst.WithSession(t) + + // Project + portal row exist but enabled=false (default). + resp, err := inst.PortalsClient(ctx).GetPortal(ctx, &gen.GetPortalPayload{ + ProjectSlugInput: inst.ProjectSlug, + }) + require.Error(t, err) + require.True(t, isHTTPStatus(err, http.StatusNotFound)) + _ = resp +} +``` + +- [ ] **Step 3: Run it to verify it fails** + +Run: `cd server && go test ./internal/portals/... -run TestGetPortal_Disabled_Returns404 -v` +Expected: FAIL — `GetPortal` is not implemented on `*Service`. + +- [ ] **Step 4: Write the minimal implementation** + +Create `server/internal/portals/getportal.go`: + +```go +package portals + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + gen "github.com/speakeasy-api/gram/server/gen/portals" + "github.com/speakeasy-api/gram/server/internal/authz" + "github.com/speakeasy-api/gram/server/internal/contextvalues" + "github.com/speakeasy-api/gram/server/internal/conv" + "github.com/speakeasy-api/gram/server/internal/oops" + "github.com/speakeasy-api/gram/server/internal/portals/repo" +) + +func (s *Service) GetPortal(ctx context.Context, payload *gen.GetPortalPayload) (*gen.Portal, error) { + authCtx, ok := contextvalues.GetAuthContext(ctx) + if !ok || authCtx.ProjectID == nil { + return nil, oops.E(ctx, nil, oops.CodeUnauthorized, "missing project context") + } + + if err := s.authz.Require(ctx, authz.Check{ + Scope: authz.ScopeProjectRead, + ResourceKind: "", + ResourceID: authCtx.ProjectID.String(), + }); err != nil { + return nil, err + } + + r := repo.New(s.db) + row, err := r.GetPortalByProjectID(ctx, *authCtx.ProjectID) + disabled := false + switch { + case errors.Is(err, pgx.ErrNoRows): + disabled = true + case err != nil: + return nil, oops.E(ctx, err, oops.CodeUnexpected, "load portal") + default: + disabled = !row.Enabled + } + + preview := payload.Preview != nil && *payload.Preview + if disabled { + if !preview { + return nil, oops.E(ctx, nil, oops.CodeNotFound, "portal not found") + } + // preview=true: require ScopeProjectWrite to bypass. + if err := s.authz.Require(ctx, authz.Check{ + Scope: authz.ScopeProjectWrite, + ResourceID: authCtx.ProjectID.String(), + }); err != nil { + return nil, oops.E(ctx, nil, oops.CodeNotFound, "portal not found") + } + } + + servers, err := r.ListPortalServerCards(ctx, *authCtx.ProjectID) + if err != nil { + return nil, oops.E(ctx, err, oops.CodeUnexpected, "list portal servers") + } + + out := &gen.Portal{ + Enabled: !disabled, + ProjectSlug: authCtx.ProjectSlug, + DisplayName: resolveDisplayName(row, authCtx), + Tagline: conv.PtrFromPGText(row.Tagline), + LogoURL: resolveLogoURL(ctx, s, row, authCtx), + Servers: make([]*gen.PortalServer, 0, len(servers)), + } + + for _, sv := range servers { + out.Servers = append(out.Servers, &gen.PortalServer{ + Slug: sv.EndpointSlug, + Name: firstNonEmpty(conv.FromPGText[string](sv.ServerName), conv.FromPGText[string](sv.ToolsetName)), + Description: conv.PtrFromPGText(sv.ToolsetDescription), + ToolCount: int(sv.ToolCount), + InstallURL: fmt.Sprintf("%s/mcp/%s", s.publicBaseURL(), sv.EndpointSlug), + }) + } + + return out, nil +} + +func resolveDisplayName(row repo.ProjectPortal, authCtx *contextvalues.AuthContext) string { + if name, ok := conv.FromPGTextOK(row.DisplayName); ok && name != "" { + return name + } + return authCtx.ProjectName +} + +func resolveLogoURL(ctx context.Context, s *Service, row repo.ProjectPortal, authCtx *contextvalues.AuthContext) string { + // TODO during implementation: prefer row.LogoAssetID, fall back to project logo, resolve via the existing asset URL helper. + return "" +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} +``` + +> **Engineer note**: `conv.PtrFromPGText`, `FromPGTextOK`, `publicBaseURL()`, and `contextvalues.AuthContext.ProjectName` are placeholders — verify the exact names against `server/internal/conv/` and `server/internal/contextvalues/` and adjust to match the codebase. The resolver for `LogoURL` is left as a focused follow-up step (Task 2.6). + +- [ ] **Step 5: Run the failing test again — it should now PASS** + +Run: `cd server && go test ./internal/portals/... -run TestGetPortal_Disabled_Returns404 -v` +Expected: PASS. + +- [ ] **Step 6: Add the enabled-portal happy-path test** + +Append to `getportal_test.go`: + +```go +func TestGetPortal_Enabled_ReturnsServers(t *testing.T) { + t.Parallel() + + inst := newTestInstance(t) + ctx := inst.WithSession(t) + + inst.SeedToolset(t, "weather") + inst.SeedMcpServerAndEndpoint(t, "weather", "weather-mcp") + + // Enable the portal. + _, err := inst.PortalsClient(ctx).UpdatePortal(ctx, &gen.UpdatePortalPayload{ + ProjectSlugInput: inst.ProjectSlug, + Enabled: conv.Ptr(true), + }) + require.NoError(t, err) + + resp, err := inst.PortalsClient(ctx).GetPortal(ctx, &gen.GetPortalPayload{ + ProjectSlugInput: inst.ProjectSlug, + }) + require.NoError(t, err) + require.True(t, resp.Enabled) + require.Equal(t, inst.ProjectSlug, resp.ProjectSlug) + require.Len(t, resp.Servers, 1) + require.Equal(t, "weather-mcp", resp.Servers[0].Slug) +} +``` + +- [ ] **Step 7: Run the new test** + +Run: `cd server && go test ./internal/portals/... -v` +Expected: both tests PASS. (`UpdatePortal` is implemented in Task 2.5; if this test is run before that task it will fail at the seeding step — defer running it until Task 2.5 is done. Mark the step incomplete to come back to.) + +### Task 2.5: Implement `updatePortal` (TDD) + +**Files:** + +- Create: `server/internal/portals/updateportal.go` +- Create: `server/internal/portals/updateportal_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +func TestUpdatePortal_RequiresProjectWrite(t *testing.T) { + t.Parallel() + + inst := newTestInstance(t) + ctx := inst.WithReadOnlySession(t) // helper that issues a session with project:read only + + _, err := inst.PortalsClient(ctx).UpdatePortal(ctx, &gen.UpdatePortalPayload{ + ProjectSlugInput: inst.ProjectSlug, + Enabled: conv.Ptr(true), + }) + require.Error(t, err) + require.True(t, isHTTPStatus(err, http.StatusForbidden)) +} + +func TestUpdatePortal_Upserts(t *testing.T) { + t.Parallel() + + inst := newTestInstance(t) + ctx := inst.WithSession(t) + + tagline := "Welcome to our MCP servers." + resp1, err := inst.PortalsClient(ctx).UpdatePortal(ctx, &gen.UpdatePortalPayload{ + ProjectSlugInput: inst.ProjectSlug, + Enabled: conv.Ptr(true), + Tagline: &tagline, + }) + require.NoError(t, err) + require.True(t, resp1.Enabled) + require.NotNil(t, resp1.Tagline) + require.Equal(t, tagline, *resp1.Tagline) + + // Second call updates the same row (no duplicate). + resp2, err := inst.PortalsClient(ctx).UpdatePortal(ctx, &gen.UpdatePortalPayload{ + ProjectSlugInput: inst.ProjectSlug, + Enabled: conv.Ptr(false), + }) + require.NoError(t, err) + require.False(t, resp2.Enabled) +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cd server && go test ./internal/portals/... -run TestUpdatePortal -v` +Expected: FAIL — `UpdatePortal` not implemented. + +- [ ] **Step 3: Write the implementation** + +Create `server/internal/portals/updateportal.go`: + +```go +package portals + +import ( + "context" + + "github.com/google/uuid" + + gen "github.com/speakeasy-api/gram/server/gen/portals" + "github.com/speakeasy-api/gram/server/internal/authz" + "github.com/speakeasy-api/gram/server/internal/contextvalues" + "github.com/speakeasy-api/gram/server/internal/conv" + "github.com/speakeasy-api/gram/server/internal/oops" + "github.com/speakeasy-api/gram/server/internal/portals/repo" +) + +func (s *Service) UpdatePortal(ctx context.Context, payload *gen.UpdatePortalPayload) (*gen.Portal, error) { + authCtx, ok := contextvalues.GetAuthContext(ctx) + if !ok || authCtx.ProjectID == nil { + return nil, oops.E(ctx, nil, oops.CodeUnauthorized, "missing project context") + } + + if err := s.authz.Require(ctx, authz.Check{ + Scope: authz.ScopeProjectWrite, + ResourceID: authCtx.ProjectID.String(), + }); err != nil { + return nil, err + } + + enabled := false + if payload.Enabled != nil { + enabled = *payload.Enabled + } + + var logoID uuid.UUID + if payload.LogoAssetID != nil && *payload.LogoAssetID != "" { + parsed, err := uuid.Parse(*payload.LogoAssetID) + if err != nil { + return nil, oops.E(ctx, err, oops.CodeBadRequest, "invalid logo_asset_id") + } + logoID = parsed + } + + r := repo.New(s.db) + row, err := r.UpsertPortal(ctx, repo.UpsertPortalParams{ + ProjectID: *authCtx.ProjectID, + Enabled: enabled, + DisplayName: conv.PtrToPGText(payload.DisplayName), + Tagline: conv.PtrToPGText(payload.Tagline), + LogoAssetID: uuidToPGUUID(logoID), + }) + if err != nil { + return nil, oops.E(ctx, err, oops.CodeUnexpected, "upsert portal") + } + + // Reuse GetPortal's resolution path by reading back. + return s.GetPortal(ctx, &gen.GetPortalPayload{ + ProjectSlugInput: payload.ProjectSlugInput, + Preview: conv.Ptr(true), + }) + _ = row +} +``` + +> **Engineer note**: `uuidToPGUUID` is the existing helper (verify name — see `conv.UUIDToPGUUID` or similar). If a stronger separation is preferred, return the constructed `*gen.Portal` directly instead of re-fetching. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd server && go test ./internal/portals/... -v` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/portals/ +git commit -m "feat(portals): implement getPortal and updatePortal + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 2.6: Resolve `logo_url` + +**Files:** + +- Modify: `server/internal/portals/getportal.go` (replace the stub `resolveLogoURL`) + +- [ ] **Step 1: Locate the existing asset-URL helper** + +Run: `grep -rn "func.*AssetURL\|func.*PublicURL\|signed_url" server/internal/ | head -20` +Expected: a function that takes an asset id (and ctx) and returns a public/signed URL. Adopt the same call shape used by `MCPHostedPage` for project logos. + +- [ ] **Step 2: Wire it up** + +Replace `resolveLogoURL` to: + +1. If `row.LogoAssetID` is set → resolve that asset's URL. +2. Else if the project has its own `logo_asset_id` → resolve it. +3. Else → return empty string. + +The exact code depends on the helper's signature — match it. Commit when complete. + +```bash +git add server/internal/portals/getportal.go +git commit -m "feat(portals): resolve portal logo url with project fallback" +``` + +### Task 2.7: Wire into `start.go` + +**Files:** + +- Modify: `server/cmd/gram/start.go` (add `portals.Attach(...)` near the other `*.Attach` calls) + +- [ ] **Step 1: Add the wiring** + +Find the line `mcpendpoints.Attach(mux, mcpendpoints.NewService(logger, tracerProvider, db, sessionManager, authzEngine, auditLogger))` (around line 1006). Add immediately after: + +```go + portals.Attach(mux, portals.NewService(logger, tracerProvider, db, sessionManager, authzEngine)) +``` + +Add the import: + +```go + "github.com/speakeasy-api/gram/server/internal/portals" +``` + +- [ ] **Step 2: Build** + +Run: `mise build:server` +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add server/cmd/gram/start.go +git commit -m "feat(portals): wire portals service into server startup" +``` + +### Task 2.8: Add RBAC isolation test (cross-org safety) + +**Files:** + +- Create: `server/internal/portals/rbac_test.go` + +- [ ] **Step 1: Write the test** + +```go +package portals_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetPortal_OtherOrg_Returns404(t *testing.T) { + t.Parallel() + + inst := newTestInstance(t) + otherInst := inst.NewSiblingOrg(t) + + ctx := otherInst.WithSession(t) + _, err := otherInst.PortalsClient(ctx).GetPortal(ctx, &gen.GetPortalPayload{ + ProjectSlugInput: inst.ProjectSlug, // belongs to a different org + }) + require.Error(t, err) + require.True(t, isHTTPStatus(err, http.StatusNotFound)) +} +``` + +- [ ] **Step 2: Run it** + +Run: `cd server && go test ./internal/portals/... -run TestGetPortal_OtherOrg_Returns404 -v` +Expected: PASS (the project-slug → project*id resolution path performed by the auth middleware should already reject mismatched orgs with a 404). If it returns 403 or 500, dig in — the answer is \_always* 404 for cross-org reads. + +- [ ] **Step 3: Commit** + +```bash +git add server/internal/portals/rbac_test.go +git commit -m "test(portals): assert cross-org getPortal returns 404" +``` + +### Task 2.9: Regenerate the TS SDK + +**Files:** + +- Modify: `client/sdk/` (generated) + +- [ ] **Step 1: Regenerate** + +Run: `mise gen:sdk` +Expected: changes under `client/sdk/src/` adding portal types and hooks (`usePortal`, `useUpdatePortalMutation`). + +- [ ] **Step 2: Rebuild the SDK so dashboard typechecking resolves** + +Run: `cd client/sdk && pnpm build` +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add client/sdk/ +git commit -m "chore(sdk): regen for portals service + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 2.10: Open PR #2 + +- [ ] **Step 1: Use the `pr` skill** to open the PR. Title: `feat(portals): backend service + SDK regen`. Description should call out: + - depends on PR #1 (migration) being merged + - lists new endpoints + - notes no new RBAC scopes added + - flags that no frontend lands in this PR yet + +Wait for review/merge. + +--- + +# Phase 3 — Frontend (PR #3) + +> Branch from `main` after PR #2 is merged. + +### Task 3.1: Add the public-facing portal route + +**Files:** + +- Create: `client/dashboard/src/pages/portal/PortalPage.tsx` +- Create: `client/dashboard/src/pages/portal/PortalHeader.tsx` +- Create: `client/dashboard/src/pages/portal/PortalCard.tsx` +- Modify: `client/dashboard/src/routes.tsx` (register `/portal/:projectSlug`) + +- [ ] **Step 1: Implement `PortalCard.tsx`** + +```tsx +import { Stack } from "@speakeasy-api/moonshine"; +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"} +
+
+
+ ); +} +``` + +- [ ] **Step 2: Implement `PortalHeader.tsx`** + +```tsx +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} +
+
+ ); +} +``` + +- [ ] **Step 3: Implement `PortalPage.tsx`** + +```tsx +import { usePortal } from "@gram/client/react-query"; +import { Heading } from "@/components/ui/heading"; +import { Stack } from "@speakeasy-api/moonshine"; +import { 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({ projectSlugInput: projectSlug, preview }, undefined, { + enabled: !!projectSlug, + }); + + if (isLoading) return ; + if (error || !portal) return ; + + return ( + + + {portal.servers.length === 0 ? ( + + ) : ( +
+ {portal.servers.map((s) => ( + + ))} +
+ )} + +
+ ); +} + +function Loading() { + return
Loading…
; +} +function NotFound() { + return ( +
+ Portal not found +

+ This portal does not exist or has not been published. +

+
+ ); +} +function EmptyState() { + return ( +
+ No MCP servers have been added to this project yet. +
+ ); +} +function PoweredByFooter() { + return ( +
+ Powered by Gram +
+ ); +} +``` + +- [ ] **Step 4: Register the route** + +Open `client/dashboard/src/routes.tsx`. Find where other top-level routes are declared. Add (lazy import to keep dashboard initial bundle slim): + +```tsx +import { lazy } from "react"; +const PortalPage = lazy(() => import("./pages/portal/PortalPage").then(m => ({ default: m.PortalPage }))); + +// inside the routes array: +{ + path: "/portal/:projectSlug", + element: , +} +``` + +> **Important**: the existing auth-guard wrapper must apply here too — the portal is auth-required. Check how other authed routes are wrapped (e.g. `/{projectSlug}/toolsets`) and apply the same wrapper. + +- [ ] **Step 5: Typecheck** + +Run: `cd client/dashboard && pnpm tsc -p tsconfig.app.json --noEmit` +Expected: clean. If you see "Property 'X' does not exist on type 'Y'" for fields you can see in `client/sdk/src/`, see the memory note about [worktree SDK resolution](../../../.claude/projects/-Users-sagar-go-src-gram/memory/project_worktree_sdk_resolution.md) — run `cd client/sdk && pnpm build` to fix. + +- [ ] **Step 6: Commit** + +```bash +git add client/dashboard/src/pages/portal/ client/dashboard/src/routes.tsx +git commit -m "feat(dashboard): add /portal/:slug public-facing portal route" +``` + +### Task 3.2: Build the in-settings admin section + +**Files:** + +- Create: `client/dashboard/src/pages/portal/PortalPreview.tsx` +- Create: `client/dashboard/src/pages/settings/PortalSettings.tsx` +- Modify: the existing settings page (find via `grep -rn "Project Settings\|projectSettings" client/dashboard/src/pages/settings/`) — mount `` as a new section. + +- [ ] **Step 1: Implement `PortalPreview.tsx`** (iframe pattern lifted from `MCPHostedPage.tsx`) + +```tsx +import { useEffect, useRef, useState } from "react"; + +interface Props { + projectSlug: string; + className?: string; +} + +export function PortalPreview({ projectSlug, className }: Props) { + const iframeRef = useRef(null); + const src = `/portal/${projectSlug}?preview=1`; + return ( +