Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3b596f3
docs(mcp): clarify Upstash provisioning via Vercel Marketplace (no se…
May 6, 2026
c8de376
docs(mcp): Phase 1 — resolve OQ #4 (Vercel team) and #5 (rate-limit b…
May 6, 2026
99be7df
chore(mcp): scaffold apps/mcp workspace
May 6, 2026
31000b9
feat(mcp): app shell + /api/health endpoint
May 6, 2026
56a277e
chore(mcp): Vercel config + complete .env.example + README
May 6, 2026
3e3956d
feat(mcp): MCP adapter entry point + empty tool registry
May 6, 2026
36ec6db
feat(mcp): Supabase service-role client singleton
May 6, 2026
0d8d633
feat(mcp): Solana RPC client wrapper
May 6, 2026
817b656
test(mcp): failing test for mintApiKey (red)
May 6, 2026
2a137d2
feat(mcp): mintApiKey + verifyApiKey (bcrypt)
May 6, 2026
09ca9d6
test(mcp): verifyApiKey + extractPrefix coverage
May 6, 2026
4562687
test(mcp): failing tests for auth middleware (red)
May 6, 2026
2f8166d
feat(mcp): Bearer auth middleware + typed errors
May 6, 2026
b0c7826
feat(mcp): Upstash rate limiters (createAccount, read, prepare) + IP …
May 6, 2026
a711cd7
test(mcp): failing tests for GitHub Device Flow client (red)
May 6, 2026
0b1dfac
feat(mcp): GitHub Device Flow client + AES-256-GCM token encryption
May 6, 2026
1082744
feat(mcp): gas-station sponsor client (delegates to frontend endpoint)
May 6, 2026
1a9a861
test(mcp): failing tests for create_account.init (red)
May 6, 2026
30fd2ed
feat(mcp): create_account.init handler + tool registration
May 6, 2026
738d0d7
test(mcp): failing tests for create_account.poll (red)
May 6, 2026
3e95bd3
feat(mcp): create_account.poll handler
May 6, 2026
3a06b20
test(mcp): failing tests for create_account.complete (red)
May 6, 2026
95a4814
feat(mcp): create_account.complete handler — full onboarding pipeline
May 6, 2026
6a75610
test(mcp): create_account.complete signature validation
May 6, 2026
a8409cd
feat(mcp): whoami tool
May 6, 2026
e42a181
feat(mcp): bounties.list tool
May 6, 2026
0298718
feat(mcp): bounties.get tool
May 6, 2026
8f94b8c
feat(mcp): submissions.get tool with role-based authz
May 6, 2026
3fdec9d
test(mcp): E2E onboarding init smoke + closes Phase 1 unit tests
May 6, 2026
868445e
docs(mcp): Vercel deploy config decision doc + manual provisioning steps
May 6, 2026
356f1d3
fix(mcp): accept KV_REST_API_* env vars from Vercel Upstash Marketplace
May 6, 2026
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
47 changes: 37 additions & 10 deletions apps/mcp/.env.example
Original file line number Diff line number Diff line change
@@ -1,17 +1,44 @@
# Phase 1 will populate this. Phase 0 just reserves the file.
# See docs/superpowers/specs/2026-05-05-ghbounty-mcp-server-design.md

# GitHub OAuth App for Device Flow
# Public; ships in env (production + preview).
GITHUB_OAUTH_CLIENT_ID=

# Secret; production env only. Backed up in 1Password.
GITHUB_OAUTH_CLIENT_SECRET=

# Same value as the existing frontend service-role key, but kept in a
# separate Vercel project so it can be rotated independently.
SUPABASE_SERVICE_ROLE_KEY=
# Supabase service-role key (separate from the frontend's; rotate independently)
SUPABASE_URL=
SUPABASE_SERVICE_ROLE_KEY=

# Solana RPC for tx-building (Helius mainnet or devnet).
# Solana RPC (Helius mainnet for production, devnet for preview/dev)
SOLANA_RPC_URL=

# Stake authority keypair (JSON array). Same format as GAS_STATION_KEYPAIR_JSON.
# Generated per Task 2 of Phase 0.
STAKE_AUTHORITY_KEYPAIR_JSON=
# Stake authority keypair (JSON array, 64 bytes). Same format as
# GAS_STATION_KEYPAIR_JSON. Used by the relayer-side cron jobs (Phase 4),
# NOT by the MCP server itself. Phase 1 doesn't read this — it's listed
# here for documentation completeness.
# STAKE_AUTHORITY_KEYPAIR_JSON=

# Upstash Redis for rate limiting — provisioned via Vercel Marketplace
# (Project Settings → Storage → Browse Marketplace → Upstash → Connect).
# Vercel auto-injects these env vars after the connection is created.
# Do NOT set them manually for production/preview deployments.
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=

# Frontend's gas-station endpoint (production: https://www.ghbounty.com/api/gas-station/sponsor)
GAS_STATION_SPONSOR_URL=

# Shared secret with the frontend's gas-station-route-core.ts. Phase 1 PR
# adds this to the frontend env too. Generate a random 64-char hex string.
GAS_STATION_SERVICE_TOKEN=

# Public; identifies this service in logs / agent welcome messages.
NEXT_PUBLIC_MCP_BASE_URL=https://mcp.ghbounty.com

# Public; same value as the frontend's GAS_STATION_PUBKEY env var. Used as
# the fee_payer in unsigned txs returned by prepare_* tools.
NEXT_PUBLIC_GAS_STATION_PUBKEY=

# Random 32-byte hex (or any string >= 32 chars). Used to AES-256-GCM
# encrypt GitHub access tokens at rest in agent_accounts.github_oauth_token_encrypted.
MCP_TOKEN_ENCRYPTION_KEY=
5 changes: 5 additions & 0 deletions apps/mcp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.next/
node_modules/
.env.local
.env*.local
next-env.d.ts
36 changes: 36 additions & 0 deletions apps/mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# @ghbounty/mcp

Public MCP server hosted at `https://mcp.ghbounty.com`. Lets any AI agent (Claude Code, Cursor, Codex, custom) sign up and operate the GhBounty marketplace autonomously.

## Architecture

- **Next.js 16** + Turbopack (matches the frontend stack)
- **`@vercel/mcp-adapter`** for the MCP transport (Streamable HTTP)
- **Supabase service-role** for DB writes; bypasses RLS, enforces equivalent policies in code
- **Upstash Redis** for rate limiting (provisioned via Vercel Marketplace, no separate Upstash account)
- **Helius RPC** for Solana
- **GitHub Device Flow** for agentic OAuth (no browser redirect needed)

## Local development

```bash
# 1. Copy the env template and fill in real values from 1Password
cp apps/mcp/.env.example apps/mcp/.env.local
# Edit apps/mcp/.env.local

# 2. Run the dev server
pnpm --filter @ghbounty/mcp dev

# 3. Health check
curl http://localhost:3001/api/health
```

## Deploy

The Vercel project is `ghbounty-mcp` in the `weareghbounty-6269` team. DNS for `mcp.ghbounty.com` is configured to point at this project. Pushes to `main` auto-deploy to production; PR branches get preview deployments.

Upstash Redis is provisioned via Vercel Marketplace (Project Settings → Storage → Browse Marketplace → Upstash → Connect). No upstash.com signup needed.

## Tools

See `lib/tools/` for the implementations. Surface and contracts documented in `docs/superpowers/specs/2026-05-05-ghbounty-mcp-server-design.md` section 6.
12 changes: 12 additions & 0 deletions apps/mcp/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const dynamic = "force-dynamic";

export async function GET() {
return new Response(
JSON.stringify({
ok: true,
service: "ghbounty-mcp",
timestamp: new Date().toISOString(),
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
25 changes: 25 additions & 0 deletions apps/mcp/app/api/mcp/[transport]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Public MCP endpoint. The dynamic route segment `[transport]` is
// `sse` for Streamable HTTP transport. Tools are registered by
// `lib/tools/register.ts`; this file is just the framework shell.

import { createMcpHandler } from "mcp-handler";
import { registerAllTools } from "@/lib/tools/register";

const handler = createMcpHandler(
async (server) => {
await registerAllTools(server);
},
{
capabilities: {
tools: {},
},
},
{
basePath: "/api/mcp",
}
);

export { handler as GET, handler as POST, handler as DELETE };

export const dynamic = "force-dynamic";
export const maxDuration = 60;
28 changes: 28 additions & 0 deletions apps/mcp/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
:root {
color-scheme: dark;
}
body {
margin: 0;
font-family: -apple-system, system-ui, sans-serif;
background: #0a0a0a;
color: #e5e5e5;
min-height: 100vh;
}
.container {
max-width: 720px;
margin: 80px auto;
padding: 0 24px;
}
code {
background: #1a1a1a;
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, monospace;
}
pre {
background: #1a1a1a;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
}
a { color: #00e5d1; }
17 changes: 17 additions & 0 deletions apps/mcp/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
title: "GhBounty MCP",
description: "Public MCP server for AI agents to operate the GhBounty marketplace.",
};

export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
18 changes: 18 additions & 0 deletions apps/mcp/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export default function Page() {
return (
<div className="container">
<h1>GhBounty MCP Server</h1>
<p>
This is the MCP endpoint for AI agents. Connect with the URL:
</p>
<pre>https://mcp.ghbounty.com/api/mcp/sse</pre>
<p>
Full docs:{" "}
<a href="https://www.ghbounty.com/agents">ghbounty.com/agents</a>
</p>
<p>
Health: <a href="/api/health">/api/health</a>
</p>
</div>
);
}
42 changes: 42 additions & 0 deletions apps/mcp/lib/auth/api-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// API key generation + verification. Format: `ghbk_live_<32 hex chars>`.
//
// Storage:
// - Plaintext is shown to the agent ONCE (response of create_account.complete).
// - bcrypt hash + first 12 chars (prefix) are stored in api_keys table.
// - Lookup is by prefix (indexed); bcrypt verifies on match.

import { randomBytes } from "node:crypto";
import bcrypt from "bcryptjs";

const PREFIX = "ghbk_live_";
const SECRET_HEX_LEN = 32; // 16 bytes → 32 hex chars
const PREFIX_HEX_LEN = 12; // first 12 chars of the hex part used as table lookup index
const BCRYPT_ROUNDS = 12;

export interface MintedKey {
/** Full plaintext key. Show to the agent ONCE; never store. */
plaintext: string;
/** First 12 hex chars (prefixed). Indexed in DB for O(1) lookup. */
prefix: string;
/** bcrypt hash. Store this in `api_keys.key_hash`. */
hash: string;
}

export function mintApiKey(): MintedKey {
const secret = randomBytes(SECRET_HEX_LEN / 2).toString("hex");
const plaintext = `${PREFIX}${secret}`;
const prefix = `${PREFIX}${secret.slice(0, PREFIX_HEX_LEN)}`;
const hash = bcrypt.hashSync(plaintext, BCRYPT_ROUNDS);
return { plaintext, prefix, hash };
}

export function extractPrefix(plaintext: string): string {
if (!plaintext.startsWith(PREFIX)) {
throw new Error("Invalid API key format");
}
return plaintext.slice(0, PREFIX.length + PREFIX_HEX_LEN);
}

export function verifyApiKey(plaintext: string, hash: string): boolean {
return bcrypt.compareSync(plaintext, hash);
}
84 changes: 84 additions & 0 deletions apps/mcp/lib/auth/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Bearer token authentication for MCP tool calls.
//
// Flow:
// 1. Parse `Authorization: Bearer <plaintext>` header.
// 2. Extract first 22 chars (prefix) for indexed DB lookup.
// 3. Fetch api_keys row + joined agent_accounts row.
// 4. bcrypt-verify the plaintext against key_hash.
// 5. Reject if revoked OR agent_account.status is not 'active'.
// 6. Return the agent for the tool to use.

import { extractPrefix, verifyApiKey } from "./api-key";
import { supabaseAdmin } from "@/lib/supabase/admin";
import type { AuthResult, AgentAccount } from "@/lib/tools/types";

export async function authenticate(
authorizationHeader: string | undefined
): Promise<AuthResult> {
if (!authorizationHeader || !authorizationHeader.startsWith("Bearer ")) {
return { ok: false, error: { code: "Unauthorized", message: "Missing or malformed Authorization header" } };
}

const plaintext = authorizationHeader.slice("Bearer ".length).trim();

let prefix: string;
try {
prefix = extractPrefix(plaintext);
} catch {
return { ok: false, error: { code: "Unauthorized", message: "Invalid API key format" } };
}

const supabase = supabaseAdmin();
const { data, error } = await supabase
.from("api_keys")
.select("id, key_hash, agent_account_id, agent_accounts(id, role, status, wallet_pubkey, github_handle)")
.eq("key_prefix", prefix)
.is("revoked_at", null)
.maybeSingle();

if (error) {
return { ok: false, error: { code: "Unauthorized", message: "Authentication lookup failed" } };
}
if (!data) {
return { ok: false, error: { code: "Unauthorized", message: "API key not found" } };
}

if (!verifyApiKey(plaintext, (data as any).key_hash)) {
return { ok: false, error: { code: "Unauthorized", message: "API key mismatch" } };
}

// The Supabase typed-join syntax returns agent_accounts as either an object
// or a single-element array depending on the relationship. Normalize.
const rawAgent = (data as any).agent_accounts;
const agentRow = Array.isArray(rawAgent) ? rawAgent[0] : rawAgent;
if (!agentRow) {
return { ok: false, error: { code: "Unauthorized", message: "Agent record missing" } };
}

if (agentRow.status !== "active") {
return {
ok: false,
error: {
code: "Forbidden",
message: `Agent account is ${agentRow.status}, not active`,
},
};
}

const agent: AgentAccount = {
id: agentRow.id,
role: agentRow.role,
status: agentRow.status,
wallet_pubkey: agentRow.wallet_pubkey,
github_handle: agentRow.github_handle,
};

// Async: update last_used_at without blocking the response.
supabase
.from("api_keys")
.update({ last_used_at: new Date().toISOString() })
.eq("id", (data as any).id)
.then(() => {});

return { ok: true, agent, apiKeyId: (data as any).id };
}
28 changes: 28 additions & 0 deletions apps/mcp/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Typed errors matching the spec's error model. Each tool returns these
// to the MCP transport, which formats them as JSON-RPC errors.

export type McpErrorCode =
| "BlockhashExpired"
| "WalletInsufficientFunds"
| "InvalidSignature"
| "WrongSigner"
| "TxTampered"
| "ProgramError"
| "RateLimited"
| "Unauthorized"
| "Forbidden"
| "NotFound"
| "Conflict"
| "RpcError"
| "InternalError"
| "InvalidInput";

export interface McpError {
code: McpErrorCode;
message: string;
details?: unknown;
}

export function mcpError(code: McpErrorCode, message: string, details?: unknown): McpError {
return { code, message, details };
}
Loading
Loading