@4da/mcp-server 5.0.0 → 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 +18 -0
- 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/http-transport.d.ts +61 -3
- package/dist/http-transport.js +159 -49
- package/dist/index.js +46 -8
- package/dist/tool-dispatch.d.ts +4 -0
- package/dist/tool-dispatch.js +6 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -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
|
|
|
@@ -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
|
|
2
|
+
* MCP Authentication — Bearer token verification for the HTTP transport.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* HTTP transport
|
|
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:
|
|
27
|
+
role: TeamRole;
|
|
13
28
|
exp: number;
|
|
14
29
|
}
|
|
15
30
|
/**
|
|
16
|
-
*
|
|
31
|
+
* The shared secret used to verify relay-issued tokens.
|
|
17
32
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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
|
|
46
|
+
export declare function verifyToken(token: string, nowMs?: number): TeamClaims | null;
|
|
26
47
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
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
|
|
3
|
+
* MCP Authentication — Bearer token verification for the HTTP transport.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* HTTP transport
|
|
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
|
-
*
|
|
34
|
+
* The shared secret used to verify relay-issued tokens.
|
|
11
35
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
|
20
|
-
const
|
|
21
|
-
if (!
|
|
22
|
-
|
|
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
|
|
25
|
-
if (
|
|
26
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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
|
|
64
|
-
|
|
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
|
|
184
|
+
return KNOWN_ROLES.includes(role);
|
|
74
185
|
case "write":
|
|
75
186
|
return role === "member" || role === "admin";
|
|
76
187
|
case "admin":
|
package/dist/http-transport.d.ts
CHANGED
|
@@ -9,21 +9,79 @@
|
|
|
9
9
|
* no express or other framework needed.
|
|
10
10
|
*
|
|
11
11
|
* Security:
|
|
12
|
-
* - Binds to 127.0.0.1
|
|
13
|
-
*
|
|
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<
|
|
86
|
+
export declare function startHttpServer(factory: () => Server, options?: HttpServerOptions): Promise<HttpServerHandle>;
|
|
29
87
|
//# sourceMappingURL=http-transport.d.ts.map
|
package/dist/http-transport.js
CHANGED
|
@@ -10,30 +10,145 @@
|
|
|
10
10
|
* no express or other framework needed.
|
|
11
11
|
*
|
|
12
12
|
* Security:
|
|
13
|
-
* - Binds to 127.0.0.1
|
|
14
|
-
*
|
|
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.
|
|
15
26
|
* - Stateless (no session tracking)
|
|
16
27
|
*/
|
|
17
28
|
import { createServer } from "node:http";
|
|
18
29
|
import { readFileSync } from "node:fs";
|
|
19
30
|
import { join, dirname } from "node:path";
|
|
20
31
|
import { fileURLToPath } from "node:url";
|
|
21
|
-
import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
32
|
+
import { createMcpHandler, localhostAllowedHostnames, localhostAllowedOrigins, validateHostHeader, validateOriginHeader, } from "@modelcontextprotocol/server";
|
|
22
33
|
import { toNodeHandler } from "@modelcontextprotocol/node";
|
|
23
|
-
import { extractAuthClaims } from "./auth.js";
|
|
34
|
+
import { extractAuthClaims, getAuthSecret } from "./auth.js";
|
|
35
|
+
import { runWithAuthContext } from "./auth-context.js";
|
|
24
36
|
const DEFAULT_PORT = 4840;
|
|
25
37
|
const DEFAULT_HOST = "127.0.0.1";
|
|
26
38
|
const __ht_dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
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
|
+
}
|
|
49
|
+
/**
|
|
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.
|
|
53
|
+
*/
|
|
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
|
+
}
|
|
138
|
+
}
|
|
28
139
|
/**
|
|
29
140
|
* Start a Streamable HTTP server for the MCP protocol.
|
|
30
141
|
*
|
|
31
142
|
* Takes a server factory: `createMcpHandler` builds a fresh instance per
|
|
32
143
|
* request (stateless — compatible with serverless environments and simple
|
|
33
144
|
* round-robin load balancing).
|
|
145
|
+
*
|
|
146
|
+
* Resolves once the server is accepting connections.
|
|
34
147
|
*/
|
|
35
148
|
export async function startHttpServer(factory, options = { port: DEFAULT_PORT, host: DEFAULT_HOST }) {
|
|
36
149
|
const { port, host } = options;
|
|
150
|
+
const policy = resolveSecurityPolicy(host);
|
|
151
|
+
enforceBindPolicy(host, policy);
|
|
37
152
|
const mcpHandler = createMcpHandler(factory);
|
|
38
153
|
const handleMcpRequest = toNodeHandler(mcpHandler, {
|
|
39
154
|
onerror: (err) => {
|
|
@@ -41,73 +156,68 @@ export async function startHttpServer(factory, options = { port: DEFAULT_PORT, h
|
|
|
41
156
|
},
|
|
42
157
|
});
|
|
43
158
|
const httpServer = createServer(async (req, res) => {
|
|
44
|
-
// DNS rebinding protection
|
|
45
|
-
const
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1" && url.hostname !== "::1" && url.hostname !== "[::1]") {
|
|
50
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
51
|
-
res.end(JSON.stringify({ error: "Forbidden: DNS rebinding protection" }));
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
catch {
|
|
56
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
57
|
-
res.end(JSON.stringify({ error: "Forbidden: invalid Origin header" }));
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
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;
|
|
60
164
|
}
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
63
168
|
let claims = null;
|
|
64
|
-
if (authRequired) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
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;
|
|
74
178
|
}
|
|
75
179
|
}
|
|
76
|
-
// Route requests
|
|
77
|
-
const pathname = new URL(req.url || "/", `http://${host}:${port}`).pathname;
|
|
78
180
|
// Health check endpoint
|
|
79
181
|
if (pathname === "/" && req.method === "GET") {
|
|
80
|
-
res
|
|
81
|
-
res.end(JSON.stringify({
|
|
182
|
+
sendJson(res, 200, {
|
|
82
183
|
name: "4da-mcp",
|
|
83
184
|
version: SERVER_VERSION,
|
|
84
185
|
transport: "streamable-http",
|
|
85
186
|
status: "ok",
|
|
86
|
-
})
|
|
187
|
+
});
|
|
87
188
|
return;
|
|
88
189
|
}
|
|
89
190
|
// Only handle /mcp endpoint
|
|
90
191
|
if (pathname !== "/mcp") {
|
|
91
|
-
res
|
|
92
|
-
res.end(JSON.stringify({ error: "Not Found" }));
|
|
192
|
+
sendJson(res, 404, { error: "Not Found" });
|
|
93
193
|
return;
|
|
94
194
|
}
|
|
95
195
|
// Hand off to the MCP handler — body parsing, era classification, and
|
|
96
|
-
// per-request instance construction happen inside the entry.
|
|
97
|
-
|
|
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));
|
|
98
199
|
});
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
200
|
+
await new Promise((resolve, reject) => {
|
|
201
|
+
httpServer.once("error", reject);
|
|
202
|
+
httpServer.listen(port, host, () => {
|
|
203
|
+
httpServer.removeListener("error", reject);
|
|
204
|
+
resolve();
|
|
205
|
+
});
|
|
102
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
|
+
};
|
|
103
214
|
// Graceful shutdown
|
|
104
215
|
const shutdown = () => {
|
|
105
216
|
console.error("Shutting down HTTP server...");
|
|
106
|
-
void
|
|
107
|
-
httpServer.close();
|
|
108
|
-
process.exit(0);
|
|
217
|
+
void close().finally(() => process.exit(0));
|
|
109
218
|
};
|
|
110
|
-
process.
|
|
111
|
-
process.
|
|
219
|
+
process.once("SIGINT", shutdown);
|
|
220
|
+
process.once("SIGTERM", shutdown);
|
|
221
|
+
return { server: httpServer, close };
|
|
112
222
|
}
|
|
113
223
|
//# sourceMappingURL=http-transport.js.map
|
package/dist/index.js
CHANGED
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
* Claude Desktop, and other MCP hosts. Runs locally; the only data leaving the
|
|
9
9
|
* machine is public package names/versions sent to registries (OSV, npm, etc.).
|
|
10
10
|
*
|
|
11
|
-
* SECURITY:
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* SECURITY: stdio serving is unauthenticated by design — the MCP host launches
|
|
12
|
+
* this process and already holds local process rights. The optional --http
|
|
13
|
+
* transport defaults to 127.0.0.1 and applies a Host-header DNS rebinding
|
|
14
|
+
* guard to every request. Binding it anywhere else requires a shared auth
|
|
15
|
+
* secret (MCP_AUTH_SECRET) and then verifies an HMAC-SHA256-signed Bearer
|
|
16
|
+
* token on every request, enforcing the token's role at tool dispatch.
|
|
14
17
|
*
|
|
15
18
|
* 14 tools across 5 categories. Live vulnerability scanning (OSV.dev),
|
|
16
19
|
* ecosystem news, persistent memory, and tech stack awareness for any MCP host.
|
|
@@ -338,13 +341,23 @@ async function main() {
|
|
|
338
341
|
Options:
|
|
339
342
|
--http Use Streamable HTTP instead of stdio
|
|
340
343
|
--port <number> HTTP port (default: 4840)
|
|
341
|
-
--host <address> HTTP bind address (default: 127.0.0.1)
|
|
344
|
+
--host <address> HTTP bind address (default: 127.0.0.1). A non-loopback
|
|
345
|
+
address exposes every tool to the network and is
|
|
346
|
+
refused unless MCP_AUTH_SECRET is set; authentication
|
|
347
|
+
is then mandatory for every request.
|
|
342
348
|
--setup Detect editors and write MCP config
|
|
343
349
|
--doctor Validate database, bindings, and LLM providers
|
|
344
350
|
|
|
345
351
|
Environment:
|
|
346
352
|
FOURDA_DB_PATH Path to 4DA's SQLite database (auto-detected if omitted)
|
|
347
353
|
FOURDA_OFFLINE Set to "true" to disable all network calls (OSV.dev, HN)
|
|
354
|
+
MCP_AUTH_SECRET Shared secret for verifying relay-issued Bearer tokens
|
|
355
|
+
(HMAC-SHA256). Falls back to JWT_SECRET. Without it, no
|
|
356
|
+
token is accepted.
|
|
357
|
+
MCP_AUTH_REQUIRED Set to "true" to require auth on a loopback --http bind
|
|
358
|
+
(always required on a non-loopback bind)
|
|
359
|
+
MCP_ALLOWED_HOSTS Extra comma-separated hostnames accepted in the Host /
|
|
360
|
+
Origin headers (needed when binding to 0.0.0.0)
|
|
348
361
|
|
|
349
362
|
Works standalone (scans your project on startup) or with the full
|
|
350
363
|
4DA desktop app for content scoring, source monitoring, and more.
|
|
@@ -365,10 +378,35 @@ async function main() {
|
|
|
365
378
|
}
|
|
366
379
|
// HTTP transport mode
|
|
367
380
|
if (args.includes("--http")) {
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
381
|
+
const flagValue = (flag) => {
|
|
382
|
+
const i = args.indexOf(flag);
|
|
383
|
+
if (i === -1)
|
|
384
|
+
return undefined;
|
|
385
|
+
const value = args[i + 1];
|
|
386
|
+
// A flag with no value (or followed by another flag) is a typo, not a
|
|
387
|
+
// request to bind everywhere — refuse rather than guess.
|
|
388
|
+
if (value === undefined || value.startsWith("-")) {
|
|
389
|
+
console.error(`[4DA] ${flag} requires a value`);
|
|
390
|
+
process.exit(1);
|
|
391
|
+
}
|
|
392
|
+
return value;
|
|
393
|
+
};
|
|
394
|
+
const rawPort = flagValue("--port");
|
|
395
|
+
const port = rawPort === undefined ? 4840 : Number.parseInt(rawPort, 10);
|
|
396
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
397
|
+
console.error(`[4DA] Invalid --port value: ${rawPort}`);
|
|
398
|
+
process.exit(1);
|
|
399
|
+
}
|
|
400
|
+
const host = flagValue("--host") ?? "127.0.0.1";
|
|
401
|
+
try {
|
|
402
|
+
await startHttpServer(buildServer, { port, host });
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
// Bind-policy refusals carry an actionable message; print it plainly
|
|
406
|
+
// rather than as an unhandled stack trace.
|
|
407
|
+
console.error(`[4DA] ${error instanceof Error ? error.message : String(error)}`);
|
|
408
|
+
process.exit(1);
|
|
409
|
+
}
|
|
372
410
|
return;
|
|
373
411
|
}
|
|
374
412
|
// -------------------------------------------------------------------------
|
package/dist/tool-dispatch.d.ts
CHANGED
|
@@ -14,6 +14,10 @@ export type ToolExecutor = (db: FourDADatabase, params: any) => unknown | Promis
|
|
|
14
14
|
/**
|
|
15
15
|
* Dispatch a tool call by name. Awaits uniformly (no-op for sync executors).
|
|
16
16
|
* Returns MCP-formatted response with JSON-serialized result.
|
|
17
|
+
*
|
|
18
|
+
* Authenticated HTTP callers are role-checked here — the single choke point
|
|
19
|
+
* every tool call passes through. Stdio callers are unaffected (no auth
|
|
20
|
+
* context; the host already owns the process).
|
|
17
21
|
*/
|
|
18
22
|
export declare function dispatchTool(name: string, db: FourDADatabase, args: Record<string, unknown> | undefined): Promise<CallToolResult>;
|
|
19
23
|
/** Check if a tool exists in the dispatch map */
|
package/dist/tool-dispatch.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Map-based dispatch for the 14 active tools.
|
|
6
6
|
* Adding a new tool = add to this map + schema-registry + barrel export.
|
|
7
7
|
*/
|
|
8
|
+
import { assertToolPermission } from "./auth-context.js";
|
|
8
9
|
import { executeGetRelevantContent, executeGetContext, executeRecordFeedback, executeGetActionableSignals, executeKnowledgeGaps, executeDecisionMemory, executeCheckDecisionAlignment, executeAgentMemory, executeDeveloperDna, executeWhatShouldIKnow, executeVulnerabilityScan, executeEcosystemPulse, executeDependencyHealth, executeUpgradePlanner, } from "./tools/index.js";
|
|
9
10
|
import { getLiveIntelligence } from "./live-singleton.js";
|
|
10
11
|
/**
|
|
@@ -46,12 +47,17 @@ const FRESHNESS_TOOLS = new Set([
|
|
|
46
47
|
/**
|
|
47
48
|
* Dispatch a tool call by name. Awaits uniformly (no-op for sync executors).
|
|
48
49
|
* Returns MCP-formatted response with JSON-serialized result.
|
|
50
|
+
*
|
|
51
|
+
* Authenticated HTTP callers are role-checked here — the single choke point
|
|
52
|
+
* every tool call passes through. Stdio callers are unaffected (no auth
|
|
53
|
+
* context; the host already owns the process).
|
|
49
54
|
*/
|
|
50
55
|
export async function dispatchTool(name, db, args) {
|
|
51
56
|
const executor = DISPATCH_MAP[name];
|
|
52
57
|
if (!executor) {
|
|
53
58
|
throw new Error(`Unknown tool: ${name}`);
|
|
54
59
|
}
|
|
60
|
+
assertToolPermission(name);
|
|
55
61
|
const result = await executor(db, (args || {}));
|
|
56
62
|
const payload = FRESHNESS_TOOLS.has(name) ? attachFreshness(db, result) : result;
|
|
57
63
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@4da/mcp-server",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.1",
|
|
4
4
|
"mcpName": "io.github.4DA-Systems/4da-mcp-server",
|
|
5
5
|
"description": "Dependency intelligence for AI coding agents. Live CVE scanning, dependency health, upgrade planning, ecosystem news, decision memory — zero config, privacy-first. 9 tools standalone, 14 total with desktop app.",
|
|
6
6
|
"type": "module",
|
|
@@ -82,7 +82,8 @@
|
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"better-sqlite3": "^12.9.0",
|
|
84
84
|
"@modelcontextprotocol/node": "^2.0.0",
|
|
85
|
-
"@modelcontextprotocol/server": "^2.0.0"
|
|
85
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
86
|
+
"hono": "^4.12.34"
|
|
86
87
|
},
|
|
87
88
|
"devDependencies": {
|
|
88
89
|
"@types/better-sqlite3": "^7.6.11",
|
|
@@ -103,7 +104,9 @@
|
|
|
103
104
|
"fast-uri": ">=3.1.2",
|
|
104
105
|
"ip-address": "^10.1.1",
|
|
105
106
|
"vite": ">=7.3.5 <8",
|
|
106
|
-
"esbuild": ">=0.28.1"
|
|
107
|
+
"esbuild": ">=0.28.1",
|
|
108
|
+
"hono": ">=4.12.34 <5",
|
|
109
|
+
"@hono/node-server": ">=2.0.5 <3"
|
|
107
110
|
}
|
|
108
111
|
}
|
|
109
112
|
}
|