@4da/mcp-server 4.6.6 → 5.0.1
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 +19 -1
- package/dist/auth-context.d.ts +49 -0
- package/dist/auth-context.js +72 -0
- package/dist/auth.d.ts +42 -15
- package/dist/auth.js +160 -49
- package/dist/db.d.ts +1 -1
- package/dist/db.js +72 -37
- package/dist/http-transport.d.ts +72 -10
- package/dist/http-transport.js +175 -99
- package/dist/index.d.ts +7 -24
- package/dist/index.js +178 -135
- package/dist/setup.js +0 -0
- package/dist/tool-dispatch.d.ts +6 -6
- package/dist/tool-dispatch.js +6 -0
- package/dist/tools/get-actionable-signals.js +5 -1
- package/dist/tools/get-relevant-content.js +4 -1
- package/package.json +31 -14
package/dist/db.js
CHANGED
|
@@ -635,15 +635,16 @@ export class FourDADatabase {
|
|
|
635
635
|
* Get relevant content items scored by the Rust pipeline (preferred) or
|
|
636
636
|
* TypeScript fallback scoring when Rust scores are unavailable.
|
|
637
637
|
*/
|
|
638
|
-
getRelevantContent(minScore = 0.35, sourceType, limit = 20, sinceHours = 24) {
|
|
638
|
+
getRelevantContent(minScore = 0.35, sourceType, limit = 20, sinceHours = 24, requireCurrentVersion = false) {
|
|
639
639
|
const sinceDate = new Date(Date.now() - sinceHours * 60 * 60 * 1000)
|
|
640
640
|
.toISOString()
|
|
641
641
|
.replace("T", " ")
|
|
642
642
|
.slice(0, 19);
|
|
643
643
|
const hasRustScores = this.hasColumn("source_items", "relevance_score");
|
|
644
644
|
if (hasRustScores) {
|
|
645
|
-
return this.getRelevantContentFromRust(minScore, sourceType, limit, sinceDate);
|
|
645
|
+
return this.getRelevantContentFromRust(minScore, sourceType, limit, sinceDate, requireCurrentVersion);
|
|
646
646
|
}
|
|
647
|
+
// TypeScript fallback computes scores fresh per call — current by construction.
|
|
647
648
|
return this.getRelevantContentFallback(minScore, sourceType, limit, sinceDate);
|
|
648
649
|
}
|
|
649
650
|
/**
|
|
@@ -722,13 +723,57 @@ export class FourDADatabase {
|
|
|
722
723
|
* Use Rust-computed relevance_score from the desktop app's scoring pipeline.
|
|
723
724
|
* These scores are from the full 5-axis PASIFA engine — much more accurate.
|
|
724
725
|
*/
|
|
725
|
-
getRelevantContentFromRust(minScore, sourceType, limit, sinceDate) {
|
|
726
|
+
getRelevantContentFromRust(minScore, sourceType, limit, sinceDate, requireCurrentVersion = false) {
|
|
726
727
|
// Grounding subquery — guarded: older/partial 4DA DBs may have relevance_score
|
|
727
728
|
// (so the Rust path runs) but lack source_item_dependencies. Fall back to 0
|
|
728
729
|
// (→ evidence_class "semantic_only") rather than throwing.
|
|
729
730
|
const depCount = this.hasColumn("source_item_dependencies", "source_item_id")
|
|
730
731
|
? `(SELECT COUNT(*) FROM source_item_dependencies d WHERE d.source_item_id = source_items.id)`
|
|
731
732
|
: `0`;
|
|
733
|
+
// Stale-epoch guard for deep-window fallbacks: when the caller widens to the
|
|
734
|
+
// 30-day window it reaches items whose stored score/signal columns may come
|
|
735
|
+
// from an OLDER scoring pipeline (a version bump makes the tail stale until
|
|
736
|
+
// the drain re-scores it). Ranking those raw scores against current ones —
|
|
737
|
+
// or trusting stale signal_type/signal_priority — produces claims the
|
|
738
|
+
// current pipeline doesn't stand behind. The MCP server can't import the
|
|
739
|
+
// Rust PIPELINE_VERSION constant, so the DB itself is the source of truth:
|
|
740
|
+
// MAX(scored_pipeline_version) IS the current brain's stamp (uncorrelated
|
|
741
|
+
// subquery, computed once per statement). Guarded on column existence for
|
|
742
|
+
// older desktop-app DBs.
|
|
743
|
+
const versionGuard = requireCurrentVersion && this.hasColumn("source_items", "scored_pipeline_version")
|
|
744
|
+
? ` AND scored_pipeline_version = (SELECT MAX(scored_pipeline_version) FROM source_items)`
|
|
745
|
+
: ``;
|
|
746
|
+
// Curation guard: never return an item the current pipeline EXPLICITLY
|
|
747
|
+
// rejected. `feed_relevant` is the analysis run's persisted verdict, and a
|
|
748
|
+
// score threshold alone does not respect it — the desktop content graph hit
|
|
749
|
+
// exactly this and adopted corpus parity in Phase 95 (W4-5).
|
|
750
|
+
//
|
|
751
|
+
// It matters because a verdict can be negative while the score stays above
|
|
752
|
+
// the threshold. v18's look-alike gate makes ungrounded registry releases
|
|
753
|
+
// categorically non-relevant while deliberately KEEPING their capped score
|
|
754
|
+
// (0.37 / 0.42) for ranking and display, so `relevance_score >= 0.35` alone
|
|
755
|
+
// still surfaces them. Live 2026-07-26: 30 crates_io items inside the
|
|
756
|
+
// 30-day window were already `feed_relevant = 0` and were still returned.
|
|
757
|
+
//
|
|
758
|
+
// NULL is KEPT, not excluded: an unjudged item is "not yet curated", not
|
|
759
|
+
// "rejected", and dropping it would blank the tool on a fresh corpus
|
|
760
|
+
// (cold-start doctrine). Guarded on column existence for older 4DA DBs.
|
|
761
|
+
//
|
|
762
|
+
// Deliberately NOT extended with `feed_verdict_version = <current>` even
|
|
763
|
+
// though Phase 101 added that stamp, and even though the 720h deep-fallback
|
|
764
|
+
// above DOES gate on `scored_pipeline_version`. The asymmetry is intentional:
|
|
765
|
+
// the version guard there narrows a last-resort widening, whereas this guard
|
|
766
|
+
// sits on the PRIMARY path, so excluding not-yet-reconciled verdicts would
|
|
767
|
+
// empty the tool after every pipeline bump until the reconciliation pass
|
|
768
|
+
// caught up (measured on the desktop: 94% of live graph nodes held a stale
|
|
769
|
+
// verdict immediately after the v18 bump). The reconciliation converging is
|
|
770
|
+
// the fix — see `analysis_backfill::reconcile_stale_verdicts_cycle`. This
|
|
771
|
+
// note exists because `check-mcp-server-sync.cjs` asks "do MCP queries need
|
|
772
|
+
// updating?" on every db-layer change; the answer for the verdict stamp is
|
|
773
|
+
// no, by design.
|
|
774
|
+
const curationGuard = this.hasColumn("source_items", "feed_relevant")
|
|
775
|
+
? ` AND (feed_relevant IS NULL OR feed_relevant = 1)`
|
|
776
|
+
: ``;
|
|
732
777
|
let query = `
|
|
733
778
|
SELECT id, source_type, source_id, url, title, content, content_hash,
|
|
734
779
|
created_at, last_seen, relevance_score, content_type,
|
|
@@ -736,7 +781,7 @@ export class FourDADatabase {
|
|
|
736
781
|
${depCount} AS dep_match_count
|
|
737
782
|
FROM source_items
|
|
738
783
|
WHERE relevance_score >= ?
|
|
739
|
-
AND datetime(created_at) >= datetime(?)
|
|
784
|
+
AND datetime(created_at) >= datetime(?)${versionGuard}${curationGuard}
|
|
740
785
|
`;
|
|
741
786
|
const params = [minScore, sinceDate];
|
|
742
787
|
if (sourceType) {
|
|
@@ -1008,12 +1053,13 @@ export class FourDADatabase {
|
|
|
1008
1053
|
context.ace.detected_tech.filter((dt) => itemTopics.some((t) => this.topicMatches(t, dt.name))).length *
|
|
1009
1054
|
0.05
|
|
1010
1055
|
: 0;
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
// Anti-penalty from exclusions
|
|
1056
|
+
// Learned affinity score DEMOTED in v19 (AD-029) — must mirror
|
|
1057
|
+
// computeRelevanceScore so the explanation matches what actually
|
|
1058
|
+
// scored (the two previously diverged on the anti-penalty weight too:
|
|
1059
|
+
// scorer used *0.3, this explanation used *0.5).
|
|
1060
|
+
const learnedScore = 0;
|
|
1061
|
+
// Anti-penalty from user-authored exclusions only (learned anti-topics
|
|
1062
|
+
// demoted with the rest of the behavioral stack, AD-029).
|
|
1017
1063
|
let antiPenalty = 0;
|
|
1018
1064
|
for (const exclusion of context.exclusions) {
|
|
1019
1065
|
if (itemTopics.some((t) => this.topicMatches(t, exclusion))) {
|
|
@@ -1021,13 +1067,6 @@ export class FourDADatabase {
|
|
|
1021
1067
|
break;
|
|
1022
1068
|
}
|
|
1023
1069
|
}
|
|
1024
|
-
if (antiPenalty === 0 && context.learned) {
|
|
1025
|
-
for (const antiTopic of context.learned.anti_topics) {
|
|
1026
|
-
if (itemTopics.some((t) => this.topicMatches(t, antiTopic.topic))) {
|
|
1027
|
-
antiPenalty = Math.max(antiPenalty, antiTopic.confidence * 0.5);
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
1070
|
const finalScore = Math.max(0, Math.min(1, staticScore + aceScore + learnedScore - antiPenalty));
|
|
1032
1071
|
const scoreBreakdown = {
|
|
1033
1072
|
embedding_similarity: null, // Not computing embedding similarity in MCP server
|
|
@@ -1087,12 +1126,17 @@ export class FourDADatabase {
|
|
|
1087
1126
|
message: `Item ${itemId} of type ${sourceType} not found`,
|
|
1088
1127
|
};
|
|
1089
1128
|
}
|
|
1090
|
-
// Map action to signal strength
|
|
1129
|
+
// Map action to signal strength — v19: unified onto the canonical ACE
|
|
1130
|
+
// scale (src-tauri/src/ace/behavior/types.rs). The old MCP-only scale
|
|
1131
|
+
// (click 0.3 / save 0.8 / dismiss -0.2 / mark_irrelevant -0.5) meant an
|
|
1132
|
+
// agent-recorded rejection never crossed the Rust side's explicit-
|
|
1133
|
+
// negative (-0.8) or anti-topic (-0.5 exclusive) thresholds, so agent
|
|
1134
|
+
// feedback could never register a real rejection.
|
|
1091
1135
|
const signalStrength = {
|
|
1092
|
-
click: 0.
|
|
1093
|
-
save: 0
|
|
1094
|
-
dismiss: -0.
|
|
1095
|
-
mark_irrelevant: -0
|
|
1136
|
+
click: 0.55,
|
|
1137
|
+
save: 1.0,
|
|
1138
|
+
dismiss: -0.8,
|
|
1139
|
+
mark_irrelevant: -1.0,
|
|
1096
1140
|
};
|
|
1097
1141
|
try {
|
|
1098
1142
|
const stmt = this.db.prepare(`
|
|
@@ -1311,21 +1355,12 @@ export class FourDADatabase {
|
|
|
1311
1355
|
}
|
|
1312
1356
|
}
|
|
1313
1357
|
}
|
|
1314
|
-
// Learned
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
// Anti-topics penalty
|
|
1323
|
-
for (const antiTopic of context.learned.anti_topics) {
|
|
1324
|
-
if (itemTopics.some((t) => this.topicMatches(t, antiTopic.topic))) {
|
|
1325
|
-
score -= antiTopic.confidence * 0.3;
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1358
|
+
// Learned affinity/anti-topic weighting DEMOTED in v19 (AD-029) to
|
|
1359
|
+
// mirror the Rust pipeline: this TypeScript fallback scorer applied
|
|
1360
|
+
// its own behavioral weights (+affinity*0.1*confidence,
|
|
1361
|
+
// -anti_confidence*0.3) whenever Rust scores were absent — a second,
|
|
1362
|
+
// uncalibrated ranking regime fed by the same untrusted capture layer.
|
|
1363
|
+
// Static identity + ACE stack matching above remain the scoring basis.
|
|
1329
1364
|
return Math.max(0, Math.min(1, score));
|
|
1330
1365
|
}
|
|
1331
1366
|
/**
|
package/dist/http-transport.d.ts
CHANGED
|
@@ -1,25 +1,87 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 4DA MCP Server — Streamable HTTP Transport
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Serves MCP over HTTP using the v2 `createMcpHandler` entry (one factory,
|
|
5
|
+
* one endpoint, both protocol eras): 2026-07-28 requests are served natively;
|
|
6
|
+
* 2025-era requests get the established stateless serving (fresh instance per
|
|
7
|
+
* request — the same behavior as the previous per-request
|
|
8
|
+
* `StreamableHTTPServerTransport` wiring). Node's built-in http module only;
|
|
9
|
+
* no express or other framework needed.
|
|
6
10
|
*
|
|
7
11
|
* Security:
|
|
8
|
-
* - Binds to 127.0.0.1
|
|
9
|
-
*
|
|
10
|
-
*
|
|
12
|
+
* - Binds to 127.0.0.1 by default. `--host` can widen that; a non-loopback
|
|
13
|
+
* bind is refused unless a shared auth secret is configured, and forces
|
|
14
|
+
* authentication on for every request.
|
|
15
|
+
* - DNS rebinding protection via a `Host` header allowlist, applied to EVERY
|
|
16
|
+
* request. This is the primary guard: a rebinding attack necessarily
|
|
17
|
+
* presents the attacker's hostname in `Host`, and unlike `Origin` the header
|
|
18
|
+
* is mandatory, so a request cannot skip the check by omitting it.
|
|
19
|
+
* - `Origin`, when present, must also be localhost-class. A missing `Origin`
|
|
20
|
+
* is not a pass — it is simply not additional evidence, so such requests are
|
|
21
|
+
* admitted only on the strength of the `Host` check above, and only ever
|
|
22
|
+
* without credentials on a loopback bind.
|
|
23
|
+
* - Bearer tokens are HMAC-SHA256 verified (see auth.ts) before any claim is
|
|
24
|
+
* read, and the verified role is enforced on tool dispatch.
|
|
25
|
+
* - Stateless (no session tracking)
|
|
11
26
|
*/
|
|
12
|
-
import { Server } from "
|
|
27
|
+
import { type Server as HttpServer } from "node:http";
|
|
28
|
+
import { type Server } from "@modelcontextprotocol/server";
|
|
13
29
|
export interface HttpServerOptions {
|
|
14
30
|
port: number;
|
|
15
31
|
host: string;
|
|
16
32
|
}
|
|
33
|
+
/** Handle for a running HTTP transport, so callers (and tests) can shut it down. */
|
|
34
|
+
export interface HttpServerHandle {
|
|
35
|
+
server: HttpServer;
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
/** Security posture derived from the bind address plus environment config. */
|
|
39
|
+
export interface HttpSecurityPolicy {
|
|
40
|
+
/** True when the server is bound to a loopback address only. */
|
|
41
|
+
loopback: boolean;
|
|
42
|
+
/** True when every non-health request must carry a verified Bearer token. */
|
|
43
|
+
authRequired: boolean;
|
|
44
|
+
/** Hostnames accepted in the `Host` header (port-agnostic). */
|
|
45
|
+
allowedHostnames: string[];
|
|
46
|
+
/** Hostnames accepted in the `Origin` header, when one is sent. */
|
|
47
|
+
allowedOrigins: string[];
|
|
48
|
+
}
|
|
49
|
+
/** Loopback bind addresses — everything else reaches the network. */
|
|
50
|
+
export declare function isLoopbackBind(host: string): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the security policy for a bind address.
|
|
53
|
+
*
|
|
54
|
+
* A loopback bind keeps the historical behaviour: auth is opt-in via
|
|
55
|
+
* `MCP_AUTH_REQUIRED`. Any other bind is network-exposed, so authentication is
|
|
56
|
+
* mandatory regardless of that variable.
|
|
57
|
+
*/
|
|
58
|
+
export declare function resolveSecurityPolicy(host: string): HttpSecurityPolicy;
|
|
59
|
+
export interface GuardRejection {
|
|
60
|
+
status: number;
|
|
61
|
+
error: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* DNS rebinding guard. Runs on every request, before routing and before auth.
|
|
65
|
+
*
|
|
66
|
+
* Returns null when the request may proceed, or the rejection to send.
|
|
67
|
+
*/
|
|
68
|
+
export declare function evaluateRequestGuard(headers: {
|
|
69
|
+
host?: string | string[];
|
|
70
|
+
origin?: string | string[];
|
|
71
|
+
}, policy: HttpSecurityPolicy): GuardRejection | null;
|
|
72
|
+
/**
|
|
73
|
+
* Refuse to start a network-exposed server that cannot authenticate anyone,
|
|
74
|
+
* and make a non-loopback bind impossible to do accidentally.
|
|
75
|
+
*/
|
|
76
|
+
export declare function enforceBindPolicy(host: string, policy: HttpSecurityPolicy): void;
|
|
17
77
|
/**
|
|
18
78
|
* Start a Streamable HTTP server for the MCP protocol.
|
|
19
79
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
80
|
+
* Takes a server factory: `createMcpHandler` builds a fresh instance per
|
|
81
|
+
* request (stateless — compatible with serverless environments and simple
|
|
82
|
+
* round-robin load balancing).
|
|
83
|
+
*
|
|
84
|
+
* Resolves once the server is accepting connections.
|
|
23
85
|
*/
|
|
24
|
-
export declare function startHttpServer(
|
|
86
|
+
export declare function startHttpServer(factory: () => Server, options?: HttpServerOptions): Promise<HttpServerHandle>;
|
|
25
87
|
//# sourceMappingURL=http-transport.d.ts.map
|
package/dist/http-transport.js
CHANGED
|
@@ -2,146 +2,222 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* 4DA MCP Server — Streamable HTTP Transport
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Serves MCP over HTTP using the v2 `createMcpHandler` entry (one factory,
|
|
6
|
+
* one endpoint, both protocol eras): 2026-07-28 requests are served natively;
|
|
7
|
+
* 2025-era requests get the established stateless serving (fresh instance per
|
|
8
|
+
* request — the same behavior as the previous per-request
|
|
9
|
+
* `StreamableHTTPServerTransport` wiring). Node's built-in http module only;
|
|
10
|
+
* no express or other framework needed.
|
|
7
11
|
*
|
|
8
12
|
* Security:
|
|
9
|
-
* - Binds to 127.0.0.1
|
|
10
|
-
*
|
|
11
|
-
*
|
|
13
|
+
* - Binds to 127.0.0.1 by default. `--host` can widen that; a non-loopback
|
|
14
|
+
* bind is refused unless a shared auth secret is configured, and forces
|
|
15
|
+
* authentication on for every request.
|
|
16
|
+
* - DNS rebinding protection via a `Host` header allowlist, applied to EVERY
|
|
17
|
+
* request. This is the primary guard: a rebinding attack necessarily
|
|
18
|
+
* presents the attacker's hostname in `Host`, and unlike `Origin` the header
|
|
19
|
+
* is mandatory, so a request cannot skip the check by omitting it.
|
|
20
|
+
* - `Origin`, when present, must also be localhost-class. A missing `Origin`
|
|
21
|
+
* is not a pass — it is simply not additional evidence, so such requests are
|
|
22
|
+
* admitted only on the strength of the `Host` check above, and only ever
|
|
23
|
+
* without credentials on a loopback bind.
|
|
24
|
+
* - Bearer tokens are HMAC-SHA256 verified (see auth.ts) before any claim is
|
|
25
|
+
* read, and the verified role is enforced on tool dispatch.
|
|
26
|
+
* - Stateless (no session tracking)
|
|
12
27
|
*/
|
|
13
28
|
import { createServer } from "node:http";
|
|
14
29
|
import { readFileSync } from "node:fs";
|
|
15
30
|
import { join, dirname } from "node:path";
|
|
16
31
|
import { fileURLToPath } from "node:url";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
32
|
+
import { createMcpHandler, localhostAllowedHostnames, localhostAllowedOrigins, validateHostHeader, validateOriginHeader, } from "@modelcontextprotocol/server";
|
|
33
|
+
import { toNodeHandler } from "@modelcontextprotocol/node";
|
|
34
|
+
import { extractAuthClaims, getAuthSecret } from "./auth.js";
|
|
35
|
+
import { runWithAuthContext } from "./auth-context.js";
|
|
19
36
|
const DEFAULT_PORT = 4840;
|
|
20
37
|
const DEFAULT_HOST = "127.0.0.1";
|
|
21
38
|
const __ht_dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
39
|
const SERVER_VERSION = JSON.parse(readFileSync(join(__ht_dirname, "..", "package.json"), "utf-8")).version;
|
|
40
|
+
/** Strip IPv6 brackets and zone id so "[::1]" and "::1" compare equal. */
|
|
41
|
+
function normalizeHostname(host) {
|
|
42
|
+
return host.trim().replace(/^\[|\]$/g, "").split("%")[0].toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
/** Loopback bind addresses — everything else reaches the network. */
|
|
45
|
+
export function isLoopbackBind(host) {
|
|
46
|
+
const h = normalizeHostname(host);
|
|
47
|
+
return h === "localhost" || h === "::1" || /^127\./.test(h);
|
|
48
|
+
}
|
|
23
49
|
/**
|
|
24
|
-
*
|
|
50
|
+
* Extra hostnames accepted in `Host`/`Origin`, from `MCP_ALLOWED_HOSTS`
|
|
51
|
+
* (comma-separated). Needed when binding to `0.0.0.0`, where the hostname
|
|
52
|
+
* legitimate clients use is not knowable from the bind address.
|
|
25
53
|
*/
|
|
26
|
-
function
|
|
27
|
-
return
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
54
|
+
function configuredExtraHosts() {
|
|
55
|
+
return (process.env.MCP_ALLOWED_HOSTS ?? "")
|
|
56
|
+
.split(",")
|
|
57
|
+
.map((h) => normalizeHostname(h))
|
|
58
|
+
.filter((h) => h.length > 0);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the security policy for a bind address.
|
|
62
|
+
*
|
|
63
|
+
* A loopback bind keeps the historical behaviour: auth is opt-in via
|
|
64
|
+
* `MCP_AUTH_REQUIRED`. Any other bind is network-exposed, so authentication is
|
|
65
|
+
* mandatory regardless of that variable.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveSecurityPolicy(host) {
|
|
68
|
+
const loopback = isLoopbackBind(host);
|
|
69
|
+
const extras = configuredExtraHosts();
|
|
70
|
+
// A concrete non-loopback bind address is itself a legitimate Host value;
|
|
71
|
+
// wildcards (0.0.0.0 / ::) name no reachable host, so they add nothing.
|
|
72
|
+
const bindHostname = normalizeHostname(host);
|
|
73
|
+
const isWildcard = bindHostname === "0.0.0.0" || bindHostname === "::" || bindHostname === "";
|
|
74
|
+
const bindExtras = loopback || isWildcard ? [] : [bindHostname];
|
|
75
|
+
return {
|
|
76
|
+
loopback,
|
|
77
|
+
authRequired: loopback ? process.env.MCP_AUTH_REQUIRED === "true" : true,
|
|
78
|
+
allowedHostnames: [...localhostAllowedHostnames(), ...bindExtras, ...extras],
|
|
79
|
+
allowedOrigins: [...localhostAllowedOrigins(), ...bindExtras, ...extras],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* DNS rebinding guard. Runs on every request, before routing and before auth.
|
|
84
|
+
*
|
|
85
|
+
* Returns null when the request may proceed, or the rejection to send.
|
|
86
|
+
*/
|
|
87
|
+
export function evaluateRequestGuard(headers, policy) {
|
|
88
|
+
const pick = (v) => Array.isArray(v) ? v[0] : v;
|
|
89
|
+
// `Host` is mandatory in HTTP/1.1 and always present in HTTP/2 (as
|
|
90
|
+
// :authority, which Node surfaces here). Missing means malformed, not
|
|
91
|
+
// trusted — validateHostHeader rejects it.
|
|
92
|
+
const hostResult = validateHostHeader(pick(headers.host), policy.allowedHostnames);
|
|
93
|
+
if (!hostResult.ok) {
|
|
94
|
+
return { status: 403, error: `Forbidden: DNS rebinding protection (${hostResult.errorCode})` };
|
|
95
|
+
}
|
|
96
|
+
// `Origin` is only sent by browsers. A present-but-foreign value is a
|
|
97
|
+
// cross-origin browser request and is refused; an absent value adds no
|
|
98
|
+
// evidence either way, and the Host check above has already run.
|
|
99
|
+
const originResult = validateOriginHeader(pick(headers.origin), policy.allowedOrigins);
|
|
100
|
+
if (!originResult.ok) {
|
|
101
|
+
return { status: 403, error: `Forbidden: ${originResult.errorCode}` };
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
function sendJson(res, status, body) {
|
|
106
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
107
|
+
res.end(JSON.stringify(body));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Refuse to start a network-exposed server that cannot authenticate anyone,
|
|
111
|
+
* and make a non-loopback bind impossible to do accidentally.
|
|
112
|
+
*/
|
|
113
|
+
export function enforceBindPolicy(host, policy) {
|
|
114
|
+
if (policy.loopback)
|
|
115
|
+
return;
|
|
116
|
+
if (!getAuthSecret()) {
|
|
117
|
+
throw new Error(`Refusing to bind the MCP HTTP transport to a non-loopback address (${host}) with no auth secret.\n` +
|
|
118
|
+
` Every 4DA tool (dependency inventory, decisions, agent memory, Developer DNA) would be\n` +
|
|
119
|
+
` readable and writable by anyone who can reach this port.\n` +
|
|
120
|
+
` Set MCP_AUTH_SECRET (the same value as the relay's JWT_SECRET) to enable token verification,\n` +
|
|
121
|
+
` or drop --host and serve on 127.0.0.1.`);
|
|
122
|
+
}
|
|
123
|
+
console.error("");
|
|
124
|
+
console.error(" ############################################################");
|
|
125
|
+
console.error(` # WARNING: MCP HTTP transport is bound to ${host}`);
|
|
126
|
+
console.error(" # This exposes 4DA tools beyond this machine.");
|
|
127
|
+
console.error(" # Authentication is REQUIRED for every request on this bind.");
|
|
128
|
+
console.error(" # Put it behind TLS and a trusted network boundary.");
|
|
129
|
+
console.error(" ############################################################");
|
|
130
|
+
console.error("");
|
|
131
|
+
const extras = configuredExtraHosts();
|
|
132
|
+
const bindHostname = normalizeHostname(host);
|
|
133
|
+
if ((bindHostname === "0.0.0.0" || bindHostname === "::") && extras.length === 0) {
|
|
134
|
+
console.error(" NOTE: MCP_ALLOWED_HOSTS is unset, so only localhost-class Host headers are accepted.");
|
|
135
|
+
console.error(" Set MCP_ALLOWED_HOSTS=<hostname-or-ip[,...]> to the address clients actually use.");
|
|
136
|
+
console.error("");
|
|
137
|
+
}
|
|
41
138
|
}
|
|
42
139
|
/**
|
|
43
140
|
* Start a Streamable HTTP server for the MCP protocol.
|
|
44
141
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
142
|
+
* Takes a server factory: `createMcpHandler` builds a fresh instance per
|
|
143
|
+
* request (stateless — compatible with serverless environments and simple
|
|
144
|
+
* round-robin load balancing).
|
|
145
|
+
*
|
|
146
|
+
* Resolves once the server is accepting connections.
|
|
48
147
|
*/
|
|
49
|
-
export async function startHttpServer(
|
|
148
|
+
export async function startHttpServer(factory, options = { port: DEFAULT_PORT, host: DEFAULT_HOST }) {
|
|
50
149
|
const { port, host } = options;
|
|
150
|
+
const policy = resolveSecurityPolicy(host);
|
|
151
|
+
enforceBindPolicy(host, policy);
|
|
152
|
+
const mcpHandler = createMcpHandler(factory);
|
|
153
|
+
const handleMcpRequest = toNodeHandler(mcpHandler, {
|
|
154
|
+
onerror: (err) => {
|
|
155
|
+
console.error("MCP transport error:", err);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
51
158
|
const httpServer = createServer(async (req, res) => {
|
|
52
|
-
// DNS rebinding protection
|
|
53
|
-
const
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1" && url.hostname !== "::1" && url.hostname !== "[::1]") {
|
|
58
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
59
|
-
res.end(JSON.stringify({ error: "Forbidden: DNS rebinding protection" }));
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
65
|
-
res.end(JSON.stringify({ error: "Forbidden: invalid Origin header" }));
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
159
|
+
// DNS rebinding protection — every request, before routing or auth.
|
|
160
|
+
const rejection = evaluateRequestGuard(req.headers, policy);
|
|
161
|
+
if (rejection) {
|
|
162
|
+
sendJson(res, rejection.status, { error: rejection.error });
|
|
163
|
+
return;
|
|
68
164
|
}
|
|
69
|
-
|
|
70
|
-
|
|
165
|
+
const pathname = new URL(req.url || "/", `http://${host}:${port}`).pathname;
|
|
166
|
+
// Auth gate. Unauthenticated health checks on "/" stay open so liveness
|
|
167
|
+
// probes work; the endpoint reveals only name, version and status.
|
|
71
168
|
let claims = null;
|
|
72
|
-
if (authRequired) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
169
|
+
if (policy.authRequired && pathname !== "/") {
|
|
170
|
+
claims = extractAuthClaims(req);
|
|
171
|
+
if (!claims) {
|
|
172
|
+
res.writeHead(401, {
|
|
173
|
+
"Content-Type": "application/json",
|
|
174
|
+
"WWW-Authenticate": 'Bearer realm="4da-mcp"',
|
|
175
|
+
});
|
|
176
|
+
res.end(JSON.stringify({ error: "Authentication required" }));
|
|
177
|
+
return;
|
|
82
178
|
}
|
|
83
179
|
}
|
|
84
|
-
// Route requests
|
|
85
|
-
const pathname = new URL(req.url || "/", `http://${host}:${port}`).pathname;
|
|
86
180
|
// Health check endpoint
|
|
87
181
|
if (pathname === "/" && req.method === "GET") {
|
|
88
|
-
res
|
|
89
|
-
res.end(JSON.stringify({
|
|
182
|
+
sendJson(res, 200, {
|
|
90
183
|
name: "4da-mcp",
|
|
91
184
|
version: SERVER_VERSION,
|
|
92
185
|
transport: "streamable-http",
|
|
93
186
|
status: "ok",
|
|
94
|
-
})
|
|
187
|
+
});
|
|
95
188
|
return;
|
|
96
189
|
}
|
|
97
190
|
// Only handle /mcp endpoint
|
|
98
191
|
if (pathname !== "/mcp") {
|
|
99
|
-
res
|
|
100
|
-
res.end(JSON.stringify({ error: "Not Found" }));
|
|
192
|
+
sendJson(res, 404, { error: "Not Found" });
|
|
101
193
|
return;
|
|
102
194
|
}
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
body = await parseBody(req);
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
111
|
-
res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
// Stateless mode: new transport per request
|
|
116
|
-
const transport = new StreamableHTTPServerTransport({
|
|
117
|
-
sessionIdGenerator: undefined,
|
|
118
|
-
});
|
|
119
|
-
res.on("close", () => {
|
|
120
|
-
transport.close();
|
|
121
|
-
});
|
|
122
|
-
try {
|
|
123
|
-
await server.connect(transport);
|
|
124
|
-
await transport.handleRequest(req, res, body);
|
|
125
|
-
}
|
|
126
|
-
catch (err) {
|
|
127
|
-
console.error("MCP transport error:", err);
|
|
128
|
-
if (!res.headersSent) {
|
|
129
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
130
|
-
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
131
|
-
}
|
|
132
|
-
}
|
|
195
|
+
// Hand off to the MCP handler — body parsing, era classification, and
|
|
196
|
+
// per-request instance construction happen inside the entry. The auth
|
|
197
|
+
// context rides along so tool dispatch can enforce the verified role.
|
|
198
|
+
await runWithAuthContext({ claims, enforced: policy.authRequired }, () => handleMcpRequest(req, res));
|
|
133
199
|
});
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
200
|
+
await new Promise((resolve, reject) => {
|
|
201
|
+
httpServer.once("error", reject);
|
|
202
|
+
httpServer.listen(port, host, () => {
|
|
203
|
+
httpServer.removeListener("error", reject);
|
|
204
|
+
resolve();
|
|
205
|
+
});
|
|
137
206
|
});
|
|
207
|
+
console.error(`4DA MCP Server v${SERVER_VERSION} (Streamable HTTP) listening on http://${host}:${port}/mcp`);
|
|
208
|
+
console.error(`Health check: http://${host}:${port}/`);
|
|
209
|
+
console.error(`Authentication: ${policy.authRequired ? "required (HMAC-SHA256 verified Bearer tokens)" : "disabled (loopback only)"}`);
|
|
210
|
+
const close = async () => {
|
|
211
|
+
await mcpHandler.close();
|
|
212
|
+
await new Promise((resolve) => httpServer.close(() => resolve()));
|
|
213
|
+
};
|
|
138
214
|
// Graceful shutdown
|
|
139
215
|
const shutdown = () => {
|
|
140
216
|
console.error("Shutting down HTTP server...");
|
|
141
|
-
|
|
142
|
-
process.exit(0);
|
|
217
|
+
void close().finally(() => process.exit(0));
|
|
143
218
|
};
|
|
144
|
-
process.
|
|
145
|
-
process.
|
|
219
|
+
process.once("SIGINT", shutdown);
|
|
220
|
+
process.once("SIGTERM", shutdown);
|
|
221
|
+
return { server: httpServer, close };
|
|
146
222
|
}
|
|
147
223
|
//# sourceMappingURL=http-transport.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -1,29 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { Server } from "@modelcontextprotocol/server";
|
|
2
3
|
/**
|
|
3
|
-
*
|
|
4
|
+
* Build a Server instance with every handler registered.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* SECURITY: the server trusts its local environment and does NOT implement
|
|
11
|
-
* authentication. The optional --http transport is intended for localhost use;
|
|
12
|
-
* do NOT expose it over a network without putting your own auth in front.
|
|
13
|
-
*/
|
|
14
|
-
/**
|
|
15
|
-
* 4DA MCP Server v4.6.3 — Dependency Intelligence for AI Coding Agents
|
|
16
|
-
*
|
|
17
|
-
* 14 tools across 5 categories. Live vulnerability scanning (OSV.dev),
|
|
18
|
-
* ecosystem news, persistent memory, and tech stack awareness for any MCP host.
|
|
19
|
-
*
|
|
20
|
-
* Categories (canonical — matches schema-registry.ts `ToolCategory`):
|
|
21
|
-
* Security (3) — vulnerability scanning, dependency health, upgrade planning
|
|
22
|
-
* Intelligence (7) — briefing, ecosystem pulse, context, content feed,
|
|
23
|
-
* actionable signals, knowledge gaps, feedback
|
|
24
|
-
* Decisions (2) — decision memory, alignment checking
|
|
25
|
-
* Agent (1) — cross-session persistent memory
|
|
26
|
-
* Identity (1) — developer DNA profile
|
|
6
|
+
* The v2 serving entries take a factory: `serveStdio` pins one instance per
|
|
7
|
+
* connection; stateless HTTP (`createMcpHandler`) builds one per request.
|
|
8
|
+
* Handlers close over the module-level database singleton, so every instance
|
|
9
|
+
* shares the same lazily-initialized database and live-intelligence layer.
|
|
27
10
|
*/
|
|
28
|
-
export
|
|
11
|
+
export declare function buildServer(): Server;
|
|
29
12
|
//# sourceMappingURL=index.d.ts.map
|