@4da/mcp-server 5.0.0 → 5.0.2

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 CHANGED
@@ -172,7 +172,7 @@ The MCP server works without the desktop app. On first run it creates a local da
172
172
  | Agent memory (cross-session) | Yes | Yes |
173
173
  | Scored content feed (20+ sources) | -- | Yes |
174
174
  | Actionable signals + knowledge gaps | -- | Yes |
175
- | Compound intelligence (learns over time) | -- | Yes |
175
+ | The analysis layer (Signal Chains, Knowledge Gaps, temporal analysis) | -- | Yes |
176
176
 
177
177
  > **[Download 4DA](https://github.com/4DA-Systems/4DA/releases/latest)** for the full experience.
178
178
 
@@ -188,6 +188,21 @@ npx @4da/mcp-server
188
188
  npx @4da/mcp-server --http --port 4840
189
189
  ```
190
190
 
191
+ The HTTP transport binds to `127.0.0.1` by default and applies a `Host`-header
192
+ DNS rebinding guard to every request. Exposing it beyond this machine requires
193
+ a shared secret:
194
+
195
+ ```bash
196
+ MCP_AUTH_SECRET=<same value as the relay's JWT_SECRET> \
197
+ MCP_ALLOWED_HOSTS=mcp.internal \
198
+ npx @4da/mcp-server --http --host 0.0.0.0
199
+ ```
200
+
201
+ Without `MCP_AUTH_SECRET` a non-loopback bind is refused at startup. With it,
202
+ every request must carry a Bearer token whose HMAC-SHA256 signature verifies
203
+ against that secret, and the token's role is enforced per tool (`viewer` is
204
+ read-only; `member` and `admin` may write). Put TLS in front of it.
205
+
191
206
  ## CLI Reference
192
207
 
193
208
  ```
@@ -204,6 +219,9 @@ npx @4da/mcp-server --version # Print version
204
219
  |----------|-------------|---------|
205
220
  | `FOURDA_DB_PATH` | Path to 4DA's SQLite database | Auto-detected |
206
221
  | `FOURDA_OFFLINE` | Disable all network calls | `false` |
222
+ | `MCP_AUTH_SECRET` | Shared secret for verifying Bearer tokens on `--http` (HMAC-SHA256). Falls back to `JWT_SECRET`. Unset means no token is accepted. | Unset |
223
+ | `MCP_AUTH_REQUIRED` | Require auth on a **loopback** `--http` bind. Always required on a non-loopback bind. | `false` |
224
+ | `MCP_ALLOWED_HOSTS` | Extra comma-separated hostnames accepted in `Host`/`Origin` (needed when binding to `0.0.0.0`). | localhost only |
207
225
 
208
226
  ## FAQ
209
227
 
@@ -211,7 +229,7 @@ npx @4da/mcp-server --version # Print version
211
229
  No. The server sends package names and versions to public APIs ([OSV.dev](https://osv.dev), npm registry, crates.io, PyPI, Go proxy) and generic tech keywords to [HN Algolia](https://hn.algolia.com/api). The same public data visible in your `package.json`. No source code, no file paths, no personal data. Set `FOURDA_OFFLINE=true` to disable all network calls. (The sole exception is opt-in OpenAI embeddings — see the network note above.)
212
230
 
213
231
  **Do I need the 4DA desktop app?**
214
- No. 9 tools work standalone: vulnerability scanning, dependency health, upgrade planning, ecosystem news, pre-task briefings, project context, decision memory, alignment checking, and agent memory. The desktop app adds scored content from 20+ sources that compounds over time.
232
+ No. 9 tools work standalone: vulnerability scanning, dependency health, upgrade planning, ecosystem news, pre-task briefings, project context, decision memory, alignment checking, and agent memory. The desktop app adds a scored content feed from 20+ sources, graded against your actual stack.
215
233
 
216
234
  **Which AI tools does this work with?**
217
235
  Any tool that supports [MCP](https://modelcontextprotocol.io): Claude Code, Claude Desktop, Cursor, Windsurf, VS Code (Copilot), and any custom MCP client.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Per-request authorization context.
3
+ *
4
+ * The MCP v2 serving entries build a fresh `Server` per request from a
5
+ * zero-argument factory, so verified claims cannot be threaded through as a
6
+ * constructor argument. `AsyncLocalStorage` carries them from the HTTP
7
+ * transport (where the token is verified) to the tool-dispatch path (where the
8
+ * role is enforced) without touching every handler signature.
9
+ *
10
+ * Trust model:
11
+ * - stdio → no context at all. The host launched this process; it
12
+ * already has local process rights. Nothing to enforce.
13
+ * - HTTP, no auth→ context with `claims: null, enforced: false`. Only
14
+ * reachable when bound to loopback with auth disabled, i.e.
15
+ * the same trust boundary as stdio.
16
+ * - HTTP, auth → context with verified claims. Roles are enforced.
17
+ */
18
+ import { type TeamClaims } from "./auth.js";
19
+ export interface RequestAuthContext {
20
+ /** Claims verified by `extractAuthClaims`, or null when auth was not required. */
21
+ claims: TeamClaims | null;
22
+ /** True when the transport required authentication for this request. */
23
+ enforced: boolean;
24
+ }
25
+ /** Run `fn` (and everything it awaits) with the given auth context attached. */
26
+ export declare function runWithAuthContext<T>(ctx: RequestAuthContext, fn: () => T): T;
27
+ /** The current request's auth context, or undefined on the stdio path. */
28
+ export declare function getAuthContext(): RequestAuthContext | undefined;
29
+ /** Thrown when an authenticated caller's role does not permit a tool call. */
30
+ export declare class AuthorizationError extends Error {
31
+ constructor(message: string);
32
+ }
33
+ /**
34
+ * The operation a tool performs, derived from the registry annotation that
35
+ * already declares it. `readOnlyHint: true` means the tool cannot mutate
36
+ * state; everything else is treated as a write.
37
+ *
38
+ * Deliberately coarse: `decision_memory` and `agent_memory` have read-shaped
39
+ * actions too, but they are dispatched through a tool that CAN write, so a
40
+ * viewer is denied the whole tool rather than trusting an `action` argument to
41
+ * decide. Unknown tools fail closed as writes.
42
+ */
43
+ export declare function requiredOperation(toolName: string): "read" | "write";
44
+ /**
45
+ * Enforce the caller's role against the tool being invoked.
46
+ * Throws `AuthorizationError` when the role is insufficient.
47
+ */
48
+ export declare function assertToolPermission(toolName: string): void;
49
+ //# sourceMappingURL=auth-context.d.ts.map
@@ -0,0 +1,72 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Per-request authorization context.
4
+ *
5
+ * The MCP v2 serving entries build a fresh `Server` per request from a
6
+ * zero-argument factory, so verified claims cannot be threaded through as a
7
+ * constructor argument. `AsyncLocalStorage` carries them from the HTTP
8
+ * transport (where the token is verified) to the tool-dispatch path (where the
9
+ * role is enforced) without touching every handler signature.
10
+ *
11
+ * Trust model:
12
+ * - stdio → no context at all. The host launched this process; it
13
+ * already has local process rights. Nothing to enforce.
14
+ * - HTTP, no auth→ context with `claims: null, enforced: false`. Only
15
+ * reachable when bound to loopback with auth disabled, i.e.
16
+ * the same trust boundary as stdio.
17
+ * - HTTP, auth → context with verified claims. Roles are enforced.
18
+ */
19
+ import { AsyncLocalStorage } from "node:async_hooks";
20
+ import { hasPermission } from "./auth.js";
21
+ import { TOOL_REGISTRY } from "./schema-registry.js";
22
+ const storage = new AsyncLocalStorage();
23
+ /** Run `fn` (and everything it awaits) with the given auth context attached. */
24
+ export function runWithAuthContext(ctx, fn) {
25
+ return storage.run(ctx, fn);
26
+ }
27
+ /** The current request's auth context, or undefined on the stdio path. */
28
+ export function getAuthContext() {
29
+ return storage.getStore();
30
+ }
31
+ /** Thrown when an authenticated caller's role does not permit a tool call. */
32
+ export class AuthorizationError extends Error {
33
+ constructor(message) {
34
+ super(message);
35
+ this.name = "AuthorizationError";
36
+ }
37
+ }
38
+ /**
39
+ * The operation a tool performs, derived from the registry annotation that
40
+ * already declares it. `readOnlyHint: true` means the tool cannot mutate
41
+ * state; everything else is treated as a write.
42
+ *
43
+ * Deliberately coarse: `decision_memory` and `agent_memory` have read-shaped
44
+ * actions too, but they are dispatched through a tool that CAN write, so a
45
+ * viewer is denied the whole tool rather than trusting an `action` argument to
46
+ * decide. Unknown tools fail closed as writes.
47
+ */
48
+ export function requiredOperation(toolName) {
49
+ return TOOL_REGISTRY[toolName]?.annotations.readOnlyHint ? "read" : "write";
50
+ }
51
+ /**
52
+ * Enforce the caller's role against the tool being invoked.
53
+ * Throws `AuthorizationError` when the role is insufficient.
54
+ */
55
+ export function assertToolPermission(toolName) {
56
+ const ctx = storage.getStore();
57
+ if (!ctx)
58
+ return; // stdio — local process, no network identity to enforce
59
+ const operation = requiredOperation(toolName);
60
+ if (!ctx.claims) {
61
+ if (ctx.enforced) {
62
+ // Defensive: the transport rejects unauthenticated requests with 401
63
+ // before dispatch, so reaching here means the gate was bypassed.
64
+ throw new AuthorizationError(`Authentication required for '${toolName}' but no verified claims are present`);
65
+ }
66
+ return; // loopback HTTP with auth disabled — same trust boundary as stdio
67
+ }
68
+ if (!hasPermission(ctx.claims.role, operation)) {
69
+ throw new AuthorizationError(`Role '${ctx.claims.role}' is not permitted to perform '${operation}' operations (tool: ${toolName})`);
70
+ }
71
+ }
72
+ //# sourceMappingURL=auth-context.js.map
package/dist/auth.d.ts CHANGED
@@ -1,36 +1,63 @@
1
1
  /**
2
- * MCP Authentication Middleware
2
+ * MCP Authentication — Bearer token verification for the HTTP transport.
3
3
  *
4
- * Validates JWT tokens from the Team Relay for network-exposed MCP access.
5
- * Stdio transport (local) remains unauthenticated.
6
- * HTTP transport requires a valid Bearer token with team_id + client_id claims.
4
+ * Stdio transport (one server process per host, launched by the host itself)
5
+ * is unauthenticated by design: the caller already has local process rights.
6
+ * The HTTP transport verifies every token cryptographically before a single
7
+ * claim is trusted.
8
+ *
9
+ * Verification is HMAC-SHA256 over `base64url(header) + "." + base64url(payload)`
10
+ * compared in constant time against the shared relay secret, followed by
11
+ * expiry, not-before, and role checks. Tokens are issued by the 4DA Team Relay
12
+ * (`relay/src/auth.rs`: `jsonwebtoken` v9, `Header::default()` = HS256,
13
+ * `Validation::default()` = HS256 + mandatory `exp` + 60s leeway) — the
14
+ * parameters below mirror that issuer exactly.
15
+ *
16
+ * FAIL CLOSED: when no shared secret is configured, every token is rejected.
17
+ * A server that cannot verify a signature must never accept the claims inside
18
+ * it — the payload of an unverified JWT is attacker-controlled plaintext.
7
19
  */
8
20
  import { IncomingMessage } from "node:http";
21
+ /** Roles the relay issues. Anything else is rejected. */
22
+ export declare const KNOWN_ROLES: readonly ["admin", "member", "viewer"];
23
+ export type TeamRole = (typeof KNOWN_ROLES)[number];
9
24
  export interface TeamClaims {
10
25
  team_id: string;
11
26
  client_id: string;
12
- role: string;
27
+ role: TeamRole;
13
28
  exp: number;
14
29
  }
15
30
  /**
16
- * Extract and validate a JWT token from an HTTP request.
31
+ * The shared secret used to verify relay-issued tokens.
17
32
  *
18
- * Returns the decoded claims if valid, null if no token or invalid.
19
- * Uses HMAC-SHA256 verification against the shared relay secret.
33
+ * `MCP_AUTH_SECRET` is the name to use when the MCP server is deployed on its
34
+ * own; `JWT_SECRET` is accepted as a fallback so a server co-deployed with the
35
+ * relay (which reads `JWT_SECRET`) inherits the same value without duplicating
36
+ * the config. Whitespace-only values count as unset.
37
+ */
38
+ export declare function getAuthSecret(): string | undefined;
39
+ /**
40
+ * Verify a JWT's signature and claims. Returns the claims only when the
41
+ * signature checks out against the configured secret AND the claims are valid.
20
42
  *
21
- * NOTE: This is a lightweight validator that checks structure and expiry.
22
- * Full JWT signature verification requires the `jsonwebtoken` package.
23
- * For launch, we validate structure + expiry. Post-launch, add signature verification.
43
+ * @param token The raw JWT (no "Bearer " prefix).
44
+ * @param nowMs Current time in epoch milliseconds (injectable for tests).
24
45
  */
25
- export declare function extractAuthClaims(req: IncomingMessage): TeamClaims | null;
46
+ export declare function verifyToken(token: string, nowMs?: number): TeamClaims | null;
26
47
  /**
27
- * Check if a license tier allows network MCP access.
28
- * Only "team" and "enterprise" tiers can use HTTP transport.
48
+ * Extract and verify the Bearer token on an HTTP request.
49
+ *
50
+ * Returns the verified claims, or null when the request carries no token, a
51
+ * malformed token, a forged/tampered token, or an expired one.
29
52
  */
30
- export declare function isNetworkTierAllowed(tier: string | undefined): boolean;
53
+ export declare function extractAuthClaims(req: IncomingMessage): TeamClaims | null;
31
54
  /**
32
55
  * Check if a role has permission for a specific operation.
33
56
  * Viewers can read, members can read+write, admins can do everything.
57
+ *
58
+ * Enforced on the tool-dispatch path — see `assertToolPermission` in
59
+ * `auth-context.ts`, which maps each tool to an operation via its
60
+ * `readOnlyHint` annotation.
34
61
  */
35
62
  export declare function hasPermission(role: string, operation: "read" | "write" | "admin"): boolean;
36
63
  //# sourceMappingURL=auth.d.ts.map
package/dist/auth.js CHANGED
@@ -1,76 +1,187 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  /**
3
- * MCP Authentication Middleware
3
+ * MCP Authentication — Bearer token verification for the HTTP transport.
4
4
  *
5
- * Validates JWT tokens from the Team Relay for network-exposed MCP access.
6
- * Stdio transport (local) remains unauthenticated.
7
- * HTTP transport requires a valid Bearer token with team_id + client_id claims.
5
+ * Stdio transport (one server process per host, launched by the host itself)
6
+ * is unauthenticated by design: the caller already has local process rights.
7
+ * The HTTP transport verifies every token cryptographically before a single
8
+ * claim is trusted.
9
+ *
10
+ * Verification is HMAC-SHA256 over `base64url(header) + "." + base64url(payload)`
11
+ * compared in constant time against the shared relay secret, followed by
12
+ * expiry, not-before, and role checks. Tokens are issued by the 4DA Team Relay
13
+ * (`relay/src/auth.rs`: `jsonwebtoken` v9, `Header::default()` = HS256,
14
+ * `Validation::default()` = HS256 + mandatory `exp` + 60s leeway) — the
15
+ * parameters below mirror that issuer exactly.
16
+ *
17
+ * FAIL CLOSED: when no shared secret is configured, every token is rejected.
18
+ * A server that cannot verify a signature must never accept the claims inside
19
+ * it — the payload of an unverified JWT is attacker-controlled plaintext.
20
+ */
21
+ import { createHmac, timingSafeEqual } from "node:crypto";
22
+ /** Roles the relay issues. Anything else is rejected. */
23
+ export const KNOWN_ROLES = ["admin", "member", "viewer"];
24
+ /**
25
+ * The only accepted signing algorithm. Pinning a single algorithm is what
26
+ * closes algorithm-confusion attacks: `alg: none` (no signature at all) and
27
+ * asymmetric algorithms (where a public key can be replayed as the HMAC key)
28
+ * are rejected before any HMAC is computed.
8
29
  */
30
+ const ACCEPTED_ALG = "HS256";
31
+ /** Clock skew tolerance, matching `jsonwebtoken`'s default `leeway` on the issuer. */
32
+ const CLOCK_SKEW_SECONDS = 60;
9
33
  /**
10
- * Extract and validate a JWT token from an HTTP request.
34
+ * The shared secret used to verify relay-issued tokens.
11
35
  *
12
- * Returns the decoded claims if valid, null if no token or invalid.
13
- * Uses HMAC-SHA256 verification against the shared relay secret.
36
+ * `MCP_AUTH_SECRET` is the name to use when the MCP server is deployed on its
37
+ * own; `JWT_SECRET` is accepted as a fallback so a server co-deployed with the
38
+ * relay (which reads `JWT_SECRET`) inherits the same value without duplicating
39
+ * the config. Whitespace-only values count as unset.
40
+ */
41
+ export function getAuthSecret() {
42
+ const secret = process.env.MCP_AUTH_SECRET?.trim() || process.env.JWT_SECRET?.trim();
43
+ return secret ? secret : undefined;
44
+ }
45
+ /** Constant-time buffer comparison that tolerates length mismatch. */
46
+ function constantTimeEquals(a, b) {
47
+ // timingSafeEqual throws on differing lengths. A wrong-length signature is
48
+ // unconditionally invalid, so returning early leaks nothing an attacker
49
+ // cannot already compute (HS256 signatures are always 32 bytes).
50
+ if (a.length !== b.length)
51
+ return false;
52
+ return timingSafeEqual(a, b);
53
+ }
54
+ function decodeSegment(segment) {
55
+ const json = Buffer.from(segment, "base64url").toString("utf-8");
56
+ return JSON.parse(json);
57
+ }
58
+ function isPlainObject(value) {
59
+ return typeof value === "object" && value !== null && !Array.isArray(value);
60
+ }
61
+ function isNonEmptyString(value) {
62
+ return typeof value === "string" && value.length > 0;
63
+ }
64
+ /** Reject anything that is not a finite number (NaN, Infinity, strings, null). */
65
+ function isFiniteNumber(value) {
66
+ return typeof value === "number" && Number.isFinite(value);
67
+ }
68
+ function reject(reason) {
69
+ // console.warn writes to stderr, which is the only stream an MCP stdio
70
+ // server may write diagnostics to. Never log the token itself.
71
+ console.warn(`[4DA MCP Auth] Token rejected: ${reason}`);
72
+ return null;
73
+ }
74
+ /**
75
+ * Verify a JWT's signature and claims. Returns the claims only when the
76
+ * signature checks out against the configured secret AND the claims are valid.
14
77
  *
15
- * NOTE: This is a lightweight validator that checks structure and expiry.
16
- * Full JWT signature verification requires the `jsonwebtoken` package.
17
- * For launch, we validate structure + expiry. Post-launch, add signature verification.
78
+ * @param token The raw JWT (no "Bearer " prefix).
79
+ * @param nowMs Current time in epoch milliseconds (injectable for tests).
18
80
  */
19
- export function extractAuthClaims(req) {
20
- const auth = req.headers["authorization"];
21
- if (!auth || !auth.startsWith("Bearer ")) {
22
- return null;
81
+ export function verifyToken(token, nowMs = Date.now()) {
82
+ const secret = getAuthSecret();
83
+ if (!secret) {
84
+ // Fail closed. Without a secret there is nothing to verify against, so no
85
+ // token can be trusted — including one that is perfectly well-formed.
86
+ return reject("no shared secret configured (set MCP_AUTH_SECRET). Refusing to trust unverifiable tokens");
23
87
  }
24
- const token = auth.slice(7).trim();
25
- if (!token)
26
- return null;
88
+ const parts = token.split(".");
89
+ if (parts.length !== 3 || parts.some((p) => p.length === 0)) {
90
+ return reject("malformed token (expected three non-empty segments)");
91
+ }
92
+ const [encodedHeader, encodedPayload, encodedSignature] = parts;
93
+ // --- 1. Header: pin the algorithm BEFORE doing any crypto ---------------
94
+ let header;
27
95
  try {
28
- // JWT is base64url(header).base64url(payload).signature
29
- const parts = token.split(".");
30
- if (parts.length !== 3)
31
- return null;
32
- // Decode payload (middle part)
33
- const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
34
- // Validate required fields
35
- if (!payload.team_id || !payload.client_id || !payload.role) {
36
- return null;
37
- }
38
- // Check expiry
39
- if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
40
- console.warn(`[4DA MCP Auth] Token expired for team ${payload.team_id}`);
41
- return null;
42
- }
43
- // Validate role is known
44
- if (!["admin", "member", "viewer"].includes(payload.role)) {
45
- return null;
46
- }
47
- return {
48
- team_id: payload.team_id,
49
- client_id: payload.client_id,
50
- role: payload.role,
51
- exp: payload.exp || 0,
52
- };
96
+ header = decodeSegment(encodedHeader);
53
97
  }
54
98
  catch {
55
- console.warn("[4DA MCP Auth] Failed to parse JWT token");
56
- return null;
99
+ return reject("undecodable header segment");
57
100
  }
101
+ if (!isPlainObject(header))
102
+ return reject("header is not a JSON object");
103
+ if (header.alg !== ACCEPTED_ALG) {
104
+ return reject(`unsupported alg '${String(header.alg)}' (only ${ACCEPTED_ALG} is accepted)`);
105
+ }
106
+ if (header.typ !== undefined && header.typ !== "JWT") {
107
+ return reject(`unsupported typ '${String(header.typ)}'`);
108
+ }
109
+ if (header.crit !== undefined) {
110
+ // `crit` marks header params a verifier MUST understand. We understand
111
+ // none of them, so the only spec-correct response is to refuse.
112
+ return reject("token declares critical header params this verifier does not implement");
113
+ }
114
+ // --- 2. Signature: constant-time HMAC-SHA256 over header.payload -------
115
+ const signingInput = `${encodedHeader}.${encodedPayload}`;
116
+ const expected = createHmac("sha256", secret).update(signingInput).digest();
117
+ const provided = Buffer.from(encodedSignature, "base64url");
118
+ if (!constantTimeEquals(expected, provided)) {
119
+ return reject("signature verification failed");
120
+ }
121
+ // --- 3. Claims: only now is the payload trustworthy --------------------
122
+ let payload;
123
+ try {
124
+ payload = decodeSegment(encodedPayload);
125
+ }
126
+ catch {
127
+ return reject("undecodable payload segment");
128
+ }
129
+ if (!isPlainObject(payload))
130
+ return reject("payload is not a JSON object");
131
+ const { team_id, client_id, role, exp, nbf } = payload;
132
+ if (!isNonEmptyString(team_id))
133
+ return reject("missing or invalid team_id");
134
+ if (!isNonEmptyString(client_id))
135
+ return reject("missing or invalid client_id");
136
+ if (!isNonEmptyString(role))
137
+ return reject("missing or invalid role");
138
+ if (!KNOWN_ROLES.includes(role)) {
139
+ return reject(`unknown role '${role}'`);
140
+ }
141
+ // `exp` is mandatory: a signed token with no expiry is a permanent
142
+ // credential, and the issuer always sets one.
143
+ if (!isFiniteNumber(exp))
144
+ return reject("missing or invalid exp claim");
145
+ const nowSeconds = Math.floor(nowMs / 1000);
146
+ if (exp + CLOCK_SKEW_SECONDS < nowSeconds) {
147
+ return reject(`token expired for team ${team_id}`);
148
+ }
149
+ if (nbf !== undefined) {
150
+ if (!isFiniteNumber(nbf))
151
+ return reject("invalid nbf claim");
152
+ if (nbf - CLOCK_SKEW_SECONDS > nowSeconds)
153
+ return reject("token not yet valid (nbf)");
154
+ }
155
+ return { team_id, client_id, role: role, exp };
58
156
  }
59
157
  /**
60
- * Check if a license tier allows network MCP access.
61
- * Only "team" and "enterprise" tiers can use HTTP transport.
158
+ * Extract and verify the Bearer token on an HTTP request.
159
+ *
160
+ * Returns the verified claims, or null when the request carries no token, a
161
+ * malformed token, a forged/tampered token, or an expired one.
62
162
  */
63
- export function isNetworkTierAllowed(tier) {
64
- return tier === "team" || tier === "enterprise";
163
+ export function extractAuthClaims(req) {
164
+ const auth = req.headers["authorization"];
165
+ if (typeof auth !== "string" || !auth.startsWith("Bearer ")) {
166
+ return null;
167
+ }
168
+ const token = auth.slice(7).trim();
169
+ if (!token)
170
+ return null;
171
+ return verifyToken(token);
65
172
  }
66
173
  /**
67
174
  * Check if a role has permission for a specific operation.
68
175
  * Viewers can read, members can read+write, admins can do everything.
176
+ *
177
+ * Enforced on the tool-dispatch path — see `assertToolPermission` in
178
+ * `auth-context.ts`, which maps each tool to an operation via its
179
+ * `readOnlyHint` annotation.
69
180
  */
70
181
  export function hasPermission(role, operation) {
71
182
  switch (operation) {
72
183
  case "read":
73
- return true; // All roles can read
184
+ return KNOWN_ROLES.includes(role);
74
185
  case "write":
75
186
  return role === "member" || role === "admin";
76
187
  case "admin":
@@ -9,21 +9,79 @@
9
9
  * no express or other framework needed.
10
10
  *
11
11
  * Security:
12
- * - Binds to 127.0.0.1 only (localhost)
13
- * - DNS rebinding protection via Origin header check
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.
14
25
  * - Stateless (no session tracking)
15
26
  */
27
+ import { type Server as HttpServer } from "node:http";
16
28
  import { type Server } from "@modelcontextprotocol/server";
17
29
  export interface HttpServerOptions {
18
30
  port: number;
19
31
  host: string;
20
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;
21
77
  /**
22
78
  * Start a Streamable HTTP server for the MCP protocol.
23
79
  *
24
80
  * Takes a server factory: `createMcpHandler` builds a fresh instance per
25
81
  * request (stateless — compatible with serverless environments and simple
26
82
  * round-robin load balancing).
83
+ *
84
+ * Resolves once the server is accepting connections.
27
85
  */
28
- export declare function startHttpServer(factory: () => Server, options?: HttpServerOptions): Promise<void>;
86
+ export declare function startHttpServer(factory: () => Server, options?: HttpServerOptions): Promise<HttpServerHandle>;
29
87
  //# sourceMappingURL=http-transport.d.ts.map