@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.
Files changed (3) hide show
  1. package/README.md +67 -0
  2. package/dist/cli.js +616 -0
  3. 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
+ }