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 revisions2026-07-28,
2025-11-25,2025-06-18, and2025-03-26).
Use this from Claude Desktop's or the Claude API's remote MCP connector.
/v1/mcpand/v1/mcp/streamare not on the API reference. OpenAPI
cannot model a JSON-RPC envelope (a singlePOSTwhose behavior branches
on themethodfield 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).
- Traveler JWT — an
Authorization: Bearerheader 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 staticAuthorizationheader should use
this token againstPOST /v1/mcp/stream. - MCP OAuth 2.1 access token — an
Authorization: Bearerheader
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/authorizehands 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. - Consumer HMAC — no bearer token, but the
TravelTech-Consumer/
TravelTech-Timestamp/TravelTech-Signatureheaders 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 withRetry-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
listTripsOnce 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) andsearchCars(pickup_query)
resolve the words themselves. When the place was a confident guess, the
result carrieslocationAssumptions. 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
findPlaceswhen you want to confirm it
or reuse it across searches. It returns rankedcandidates(each with
placeId,kind,label,city,countryandwhy), the
recommendedone, andneedsConfirmation.
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:
| Vertical | Pass placeId as |
|---|---|
stay | searchStays.place_id |
flight | searchFlights.origin or destination |
train | searchTrains.origin_id or destination_id |
car | searchCars.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.
nullmeans 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:
| Purpose | Method 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 |
| JWKS | GET /.well-known/jwks.json |
| Authorization | GET /oauth/authorize |
| Token | POST /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.
Calltools/listand 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 theretryAfterseconds in the
error'sdatafield 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.
Updated 2 days ago