/** * Google Maps MCP tools. * * Trust tier: ops * Required scope: ops:read * * All tools in this package call the injected GoogleMapsClient — no direct * network calls here. Tests substitute a mock client. */ import { defineTool, requireScope } from '@sh-mcp/shared'; import type { AuthContext } from '@sh-mcp/shared'; import type { GoogleMapsClient, PlaceResult } from './client.js'; // --------------------------------------------------------------------------- // Input / output types // --------------------------------------------------------------------------- export interface SearchNearbyVendorsInput { /** Free-text query, e.g. "electrician" or "plumbing supply near downtown". */ query: string; /** * Optional latitude of the search centre for a location bias. * Must be provided together with lng and radius_meters. */ lat?: number; /** * Optional longitude of the search centre for a location bias. * Must be provided together with lat and radius_meters. */ lng?: number; /** * Bias radius in metres (max 50 000). Ignored unless lat + lng are also set. */ radius_meters?: number; /** Maximum number of results to return (1–20, default 10). */ max_results?: number; } export interface VendorListing { name: string; address: string; phone?: string; categories: string[]; rating?: number; maps_url?: string; /** Always present — callers must surface this to users. */ disclaimer: string; } export interface SearchNearbyVendorsOutput { results: VendorListing[]; total: number; } // --------------------------------------------------------------------------- // Tool factory — accepts an injected client so tests can mock it // --------------------------------------------------------------------------- export function makeSearchNearbyVendors(client: GoogleMapsClient) { return defineTool({ name: 'search_nearby_vendors', description: 'Search for nearby vendors or service providers using the Google Places API. ' + 'Returns unvetted listings from Google Maps — always label results as unvetted to the user.', tier: 'ops', requiredScope: 'ops:read', inputSchema: { type: 'object', required: ['query'], additionalProperties: false, properties: { query: { type: 'string', description: 'Free-text search query (e.g. "electrician", "plumbing supply shop"). ' + 'Do NOT include the word "contractor" as a type qualifier — use a descriptive phrase instead.', minLength: 1, maxLength: 200, }, lat: { type: 'number', description: 'Latitude of the search centre for a location bias (-90 to 90).', minimum: -90, maximum: 90, }, lng: { type: 'number', description: 'Longitude of the search centre for a location bias (-180 to 180).', minimum: -180, maximum: 180, }, radius_meters: { type: 'number', description: 'Location-bias radius in metres (1–50000). Requires lat + lng.', minimum: 1, maximum: 50000, }, max_results: { type: 'integer', description: 'Maximum number of results to return (1–20, default 10).', minimum: 1, maximum: 20, default: 10, }, }, }, async handler( input: SearchNearbyVendorsInput, ctx: AuthContext, ): Promise { // TODO (DEFERRED auth layer): requireScope currently validates the scope // claim present in ctx.scopes. Real JWT signature verification, audience // binding (ctx.aud === 'sh-mcp-ops'), and issuer checks are implemented in // the DEFERRED auth middleware layer — not here. requireScope(ctx, 'ops:read'); const places: PlaceResult[] = await client.searchText({ textQuery: input.query, maxResultCount: input.max_results ?? 10, locationBiasLat: input.lat, locationBiasLng: input.lng, locationBiasRadiusMeters: input.radius_meters, }); const disclaimer = 'UNVETTED: These results are sourced directly from Google Maps and have not ' + 'been verified by Sea Haven Industries. Always confirm vendor credentials, ' + 'licensing, and insurance before engaging any vendor.'; const results: VendorListing[] = places.map((p) => ({ name: p.displayName, address: p.formattedAddress, phone: p.nationalPhoneNumber, categories: p.types, rating: p.rating, maps_url: p.googleMapsUri, disclaimer, })); return { results, total: results.length }; }, }); }