@attrkit/mcp 1.0.0 → 1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0 (2026-10-03)
4
+
5
+ ### Added
6
+
7
+ - `attrkit-mcp login` (macOS): asks for the management key with echo off, checks it against the
8
+ API, and stores it in the Keychain item `attrkit-mcp` through `security -i` with the key on
9
+ stdin, so it never appears in argv. `attrkit-mcp logout` removes the item.
10
+ - The server reads the key from that Keychain item on macOS when `ATTRKIT_API_KEY` is unset, empty,
11
+ or an unexpanded client placeholder (`$ATTRKIT_API_KEY`, `${ATTRKIT_API_KEY}`,
12
+ `${ATTRKIT_API_KEY:-}`, `${env:ATTRKIT_API_KEY}`, `${input:attrkit-key}`). A real
13
+ `ATTRKIT_API_KEY` still wins and the Keychain is not consulted, so existing configurations behave
14
+ exactly as before.
15
+ - The missing-key error now mentions `npx -y @attrkit/mcp login` on macOS.
16
+
17
+ ## 1.1.0 (2026-10-01)
18
+
19
+ ### Added
20
+
21
+ - `send_server_event` accepts an optional `consent.dma` object: `eea`, and optionally
22
+ `ad_user_data` and `ad_personalization`, the user's answers to Google's EU consent questions. It
23
+ is forwarded to the API as `consent.dma` with `source: "manual"`, and only when you give it; a
24
+ call without it sends exactly what 1.0.0 sent. A key the object does not define (`adUserData`
25
+ for `ad_user_data`) is refused before any request, as the API refuses it.
26
+
3
27
  ## 1.0.0 (2026-09-30)
4
28
 
5
29
  Major version release with input contract formalization, bug fixes, and security hardening.
package/README.md CHANGED
@@ -45,15 +45,29 @@ Use a read-only or app-scoped key when the client needs less access. App-scoped
45
45
  keys cannot read or change the workspace usage cap, create sibling apps, or
46
46
  manage workspace-wide integrations.
47
47
 
48
- Load the key into the shell that starts your MCP client:
48
+ ### Give the server the key
49
+
50
+ On macOS, store the key once in the Keychain:
49
51
 
50
52
  ```bash
51
- printf "AttriKit management key: "
52
- IFS= read -r -s ATTRKIT_API_KEY
53
- export ATTRKIT_API_KEY
54
- printf "\n"
53
+ npx -y @attrkit/mcp login
55
54
  ```
56
55
 
56
+ It asks for the key with echo off, checks it against AttriKit (and prints the plan and scopes),
57
+ then stores it in the Keychain item `attrkit-mcp`. The key never appears in a process listing.
58
+ `npx -y @attrkit/mcp logout` removes the item.
59
+
60
+ On any system you can instead set `ATTRKIT_API_KEY` in the environment of the process that starts
61
+ your MCP client, or in the client's own secret store.
62
+
63
+ At start the server resolves the key in this order:
64
+
65
+ 1. `ATTRKIT_API_KEY`, when it holds a real value.
66
+ 2. On macOS, the Keychain item `attrkit-mcp`. An empty `ATTRKIT_API_KEY`, or one a client left
67
+ unexpanded (`$ATTRKIT_API_KEY`, `${ATTRKIT_API_KEY}`, `${ATTRKIT_API_KEY:-}`,
68
+ `${env:ATTRKIT_API_KEY}`, `${input:attrkit-key}`), counts as unset.
69
+ 3. Otherwise it exits with an error that names the fix.
70
+
57
71
  ## Codex
58
72
 
59
73
  Add this server to `~/.codex/config.toml`:
package/dist/cli.js CHANGED
@@ -13876,6 +13876,13 @@ var consentStateSchema = z3.object({
13876
13876
  policy_version: z3.number().int().positive(),
13877
13877
  framework_string: z3.string().max(512).optional()
13878
13878
  }).strict();
13879
+ var dmaConsentSchema = z3.object({
13880
+ eea: z3.boolean(),
13881
+ ad_user_data: z3.boolean().optional(),
13882
+ ad_personalization: z3.boolean().optional(),
13883
+ source: z3.enum(["tcf", "manual"])
13884
+ }).strict();
13885
+ var firstOpenConsentSchema = consentStateSchema.extend({ dma: dmaConsentSchema.optional() }).strict();
13879
13886
  var coarseContextSchema = z3.object({
13880
13887
  country_code: z3.string().regex(/^[A-Z]{2}$/).optional(),
13881
13888
  region_code: z3.string().max(16).optional(),
@@ -13940,7 +13947,7 @@ var firstOpenEnvelopeSchema = z3.object({
13940
13947
  occurred_at: z3.string().datetime({ offset: true }),
13941
13948
  app_version: z3.string().min(1).max(128),
13942
13949
  coarse_context: coarseContextSchema,
13943
- consent: consentStateSchema,
13950
+ consent: firstOpenConsentSchema,
13944
13951
  app_transaction_jws: z3.string().min(16).max(32768).optional(),
13945
13952
  asa_token: z3.string().min(8).max(8192).optional(),
13946
13953
  exact_token_ref: exactTokenReferenceSchema.optional(),
@@ -13969,7 +13976,8 @@ var identifyEnvelopeSchema = z3.object({
13969
13976
  var eventConsentSchema = z3.object({
13970
13977
  measurement: z3.enum(["granted", "denied", "unknown"]),
13971
13978
  tracking: z3.enum(["granted", "denied", "unknown"]),
13972
- policy_version: z3.number().int().positive()
13979
+ policy_version: z3.number().int().positive(),
13980
+ dma: dmaConsentSchema.optional()
13973
13981
  }).strict();
13974
13982
  var propertyScalarSchema = z3.union([
13975
13983
  z3.string().max(1024),
@@ -14783,7 +14791,7 @@ function validatedApiBaseUrl(value) {
14783
14791
  function readConfig(env = process.env) {
14784
14792
  const apiKey = env.ATTRKIT_API_KEY;
14785
14793
  if (!apiKey) {
14786
- throw new Error("ATTRKIT_API_KEY is required (create one in AttriKit → Settings → API keys).");
14794
+ throw new Error("ATTRKIT_API_KEY is required (create one in AttriKit → Settings → API keys). On macOS, run `npx -y @attrkit/mcp login` to store it in the Keychain.");
14787
14795
  }
14788
14796
  const apiUrl = (env.ATTRKIT_API_URL ?? "https://attrikit.io").replace(/\/+$/, "");
14789
14797
  validatedApiBaseUrl(apiUrl);
@@ -15047,7 +15055,7 @@ ${body}`
15047
15055
  structuredContent: structured
15048
15056
  }, result.nextCursor);
15049
15057
  }
15050
- var SERVER_VERSION = "1.0.0";
15058
+ var SERVER_VERSION = "1.2.0";
15051
15059
  var periodSchema = z5.enum(["7d", "30d", "90d", "all"]).describe("Reporting window. Omit to use the endpoint's documented default.").optional();
15052
15060
  var appIdSchema = z5.string().describe("Optional app id to scope the result to one app.").optional();
15053
15061
  var requiredAppIdSchema = z5.string().min(1).describe("The AttriKit app id.");
@@ -15802,13 +15810,31 @@ function createServer(config, fetchImpl) {
15802
15810
  consent: z5.object({
15803
15811
  measurement: z5.enum(["granted", "denied", "unknown"]),
15804
15812
  tracking: z5.enum(["granted", "denied", "unknown"]),
15805
- policy_version: z5.number().int().positive()
15813
+ policy_version: z5.number().int().positive(),
15814
+ dma: z5.object({
15815
+ eea: z5.boolean().describe("Whether EEA, UK or Swiss rules apply to this user."),
15816
+ ad_user_data: z5.boolean().optional().describe("The user's ad_user_data consent."),
15817
+ ad_personalization: z5.boolean().optional().describe("The user's ad_personalization consent.")
15818
+ }).strict().optional().describe("Optional. The user's answers to Google's EU consent questions, sent to Google with this event. A value left out is derived from tracking; an unknown key is refused, as the API refuses it.")
15806
15819
  }).describe("The per-event consent SDK events carry. An event whose measurement is not granted is not collected."),
15807
15820
  properties: z5.record(z5.string(), z5.union([z5.string().max(1024), z5.number(), z5.boolean(), z5.null()])).optional().describe("Scalar values only, at most 64 keys. Revenue is value (major units) or price_minor_units with currency, an ISO 4217 code."),
15808
15821
  idempotency_key: idempotencyKeySchema
15809
15822
  }
15810
15823
  }, async ({ app_id, customer_user_id, event_id, event_name, event_version, occurred_at, consent, properties, idempotency_key }) => {
15811
- const body = { customer_user_id, event_id, event_name, event_version, occurred_at, consent };
15824
+ const forwardedConsent = {
15825
+ measurement: consent.measurement,
15826
+ tracking: consent.tracking,
15827
+ policy_version: consent.policy_version
15828
+ };
15829
+ if (consent.dma !== undefined) {
15830
+ const dma = { eea: consent.dma.eea, source: "manual" };
15831
+ if (consent.dma.ad_user_data !== undefined)
15832
+ dma.ad_user_data = consent.dma.ad_user_data;
15833
+ if (consent.dma.ad_personalization !== undefined)
15834
+ dma.ad_personalization = consent.dma.ad_personalization;
15835
+ forwardedConsent.dma = dma;
15836
+ }
15837
+ const body = { customer_user_id, event_id, event_name, event_version, occurred_at, consent: forwardedConsent };
15812
15838
  if (properties !== undefined)
15813
15839
  body.properties = properties;
15814
15840
  return toToolResult(await call(`/api/v1/apps/${encodeOpaquePathSegment(app_id)}/events`, {
@@ -15829,23 +15855,265 @@ function createServer(config, fetchImpl) {
15829
15855
  return server;
15830
15856
  }
15831
15857
 
15832
- // src/cli.ts
15833
- async function main() {
15858
+ // src/key.ts
15859
+ import { execFile } from "node:child_process";
15860
+ var KEYCHAIN_SERVICE = "attrkit-mcp";
15861
+ var SECURITY_BIN = "/usr/bin/security";
15862
+ var KEY_REQUIRED_MESSAGE = "ATTRKIT_API_KEY is required (create one in AttriKit → Settings → API keys). On macOS, run `npx -y @attrkit/mcp login` to store it in the Keychain.";
15863
+ var KEY_REQUIRED_MESSAGE_OTHER_PLATFORM = "ATTRKIT_API_KEY is required (create one in AttriKit → Settings → API keys). Set it in the environment or in your MCP client's secret store.";
15864
+ var UNEXPANDED_PLACEHOLDERS = new Set([
15865
+ "$ATTRKIT_API_KEY",
15866
+ "${ATTRKIT_API_KEY}",
15867
+ "${ATTRKIT_API_KEY:-}",
15868
+ "${env:ATTRKIT_API_KEY}",
15869
+ "${input:attrkit-key}"
15870
+ ]);
15871
+ function realEnvKey(env) {
15872
+ const value = env.ATTRKIT_API_KEY;
15873
+ if (value === undefined || value.trim() === "")
15874
+ return;
15875
+ if (UNEXPANDED_PLACEHOLDERS.has(value.trim()))
15876
+ return;
15877
+ return value;
15878
+ }
15879
+ var realExecFile = (file, args, options, done) => execFile(file, args, options, done);
15880
+ function createSecurityRunner(execFileImpl = realExecFile) {
15881
+ return (args, stdin) => new Promise((resolve) => {
15882
+ let writeFailed = false;
15883
+ const child = execFileImpl(SECURITY_BIN, [...args], { encoding: "utf8", timeout: 15000 }, (error, stdout, stderr) => {
15884
+ const code = error === null ? 0 : Number.isInteger(error.code) ? Number(error.code) : 1;
15885
+ resolve(writeFailed ? { code: code === 0 ? 1 : code, stdout, stderr: `${stderr}could not write to security` } : { code, stdout, stderr });
15886
+ });
15887
+ child.stdin?.on("error", () => {
15888
+ writeFailed = true;
15889
+ });
15890
+ if (stdin !== undefined)
15891
+ child.stdin?.end(stdin);
15892
+ else
15893
+ child.stdin?.end();
15894
+ });
15895
+ }
15896
+ var defaultSecurityRunner = createSecurityRunner();
15897
+ async function keychainKey(runSecurity) {
15898
+ const result = await runSecurity(["find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"]);
15899
+ if (result.code !== 0)
15900
+ return;
15901
+ const key = result.stdout.replace(/\r?\n$/, "");
15902
+ return key.trim() === "" ? undefined : key;
15903
+ }
15904
+ async function resolveApiKey(options = {}) {
15905
+ const env = options.env ?? process.env;
15906
+ const platform = options.platform ?? process.platform;
15907
+ const fromEnv = realEnvKey(env);
15908
+ if (fromEnv !== undefined)
15909
+ return fromEnv;
15910
+ if (platform !== "darwin")
15911
+ throw new Error(KEY_REQUIRED_MESSAGE_OTHER_PLATFORM);
15912
+ const fromKeychain = await keychainKey(options.runSecurity ?? defaultSecurityRunner);
15913
+ if (fromKeychain !== undefined)
15914
+ return fromKeychain;
15915
+ throw new Error(KEY_REQUIRED_MESSAGE);
15916
+ }
15917
+
15918
+ // src/login.ts
15919
+ import { userInfo } from "node:os";
15920
+ import { z as z6 } from "zod";
15921
+ function quotedForSecurity(key) {
15922
+ if (key === "" || Array.from(key).some(isUnquotable))
15923
+ return null;
15924
+ return `"${key}"`;
15925
+ }
15926
+ function isUnquotable(character) {
15927
+ const code = character.charCodeAt(0);
15928
+ return character === '"' || character === "\\" || code <= 31 || code === 127;
15929
+ }
15930
+ function addKeyCommand(username, quotedKey) {
15931
+ return `add-generic-password -U -s ${KEYCHAIN_SERVICE} -a "${Array.from(username).filter((character) => !isUnquotable(character)).join("")}" -w ${quotedKey}
15932
+ `;
15933
+ }
15934
+ function readHidden(input, prompt, write, idleMs = 50) {
15935
+ return new Promise((resolve) => {
15936
+ write(prompt);
15937
+ input.setRawMode(true);
15938
+ input.resume();
15939
+ input.setEncoding("utf8");
15940
+ let value = "";
15941
+ let mode = "text";
15942
+ let idle;
15943
+ const finish = (result) => {
15944
+ if (idle !== undefined)
15945
+ clearTimeout(idle);
15946
+ input.removeListener("data", onData);
15947
+ input.setRawMode(false);
15948
+ input.pause();
15949
+ write(`
15950
+ `);
15951
+ resolve(result);
15952
+ };
15953
+ const armIdle = () => {
15954
+ if (idle !== undefined)
15955
+ clearTimeout(idle);
15956
+ idle = setTimeout(() => finish(value), idleMs);
15957
+ };
15958
+ const onData = (chunk) => {
15959
+ for (const character of chunk) {
15960
+ if (mode === "drain")
15961
+ continue;
15962
+ if (mode === "escape") {
15963
+ mode = character === "[" ? "csi" : character === "O" ? "ss3" : "text";
15964
+ } else if (mode === "csi") {
15965
+ if (character >= "@" && character <= "~")
15966
+ mode = "text";
15967
+ } else if (mode === "ss3") {
15968
+ mode = "text";
15969
+ } else if (character === "\x03") {
15970
+ return finish(null);
15971
+ } else if (character === "\x1B") {
15972
+ mode = "escape";
15973
+ } else if (character === "\r" || character === `
15974
+ `) {
15975
+ mode = "drain";
15976
+ } else if (character === "" || character === "\b") {
15977
+ value = value.slice(0, -1);
15978
+ } else if (character >= " ") {
15979
+ value += character;
15980
+ }
15981
+ }
15982
+ if (mode === "drain")
15983
+ armIdle();
15984
+ };
15985
+ input.on("data", onData);
15986
+ });
15987
+ }
15988
+ function readHiddenFromTty(prompt) {
15989
+ return readHidden(process.stdin, prompt, (text) => void process.stderr.write(text));
15990
+ }
15991
+ var whoamiSchema = z6.object({
15992
+ data: z6.object({
15993
+ plan: z6.string().optional(),
15994
+ scope: z6.object({
15995
+ app_id: z6.string().nullish(),
15996
+ scopes: z6.array(z6.string()).optional()
15997
+ }).optional()
15998
+ })
15999
+ });
16000
+ function describeWhoami(json) {
16001
+ const parsed = whoamiSchema.safeParse(json);
16002
+ if (!parsed.success)
16003
+ return "Key accepted.";
16004
+ const { plan, scope } = parsed.data.data;
16005
+ const app = scope?.app_id ? `app ${scope.app_id}` : "all apps";
16006
+ return `Key accepted. Plan: ${plan ?? "unknown"}. Scopes: ${scope?.scopes?.join(", ") ?? "unknown"}. Restriction: ${app}.`;
16007
+ }
16008
+ async function runLogin(options = {}) {
16009
+ const write = options.write ?? ((text) => void process.stderr.write(text));
16010
+ const platform = options.platform ?? process.platform;
16011
+ if (platform !== "darwin") {
16012
+ write(`attrkit-mcp login stores the key in the macOS Keychain. On this system set ATTRKIT_API_KEY in the environment or in your MCP client's secret store.
16013
+ `);
16014
+ return 1;
16015
+ }
16016
+ const isTTY = options.isTTY ?? Boolean(process.stdin.isTTY);
16017
+ if (!isTTY) {
16018
+ write(`attrkit-mcp login needs an interactive terminal to ask for the key.
16019
+ `);
16020
+ return 1;
16021
+ }
16022
+ const readSecret = options.readSecret ?? readHiddenFromTty;
16023
+ const entered = await readSecret("AttriKit management key: ");
16024
+ if (entered === null)
16025
+ return 130;
16026
+ const key = entered.trim();
16027
+ const quoted = quotedForSecurity(key);
16028
+ if (quoted === null) {
16029
+ write(`That key was not accepted: it is empty or holds a quote, backslash or control character. Nothing was stored.
16030
+ `);
16031
+ return 1;
16032
+ }
15834
16033
  let config;
15835
16034
  try {
15836
- config = readConfig();
16035
+ config = readConfig({ ...options.env ?? process.env, ATTRKIT_API_KEY: key });
15837
16036
  } catch (error) {
15838
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}
15839
- `, () => process.exit(1));
16037
+ write(`${error instanceof Error ? error.message : String(error)}
16038
+ `);
16039
+ return 1;
16040
+ }
16041
+ const result = await callApi(config, "/api/v1/whoami", options.fetchImpl ? { fetchImpl: options.fetchImpl } : {});
16042
+ if (result.status === 401 || result.status === 403) {
16043
+ write(`AttriKit refused that key. Nothing was stored.
16044
+ `);
16045
+ return 1;
16046
+ }
16047
+ if (!result.ok) {
16048
+ write(result.status === 0 ? `Could not reach ${new URL(config.apiUrl).origin} to check the key. Nothing was stored.
16049
+ ` : `Could not check the key (HTTP ${result.status}). Nothing was stored.
16050
+ `);
16051
+ return 1;
16052
+ }
16053
+ write(`${describeWhoami(result.json)}
16054
+ `);
16055
+ const runSecurity = options.runSecurity ?? defaultSecurityRunner;
16056
+ const stored = await runSecurity(["-i"], addKeyCommand(options.username ?? userInfo().username, quoted));
16057
+ if (stored.code !== 0 || stored.stderr.trim() !== "") {
16058
+ write(`The Keychain refused the item (exit ${stored.code}). Nothing was stored.
16059
+ `);
16060
+ return 1;
16061
+ }
16062
+ write(`Stored in the macOS Keychain as attrkit-mcp. Your MCP client can now start the server without ATTRKIT_API_KEY.
16063
+ `);
16064
+ return 0;
16065
+ }
16066
+ async function runLogout(options = {}) {
16067
+ const write = options.write ?? ((text) => void process.stderr.write(text));
16068
+ if ((options.platform ?? process.platform) !== "darwin") {
16069
+ write(`attrkit-mcp logout removes the macOS Keychain item; there is nothing to remove on this system.
16070
+ `);
16071
+ return 1;
16072
+ }
16073
+ const result = await (options.runSecurity ?? defaultSecurityRunner)(["delete-generic-password", "-s", KEYCHAIN_SERVICE]);
16074
+ write(result.code === 0 ? `Removed the attrkit-mcp item from the macOS Keychain.
16075
+ ` : `Nothing was stored in the Keychain for attrkit-mcp.
16076
+ `);
16077
+ return 0;
16078
+ }
16079
+
16080
+ // src/main.ts
16081
+ function defaultDeps(startServer) {
16082
+ return {
16083
+ runLogin,
16084
+ runLogout,
16085
+ resolveApiKey,
16086
+ startServer,
16087
+ fail: (message, code) => void process.stderr.write(`${message}
16088
+ `, () => process.exit(code)),
16089
+ exit: (code) => process.exit(code)
16090
+ };
16091
+ }
16092
+ async function runCli(argv, deps) {
16093
+ const command = argv[2];
16094
+ if (command === "login")
16095
+ return deps.exit(await deps.runLogin());
16096
+ if (command === "logout")
16097
+ return deps.exit(await deps.runLogout());
16098
+ let config;
16099
+ try {
16100
+ const apiKey = await deps.resolveApiKey();
16101
+ config = readConfig({ ...process.env, ATTRKIT_API_KEY: apiKey });
16102
+ } catch (error) {
16103
+ deps.fail(error instanceof Error ? error.message : String(error), 1);
15840
16104
  return;
15841
16105
  }
16106
+ await deps.startServer(config);
16107
+ }
16108
+
16109
+ // src/cli.ts
16110
+ runCli(process.argv, defaultDeps(async (config) => {
15842
16111
  const server = createServer(config);
15843
16112
  const transport = new StdioServerTransport;
15844
16113
  await server.connect(transport);
15845
16114
  process.stderr.write(`attrkit-mcp connected (api=${config.apiUrl})
15846
16115
  `);
15847
- }
15848
- main().catch((error) => {
16116
+ })).catch((error) => {
15849
16117
  process.stderr.write(`attrkit-mcp failed to start: ${error instanceof Error ? error.stack ?? error.message : String(error)}
15850
16118
  `, () => process.exit(1));
15851
16119
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@attrkit/mcp",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "AttriKit's stdio MCP server for its public Management API",
5
5
  "keywords": [
6
6
  "attrkit",
@@ -34,7 +34,7 @@
34
34
  "prepack": "bun run build",
35
35
  "typecheck": "../../node_modules/typescript-7/bin/tsc --noEmit",
36
36
  "lint": "eslint .",
37
- "test": "cd ../.. && vitest run packages/mcp/src/index.test.ts"
37
+ "test": "cd ../.. && vitest run packages/mcp/src"
38
38
  },
39
39
  "dependencies": {
40
40
  "@modelcontextprotocol/sdk": "1.30.0",