rhombus-node-mcp 0.1.36 → 0.1.43

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.
@@ -0,0 +1,2 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ export const requestAuthContext = new AsyncLocalStorage();
@@ -295,9 +295,35 @@ export function createFilteringProxy(server, blacklist = new Set()) {
295
295
  if (blacklist.has(name)) {
296
296
  return target.registerTool(name, config, handler);
297
297
  }
298
+ let descriptionSuffix = FILTERING_DESCRIPTION_SUFFIX;
299
+ if (config.outputSchema) {
300
+ try {
301
+ let schema;
302
+ if (config.outputSchema instanceof z.ZodType) {
303
+ schema = config.outputSchema;
304
+ }
305
+ else if (typeof config.outputSchema === "object") {
306
+ schema = z.object(config.outputSchema);
307
+ }
308
+ else {
309
+ schema = config.outputSchema;
310
+ }
311
+ const paths = zodToDotNotationPaths(schema);
312
+ if (paths.length > 0) {
313
+ const filteredPaths = [...new Set(paths.filter((p) => p !== "requestType" && p !== "error" && p.trim() !== ""))].sort();
314
+ if (filteredPaths.length > 0) {
315
+ descriptionSuffix += `\n\n**Available output field paths for this tool's \`includeFields\` / \`filterBy\`:**\n` +
316
+ filteredPaths.map((p) => `- \`"${p}"\``).join("\n");
317
+ }
318
+ }
319
+ }
320
+ catch (error) {
321
+ // Fall back to the default description suffix on parsing failure
322
+ }
323
+ }
298
324
  const augmentedConfig = {
299
325
  ...config,
300
- description: (config.description ?? "") + FILTERING_DESCRIPTION_SUFFIX,
326
+ description: (config.description ?? "") + descriptionSuffix,
301
327
  inputSchema: {
302
328
  ...config.inputSchema,
303
329
  includeFields: INCLUDE_FIELDS_ARG,
@@ -1,5 +1,5 @@
1
1
  import { logger } from "../logger.js";
2
- import { authStore } from "../transports/streamable-http.js";
2
+ import { requestAuthContext } from "../auth-context.js";
3
3
  export const RHOMBUS_API_KEY = process.env.RHOMBUS_API_KEY;
4
4
  export const serverUrl = process.env.RHOMBUS_API_SERVER || "api2.rhombussystems.com";
5
5
  export const BASE_URL = `https://${serverUrl}/api`;
@@ -26,50 +26,48 @@ export const appendQueryParams = (url, params) => {
26
26
  const queryString = existingSearchParams.toString();
27
27
  return queryString ? `${baseUrl}?${queryString}` : baseUrl;
28
28
  };
29
- export function constructRequestHeaders(url, modifiers, sessionId) {
30
- // construct auth headers
29
+ export function constructRequestHeaders(url, modifiers, sessionId // kept for API compatibility; ignored — always uses AsyncLocalStorage
30
+ ) {
31
+ // construct auth headers from async context (stateless: set per-request by the transport handler)
31
32
  let authHeaders = {};
32
- if (!sessionId) {
33
- // if no sessionId, we fall back to the api key in our environment variables
34
- authHeaders = AUTH_HEADERS;
35
- }
36
- else {
37
- // use sessionId to get auth
38
- const auth = authStore.get(sessionId);
39
- if (!auth) {
40
- logger.error(`No auth found for sessionId: ${sessionId}`);
41
- throw new Error(`No auth found for sessionId: ${sessionId}`);
42
- }
43
- if ("oauthToken" in auth) {
33
+ const contextAuth = requestAuthContext.getStore();
34
+ if (contextAuth) {
35
+ if ("oauthBearer" in contextAuth) {
36
+ // The Bearer is an opaque Rhombus access token issued by the Rhombus
37
+ // OAuth 2.1 authorization server. api2 validates it directly.
44
38
  authHeaders = {
45
- Authorization: `Bearer ${auth.oauthToken}`,
39
+ "x-auth-access-token": contextAuth.oauthBearer,
46
40
  "x-auth-scheme": "api-oauth-token",
47
41
  };
48
42
  }
49
- else if ("apiKey" in auth) {
43
+ else if ("apiKey" in contextAuth) {
50
44
  authHeaders = {
51
- "x-auth-apikey": auth.apiKey,
45
+ "x-auth-apikey": contextAuth.apiKey,
52
46
  "x-auth-scheme": "api-token",
53
47
  };
54
48
  }
55
- else if ("sessionId" in auth) {
49
+ else if ("sessionId" in contextAuth) {
56
50
  authHeaders = {
57
- "x-auth-session": auth.sessionId,
58
- "x-auth-chat": auth.latestRecordUuid,
51
+ "x-auth-session": contextAuth.sessionId,
52
+ "x-auth-chat": contextAuth.latestRecordUuid,
59
53
  "x-auth-scheme": "chatbot",
60
54
  };
61
- url = appendQueryParams(url, { _rs: auth.sessionId });
55
+ url = appendQueryParams(url, { _rs: contextAuth.sessionId });
62
56
  }
63
- else if ("cookie" in auth) {
57
+ else if ("cookie" in contextAuth) {
64
58
  authHeaders = {
65
59
  "x-auth-scheme": "web2",
66
- cookie: auth.cookie,
60
+ cookie: contextAuth.cookie,
67
61
  };
68
- if ("sessionAlias" in auth) {
69
- url = appendQueryParams(url, { _rs: auth.sessionAlias });
62
+ if (contextAuth.sessionAlias) {
63
+ url = appendQueryParams(url, { _rs: contextAuth.sessionAlias });
70
64
  }
71
65
  }
72
66
  }
67
+ else {
68
+ // no async context — fall back to env API key (local dev / stdio)
69
+ authHeaders = AUTH_HEADERS;
70
+ }
73
71
  // merge headers
74
72
  const requestHeaders = {
75
73
  ...STATIC_HEADERS,
@@ -1,215 +1,188 @@
1
- import crypto from "node:crypto";
2
1
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
- import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
4
2
  import cors from "cors";
5
3
  import express from "express";
6
- import { clearAccessibleAppsCache } from "../api/get-accessible-apps.js";
4
+ import { requestAuthContext } from "../auth-context.js";
7
5
  import createServer from "../createServer.js";
8
6
  import { logger } from "../logger.js";
7
+ // ---------------------------------------------------------------------------
8
+ // x-auth-* header extraction (internal chatbot / API key clients) — UNCHANGED
9
+ // ---------------------------------------------------------------------------
9
10
  var AuthScheme;
10
11
  (function (AuthScheme) {
11
- AuthScheme["OAUTH"] = "oauth";
12
12
  AuthScheme["API_TOKEN"] = "api-token";
13
13
  AuthScheme["CHATBOT"] = "chatbot";
14
14
  AuthScheme["WEB2"] = "web2";
15
15
  })(AuthScheme || (AuthScheme = {}));
16
- export const authStore = new Map();
17
- const transports = new Map();
18
- /**
19
- * Populate authStore for the given sessionId based on the request headers.
20
- * Done up-front (before createServer) so resolveAccessibleApps can read auth
21
- * during tool registration. Returns true on success.
22
- */
23
- function populateAuthStore(req, sessionId) {
24
- const oauthToken = req.headers["x-auth-access-token"];
25
- const authScheme = req.headers["x-auth-scheme"] ?? AuthScheme.API_TOKEN;
26
- if (oauthToken && typeof oauthToken === "string") {
27
- authStore.set(sessionId, { oauthToken, createdMs: Date.now() });
28
- logger.info(`🔒 MCP request authenticated with oauth token (session ${sessionId})`);
29
- return true;
16
+ function extractAuth(req) {
17
+ const scheme = req.headers["x-auth-scheme"] ?? AuthScheme.API_TOKEN;
18
+ if (scheme === AuthScheme.API_TOKEN) {
19
+ const apiKey = req.headers["x-auth-apikey"] ?? process.env.RHOMBUS_API_KEY;
20
+ if (!apiKey)
21
+ return null;
22
+ return { apiKey };
30
23
  }
31
- if (authScheme === AuthScheme.API_TOKEN) {
32
- const apiKey = "x-auth-apikey" in req.headers
33
- ? req.headers["x-auth-apikey"]
34
- : process.env.RHOMBUS_API_KEY;
35
- if (!apiKey) {
36
- logger.warn("populateAuthStore: API_TOKEN scheme but no api key found");
37
- return false;
38
- }
39
- logger.info(`🔒 MCP request authenticated with api key (session ${sessionId})`);
40
- authStore.set(sessionId, { apiKey, createdMs: Date.now() });
41
- return true;
24
+ if (scheme === AuthScheme.CHATBOT) {
25
+ const sessionId = req.headers["x-auth-session"];
26
+ const latestRecordUuid = req.headers["x-auth-chat"];
27
+ if (!sessionId || !latestRecordUuid)
28
+ return null;
29
+ return { sessionId, latestRecordUuid };
42
30
  }
43
- if (authScheme === AuthScheme.CHATBOT &&
44
- "x-auth-session" in req.headers &&
45
- "x-auth-chat" in req.headers) {
46
- logger.info(`🔒 MCP request authenticated with x-auth-session: ${req.headers["x-auth-session"]} and x-auth-chat: ${req.headers["x-auth-chat"]}`);
47
- authStore.set(sessionId, {
48
- sessionId: req.headers["x-auth-session"],
49
- latestRecordUuid: req.headers["x-auth-chat"],
50
- createdMs: Date.now(),
51
- });
52
- return true;
31
+ if (scheme === AuthScheme.WEB2) {
32
+ const cookie = req.headers["x-auth-cookie"];
33
+ if (!cookie)
34
+ return null;
35
+ return { cookie, sessionAlias: req.headers["x-auth-session-alias"] };
53
36
  }
54
- if (authScheme === AuthScheme.WEB2 && "x-auth-cookie" in req.headers) {
55
- logger.info(`🔒 MCP request authenticated with x-auth-cookie (session ${sessionId})`);
56
- authStore.set(sessionId, {
57
- cookie: req.headers["x-auth-cookie"],
58
- sessionAlias: req.headers["x-auth-session-alias"],
59
- createdMs: Date.now(),
60
- });
61
- return true;
62
- }
63
- logger.warn(`populateAuthStore: invalid auth scheme. x-auth-scheme: ${req.headers["x-auth-scheme"]}, x-auth-session: ${req.headers["x-auth-session"]}, x-auth-chat: ${req.headers["x-auth-chat"]}`);
64
- return false;
37
+ return null;
65
38
  }
39
+ // ---------------------------------------------------------------------------
40
+ // Transport
41
+ //
42
+ // Pure RFC 9728 OAuth 2.1 Resource Server. The Rhombus authorization server
43
+ // (RFC 8414 issuer) is configured via OAUTH_AS_ISSUER_URL — e.g. set it to
44
+ // the Rhombus auth host whose /.well-known/oauth-authorization-server
45
+ // document advertises /authorize, /token, /register, /revoke per
46
+ // RFC 6749 + 7636 + 7591 + 7009.
47
+ //
48
+ // The AS issues opaque Rhombus access tokens, so the MCP server does no
49
+ // local validation — it just forwards the Bearer to api2 as
50
+ // x-auth-access-token, which api2 already knows how to validate.
51
+ //
52
+ // The legacy x-auth-* dispatch for internal callers (chatbot, API-key, web2)
53
+ // is unchanged.
54
+ // ---------------------------------------------------------------------------
66
55
  export default function streamableHttpTransport() {
67
56
  const app = express();
68
57
  app.use(express.json());
58
+ const oauthAsIssuerUrl = process.env.OAUTH_AS_ISSUER_URL;
59
+ const mcpServerUrl = process.env.MCP_SERVER_URL;
60
+ const allowedHost = process.env.ALLOWED_HOST;
61
+ const allowedHosts = allowedHost
62
+ ? allowedHost
63
+ .split(",")
64
+ .map(h => h.trim())
65
+ .filter(Boolean)
66
+ : [];
69
67
  app.use(cors({
70
- // TODO: domain
71
68
  origin: ["*"],
72
69
  exposedHeaders: ["mcp-session-id"],
73
- allowedHeaders: ["Content-Type", "mcp-session-id"],
70
+ allowedHeaders: [
71
+ "Content-Type",
72
+ "Authorization",
73
+ "mcp-session-id",
74
+ "x-auth-scheme",
75
+ "x-auth-apikey",
76
+ "x-auth-session",
77
+ "x-auth-chat",
78
+ "x-auth-cookie",
79
+ "x-auth-session-alias",
80
+ ],
74
81
  }));
75
- /**
76
- * STATEFUL ENDPOINT
77
- */
78
- app.post("/mcp", async (req, res) => {
79
- logger.info(`Received MCP request`, JSON.stringify(req.body, null, 2));
80
- // Check for existing session ID
81
- const sessionId = req.headers["mcp-session-id"];
82
- let transport;
83
- if (sessionId && transports.has(sessionId)) {
84
- // Reuse existing transport
85
- const _transport = transports.get(sessionId);
86
- if (!_transport) {
87
- throw new Error(`Transport not found for sessionId: ${sessionId}`);
88
- }
89
- transport = _transport;
82
+ app.get("/health", (_, res) => {
83
+ res.status(200).json({ status: "ok" });
84
+ });
85
+ if (oauthAsIssuerUrl) {
86
+ logger.info(`OAuth Resource Server AS at ${oauthAsIssuerUrl}`);
87
+ }
88
+ else {
89
+ logger.info("OAUTH_AS_ISSUER_URL not set — Bearer tokens will be rejected. Set this to the Rhombus authorization server issuer URL (e.g. https://auth-web.<env>.rhombussystems.com/).");
90
+ }
91
+ // RFC 9728 — Protected Resource Metadata. Points clients at the Rhombus AS.
92
+ // Preserves the issuer URL verbatim so the value matches the `issuer` field
93
+ // strict OAuth clients (Claude Desktop, etc.) read from the AS metadata.
94
+ app.get("/.well-known/oauth-protected-resource", (req, res) => {
95
+ if (!oauthAsIssuerUrl) {
96
+ res.status(404).json({ error: "oauth_not_configured" });
97
+ return;
90
98
  }
91
- else if (!sessionId && isInitializeRequest(req.body)) {
92
- // New initialization request — mint our sessionId up front so we can
93
- // populate authStore and gate tool registration BEFORE the SDK calls
94
- // onsessioninitialized.
95
- const newSessionId = crypto.randomUUID();
96
- const authOk = populateAuthStore(req, newSessionId);
97
- // Reject the initialize itself when auth couldn't be populated. Letting
98
- // the session through without an authStore entry only defers the failure:
99
- // any later /customer/getCurrentUser (during tool registration) or tool
100
- // call will throw "No auth found for sessionId" from network.ts, which
101
- // is harder to diagnose than an upfront 401 here.
102
- if (!authOk) {
103
- logger.error(`Auth could not be populated for ${req.body.method}; rejecting`);
104
- res
105
- .status(401)
106
- .setHeader("WWW-Authenticate", `Bearer realm="${process.env.REALM}", error="invalid_token", error_description="The access token is missing or invalid"`)
107
- .send("Unauthorized");
108
- return;
99
+ res.json({
100
+ resource: mcpServerUrl ?? `${getSelfOrigin(req, mcpServerUrl)}/mcp`,
101
+ authorization_servers: [oauthAsIssuerUrl],
102
+ scopes_supported: ["rhombus:access"],
103
+ bearer_methods_supported: ["header"],
104
+ });
105
+ });
106
+ const handleMcpRequest = async (req, res) => {
107
+ logger.info("Received MCP request");
108
+ let auth = null;
109
+ const authHeader = req.headers.authorization;
110
+ if (authHeader?.startsWith("Bearer ")) {
111
+ if (!oauthAsIssuerUrl) {
112
+ return reject401(req, res, mcpServerUrl, "OAuth not configured: set OAUTH_AS_ISSUER_URL");
109
113
  }
110
- transport = new StreamableHTTPServerTransport({
111
- sessionIdGenerator: () => newSessionId,
112
- onsessioninitialized: sessionId => {
113
- transports.set(sessionId, transport);
114
- logger.info(`🔒 MCP request initialized with sessionId: ${sessionId}`);
115
- },
116
- // DNS rebinding protection is disabled by default for backwards compatibility. If you are running this server
117
- // locally, make sure to set:
118
- // enableDnsRebindingProtection: true,
119
- // allowedHosts: ['127.0.0.1'],
120
- });
121
- // Clean up transport when closed
122
- transport.onclose = () => {
123
- if (transport.sessionId) {
124
- transports.delete(transport.sessionId);
125
- authStore.delete(transport.sessionId);
126
- clearAccessibleAppsCache(transport.sessionId);
127
- }
128
- };
129
- // newSessionId is already in authStore; createServer can resolve
130
- // accessibleRhombusApps and pick the right tool set.
131
- const server = await createServer({ sessionId: newSessionId });
132
- // Connect to the MCP server
133
- await server.connect(transport);
134
- logger.info(`🔗 Transport connected with sessionId: ${newSessionId}`);
114
+ const token = authHeader.slice(7).trim();
115
+ if (!token) {
116
+ return reject401(req, res, mcpServerUrl, "empty Bearer token");
117
+ }
118
+ auth = { oauthBearer: token };
119
+ logger.info("MCP request authenticated via Bearer (opaque, forwarded to api2)");
135
120
  }
136
121
  else {
137
- // Invalid request
138
- res.status(400).json({
139
- jsonrpc: "2.0",
140
- error: {
141
- code: -32000,
142
- message: "Bad Request: No valid session ID provided",
143
- },
144
- id: null,
145
- });
146
- return;
122
+ auth = extractAuth(req);
123
+ if (!auth) {
124
+ return reject401(req, res, mcpServerUrl, "no credentials presented");
125
+ }
126
+ logger.info("MCP request authenticated via x-auth-* headers");
147
127
  }
148
- await transport.handleRequest(req, res, req.body);
149
- });
150
- app.get("/mcp", async (_, res) => {
151
- logger.warn("Received Not Allowed GET MCP request");
152
- res.writeHead(405).end(JSON.stringify({
153
- jsonrpc: "2.0",
154
- error: {
155
- code: -32000,
156
- message: "Method not allowed.",
157
- },
158
- id: null,
159
- }));
160
- });
161
- app.delete("/mcp", async (req, res) => {
162
- logger.warn("Received Not Allowed DELETE MCP request");
163
- res.writeHead(405).end(JSON.stringify({
164
- jsonrpc: "2.0",
165
- error: {
166
- code: -32000,
167
- message: "Method not allowed.",
168
- },
169
- id: null,
170
- }));
171
- });
172
- /**
173
- * STATELESS ENDPOINT
174
- */
175
- app.post("/mcp-stateless", async (req, res) => {
176
- logger.info(`Received stateless MCP request`, req.body);
177
- let transport;
178
- transport = new StreamableHTTPServerTransport({
179
- sessionIdGenerator: undefined,
128
+ await requestAuthContext.run(auth, async () => {
129
+ const transport = new StreamableHTTPServerTransport({
130
+ sessionIdGenerator: undefined,
131
+ ...(allowedHosts.length > 0 ? { enableDnsRebindingProtection: true, allowedHosts } : {}),
132
+ });
133
+ const server = await createServer();
134
+ await server.connect(transport);
135
+ logger.info("🔗 Stateless MCP Transport connected");
136
+ await transport.handleRequest(req, res, req.body);
180
137
  });
181
- const server = await createServer();
182
- // Connect to the MCP server
183
- await server.connect(transport);
184
- logger.info(`🔗 Stateless Transport connected`);
185
- // Handle the request
186
- await transport.handleRequest(req, res, req.body);
187
- });
188
- app.get("/mcp-stateless", async (req, res) => {
189
- logger.warn("Received Not Allowed GET MCP request");
138
+ };
139
+ app.post("/mcp", handleMcpRequest);
140
+ app.get("/mcp", (_, res) => {
190
141
  res.writeHead(405).end(JSON.stringify({
191
142
  jsonrpc: "2.0",
192
- error: {
193
- code: -32000,
194
- message: "Method not allowed.",
195
- },
143
+ error: { code: -32000, message: "Method not allowed." },
196
144
  id: null,
197
145
  }));
198
146
  });
199
- app.delete("/mcp-stateless", async (req, res) => {
200
- logger.warn("Received Not Allowed DELETE MCP request");
147
+ app.delete("/mcp", (_, res) => {
201
148
  res.writeHead(405).end(JSON.stringify({
202
149
  jsonrpc: "2.0",
203
- error: {
204
- code: -32000,
205
- message: "Method not allowed.",
206
- },
150
+ error: { code: -32000, message: "Method not allowed." },
207
151
  id: null,
208
152
  }));
209
153
  });
210
- // Start the server
211
- const PORT = process.env.PORT || 3000;
154
+ const PORT = process.env.PORT ?? 3000;
212
155
  app.listen(PORT, () => {
213
156
  logger.info(`rhombus-node-mcp listening on port ${PORT}`);
214
157
  });
215
158
  }
159
+ // ---------------------------------------------------------------------------
160
+ // Helpers
161
+ // ---------------------------------------------------------------------------
162
+ function reject401(req, res, mcpServerUrl, msg) {
163
+ const resourceMetadataUrl = `${getSelfOrigin(req, mcpServerUrl)}/.well-known/oauth-protected-resource`;
164
+ res
165
+ .status(401)
166
+ .set("WWW-Authenticate", `Bearer error="invalid_token", error_description="${escapeWwwAuth(msg)}", resource_metadata="${resourceMetadataUrl}"`)
167
+ .json({
168
+ jsonrpc: "2.0",
169
+ error: { code: -32000, message: `Unauthorized: ${msg}` },
170
+ id: null,
171
+ });
172
+ }
173
+ function getSelfOrigin(req, mcpServerUrl) {
174
+ if (mcpServerUrl) {
175
+ try {
176
+ return new URL(mcpServerUrl).origin;
177
+ }
178
+ catch {
179
+ // fall through
180
+ }
181
+ }
182
+ const proto = req.headers["x-forwarded-proto"] ?? req.protocol;
183
+ const host = req.headers.host ?? "localhost";
184
+ return `${proto}://${host}`;
185
+ }
186
+ function escapeWwwAuth(s) {
187
+ return s.replace(/["\r\n]/g, "");
188
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rhombus-node-mcp",
3
- "version": "0.1.36",
3
+ "version": "0.1.43",
4
4
  "description": "MCP server for Rhombus API",
5
5
  "keywords": [
6
6
  "ai",
@@ -56,9 +56,7 @@
56
56
  "cors": "^2.8.5",
57
57
  "dotenv": "^16.5.0",
58
58
  "express": "^5.1.0",
59
- "express-jwt": "^8.5.1",
60
59
  "faiss-node": "^0.5.1",
61
- "jsonwebtoken": "^9.0.2",
62
60
  "langchain": "^0.3.30",
63
61
  "log4js": "^6.9.1",
64
62
  "luxon": "^3.6.1",
@@ -69,7 +67,6 @@
69
67
  "devDependencies": {
70
68
  "@types/cors": "^2.8.19",
71
69
  "@types/express": "^5.0.3",
72
- "@types/jwt-express": "^1.1.6",
73
70
  "@types/luxon": "^3.6.2",
74
71
  "@types/node": "^22.14.0",
75
72
  "openapi-typescript-codegen": "^0.29.0",