fortrip 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,11 +2,13 @@
2
2
  import {
3
3
  API,
4
4
  COMMANDS,
5
+ COMMAND_GROUPS,
5
6
  CliError,
6
7
  EXIT,
7
8
  GLOBAL_FLAGS,
8
9
  VERSION,
9
10
  authorizedRequest,
11
+ commandInvocation,
10
12
  login,
11
13
  logout,
12
14
  notices,
@@ -15,7 +17,7 @@ import {
15
17
  unwrap,
16
18
  usageError,
17
19
  whoami
18
- } from "./chunk-OBK2KTLC.js";
20
+ } from "./chunk-53656LB2.js";
19
21
 
20
22
  // src/commands.ts
21
23
  import { readFileSync } from "fs";
@@ -39,14 +41,16 @@ function wrap(text, indent) {
39
41
  if (line) lines.push(pad + line);
40
42
  return lines;
41
43
  }
42
- function invocation(spec) {
43
- const positionals = (spec.positionals ?? []).map(
44
+ function positionals(spec) {
45
+ return (spec.positionals ?? []).map(
44
46
  (name) => OPTIONAL_POSITIONALS.has(name) ? `[${name}]` : `<${name}>`
45
47
  );
46
- return ["fortrip", spec.name, ...positionals].join(" ");
48
+ }
49
+ function label(spec) {
50
+ return [spec.name, ...positionals(spec)].join(" ");
47
51
  }
48
52
  function usageLine(spec) {
49
- return `${invocation(spec)} [options]`;
53
+ return [commandInvocation(spec), ...positionals(spec), "[options]"].join(" ");
50
54
  }
51
55
  function names(option, aligned) {
52
56
  const short = option.short ? `-${option.short}, ` : aligned ? " " : "";
@@ -71,23 +75,45 @@ Options:
71
75
  ${optionList(options)}
72
76
  `;
73
77
  }
74
- function commandList() {
75
- const labels = COMMANDS.map((spec) => invocation(spec).slice("fortrip ".length));
76
- const column = 2 + Math.max(...labels.map((label) => label.length)) + 2;
77
- return COMMANDS.map((spec, n) => {
78
- const [first = "", ...rest] = wrap(spec.summary, column);
79
- const label = ` ${labels[n]}`.padEnd(column);
80
- return [label + first.slice(column), ...rest].join("\n");
78
+ function commandList(rows) {
79
+ const column = 2 + Math.max(...rows.map(([name]) => name.length)) + 2;
80
+ return rows.map(([name, summary]) => {
81
+ const [first = "", ...rest] = wrap(summary, column);
82
+ return [` ${name}`.padEnd(column) + first.slice(column), ...rest].join("\n");
81
83
  }).join("\n");
82
84
  }
85
+ function groupHelp(group) {
86
+ const usage = `fortrip ${group.name} <command>`;
87
+ const commands = COMMANDS.filter((spec) => spec.group === group.name);
88
+ return `Usage: ${usage} [options]
89
+ ${usage} --help
90
+
91
+ ${wrap(group.summary, 0).join("\n")}
92
+
93
+ Commands:
94
+ ${commandList(commands.map((spec) => [label(spec), spec.summary]))}
95
+
96
+ Options:
97
+ ${optionList(HELP_FLAG)}
98
+ `;
99
+ }
100
+ var CURRENCY_COMMANDS = COMMANDS.filter(
101
+ (spec) => spec.flags.some((flag) => flag.field === "currency")
102
+ ).map((spec) => commandInvocation(spec).slice("fortrip ".length)).join(" and ");
83
103
  function overallHelp() {
104
+ const rows = [
105
+ ...COMMAND_GROUPS.map((group) => [`${group.name} <command>`, group.summary]),
106
+ ...COMMANDS.filter((spec) => spec.group === null).map(
107
+ (spec) => [label(spec), spec.summary]
108
+ )
109
+ ];
84
110
  return `fortrip ${VERSION} \u2014 search, price and book Fortrip hotels
85
111
 
86
112
  Usage: fortrip <command> [options]
87
113
  fortrip <command> --help
88
114
 
89
115
  Commands:
90
- ${commandList()}
116
+ ${commandList(rows)}
91
117
 
92
118
  Options:
93
119
  ${optionList(GLOBAL_FLAGS)}
@@ -99,7 +125,7 @@ Exit codes: 0 ok, 1 error (retry once when stderr has "retryable": true),
99
125
  Environment:
100
126
  FORTRIP_TOKEN Use this token instead of the saved login (CI, sandboxes)
101
127
  FORTRIP_API_URL API base URL (default https://fortrip.ai)
102
- FORTRIP_CURRENCY Default --currency for search and rates
128
+ FORTRIP_CURRENCY Default --currency for ${CURRENCY_COMMANDS}
103
129
  `;
104
130
  }
105
131
 
@@ -108,9 +134,9 @@ var COMMA_LISTS = /* @__PURE__ */ new Set(["hotelIds", "childrenAges", "boardCod
108
134
  var UPPER_CASE = /* @__PURE__ */ new Set(["nationality", "boardCodes", "currency"]);
109
135
  var TURNS_OFF = /* @__PURE__ */ new Set(["bookableOnly"]);
110
136
  var POSTS = {
111
- search: API.search,
112
- rates: API.rates,
113
- book: API.bookings
137
+ search_hotels: API.search,
138
+ get_hotel_rates: API.rates,
139
+ book_hotel: API.bookings
114
140
  };
115
141
  function parseOptions(spec, argv) {
116
142
  const options = {};
@@ -143,24 +169,24 @@ function parseOptions(spec, argv) {
143
169
  }
144
170
  if (!spec.positionals && parsed.positionals.length) {
145
171
  throw usageError(
146
- `${spec.name} takes no arguments, got "${parsed.positionals[0]}"`
172
+ `${commandInvocation(spec)} takes no arguments, got "${parsed.positionals[0]}"`
147
173
  );
148
174
  }
149
175
  return { values: parsed.values, positionals: parsed.positionals };
150
176
  }
151
177
  function parseCommand(spec, argv, env) {
152
- const post = POSTS[spec.name];
153
- if (!post && spec.name !== "hotel") {
154
- throw new Error(`${spec.name} sends no request`);
178
+ const post = spec.tool ? POSTS[spec.tool] : void 0;
179
+ if (!post && spec.tool !== "get_hotel") {
180
+ throw new Error(`${commandInvocation(spec)} sends no request`);
155
181
  }
156
- const { values, positionals } = parseOptions(spec, argv);
182
+ const { values, positionals: positionals2 } = parseOptions(spec, argv);
157
183
  const input = baseBody(values.body);
158
184
  for (const flag of spec.flags) {
159
185
  const raw = values[flag.flag];
160
186
  if (raw !== void 0) setField(input, flag.field, readFlag(flag, raw));
161
187
  }
162
- if (spec.name === "hotel") {
163
- const [hotelId, ...extra] = positionals;
188
+ if (spec.tool === "get_hotel") {
189
+ const [hotelId, ...extra] = positionals2;
164
190
  const parsed = Number(hotelId);
165
191
  if (!hotelId || extra.length || !/^\d+$/.test(hotelId) || !Number.isSafeInteger(parsed)) {
166
192
  throw usageError(`Usage: ${usageLine(spec)}`);
@@ -177,7 +203,7 @@ function parseCommand(spec, argv, env) {
177
203
  const missing = occupancy.filter((flag) => input[flag.field] === void 0);
178
204
  if (occupancy.length === 2 && missing.length) {
179
205
  throw usageError(
180
- `${spec.name} needs ${missing.map((flag) => `--${flag.flag}`).join(" and ")}, per room and the same for every room (2 rooms for 4 adults is --rooms 2 --adults 2)`
206
+ `${commandInvocation(spec)} needs ${missing.map((flag) => `--${flag.flag}`).join(" and ")}, per room and the same for every room (2 rooms for 4 adults is --rooms 2 --adults 2)`
181
207
  );
182
208
  }
183
209
  return { body: input, path: post };
@@ -277,8 +303,42 @@ function globalFlag(name) {
277
303
  }
278
304
  var HELP_FLAGS = globalFlag("help");
279
305
  var VERSION_FLAGS = globalFlag("version");
306
+ var RENAMED = /* @__PURE__ */ new Map([
307
+ ["hotel", { group: "hotels", name: "get" }]
308
+ ]);
309
+ function findCommand(argv) {
310
+ const [word, ...afterWord] = argv;
311
+ const group = COMMAND_GROUPS.find((candidate) => candidate.name === word);
312
+ if (group) {
313
+ const [name, ...rest] = afterWord;
314
+ if (!name || name === "help" || HELP_FLAGS.includes(name)) {
315
+ return { groupHelp: group };
316
+ }
317
+ const spec2 = COMMANDS.find(
318
+ (candidate) => candidate.group === group.name && candidate.name === name
319
+ );
320
+ if (!spec2) {
321
+ throw usageError(
322
+ `Unknown command "${group.name} ${name}". Run \`fortrip ${group.name} --help\`.`
323
+ );
324
+ }
325
+ return { spec: spec2, rest };
326
+ }
327
+ const spec = COMMANDS.find(
328
+ (candidate) => candidate.group === null && candidate.name === word
329
+ );
330
+ if (spec) return { spec, rest: afterWord };
331
+ const renamed = RENAMED.get(word ?? "");
332
+ const meant = [
333
+ ...COMMANDS.filter((candidate) => candidate.group !== null && candidate.name === word),
334
+ ...renamed ? [renamed] : []
335
+ ].map((candidate) => `\`${commandInvocation(candidate)}\``);
336
+ throw usageError(
337
+ meant.length ? `Unknown command "${word}". Did you mean ${meant.join(" or ")}?` : `Unknown command "${word}". Run \`fortrip --help\`.`
338
+ );
339
+ }
280
340
  async function run(argv) {
281
- const [command, ...rest] = argv;
341
+ const [command] = argv;
282
342
  if (!command || command === "help" || HELP_FLAGS.includes(command)) {
283
343
  process.stdout.write(overallHelp());
284
344
  return void 0;
@@ -288,17 +348,19 @@ async function run(argv) {
288
348
  `);
289
349
  return void 0;
290
350
  }
291
- const spec = COMMANDS.find((candidate) => candidate.name === command);
292
- if (!spec) {
293
- throw usageError(`Unknown command "${command}". Run \`fortrip --help\`.`);
351
+ const found = findCommand(argv);
352
+ if ("groupHelp" in found) {
353
+ process.stdout.write(groupHelp(found.groupHelp));
354
+ return void 0;
294
355
  }
356
+ const { spec, rest } = found;
295
357
  if (wantsHelp(rest)) {
296
358
  process.stdout.write(commandHelp(spec));
297
359
  return void 0;
298
360
  }
299
- if (spec.name === "guide") {
300
- const { positionals } = parseOptions(spec, rest);
301
- const topic = positionals.join(" ");
361
+ if (spec.group === null && spec.name === "guide") {
362
+ const { positionals: positionals2 } = parseOptions(spec, rest);
363
+ const topic = positionals2.join(" ");
302
364
  if (topic !== "" && topic !== "planning") {
303
365
  throw usageError("Usage: fortrip guide [planning]");
304
366
  }
@@ -308,30 +370,30 @@ async function run(argv) {
308
370
  const apiUrl = resolveApiUrl();
309
371
  const log = (line) => process.stderr.write(`${line}
310
372
  `);
311
- switch (spec.name) {
312
- case "login": {
313
- const { values } = parseOptions(spec, rest);
314
- if (process.env.FORTRIP_TOKEN) {
315
- log("Note: FORTRIP_TOKEN is set and takes precedence over this login.");
373
+ if (spec.group === null) {
374
+ switch (spec.name) {
375
+ case "login": {
376
+ const { values } = parseOptions(spec, rest);
377
+ if (process.env.FORTRIP_TOKEN) {
378
+ log("Note: FORTRIP_TOKEN is set and takes precedence over this login.");
379
+ }
380
+ return login({ apiUrl, openBrowser: !values["no-browser"], log });
381
+ }
382
+ case "logout":
383
+ parseOptions(spec, rest);
384
+ return logout(apiUrl);
385
+ case "whoami":
386
+ parseOptions(spec, rest);
387
+ return whoami(apiUrl);
388
+ case "mcp": {
389
+ parseOptions(spec, rest);
390
+ const { runMcpBridge } = await import("./mcp-bridge-LPQBHWLD.js");
391
+ return runMcpBridge(apiUrl);
316
392
  }
317
- return login({ apiUrl, openBrowser: !values["no-browser"], log });
318
- }
319
- case "logout":
320
- parseOptions(spec, rest);
321
- return logout(apiUrl);
322
- case "whoami":
323
- parseOptions(spec, rest);
324
- return whoami(apiUrl);
325
- case "mcp": {
326
- parseOptions(spec, rest);
327
- const { runMcpBridge } = await import("./mcp-bridge-VEJ33LYD.js");
328
- return runMcpBridge(apiUrl);
329
- }
330
- default: {
331
- const { body, path } = parseCommand(spec, rest, process.env);
332
- return call(apiUrl, body ? "POST" : "GET", path, body);
333
393
  }
334
394
  }
395
+ const { body, path } = parseCommand(spec, rest, process.env);
396
+ return call(apiUrl, body ? "POST" : "GET", path, body);
335
397
  }
336
398
  async function call(apiUrl, method, path, body) {
337
399
  return unwrap(await authorizedRequest(apiUrl, path, { method, body }));
@@ -1,11 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  API,
4
+ CONTRACT_VERSION,
4
5
  CliError,
5
6
  EXIT,
7
+ TOOLS,
8
+ loadSession,
6
9
  refreshSession,
10
+ renderGuide,
7
11
  requireToken
8
- } from "./chunk-OBK2KTLC.js";
12
+ } from "./chunk-53656LB2.js";
9
13
 
10
14
  // src/mcp-bridge.ts
11
15
  import {
@@ -15,6 +19,10 @@ import {
15
19
  SdkHttpError,
16
20
  StreamableHTTPClientTransport
17
21
  } from "@modelcontextprotocol/client";
22
+ import {
23
+ InMemoryTransport,
24
+ McpServer
25
+ } from "@modelcontextprotocol/server";
18
26
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
19
27
  var SIGN_IN_REQUIRED = -32001;
20
28
  var INTERNAL_ERROR = -32603;
@@ -121,27 +129,74 @@ var CONNECTION_LOST = {
121
129
  message: "The connection to Fortrip ended before it answered.",
122
130
  data: { retryable: true }
123
131
  };
132
+ var SWITCH_TIMEOUT_MS = 6e4;
133
+ async function hasSignIn(apiUrl) {
134
+ if (process.env.FORTRIP_TOKEN?.trim()) return true;
135
+ return await loadSession(apiUrl).catch(() => null) !== null;
136
+ }
137
+ function localServer() {
138
+ const server = new McpServer(
139
+ { name: "fortrip", version: CONTRACT_VERSION },
140
+ { instructions: renderGuide("mcp", "main") }
141
+ );
142
+ const handler = () => {
143
+ throw new Error("Fortrip's tools run on the hosted server.");
144
+ };
145
+ for (const spec of TOOLS) {
146
+ const config = {
147
+ title: spec.title,
148
+ description: spec.description,
149
+ annotations: spec.annotations,
150
+ outputSchema: spec.output ?? void 0
151
+ };
152
+ if (spec.input === null) {
153
+ server.registerTool(spec.name, config, handler);
154
+ } else {
155
+ server.registerTool(
156
+ spec.name,
157
+ { ...config, inputSchema: spec.input },
158
+ handler
159
+ );
160
+ }
161
+ }
162
+ return server;
163
+ }
124
164
  async function runMcpBridge(apiUrl, io = {
125
165
  stdin: process.stdin,
126
166
  stdout: process.stdout
127
- }) {
167
+ }, { switchTimeoutMs = SWITCH_TIMEOUT_MS } = {}) {
128
168
  const stderr = io.stderr ?? process.stderr;
129
169
  const stdio = new StdioServerTransport(io.stdin, io.stdout);
130
170
  const http = new StreamableHTTPClientTransport(new URL(API.mcp, apiUrl), {
131
171
  fetch: authorizedFetch(apiUrl)
132
172
  });
173
+ const server = localServer();
174
+ const [local, localServerEnd] = InMemoryTransport.createLinkedPair();
175
+ const signInSaved = await hasSignIn(apiUrl);
133
176
  let closed = false;
177
+ let remote = false;
178
+ let clientInitialize;
179
+ let listChanged = false;
180
+ let received = 0;
181
+ let abandonedThrough = 0;
134
182
  const pending = /* @__PURE__ */ new Set();
135
183
  let initializeId;
184
+ const asked = /* @__PURE__ */ new Map();
136
185
  const log = (message) => {
137
186
  if (!closed) stderr.write(`fortrip mcp: ${message}
138
187
  `);
139
188
  };
189
+ const toClient = (message) => {
190
+ stdio.send(message).catch((cause) => log(cause.message));
191
+ };
140
192
  const fail = (id, error) => {
141
193
  if (closed || !pending.delete(id)) return;
142
- stdio.send({ jsonrpc: "2.0", id, error }).catch((cause) => log(cause.message));
194
+ toClient({ jsonrpc: "2.0", id, error });
143
195
  };
144
- stdio.onmessage = async (message) => {
196
+ const adopt = ({ result }) => {
197
+ if (typeof result.protocolVersion === "string") http.setProtocolVersion(result.protocolVersion);
198
+ };
199
+ const forward = async (message) => {
145
200
  if (!isJSONRPCRequest(message)) {
146
201
  await http.send(message).catch(() => {
147
202
  });
@@ -155,15 +210,107 @@ async function runMcpBridge(apiUrl, io = {
155
210
  fail(message.id, rpcErrorOf(error));
156
211
  }
157
212
  };
213
+ const ask = (request, signal) => {
214
+ let waiter;
215
+ return new Promise((resolve, reject) => {
216
+ waiter = resolve;
217
+ asked.set(request.id, resolve);
218
+ http.send(request, {
219
+ requestSignal: signal,
220
+ onRequestStreamEnd: () => resolve({ jsonrpc: "2.0", id: request.id, error: CONNECTION_LOST })
221
+ }).catch(reject);
222
+ }).finally(() => {
223
+ if (asked.get(request.id) === waiter) asked.delete(request.id);
224
+ });
225
+ };
226
+ const initialize = async (request) => {
227
+ clientInitialize = request;
228
+ if (signInSaved) {
229
+ try {
230
+ const answer = await ask(request);
231
+ if (isJSONRPCResultResponse(answer)) {
232
+ adopt(answer);
233
+ remote = true;
234
+ }
235
+ return toClient(answer);
236
+ } catch (error) {
237
+ if (!(error instanceof SignInRequired)) {
238
+ return toClient({ jsonrpc: "2.0", id: request.id, error: rpcErrorOf(error) });
239
+ }
240
+ }
241
+ }
242
+ await local.send(request);
243
+ };
244
+ const handshake = async (initialize2) => {
245
+ const deadline = new AbortController();
246
+ const timer = setTimeout(() => deadline.abort(), switchTimeoutMs);
247
+ const late = new Promise(
248
+ (resolve) => deadline.signal.addEventListener("abort", () => resolve(void 0))
249
+ );
250
+ try {
251
+ let answer;
252
+ try {
253
+ answer = await Promise.race([ask(initialize2, deadline.signal), late]);
254
+ } catch (error) {
255
+ return rpcErrorOf(error);
256
+ }
257
+ if (answer && !isJSONRPCResultResponse(answer)) return answer.error;
258
+ if (answer) {
259
+ adopt(answer);
260
+ await http.send(
261
+ { jsonrpc: "2.0", method: "notifications/initialized" },
262
+ { requestSignal: deadline.signal }
263
+ ).catch(() => {
264
+ });
265
+ }
266
+ if (!deadline.signal.aborted) return void 0;
267
+ abandonedThrough = received;
268
+ return CONNECTION_LOST;
269
+ } finally {
270
+ clearTimeout(timer);
271
+ }
272
+ };
273
+ const callTool = async (call) => {
274
+ if (clientInitialize) {
275
+ const error = await handshake(clientInitialize);
276
+ if (error) return toClient({ jsonrpc: "2.0", id: call.id, error });
277
+ }
278
+ remote = true;
279
+ void forward(call);
280
+ if (listChanged) toClient({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
281
+ };
282
+ const handle = async (message, seq) => {
283
+ if (closed) return;
284
+ if (remote) return void forward(message);
285
+ if (isJSONRPCRequest(message) && message.method === "initialize") return initialize(message);
286
+ if (isJSONRPCRequest(message) && message.method === "tools/call") {
287
+ if (seq <= abandonedThrough) {
288
+ return toClient({ jsonrpc: "2.0", id: message.id, error: CONNECTION_LOST });
289
+ }
290
+ return callTool(message);
291
+ }
292
+ await local.send(message);
293
+ };
294
+ let inbox = Promise.resolve();
295
+ stdio.onmessage = (message) => {
296
+ const seq = ++received;
297
+ inbox = inbox.then(() => handle(message, seq)).catch((error) => log(error.message));
298
+ };
299
+ local.onmessage = (message) => {
300
+ if (isJSONRPCResultResponse(message) && message.id === clientInitialize?.id) {
301
+ const { capabilities } = message.result;
302
+ listChanged = capabilities?.tools?.listChanged === true;
303
+ }
304
+ toClient(message);
305
+ };
158
306
  http.onmessage = (message) => {
159
307
  if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
308
+ const waiting = message.id === void 0 ? void 0 : asked.get(message.id);
309
+ if (waiting) return waiting(message);
160
310
  if (message.id !== void 0) pending.delete(message.id);
161
- if (isJSONRPCResultResponse(message) && message.id === initializeId) {
162
- const { protocolVersion } = message.result;
163
- if (typeof protocolVersion === "string") http.setProtocolVersion(protocolVersion);
164
- }
311
+ if (isJSONRPCResultResponse(message) && message.id === initializeId) adopt(message);
165
312
  }
166
- stdio.send(message).catch((cause) => log(cause.message));
313
+ toClient(message);
167
314
  };
168
315
  http.onerror = (error) => log(error.message);
169
316
  stdio.onerror = (error) => log(error.message);
@@ -176,13 +323,14 @@ async function runMcpBridge(apiUrl, io = {
176
323
  closed = true;
177
324
  process.off("SIGINT", shutdown);
178
325
  process.off("SIGTERM", shutdown);
179
- await Promise.allSettled([http.close(), stdio.close()]);
326
+ await Promise.allSettled([http.close(), stdio.close(), server.close()]);
180
327
  stopped();
181
328
  };
182
329
  stdio.onclose = shutdown;
183
330
  process.on("SIGINT", shutdown);
184
331
  process.on("SIGTERM", shutdown);
185
332
  await http.start();
333
+ await server.connect(localServerEnd);
186
334
  await stdio.start();
187
335
  return done;
188
336
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fortrip",
3
- "version": "1.0.0",
4
- "license": "MIT",
3
+ "version": "2.0.0",
4
+ "license": "Apache-2.0",
5
5
  "description": "Search, price and book Fortrip hotels from the terminal or an AI agent.",
6
6
  "mcpName": "io.github.FortripEngineering/fortrip",
7
7
  "type": "module",
@@ -15,7 +15,9 @@
15
15
  },
16
16
  "files": [
17
17
  "dist",
18
- "README.md"
18
+ "README.md",
19
+ "NOTICE",
20
+ "THIRD-PARTY-NOTICES"
19
21
  ],
20
22
  "engines": {
21
23
  "node": ">=22"
@@ -30,7 +32,7 @@
30
32
  "tsx": "^4.20.0",
31
33
  "typescript": "^5.9.0",
32
34
  "zod": "4.4.3",
33
- "@fortrip/agent-contract": "1.0.0"
35
+ "@fortrip/agent-contract": "2.0.0"
34
36
  },
35
37
  "scripts": {
36
38
  "build": "tsup",