Skip to content

Commit fad555f

Browse files
authored
Merge pull request #5293 from modelcontextprotocol/v2/fix/3986-get-env-mask-secrets
fix(everything): get-env masks credential-like values by default
2 parents 25759cc + f0c74aa commit fad555f

7 files changed

Lines changed: 185 additions & 19 deletions

File tree

‎.changeset/get-env-mask-secrets.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@modelcontextprotocol/server-everything": minor
3+
---
4+
5+
`get-env` now masks the values of credential-like environment variables (names with a `KEY`, `TOKEN`, `SECRET`, `PASSWORD`, `PASSWD`, `CREDENTIAL`, `AUTH` or `COOKIE` segment) as `"<redacted>"`. Set `GET_ENV_INCLUDE_SECRETS=1` to return every value unmasked.

‎src/everything/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ This MCP server attempts to exercise all the features of the MCP protocol. It is
1313

1414
A complete list of the registered MCP primitives and other protocol features demonstrated can be found in the [Server Features](docs/features.md) document.
1515

16+
The `get-env` tool returns the server's environment variables, but masks the value of any credential-like name (a `KEY`, `TOKEN`, `SECRET`, `PASSWORD`, `PASSWD`, `CREDENTIAL`, `AUTH` or `COOKIE` segment, such as `OPENAI_API_KEY`) as `"<redacted>"`. To see every value unmasked while debugging, start the server with `GET_ENV_INCLUDE_SECRETS=1` (or `true`). Leave it unset otherwise: with it set, every credential in the server's environment reaches the model's context.
17+
1618
## Protocol versions
1719

1820
The server serves both protocol eras, over stdio and Streamable HTTP, from one process:

‎src/everything/__tests__/tools.test.ts‎

Lines changed: 88 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
* to the client (sampling, elicitation, roots), the task tools, logging,
66
* subscriptions and gzip each have their own file.
77
*/
8-
import { afterEach, describe, expect, it } from "vitest";
8+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9+
import { INCLUDE_SECRETS_VAR, REDACTED } from "../tools/get-env.js";
910
import { MCP_TINY_IMAGE } from "../tools/get-tiny-image.js";
1011
import {
1112
connect,
@@ -69,18 +70,92 @@ describe("get-sum", () => {
6970
});
7071

7172
describe("get-env", () => {
72-
it("returns the whole server process environment as JSON", async () => {
73-
// Characterization: every variable is returned, secrets included. This
74-
// is the tool's documented purpose (debugging server configuration).
75-
process.env.EVERYTHING_TEST_VAR = "test-value";
76-
try {
77-
const result = await call("get-env");
78-
const env = JSON.parse(textOf(contentOf(result)[0]));
79-
expect(env.EVERYTHING_TEST_VAR).toBe("test-value");
80-
expect(env.PATH).toBe(process.env.PATH);
81-
} finally {
82-
delete process.env.EVERYTHING_TEST_VAR;
83-
}
73+
/** Calls `get-env` and parses the JSON object it returns. */
74+
async function getEnv(): Promise<Record<string, string>> {
75+
const result = await call("get-env");
76+
return JSON.parse(textOf(contentOf(result)[0]));
77+
}
78+
79+
beforeEach(() => {
80+
// Mask by default even if the developer's shell sets the opt-in.
81+
vi.stubEnv(INCLUDE_SECRETS_VAR, undefined);
82+
});
83+
84+
afterEach(() => {
85+
vi.unstubAllEnvs();
86+
});
87+
88+
it("returns every variable name, with non-credential values intact", async () => {
89+
vi.stubEnv("EVERYTHING_TEST_VAR", "test-value");
90+
const env = await getEnv();
91+
expect(env.EVERYTHING_TEST_VAR).toBe("test-value");
92+
expect(env.PATH).toBe(process.env.PATH);
93+
expect(Object.keys(env).sort()).toEqual(Object.keys(process.env).sort());
94+
});
95+
96+
it.each([
97+
"OPENAI_API_KEY",
98+
"GITHUB_TOKEN",
99+
"AWS_SECRET_ACCESS_KEY",
100+
"DB_PASSWORD",
101+
"MYSQL_PASSWD",
102+
"GOOGLE_APPLICATION_CREDENTIAL",
103+
"BASIC_AUTH",
104+
"SESSION_COOKIE",
105+
"key",
106+
"my_Token",
107+
])("masks the value of the credential-like name %s", async (key) => {
108+
vi.stubEnv(key, "planted-secret-9f3a1c");
109+
const env = await getEnv();
110+
expect(env[key]).toBe(REDACTED);
111+
expect(JSON.stringify(env)).not.toContain("planted-secret-9f3a1c");
112+
});
113+
114+
it.each([
115+
"PWD",
116+
"OLDPWD",
117+
"KEYBOARD_LAYOUT",
118+
"MONKEY",
119+
"TOKENIZER_PATH",
120+
"AUTHOR_NAME",
121+
"PASSWORDLESS",
122+
])(
123+
"leaves %s readable: no whole segment is a credential word",
124+
async (key) => {
125+
vi.stubEnv(key, "plain-value");
126+
const env = await getEnv();
127+
expect(env[key]).toBe("plain-value");
128+
},
129+
);
130+
131+
it.each(["1", "true", "TRUE", " yes ", "on"])(
132+
"returns credential values unmasked when GET_ENV_INCLUDE_SECRETS=%j",
133+
async (flag) => {
134+
vi.stubEnv(INCLUDE_SECRETS_VAR, flag);
135+
vi.stubEnv("OPENAI_API_KEY", "planted-secret-9f3a1c");
136+
const env = await getEnv();
137+
expect(env.OPENAI_API_KEY).toBe("planted-secret-9f3a1c");
138+
expect(env[INCLUDE_SECRETS_VAR]).toBe(flag);
139+
},
140+
);
141+
142+
it.each(["", "0", "false", "no"])(
143+
"keeps masking when GET_ENV_INCLUDE_SECRETS=%j",
144+
async (flag) => {
145+
vi.stubEnv(INCLUDE_SECRETS_VAR, flag);
146+
vi.stubEnv("OPENAI_API_KEY", "planted-secret-9f3a1c");
147+
const env = await getEnv();
148+
expect(env.OPENAI_API_KEY).toBe(REDACTED);
149+
},
150+
);
151+
152+
it("says in its description that credential values are masked", async () => {
153+
session ??= await connect();
154+
const { tools } = await session.client.listTools();
155+
const tool = tools.find((t) => t.name === "get-env");
156+
expect(tool?.description).toContain(REDACTED);
157+
expect(tool?.description).toContain(INCLUDE_SECRETS_VAR);
158+
expect(tool?.inputSchema.properties ?? {}).toEqual({});
84159
});
85160
});
86161

‎src/everything/docs/features.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Each feature below is marked **both**, **2026-07-28** or **2025-era**. Roots, sa
3131

3232
- `echo` (tools/echo.ts), **both**: Echoes the provided `message: string`. Uses Zod to validate inputs.
3333
- `get-annotated-message` (tools/get-annotated-message.ts), **both**: Returns a `text` message annotated with `priority` and `audience` based on `messageType` (`error`, `success`, or `debug`); can optionally include an annotated `image`.
34-
- `get-env` (tools/get-env.ts), **both**: Returns all environment variables from the running process as pretty-printed JSON text.
34+
- `get-env` (tools/get-env.ts), **both**: Returns all environment variables from the running process as pretty-printed JSON text. Every name is returned, but the value of a credential-like name (one with a `KEY`, `TOKEN`, `SECRET`, `PASSWORD`, `PASSWD`, `CREDENTIAL`, `AUTH` or `COOKIE` segment between underscores, in any case) is replaced with `"<redacted>"`. Set `GET_ENV_INCLUDE_SECRETS=1` (or `true`) in the server's environment to return every value unmasked, for debugging.
3535
- `get-header-params` (tools/get-header-params.ts), **both**: Returns its `region` argument and the `Mcp-Param-Region` HTTP header it arrived in. Its input and output schemas are plain JSON Schema 2020-12, and `region` carries `x-mcp-header: "Region"`, so a 2026-07-28 Streamable HTTP client mirrors it into that header and the server refuses a call whose header disagrees with the body (`-32020`). The header is `null` on stdio and for a 2025-era client.
3636
- `get-resource-links` (tools/get-resource-links.ts), **both**: Returns an intro `text` block followed by multiple `resource_link` items. For a requested `count` (1–10), alternates between dynamic Text and Blob resources using URIs from `resources/templates.ts`.
3737
- `get-resource-reference` (tools/get-resource-reference.ts), **both**: Accepts `resourceType` (`text` or `blob`) and `resourceId` (positive integer). Returns a concrete `resource` content block (with its `uri`, `mimeType`, and data) with surrounding explanatory `text`.

‎src/everything/docs/instructions.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ The server serves two protocol eras. A client on protocol version 2026-07-28 con
2020
## Constraints & Limitations
2121

2222
- `gzip-file-as-resource`: Max fetch size controlled by `GZIP_MAX_FETCH_SIZE` (default 10MB), timeout by `GZIP_MAX_FETCH_TIME_MILLIS` (default 30s), allowed domains by `GZIP_ALLOWED_DOMAINS`
23+
- `get-env`: Values of credential-like variable names (`*_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD` and the like) come back as `"<redacted>"`; the server operator can set `GET_ENV_INCLUDE_SECRETS=1` to return them unmasked
2324
- Session resources are ephemeral and lost when the session ends; minted resources expire after ten minutes
2425
- Roots, sampling and logging are deprecated as of protocol version 2026-07-28, as is the HTTP+SSE transport
2526

‎src/everything/docs/structure.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ src/everything
168168
- `get-annotated-message.ts`
169169
- Registers a `get-annotated-message` tool which demonstrates content-level annotations. Emits a primary `text` message with content `annotations` (`priority`, `audience`) that vary by `messageType` (`"error" | "success" | "debug"`), and optionally includes an annotated `image` (tiny PNG) when `includeImage` is true. All tools in this server include tool-level annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`).
170170
- `get-env.ts`
171-
- Registers a `get-env` tool that returns the current process environment variables as formatted JSON text; useful for debugging configuration.
171+
- Registers a `get-env` tool that returns the current process environment variables as formatted JSON text; useful for debugging configuration. Values of credential-like names are masked as `"<redacted>"` unless `GET_ENV_INCLUDE_SECRETS` is set to a truthy value.
172172
- `get-header-params.ts`
173173
- Registers a `get-header-params` tool with JSON Schema 2020-12 input and output schemas whose `region` argument carries `x-mcp-header`; returns the argument and the `Mcp-Param-Region` header it arrived in.
174174
- `get-resource-links.ts`

‎src/everything/tools/get-env.ts‎

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,50 @@
1+
/**
2+
* The `get-env` tool: returns the server process's environment as JSON, for
3+
* debugging how a client launched the server.
4+
*
5+
* A raw dump would put every credential in the server's environment into
6+
* model context, on a tool annotated read-only that clients may auto-approve
7+
* (#3986, #5130). So by default every variable NAME is returned, but the VALUE
8+
* of a credential-looking name is replaced with a placeholder. A name counts
9+
* as credential-looking when one of its underscore-separated segments is a
10+
* credential word (`OPENAI_API_KEY`, `GITHUB_TOKEN`, `DB_PASSWORD`). Whole
11+
* segments, not substrings, so `PWD`, `KEYBOARD` or `MONKEY` stay readable.
12+
* The heuristic over-masks some names (`SSH_AUTH_SOCK`) and cannot see a
13+
* secret stored under an innocuous name; it is a safer default, not a
14+
* guarantee. Setting `GET_ENV_INCLUDE_SECRETS` to a truthy value restores the
15+
* full, unmasked dump for debugging. It is read on each call, so it reflects
16+
* the environment at call time.
17+
*/
118
import { McpServer, CallToolResult } from "@modelcontextprotocol/server";
219

20+
/** The placeholder that replaces a masked value. */
21+
export const REDACTED = "<redacted>";
22+
23+
/** The opt-in that returns every value unmasked. */
24+
export const INCLUDE_SECRETS_VAR = "GET_ENV_INCLUDE_SECRETS";
25+
26+
/** Name segments that mark a variable as holding a credential. */
27+
const CREDENTIAL_SEGMENTS = new Set([
28+
"KEY",
29+
"TOKEN",
30+
"SECRET",
31+
"PASSWORD",
32+
"PASSWD",
33+
"CREDENTIAL",
34+
"AUTH",
35+
"COOKIE",
36+
]);
37+
38+
/** Values of `GET_ENV_INCLUDE_SECRETS` that turn the opt-in on. */
39+
const TRUTHY = new Set(["1", "true", "yes", "on"]);
40+
341
// Tool configuration
442
const name = "get-env";
543
const config = {
644
title: "Print Environment Tool",
745
description:
8-
"Returns all environment variables, helpful for debugging MCP server configuration",
46+
"Returns all environment variables, helpful for debugging MCP server configuration. " +
47+
`Values of credential-like names (a KEY, TOKEN, SECRET, PASSWORD, PASSWD, CREDENTIAL, AUTH or COOKIE name segment) are masked as "${REDACTED}" unless ${INCLUDE_SECRETS_VAR} is set to 1 or true in the server's environment.`,
948
inputSchema: {},
1049
annotations: {
1150
readOnlyHint: true,
@@ -15,11 +54,55 @@ const config = {
1554
},
1655
};
1756

57+
/**
58+
* Whether an environment variable name looks like it holds a credential:
59+
* one of its underscore-separated segments, compared case-insensitively, is
60+
* a credential word.
61+
*
62+
* @param {string} key - The environment variable name.
63+
* @returns {boolean} True when the value should be masked.
64+
*/
65+
export const isCredentialName = (key: string): boolean =>
66+
key
67+
.toUpperCase()
68+
.split("_")
69+
.some((segment) => CREDENTIAL_SEGMENTS.has(segment));
70+
71+
/**
72+
* Whether `GET_ENV_INCLUDE_SECRETS` is set to a truthy value
73+
* (`1`, `true`, `yes` or `on`, case-insensitively).
74+
*
75+
* @param {NodeJS.ProcessEnv} env - The environment to read the opt-in from.
76+
* @returns {boolean} True when values should be returned unmasked.
77+
*/
78+
export const includeSecrets = (env: NodeJS.ProcessEnv): boolean =>
79+
TRUTHY.has((env[INCLUDE_SECRETS_VAR] ?? "").trim().toLowerCase());
80+
81+
/**
82+
* The environment as the tool returns it: every name, with the value of each
83+
* credential-like name masked unless the opt-in is set.
84+
*
85+
* @param {NodeJS.ProcessEnv} env - The environment to render.
86+
* @returns {Record<string, string | undefined>} The (possibly masked) copy.
87+
*/
88+
export const maskEnv = (
89+
env: NodeJS.ProcessEnv,
90+
): Record<string, string | undefined> => {
91+
if (includeSecrets(env)) return { ...env };
92+
return Object.fromEntries(
93+
Object.entries(env).map(([key, value]) => [
94+
key,
95+
isCredentialName(key) ? REDACTED : value,
96+
]),
97+
);
98+
};
99+
18100
/**
19101
* Registers the 'get-env' tool.
20102
*
21-
* The registered tool Retrieves and returns the environment variables
22-
* of the current process as a JSON-formatted string encapsulated in a text response.
103+
* The registered tool returns the environment variables of the current
104+
* process as a JSON-formatted string in a text response, with the values of
105+
* credential-like names masked unless `GET_ENV_INCLUDE_SECRETS` is set.
23106
*
24107
* @param {McpServer} server - The McpServer instance where the tool will be registered.
25108
* @returns {void}
@@ -30,7 +113,7 @@ export const registerGetEnvTool = (server: McpServer) => {
30113
content: [
31114
{
32115
type: "text",
33-
text: JSON.stringify(process.env, null, 2),
116+
text: JSON.stringify(maskEnv(process.env), null, 2),
34117
},
35118
],
36119
};

0 commit comments

Comments
 (0)