@attrkit/mcp 0.2.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/README.md +67 -0
- package/dist/cli.js +616 -0
- package/package.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# @attrkit/mcp
|
|
2
|
+
|
|
3
|
+
A stdio [MCP](https://modelcontextprotocol.io) server that lets you drive [AttriKit](https://attrikit.io)
|
|
4
|
+
from Codex, Cursor, Gemini, or another MCP client. It is a thin client over AttriKit's public API — it
|
|
5
|
+
holds no database access; your tenant scope is resolved from your secret management key.
|
|
6
|
+
|
|
7
|
+
## What it exposes
|
|
8
|
+
|
|
9
|
+
| Area | Tools |
|
|
10
|
+
|---|---|
|
|
11
|
+
| Key scope | `whoami` |
|
|
12
|
+
| Apps | `list_apps`, `create_app`, `get_app`, `update_app`, `archive_app` |
|
|
13
|
+
| SDK keys | `get_publishable_key`, `rotate_publishable_key` |
|
|
14
|
+
| Management keys | `list_api_keys`, `create_api_key`, `revoke_api_key` |
|
|
15
|
+
| Campaign links | `list_links`, `create_campaign_link`, `update_link_status` |
|
|
16
|
+
| Decisions and reporting | `list_campaigns`, `list_ads`, `list_recent_events`, `get_campaign_decision`, `record_campaign_decision`, `get_attribution_rollup` |
|
|
17
|
+
| Spend and costs | `get_spend`, `import_spend`, `set_cost_model`, `pull_network_spend` |
|
|
18
|
+
| Ad integrations | `list_integrations`, `create_integration`, `update_integration`, `delete_integration` |
|
|
19
|
+
| RevenueCat | `get_revenuecat`, `configure_revenuecat` |
|
|
20
|
+
| Outbound webhooks | `list_webhooks`, `create_webhook`, `delete_webhook` |
|
|
21
|
+
| Data export | `export_events` |
|
|
22
|
+
|
|
23
|
+
`get_campaign_decision` returns the engine's call **verbatim** — the model reports it, it does not recompute it.
|
|
24
|
+
|
|
25
|
+
## Setup
|
|
26
|
+
|
|
27
|
+
1. Obtain one workspace management key (`sk_…`). Once you have that bootstrap key, all operational
|
|
28
|
+
setup—including apps and delegated app keys—can be completed through MCP without the web dashboard.
|
|
29
|
+
Keep `write` only on agents that should mutate the workspace.
|
|
30
|
+
2. Add the server to your client config:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"attrkit": {
|
|
36
|
+
"command": "npx",
|
|
37
|
+
"args": ["-y", "@attrkit/mcp"],
|
|
38
|
+
"env": {
|
|
39
|
+
"ATTRKIT_API_KEY": "sk_your_key_here",
|
|
40
|
+
"ATTRKIT_API_URL": "https://attrikit.io"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`ATTRKIT_API_URL` is optional (defaults to `https://attrikit.io`).
|
|
48
|
+
|
|
49
|
+
## Test it locally
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
ATTRKIT_API_KEY=sk_... npx @modelcontextprotocol/inspector npx -y @attrkit/mcp
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The Inspector lists the tools and lets you invoke them against your live workspace.
|
|
56
|
+
|
|
57
|
+
## Notes
|
|
58
|
+
|
|
59
|
+
- stdio only — no network listener, no port bind.
|
|
60
|
+
- Key scopes gate reads vs writes; a read-only key gets `403 forbidden_scope` on every mutation.
|
|
61
|
+
- App-scoped keys cannot create other apps, reach sibling apps, or manage workspace-wide integrations.
|
|
62
|
+
- App creation, management-key creation, and webhook creation return secret values once. Capture them
|
|
63
|
+
from the tool result; list tools only return masked metadata.
|
|
64
|
+
- Mutation tools accept an optional `idempotency_key`. Reuse the same value only for an exact retry;
|
|
65
|
+
one-time secret responses are encrypted at rest and safely replayed.
|
|
66
|
+
- Management keys are protected by distributed per-key global, mutation, and expensive-operation limits.
|
|
67
|
+
- All diagnostics go to stderr (stdout is the MCP transport).
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
|
|
6
|
+
// src/index.ts
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
function readConfig(env = process.env) {
|
|
10
|
+
const apiKey = env.ATTRKIT_API_KEY;
|
|
11
|
+
if (!apiKey) {
|
|
12
|
+
throw new Error("ATTRKIT_API_KEY is required (create one in AttriKit → Settings → API keys).");
|
|
13
|
+
}
|
|
14
|
+
const apiUrl = (env.ATTRKIT_API_URL ?? "https://attrikit.io").replace(/\/+$/, "");
|
|
15
|
+
return { apiKey, apiUrl };
|
|
16
|
+
}
|
|
17
|
+
var REQUEST_TIMEOUT_MS = 15000;
|
|
18
|
+
async function callApi(config, path, options = {}) {
|
|
19
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
20
|
+
const url = new URL(`${config.apiUrl}${path}`);
|
|
21
|
+
for (const [key, value] of Object.entries(options.query ?? {})) {
|
|
22
|
+
if (value !== undefined && value !== "")
|
|
23
|
+
url.searchParams.set(key, value);
|
|
24
|
+
}
|
|
25
|
+
const controller = new AbortController;
|
|
26
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
27
|
+
try {
|
|
28
|
+
const response = await doFetch(url.toString(), {
|
|
29
|
+
method: options.method ?? "GET",
|
|
30
|
+
headers: {
|
|
31
|
+
authorization: `AttriKit-Management ${config.apiKey}`,
|
|
32
|
+
"content-type": "application/json",
|
|
33
|
+
...options.idempotencyKey ? { "idempotency-key": options.idempotencyKey } : {}
|
|
34
|
+
},
|
|
35
|
+
...options.body !== undefined ? { body: JSON.stringify(options.body) } : {},
|
|
36
|
+
signal: controller.signal
|
|
37
|
+
});
|
|
38
|
+
const text = await response.text();
|
|
39
|
+
let json = null;
|
|
40
|
+
if (text) {
|
|
41
|
+
try {
|
|
42
|
+
json = JSON.parse(text);
|
|
43
|
+
} catch {
|
|
44
|
+
json = text;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { ok: response.ok, status: response.status, json };
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
status: 0,
|
|
52
|
+
json: { error: { code: "network_error", message: error instanceof Error ? error.message : String(error) } }
|
|
53
|
+
};
|
|
54
|
+
} finally {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function toToolResult(result) {
|
|
59
|
+
if (!result.ok) {
|
|
60
|
+
const envelope = result.json;
|
|
61
|
+
const code = envelope?.error?.code ?? `http_${result.status}`;
|
|
62
|
+
const message = envelope?.error?.message ? `: ${envelope.error.message}` : "";
|
|
63
|
+
return {
|
|
64
|
+
content: [{ type: "text", text: `AttriKit API error (${result.status || "network"}): ${code}${message}` }],
|
|
65
|
+
isError: true
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const structured = result.json && typeof result.json === "object" ? result.json : { value: result.json };
|
|
69
|
+
return {
|
|
70
|
+
content: [{ type: "text", text: JSON.stringify(result.json, null, 2) }],
|
|
71
|
+
structuredContent: structured
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
var periodSchema = z.enum(["7d", "30d", "90d", "all"]).describe("Reporting window. Defaults to 30d.").optional();
|
|
75
|
+
var appIdSchema = z.string().describe("Optional app id to scope the result to one app.").optional();
|
|
76
|
+
var requiredAppIdSchema = z.string().describe("The AttriKit app id.");
|
|
77
|
+
var idempotencyKeySchema = z.string().max(255).describe("Stable retry key for this exact mutation.").optional();
|
|
78
|
+
var ownersSchema = z.record(z.string(), z.enum(["attrkit", "revenuecat", "customer"]));
|
|
79
|
+
var spendRowSchema = z.object({
|
|
80
|
+
campaignId: z.string(),
|
|
81
|
+
adsetId: z.string().optional().nullable(),
|
|
82
|
+
adId: z.string().optional().nullable(),
|
|
83
|
+
adsetName: z.string().optional().nullable(),
|
|
84
|
+
adName: z.string().optional().nullable(),
|
|
85
|
+
metricDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
86
|
+
clicks: z.number().int().nonnegative().optional(),
|
|
87
|
+
impressions: z.number().int().nonnegative().optional(),
|
|
88
|
+
spendMinorUnits: z.number().int().nonnegative()
|
|
89
|
+
});
|
|
90
|
+
function createServer(config, fetchImpl) {
|
|
91
|
+
const server = new McpServer({ name: "attrkit", version: "0.2.0" });
|
|
92
|
+
const call = (path, options) => callApi(config, path, { fetchImpl, ...options });
|
|
93
|
+
server.registerTool("whoami", {
|
|
94
|
+
title: "Inspect management-key scope",
|
|
95
|
+
description: "Return the authenticated workspace, plan, app restriction and read/write scopes.",
|
|
96
|
+
inputSchema: {}
|
|
97
|
+
}, async () => toToolResult(await call("/api/v1/whoami")));
|
|
98
|
+
server.registerTool("list_apps", {
|
|
99
|
+
title: "List apps",
|
|
100
|
+
description: "List the iOS and Android apps in your AttriKit workspace (id, name, platform, bundle id, store id, status).",
|
|
101
|
+
inputSchema: {}
|
|
102
|
+
}, async () => toToolResult(await call("/api/v1/apps")));
|
|
103
|
+
server.registerTool("create_app", {
|
|
104
|
+
title: "Create an app",
|
|
105
|
+
description: "Create an iOS or Android app without the web UI. Returns the publishable key and a one-time app-scoped management key.",
|
|
106
|
+
inputSchema: {
|
|
107
|
+
name: z.string().min(1).max(80),
|
|
108
|
+
bundle_id: z.string().min(3).max(255),
|
|
109
|
+
platform: z.enum(["iOS", "Android"]),
|
|
110
|
+
store_url: z.string().url(),
|
|
111
|
+
timezone: z.string().max(64).optional(),
|
|
112
|
+
device_matching_mode: z.enum(["on", "strict"]).optional(),
|
|
113
|
+
idempotency_key: idempotencyKeySchema
|
|
114
|
+
}
|
|
115
|
+
}, async ({
|
|
116
|
+
name,
|
|
117
|
+
bundle_id,
|
|
118
|
+
platform,
|
|
119
|
+
store_url,
|
|
120
|
+
timezone,
|
|
121
|
+
device_matching_mode,
|
|
122
|
+
idempotency_key
|
|
123
|
+
}) => toToolResult(await call("/api/v1/apps", {
|
|
124
|
+
method: "POST",
|
|
125
|
+
idempotencyKey: idempotency_key,
|
|
126
|
+
body: {
|
|
127
|
+
name,
|
|
128
|
+
bundleId: bundle_id,
|
|
129
|
+
platform,
|
|
130
|
+
storeUrl: store_url,
|
|
131
|
+
...timezone ? { timezone } : {},
|
|
132
|
+
...device_matching_mode ? { deviceMatchingMode: device_matching_mode } : {}
|
|
133
|
+
}
|
|
134
|
+
})));
|
|
135
|
+
server.registerTool("get_app", {
|
|
136
|
+
title: "Get an app",
|
|
137
|
+
description: "Return one app's configuration.",
|
|
138
|
+
inputSchema: { app_id: requiredAppIdSchema }
|
|
139
|
+
}, async ({ app_id }) => toToolResult(await call(`/api/v1/apps/${encodeURIComponent(app_id)}`)));
|
|
140
|
+
server.registerTool("update_app", {
|
|
141
|
+
title: "Update an app",
|
|
142
|
+
description: "Update app metadata, store destination, timezone or device-matching mode.",
|
|
143
|
+
inputSchema: {
|
|
144
|
+
app_id: requiredAppIdSchema,
|
|
145
|
+
name: z.string().min(1).max(80).optional(),
|
|
146
|
+
bundle_id: z.string().min(3).max(255).optional(),
|
|
147
|
+
platform: z.enum(["iOS", "Android"]).optional(),
|
|
148
|
+
store_url: z.string().url().optional(),
|
|
149
|
+
timezone: z.string().max(64).optional(),
|
|
150
|
+
device_matching_mode: z.enum(["on", "strict"]).optional(),
|
|
151
|
+
idempotency_key: idempotencyKeySchema
|
|
152
|
+
}
|
|
153
|
+
}, async ({
|
|
154
|
+
app_id,
|
|
155
|
+
name,
|
|
156
|
+
bundle_id,
|
|
157
|
+
platform,
|
|
158
|
+
store_url,
|
|
159
|
+
timezone,
|
|
160
|
+
device_matching_mode,
|
|
161
|
+
idempotency_key
|
|
162
|
+
}) => toToolResult(await call(`/api/v1/apps/${encodeURIComponent(app_id)}`, {
|
|
163
|
+
method: "PATCH",
|
|
164
|
+
idempotencyKey: idempotency_key,
|
|
165
|
+
body: {
|
|
166
|
+
...name ? { name } : {},
|
|
167
|
+
...bundle_id ? { bundleId: bundle_id } : {},
|
|
168
|
+
...platform ? { platform } : {},
|
|
169
|
+
...store_url ? { storeUrl: store_url } : {},
|
|
170
|
+
...timezone ? { timezone } : {},
|
|
171
|
+
...device_matching_mode ? { deviceMatchingMode: device_matching_mode } : {}
|
|
172
|
+
}
|
|
173
|
+
})));
|
|
174
|
+
server.registerTool("archive_app", {
|
|
175
|
+
title: "Archive an app",
|
|
176
|
+
description: "Archive an app and remove it from active customer operations.",
|
|
177
|
+
inputSchema: { app_id: requiredAppIdSchema }
|
|
178
|
+
}, async ({ app_id }) => toToolResult(await call(`/api/v1/apps/${encodeURIComponent(app_id)}`, {
|
|
179
|
+
method: "DELETE"
|
|
180
|
+
})));
|
|
181
|
+
server.registerTool("get_publishable_key", {
|
|
182
|
+
title: "Get an app publishable key",
|
|
183
|
+
description: "Reveal the recoverable SDK publishable key for an app.",
|
|
184
|
+
inputSchema: { app_id: requiredAppIdSchema }
|
|
185
|
+
}, async ({ app_id }) => toToolResult(await call(`/api/v1/apps/${encodeURIComponent(app_id)}/publishable-key`)));
|
|
186
|
+
server.registerTool("rotate_publishable_key", {
|
|
187
|
+
title: "Rotate an app publishable key",
|
|
188
|
+
description: "Issue a new SDK publishable key and return the old-key grace deadline.",
|
|
189
|
+
inputSchema: {
|
|
190
|
+
app_id: requiredAppIdSchema,
|
|
191
|
+
idempotency_key: idempotencyKeySchema
|
|
192
|
+
}
|
|
193
|
+
}, async ({ app_id, idempotency_key }) => toToolResult(await call(`/api/v1/apps/${encodeURIComponent(app_id)}/publishable-key`, { method: "POST", idempotencyKey: idempotency_key })));
|
|
194
|
+
server.registerTool("list_api_keys", {
|
|
195
|
+
title: "List management keys",
|
|
196
|
+
description: "List masked workspace and app management keys. Secret plaintext is never returned.",
|
|
197
|
+
inputSchema: {}
|
|
198
|
+
}, async () => toToolResult(await call("/api/v1/api-keys")));
|
|
199
|
+
server.registerTool("create_api_key", {
|
|
200
|
+
title: "Create a management key",
|
|
201
|
+
description: "Create a read-only or read/write workspace or app key. The secret is returned once.",
|
|
202
|
+
inputSchema: {
|
|
203
|
+
label: z.string().min(1).max(80),
|
|
204
|
+
scopes: z.array(z.enum(["read", "write"])).min(1).max(2),
|
|
205
|
+
app_id: z.string().uuid().optional().nullable(),
|
|
206
|
+
idempotency_key: idempotencyKeySchema
|
|
207
|
+
}
|
|
208
|
+
}, async ({ label, scopes, app_id, idempotency_key }) => toToolResult(await call("/api/v1/api-keys", {
|
|
209
|
+
method: "POST",
|
|
210
|
+
idempotencyKey: idempotency_key,
|
|
211
|
+
body: { label, scopes, app_id: app_id ?? null }
|
|
212
|
+
})));
|
|
213
|
+
server.registerTool("revoke_api_key", {
|
|
214
|
+
title: "Revoke a management key",
|
|
215
|
+
description: "Immediately revoke a management key by id.",
|
|
216
|
+
inputSchema: { key_id: z.string().uuid() }
|
|
217
|
+
}, async ({ key_id }) => toToolResult(await call(`/api/v1/api-keys/${encodeURIComponent(key_id)}`, {
|
|
218
|
+
method: "DELETE"
|
|
219
|
+
})));
|
|
220
|
+
server.registerTool("list_links", {
|
|
221
|
+
title: "List campaign links",
|
|
222
|
+
description: "List tracking links, optionally for one app.",
|
|
223
|
+
inputSchema: {
|
|
224
|
+
app_id: appIdSchema,
|
|
225
|
+
cursor: z.string().optional(),
|
|
226
|
+
limit: z.number().int().min(1).max(200).optional()
|
|
227
|
+
}
|
|
228
|
+
}, async ({ app_id, cursor, limit }) => toToolResult(await call("/api/v1/links", {
|
|
229
|
+
query: {
|
|
230
|
+
appId: app_id,
|
|
231
|
+
cursor,
|
|
232
|
+
limit: limit === undefined ? undefined : String(limit)
|
|
233
|
+
}
|
|
234
|
+
})));
|
|
235
|
+
server.registerTool("list_campaigns", {
|
|
236
|
+
title: "List campaigns",
|
|
237
|
+
description: "List ad campaigns with their honest profit surface: the scale/cut/hold call, profit p05/p50/p95, spend, retention, and payback. Use this to see how campaigns are performing.",
|
|
238
|
+
inputSchema: { app_id: appIdSchema, period: periodSchema }
|
|
239
|
+
}, async ({ app_id, period }) => toToolResult(await call("/api/v1/reports/campaigns", { query: { appId: app_id, period } })));
|
|
240
|
+
server.registerTool("list_ads", {
|
|
241
|
+
title: "List creative-level ad performance",
|
|
242
|
+
description: "Return ad and ad-set performance, evidence tiers, ROAS, CPA, CPC and modeled remainders.",
|
|
243
|
+
inputSchema: {
|
|
244
|
+
app_id: appIdSchema,
|
|
245
|
+
period: periodSchema,
|
|
246
|
+
limit: z.number().int().min(1).max(200).optional()
|
|
247
|
+
}
|
|
248
|
+
}, async ({ app_id, period, limit }) => toToolResult(await call("/api/v1/reports/ads", {
|
|
249
|
+
query: {
|
|
250
|
+
appId: app_id,
|
|
251
|
+
period,
|
|
252
|
+
limit: limit === undefined ? undefined : String(limit)
|
|
253
|
+
}
|
|
254
|
+
})));
|
|
255
|
+
server.registerTool("list_recent_events", {
|
|
256
|
+
title: "List recent events and installs",
|
|
257
|
+
description: "Return the same recent attribution events and installs visible on the Live dashboard.",
|
|
258
|
+
inputSchema: {
|
|
259
|
+
app_id: appIdSchema,
|
|
260
|
+
limit: z.number().int().min(1).max(50).optional()
|
|
261
|
+
}
|
|
262
|
+
}, async ({ app_id, limit }) => toToolResult(await call("/api/v1/events", {
|
|
263
|
+
query: {
|
|
264
|
+
appId: app_id,
|
|
265
|
+
limit: limit === undefined ? undefined : String(limit)
|
|
266
|
+
}
|
|
267
|
+
})));
|
|
268
|
+
server.registerTool("get_campaign_decision", {
|
|
269
|
+
title: "Get campaign decisions (scale / cut / hold)",
|
|
270
|
+
description: "Return AttriKit's profit DECISIONS for your campaigns: the scale/cut/hold call and confidence, computed by the attribution engine. Report the `call` VERBATIM — do not recompute or override it; when it says not_enough_data, say so. This is the tool to drive budget automation.",
|
|
271
|
+
inputSchema: { app_id: appIdSchema }
|
|
272
|
+
}, async ({ app_id }) => toToolResult(await call("/api/v1/decisions", { query: { appId: app_id } })));
|
|
273
|
+
server.registerTool("record_campaign_decision", {
|
|
274
|
+
title: "Record a campaign decision",
|
|
275
|
+
description: "Freeze a scale/cut/hold decision and its exact model inputs for later maturity evaluation.",
|
|
276
|
+
inputSchema: {
|
|
277
|
+
app_id: requiredAppIdSchema,
|
|
278
|
+
campaign_id: z.string(),
|
|
279
|
+
call: z.enum(["scale", "cut", "hold"]),
|
|
280
|
+
actor: z.enum(["dashboard", "auto_suggested"]).optional(),
|
|
281
|
+
p05: z.number(),
|
|
282
|
+
p50: z.number(),
|
|
283
|
+
p95: z.number(),
|
|
284
|
+
spend_minor_units: z.number().nonnegative(),
|
|
285
|
+
currency: z.string().length(3),
|
|
286
|
+
band_width_pct: z.number().nonnegative(),
|
|
287
|
+
threshold: z.number(),
|
|
288
|
+
model_version: z.string(),
|
|
289
|
+
cost_model_version: z.number().int().positive().nullable(),
|
|
290
|
+
revenue_per_install_minor_units: z.number().nonnegative().nullable(),
|
|
291
|
+
revenue_per_install_quality: z.enum([
|
|
292
|
+
"campaign_verified_cohort",
|
|
293
|
+
"app_verified_cohort_fallback",
|
|
294
|
+
"unavailable"
|
|
295
|
+
]),
|
|
296
|
+
idempotency_key: idempotencyKeySchema
|
|
297
|
+
}
|
|
298
|
+
}, async ({
|
|
299
|
+
app_id,
|
|
300
|
+
campaign_id,
|
|
301
|
+
call: decisionCall,
|
|
302
|
+
actor,
|
|
303
|
+
p05,
|
|
304
|
+
p50,
|
|
305
|
+
p95,
|
|
306
|
+
spend_minor_units,
|
|
307
|
+
currency,
|
|
308
|
+
band_width_pct,
|
|
309
|
+
threshold,
|
|
310
|
+
model_version,
|
|
311
|
+
cost_model_version,
|
|
312
|
+
revenue_per_install_minor_units,
|
|
313
|
+
revenue_per_install_quality,
|
|
314
|
+
idempotency_key
|
|
315
|
+
}) => toToolResult(await call("/api/v1/decisions", {
|
|
316
|
+
method: "POST",
|
|
317
|
+
idempotencyKey: idempotency_key,
|
|
318
|
+
body: {
|
|
319
|
+
appId: app_id,
|
|
320
|
+
campaignId: campaign_id,
|
|
321
|
+
call: decisionCall,
|
|
322
|
+
...actor ? { actor } : {},
|
|
323
|
+
inputs: {
|
|
324
|
+
p05,
|
|
325
|
+
p50,
|
|
326
|
+
p95,
|
|
327
|
+
spend_minor_units,
|
|
328
|
+
currency,
|
|
329
|
+
band_width_pct,
|
|
330
|
+
threshold,
|
|
331
|
+
model_version,
|
|
332
|
+
cost_model_version,
|
|
333
|
+
revenue_per_install_minor_units,
|
|
334
|
+
revenue_per_install_quality
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
})));
|
|
338
|
+
server.registerTool("get_attribution_rollup", {
|
|
339
|
+
title: "Get attribution rollup",
|
|
340
|
+
description: "Return the account-level attribution overview: installs accounted-for by evidence class (verified / device-matched / modeled / organic), MER, revenue and data freshness.",
|
|
341
|
+
inputSchema: { app_id: appIdSchema, period: periodSchema }
|
|
342
|
+
}, async ({ app_id, period }) => toToolResult(await call("/api/v1/reports/overview", { query: { appId: app_id, period } })));
|
|
343
|
+
server.registerTool("create_campaign_link", {
|
|
344
|
+
title: "Create a campaign tracking link",
|
|
345
|
+
description: "Create a web-to-app tracking link for an ad campaign. Requires a write-scoped API key. Returns the link id and url.",
|
|
346
|
+
inputSchema: {
|
|
347
|
+
app_id: z.string().describe("The app id the link belongs to."),
|
|
348
|
+
network: z.enum(["meta", "google", "tiktok"]).describe("The ad network delivering the campaign."),
|
|
349
|
+
destination_url: z.string().url().describe("The App Store URL to send users to."),
|
|
350
|
+
campaign_name: z.string().max(120).optional().describe("Optional human-readable campaign name."),
|
|
351
|
+
campaign_id: z.string().optional().describe("Optional external campaign id."),
|
|
352
|
+
idempotency_key: idempotencyKeySchema
|
|
353
|
+
}
|
|
354
|
+
}, async ({ app_id, network, destination_url, campaign_name, campaign_id, idempotency_key }) => toToolResult(await call("/api/v1/links", {
|
|
355
|
+
method: "POST",
|
|
356
|
+
idempotencyKey: idempotency_key,
|
|
357
|
+
body: {
|
|
358
|
+
appId: app_id,
|
|
359
|
+
network,
|
|
360
|
+
destinationUrl: destination_url,
|
|
361
|
+
...campaign_name ? { campaignName: campaign_name } : {},
|
|
362
|
+
...campaign_id ? { campaignId: campaign_id } : {}
|
|
363
|
+
}
|
|
364
|
+
})));
|
|
365
|
+
server.registerTool("update_link_status", {
|
|
366
|
+
title: "Enable or disable a campaign link",
|
|
367
|
+
description: "Change a tracking link between active and disabled.",
|
|
368
|
+
inputSchema: {
|
|
369
|
+
link_id: z.string().uuid(),
|
|
370
|
+
status: z.enum(["active", "disabled"]),
|
|
371
|
+
idempotency_key: idempotencyKeySchema
|
|
372
|
+
}
|
|
373
|
+
}, async ({ link_id, status, idempotency_key }) => toToolResult(await call(`/api/v1/links/${encodeURIComponent(link_id)}`, { method: "PATCH", idempotencyKey: idempotency_key, body: { status } })));
|
|
374
|
+
server.registerTool("get_spend", {
|
|
375
|
+
title: "Get spend and cost models",
|
|
376
|
+
description: "Return imported ad spend, cost models, completeness and spend freshness.",
|
|
377
|
+
inputSchema: { app_id: appIdSchema }
|
|
378
|
+
}, async ({ app_id }) => toToolResult(await call("/api/v1/spend", {
|
|
379
|
+
query: { appId: app_id }
|
|
380
|
+
})));
|
|
381
|
+
server.registerTool("import_spend", {
|
|
382
|
+
title: "Import ad spend",
|
|
383
|
+
description: "Import campaign or creative-level spend rows.",
|
|
384
|
+
inputSchema: {
|
|
385
|
+
app_id: requiredAppIdSchema,
|
|
386
|
+
network: z.enum(["meta", "google", "tiktok", "apple_ads"]),
|
|
387
|
+
source: z.enum(["network_api", "csv", "manual"]).optional(),
|
|
388
|
+
account_ref: z.string().optional(),
|
|
389
|
+
currency: z.string().length(3),
|
|
390
|
+
report_timezone: z.string().optional(),
|
|
391
|
+
covers_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
392
|
+
covers_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
393
|
+
completeness: z.enum(["partial", "final"]).optional(),
|
|
394
|
+
notes: z.string().optional(),
|
|
395
|
+
rows: z.array(spendRowSchema).min(1).max(5000),
|
|
396
|
+
idempotency_key: idempotencyKeySchema
|
|
397
|
+
}
|
|
398
|
+
}, async ({
|
|
399
|
+
app_id,
|
|
400
|
+
network,
|
|
401
|
+
source,
|
|
402
|
+
account_ref,
|
|
403
|
+
currency,
|
|
404
|
+
report_timezone,
|
|
405
|
+
covers_from,
|
|
406
|
+
covers_to,
|
|
407
|
+
completeness,
|
|
408
|
+
notes,
|
|
409
|
+
rows,
|
|
410
|
+
idempotency_key
|
|
411
|
+
}) => toToolResult(await call("/api/v1/spend", {
|
|
412
|
+
method: "POST",
|
|
413
|
+
idempotencyKey: idempotency_key,
|
|
414
|
+
body: {
|
|
415
|
+
kind: "spend",
|
|
416
|
+
appId: app_id,
|
|
417
|
+
network,
|
|
418
|
+
...source ? { source } : {},
|
|
419
|
+
...account_ref ? { accountRef: account_ref } : {},
|
|
420
|
+
currency,
|
|
421
|
+
...report_timezone ? { reportTimezone: report_timezone } : {},
|
|
422
|
+
coversFrom: covers_from,
|
|
423
|
+
coversTo: covers_to,
|
|
424
|
+
...completeness ? { completeness } : {},
|
|
425
|
+
...notes ? { notes } : {},
|
|
426
|
+
rows
|
|
427
|
+
}
|
|
428
|
+
})));
|
|
429
|
+
server.registerTool("set_cost_model", {
|
|
430
|
+
title: "Set an app cost model",
|
|
431
|
+
description: "Version commission, payment fees and variable COGS used in profit decisions.",
|
|
432
|
+
inputSchema: {
|
|
433
|
+
app_id: requiredAppIdSchema,
|
|
434
|
+
effective_at: z.string(),
|
|
435
|
+
store_commission_bps: z.number().int().min(0).max(1e4),
|
|
436
|
+
payment_fee_bps: z.number().int().min(0).max(1e4),
|
|
437
|
+
variable_cogs_kind: z.enum(["bps", "per_unit_minor"]),
|
|
438
|
+
variable_cogs_value: z.number().nonnegative(),
|
|
439
|
+
assumptions: z.record(z.string(), z.unknown()).optional(),
|
|
440
|
+
idempotency_key: idempotencyKeySchema
|
|
441
|
+
}
|
|
442
|
+
}, async ({
|
|
443
|
+
app_id,
|
|
444
|
+
effective_at,
|
|
445
|
+
store_commission_bps,
|
|
446
|
+
payment_fee_bps,
|
|
447
|
+
variable_cogs_kind,
|
|
448
|
+
variable_cogs_value,
|
|
449
|
+
assumptions,
|
|
450
|
+
idempotency_key
|
|
451
|
+
}) => toToolResult(await call("/api/v1/spend", {
|
|
452
|
+
method: "POST",
|
|
453
|
+
idempotencyKey: idempotency_key,
|
|
454
|
+
body: {
|
|
455
|
+
kind: "cost_model",
|
|
456
|
+
appId: app_id,
|
|
457
|
+
effectiveAt: effective_at,
|
|
458
|
+
storeCommissionBps: store_commission_bps,
|
|
459
|
+
paymentFeeBps: payment_fee_bps,
|
|
460
|
+
variableCogs: { kind: variable_cogs_kind, value: variable_cogs_value },
|
|
461
|
+
...assumptions ? { assumptions } : {}
|
|
462
|
+
}
|
|
463
|
+
})));
|
|
464
|
+
server.registerTool("pull_network_spend", {
|
|
465
|
+
title: "Pull spend from an ad network",
|
|
466
|
+
description: "Fetch and import creative-level spend from a configured Meta, Google or TikTok connection.",
|
|
467
|
+
inputSchema: {
|
|
468
|
+
app_id: requiredAppIdSchema,
|
|
469
|
+
network: z.enum(["meta", "google", "tiktok"]),
|
|
470
|
+
connection_id: z.string().optional(),
|
|
471
|
+
covers_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
472
|
+
covers_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
473
|
+
idempotency_key: idempotencyKeySchema
|
|
474
|
+
}
|
|
475
|
+
}, async ({ app_id, network, connection_id, covers_from, covers_to, idempotency_key }) => toToolResult(await call("/api/v1/spend", {
|
|
476
|
+
method: "POST",
|
|
477
|
+
idempotencyKey: idempotency_key,
|
|
478
|
+
body: {
|
|
479
|
+
kind: "network_pull",
|
|
480
|
+
appId: app_id,
|
|
481
|
+
network,
|
|
482
|
+
...connection_id ? { connectionId: connection_id } : {},
|
|
483
|
+
coversFrom: covers_from,
|
|
484
|
+
coversTo: covers_to
|
|
485
|
+
}
|
|
486
|
+
})));
|
|
487
|
+
server.registerTool("list_integrations", {
|
|
488
|
+
title: "List ad-network integrations",
|
|
489
|
+
description: "List redacted ad-network connections and their readiness.",
|
|
490
|
+
inputSchema: {}
|
|
491
|
+
}, async () => toToolResult(await call("/api/v1/integrations")));
|
|
492
|
+
server.registerTool("create_integration", {
|
|
493
|
+
title: "Create an ad-network integration",
|
|
494
|
+
description: "Store encrypted network credentials and configure event ownership.",
|
|
495
|
+
inputSchema: {
|
|
496
|
+
network: z.enum(["meta", "google", "tiktok"]),
|
|
497
|
+
account_ref: z.string().min(1),
|
|
498
|
+
credential: z.string().min(1),
|
|
499
|
+
owners: ownersSchema,
|
|
500
|
+
enable: z.boolean().optional(),
|
|
501
|
+
idempotency_key: idempotencyKeySchema
|
|
502
|
+
}
|
|
503
|
+
}, async ({ network, account_ref, credential, owners, enable, idempotency_key }) => toToolResult(await call("/api/v1/integrations", {
|
|
504
|
+
method: "POST",
|
|
505
|
+
idempotencyKey: idempotency_key,
|
|
506
|
+
body: { network, account_ref, credential, owners, enable: enable ?? false }
|
|
507
|
+
})));
|
|
508
|
+
server.registerTool("update_integration", {
|
|
509
|
+
title: "Update an ad-network integration",
|
|
510
|
+
description: "Rotate credentials or enable/disable an integration.",
|
|
511
|
+
inputSchema: {
|
|
512
|
+
connection_id: z.string(),
|
|
513
|
+
action: z.enum(["rotate", "disable", "enable"]),
|
|
514
|
+
credential: z.string().optional(),
|
|
515
|
+
owners: ownersSchema.optional(),
|
|
516
|
+
idempotency_key: idempotencyKeySchema
|
|
517
|
+
}
|
|
518
|
+
}, async ({ connection_id, action, credential, owners, idempotency_key }) => toToolResult(await call(`/api/v1/integrations/${encodeURIComponent(connection_id)}`, {
|
|
519
|
+
method: "PATCH",
|
|
520
|
+
idempotencyKey: idempotency_key,
|
|
521
|
+
body: {
|
|
522
|
+
action,
|
|
523
|
+
...credential ? { credential } : {},
|
|
524
|
+
...owners ? { owners } : {}
|
|
525
|
+
}
|
|
526
|
+
})));
|
|
527
|
+
server.registerTool("delete_integration", {
|
|
528
|
+
title: "Delete an ad-network integration",
|
|
529
|
+
description: "Delete a disabled integration. Enabled integrations must be disabled first.",
|
|
530
|
+
inputSchema: { connection_id: z.string() }
|
|
531
|
+
}, async ({ connection_id }) => toToolResult(await call(`/api/v1/integrations/${encodeURIComponent(connection_id)}`, { method: "DELETE" })));
|
|
532
|
+
server.registerTool("get_revenuecat", {
|
|
533
|
+
title: "Get RevenueCat configuration",
|
|
534
|
+
description: "Return redacted RevenueCat status and the AttriKit webhook URL.",
|
|
535
|
+
inputSchema: { app_id: requiredAppIdSchema }
|
|
536
|
+
}, async ({ app_id }) => toToolResult(await call(`/api/v1/apps/${encodeURIComponent(app_id)}/revenuecat`)));
|
|
537
|
+
server.registerTool("configure_revenuecat", {
|
|
538
|
+
title: "Configure RevenueCat",
|
|
539
|
+
description: "Store the RevenueCat provider app id and encrypted authorization secret for an app.",
|
|
540
|
+
inputSchema: {
|
|
541
|
+
app_id: requiredAppIdSchema,
|
|
542
|
+
provider_app_id: z.string().min(1).max(255),
|
|
543
|
+
authorization_secret: z.string().min(16).max(1024),
|
|
544
|
+
idempotency_key: idempotencyKeySchema
|
|
545
|
+
}
|
|
546
|
+
}, async ({ app_id, provider_app_id, authorization_secret, idempotency_key }) => toToolResult(await call(`/api/v1/apps/${encodeURIComponent(app_id)}/revenuecat`, {
|
|
547
|
+
method: "PUT",
|
|
548
|
+
idempotencyKey: idempotency_key,
|
|
549
|
+
body: { provider_app_id, authorization_secret }
|
|
550
|
+
})));
|
|
551
|
+
server.registerTool("list_webhooks", {
|
|
552
|
+
title: "List outbound webhooks",
|
|
553
|
+
description: "List redacted outbound webhook subscriptions.",
|
|
554
|
+
inputSchema: {}
|
|
555
|
+
}, async () => toToolResult(await call("/api/v1/webhooks")));
|
|
556
|
+
server.registerTool("create_webhook", {
|
|
557
|
+
title: "Create an outbound webhook",
|
|
558
|
+
description: "Create an SSRF-checked webhook subscription. Returns the signing secret once.",
|
|
559
|
+
inputSchema: {
|
|
560
|
+
url: z.string().url(),
|
|
561
|
+
event_types: z.array(z.enum(["install", "event", "decision", "fraud"])).min(1).max(4),
|
|
562
|
+
description: z.string().max(500).optional(),
|
|
563
|
+
app_id: z.string().uuid().optional(),
|
|
564
|
+
idempotency_key: idempotencyKeySchema
|
|
565
|
+
}
|
|
566
|
+
}, async ({ url, event_types, description, app_id, idempotency_key }) => toToolResult(await call("/api/v1/webhooks", {
|
|
567
|
+
method: "POST",
|
|
568
|
+
idempotencyKey: idempotency_key,
|
|
569
|
+
body: {
|
|
570
|
+
url,
|
|
571
|
+
event_types,
|
|
572
|
+
...description ? { description } : {},
|
|
573
|
+
...app_id ? { app_id } : {}
|
|
574
|
+
}
|
|
575
|
+
})));
|
|
576
|
+
server.registerTool("delete_webhook", {
|
|
577
|
+
title: "Delete an outbound webhook",
|
|
578
|
+
description: "Delete a webhook subscription.",
|
|
579
|
+
inputSchema: { subscription_id: z.string().uuid() }
|
|
580
|
+
}, async ({ subscription_id }) => toToolResult(await call(`/api/v1/webhooks/${encodeURIComponent(subscription_id)}`, { method: "DELETE" })));
|
|
581
|
+
server.registerTool("export_events", {
|
|
582
|
+
title: "Export events and installs",
|
|
583
|
+
description: "Export one app's events and installs as CSV for an inclusive date range.",
|
|
584
|
+
inputSchema: {
|
|
585
|
+
app_id: requiredAppIdSchema,
|
|
586
|
+
from: z.string().describe("ISO date or timestamp"),
|
|
587
|
+
to: z.string().describe("ISO date or timestamp")
|
|
588
|
+
}
|
|
589
|
+
}, async ({ app_id, from, to }) => toToolResult(await call("/api/v1/export", {
|
|
590
|
+
query: { appId: app_id, from, to }
|
|
591
|
+
})));
|
|
592
|
+
return server;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// src/cli.ts
|
|
596
|
+
async function main() {
|
|
597
|
+
let config;
|
|
598
|
+
try {
|
|
599
|
+
config = readConfig();
|
|
600
|
+
} catch (error) {
|
|
601
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
602
|
+
`);
|
|
603
|
+
process.exit(1);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const server = createServer(config);
|
|
607
|
+
const transport = new StdioServerTransport;
|
|
608
|
+
await server.connect(transport);
|
|
609
|
+
process.stderr.write(`attrkit-mcp connected (api=${config.apiUrl})
|
|
610
|
+
`);
|
|
611
|
+
}
|
|
612
|
+
main().catch((error) => {
|
|
613
|
+
process.stderr.write(`attrkit-mcp failed to start: ${error instanceof Error ? error.stack ?? error.message : String(error)}
|
|
614
|
+
`);
|
|
615
|
+
process.exit(1);
|
|
616
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@attrkit/mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "AttriKit's stdio MCP server for its public management API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"attrkit-mcp": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "bun build ./src/cli.ts --outdir ./dist --target=node --format=esm --external @modelcontextprotocol/sdk --external zod",
|
|
15
|
+
"typecheck": "../../node_modules/typescript-7/bin/tsc --noEmit",
|
|
16
|
+
"test": "cd ../.. && vitest run packages/mcp/src/index.test.ts"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
20
|
+
"zod": "4.4.3"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@attrkit/shared": "workspace:*",
|
|
24
|
+
"@types/node": "26.1.1",
|
|
25
|
+
"typescript": "6.0.3",
|
|
26
|
+
"vitest": "4.1.10"
|
|
27
|
+
}
|
|
28
|
+
}
|