Skip to content

Arkana MCP Server

The Arkana MCP server makes Arkana (the Volāre Laravel/Filament back office) agent-ready through a single, generic MCP endpoint that grows domain by domain. People connect from Claude Desktop, claude.ai, Claude Code or Codex by signing in with their Arkana account; machine agents such as the Hermes Slack bot use a token. It starts with bookings.

Ops asked Hermes for one CSV per confirmed booking with the client name and the “How the price was built (at purchase)” table. That table is computed when the Cost Breakdown page is viewed; it is not stored. The Analytics MCP gateway only has raw SQL views, so recomputing it there would give wrong numbers.

Arkana is the source of truth. The MCP server is only a connector: each tool calls the same domain class the admin screen uses and never re-implements its logic, so agents and operators can never see different figures.

  1. The client sends a Streamable HTTP request to one of two endpoints of the same server (routes/ai.php):

    • People: /mcp/arkana, public, with an OAuth access token (auth:api = Laravel Passport, scope mcp:use).
    • Agents: /mcp/arkana-agent, internal only, with a Sanctum token (auth:sanctum).

    Both add throttle:120,1 and LogMcpToolCall.

  2. ArkanaServer lists its tools; each tool’s ArkanaTool::shouldRegister() decides whether this caller may see and call it.

  3. The tool validates its input and calls a domain class under app/Services/.

  4. That domain class is the one the Arkana screen consumes, so the tool returns the screen’s figures, only rounded to 2 decimals as the screen displays them (ArkanaTool::asDisplayed()).

Definitions shared by Filament and the tools:

Definition Used by the screen in Used by the tools for
BookingStatusGroup enum Arkana › Bookings tabs, Booking::open() / inStatusGroup() scopes status_group filter
Booking::scopeVisibleTo() BookingResource (supplier-manager scoping) Every booking lookup
User::canViewInternalPricing() GatesSupplierManagerPrices trait Hiding pricing from the cost breakdown tool
BookingPricingData (and its RELATIONS) Booking › Cost Breakdown (ViewBooking) bookings_get_cost_breakdown

Access is per tool, decided once in ArkanaTool::shouldRegister(). A tool is listed and callable only when the user holds the Spatie permissions mapped to the tool’s ability, and the request carries one of:

  • a person’s OAuth token (Passport) with the mcp:use scope. The person acts with their own Arkana account: their roles, supplier scoping and pricing visibility apply exactly as on screen;
  • an agent’s Sanctum token (PersonalAccessToken) granted the tool’s ability.

Session-authenticated requests (Sanctum TransientToken) never qualify. An OAuth token is refused on the agent endpoint and a Sanctum token on the people endpoint.

Mcp::oauthRoutes() publishes the OAuth discovery metadata and dynamic client registration (/oauth/register, rate limited). Clients may only register callbacks on https://claude.ai, https://claude.com or http://localhost (config/mcp.php). Signing in goes through the normal Arkana login (password or Google) and a Filament consent screen (resources/views/mcp/authorize.blade.php). OAuth sessions do not expire on their own; access ends when the person loses the Arkana permissions, or when the token is revoked. Passport signs tokens with PASSPORT_PRIVATE_KEY / PASSPORT_PUBLIC_KEY from the backend environment, the same on every instance.

Each tool belongs to an ability, named {domain}:read / {domain}:write, in App\Enums\McpAbility. The ability’s permissions decide whether a person sees the tool:

Ability Required permissions Tools
bookings:read view_booking bookings_list, bookings_get_cost_breakdown
products:read view_market_product, view_offer products_list, products_get, products_list_offers, products_diagnose_offers
tours:read view_supplier_tour, view_market_product tours_list, tours_get

People get the same tools through their roles: admins all of them; sales agents the bookings tools. Finance and supplier managers hold none of these permissions today.

Agents get a role-less service user created by mcp:agent-token (see Connecting). With no role it cannot sign in to the admin panel. Its token carries the mcp:* ability (McpAbility::ALL, accepted only by the MCP tools, not by the REST API) and the user the * permission, so an agent reaches every tool, including tools added later. The abilities above decide which tools a person sees.

LogMcpToolCall records every tools/call in the activity log under the mcp log name, with the caller (the person or the agent’s service user), the agent’s token name (e.g. mcp-hermes) or the person’s OAuth client (e.g. oauth:Claude), the tool name, the arguments, the outcome (status: ok or error, where a validation error or an unknown tool counts as error; null for streamed SSE responses, whose outcome is not visible there) and duration_ms. Nightwatch and Sentry see every call as the same POST to the MCP endpoint, so this log is the per-tool view.

Tools are named {domain}_{verb}[_{object}].

Lists bookings as Arkana › Bookings does, newest booking date first. Use it to find references and ids.

  • Arguments:

    • status_group: an Arkana › Bookings tab (open, action_required, active, checkout, quotations, completed, closed), or all for every status. Defaults to open, the admin default.
    • status: booking statuses, within the group.
    • attention: the booking page’s Attention badges (flight_booking_failed, balance_overdue, payment_pending, needs_admin_action, flight_repriced, flight_fare_lost, ticket_not_issued), from App\Enums\BookingAttention. Fare lost and ticket not issued reuse FlightBooking::hasLostItsFare() / canIssueTicket() and their query scopes (withLostFare, issuable); the Flight Bookings “Fare expired” filter uses the same scope.
    • product_id, or product: title text that also matches quote copies (“Beduino - Aida Fernández”).
    • country: ISO-2 code of a country the trip visits; see Destination countries.
    • location: a place the itinerary stops at, by name: a POI name or translation, or a legacy stop name (ProductTemplate::scopeVisitingLocation; arrival and departure endpoints are not stops).
    • market: code, e.g. ES.
    • departure_from / departure_to and booked_from / booked_to: YYYY-MM-DD, inclusive. Booked dates are calendar days in the viewer’s display timezone (Europe/Madrid by default), like the admin.
    • sort: booked (default) or departure (soonest first).
    • per_page (default 25, max 100) and cursor.

    Arkana › Bookings has the same Attention, Destination, Location, Product and Departure filters, built on the same Booking scopes.

  • Output: {data, next_cursor}. Each row has id, reference, status, client_name, product (id, title, sku), countries, locations (itinerary stops), market, departure_date, currency, total_amount, number_of_travelers, booked_at. Pass next_cursor back as cursor; null means no more pages. Any other cursor value is rejected with a validation error, rather than silently restarting from the first page.

  • Visibility: the same Booking::visibleTo() scope as the admin list: supplier managers see only their supplier’s bookings, and none while no supplier is assigned to them.

  • Source: backend/app/Mcp/Tools/Bookings/ListBookingsTool.php → backend/app/Services/Booking/ListBookingsQuery.php

Returns one booking’s Arkana › Booking › Cost Breakdown tables.

  • Arguments: reference (e.g. BK-28776C91) or id.

  • Output: id, reference, status, client_name, currency, pax_count, total_amount, gross_profit, gross_profit_percent, reconciles (cost lines add up to the total), has_snapshot, and the tables:

    Field Screen table
    pricing_view How the price was built (at purchase)
    pnl_summary P&L summary
    payment_schedule Customer payments
    cost_lines Direct supplier costs
    land_components Land cost composition
    land_fx Land FX position (exposure + detail; null for EUR suppliers)

    land_components are priced from current supplier rates (computed by App\Services\Offers\LandServicesBreakdown, the same service the offer preview uses) and are informative. land_components_total is that current-rates total. The authoritative land cost is the cost_lines “Land cost” row, which is checkout-time, and land_components_gap_to_checkout is the current-rates total minus that row: 0 means rates have not moved, and null means the two cannot be compared.

  • Gotchas: has_snapshot = false (manual or pre-snapshot bookings) means pricing_view and pnl_summary are empty and cost_lines holds a single package line. Users who cannot view internal pricing (supplier managers, always) get an error instead of figures. Bookings outside visibleTo() return “not found”.

  • Source: backend/app/Mcp/Tools/Bookings/GetBookingCostBreakdownTool.php → backend/app/Services/Booking/BookingPricingData.php (looked up via FindBookingQuery)

Lists products by market as Arkana › Products By Market does, newest first.

  • Arguments: market, status (draft, active, inactive), country (ISO-2), location, title (text, quote copies included), tour_id, show_itinerary_module (true: only products whose customers see the new day-by-day itinerary; false: only those still on the current one), per_page (max 50), cursor.
  • Output: id, title, sku, market, locale, status, show_itinerary_module, countries (code, name), locations, tour_id, published_offers_count (Active offers), created_at.
  • Source: ListProductsTool → App\Services\Products\ListProductsByMarketQuery (ProductByMarket::scopeForListing, scopeVisitingCountry, scopeTitled, scopeUsingTour, shared with the list’s filters).

One product’s page: flights (trip duration, route, search window, excluded dates, each departure airport’s legs), land (the tour’s itinerary nights and the supplier services that price the land part, with rate periods, blackouts and net prices per room type, from SupplierTour::landPricingServices(), the same services the offer generator and LandServicesBreakdown price), content (translated texts, itinerary, inclusions, SEO), status, show_itinerary_module (the Show the new day-by-day itinerary switch), countries, locations, tour and public_url (active products only).

A product’s offers as its Offers tab shows them, soonest departure first: display_status (with the “Stop sale” and release-window overlays), departure_date, arrival_date, departure_airport, room and CUG type, final_price, price_per_pax, the internal breakdown (land_price, flight_price, base_price, margin), the bound international flight (route, stops, airlines, stopovers, elapsed minutes, from App\Services\Offers\OfferFlightSummary) and flight_price_searched_at. Filters: status, bookable (true bookable now, false expired for sale), airports, departure dates, min_price / max_price, best_price_only. Source: ListOffersQuery (Offer::scopeForListing and the filter scopes, shared with the Offers table).

The Diagnose missing offers action: for each (airport, date) slot, the verdict and every gate (pass, fail, skip, with its reason) the offer generator applies. Arguments: product_id, airports (IATA codes of the product’s active flight configurations), dates; at most OfferGenerationDiagnosticsService::MAX_SLOTS_PER_RUN (100) slots per call.

Lists supplier tours as Arkana › Suppliers › Tours does, newest first. Filters: supplier_id, status (draft, complete), has_guide, search (title or supplier name). Each row: title, suppliers, status, duration_days, guide and languages, rate_periods_count, products_by_market_count / active_products_by_market_count and locked. Visibility follows SupplierTour::scopeVisibleTo(), the same rule as bookings.

One tour’s page: details, completion, route and itinerary, travel_windows (with blackouts), hotels, activities and transfers with their tiers and itinerary days (App\Services\SupplierTourAssignments, also behind the page’s Hotels, Activities and Transfers tables), package_services (with alternative_group), guide and inclusions, products_by_market and active_stop_sales.

A product visits the countries its CMS country pages link plus the countries its flight-leg airports reach, minus its market’s own countries (ProductByMarket::getDestinationCountries(); scopeVisitingCountry() is the query form). Both sources are needed: quote copies have no CMS link, and some countries are reached by land (Maya’s Honduras). The Products By Market Destination column and filter, the Bookings Destination filter and the country arguments all use it.

The two servers are independent; there is no proxy between them. The server instructions in ArkanaServer tell agents how to route:

Question Server
Anything Arkana computes (price breakdown, P&L, margins, payment plan) or a record “as Arkana shows it” Arkana
Counts, trends, groupings, ad-hoc slices across many records Analytics (db)
Website traffic and behaviour Analytics (GA, Clarity)
Mixed (e.g. “margin of every booking from Spain last month”) Analytics for the ids, then Arkana for the figures
  • Who can connect. Only Arkana staff (a user with an Arkana role) can authorize a client; anyone else gets a 403 on the consent screen. The consent screen shows where the client sends the person back (e.g. claude.ai), since the client name is chosen by whoever registered it.

  • Session lifetime. OAuth access and refresh tokens last 100 years, so in practice they never expire (product decision). Access ends when the person loses the Arkana permissions the tools check, or when their sessions are revoked:

    Terminal window
    php artisan mcp:revoke-oauth person@byvolare.com

    This revokes every access and refresh token of that person; their MCP clients must sign in again. Agent tokens are revoked with mcp:agent-token <agent> --revoke.

  • Signing keys. Passport signs and verifies tokens with PASSPORT_PRIVATE_KEY / PASSPORT_PUBLIC_KEY from the backend environment (/volare/<env>/backend/env). Every API instance must have the same pair: if they are missing, /oauth/token fails; if they differ between instances, tokens fail intermittently with 401.

  • Load balancers. /mcp/arkana, /oauth/* and /.well-known/oauth-* must be reachable from the internet on the Arkana host (Claude connectors connect from Anthropic’s cloud). /mcp/arkana-agent must be denied on the public load balancer and reachable only through the internal one.