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
This commit is contained in:
Executable
+455
@@ -0,0 +1,455 @@
|
||||
#!/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"));
|
||||
Reference in New Issue
Block a user