mcp-server-kubernetes 4.1.4 → 4.1.6
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/dist/config/server-config.d.ts +1 -1
- package/dist/config/server-config.js +1 -1
- package/dist/security/kubectl-flags.d.ts +40 -0
- package/dist/security/kubectl-flags.js +171 -6
- package/dist/tools/helm-operations.js +17 -1
- package/dist/tools/kubectl-delete.js +6 -1
- package/dist/tools/kubectl-describe.js +6 -1
- package/dist/tools/kubectl-generic.js +15 -1
- package/dist/tools/kubectl-get.js +6 -1
- package/dist/tools/kubectl-scale.js +6 -1
- package/dist/utils/allowed-hosts.d.ts +28 -0
- package/dist/utils/allowed-hosts.js +65 -0
- package/dist/utils/sse.js +19 -34
- package/dist/utils/streamable-http.js +19 -34
- package/package.json +1 -1
|
@@ -21,6 +21,46 @@ export declare function assertNoDangerousFlags(flags?: Record<string, unknown>,
|
|
|
21
21
|
* flag unless ALLOW_KUBECTL_UNSAFE_FLAGS=true.
|
|
22
22
|
*/
|
|
23
23
|
export declare function assertSafeArgv(args: readonly string[]): void;
|
|
24
|
+
/**
|
|
25
|
+
* Reject flags that make kubectl read a file from the MCP server's filesystem,
|
|
26
|
+
* on remote transports only. No-op under stdio.
|
|
27
|
+
*
|
|
28
|
+
* kubectl_generic lets a caller assemble an arbitrary kubectl argv, so the
|
|
29
|
+
* per-parameter guards the structured tools use (`filename`, `fromFile`,
|
|
30
|
+
* `patchFile`, `valuesFile`) have nothing to attach to: the same read is
|
|
31
|
+
* reachable as a raw "--from-file=leak=/etc/passwd" token. Under stdio that is
|
|
32
|
+
* the operator reading their own files and stays allowed; over SSE/Streamable
|
|
33
|
+
* HTTP the path comes from whoever can reach the endpoint but resolves on the
|
|
34
|
+
* server host, turning kubectl into an arbitrary file-read oracle (kubeconfig,
|
|
35
|
+
* service-account token, /proc/self/environ) — with --dry-run=client, without
|
|
36
|
+
* even a cluster.
|
|
37
|
+
*
|
|
38
|
+
* Takes a fully-constructed argv rather than the `flags`/`args` inputs so that
|
|
39
|
+
* flags built from the object form and file-shaped values smuggled through
|
|
40
|
+
* positional slots (resourceType, name) are covered by the same scan. Unlike
|
|
41
|
+
* assertNoDangerousFlags there is no ALLOW_KUBECTL_UNSAFE_FLAGS escape hatch:
|
|
42
|
+
* this mirrors the transport guards in the structured tools, which reject
|
|
43
|
+
* server-side paths unconditionally.
|
|
44
|
+
*/
|
|
45
|
+
export declare function assertNoRemoteFileReads(args: readonly string[]): void;
|
|
46
|
+
/**
|
|
47
|
+
* Reject a caller-supplied value that a tool is about to place in a *positional*
|
|
48
|
+
* argv slot (a resource type, a resource name, a release name, a chart
|
|
49
|
+
* reference, a namespace) when it is shaped like a command-line flag.
|
|
50
|
+
*
|
|
51
|
+
* assertSafeArgv is a deny-list: it can only refuse the flags it knows about,
|
|
52
|
+
* so every dangerous flag added upstream is exploitable through any positional
|
|
53
|
+
* slot until the list is extended. This guard closes the injection point
|
|
54
|
+
* instead of chasing the flags: pflag treats any token starting with "-" as a
|
|
55
|
+
* flag no matter where it sits, and none of these operands is ever legitimately
|
|
56
|
+
* flag-shaped (Kubernetes resource types and names, helm release names and
|
|
57
|
+
* namespaces are RFC 1123 names; chart references are repo/name pairs, paths
|
|
58
|
+
* or URLs). Refusing a leading "-" here means caller data can never occupy a
|
|
59
|
+
* slot where it would be parsed as a flag.
|
|
60
|
+
*
|
|
61
|
+
* Honours the same ALLOW_KUBECTL_UNSAFE_FLAGS escape hatch as the flag guard.
|
|
62
|
+
*/
|
|
63
|
+
export declare function assertNotFlagLike(value: string | undefined, field: string): void;
|
|
24
64
|
/**
|
|
25
65
|
* Drop-in replacement for child_process.execFileSync that scans the argv for
|
|
26
66
|
* credential/target-redirecting flags before executing. Tool files import this
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
2
2
|
import { execFileSync, } from "child_process";
|
|
3
|
+
import { isRemoteTransport } from "./transport.js";
|
|
3
4
|
// Flags that would let a caller redirect kubectl to a different API server,
|
|
4
5
|
// substitute credentials, or impersonate another identity. Allowing any of
|
|
5
6
|
// these to flow in from tool inputs lets an attacker who can influence the
|
|
@@ -69,7 +70,8 @@ const SHORT_VALUE_FLAGS = new Set([
|
|
|
69
70
|
"v", // --v (log level)
|
|
70
71
|
]);
|
|
71
72
|
// helm exposes the same exfiltration surface as kubectl, but under "kube-"
|
|
72
|
-
// prefixed flag names (e.g. --kube-apiserver instead of --server)
|
|
73
|
+
// prefixed flag names (e.g. --kube-apiserver instead of --server), plus a few
|
|
74
|
+
// helm-only flags that run or overwrite things on the MCP server host. We add
|
|
73
75
|
// those here so the argv-level guard covers helm invocations too. Context
|
|
74
76
|
// selection flags (--context / --kube-context) are intentionally omitted:
|
|
75
77
|
// they can only select a cluster already present in the loaded kubeconfig,
|
|
@@ -83,12 +85,58 @@ const HELM_DANGEROUS_FLAGS = new Set([
|
|
|
83
85
|
"kube-as-group",
|
|
84
86
|
"kube-tls-server-name",
|
|
85
87
|
"kube-insecure-skip-tls-verify",
|
|
88
|
+
// Runs a binary on the MCP server host: helm 3.x resolves --post-renderer to
|
|
89
|
+
// an executable path (or a $PATH name) and runs it over the rendered
|
|
90
|
+
// manifests. Nothing about installing a chart otherwise gives host-side
|
|
91
|
+
// execution, so this is never a flag a tool should emit.
|
|
92
|
+
"post-renderer",
|
|
93
|
+
"post-renderer-args",
|
|
94
|
+
// Writes to attacker-chosen filesystem paths: helm rewrites these files as
|
|
95
|
+
// part of "repo add" / registry login.
|
|
96
|
+
"repository-config",
|
|
97
|
+
"registry-config",
|
|
86
98
|
]);
|
|
87
99
|
// Flag names that are dangerous when they appear anywhere in a fully
|
|
88
100
|
// constructed argv (positional slots included), regardless of which tool
|
|
89
101
|
// built it. This is DANGEROUS_FLAGS minus the context-selection flags, plus
|
|
90
102
|
// the helm equivalents. See assertSafeArgv / execFileSyncSafe below.
|
|
91
103
|
const ARGV_DANGEROUS_FLAGS = new Set([...DANGEROUS_FLAGS, ...HELM_DANGEROUS_FLAGS].filter((name) => name !== "context"));
|
|
104
|
+
// Flags that make kubectl read a file from the machine it is running on.
|
|
105
|
+
//
|
|
106
|
+
// These are not in DANGEROUS_FLAGS: under stdio that machine is the operator's
|
|
107
|
+
// own, and "-f manifest.yaml" is the whole point of the tool. They only become
|
|
108
|
+
// a vulnerability on a remote (SSE / Streamable HTTP) transport, where the path
|
|
109
|
+
// is chosen by a client but resolved on the server host — so they are rejected
|
|
110
|
+
// by transport, in assertNoRemoteFileReads, rather than denied outright.
|
|
111
|
+
//
|
|
112
|
+
// Long names only; the single-dash spellings live in FILE_READ_SHORT_FLAGS.
|
|
113
|
+
const FILE_READ_FLAGS = new Set([
|
|
114
|
+
"filename",
|
|
115
|
+
"from-file",
|
|
116
|
+
"from-env-file",
|
|
117
|
+
"kustomize",
|
|
118
|
+
"patch-file",
|
|
119
|
+
]);
|
|
120
|
+
// Single-letter spellings of the above. Unlike SHORT_ALIASES these are checked
|
|
121
|
+
// against every letter pflag would parse out of a shorthand cluster, so the
|
|
122
|
+
// attached ("-f/etc/passwd") and clustered ("-Rf/etc/passwd") forms are caught
|
|
123
|
+
// alongside the split one ("-f", "/etc/passwd").
|
|
124
|
+
const FILE_READ_SHORT_FLAGS = new Set([
|
|
125
|
+
"f", // --filename
|
|
126
|
+
"k", // --kustomize
|
|
127
|
+
]);
|
|
128
|
+
// Output formats whose argument is a file path on the machine kubectl runs on:
|
|
129
|
+
// "-o go-template-file=<path>" makes kubectl read that file and render it into
|
|
130
|
+
// the result. That is the same read as --from-file under a different flag
|
|
131
|
+
// name, but unlike --from-file no tool here ever legitimately emits one — so
|
|
132
|
+
// assertSafeArgv refuses them in any argv, on any transport, rather than only
|
|
133
|
+
// where a server-side path would be attacker-chosen. Every tool has positional
|
|
134
|
+
// slots, read-only ones included, so a name-shaped denial is not enough.
|
|
135
|
+
const FILE_READ_OUTPUT_FORMATS = new Set([
|
|
136
|
+
"go-template-file",
|
|
137
|
+
"jsonpath-file",
|
|
138
|
+
"custom-columns-file",
|
|
139
|
+
]);
|
|
92
140
|
function isUnsafeFlagsAllowed() {
|
|
93
141
|
return process.env.ALLOW_KUBECTL_UNSAFE_FLAGS === "true";
|
|
94
142
|
}
|
|
@@ -159,10 +207,10 @@ function isDangerousFlagName(rawName, fromArgs) {
|
|
|
159
207
|
}
|
|
160
208
|
function reject(flag) {
|
|
161
209
|
throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl with flag "${flag}": this flag can redirect ` +
|
|
162
|
-
`kubectl to a different API server
|
|
163
|
-
`
|
|
164
|
-
`genuinely need this flag, set
|
|
165
|
-
`server environment.`);
|
|
210
|
+
`kubectl/helm to a different API server, substitute credentials, or ` +
|
|
211
|
+
`act on the MCP server's own host, which would allow exfiltration of ` +
|
|
212
|
+
`the operator's bearer token. If you genuinely need this flag, set ` +
|
|
213
|
+
`ALLOW_KUBECTL_UNSAFE_FLAGS=true in the server environment.`);
|
|
166
214
|
}
|
|
167
215
|
/**
|
|
168
216
|
* Validate user-supplied kubectl flags and args. Throws an McpError if any
|
|
@@ -207,7 +255,8 @@ export function assertNoDangerousFlags(flags, args) {
|
|
|
207
255
|
export function assertSafeArgv(args) {
|
|
208
256
|
if (isUnsafeFlagsAllowed())
|
|
209
257
|
return;
|
|
210
|
-
for (
|
|
258
|
+
for (let i = 0; i < args.length; i++) {
|
|
259
|
+
const tok = args[i];
|
|
211
260
|
if (typeof tok !== "string")
|
|
212
261
|
continue;
|
|
213
262
|
if (!tok.startsWith("-"))
|
|
@@ -220,7 +269,123 @@ export function assertSafeArgv(args) {
|
|
|
220
269
|
// first one.
|
|
221
270
|
if (hasDangerousShortFlag(tok))
|
|
222
271
|
reject(tok);
|
|
272
|
+
// A file read hiding in the *value* of an otherwise ordinary flag
|
|
273
|
+
// ("-o go-template-file=<path>"), so it has to be matched on the value
|
|
274
|
+
// rather than on the flag name.
|
|
275
|
+
const format = outputFormatValue(tok, args[i + 1]);
|
|
276
|
+
if (format !== undefined &&
|
|
277
|
+
FILE_READ_OUTPUT_FORMATS.has(format.split("=")[0])) {
|
|
278
|
+
rejectOutputFileRead(tok, format);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Return the value kubectl would take for -o / --output in `tok`, or undefined
|
|
284
|
+
* if `tok` is not an output flag. The value may be attached to the token
|
|
285
|
+
* ("-ojson", "-o=json", "--output=json") or sit in the following one, which is
|
|
286
|
+
* why the caller passes `next`.
|
|
287
|
+
*/
|
|
288
|
+
function outputFormatValue(tok, next) {
|
|
289
|
+
if (tok.startsWith("--")) {
|
|
290
|
+
if (normalizeFlagName(tok) !== "output")
|
|
291
|
+
return undefined;
|
|
292
|
+
const eq = tok.indexOf("=");
|
|
293
|
+
return eq === -1 ? next : tok.slice(eq + 1);
|
|
223
294
|
}
|
|
295
|
+
const letters = shortFlagLetters(tok);
|
|
296
|
+
if (letters === null)
|
|
297
|
+
return undefined;
|
|
298
|
+
const idx = letters.indexOf("o");
|
|
299
|
+
if (idx === -1)
|
|
300
|
+
return undefined;
|
|
301
|
+
// "o" takes a value, so pflag stops the cluster there and reads the rest of
|
|
302
|
+
// the token as the format ("-Aojson" -> "-A -o json"). An empty remainder
|
|
303
|
+
// means the format is the next argv token instead.
|
|
304
|
+
let rest = tok.slice(1 + idx + 1);
|
|
305
|
+
if (rest.startsWith("="))
|
|
306
|
+
rest = rest.slice(1);
|
|
307
|
+
return rest === "" ? next : rest;
|
|
308
|
+
}
|
|
309
|
+
function rejectOutputFileRead(tok, format) {
|
|
310
|
+
throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl with "${tok} ${format}": this output format ` +
|
|
311
|
+
`takes a file path and makes kubectl read that file from the MCP ` +
|
|
312
|
+
`server's own filesystem, rendering it into the result. No tool needs ` +
|
|
313
|
+
`it, and a resource name or type that parses as this flag is an ` +
|
|
314
|
+
`injection attempt. Use an inline template ("-o go-template=...") or a ` +
|
|
315
|
+
`jsonpath expression instead.`);
|
|
316
|
+
}
|
|
317
|
+
function rejectFileRead(flag) {
|
|
318
|
+
throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl with "${flag}": this flag reads a file from the ` +
|
|
319
|
+
`MCP server's own filesystem, which is disabled on remote (SSE/` +
|
|
320
|
+
`Streamable HTTP) transports because the path is chosen by the client ` +
|
|
321
|
+
`but resolved on the server host. Pass the file contents inline ` +
|
|
322
|
+
`instead (kubectl_apply/kubectl_create "manifest", kubectl_create ` +
|
|
323
|
+
`"fromFileContent", or install_helm_chart "values").`);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Reject flags that make kubectl read a file from the MCP server's filesystem,
|
|
327
|
+
* on remote transports only. No-op under stdio.
|
|
328
|
+
*
|
|
329
|
+
* kubectl_generic lets a caller assemble an arbitrary kubectl argv, so the
|
|
330
|
+
* per-parameter guards the structured tools use (`filename`, `fromFile`,
|
|
331
|
+
* `patchFile`, `valuesFile`) have nothing to attach to: the same read is
|
|
332
|
+
* reachable as a raw "--from-file=leak=/etc/passwd" token. Under stdio that is
|
|
333
|
+
* the operator reading their own files and stays allowed; over SSE/Streamable
|
|
334
|
+
* HTTP the path comes from whoever can reach the endpoint but resolves on the
|
|
335
|
+
* server host, turning kubectl into an arbitrary file-read oracle (kubeconfig,
|
|
336
|
+
* service-account token, /proc/self/environ) — with --dry-run=client, without
|
|
337
|
+
* even a cluster.
|
|
338
|
+
*
|
|
339
|
+
* Takes a fully-constructed argv rather than the `flags`/`args` inputs so that
|
|
340
|
+
* flags built from the object form and file-shaped values smuggled through
|
|
341
|
+
* positional slots (resourceType, name) are covered by the same scan. Unlike
|
|
342
|
+
* assertNoDangerousFlags there is no ALLOW_KUBECTL_UNSAFE_FLAGS escape hatch:
|
|
343
|
+
* this mirrors the transport guards in the structured tools, which reject
|
|
344
|
+
* server-side paths unconditionally.
|
|
345
|
+
*/
|
|
346
|
+
export function assertNoRemoteFileReads(args) {
|
|
347
|
+
if (!isRemoteTransport())
|
|
348
|
+
return;
|
|
349
|
+
for (let i = 0; i < args.length; i++) {
|
|
350
|
+
const tok = args[i];
|
|
351
|
+
if (typeof tok !== "string" || !tok.startsWith("-"))
|
|
352
|
+
continue;
|
|
353
|
+
if (FILE_READ_FLAGS.has(normalizeFlagName(tok)))
|
|
354
|
+
rejectFileRead(tok);
|
|
355
|
+
// Short spellings, including the attached and clustered forms pflag
|
|
356
|
+
// accepts: "-f/etc/passwd", "-Rf/etc/passwd".
|
|
357
|
+
const letters = shortFlagLetters(tok);
|
|
358
|
+
if (letters?.some((letter) => FILE_READ_SHORT_FLAGS.has(letter))) {
|
|
359
|
+
rejectFileRead(tok);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Reject a caller-supplied value that a tool is about to place in a *positional*
|
|
365
|
+
* argv slot (a resource type, a resource name, a release name, a chart
|
|
366
|
+
* reference, a namespace) when it is shaped like a command-line flag.
|
|
367
|
+
*
|
|
368
|
+
* assertSafeArgv is a deny-list: it can only refuse the flags it knows about,
|
|
369
|
+
* so every dangerous flag added upstream is exploitable through any positional
|
|
370
|
+
* slot until the list is extended. This guard closes the injection point
|
|
371
|
+
* instead of chasing the flags: pflag treats any token starting with "-" as a
|
|
372
|
+
* flag no matter where it sits, and none of these operands is ever legitimately
|
|
373
|
+
* flag-shaped (Kubernetes resource types and names, helm release names and
|
|
374
|
+
* namespaces are RFC 1123 names; chart references are repo/name pairs, paths
|
|
375
|
+
* or URLs). Refusing a leading "-" here means caller data can never occupy a
|
|
376
|
+
* slot where it would be parsed as a flag.
|
|
377
|
+
*
|
|
378
|
+
* Honours the same ALLOW_KUBECTL_UNSAFE_FLAGS escape hatch as the flag guard.
|
|
379
|
+
*/
|
|
380
|
+
export function assertNotFlagLike(value, field) {
|
|
381
|
+
if (isUnsafeFlagsAllowed())
|
|
382
|
+
return;
|
|
383
|
+
if (typeof value !== "string" || !value.startsWith("-"))
|
|
384
|
+
return;
|
|
385
|
+
throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl/helm with ${field}="${value}": a value ` +
|
|
386
|
+
`starting with "-" is parsed as a command-line flag rather than as ` +
|
|
387
|
+
`the ${field}, which would let the caller inject arbitrary flags. ` +
|
|
388
|
+
`Provide a ${field} that does not begin with "-".`);
|
|
224
389
|
}
|
|
225
390
|
/**
|
|
226
391
|
* Drop-in replacement for child_process.execFileSync that scans the argv for
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Supports local chart paths, remote repositories, and custom values.
|
|
6
6
|
*/
|
|
7
7
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
-
import { execFileSyncSafe } from "../security/kubectl-flags.js";
|
|
8
|
+
import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
|
|
9
9
|
import { writeFileSync, unlinkSync } from "fs";
|
|
10
10
|
import { dump } from "js-yaml";
|
|
11
11
|
import { isRemoteTransport } from "../security/transport.js";
|
|
@@ -166,6 +166,19 @@ const rejectValuesFileOnRemoteTransport = (valuesFile) => {
|
|
|
166
166
|
throw new McpError(ErrorCode.InvalidRequest, "The 'valuesFile' parameter reads a file from the MCP server's filesystem and is disabled on remote (SSE/Streamable HTTP) transports. Pass the values inline via 'values' instead.");
|
|
167
167
|
}
|
|
168
168
|
};
|
|
169
|
+
// The release name, chart reference, namespace and repo URL are all placed in
|
|
170
|
+
// positional argv slots ("helm install <name> <chart>", "helm repo add
|
|
171
|
+
// <name> <url>", "kubectl create namespace <ns>"). pflag parses any token
|
|
172
|
+
// beginning with "-" as a flag regardless of position, so a caller-supplied
|
|
173
|
+
// value such as "--post-renderer=/tmp/x.sh" would become a helm flag instead
|
|
174
|
+
// of an operand. Reject flag-shaped operands up front rather than relying on
|
|
175
|
+
// the argv deny-list to know every dangerous helm flag.
|
|
176
|
+
const assertOperandsNotFlagLike = (params) => {
|
|
177
|
+
assertNotFlagLike(params.name, "release name");
|
|
178
|
+
assertNotFlagLike(params.chart, "chart");
|
|
179
|
+
assertNotFlagLike(params.namespace, "namespace");
|
|
180
|
+
assertNotFlagLike(params.repo, "repo");
|
|
181
|
+
};
|
|
169
182
|
/**
|
|
170
183
|
* Install a Helm chart using template mode (helm template + kubectl apply).
|
|
171
184
|
* This mode bypasses authentication issues and kubeconfig API version mismatches.
|
|
@@ -274,6 +287,7 @@ async function installHelmChartTemplate(params) {
|
|
|
274
287
|
*/
|
|
275
288
|
export async function installHelmChart(params) {
|
|
276
289
|
rejectValuesFileOnRemoteTransport(params.valuesFile);
|
|
290
|
+
assertOperandsNotFlagLike(params);
|
|
277
291
|
// Use template mode if requested
|
|
278
292
|
if (params.useTemplate) {
|
|
279
293
|
return installHelmChartTemplate(params);
|
|
@@ -349,6 +363,7 @@ export async function installHelmChart(params) {
|
|
|
349
363
|
*/
|
|
350
364
|
export async function upgradeHelmChart(params) {
|
|
351
365
|
rejectValuesFileOnRemoteTransport(params.valuesFile);
|
|
366
|
+
assertOperandsNotFlagLike(params);
|
|
352
367
|
try {
|
|
353
368
|
// Add repository if provided
|
|
354
369
|
if (params.repo) {
|
|
@@ -415,6 +430,7 @@ export async function upgradeHelmChart(params) {
|
|
|
415
430
|
* @returns Promise with uninstall result
|
|
416
431
|
*/
|
|
417
432
|
export async function uninstallHelmChart(params) {
|
|
433
|
+
assertOperandsNotFlagLike(params);
|
|
418
434
|
try {
|
|
419
435
|
executeCommand("helm", [
|
|
420
436
|
"uninstall",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFileSyncSafe } from "../security/kubectl-flags.js";
|
|
1
|
+
import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
|
|
2
2
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as path from "path";
|
|
@@ -94,6 +94,11 @@ export async function kubectlDelete(k8sManager, input) {
|
|
|
94
94
|
}
|
|
95
95
|
else {
|
|
96
96
|
// Handle deleting by resource type and name/selector
|
|
97
|
+
// A resource type or name is an RFC 1123 name, never a flag. Refusing
|
|
98
|
+
// a leading "-" closes the positional injection slot itself rather than
|
|
99
|
+
// relying on the argv denylist to know every dangerous flag.
|
|
100
|
+
assertNotFlagLike(input.resourceType, "resourceType");
|
|
101
|
+
assertNotFlagLike(input.name, "name");
|
|
97
102
|
args.push(input.resourceType);
|
|
98
103
|
if (input.name) {
|
|
99
104
|
args.push(input.name);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFileSyncSafe } from "../security/kubectl-flags.js";
|
|
1
|
+
import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
|
|
2
2
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import { getSpawnMaxBuffer } from "../config/max-buffer.js";
|
|
4
4
|
import { namespaceParameter, contextParameter, } from "../models/common-parameters.js";
|
|
@@ -32,6 +32,11 @@ export const kubectlDescribeSchema = {
|
|
|
32
32
|
};
|
|
33
33
|
export async function kubectlDescribe(k8sManager, input) {
|
|
34
34
|
try {
|
|
35
|
+
// A resource type or name is an RFC 1123 name, never a flag. Refusing a
|
|
36
|
+
// leading "-" closes the positional injection slot itself rather than
|
|
37
|
+
// relying on the argv denylist to know every dangerous flag.
|
|
38
|
+
assertNotFlagLike(input.resourceType, "resourceType");
|
|
39
|
+
assertNotFlagLike(input.name, "name");
|
|
35
40
|
const resourceType = input.resourceType.toLowerCase();
|
|
36
41
|
const name = input.name;
|
|
37
42
|
const namespace = input.namespace || "default";
|
|
@@ -2,7 +2,7 @@ import { execFileSyncSafe } from "../security/kubectl-flags.js";
|
|
|
2
2
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import { getSpawnMaxBuffer } from "../config/max-buffer.js";
|
|
4
4
|
import { contextParameter, namespaceParameter, } from "../models/common-parameters.js";
|
|
5
|
-
import { assertNoDangerousFlags } from "../security/kubectl-flags.js";
|
|
5
|
+
import { assertNoDangerousFlags, assertNoRemoteFileReads, assertNotFlagLike, } from "../security/kubectl-flags.js";
|
|
6
6
|
export const kubectlGenericSchema = {
|
|
7
7
|
name: "kubectl_generic",
|
|
8
8
|
description: "Execute any kubectl command with the provided arguments and flags",
|
|
@@ -59,6 +59,13 @@ export async function kubectlGeneric(k8sManager, input) {
|
|
|
59
59
|
// Reject credential/target-redirecting flags before constructing the
|
|
60
60
|
// command. See src/security/kubectl-flags.ts for the rationale.
|
|
61
61
|
assertNoDangerousFlags(input.flags, input.args);
|
|
62
|
+
// The verb and the resource operands are never flags — only the free-form
|
|
63
|
+
// `args` array is. Refusing a leading "-" in the operand slots closes the
|
|
64
|
+
// positional injection point without narrowing what `args` can express.
|
|
65
|
+
assertNotFlagLike(input.command, "command");
|
|
66
|
+
assertNotFlagLike(input.subCommand, "subCommand");
|
|
67
|
+
assertNotFlagLike(input.resourceType, "resourceType");
|
|
68
|
+
assertNotFlagLike(input.name, "name");
|
|
62
69
|
// Start building the kubectl command
|
|
63
70
|
const command = "kubectl";
|
|
64
71
|
const cmdArgs = [input.command];
|
|
@@ -107,6 +114,13 @@ export async function kubectlGeneric(k8sManager, input) {
|
|
|
107
114
|
if (input.context) {
|
|
108
115
|
cmdArgs.push("--context", input.context);
|
|
109
116
|
}
|
|
117
|
+
// Reject server-side filesystem reads on remote transports, matching the
|
|
118
|
+
// per-parameter guards in the structured tools. This tool hands the caller
|
|
119
|
+
// a free-form kubectl argv, so there is no parameter to attach them to:
|
|
120
|
+
// "--from-file", "-f" and friends arrive as raw tokens and have to be
|
|
121
|
+
// matched in the argv. See src/security/transport.ts for the trust model.
|
|
122
|
+
// No-op under stdio, where the files are the operator's own.
|
|
123
|
+
assertNoRemoteFileReads(cmdArgs);
|
|
110
124
|
// Execute the command
|
|
111
125
|
try {
|
|
112
126
|
console.error(`Executing: kubectl ${cmdArgs.join(" ")}`);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFileSyncSafe } from "../security/kubectl-flags.js";
|
|
1
|
+
import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
|
|
2
2
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import { getSpawnMaxBuffer } from "../config/max-buffer.js";
|
|
4
4
|
import * as yaml from "js-yaml";
|
|
@@ -51,6 +51,11 @@ export const kubectlGetSchema = {
|
|
|
51
51
|
};
|
|
52
52
|
export async function kubectlGet(k8sManager, input) {
|
|
53
53
|
try {
|
|
54
|
+
// A resource type or name is an RFC 1123 name, never a flag. Refusing a
|
|
55
|
+
// leading "-" closes the positional injection slot itself rather than
|
|
56
|
+
// relying on the argv denylist to know every dangerous flag.
|
|
57
|
+
assertNotFlagLike(input.resourceType, "resourceType");
|
|
58
|
+
assertNotFlagLike(input.name, "name");
|
|
54
59
|
const resourceType = input.resourceType.toLowerCase();
|
|
55
60
|
const name = input.name || "";
|
|
56
61
|
const namespace = input.namespace || "default";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFileSyncSafe } from "../security/kubectl-flags.js";
|
|
1
|
+
import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
|
|
2
2
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import { getSpawnMaxBuffer } from "../config/max-buffer.js";
|
|
4
4
|
import { contextParameter, namespaceParameter } from "../models/common-parameters.js";
|
|
@@ -32,6 +32,11 @@ export const kubectlScaleSchema = {
|
|
|
32
32
|
};
|
|
33
33
|
export async function kubectlScale(k8sManager, input) {
|
|
34
34
|
try {
|
|
35
|
+
// A resource type or name is an RFC 1123 name, never a flag. Refusing a
|
|
36
|
+
// leading "-" closes the positional injection slot itself rather than
|
|
37
|
+
// relying on the argv denylist to know every dangerous flag.
|
|
38
|
+
assertNotFlagLike(input.resourceType, "resourceType");
|
|
39
|
+
assertNotFlagLike(input.name, "name");
|
|
35
40
|
const namespace = input.namespace || "default";
|
|
36
41
|
const resourceType = input.resourceType || "deployment";
|
|
37
42
|
const context = input.context || "";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared construction of the allowedHosts list used by both HTTP transports
|
|
3
|
+
* for DNS rebinding protection.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* True when `host` is an all-interfaces bind address ("0.0.0.0", "::", and the
|
|
7
|
+
* bracketed IPv6 spelling).
|
|
8
|
+
*/
|
|
9
|
+
export declare function isAllInterfacesHost(host: string): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Build the default allowedHosts list for DNS rebinding protection.
|
|
12
|
+
* Includes localhost variants with and without port.
|
|
13
|
+
*
|
|
14
|
+
* The configured HOST is added too, so that binding to a specific address also
|
|
15
|
+
* accepts that address as a Host header — but only when it names an actual
|
|
16
|
+
* interface. An all-interfaces bind ("0.0.0.0", "::") is deliberately excluded:
|
|
17
|
+
* it is a bind directive, not an address a client resolves, so accepting it
|
|
18
|
+
* would let any caller satisfy the allowlist by sending "Host: 0.0.0.0:<port>"
|
|
19
|
+
* — a value that is public knowledge rather than a property of the deployment.
|
|
20
|
+
* That would defeat the guard exactly where it matters most, since binding to
|
|
21
|
+
* all interfaces is what makes the server reachable off-host in the first
|
|
22
|
+
* place.
|
|
23
|
+
*
|
|
24
|
+
* Deployments that bind to all interfaces and need to accept a real hostname
|
|
25
|
+
* (a Kubernetes Service DNS name, an ingress host) set
|
|
26
|
+
* DNS_REBINDING_ALLOWED_HOST to that name; it replaces this list entirely.
|
|
27
|
+
*/
|
|
28
|
+
export declare function buildDefaultAllowedHosts(host: string, port: number): string[];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared construction of the allowedHosts list used by both HTTP transports
|
|
3
|
+
* for DNS rebinding protection.
|
|
4
|
+
*/
|
|
5
|
+
// Bind addresses that mean "every interface". They are not hostnames: no
|
|
6
|
+
// legitimate client resolves them, so no legitimate client sends one in a Host
|
|
7
|
+
// header. See isAllInterfacesHost / buildDefaultAllowedHosts below.
|
|
8
|
+
const ALL_INTERFACES_HOSTS = new Set(["0.0.0.0", "::", "::0", "0"]);
|
|
9
|
+
/**
|
|
10
|
+
* True when `host` is an all-interfaces bind address ("0.0.0.0", "::", and the
|
|
11
|
+
* bracketed IPv6 spelling).
|
|
12
|
+
*/
|
|
13
|
+
export function isAllInterfacesHost(host) {
|
|
14
|
+
const bare = host.startsWith("[") && host.endsWith("]")
|
|
15
|
+
? host.slice(1, -1)
|
|
16
|
+
: host;
|
|
17
|
+
return ALL_INTERFACES_HOSTS.has(bare);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build the default allowedHosts list for DNS rebinding protection.
|
|
21
|
+
* Includes localhost variants with and without port.
|
|
22
|
+
*
|
|
23
|
+
* The configured HOST is added too, so that binding to a specific address also
|
|
24
|
+
* accepts that address as a Host header — but only when it names an actual
|
|
25
|
+
* interface. An all-interfaces bind ("0.0.0.0", "::") is deliberately excluded:
|
|
26
|
+
* it is a bind directive, not an address a client resolves, so accepting it
|
|
27
|
+
* would let any caller satisfy the allowlist by sending "Host: 0.0.0.0:<port>"
|
|
28
|
+
* — a value that is public knowledge rather than a property of the deployment.
|
|
29
|
+
* That would defeat the guard exactly where it matters most, since binding to
|
|
30
|
+
* all interfaces is what makes the server reachable off-host in the first
|
|
31
|
+
* place.
|
|
32
|
+
*
|
|
33
|
+
* Deployments that bind to all interfaces and need to accept a real hostname
|
|
34
|
+
* (a Kubernetes Service DNS name, an ingress host) set
|
|
35
|
+
* DNS_REBINDING_ALLOWED_HOST to that name; it replaces this list entirely.
|
|
36
|
+
*/
|
|
37
|
+
export function buildDefaultAllowedHosts(host, port) {
|
|
38
|
+
// Always allow the bare host and host:port for common localhost addresses
|
|
39
|
+
const localhostAliases = ["127.0.0.1", "localhost", "::1"];
|
|
40
|
+
const hosts = [];
|
|
41
|
+
for (const alias of localhostAliases) {
|
|
42
|
+
hosts.push(alias);
|
|
43
|
+
// HTTP Host header uses bracket notation for IPv6: [::1]:3000
|
|
44
|
+
if (alias.includes(":")) {
|
|
45
|
+
hosts.push(`[${alias}]`);
|
|
46
|
+
hosts.push(`[${alias}]:${port}`);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
hosts.push(`${alias}:${port}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Also add the configured host if it's not already covered, unless it is an
|
|
53
|
+
// all-interfaces bind address (see above).
|
|
54
|
+
if (!localhostAliases.includes(host) && !isAllInterfacesHost(host)) {
|
|
55
|
+
hosts.push(host);
|
|
56
|
+
if (host.includes(":") && !host.startsWith("[")) {
|
|
57
|
+
hosts.push(`[${host}]`);
|
|
58
|
+
hosts.push(`[${host}]:${port}`);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
hosts.push(`${host}:${port}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return hosts;
|
|
65
|
+
}
|
package/dist/utils/sse.js
CHANGED
|
@@ -1,38 +1,7 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
3
3
|
import { createAuthMiddleware, isAuthEnabled } from "./auth.js";
|
|
4
|
-
|
|
5
|
-
* Build the default allowedHosts list for DNS rebinding protection.
|
|
6
|
-
* Includes localhost variants with and without port.
|
|
7
|
-
*/
|
|
8
|
-
function buildDefaultAllowedHosts(host, port) {
|
|
9
|
-
// Always allow the bare host and host:port for common localhost addresses
|
|
10
|
-
const localhostAliases = ["127.0.0.1", "localhost", "::1"];
|
|
11
|
-
const hosts = [];
|
|
12
|
-
for (const alias of localhostAliases) {
|
|
13
|
-
hosts.push(alias);
|
|
14
|
-
// HTTP Host header uses bracket notation for IPv6: [::1]:3000
|
|
15
|
-
if (alias.includes(":")) {
|
|
16
|
-
hosts.push(`[${alias}]`);
|
|
17
|
-
hosts.push(`[${alias}]:${port}`);
|
|
18
|
-
}
|
|
19
|
-
else {
|
|
20
|
-
hosts.push(`${alias}:${port}`);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
// Also add the configured host if it's not already covered
|
|
24
|
-
if (!localhostAliases.includes(host)) {
|
|
25
|
-
hosts.push(host);
|
|
26
|
-
if (host.includes(":") && !host.startsWith("[")) {
|
|
27
|
-
hosts.push(`[${host}]`);
|
|
28
|
-
hosts.push(`[${host}]:${port}`);
|
|
29
|
-
}
|
|
30
|
-
else {
|
|
31
|
-
hosts.push(`${host}:${port}`);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return hosts;
|
|
35
|
-
}
|
|
4
|
+
import { buildDefaultAllowedHosts, isAllInterfacesHost, } from "./allowed-hosts.js";
|
|
36
5
|
export function startSSEServer(server) {
|
|
37
6
|
const app = express();
|
|
38
7
|
// Create auth middleware - when MCP_AUTH_TOKEN is set, requires X-MCP-AUTH header
|
|
@@ -46,12 +15,24 @@ export function startSSEServer(server) {
|
|
|
46
15
|
? [process.env.DNS_REBINDING_ALLOWED_HOST]
|
|
47
16
|
: buildDefaultAllowedHosts(host, port);
|
|
48
17
|
// Warn when binding to all interfaces with DNS rebinding protection disabled
|
|
49
|
-
if (!enableDnsRebindingProtection && (host
|
|
18
|
+
if (!enableDnsRebindingProtection && isAllInterfacesHost(host)) {
|
|
50
19
|
console.warn("WARNING: DNS rebinding protection is disabled while HOST is set to " +
|
|
51
20
|
`'${host}'. This exposes the MCP server to DNS rebinding attacks ` +
|
|
52
21
|
"from any browser on the network. Set DNS_REBINDING_PROTECTION=true " +
|
|
53
22
|
"(the default) or restrict HOST to 'localhost' / '127.0.0.1'.");
|
|
54
23
|
}
|
|
24
|
+
// Binding to all interfaces makes the server reachable off-host, but the
|
|
25
|
+
// default allowlist only covers localhost: a client reaching it under any
|
|
26
|
+
// other name gets a 403 until the operator names that hostname explicitly.
|
|
27
|
+
if (enableDnsRebindingProtection &&
|
|
28
|
+
isAllInterfacesHost(host) &&
|
|
29
|
+
!process.env.DNS_REBINDING_ALLOWED_HOST) {
|
|
30
|
+
console.warn(`NOTE: HOST is set to '${host}' (all interfaces) and DNS rebinding ` +
|
|
31
|
+
"protection is enabled, so only requests with a localhost Host header " +
|
|
32
|
+
"are accepted. Clients reaching this server under another hostname " +
|
|
33
|
+
"(a Kubernetes Service name, an ingress host) must be allowed by " +
|
|
34
|
+
"setting DNS_REBINDING_ALLOWED_HOST to that hostname.");
|
|
35
|
+
}
|
|
55
36
|
// Currently just copying from docs & allowing for multiple transport connections: https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse
|
|
56
37
|
// Note: When MCP_AUTH_TOKEN is set, requests require X-MCP-AUTH header for authentication
|
|
57
38
|
let transports = [];
|
|
@@ -95,8 +76,12 @@ export function startSSEServer(server) {
|
|
|
95
76
|
});
|
|
96
77
|
}
|
|
97
78
|
});
|
|
79
|
+
// An all-interfaces bind is not a hostname a client can use: with DNS
|
|
80
|
+
// rebinding protection on it is not in the allowlist either, so advertise a
|
|
81
|
+
// localhost URL that actually works rather than 'http://0.0.0.0:<port>'.
|
|
82
|
+
const advertisedHost = isAllInterfacesHost(host) ? "localhost" : host;
|
|
98
83
|
app.listen(port, host, () => {
|
|
99
|
-
console.log(`mcp-kubernetes-server is listening on port ${port}\nUse the following url to connect to the server:\nhttp://${
|
|
84
|
+
console.log(`mcp-kubernetes-server is listening on port ${port}\nUse the following url to connect to the server:\nhttp://${advertisedHost}:${port}/sse`);
|
|
100
85
|
if (isAuthEnabled()) {
|
|
101
86
|
console.log("Authentication enabled: X-MCP-AUTH header required for all MCP requests");
|
|
102
87
|
}
|
|
@@ -1,38 +1,7 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
3
3
|
import { createAuthMiddleware, isAuthEnabled } from "./auth.js";
|
|
4
|
-
|
|
5
|
-
* Build the default allowedHosts list for DNS rebinding protection.
|
|
6
|
-
* Includes localhost variants with and without port.
|
|
7
|
-
*/
|
|
8
|
-
function buildDefaultAllowedHosts(host, port) {
|
|
9
|
-
// Always allow the bare host and host:port for common localhost addresses
|
|
10
|
-
const localhostAliases = ["127.0.0.1", "localhost", "::1"];
|
|
11
|
-
const hosts = [];
|
|
12
|
-
for (const alias of localhostAliases) {
|
|
13
|
-
hosts.push(alias);
|
|
14
|
-
// HTTP Host header uses bracket notation for IPv6: [::1]:3000
|
|
15
|
-
if (alias.includes(":")) {
|
|
16
|
-
hosts.push(`[${alias}]`);
|
|
17
|
-
hosts.push(`[${alias}]:${port}`);
|
|
18
|
-
}
|
|
19
|
-
else {
|
|
20
|
-
hosts.push(`${alias}:${port}`);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
// Also add the configured host if it's not already covered
|
|
24
|
-
if (!localhostAliases.includes(host)) {
|
|
25
|
-
hosts.push(host);
|
|
26
|
-
if (host.includes(":") && !host.startsWith("[")) {
|
|
27
|
-
hosts.push(`[${host}]`);
|
|
28
|
-
hosts.push(`[${host}]:${port}`);
|
|
29
|
-
}
|
|
30
|
-
else {
|
|
31
|
-
hosts.push(`${host}:${port}`);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return hosts;
|
|
35
|
-
}
|
|
4
|
+
import { buildDefaultAllowedHosts, isAllInterfacesHost, } from "./allowed-hosts.js";
|
|
36
5
|
export function startStreamableHTTPServer(server) {
|
|
37
6
|
const app = express();
|
|
38
7
|
app.use(express.json());
|
|
@@ -47,12 +16,24 @@ export function startStreamableHTTPServer(server) {
|
|
|
47
16
|
? [process.env.DNS_REBINDING_ALLOWED_HOST]
|
|
48
17
|
: buildDefaultAllowedHosts(host, port);
|
|
49
18
|
// Warn when binding to all interfaces with DNS rebinding protection disabled
|
|
50
|
-
if (!enableDnsRebindingProtection && (host
|
|
19
|
+
if (!enableDnsRebindingProtection && isAllInterfacesHost(host)) {
|
|
51
20
|
console.warn("WARNING: DNS rebinding protection is disabled while HOST is set to " +
|
|
52
21
|
`'${host}'. This exposes the MCP server to DNS rebinding attacks ` +
|
|
53
22
|
"from any browser on the network. Set DNS_REBINDING_PROTECTION=true " +
|
|
54
23
|
"(the default) or restrict HOST to 'localhost' / '127.0.0.1'.");
|
|
55
24
|
}
|
|
25
|
+
// Binding to all interfaces makes the server reachable off-host, but the
|
|
26
|
+
// default allowlist only covers localhost: a client reaching it under any
|
|
27
|
+
// other name gets a 403 until the operator names that hostname explicitly.
|
|
28
|
+
if (enableDnsRebindingProtection &&
|
|
29
|
+
isAllInterfacesHost(host) &&
|
|
30
|
+
!process.env.DNS_REBINDING_ALLOWED_HOST) {
|
|
31
|
+
console.warn(`NOTE: HOST is set to '${host}' (all interfaces) and DNS rebinding ` +
|
|
32
|
+
"protection is enabled, so only requests with a localhost Host header " +
|
|
33
|
+
"are accepted. Clients reaching this server under another hostname " +
|
|
34
|
+
"(a Kubernetes Service name, an ingress host) must be allowed by " +
|
|
35
|
+
"setting DNS_REBINDING_ALLOWED_HOST to that hostname.");
|
|
36
|
+
}
|
|
56
37
|
app.post("/mcp", authMiddleware, async (req, res) => {
|
|
57
38
|
// In stateless mode, create a new instance of transport and server for each request
|
|
58
39
|
// to ensure complete isolation. A single instance would cause request ID collisions
|
|
@@ -132,8 +113,12 @@ export function startStreamableHTTPServer(server) {
|
|
|
132
113
|
});
|
|
133
114
|
}
|
|
134
115
|
});
|
|
116
|
+
// An all-interfaces bind is not a hostname a client can use: with DNS
|
|
117
|
+
// rebinding protection on it is not in the allowlist either, so advertise a
|
|
118
|
+
// localhost URL that actually works rather than 'http://0.0.0.0:<port>'.
|
|
119
|
+
const advertisedHost = isAllInterfacesHost(host) ? "localhost" : host;
|
|
135
120
|
const httpServer = app.listen(port, host, () => {
|
|
136
|
-
console.log(`mcp-kubernetes-server is listening on port ${port}\nUse the following url to connect to the server:\nhttp://${
|
|
121
|
+
console.log(`mcp-kubernetes-server is listening on port ${port}\nUse the following url to connect to the server:\nhttp://${advertisedHost}:${port}/mcp`);
|
|
137
122
|
if (isAuthEnabled()) {
|
|
138
123
|
console.log("Authentication enabled: X-MCP-AUTH header required for all MCP requests");
|
|
139
124
|
}
|