@mfukushim/map-traveler-mcp
Version:
Virtual traveler library for MCP
218 lines (216 loc) • 11.5 kB
JavaScript
/*! map-traveler-mcp | MIT License | https://github.com/mfukushim/map-traveler-mcp */
import { createRequire as _createRequire } from "module";
const __require = _createRequire(import.meta.url);
import { Effect, Option, Schedule } from "effect";
import dayjs from "dayjs";
const timezone = __require("dayjs/plugin/timezone");
import { MapService } from "./MapService.js";
import { __pwd, DbService, env } from "./DbService.js";
import { McpLogService } from "./McpLogService.js";
import * as path from "node:path";
import * as fs from "node:fs";
import { bs_handle, bs_id, bs_pass } from "./EnvUtils.js";
dayjs.extend(timezone);
export class StoryService extends Effect.Service()("traveler/StoryService", {
accessors: true,
effect: Effect.gen(function* () {
const getNearlyPoliticalParse = (places, id) => {
const n = (places).filter(value => value.id === id).flatMap(d => {
if (Option.isSome(d.addressComponents)) {
const addressComponents = d.addressComponents.value;
const country = addressComponents.find(f => f.types[0] === 'country');
const townName = addressComponents.find(f => f.types[0] === 'sublocality_level_2') ||
addressComponents.find(f => f.types[0] === 'locality');
return [
{
address: Option.fromNullable(d.formattedAddress),
country: Option.fromNullable(country?.shortText),
townName: Option.fromNullable(townName?.longText)
}
];
}
return [];
}).shift();
return n ? n : { country: Option.none(), townName: Option.none(), address: Option.none() };
};
const getNearlyParse = (places) => {
return places.filter(value => Option.isSome(value.photos)).map(d => {
return {
name: d.displayName.text,
types: Option.isSome(d.primaryType) ? [d.primaryType.value] : [],
id: d.id
};
});
};
const getPhotoReferences = (places, id) => {
return (places).filter(value => value.id === id).map(result => {
const filter = Option.getOrElse(result.photos, () => []).map(a => {
return {
photoRef: a.name || '',
author: a?.authorAttributions ? a?.authorAttributions[0]?.displayName || '' : ''
};
});
const t = Option.getOrElse(result.primaryType, () => '');
return {
name: result.displayName.text,
types: t ? [t] : Option.isSome(result.types) ? result.types.value : [result.displayName.text],
photoReference: filter
};
});
};
function placesToFacilities(a) {
const buildings = getNearlyParse(a);
if (buildings.length === 0) {
return Effect.succeed({
townName: Option.none(),
address: Option.none(),
country: Option.none(),
facilities: [],
photoReferences: [],
});
}
const selBuilding = buildings[Math.floor(Math.random() * buildings.length)];
const political = getNearlyPoliticalParse(a, selBuilding.id);
const photoReferences = getPhotoReferences(a, selBuilding.id);
const maxLocationNum = 4;
const outBuildings = buildings.slice(0, maxLocationNum);
const buildingProperties = outBuildings.flatMap(value => value.types);
buildingProperties.push('|');
return Effect.succeed({
townName: political.townName,
address: political.address,
country: political.country,
facilities: outBuildings,
photoReferences: photoReferences,
});
}
function getNearbyFacilities(currentLoc) {
return Effect.gen(function* () {
let retry = 3;
return yield* Effect.async((resume) => resume(Effect.succeed(--retry))).pipe(Effect.tap(a => McpLogService.logTrace(`getNearbyFacilities retry:`, a)), Effect.andThen(a => MapService.getNearly(currentLoc.lat, currentLoc.lng, a === 2 ? 200 : a === 1 ? 1000 : 3000)), Effect.andThen(a => a.kind === 'places' ? placesToFacilities(a.places) : Effect.fail(new Error('no nearly'))), Effect.tap(a => McpLogService.logTrace(`getNearbyFacilities:`, a)), Effect.tapError(e => McpLogService.logTrace(`getNearbyFacilities error:${e}`)), Effect.retry(Schedule.recurs(2).pipe(Schedule.intersect(Schedule.spaced("3 seconds")))), Effect.orElse(() => placesToFacilities([])));
});
}
const tips = () => {
const textList = [];
const imagePathList = [];
if (env.isPractice) {
textList.push('Currently in practice mode. You can only go to fixed locations.' +
' To switch to normal mode, you need to obtain and set a Google Map API key.' +
' key for detail: https://developers.google.com/maps/documentation/streetview/get-api-key ' +
' Need Credentials: [Street View Static API],[Places API (New)],[Time Zone API],[Directions API]' +
' Please specify the API key in the configuration file(claude_desktop_config.json).' +
' And restart app. Claude Desktop App. Claude App may shrink into the taskbar, so please quit it completely.\n' +
`claude_desktop_config.json\n
\`\`\`
"env":{"GoogleMapApi_key":"xxxxxxx"}
\`\`\`
`);
}
else {
if (!env.dbFileExist) {
textList.push('Since the database is not currently set, the configuration information will be lost when you exit.' +
' Please specify the path of the saved database file in the configuration file(claude_desktop_config.json).' +
`claude_desktop_config.json\n
\`\`\`
"env":{"sqlite_path":"%USERPROFILE%/Desktop/traveler.sqlite"}
\`\`\`
`);
}
else {
if (!env.anyImageAiExist) {
textList.push('If you want to synthesize an avatar image, you will need a key for the image generation AI.' +
' Currently, PixAi and Stability AI\'s SDXL 1.0 API are supported.' +
' Please refer to the website of each company to obtain an API key.' +
' https://platform.stability.ai/docs/getting-started https://platform.stability.ai/account/keys ' +
' https://pixai.art/ https://platform.pixai.art/docs/getting-started/00---quickstart/ ' +
' Please specify the API key in the configuration file(claude_desktop_config.json).' +
`claude_desktop_config.json\n
\`\`\`
"env":{"pixAi_key":"xyzxyz"}
or
"env":{"sd_key":"xyzxyz"}
\`\`\`
`);
}
if (!env.rembgPath && !env.remBgUrl) {
textList.push('In order to synthesize avatar images, your PC must be running Python and install rembg.' +
` Please install Python and rembg on your PC using information from the Internet.\n
\`\`\`
"env":{"rembgPath":"(absolute path to rembg cli)"}
\`\`\`\n
or
\`\`\`
"env":{"rembgUrl":"(rembg service API url)"}
\`\`\`\n
To keep your pc environment clean, I recommend using a Python virtual environment such as venv.
`);
}
const bsEnable = bs_id && bs_pass && bs_handle;
if (!bsEnable) {
textList.push('Optional: Set up a Bluesky SNS account\n' +
'By setting your registered address, password, and handle for Bluesky SNS, you can post travel information on the SNS and obtain and interact with other people\'s travel information.\n' +
'Since articles may be posted automatically, we strongly recommend using a dedicated account.\n' +
`claude_desktop_config.json\n
\`\`\`
"env":{
"bs_id":"xxxx",
"bs_pass":"yyyyy",
"bs_handle":"zzzz"
}
\`\`\`
`);
}
if (!env.promptChanged && !env.fixedModelPrompt) {
textList.push('You can change the appearance of your avatar by directly telling the AI what you want it to look like, or by specifying a prompt to show its appearance with set_avatar_prompt.');
}
textList.push('You can play a tiny role play game using the scenario in carBattle.txt. Have fun!');
}
}
return Effect.succeed({
textList,
imagePathList
});
};
function getSettingResource(pathname) {
return Effect.gen(function* () {
yield* McpLogService.logTrace(`getSettingResource:`, pathname);
const files = yield* Effect.tryPromise(() => fs.promises.readdir(path.join(__pwd, `assets/scenario`)));
if (files.some(value => pathname === `/${value}`)) {
return yield* Effect.async((resume) => {
fs.readFile(path.join(__pwd, `assets/scenario${pathname}`), { encoding: "utf8" }, (err, data) => {
if (err)
resume(Effect.fail(err));
else
resume(Effect.succeed(data));
});
});
}
else if (pathname.includes("/setting.txt")) {
const langText = yield* DbService.getEnv('language').pipe(Effect.andThen(a => `Please speak to me in ${a}`), Effect.orElseSucceed(() => 'The language of the conversation should be the language the user speaks.'));
const destText = yield* Effect.gen(function* () {
const runStatus = yield* DbService.getRecentRunStatus();
if (runStatus.status !== 'stop' && runStatus.destination) {
return `Current destination is ${runStatus.destination}`;
}
return yield* DbService.getEnv('destination').pipe(Effect.andThen(a => `Current destination is ${a}`), Effect.orElseSucceed(() => 'The destination is not decided.'));
});
return [langText, destText].join('\n');
}
else if (pathname.includes("/credit.txt")) {
return yield* DbService.getVersion().pipe(Effect.andThen(a => `map-traveler.mcp version:${a} https://akibakokoubou.jp/ `));
}
else {
return yield* Effect.fail(new Error(`resource not found`));
}
});
}
return {
tips,
placesToFacilities,
getNearbyFacilities,
getSettingResource
};
}),
}) {
}
export const StoryServiceLive = StoryService.Default;