From 815640d9195fc7f276bf796b048f35c9b4130f04 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Fri, 11 Sep 2026 18:12:48 -0400 Subject: [PATCH] Add permission-scoped MCP, readiness checks, and management UI improvements --- apps/web/package.json | 4 +- apps/web/src/app/api/health/ready/route.ts | 10 ++ apps/web/src/app/api/mcp/route.ts | 6 ++ .../app/dashboard/assistants/connections.tsx | 54 +++++++++++ .../web/src/app/dashboard/assistants/page.tsx | 10 ++ .../dashboard/events/[id]/moderation-grid.tsx | 14 ++- apps/web/src/app/dashboard/people/page.tsx | 2 +- apps/web/src/app/e/[slug]/guest-upload.tsx | 21 +++- .../src/components/create-group-dialog.tsx | 37 +++++++ apps/web/src/components/group-switcher.tsx | 18 +++- apps/web/src/components/site-header-bar.tsx | 2 +- apps/web/src/lib/upload-progress.test.ts | 14 +++ apps/web/src/lib/upload-progress.ts | 7 ++ apps/web/src/lib/workspace-navigation.ts | 4 +- apps/web/src/server/api/root.ts | 2 + apps/web/src/server/api/routers/assistants.ts | 45 +++++++++ apps/web/src/server/api/routers/group.ts | 12 +++ apps/web/src/server/api/routers/manager.ts | 60 ++++++++---- apps/web/src/server/api/routers/platform.ts | 15 ++- apps/web/src/server/mcp/auth.ts | 24 +++++ apps/web/src/server/mcp/catalog.ts | 74 ++++++++++++++ .../src/server/mcp/mcp.integration.test.ts | 83 ++++++++++++++++ apps/web/src/server/mcp/server.ts | 96 +++++++++++++++++++ .../web/src/server/permission-ceiling.test.ts | 17 ++++ apps/web/src/server/permission-ceiling.ts | 8 ++ apps/web/src/server/roles.ts | 15 +-- .../submission-groups.integration.test.ts | 48 ++++++++++ apps/worker/src/index.ts | 16 +++- bun.lock | 14 +-- compose.coolify.yml | 12 ++- compose.production.yml | 12 ++- docs/mcp.md | 83 ++++++++++++++++ packages/contracts/src/assistant.ts | 8 ++ packages/contracts/src/index.ts | 2 + packages/contracts/src/sign.test.ts | 6 +- .../drizzle/0013_assistant_tokens.sql | 14 +++ packages/database/drizzle/meta/_journal.json | 3 +- packages/database/src/health.ts | 16 ++++ packages/database/src/index.ts | 1 + packages/database/src/schema.ts | 13 +++ 40 files changed, 845 insertions(+), 57 deletions(-) create mode 100644 apps/web/src/app/api/health/ready/route.ts create mode 100644 apps/web/src/app/api/mcp/route.ts create mode 100644 apps/web/src/app/dashboard/assistants/connections.tsx create mode 100644 apps/web/src/app/dashboard/assistants/page.tsx create mode 100644 apps/web/src/components/create-group-dialog.tsx create mode 100644 apps/web/src/lib/upload-progress.test.ts create mode 100644 apps/web/src/lib/upload-progress.ts create mode 100644 apps/web/src/server/api/routers/assistants.ts create mode 100644 apps/web/src/server/mcp/auth.ts create mode 100644 apps/web/src/server/mcp/catalog.ts create mode 100644 apps/web/src/server/mcp/mcp.integration.test.ts create mode 100644 apps/web/src/server/mcp/server.ts create mode 100644 apps/web/src/server/permission-ceiling.test.ts create mode 100644 apps/web/src/server/permission-ceiling.ts create mode 100644 apps/web/src/server/submission-groups.integration.test.ts create mode 100644 docs/mcp.md create mode 100644 packages/contracts/src/assistant.ts create mode 100644 packages/database/drizzle/0013_assistant_tokens.sql create mode 100644 packages/database/src/health.ts diff --git a/apps/web/package.json b/apps/web/package.json index ef198e0..c4a377f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,6 +18,7 @@ "@fontsource-variable/funnel-display": "^5.3.0", "@fontsource-variable/geologica": "^5.3.0", "@fontsource-variable/inter": "^5.3.0", + "@modelcontextprotocol/sdk": "^1.30.0", "@radix-ui/react-popover": "^1.1.23", "@tanstack/react-query": "^5.90.2", "@trpc/client": "^11.4.3", @@ -46,7 +47,8 @@ "superjson": "^2.2.2", "tailwind-merge": "^3.3.0", "tw-animate-css": "^1.4.0", - "zod": "^3.25.67" + "zod": "^3.25.67", + "zod-to-json-schema": "^3.25.2" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.10", diff --git a/apps/web/src/app/api/health/ready/route.ts b/apps/web/src/app/api/health/ready/route.ts new file mode 100644 index 0000000..0d27edd --- /dev/null +++ b/apps/web/src/app/api/health/ready/route.ts @@ -0,0 +1,10 @@ +import { databaseReady } from "@album/database"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export async function GET() { + const ready = await databaseReady(); + return Response.json({ status: ready ? "ready" : "unavailable" }, { + status: ready ? 200 : 503, headers: { "Cache-Control": "no-store" }, + }); +} diff --git a/apps/web/src/app/api/mcp/route.ts b/apps/web/src/app/api/mcp/route.ts new file mode 100644 index 0000000..6852432 --- /dev/null +++ b/apps/web/src/app/api/mcp/route.ts @@ -0,0 +1,6 @@ +import { handleMcp } from "@/server/mcp/server"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const POST = handleMcp; +export const GET = handleMcp; +export const DELETE = handleMcp; diff --git a/apps/web/src/app/dashboard/assistants/connections.tsx b/apps/web/src/app/dashboard/assistants/connections.tsx new file mode 100644 index 0000000..1133577 --- /dev/null +++ b/apps/web/src/app/dashboard/assistants/connections.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useState } from "react"; +import { CopyIcon, KeyRoundIcon } from "lucide-react"; +import { toast } from "sonner"; +import { api } from "@/trpc/react"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Field, FieldGroup, FieldLabel, FieldDescription } from "@/components/ui/field"; +import { Switch } from "@/components/ui/switch"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +export function AssistantConnections({ endpoint }: { endpoint: string }) { + const utils = api.useUtils(); + const options = api.assistants.options.useQuery(); + const tokens = api.assistants.list.useQuery(); + const [name, setName] = useState(""); + const [days, setDays] = useState(30); + const [readOnly, setReadOnly] = useState(true); + const [selected, setSelected] = useState(null); + const [secret, setSecret] = useState(null); + const permissions = selected ?? options.data?.defaults ?? []; + const create = api.assistants.create.useMutation({ onSuccess: async result => { + setSecret(result.token); create.reset(); setName(""); await utils.assistants.list.invalidate(); toast.success("Assistant token created"); + }, onError: error => toast.error(error.message) }); + const revoke = api.assistants.revoke.useMutation({ onSuccess: async () => { await utils.assistants.list.invalidate(); toast.success("Token revoked"); }, onError: error => toast.error(error.message) }); + async function copy(value: string) { try { await navigator.clipboard.writeText(value); toast.success("Copied"); } catch { toast.error("Could not copy; select and copy the text manually."); } } + return <> + MCP endpointStreamable HTTP with an Authorization: Bearer token header. No browser cookies or shared administrator key. + + {secret ? Save your token nowThis is the only time it can be shown. Store it in your assistant's secret manager; never paste it into prompts. +
: null} + Create a connectionSelected permissions are a ceiling, not a new role. Your current account permissions are checked for every action. +
{ event.preventDefault(); if (!create.isPending) create.mutate({ name, days, readOnly, permissions }); }}> + Connection name setName(event.target.value)} placeholder="My assistant" required maxLength={80} /> + Expires after (days) setDays(Number(event.target.value))} /> + Read-only access + Turn off read-only only for assistants allowed to make changes, send emails, or delete data. Write tools also require an explicit confirmation argument. + + {!readOnly ? This token can perform changes permitted by the selections below and your current roles. Only give it to an assistant you trust. : null} +
Permissions · {permissions.length} selected + {options.data?.permissions.map(permission => {permission} setSelected(checked ? [...permissions, permission] : permissions.filter(value => value !== permission))} />)} +
+ {options.isError ?

Permissions could not load. Refresh to retry.

: null} + +
+ Your connectionsRevocation blocks subsequent requests immediately. Requests already executing may finish. + {tokens.isLoading ?

Loading connections…

: tokens.isError ?

Connections could not load.

: !tokens.data?.length ?

No assistant connections yet.

: tokens.data.map(token =>
+

{token.name}

{token.revokedAt ? "Revoked" : token.expiresAt < new Date() ? "Expired" : token.readOnly ? "Read-only" : "Read and write"} · expires {token.expiresAt.toLocaleDateString()} · {token.permissions.length} permissions

{token.lastUsedAt ? `Last used ${token.lastUsedAt.toLocaleString()}` : "Not used yet"}

+ {!token.revokedAt ? : null} +
)}
+ ; +} diff --git a/apps/web/src/app/dashboard/assistants/page.tsx b/apps/web/src/app/dashboard/assistants/page.tsx new file mode 100644 index 0000000..67e4ab9 --- /dev/null +++ b/apps/web/src/app/dashboard/assistants/page.tsx @@ -0,0 +1,10 @@ +import { AssistantConnections } from "./connections"; +import { publicAppOrigin } from "@/server/public-app-url"; + +export default function AssistantsPage() { + return
+

Assistant connections

+

Connect assistants through MCP with your existing event, group, and platform permissions.

+ +
; +} diff --git a/apps/web/src/app/dashboard/events/[id]/moderation-grid.tsx b/apps/web/src/app/dashboard/events/[id]/moderation-grid.tsx index 0002739..b933626 100644 --- a/apps/web/src/app/dashboard/events/[id]/moderation-grid.tsx +++ b/apps/web/src/app/dashboard/events/[id]/moderation-grid.tsx @@ -70,8 +70,8 @@ export function ModerationGrid({ onError: (error) => toast.error(error.message), }); const moderateSubmission = api.manager.moderateSubmission.useMutation({ - onSuccess: async () => { - toast.success("Submission updated"); + onSuccess: async (result) => { + toast.success(`${result.updatedCount} photos updated`); await utils.manager.photos.invalidate({ eventId }); }, onError: (error) => toast.error(error.message), @@ -211,9 +211,15 @@ export function ModerationGrid({ ) : null} - {canModerate && photo.processingStatus === "ready" ? ( + {canModerate ? ( moderateSubmission.mutate({ submissionId: photo.submissionId, visibility: "hidden" })}> + onSelect={() => moderateSubmission.mutate({ eventId, submissionId: photo.submissionId, visibility: "public" })}> + + ) : null} + {canModerate ? ( + moderateSubmission.mutate({ eventId, submissionId: photo.submissionId, visibility: "hidden" })}> ) : null} diff --git a/apps/web/src/app/dashboard/people/page.tsx b/apps/web/src/app/dashboard/people/page.tsx index 3a38597..578799d 100644 --- a/apps/web/src/app/dashboard/people/page.tsx +++ b/apps/web/src/app/dashboard/people/page.tsx @@ -17,7 +17,7 @@ export default async function DashboardPeoplePage() { No group yet - Create an event to start a group. + Use Create group in the header to start a workspace for your team. ); diff --git a/apps/web/src/app/e/[slug]/guest-upload.tsx b/apps/web/src/app/e/[slug]/guest-upload.tsx index 946cb1e..0f49362 100644 --- a/apps/web/src/app/e/[slug]/guest-upload.tsx +++ b/apps/web/src/app/e/[slug]/guest-upload.tsx @@ -17,6 +17,8 @@ import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; import { Progress } from "@/components/ui/progress"; import { Spinner } from "@/components/ui/spinner"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; +import { uploadProgress } from "@/lib/upload-progress"; type QueueItem = { file?: File; @@ -59,6 +61,7 @@ export function GuestUpload({ const retryUpload = api.photos.retryUpload.useMutation(); const uploading = useRef(false); const utils = api.useUtils(); + const progress = uploadProgress(queue); useEffect(() => { const storedName = localStorage.getItem(guestKey(slug, "name")) ?? ""; @@ -257,7 +260,20 @@ export function GuestUpload({ : null} {queue.length > 0 ? ( -
    + + +
    + {busy ? "Uploading your photos" : progress.failed ? "Some photos need attention" : "Photos uploaded"} + {progress.percent}% +
    + {progress.completed} of {progress.total} photos uploaded{progress.failed ? ` · ${progress.failed} need retry` : ""} +
    + + +

    {busy ? "Keep this page open until uploading finishes." : progress.failed ? "Open file details below to retry unsuccessful uploads." : "Your originals are saved. Gallery previews may take a moment to appear."}

    +
    + File details +
      {queue.map((item) => (
    • @@ -272,6 +288,9 @@ export function GuestUpload({
    • ))}
    +
    +
    +
    ) : null}
    void }) { + const [name, setName] = useState(""); + const router = useRouter(); + const utils = api.useUtils(); + const create = api.group.create.useMutation({ + onSuccess: async () => { + await utils.invalidate(); + setName(""); + onOpenChange(false); + toast.success("Group created"); + router.push("/dashboard/people"); + router.refresh(); + }, + onError: error => toast.error(error.message), + }); + return { if (!create.isPending) onOpenChange(next); }}> + + Create a groupA separate workspace for your team. Existing events, members, and event allowances stay with their current group. +
    { event.preventDefault(); if (!create.isPending) create.mutate({ name }); }}> + Group name setName(event.target.value)} maxLength={100} required disabled={create.isPending} autoFocus /> + +
    +
    +
    ; +} diff --git a/apps/web/src/components/group-switcher.tsx b/apps/web/src/components/group-switcher.tsx index 9e676ed..f700191 100644 --- a/apps/web/src/components/group-switcher.tsx +++ b/apps/web/src/components/group-switcher.tsx @@ -1,6 +1,9 @@ "use client"; import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { CreateGroupDialog } from "@/components/create-group-dialog"; +import { Button } from "@/components/ui/button"; import { ImagesIcon } from "lucide-react"; import { toast } from "sonner"; import { api } from "@/trpc/react"; @@ -21,6 +24,7 @@ export function GroupSwitcher({ groups, activeGroupId, compact = false }: { compact?: boolean; }) { const router = useRouter(); + const [creating, setCreating] = useState(false); const utils = api.useUtils(); const select = api.group.select.useMutation({ onSuccess: async () => { @@ -30,11 +34,15 @@ export function GroupSwitcher({ groups, activeGroupId, compact = false }: { }, onError: (error) => toast.error(error.message), }); - if (groups.length === 0) return null; + if (groups.length === 0) return <>; const activeGroup = groups.find((group) => group.id === activeGroupId) ?? groups[0]!; return ( - { + if (groupId === "create") { setCreating(true); return; } + if (groupId === "manage") { router.push("/dashboard/people"); return; } + if (groupId !== activeGroup.id) select.mutate({ groupId }); + }}> svg:last-child]:hidden sm:[&>svg:last-child]:block", !compact && "sm:w-full")}>