@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 CHANGED
@@ -67,7 +67,7 @@ them caps that fan-out with an argument.
67
67
  | Tool | Credits | What it is for |
68
68
  |------|---------|----------------|
69
69
  | `get_social_media` | 1 | The post's facts and media — contentType, title, caption, author, stats, direct media URLs, plus an inline thumbnail. Use when you want the post itself and nothing interpreted. |
70
- | `get_post_transcript` | 1 | The words actually spoken. Where the platform publishes a caption track (TikTok, Douyin, YouTube) it is read as-is — exact rather than inferred, and far cheaper than watching the video; everywhere else the post's own audio is transcribed, which needs speech-to-text configured on the server. Use before any analysis when the wording matters. |
70
+ | `get_post_transcript` | 1 | The words actually spoken. Where the platform publishes a caption track (TikTok, Douyin, YouTube) it is read as-is — exact rather than inferred, and far cheaper than watching the video; everywhere else the post's own audio is transcribed, which needs speech-to-text configured on the server. `format: "srt"` or `"vtt"` also returns a ready caption file built from the track's own timing, at no extra cost. Use before any analysis when the wording matters. |
71
71
  | `get_post_frames` | 2 | Frames sampled evenly across a post's video, returned as **images you can actually look at** — not a description of them. ffmpeg opens the stream directly rather than downloading it, so HLS works and an expired link is re-resolved on the spot. Verified live at 3/3 on TikTok, YouTube, Instagram, Douyin and X; Reddit works on video posts. A carousel or slideshow returns its own images unchanged. Each frame costs roughly 1,200 tokens of your context. |
72
72
  | `get_post_comments` | 2 | Top comments plus the themes the platform clusters them into, with which ones the creator pinned or liked. Use when you want to read what people wrote. |
73
73
 
@@ -78,7 +78,7 @@ them caps that fan-out with an argument.
78
78
  | `analyze_post_fast` | 2 | The post's transcript, caption and stats — everything but the pictures, which is what makes it the cheap read. Two fetches: `get_social_media` (1) and `get_post_transcript` (1). The sensible default. |
79
79
  | `analyze_post` | 3 | Frames sampled across the video, as **images your model can actually look at**, plus the transcript. Two fetches: `get_post_frames` (2) and `get_post_transcript` (1). Use when the visuals are the point — framing, editing, on-screen text. |
80
80
  | `understand_social_post` | 3 | The same two fetches, asked for a description of what physically happens on screen rather than why it works. Use when you need the events, not the strategy. |
81
- | `analyze_comments` | 2 | The comment section, every comment with a stable id, and the taxonomy to label them with — sentiment, and whether each is praise, a complaint, a bug report, a question, a request, a comparison or spam. The same `get_post_comments` call, at the same price as reading them directly. |
81
+ | `analyze_comments` | 2 | The comment section, every comment with a stable id, and the taxonomy to label them with — sentiment, and whether each is praise, a complaint, a bug report, a question, a request, a comparison or spam. The same `get_post_comments` call, at the same price as reading them directly. Pass `verbatim: true` to have every item rendered whole for quoting rather than clipped to fit; same price. |
82
82
  | `show_comment_review` | free | Draws the classifications your model produced — every comment with its sentiment and category, filterable and selectable. Makes no requests; it only renders what you pass it. |
83
83
  | `show_post_analysis` | free | Draws the analysis your model wrote from `analyze_post`/`analyze_post_fast`/`understand_social_post` — hook strength, script structure, quotable lines, suggested hashtags and more, whichever fields you produced. Makes no requests; it only renders what you pass it. |
84
84
  | `compare_posts` | 1 | The first of two to five posts, fetched with its stats, and the comparison left to you. Fetch the rest with `get_social_media` at 1 credit each. Use when performance differs and you need to know why. |
@@ -92,12 +92,14 @@ them caps that fan-out with an argument.
92
92
  | `get_user_posts` | 2 | One creator's recent posts with stats. Use to scan an account. |
93
93
  | `search_creators` | 2 | People by what they make — designers, developers, photographers, anyone with an audience — found by craft, niche or keyword. Use when you know the kind of person but not their names. Searches TikTok, Instagram and Xiaohongshu; **not** YouTube, Douyin, X, Reddit or LinkedIn, which cannot be creator-searched upstream. |
94
94
  | `get_similar_creators` | 2 | Lookalikes for a creator that already works. |
95
+ | `suggest_creator_identity` | 2 per network searched, plus 2 to find the handle (6 by default) | The same person on the other networks, from one handle you already know — with the evidence for each: a link published in both bios, one bio naming the other account, an identical handle, a matching display name. **Suggests, never merges**: nothing is stored, a human confirms, and two accounts' follower counts are never added together. TikTok, Instagram and Xiaohongshu. |
95
96
  | `discover_sounds` | 2 | Trending audio with playable previews. Sound is a major ranking signal on TikTok. |
96
97
  | `discover_hashtags` | 2 | What to tag with. TikTok comes from the Creative Center trend board — volumes plus rising/cooling/steady. The other eight searchable networks have no trend board upstream, so tags are counted across a live sweep of a niche you name: real and current, but a single sample, so no rising/cooling signal. `source` says which you got. LinkedIn cannot be swept. |
97
98
  | `find_hook_pattern` | 2 | A creator's recent posts, so their opening lines can be read as a set and turned into fill-in-the-blank templates. One `get_user_posts` call. |
98
99
  | `search_mentions` | 2 per network (5 for Xiaohongshu) | **Brand monitoring.** Every *comment* that names a term, across nine networks at once, grouped under the post it was left on. A brand is named far more often in the replies than in a caption, so the comment is the unit — not the post. Takes a `since` date to read a past window, and pages with `offset`/`pageSize` so a nine-network sweep does not arrive all at once. Does not read speech inside a video — `search_spoken_mentions` does, on TikTok, YouTube and Douyin. |
99
100
  | `watch_creator` | free | Add a creator to your watchlist. Stores the handle only — nothing is fetched. |
100
101
  | `unwatch_creator` | free | Drop a creator from the watchlist. |
102
+ | `list_watchlist` | free | Who you are watching: handle, platform, note, and when you last caught up on each. |
101
103
  | `catch_up_watchlist` | 2 per creator | What everyone you watch has posted since your last catch-up. Compares against the snapshot taken last time and moves it forward, so it answers "what is new" rather than "what exists". |
102
104
  | `create_brand_watch` | free to call | Schedule a recurring sweep and get emailed only what is new. `kind: "mentions"` (default) is a recurring `search_mentions` sweep for `term`/`platforms`; `kind: "competitor"` is a recurring `get_user_posts` check on one creator (`handle`/`platform`), mailing only what beats their own recent median. Two calls by design: the first returns the quote (cost per run, cadence, cost per day) and a `confirmationToken`; nothing is created or charged until you call it again with `confirm: true` and that token. Each run then bills the same as calling the matching tool yourself — 2 credits per network (5 for Xiaohongshu) for mentions, a flat 2 credits for competitor. |
103
105
  | `list_brand_watches` | free | Every watch you have scheduled: term, networks, cadence, cost per run, credits spent so far, runs made, and when the next one is due. |
@@ -110,7 +112,7 @@ them caps that fan-out with an argument.
110
112
 
111
113
  | Tool | Credits | What it is for |
112
114
  |------|---------|----------------|
113
- | `answer_my_audience` | 2 + 2 per post opened (14 by default) | **The mirror of `search_mentions`.** The questions waiting under your *own* posts: recent posts fetched, comments read on each, grouped under the post they were left on, every comment with a stable id, and the ones that read like questions or requests flagged and sorted to the top. It finds and drafts — it cannot post a reply, because no nooticr connection carries comment-write permission on any network. `limit` caps how many posts are opened, which is the price. |
115
+ | `answer_my_audience` | 2 + 2 per post opened (14 by default) | **The mirror of `search_mentions`.** The questions waiting under your *own* posts: recent posts fetched, comments read on each, grouped under the post they were left on, every comment with a stable id, and the ones that read like questions or requests flagged and sorted to the top. It finds and drafts — it cannot post a reply: nooticr does not post comments on any network, even where a connection's grant allows managing them. `limit` caps how many posts are opened, which is the price. |
114
116
  | `prepare_handoff` | free | **The hand-off to your tracker.** Turns items you classified — a bug report in a comment, a complaint said out loud in a video — into the exact `title`, `body` and `labels` to file through whichever GitHub, Jira or Linear MCP server the host also has connected. nooticr files nothing and holds no tracker credential. The body carries the quote fenced and framed as a third-party report rather than as instructions, because the issue is read next by a coding agent; contact details are redacted, `@handles` and `#numbers` are defanged so filing notifies nobody by accident, and a `searchFirst` string dedupes a second sweep against the first. Makes no requests. |
115
117
  | `show_audience_replies` | free | Lays your drafts out for a person to work through, grouped under the post, each with what you decided to do about it. Sends nothing; fetches nothing. |
116
118
  | `show_standings` | free | Draws the standings your model read out of `compare_creators` or `watchlist_standings` — one row per creator, with the window and the median it was scored against. `ranking` records which axis you ordered on, and `tooThin` draws a creator with too short a window as unranked rather than last: missing from a comparison is not the same as losing it. Makes no requests. |
@@ -119,10 +121,11 @@ them caps that fan-out with an argument.
119
121
  | `compare_creators` | 2 per creator (4-10) | Two to five creators side by side on the one axis that compares: each post's ratio to **that creator's own** median. Per creator you get the window that was scored, their median, the share of it that beat that median, how hard they beat it when they did, every ratio, and their best and worst post. It does not rank them — "how often they land one" and "how big it is when they do" usually disagree, and a hit rate over a short window is one post either way, so the window travels with every number and the read is yours. |
120
122
  | `watchlist_standings` | 2 per watched creator | The same, across everyone on your watchlist: "who moved" in one call instead of one per creator. Reading the watchlist is free; the fetches are not, so it confirms the total before spending. `catch_up_watchlist` answers what is **new**; this answers how it **did**. |
121
123
  | `who_should_i_work_with` | 2, or 4 with a seed | A collaboration shortlist: a keyword search merged with the lookalikes of a creator who already fits, marked by which search found each one. Every candidate also carries the **links pulled out of their bio**, typed and sorted by how much opening one will tell you — a repository to read the code in, their own site, a link hub that holds the real links — so vetting is reading the work rather than re-reading the follower count. Those links are never fetched here: they came out of a field the person being evaluated controls, so the host opens them, and the result says so. It does **not** measure audience overlap — that costs about nine credits a candidate, so the result says so and shows how to check a finalist rather than faking the signal. |
122
- | `find_people_with_problem` | 6 per platform | Find people **describing a problem in their own words** — prospects, early users, anyone whose complaint your product answers. Say the problem the way a person would say it and it searches for posts shaped like that complaint rather than for the topic it is about: searching the topic finds marketing about it, which is what `discover_social_posts` is for. Widens the query into the forms a complaint takes — the plain phrasing, the does-anyone-else question, the is-there-a-tool ask — and marks which one surfaced each post. A wide cheap net and **not** a filter: the posts come back for you to judge, and plenty will be off-target. Defaults to reddit, where people describe workflow pain in sentences. Each platform is searched once per query shape — three by default, at 2 credits a search. Not searchable here: linkedin, which has no keyword post search upstream. |
124
+ | `find_people_with_problem` | 6 per platform | Find people **describing a problem in their own words** — prospects, early users, anyone whose complaint your product answers. Say the problem the way a person would say it and it searches for posts shaped like that complaint rather than for the topic it is about: searching the topic finds marketing about it, which is what `discover_social_posts` is for. Widens the query into the forms a complaint takes — the plain phrasing, the does-anyone-else question, the is-there-a-tool ask — and marks which one surfaced each post. A wide cheap net and **not** a filter: the posts come back for you to judge, and plenty will be off-target. Defaults to reddit, where people describe workflow pain in sentences. Each platform is searched once per query shape — three by default, at 2 credits a search. Not searchable here: linkedin, which has no keyword post search upstream. Pass `verbatim: true` to have every item rendered whole for quoting rather than clipped to fit; same price. |
123
125
  | `show_collab_shortlist` | free | Draws the candidates you scored, ranked, and asks the user which to approach. The scores are attributed to your model, not presented as a nooticr rating, and a candidate scored without anything having been opened is marked unverified. Makes no requests. |
124
126
  | `why_did_this_underperform` | 3 | One post against the creator's own recent distribution, with the post taken back out of its own baseline. Returns median, quartiles, ratio and percentile, so the answer can be "this is an ordinary result, not a failure". Different question from `compare_posts`, which weighs two URLs you already picked. |
125
127
  | `what_should_i_make_next` | 2 + 2 per post read + 2 (12 by default) | Demand against supply: what your commenters explicitly ask for, set beside what a niche sweep shows is already being made. A gap nobody asked for is noise; a request nobody serves is the opportunity. Falls back to your most-used hashtag when you name no niche. |
128
+ | `detect_spoken_mentions` | 2 | **Every brand named out loud in one video.** The spoken transcript and the written caption, side by side, for your model to list each brand with its verbatim sentence, the creator's framing and whether it was said, written or both. Works where the creator never enabled captions, because the audio is transcribed. 1 credit for `get_post_transcript` plus 1 for `get_social_media`; nothing when no transcript can be made. |
126
129
  | `search_spoken_mentions` | 2 per platform narrowed by niche + 2 per creator handle + 1 per transcript, up to `maxTranscripts` | **The mirror of `search_mentions`, for what was said rather than typed.** Narrows to candidate posts (a niche sweep, named handles, and/or your watchlist), transcribes only the most-viewed survivors up to a hard ceiling, and searches the words for the term — TikTok, YouTube and Douyin, and only where the creator actually enabled captions. Reports how many candidates were found, transcribed and matched, so the spend is legible. |
127
130
 
128
131
  ### Study a market on Amazon
@@ -148,7 +151,7 @@ a `scanId` for the rest.
148
151
  | `scan_marketplace_category` | 3 per product collected (30 by default) | The same category read on eleven more sites — AliExpress, Cdiscount, Flipkart, Mercado Libre, Lazada, Otto, Rakuten, Trendyol, Jumia, Vinted and Temu. Vinted is resale rather than retail, so its `rating` describes the seller and the scan says so in `ratingScope` — do not read it as a product's reviews. `marketplace` picks the site; `market` picks the storefront for the sites that have several, and the argument names the real set per site so a model cannot send Otto a Lazada region. Same rollup, same confirmation before spending. |
149
152
  | `marketplace_scan_status` | free | Picks a running marketplace collection back up by `scanId` and the same `marketplace`. Free, for the same reason `amazon_scan_status` is. |
150
153
  | `get_marketplace_product` | 3 | One product from any of the twelve sites, by id or URL: price, rating, the full star histogram, specs and the reviews. |
151
- | `show_amazon_category_insights` | free | Draws the read **your model** wrote — purchase drivers, barriers, what each brand does well, the gaps, the positioning angles — beside the listings and their reviews, so a person can click a product and check any claim against the text it came from. The conclusions are attributed to your model, never presented as a nooticr rating of anyone's product. Makes no requests. |
154
+ | `show_amazon_category_insights` | free | Draws the read **your model** wrote — purchase drivers, barriers, what each brand does well, the gaps, the positioning angles — beside the listings and their reviews, so a person can click a product and check any claim against the text it came from. The conclusions are attributed to your model, never presented as a nooticr rating of anyone's product. Given the `scanId`, it re-reads the listings from the scan itself (free) instead of drawing whatever the model re-sends, and flags any ASIN the read cites that the scan does not contain; without one, the card says its listings were not checked. |
152
155
 
153
156
  ### Make something
154
157
 
@@ -166,9 +169,12 @@ a `scanId` for the rest.
166
169
 
167
170
  | Tool | Credits | What it is for |
168
171
  |------|---------|----------------|
172
+ | `nooticr_getting_started` | free | **Start here.** Where this account stands — balance, connected accounts, watchlist — and the next calls worth making, each with its price and an example. The server's `instructions` point every host at it. |
169
173
  | `check_nooticr_credits` | free | Balance and billing URL. |
174
+ | `list_tool_runs` | free | Your tool-call history: what ran, whether it worked, and the credits each call actually took (0 for a free, refunded, cached or replayed call). `scope: "workspace"` shows every member's runs to owners and admins. |
175
+ | `get_tool_run` | free | One run from that history by id, with its full error. |
170
176
  | `nooticr_login` | free | Re-link the account when a call fails with an authentication error. |
171
- | `list_social_connections` | free | What you have connected and what each connection is actually allowed to do — read the account, publish a post, manage comments — as yes, no or **unknown**. Unknown means the grant predates scope recording: treat it as "try it", not as a refusal. Also says which platforms can be linked at all, which is a smaller set than the networks nooticr reads. |
177
+ | `list_social_connections` | free | What you have connected and what each connection is actually allowed to do — read the account, publish a post, manage comments — as yes, no or **unknown**. "Manage comments" reports the platform's grant; no nooticr tool posts or replies to a comment. Unknown means the grant predates scope recording: treat it as "try it", not as a refusal. Also says which platforms can be linked at all, which is a smaller set than the networks nooticr reads. |
172
178
  | `connect_social_account` | free | A link the user opens to connect one account. They approve at the provider in their own browser; nothing is connected until they do, and no credential passes through the tool or the model's context. Each call mints a fresh link — an old one does not work twice. |
173
179
  | `create_product` | free | Create a product in your own workspace — the row every other own-account tool needs before it has anything to work with. Always creates in the calling session's workspace, never one you could name. `name` and `slug` are required; every other field is snake_case and read by exact key, so a camelCase `websiteUrl` lands nowhere. Writes no brand playbook. |
174
180
  | `update_product` | free | Patch a product's fields. An omitted argument leaves its column unchanged, so this cannot blank a field by not mentioning it, and the result lists what was actually written — a misspelled field shows up as one that did not change rather than as a silent no-op. |
@@ -337,6 +343,76 @@ npx @nooticr/mcp --http # remote HTTP with OAuth (for OpenAI Agents SDK)
337
343
  (mode `0600`). If your browser cannot be opened automatically, copy the URL it
338
344
  prints into a browser manually.
339
345
 
346
+ ## Running on a server (no browser)
347
+
348
+ `login` and the OAuth flow both need a browser once, and both leave behind a
349
+ 15-minute access token kept alive by a refresh token. That is the wrong shape
350
+ for an integration running server-side: nothing there can complete a consent
351
+ screen, and a refresh chain that breaks has to be repaired by hand.
352
+
353
+ An **API key** is the other way in. Mint one from a browser at
354
+ [mcp.nooticr.com/dashboard](https://mcp.nooticr.com/dashboard) — sign in, name
355
+ the key, copy it — or from a machine you *can* sign in on, once:
356
+
357
+ ```bash
358
+ npx @nooticr/mcp login # the one interactive step
359
+ npx @nooticr/mcp api-key create --name "aybee-prod" # prints the key, once
360
+ ```
361
+
362
+ Then give it to the deployment. There is nothing else to configure and nothing
363
+ to refresh — the key stays valid until you revoke it.
364
+
365
+ ```bash
366
+ # stdio, as a subprocess of your own service
367
+ NOOTICR_API_KEY=nk_... npx @nooticr/mcp
368
+ ```
369
+
370
+ On a server, prefer the environment variable: it is what every container
371
+ runtime and secret manager already injects, and it leaves nothing on a disk
372
+ that a redeploy is going to throw away. Where setting one is the awkward part
373
+ — a laptop, or a client like Claude Desktop that does not inherit your shell —
374
+ store the key in the credentials file instead:
375
+
376
+ ```bash
377
+ npx @nooticr/mcp login --api-key nk_...
378
+ ```
379
+
380
+ That checks the key against the server before writing it, so a typo fails
381
+ while you are still there to fix it, and then behaves exactly like the
382
+ environment variable: no browser, no refresh, valid until revoked. The file
383
+ holds one credential, so this replaces any session an earlier `login` left
384
+ behind (and `NOOTICR_API_KEY` still overrides it).
385
+
386
+ ```http
387
+ # or against the remote endpoint, as an ordinary bearer token —
388
+ # no /authorize, no PKCE, no redirect URI
389
+ POST https://mcp.nooticr.com/mcp
390
+ Authorization: Bearer nk_...
391
+ ```
392
+
393
+ Managing them, from the dashboard's **API keys** card or the CLI:
394
+
395
+ ```bash
396
+ npx @nooticr/mcp api-key create --name "staging" --expires-in-days 90
397
+ npx @nooticr/mcp api-key list
398
+ npx @nooticr/mcp api-key revoke <id>
399
+ ```
400
+
401
+ Every command takes `--json` for scripting. A few things worth knowing:
402
+
403
+ - The key is shown **once**, by `api-key create`. nooticr stores only a hash of
404
+ it and cannot show it again; `list` shows the head (`nk_1a2b3c4d…`) so you
405
+ can tell keys apart.
406
+ - A key carries the scope of whoever created it — their account, and the
407
+ workspace they were in (`--workspace-id` picks another one you belong to).
408
+ - A key **cannot create or revoke keys**. That is deliberate: it means a leaked
409
+ key cannot mint a replacement that outlives revoking the original.
410
+ - `--expires-in-days` is optional. Without it a key does not expire on its own.
411
+ - Revoking takes effect immediately at the API. The hosted remote endpoint
412
+ caches the "this key is real" answer for up to five minutes, which only ever
413
+ lets a revoked key see the tool list — every call that touches your account
414
+ is checked against nooticr-server on the spot.
415
+
340
416
  ## Usage in Claude Desktop
341
417
 
342
418
  After `npx @nooticr/mcp login`, add to `claude_desktop_config.json`:
@@ -455,6 +531,11 @@ nooticr-mcp --stdio Same as above
455
531
  nooticr-mcp --http [--port N] Start the remote HTTP transport with OAuth (default port 3457)
456
532
  nooticr-mcp login Sign in to nooticr via Google in your browser
457
533
  nooticr-mcp login --email ... --password ... Password login
534
+ nooticr-mcp login --api-key nk_... Sign in with an API key, no browser
535
+ nooticr-mcp api-key create [--name N] [--expires-in-days D] [--workspace-id W] [--json]
536
+ Mint a key for a server with no browser
537
+ nooticr-mcp api-key list [--json] List this account's keys
538
+ nooticr-mcp api-key revoke <id> [--json] Revoke one
458
539
  nooticr-mcp --help Show help
459
540
  ```
460
541
 
@@ -463,7 +544,8 @@ nooticr-mcp --help Show help
463
544
  | Variable | Default | Description |
464
545
  |----------|---------|-------------|
465
546
  | `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 |
547
+ | `NOOTICR_ACCESS_TOKEN` | — | nooticr JWT access token; takes priority over everything below |
548
+ | `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
549
  | `NOOTICR_CREDENTIALS_FILE` | `~/.config/nooticr-mcp/credentials.json` | token store path |
468
550
  | `NOOTICR_PUBLIC_URL` | `http://localhost:3457` | public base URL advertised in OAuth metadata (HTTP mode) |
469
551
  | `NOOTICR_PORT` | `3457` | port for `--http` and `login` |
@@ -471,10 +553,20 @@ nooticr-mcp --help Show help
471
553
 
472
554
  ## How authentication works
473
555
 
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.
556
+ **stdio mode** (Claude Desktop, Cursor): the server uses the first credential
557
+ it finds — `NOOTICR_ACCESS_TOKEN`, then `NOOTICR_API_KEY`, then the credentials
558
+ file written by `login`. That file holds one credential, whichever `login` last
559
+ wrote: an API key (`login --api-key`) or a session. A session token that has
560
+ expired is refreshed with the stored refresh token, and a `401` from the
561
+ nooticr API is retried once after a refresh. An API key is never refreshed
562
+ wherever it came from: it does not expire, so a `401` on one means revoked or
563
+ mistyped, and retrying would only hide that.
564
+
565
+ **API key** (any transport, no browser): send it as the bearer token, or set
566
+ `NOOTICR_API_KEY`. nooticr-server resolves the key to its owner and the
567
+ workspace it was scoped to, and every authorization check downstream sees
568
+ exactly what it would have seen for a signed-in session. See
569
+ [Running on a server](#running-on-a-server-no-browser).
478
570
 
479
571
  **HTTP mode** (OpenAI Agents SDK, remote clients): the server runs its own
480
572
  OAuth 2.0 authorization server (Authorization Code + PKCE S256, public client,
@@ -488,7 +580,9 @@ per the MCP 2025-03-26 spec):
488
580
  one-time code
489
581
  - `POST /token` — verifies PKCE and issues an opaque Bearer token bound to the
490
582
  nooticr session (valid 1 hour)
491
- - every MCP RPC validates the Bearer token against the session map
583
+ - every MCP RPC validates the Bearer token against the session map — or, when
584
+ the bearer is an API key, forwards it to nooticr-server as the caller's own
585
+ credential, which is what makes this endpoint usable with no browser at all
492
586
 
493
587
  ## Supported URLs
494
588
 
@@ -508,8 +602,14 @@ and text** posts.
508
602
 
509
603
  ## Troubleshooting
510
604
 
511
- - **`Not authenticated with nooticr` / 401**: run `npx @nooticr/mcp login` or set
512
- `NOOTICR_ACCESS_TOKEN`.
605
+ - **`Not authenticated with nooticr` / 401**: run `npx @nooticr/mcp login`, or
606
+ set `NOOTICR_API_KEY` (see [Running on a server](#running-on-a-server-no-browser)).
607
+ - **`unknown, revoked or expired nooticr API key`**: the key is not one this
608
+ account has live. `npx @nooticr/mcp api-key list` shows every key and its
609
+ state, revoked ones included.
610
+ - **`API keys cannot manage API keys`**: `api-key create`/`list`/`revoke` need a
611
+ signed-in session, which is why they ignore a key in `NOOTICR_API_KEY` *and*
612
+ one stored by `login --api-key`. Run `npx @nooticr/mcp login` first.
513
613
  - **402 paywall / `insufficient MCP credits`**: your nooticr account is out of
514
614
  credits. Prices are listed per tool in [Tools](#tools) — 1 credit for a post
515
615
  lookup or transcript, 2 for discovery and for a tool that makes one fetch, 3
@@ -541,7 +641,12 @@ package for **Claude Desktop**, **Cursor**, and **OpenAI Agents SDK**.
541
641
  (in-memory).
542
642
  - Access tokens are opaque, random, and bound to the in-memory session map;
543
643
  they expire after 1 hour. Restarting the server invalidates all sessions.
544
- - Never share your credentials file or `NOOTICR_ACCESS_TOKEN`.
644
+ - API keys are stored as a sha256 hash, never in the clear, so a database dump
645
+ cannot be replayed against the API. They are scoped to one account and one
646
+ workspace, carry their owner's role as it is *now* rather than as it was when
647
+ the key was minted, and cannot be used to create or revoke keys.
648
+ - Never share your credentials file, `NOOTICR_ACCESS_TOKEN` or
649
+ `NOOTICR_API_KEY`.
545
650
 
546
651
  ## Development
547
652
 
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;