@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/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
|
|
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.
|
|
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
|
|
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
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
|
512
|
-
`
|
|
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
|
-
-
|
|
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
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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;
|