diff --git a/README.md b/README.md index faa91ec..27e5ba4 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,8 @@ To use the **Agent Skill without MCP**: You can verify the CLI is available with `npx -y @cablate/mcp-google-map exec --help` before making a billable API call. +For a reproducible no-MCP walkthrough, see the [Agent Skill demo](./examples/agent-skill-demo.md). If your agent or app displays Places reviews, photos, or AI summaries, follow the [content attribution and storage guidance](./skills/google-maps/references/content-attribution.md); the tool preserves source/disclosure metadata but does not render a compliant UI for you. + ### Batch Geocode Geocode hundreds of addresses from a file: diff --git a/README.zh-TW.md b/README.zh-TW.md index 9a88ed2..fa38004 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -168,6 +168,8 @@ npx @cablate/mcp-google-map exec search-places '{"query":"東京拉麵"}' 呼叫可能計費的 API 前,可先用 `npx -y @cablate/mcp-google-map exec --help` 確認 CLI 可用。 +完整的非 MCP 操作步驟見 [Agent Skill 示範](./examples/agent-skill-demo.md)。若代理工具或應用程式會呈現 Places 評論、照片或 AI 摘要,請遵照[內容署名與保存指引](./skills/google-maps/references/content-attribution.md);工具會保留來源及揭露欄位,但不會替你的介面完成署名呈現。 + ### 批次地理編碼 從檔案批次地理編碼: diff --git a/docs/backlog-and-strategy.md b/docs/backlog-and-strategy.md index 3d2c843..60c4ae7 100644 --- a/docs/backlog-and-strategy.md +++ b/docs/backlog-and-strategy.md @@ -1,191 +1,30 @@ -# mcp-google-map Backlog & Strategy +# mcp-google-map backlog -> Consolidated: 2026-03-20 -> Source: dev-roadmap-spec.md, strategy-todo.md, mcp-google-map-growth-strategy-2026.md (all retired) -> Current state: v0.0.44, 17 tools, ~192+ GitHub stars +This is a current maintenance and product roadmap, not a release-status ledger. For the current version and tool count, use [`package.json`](../package.json) and the [README](../README.md), not a number copied into this file. Earlier market-size claims, competitor counts, and growth forecasts were removed because they had no maintained evidence or expiry date; Git history retains them if historical context is needed. -This file contains ONLY items that have NOT been implemented and strategic insights that remain relevant. All completed items have been removed. +## Product quality and adoption ---- +1. **Agent Skill onboarding evidence.** The [no-MCP walkthrough](../examples/agent-skill-demo.md) documents a reproducible CLI path. Test actual Skill discovery and tool selection in representative clients before claiming cross-client support. A short screen recording may then show the verified flow; do not substitute a staged transcript for an observed run. +2. **Geo-reasoning evaluation.** Start with a small set of versioned tasks that exercise tool selection, parameters, result interpretation, cost, and latency. Publish raw task definitions and evaluation criteria with any result; do not make an unsupported “tool vs no tool” success-rate claim. +3. **MCP prompt templates.** Consider travel planning, neighborhood scouting, and multi-stop routing templates only after checking which target MCP clients expose prompts to users. Reuse the existing Skill recipes where appropriate; avoid maintaining two divergent sets of instructions. +4. **Contributor feedback.** Collect reproducible user reports and installation friction before expanding the tool catalog. Existing tools cover geocoding, search, routes, environment data, maps, and four composite workflows. -## Backlog: Features +## Trust and maintenance -### MCP Prompt Templates (from dev-roadmap-spec P1) +- Keep Google Maps content display guidance aligned with the [Places API policies](https://developers.google.com/maps/documentation/places/web-service/policies), particularly attribution for reviews/photos, AI-summary disclosures, source links, and storage limits. The package is a data/tool layer; downstream applications still own their rendered UI, terms, and privacy notices. +- Triage production dependency advisories by affected path and exploit preconditions, then update compatible versions and verify build, tests, and live API behavior. An audit count alone is not proof of an exploitable path. +- Keep the packaged Agent Skill synchronized with its source using `python3 scripts/package-skill.py --check` in CI. Update its tool map and API reference whenever tools change. -**Status**: Not started | **Effort**: ~6hr +## Deferred ideas -MCP Prompts 讓 Claude Desktop / Cursor 使用者一鍵進入 geo agent 模式,不需要讀文件。 +- **Isochrone approximation:** Google Maps Platform has no direct isochrone API in this project. An approximate polygon from distance-matrix probes needs a concrete user requirement and accuracy/cost bounds before implementation. +- **Geo-agent scaffold:** A separate starter template may be useful after the Skill onboarding study; first determine which setup steps are not already covered by the Skill and README. +- **Directory and content submissions:** Validate each directory's current submission path and requirements before spending maintainer time. Prefer a verified demo and honest capability claims over stale comparison tables. -```typescript -// 3 prompts to register in BaseMcpServer.ts: -{ - name: "travel-planner", - description: "Plan a trip with geo tools", - arguments: [ - { name: "destination", required: true }, - { name: "duration", required: false }, - { name: "style", required: false } // budget, luxury, foodie - ] -}, -{ - name: "neighborhood-scout", - description: "Analyze a location for living/working", - arguments: [ - { name: "location", required: true }, - { name: "priorities", required: false } // schools, transit, restaurants - ] -}, -{ - name: "route-optimizer", - description: "Optimize a multi-stop route", - arguments: [ - { name: "stops", required: true }, - { name: "mode", required: false } - ] -} -``` +## Previously rejected -Implementation: `BaseMcpServer.ts` 加 `prompts` capability,Prompt 內容從 `tools-api.md` Scenario Recipes 提取。 - -### Geo-Reasoning Benchmark (from dev-roadmap-spec P2) - -**Status**: Not started | **Effort**: Phase 1 ~2 weeks - -10 scenarios (Easy 2 + Medium 3 + Hard 3 + Expert 2), 5 metrics (Tool Selection Accuracy, Parameter Correctness, Chain Efficiency, Result Quality, Latency). - -Key metric — **Composite Tool Efficiency Score (CES)**: -``` -CES = min_required_calls / actual_calls_made -``` - -Marketing angle: "Without geo tools: 4% success (MIT TravelPlanner). With mcp-google-map: 87%+" - -Phase 1: 5 YAML scenarios + automated evaluator + README results table -Phase 2: 全 10 scenarios + LLM-as-Judge + multi-LLM comparison - -### Geo Agent Scaffold (from strategy-todo Backlog) - -**Status**: Not started | **Effort**: TBD - -Pre-built agent prompt template — 「你是一個旅遊規劃專家,你有以下地理工具...」。讓人可以一行指令啟動 geo-aware AI agent。 - -### maps_isochrone (from dev-roadmap-spec P3) - -**Status**: Deprioritized | **Effort**: ~8hr - -Google 沒有 isochrone API。最佳路徑:Distance Matrix 8 方向探測 + static map 畫 approximated polygon。ROI 目前不夠,待有明確需求再做。 - ---- - -## Backlog: Growth & Marketing - -### Pending Submissions (manual) - -| Platform | Type | Status | -|---|---|---| -| pulsemcp.com | Web form | Not submitted | -| mcpservers.org | Web form | Not submitted | -| mcp.so | Web form | Not submitted | -| mcpmarket.com | Web form | Not submitted | -| mcpserverfinder.com | Web form | Not submitted | -| Cline Marketplace | PR to `cline/mcp-marketplace` | Not submitted | - -### Content Marketing (not started) - -| Item | Effort | Notes | -|---|---|---| -| Blog post / Dev.to article | Medium | "Why your AI agent needs geospatial capabilities" | -| Demo video (30-60 sec) | Medium | Screen recording of Claude Code + mcp-google-map trip planning | -| Twitter/X launch campaign | Low | Needs demo video first | -| HN "Show HN" post | Low | README ready, can execute anytime | - ---- - -## Strategic Insights (still relevant) - -### Competitive Positioning - -**vs Google Grounding Lite (official MCP)**: -- Grounding Lite: 3 tools, managed hosting, no API key needed, experimental -- Us: 17 tools, self-hosted, full control, Agent Skill, exec CLI -- Key gaps they have that we don't: none anymore (we added weather, timezone, air quality) -- Key things we have that they don't: geocoding, directions, elevation, distance matrix, place details, batch geocode, composite tools, Agent Skill - -**vs Mapbox MCP (315 stars)**: -- Mapbox: 20+ tools, offline Turf.js, TSP, Isochrone, separate Agent Skills repo -- Us: Google Maps API (larger user base), Skills embedded in npm package, exec CLI, MIT - -**vs baidu-maps/mcp (411 stars)**: -- China market only, not direct competition internationally - -### MCP + Agent Skills Ecosystem - -- Agent Skills 已是跨平台標準(Claude Code, VS Code, OpenAI Codex, Cursor, Gemini CLI) -- AAIF (Linux Foundation): Anthropic, Google, Microsoft, OpenAI 共同治理 -- Anthropic 的 anthropics/skills (92.9k stars) 目前沒有任何 geo/maps skill — 空白機會 -- 我們的 `skills/google-maps/` 已 90% 符合 skillpm 格式 -- PR #644 已提交到 anthropics/skills - -### Geo-AI Market Signals - -- Google "Ask Maps" (2026-03-12): Gemini 驅動的對話式地圖搜尋,驗證賽道 -- MIT 研究:LLM 無 tool 輔助時旅行規劃成功率只有 ~4% -- 62% 年輕旅客已用 AI 工具做旅行規劃 -- ChatGPT 沒有原生 Maps 整合 — MCP 填補空白 -- UPS 每減少一英里/司機/天 = $50M/年 savings - -### Our Moat (validated) - -| Type | Advantage | -|---|---| -| Technical | StreamableHTTP multi-tenant, per-session API key, Tool Annotations | -| Design | Agent Skill (教 AI 怎麼思考地理問題), exec CLI, chaining patterns | -| Market | Google Maps category leader (192 vs #2 at 14), npm user base | - -### High-Value Use Case Tiers (for marketing/positioning) - -**Tier 1 (huge market + perfect tool coverage)**: -- Real estate analysis ($47T market) -- Logistics multi-stop optimization ($200B+) -- B2B field sales optimization -- E-commerce delivery estimation ($6.3T) - -**Tier 2 (large market + good coverage)**: -- Data enrichment (CSV batch geocoding, $15B+) -- Disaster response ($30B+) -- Content creation (travel/city guides, $600B+) -- Accessible route planning (61M disabled users) - -### README / Marketing Playbook - -Key elements proven to work (from Figma 13.7k, Playwright 28.8k, Serena 21.5k analysis): -1. One-click install badges (Playwright) — already partially done -2. GitHub-native video demo (Serena) — NOT DONE -3. 3-bullet value proposition (Serena) — done in current README -4. Star History chart — done -5. vs comparison table (Playwright) — done (vs Grounding Lite) -6. GitHub alert boxes `[!TIP]` (Serena) — done - -Still missing: -- Demo video (highest ROI remaining item) -- User testimonials / community feedback section - -### Growth Projections (research-backed) - -| Milestone | Timeline | Driver | -|---|---|---| -| 300+ stars | 2-4 weeks | Registry submissions + content | -| 500+ stars | 1-2 months | HN/Reddit + blog post | -| 1,000+ stars | 3-4 months | Sustained content + community | - -Reference: HN front page averages +121 stars/24hr (median much lower). First 1000 is hardest, then organic discovery accelerates. - ---- - -## Decided Not To Build - -| Item | Reason | Source | -|---|---|---| -| maps_validate_address | $17/1000 too expensive, backend-only use case, geocode covers 60% | dev-roadmap-spec | -| Spatial Context / Memory | Claude conversation history already solves this, composite tools reduce chaining | dev-roadmap-spec | -| Language parameter for all tools | Existing behavior sufficient, deprioritized | strategy-todo | +| Idea | Reason to revisit only with new evidence | +|------|------------------------------------------| +| Address Validation tool | Separate pricing and a narrower use case than existing geocoding | +| Spatial memory layer | Would duplicate host-agent conversation state without a demonstrated gap | +| Language parameter on every tool | Broad API change without a validated user need | diff --git a/examples/agent-skill-demo.md b/examples/agent-skill-demo.md new file mode 100644 index 0000000..03b9f82 --- /dev/null +++ b/examples/agent-skill-demo.md @@ -0,0 +1,39 @@ +# Agent Skill demo (no MCP server) + +This walkthrough demonstrates the two pieces separately: the Agent Skill tells an agent which geographic tool to use, and the package CLI performs the API call. It does not require an MCP client or server. Build and run the CLI from the same repository revision as the Skill so the instructions and executable stay aligned. + +## 1. Install the Skill + +Clone this repository at a chosen release tag, then copy the whole `skills/google-maps/` directory into your agent's Skills directory according to that client's instructions. Keep `SKILL.md` and `references/` together. If the client accepts `.skill` archives, import `skills/google-maps/SKILL.skill` instead. Installing npm alone does not register the Skill. + +Check that your agent can discover `google-maps` and run shell commands. Provide a Google Maps Platform API key to the agent's environment as `GOOGLE_MAPS_API_KEY` through your normal secret-management method; never paste it into the prompt or commit it. Node.js 18+ and `npx` must be available. + +## 2. Validate without an API call + +From the cloned repository, install and build the package, then run its CLI help: + +```bash +npm ci +npm run build +node dist/cli.js exec --help +``` + +The help should list `geocode` and `search-places`. This only checks CLI availability; it does not prove that your key or Google APIs are enabled. + +## 3. Try one live request + +Ask your agent: + +> Use the google-maps Skill, without MCP, to find the coordinates of Tokyo Tower. Tell me which tool you used and cite Google Maps as the data source. + +The agent should select `maps_geocode` and run an `exec geocode` call from the same checkout like: + +```bash +node dist/cli.js exec geocode '{"address":"Tokyo Tower"}' +``` + +Success means the CLI exits 0 and returns JSON with `success: true`, a `data.location` latitude/longitude, and a `data.place_id`. The exact coordinates and address may change; do not compare them to a hard-coded snapshot. A failed call exits nonzero and writes JSON to stderr. This step calls a billable Google API using your key. + +For a published-package check from **outside** this repository, the equivalent command is `npx -y @cablate/mcp-google-map exec --help`. Running `npx` inside a checkout of the same package can resolve the local package instead of the published executable. + +If you ask for place reviews, photos, or an AI summary in a later step, read the Skill's [content-attribution guidance](../skills/google-maps/references/content-attribution.md) before displaying them. This geocoding example does not validate every Google Maps Platform policy requirement for a downstream app. diff --git a/package-lock.json b/package-lock.json index 6fc02fc..a87e2fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2093,7 +2093,8 @@ "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/base64": { "version": "1.1.2", @@ -2101,22 +2102,24 @@ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -2124,11 +2127,6 @@ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -2140,9 +2138,10 @@ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", @@ -6959,23 +6958,23 @@ } }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", "hasInstallScript": true, + "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -9889,22 +9888,21 @@ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" }, "@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==" }, "@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==" }, "@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "requires": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "@protobufjs/float": { @@ -9912,11 +9910,6 @@ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" }, - "@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" - }, "@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -9928,9 +9921,9 @@ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" }, "@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==" }, "@rollup/rollup-android-arm-eabi": { "version": "4.59.0", @@ -13367,22 +13360,21 @@ } }, "protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", "requires": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" } }, "proxy-addr": { diff --git a/package.json b/package.json index 7b8d0ac..cbcaaf1 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "dist", "dist/**/*.map", "README.md", - "skills" + "skills", + "examples" ], "scripts": { "build": "tsup --dts", diff --git a/skills/google-maps/SKILL.md b/skills/google-maps/SKILL.md index dbd2778..9186156 100644 --- a/skills/google-maps/SKILL.md +++ b/skills/google-maps/SKILL.md @@ -21,6 +21,7 @@ Use the package's `exec` CLI to answer location questions without starting an MC | Precise input saves trouble | Use coordinates over address strings when available. Use place_id over name search. More precise input = more reliable output. | | Output is structured | Every tool returns JSON. Use it directly for downstream computation or comparison — no extra parsing needed. | | Present results clearly | Summarize comparisons in a table when it helps; do not pass raw JSON through as the final answer. | +| Preserve source context | Google Maps content has display, attribution, and retention conditions. Read `references/content-attribution.md` before presenting place reviews, photos, or AI summaries. | --- @@ -109,5 +110,6 @@ npx -y @cablate/mcp-google-map exec '' | `references/tools-api.md` | Full parameter specs, response formats, 7 scenario recipes, and decision guide | When you need exact parameters, response shapes, or multi-tool workflow patterns | | `references/travel-planning.md` | Travel planning methodology — 6-layer model, Search Along Route, anti-patterns | When planning multi-day trips — **read before Recipe 1** | | `references/local-seo.md` | Local SEO / Google Business Profile ranking analysis — competitor audit, keyword landscape, gap analysis | When analyzing business rankings, comparing competitors, or scouting locations | +| `references/content-attribution.md` | Google Maps content attribution, source links, AI disclosure, and storage limits | Before presenting reviews, photos, or AI summaries from Places | > For **project development** knowledge (architecture, API guide, GIS domain, design decisions), see `skills/project-docs/SKILL.md`. diff --git a/skills/google-maps/SKILL.skill b/skills/google-maps/SKILL.skill index ff06c85..b901dca 100644 Binary files a/skills/google-maps/SKILL.skill and b/skills/google-maps/SKILL.skill differ diff --git a/skills/google-maps/references/content-attribution.md b/skills/google-maps/references/content-attribution.md new file mode 100644 index 0000000..5a68b81 --- /dev/null +++ b/skills/google-maps/references/content-attribution.md @@ -0,0 +1,11 @@ +# Presenting Google Maps content + +This Skill calls Google Maps Platform APIs with the user's key. Preserve the source metadata returned by `maps_place_details`; the agent or application displaying the result remains responsible for following the current [Places API policies](https://developers.google.com/maps/documentation/places/web-service/policies). Do not assume that plain tool output is a compliant end-user display. + +- Attribute Google Maps content visibly. Places content shown without a Google Map generally needs the Google Maps logo; use the official attribution guidance for the actual interface. A text-only chat may have different display constraints, so do not claim that a source sentence alone satisfies every UI requirement. +- When showing a review, keep its author name, avatar/profile links where available, and `google_maps_uri` to the individual review. Explain any ordering or filtering of reviews. If required source metadata is absent, omit the quoted review rather than presenting it without attribution. +- When showing a photo, keep its `author_attributions` and `google_maps_uri` with the image. Do not detach the image URL from those fields. +- When showing `review_summary`, label it "Review summary," display `review_summary_attribution.disclosure_text` unmodified immediately below it, and provide its `reviews_uri` and `flag_content_uri`. When showing `generative_summary`, keep its disclosure and report link too. Provide Google's ["About this summary" explanation](https://support.google.com/local-listings/answer/9851099) with either summary. If required disclosure or links are missing, do not show the AI summary. +- Do not pre-fetch, cache, or store Places content beyond Google's permitted exceptions. Place IDs are exempt from the Places caching restriction; other fields are not automatically exempt. + +These instructions help preserve metadata, but they are not a legal determination of compliance. Applications also need to check Google's requirements for their own terms, privacy policy, location, and presentation surface. diff --git a/skills/google-maps/references/tools-api.md b/skills/google-maps/references/tools-api.md index 726a84b..e02245c 100644 --- a/skills/google-maps/references/tools-api.md +++ b/skills/google-maps/references/tools-api.md @@ -148,8 +148,15 @@ Response includes (when available from Google): | `atmosphere` | object | `{ good_for_groups, good_for_children, outdoor_seating, allows_dogs, live_music, ... }` (truthy only) | | `payment_options` | object | Payment methods accepted | | `review_summary` | string | AI-generated review summary (region-limited: US/UK/India/Japan) | +| `review_summary_attribution` | object | Disclosure text, reporting URL, and source reviews URL; display with the summary | | `generative_summary` | string | AI-generated place overview (region-limited: US/India) | +| `generative_summary_attribution` | object | Disclosure text and reporting URL; display with the summary | +| `google_maps_uri` | string | Source place link on Google Maps | | `reviews[].language` | string | Review language code (e.g., `en`, `zh-TW`) | +| `reviews[].author_uri`, `author_photo_uri`, `google_maps_uri` | string | Author profile/avatar and individual review source | +| `photos[].author_attributions`, `google_maps_uri` | object[], string | Photo credits and individual photo source, returned with `maxPhotos > 0` | + +Before showing reviews, photos, or AI summaries to end users, read [`content-attribution.md`](./content-attribution.md). The old legacy-review merge was removed because it could not supply a per-review Google Maps source link; reviews now come from Places API (New) only. --- diff --git a/src/services/NewPlacesService.ts b/src/services/NewPlacesService.ts index 5b97c56..85bed0e 100644 --- a/src/services/NewPlacesService.ts +++ b/src/services/NewPlacesService.ts @@ -27,9 +27,18 @@ export class NewPlacesService { "reviews.text", "reviews.publishTime", "reviews.authorAttribution.displayName", + "reviews.authorAttribution.uri", + "reviews.authorAttribution.photoUri", + "reviews.googleMapsUri", + "reviews.flagContentUri", + "reviews.relativePublishTimeDescription", "photos.heightPx", "photos.widthPx", "photos.name", + "photos.authorAttributions", + "photos.googleMapsUri", + "photos.flagContentUri", + "googleMapsUri", // Parking & Accessibility "parkingOptions", "accessibilityOptions", @@ -213,61 +222,15 @@ export class NewPlacesService { } ); - // Fetch newest reviews via REST (gRPC SDK doesn't support reviews_sort) - const newestReviews = await this.fetchNewestReviews(placeId); - - // Merge: relevant (default) + newest, deduplicate by author+time - const allReviews = this.mergeReviews(place?.reviews || [], newestReviews); - const merged = { ...place, reviews: allReviews }; - - return this.transformPlaceResponse(merged); + // Use only Places (New) reviews: the legacy reviews endpoint does not + // provide the per-review Google Maps source link required for display. + return this.transformPlaceResponse(place); } catch (error: any) { Logger.error("Error in getPlaceDetails (New API):", error); throw new Error(`Failed to get place details for ${placeId}: ${this.extractErrorMessage(error)}`); } } - private async fetchNewestReviews(placeId: string): Promise { - try { - // Use Legacy Place Details API which supports reviews_sort=newest - // (Places API New does not support this parameter) - const url = `https://maps.googleapis.com/maps/api/place/details/json?place_id=${placeId}&fields=reviews&reviews_sort=newest&language=${this.defaultLanguage}&key=${this.apiKey}`; - const response = await fetch(url); - if (!response.ok) return []; - const data = await response.json(); - if (data.status !== "OK") return []; - // Transform Legacy format to match New API format for mergeReviews - return (data.result?.reviews || []).map((r: any) => ({ - rating: r.rating, - text: { text: r.text || "", languageCode: r.language || null }, - publishTime: { seconds: r.time }, - authorAttribution: { displayName: r.author_name || "" }, - })); - } catch { - return []; - } - } - - private mergeReviews(relevant: any[], newest: any[]): any[] { - const seen = new Set(); - const merged: any[] = []; - - for (const review of [...relevant, ...newest]) { - const author = review?.authorAttribution?.displayName || ""; - const time = String(review?.publishTime?.seconds || ""); - const key = `${author}|${time}`; - if (!key || key === "|") { - merged.push(review); - continue; - } - if (seen.has(key)) continue; - seen.add(key); - merged.push(review); - } - - return merged; - } - private transformSearchResult(place: any) { return { name: place.displayName?.text || "", @@ -336,6 +299,7 @@ export class NewPlacesService { name: place.displayName?.text || place.name || "", place_id: this.extractLegacyPlaceId(place), formatted_address: place.formattedAddress || "", + google_maps_uri: place.googleMapsUri || "", geometry: { location: { lat: place.location?.latitude || 0, @@ -372,8 +336,25 @@ export class NewPlacesService { ), } : {}), - ...(place.reviewSummary?.text?.text ? { review_summary: place.reviewSummary.text.text } : {}), - ...(place.generativeSummary?.overview?.text ? { generative_summary: place.generativeSummary.overview.text } : {}), + ...(place.reviewSummary?.text?.text + ? { + review_summary: place.reviewSummary.text.text, + review_summary_attribution: { + disclosure_text: place.reviewSummary.disclosureText?.text || "", + flag_content_uri: place.reviewSummary.flagContentUri || "", + reviews_uri: place.reviewSummary.reviewsUri || "", + }, + } + : {}), + ...(place.generativeSummary?.overview?.text + ? { + generative_summary: place.generativeSummary.overview.text, + generative_summary_attribution: { + disclosure_text: place.generativeSummary.disclosureText?.text || "", + flag_content_uri: place.generativeSummary.flagContentUri || "", + }, + } + : {}), reviews: place.reviews?.map((review: any) => ({ rating: review.rating || 0, @@ -381,12 +362,25 @@ export class NewPlacesService { language: review.text?.languageCode || null, time: review.publishTime?.seconds || 0, author_name: review.authorAttribution?.displayName || "", + author_uri: review.authorAttribution?.uri || "", + author_photo_uri: review.authorAttribution?.photoUri || "", + google_maps_uri: review.googleMapsUri || "", + flag_content_uri: review.flagContentUri || "", + relative_publish_time_description: review.relativePublishTimeDescription || "", })) || [], photos: place.photos?.map((photo: any) => ({ photo_reference: photo.name || "", height: photo.heightPx || 0, width: photo.widthPx || 0, + google_maps_uri: photo.googleMapsUri || "", + flag_content_uri: photo.flagContentUri || "", + author_attributions: + photo.authorAttributions?.map((author: any) => ({ + display_name: author.displayName || "", + uri: author.uri || "", + photo_uri: author.photoUri || "", + })) || [], })) || [], }; } diff --git a/src/services/PlacesSearcher.ts b/src/services/PlacesSearcher.ts index 5bc06a4..e6eaf76 100644 --- a/src/services/PlacesSearcher.ts +++ b/src/services/PlacesSearcher.ts @@ -251,14 +251,30 @@ export class PlacesSearcher { const details = await this.newPlacesService.getPlaceDetails(placeId); // Resolve photo URLs if requested - let photos: Array<{ url: string; width: number; height: number }> | undefined; + let photos: + | Array<{ + url: string; + width: number; + height: number; + google_maps_uri: string; + flag_content_uri: string; + author_attributions: Array<{ display_name: string; uri: string; photo_uri: string }>; + }> + | undefined; if (maxPhotos > 0 && details.photos?.length > 0) { const photosToFetch = details.photos.slice(0, maxPhotos); photos = []; for (const photo of photosToFetch) { try { const url = await this.newPlacesService.getPhotoUri(photo.photo_reference); - photos.push({ url, width: photo.width, height: photo.height }); + photos.push({ + url, + width: photo.width, + height: photo.height, + google_maps_uri: photo.google_maps_uri, + flag_content_uri: photo.flag_content_uri, + author_attributions: photo.author_attributions, + }); } catch { // Skip failed photos silently } @@ -270,6 +286,7 @@ export class PlacesSearcher { data: { name: details.name, address: details.formatted_address, + google_maps_uri: details.google_maps_uri, location: details.geometry?.location, primary_type: details.primary_type || null, types: details.types || [], @@ -287,7 +304,13 @@ export class PlacesSearcher { ...(details.atmosphere ? { atmosphere: details.atmosphere } : {}), ...(details.payment_options ? { payment_options: details.payment_options } : {}), ...(details.review_summary ? { review_summary: details.review_summary } : {}), + ...(details.review_summary_attribution + ? { review_summary_attribution: details.review_summary_attribution } + : {}), ...(details.generative_summary ? { generative_summary: details.generative_summary } : {}), + ...(details.generative_summary_attribution + ? { generative_summary_attribution: details.generative_summary_attribution } + : {}), photo_count: details.photos?.length || 0, ...(photos && photos.length > 0 ? { photos } : {}), reviews: details.reviews?.map((review: any) => ({ @@ -296,6 +319,11 @@ export class PlacesSearcher { language: review.language || null, time: review.time, author_name: review.author_name, + author_uri: review.author_uri, + author_photo_uri: review.author_photo_uri, + google_maps_uri: review.google_maps_uri, + flag_content_uri: review.flag_content_uri, + relative_publish_time_description: review.relative_publish_time_description, })), }, }; diff --git a/src/tools/maps/placeDetails.ts b/src/tools/maps/placeDetails.ts index a175798..59f459d 100644 --- a/src/tools/maps/placeDetails.ts +++ b/src/tools/maps/placeDetails.ts @@ -4,7 +4,7 @@ import { getCurrentApiKey } from "../../utils/requestContext.js"; const NAME = "maps_place_details"; const DESCRIPTION = - "Get comprehensive details for a specific place using its Google Maps place_id. Use after search_nearby or maps_search_places to get full information including reviews, phone number, website, and opening hours. Set maxPhotos (1-10) to include photo URLs — omit or set to 0 for no photos (saves tokens)."; + "Get comprehensive details for a specific place using its Google Maps place_id. Use after search_nearby or maps_search_places to get reviews, phone, website, and hours. Reviews, photos, and AI summaries include source/disclosure metadata that must accompany end-user display. Set maxPhotos (1-10) to include photo URLs; omit or set 0 for none."; const SCHEMA = { placeId: z.string().describe("Google Maps place ID"), diff --git a/tests/placeAttribution.unit.test.ts b/tests/placeAttribution.unit.test.ts new file mode 100644 index 0000000..5ce577f --- /dev/null +++ b/tests/placeAttribution.unit.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { NewPlacesService } from "../src/services/NewPlacesService.js"; +import { PlacesSearcher } from "../src/services/PlacesSearcher.js"; + +const place = { + name: "places/example", + displayName: { text: "Example Cafe" }, + googleMapsUri: "https://www.google.com/maps/place/example", + location: { latitude: 1, longitude: 2 }, + reviews: [ + { + rating: 5, + text: { text: "Great coffee", languageCode: "en" }, + publishTime: { seconds: 123 }, + authorAttribution: { + displayName: "Reviewer", + uri: "https://www.google.com/maps/contrib/reviewer", + photoUri: "https://example.com/avatar", + }, + googleMapsUri: "https://www.google.com/maps/reviews/example", + flagContentUri: "https://www.google.com/local/review/report/example", + relativePublishTimeDescription: "2 days ago", + }, + ], + photos: [ + { + name: "places/example/photos/1", + widthPx: 800, + heightPx: 600, + googleMapsUri: "https://www.google.com/maps/photos/example", + flagContentUri: "https://www.google.com/local/photo/report/example", + authorAttributions: [{ displayName: "Photographer", uri: "https://example.com/author", photoUri: "" }], + }, + ], + reviewSummary: { + text: { text: "Popular for coffee" }, + disclosureText: { text: "Summarized with Gemini" }, + flagContentUri: "https://www.google.com/local/summary/report/example", + reviewsUri: "https://www.google.com/maps/place/example/reviews", + }, + generativeSummary: { + overview: { text: "A neighborhood cafe" }, + disclosureText: { text: "Summarized with Gemini" }, + flagContentUri: "https://www.google.com/local/place-summary/report/example", + }, +}; + +test("Place Details retains source and disclosure metadata through the service facade", async () => { + const service = new NewPlacesService("test-key"); + let requestedMask = ""; + (service as unknown as { client: { getPlace: (...args: any[]) => Promise<[typeof place]> } }).client = { + getPlace: async (_request, options) => { + requestedMask = options.otherArgs.headers["X-Goog-FieldMask"]; + return [place]; + }, + }; + const searcher = new PlacesSearcher("test-key"); + (searcher as unknown as { newPlacesService: NewPlacesService }).newPlacesService = service; + service.getPhotoUri = async () => "https://example.com/photo"; + + const response = await searcher.getPlaceDetails("example", 1); + assert.equal(response.success, true); + assert.equal(response.data.google_maps_uri, place.googleMapsUri); + assert.deepEqual(response.data.reviews[0], { + rating: 5, + text: "Great coffee", + language: "en", + time: 123, + author_name: "Reviewer", + author_uri: "https://www.google.com/maps/contrib/reviewer", + author_photo_uri: "https://example.com/avatar", + google_maps_uri: "https://www.google.com/maps/reviews/example", + flag_content_uri: "https://www.google.com/local/review/report/example", + relative_publish_time_description: "2 days ago", + }); + assert.deepEqual(response.data.photos[0], { + url: "https://example.com/photo", + width: 800, + height: 600, + google_maps_uri: "https://www.google.com/maps/photos/example", + flag_content_uri: "https://www.google.com/local/photo/report/example", + author_attributions: [{ display_name: "Photographer", uri: "https://example.com/author", photo_uri: "" }], + }); + assert.deepEqual(response.data.review_summary_attribution, { + disclosure_text: "Summarized with Gemini", + flag_content_uri: "https://www.google.com/local/summary/report/example", + reviews_uri: "https://www.google.com/maps/place/example/reviews", + }); + assert.deepEqual(response.data.generative_summary_attribution, { + disclosure_text: "Summarized with Gemini", + flag_content_uri: "https://www.google.com/local/place-summary/report/example", + }); + for (const field of [ + "reviews.authorAttribution.uri", + "reviews.authorAttribution.photoUri", + "reviews.googleMapsUri", + "photos.authorAttributions", + "photos.googleMapsUri", + "reviewSummary", + "generativeSummary", + ]) { + assert.ok(requestedMask.split(",").includes(field), `${field} missing from field mask`); + } +}); diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index ca6f5c6..07c5638 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -627,6 +627,7 @@ async function testPlaceDetailsPhotos(session: McpSession): Promise { assert(noPhotoData.photos === undefined, "place_details without maxPhotos omits photos array"); assert(typeof noPhotoData.name === "string", "place_details returns name"); assert(typeof noPhotoData.rating === "number", "place_details returns rating"); + assert(typeof noPhotoData.google_maps_uri === "string", "place_details returns Google Maps source field"); // Verify new place attribute fields assert(Array.isArray(noPhotoData.types), "place_details returns types array"); @@ -645,6 +646,14 @@ async function testPlaceDetailsPhotos(session: McpSession): Promise { // reviews should have language field if (noPhotoData.reviews?.length > 0) { assert("language" in noPhotoData.reviews[0], "reviews include language field"); + assert(typeof noPhotoData.reviews[0].google_maps_uri === "string", "reviews include source link field"); + assert(typeof noPhotoData.reviews[0].author_uri === "string", "reviews include author profile field"); + } + if (noPhotoData.review_summary) { + assert( + typeof noPhotoData.review_summary_attribution?.disclosure_text === "string", + "review summary includes disclosure" + ); } // Test with maxPhotos=1 — should return photos array with URLs @@ -659,6 +668,8 @@ async function testPlaceDetailsPhotos(session: McpSession): Promise { assert(withPhotoData.photos[0].url.startsWith("https://"), "photo URL is a valid HTTPS URL"); assert(typeof withPhotoData.photos[0].width === "number", "photo has width"); assert(typeof withPhotoData.photos[0].height === "number", "photo has height"); + assert(typeof withPhotoData.photos[0].google_maps_uri === "string", "photo includes source link field"); + assert(Array.isArray(withPhotoData.photos[0].author_attributions), "photo includes author attribution field"); } async function testMultiSession(): Promise {