@nooticr/mcp 1.26.40 → 1.26.42

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/dist/index.js CHANGED
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * Modes:
7
7
  * nooticr-mcp stdio transport (default; Claude Desktop, Cursor)
8
- * nooticr-mcp login browser-based Google sign-in to nooticr
8
+ * nooticr-mcp login browser-based Google sign-in (or --api-key, no browser)
9
+ * nooticr-mcp api-key mint/list/revoke a key for a server with no browser
9
10
  * nooticr-mcp --http remote HTTP transport with OAuth (OpenAI Agents SDK)
10
11
  */
11
12
  import http from "node:http";
@@ -13,7 +14,8 @@ import { randomUUID } from "node:crypto";
13
14
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
15
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
15
16
  import { getBaseUrl, getPublicUrl, getPort, getCredentialsFile, getTransportMode, DEFAULT_PORT, } from "./config.js";
16
- import { AuthManager, NooticrAuthError, createHttpTokenProvider, createStdioTokenProvider } from "./auth.js";
17
+ import { AuthManager, NooticrAuthError, createApiKeyTokenProvider, createHttpTokenProvider, createManagementTokenProvider, createStdioTokenProvider, } from "./auth.js";
18
+ import { looksLikeApiKey } from "./shared/api-key.js";
17
19
  import { NooticrClient, NooticrError } from "./nooticr.js";
18
20
  import { OAuthManager, SCOPES } from "./oauth.js";
19
21
  import { createMcpServer } from "./shared/tools.js";
@@ -78,12 +80,21 @@ function bearerToken(req) {
78
80
  }
79
81
  /**
80
82
  * A request bearer token is valid when it was issued by our OAuth `/token`
81
- * endpoint, or when it matches `NOOTICR_ACCESS_TOKEN` (pre-provisioned
82
- * deployments and local testing — no browser OAuth round-trip needed).
83
+ * endpoint, when it is a nooticr API key, or when it matches
84
+ * `NOOTICR_ACCESS_TOKEN` (pre-provisioned deployments and local testing — no
85
+ * browser OAuth round-trip needed).
86
+ *
87
+ * A key is accepted on its shape alone because this process does not hold the
88
+ * secret to check it against — only nooticr-server does, and it checks every
89
+ * call. What that admits without a round trip is `initialize` and
90
+ * `tools/list`, both of which answer the same for everyone; the first call
91
+ * that touches an account gets the backend's own 401.
83
92
  */
84
93
  function validMcpToken(token, oauth) {
85
94
  if (oauth.verifyToken(token))
86
95
  return true;
96
+ if (looksLikeApiKey(token))
97
+ return true;
87
98
  const envToken = process.env.NOOTICR_ACCESS_TOKEN;
88
99
  return typeof envToken === "string" && envToken.length > 0 && token === envToken;
89
100
  }
@@ -93,8 +104,10 @@ async function handleMcpRequest(state, req, res) {
93
104
  if (!token || !validMcpToken(token, state.oauth)) {
94
105
  return sendJson(res, 401, {
95
106
  error: "Unauthorized",
96
- error_description: "This MCP server requires OAuth authentication. Fetch an access token from " +
97
- `${state.oauth.authorizationServerMetadata().authorization_endpoint} first.`,
107
+ error_description: "This MCP server requires authentication. Fetch an access token from " +
108
+ `${state.oauth.authorizationServerMetadata().authorization_endpoint}, or — on a ` +
109
+ "server with no browser — send a nooticr API key as the bearer token " +
110
+ "(npx @nooticr/mcp api-key create).",
98
111
  });
99
112
  }
100
113
  // Hand the validated token to the SDK transport, which forwards it to tool
@@ -221,8 +234,16 @@ export async function runHttp(port, publicUrl) {
221
234
  transports: new Map(),
222
235
  connections: new Map(),
223
236
  serverFactory: () => createMcpServer((extra) => {
224
- const session = extra.authInfo?.token
225
- ? oauth.verifyToken(extra.authInfo.token)
237
+ const bearer = extra.authInfo?.token;
238
+ // A caller who presented their own API key is answered as themselves.
239
+ // Falling through to `createHttpTokenProvider` here would resolve the
240
+ // *operator's* environment or credentials file instead — one tenant's
241
+ // request served from another's account.
242
+ if (looksLikeApiKey(bearer)) {
243
+ return new NooticrClient(baseUrl, createApiKeyTokenProvider(bearer));
244
+ }
245
+ const session = bearer
246
+ ? oauth.verifyToken(bearer)
226
247
  : undefined;
227
248
  return new NooticrClient(baseUrl, createHttpTokenProvider(auth, session
228
249
  ? {
@@ -253,6 +274,27 @@ export async function runLogin(opts) {
253
274
  const client = new NooticrClient(baseUrl, {
254
275
  getAccessToken: async () => undefined,
255
276
  });
277
+ if (opts.apiKey) {
278
+ // Check the key before writing it. This is the one moment a person is
279
+ // present to fix a typo — an unchecked key would instead surface as a
280
+ // failed tool call later, from a client that cannot explain itself.
281
+ const keyed = new NooticrClient(baseUrl, createApiKeyTokenProvider(opts.apiKey));
282
+ let user;
283
+ try {
284
+ user = await keyed.me();
285
+ }
286
+ catch (err) {
287
+ const msg = err instanceof Error ? err.message : String(err);
288
+ throw new NooticrAuthError(`That API key was rejected by ${baseUrl}: ${msg}\n` +
289
+ "`npx nooticr-mcp api-key list` (signed in) shows which keys are still active.");
290
+ }
291
+ await auth.persistApiKey(opts.apiKey, user);
292
+ process.stdout.write(`Signed in as ${user?.email ?? "unknown user"} with an API key. ` +
293
+ `Saved to ${auth.getCredentialsFile()}\n` +
294
+ "The key does not expire, so there is nothing to refresh and no browser to open again.\n" +
295
+ "On a server, prefer NOOTICR_API_KEY over this file — see the README.\n");
296
+ return;
297
+ }
256
298
  if (opts.email && opts.password) {
257
299
  const session = await client.login(opts.email, opts.password);
258
300
  await auth.persistSession(session);
@@ -315,6 +357,98 @@ export async function runLogin(opts) {
315
357
  process.exit(1);
316
358
  }, 300_000).unref();
317
359
  }
360
+ function formatDate(value) {
361
+ if (!value)
362
+ return "never";
363
+ const at = new Date(value);
364
+ return Number.isNaN(at.getTime()) ? value : at.toISOString().slice(0, 10);
365
+ }
366
+ function describeKey(key) {
367
+ const state = key.revokedAt
368
+ ? "revoked"
369
+ : key.expiresAt && new Date(key.expiresAt).getTime() <= Date.now()
370
+ ? "expired"
371
+ : "active";
372
+ return [
373
+ `${key.prefix}\u2026 ${key.name}`,
374
+ ` id ${key.id}`,
375
+ ` state ${state}`,
376
+ ` created ${formatDate(key.createdAt)}`,
377
+ ` last used ${formatDate(key.lastUsedAt)}`,
378
+ ` expires ${formatDate(key.expiresAt)}`,
379
+ ].join("\n");
380
+ }
381
+ /**
382
+ * Mints, lists and revokes the keys a headless deployment authenticates with.
383
+ *
384
+ * Creating one needs an interactive session exactly once — that is the step it
385
+ * removes from every run afterwards — so these commands deliberately refuse to
386
+ * authenticate with `NOOTICR_API_KEY` even when one is set. The backend
387
+ * refuses key management to a key-authenticated caller anyway; resolving one
388
+ * here would only turn that bound into a 403 that reads like a bug.
389
+ */
390
+ export async function runApiKey(opts) {
391
+ const baseUrl = getBaseUrl();
392
+ const auth = new AuthManager(baseUrl, getCredentialsFile());
393
+ const token = await auth.getAccessToken(undefined, { allowApiKey: false });
394
+ if (!token) {
395
+ // Telling these two apart matters: "sign in first" is wrong advice for
396
+ // someone who *is* signed in, with a key, and is being refused for a
397
+ // reason the message otherwise never mentions.
398
+ const hasKey = Boolean(await auth.getAccessToken());
399
+ throw new NooticrAuthError(hasKey
400
+ ? "You are signed in with an API key, and a key cannot manage keys — that bound is " +
401
+ "what stops a leaked key minting a replacement that outlives revoking the original. " +
402
+ "Sign in as yourself for this one: `npx nooticr-mcp login` " +
403
+ "(or `login --email ... --password ...`)."
404
+ : "Sign in first: `npx nooticr-mcp login` (or `login --email ... --password ...`). " +
405
+ "Minting a key is the one step that needs a browser, and it is the last one: " +
406
+ "the key it returns is what every run afterwards uses.");
407
+ }
408
+ const client = new NooticrClient(baseUrl, createManagementTokenProvider(auth));
409
+ if (opts.action === "create") {
410
+ const created = await client.createApiKey({
411
+ ...(opts.name !== undefined ? { name: opts.name } : {}),
412
+ ...(opts.workspaceId !== undefined ? { workspaceId: opts.workspaceId } : {}),
413
+ ...(opts.expiresInDays !== undefined ? { expiresInDays: opts.expiresInDays } : {}),
414
+ });
415
+ if (opts.json) {
416
+ process.stdout.write(`${JSON.stringify(created, null, 2)}\n`);
417
+ return;
418
+ }
419
+ process.stdout.write(`Created API key "${created.name}" (${created.id}).\n\n` +
420
+ ` ${created.key}\n\n` +
421
+ "This is the only time the key is shown — nooticr stores a hash of it and\n" +
422
+ "cannot show it again. Store it wherever this deployment keeps its secrets,\n" +
423
+ "then give it to the server:\n\n" +
424
+ " NOOTICR_API_KEY=<key> npx nooticr-mcp\n\n" +
425
+ "or, against the remote HTTP endpoint, as the bearer token:\n\n" +
426
+ " Authorization: Bearer <key>\n");
427
+ return;
428
+ }
429
+ if (opts.action === "list") {
430
+ const keys = await client.listApiKeys();
431
+ if (opts.json) {
432
+ process.stdout.write(`${JSON.stringify(keys, null, 2)}\n`);
433
+ return;
434
+ }
435
+ if (keys.length === 0) {
436
+ process.stdout.write("No API keys. Create one with `npx nooticr-mcp api-key create --name <name>`.\n");
437
+ return;
438
+ }
439
+ process.stdout.write(`${keys.map(describeKey).join("\n\n")}\n`);
440
+ return;
441
+ }
442
+ if (!opts.id) {
443
+ throw new NooticrError(400, "Usage: nooticr-mcp api-key revoke <id>");
444
+ }
445
+ await client.revokeApiKey(opts.id);
446
+ if (opts.json) {
447
+ process.stdout.write(`${JSON.stringify({ id: opts.id, revoked: true }, null, 2)}\n`);
448
+ return;
449
+ }
450
+ process.stdout.write(`Revoked ${opts.id}. Any server still presenting it now gets a 401.\n`);
451
+ }
318
452
  // ---------------------------------------------------------------------------
319
453
  // CLI
320
454
  // ---------------------------------------------------------------------------
@@ -328,11 +462,18 @@ Usage:
328
462
  (default port ${DEFAULT_PORT}; also NOOTICR_PORT)
329
463
  nooticr-mcp login Sign in to nooticr via Google in your browser
330
464
  nooticr-mcp login --email me@example.com --password '...' Password login
465
+ nooticr-mcp login --api-key nk_... Sign in with an API key (no browser)
466
+ nooticr-mcp api-key create [--name N] [--expires-in-days D] [--workspace-id W] [--json]
467
+ Mint a key for a server with no browser
468
+ nooticr-mcp api-key list [--json] List this account's keys
469
+ nooticr-mcp api-key revoke <id> [--json] Revoke one
331
470
  nooticr-mcp --help Show this help
332
471
 
333
472
  Environment variables:
334
473
  NOOTICR_BASE_URL nooticr server base URL (default http://localhost:8080)
335
474
  NOOTICR_ACCESS_TOKEN nooticr JWT access token (bypasses login)
475
+ NOOTICR_API_KEY nooticr API key from "api-key create" — no browser,
476
+ no refresh, valid until revoked
336
477
  NOOTICR_CREDENTIALS_FILE token store path (default ~/.config/nooticr-mcp/credentials.json)
337
478
  NOOTICR_PUBLIC_URL public base URL for the HTTP mode (default http://localhost:3457)
338
479
  NOOTICR_PORT port for --http and login (default ${DEFAULT_PORT})
@@ -343,6 +484,11 @@ Client setup:
343
484
  "command": "npx", "args": ["nooticr-mcp"] (plus NOOTICR_ACCESS_TOKEN if needed)
344
485
  OpenAI Agents SDK (remote HTTP): use the RemoteMCPClient with URL
345
486
  <NOOTICR_PUBLIC_URL>/mcp — the OAuth flow will open your browser.
487
+ Server-side, no browser: mint a key once with "api-key create", then either
488
+ set NOOTICR_API_KEY for the stdio server, or send it to the remote endpoint
489
+ as "Authorization: Bearer <key>" and skip the OAuth flow entirely.
490
+ A client that does not inherit your shell environment: "login --api-key nk_..."
491
+ stores the same key in the credentials file instead.
346
492
 
347
493
  See README.md for full instructions.
348
494
  `);
@@ -361,6 +507,17 @@ async function main() {
361
507
  };
362
508
  const email = valueOf("--email");
363
509
  const password = valueOf("--password");
510
+ // Every other flag here is kebab-case; `--api_key` is the near-miss worth
511
+ // naming rather than ignoring, since ignoring it opens a browser instead.
512
+ if (rest.includes("--api_key")) {
513
+ process.stderr.write("Unknown flag --api_key. Did you mean --api-key?\n");
514
+ process.exit(1);
515
+ }
516
+ const apiKey = valueOf("--api-key");
517
+ if (rest.includes("--api-key") && !apiKey) {
518
+ process.stderr.write("--api-key needs a value (the nk_… key from `api-key create`).\n");
519
+ process.exit(1);
520
+ }
364
521
  let port = getPort();
365
522
  const portRaw = valueOf("--port");
366
523
  if (portRaw !== undefined)
@@ -373,8 +530,12 @@ async function main() {
373
530
  process.stderr.write("Both --email and --password must be provided together.\n");
374
531
  process.exit(1);
375
532
  }
533
+ if (apiKey && (email || password)) {
534
+ process.stderr.write("Use --api-key or --email/--password, not both.\n");
535
+ process.exit(1);
536
+ }
376
537
  try {
377
- await runLogin({ email, password, port });
538
+ await runLogin({ email, password, apiKey, port });
378
539
  }
379
540
  catch (err) {
380
541
  process.stderr.write(`Login failed: ${err instanceof Error ? err.message : String(err)}\n`);
@@ -382,6 +543,55 @@ async function main() {
382
543
  }
383
544
  return;
384
545
  }
546
+ if (args[0] === "api-key" || args[0] === "api-keys") {
547
+ const rest = args.slice(1);
548
+ const action = rest[0];
549
+ if (action !== "create" && action !== "list" && action !== "revoke") {
550
+ process.stderr.write("Usage: nooticr-mcp api-key <create|list|revoke> [options]\n" +
551
+ " nooticr-mcp api-key create [--name N] [--expires-in-days D] [--workspace-id W]\n" +
552
+ " nooticr-mcp api-key revoke <id>\n");
553
+ process.exit(1);
554
+ }
555
+ const flags = rest.slice(1);
556
+ const valueOf = (flag) => {
557
+ const idx = flags.indexOf(flag);
558
+ return idx >= 0 && flags[idx + 1] ? flags[idx + 1] : undefined;
559
+ };
560
+ const opts = { action, json: flags.includes("--json") };
561
+ const name = valueOf("--name");
562
+ if (name !== undefined)
563
+ opts.name = name;
564
+ const workspaceId = valueOf("--workspace-id");
565
+ if (workspaceId !== undefined)
566
+ opts.workspaceId = workspaceId;
567
+ const expiresRaw = valueOf("--expires-in-days");
568
+ if (expiresRaw !== undefined) {
569
+ const days = Number.parseInt(expiresRaw, 10);
570
+ if (!Number.isInteger(days) || days < 1) {
571
+ process.stderr.write("--expires-in-days must be a positive whole number of days.\n");
572
+ process.exit(1);
573
+ }
574
+ opts.expiresInDays = days;
575
+ }
576
+ if (action === "revoke") {
577
+ // The id is positional, so anything that starts with "-" is a flag the
578
+ // user meant for something else, not the key they want gone.
579
+ const id = flags.find((arg) => !arg.startsWith("-"));
580
+ if (!id) {
581
+ process.stderr.write("Usage: nooticr-mcp api-key revoke <id>\n");
582
+ process.exit(1);
583
+ }
584
+ opts.id = id;
585
+ }
586
+ try {
587
+ await runApiKey(opts);
588
+ }
589
+ catch (err) {
590
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
591
+ process.exit(1);
592
+ }
593
+ return;
594
+ }
385
595
  const isHttp = args.includes("--http") || getTransportMode() === "http";
386
596
  let port = getPort();
387
597
  if (args.includes("--port")) {
@@ -192,7 +192,7 @@ export function categoryGuidance(opts) {
192
192
  lines.push("Judge only from what the reviews say. Where the evidence is thin, say so rather than", "producing a confident line the text does not support — a category read that overstates its", "basis is worse than a short one. Prices, ratings and mention counts are in the payload;", "do not re-derive them by hand.");
193
193
  if (insightsTool) {
194
194
  lines.push("");
195
- lines.push("To show the result in the conversation — products, their reviews, and your read side by", `side — call ${insightsTool} with what you concluded. It costs nothing and`, "makes no further requests.");
195
+ lines.push("To show the result in the conversation — products, their reviews, and your read side by", `side — call ${insightsTool} with what you concluded${opts.scanId ? ` and scanId "${opts.scanId}"` : " and the scanId"}.`, "It costs nothing, and it re-reads the listings from the scan itself, so do not re-send them.");
196
196
  }
197
197
  return lines.join("\n");
198
198
  }
@@ -390,14 +390,19 @@ export function registerAmazonTools(server, makeClient) {
390
390
  description: "Display the category read you produced from scan_amazon_category: purchase drivers, " +
391
391
  "barriers, what each brand does well, the gaps, and the positioning angles — drawn beside " +
392
392
  "the listings and their reviews, so a person can click a product and check any claim " +
393
- "against the text it came from. Free, and makes no requests: it only draws what you pass " +
394
- "it, attributed to you rather than presented as a nooticr rating of anyone's product. " +
393
+ "against the text it came from. Pass the scanId: the listings are then re-read from the " +
394
+ "scan itself (free) rather than taken from you, so do not re-send them, and any ASIN you " +
395
+ "cite that the scan does not contain is flagged on the card. Without a scanId, listings " +
396
+ "you pass are drawn marked as not checked against a scan. Free. Your read is attributed " +
397
+ "to you rather than presented as a nooticr rating of anyone's product. " +
395
398
  "Call this after you have read the reviews, not instead of reading them.",
396
399
  annotations: {
397
400
  readOnlyHint: true,
398
401
  destructiveHint: false,
399
402
  idempotentHint: true,
400
- // Draws what it is given; reaches nothing.
403
+ // Draws what it is given. Its one request re-reads a scan the caller
404
+ // already ran, from nooticr's own job store with no wait, so it
405
+ // fetches nothing new from the marketplace (#107).
401
406
  openWorldHint: false,
402
407
  },
403
408
  outputSchema: OUTPUT_SCHEMAS.show_amazon_category_insights,
@@ -457,15 +462,17 @@ export function registerAmazonTools(server, makeClient) {
457
462
  products: z
458
463
  .array(anyObject())
459
464
  .optional()
460
- .describe("The listings from the scan, passed straight through so the view can draw them."),
461
- rollup: anyObject().optional().describe("The rollup from the scan, passed straight through."),
462
- scanId: z.string().optional().describe("The scan this reads, for the record."),
465
+ .describe("Only when there is no scanId. With one, the listings are re-read from the scan and anything here is ignored."),
466
+ rollup: anyObject().optional().describe("Only when there is no scanId; re-read from the scan otherwise."),
467
+ scanId: z
468
+ .string()
469
+ .optional()
470
+ .describe("The scan this reads. Pass it: it is what lets the card draw the collector's own listings."),
463
471
  })
464
472
  .passthrough(),
465
- }, async (args) => {
466
- const products = Array.isArray(args.products)
467
- ? args.products
468
- : [];
473
+ }, async (args, extra) => {
474
+ const checked = await rehydrate(args, () => makeClient({ ...extra, arguments: args }));
475
+ const products = checked.products;
469
476
  const counts = {
470
477
  drivers: Array.isArray(args.drivers) ? args.drivers.length : 0,
471
478
  barriers: Array.isArray(args.barriers) ? args.barriers.length : 0,
@@ -475,7 +482,8 @@ export function registerAmazonTools(server, makeClient) {
475
482
  const text = `Category read for ${String(args.category ?? "this category")}: ${counts.drivers} driver(s), ` +
476
483
  `${counts.barriers} barrier(s), ${counts.gaps} gap(s), ${counts.positioning} positioning angle(s)` +
477
484
  `${products.length ? `, drawn beside ${products.length} listing(s)` : ""}.` +
478
- (args.summary ? ` ${String(args.summary)}` : "");
485
+ (args.summary ? ` ${String(args.summary)}` : "") +
486
+ ` ${checked.verification.note}`;
479
487
  return {
480
488
  content: [{ type: "text", text }],
481
489
  structuredContent: {
@@ -489,14 +497,77 @@ export function registerAmazonTools(server, makeClient) {
489
497
  gaps: args.gaps ?? [],
490
498
  positioning: args.positioning ?? [],
491
499
  products,
492
- rollup: args.rollup ?? {},
500
+ rollup: checked.rollup,
493
501
  scanId: args.scanId ?? null,
494
- // Nothing was fetched, so nothing was charged.
502
+ verification: checked.verification,
503
+ // The one read is amazon_scan_status, which is free by design.
495
504
  mcpCredits: { cost: 0 },
496
505
  },
497
506
  };
498
507
  });
499
508
  }
509
+ /**
510
+ * The listings a category read is drawn beside, from the scan rather than
511
+ * from the model (#107).
512
+ *
513
+ * The card exists so a person can check a claim against the text it came
514
+ * from, which only works if the text is the collector's. A model that dropped
515
+ * three listings, mis-copied a price or invented a competitor used to produce
516
+ * a card identical to a truthful one. With a scanId the listings are re-read
517
+ * from the scan, free and with no wait, and whatever the model sent is only
518
+ * compared against them. Without one, or when the re-read fails, what the
519
+ * model sent is drawn and marked unverified rather than refused: a card with a
520
+ * visible caveat is more use to a person than no card.
521
+ */
522
+ export async function rehydrate(args, client) {
523
+ const sent = Array.isArray(args.products) ? args.products : [];
524
+ const sentRollup = (args.rollup ?? {});
525
+ const scanId = typeof args.scanId === "string" ? args.scanId.trim() : "";
526
+ const unverified = (note) => ({
527
+ products: sent,
528
+ rollup: sentRollup,
529
+ verification: { status: "unverified", note },
530
+ });
531
+ if (!scanId) {
532
+ return unverified(sent.length
533
+ ? "No scanId was passed, so these listings are as re-sent and were not checked against a scan."
534
+ : "No scanId was passed, so there are no listings to draw beside this read.");
535
+ }
536
+ let payload;
537
+ try {
538
+ const res = await (await client()).callTool("amazon_scan_status", { scanId, waitSeconds: 0 });
539
+ payload = structured(res);
540
+ }
541
+ catch (err) {
542
+ const why = err instanceof Error ? err.message : String(err);
543
+ return unverified(`Scan ${scanId} could not be re-read (${why}), so these listings are as re-sent and were not checked.`);
544
+ }
545
+ const products = normaliseProducts(payload.products);
546
+ const ids = new Set(products.map((p) => String(p.asin ?? "")).filter(Boolean));
547
+ const idOf = (p) => String([p.asin, p.sku, p.pid, p.goods_id, p.item_id, p.product_id, p.id].find((v) => typeof v === "string" && v) ?? "");
548
+ const notInScan = [...new Set(sent.map(idOf).filter((id) => id && !ids.has(id)))];
549
+ const cited = Array.isArray(args.strengths)
550
+ ? args.strengths.map((s) => String(s.asin ?? "")).filter(Boolean)
551
+ : [];
552
+ const unknownAsins = [...new Set(cited.filter((a) => !ids.has(a)))];
553
+ const scanComplete = payload.complete !== false;
554
+ const flags = [
555
+ notInScan.length ? `${notInScan.length} listing(s) you sent are not in the scan (${notInScan.join(", ")}) and are not drawn.` : "",
556
+ unknownAsins.length ? `ASIN(s) cited in strengths but not in the scan: ${unknownAsins.join(", ")}.` : "",
557
+ scanComplete ? "" : "The scan is still running, so the set drawn is what it had collected so far.",
558
+ ].filter(Boolean);
559
+ return {
560
+ products,
561
+ rollup: (payload.rollup ?? {}),
562
+ verification: {
563
+ status: "verified",
564
+ note: [`The ${products.length} listing(s) drawn are the scan's own, re-read from ${scanId}.`, ...flags].join(" "),
565
+ scanComplete,
566
+ notInScan,
567
+ unknownAsins,
568
+ },
569
+ };
570
+ }
500
571
  /**
501
572
  * One scan result, shaped for both channels.
502
573
  *
@@ -0,0 +1,20 @@
1
+ /**
2
+ * nooticr API keys — the credential for a caller with no browser.
3
+ *
4
+ * Everything else this package can authenticate with ends at a 15-minute JWT:
5
+ * `login` writes one (plus a refresh token) to a credentials file, and the
6
+ * OAuth flows mint one behind a consent screen. Both assume a machine someone
7
+ * can sit at once. A partner's backend cannot run either, so it gets a key
8
+ * instead: one string, set once, valid until it is revoked.
9
+ *
10
+ * The prefix is load-bearing in two places that must agree — this file and
11
+ * nooticr-server's `routes::api_keys::API_KEY_PREFIX`. The server uses it to
12
+ * decide whether a bearer token is a key at all (and therefore whether to look
13
+ * in its database); this package uses it to decide whether a token can be
14
+ * refreshed. A JWT is three base64url segments, so it can never collide.
15
+ */
16
+ export const API_KEY_PREFIX = "nk_";
17
+ /** Whether a bearer token is a nooticr API key rather than a JWT. */
18
+ export function looksLikeApiKey(token) {
19
+ return typeof token === "string" && token.startsWith(API_KEY_PREFIX);
20
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * nooticr-server tools this public surface deliberately does not register.
3
+ *
4
+ * `POST /mcp` also serves the dashboard's internal copilot, so it carries
5
+ * tools that have no business in a Claude or ChatGPT host. Absence alone
6
+ * reads the same as "nobody got round to it", so each exclusion is written
7
+ * down with its reason (#106). A tool leaving this list for `TOOL_NAMES` is a
8
+ * product decision, not a cleanup; tests/backend-only.test.ts keeps the two
9
+ * lists disjoint.
10
+ */
11
+ export const BACKEND_ONLY_TOOLS = {
12
+ get_watchlist_baseline: "Plumbing for BackendWatchStore, which reads a creator's baseline itself; a host has nothing to do with the raw row.",
13
+ advance_watchlist_baseline: "Plumbing for BackendWatchStore; catch_up_watchlist advances the baseline after the user confirms, so a host never needs to.",
14
+ buy_nooticr_credits: "No purchase path is exposed to a model (#100). Credits are topped up on the nooticr website.",
15
+ overlay_bake: "Rendering and publishing stay in the dashboard (#92).",
16
+ bake_job_status: "Rendering and publishing stay in the dashboard (#92).",
17
+ compose_sequence: "Rendering and publishing stay in the dashboard (#92).",
18
+ spawn_variants: "Rendering and publishing stay in the dashboard (#92).",
19
+ enqueue_publish_job: "Rendering and publishing stay in the dashboard (#92).",
20
+ schedule_post: "Rendering and publishing stay in the dashboard (#92).",
21
+ get_batch_history: "History of the dashboard's compose batches, which only exist once rendering does; stays with #92.",
22
+ get_latest_analysis: "Returns the dashboard's stored AI fleet-analysis verdict; the public surface sells the evidence, not a judgement of ours.",
23
+ brand_watch_history: "Not a deliberate exclusion: a candidate for the public surface once it has a trend view to draw. Listed so it reads as pending, not forgotten.",
24
+ get_google_analytics: "Site analytics are dashboard-only until #104 decides their shape.",
25
+ get_posthog_analytics: "Site analytics are dashboard-only until #104 decides their shape.",
26
+ get_search_console_data: "Site analytics are dashboard-only until #104 decides their shape.",
27
+ };
@@ -44,8 +44,10 @@ export function registerConnectionTools(server, makeClient) {
44
44
  "allowed to do — read the account, publish a post, manage comments. Each answer is yes, " +
45
45
  "no, or unknown; unknown means the grant predates scope recording, so treat it as \"try " +
46
46
  "it\", not as a refusal. Also returns which platforms can be connected at all, which is a " +
47
- "smaller set than the networks nooticr can read. Call this before promising that " +
48
- "something can be posted or replied to. No cost to call.",
47
+ "smaller set than the networks nooticr can read. \"Manage comments\" reports what the " +
48
+ "platform granted, not something nooticr does: no nooticr tool posts, replies to or " +
49
+ "moderates a comment, so a yes there never means a drafted reply will be sent. Call this " +
50
+ "before promising that something can be posted or replied to. No cost to call.",
49
51
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
50
52
  inputSchema: z.object({}).strict(),
51
53
  outputSchema: OUTPUT_SCHEMAS.list_social_connections,