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/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..5a7273d --- /dev/null +++ b/servlets/amadeus-flight-api/README.md @@ -0,0 +1,76 @@ +# Amadeus Flight API + +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 new file mode 100644 index 0000000..044d279 --- /dev/null +++ b/servlets/amadeus-flight-api/flights.go @@ -0,0 +1,150 @@ +package main + +import ( + "fmt" + "net/url" + "strconv" + "strings" + + 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"}, + }, + } + + FlightsTools = []ToolDescription{ + FlightOfferSearchTool, + } +) + +// 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)) + + if returnDate, hasReturnDate := getString(args, "returnDate"); hasReturnDate { + q.Set("returnDate", returnDate) + } + + if adults, hasAdults := getNumber(args, "adults"); hasAdults { + q.Set("adults", fmt.Sprint(adults)) + } + + 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") + } + + 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") + } + + if maxPrice, hasMaxPrice := getString(args, "maxPrice"); hasMaxPrice { + q.Set("maxPrice", fmt.Sprint(maxPrice)) + } + 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()) + 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 " + string(resp.Body())) + } + + 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 +} 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..330c895 --- /dev/null +++ b/servlets/amadeus-flight-api/main.go @@ -0,0 +1,119 @@ +// 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 + 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 ./..." 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