@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/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
- const learnedScore = context.learned
1012
- ? context.learned.topic_affinities
1013
- .filter((ta) => ta.affinity_score > 0 && itemTopics.some((t) => this.topicMatches(t, ta.topic)))
1014
- .reduce((sum, ta) => sum + ta.affinity_score * 0.1 * ta.confidence, 0)
1015
- : 0;
1016
- // Anti-penalty from exclusions and anti-topics
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.3,
1093
- save: 0.8,
1094
- dismiss: -0.2,
1095
- mark_irrelevant: -0.5,
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 affinities
1315
- if (context.learned) {
1316
- for (const affinity of context.learned.topic_affinities) {
1317
- if (affinity.affinity_score > 0 &&
1318
- itemTopics.some((t) => this.topicMatches(t, affinity.topic))) {
1319
- score += affinity.affinity_score * 0.1 * affinity.confidence;
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
  /**
@@ -1,25 +1,87 @@
1
1
  /**
2
2
  * 4DA MCP Server — Streamable HTTP Transport
3
3
  *
4
- * Implements the MCP Streamable HTTP transport (spec 2025-03-26) using
5
- * Node's built-in http module. No express or other framework needed.
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 only (localhost)
9
- * - DNS rebinding protection via Origin header check
10
- * - Stateless mode (no session tracking)
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 "@modelcontextprotocol/sdk/server/index.js";
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
- * Uses stateless mode (sessionIdGenerator: undefined) — each request
21
- * gets its own transport instance. This is the simplest approach and
22
- * is compatible with serverless environments.
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(server: Server, options?: HttpServerOptions): Promise<void>;
86
+ export declare function startHttpServer(factory: () => Server, options?: HttpServerOptions): Promise<HttpServerHandle>;
25
87
  //# sourceMappingURL=http-transport.d.ts.map
@@ -2,146 +2,222 @@
2
2
  /**
3
3
  * 4DA MCP Server — Streamable HTTP Transport
4
4
  *
5
- * Implements the MCP Streamable HTTP transport (spec 2025-03-26) using
6
- * Node's built-in http module. No express or other framework needed.
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 only (localhost)
10
- * - DNS rebinding protection via Origin header check
11
- * - Stateless mode (no session tracking)
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 { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
18
- import { extractAuthClaims } from "./auth.js";
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
- * Parse JSON body from an incoming HTTP request.
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 parseBody(req) {
27
- return new Promise((resolve, reject) => {
28
- const chunks = [];
29
- req.on("data", (chunk) => chunks.push(chunk));
30
- req.on("end", () => {
31
- try {
32
- const raw = Buffer.concat(chunks).toString("utf-8");
33
- resolve(raw ? JSON.parse(raw) : undefined);
34
- }
35
- catch (e) {
36
- reject(e);
37
- }
38
- });
39
- req.on("error", reject);
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
- * Uses stateless mode (sessionIdGenerator: undefined) — each request
46
- * gets its own transport instance. This is the simplest approach and
47
- * is compatible with serverless environments.
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(server, options = { port: DEFAULT_PORT, host: DEFAULT_HOST }) {
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: reject cross-origin requests from non-local origins
53
- const origin = req.headers.origin;
54
- if (origin) {
55
- try {
56
- const url = new URL(origin);
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
- // Auth check: if MCP_AUTH_REQUIRED env var is set, validate JWT
70
- const authRequired = process.env.MCP_AUTH_REQUIRED === "true";
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
- // Allow unauthenticated health checks on root
74
- const checkPath = new URL(req.url || "/", `http://${host}:${port}`).pathname;
75
- if (checkPath !== "/") {
76
- claims = extractAuthClaims(req);
77
- if (!claims) {
78
- res.writeHead(401, { "Content-Type": "application/json" });
79
- res.end(JSON.stringify({ error: "Authentication required" }));
80
- return;
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.writeHead(200, { "Content-Type": "application/json" });
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.writeHead(404, { "Content-Type": "application/json" });
100
- res.end(JSON.stringify({ error: "Not Found" }));
192
+ sendJson(res, 404, { error: "Not Found" });
101
193
  return;
102
194
  }
103
- // Parse JSON body for POST requests
104
- let body = undefined;
105
- if (req.method === "POST") {
106
- try {
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
- httpServer.listen(port, host, () => {
135
- console.error(`4DA MCP Server v${SERVER_VERSION} (Streamable HTTP) listening on http://${host}:${port}/mcp`);
136
- console.error(`Health check: http://${host}:${port}/`);
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
- httpServer.close();
142
- process.exit(0);
217
+ void close().finally(() => process.exit(0));
143
218
  };
144
- process.on("SIGINT", shutdown);
145
- process.on("SIGTERM", shutdown);
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
- * 4DA MCP Server
4
+ * Build a Server instance with every handler registered.
4
5
  *
5
- * Provides dependency-intelligence tools (CVE scanning, dependency health,
6
- * upgrade planning, ecosystem news, decision/agent memory) to Claude Code,
7
- * Claude Desktop, and other MCP hosts. Runs locally; the only data leaving the
8
- * machine is public package names/versions sent to registries (OSV, npm, etc.).
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