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.
- package/dist/api/onguard-tool-api.js +44 -0
- package/dist/auth-context.js +2 -0
- package/dist/filtering-utils.js +27 -1
- package/dist/network/network.js +24 -26
- package/dist/tools-console/onguard-tool.js +55 -0
- package/dist/transports/streamable-http.js +146 -173
- package/dist/types/onguard-tool-types.js +70 -0
- package/dist/types/zod-schemas.js +27 -0
- package/package.json +1 -4
|
@@ -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
|
+
}
|
package/dist/filtering-utils.js
CHANGED
|
@@ -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 ?? "") +
|
|
326
|
+
description: (config.description ?? "") + descriptionSuffix,
|
|
301
327
|
inputSchema: {
|
|
302
328
|
...config.inputSchema,
|
|
303
329
|
includeFields: INCLUDE_FIELDS_ARG,
|
package/dist/network/network.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { logger } from "../logger.js";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
+
"x-auth-access-token": contextAuth.oauthBearer,
|
|
46
40
|
"x-auth-scheme": "api-oauth-token",
|
|
47
41
|
};
|
|
48
42
|
}
|
|
49
|
-
else if ("apiKey" in
|
|
43
|
+
else if ("apiKey" in contextAuth) {
|
|
50
44
|
authHeaders = {
|
|
51
|
-
"x-auth-apikey":
|
|
45
|
+
"x-auth-apikey": contextAuth.apiKey,
|
|
52
46
|
"x-auth-scheme": "api-token",
|
|
53
47
|
};
|
|
54
48
|
}
|
|
55
|
-
else if ("sessionId" in
|
|
49
|
+
else if ("sessionId" in contextAuth) {
|
|
56
50
|
authHeaders = {
|
|
57
|
-
"x-auth-session":
|
|
58
|
-
"x-auth-chat":
|
|
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:
|
|
55
|
+
url = appendQueryParams(url, { _rs: contextAuth.sessionId });
|
|
62
56
|
}
|
|
63
|
-
else if ("cookie" in
|
|
57
|
+
else if ("cookie" in contextAuth) {
|
|
64
58
|
authHeaders = {
|
|
65
59
|
"x-auth-scheme": "web2",
|
|
66
|
-
cookie:
|
|
60
|
+
cookie: contextAuth.cookie,
|
|
67
61
|
};
|
|
68
|
-
if (
|
|
69
|
-
url = appendQueryParams(url, { _rs:
|
|
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 {
|
|
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
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 (
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 (
|
|
44
|
-
"x-auth-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
logger.info(`
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|