Skip to content

Commit 84e1ce7

Browse files
mdrokon-devclaude
andcommitted
fix(docker): restore start.sh, oauth-proxy.js and Node.js in Dockerfile
Upstream's Dockerfile is Python-only with CMD python -m meta_ads_mcp (stdio transport — exits immediately). Our deployment needs: - Node.js 22 installed alongside Python - start.sh: launches Python MCP on port 8081 (streamable-http) then the Node OAuth proxy on port 8080 - oauth-proxy.js: handles OAuth2 + /health + proxies /mcp to Python Without these the container exits with code 0 and restart-loops. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6fb9f22 commit 84e1ce7

3 files changed

Lines changed: 170 additions & 15 deletions

File tree

‎Dockerfile‎

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,20 @@
11
FROM python:3.11-slim
22

3-
# Install system dependencies
43
RUN apt-get update && \
5-
apt-get install -y --no-install-recommends gcc && \
4+
apt-get install -y --no-install-recommends gcc curl && \
5+
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
6+
apt-get install -y nodejs && \
67
rm -rf /var/lib/apt/lists/*
78

8-
# Set working directory
99
WORKDIR /app
10-
11-
# Install uv
12-
RUN pip install --upgrade pip && \
13-
pip install uv
14-
15-
# Copy requirements file
10+
RUN pip install --upgrade pip && pip install uv
1611
COPY requirements.txt .
17-
18-
# Install dependencies using uv with --system flag
1912
RUN uv pip install --system -r requirements.txt
20-
21-
# Copy the rest of the application
2213
COPY . .
2314

24-
# Command to run the Meta Ads MCP server
25-
CMD ["python", "-m", "meta_ads_mcp"]
15+
ENV PORT=8080
16+
ENV BACKEND_PORT=8081
17+
EXPOSE 8080
18+
19+
RUN chmod +x start.sh
20+
CMD ["./start.sh"]

‎oauth-proxy.js‎

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
const http = require("http");
2+
const { createHash, randomUUID } = require("crypto");
3+
const { URL } = require("url");
4+
5+
const PORT = parseInt(process.env.PORT || "8080", 10);
6+
const BACKEND_PORT = parseInt(process.env.BACKEND_PORT || "8081", 10);
7+
const AUTH_TOKEN = (process.env.MCP_AUTH_TOKEN || "").trim();
8+
const OAUTH_CLIENT_ID = (process.env.OAUTH_CLIENT_ID || "").trim();
9+
const OAUTH_CLIENT_SECRET = (process.env.OAUTH_CLIENT_SECRET || "").trim();
10+
11+
const authCodes = {};
12+
13+
function parseBody(req) {
14+
return new Promise((resolve) => {
15+
const chunks = [];
16+
req.on("data", (c) => chunks.push(c));
17+
req.on("end", () => {
18+
const raw = Buffer.concat(chunks).toString();
19+
const ct = req.headers["content-type"] || "";
20+
if (ct.includes("application/json")) {
21+
try { resolve(JSON.parse(raw)); } catch { resolve(raw); }
22+
} else if (ct.includes("urlencoded")) {
23+
resolve(Object.fromEntries(new URLSearchParams(raw)));
24+
} else { resolve(raw); }
25+
});
26+
});
27+
}
28+
29+
function sendJson(res, status, obj) {
30+
const body = JSON.stringify(obj);
31+
res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) });
32+
res.end(body);
33+
}
34+
35+
// Transparent byte-pipe to the internal MCP server. No session state held
36+
// in this layer. If the backend returns 404 for an unknown session id we
37+
// propagate it as-is so the client can reinitialize cleanly per MCP spec.
38+
// Do NOT silently remap stale ids to new sessions — see
39+
// PM-Labs/mcp-playwright@1d75780 for root cause analysis.
40+
function proxy(req, res, bodyBuf) {
41+
// Strip the public-facing Authorization (Bearer is consumed by this proxy,
42+
// not the upstream MCP) and force the host header to localhost so upstream
43+
// host checks accept the proxied request.
44+
const headers = { ...req.headers, host: "localhost:" + BACKEND_PORT };
45+
delete headers["authorization"];
46+
delete headers["content-length"];
47+
if (bodyBuf) headers["content-length"] = bodyBuf.length;
48+
const proxyReq = http.request(
49+
{ hostname: "127.0.0.1", port: BACKEND_PORT, path: req.url, method: req.method, headers },
50+
(proxyRes) => {
51+
res.writeHead(proxyRes.statusCode, proxyRes.headers);
52+
proxyRes.pipe(res);
53+
}
54+
);
55+
proxyReq.on("error", (e) => { console.error("[PROXY] Backend error:", e.message); sendJson(res, 502, { error: "backend_unavailable" }); });
56+
if (bodyBuf) proxyReq.write(bodyBuf);
57+
proxyReq.end();
58+
}
59+
60+
const server = http.createServer(async (req, res) => {
61+
const url = new URL(req.url, "http://localhost:" + PORT);
62+
const path = url.pathname;
63+
if (path === "/health") return sendJson(res, 200, { status: "ok" });
64+
if (path === "/.well-known/oauth-protected-resource") {
65+
const base = "https://" + req.headers.host;
66+
return sendJson(res, 200, { resource: base + "/mcp", authorization_servers: [base] });
67+
}
68+
if (path === "/.well-known/oauth-authorization-server") {
69+
const base = "https://" + req.headers.host;
70+
return sendJson(res, 200, { issuer: base, authorization_endpoint: base + "/authorize", token_endpoint: base + "/oauth/token", grant_types_supported: ["authorization_code", "client_credentials"], code_challenge_methods_supported: ["S256"], response_types_supported: ["code"] });
71+
}
72+
if (path === "/authorize" && req.method === "GET") {
73+
const p = url.searchParams;
74+
if (p.get("client_id") !== OAUTH_CLIENT_ID) return sendJson(res, 401, { error: "invalid_client" });
75+
if (p.get("response_type") !== "code") return sendJson(res, 400, { error: "unsupported_response_type" });
76+
if (!p.get("code_challenge")) return sendJson(res, 400, { error: "code_challenge required" });
77+
const code = randomUUID();
78+
authCodes[code] = { codeChallenge: p.get("code_challenge"), codeChallengeMethod: p.get("code_challenge_method") || "S256", redirectUri: p.get("redirect_uri"), expiresAt: Date.now() + 5 * 60 * 1000 };
79+
const redir = new URL(p.get("redirect_uri"));
80+
redir.searchParams.set("code", code);
81+
if (p.get("state")) redir.searchParams.set("state", p.get("state"));
82+
res.writeHead(302, { Location: redir.toString() });
83+
return res.end();
84+
}
85+
if (path === "/oauth/token" && req.method === "POST") {
86+
const body = await parseBody(req);
87+
if (body.grant_type === "authorization_code") {
88+
const stored = authCodes[body.code];
89+
if (!stored || stored.expiresAt < Date.now()) return sendJson(res, 400, { error: "invalid_grant" });
90+
const expected = createHash("sha256").update(body.code_verifier).digest("base64url");
91+
if (expected !== stored.codeChallenge) return sendJson(res, 400, { error: "invalid_grant" });
92+
if (body.redirect_uri && body.redirect_uri !== stored.redirectUri) return sendJson(res, 400, { error: "invalid_grant" });
93+
delete authCodes[body.code];
94+
return sendJson(res, 200, { access_token: AUTH_TOKEN, token_type: "Bearer", expires_in: 86400 });
95+
}
96+
let cid, csec;
97+
const ba = req.headers["authorization"];
98+
if (ba && ba.startsWith("Basic ")) {
99+
const decoded = Buffer.from(ba.slice(6), "base64").toString();
100+
const colon = decoded.indexOf(":");
101+
cid = decoded.slice(0, colon); csec = decoded.slice(colon + 1);
102+
} else { cid = body.client_id; csec = body.client_secret; }
103+
if (cid !== OAUTH_CLIENT_ID || csec !== OAUTH_CLIENT_SECRET) return sendJson(res, 401, { error: "invalid_client" });
104+
return sendJson(res, 200, { access_token: AUTH_TOKEN, token_type: "Bearer", expires_in: 86400 });
105+
}
106+
// Proxy Meta OAuth callback to Python callback server (port 8082)
107+
const CALLBACK_PORT = parseInt(process.env.CALLBACK_PORT || "8082", 10);
108+
if (path === "/callback" || path === "/token") {
109+
const cHeaders = { ...req.headers, host: "localhost:" + CALLBACK_PORT };
110+
const chunks = [];
111+
req.on("data", (c) => chunks.push(c));
112+
req.on("end", () => {
113+
const bodyBuf = Buffer.concat(chunks);
114+
delete cHeaders["content-length"];
115+
if (bodyBuf.length) cHeaders["content-length"] = bodyBuf.length;
116+
const cReq = http.request({ hostname: "127.0.0.1", port: CALLBACK_PORT, path: req.url, method: req.method, headers: cHeaders }, (cRes) => {
117+
res.writeHead(cRes.statusCode, cRes.headers);
118+
cRes.pipe(res);
119+
});
120+
cReq.on("error", (e) => { console.error("[CALLBACK] Error:", e.message); sendJson(res, 502, { error: "callback_unavailable" }); });
121+
if (bodyBuf.length) cReq.write(bodyBuf);
122+
cReq.end();
123+
});
124+
return;
125+
}
126+
if (path === "/mcp" || path.startsWith("/mcp/")) {
127+
if (AUTH_TOKEN) {
128+
const ah = req.headers["authorization"];
129+
if (!ah || !ah.startsWith("Bearer ")) {
130+
res.writeHead(401, { "WWW-Authenticate": "Bearer resource_metadata=\"https://" + req.headers.host + "/.well-known/oauth-protected-resource\"", "Content-Type": "application/json" });
131+
return res.end(JSON.stringify({ error: "Unauthorized" }));
132+
}
133+
if (ah.slice(7) !== AUTH_TOKEN) {
134+
res.writeHead(401, { "WWW-Authenticate": "Bearer error=\"invalid_token\"", "Content-Type": "application/json" });
135+
return res.end(JSON.stringify({ error: "Unauthorized" }));
136+
}
137+
}
138+
const chunks = [];
139+
req.on("data", (c) => chunks.push(c));
140+
req.on("end", () => proxy(req, res, Buffer.concat(chunks)));
141+
return;
142+
}
143+
sendJson(res, 404, { error: "not_found" });
144+
});
145+
146+
server.listen(PORT, "0.0.0.0", () => { console.log("OAuth proxy listening on :" + PORT + ", backend on :" + BACKEND_PORT); });

‎start.sh‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/bin/bash
2+
set -e
3+
echo "Starting Meta Ads MCP (Python) on port ${BACKEND_PORT:-8081}..."
4+
python -m meta_ads_mcp --transport streamable-http --host 0.0.0.0 --port "${BACKEND_PORT:-8081}" &
5+
PYTHON_PID=$!
6+
for i in $(seq 1 30); do
7+
if curl -sf "http://localhost:${BACKEND_PORT:-8081}/mcp" > /dev/null 2>&1; then
8+
echo "Python MCP server ready"
9+
break
10+
fi
11+
sleep 1
12+
done
13+
echo "Starting OAuth proxy on port ${PORT:-8080}..."
14+
exec node oauth-proxy.js

0 commit comments

Comments
 (0)