@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/README.md +120 -15
- package/dist/auth.js +78 -7
- package/dist/config.js +11 -0
- package/dist/index.js +219 -9
- package/dist/shared/amazon.js +85 -14
- package/dist/shared/api-key.js +20 -0
- package/dist/shared/backend-only.js +27 -0
- package/dist/shared/connections.js +4 -2
- package/dist/shared/evidence-digest.js +83 -20
- package/dist/shared/getting-started.js +201 -0
- package/dist/shared/jobs.js +20 -10
- package/dist/shared/loading-plans.js +18 -0
- package/dist/shared/nooticr.js +74 -4
- package/dist/shared/oauth.js +3 -2
- package/dist/shared/output-schemas.js +104 -4
- package/dist/shared/prompts.js +5 -1
- package/dist/shared/spend.js +4 -0
- package/dist/shared/tools-def.js +47 -11
- package/dist/shared/tools.js +304 -8
- package/dist/shared/ui-template.js +159 -5
- package/dist/shared/watchlist.js +39 -0
- package/package.json +1 -1
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
|
|
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
|
|
82
|
-
* deployments and local testing — no
|
|
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
|
|
97
|
-
`${state.oauth.authorizationServerMetadata().authorization_endpoint}
|
|
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
|
|
225
|
-
|
|
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")) {
|
package/dist/shared/amazon.js
CHANGED
|
@@ -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
|
|
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.
|
|
394
|
-
"
|
|
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
|
|
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("
|
|
461
|
-
rollup: anyObject().optional().describe("
|
|
462
|
-
scanId: z
|
|
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
|
|
467
|
-
|
|
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:
|
|
500
|
+
rollup: checked.rollup,
|
|
493
501
|
scanId: args.scanId ?? null,
|
|
494
|
-
|
|
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.
|
|
48
|
-
"something
|
|
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,
|