openrouteservice-mcp 1.0.0 → 1.0.2
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.
- package/README.md +22 -3
- package/dist/api.d.ts +7 -0
- package/dist/api.js +90 -0
- package/dist/gateway.d.ts +16 -0
- package/dist/gateway.js +85 -0
- package/dist/license.d.ts +8 -0
- package/dist/server.js +69 -6
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,10 +4,29 @@ Query OpenRouteService (free key; set OPENROUTESERVICE_API_KEY): geocode, revers
|
|
|
4
4
|
|
|
5
5
|
## Tools
|
|
6
6
|
|
|
7
|
-
- `geocode` —
|
|
8
|
-
- `reverse` —
|
|
9
|
-
- `directions` —
|
|
7
|
+
- `geocode` — Coordinates for a place name, e.g. Berlin.
|
|
8
|
+
- `reverse` — Nearest place name for latitude and longitude.
|
|
9
|
+
- `directions` — Driving route between two points, with distance and time.
|
|
10
10
|
|
|
11
11
|
## Source
|
|
12
12
|
|
|
13
13
|
[openrouteservice-mcp](https://github.com/mrfentmen/premium-mcps/tree/main/servers/openrouteservice-mcp)
|
|
14
|
+
|
|
15
|
+
<!-- paywall -->
|
|
16
|
+
## Premium tools
|
|
17
|
+
|
|
18
|
+
These tools need a licence key:
|
|
19
|
+
|
|
20
|
+
* `osrm_route_steps`
|
|
21
|
+
* `valhalla_mode_compare`
|
|
22
|
+
|
|
23
|
+
The tool bodies run on our gateway, not inside this package, so the key is what buys access. Buy one at https://buy.polar.sh/polar_cl_mrO7rS5LZxpqM7oAFFEA10i9121J240U40IIj4JRZO4 and set it in your MCP client config:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
"env": { "MCP_LICENSE_KEY": "polar_..." }
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The gateway checks the key against Polar and gives every key 1,000 free premium
|
|
30
|
+
calls a month before per-call billing starts. Every other tool on this server
|
|
31
|
+
stays free.
|
|
32
|
+
<!-- /paywall -->
|
package/dist/api.d.ts
CHANGED
|
@@ -5,3 +5,10 @@ export declare function errorMessage(e: unknown): string;
|
|
|
5
5
|
export declare function geocode(query: string, limit?: string): Promise<string>;
|
|
6
6
|
export declare function reverse(lat: string, lon: string): Promise<string>;
|
|
7
7
|
export declare function directions(fromLon: string, fromLat: string, toLon: string, toLat: string): Promise<string>;
|
|
8
|
+
/** Geocode against OpenStreetMap itself, so you can see how the answer moves between geocoders. */
|
|
9
|
+
export declare function nominatimLookup(args: {
|
|
10
|
+
query?: string;
|
|
11
|
+
lat?: number;
|
|
12
|
+
lon?: number;
|
|
13
|
+
limit?: number;
|
|
14
|
+
}): Promise<string>;
|
package/dist/api.js
CHANGED
|
@@ -57,3 +57,93 @@ export async function directions(fromLon, fromLat, toLon, toLat) {
|
|
|
57
57
|
throw new OrsError(`HTTP ${res.status}: ${text.slice(0, 300)}`);
|
|
58
58
|
return text.slice(0, 8000);
|
|
59
59
|
}
|
|
60
|
+
// ---- muxG tools:
|
|
61
|
+
const ORS_NOMINATIM = "https://nominatim.openstreetmap.org";
|
|
62
|
+
const ORS_OSRM = "https://router.project-osrm.org";
|
|
63
|
+
const ORS_VALHALLA = "https://valhalla1.openstreetmap.de";
|
|
64
|
+
const ORS_UA = "awesome-mcps/1.0 (mcp routing)";
|
|
65
|
+
const ORS_LIST_CAP = 8;
|
|
66
|
+
async function orsFetch(url, init = {}) {
|
|
67
|
+
const res = await fetch(url, {
|
|
68
|
+
...init,
|
|
69
|
+
headers: { "User-Agent": ORS_UA, Accept: "application/json", ...(init.headers ?? {}) },
|
|
70
|
+
signal: AbortSignal.timeout(30000),
|
|
71
|
+
});
|
|
72
|
+
if (res.status === 429)
|
|
73
|
+
throw new OrsError("OpenStreetMap Nominatim is rate limiting this IP. Its public instance allows at most one request a second, so wait a moment and try again.");
|
|
74
|
+
if (res.status === 404)
|
|
75
|
+
throw new OrsError("The routing backend has no answer for that point.");
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
const body = (await res.text()).slice(0, 200);
|
|
78
|
+
throw new OrsError(`Returned ${res.status}: ${body}`);
|
|
79
|
+
}
|
|
80
|
+
return (await res.json());
|
|
81
|
+
}
|
|
82
|
+
function orsBound(value, min, max, fallback) {
|
|
83
|
+
const n = Number(value);
|
|
84
|
+
if (!Number.isFinite(n))
|
|
85
|
+
return fallback;
|
|
86
|
+
return Math.max(min, Math.min(Math.floor(n), max));
|
|
87
|
+
}
|
|
88
|
+
function orsMetres(n) {
|
|
89
|
+
return n >= 1000 ? `${(n / 1000).toFixed(2)} km` : `${Math.round(n)} m`;
|
|
90
|
+
}
|
|
91
|
+
function orsDuration(seconds) {
|
|
92
|
+
const m = Math.round(seconds / 60);
|
|
93
|
+
if (m < 60)
|
|
94
|
+
return `${m} min`;
|
|
95
|
+
return `${Math.floor(m / 60)} h ${m % 60} min`;
|
|
96
|
+
}
|
|
97
|
+
/** Geocode against OpenStreetMap itself, so you can see how the answer moves between geocoders. */
|
|
98
|
+
export async function nominatimLookup(args) {
|
|
99
|
+
const limit = orsBound(args?.limit, 1, ORS_LIST_CAP, 5);
|
|
100
|
+
const hasCoords = args?.lat !== undefined && args.lon !== undefined;
|
|
101
|
+
const hasQuery = String(args?.query ?? "").trim().length > 0;
|
|
102
|
+
if (!hasCoords && !hasQuery)
|
|
103
|
+
throw new OrsError("Give a query to look up, or a lat and lon to reverse geocode.");
|
|
104
|
+
let url;
|
|
105
|
+
let direction;
|
|
106
|
+
if (hasCoords) {
|
|
107
|
+
const lat = Number(args.lat);
|
|
108
|
+
const lon = Number(args.lon);
|
|
109
|
+
if (!Number.isFinite(lat) || Math.abs(lat) > 90)
|
|
110
|
+
throw new OrsError(`"${args.lat}" is not a latitude between -90 and 90.`);
|
|
111
|
+
if (!Number.isFinite(lon) || Math.abs(lon) > 180)
|
|
112
|
+
throw new OrsError(`"${args.lon}" is not a longitude between -180 and 180.`);
|
|
113
|
+
const zoom = orsBound(args.limit, 1, 18, 18);
|
|
114
|
+
url = `${ORS_NOMINATIM}/reverse?format=jsonv2&lat=${lat}&lon=${lon}&zoom=${zoom}&addressdetails=1&namedetails=1`;
|
|
115
|
+
direction = `Reverse geocode of ${lat}, ${lon} at zoom ${zoom}`;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
url = `${ORS_NOMINATIM}/search?format=jsonv2&addressdetails=1&namedetails=1&limit=${limit}&q=${encodeURIComponent(String(args.query).trim())}`;
|
|
119
|
+
direction = `Forward geocode of "${String(args.query).trim()}" in OpenStreetMap`;
|
|
120
|
+
}
|
|
121
|
+
const raw = await orsFetch(url);
|
|
122
|
+
const rows = Array.isArray(raw) ? raw : [raw];
|
|
123
|
+
if (!rows.length || !rows[0]?.display_name) {
|
|
124
|
+
return [
|
|
125
|
+
`${direction}`,
|
|
126
|
+
"OpenStreetMap returned nothing.",
|
|
127
|
+
"No match usually means the name is missing from OSM, is spelled differently, or is a building rather than an address. Try a coarser place name, or the postcode.",
|
|
128
|
+
].join("\n");
|
|
129
|
+
}
|
|
130
|
+
return [
|
|
131
|
+
direction,
|
|
132
|
+
`${rows.length} result(s) from Nominatim, OpenStreetMap's own geocoder`,
|
|
133
|
+
"",
|
|
134
|
+
...rows.map((r, i) => {
|
|
135
|
+
const addr = r.address ?? {};
|
|
136
|
+
const named = Object.entries(addr).filter(([k]) => !["country_code", "country", "state", "county", "municipality", "city_district", "ISO3166-2-lvl4"].includes(k)).slice(0, 6);
|
|
137
|
+
return [
|
|
138
|
+
`${i + 1}. ${r.name ?? "(no name)"} — ${r.display_name}`,
|
|
139
|
+
` ${r.lat}, ${r.lon} | OSM ${r.osm_type ?? "?"}/${r.osm_id ?? "?"} | place id ${r.place_id ?? "not reported"}`,
|
|
140
|
+
` classified as ${r.category ?? "not reported"}/${r.type ?? "not reported"} (address type ${r.addresstype ?? "not reported"})`,
|
|
141
|
+
r.importance !== undefined ? ` importance ${Number(r.importance).toFixed(4)}` : "",
|
|
142
|
+
named.length ? ` address parts: ${named.map(([k, v]) => `${k}=${v}`).join(", ")}` : " address parts: not reported",
|
|
143
|
+
r.boundingbox ? ` bounding box: ${r.boundingbox.join(", ")}` : "",
|
|
144
|
+
].filter(Boolean).join("\n");
|
|
145
|
+
}),
|
|
146
|
+
"",
|
|
147
|
+
"This is a different geocoder from the one this server's other tools use, and they will disagree: Nominatim ranks by OpenStreetMap importance, while a commercial geocoder ranks by its own address database. A pin that moves between them is usually a boundary or a duplicated feature, not a bug in either.",
|
|
148
|
+
].filter(Boolean).join("\n");
|
|
149
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin client for the premium gateway.
|
|
3
|
+
*
|
|
4
|
+
* The premium tools do not run in this package any more. Their bodies live on
|
|
5
|
+
* the gateway, which checks a Polar licence key before it does any work, so
|
|
6
|
+
* deleting anything here buys nothing: the answer only exists behind the gate.
|
|
7
|
+
*
|
|
8
|
+
* Set `MCP_LICENSE_KEY` to a Polar licence key. `MCP_GATEWAY_URL` overrides the
|
|
9
|
+
* endpoint, which is only useful for testing.
|
|
10
|
+
*/
|
|
11
|
+
/** Raised when a premium call cannot be completed. `message` is safe to show. */
|
|
12
|
+
export declare class GatewayError extends Error {
|
|
13
|
+
readonly refused: boolean;
|
|
14
|
+
constructor(message: string, refused: boolean);
|
|
15
|
+
}
|
|
16
|
+
export declare function premiumTool(tool: string, args: Record<string, unknown>): Promise<string>;
|
package/dist/gateway.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin client for the premium gateway.
|
|
3
|
+
*
|
|
4
|
+
* The premium tools do not run in this package any more. Their bodies live on
|
|
5
|
+
* the gateway, which checks a Polar licence key before it does any work, so
|
|
6
|
+
* deleting anything here buys nothing: the answer only exists behind the gate.
|
|
7
|
+
*
|
|
8
|
+
* Set `MCP_LICENSE_KEY` to a Polar licence key. `MCP_GATEWAY_URL` overrides the
|
|
9
|
+
* endpoint, which is only useful for testing.
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_GATEWAY_URL = 'https://mcp-gateway.contactae2000.workers.dev/mcp';
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 25000;
|
|
13
|
+
const USER_AGENT = 'mrfentmen-mcp/1.0';
|
|
14
|
+
/** Raised when a premium call cannot be completed. `message` is safe to show. */
|
|
15
|
+
export class GatewayError extends Error {
|
|
16
|
+
refused;
|
|
17
|
+
constructor(message, refused) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.refused = refused;
|
|
20
|
+
this.name = 'GatewayError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function premiumTool(tool, args) {
|
|
24
|
+
const key = (process.env.MCP_LICENSE_KEY ?? '').trim();
|
|
25
|
+
if (!key) {
|
|
26
|
+
throw new GatewayError(`"${tool}" is a premium tool and no licence key is set. Set MCP_LICENSE_KEY in your MCP client config.`, true);
|
|
27
|
+
}
|
|
28
|
+
const url = (process.env.MCP_GATEWAY_URL ?? DEFAULT_GATEWAY_URL).trim();
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
|
31
|
+
let response;
|
|
32
|
+
try {
|
|
33
|
+
response = await fetch(url, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: {
|
|
36
|
+
'content-type': 'application/json',
|
|
37
|
+
accept: 'application/json',
|
|
38
|
+
'user-agent': USER_AGENT,
|
|
39
|
+
authorization: `Bearer ${key}`,
|
|
40
|
+
},
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
jsonrpc: '2.0',
|
|
43
|
+
id: 1,
|
|
44
|
+
method: 'tools/call',
|
|
45
|
+
params: { name: tool, arguments: args },
|
|
46
|
+
}),
|
|
47
|
+
signal: controller.signal,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new GatewayError(`"${tool}" could not reach the licence gateway. Check your connection and try again.`, false);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
}
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
throw new GatewayError(`"${tool}" could not reach the licence gateway (HTTP ${response.status}).`, false);
|
|
58
|
+
}
|
|
59
|
+
let body;
|
|
60
|
+
try {
|
|
61
|
+
body = (await response.json());
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new GatewayError(`"${tool}" got an unreadable answer from the licence gateway.`, false);
|
|
65
|
+
}
|
|
66
|
+
if (body.error) {
|
|
67
|
+
throw new GatewayError(`"${tool}" was refused by the licence gateway: ${body.error.message ?? 'unknown error'}`, false);
|
|
68
|
+
}
|
|
69
|
+
const result = body.result ?? {};
|
|
70
|
+
const text = (result.content ?? [])
|
|
71
|
+
.map((part) => part.text ?? '')
|
|
72
|
+
.join('\n')
|
|
73
|
+
.trim();
|
|
74
|
+
if (result.isError) {
|
|
75
|
+
// The gateway marks a licence refusal structurally, so a missing or rejected
|
|
76
|
+
// key is told apart from a tool that failed after the gate let it through.
|
|
77
|
+
if (result.structuredContent?.licenceRefusal) {
|
|
78
|
+
throw new GatewayError(text || `"${tool}" needs a valid licence key.`, true);
|
|
79
|
+
}
|
|
80
|
+
throw new GatewayError(text || `"${tool}" failed on the licence gateway.`, false);
|
|
81
|
+
}
|
|
82
|
+
if (!text)
|
|
83
|
+
throw new GatewayError(`"${tool}" returned no data.`, false);
|
|
84
|
+
return text;
|
|
85
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns null when the caller may run the tool, or a short message telling
|
|
3
|
+
* them how to get a key.
|
|
4
|
+
*
|
|
5
|
+
* A good key is remembered for an hour, so a busy session does not hit the
|
|
6
|
+
* license server on every call and keeps working through a brief outage.
|
|
7
|
+
*/
|
|
8
|
+
export declare function premiumRequired(tool: string): Promise<string | null>;
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { errorMessage, geocode, reverse, directions, } from "./api.js";
|
|
3
|
+
import { errorMessage, geocode, reverse, directions, nominatimLookup } from "./api.js";
|
|
4
|
+
import { premiumTool } from "./gateway.js";
|
|
4
5
|
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
5
6
|
const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
6
7
|
const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
|
|
@@ -11,7 +12,7 @@ export function createServer() {
|
|
|
11
12
|
});
|
|
12
13
|
server.registerTool("geocode", {
|
|
13
14
|
title: "Geocode",
|
|
14
|
-
description: "
|
|
15
|
+
description: "Coordinates for a place name, e.g. Berlin.",
|
|
15
16
|
inputSchema: z.object({
|
|
16
17
|
query: z.string().describe("Place name, e.g. Berlin."),
|
|
17
18
|
limit: z.string().describe("Max results (default 5, max 20).").optional()
|
|
@@ -27,10 +28,10 @@ export function createServer() {
|
|
|
27
28
|
});
|
|
28
29
|
server.registerTool("reverse", {
|
|
29
30
|
title: "Reverse geocode",
|
|
30
|
-
description: "
|
|
31
|
+
description: "Nearest place name for latitude and longitude.",
|
|
31
32
|
inputSchema: z.object({
|
|
32
|
-
lat: z.string().describe("Latitude."),
|
|
33
|
-
lon: z.string().describe("Longitude.")
|
|
33
|
+
lat: z.string().describe("Latitude, e.g. 48.86"),
|
|
34
|
+
lon: z.string().describe("Longitude, e.g. 2.35")
|
|
34
35
|
}),
|
|
35
36
|
annotations: READ_ONLY,
|
|
36
37
|
}, async ({ lat, lon }) => {
|
|
@@ -43,7 +44,7 @@ export function createServer() {
|
|
|
43
44
|
});
|
|
44
45
|
server.registerTool("directions", {
|
|
45
46
|
title: "Driving directions",
|
|
46
|
-
description: "
|
|
47
|
+
description: "Driving route between two points, with distance and time.",
|
|
47
48
|
inputSchema: z.object({
|
|
48
49
|
from_lon: z.string().describe("Origin longitude."),
|
|
49
50
|
from_lat: z.string().describe("Origin latitude."),
|
|
@@ -59,5 +60,67 @@ export function createServer() {
|
|
|
59
60
|
return textError(errorMessage(e));
|
|
60
61
|
}
|
|
61
62
|
});
|
|
63
|
+
server.registerTool("nominatim_lookup", {
|
|
64
|
+
title: "Nominatim lookup",
|
|
65
|
+
description: "Geocode against OpenStreetMap's own Nominatim instead of the commercial geocoder, forward or reverse: display name, coordinates, OSM object type and id, place id, how Nominatim classifies the feature, importance score, address parts and bounding box, so you can see how the answer differs from another geocoder.",
|
|
66
|
+
inputSchema: z.object({
|
|
67
|
+
query: z.string().describe("What to look up, e.g. 'Eiffel Tower' or a postcode.").optional(),
|
|
68
|
+
lat: z.number().describe("Latitude, for a reverse lookup.").optional(),
|
|
69
|
+
lon: z.number().describe("Longitude, for a reverse lookup.").optional(),
|
|
70
|
+
limit: z.number().describe("Results to show, 1-8. For a reverse lookup this is the zoom level.").optional()
|
|
71
|
+
}),
|
|
72
|
+
annotations: READ_ONLY,
|
|
73
|
+
}, async (args) => {
|
|
74
|
+
try {
|
|
75
|
+
return text(await nominatimLookup(args));
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
return textError(errorMessage(e));
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
server.registerTool("osrm_route_steps", {
|
|
82
|
+
title: "OSRM route steps",
|
|
83
|
+
description: "A road route from OSRM over OpenStreetMap data with turn-by-turn instructions and alternatives: distance, estimated time, the named roads used, and each manoeuvre with its modifier, type and instruction. A different router on different data from this server's directions tool.",
|
|
84
|
+
inputSchema: z.object({
|
|
85
|
+
from_lat: z.number().describe("Origin latitude."),
|
|
86
|
+
from_lon: z.number().describe("Origin longitude."),
|
|
87
|
+
to_lat: z.number().describe("Destination latitude."),
|
|
88
|
+
to_lon: z.number().describe("Destination longitude."),
|
|
89
|
+
alternatives: z.boolean().describe("Ask for alternative routes, default true.").optional()
|
|
90
|
+
}),
|
|
91
|
+
annotations: READ_ONLY,
|
|
92
|
+
}, async (args) => {
|
|
93
|
+
// ---- paywall: osrm_route_steps ----
|
|
94
|
+
// The body runs on the gateway, behind the licence gate.
|
|
95
|
+
// ---- paywall: end ----
|
|
96
|
+
try {
|
|
97
|
+
return text(await premiumTool("mcp_openrouteservice_osrm_route_steps", args));
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
return textError(errorMessage(e));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
server.registerTool("valhalla_mode_compare", {
|
|
104
|
+
title: "Valhalla mode compare",
|
|
105
|
+
description: "The same origin and destination costed by several Valhalla modes (auto, bicycle, pedestrian, multimodal, truck, taxi) side by side: distance, time, cost, tolls, ferries, motorway use, energy estimate and the opening manoeuvres, so the right mode for a journey can be compared rather than assumed.",
|
|
106
|
+
inputSchema: z.object({
|
|
107
|
+
from_lat: z.number().describe("Origin latitude."),
|
|
108
|
+
from_lon: z.number().describe("Origin longitude."),
|
|
109
|
+
to_lat: z.number().describe("Destination latitude."),
|
|
110
|
+
to_lon: z.number().describe("Destination longitude."),
|
|
111
|
+
modes: z.string().describe("Comma separated costings, default 'auto,bicycle,pedestrian'.").optional()
|
|
112
|
+
}),
|
|
113
|
+
annotations: READ_ONLY,
|
|
114
|
+
}, async (args) => {
|
|
115
|
+
// ---- paywall: valhalla_mode_compare ----
|
|
116
|
+
// The body runs on the gateway, behind the licence gate.
|
|
117
|
+
// ---- paywall: end ----
|
|
118
|
+
try {
|
|
119
|
+
return text(await premiumTool("mcp_openrouteservice_valhalla_mode_compare", args));
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
return textError(errorMessage(e));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
62
125
|
return server;
|
|
63
126
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openrouteservice-mcp",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "OpenRouteService
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Query OpenRouteService (free key; set OPENROUTESERVICE_API_KEY): geocode, reverse geocode, driving directions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"mcpName": "io.github.mrfentmen/openrouteservice-mcp",
|
|
7
7
|
"repository": {
|