diff --git a/src/tools/category-search-tool/CategorySearchTool.input.schema.ts b/src/tools/category-search-tool/CategorySearchTool.input.schema.ts index 0e06e99..76ae19a 100644 --- a/src/tools/category-search-tool/CategorySearchTool.input.schema.ts +++ b/src/tools/category-search-tool/CategorySearchTool.input.schema.ts @@ -44,5 +44,12 @@ export const CategorySearchInputSchema = z.object({ .default('formatted_text') .describe( 'Output format: "json_string" returns raw GeoJSON data as a JSON string that can be parsed; "formatted_text" returns human-readable text with place names, addresses, and coordinates. Both return as text content but json_string contains parseable JSON data while formatted_text is for display.' + ), + compact: z + .boolean() + .optional() + .default(true) + .describe( + 'When true (default), returns simplified GeoJSON with only essential fields (name, address, coordinates, categories, brand). When false, returns full verbose Mapbox API response with all metadata. Only applies to structured content output.' ) }); diff --git a/src/tools/category-search-tool/CategorySearchTool.ts b/src/tools/category-search-tool/CategorySearchTool.ts index 5a3f258..5de8060 100644 --- a/src/tools/category-search-tool/CategorySearchTool.ts +++ b/src/tools/category-search-tool/CategorySearchTool.ts @@ -93,6 +93,62 @@ export class CategorySearchTool extends MapboxApiBasedTool< return results.join('\n\n'); } + /** + * Simplify GeoJSON response to only essential fields + * Removes verbose nested context, attribution, and metadata while keeping valid GeoJSON structure + */ + private compactGeoJsonResponse( + geoJsonResponse: MapboxFeatureCollection + ): Record { + return { + type: 'FeatureCollection', + features: geoJsonResponse.features.map((feature) => { + const props = feature.properties || {}; + const context = (props.context || {}) as Record; + const geom = feature.geometry; + + // Extract coordinates safely + let coordinates: [number, number] | undefined; + if (geom && geom.type === 'Point' && 'coordinates' in geom) { + coordinates = geom.coordinates as [number, number]; + } + + return { + type: 'Feature', + geometry: geom + ? { + type: geom.type, + coordinates: + 'coordinates' in geom ? geom.coordinates : undefined + } + : null, + properties: { + name: props.name, + full_address: props.full_address, + place_formatted: props.place_formatted, + feature_type: props.feature_type, + coordinates: coordinates + ? { + longitude: coordinates[0], + latitude: coordinates[1] + } + : undefined, + poi_category: props.poi_category, + brand: props.brand, + maki: props.maki, + address: context.address?.name, + street: context.street?.name, + postcode: context.postcode?.name, + place: context.place?.name, + district: context.district?.name, + region: context.region?.name, + country: context.country?.name + } + }; + }) + }; + } + protected async execute( input: z.infer, accessToken: string @@ -175,16 +231,23 @@ export class CategorySearchTool extends MapboxApiBasedTool< data = rawData as MapboxFeatureCollection; } + // Determine which structured content to return + const structuredContent = input.compact + ? this.compactGeoJsonResponse(data) + : (data as unknown as Record); + if (input.format === 'json_string') { return { - content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], - structuredContent: data as unknown as Record, + content: [ + { type: 'text', text: JSON.stringify(structuredContent, null, 2) } + ], + structuredContent, isError: false }; } else { return { content: [{ type: 'text', text: this.formatGeoJsonToText(data) }], - structuredContent: data as unknown as Record, + structuredContent, isError: false }; } diff --git a/src/tools/reverse-geocode-tool/ReverseGeocodeTool.input.schema.ts b/src/tools/reverse-geocode-tool/ReverseGeocodeTool.input.schema.ts index fc15efc..31d52b5 100644 --- a/src/tools/reverse-geocode-tool/ReverseGeocodeTool.input.schema.ts +++ b/src/tools/reverse-geocode-tool/ReverseGeocodeTool.input.schema.ts @@ -56,5 +56,12 @@ export const ReverseGeocodeInputSchema = z.object({ .default('formatted_text') .describe( 'Output format: "json_string" returns raw GeoJSON data as a JSON string that can be parsed; "formatted_text" returns human-readable text with place names, addresses, and coordinates. Both return as text content but json_string contains parseable JSON data while formatted_text is for display.' + ), + compact: z + .boolean() + .optional() + .default(true) + .describe( + 'When true (default), returns simplified GeoJSON with only essential fields (name, address, coordinates, location hierarchy). When false, returns full verbose Mapbox API response with all metadata. Only applies to structured content output.' ) }); diff --git a/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts b/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts index 5f8cd21..029415d 100644 --- a/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts +++ b/src/tools/reverse-geocode-tool/ReverseGeocodeTool.ts @@ -86,6 +86,58 @@ export class ReverseGeocodeTool extends MapboxApiBasedTool< return results.join('\n\n'); } + /** + * Simplify GeoJSON response to only essential fields + * Removes verbose nested context and attribution while keeping valid GeoJSON structure + */ + private compactGeoJsonResponse( + geoJsonResponse: MapboxFeatureCollection + ): Record { + return { + type: 'FeatureCollection', + features: geoJsonResponse.features.map((feature) => { + const props = feature.properties || {}; + const context = (props.context || {}) as Record; + const geom = feature.geometry; + + // Extract coordinates safely + let coordinates: [number, number] | undefined; + if (geom && geom.type === 'Point' && 'coordinates' in geom) { + coordinates = geom.coordinates as [number, number]; + } + + return { + type: 'Feature', + geometry: geom + ? { + type: geom.type, + coordinates: + 'coordinates' in geom ? geom.coordinates : undefined + } + : null, + properties: { + name: props.name, + full_address: props.full_address, + place_formatted: props.place_formatted, + feature_type: props.feature_type, + coordinates: coordinates + ? { + longitude: coordinates[0], + latitude: coordinates[1] + } + : undefined, + address: context.address?.name, + postcode: context.postcode?.name, + place: context.place?.name, + district: context.district?.name, + region: context.region?.name, + country: context.country?.name + } + }; + }) + }; + } + protected async execute( input: z.infer, accessToken: string @@ -171,16 +223,23 @@ export class ReverseGeocodeTool extends MapboxApiBasedTool< }; } + // Determine which structured content to return + const structuredContent = input.compact + ? this.compactGeoJsonResponse(data) + : (data as unknown as Record); + if (input.format === 'json_string') { return { - content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], - structuredContent: data as unknown as Record, + content: [ + { type: 'text', text: JSON.stringify(structuredContent, null, 2) } + ], + structuredContent, isError: false }; } else { return { content: [{ type: 'text', text: this.formatGeoJsonToText(data) }], - structuredContent: data as unknown as Record, + structuredContent, isError: false }; } diff --git a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.input.schema.ts b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.input.schema.ts index 0854b53..df56bb8 100644 --- a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.input.schema.ts +++ b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.input.schema.ts @@ -61,5 +61,12 @@ export const SearchAndGeocodeInputSchema = z.object({ longitude: z.number().min(-180).max(180), latitude: z.number().min(-90).max(90) }) + .optional(), + compact: z + .boolean() .optional() + .default(true) + .describe( + 'When true (default), returns simplified GeoJSON with only essential fields (name, address, coordinates, categories, brand). When false, returns full verbose Mapbox API response with all metadata. Only applies to structured content output.' + ) }); diff --git a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.ts b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.ts index ae9d434..6df1e0f 100644 --- a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.ts +++ b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.ts @@ -93,6 +93,20 @@ const SearchBoxFeaturePropertiesSchema = z .optional(), bbox: z.array(z.number()).length(4).optional(), + // Metadata schema for additional feature information + metadata: z + .object({ + // API sometimes returns string, sometimes array - accept both + primary_photo: z.union([z.string(), z.array(z.string())]).optional(), + reading: z + .object({ + ja_kana: z.string().optional(), + ja_latin: z.string().optional() + }) + .optional() + }) + .optional(), + // POI specific fields poi_category: z.array(z.string()).optional(), poi_category_ids: z.array(z.string()).optional(), diff --git a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts index ebabf3b..3e50998 100644 --- a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts +++ b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts @@ -96,6 +96,62 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< return results.join('\n\n'); } + /** + * Simplify GeoJSON response to only essential fields + * Removes verbose nested context, attribution, and metadata while keeping valid GeoJSON structure + */ + private compactGeoJsonResponse( + geoJsonResponse: MapboxFeatureCollection + ): Record { + return { + type: 'FeatureCollection', + features: geoJsonResponse.features.map((feature) => { + const props = feature.properties || {}; + const context = (props.context || {}) as Record; + const geom = feature.geometry; + + // Extract coordinates safely + let coordinates: [number, number] | undefined; + if (geom && geom.type === 'Point' && 'coordinates' in geom) { + coordinates = geom.coordinates as [number, number]; + } + + return { + type: 'Feature', + geometry: geom + ? { + type: geom.type, + coordinates: + 'coordinates' in geom ? geom.coordinates : undefined + } + : null, + properties: { + name: props.name, + full_address: props.full_address, + place_formatted: props.place_formatted, + feature_type: props.feature_type, + coordinates: coordinates + ? { + longitude: coordinates[0], + latitude: coordinates[1] + } + : undefined, + poi_category: props.poi_category, + brand: props.brand, + maki: props.maki, + address: context.address?.name, + street: context.street?.name, + postcode: context.postcode?.name, + place: context.place?.name, + district: context.district?.name, + region: context.region?.name, + country: context.country?.name + } + }; + }) + }; + } + protected async execute( input: z.infer, accessToken: string @@ -211,6 +267,11 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< `SearchAndGeocodeTool: Successfully completed search, found ${data.features?.length || 0} results` ); + // Determine which structured content to return + const structuredContent = input.compact + ? this.compactGeoJsonResponse(data as MapboxFeatureCollection) + : (data as unknown as Record); + return { content: [ { @@ -218,7 +279,7 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< text: this.formatGeoJsonToText(data as MapboxFeatureCollection) } ], - structuredContent: data, + structuredContent, isError: false }; } diff --git a/test/tools/category-search-tool/CategorySearchTool.test.ts b/test/tools/category-search-tool/CategorySearchTool.test.ts index d7be658..f1ad1fc 100644 --- a/test/tools/category-search-tool/CategorySearchTool.test.ts +++ b/test/tools/category-search-tool/CategorySearchTool.test.ts @@ -389,7 +389,8 @@ describe('CategorySearchTool', () => { const result = await new CategorySearchTool({ httpRequest }).run({ category: 'restaurant', - format: 'json_string' + format: 'json_string', + compact: false // Use verbose format to match exact mockResponse }); expect(result.isError).toBe(false); @@ -432,6 +433,112 @@ describe('CategorySearchTool', () => { ).toContain('1. Test Cafe'); }); + it('returns compact JSON by default', async () => { + const mockResponse = { + type: 'FeatureCollection', + attribution: 'Some attribution text', + features: [ + { + type: 'Feature', + properties: { + name: 'Starbucks', + full_address: '123 Main St, New York, NY 10001', + feature_type: 'poi', + poi_category: ['coffee', 'restaurant'], + brand: 'Starbucks', + maki: 'cafe', + context: { + address: { name: '123 Main St' }, + street: { name: 'Main Street' }, + postcode: { name: '10001' }, + place: { name: 'New York' }, + region: { name: 'New York' }, + country: { name: 'United States' } + }, + mapbox_id: 'some-mapbox-id', + external_ids: { foursquare: '123' }, + metadata: { iso_3166_1: 'US' } + }, + geometry: { + type: 'Point', + coordinates: [-74.006, 40.7128] + } + } + ] + }; + + const { httpRequest } = setupHttpRequest({ + json: async () => mockResponse + }); + + const result = await new CategorySearchTool({ httpRequest }).run({ + category: 'coffee' + // compact defaults to true + }); + + expect(result.isError).toBe(false); + + const compactResult = result.structuredContent as Record; + + // Should be compact (no attribution, flattened context) + expect(compactResult.attribution).toBeUndefined(); + expect(compactResult.features[0].properties.address).toBe('123 Main St'); + expect(compactResult.features[0].properties.street).toBe('Main Street'); + expect(compactResult.features[0].properties.postcode).toBe('10001'); + expect(compactResult.features[0].properties.place).toBe('New York'); + expect(compactResult.features[0].properties.region).toBe('New York'); + expect(compactResult.features[0].properties.country).toBe('United States'); + expect(compactResult.features[0].properties.coordinates).toEqual({ + longitude: -74.006, + latitude: 40.7128 + }); + // Should not have nested context object or verbose fields + expect(compactResult.features[0].properties.context).toBeUndefined(); + expect(compactResult.features[0].properties.mapbox_id).toBeUndefined(); + expect(compactResult.features[0].properties.external_ids).toBeUndefined(); + expect(compactResult.features[0].properties.metadata).toBeUndefined(); + // Should keep essential fields + expect(compactResult.features[0].properties.name).toBe('Starbucks'); + expect(compactResult.features[0].properties.poi_category).toEqual([ + 'coffee', + 'restaurant' + ]); + expect(compactResult.features[0].properties.brand).toBe('Starbucks'); + expect(compactResult.features[0].properties.maki).toBe('cafe'); + }); + + it('returns verbose JSON when compact is false', async () => { + const mockResponse = { + type: 'FeatureCollection', + attribution: 'Some attribution text', + features: [ + { + type: 'Feature', + properties: { + name: 'Starbucks', + full_address: '123 Main St, New York, NY 10001' + }, + geometry: { + type: 'Point', + coordinates: [-74.006, 40.7128] + } + } + ] + }; + + const { httpRequest } = setupHttpRequest({ + json: async () => mockResponse + }); + + const result = await new CategorySearchTool({ httpRequest }).run({ + category: 'coffee', + compact: false // Use verbose format to match exact mockResponse + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent).toEqual(mockResponse); + }); + it('should have output schema defined', () => { const { httpRequest } = setupHttpRequest(); const tool = new CategorySearchTool({ httpRequest }); diff --git a/test/tools/reverse-geocode-tool/ReverseGeocodeTool.test.ts b/test/tools/reverse-geocode-tool/ReverseGeocodeTool.test.ts index 864cfb9..d9eb98c 100644 --- a/test/tools/reverse-geocode-tool/ReverseGeocodeTool.test.ts +++ b/test/tools/reverse-geocode-tool/ReverseGeocodeTool.test.ts @@ -463,7 +463,8 @@ describe('ReverseGeocodeTool', () => { const result = await new ReverseGeocodeTool({ httpRequest }).run({ longitude: -122.676, latitude: 45.515, - format: 'json_string' + format: 'json_string', + compact: false // Use verbose format to match exact mockResponse }); expect(result.isError).toBe(false); @@ -474,6 +475,63 @@ describe('ReverseGeocodeTool', () => { expect(JSON.parse(jsonContent)).toEqual(mockResponse); }); + it('returns compact JSON by default', async () => { + const mockResponse = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { + name: 'Test Address', + full_address: '123 Test St, Test City, TC 12345', + feature_type: 'address', + context: { + address: { name: '123 Test St' }, + place: { name: 'Test City' }, + region: { name: 'Test Region' }, + country: { name: 'Test Country' } + } + }, + geometry: { + type: 'Point', + coordinates: [-122.676, 45.515] + } + } + ], + attribution: 'Some attribution text' + }; + + const { httpRequest } = setupHttpRequest({ + json: async () => mockResponse + }); + + const result = await new ReverseGeocodeTool({ httpRequest }).run({ + longitude: -122.676, + latitude: 45.515, + format: 'json_string' + // compact defaults to true + }); + + expect(result.isError).toBe(false); + + const compactResult = JSON.parse( + (result.content[0] as { type: 'text'; text: string }).text + ); + + // Should be compact (no attribution, flattened context) + expect(compactResult.attribution).toBeUndefined(); + expect(compactResult.features[0].properties.address).toBe('123 Test St'); + expect(compactResult.features[0].properties.place).toBe('Test City'); + expect(compactResult.features[0].properties.region).toBe('Test Region'); + expect(compactResult.features[0].properties.country).toBe('Test Country'); + expect(compactResult.features[0].properties.coordinates).toEqual({ + longitude: -122.676, + latitude: 45.515 + }); + // Should not have nested context object + expect(compactResult.features[0].properties.context).toBeUndefined(); + }); + it('defaults to formatted_text format when format not specified', async () => { const mockResponse = { type: 'FeatureCollection', diff --git a/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.test.ts b/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.test.ts index 9bda04e..62dd07b 100644 --- a/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.test.ts +++ b/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.test.ts @@ -391,4 +391,110 @@ describe('SearchAndGeocodeTool', () => { isError: true }); }); + + it('returns compact JSON by default', async () => { + const mockResponse = { + type: 'FeatureCollection', + attribution: 'Some attribution text', + features: [ + { + type: 'Feature', + properties: { + name: 'Starbucks', + full_address: '123 Main St, New York, NY 10001', + feature_type: 'poi', + poi_category: ['coffee', 'restaurant'], + brand: 'Starbucks', + maki: 'cafe', + context: { + address: { name: '123 Main St' }, + street: { name: 'Main Street' }, + postcode: { name: '10001' }, + place: { name: 'New York' }, + region: { name: 'New York' }, + country: { name: 'United States' } + }, + mapbox_id: 'some-mapbox-id', + external_ids: { foursquare: '123' }, + metadata: { iso_3166_1: 'US' } + }, + geometry: { + type: 'Point', + coordinates: [-74.006, 40.7128] + } + } + ] + }; + + const { httpRequest } = setupHttpRequest({ + json: async () => mockResponse + }); + + const result = await new SearchAndGeocodeTool({ httpRequest }).run({ + q: 'Starbucks' + // compact defaults to true + }); + + expect(result.isError).toBe(false); + + const compactResult = result.structuredContent as Record; + + // Should be compact (no attribution, flattened context) + expect(compactResult.attribution).toBeUndefined(); + expect(compactResult.features[0].properties.address).toBe('123 Main St'); + expect(compactResult.features[0].properties.street).toBe('Main Street'); + expect(compactResult.features[0].properties.postcode).toBe('10001'); + expect(compactResult.features[0].properties.place).toBe('New York'); + expect(compactResult.features[0].properties.region).toBe('New York'); + expect(compactResult.features[0].properties.country).toBe('United States'); + expect(compactResult.features[0].properties.coordinates).toEqual({ + longitude: -74.006, + latitude: 40.7128 + }); + // Should not have nested context object or verbose fields + expect(compactResult.features[0].properties.context).toBeUndefined(); + expect(compactResult.features[0].properties.mapbox_id).toBeUndefined(); + expect(compactResult.features[0].properties.external_ids).toBeUndefined(); + expect(compactResult.features[0].properties.metadata).toBeUndefined(); + // Should keep essential fields + expect(compactResult.features[0].properties.name).toBe('Starbucks'); + expect(compactResult.features[0].properties.poi_category).toEqual([ + 'coffee', + 'restaurant' + ]); + expect(compactResult.features[0].properties.brand).toBe('Starbucks'); + expect(compactResult.features[0].properties.maki).toBe('cafe'); + }); + + it('returns verbose JSON when compact is false', async () => { + const mockResponse = { + type: 'FeatureCollection', + attribution: 'Some attribution text', + features: [ + { + type: 'Feature', + properties: { + name: 'Starbucks', + full_address: '123 Main St, New York, NY 10001' + }, + geometry: { + type: 'Point', + coordinates: [-74.006, 40.7128] + } + } + ] + }; + + const { httpRequest } = setupHttpRequest({ + json: async () => mockResponse + }); + + const result = await new SearchAndGeocodeTool({ httpRequest }).run({ + q: 'Starbucks', + compact: false // Use verbose format to match exact mockResponse + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent).toEqual(mockResponse); + }); });