map-mcp 0.1.0__py3-none-any.whl
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/__init__.py +10 -0
- map_mcp/bridge.py +202 -0
- map_mcp/cli.py +153 -0
- map_mcp/coreops.py +114 -0
- map_mcp/protocol.py +46 -0
- map_mcp/server.py +129 -0
- map_mcp-0.1.0.dist-info/METADATA +132 -0
- map_mcp-0.1.0.dist-info/RECORD +11 -0
- map_mcp-0.1.0.dist-info/WHEEL +4 -0
- map_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- map_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
map_mcp/__init__.py
ADDED
|
@@ -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"
|
map_mcp/bridge.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""The local WebSocket bridge between this process and the in-browser map hook.
|
|
2
|
+
|
|
3
|
+
``call(method, params)`` sends a request to the connected hook and blocks until the matching
|
|
4
|
+
reply arrives (correlated by id). The correlation logic is synchronous and unit-testable
|
|
5
|
+
without a socket — feed replies via ``_on_message``. The live WebSocket server is gated on the
|
|
6
|
+
``websockets`` library, binds loopback only, and (with U6) requires a per-session token.
|
|
7
|
+
|
|
8
|
+
Sync-over-async: the ws server runs in a daemon thread with its own event loop; ``call`` (on
|
|
9
|
+
the MCP/CLI thread) waits on a threading event the receive coroutine sets. This keeps core-ops
|
|
10
|
+
and the MCP tools synchronous, matching streamlit-mcp's engine.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import secrets
|
|
16
|
+
import threading
|
|
17
|
+
from typing import Any, Callable, Optional
|
|
18
|
+
|
|
19
|
+
from .protocol import decode, encode, make_request
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BridgeError(RuntimeError):
|
|
23
|
+
"""Raised when no map is connected, a call times out, or the hook returns an error."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ws_available() -> bool:
|
|
27
|
+
try:
|
|
28
|
+
import websockets # noqa: F401
|
|
29
|
+
except Exception:
|
|
30
|
+
return False
|
|
31
|
+
return True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def authenticate(first_message: str, expected_token: Optional[str]) -> bool:
|
|
35
|
+
"""Validate the hook's opening ``{type:"hello", token}`` frame against the session token.
|
|
36
|
+
|
|
37
|
+
Pure (no I/O), so the handshake rule is unit-testable. When no token is configured
|
|
38
|
+
(``expected_token`` falsy) the gate is open — but the CLI always issues a token."""
|
|
39
|
+
if not expected_token:
|
|
40
|
+
return True
|
|
41
|
+
try:
|
|
42
|
+
msg = decode(first_message)
|
|
43
|
+
except Exception:
|
|
44
|
+
return False
|
|
45
|
+
if msg.get("type") != "hello":
|
|
46
|
+
return False
|
|
47
|
+
# constant-time compare so token validation leaks no timing signal
|
|
48
|
+
return secrets.compare_digest(str(msg.get("token") or ""), str(expected_token))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _Pending:
|
|
52
|
+
__slots__ = ("event", "result", "error")
|
|
53
|
+
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
self.event = threading.Event()
|
|
56
|
+
self.result: Any = None
|
|
57
|
+
self.error: Optional[str] = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Bridge:
|
|
61
|
+
"""Correlates outbound requests with inbound replies by id. Transport-agnostic."""
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
self._pending: dict[Any, _Pending] = {}
|
|
65
|
+
self._lock = threading.Lock()
|
|
66
|
+
self._sender: Optional[Callable[[str], None]] = None
|
|
67
|
+
self._thread: Optional[threading.Thread] = None
|
|
68
|
+
self.port: Optional[int] = None
|
|
69
|
+
self._loop: Any = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def connected(self) -> bool:
|
|
73
|
+
return self._sender is not None
|
|
74
|
+
|
|
75
|
+
def attach_sender(self, sender: Callable[[str], None]) -> None:
|
|
76
|
+
"""Bind the function that writes a frame to the connected hook (last writer wins —
|
|
77
|
+
a reconnect/new tab replaces the old; the old's in-flight calls then time out)."""
|
|
78
|
+
self._sender = sender
|
|
79
|
+
|
|
80
|
+
def detach(self, sender: Optional[Callable[[str], None]] = None) -> None:
|
|
81
|
+
"""Clear the sender on disconnect. Identity-guarded: a stale connection's teardown
|
|
82
|
+
only clears the sender if it is still the *current* one — so a page reload (new
|
|
83
|
+
socket attaches, then the old socket's close fires) can't null the live sender.
|
|
84
|
+
Draining lets in-flight calls fail fast with 'map disconnected' instead of timing out."""
|
|
85
|
+
if sender is None or self._sender is sender:
|
|
86
|
+
self._sender = None
|
|
87
|
+
self._drain_pending("map disconnected")
|
|
88
|
+
|
|
89
|
+
def _drain_pending(self, message: str) -> None:
|
|
90
|
+
with self._lock:
|
|
91
|
+
waiting = list(self._pending.values())
|
|
92
|
+
for pend in waiting:
|
|
93
|
+
if pend.error is None and pend.result is None:
|
|
94
|
+
pend.error = message
|
|
95
|
+
pend.event.set()
|
|
96
|
+
|
|
97
|
+
def call(self, method: str, params: Optional[dict] = None, *, timeout: float = 10.0) -> Any:
|
|
98
|
+
"""Send a request and block for its reply. Raises BridgeError on no-connection,
|
|
99
|
+
send failure, timeout, disconnect, or an error reply — never leaks another exception."""
|
|
100
|
+
sender = self._sender
|
|
101
|
+
if sender is None:
|
|
102
|
+
raise BridgeError("no map connected — open a hooked map and try again")
|
|
103
|
+
req = make_request(method, params)
|
|
104
|
+
pend = _Pending()
|
|
105
|
+
with self._lock:
|
|
106
|
+
self._pending[req["id"]] = pend
|
|
107
|
+
try:
|
|
108
|
+
try:
|
|
109
|
+
sender(encode(req))
|
|
110
|
+
except Exception as e: # closed loop / dropped socket -> clean BridgeError, not a leak
|
|
111
|
+
raise BridgeError(f"send failed: {e}") from e
|
|
112
|
+
if not pend.event.wait(timeout):
|
|
113
|
+
raise BridgeError(f"timeout after {timeout}s waiting for {method!r}")
|
|
114
|
+
if pend.error is not None:
|
|
115
|
+
raise BridgeError(pend.error)
|
|
116
|
+
return pend.result
|
|
117
|
+
finally:
|
|
118
|
+
with self._lock:
|
|
119
|
+
self._pending.pop(req["id"], None)
|
|
120
|
+
|
|
121
|
+
def _on_message(self, text: str) -> None:
|
|
122
|
+
"""Resolve the pending call matching this reply's id. Unknown/stale ids are ignored."""
|
|
123
|
+
try:
|
|
124
|
+
msg = decode(text)
|
|
125
|
+
except Exception:
|
|
126
|
+
return
|
|
127
|
+
with self._lock:
|
|
128
|
+
pend = self._pending.get(msg.get("id"))
|
|
129
|
+
if pend is None:
|
|
130
|
+
return
|
|
131
|
+
if msg.get("ok"):
|
|
132
|
+
pend.result = msg.get("result")
|
|
133
|
+
else:
|
|
134
|
+
pend.error = msg.get("error") or "unknown error from hook"
|
|
135
|
+
pend.event.set()
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------- live server
|
|
138
|
+
def serve_ws(self, host: str = "127.0.0.1", port: int = 8765,
|
|
139
|
+
token: Optional[str] = None) -> None:
|
|
140
|
+
"""Start the loopback WebSocket server in a daemon thread (non-blocking).
|
|
141
|
+
|
|
142
|
+
Gated on ``websockets``. Binds loopback only. The token handshake is layered in U6;
|
|
143
|
+
v1 here wires connect -> sender, and routes inbound frames to ``_on_message``.
|
|
144
|
+
"""
|
|
145
|
+
if not ws_available():
|
|
146
|
+
raise BridgeError("websockets not installed — `pip install map-mcp` includes it")
|
|
147
|
+
if host not in ("127.0.0.1", "::1", "localhost"):
|
|
148
|
+
raise BridgeError(f"refusing to bind non-loopback host {host!r} (local-only)")
|
|
149
|
+
|
|
150
|
+
import asyncio
|
|
151
|
+
|
|
152
|
+
import websockets
|
|
153
|
+
|
|
154
|
+
loop = asyncio.new_event_loop()
|
|
155
|
+
self._loop = loop
|
|
156
|
+
ready = threading.Event()
|
|
157
|
+
startup: dict[str, BaseException] = {}
|
|
158
|
+
|
|
159
|
+
async def handler(ws):
|
|
160
|
+
# Token handshake (local-only security): require a valid hello before accepting
|
|
161
|
+
# commands, so a stray page on the loopback port can't drive the operator's map.
|
|
162
|
+
if token:
|
|
163
|
+
try:
|
|
164
|
+
first = await asyncio.wait_for(ws.recv(), timeout=10)
|
|
165
|
+
except Exception:
|
|
166
|
+
return
|
|
167
|
+
if not authenticate(first, token):
|
|
168
|
+
await ws.close(code=4001, reason="bad token")
|
|
169
|
+
return
|
|
170
|
+
send = lambda text: asyncio.run_coroutine_threadsafe(ws.send(text), loop)
|
|
171
|
+
self.attach_sender(send)
|
|
172
|
+
try:
|
|
173
|
+
async for message in ws:
|
|
174
|
+
self._on_message(message)
|
|
175
|
+
finally:
|
|
176
|
+
self.detach(send) # identity-guarded: only clears if still the current sender
|
|
177
|
+
|
|
178
|
+
async def main():
|
|
179
|
+
async with websockets.serve(handler, host, port) as server:
|
|
180
|
+
try:
|
|
181
|
+
self.port = server.sockets[0].getsockname()[1]
|
|
182
|
+
except Exception:
|
|
183
|
+
self.port = port
|
|
184
|
+
ready.set()
|
|
185
|
+
await asyncio.Future() # serve forever
|
|
186
|
+
|
|
187
|
+
def run():
|
|
188
|
+
asyncio.set_event_loop(loop)
|
|
189
|
+
try:
|
|
190
|
+
loop.run_until_complete(main())
|
|
191
|
+
except BaseException as e: # bind failure (port in use), etc.
|
|
192
|
+
startup["error"] = e
|
|
193
|
+
ready.set() # unblock the waiter so it can surface the error
|
|
194
|
+
|
|
195
|
+
self._thread = threading.Thread(target=run, daemon=True, name="map-mcp-bridge")
|
|
196
|
+
self._thread.start()
|
|
197
|
+
# Don't return until the server is actually listening — and surface startup failure
|
|
198
|
+
# (a port-in-use bind error would otherwise die silently in the thread).
|
|
199
|
+
if not ready.wait(timeout=5):
|
|
200
|
+
raise BridgeError("bridge did not start within 5s")
|
|
201
|
+
if "error" in startup:
|
|
202
|
+
raise BridgeError(f"bridge failed to start: {startup['error']}")
|
map_mcp/cli.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""map-mcp CLI — the human-first surface.
|
|
2
|
+
|
|
3
|
+
`serve` starts the bridge + the MCP server (for agents) and prints the opt-in snippet + token.
|
|
4
|
+
`call` lets a human drive the live map from the terminal. Both paths go through the same
|
|
5
|
+
``CoreOps`` an agent uses over MCP, so parity holds by construction — ``run_op`` accepts exactly
|
|
6
|
+
the operations the MCP tools expose.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import secrets
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
from .bridge import Bridge, BridgeError
|
|
19
|
+
from .coreops import OP_NAMES, CoreOps
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _force_utf8_output() -> None:
|
|
23
|
+
for stream in (sys.stdout, sys.stderr):
|
|
24
|
+
try:
|
|
25
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_op(ops: CoreOps, op: str, params: Optional[dict] = None) -> dict:
|
|
31
|
+
"""Dispatch one operation by name through core-ops. Fail-closed, like the MCP tools.
|
|
32
|
+
|
|
33
|
+
This is the parity seam: it accepts exactly ``OP_NAMES`` — the same surface the agent has."""
|
|
34
|
+
if op not in OP_NAMES:
|
|
35
|
+
return {"error": f"unknown operation {op!r}; expected one of {list(OP_NAMES)}"}
|
|
36
|
+
try:
|
|
37
|
+
result = getattr(ops, op)(**(params or {}))
|
|
38
|
+
except (BridgeError, ValueError, TypeError) as e:
|
|
39
|
+
return {"error": str(e)}
|
|
40
|
+
return result if isinstance(result, dict) else {"result": result}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _snippet(ws_port: int, token: str) -> str:
|
|
44
|
+
url = f"ws://127.0.0.1:{ws_port}"
|
|
45
|
+
return (
|
|
46
|
+
"Add this to your MapLibre page (the map is your existing maplibregl.Map):\n"
|
|
47
|
+
' <script src="map-mcp-hook.js"></script>\n'
|
|
48
|
+
" <script>\n"
|
|
49
|
+
f' mapMcp.register(map, {{ url: "{url}", token: "{token}" }});\n'
|
|
50
|
+
" </script>\n"
|
|
51
|
+
f" bridge: {url} token: {token}"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --------------------------------------------------------------------- commands
|
|
56
|
+
def _start_bridge(ws_port: int) -> tuple[Bridge, str]:
|
|
57
|
+
"""Start the loopback bridge with a fresh per-session token. Raises BridgeError on
|
|
58
|
+
startup failure (e.g. port in use). Shared by serve and call."""
|
|
59
|
+
token = secrets.token_urlsafe(16)
|
|
60
|
+
bridge = Bridge()
|
|
61
|
+
bridge.serve_ws(host="127.0.0.1", port=ws_port, token=token)
|
|
62
|
+
return bridge, token
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_serve(args: argparse.Namespace) -> int:
|
|
66
|
+
from .server import run_server, validate_transport
|
|
67
|
+
try:
|
|
68
|
+
validate_transport(args.transport)
|
|
69
|
+
except ValueError as e:
|
|
70
|
+
print(str(e), file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
try:
|
|
73
|
+
bridge, token = _start_bridge(args.ws_port)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
print(f"could not start the map bridge: {e}", file=sys.stderr)
|
|
76
|
+
return 1
|
|
77
|
+
print(_snippet(bridge.port, token), file=sys.stderr)
|
|
78
|
+
try:
|
|
79
|
+
run_server(bridge, transport=args.transport, host=args.host, port=args.port)
|
|
80
|
+
except ValueError as e:
|
|
81
|
+
print(str(e), file=sys.stderr)
|
|
82
|
+
return 1
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _wait_for_map(bridge: Bridge, timeout: float) -> bool:
|
|
87
|
+
deadline = time.monotonic() + timeout
|
|
88
|
+
while time.monotonic() < deadline:
|
|
89
|
+
if bridge.connected:
|
|
90
|
+
return True
|
|
91
|
+
time.sleep(0.1)
|
|
92
|
+
return bridge.connected
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cmd_call(args: argparse.Namespace) -> int:
|
|
96
|
+
try:
|
|
97
|
+
params = json.loads(args.params) if args.params else {}
|
|
98
|
+
except json.JSONDecodeError as e:
|
|
99
|
+
print(f"bad --params JSON: {e}", file=sys.stderr)
|
|
100
|
+
return 1
|
|
101
|
+
try:
|
|
102
|
+
bridge, token = _start_bridge(args.ws_port)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
print(f"could not start the map bridge: {e}", file=sys.stderr)
|
|
105
|
+
return 1
|
|
106
|
+
print(_snippet(bridge.port, token), file=sys.stderr)
|
|
107
|
+
if not _wait_for_map(bridge, args.wait):
|
|
108
|
+
print("no map connected — open your hooked map, then retry", file=sys.stderr)
|
|
109
|
+
return 1
|
|
110
|
+
result = run_op(CoreOps(bridge), args.op, params)
|
|
111
|
+
print(json.dumps(result, indent=2, default=str))
|
|
112
|
+
return 1 if "error" in result else 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# --------------------------------------------------------------------- parser
|
|
116
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
117
|
+
parser = argparse.ArgumentParser(
|
|
118
|
+
prog="map-mcp",
|
|
119
|
+
description="Drive and perceive an existing live MapLibre GL map (agent via MCP, or here).")
|
|
120
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
121
|
+
|
|
122
|
+
p_serve = sub.add_parser("serve", help="serve the live map over MCP (for agents)")
|
|
123
|
+
p_serve.add_argument("--transport", choices=["stdio", "http", "sse"], default="stdio")
|
|
124
|
+
p_serve.add_argument("--host", default="127.0.0.1")
|
|
125
|
+
p_serve.add_argument("--port", type=int, default=8000, help="MCP HTTP/SSE port")
|
|
126
|
+
p_serve.add_argument("--ws-port", dest="ws_port", type=int, default=8765,
|
|
127
|
+
help="bridge WebSocket port the hook connects to")
|
|
128
|
+
p_serve.set_defaults(func=cmd_serve)
|
|
129
|
+
|
|
130
|
+
p_call = sub.add_parser("call", help="run one operation against the live map (same ops agents use)")
|
|
131
|
+
p_call.add_argument("op", choices=list(OP_NAMES))
|
|
132
|
+
p_call.add_argument("--params", help="JSON params, e.g. '{\"point\":[12.5,41.9]}'")
|
|
133
|
+
p_call.add_argument("--ws-port", dest="ws_port", type=int, default=8765)
|
|
134
|
+
p_call.add_argument("--wait", type=float, default=30.0,
|
|
135
|
+
help="seconds to wait for the hooked map to connect")
|
|
136
|
+
p_call.set_defaults(func=cmd_call)
|
|
137
|
+
|
|
138
|
+
return parser
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
142
|
+
_force_utf8_output()
|
|
143
|
+
args = build_parser().parse_args(argv)
|
|
144
|
+
return args.func(args)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _cli() -> None:
|
|
148
|
+
"""Console-script entry point — propagate the exit code."""
|
|
149
|
+
raise SystemExit(main())
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
_cli()
|
map_mcp/coreops.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Core operations — the single implementation each frontend (MCP server + CLI) calls.
|
|
2
|
+
|
|
3
|
+
Every op issues one bridge call and returns the hook's structured result. The bridge is
|
|
4
|
+
injected, so a fake bridge makes this layer fully unit-testable without a browser. Input
|
|
5
|
+
validation lives here (geographic inputs are ``[lng, lat]``); the hook does the MapLibre work.
|
|
6
|
+
A bridge error (no map connected, timeout, hook error) propagates as ``BridgeError`` — the
|
|
7
|
+
frontends turn it into a clean tool/CLI error.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
# The canonical operation surface. The MCP tool set and the CLI command set both cover exactly
|
|
15
|
+
# these — the parity contract (asserted in the scenario test).
|
|
16
|
+
OP_NAMES = (
|
|
17
|
+
"get_viewport",
|
|
18
|
+
"query_rendered_features",
|
|
19
|
+
"get_features_at",
|
|
20
|
+
"click_at",
|
|
21
|
+
"read_popup",
|
|
22
|
+
"set_view",
|
|
23
|
+
"list_layers",
|
|
24
|
+
"set_layer_visibility",
|
|
25
|
+
"screenshot",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CoreOps:
|
|
30
|
+
def __init__(self, bridge: Any) -> None:
|
|
31
|
+
self.bridge = bridge
|
|
32
|
+
|
|
33
|
+
def get_viewport(self) -> dict:
|
|
34
|
+
return self.bridge.call("get_viewport")
|
|
35
|
+
|
|
36
|
+
def query_rendered_features(
|
|
37
|
+
self,
|
|
38
|
+
point: Optional[list] = None,
|
|
39
|
+
bbox: Optional[list] = None,
|
|
40
|
+
layers: Optional[list] = None,
|
|
41
|
+
limit: Optional[int] = None,
|
|
42
|
+
) -> dict:
|
|
43
|
+
params: dict = {}
|
|
44
|
+
if point is not None:
|
|
45
|
+
params["point"] = _point(point)
|
|
46
|
+
if bbox is not None:
|
|
47
|
+
params["bbox"] = _bbox(bbox)
|
|
48
|
+
if layers:
|
|
49
|
+
params["layers"] = list(layers)
|
|
50
|
+
if limit is not None:
|
|
51
|
+
params["limit"] = int(limit)
|
|
52
|
+
return self.bridge.call("query_rendered_features", params)
|
|
53
|
+
|
|
54
|
+
def get_features_at(self, point: list, layers: Optional[list] = None) -> dict:
|
|
55
|
+
params: dict = {"point": _point(point)}
|
|
56
|
+
if layers:
|
|
57
|
+
params["layers"] = list(layers)
|
|
58
|
+
return self.bridge.call("get_features_at", params)
|
|
59
|
+
|
|
60
|
+
def click_at(self, point: list, layers: Optional[list] = None) -> dict:
|
|
61
|
+
params: dict = {"point": _point(point)}
|
|
62
|
+
if layers:
|
|
63
|
+
params["layers"] = list(layers)
|
|
64
|
+
return self.bridge.call("click_at", params)
|
|
65
|
+
|
|
66
|
+
def read_popup(self) -> dict:
|
|
67
|
+
return self.bridge.call("read_popup")
|
|
68
|
+
|
|
69
|
+
def set_view(
|
|
70
|
+
self,
|
|
71
|
+
center: Optional[list] = None,
|
|
72
|
+
zoom: Optional[float] = None,
|
|
73
|
+
bbox: Optional[list] = None,
|
|
74
|
+
bearing: Optional[float] = None,
|
|
75
|
+
pitch: Optional[float] = None,
|
|
76
|
+
) -> dict:
|
|
77
|
+
params: dict = {}
|
|
78
|
+
if center is not None:
|
|
79
|
+
params["center"] = _point(center)
|
|
80
|
+
if zoom is not None:
|
|
81
|
+
params["zoom"] = float(zoom)
|
|
82
|
+
if bbox is not None:
|
|
83
|
+
params["bbox"] = _bbox(bbox)
|
|
84
|
+
if bearing is not None:
|
|
85
|
+
params["bearing"] = float(bearing)
|
|
86
|
+
if pitch is not None:
|
|
87
|
+
params["pitch"] = float(pitch)
|
|
88
|
+
if not params:
|
|
89
|
+
raise ValueError("set_view needs at least one of center/zoom/bbox/bearing/pitch")
|
|
90
|
+
return self.bridge.call("set_view", params)
|
|
91
|
+
|
|
92
|
+
def list_layers(self) -> dict:
|
|
93
|
+
return self.bridge.call("list_layers")
|
|
94
|
+
|
|
95
|
+
def set_layer_visibility(self, layer: str, visible: bool) -> dict:
|
|
96
|
+
if not layer:
|
|
97
|
+
raise ValueError("set_layer_visibility needs a layer id")
|
|
98
|
+
return self.bridge.call("set_layer_visibility",
|
|
99
|
+
{"layer": layer, "visible": bool(visible)})
|
|
100
|
+
|
|
101
|
+
def screenshot(self) -> dict:
|
|
102
|
+
return self.bridge.call("screenshot")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _point(p: Any) -> list:
|
|
106
|
+
if not (isinstance(p, (list, tuple)) and len(p) == 2):
|
|
107
|
+
raise ValueError(f"point must be [lng, lat], got {p!r}")
|
|
108
|
+
return [float(p[0]), float(p[1])]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _bbox(b: Any) -> list:
|
|
112
|
+
if not (isinstance(b, (list, tuple)) and len(b) == 2):
|
|
113
|
+
raise ValueError(f"bbox must be [[lng,lat],[lng,lat]], got {b!r}")
|
|
114
|
+
return [_point(b[0]), _point(b[1])]
|
map_mcp/protocol.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Pure wire protocol for the bridge: request/response envelopes + id correlation.
|
|
2
|
+
|
|
3
|
+
No I/O — fully unit-testable. Both the live socket (``bridge.py``) and the in-browser hook
|
|
4
|
+
speak this. A request is ``{id, method, params}``; a reply is ``{id, ok, result}`` on success
|
|
5
|
+
or ``{id, ok=false, error}`` on failure.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import itertools
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
_counter = itertools.count(1)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def next_id() -> int:
|
|
18
|
+
"""Monotonic request id (process-local)."""
|
|
19
|
+
return next(_counter)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def make_request(method: str, params: Optional[dict] = None, *, id: Optional[int] = None) -> dict:
|
|
23
|
+
if not method:
|
|
24
|
+
raise ValueError("request needs a method")
|
|
25
|
+
return {
|
|
26
|
+
"id": id if id is not None else next_id(),
|
|
27
|
+
"method": method,
|
|
28
|
+
"params": params or {},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def make_response(id: Any, *, result: Any = None, error: Optional[str] = None) -> dict:
|
|
33
|
+
if error is not None:
|
|
34
|
+
return {"id": id, "ok": False, "error": error}
|
|
35
|
+
return {"id": id, "ok": True, "result": result}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def encode(msg: dict) -> str:
|
|
39
|
+
return json.dumps(msg)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def decode(text: str) -> dict:
|
|
43
|
+
msg = json.loads(text)
|
|
44
|
+
if not isinstance(msg, dict):
|
|
45
|
+
raise ValueError("envelope must be a JSON object")
|
|
46
|
+
return msg
|
map_mcp/server.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""FastMCP server: exposes the core operations as MCP tools over stdio and HTTP/SSE.
|
|
2
|
+
|
|
3
|
+
Tool bodies are thin — they call the shared ``CoreOps`` (the same layer the CLI uses, so parity
|
|
4
|
+
holds). A no-map-connected / hook error / bad input is failed closed into a clean ``{"error":
|
|
5
|
+
...}`` result rather than a raised exception, so an agent gets a message it can act on. HTTP/SSE
|
|
6
|
+
refuses non-loopback binds (the bridge is local-only).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Callable, Optional
|
|
12
|
+
|
|
13
|
+
from fastmcp import FastMCP
|
|
14
|
+
|
|
15
|
+
from .bridge import BridgeError
|
|
16
|
+
from .coreops import OP_NAMES, CoreOps
|
|
17
|
+
|
|
18
|
+
TOOL_NAMES = OP_NAMES
|
|
19
|
+
VALID_TRANSPORTS = ("stdio", "http", "sse")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _guard(fn: Callable) -> Callable:
|
|
23
|
+
"""Fail closed: turn a no-connection/hook/input error into a clean error result.
|
|
24
|
+
|
|
25
|
+
Matches the CLI's run_op catch set (BridgeError, ValueError, TypeError) so the two
|
|
26
|
+
frontends fail closed identically. The bridge converts transport faults (closed loop,
|
|
27
|
+
dropped socket) into BridgeError at the source, so nothing else should escape here."""
|
|
28
|
+
def wrapped(*args, **kwargs):
|
|
29
|
+
try:
|
|
30
|
+
return fn(*args, **kwargs)
|
|
31
|
+
except (BridgeError, ValueError, TypeError) as e:
|
|
32
|
+
return {"error": str(e)}
|
|
33
|
+
return wrapped
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def core_tool_handlers(ops: CoreOps) -> dict[str, Callable]:
|
|
37
|
+
"""The tool implementations bound to one CoreOps — directly unit-testable."""
|
|
38
|
+
return {
|
|
39
|
+
"get_viewport": _guard(lambda: ops.get_viewport()),
|
|
40
|
+
"query_rendered_features": _guard(
|
|
41
|
+
lambda point=None, bbox=None, layers=None, limit=None:
|
|
42
|
+
ops.query_rendered_features(point, bbox, layers, limit)),
|
|
43
|
+
"get_features_at": _guard(lambda point, layers=None: ops.get_features_at(point, layers)),
|
|
44
|
+
"click_at": _guard(lambda point, layers=None: ops.click_at(point, layers)),
|
|
45
|
+
"read_popup": _guard(lambda: ops.read_popup()),
|
|
46
|
+
"set_view": _guard(lambda center=None, zoom=None, bbox=None, bearing=None, pitch=None:
|
|
47
|
+
ops.set_view(center, zoom, bbox, bearing, pitch)),
|
|
48
|
+
"list_layers": _guard(lambda: ops.list_layers()),
|
|
49
|
+
"set_layer_visibility": _guard(lambda layer, visible: ops.set_layer_visibility(layer, visible)),
|
|
50
|
+
"screenshot": _guard(lambda: ops.screenshot()),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_server(bridge: Any, app_name: str = "map-mcp"):
|
|
55
|
+
"""Construct a FastMCP server exposing the core tools, bound to one bridge."""
|
|
56
|
+
mcp = FastMCP(app_name)
|
|
57
|
+
H = core_tool_handlers(CoreOps(bridge))
|
|
58
|
+
|
|
59
|
+
@mcp.tool
|
|
60
|
+
def get_viewport() -> dict:
|
|
61
|
+
"""Current viewport: center [lng,lat], zoom, bearing, pitch, bounds."""
|
|
62
|
+
return H["get_viewport"]()
|
|
63
|
+
|
|
64
|
+
@mcp.tool
|
|
65
|
+
def query_rendered_features(point: Optional[list] = None, bbox: Optional[list] = None,
|
|
66
|
+
layers: Optional[list] = None, limit: Optional[int] = None) -> dict:
|
|
67
|
+
"""Features currently rendered, optionally at a [lng,lat] point or within a [[lng,lat],[lng,lat]] bbox."""
|
|
68
|
+
return H["query_rendered_features"](point=point, bbox=bbox, layers=layers, limit=limit)
|
|
69
|
+
|
|
70
|
+
@mcp.tool
|
|
71
|
+
def get_features_at(point: list, layers: Optional[list] = None) -> dict:
|
|
72
|
+
"""Features rendered at a [lng,lat] point."""
|
|
73
|
+
return H["get_features_at"](point, layers)
|
|
74
|
+
|
|
75
|
+
@mcp.tool
|
|
76
|
+
def click_at(point: list, layers: Optional[list] = None) -> dict:
|
|
77
|
+
"""Fire the map's click at a [lng,lat] point (runs app popup handlers) and return features + popup."""
|
|
78
|
+
return H["click_at"](point, layers)
|
|
79
|
+
|
|
80
|
+
@mcp.tool
|
|
81
|
+
def read_popup() -> dict:
|
|
82
|
+
"""Text of any open map popup(s)."""
|
|
83
|
+
return H["read_popup"]()
|
|
84
|
+
|
|
85
|
+
@mcp.tool
|
|
86
|
+
def set_view(center: Optional[list] = None, zoom: Optional[float] = None,
|
|
87
|
+
bbox: Optional[list] = None, bearing: Optional[float] = None,
|
|
88
|
+
pitch: Optional[float] = None) -> dict:
|
|
89
|
+
"""Move the map: center+zoom (and bearing/pitch), or fit a bbox. Returns the new viewport."""
|
|
90
|
+
return H["set_view"](center=center, zoom=zoom, bbox=bbox, bearing=bearing, pitch=pitch)
|
|
91
|
+
|
|
92
|
+
@mcp.tool
|
|
93
|
+
def list_layers() -> dict:
|
|
94
|
+
"""The style's layers with id, type, source, and visibility."""
|
|
95
|
+
return H["list_layers"]()
|
|
96
|
+
|
|
97
|
+
@mcp.tool
|
|
98
|
+
def set_layer_visibility(layer: str, visible: bool) -> dict:
|
|
99
|
+
"""Show or hide a layer by id."""
|
|
100
|
+
return H["set_layer_visibility"](layer, visible)
|
|
101
|
+
|
|
102
|
+
@mcp.tool
|
|
103
|
+
def screenshot() -> dict:
|
|
104
|
+
"""A PNG data URL of the current map (needs preserveDrawingBuffer:true on the map)."""
|
|
105
|
+
return H["screenshot"]()
|
|
106
|
+
|
|
107
|
+
return mcp
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def validate_transport(transport: str) -> str:
|
|
111
|
+
if transport not in VALID_TRANSPORTS:
|
|
112
|
+
raise ValueError(f"transport must be one of {VALID_TRANSPORTS}, got {transport!r}")
|
|
113
|
+
return transport
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def run_server(bridge: Any, transport: str = "stdio", host: str = "127.0.0.1",
|
|
117
|
+
port: int = 8000) -> None:
|
|
118
|
+
"""Build and run the MCP server (blocking). The bridge's ws server is started separately."""
|
|
119
|
+
validate_transport(transport)
|
|
120
|
+
if transport in ("http", "sse") and host not in ("127.0.0.1", "::1", "localhost"):
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"Refusing to serve {transport} on non-loopback host {host!r}: the map bridge is "
|
|
123
|
+
"local-only. Bind 127.0.0.1, or use stdio."
|
|
124
|
+
)
|
|
125
|
+
mcp = build_server(bridge)
|
|
126
|
+
if transport == "stdio":
|
|
127
|
+
mcp.run()
|
|
128
|
+
else:
|
|
129
|
+
mcp.run(transport=transport, host=host, port=port)
|
|
@@ -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
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
map_mcp/__init__.py,sha256=ibEmgPip9FsO2DtyPz6vDwy-wGIiiovMgrrN_Z-7mdg,510
|
|
2
|
+
map_mcp/bridge.py,sha256=073eNQ4ucVAeNgMJapok8VJlWEBZvI8z8DZXMEGkjk8,8355
|
|
3
|
+
map_mcp/cli.py,sha256=OBSZrSGGB5w-hKkpCldHU3isPE-xR7AO4gO6IIonsOQ,5618
|
|
4
|
+
map_mcp/coreops.py,sha256=7Cl7sm92FqjrnINDMN0OI9FQFkXrs46zSknVYuwZQ7Q,3938
|
|
5
|
+
map_mcp/protocol.py,sha256=2hNGFUXuYPWvn9szRQCcszDVOq1_e1Ei-R1f4oAhykw,1293
|
|
6
|
+
map_mcp/server.py,sha256=_27L-DxCFvzkZYf7RnHDyGrxfYsRcj3B_AZHNEifGPA,5493
|
|
7
|
+
map_mcp-0.1.0.dist-info/METADATA,sha256=GJQvG7TcQjO5UGZGiY8I155hz7qz2QoBCh9IjcFW7gI,5980
|
|
8
|
+
map_mcp-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
9
|
+
map_mcp-0.1.0.dist-info/entry_points.txt,sha256=uviG52jhZepoabiz5QZLdz3kgugXzmEyThkNp0MJklY,45
|
|
10
|
+
map_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=68VkOZUTE_Q_g8vf9largzAs5-Z9lXBNy3EMH-z_Gw4,1072
|
|
11
|
+
map_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|