ES module imports¶
An explanation of how mcp-v8 resolves and fetches ES modules, why external imports are off by default, and how policy gating reduces supply-chain risk.
Why imports are disabled by default¶
External ES modules can require network requests to external hosts. Embedded node: modules are supplied by the runtime. In the context of an AI-agent workload, code is generated dynamically — the server operator may not know in advance what packages an agent will try to import. Allowing unrestricted network fetches at import time creates two related problems:
- Data exfiltration: a crafted
importURL can be used to send data to an attacker-controlled host via the URL itself or the HTTP headers of the request. - Supply-chain compromise: a typosquatted or malicious package fetched live can execute arbitrary code inside the V8 isolate.
Setting allow_external to false by default means the server is safe out of the box. An operator who needs module imports explicitly opts in with --allow-external-modules, and can immediately further restrict what can be imported with a policy.
Specifier resolution¶
The module loader resolves these public specifier forms:
| Specifier form | allow_external gate |
Resolves to |
|---|---|---|
npm:<rest> |
yes — blocked if disabled | https://esm.sh/<rest> |
jsr:<rest> |
yes — blocked if disabled | https://esm.sh/jsr/<rest> |
https://… or http://… |
yes — blocked if disabled | URL as-is |
Relative (./foo.js) |
checked when an HTTP/HTTPS module is loaded | resolved against referrer URL |
node:<name> |
no external gate | supported embedded Node builtin |
npm: and jsr: specifiers are a developer-convenience layer: they are translated to https://esm.sh/… before any network activity occurs. The esm.sh CDN serves ES-module-compatible builds of npm and JSR packages. Package specifiers appear to the module policy as https://esm.sh/… URLs. Explicit HTTP/HTTPS URLs retain their original host.
Relative specifiers are resolved using standard URL-relative resolution (resolve_import from deno_core) against the URL of the importing module. Resolution itself does not apply the external gate. Loading the resolved HTTP/HTTPS URL checks allow_external and the module hook/policy chain, including dependencies in the same module graph.
Module loading and fetch¶
After resolution, the loader's load phase fetches the module source over HTTP. External network loading supports https and http. Supported node: builtins are loaded from embedded source; file:// imports are rejected.
Fetch parameters are fixed:
- Connect timeout: 10 seconds
- Total request timeout: 30 seconds
If the HTTP response is a redirect, the loader records the final URL. If the final URL path ends in .ts or .tsx, the response body is passed through the same swc-based TypeScript transpiler used for inline code before being handed to V8 as JavaScript.
Node builtins used by npm packages¶
esm.sh can rewrite a package's Node builtin imports to URLs such as https://esm.sh/node/stream.mjs. For supported builtins, mcp-v8 serves a small module at that URL which re-exports its embedded node:stream implementation. This keeps builtin dependencies in the same runtime as direct node: imports.
The bridge applies only to canonical HTTPS esm.sh/node/<supported-name>.mjs URLs, without credentials, a non-default port, a query, or a fragment. The external-import gate and module hook/policy checks run before the bridge is used. Unsupported URLs follow normal network loading.
This enables packages such as npm:pngjs@7.0.0 to use the embedded stream and zlib implementations. Node compatibility remains partial: importing a package successfully does not establish that all of its APIs work. See the compatibility reference for supported surfaces and the import how-to for a PNG example.
Policy gating¶
When a modules policy chain is configured, it is evaluated inside the load phase, after allow_external has already been confirmed but before the HTTP request is sent. This means:
- The
allow_externalflag acts as a binary kill switch — no policy can override it. - The policy acts as a fine-grained allowlist on top of the flag.
The policy receives an input document with the following shape:
{
"specifier": "https://esm.sh/lodash-es",
"specifier_type": "npm",
"resolved_url": "https://esm.sh/lodash-es",
"url_parsed": {
"scheme": "https",
"host": "esm.sh",
"path": "/lodash-es"
}
}
specifier_type is derived from the resolved URL:
"npm"— resolved URL containsesm.sh/(excludingesm.sh/jsr/)"jsr"— resolved URL containsesm.sh/jsr/"url"— any other HTTP/HTTPS URL
The policy entrypoint is data.mcp.modules.allow. A false or undefined result blocks loading; the execution receives a module import denial error.
Supply-chain risk considerations¶
Even with a policy, consider the following:
- The policy allowlists by host and path prefix, but CDN redirects may land on a different subdomain (e.g. a
*.esm.shCDN edge). The example policy inpolicies/modules.regoaccounts for this with a wildcard suffix rule for.esm.sh. - Pinning by exact path prefix (e.g.
/lodash-es@4.17.21) prevents loading a newer, potentially modified version of a package. - An HTTP (non-TLS) import URL allows a man-in-the-middle to serve modified module source. Prefer
https://URLs and treathttp://as suitable only in controlled development environments. - Modules are fetched fresh on every execution that imports them; there is no built-in module cache across executions. This means network availability affects execution reliability, but also means no stale cached code is served.