@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 +108 -9
- package/dist/auth.js +78 -7
- package/dist/config.js +11 -0
- package/dist/index.js +219 -9
- package/dist/shared/api-key.js +20 -0
- package/dist/shared/nooticr.js +45 -3
- package/dist/shared/tools-def.js +4 -4
- package/dist/shared/tools.js +19 -1
- package/package.json +1 -1
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
|
|
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
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
|
512
|
-
`
|
|
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
|
-
-
|
|
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
|
|
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;
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Modes:
|
|
7
7
|
* nooticr-mcp stdio transport (default; Claude Desktop, Cursor)
|
|
8
|
-
* nooticr-mcp login browser-based Google sign-in
|
|
8
|
+
* nooticr-mcp login browser-based Google sign-in (or --api-key, no browser)
|
|
9
|
+
* nooticr-mcp api-key mint/list/revoke a key for a server with no browser
|
|
9
10
|
* nooticr-mcp --http remote HTTP transport with OAuth (OpenAI Agents SDK)
|
|
10
11
|
*/
|
|
11
12
|
import http from "node:http";
|
|
@@ -13,7 +14,8 @@ import { randomUUID } from "node:crypto";
|
|
|
13
14
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
15
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
15
16
|
import { getBaseUrl, getPublicUrl, getPort, getCredentialsFile, getTransportMode, DEFAULT_PORT, } from "./config.js";
|
|
16
|
-
import { AuthManager, NooticrAuthError, createHttpTokenProvider, createStdioTokenProvider } from "./auth.js";
|
|
17
|
+
import { AuthManager, NooticrAuthError, createApiKeyTokenProvider, createHttpTokenProvider, createManagementTokenProvider, createStdioTokenProvider, } from "./auth.js";
|
|
18
|
+
import { looksLikeApiKey } from "./shared/api-key.js";
|
|
17
19
|
import { NooticrClient, NooticrError } from "./nooticr.js";
|
|
18
20
|
import { OAuthManager, SCOPES } from "./oauth.js";
|
|
19
21
|
import { createMcpServer } from "./shared/tools.js";
|
|
@@ -78,12 +80,21 @@ function bearerToken(req) {
|
|
|
78
80
|
}
|
|
79
81
|
/**
|
|
80
82
|
* A request bearer token is valid when it was issued by our OAuth `/token`
|
|
81
|
-
* endpoint, or when it matches
|
|
82
|
-
* deployments and local testing — no
|
|
83
|
+
* endpoint, when it is a nooticr API key, or when it matches
|
|
84
|
+
* `NOOTICR_ACCESS_TOKEN` (pre-provisioned deployments and local testing — no
|
|
85
|
+
* browser OAuth round-trip needed).
|
|
86
|
+
*
|
|
87
|
+
* A key is accepted on its shape alone because this process does not hold the
|
|
88
|
+
* secret to check it against — only nooticr-server does, and it checks every
|
|
89
|
+
* call. What that admits without a round trip is `initialize` and
|
|
90
|
+
* `tools/list`, both of which answer the same for everyone; the first call
|
|
91
|
+
* that touches an account gets the backend's own 401.
|
|
83
92
|
*/
|
|
84
93
|
function validMcpToken(token, oauth) {
|
|
85
94
|
if (oauth.verifyToken(token))
|
|
86
95
|
return true;
|
|
96
|
+
if (looksLikeApiKey(token))
|
|
97
|
+
return true;
|
|
87
98
|
const envToken = process.env.NOOTICR_ACCESS_TOKEN;
|
|
88
99
|
return typeof envToken === "string" && envToken.length > 0 && token === envToken;
|
|
89
100
|
}
|
|
@@ -93,8 +104,10 @@ async function handleMcpRequest(state, req, res) {
|
|
|
93
104
|
if (!token || !validMcpToken(token, state.oauth)) {
|
|
94
105
|
return sendJson(res, 401, {
|
|
95
106
|
error: "Unauthorized",
|
|
96
|
-
error_description: "This MCP server requires
|
|
97
|
-
`${state.oauth.authorizationServerMetadata().authorization_endpoint}
|
|
107
|
+
error_description: "This MCP server requires authentication. Fetch an access token from " +
|
|
108
|
+
`${state.oauth.authorizationServerMetadata().authorization_endpoint}, or — on a ` +
|
|
109
|
+
"server with no browser — send a nooticr API key as the bearer token " +
|
|
110
|
+
"(npx @nooticr/mcp api-key create).",
|
|
98
111
|
});
|
|
99
112
|
}
|
|
100
113
|
// Hand the validated token to the SDK transport, which forwards it to tool
|
|
@@ -221,8 +234,16 @@ export async function runHttp(port, publicUrl) {
|
|
|
221
234
|
transports: new Map(),
|
|
222
235
|
connections: new Map(),
|
|
223
236
|
serverFactory: () => createMcpServer((extra) => {
|
|
224
|
-
const
|
|
225
|
-
|
|
237
|
+
const bearer = extra.authInfo?.token;
|
|
238
|
+
// A caller who presented their own API key is answered as themselves.
|
|
239
|
+
// Falling through to `createHttpTokenProvider` here would resolve the
|
|
240
|
+
// *operator's* environment or credentials file instead — one tenant's
|
|
241
|
+
// request served from another's account.
|
|
242
|
+
if (looksLikeApiKey(bearer)) {
|
|
243
|
+
return new NooticrClient(baseUrl, createApiKeyTokenProvider(bearer));
|
|
244
|
+
}
|
|
245
|
+
const session = bearer
|
|
246
|
+
? oauth.verifyToken(bearer)
|
|
226
247
|
: undefined;
|
|
227
248
|
return new NooticrClient(baseUrl, createHttpTokenProvider(auth, session
|
|
228
249
|
? {
|
|
@@ -253,6 +274,27 @@ export async function runLogin(opts) {
|
|
|
253
274
|
const client = new NooticrClient(baseUrl, {
|
|
254
275
|
getAccessToken: async () => undefined,
|
|
255
276
|
});
|
|
277
|
+
if (opts.apiKey) {
|
|
278
|
+
// Check the key before writing it. This is the one moment a person is
|
|
279
|
+
// present to fix a typo — an unchecked key would instead surface as a
|
|
280
|
+
// failed tool call later, from a client that cannot explain itself.
|
|
281
|
+
const keyed = new NooticrClient(baseUrl, createApiKeyTokenProvider(opts.apiKey));
|
|
282
|
+
let user;
|
|
283
|
+
try {
|
|
284
|
+
user = await keyed.me();
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
288
|
+
throw new NooticrAuthError(`That API key was rejected by ${baseUrl}: ${msg}\n` +
|
|
289
|
+
"`npx nooticr-mcp api-key list` (signed in) shows which keys are still active.");
|
|
290
|
+
}
|
|
291
|
+
await auth.persistApiKey(opts.apiKey, user);
|
|
292
|
+
process.stdout.write(`Signed in as ${user?.email ?? "unknown user"} with an API key. ` +
|
|
293
|
+
`Saved to ${auth.getCredentialsFile()}\n` +
|
|
294
|
+
"The key does not expire, so there is nothing to refresh and no browser to open again.\n" +
|
|
295
|
+
"On a server, prefer NOOTICR_API_KEY over this file — see the README.\n");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
256
298
|
if (opts.email && opts.password) {
|
|
257
299
|
const session = await client.login(opts.email, opts.password);
|
|
258
300
|
await auth.persistSession(session);
|
|
@@ -315,6 +357,98 @@ export async function runLogin(opts) {
|
|
|
315
357
|
process.exit(1);
|
|
316
358
|
}, 300_000).unref();
|
|
317
359
|
}
|
|
360
|
+
function formatDate(value) {
|
|
361
|
+
if (!value)
|
|
362
|
+
return "never";
|
|
363
|
+
const at = new Date(value);
|
|
364
|
+
return Number.isNaN(at.getTime()) ? value : at.toISOString().slice(0, 10);
|
|
365
|
+
}
|
|
366
|
+
function describeKey(key) {
|
|
367
|
+
const state = key.revokedAt
|
|
368
|
+
? "revoked"
|
|
369
|
+
: key.expiresAt && new Date(key.expiresAt).getTime() <= Date.now()
|
|
370
|
+
? "expired"
|
|
371
|
+
: "active";
|
|
372
|
+
return [
|
|
373
|
+
`${key.prefix}\u2026 ${key.name}`,
|
|
374
|
+
` id ${key.id}`,
|
|
375
|
+
` state ${state}`,
|
|
376
|
+
` created ${formatDate(key.createdAt)}`,
|
|
377
|
+
` last used ${formatDate(key.lastUsedAt)}`,
|
|
378
|
+
` expires ${formatDate(key.expiresAt)}`,
|
|
379
|
+
].join("\n");
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Mints, lists and revokes the keys a headless deployment authenticates with.
|
|
383
|
+
*
|
|
384
|
+
* Creating one needs an interactive session exactly once — that is the step it
|
|
385
|
+
* removes from every run afterwards — so these commands deliberately refuse to
|
|
386
|
+
* authenticate with `NOOTICR_API_KEY` even when one is set. The backend
|
|
387
|
+
* refuses key management to a key-authenticated caller anyway; resolving one
|
|
388
|
+
* here would only turn that bound into a 403 that reads like a bug.
|
|
389
|
+
*/
|
|
390
|
+
export async function runApiKey(opts) {
|
|
391
|
+
const baseUrl = getBaseUrl();
|
|
392
|
+
const auth = new AuthManager(baseUrl, getCredentialsFile());
|
|
393
|
+
const token = await auth.getAccessToken(undefined, { allowApiKey: false });
|
|
394
|
+
if (!token) {
|
|
395
|
+
// Telling these two apart matters: "sign in first" is wrong advice for
|
|
396
|
+
// someone who *is* signed in, with a key, and is being refused for a
|
|
397
|
+
// reason the message otherwise never mentions.
|
|
398
|
+
const hasKey = Boolean(await auth.getAccessToken());
|
|
399
|
+
throw new NooticrAuthError(hasKey
|
|
400
|
+
? "You are signed in with an API key, and a key cannot manage keys — that bound is " +
|
|
401
|
+
"what stops a leaked key minting a replacement that outlives revoking the original. " +
|
|
402
|
+
"Sign in as yourself for this one: `npx nooticr-mcp login` " +
|
|
403
|
+
"(or `login --email ... --password ...`)."
|
|
404
|
+
: "Sign in first: `npx nooticr-mcp login` (or `login --email ... --password ...`). " +
|
|
405
|
+
"Minting a key is the one step that needs a browser, and it is the last one: " +
|
|
406
|
+
"the key it returns is what every run afterwards uses.");
|
|
407
|
+
}
|
|
408
|
+
const client = new NooticrClient(baseUrl, createManagementTokenProvider(auth));
|
|
409
|
+
if (opts.action === "create") {
|
|
410
|
+
const created = await client.createApiKey({
|
|
411
|
+
...(opts.name !== undefined ? { name: opts.name } : {}),
|
|
412
|
+
...(opts.workspaceId !== undefined ? { workspaceId: opts.workspaceId } : {}),
|
|
413
|
+
...(opts.expiresInDays !== undefined ? { expiresInDays: opts.expiresInDays } : {}),
|
|
414
|
+
});
|
|
415
|
+
if (opts.json) {
|
|
416
|
+
process.stdout.write(`${JSON.stringify(created, null, 2)}\n`);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
process.stdout.write(`Created API key "${created.name}" (${created.id}).\n\n` +
|
|
420
|
+
` ${created.key}\n\n` +
|
|
421
|
+
"This is the only time the key is shown — nooticr stores a hash of it and\n" +
|
|
422
|
+
"cannot show it again. Store it wherever this deployment keeps its secrets,\n" +
|
|
423
|
+
"then give it to the server:\n\n" +
|
|
424
|
+
" NOOTICR_API_KEY=<key> npx nooticr-mcp\n\n" +
|
|
425
|
+
"or, against the remote HTTP endpoint, as the bearer token:\n\n" +
|
|
426
|
+
" Authorization: Bearer <key>\n");
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (opts.action === "list") {
|
|
430
|
+
const keys = await client.listApiKeys();
|
|
431
|
+
if (opts.json) {
|
|
432
|
+
process.stdout.write(`${JSON.stringify(keys, null, 2)}\n`);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (keys.length === 0) {
|
|
436
|
+
process.stdout.write("No API keys. Create one with `npx nooticr-mcp api-key create --name <name>`.\n");
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
process.stdout.write(`${keys.map(describeKey).join("\n\n")}\n`);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (!opts.id) {
|
|
443
|
+
throw new NooticrError(400, "Usage: nooticr-mcp api-key revoke <id>");
|
|
444
|
+
}
|
|
445
|
+
await client.revokeApiKey(opts.id);
|
|
446
|
+
if (opts.json) {
|
|
447
|
+
process.stdout.write(`${JSON.stringify({ id: opts.id, revoked: true }, null, 2)}\n`);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
process.stdout.write(`Revoked ${opts.id}. Any server still presenting it now gets a 401.\n`);
|
|
451
|
+
}
|
|
318
452
|
// ---------------------------------------------------------------------------
|
|
319
453
|
// CLI
|
|
320
454
|
// ---------------------------------------------------------------------------
|
|
@@ -328,11 +462,18 @@ Usage:
|
|
|
328
462
|
(default port ${DEFAULT_PORT}; also NOOTICR_PORT)
|
|
329
463
|
nooticr-mcp login Sign in to nooticr via Google in your browser
|
|
330
464
|
nooticr-mcp login --email me@example.com --password '...' Password login
|
|
465
|
+
nooticr-mcp login --api-key nk_... Sign in with an API key (no browser)
|
|
466
|
+
nooticr-mcp api-key create [--name N] [--expires-in-days D] [--workspace-id W] [--json]
|
|
467
|
+
Mint a key for a server with no browser
|
|
468
|
+
nooticr-mcp api-key list [--json] List this account's keys
|
|
469
|
+
nooticr-mcp api-key revoke <id> [--json] Revoke one
|
|
331
470
|
nooticr-mcp --help Show this help
|
|
332
471
|
|
|
333
472
|
Environment variables:
|
|
334
473
|
NOOTICR_BASE_URL nooticr server base URL (default http://localhost:8080)
|
|
335
474
|
NOOTICR_ACCESS_TOKEN nooticr JWT access token (bypasses login)
|
|
475
|
+
NOOTICR_API_KEY nooticr API key from "api-key create" — no browser,
|
|
476
|
+
no refresh, valid until revoked
|
|
336
477
|
NOOTICR_CREDENTIALS_FILE token store path (default ~/.config/nooticr-mcp/credentials.json)
|
|
337
478
|
NOOTICR_PUBLIC_URL public base URL for the HTTP mode (default http://localhost:3457)
|
|
338
479
|
NOOTICR_PORT port for --http and login (default ${DEFAULT_PORT})
|
|
@@ -343,6 +484,11 @@ Client setup:
|
|
|
343
484
|
"command": "npx", "args": ["nooticr-mcp"] (plus NOOTICR_ACCESS_TOKEN if needed)
|
|
344
485
|
OpenAI Agents SDK (remote HTTP): use the RemoteMCPClient with URL
|
|
345
486
|
<NOOTICR_PUBLIC_URL>/mcp — the OAuth flow will open your browser.
|
|
487
|
+
Server-side, no browser: mint a key once with "api-key create", then either
|
|
488
|
+
set NOOTICR_API_KEY for the stdio server, or send it to the remote endpoint
|
|
489
|
+
as "Authorization: Bearer <key>" and skip the OAuth flow entirely.
|
|
490
|
+
A client that does not inherit your shell environment: "login --api-key nk_..."
|
|
491
|
+
stores the same key in the credentials file instead.
|
|
346
492
|
|
|
347
493
|
See README.md for full instructions.
|
|
348
494
|
`);
|
|
@@ -361,6 +507,17 @@ async function main() {
|
|
|
361
507
|
};
|
|
362
508
|
const email = valueOf("--email");
|
|
363
509
|
const password = valueOf("--password");
|
|
510
|
+
// Every other flag here is kebab-case; `--api_key` is the near-miss worth
|
|
511
|
+
// naming rather than ignoring, since ignoring it opens a browser instead.
|
|
512
|
+
if (rest.includes("--api_key")) {
|
|
513
|
+
process.stderr.write("Unknown flag --api_key. Did you mean --api-key?\n");
|
|
514
|
+
process.exit(1);
|
|
515
|
+
}
|
|
516
|
+
const apiKey = valueOf("--api-key");
|
|
517
|
+
if (rest.includes("--api-key") && !apiKey) {
|
|
518
|
+
process.stderr.write("--api-key needs a value (the nk_… key from `api-key create`).\n");
|
|
519
|
+
process.exit(1);
|
|
520
|
+
}
|
|
364
521
|
let port = getPort();
|
|
365
522
|
const portRaw = valueOf("--port");
|
|
366
523
|
if (portRaw !== undefined)
|
|
@@ -373,8 +530,12 @@ async function main() {
|
|
|
373
530
|
process.stderr.write("Both --email and --password must be provided together.\n");
|
|
374
531
|
process.exit(1);
|
|
375
532
|
}
|
|
533
|
+
if (apiKey && (email || password)) {
|
|
534
|
+
process.stderr.write("Use --api-key or --email/--password, not both.\n");
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
376
537
|
try {
|
|
377
|
-
await runLogin({ email, password, port });
|
|
538
|
+
await runLogin({ email, password, apiKey, port });
|
|
378
539
|
}
|
|
379
540
|
catch (err) {
|
|
380
541
|
process.stderr.write(`Login failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
@@ -382,6 +543,55 @@ async function main() {
|
|
|
382
543
|
}
|
|
383
544
|
return;
|
|
384
545
|
}
|
|
546
|
+
if (args[0] === "api-key" || args[0] === "api-keys") {
|
|
547
|
+
const rest = args.slice(1);
|
|
548
|
+
const action = rest[0];
|
|
549
|
+
if (action !== "create" && action !== "list" && action !== "revoke") {
|
|
550
|
+
process.stderr.write("Usage: nooticr-mcp api-key <create|list|revoke> [options]\n" +
|
|
551
|
+
" nooticr-mcp api-key create [--name N] [--expires-in-days D] [--workspace-id W]\n" +
|
|
552
|
+
" nooticr-mcp api-key revoke <id>\n");
|
|
553
|
+
process.exit(1);
|
|
554
|
+
}
|
|
555
|
+
const flags = rest.slice(1);
|
|
556
|
+
const valueOf = (flag) => {
|
|
557
|
+
const idx = flags.indexOf(flag);
|
|
558
|
+
return idx >= 0 && flags[idx + 1] ? flags[idx + 1] : undefined;
|
|
559
|
+
};
|
|
560
|
+
const opts = { action, json: flags.includes("--json") };
|
|
561
|
+
const name = valueOf("--name");
|
|
562
|
+
if (name !== undefined)
|
|
563
|
+
opts.name = name;
|
|
564
|
+
const workspaceId = valueOf("--workspace-id");
|
|
565
|
+
if (workspaceId !== undefined)
|
|
566
|
+
opts.workspaceId = workspaceId;
|
|
567
|
+
const expiresRaw = valueOf("--expires-in-days");
|
|
568
|
+
if (expiresRaw !== undefined) {
|
|
569
|
+
const days = Number.parseInt(expiresRaw, 10);
|
|
570
|
+
if (!Number.isInteger(days) || days < 1) {
|
|
571
|
+
process.stderr.write("--expires-in-days must be a positive whole number of days.\n");
|
|
572
|
+
process.exit(1);
|
|
573
|
+
}
|
|
574
|
+
opts.expiresInDays = days;
|
|
575
|
+
}
|
|
576
|
+
if (action === "revoke") {
|
|
577
|
+
// The id is positional, so anything that starts with "-" is a flag the
|
|
578
|
+
// user meant for something else, not the key they want gone.
|
|
579
|
+
const id = flags.find((arg) => !arg.startsWith("-"));
|
|
580
|
+
if (!id) {
|
|
581
|
+
process.stderr.write("Usage: nooticr-mcp api-key revoke <id>\n");
|
|
582
|
+
process.exit(1);
|
|
583
|
+
}
|
|
584
|
+
opts.id = id;
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
await runApiKey(opts);
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
591
|
+
process.exit(1);
|
|
592
|
+
}
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
385
595
|
const isHttp = args.includes("--http") || getTransportMode() === "http";
|
|
386
596
|
let port = getPort();
|
|
387
597
|
if (args.includes("--port")) {
|
|
@@ -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
|
+
}
|
package/dist/shared/nooticr.js
CHANGED
|
@@ -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
|
-
:
|
|
104
|
-
|
|
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 {
|
package/dist/shared/tools-def.js
CHANGED
|
@@ -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
|
|
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
|
|
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",
|
package/dist/shared/tools.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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": {
|