nds-mapviewer 2026.4.4.dev270__tar.gz → 2026.4.5.dev294__tar.gz

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.
Files changed (32) hide show
  1. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/CHANGELOG.md +4 -0
  2. {nds_mapviewer-2026.4.4.dev270/src/nds_mapviewer.egg-info → nds_mapviewer-2026.4.5.dev294}/PKG-INFO +53 -4
  3. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/README.md +52 -3
  4. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294/src/nds_mapviewer.egg-info}/PKG-INFO +53 -4
  5. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/src/ndslive/mapviewer/browser.py +13 -4
  6. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/src/ndslive/mapviewer/cli.py +42 -5
  7. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/src/ndslive/mapviewer/clipboard.py +3 -3
  8. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/src/ndslive/mapviewer/config.py +29 -4
  9. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/src/ndslive/mapviewer/container_runtime.py +12 -6
  10. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/src/ndslive/mapviewer/docker_client.py +51 -15
  11. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/src/ndslive/mapviewer/exceptions.py +1 -1
  12. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/src/ndslive/mapviewer/tui.py +93 -4
  13. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/src/ndslive/mapviewer/viewer.py +82 -11
  14. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/tests/test_cli.py +84 -15
  15. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/tests/test_docker_client.py +9 -0
  16. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/tests/test_tui.py +27 -0
  17. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/tests/test_viewer.py +75 -0
  18. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/.gitignore +0 -0
  19. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/pyproject.toml +0 -0
  20. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/setup.cfg +0 -0
  21. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/setup.py +0 -0
  22. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/src/nds_mapviewer.egg-info/SOURCES.txt +0 -0
  23. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/src/nds_mapviewer.egg-info/dependency_links.txt +0 -0
  24. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/src/nds_mapviewer.egg-info/entry_points.txt +0 -0
  25. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/src/nds_mapviewer.egg-info/requires.txt +0 -0
  26. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/src/nds_mapviewer.egg-info/scm_file_list.json +0 -0
  27. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/src/nds_mapviewer.egg-info/top_level.txt +0 -0
  28. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/src/ndslive/mapviewer/__init__.py +0 -0
  29. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/src/ndslive/mapviewer/ui.py +0 -0
  30. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/tests/conftest.py +0 -0
  31. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/tests/test_clipboard.py +0 -0
  32. {nds_mapviewer-2026.4.4.dev270 → nds_mapviewer-2026.4.5.dev294}/tests/test_config.py +0 -0
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ### Added
11
11
 
12
+ - Enable unauthenticated MCP through the launcher with `--mcp-local`, using
13
+ ordinary bind/network settings and the selected host port for discovery
14
+ (requires a supporting container image).
15
+ - Set a public MCP URL with `--mcp-endpoint` for LAN access.
12
16
  - `--debug` flag to enable container debug logging (sets `HTTP_LOG_LEVEL=debug`)
13
17
  - `--env KEY=VALUE` (repeatable, short `-E`) to pass custom environment
14
18
  variables into the container; overrides `--debug` on key collision
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nds-mapviewer
3
- Version: 2026.4.4.dev270
3
+ Version: 2026.4.5.dev294
4
4
  Summary: CLI to run the NDS MapViewer Docker container for visualizing map data (NDS.Live, GeoJSON, and more)
5
5
  Author-email: NDS Association <support@nds-association.org>
6
6
  License: Proprietary
@@ -121,9 +121,55 @@ appear in the Docker screen's "Copy Command" output.
121
121
 
122
122
  `--network NAME` attaches the container to a specific network (e.g. a custom
123
123
  bridge or `host`). With `--network host` the container binds the host's port
124
- directly, so `--port`/`--bind` are ignored and the MapViewer is reached on its
125
- internal port (8089). Host networking behaves differently across platforms and
126
- runtimes — it is intended as a connectivity workaround.
124
+ directly, so `--port` is ignored and the MapViewer is reached on its internal
125
+ port (8089). With `--mcp-local`, `--bind` selects the native listener in this mode;
126
+ otherwise it is ignored. Host networking behaves differently across platforms
127
+ and runtimes — it is intended as a connectivity workaround.
128
+
129
+ ### Local MCP
130
+
131
+ MCP requires **Member Edition**. Select `--edition member` and use `--mcp-local`
132
+ with a supporting Member image. Community Edition does not provide MCP tools or
133
+ browser-agent access. No peer addresses are needed; ordinary server networking applies:
134
+
135
+ ```bash
136
+ mapviewer --edition member --demo --mcp-local --port 8099
137
+ ```
138
+
139
+ Connect your MCP client to `http://localhost:8099/mcp` and open the viewer at
140
+ `http://localhost:8099` for browser-control tools. Without `--port`, the endpoint
141
+ uses the free port selected by the launcher, shown in the run screen. The Docker
142
+ screen shows the generated native options in its copyable command.
143
+
144
+ For LAN access, set the public URL explicitly so native MCP discovery and its
145
+ default Host/Origin allowlists use the address seen by remote browsers. For
146
+ example, replace `192.168.1.50` with your server's LAN address:
147
+
148
+ ```bash
149
+ mapviewer --edition member --demo --mcp-local --bind 0.0.0.0 --port 8099 --no-browser \
150
+ --mcp-endpoint http://192.168.1.50:8099/mcp
151
+ ```
152
+
153
+ Open `http://192.168.1.50:8099` and connect the MCP client to that URL plus `/mcp`.
154
+ The override describes the public URL; it does not change the listener, enable
155
+ TLS or change the launcher's ordinary localhost browser URL/readiness probe.
156
+ `--mcp-endpoint` requires `--mcp-local`; the native server validates URL syntax.
157
+
158
+ **Local MCP is unauthenticated. Everyone who can reach the server shares access
159
+ to its enabled MCP tools.** The name `local` does not enforce loopback access.
160
+ The ordinary `--bind` and `--network` settings control reachability: the default
161
+ bind is `127.0.0.1`; `--bind 0.0.0.0` or the TUI's **Allow network access** checkbox
162
+ exposes the published port on all host interfaces. Use firewall, container or
163
+ proxy policy to limit access, or OAuth when authentication is required. See the
164
+ [container setup](../docs/mv-mcp-workflows.md) and
165
+ [native MCP documentation](../deps/mapget/docs/mapget-mcp.md).
166
+
167
+ On hosts supporting host networking, `mapviewer --edition member --demo --network host --mcp-local`
168
+ instead uses port 8089 directly and honors `--bind` for the native listener.
169
+ The Python API accepts `edition="member"`, `mcp_local=True` and optional
170
+ `mcp_endpoint="http://host:port/mcp"` arguments to `MapViewer`.
171
+
172
+ ### Local configuration
127
173
 
128
174
  If `~/.nds/config.yaml` exists, `mapviewer` (with no arguments) loads it
129
175
  automatically and goes straight to the RunScreen.
@@ -150,6 +196,9 @@ durable map-preset write target.
150
196
  | **community** | NDS.Live, GeoJSON, open formats | Free — [nds.live](https://nds.live) |
151
197
  | **member** | + NDS.Classic | [NDS membership](https://nds-association.org/#contact) |
152
198
 
199
+ MCP server access, browser-agent tools and MCP tunnels are available only in
200
+ Member Edition. Selecting Community with `--mcp-local` is rejected before launch.
201
+
153
202
  ## Open-Source Building Blocks
154
203
 
155
204
  - **[mapget](https://github.com/ndsev/mapget)** — tile-based map data server with caching
@@ -88,9 +88,55 @@ appear in the Docker screen's "Copy Command" output.
88
88
 
89
89
  `--network NAME` attaches the container to a specific network (e.g. a custom
90
90
  bridge or `host`). With `--network host` the container binds the host's port
91
- directly, so `--port`/`--bind` are ignored and the MapViewer is reached on its
92
- internal port (8089). Host networking behaves differently across platforms and
93
- runtimes — it is intended as a connectivity workaround.
91
+ directly, so `--port` is ignored and the MapViewer is reached on its internal
92
+ port (8089). With `--mcp-local`, `--bind` selects the native listener in this mode;
93
+ otherwise it is ignored. Host networking behaves differently across platforms
94
+ and runtimes — it is intended as a connectivity workaround.
95
+
96
+ ### Local MCP
97
+
98
+ MCP requires **Member Edition**. Select `--edition member` and use `--mcp-local`
99
+ with a supporting Member image. Community Edition does not provide MCP tools or
100
+ browser-agent access. No peer addresses are needed; ordinary server networking applies:
101
+
102
+ ```bash
103
+ mapviewer --edition member --demo --mcp-local --port 8099
104
+ ```
105
+
106
+ Connect your MCP client to `http://localhost:8099/mcp` and open the viewer at
107
+ `http://localhost:8099` for browser-control tools. Without `--port`, the endpoint
108
+ uses the free port selected by the launcher, shown in the run screen. The Docker
109
+ screen shows the generated native options in its copyable command.
110
+
111
+ For LAN access, set the public URL explicitly so native MCP discovery and its
112
+ default Host/Origin allowlists use the address seen by remote browsers. For
113
+ example, replace `192.168.1.50` with your server's LAN address:
114
+
115
+ ```bash
116
+ mapviewer --edition member --demo --mcp-local --bind 0.0.0.0 --port 8099 --no-browser \
117
+ --mcp-endpoint http://192.168.1.50:8099/mcp
118
+ ```
119
+
120
+ Open `http://192.168.1.50:8099` and connect the MCP client to that URL plus `/mcp`.
121
+ The override describes the public URL; it does not change the listener, enable
122
+ TLS or change the launcher's ordinary localhost browser URL/readiness probe.
123
+ `--mcp-endpoint` requires `--mcp-local`; the native server validates URL syntax.
124
+
125
+ **Local MCP is unauthenticated. Everyone who can reach the server shares access
126
+ to its enabled MCP tools.** The name `local` does not enforce loopback access.
127
+ The ordinary `--bind` and `--network` settings control reachability: the default
128
+ bind is `127.0.0.1`; `--bind 0.0.0.0` or the TUI's **Allow network access** checkbox
129
+ exposes the published port on all host interfaces. Use firewall, container or
130
+ proxy policy to limit access, or OAuth when authentication is required. See the
131
+ [container setup](../docs/mv-mcp-workflows.md) and
132
+ [native MCP documentation](../deps/mapget/docs/mapget-mcp.md).
133
+
134
+ On hosts supporting host networking, `mapviewer --edition member --demo --network host --mcp-local`
135
+ instead uses port 8089 directly and honors `--bind` for the native listener.
136
+ The Python API accepts `edition="member"`, `mcp_local=True` and optional
137
+ `mcp_endpoint="http://host:port/mcp"` arguments to `MapViewer`.
138
+
139
+ ### Local configuration
94
140
 
95
141
  If `~/.nds/config.yaml` exists, `mapviewer` (with no arguments) loads it
96
142
  automatically and goes straight to the RunScreen.
@@ -117,6 +163,9 @@ durable map-preset write target.
117
163
  | **community** | NDS.Live, GeoJSON, open formats | Free — [nds.live](https://nds.live) |
118
164
  | **member** | + NDS.Classic | [NDS membership](https://nds-association.org/#contact) |
119
165
 
166
+ MCP server access, browser-agent tools and MCP tunnels are available only in
167
+ Member Edition. Selecting Community with `--mcp-local` is rejected before launch.
168
+
120
169
  ## Open-Source Building Blocks
121
170
 
122
171
  - **[mapget](https://github.com/ndsev/mapget)** — tile-based map data server with caching
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nds-mapviewer
3
- Version: 2026.4.4.dev270
3
+ Version: 2026.4.5.dev294
4
4
  Summary: CLI to run the NDS MapViewer Docker container for visualizing map data (NDS.Live, GeoJSON, and more)
5
5
  Author-email: NDS Association <support@nds-association.org>
6
6
  License: Proprietary
@@ -121,9 +121,55 @@ appear in the Docker screen's "Copy Command" output.
121
121
 
122
122
  `--network NAME` attaches the container to a specific network (e.g. a custom
123
123
  bridge or `host`). With `--network host` the container binds the host's port
124
- directly, so `--port`/`--bind` are ignored and the MapViewer is reached on its
125
- internal port (8089). Host networking behaves differently across platforms and
126
- runtimes — it is intended as a connectivity workaround.
124
+ directly, so `--port` is ignored and the MapViewer is reached on its internal
125
+ port (8089). With `--mcp-local`, `--bind` selects the native listener in this mode;
126
+ otherwise it is ignored. Host networking behaves differently across platforms
127
+ and runtimes — it is intended as a connectivity workaround.
128
+
129
+ ### Local MCP
130
+
131
+ MCP requires **Member Edition**. Select `--edition member` and use `--mcp-local`
132
+ with a supporting Member image. Community Edition does not provide MCP tools or
133
+ browser-agent access. No peer addresses are needed; ordinary server networking applies:
134
+
135
+ ```bash
136
+ mapviewer --edition member --demo --mcp-local --port 8099
137
+ ```
138
+
139
+ Connect your MCP client to `http://localhost:8099/mcp` and open the viewer at
140
+ `http://localhost:8099` for browser-control tools. Without `--port`, the endpoint
141
+ uses the free port selected by the launcher, shown in the run screen. The Docker
142
+ screen shows the generated native options in its copyable command.
143
+
144
+ For LAN access, set the public URL explicitly so native MCP discovery and its
145
+ default Host/Origin allowlists use the address seen by remote browsers. For
146
+ example, replace `192.168.1.50` with your server's LAN address:
147
+
148
+ ```bash
149
+ mapviewer --edition member --demo --mcp-local --bind 0.0.0.0 --port 8099 --no-browser \
150
+ --mcp-endpoint http://192.168.1.50:8099/mcp
151
+ ```
152
+
153
+ Open `http://192.168.1.50:8099` and connect the MCP client to that URL plus `/mcp`.
154
+ The override describes the public URL; it does not change the listener, enable
155
+ TLS or change the launcher's ordinary localhost browser URL/readiness probe.
156
+ `--mcp-endpoint` requires `--mcp-local`; the native server validates URL syntax.
157
+
158
+ **Local MCP is unauthenticated. Everyone who can reach the server shares access
159
+ to its enabled MCP tools.** The name `local` does not enforce loopback access.
160
+ The ordinary `--bind` and `--network` settings control reachability: the default
161
+ bind is `127.0.0.1`; `--bind 0.0.0.0` or the TUI's **Allow network access** checkbox
162
+ exposes the published port on all host interfaces. Use firewall, container or
163
+ proxy policy to limit access, or OAuth when authentication is required. See the
164
+ [container setup](../docs/mv-mcp-workflows.md) and
165
+ [native MCP documentation](../deps/mapget/docs/mapget-mcp.md).
166
+
167
+ On hosts supporting host networking, `mapviewer --edition member --demo --network host --mcp-local`
168
+ instead uses port 8089 directly and honors `--bind` for the native listener.
169
+ The Python API accepts `edition="member"`, `mcp_local=True` and optional
170
+ `mcp_endpoint="http://host:port/mcp"` arguments to `MapViewer`.
171
+
172
+ ### Local configuration
127
173
 
128
174
  If `~/.nds/config.yaml` exists, `mapviewer` (with no arguments) loads it
129
175
  automatically and goes straight to the RunScreen.
@@ -150,6 +196,9 @@ durable map-preset write target.
150
196
  | **community** | NDS.Live, GeoJSON, open formats | Free — [nds.live](https://nds.live) |
151
197
  | **member** | + NDS.Classic | [NDS membership](https://nds-association.org/#contact) |
152
198
 
199
+ MCP server access, browser-agent tools and MCP tunnels are available only in
200
+ Member Edition. Selecting Community with `--mcp-local` is rejected before launch.
201
+
153
202
  ## Open-Source Building Blocks
154
203
 
155
204
  - **[mapget](https://github.com/ndsev/mapget)** — tile-based map data server with caching
@@ -97,7 +97,10 @@ def is_playwright_available() -> bool:
97
97
 
98
98
 
99
99
  def is_playwright_chromium_installed() -> bool:
100
- """Check if playwright's Chromium browser is installed."""
100
+ """Check whether Playwright exposes a Chromium executable path.
101
+
102
+ This probe does not check that the file exists or that Chromium can launch.
103
+ """
101
104
  if not is_playwright_available():
102
105
  return False
103
106
 
@@ -135,13 +138,19 @@ def open_browser(
135
138
  """
136
139
  Open a URL in a web browser.
137
140
 
141
+ Bundled Chromium blocks until the user presses Enter and is then closed.
142
+ System-browser launches return immediately; they do not verify that the
143
+ page loaded. Missing Playwright or a failed browser-path probe raises before
144
+ launch, while errors during launch/navigation return False.
145
+
138
146
  Args:
139
147
  url: The URL to open
140
148
  use_bundled: Use playwright's bundled Chromium
141
149
  prefer_chromium: Prefer Chromium-based browsers for best performance
142
150
 
143
151
  Returns:
144
- True if the browser was opened successfully
152
+ True if the launch path completed without an exception. The default
153
+ browser's own boolean result is not checked.
145
154
  """
146
155
  # Option 1: Use bundled Chromium via playwright
147
156
  if use_bundled:
@@ -152,7 +161,7 @@ def open_browser(
152
161
  )
153
162
 
154
163
  if not is_playwright_chromium_installed():
155
- # Prompt user or auto-install
164
+ # Installation remains an explicit caller/user action.
156
165
  raise RuntimeError(
157
166
  "Playwright Chromium is not installed. Install with:\n"
158
167
  " playwright install chromium"
@@ -164,7 +173,7 @@ def open_browser(
164
173
  browser = p.chromium.launch(headless=False)
165
174
  page = browser.new_page()
166
175
  page.goto(url)
167
- # Keep browser open - user closes manually
176
+ # Keep the browser open until the user presses Enter.
168
177
  input("Press Enter to close the browser...")
169
178
  browser.close()
170
179
  return True
@@ -28,7 +28,7 @@ def _build_run_subtitle(edition: str, image_version_raw: str | None) -> str:
28
28
 
29
29
 
30
30
  def _stop_existing_container() -> None:
31
- """Stop the current MapViewer container if it is running."""
31
+ """Stop and remove the default MapViewer container if it exists."""
32
32
  from .viewer import MapViewer
33
33
  from .exceptions import ContainerNotFound
34
34
 
@@ -40,7 +40,7 @@ def _stop_existing_container() -> None:
40
40
 
41
41
 
42
42
  def _get_edition_default() -> str:
43
- """Read edition from ndslive-setup config, or detect member access, fallback to community."""
43
+ """Use the edition saved by ndslive-setup, then local member credentials, else community."""
44
44
  setup_config = Path.home() / ".nds" / "setup.json"
45
45
  if setup_config.exists():
46
46
  import json
@@ -109,7 +109,14 @@ def _build_environment(args: argparse.Namespace) -> dict[str, str]:
109
109
  # ---------------------------------------------------------------------------
110
110
 
111
111
  def cmd_main(args: argparse.Namespace) -> int:
112
- """Unified handler: sources → resolve & launch, no sources → HomeScreen."""
112
+ """Resolve launch inputs and run the corresponding TUI until it exits.
113
+
114
+ Demo mode takes precedence. Without explicit sources, try the default then
115
+ legacy config before showing HomeScreen. Explicit inputs combine their
116
+ sources and HTTP settings; retain a config path for live reload only when
117
+ the sole input is a YAML config. Return 1 for source resolution errors and
118
+ 2 for incompatible MCP flags.
119
+ """
113
120
  from .config import (
114
121
  load_config,
115
122
  resolve_config_source,
@@ -133,6 +140,14 @@ def cmd_main(args: argparse.Namespace) -> int:
133
140
  source_args = args.sources
134
141
  environment = _build_environment(args)
135
142
  network = getattr(args, "network", None)
143
+ mcp_local = args.mcp_local
144
+ mcp_endpoint = args.mcp_endpoint
145
+ if mcp_endpoint is not None and not mcp_local:
146
+ console.print("[red]Error:[/red] --mcp-endpoint requires --mcp-local.")
147
+ return 2
148
+ if mcp_local and edition != "member":
149
+ console.print("[red]Error:[/red] MCP requires MapViewer Member Edition. Select --edition member.")
150
+ return 2
136
151
 
137
152
  # --demo → demo config
138
153
  if args.demo:
@@ -146,6 +161,8 @@ def cmd_main(args: argparse.Namespace) -> int:
146
161
  pull_policy=pull_policy,
147
162
  environment=environment,
148
163
  network=network,
164
+ mcp_local=mcp_local,
165
+ mcp_endpoint=mcp_endpoint,
149
166
  run_config=config,
150
167
  run_label="Demo (NDS Islands)",
151
168
  is_demo=True,
@@ -168,6 +185,8 @@ def cmd_main(args: argparse.Namespace) -> int:
168
185
  pull_policy=pull_policy,
169
186
  environment=environment,
170
187
  network=network,
188
+ mcp_local=mcp_local,
189
+ mcp_endpoint=mcp_endpoint,
171
190
  run_config=config,
172
191
  run_config_path=DEFAULT_CONFIG,
173
192
  run_label="~/.nds/config.yaml",
@@ -193,6 +212,8 @@ def cmd_main(args: argparse.Namespace) -> int:
193
212
  pull_policy=pull_policy,
194
213
  environment=environment,
195
214
  network=network,
215
+ mcp_local=mcp_local,
216
+ mcp_endpoint=mcp_endpoint,
196
217
  run_config=config,
197
218
  run_config_path=LEGACY_CONFIG,
198
219
  run_label="~/.nds/mapviewer.yaml",
@@ -211,6 +232,8 @@ def cmd_main(args: argparse.Namespace) -> int:
211
232
  pull_policy=pull_policy,
212
233
  environment=environment,
213
234
  network=network,
235
+ mcp_local=mcp_local,
236
+ mcp_endpoint=mcp_endpoint,
214
237
  )
215
238
  app.run()
216
239
  return 0
@@ -306,6 +329,8 @@ def cmd_main(args: argparse.Namespace) -> int:
306
329
  pull_policy=pull_policy,
307
330
  environment=environment,
308
331
  network=network,
332
+ mcp_local=mcp_local,
333
+ mcp_endpoint=mcp_endpoint,
309
334
  run_config=merged,
310
335
  run_config_path=source_config_path,
311
336
  run_label=source_desc,
@@ -411,8 +436,20 @@ Examples:
411
436
  "--network",
412
437
  metavar="NAME",
413
438
  default=None,
414
- help="Container network to attach (e.g. 'host'). With 'host' the "
415
- "MapViewer binds the host port directly and --port/--bind are ignored.",
439
+ help="Container network to attach (e.g. 'host'). With 'host', --port is "
440
+ "ignored (8089 is used); with --mcp-local, --bind selects the native listener.",
441
+ )
442
+ parser.add_argument(
443
+ "--mcp-local",
444
+ action="store_true",
445
+ help="Enable Member Edition's unauthenticated MCP with one shared user. Anyone who can "
446
+ "reach the server can use its enabled MCP tools; normal --bind/--network settings apply.",
447
+ )
448
+ parser.add_argument(
449
+ "--mcp-endpoint",
450
+ metavar="URL",
451
+ help="Public MCP URL ending in /mcp (requires --mcp-local). Defaults to the "
452
+ "local launcher URL; set it for LAN/public browser access. Does not change the bind address.",
416
453
  )
417
454
 
418
455
  pull_group = parser.add_mutually_exclusive_group()
@@ -4,7 +4,7 @@ Textual's ``App.copy_to_clipboard`` relies on the OSC 52 terminal escape
4
4
  sequence, which many Windows terminals (conhost, some Windows Terminal
5
5
  configs, PowerShell hosts) silently drop — so the copy buttons appear to do
6
6
  nothing. This module copies via the native OS clipboard utility first and
7
- only falls back to OSC 52 when no native tool is available.
7
+ falls back to OSC 52 when native copying is unavailable or fails.
8
8
  """
9
9
 
10
10
  from __future__ import annotations
@@ -65,8 +65,8 @@ def copy_text(text: str, app=None) -> bool:
65
65
  """Copy *text* to the clipboard, returning True on confirmed success.
66
66
 
67
67
  Tries the native OS clipboard utility first (reliable on Windows where
68
- OSC 52 is commonly unsupported). If no native tool is available and an
69
- *app* is given, falls back to Textual's OSC 52 escape — which cannot be
68
+ OSC 52 is commonly unsupported). If native copying fails and an *app* is
69
+ given, falls back to Textual's OSC 52 escape — which cannot be
70
70
  confirmed, so this returns False to let callers warn the user.
71
71
  """
72
72
  if _native_copy(text):
@@ -286,6 +286,15 @@ def transform_config(
286
286
  - Rewrites paths to use container mount points
287
287
  - Transforms localhost references to host.docker.internal
288
288
  - Rewrites CLI-managed erdblick asset paths to static container paths
289
+
290
+ The input is deep-copied. ``base_dir`` resolves relative browser assets;
291
+ local datasource URIs must already contain absolute paths. ``existing_volumes``
292
+ reserves the original container paths during hot reload, including mounts no
293
+ longer referenced. The returned plan may require new mounts: the caller must
294
+ check it against the running container before publishing a reload.
295
+
296
+ ``ephemeral`` marks generated configs so browser preset edits are not offered
297
+ as durable writes to a temporary launcher-owned file.
289
298
  """
290
299
  import copy
291
300
 
@@ -306,6 +315,7 @@ def transform_config(
306
315
  assigned_by_host: dict[tuple[Path, str], str] = dict(existing_by_host)
307
316
 
308
317
  def existing_container_path(host_path: Path, root: str) -> str | None:
318
+ """Reuse a host mapping only within its data/style/background mount category."""
309
319
  container_path = assigned_by_host.get((host_path.expanduser().resolve(), root))
310
320
  if container_path == root or (
311
321
  container_path is not None
@@ -315,6 +325,7 @@ def transform_config(
315
325
  return None
316
326
 
317
327
  def next_container_path(root: str, preferred: str) -> str:
328
+ """Reserve an unused target without displacing mounts from an earlier config."""
318
329
  candidate = preferred
319
330
  if candidate not in used_container_paths:
320
331
  used_container_paths.add(candidate)
@@ -329,12 +340,14 @@ def transform_config(
329
340
  index += 1
330
341
 
331
342
  def assign_volume(host_path: Path, root: str, preferred: str) -> str:
343
+ """Assign one stable destination per canonical host path and mount category."""
332
344
  host_path = host_path.expanduser().resolve()
333
345
  container_path = existing_container_path(host_path, root)
334
346
  if container_path is None:
335
347
  container_path = next_container_path(root, preferred)
336
348
  assigned_by_host[(host_path, root)] = container_path
337
349
  return container_path
350
+
338
351
  local_paths: list[Path] = []
339
352
  path_locations: list[tuple[str, int, str, str]] = [] # (section, index, field, original)
340
353
 
@@ -430,6 +443,7 @@ def transform_config(
430
443
  asset_volume_keys: set[tuple[Path, str]] = set()
431
444
 
432
445
  def add_asset_volume(host_path: Path, root: str, preferred: str) -> str:
446
+ """Deduplicate browser assets while sharing the launcher's target allocation."""
433
447
  resolved_host_path = host_path.expanduser().resolve()
434
448
  container_path = assign_volume(resolved_host_path, root, preferred)
435
449
  key = (resolved_host_path, container_path)
@@ -444,6 +458,7 @@ def transform_config(
444
458
  background_asset_index = 0
445
459
 
446
460
  def transform_additional_style_url(url: str) -> str:
461
+ """Mount local style files or wildcard directories and preserve remote URLs."""
447
462
  nonlocal style_asset_index
448
463
  url = url.strip()
449
464
  if (
@@ -473,6 +488,7 @@ def transform_config(
473
488
  return container_path
474
489
 
475
490
  def transform_background_template(template: str) -> str:
491
+ """Mount the fixed directory prefix and keep XYZ placeholders in the URL suffix."""
476
492
  nonlocal background_asset_index
477
493
  template = template.strip()
478
494
  if (
@@ -569,8 +585,13 @@ def validate_paths(
569
585
  base_dir: str | Path | None = None,
570
586
  ) -> list[str]:
571
587
  """
572
- Validate that all local paths in the config exist.
573
- Returns a list of error messages for missing paths.
588
+ Validate that supported datasource paths and CLI-managed erdblick assets exist.
589
+
590
+ Source URI/folder paths use the working directory for relative paths;
591
+ erdblick assets use ``base_dir``. External/browser asset URLs are skipped,
592
+ and XYZ templates require only their static root directory to exist.
593
+ Return accumulated path/extension errors; this does not validate the full
594
+ configuration schema or the contents of asset files.
574
595
  """
575
596
  errors = []
576
597
  sources = config.get("sources", [])
@@ -831,6 +852,10 @@ def create_service_config(
831
852
  ) -> dict[str, Any]:
832
853
  """Create a config dict for connecting to a remote service.
833
854
 
855
+ HTTP settings apply to all paths on the URL's origin. Only the selected
856
+ authentication method is emitted, and incomplete credentials omit that
857
+ method rather than raising. Proxy settings are independent of authentication.
858
+
834
859
  Args:
835
860
  url: Service URL (e.g., https://api.example.com/openapi.json)
836
861
  service_type: SmartLayer datasource type, see ``detect_service_type``
@@ -941,7 +966,7 @@ def resolve_config_source(arg: str) -> ResolvedConfig | None:
941
966
  """Resolve a CLI argument to a config source.
942
967
 
943
968
  Resolution order:
944
- 1. If arg is an absolute path or starts with ./ → treat as local path
969
+ 1. If arg starts with /, ./ or ../ → resolve only as a local path
945
970
  2. If arg exists as local file/folder → use it
946
971
  3. If ~/.nds/configs/<arg>.yaml exists → use saved config
947
972
  4. Return None if not found
@@ -1024,7 +1049,7 @@ def resolve_config_source(arg: str) -> ResolvedConfig | None:
1024
1049
 
1025
1050
 
1026
1051
  def save_config(config: dict[str, Any], name: str) -> Path:
1027
- """Save a config to the workspace directory.
1052
+ """Write a named YAML config under ``~/.nds/configs``, replacing its contents.
1028
1053
 
1029
1054
  Args:
1030
1055
  config: Config dict to save
@@ -71,10 +71,14 @@ def detect_runtime() -> str:
71
71
 
72
72
  1. Saved ``runtime_name`` in ``~/.nds/setup.json`` (written by ``ndslive-setup``).
73
73
  2. ``CONTAINER_RUNTIME`` environment variable (``docker`` or ``podman``).
74
- 3. ``docker`` on ``$PATH``.
75
- 4. ``podman`` on ``$PATH``.
76
-
77
- Raises :class:`FileNotFoundError` when no runtime is found.
74
+ 3. A responsive installed runtime, checking Docker before Podman.
75
+ 4. The first installed runtime when neither responds, so the caller can
76
+ diagnose a stopped service instead of reporting a missing installation.
77
+
78
+ Saved and environment choices must exist on ``PATH`` but are not probed here.
79
+ An unavailable saved choice falls through; an unavailable explicit environment
80
+ choice raises :class:`FileNotFoundError`, as does finding no runtime at all.
81
+ The result is cached for this process.
78
82
  """
79
83
  saved = _saved_runtime_preference()
80
84
  if saved and _runtime_exists(saved):
@@ -160,8 +164,10 @@ def stream(
160
164
  ) -> subprocess.Popen[str]:
161
165
  """Start a long-running runtime command for line-by-line streaming.
162
166
 
163
- The caller is responsible for reading ``proc.stdout`` and
164
- eventually calling ``proc.terminate()`` / ``proc.wait()``.
167
+ The caller must read ``proc.stdout`` and eventually call ``proc.terminate()``
168
+ and/or ``proc.wait()``. With separate stderr, also read ``proc.stderr`` to
169
+ avoid blocking a verbose subprocess; otherwise request combined output
170
+ with ``merge_stderr``.
165
171
 
166
172
  Parameters
167
173
  ----------