local-maps-mcp 0.0.0-stage → 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +117 -2
- package/package.json +41 -4
- package/src/index.js +92 -0
- package/src/maps.js +95 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 local-maps-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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,118 @@
|
|
|
1
|
-
#
|
|
1
|
+
# local-maps-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> 用一个小项目,学会开发本地 MCP 服务
|
|
4
|
+
|
|
5
|
+
一个跑在 macOS 上的本地 MCP 服务。在 AI 工具里配置后说「在地图里搜索西湖景区」,它会打开系统自带的「地图」并搜索这个地点。
|
|
6
|
+
|
|
7
|
+
源码:<https://github.com/liyufengrex/mcp_search_map>
|
|
8
|
+
|
|
9
|
+
## MCP 是什么
|
|
10
|
+
|
|
11
|
+
MCP(Model Context Protocol,模型上下文协议)让 AI 工具调用外部能力。模型负责理解你的话并决定调用哪个工具;MCP 服务把能力登记成工具,执行后把结果交还模型;真正的工作由后面的系统完成,例如打开地图或读取文件。
|
|
12
|
+
|
|
13
|
+
本地服务由 AI 工具启动,双方用标准输入和标准输出通信,这叫 stdio。日志写到标准错误,标准输出只放协议消息。模型依据工具的名称、说明和参数决定何时调用。
|
|
14
|
+
|
|
15
|
+
## 本项目做什么
|
|
16
|
+
|
|
17
|
+
本项目只提供一个工具 `search_map`。参数 `query` 是地点或地址。服务检查参数后,调用 macOS 打开系统地图并发起搜索,然后返回「已向系统地图发送搜索请求:……」。搜索结果以地图窗口为准。不需要地图 API Key。
|
|
18
|
+
|
|
19
|
+
环境:macOS,Node.js 20 或更高。
|
|
20
|
+
|
|
21
|
+
## 文件怎么分工
|
|
22
|
+
|
|
23
|
+
协议入口和地图动作分开:前者向 AI 暴露工具,后者只负责打开地图。
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
src/index.js 注册 search_map,用 stdio 等待 AI 工具连接
|
|
27
|
+
src/maps.js 检查参数、生成地图链接、调用 open
|
|
28
|
+
test/maps.test.js 检查链接编码和命令参数
|
|
29
|
+
test/server.test.js 用 MCP 客户端检查工具是否能被调用
|
|
30
|
+
.cursor/mcp.json 一种客户端的配置示例(例如Cursor)
|
|
31
|
+
package.json 依赖、npm start 与 npm test
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`src/index.js` 使用官方 SDK `@modelcontextprotocol/server` 创建服务,并用 Zod 描述参数。`src/maps.js` 不处理 MCP 消息。测试用模拟命令代替真正的 `open`,所以 `npm test` 不会反复打开地图。
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install
|
|
38
|
+
npm test
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
平时由 AI 工具启动。单独执行 `npm start` 时,进程会停住等待连接,用 Ctrl+C 结束。
|
|
42
|
+
|
|
43
|
+
## 功能怎么实现
|
|
44
|
+
|
|
45
|
+
1. AI 工具启动 `src/index.js`,`StdioServerTransport` 开始等待调用。
|
|
46
|
+
2. `createServer()` 用 `registerTool()` 登记 `search_map`。`query` 是必填字符串,说明里写了「在地图里搜索地点时使用」。
|
|
47
|
+
3. 调用到达后,处理函数把 `query` 交给 `openMapSearch()`。成功返回搜索已发送;失败返回 `isError: true`,例如空查询、非 macOS,或系统命令失败。
|
|
48
|
+
4. `normalizeQuery()` 去掉首尾空格,空内容会被拒绝。
|
|
49
|
+
5. `buildMapSearchUrl()` 按 Apple [Map Links](https://developer.apple.com/library/archive/featuredarticles/iPhoneURLScheme_Reference/MapLinks/MapLinks.html) 的 `q` 参数生成链接。`q` 就是搜索框里的文字,并用 `encodeURIComponent()` 编码中文、空格和 `&`、`#`:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
https://maps.apple.com/?q=%E8%A5%BF%E6%B9%96%E6%99%AF%E5%8C%BA
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`q` 是系统地图长期支持的搜索参数。macOS 15.4 及更高版本还可以使用 [Adopting unified Maps URLs](https://developer.apple.com/documentation/mapkit/unified-map-urls) 里的 `/search?query=`,本项目选择 `q`,使更早的系统也能打开搜索。
|
|
56
|
+
|
|
57
|
+
6. `openMapSearch()` 用 `execFile()` 执行下面的命令。参数是数组,搜索词只出现在编码后的链接里,最长等待 10 秒:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
/usr/bin/open -b com.apple.Maps <地图链接>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`-b com.apple.Maps` 指定打开系统「地图」。`open` 返回表示系统已经收下请求。
|
|
64
|
+
|
|
65
|
+
## 在 AI 工具里配置
|
|
66
|
+
|
|
67
|
+
AI 工具需要一份配置,写明服务名、用来启动的 Node.js,以及入口文件 `src/index.js`。各工具的配置文件位置不同,本地服务通常都使用 `command` 和 `args`。下面以 Cursor 为例。
|
|
68
|
+
|
|
69
|
+
Cursor 会合并项目级和用户级配置;同名时以项目级为准。市场里的现成 MCP 可以在 **Customize → MCPs** 里点 **Add to Cursor**。本项目是本地服务,使用下面的配置文件。
|
|
70
|
+
|
|
71
|
+
| 位置 | 文件 | 生效范围 |
|
|
72
|
+
| --- | --- | --- |
|
|
73
|
+
| 项目级 | `.cursor/mcp.json` | 打开本项目时 |
|
|
74
|
+
| 用户级 | `~/.cursor/mcp.json` | 这台电脑上的所有项目 |
|
|
75
|
+
|
|
76
|
+
本仓库已经写好项目级配置。添加到用户级配置时,保留文件里原有的其他服务。
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"mcpServers": {
|
|
81
|
+
"local-maps-mcp": {
|
|
82
|
+
"command": "node",
|
|
83
|
+
"args": ["${workspaceFolder}/src/index.js"]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`${workspaceFolder}` 是当前项目目录。如果 Cursor 从图形界面启动时找不到 `node`,把 `command` 改成终端里 `nvm which current` 打印出的绝对路径。切换 Node 版本后同步修改,再刷新 MCP。
|
|
90
|
+
|
|
91
|
+
安装发布后的 npm 包时,入口不再是项目里的源文件:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"mcpServers": {
|
|
96
|
+
"local-maps-mcp": {
|
|
97
|
+
"command": "npx",
|
|
98
|
+
"args": ["-y", "local-maps-mcp"]
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
用 Cursor 打开本项目,进入 **Customize → MCPs**。`local-maps-mcp` 已连接时,工具列表里有 `search_map`。改完配置或代码后,在这里刷新该服务。
|
|
105
|
+
|
|
106
|
+
## 示例:在地图里搜索西湖景区
|
|
107
|
+
|
|
108
|
+
例如在 Cursor 的 Agent 对话里输入:
|
|
109
|
+
|
|
110
|
+
```text
|
|
111
|
+
在地图里搜索西湖景区
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
模型会调用 `search_map`,参数为 `{ "query": "西湖景区" }`。系统地图来到前台并接收这次搜索,对话里出现:
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
已向系统地图发送搜索请求:西湖景区
|
|
118
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "local-maps-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "在 macOS 系统地图中搜索地点的本地 MCP 服务",
|
|
5
|
+
"mcpName": "io.github.liyufengrex/mcp_search_map",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/liyufengrex/mcp_search_map.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/liyufengrex/mcp_search_map#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/liyufengrex/mcp_search_map/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mcp",
|
|
18
|
+
"macos",
|
|
19
|
+
"maps"
|
|
20
|
+
],
|
|
21
|
+
"bin": {
|
|
22
|
+
"local-maps-mcp": "./src/index.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"start": "node src/index.js",
|
|
34
|
+
"test": "node --test"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@modelcontextprotocol/server": "^2.3.1",
|
|
38
|
+
"zod": "^4.1.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@modelcontextprotocol/client": "^2.3.1"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
7
|
+
import * as z from "zod/v4";
|
|
8
|
+
import { openMapSearch } from "./maps.js";
|
|
9
|
+
|
|
10
|
+
const instructions = [
|
|
11
|
+
"这个服务只有 search_map 一个工具。",
|
|
12
|
+
"用户要求在地图中搜索、查找或打开某个地点时,调用 search_map。",
|
|
13
|
+
"工具只会打开 macOS 系统地图并发起搜索。",
|
|
14
|
+
"它不返回地点坐标,也不表示地图已经显示结果。",
|
|
15
|
+
].join("");
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 创建 MCP 服务。openSearch 可以在测试里换成不真正打开地图的函数。
|
|
19
|
+
* @param {{ openSearch?: typeof openMapSearch }} [options]
|
|
20
|
+
*/
|
|
21
|
+
export function createServer({ openSearch = openMapSearch } = {}) {
|
|
22
|
+
const server = new McpServer(
|
|
23
|
+
{ name: "local-maps-mcp", version: "1.0.0" },
|
|
24
|
+
{ instructions },
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
server.registerTool(
|
|
28
|
+
"search_map",
|
|
29
|
+
{
|
|
30
|
+
title: "在系统地图中搜索",
|
|
31
|
+
description:
|
|
32
|
+
"在这台 Mac 的系统地图(Apple 地图)中搜索地点或地址。当用户要求在地图里搜索、查找或打开某个地点时使用。此工具只打开系统地图并发起搜索,不会读取搜索结果,也不能确认地点是否存在或地图是否已经显示完成。",
|
|
33
|
+
inputSchema: z.object({
|
|
34
|
+
query: z
|
|
35
|
+
.string()
|
|
36
|
+
.describe("要搜索的地点名称或地址,例如“杭州西湖”。去掉首尾空格后不能为空。"),
|
|
37
|
+
}),
|
|
38
|
+
},
|
|
39
|
+
async ({ query }) => {
|
|
40
|
+
try {
|
|
41
|
+
const result = await openSearch(query);
|
|
42
|
+
// 日志只能写 stderr。stdout 是 MCP 协议通道。
|
|
43
|
+
console.error(`[local-maps-mcp] 已发送搜索请求:${result.query}`);
|
|
44
|
+
return {
|
|
45
|
+
content: [
|
|
46
|
+
{
|
|
47
|
+
type: "text",
|
|
48
|
+
text: `已向系统地图发送搜索请求:${result.query}`,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
} catch (error) {
|
|
53
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
54
|
+
console.error(`[local-maps-mcp] 搜索失败:${message}`);
|
|
55
|
+
return {
|
|
56
|
+
content: [{ type: "text", text: message }],
|
|
57
|
+
isError: true,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return server;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 只有直接运行 src/index.js 时才连接 stdio,被测试导入时不启动。 */
|
|
67
|
+
function isDirectRun() {
|
|
68
|
+
const entry = process.argv[1];
|
|
69
|
+
if (!entry) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function start() {
|
|
81
|
+
console.error("[local-maps-mcp] 已启动,等待 Cursor 通过 stdio 连接");
|
|
82
|
+
const server = createServer();
|
|
83
|
+
const transport = new StdioServerTransport();
|
|
84
|
+
await server.connect(transport);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (isDirectRun()) {
|
|
88
|
+
start().catch((error) => {
|
|
89
|
+
console.error("[local-maps-mcp] 启动失败:", error);
|
|
90
|
+
process.exitCode = 1;
|
|
91
|
+
});
|
|
92
|
+
}
|
package/src/maps.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
// promisify 之后可以用 await。execFile 使用参数数组,不会经过 shell。
|
|
5
|
+
const defaultExecFile = promisify(execFileCallback);
|
|
6
|
+
|
|
7
|
+
/** 系统地图的 bundle id,来自 Maps.app 的 Info.plist。 */
|
|
8
|
+
export const MAPS_BUNDLE_ID = "com.apple.Maps";
|
|
9
|
+
|
|
10
|
+
/** open 返回前最多等待的时间。open 只负责把请求交给地图,通常很快结束。 */
|
|
11
|
+
export const COMMAND_TIMEOUT_MS = 10_000;
|
|
12
|
+
|
|
13
|
+
const OPEN_PATH = "/usr/bin/open";
|
|
14
|
+
|
|
15
|
+
export class MapSearchError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "MapSearchError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 去掉首尾空格,并拒绝空查询。
|
|
24
|
+
* @param {unknown} query
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeQuery(query) {
|
|
28
|
+
if (typeof query !== "string") {
|
|
29
|
+
throw new MapSearchError("query 必须是字符串");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const trimmed = query.trim();
|
|
33
|
+
if (!trimmed) {
|
|
34
|
+
throw new MapSearchError("query 不能为空");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return trimmed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 构造系统地图搜索链接。
|
|
42
|
+
*
|
|
43
|
+
* Apple《Adopting unified Maps URLs》中的
|
|
44
|
+
* https://maps.apple.com/search?query= 从 macOS 15.4 开始提供。
|
|
45
|
+
* 这里使用更早的系统也能识别的 Map Links 格式:
|
|
46
|
+
* https://maps.apple.com/?q=搜索词
|
|
47
|
+
* 文档规定 q 是 URL 编码后的搜索文字,地图会把它当作搜索框中的输入。
|
|
48
|
+
* 官方示例使用 http,这里使用同一主机和同一参数的 https。
|
|
49
|
+
*
|
|
50
|
+
* @param {string} query
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function buildMapSearchUrl(query) {
|
|
54
|
+
const trimmed = normalizeQuery(query);
|
|
55
|
+
return `https://maps.apple.com/?q=${encodeURIComponent(trimmed)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 校验参数和平台,然后让 macOS 打开系统地图。
|
|
60
|
+
* options.execFile 供测试替换系统命令,正式运行时不要传入。
|
|
61
|
+
*
|
|
62
|
+
* @param {string} query
|
|
63
|
+
* @param {{
|
|
64
|
+
* platform?: NodeJS.Platform,
|
|
65
|
+
* execFile?: (file: string, args: string[], options: { timeout: number }) => Promise<unknown>,
|
|
66
|
+
* timeoutMs?: number,
|
|
67
|
+
* }} [options]
|
|
68
|
+
* @returns {Promise<{ query: string, url: string }>}
|
|
69
|
+
*/
|
|
70
|
+
export async function openMapSearch(query, options = {}) {
|
|
71
|
+
const trimmed = normalizeQuery(query);
|
|
72
|
+
const platform = options.platform ?? process.platform;
|
|
73
|
+
|
|
74
|
+
if (platform !== "darwin") {
|
|
75
|
+
throw new MapSearchError("当前系统不是 macOS,无法打开系统地图");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const url = buildMapSearchUrl(trimmed);
|
|
79
|
+
const execFile = options.execFile ?? defaultExecFile;
|
|
80
|
+
const timeout = options.timeoutMs ?? COMMAND_TIMEOUT_MS;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
// man open:-b 用 bundle id 指定由哪个应用打开 URL,从而打开原生地图。
|
|
84
|
+
await execFile(OPEN_PATH, ["-b", MAPS_BUNDLE_ID, url], { timeout });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error && /** @type {{ killed?: boolean }} */ (error).killed) {
|
|
87
|
+
throw new MapSearchError(`打开系统地图超时(超过 ${timeout} 毫秒)`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
91
|
+
throw new MapSearchError(`无法打开系统地图:${detail}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { query: trimmed, url };
|
|
95
|
+
}
|