apple-maps-mcp 1.0.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.
- apple_maps_mcp-1.0.0/LICENSE +21 -0
- apple_maps_mcp-1.0.0/PKG-INFO +119 -0
- apple_maps_mcp-1.0.0/README.md +89 -0
- apple_maps_mcp-1.0.0/pyproject.toml +60 -0
- apple_maps_mcp-1.0.0/setup.cfg +4 -0
- apple_maps_mcp-1.0.0/src/apple_maps_mcp/__init__.py +1 -0
- apple_maps_mcp-1.0.0/src/apple_maps_mcp/apple_maps_bridge.swift +234 -0
- apple_maps_mcp-1.0.0/src/apple_maps_mcp/config.py +46 -0
- apple_maps_mcp-1.0.0/src/apple_maps_mcp/maps_bridge.py +115 -0
- apple_maps_mcp-1.0.0/src/apple_maps_mcp/models.py +62 -0
- apple_maps_mcp-1.0.0/src/apple_maps_mcp/tools.py +231 -0
- apple_maps_mcp-1.0.0/src/apple_maps_mcp.egg-info/PKG-INFO +119 -0
- apple_maps_mcp-1.0.0/src/apple_maps_mcp.egg-info/SOURCES.txt +18 -0
- apple_maps_mcp-1.0.0/src/apple_maps_mcp.egg-info/dependency_links.txt +1 -0
- apple_maps_mcp-1.0.0/src/apple_maps_mcp.egg-info/entry_points.txt +2 -0
- apple_maps_mcp-1.0.0/src/apple_maps_mcp.egg-info/requires.txt +7 -0
- apple_maps_mcp-1.0.0/src/apple_maps_mcp.egg-info/top_level.txt +1 -0
- apple_maps_mcp-1.0.0/tests/test_bridge.py +22 -0
- apple_maps_mcp-1.0.0/tests/test_config.py +15 -0
- apple_maps_mcp-1.0.0/tests/test_tools.py +75 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Apple MCP Contributors
|
|
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.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apple-maps-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Local Apple Maps MCP server for macOS
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/JonathanRReed/Apple-MCPs
|
|
7
|
+
Project-URL: Repository, https://github.com/JonathanRReed/Apple-MCPs
|
|
8
|
+
Project-URL: Changelog, https://github.com/JonathanRReed/Apple-MCPs/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/JonathanRReed/Apple-MCPs/issues
|
|
10
|
+
Keywords: mcp,model-context-protocol,macos,apple,ai-agent,automation
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Environment :: MacOS X
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: apple-mcp-common<2,>=1.0.0
|
|
24
|
+
Requires-Dist: mcp<3,>=2.0.0
|
|
25
|
+
Requires-Dist: pydantic>=2.12.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest<10,>=8; extra == "dev"
|
|
28
|
+
Requires-Dist: ruff<1,>=0.12; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
<!-- mcp-name: io.github.jonathanrreed/apple-maps-mcp -->
|
|
32
|
+
|
|
33
|
+
# Apple Maps MCP
|
|
34
|
+
|
|
35
|
+
Local MCP server for Apple Maps search and routing on macOS.
|
|
36
|
+
|
|
37
|
+
## Capabilities
|
|
38
|
+
|
|
39
|
+
- search for places
|
|
40
|
+
- estimate route distance and travel time
|
|
41
|
+
- build Apple Maps links
|
|
42
|
+
- open directions in Apple Maps
|
|
43
|
+
- resource: `maps://status`
|
|
44
|
+
- prompt: `maps_plan_route`
|
|
45
|
+
- tool discovery helpers `search_tools` and `get_tool_info` for context-constrained clients
|
|
46
|
+
|
|
47
|
+
## Install On This Mac
|
|
48
|
+
|
|
49
|
+
<details>
|
|
50
|
+
<summary>Quick start (uvx, from PyPI)</summary>
|
|
51
|
+
|
|
52
|
+
With [uv](https://docs.astral.sh/uv/getting-started/installation/) installed:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uvx apple-maps-mcp
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
No clone, no venv management.
|
|
59
|
+
|
|
60
|
+
</details>
|
|
61
|
+
|
|
62
|
+
<details>
|
|
63
|
+
<summary>From a clone</summary>
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
git clone https://github.com/JonathanRReed/Apple-MCPs.git
|
|
67
|
+
cd Apple-MCPs
|
|
68
|
+
uv sync --all-packages
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This builds one workspace environment with every server's entry point in `.venv/bin` (for example `.venv/bin/apple-maps-mcp`). You can also point an MCP client at `AppleMaps-MCP/start.sh`, which prefers `uv run` and falls back to a plain venv bootstrap (Python 3.11+ required).
|
|
72
|
+
|
|
73
|
+
</details>
|
|
74
|
+
|
|
75
|
+
## Install In AI Agents
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"mcpServers": {
|
|
80
|
+
"apple-maps": {
|
|
81
|
+
"command": "uvx",
|
|
82
|
+
"args": ["apple-maps-mcp"],
|
|
83
|
+
"env": {}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Running from a clone instead? Use `/path/to/Apple-MCPs/AppleMaps-MCP/start.sh` as the command with empty `args`.
|
|
90
|
+
|
|
91
|
+
Claude Code:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
claude mcp add --transport stdio --scope project apple-maps -- uvx apple-maps-mcp
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Transport
|
|
98
|
+
|
|
99
|
+
`stdio` is the default and recommended transport. Set `APPLE_MAPS_MCP_TRANSPORT=streamable-http` (with optional `APPLE_MAPS_MCP_HOST` and `APPLE_MAPS_MCP_PORT`) to serve Streamable HTTP instead.
|
|
100
|
+
|
|
101
|
+
## Prompting Notes
|
|
102
|
+
|
|
103
|
+
- `tools/list` returns the full Maps tool surface. Context-constrained clients can use `search_tools` first, then `get_tool_info` for the Maps tool they need.
|
|
104
|
+
- Use this server when travel, routing, or place lookup affects a Calendar, Reminders, Messages, or Mail action.
|
|
105
|
+
- Confirm origin, destination, and transport mode before writing a time-sensitive plan.
|
|
106
|
+
- If helper compilation fails, install Xcode command line tools and retry.
|
|
107
|
+
|
|
108
|
+
## Health And Recovery
|
|
109
|
+
|
|
110
|
+
- `maps_health`
|
|
111
|
+
- `maps_permission_guide`
|
|
112
|
+
|
|
113
|
+
## Launch Checklist
|
|
114
|
+
|
|
115
|
+
- Add `uvx apple-maps-mcp` (or a clone's `AppleMaps-MCP/start.sh`) to your MCP client
|
|
116
|
+
- Reload or reconnect the client so the Maps tool surface is loaded into context
|
|
117
|
+
- Call `maps_health` first
|
|
118
|
+
- If the local helper or routing surface is blocked, call `maps_permission_guide`
|
|
119
|
+
- Run `maps_search_places` once to confirm the local Swift helper compiles
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<!-- mcp-name: io.github.jonathanrreed/apple-maps-mcp -->
|
|
2
|
+
|
|
3
|
+
# Apple Maps MCP
|
|
4
|
+
|
|
5
|
+
Local MCP server for Apple Maps search and routing on macOS.
|
|
6
|
+
|
|
7
|
+
## Capabilities
|
|
8
|
+
|
|
9
|
+
- search for places
|
|
10
|
+
- estimate route distance and travel time
|
|
11
|
+
- build Apple Maps links
|
|
12
|
+
- open directions in Apple Maps
|
|
13
|
+
- resource: `maps://status`
|
|
14
|
+
- prompt: `maps_plan_route`
|
|
15
|
+
- tool discovery helpers `search_tools` and `get_tool_info` for context-constrained clients
|
|
16
|
+
|
|
17
|
+
## Install On This Mac
|
|
18
|
+
|
|
19
|
+
<details>
|
|
20
|
+
<summary>Quick start (uvx, from PyPI)</summary>
|
|
21
|
+
|
|
22
|
+
With [uv](https://docs.astral.sh/uv/getting-started/installation/) installed:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uvx apple-maps-mcp
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
No clone, no venv management.
|
|
29
|
+
|
|
30
|
+
</details>
|
|
31
|
+
|
|
32
|
+
<details>
|
|
33
|
+
<summary>From a clone</summary>
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
git clone https://github.com/JonathanRReed/Apple-MCPs.git
|
|
37
|
+
cd Apple-MCPs
|
|
38
|
+
uv sync --all-packages
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This builds one workspace environment with every server's entry point in `.venv/bin` (for example `.venv/bin/apple-maps-mcp`). You can also point an MCP client at `AppleMaps-MCP/start.sh`, which prefers `uv run` and falls back to a plain venv bootstrap (Python 3.11+ required).
|
|
42
|
+
|
|
43
|
+
</details>
|
|
44
|
+
|
|
45
|
+
## Install In AI Agents
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"mcpServers": {
|
|
50
|
+
"apple-maps": {
|
|
51
|
+
"command": "uvx",
|
|
52
|
+
"args": ["apple-maps-mcp"],
|
|
53
|
+
"env": {}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Running from a clone instead? Use `/path/to/Apple-MCPs/AppleMaps-MCP/start.sh` as the command with empty `args`.
|
|
60
|
+
|
|
61
|
+
Claude Code:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
claude mcp add --transport stdio --scope project apple-maps -- uvx apple-maps-mcp
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Transport
|
|
68
|
+
|
|
69
|
+
`stdio` is the default and recommended transport. Set `APPLE_MAPS_MCP_TRANSPORT=streamable-http` (with optional `APPLE_MAPS_MCP_HOST` and `APPLE_MAPS_MCP_PORT`) to serve Streamable HTTP instead.
|
|
70
|
+
|
|
71
|
+
## Prompting Notes
|
|
72
|
+
|
|
73
|
+
- `tools/list` returns the full Maps tool surface. Context-constrained clients can use `search_tools` first, then `get_tool_info` for the Maps tool they need.
|
|
74
|
+
- Use this server when travel, routing, or place lookup affects a Calendar, Reminders, Messages, or Mail action.
|
|
75
|
+
- Confirm origin, destination, and transport mode before writing a time-sensitive plan.
|
|
76
|
+
- If helper compilation fails, install Xcode command line tools and retry.
|
|
77
|
+
|
|
78
|
+
## Health And Recovery
|
|
79
|
+
|
|
80
|
+
- `maps_health`
|
|
81
|
+
- `maps_permission_guide`
|
|
82
|
+
|
|
83
|
+
## Launch Checklist
|
|
84
|
+
|
|
85
|
+
- Add `uvx apple-maps-mcp` (or a clone's `AppleMaps-MCP/start.sh`) to your MCP client
|
|
86
|
+
- Reload or reconnect the client so the Maps tool surface is loaded into context
|
|
87
|
+
- Call `maps_health` first
|
|
88
|
+
- If the local helper or routing surface is blocked, call `maps_permission_guide`
|
|
89
|
+
- Run `maps_search_places` once to confirm the local Swift helper compiles
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "apple-maps-mcp"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Local Apple Maps MCP server for macOS"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
keywords = ["mcp", "model-context-protocol", "macos", "apple", "ai-agent", "automation"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Environment :: MacOS X",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: MacOS",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"apple-mcp-common>=1.0.0,<2",
|
|
27
|
+
"mcp>=2.0.0,<3",
|
|
28
|
+
"pydantic>=2.12.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/JonathanRReed/Apple-MCPs"
|
|
33
|
+
Repository = "https://github.com/JonathanRReed/Apple-MCPs"
|
|
34
|
+
Changelog = "https://github.com/JonathanRReed/Apple-MCPs/blob/main/CHANGELOG.md"
|
|
35
|
+
Issues = "https://github.com/JonathanRReed/Apple-MCPs/issues"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
apple-maps-mcp = "apple_maps_mcp.tools:main"
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
dev = [
|
|
42
|
+
"pytest>=8,<10",
|
|
43
|
+
"ruff>=0.12,<1",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.setuptools]
|
|
47
|
+
package-dir = {"" = "src"}
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.package-data]
|
|
50
|
+
apple_maps_mcp = ["*.swift"]
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.packages.find]
|
|
53
|
+
where = ["src"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
pythonpath = ["src"]
|
|
57
|
+
testpaths = ["tests"]
|
|
58
|
+
|
|
59
|
+
[tool.uv.sources]
|
|
60
|
+
apple-mcp-common = { workspace = true }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Apple Maps MCP package."""
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import MapKit
|
|
3
|
+
|
|
4
|
+
struct BridgeFailure: Error {
|
|
5
|
+
let errorCode: String
|
|
6
|
+
let message: String
|
|
7
|
+
let suggestion: String?
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
struct ErrorPayload: Encodable {
|
|
11
|
+
let ok = false
|
|
12
|
+
let error_code: String
|
|
13
|
+
let message: String
|
|
14
|
+
let suggestion: String?
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
struct PlaceRecord: Encodable {
|
|
18
|
+
let name: String
|
|
19
|
+
let address: String?
|
|
20
|
+
let latitude: Double?
|
|
21
|
+
let longitude: Double?
|
|
22
|
+
let phone: String?
|
|
23
|
+
let url: String?
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
struct PlaceListPayload: Encodable {
|
|
27
|
+
let places: [PlaceRecord]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
struct DirectionsPayload: Encodable {
|
|
31
|
+
let origin: PlaceRecord
|
|
32
|
+
let destination: PlaceRecord
|
|
33
|
+
let transport: String
|
|
34
|
+
let distance_meters: Double
|
|
35
|
+
let expected_travel_time_seconds: Double
|
|
36
|
+
let advisory_notices: [String]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@main
|
|
40
|
+
struct AppleMapsBridge {
|
|
41
|
+
static let asyncTimeoutSeconds: TimeInterval = 15
|
|
42
|
+
static let encoder: JSONEncoder = {
|
|
43
|
+
let encoder = JSONEncoder()
|
|
44
|
+
encoder.outputFormatting = [.sortedKeys]
|
|
45
|
+
return encoder
|
|
46
|
+
}()
|
|
47
|
+
|
|
48
|
+
static func main() async {
|
|
49
|
+
do {
|
|
50
|
+
let arguments = Array(CommandLine.arguments.dropFirst())
|
|
51
|
+
guard arguments.count == 2 else {
|
|
52
|
+
throw BridgeFailure(errorCode: "USAGE_ERROR", message: "Expected a command and a JSON payload.", suggestion: "Pass search-places or directions with a JSON object.")
|
|
53
|
+
}
|
|
54
|
+
let command = arguments[0]
|
|
55
|
+
let payload = try decodeJSONObject(arguments[1])
|
|
56
|
+
let data = try await handle(command: command, payload: payload)
|
|
57
|
+
FileHandle.standardOutput.write(data)
|
|
58
|
+
} catch let failure as BridgeFailure {
|
|
59
|
+
writeErrorAndExit(failure)
|
|
60
|
+
} catch {
|
|
61
|
+
writeErrorAndExit(BridgeFailure(errorCode: "UNEXPECTED_ERROR", message: String(describing: error), suggestion: "Retry the Apple Maps request."))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static func handle(command: String, payload: [String: Any]) async throws -> Data {
|
|
66
|
+
switch command {
|
|
67
|
+
case "search-places":
|
|
68
|
+
let query = try string(payload["query"], field: "query")
|
|
69
|
+
let limit = try int(payload["limit"], field: "limit", defaultValue: 5)
|
|
70
|
+
let places = try await searchPlaces(query: query, limit: limit)
|
|
71
|
+
return try encoder.encode(PlaceListPayload(places: places))
|
|
72
|
+
case "directions":
|
|
73
|
+
let originQuery = try string(payload["origin"], field: "origin")
|
|
74
|
+
let destinationQuery = try string(payload["destination"], field: "destination")
|
|
75
|
+
let transport = try string(payload["transport"], field: "transport")
|
|
76
|
+
let route = try await directions(originQuery: originQuery, destinationQuery: destinationQuery, transport: transport)
|
|
77
|
+
return try encoder.encode(route)
|
|
78
|
+
default:
|
|
79
|
+
throw BridgeFailure(errorCode: "UNKNOWN_COMMAND", message: "Unknown command '\(command)'.", suggestion: "Use search-places or directions.")
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static func searchPlaces(query: String, limit: Int) async throws -> [PlaceRecord] {
|
|
84
|
+
let request = MKLocalSearch.Request()
|
|
85
|
+
request.naturalLanguageQuery = query
|
|
86
|
+
let search = MKLocalSearch(request: request)
|
|
87
|
+
let response = try await withTimeout(
|
|
88
|
+
errorCode: "SEARCH_TIMEOUT",
|
|
89
|
+
message: "Apple Maps search timed out.",
|
|
90
|
+
suggestion: "Retry the query, or confirm Maps services are available on this Mac."
|
|
91
|
+
) {
|
|
92
|
+
try await search.start()
|
|
93
|
+
}
|
|
94
|
+
let items = response.mapItems.prefix(limit)
|
|
95
|
+
return items.map(placeRecord)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static func directions(originQuery: String, destinationQuery: String, transport: String) async throws -> DirectionsPayload {
|
|
99
|
+
let originItem = try await resolveFirstMapItem(query: originQuery)
|
|
100
|
+
let destinationItem = try await resolveFirstMapItem(query: destinationQuery)
|
|
101
|
+
|
|
102
|
+
let request = MKDirections.Request()
|
|
103
|
+
request.source = originItem
|
|
104
|
+
request.destination = destinationItem
|
|
105
|
+
request.transportType = transportType(transport)
|
|
106
|
+
|
|
107
|
+
let directions = MKDirections(request: request)
|
|
108
|
+
let routeResponse = try await withTimeout(
|
|
109
|
+
errorCode: "DIRECTIONS_TIMEOUT",
|
|
110
|
+
message: "Apple Maps directions timed out.",
|
|
111
|
+
suggestion: "Retry the request, or confirm Maps services are available on this Mac."
|
|
112
|
+
) {
|
|
113
|
+
try await directions.calculate()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
guard let route = routeResponse.routes.first else {
|
|
117
|
+
throw BridgeFailure(errorCode: "ROUTE_NOT_FOUND", message: "Apple Maps did not return a route.", suggestion: "Try a different transport mode or place query.")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return DirectionsPayload(
|
|
121
|
+
origin: placeRecord(originItem),
|
|
122
|
+
destination: placeRecord(destinationItem),
|
|
123
|
+
transport: transport,
|
|
124
|
+
distance_meters: route.distance,
|
|
125
|
+
expected_travel_time_seconds: route.expectedTravelTime,
|
|
126
|
+
advisory_notices: route.advisoryNotices
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
static func resolveFirstMapItem(query: String) async throws -> MKMapItem {
|
|
131
|
+
let request = MKLocalSearch.Request()
|
|
132
|
+
request.naturalLanguageQuery = query
|
|
133
|
+
let search = MKLocalSearch(request: request)
|
|
134
|
+
let response = try await withTimeout(
|
|
135
|
+
errorCode: "SEARCH_TIMEOUT",
|
|
136
|
+
message: "Apple Maps search timed out.",
|
|
137
|
+
suggestion: "Retry the query, or confirm Maps services are available on this Mac."
|
|
138
|
+
) {
|
|
139
|
+
try await search.start()
|
|
140
|
+
}
|
|
141
|
+
guard let item = response.mapItems.first else {
|
|
142
|
+
throw BridgeFailure(errorCode: "PLACE_NOT_FOUND", message: "Apple Maps did not find a matching place.", suggestion: "Use a more specific address or place name.")
|
|
143
|
+
}
|
|
144
|
+
return item
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
static func placeRecord(_ item: MKMapItem) -> PlaceRecord {
|
|
148
|
+
let placemark = item.placemark
|
|
149
|
+
let formattedAddress = [placemark.subThoroughfare, placemark.thoroughfare, placemark.locality, placemark.administrativeArea, placemark.postalCode]
|
|
150
|
+
.compactMap { $0 }
|
|
151
|
+
.joined(separator: " ")
|
|
152
|
+
|
|
153
|
+
return PlaceRecord(
|
|
154
|
+
name: item.name ?? placemark.name ?? "Unknown",
|
|
155
|
+
address: formattedAddress.isEmpty ? nil : formattedAddress,
|
|
156
|
+
latitude: placemark.coordinate.latitude,
|
|
157
|
+
longitude: placemark.coordinate.longitude,
|
|
158
|
+
phone: item.phoneNumber,
|
|
159
|
+
url: item.url?.absoluteString
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
static func transportType(_ value: String) -> MKDirectionsTransportType {
|
|
164
|
+
switch value.lowercased() {
|
|
165
|
+
case "walking":
|
|
166
|
+
return .walking
|
|
167
|
+
case "transit":
|
|
168
|
+
return .transit
|
|
169
|
+
default:
|
|
170
|
+
return .automobile
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
static func decodeJSONObject(_ raw: String) throws -> [String: Any] {
|
|
175
|
+
guard
|
|
176
|
+
let data = raw.data(using: .utf8),
|
|
177
|
+
let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
178
|
+
else {
|
|
179
|
+
throw BridgeFailure(errorCode: "INVALID_INPUT", message: "Expected a JSON object payload.", suggestion: "Pass a valid JSON object.")
|
|
180
|
+
}
|
|
181
|
+
return payload
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
static func string(_ value: Any?, field: String) throws -> String {
|
|
185
|
+
guard let text = value as? String, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
186
|
+
throw BridgeFailure(errorCode: "INVALID_INPUT", message: "\(field) must be a non-empty string.", suggestion: "Provide the missing field.")
|
|
187
|
+
}
|
|
188
|
+
return text
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
static func int(_ value: Any?, field: String, defaultValue: Int) throws -> Int {
|
|
192
|
+
if value == nil {
|
|
193
|
+
return defaultValue
|
|
194
|
+
}
|
|
195
|
+
if let number = value as? Int {
|
|
196
|
+
return number
|
|
197
|
+
}
|
|
198
|
+
if let number = value as? NSNumber {
|
|
199
|
+
return number.intValue
|
|
200
|
+
}
|
|
201
|
+
throw BridgeFailure(errorCode: "INVALID_INPUT", message: "\(field) must be an integer.", suggestion: "Provide a numeric limit.")
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
static func withTimeout<T>(
|
|
205
|
+
errorCode: String,
|
|
206
|
+
message: String,
|
|
207
|
+
suggestion: String?,
|
|
208
|
+
operation: @escaping @Sendable () async throws -> T
|
|
209
|
+
) async throws -> T {
|
|
210
|
+
try await withThrowingTaskGroup(of: T.self) { group in
|
|
211
|
+
group.addTask {
|
|
212
|
+
try await operation()
|
|
213
|
+
}
|
|
214
|
+
group.addTask {
|
|
215
|
+
let duration = UInt64(asyncTimeoutSeconds * 1_000_000_000)
|
|
216
|
+
try await Task.sleep(nanoseconds: duration)
|
|
217
|
+
throw BridgeFailure(errorCode: errorCode, message: message, suggestion: suggestion)
|
|
218
|
+
}
|
|
219
|
+
guard let result = try await group.next() else {
|
|
220
|
+
throw BridgeFailure(errorCode: errorCode, message: message, suggestion: suggestion)
|
|
221
|
+
}
|
|
222
|
+
group.cancelAll()
|
|
223
|
+
return result
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
static func writeErrorAndExit(_ failure: BridgeFailure) -> Never {
|
|
228
|
+
let payload = ErrorPayload(error_code: failure.errorCode, message: failure.message, suggestion: failure.suggestion)
|
|
229
|
+
if let data = try? encoder.encode(payload) {
|
|
230
|
+
FileHandle.standardError.write(data)
|
|
231
|
+
}
|
|
232
|
+
exit(1)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Settings:
|
|
9
|
+
server_name: str
|
|
10
|
+
version: str
|
|
11
|
+
helper_source: Path
|
|
12
|
+
helper_binary: Path
|
|
13
|
+
transport: str
|
|
14
|
+
host: str
|
|
15
|
+
port: int
|
|
16
|
+
log_level: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse_int(value: str | None, default: int) -> int:
|
|
20
|
+
if value is None:
|
|
21
|
+
return default
|
|
22
|
+
try:
|
|
23
|
+
return int(value)
|
|
24
|
+
except ValueError:
|
|
25
|
+
return default
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@lru_cache(maxsize=1)
|
|
29
|
+
def load_settings() -> Settings:
|
|
30
|
+
package_dir = Path(__file__).resolve().parent
|
|
31
|
+
build_dir = Path(
|
|
32
|
+
os.environ.get(
|
|
33
|
+
"APPLE_MAPS_MCP_HELPER_BUILD_DIR",
|
|
34
|
+
str(Path.home() / ".apple-mcps" / "build"),
|
|
35
|
+
)
|
|
36
|
+
).expanduser()
|
|
37
|
+
return Settings(
|
|
38
|
+
server_name="Apple Maps MCP",
|
|
39
|
+
version="0.1.0",
|
|
40
|
+
helper_source=package_dir / "apple_maps_bridge.swift",
|
|
41
|
+
helper_binary=build_dir / "apple-maps-bridge",
|
|
42
|
+
transport=os.environ.get("APPLE_MAPS_MCP_TRANSPORT", "stdio").strip().lower() or "stdio",
|
|
43
|
+
host=os.environ.get("APPLE_MAPS_MCP_HOST", "127.0.0.1"),
|
|
44
|
+
port=_parse_int(os.environ.get("APPLE_MAPS_MCP_PORT"), 8000),
|
|
45
|
+
log_level=os.environ.get("APPLE_MAPS_MCP_LOG_LEVEL", "INFO").strip().upper() or "INFO",
|
|
46
|
+
)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import quote_plus, urlencode
|
|
8
|
+
|
|
9
|
+
from apple_maps_mcp.config import load_settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class MapsBridgeError(Exception):
|
|
14
|
+
error_code: str
|
|
15
|
+
message: str
|
|
16
|
+
suggestion: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AppleMapsBridge:
|
|
20
|
+
def __init__(self, helper_source: Path, helper_binary: Path) -> None:
|
|
21
|
+
self.helper_source = helper_source
|
|
22
|
+
self.helper_binary = helper_binary
|
|
23
|
+
self.timeout_seconds = 20
|
|
24
|
+
|
|
25
|
+
def helper_available(self) -> tuple[bool, bool]:
|
|
26
|
+
return self.helper_source.exists(), self.helper_binary.exists()
|
|
27
|
+
|
|
28
|
+
def _ensure_helper(self) -> None:
|
|
29
|
+
if not self.helper_source.exists():
|
|
30
|
+
raise MapsBridgeError(
|
|
31
|
+
"HELPER_SOURCE_MISSING",
|
|
32
|
+
f"Missing Apple Maps helper source at '{self.helper_source}'.",
|
|
33
|
+
"Reinstall apple-maps-mcp and retry.",
|
|
34
|
+
)
|
|
35
|
+
self.helper_binary.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
if self.helper_binary.exists() and self.helper_binary.stat().st_mtime >= self.helper_source.stat().st_mtime:
|
|
37
|
+
return
|
|
38
|
+
try:
|
|
39
|
+
subprocess.run(
|
|
40
|
+
["swiftc", "-parse-as-library", "-O", str(self.helper_source), "-o", str(self.helper_binary)],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
check=True,
|
|
43
|
+
text=True,
|
|
44
|
+
timeout=self.timeout_seconds,
|
|
45
|
+
)
|
|
46
|
+
except subprocess.TimeoutExpired as exc:
|
|
47
|
+
raise MapsBridgeError(
|
|
48
|
+
"HELPER_COMPILE_TIMEOUT",
|
|
49
|
+
"Timed out while compiling the Apple Maps helper.",
|
|
50
|
+
"Confirm Xcode command line tools are installed, then retry.",
|
|
51
|
+
) from exc
|
|
52
|
+
except OSError as exc:
|
|
53
|
+
raise MapsBridgeError(
|
|
54
|
+
"SWIFTC_UNAVAILABLE",
|
|
55
|
+
f"Could not run 'swiftc': {exc}.",
|
|
56
|
+
"This server requires macOS with the Swift toolchain (swiftc) available.",
|
|
57
|
+
) from exc
|
|
58
|
+
except subprocess.CalledProcessError as exc:
|
|
59
|
+
stderr = exc.stderr.strip() if exc.stderr else ""
|
|
60
|
+
raise MapsBridgeError(
|
|
61
|
+
"HELPER_COMPILE_FAILED",
|
|
62
|
+
stderr or "Failed to compile the Apple Maps helper.",
|
|
63
|
+
"Install Xcode command line tools and retry.",
|
|
64
|
+
) from exc
|
|
65
|
+
|
|
66
|
+
def _run_helper(self, command: str, payload: dict[str, object]) -> dict[str, object]:
|
|
67
|
+
self._ensure_helper()
|
|
68
|
+
try:
|
|
69
|
+
result = subprocess.run(
|
|
70
|
+
[str(self.helper_binary), command, json.dumps(payload)],
|
|
71
|
+
capture_output=True,
|
|
72
|
+
check=True,
|
|
73
|
+
text=True,
|
|
74
|
+
timeout=self.timeout_seconds,
|
|
75
|
+
)
|
|
76
|
+
except subprocess.TimeoutExpired as exc:
|
|
77
|
+
raise MapsBridgeError(
|
|
78
|
+
"HELPER_TIMEOUT",
|
|
79
|
+
"Apple Maps helper timed out while waiting for MapKit.",
|
|
80
|
+
"Retry the request, or confirm Maps and Location Services are available on this Mac.",
|
|
81
|
+
) from exc
|
|
82
|
+
except OSError as exc:
|
|
83
|
+
raise MapsBridgeError(
|
|
84
|
+
"HELPER_UNAVAILABLE",
|
|
85
|
+
f"Could not run the Apple Maps helper '{self.helper_binary}': {exc}.",
|
|
86
|
+
"This server requires macOS with the compiled Apple Maps helper available.",
|
|
87
|
+
) from exc
|
|
88
|
+
except subprocess.CalledProcessError as exc:
|
|
89
|
+
stderr = exc.stderr.strip() or exc.stdout.strip()
|
|
90
|
+
raise MapsBridgeError("HELPER_FAILED", stderr or "Apple Maps helper failed.", "Retry the request.") from exc
|
|
91
|
+
try:
|
|
92
|
+
return json.loads(result.stdout)
|
|
93
|
+
except json.JSONDecodeError as exc:
|
|
94
|
+
raise MapsBridgeError("INVALID_RESPONSE", "Apple Maps helper returned invalid JSON.", "Retry the request.") from exc
|
|
95
|
+
|
|
96
|
+
def search_places(self, query: str, limit: int = 5) -> dict[str, object]:
|
|
97
|
+
return self._run_helper("search-places", {"query": query, "limit": limit})
|
|
98
|
+
|
|
99
|
+
def directions(self, origin: str, destination: str, transport: str = "driving") -> dict[str, object]:
|
|
100
|
+
payload = self._run_helper("directions", {"origin": origin, "destination": destination, "transport": transport})
|
|
101
|
+
payload["maps_url"] = self.maps_url(destination=destination, origin=origin, transport=transport)
|
|
102
|
+
return payload
|
|
103
|
+
|
|
104
|
+
def maps_url(self, destination: str, origin: str | None = None, transport: str = "driving") -> str:
|
|
105
|
+
params = {"daddr": destination}
|
|
106
|
+
if origin:
|
|
107
|
+
params["saddr"] = origin
|
|
108
|
+
transport_flag = {"driving": "d", "walking": "w", "transit": "r"}.get(transport, "d")
|
|
109
|
+
params["dirflg"] = transport_flag
|
|
110
|
+
return f"https://maps.apple.com/?{urlencode(params, quote_via=quote_plus)}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_bridge() -> AppleMapsBridge:
|
|
114
|
+
settings = load_settings()
|
|
115
|
+
return AppleMapsBridge(settings.helper_source, settings.helper_binary)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ToolError(BaseModel):
|
|
7
|
+
error_code: str = Field(description="Stable machine-readable error code")
|
|
8
|
+
message: str = Field(description="Human-readable error message")
|
|
9
|
+
suggestion: str | None = Field(default=None, description="Suggested next step")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ErrorResponse(BaseModel):
|
|
13
|
+
ok: bool = False
|
|
14
|
+
error: ToolError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HealthResponse(BaseModel):
|
|
18
|
+
ok: bool = True
|
|
19
|
+
server_name: str
|
|
20
|
+
version: str
|
|
21
|
+
helper_available: bool
|
|
22
|
+
helper_compiled: bool
|
|
23
|
+
transport: str
|
|
24
|
+
capabilities: list[str]
|
|
25
|
+
supports: list[str]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PlaceRecord(BaseModel):
|
|
29
|
+
name: str
|
|
30
|
+
address: str | None = None
|
|
31
|
+
latitude: float | None = None
|
|
32
|
+
longitude: float | None = None
|
|
33
|
+
phone: str | None = None
|
|
34
|
+
url: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PlaceSearchResponse(BaseModel):
|
|
38
|
+
ok: bool = True
|
|
39
|
+
places: list[PlaceRecord]
|
|
40
|
+
count: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DirectionsResponse(BaseModel):
|
|
44
|
+
ok: bool = True
|
|
45
|
+
origin: PlaceRecord
|
|
46
|
+
destination: PlaceRecord
|
|
47
|
+
transport: str
|
|
48
|
+
distance_meters: float
|
|
49
|
+
expected_travel_time_seconds: float
|
|
50
|
+
advisory_notices: list[str]
|
|
51
|
+
maps_url: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MapsLinkResponse(BaseModel):
|
|
55
|
+
ok: bool = True
|
|
56
|
+
url: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class OpenMapsResponse(BaseModel):
|
|
60
|
+
ok: bool = True
|
|
61
|
+
opened: bool
|
|
62
|
+
url: str
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from mcp.server.mcpserver import MCPServer
|
|
7
|
+
from mcp.types import Annotations, ToolAnnotations
|
|
8
|
+
|
|
9
|
+
from apple_maps_mcp.config import load_settings
|
|
10
|
+
from apple_maps_mcp.maps_bridge import AppleMapsBridge, MapsBridgeError, build_bridge
|
|
11
|
+
from apple_maps_mcp.models import DirectionsResponse, ErrorResponse, HealthResponse, MapsLinkResponse, OpenMapsResponse, PlaceRecord, PlaceSearchResponse, ToolError
|
|
12
|
+
from apple_mcp_common.discovery import install_search_first_discovery
|
|
13
|
+
|
|
14
|
+
SERVER_INSTRUCTIONS = (
|
|
15
|
+
"Use this server for Apple Maps and travel context on macOS. "
|
|
16
|
+
"Search here when the user wants to find a place, estimate travel time, build an Apple Maps link, or open directions in Apple Maps."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
mcp = MCPServer("Apple Maps MCP", instructions=SERVER_INSTRUCTIONS)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _bridge() -> AppleMapsBridge:
|
|
23
|
+
return build_bridge()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _error_response(error_code: str, message: str, suggestion: str | None = None) -> ErrorResponse:
|
|
27
|
+
return ErrorResponse(error=ToolError(error_code=error_code, message=message, suggestion=suggestion))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resource_json(value: object) -> str:
|
|
31
|
+
return json.dumps(value, indent=2, sort_keys=True, default=str)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@mcp.resource(
|
|
35
|
+
"maps://status",
|
|
36
|
+
name="maps_status",
|
|
37
|
+
title="Maps Status",
|
|
38
|
+
description="Apple Maps helper availability and supported transport modes.",
|
|
39
|
+
mime_type="application/json",
|
|
40
|
+
annotations=Annotations(audience=["assistant"], priority=0.75),
|
|
41
|
+
)
|
|
42
|
+
def maps_status_resource() -> str:
|
|
43
|
+
helper_available, helper_compiled = _bridge().helper_available()
|
|
44
|
+
return _resource_json(
|
|
45
|
+
{
|
|
46
|
+
"helper_available": helper_available,
|
|
47
|
+
"helper_compiled": helper_compiled,
|
|
48
|
+
"supported_transports": ["driving", "walking", "transit"],
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@mcp.prompt(name="maps_plan_route", title="Plan Route")
|
|
54
|
+
def maps_plan_route_prompt() -> str:
|
|
55
|
+
return (
|
|
56
|
+
"Use Apple Maps to find the right destination, estimate travel time, and choose the right transport mode "
|
|
57
|
+
"before scheduling a meeting or sending directions."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@mcp.tool(
|
|
62
|
+
title="Maps Health",
|
|
63
|
+
description="Report the active Apple Maps MCP configuration.",
|
|
64
|
+
annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True),
|
|
65
|
+
structured_output=True,
|
|
66
|
+
)
|
|
67
|
+
def maps_health() -> HealthResponse:
|
|
68
|
+
settings = load_settings()
|
|
69
|
+
helper_available, helper_compiled = _bridge().helper_available()
|
|
70
|
+
return HealthResponse(
|
|
71
|
+
server_name=settings.server_name,
|
|
72
|
+
version=settings.version,
|
|
73
|
+
helper_available=helper_available,
|
|
74
|
+
helper_compiled=helper_compiled,
|
|
75
|
+
transport=settings.transport,
|
|
76
|
+
capabilities=[
|
|
77
|
+
"search_places",
|
|
78
|
+
"get_directions",
|
|
79
|
+
"build_maps_link",
|
|
80
|
+
"open_directions_in_maps",
|
|
81
|
+
"resources",
|
|
82
|
+
"prompts",
|
|
83
|
+
],
|
|
84
|
+
supports=["stdio", "streamable-http"],
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@mcp.tool(
|
|
89
|
+
title="Maps Permission Guide",
|
|
90
|
+
description="Explain Apple Maps MCP local helper requirements on macOS.",
|
|
91
|
+
annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True),
|
|
92
|
+
structured_output=True,
|
|
93
|
+
)
|
|
94
|
+
def maps_permission_guide() -> dict[str, object]:
|
|
95
|
+
return {
|
|
96
|
+
"ok": True,
|
|
97
|
+
"domain": "maps",
|
|
98
|
+
"can_prompt_in_app": False,
|
|
99
|
+
"requires_manual_system_settings": False,
|
|
100
|
+
"steps": [
|
|
101
|
+
"Apple Maps MCP uses a local Swift helper for search and routing.",
|
|
102
|
+
"If helper compilation fails, install Xcode command line tools and retry.",
|
|
103
|
+
"Opening a route uses the standard macOS open command with an Apple Maps URL.",
|
|
104
|
+
],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@mcp.tool(
|
|
109
|
+
title="Search Places",
|
|
110
|
+
description="Search Apple Maps for matching places.",
|
|
111
|
+
annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True),
|
|
112
|
+
structured_output=True,
|
|
113
|
+
)
|
|
114
|
+
def maps_search_places(query: str, limit: int = 5) -> PlaceSearchResponse | ErrorResponse:
|
|
115
|
+
try:
|
|
116
|
+
payload = _bridge().search_places(query=query, limit=limit)
|
|
117
|
+
places = [PlaceRecord(**item) for item in payload.get("places", [])]
|
|
118
|
+
return PlaceSearchResponse(places=places, count=len(places))
|
|
119
|
+
except MapsBridgeError as exc:
|
|
120
|
+
return _error_response(exc.error_code, exc.message, exc.suggestion)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@mcp.tool(
|
|
124
|
+
title="Get Directions",
|
|
125
|
+
description="Get Apple Maps route details between an origin and destination.",
|
|
126
|
+
annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True),
|
|
127
|
+
structured_output=True,
|
|
128
|
+
)
|
|
129
|
+
def maps_get_directions(origin: str, destination: str, transport: str = "driving") -> DirectionsResponse | ErrorResponse:
|
|
130
|
+
try:
|
|
131
|
+
payload = _bridge().directions(origin=origin, destination=destination, transport=transport)
|
|
132
|
+
return DirectionsResponse(
|
|
133
|
+
origin=PlaceRecord(**payload["origin"]),
|
|
134
|
+
destination=PlaceRecord(**payload["destination"]),
|
|
135
|
+
transport=str(payload["transport"]),
|
|
136
|
+
distance_meters=float(payload["distance_meters"]),
|
|
137
|
+
expected_travel_time_seconds=float(payload["expected_travel_time_seconds"]),
|
|
138
|
+
advisory_notices=[str(item) for item in payload.get("advisory_notices", [])],
|
|
139
|
+
maps_url=str(payload["maps_url"]),
|
|
140
|
+
)
|
|
141
|
+
except MapsBridgeError as exc:
|
|
142
|
+
return _error_response(exc.error_code, exc.message, exc.suggestion)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@mcp.tool(
|
|
146
|
+
title="Build Maps Link",
|
|
147
|
+
description="Build an Apple Maps URL for a destination or route.",
|
|
148
|
+
annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True),
|
|
149
|
+
structured_output=True,
|
|
150
|
+
)
|
|
151
|
+
def maps_build_maps_link(destination: str, origin: str | None = None, transport: str = "driving") -> MapsLinkResponse:
|
|
152
|
+
return MapsLinkResponse(url=_bridge().maps_url(destination=destination, origin=origin, transport=transport))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@mcp.tool(
|
|
156
|
+
title="Open Directions In Maps",
|
|
157
|
+
description="Open directions in the Apple Maps app.",
|
|
158
|
+
annotations=ToolAnnotations(destructive_hint=False, idempotent_hint=False, open_world_hint=True),
|
|
159
|
+
structured_output=True,
|
|
160
|
+
)
|
|
161
|
+
def maps_open_directions_in_maps(destination: str, origin: str | None = None, transport: str = "driving") -> OpenMapsResponse | ErrorResponse:
|
|
162
|
+
try:
|
|
163
|
+
url = _bridge().maps_url(destination=destination, origin=origin, transport=transport)
|
|
164
|
+
subprocess.run(["open", url], capture_output=True, check=True, text=True)
|
|
165
|
+
return OpenMapsResponse(opened=True, url=url)
|
|
166
|
+
except MapsBridgeError as exc:
|
|
167
|
+
return _error_response(exc.error_code, exc.message, exc.suggestion)
|
|
168
|
+
except subprocess.CalledProcessError:
|
|
169
|
+
return _error_response("OPEN_FAILED", "Failed to open Apple Maps.", "Retry the request.")
|
|
170
|
+
except OSError:
|
|
171
|
+
return _error_response("OPEN_UNAVAILABLE", "Could not run the 'open' command.", "This server requires macOS with the 'open' command available.")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _serialize_prompt_messages(messages: list[object]) -> list[dict[str, object]]:
|
|
175
|
+
return [
|
|
176
|
+
{
|
|
177
|
+
"role": getattr(message, "role", "user"),
|
|
178
|
+
"content": message.content.model_dump(mode="json") if hasattr(message.content, "model_dump") else message.content,
|
|
179
|
+
}
|
|
180
|
+
for message in messages
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@mcp.tool(
|
|
185
|
+
title="Maps List Prompts",
|
|
186
|
+
description="Fallback prompt discovery tool for tool-only MCP clients.",
|
|
187
|
+
annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True),
|
|
188
|
+
structured_output=True,
|
|
189
|
+
)
|
|
190
|
+
async def maps_list_prompts() -> dict[str, object]:
|
|
191
|
+
prompts = await mcp.list_prompts()
|
|
192
|
+
return {
|
|
193
|
+
"ok": True,
|
|
194
|
+
"prompts": [{"name": prompt.name, "title": prompt.title, "description": prompt.description} for prompt in prompts],
|
|
195
|
+
"count": len(prompts),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@mcp.tool(
|
|
200
|
+
name="maps_get_prompt",
|
|
201
|
+
title="Maps Get Prompt",
|
|
202
|
+
description="Fallback prompt rendering tool for tool-only MCP clients.",
|
|
203
|
+
annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True),
|
|
204
|
+
structured_output=True,
|
|
205
|
+
)
|
|
206
|
+
async def maps_get_prompt_prompt(name: str, arguments_json: str | None = None) -> dict[str, object]:
|
|
207
|
+
arguments = json.loads(arguments_json) if arguments_json else None
|
|
208
|
+
prompt = await mcp.get_prompt(name, arguments)
|
|
209
|
+
return {"ok": True, "name": name, "messages": _serialize_prompt_messages(prompt.messages), "message_count": len(prompt.messages)}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
TOOL_DISCOVERY = install_search_first_discovery(
|
|
213
|
+
mcp,
|
|
214
|
+
server_name="Apple Maps MCP",
|
|
215
|
+
domain="maps",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def main() -> None:
|
|
220
|
+
settings = load_settings()
|
|
221
|
+
if settings.transport == "stdio":
|
|
222
|
+
mcp.run(transport="stdio")
|
|
223
|
+
return
|
|
224
|
+
mcp.settings.log_level = settings.log_level
|
|
225
|
+
mcp.run(
|
|
226
|
+
transport="streamable-http",
|
|
227
|
+
host=settings.host,
|
|
228
|
+
port=settings.port,
|
|
229
|
+
json_response=True,
|
|
230
|
+
stateless_http=True,
|
|
231
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apple-maps-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Local Apple Maps MCP server for macOS
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/JonathanRReed/Apple-MCPs
|
|
7
|
+
Project-URL: Repository, https://github.com/JonathanRReed/Apple-MCPs
|
|
8
|
+
Project-URL: Changelog, https://github.com/JonathanRReed/Apple-MCPs/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/JonathanRReed/Apple-MCPs/issues
|
|
10
|
+
Keywords: mcp,model-context-protocol,macos,apple,ai-agent,automation
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Environment :: MacOS X
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: apple-mcp-common<2,>=1.0.0
|
|
24
|
+
Requires-Dist: mcp<3,>=2.0.0
|
|
25
|
+
Requires-Dist: pydantic>=2.12.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest<10,>=8; extra == "dev"
|
|
28
|
+
Requires-Dist: ruff<1,>=0.12; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
<!-- mcp-name: io.github.jonathanrreed/apple-maps-mcp -->
|
|
32
|
+
|
|
33
|
+
# Apple Maps MCP
|
|
34
|
+
|
|
35
|
+
Local MCP server for Apple Maps search and routing on macOS.
|
|
36
|
+
|
|
37
|
+
## Capabilities
|
|
38
|
+
|
|
39
|
+
- search for places
|
|
40
|
+
- estimate route distance and travel time
|
|
41
|
+
- build Apple Maps links
|
|
42
|
+
- open directions in Apple Maps
|
|
43
|
+
- resource: `maps://status`
|
|
44
|
+
- prompt: `maps_plan_route`
|
|
45
|
+
- tool discovery helpers `search_tools` and `get_tool_info` for context-constrained clients
|
|
46
|
+
|
|
47
|
+
## Install On This Mac
|
|
48
|
+
|
|
49
|
+
<details>
|
|
50
|
+
<summary>Quick start (uvx, from PyPI)</summary>
|
|
51
|
+
|
|
52
|
+
With [uv](https://docs.astral.sh/uv/getting-started/installation/) installed:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uvx apple-maps-mcp
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
No clone, no venv management.
|
|
59
|
+
|
|
60
|
+
</details>
|
|
61
|
+
|
|
62
|
+
<details>
|
|
63
|
+
<summary>From a clone</summary>
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
git clone https://github.com/JonathanRReed/Apple-MCPs.git
|
|
67
|
+
cd Apple-MCPs
|
|
68
|
+
uv sync --all-packages
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This builds one workspace environment with every server's entry point in `.venv/bin` (for example `.venv/bin/apple-maps-mcp`). You can also point an MCP client at `AppleMaps-MCP/start.sh`, which prefers `uv run` and falls back to a plain venv bootstrap (Python 3.11+ required).
|
|
72
|
+
|
|
73
|
+
</details>
|
|
74
|
+
|
|
75
|
+
## Install In AI Agents
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"mcpServers": {
|
|
80
|
+
"apple-maps": {
|
|
81
|
+
"command": "uvx",
|
|
82
|
+
"args": ["apple-maps-mcp"],
|
|
83
|
+
"env": {}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Running from a clone instead? Use `/path/to/Apple-MCPs/AppleMaps-MCP/start.sh` as the command with empty `args`.
|
|
90
|
+
|
|
91
|
+
Claude Code:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
claude mcp add --transport stdio --scope project apple-maps -- uvx apple-maps-mcp
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Transport
|
|
98
|
+
|
|
99
|
+
`stdio` is the default and recommended transport. Set `APPLE_MAPS_MCP_TRANSPORT=streamable-http` (with optional `APPLE_MAPS_MCP_HOST` and `APPLE_MAPS_MCP_PORT`) to serve Streamable HTTP instead.
|
|
100
|
+
|
|
101
|
+
## Prompting Notes
|
|
102
|
+
|
|
103
|
+
- `tools/list` returns the full Maps tool surface. Context-constrained clients can use `search_tools` first, then `get_tool_info` for the Maps tool they need.
|
|
104
|
+
- Use this server when travel, routing, or place lookup affects a Calendar, Reminders, Messages, or Mail action.
|
|
105
|
+
- Confirm origin, destination, and transport mode before writing a time-sensitive plan.
|
|
106
|
+
- If helper compilation fails, install Xcode command line tools and retry.
|
|
107
|
+
|
|
108
|
+
## Health And Recovery
|
|
109
|
+
|
|
110
|
+
- `maps_health`
|
|
111
|
+
- `maps_permission_guide`
|
|
112
|
+
|
|
113
|
+
## Launch Checklist
|
|
114
|
+
|
|
115
|
+
- Add `uvx apple-maps-mcp` (or a clone's `AppleMaps-MCP/start.sh`) to your MCP client
|
|
116
|
+
- Reload or reconnect the client so the Maps tool surface is loaded into context
|
|
117
|
+
- Call `maps_health` first
|
|
118
|
+
- If the local helper or routing surface is blocked, call `maps_permission_guide`
|
|
119
|
+
- Run `maps_search_places` once to confirm the local Swift helper compiles
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/apple_maps_mcp/__init__.py
|
|
5
|
+
src/apple_maps_mcp/apple_maps_bridge.swift
|
|
6
|
+
src/apple_maps_mcp/config.py
|
|
7
|
+
src/apple_maps_mcp/maps_bridge.py
|
|
8
|
+
src/apple_maps_mcp/models.py
|
|
9
|
+
src/apple_maps_mcp/tools.py
|
|
10
|
+
src/apple_maps_mcp.egg-info/PKG-INFO
|
|
11
|
+
src/apple_maps_mcp.egg-info/SOURCES.txt
|
|
12
|
+
src/apple_maps_mcp.egg-info/dependency_links.txt
|
|
13
|
+
src/apple_maps_mcp.egg-info/entry_points.txt
|
|
14
|
+
src/apple_maps_mcp.egg-info/requires.txt
|
|
15
|
+
src/apple_maps_mcp.egg-info/top_level.txt
|
|
16
|
+
tests/test_bridge.py
|
|
17
|
+
tests/test_config.py
|
|
18
|
+
tests/test_tools.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
apple_maps_mcp
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from apple_maps_mcp.maps_bridge import AppleMapsBridge, MapsBridgeError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_run_helper_maps_timeout_to_structured_error(monkeypatch) -> None:
|
|
10
|
+
bridge = AppleMapsBridge(Path("/tmp/apple_maps_bridge.swift"), Path("/tmp/apple-maps-bridge"))
|
|
11
|
+
|
|
12
|
+
monkeypatch.setattr(bridge, "_ensure_helper", lambda: None)
|
|
13
|
+
|
|
14
|
+
def fake_run(*args, **kwargs):
|
|
15
|
+
raise subprocess.TimeoutExpired(cmd="apple-maps-bridge", timeout=20)
|
|
16
|
+
|
|
17
|
+
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
18
|
+
|
|
19
|
+
with pytest.raises(MapsBridgeError) as exc_info:
|
|
20
|
+
bridge.search_places("coffee")
|
|
21
|
+
|
|
22
|
+
assert exc_info.value.error_code == "HELPER_TIMEOUT"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from apple_maps_mcp.config import load_settings
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_load_settings_uses_packaged_helper_source() -> None:
|
|
5
|
+
load_settings.cache_clear()
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
settings = load_settings()
|
|
9
|
+
finally:
|
|
10
|
+
load_settings.cache_clear()
|
|
11
|
+
|
|
12
|
+
assert settings.helper_source.name == "apple_maps_bridge.swift"
|
|
13
|
+
assert settings.helper_source.parent.name == "apple_maps_mcp"
|
|
14
|
+
assert settings.helper_source.exists()
|
|
15
|
+
assert settings.helper_binary.name == "apple-maps-bridge"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from apple_maps_mcp import tools
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class StubBridge:
|
|
5
|
+
def helper_available(self):
|
|
6
|
+
return True, True
|
|
7
|
+
|
|
8
|
+
def search_places(self, query: str, limit: int = 5):
|
|
9
|
+
return {"places": [{"name": "Coffee Shop", "address": "123 Main St", "latitude": 30.0, "longitude": -97.0, "phone": None, "url": None}]}
|
|
10
|
+
|
|
11
|
+
def directions(self, origin: str, destination: str, transport: str = "driving"):
|
|
12
|
+
return {
|
|
13
|
+
"origin": {"name": origin, "address": origin, "latitude": 30.0, "longitude": -97.0, "phone": None, "url": None},
|
|
14
|
+
"destination": {"name": destination, "address": destination, "latitude": 30.1, "longitude": -97.1, "phone": None, "url": None},
|
|
15
|
+
"transport": transport,
|
|
16
|
+
"distance_meters": 1000,
|
|
17
|
+
"expected_travel_time_seconds": 600,
|
|
18
|
+
"advisory_notices": [],
|
|
19
|
+
"maps_url": "https://maps.apple.com/?daddr=Coffee+Shop",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
def maps_url(self, destination: str, origin: str | None = None, transport: str = "driving"):
|
|
23
|
+
return "https://maps.apple.com/?daddr=Coffee+Shop"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_maps_search_places(monkeypatch):
|
|
27
|
+
monkeypatch.setattr(tools, "_bridge", lambda: StubBridge())
|
|
28
|
+
result = tools.maps_search_places("coffee")
|
|
29
|
+
assert result.ok is True
|
|
30
|
+
assert result.count == 1
|
|
31
|
+
assert result.places[0].name == "Coffee Shop"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_maps_build_maps_link(monkeypatch):
|
|
35
|
+
monkeypatch.setattr(tools, "_bridge", lambda: StubBridge())
|
|
36
|
+
result = tools.maps_build_maps_link("Coffee Shop")
|
|
37
|
+
assert result.url.startswith("https://maps.apple.com/")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_maps_status_resource(monkeypatch):
|
|
41
|
+
monkeypatch.setattr(tools, "_bridge", lambda: StubBridge())
|
|
42
|
+
payload = tools.maps_status_resource()
|
|
43
|
+
assert "\"helper_available\": true" in payload
|
|
44
|
+
assert "\"driving\"" in payload
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_main_uses_streamable_http(monkeypatch):
|
|
48
|
+
monkeypatch.setenv("APPLE_MAPS_MCP_TRANSPORT", "streamable-http")
|
|
49
|
+
monkeypatch.setenv("APPLE_MAPS_MCP_HOST", "0.0.0.0")
|
|
50
|
+
monkeypatch.setenv("APPLE_MAPS_MCP_PORT", "8765")
|
|
51
|
+
monkeypatch.setenv("APPLE_MAPS_MCP_LOG_LEVEL", "DEBUG")
|
|
52
|
+
tools.load_settings.cache_clear()
|
|
53
|
+
|
|
54
|
+
captured = {}
|
|
55
|
+
|
|
56
|
+
def fake_run(*, transport: str, host: str, port: int, json_response: bool, stateless_http: bool):
|
|
57
|
+
captured["transport"] = transport
|
|
58
|
+
captured["host"] = host
|
|
59
|
+
captured["port"] = port
|
|
60
|
+
captured["json_response"] = json_response
|
|
61
|
+
captured["stateless_http"] = stateless_http
|
|
62
|
+
captured["log_level"] = tools.mcp.settings.log_level
|
|
63
|
+
|
|
64
|
+
monkeypatch.setattr(tools.mcp, "run", fake_run)
|
|
65
|
+
|
|
66
|
+
tools.main()
|
|
67
|
+
|
|
68
|
+
assert captured == {
|
|
69
|
+
"transport": "streamable-http",
|
|
70
|
+
"host": "0.0.0.0",
|
|
71
|
+
"port": 8765,
|
|
72
|
+
"json_response": True,
|
|
73
|
+
"stateless_http": True,
|
|
74
|
+
"log_level": "DEBUG",
|
|
75
|
+
}
|