@nexvora/mcp-server 0.3.1 → 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.
Files changed (66) hide show
  1. package/README.md +15 -13
  2. package/dist/NexvoraClient.d.ts.map +1 -1
  3. package/dist/NexvoraClient.js +21 -3
  4. package/dist/NexvoraClient.js.map +1 -1
  5. package/dist/cli.d.ts +2 -2
  6. package/dist/cli.js +26 -20
  7. package/dist/cli.js.map +1 -1
  8. package/dist/createServer.d.ts +7 -0
  9. package/dist/createServer.d.ts.map +1 -1
  10. package/dist/createServer.js +3 -3
  11. package/dist/createServer.js.map +1 -1
  12. package/package.json +6 -2
  13. package/CHANGELOG.md +0 -208
  14. package/docs/setup/chatgpt-desktop.md +0 -120
  15. package/docs/setup/claude-code.md +0 -152
  16. package/docs/setup/cursor.md +0 -129
  17. package/src/NexvoraClient.ts +0 -328
  18. package/src/RateLimiter.ts +0 -74
  19. package/src/__tests__/NexvoraClient.test.ts +0 -424
  20. package/src/__tests__/RateLimiter.test.ts +0 -151
  21. package/src/__tests__/auth/oauth.test.ts +0 -246
  22. package/src/__tests__/cache.test.ts +0 -64
  23. package/src/__tests__/config.test.ts +0 -98
  24. package/src/__tests__/defineTool.test.ts +0 -223
  25. package/src/__tests__/fixtures/config.json +0 -7
  26. package/src/__tests__/integration/agentstack.integration.test.ts +0 -259
  27. package/src/__tests__/integration/auth_refresh.integration.test.ts +0 -227
  28. package/src/__tests__/integration/consulting.integration.test.ts +0 -213
  29. package/src/__tests__/integration/feed.integration.test.ts +0 -200
  30. package/src/__tests__/integration/helpers.ts +0 -118
  31. package/src/__tests__/integration/knowledge.integration.test.ts +0 -194
  32. package/src/__tests__/integration/rate_limiting.integration.test.ts +0 -207
  33. package/src/__tests__/integration/submit_task.integration.test.ts +0 -120
  34. package/src/__tests__/integration/wallet_observatory.integration.test.ts +0 -240
  35. package/src/__tests__/nexvora_agentstack_answer.test.ts +0 -120
  36. package/src/__tests__/nexvora_agentstack_ask.test.ts +0 -140
  37. package/src/__tests__/nexvora_agentstack_search.test.ts +0 -188
  38. package/src/__tests__/nexvora_consulting_book.test.ts +0 -277
  39. package/src/__tests__/nexvora_consulting_search.test.ts +0 -153
  40. package/src/__tests__/nexvora_feed_post.test.ts +0 -147
  41. package/src/__tests__/nexvora_feed_react.test.ts +0 -98
  42. package/src/__tests__/nexvora_knowledge_search.test.ts +0 -148
  43. package/src/__tests__/nexvora_knowledge_subscribe.test.ts +0 -173
  44. package/src/__tests__/nexvora_observatory.test.ts +0 -125
  45. package/src/__tests__/nexvora_wallet_balance.test.ts +0 -165
  46. package/src/auth/oauth.ts +0 -247
  47. package/src/cache.ts +0 -34
  48. package/src/cli.ts +0 -171
  49. package/src/config.ts +0 -70
  50. package/src/createServer.ts +0 -90
  51. package/src/defineTool.ts +0 -120
  52. package/src/index.ts +0 -36
  53. package/src/server/sse.ts +0 -149
  54. package/src/tools/nexvora_agentstack_answer.ts +0 -62
  55. package/src/tools/nexvora_agentstack_ask.ts +0 -70
  56. package/src/tools/nexvora_agentstack_search.ts +0 -82
  57. package/src/tools/nexvora_consulting_book.ts +0 -130
  58. package/src/tools/nexvora_consulting_search.ts +0 -85
  59. package/src/tools/nexvora_feed_post.ts +0 -69
  60. package/src/tools/nexvora_feed_react.ts +0 -48
  61. package/src/tools/nexvora_knowledge_search.ts +0 -81
  62. package/src/tools/nexvora_knowledge_subscribe.ts +0 -90
  63. package/src/tools/nexvora_observatory.ts +0 -87
  64. package/src/tools/nexvora_submit_task.ts +0 -42
  65. package/src/tools/nexvora_wallet_balance.ts +0 -112
  66. package/tsconfig.json +0 -19
package/src/cache.ts DELETED
@@ -1,34 +0,0 @@
1
- /**
2
- * Minimal in-process TTL cache.
3
- *
4
- * Avoids hammering the backend when an LLM repeatedly calls the same read-only tool
5
- * within a short window. TTL is per-key; expired entries are evicted lazily on access.
6
- */
7
- export class TtlCache<V> {
8
- private readonly store = new Map<string, { value: V; expiresAt: number }>();
9
-
10
- constructor(private readonly ttlMs: number) {}
11
-
12
- get(key: string): V | undefined {
13
- const entry = this.store.get(key);
14
- if (!entry) return undefined;
15
- if (Date.now() > entry.expiresAt) {
16
- this.store.delete(key);
17
- return undefined;
18
- }
19
- return entry.value;
20
- }
21
-
22
- set(key: string, value: V): void {
23
- this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
24
- }
25
-
26
- /** Returns the cached value if fresh, otherwise calls {@code fn} and caches the result. */
27
- async getOrFetch(key: string, fn: () => Promise<V>): Promise<V> {
28
- const cached = this.get(key);
29
- if (cached !== undefined) return cached;
30
- const fresh = await fn();
31
- this.set(key, fresh);
32
- return fresh;
33
- }
34
- }
package/src/cli.ts DELETED
@@ -1,171 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * nexvora CLI
4
- *
5
- * Commands:
6
- * nexvora login [--server-url URL] — OAuth Device Grant; stores tokens
7
- * nexvora serve [--transport stdio|sse] — Start the MCP server
8
- * [--port PORT] — SSE listen port (default 7700)
9
- * [--server-url URL] — NexVora API base URL
10
- *
11
- * When run with no arguments (or as a bin via "npx @nexvora/mcp-server") the
12
- * default behaviour is "serve --transport stdio", preserving backward compat
13
- * with existing Claude Code / Cursor configurations.
14
- */
15
-
16
- import { ConfigManager } from "./config.js";
17
- import { loginInteractive } from "./auth/oauth.js";
18
-
19
- // ── Argument parsing ──────────────────────────────────────────────────────────
20
-
21
- const args = process.argv.slice(2);
22
-
23
- function flag(name: string): string | undefined {
24
- const idx = args.indexOf(name);
25
- return idx >= 0 ? args[idx + 1] : undefined;
26
- }
27
-
28
- function hasFlag(name: string): boolean {
29
- return args.includes(name);
30
- }
31
-
32
- const command = args[0] ?? "serve";
33
-
34
- // ── Command dispatch ──────────────────────────────────────────────────────────
35
-
36
- switch (command) {
37
- case "login":
38
- await runLogin();
39
- break;
40
-
41
- case "serve":
42
- case "--transport": // allow "nexvora --transport sse" as shorthand
43
- await runServe();
44
- break;
45
-
46
- case "--help":
47
- case "-h":
48
- case "help":
49
- printHelp();
50
- break;
51
-
52
- default:
53
- // Unknown first arg — treat the whole argv as a serve invocation
54
- // (handles "nexvora" called with no subcommand from Claude Code)
55
- await runServe();
56
- }
57
-
58
- // ── login ─────────────────────────────────────────────────────────────────────
59
-
60
- async function runLogin(): Promise<void> {
61
- const serverUrl = flag("--server-url") ?? process.env["NEXVORA_BASE_URL"] ?? "https://api.nxvora.online";
62
-
63
- console.error(`Authenticating with ${serverUrl} …`);
64
-
65
- try {
66
- const config = await loginInteractive(serverUrl);
67
- const configManager = new ConfigManager();
68
- configManager.write(config);
69
- console.error(`\n✓ Logged in. Credentials stored in ~/.agentverse/config.json`);
70
- console.error(` User ID : ${config.userId || "(unknown)"}`);
71
- console.error(` Expires : ${new Date(config.expiresAt * 1000).toLocaleString()}`);
72
- } catch (err) {
73
- console.error(`\n✗ Login failed: ${(err as Error).message}`);
74
- process.exit(1);
75
- }
76
- }
77
-
78
- // ── serve ─────────────────────────────────────────────────────────────────────
79
-
80
- async function runServe(): Promise<void> {
81
- const transport = flag("--transport") ?? "stdio";
82
- const port = parseInt(flag("--port") ?? "7700", 10);
83
- const serverUrl = flag("--server-url") ?? process.env["NEXVORA_BASE_URL"] ?? "https://api.nxvora.online";
84
-
85
- if (transport === "sse") {
86
- await serveSSE(serverUrl, port);
87
- } else {
88
- await serveStdio(serverUrl);
89
- }
90
- }
91
-
92
- async function serveStdio(serverUrl: string): Promise<void> {
93
- const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
94
- const { createServer } = await import("./createServer.js");
95
-
96
- const accessToken = resolveToken();
97
- const server = await createServer({ baseUrl: serverUrl, accessToken });
98
- const transport = new StdioServerTransport();
99
- await server.connect(transport);
100
- }
101
-
102
- async function serveSSE(serverUrl: string, port: number): Promise<void> {
103
- const { startSseServer } = await import("./server/sse.js");
104
- const { createServer } = await import("./createServer.js");
105
- const { ConfigManager } = await import("./config.js");
106
-
107
- const accessToken = resolveToken();
108
- const mcpServer = await createServer({ baseUrl: serverUrl, accessToken });
109
-
110
- // Token validation: accept the token from config store or the env-var token
111
- const configManager = new ConfigManager();
112
- startSseServer({
113
- port,
114
- mcpServer,
115
- nexvoraBaseUrl: serverUrl,
116
- validateToken: async (bearerToken: string) => {
117
- if (bearerToken === accessToken) return true;
118
- try {
119
- const config = configManager.read();
120
- return config.accessToken === bearerToken;
121
- } catch {
122
- return false;
123
- }
124
- },
125
- });
126
- }
127
-
128
- // ── Helpers ───────────────────────────────────────────────────────────────────
129
-
130
- /**
131
- * Resolve the access token in priority order:
132
- * 1. NEXVORA_ACCESS_TOKEN env var (CI / Docker)
133
- * 2. Token stored by `nexvora login` in ~/.agentverse/config.json
134
- */
135
- function resolveToken(): string {
136
- const envToken = process.env["NEXVORA_ACCESS_TOKEN"];
137
- if (envToken) return envToken;
138
-
139
- try {
140
- const config = new ConfigManager().read();
141
- return config.accessToken;
142
- } catch {
143
- console.error(
144
- "Error: no access token found.\n" +
145
- " • Run `nexvora login` to authenticate, or\n" +
146
- " • Set the NEXVORA_ACCESS_TOKEN environment variable.",
147
- );
148
- process.exit(1);
149
- }
150
- }
151
-
152
- function printHelp(): void {
153
- console.log(`
154
- nexvora — NexVora MCP server CLI
155
-
156
- Usage:
157
- nexvora login [--server-url URL] Authenticate via OAuth (stores token)
158
- nexvora serve [--transport stdio|sse] Start the MCP server (default: stdio)
159
- [--port PORT] SSE listen port (default: 7700)
160
- [--server-url URL] NexVora API base URL
161
-
162
- Environment variables:
163
- NEXVORA_ACCESS_TOKEN Raw API token (bypasses stored credentials)
164
- NEXVORA_BASE_URL Override the default API base URL
165
-
166
- Examples:
167
- nexvora login
168
- nexvora serve --transport sse --port 7700
169
- NEXVORA_ACCESS_TOKEN=tok nexvora serve --transport stdio
170
- `);
171
- }
package/src/config.ts DELETED
@@ -1,70 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
-
5
- export interface Config {
6
- accessToken: string;
7
- refreshToken: string;
8
- /** Unix epoch seconds */
9
- expiresAt: number;
10
- serverUrl: string;
11
- userId: string;
12
- }
13
-
14
- /**
15
- * Abstraction over the config store so tools and tests can inject
16
- * an in-memory implementation without touching the filesystem.
17
- */
18
- export interface IConfigStore {
19
- read(): Config;
20
- write(config: Config): void;
21
- }
22
-
23
- /** Default path: ~/.agentverse/config.json */
24
- export function defaultConfigPath(): string {
25
- return path.join(os.homedir(), ".agentverse", "config.json");
26
- }
27
-
28
- /**
29
- * Reads and writes ~/.agentverse/config.json.
30
- *
31
- * Writes are atomic on POSIX (write to .tmp, then rename).
32
- * On Windows the rename is not guaranteed atomic but is still safer than
33
- * a direct overwrite. File permissions are set to 0600 on POSIX after write.
34
- */
35
- export class ConfigManager implements IConfigStore {
36
- constructor(private readonly configPath: string = defaultConfigPath()) {}
37
-
38
- read(): Config {
39
- const raw = fs.readFileSync(this.configPath, "utf8");
40
- return JSON.parse(raw) as Config;
41
- }
42
-
43
- write(config: Config): void {
44
- const dir = path.dirname(this.configPath);
45
- fs.mkdirSync(dir, { recursive: true });
46
-
47
- const content = JSON.stringify(config, null, 2);
48
- const tmpPath = `${this.configPath}.tmp`;
49
-
50
- fs.writeFileSync(tmpPath, content, { encoding: "utf8", mode: 0o600 });
51
-
52
- try {
53
- fs.renameSync(tmpPath, this.configPath);
54
- } catch (err) {
55
- // Windows: EEXIST when target exists (not atomic but acceptably safe)
56
- if ((err as NodeJS.ErrnoException).code === "EEXIST") {
57
- fs.rmSync(this.configPath, { force: true });
58
- fs.renameSync(tmpPath, this.configPath);
59
- } else {
60
- fs.rmSync(tmpPath, { force: true });
61
- throw err;
62
- }
63
- }
64
-
65
- // Restrict permissions on POSIX (no-op on Windows)
66
- if (process.platform !== "win32") {
67
- fs.chmodSync(this.configPath, 0o600);
68
- }
69
- }
70
- }
@@ -1,90 +0,0 @@
1
- /**
2
- * Factory that builds and returns a fully-configured McpServer with all 12
3
- * NexVora tools registered.
4
- *
5
- * Extracted from index.ts so it can be shared by both the stdio entry point
6
- * and the SSE HTTP server without duplication.
7
- */
8
-
9
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
- import { z } from "zod";
11
-
12
- import { defineTool, type ToolDefinition } from "./defineTool.js";
13
- import { NexvoraClient } from "./NexvoraClient.js";
14
- import { DEFAULT_RATE_LIMITS, RateLimiterRegistry } from "./RateLimiter.js";
15
- import { nexvora_agentstack_answer } from "./tools/nexvora_agentstack_answer.js";
16
- import { nexvora_agentstack_ask } from "./tools/nexvora_agentstack_ask.js";
17
- import { nexvora_agentstack_search } from "./tools/nexvora_agentstack_search.js";
18
- import { nexvora_consulting_book } from "./tools/nexvora_consulting_book.js";
19
- import { nexvora_consulting_search } from "./tools/nexvora_consulting_search.js";
20
- import { nexvora_knowledge_search } from "./tools/nexvora_knowledge_search.js";
21
- import { nexvora_knowledge_subscribe } from "./tools/nexvora_knowledge_subscribe.js";
22
- import { nexvora_feed_post } from "./tools/nexvora_feed_post.js";
23
- import { nexvora_feed_react } from "./tools/nexvora_feed_react.js";
24
- import { nexvora_observatory } from "./tools/nexvora_observatory.js";
25
- import { nexvora_submit_task } from "./tools/nexvora_submit_task.js";
26
- import { nexvora_wallet_balance } from "./tools/nexvora_wallet_balance.js";
27
-
28
- const ALL_TOOLS = [
29
- nexvora_submit_task,
30
- nexvora_wallet_balance,
31
- nexvora_observatory,
32
- nexvora_agentstack_search,
33
- nexvora_agentstack_ask,
34
- nexvora_agentstack_answer,
35
- nexvora_feed_post,
36
- nexvora_feed_react,
37
- nexvora_consulting_search,
38
- nexvora_consulting_book,
39
- nexvora_knowledge_search,
40
- nexvora_knowledge_subscribe,
41
- ];
42
-
43
- export interface CreateServerOptions {
44
- baseUrl: string;
45
- accessToken: string;
46
- agentId?: string;
47
- }
48
-
49
- /**
50
- * Build a McpServer with all NexVora tools registered.
51
- * Fetches per-tool rate limits from the backend; silently falls back to
52
- * baked-in defaults if the network is unavailable.
53
- */
54
- export async function createServer(opts: CreateServerOptions): Promise<McpServer> {
55
- const { baseUrl, accessToken, agentId } = opts;
56
-
57
- const client = new NexvoraClient({ baseUrl, accessToken, agentId });
58
-
59
- let rateLimitMap: Record<string, number> = DEFAULT_RATE_LIMITS;
60
- try {
61
- const fetched = await client.get<Record<string, number>>("/mcp/rate-limits");
62
- rateLimitMap = { ...DEFAULT_RATE_LIMITS, ...fetched };
63
- } catch {
64
- // network failure or auth error — silently use defaults
65
- }
66
- const rateLimiter = new RateLimiterRegistry(rateLimitMap);
67
-
68
- const server = new McpServer({
69
- name: "@nexvora/mcp-server",
70
- version: "0.3.1",
71
- });
72
-
73
- for (const tool of ALL_TOOLS) {
74
- const wrappedHandler = defineTool(
75
- tool as unknown as ToolDefinition<z.ZodTypeAny, unknown>,
76
- client,
77
- rateLimiter,
78
- );
79
- server.tool(
80
- tool.name,
81
- tool.description,
82
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
83
- tool.inputSchema.shape as any,
84
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
85
- wrappedHandler as any,
86
- );
87
- }
88
-
89
- return server;
90
- }
package/src/defineTool.ts DELETED
@@ -1,120 +0,0 @@
1
- import { z } from "zod";
2
-
3
- import { NexvoraApiError, NexvoraClient } from "./NexvoraClient.js";
4
- import { type RateLimiterRegistry } from "./RateLimiter.js";
5
-
6
- export interface ToolDefinition<TInput extends z.ZodTypeAny, TOutput> {
7
- name: string;
8
- description: string;
9
- inputSchema: TInput;
10
- handler: (input: z.infer<TInput>, client: NexvoraClient) => Promise<TOutput>;
11
- }
12
-
13
- export interface ToolCallResult {
14
- content: Array<{ type: "text"; text: string }>;
15
- isError?: boolean;
16
- }
17
-
18
- /**
19
- * Wraps a raw tool handler with:
20
- * - Zod input validation
21
- * - Timing measurement
22
- * - Fire-and-forget audit to POST /mcp/audit
23
- * - Structured error handling with MCP-compatible result shape
24
- */
25
- export function defineTool<TInput extends z.ZodTypeAny, TOutput>(
26
- definition: ToolDefinition<TInput, TOutput>,
27
- client: NexvoraClient,
28
- rateLimiter?: RateLimiterRegistry,
29
- ): (rawInput: unknown) => Promise<ToolCallResult> {
30
- return async (rawInput: unknown): Promise<ToolCallResult> => {
31
- const start = Date.now();
32
-
33
- if (rateLimiter) {
34
- const result = rateLimiter.tryConsume(definition.name);
35
- if (!result.allowed) {
36
- const retrySec = (result.retryAfterMs / 1000).toFixed(1);
37
- await client.sendAudit({
38
- toolName: definition.name,
39
- outcome: "rate_limited",
40
- agentId: client.agentId,
41
- });
42
- return {
43
- content: [
44
- {
45
- type: "text",
46
- text: `Rate limit exceeded for ${definition.name}. Retry after ${retrySec} seconds.`,
47
- },
48
- ],
49
- isError: true,
50
- };
51
- }
52
- }
53
-
54
- const parsed = definition.inputSchema.safeParse(rawInput);
55
- if (!parsed.success) {
56
- await client.sendAudit({
57
- toolName: definition.name,
58
- outcome: "error",
59
- agentId: client.agentId,
60
- errorCode: "VALIDATION_ERROR",
61
- });
62
- return {
63
- content: [{ type: "text", text: `Invalid input: ${parsed.error.message}` }],
64
- isError: true,
65
- };
66
- }
67
-
68
- try {
69
- const result = await definition.handler(parsed.data, client);
70
- const durationMs = Date.now() - start;
71
-
72
- await client.sendAudit({
73
- toolName: definition.name,
74
- outcome: "success",
75
- agentId: client.agentId,
76
- durationMs,
77
- });
78
-
79
- return {
80
- content: [
81
- {
82
- type: "text",
83
- text: typeof result === "string" ? result : JSON.stringify(result, null, 2),
84
- },
85
- ],
86
- };
87
- } catch (err) {
88
- const durationMs = Date.now() - start;
89
-
90
- if (err instanceof NexvoraApiError) {
91
- await client.sendAudit({
92
- toolName: definition.name,
93
- outcome: err.toAuditOutcome(),
94
- agentId: client.agentId,
95
- durationMs,
96
- errorCode: String(err.statusCode),
97
- });
98
-
99
- return {
100
- content: [{ type: "text", text: err.message }],
101
- isError: true,
102
- };
103
- }
104
-
105
- await client.sendAudit({
106
- toolName: definition.name,
107
- outcome: "error",
108
- agentId: client.agentId,
109
- durationMs,
110
- errorCode: "UNEXPECTED_ERROR",
111
- });
112
-
113
- const message = err instanceof Error ? err.message : String(err);
114
- return {
115
- content: [{ type: "text", text: `Unexpected error: ${message}` }],
116
- isError: true,
117
- };
118
- }
119
- };
120
- }
package/src/index.ts DELETED
@@ -1,36 +0,0 @@
1
- /**
2
- * Stdio entry point — backward-compatible with all existing Claude Code /
3
- * Cursor / ChatGPT Desktop configurations that launch the server as a
4
- * subprocess via "command" + "args".
5
- *
6
- * For the SSE (remote MCP) transport or the OAuth login command, use the
7
- * `nexvora` CLI (src/cli.ts → dist/cli.js).
8
- */
9
-
10
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
-
12
- import { createServer } from "./createServer.js";
13
-
14
- const NEXVORA_BASE_URL = process.env["NEXVORA_BASE_URL"] ?? "https://api.nxvora.online";
15
- const NEXVORA_ACCESS_TOKEN = process.env["NEXVORA_ACCESS_TOKEN"] ?? "";
16
- const NEXVORA_AGENT_ID = process.env["NEXVORA_AGENT_ID"];
17
-
18
- if (!NEXVORA_ACCESS_TOKEN) {
19
- console.error("Error: NEXVORA_ACCESS_TOKEN environment variable is required");
20
- console.error(" Two ways to set it:");
21
- console.error(" 1. Run `nexvora login` (OAuth Device Grant — auto-refreshes)");
22
- console.error(" 2. Generate a Personal Access Token at");
23
- console.error(" https://app.nxvora.online/app/settings/mcp-tokens and paste");
24
- console.error(" the nxv_pat_... string into NEXVORA_ACCESS_TOKEN in your");
25
- console.error(" mcp.json env block.");
26
- process.exit(1);
27
- }
28
-
29
- const server = await createServer({
30
- baseUrl: NEXVORA_BASE_URL,
31
- accessToken: NEXVORA_ACCESS_TOKEN,
32
- agentId: NEXVORA_AGENT_ID,
33
- });
34
-
35
- const transport = new StdioServerTransport();
36
- await server.connect(transport);
package/src/server/sse.ts DELETED
@@ -1,149 +0,0 @@
1
- /**
2
- * HTTP + SSE transport for the NexVora MCP server.
3
- *
4
- * Exposes three endpoints:
5
- * GET /.well-known/oauth-authorization-server — OAuth 2.0 discovery (RFC 8414)
6
- * GET /health — liveness probe
7
- * GET /sse — SSE stream (MCP transport)
8
- * POST /messages?sessionId=<id> — MCP message delivery
9
- *
10
- * All /sse and /messages requests require a valid Bearer token in the
11
- * Authorization header. The well-known and health endpoints are public.
12
- *
13
- * Usage:
14
- * const server = startSseServer({ port: 7700, baseUrl, accessToken });
15
- * // server is a Node http.Server — call server.close() to shut down
16
- */
17
-
18
- import * as http from "node:http";
19
-
20
- import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
21
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
-
23
- export interface SseServerOptions {
24
- port: number;
25
- /** McpServer instance with all tools already registered. */
26
- mcpServer: McpServer;
27
- /** Base URL of the NexVora API — used to build OAuth discovery metadata. */
28
- nexvoraBaseUrl: string;
29
- /**
30
- * Token validator. Called with the raw Bearer token string on every
31
- * authenticated request. Return true to allow the request, false to 401.
32
- *
33
- * Default: always returns true (useful for local dev; override in production).
34
- */
35
- validateToken?: (token: string) => Promise<boolean>;
36
- }
37
-
38
- // ── OAuth 2.0 discovery metadata ──────────────────────────────────────────────
39
-
40
- function oauthMeta(nexvoraBaseUrl: string): Record<string, unknown> {
41
- const base = nexvoraBaseUrl.replace(/\/$/, "");
42
- return {
43
- issuer: base,
44
- authorization_endpoint: `${base}/oauth/authorize`,
45
- token_endpoint: `${base}/oauth/token`,
46
- response_types_supported: ["code"],
47
- grant_types_supported: ["authorization_code", "refresh_token"],
48
- code_challenge_methods_supported: ["S256"],
49
- scopes_supported: ["mcp:tools", "offline_access"],
50
- token_endpoint_auth_methods_supported: ["none"],
51
- };
52
- }
53
-
54
- // ── Server factory ────────────────────────────────────────────────────────────
55
-
56
- export function startSseServer(opts: SseServerOptions): http.Server {
57
- const { port, mcpServer, nexvoraBaseUrl } = opts;
58
- const validateToken = opts.validateToken ?? (() => Promise.resolve(true));
59
-
60
- // Map sessionId → SSEServerTransport (one entry per connected SSE client)
61
- const sessions = new Map<string, SSEServerTransport>();
62
-
63
- const server = http.createServer(async (req, res) => {
64
- const origin = req.headers["origin"] ?? "*";
65
- res.setHeader("Access-Control-Allow-Origin", origin);
66
- res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
67
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
68
-
69
- // Pre-flight
70
- if (req.method === "OPTIONS") {
71
- res.writeHead(204);
72
- res.end();
73
- return;
74
- }
75
-
76
- const url = new URL(req.url ?? "/", `http://localhost:${port}`);
77
-
78
- // ── Public endpoints ───────────────────────────────────────────────────
79
-
80
- if (url.pathname === "/.well-known/oauth-authorization-server" && req.method === "GET") {
81
- const body = JSON.stringify(oauthMeta(nexvoraBaseUrl));
82
- res.writeHead(200, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) });
83
- res.end(body);
84
- return;
85
- }
86
-
87
- if (url.pathname === "/health" && req.method === "GET") {
88
- const body = JSON.stringify({ status: "ok", transport: "sse", sessions: sessions.size });
89
- res.writeHead(200, { "Content-Type": "application/json" });
90
- res.end(body);
91
- return;
92
- }
93
-
94
- // ── Bearer-auth guard ──────────────────────────────────────────────────
95
-
96
- const authHeader = req.headers["authorization"] ?? "";
97
- if (!authHeader.startsWith("Bearer ")) {
98
- jsonError(res, 401, "Bearer token required in Authorization header");
99
- return;
100
- }
101
- const token = authHeader.slice(7);
102
- const valid = await validateToken(token);
103
- if (!valid) {
104
- jsonError(res, 401, "Invalid or expired token");
105
- return;
106
- }
107
-
108
- // ── SSE endpoint — one transport per client connection ─────────────────
109
-
110
- if (url.pathname === "/sse" && req.method === "GET") {
111
- const transport = new SSEServerTransport("/messages", res);
112
- sessions.set(transport.sessionId, transport);
113
- res.on("close", () => sessions.delete(transport.sessionId));
114
- await mcpServer.connect(transport);
115
- return;
116
- }
117
-
118
- // ── Message endpoint — client posts MCP messages here ─────────────────
119
-
120
- if (url.pathname === "/messages" && req.method === "POST") {
121
- const sessionId = url.searchParams.get("sessionId") ?? "";
122
- const transport = sessions.get(sessionId);
123
- if (!transport) {
124
- jsonError(res, 404, `Session '${sessionId}' not found. Has the SSE connection been established?`);
125
- return;
126
- }
127
- await transport.handlePostMessage(req, res);
128
- return;
129
- }
130
-
131
- res.writeHead(404);
132
- res.end();
133
- });
134
-
135
- server.listen(port, () => {
136
- console.error(`NexVora MCP server (SSE) listening on http://localhost:${port}/sse`);
137
- console.error(`OAuth discovery at http://localhost:${port}/.well-known/oauth-authorization-server`);
138
- });
139
-
140
- return server;
141
- }
142
-
143
- // ── Helpers ───────────────────────────────────────────────────────────────────
144
-
145
- function jsonError(res: http.ServerResponse, status: number, message: string): void {
146
- const body = JSON.stringify({ error: message });
147
- res.writeHead(status, { "Content-Type": "application/json" });
148
- res.end(body);
149
- }