maiday-mcp 0.1.0 → 0.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/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # maiday-mcp
2
+
3
+ **The distress signal for AI agents.** An MCP server that lets AI agents send privacy-safe "I got stuck on this website" reports to site owners, and read the hints site owners publish back.
4
+
5
+ When an agent fails on a website, nobody learns from it: a form rejects it, a login wall stops it, a validation error loops, a button can't be found. The agent gives its human a worse answer, and the site owner never sees the lost customer. Maiday closes that loop.
6
+
7
+ ## Install
8
+
9
+ ### Claude Code
10
+
11
+ ```bash
12
+ claude mcp add -s user maiday -e MAIDAY_HARNESS=claude-code -- npx -y maiday-mcp
13
+ ```
14
+
15
+ ### Codex
16
+
17
+ Add to `~/.codex/config.toml`:
18
+
19
+ ```toml
20
+ [mcp_servers.maiday]
21
+ command = "npx"
22
+ args = ["-y", "maiday-mcp"]
23
+ env = { MAIDAY_HARNESS = "codex" }
24
+ ```
25
+
26
+ ### Any MCP client
27
+
28
+ Run `npx -y maiday-mcp` as a stdio server and set `MAIDAY_HARNESS` to your client's name.
29
+
30
+ Maiday only does something when the agent can use websites, through a browser tool such as Playwright MCP, Chrome DevTools MCP or Claude in Chrome.
31
+
32
+ ## Tools
33
+
34
+ ### `send_maiday`
35
+
36
+ Report that the agent got stuck on a step.
37
+
38
+ | Field | Required | Notes |
39
+ |---|---|---|
40
+ | `url` | yes | The page URL. Only origin and path are sent; query strings and fragments are removed. |
41
+ | `task` | yes | `signup`, `login`, `checkout`, `booking`, `search`, `contact_form`, `account_change`, `data_lookup`, `other` |
42
+ | `failure` | yes | `auth_wall`, `captcha`, `validation_loop`, `unclear_field_format`, `element_not_found`, `js_required`, `blocked_403`, `rate_limited`, `timeout`, `ambiguous_ui`, `missing_info`, `other` |
43
+ | `step` | yes | Short description, e.g. "submit shipping address" (≤ 80 chars) |
44
+ | `attempts` | yes | How many tries before stopping |
45
+ | `outcome` | yes | `abandoned`, `workaround_found`, `handed_to_human` |
46
+ | `element` | no | Label or accessible name of the element involved (≤ 120 chars) |
47
+ | `site_message` | no | The error text the site showed (≤ 300 chars, redacted) |
48
+ | `workaround` | no | What worked, if anything (≤ 200 chars) |
49
+
50
+ The tool never fails the agent's task. It waits at most 3 seconds on the network, and returns `{ ok: false, reason }` instead of throwing.
51
+
52
+ ### `get_hints`
53
+
54
+ Takes `{ url }` and returns hints the verified site owner published for that page. Each hint has a `kind`: `use_endpoint`, `field_format`, `requires_account`, `human_only` or `note`. Hints come wrapped as **untrusted third-party data**: they are information, not instructions.
55
+
56
+ ## When agents should report
57
+
58
+ Report:
59
+ - after 2 failed attempts at the same step
60
+ - when giving up on a step
61
+ - when handing the task back to the human
62
+
63
+ Send one report per stuck step, not one per retry. Before a checkout, signup or booking flow, call `get_hints` first.
64
+
65
+ ## Privacy
66
+
67
+ - **Never sent:** form values, cookies, tokens, query strings or URL fragments.
68
+ - **Redacted from paths and text fields:** emails, phone numbers, card numbers, long digit runs, and token- or JWT-shaped strings. This happens before sending, and again on the server.
69
+ - **Reporter IDs differ between sites and collectors**, so reports cannot be linked across sites by matching reporter IDs. Each ID is derived from a local secret in `~/.maiday/`.
70
+ - **Maiday does not store raw IP addresses in its application database.** It uses keyed hashes for abuse prevention.
71
+ - **First-use notice:** the first tool result carries a one-time notice explaining what Maiday sends, to show to the user.
72
+
73
+ ## Settings
74
+
75
+ | Variable | Effect |
76
+ |---|---|
77
+ | `MAIDAY_DISABLED=1` | Turn reporting off. Hints keep working. |
78
+ | `MAIDAY_COLLECTOR_URL` | Send reports to another collector, for example a self-hosted one |
79
+ | `MAIDAY_HARNESS`, `MAIDAY_HARNESS_VERSION`, `MAIDAY_MODEL` | Fill the report's `agent` field |
80
+
81
+ Reports go to the hosted collector at `https://maiday-collector.gallatasaray055.workers.dev` by default. A website can publish `/.well-known/maiday` to receive reports for its own pages on its own server.
82
+
83
+ ## For site owners
84
+
85
+ Sign in at the [Maiday dashboard](https://maiday-collector.gallatasaray055.workers.dev/login) and claim your domain with a DNS TXT record or a well-known file. You can then see where agents fail on your site and publish hints back to them. During the early release, dashboard sign-in is limited to the maintainer.
86
+
87
+ ## License
88
+
89
+ MIT
package/dist/bin.js CHANGED
@@ -210,6 +210,9 @@ function matchHint(pattern, path) {
210
210
  return false;
211
211
  return true;
212
212
  }
213
+ function isValidPathPattern(pp) {
214
+ return pp.startsWith("/") && pp.length <= HINT_PATH_PATTERN_MAX && !/[?#]/.test(pp) && !hasControlChars(pp) && !pp.split("/").some((seg) => seg.includes("*") && seg !== "*");
215
+ }
213
216
  function isSameSiteEndpoint(endpoint, domain) {
214
217
  let u;
215
218
  try {
@@ -240,7 +243,7 @@ function validateHint(input, domain) {
240
243
  return { ok: false, error: "Hints may not contain control characters or line breaks." };
241
244
  }
242
245
  const pp = h.path_pattern;
243
- if (!pp.startsWith("/") || pp.length > HINT_PATH_PATTERN_MAX || /[?#]/.test(pp) || pp.split("/").some((seg) => seg.includes("*") && seg !== "*")) {
246
+ if (!isValidPathPattern(pp)) {
244
247
  return { ok: false, error: `Path pattern must start with "/", be at most ${HINT_PATH_PATTERN_MAX} characters, contain no "?" or "#", and use "*" only as a whole segment.` };
245
248
  }
246
249
  if (h.kind === "use_endpoint") {
@@ -353,26 +356,45 @@ async function fetchSite(origin, fetchFn, fallback, parent) {
353
356
 
354
357
  // src/install.ts
355
358
  import { createHmac, randomBytes } from "crypto";
356
- import { mkdir, readFile, writeFile } from "fs/promises";
359
+ import { link, mkdir, readFile, rename, unlink, writeFile } from "fs/promises";
357
360
  import { join } from "path";
358
361
  var newSecret = () => randomBytes(32).toString("base64url");
362
+ async function claim(target) {
363
+ const tmp = `${target}.${randomBytes(8).toString("hex")}.tmp`;
364
+ await writeFile(tmp, JSON.stringify({ secret: newSecret() }) + "\n", { mode: 384 });
365
+ let created = true;
366
+ try {
367
+ await link(tmp, target);
368
+ } catch (e) {
369
+ if (e.code !== "EEXIST") throw e;
370
+ created = false;
371
+ } finally {
372
+ await unlink(tmp).catch(() => {
373
+ });
374
+ }
375
+ return { secret: await readSecret(target), created };
376
+ }
377
+ async function replaceWith(file, secret) {
378
+ const tmp = `${file}.${randomBytes(8).toString("hex")}.tmp`;
379
+ await writeFile(tmp, JSON.stringify({ secret }) + "\n", { mode: 384 });
380
+ await rename(tmp, file);
381
+ }
359
382
  async function getInstall(home) {
360
383
  const dir = join(home, ".maiday");
361
384
  const file = join(dir, "install.json");
362
385
  await mkdir(dir, { recursive: true });
363
- const existing = await readSecret(file);
364
- if (existing) return { secret: existing, firstRun: false };
365
- const secret = newSecret();
366
- try {
367
- await writeFile(file, JSON.stringify({ secret }) + "\n", { flag: "wx", mode: 384 });
368
- return { secret, firstRun: true };
369
- } catch (e) {
370
- if (e.code !== "EEXIST") throw e;
371
- const raced = await readSecret(file);
372
- if (raced) return { secret: raced, firstRun: false };
373
- await writeFile(file, JSON.stringify({ secret }) + "\n", { mode: 384 });
374
- return { secret, firstRun: true };
386
+ let existing = await readSecret(file);
387
+ if (typeof existing === "string") return { secret: existing, firstRun: false };
388
+ if (existing === null) {
389
+ const c = await claim(file);
390
+ if (c.created) return { secret: c.secret, firstRun: true };
391
+ if (typeof c.secret === "string") return { secret: c.secret, firstRun: false };
392
+ existing = c.secret;
375
393
  }
394
+ const replacement = await claim(join(dir, "install.v2.json"));
395
+ if (typeof replacement.secret !== "string") throw new Error(`unreadable ${join(dir, "install.v2.json")}`);
396
+ await replaceWith(file, replacement.secret);
397
+ return { secret: replacement.secret, firstRun: existing === null && replacement.created };
376
398
  }
377
399
  function cachedInstall(home) {
378
400
  let p;
@@ -384,14 +406,18 @@ function cachedInstall(home) {
384
406
  return p;
385
407
  };
386
408
  }
387
- function installIdFor(secret, reportEndpoint) {
388
- return createHmac("sha256", secret).update(new URL(reportEndpoint).origin).digest("base64url");
409
+ var INSTALL_ID_VERSION = "v2";
410
+ function installIdFor(secret, reportEndpoint, siteOrigin) {
411
+ const input = JSON.stringify(["maiday-install-id", INSTALL_ID_VERSION, new URL(reportEndpoint).origin, new URL(siteOrigin).origin]);
412
+ return `${INSTALL_ID_VERSION}.${createHmac("sha256", secret).update(input).digest("base64url")}`;
389
413
  }
414
+ var LEGACY = /* @__PURE__ */ Symbol("legacy install file");
390
415
  async function readSecret(file) {
391
416
  try {
392
417
  const parsed = JSON.parse(await readFile(file, "utf8"));
393
- const v = parsed.secret ?? parsed.id;
394
- return typeof v === "string" && v.length > 0 && v.length <= 128 ? v : null;
418
+ const v = parsed.secret;
419
+ if (typeof v === "string" && v.length > 0 && v.length <= 128) return v;
420
+ return typeof parsed.id === "string" && parsed.id.length > 0 ? LEGACY : null;
395
421
  } catch {
396
422
  return null;
397
423
  }
@@ -481,7 +507,7 @@ async function sendMaiday(input, deps) {
481
507
  return await withDeadline(
482
508
  async (signal) => {
483
509
  const site = await discover(report.origin, deps.fetch, discoverOpts(deps, signal));
484
- const body = { ...checked.data, install_id: installIdFor(secret, site.reportEndpoint) };
510
+ const body = { ...checked.data, install_id: installIdFor(secret, site.reportEndpoint, checked.data.origin) };
485
511
  const res = await deps.fetch(site.reportEndpoint, {
486
512
  method: "POST",
487
513
  redirect: "manual",
@@ -570,7 +596,7 @@ var sendShape = {
570
596
  };
571
597
  var asText = (payload) => ({ content: [{ type: "text", text: JSON.stringify(payload) }] });
572
598
  function createServer(baseDeps) {
573
- const server2 = new McpServer({ name: "maiday-mcp", version: "0.1.0" });
599
+ const server2 = new McpServer({ name: "maiday-mcp", version: "0.1.2" });
574
600
  const deps = { ...baseDeps, install: baseDeps.install ?? cachedInstall(baseDeps.config.home) };
575
601
  let noticeClaimed = false;
576
602
  const takeNotice = async () => {
package/dist/index.d.ts CHANGED
@@ -21,11 +21,14 @@ declare function getInstall(home: string): Promise<Install>;
21
21
  * firstRun and the first-run notice cannot be lost to a concurrent write. A failure is not cached (next call retries).
22
22
  */
23
23
  declare function cachedInstall(home: string): () => Promise<Install>;
24
+ /** Prefix of the current reporter-ID scheme; collectors count IDs per scheme and never add schemes together. */
25
+ declare const INSTALL_ID_VERSION = "v2";
24
26
  /**
25
- * The install_id sent to one report endpoint: base64url(HMAC-SHA256(secret, endpoint origin)), 43 chars.
26
- * Each collector sees a stable id it can count, but ids cannot be linked across collectors.
27
+ * The install_id sent with a report: "v2." + base64url(HMAC-SHA256(secret, JSON ["maiday-install-id", "v2",
28
+ * collector origin, site origin])). Stable for one collector and one site; different for every other site or
29
+ * collector, so reports cannot be linked across sites by matching IDs. JSON keeps the inputs unambiguous.
27
30
  */
28
- declare function installIdFor(secret: string, reportEndpoint: string): string;
31
+ declare function installIdFor(secret: string, reportEndpoint: string, siteOrigin: string): string;
29
32
 
30
33
  interface Discovered {
31
34
  reportEndpoint: string;
@@ -88,5 +91,5 @@ declare function getHints(url: string, deps: Deps): Promise<HintsResult>;
88
91
  declare const FIRST_RUN_NOTICE = "Maiday sends anonymous reports when an agent gets stuck on a website (page path, failure type, the site's error message with personal data removed). No form values or personal data are sent. Set MAIDAY_DISABLED=1 to turn reporting off. Please tell the user this once.";
89
92
  declare function createServer(baseDeps: Deps): McpServer;
90
93
 
91
- export { DEFAULT_COLLECTOR_URL, FIRST_RUN_NOTICE, HINTS_INSTRUCTIONS, cachedInstall, createServer, defaultEndpoints, discover, getHints, getInstall, installIdFor, isSafeEndpoint, loadConfig, sendMaiday };
94
+ export { DEFAULT_COLLECTOR_URL, FIRST_RUN_NOTICE, HINTS_INSTRUCTIONS, INSTALL_ID_VERSION, cachedInstall, createServer, defaultEndpoints, discover, getHints, getInstall, installIdFor, isSafeEndpoint, loadConfig, sendMaiday };
92
95
  export type { Config, Deps, DiscoverOptions, Discovered, Env, HintsResult, Install, SendResult };
package/dist/index.js CHANGED
@@ -13,26 +13,45 @@ function loadConfig(env = process.env) {
13
13
 
14
14
  // src/install.ts
15
15
  import { createHmac, randomBytes } from "crypto";
16
- import { mkdir, readFile, writeFile } from "fs/promises";
16
+ import { link, mkdir, readFile, rename, unlink, writeFile } from "fs/promises";
17
17
  import { join } from "path";
18
18
  var newSecret = () => randomBytes(32).toString("base64url");
19
+ async function claim(target) {
20
+ const tmp = `${target}.${randomBytes(8).toString("hex")}.tmp`;
21
+ await writeFile(tmp, JSON.stringify({ secret: newSecret() }) + "\n", { mode: 384 });
22
+ let created = true;
23
+ try {
24
+ await link(tmp, target);
25
+ } catch (e) {
26
+ if (e.code !== "EEXIST") throw e;
27
+ created = false;
28
+ } finally {
29
+ await unlink(tmp).catch(() => {
30
+ });
31
+ }
32
+ return { secret: await readSecret(target), created };
33
+ }
34
+ async function replaceWith(file, secret) {
35
+ const tmp = `${file}.${randomBytes(8).toString("hex")}.tmp`;
36
+ await writeFile(tmp, JSON.stringify({ secret }) + "\n", { mode: 384 });
37
+ await rename(tmp, file);
38
+ }
19
39
  async function getInstall(home) {
20
40
  const dir = join(home, ".maiday");
21
41
  const file = join(dir, "install.json");
22
42
  await mkdir(dir, { recursive: true });
23
- const existing = await readSecret(file);
24
- if (existing) return { secret: existing, firstRun: false };
25
- const secret = newSecret();
26
- try {
27
- await writeFile(file, JSON.stringify({ secret }) + "\n", { flag: "wx", mode: 384 });
28
- return { secret, firstRun: true };
29
- } catch (e) {
30
- if (e.code !== "EEXIST") throw e;
31
- const raced = await readSecret(file);
32
- if (raced) return { secret: raced, firstRun: false };
33
- await writeFile(file, JSON.stringify({ secret }) + "\n", { mode: 384 });
34
- return { secret, firstRun: true };
43
+ let existing = await readSecret(file);
44
+ if (typeof existing === "string") return { secret: existing, firstRun: false };
45
+ if (existing === null) {
46
+ const c = await claim(file);
47
+ if (c.created) return { secret: c.secret, firstRun: true };
48
+ if (typeof c.secret === "string") return { secret: c.secret, firstRun: false };
49
+ existing = c.secret;
35
50
  }
51
+ const replacement = await claim(join(dir, "install.v2.json"));
52
+ if (typeof replacement.secret !== "string") throw new Error(`unreadable ${join(dir, "install.v2.json")}`);
53
+ await replaceWith(file, replacement.secret);
54
+ return { secret: replacement.secret, firstRun: existing === null && replacement.created };
36
55
  }
37
56
  function cachedInstall(home) {
38
57
  let p;
@@ -44,14 +63,18 @@ function cachedInstall(home) {
44
63
  return p;
45
64
  };
46
65
  }
47
- function installIdFor(secret, reportEndpoint) {
48
- return createHmac("sha256", secret).update(new URL(reportEndpoint).origin).digest("base64url");
66
+ var INSTALL_ID_VERSION = "v2";
67
+ function installIdFor(secret, reportEndpoint, siteOrigin) {
68
+ const input = JSON.stringify(["maiday-install-id", INSTALL_ID_VERSION, new URL(reportEndpoint).origin, new URL(siteOrigin).origin]);
69
+ return `${INSTALL_ID_VERSION}.${createHmac("sha256", secret).update(input).digest("base64url")}`;
49
70
  }
71
+ var LEGACY = /* @__PURE__ */ Symbol("legacy install file");
50
72
  async function readSecret(file) {
51
73
  try {
52
74
  const parsed = JSON.parse(await readFile(file, "utf8"));
53
- const v = parsed.secret ?? parsed.id;
54
- return typeof v === "string" && v.length > 0 && v.length <= 128 ? v : null;
75
+ const v = parsed.secret;
76
+ if (typeof v === "string" && v.length > 0 && v.length <= 128) return v;
77
+ return typeof parsed.id === "string" && parsed.id.length > 0 ? LEGACY : null;
55
78
  } catch {
56
79
  return null;
57
80
  }
@@ -247,6 +270,9 @@ function matchHint(pattern, path) {
247
270
  return false;
248
271
  return true;
249
272
  }
273
+ function isValidPathPattern(pp) {
274
+ return pp.startsWith("/") && pp.length <= HINT_PATH_PATTERN_MAX && !/[?#]/.test(pp) && !hasControlChars(pp) && !pp.split("/").some((seg) => seg.includes("*") && seg !== "*");
275
+ }
250
276
  function isSameSiteEndpoint(endpoint, domain) {
251
277
  let u;
252
278
  try {
@@ -277,7 +303,7 @@ function validateHint(input, domain) {
277
303
  return { ok: false, error: "Hints may not contain control characters or line breaks." };
278
304
  }
279
305
  const pp = h.path_pattern;
280
- if (!pp.startsWith("/") || pp.length > HINT_PATH_PATTERN_MAX || /[?#]/.test(pp) || pp.split("/").some((seg) => seg.includes("*") && seg !== "*")) {
306
+ if (!isValidPathPattern(pp)) {
281
307
  return { ok: false, error: `Path pattern must start with "/", be at most ${HINT_PATH_PATTERN_MAX} characters, contain no "?" or "#", and use "*" only as a whole segment.` };
282
308
  }
283
309
  if (h.kind === "use_endpoint") {
@@ -472,7 +498,7 @@ async function sendMaiday(input, deps) {
472
498
  return await withDeadline(
473
499
  async (signal) => {
474
500
  const site = await discover(report.origin, deps.fetch, discoverOpts(deps, signal));
475
- const body = { ...checked.data, install_id: installIdFor(secret, site.reportEndpoint) };
501
+ const body = { ...checked.data, install_id: installIdFor(secret, site.reportEndpoint, checked.data.origin) };
476
502
  const res = await deps.fetch(site.reportEndpoint, {
477
503
  method: "POST",
478
504
  redirect: "manual",
@@ -563,7 +589,7 @@ var sendShape = {
563
589
  };
564
590
  var asText = (payload) => ({ content: [{ type: "text", text: JSON.stringify(payload) }] });
565
591
  function createServer(baseDeps) {
566
- const server = new McpServer({ name: "maiday-mcp", version: "0.1.0" });
592
+ const server = new McpServer({ name: "maiday-mcp", version: "0.1.2" });
567
593
  const deps = { ...baseDeps, install: baseDeps.install ?? cachedInstall(baseDeps.config.home) };
568
594
  let noticeClaimed = false;
569
595
  const takeNotice = async () => {
@@ -596,6 +622,7 @@ export {
596
622
  DEFAULT_COLLECTOR_URL,
597
623
  FIRST_RUN_NOTICE,
598
624
  HINTS_INSTRUCTIONS,
625
+ INSTALL_ID_VERSION,
599
626
  cachedInstall,
600
627
  createServer,
601
628
  defaultEndpoints,
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "maiday-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Maiday MCP client: privacy-safe stuck reports and site-owner hints for AI agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",
9
9
  "bin": {
10
- "maiday-mcp": "./dist/bin.js"
10
+ "maiday-mcp": "dist/bin.js"
11
11
  },
12
12
  "files": [
13
13
  "dist"