@mfukushim/map-traveler-mcp
Version:
Virtual traveler library for MCP
327 lines (326 loc) • 17.1 kB
JavaScript
/*! map-traveler-mcp | MIT License | https://github.com/mfukushim/map-traveler-mcp */
import * as geolib from "geolib";
import { Effect, Schema, Option, Schedule } from "effect";
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "@effect/platform";
import dayjs from "dayjs";
import * as querystring from "querystring";
import { Jimp } from "jimp";
import { McpLogService, McpLogServiceLive } from "./McpLogService.js";
import { AnswerError } from "./mapTraveler.js";
import { env } from "./DbService.js";
import { GoogleMapApi_key } from "./EnvUtils.js";
export class MapDef {
static GmPlaceSchema = Schema.Struct({
id: Schema.String,
types: Schema.OptionFromUndefinedOr(Schema.Array(Schema.String)),
formattedAddress: Schema.String,
location: Schema.Struct({
latitude: Schema.Number,
longitude: Schema.Number,
}),
displayName: Schema.Struct({
text: Schema.String,
languageCode: Schema.UndefinedOr(Schema.String)
}),
primaryTypeDisplayName: Schema.OptionFromUndefinedOr(Schema.Struct({
text: Schema.String,
languageCode: Schema.String
})),
primaryType: Schema.OptionFromUndefinedOr(Schema.String),
photos: Schema.OptionFromUndefinedOr(Schema.Array(Schema.Struct({
name: Schema.String,
authorAttributions: Schema.Array(Schema.Struct({
displayName: Schema.String,
photoUri: Schema.String,
}))
}))),
addressComponents: Schema.OptionFromUndefinedOr(Schema.Array(Schema.Struct({
shortText: Schema.String,
longText: Schema.String,
types: Schema.Array(Schema.String),
})))
});
static GmPlacesSchema = Schema.Array(MapDef.GmPlaceSchema);
static GmTextSearchSchema = Schema.Struct({
places: MapDef.GmPlacesSchema
});
static GmStepSchema = Schema.Struct({
html_instructions: Schema.String,
distance: Schema.Struct({
text: Schema.String,
value: Schema.Number,
}),
duration: Schema.Struct({
text: Schema.String,
value: Schema.Number,
}),
start_location: Schema.Struct({
lat: Schema.Number,
lng: Schema.Number
}),
end_location: Schema.Struct({
lat: Schema.Number,
lng: Schema.Number
}),
maneuver: Schema.UndefinedOr(Schema.String),
travel_mode: Schema.String
});
static DirectionStepSchema = Schema.mutable(Schema.Struct({
...MapDef.GmStepSchema.fields,
pathNo: Schema.Number,
stepNo: Schema.Number,
isRelayPoint: Schema.Boolean,
start: Schema.Number,
end: Schema.Number,
}));
static GmLegSchema = Schema.Struct({
start_address: Schema.UndefinedOr(Schema.String),
end_address: Schema.UndefinedOr(Schema.String),
end_location: Schema.UndefinedOr(Schema.Struct({
lat: Schema.Number,
lng: Schema.Number,
})),
start_location: Schema.UndefinedOr(Schema.Struct({
lat: Schema.Number,
lng: Schema.Number,
})),
distance: Schema.Struct({
text: Schema.String,
value: Schema.Number,
}),
duration: Schema.Struct({
text: Schema.String,
value: Schema.Number,
}),
steps: Schema.NonEmptyArray(MapDef.GmStepSchema)
});
static LegSchema = Schema.Struct({
...MapDef.GmLegSchema.fields,
start_country: Schema.String,
end_country: Schema.String,
});
static GmRouteSchema = Schema.Struct({
summary: Schema.String,
legs: Schema.NonEmptyArray(MapDef.GmLegSchema)
});
static RouteSchema = Schema.Struct({
summary: Schema.String,
leg: MapDef.LegSchema,
});
static RouteArraySchema = Schema.Array(MapDef.RouteSchema);
static DirectionsSchema = Schema.Struct({
status: Schema.String,
routes: Schema.Array(MapDef.GmRouteSchema)
});
static ErrorSchema = Schema.Struct({
error: Schema.Struct({
code: Schema.Number,
message: Schema.String,
status: Schema.String,
})
});
static EmptySchema = Schema.Struct({});
}
export class MapService extends Effect.Service()("traveler/MapService", {
accessors: true,
effect: Effect.gen(function* () {
const key = GoogleMapApi_key || '';
const getDistance = (curLat, curLng, targetLat, targetLng) => {
return geolib.getDistance({ lat: curLat, lng: curLng }, { lat: targetLat, lng: targetLng });
};
const calcMultiPathRoute = (start, destList) => {
return Effect.forEach(destList, destElement => calcSingleRoute(start, destElement));
};
const calcSingleRoute = (start, dest) => {
return calcDomesticTravelRoute(start.lat, start.lng, dest.lat, dest.lng, start.country, dest.country);
};
function calcDomesticTravelRoute(depLat, depLng, destLat, destLng, depCountry, destCountry, method = "BICYCLING") {
if (env.isPractice) {
return Effect.fail(new Error('no key'));
}
return Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
return yield* client.get(env.mapApis.get('directions') || 'https://maps.googleapis.com/maps/api/directions/json', {
urlParams: {
origin: `${depLat},${depLng}`,
destination: `${destLat},${destLng}`,
mode: method,
key: key
}
}).pipe(Effect.retry({ times: 2 }), Effect.flatMap(a => HttpClientResponse.schemaBodyJson(Schema.Union(MapDef.DirectionsSchema.pipe(Schema.attachPropertySignature('kind', 'routes')), MapDef.ErrorSchema.pipe(Schema.attachPropertySignature('kind', 'error')), MapDef.EmptySchema.pipe(Schema.attachPropertySignature('kind', 'empty'))))(a)), Effect.scoped, Effect.tap(a => McpLogService.logTrace(`calcDomesticTravelRoute: ${JSON.stringify(a).slice(0, 10)}`)), Effect.tapError(e => McpLogService.logError(`calcDomesticTravelRoute error:${JSON.stringify(e)}`)), Effect.flatMap(a => {
if (a.kind === 'routes') {
if (a.status === 'OK' && a.routes.length > 0) {
return Effect.succeed({
summary: a.routes[0].summary,
leg: {
...a.routes[0].legs[0],
start_country: depCountry,
end_country: destCountry,
},
start_country: 'jp',
end_country: 'jp'
});
}
else if (a.status === 'REQUEST_DENIED') {
return Effect.fail(new AnswerError(`directions API request denied. Check Api setting.`));
}
}
else if (a.kind === 'error') {
return Effect.fail(new AnswerError(`A system error has occurred. ${a.error.message}`));
}
return Effect.fail(new AnswerError(`No suitable route was found`));
}));
}).pipe(Effect.provide(FetchHttpClient.layer));
}
function getTimezoneByLatLng(lat, lng) {
if (env.isPractice) {
return Effect.fail(new Error('no key'));
}
return Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
return yield* client.get(env.mapApis.get('timezone') || `https://maps.googleapis.com/maps/api/timezone/json`, {
urlParams: {
location: `${lat},${lng}`,
timestamp: `${dayjs().unix()}`,
key: key
}
}).pipe(Effect.retry(Schedule.recurs(1).pipe(Schedule.intersect(Schedule.spaced("5 seconds")))), Effect.flatMap(a => a.json), Effect.scoped, Effect.tap(a => McpLogService.logTrace(`getTimezoneByLatLng:${JSON.stringify(a)}`)), Effect.tapError(e => McpLogService.logError(`getTimezoneByLatLng error:${JSON.stringify(e)}`)), Effect.andThen(a => a), Effect.tap(a => (a.status !== 'OK' || !a.timeZoneId) && Effect.fail(new Error('getTimezoneByLatLng error'))), Effect.andThen(a => a.timeZoneId));
}).pipe(Effect.provide([FetchHttpClient.layer, McpLogServiceLive]));
}
const getCountry = (place) => {
const countryData = place.addressComponents.pipe(Option.andThen(a => Option.fromNullable(a.find(value => value.types.includes('country')))));
return Option.getOrElse(countryData, () => ({ shortText: 'JP' })).shortText;
};
function getMapLocation(address) {
if (env.isPractice) {
return Effect.fail(new Error('no key'));
}
return Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
return yield* HttpClientRequest.post(env.mapApis.get('places') || 'https://places.googleapis.com/v1/places:searchText').pipe(HttpClientRequest.setHeaders({
"Content-Type": "application/json",
"X-Goog-Api-Key": key,
'X-Goog-FieldMask': 'places.displayName,places.formattedAddress,places.addressComponents,places.location,places.photos,places.id'
}), HttpClientRequest.bodyJson({
textQuery: address
}), Effect.flatMap(client.execute), Effect.retry(Schedule.recurs(1).pipe(Schedule.intersect(Schedule.spaced("5 seconds")))), Effect.flatMap(a => a.text), Effect.tap(a => McpLogService.logTrace(`getMapLocation:${JSON.stringify(a).slice(0, 10)}`)), Effect.flatMap(a => Schema.decode(Schema.parseJson(Schema.Union(MapDef.GmTextSearchSchema.pipe(Schema.attachPropertySignature('kind', 'places')), MapDef.ErrorSchema.pipe(Schema.attachPropertySignature('kind', 'error')), MapDef.EmptySchema.pipe(Schema.attachPropertySignature('kind', 'empty')))))(a)), Effect.scoped, Effect.flatMap(adr => {
if (adr.kind === 'places') {
return Effect.succeed(Option.some({
status: "OK",
address: adr.places[0].formattedAddress,
country: getCountry(adr.places[0]),
lat: adr.places[0].location.latitude,
lng: adr.places[0].location.longitude
}));
}
else if (adr.kind === 'error') {
return Effect.fail(new AnswerError(`A system error has occurred. ${adr.error.message}`));
}
return Effect.succeed(Option.none());
}));
}).pipe(Effect.provide(FetchHttpClient.layer));
}
function getNearly(lat, lng, radius = 2000, findLandMark = false, additionalType = []) {
if (env.isPractice) {
return Effect.fail(new Error('no key'));
}
return Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
return yield* HttpClientRequest.post(env.mapApis.get('nearby') || 'https://places.googleapis.com/v1/places:searchNearby').pipe(HttpClientRequest.setHeaders({
"Content-Type": "application/json",
"X-Goog-Api-Key": key,
'X-Goog-FieldMask': 'places.id,places.displayName,places.primaryType,places.location,places.shortFormattedAddress,places.formattedAddress'
+ (findLandMark ? '' : ',places.addressComponents,places.photos')
}), HttpClientRequest.bodyJson({
maxResultCount: 6,
languageCode: "ja",
locationRestriction: {
circle: {
center: {
latitude: lat,
longitude: lng
},
radius: radius
}
},
includedTypes: findLandMark ? ["tourist_attraction", "museum", "park", "national_park", "historical_landmark", "aquarium", "zoo", "university", "library", "art_gallery"].concat(additionalType) : undefined
}), Effect.flatMap(client.execute), Effect.retry(Schedule.recurs(1).pipe(Schedule.intersect(Schedule.spaced("5 seconds")))), Effect.flatMap(a => HttpClientResponse.schemaBodyJson(Schema.Union(MapDef.GmTextSearchSchema.pipe(Schema.attachPropertySignature('kind', 'places')), MapDef.ErrorSchema.pipe(Schema.attachPropertySignature('kind', 'error')), MapDef.EmptySchema.pipe(Schema.attachPropertySignature('kind', 'empty'))))(a)), Effect.onError(cause => McpLogService.logError(`getNearly error:${JSON.stringify(cause)}`)), Effect.scoped);
}).pipe(Effect.provide(FetchHttpClient.layer));
}
function findStreetViewMeta(lat, lng, bearing, width, height) {
if (!key) {
return Effect.fail(new Error('no street view key'));
}
return Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
let result;
yield* Effect.iterate(5, {
while: a => a > 0,
body: b => {
const checkLat = lat + 0.05 * (Math.random() - 0.5);
const checkLng = lng + 0.04 * (Math.random() - 0.5);
return client.get(env.mapApis.get('svMeta') || `https://maps.googleapis.com/maps/api/streetview/metadata`, {
urlParams: {
size: `${width}x${height}`,
location: `${checkLat.toFixed(15)},${checkLng.toFixed(15)}`,
fov: 60,
heading: bearing.toFixed(1),
pitch: 0,
key: key,
return_error_code: true
}
}).pipe(Effect.retry(Schedule.recurs(1).pipe(Schedule.intersect(Schedule.spaced("5 seconds")))), Effect.flatMap(a => a.json), Effect.scoped, Effect.tap(a => McpLogService.logTrace(`findStreetViewMeta:${JSON.stringify(a)}`)), Effect.tapError(e => McpLogService.logError(`findStreetViewMeta error:${JSON.stringify(e)}`)), Effect.andThen(a => {
if (a.status === 'OK') {
result = { lat: checkLat, lng: checkLng };
return 0;
}
return b - 1;
}));
}
});
return result ? result : yield* Effect.fail(new Error('no StreetView'));
});
}
async function imageUrlToBuffer(url, width, height) {
const jimp = await Jimp.read(url);
jimp.resize({ w: width, h: height });
return await jimp.getBuffer("image/jpeg");
}
function getStreetViewImage(lat, lng, bearing, width, height) {
if (env.isPractice) {
return Effect.fail(new Error('no key'));
}
const query = querystring.stringify({
size: `${width}x${height}`,
location: `${lat.toFixed(15)},${lng.toFixed(15)}`,
fov: 60,
heading: bearing.toFixed(1),
pitch: 0,
key: key,
return_error_code: true
});
const url = (env.mapApis.get('streetView') || 'https://maps.googleapis.com/maps/api/streetview') + '?' + query;
return Effect.tryPromise({
try: () => imageUrlToBuffer(url, width, height),
catch: error => new Error(`getStreetViewImage error:${error}`)
});
}
return {
getDistance,
calcSingleRoute,
calcMultiPathRoute,
getMapLocation,
getCountry,
getNearly,
findStreetViewMeta,
getStreetViewImage,
calcDomesticTravelRoute,
getTimezoneByLatLng,
};
}),
}) {
static getBearing(startLat, startLng, endLat, endLng) {
return geolib.getRhumbLineBearing({ lat: startLat, lng: startLng }, { lat: endLat, lng: endLng });
}
}
export const MapServiceLive = MapService.Default;