Skip to main content
interlace
Plugin: mcp-sdk-securityRules

no-credential-leak-in-tool

Disallow an MCP tool returning the server's environment or the caller's token, or forwarding that token upstream.

CWE: CWE-200, CWE-526, CWE-441 OWASP: A01:2021 – Broken Access Control

Detects process.env returned in a tool result, the caller's bearer token (authInfo.token) returned to the model, and that token forwarded to another service. This rule is part of eslint-plugin-mcp-sdk-security.

šŸ’¼ This rule is set to error in the recommended and strict configs.

Quick Summary

AspectDetails
CWE ReferenceCWE-200 (Exposure of Sensitive Information)
SeverityHigh (CVSS 7.5)
Auto-FixāŒ No auto-fix available
CategorySecurity

Why this matters

A tool result is read by the model, written to the host's logs, and can be echoed into any later tool call the model is steered into making. Anything a handler returns should be treated as published to whoever can influence the model.

MessageWhat it catches
envInResult (CWE-526)process.env returned whole — JSON.stringify(process.env), { ...process.env }, Object.entries(process.env) — or process.env[name] with name chosen by a tool argument. Every key the server was started with is one prompt away.
tokenInResult (CWE-200)authInfo.token — the bearer token the SDK verified for this request — returned to the model.
tokenPassthrough (CWE-441)The same token forwarded in an outbound HTTP request or to an upstream MCP client transport. The MCP authorization spec forbids it: the downstream API trusts a token not issued for it (a confused deputy), and audit trails attribute the call to the wrong party.

Relationship to other plugins

None overlap. Neither the tool-result channel nor the SDK's AuthInfo exists outside MCP, and node-security / secure-coding have no rule for either.

āŒ Incorrect

// āŒ every credential the server holds, handed to the model
server.registerTool('debug_env', cfg, async () => ({
  content: [{ type: 'text', text: JSON.stringify(process.env, null, 2) }],
}));

// āŒ the model chooses which variable to read
server.registerTool('get_env', cfg, async ({ name }) => ({
  content: [{ type: 'text', text: String(process.env[name]) }],
}));

// āŒ token passthrough
server.registerTool('list_repos', cfg, async (args, extra) =>
  fetch('https://api.github.com/user/repos', {
    headers: { Authorization: `Bearer ${extra.authInfo.token}` },
  }),
);

How results, tokens and requests are recognised

  • Results are every value a tool handler (or ResourceTemplate callback) returns, followed through same-file bindings.
  • process.env is the global process (unless shadowed) or node:process's export. Reading one named variable — process.env.NODE_ENV — is not reported: whether it is secret is not something a name can prove.
  • authInfo is read from the request context the SDK passes each handler: the second parameter when the tool declares an input schema, the first when it does not, and the second of a setRequestHandler(CallToolRequestSchema) handler. Its token is followed through destructuring, bindings, templates and JSON.stringify(authInfo).
  • Outbound requests are the clients no-ssrf-in-tool resolves (fetch, axios, got, undici, node-fetch, http(s)), plus the SDK's StreamableHTTPClientTransport and SSEClientTransport. Every argument is searched, including nested headers objects and arrays.

āœ… Correct

// āœ… report what the token grants, not the token
server.registerTool('whoami', cfg, async (args, { authInfo }) => ({
  content: [
    {
      type: 'text',
      text: `${authInfo?.clientId}: ${authInfo?.scopes.join(', ')}`,
    },
  ],
}));

// āœ… call downstream with a credential issued to this server
server.registerTool('list_repos', cfg, async (args, extra) => {
  const token = await exchangeForGitHubToken(extra.authInfo);
  return fetch('https://api.github.com/user/repos', {
    headers: { Authorization: `Bearer ${token}` },
  });
});

What this rule deliberately does not report

  • One named environment variable in a result.
  • The variable names — Object.keys(process.env) lists names, not values.
  • Test files (*.test.ts, test/, e2e/, fixtures/, …): a fixture server reporting its own environment ships nothing.
  • The server's own credentials sent downstream (process.env.SERVICE_TOKEN).
  • Other authInfo fields — clientId, scopes, expiresAt.
  • A token exchanged by a call this rule cannot see into: the result of that call is a different token.
  • A file that imports no MCP server package.

When Not To Use It

A debugging tool that deliberately reports configuration should return a fixed list of non-secret keys instead, so this rule has no options.

Further Reading

Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.