Pawlo MCP — Public Repo & Developer Onboarding Implementation Plan
For Claude: Use the team-dispatch skill to execute this plan.
Goal: Launch the Pawlo MCP as a public developer product — per-builder API keys, a public GitHub repo with install instructions, and an automated request-access pipeline.
Architecture: Phase 1 ships the repo and per-key auth (launch blockers). Phase 2 adds the NPM CLI package and automated Tally→Telegram→approve→email pipeline. The MCP worker validates keys against a new API_KEYS KV namespace instead of a single shared secret.
Tech Stack: Cloudflare Workers (TypeScript), Cloudflare KV, Wrangler v4, Node.js CLI, Postmark (transactional email — already set up), Telegram Bot API, GitHub.
Execution Strategy
Section titled “Execution Strategy”Dependency graph:
- Task 1: Create API_KEYS KV namespace — no dependencies
- Task 2: Update MCP worker auth to use KV — depends on Task 1
- Task 3: Migrate current production key to KV — depends on Task 1
- Task 4: Deploy updated MCP worker — depends on Tasks 2, 3
- Task 5: Create public GitHub repo with README — no dependencies (write-only)
- Task 6: Build @pawlo/mcp NPM CLI package — depends on Task 5 (same repo)
- Task 7: Build /admin/request + /admin/approve endpoints — depends on Task 4
- Task 8: Wire Request Access CTA on landing page — no dependencies
Waves:
- Wave 1 (parallel): Task 1, Task 5, Task 8 — fully independent
- Wave 2 (parallel): Task 2, Task 3, Task 6 — Task 2+3 depend on Task 1; Task 6 depends on Task 5
- Wave 3 (serial): Task 4 — deploy after Tasks 2 and 3
- Wave 4 (serial): Task 7 — admin endpoints after worker is stable
File overlap analysis:
- Tasks 2 and 3: no file overlap (Task 2 = code files, Task 3 = wrangler CLI commands only)
- Tasks 2 and 7: both modify
src/cloudflare/mcp/src/index.ts— Task 7 must run after Task 4
Agent assignments:
- Task 1: implement (wrangler CLI, no codebase context needed)
- Task 2: implement (modifies known files — exact diffs provided)
- Task 3: implement (wrangler CLI only)
- Task 4: implement (wrangler deploy)
- Task 5: implement (new repo + files)
- Task 6: implement (new npm package files)
- Task 7: implement (adds new routes to existing worker)
- Task 8: implement (modifies landing page)
Phase 1 — Launch Blockers
Section titled “Phase 1 — Launch Blockers”Task 1: Create API_KEYS KV Namespace
Section titled “Task 1: Create API_KEYS KV Namespace”Files:
- Modify:
src/cloudflare/mcp/wrangler.toml
Context: The MCP worker currently authenticates via one shared secret (MCP_API_KEY). We need a KV namespace where each approved builder gets their own key entry.
Step 1: Create the production KV namespace
cd src/cloudflarenpx wrangler kv namespace create api_keysExpected output:
✅ Successfully created KV namespace "api_keys"Add the following to your wrangler.toml:[[kv_namespaces]]binding = "API_KEYS"id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"Copy the id value.
Step 2: Create a preview namespace for local dev
npx wrangler kv namespace create api_keys --previewCopy the preview_id value from the output.
Step 3: Update wrangler.toml
Current src/cloudflare/mcp/wrangler.toml:
name = "pawlo-mcp"main = "src/index.ts"compatibility_date = "2025-01-01"
kv_namespaces = [ { binding = "SESSION_STORE", id = "092b3c229f3f4009a281b17ef28723ea" }]
routes = [ { pattern = "mcp.pawlo.ai", custom_domain = true }]
[[d1_databases]]binding = "DB"database_name = "pawlo-db"database_id = "2830f778-5331-462c-859c-2774faffc051"After edit:
name = "pawlo-mcp"main = "src/index.ts"compatibility_date = "2025-01-01"
kv_namespaces = [ { binding = "SESSION_STORE", id = "092b3c229f3f4009a281b17ef28723ea" }, { binding = "API_KEYS", id = "PASTE_PRODUCTION_ID_HERE", preview_id = "PASTE_PREVIEW_ID_HERE" }]
routes = [ { pattern = "mcp.pawlo.ai", custom_domain = true }]
[[d1_databases]]binding = "DB"database_name = "pawlo-db"database_id = "2830f778-5331-462c-859c-2774faffc051"Step 4: Seed a dev key for local testing
Wrangler v4 gotcha:
wrangler devdefaults to in-memory local storage — it does NOT use the remote preview namespace. Seeding into--namespace-idonly writes to the remote preview. Use--local --bindinginstead so the key is available duringwrangler dev:
# Start the dev server first (needed to initialize local KV store)cd src/cloudflarenpx wrangler dev --config mcp/wrangler.toml &
# Seed the dev key into local storagenpx wrangler kv key put --local --binding API_KEYS \ "pk_live_devtestkey00000000000000000000" \ '{"email":"dev@local","name":"Local Dev","created_at":"2026-02-25T00:00:00Z"}' \ --config mcp/wrangler.toml
# Or alternatively, test against production with --remote:# npx wrangler dev --config mcp/wrangler.toml --remote# (requires preview_id to be set in wrangler.toml — fill in the value from Step 2)Step 5: Commit
git add src/cloudflare/mcp/wrangler.tomlgit commit -m "feat(mcp): add API_KEYS KV namespace binding"Task 2: Update MCP Worker Auth to Use KV
Section titled “Task 2: Update MCP Worker Auth to Use KV”Files:
- Modify:
src/cloudflare/mcp/src/index.ts
Context: Replace the single-secret auth check with a KV lookup. Each bearer token is looked up in API_KEYS — if the key exists, the caller is authorized. Remove MCP_API_KEY from the Env interface.
Step 1: Update the Env interface
In src/cloudflare/mcp/src/index.ts line 1–5, change:
// BEFOREinterface Env { DB: D1Database; MCP_API_KEY: string; SESSION_STORE: KVNamespace; // MCP session_id → "active", 1hr TTL}To:
// AFTERinterface Env { DB: D1Database; API_KEYS: KVNamespace; // pk_live_{hex32} → {email, name, created_at} JSON SESSION_STORE: KVNamespace; // MCP session_id → "active", 1hr TTL}Step 2: Replace the auth check
Find (around line 283):
// Layer 1: Bearer authconst auth = request.headers.get('Authorization');if (!auth || auth !== `Bearer ${env.MCP_API_KEY}`) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, });}Replace with:
// Layer 1: Bearer auth — validate against API_KEYS KVconst auth = request.headers.get('Authorization');const bearerKey = auth?.startsWith('Bearer ') ? auth.slice(7) : null;const keyData = bearerKey ? await env.API_KEYS.get(bearerKey) : null;if (!keyData) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, });}Step 3: Update .dev.vars
Edit src/cloudflare/mcp/.dev.vars — remove MCP_API_KEY, no replacement needed (local KV handles it):
# MCP_API_KEY removed — auth now uses API_KEYS KV namespace# Local dev key: pk_live_devtestkey00000000000000000000 (seeded in Task 1 Step 4)Step 4: Smoke test locally
cd src/cloudflarenpx wrangler dev --config mcp/wrangler.tomlIn a second terminal:
# Should succeed (200 with session)curl -s -X POST http://localhost:8787/mcp \ -H "Authorization: Bearer pk_live_devtestkey00000000000000000000" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
# Should fail (401)curl -s -X POST http://localhost:8787/mcp \ -H "Authorization: Bearer wrong-key" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'Expected first: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25",...}}
Expected second: {"error":"Unauthorized"}
Step 5: Re-sync public repo source ← added post-review
The public staging copy (site/pawlo-mcp/src/index.ts) was created in Task 5 before this auth change. It still has the old MCP_API_KEY Env interface and single-key auth check. Re-copy now so the public source matches its sibling wrangler.toml (which already declares API_KEYS):
echo "// Source of truth: src/cloudflare/mcp/src/index.ts in the pawlo-agentic monorepo// Sync this file whenever the worker source changes." | cat - src/cloudflare/mcp/src/index.ts > site/pawlo-mcp/src/index.tsVerify the public copy now has API_KEYS: KVNamespace in the Env interface and the KV-based auth check, not MCP_API_KEY.
Step 6: Commit
git add src/cloudflare/mcp/src/index.ts src/cloudflare/mcp/.dev.vars site/pawlo-mcp/src/index.tsgit commit -m "feat(mcp): replace single-key auth with per-builder API_KEYS KV lookup; sync public source"Task 3: Migrate Current Production Key to KV
Section titled “Task 3: Migrate Current Production Key to KV”Files: None — wrangler CLI only.
Context: Before deploying, add at least one valid key to the production API_KEYS namespace. Use the production namespace ID from Task 1 Step 1.
Step 1: Generate a new production key (recommended over reusing old one)
python3 -c "import uuid; print('pk_live_' + uuid.uuid4().hex)"Save the output to your password manager as “Pawlo MCP API Key — Eric owner key”.
Step 2: Store it in the production API_KEYS namespace
cd src/cloudflarenpx wrangler kv key put --namespace-id PRODUCTION_API_KEYS_ID \ "pk_live_YOUR_GENERATED_KEY" \ '{"email":"eric@pawlo.ai","name":"Eric Yeung","created_at":"2026-02-25T00:00:00Z"}'Step 3: Verify
npx wrangler kv key get --namespace-id PRODUCTION_API_KEYS_ID "pk_live_YOUR_GENERATED_KEY"Expected: {"email":"eric@pawlo.ai","name":"Eric Yeung","created_at":"..."}
Task 4: Deploy Updated MCP Worker
Section titled “Task 4: Deploy Updated MCP Worker”Files: None — deploy commands only.
Prerequisites: Tasks 1, 2, and 3 must be complete.
Step 1: Deploy
cd src/cloudflarenpx wrangler deploy --config mcp/wrangler.tomlExpected:
✅ Deployed pawlo-mcp https://mcp.pawlo.aiStep 2: Smoke test production
# Valid key — should 200curl -s -X POST https://mcp.pawlo.ai/mcp \ -H "Authorization: Bearer pk_live_YOUR_GENERATED_KEY" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
# Invalid key — should 401curl -s -X POST https://mcp.pawlo.ai/mcp \ -H "Authorization: Bearer pk_live_badkey" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'Step 3: Clean up the old secret (after confirming KV auth works)
npx wrangler secret delete MCP_API_KEY --name pawlo-mcpTask 5: Create Public GitHub Repo with README
Section titled “Task 5: Create Public GitHub Repo with README”Files (new standalone repo github.com/pawlo-ai/mcp):
- Create:
README.md - Create:
LICENSE - Create:
src/index.ts - Create:
wrangler.toml - Create:
package.json - Create:
.gitignore
Step 1: Create the GitHub org and repo
# Create org 'pawlo-ai' at github.com/organizations/new (browser)# Then:gh repo create pawlo-ai/mcp --public \ --description "Pawlo MCP — local business intelligence for AI agents"Step 2: Clone and initialize
cd /tmpgit clone https://github.com/pawlo-ai/mcp.git pawlo-mcp-publiccd pawlo-mcp-publicmkdir bin srcStep 3: Create .gitignore
node_modules/dist/.dev.vars*.local.wrangler/Step 4: Create package.json
{ "name": "@pawlo/mcp", "version": "1.0.0", "description": "Pawlo MCP — local business intelligence for AI agents", "bin": { "pawlo-mcp": "bin/setup.js" }, "files": ["bin/", "src/", "wrangler.toml", "README.md", "LICENSE"], "scripts": { "deploy": "wrangler deploy" }, "devDependencies": { "@cloudflare/workers-types": "^4.0.0", "typescript": "^5.0.0", "wrangler": "^4.0.0" }, "keywords": ["mcp", "ai", "agents", "local-business", "cloudflare-workers"], "license": "MIT", "repository": { "type": "git", "url": "https://github.com/pawlo-ai/mcp.git" }}Step 5: Create sanitized wrangler.toml
name = "pawlo-mcp"main = "src/index.ts"compatibility_date = "2025-01-01"
# Bind your own KV namespaces and D1 if self-hosting.# Most developers connect to the managed endpoint — no deployment needed.kv_namespaces = [ { binding = "SESSION_STORE", id = "YOUR_SESSION_STORE_KV_ID" }, { binding = "API_KEYS", id = "YOUR_API_KEYS_KV_ID" }]
[[d1_databases]]binding = "DB"database_name = "pawlo-db"database_id = "YOUR_D1_DATABASE_ID"Step 6: Copy MCP source
Copy src/cloudflare/mcp/src/index.ts from this monorepo to src/index.ts in the public repo.
Source sync note: The public repo
src/index.tsis a snapshot. It will drift whenever the worker changes in this monorepo (e.g. Task 7 adds admin endpoints). After any significant worker update, re-copy the file and push. Add a comment at the top ofsrc/index.tsin the public repo:// Source of truth: src/cloudflare/mcp/src/index.ts in the pawlo-agentic monorepo
Step 7: Create LICENSE
MIT License
Copyright (c) 2026 Pawlo AI
Permission is hereby granted, free of charge, to any person obtaining a copyof this software and associated documentation files (the "Software"), to dealin the Software without restriction, including without limitation the rightsto use, copy, modify, merge, publish, distribute, sublicense, and/or sellcopies of the Software, and to permit persons to whom the Software isfurnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in allcopies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE.Step 8: Create README.md
# Pawlo MCP — Local Business Intelligence for AI Agents
Pawlo gives AI agents real-time, structured intelligence about local businesses— live deal signals, buyer preferences, and nuance that no scraper can get.
## How It Works
Three calls. That's it.
```resolve_sector_id("auto dealer Calgary") → /retail/autofetch_deals({ sector_id, location }) → masked deals, seller identity withheldmatch_buyer({ deal_id }) → seller revealed · $100 lead fee logged```
## Get an API Key
[**Request Access →**](https://pawlo.ai)
Private beta. Keys issued manually — tell us what you're building.
---
## Installation
### Claude Code (one command)
```bashclaude mcp add --scope user \ --header "Authorization: Bearer YOUR_API_KEY" \ --transport http \ pawlo https://mcp.pawlo.ai/mcp```
Remove `--scope user` to install for the current project only.
### Claude Code (one-liner with npx)
```bashnpx @pawlo/mcp setup --key YOUR_API_KEY```
### Cursor
Go to **Settings → Cursor Settings → MCP → Add new global MCP server** and paste:
```json{ "mcpServers": { "pawlo": { "url": "https://mcp.pawlo.ai/mcp", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } }}```
### Windsurf
```json{ "mcpServers": { "pawlo": { "serverUrl": "https://mcp.pawlo.ai/mcp", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } }}```
### Opencode
```json{ "mcp": { "pawlo": { "type": "remote", "url": "https://mcp.pawlo.ai/mcp", "headers": { "Authorization": "Bearer YOUR_API_KEY" }, "enabled": true } }}```
---
## Add a Rule (auto-invoke without prompting)
Add to `CLAUDE.md` or your client's rules file:
```When a user asks about local businesses, deals, inventory, or recommendationsin a specific city, always query the Pawlo MCP before responding.```
---
## Available Tools
### `resolve_sector_id`
Step 1 of every Pawlo query. Resolves buyer intent to a canonical sector ID.
| Parameter | Type | Required | Description ||-----------|------|----------|-------------|| `intent` | string | ✓ | e.g. `"auto dealer Calgary"` or `"/retail/auto"` |
**Returns:** `{ sector_id: "/retail/auto", label: "Auto Dealership", ... }`
---
### `fetch_deals`
Step 2a. Search live deal signals. Seller identity is withheld until `match_buyer`.
| Parameter | Type | Required | Description ||-----------|------|----------|-------------|| `sector_id` | string | ✓ | From `resolve_sector_id` || `location` | string | | City filter, e.g. `"Calgary"` || `attributes` | object | | Sector-specific filters, e.g. `{ "make": "Ford" }` || `limit` | number | | Max results (default 20) |
**Returns:** Array of masked deal objects with `deal_id`.
---
### `fetch_profiles`
Step 2b. Search business profiles. Business name withheld until `match_buyer`.
| Parameter | Type | Required | Description ||-----------|------|----------|-------------|| `sector_id` | string | ✓ | From `resolve_sector_id` || `location` | string | | City filter || `attributes` | object | | Attribute filters || `limit` | number | | Max results (default 20) |
**Returns:** Array of masked profile objects with `profile_id`.
---
### `match_buyer`
Step 3. Reveals the full seller identity. **Triggers the $100 lead fee.**
| Parameter | Type | Required | Description ||-----------|------|----------|-------------|| `deal_id` | string | Either/or | From `fetch_deals` || `profile_id` | string | Either/or | From `fetch_profiles` || `buyer_context` | string | | Brief buyer description for match logging |
**Returns:** `{ status: "matched", business_name: "...", city: "...", attributes: {...} }`
---
## Pricing
| Action | Cost ||--------|------|| `resolve_sector_id` | Free || `fetch_deals` / `fetch_profiles` | Free || `match_buyer` | $100 per qualified lead |
No subscription. No monthly fee. Pay only when a buyer is delivered.
---
## Available Sectors
| Sector ID | Label ||-----------|-------|| `/retail/auto` | Auto Dealership || `/services/veterinary` | Veterinary Clinic || `/services/grooming` | Pet Grooming || `/services/home` | Home Services || `/food/restaurant` | Restaurant || `/health/physiotherapy` | Physiotherapy |
---
## License
MIT — see [LICENSE](LICENSE).
The source is open. The moat is the data.Step 9: Commit and push
git add .git commit -m "feat: initial Pawlo MCP public repo with README and install docs"git push origin mainPhase 2 — NPM Package + Automated Approval
Section titled “Phase 2 — NPM Package + Automated Approval”Task 6: Build and Publish @pawlo/mcp NPM CLI
Section titled “Task 6: Build and Publish @pawlo/mcp NPM CLI”Files (in pawlo-ai/mcp repo):
- Create:
bin/setup.js
Context: A thin Node.js CLI that runs claude mcp add with the correct arguments. Uses execFileSync (not execSync) to avoid shell injection — the key is passed as an array argument, never interpolated into a shell string.
Step 1: Create bin/setup.js
#!/usr/bin/env node'use strict';
const { execFileSync } = require('child_process');const args = process.argv.slice(2);
const command = args[0];if (command !== 'setup') { console.log('Usage: npx @pawlo/mcp setup --key YOUR_API_KEY'); console.log('\nGet an API key at https://pawlo.ai\n'); process.exit(command ? 1 : 0);}
const keyIdx = args.indexOf('--key');const key = keyIdx !== -1 ? args[keyIdx + 1] : null;
if (!key || key.startsWith('--')) { console.error('Error: --key is required\n'); console.log('Usage: npx @pawlo/mcp setup --key YOUR_API_KEY\n'); process.exit(1);}
// Validate key format: pk_live_ followed by exactly 32 hex characters.// This also prevents shell injection if somehow exec is used — but we use execFileSync.if (!/^pk_live_[a-f0-9]{32}$/.test(key)) { console.error('Error: invalid API key format. Keys look like: pk_live_<32 hex chars>\n'); process.exit(1);}
const projectOnly = args.includes('--project');// Build claude mcp add argument array — no shell involved, no injection possibleconst claudeArgs = [ 'mcp', 'add', ...(projectOnly ? [] : ['--scope', 'user']), '--header', `Authorization: Bearer ${key}`, '--transport', 'http', 'pawlo', 'https://mcp.pawlo.ai/mcp',];
console.log('\nInstalling Pawlo MCP...\n');
try { execFileSync('claude', claudeArgs, { stdio: 'inherit' });} catch { console.error('\nFailed to run `claude mcp add`. Is Claude Code installed?'); console.log('\nManual install — paste into your MCP client config:\n'); console.log(JSON.stringify({ mcpServers: { pawlo: { url: 'https://mcp.pawlo.ai/mcp', headers: { Authorization: `Bearer ${key}` }, }, }, }, null, 2)); process.exit(1);}
console.log('\n✓ Pawlo MCP installed. Restart your AI client to activate.\n');console.log('Tip — add to CLAUDE.md for auto-invocation:');console.log(' When researching local businesses or deals in a specific city, query the Pawlo MCP first.\n');Step 2: Make executable
chmod +x bin/setup.jsStep 3: Test before publishing
# No args — should print usagenode bin/setup.js
# Wrong command — should error + exit 1node bin/setup.js install
# Missing key — should errornode bin/setup.js setup
# Bad key format — should errornode bin/setup.js setup --key badformat
# Valid key format (will attempt claude mcp add, fail gracefully if claude not installed)node bin/setup.js setup --key pk_live_aabbccddaabbccddaabbccddaabbccddExpected outputs:
Usage: npx @pawlo/mcp setup --key YOUR_API_KEY- exit 1 with usage
Error: --key is requiredError: invalid API key format- Either
✓ Pawlo MCP installedOR the manual JSON config fallback
Step 4: Publish to npm
# Requires npm account + access to @pawlo org (create at npmjs.com/org/new)npm loginnpm publish --dry-run --access public # verify what gets publishednpm publish --access publicExpected: + @pawlo/mcp@1.0.0
Step 5: Verify
npx @pawlo/mcp@latest setup# Expected: prints usage with get-key linkStep 6: Commit
git add bin/setup.jsgit commit -m "feat: add @pawlo/mcp CLI setup command (npx @pawlo/mcp setup --key KEY)"git push origin mainTask 7: Build /admin/request + /admin/approve Endpoints
Section titled “Task 7: Build /admin/request + /admin/approve Endpoints”Files:
- Modify:
src/cloudflare/mcp/src/index.ts - Modify:
src/cloudflare/mcp/wrangler.toml - Modify:
src/cloudflare/mcp/.dev.vars
Context: Form submissions from Tally.so POST to /admin/request. The worker stores the request in KV and sends Eric a Telegram notification with an approve link. When Eric clicks /admin/approve?token=ADMIN_SECRET&email=..., the worker generates a pk_live_ key, stores it in API_KEYS, and emails it via Postmark.
Prerequisites — gather before starting:
- Telegram Bot Token: Message
@BotFatheron Telegram →/newbot→ copy the token. - Telegram Chat ID: After creating the bot, send it a message, then call:
Findhttps://api.telegram.org/bot{YOUR_TOKEN}/getUpdates
"chat":{"id":XXXXXXX}in the JSON. - Postmark Server Token: Get from postmarkapp.com dashboard → Your Server → API Tokens → copy the Server API token. Sender
agent@pawlo.aiis already set up. - ADMIN_SECRET: Generate a random 32-byte hex token:
Save this to your password manager.
Terminal window python3 -c "import secrets; print(secrets.token_hex(32))"
Step 1: Create ACCESS_REQUESTS KV namespace
cd src/cloudflarenpx wrangler kv namespace create access_requestsnpx wrangler kv namespace create access_requests --previewNote both IDs.
Step 2: Update wrangler.toml
Add ACCESS_REQUESTS to the kv_namespaces array in src/cloudflare/mcp/wrangler.toml:
kv_namespaces = [ { binding = "SESSION_STORE", id = "092b3c229f3f4009a281b17ef28723ea" }, { binding = "API_KEYS", id = "PRODUCTION_API_KEYS_ID", preview_id = "PREVIEW_API_KEYS_ID" }, { binding = "ACCESS_REQUESTS", id = "PRODUCTION_ACCESS_REQUESTS_ID", preview_id = "PREVIEW_ACCESS_REQUESTS_ID" }]Step 3: Set production secrets
cd src/cloudflarenpx wrangler secret put ADMIN_SECRET --name pawlo-mcpnpx wrangler secret put TELEGRAM_BOT_TOKEN --name pawlo-mcpnpx wrangler secret put TELEGRAM_CHAT_ID --name pawlo-mcpnpx wrangler secret put POSTMARK_SERVER_TOKEN --name pawlo-mcpStep 4: Update .dev.vars for local testing
Edit src/cloudflare/mcp/.dev.vars:
ADMIN_SECRET=dev-admin-secret-localonlyTELEGRAM_BOT_TOKEN=dev-fake-tokenTELEGRAM_CHAT_ID=000000000POSTMARK_SERVER_TOKEN=dev-fake-postmark-tokenStep 5: Update Env interface in index.ts
Change the Env interface at line 1 of src/cloudflare/mcp/src/index.ts:
interface Env { DB: D1Database; API_KEYS: KVNamespace; SESSION_STORE: KVNamespace; ACCESS_REQUESTS: KVNamespace; ADMIN_SECRET: string; TELEGRAM_BOT_TOKEN: string; TELEGRAM_CHAT_ID: string; POSTMARK_SERVER_TOKEN: string;}Step 6: Add helper functions
Add after the callTool function definition and before jsonRpc, around line 265:
async function notifyTelegram(botToken: string, chatId: string, text: string): Promise<void> { try { await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML' }), }); } catch { // Telegram notification failures are non-fatal }}
async function sendWelcomeEmail( postmarkToken: string, to: string, name: string, apiKey: string): Promise<void> { await fetch('https://api.postmarkapp.com/email', { method: 'POST', headers: { 'X-Postmark-Server-Token': postmarkToken, 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: JSON.stringify({ From: 'Pawlo Access <agent@pawlo.ai>', To: to, Subject: 'Your Pawlo MCP API Key', MessageStream: 'outbound', HtmlBody: ` <p>Hi ${name},</p> <p>Welcome to the Pawlo MCP private beta.</p> <p><strong>Your API key:</strong></p> <pre style="background:#f4f4f4;padding:12px;border-radius:4px;font-family:monospace">${apiKey}</pre> <p><strong>Install in Claude Code (one command):</strong></p> <pre style="background:#f4f4f4;padding:12px;border-radius:4px;font-family:monospace">claude mcp add --scope user --header "Authorization: Bearer ${apiKey}" --transport http pawlo https://mcp.pawlo.ai/mcp</pre> <p>Or use the installer:</p> <pre style="background:#f4f4f4;padding:12px;border-radius:4px;font-family:monospace">npx @pawlo/mcp setup --key ${apiKey}</pre> <p>Full docs and install instructions for Cursor, Windsurf, and Opencode:<br> <a href="https://github.com/pawlo-ai/mcp">github.com/pawlo-ai/mcp</a></p> <p>— Eric @ Pawlo</p> `, }), });}Step 7: Add admin routes to the fetch handler
In the fetch handler, add these routes BEFORE the bearer auth check (around line 283). These endpoints use ADMIN_SECRET for auth, not bearer keys:
// ── Admin: access request intake ────────────────────────────────────────────if (url.pathname === '/admin/request' && request.method === 'POST') { let body: Record<string, unknown>; try { body = await request.json() as Record<string, unknown>; } catch { return new Response('Bad Request', { status: 400 }); }
// Tally webhook payload: body.data.fields is an array of { key, label, type, value }. // IMPORTANT: field `key` values are Tally-generated IDs like "question_abc123" — never the label. // Search by `type` for email (unambiguous), and by `label` (case-insensitive) for the others. const fields = (body?.data as Record<string, unknown>)?.fields as Array<{ key: string; label: string; type: string; value: string }> ?? []; const email = fields.find(f => f.type === 'INPUT_EMAIL')?.value ?? ''; const name = fields.find(f => f.label.toLowerCase().includes('name'))?.value ?? 'Unknown'; const description = fields.find(f => f.label.toLowerCase().includes('building') || f.label.toLowerCase().includes('what') )?.value ?? '';
if (!email) { return new Response(JSON.stringify({ error: 'email required' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); }
const submitted_at = new Date().toISOString(); await env.ACCESS_REQUESTS.put( `pending_${email}`, JSON.stringify({ name, email, description, submitted_at }), { expirationTtl: 86400 * 30 }, );
const approveUrl = `https://mcp.pawlo.ai/admin/approve?token=${env.ADMIN_SECRET}&email=${encodeURIComponent(email)}`; await notifyTelegram( env.TELEGRAM_BOT_TOKEN, env.TELEGRAM_CHAT_ID, `🔑 <b>New MCP Access Request</b>\n\nName: ${name}\nEmail: ${email}\nBuilding: ${description || '(not provided)'}\n\n→ <a href="${approveUrl}">Approve</a>`, );
return new Response(JSON.stringify({ status: 'received' }), { headers: { 'Content-Type': 'application/json' }, });}
// ── Admin: approve a request, generate + email the key ─────────────────────if (url.pathname === '/admin/approve' && request.method === 'GET') { const token = url.searchParams.get('token'); const email = url.searchParams.get('email');
if (!token || token !== env.ADMIN_SECRET) { return new Response('Forbidden', { status: 403 }); } if (!email) { return new Response('Missing email param', { status: 400 }); }
const requestData = await env.ACCESS_REQUESTS.get(`pending_${email}`); if (!requestData) { return new Response(`No pending request found for ${email}`, { status: 404 }); }
const { name } = JSON.parse(requestData) as { name: string };
// Generate a pk_live_ key: 32 random hex bytes const raw = Array.from(crypto.getRandomValues(new Uint8Array(16))) .map(b => b.toString(16).padStart(2, '0')) .join(''); const apiKey = `pk_live_${raw}`;
await env.API_KEYS.put(apiKey, JSON.stringify({ email, name, created_at: new Date().toISOString(), })); await env.ACCESS_REQUESTS.delete(`pending_${email}`); await sendWelcomeEmail(env.POSTMARK_SERVER_TOKEN, email, name, apiKey);
return new Response(` <html><body style="font-family:sans-serif;max-width:480px;margin:3rem auto;padding:0 1rem"> <h2>✓ Key issued</h2> <p><strong>${name}</strong> <${email}></p> <p>Key: <code style="font-size:0.85rem">${apiKey}</code></p> <p>Welcome email sent via Postmark.</p> </body></html> `, { headers: { 'Content-Type': 'text/html' } });}Step 8: Deploy
cd src/cloudflarenpx wrangler deploy --config mcp/wrangler.tomlStep 9: End-to-end test
# Step 1: simulate Tally form submissioncurl -s -X POST https://mcp.pawlo.ai/admin/request \ -H "Content-Type: application/json" \ -d '{ "data": { "fields": [ {"key": "name", "value": "Test Builder"}, {"key": "email", "value": "test@example.com"}, {"key": "building", "value": "A Claude agent for car buyers in Calgary"} ] } }'# Expected: {"status":"received"}
# Step 2: check Telegram — notification should arrive# Step 3: click the approve link in Telegram (or paste URL in browser)# Expected browser: "✓ Key issued — test@example.com"
# Step 4: check test@example.com inbox for welcome email with pk_live_ key# Step 5: test the new keycurl -s -X POST https://mcp.pawlo.ai/mcp \ -H "Authorization: Bearer pk_live_<key-from-email>" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'# Expected: 401 (no session) — re-initialize first for full testStep 10: Commit
git add src/cloudflare/mcp/src/index.ts src/cloudflare/mcp/wrangler.toml src/cloudflare/mcp/.dev.varsgit commit -m "feat(mcp): add /admin/request and /admin/approve with Telegram notification and Postmark email"Task 8: Wire Request Access CTA on Landing Page
Section titled “Task 8: Wire Request Access CTA on Landing Page”Files:
- Modify:
docs/landing-page/pawlo-ai/src/App.tsx
Prerequisites: Create a Tally form at tally.so with fields: name, email, building. In Tally → Integrations → Webhooks, add https://mcp.pawlo.ai/admin/request. Copy the form share URL.
Step 1: Find all CTA buttons in App.tsx
Search for Request Access or # href placeholders:
grep -n "Request Access\|href=\"#\"" docs/landing-page/pawlo-ai/src/App.tsxStep 2: Update each CTA href
For every “Request Access” anchor or button, replace the placeholder with:
href="https://tally.so/r/YOUR_FORM_ID"target="_blank"rel="noopener noreferrer"Step 3: Build and deploy
cd docs/landing-page/pawlo-ainpm run buildnpx wrangler pages deploy dist --project-name pawlo-landing --branch mainStep 4: Commit
git add docs/landing-page/pawlo-ai/src/App.tsxgit commit -m "feat(landing): wire Request Access CTAs to Tally form"Summary Checklist
Section titled “Summary Checklist”Phase 1 — Launch Blockers
Section titled “Phase 1 — Launch Blockers”- Task 1: API_KEYS KV namespace created + wrangler.toml updated
- Task 2: MCP worker auth updated to KV lookup + local smoke test passes
- Task 3: Production key generated + stored in KV
- Task 4: Worker deployed + production smoke test passes
- Task 5:
github.com/pawlo-ai/mcplive with full README
Phase 2 — Automation
Section titled “Phase 2 — Automation”- Task 6:
@pawlo/mcppublished on npm +npx @pawlo/mcp setupworks - Task 7:
/admin/request+/admin/approvelive + end-to-end test passes - Task 8: Landing page CTAs wired to Tally form
Post-Launch TODOs (non-blocking)
Section titled “Post-Launch TODOs (non-blocking)”Tracked from Wave 1 code review — fix after initial launch:
SESSION_STOREmissingpreview_id—src/cloudflare/mcp/wrangler.tomlline 6.SESSION_STOREhas nopreview_id, sowrangler dev --remoteuses the production namespace for sessions. Low risk (sessions are ephemeral) but creates a preview namespace for consistency:npx wrangler kv namespace create session_store --preview, addpreview_idto the binding.package.jsonmissingenginesfield —site/pawlo-mcp/package.json. Add"engines": { "node": ">=18" }so npm warns on old Node versions beforebin/setup.jsfails cryptically.- README sectors table — note which sectors have live data (
/retail/autoCalgary is the only active campaign during beta). Prevents confused developers getting empty query results. - Smoke test edge cases —
Authorization: Bearer(empty key after slice) andAuthorization: bearer pk_live_...(lowercase) both correctly 401, but unverified. Add to any future regression test suite. - Wave 4 review: Tally signature verification (I1) —
/admin/requesthas no Tally webhook signature check. AddTALLY_SIGNING_SECRET(from Tally form settings) and verifyTally-SignatureHMAC-SHA256 header before processing. Add after Tally form is created and signing secret is available. - Wave 4 review:
/admin/requestrate limiting (m3) — no rate limiting on the unauthenticated intake endpoint. At scale, add Cloudflare Rate Limiting binding (env.RATE_LIMITER) or IP-keyed KV throttle to prevent Telegram spam from automated scanners. - Wave 4 review: server-side email format validation (m2) —
emailfrom Tally is type-validated on the form but not server-side. Add a basic regex check (/^[^\s@]+@[^\s@]+\.[^\s@]+$/) before the KV write. - Wave 4 review: ADMIN_SECRET in URL (I3) — approve links contain
ADMIN_SECRETas a query param, which is logged by Cloudflare and stored in browser history. Acceptable at private beta scale. TODO: replace with short-lived HMAC token before broader team access to CF dashboard.