@sfranalytics/mcp 0.6.2 → 0.6.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 (42) hide show
  1. package/README.md +67 -50
  2. package/dist/analytics/logger.d.ts +3 -0
  3. package/dist/analytics/logger.js +39 -0
  4. package/dist/analytics/logger.js.map +1 -0
  5. package/dist/analytics/requestContext.d.ts +21 -0
  6. package/dist/analytics/requestContext.js +39 -0
  7. package/dist/analytics/requestContext.js.map +1 -0
  8. package/dist/analytics/sanitize.d.ts +1 -0
  9. package/dist/analytics/sanitize.js +78 -0
  10. package/dist/analytics/sanitize.js.map +1 -0
  11. package/dist/http.js +193 -40
  12. package/dist/http.js.map +1 -1
  13. package/dist/index.js +17 -4
  14. package/dist/index.js.map +1 -1
  15. package/dist/landing.d.ts +3 -0
  16. package/dist/landing.js +891 -0
  17. package/dist/landing.js.map +1 -0
  18. package/dist/server.d.ts +5 -1
  19. package/dist/server.js +12 -4
  20. package/dist/server.js.map +1 -1
  21. package/dist/services/httpClient.d.ts +22 -0
  22. package/dist/services/httpClient.js +147 -26
  23. package/dist/services/httpClient.js.map +1 -1
  24. package/dist/tools/formatters.js +29 -1
  25. package/dist/tools/formatters.js.map +1 -1
  26. package/dist/tools/health.js +84 -7
  27. package/dist/tools/health.js.map +1 -1
  28. package/dist/tools/plr/borrowerContacts.js +47 -10
  29. package/dist/tools/plr/borrowerContacts.js.map +1 -1
  30. package/dist/tools/plr/borrowerProfile.js +11 -5
  31. package/dist/tools/plr/borrowerProfile.js.map +1 -1
  32. package/dist/tools/registerToolSafe.d.ts +5 -1
  33. package/dist/tools/registerToolSafe.js +110 -4
  34. package/dist/tools/registerToolSafe.js.map +1 -1
  35. package/dist/tools/sfr/rentalStats.js +19 -3
  36. package/dist/tools/sfr/rentalStats.js.map +1 -1
  37. package/dist/tools/sfr/topBuyers.js +1 -0
  38. package/dist/tools/sfr/topBuyers.js.map +1 -1
  39. package/dist/tools/welcome.d.ts +3 -0
  40. package/dist/tools/welcome.js +58 -0
  41. package/dist/tools/welcome.js.map +1 -0
  42. package/package.json +2 -2
package/README.md CHANGED
@@ -1,68 +1,54 @@
1
1
  # @sfranalytics/mcp
2
2
 
3
- MCP server for [SFR Analytics](https://www.sfranalytics.com) — single-family rental property data, buyer intelligence, and private lending insights for AI assistants.
3
+ MCP server for [SFR Analytics](https://www.sfranalytics.com) — single-family residential property data, buyer intelligence, and private lending insights for AI assistants.
4
4
 
5
5
  ## Quick Start
6
6
 
7
7
  ### Claude Code
8
8
 
9
9
  ```bash
10
- claude mcp add sfra -- npx -y @sfranalytics/mcp \
11
- --env SFR_API_TOKEN=your-sfr-key \
12
- --env PLR_API_TOKEN=your-plr-key
10
+ claude mcp add sfranalytics --transport http https://mcp.sfranalytics.com/mcp \
11
+ -H "SFR-Api-Token: YOUR_SFR_KEY" \
12
+ -H "PLR-Api-Token: YOUR_PLR_KEY"
13
13
  ```
14
14
 
15
+ Provide at least one token header (`SFR-Api-Token` or `PLR-Api-Token`).
16
+
15
17
  ### Claude Desktop
16
18
 
19
+ Claude Desktop currently uses stdio transport, so use `npx`:
20
+
17
21
  Add to your `claude_desktop_config.json`:
18
22
 
19
23
  ```json
20
24
  {
21
25
  "mcpServers": {
22
- "sfra": {
26
+ "sfranalytics": {
23
27
  "command": "npx",
24
28
  "args": ["-y", "@sfranalytics/mcp"],
25
29
  "env": {
26
- "SFR_API_TOKEN": "your-sfr-key",
27
- "PLR_API_TOKEN": "your-plr-key"
30
+ "SFR_API_TOKEN": "YOUR_SFR_KEY",
31
+ "PLR_API_TOKEN": "YOUR_PLR_KEY"
28
32
  }
29
33
  }
30
34
  }
31
35
  }
32
36
  ```
33
37
 
34
- ### Hosted HTTP Transport
38
+ ### Other MCP Clients
35
39
 
36
- Run the MCP server over HTTP:
40
+ Use the hosted streamable HTTP endpoint:
37
41
 
38
- ```bash
39
- npm run dev:http
40
- ```
42
+ - Endpoint: `https://mcp.sfranalytics.com/mcp`
43
+ - Headers: `SFR-Api-Token` and/or `PLR-Api-Token`
41
44
 
42
- Connect from Claude Code:
45
+ ### Verify Setup
43
46
 
44
- ```bash
45
- claude mcp add sfra-http --transport http http://localhost:3000/mcp \
46
- --header "SFR-Api-Token: your-sfr-key" \
47
- --header "PLR-Api-Token: your-plr-key"
48
- ```
47
+ After connecting, ask Claude:
49
48
 
50
- Production endpoint example:
49
+ > Check my SFR Analytics connection
51
50
 
52
- ```bash
53
- claude mcp add sfra-http --transport http https://mcp.sfranalytics.com/mcp \
54
- --header "SFR-Api-Token: your-sfr-key" \
55
- --header "PLR-Api-Token: your-plr-key"
56
- ```
57
-
58
- Notes:
59
-
60
- - Provide at least one token header (`SFR-Api-Token` or `PLR-Api-Token`).
61
- - `MCP_ALLOWED_ORIGINS` controls allowed `Origin` headers.
62
- - `MCP_ALLOWED_HOSTS` controls allowed `Host` headers (recommended for public deployments).
63
- - `MCP_STRICT_TOKEN_VALIDATION=true` optionally validates token(s) on each MCP request via upstream auth probes.
64
- - In strict mode, explicit auth failures return `401`; temporary probe outages return `503`.
65
- - Raw HTTP clients should send `Accept: application/json, text/event-stream`.
51
+ This calls `sfra_health` and confirms your configured API access.
66
52
 
67
53
  ## API Keys
68
54
 
@@ -102,7 +88,47 @@ Get your API keys at [sfranalytics.com](https://www.sfranalytics.com).
102
88
  |------|-------------|
103
89
  | `sfra_health` | Connectivity check for configured APIs |
104
90
 
105
- ## Configuration
91
+ ## Example Prompts
92
+
93
+ The server includes built-in prompts for common workflows:
94
+
95
+ - **market-screen** — Find top zip codes by investment criteria
96
+ - **buyer-research** — Research a property investor's portfolio and strategy
97
+ - **property-analysis** — Evaluate a property for SFR investment
98
+ - **lending-overview** — Private lending market overview
99
+ - **borrower-outreach** — Build a targeted borrower outreach list
100
+
101
+ Prompts are scoped to your configured APIs.
102
+
103
+ <details>
104
+ <summary>Self-Hosted / Local Development</summary>
105
+
106
+ ### Requirements
107
+
108
+ - Node.js >= 18.0.0
109
+ - An MCP-compatible client (Claude Code, Claude Desktop, etc.)
110
+
111
+ ### Local stdio (npx)
112
+
113
+ ```bash
114
+ npx -y @sfranalytics/mcp
115
+ ```
116
+
117
+ ### Local HTTP server
118
+
119
+ ```bash
120
+ npm run dev:http
121
+ ```
122
+
123
+ Connect from Claude Code:
124
+
125
+ ```bash
126
+ claude mcp add sfranalytics-local --transport http http://localhost:3000/mcp \
127
+ -H "SFR-Api-Token: YOUR_SFR_KEY" \
128
+ -H "PLR-Api-Token: YOUR_PLR_KEY"
129
+ ```
130
+
131
+ ### Configuration
106
132
 
107
133
  | Variable | Required | Default | Description |
108
134
  |----------|----------|---------|-------------|
@@ -118,24 +144,15 @@ Get your API keys at [sfranalytics.com](https://www.sfranalytics.com).
118
144
  | `MCP_STRICT_TOKEN_VALIDATION` | No | `false` | If `true`, validate provided HTTP token(s) on each request using upstream auth probes |
119
145
  | `MCP_SFR_TOKEN_PROBE_ADDRESS` | No | `123 Main St, Phoenix, AZ 85004` | Address used for SFR strict-token auth probe |
120
146
 
121
- At least one API token must be provided.
122
-
123
- ## Example Prompts
124
-
125
- The server includes built-in prompts for common workflows:
126
-
127
- - **market-screen** — Find top zip codes by investment criteria
128
- - **buyer-research** — Research a property investor's portfolio and strategy
129
- - **property-analysis** — Evaluate a property for SFR investment
130
- - **lending-overview** — Private lending market overview
131
- - **borrower-outreach** — Build a targeted borrower outreach list
132
-
133
- Prompts are scoped to your configured APIs.
147
+ Notes:
134
148
 
135
- ## Requirements
149
+ - At least one token is required.
150
+ - `MCP_ALLOWED_ORIGINS` controls allowed `Origin` headers.
151
+ - `MCP_ALLOWED_HOSTS` controls allowed `Host` headers.
152
+ - In strict mode, explicit auth failures return `401`; temporary probe outages return `503`.
153
+ - Raw HTTP clients should send `Accept: application/json, text/event-stream`.
136
154
 
137
- - Node.js >= 18.0.0
138
- - An MCP-compatible client (Claude Code, Claude Desktop, etc.)
155
+ </details>
139
156
 
140
157
  ## Support
141
158
 
@@ -0,0 +1,3 @@
1
+ type AnalyticsEvent = Record<string, unknown>;
2
+ export declare function logEvent(event: AnalyticsEvent): void;
3
+ export {};
@@ -0,0 +1,39 @@
1
+ function parseBoolean(input, defaultValue) {
2
+ if (!input)
3
+ return defaultValue;
4
+ const normalized = input.trim().toLowerCase();
5
+ if (["1", "true", "yes", "on"].includes(normalized))
6
+ return true;
7
+ if (["0", "false", "no", "off"].includes(normalized))
8
+ return false;
9
+ return defaultValue;
10
+ }
11
+ function parseSampleRate(input) {
12
+ if (!input)
13
+ return 1;
14
+ const parsed = Number(input);
15
+ if (!Number.isFinite(parsed))
16
+ return 1;
17
+ if (parsed <= 0)
18
+ return 0;
19
+ if (parsed >= 1)
20
+ return 1;
21
+ return parsed;
22
+ }
23
+ const ANALYTICS_ENABLED = parseBoolean(process.env.MCP_ANALYTICS_ENABLED, true);
24
+ const SAMPLE_RATE = parseSampleRate(process.env.MCP_ANALYTICS_SAMPLE_RATE);
25
+ export function logEvent(event) {
26
+ if (!ANALYTICS_ENABLED)
27
+ return;
28
+ if (SAMPLE_RATE <= 0)
29
+ return;
30
+ if (SAMPLE_RATE < 1 && Math.random() > SAMPLE_RATE)
31
+ return;
32
+ try {
33
+ process.stderr.write(`${JSON.stringify(event)}\n`);
34
+ }
35
+ catch {
36
+ // Logging must never break tool execution.
37
+ }
38
+ }
39
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.js","sourceRoot":"","sources":["../../src/analytics/logger.ts"],"names":[],"mappings":"AAEA,SAAS,YAAY,CAAC,KAAyB,EAAE,YAAqB;IACpE,IAAI,CAAC,KAAK;QAAE,OAAO,YAAY,CAAC;IAChC,MAAM,UAAU,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAC9C,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC,QAAQ,CAAC,UAAU,CAAC;QAAE,OAAO,IAAI,CAAC;IACjE,IAAI,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,QAAQ,CAAC,UAAU,CAAC;QAAE,OAAO,KAAK,CAAC;IACnE,OAAO,YAAY,CAAC;AACtB,CAAC;AAED,SAAS,eAAe,CAAC,KAAyB;IAChD,IAAI,CAAC,KAAK;QAAE,OAAO,CAAC,CAAC;IACrB,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IAC7B,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC;QAAE,OAAO,CAAC,CAAC;IACvC,IAAI,MAAM,IAAI,CAAC;QAAE,OAAO,CAAC,CAAC;IAC1B,IAAI,MAAM,IAAI,CAAC;QAAE,OAAO,CAAC,CAAC;IAC1B,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,iBAAiB,GAAG,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,qBAAqB,EAAE,IAAI,CAAC,CAAC;AAChF,MAAM,WAAW,GAAG,eAAe,CAAC,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,CAAC;AAE3E,MAAM,UAAU,QAAQ,CAAC,KAAqB;IAC5C,IAAI,CAAC,iBAAiB;QAAE,OAAO;IAC/B,IAAI,WAAW,IAAI,CAAC;QAAE,OAAO;IAC7B,IAAI,WAAW,GAAG,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,GAAG,WAAW;QAAE,OAAO;IAE3D,IAAI,CAAC;QACH,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IACrD,CAAC;IAAC,MAAM,CAAC;QACP,2CAA2C;IAC7C,CAAC;AACH,CAAC"}
@@ -0,0 +1,21 @@
1
+ export type RuntimeMode = "hosted_http" | "stdio";
2
+ export type RequestContext = {
3
+ requestId: string | null;
4
+ tokenHash: string | null;
5
+ tokenHashSfr: string | null;
6
+ tokenHashPlr: string | null;
7
+ mode: RuntimeMode;
8
+ clientIp?: string;
9
+ };
10
+ /**
11
+ * Compute a token hash for analytics correlation.
12
+ * If MCP_TOKEN_HASH_PEPPER is configured, use HMAC-SHA256.
13
+ * Otherwise use plain SHA-256 for backwards compatibility.
14
+ */
15
+ export declare function computeTokenHash(token: string | null | undefined): string | null;
16
+ export declare function buildTokenHashes(input: {
17
+ sfrToken?: string | null;
18
+ plrToken?: string | null;
19
+ }): Pick<RequestContext, "tokenHash" | "tokenHashSfr" | "tokenHashPlr">;
20
+ export declare function runWithRequestContext<T>(ctx: RequestContext, cb: () => T): T;
21
+ export declare function getCtx(): RequestContext | undefined;
@@ -0,0 +1,39 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { createHash, createHmac } from "node:crypto";
3
+ const requestContext = new AsyncLocalStorage();
4
+ function normalizeSecret(input) {
5
+ if (!input)
6
+ return null;
7
+ const trimmed = input.trim();
8
+ return trimmed.length > 0 ? trimmed : null;
9
+ }
10
+ /**
11
+ * Compute a token hash for analytics correlation.
12
+ * If MCP_TOKEN_HASH_PEPPER is configured, use HMAC-SHA256.
13
+ * Otherwise use plain SHA-256 for backwards compatibility.
14
+ */
15
+ export function computeTokenHash(token) {
16
+ const normalizedToken = normalizeSecret(token);
17
+ if (!normalizedToken)
18
+ return null;
19
+ const pepper = normalizeSecret(process.env.MCP_TOKEN_HASH_PEPPER);
20
+ if (pepper) {
21
+ const digest = createHmac("sha256", pepper).update(normalizedToken).digest("hex");
22
+ return `hmac-sha256:${digest}`;
23
+ }
24
+ const digest = createHash("sha256").update(normalizedToken).digest("hex");
25
+ return `sha256:${digest}`;
26
+ }
27
+ export function buildTokenHashes(input) {
28
+ const tokenHashSfr = computeTokenHash(input.sfrToken);
29
+ const tokenHashPlr = computeTokenHash(input.plrToken);
30
+ const tokenHash = tokenHashSfr ?? tokenHashPlr;
31
+ return { tokenHash, tokenHashSfr, tokenHashPlr };
32
+ }
33
+ export function runWithRequestContext(ctx, cb) {
34
+ return requestContext.run(ctx, cb);
35
+ }
36
+ export function getCtx() {
37
+ return requestContext.getStore();
38
+ }
39
+ //# sourceMappingURL=requestContext.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"requestContext.js","sourceRoot":"","sources":["../../src/analytics/requestContext.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAarD,MAAM,cAAc,GAAG,IAAI,iBAAiB,EAAkB,CAAC;AAE/D,SAAS,eAAe,CAAC,KAAgC;IACvD,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAC;IACxB,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;IAC7B,OAAO,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC;AAC7C,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAgC;IAC/D,MAAM,eAAe,GAAG,eAAe,CAAC,KAAK,CAAC,CAAC;IAC/C,IAAI,CAAC,eAAe;QAAE,OAAO,IAAI,CAAC;IAElC,MAAM,MAAM,GAAG,eAAe,CAAC,OAAO,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC;IAClE,IAAI,MAAM,EAAE,CAAC;QACX,MAAM,MAAM,GAAG,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QAClF,OAAO,eAAe,MAAM,EAAE,CAAC;IACjC,CAAC;IAED,MAAM,MAAM,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC1E,OAAO,UAAU,MAAM,EAAE,CAAC;AAC5B,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,KAGhC;IACC,MAAM,YAAY,GAAG,gBAAgB,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IACtD,MAAM,YAAY,GAAG,gBAAgB,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IACtD,MAAM,SAAS,GAAG,YAAY,IAAI,YAAY,CAAC;IAC/C,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,YAAY,EAAE,CAAC;AACnD,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAI,GAAmB,EAAE,EAAW;IACvE,OAAO,cAAc,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;AACrC,CAAC;AAED,MAAM,UAAU,MAAM;IACpB,OAAO,cAAc,CAAC,QAAQ,EAAE,CAAC;AACnC,CAAC"}
@@ -0,0 +1 @@
1
+ export declare function sanitizeArgs(args: unknown): Record<string, unknown>;
@@ -0,0 +1,78 @@
1
+ const MAX_STRING_CHARS = 256;
2
+ const MAX_ARRAY_ITEMS = 20;
3
+ const MAX_OBJECT_KEYS = 100;
4
+ const MAX_DEPTH = 8;
5
+ const REDACTED = "[redacted]";
6
+ const TRUNCATED = "[truncated]";
7
+ const CIRCULAR = "[circular]";
8
+ const KEY_DENYLIST = /ssn|social|address|street|dob|birth|email|phone|token|secret|password|authorization|api[-_]?key/i;
9
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/i;
10
+ const PHONE_PATTERN = /^\+?[0-9()\-\s.]{10,}$/;
11
+ const TOKENISH_PATTERN = /^(?:[a-f0-9]{32,}|[A-Za-z0-9+/_\-]{32,})$/;
12
+ function truncateString(value) {
13
+ if (value.length <= MAX_STRING_CHARS)
14
+ return value;
15
+ return value.slice(0, MAX_STRING_CHARS) + TRUNCATED;
16
+ }
17
+ function looksSensitiveValue(value) {
18
+ const normalized = value.trim();
19
+ if (!normalized)
20
+ return false;
21
+ return EMAIL_PATTERN.test(normalized) || PHONE_PATTERN.test(normalized) || TOKENISH_PATTERN.test(normalized);
22
+ }
23
+ function sanitizeValue(value, keyName, depth, seen) {
24
+ if (depth > MAX_DEPTH)
25
+ return TRUNCATED;
26
+ if (value == null)
27
+ return value;
28
+ if (typeof value === "string") {
29
+ if ((keyName && KEY_DENYLIST.test(keyName)) || looksSensitiveValue(value))
30
+ return REDACTED;
31
+ return truncateString(value);
32
+ }
33
+ if (typeof value === "number") {
34
+ return Number.isFinite(value) ? value : String(value);
35
+ }
36
+ if (typeof value === "boolean")
37
+ return value;
38
+ if (typeof value === "bigint")
39
+ return value.toString();
40
+ if (value instanceof Date)
41
+ return value.toISOString();
42
+ if (Array.isArray(value)) {
43
+ const capped = value.slice(0, MAX_ARRAY_ITEMS);
44
+ const sanitized = capped.map((entry) => sanitizeValue(entry, keyName, depth + 1, seen));
45
+ if (value.length > MAX_ARRAY_ITEMS)
46
+ sanitized.push(TRUNCATED);
47
+ return sanitized;
48
+ }
49
+ if (typeof value === "object") {
50
+ const obj = value;
51
+ if (seen.has(obj))
52
+ return CIRCULAR;
53
+ seen.add(obj);
54
+ const out = {};
55
+ const entries = Object.entries(obj).slice(0, MAX_OBJECT_KEYS);
56
+ for (const [k, v] of entries) {
57
+ if (KEY_DENYLIST.test(k)) {
58
+ out[k] = REDACTED;
59
+ continue;
60
+ }
61
+ out[k] = sanitizeValue(v, k, depth + 1, seen);
62
+ }
63
+ if (Object.keys(obj).length > MAX_OBJECT_KEYS) {
64
+ out._truncatedKeys = true;
65
+ }
66
+ return out;
67
+ }
68
+ return truncateString(String(value));
69
+ }
70
+ export function sanitizeArgs(args) {
71
+ const seen = new WeakSet();
72
+ const sanitized = sanitizeValue(args, null, 0, seen);
73
+ if (sanitized && typeof sanitized === "object" && !Array.isArray(sanitized)) {
74
+ return sanitized;
75
+ }
76
+ return { value: sanitized };
77
+ }
78
+ //# sourceMappingURL=sanitize.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sanitize.js","sourceRoot":"","sources":["../../src/analytics/sanitize.ts"],"names":[],"mappings":"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"}
package/dist/http.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { randomUUID } from "node:crypto";
2
3
  import cors from "cors";
3
4
  import rateLimit from "express-rate-limit";
4
5
  import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
@@ -7,6 +8,9 @@ import { configFromHeaders } from "./config.js";
7
8
  import { createServer } from "./server.js";
8
9
  import { ApiError, HttpClient } from "./services/httpClient.js";
9
10
  import { VERSION } from "./version.js";
11
+ import { renderLandingPage } from "./landing.js";
12
+ import { buildTokenHashes, runWithRequestContext } from "./analytics/requestContext.js";
13
+ import { logEvent } from "./analytics/logger.js";
10
14
  function env(name) {
11
15
  const value = process.env[name];
12
16
  return value && value.trim().length > 0 ? value.trim() : undefined;
@@ -99,7 +103,20 @@ const mcpLimiter = rateLimit({
99
103
  max: 60,
100
104
  standardHeaders: true,
101
105
  legacyHeaders: false,
102
- handler(_req, res) {
106
+ handler(req, res) {
107
+ const sfrToken = headerValue(req, "SFR-Api-Token");
108
+ const plrToken = headerValue(req, "PLR-Api-Token");
109
+ const hashes = buildTokenHashes({ sfrToken, plrToken });
110
+ logEvent({
111
+ v: 1,
112
+ event: "rate_limited",
113
+ ts: new Date().toISOString(),
114
+ token_hash: hashes.tokenHash,
115
+ token_hash_sfr: hashes.tokenHashSfr,
116
+ token_hash_plr: hashes.tokenHashPlr,
117
+ client_ip: req.ip,
118
+ mode: "hosted_http",
119
+ });
103
120
  res
104
121
  .status(429)
105
122
  .json(jsonRpcError(-32000, "Rate limit exceeded. Max 60 requests per minute."));
@@ -186,53 +203,149 @@ async function quickTokenCheck(config) {
186
203
  app.post("/mcp", async (req, res) => {
187
204
  const sfrToken = headerValue(req, "SFR-Api-Token");
188
205
  const plrToken = headerValue(req, "PLR-Api-Token");
189
- if (!sfrToken && !plrToken) {
190
- res
191
- .status(401)
192
- .json(jsonRpcError(-32001, "Missing API token. Provide SFR-Api-Token and/or PLR-Api-Token."));
193
- return;
194
- }
195
- const config = configFromHeaders({ sfrToken, plrToken });
196
- if (strictTokenValidation) {
197
- try {
198
- const validationResult = await quickTokenCheck(config);
199
- if (validationResult === "invalid") {
200
- res.status(401).json(jsonRpcError(-32001, "Invalid API token."));
201
- return;
206
+ const requestStartedAt = performance.now();
207
+ const requestId = randomUUID();
208
+ const tokenHashes = buildTokenHashes({ sfrToken, plrToken });
209
+ return runWithRequestContext({
210
+ requestId,
211
+ mode: "hosted_http",
212
+ tokenHash: tokenHashes.tokenHash,
213
+ tokenHashSfr: tokenHashes.tokenHashSfr,
214
+ tokenHashPlr: tokenHashes.tokenHashPlr,
215
+ clientIp: req.ip,
216
+ }, async () => {
217
+ res.once("finish", () => {
218
+ const latencyMs = performance.now() - requestStartedAt;
219
+ logEvent({
220
+ v: 1,
221
+ event: "http_request",
222
+ ts: new Date().toISOString(),
223
+ method: req.method,
224
+ path: req.path,
225
+ status: res.statusCode,
226
+ latency_ms: Number(latencyMs.toFixed(1)),
227
+ token_hash: tokenHashes.tokenHash,
228
+ token_hash_sfr: tokenHashes.tokenHashSfr,
229
+ token_hash_plr: tokenHashes.tokenHashPlr,
230
+ request_id: requestId,
231
+ mode: "hosted_http",
232
+ client_ip: req.ip,
233
+ });
234
+ });
235
+ if (!sfrToken && !plrToken) {
236
+ logEvent({
237
+ v: 1,
238
+ event: "auth_failure",
239
+ ts: new Date().toISOString(),
240
+ reason: "missing_token",
241
+ has_sfr_token: false,
242
+ has_plr_token: false,
243
+ request_id: requestId,
244
+ client_ip: req.ip,
245
+ user_agent: req.get("user-agent") ?? null,
246
+ mode: "hosted_http",
247
+ });
248
+ res
249
+ .status(401)
250
+ .json(jsonRpcError(-32001, "Missing API token. Provide SFR-Api-Token and/or PLR-Api-Token."));
251
+ return;
252
+ }
253
+ const config = configFromHeaders({ sfrToken, plrToken });
254
+ if (strictTokenValidation) {
255
+ try {
256
+ const validationResult = await quickTokenCheck(config);
257
+ if (validationResult === "invalid") {
258
+ logEvent({
259
+ v: 1,
260
+ event: "auth_failure",
261
+ ts: new Date().toISOString(),
262
+ reason: "invalid_token",
263
+ has_sfr_token: !!sfrToken,
264
+ has_plr_token: !!plrToken,
265
+ token_hash: tokenHashes.tokenHash,
266
+ token_hash_sfr: tokenHashes.tokenHashSfr,
267
+ token_hash_plr: tokenHashes.tokenHashPlr,
268
+ request_id: requestId,
269
+ client_ip: req.ip,
270
+ mode: "hosted_http",
271
+ });
272
+ res.status(401).json(jsonRpcError(-32001, "Invalid API token."));
273
+ return;
274
+ }
275
+ if (validationResult === "unavailable") {
276
+ res.status(503).json(jsonRpcError(-32603, "Token validation unavailable. Try again later."));
277
+ return;
278
+ }
202
279
  }
203
- if (validationResult === "unavailable") {
280
+ catch (error) {
281
+ console.error(`Token validation error: ${safeErrorMessage(error)}`);
204
282
  res.status(503).json(jsonRpcError(-32603, "Token validation unavailable. Try again later."));
205
283
  return;
206
284
  }
207
285
  }
286
+ const { server, toolNames } = createServer(config);
287
+ // Log MCP protocol-level events (initialize, tools/list)
288
+ const rpcMethod = typeof req.body?.method === "string" ? req.body.method : null;
289
+ if (rpcMethod === "initialize") {
290
+ const clientInfo = req.body?.params?.clientInfo;
291
+ logEvent({
292
+ v: 1,
293
+ event: "session_start",
294
+ ts: new Date().toISOString(),
295
+ token_hash: tokenHashes.tokenHash,
296
+ token_hash_sfr: tokenHashes.tokenHashSfr,
297
+ token_hash_plr: tokenHashes.tokenHashPlr,
298
+ request_id: requestId,
299
+ has_sfr_token: !!sfrToken,
300
+ has_plr_token: !!plrToken,
301
+ client_name: typeof clientInfo?.name === "string" ? clientInfo.name : null,
302
+ client_version: typeof clientInfo?.version === "string" ? clientInfo.version : null,
303
+ protocol_version: typeof req.body?.params?.protocolVersion === "string"
304
+ ? req.body.params.protocolVersion
305
+ : null,
306
+ user_agent: req.get("user-agent") ?? null,
307
+ client_ip: req.ip,
308
+ mode: "hosted_http",
309
+ tool_names: toolNames,
310
+ tool_count: toolNames.length,
311
+ });
312
+ }
313
+ if (rpcMethod === "tools/list") {
314
+ logEvent({
315
+ v: 1,
316
+ event: "tool_list",
317
+ ts: new Date().toISOString(),
318
+ token_hash: tokenHashes.tokenHash,
319
+ request_id: requestId,
320
+ has_sfr_token: !!sfrToken,
321
+ has_plr_token: !!plrToken,
322
+ mode: "hosted_http",
323
+ tool_names: toolNames,
324
+ tool_count: toolNames.length,
325
+ });
326
+ }
327
+ const transport = new StreamableHTTPServerTransport({
328
+ sessionIdGenerator: undefined,
329
+ enableJsonResponse: true,
330
+ });
331
+ const cleanup = () => {
332
+ res.off("close", cleanup);
333
+ void transport.close().catch(() => { });
334
+ void server.close().catch(() => { });
335
+ };
336
+ res.on("close", cleanup);
337
+ try {
338
+ await server.connect(transport);
339
+ await transport.handleRequest(req, res, req.body);
340
+ }
208
341
  catch (error) {
209
- console.error(`Token validation error: ${safeErrorMessage(error)}`);
210
- res.status(503).json(jsonRpcError(-32603, "Token validation unavailable. Try again later."));
211
- return;
342
+ console.error(`Error handling MCP request: ${safeErrorMessage(error)}`);
343
+ if (!res.headersSent) {
344
+ res.status(500).json(jsonRpcError(-32603, "Internal server error"));
345
+ }
346
+ cleanup();
212
347
  }
213
- }
214
- const server = createServer(config);
215
- const transport = new StreamableHTTPServerTransport({
216
- sessionIdGenerator: undefined,
217
- enableJsonResponse: true,
218
348
  });
219
- const cleanup = () => {
220
- res.off("close", cleanup);
221
- void transport.close().catch(() => { });
222
- void server.close().catch(() => { });
223
- };
224
- res.on("close", cleanup);
225
- try {
226
- await server.connect(transport);
227
- await transport.handleRequest(req, res, req.body);
228
- }
229
- catch (error) {
230
- console.error(`Error handling MCP request: ${safeErrorMessage(error)}`);
231
- if (!res.headersSent) {
232
- res.status(500).json(jsonRpcError(-32603, "Internal server error"));
233
- }
234
- cleanup();
235
- }
236
349
  });
237
350
  const methodNotAllowed = (_req, res) => {
238
351
  res.status(405).json(jsonRpcError(-32000, "Method not allowed."));
@@ -243,6 +356,46 @@ app.options("/mcp", cors(corsOptions));
243
356
  app.get("/health", (_req, res) => {
244
357
  res.status(200).json({ ok: true, version: VERSION });
245
358
  });
359
+ // — Status dashboard (upstream connectivity checks) —
360
+ const startedAt = Date.now();
361
+ let statusCache = null;
362
+ const STATUS_CACHE_TTL_MS = 30_000;
363
+ async function probeApi(baseUrl) {
364
+ const start = Date.now();
365
+ try {
366
+ await fetch(baseUrl, {
367
+ method: "HEAD",
368
+ signal: AbortSignal.timeout(3000),
369
+ });
370
+ return { reachable: true, latencyMs: Date.now() - start };
371
+ }
372
+ catch {
373
+ // Any response (even errors) from the host means it's reachable;
374
+ // only network/timeout failures land here.
375
+ return { reachable: false, latencyMs: Date.now() - start };
376
+ }
377
+ }
378
+ app.get("/status", async (_req, res) => {
379
+ const now = Date.now();
380
+ if (!statusCache || now - statusCache.timestamp > STATUS_CACHE_TTL_MS) {
381
+ const sfrUrl = env("SFR_BASE_URL") ?? "https://api.sfranalytics.com";
382
+ const plrUrl = env("PLR_BASE_URL") ?? "https://radar-api.sfranalytics.com";
383
+ const [sfr, plr] = await Promise.all([probeApi(sfrUrl), probeApi(plrUrl)]);
384
+ statusCache = { timestamp: now, sfr, plr };
385
+ }
386
+ res.json({
387
+ ok: true,
388
+ version: VERSION,
389
+ uptimeSeconds: Math.floor((now - startedAt) / 1000),
390
+ startedAt: new Date(startedAt).toISOString(),
391
+ apis: { sfr: statusCache.sfr, plr: statusCache.plr },
392
+ });
393
+ });
394
+ // — Landing page —
395
+ const landingHtml = renderLandingPage({ crispWebsiteId: env("CRISP_WEBSITE_ID") });
396
+ app.get("/", (_req, res) => {
397
+ res.type("html").send(landingHtml);
398
+ });
246
399
  const httpServer = app.listen(port, host, () => {
247
400
  if (allowedOriginSet.size === 0 && !allowAnyOrigin) {
248
401
  console.error("MCP_ALLOWED_ORIGINS not set. Requests with an Origin header will be rejected.");