Connect an OpenAI-based client to travel.tech's MCP server

Wire travel.tech's MCP tools into an OpenAI Assistants/Apps SDK integration or a function-calling client.

travel.tech exposes its travel-search, booking, and trip tools as an
MCP server. Two HTTP transports share the
same assistant tool surface:

  • POST /v1/mcp — JSON-RPC in a single HTTP request/response. Use this from
    a function-calling bridge you run yourself (you sign or attach
    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 OpenAI's hosted MCP connector, which calls travel.tech
    directly over Streamable HTTP.

/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. Hosted
    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. OpenAI's hosted connector,
    which can open a system browser, completes the full first-time login
    this way. If you already hold a linked access token, send it directly;
    the connector 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
    OpenAI's hosted connector; use a function-calling 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 function-calling bridge

Since your own server holds the credentials and makes the HTTP call, this
path uses POST /v1/mcp and does not require Streamable HTTP. The shape is:

  1. Call tools/list once (cache the result) to get each tool's name,
    description, and JSON-Schema inputSchema.
  2. Map each entry to an OpenAI function/tool definition — the schemas are
    already JSON Schema, so this is close to a direct pass-through.
  3. Pass those definitions as tools on your Responses (or Chat Completions)
    API call.
  4. When the model emits a function/tool call, forward it to travel.tech's
    tools/call and return the result as the tool output.
import hashlib
import hmac
import json
import time
import uuid

import httpx
from openai import OpenAI

TRAVELTECH_BASE_URL = "https://api.travel.tech"
CONSUMER = "acme-travel"
CONSUMER_SECRET = "<your consumer secret>"
traveler_jwt = "<traveler-jwt>"  # issued by BizAway sign-in; omit for conduit-only mode

openai_client = OpenAI()
travel_tech = httpx.Client(base_url=TRAVELTECH_BASE_URL, timeout=15.0)


def _signed_headers(method: str, path: str, body: bytes) -> dict[str, str]:
    timestamp = str(int(time.time()))
    body_hash = hashlib.sha256(body).hexdigest()
    canonical = f"{method}\n{path}\n{timestamp}\n{body_hash}"
    signature = hmac.new(CONSUMER_SECRET.encode(), canonical.encode(), hashlib.sha256).hexdigest()
    headers = {
        "TravelTech-Consumer": 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 -- send it only when acting on behalf of a signed-in traveler.
    if traveler_jwt:
        headers["Authorization"] = f"Bearer {traveler_jwt}"
    return headers


def call_travel_tech_mcp(method: str, params: dict) -> dict:
    body = {"jsonrpc": "2.0", "id": str(uuid.uuid4()), "method": method, "params": params}
    payload = json.dumps(body).encode()
    response = travel_tech.post(
        "/v1/mcp", content=payload, headers=_signed_headers("POST", "/v1/mcp", payload)
    )
    data = response.json()
    if "error" in data:
        raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
    return data["result"]


def travel_tech_tools_as_openai_functions() -> list[dict]:
    tools = call_travel_tech_mcp("tools/list", {})["tools"]
    return [
        {
            "type": "function",
            "name": t["name"],
            "description": t["description"],
            "parameters": t["inputSchema"],
        }
        for t in tools
    ]

Worked example: listTrips

Give the model the mapped tool list and let it decide when to call one:

response = openai_client.responses.create(
    model="gpt-4.1",
    input="What's my next trip?",
    tools=travel_tech_tools_as_openai_functions(),
)

for item in response.output:
    if item.type == "function_call" and item.name == "listTrips":
        tool_result = call_travel_tech_mcp(
            "tools/call", {"name": "listTrips", "input": {}}
        )
        # tool_result == {"result": {"past": [...], "current": [...],
        #                  "upcoming": [...], "incomplete": [...], "totalCount": N}}
        # Feed tool_result back as the function_call_output for this call_id,
        # then call responses.create again to get the model's final reply.

The signed HTTP request your bridge sends for that call looks exactly like
the one on the Claude integration guide — same JSON-RPC
envelope, same signed headers, same response shape — because both platforms
are calling the same /v1/mcp endpoint; only the client-side tool-call
plumbing differs.

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." The model 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."
    }
  }
}

The model 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 OpenAI's hosted MCP connector

OpenAI's hosted MCP tool (Responses API type: "mcp", and the Apps SDK
built on it) calls travel.tech directly over Streamable HTTP. Point it
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 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 function-calling bridge
for HMAC.

response = openai_client.responses.create(
    model="gpt-4.1",
    input="What's my next trip?",
    tools=[
        {
            "type": "mcp",
            "server_label": "travel-tech",
            "server_url": "https://api.travel.tech/v1/mcp/stream",
            "authorization": "<traveler-jwt-or-mcp-oauth-access-token>",
        }
    ],
)

OpenAI's hosted connector completes 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 the connector carrying the authorization
code. If you already hold a linked MCP OAuth access token, pass it as
authorization directly; otherwise let the connector open the browser
flow, or use a traveler JWT or the function-calling bridge.

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 hosted
connector that can open a system browser 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 retry.
  • 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?