@nooticr/mcp 1.26.40 → 1.26.41

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 CHANGED
@@ -337,6 +337,76 @@ npx @nooticr/mcp --http # remote HTTP with OAuth (for OpenAI Agents SDK)
337
337
  (mode `0600`). If your browser cannot be opened automatically, copy the URL it
338
338
  prints into a browser manually.
339
339
 
340
+ ## Running on a server (no browser)
341
+
342
+ `login` and the OAuth flow both need a browser once, and both leave behind a
343
+ 15-minute access token kept alive by a refresh token. That is the wrong shape
344
+ for an integration running server-side: nothing there can complete a consent
345
+ screen, and a refresh chain that breaks has to be repaired by hand.
346
+
347
+ An **API key** is the other way in. Mint one from a browser at
348
+ [mcp.nooticr.com/dashboard](https://mcp.nooticr.com/dashboard) — sign in, name
349
+ the key, copy it — or from a machine you *can* sign in on, once:
350
+
351
+ ```bash
352
+ npx @nooticr/mcp login # the one interactive step
353
+ npx @nooticr/mcp api-key create --name "aybee-prod" # prints the key, once
354
+ ```
355
+
356
+ Then give it to the deployment. There is nothing else to configure and nothing
357
+ to refresh — the key stays valid until you revoke it.
358
+
359
+ ```bash
360
+ # stdio, as a subprocess of your own service
361
+ NOOTICR_API_KEY=nk_... npx @nooticr/mcp
362
+ ```
363
+
364
+ On a server, prefer the environment variable: it is what every container
365
+ runtime and secret manager already injects, and it leaves nothing on a disk
366
+ that a redeploy is going to throw away. Where setting one is the awkward part
367
+ — a laptop, or a client like Claude Desktop that does not inherit your shell —
368
+ store the key in the credentials file instead:
369
+
370
+ ```bash
371
+ npx @nooticr/mcp login --api-key nk_...
372
+ ```
373
+
374
+ That checks the key against the server before writing it, so a typo fails
375
+ while you are still there to fix it, and then behaves exactly like the
376
+ environment variable: no browser, no refresh, valid until revoked. The file
377
+ holds one credential, so this replaces any session an earlier `login` left
378
+ behind (and `NOOTICR_API_KEY` still overrides it).
379
+
380
+ ```http
381
+ # or against the remote endpoint, as an ordinary bearer token —
382
+ # no /authorize, no PKCE, no redirect URI
383
+ POST https://mcp.nooticr.com/mcp
384
+ Authorization: Bearer nk_...
385
+ ```
386
+
387
+ Managing them, from the dashboard's **API keys** card or the CLI:
388
+
389
+ ```bash
390
+ npx @nooticr/mcp api-key create --name "staging" --expires-in-days 90
391
+ npx @nooticr/mcp api-key list
392
+ npx @nooticr/mcp api-key revoke <id>
393
+ ```
394
+
395
+ Every command takes `--json` for scripting. A few things worth knowing:
396
+
397
+ - The key is shown **once**, by `api-key create`. nooticr stores only a hash of
398
+ it and cannot show it again; `list` shows the head (`nk_1a2b3c4d…`) so you
399
+ can tell keys apart.
400
+ - A key carries the scope of whoever created it — their account, and the
401
+ workspace they were in (`--workspace-id` picks another one you belong to).
402
+ - A key **cannot create or revoke keys**. That is deliberate: it means a leaked
403
+ key cannot mint a replacement that outlives revoking the original.
404
+ - `--expires-in-days` is optional. Without it a key does not expire on its own.
405
+ - Revoking takes effect immediately at the API. The hosted remote endpoint
406
+ caches the "this key is real" answer for up to five minutes, which only ever
407
+ lets a revoked key see the tool list — every call that touches your account
408
+ is checked against nooticr-server on the spot.
409
+
340
410
  ## Usage in Claude Desktop
341
411
 
342
412
  After `npx @nooticr/mcp login`, add to `claude_desktop_config.json`:
@@ -455,6 +525,11 @@ nooticr-mcp --stdio Same as above
455
525
  nooticr-mcp --http [--port N] Start the remote HTTP transport with OAuth (default port 3457)
456
526
  nooticr-mcp login Sign in to nooticr via Google in your browser
457
527
  nooticr-mcp login --email ... --password ... Password login
528
+ nooticr-mcp login --api-key nk_... Sign in with an API key, no browser
529
+ nooticr-mcp api-key create [--name N] [--expires-in-days D] [--workspace-id W] [--json]
530
+ Mint a key for a server with no browser
531
+ nooticr-mcp api-key list [--json] List this account's keys
532
+ nooticr-mcp api-key revoke <id> [--json] Revoke one
458
533
  nooticr-mcp --help Show help
459
534
  ```
460
535
 
@@ -463,7 +538,8 @@ nooticr-mcp --help Show help
463
538
  | Variable | Default | Description |
464
539
  |----------|---------|-------------|
465
540
  | `NOOTICR_BASE_URL` | `https://api.nooticr.com` | nooticr server base URL (trailing slash stripped) |
466
- | `NOOTICR_ACCESS_TOKEN` | — | nooticr JWT access token; takes priority over the credentials file |
541
+ | `NOOTICR_ACCESS_TOKEN` | — | nooticr JWT access token; takes priority over everything below |
542
+ | `NOOTICR_API_KEY` | — | nooticr API key from `api-key create` — no browser, no refresh, valid until revoked; takes priority over the credentials file (including a key `login --api-key` put there) |
467
543
  | `NOOTICR_CREDENTIALS_FILE` | `~/.config/nooticr-mcp/credentials.json` | token store path |
468
544
  | `NOOTICR_PUBLIC_URL` | `http://localhost:3457` | public base URL advertised in OAuth metadata (HTTP mode) |
469
545
  | `NOOTICR_PORT` | `3457` | port for `--http` and `login` |
@@ -471,10 +547,20 @@ nooticr-mcp --help Show help
471
547
 
472
548
  ## How authentication works
473
549
 
474
- **stdio mode** (Claude Desktop, Cursor): the server uses the token from
475
- `NOOTICR_ACCESS_TOKEN` or the credentials file written by `login`. If the token
476
- is expired it is automatically refreshed with the stored refresh token, and if
477
- the nooticr API returns `401` the request is retried once after a refresh.
550
+ **stdio mode** (Claude Desktop, Cursor): the server uses the first credential
551
+ it finds — `NOOTICR_ACCESS_TOKEN`, then `NOOTICR_API_KEY`, then the credentials
552
+ file written by `login`. That file holds one credential, whichever `login` last
553
+ wrote: an API key (`login --api-key`) or a session. A session token that has
554
+ expired is refreshed with the stored refresh token, and a `401` from the
555
+ nooticr API is retried once after a refresh. An API key is never refreshed
556
+ wherever it came from: it does not expire, so a `401` on one means revoked or
557
+ mistyped, and retrying would only hide that.
558
+
559
+ **API key** (any transport, no browser): send it as the bearer token, or set
560
+ `NOOTICR_API_KEY`. nooticr-server resolves the key to its owner and the
561
+ workspace it was scoped to, and every authorization check downstream sees
562
+ exactly what it would have seen for a signed-in session. See
563
+ [Running on a server](#running-on-a-server-no-browser).
478
564
 
479
565
  **HTTP mode** (OpenAI Agents SDK, remote clients): the server runs its own
480
566
  OAuth 2.0 authorization server (Authorization Code + PKCE S256, public client,
@@ -488,7 +574,9 @@ per the MCP 2025-03-26 spec):
488
574
  one-time code
489
575
  - `POST /token` — verifies PKCE and issues an opaque Bearer token bound to the
490
576
  nooticr session (valid 1 hour)
491
- - every MCP RPC validates the Bearer token against the session map
577
+ - every MCP RPC validates the Bearer token against the session map — or, when
578
+ the bearer is an API key, forwards it to nooticr-server as the caller's own
579
+ credential, which is what makes this endpoint usable with no browser at all
492
580
 
493
581
  ## Supported URLs
494
582
 
@@ -508,8 +596,14 @@ and text** posts.
508
596
 
509
597
  ## Troubleshooting
510
598
 
511
- - **`Not authenticated with nooticr` / 401**: run `npx @nooticr/mcp login` or set
512
- `NOOTICR_ACCESS_TOKEN`.
599
+ - **`Not authenticated with nooticr` / 401**: run `npx @nooticr/mcp login`, or
600
+ set `NOOTICR_API_KEY` (see [Running on a server](#running-on-a-server-no-browser)).
601
+ - **`unknown, revoked or expired nooticr API key`**: the key is not one this
602
+ account has live. `npx @nooticr/mcp api-key list` shows every key and its
603
+ state, revoked ones included.
604
+ - **`API keys cannot manage API keys`**: `api-key create`/`list`/`revoke` need a
605
+ signed-in session, which is why they ignore a key in `NOOTICR_API_KEY` *and*
606
+ one stored by `login --api-key`. Run `npx @nooticr/mcp login` first.
513
607
  - **402 paywall / `insufficient MCP credits`**: your nooticr account is out of
514
608
  credits. Prices are listed per tool in [Tools](#tools) — 1 credit for a post
515
609
  lookup or transcript, 2 for discovery and for a tool that makes one fetch, 3
@@ -541,7 +635,12 @@ package for **Claude Desktop**, **Cursor**, and **OpenAI Agents SDK**.
541
635
  (in-memory).
542
636
  - Access tokens are opaque, random, and bound to the in-memory session map;
543
637
  they expire after 1 hour. Restarting the server invalidates all sessions.
544
- - Never share your credentials file or `NOOTICR_ACCESS_TOKEN`.
638
+ - API keys are stored as a sha256 hash, never in the clear, so a database dump
639
+ cannot be replayed against the API. They are scoped to one account and one
640
+ workspace, carry their owner's role as it is *now* rather than as it was when
641
+ the key was minted, and cannot be used to create or revoke keys.
642
+ - Never share your credentials file, `NOOTICR_ACCESS_TOKEN` or
643
+ `NOOTICR_API_KEY`.
545
644
 
546
645
  ## Development
547
646
 
package/dist/auth.js CHANGED
@@ -1,11 +1,23 @@
1
1
  /**
2
2
  * Token storage + resolution.
3
3
  *
4
- * Priority: env NOOTICR_ACCESS_TOKEN > credentials file (auto-refresh when
5
- * expired) > per-session tokens (HTTP OAuth mode only).
4
+ * Priority: env NOOTICR_ACCESS_TOKEN > env NOOTICR_API_KEY > credentials file
5
+ * (an API key stored by `login --api-key`, else a session token, refreshed
6
+ * when expired) > per-session tokens (HTTP OAuth mode only).
7
+ *
8
+ * `NOOTICR_ACCESS_TOKEN` stays first because it has always been the explicit
9
+ * "use exactly this token" override, and someone who exports one for a single
10
+ * debugging run should get it. An API key sits directly behind it: it is the
11
+ * credential a server holds permanently, so it outranks anything in a file.
12
+ *
13
+ * The file holds *one* credential, whichever `login` last wrote. A store
14
+ * carrying both a key and a session would make "which am I signed in as"
15
+ * unanswerable — and, worse, let a rejected key quietly fall back to a stale
16
+ * session belonging to someone else.
6
17
  */
7
18
  import fs from "node:fs";
8
19
  import path from "node:path";
20
+ import { getApiKey } from "./config.js";
9
21
  import { NooticrClient, NooticrError } from "./nooticr.js";
10
22
  const REFRESH_BEFORE_EXPIRY_MS = 30_000;
11
23
  export class NooticrAuthError extends Error {
@@ -40,7 +52,7 @@ export class AuthManager {
40
52
  try {
41
53
  const raw = await fs.promises.readFile(this.credentialsFile, "utf8");
42
54
  const parsed = JSON.parse(raw);
43
- if (parsed && typeof parsed.accessToken === "string") {
55
+ if (parsed && (typeof parsed.accessToken === "string" || typeof parsed.apiKey === "string")) {
44
56
  return parsed;
45
57
  }
46
58
  return null;
@@ -63,15 +75,34 @@ export class AuthManager {
63
75
  /**
64
76
  * Resolves the current access token for a request. `session` provides the
65
77
  * per-session tokens issued via our own OAuth /token endpoint (HTTP mode).
78
+ *
79
+ * `allowApiKey` exists for the one caller that must not use one: the
80
+ * `api-key` CLI commands. The backend refuses key management to a
81
+ * key-authenticated caller, so resolving a key there would turn "create me
82
+ * a second key" into a 403 that reads like a permissions bug rather than
83
+ * the deliberate bound it is. It skips both places a key can live — the
84
+ * environment and the credentials file — since the bound is about the
85
+ * credential, not about where it was found.
66
86
  */
67
- async getAccessToken(session) {
87
+ async getAccessToken(session, { allowApiKey = true } = {}) {
68
88
  const envToken = process.env.NOOTICR_ACCESS_TOKEN;
69
89
  if (envToken) {
70
90
  this.lastSource = "env";
71
91
  this.lastRefreshToken = undefined;
72
92
  return envToken;
73
93
  }
94
+ const apiKey = allowApiKey ? getApiKey() : undefined;
95
+ if (apiKey) {
96
+ this.lastSource = "apiKey";
97
+ this.lastRefreshToken = undefined;
98
+ return apiKey;
99
+ }
74
100
  const store = await this.loadStore();
101
+ if (allowApiKey && store?.apiKey) {
102
+ this.lastSource = "apiKey";
103
+ this.lastRefreshToken = undefined;
104
+ return store.apiKey;
105
+ }
75
106
  if (store?.accessToken && !isTokenExpired(store)) {
76
107
  this.lastSource = "file";
77
108
  this.lastRefreshToken = store.refreshToken;
@@ -101,6 +132,10 @@ export class AuthManager {
101
132
  * the current token. Returns true when a new token is available.
102
133
  */
103
134
  async onUnauthorized(session) {
135
+ // A key has nothing to redeem: a 401 on one means revoked, expired or
136
+ // mistyped, and retrying with the same string forever would hide that.
137
+ if (this.lastSource === "apiKey")
138
+ return false;
104
139
  let refreshToken = this.lastRefreshToken;
105
140
  if (this.lastSource === "session" && session?.refreshToken) {
106
141
  refreshToken = session.refreshToken;
@@ -131,7 +166,22 @@ export class AuthManager {
131
166
  await this.saveStore(store);
132
167
  this.lastSource = "file";
133
168
  this.lastRefreshToken = store.refreshToken;
134
- return store.accessToken;
169
+ // `session.accessToken` rather than `store.accessToken`: the guard above
170
+ // has already narrowed it, and the store's field is optional now that a
171
+ // credentials file may hold an API key instead of a session.
172
+ return session.accessToken;
173
+ }
174
+ /**
175
+ * Persists an API key as this machine's credential, replacing whatever
176
+ * `login` last wrote. For clients that do not inherit a shell environment
177
+ * (Claude Desktop, Cursor) and for a laptop where editing JSON to set an
178
+ * env var is the annoying part; a server should still be given
179
+ * `NOOTICR_API_KEY` and left with nothing on disk.
180
+ */
181
+ async persistApiKey(apiKey, user) {
182
+ await this.saveStore({ apiKey, user });
183
+ this.lastSource = "apiKey";
184
+ this.lastRefreshToken = undefined;
135
185
  }
136
186
  /**
137
187
  * Persists a session obtained from login/OAuth completion.
@@ -150,7 +200,8 @@ export class AuthManager {
150
200
  }
151
201
  ensureUnauthenticatedError() {
152
202
  throw new NooticrAuthError("Not authenticated with nooticr. Run `npx nooticr-mcp login` to sign in " +
153
- "with Google, or set the NOOTICR_ACCESS_TOKEN environment variable " +
203
+ "with Google, or — on a server with no browser — set NOOTICR_API_KEY " +
204
+ "to a key from `npx nooticr-mcp api-key create` " +
154
205
  "(see `npx nooticr-mcp --help`).");
155
206
  }
156
207
  }
@@ -161,7 +212,27 @@ export function createStdioTokenProvider(auth) {
161
212
  onUnauthorized: async () => auth.onUnauthorized(),
162
213
  };
163
214
  }
164
- /** Builds a TokenProvider for HTTP mode: env > file > per-session tokens. */
215
+ /**
216
+ * Builds a TokenProvider for the `api-key` CLI commands: env access token or
217
+ * the credentials file, never `NOOTICR_API_KEY`. See `getAccessToken`.
218
+ */
219
+ export function createManagementTokenProvider(auth) {
220
+ return {
221
+ getAccessToken: async () => auth.getAccessToken(undefined, { allowApiKey: false }),
222
+ onUnauthorized: async () => auth.onUnauthorized(),
223
+ };
224
+ }
225
+ /**
226
+ * Builds a TokenProvider for one caller's own API key — the headless path
227
+ * through HTTP mode, where the bearer the client presented *is* the nooticr
228
+ * credential. Deliberately not `createHttpTokenProvider`: that one falls back
229
+ * to the operator's environment and credentials file, which would answer one
230
+ * tenant's call with another's account.
231
+ */
232
+ export function createApiKeyTokenProvider(apiKey) {
233
+ return { getAccessToken: async () => apiKey };
234
+ }
235
+ /** Builds a TokenProvider for HTTP mode: env > api key > file > per-session tokens. */
165
236
  export function createHttpTokenProvider(auth, session) {
166
237
  return {
167
238
  getAccessToken: async () => auth.getAccessToken(session),
package/dist/config.js CHANGED
@@ -40,6 +40,17 @@ export function getPort() {
40
40
  }
41
41
  return port;
42
42
  }
43
+ /**
44
+ * A nooticr API key from the environment, if one is set.
45
+ *
46
+ * This is the headless path: no browser, no credentials file, nothing to
47
+ * refresh. A server-side integration sets `NOOTICR_API_KEY` once and the key
48
+ * stays valid until someone revokes it.
49
+ */
50
+ export function getApiKey() {
51
+ const raw = process.env.NOOTICR_API_KEY?.trim();
52
+ return raw ? raw : undefined;
53
+ }
43
54
  export function getCredentialsFile() {
44
55
  if (process.env.NOOTICR_CREDENTIALS_FILE) {
45
56
  return process.env.NOOTICR_CREDENTIALS_FILE;
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")) {
@@ -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
+ }
@@ -98,11 +98,16 @@ export class NooticrClient {
98
98
  body = undefined;
99
99
  }
100
100
  const json = (body ?? {});
101
+ // Name the endpoint: a bare status code says nothing about which call
102
+ // failed, and these surface to the user as tool errors. Then add the
103
+ // body when it is plainly a sentence — dropping it leaves a headless
104
+ // caller with a number and no cause.
105
+ const detail = plainTextDetail(text);
101
106
  const errorMessage = typeof json.error === "string"
102
107
  ? json.error
103
- : // Name the endpoint: a bare status code says nothing about which
104
- // call failed, and these surface to the user as tool errors.
105
- `nooticr API error (${res.status}) from ${path}`;
108
+ : detail
109
+ ? `nooticr API error (${res.status}) from ${path}: ${detail}`
110
+ : `nooticr API error (${res.status}) from ${path}`;
106
111
  if (res.status >= 200 && res.status < 300) {
107
112
  return body;
108
113
  }
@@ -172,6 +177,28 @@ export class NooticrClient {
172
177
  auth: true,
173
178
  });
174
179
  }
180
+ /**
181
+ * Mints a key for the signed-in user. Deliberately not callable *with* a
182
+ * key: the backend refuses key-management to key-authenticated callers so a
183
+ * leaked key cannot mint a sibling that survives its own revocation.
184
+ */
185
+ async createApiKey(input = {}) {
186
+ return this.request("POST", "/auth/api-keys", {
187
+ auth: true,
188
+ body: input,
189
+ });
190
+ }
191
+ async listApiKeys() {
192
+ const res = await this.request("GET", "/auth/api-keys", {
193
+ auth: true,
194
+ });
195
+ return res?.keys ?? [];
196
+ }
197
+ async revokeApiKey(id) {
198
+ await this.request("DELETE", `/auth/api-keys/${encodeURIComponent(id)}`, {
199
+ auth: true,
200
+ });
201
+ }
175
202
  async me() {
176
203
  return this.request("GET", "/auth/me", { auth: true });
177
204
  }
@@ -238,6 +265,21 @@ export class NooticrClient {
238
265
  throw new NooticrError(res.status, typeof json.error === "string" ? json.error : `Refresh failed (${res.status})`, { body });
239
266
  }
240
267
  }
268
+ /**
269
+ * The backend answers a good many routes with a bare string rather than JSON
270
+ * (axum's `(StatusCode, String)`), and "nooticr API error (403)" tells whoever
271
+ * reads it nothing — least of all a headless integration, where the message is
272
+ * the only thing anyone will see. Use the body when it is plainly a sentence:
273
+ * short, one line, and not the start of a document.
274
+ */
275
+ function plainTextDetail(text) {
276
+ const trimmed = text.trim();
277
+ if (!trimmed || trimmed.length > 300 || trimmed.includes("\n"))
278
+ return undefined;
279
+ if (/^[<{[]/.test(trimmed))
280
+ return undefined;
281
+ return trimmed;
282
+ }
241
283
  async function parseJsonBody(res) {
242
284
  const text = await res.text();
243
285
  try {
@@ -435,8 +435,8 @@ export const TOOL_DEFINITIONS = [
435
435
  {
436
436
  name: "scan_marketplace_category",
437
437
  title: "Scan Marketplace Category",
438
- description: "Collect a category from any of eleven marketplaces and read it: products with prices, star histograms, the review text itself, and the arithmetic over them (price spread, rating spread, which aspects recur across how many brands, and the share of each product's ratings sitting at 1-2 stars). marketplace picks the site — one of amazon, aliexpress, cdiscount, flipkart, mercadolibre, lazada, otto, rakuten, trendyol, jumia, temu. Pass query to search the category, items to pin named competitors by id or URL, or both. You write the read: purchase drivers, barriers, what the incumbents do well, the gaps, and how a new entrant could position. Collection runs in the background, so this returns everything ready within waitSeconds plus a scanId to continue with marketplace_scan_status — that is free, and repeating it is how a scan gets more time. For Amazon specifically, scan_amazon_category returns the same shape plus Amazon's own review-aspect counts. Consumes 3 nooticr credits per product collected — a product with reviews is a real browser render behind the site's bot defences.",
439
- inputSchema: z.object({ marketplace: z.string().describe("Which site to read. One of: amazon, aliexpress, cdiscount, flipkart, mercadolibre, lazada, otto, rakuten, trendyol, jumia, temu."), query: z.string().optional().describe("Category or keyword to search, e.g. 'cast iron skillet'."), items: z.array(z.string()).optional().describe("Named competitors, by product id or URL."), limit: z.number().int().optional().describe("Products to collect (default 10, max 50)."), reviews: z.boolean().optional().describe("Collect review text too (default true)."), market: z.string().optional().describe("The storefront to read, for the sites that have more than one. Amazon: a country code or host — com (US), co.uk, de, fr and the rest, default www.amazon.com · Mercado Libre: one of eighteen markets, named however it comes to hand — MLB, br, Brazil · Lazada: sg, my, th, vn, ph or id · Otto: de or at · Rakuten: jp (Ichiba) or tw · Trendyol: one of 47 storefronts — tr, de, ae, sa and the rest · Jumia: ng, eg, ke, ma, ci, gh, sn, tn, ug or dz · AliExpress, Cdiscount, Flipkart and Temu have one storefront each and take no market argument. An id is scoped to one storefront: the same id on another is a different product or nothing at all."), domain: z.string().optional().describe("Amazon only: the marketplace host. Other sites use market."), waitSeconds: z.number().int().optional().describe("How long to wait before returning partial results (default 45, max 55). Poll marketplace_scan_status for the rest instead; it is free and repeatable."), focus: z.string().optional().describe("An extra question to answer from the reviews.") }).strict(),
438
+ description: "Collect a category from any of twelve marketplaces and read it: products with prices, star histograms, the review text itself, and the arithmetic over them (price spread, rating spread, which aspects recur across how many brands, and the share of each product's ratings sitting at 1-2 stars). marketplace picks the site — one of amazon, aliexpress, cdiscount, flipkart, mercadolibre, lazada, otto, rakuten, trendyol, jumia, vinted, temu. Pass query to search the category, items to pin named competitors by id or URL, or both. You write the read: purchase drivers, barriers, what the incumbents do well, the gaps, and how a new entrant could position. Collection runs in the background, so this returns everything ready within waitSeconds plus a scanId to continue with marketplace_scan_status — that is free, and repeating it is how a scan gets more time. For Amazon specifically, scan_amazon_category returns the same shape plus Amazon's own review-aspect counts. Consumes 3 nooticr credits per product collected — a product with reviews is a real browser render behind the site's bot defences.",
439
+ inputSchema: z.object({ marketplace: z.string().describe("Which site to read. One of: amazon, aliexpress, cdiscount, flipkart, mercadolibre, lazada, otto, rakuten, trendyol, jumia, vinted, temu."), query: z.string().optional().describe("Category or keyword to search, e.g. 'cast iron skillet'."), items: z.array(z.string()).optional().describe("Named competitors, by product id or URL."), limit: z.number().int().optional().describe("Products to collect (default 10, max 50)."), reviews: z.boolean().optional().describe("Collect review text too (default true)."), market: z.string().optional().describe("The storefront to read, for the sites that have more than one. Amazon: a country code or host — com (US), co.uk, de, fr and the rest, default www.amazon.com · Mercado Libre: one of eighteen markets, named however it comes to hand — MLB, br, Brazil · Lazada: sg, my, th, vn, ph or id · Otto: de or at · Rakuten: jp (Ichiba) or tw · Trendyol: one of 47 storefronts — tr, de, ae, sa and the rest · Jumia: ng, eg, ke, ma, ci, gh, sn, tn, ug or dz · AliExpress, Cdiscount, Flipkart and Temu have one storefront each and take no market argument. An id is scoped to one storefront: the same id on another is a different product or nothing at all."), domain: z.string().optional().describe("Amazon only: the marketplace host. Other sites use market."), waitSeconds: z.number().int().optional().describe("How long to wait before returning partial results (default 45, max 55). Poll marketplace_scan_status for the rest instead; it is free and repeatable."), focus: z.string().optional().describe("An extra question to answer from the reviews.") }).strict(),
440
440
  },
441
441
  {
442
442
  name: "marketplace_scan_status",
@@ -447,8 +447,8 @@ export const TOOL_DEFINITIONS = [
447
447
  {
448
448
  name: "get_marketplace_product",
449
449
  title: "Get Marketplace Product",
450
- description: "Fetch one product from any of eleven marketplaces by id or URL: price, rating, the full star histogram, specs, and the review text. marketplace picks the site — one of amazon, aliexpress, cdiscount, flipkart, mercadolibre, lazada, otto, rakuten, trendyol, jumia, temu. Use for a single product; for a category or a competitive set, scan_marketplace_category collects many in one job and rolls them up. Consumes 3 nooticr credits.",
451
- inputSchema: z.object({ marketplace: z.string().describe("Which site the product is on. One of: amazon, aliexpress, cdiscount, flipkart, mercadolibre, lazada, otto, rakuten, trendyol, jumia, temu."), id: z.string().optional().describe("The product id, in whatever form the site addresses products by."), url: z.string().optional().describe("The full product URL, if you have that instead."), reviews: z.boolean().optional().describe("Collect review text too (default true)."), market: z.string().optional().describe("The storefront the product is on, for sites that have more than one. Amazon: a country code or host — com (US), co.uk, de, fr and the rest, default www.amazon.com · Mercado Libre: one of eighteen markets, named however it comes to hand — MLB, br, Brazil · Lazada: sg, my, th, vn, ph or id · Otto: de or at · Rakuten: jp (Ichiba) or tw · Trendyol: one of 47 storefronts — tr, de, ae, sa and the rest · Jumia: ng, eg, ke, ma, ci, gh, sn, tn, ug or dz · AliExpress, Cdiscount, Flipkart and Temu have one storefront each and take no market argument. An id is scoped to one storefront: the same id on another is a different product or nothing at all."), domain: z.string().optional().describe("Amazon only: the marketplace host. Other sites use market.") }).strict(),
450
+ description: "Fetch one product from any of twelve marketplaces by id or URL: price, rating, the full star histogram, specs, and the review text. marketplace picks the site — one of amazon, aliexpress, cdiscount, flipkart, mercadolibre, lazada, otto, rakuten, trendyol, jumia, vinted, temu. Use for a single product; for a category or a competitive set, scan_marketplace_category collects many in one job and rolls them up. Consumes 3 nooticr credits.",
451
+ inputSchema: z.object({ marketplace: z.string().describe("Which site the product is on. One of: amazon, aliexpress, cdiscount, flipkart, mercadolibre, lazada, otto, rakuten, trendyol, jumia, vinted, temu."), id: z.string().optional().describe("The product id, in whatever form the site addresses products by."), url: z.string().optional().describe("The full product URL, if you have that instead."), reviews: z.boolean().optional().describe("Collect review text too (default true)."), market: z.string().optional().describe("The storefront the product is on, for sites that have more than one. Amazon: a country code or host — com (US), co.uk, de, fr and the rest, default www.amazon.com · Mercado Libre: one of eighteen markets, named however it comes to hand — MLB, br, Brazil · Lazada: sg, my, th, vn, ph or id · Otto: de or at · Rakuten: jp (Ichiba) or tw · Trendyol: one of 47 storefronts — tr, de, ae, sa and the rest · Jumia: ng, eg, ke, ma, ci, gh, sn, tn, ug or dz · AliExpress, Cdiscount, Flipkart and Temu have one storefront each and take no market argument. An id is scoped to one storefront: the same id on another is a different product or nothing at all."), domain: z.string().optional().describe("Amazon only: the marketplace host. Other sites use market.") }).strict(),
452
452
  },
453
453
  {
454
454
  name: "show_amazon_category_insights",
@@ -28,7 +28,7 @@ import { registerCollabTools } from "./collab.js";
28
28
  import { registerAmazonTools } from "./amazon.js";
29
29
  import { registerMarketplaceTools } from "./marketplace.js";
30
30
  /** Current MCP server version — bumped on every deploy for traceability. */
31
- export const MCP_SERVER_VERSION = "1.26.40";
31
+ export const MCP_SERVER_VERSION = "1.26.41";
32
32
  /** MCP Apps extension identifier */
33
33
  const UI_EXTENSION = "io.modelcontextprotocol/ui";
34
34
  /** MIME type for MCP Apps HTML resources */
@@ -346,6 +346,24 @@ function toolError(prefix, err) {
346
346
  // and what fixes it — including that they will not have to ask twice, which
347
347
  // is only true now that nooticr_login resumes the interrupted call.
348
348
  if (isAuthFailure(err)) {
349
+ // A deployment running on an API key has nobody to open a sign-in link:
350
+ // its 401 means the key was revoked, expired or mistyped, and "sign in
351
+ // again" sends whoever reads that log looking for a browser that does not
352
+ // exist. The backend names this case in its own message and the client
353
+ // carries plain-text errors through, so the two can be told apart here.
354
+ if (/api key/i.test(msg)) {
355
+ return {
356
+ content: [{
357
+ type: "text",
358
+ text: `${prefix}: this nooticr API key is not valid — revoked, expired, or mistyped. ` +
359
+ `No sign-in link will help: nothing here can be re-authorised interactively. ` +
360
+ `Replace the key (NOOTICR_API_KEY, or the bearer token sent to the remote ` +
361
+ `endpoint) with a live one — \`npx @nooticr/mcp api-key list\` shows which are ` +
362
+ `still active. (${msg})`,
363
+ }],
364
+ isError: true,
365
+ };
366
+ }
349
367
  return {
350
368
  content: [{
351
369
  type: "text",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nooticr/mcp",
3
3
  "mcpName": "com.nooticr/mcp",
4
- "version": "1.26.40",
4
+ "version": "1.26.41",
5
5
  "description": "MCP server for nooticr - fetch, discover and understand TikTok/Instagram/YouTube/X/Reddit/LinkedIn/Weibo posts with AI (media metadata, niche discovery, brand monitoring, hook & viral analysis)",
6
6
  "homepage": "https://mcp.nooticr.com",
7
7
  "repository": {