Connect Claude to travel.tech's MCP server

Register travel.tech's MCP tools with Claude Desktop or the Claude API's MCP connector.

travel.tech exposes its travel-search, booking, and trip tools as an
MCP server, so a Claude integration can
call searchFlights, listTrips, and the rest of the
assistant tool surface directly instead of you
re-implementing each one as a hand-written function tool.

Two HTTP transports share that surface:

  • POST /v1/mcp — JSON-RPC in a single HTTP request/response. Use this from
    a local stdio bridge that signs or attaches credentials on every call.
  • POST /v1/mcp/stream — Streamable HTTP (MCP spec revisions 2026-07-28,
    2025-11-25, 2025-06-18, and 2025-03-26).
    Use this from Claude Desktop's or the Claude API's remote MCP connector.

/v1/mcp and /v1/mcp/stream are not on the API reference. OpenAPI
cannot model a JSON-RPC envelope (a single POST whose behavior branches
on the method field in the body) as a set of typed operations, so this
guide — not the reference's schema browser — is the source of truth for
the tool names, inputs, and error codes below.

Authentication

POST /v1/mcp and POST /v1/mcp/stream accept the same credential model.
Exactly one mode applies per request — present whichever matches your
integration, never more than one credential type at once. A request with no
credential is rejected (HTTP 401).

  1. Traveler JWT — an Authorization: Bearer header carrying a token
    obtained through BizAway sign-in (see
    Authentication § Obtaining a token). This
    unlocks every tool scoped to that traveler — listTrips,
    searchFlights, getInvoiceProfiles, and so on. Most integrations
    acting on behalf of a signed-in traveler want this mode. Remote
    connectors that send a single static Authorization header should use
    this token against POST /v1/mcp/stream.
  2. MCP OAuth 2.1 access token — an Authorization: Bearer header
    carrying an access token issued by travel.tech's MCP authorization
    server. Discovery, authorization, and token endpoints are all live (see
    OAuth 2.1), including the browser
    account-linking flow /oauth/authorize hands off to. Completing that
    flow links the token to a specific traveler's BizAway account, so a
    linked access token unlocks the same traveler-scoped tool surface a
    traveler JWT does — mutating tools included. Claude Desktop's and the
    Claude API's MCP connector, which can open a system browser, complete
    the full first-time login this way. If you already hold a linked access
    token, send it directly; Claude only needs to open the browser flow once
    per traveler.
  3. Consumer HMAC — no bearer token, but the TravelTech-Consumer /
    TravelTech-Timestamp / TravelTech-Signature headers that identify your
    registered integration and are recomputed per request from a per-consumer
    secret. Full algorithm and worked examples:
    Authentication § Consumer HMAC. This is a
    conduit call with no traveler identity attached — it only reaches
    non-mutating, non-traveler-scoped tools; mutating tools (bookings) and
    every traveler-scoped tool or resource stay withheld on this path. Your
    integration must be registered as a consumer with travel.tech before this
    works — contact your BizAway integration contact to provision a slug and
    secret. Because the signature covers a hash of the exact request body and
    a per-request timestamp, it cannot be entered as a static header in
    Claude's remote-connector field; use a local stdio bridge for HMAC.

A credential that is presented but fails its own verification (an
expired/malformed JWT or MCP OAuth token, or a consumer-HMAC signature that
doesn't match) is rejected outright — it never falls back to a weaker mode.
Every authentication failure is an HTTP status with travel.tech's standard
REST error envelope, not a JSON-RPC error body:

  • A rejected bearer (an expired, malformed, or revoked traveler JWT or MCP
    OAuth access token) returns HTTP 401 with
    WWW-Authenticate: Bearer error="invalid_token", error_description="…".
    When MCP OAuth is enabled the challenge also carries
    resource_metadata="…/.well-known/oauth-protected-resource/v1/mcp", so a
    connector refreshes its access token (or restarts sign-in) on its own.
  • A bad consumer signature returns CONSUMER_AUTH_REQUIRED (HTTP 401).
  • When the credential cannot be checked right now (for example the signing
    keys or BizAway identity service are briefly unreachable) the response is
    HTTP 503 with Retry-After. Keep the token and retry after that many
    seconds; it has not been rejected.

Connecting with a local MCP server that signs each call

Claude Desktop can launch a local MCP server over stdio — a command you
provide in claude_desktop_config.json — instead of only speaking to a
remote URL. That local process holds your consumer secret and traveler JWT,
signs each request the same way any travel.tech integration does, and relays
Claude's tools/list / tools/call traffic to POST /v1/mcp over HTTPS.
This is the practical path for HMAC (and for any client built on the same
local-server pattern, e.g. Claude Code) because the signature must be
computed fresh on every call.

claude_desktop_config.json:

{
  "mcpServers": {
    "travel-tech": {
      "command": "node",
      "args": ["/path/to/travel-tech-mcp-bridge/index.js"],
      "env": {
        "TRAVELTECH_BASE_URL": "https://api.travel.tech",
        "TRAVELTECH_CONSUMER": "acme-travel",
        "TRAVELTECH_CONSUMER_SECRET": "<your consumer secret>",
        "TRAVELTECH_TRAVELER_JWT": "<traveler bearer token, or omit for conduit-only mode>"
      }
    }
  }
}

The bridge script itself is a thin MCP stdio server: on each tools/call (or
tools/list) message from Claude, it signs and forwards the same call to
travel.tech and streams the reply back over stdio. A minimal Node
implementation, using the
MCP TypeScript SDK
for the stdio side and travel.tech's own signing algorithm for the HTTP side:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import crypto from "node:crypto";

const { TRAVELTECH_BASE_URL, TRAVELTECH_CONSUMER, TRAVELTECH_CONSUMER_SECRET, TRAVELTECH_TRAVELER_JWT } =
  process.env;

function signedHeaders(method, path, body) {
  const timestamp = String(Math.floor(Date.now() / 1000));
  const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
  const canonical = `${method}\n${path}\n${timestamp}\n${bodyHash}`;
  const signature = crypto
    .createHmac("sha256", TRAVELTECH_CONSUMER_SECRET)
    .update(canonical)
    .digest("hex");
  const headers = {
    "TravelTech-Consumer": TRAVELTECH_CONSUMER,
    "TravelTech-Timestamp": timestamp,
    "TravelTech-Signature": signature,
    "Content-Type": "application/json",
  };
  // A traveler JWT takes priority over the consumer HMAC above when both are
  // present -- set it only when acting on behalf of a signed-in traveler.
  if (TRAVELTECH_TRAVELER_JWT) {
    headers.Authorization = `Bearer ${TRAVELTECH_TRAVELER_JWT}`;
  }
  return headers;
}

async function callTravelTech(method, params) {
  const body = JSON.stringify({ jsonrpc: "2.0", id: crypto.randomUUID(), method, params });
  const res = await fetch(`${TRAVELTECH_BASE_URL}/v1/mcp`, {
    method: "POST",
    headers: signedHeaders("POST", "/v1/mcp", body),
    body,
  });
  const { result, error } = await res.json();
  if (error) throw new Error(`${error.code}: ${error.message}`);
  return result;
}

const server = new Server({ name: "travel-tech-bridge", version: "1.0.0" }, { capabilities: { tools: {} } });

server.setRequestHandler("tools/list", async () => callTravelTech("tools/list", {}));
server.setRequestHandler("tools/call", async (request) =>
  callTravelTech("tools/call", request.params),
);

await server.connect(new StdioServerTransport());

Worked example: listTrips

Once Claude connects, ask it something that needs the traveler's trips (for
example, "what's my next trip?"). Claude issues a tools/call for
listTrips, which your bridge forwards as this signed HTTP request:

POST /v1/mcp HTTP/1.1
TravelTech-Consumer: acme-travel
TravelTech-Timestamp: 1767200400
TravelTech-Signature: <hex hmac-sha256, per the algorithm above>
Authorization: Bearer <traveler-jwt>
Content-Type: application/json

{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"listTrips","input":{}}}

travel.tech replies with the traveler's trips grouped by temporal bucket:

{
  "jsonrpc": "2.0",
  "id": "1",
  "result": {
    "result": {
      "past": [],
      "current": [],
      "upcoming": [{ "tripId": "…", "title": "Milan → London" }],
      "incomplete": [],
      "totalCount": 1
    }
  }
}

Your bridge hands that result.result payload back to Claude over stdio,
which folds it into its reply.

Look up a place, then search

Travelers name places loosely: "a hotel in Milan near the city centre",
"Milan to Paris", "Gare de Lyon", "Mailand". travel.tech resolves those words
with one policy for stays, flights, trains and cars, in English, Italian,
French, Spanish and German, so your integration never needs place ids or
airport codes.

There are two ways to use it:

  • Search with the traveler's words. searchStays (destination_query),
    searchFlights (origin_query, destination_query), searchTrains
    (origin_query, destination_query) and searchCars (pickup_query)
    resolve the words themselves. When the place was a confident guess, the
    result carries locationAssumptions. Show that line to the traveler (for
    example "Searched near Milano Centrale, Piazza Duca d'Aosta, Milan, …") so
    they can correct it in one turn.
  • Look the place up first with findPlaces when you want to confirm it
    or reuse it across searches. It returns ranked candidates (each with
    placeId, kind, label, city, country and why), the
    recommended one, and needsConfirmation.

Ask the traveler only when needsConfirmation is true (words that truly
name several places, such as "Springfield"), offering the candidates'
labels, at most once. Otherwise search with recommended.placeId:

VerticalPass placeId as
staysearchStays.place_id
flightsearchFlights.origin or destination
trainsearchTrains.origin_id or destination_id
carsearchCars.pickup_id, with pickup_type set to carLocationType

When a search tool can't pick a place safely on its own, it returns the same
labelled candidates in its error (ambiguous_destination, ambiguous_station
or ambiguous_location) with needsConfirmation: true.

Example exchange

The traveler asks: "Find me a hotel near Centrale in Milan for 18 to 20
November." Claude looks the place up:

{"jsonrpc":"2.0","id":"2","method":"tools/call","params":{"name":"findPlaces","input":{"query":"Milan near Centrale","vertical":"stay"}}}

travel.tech recommends the station, and says what it assumed:

{
  "jsonrpc": "2.0",
  "id": "2",
  "result": {
    "result": {
      "vertical": "stay",
      "rule": "landmark_in_city",
      "confidence": "good",
      "needsConfirmation": false,
      "recommended": {
        "placeId": "ChIJ…",
        "kind": "station",
        "name": "Milano Centrale, Piazza Duca d'Aosta, Milan, Italy",
        "label": "Milano Centrale, Piazza Duca d'Aosta, Milan, Italy (station)",
        "assumption": "Searched near Milano Centrale, Piazza Duca d'Aosta, Milan, Italy"
      },
      "candidates": ["…"],
      "message": "Search with recommended.placeId without asking, and tell the traveler what was assumed (recommended.assumption) so they can correct it."
    }
  }
}

Claude then calls searchStays with place_id set to the recommended
placeId and the dates, and answers with the stays and the assumption:
"These are near Milano Centrale; tell me if you meant somewhere else."

Results come ranked, with a reason

searchStays, searchFlights, searchTrains and searchCars, and the tools
that narrow or page them (refineStaySearch, refineFlightSearch,
refineTrainSearch, refineCarSearch, getMoreResults), list options best
first. Each option carries three fields to quote to the traveler:

  • rank: 1 is the best match among the options that passed the traveler's
    constraints. The constraints you set (stars, price, direct only) are never
    traded away for a higher score; ranking only orders what they kept.
  • rankReason: a short phrase for the rank, such as "Lowest price; Best value
    for the price", "Your preferred stay chain", or "Outside your travel policy"
    with the first reason. It is English text.
  • isPolicyCompliant: whether the traveler's travel policy clears the option.
    An option the policy does not clear ranks below every option it does clear.
    null means no policy verdict is available.

A flight search keeps the order the traveler asked for when it sets sort
(for example price-asc), and a train search lists its cheapest and fastest
direct picks first; rank still tells you where the scorer placed each one.
Present the options in the order returned instead of sorting them again.

Connecting with Claude's remote MCP connector

Claude Desktop's and the Claude API's remote MCP connector fields speak
Streamable HTTP. Point them at POST /v1/mcp/stream, not at POST /v1/mcp.
The connector negotiates the protocol revision itself (the initialize
handshake for 2025-03-26 through 2025-11-25, per-request
MCP-Protocol-Version: 2026-07-28 otherwise). On this URL, tools/call
takes the spec's params.arguments and returns a standard CallToolResult
(content, plus structuredContent from 2025-06-18 on). The
params.input / {"result": ...} shape shown for POST /v1/mcp applies
only to that endpoint.

With a traveler JWT or a linked MCP OAuth access token on a
handshake revision (2025-03-26 through
2025-11-25), initialize returns an Mcp-Session-Id response header
naming the traveler's open assistant session. If no session can be opened
at that moment, initialize still succeeds without the header and the
connection is stateless. The connector echoes it on
every later request, which is what lets refineFlightSearch,
getMoreResults, getResultDetails, and presentResults build on a search
from an earlier call. An Mcp-Session-Id the bearer does not own, or one
that has expired, returns HTTP 404; start a new session with initialize.
Sessions cannot be ended by the client (DELETE returns HTTP 405).
Consumer HMAC connections and 2026-07-28 requests are stateless: no
Mcp-Session-Id is issued and each tool call stands alone.

Use a traveler JWT as authorization_token when the model must call
traveler-scoped tools (listTrips, bookings, and so on). A linked MCP
OAuth 2.1 access token
is also a valid bearer on this URL and acts for
the traveler it was linked to, exactly like a traveler JWT. Consumer
HMAC cannot be expressed as a single static header; keep using the local stdio bridge
for HMAC.

For the Claude API's mcp_servers connector:

response = client.beta.messages.create(
    model="claude-opus-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": "What's my next trip?"}],
    mcp_servers=[
        {
            "type": "url",
            "url": "https://api.travel.tech/v1/mcp/stream",
            "name": "travel-tech",
            "authorization_token": "<traveler-jwt-or-mcp-oauth-access-token>",
        }
    ],
    tools=[{"type": "mcp_toolset", "mcp_server_name": "travel-tech"}],
    betas=["mcp-client-2025-11-20"],
)

Claude Desktop's remote-connector URL is the same https://api.travel.tech/v1/mcp/stream
endpoint; attach the bearer as the connector's authorization token.

Claude Desktop and the Claude API's MCP connector complete a first-time
in-browser OAuth login against travel.tech: /oauth/authorize hands off to
a browser account-linking flow (sign-in, account confirmation, consent)
that finishes with a redirect back to Claude carrying the authorization
code. If you already hold a linked MCP OAuth access token, pass it as
authorization_token directly; otherwise let Claude open the browser
flow, or use a traveler JWT or the local-server path.

OAuth 2.1

When MCP OAuth is enabled on the deployment, travel.tech publishes:

PurposeMethod and path
Authorization server metadata (RFC 8414)GET /.well-known/oauth-authorization-server
Protected resource metadata (RFC 9728)GET /.well-known/oauth-protected-resource and GET /.well-known/oauth-protected-resource/v1/mcp
JWKSGET /.well-known/jwks.json
AuthorizationGET /oauth/authorize
TokenPOST /oauth/token

The advertised resource identifier is {issuer}/v1/mcp. Supported grant
types are authorization code (PKCE S256) and refresh token. The MCP
scope is bizaway.mcp. Access tokens are sent as
Authorization: Bearer on POST /v1/mcp and POST /v1/mcp/stream.
Clients identify themselves with an OAuth Client ID Metadata Document;
dynamic client registration is not exposed.

GET /oauth/authorize validates the client, parks the request, and
redirects to a browser account-linking flow (sign-in, account
confirmation, consent) that completes at /auth/complete, redirecting
back to the client's redirect_uri with the authorization code. A remote
connector that can open a system browser — Claude Desktop, the Claude API
connector — completes a brand-new login this way. Token refresh and
bearer use of an already-issued access token are live.

Troubleshooting

  • HTTP 401 with WWW-Authenticate: Bearer error="invalid_token" — the
    traveler JWT or MCP OAuth access token is malformed, expired, or revoked. A connector refreshes its token on this response;
    a bridge should re-mint it and confirm it reads the current value.
  • HTTP 503 with Retry-After — the credential could not be checked right
    now. Retry after the given number of seconds with the same token.
  • -32010 (auth error) — BizAway rejected the traveler's token while a
    tool call was already running (the token expired mid-call). Refresh or
    re-mint the token and retry the call.
  • -32001 (tool not found) — the tool name is misspelled, or the
    account calling in does not have that tool's module/feature flag enabled.
    Call tools/list and use the returned names verbatim.
  • -32002 (permission denied) — the credential used cannot reach that
    tool (for example, a mutating or traveler-scoped tool called via the
    consumer-HMAC conduit or an MCP OAuth access token, instead of a traveler
    JWT).
  • -32012 (rate limited) — back off for the retryAfter seconds in the
    error's data field before retrying.
  • CONSUMER_AUTH_REQUIRED (HTTP 401, before any JSON-RPC body is read) —
    the consumer HMAC headers are missing or the signature does not match; see
    Authentication § Troubleshooting.

Reporting issues

This page is maintained by the BizAway integration team. To request changes or
report an error, contact your BizAway integration contact. Edits made directly
in the ReadMe dashboard are overwritten on the next publish.


Did this page help you?