atracker-mcp-server 1.1.0 → 1.1.2

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/package.json CHANGED
@@ -1,17 +1,22 @@
1
1
  {
2
2
  "name": "atracker-mcp-server",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "MCP Server for ATracker self-hosted ad tracker",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "atracker-mcp-server": "./src/index.js"
8
8
  },
9
- "files": ["src"],
9
+ "files": [
10
+ "src"
11
+ ],
10
12
  "dependencies": {
11
- "@modelcontextprotocol/sdk": "^1.12.0"
13
+ "@modelcontextprotocol/sdk": "1.12.0"
12
14
  },
13
15
  "engines": {
14
16
  "node": ">=20"
15
17
  },
18
+ "scripts": {
19
+ "test": "node --test src/util.test.js"
20
+ },
16
21
  "license": "UNLICENSED"
17
22
  }
package/src/client.js CHANGED
@@ -1,6 +1,7 @@
1
- class ApiError extends Error {
1
+ export class ApiError extends Error {
2
2
  constructor(status, body) {
3
- super(body?.error || `HTTP ${status}`);
3
+ const detail = body?.message || body?.error || `HTTP ${status}`;
4
+ super(detail);
4
5
  this.status = status;
5
6
  this.body = body;
6
7
  }
package/src/index.js CHANGED
@@ -27,7 +27,7 @@ const client = createClient(url, token);
27
27
 
28
28
  const server = new McpServer({
29
29
  name: 'atracker',
30
- version: '1.1.0',
30
+ version: '1.1.1',
31
31
  });
32
32
 
33
33
  registerCampaignTools(server, client);
@@ -1,46 +1,64 @@
1
+ import { defineTool, jsonText, stripUndefined, toolErrorText } from '../util.js';
2
+
1
3
  export function registerCampaignTools(server, client) {
2
- server.tool('list_campaigns', 'List all campaigns. Optional filter by status.', { status: { type: 'string', description: 'active or paused', enum: ['active', 'paused'] } }, async ({ status }) => {
3
- const rows = await client.get('/campaigns', { query: { status } });
4
- return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] };
4
+ defineTool(server, 'list_campaigns', 'List all campaigns. Optional filter by status or group_name.', {
5
+ status: { type: 'string', description: 'active or paused', enum: ['active', 'paused'] },
6
+ group_name: { type: 'string', description: 'Filter by campaign group name (optional)' },
7
+ }, async ({ status, group_name }) => {
8
+ const rows = await client.get('/campaigns', { query: { status, group_name } });
9
+ return jsonText(rows);
5
10
  });
6
11
 
7
- server.tool('get_campaign', 'Get campaign details by ID.', { id: { type: 'string', description: 'Campaign UUID' } }, async ({ id }) => {
12
+ defineTool(server, 'get_campaign', 'Get campaign details by ID.', {
13
+ id: { type: 'string', description: 'Campaign UUID' },
14
+ }, async ({ id }) => {
8
15
  const row = await client.get(`/campaigns/${id}`);
9
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
16
+ return jsonText(row);
10
17
  });
11
18
 
12
- server.tool('create_campaign', 'Create a new campaign.', {
13
- name: { type: 'string', description: 'Campaign name' },
19
+ defineTool(server, 'create_campaign', 'Create a new campaign. Requires name.', {
20
+ name: { type: 'string', description: 'Campaign name (required)' },
14
21
  alias: { type: 'string', description: 'URL alias (optional)' },
15
- domain: { type: 'string', description: 'Domain (optional)' },
22
+ domain_id: { type: 'string', description: 'Domain UUID (optional)' },
23
+ domain: { type: 'string', description: 'Domain hostname — resolved to domain_id if set (optional)' },
24
+ group_name: { type: 'string', description: 'Campaign group name (optional)' },
16
25
  traffic_source_id: { type: 'string', description: 'Traffic source UUID (optional)' },
17
26
  }, async (args) => {
18
- const row = await client.post('/campaigns', args);
19
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
27
+ const body = stripUndefined(args);
28
+ if (!body.name) return toolErrorText('Validation error: name is required');
29
+ const row = await client.post('/campaigns', body);
30
+ return jsonText(row);
20
31
  });
21
32
 
22
- server.tool('update_campaign', 'Update an existing campaign.', {
33
+ defineTool(server, 'update_campaign', 'Update an existing campaign.', {
23
34
  id: { type: 'string', description: 'Campaign UUID' },
24
35
  name: { type: 'string', description: 'New name (optional)' },
25
36
  alias: { type: 'string', description: 'New alias (optional)' },
26
- domain: { type: 'string', description: 'New domain (optional)' },
37
+ domain_id: { type: 'string', description: 'Domain UUID (optional)' },
38
+ domain: { type: 'string', description: 'Domain hostname — resolved to domain_id if set (optional)' },
39
+ group_name: { type: 'string', description: 'Campaign group name (optional)' },
27
40
  }, async ({ id, ...rest }) => {
28
- const body = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined));
29
- const row = await client.put(`/campaigns/${id}`, body);
30
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
41
+ const row = await client.put(`/campaigns/${id}`, stripUndefined(rest));
42
+ return jsonText(row);
31
43
  });
32
44
 
33
- server.tool('pause_campaign', 'Pause a campaign.', { id: { type: 'string', description: 'Campaign UUID' } }, async ({ id }) => {
45
+ defineTool(server, 'pause_campaign', 'Pause a campaign.', {
46
+ id: { type: 'string', description: 'Campaign UUID' },
47
+ }, async ({ id }) => {
34
48
  const row = await client.patch(`/campaigns/${id}/status`, { status: 'paused' });
35
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
49
+ return jsonText(row);
36
50
  });
37
51
 
38
- server.tool('resume_campaign', 'Resume a paused campaign.', { id: { type: 'string', description: 'Campaign UUID' } }, async ({ id }) => {
52
+ defineTool(server, 'resume_campaign', 'Resume a paused campaign.', {
53
+ id: { type: 'string', description: 'Campaign UUID' },
54
+ }, async ({ id }) => {
39
55
  const row = await client.patch(`/campaigns/${id}/status`, { status: 'active' });
40
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
56
+ return jsonText(row);
41
57
  });
42
58
 
43
- server.tool('delete_campaign', 'Archive (soft-delete) a campaign.', { id: { type: 'string', description: 'Campaign UUID' } }, async ({ id }) => {
59
+ defineTool(server, 'delete_campaign', 'Archive (soft-delete) a campaign.', {
60
+ id: { type: 'string', description: 'Campaign UUID' },
61
+ }, async ({ id }) => {
44
62
  await client.del(`/campaigns/${id}`);
45
63
  return { content: [{ type: 'text', text: 'Campaign archived.' }] };
46
64
  });
@@ -1,20 +1,32 @@
1
+ import { defineTool, jsonText, stripUndefined, toolErrorText } from '../util.js';
2
+
1
3
  export function registerDomainTools(server, client) {
2
- server.tool('list_domains', 'List domains. Optional filter by status.', { status: { type: 'string', description: 'active or paused', enum: ['active', 'paused'] } }, async ({ status }) => {
4
+ defineTool(server, 'list_domains', 'List domains. Optional filter by status.', {
5
+ status: { type: 'string', description: 'active or paused', enum: ['active', 'paused'] },
6
+ }, async ({ status }) => {
3
7
  const rows = await client.get('/domains', { query: { status } });
4
- return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] };
8
+ return jsonText(rows);
5
9
  });
6
10
 
7
- server.tool('get_domain', 'Get domain details by ID.', { id: { type: 'string', description: 'Domain UUID' } }, async ({ id }) => {
11
+ defineTool(server, 'get_domain', 'Get domain details by ID.', {
12
+ id: { type: 'string', description: 'Domain UUID' },
13
+ }, async ({ id }) => {
8
14
  const row = await client.get(`/domains/${id}`);
9
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
15
+ return jsonText(row);
10
16
  });
11
17
 
12
- server.tool('create_domain', 'Create a new domain.', { domain: { type: 'string', description: 'Domain name (e.g. trk.example.com)' } }, async (args) => {
13
- const row = await client.post('/domains', args);
14
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
18
+ defineTool(server, 'create_domain', 'Create a new domain. Requires domain hostname.', {
19
+ domain: { type: 'string', description: 'Domain name e.g. trk.example.com (required)' },
20
+ }, async (args) => {
21
+ const body = stripUndefined(args);
22
+ if (!body.domain) return toolErrorText('Validation error: domain is required');
23
+ const row = await client.post('/domains', body);
24
+ return jsonText(row);
15
25
  });
16
26
 
17
- server.tool('delete_domain', 'Delete a domain.', { id: { type: 'string', description: 'Domain UUID' } }, async ({ id }) => {
27
+ defineTool(server, 'delete_domain', 'Delete a domain.', {
28
+ id: { type: 'string', description: 'Domain UUID' },
29
+ }, async ({ id }) => {
18
30
  await client.del(`/domains/${id}`);
19
31
  return { content: [{ type: 'text', text: 'Domain deleted.' }] };
20
32
  });
@@ -1,32 +1,43 @@
1
+ import { defineTool, jsonText, stripUndefined, toolErrorText } from '../util.js';
2
+
1
3
  export function registerFlowTools(server, client) {
2
- server.tool('list_flows', 'List flows. Optional filter by campaign_id.', { campaign_id: { type: 'string', description: 'Campaign UUID (optional)' } }, async ({ campaign_id }) => {
4
+ defineTool(server, 'list_flows', 'List flows. Optional filter by campaign_id.', {
5
+ campaign_id: { type: 'string', description: 'Campaign UUID (optional)' },
6
+ }, async ({ campaign_id }) => {
3
7
  const rows = await client.get('/flows', { query: { campaign_id } });
4
- return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] };
8
+ return jsonText(rows);
5
9
  });
6
10
 
7
- server.tool('get_flow', 'Get flow details by ID.', { id: { type: 'string', description: 'Flow UUID' } }, async ({ id }) => {
11
+ defineTool(server, 'get_flow', 'Get flow details by ID.', {
12
+ id: { type: 'string', description: 'Flow UUID' },
13
+ }, async ({ id }) => {
8
14
  const row = await client.get(`/flows/${id}`);
9
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
15
+ return jsonText(row);
10
16
  });
11
17
 
12
- server.tool('create_flow', 'Create a new flow for a campaign.', {
13
- campaign_id: { type: 'string', description: 'Campaign UUID' },
14
- name: { type: 'string', description: 'Flow name' },
18
+ defineTool(server, 'create_flow', 'Create a new flow for a campaign.', {
19
+ campaign_id: { type: 'string', description: 'Campaign UUID (required)' },
20
+ name: { type: 'string', description: 'Flow name (required)' },
15
21
  }, async (args) => {
16
- const row = await client.post('/flows', args);
17
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
22
+ const body = stripUndefined(args);
23
+ if (!body.campaign_id) return toolErrorText('Validation error: campaign_id is required');
24
+ if (!body.name) return toolErrorText('Validation error: name is required');
25
+ const row = await client.post('/flows', body);
26
+ return jsonText(row);
18
27
  });
19
28
 
20
- server.tool('update_flow', 'Update an existing flow.', {
29
+ defineTool(server, 'update_flow', 'Update an existing flow.', {
21
30
  id: { type: 'string', description: 'Flow UUID' },
22
31
  name: { type: 'string', description: 'New name (optional)' },
32
+ campaign_id: { type: 'string', description: 'Move flow to another campaign UUID (optional)' },
23
33
  }, async ({ id, ...rest }) => {
24
- const body = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined));
25
- const row = await client.put(`/flows/${id}`, body);
26
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
34
+ const row = await client.put(`/flows/${id}`, stripUndefined(rest));
35
+ return jsonText(row);
27
36
  });
28
37
 
29
- server.tool('delete_flow', 'Delete a flow.', { id: { type: 'string', description: 'Flow UUID' } }, async ({ id }) => {
38
+ defineTool(server, 'delete_flow', 'Delete a flow.', {
39
+ id: { type: 'string', description: 'Flow UUID' },
40
+ }, async ({ id }) => {
30
41
  await client.del(`/flows/${id}`);
31
42
  return { content: [{ type: 'text', text: 'Flow deleted.' }] };
32
43
  });
package/src/tools/geo.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { defineTool, jsonText, stripUndefined } from '../util.js';
2
+
1
3
  const CATEGORIES = ['geo', 'asn', 'threat'];
2
4
  const POLL_INTERVAL_MS = 1000;
3
5
  const POLL_TIMEOUT_MS = 30 * 60 * 1000;
@@ -8,12 +10,14 @@ async function waitForDownload(client, categories) {
8
10
  // eslint-disable-next-line no-constant-condition
9
11
  while (true) {
10
12
  if (Date.now() - startedAt > POLL_TIMEOUT_MS) {
11
- throw new Error('Timed out waiting for geo bundle download');
13
+ throw new Error('Timed out waiting for geo bundle download (30 min)');
12
14
  }
13
15
  const state = await client.get('/geo2ip/download-status');
14
16
  const summaries = targets.map((c) => ({ category: c, ...(state[c] || {}) }));
15
17
  const errored = summaries.find((s) => s.status === 'error');
16
- if (errored) throw new Error(`Download failed for ${errored.category}: ${errored.error || 'unknown'}`);
18
+ if (errored) {
19
+ throw new Error(`Download failed for ${errored.category}: ${errored.error || 'unknown'}`);
20
+ }
17
21
  const active = summaries.find((s) => !['done', 'idle'].includes(s.status));
18
22
  if (!active) return summaries;
19
23
  await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
@@ -21,58 +25,51 @@ async function waitForDownload(client, categories) {
21
25
  }
22
26
 
23
27
  export function registerGeoTools(server, client) {
24
- server.tool(
28
+ defineTool(
29
+ server,
25
30
  'geo_status',
26
- 'Get current geo2ip bundle versions, sizes, and last download status for all three categories (geo, asn, threat).',
31
+ 'Get geo2ip bundle versions, sizes, and download state (admin token).',
27
32
  {},
28
- async () => {
29
- const data = await client.get('/geo2ip/bundle-status');
30
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
31
- }
33
+ async () => jsonText(await client.get('/geo2ip/bundle-status')),
32
34
  );
33
35
 
34
- server.tool(
36
+ defineTool(
37
+ server,
35
38
  'geo_check_update',
36
- 'Check the ATracker platform for newer geo bundle versions. Returns current versions, available manifests (including delta info), and the list of categories that have updates.',
39
+ 'Check platform for newer geo bundle versions (admin token).',
37
40
  {},
38
- async () => {
39
- const data = await client.post('/geo2ip/check-updates', {});
40
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
41
- }
41
+ async () => jsonText(await client.post('/geo2ip/check-updates', {})),
42
42
  );
43
43
 
44
- server.tool(
44
+ defineTool(
45
+ server,
45
46
  'geo_update',
46
- 'Download and apply the latest geo bundles from the platform. Optionally limit to specific categories (geo|asn|threat). Polls until completion and returns the final bundle status.',
47
+ 'Download and apply latest geo bundles (admin token). Polls until complete.',
47
48
  {
48
- categories: { type: 'array', items: { type: 'string', enum: CATEGORIES }, description: 'Optional subset of categories to update' },
49
+ categories: {
50
+ type: 'array',
51
+ items: { type: 'string', enum: CATEGORIES },
52
+ description: 'Optional subset: geo, asn, threat',
53
+ },
49
54
  force: { type: 'boolean', description: 'Re-download even if bundles are up to date' },
50
55
  },
51
56
  async (args = {}) => {
52
- const body = {};
53
- if (Array.isArray(args.categories) && args.categories.length) body.categories = args.categories;
54
- if (args.force) body.force = true;
57
+ const body = stripUndefined({
58
+ categories: Array.isArray(args.categories) && args.categories.length ? args.categories : undefined,
59
+ force: args.force ? true : undefined,
60
+ });
55
61
  await client.post('/geo2ip/download-bundle', body);
56
62
  const summaries = await waitForDownload(client, body.categories);
57
63
  const finalStatus = await client.get('/geo2ip/bundle-status');
58
- return {
59
- content: [{
60
- type: 'text',
61
- text: JSON.stringify({ summaries, status: finalStatus }, null, 2),
62
- }],
63
- };
64
- }
64
+ return jsonText({ summaries, status: finalStatus });
65
+ },
65
66
  );
66
67
 
67
- server.tool(
68
+ defineTool(
69
+ server,
68
70
  'geo_lookup',
69
- 'Look up geo, ASN, and threat info for an IPv4/IPv6 address using the locally loaded bundles. Returns resolved data with per-field source attribution.',
70
- {
71
- ip: { type: 'string', description: 'IPv4 or IPv6 address to look up' },
72
- },
73
- async ({ ip }) => {
74
- const data = await client.post('/geo2ip/test-lookup', { ip });
75
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
76
- }
71
+ 'Look up geo, ASN, and threat info for an IP (admin token).',
72
+ { ip: { type: 'string', description: 'IPv4 or IPv6 address' } },
73
+ async ({ ip }) => jsonText(await client.post('/geo2ip/test-lookup', { ip })),
77
74
  );
78
75
  }
@@ -1,33 +1,44 @@
1
+ import { defineTool, jsonText, stripUndefined, toolErrorText } from '../util.js';
2
+
1
3
  export function registerLandingTools(server, client) {
2
- server.tool('list_landings', 'List landing pages. Optional filter by status.', { status: { type: 'string', description: 'active or paused', enum: ['active', 'paused'] } }, async ({ status }) => {
4
+ defineTool(server, 'list_landings', 'List landing pages. Optional filter by status.', {
5
+ status: { type: 'string', description: 'active or paused', enum: ['active', 'paused'] },
6
+ }, async ({ status }) => {
3
7
  const rows = await client.get('/landings', { query: { status } });
4
- return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] };
8
+ return jsonText(rows);
5
9
  });
6
10
 
7
- server.tool('get_landing', 'Get landing page details by ID.', { id: { type: 'string', description: 'Landing page UUID' } }, async ({ id }) => {
11
+ defineTool(server, 'get_landing', 'Get landing page details by ID.', {
12
+ id: { type: 'string', description: 'Landing page UUID' },
13
+ }, async ({ id }) => {
8
14
  const row = await client.get(`/landings/${id}`);
9
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
15
+ return jsonText(row);
10
16
  });
11
17
 
12
- server.tool('create_landing', 'Create a new landing page.', {
13
- name: { type: 'string', description: 'Landing page name' },
14
- url: { type: 'string', description: 'Landing page URL' },
18
+ defineTool(server, 'create_landing', 'Create a new landing page. Requires name.', {
19
+ name: { type: 'string', description: 'Landing page name (required)' },
20
+ url: { type: 'string', description: 'Landing page URL (optional)' },
21
+ group_name: { type: 'string', description: 'Landing group name (optional)' },
15
22
  }, async (args) => {
16
- const row = await client.post('/landings', args);
17
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
23
+ const body = stripUndefined(args);
24
+ if (!body.name) return toolErrorText('Validation error: name is required');
25
+ const row = await client.post('/landings', body);
26
+ return jsonText(row);
18
27
  });
19
28
 
20
- server.tool('update_landing', 'Update an existing landing page.', {
29
+ defineTool(server, 'update_landing', 'Update an existing landing page.', {
21
30
  id: { type: 'string', description: 'Landing page UUID' },
22
31
  name: { type: 'string', description: 'New name (optional)' },
23
32
  url: { type: 'string', description: 'New URL (optional)' },
33
+ group_name: { type: 'string', description: 'Landing group name (optional)' },
24
34
  }, async ({ id, ...rest }) => {
25
- const body = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined));
26
- const row = await client.put(`/landings/${id}`, body);
27
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
35
+ const row = await client.put(`/landings/${id}`, stripUndefined(rest));
36
+ return jsonText(row);
28
37
  });
29
38
 
30
- server.tool('delete_landing', 'Archive (soft-delete) a landing page.', { id: { type: 'string', description: 'Landing page UUID' } }, async ({ id }) => {
39
+ defineTool(server, 'delete_landing', 'Archive (soft-delete) a landing page.', {
40
+ id: { type: 'string', description: 'Landing page UUID' },
41
+ }, async ({ id }) => {
31
42
  await client.del(`/landings/${id}`);
32
43
  return { content: [{ type: 'text', text: 'Landing page archived.' }] };
33
44
  });
@@ -1,36 +1,48 @@
1
+ import { defineTool, jsonText, stripUndefined, toolErrorText } from '../util.js';
2
+
1
3
  export function registerOfferTools(server, client) {
2
- server.tool('list_offers', 'List all offers. Optional filter by status.', { status: { type: 'string', description: 'active or paused', enum: ['active', 'paused'] } }, async ({ status }) => {
4
+ defineTool(server, 'list_offers', 'List all offers. Optional filter by status.', {
5
+ status: { type: 'string', description: 'active or paused', enum: ['active', 'paused'] },
6
+ }, async ({ status }) => {
3
7
  const rows = await client.get('/offers', { query: { status } });
4
- return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] };
8
+ return jsonText(rows);
5
9
  });
6
10
 
7
- server.tool('get_offer', 'Get offer details by ID.', { id: { type: 'string', description: 'Offer UUID' } }, async ({ id }) => {
11
+ defineTool(server, 'get_offer', 'Get offer details by ID.', {
12
+ id: { type: 'string', description: 'Offer UUID' },
13
+ }, async ({ id }) => {
8
14
  const row = await client.get(`/offers/${id}`);
9
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
15
+ return jsonText(row);
10
16
  });
11
17
 
12
- server.tool('create_offer', 'Create a new offer.', {
13
- name: { type: 'string', description: 'Offer name' },
14
- url: { type: 'string', description: 'Offer URL' },
18
+ defineTool(server, 'create_offer', 'Create a new offer. Requires name.', {
19
+ name: { type: 'string', description: 'Offer name (required)' },
20
+ url: { type: 'string', description: 'Offer URL (optional)' },
15
21
  payout: { type: 'number', description: 'Payout amount (optional)' },
16
22
  currency: { type: 'string', description: 'Currency code (optional)' },
23
+ group_name: { type: 'string', description: 'Offer group name (optional)' },
17
24
  }, async (args) => {
18
- const row = await client.post('/offers', args);
19
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
25
+ const body = stripUndefined(args);
26
+ if (!body.name) return toolErrorText('Validation error: name is required');
27
+ const row = await client.post('/offers', body);
28
+ return jsonText(row);
20
29
  });
21
30
 
22
- server.tool('update_offer', 'Update an existing offer.', {
31
+ defineTool(server, 'update_offer', 'Update an existing offer.', {
23
32
  id: { type: 'string', description: 'Offer UUID' },
24
33
  name: { type: 'string', description: 'New name (optional)' },
25
34
  url: { type: 'string', description: 'New URL (optional)' },
26
35
  payout: { type: 'number', description: 'New payout (optional)' },
36
+ currency: { type: 'string', description: 'Currency code (optional)' },
37
+ group_name: { type: 'string', description: 'Offer group name (optional)' },
27
38
  }, async ({ id, ...rest }) => {
28
- const body = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined));
29
- const row = await client.put(`/offers/${id}`, body);
30
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
39
+ const row = await client.put(`/offers/${id}`, stripUndefined(rest));
40
+ return jsonText(row);
31
41
  });
32
42
 
33
- server.tool('delete_offer', 'Archive (soft-delete) an offer.', { id: { type: 'string', description: 'Offer UUID' } }, async ({ id }) => {
43
+ defineTool(server, 'delete_offer', 'Archive (soft-delete) an offer.', {
44
+ id: { type: 'string', description: 'Offer UUID' },
45
+ }, async ({ id }) => {
34
46
  await client.del(`/offers/${id}`);
35
47
  return { content: [{ type: 'text', text: 'Offer archived.' }] };
36
48
  });
@@ -1,22 +1,41 @@
1
+ import { defineTool, jsonText } from '../util.js';
2
+
3
+ function normalizeLimit(limit) {
4
+ if (limit === undefined || limit === null || limit === '') return undefined;
5
+ const n = Number(limit);
6
+ if (!Number.isFinite(n) || n < 1) return 100;
7
+ return Math.min(Math.floor(n), 1000);
8
+ }
9
+
1
10
  export function registerReportTools(server, client) {
2
- server.tool('get_reports', 'Get tracker report data with date range and grouping.', {
11
+ defineTool(server, 'get_reports', 'Get tracker report data with date range and grouping.', {
3
12
  date_from: { type: 'string', description: 'Start date YYYY-MM-DD (default: 7 days ago)' },
4
- date_to: { type: 'string', description: 'End date YYYY-MM-DD (default: today)' },
5
- group_by: { type: 'string', description: 'Group by field: campaign, offer, country, etc.' },
13
+ date_to: { type: 'string', description: 'End date YYYY-MM-DD inclusive (default: today)' },
14
+ group_by: { type: 'string', description: 'Group by field: campaign, offer, country, category, city, hour' },
6
15
  campaign_id: { type: 'string', description: 'Filter by campaign UUID (optional)' },
7
16
  }, async ({ date_from, date_to, group_by, campaign_id }) => {
8
- const data = await client.get('/reports', { query: { date_from, date_to, group_by, campaign_id } });
9
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
17
+ const data = await client.get('/reports', {
18
+ query: { date_from, date_to, group_by, campaign_id },
19
+ });
20
+ return jsonText(data);
10
21
  });
11
22
 
12
- server.tool('get_conversions', 'Get conversion data with date range and filters.', {
23
+ defineTool(server, 'get_conversions', 'Get conversion log with date range and filters.', {
13
24
  date_from: { type: 'string', description: 'Start date YYYY-MM-DD' },
14
- date_to: { type: 'string', description: 'End date YYYY-MM-DD' },
25
+ date_to: { type: 'string', description: 'End date YYYY-MM-DD inclusive' },
15
26
  campaign_id: { type: 'string', description: 'Filter by campaign UUID (optional)' },
16
- status: { type: 'string', description: 'Conversion status (optional)' },
17
- limit: { type: 'number', description: 'Max rows (default 100, max 10000)' },
27
+ status: { type: 'string', description: 'Conversion status: lead, sale, rejected, registration, deposit, rebill (optional)' },
28
+ limit: { type: 'number', description: 'Max rows (default 100, max 1000)' },
18
29
  }, async ({ date_from, date_to, campaign_id, status, limit }) => {
19
- const data = await client.get('/conversions', { query: { date_from, date_to, campaign_id, status, limit } });
20
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
30
+ const data = await client.get('/conversions', {
31
+ query: {
32
+ date_from,
33
+ date_to,
34
+ campaign_id,
35
+ status,
36
+ limit: normalizeLimit(limit),
37
+ },
38
+ });
39
+ return jsonText(data);
21
40
  });
22
41
  }
@@ -1,20 +1,39 @@
1
+ import { defineTool, jsonText, toolErrorText } from '../util.js';
2
+
1
3
  export function registerSettingsTools(server, client) {
2
- server.tool('get_settings', 'Get all tracker settings. Optional filter by category.', {
4
+ defineTool(server, 'get_settings', 'Get tracker settings (admin token). Optional filter by category. Secrets are redacted.', {
3
5
  category: { type: 'string', description: 'Settings category (optional)' },
4
6
  }, async ({ category }) => {
5
7
  const data = await client.get('/settings', { query: { category } });
6
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
8
+ return jsonText(data);
7
9
  });
8
10
 
9
- server.tool('update_settings', 'Update one or more tracker settings.', {
11
+ defineTool(server, 'update_settings', 'Update one or more tracker settings (admin token). Body is JSON array of {key, value}.', {
10
12
  settings: {
11
13
  type: 'string',
12
14
  description: 'JSON array of { key, value } objects, e.g. [{"key":"default_currency","value":"EUR"}]',
13
15
  },
14
16
  }, async ({ settings }) => {
15
17
  let items;
16
- try { items = JSON.parse(settings); } catch { return { content: [{ type: 'text', text: 'Error: settings must be a valid JSON array of {key, value} objects.' }], isError: true }; }
18
+ try {
19
+ items = JSON.parse(settings);
20
+ } catch {
21
+ return toolErrorText('Error: settings must be a valid JSON array of {key, value} objects.');
22
+ }
23
+ if (!Array.isArray(items)) {
24
+ return toolErrorText('Error: settings must be a JSON array.');
25
+ }
17
26
  const data = await client.put('/settings', items);
18
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
27
+ return jsonText(data);
28
+ });
29
+
30
+ defineTool(server, 'get_global_postback', 'Get global postback URL and installation token (resolves campaign from subid).', {}, async () => {
31
+ const data = await client.get('/global-postback');
32
+ return jsonText(data);
33
+ });
34
+
35
+ defineTool(server, 'regenerate_global_postback_token', 'Regenerate global postback token. The old global postback URL stops working.', {}, async () => {
36
+ const data = await client.post('/global-postback/regenerate');
37
+ return jsonText(data);
19
38
  });
20
39
  }
@@ -1,32 +1,40 @@
1
+ import { defineTool, jsonText, stripUndefined, toolErrorText } from '../util.js';
2
+
1
3
  export function registerSourceTools(server, client) {
2
- server.tool('list_sources', 'List traffic sources.', {}, async () => {
4
+ defineTool(server, 'list_sources', 'List traffic sources.', {}, async () => {
3
5
  const rows = await client.get('/sources');
4
- return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] };
6
+ return jsonText(rows);
5
7
  });
6
8
 
7
- server.tool('get_source', 'Get traffic source details by ID.', { id: { type: 'string', description: 'Traffic source UUID' } }, async ({ id }) => {
9
+ defineTool(server, 'get_source', 'Get traffic source details by ID.', {
10
+ id: { type: 'string', description: 'Traffic source UUID' },
11
+ }, async ({ id }) => {
8
12
  const row = await client.get(`/sources/${id}`);
9
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
13
+ return jsonText(row);
10
14
  });
11
15
 
12
- server.tool('create_source', 'Create a new traffic source.', {
13
- name: { type: 'string', description: 'Source name' },
16
+ defineTool(server, 'create_source', 'Create a new traffic source. Requires name.', {
17
+ name: { type: 'string', description: 'Source name (required)' },
14
18
  type: { type: 'string', description: 'Source type (optional)' },
15
19
  }, async (args) => {
16
- const row = await client.post('/sources', args);
17
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
20
+ const body = stripUndefined(args);
21
+ if (!body.name) return toolErrorText('Validation error: name is required');
22
+ const row = await client.post('/sources', body);
23
+ return jsonText(row);
18
24
  });
19
25
 
20
- server.tool('update_source', 'Update a traffic source.', {
26
+ defineTool(server, 'update_source', 'Update a traffic source.', {
21
27
  id: { type: 'string', description: 'Traffic source UUID' },
22
28
  name: { type: 'string', description: 'New name (optional)' },
29
+ type: { type: 'string', description: 'Source type (optional)' },
23
30
  }, async ({ id, ...rest }) => {
24
- const body = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined));
25
- const row = await client.put(`/sources/${id}`, body);
26
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
31
+ const row = await client.put(`/sources/${id}`, stripUndefined(rest));
32
+ return jsonText(row);
27
33
  });
28
34
 
29
- server.tool('delete_source', 'Delete a traffic source.', { id: { type: 'string', description: 'Traffic source UUID' } }, async ({ id }) => {
35
+ defineTool(server, 'delete_source', 'Delete a traffic source.', {
36
+ id: { type: 'string', description: 'Traffic source UUID' },
37
+ }, async ({ id }) => {
30
38
  await client.del(`/sources/${id}`);
31
39
  return { content: [{ type: 'text', text: 'Traffic source deleted.' }] };
32
40
  });
@@ -1,6 +1,8 @@
1
+ import { defineTool, jsonText } from '../util.js';
2
+
1
3
  export function registerStatusTools(server, client) {
2
- server.tool('get_system_status', 'Get tracker system status: hostname, CPU, memory, uptime, version.', {}, async () => {
4
+ defineTool(server, 'get_system_status', 'Get tracker system status: hostname, CPU, memory, uptime, version.', {}, async () => {
3
5
  const data = await client.get('/system-status');
4
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
6
+ return jsonText(data);
5
7
  });
6
8
  }
package/src/util.js ADDED
@@ -0,0 +1,48 @@
1
+ import { ApiError } from './client.js';
2
+
3
+ export function stripUndefined(obj) {
4
+ if (!obj || typeof obj !== 'object') return {};
5
+ return Object.fromEntries(
6
+ Object.entries(obj).filter(([, v]) => v !== undefined && v !== null),
7
+ );
8
+ }
9
+
10
+ export function formatApiErrorMessage(err) {
11
+ if (err instanceof ApiError) {
12
+ const body = err.body;
13
+ const detail =
14
+ (body && (body.message || body.error)) ||
15
+ err.message ||
16
+ `HTTP ${err.status}`;
17
+ return `API error ${err.status}: ${detail}`;
18
+ }
19
+ return err?.message || String(err);
20
+ }
21
+
22
+ export function jsonText(data) {
23
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
24
+ }
25
+
26
+ export function toolError(err) {
27
+ return {
28
+ content: [{ type: 'text', text: formatApiErrorMessage(err) }],
29
+ isError: true,
30
+ };
31
+ }
32
+
33
+ export function toolErrorText(text) {
34
+ return { content: [{ type: 'text', text }], isError: true };
35
+ }
36
+
37
+ /**
38
+ * Registers an MCP tool with consistent API error handling (isError + message).
39
+ */
40
+ export function defineTool(server, name, description, schema, handler) {
41
+ server.tool(name, description, schema, async (args) => {
42
+ try {
43
+ return await handler(args ?? {});
44
+ } catch (err) {
45
+ return toolError(err);
46
+ }
47
+ });
48
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { ApiError } from './client.js';
4
+ import { stripUndefined, formatApiErrorMessage } from './util.js';
5
+
6
+ describe('stripUndefined', () => {
7
+ it('removes null and undefined keys', () => {
8
+ assert.deepEqual(stripUndefined({ a: 1, b: undefined, c: null, d: '' }), { a: 1, d: '' });
9
+ });
10
+ });
11
+
12
+ describe('formatApiErrorMessage', () => {
13
+ it('prefers Fastify message over generic error', () => {
14
+ const err = new ApiError(500, {
15
+ statusCode: 500,
16
+ error: 'Internal Server Error',
17
+ message: 'reportService.getCampaignReport is not a function',
18
+ });
19
+ const msg = formatApiErrorMessage(err);
20
+ assert.match(msg, /getCampaignReport/);
21
+ assert.notEqual(msg, 'API error 500: Internal Server Error');
22
+ });
23
+
24
+ it('formats plain errors', () => {
25
+ assert.equal(formatApiErrorMessage(new Error('network')), 'network');
26
+ });
27
+ });