premiere-pro-mcp 1.12.0 → 1.12.2
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/CHANGELOG.md +20 -0
- package/README.md +41 -23
- package/artifacts/MCPBridgeCEP.zxp +0 -0
- package/cep-plugin/CSXS/manifest.xml +4 -4
- package/cep-plugin/index.html +4 -4
- package/cep-plugin/main.js +1 -1
- package/cep-plugin/updater.cjs +1 -1
- package/dist/bridge/script-builder.js +44 -0
- package/dist/http-admission.d.ts +79 -0
- package/dist/http-admission.js +231 -0
- package/dist/http-security.d.ts +5 -1
- package/dist/http-security.js +19 -7
- package/dist/http-server.d.ts +3 -2
- package/dist/http-server.js +129 -39
- package/dist/tools/effects.js +21 -7
- package/dist/tools/keyframes.d.ts +3 -2
- package/dist/tools/keyframes.js +26 -4
- package/package.json +6 -4
- package/scripts/build-signed-cep.ps1 +1 -1
- package/scripts/install-cep.ps1 +2 -2
- package/scripts/install-cep.sh +3 -3
- package/scripts/install-chat-plugin.sh +1 -1
- package/scripts/validate-adobe-marketplace-branding.mjs +72 -0
- package/scripts/validate-distribution.mjs +1 -1
- package/scripts/validate-licensed-host-report.mjs +54 -0
- package/uxp-plugin/README.md +2 -2
- package/uxp-plugin/index.html +1 -1
- package/uxp-plugin/manifest.json +3 -3
- package/uxp-plugin/workspace.cjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.12.2] - 2026-08-22
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- `set_effect_property` now accepts safely serialized string values as well as
|
|
14
|
+
numbers, unlocking MOGRT and graphic parameters that Premiere exposes as
|
|
15
|
+
JSON strings. Responses report parameter readback separately from render
|
|
16
|
+
verification.
|
|
17
|
+
- An empty legacy QE effect catalog now returns a clear no-mutation capability
|
|
18
|
+
response rather than incorrectly reporting a requested effect as missing.
|
|
19
|
+
When connected, the documented UXP effect catalog and transaction workflow is
|
|
20
|
+
the supported alternative.
|
|
21
|
+
|
|
22
|
+
## [1.12.1] - 2026-08-22
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Allowed Google Analytics collection requests to `www.google.com` in the
|
|
27
|
+
restrictive Content Security Policy, matching the current Google tag client.
|
|
28
|
+
|
|
9
29
|
## [1.12.0] - 2026-08-22
|
|
10
30
|
|
|
11
31
|
### Added
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
# Premiere Pro
|
|
3
|
+
# MCP for Adobe Premiere Pro
|
|
4
4
|
|
|
5
5
|
[](https://mcptoplist.com/server/glama%2Fleancoderkavy%2Fpremiere-pro-mcp)
|
|
6
6
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
---
|
|
21
21
|
|
|
22
|
-

|
|
23
23
|
|
|
24
24
|
## What is this?
|
|
25
25
|
|
|
@@ -31,16 +31,17 @@ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that l
|
|
|
31
31
|
|
|
32
32
|
The AI handles the entire workflow through 287 core tools spanning the supported ExtendScript, QE DOM, revisioned project-context retrieval, safe edit-planning, and connection-verification surfaces. A compatible, authenticated UXP panel adds 50 documented, capability-gated tools without replacing the production CEP bridge.
|
|
33
33
|
|
|
34
|
-
### Latest release: 1.12.
|
|
34
|
+
### Latest release: 1.12.2
|
|
35
35
|
|
|
36
|
-
- **
|
|
37
|
-
|
|
38
|
-
- **
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
- **MOGRT text and graphics:** `set_effect_property` now accepts string-backed
|
|
37
|
+
parameters with safe serialization and readback status.
|
|
38
|
+
- **Effect discovery clarity:** an empty legacy QE effect catalog now returns a
|
|
39
|
+
no-mutation capability response and directs connected hosts to the documented
|
|
40
|
+
UXP catalog/add workflow.
|
|
41
|
+
- **Verification clarity:** property readback remains distinct from playback or
|
|
42
|
+
exported-frame verification in a licensed Premiere Pro host.
|
|
42
43
|
|
|
43
|
-
See the [v1.12.
|
|
44
|
+
See the [v1.12.2 release notes](https://github.com/leancoderkavy/premiere-pro-mcp/releases/tag/v1.12.2)
|
|
44
45
|
for complete details. Live installation in Premiere Pro still requires host verification.
|
|
45
46
|
|
|
46
47
|
---
|
|
@@ -49,10 +50,10 @@ for complete details. Live installation in Premiere Pro still requires host veri
|
|
|
49
50
|
|
|
50
51
|
### Easiest supported path: Claude Desktop
|
|
51
52
|
|
|
52
|
-
1. Download the current [Claude Desktop bundle (`.mcpb`)](https://github.com/leancoderkavy/premiere-pro-mcp/releases/download/v1.12.
|
|
53
|
+
1. Download the current [Claude Desktop bundle (`.mcpb`)](https://github.com/leancoderkavy/premiere-pro-mcp/releases/download/v1.12.2/premiere-pro-mcp-1.12.2.mcpb).
|
|
53
54
|
2. In Claude Desktop, open **Settings > Extensions > Advanced settings > Install Extension**, select the downloaded bundle, and restart Claude Desktop.
|
|
54
|
-
3. Download the separate [signed Premiere connector (`.zxp`)](https://github.com/leancoderkavy/premiere-pro-mcp/releases/download/v1.12.
|
|
55
|
-
4. Restart Premiere, open a project, then open **Window > Extensions > MCP
|
|
55
|
+
3. Download the separate [signed Premiere connector (`.zxp`)](https://github.com/leancoderkavy/premiere-pro-mcp/releases/download/v1.12.2/MCPBridgeCEP.zxp). Open it with your trusted ZXP installer. If your computer has no ZXP installer, use the npm connector installer in **Advanced setup** below.
|
|
56
|
+
4. Restart Premiere, open a project, then open **Window > Extensions > MCP for Adobe Premiere Pro**.
|
|
56
57
|
5. In Claude, enter: `Safely check my Premiere connection with verify_premiere_connection. Make no changes.`
|
|
57
58
|
|
|
58
59
|
The Claude bundle contains the local MCP server, so this route does not require Node.js. The Premiere connector is a separate required install. The first prompt is read-only and reports whether the server is installed, configured, connected, and live-verified.
|
|
@@ -259,7 +260,7 @@ Add to your VS Code MCP server configuration:
|
|
|
259
260
|
|
|
260
261
|
1. Open (or restart) Premiere Pro
|
|
261
262
|
2. The bridge starts automatically using the default temp directory (or its previously saved setting)
|
|
262
|
-
3. Optionally go to **Window > Extensions > MCP
|
|
263
|
+
3. Optionally go to **Window > Extensions > MCP for Adobe Premiere Pro** to confirm the green "Running" status or change the **Temp Directory** to match your MCP client config
|
|
263
264
|
4. Ask your AI assistant to run `get_capabilities`, then `ping`, with Premiere open.
|
|
264
265
|
5. For a safe first request, ask: *"What is my current Premiere Pro project and active sequence? Do not make changes."*
|
|
265
266
|
|
|
@@ -275,11 +276,11 @@ From a clone of this repository:
|
|
|
275
276
|
```bash
|
|
276
277
|
codex plugin marketplace add .
|
|
277
278
|
codex plugin add premiere-pro@premiere-pro-mcp
|
|
278
|
-
npx -y premiere-pro-mcp@1.12.
|
|
279
|
+
npx -y premiere-pro-mcp@1.12.2 --install-cep
|
|
279
280
|
```
|
|
280
281
|
|
|
281
282
|
Restart Premiere Pro and start a new Codex session after installation. The plugin
|
|
282
|
-
launches `premiere-pro-mcp@1.12.
|
|
283
|
+
launches `premiere-pro-mcp@1.12.2` through `npx`; the separate CEP installation is
|
|
283
284
|
required because the MCP server communicates with the running Premiere host through
|
|
284
285
|
the local bridge.
|
|
285
286
|
|
|
@@ -299,7 +300,7 @@ For Claude Code, add this repository as a marketplace and install the plugin:
|
|
|
299
300
|
Then install the Premiere bridge and start a new Claude Code session:
|
|
300
301
|
|
|
301
302
|
```bash
|
|
302
|
-
npx -y premiere-pro-mcp@1.12.
|
|
303
|
+
npx -y premiere-pro-mcp@1.12.2 --install-cep
|
|
303
304
|
```
|
|
304
305
|
|
|
305
306
|
The Claude Code package lives in
|
|
@@ -453,7 +454,7 @@ argument, undo, confirmation, and live-host boundaries.
|
|
|
453
454
|
|
|
454
455
|
## Architecture
|
|
455
456
|
|
|
456
|
-

|
|
457
458
|
|
|
458
459
|
**Local (stdio):**
|
|
459
460
|
|
|
@@ -731,7 +732,7 @@ A live instance is running at **https://premiere-pro-mcp.fly.dev**.
|
|
|
731
732
|
git clone https://github.com/leancoderkavy/premiere-pro-mcp.git
|
|
732
733
|
cd premiere-pro-mcp
|
|
733
734
|
fly apps create your-app-name
|
|
734
|
-
# Required: add bearer token auth
|
|
735
|
+
# Required: add bearer token auth. Use a unique, high-entropy secret per deployment.
|
|
735
736
|
fly secrets set MCP_AUTH_TOKEN=your-secret-token
|
|
736
737
|
fly deploy --remote-only
|
|
737
738
|
```
|
|
@@ -752,6 +753,7 @@ Then connect with:
|
|
|
752
753
|
|
|
753
754
|
> **Note:** The file bridge still requires the CEP plugin to share the same `PREMIERE_TEMP_DIR`. For cloud deployments this means running a sync agent or using `fly proxy` / WireGuard to reach your local machine.
|
|
754
755
|
> `detect_silence` can analyze only media paths available inside the server filesystem; a desktop-only path is not automatically available to a remote Fly machine.
|
|
756
|
+
> For a shared or multi-user remote deployment, put a managed identity-aware edge in front of the server and replace the shared bearer secret with per-user authorization. The built-in limiter is intentionally process-local defense in depth, not a substitute for an edge/WAF or account system.
|
|
755
757
|
|
|
756
758
|
---
|
|
757
759
|
|
|
@@ -768,14 +770,24 @@ Then connect with:
|
|
|
768
770
|
| `PREMIERE_CONTEXT_DIR` | Override the local project-context storage directory | OS application-data directory |
|
|
769
771
|
| `PORT` | HTTP port (HTTP/SSE transport only) | `3000` |
|
|
770
772
|
| `MCP_AUTH_TOKEN` | Bearer token required by the HTTP transport | unset |
|
|
771
|
-
| `ALLOW_UNAUTHENTICATED` | Set to `1`
|
|
773
|
+
| `ALLOW_UNAUTHENTICATED` | Set to `1` only for local/test HTTP harnesses; it is rejected when `NODE_ENV=production` | unset |
|
|
774
|
+
| `MCP_MAX_REQUEST_BYTES` | Maximum HTTP MCP request body size | `1048576` |
|
|
775
|
+
| `MCP_HEADERS_TIMEOUT_MS` | Maximum time to receive request headers | `10000` |
|
|
776
|
+
| `MCP_REQUEST_TIMEOUT_MS` | Maximum time to receive an HTTP request | `60000` |
|
|
777
|
+
| `MCP_KEEP_ALIVE_TIMEOUT_MS` | Idle keep-alive socket timeout | `5000` |
|
|
778
|
+
| `MCP_MAX_REQUESTS_PER_SOCKET` | Requests permitted on one keep-alive socket | `100` |
|
|
779
|
+
| `MCP_MAX_CONCURRENT_REQUESTS` | In-flight authenticated MCP request ceiling | `8` |
|
|
780
|
+
| `MCP_RATE_LIMIT_PER_MINUTE` | Per-credential token-bucket refill rate | `120` |
|
|
781
|
+
| `MCP_RATE_LIMIT_BURST` | Per-credential short burst allowance | `30` |
|
|
782
|
+
| `MCP_MAX_RATE_LIMIT_KEYS` | In-memory rate-limit identity ceiling | `2048` |
|
|
783
|
+
| `MCP_TRUST_PROXY` | Set to `1` only behind a proxy that overwrites `X-Forwarded-For` | unset |
|
|
772
784
|
| `POSTHOG_API_KEY` | PostHog project token; enables privacy-safe MCP usage telemetry | unset |
|
|
773
785
|
| `POSTHOG_HOST` | PostHog ingestion host | `https://us.i.posthog.com` |
|
|
774
786
|
| `POSTHOG_ENVIRONMENT` | Environment property attached to telemetry events | `production` |
|
|
775
787
|
| `POSTHOG_DISTINCT_ID` | Optional stable anonymous server identifier | Fly machine ID or random boot ID |
|
|
776
788
|
|
|
777
789
|
When PostHog is enabled, the server records `mcp_connection_attempt`,
|
|
778
|
-
`mcp_request`, and `mcp_tool_call`. Events contain operational fields such as
|
|
790
|
+
`mcp_request`, `mcp_request_rejected`, and `mcp_tool_call`. Events contain operational fields such as
|
|
779
791
|
method, tool name, outcome, status code, and duration. Authentication tokens,
|
|
780
792
|
IP addresses, MCP arguments, project paths, media names, and tool results are
|
|
781
793
|
never sent. Person profiles are disabled for these events.
|
|
@@ -865,8 +877,14 @@ by default. Enable them only by setting
|
|
|
865
877
|
|
|
866
878
|
- **Run it locally over stdio** unless you have a specific reason not to. That's the safe default.
|
|
867
879
|
- **The HTTP transport (`http-server`) requires `MCP_AUTH_TOKEN`** and refuses to start
|
|
868
|
-
without it. It binds `0.0.0.0` and is remotely reachable, so never expose it publicly
|
|
869
|
-
without a strong token
|
|
880
|
+
without it in production. It binds `0.0.0.0` and is remotely reachable, so never expose it publicly
|
|
881
|
+
without a strong token and edge controls. `ALLOW_UNAUTHENTICATED=1` is limited to non-production local/test use.
|
|
882
|
+
- **The HTTP transport admits only exact `/mcp` Streamable HTTP requests**, enforces
|
|
883
|
+
body/socket/request limits, and applies a bounded in-process per-credential rate and concurrency limit before
|
|
884
|
+
MCP request parsing or Premiere bridge work begins. It returns `413`, `429`, or `503` on containment failures. Configure an
|
|
885
|
+
upstream rate limit and request-size limit too; process-local counters do not protect a multi-machine deployment.
|
|
886
|
+
- **The landing CSP uses a per-response nonce for scripts**, and its static assets use explicit cache policies.
|
|
887
|
+
Keep the server in front of the exported landing so those controls are not bypassed by a separate static host.
|
|
870
888
|
- The bridge temp directory is created private to your user (mode `0700`), and the server
|
|
871
889
|
refuses to use one owned by another user — relevant on shared machines, where the CEP
|
|
872
890
|
panel would otherwise execute any `cmd_*.jsx` staged there.
|
|
Binary file
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<ExtensionManifest Version="7.0" ExtensionBundleId="com.mcp.premiere.bridge" ExtensionBundleVersion="1.12.
|
|
2
|
+
<ExtensionManifest Version="7.0" ExtensionBundleId="com.mcp.premiere.bridge" ExtensionBundleVersion="1.12.2" ExtensionBundleName="MCP for Adobe Premiere Pro">
|
|
3
3
|
<ExtensionList>
|
|
4
|
-
<Extension Id="com.mcp.premiere.bridge.panel" Version="1.12.
|
|
5
|
-
<Extension Id="com.mcp.premiere.bridge.headless" Version="1.12.
|
|
4
|
+
<Extension Id="com.mcp.premiere.bridge.panel" Version="1.12.2"/>
|
|
5
|
+
<Extension Id="com.mcp.premiere.bridge.headless" Version="1.12.2"/>
|
|
6
6
|
</ExtensionList>
|
|
7
7
|
<ExecutionEnvironment>
|
|
8
8
|
<HostList>
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
</Lifecycle>
|
|
32
32
|
<UI>
|
|
33
33
|
<Type>Panel</Type>
|
|
34
|
-
<Menu>MCP
|
|
34
|
+
<Menu>MCP for Adobe Premiere Pro</Menu>
|
|
35
35
|
<Geometry>
|
|
36
36
|
<Size>
|
|
37
37
|
<Height>300</Height>
|
package/cep-plugin/index.html
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
-
<title>MCP
|
|
6
|
+
<title>MCP for Adobe Premiere Pro</title>
|
|
7
7
|
<link rel="stylesheet" href="styles.css">
|
|
8
8
|
</head>
|
|
9
9
|
<body>
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
<header class="panel-header">
|
|
12
12
|
<div class="brand-mark" aria-hidden="true"><span>M</span></div>
|
|
13
13
|
<div class="brand-copy">
|
|
14
|
-
<h1>MCP
|
|
15
|
-
<p>Premiere
|
|
14
|
+
<h1>MCP for Adobe Premiere Pro</h1>
|
|
15
|
+
<p>Local Premiere connection</p>
|
|
16
16
|
</div>
|
|
17
17
|
<div class="auto-start"><span></span>Auto-start</div>
|
|
18
18
|
</header>
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
<section class="update-section" aria-live="polite">
|
|
82
82
|
<div class="update-copy">
|
|
83
83
|
<span class="section-label">Connector updates</span>
|
|
84
|
-
<strong id="updateTitle">Version 1.12.
|
|
84
|
+
<strong id="updateTitle">Version 1.12.2</strong>
|
|
85
85
|
<span id="updateDetail">Checking for updates…</span>
|
|
86
86
|
</div>
|
|
87
87
|
<button id="btnUpdate" class="button button-update" onclick="handleUpdateClick()" type="button" disabled>
|
package/cep-plugin/main.js
CHANGED
|
@@ -416,7 +416,7 @@ function handleUpdateClick() {
|
|
|
416
416
|
}
|
|
417
417
|
} catch (e) {}
|
|
418
418
|
|
|
419
|
-
log("MCP
|
|
419
|
+
log("MCP for Adobe Premiere Pro CEP connector loaded");
|
|
420
420
|
setStatus("waiting", "Ready — click Start Bridge");
|
|
421
421
|
|
|
422
422
|
// Always auto-start. The headless instance (StartOn ApplicationActivate) has no
|
package/cep-plugin/updater.cjs
CHANGED
|
@@ -115,6 +115,50 @@ function __findClip(nodeId) {
|
|
|
115
115
|
return null;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
// CEP's legacy QE path can enumerate a host's effect catalog before adding an
|
|
119
|
+
// effect to a timeline clip. Recent Premiere builds can expose QE yet return an
|
|
120
|
+
// empty catalog, so distinguish that host limitation from a misspelled effect
|
|
121
|
+
// name. Calling addVideoEffect/addAudioEffect without a catalog entry is not a
|
|
122
|
+
// safe fallback; an available UXP bridge has its own documented effect workflow.
|
|
123
|
+
function __getQeEffectCatalog(kind) {
|
|
124
|
+
var label = kind === "audio" ? "audio" : "video";
|
|
125
|
+
if (typeof app === "undefined" || typeof app.enableQE !== "function") {
|
|
126
|
+
return { ok: false, error: "QE is unavailable in this Premiere build, so " + label + " effects cannot be enumerated or applied." };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
app.enableQE();
|
|
131
|
+
} catch (eEnable) {
|
|
132
|
+
return { ok: false, error: "Premiere could not enable QE for " + label + " effect discovery: " + eEnable.toString() };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (typeof qe === "undefined" || !qe.project) {
|
|
136
|
+
return { ok: false, error: "QE did not expose a project after enableQE(), so " + label + " effects cannot be enumerated or applied." };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
var getter = kind === "audio" ? qe.project.getAudioEffectList : qe.project.getVideoEffectList;
|
|
140
|
+
if (typeof getter !== "function") {
|
|
141
|
+
return { ok: false, error: "This Premiere QE build does not expose the " + label + " effect catalog API." };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
var effects = null;
|
|
145
|
+
try {
|
|
146
|
+
effects = getter.call(qe.project);
|
|
147
|
+
} catch (eList) {
|
|
148
|
+
return { ok: false, error: "Premiere could not read its QE " + label + " effect catalog: " + eList.toString() };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
var count = effects && typeof effects.numItems !== "undefined" ? Number(effects.numItems) : NaN;
|
|
152
|
+
if (isNaN(count) || count < 1) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
error: "Premiere returned an empty legacy QE " + label + " effect catalog; no effect was applied. If the authenticated Premiere UXP bridge is connected, use manage_clip_effects_uxp with action 'catalog' and then 'add' instead. Existing clip components can still be inspected or edited."
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { ok: true, effects: effects, count: count };
|
|
160
|
+
}
|
|
161
|
+
|
|
118
162
|
function __getAllClips(seq) {
|
|
119
163
|
if (!seq) seq = app.project.activeSequence;
|
|
120
164
|
if (!seq) return [];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type http from "node:http";
|
|
2
|
+
export declare const MCP_HTTP_METHODS: readonly ["GET", "POST", "DELETE"];
|
|
3
|
+
export interface HttpAuthConfiguration {
|
|
4
|
+
authToken?: string;
|
|
5
|
+
allowUnauthenticated: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface HttpAdmissionSettings {
|
|
8
|
+
maxRequestBytes: number;
|
|
9
|
+
headersTimeoutMs: number;
|
|
10
|
+
requestTimeoutMs: number;
|
|
11
|
+
keepAliveTimeoutMs: number;
|
|
12
|
+
maxRequestsPerSocket: number;
|
|
13
|
+
maxConcurrentRequests: number;
|
|
14
|
+
rateLimitPerMinute: number;
|
|
15
|
+
rateLimitBurst: number;
|
|
16
|
+
maxRateLimitKeys: number;
|
|
17
|
+
trustProxy: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface AdmissionMetrics {
|
|
20
|
+
activeRequests: number;
|
|
21
|
+
trackedRateLimitKeys: number;
|
|
22
|
+
}
|
|
23
|
+
export type AdmissionDecision = {
|
|
24
|
+
accepted: true;
|
|
25
|
+
release: () => void;
|
|
26
|
+
} | {
|
|
27
|
+
accepted: false;
|
|
28
|
+
reason: "rate_limited" | "at_capacity";
|
|
29
|
+
statusCode: 429 | 503;
|
|
30
|
+
retryAfterSeconds: number;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Reads the public-HTTP containment settings. Invalid values fail startup so a
|
|
34
|
+
* typo cannot silently turn a request or socket bound into an unlimited one.
|
|
35
|
+
*/
|
|
36
|
+
export declare function readHttpAdmissionSettings(env: NodeJS.ProcessEnv): HttpAdmissionSettings;
|
|
37
|
+
/**
|
|
38
|
+
* A network-reachable editor control plane must never start unauthenticated in
|
|
39
|
+
* production. The override remains available only for local development and
|
|
40
|
+
* test harnesses where it does not create a public deployment.
|
|
41
|
+
*/
|
|
42
|
+
export declare function readHttpAuthConfiguration(env: NodeJS.ProcessEnv): HttpAuthConfiguration;
|
|
43
|
+
export declare function getRequestPathname(rawUrl: string | undefined): string | undefined;
|
|
44
|
+
export declare function isSupportedMcpMethod(method: string | undefined): boolean;
|
|
45
|
+
export declare function requestContentLength(req: Pick<http.IncomingMessage, "headers">): number | undefined;
|
|
46
|
+
export declare function exceedsRequestBodyLimit(req: Pick<http.IncomingMessage, "headers">, maxRequestBytes: number): boolean;
|
|
47
|
+
export declare class RequestBodyTooLargeError extends Error {
|
|
48
|
+
constructor();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Reads an MCP request body with a hard byte cap before it reaches the transport.
|
|
52
|
+
* This avoids attaching a second live data listener beside the transport, which
|
|
53
|
+
* can otherwise race and consume a fast chunked body before the transport does.
|
|
54
|
+
*/
|
|
55
|
+
export declare function readBoundedRequestBody(req: http.IncomingMessage, maxRequestBytes: number): Promise<Buffer>;
|
|
56
|
+
export declare function isAuthorizedBearer(req: Pick<http.IncomingMessage, "headers">, authToken: string | undefined): boolean;
|
|
57
|
+
/**
|
|
58
|
+
* The edge is authoritative by default. Honor X-Forwarded-For only after an
|
|
59
|
+
* operator explicitly declares the proxy trusted; otherwise it is attacker
|
|
60
|
+
* input and must not be used as a rate-limit identity.
|
|
61
|
+
*/
|
|
62
|
+
export declare function rateLimitIdentity(req: Pick<http.IncomingMessage, "headers" | "socket">, authorizedCredential: string | undefined, trustProxy: boolean): string;
|
|
63
|
+
/**
|
|
64
|
+
* Bounded, process-local protection for a single machine. It deliberately does
|
|
65
|
+
* not log or export identities. An edge/WAF remains necessary for fleet-wide
|
|
66
|
+
* protection across restarts and multiple instances.
|
|
67
|
+
*/
|
|
68
|
+
export declare class HttpAdmissionController {
|
|
69
|
+
private readonly settings;
|
|
70
|
+
private readonly clock;
|
|
71
|
+
private readonly buckets;
|
|
72
|
+
private activeRequests;
|
|
73
|
+
constructor(settings: Pick<HttpAdmissionSettings, "maxConcurrentRequests" | "rateLimitPerMinute" | "rateLimitBurst" | "maxRateLimitKeys">, clock?: () => number);
|
|
74
|
+
acquire(identity: string): AdmissionDecision;
|
|
75
|
+
metrics(): AdmissionMetrics;
|
|
76
|
+
private getOrCreateBucket;
|
|
77
|
+
private pruneIdleBuckets;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=http-admission.d.ts.map
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
export const MCP_HTTP_METHODS = ["GET", "POST", "DELETE"];
|
|
3
|
+
const ONE_MINUTE_MS = 60_000;
|
|
4
|
+
function readBoundedInteger(env, name, fallback, minimum, maximum) {
|
|
5
|
+
const raw = env[name];
|
|
6
|
+
if (raw === undefined || raw === "")
|
|
7
|
+
return fallback;
|
|
8
|
+
if (!/^\d+$/.test(raw)) {
|
|
9
|
+
throw new Error(`${name} must be an integer between ${minimum} and ${maximum}`);
|
|
10
|
+
}
|
|
11
|
+
const value = Number(raw);
|
|
12
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
13
|
+
throw new Error(`${name} must be an integer between ${minimum} and ${maximum}`);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Reads the public-HTTP containment settings. Invalid values fail startup so a
|
|
19
|
+
* typo cannot silently turn a request or socket bound into an unlimited one.
|
|
20
|
+
*/
|
|
21
|
+
export function readHttpAdmissionSettings(env) {
|
|
22
|
+
const rateLimitPerMinute = readBoundedInteger(env, "MCP_RATE_LIMIT_PER_MINUTE", 120, 1, 10_000);
|
|
23
|
+
const rateLimitBurst = readBoundedInteger(env, "MCP_RATE_LIMIT_BURST", 30, 1, rateLimitPerMinute);
|
|
24
|
+
return {
|
|
25
|
+
maxRequestBytes: readBoundedInteger(env, "MCP_MAX_REQUEST_BYTES", 1_048_576, 1_024, 10_485_760),
|
|
26
|
+
headersTimeoutMs: readBoundedInteger(env, "MCP_HEADERS_TIMEOUT_MS", 10_000, 1_000, 60_000),
|
|
27
|
+
requestTimeoutMs: readBoundedInteger(env, "MCP_REQUEST_TIMEOUT_MS", 60_000, 1_000, 300_000),
|
|
28
|
+
keepAliveTimeoutMs: readBoundedInteger(env, "MCP_KEEP_ALIVE_TIMEOUT_MS", 5_000, 1_000, 60_000),
|
|
29
|
+
maxRequestsPerSocket: readBoundedInteger(env, "MCP_MAX_REQUESTS_PER_SOCKET", 100, 1, 10_000),
|
|
30
|
+
maxConcurrentRequests: readBoundedInteger(env, "MCP_MAX_CONCURRENT_REQUESTS", 8, 1, 128),
|
|
31
|
+
rateLimitPerMinute,
|
|
32
|
+
rateLimitBurst,
|
|
33
|
+
maxRateLimitKeys: readBoundedInteger(env, "MCP_MAX_RATE_LIMIT_KEYS", 2_048, 16, 100_000),
|
|
34
|
+
trustProxy: env.MCP_TRUST_PROXY === "1",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A network-reachable editor control plane must never start unauthenticated in
|
|
39
|
+
* production. The override remains available only for local development and
|
|
40
|
+
* test harnesses where it does not create a public deployment.
|
|
41
|
+
*/
|
|
42
|
+
export function readHttpAuthConfiguration(env) {
|
|
43
|
+
const authToken = env.MCP_AUTH_TOKEN?.trim();
|
|
44
|
+
if (authToken)
|
|
45
|
+
return { authToken, allowUnauthenticated: false };
|
|
46
|
+
if (env.ALLOW_UNAUTHENTICATED === "1" && env.NODE_ENV !== "production") {
|
|
47
|
+
return { allowUnauthenticated: true };
|
|
48
|
+
}
|
|
49
|
+
throw new Error("MCP_AUTH_TOKEN is required for the HTTP transport. " +
|
|
50
|
+
"ALLOW_UNAUTHENTICATED=1 is permitted only outside NODE_ENV=production.");
|
|
51
|
+
}
|
|
52
|
+
export function getRequestPathname(rawUrl) {
|
|
53
|
+
if (!rawUrl)
|
|
54
|
+
return undefined;
|
|
55
|
+
try {
|
|
56
|
+
return new URL(rawUrl, "http://localhost").pathname;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function isSupportedMcpMethod(method) {
|
|
63
|
+
return MCP_HTTP_METHODS.some((allowed) => allowed === method);
|
|
64
|
+
}
|
|
65
|
+
export function requestContentLength(req) {
|
|
66
|
+
const header = req.headers["content-length"];
|
|
67
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
68
|
+
if (value === undefined)
|
|
69
|
+
return undefined;
|
|
70
|
+
if (!/^\d+$/.test(value))
|
|
71
|
+
return Number.NaN;
|
|
72
|
+
const parsed = Number(value);
|
|
73
|
+
return Number.isSafeInteger(parsed) ? parsed : Number.NaN;
|
|
74
|
+
}
|
|
75
|
+
export function exceedsRequestBodyLimit(req, maxRequestBytes) {
|
|
76
|
+
const contentLength = requestContentLength(req);
|
|
77
|
+
return contentLength !== undefined && (!Number.isFinite(contentLength) || contentLength > maxRequestBytes);
|
|
78
|
+
}
|
|
79
|
+
export class RequestBodyTooLargeError extends Error {
|
|
80
|
+
constructor() {
|
|
81
|
+
super("Request body too large");
|
|
82
|
+
this.name = "RequestBodyTooLargeError";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Reads an MCP request body with a hard byte cap before it reaches the transport.
|
|
87
|
+
* This avoids attaching a second live data listener beside the transport, which
|
|
88
|
+
* can otherwise race and consume a fast chunked body before the transport does.
|
|
89
|
+
*/
|
|
90
|
+
export function readBoundedRequestBody(req, maxRequestBytes) {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const chunks = [];
|
|
93
|
+
let receivedBytes = 0;
|
|
94
|
+
const cleanup = () => {
|
|
95
|
+
req.off("data", onData);
|
|
96
|
+
req.off("end", onEnd);
|
|
97
|
+
req.off("error", onError);
|
|
98
|
+
req.off("aborted", onAborted);
|
|
99
|
+
};
|
|
100
|
+
const onData = (chunk) => {
|
|
101
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
102
|
+
receivedBytes += buffer.length;
|
|
103
|
+
if (receivedBytes <= maxRequestBytes) {
|
|
104
|
+
chunks.push(buffer);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
cleanup();
|
|
108
|
+
// Drain rather than destroy so the caller can reliably send its 413.
|
|
109
|
+
req.resume();
|
|
110
|
+
reject(new RequestBodyTooLargeError());
|
|
111
|
+
};
|
|
112
|
+
const onEnd = () => {
|
|
113
|
+
cleanup();
|
|
114
|
+
resolve(Buffer.concat(chunks));
|
|
115
|
+
};
|
|
116
|
+
const onError = (error) => {
|
|
117
|
+
cleanup();
|
|
118
|
+
reject(error);
|
|
119
|
+
};
|
|
120
|
+
const onAborted = () => {
|
|
121
|
+
cleanup();
|
|
122
|
+
reject(new Error("Request aborted"));
|
|
123
|
+
};
|
|
124
|
+
req.on("data", onData);
|
|
125
|
+
req.once("end", onEnd);
|
|
126
|
+
req.once("error", onError);
|
|
127
|
+
req.once("aborted", onAborted);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
export function isAuthorizedBearer(req, authToken) {
|
|
131
|
+
if (!authToken)
|
|
132
|
+
return true;
|
|
133
|
+
const header = req.headers.authorization;
|
|
134
|
+
const value = Array.isArray(header) ? header[0] : header ?? "";
|
|
135
|
+
if (!value.startsWith("Bearer "))
|
|
136
|
+
return false;
|
|
137
|
+
const provided = Buffer.from(value.slice(7));
|
|
138
|
+
const expected = Buffer.from(authToken);
|
|
139
|
+
if (provided.length !== expected.length)
|
|
140
|
+
return false;
|
|
141
|
+
return timingSafeBufferEqual(provided, expected);
|
|
142
|
+
}
|
|
143
|
+
function timingSafeBufferEqual(left, right) {
|
|
144
|
+
return timingSafeEqual(left, right);
|
|
145
|
+
}
|
|
146
|
+
function hashedIdentity(value) {
|
|
147
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 32);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The edge is authoritative by default. Honor X-Forwarded-For only after an
|
|
151
|
+
* operator explicitly declares the proxy trusted; otherwise it is attacker
|
|
152
|
+
* input and must not be used as a rate-limit identity.
|
|
153
|
+
*/
|
|
154
|
+
export function rateLimitIdentity(req, authorizedCredential, trustProxy) {
|
|
155
|
+
if (authorizedCredential)
|
|
156
|
+
return `credential:${hashedIdentity(authorizedCredential)}`;
|
|
157
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
158
|
+
const forwardedValue = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
|
159
|
+
const remoteAddress = trustProxy && forwardedValue
|
|
160
|
+
? forwardedValue.split(",")[0].trim()
|
|
161
|
+
: req.socket?.remoteAddress ?? "unknown";
|
|
162
|
+
return `ip:${hashedIdentity(remoteAddress || "unknown")}`;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Bounded, process-local protection for a single machine. It deliberately does
|
|
166
|
+
* not log or export identities. An edge/WAF remains necessary for fleet-wide
|
|
167
|
+
* protection across restarts and multiple instances.
|
|
168
|
+
*/
|
|
169
|
+
export class HttpAdmissionController {
|
|
170
|
+
settings;
|
|
171
|
+
clock;
|
|
172
|
+
buckets = new Map();
|
|
173
|
+
activeRequests = 0;
|
|
174
|
+
constructor(settings, clock = Date.now) {
|
|
175
|
+
this.settings = settings;
|
|
176
|
+
this.clock = clock;
|
|
177
|
+
}
|
|
178
|
+
acquire(identity) {
|
|
179
|
+
const now = this.clock();
|
|
180
|
+
this.pruneIdleBuckets(now);
|
|
181
|
+
const bucket = this.getOrCreateBucket(identity, now);
|
|
182
|
+
if (!bucket) {
|
|
183
|
+
return { accepted: false, reason: "rate_limited", statusCode: 429, retryAfterSeconds: 60 };
|
|
184
|
+
}
|
|
185
|
+
const elapsed = Math.max(0, now - bucket.updatedAt);
|
|
186
|
+
const refill = elapsed * (this.settings.rateLimitPerMinute / ONE_MINUTE_MS);
|
|
187
|
+
bucket.tokens = Math.min(this.settings.rateLimitBurst, bucket.tokens + refill);
|
|
188
|
+
bucket.updatedAt = now;
|
|
189
|
+
if (bucket.tokens < 1) {
|
|
190
|
+
const missing = 1 - bucket.tokens;
|
|
191
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((missing / this.settings.rateLimitPerMinute) * 60));
|
|
192
|
+
return { accepted: false, reason: "rate_limited", statusCode: 429, retryAfterSeconds };
|
|
193
|
+
}
|
|
194
|
+
if (this.activeRequests >= this.settings.maxConcurrentRequests) {
|
|
195
|
+
return { accepted: false, reason: "at_capacity", statusCode: 503, retryAfterSeconds: 1 };
|
|
196
|
+
}
|
|
197
|
+
bucket.tokens -= 1;
|
|
198
|
+
this.activeRequests += 1;
|
|
199
|
+
let released = false;
|
|
200
|
+
return {
|
|
201
|
+
accepted: true,
|
|
202
|
+
release: () => {
|
|
203
|
+
if (released)
|
|
204
|
+
return;
|
|
205
|
+
released = true;
|
|
206
|
+
this.activeRequests = Math.max(0, this.activeRequests - 1);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
metrics() {
|
|
211
|
+
return { activeRequests: this.activeRequests, trackedRateLimitKeys: this.buckets.size };
|
|
212
|
+
}
|
|
213
|
+
getOrCreateBucket(identity, now) {
|
|
214
|
+
const existing = this.buckets.get(identity);
|
|
215
|
+
if (existing)
|
|
216
|
+
return existing;
|
|
217
|
+
if (this.buckets.size >= this.settings.maxRateLimitKeys)
|
|
218
|
+
return undefined;
|
|
219
|
+
const bucket = { tokens: this.settings.rateLimitBurst, updatedAt: now };
|
|
220
|
+
this.buckets.set(identity, bucket);
|
|
221
|
+
return bucket;
|
|
222
|
+
}
|
|
223
|
+
pruneIdleBuckets(now) {
|
|
224
|
+
const maxIdleMs = Math.max(ONE_MINUTE_MS, Math.ceil((this.settings.rateLimitBurst / this.settings.rateLimitPerMinute) * ONE_MINUTE_MS) * 2);
|
|
225
|
+
for (const [identity, bucket] of this.buckets) {
|
|
226
|
+
if (now - bucket.updatedAt > maxIdleMs)
|
|
227
|
+
this.buckets.delete(identity);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
//# sourceMappingURL=http-admission.js.map
|
package/dist/http-security.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type http from "node:http";
|
|
2
|
+
export interface HttpSecurityHeaderOptions {
|
|
3
|
+
scriptNonce?: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function buildContentSecurityPolicy(options?: HttpSecurityHeaderOptions): string;
|
|
2
6
|
export declare const HTTP_SECURITY_HEADERS: Readonly<{
|
|
3
7
|
"Content-Security-Policy": string;
|
|
4
8
|
"Strict-Transport-Security": "max-age=31536000; includeSubDomains";
|
|
@@ -8,5 +12,5 @@ export declare const HTTP_SECURITY_HEADERS: Readonly<{
|
|
|
8
12
|
"Permissions-Policy": "camera=(), microphone=(), geolocation=()";
|
|
9
13
|
"Cross-Origin-Opener-Policy": "same-origin";
|
|
10
14
|
}>;
|
|
11
|
-
export declare function applyHttpSecurityHeaders(res: http.ServerResponse): void;
|
|
15
|
+
export declare function applyHttpSecurityHeaders(res: http.ServerResponse, options?: HttpSecurityHeaderOptions): void;
|
|
12
16
|
//# sourceMappingURL=http-security.d.ts.map
|