fix(web): move the app origin off 6276, bound the publish route
Copilot review round 2 on #2100, plus a collision the review did not find. The default app-origin port was 6276 — which is already RUNNER_OAUTH_CALLBACK_DEFAULT_PORT, the CLI/TUI loopback OAuth callback. That port is fixed precisely so an OAuth app can pre-register http://127.0.0.1:6276/oauth/callback, which makes it the one listener here that cannot move when something takes it first. A running --web would hold it and break a later --cli/--tui OAuth flow at its registered redirect URI, and docs/mcp-app-review.md has those two running side by side. This listener has a documented dynamic fallback, so this listener yields: the default is now 6278 (6277 skipped — v1's retired proxy port, which the migration guide tells people to stop forwarding). A test asserts the two defaults stay distinct. Review comments: - POST /api/app-document now applies hono's bodyLimit BEFORE parsing. The 8MiB check ran after c.req.json() had buffered and parsed the whole request, and bounded only `html` — so bulk in `csp`, or in keys the route never reads, was fully materialized before the 413. - `csp` is validated as an HTTP header value and length-bounded. The controller hands it to res.writeHead() verbatim, where Node throws ERR_INVALID_CHAR synchronously — inside the handler for a later, unrelated request, outside this route's error handling. - The app-origin port now propagates everywhere the other two do: the CLI --print-handoff port-forward command (and its test), the Dockerfile's ENV and EXPOSE, and the remote-review SSH recipe in docs/mcp-app-review.md. Without it an app declaring _meta.ui.domain is unreachable through every documented remote workflow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
fa441da85f
commit
fafc8367b8
15 changed files with 211 additions and 73 deletions
|
|
@ -25,7 +25,7 @@ v2/main/
|
||||||
│ │ # HOST-DEPENDENT and the Inspector owns no domain
|
│ │ # HOST-DEPENDENT and the Inspector owns no domain
|
||||||
│ │ # infrastructure, so it treats any non-empty value as a
|
│ │ # infrastructure, so it treats any non-empty value as a
|
||||||
│ │ # REQUEST, not an address, and answers with a real loopback
|
│ │ # REQUEST, not an address, and answers with a real loopback
|
||||||
│ │ # origin on its own port (MCP_APP_ORIGIN_PORT, default 6276).
|
│ │ # origin on its own port (MCP_APP_ORIGIN_PORT, default 6278).
|
||||||
│ │ # ONE SHARED origin, path-keyed per document — a real,
|
│ │ # ONE SHARED origin, path-keyed per document — a real,
|
||||||
│ │ # allowlistable origin without a port or DNS name per app,
|
│ │ # allowlistable origin without a port or DNS name per app,
|
||||||
│ │ # and therefore NOT a per-app isolation boundary (two such
|
│ │ # and therefore NOT a per-app isolation boundary (two such
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,14 @@ ENV HOST=0.0.0.0 \
|
||||||
DANGEROUSLY_BIND_ALL_INTERFACES=true \
|
DANGEROUSLY_BIND_ALL_INTERFACES=true \
|
||||||
CLIENT_PORT=6274 \
|
CLIENT_PORT=6274 \
|
||||||
MCP_SANDBOX_PORT=6275 \
|
MCP_SANDBOX_PORT=6275 \
|
||||||
|
MCP_APP_ORIGIN_PORT=6278 \
|
||||||
MCP_AUTO_OPEN_ENABLED=false
|
MCP_AUTO_OPEN_ENABLED=false
|
||||||
# 6275 is the MCP Apps sandbox, a second listener the browser reaches directly.
|
# 6275 is the MCP Apps sandbox and 6278 the dedicated app origin (#2056) — two
|
||||||
# It is only needed for the Apps tab, so it is EXPOSEd but publishing it is
|
# further listeners the browser reaches DIRECTLY, so neither works through the
|
||||||
|
# 6274 publish alone. Both are only needed for the Apps tab (6278 only for an
|
||||||
|
# App declaring `_meta.ui.domain`), so they are EXPOSEd but publishing them is
|
||||||
# optional — see the Docker section of the root README.
|
# optional — see the Docker section of the root README.
|
||||||
EXPOSE 6274 6275
|
EXPOSE 6274 6275 6278
|
||||||
|
|
||||||
# Run as the non-root `node` user the base image ships. The inspector resolves
|
# Run as the non-root `node` user the base image ships. The inspector resolves
|
||||||
# its runtime-state dir (default catalog, OAuth token storage) from `HOME`
|
# its runtime-state dir (default catalog, OAuth token storage) from `HOME`
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ For the scripted version of the same flow (`--app-info` probe → deep link →
|
||||||
|
|
||||||
That field is how a server asks its host for a stable, dedicated origin. Without one, an App renders into a `srcdoc` frame sandboxed without `allow-same-origin`, so its document has an *opaque* origin and every request it makes carries `Origin: null` — which no CORS policy, OAuth callback, or API-key allowlist can admit ([#2056](https://github.com/modelcontextprotocol/inspector/issues/2056)).
|
That field is how a server asks its host for a stable, dedicated origin. Without one, an App renders into a `srcdoc` frame sandboxed without `allow-same-origin`, so its document has an *opaque* origin and every request it makes carries `Origin: null` — which no CORS policy, OAuth callback, or API-key allowlist can admit ([#2056](https://github.com/modelcontextprotocol/inspector/issues/2056)).
|
||||||
|
|
||||||
Open the Apps tab and run `mcp_app_demo`. The widget renders identically to `mcp-app-http.json` — the difference is not visual. Inspect the inner iframe in devtools: on this server it is served from `http://127.0.0.1:6276/app-document/<id>` and `location.origin` is that real origin, where on `mcp-app-http.json` it is `about:srcdoc` with an origin of `null`. (The host is whatever the Inspector bound to — `127.0.0.1` by default, an *address* rather than the name `localhost`, for the reason `resolve-bind-host.ts` documents.)
|
Open the Apps tab and run `mcp_app_demo`. The widget renders identically to `mcp-app-http.json` — the difference is not visual. Inspect the inner iframe in devtools: on this server it is served from `http://127.0.0.1:6278/app-document/<id>` and `location.origin` is that real origin, where on `mcp-app-http.json` it is `about:srcdoc` with an origin of `null`. (The host is whatever the Inspector bound to — `127.0.0.1` by default, an *address* rather than the name `localhost`, for the reason `resolve-bind-host.ts` documents.)
|
||||||
|
|
||||||
The spec makes `domain`'s format **host-dependent**, and the Inspector owns no domain infrastructure — so it reads any non-empty value as a *request* rather than an address, and answers with a real loopback origin of its own. See [MCP App dedicated origins](./clients/web/README.md#mcp-app-dedicated-origins-metauidomain) for the full contract, including what the one shared origin does and does not isolate, and how every failure falls back to the default render rather than blanking the app.
|
The spec makes `domain`'s format **host-dependent**, and the Inspector owns no domain infrastructure — so it reads any non-empty value as a *request* rather than an address, and answers with a real loopback origin of its own. See [MCP App dedicated origins](./clients/web/README.md#mcp-app-dedicated-origins-metauidomain) for the full contract, including what the one shared origin does and does not isolate, and how every failure falls back to the default render rather than blanking the app.
|
||||||
|
|
||||||
|
|
@ -477,10 +477,10 @@ docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 \
|
||||||
ghcr.io/modelcontextprotocol/inspector
|
ghcr.io/modelcontextprotocol/inspector
|
||||||
```
|
```
|
||||||
|
|
||||||
**And `6276` if your app declares `_meta.ui.domain`.** That is the spec field a server uses to ask its host for a stable, dedicated origin — without one the app runs at an opaque origin and its requests carry `Origin: null`, which no CORS / OAuth-callback / API-key allowlist can admit. The Inspector answers the request with a real loopback origin on a third listener, `MCP_APP_ORIGIN_PORT` (default `6276`); apps that declare no `domain` never touch it. An app that declares one and can't reach it still renders — at an opaque origin, with a console warning. See [MCP App dedicated origins](./clients/web/README.md#mcp-app-dedicated-origins-metauidomain) for the host-specific contract and its isolation trade-offs.
|
**And `6278` if your app declares `_meta.ui.domain`.** That is the spec field a server uses to ask its host for a stable, dedicated origin — without one the app runs at an opaque origin and its requests carry `Origin: null`, which no CORS / OAuth-callback / API-key allowlist can admit. The Inspector answers the request with a real loopback origin on a third listener, `MCP_APP_ORIGIN_PORT` (default `6278`); apps that declare no `domain` never touch it. An app that declares one and can't reach it still renders — at an opaque origin, with a console warning. See [MCP App dedicated origins](./clients/web/README.md#mcp-app-dedicated-origins-metauidomain) for the host-specific contract and its isolation trade-offs.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 -p 127.0.0.1:6276:6276 \
|
docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 -p 127.0.0.1:6278:6278 \
|
||||||
ghcr.io/modelcontextprotocol/inspector
|
ghcr.io/modelcontextprotocol/inspector
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -554,6 +554,7 @@ describe("--print-handoff", () => {
|
||||||
MCP_INSPECTOR_API_TOKEN: "tok123",
|
MCP_INSPECTOR_API_TOKEN: "tok123",
|
||||||
CLIENT_PORT: "16274",
|
CLIENT_PORT: "16274",
|
||||||
MCP_SANDBOX_PORT: "16275",
|
MCP_SANDBOX_PORT: "16275",
|
||||||
|
MCP_APP_ORIGIN_PORT: "16278",
|
||||||
MCP_STORAGE_DIR: "/tmp/inspector-storage",
|
MCP_STORAGE_DIR: "/tmp/inspector-storage",
|
||||||
MCP_INSPECTOR_OAUTH_STATE_PATH: "",
|
MCP_INSPECTOR_OAUTH_STATE_PATH: "",
|
||||||
},
|
},
|
||||||
|
|
@ -574,6 +575,9 @@ describe("--print-handoff", () => {
|
||||||
expect(out.deepLink).toContain("transport=http");
|
expect(out.deepLink).toContain("transport=http");
|
||||||
expect(out.portForwardCmd).toContain("--tcp 16274:16274");
|
expect(out.portForwardCmd).toContain("--tcp 16274:16274");
|
||||||
expect(out.portForwardCmd).toContain("--tcp 16275:16275");
|
expect(out.portForwardCmd).toContain("--tcp 16275:16275");
|
||||||
|
// The dedicated app origin (#2056) forwards with the other two — an app
|
||||||
|
// declaring `_meta.ui.domain` is unreachable without it.
|
||||||
|
expect(out.portForwardCmd).toContain("--tcp 16278:16278");
|
||||||
expect(out.oauthStatePath).toBe(
|
expect(out.oauthStatePath).toBe(
|
||||||
join("/tmp/inspector-storage", "oauth.json"),
|
join("/tmp/inspector-storage", "oauth.json"),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -452,6 +452,11 @@ function buildHandoff(
|
||||||
: canonicalUrlHost(host);
|
: canonicalUrlHost(host);
|
||||||
const clientPort = process.env.CLIENT_PORT || "6274";
|
const clientPort = process.env.CLIENT_PORT || "6274";
|
||||||
const sandboxPort = process.env.MCP_SANDBOX_PORT || "6275";
|
const sandboxPort = process.env.MCP_SANDBOX_PORT || "6275";
|
||||||
|
// The dedicated app origin (#2056). Forwarded alongside the other two: an App
|
||||||
|
// whose UI resource declares `_meta.ui.domain` is served from this port and
|
||||||
|
// the browser reaches it DIRECTLY, so a handoff that forwards only 6274/6275
|
||||||
|
// renders that app from an unreachable origin.
|
||||||
|
const appOriginPort = process.env.MCP_APP_ORIGIN_PORT || "6278";
|
||||||
// Treat an empty MCP_INSPECTOR_API_TOKEN the same as unset — an empty token
|
// Treat an empty MCP_INSPECTOR_API_TOKEN the same as unset — an empty token
|
||||||
// can't satisfy the deep-link autoConnect gate.
|
// can't satisfy the deep-link autoConnect gate.
|
||||||
const apiToken = process.env.MCP_INSPECTOR_API_TOKEN || undefined;
|
const apiToken = process.env.MCP_INSPECTOR_API_TOKEN || undefined;
|
||||||
|
|
@ -469,7 +474,7 @@ function buildHandoff(
|
||||||
return {
|
return {
|
||||||
serverUrl: normalizedUrl,
|
serverUrl: normalizedUrl,
|
||||||
deepLink: `http://${linkHost}:${clientPort}/?${params.toString()}`,
|
deepLink: `http://${linkHost}:${clientPort}/?${params.toString()}`,
|
||||||
portForwardCmd: `coder port-forward <workspace> --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort}`,
|
portForwardCmd: `coder port-forward <workspace> --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort} --tcp ${appOriginPort}:${appOriginPort}`,
|
||||||
oauthStatePath: statePath,
|
oauthStatePath: statePath,
|
||||||
apiToken: apiToken ?? null,
|
apiToken: apiToken ?? null,
|
||||||
note:
|
note:
|
||||||
|
|
|
||||||
|
|
@ -155,8 +155,11 @@ Inspector owns no domain infrastructure — it cannot serve
|
||||||
not parsed, matched, or reserved — declare whatever your production host
|
not parsed, matched, or reserved — declare whatever your production host
|
||||||
expects.
|
expects.
|
||||||
- The Inspector answers with a real HTTP origin on loopback:
|
- The Inspector answers with a real HTTP origin on loopback:
|
||||||
`http://<host>:<MCP_APP_ORIGIN_PORT>`, default **`6276`** (beside the web
|
`http://<host>:<MCP_APP_ORIGIN_PORT>`, default **`6278`** — in the same
|
||||||
port `6274` and the sandbox port `6275`, so the three forward together).
|
`627x` family as the web port `6274` and the sandbox port `6275`, so the
|
||||||
|
three forward together. It is **not** `6276`: that is the fixed loopback
|
||||||
|
OAuth callback the CLI and TUI listen on, which OAuth apps pre-register and
|
||||||
|
which therefore cannot move. `6277` is skipped as v1's retired proxy port.
|
||||||
- The app document is served from there under an unguessable path, its
|
- The app document is served from there under an unguessable path, its
|
||||||
per-app CSP delivered as a real response **header** (stronger than the
|
per-app CSP delivered as a real response **header** (stronger than the
|
||||||
`<meta>` the `srcdoc` path relies on) plus a `frame-ancestors` that admits
|
`<meta>` the `srcdoc` path relies on) plus a `frame-ancestors` that admits
|
||||||
|
|
@ -186,7 +189,7 @@ default (opaque-origin) way and logs a console warning naming `_meta.ui.domain`.
|
||||||
Losing the real origin degrades what the app can reach; losing the app itself
|
Losing the real origin degrades what the app can reach; losing the app itself
|
||||||
would be worse.
|
would be worse.
|
||||||
|
|
||||||
**Forward `6276` too** if you need this off loopback (a container, a tunnel).
|
**Forward `6278` too** if you need this off loopback (a container, a tunnel).
|
||||||
As with the sandbox port, a taken port falls back to an OS-assigned one with a
|
As with the sandbox port, a taken port falls back to an OS-assigned one with a
|
||||||
loud warning — the app still renders, but the origin an app's backend was told
|
loud warning — the app still renders, but the origin an app's backend was told
|
||||||
to allowlist is then wrong, which is what the warning tells you.
|
to allowlist is then wrong, which is what the warning tells you.
|
||||||
|
|
|
||||||
|
|
@ -57,11 +57,22 @@ import { frameAncestorsDirective } from "./sandbox-controller.js";
|
||||||
/**
|
/**
|
||||||
* The default app-origin listen port — **fixed**, for the same reason
|
* The default app-origin listen port — **fixed**, for the same reason
|
||||||
* `DEFAULT_SANDBOX_PORT` is: a port that changes per run can never be named in
|
* `DEFAULT_SANDBOX_PORT` is: a port that changes per run can never be named in
|
||||||
* a dev container's `forwardPorts`, a `docker run -p`, or an SSH tunnel. `6276`
|
* a dev container's `forwardPorts`, a `docker run -p`, or an SSH tunnel.
|
||||||
* sits next to the web (`6274`) and sandbox (`6275`) ports so the three forward
|
*
|
||||||
* together.
|
* **Not 6276, and not 6277.** `6276` is already taken, by the fixed loopback
|
||||||
|
* OAuth callback the CLI and TUI listen on
|
||||||
|
* (`RUNNER_OAUTH_CALLBACK_DEFAULT_PORT`) — fixed precisely so an OAuth app can
|
||||||
|
* pre-register `http://127.0.0.1:6276/oauth/callback`, which means it is the
|
||||||
|
* one port here that CANNOT move when something else takes it first. Binding it
|
||||||
|
* would let a running `--web` (started at login, holding the port) break a
|
||||||
|
* later `--cli`/`--tui` OAuth flow at its registered redirect URI — and the
|
||||||
|
* handoff recipe in `docs/mcp-app-review.md` has those two running side by
|
||||||
|
* side. Of the two listeners this listener is the one with a documented
|
||||||
|
* dynamic fallback, so it is the one that yields. `6277` is skipped because it
|
||||||
|
* was v1's MCP proxy port, which the migration guide tells people to stop
|
||||||
|
* forwarding; reusing it would make that instruction wrong.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_APP_ORIGIN_PORT = 6276;
|
export const DEFAULT_APP_ORIGIN_PORT = 6278;
|
||||||
|
|
||||||
/** Documents kept at once. Oldest is evicted past this; see {@link publish}. */
|
/** Documents kept at once. Oldest is evicted past this; see {@link publish}. */
|
||||||
const MAX_DOCUMENTS = 32;
|
const MAX_DOCUMENTS = 32;
|
||||||
|
|
@ -142,7 +153,7 @@ export interface AppOriginController {
|
||||||
/**
|
/**
|
||||||
* A usable listen port from a raw env value, or `undefined` if it isn't a plain
|
* A usable listen port from a raw env value, or `undefined` if it isn't a plain
|
||||||
* integer in `0`–`65535`. Mirrors `sandbox-controller`'s parser: `^\d+$` rejects
|
* integer in `0`–`65535`. Mirrors `sandbox-controller`'s parser: `^\d+$` rejects
|
||||||
* `parseInt`'s partial parses (`6276abc`) and the bound keeps an out-of-range
|
* `parseInt`'s partial parses (`6278abc`) and the bound keeps an out-of-range
|
||||||
* value from reaching `server.listen`, which throws synchronously.
|
* value from reaching `server.listen`, which throws synchronously.
|
||||||
*/
|
*/
|
||||||
function parseListenPort(raw: string | undefined): number | undefined {
|
function parseListenPort(raw: string | undefined): number | undefined {
|
||||||
|
|
|
||||||
|
|
@ -3489,7 +3489,7 @@ describe("App MCP App listed-resource metadata wiring (#2055)", () => {
|
||||||
// green while a domain-declaring elicitation app silently loses its real
|
// green while a domain-declaring elicitation app silently loses its real
|
||||||
// origin. Assert it is not merely present but actually reaches the lib.
|
// origin. Assert it is not merely present but actually reaches the lib.
|
||||||
publishAppDocumentMock.mockResolvedValue(
|
publishAppDocumentMock.mockResolvedValue(
|
||||||
"http://127.0.0.1:6276/app-document/deadbeef",
|
"http://127.0.0.1:6278/app-document/deadbeef",
|
||||||
);
|
);
|
||||||
|
|
||||||
renderWithMantine(<App />);
|
renderWithMantine(<App />);
|
||||||
|
|
@ -3510,7 +3510,7 @@ describe("App MCP App listed-resource metadata wiring (#2055)", () => {
|
||||||
html: "<p>app</p>",
|
html: "<p>app</p>",
|
||||||
csp: "default-src 'none'",
|
csp: "default-src 'none'",
|
||||||
}),
|
}),
|
||||||
).resolves.toBe("http://127.0.0.1:6276/app-document/deadbeef");
|
).resolves.toBe("http://127.0.0.1:6278/app-document/deadbeef");
|
||||||
expect(publishAppDocumentMock).toHaveBeenCalledWith(
|
expect(publishAppDocumentMock).toHaveBeenCalledWith(
|
||||||
{ html: "<p>app</p>", csp: "default-src 'none'" },
|
{ html: "<p>app</p>", csp: "default-src 'none'" },
|
||||||
expect.objectContaining({ baseUrl: expect.any(String) }),
|
expect.objectContaining({ baseUrl: expect.any(String) }),
|
||||||
|
|
|
||||||
|
|
@ -498,7 +498,7 @@ describe("createAppBridgeFactory", () => {
|
||||||
);
|
);
|
||||||
const publishAppDocument = vi
|
const publishAppDocument = vi
|
||||||
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
|
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
|
||||||
.mockResolvedValue("http://localhost:6276/app-document/abc");
|
.mockResolvedValue("http://localhost:6278/app-document/abc");
|
||||||
const factory = createAppBridgeFactory({
|
const factory = createAppBridgeFactory({
|
||||||
getClient: () => fakeClient,
|
getClient: () => fakeClient,
|
||||||
readResource,
|
readResource,
|
||||||
|
|
@ -517,7 +517,7 @@ describe("createAppBridgeFactory", () => {
|
||||||
|
|
||||||
const call = bridgeInstances[0].sendSandboxResourceReady.mock
|
const call = bridgeInstances[0].sendSandboxResourceReady.mock
|
||||||
.calls[0][0] as { html: string; src?: string };
|
.calls[0][0] as { html: string; src?: string };
|
||||||
expect(call.src).toBe("http://localhost:6276/app-document/abc");
|
expect(call.src).toBe("http://localhost:6278/app-document/abc");
|
||||||
// `html` still rides along: a proxy that ignores `src` renders the app
|
// `html` still rides along: a proxy that ignores `src` renders the app
|
||||||
// rather than a blank frame.
|
// rather than a blank frame.
|
||||||
expect(call.html).toBe(published.html);
|
expect(call.html).toBe(published.html);
|
||||||
|
|
@ -530,7 +530,7 @@ describe("createAppBridgeFactory", () => {
|
||||||
])("does not take the dedicated path for %s", async (_label, domain) => {
|
])("does not take the dedicated path for %s", async (_label, domain) => {
|
||||||
const publishAppDocument = vi
|
const publishAppDocument = vi
|
||||||
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
|
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
|
||||||
.mockResolvedValue("http://localhost:6276/app-document/abc");
|
.mockResolvedValue("http://localhost:6278/app-document/abc");
|
||||||
const factory = createAppBridgeFactory({
|
const factory = createAppBridgeFactory({
|
||||||
getClient: () => fakeClient,
|
getClient: () => fakeClient,
|
||||||
readResource: vi
|
readResource: vi
|
||||||
|
|
@ -550,7 +550,7 @@ describe("createAppBridgeFactory", () => {
|
||||||
it("never publishes a resource that declares no domain", async () => {
|
it("never publishes a resource that declares no domain", async () => {
|
||||||
const publishAppDocument = vi
|
const publishAppDocument = vi
|
||||||
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
|
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
|
||||||
.mockResolvedValue("http://localhost:6276/app-document/abc");
|
.mockResolvedValue("http://localhost:6278/app-document/abc");
|
||||||
const factory = createAppBridgeFactory({
|
const factory = createAppBridgeFactory({
|
||||||
getClient: () => fakeClient,
|
getClient: () => fakeClient,
|
||||||
readResource: vi.fn().mockResolvedValue(uiResource("<h1>x</h1>")),
|
readResource: vi.fn().mockResolvedValue(uiResource("<h1>x</h1>")),
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,13 @@ describe("publishAppDocument", () => {
|
||||||
const fetchFn = vi
|
const fetchFn = vi
|
||||||
.fn<typeof fetch>()
|
.fn<typeof fetch>()
|
||||||
.mockResolvedValue(
|
.mockResolvedValue(
|
||||||
jsonResponse({ url: "http://localhost:6276/app-document/abc" }),
|
jsonResponse({ url: "http://localhost:6278/app-document/abc" }),
|
||||||
);
|
);
|
||||||
const url = await publishAppDocument(
|
const url = await publishAppDocument(
|
||||||
{ html: "<p>app</p>", csp: "default-src 'none'" },
|
{ html: "<p>app</p>", csp: "default-src 'none'" },
|
||||||
{ baseUrl: "http://localhost:6274", authToken: "tok", fetchFn },
|
{ baseUrl: "http://localhost:6274", authToken: "tok", fetchFn },
|
||||||
);
|
);
|
||||||
expect(url).toBe("http://localhost:6276/app-document/abc");
|
expect(url).toBe("http://localhost:6278/app-document/abc");
|
||||||
expect(fetchFn).toHaveBeenCalledWith(
|
expect(fetchFn).toHaveBeenCalledWith(
|
||||||
"http://localhost:6274/api/app-document",
|
"http://localhost:6274/api/app-document",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|
|
||||||
|
|
@ -37,13 +37,13 @@ describe("createRemoteApp POST /api/app-document", () => {
|
||||||
it("publishes the document and returns the URL", async () => {
|
it("publishes the document and returns the URL", async () => {
|
||||||
const publish = vi
|
const publish = vi
|
||||||
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
|
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
|
||||||
.mockReturnValue({ url: "http://localhost:6276/app-document/abc" });
|
.mockReturnValue({ url: "http://localhost:6278/app-document/abc" });
|
||||||
const res = await appWith(publish).request(
|
const res = await appWith(publish).request(
|
||||||
post({ html: "<h1>hi</h1>", csp: "default-src 'none'" }),
|
post({ html: "<h1>hi</h1>", csp: "default-src 'none'" }),
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
await expect(res.json()).resolves.toEqual({
|
await expect(res.json()).resolves.toEqual({
|
||||||
url: "http://localhost:6276/app-document/abc",
|
url: "http://localhost:6278/app-document/abc",
|
||||||
});
|
});
|
||||||
expect(publish).toHaveBeenCalledWith({
|
expect(publish).toHaveBeenCalledWith({
|
||||||
html: "<h1>hi</h1>",
|
html: "<h1>hi</h1>",
|
||||||
|
|
@ -54,7 +54,7 @@ describe("createRemoteApp POST /api/app-document", () => {
|
||||||
it("accepts a document with no csp", async () => {
|
it("accepts a document with no csp", async () => {
|
||||||
const publish = vi
|
const publish = vi
|
||||||
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
|
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
|
||||||
.mockReturnValue({ url: "http://localhost:6276/app-document/abc" });
|
.mockReturnValue({ url: "http://localhost:6278/app-document/abc" });
|
||||||
const res = await appWith(publish).request(post({ html: "<h1>hi</h1>" }));
|
const res = await appWith(publish).request(post({ html: "<h1>hi</h1>" }));
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(publish).toHaveBeenCalledWith({
|
expect(publish).toHaveBeenCalledWith({
|
||||||
|
|
@ -112,6 +112,46 @@ describe("createRemoteApp POST /api/app-document", () => {
|
||||||
expect(publish).not.toHaveBeenCalled();
|
expect(publish).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a CR/LF-carrying csp", "default-src 'none'\r\nX-Evil: 1"],
|
||||||
|
["a NUL-carrying csp", "default-src 'none'\u0000"],
|
||||||
|
])("rejects %s with 400", async (_label, csp) => {
|
||||||
|
// The controller hands `csp` to `res.writeHead()` verbatim. Node throws
|
||||||
|
// ERR_INVALID_CHAR there SYNCHRONOUSLY, inside the handler for a later,
|
||||||
|
// unrelated request — so accepting one here turns a bad publish into a
|
||||||
|
// thrown listener rather than a 400 anyone can see.
|
||||||
|
const publish = vi
|
||||||
|
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
|
||||||
|
.mockReturnValue({ url: "http://x/y" });
|
||||||
|
const res = await appWith(publish).request(post({ html: "<p>x</p>", csp }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(publish).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an oversized csp with 413 — it is retained per document", async () => {
|
||||||
|
const publish = vi
|
||||||
|
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
|
||||||
|
.mockReturnValue({ url: "http://x/y" });
|
||||||
|
const res = await appWith(publish).request(
|
||||||
|
post({ html: "<p>x</p>", csp: "a".repeat(8 * 1024 + 1) }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(413);
|
||||||
|
expect(publish).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an oversized BODY before parsing it", async () => {
|
||||||
|
// Distinct from the document check below, which only runs once the whole
|
||||||
|
// request has been buffered and parsed — and only bounds `html`.
|
||||||
|
const publish = vi
|
||||||
|
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
|
||||||
|
.mockReturnValue({ url: "http://x/y" });
|
||||||
|
const res = await appWith(publish).request(
|
||||||
|
post(`{"junk":"${"x".repeat(24 * 1024 * 1024 + 1024)}"}`),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(413);
|
||||||
|
expect(publish).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects an oversized document with 413 rather than pinning it in memory", async () => {
|
it("rejects an oversized document with 413 rather than pinning it in memory", async () => {
|
||||||
const publish = vi
|
const publish = vi
|
||||||
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
|
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
resolveAppOriginPort,
|
resolveAppOriginPort,
|
||||||
appDocumentEmbedders,
|
appDocumentEmbedders,
|
||||||
} from "../../../../server/app-origin-controller.js";
|
} from "../../../../server/app-origin-controller.js";
|
||||||
|
import { RUNNER_OAUTH_CALLBACK_DEFAULT_PORT } from "@inspector/core/auth/node/runner-oauth-callback.js";
|
||||||
|
|
||||||
describe("resolveAppOriginPort", () => {
|
describe("resolveAppOriginPort", () => {
|
||||||
const saved = process.env.MCP_APP_ORIGIN_PORT;
|
const saved = process.env.MCP_APP_ORIGIN_PORT;
|
||||||
|
|
@ -34,6 +35,19 @@ describe("resolveAppOriginPort", () => {
|
||||||
expect(resolveAppOriginPort()).toBe(DEFAULT_APP_ORIGIN_PORT);
|
expect(resolveAppOriginPort()).toBe(DEFAULT_APP_ORIGIN_PORT);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not default to the runners' fixed OAuth callback port", () => {
|
||||||
|
// Both listeners are fixed-by-default and both live in the 627x family, so
|
||||||
|
// the next free number is the obvious pick for each — which is how they
|
||||||
|
// collided. This one can move (it warns and retries on a dynamic port);
|
||||||
|
// the OAuth callback cannot, because apps pre-register
|
||||||
|
// `http://127.0.0.1:<that port>/oauth/callback`. A `--web` holding the
|
||||||
|
// port would therefore break a later `--cli`/`--tui` OAuth flow, and the
|
||||||
|
// handoff recipe runs those side by side.
|
||||||
|
expect(DEFAULT_APP_ORIGIN_PORT).not.toBe(
|
||||||
|
RUNNER_OAUTH_CALLBACK_DEFAULT_PORT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("honors a valid MCP_APP_ORIGIN_PORT, including an explicit 0", () => {
|
it("honors a valid MCP_APP_ORIGIN_PORT, including an explicit 0", () => {
|
||||||
process.env.MCP_APP_ORIGIN_PORT = "9200";
|
process.env.MCP_APP_ORIGIN_PORT = "9200";
|
||||||
expect(resolveAppOriginPort()).toBe(9200);
|
expect(resolveAppOriginPort()).toBe(9200);
|
||||||
|
|
@ -41,7 +55,7 @@ describe("resolveAppOriginPort", () => {
|
||||||
expect(resolveAppOriginPort()).toBe(0);
|
expect(resolveAppOriginPort()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["6276abc", "70000", "-1", "nope", " "])(
|
it.each(["6278abc", "70000", "-1", "nope", " "])(
|
||||||
"warns and falls back for an invalid value %j",
|
"warns and falls back for an invalid value %j",
|
||||||
(value) => {
|
(value) => {
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
@ -357,7 +371,7 @@ describe("createAppOriginController", () => {
|
||||||
address: () => ({
|
address: () => ({
|
||||||
address: "127.0.0.1",
|
address: "127.0.0.1",
|
||||||
family: "IPv4",
|
family: "IPv4",
|
||||||
port: 6276,
|
port: 6278,
|
||||||
}),
|
}),
|
||||||
close: (cb?: () => void) => cb?.(),
|
close: (cb?: () => void) => cb?.(),
|
||||||
});
|
});
|
||||||
|
|
@ -368,11 +382,11 @@ describe("createAppOriginController", () => {
|
||||||
try {
|
try {
|
||||||
const mod = await import("../../../../server/app-origin-controller.js");
|
const mod = await import("../../../../server/app-origin-controller.js");
|
||||||
const c = mod.createAppOriginController({
|
const c = mod.createAppOriginController({
|
||||||
port: 6276,
|
port: 6278,
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
});
|
});
|
||||||
await c.start();
|
await c.start();
|
||||||
expect(c.getOrigin()).toBe("http://127.0.0.1:6276");
|
expect(c.getOrigin()).toBe("http://127.0.0.1:6278");
|
||||||
expect(c.publish({ html: "<p>x</p>" })).not.toBeNull();
|
expect(c.publish({ html: "<p>x</p>" })).not.toBeNull();
|
||||||
|
|
||||||
emitter.emit(
|
emitter.emit(
|
||||||
|
|
|
||||||
|
|
@ -303,13 +303,13 @@ describe("buildWebServerConfigFromEnv", () => {
|
||||||
expect(buildWebServerConfigFromEnv().sandboxPort).toBe(9100);
|
expect(buildWebServerConfigFromEnv().sandboxPort).toBe(9100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves appOriginPort from MCP_APP_ORIGIN_PORT, defaulting to 6276", () => {
|
it("resolves appOriginPort from MCP_APP_ORIGIN_PORT, defaulting to 6278", () => {
|
||||||
// The dedicated app origin (#2056). Deliberately NOT sharing the sandbox's
|
// The dedicated app origin (#2056). Deliberately NOT sharing the sandbox's
|
||||||
// SERVER_PORT fallback: it is a different listener and must be pinnable
|
// SERVER_PORT fallback: it is a different listener and must be pinnable
|
||||||
// (and forwardable) on its own.
|
// (and forwardable) on its own.
|
||||||
delete process.env.MCP_APP_ORIGIN_PORT;
|
delete process.env.MCP_APP_ORIGIN_PORT;
|
||||||
process.env.SERVER_PORT = "9100";
|
process.env.SERVER_PORT = "9100";
|
||||||
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(6276);
|
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(6278);
|
||||||
process.env.MCP_APP_ORIGIN_PORT = "9300";
|
process.env.MCP_APP_ORIGIN_PORT = "9300";
|
||||||
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(9300);
|
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(9300);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import type { LogEvent } from "pino";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { Context, Env, Next } from "hono";
|
import type { Context, Env, Next } from "hono";
|
||||||
import { streamSSE } from "hono/streaming";
|
import { streamSSE } from "hono/streaming";
|
||||||
|
import { bodyLimit } from "hono/body-limit";
|
||||||
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
||||||
import { createTransportNode } from "../../node/transport.js";
|
import { createTransportNode } from "../../node/transport.js";
|
||||||
import { createProxyFetch } from "../../node/proxyFetch.js";
|
import { createProxyFetch } from "../../node/proxyFetch.js";
|
||||||
|
|
@ -306,6 +307,41 @@ export interface RemoteServerOptions {
|
||||||
*/
|
*/
|
||||||
const MAX_APP_DOCUMENT_CHARS = 8 * 1024 * 1024;
|
const MAX_APP_DOCUMENT_CHARS = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound on the request BODY, in bytes, enforced by `bodyLimit` *before*
|
||||||
|
* anything is parsed. {@link MAX_APP_DOCUMENT_CHARS} alone is not enough: it is
|
||||||
|
* checked after `c.req.json()` has already buffered and parsed the whole
|
||||||
|
* request, so an unbounded body — or one whose bulk sits in `csp` or in keys
|
||||||
|
* the route never reads — is fully materialized in memory before being
|
||||||
|
* rejected. Generous relative to the document bound because JSON escaping
|
||||||
|
* inflates the payload (`<` stays one byte, but a `"` or a newline becomes
|
||||||
|
* two, and non-BMP text more), so this must not reject a document the char
|
||||||
|
* check would accept.
|
||||||
|
*/
|
||||||
|
const MAX_APP_DOCUMENT_BODY_BYTES = 24 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound on the per-app CSP policy string. Unlike the document, this is
|
||||||
|
* retained for the life of the published entry, and a real policy is a few
|
||||||
|
* hundred characters — an app pushing more is not describing sources.
|
||||||
|
*/
|
||||||
|
const MAX_APP_CSP_CHARS = 8 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a string is safe to emit as an HTTP header VALUE.
|
||||||
|
*
|
||||||
|
* The published `csp` is handed to `res.writeHead()` verbatim by the app-origin
|
||||||
|
* controller. Node validates header values there and throws
|
||||||
|
* `ERR_INVALID_CHAR` **synchronously inside the request handler** — outside
|
||||||
|
* this route's error handling, on a request this route is not even part of —
|
||||||
|
* so a CR/LF or NUL accepted here surfaces later as a thrown listener rather
|
||||||
|
* than a 400. Restrict to tab plus printable ASCII, which is stricter than RFC
|
||||||
|
* 9110 field-value grammar and is all a CSP policy needs.
|
||||||
|
*/
|
||||||
|
function isHeaderSafeValue(value: string): boolean {
|
||||||
|
return !/[^\t\x20-\x7e]/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateRemoteAppResult {
|
export interface CreateRemoteAppResult {
|
||||||
/** The Hono app */
|
/** The Hono app */
|
||||||
app: Hono;
|
app: Hono;
|
||||||
|
|
@ -744,44 +780,61 @@ export function createRemoteApp(
|
||||||
* listener on its own port, which is what makes the app's origin real
|
* listener on its own port, which is what makes the app's origin real
|
||||||
* instead of opaque.
|
* instead of opaque.
|
||||||
*/
|
*/
|
||||||
app.post("/api/app-document", async (c) => {
|
app.post(
|
||||||
const publish = options.publishAppDocument;
|
"/api/app-document",
|
||||||
if (!publish) {
|
// Before the parse, not after: see MAX_APP_DOCUMENT_BODY_BYTES.
|
||||||
return c.json({ error: "App-origin hosting is not available" }, 503);
|
bodyLimit({
|
||||||
}
|
maxSize: MAX_APP_DOCUMENT_BODY_BYTES,
|
||||||
let parsed: unknown;
|
onError: (c) => c.json({ error: "Document too large" }, 413),
|
||||||
try {
|
}),
|
||||||
parsed = await c.req.json();
|
async (c) => {
|
||||||
} catch {
|
const publish = options.publishAppDocument;
|
||||||
return c.json({ error: "Invalid JSON body" }, 400);
|
if (!publish) {
|
||||||
}
|
return c.json({ error: "App-origin hosting is not available" }, 503);
|
||||||
// `null` and `[1,2]` are both valid JSON, so `c.req.json()` resolving is
|
}
|
||||||
// not proof there is an object to destructure — and destructuring `null`
|
let parsed: unknown;
|
||||||
// here would throw OUTSIDE the try above, turning a malformed body into a
|
try {
|
||||||
// 500 instead of the 400 every other bad shape gets.
|
parsed = await c.req.json();
|
||||||
if (
|
} catch {
|
||||||
parsed === null ||
|
return c.json({ error: "Invalid JSON body" }, 400);
|
||||||
typeof parsed !== "object" ||
|
}
|
||||||
Array.isArray(parsed)
|
// `null` and `[1,2]` are both valid JSON, so `c.req.json()` resolving is
|
||||||
) {
|
// not proof there is an object to destructure — and destructuring `null`
|
||||||
return c.json({ error: "Invalid JSON body" }, 400);
|
// here would throw OUTSIDE the try above, turning a malformed body into a
|
||||||
}
|
// 500 instead of the 400 every other bad shape gets.
|
||||||
const { html, csp } = parsed as { html?: unknown; csp?: unknown };
|
if (
|
||||||
if (typeof html !== "string" || html === "") {
|
parsed === null ||
|
||||||
return c.json({ error: "Missing html" }, 400);
|
typeof parsed !== "object" ||
|
||||||
}
|
Array.isArray(parsed)
|
||||||
if (csp !== undefined && typeof csp !== "string") {
|
) {
|
||||||
return c.json({ error: "csp must be a string" }, 400);
|
return c.json({ error: "Invalid JSON body" }, 400);
|
||||||
}
|
}
|
||||||
if (html.length > MAX_APP_DOCUMENT_CHARS) {
|
const { html, csp } = parsed as { html?: unknown; csp?: unknown };
|
||||||
return c.json({ error: "Document too large" }, 413);
|
if (typeof html !== "string" || html === "") {
|
||||||
}
|
return c.json({ error: "Missing html" }, 400);
|
||||||
const published = publish({ html, csp });
|
}
|
||||||
if (!published) {
|
if (csp !== undefined && typeof csp !== "string") {
|
||||||
return c.json({ error: "App-origin hosting is not available" }, 503);
|
return c.json({ error: "csp must be a string" }, 400);
|
||||||
}
|
}
|
||||||
return c.json(published);
|
if (csp !== undefined && !isHeaderSafeValue(csp)) {
|
||||||
});
|
// Rejected here rather than sanitized: a policy carrying a control
|
||||||
|
// character is not a policy with a stray byte in it, it is a caller
|
||||||
|
// doing something this route has no honest interpretation of.
|
||||||
|
return c.json({ error: "csp is not a valid header value" }, 400);
|
||||||
|
}
|
||||||
|
if (html.length > MAX_APP_DOCUMENT_CHARS) {
|
||||||
|
return c.json({ error: "Document too large" }, 413);
|
||||||
|
}
|
||||||
|
if (csp !== undefined && csp.length > MAX_APP_CSP_CHARS) {
|
||||||
|
return c.json({ error: "csp too large" }, 413);
|
||||||
|
}
|
||||||
|
const published = publish({ html, csp });
|
||||||
|
if (!published) {
|
||||||
|
return c.json({ error: "App-origin hosting is not available" }, 503);
|
||||||
|
}
|
||||||
|
return c.json(published);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.post("/api/mcp/connect", async (c) => {
|
app.post("/api/mcp/connect", async (c) => {
|
||||||
let body: RemoteConnectRequest;
|
let body: RemoteConnectRequest;
|
||||||
|
|
|
||||||
|
|
@ -159,11 +159,11 @@ launching.
|
||||||
## 6. Reaching the web inspector remotely (SSH port-forward)
|
## 6. Reaching the web inspector remotely (SSH port-forward)
|
||||||
|
|
||||||
When the backend runs on another host and you want to complete OAuth or
|
When the backend runs on another host and you want to complete OAuth or
|
||||||
visually inspect a rendered App from your local browser, forward **both**
|
visually inspect a rendered App from your local browser, forward **all three**
|
||||||
ports:
|
ports:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh -L 6274:127.0.0.1:6274 -L 6275:127.0.0.1:6275 <remote-host>
|
ssh -L 6274:127.0.0.1:6274 -L 6275:127.0.0.1:6275 -L 6278:127.0.0.1:6278 <remote-host>
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open `http://127.0.0.1:6274/?MCP_INSPECTOR_API_TOKEN=$TOKEN` locally.
|
Then open `http://127.0.0.1:6274/?MCP_INSPECTOR_API_TOKEN=$TOKEN` locally.
|
||||||
|
|
@ -178,6 +178,11 @@ Then open `http://127.0.0.1:6274/?MCP_INSPECTOR_API_TOKEN=$TOKEN` locally.
|
||||||
- Forward `:6275` (`MCP_SANDBOX_PORT`) as well: the Apps tab renders widgets in
|
- Forward `:6275` (`MCP_SANDBOX_PORT`) as well: the Apps tab renders widgets in
|
||||||
an iframe served from that second origin; without it the App frame stays
|
an iframe served from that second origin; without it the App frame stays
|
||||||
blank.
|
blank.
|
||||||
|
- Forward `:6278` (`MCP_APP_ORIGIN_PORT`) too, for an App whose UI resource
|
||||||
|
declares `_meta.ui.domain` (#2056). That App is served from a *third* origin
|
||||||
|
the browser loads directly, so without the forward its frame fails to load —
|
||||||
|
and the backend cannot detect that, since publishing succeeded on its side.
|
||||||
|
An App that declares no `domain` is unaffected.
|
||||||
- Forward the **same** local port number you bind on the remote — the browser
|
- Forward the **same** local port number you bind on the remote — the browser
|
||||||
sends `Origin: http://127.0.0.1:<local-port>` and the backend compares it to
|
sends `Origin: http://127.0.0.1:<local-port>` and the backend compares it to
|
||||||
its own port.
|
its own port.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue