openclaw-adspirer 0.2.0 → 0.3.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.
package/LICENSE CHANGED
@@ -1,21 +1,8 @@
1
- MIT License
1
+ Copyright (c) 2026 Adspirer, Inc. All rights reserved.
2
2
 
3
- Copyright (c) 2026 Adspirer
3
+ This software is proprietary and confidential. Unauthorized copying, modification,
4
+ distribution, or use of this software, via any medium, is strictly prohibited.
4
5
 
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
6
+ This software is provided "as is" without warranty of any kind, express or implied.
11
7
 
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
8
+ For licensing inquiries, contact: support@adspirer.com
package/README.md CHANGED
@@ -13,7 +13,7 @@ OpenClaw plugin for [Adspirer](https://adspirer.com) — manage Google, Meta, Ti
13
13
  ## Installation
14
14
 
15
15
  ```bash
16
- openclaw plugin install openclaw-adspirer
16
+ openclaw plugins install openclaw-adspirer
17
17
  ```
18
18
 
19
19
  ## Setup
@@ -48,6 +48,7 @@ openclaw adspirer status
48
48
  | `openclaw adspirer status` | Show auth status, connections, tool count |
49
49
  | `openclaw adspirer accounts` | List connected ad accounts |
50
50
  | `openclaw adspirer tools` | List registered tools by platform |
51
+ | `openclaw adspirer connect` | Open Adspirer to connect ad platforms |
51
52
 
52
53
  ## Tool Groups
53
54
 
package/package.json CHANGED
@@ -1,12 +1,20 @@
1
1
  {
2
2
  "name": "openclaw-adspirer",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "OpenClaw plugin for Adspirer — manage Google, Meta, TikTok & LinkedIn ads via natural language",
6
- "license": "MIT",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "files": [
8
+ "src/",
9
+ "types/",
10
+ "openclaw.plugin.json",
11
+ "README.md",
12
+ "LICENSE",
13
+ "CHANGELOG.md"
14
+ ],
7
15
  "scripts": {
8
16
  "check-types": "tsc --noEmit",
9
- "test": "ESBUILD_BINARY_PATH=./node_modules/@esbuild/linux-x64/bin/esbuild tsx --test tests/*.test.ts",
17
+ "test": "tsx --test tests/*.test.ts",
10
18
  "build": "tsc"
11
19
  },
12
20
  "peerDependencies": {
@@ -18,10 +26,8 @@
18
26
  ]
19
27
  },
20
28
  "devDependencies": {
21
- "@esbuild/linux-x64": "^0.27.2",
22
29
  "@types/node": "^22.0.0",
23
30
  "tsx": "^4.21.0",
24
- "typescript": "^5.9.3",
25
- "vitest": "^3.0.0"
31
+ "typescript": "^5.9.3"
26
32
  }
27
33
  }
@@ -16,6 +16,7 @@ import { readFileSync, writeFileSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
 
18
18
  const PLUGIN_ID = "openclaw-adspirer";
19
+ const PLUGIN_VERSION = "0.3.0";
19
20
 
20
21
  function getConfigPath(): string {
21
22
  return join(process.env.HOME || process.env.USERPROFILE || ".", ".openclaw", "openclaw.json");
@@ -85,6 +86,13 @@ export function registerCommands(
85
86
  .action(async () => {
86
87
  await handleTools(config);
87
88
  });
89
+
90
+ cmd
91
+ .command("connect")
92
+ .description("Open Adspirer to connect ad platforms")
93
+ .action(async () => {
94
+ await handleConnect();
95
+ });
88
96
  },
89
97
  { commands: ["adspirer"] },
90
98
  );
@@ -121,6 +129,7 @@ async function handleLogin(api: OpenClawPluginApi, config: AdspirerConfig): Prom
121
129
 
122
130
  // Persist client_id so we don't re-register every login
123
131
  patchPluginConfig({ oauthClientId: clientId });
132
+ config.oauthClientId = clientId;
124
133
  } catch (regErr: any) {
125
134
  callbackServer.close();
126
135
  throw new Error(`Client registration failed: ${regErr.message}`);
@@ -159,7 +168,7 @@ async function handleLogin(api: OpenClawPluginApi, config: AdspirerConfig): Prom
159
168
  // Browser open failed, user can use the URL
160
169
  }
161
170
 
162
- console.log("⏳ Waiting for authentication (30s timeout)...\n");
171
+ console.log("⏳ Waiting for authentication (120s timeout)...\n");
163
172
 
164
173
  const { code, state: returnedState } = await callbackServer.waitForCallback();
165
174
  callbackServer.close();
@@ -185,6 +194,7 @@ async function handleLogin(api: OpenClawPluginApi, config: AdspirerConfig): Prom
185
194
  });
186
195
 
187
196
  console.log(`✅ Connected to Adspirer! Restart the gateway to load all tools.`);
197
+ console.log(`\n 📌 Connect your ad platforms at https://www.adspirer.com`);
188
198
  } catch (err: any) {
189
199
  console.log(`❌ Login failed: ${err.message}`);
190
200
  }
@@ -200,6 +210,8 @@ async function handleLogout(api: OpenClawPluginApi): Promise<void> {
200
210
  }
201
211
 
202
212
  async function handleStatus(client: AdspirerMCPClient, config: AdspirerConfig): Promise<void> {
213
+ console.log(`\n📦 openclaw-adspirer v${PLUGIN_VERSION}\n`);
214
+
203
215
  if (config.testMode) {
204
216
  console.log("🧪 Test Mode Active");
205
217
  console.log(` Server: ${config.serverUrl}`);
@@ -230,6 +242,16 @@ async function handleStatus(client: AdspirerMCPClient, config: AdspirerConfig):
230
242
  console.log("\n⚠️ Could not fetch connection status.");
231
243
  }
232
244
 
245
+ // Try to get usage/quota status
246
+ try {
247
+ const result = await client.callTool("get_usage_status", {});
248
+ if (result.content?.[0]?.text) {
249
+ console.log(`\n📊 Plan & Usage:\n${result.content[0].text}`);
250
+ }
251
+ } catch {
252
+ // Quota info not available
253
+ }
254
+
233
255
  // Show tool count
234
256
  const tools = config.enabledTools.length > 0
235
257
  ? config.enabledTools
@@ -276,3 +298,22 @@ async function handleTools(config: AdspirerConfig): Promise<void> {
276
298
 
277
299
  console.log(`\nTotal enabled: ${total}`);
278
300
  }
301
+
302
+ async function handleConnect(): Promise<void> {
303
+ const url = "https://www.adspirer.com";
304
+ console.log("🔗 Connect your ad platforms at Adspirer:\n");
305
+ console.log(` ${url}\n`);
306
+ console.log(" Supported platforms:");
307
+ console.log(" • Google Ads");
308
+ console.log(" • Meta Ads");
309
+ console.log(" • LinkedIn Ads");
310
+ console.log(" • TikTok Ads");
311
+
312
+ try {
313
+ const { execFile } = await import("node:child_process");
314
+ const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
315
+ execFile(openCmd, [url], (err) => { /* ignore browser open failures */ });
316
+ } catch {
317
+ // Browser open failed, user can visit the URL manually
318
+ }
319
+ }
package/src/config.ts CHANGED
@@ -41,7 +41,8 @@ export function parseConfig(raw: unknown): AdspirerConfig {
41
41
  export function isAuthenticated(config: AdspirerConfig): boolean {
42
42
  if (config.testMode) return true;
43
43
  if (!config.accessToken) return false;
44
- if (config.tokenExpiresAt && config.tokenExpiresAt < Date.now()) return false;
44
+ // Allow expired tokens through if we have a refresh token — getToken() will refresh
45
+ if (config.tokenExpiresAt && config.tokenExpiresAt < Date.now() && !config.refreshToken) return false;
45
46
  return true;
46
47
  }
47
48
 
package/src/index.ts CHANGED
@@ -71,6 +71,18 @@ export default {
71
71
  api.logger,
72
72
  );
73
73
 
74
+ // If token is expired but we have a refresh token, refresh before registering tools
75
+ if (isAuthenticated(config) && config.tokenExpiresAt && config.tokenExpiresAt < Date.now()) {
76
+ try {
77
+ api.logger.info("adspirer: token expired, refreshing...");
78
+ const tokens = await doRefresh();
79
+ saveTokens(api, config, tokens);
80
+ api.logger.info("adspirer: token refreshed successfully");
81
+ } catch (err) {
82
+ api.logger.warn(`adspirer: token refresh failed: ${err}`);
83
+ }
84
+ }
85
+
74
86
  // Register tools
75
87
  if (isAuthenticated(config)) {
76
88
  try {
@@ -100,13 +112,15 @@ function saveTokens(api: OpenClawPluginApi, config: AdspirerConfig, tokens: Toke
100
112
  const path = require("node:path");
101
113
  const configPath = path.join(process.env.HOME || ".", ".openclaw", "openclaw.json");
102
114
  const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
103
- const entry = raw?.plugins?.entries?.["openclaw-adspirer"];
104
- if (entry?.config) {
105
- entry.config.accessToken = tokens.accessToken;
106
- entry.config.refreshToken = tokens.refreshToken;
107
- entry.config.tokenExpiresAt = tokens.expiresAt;
108
- fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n");
109
- }
115
+ if (!raw.plugins) raw.plugins = {};
116
+ if (!raw.plugins.entries) raw.plugins.entries = {};
117
+ if (!raw.plugins.entries["openclaw-adspirer"]) raw.plugins.entries["openclaw-adspirer"] = { enabled: true, config: {} };
118
+ if (!raw.plugins.entries["openclaw-adspirer"].config) raw.plugins.entries["openclaw-adspirer"].config = {};
119
+ const entry = raw.plugins.entries["openclaw-adspirer"];
120
+ entry.config.accessToken = tokens.accessToken;
121
+ entry.config.refreshToken = tokens.refreshToken;
122
+ entry.config.tokenExpiresAt = tokens.expiresAt;
123
+ fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n");
110
124
  } catch {
111
125
  api.logger.warn("Could not persist tokens to config file");
112
126
  }
@@ -101,7 +101,7 @@ export function registerConnectTool(api: OpenClawPluginApi): void {
101
101
  execute: async () => ({
102
102
  content: [{
103
103
  type: "text" as const,
104
- text: "🔑 You're not connected to Adspirer yet.\n\nRun `openclaw adspirer login` in your terminal to authenticate.\n\nOnce connected, you'll have access to 103 ad management tools across Google, Meta, TikTok, and LinkedIn.",
104
+ text: "🔑 You're not connected to Adspirer yet.\n\nRun `openclaw adspirer login` in your terminal to authenticate.\n\nThen connect your ad platforms at https://www.adspirer.com\n\nOnce connected, you'll have access to 103 ad management tools across Google, Meta, TikTok, and LinkedIn.",
105
105
  }],
106
106
  }),
107
107
  },
@@ -1,75 +0,0 @@
1
- import { test, describe } from "node:test";
2
- import { strict as assert } from "node:assert";
3
- import { generateCodeVerifier, generateCodeChallenge, buildAuthUrl } from "../src/auth.ts";
4
-
5
- describe("generateCodeVerifier", () => {
6
- test("length 43-128", () => {
7
- const v = generateCodeVerifier();
8
- assert.ok(v.length >= 43, `verifier too short: ${v.length}`);
9
- assert.ok(v.length <= 128, `verifier too long: ${v.length}`);
10
- });
11
-
12
- test("base64url charset only", () => {
13
- const v = generateCodeVerifier();
14
- assert.ok(/^[A-Za-z0-9\-_]+$/.test(v), `invalid chars in verifier: ${v}`);
15
- });
16
-
17
- test("unique each time", () => {
18
- const a = generateCodeVerifier();
19
- const b = generateCodeVerifier();
20
- assert.notEqual(a, b);
21
- });
22
- });
23
-
24
- describe("generateCodeChallenge", () => {
25
- test("produces base64url string", async () => {
26
- const v = generateCodeVerifier();
27
- const c = await generateCodeChallenge(v);
28
- assert.ok(/^[A-Za-z0-9\-_]+$/.test(c));
29
- });
30
-
31
- test("known vector: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", async () => {
32
- // RFC 7636 Appendix B test vector
33
- const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
34
- const challenge = await generateCodeChallenge(verifier);
35
- assert.equal(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
36
- });
37
-
38
- test("deterministic", async () => {
39
- const v = "test_verifier_string";
40
- const a = await generateCodeChallenge(v);
41
- const b = await generateCodeChallenge(v);
42
- assert.equal(a, b);
43
- });
44
- });
45
-
46
- describe("buildAuthUrl", () => {
47
- test("contains all params using discovered authorize endpoint", () => {
48
- const url = buildAuthUrl({
49
- authorizeEndpoint: "https://mcp.adspirer.com/oauth/authorize",
50
- clientId: "openclaw-abc123",
51
- codeChallenge: "abc123",
52
- redirectUri: "http://localhost:52847/callback",
53
- scope: "ads:read ads:write accounts:read",
54
- state: "state123",
55
- });
56
- assert.ok(url.includes("client_id=openclaw-abc123"), "missing client_id");
57
- assert.ok(url.includes("redirect_uri=http%3A%2F%2Flocalhost%3A52847%2Fcallback"), "missing redirect_uri");
58
- assert.ok(url.includes("response_type=code"), "missing response_type");
59
- assert.ok(url.includes("code_challenge=abc123"), "missing code_challenge");
60
- assert.ok(url.includes("code_challenge_method=S256"), "missing code_challenge_method");
61
- assert.ok(url.includes("state=state123"), "missing state");
62
- });
63
-
64
- test("uses full authorize endpoint URL (not relative path)", () => {
65
- const url = buildAuthUrl({
66
- authorizeEndpoint: "https://auth.example.com/oauth/authorize",
67
- clientId: "test-client",
68
- codeChallenge: "challenge",
69
- redirectUri: "http://localhost:3000/callback",
70
- scope: "read",
71
- state: "s",
72
- });
73
- assert.ok(url.startsWith("https://auth.example.com/oauth/authorize?"), `unexpected URL prefix: ${url}`);
74
- });
75
- });
@@ -1,131 +0,0 @@
1
- import { test, describe, afterEach } from "node:test";
2
- import { strict as assert } from "node:assert";
3
- import { AdspirerMCPClient } from "../src/client.ts";
4
- import { parseConfig } from "../src/config.ts";
5
- import { installMockFetch, jsonResponse } from "./helpers/mock-fetch.ts";
6
-
7
- function makeClient(overrides: Record<string, unknown> = {}, fetchHandler?: (url: string | URL | Request, init?: RequestInit) => Promise<Response>) {
8
- const config = parseConfig({
9
- serverUrl: "https://test.example.com",
10
- testMode: true,
11
- ...overrides,
12
- });
13
- const logger = {
14
- info: () => {},
15
- warn: () => {},
16
- error: () => {},
17
- debug: () => {},
18
- };
19
- const client = new AdspirerMCPClient(
20
- config,
21
- async () => "test-token",
22
- () => {},
23
- async () => ({ accessToken: "new", refreshToken: "new", expiresIn: 3600, expiresAt: Date.now() + 3600000 }),
24
- logger,
25
- );
26
- return client;
27
- }
28
-
29
- describe("AdspirerMCPClient", () => {
30
- let restore: (() => void) | null = null;
31
- afterEach(() => { if (restore) { restore(); restore = null; } });
32
-
33
- test("callTool happy path", async () => {
34
- const client = makeClient();
35
- restore = installMockFetch(async () =>
36
- jsonResponse({
37
- jsonrpc: "2.0",
38
- result: { content: [{ type: "text", text: "hello" }] },
39
- id: "1",
40
- }),
41
- );
42
- const result = await client.callTool("echo_test", { message: "hi" });
43
- assert.equal(result.content[0].text, "hello");
44
- });
45
-
46
- test("callTool JSON-RPC error", async () => {
47
- const client = makeClient();
48
- restore = installMockFetch(async () =>
49
- jsonResponse({
50
- jsonrpc: "2.0",
51
- error: { code: -32602, message: "bad param" },
52
- id: "1",
53
- }),
54
- );
55
- const result = await client.callTool("echo_test", {});
56
- assert.ok(result.content[0].text.includes("bad param"));
57
- });
58
-
59
- test("callTool wraps non-content result", async () => {
60
- const client = makeClient();
61
- restore = installMockFetch(async () =>
62
- jsonResponse({
63
- jsonrpc: "2.0",
64
- result: { data: "raw" },
65
- id: "1",
66
- }),
67
- );
68
- const result = await client.callTool("echo_test", {});
69
- assert.ok(result.content[0].text.includes("raw"));
70
- });
71
-
72
- test("callTool 401 → refresh → retry (production mode)", async () => {
73
- let callCount = 0;
74
- const config = parseConfig({
75
- serverUrl: "https://test.example.com",
76
- testMode: false,
77
- accessToken: "old-token",
78
- refreshToken: "ref-token",
79
- tokenExpiresAt: Date.now() + 3600000,
80
- });
81
- const logger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
82
- let refreshCalled = false;
83
- const client = new AdspirerMCPClient(
84
- config,
85
- async () => "old-token",
86
- () => {},
87
- async () => { refreshCalled = true; return { accessToken: "new", refreshToken: "new", expiresIn: 3600, expiresAt: Date.now() + 3600000 }; },
88
- logger,
89
- );
90
-
91
- restore = installMockFetch(async () => {
92
- callCount++;
93
- if (callCount === 1) return new Response("Unauthorized", { status: 401 });
94
- return jsonResponse({ jsonrpc: "2.0", result: { content: [{ type: "text", text: "ok" }] }, id: "1" });
95
- });
96
-
97
- const result = await client.callTool("echo_test", {});
98
- assert.equal(refreshCalled, true);
99
- assert.equal(result.content[0].text, "ok");
100
- });
101
-
102
- test("callTool 402 → upgrade message", async () => {
103
- const client = makeClient({ testMode: false, accessToken: "tok", tokenExpiresAt: Date.now() + 3600000 });
104
- restore = installMockFetch(async () => new Response("Payment Required", { status: 402 }));
105
- const result = await client.callTool("premium_tool", {});
106
- assert.ok(result.content[0].text.includes("Upgrade"));
107
- });
108
-
109
- test("listTools happy path", async () => {
110
- const client = makeClient();
111
- restore = installMockFetch(async () =>
112
- jsonResponse({
113
- jsonrpc: "2.0",
114
- result: { tools: [{ name: "echo_test", description: "test", inputSchema: {} }] },
115
- id: "1",
116
- }),
117
- );
118
- const tools = await client.listTools();
119
- assert.equal(tools.length, 1);
120
- assert.equal(tools[0].name, "echo_test");
121
- });
122
-
123
- test("malformed response", async () => {
124
- const client = makeClient();
125
- restore = installMockFetch(async () =>
126
- jsonResponse({ invalid: true }),
127
- );
128
- const result = await client.callTool("echo_test", {});
129
- assert.ok(result.content[0].text.includes("Malformed"));
130
- });
131
- });
@@ -1,115 +0,0 @@
1
- import { test, describe } from "node:test";
2
- import { strict as assert } from "node:assert";
3
- import { parseConfig, isAuthenticated, isTokenExpiringSoon, shouldRegisterTool } from "../src/config.ts";
4
-
5
- describe("parseConfig", () => {
6
- test("empty config → defaults", () => {
7
- const c = parseConfig({});
8
- assert.equal(c.serverUrl, "https://mcp.adspirer.com");
9
- assert.equal(c.requestTimeoutMs, 60000);
10
- assert.equal(c.testMode, false);
11
- assert.equal(c.enabledGroups.length, 6);
12
- assert.equal(c.enabledTools.length, 0);
13
- assert.equal(c.accessToken, undefined);
14
- });
15
-
16
- test("full config", () => {
17
- const c = parseConfig({
18
- serverUrl: "http://localhost:8000",
19
- testMode: true,
20
- testUserEmail: "test@test.com",
21
- accessToken: "tok",
22
- refreshToken: "ref",
23
- tokenExpiresAt: 99999999999999,
24
- defaultAccountId: "123",
25
- enabledGroups: ["google_ads"],
26
- enabledTools: ["echo_test"],
27
- requestTimeoutMs: 120000,
28
- });
29
- assert.equal(c.serverUrl, "http://localhost:8000");
30
- assert.equal(c.testMode, true);
31
- assert.equal(c.testUserEmail, "test@test.com");
32
- assert.equal(c.accessToken, "tok");
33
- assert.equal(c.refreshToken, "ref");
34
- assert.equal(c.enabledGroups.length, 1);
35
- assert.equal(c.enabledTools.length, 1);
36
- assert.equal(c.requestTimeoutMs, 120000);
37
- });
38
-
39
- test("partial config fills defaults", () => {
40
- const c = parseConfig({ serverUrl: "https://custom.com" });
41
- assert.equal(c.serverUrl, "https://custom.com");
42
- assert.equal(c.requestTimeoutMs, 60000);
43
- assert.equal(c.enabledGroups.length, 6);
44
- });
45
-
46
- test("null/undefined/non-object → defaults", () => {
47
- for (const v of [null, undefined, "string", 42, []]) {
48
- const c = parseConfig(v);
49
- assert.equal(c.serverUrl, "https://mcp.adspirer.com");
50
- }
51
- });
52
- });
53
-
54
- describe("isAuthenticated", () => {
55
- test("with valid token → true", () => {
56
- const c = parseConfig({ accessToken: "tok", tokenExpiresAt: Date.now() + 3600000 });
57
- assert.equal(isAuthenticated(c), true);
58
- });
59
-
60
- test("no token → false", () => {
61
- assert.equal(isAuthenticated(parseConfig({})), false);
62
- });
63
-
64
- test("expired token → false", () => {
65
- const c = parseConfig({ accessToken: "tok", tokenExpiresAt: Date.now() - 1000 });
66
- assert.equal(isAuthenticated(c), false);
67
- });
68
-
69
- test("testMode → true regardless", () => {
70
- const c = parseConfig({ testMode: true });
71
- assert.equal(isAuthenticated(c), true);
72
- });
73
- });
74
-
75
- describe("isTokenExpiringSoon", () => {
76
- test("expiring within 5min → true", () => {
77
- const c = parseConfig({ accessToken: "tok", tokenExpiresAt: Date.now() + 60000 });
78
- assert.equal(isTokenExpiringSoon(c), true);
79
- });
80
-
81
- test("not expiring soon → false", () => {
82
- const c = parseConfig({ accessToken: "tok", tokenExpiresAt: Date.now() + 600000 });
83
- assert.equal(isTokenExpiringSoon(c), false);
84
- });
85
-
86
- test("testMode → false", () => {
87
- const c = parseConfig({ testMode: true, tokenExpiresAt: Date.now() + 1000 });
88
- assert.equal(isTokenExpiringSoon(c), false);
89
- });
90
- });
91
-
92
- describe("shouldRegisterTool", () => {
93
- test("system tool → always true", () => {
94
- const c = parseConfig({ enabledGroups: ["google_ads"] });
95
- assert.equal(shouldRegisterTool("get_usage_status", "system", c), true);
96
- assert.equal(shouldRegisterTool("get_connections_status", "system", c), true);
97
- });
98
-
99
- test("group filter works", () => {
100
- const c = parseConfig({ enabledGroups: ["google_ads"] });
101
- assert.equal(shouldRegisterTool("get_campaign_performance", "google_ads", c), true);
102
- assert.equal(shouldRegisterTool("pause_meta_campaign", "meta_ads", c), false);
103
- });
104
-
105
- test("enabledTools override", () => {
106
- const c = parseConfig({ enabledTools: ["pause_meta_campaign"] });
107
- assert.equal(shouldRegisterTool("pause_meta_campaign", "meta_ads", c), true);
108
- assert.equal(shouldRegisterTool("get_campaign_performance", "google_ads", c), false);
109
- });
110
-
111
- test("default → all-on", () => {
112
- const c = parseConfig({});
113
- assert.equal(shouldRegisterTool("pause_meta_campaign", "meta_ads", c), true);
114
- });
115
- });
@@ -1,33 +0,0 @@
1
- import { test, describe } from "node:test";
2
- import { strict as assert } from "node:assert";
3
- import { mapHTTPError, mapJSONRPCError, createErrorResult, isRetryableError } from "../src/errors.ts";
4
-
5
- describe("mapHTTPError", () => {
6
- test("401", () => assert.ok(mapHTTPError(401).includes("login")));
7
- test("402", () => assert.ok(mapHTTPError(402).includes("Upgrade")));
8
- test("403", () => assert.ok(mapHTTPError(403).includes("Access denied")));
9
- test("429", () => assert.ok(mapHTTPError(429).includes("Rate limit")));
10
- test("500", () => assert.ok(mapHTTPError(500).includes("service error")));
11
- });
12
-
13
- describe("mapJSONRPCError", () => {
14
- test("-32601", () => assert.ok(mapJSONRPCError({ code: -32601, message: "" }).includes("not found")));
15
- test("-32602", () => assert.ok(mapJSONRPCError({ code: -32602, message: "bad param" }).includes("bad param")));
16
- });
17
-
18
- describe("createErrorResult", () => {
19
- test("wraps in content format", () => {
20
- const r = createErrorResult("oops");
21
- assert.equal(r.content.length, 1);
22
- assert.equal(r.content[0].type, "text");
23
- assert.ok(r.content[0].text.includes("oops"));
24
- });
25
- });
26
-
27
- describe("isRetryableError", () => {
28
- test("401 → true", () => assert.equal(isRetryableError(401), true));
29
- test("429 → true", () => assert.equal(isRetryableError(429), true));
30
- test("503 → true", () => assert.equal(isRetryableError(503), true));
31
- test("500 → false", () => assert.equal(isRetryableError(500), false));
32
- test("200 → false", () => assert.equal(isRetryableError(200), false));
33
- });
@@ -1,12 +0,0 @@
1
- export function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
2
- return new Response(JSON.stringify(body), {
3
- status,
4
- headers: { "Content-Type": "application/json", ...headers },
5
- });
6
- }
7
-
8
- export function installMockFetch(handler: (url: string | URL | Request, init?: RequestInit) => Promise<Response>): () => void {
9
- const original = globalThis.fetch;
10
- globalThis.fetch = handler as typeof fetch;
11
- return () => { globalThis.fetch = original; };
12
- }
@@ -1,41 +0,0 @@
1
- import { test, describe } from "node:test";
2
- import { strict as assert } from "node:assert";
3
- import {
4
- ALL_TOOLS, GOOGLE_ADS_TOOLS, META_ADS_TOOLS, TIKTOK_ADS_TOOLS,
5
- LINKEDIN_ADS_TOOLS, MANUS_TOOLS, SYSTEM_TOOLS,
6
- getGroupForTool, getToolsForGroups,
7
- } from "../src/tool-groups.ts";
8
-
9
- describe("tool counts", () => {
10
- test("ALL_TOOLS = 104 (with 1 duplicate)", () => assert.equal(ALL_TOOLS.length, 104));
11
- test("Google Ads = 40", () => assert.equal(GOOGLE_ADS_TOOLS.length, 40));
12
- test("Meta Ads = 20", () => assert.equal(META_ADS_TOOLS.length, 20));
13
- test("TikTok Ads = 4", () => assert.equal(TIKTOK_ADS_TOOLS.length, 4));
14
- test("LinkedIn Ads = 28", () => assert.equal(LINKEDIN_ADS_TOOLS.length, 28));
15
- test("Manus = 8", () => assert.equal(MANUS_TOOLS.length, 8));
16
- test("System = 4", () => assert.equal(SYSTEM_TOOLS.length, 4));
17
-
18
- test("ALL_TOOLS has 104 entries (get_usage_status in google_ads and system)", () => {
19
- assert.equal(ALL_TOOLS.length, 104);
20
- const unique = new Set(ALL_TOOLS);
21
- assert.equal(unique.size, 103); // 103 unique tools
22
- });
23
- });
24
-
25
- describe("getGroupForTool", () => {
26
- test("known google tool", () => assert.equal(getGroupForTool("get_campaign_performance"), "google_ads"));
27
- test("known meta tool", () => assert.equal(getGroupForTool("pause_meta_campaign"), "meta_ads"));
28
- test("known system tool", () => assert.equal(getGroupForTool("switch_primary_account"), "system"));
29
- test("unknown tool", () => assert.equal(getGroupForTool("nonexistent_tool"), null));
30
- });
31
-
32
- describe("getToolsForGroups", () => {
33
- test("meta_ads + tiktok_ads = 24", () => {
34
- const tools = getToolsForGroups(["meta_ads", "tiktok_ads"]);
35
- assert.equal(tools.length, 24);
36
- });
37
-
38
- test("empty groups = 0", () => {
39
- assert.equal(getToolsForGroups([]).length, 0);
40
- });
41
- });
@@ -1,118 +0,0 @@
1
- import { test, describe, afterEach } from "node:test";
2
- import { strict as assert } from "node:assert";
3
- import { isWriteTool, fetchAndRegisterTools, registerStaticFallbacks, registerConnectTool, createToolExecutor } from "../src/tool-registry.ts";
4
- import { parseConfig } from "../src/config.ts";
5
- import { AdspirerMCPClient } from "../src/client.ts";
6
- import { installMockFetch, jsonResponse } from "./helpers/mock-fetch.ts";
7
-
8
- function makeMockApi() {
9
- const registered: { name: string; opts?: any }[] = [];
10
- return {
11
- pluginConfig: {},
12
- logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
13
- registerTool: (tool: any, opts?: any) => { registered.push({ name: tool.name, opts }); },
14
- registerCommand: () => {},
15
- registerCli: () => {},
16
- registerService: () => {},
17
- on: () => {},
18
- updateConfig: () => {},
19
- _registered: registered,
20
- };
21
- }
22
-
23
- function makeClient(config = parseConfig({ testMode: true, serverUrl: "https://test.example.com" })) {
24
- return new AdspirerMCPClient(
25
- config,
26
- async () => "",
27
- () => {},
28
- async () => ({ accessToken: "", refreshToken: "", expiresIn: 3600, expiresAt: 0 }),
29
- { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
30
- );
31
- }
32
-
33
- describe("isWriteTool", () => {
34
- test("create_ prefix → write", () => assert.equal(isWriteTool("create_search_campaign"), true));
35
- test("update_ prefix → write", () => assert.equal(isWriteTool("update_campaign"), true));
36
- test("pause_ prefix → write", () => assert.equal(isWriteTool("pause_campaign"), true));
37
- test("get_ prefix → read", () => assert.equal(isWriteTool("get_campaign_performance"), false));
38
- test("list_ prefix → read", () => assert.equal(isWriteTool("list_campaigns"), false));
39
- test("analyze_ prefix → read", () => assert.equal(isWriteTool("analyze_wasted_spend"), false));
40
- test("explicit write: switch_primary_account", () => assert.equal(isWriteTool("switch_primary_account"), true));
41
- test("explicit write: infer_business_profile", () => assert.equal(isWriteTool("infer_business_profile"), true));
42
- test("echo_test → read", () => assert.equal(isWriteTool("echo_test"), false));
43
- });
44
-
45
- describe("fetchAndRegisterTools", () => {
46
- let restore: (() => void) | null = null;
47
- afterEach(() => { if (restore) { restore(); restore = null; } });
48
-
49
- test("registers tools from server", async () => {
50
- const api = makeMockApi();
51
- const config = parseConfig({ testMode: true, serverUrl: "https://test.example.com" });
52
- const client = makeClient(config);
53
-
54
- restore = installMockFetch(async () =>
55
- jsonResponse({
56
- jsonrpc: "2.0",
57
- result: {
58
- tools: [
59
- { name: "echo_test", description: "test", inputSchema: {} },
60
- { name: "create_search_campaign", description: "create", inputSchema: {} },
61
- ],
62
- },
63
- id: "1",
64
- }),
65
- );
66
-
67
- const count = await fetchAndRegisterTools(api as any, client, config);
68
- assert.equal(count, 2);
69
- assert.equal(api._registered.length, 2);
70
- // echo_test is read, no confirmation needed
71
- assert.equal(api._registered[0].opts, undefined);
72
- // create_search_campaign is write, needs confirmation
73
- assert.deepEqual(api._registered[1].opts, { needsConfirmation: true });
74
- });
75
-
76
- test("filters by enabledGroups", async () => {
77
- const api = makeMockApi();
78
- const config = parseConfig({ testMode: true, serverUrl: "https://test.example.com", enabledGroups: ["meta_ads"] });
79
- const client = makeClient(config);
80
-
81
- restore = installMockFetch(async () =>
82
- jsonResponse({
83
- jsonrpc: "2.0",
84
- result: {
85
- tools: [
86
- { name: "get_campaign_performance", description: "google", inputSchema: {} },
87
- { name: "get_meta_campaign_performance", description: "meta", inputSchema: {} },
88
- { name: "get_usage_status", description: "system", inputSchema: {} }, // system always
89
- ],
90
- },
91
- id: "1",
92
- }),
93
- );
94
-
95
- const count = await fetchAndRegisterTools(api as any, client, config);
96
- // get_campaign_performance filtered out (google_ads), meta + system stay
97
- assert.equal(count, 2);
98
- });
99
- });
100
-
101
- describe("registerStaticFallbacks", () => {
102
- test("registers 6 fallback tools", () => {
103
- const api = makeMockApi();
104
- const config = parseConfig({ testMode: true });
105
- const client = makeClient(config);
106
- registerStaticFallbacks(api as any, client, config);
107
- assert.equal(api._registered.length, 6);
108
- });
109
- });
110
-
111
- describe("registerConnectTool", () => {
112
- test("registers adspirer_connect", () => {
113
- const api = makeMockApi();
114
- registerConnectTool(api as any);
115
- assert.equal(api._registered.length, 1);
116
- assert.equal(api._registered[0].name, "adspirer_connect");
117
- });
118
- });
package/tsconfig.json DELETED
@@ -1,20 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "ES2022",
5
- "moduleResolution": "bundler",
6
- "strict": true,
7
- "esModuleInterop": true,
8
- "skipLibCheck": true,
9
- "forceConsistentCasingInFileNames": true,
10
- "resolveJsonModule": true,
11
- "allowImportingTsExtensions": true,
12
- "noEmit": true,
13
- "rootDir": "."
14
- },
15
- "include": [
16
- "src/**/*.ts",
17
- "types/*.d.ts"
18
- ],
19
- "exclude": ["node_modules", "dist", "tests"]
20
- }
package/vitest.config.ts DELETED
@@ -1,9 +0,0 @@
1
- import { defineConfig } from "vitest/config";
2
-
3
- export default defineConfig({
4
- test: {
5
- globals: true,
6
- environment: "node",
7
- include: ["tests/**/*.test.ts"],
8
- },
9
- });