rhombus-node-mcp 0.1.36 → 0.1.44

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,44 @@
1
+ import { postApi } from "../network/network.js";
2
+ import { formatTimestamp } from "../util.js";
3
+ /**
4
+ * Calls the webservice OnGuard event search (POST /eventSearchV2/searchOnGuardEvents) and maps the
5
+ * raw seekpoints to an agent-friendly shape. Typed against the generated public OpenAPI schema.
6
+ */
7
+ export async function searchOnGuardEvents(args, timeZone, requestModifiers, sessionId) {
8
+ const body = {
9
+ deviceUuids: args.deviceUuids,
10
+ locationUuids: args.locationUuids,
11
+ afterMs: args.afterMs,
12
+ beforeMs: args.beforeMs,
13
+ cardholderQuery: args.cardholderQuery,
14
+ badgeStatus: args.badgeStatus,
15
+ badgeType: args.badgeType,
16
+ area: args.area,
17
+ anomalyOnly: args.anomalyOnly,
18
+ entryMade: args.entryMade,
19
+ limit: args.limit ?? 200,
20
+ };
21
+ const res = await postApi({
22
+ route: "/eventSearchV2/searchOnGuardEvents",
23
+ body,
24
+ modifiers: requestModifiers,
25
+ sessionId,
26
+ });
27
+ if (res.error) {
28
+ throw new Error(res.status ?? res.errorMsg ?? "OnGuard event search failed");
29
+ }
30
+ const events = (res.events ?? []).map((e) => ({
31
+ timestampMs: e.timestampMs ?? undefined,
32
+ datetime: e.timestampMs != null ? formatTimestamp(e.timestampMs, timeZone) : undefined,
33
+ deviceUuid: e.deviceUuid ?? undefined,
34
+ label: e.customDisplayName ?? undefined,
35
+ cardholderName: e.customDescription ?? undefined,
36
+ badgeStatus: e.badgeStatus ?? undefined,
37
+ badgeType: e.badgeType ?? undefined,
38
+ areaEntering: e.areaEntering ?? undefined,
39
+ areaExiting: e.areaExiting ?? undefined,
40
+ entryMade: e.entryMade ?? undefined,
41
+ isAnomaly: e.alert ?? undefined,
42
+ }));
43
+ return { events };
44
+ }
@@ -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,
@@ -0,0 +1,55 @@
1
+ import { searchOnGuardEvents } from "../api/onguard-tool-api.js";
2
+ import { OUTPUT_SCHEMA, TOOL_ARGS } from "../types/onguard-tool-types.js";
3
+ import { createToolStructuredContent, extractFromToolExtra } from "../util.js";
4
+ const TOOL_NAME = "onguard-events-tool";
5
+ const TOOL_DESCRIPTION = `
6
+ Searches Honeywell OnGuard (Lenel) badge / access-control events for the organization. Use this to answer
7
+ "who entered WHERE and WHEN" questions, e.g. "who entered the back office yesterday".
8
+
9
+ Each returned event includes:
10
+ - cardholderName: the person's name
11
+ - deviceUuid: the camera that saw the event
12
+ - timestampMs / datetime: when it happened
13
+ - label: e.g. "OnGuard: Badge Authorized" (a grant) or an anomaly label
14
+ - badgeStatus, badgeType, areaEntering, areaExiting, entryMade, isAnomaly
15
+
16
+ Filters (all optional): area, locationUuids, deviceUuids, cardholderQuery, badgeStatus, badgeType,
17
+ anomalyOnly, entryMade, startTime, endTime, limit. Resolve relative times like "yesterday" to ISO 8601
18
+ first (use the timestamp tool), then pass startTime/endTime.
19
+
20
+ IMPORTANT — to show pictures and video of each person so the user can visually identify them: after this
21
+ returns, for each event (or the most relevant ones) call the camera-tool (requestType "image",
22
+ cameraUuid = the event's deviceUuid, timestamp = the event's time) to get a still you can see, and/or the
23
+ clips-tool (requestType "createClip") with a short window around the timestamp for video. Issue those
24
+ per-event media calls in PARALLEL.
25
+ `;
26
+ const TOOL_HANDLER = async (args, _extra) => {
27
+ const { requestModifiers, sessionId } = extractFromToolExtra(_extra);
28
+ try {
29
+ const result = await searchOnGuardEvents({
30
+ area: args.area ?? undefined,
31
+ locationUuids: args.locationUuids ?? undefined,
32
+ deviceUuids: args.deviceUuids ?? undefined,
33
+ cardholderQuery: args.cardholderQuery ?? undefined,
34
+ badgeStatus: args.badgeStatus ?? undefined,
35
+ badgeType: args.badgeType ?? undefined,
36
+ anomalyOnly: args.anomalyOnly ?? undefined,
37
+ entryMade: args.entryMade ?? undefined,
38
+ afterMs: args.startTime ? new Date(args.startTime).getTime() : undefined,
39
+ beforeMs: args.endTime ? new Date(args.endTime).getTime() : undefined,
40
+ limit: args.limit ?? undefined,
41
+ }, args.timeZone ?? "UTC", requestModifiers, sessionId);
42
+ return createToolStructuredContent(result);
43
+ }
44
+ catch (error) {
45
+ const message = error instanceof Error ? error.message : "Unknown error";
46
+ return createToolStructuredContent({ error: message });
47
+ }
48
+ };
49
+ export function createTool(server) {
50
+ server.registerTool(TOOL_NAME, {
51
+ description: TOOL_DESCRIPTION,
52
+ inputSchema: TOOL_ARGS,
53
+ outputSchema: OUTPUT_SCHEMA.shape,
54
+ }, TOOL_HANDLER);
55
+ }
@@ -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
+ }
@@ -0,0 +1,70 @@
1
+ import { z } from "zod";
2
+ import { createUuidSchema } from "../types.js";
3
+ import { ISOTimestampFormatDescription } from "../utils/timestampInput.js";
4
+ export const TOOL_ARGS = {
5
+ area: z
6
+ .string()
7
+ .nullable()
8
+ .describe('Filter to events whose entered area matches this, e.g. "back office". Full-text match.'),
9
+ locationUuids: z
10
+ .array(createUuidSchema())
11
+ .nullable()
12
+ .describe("Filter to these Rhombus location UUIDs. Use the location-tool to resolve names to UUIDs."),
13
+ deviceUuids: z
14
+ .array(createUuidSchema())
15
+ .nullable()
16
+ .describe("Filter to these camera UUIDs (the camera that saw the badge event)."),
17
+ cardholderQuery: z
18
+ .string()
19
+ .nullable()
20
+ .describe("Match the cardholder's name (full-text), e.g. a person you are looking for."),
21
+ badgeStatus: z.string().nullable().describe("Filter by badge status, e.g. Active or Lost."),
22
+ badgeType: z.string().nullable().describe("Filter by badge type."),
23
+ anomalyOnly: z
24
+ .boolean()
25
+ .nullable()
26
+ .describe("Only return anomaly events: an inactive/lost badge was used, or access was granted but no entry was made (possible tailgating)."),
27
+ entryMade: z
28
+ .boolean()
29
+ .nullable()
30
+ .describe("Filter by whether access was granted AND entry was actually made."),
31
+ startTime: z
32
+ .string()
33
+ .datetime({ message: "Invalid datetime string. Expected ISO 8601 format.", offset: true })
34
+ .nullable()
35
+ .describe("Only events at or after this time (inclusive). " + ISOTimestampFormatDescription),
36
+ endTime: z
37
+ .string()
38
+ .datetime({ message: "Invalid datetime string. Expected ISO 8601 format.", offset: true })
39
+ .nullable()
40
+ .describe("Only events at or before this time (inclusive). " + ISOTimestampFormatDescription),
41
+ limit: z
42
+ .number()
43
+ .nullable()
44
+ .describe("Maximum number of events to return (default 200; the server caps at 1000)."),
45
+ timeZone: z
46
+ .string()
47
+ .nullable()
48
+ .describe("IANA timezone used to format event times, e.g. America/New_York. Defaults to UTC."),
49
+ };
50
+ const TOOL_ARGS_SCHEMA = z.object(TOOL_ARGS);
51
+ export const OnGuardEventSchema = z.object({
52
+ timestampMs: z.number().optional(),
53
+ datetime: z.string().optional().describe("Human-readable event time in the requested timezone."),
54
+ deviceUuid: z
55
+ .string()
56
+ .optional()
57
+ .describe("The camera that saw this event. Pass to camera-tool (requestType image) or clips-tool (createClip) to get a still/video."),
58
+ label: z.string().optional().describe('Event label, e.g. "OnGuard: Badge Authorized" or an anomaly label.'),
59
+ cardholderName: z.string().optional().describe("The cardholder (person) name."),
60
+ badgeStatus: z.string().optional(),
61
+ badgeType: z.string().optional(),
62
+ areaEntering: z.string().optional(),
63
+ areaExiting: z.string().optional(),
64
+ entryMade: z.boolean().optional(),
65
+ isAnomaly: z.boolean().optional().describe("True if this is an alerting/anomalous event."),
66
+ });
67
+ export const OUTPUT_SCHEMA = z.object({
68
+ events: z.array(OnGuardEventSchema).optional(),
69
+ error: z.string().optional(),
70
+ });
@@ -5855,6 +5855,12 @@ const Camera_GetCustomFootageSeekpointsV2WSRequest = z.object({
5855
5855
  });
5856
5856
  const SeekpointType = z.string();
5857
5857
  const SeekpointIndexType = z.object({
5858
+ alert: z.boolean().optional(),
5859
+ areaEntering: z.string().optional(),
5860
+ areaExiting: z.string().optional(),
5861
+ badgeStatus: z.string().optional(),
5862
+ badgeType: z.string().optional(),
5863
+ entryMade: z.boolean().optional(),
5858
5864
  compositComponentUuid: z.string().optional(),
5859
5865
  customDescription: z.string().optional(),
5860
5866
  customDisplayName: z.string().optional(),
@@ -10818,6 +10824,25 @@ const Eventsearch_GetEventSeekpointsWSResponse = z.object({
10818
10824
  errorMsg: z.string().optional(),
10819
10825
  warningMsg: z.string().optional()
10820
10826
  });
10827
+ const Eventsearch_SearchOnGuardEventsWSRequest = z.object({
10828
+ afterMs: z.number().int().optional(),
10829
+ anomalyOnly: z.boolean().optional(),
10830
+ area: z.string().optional(),
10831
+ badgeStatus: z.string().optional(),
10832
+ badgeType: z.string().optional(),
10833
+ beforeMs: z.number().int().optional(),
10834
+ cardholderQuery: z.string().optional(),
10835
+ deviceUuids: z.array(z.string()).optional(),
10836
+ entryMade: z.boolean().optional(),
10837
+ limit: z.number().int().optional(),
10838
+ locationUuids: z.array(z.string()).optional()
10839
+ });
10840
+ const Eventsearch_SearchOnGuardEventsWSResponse = z.object({
10841
+ error: z.boolean().optional(),
10842
+ errorMsg: z.string().optional(),
10843
+ events: z.array(SeekpointIndexType).optional(),
10844
+ warningMsg: z.string().optional()
10845
+ });
10821
10846
  const Export_ExportAuditEventsWSRequest = z.object({
10822
10847
  endInterval: z.number().int().optional(),
10823
10848
  excludeActions: z.array(z.string()).optional(),
@@ -21985,6 +22010,8 @@ export const schemas = {
21985
22010
  Eventsearch_VideoFootageWSRequest,
21986
22011
  Eventsearch_GetEventSeekpointsWSRequest,
21987
22012
  Eventsearch_GetEventSeekpointsWSResponse,
22013
+ Eventsearch_SearchOnGuardEventsWSRequest,
22014
+ Eventsearch_SearchOnGuardEventsWSResponse,
21988
22015
  Export_ExportAuditEventsWSRequest,
21989
22016
  Export_ExportClimateEventsWSRequest,
21990
22017
  Export_ExportCountReportsWSRequest,
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.44",
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",