sh-mcp/packages/google-maps/src/tools.ts
Adam Moussa 0d1fefb326
Some checks are pending
deploy / deploy (push) Waiting to run
Phase 0b slice: monorepo scaffold + @sh-mcp/shared core + integration packages (#2)
* Phase 0b slice: monorepo scaffold + shared core + integration packages

The 0a-INDEPENDENT code slice (one-shot via af-0b-package-slice workflow: Haiku
scaffold + Sonnet packages, Sonnet fix-to-green). Nothing deploys; no CDK/servers.

- Monorepo scaffold: npm workspaces, strict TS (NodeNext), vitest (80% gate),
  eslint 9 flat config, prettier; ci.yaml/deploy.yaml callers (Node 24, enable-qemu).
- @sh-mcp/shared: transport-agnostic core — Scope/AuthContext/ToolDef, ToolRegistry,
  redact()+maskValue() (PII), OpenAPI 3.1 generator. AUTH STUBBED behind an AuthProvider
  interface (TODO auth-layer-0a); JWT/aud/client_id/JWKS/deny-list deferred per design.md §2.
- 9 integration packages (qbo, google-maps, internal-data, payments, knowledge-base,
  gmail, calendar, tasks, reminders): tools against shared, external deps mocked behind
  injected client interfaces; finance handlers call redact().

Verified green: tsc -b clean, vitest 245/245, eslint 0 errors. Auth mechanism intentionally
deferred until the 0a spike resolves it (G16/§0.4).

* Complete Cognito auth provider + Phase 1 build brief

Finish the WIP CognitoAuthProvider (client_id allow-list as audience
boundary, finance TTL ceiling, deny-list, scope-prefix stripping) with
its test suite, and check in docs/build-plan-phase-1.md so the Phase 1
work has its governing brief in-tree (design.md §2.5).

* ci: disable cdk synth for Phase 0b (no CDK app yet)

The reusable ci-typescript-cdk workflow defaults run-cdk-synth: true, but
the Phase 0b package scaffold has no cdk.json or stacks, so cdk synth fails
with '--app is required'. Disable it here; Phase 1 re-enables it with the
server CDK stubs.
2026-06-26 12:42:17 -04:00

144 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<SearchNearbyVendorsInput, SearchNearbyVendorsOutput>({
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<SearchNearbyVendorsOutput> {
// 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 };
},
});
}