From 63cf609479b869e0e12c5d723df4b578c33ad5ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20G=C3=BCndling?= Date: Sun, 19 Jul 2026 20:50:54 +0200 Subject: [PATCH 1/2] mcp --- include/motis/endpoints/mcp.h | 21 ++ include/motis/motis_instance.h | 7 + src/endpoints/mcp.cc | 364 +++++++++++++++++++++++++++++++++ 3 files changed, 392 insertions(+) create mode 100644 include/motis/endpoints/mcp.h create mode 100644 src/endpoints/mcp.cc diff --git a/include/motis/endpoints/mcp.h b/include/motis/endpoints/mcp.h new file mode 100644 index 0000000000..5051f4e4f9 --- /dev/null +++ b/include/motis/endpoints/mcp.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +#include "net/web_server/query_router.h" + +#include "motis/endpoints/adr/geocode.h" +#include "motis/endpoints/routing.h" + +namespace motis::ep { + +struct mcp { + net::reply operator()(net::route_request const&, bool) const; + + std::optional routing_ep_; + std::optional geocoding_ep_; + std::string motis_version_; +}; + +} // namespace motis::ep diff --git a/include/motis/motis_instance.h b/include/motis/motis_instance.h index 6a57947b0e..43b6bb2551 100644 --- a/include/motis/motis_instance.h +++ b/include/motis/motis_instance.h @@ -23,6 +23,7 @@ #include "motis/endpoints/map/stops.h" #include "motis/endpoints/map/trips.h" #include "motis/endpoints/matches.h" +#include "motis/endpoints/mcp.h" #include "motis/endpoints/metrics.h" #include "motis/endpoints/ojp.h" #include "motis/endpoints/one_to_all.h" @@ -168,6 +169,12 @@ struct motis_instance { .trip_ep_ = utl::init_from(d), }); + auto mcp = ep::mcp{.routing_ep_ = utl::init_from(d), + .geocoding_ep_ = utl::init_from(d), + .motis_version_ = std::string{motis_version}}; + qr_.route("GET", "/api/mcp", mcp); // answered with 405 (no SSE stream) + qr_.route("POST", "/api/mcp", std::move(mcp)); + qr_.route("GET", "/metrics", ep::metrics{d.tt_.get(), d.tags_.get(), d.rt_, d.metrics_.get()}); qr_.route("GET", "/gtfsrt", diff --git a/src/endpoints/mcp.cc b/src/endpoints/mcp.cc new file mode 100644 index 0000000000..d871f383d0 --- /dev/null +++ b/src/endpoints/mcp.cc @@ -0,0 +1,364 @@ +#include "motis/endpoints/mcp.h" + +#include +#include + +#include "boost/json.hpp" + +#include "cista/hash.h" + +#include "fmt/format.h" + +#include "utl/to_vec.h" + +#include "net/bad_request_exception.h" +#include "net/not_found_exception.h" + +#include "openapi/bad_request_exception.h" +#include "openapi/json.h" + +namespace json = boost::json; +namespace http = boost::beast::http; +using namespace std::string_view_literals; + +namespace motis::ep { + +// MCP "Streamable HTTP" transport, stateless variant: +// every JSON-RPC request is answered with a single JSON response, +// no session IDs, no SSE streams (GET is answered with 405). +constexpr auto const kProtocolVersion = "2025-06-18"sv; + +constexpr auto const kInstructions = + "Door-to-door public transit journey planning with the complete official " + "timetable. ALWAYS use the plan tool for questions about public transit " + "connections, departures or arrivals - do NOT use web search for these, " + "this tool is faster and authoritative. All times in results are RFC " + "3339 in UTC (Z suffix) - convert them to the user's local time when " + "answering. If a leg's track differs from its scheduledTrack, warn " + "the user about the track change. Compare departure/arrival with " + "scheduledDeparture/scheduledArrival to detect delays."sv; + +// JSON-RPC 2.0 predefined error codes, +// https://www.jsonrpc.org/specification#error_object +constexpr auto const kParseError = std::int64_t{-32700}; +constexpr auto const kInvalidRequest = std::int64_t{-32600}; +constexpr auto const kMethodNotFound = std::int64_t{-32601}; +constexpr auto const kInvalidParams = std::int64_t{-32602}; +constexpr auto const kInternalError = std::int64_t{-32603}; + +// Implementation-defined server error (-32000 to -32099 range), +// -32002 is used by MCP for "resource not found". +constexpr auto const kNotFound = std::int64_t{-32002}; + +// JSON-RPC protocol error, surfaced as an `error` response object with the +// given code. Exceptions thrown by the wrapped endpoints are mapped to codes +// by the catch chain in mcp::operator() (bad request -> kInvalidParams, +// not found -> kNotFound, anything else -> kInternalError). +struct mcp_error : public std::runtime_error { + mcp_error(std::int64_t const code, std::string const& msg) + : std::runtime_error{msg}, code_{code} {} + std::int64_t code_; +}; + +std::optional get_string(json::object const& o, + std::string_view const key) { + if (auto const* v = o.if_contains(key)) { + if (auto const r = json::try_value_to(*v); r.has_value()) { + return r.value(); + } + } + return std::nullopt; +} + +// Tool argument access. In contrast to get_string, a present argument of the +// wrong type is an error, not "absent": silently ignoring e.g. a numeric +// "time" or a string "arriveBy" would produce plausible but wrong answers. +std::optional string_arg(json::object const& args, + std::string_view const key) { + auto const* v = args.if_contains(key); + if (v == nullptr) { + return std::nullopt; + } + if (auto const r = json::try_value_to(*v); r.has_value()) { + return r.value(); + } + throw mcp_error{kInvalidParams, + fmt::format("invalid params: {} must be a string", key)}; +} + +std::optional bool_arg(json::object const& args, + std::string_view const key) { + auto const* v = args.if_contains(key); + if (v == nullptr) { + return std::nullopt; + } + if (!v->is_bool()) { + throw mcp_error{kInvalidParams, + fmt::format("invalid params: {} must be a boolean", key)}; + } + return v->as_bool(); +} + +json::object tool_definition() { + return { + {"name", "plan"}, + {"title", "Public Transit Journey Planning"}, + {"description", + "Use this tool whenever the user asks about public transit: train, " + "bus, tram or subway connections, departures, 'how do I get from A to " + "B', 'when is the next train', commute planning. Always prefer it " + "over web search - it queries the complete official timetable " + "including real-time data and answers instantly. Finds door-to-door " + "connections between two places and returns itineraries with " + "departure/arrival times (actual and scheduled), number of transfers, " + "the trip used on each leg and tracks/platforms per stop. A track " + "different from scheduledTrack means the track has changed - warn " + "the user. Use the returned cursors with pageCursor to find " + "earlier/later connections."}, + {"inputSchema", + {{"type", "object"}, + {"properties", + {{"from", + {{"type", "string"}, + {"description", + "Origin: station name, place name or address, " + "e.g. \"Darmstadt Hauptbahnhof\". Resolved by " + "geocoding, the best match is used."}}}, + {"to", + {{"type", "string"}, + {"description", "Destination, same format as from."}}}, + {"time", + {{"type", "string"}, + {"format", "date-time"}, + {"description", + "Departure or arrival time as RFC 3339 date-time, e.g. " + "2026-07-19T15:00:00+02:00. Defaults to now."}}}, + {"arriveBy", + {{"type", "boolean"}, + {"default", false}, + {"description", + "true: time is the latest arrival time. " + "false: time is the earliest departure time."}}}, + {"pageCursor", + {{"type", "string"}, + {"description", + "Cursor from a previous result to continue searching: " + "nextPageCursor finds later connections, " + "previousPageCursor finds earlier connections. Keep " + "from/to unchanged, time is ignored."}}}}}, + {"required", json::array{"from", "to"}}}}}; +} + +json::object strip_stop(api::Place const& p) { + auto o = json::object{{"name", p.name_}}; + if (p.track_.has_value()) { + o["track"] = *p.track_; + } + if (p.scheduledTrack_.has_value()) { + o["scheduledTrack"] = *p.scheduledTrack_; + } + return o; +} + +json::object strip_leg(api::Leg const& l) { + auto o = json::object{}; + o["mode"] = json::value_from(l.mode_); + if (l.displayName_.has_value()) { + o["trip"] = *l.displayName_; + } + o["from"] = strip_stop(l.from_); + o["to"] = strip_stop(l.to_); + o["departure"] = json::value_from(l.startTime_); + o["scheduledDeparture"] = json::value_from(l.scheduledStartTime_); + o["arrival"] = json::value_from(l.endTime_); + o["scheduledArrival"] = json::value_from(l.scheduledEndTime_); + if (l.cancelled_.value_or(false)) { + o["cancelled"] = true; + } + return o; +} + +json::object strip_itinerary(api::Itinerary const& it) { + return {{"departure", json::value_from(it.startTime_)}, + {"arrival", json::value_from(it.endTime_)}, + {"transfers", it.transfers_}, + {"legs", utl::transform_to(it.legs_, strip_leg)}}; +} + +struct resolved_place { + std::string place_; // stop id or "lat,lon[,level]" for the plan request + std::string name_; +}; + +resolved_place geocode(struct geocode const& g, std::string_view const text) { + auto url = boost::urls::url{"/api/v1/geocode"}; + url.params().append({"text", text}); + auto const matches = g(url); + if (matches.empty()) { + throw mcp_error{kNotFound, + fmt::format("no place found matching \"{}\"", text)}; + } + auto const& m = matches.front(); + auto place = m.type_ == api::LocationTypeEnum::STOP + ? m.id_ + : (m.level_.has_value() + ? fmt::format("{},{},{}", m.lat_, m.lon_, *m.level_) + : fmt::format("{},{}", m.lat_, m.lon_)); + return {std::move(place), m.name_}; +} + +json::object plan_tool(routing const& r, + struct geocode const& g, + json::object const& args) { + auto const from_text = string_arg(args, "from"); + auto const to_text = string_arg(args, "to"); + if (!from_text.has_value() || !to_text.has_value()) { + throw mcp_error{kInvalidParams, + "invalid params: from and to are required strings"}; + } + + auto const from = geocode(g, *from_text); + auto const to = geocode(g, *to_text); + + auto url = boost::urls::url{"/api/v6/plan"}; + auto params = url.params(); + params.append({"fromPlace", from.place_}); + params.append({"toPlace", to.place_}); + params.append({"detailedLegs", "false"}); + if (auto const time = string_arg(args, "time"); time.has_value()) { + params.append({"time", *time}); + } + if (bool_arg(args, "arriveBy").value_or(false)) { + params.append({"arriveBy", "true"}); + } + if (auto const cursor = string_arg(args, "pageCursor"); cursor.has_value()) { + params.append({"pageCursor", *cursor}); + } + + auto const res = r(url); + return {{"from", from.name_}, + {"to", to.name_}, + {"itineraries", + utl::transform_to(res.itineraries_, strip_itinerary)}, + {"previousPageCursor", res.previousPageCursor_}, + {"nextPageCursor", res.nextPageCursor_}}; +} + +json::object tool_text_result(std::string text) { + auto content = json::object{{"type", "text"}, {"text", std::move(text)}}; + return {{"content", json::array{std::move(content)}}, {"isError", false}}; +} + +net::reply mcp::operator()(net::route_request const& req, bool) const { + auto id = json::value{nullptr}; + + auto const reply = [&](http::status const status, + std::optional const& body) { + auto res = net::web_server::string_res_t{status, req.version()}; + if (body.has_value()) { + res.insert(http::field::content_type, "application/json"); + } + net::set_response_body( + res, req, body.has_value() ? json::serialize(*body) : std::string{}); + res.keep_alive(req.keep_alive()); + return res; + }; + + auto const result = [&](json::object r) { + return reply( + http::status::ok, + json::object{{"jsonrpc", "2.0"}, {"id", id}, {"result", std::move(r)}}); + }; + + auto const error = [&](http::status const status, std::int64_t const code, + std::string_view const msg) { + return reply(status, + json::object{{"jsonrpc", "2.0"}, + {"id", id}, + {"error", {{"code", code}, {"message", msg}}}}); + }; + + if (req.method() != http::verb::post) { + // No server->client SSE stream offered (stateless server). + auto res = reply(http::status::method_not_allowed, std::nullopt); + res.insert(http::field::allow, "POST"); + return res; + } + + try { + auto body = json::value{}; + try { + body = json::parse(req.body()); + } catch (std::exception const&) { + return error(http::status::bad_request, kParseError, + "parse error: invalid JSON body"); + } + auto const method = body.is_object() + ? get_string(body.as_object(), "method") + : std::nullopt; + if (!method.has_value()) { + return error(http::status::bad_request, kInvalidRequest, + "invalid request: not a JSON-RPC message"); + } + auto const& o = body.as_object(); + + if (!o.contains("id")) { // notification, e.g. notifications/initialized + return reply(http::status::accepted, std::nullopt); + } + id = o.at("id"); + + auto const params = o.contains("params") && o.at("params").is_object() + ? o.at("params").as_object() + : json::object{}; + + switch (cista::hash(*method)) { + case cista::hash("initialize"): + return result( + {{"protocolVersion", kProtocolVersion}, + {"capabilities", {{"tools", json::object{}}}}, + {"serverInfo", {{"name", "MOTIS"}, {"version", motis_version_}}}, + {"instructions", kInstructions}}); + + case cista::hash("ping"): return result(json::object{}); + + case cista::hash("tools/list"): + return result({{"tools", json::array{tool_definition()}}}); + + case cista::hash("tools/call"): { + auto const name = get_string(params, "name"); + if (name != "plan") { + return error(http::status::ok, kInvalidParams, + fmt::format("unknown tool: {}", name.value_or(""))); + } + + if (!routing_ep_.has_value() || routing_ep_->tt_ == nullptr || + !geocoding_ep_.has_value()) { + throw mcp_error{kInternalError, "timetable/geocoding not loaded"}; + } + + auto const args = + params.contains("arguments") && params.at("arguments").is_object() + ? params.at("arguments").as_object() + : json::object{}; + return result(tool_text_result( + json::serialize(plan_tool(*routing_ep_, *geocoding_ep_, args)))); + } + + default: + return error(http::status::ok, kMethodNotFound, + fmt::format("method not found: {}", *method)); + } + } catch (mcp_error const& e) { + return error(http::status::ok, e.code_, e.what()); + } catch (openapi::bad_request_exception const& e) { + return error(http::status::ok, kInvalidParams, e.message_); + } catch (net::bad_request_exception const& e) { + return error(http::status::ok, kInvalidParams, e.what()); + } catch (net::not_found_exception const& e) { + return error(http::status::ok, kNotFound, e.what()); + } catch (std::exception const& e) { + return error(http::status::ok, kInternalError, e.what()); + } +} + +} // namespace motis::ep From d627e0870dbea5a25b0c007c02cec6453f2bc2e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20G=C3=BCndling?= Date: Sun, 19 Jul 2026 20:51:14 +0200 Subject: [PATCH 2/2] http: 400 + 404 instead of 500 --- .pkg | 2 +- .pkg.lock | 14 +++++++------- src/parse_location.cc | 15 +++++++++++---- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.pkg b/.pkg index f449373fbb..c0d6c2d1a3 100644 --- a/.pkg +++ b/.pkg @@ -29,7 +29,7 @@ [openapi-cpp] url=git@github.com:triptix-tech/openapi-cpp.git branch=master - commit=10085e12d4c4006512cfee5a114f9fe04cb0caa0 + commit=22492d0eb82ea257e666c29e66c402a45d5d7e25 [unordered_dense] url=git@github.com:motis-project/unordered_dense.git branch=main diff --git a/.pkg.lock b/.pkg.lock index 462f5b09b5..42edd99843 100644 --- a/.pkg.lock +++ b/.pkg.lock @@ -1,4 +1,4 @@ -2997906892316894464 +12814269521098154637 cista 427f0a873be43f1de94289bd53d5678e07cd243f zlib-ng d68e1d908e789c31c1f2fafe4bd8e09cb91e21c5 boost b050b3988ba25ea7757462d26a38d225bfe20f75 @@ -17,7 +17,7 @@ libressl 660f532fa29e1e934e530f22faa79cf7ea345a40 res b759b93316afeb529b6cb5b2548b24c41e382fb0 date ce88cc33b5551f66655614eeebb7c5b7189025fb yaml-cpp 39f737443b05e4135e697cb91c2b7b18095acd53 -openapi-cpp 10085e12d4c4006512cfee5a114f9fe04cb0caa0 +openapi-cpp 22492d0eb82ea257e666c29e66c402a45d5d7e25 net d1d445326a5d3ec77e14394fee60962d08894ba0 PEGTL 0d37dcf8f02c12a84fdf521973801a5baab78e8f oh d9eb908452e808179afeb7954e8beaa5b3626c36 @@ -39,22 +39,22 @@ nigiri c9a8d9350d681643dd5809a5e415e4cb0cbc2285 conf f9bf4bd83bf55a2170725707e526cbacc45dcc66 expat 636c9861e8e7c119f3626d1e6c260603ab624516 libosmium aec81a466451d5c6913df503e543806129df02d1 +oneTBB cda5d37ce8303c58ac506259387e75de4ffcf26b protozero a94574f49d3820dc2f90121f8d5c5ffb6210b5b2 -osm 4b9644b8b3753ac13903f1f08a7b06cc19943d27 rapidjson 8b92de0bfdcf2127e67903959c9c48d580829da7 -osr 5b51bdf072062a6caa88539e2a2619cd7222f5c1 -prometheus-cpp e420cd7cf3995a994220b40a36c987ac8e67c0bf -reflect-cpp 86fdcdd09a54b0f55de97110e1911d27f60e498a clipper 2abf72015b85f1cfd38706b21d4f28b8c359c7d5 concurrentqueue ab6b0b5334a005ef6ff1365ba5af87404de39d29 lmdb 39d8127e5697b1323a67e61c3ad8f087384c7429 +osm 4b9644b8b3753ac13903f1f08a7b06cc19943d27 pbf-sdf-fonts 32e9dfe72fcf0be3477f262880f66f8a1248a381 variant 5aa73631dc969087c77433a5cdef246303051f69 tiles f94499b4defcb807156743b9bb2fc047394b9fab +osr 5b51bdf072062a6caa88539e2a2619cd7222f5c1 +prometheus-cpp e420cd7cf3995a994220b40a36c987ac8e67c0bf +reflect-cpp 86fdcdd09a54b0f55de97110e1911d27f60e498a FTXUI dd6a5d371fd7a3e2937bb579955003c54b727233 Mustache 3f654942a70c46a775070d7a09ca7acfa3e205b7 address-formatting d08c98b5b2c57f51bf03cae6c4f6586edab23a5c -oneTBB cda5d37ce8303c58ac506259387e75de4ffcf26b Nominatim c0c8a26c3915c928ff5fcc2a78e47e851603a06b utf8proc 779b780da3b99d123133eb99707b65c7e4324cc8 adr 381a389f4d6ce634cc6608d59d1d4525c2608dc6 diff --git a/src/parse_location.cc b/src/parse_location.cc index 5b89011fde..b5f4b669aa 100644 --- a/src/parse_location.cc +++ b/src/parse_location.cc @@ -7,6 +7,10 @@ #include "date/date.h" +#include "fmt/format.h" + +#include "net/bad_request_exception.h" + #include "utl/parser/arg_parser.h" namespace n = nigiri; @@ -52,11 +56,12 @@ date::sys_days parse_iso_date(std::string_view s) { std::pair parse_cursor(std::string_view s) { auto const split_pos = s.find("|"); - utl::verify(split_pos != std::string_view::npos && split_pos != s.size() - 1U, - "invalid page cursor {}, separator '|' not found", s); + utl::verify( + split_pos != std::string_view::npos && split_pos != s.size() - 1U, + "invalid page cursor {}, separator '|' not found", s); auto const time_str = s.substr(split_pos + 1U); - utl::verify( + utl::verify( utl::all_of(time_str, [&](auto&& c) { return std::isdigit(c) != 0U; }), "invalid page cursor \"{}\", timestamp not a number", s); @@ -67,7 +72,9 @@ std::pair parse_cursor(std::string_view s) { switch (cista::hash(direction)) { case cista::hash("EARLIER"): return {n::direction::kBackward, t}; case cista::hash("LATER"): return {n::direction::kForward, t}; - default: throw utl::fail("invalid cursor: \"{}\"", s); + default: + throw net::bad_request_exception{ + fmt::format("invalid cursor: \"{}\"", s)}; } }