Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/components/site/InstallButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -74,6 +76,7 @@ const BUTTONS: ButtonSpec[] = [

export function InstallButtons({ compact = false }: { compact?: boolean }) {
const [clicked, setClicked] = useState<string | null>(null);
const track = useServerFn(recordFunnelEvent);
return (
<div className={compact ? "flex flex-wrap gap-2" : "grid gap-3 sm:grid-cols-3"}>
{BUTTONS.map((b) => (
Expand All @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions src/lib/account/tokens.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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 };
});
Expand Down
22 changes: 22 additions & 0 deletions src/lib/admin/funnel.functions.ts
Original file line number Diff line number Diff line change
@@ -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) =>
Comment on lines +6 to +8

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce admin middleware on funnel summary endpoint

This server function is labeled “Admin only” but only requires authentication, not admin role. As written, any signed-in user can call it; mcp_funnel_summary just filters rows by auth.uid() and returns an empty result instead of a 403, so /admin/funnel is not actually access-controlled as intended. Use the existing requireAdmin middleware (or an explicit role check) here.

Useful? React with 👍 / 👎.

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 }>,
};
});
58 changes: 58 additions & 0 deletions src/lib/mcp/idempotency.ts
Original file line number Diff line number Diff line change
@@ -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<unknown | null> {
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<void> {
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 */
}
}
48 changes: 39 additions & 9 deletions src/lib/mcp/tools/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
},
});

Expand Down Expand Up @@ -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(
Expand All @@ -278,26 +294,40 @@ 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)
return json({
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);
Comment on lines +312 to 318

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make idempotency check-and-write atomic

This flow checks cache first, performs the write, and only then stores the idempotent response. If two retries with the same idempotency_key arrive concurrently, both requests can miss the cache and both execute processBulkUpload, creating duplicates before either putIdempotent runs. To provide real idempotency under retry races, reserve the key atomically before the side effect (or move the whole operation into one transactional RPC).

Useful? React with 👍 / 👎.

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" });
}
Expand Down
55 changes: 55 additions & 0 deletions src/lib/telemetry/funnel.functions.ts
Original file line number Diff line number Diff line change
@@ -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];
Loading
Loading