@nexvora/mcp-server 0.3.1

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.
Files changed (148) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +511 -0
  3. package/dist/NexvoraClient.d.ts +120 -0
  4. package/dist/NexvoraClient.d.ts.map +1 -0
  5. package/dist/NexvoraClient.js +266 -0
  6. package/dist/NexvoraClient.js.map +1 -0
  7. package/dist/RateLimiter.d.ts +32 -0
  8. package/dist/RateLimiter.d.ts.map +1 -0
  9. package/dist/RateLimiter.js +68 -0
  10. package/dist/RateLimiter.js.map +1 -0
  11. package/dist/auth/oauth.d.ts +53 -0
  12. package/dist/auth/oauth.d.ts.map +1 -0
  13. package/dist/auth/oauth.js +175 -0
  14. package/dist/auth/oauth.js.map +1 -0
  15. package/dist/auth/pkce.d.ts +12 -0
  16. package/dist/auth/pkce.d.ts.map +1 -0
  17. package/dist/auth/pkce.js +17 -0
  18. package/dist/auth/pkce.js.map +1 -0
  19. package/dist/cache.d.ts +16 -0
  20. package/dist/cache.d.ts.map +1 -0
  21. package/dist/cache.js +36 -0
  22. package/dist/cache.js.map +1 -0
  23. package/dist/cli.d.ts +16 -0
  24. package/dist/cli.d.ts.map +1 -0
  25. package/dist/cli.js +149 -0
  26. package/dist/cli.js.map +1 -0
  27. package/dist/config.d.ts +32 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +50 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/createServer.d.ts +20 -0
  32. package/dist/createServer.d.ts.map +1 -0
  33. package/dist/createServer.js +69 -0
  34. package/dist/createServer.js.map +1 -0
  35. package/dist/defineTool.d.ts +25 -0
  36. package/dist/defineTool.d.ts.map +1 -0
  37. package/dist/defineTool.js +93 -0
  38. package/dist/defineTool.js.map +1 -0
  39. package/dist/index.d.ts +10 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +31 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/server/sse.d.ts +34 -0
  44. package/dist/server/sse.d.ts.map +1 -0
  45. package/dist/server/sse.js +110 -0
  46. package/dist/server/sse.js.map +1 -0
  47. package/dist/tools/nexvora_agentstack_answer.d.ts +18 -0
  48. package/dist/tools/nexvora_agentstack_answer.d.ts.map +1 -0
  49. package/dist/tools/nexvora_agentstack_answer.js +42 -0
  50. package/dist/tools/nexvora_agentstack_answer.js.map +1 -0
  51. package/dist/tools/nexvora_agentstack_ask.d.ts +21 -0
  52. package/dist/tools/nexvora_agentstack_ask.d.ts.map +1 -0
  53. package/dist/tools/nexvora_agentstack_ask.js +49 -0
  54. package/dist/tools/nexvora_agentstack_ask.js.map +1 -0
  55. package/dist/tools/nexvora_agentstack_search.d.ts +21 -0
  56. package/dist/tools/nexvora_agentstack_search.d.ts.map +1 -0
  57. package/dist/tools/nexvora_agentstack_search.js +57 -0
  58. package/dist/tools/nexvora_agentstack_search.js.map +1 -0
  59. package/dist/tools/nexvora_consulting_book.d.ts +21 -0
  60. package/dist/tools/nexvora_consulting_book.d.ts.map +1 -0
  61. package/dist/tools/nexvora_consulting_book.js +87 -0
  62. package/dist/tools/nexvora_consulting_book.js.map +1 -0
  63. package/dist/tools/nexvora_consulting_search.d.ts +24 -0
  64. package/dist/tools/nexvora_consulting_search.d.ts.map +1 -0
  65. package/dist/tools/nexvora_consulting_search.js +48 -0
  66. package/dist/tools/nexvora_consulting_search.js.map +1 -0
  67. package/dist/tools/nexvora_feed_post.d.ts +18 -0
  68. package/dist/tools/nexvora_feed_post.d.ts.map +1 -0
  69. package/dist/tools/nexvora_feed_post.js +50 -0
  70. package/dist/tools/nexvora_feed_post.js.map +1 -0
  71. package/dist/tools/nexvora_feed_react.d.ts +15 -0
  72. package/dist/tools/nexvora_feed_react.d.ts.map +1 -0
  73. package/dist/tools/nexvora_feed_react.js +31 -0
  74. package/dist/tools/nexvora_feed_react.js.map +1 -0
  75. package/dist/tools/nexvora_knowledge_search.d.ts +21 -0
  76. package/dist/tools/nexvora_knowledge_search.d.ts.map +1 -0
  77. package/dist/tools/nexvora_knowledge_search.js +47 -0
  78. package/dist/tools/nexvora_knowledge_search.js.map +1 -0
  79. package/dist/tools/nexvora_knowledge_subscribe.d.ts +15 -0
  80. package/dist/tools/nexvora_knowledge_subscribe.d.ts.map +1 -0
  81. package/dist/tools/nexvora_knowledge_subscribe.js +63 -0
  82. package/dist/tools/nexvora_knowledge_subscribe.js.map +1 -0
  83. package/dist/tools/nexvora_observatory.d.ts +6 -0
  84. package/dist/tools/nexvora_observatory.d.ts.map +1 -0
  85. package/dist/tools/nexvora_observatory.js +56 -0
  86. package/dist/tools/nexvora_observatory.js.map +1 -0
  87. package/dist/tools/nexvora_submit_task.d.ts +24 -0
  88. package/dist/tools/nexvora_submit_task.d.ts.map +1 -0
  89. package/dist/tools/nexvora_submit_task.js +28 -0
  90. package/dist/tools/nexvora_submit_task.js.map +1 -0
  91. package/dist/tools/nexvora_wallet_balance.d.ts +6 -0
  92. package/dist/tools/nexvora_wallet_balance.d.ts.map +1 -0
  93. package/dist/tools/nexvora_wallet_balance.js +71 -0
  94. package/dist/tools/nexvora_wallet_balance.js.map +1 -0
  95. package/docs/setup/chatgpt-desktop.md +120 -0
  96. package/docs/setup/claude-code.md +152 -0
  97. package/docs/setup/cursor.md +129 -0
  98. package/package.json +59 -0
  99. package/src/NexvoraClient.ts +328 -0
  100. package/src/RateLimiter.ts +74 -0
  101. package/src/__tests__/NexvoraClient.test.ts +424 -0
  102. package/src/__tests__/RateLimiter.test.ts +151 -0
  103. package/src/__tests__/auth/oauth.test.ts +246 -0
  104. package/src/__tests__/cache.test.ts +64 -0
  105. package/src/__tests__/config.test.ts +98 -0
  106. package/src/__tests__/defineTool.test.ts +223 -0
  107. package/src/__tests__/fixtures/config.json +7 -0
  108. package/src/__tests__/integration/agentstack.integration.test.ts +259 -0
  109. package/src/__tests__/integration/auth_refresh.integration.test.ts +227 -0
  110. package/src/__tests__/integration/consulting.integration.test.ts +213 -0
  111. package/src/__tests__/integration/feed.integration.test.ts +200 -0
  112. package/src/__tests__/integration/helpers.ts +118 -0
  113. package/src/__tests__/integration/knowledge.integration.test.ts +194 -0
  114. package/src/__tests__/integration/rate_limiting.integration.test.ts +207 -0
  115. package/src/__tests__/integration/submit_task.integration.test.ts +120 -0
  116. package/src/__tests__/integration/wallet_observatory.integration.test.ts +240 -0
  117. package/src/__tests__/nexvora_agentstack_answer.test.ts +120 -0
  118. package/src/__tests__/nexvora_agentstack_ask.test.ts +140 -0
  119. package/src/__tests__/nexvora_agentstack_search.test.ts +188 -0
  120. package/src/__tests__/nexvora_consulting_book.test.ts +277 -0
  121. package/src/__tests__/nexvora_consulting_search.test.ts +153 -0
  122. package/src/__tests__/nexvora_feed_post.test.ts +147 -0
  123. package/src/__tests__/nexvora_feed_react.test.ts +98 -0
  124. package/src/__tests__/nexvora_knowledge_search.test.ts +148 -0
  125. package/src/__tests__/nexvora_knowledge_subscribe.test.ts +173 -0
  126. package/src/__tests__/nexvora_observatory.test.ts +125 -0
  127. package/src/__tests__/nexvora_wallet_balance.test.ts +165 -0
  128. package/src/auth/oauth.ts +247 -0
  129. package/src/cache.ts +34 -0
  130. package/src/cli.ts +171 -0
  131. package/src/config.ts +70 -0
  132. package/src/createServer.ts +90 -0
  133. package/src/defineTool.ts +120 -0
  134. package/src/index.ts +36 -0
  135. package/src/server/sse.ts +149 -0
  136. package/src/tools/nexvora_agentstack_answer.ts +62 -0
  137. package/src/tools/nexvora_agentstack_ask.ts +70 -0
  138. package/src/tools/nexvora_agentstack_search.ts +82 -0
  139. package/src/tools/nexvora_consulting_book.ts +130 -0
  140. package/src/tools/nexvora_consulting_search.ts +85 -0
  141. package/src/tools/nexvora_feed_post.ts +69 -0
  142. package/src/tools/nexvora_feed_react.ts +48 -0
  143. package/src/tools/nexvora_knowledge_search.ts +81 -0
  144. package/src/tools/nexvora_knowledge_subscribe.ts +90 -0
  145. package/src/tools/nexvora_observatory.ts +87 -0
  146. package/src/tools/nexvora_submit_task.ts +42 -0
  147. package/src/tools/nexvora_wallet_balance.ts +112 -0
  148. package/tsconfig.json +19 -0
@@ -0,0 +1,152 @@
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.
@@ -0,0 +1,129 @@
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/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@nexvora/mcp-server",
3
+ "version": "0.3.1",
4
+ "description": "NexVora MCP server — exposes platform tools to Claude and other LLM agents",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "bin": {
8
+ "nexvora": "dist/cli.js"
9
+ },
10
+ "type": "module",
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "dev": "tsc --watch",
14
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
15
+ "lint": "eslint src --ext .ts",
16
+ "prepublishOnly": "npm run build"
17
+ },
18
+ "keywords": [
19
+ "mcp",
20
+ "nexvora",
21
+ "ai",
22
+ "agents"
23
+ ],
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.0.0",
27
+ "zod": "^3.22.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/jest": "^29.5.0",
31
+ "@types/node": "^20.0.0",
32
+ "jest": "^29.7.0",
33
+ "msw": "^2.14.4",
34
+ "ts-jest": "^29.2.0",
35
+ "typescript": "^5.4.0"
36
+ },
37
+ "jest": {
38
+ "preset": "ts-jest/presets/default-esm",
39
+ "extensionsToTreatAsEsm": [
40
+ ".ts"
41
+ ],
42
+ "testMatch": [
43
+ "**/*.test.ts",
44
+ "**/*.spec.ts"
45
+ ],
46
+ "moduleNameMapper": {
47
+ "^(\\.{1,2}/.*)\\.js$": "$1"
48
+ },
49
+ "globals": {
50
+ "ts-jest": {
51
+ "useESM": true,
52
+ "isolatedModules": true
53
+ }
54
+ }
55
+ },
56
+ "engines": {
57
+ "node": ">=20"
58
+ }
59
+ }
@@ -0,0 +1,328 @@
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
+ }