From 8932afa19e179ed738c05c5dac0dbf53459f43af Mon Sep 17 00:00:00 2001 From: Edoardo Vacchi Date: Wed, 18 Dec 2024 14:29:14 +0100 Subject: [PATCH 1/5] wip Signed-off-by: Edoardo Vacchi --- go.work | 1 + servlets/github/go.mod | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/go.work b/go.work index bef5ba5..f64d1f0 100644 --- a/go.work +++ b/go.work @@ -1,6 +1,7 @@ go 1.23 use ( + ./servlets/amadeus-flight-api ./servlets/crypto-hash ./servlets/github ./servlets/historical-flight-api diff --git a/servlets/github/go.mod b/servlets/github/go.mod index 685f04d..960411f 100755 --- a/servlets/github/go.mod +++ b/servlets/github/go.mod @@ -1,5 +1,5 @@ module github -go 1.22.1 +go 1.23 require github.com/extism/go-pdk v1.0.5 From 9aac333ef7f3463ea2e49d78e8c7f9c57b855688 Mon Sep 17 00:00:00 2001 From: Edoardo Vacchi Date: Wed, 18 Dec 2024 15:36:00 +0100 Subject: [PATCH 2/5] wip Signed-off-by: Edoardo Vacchi --- servlets/amadeus-flight-api/.gitignore | 1 + servlets/amadeus-flight-api/README.md | 3 + servlets/amadeus-flight-api/flights.go | 238 +++++++++++++++++++++++++ servlets/amadeus-flight-api/go.mod | 5 + servlets/amadeus-flight-api/go.sum | 2 + servlets/amadeus-flight-api/main.go | 123 +++++++++++++ servlets/amadeus-flight-api/pdk.gen.go | 221 +++++++++++++++++++++++ servlets/amadeus-flight-api/prepare.sh | 72 ++++++++ servlets/amadeus-flight-api/xtp.toml | 17 ++ 9 files changed, 682 insertions(+) create mode 100644 servlets/amadeus-flight-api/.gitignore create mode 100644 servlets/amadeus-flight-api/README.md create mode 100644 servlets/amadeus-flight-api/flights.go create mode 100755 servlets/amadeus-flight-api/go.mod create mode 100644 servlets/amadeus-flight-api/go.sum create mode 100755 servlets/amadeus-flight-api/main.go create mode 100755 servlets/amadeus-flight-api/pdk.gen.go create mode 100644 servlets/amadeus-flight-api/prepare.sh create mode 100755 servlets/amadeus-flight-api/xtp.toml diff --git a/servlets/amadeus-flight-api/.gitignore b/servlets/amadeus-flight-api/.gitignore new file mode 100644 index 0000000..1521c8b --- /dev/null +++ b/servlets/amadeus-flight-api/.gitignore @@ -0,0 +1 @@ +dist diff --git a/servlets/amadeus-flight-api/README.md b/servlets/amadeus-flight-api/README.md new file mode 100644 index 0000000..b8ae053 --- /dev/null +++ b/servlets/amadeus-flight-api/README.md @@ -0,0 +1,3 @@ +# Amadeus Flight API + +Amadeus is ... diff --git a/servlets/amadeus-flight-api/flights.go b/servlets/amadeus-flight-api/flights.go new file mode 100644 index 0000000..5a37b93 --- /dev/null +++ b/servlets/amadeus-flight-api/flights.go @@ -0,0 +1,238 @@ +package main + +import ( + "fmt" + "net/url" + + pdk "github.com/extism/go-pdk" +) + +var ( + FlightOfferSearchTool = ToolDescription{ + Name: "am-flights-offer-search", + Description: "Search for flight offers. For each itinerary, the API provides a list of flight offers with prices, " + + "fare details, airline names, baggage allowances and departure terminals. " + + "The results can be displayed as a table. Users should be offered to display such a table", + InputSchema: schema{ + "type": "object", + "properties": props{ + "originLocationCode": prop("string", "city/airport IATA code from which the traveler will depart, e.g. BOS for Boston"), + "destinationLocationCode": prop("string", "city/airport IATA code to which the traveler is going, e.g. PAR for Paris"), + "departureDate": prop("string", "the date on which the traveler will depart from the origin to go to the destination. Dates are specified in the ISO 8601 YYYY-MM-DD format, e.g. 2017-12-25"), + "returnDate": prop("string", "the date on which the traveler will depart from the destination to return to the origin. "+ + "If this parameter is not specified, only one-way itineraries are found. "+ + "If this parameter is specified, only round-trip itineraries are found. Dates are specified in the ISO 8601 YYYY-MM-DD format, e.g. 2018-02-28"), + "adults": prop("integer", "the number of adult travelers (age 12 or older on date of departure)."+ + "The total number of seated travelers (adult and children) cannot exceed 9."), + "children": prop("integer", "(optional) The number of children"), + "infants": prop("integer", "(optional) The number of infants"), + "travelClass": prop("string", "most of the flight time should be spent in a cabin of this quality or higher. "+ + "The accepted travel class is economy, premium economy, business or first class. If no travel class is specified, "+ + "the search considers any travel class. Available values : ECONOMY, PREMIUM_ECONOMY, BUSINESS, FIRST"), + "nonStop": prop("boolean", "If set to true, the search will find only flights going from the origin to the destination with no stop in between"), + "currencyCode": prop("string", "The preferred currency for the flight offers. Currency is specified in the ISO 4217 format, e.g. EUR for Euro"), + "maxPrice": prop("integer", "The maximum price of the flight offers. If specified, the value should be a positive number with no decimals"), + "max": prop("integer", "The maximum number of offers to return (default: 10)"), + }, + "required": []string{"originLocationCode", "destinationLocationCode", "departureDate", "adults", "travelClass", "currencyCode"}, + }, + } + + FlightDatesTool = ToolDescription{ + Name: "am-flights-dates", + Description: "Find the cheapest flight dates from an origin to a destination. " + + "The API provides list of flight options with dates and prices, and allows you to order by price, departure date or duration. " + + "The results can be displayed as a table. Users should be offered to display such a table", + InputSchema: schema{ + "type": "object", + "properties": props{ + "origin": prop("string", "city/airport IATA code from which the traveler will depart, e.g. BOS for Boston"), + "destination": prop("string", "city/airport IATA code to which the traveler is going, e.g. PAR for Paris"), + "departureDate": prop("string", "the date on which the traveler will depart from the origin to go to the destination. "+ + "Dates are specified in the ISO 8601 YYYY-MM-DD format, e.g. 2017-12-25. Ranges are specified with a comma and are inclusive, e.g. 2017-12-25,2017-12-30"), + "oneWay": prop("boolean", "one way or round trip. if this parameter is set to true, only one-way flights are considered. If this parameter is not set or set to false, only round-trip flights are considered"), + "duration": prop("string", "exact duration or range of durations of the travel, in days. This parameter must not be set if oneWay is true. Ranges are specified with a comma and are inclusive, e.g. 2,8"), + "nonStop": prop("boolean", "if this parameter is set to true, only flights going from the origin to the destination with no stop in-between are considered. Default value: false"), + "maxPrice": prop("integer", "the maximum price of the flight offers. The value should be a positive number with no decimals"), + "viewBy": prop("string", "view the flight dates by DATE, DURATION, or WEEK. View by DATE (default when oneWay is true) to get the cheapest flight dates for every departure date in the given range. "+ + "View by DURATION (default when oneWay is false) to get the cheapest flight dates for every departure date and for every duration in the given ranges. "+ + "View by WEEK to get the cheapest flight destination for every week in the given range of departure dates. Note that specifying a detailed view but large ranges may result in a huge number of flight dates being returned. "+ + "For some very large numbers of flight dates, the API may refuse to provide a response."+ + "Available values : DATE, DURATION, WEEK"), + }, + "required": []string{"origin", "destination", "departureDate", "maxPrice", "viewBy"}, + }, + } + + FlightInspirationTool = ToolDescription{ + Name: "am-flights-inspiration", + Description: "Find the cheapest destinations where you can fly to." + + "The Flight Inspiration Search API provides a list of destinations from a given city that is ordered by price and can be filtered by departure date or maximum price" + + "The results can be displayed as a table. Users should be offered to display such a table", + InputSchema: schema{ + "type": "object", + "properties": props{ + "origin": prop("string", "IATA code of the city from which the flight will depart"), + "departureDate": prop("string", "The date, or range of dates, on which the flight will depart from the origin. "+ + "Dates are specified in the ISO 8601 YYYY-MM-DD format, e.g. 2017-12-25. Ranges are specified with a comma and are inclusive. "+ + "Departure date can not be more than 180 days in the future."), + "oneWay": prop("boolean", "if this parameter is set to true, only one-way flights are considered. "+ + "If this parameter is not set or set to false, only round-trip flights are considered"), + "duration": prop("string", "Exact duration or range of durations of the travel, in days. "+ + "This parameter must not be set if oneWay is true. Ranges are specified with a comma and are inclusive, e.g. 2,8. "+ + "Duration can not be lower than 1 days or higher than 15 days"), + "nonStop": prop("boolean", "if this parameter is set to true, only flights going from the origin to the destination with no stop in-between are considered. "+ + "Default value: false"), + "maxPrice": prop("integer", "defines the price limit for each offer returned. The value should be a positive number, without decimals"), + "viewBy": prop("string", "view the flight destinations by DATE, DESTINATION, DURATION, WEEK, or COUNTRY. "+ + "View by DATE (default when oneWay is true) to get the cheapest flight destination for every departure date in the given range. "+ + "View by DURATION (default when oneWay is false) to get the cheapest flight destination for every departure date and for every duration in the given ranges. "+ + "View by WEEK to get the cheapest flight destination for every week in the given range of departure dates. "+ + "View by COUNTRY to get the cheapest flight destination by country. Note that specifying a detailed view but large ranges may result in a huge number of flight destinations being returned. "+ + "For some very large numbers of flight destinations, the API may refuse to provide a response"), + }, + }, + } + + FlightsTools = []ToolDescription{ + FlightOfferSearchTool, FlightDatesTool, FlightInspirationTool, + } +) + +// https://developers.amadeus.com/self-service/category/flights/api-doc/flight-offers-search +func flightOfferSearch(args map[string]any) CallToolResult { + const endpoint = "/v2/shopping/flight-offers" + + q := url.Values{} + q.Set("originLocationCode", args["originLocationCode"].(string)) + q.Set("destinationLocationCode", args["destinationLocationCode"].(string)) + q.Set("departureDate", args["departureDate"].(string)) + q.Set("returnDate", args["returnDate"].(string)) + q.Set("adults", fmt.Sprint(args["adults"])) + if children, hasChildren := args["children"]; hasChildren { + q.Set("children", fmt.Sprint(children)) + } + if infants, hasInfants := args["infants"]; hasInfants { + q.Set("infants", fmt.Sprint(infants)) + } + q.Set("travelClass", args["travelClass"].(string)) + q.Set("nonStop", fmt.Sprint(args["nonStop"])) + q.Set("currencyCode", args["currencyCode"].(string)) + if maxPrice, hasMaxPrice := args["maxPrice"]; hasMaxPrice { + q.Set("maxPrice", fmt.Sprint(maxPrice)) + } + if max, hasMax := args["max"]; hasMax { + q.Set("max", fmt.Sprint(max)) + } + + req := pdk.NewHTTPRequest(pdk.MethodGet, config.baseUrl+endpoint+"?"+q.Encode()) + req.SetHeader("Authorization", "Bearer "+config.token) + req.SetHeader("Accept", "application/json") + resp := req.Send() + + if resp.Status() != 200 { + return callToolError("error while searching for flight offers") + } + + return callToolSuccess(string(resp.Body())) +} + +// https://developers.amadeus.com/self-service/category/flights/api-doc/flight-cheapest-date-search/api-reference +func flightDates(args map[string]any) CallToolResult { + const endpoint = "/v1/shopping/flight-dates" + + q := url.Values{} + + // origin IATA + q.Add("origin", args["origin"].(string)) + // destination IATA + q.Add("destination", args["destination"].(string)) + // departure date + q.Add("departureDate", args["departureDate"].(string)) + // one way or round trip + if oneWay, hasOneWay := args["oneWay"]; hasOneWay { + q.Add("oneWay", fmt.Sprint(oneWay)) + } + // exact duration or range of durations of the travel, in days. + // This parameter must not be set if oneWay is true. + // Ranges are specified with a comma and are inclusive, e.g. 2,8 + if duration, hasDuration := args["duration"]; hasDuration { + q.Add("duration", fmt.Sprint(duration)) + } + // direct flights only + if nonStop, hasNonStop := args["nonStop"]; hasNonStop { + q.Add("nonStop", fmt.Sprint(nonStop)) + } + // max price + q.Add("maxPrice", fmt.Sprint(args["maxPrice"])) + // view the flight dates by DATE, DURATION, or WEEK. + // View by DATE (default when oneWay is true) to get the cheapest flight dates for every departure date in the given range. + // View by DURATION (default when oneWay is false) to get the cheapest flight dates for every departure date and + // for every duration in the given ranges. View by WEEK to get the cheapest flight destination for every week + // in the given range of departure dates. + // Note that specifying a detailed view but large ranges may result in a huge number of flight dates being returned. + // For some very large numbers of flight dates, the API may refuse to provide a response + // + // Available values : DATE, DURATION, WEEK + q.Add("viewBy", args["viewBy"].(string)) + + req := pdk.NewHTTPRequest(pdk.MethodGet, config.baseUrl+endpoint+"?"+q.Encode()) + req.SetHeader("Authorization", "Bearer "+config.token) + req.SetHeader("Accept", "application/json") + resp := req.Send() + + if resp.Status() != 200 { + return callToolError("error while searching for flight dates " + string(resp.Body())) + } + + return callToolSuccess(string(resp.Body())) +} + +// https://developers.amadeus.com/self-service/category/flights/api-doc/flight-inspiration-search/api-reference +func flightInspiration(args map[string]any) CallToolResult { + const endpoint = "/v1/shopping/flight-destinations" + + q := url.Values{} + + // origin IATA + q.Add("origin", args["origin"].(string)) + // departure date + q.Add("departureDate", args["departureDate"].(string)) + // one way or round trip + if oneWay, hasOneWay := args["oneWay"]; hasOneWay { + q.Add("oneWay", fmt.Sprint(oneWay)) + } + // exact duration or range of durations of the travel, in days. + // This parameter must not be set if oneWay is true. + // Ranges are specified with a comma and are inclusive, e.g. 2,8 + if duration, hasDuration := args["duration"]; hasDuration { + q.Add("duration", fmt.Sprint(duration)) + } + // direct flights only + if nonStop, hasNonStop := args["nonStop"]; hasNonStop { + q.Add("nonStop", fmt.Sprint(nonStop)) + } + // max price + q.Add("maxPrice", fmt.Sprint(args["maxPrice"])) + // view the flight destinations by DATE, DESTINATION, DURATION, WEEK, or COUNTRY. + // View by DATE (default when oneWay is true) to get the cheapest flight destination for every departure date in the given range. + // View by DURATION (default when oneWay is false) to get the cheapest flight destination for every departure date and + // for every duration in the given ranges. View by WEEK to get the cheapest flight destination for every week + // in the given range of departure dates. View by COUNTRY to get the cheapest flight destination by country. + // Note that specifying a detailed view but large ranges may result in a huge number of flight destinations being returned. + // For some very large numbers of flight destinations, the API may refuse to provide a response + // + // Available values : DATE, DURATION, WEEK + q.Add("viewBy", args["viewBy"].(string)) + + req := pdk.NewHTTPRequest(pdk.MethodGet, config.baseUrl+endpoint+"?"+q.Encode()) + req.SetHeader("Authorization", "Bearer "+config.token) + req.SetHeader("Accept", "application/json") + resp := req.Send() + + if resp.Status() != 200 { + return callToolError("error while searching for flight inspiration " + string(resp.Body())) + } + + return callToolSuccess(string(resp.Body())) +} diff --git a/servlets/amadeus-flight-api/go.mod b/servlets/amadeus-flight-api/go.mod new file mode 100755 index 0000000..b7cf81e --- /dev/null +++ b/servlets/amadeus-flight-api/go.mod @@ -0,0 +1,5 @@ +module amadeus-flight-api + +go 1.23 + +require github.com/extism/go-pdk v1.0.5 diff --git a/servlets/amadeus-flight-api/go.sum b/servlets/amadeus-flight-api/go.sum new file mode 100644 index 0000000..e3bfb8b --- /dev/null +++ b/servlets/amadeus-flight-api/go.sum @@ -0,0 +1,2 @@ +github.com/extism/go-pdk v1.0.5 h1:5d5yYkWBweBP84Z+H3DP5DsD0fwvf2anWXyypCXpSW8= +github.com/extism/go-pdk v1.0.5/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= diff --git a/servlets/amadeus-flight-api/main.go b/servlets/amadeus-flight-api/main.go new file mode 100755 index 0000000..ef22792 --- /dev/null +++ b/servlets/amadeus-flight-api/main.go @@ -0,0 +1,123 @@ +// Note: run `go doc -all` in this package to see all of the types and functions available. +// ./pdk.gen.go contains the domain types from the host where your plugin will run. +package main + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/extism/go-pdk" +) + +var ( + config = Config{} +) + +// Called when the tool is invoked. +// If you support multiple tools, you must switch on the input.params.name to detect which tool is being called. +// It takes CallToolRequest as input (The incoming tool request from the LLM) +// And returns CallToolResult (The servlet's response to the given tool call) +func Call(input CallToolRequest) (CallToolResult, error) { + if err := loadConfig(); err != nil { + return callToolError(err.Error()), nil + } + refreshToken() + args := input.Params.Arguments.(map[string]any) + switch input.Params.Name { + case FlightOfferSearchTool.Name: + return flightOfferSearch(args), nil + case FlightDatesTool.Name: + return flightDates(args), nil + case FlightInspirationTool.Name: + return flightInspiration(args), nil + default: + return CallToolResult{}, fmt.Errorf("unknown tool name %q", input.Params.Name) + } +} + +// Called by mcpx to understand how and why to use this tool. +// Note: Your servlet configs will not be set when this function is called, +// so do not rely on config in this function +// And returns ListToolsResult (The tools' descriptions, supporting multiple tools from a single servlet.) +func Describe() (ListToolsResult, error) { + return ListToolsResult{ + Tools: FlightsTools, + }, nil +} + +func loadConfig() error { + if config.apiKey != "" && config.apiSecret != "" { + return nil + } + + var okU, okK, okS bool + config.baseUrl, okU = pdk.GetConfig("base-url") + config.apiKey, okK = pdk.GetConfig("api-key") + config.apiSecret, okS = pdk.GetConfig("api-secret") + if !okU || !okK || !okS { + return errors.New("missing required configuration") + } + + return nil +} + +func refreshToken() { + if time.Now().UTC().Unix() > config.expiration { + req := pdk.NewHTTPRequest(pdk.MethodPost, config.baseUrl+"/v1/security/oauth2/token") + req.SetHeader("Content-Type", "application/x-www-form-urlencoded") + form := "grant_type=client_credentials&client_id=" + config.apiKey + "&client_secret=" + config.apiSecret + pdk.Log(pdk.LogDebug, form) + req.SetBody([]byte(form)) + resp := req.Send() + if resp.Status() == 200 { + var res map[string]any + json.Unmarshal(resp.Body(), &res) + config.token = res["access_token"].(string) + config.expiration = time.Now().UTC().Unix() + int64(res["expires_in"].(float64)) + } + } +} + +func some[T any](t T) *T { + return &t +} + +type SchemaProperty struct { + Type string `json:"type"` + Description string `json:"description,omitempty"` + Items *schema `json:"items,omitempty"` +} + +func prop(tpe, description string) SchemaProperty { + return SchemaProperty{Type: tpe, Description: description} +} + +func arrprop(tpe, description, itemstpe string) SchemaProperty { + items := schema{"type": itemstpe} + return SchemaProperty{Type: tpe, Description: description, Items: &items} +} + +type schema = map[string]any +type props = map[string]SchemaProperty + +func callToolSuccess(msg string) (res CallToolResult) { + res.Content = []Content{{Type: ContentTypeText, Text: some(msg)}} + return +} + +func callToolError(msg string) (res CallToolResult) { + res.IsError = some(true) + res.Content = []Content{{Type: ContentTypeText, Text: some(msg)}} + return +} + +type Config struct { + // grant_type=client_credentials&client_id={client_id}&client_secret={client_secret} + baseUrl string + apiKey string + apiSecret string + token string + expiration int64 +} diff --git a/servlets/amadeus-flight-api/pdk.gen.go b/servlets/amadeus-flight-api/pdk.gen.go new file mode 100755 index 0000000..712f74e --- /dev/null +++ b/servlets/amadeus-flight-api/pdk.gen.go @@ -0,0 +1,221 @@ +// THIS FILE WAS GENERATED BY `xtp-go-bindgen`. DO NOT EDIT. +package main + +import ( + "errors" + + pdk "github.com/extism/go-pdk" +) + +//export call +func _Call() int32 { + var err error + _ = err + pdk.Log(pdk.LogDebug, "Call: getting JSON input") + var input CallToolRequest + err = pdk.InputJSON(&input) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "Call: calling implementation function") + output, err := Call(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "Call: setting JSON output") + err = pdk.OutputJSON(output) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "Call: returning") + return 0 +} + +//export describe +func _Describe() int32 { + var err error + _ = err + output, err := Describe() + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "Describe: setting JSON output") + err = pdk.OutputJSON(output) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "Describe: returning") + return 0 +} + +// +type BlobResourceContents struct { + // A base64-encoded string representing the binary data of the item. + Blob string `json:"blob"` + // The MIME type of this resource, if known. + MimeType *string `json:"mimeType,omitempty"` + // The URI of this resource. + Uri string `json:"uri"` +} + +// Used by the client to invoke a tool provided by the server. +type CallToolRequest struct { + Method *string `json:"method,omitempty"` + Params Params `json:"params"` +} + +// The server's response to a tool call. +// +// Any errors that originate from the tool SHOULD be reported inside the result +// object, with `isError` set to true, _not_ as an MCP protocol-level error +// response. Otherwise, the LLM would not be able to see that an error occurred +// and self-correct. +// +// However, any errors in _finding_ the tool, an error indicating that the +// server does not support tool calls, or any other exceptional conditions, +// should be reported as an MCP error response. +type CallToolResult struct { + Content []Content `json:"content"` + // Whether the tool call ended in an error. + // + // If not set, this is assumed to be false (the call was successful). + IsError *bool `json:"isError,omitempty"` +} + +// A content response. +// For text content set type to ContentType.Text and set the `text` property +// For image content set type to ContentType.Image and set the `data` and `mimeType` properties +type Content struct { + Annotations *TextAnnotation `json:"annotations,omitempty"` + // The base64-encoded image data. + Data *string `json:"data,omitempty"` + // The MIME type of the image. Different providers may support different image types. + MimeType *string `json:"mimeType,omitempty"` + // The text content of the message. + Text *string `json:"text,omitempty"` + Type ContentType `json:"type"` +} + +// +type ContentType string + +const ( + ContentTypeText ContentType = "text" + ContentTypeImage ContentType = "image" + ContentTypeResource ContentType = "resource" +) + +func (v ContentType) String() string { + switch v { + case ContentTypeText: + return `text` + case ContentTypeImage: + return `image` + case ContentTypeResource: + return `resource` + default: + return "" + } +} + +func stringToContentType(s string) (ContentType, error) { + switch s { + case `text`: + return ContentTypeText, nil + case `image`: + return ContentTypeImage, nil + case `resource`: + return ContentTypeResource, nil + default: + return ContentType(""), errors.New("unable to convert string to ContentType") + } +} + +// Provides one or more descriptions of the tools available in this servlet. +type ListToolsResult struct { + // The list of ToolDescription objects provided by this servlet. + Tools []ToolDescription `json:"tools"` +} + +// +type Params struct { + Arguments interface{} `json:"arguments,omitempty"` + Name string `json:"name"` +} + +// The sender or recipient of messages and data in a conversation. +type Role string + +const ( + RoleAssistant Role = "assistant" + RoleUser Role = "user" +) + +func (v Role) String() string { + switch v { + case RoleAssistant: + return `assistant` + case RoleUser: + return `user` + default: + return "" + } +} + +func stringToRole(s string) (Role, error) { + switch s { + case `assistant`: + return RoleAssistant, nil + case `user`: + return RoleUser, nil + default: + return Role(""), errors.New("unable to convert string to Role") + } +} + +// A text annotation +type TextAnnotation struct { + // Describes who the intended customer of this object or data is. + // + // It can include multiple entries to indicate content useful for multiple audiences (e.g., `["user", "assistant"]`). + Audience []Role `json:"audience,omitempty"` + // Describes how important this data is for operating the server. + // + // A value of 1 means "most important," and indicates that the data is + // effectively required, while 0 means "least important," and indicates that + // the data is entirely optional. + Priority float32 `json:"priority,omitempty"` +} + +// +type TextResourceContents struct { + // The MIME type of this resource, if known. + MimeType *string `json:"mimeType,omitempty"` + // The text of the item. This must only be set if the item can actually be represented as text (not binary data). + Text string `json:"text"` + // The URI of this resource. + Uri string `json:"uri"` +} + +// Describes the capabilities and expected paramters of the tool function +type ToolDescription struct { + // A description of the tool + Description string `json:"description"` + // The JSON schema describing the argument input + InputSchema interface{} `json:"inputSchema"` + // The name of the tool. It should match the plugin / binding name. + Name string `json:"name"` +} + +// Note: leave this in place, as the Go compiler will find the `export` function as the entrypoint. +func main() {} diff --git a/servlets/amadeus-flight-api/prepare.sh b/servlets/amadeus-flight-api/prepare.sh new file mode 100644 index 0000000..ec08baf --- /dev/null +++ b/servlets/amadeus-flight-api/prepare.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +# Function to check if a command exists +command_exists () { + command -v "$1" >/dev/null 2>&1 +} + +missing_deps=0 + +# Check for Go +if ! (command_exists go); then + missing_deps=1 + echo "❌ Go (supported version between 1.18 - 1.22) is not installed." + echo "" + echo "To install Go, visit the official download page:" + echo "👉 https://go.dev/dl/" + echo "" + echo "Or install it using a package manager:" + echo "" + echo "🔹 macOS (Homebrew):" + echo " brew install go" + echo "" + echo "🔹 Ubuntu/Debian:" + echo " sudo apt-get -y install golang-go" + echo "" + echo "🔹 Arch Linux:" + echo " sudo pacman -S go" + echo "" +fi + +# Check for the right version of Go, needed by TinyGo (supports go 1.18 - 1.22) +if (command_exists go); then + compat=0 + for v in `seq 18 22`; do + if (go version | grep -q "go1.$v"); then + compat=1 + fi + done + + if [ $compat -eq 0 ]; then + echo "❌ Supported Go version is not installed. Must be Go 1.18 - 1.22." + echo "" + fi +fi + + +ARCH=$(arch) + +# Check for TinyGo +if ! (command_exists tinygo); then + missing_deps=1 + echo "❌ TinyGo is not installed." + echo "" + echo "To install TinyGo, visit the official download page:" + echo "👉 https://tinygo.org/getting-started/install/" + echo "" + echo "Or install it using a package manager:" + echo "" + echo "🔹 macOS (Homebrew):" + echo " brew tap tinygo-org/tools" + echo " brew install tinygo" + echo "" + echo "🔹 Ubuntu/Debian:" + echo " wget https://github.com/tinygo-org/tinygo/releases/download/v0.31.2/tinygo_0.31.2_$ARCH.deb" + echo " sudo dpkg -i tinygo_0.31.2_$ARCH.deb" + echo "" + echo "🔹 Arch Linux:" + echo " pacman -S extra/tinygo" + echo "" +fi + +go install golang.org/x/tools/cmd/goimports@latest diff --git a/servlets/amadeus-flight-api/xtp.toml b/servlets/amadeus-flight-api/xtp.toml new file mode 100755 index 0000000..dc0a988 --- /dev/null +++ b/servlets/amadeus-flight-api/xtp.toml @@ -0,0 +1,17 @@ +app_id = "app_01je4dgpcyfvgrz8f1ys3pbxas" + +# This is where 'xtp plugin push' expects to find the wasm file after the build script has run. +bin = "dist/plugin.wasm" +extension_point_id = "ext_01je4jj1tteaktf0zd0anm8854" +name = "amadeus-flight-api" + +[scripts] + + # xtp plugin build runs this script to generate the wasm file + build = "mkdir -p dist && tinygo build -target wasi -o dist/plugin.wasm ." + + # xtp plugin init runs this script to format the plugin code + format = "go fmt && go mod tidy && goimports -w main.go" + + # xtp plugin init runs this script before running the format script + prepare = "sh prepare.sh && go get ./..." From 992d4b04a94e24094f5503749f33edc9935fa48b Mon Sep 17 00:00:00 2001 From: Edoardo Vacchi Date: Wed, 18 Dec 2024 16:02:21 +0100 Subject: [PATCH 3/5] keep only the flight offer endpoint, update readme Signed-off-by: Edoardo Vacchi --- servlets/amadeus-flight-api/README.md | 75 ++++++++++- servlets/amadeus-flight-api/flights.go | 164 +------------------------ servlets/amadeus-flight-api/main.go | 4 - 3 files changed, 79 insertions(+), 164 deletions(-) diff --git a/servlets/amadeus-flight-api/README.md b/servlets/amadeus-flight-api/README.md index b8ae053..5a7273d 100644 --- a/servlets/amadeus-flight-api/README.md +++ b/servlets/amadeus-flight-api/README.md @@ -1,3 +1,76 @@ # Amadeus Flight API -Amadeus is ... +Amadeus is a multinational tech company that provides software for the global travel and tourism industry. + +The company's core business involves connecting travel providers (like airlines, hotels, and car rental companies) with travel sellers (such as travel agencies and websites). Their systems help manage reservations, inventory, and departure control for airlines, while also providing booking capabilities for travel agents and online travel platforms. + +One of Amadeus's key strengths is its flight booking system, which processes hundreds of millions of bookings annually. They also provide important technological infrastructure that powers many airline websites and reservation systems worldwide. + +This servlet makes available the [Flight Offer Search endpoint](https://developers.amadeus.com/self-service/category/flights/api-doc/flight-offers-search) as a tool. + +NOTE: Depending on the credentials and host you will use, you might get real-time or simulated data. + +You can get free development access to the test endpoints by creating an account at https://developers.amadeus.com/register + +## Config + +- `base-url`: the base URL of the API. For the test system use `https://test.api.amadeus.com` +- `api-key`: your self-service or production API key +- `api-secret`: your self-service or production API Secret + +## Hosts + +- You should allow access to `*.amadeus.com` or at least `test.api.amadeus.com` + +## Example: + +— hi Claude I'd like to spend my holidays in Paris, can you find me a flight from Milan Malpensa on Xmas eve, and return on January new year's day? + + +
+I'll help you search for flights from Milan Malpensa (MXP) to Paris for the holiday season. + +I've found several flight options for your Christmas holiday trip to Paris. All flights are operated by Lufthansa and include a stopover in Munich. Here are the best options: + +1. **Afternoon Departure (13:15)** + - Outbound: Dec 24, MXP 13:15 → CDG 17:25 (4h 10m total) + - Return: Jan 1, CDG 09:25 → MXP 12:35 (3h 10m total) + - Price: €241.32 + +2. **Early Morning Departure (06:00)** + - Outbound: Dec 24, MXP 06:00 → CDG 11:30 (5h 30m total) + - Return: Jan 1, CDG 09:25 → MXP 12:35 (3h 10m total) + - Price: €241.32 + +All flights include: +- 1 checked bag +- Standard seat reservation +- Option to purchase meals +- Possibility to change flights (with fee) + +Would you like to know more details about any of these options or would you like me to search for different times? +
+ +— is there any non-stop option? + +
+Let me search for direct flights between Milan Malpensa and Paris. + +Yes, I found several direct flight options! You have choices between Air France (flying to Charles de Gaulle - CDG) and Vueling (flying to Orly - ORY). Here are the best options: + +1. **Air France to CDG** + - Outbound: Dec 24, MXP 18:40 → CDG 20:15 (1h 35m) + - Return: Jan 1, CDG 09:05 → MXP 10:35 (1h 30m) + - Price: €247.16 + - Includes: 1 checked bag, snack, beverage, and standard seat selection + +2. **Vueling to ORY** + - Outbound: Dec 24, MXP 20:45 → ORY 22:15 (1h 30m) + - Return: Jan 1, ORY 06:40 → MXP 08:10 (1h 30m) + - Price: €250.62 + - Includes: 25kg checked baggage allowance + +These direct flights are only slightly more expensive than the connecting flights (around €5-10 more) but save you 2-4 hours of travel time each way. Would you like me to provide more details about any of these options? + +Note that the Air France flight arrives at Charles de Gaulle (CDG), which is connected to central Paris by RER B train, while the Vueling flight uses Orly (ORY), which is typically closer to southern Paris and connected by the Orlyval shuttle to RER B. +
\ No newline at end of file diff --git a/servlets/amadeus-flight-api/flights.go b/servlets/amadeus-flight-api/flights.go index 5a37b93..bc524a5 100644 --- a/servlets/amadeus-flight-api/flights.go +++ b/servlets/amadeus-flight-api/flights.go @@ -38,64 +38,8 @@ var ( }, } - FlightDatesTool = ToolDescription{ - Name: "am-flights-dates", - Description: "Find the cheapest flight dates from an origin to a destination. " + - "The API provides list of flight options with dates and prices, and allows you to order by price, departure date or duration. " + - "The results can be displayed as a table. Users should be offered to display such a table", - InputSchema: schema{ - "type": "object", - "properties": props{ - "origin": prop("string", "city/airport IATA code from which the traveler will depart, e.g. BOS for Boston"), - "destination": prop("string", "city/airport IATA code to which the traveler is going, e.g. PAR for Paris"), - "departureDate": prop("string", "the date on which the traveler will depart from the origin to go to the destination. "+ - "Dates are specified in the ISO 8601 YYYY-MM-DD format, e.g. 2017-12-25. Ranges are specified with a comma and are inclusive, e.g. 2017-12-25,2017-12-30"), - "oneWay": prop("boolean", "one way or round trip. if this parameter is set to true, only one-way flights are considered. If this parameter is not set or set to false, only round-trip flights are considered"), - "duration": prop("string", "exact duration or range of durations of the travel, in days. This parameter must not be set if oneWay is true. Ranges are specified with a comma and are inclusive, e.g. 2,8"), - "nonStop": prop("boolean", "if this parameter is set to true, only flights going from the origin to the destination with no stop in-between are considered. Default value: false"), - "maxPrice": prop("integer", "the maximum price of the flight offers. The value should be a positive number with no decimals"), - "viewBy": prop("string", "view the flight dates by DATE, DURATION, or WEEK. View by DATE (default when oneWay is true) to get the cheapest flight dates for every departure date in the given range. "+ - "View by DURATION (default when oneWay is false) to get the cheapest flight dates for every departure date and for every duration in the given ranges. "+ - "View by WEEK to get the cheapest flight destination for every week in the given range of departure dates. Note that specifying a detailed view but large ranges may result in a huge number of flight dates being returned. "+ - "For some very large numbers of flight dates, the API may refuse to provide a response."+ - "Available values : DATE, DURATION, WEEK"), - }, - "required": []string{"origin", "destination", "departureDate", "maxPrice", "viewBy"}, - }, - } - - FlightInspirationTool = ToolDescription{ - Name: "am-flights-inspiration", - Description: "Find the cheapest destinations where you can fly to." + - "The Flight Inspiration Search API provides a list of destinations from a given city that is ordered by price and can be filtered by departure date or maximum price" + - "The results can be displayed as a table. Users should be offered to display such a table", - InputSchema: schema{ - "type": "object", - "properties": props{ - "origin": prop("string", "IATA code of the city from which the flight will depart"), - "departureDate": prop("string", "The date, or range of dates, on which the flight will depart from the origin. "+ - "Dates are specified in the ISO 8601 YYYY-MM-DD format, e.g. 2017-12-25. Ranges are specified with a comma and are inclusive. "+ - "Departure date can not be more than 180 days in the future."), - "oneWay": prop("boolean", "if this parameter is set to true, only one-way flights are considered. "+ - "If this parameter is not set or set to false, only round-trip flights are considered"), - "duration": prop("string", "Exact duration or range of durations of the travel, in days. "+ - "This parameter must not be set if oneWay is true. Ranges are specified with a comma and are inclusive, e.g. 2,8. "+ - "Duration can not be lower than 1 days or higher than 15 days"), - "nonStop": prop("boolean", "if this parameter is set to true, only flights going from the origin to the destination with no stop in-between are considered. "+ - "Default value: false"), - "maxPrice": prop("integer", "defines the price limit for each offer returned. The value should be a positive number, without decimals"), - "viewBy": prop("string", "view the flight destinations by DATE, DESTINATION, DURATION, WEEK, or COUNTRY. "+ - "View by DATE (default when oneWay is true) to get the cheapest flight destination for every departure date in the given range. "+ - "View by DURATION (default when oneWay is false) to get the cheapest flight destination for every departure date and for every duration in the given ranges. "+ - "View by WEEK to get the cheapest flight destination for every week in the given range of departure dates. "+ - "View by COUNTRY to get the cheapest flight destination by country. Note that specifying a detailed view but large ranges may result in a huge number of flight destinations being returned. "+ - "For some very large numbers of flight destinations, the API may refuse to provide a response"), - }, - }, - } - FlightsTools = []ToolDescription{ - FlightOfferSearchTool, FlightDatesTool, FlightInspirationTool, + FlightOfferSearchTool, } ) @@ -116,7 +60,9 @@ func flightOfferSearch(args map[string]any) CallToolResult { q.Set("infants", fmt.Sprint(infants)) } q.Set("travelClass", args["travelClass"].(string)) - q.Set("nonStop", fmt.Sprint(args["nonStop"])) + if nonStop, hasNonStop := args["nonStop"]; hasNonStop { + q.Set("nonStop", fmt.Sprint(nonStop)) + } q.Set("currencyCode", args["currencyCode"].(string)) if maxPrice, hasMaxPrice := args["maxPrice"]; hasMaxPrice { q.Set("maxPrice", fmt.Sprint(maxPrice)) @@ -131,107 +77,7 @@ func flightOfferSearch(args map[string]any) CallToolResult { resp := req.Send() if resp.Status() != 200 { - return callToolError("error while searching for flight offers") - } - - return callToolSuccess(string(resp.Body())) -} - -// https://developers.amadeus.com/self-service/category/flights/api-doc/flight-cheapest-date-search/api-reference -func flightDates(args map[string]any) CallToolResult { - const endpoint = "/v1/shopping/flight-dates" - - q := url.Values{} - - // origin IATA - q.Add("origin", args["origin"].(string)) - // destination IATA - q.Add("destination", args["destination"].(string)) - // departure date - q.Add("departureDate", args["departureDate"].(string)) - // one way or round trip - if oneWay, hasOneWay := args["oneWay"]; hasOneWay { - q.Add("oneWay", fmt.Sprint(oneWay)) - } - // exact duration or range of durations of the travel, in days. - // This parameter must not be set if oneWay is true. - // Ranges are specified with a comma and are inclusive, e.g. 2,8 - if duration, hasDuration := args["duration"]; hasDuration { - q.Add("duration", fmt.Sprint(duration)) - } - // direct flights only - if nonStop, hasNonStop := args["nonStop"]; hasNonStop { - q.Add("nonStop", fmt.Sprint(nonStop)) - } - // max price - q.Add("maxPrice", fmt.Sprint(args["maxPrice"])) - // view the flight dates by DATE, DURATION, or WEEK. - // View by DATE (default when oneWay is true) to get the cheapest flight dates for every departure date in the given range. - // View by DURATION (default when oneWay is false) to get the cheapest flight dates for every departure date and - // for every duration in the given ranges. View by WEEK to get the cheapest flight destination for every week - // in the given range of departure dates. - // Note that specifying a detailed view but large ranges may result in a huge number of flight dates being returned. - // For some very large numbers of flight dates, the API may refuse to provide a response - // - // Available values : DATE, DURATION, WEEK - q.Add("viewBy", args["viewBy"].(string)) - - req := pdk.NewHTTPRequest(pdk.MethodGet, config.baseUrl+endpoint+"?"+q.Encode()) - req.SetHeader("Authorization", "Bearer "+config.token) - req.SetHeader("Accept", "application/json") - resp := req.Send() - - if resp.Status() != 200 { - return callToolError("error while searching for flight dates " + string(resp.Body())) - } - - return callToolSuccess(string(resp.Body())) -} - -// https://developers.amadeus.com/self-service/category/flights/api-doc/flight-inspiration-search/api-reference -func flightInspiration(args map[string]any) CallToolResult { - const endpoint = "/v1/shopping/flight-destinations" - - q := url.Values{} - - // origin IATA - q.Add("origin", args["origin"].(string)) - // departure date - q.Add("departureDate", args["departureDate"].(string)) - // one way or round trip - if oneWay, hasOneWay := args["oneWay"]; hasOneWay { - q.Add("oneWay", fmt.Sprint(oneWay)) - } - // exact duration or range of durations of the travel, in days. - // This parameter must not be set if oneWay is true. - // Ranges are specified with a comma and are inclusive, e.g. 2,8 - if duration, hasDuration := args["duration"]; hasDuration { - q.Add("duration", fmt.Sprint(duration)) - } - // direct flights only - if nonStop, hasNonStop := args["nonStop"]; hasNonStop { - q.Add("nonStop", fmt.Sprint(nonStop)) - } - // max price - q.Add("maxPrice", fmt.Sprint(args["maxPrice"])) - // view the flight destinations by DATE, DESTINATION, DURATION, WEEK, or COUNTRY. - // View by DATE (default when oneWay is true) to get the cheapest flight destination for every departure date in the given range. - // View by DURATION (default when oneWay is false) to get the cheapest flight destination for every departure date and - // for every duration in the given ranges. View by WEEK to get the cheapest flight destination for every week - // in the given range of departure dates. View by COUNTRY to get the cheapest flight destination by country. - // Note that specifying a detailed view but large ranges may result in a huge number of flight destinations being returned. - // For some very large numbers of flight destinations, the API may refuse to provide a response - // - // Available values : DATE, DURATION, WEEK - q.Add("viewBy", args["viewBy"].(string)) - - req := pdk.NewHTTPRequest(pdk.MethodGet, config.baseUrl+endpoint+"?"+q.Encode()) - req.SetHeader("Authorization", "Bearer "+config.token) - req.SetHeader("Accept", "application/json") - resp := req.Send() - - if resp.Status() != 200 { - return callToolError("error while searching for flight inspiration " + string(resp.Body())) + return callToolError("error while searching for flight offers " + string(resp.Body())) } return callToolSuccess(string(resp.Body())) diff --git a/servlets/amadeus-flight-api/main.go b/servlets/amadeus-flight-api/main.go index ef22792..330c895 100755 --- a/servlets/amadeus-flight-api/main.go +++ b/servlets/amadeus-flight-api/main.go @@ -28,10 +28,6 @@ func Call(input CallToolRequest) (CallToolResult, error) { switch input.Params.Name { case FlightOfferSearchTool.Name: return flightOfferSearch(args), nil - case FlightDatesTool.Name: - return flightDates(args), nil - case FlightInspirationTool.Name: - return flightInspiration(args), nil default: return CallToolResult{}, fmt.Errorf("unknown tool name %q", input.Params.Name) } From 60840ac67bc228266ade86ec53d1beb7681c440f Mon Sep 17 00:00:00 2001 From: Edoardo Vacchi Date: Thu, 19 Dec 2024 20:02:59 +0100 Subject: [PATCH 4/5] make the returnDate field optional, travelClass case-insensitive Signed-off-by: Edoardo Vacchi --- servlets/amadeus-flight-api/flights.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/servlets/amadeus-flight-api/flights.go b/servlets/amadeus-flight-api/flights.go index bc524a5..8920a8f 100644 --- a/servlets/amadeus-flight-api/flights.go +++ b/servlets/amadeus-flight-api/flights.go @@ -3,6 +3,7 @@ package main import ( "fmt" "net/url" + "strings" pdk "github.com/extism/go-pdk" ) @@ -51,7 +52,9 @@ func flightOfferSearch(args map[string]any) CallToolResult { q.Set("originLocationCode", args["originLocationCode"].(string)) q.Set("destinationLocationCode", args["destinationLocationCode"].(string)) q.Set("departureDate", args["departureDate"].(string)) - q.Set("returnDate", args["returnDate"].(string)) + if returnDate, hasReturnDate := args["returnDate"]; hasReturnDate { + q.Set("returnDate", returnDate.(string)) + } q.Set("adults", fmt.Sprint(args["adults"])) if children, hasChildren := args["children"]; hasChildren { q.Set("children", fmt.Sprint(children)) @@ -59,7 +62,7 @@ func flightOfferSearch(args map[string]any) CallToolResult { if infants, hasInfants := args["infants"]; hasInfants { q.Set("infants", fmt.Sprint(infants)) } - q.Set("travelClass", args["travelClass"].(string)) + q.Set("travelClass", strings.ToUpper(args["travelClass"].(string))) if nonStop, hasNonStop := args["nonStop"]; hasNonStop { q.Set("nonStop", fmt.Sprint(nonStop)) } From 3bb72bce5f1431beb525ebbb4dc3a4427db4fce9 Mon Sep 17 00:00:00 2001 From: Edoardo Vacchi Date: Fri, 20 Dec 2024 15:54:41 +0100 Subject: [PATCH 5/5] more validation/parsing Signed-off-by: Edoardo Vacchi --- servlets/amadeus-flight-api/flights.go | 89 ++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 13 deletions(-) diff --git a/servlets/amadeus-flight-api/flights.go b/servlets/amadeus-flight-api/flights.go index 8920a8f..044d279 100644 --- a/servlets/amadeus-flight-api/flights.go +++ b/servlets/amadeus-flight-api/flights.go @@ -3,6 +3,7 @@ package main import ( "fmt" "net/url" + "strconv" "strings" pdk "github.com/extism/go-pdk" @@ -52,26 +53,44 @@ func flightOfferSearch(args map[string]any) CallToolResult { q.Set("originLocationCode", args["originLocationCode"].(string)) q.Set("destinationLocationCode", args["destinationLocationCode"].(string)) q.Set("departureDate", args["departureDate"].(string)) - if returnDate, hasReturnDate := args["returnDate"]; hasReturnDate { - q.Set("returnDate", returnDate.(string)) + + if returnDate, hasReturnDate := getString(args, "returnDate"); hasReturnDate { + q.Set("returnDate", returnDate) } - q.Set("adults", fmt.Sprint(args["adults"])) - if children, hasChildren := args["children"]; hasChildren { - q.Set("children", fmt.Sprint(children)) + + if adults, hasAdults := getNumber(args, "adults"); hasAdults { + q.Set("adults", fmt.Sprint(adults)) } - if infants, hasInfants := args["infants"]; hasInfants { - q.Set("infants", fmt.Sprint(infants)) + + children, _ := getNumber(args, "children") + q.Set("children", fmt.Sprint(children)) + + infants, _ := getNumber(args, "infants") + q.Set("infants", fmt.Sprint(infants)) + + if travelClass, hasTravelClass := getString(args, "travelClass"); hasTravelClass { + q.Set("travelClass", strings.ToUpper(travelClass)) + } else { + q.Set("travelClass", "ECONOMY") } - q.Set("travelClass", strings.ToUpper(args["travelClass"].(string))) - if nonStop, hasNonStop := args["nonStop"]; hasNonStop { - q.Set("nonStop", fmt.Sprint(nonStop)) + + nonStop, _ := getBool(args, "nonStop") + + q.Set("nonStop", fmt.Sprint(nonStop)) + + if currencyCode, hasCurrencyCode := getString(args, "currencyCode"); hasCurrencyCode { + q.Set("currencyCode", currencyCode) + } else { + q.Set("currencyCode", "USD") } - q.Set("currencyCode", args["currencyCode"].(string)) - if maxPrice, hasMaxPrice := args["maxPrice"]; hasMaxPrice { + + if maxPrice, hasMaxPrice := getString(args, "maxPrice"); hasMaxPrice { q.Set("maxPrice", fmt.Sprint(maxPrice)) } - if max, hasMax := args["max"]; hasMax { + if max, hasMax := getNumber(args, "max"); hasMax { q.Set("max", fmt.Sprint(max)) + } else { + q.Set("max", fmt.Sprint(10)) } req := pdk.NewHTTPRequest(pdk.MethodGet, config.baseUrl+endpoint+"?"+q.Encode()) @@ -85,3 +104,47 @@ func flightOfferSearch(args map[string]any) CallToolResult { return callToolSuccess(string(resp.Body())) } + +func getNumber(args map[string]any, key string) (float64, bool) { + if n, ok := args[key]; ok && n != nil { + if i, ok := n.(float64); ok { + return i, true + } + if i, ok := n.(int); ok { + return float64(i), true + } + if i, ok := n.(string); ok { + if f, err := strconv.ParseFloat(i, 64); err == nil { + return f, true + } + } + } + return 0, false +} + +func getBool(args map[string]any, key string) (bool, bool) { + if n, ok := args[key]; ok && n != nil { + if i, ok := n.(bool); ok { + return i, true + } + if i, ok := n.(string); ok { + if f, err := strconv.ParseBool(i); err == nil { + return f, true + } + } + } + return false, false +} + +func getString(args map[string]any, key string) (string, bool) { + if s, ok := args[key]; ok && s != nil { + if ss, ok := s.(string); ok { + ss = strings.TrimSpace(ss) + if ss == "" { + return "", false + } + return ss, ok + } + } + return "", false +}