map-mcp 0.1.0__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.
- map_mcp-0.1.0/.github/workflows/release.yml +27 -0
- map_mcp-0.1.0/.github/workflows/test.yml +22 -0
- map_mcp-0.1.0/.gitignore +9 -0
- map_mcp-0.1.0/LICENSE +21 -0
- map_mcp-0.1.0/PKG-INFO +132 -0
- map_mcp-0.1.0/README.md +86 -0
- map_mcp-0.1.0/examples/sample_app/index.html +64 -0
- map_mcp-0.1.0/hook/map-mcp-hook.js +176 -0
- map_mcp-0.1.0/hook/snippet.md +37 -0
- map_mcp-0.1.0/pyproject.toml +45 -0
- map_mcp-0.1.0/src/map_mcp/__init__.py +10 -0
- map_mcp-0.1.0/src/map_mcp/bridge.py +202 -0
- map_mcp-0.1.0/src/map_mcp/cli.py +153 -0
- map_mcp-0.1.0/src/map_mcp/coreops.py +114 -0
- map_mcp-0.1.0/src/map_mcp/protocol.py +46 -0
- map_mcp-0.1.0/src/map_mcp/server.py +129 -0
- map_mcp-0.1.0/tests/conftest.py +36 -0
- map_mcp-0.1.0/tests/test_cli.py +54 -0
- map_mcp-0.1.0/tests/test_coreops.py +79 -0
- map_mcp-0.1.0/tests/test_protocol.py +141 -0
- map_mcp-0.1.0/tests/test_scenario.py +149 -0
- map_mcp-0.1.0/tests/test_security.py +36 -0
- map_mcp-0.1.0/tests/test_server.py +64 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
# Tag a release (e.g. `git tag v0.1.0 && git push --tags`) to build and publish to PyPI.
|
|
4
|
+
# Publishing uses PyPI Trusted Publishing (OIDC) — no API token stored in the repo.
|
|
5
|
+
# One-time setup on pypi.org: add a Trusted Publisher for project `map-mcp` with
|
|
6
|
+
# owner `dkedar7`, repository `map-mcp`, workflow `release.yml`, environment `pypi`.
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
tags: ["v*"]
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
release:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
environment: pypi
|
|
16
|
+
permissions:
|
|
17
|
+
id-token: write # required for Trusted Publishing (OIDC)
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
- name: Install uv
|
|
21
|
+
uses: astral-sh/setup-uv@v5
|
|
22
|
+
- name: Run tests
|
|
23
|
+
run: uv run --with pytest pytest -q
|
|
24
|
+
- name: Build
|
|
25
|
+
run: uv build
|
|
26
|
+
- name: Publish to PyPI (Trusted Publishing)
|
|
27
|
+
run: uv publish
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- name: Install uv
|
|
18
|
+
uses: astral-sh/setup-uv@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python-version }}
|
|
21
|
+
- name: Run tests
|
|
22
|
+
run: uv run --with pytest pytest -q
|
map_mcp-0.1.0/.gitignore
ADDED
map_mcp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kedar Dabhadkar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
map_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: map-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drive and perceive an existing live MapLibre GL map from an AI agent (MCP) or a human CLI — no test code, no browser automation.
|
|
5
|
+
Project-URL: Homepage, https://github.com/dkedar7/map-mcp
|
|
6
|
+
Project-URL: Source, https://github.com/dkedar7/map-mcp
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/dkedar7/map-mcp/issues
|
|
8
|
+
Author: Kedar Dabhadkar
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Kedar Dabhadkar
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: agent,geospatial,llm,map,maplibre,mcp,model-context-protocol
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
38
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
39
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
40
|
+
Requires-Python: >=3.10
|
|
41
|
+
Requires-Dist: fastmcp<4,>=3
|
|
42
|
+
Requires-Dist: websockets>=12
|
|
43
|
+
Provides-Extra: dev
|
|
44
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
45
|
+
Description-Content-Type: text/markdown
|
|
46
|
+
|
|
47
|
+
# map-mcp
|
|
48
|
+
|
|
49
|
+
Drive and perceive an **existing, live MapLibre GL map** from an AI agent (over MCP) or a human
|
|
50
|
+
CLI — query the rendered features, read the viewport, click and read popups, navigate, toggle
|
|
51
|
+
layers. The agent and the CLI act on the **same map a person is looking at**, with parity by
|
|
52
|
+
construction.
|
|
53
|
+
|
|
54
|
+
It does **not** generate maps. Other geo-MCP servers (gis-mcp, Mapbox, CARTO) create maps or
|
|
55
|
+
call GIS operations; map-mcp reaches into a map that's *already on screen*. Think of it as the
|
|
56
|
+
agent-native counterpart to [MapGrab](https://github.com/maxlapides/mapgrab): same live-map
|
|
57
|
+
access, but conversational over MCP instead of written as test code.
|
|
58
|
+
|
|
59
|
+
> **Status:** v1, MapLibre GL only. Cooperation-required (your app adds a one-line hook). A
|
|
60
|
+
> no-cooperation path and other map libraries are future work.
|
|
61
|
+
|
|
62
|
+
## Install
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
uvx map-mcp --help # or: pip install map-mcp
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Quickstart
|
|
69
|
+
|
|
70
|
+
1. **Start the bridge + MCP server.** It prints a WebSocket URL and a per-session token.
|
|
71
|
+
```
|
|
72
|
+
map-mcp serve
|
|
73
|
+
```
|
|
74
|
+
2. **Add the hook to your MapLibre page** (`map` is your existing `maplibregl.Map`):
|
|
75
|
+
```html
|
|
76
|
+
<script src="map-mcp-hook.js"></script>
|
|
77
|
+
<script>
|
|
78
|
+
mapMcp.register(map, { url: "ws://127.0.0.1:8765", token: "PASTE_TOKEN" });
|
|
79
|
+
</script>
|
|
80
|
+
```
|
|
81
|
+
3. **Point your agent at the MCP server** (stdio by default; `--transport http` for HTTP/SSE).
|
|
82
|
+
Or drive it yourself from the terminal:
|
|
83
|
+
```
|
|
84
|
+
map-mcp call get_viewport
|
|
85
|
+
map-mcp call query_rendered_features --params '{"point":[12.5,41.9]}'
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
There's a runnable example in [`examples/sample_app/`](examples/sample_app/).
|
|
89
|
+
|
|
90
|
+
## Tools (the operation surface)
|
|
91
|
+
|
|
92
|
+
The agent's MCP tools and the CLI's `call` operations are exactly the same set:
|
|
93
|
+
|
|
94
|
+
| Operation | What it does |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `get_viewport` | center `[lng,lat]`, zoom, bearing, pitch, bounds |
|
|
97
|
+
| `query_rendered_features` | features currently rendered (optionally at a point or within a bbox) |
|
|
98
|
+
| `get_features_at` | features rendered at a `[lng,lat]` point |
|
|
99
|
+
| `click_at` | fire the map's click at a point (runs your popup handlers), return features + popup |
|
|
100
|
+
| `read_popup` | text of any open popup(s) |
|
|
101
|
+
| `set_view` | center+zoom (and bearing/pitch), or fit a bbox |
|
|
102
|
+
| `list_layers` | the style's layers + visibility |
|
|
103
|
+
| `set_layer_visibility` | show/hide a layer |
|
|
104
|
+
| `screenshot` | a PNG data URL of the current map* |
|
|
105
|
+
|
|
106
|
+
Perception returns **structured feature properties** (GeoJSON-shaped) — agents reason over
|
|
107
|
+
properties, not pixels. `screenshot` is optional.
|
|
108
|
+
|
|
109
|
+
\* needs the map created with `preserveDrawingBuffer: true` (see [`hook/snippet.md`](hook/snippet.md)).
|
|
110
|
+
|
|
111
|
+
## How it works
|
|
112
|
+
|
|
113
|
+
The hook connects *out* to a loopback WebSocket the `map-mcp` process runs. The MCP tools and
|
|
114
|
+
the CLI are thin frontends over one shared core-operations layer, so any operation one can do,
|
|
115
|
+
the other can too.
|
|
116
|
+
|
|
117
|
+
**Security model (local-only).** The bridge binds `127.0.0.1` only, so nothing off your machine
|
|
118
|
+
can reach it. Browsers do *not* apply same-origin policy to WebSocket connections, so the
|
|
119
|
+
**per-session token is the security boundary**: only a page that presents it can drive your map.
|
|
120
|
+
Treat the token like a secret — the convenience `?token=` pattern in the example leaks it via
|
|
121
|
+
browser history and server logs, so for anything sensitive paste the token into the page rather
|
|
122
|
+
than the URL. A hardened Origin allowlist is future work.
|
|
123
|
+
|
|
124
|
+
## Scope (v1)
|
|
125
|
+
|
|
126
|
+
- **In:** MapLibre GL; the operations above; stdio + HTTP/SSE; a human CLI with parity.
|
|
127
|
+
- **Out:** generating maps; a hosted service; non-map visualizations; other map libraries
|
|
128
|
+
(Leaflet/deck.gl) and a no-cooperation (Playwright) path are future work.
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT
|
map_mcp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# map-mcp
|
|
2
|
+
|
|
3
|
+
Drive and perceive an **existing, live MapLibre GL map** from an AI agent (over MCP) or a human
|
|
4
|
+
CLI — query the rendered features, read the viewport, click and read popups, navigate, toggle
|
|
5
|
+
layers. The agent and the CLI act on the **same map a person is looking at**, with parity by
|
|
6
|
+
construction.
|
|
7
|
+
|
|
8
|
+
It does **not** generate maps. Other geo-MCP servers (gis-mcp, Mapbox, CARTO) create maps or
|
|
9
|
+
call GIS operations; map-mcp reaches into a map that's *already on screen*. Think of it as the
|
|
10
|
+
agent-native counterpart to [MapGrab](https://github.com/maxlapides/mapgrab): same live-map
|
|
11
|
+
access, but conversational over MCP instead of written as test code.
|
|
12
|
+
|
|
13
|
+
> **Status:** v1, MapLibre GL only. Cooperation-required (your app adds a one-line hook). A
|
|
14
|
+
> no-cooperation path and other map libraries are future work.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
uvx map-mcp --help # or: pip install map-mcp
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quickstart
|
|
23
|
+
|
|
24
|
+
1. **Start the bridge + MCP server.** It prints a WebSocket URL and a per-session token.
|
|
25
|
+
```
|
|
26
|
+
map-mcp serve
|
|
27
|
+
```
|
|
28
|
+
2. **Add the hook to your MapLibre page** (`map` is your existing `maplibregl.Map`):
|
|
29
|
+
```html
|
|
30
|
+
<script src="map-mcp-hook.js"></script>
|
|
31
|
+
<script>
|
|
32
|
+
mapMcp.register(map, { url: "ws://127.0.0.1:8765", token: "PASTE_TOKEN" });
|
|
33
|
+
</script>
|
|
34
|
+
```
|
|
35
|
+
3. **Point your agent at the MCP server** (stdio by default; `--transport http` for HTTP/SSE).
|
|
36
|
+
Or drive it yourself from the terminal:
|
|
37
|
+
```
|
|
38
|
+
map-mcp call get_viewport
|
|
39
|
+
map-mcp call query_rendered_features --params '{"point":[12.5,41.9]}'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
There's a runnable example in [`examples/sample_app/`](examples/sample_app/).
|
|
43
|
+
|
|
44
|
+
## Tools (the operation surface)
|
|
45
|
+
|
|
46
|
+
The agent's MCP tools and the CLI's `call` operations are exactly the same set:
|
|
47
|
+
|
|
48
|
+
| Operation | What it does |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `get_viewport` | center `[lng,lat]`, zoom, bearing, pitch, bounds |
|
|
51
|
+
| `query_rendered_features` | features currently rendered (optionally at a point or within a bbox) |
|
|
52
|
+
| `get_features_at` | features rendered at a `[lng,lat]` point |
|
|
53
|
+
| `click_at` | fire the map's click at a point (runs your popup handlers), return features + popup |
|
|
54
|
+
| `read_popup` | text of any open popup(s) |
|
|
55
|
+
| `set_view` | center+zoom (and bearing/pitch), or fit a bbox |
|
|
56
|
+
| `list_layers` | the style's layers + visibility |
|
|
57
|
+
| `set_layer_visibility` | show/hide a layer |
|
|
58
|
+
| `screenshot` | a PNG data URL of the current map* |
|
|
59
|
+
|
|
60
|
+
Perception returns **structured feature properties** (GeoJSON-shaped) — agents reason over
|
|
61
|
+
properties, not pixels. `screenshot` is optional.
|
|
62
|
+
|
|
63
|
+
\* needs the map created with `preserveDrawingBuffer: true` (see [`hook/snippet.md`](hook/snippet.md)).
|
|
64
|
+
|
|
65
|
+
## How it works
|
|
66
|
+
|
|
67
|
+
The hook connects *out* to a loopback WebSocket the `map-mcp` process runs. The MCP tools and
|
|
68
|
+
the CLI are thin frontends over one shared core-operations layer, so any operation one can do,
|
|
69
|
+
the other can too.
|
|
70
|
+
|
|
71
|
+
**Security model (local-only).** The bridge binds `127.0.0.1` only, so nothing off your machine
|
|
72
|
+
can reach it. Browsers do *not* apply same-origin policy to WebSocket connections, so the
|
|
73
|
+
**per-session token is the security boundary**: only a page that presents it can drive your map.
|
|
74
|
+
Treat the token like a secret — the convenience `?token=` pattern in the example leaks it via
|
|
75
|
+
browser history and server logs, so for anything sensitive paste the token into the page rather
|
|
76
|
+
than the URL. A hardened Origin allowlist is future work.
|
|
77
|
+
|
|
78
|
+
## Scope (v1)
|
|
79
|
+
|
|
80
|
+
- **In:** MapLibre GL; the operations above; stdio + HTTP/SSE; a human CLI with parity.
|
|
81
|
+
- **Out:** generating maps; a hosted service; non-map visualizations; other map libraries
|
|
82
|
+
(Leaflet/deck.gl) and a no-cooperation (Playwright) path are future work.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<!--
|
|
3
|
+
map-mcp sample app — a minimal hooked MapLibre GL map for the first validated scenario.
|
|
4
|
+
|
|
5
|
+
Run:
|
|
6
|
+
1) map-mcp serve # prints ws url + token
|
|
7
|
+
2) serve this folder: python -m http.server 8080
|
|
8
|
+
3) open http://localhost:8080/?token=PASTE_TOKEN
|
|
9
|
+
(the page reads ?token= and registers the live map with the bridge)
|
|
10
|
+
|
|
11
|
+
It shows a basemap + a points layer with clickable popups, created with
|
|
12
|
+
preserveDrawingBuffer:true so the screenshot tool works.
|
|
13
|
+
-->
|
|
14
|
+
<html>
|
|
15
|
+
<head>
|
|
16
|
+
<meta charset="utf-8" />
|
|
17
|
+
<title>map-mcp sample</title>
|
|
18
|
+
<script src="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.js"></script>
|
|
19
|
+
<link href="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.css" rel="stylesheet" />
|
|
20
|
+
<script src="../../hook/map-mcp-hook.js"></script>
|
|
21
|
+
<style>html, body, #map { height: 100%; margin: 0; }</style>
|
|
22
|
+
</head>
|
|
23
|
+
<body>
|
|
24
|
+
<div id="map"></div>
|
|
25
|
+
<script>
|
|
26
|
+
const map = new maplibregl.Map({
|
|
27
|
+
container: "map",
|
|
28
|
+
style: "https://demotiles.maplibre.org/style.json",
|
|
29
|
+
center: [12.5, 41.9],
|
|
30
|
+
zoom: 4,
|
|
31
|
+
preserveDrawingBuffer: true, // required for the screenshot tool
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const cities = {
|
|
35
|
+
type: "FeatureCollection",
|
|
36
|
+
features: [
|
|
37
|
+
{ type: "Feature", properties: { name: "Rome", pop: 2800000 },
|
|
38
|
+
geometry: { type: "Point", coordinates: [12.5, 41.9] } },
|
|
39
|
+
{ type: "Feature", properties: { name: "Berlin", pop: 3600000 },
|
|
40
|
+
geometry: { type: "Point", coordinates: [13.4, 52.5] } },
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
map.on("load", () => {
|
|
45
|
+
map.addSource("cities", { type: "geojson", data: cities });
|
|
46
|
+
map.addLayer({
|
|
47
|
+
id: "cities", type: "circle", source: "cities",
|
|
48
|
+
paint: { "circle-radius": 7, "circle-color": "#c33" },
|
|
49
|
+
});
|
|
50
|
+
map.on("click", "cities", (e) => {
|
|
51
|
+
const f = e.features[0];
|
|
52
|
+
new maplibregl.Popup()
|
|
53
|
+
.setLngLat(f.geometry.coordinates)
|
|
54
|
+
.setHTML(`<b>${f.properties.name}</b><br/>pop ${f.properties.pop}`)
|
|
55
|
+
.addTo(map);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Register the live map with the bridge (token from ?token=).
|
|
59
|
+
const token = new URLSearchParams(location.search).get("token") || "";
|
|
60
|
+
mapMcp.register(map, { url: "ws://127.0.0.1:8765", token });
|
|
61
|
+
});
|
|
62
|
+
</script>
|
|
63
|
+
</body>
|
|
64
|
+
</html>
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* map-mcp hook — expose a live MapLibre GL map to the local map-mcp bridge.
|
|
3
|
+
*
|
|
4
|
+
* Add this file to your page and register your map:
|
|
5
|
+
*
|
|
6
|
+
* <script src="map-mcp-hook.js"></script>
|
|
7
|
+
* <script>
|
|
8
|
+
* mapMcp.register(map, { url: "ws://127.0.0.1:8765", token: "PASTE_TOKEN" });
|
|
9
|
+
* </script>
|
|
10
|
+
*
|
|
11
|
+
* The hook connects out to the bridge, presents the token (see snippet.md), and answers
|
|
12
|
+
* requests by calling the map's own JS API. Vanilla JS, no build step. Drives an EXISTING
|
|
13
|
+
* map — it never creates one. An unknown method returns an error reply; the hook never throws
|
|
14
|
+
* across the socket.
|
|
15
|
+
*
|
|
16
|
+
* Note: `screenshot` needs the map created with `preserveDrawingBuffer: true`, or a WebGL
|
|
17
|
+
* canvas reads back blank. See snippet.md.
|
|
18
|
+
*/
|
|
19
|
+
(function (global) {
|
|
20
|
+
"use strict";
|
|
21
|
+
|
|
22
|
+
// --- method -> MapLibre operation ----------------------------------------
|
|
23
|
+
var HANDLERS = {
|
|
24
|
+
get_viewport: function (map) {
|
|
25
|
+
var c = map.getCenter();
|
|
26
|
+
var b = map.getBounds();
|
|
27
|
+
return {
|
|
28
|
+
center: [c.lng, c.lat],
|
|
29
|
+
zoom: map.getZoom(),
|
|
30
|
+
bearing: map.getBearing(),
|
|
31
|
+
pitch: map.getPitch(),
|
|
32
|
+
bounds: b ? b.toArray() : null, // [[swLng,swLat],[neLng,neLat]]
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
query_rendered_features: function (map, p) {
|
|
37
|
+
p = p || {};
|
|
38
|
+
var geom = _geometry(map, p); // undefined => whole viewport
|
|
39
|
+
var opts = p.layers ? { layers: p.layers } : undefined;
|
|
40
|
+
var feats = map.queryRenderedFeatures(geom, opts) || [];
|
|
41
|
+
var limit = p.limit || 200;
|
|
42
|
+
return { features: feats.slice(0, limit).map(_serializeFeature), truncated: feats.length > limit };
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
get_features_at: function (map, p) {
|
|
46
|
+
var pt = map.project(_lngLat(p)); // geographic -> pixel
|
|
47
|
+
var feats = map.queryRenderedFeatures(pt, p.layers ? { layers: p.layers } : undefined) || [];
|
|
48
|
+
return { features: feats.map(_serializeFeature) };
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
click_at: function (map, p) {
|
|
52
|
+
var lngLat = _lngLat(p);
|
|
53
|
+
var pt = map.project(lngLat);
|
|
54
|
+
// fire the map's own click so app handlers (popups, selection) run
|
|
55
|
+
map.fire("click", { lngLat: lngLat, point: pt, originalEvent: {} });
|
|
56
|
+
var feats = map.queryRenderedFeatures(pt, p.layers ? { layers: p.layers } : undefined) || [];
|
|
57
|
+
return { features: feats.map(_serializeFeature), popup: _readPopup() };
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
read_popup: function () {
|
|
61
|
+
return { popups: _readPopup() };
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
set_view: function (map, p) {
|
|
65
|
+
p = p || {};
|
|
66
|
+
if (p.bbox) {
|
|
67
|
+
map.fitBounds(p.bbox, { animate: false });
|
|
68
|
+
} else {
|
|
69
|
+
var to = {};
|
|
70
|
+
if (p.center) to.center = p.center;
|
|
71
|
+
if (p.zoom != null) to.zoom = p.zoom;
|
|
72
|
+
if (p.bearing != null) to.bearing = p.bearing;
|
|
73
|
+
if (p.pitch != null) to.pitch = p.pitch;
|
|
74
|
+
map.jumpTo(to);
|
|
75
|
+
}
|
|
76
|
+
return HANDLERS.get_viewport(map);
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
list_layers: function (map) {
|
|
80
|
+
var layers = (map.getStyle() && map.getStyle().layers) || [];
|
|
81
|
+
return {
|
|
82
|
+
layers: layers.map(function (l) {
|
|
83
|
+
var vis;
|
|
84
|
+
try { vis = map.getLayoutProperty(l.id, "visibility"); } catch (e) { vis = undefined; }
|
|
85
|
+
return { id: l.id, type: l.type, source: l.source, visibility: vis || "visible" };
|
|
86
|
+
}),
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
set_layer_visibility: function (map, p) {
|
|
91
|
+
var vis = p.visible ? "visible" : "none";
|
|
92
|
+
map.setLayoutProperty(p.layer, "visibility", vis);
|
|
93
|
+
return { layer: p.layer, visibility: vis };
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
screenshot: function (map) {
|
|
97
|
+
var canvas = map.getCanvas();
|
|
98
|
+
var data;
|
|
99
|
+
try { data = canvas.toDataURL("image/png"); } catch (e) { data = null; }
|
|
100
|
+
if (!data || data.length < 64) {
|
|
101
|
+
throw new Error("screenshot unavailable — create the map with preserveDrawingBuffer:true");
|
|
102
|
+
}
|
|
103
|
+
return { image: data, width: canvas.width, height: canvas.height };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// --- helpers --------------------------------------------------------------
|
|
108
|
+
function _lngLat(p) {
|
|
109
|
+
if (!p || !p.point) throw new Error("expected params.point as [lng, lat]");
|
|
110
|
+
return { lng: p.point[0], lat: p.point[1] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function _geometry(map, p) {
|
|
114
|
+
if (p.point) return map.project(_lngLat(p));
|
|
115
|
+
if (p.bbox) {
|
|
116
|
+
var sw = map.project({ lng: p.bbox[0][0], lat: p.bbox[0][1] });
|
|
117
|
+
var ne = map.project({ lng: p.bbox[1][0], lat: p.bbox[1][1] });
|
|
118
|
+
return [sw, ne];
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function _serializeFeature(f) {
|
|
124
|
+
return {
|
|
125
|
+
id: f.id != null ? f.id : null,
|
|
126
|
+
layer: f.layer ? f.layer.id : null,
|
|
127
|
+
source: f.source != null ? f.source : null,
|
|
128
|
+
sourceLayer: f.sourceLayer != null ? f.sourceLayer : null,
|
|
129
|
+
properties: f.properties || {},
|
|
130
|
+
geometryType: f.geometry ? f.geometry.type : null,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _readPopup() {
|
|
135
|
+
var nodes = document.querySelectorAll(".maplibregl-popup-content, .mapboxgl-popup-content");
|
|
136
|
+
return Array.prototype.map.call(nodes, function (n) {
|
|
137
|
+
return (n.textContent || "").trim();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- transport ------------------------------------------------------------
|
|
142
|
+
function register(map, opts) {
|
|
143
|
+
opts = opts || {};
|
|
144
|
+
var url = opts.url || "ws://127.0.0.1:8765";
|
|
145
|
+
var ws = new WebSocket(url);
|
|
146
|
+
|
|
147
|
+
ws.onopen = function () {
|
|
148
|
+
// U6: present the per-session token before commands are accepted.
|
|
149
|
+
if (opts.token) ws.send(JSON.stringify({ type: "hello", token: opts.token }));
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
ws.onmessage = function (ev) {
|
|
153
|
+
var req;
|
|
154
|
+
try { req = JSON.parse(ev.data); } catch (e) { return; }
|
|
155
|
+
if (!req || req.id == null) return;
|
|
156
|
+
var reply = { id: req.id, ok: true, result: null };
|
|
157
|
+
try {
|
|
158
|
+
var handler = HANDLERS[req.method];
|
|
159
|
+
if (!handler) {
|
|
160
|
+
reply.ok = false;
|
|
161
|
+
reply.error = "unknown method: " + req.method;
|
|
162
|
+
} else {
|
|
163
|
+
reply.result = handler(map, req.params || {});
|
|
164
|
+
}
|
|
165
|
+
} catch (e) {
|
|
166
|
+
reply.ok = false;
|
|
167
|
+
reply.error = String(e && e.message ? e.message : e);
|
|
168
|
+
}
|
|
169
|
+
ws.send(JSON.stringify(reply));
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return ws;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
global.mapMcp = { register: register, _handlers: HANDLERS };
|
|
176
|
+
})(typeof window !== "undefined" ? window : this);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Adding the map-mcp hook to your MapLibre app
|
|
2
|
+
|
|
3
|
+
map-mcp drives an **existing** map — you add one snippet so it can reach your live map
|
|
4
|
+
instance. Cooperation is required (v1); a no-cooperation path is deferred.
|
|
5
|
+
|
|
6
|
+
## 1. Start the bridge
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
map-mcp serve
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
It prints a `ws://127.0.0.1:<port>` URL and a **per-session token** (the token gate is
|
|
13
|
+
local-only security — only a page presenting it can drive your map).
|
|
14
|
+
|
|
15
|
+
## 2. Add the hook to your page
|
|
16
|
+
|
|
17
|
+
```html
|
|
18
|
+
<script src="map-mcp-hook.js"></script>
|
|
19
|
+
<script>
|
|
20
|
+
// `map` is your existing maplibregl.Map instance.
|
|
21
|
+
mapMcp.register(map, { url: "ws://127.0.0.1:8765", token: "PASTE_TOKEN_FROM_SERVE" });
|
|
22
|
+
</script>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
That's it. The agent (over MCP) and the `map-mcp` CLI now act on the same map you're looking at.
|
|
26
|
+
|
|
27
|
+
## Notes
|
|
28
|
+
|
|
29
|
+
- **Screenshots** need the map created with `preserveDrawingBuffer: true`:
|
|
30
|
+
```js
|
|
31
|
+
const map = new maplibregl.Map({ /* ... */, preserveDrawingBuffer: true });
|
|
32
|
+
```
|
|
33
|
+
Without it, a WebGL canvas reads back blank and the `screenshot` tool returns an error.
|
|
34
|
+
- Geographic inputs (`point`, `bbox`, `center`) are `[lng, lat]`. The hook projects them to
|
|
35
|
+
pixels for `queryRenderedFeatures` itself.
|
|
36
|
+
- The hook never creates a map and never posts anywhere — it answers read/drive requests on the
|
|
37
|
+
loopback socket only.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "map-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Drive and perceive an existing live MapLibre GL map from an AI agent (MCP) or a human CLI — no test code, no browser automation."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { file = "LICENSE" }
|
|
8
|
+
authors = [{ name = "Kedar Dabhadkar" }]
|
|
9
|
+
keywords = ["maplibre", "map", "mcp", "model-context-protocol", "agent", "llm", "geospatial"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.10",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
18
|
+
"Topic :: Software Development :: Libraries",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"fastmcp>=3,<4",
|
|
22
|
+
"websockets>=12",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/dkedar7/map-mcp"
|
|
27
|
+
Source = "https://github.com/dkedar7/map-mcp"
|
|
28
|
+
"Bug Tracker" = "https://github.com/dkedar7/map-mcp/issues"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
map-mcp = "map_mcp.cli:_cli"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = ["pytest>=7"]
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["hatchling"]
|
|
38
|
+
build-backend = "hatchling.build"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/map_mcp"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
pythonpath = ["src"]
|
|
45
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""map-mcp — drive and perceive an existing live MapLibre GL map from an agent or a human CLI.
|
|
2
|
+
|
|
3
|
+
A map app opts in with a small JS hook that registers its live map and connects out to a
|
|
4
|
+
local WebSocket this process runs. The MCP tools (for agents) and the CLI (for humans) are
|
|
5
|
+
thin frontends over one shared core-operations layer, so any operation one can do, the other
|
|
6
|
+
can too (parity by construction). v1 is MapLibre-only and drives *existing* maps — it does not
|
|
7
|
+
generate maps.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|