Files
jhonny-editor/bm/premiere-pro-mcp-main/src/http-server.ts
T
João Henrique b541f502ba feat: initial commit - Jhonny Editor
- Adicionado estrutura completa do projeto
- Configurado MCP server para Premiere Pro
- Adicionado documentação e skills
- Configurado Gitignore para o projeto
2026-09-08 09:59:31 -04:00

456 lines
18 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* HTTP/SSE transport entry point for remote deployment (e.g. Fly.io).
*
* The MCP server is identical to the stdio version — only the transport differs.
* Clients connect via the MCP Streamable HTTP transport:
* POST /mcp — send JSON-RPC messages
* GET /mcp — open SSE stream
*
* The bridge still uses the local filesystem temp directory, so the CEP plugin
* must be reachable from the same machine OR you must set PREMIERE_TEMP_DIR to
* a shared volume mount that the CEP plugin also writes to.
*
* Environment variables:
* PORT HTTP port to listen on (default: 3000)
* PREMIERE_TEMP_DIR Shared temp directory for the file bridge
* PREMIERE_TIMEOUT_MS Command timeout in ms (default: 30000)
* MCP_AUTH_TOKEN Bearer token required on every /mcp request. REQUIRED — the
* server refuses to start without it, because this transport
* binds 0.0.0.0 and can drive Premiere.
* MCP_OAUTH_* Alternatively configure an OAuth issuer, JWKS URI,
* audience, public URL, and required scopes for per-user auth.
* MCP_MAX_REQUEST_BYTES, MCP_*_TIMEOUT_MS, MCP_RATE_LIMIT_*,
* MCP_MAX_CONCURRENT_REQUESTS, and MCP_MAX_CONCURRENT_STREAMS bound public
* HTTP resource use. See README.
*/
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import { randomBytes } from "node:crypto";
import { fileURLToPath } from "node:url";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler } from "@modelcontextprotocol/server";
import { createServer } from "./server.js";
import { cleanupTempDir, getTempDir } from "./bridge/file-bridge.js";
import { getTelemetry } from "./telemetry.js";
import { applyHttpSecurityHeaders } from "./http-security.js";
import { OAuthResourceServer } from "./oauth-resource-server.js";
import { ProjectContextRepository } from "./context/project-context-store.js";
import { MediaWatchRegistry } from "./tools/media-watch.js";
import {
HttpAdmissionController,
MCP_HTTP_METHODS,
exceedsRequestBodyLimit,
getRequestPathname,
isAuthorizedBearer,
isSupportedMcpMethod,
readBoundedRequestBody,
rateLimitIdentity,
readHttpAdmissionSettings,
readHttpAuthConfiguration,
RequestBodyTooLargeError,
} from "./http-admission.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LANDING_DIR = path.resolve(__dirname, "../landing-dist");
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json",
".png": "image/png",
".mp4": "video/mp4",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff2":"font/woff2",
".woff": "font/woff",
".ttf": "font/ttf",
".txt": "text/plain",
".xml": "application/xml",
};
function cacheControlForLandingAsset(urlPath: string, contentType: string): string {
if (contentType.startsWith("text/html")) return "no-cache, must-revalidate";
if (urlPath.startsWith("/_next/static/")) return "public, max-age=31536000, immutable";
return "public, max-age=86400, stale-while-revalidate=604800";
}
function injectScriptNonce(document: string, nonce: string): string {
return document.replace(/<script(?=\s|>)/gi, `<script nonce="${nonce}"`);
}
function serveLanding(req: http.IncomingMessage, res: http.ServerResponse, scriptNonce: string): boolean {
if (req.method !== "GET" && req.method !== "HEAD") return false;
if (!fs.existsSync(LANDING_DIR)) return false;
let urlPath: string;
try {
urlPath = decodeURIComponent(req.url?.split("?")[0] ?? "/");
} catch {
return false;
}
const requestedSegments = urlPath.split("/").filter(Boolean);
const safeSegments = requestedSegments.map((segment) => path.basename(segment));
if (safeSegments.some((segment, index) => (
segment !== requestedSegments[index] ||
segment === "." ||
segment === ".." ||
segment.includes("\\") ||
segment.includes("\0")
))) return false;
// Next.js trailingSlash exports /about/ as /about/index.html.
if (urlPath.endsWith("/") || safeSegments.length === 0) safeSegments.push("index.html");
let filePath = path.join(LANDING_DIR, ...safeSegments);
// Security: ensure we stay within LANDING_DIR
const relativePath = path.relative(LANDING_DIR, filePath);
if (
relativePath === ".." ||
relativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativePath)
) return false;
if (!fs.existsSync(filePath)) {
// Next static exports store dynamic-route Flight payloads in nested folders
// (for example, __next.blog/$d$slug/__PAGE__.txt), while the client asks
// for a flattened filename (__next.blog.$d$slug.__PAGE__.txt). Map only
// that generated shape after every original URL segment has passed the
// containment check above. Without this, guide-to-guide navigation falls
// back to a full-document load and emits avoidable 404s.
const requestedFlightFile = safeSegments.at(-1);
const flightParts = requestedFlightFile?.split(".") ?? [];
const isNestedFlightPayload =
flightParts.length >= 4 &&
flightParts[0] === "__next" &&
flightParts.at(-1) === "txt";
if (!isNestedFlightPayload) return false;
const remappedFlightPath = path.join(
LANDING_DIR,
...safeSegments.slice(0, -1),
`${flightParts[0]}.${flightParts[1]}`,
...flightParts.slice(2, -2),
`${flightParts.at(-2)}.txt`,
);
if (!fs.existsSync(remappedFlightPath)) return false;
filePath = remappedFlightPath;
}
let fileStats: fs.Stats;
try {
fileStats = fs.statSync(filePath);
// Accept extensionless Next.js routes such as /changelog without trying to
// stream the directory itself. Streaming a directory emits an unhandled
// EISDIR error on Linux and previously restarted the production process.
if (fileStats.isDirectory()) {
filePath = path.join(filePath, "index.html");
if (!fs.existsSync(filePath)) return false;
fileStats = fs.statSync(filePath);
}
} catch {
return false;
}
if (!fileStats.isFile()) return false;
const ext = path.extname(filePath);
const contentType = MIME[ext] ?? "application/octet-stream";
const headers = {
"Content-Type": contentType,
"Cache-Control": cacheControlForLandingAsset(urlPath, contentType),
};
// A static export cannot generate per-request nonces itself. Add the nonce
// at the trusted server boundary so Next bootstrap and JSON-LD scripts remain
// executable without retaining script-src 'unsafe-inline'.
if (contentType.startsWith("text/html")) {
try {
const document = injectScriptNonce(fs.readFileSync(filePath, "utf8"), scriptNonce);
res.writeHead(200, headers);
res.end(req.method === "HEAD" ? undefined : document);
return true;
} catch (error) {
console.error("[premiere-pro-mcp] Landing document read failed:", error);
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
res.end("Internal server error");
return true;
}
}
const stream = fs.createReadStream(filePath);
stream.once("error", (error) => {
console.error("[premiere-pro-mcp] Landing asset read failed:", error);
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Internal server error");
return;
}
res.destroy();
});
res.writeHead(200, headers);
if (req.method === "HEAD") res.end();
else stream.pipe(res);
return true;
}
const PORT = parseInt(process.env.PORT ?? "3000", 10);
let httpAuth: ReturnType<typeof readHttpAuthConfiguration>;
let admissionSettings: ReturnType<typeof readHttpAdmissionSettings>;
try {
httpAuth = readHttpAuthConfiguration(process.env);
admissionSettings = readHttpAdmissionSettings(process.env);
} catch (error) {
console.error("[premiere-pro-mcp] Refusing to start:", error instanceof Error ? error.message : error);
process.exit(1);
throw error;
}
const bridgeOptions = {
tempDir: process.env.PREMIERE_TEMP_DIR,
timeoutMs: process.env.PREMIERE_TIMEOUT_MS
? parseInt(process.env.PREMIERE_TIMEOUT_MS, 10)
: undefined,
};
process.env.PREMIERE_MCP_TRANSPORT = "http";
const telemetry = getTelemetry();
const admission = new HttpAdmissionController(admissionSettings);
const preAuthAdmission = new HttpAdmissionController(admissionSettings);
const oauthResourceServer = httpAuth.oauth ? new OAuthResourceServer(httpAuth.oauth) : undefined;
// Streamable HTTP creates an McpServer for every request. Sharing the repository
// keeps memory-backed context durable across those request-scoped servers and
// avoids repeatedly opening the same JSON or SQLite store.
const projectContextRepository = new ProjectContextRepository();
const mediaWatchRegistry = new MediaWatchRegistry();
const mcpHandler = createMcpHandler(
() => createServer(bridgeOptions, { telemetry, contextRepository: projectContextRepository, mediaWatchRegistry }),
{
onerror: (error) => console.error("[premiere-pro-mcp] MCP handler error:", error),
},
);
const handleMcpRequest = toNodeHandler(mcpHandler, {
onerror: (error) => console.error("[premiere-pro-mcp] MCP Node adapter error:", error),
});
const tempDir = getTempDir(bridgeOptions);
console.error(`[premiere-pro-mcp] Starting HTTP server on port ${PORT}...`);
console.error(`[premiere-pro-mcp] Temp directory: ${tempDir}`);
cleanupTempDir(bridgeOptions);
// Each request gets its own transport+server instance (stateless per-request model)
const httpServer = http.createServer(async (req, res) => {
const scriptNonce = randomBytes(18).toString("base64");
applyHttpSecurityHeaders(res, { scriptNonce });
const pathname = getRequestPathname(req.url);
if (!pathname) {
res.writeHead(400, { "Content-Type": "application/json", "Cache-Control": "no-store" });
res.end(JSON.stringify({ error: "Malformed request URL" }));
return;
}
// Health check
if (req.method === "GET" && pathname === "/health") {
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
res.end(JSON.stringify({ status: "ok", service: "premiere-pro-mcp" }));
return;
}
if (
req.method === "GET" &&
(pathname === "/.well-known/oauth-protected-resource" ||
pathname === "/.well-known/oauth-protected-resource/mcp") &&
oauthResourceServer
) {
res.writeHead(200, {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=300",
});
res.end(JSON.stringify(oauthResourceServer.metadata()));
return;
}
// Only handle /mcp endpoint; everything else goes to the landing page
if (pathname !== "/mcp") {
if (serveLanding(req, res, scriptNonce)) return;
res.writeHead(404);
res.end("Not found");
return;
}
if (!isSupportedMcpMethod(req.method)) {
res.writeHead(405, { "Allow": MCP_HTTP_METHODS.join(", "), "Content-Type": "application/json", "Cache-Control": "no-store" });
res.end(JSON.stringify({ error: "Method not allowed" }));
return;
}
if (exceedsRequestBodyLimit(req, admissionSettings.maxRequestBytes)) {
telemetry.capture("mcp_request_rejected", { outcome: "request_too_large", status_code: 413 });
res.writeHead(413, { "Content-Type": "application/json", "Cache-Control": "no-store" });
res.end(JSON.stringify({ error: "Request body too large" }));
return;
}
// OAuth access tokens are verified for issuer, audience, lifetime, signature,
// subject, and scope. The legacy shared token remains available for controlled
// single-operator deployments and is compared in constant time.
// Apply an IP-keyed gate first so untrusted JWT/JWKS work cannot bypass the
// same concurrency and rate bounds that protect authenticated requests.
const preAuthDecision = preAuthAdmission.acquire(rateLimitIdentity(req, admissionSettings.trustProxy));
if (!preAuthDecision.accepted) {
telemetry.capture("mcp_request_rejected", {
outcome: preAuthDecision.reason,
status_code: preAuthDecision.statusCode,
phase: "pre_auth",
});
res.writeHead(preAuthDecision.statusCode, {
"Content-Type": "application/json",
"Cache-Control": "no-store",
"Retry-After": String(preAuthDecision.retryAfterSeconds),
});
res.end(JSON.stringify({ error: preAuthDecision.reason === "rate_limited" ? "Too many requests" : "Service busy" }));
return;
}
let oauthAuthentication: Awaited<ReturnType<OAuthResourceServer["authenticate"]>> | undefined;
try {
oauthAuthentication = oauthResourceServer
? await oauthResourceServer.authenticate(req)
: undefined;
} finally {
preAuthDecision.release();
}
const isAuthorized = oauthAuthentication
? oauthAuthentication.authenticated
: isAuthorizedBearer(req, httpAuth.authToken);
if (!isAuthorized) {
telemetry.capture("mcp_connection_attempt", {
outcome: "unauthorized",
method: req.method ?? "unknown",
});
const challenge = oauthResourceServer
? oauthResourceServer.challenge(oauthAuthentication?.authenticated === false ? oauthAuthentication.error : undefined)
: "Bearer";
const statusCode = oauthAuthentication?.authenticated === false && oauthAuthentication.error === "insufficient_scope"
? 403
: 401;
res.writeHead(statusCode, {
"Content-Type": "application/json",
"Cache-Control": "no-store",
"WWW-Authenticate": challenge,
});
res.end(JSON.stringify({ error: statusCode === 403 ? "Forbidden" : "Unauthorized" }));
return;
}
const authenticatedIdentity = oauthAuthentication?.authenticated
? `oauth:${oauthAuthentication.principal.rateLimitKey}`
: "credential:shared-operator";
const admissionDecision = admission.acquire(
authenticatedIdentity,
req.method === "GET" ? "stream" : "operation",
);
if (!admissionDecision.accepted) {
telemetry.capture("mcp_request_rejected", {
outcome: admissionDecision.reason,
status_code: admissionDecision.statusCode,
});
res.writeHead(admissionDecision.statusCode, {
"Content-Type": "application/json",
"Cache-Control": "no-store",
"Retry-After": String(admissionDecision.retryAfterSeconds),
});
res.end(JSON.stringify({ error: admissionDecision.reason === "rate_limited" ? "Too many requests" : "Service busy" }));
return;
}
let parsedBody: unknown;
if (req.method !== "GET") {
try {
const body = await readBoundedRequestBody(req, admissionSettings.maxRequestBytes);
// Passing an already-parsed body prevents the transport from reading the
// Node stream a second time. `null` represents a deliberately empty POST.
parsedBody = body.length === 0 ? null : JSON.parse(body.toString("utf8"));
} catch (error) {
admissionDecision.release();
if (error instanceof RequestBodyTooLargeError) {
telemetry.capture("mcp_request_rejected", { outcome: "request_too_large", status_code: 413 });
res.writeHead(413, { "Content-Type": "application/json", "Cache-Control": "no-store", Connection: "close" });
res.end(JSON.stringify({ error: "Request body too large" }));
return;
}
telemetry.capture("mcp_request_rejected", { outcome: "invalid_json", status_code: 400 });
res.writeHead(400, { "Content-Type": "application/json", "Cache-Control": "no-store" });
res.end(JSON.stringify({
jsonrpc: "2.0",
error: { code: -32700, message: "Parse error: Invalid JSON-RPC message" },
id: null,
}));
return;
}
}
const requestStartedAt = Date.now();
telemetry.capture("mcp_connection_attempt", {
outcome: "authorized",
method: req.method ?? "unknown",
});
res.on("close", () => {
admissionDecision.release();
});
try {
await handleMcpRequest(req, res, parsedBody);
telemetry.capture("mcp_request", {
outcome: res.statusCode >= 400 ? "failed" : "succeeded",
method: req.method ?? "unknown",
status_code: res.statusCode,
duration_ms: Date.now() - requestStartedAt,
});
} catch (err) {
telemetry.capture("mcp_request", {
outcome: "failed",
method: req.method ?? "unknown",
status_code: 500,
duration_ms: Date.now() - requestStartedAt,
error_type: err instanceof Error ? err.name : "UnknownError",
});
console.error("[premiere-pro-mcp] Request error:", err);
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Internal server error" }));
}
}
});
httpServer.headersTimeout = admissionSettings.headersTimeoutMs;
httpServer.requestTimeout = admissionSettings.requestTimeoutMs;
httpServer.keepAliveTimeout = admissionSettings.keepAliveTimeoutMs;
httpServer.maxRequestsPerSocket = admissionSettings.maxRequestsPerSocket;
httpServer.listen(PORT, "0.0.0.0", () => {
console.error(`[premiere-pro-mcp] HTTP server listening on 0.0.0.0:${PORT}`);
console.error(`[premiere-pro-mcp] MCP endpoint: http://0.0.0.0:${PORT}/mcp`);
if (oauthResourceServer) {
console.error(`[premiere-pro-mcp] Auth: OAuth bearer tokens required`);
} else if (httpAuth.authToken) {
console.error(`[premiere-pro-mcp] Auth: Bearer token required`);
} else {
console.error(`[premiere-pro-mcp] Auth: disabled outside production for an explicit local/test override`);
}
});
async function shutdown(signal: string) {
console.error(`[premiere-pro-mcp] ${signal} received, shutting down...`);
httpServer.close();
await mcpHandler.close();
await projectContextRepository.close();
await telemetry.shutdown();
process.exit(0);
}
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));