mcptrustchecker 1.0.0 → 1.1.0
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 +5 -2
- package/dist/acquire/live.d.ts +14 -0
- package/dist/acquire/live.js +84 -13
- package/dist/acquire/oauth.d.ts +49 -0
- package/dist/acquire/oauth.js +190 -0
- package/dist/cli/index.js +17 -0
- package/dist/util/headers.d.ts +16 -0
- package/dist/util/headers.js +49 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
npx mcptrustchecker # 🔍 scan every MCP server you already have installed — zero config
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
<sub>· offline · deterministic · no account ·
|
|
27
|
+
<sub>· offline · deterministic · no account · OAuth browser login for protected servers · <a href="#the-algorithm-the-capability-flow-trust-model">one novel core</a> ·</sub>
|
|
28
28
|
|
|
29
29
|
</div>
|
|
30
30
|
|
|
@@ -70,7 +70,8 @@ Toxic flows (untrusted-input → sensitive-source → external-sink)
|
|
|
70
70
|
|
|
71
71
|
MCP Trust Checker's wedge is **accuracy + explainability + privacy**, with one genuinely novel core — the cross-tool toxic-flow graph. Every property below holds together in a single offline binary — no account, no LLM in the loop, no telemetry:
|
|
72
72
|
|
|
73
|
-
- 🔒 **
|
|
73
|
+
- 🔒 **Offline by default** — no account, token, API key, or hosted service; your data never leaves the machine.
|
|
74
|
+
- 🔐 **Scans protected remote servers** — `--login` runs the full **OAuth 2.0 browser sign-in** (discovery → dynamic client registration → PKCE → token), so it can audit auth-gated remote MCP endpoints, not just public ones — something most scanners can't do. (Or pass a static `--header "Authorization: Bearer …"`.) Tokens stay in memory for the scan only.
|
|
74
75
|
- 🎯 **Deterministic** — same input ⇒ byte-identical score, on every run and every machine.
|
|
75
76
|
- 🕸️ **Cross-tool toxic-flow graph** — proves the lethal trifecta statically, composed across tools, not just within one.
|
|
76
77
|
- 🔬 **Reads the code, not just the claim** — when the server's source is available (`scan ./path`), it grades what the implementation *does* (eval / shell-spawn / hardcoded egress / credential reads / obfuscated payloads), so a poisoned server can't hide behind honest-looking tool descriptions. Metadata **and** implementation, in one deterministic pass.
|
|
@@ -125,6 +126,8 @@ mcptrustchecker scan ./tools.json # an offline manifest
|
|
|
125
126
|
mcptrustchecker scan ./path/to/mcp-server # a local package dir — analyzes the CODE too
|
|
126
127
|
mcptrustchecker scan --command "npx -y @some/mcp-server" # a local stdio server (sandboxed)
|
|
127
128
|
mcptrustchecker scan https://mcp.example.com/mcp # a live HTTP/SSE endpoint
|
|
129
|
+
mcptrustchecker scan https://mcp.example.com/mcp --login # an OAuth-protected endpoint (browser sign-in)
|
|
130
|
+
mcptrustchecker scan https://mcp.example.com/mcp --header "Authorization: Bearer <token>" # static auth
|
|
128
131
|
mcptrustchecker scan @modelcontextprotocol/server-filesystem --online # a package name (typosquat/CVE)
|
|
129
132
|
```
|
|
130
133
|
|
package/dist/acquire/live.d.ts
CHANGED
|
@@ -40,6 +40,20 @@ export interface LiveOptions {
|
|
|
40
40
|
allowedHosts?: string[];
|
|
41
41
|
/** Block private/loopback/link-local hosts (set for untrusted, config-derived URLs). */
|
|
42
42
|
blockPrivateHosts?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Extra request headers (e.g. `Authorization: Bearer …`) for HTTP acquisition
|
|
45
|
+
* of protected endpoints. Sent ONLY to the target host — never forwarded to a
|
|
46
|
+
* redirect on a different host, so the credential can't leak.
|
|
47
|
+
*/
|
|
48
|
+
headers?: Record<string, string>;
|
|
49
|
+
/**
|
|
50
|
+
* Perform an interactive OAuth 2.0 browser sign-in for endpoints that require
|
|
51
|
+
* it (the MCP authorization flow). Opens the user's browser; tokens live in
|
|
52
|
+
* memory for the scan only.
|
|
53
|
+
*/
|
|
54
|
+
login?: boolean;
|
|
55
|
+
/** Optional OAuth scope to request during `login` (e.g. `mcp:tools`). */
|
|
56
|
+
scope?: string;
|
|
43
57
|
}
|
|
44
58
|
/** Acquire a surface by spawning a local stdio MCP server. */
|
|
45
59
|
export declare function acquireStdio(spec: StdioSpec, opts?: LiveOptions): Promise<ServerSurface>;
|
package/dist/acquire/live.js
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* it loaded; live scanning requires `@modelcontextprotocol/sdk` at runtime.
|
|
20
20
|
*/
|
|
21
21
|
import { surfaceFromManifest } from './manifest.js';
|
|
22
|
+
import { TOOL_VERSION } from '../version.js';
|
|
23
|
+
import { applyCredentialGate } from '../util/headers.js';
|
|
22
24
|
/** Executables permitted for stdio acquisition (the canonical safe set). */
|
|
23
25
|
export const ALLOWED_COMMANDS = new Set(['npx', 'uvx', 'python', 'python3', 'node', 'docker', 'deno']);
|
|
24
26
|
/**
|
|
@@ -143,17 +145,19 @@ function withTimeout(p, ms, label) {
|
|
|
143
145
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
144
146
|
async function loadSdk() {
|
|
145
147
|
try {
|
|
146
|
-
const [{ Client }, stdio, http, sse] = await Promise.all([
|
|
148
|
+
const [{ Client }, stdio, http, sse, auth] = await Promise.all([
|
|
147
149
|
import('@modelcontextprotocol/sdk/client/index.js'),
|
|
148
150
|
import('@modelcontextprotocol/sdk/client/stdio.js'),
|
|
149
151
|
import('@modelcontextprotocol/sdk/client/streamableHttp.js'),
|
|
150
152
|
import('@modelcontextprotocol/sdk/client/sse.js'),
|
|
153
|
+
import('@modelcontextprotocol/sdk/client/auth.js'),
|
|
151
154
|
]);
|
|
152
155
|
return {
|
|
153
156
|
Client,
|
|
154
157
|
StdioClientTransport: stdio.StdioClientTransport,
|
|
155
158
|
StreamableHTTPClientTransport: http.StreamableHTTPClientTransport,
|
|
156
159
|
SSEClientTransport: sse.SSEClientTransport,
|
|
160
|
+
UnauthorizedError: auth.UnauthorizedError,
|
|
157
161
|
};
|
|
158
162
|
}
|
|
159
163
|
catch (err) {
|
|
@@ -270,7 +274,7 @@ export async function acquireStdio(spec, opts = {}) {
|
|
|
270
274
|
cwd: spec.cwd ?? process.cwd(),
|
|
271
275
|
stderr: 'pipe',
|
|
272
276
|
});
|
|
273
|
-
const client = new sdk.Client({ name: 'mcptrustchecker', version:
|
|
277
|
+
const client = new sdk.Client({ name: 'mcptrustchecker', version: TOOL_VERSION }, { capabilities: {} });
|
|
274
278
|
try {
|
|
275
279
|
await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, 'stdio connect');
|
|
276
280
|
const enumd = await enumerate(client);
|
|
@@ -310,25 +314,42 @@ export async function acquireHttp(url, opts = {}) {
|
|
|
310
314
|
`Use --allowed-hosts to permit specific hosts.`);
|
|
311
315
|
}
|
|
312
316
|
const sdk = await loadSdk();
|
|
313
|
-
const client = new sdk.Client({ name: 'mcptrustchecker', version:
|
|
317
|
+
const client = new sdk.Client({ name: 'mcptrustchecker', version: TOOL_VERSION }, { capabilities: {} });
|
|
314
318
|
// Re-validate every redirect hop: the initial-URL SSRF check is useless if the
|
|
315
319
|
// server 302s to http://[::ffff:169.254.169.254]/. We follow redirects
|
|
316
320
|
// manually (capped) and run each hop's host back through the same guard.
|
|
321
|
+
// Re-validate every hop AND contain credentials. The SDK routes ALL its
|
|
322
|
+
// requests — including OAuth discovery / registration / token endpoints, whose
|
|
323
|
+
// hosts come from the *server's* metadata — through this fetch, so every hop
|
|
324
|
+
// (not just 3xx targets) must pass the SSRF guard, and credentials must never
|
|
325
|
+
// cross to a different origin.
|
|
317
326
|
const guardedFetch = async (input, init) => {
|
|
318
327
|
let current = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
319
328
|
for (let hop = 0; hop < 6; hop++) {
|
|
320
|
-
|
|
329
|
+
let cur;
|
|
330
|
+
try {
|
|
331
|
+
cur = new URL(current);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
throw new Error(`Invalid request URL "${current}".`);
|
|
335
|
+
}
|
|
336
|
+
// SSRF guard on THIS hop's host — covers the initial request too, not just
|
|
337
|
+
// redirect targets (an OAuth authorization-server host is server-chosen).
|
|
338
|
+
if (cur.protocol !== 'http:' && cur.protocol !== 'https:')
|
|
339
|
+
throw new Error(`Blocked request to non-http scheme "${cur.protocol}".`);
|
|
340
|
+
if (opts.allowedHosts && !opts.allowedHosts.includes(cur.hostname))
|
|
341
|
+
throw new Error(`Blocked request to disallowed host "${cur.hostname}".`);
|
|
342
|
+
if (opts.blockPrivateHosts && isBlockedHost(cur.hostname))
|
|
343
|
+
throw new Error(`SSRF: blocked request to private/loopback host "${cur.hostname}".`);
|
|
344
|
+
// Credentials (the SDK-injected OAuth bearer in init.headers, and any
|
|
345
|
+
// static --header) go ONLY to the exact target ORIGIN; stripped on any
|
|
346
|
+
// cross-origin hop (redirect, downgrade, different port).
|
|
347
|
+
const headers = applyCredentialGate(init?.headers, cur.origin === parsed.origin, opts.headers);
|
|
348
|
+
const res = await fetch(current, { ...init, headers, redirect: 'manual' });
|
|
321
349
|
const loc = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;
|
|
322
350
|
if (!loc)
|
|
323
351
|
return res;
|
|
324
|
-
|
|
325
|
-
if (next.protocol !== 'http:' && next.protocol !== 'https:')
|
|
326
|
-
throw new Error(`Blocked redirect to non-http scheme "${next.protocol}".`);
|
|
327
|
-
if (opts.allowedHosts && !opts.allowedHosts.includes(next.hostname))
|
|
328
|
-
throw new Error(`Blocked redirect to disallowed host "${next.hostname}".`);
|
|
329
|
-
if (opts.blockPrivateHosts && isBlockedHost(next.hostname))
|
|
330
|
-
throw new Error(`SSRF: blocked redirect to private/loopback host "${next.hostname}".`);
|
|
331
|
-
current = next.href;
|
|
352
|
+
current = new URL(loc, current).href;
|
|
332
353
|
}
|
|
333
354
|
throw new Error('Too many redirects.');
|
|
334
355
|
};
|
|
@@ -342,8 +363,58 @@ export async function acquireHttp(url, opts = {}) {
|
|
|
342
363
|
await withTimeout(client.connect(sseT), CONNECT_TIMEOUT_MS, 'sse connect');
|
|
343
364
|
}
|
|
344
365
|
};
|
|
366
|
+
// Interactive OAuth: register a client, open the browser, catch the redirect,
|
|
367
|
+
// exchange the code for a token, then reconnect authenticated.
|
|
368
|
+
const connectWithOAuth = async () => {
|
|
369
|
+
const { CliOAuthProvider, startCallbackServer, openBrowser } = await import('./oauth.js');
|
|
370
|
+
const cb = await startCallbackServer();
|
|
371
|
+
// Flips true the instant the browser sign-in is triggered. After that, a
|
|
372
|
+
// failure (denied consent, callback timeout, token-exchange error) is NOT a
|
|
373
|
+
// transport mismatch — we must not restart the flow on SSE (a second browser
|
|
374
|
+
// window + reuse of the already-settled single-shot callback promise).
|
|
375
|
+
let authStarted = false;
|
|
376
|
+
const provider = new CliOAuthProvider(cb.redirectUrl, opts.scope, (authUrl) => {
|
|
377
|
+
authStarted = true;
|
|
378
|
+
process.stderr.write(`\n${'→'} This MCP server requires sign-in. Opening your browser…\n` +
|
|
379
|
+
` If it doesn't open, paste this URL:\n ${authUrl.href}\n\n`);
|
|
380
|
+
openBrowser(authUrl.href);
|
|
381
|
+
}, cb.state);
|
|
382
|
+
const attempt = async (TransportCtor) => {
|
|
383
|
+
let transport = new TransportCtor(parsed, { authProvider: provider, fetch: guardedFetch });
|
|
384
|
+
try {
|
|
385
|
+
await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, 'oauth connect');
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
if (sdk.UnauthorizedError && err instanceof sdk.UnauthorizedError) {
|
|
389
|
+
const code = await cb.waitForCode();
|
|
390
|
+
await transport.finishAuth(code);
|
|
391
|
+
transport = new TransportCtor(parsed, { authProvider: provider, fetch: guardedFetch });
|
|
392
|
+
await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, 'oauth reconnect');
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
throw err;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
try {
|
|
400
|
+
try {
|
|
401
|
+
await attempt(sdk.StreamableHTTPClientTransport);
|
|
402
|
+
}
|
|
403
|
+
catch (primary) {
|
|
404
|
+
// Fall back to SSE ONLY for a pre-auth transport/protocol mismatch; once
|
|
405
|
+
// the browser has opened, surface the real error instead of re-running
|
|
406
|
+
// the whole sign-in on a second transport.
|
|
407
|
+
if (authStarted || (sdk.UnauthorizedError && primary instanceof sdk.UnauthorizedError))
|
|
408
|
+
throw primary;
|
|
409
|
+
await attempt(sdk.SSEClientTransport);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
cb.close();
|
|
414
|
+
}
|
|
415
|
+
};
|
|
345
416
|
try {
|
|
346
|
-
await connect();
|
|
417
|
+
await (opts.login ? connectWithOAuth() : connect());
|
|
347
418
|
const enumd = await enumerate(client);
|
|
348
419
|
const surface = surfaceFromManifest({ ...enumd }, url, url);
|
|
349
420
|
surface.source = { kind: 'http', origin: url };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
|
2
|
+
import type { OAuthClientMetadata, OAuthTokens, OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js';
|
|
3
|
+
/** How long to wait for the user to finish signing in in their browser. */
|
|
4
|
+
export declare const OAUTH_CALLBACK_TIMEOUT_MS = 180000;
|
|
5
|
+
/** Open a URL in the user's default browser (best-effort, cross-platform). */
|
|
6
|
+
export declare function openBrowser(url: string): void;
|
|
7
|
+
/** Parse an OAuth redirect request URL into a code (+ state), or a descriptive error. */
|
|
8
|
+
export declare function parseCallback(reqUrl: string | undefined): {
|
|
9
|
+
code?: string;
|
|
10
|
+
state?: string;
|
|
11
|
+
error?: Error;
|
|
12
|
+
};
|
|
13
|
+
export interface CallbackServer {
|
|
14
|
+
/** The loopback redirect URI to register and redirect back to. */
|
|
15
|
+
redirectUrl: string;
|
|
16
|
+
/** The CSRF `state` value the authorization request must echo back. */
|
|
17
|
+
state: string;
|
|
18
|
+
/** Resolves with the authorization code once the browser redirects back. */
|
|
19
|
+
waitForCode(): Promise<string>;
|
|
20
|
+
/** Shut the loopback server down. */
|
|
21
|
+
close(): void;
|
|
22
|
+
}
|
|
23
|
+
/** Start a loopback HTTP server on a free port to receive the OAuth redirect. */
|
|
24
|
+
export declare function startCallbackServer(): Promise<CallbackServer>;
|
|
25
|
+
/**
|
|
26
|
+
* In-memory `OAuthClientProvider`. Holds the dynamically-registered client,
|
|
27
|
+
* tokens and PKCE verifier for a single scan; opens the browser on redirect.
|
|
28
|
+
*/
|
|
29
|
+
export declare class CliOAuthProvider implements OAuthClientProvider {
|
|
30
|
+
private readonly _redirectUrl;
|
|
31
|
+
private readonly _scope;
|
|
32
|
+
private readonly _onAuthorize;
|
|
33
|
+
private readonly _state;
|
|
34
|
+
private _clientInformation?;
|
|
35
|
+
private _tokens?;
|
|
36
|
+
private _codeVerifier?;
|
|
37
|
+
constructor(_redirectUrl: string, _scope: string | undefined, _onAuthorize: (url: URL) => void, _state: string);
|
|
38
|
+
get redirectUrl(): string;
|
|
39
|
+
/** CSRF nonce echoed through the authorization request (OAuth 2.1 hardening). */
|
|
40
|
+
state(): string;
|
|
41
|
+
get clientMetadata(): OAuthClientMetadata;
|
|
42
|
+
clientInformation(): OAuthClientInformationFull | undefined;
|
|
43
|
+
saveClientInformation(info: OAuthClientInformationFull): void;
|
|
44
|
+
tokens(): OAuthTokens | undefined;
|
|
45
|
+
saveTokens(tokens: OAuthTokens): void;
|
|
46
|
+
redirectToAuthorization(authorizationUrl: URL): void;
|
|
47
|
+
saveCodeVerifier(codeVerifier: string): void;
|
|
48
|
+
codeVerifier(): string;
|
|
49
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
|
+
/**
|
|
3
|
+
* Interactive OAuth 2.0 login for scanning protected remote MCP servers.
|
|
4
|
+
*
|
|
5
|
+
* Modern remote MCP servers gate their `/mcp` endpoint behind OAuth (the MCP
|
|
6
|
+
* authorization spec: discovery → dynamic client registration → authorization-
|
|
7
|
+
* code + PKCE). A static header can't authenticate there. This implements the
|
|
8
|
+
* client half of that flow for the CLI: a loopback callback server catches the
|
|
9
|
+
* redirect, the browser is opened for the user's sign-in, and the SDK exchanges
|
|
10
|
+
* the code for an access token — which it then attaches to the MCP handshake.
|
|
11
|
+
*
|
|
12
|
+
* Tokens are held in memory for the duration of one scan only — nothing is
|
|
13
|
+
* written to disk, consistent with the tool's local-first, no-account stance.
|
|
14
|
+
*/
|
|
15
|
+
import { createServer } from 'node:http';
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import { randomUUID } from 'node:crypto';
|
|
18
|
+
const CLIENT_NAME = 'MCP Trust Checker';
|
|
19
|
+
/** How long to wait for the user to finish signing in in their browser. */
|
|
20
|
+
export const OAUTH_CALLBACK_TIMEOUT_MS = 180_000;
|
|
21
|
+
/** Open a URL in the user's default browser (best-effort, cross-platform). */
|
|
22
|
+
export function openBrowser(url) {
|
|
23
|
+
const [cmd, args] = process.platform === 'darwin'
|
|
24
|
+
? ['open', [url]]
|
|
25
|
+
: process.platform === 'win32'
|
|
26
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
27
|
+
: ['xdg-open', [url]];
|
|
28
|
+
try {
|
|
29
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
30
|
+
child.on('error', () => {
|
|
31
|
+
/* the URL is also printed, so the user can open it manually */
|
|
32
|
+
});
|
|
33
|
+
child.unref();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
/* ignore — non-fatal, the URL is printed for manual use */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Parse an OAuth redirect request URL into a code (+ state), or a descriptive error. */
|
|
40
|
+
export function parseCallback(reqUrl) {
|
|
41
|
+
const parsed = new URL(reqUrl || '', 'http://127.0.0.1');
|
|
42
|
+
const code = parsed.searchParams.get('code');
|
|
43
|
+
const state = parsed.searchParams.get('state') || undefined;
|
|
44
|
+
if (code)
|
|
45
|
+
return { code, state };
|
|
46
|
+
const error = parsed.searchParams.get('error');
|
|
47
|
+
if (error) {
|
|
48
|
+
const desc = parsed.searchParams.get('error_description');
|
|
49
|
+
return { error: new Error(`OAuth authorization failed: ${error}${desc ? ` — ${desc}` : ''}`) };
|
|
50
|
+
}
|
|
51
|
+
return { error: new Error('OAuth callback carried no authorization code.') };
|
|
52
|
+
}
|
|
53
|
+
const OK_PAGE = '<!doctype html><meta charset="utf-8"><title>Authorized</title>' +
|
|
54
|
+
'<body style="font-family:system-ui,sans-serif;background:#07080b;color:#eef1f6;text-align:center;padding:64px 24px">' +
|
|
55
|
+
'<h1 style="color:#c4f542">✓ Authorized</h1>' +
|
|
56
|
+
'<p>You can close this tab and return to the terminal.</p>' +
|
|
57
|
+
'<script>setTimeout(function(){window.close()},1500)</script></body>';
|
|
58
|
+
/** Start a loopback HTTP server on a free port to receive the OAuth redirect. */
|
|
59
|
+
export async function startCallbackServer() {
|
|
60
|
+
let resolveCode;
|
|
61
|
+
let rejectCode;
|
|
62
|
+
const codePromise = new Promise((res, rej) => {
|
|
63
|
+
resolveCode = res;
|
|
64
|
+
rejectCode = rej;
|
|
65
|
+
});
|
|
66
|
+
const expectedState = randomUUID();
|
|
67
|
+
const server = createServer((req, res) => {
|
|
68
|
+
const parsed = new URL(req.url || '', 'http://127.0.0.1');
|
|
69
|
+
if (parsed.pathname !== '/callback') {
|
|
70
|
+
res.writeHead(404);
|
|
71
|
+
res.end();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const { code, state, error } = parseCallback(req.url);
|
|
75
|
+
// Require the CSRF state to match before settling — a stray/forged local
|
|
76
|
+
// request (or a page spraying the ephemeral port) can neither complete NOR
|
|
77
|
+
// abort the sign-in.
|
|
78
|
+
if (state !== expectedState) {
|
|
79
|
+
res.writeHead(400, { 'content-type': 'text/html' });
|
|
80
|
+
res.end('<!doctype html><meta charset="utf-8"><body>Invalid OAuth state.</body>');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (code) {
|
|
84
|
+
res.writeHead(200, { 'content-type': 'text/html' });
|
|
85
|
+
res.end(OK_PAGE);
|
|
86
|
+
resolveCode(code);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
res.writeHead(400, { 'content-type': 'text/html' });
|
|
90
|
+
res.end(`<!doctype html><meta charset="utf-8"><body>${error.message}</body>`);
|
|
91
|
+
rejectCode(error);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
await new Promise((resolve, reject) => {
|
|
95
|
+
server.once('error', reject);
|
|
96
|
+
server.listen(0, '127.0.0.1', () => {
|
|
97
|
+
server.off('error', reject);
|
|
98
|
+
resolve();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
const addr = server.address();
|
|
102
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
103
|
+
const redirectUrl = `http://127.0.0.1:${port}/callback`;
|
|
104
|
+
// Race the callback against a timeout, but ALWAYS clear the timer once the
|
|
105
|
+
// code arrives — a lingering setTimeout would keep the event loop (and the
|
|
106
|
+
// whole CLI, after the scan finishes) alive until it fires.
|
|
107
|
+
const waitForCode = () => new Promise((resolve, reject) => {
|
|
108
|
+
const timer = setTimeout(() => reject(new Error('Timed out waiting for the browser sign-in (3 min). Re-run to try again.')), OAUTH_CALLBACK_TIMEOUT_MS);
|
|
109
|
+
timer.unref?.();
|
|
110
|
+
codePromise.then((code) => {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
resolve(code);
|
|
113
|
+
}, (err) => {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
reject(err);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
return {
|
|
119
|
+
redirectUrl,
|
|
120
|
+
state: expectedState,
|
|
121
|
+
waitForCode,
|
|
122
|
+
close: () => {
|
|
123
|
+
try {
|
|
124
|
+
server.close();
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
/* already closed */
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* In-memory `OAuthClientProvider`. Holds the dynamically-registered client,
|
|
134
|
+
* tokens and PKCE verifier for a single scan; opens the browser on redirect.
|
|
135
|
+
*/
|
|
136
|
+
export class CliOAuthProvider {
|
|
137
|
+
_redirectUrl;
|
|
138
|
+
_scope;
|
|
139
|
+
_onAuthorize;
|
|
140
|
+
_state;
|
|
141
|
+
_clientInformation;
|
|
142
|
+
_tokens;
|
|
143
|
+
_codeVerifier;
|
|
144
|
+
constructor(_redirectUrl, _scope, _onAuthorize, _state) {
|
|
145
|
+
this._redirectUrl = _redirectUrl;
|
|
146
|
+
this._scope = _scope;
|
|
147
|
+
this._onAuthorize = _onAuthorize;
|
|
148
|
+
this._state = _state;
|
|
149
|
+
}
|
|
150
|
+
get redirectUrl() {
|
|
151
|
+
return this._redirectUrl;
|
|
152
|
+
}
|
|
153
|
+
/** CSRF nonce echoed through the authorization request (OAuth 2.1 hardening). */
|
|
154
|
+
state() {
|
|
155
|
+
return this._state;
|
|
156
|
+
}
|
|
157
|
+
get clientMetadata() {
|
|
158
|
+
return {
|
|
159
|
+
client_name: CLIENT_NAME,
|
|
160
|
+
redirect_uris: [this._redirectUrl],
|
|
161
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
162
|
+
response_types: ['code'],
|
|
163
|
+
token_endpoint_auth_method: 'client_secret_post',
|
|
164
|
+
...(this._scope ? { scope: this._scope } : {}),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
clientInformation() {
|
|
168
|
+
return this._clientInformation;
|
|
169
|
+
}
|
|
170
|
+
saveClientInformation(info) {
|
|
171
|
+
this._clientInformation = info;
|
|
172
|
+
}
|
|
173
|
+
tokens() {
|
|
174
|
+
return this._tokens;
|
|
175
|
+
}
|
|
176
|
+
saveTokens(tokens) {
|
|
177
|
+
this._tokens = tokens;
|
|
178
|
+
}
|
|
179
|
+
redirectToAuthorization(authorizationUrl) {
|
|
180
|
+
this._onAuthorize(authorizationUrl);
|
|
181
|
+
}
|
|
182
|
+
saveCodeVerifier(codeVerifier) {
|
|
183
|
+
this._codeVerifier = codeVerifier;
|
|
184
|
+
}
|
|
185
|
+
codeVerifier() {
|
|
186
|
+
if (!this._codeVerifier)
|
|
187
|
+
throw new Error('No PKCE code verifier saved.');
|
|
188
|
+
return this._codeVerifier;
|
|
189
|
+
}
|
|
190
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -30,6 +30,7 @@ import { GRADE_RANK } from '../scoring/model.js';
|
|
|
30
30
|
import { RULE_CATALOG, findRule } from '../data/ruleCatalog.js';
|
|
31
31
|
import { METHODOLOGY_VERSION, TOOL_VERSION } from '../version.js';
|
|
32
32
|
import { c } from '../util/ansi.js';
|
|
33
|
+
import { parseHeaderArgs } from '../util/headers.js';
|
|
33
34
|
const KNOWN_COMMANDS = new Set(['scan', 'pin', 'diff', 'rules', 'explain', 'version', 'help']);
|
|
34
35
|
function fail(msg, code = 2) {
|
|
35
36
|
process.stderr.write(`${c.red('mcptrustchecker: error')} ${msg}\n`);
|
|
@@ -49,6 +50,9 @@ ${c.bold('Usage')}
|
|
|
49
50
|
${c.bold('Targets')}
|
|
50
51
|
path/to/tools.json a pre-generated manifest (offline)
|
|
51
52
|
https://host/mcp a live Streamable-HTTP / SSE endpoint
|
|
53
|
+
--login OAuth browser sign-in for a protected endpoint (opens your browser)
|
|
54
|
+
--scope <scope> OAuth scope to request with --login (e.g. mcp:tools)
|
|
55
|
+
--header "Authorization: Bearer …" static auth header instead of --login (repeatable)
|
|
52
56
|
claude_desktop_config.json a client config (scans each server)
|
|
53
57
|
@scope/package supply-chain / typosquat check (add --online for metadata)
|
|
54
58
|
--command "npx -y my-server" spawn a local stdio server (sandboxed)
|
|
@@ -104,6 +108,9 @@ async function main() {
|
|
|
104
108
|
command: { type: 'string' },
|
|
105
109
|
args: { type: 'string' },
|
|
106
110
|
env: { type: 'string', multiple: true },
|
|
111
|
+
header: { type: 'string', multiple: true },
|
|
112
|
+
login: { type: 'boolean' },
|
|
113
|
+
scope: { type: 'string' },
|
|
107
114
|
url: { type: 'string' },
|
|
108
115
|
online: { type: 'boolean' },
|
|
109
116
|
run: { type: 'boolean' },
|
|
@@ -180,8 +187,18 @@ async function main() {
|
|
|
180
187
|
envVars: parseEnvFlags(values.env),
|
|
181
188
|
allowAnyCommand: values['allow-any-command'],
|
|
182
189
|
allowedHosts: values['allowed-hosts'] ? values['allowed-hosts'].split(',').map((h) => h.trim()) : undefined,
|
|
190
|
+
headers: parseHeaderArgs(values.header),
|
|
191
|
+
login: values.login,
|
|
192
|
+
scope: values.scope,
|
|
183
193
|
registry: values.registry === 'pypi' ? 'pypi' : 'npm',
|
|
184
194
|
};
|
|
195
|
+
// --login and a static Authorization header are alternative auth methods;
|
|
196
|
+
// combining them would silently clobber the freshly-minted OAuth token.
|
|
197
|
+
if (values.login &&
|
|
198
|
+
resolveOpts.headers &&
|
|
199
|
+
Object.keys(resolveOpts.headers).some((h) => h.toLowerCase() === 'authorization')) {
|
|
200
|
+
fail('--login cannot be combined with a static --header "Authorization: …" — choose one auth method.');
|
|
201
|
+
}
|
|
185
202
|
let resolved = [];
|
|
186
203
|
if (autoDiscover) {
|
|
187
204
|
// Zero-config: find and scan every installed MCP client config.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
|
+
/**
|
|
3
|
+
* Parse repeated `--header "Name: Value"` CLI arguments into a header map.
|
|
4
|
+
* Used to authenticate against protected remote MCP endpoints (e.g. a Bearer
|
|
5
|
+
* token). Kept pure and separate from the CLI entrypoint so it stays testable.
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseHeaderArgs(items?: string[]): Record<string, string> | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Contain credentials to the target origin. On the target origin, keep the
|
|
10
|
+
* request's own headers and add any static `--header`s. On any OTHER origin
|
|
11
|
+
* (cross-host redirect, https→http downgrade, different port), strip the
|
|
12
|
+
* credential headers the caller/SDK attached (incl. the OAuth bearer) and drop
|
|
13
|
+
* the static headers — mirroring how a browser drops `Authorization` across
|
|
14
|
+
* origins, so a redirect can't exfiltrate a token.
|
|
15
|
+
*/
|
|
16
|
+
export declare function applyCredentialGate(rawHeaders: ConstructorParameters<typeof Headers>[0], sameOrigin: boolean, staticHeaders?: Record<string, string>): Headers;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
|
+
/**
|
|
3
|
+
* Parse repeated `--header "Name: Value"` CLI arguments into a header map.
|
|
4
|
+
* Used to authenticate against protected remote MCP endpoints (e.g. a Bearer
|
|
5
|
+
* token). Kept pure and separate from the CLI entrypoint so it stays testable.
|
|
6
|
+
*/
|
|
7
|
+
export function parseHeaderArgs(items) {
|
|
8
|
+
if (!items || items.length === 0)
|
|
9
|
+
return undefined;
|
|
10
|
+
const out = {};
|
|
11
|
+
for (const raw of items) {
|
|
12
|
+
const item = raw.trim();
|
|
13
|
+
const idx = item.indexOf(':');
|
|
14
|
+
if (idx <= 0)
|
|
15
|
+
throw new Error(`Invalid --header "${raw}" — expected "Name: Value".`);
|
|
16
|
+
const name = item.slice(0, idx).trim();
|
|
17
|
+
const value = item.slice(idx + 1).trim();
|
|
18
|
+
if (!name)
|
|
19
|
+
throw new Error(`Invalid --header "${raw}" — empty header name.`);
|
|
20
|
+
out[name] = value;
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
/** Credential headers that must never cross an origin boundary on a redirect. */
|
|
25
|
+
const CREDENTIAL_HEADERS = ['authorization', 'cookie', 'proxy-authorization', 'mcp-session-id'];
|
|
26
|
+
/**
|
|
27
|
+
* Contain credentials to the target origin. On the target origin, keep the
|
|
28
|
+
* request's own headers and add any static `--header`s. On any OTHER origin
|
|
29
|
+
* (cross-host redirect, https→http downgrade, different port), strip the
|
|
30
|
+
* credential headers the caller/SDK attached (incl. the OAuth bearer) and drop
|
|
31
|
+
* the static headers — mirroring how a browser drops `Authorization` across
|
|
32
|
+
* origins, so a redirect can't exfiltrate a token.
|
|
33
|
+
*/
|
|
34
|
+
export function applyCredentialGate(rawHeaders, sameOrigin, staticHeaders) {
|
|
35
|
+
const headers = new Headers(rawHeaders);
|
|
36
|
+
if (sameOrigin) {
|
|
37
|
+
if (staticHeaders)
|
|
38
|
+
for (const [k, v] of Object.entries(staticHeaders))
|
|
39
|
+
headers.set(k, v);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
for (const h of CREDENTIAL_HEADERS)
|
|
43
|
+
headers.delete(h);
|
|
44
|
+
if (staticHeaders)
|
|
45
|
+
for (const k of Object.keys(staticHeaders))
|
|
46
|
+
headers.delete(k);
|
|
47
|
+
}
|
|
48
|
+
return headers;
|
|
49
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
2
|
/** Package version. Keep in sync with package.json. */
|
|
3
|
-
export declare const TOOL_VERSION = "1.
|
|
3
|
+
export declare const TOOL_VERSION = "1.1.0";
|
|
4
4
|
/**
|
|
5
5
|
* Methodology version. Bump this whenever scoring weights, gates, rule
|
|
6
6
|
* severities, or bundled threat data change in a way that could move a score.
|
package/dist/version.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
2
|
/** Package version. Keep in sync with package.json. */
|
|
3
|
-
export const TOOL_VERSION = '1.
|
|
3
|
+
export const TOOL_VERSION = '1.1.0';
|
|
4
4
|
/**
|
|
5
5
|
* Methodology version. Bump this whenever scoring weights, gates, rule
|
|
6
6
|
* severities, or bundled threat data change in a way that could move a score.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcptrustchecker",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Local-first, deterministic security scanner for Model Context Protocol (MCP) servers. Cross-tool toxic-flow analysis, Unicode-smuggling decode, prompt-injection & supply-chain detection, with an auditable 0–100 Trust Score.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"scripts": {
|
|
52
52
|
"build": "tsc -p tsconfig.json",
|
|
53
53
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
54
|
-
"test": "node
|
|
54
|
+
"test": "node scripts/run-tests.mjs",
|
|
55
55
|
"clean": "rm -rf dist",
|
|
56
56
|
"prepublishOnly": "npm run clean && npm run build",
|
|
57
57
|
"scan:self": "tsx src/cli/index.ts scan test/fixtures/clean-server.json",
|