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 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 · no API key · <a href="#the-algorithm-the-capability-flow-trust-model">one novel core</a> ·</sub>
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
- - 🔒 **Runs 100% offline** — no account, token, API key, or hosted service; your data never leaves the machine.
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
 
@@ -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>;
@@ -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: '1.0.0' }, { capabilities: {} });
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: '1.0.0' }, { capabilities: {} });
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
- const res = await fetch(current, { ...init, redirect: 'manual' });
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
- const next = new URL(loc, current);
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.0.0";
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.0.0';
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.0.0",
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 --import tsx --test \"test/**/*.test.ts\"",
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",