google-tools-mcp 1.2.11 → 2.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.
Files changed (97) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +555 -276
  3. package/dist/cachedToolsList.js +52 -52
  4. package/dist/googleDocsApiHelpers.js +76 -12
  5. package/dist/helpers.js +572 -252
  6. package/dist/httpAuth.js +296 -0
  7. package/dist/index.js +162 -11
  8. package/dist/logger.js +87 -87
  9. package/dist/markdown-transformer/docsToMarkdown.js +222 -21
  10. package/dist/markdown-transformer/index.js +19 -9
  11. package/dist/markdown-transformer/markdownToDocs.js +458 -28
  12. package/dist/readTracker.js +136 -17
  13. package/dist/sessionContext.js +37 -0
  14. package/dist/setup.js +187 -9
  15. package/dist/tools/calendar/getBusy.js +64 -64
  16. package/dist/tools/calendar/getEvents.js +141 -141
  17. package/dist/tools/calendar/getFree.js +225 -225
  18. package/dist/tools/calendar/index.js +19 -19
  19. package/dist/tools/calendar/listCalendars.js +38 -38
  20. package/dist/tools/calendar/listRecurringInstances.js +83 -83
  21. package/dist/tools/calendar/manageCalendar.js +121 -121
  22. package/dist/tools/calendar/manageEvent.js +258 -258
  23. package/dist/tools/calendar/moveEvent.js +66 -66
  24. package/dist/tools/docs/addTab.js +10 -12
  25. package/dist/tools/docs/appendToGoogleDoc.js +9 -4
  26. package/dist/tools/docs/deleteRange.js +9 -4
  27. package/dist/tools/docs/findAndReplace.js +9 -4
  28. package/dist/tools/docs/formatting/applyParagraphStyle.js +4 -1
  29. package/dist/tools/docs/formatting/getFormatting.js +171 -171
  30. package/dist/tools/docs/insertImage.js +15 -2
  31. package/dist/tools/docs/insertPageBreak.js +4 -1
  32. package/dist/tools/docs/insertTable.js +4 -1
  33. package/dist/tools/docs/insertTableWithData.js +4 -1
  34. package/dist/tools/docs/modifyText.js +14 -4
  35. package/dist/tools/docs/modifyText.test.js +84 -84
  36. package/dist/tools/docs/readGoogleDoc.js +71 -11
  37. package/dist/tools/docs/renameTab.js +12 -14
  38. package/dist/tools/drafts.js +165 -165
  39. package/dist/tools/drive/createDocument.js +13 -1
  40. package/dist/tools/drive/downloadFile.js +268 -268
  41. package/dist/tools/drive/getFileInfo.js +48 -48
  42. package/dist/tools/drive/getFilePath.js +50 -50
  43. package/dist/tools/drive/listDriveFiles.js +112 -112
  44. package/dist/tools/drive/listSharedDrives.js +52 -52
  45. package/dist/tools/drive/listSharedWithMe.js +97 -97
  46. package/dist/tools/drive/uploadFile.js +111 -111
  47. package/dist/tools/extras/index.js +9 -9
  48. package/dist/tools/extras/readDriveFile.js +212 -206
  49. package/dist/tools/extras/readFile.js +84 -84
  50. package/dist/tools/extras/searchFileContents.js +81 -81
  51. package/dist/tools/forms/batchUpdateForm.js +81 -81
  52. package/dist/tools/forms/createForm.js +54 -54
  53. package/dist/tools/forms/getForm.js +118 -118
  54. package/dist/tools/forms/getFormResponse.js +45 -45
  55. package/dist/tools/forms/index.js +15 -15
  56. package/dist/tools/forms/listFormResponses.js +56 -56
  57. package/dist/tools/forms/setPublishSettings.js +59 -59
  58. package/dist/tools/gmail/drafts.js +165 -165
  59. package/dist/tools/gmail/labels.js +54 -83
  60. package/dist/tools/gmail/messages.js +444 -441
  61. package/dist/tools/gmail/settings.js +358 -528
  62. package/dist/tools/gmail/threads.js +286 -164
  63. package/dist/tools/index.js +511 -473
  64. package/dist/tools/labels.js +103 -103
  65. package/dist/tools/legacyAliases.js +426 -0
  66. package/dist/tools/maps/directions.js +72 -0
  67. package/dist/tools/maps/geocode.js +16 -0
  68. package/dist/tools/maps/index.js +15 -0
  69. package/dist/tools/maps/mapsClient.js +83 -0
  70. package/dist/tools/maps/placeDetails.js +16 -0
  71. package/dist/tools/maps/reverseGeocode.js +19 -0
  72. package/dist/tools/maps/searchNearby.js +54 -0
  73. package/dist/tools/maps/searchPlaces.js +24 -0
  74. package/dist/tools/messages.js +448 -448
  75. package/dist/tools/settings.js +528 -528
  76. package/dist/tools/slides/createPresentation.js +104 -104
  77. package/dist/tools/slides/createShape.js +92 -92
  78. package/dist/tools/slides/createTextBox.js +82 -82
  79. package/dist/tools/slides/deleteSlide.js +30 -30
  80. package/dist/tools/slides/duplicateSlide.js +37 -37
  81. package/dist/tools/slides/exportThumbnail.js +42 -42
  82. package/dist/tools/slides/formatParagraph.js +72 -72
  83. package/dist/tools/slides/formatText.js +84 -84
  84. package/dist/tools/slides/getPresentation.js +87 -87
  85. package/dist/tools/slides/index.js +33 -33
  86. package/dist/tools/slides/reorderSlides.js +41 -41
  87. package/dist/tools/slides/replaceAllText.js +46 -46
  88. package/dist/tools/slides/setBackground.js +58 -58
  89. package/dist/tools/slides/speakerNotes.js +102 -102
  90. package/dist/tools/slides/styleShape.js +111 -111
  91. package/dist/tools/slides/updatePresentation.js +128 -128
  92. package/dist/tools/threads.js +145 -145
  93. package/dist/tools/utils/appendMarkdownToGoogleDoc.js +31 -7
  94. package/dist/tools/utils/replaceDocumentWithMarkdown.js +72 -9
  95. package/dist/updateCheck.js +202 -0
  96. package/dist/workspace.js +122 -0
  97. package/package.json +82 -80
@@ -0,0 +1,72 @@
1
+ import { z } from 'zod';
2
+ import { UserError } from 'fastmcp';
3
+ import { getMapsApiKey, mapsFetch } from './mapsClient.js';
4
+
5
+ const waypoint = z.union([
6
+ z.string().min(1).describe('An address or place name'),
7
+ z.object({
8
+ latitude: z.number().min(-90).max(90).describe('Latitude in degrees'),
9
+ longitude: z.number().min(-180).max(180).describe('Longitude in degrees'),
10
+ }).describe('Explicit coordinates'),
11
+ ]);
12
+ const FIELD_MASK = 'routes.distanceMeters,routes.duration,routes.description,routes.legs.steps.distanceMeters,routes.legs.steps.staticDuration,routes.legs.steps.navigationInstruction.instructions,routes.legs.steps.transitDetails';
13
+
14
+ // The Routes API documents WALK and BICYCLE as beta travel modes and requires displaying
15
+ // this warning to the user for any walking/bicycling route shown:
16
+ // https://developers.google.com/maps/documentation/routes/reference/rest/v2/RouteTravelMode
17
+ const BETA_TRAVEL_MODES = new Set(['WALK', 'BICYCLE']);
18
+ const BETA_TRAVEL_MODE_WARNING = 'WALK and BICYCLE routes are in beta: pedestrian and cycling paths may be incomplete or imprecise in some areas.';
19
+
20
+ function formatWaypoint(value) {
21
+ return typeof value === 'string' ? { address: value } : { location: { latLng: value } };
22
+ }
23
+
24
+ function formatTransitDetails(details) {
25
+ if (!details) return undefined;
26
+ const line = details.transitLine;
27
+ return {
28
+ line: line?.nameShort || line?.name || null,
29
+ vehicle: line?.vehicle?.type || null,
30
+ headsign: details.headsign || null,
31
+ departureStop: details.stopDetails?.departureStop?.name || null,
32
+ departureTime: details.stopDetails?.departureTime || null,
33
+ arrivalStop: details.stopDetails?.arrivalStop?.name || null,
34
+ arrivalTime: details.stopDetails?.arrivalTime || null,
35
+ stopCount: details.stopCount ?? null,
36
+ };
37
+ }
38
+
39
+ export function register(server) {
40
+ server.addTool({
41
+ name: 'mapsDirections',
42
+ description: 'Get a route summary and turn-by-turn instructions between addresses or coordinates.',
43
+ parameters: z.object({
44
+ origin: waypoint.describe('Route start: address string or {latitude, longitude}'),
45
+ destination: waypoint.describe('Route end: address string or {latitude, longitude}'),
46
+ travelMode: z.enum(['DRIVE', 'WALK', 'BICYCLE', 'TRANSIT']).describe('Travel mode for the route'),
47
+ departureTime: z.string().datetime({ offset: true }).optional().describe('RFC 3339 departure time. Omit to depart now. Must be in the future unless travelMode is TRANSIT (the Routes API rejects past times for other modes).'),
48
+ }),
49
+ execute: async (args) => {
50
+ if (args.departureTime && args.travelMode !== 'TRANSIT' && new Date(args.departureTime).getTime() < Date.now()) {
51
+ throw new UserError('departureTime must be in the future for DRIVE/WALK/BICYCLE routes (the Routes API only accepts past departure times for TRANSIT). Omit departureTime to depart now.');
52
+ }
53
+ const body = { origin: formatWaypoint(args.origin), destination: formatWaypoint(args.destination), travelMode: args.travelMode };
54
+ if (args.departureTime) body.departureTime = args.departureTime;
55
+ const data = await mapsFetch('https://routes.googleapis.com/directions/v2:computeRoutes', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Goog-Api-Key': getMapsApiKey(), 'X-Goog-FieldMask': FIELD_MASK }, body: JSON.stringify(body) });
56
+ const route = data.routes?.[0];
57
+ if (!route) return JSON.stringify(null);
58
+ return JSON.stringify({
59
+ description: route.description || null,
60
+ distanceMeters: route.distanceMeters ?? null,
61
+ duration: route.duration || null,
62
+ ...(BETA_TRAVEL_MODES.has(args.travelMode) ? { warning: BETA_TRAVEL_MODE_WARNING } : {}),
63
+ steps: (route.legs || []).flatMap((leg) => (leg.steps || []).map((step) => ({
64
+ instructions: step.navigationInstruction?.instructions || null,
65
+ distanceMeters: step.distanceMeters ?? null,
66
+ duration: step.staticDuration || null,
67
+ ...(step.transitDetails ? { transit: formatTransitDetails(step.transitDetails) } : {}),
68
+ }))),
69
+ });
70
+ },
71
+ });
72
+ }
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+ import { getMapsApiKey, mapsFetch } from './mapsClient.js';
3
+
4
+ export function register(server) {
5
+ server.addTool({
6
+ name: 'mapsGeocode',
7
+ description: 'Convert an address to coordinates, a formatted address, and a Google place ID.',
8
+ parameters: z.object({ address: z.string().min(1).describe('The street address or place name to geocode') }),
9
+ execute: async ({ address }) => {
10
+ const params = new URLSearchParams({ address, key: getMapsApiKey() });
11
+ const data = await mapsFetch(`https://maps.googleapis.com/maps/api/geocode/json?${params}`);
12
+ const result = data.results?.[0];
13
+ return JSON.stringify(result ? { location: result.geometry?.location || null, formattedAddress: result.formatted_address || null, placeId: result.place_id || null } : null);
14
+ },
15
+ });
16
+ }
@@ -0,0 +1,15 @@
1
+ import { register as geocode } from './geocode.js';
2
+ import { register as reverseGeocode } from './reverseGeocode.js';
3
+ import { register as searchNearby } from './searchNearby.js';
4
+ import { register as searchPlaces } from './searchPlaces.js';
5
+ import { register as placeDetails } from './placeDetails.js';
6
+ import { register as directions } from './directions.js';
7
+
8
+ export function registerMapsTools(server) {
9
+ geocode(server);
10
+ reverseGeocode(server);
11
+ searchNearby(server);
12
+ searchPlaces(server);
13
+ placeDetails(server);
14
+ directions(server);
15
+ }
@@ -0,0 +1,83 @@
1
+ import { UserError } from 'fastmcp';
2
+
3
+ export function getMapsApiKey() {
4
+ const apiKey = process.env.GOOGLE_MAPS_API_KEY;
5
+ if (!apiKey) throw new UserError('Set GOOGLE_MAPS_API_KEY to use Maps tools. This must be a Google Maps Platform API key, separate from Google OAuth.');
6
+ return apiKey;
7
+ }
8
+
9
+ // Generic query-string secret patterns (key=, api_key=, apikey=, access_token=, token=),
10
+ // so future call sites that embed a credential in a URL are covered even if the exact
11
+ // param name changes.
12
+ const SECRET_QUERY_PARAM_PATTERN = /([?&](?:key|api[_-]?key|access_token|token)=)[^&\s'"]+/gi;
13
+
14
+ // Scrubs the live GOOGLE_MAPS_API_KEY value (if set) plus any key-shaped query-string
15
+ // parameter out of a string before it can reach a caller-visible UserError. This is the
16
+ // single boundary every mapsFetch error path routes through, so no code path (geocoding's
17
+ // URL-embedded key, a future endpoint, a raw network error whose message happens to quote
18
+ // the failed URL) can leak the credential to the MCP caller or its logs.
19
+ export function redactSecrets(text) {
20
+ if (typeof text !== 'string') return text;
21
+ let redacted = text;
22
+ const apiKey = process.env.GOOGLE_MAPS_API_KEY;
23
+ if (apiKey) redacted = redacted.split(apiKey).join('[REDACTED]');
24
+ return redacted.replace(SECRET_QUERY_PARAM_PATTERN, '$1[REDACTED]');
25
+ }
26
+
27
+ export async function mapsFetch(url, options = {}) {
28
+ let response;
29
+ try { response = await fetch(url, options); }
30
+ catch (error) { throw new UserError(redactSecrets(`Google Maps request failed: ${error.message || error}`)); }
31
+ let data;
32
+ try { data = await response.json(); } catch { data = null; }
33
+ if (data === null && response.ok) {
34
+ throw new UserError(`Google Maps API returned an unparsable response (HTTP ${response.status}).`);
35
+ }
36
+ const apiStatusError = data?.status && !['OK', 'ZERO_RESULTS'].includes(data.status);
37
+ if (!response.ok || apiStatusError) {
38
+ const status = data?.error?.status || data?.status || response.status;
39
+ const message = data?.error?.message || data?.error_message || response.statusText || 'Unknown error';
40
+ throw new UserError(redactSecrets(`Google Maps API error (${status}): ${message}`));
41
+ }
42
+ return data;
43
+ }
44
+
45
+ export async function placesRequest(url, { method = 'GET', body, fieldMask }) {
46
+ const headers = { 'X-Goog-Api-Key': getMapsApiKey(), 'X-Goog-FieldMask': fieldMask };
47
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
48
+ return mapsFetch(url, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
49
+ }
50
+
51
+ export function formatPlace(place) {
52
+ return { placeId: place.id, name: place.displayName?.text || null, address: place.formattedAddress || null, location: place.location || null, types: place.types || [], rating: place.rating ?? null, userRatingCount: place.userRatingCount ?? null, priceLevel: place.priceLevel || null, googleMapsUri: place.googleMapsUri || null };
53
+ }
54
+
55
+ export function haversineMeters(a, b) {
56
+ const R = 6371000;
57
+ const toRad = (deg) => (deg * Math.PI) / 180;
58
+ const dLat = toRad(b.latitude - a.latitude);
59
+ const dLng = toRad(b.longitude - a.longitude);
60
+ const lat1 = toRad(a.latitude);
61
+ const lat2 = toRad(b.latitude);
62
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
63
+ return 2 * R * Math.asin(Math.sqrt(h));
64
+ }
65
+
66
+ export function withinRadius(places, center, radiusMeters) {
67
+ return places.filter((place) => {
68
+ const loc = place.location;
69
+ if (!loc || typeof loc.latitude !== 'number' || typeof loc.longitude !== 'number') return false;
70
+ return haversineMeters(center, loc) <= radiusMeters;
71
+ });
72
+ }
73
+
74
+ export function dedupePlaces(places, maxResults) {
75
+ const seen = new Set();
76
+ return places.filter((place) => {
77
+ if (!place.placeId || seen.has(place.placeId)) return false;
78
+ seen.add(place.placeId);
79
+ return true;
80
+ }).slice(0, maxResults);
81
+ }
82
+
83
+ export const SEARCH_FIELD_MASK = 'places.id,places.displayName,places.formattedAddress,places.location,places.types,places.rating,places.userRatingCount,places.priceLevel,places.googleMapsUri';
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+ import { placesRequest } from './mapsClient.js';
3
+
4
+ const FIELD_MASK = 'id,displayName,formattedAddress,nationalPhoneNumber,websiteUri,regularOpeningHours.weekdayDescriptions,rating,userRatingCount,priceLevel,types,googleMapsUri,location';
5
+
6
+ export function register(server) {
7
+ server.addTool({
8
+ name: 'mapsPlaceDetails',
9
+ description: 'Get contact, hours, rating, price, type, map link, and location details for a place ID.',
10
+ parameters: z.object({ placeId: z.string().min(1).describe('Google place ID (from mapsGeocode or the search tools)') }),
11
+ execute: async ({ placeId }) => {
12
+ const place = await placesRequest(`https://places.googleapis.com/v1/places/${encodeURIComponent(placeId)}`, { fieldMask: FIELD_MASK });
13
+ return JSON.stringify({ placeId: place.id || placeId, name: place.displayName?.text || null, address: place.formattedAddress || null, phone: place.nationalPhoneNumber || null, website: place.websiteUri || null, hours: place.regularOpeningHours?.weekdayDescriptions || [], rating: place.rating ?? null, userRatingCount: place.userRatingCount ?? null, priceLevel: place.priceLevel || null, types: place.types || [], googleMapsUri: place.googleMapsUri || null, location: place.location || null });
14
+ },
15
+ });
16
+ }
@@ -0,0 +1,19 @@
1
+ import { z } from 'zod';
2
+ import { getMapsApiKey, mapsFetch } from './mapsClient.js';
3
+
4
+ export function register(server) {
5
+ server.addTool({
6
+ name: 'mapsReverseGeocode',
7
+ description: 'Convert coordinates to the nearest formatted street address.',
8
+ parameters: z.object({
9
+ latitude: z.number().min(-90).max(90).describe('Latitude in degrees'),
10
+ longitude: z.number().min(-180).max(180).describe('Longitude in degrees'),
11
+ }),
12
+ execute: async ({ latitude, longitude }) => {
13
+ const params = new URLSearchParams({ latlng: `${latitude},${longitude}`, key: getMapsApiKey() });
14
+ const data = await mapsFetch(`https://maps.googleapis.com/maps/api/geocode/json?${params}`);
15
+ const result = data.results?.[0];
16
+ return JSON.stringify(result ? { formattedAddress: result.formatted_address || null, placeId: result.place_id || null, location: result.geometry?.location || { lat: latitude, lng: longitude } } : null);
17
+ },
18
+ });
19
+ }
@@ -0,0 +1,54 @@
1
+ import { z } from 'zod';
2
+ import { dedupePlaces, formatPlace, placesRequest, SEARCH_FIELD_MASK, withinRadius } from './mapsClient.js';
3
+
4
+ export function register(server) {
5
+ server.addTool({
6
+ name: 'mapsSearchNearby',
7
+ description: 'Find places near coordinates, optionally filtering by place types or a keyword.',
8
+ parameters: z.object({
9
+ latitude: z.number().min(-90).max(90).describe('Center latitude in degrees'),
10
+ longitude: z.number().min(-180).max(180).describe('Center longitude in degrees'),
11
+ radiusMeters: z.number().positive().max(50000).describe('Search radius in meters (max 50000)'),
12
+ includedTypes: z.array(z.string()).max(50).optional().describe('Place types to include, e.g. ["restaurant", "gym"]'),
13
+ keyword: z.string().min(1).optional().describe('Free-text query ranked server-side by Text Search, biased to the radius'),
14
+ maxResults: z.number().int().min(1).max(20).optional().default(10).describe('Maximum places to return (default 10, max 20)'),
15
+ }),
16
+ execute: async (args) => {
17
+ const center = { latitude: args.latitude, longitude: args.longitude };
18
+ let places;
19
+ if (args.keyword) {
20
+ // Nearby Search (New) has no keyword field, and filtering an unfiltered
21
+ // nearby page locally drops semantically-relevant hits (e.g. a Starbucks
22
+ // that lacks the literal word "coffee"). Send the keyword to Text Search,
23
+ // which ranks relevance server-side, bias it to the circle, then enforce
24
+ // the radius locally so the result still honors radiusMeters.
25
+ //
26
+ // Text Search's includedType is a ranking bias, not a filter, unless
27
+ // strictTypeFiltering is set (Google docs:
28
+ // https://developers.google.com/maps/documentation/places/web-service/text-search#includedtype).
29
+ // This tool promises type filtering, and Text Search only accepts a single
30
+ // includedType per request, so for multiple requested types we run one
31
+ // strict Text Search per type and merge, instead of asking for one type and
32
+ // locally filtering an unfiltered (and therefore incomplete, capped-at-20)
33
+ // page of results by the rest.
34
+ const types = args.includedTypes?.length ? args.includedTypes : [undefined];
35
+ const resultSets = await Promise.all(types.map(async (type) => {
36
+ const body = { textQuery: args.keyword, maxResultCount: 20, locationBias: { circle: { center, radius: args.radiusMeters } } };
37
+ if (type) {
38
+ body.includedType = type;
39
+ body.strictTypeFiltering = true;
40
+ }
41
+ const data = await placesRequest('https://places.googleapis.com/v1/places:searchText', { method: 'POST', body, fieldMask: SEARCH_FIELD_MASK });
42
+ return (data.places || []).map(formatPlace);
43
+ }));
44
+ places = withinRadius(resultSets.flat(), center, args.radiusMeters);
45
+ } else {
46
+ const body = { locationRestriction: { circle: { center, radius: args.radiusMeters } }, maxResultCount: args.maxResults };
47
+ if (args.includedTypes?.length) body.includedTypes = args.includedTypes;
48
+ const data = await placesRequest('https://places.googleapis.com/v1/places:searchNearby', { method: 'POST', body, fieldMask: SEARCH_FIELD_MASK });
49
+ places = (data.places || []).map(formatPlace);
50
+ }
51
+ return JSON.stringify(dedupePlaces(places, args.maxResults));
52
+ },
53
+ });
54
+ }
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+ import { dedupePlaces, formatPlace, placesRequest, SEARCH_FIELD_MASK } from './mapsClient.js';
3
+
4
+ export function register(server) {
5
+ server.addTool({
6
+ name: 'mapsSearchPlaces',
7
+ description: 'Search Google Places with free text and an optional circular location bias.',
8
+ parameters: z.object({
9
+ query: z.string().min(1).describe('Free-text place search, e.g. "vegan restaurants in Lafayette IN"'),
10
+ locationBias: z.object({
11
+ latitude: z.number().min(-90).max(90).describe('Bias center latitude'),
12
+ longitude: z.number().min(-180).max(180).describe('Bias center longitude'),
13
+ radiusMeters: z.number().positive().max(50000).describe('Bias radius in meters (max 50000)'),
14
+ }).optional().describe('Optional circular area to bias results toward'),
15
+ maxResults: z.number().int().min(1).max(20).optional().default(10).describe('Maximum places to return (default 10, max 20)'),
16
+ }),
17
+ execute: async (args) => {
18
+ const body = { textQuery: args.query, maxResultCount: args.maxResults };
19
+ if (args.locationBias) body.locationBias = { circle: { center: { latitude: args.locationBias.latitude, longitude: args.locationBias.longitude }, radius: args.locationBias.radiusMeters } };
20
+ const data = await placesRequest('https://places.googleapis.com/v1/places:searchText', { method: 'POST', body, fieldMask: SEARCH_FIELD_MASK });
21
+ return JSON.stringify(dedupePlaces((data.places || []).map(formatPlace), args.maxResults));
22
+ },
23
+ });
24
+ }