@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.
- package/README.md +67 -50
- package/dist/analytics/logger.d.ts +3 -0
- package/dist/analytics/logger.js +39 -0
- package/dist/analytics/logger.js.map +1 -0
- package/dist/analytics/requestContext.d.ts +21 -0
- package/dist/analytics/requestContext.js +39 -0
- package/dist/analytics/requestContext.js.map +1 -0
- package/dist/analytics/sanitize.d.ts +1 -0
- package/dist/analytics/sanitize.js +78 -0
- package/dist/analytics/sanitize.js.map +1 -0
- package/dist/http.js +193 -40
- package/dist/http.js.map +1 -1
- package/dist/index.js +17 -4
- package/dist/index.js.map +1 -1
- package/dist/landing.d.ts +3 -0
- package/dist/landing.js +891 -0
- package/dist/landing.js.map +1 -0
- package/dist/server.d.ts +5 -1
- package/dist/server.js +12 -4
- package/dist/server.js.map +1 -1
- package/dist/services/httpClient.d.ts +22 -0
- package/dist/services/httpClient.js +147 -26
- package/dist/services/httpClient.js.map +1 -1
- package/dist/tools/formatters.js +29 -1
- package/dist/tools/formatters.js.map +1 -1
- package/dist/tools/health.js +84 -7
- package/dist/tools/health.js.map +1 -1
- package/dist/tools/plr/borrowerContacts.js +47 -10
- package/dist/tools/plr/borrowerContacts.js.map +1 -1
- package/dist/tools/plr/borrowerProfile.js +11 -5
- package/dist/tools/plr/borrowerProfile.js.map +1 -1
- package/dist/tools/registerToolSafe.d.ts +5 -1
- package/dist/tools/registerToolSafe.js +110 -4
- package/dist/tools/registerToolSafe.js.map +1 -1
- package/dist/tools/sfr/rentalStats.js +19 -3
- package/dist/tools/sfr/rentalStats.js.map +1 -1
- package/dist/tools/sfr/topBuyers.js +1 -0
- package/dist/tools/sfr/topBuyers.js.map +1 -1
- package/dist/tools/welcome.d.ts +3 -0
- package/dist/tools/welcome.js +58 -0
- package/dist/tools/welcome.js.map +1 -0
- 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
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
"
|
|
26
|
+
"sfranalytics": {
|
|
23
27
|
"command": "npx",
|
|
24
28
|
"args": ["-y", "@sfranalytics/mcp"],
|
|
25
29
|
"env": {
|
|
26
|
-
"SFR_API_TOKEN": "
|
|
27
|
-
"PLR_API_TOKEN": "
|
|
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
|
-
###
|
|
38
|
+
### Other MCP Clients
|
|
35
39
|
|
|
36
|
-
|
|
40
|
+
Use the hosted streamable HTTP endpoint:
|
|
37
41
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
```
|
|
42
|
+
- Endpoint: `https://mcp.sfranalytics.com/mcp`
|
|
43
|
+
- Headers: `SFR-Api-Token` and/or `PLR-Api-Token`
|
|
41
44
|
|
|
42
|
-
|
|
45
|
+
### Verify Setup
|
|
43
46
|
|
|
44
|
-
|
|
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
|
-
|
|
49
|
+
> Check my SFR Analytics connection
|
|
51
50
|
|
|
52
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
- An MCP-compatible client (Claude Code, Claude Desktop, etc.)
|
|
155
|
+
</details>
|
|
139
156
|
|
|
140
157
|
## Support
|
|
141
158
|
|
|
@@ -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(
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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(`
|
|
210
|
-
res.
|
|
211
|
-
|
|
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.");
|