pubweb-ads-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +80 -0
  2. package/package.json +33 -0
  3. package/src/index.js +136 -0
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # pubweb-ads-mcp
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server that gives **Claude** a knowledge base of
4
+ your scraped competitor ads — **semantic search**, ad copy, **images/videos**, and the
5
+ landing→destination **funnels** — so Claude can reason over them like a RAG.
6
+
7
+ It's a thin, secure wrapper over the pubweb external API (`/api/v1`): one **bearer API key**,
8
+ every call scoped server-side to your account, read-only.
9
+
10
+ ## Setup (2 steps)
11
+
12
+ **1. Create an API key** — in pubweb → **Settings → API Keys** → "Create key for Claude/MCP".
13
+ Pick the abilities `v1:ads:read` and `v1:ads:search`. Copy the key (shown once).
14
+
15
+ **2. Add the server to your MCP client.**
16
+
17
+ Claude Desktop (`claude_desktop_config.json`) or Claude Code (`.mcp.json`):
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "pubweb-ads": {
23
+ "command": "npx",
24
+ "args": ["-y", "pubweb-ads-mcp"],
25
+ "env": { "PUBWEB_API_KEY": "PASTE_YOUR_KEY_HERE" }
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ That's it — restart the client and ask Claude things like *"search the ad knowledge base for
32
+ job-offer ads"* or *"show me the funnel of ad <id>"*.
33
+
34
+ > Self-hosted / staging? add `"PUBWEB_API_URL": "https://your-domain"` to `env`
35
+ > (default `https://app.pubweb.ai`).
36
+
37
+ ### Run without npx (local checkout)
38
+
39
+ ```bash
40
+ cd mcp/ads-mcp && npm install
41
+ PUBWEB_API_KEY=... node src/index.js # stdio server
42
+ PUBWEB_API_KEY=... npm run smoke # lists the tools (sanity check)
43
+ ```
44
+ …and point the MCP client's `command`/`args` at `node /abs/path/mcp/ads-mcp/src/index.js`.
45
+
46
+ ## Tools
47
+
48
+ | Tool | What it does |
49
+ |------|--------------|
50
+ | `ads_search(query, network?, funnel_type?)` | **Semantic** (vector) search — multilingual; ranked ads with copy + thumbnail + funnel summary |
51
+ | `ads_browse(q?, network?, funnel_type?, format?, from?, to?, per_page?)` | Keyword/filter browse, paginated |
52
+ | `ad_get(id)` | One ad — full copy, media list, funnel summary |
53
+ | `ad_media(id)` | Presigned image/video URLs (30-min TTL) |
54
+ | `ad_funnel(id)` | The ad's landing→destination funnel |
55
+ | `targets_list()` | Competitor domains you track, with ad counts |
56
+ | `whoami()` | Verify the key + see its abilities (setup/debug) |
57
+
58
+ ## Security
59
+
60
+ - **Bearer key only** — never your password/session. The key is `v1:ads:read[/search]` scoped;
61
+ it can't touch anything else. Stored only in your local MCP config.
62
+ - The server **never** sees more than your own ads (scoped server-side by the key owner).
63
+ - Read-only: no tool can create, modify, or delete anything.
64
+ - Rate-limited per key (HTTP 429 + `Retry-After` surfaces as a normal tool error).
65
+
66
+ ## Releasing (maintainers)
67
+
68
+ Publishing is automated by `.github/workflows/publish-mcp.yml` — it fires only on changes
69
+ under `mcp/ads-mcp/**` and publishes to npm **only when `version` here is bumped** (an
70
+ unbumped change runs but skips publish). So a release is just:
71
+
72
+ ```bash
73
+ npm version patch # or minor / major — bumps package.json
74
+ git commit -am "release(ads-mcp): vX.Y.Z" && git push # → workflow publishes
75
+ ```
76
+
77
+ The package is **unscoped** — it publishes to the personal npm account that owns the token.
78
+ One-time setup by the repo owner: create an automation token on npmjs.com, then add it as
79
+ the GitHub repo secret `NPM_TOKEN`.
80
+
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "pubweb-ads-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server exposing the pubweb Ads knowledge base (semantic search over scraped competitor ads, with media + funnels) to Claude and other MCP clients.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/f1sc4ll/pubweb-saas.git",
10
+ "directory": "mcp/ads-mcp"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "bin": {
16
+ "pubweb-ads-mcp": "src/index.js"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "README.md"
21
+ ],
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "scripts": {
26
+ "start": "node src/index.js",
27
+ "smoke": "node test/smoke.mjs"
28
+ },
29
+ "dependencies": {
30
+ "@modelcontextprotocol/sdk": "^1.0.0",
31
+ "zod": "^3.23.0"
32
+ }
33
+ }
package/src/index.js ADDED
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pubweb-ads-mcp — MCP server that gives Claude a RAG-style knowledge base of scraped
4
+ * competitor ads (semantic search + media + funnels), backed by the pubweb external API
5
+ * (/api/v1). Auth is a single bearer API key; every call is scoped server-side to the
6
+ * key owner's targets. Transport is stdio (run locally by the MCP client).
7
+ *
8
+ * Env:
9
+ * PUBWEB_API_KEY (required) the v1 API key, abilities v1:ads:read [+ v1:ads:search]
10
+ * PUBWEB_API_URL (optional) default https://app.pubweb.ai
11
+ */
12
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
+ import { z } from 'zod';
15
+
16
+ const VERSION = '0.1.0';
17
+ const API_URL = (process.env.PUBWEB_API_URL || 'https://app.pubweb.ai').replace(/\/+$/, '');
18
+ const API_KEY = process.env.PUBWEB_API_KEY;
19
+
20
+ if (!API_KEY) {
21
+ console.error('[pubweb-ads-mcp] PUBWEB_API_KEY is required (create one in pubweb → Settings → API Keys).');
22
+ process.exit(1);
23
+ }
24
+
25
+ /** GET /api/v1{path} with the bearer key; throws a readable error on non-2xx / network fail. */
26
+ async function apiGet(path, params = {}) {
27
+ const url = new URL(`${API_URL}/api/v1${path}`);
28
+ for (const [k, v] of Object.entries(params)) {
29
+ if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
30
+ }
31
+
32
+ let res;
33
+ try {
34
+ res = await fetch(url, {
35
+ headers: {
36
+ Authorization: `Bearer ${API_KEY}`,
37
+ Accept: 'application/json',
38
+ // A named UA is REQUIRED — the edge (Cloudflare) 520s requests with an empty
39
+ // User-Agent. Any non-empty value passes; this identifies the client cleanly.
40
+ 'User-Agent': `pubweb-ads-mcp/${VERSION}`,
41
+ },
42
+ signal: AbortSignal.timeout(20000),
43
+ });
44
+ } catch (e) {
45
+ throw new Error(`network error calling ${path}: ${e.message}`);
46
+ }
47
+
48
+ const body = await res.text();
49
+ if (!res.ok) {
50
+ throw new Error(`API ${res.status} on ${path}: ${body.slice(0, 400)}`);
51
+ }
52
+ try {
53
+ return JSON.parse(body);
54
+ } catch {
55
+ return body;
56
+ }
57
+ }
58
+
59
+ const okText = (data) => ({
60
+ content: [{ type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }],
61
+ });
62
+ const errText = (e) => ({ content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true });
63
+ const run = async (fn) => {
64
+ try {
65
+ return okText(await fn());
66
+ } catch (e) {
67
+ return errText(e);
68
+ }
69
+ };
70
+
71
+ const server = new McpServer({ name: 'pubweb-ads', version: VERSION });
72
+
73
+ server.tool(
74
+ 'ads_search',
75
+ 'Semantic (vector) search over the ad knowledge base. Pass a natural-language query — it is multilingual, so "emprego" / "job" / "empleo" all match the same concept. Returns ads ranked by relevance with copy, advertiser, a thumbnail URL and a funnel summary. Use this for "find ads about X".',
76
+ {
77
+ query: z.string().describe('Natural-language search query'),
78
+ network: z.string().optional().describe('Filter by ad network (e.g. "meta")'),
79
+ funnel_type: z.string().optional().describe('Filter by funnel type'),
80
+ },
81
+ async (args) => run(() => apiGet('/ads/search', { q: args.query, network: args.network, funnel_type: args.funnel_type })),
82
+ );
83
+
84
+ server.tool(
85
+ 'ads_browse',
86
+ 'Keyword + filter browse of ads (no embeddings), paginated. Use ads_search for relevance; use this for exact filters, date ranges, or paging through results.',
87
+ {
88
+ q: z.string().optional().describe('Keyword (advertiser/headline/link/funnel)'),
89
+ network: z.string().optional(),
90
+ funnel_type: z.string().optional(),
91
+ format: z.string().optional().describe('display_format, e.g. "image" | "video"'),
92
+ from: z.string().optional().describe('ISO date — ads last seen on/after'),
93
+ to: z.string().optional().describe('ISO date — ads last seen on/before'),
94
+ per_page: z.number().int().min(1).max(50).optional(),
95
+ },
96
+ async (args) => run(() => apiGet('/ads', args)),
97
+ );
98
+
99
+ server.tool(
100
+ 'ad_get',
101
+ 'Fetch one ad by id: full copy (headline/body/cta), advertiser, media list and funnel summary.',
102
+ { id: z.string().describe('Ad id (uuid)') },
103
+ async ({ id }) => run(() => apiGet(`/ads/${encodeURIComponent(id)}`)),
104
+ );
105
+
106
+ server.tool(
107
+ 'ad_media',
108
+ 'Presigned image/video URLs for an ad (30-minute TTL). Use to view or download the creatives.',
109
+ { id: z.string().describe('Ad id (uuid)') },
110
+ async ({ id }) => run(() => apiGet(`/ads/${encodeURIComponent(id)}/media`)),
111
+ );
112
+
113
+ server.tool(
114
+ 'ad_funnel',
115
+ 'The mapped landing → destination funnel for an ad (gateway, steps with URLs, destination).',
116
+ { id: z.string().describe('Ad id (uuid)') },
117
+ async ({ id }) => run(() => apiGet(`/ads/${encodeURIComponent(id)}/funnel`)),
118
+ );
119
+
120
+ server.tool(
121
+ 'targets_list',
122
+ 'List the competitor targets (advertiser domains) tracked in your account, with ad counts.',
123
+ {},
124
+ async () => run(() => apiGet('/targets')),
125
+ );
126
+
127
+ server.tool(
128
+ 'whoami',
129
+ 'Verify the API key works and show its abilities. Use for setup/debugging.',
130
+ {},
131
+ async () => run(() => apiGet('/me')),
132
+ );
133
+
134
+ const transport = new StdioServerTransport();
135
+ await server.connect(transport);
136
+ console.error(`[pubweb-ads-mcp] ready → ${API_URL}/api/v1`);