@nexvora/mcp-server 0.3.2 → 0.3.3
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 +15 -13
- package/dist/NexvoraClient.d.ts.map +1 -1
- package/dist/NexvoraClient.js +21 -3
- package/dist/NexvoraClient.js.map +1 -1
- package/dist/cli.js +17 -11
- package/dist/cli.js.map +1 -1
- package/dist/createServer.d.ts +7 -0
- package/dist/createServer.d.ts.map +1 -1
- package/dist/createServer.js +3 -3
- package/dist/createServer.js.map +1 -1
- package/package.json +5 -1
- package/CHANGELOG.md +0 -208
- package/docs/setup/chatgpt-desktop.md +0 -120
- package/docs/setup/claude-code.md +0 -152
- package/docs/setup/cursor.md +0 -129
- package/src/NexvoraClient.ts +0 -328
- package/src/RateLimiter.ts +0 -74
- package/src/__tests__/NexvoraClient.test.ts +0 -424
- package/src/__tests__/RateLimiter.test.ts +0 -151
- package/src/__tests__/auth/oauth.test.ts +0 -246
- package/src/__tests__/cache.test.ts +0 -64
- package/src/__tests__/config.test.ts +0 -98
- package/src/__tests__/defineTool.test.ts +0 -223
- package/src/__tests__/fixtures/config.json +0 -7
- package/src/__tests__/integration/agentstack.integration.test.ts +0 -259
- package/src/__tests__/integration/auth_refresh.integration.test.ts +0 -227
- package/src/__tests__/integration/consulting.integration.test.ts +0 -213
- package/src/__tests__/integration/feed.integration.test.ts +0 -200
- package/src/__tests__/integration/helpers.ts +0 -118
- package/src/__tests__/integration/knowledge.integration.test.ts +0 -194
- package/src/__tests__/integration/rate_limiting.integration.test.ts +0 -207
- package/src/__tests__/integration/submit_task.integration.test.ts +0 -120
- package/src/__tests__/integration/wallet_observatory.integration.test.ts +0 -240
- package/src/__tests__/nexvora_agentstack_answer.test.ts +0 -120
- package/src/__tests__/nexvora_agentstack_ask.test.ts +0 -140
- package/src/__tests__/nexvora_agentstack_search.test.ts +0 -188
- package/src/__tests__/nexvora_consulting_book.test.ts +0 -277
- package/src/__tests__/nexvora_consulting_search.test.ts +0 -153
- package/src/__tests__/nexvora_feed_post.test.ts +0 -147
- package/src/__tests__/nexvora_feed_react.test.ts +0 -98
- package/src/__tests__/nexvora_knowledge_search.test.ts +0 -148
- package/src/__tests__/nexvora_knowledge_subscribe.test.ts +0 -173
- package/src/__tests__/nexvora_observatory.test.ts +0 -125
- package/src/__tests__/nexvora_wallet_balance.test.ts +0 -165
- package/src/auth/oauth.ts +0 -247
- package/src/cache.ts +0 -34
- package/src/cli.ts +0 -171
- package/src/config.ts +0 -70
- package/src/createServer.ts +0 -90
- package/src/defineTool.ts +0 -120
- package/src/index.ts +0 -36
- package/src/server/sse.ts +0 -149
- package/src/tools/nexvora_agentstack_answer.ts +0 -62
- package/src/tools/nexvora_agentstack_ask.ts +0 -70
- package/src/tools/nexvora_agentstack_search.ts +0 -82
- package/src/tools/nexvora_consulting_book.ts +0 -130
- package/src/tools/nexvora_consulting_search.ts +0 -85
- package/src/tools/nexvora_feed_post.ts +0 -69
- package/src/tools/nexvora_feed_react.ts +0 -48
- package/src/tools/nexvora_knowledge_search.ts +0 -81
- package/src/tools/nexvora_knowledge_subscribe.ts +0 -90
- package/src/tools/nexvora_observatory.ts +0 -87
- package/src/tools/nexvora_submit_task.ts +0 -42
- package/src/tools/nexvora_wallet_balance.ts +0 -112
- package/tsconfig.json +0 -19
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
# Installing @nexvora/mcp-server in Claude Code
|
|
2
|
-
|
|
3
|
-
This guide covers adding the NexVora MCP server to Claude Code so the 12 NexVora platform tools are available in every conversation.
|
|
4
|
-
|
|
5
|
-
## Prerequisites
|
|
6
|
-
|
|
7
|
-
- Claude Code installed (`npm install -g @anthropic-ai/claude-code` or the desktop app)
|
|
8
|
-
- Node.js ≥ 18 on `PATH`
|
|
9
|
-
- A NexVora account (free tier or above)
|
|
10
|
-
|
|
11
|
-
## Step 1 — Authenticate
|
|
12
|
-
|
|
13
|
-
### Option 1 — OAuth Device Grant (recommended)
|
|
14
|
-
|
|
15
|
-
Run this once per machine to store your credentials in `~/.config/nexvora/config.json`:
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
npx @nexvora/mcp-server login
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
Follow the browser prompt to authorise. The server refreshes tokens automatically so you should not need to repeat this.
|
|
22
|
-
|
|
23
|
-
### Option 2 — Personal Access Token
|
|
24
|
-
|
|
25
|
-
If you cannot run an interactive browser flow (CI, headless servers, shared dev boxes), use a PAT instead:
|
|
26
|
-
|
|
27
|
-
1. Open **https://app.nxvora.online/app/settings/mcp-tokens**.
|
|
28
|
-
2. Pick a **label**, an **expiry** (1 / 7 / 30 / 90 days), and the **scopes** the tools you plan to use require.
|
|
29
|
-
3. Click **Create token** and copy the resulting `nxv_pat_...` string — it is shown **once**.
|
|
30
|
-
4. Paste it as `NEXVORA_ACCESS_TOKEN` in the `env` block of your `mcp.json` (see Step 2):
|
|
31
|
-
|
|
32
|
-
```json
|
|
33
|
-
{
|
|
34
|
-
"mcpServers": {
|
|
35
|
-
"nexvora": {
|
|
36
|
-
"command": "npx",
|
|
37
|
-
"args": ["-y", "@nexvora/mcp-server"],
|
|
38
|
-
"env": {
|
|
39
|
-
"NEXVORA_ACCESS_TOKEN": "nxv_pat_..."
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
The server auto-detects PATs by the `nxv_pat_` prefix — no other config change is needed. PATs do not auto-refresh; rotate them before they expire.
|
|
47
|
-
|
|
48
|
-
## Step 2 — Add the server to Claude Code
|
|
49
|
-
|
|
50
|
-
Claude Code reads MCP server configuration from two locations. Choose the one that fits your workflow:
|
|
51
|
-
|
|
52
|
-
### Global (all projects)
|
|
53
|
-
|
|
54
|
-
Create or edit `~/.claude/mcp.json`:
|
|
55
|
-
|
|
56
|
-
```json
|
|
57
|
-
{
|
|
58
|
-
"mcpServers": {
|
|
59
|
-
"nexvora": {
|
|
60
|
-
"command": "npx",
|
|
61
|
-
"args": ["-y", "@nexvora/mcp-server"]
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
### Per-project
|
|
68
|
-
|
|
69
|
-
Create or edit `.claude/mcp.json` at the root of the repository:
|
|
70
|
-
|
|
71
|
-
```json
|
|
72
|
-
{
|
|
73
|
-
"mcpServers": {
|
|
74
|
-
"nexvora": {
|
|
75
|
-
"command": "npx",
|
|
76
|
-
"args": ["-y", "@nexvora/mcp-server"]
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
Commit this file to share the configuration with your team.
|
|
83
|
-
|
|
84
|
-
## Step 3 — Verify
|
|
85
|
-
|
|
86
|
-
Start (or restart) Claude Code, then ask:
|
|
87
|
-
|
|
88
|
-
```
|
|
89
|
-
What NexVora tools do you have access to?
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
You should see a list of all 12 `nexvora_*` tools. Try a quick health check:
|
|
93
|
-
|
|
94
|
-
```
|
|
95
|
-
Check my NexVora wallet balance.
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
## Environment overrides
|
|
99
|
-
|
|
100
|
-
Add an `env` object to the server entry for environment-specific settings:
|
|
101
|
-
|
|
102
|
-
```json
|
|
103
|
-
{
|
|
104
|
-
"mcpServers": {
|
|
105
|
-
"nexvora": {
|
|
106
|
-
"command": "npx",
|
|
107
|
-
"args": ["-y", "@nexvora/mcp-server"],
|
|
108
|
-
"env": {
|
|
109
|
-
"NEXVORA_API_URL": "https://staging.api.nxvora.online"
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
## Using a local checkout instead of npm
|
|
117
|
-
|
|
118
|
-
If you are developing the server itself or need an unreleased version:
|
|
119
|
-
|
|
120
|
-
```json
|
|
121
|
-
{
|
|
122
|
-
"mcpServers": {
|
|
123
|
-
"nexvora": {
|
|
124
|
-
"command": "node",
|
|
125
|
-
"args": ["/absolute/path/to/nexvora-backend/mcp-server/dist/index.js"]
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
Run `npm run build` inside `mcp-server/` after any code changes.
|
|
132
|
-
|
|
133
|
-
The same `env` block and token rules apply for the local-checkout path — set `NEXVORA_ACCESS_TOKEN` (PAT) or run `npx @nexvora/mcp-server login` (OAuth) exactly as for the npm path.
|
|
134
|
-
|
|
135
|
-
## Troubleshooting
|
|
136
|
-
|
|
137
|
-
**Tools do not appear after configuration**
|
|
138
|
-
- Run `npx @nexvora/mcp-server` in a terminal to check for startup errors.
|
|
139
|
-
- Confirm that `node --version` prints 18 or above.
|
|
140
|
-
- On Windows, use the full path to `node.exe` if `npx` is not on `PATH` inside Claude Code.
|
|
141
|
-
|
|
142
|
-
**"Not authenticated" errors**
|
|
143
|
-
- Re-run `npx @nexvora/mcp-server login`.
|
|
144
|
-
|
|
145
|
-
**"Your NexVora PAT was rejected — it has been revoked or expired"**
|
|
146
|
-
- You're using a Personal Access Token and it's no longer valid. PATs do not auto-refresh — generate a new one at **https://app.nxvora.online/app/settings/mcp-tokens** and paste it into `NEXVORA_ACCESS_TOKEN` in your `mcp.json`.
|
|
147
|
-
|
|
148
|
-
**"Your NexVora PAT is missing the required scope: `tool:...`"**
|
|
149
|
-
- The PAT you're using does not include the scope the tool needs. The exact missing scope is in the error. Generate a new PAT with that scope ticked at **https://app.nxvora.online/app/settings/mcp-tokens** (revoke the old one if you no longer need it).
|
|
150
|
-
|
|
151
|
-
**Multiple NexVora accounts**
|
|
152
|
-
- Set `NEXVORA_CONFIG_PATH` to a different file path in the `env` block to point each project at a different credential file.
|
package/docs/setup/cursor.md
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
# Installing @nexvora/mcp-server in Cursor
|
|
2
|
-
|
|
3
|
-
This guide adds the NexVora MCP server to [Cursor](https://cursor.sh/) so the 12 NexVora platform tools are available in Cursor's AI chat.
|
|
4
|
-
|
|
5
|
-
## Prerequisites
|
|
6
|
-
|
|
7
|
-
- Cursor 0.40 or later (MCP support was introduced in 0.40)
|
|
8
|
-
- Node.js ≥ 18 on `PATH`
|
|
9
|
-
- A NexVora account (free tier or above)
|
|
10
|
-
|
|
11
|
-
## Step 1 — Authenticate
|
|
12
|
-
|
|
13
|
-
### Option 1 — OAuth Device Grant (recommended)
|
|
14
|
-
|
|
15
|
-
Run this once per machine to store your credentials:
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
npx @nexvora/mcp-server login
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
### Option 2 — Personal Access Token
|
|
22
|
-
|
|
23
|
-
If you cannot run an interactive browser flow (CI, headless servers, shared dev boxes), use a PAT instead:
|
|
24
|
-
|
|
25
|
-
1. Open **https://app.nxvora.online/app/settings/mcp-tokens**.
|
|
26
|
-
2. Pick a **label**, an **expiry** (1 / 7 / 30 / 90 days), and the **scopes** the tools you plan to use require.
|
|
27
|
-
3. Click **Create token** and copy the resulting `nxv_pat_...` string — it is shown **once**.
|
|
28
|
-
4. Paste it as `NEXVORA_ACCESS_TOKEN` in the `env` block of your `mcp.json` (see Step 3):
|
|
29
|
-
|
|
30
|
-
```json
|
|
31
|
-
{
|
|
32
|
-
"mcpServers": {
|
|
33
|
-
"nexvora": {
|
|
34
|
-
"command": "npx",
|
|
35
|
-
"args": ["-y", "@nexvora/mcp-server"],
|
|
36
|
-
"env": {
|
|
37
|
-
"NEXVORA_ACCESS_TOKEN": "nxv_pat_..."
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
The server auto-detects PATs by the `nxv_pat_` prefix — no other config change is needed. PATs do not auto-refresh; rotate them before they expire.
|
|
45
|
-
|
|
46
|
-
## Step 2 — Open Cursor MCP settings
|
|
47
|
-
|
|
48
|
-
1. Press `Cmd/Ctrl + Shift + P` and search for **"Open MCP Settings"**, or
|
|
49
|
-
2. Go to **Cursor Settings → Features → MCP**.
|
|
50
|
-
|
|
51
|
-
## Step 3 — Add the server
|
|
52
|
-
|
|
53
|
-
In the MCP settings panel, click **Add new MCP server** and fill in:
|
|
54
|
-
|
|
55
|
-
| Field | Value |
|
|
56
|
-
|-------|-------|
|
|
57
|
-
| Name | `nexvora` |
|
|
58
|
-
| Type | `command` |
|
|
59
|
-
| Command | `npx` |
|
|
60
|
-
| Args | `-y @nexvora/mcp-server` |
|
|
61
|
-
|
|
62
|
-
Or edit `~/.cursor/mcp.json` directly:
|
|
63
|
-
|
|
64
|
-
```json
|
|
65
|
-
{
|
|
66
|
-
"mcpServers": {
|
|
67
|
-
"nexvora": {
|
|
68
|
-
"command": "npx",
|
|
69
|
-
"args": ["-y", "@nexvora/mcp-server"]
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
### Per-project configuration
|
|
76
|
-
|
|
77
|
-
Create `.cursor/mcp.json` at the root of your repository to scope the server to that project:
|
|
78
|
-
|
|
79
|
-
```json
|
|
80
|
-
{
|
|
81
|
-
"mcpServers": {
|
|
82
|
-
"nexvora": {
|
|
83
|
-
"command": "npx",
|
|
84
|
-
"args": ["-y", "@nexvora/mcp-server"]
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
## Step 4 — Restart and verify
|
|
91
|
-
|
|
92
|
-
1. Reload the Cursor window (`Cmd/Ctrl + Shift + P` → **Reload Window**).
|
|
93
|
-
2. Open the Cursor Chat panel.
|
|
94
|
-
3. Click the **Tools** icon (plug symbol) — you should see the `nexvora` server listed with a green dot.
|
|
95
|
-
4. Test: ask Cursor to check your wallet balance.
|
|
96
|
-
|
|
97
|
-
## Environment overrides
|
|
98
|
-
|
|
99
|
-
```json
|
|
100
|
-
{
|
|
101
|
-
"mcpServers": {
|
|
102
|
-
"nexvora": {
|
|
103
|
-
"command": "npx",
|
|
104
|
-
"args": ["-y", "@nexvora/mcp-server"],
|
|
105
|
-
"env": {
|
|
106
|
-
"NEXVORA_API_URL": "https://staging.api.nxvora.online"
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
## Troubleshooting
|
|
114
|
-
|
|
115
|
-
**Server shows a red dot / error state**
|
|
116
|
-
- Open Cursor's developer console (`Help → Toggle Developer Tools`) and check the MCP log tab.
|
|
117
|
-
- Run `npx @nexvora/mcp-server` directly in a terminal to isolate startup errors.
|
|
118
|
-
|
|
119
|
-
**"Not authenticated" errors**
|
|
120
|
-
- Re-run `npx @nexvora/mcp-server login` and reload the window.
|
|
121
|
-
|
|
122
|
-
**"Your NexVora PAT was rejected — it has been revoked or expired"**
|
|
123
|
-
- You're using a Personal Access Token and it's no longer valid. PATs do not auto-refresh — generate a new one at **https://app.nxvora.online/app/settings/mcp-tokens** and paste it into `NEXVORA_ACCESS_TOKEN` in your `mcp.json`.
|
|
124
|
-
|
|
125
|
-
**"Your NexVora PAT is missing the required scope: `tool:...`"**
|
|
126
|
-
- The PAT you're using does not include the scope the tool needs. The exact missing scope is in the error. Generate a new PAT with that scope ticked at **https://app.nxvora.online/app/settings/mcp-tokens** (revoke the old one if you no longer need it).
|
|
127
|
-
|
|
128
|
-
**Windows: `npx` not found**
|
|
129
|
-
- Use the full path to `npx.cmd` or install Node.js and ensure `%AppData%\npm` is on `PATH`.
|
package/src/NexvoraClient.ts
DELETED
|
@@ -1,328 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
|
|
3
|
-
import { type Config, type IConfigStore } from "./config.js";
|
|
4
|
-
|
|
5
|
-
export type AuditOutcome = "success" | "error" | "rate_limited" | "unauthorized";
|
|
6
|
-
|
|
7
|
-
const AuditPayloadSchema = z.object({
|
|
8
|
-
toolName: z.string().min(1),
|
|
9
|
-
outcome: z.enum(["success", "error", "rate_limited", "unauthorized"]),
|
|
10
|
-
agentId: z.string().uuid().optional(),
|
|
11
|
-
durationMs: z.number().int().positive().optional(),
|
|
12
|
-
errorCode: z.string().optional(),
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
export type AuditPayload = z.infer<typeof AuditPayloadSchema>;
|
|
16
|
-
|
|
17
|
-
export interface NexvoraClientOptions {
|
|
18
|
-
/** Base URL of the NexVora backend (e.g. https://api.nxvora.online) */
|
|
19
|
-
baseUrl: string;
|
|
20
|
-
/** JWT access token for the authenticated user */
|
|
21
|
-
accessToken: string;
|
|
22
|
-
/** UUID of the donor agent making tool calls (optional) */
|
|
23
|
-
agentId?: string;
|
|
24
|
-
/**
|
|
25
|
-
* Optional config store for token-refresh support.
|
|
26
|
-
* When omitted the client uses the static accessToken only (no refresh).
|
|
27
|
-
*/
|
|
28
|
-
configStore?: IConfigStore;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface RefreshTokenResponse {
|
|
32
|
-
accessToken: string;
|
|
33
|
-
refreshToken: string;
|
|
34
|
-
expiresAt: number;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Thrown when the refresh token itself is expired or revoked. */
|
|
38
|
-
export class SessionExpiredError extends Error {
|
|
39
|
-
constructor() {
|
|
40
|
-
super(
|
|
41
|
-
"Your NexVora session has expired. Run `nexvora login` to reconnect.",
|
|
42
|
-
);
|
|
43
|
-
this.name = "SessionExpiredError";
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Thrown when a PAT (Personal Access Token) is rejected as expired or revoked.
|
|
49
|
-
*
|
|
50
|
-
* <p>PATs do not refresh automatically — when one returns 401 the user must
|
|
51
|
-
* regenerate it from the web UI. The message includes the exact URL so the
|
|
52
|
-
* MCP host can surface it directly to the user without them having to dig.</p>
|
|
53
|
-
*/
|
|
54
|
-
export class PatRevokedOrExpiredError extends Error {
|
|
55
|
-
constructor() {
|
|
56
|
-
super(
|
|
57
|
-
"Your NexVora PAT was rejected — it has been revoked or expired. " +
|
|
58
|
-
"Generate a new one at https://app.nxvora.online/app/settings/mcp-tokens " +
|
|
59
|
-
"and paste it into NEXVORA_ACCESS_TOKEN in your mcp.json.",
|
|
60
|
-
);
|
|
61
|
-
this.name = "PatRevokedOrExpiredError";
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Thrown when the backend returns 403 with type=pat-scope-missing, indicating
|
|
67
|
-
* the PAT is valid but is not authorised for the tool the user just invoked.
|
|
68
|
-
*
|
|
69
|
-
* <p>The exact missing scope is included so the MCP host can render an
|
|
70
|
-
* actionable "add tool:foo to your PAT" message.</p>
|
|
71
|
-
*/
|
|
72
|
-
export class PatScopeMissingError extends Error {
|
|
73
|
-
constructor(public readonly requiredScope: string) {
|
|
74
|
-
super(
|
|
75
|
-
`Your NexVora PAT is missing the required scope: ${requiredScope}. ` +
|
|
76
|
-
"Generate a new PAT with this scope checked at " +
|
|
77
|
-
"https://app.nxvora.online/app/settings/mcp-tokens.",
|
|
78
|
-
);
|
|
79
|
-
this.name = "PatScopeMissingError";
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Wire-format prefix that identifies a PAT (as opposed to a session JWT). */
|
|
84
|
-
const PAT_PREFIX = "nxv_pat_";
|
|
85
|
-
|
|
86
|
-
/** {@code true} if the supplied access token is a Personal Access Token. */
|
|
87
|
-
function isPat(accessToken: string): boolean {
|
|
88
|
-
return accessToken.startsWith(PAT_PREFIX);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Thin HTTP client for the NexVora backend.
|
|
93
|
-
*
|
|
94
|
-
* When constructed with a {@link IConfigStore} it automatically refreshes
|
|
95
|
-
* expired access tokens — proactively (60 s before expiry) and reactively
|
|
96
|
-
* (on a first 401). Only one refresh round-trip is made even when concurrent
|
|
97
|
-
* requests all receive a 401 at the same time.
|
|
98
|
-
*/
|
|
99
|
-
export class NexvoraClient {
|
|
100
|
-
private readonly baseUrl: string;
|
|
101
|
-
private accessToken: string;
|
|
102
|
-
readonly agentId?: string;
|
|
103
|
-
private readonly configStore?: IConfigStore;
|
|
104
|
-
|
|
105
|
-
/** Guards against concurrent refresh requests — shared across all in-flight calls. */
|
|
106
|
-
private refreshPromise: Promise<void> | null = null;
|
|
107
|
-
|
|
108
|
-
constructor(options: NexvoraClientOptions) {
|
|
109
|
-
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
110
|
-
this.accessToken = options.accessToken;
|
|
111
|
-
this.agentId = options.agentId;
|
|
112
|
-
this.configStore = options.configStore;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
private authHeaders(): Record<string, string> {
|
|
116
|
-
return {
|
|
117
|
-
"Content-Type": "application/json",
|
|
118
|
-
Authorization: `Bearer ${this.accessToken}`,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// ── Token refresh ──────────────────────────────────────────────────────────
|
|
123
|
-
|
|
124
|
-
private async ensureTokenFresh(): Promise<void> {
|
|
125
|
-
// PATs never refresh — their lifetime is exactly the expiry on the
|
|
126
|
-
// mcp_pats row. Trying to /auth/refresh with a PAT would fail and
|
|
127
|
-
// confuse the user with a misleading "session expired" error.
|
|
128
|
-
if (isPat(this.accessToken)) return;
|
|
129
|
-
if (!this.configStore) return;
|
|
130
|
-
let config: Config;
|
|
131
|
-
try {
|
|
132
|
-
config = this.configStore.read();
|
|
133
|
-
} catch {
|
|
134
|
-
return; // no config file yet — continue with the static token
|
|
135
|
-
}
|
|
136
|
-
const nowSecs = Math.floor(Date.now() / 1000);
|
|
137
|
-
if (config.expiresAt < nowSecs + 60) {
|
|
138
|
-
await this.triggerRefresh(config);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Ensures only one refresh is in-flight at a time.
|
|
144
|
-
* Concurrent callers await the same promise.
|
|
145
|
-
*/
|
|
146
|
-
private triggerRefresh(config?: Config): Promise<void> {
|
|
147
|
-
if (!this.refreshPromise) {
|
|
148
|
-
this.refreshPromise = this.performRefresh(config).finally(() => {
|
|
149
|
-
this.refreshPromise = null;
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
return this.refreshPromise;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
private async performRefresh(existingConfig?: Config): Promise<void> {
|
|
156
|
-
if (!this.configStore) return;
|
|
157
|
-
|
|
158
|
-
let config: Config;
|
|
159
|
-
try {
|
|
160
|
-
config = existingConfig ?? this.configStore.read();
|
|
161
|
-
} catch {
|
|
162
|
-
throw new SessionExpiredError();
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
|
|
166
|
-
method: "POST",
|
|
167
|
-
headers: { "Content-Type": "application/json" },
|
|
168
|
-
body: JSON.stringify({ refreshToken: config.refreshToken }),
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
if (!response.ok) {
|
|
172
|
-
throw new SessionExpiredError();
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const { accessToken, refreshToken, expiresAt } =
|
|
176
|
-
(await response.json()) as RefreshTokenResponse;
|
|
177
|
-
|
|
178
|
-
this.accessToken = accessToken;
|
|
179
|
-
this.configStore.write({ ...config, accessToken, refreshToken, expiresAt });
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// ── Core request dispatcher ────────────────────────────────────────────────
|
|
183
|
-
|
|
184
|
-
private async dispatchFetch(url: string, init: RequestInit): Promise<Response> {
|
|
185
|
-
await this.ensureTokenFresh();
|
|
186
|
-
|
|
187
|
-
let response = await fetch(url, { ...init, headers: this.authHeaders() });
|
|
188
|
-
|
|
189
|
-
if (response.status === 401) {
|
|
190
|
-
// PATs never refresh — a 401 means the token is revoked or expired.
|
|
191
|
-
// Surface a targeted error pointing at the regen URL rather than the
|
|
192
|
-
// generic "session expired, run nexvora login" message that suits
|
|
193
|
-
// JWT users.
|
|
194
|
-
if (isPat(this.accessToken)) {
|
|
195
|
-
throw new PatRevokedOrExpiredError();
|
|
196
|
-
}
|
|
197
|
-
// JWT path: try one refresh then retry the original request once.
|
|
198
|
-
if (this.configStore) {
|
|
199
|
-
await this.triggerRefresh();
|
|
200
|
-
response = await fetch(url, { ...init, headers: this.authHeaders() });
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// 403 with type=pat-scope-missing carries the exact scope the PAT lacks.
|
|
205
|
-
// Pull it out so we can throw a typed error that downstream tools can
|
|
206
|
-
// render as "your PAT needs tool:foo — regenerate at <url>".
|
|
207
|
-
if (response.status === 403 && isPat(this.accessToken)) {
|
|
208
|
-
const scope = await readMissingScope(response);
|
|
209
|
-
if (scope != null) {
|
|
210
|
-
throw new PatScopeMissingError(scope);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return response;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// ── Public API ─────────────────────────────────────────────────────────────
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Sends an audit event to {@code POST /mcp/audit} fire-and-forget.
|
|
221
|
-
* Errors are silently swallowed to prevent audit failures from affecting tool callers.
|
|
222
|
-
*/
|
|
223
|
-
async sendAudit(payload: AuditPayload): Promise<void> {
|
|
224
|
-
try {
|
|
225
|
-
const validated = AuditPayloadSchema.parse(payload);
|
|
226
|
-
await fetch(`${this.baseUrl}/mcp/audit`, {
|
|
227
|
-
method: "POST",
|
|
228
|
-
headers: this.authHeaders(),
|
|
229
|
-
body: JSON.stringify(validated),
|
|
230
|
-
});
|
|
231
|
-
} catch {
|
|
232
|
-
// audit failures must not surface to the tool caller
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Makes an authenticated POST request to the NexVora backend.
|
|
238
|
-
*
|
|
239
|
-
* @throws {NexvoraApiError} on non-2xx responses
|
|
240
|
-
* @throws {SessionExpiredError} when the refresh token is also expired
|
|
241
|
-
*/
|
|
242
|
-
async post<T>(path: string, body: unknown): Promise<T> {
|
|
243
|
-
const response = await this.dispatchFetch(`${this.baseUrl}${path}`, {
|
|
244
|
-
method: "POST",
|
|
245
|
-
body: JSON.stringify(body),
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
if (!response.ok) {
|
|
249
|
-
const text = await response.text().catch(() => "");
|
|
250
|
-
throw new NexvoraApiError(response.status, text, path);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
return response.json() as Promise<T>;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Makes an authenticated GET request to the NexVora backend.
|
|
258
|
-
*
|
|
259
|
-
* @throws {NexvoraApiError} on non-2xx responses
|
|
260
|
-
* @throws {SessionExpiredError} when the refresh token is also expired
|
|
261
|
-
*/
|
|
262
|
-
async get<T>(path: string): Promise<T> {
|
|
263
|
-
const response = await this.dispatchFetch(`${this.baseUrl}${path}`, {});
|
|
264
|
-
|
|
265
|
-
if (!response.ok) {
|
|
266
|
-
const text = await response.text().catch(() => "");
|
|
267
|
-
throw new NexvoraApiError(response.status, text, path);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
return response.json() as Promise<T>;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Reads a 403 response body as RFC 7807 ProblemDetail and pulls the
|
|
276
|
-
* {@code required_scope} property out. Returns {@code null} for any other
|
|
277
|
-
* shape — the caller falls back to the generic error path.
|
|
278
|
-
*
|
|
279
|
-
* <p>The response body is consumed by this call; the dispatcher must not
|
|
280
|
-
* attempt to read it again. We clone first so the original {@link Response}
|
|
281
|
-
* remains usable if no scope is found.</p>
|
|
282
|
-
*/
|
|
283
|
-
async function readMissingScope(response: Response): Promise<string | null> {
|
|
284
|
-
try {
|
|
285
|
-
const cloned = response.clone();
|
|
286
|
-
const body = (await cloned.json()) as {
|
|
287
|
-
type?: string;
|
|
288
|
-
required_scope?: string;
|
|
289
|
-
};
|
|
290
|
-
if (
|
|
291
|
-
typeof body?.required_scope === "string" &&
|
|
292
|
-
body.type?.includes("pat-scope-missing")
|
|
293
|
-
) {
|
|
294
|
-
return body.required_scope;
|
|
295
|
-
}
|
|
296
|
-
} catch {
|
|
297
|
-
// not JSON, or fetch couldn't be cloned — fall through
|
|
298
|
-
}
|
|
299
|
-
return null;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* Represents a non-2xx response from the NexVora API.
|
|
304
|
-
*/
|
|
305
|
-
export class NexvoraApiError extends Error {
|
|
306
|
-
constructor(
|
|
307
|
-
public readonly statusCode: number,
|
|
308
|
-
public readonly body: string,
|
|
309
|
-
public readonly path: string,
|
|
310
|
-
) {
|
|
311
|
-
super(`NexVora API error ${statusCode} on ${path}: ${body}`);
|
|
312
|
-
this.name = "NexvoraApiError";
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
get isRateLimited(): boolean {
|
|
316
|
-
return this.statusCode === 429;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
get isUnauthorized(): boolean {
|
|
320
|
-
return this.statusCode === 401 || this.statusCode === 403;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
toAuditOutcome(): AuditOutcome {
|
|
324
|
-
if (this.isRateLimited) return "rate_limited";
|
|
325
|
-
if (this.isUnauthorized) return "unauthorized";
|
|
326
|
-
return "error";
|
|
327
|
-
}
|
|
328
|
-
}
|
package/src/RateLimiter.ts
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
export const DEFAULT_RATE_LIMITS: Record<string, number> = {
|
|
2
|
-
nexvora_wallet_balance: 60,
|
|
3
|
-
nexvora_observatory: 60,
|
|
4
|
-
nexvora_agentstack_search: 30,
|
|
5
|
-
nexvora_agentstack_ask: 5,
|
|
6
|
-
nexvora_agentstack_answer: 10,
|
|
7
|
-
nexvora_feed_post: 10,
|
|
8
|
-
nexvora_feed_react: 60,
|
|
9
|
-
nexvora_consulting_search: 30,
|
|
10
|
-
nexvora_consulting_book: 5,
|
|
11
|
-
nexvora_knowledge_search: 30,
|
|
12
|
-
nexvora_knowledge_subscribe: 5,
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export type ConsumeResult = { allowed: true } | { allowed: false; retryAfterMs: number };
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Smooth token-bucket with continuous refill.
|
|
19
|
-
*
|
|
20
|
-
* Capacity = perMinuteLimit (burst up to the full window's worth of tokens).
|
|
21
|
-
* Refill rate = perMinuteLimit / 60 tokens-per-second (smooth, not batch).
|
|
22
|
-
*/
|
|
23
|
-
export class TokenBucket {
|
|
24
|
-
private tokens: number;
|
|
25
|
-
private lastRefill: number;
|
|
26
|
-
|
|
27
|
-
constructor(
|
|
28
|
-
private readonly capacity: number,
|
|
29
|
-
private readonly refillRatePerSec: number,
|
|
30
|
-
) {
|
|
31
|
-
this.tokens = capacity;
|
|
32
|
-
this.lastRefill = Date.now();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
tryConsume(): ConsumeResult {
|
|
36
|
-
this.refill();
|
|
37
|
-
if (this.tokens >= 1) {
|
|
38
|
-
this.tokens -= 1;
|
|
39
|
-
return { allowed: true };
|
|
40
|
-
}
|
|
41
|
-
// milliseconds until 1 token is available
|
|
42
|
-
const retryAfterMs = Math.ceil(((1 - this.tokens) / this.refillRatePerSec) * 1000);
|
|
43
|
-
return { allowed: false, retryAfterMs };
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
private refill(): void {
|
|
47
|
-
const now = Date.now();
|
|
48
|
-
const elapsedSec = (now - this.lastRefill) / 1000;
|
|
49
|
-
this.tokens = Math.min(this.capacity, this.tokens + elapsedSec * this.refillRatePerSec);
|
|
50
|
-
this.lastRefill = now;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* One TokenBucket per registered tool name.
|
|
56
|
-
* Tools not in the registry are always allowed.
|
|
57
|
-
*/
|
|
58
|
-
export class RateLimiterRegistry {
|
|
59
|
-
private readonly buckets = new Map<string, TokenBucket>();
|
|
60
|
-
|
|
61
|
-
constructor(limits: Record<string, number> = DEFAULT_RATE_LIMITS) {
|
|
62
|
-
for (const [toolName, perMinute] of Object.entries(limits)) {
|
|
63
|
-
if (perMinute > 0) {
|
|
64
|
-
this.buckets.set(toolName, new TokenBucket(perMinute, perMinute / 60));
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
tryConsume(toolName: string): ConsumeResult {
|
|
70
|
-
const bucket = this.buckets.get(toolName);
|
|
71
|
-
if (!bucket) return { allowed: true };
|
|
72
|
-
return bucket.tryConsume();
|
|
73
|
-
}
|
|
74
|
-
}
|