Skip to content

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.


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)


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

Terminal window
cd src/cloudflare
npx wrangler kv namespace create api_keys

Expected 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

Terminal window
npx wrangler kv namespace create api_keys --preview

Copy 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 dev defaults to in-memory local storage — it does NOT use the remote preview namespace. Seeding into --namespace-id only writes to the remote preview. Use --local --binding instead so the key is available during wrangler dev:

Terminal window
# Start the dev server first (needed to initialize local KV store)
cd src/cloudflare
npx wrangler dev --config mcp/wrangler.toml &
# Seed the dev key into local storage
npx 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

Terminal window
git add src/cloudflare/mcp/wrangler.toml
git commit -m "feat(mcp): add API_KEYS KV namespace binding"

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:

// BEFORE
interface Env {
DB: D1Database;
MCP_API_KEY: string;
SESSION_STORE: KVNamespace; // MCP session_id → "active", 1hr TTL
}

To:

// AFTER
interface 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 auth
const 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 KV
const 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

Terminal window
cd src/cloudflare
npx wrangler dev --config mcp/wrangler.toml

In a second terminal:

Terminal window
# 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):

Terminal window
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.ts

Verify 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

Terminal window
git add src/cloudflare/mcp/src/index.ts src/cloudflare/mcp/.dev.vars site/pawlo-mcp/src/index.ts
git 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)

Terminal window
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

Terminal window
cd src/cloudflare
npx 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

Terminal window
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":"..."}


Files: None — deploy commands only.

Prerequisites: Tasks 1, 2, and 3 must be complete.

Step 1: Deploy

Terminal window
cd src/cloudflare
npx wrangler deploy --config mcp/wrangler.toml

Expected:

✅ Deployed pawlo-mcp
https://mcp.pawlo.ai

Step 2: Smoke test production

Terminal window
# Valid key — should 200
curl -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 401
curl -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)

Terminal window
npx wrangler secret delete MCP_API_KEY --name pawlo-mcp

Task 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

Terminal window
# 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

Terminal window
cd /tmp
git clone https://github.com/pawlo-ai/mcp.git pawlo-mcp-public
cd pawlo-mcp-public
mkdir bin src

Step 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.ts is 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 of src/index.ts in 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 copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, 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 THE
SOFTWARE.

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/auto
fetch_deals({ sector_id, location }) → masked deals, seller identity withheld
match_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)
```bash
claude 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)
```bash
npx @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 recommendations
in 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

Terminal window
git add .
git commit -m "feat: initial Pawlo MCP public repo with README and install docs"
git push origin main

Phase 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 possible
const 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

Terminal window
chmod +x bin/setup.js

Step 3: Test before publishing

Terminal window
# No args — should print usage
node bin/setup.js
# Wrong command — should error + exit 1
node bin/setup.js install
# Missing key — should error
node bin/setup.js setup
# Bad key format — should error
node 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_aabbccddaabbccddaabbccddaabbccdd

Expected outputs:

  1. Usage: npx @pawlo/mcp setup --key YOUR_API_KEY
  2. exit 1 with usage
  3. Error: --key is required
  4. Error: invalid API key format
  5. Either ✓ Pawlo MCP installed OR the manual JSON config fallback

Step 4: Publish to npm

Terminal window
# Requires npm account + access to @pawlo org (create at npmjs.com/org/new)
npm login
npm publish --dry-run --access public # verify what gets published
npm publish --access public

Expected: + @pawlo/mcp@1.0.0

Step 5: Verify

Terminal window
npx @pawlo/mcp@latest setup
# Expected: prints usage with get-key link

Step 6: Commit

Terminal window
git add bin/setup.js
git commit -m "feat: add @pawlo/mcp CLI setup command (npx @pawlo/mcp setup --key KEY)"
git push origin main

Task 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:

  1. Telegram Bot Token: Message @BotFather on Telegram → /newbot → copy the token.
  2. Telegram Chat ID: After creating the bot, send it a message, then call:
    https://api.telegram.org/bot{YOUR_TOKEN}/getUpdates
    Find "chat":{"id":XXXXXXX} in the JSON.
  3. Postmark Server Token: Get from postmarkapp.com dashboard → Your Server → API Tokens → copy the Server API token. Sender agent@pawlo.ai is already set up.
  4. ADMIN_SECRET: Generate a random 32-byte hex token:
    Terminal window
    python3 -c "import secrets; print(secrets.token_hex(32))"
    Save this to your password manager.

Step 1: Create ACCESS_REQUESTS KV namespace

Terminal window
cd src/cloudflare
npx wrangler kv namespace create access_requests
npx wrangler kv namespace create access_requests --preview

Note 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

Terminal window
cd src/cloudflare
npx wrangler secret put ADMIN_SECRET --name pawlo-mcp
npx wrangler secret put TELEGRAM_BOT_TOKEN --name pawlo-mcp
npx wrangler secret put TELEGRAM_CHAT_ID --name pawlo-mcp
npx wrangler secret put POSTMARK_SERVER_TOKEN --name pawlo-mcp

Step 4: Update .dev.vars for local testing

Edit src/cloudflare/mcp/.dev.vars:

ADMIN_SECRET=dev-admin-secret-localonly
TELEGRAM_BOT_TOKEN=dev-fake-token
TELEGRAM_CHAT_ID=000000000
POSTMARK_SERVER_TOKEN=dev-fake-postmark-token

Step 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> &lt;${email}&gt;</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

Terminal window
cd src/cloudflare
npx wrangler deploy --config mcp/wrangler.toml

Step 9: End-to-end test

Terminal window
# Step 1: simulate Tally form submission
curl -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 key
curl -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 test

Step 10: Commit

Terminal window
git add src/cloudflare/mcp/src/index.ts src/cloudflare/mcp/wrangler.toml src/cloudflare/mcp/.dev.vars
git 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:

Terminal window
grep -n "Request Access\|href=\"#\"" docs/landing-page/pawlo-ai/src/App.tsx

Step 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

Terminal window
cd docs/landing-page/pawlo-ai
npm run build
npx wrangler pages deploy dist --project-name pawlo-landing --branch main

Step 4: Commit

Terminal window
git add docs/landing-page/pawlo-ai/src/App.tsx
git commit -m "feat(landing): wire Request Access CTAs to Tally form"

  • 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/mcp live with full README
  • Task 6: @pawlo/mcp published on npm + npx @pawlo/mcp setup works
  • Task 7: /admin/request + /admin/approve live + end-to-end test passes
  • Task 8: Landing page CTAs wired to Tally form

Tracked from Wave 1 code review — fix after initial launch:

  • SESSION_STORE missing preview_idsrc/cloudflare/mcp/wrangler.toml line 6. SESSION_STORE has no preview_id, so wrangler dev --remote uses 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, add preview_id to the binding.
  • package.json missing engines fieldsite/pawlo-mcp/package.json. Add "engines": { "node": ">=18" } so npm warns on old Node versions before bin/setup.js fails cryptically.
  • README sectors table — note which sectors have live data (/retail/auto Calgary is the only active campaign during beta). Prevents confused developers getting empty query results.
  • Smoke test edge casesAuthorization: Bearer (empty key after slice) and Authorization: bearer pk_live_... (lowercase) both correctly 401, but unverified. Add to any future regression test suite.
  • Wave 4 review: Tally signature verification (I1)/admin/request has no Tally webhook signature check. Add TALLY_SIGNING_SECRET (from Tally form settings) and verify Tally-Signature HMAC-SHA256 header before processing. Add after Tally form is created and signing secret is available.
  • Wave 4 review: /admin/request rate 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)email from 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_SECRET as 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.