From 1479161b0954ee01f48c2836e70f5b5714d93492 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 00:47:58 +0000 Subject: [PATCH] feat(mcp): funnel telemetry, health, rate-limit headers, playground, idempotency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 of the MCP UX roadmap. Five orthogonal improvements landed together because they all hit /api/mcp or the connect flow. 1. Funnel telemetry — new mcp_funnel_events table with a closed event vocabulary and a SECURITY DEFINER RPC that anonymous browsers and the server can both call. Events fire from /oauth/authorize (viewed/approved/denied), /oauth/success (shown/loopback/scheme/manual_copy), /connect (viewed), the install buttons, the PAT mint server fn, and /api/mcp (first tools/call + first write). Aggregates exposed via the mcp_funnel_summary RPC and rendered at /admin/funnel with conversion rates against connect_viewed. 2. Health endpoint — GET /api/mcp/health returns { ok, db.ping_ms, version, uptime } with CORS. Public on purpose — status pages shouldn't require auth. 3. /status page — polls /api/mcp/health every 15s and shows a live operational/degraded banner plus DB latency + uptime so users (and us) can sanity-check the service. 4. Rate-limit headers — /api/mcp now emits X-RateLimit-Limit, -Remaining, -Reset and -Window on EVERY quota-bearing response, not just 429s. Lets agents self-regulate before they hit the ceiling. 5. Playground at /play — chat-style UI wrapping the existing McpTester with one-click run for every tool, plus a token paste box for write tools. Zero install path for evaluators. 6. Idempotency keys — upload_packages and request_primitive now accept an optional idempotency_key. New mcp_idempotency table caches the response for 24h per (user_id, key_hash, tool); retry returns { ...response, replayed: true }. Kills the duplicate uploads we see whenever an agent retries a flaky network call. Telemetry is fire-and-forget end-to-end: the RPC swallows errors, the server fn never throws, and a failure to record an event will never break a user flow. https://claude.ai/code/session_019gMoupKKTVydpNwiiACQRd --- src/components/site/InstallButtons.tsx | 6 + src/lib/account/tokens.functions.ts | 12 ++ src/lib/admin/funnel.functions.ts | 22 +++ src/lib/mcp/idempotency.ts | 58 ++++++ src/lib/mcp/tools/skills.ts | 48 ++++- src/lib/telemetry/funnel.functions.ts | 55 ++++++ src/routeTree.gen.ts | 103 ++++++++++- src/routes/admin.funnel.tsx | 151 +++++++++++++++ src/routes/api/mcp.ts | 62 ++++++- src/routes/api/mcp/health.ts | 60 ++++++ src/routes/connect.tsx | 8 +- src/routes/oauth.authorize.tsx | 29 ++- src/routes/oauth.success.tsx | 23 ++- src/routes/play.tsx | 98 ++++++++++ src/routes/status.tsx | 140 ++++++++++++++ ...60524000000_mcp_funnel_and_idempotency.sql | 174 ++++++++++++++++++ 16 files changed, 1020 insertions(+), 29 deletions(-) create mode 100644 src/lib/admin/funnel.functions.ts create mode 100644 src/lib/mcp/idempotency.ts create mode 100644 src/lib/telemetry/funnel.functions.ts create mode 100644 src/routes/admin.funnel.tsx create mode 100644 src/routes/api/mcp/health.ts create mode 100644 src/routes/play.tsx create mode 100644 src/routes/status.tsx create mode 100644 supabase/migrations/20260524000000_mcp_funnel_and_idempotency.sql diff --git a/src/components/site/InstallButtons.tsx b/src/components/site/InstallButtons.tsx index 14d3f998..4579b189 100644 --- a/src/components/site/InstallButtons.tsx +++ b/src/components/site/InstallButtons.tsx @@ -15,7 +15,9 @@ * render a button for them here. */ import { useState } from "react"; +import { useServerFn } from "@tanstack/react-start"; import { Check, ExternalLink } from "lucide-react"; +import { recordFunnelEvent } from "@/lib/telemetry/funnel.functions"; const ENDPOINT = "https://superagentskill.com/api/mcp"; const SERVER_NAME = "super-agent-skill"; @@ -74,6 +76,7 @@ const BUTTONS: ButtonSpec[] = [ export function InstallButtons({ compact = false }: { compact?: boolean }) { const [clicked, setClicked] = useState(null); + const track = useServerFn(recordFunnelEvent); return (
{BUTTONS.map((b) => ( @@ -82,6 +85,9 @@ export function InstallButtons({ compact = false }: { compact?: boolean }) { href={b.href} onClick={() => { setClicked(b.id); + void track({ + data: { event: "install_button_clicked", client_name: b.id }, + }).catch(() => {}); // The OS hand-off can be silent if the app isn't installed. // Clear after 4s so users can retry. setTimeout(() => setClicked((c) => (c === b.id ? null : c)), 4000); diff --git a/src/lib/account/tokens.functions.ts b/src/lib/account/tokens.functions.ts index c8847897..4499efd4 100644 --- a/src/lib/account/tokens.functions.ts +++ b/src/lib/account/tokens.functions.ts @@ -2,6 +2,8 @@ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { requireSupabaseAuth } from "@/integrations/supabase/auth-middleware"; import { hashToken, newToken } from "./tokens.server"; +import { supabaseAdmin as _supabaseAdmin } from "@/integrations/supabase/client.server"; +const supabaseAdmin = _supabaseAdmin as any; export const listMcpTokens = createServerFn({ method: "GET" }) .middleware([requireSupabaseAuth]) @@ -34,6 +36,16 @@ export const createMcpToken = createServerFn({ method: "POST" }) .select("id,name,prefix,created_at") .single(); if (error) throw new Response(error.message, { status: 500 }); + // Fire-and-forget funnel event so /admin/funnel reflects PAT mints. + void supabaseAdmin + .rpc("record_mcp_funnel_event", { + _event: "pat_minted", + _client_id: null, + _client_name: null, + _anon_hash: null, + _props: { name_length: data.name.length }, + } as never) + .then(() => {}, () => {}); // Plaintext returned ONCE — never stored anywhere except hashed. return { token, ...row }; }); diff --git a/src/lib/admin/funnel.functions.ts b/src/lib/admin/funnel.functions.ts new file mode 100644 index 00000000..85003771 --- /dev/null +++ b/src/lib/admin/funnel.functions.ts @@ -0,0 +1,22 @@ +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { requireSupabaseAuth } from "@/integrations/supabase/auth-middleware"; + +/** Aggregate counts per funnel event over the last N days. Admin only. */ +export const getMcpFunnelSummary = createServerFn({ method: "GET" }) + .middleware([requireSupabaseAuth]) + .inputValidator((d: unknown) => + z.object({ days: z.number().int().min(1).max(90).optional() }).parse(d ?? {}), + ) + .handler(async ({ context, data }) => { + const { supabase: _sb } = context as any; + const supabase = _sb as any; + const { data: rows, error } = await supabase.rpc("mcp_funnel_summary", { + _days: data.days ?? 7, + }); + if (error) throw new Response(error.message, { status: 403 }); + return { + days: data.days ?? 7, + events: (rows ?? []) as Array<{ event: string; count: number; distinct_users: number }>, + }; + }); diff --git a/src/lib/mcp/idempotency.ts b/src/lib/mcp/idempotency.ts new file mode 100644 index 00000000..8400d154 --- /dev/null +++ b/src/lib/mcp/idempotency.ts @@ -0,0 +1,58 @@ +/** + * Idempotency helpers for MCP write tools (upload_packages, request_primitive). + * + * Agents retry on network blips. Without idempotency, a retried + * upload_packages creates duplicate drafts and burns the write quota. + * Callers may pass `idempotency_key` (any opaque string they generate + * once per logical operation); the server hashes it, scopes it to the + * user + tool, caches the response for 24h, and returns the cached + * result on subsequent retries with the same key. + * + * Implementation lives in two RPCs (mcp_idempotency_get / + * mcp_idempotency_put) so the contention is in the database and the + * tool execute() handlers stay small. + */ +import { createHash } from "crypto"; +import { supabaseAdmin as _supabaseAdmin } from "@/integrations/supabase/client.server"; +const supabaseAdmin = _supabaseAdmin as any; + +function hashKey(key: string): string { + return createHash("sha256").update(key).digest("hex"); +} + +export async function getIdempotent( + userId: string, + tool: string, + key: string | undefined | null, +): Promise { + if (!key) return null; + try { + const { data } = await supabaseAdmin.rpc("mcp_idempotency_get", { + _user_id: userId, + _key_hash: hashKey(key), + _tool: tool, + } as never); + return data ?? null; + } catch { + return null; + } +} + +export async function putIdempotent( + userId: string, + tool: string, + key: string | undefined | null, + response: unknown, +): Promise { + if (!key) return; + try { + await supabaseAdmin.rpc("mcp_idempotency_put", { + _user_id: userId, + _key_hash: hashKey(key), + _tool: tool, + _response: response as never, + } as never); + } catch { + /* never fail the user's call on idempotency persistence */ + } +} diff --git a/src/lib/mcp/tools/skills.ts b/src/lib/mcp/tools/skills.ts index aedd5db5..52d0eea4 100644 --- a/src/lib/mcp/tools/skills.ts +++ b/src/lib/mcp/tools/skills.ts @@ -6,6 +6,7 @@ const supabaseAdmin = _supabaseAdmin as any; import { hashToken } from "@/lib/account/tokens.server"; import { processBulkUpload } from "@/lib/uploads/uploads.server"; import { getGatewayModel } from "@/lib/ai-gateway"; +import { getIdempotent, putIdempotent } from "@/lib/mcp/idempotency"; const json = (v: unknown) => JSON.stringify(v, null, 2); @@ -185,24 +186,39 @@ export const searchRegistryTool = defineTool({ export const requestPrimitiveTool = defineTool({ name: "request_primitive", description: - "[PUBLISH] Submit a request for a primitive that does not yet exist. SuperAgentSkill researches and auto-creates it via the proprietary forge pipeline. Requires OAuth.", + "[PUBLISH] Submit a request for a primitive that does not yet exist. SuperAgentSkill researches and auto-creates it via the proprietary forge pipeline. Requires OAuth. Pass an `idempotency_key` (any opaque string you generate once per request) and retries return the original `request_id` instead of creating duplicates.", parameters: z.object({ type: z.enum(["skill", "playbook", "soul", "guardrail"]), brief: z.string().min(20).max(2000).describe("What the primitive should do, with industry/context"), industry: z.string().max(80).optional(), + idempotency_key: z + .string() + .min(8) + .max(200) + .optional() + .describe("Opaque string generated once per logical request. Repeats within 24h return the original response."), }), - execute: async ({ type, brief, industry }) => { + execute: async ({ type, brief, industry, idempotency_key }, ctx) => { + const userId = (ctx?.auth?.claims as { user_id?: string } | undefined)?.user_id ?? null; + if (userId && idempotency_key) { + const cached = await getIdempotent(userId, "request_primitive", idempotency_key); + if (cached) return json({ ...(cached as object), replayed: true }); + } const { data, error } = await supabaseAdmin .from("package_requests") .insert({ kind: type, brief, industry: industry ?? null, status: "queued" }) .select("id,status") .single(); if (error) return json({ error: error.message }); - return json({ + const response = { request_id: data.id, status: data.status, note: "Queued for the Super Agent Skill forge pipeline.", - }); + }; + if (userId && idempotency_key) { + await putIdempotent(userId, "request_primitive", idempotency_key, response); + } + return json(response); }, }); @@ -261,7 +277,7 @@ export const getTrustTool = defineTool({ export const uploadPackagesTool = defineTool({ name: "upload_packages", description: - "[PRIVATE UPLOAD] Push local primitive(s) into the author's PRIVATE workspace. Files are normalised by the SkillForge author pipeline and stored as private drafts owned by the token holder — NOT visible in the public marketplace, search, or trust leaderboard. To list a draft for sale on the marketplace, the author must explicitly publish it from the website UI (/account/packages). This MCP tool intentionally has no `publish` parameter so agents cannot expose a user's skill publicly without their consent. Authenticates via the OAuth bearer of the active MCP session — no extra personal token needed.", + "[PRIVATE UPLOAD] Push local primitive(s) into the author's PRIVATE workspace. Files are normalised by the SkillForge author pipeline and stored as private drafts owned by the token holder — NOT visible in the public marketplace, search, or trust leaderboard. To list a draft for sale on the marketplace, the author must submit it for admin review from the website UI (/account/packages). This MCP tool intentionally has no `publish` parameter so agents cannot expose a user's skill publicly without their consent. Authenticates via the OAuth bearer of the active MCP session — no extra personal token needed. Pass an `idempotency_key` (any opaque string you generate once per upload) so retries on network failures don't create duplicates.", parameters: z.object({ files: z .array( @@ -278,8 +294,14 @@ export const uploadPackagesTool = defineTool({ .min(8) .optional() .describe("Deprecated. Ignored when the request already carries an OAuth bearer; only used as a fallback for legacy personal MCP tokens."), + idempotency_key: z + .string() + .min(8) + .max(200) + .optional() + .describe("Opaque string generated once per logical upload. Repeats within 24h return the original response and DO NOT re-process the files."), }), - execute: async ({ auth_token, files }, ctx) => { + execute: async ({ auth_token, files, idempotency_key }, ctx) => { const sessionUserId = (ctx?.auth?.claims as { user_id?: string } | undefined)?.user_id ?? null; const userId = sessionUserId ?? (auth_token ? await resolveUserFromToken(auth_token) : null); if (!userId) @@ -287,17 +309,25 @@ export const uploadPackagesTool = defineTool({ error: "unauthorized", hint: "Connect via OAuth (the host opens https://superagentskill.com/oauth/authorize automatically) — no personal token needed.", }); + if (idempotency_key) { + const cached = await getIdempotent(userId, "upload_packages", idempotency_key); + if (cached) return json({ ...(cached as object), replayed: true }); + } try { // Always private. Marketplace listing requires an explicit user action in the UI. const results = await processBulkUpload(supabaseAdmin as any, userId, files); const ok = results.filter((r) => r.ok).length; - return json({ + const response = { uploaded: ok, failed: results.length - ok, visibility: "private_draft", - next_step: "Open /account/packages on superagentskill.com to list a draft on the marketplace.", + next_step: "Open /account/packages on superagentskill.com to submit a draft for admin review.", results, - }); + }; + if (idempotency_key) { + await putIdempotent(userId, "upload_packages", idempotency_key, response); + } + return json(response); } catch (e: any) { return json({ error: e?.message ?? "upload_failed" }); } diff --git a/src/lib/telemetry/funnel.functions.ts b/src/lib/telemetry/funnel.functions.ts new file mode 100644 index 00000000..90e06b8e --- /dev/null +++ b/src/lib/telemetry/funnel.functions.ts @@ -0,0 +1,55 @@ +/** + * Funnel telemetry for the MCP connect flow. The browser calls + * `recordFunnelEvent` from the consent page, the success page and the + * /connect landing; the MCP route fires `mcp_first_call` server-side + * the first time a given user/anon hash invokes a tool. + * + * Telemetry is best-effort — the RPC swallows any error so a failed + * insert never breaks the user's flow. + */ +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { supabaseAdmin as _supabaseAdmin } from "@/integrations/supabase/client.server"; +const supabaseAdmin = _supabaseAdmin as any; + +const EVENTS = [ + "connect_viewed", + "oauth_authorize_viewed", + "oauth_authorize_approved", + "oauth_authorize_denied", + "oauth_success_shown", + "oauth_loopback_attempted", + "oauth_manual_code_copied", + "oauth_scheme_triggered", + "mcp_first_call", + "mcp_first_write", + "install_button_clicked", + "pat_minted", +] as const; + +const Input = z.object({ + event: z.enum(EVENTS), + client_id: z.string().max(200).optional(), + client_name: z.string().max(200).optional(), + anon_hash: z.string().max(64).optional(), + props: z.record(z.string(), z.unknown()).optional(), +}); + +export const recordFunnelEvent = createServerFn({ method: "POST" }) + .inputValidator((d: unknown) => Input.parse(d)) + .handler(async ({ data }) => { + try { + await supabaseAdmin.rpc("record_mcp_funnel_event", { + _event: data.event, + _client_id: data.client_id ?? null, + _client_name: data.client_name ?? null, + _anon_hash: data.anon_hash ?? null, + _props: data.props ?? {}, + } as never); + } catch { + // never fail user flow on telemetry + } + return { ok: true }; + }); + +export type FunnelEvent = (typeof EVENTS)[number]; diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index a60e532a..6537b207 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as WelcomeRouteImport } from './routes/welcome' import { Route as UseCasesRouteImport } from './routes/use-cases' import { Route as UploadRouteImport } from './routes/upload' import { Route as TermsRouteImport } from './routes/terms' +import { Route as StatusRouteImport } from './routes/status' import { Route as SkillforgeRouteImport } from './routes/skillforge' import { Route as SkillOfTheWeekRouteImport } from './routes/skill-of-the-week' import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml' @@ -22,6 +23,7 @@ import { Route as ResetPasswordRouteImport } from './routes/reset-password' import { Route as RefundsRouteImport } from './routes/refunds' import { Route as PrivacyRouteImport } from './routes/privacy' import { Route as PricingRouteImport } from './routes/pricing' +import { Route as PlayRouteImport } from './routes/play' import { Route as OnboardingRouteImport } from './routes/onboarding' import { Route as MatchRouteImport } from './routes/match' import { Route as LoginRouteImport } from './routes/login' @@ -68,6 +70,7 @@ import { Route as AdminRequestsRouteImport } from './routes/admin.requests' import { Route as AdminPlansRouteImport } from './routes/admin.plans' import { Route as AdminPackagesRouteImport } from './routes/admin.packages' import { Route as AdminMetaAdsPackRouteImport } from './routes/admin.meta-ads-pack' +import { Route as AdminFunnelRouteImport } from './routes/admin.funnel' import { Route as AdminCustomersRouteImport } from './routes/admin.customers' import { Route as AdminAccountsRouteImport } from './routes/admin.accounts' import { Route as AccountUsageRouteImport } from './routes/account.usage' @@ -86,6 +89,7 @@ import { Route as ForgeReportSlugRouteImport } from './routes/forge.report.$slug import { Route as ApiPublicTelemetryRouteImport } from './routes/api/public/telemetry' import { Route as ApiPublicSearchRouteImport } from './routes/api/public/search' import { Route as ApiPublicPackagesRouteImport } from './routes/api/public/packages' +import { Route as ApiMcpHealthRouteImport } from './routes/api/mcp/health' import { Route as AdminPackagesNewRouteImport } from './routes/admin.packages.new' import { Route as AdminImportMarkdownRouteImport } from './routes/admin.import.markdown' import { Route as AdminImportGithubRouteImport } from './routes/admin.import.github' @@ -127,6 +131,11 @@ const TermsRoute = TermsRouteImport.update({ path: '/terms', getParentRoute: () => rootRouteImport, } as any) +const StatusRoute = StatusRouteImport.update({ + id: '/status', + path: '/status', + getParentRoute: () => rootRouteImport, +} as any) const SkillforgeRoute = SkillforgeRouteImport.update({ id: '/skillforge', path: '/skillforge', @@ -172,6 +181,11 @@ const PricingRoute = PricingRouteImport.update({ path: '/pricing', getParentRoute: () => rootRouteImport, } as any) +const PlayRoute = PlayRouteImport.update({ + id: '/play', + path: '/play', + getParentRoute: () => rootRouteImport, +} as any) const OnboardingRoute = OnboardingRouteImport.update({ id: '/onboarding', path: '/onboarding', @@ -402,6 +416,11 @@ const AdminMetaAdsPackRoute = AdminMetaAdsPackRouteImport.update({ path: '/meta-ads-pack', getParentRoute: () => AdminRoute, } as any) +const AdminFunnelRoute = AdminFunnelRouteImport.update({ + id: '/funnel', + path: '/funnel', + getParentRoute: () => AdminRoute, +} as any) const AdminCustomersRoute = AdminCustomersRouteImport.update({ id: '/customers', path: '/customers', @@ -494,6 +513,11 @@ const ApiPublicPackagesRoute = ApiPublicPackagesRouteImport.update({ path: '/api/public/packages', getParentRoute: () => rootRouteImport, } as any) +const ApiMcpHealthRoute = ApiMcpHealthRouteImport.update({ + id: '/health', + path: '/health', + getParentRoute: () => ApiMcpRoute, +} as any) const AdminPackagesNewRoute = AdminPackagesNewRouteImport.update({ id: '/new', path: '/new', @@ -621,6 +645,7 @@ export interface FileRoutesByFullPath { '/login': typeof LoginRoute '/match': typeof MatchRoute '/onboarding': typeof OnboardingRoute + '/play': typeof PlayRoute '/pricing': typeof PricingRoute '/privacy': typeof PrivacyRoute '/refunds': typeof RefundsRoute @@ -630,6 +655,7 @@ export interface FileRoutesByFullPath { '/sitemap.xml': typeof SitemapDotxmlRoute '/skill-of-the-week': typeof SkillOfTheWeekRoute '/skillforge': typeof SkillforgeRoute + '/status': typeof StatusRoute '/terms': typeof TermsRoute '/upload': typeof UploadRoute '/use-cases': typeof UseCasesRoute @@ -645,6 +671,7 @@ export interface FileRoutesByFullPath { '/account/usage': typeof AccountUsageRoute '/admin/accounts': typeof AdminAccountsRoute '/admin/customers': typeof AdminCustomersRoute + '/admin/funnel': typeof AdminFunnelRoute '/admin/meta-ads-pack': typeof AdminMetaAdsPackRoute '/admin/packages': typeof AdminPackagesRouteWithChildren '/admin/plans': typeof AdminPlansRoute @@ -653,7 +680,7 @@ export interface FileRoutesByFullPath { '/admin/review-audit': typeof AdminReviewAuditRoute '/admin/review-reports': typeof AdminReviewReportsRoute '/agents/md': typeof AgentsMdRoute - '/api/mcp': typeof ApiMcpRoute + '/api/mcp': typeof ApiMcpRouteWithChildren '/api/telemetry': typeof ApiTelemetryRoute '/bounties/$id': typeof BountiesIdRoute '/bounties/new': typeof BountiesNewRoute @@ -678,6 +705,7 @@ export interface FileRoutesByFullPath { '/admin/import/github': typeof AdminImportGithubRoute '/admin/import/markdown': typeof AdminImportMarkdownRoute '/admin/packages/new': typeof AdminPackagesNewRoute + '/api/mcp/health': typeof ApiMcpHealthRoute '/api/public/packages': typeof ApiPublicPackagesRouteWithChildren '/api/public/search': typeof ApiPublicSearchRoute '/api/public/telemetry': typeof ApiPublicTelemetryRoute @@ -719,6 +747,7 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/match': typeof MatchRoute '/onboarding': typeof OnboardingRoute + '/play': typeof PlayRoute '/pricing': typeof PricingRoute '/privacy': typeof PrivacyRoute '/refunds': typeof RefundsRoute @@ -728,6 +757,7 @@ export interface FileRoutesByTo { '/sitemap.xml': typeof SitemapDotxmlRoute '/skill-of-the-week': typeof SkillOfTheWeekRoute '/skillforge': typeof SkillforgeRoute + '/status': typeof StatusRoute '/terms': typeof TermsRoute '/upload': typeof UploadRoute '/use-cases': typeof UseCasesRoute @@ -743,6 +773,7 @@ export interface FileRoutesByTo { '/account/usage': typeof AccountUsageRoute '/admin/accounts': typeof AdminAccountsRoute '/admin/customers': typeof AdminCustomersRoute + '/admin/funnel': typeof AdminFunnelRoute '/admin/meta-ads-pack': typeof AdminMetaAdsPackRoute '/admin/packages': typeof AdminPackagesRouteWithChildren '/admin/plans': typeof AdminPlansRoute @@ -751,7 +782,7 @@ export interface FileRoutesByTo { '/admin/review-audit': typeof AdminReviewAuditRoute '/admin/review-reports': typeof AdminReviewReportsRoute '/agents/md': typeof AgentsMdRoute - '/api/mcp': typeof ApiMcpRoute + '/api/mcp': typeof ApiMcpRouteWithChildren '/api/telemetry': typeof ApiTelemetryRoute '/bounties/$id': typeof BountiesIdRoute '/bounties/new': typeof BountiesNewRoute @@ -776,6 +807,7 @@ export interface FileRoutesByTo { '/admin/import/github': typeof AdminImportGithubRoute '/admin/import/markdown': typeof AdminImportMarkdownRoute '/admin/packages/new': typeof AdminPackagesNewRoute + '/api/mcp/health': typeof ApiMcpHealthRoute '/api/public/packages': typeof ApiPublicPackagesRouteWithChildren '/api/public/search': typeof ApiPublicSearchRoute '/api/public/telemetry': typeof ApiPublicTelemetryRoute @@ -819,6 +851,7 @@ export interface FileRoutesById { '/login': typeof LoginRoute '/match': typeof MatchRoute '/onboarding': typeof OnboardingRoute + '/play': typeof PlayRoute '/pricing': typeof PricingRoute '/privacy': typeof PrivacyRoute '/refunds': typeof RefundsRoute @@ -828,6 +861,7 @@ export interface FileRoutesById { '/sitemap.xml': typeof SitemapDotxmlRoute '/skill-of-the-week': typeof SkillOfTheWeekRoute '/skillforge': typeof SkillforgeRoute + '/status': typeof StatusRoute '/terms': typeof TermsRoute '/upload': typeof UploadRoute '/use-cases': typeof UseCasesRoute @@ -843,6 +877,7 @@ export interface FileRoutesById { '/account/usage': typeof AccountUsageRoute '/admin/accounts': typeof AdminAccountsRoute '/admin/customers': typeof AdminCustomersRoute + '/admin/funnel': typeof AdminFunnelRoute '/admin/meta-ads-pack': typeof AdminMetaAdsPackRoute '/admin/packages': typeof AdminPackagesRouteWithChildren '/admin/plans': typeof AdminPlansRoute @@ -851,7 +886,7 @@ export interface FileRoutesById { '/admin/review-audit': typeof AdminReviewAuditRoute '/admin/review-reports': typeof AdminReviewReportsRoute '/agents/md': typeof AgentsMdRoute - '/api/mcp': typeof ApiMcpRoute + '/api/mcp': typeof ApiMcpRouteWithChildren '/api/telemetry': typeof ApiTelemetryRoute '/bounties/$id': typeof BountiesIdRoute '/bounties/new': typeof BountiesNewRoute @@ -876,6 +911,7 @@ export interface FileRoutesById { '/admin/import/github': typeof AdminImportGithubRoute '/admin/import/markdown': typeof AdminImportMarkdownRoute '/admin/packages/new': typeof AdminPackagesNewRoute + '/api/mcp/health': typeof ApiMcpHealthRoute '/api/public/packages': typeof ApiPublicPackagesRouteWithChildren '/api/public/search': typeof ApiPublicSearchRoute '/api/public/telemetry': typeof ApiPublicTelemetryRoute @@ -920,6 +956,7 @@ export interface FileRouteTypes { | '/login' | '/match' | '/onboarding' + | '/play' | '/pricing' | '/privacy' | '/refunds' @@ -929,6 +966,7 @@ export interface FileRouteTypes { | '/sitemap.xml' | '/skill-of-the-week' | '/skillforge' + | '/status' | '/terms' | '/upload' | '/use-cases' @@ -944,6 +982,7 @@ export interface FileRouteTypes { | '/account/usage' | '/admin/accounts' | '/admin/customers' + | '/admin/funnel' | '/admin/meta-ads-pack' | '/admin/packages' | '/admin/plans' @@ -977,6 +1016,7 @@ export interface FileRouteTypes { | '/admin/import/github' | '/admin/import/markdown' | '/admin/packages/new' + | '/api/mcp/health' | '/api/public/packages' | '/api/public/search' | '/api/public/telemetry' @@ -1018,6 +1058,7 @@ export interface FileRouteTypes { | '/login' | '/match' | '/onboarding' + | '/play' | '/pricing' | '/privacy' | '/refunds' @@ -1027,6 +1068,7 @@ export interface FileRouteTypes { | '/sitemap.xml' | '/skill-of-the-week' | '/skillforge' + | '/status' | '/terms' | '/upload' | '/use-cases' @@ -1042,6 +1084,7 @@ export interface FileRouteTypes { | '/account/usage' | '/admin/accounts' | '/admin/customers' + | '/admin/funnel' | '/admin/meta-ads-pack' | '/admin/packages' | '/admin/plans' @@ -1075,6 +1118,7 @@ export interface FileRouteTypes { | '/admin/import/github' | '/admin/import/markdown' | '/admin/packages/new' + | '/api/mcp/health' | '/api/public/packages' | '/api/public/search' | '/api/public/telemetry' @@ -1117,6 +1161,7 @@ export interface FileRouteTypes { | '/login' | '/match' | '/onboarding' + | '/play' | '/pricing' | '/privacy' | '/refunds' @@ -1126,6 +1171,7 @@ export interface FileRouteTypes { | '/sitemap.xml' | '/skill-of-the-week' | '/skillforge' + | '/status' | '/terms' | '/upload' | '/use-cases' @@ -1141,6 +1187,7 @@ export interface FileRouteTypes { | '/account/usage' | '/admin/accounts' | '/admin/customers' + | '/admin/funnel' | '/admin/meta-ads-pack' | '/admin/packages' | '/admin/plans' @@ -1174,6 +1221,7 @@ export interface FileRouteTypes { | '/admin/import/github' | '/admin/import/markdown' | '/admin/packages/new' + | '/api/mcp/health' | '/api/public/packages' | '/api/public/search' | '/api/public/telemetry' @@ -1217,6 +1265,7 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRoute MatchRoute: typeof MatchRoute OnboardingRoute: typeof OnboardingRoute + PlayRoute: typeof PlayRoute PricingRoute: typeof PricingRoute PrivacyRoute: typeof PrivacyRoute RefundsRoute: typeof RefundsRoute @@ -1226,6 +1275,7 @@ export interface RootRouteChildren { SitemapDotxmlRoute: typeof SitemapDotxmlRoute SkillOfTheWeekRoute: typeof SkillOfTheWeekRoute SkillforgeRoute: typeof SkillforgeRoute + StatusRoute: typeof StatusRoute TermsRoute: typeof TermsRoute UploadRoute: typeof UploadRoute UseCasesRoute: typeof UseCasesRoute @@ -1240,7 +1290,7 @@ export interface RootRouteChildren { AccountTokensRoute: typeof AccountTokensRoute AccountUsageRoute: typeof AccountUsageRoute AgentsMdRoute: typeof AgentsMdRoute - ApiMcpRoute: typeof ApiMcpRoute + ApiMcpRoute: typeof ApiMcpRouteWithChildren ApiTelemetryRoute: typeof ApiTelemetryRoute CollectionsSlugRoute: typeof CollectionsSlugRoute ComparePairRoute: typeof ComparePairRoute @@ -1308,6 +1358,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof TermsRouteImport parentRoute: typeof rootRouteImport } + '/status': { + id: '/status' + path: '/status' + fullPath: '/status' + preLoaderRoute: typeof StatusRouteImport + parentRoute: typeof rootRouteImport + } '/skillforge': { id: '/skillforge' path: '/skillforge' @@ -1371,6 +1428,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PricingRouteImport parentRoute: typeof rootRouteImport } + '/play': { + id: '/play' + path: '/play' + fullPath: '/play' + preLoaderRoute: typeof PlayRouteImport + parentRoute: typeof rootRouteImport + } '/onboarding': { id: '/onboarding' path: '/onboarding' @@ -1693,6 +1757,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminMetaAdsPackRouteImport parentRoute: typeof AdminRoute } + '/admin/funnel': { + id: '/admin/funnel' + path: '/funnel' + fullPath: '/admin/funnel' + preLoaderRoute: typeof AdminFunnelRouteImport + parentRoute: typeof AdminRoute + } '/admin/customers': { id: '/admin/customers' path: '/customers' @@ -1819,6 +1890,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiPublicPackagesRouteImport parentRoute: typeof rootRouteImport } + '/api/mcp/health': { + id: '/api/mcp/health' + path: '/health' + fullPath: '/api/mcp/health' + preLoaderRoute: typeof ApiMcpHealthRouteImport + parentRoute: typeof ApiMcpRoute + } '/admin/packages/new': { id: '/admin/packages/new' path: '/new' @@ -1977,6 +2055,7 @@ const AdminPackagesRouteWithChildren = AdminPackagesRoute._addFileChildren( interface AdminRouteChildren { AdminAccountsRoute: typeof AdminAccountsRoute AdminCustomersRoute: typeof AdminCustomersRoute + AdminFunnelRoute: typeof AdminFunnelRoute AdminMetaAdsPackRoute: typeof AdminMetaAdsPackRoute AdminPackagesRoute: typeof AdminPackagesRouteWithChildren AdminPlansRoute: typeof AdminPlansRoute @@ -1992,6 +2071,7 @@ interface AdminRouteChildren { const AdminRouteChildren: AdminRouteChildren = { AdminAccountsRoute: AdminAccountsRoute, AdminCustomersRoute: AdminCustomersRoute, + AdminFunnelRoute: AdminFunnelRoute, AdminMetaAdsPackRoute: AdminMetaAdsPackRoute, AdminPackagesRoute: AdminPackagesRouteWithChildren, AdminPlansRoute: AdminPlansRoute, @@ -2081,6 +2161,17 @@ const DotwellKnownOauthProtectedResourceRouteWithChildren = DotwellKnownOauthProtectedResourceRouteChildren, ) +interface ApiMcpRouteChildren { + ApiMcpHealthRoute: typeof ApiMcpHealthRoute +} + +const ApiMcpRouteChildren: ApiMcpRouteChildren = { + ApiMcpHealthRoute: ApiMcpHealthRoute, +} + +const ApiMcpRouteWithChildren = + ApiMcpRoute._addFileChildren(ApiMcpRouteChildren) + interface PacksSlugRouteChildren { PacksSlugCustomizeRoute: typeof PacksSlugCustomizeRoute } @@ -2121,6 +2212,7 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRoute, MatchRoute: MatchRoute, OnboardingRoute: OnboardingRoute, + PlayRoute: PlayRoute, PricingRoute: PricingRoute, PrivacyRoute: PrivacyRoute, RefundsRoute: RefundsRoute, @@ -2130,6 +2222,7 @@ const rootRouteChildren: RootRouteChildren = { SitemapDotxmlRoute: SitemapDotxmlRoute, SkillOfTheWeekRoute: SkillOfTheWeekRoute, SkillforgeRoute: SkillforgeRoute, + StatusRoute: StatusRoute, TermsRoute: TermsRoute, UploadRoute: UploadRoute, UseCasesRoute: UseCasesRoute, @@ -2146,7 +2239,7 @@ const rootRouteChildren: RootRouteChildren = { AccountTokensRoute: AccountTokensRoute, AccountUsageRoute: AccountUsageRoute, AgentsMdRoute: AgentsMdRoute, - ApiMcpRoute: ApiMcpRoute, + ApiMcpRoute: ApiMcpRouteWithChildren, ApiTelemetryRoute: ApiTelemetryRoute, CollectionsSlugRoute: CollectionsSlugRoute, ComparePairRoute: ComparePairRoute, diff --git a/src/routes/admin.funnel.tsx b/src/routes/admin.funnel.tsx new file mode 100644 index 00000000..0dd3e8cf --- /dev/null +++ b/src/routes/admin.funnel.tsx @@ -0,0 +1,151 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; +import { useServerFn } from "@tanstack/react-start"; +import { useState } from "react"; +import { SitePage } from "@/components/site/SitePage"; +import { getMcpFunnelSummary } from "@/lib/admin/funnel.functions"; + +export const Route = createFileRoute("/admin/funnel")({ + head: () => ({ + meta: [ + { title: "MCP funnel — Admin" }, + { name: "robots", content: "noindex" }, + ], + }), + component: AdminFunnelPage, +}); + +// Ordered logically — top of funnel → bottom. Events not in this list +// still get rendered (sorted to the end) so a new event we forget to +// add to the order doesn't silently disappear. +const EVENT_ORDER = [ + "connect_viewed", + "install_button_clicked", + "oauth_authorize_viewed", + "oauth_authorize_approved", + "oauth_authorize_denied", + "oauth_success_shown", + "oauth_loopback_attempted", + "oauth_scheme_triggered", + "oauth_manual_code_copied", + "mcp_first_call", + "mcp_first_write", + "pat_minted", +]; + +const EVENT_LABEL: Record = { + connect_viewed: "Viewed /connect", + install_button_clicked: "Clicked install button", + oauth_authorize_viewed: "Saw consent screen", + oauth_authorize_approved: "Clicked Authorize", + oauth_authorize_denied: "Clicked Deny", + oauth_success_shown: "Saw success screen", + oauth_loopback_attempted: "Loopback delivery attempted", + oauth_scheme_triggered: "Deep-link triggered", + oauth_manual_code_copied: "Copied manual code", + mcp_first_call: "First tools/call", + mcp_first_write: "First write tool", + pat_minted: "Minted a PAT", +}; + +function AdminFunnelPage() { + const fn = useServerFn(getMcpFunnelSummary); + const [days, setDays] = useState(7); + const q = useQuery({ + queryKey: ["admin", "mcp-funnel", days], + queryFn: () => fn({ data: { days } }), + }); + + const top = q.data?.events.find((e) => e.event === "connect_viewed")?.count ?? 0; + function rate(c: number): string { + if (!top) return "—"; + return `${((c / top) * 100).toFixed(1)}%`; + } + + const ordered = (q.data?.events ?? []).slice().sort((a, b) => { + const ai = EVENT_ORDER.indexOf(a.event); + const bi = EVENT_ORDER.indexOf(b.event); + if (ai === -1 && bi === -1) return b.count - a.count; + if (ai === -1) return 1; + if (bi === -1) return -1; + return ai - bi; + }); + + return ( + +
+

Admin

+

MCP funnel

+

+ Connect-flow telemetry over the last {days} days. Rates are calculated against{" "} + connect_viewed (top of funnel). +

+ +
+ {[1, 7, 30, 90].map((d) => ( + + ))} +
+ +
+ + + + + + + + + + + {q.isLoading && ( + + + + )} + {q.isError && ( + + + + )} + {ordered.map((e) => ( + + + + + + + ))} + {q.data && ordered.length === 0 && ( + + + + )} + +
StepCountDistinct% of top
+ Loading… +
+ Forbidden. Admin role required. +
+
{EVENT_LABEL[e.event] ?? e.event}
+
{e.event}
+
{e.count}{e.distinct_users} + {rate(e.count)} +
+ No events in this window yet. +
+
+
+
+ ); +} diff --git a/src/routes/api/mcp.ts b/src/routes/api/mcp.ts index d0c1eb81..08f4c73f 100644 --- a/src/routes/api/mcp.ts +++ b/src/routes/api/mcp.ts @@ -79,6 +79,34 @@ function withCors(res: Response): Response { return new Response(res.body, { status: res.status, statusText: res.statusText, headers }); } +/** + * Standard rate-limit headers (RFC 6585 + de-facto X-RateLimit-*). We emit + * these on every quota-bearing response — including 200s — so well-behaved + * clients can self-regulate and surface "X calls remaining" without having + * to wait for a 429. + */ +function withRateLimitHeaders( + res: Response, + quota: { limit?: number; used?: number; remaining?: number; reset_at?: string; window?: string } | null, +): Response { + if (!quota) return res; + const headers = new Headers(res.headers); + if (typeof quota.limit === "number") headers.set("X-RateLimit-Limit", String(quota.limit)); + const remaining = + typeof quota.remaining === "number" + ? quota.remaining + : typeof quota.limit === "number" && typeof quota.used === "number" + ? Math.max(0, quota.limit - quota.used) + : null; + if (remaining != null) headers.set("X-RateLimit-Remaining", String(remaining)); + if (quota.reset_at) { + const epoch = Math.floor(new Date(quota.reset_at).getTime() / 1000); + if (!Number.isNaN(epoch)) headers.set("X-RateLimit-Reset", String(epoch)); + } + if (quota.window) headers.set("X-RateLimit-Window", quota.window); + return new Response(res.body, { status: res.status, statusText: res.statusText, headers }); +} + /** Try OAuth tokens first, then fall back to legacy MCP personal tokens. */ async function verifyBearer(token: string): Promise<{ user_id: string; source: "oauth" | "pat" } | null> { // OAuth access token @@ -226,6 +254,7 @@ async function handle(request: Request): Promise { // Quota gate. Skip discovery / lifecycle methods (initialize, tools/list, ping…) // and ultra-cheap tools, since they're needed just to bootstrap the session. + let lastQuota: any = null; if (isToolsCall && !FREE_TOOLS.has(toolName)) { const identity = quotaIdentity(userId, request); const isWrite = WRITE_TOOLS.has(toolName); @@ -235,19 +264,36 @@ async function handle(request: Request): Promise { _tool_name: toolName, _is_write: isWrite, } as never); + if (!quotaErr) lastQuota = quota ?? null; if (!quotaErr && quota && quota.allowed === false) { - return rateLimited(quota, rpcId); + return withRateLimitHeaders(rateLimited(quota, rpcId), quota); } } - if (userId && authSource) { - return withCors( - await mcp.handleRequest(request, { - auth: { token, claims: { user_id: userId, source: authSource } }, - }), - ); + // Funnel telemetry: fire-and-forget record of the first successful + // tools/call this caller has made. The RPC itself dedupes on event + + // (user_id | anon_hash) so we don't need to check here. + if (isToolsCall) { + const identity = quotaIdentity(userId, request); + const anonHash = userId ? null : identity.replace(/^ip:/, ""); + void supabaseAdmin + .rpc("record_mcp_funnel_event", { + _event: WRITE_TOOLS.has(toolName) ? "mcp_first_write" : "mcp_first_call", + _client_id: null, + _client_name: null, + _anon_hash: anonHash, + _props: { tool: toolName, auth_source: authSource }, + } as never) + .then(() => {}, () => {}); } - return withCors(await mcp.handleRequest(request)); + + const handled = + userId && authSource + ? await mcp.handleRequest(request, { + auth: { token, claims: { user_id: userId, source: authSource } }, + }) + : await mcp.handleRequest(request); + return withRateLimitHeaders(withCors(handled), lastQuota); } export const Route = createFileRoute("/api/mcp")({ diff --git a/src/routes/api/mcp/health.ts b/src/routes/api/mcp/health.ts new file mode 100644 index 00000000..c7bdb873 --- /dev/null +++ b/src/routes/api/mcp/health.ts @@ -0,0 +1,60 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { supabaseAdmin as _supabaseAdmin } from "@/integrations/supabase/client.server"; +import { CORS_HEADERS } from "@/lib/oauth/mcp-oauth.server"; +const supabaseAdmin = _supabaseAdmin as any; + +// Public health endpoint for the MCP service. Returns 200 with a JSON +// payload describing service status (db reachable + reported latency) +// and a build version, plus CORS so dashboards / clients can hit it +// from anywhere. We do not gate this behind auth — knowing whether the +// service is up is not sensitive, and 401/403 on a health check is +// exactly the wrong UX for status pages. +const VERSION = "1.5.0"; +const STARTED_AT = Date.now(); + +async function probe(): Promise<{ ok: boolean; ping_ms: number | null }> { + const t0 = Date.now(); + try { + // Lightest possible read — count(*) HEAD against a small table. + const { error } = await supabaseAdmin + .from("mcp_oauth_clients") + .select("client_id", { count: "exact", head: true }) + .limit(1); + if (error) return { ok: false, ping_ms: null }; + return { ok: true, ping_ms: Date.now() - t0 }; + } catch { + return { ok: false, ping_ms: null }; + } +} + +async function handle(): Promise { + const db = await probe(); + const body = { + ok: db.ok, + version: VERSION, + uptime_seconds: Math.floor((Date.now() - STARTED_AT) / 1000), + db: { ok: db.ok, ping_ms: db.ping_ms }, + endpoint: "https://superagentskill.com/api/mcp", + docs: "https://superagentskill.com/docs/mcp", + status_page: "https://superagentskill.com/status", + checked_at: new Date().toISOString(), + }; + return new Response(JSON.stringify(body), { + status: db.ok ? 200 : 503, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + ...CORS_HEADERS, + }, + }); +} + +export const Route = createFileRoute("/api/mcp/health")({ + server: { + handlers: { + OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }), + GET: async () => handle(), + HEAD: async () => handle(), + }, + }, +}); diff --git a/src/routes/connect.tsx b/src/routes/connect.tsx index bf6fc6f2..72a25391 100644 --- a/src/routes/connect.tsx +++ b/src/routes/connect.tsx @@ -1,5 +1,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { useServerFn } from "@tanstack/react-start"; +import { recordFunnelEvent } from "@/lib/telemetry/funnel.functions"; import { ArrowRight, Check, Copy, Plug, Sparkles, Search, Upload, Terminal, Zap } from "lucide-react"; import { SitePage } from "@/components/site/SitePage"; import { CodeBlock } from "@/components/site/CodeBlock"; @@ -494,6 +496,10 @@ function PromptGrid({ prompts }: { prompts: Prompt[] }) { function ConnectPage() { const [activeClient, setActiveClient] = useState(CLIENTS[0].id); const client = CLIENTS.find((c) => c.id === activeClient) ?? CLIENTS[0]; + const track = useServerFn(recordFunnelEvent); + useEffect(() => { + void track({ data: { event: "connect_viewed" } }).catch(() => {}); + }, [track]); return ( diff --git a/src/routes/oauth.authorize.tsx b/src/routes/oauth.authorize.tsx index 5434eae3..870e1ea2 100644 --- a/src/routes/oauth.authorize.tsx +++ b/src/routes/oauth.authorize.tsx @@ -5,6 +5,7 @@ import { z } from "zod"; import { Nav } from "@/components/site/Nav"; import { useAuth } from "@/hooks/use-auth"; import { getOauthClient, issueOauthCode } from "@/lib/oauth/mcp-oauth.functions"; +import { recordFunnelEvent } from "@/lib/telemetry/funnel.functions"; const ParamsSchema = z.object({ client_id: z.string().min(3), @@ -34,6 +35,7 @@ function AuthorizePage() { const navigate = useNavigate(); const fetchClient = useServerFn(getOauthClient); const consent = useServerFn(issueOauthCode); + const track = useServerFn(recordFunnelEvent); const [client, setClient] = useState< | { client_id: string; client_name: string; redirect_uris: string[]; scope: string; client_uri?: string | null } @@ -44,9 +46,18 @@ function AuthorizePage() { useEffect(() => { fetchClient({ data: { client_id: params.client_id } }) - .then((c) => setClient(c)) + .then((c) => { + setClient(c); + void track({ + data: { + event: "oauth_authorize_viewed", + client_id: params.client_id, + client_name: c?.client_name, + }, + }).catch(() => {}); + }) .catch((e) => setError(e instanceof Error ? e.message : "Unknown error")); - }, [params.client_id, fetchClient]); + }, [params.client_id, fetchClient, track]); const redirectOk = client?.redirect_uris.includes(params.redirect_uri); @@ -75,6 +86,13 @@ function AuthorizePage() { code_challenge_method: "S256", }, }); + void track({ + data: { + event: "oauth_authorize_approved", + client_id: params.client_id, + client_name: client?.client_name, + }, + }).catch(() => {}); // Hand off to /oauth/success via sessionStorage so the auth code // never lands in the URL bar / history of the consent tab. The // success page handles delivery to loopback listeners, private-use @@ -108,6 +126,13 @@ function AuthorizePage() { setError("Invalid redirect URI — cannot deny safely."); return; } + void track({ + data: { + event: "oauth_authorize_denied", + client_id: params.client_id, + client_name: client?.client_name, + }, + }).catch(() => {}); window.location.href = buildDeniedUrl(); } diff --git a/src/routes/oauth.success.tsx b/src/routes/oauth.success.tsx index 2c5b4972..c73b8f27 100644 --- a/src/routes/oauth.success.tsx +++ b/src/routes/oauth.success.tsx @@ -1,6 +1,8 @@ import { createFileRoute, Link } from "@tanstack/react-router"; +import { useServerFn } from "@tanstack/react-start"; import { useEffect, useMemo, useRef, useState } from "react"; import { Nav } from "@/components/site/Nav"; +import { recordFunnelEvent } from "@/lib/telemetry/funnel.functions"; // We stash the sensitive payload (redirect_to URL + raw auth code) in // sessionStorage rather than putting it in the URL bar, so the auth code @@ -52,14 +54,18 @@ function SuccessPage() { const [handoff, setHandoff] = useState(null); const [copied, setCopied] = useState(false); const deliveredRef = useRef(false); + const track = useServerFn(recordFunnelEvent); useEffect(() => { const h = readHandoff(); setHandoff(h); - // One-shot: clear immediately so a refresh of this URL doesn't - // re-replay the loopback delivery. - if (h) window.sessionStorage.removeItem(STORAGE_KEY); - }, []); + if (h) { + window.sessionStorage.removeItem(STORAGE_KEY); + void track({ + data: { event: "oauth_success_shown", client_name: h.client_name }, + }).catch(() => {}); + } + }, [track]); const kind = useMemo( () => (handoff ? classifyRedirect(handoff.redirect_uri) : null), @@ -71,6 +77,9 @@ function SuccessPage() { deliveredRef.current = true; if (kind === "loopback") { + void track({ + data: { event: "oauth_loopback_attempted", client_name: handoff.client_name }, + }).catch(() => {}); // Fire-and-forget GET to the local listener so the CLI/desktop // client picks up the code. We do not navigate the tab — that's // exactly what produced the broken "site can't be reached" page @@ -85,6 +94,9 @@ function SuccessPage() { return; } if (kind === "private-scheme") { + void track({ + data: { event: "oauth_scheme_triggered", client_name: handoff.client_name }, + }).catch(() => {}); // Trigger the OS deep link. If the app is registered, the user // pops back into Claude/Cursor/etc. If not, the browser stays on // this page (which is fine — we show the manual fallback). @@ -105,6 +117,9 @@ function SuccessPage() { await navigator.clipboard.writeText(handoff.code); setCopied(true); setTimeout(() => setCopied(false), 1800); + void track({ + data: { event: "oauth_manual_code_copied", client_name: handoff.client_name }, + }).catch(() => {}); } catch { /* clipboard may be denied; the code is still visible */ } diff --git a/src/routes/play.tsx b/src/routes/play.tsx new file mode 100644 index 00000000..1cdaeb1c --- /dev/null +++ b/src/routes/play.tsx @@ -0,0 +1,98 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { Sparkles, Zap } from "lucide-react"; +import { SitePage } from "@/components/site/SitePage"; +import { McpTester } from "@/components/site/McpTester"; + +export const Route = createFileRoute("/play")({ + head: () => ({ + meta: [ + { title: "Playground — Super Agent Skill MCP" }, + { + name: "description", + content: + "Try every Super Agent Skill MCP tool in your browser — no install, no JSON, no auth needed for reads. Connect via OAuth to test write tools.", + }, + { property: "og:title", content: "Try the MCP server in your browser — Super Agent Skill" }, + ], + }), + component: PlaygroundPage, +}); + +function PlaygroundPage() { + return ( + +
+

+ Playground +

+

+ Try the MCP server in your browser. +

+

+ Pick a tool, fill the args, hit run. Read tools work anonymously; write tools need a + token. Same endpoint your agents hit (/api/mcp), + same shape, same rate limits — nothing to install. +

+ +
+ + + +
+ +
+ +
+ +
+

+ Like what you see? +

+

+ Hook your real agent up in seconds — one-click installs and per-client guides at{" "} + + /connect + + , or generate a token at{" "} + + /account/tokens + {" "} + and paste it as a Bearer header anywhere. +

+
+
+
+ ); +} + +function Highlight({ + icon: Icon, + title, + body, +}: { + icon: React.ComponentType<{ className?: string }>; + title: string; + body: string; +}) { + return ( +
+
+ + {title} +
+

{body}

+
+ ); +} diff --git a/src/routes/status.tsx b/src/routes/status.tsx new file mode 100644 index 00000000..fcc840a4 --- /dev/null +++ b/src/routes/status.tsx @@ -0,0 +1,140 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { SitePage } from "@/components/site/SitePage"; + +type Health = { + ok: boolean; + version: string; + uptime_seconds: number; + db: { ok: boolean; ping_ms: number | null }; + endpoint: string; + checked_at: string; +}; + +export const Route = createFileRoute("/status")({ + head: () => ({ + meta: [ + { title: "Status — Super Agent Skill" }, + { + name: "description", + content: + "Live status of the Super Agent Skill MCP endpoint and database. Public health endpoint at /api/mcp/health.", + }, + ], + }), + component: StatusPage, +}); + +function formatUptime(s: number): string { + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ${m % 60}m`; + return `${Math.floor(h / 24)}d ${h % 24}h`; +} + +function StatusPage() { + const [health, setHealth] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + async function tick() { + try { + const r = await fetch("/api/mcp/health", { cache: "no-store" }); + const j = (await r.json()) as Health; + if (!cancelled) { + setHealth(j); + setError(null); + } + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : "fetch failed"); + } + } + void tick(); + const id = setInterval(tick, 15_000); + return () => { + cancelled = true; + clearInterval(id); + }; + }, []); + + const ok = !!health?.ok && !error; + + return ( + +
+ Status +

+ Super Agent Skill MCP +

+

+ Live health of the MCP endpoint at{" "} + /api/mcp. The raw JSON probe is at{" "} + /api/mcp/health — wire it into your own + status dashboard if you want to be notified before we are. +

+ +
+
+ + + {ok ? "All systems operational" : error ? "Status check failed" : "Degraded"} + +
+ {error && ( +

+ Could not reach /api/mcp/health: {error} +

+ )} +
+ +
+ + + + + +
+ +

+ This page polls /api/mcp/health every 15 seconds. Incidents are reported on the + GitHub repo — subscribe to releases there for the canonical history. +

+
+
+ ); +} + +function Stat({ label, value, ok }: { label: string; value: string; ok?: boolean }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} diff --git a/supabase/migrations/20260524000000_mcp_funnel_and_idempotency.sql b/supabase/migrations/20260524000000_mcp_funnel_and_idempotency.sql new file mode 100644 index 00000000..c30af50e --- /dev/null +++ b/supabase/migrations/20260524000000_mcp_funnel_and_idempotency.sql @@ -0,0 +1,174 @@ +-- Slice 1 of the MCP UX roadmap: server-side funnel telemetry and +-- idempotency keys for MCP write tools. Both backed by simple tables +-- with public RPCs so anonymous and authenticated callers can use them +-- (rate-limited by IP for the anonymous case). + +-- --------------------------------------------------------------------- +-- mcp_funnel_events: append-only event log we use to figure out where +-- users drop out of the connect flow. Names are deliberately a +-- closed vocabulary (CHECK constraint) so we don't end up with the +-- typical "127 spellings of the same event" telemetry mess. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS public.mcp_funnel_events ( + id BIGSERIAL PRIMARY KEY, + event TEXT NOT NULL, + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + -- Stable, hashed (sha256 hex) so we can count uniques without + -- storing PII / raw IPs. + anon_hash TEXT, + client_id TEXT, + client_name TEXT, + props JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT mcp_funnel_events_event_known CHECK (event IN ( + 'connect_viewed', + 'oauth_authorize_viewed', + 'oauth_authorize_approved', + 'oauth_authorize_denied', + 'oauth_success_shown', + 'oauth_loopback_attempted', + 'oauth_manual_code_copied', + 'oauth_scheme_triggered', + 'mcp_first_call', + 'mcp_first_write', + 'install_button_clicked', + 'pat_minted' + )) +); + +CREATE INDEX IF NOT EXISTS mcp_funnel_events_event_created_idx + ON public.mcp_funnel_events (event, created_at DESC); +CREATE INDEX IF NOT EXISTS mcp_funnel_events_user_idx + ON public.mcp_funnel_events (user_id, created_at DESC) + WHERE user_id IS NOT NULL; + +ALTER TABLE public.mcp_funnel_events ENABLE ROW LEVEL SECURITY; +-- Only admins can read events directly. Writes go through the RPC. +CREATE POLICY mcp_funnel_events_admin_read + ON public.mcp_funnel_events FOR SELECT + USING (EXISTS ( + SELECT 1 FROM public.user_roles + WHERE user_id = auth.uid() AND role = 'admin' + )); + +-- Public RPC used by browser/server callers to record an event. We +-- rate-limit anonymous callers per anon_hash to keep this from +-- becoming a spam vector. +CREATE OR REPLACE FUNCTION public.record_mcp_funnel_event( + _event TEXT, + _client_id TEXT DEFAULT NULL, + _client_name TEXT DEFAULT NULL, + _anon_hash TEXT DEFAULT NULL, + _props JSONB DEFAULT '{}'::jsonb +) RETURNS VOID +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + _uid UUID := auth.uid(); + _recent_count INT; +BEGIN + -- Cheap throttle: anonymous callers can record at most 60 events per + -- minute per anon_hash. Signed-in users are trusted (auth is the rate + -- limit). NULL anon_hash from a signed-in user is fine. + IF _uid IS NULL AND _anon_hash IS NOT NULL THEN + SELECT count(*) INTO _recent_count + FROM public.mcp_funnel_events + WHERE anon_hash = _anon_hash + AND created_at > now() - interval '1 minute'; + IF _recent_count >= 60 THEN + RETURN; -- silently drop; never fail the user's flow on telemetry + END IF; + END IF; + + INSERT INTO public.mcp_funnel_events ( + event, user_id, anon_hash, client_id, client_name, props + ) VALUES ( + _event, _uid, _anon_hash, _client_id, _client_name, COALESCE(_props, '{}'::jsonb) + ); +EXCEPTION WHEN OTHERS THEN + -- Telemetry must NEVER break user flows. + RETURN; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.record_mcp_funnel_event(TEXT, TEXT, TEXT, TEXT, JSONB) + TO anon, authenticated; + +-- Read-only aggregate the admin funnel page queries. Lives in SQL so +-- we don't ship a query that does a full table scan on every refresh. +CREATE OR REPLACE FUNCTION public.mcp_funnel_summary(_days INT DEFAULT 7) +RETURNS TABLE (event TEXT, count BIGINT, distinct_users BIGINT) +LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT + e.event, + count(*) AS count, + count(DISTINCT COALESCE(e.user_id::text, e.anon_hash)) AS distinct_users + FROM public.mcp_funnel_events e + WHERE e.created_at > now() - make_interval(days => GREATEST(_days, 1)) + AND EXISTS ( + SELECT 1 FROM public.user_roles + WHERE user_id = auth.uid() AND role = 'admin' + ) + GROUP BY e.event + ORDER BY count DESC; +$$; + +GRANT EXECUTE ON FUNCTION public.mcp_funnel_summary(INT) TO authenticated; + +-- --------------------------------------------------------------------- +-- mcp_idempotency: per-user idempotency keys for MCP write tools so +-- agents that retry a failed upload don't end up creating duplicates. +-- The key is hashed (sha256 hex) so a leaked key still doesn't leak +-- the response payload directly via casual snooping. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS public.mcp_idempotency ( + key_hash TEXT NOT NULL, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + tool TEXT NOT NULL, + response JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + interval '24 hours'), + PRIMARY KEY (user_id, key_hash, tool) +); + +CREATE INDEX IF NOT EXISTS mcp_idempotency_expires_idx + ON public.mcp_idempotency (expires_at); + +ALTER TABLE public.mcp_idempotency ENABLE ROW LEVEL SECURITY; +-- Direct access blocked; only the SECURITY DEFINER RPCs below touch it. + +CREATE OR REPLACE FUNCTION public.mcp_idempotency_get( + _user_id UUID, _key_hash TEXT, _tool TEXT +) RETURNS JSONB +LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT response + FROM public.mcp_idempotency + WHERE user_id = _user_id + AND key_hash = _key_hash + AND tool = _tool + AND expires_at > now() + LIMIT 1; +$$; + +CREATE OR REPLACE FUNCTION public.mcp_idempotency_put( + _user_id UUID, _key_hash TEXT, _tool TEXT, _response JSONB +) RETURNS VOID +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +BEGIN + INSERT INTO public.mcp_idempotency (user_id, key_hash, tool, response) + VALUES (_user_id, _key_hash, _tool, _response) + ON CONFLICT (user_id, key_hash, tool) DO NOTHING; + + -- Lazy GC: every put has a chance to evict expired rows. + IF random() < 0.05 THEN + DELETE FROM public.mcp_idempotency WHERE expires_at < now(); + END IF; +END; +$$; + +-- Server-only — these RPCs are intentionally NOT granted to anon / +-- authenticated. The MCP route calls them via the service role. + +COMMENT ON TABLE public.mcp_funnel_events IS + 'Append-only funnel telemetry for the MCP connect flow. Events use a closed vocabulary; anonymous callers are rate-limited to 60/min per anon_hash.'; +COMMENT ON TABLE public.mcp_idempotency IS + 'Per-user idempotency keys for MCP write tools (upload_packages, request_primitive). 24h TTL; lazy GC on writes.';