diff --git a/DOCKER.md b/DOCKER.md index 351c0b5..cbeb816 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -33,21 +33,23 @@ The MCP server requires Cloudinary credentials to run. You can provide these cre This is the recommended method. ```sh -docker run -d -p 2718:2718 \ +docker run -d -p 127.0.0.1:2718:2718 \ -e CLOUDINARY_CLOUD_NAME="" \ -e CLOUDINARY_API_KEY="" \ -e CLOUDINARY_API_SECRET="" \ - cloudinary-analysis-mcp start --transport sse + cloudinary-analysis-mcp start --transport sse --host 0.0.0.0 ``` -**Note:** If you have these variables already set in your shell environment, you can pass them directly to the container without specifying the values: +**Note:** The `--host 0.0.0.0` flag is required inside Docker containers for port mapping to work. The `-p 127.0.0.1:2718:2718` ensures the port is only exposed on your machine's localhost, not to the network. + +If you have these variables already set in your shell environment, you can pass them directly to the container without specifying the values: ```sh -docker run -d -p 2718:2718 \ +docker run -d -p 127.0.0.1:2718:2718 \ -e CLOUDINARY_CLOUD_NAME \ -e CLOUDINARY_API_KEY \ -e CLOUDINARY_API_SECRET \ - cloudinary-analysis-mcp start --transport sse + cloudinary-analysis-mcp start --transport sse --host 0.0.0.0 ``` ### Option 2: Using Command-Line Arguments @@ -55,8 +57,8 @@ docker run -d -p 2718:2718 \ You can also provide the credentials as arguments to the `start` command. ```sh -docker run -d -p 2718:2718 \ - cloudinary-analysis-mcp start --transport sse \ +docker run -d -p 127.0.0.1:2718:2718 \ + cloudinary-analysis-mcp start --transport sse --host 0.0.0.0 \ --cloud-name "" \ --api-key "" \ --api-secret "" @@ -67,15 +69,15 @@ docker run -d -p 2718:2718 \ This method combines all credentials into a single URL. ```sh -docker run -d -p 2718:2718 \ +docker run -d -p 127.0.0.1:2718:2718 \ -e CLOUDINARY_URL="cloudinary://:@" \ - cloudinary-analysis-mcp start --transport sse + cloudinary-analysis-mcp start --transport sse --host 0.0.0.0 ``` **Note:** If you have the `CLOUDINARY_URL` variable already set in your shell environment, you can pass it directly: ```sh -docker run -d -p 2718:2718 -e CLOUDINARY_URL cloudinary-analysis-mcp start --transport sse +docker run -d -p 127.0.0.1:2718:2718 -e CLOUDINARY_URL cloudinary-analysis-mcp start --transport sse --host 0.0.0.0 ``` ## Connecting to the Server @@ -134,7 +136,7 @@ Set the `--log-level` flag to `debug` when starting the container. ```sh docker run -d -p 2718:2718 \ -e CLOUDINARY_URL \ - cloudinary-analysis-mcp start --transport sse --log-level debug + cloudinary-analysis-mcp start --transport sse --host 0.0.0.0 --log-level debug ``` ### Using the `CLOUDINARY_DEBUG` Environment Variable @@ -145,6 +147,15 @@ You can also enable a debug logger by setting the `CLOUDINARY_DEBUG` environment docker run -d -p 2718:2718 \ -e CLOUDINARY_URL \ -e CLOUDINARY_DEBUG=true \ - cloudinary-analysis-mcp start --transport sse + cloudinary-analysis-mcp start --transport sse --host 0.0.0.0 ``` +## Security Considerations + +The MCP server handles your Cloudinary API credentials. To prevent unauthorized access: + +- **Localhost binding (default):** When running outside Docker, the server binds to `127.0.0.1` by default, making it accessible only from your local machine. +- **Docker port mapping:** Use `-p 127.0.0.1:2718:2718` (not `-p 2718:2718`) to restrict Docker port exposure to localhost only. The broader form exposes the port to your entire network. +- **`--host 0.0.0.0`:** Only use this inside Docker containers (where it's required for port mapping) or when you explicitly need network access. Never use this on a host machine without additional access controls. +- **`--allowed-origins`:** When using the `serve` command with cross-origin clients, specify allowed origins explicitly (e.g., `--allowed-origins http://localhost:3000`) instead of allowing all origins. + diff --git a/src/mcp-server/cli/serve/command.ts b/src/mcp-server/cli/serve/command.ts index 3024572..552293e 100644 --- a/src/mcp-server/cli/serve/command.ts +++ b/src/mcp-server/cli/serve/command.ts @@ -22,6 +22,21 @@ export const serveCommand = buildCommand({ parse: (val: string) => z.coerce.number().int().gte(0).lt(65536).parse(val), }, + host: { + kind: "parsed", + brief: + "The host address to bind to (default: 127.0.0.1). Use 0.0.0.0 to listen on all interfaces (SECURITY: exposes server to the network)", + default: "127.0.0.1", + parse: (value) => z.string().parse(value), + }, + "allowed-origins": { + kind: "parsed", + brief: + "Allowed CORS origins (comma-separated). Only requests from these origins will receive CORS headers. Required when accessed cross-origin.", + optional: true, + variadic: true, + parse: (value) => z.string().url("Must be a valid origin URL").parse(value), + }, "disable-static-auth": { kind: "boolean", brief: diff --git a/src/mcp-server/cli/serve/impl.ts b/src/mcp-server/cli/serve/impl.ts index f04bfff..7c4b261 100644 --- a/src/mcp-server/cli/serve/impl.ts +++ b/src/mcp-server/cli/serve/impl.ts @@ -20,7 +20,9 @@ import { landingPageExpress } from "../../../landing-page.js"; interface ServeCommandFlags extends MCPServerFlags { readonly port: number; + readonly host: string; readonly "disable-static-auth": boolean; + readonly "allowed-origins"?: string[]; readonly "log-level": ConsoleLoggerLevel; readonly env?: [string, string][]; } @@ -37,11 +39,29 @@ async function startStreamableHTTP(cliFlags: ServeCommandFlags) { const logger = createConsoleLogger(cliFlags["log-level"]); const app = express(); - // Enable CORS for cross-origin requests + const allowedOrigins = cliFlags["allowed-origins"]; + + // Origin validation middleware (replaces wildcard CORS) app.use((req, res, next) => { - res.header("Access-Control-Allow-Origin", "*"); - res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - res.header("Access-Control-Allow-Headers", "*"); + const origin = req.headers.origin; + + if (allowedOrigins && allowedOrigins.length > 0 && origin) { + if (allowedOrigins.includes(origin)) { + res.header("Access-Control-Allow-Origin", origin); + res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.header("Access-Control-Allow-Headers", "Content-Type, Accept, Mcp-Session-Id, Last-Event-Id, api-key, api-secret, cloud-name, o-auth2"); + res.header("Vary", "Origin"); + } else { + // Origin not allowed — reject preflight, omit CORS headers on actual requests + if (req.method === "OPTIONS") { + res.sendStatus(403); + return; + } + } + } + // When no allowed-origins configured and binding to localhost, no CORS headers + // are needed (same-origin by default). This is the secure default. + if (req.method === "OPTIONS") { res.sendStatus(204); return; @@ -88,10 +108,16 @@ async function startStreamableHTTP(cliFlags: ServeCommandFlags) { app.get("/", landingPageExpress); - const httpServer = app.listen(cliFlags.port, "0.0.0.0", () => { + const httpServer = app.listen(cliFlags.port, cliFlags.host, () => { const ha = httpServer.address(); const host = typeof ha === "string" ? ha : `${ha?.address}:${ha?.port}`; logger.info("MCP Streamable HTTP server started", { host }); + if (cliFlags.host === "0.0.0.0") { + logger.warning( + "Server is listening on all interfaces (0.0.0.0). " + + "This exposes the server to the network. Use --host 127.0.0.1 for localhost-only access." + ); + } }); const shutdown = () => { diff --git a/src/mcp-server/cli/start/command.ts b/src/mcp-server/cli/start/command.ts index 7cc7915..aa77686 100644 --- a/src/mcp-server/cli/start/command.ts +++ b/src/mcp-server/cli/start/command.ts @@ -28,6 +28,13 @@ export const startCommand = buildCommand({ parse: (val: string) => z.coerce.number().int().gte(0).lt(65536).parse(val), }, + host: { + kind: "parsed", + brief: + "The host address to bind to (default: 127.0.0.1). Use 0.0.0.0 to listen on all interfaces (SECURITY: exposes server to the network)", + default: "127.0.0.1", + parse: (value) => z.string().parse(value), + }, tool: { kind: "parsed", brief: "Specify tools to mount on the server", diff --git a/src/mcp-server/cli/start/impl.ts b/src/mcp-server/cli/start/impl.ts index 243ee0d..2b4770f 100644 --- a/src/mcp-server/cli/start/impl.ts +++ b/src/mcp-server/cli/start/impl.ts @@ -20,6 +20,7 @@ import { landingPageExpress } from "../../../landing-page.js"; interface StartCommandFlags extends MCPServerFlags { readonly transport: "stdio" | "sse"; readonly port: number; + readonly host: string; readonly "log-level": ConsoleLoggerLevel; readonly env?: [string, string][]; } @@ -191,10 +192,16 @@ async function startSSE(cliFlags: StartCommandFlags) { app.get("/", landingPageExpress); - const httpServer = app.listen(cliFlags.port, "0.0.0.0", () => { + const httpServer = app.listen(cliFlags.port, cliFlags.host, () => { const ha = httpServer.address(); const host = typeof ha === "string" ? ha : `${ha?.address}:${ha?.port}`; logger.info("MCP HTTP server started", { host }); + if (cliFlags.host === "0.0.0.0") { + logger.warning( + "Server is listening on all interfaces (0.0.0.0). " + + "This exposes the server to the network. Use --host 127.0.0.1 for localhost-only access." + ); + } }); let closing = false;