diff --git a/doc/connections/CONNECTION-SEARCH.md b/doc/connections/CONNECTION-SEARCH.md new file mode 100644 index 00000000000..dbcb31c816f --- /dev/null +++ b/doc/connections/CONNECTION-SEARCH.md @@ -0,0 +1,64 @@ +# Connection search + +`connections_search` accepts a service name or a natural-language query up to +4,000 characters. It ranks names before capabilities, tolerates extra words, +split names such as “Agent Mail,” and single-character spelling errors or +transpositions. Short partial names work too. Capability searches return +overlapping matches without requiring every query word to occur in the catalog. +Results are bounded to 40 entries. Exact app names outrank typo matches for +different apps (for example, Motion is not replaced by Notion). Query preparation +is shared across the catalog scan. + +Discovery includes tool, channel/email, and AI methods. Channel methods follow +the instance's Chat connectors experimental setting. Each catalog method names +its `purpose`. Channel and AI methods include a company-scoped `setupPath` to +their existing setup flow. `connection_request` still creates tool setup cards; +channel discovery does not turn an email inbox into an executable MCP tool or +add channel setup to that card. + +The agent chooses the relevant match using descriptions and method purposes. +It should clarify only when the task remains ambiguous. A search match does not +prove that an account is authorized, that a tool is installed, or that a provider +can perform every requested action. + +## Aggregator discovery + +The local support index combines reviewed Arcade/Zapier claims with the public +[Composio toolkit catalog](https://docs.composio.dev/toolkits). The Composio +snapshot includes names and toolkit identifiers, a source URL, and a verification +date. Each result links to its official toolkit page. This covers, for example, +[Circleback MCP](https://docs.composio.dev/toolkits/circleback_mcp), including +queries such as “help me find tools for circle back.” + +Refresh the snapshot from the official public page with: + +```sh +node scripts/update-composio-search-catalog.mjs +``` + +The script also accepts a saved HTML file as its first argument for reproducible +extraction. Review the resulting diff before committing it. Searches use this +local snapshot and authorized indexed tool namespaces; they do not send user +queries to a provider. Broad provider descriptions are not evidence of support. +Configured connection metadata remains scoped to the current company and +identity. Archived and unauthorized catalogs cannot establish an aggregator route. + +Built-in connections remain preferred for the same app. Existing AI access is +checked through the agent's AI binding and credential selection. An aggregator route still requires a +saved provider choice or a verified explicit user request. Fuzzy retrieval never +grants consent. A provider connection is not proof that its underlying app is +authorized. Multiple matching aggregator apps are returned as candidates; the +agent searches the selected `aggregator.targetService` to get its provider question. + +## Regression coverage + +```sh +pnpm exec vitest run packages/shared/src/connection-search.test.ts packages/shared/src/connection-routing.test.ts packages/shared/src/validators/connection-intent.test.ts server/src/__tests__/connection-aggregator-fallback.test.ts server/src/__tests__/connection-intents-service.test.ts +``` + +The database suite covers the original AgentMail sentence, split names, typos, +capability overlap, multiple services, AI discovery, experimental gating, +Circleback/Attio/ClickUp, indexed Executor tools, and provider consent. The native +tool authority test searches a paragraph longer than the old 200-character limit +and then creates a real connection interaction. Existing tests retain checks for +private metadata, administrative denials, stale identities, and saved declines. diff --git a/packages/shared/src/composio-search-catalog.json b/packages/shared/src/composio-search-catalog.json new file mode 100644 index 00000000000..483d391b1db --- /dev/null +++ b/packages/shared/src/composio-search-catalog.json @@ -0,0 +1,1589 @@ +{ + "source": "https://docs.composio.dev/toolkits", + "verifiedAt": "2026-09-30", + "toolkits": [ + ["_1password","1password"], + ["_21risk","21risk"], + ["_2chat","2chat"], + ["0codekit","0CodeKit"], + ["44api","44API"], + ["7shifts","7shifts"], + ["ably","Ably"], + ["abstract","Abstract"], + ["abuselpdb","AbuseIPDB"], + ["abyssale","Abyssale"], + ["accredible_certificates","Accredible certificates"], + ["accu_weather","AccuWeather"], + ["acculynx","Acculynx"], + ["active_campaign","ActiveCampaign"], + ["active_merge","ActiveMerge"], + ["active_trail","ActiveTrail"], + ["adafruit_io","Adafruit IO"], + ["addressfinder","Addressfinder"], + ["addresszen","Addresszen"], + ["adobe_marketing_agent_mcp","Adobe Marketing Agent MCP"], + ["adrapid","Adrapid"], + ["adtraction","Adtraction"], + ["adyntel","Adyntel"], + ["aeroleads","Aeroleads"], + ["affinda","Affinda"], + ["affinity","Affinity"], + ["agencyzoom","AgencyZoom"], + ["agent_mail","Agent Mail"], + ["agentql","Agentql"], + ["agenty","Agenty"], + ["agile_crm","Agile CRM"], + ["agiled","Agiled"], + ["agility_cms","Agility CMS"], + ["ahrefs","Ahrefs"], + ["ahrefs_mcp","Ahrefs MCP"], + ["ai_ml_api","AI/ML API"], + ["aimtell","AimTell"], + ["airops_mcp","AirOps MCP"], + ["airparser","Airparser"], + ["airtable","Airtable"], + ["airtop","Airtop"], + ["airwallex","Airwallex"], + ["aivoov","Aivoov"], + ["aiwyn_tax_mcp","Aiwyn Tax MCP"], + ["akta","Akta"], + ["alchemy","Alchemy"], + ["algodocs","Algodocs"], + ["algolia","Algolia"], + ["all_images_ai","All Images AI"], + ["alltrails_mcp","AllTrails MCP"], + ["alpaca","Alpaca"], + ["alpha_vantage","Alpha Vantage"], + ["alt_text_lab","AltTextLab"], + ["altoviz","Altoviz"], + ["alttext_ai","AltText.ai"], + ["amara","Amara"], + ["amazing_marvin","Amazing Marvin"], + ["ambee","Ambee"], + ["ambient_weather","Ambient Weather"], + ["amcards","Amcards"], + ["amplitude","Amplitude"], + ["amplitude_mcp","Amplitude MCP"], + ["anakin_io","Anakin"], + ["anchor_browser","Anchor Browser"], + ["android_texter","Android Texter"], + ["anonyflow","Anonyflow"], + ["anthropic_administrator","Anthropic Administrator"], + ["any_db","AnyDB"], + ["apaleo","Apaleo"], + ["apexverify","ApexVerify"], + ["api_bible","API.Bible"], + ["api_labz","API Labz"], + ["api_ninjas","API Ninjas"], + ["api_sports","API-Sports"], + ["api2pdf","API2PDF"], + ["apiflash","Apiflash"], + ["apiframe","Apiframe"], + ["apify","Apify"], + ["apify_mcp","Apify MCP"], + ["apilio","Apilio"], + ["apipie_ai","APIpie AI"], + ["apiverve","Apiverve"], + ["apollo","Apollo"], + ["appcircle","Appcircle"], + ["appdrag","AppDrag"], + ["appointo","Appointo"], + ["apptivegrid","ApptiveGrid"], + ["appveyor","Appveyor"], + ["archive_org","Archive.org"], + ["arena","Are.na"], + ["arize_ax","Arize AX"], + ["artificial_analysis","Artificial Analysis"], + ["aryn","Aryn"], + ["asana","Asana"], + ["ascora","Ascora"], + ["ashby","Ashby"], + ["asin_data_api","ASIN Data API"], + ["assemblyai","AssemblyAI"], + ["astica_ai","Astica AI"], + ["async_interview","Async Interview"], + ["atlassian_mcp","Atlassian MCP"], + ["attio","Attio"], + ["audioscrape_mcp","Audioscrape MCP"], + ["authyo","Authyo"], + ["autobound","Autobound"], + ["autom","Autom"], + ["avoma","Avoma"], + ["aweber","AWeber"], + ["aws_marketplace_mcp","AWS Marketplace MCP"], + ["ayrshare","Ayrshare"], + ["azure_monitor_activity_log","Azure Monitor Activity Log"], + ["backendless","Backendless"], + ["bamboohr","BambooHR"], + ["bannerbear","Bannerbear"], + ["bannerbite","Bannerbite"], + ["baremetrics","Baremetrics"], + ["bart","Bart"], + ["basecamp","Basecamp"], + ["baselinker","Baselinker"], + ["baserow","Baserow"], + ["basin","Basin"], + ["beach_day_api","Beach Day API"], + ["beaconchain","beaconcha.in"], + ["beaconstac","Beaconstac"], + ["beamer","Beamer"], + ["beatapi","BeatAPI"], + ["beehiiv","Beehiiv"], + ["beeminder","Beeminder"], + ["bench","Bench"], + ["benchmark_email","Benchmark Email"], + ["benzinga","Benzinga"], + ["bestbuy","Bestbuy"], + ["better_proposals","Better Proposals"], + ["better_stack","Better Stack"], + ["better_stack_mcp","Better Stack MCP"], + ["bettercontact","Bettercontact"], + ["bidsketch","Bidsketch"], + ["big_data_cloud","BigDataCloud"], + ["bigdata_com_mcp","Bigdata.com MCP"], + ["bigmailer","Bigmailer"], + ["bigml","Bigml"], + ["bigpicture_io","BigPicture.io"], + ["bika","Bika.ai"], + ["billsby","Billsby"], + ["bing_webmaster_tools","Bing Webmaster Tools"], + ["bitbucket","Bitbucket"], + ["bitly","Bitly"], + ["bitquery","Bitquery"], + ["bitwarden","Bitwarden"], + ["blackbaud","Blackbaud"], + ["blackboard","Blackboard"], + ["bland_ai","Bland AI"], + ["blazemeter","Blazemeter"], + ["blitz_api","Blitz API"], + ["blocknative","Blocknative"], + ["blooio","Blooio"], + ["bluebarry","Bluebarry"], + ["bluefox_email","BlueFox Email"], + ["bluepillow","Blue Pillow Hotels & Stays"], + ["boldsign","Boldsign"], + ["bolna","Bolna"], + ["boloforms","Boloforms"], + ["bolt_iot","Bolt Iot"], + ["bonsai","Bonsai"], + ["bookingmood","Bookingmood"], + ["booqable","Booqable"], + ["borneo","Borneo"], + ["botbaba","Botbaba"], + ["botdog","Botdog"], + ["botmape","BotMape"], + ["botpenguin","BotPenguin"], + ["botpress","Botpress"], + ["botsonic","Botsonic"], + ["botstar","Botstar"], + ["botster","Botster"], + ["bouncer","Bouncer"], + ["bouncify","Bouncify"], + ["box","Box"], + ["box_mcp","Box MCP"], + ["boxhero","Boxhero"], + ["brain_graph","BrainGraph"], + ["braintrust","Braintrust"], + ["brandfetch","Brandfetch"], + ["braze","Braze"], + ["bread_butter","Bread & Butter"], + ["breathehr","Breathe HR"], + ["breeze","Breeze"], + ["breezy_hr","Breezy Hr"], + ["brevo","Brevo"], + ["brex","Brex"], + ["brightdata","Bright Data"], + ["brilliant_directories","Brilliant Directories"], + ["browseai","Browseai"], + ["browser_tool","Browser Tool"], + ["browser_use","Browser Use"], + ["browseract","BrowserAct"], + ["browserbase_mcp","Browserbase MCP"], + ["browserbase_tool","Browserbase"], + ["browserless","Browserless"], + ["bsc_designer","BSC Designer"], + ["btcpay_server","BTCPay Server"], + ["bubble","Bubble"], + ["buffer","Buffer"], + ["bug_recorder","Bug Recorder"], + ["bugbug","Bugbug"], + ["bugherd","Bugherd"], + ["bugsnag","Bugsnag"], + ["builder_io","Builder.io"], + ["buildkite","Buildkite"], + ["builtwith","BuiltWith"], + ["bulk_email_checker","Bulk Email Checker"], + ["bulkpublish","BulkPublish"], + ["bunnycdn","BunnyCDN"], + ["byte_plus_ark","BytePlus Ark"], + ["byteforms","Byteforms"], + ["cabinpanda","Cabinpanda"], + ["cal","Cal"], + ["calendarhero","CalendarHero"], + ["calendly","Calendly"], + ["calendly_mcp","Calendly MCP"], + ["callerapi","Callerapi"], + ["callingly","Callingly"], + ["callonthego","CallOnTheGo"], + ["callpage","Callpage"], + ["camb_ai","CAMB.AI"], + ["campaign_cleaner","Campaign Cleaner"], + ["campayn","Campayn"], + ["candid_mcp","Candid MCP"], + ["canny","Canny"], + ["canva","Canva"], + ["canva_mcp","Canva MCP"], + ["canvas","Canvas"], + ["capsule_crm","Capsule CRM"], + ["captions","Captions"], + ["capturekit","CaptureKit"], + ["carbon_voice_mcp","Carbon Voice MCP"], + ["carbone","Carbone"], + ["cardclan","CardClan"], + ["cardly","Cardly"], + ["carsxe","CarsXE"], + ["cashfree_payments_mcp","Cashfree Payments MCP"], + ["castingwords","Castingwords"], + ["cats","Cats"], + ["cdr_platform","Cdr Platform"], + ["celigo","Celigo"], + ["census_bureau","Census Bureau"], + ["centralstationcrm","CentralStationCRM"], + ["ceramic","Ceramic"], + ["cert_seal","CertSeal"], + ["certifier","Certifier"], + ["chameleon","Chameleon"], + ["charla","Charla"], + ["chartly","Chartly"], + ["chartmogul","ChartMogul"], + ["chaser","Chaser"], + ["chatbotkit","Chatbotkit"], + ["chatfai","Chatfai"], + ["chatforma","Chatforma"], + ["chatwork","Chatwork"], + ["check_cherry","Check Cherry"], + ["checkflow","CheckFlow"], + ["checkvist","Checkvist"], + ["chmeetings","Chmeetings"], + ["cincopa","Cincopa"], + ["circleback_mcp","Circleback MCP"], + ["circleci","CircleCI"], + ["claid_ai","Claid.ai"], + ["clarify_mcp","Clarify MCP"], + ["clarity_ai_mcp","Clarity AI MCP"], + ["classmarker","Classmarker"], + ["clay","Clay"], + ["clay_mcp","Clay MCP"], + ["clearout","Clearout"], + ["clickhouse","ClickHouse"], + ["clickhouse_mcp","ClickHouse MCP"], + ["clickmeeting","Clickmeeting"], + ["clicksend","ClickSend"], + ["clickup","ClickUp"], + ["clickup_mcp","ClickUp MCP"], + ["clientary","Clientary"], + ["clio_manage","Clio Manage"], + ["clockify","Clockify"], + ["cloro","Cloro"], + ["close","Close"], + ["close_mcp","Close MCP"], + ["cloud_pdf","CloudPDF"], + ["cloudcart","Cloudcart"], + ["cloudconvert","CloudConvert"], + ["cloudflare","Cloudflare"], + ["cloudflare_api_key","Cloudflare Api Key"], + ["cloudflare_browser_rendering","Cloudflare Browser Rendering"], + ["cloudflare_mcp","Cloudflare MCP"], + ["cloudinary","Cloudinary"], + ["cloudlayer","Cloudlayer"], + ["cloudpress","Cloudpress"], + ["cloudsmith","Cloudsmith"], + ["clover","Clover"], + ["coassemble","Coassemble"], + ["coda","Coda"], + ["codacy","Codacy"], + ["codeinterpreter","Code Interpreter"], + ["codemagic","Codemagic"], + ["codereadr","Codereadr"], + ["cody","Cody"], + ["cohere","Cohere"], + ["coinbase","Coinbase"], + ["coinbase_agents_mcp","Coinbase Agents MCP"], + ["coinbase_wallet_mcp","Coinbase Wallet MCP"], + ["coingecko","CoinGecko"], + ["coinmarketcal","CoinMarketCal"], + ["coinmarketcap","CoinMarketCap"], + ["coinranking","Coinranking"], + ["college_football_data","College Football Data"], + ["cometapi","CometAPI"], + ["commcare","CommCare"], + ["commerce_layer","Commerce Layer"], + ["common_room_mcp","Common Room MCP"], + ["company_url_finder","Company URL Finder"], + ["companycam","CompanyCam"], + ["companyenrich","Companyenrich"], + ["compdf","ComPDF"], + ["composio","Composio"], + ["composio_search","Composio Search"], + ["conductor","Conductor"], + ["configcat","ConfigCat"], + ["confluence","Confluence"], + ["connecteam","Connecteam"], + ["consensus","Consensus"], + ["consensus_mcp","Consensus MCP"], + ["constant_contact","Constant Contact"], + ["contactout","ContactOut"], + ["contacts_plus","Contacts+"], + ["content_studio","ContentStudio"], + ["contentdrips","Contentdrips"], + ["contentful","Contentful"], + ["contentful_graphql","Contentful Graphql"], + ["contentsquare_mcp","Contentsquare MCP"], + ["context_dev","Context.dev"], + ["context7_mcp","Context7 MCP"], + ["contractbook","Contractbook"], + ["control_d","Control D"], + ["conversion_tools","Conversion Tools"], + ["convertapi","Convertapi"], + ["convex","Convex"], + ["conveyor","Conveyor"], + ["convolo_ai","Convolo AI"], + ["copper","Copper"], + ["copyleaks","Copyleaks"], + ["coresignal","Coresignal"], + ["corrently","Corrently"], + ["countdown_api","Countdown API"], + ["coupa","Coupa"], + ["cradl_ai","Cradl AI"], + ["craft_mcp","Craft MCP"], + ["craftmypdf","Craftmypdf"], + ["crawlbase","Crawlbase"], + ["creatomate","Creatomate"], + ["creem","Creem"], + ["crisp","Crisp"], + ["cronfree_time_scheduler","Cronfree Time Scheduler"], + ["cronly","Cronly"], + ["crowdin","Crowdin"], + ["crowterminal","CrowTerminal"], + ["crustdata","Crustdata"], + ["csvbox","CSVBox"], + ["cults","Cults"], + ["curated","Curated"], + ["currencyscoop","CurrencyScoop"], + ["currents_api","Currents API"], + ["cursor","Cursor"], + ["customerio","Customer.io"], + ["customerio_mcp","Customer.io MCP"], + ["customgpt","Customgpt"], + ["customjs","Customjs"], + ["cutt_ly","Cutt.ly"], + ["d2lbrightspace","D2L Brightspace"], + ["dadata_ru","DaData.ru"], + ["daffy","Daffy"], + ["daily","Daily"], + ["dailybot","Dailybot"], + ["dart","Dart"], + ["data247","Data247"], + ["databox","Databox"], + ["databricks","Databricks"], + ["datadog","Datadog"], + ["datadog_mcp","Datadog MCP"], + ["dataforseo","Dataforseo"], + ["datagma","Datagma"], + ["datarobot","DataRobot"], + ["datascope","Datascope"], + ["daytona","Daytona"], + ["deadline_funnel","Deadline Funnel"], + ["debitura","Debitura"], + ["debounce","DeBounce"], + ["deepgram","Deepgram"], + ["deepimage","DeepImage"], + ["deepseek","DeepSeek"], + ["deepwiki_mcp","DeepWiki MCP"], + ["delighted","Delighted"], + ["demio","Demio"], + ["deployhq","DeployHQ"], + ["descript_mcp","Descript MCP"], + ["desktime","DeskTime"], + ["detrack","Detrack"], + ["dev_cycle","DevCycle"], + ["devin_mcp","Devin MCP"], + ["devolens","Devolens / Cryptolens"], + ["devto","DEV Community"], + ["dialmycalls","Dialmycalls"], + ["dialpad","Dialpad"], + ["dice_mcp","Dice MCP"], + ["dictionary_api","Dictionary Api"], + ["didit","Didit"], + ["diffbot","Diffbot"], + ["dify","Dify"], + ["digicert","Digicert"], + ["digital_humani","Digital Humani"], + ["digital_ocean","DigitalOcean"], + ["directiq","DirectIQ"], + ["discord","Discord"], + ["discordbot","Discord Bot"], + ["dnsfilter","Dnsfilter"], + ["dock_certs","Dock Certs"], + ["docker_hub","Docker Hub"], + ["docmosis","Docmosis"], + ["docnify","Docnify"], + ["docparser","Docparser"], + ["docraptor","DocRaptor"], + ["docsautomator","Docsautomator"], + ["docsbot_ai","DocsBot AI"], + ["docsumo","Docsumo"], + ["docugenerate","Docugenerate"], + ["documenso","Documenso"], + ["documentero","Documentero"], + ["documint","Documint"], + ["docupilot","Docupilot"], + ["docupipe","DocuPipe"], + ["docupost","DocuPost"], + ["docupotion","DocuPotion"], + ["docuseal","DocuSeal"], + ["docusign","DocuSign"], + ["domotz_mcp","Domotz MCP"], + ["donately","Donately"], + ["doppler","Doppler"], + ["doppler_marketing_automation","Doppler Marketing Automation"], + ["doppler_secretops","Doppler SecretOps"], + ["dotsimple","Dotsimple"], + ["dovetail","Dovetail"], + ["dpd2","Dpd2"], + ["draftable","Draftable"], + ["dreamstudio","Dreamstudio"], + ["dribbble","Dribbble"], + ["dripcel","Dripcel"], + ["dromo","Dromo"], + ["drop_to_cdn","Drop to CDN"], + ["dropbox","Dropbox"], + ["dropbox_sign","Dropbox Sign"], + ["dropcontact","Dropcontact"], + ["dub","Dub"], + ["dungeon_fighter_online","Dungeon Fighter Online"], + ["dynamic_mockups","Dynamic Mockups"], + ["dynamics365","Dynamics 365"], + ["dynapictures","Dynapictures"], + ["dynosend","Dynosend"], + ["e2b","E2b"], + ["eagle_doc","Eagle doc"], + ["easy_email_verification","Easy Email Verification"], + ["echtpost","Echtpost"], + ["ecologi","Ecologi"], + ["eden_ai","Eden AI"], + ["edworking","Edworking"], + ["egnyte","Egnyte"], + ["elai","Elai"], + ["elastic_email","Elastic Email"], + ["elasticsearch","Elasticsearch"], + ["eledo","Eledo PDF"], + ["elevenlabs","ElevenLabs"], + ["elevenreader","Elevenreader"], + ["elorus","Elorus"], + ["emailable","Emailable"], + ["emaillistverify","Emaillistverify"], + ["emailoctopus","EmailOctopus"], + ["emailvalidation","Emailvalidation"], + ["emelia","Emelia"], + ["encodian","Encodian"], + ["endorsal","Endorsal"], + ["enforcedflow","EnforcedFlow"], + ["engage","Engage"], + ["engage_platform","Engage Platform"], + ["enginemailer","Enginemailer"], + ["enigma","Enigma"], + ["enrich","Enrich"], + ["enrich_layer","Enrich Layer"], + ["entelligence","Entelligence"], + ["eodhd_apis","EODHD APIs"], + ["epic_games","Epic Games"], + ["era_context_mcp","Era Context MCP"], + ["eranol","Eranol"], + ["eraser_mcp","Eraser MCP"], + ["erpnext","Erpnext"], + ["esignatures","eSignatures"], + ["esignatures_io","eSignatures.io"], + ["espocrm","Espocrm"], + ["esputnik","Esputnik"], + ["etermin","Etermin"], + ["evenium","Evenium"], + ["eventbrite","Eventbrite"], + ["eventee","Eventee"], + ["eventzilla","Eventzilla"], + ["everhour","Everhour"], + ["eversign","Eversign"], + ["exa","Exa"], + ["excalidraw_mcp","Excalidraw MCP"], + ["excel","Excel"], + ["exceptionless","Exceptionless"], + ["exist","Exist"], + ["explorium","Explorium"], + ["explorium_mcp","Explorium MCP"], + ["expofp","Expofp"], + ["extracta_ai","Extracta.ai"], + ["extrovert","Extrovert"], + ["facebook","Facebook"], + ["faceup","Faceup"], + ["fairing","Fairing"], + ["fal_ai","Fal.ai"], + ["faraday","Faraday"], + ["fathom","Fathom"], + ["feathery","Feathery"], + ["feedbear","FeedBear"], + ["fellow_ai_mcp","Fellow.ai MCP"], + ["felt","Felt"], + ["fibery","Fibery"], + ["fidel_api","Fidel API"], + ["figma","Figma"], + ["files_com","Files.com"], + ["filetopdf","FileToPDF.dev"], + ["fillout_forms","Fillout Forms"], + ["finage","Finage"], + ["financial_datasets_ai_mcp","Financial Datasets AI MCP"], + ["findymail","Findymail"], + ["finerworks","Finerworks"], + ["fingertip","Fingertip"], + ["finlight","Finlight"], + ["finmei","Finmei"], + ["finmo","Finmo"], + ["finnhub","Finnhub"], + ["finviz","Finviz"], + ["firebase","Firebase"], + ["fireberry","Fireberry"], + ["firecrawl","Firecrawl"], + ["fireflies","Fireflies"], + ["fireworks_ai","Fireworks AI"], + ["firmao","Firmao"], + ["fish_audio","Fish Audio"], + ["fixer","Fixer"], + ["fixer_io","Fixer.io"], + ["flatfile","Flatfile"], + ["fleetio","Fleetio"], + ["flexisign","Flexisign"], + ["float","Float"], + ["flotiq","Flotiq"], + ["flowiseai","Flowiseai"], + ["flutterwave","Flutterwave"], + ["fluxguard","Fluxguard"], + ["fly","Fly"], + ["folk","Folk"], + ["follow_up_boss","Follow Up Boss"], + ["fomo","Fomo"], + ["forcemanager","Forcemanager"], + ["formbricks","Formbricks"], + ["formcarry","Formcarry"], + ["formcrafts","Formcrafts"], + ["formdesk","Formdesk"], + ["formilla","Formilla"], + ["formsite","Formsite"], + ["foursquare","Foursquare"], + ["fraudlabs_pro","FraudLabs Pro"], + ["freeagent","Freeagent"], + ["freshbooks","FreshBooks"], + ["freshdesk","Freshdesk"], + ["freshservice","Freshservice"], + ["fullenrich","Fullenrich"], + ["fullstory","FullStory"], + ["fuseai","FuseAI"], + ["fuseai_mcp","FuseAI MCP"], + ["fusedesk","FuseDesk"], + ["futureagi","FutureAGI"], + ["gagelist","Gagelist"], + ["gamma","Gamma"], + ["gan_ai","Gan.AI"], + ["gatherup","Gatherup"], + ["geekflare","Geekflare"], + ["gelato","Gelato"], + ["gemini","Gemini"], + ["gender_api","Gender API"], + ["genderapi_io","GenderAPI.io"], + ["genderize","Genderize"], + ["geoapify","Geoapify"], + ["geocodio","Geocodio"], + ["geokeo","Geokeo"], + ["get_transcribe","GetTranscribe"], + ["getform","Getform"], + ["getprospect","Getprospect"], + ["getresponse","GetResponse"], + ["gift_up","Gift Up!"], + ["gigasheet","Gigasheet"], + ["giphy","Giphy"], + ["gist","Gist"], + ["gitea","Gitea"], + ["github","GitHub"], + ["github_mcp","GitHub MCP"], + ["gitlab","GitLab"], + ["givebutter","Givebutter"], + ["gladia","Gladia"], + ["glasp","Glasp"], + ["gleap","Gleap"], + ["gleif","GLEIF API"], + ["globalping","Globalping"], + ["gmail","Gmail"], + ["go_table","GoTable"], + ["gocardless_mcp","GoCardless MCP"], + ["godaddy_mcp","GoDaddy MCP"], + ["godial","Godial"], + ["gofile","GoFile"], + ["gong","Gong"], + ["goodapi_plant_trees","GoodAPI: Plant Trees"], + ["goodbits","Goodbits"], + ["goody","Goody"], + ["google_address_validation","Google Address Validation"], + ["google_admin","Google Admin"], + ["google_analytics","Google Analytics"], + ["google_chat","Google Chat"], + ["google_classroom","Google Classroom"], + ["google_cloud_vision","Google Cloud Vision"], + ["google_data_studio","Google Data Studio"], + ["google_maps","Google Maps"], + ["google_search_console","Google Search Console"], + ["googleads","Google Ads"], + ["googlebigquery","Google BigQuery"], + ["googlecalendar","Google Calendar"], + ["googlecontacts","Googlecontacts"], + ["googledocs","Google Docs"], + ["googledrive","Google Drive"], + ["googleforms","Googleforms"], + ["googlemeet","Google Meet"], + ["googlephotos","Google Photos"], + ["googlesheets","Google Sheets"], + ["googleslides","Google Slides"], + ["googlesuper","Google Super"], + ["googletasks","Google Tasks"], + ["gorgias","Gorgias"], + ["gorilladesk","GorillaDesk"], + ["gosquared","Gosquared"], + ["gptbots","GPTBots"], + ["gradium","Gradium"], + ["grafana","Grafana"], + ["grafana_cloud_mcp","Grafana Cloud MCP"], + ["grafbase","Grafbase"], + ["granola_mcp","Granola MCP"], + ["granted_mcp","Granted MCP"], + ["graphhopper","Graphhopper"], + ["greenhouse","Greenhouse"], + ["greptile_mcp","Greptile MCP"], + ["griptape","Griptape"], + ["grist","Grist"], + ["grok","Grok"], + ["groqcloud","GroqCloud"], + ["gtmetrix","GTmetrix"], + ["gumroad","Gumroad"], + ["gusto","Gusto"], + ["habitica","Habitica"], + ["hackernews","Hacker News"], + ["hackerrank_work","HackerRank Work"], + ["hackmd","HackMD"], + ["handwrite","Handwrite.io"], + ["handwrytten","Handwrytten"], + ["happy_scribe","Happy Scribe"], + ["harness","Harness"], + ["harvest","Harvest"], + ["hashnode","Hashnode"], + ["headout","Headout"], + ["heartbeat","Heartbeat"], + ["heights_platform","Heights Platform"], + ["hello_tracks","HelloTracks"], + ["helloleads","Helloleads"], + ["help_scout","Help Scout"], + ["help_spot","HelpSpot"], + ["helpdesk","Helpdesk"], + ["helpwise","Helpwise"], + ["here","Here"], + ["hex","Hex"], + ["heygen","HeyGen"], + ["heyreach","Heyreach"], + ["heyy","Heyy"], + ["heyzine","Heyzine"], + ["higgsfield","Higgsfield"], + ["higgsfield_mcp","Higgsfield MCP"], + ["highergov","Highergov"], + ["highlevel","Highlevel"], + ["highlevel_mcp","HighLevel MCP"], + ["hireflix","Hireflix"], + ["hitl","Human Approval - HITL Platform"], + ["hitpay","HitPay"], + ["hive","Hive"], + ["hjarni","Hjarni"], + ["holded","Holded"], + ["honcho","Honcho"], + ["honeybadger","Honeybadger"], + ["honeycomb_mcp","Honeycomb MCP"], + ["honeyhive","Honeyhive"], + ["hookdeck","Hookdeck"], + ["hostinger","Hostinger"], + ["hotspotsystem","Hotspotsystem"], + ["hr_partner","HR Partner"], + ["html_to_image","HTML to Image"], + ["htmlcss_to_image","HTML/CSS to Image"], + ["hub_planner","Hub Planner"], + ["hubspot","HubSpot"], + ["hugging_face","Hugging Face"], + ["humanitix","Humanitix"], + ["humanloop","Humanloop"], + ["hunter","Hunter"], + ["hypeauditor","Hypeauditor"], + ["hyperbrowser","Hyperbrowser"], + ["hyperise","Hyperise"], + ["hystruct","Hystruct"], + ["i_love_pdf","iLovePDF"], + ["ibm_x_force_exchange","IBM X-Force Exchange"], + ["icypeas","Icypeas"], + ["idealpostcodes","Ideal Postcodes"], + ["identitycheck","Identitycheck"], + ["ignav_flights","Ignav Flights"], + ["ignisign","Ignisign"], + ["ilert","iLert"], + ["imagekit_io","ImageKit"], + ["imagerouter","ImageRouter"], + ["imagior","Imagior"], + ["imejis_io","Imejis.io"], + ["imgbb","ImgBB"], + ["imgix","Imgix"], + ["inblog","Inblog"], + ["incident_io","incident.io"], + ["influxdb_cloud","InfluxDB Cloud"], + ["inistate","Inistate"], + ["insighto_ai","Insighto.ai"], + ["inspo_mcp","Inspo MCP"], + ["instacart","Instacart"], + ["instagram","Instagram"], + ["instantly","Instantly"], + ["instatus","Instatus"], + ["intelliprint","Intelliprint"], + ["intercom","Intercom"], + ["intercom_mcp","Intercom MCP"], + ["interzoid","Interzoid"], + ["invalid_bounce","Invalid Bounce"], + ["invofox","Invofox"], + ["inworld_ai","Inworld AI"], + ["ip_registry","IPRegistry"], + ["ip2location","Ip2Location"], + ["ip2location_io","IP2Location.io"], + ["ip2proxy","Ip2Proxy"], + ["ip2whois","Ip2Whois"], + ["ipdata_co","Ipdata.co"], + ["ipgeolocation","IPGeolocation.io"], + ["ipinfo_io","Ipinfo.io"], + ["iplocate","IPLocate"], + ["iqair_airvisual","Iqair Airvisual"], + ["iterable","Iterable"], + ["iterate","Iterate"], + ["itm_platform","ITM Platform"], + ["jam","Jam"], + ["jentic_mcp","Jentic MCP"], + ["jev","Jev"], + ["jigsawstack","Jigsawstack"], + ["jinko","Jinko"], + ["jira","Jira"], + ["jobber","Jobber"], + ["jobnimbus","JobNimbus"], + ["jogg_ai","JoggAI"], + ["jotform","Jotform"], + ["jotform_sign","Jotform Sign"], + ["jumpcloud","JumpCloud"], + ["junglescout","Jungle Scout"], + ["just_one_api","Just One API"], + ["kadoa","Kadoa"], + ["kaggle","Kaggle"], + ["kaleido","Kaleido"], + ["kanban_tool","Kanban Tool"], + ["kanbanize","Kanbanize"], + ["kapso","Kapso"], + ["keen_io","Keen.io"], + ["kernel_mcp","Kernel MCP"], + ["keyword","Keyword"], + ["keyzy","Keyzy"], + ["kibana","Kibana"], + ["kickbox","Kickbox"], + ["kieai","Kieai"], + ["kintone","Kintone"], + ["kintone_rest","Kintone REST"], + ["kit","Kit"], + ["klaviyo","Klaviyo"], + ["klazify","Klazify"], + ["klipfolio","Klipfolio"], + ["knack","Knack"], + ["ko_fi","Ko-fi"], + ["kobotoolbox","KoboToolbox"], + ["kommo","Kommo"], + ["kommunicate","Kommunicate"], + ["kontent_ai","Kontent.ai"], + ["kraken_io","Kraken.io"], + ["l2s","L2s"], + ["labs64_netlicensing","Labs64 NetLicensing"], + ["labsmobile","LabsMobile"], + ["lagrowthmachine","La Growth Machine"], + ["lakera_guard","Lakera Guard"], + ["landbot","Landbot"], + ["langbase","Langbase"], + ["langfuse","Langfuse"], + ["laposta","Laposta"], + ["launch_darkly","LaunchDarkly"], + ["layerre","Layerre"], + ["lc_cx","lc.cx"], + ["leadboxer","LeadBoxer"], + ["leadfeeder","Leadfeeder"], + ["leadiq","LeadIQ"], + ["leadmagic","LeadMagic"], + ["leadoku","Leadoku"], + ["leexi","Leexi"], + ["legal_data_hunter_mcp","Legal Data Hunter MCP"], + ["leiga","Leiga"], + ["lemlist","Lemlist"], + ["lemon_squeezy","Lemon Squeezy"], + ["lessonspace","Lessonspace"], + ["lettr","Lettr"], + ["lever","Lever"], + ["leverly","Leverly"], + ["lexoffice","Lexoffice"], + ["lightspeed","Lightspeed Retail X-Series"], + ["lilt_mcp","LILT MCP"], + ["line","LINE"], + ["linear","Linear"], + ["linear_mcp","Linear MCP"], + ["linguapop","Linguapop"], + ["linguin_ai","Linguin AI"], + ["link_twin","LinkTwin"], + ["linkedin","LinkedIn"], + ["linkedin_ads","Linkedin Ads"], + ["linkhut","Linkhut"], + ["linkly","Linkly"], + ["linkup","Linkup"], + ["listclean","Listclean"], + ["listennotes","Listen Notes"], + ["livesession","Livesession"], + ["llmwhisperer","LLMWhisperer"], + ["lmnt","LMNT"], + ["lob","Lob"], + ["lodgify","Lodgify"], + ["logo_dev","Logo.dev"], + ["lokalise","Lokalise"], + ["longbridge_mcp","Longbridge MCP"], + ["loomio","Loomio"], + ["loops_so","Loops.so"], + ["loyverse","Loyverse"], + ["lucid_mcp","Lucid MCP"], + ["luma_labs","Luma Labs"], + ["lumify","Lumify"], + ["lusha","Lusha"], + ["lyzr","Lyzr"], + ["magic_hour","Magic Hour"], + ["magic_patterns_mcp","Magic Patterns MCP"], + ["magicslides","MagicSlides"], + ["magnetic","Magnetic"], + ["mailbluster","MailBluster"], + ["mailboxlayer","Mailboxlayer"], + ["mailboxvalidator","MailboxValidator"], + ["mailcheck","Mailcheck"], + ["mailchimp","Mailchimp"], + ["mailcoach","Mailcoach"], + ["maildroppa","Maildroppa"], + ["mailercheck","MailerCheck"], + ["mailercloud","Mailercloud"], + ["mailerlite","MailerLite"], + ["mailersend","MailerSend"], + ["mails_so","Mails.so"], + ["mailslurp","MailSlurp"], + ["mailsoftly","Mailsoftly"], + ["mailtrap","Mailtrap"], + ["maintainx","Maintainx"], + ["make","Make"], + ["malwarebytes_mcp","Malwarebytes MCP"], + ["manus","Manus"], + ["manyreach","ManyReach"], + ["mapbox","Mapbox"], + ["mapflow","Mapflow.ai"], + ["mapulus","Mapulus"], + ["marcopolo_mcp","MarcoPolo MCP"], + ["marketstack","Marketstack"], + ["markup_ai","Markup AI"], + ["mathpix","Mathpix"], + ["matterport","Matterport"], + ["maxio","Maxio"], + ["melo","Melo"], + ["mem","Mem"], + ["mem0","Mem0"], + ["memberspot","Memberspot"], + ["memberstack","Memberstack"], + ["membervault","Membervault"], + ["merchantpro","MerchantPro"], + ["mercury_mcp","Mercury MCP"], + ["mermaid_chart_mcp","Mermaid Chart MCP"], + ["meshy","Meshy"], + ["metaads","Meta Ads"], + ["metabase","Metabase"], + ["metaphor","Metaphor"], + ["metatextai","Metatextai"], + ["mezmo","Mezmo"], + ["microsoft_clarity","Microsoft Clarity"], + ["microsoft_power_bi","Microsoft Power Bi"], + ["microsoft_teams","Microsoft Teams"], + ["microsoft_todo","Microsoft To Do"], + ["midpage_mcp","Midpage MCP"], + ["mighty_networks","Mighty Networks"], + ["mihu_ai","Mihu AI"], + ["mindbody","Mindbody"], + ["minelead","Minelead.io"], + ["minerstat","Minerstat"], + ["mintlify","Mintlify"], + ["mintlify_mcp","Mintlify MCP"], + ["miro","Miro"], + ["missive","Missive"], + ["mistral_ai","Mistral AI"], + ["mixmax","Mixmax"], + ["mixpanel","Mixpanel"], + ["mobbin_mcp","Mobbin MCP"], + ["mocean","Mocean"], + ["moco","Moco"], + ["modelry","Modelry"], + ["modem","Modem"], + ["modem_mcp","Modem MCP"], + ["moderationapi","Moderation API"], + ["monday","Monday"], + ["monday_mcp","Monday MCP"], + ["moneybird","Moneybird"], + ["mongodb_mcp","MongoDB MCP"], + ["moonclerk","Moonclerk"], + ["moosend","Moosend"], + ["mopinion","Mopinion"], + ["more_trees","More Trees"], + ["mospi_mcp","MoSPI MCP"], + ["motion","Motion"], + ["moz","Moz"], + ["mrscraper","MrScraper"], + ["msg91","Msg91"], + ["muapi","MuAPI"], + ["mural","Mural"], + ["mx_technologies","MX Technologies"], + ["mx_toolbox","MXToolbox"], + ["myemailverifier","MyEmailVerifier"], + ["namsor","NamSor"], + ["nango","Nango"], + ["nano_nets","Nanonets"], + ["nanonets_ocr","Nanonets OCR"], + ["nasa","Nasa"], + ["nasdaq","Nasdaq"], + ["needle","Needle"], + ["neetoform","NeetoForm"], + ["neo4j","Neo4J"], + ["neon","Neon"], + ["neon_mcp","Neon MCP"], + ["nethunt_crm","NetHunt CRM"], + ["netlicensing","NetLicensing"], + ["netlify_mcp","Netlify MCP"], + ["netsuite","Netsuite"], + ["neuronwriter","Neuronwriter"], + ["neutrino","Neutrino"], + ["neverbounce","NeverBounce"], + ["new_relic","New Relic"], + ["news_api","NewsAPI"], + ["nextdns","Nextdns"], + ["ngram","Ngram"], + ["ngrok","Ngrok"], + ["niftyimages","Niftyimages"], + ["ninox","Ninox"], + ["nmkr_studio","NMKR Studio"], + ["nocodb","NocoDB"], + ["nocrm_io","NoCRM.io"], + ["noloco","Noloco"], + ["northflank","Northflank"], + ["notebook_lm","NotebookLM"], + ["notion","Notion"], + ["notion_mcp","Notion MCP"], + ["nozbe_teams","Nozbe Teams"], + ["npm","Npm"], + ["ntfy","Ntfy"], + ["nusii_proposals","Nusii proposals"], + ["nutrient_document_web_services_api","Nutrient Document Web Services API"], + ["nutshell","Nutshell"], + ["nylas_mcp","Nylas MCP"], + ["ocean_io","Ocean.io"], + ["ocr_web_service","OCR Web Service"], + ["ocrspace","OCR.space"], + ["octagon_mcp","Octagon MCP"], + ["odoo","Odoo"], + ["oksign","Oksign"], + ["okzest","OKZest"], + ["ollama","Ollama"], + ["olostep","Olostep"], + ["omnisend","Omnisend"], + ["one_drive","OneDrive"], + ["onedesk","Onedesk"], + ["onenote","Microsoft OneNote"], + ["onepage","Onepage"], + ["onesignal_rest_api","OneSignal REST API"], + ["onesignal_user_auth","OneSignal User Auth"], + ["onesimpleapi","OneSimpleAPI"], + ["onetapcheckin","OneTapCheckIn"], + ["oneup","OneUp"], + ["onlyoffice_docspace","ONLYOFFICE DocSpace"], + ["onlysocial","OnlySocial"], + ["open_register","OpenRegister"], + ["open_sea","OpenSea"], + ["openai","OpenAI"], + ["openalex","OpenAlex"], + ["opencage","Opencage"], + ["openfunnel_agent_primitives","OpenFunnel Agent Primitives"], + ["opengraph_io","OpenGraph.io"], + ["openlayer","Openlayer"], + ["openperplex","Openperplex"], + ["openrouter","Openrouter"], + ["openui","OpenUI Cloud"], + ["openweather_api","OpenWeather API"], + ["openweb_ninja","OpenWeb Ninja"], + ["opn_form","OPNForm"], + ["optimoroute","OptimoRoute"], + ["order_desk","Order Desk"], + ["ordinal","Ordinal"], + ["orgo","Orgo"], + ["orshot","Orshot"], + ["osv","OSV"], + ["otter_ai_mcp","Otter.ai MCP"], + ["outline","Outline"], + ["outlook","Outlook"], + ["owl_protocol","Owl Protocol"], + ["page_vitals","PageVitals"], + ["page_x","Page X"], + ["pagerduty","PagerDuty"], + ["pandadoc","PandaDoc"], + ["pandadoc_mcp","PandaDoc MCP"], + ["paperform","Paperform"], + ["paradym","Paradym"], + ["parallel","Parallel"], + ["parallel_task_mcp","Parallel Task MCP"], + ["parma","Parma"], + ["parsehub","Parsehub"], + ["parsera","Parsera"], + ["parseur","Parseur"], + ["parsio","Parsio"], + ["parsio_io","Parsio.io"], + ["particl_mcp","Particl MCP"], + ["passcreator","Passcreator"], + ["passslot","Passslot"], + ["paychasers","PayChasers"], + ["payhere","Payhere"], + ["payhip","Payhip"], + ["paypal","Paypal"], + ["paystack","Paystack"], + ["pdf_api_io","PDF-API.io"], + ["pdf_blocks","PDF Blocks"], + ["pdf_co","PDF.co"], + ["pdf_munk","PDF Munk"], + ["pdf_vector","PDF Vector"], + ["pdf4me","Pdf4me"], + ["pdfgen","PDFgen"], + ["pdfless","Pdfless"], + ["pdfmonkey","PDFMonkey"], + ["peaka","Peaka"], + ["peec_ai_mcp","Peec AI MCP"], + ["peerdom","Peerdom"], + ["penpot","Penpot"], + ["peopledatalabs","People Data Labs"], + ["perigon","Perigon"], + ["perplexityai","Perplexity AI"], + ["persistiq","Persistiq"], + ["persona","Persona"], + ["personio","Personio"], + ["pexels","Pexels"], + ["phantombuster","PhantomBuster"], + ["photoroom","PhotoRoom"], + ["picnie","Picnie"], + ["piggy","Piggy"], + ["piloterr","Piloterr"], + ["pilvio","Pilvio"], + ["pinata","Pinata"], + ["pinecone","Pinecone"], + ["pingdom","Pingdom"], + ["pinterest","Pinterest"], + ["pinterest_ads","Pinterest Ads"], + ["pipedrive","Pipedrive"], + ["pipeless","Pipeless Recommendations"], + ["pipeline_crm","Pipeline CRM"], + ["placekey","Placekey"], + ["placid","Placid"], + ["plaid_mcp","Plaid MCP"], + ["plain","Plain"], + ["planly","Planly"], + ["planyo_online_booking","Planyo Online Booking"], + ["plasmic","Plasmic"], + ["platerecognizer","Plate Recognizer"], + ["plausible_analytics","Plausible Analytics"], + ["plisio","Plisio"], + ["plivo","Plivo"], + ["pneumatic","Pneumatic"], + ["pocketsmith","PocketSmith"], + ["pointagram","Pointagram"], + ["polar_signals_mcp","Polar Signals MCP"], + ["polygon","Polygon"], + ["polygon_io","Polygon.io"], + ["polymarket","Polymarket US"], + ["polymarket_us","Polymarket US"], + ["poof","Poof"], + ["pophive_mcp","PopHIVE MCP"], + ["popupsmart","Popupsmart"], + ["postal_data_api","Postal Data API"], + ["postalytics","Postalytics"], + ["postgrid","Postgrid"], + ["postgrid_verify","PostGrid Verify"], + ["posthog","PostHog"], + ["posthog_mcp","PostHog MCP"], + ["postiz_mcp","Postiz"], + ["postman","Postman"], + ["postmark","Postmark"], + ["postpone","Postpone"], + ["predict_leads","PredictLeads"], + ["prerender","Prerender"], + ["presenton","Presenton"], + ["printautopilot","Printautopilot"], + ["printify","Printify"], + ["prisma","Prisma"], + ["prismic","Prismic"], + ["proabono","Proabono"], + ["process_street","Process Street"], + ["procfu","Procfu"], + ["productboard","Productboard"], + ["productlane","Productlane"], + ["project_bubble","Project Bubble"], + ["prompt_hub","PromptHub"], + ["promptlayer","PromptLayer"], + ["promptmate_io","Promptmate.io"], + ["proofly","Proofly"], + ["prospeo","Prospeo"], + ["proxiedmail","Proxiedmail"], + ["pumble","Pumble"], + ["purechecker","PureChecker"], + ["push_alert","PushAlert"], + ["push_by_techulus","Push by Techulus"], + ["pushbullet","Pushbullet"], + ["pushinator","Pushinator"], + ["pushover","Pushover"], + ["pylon_mcp","Pylon MCP"], + ["quaderno","Quaderno"], + ["quickbooks","QuickBooks"], + ["quicknode_mcp","QuickNode MCP"], + ["quiltt","Quiltt"], + ["quintadb","QuintaDB"], + ["quo","Quo"], + ["radar","Radar"], + ["rafflys","Rafflys"], + ["ragic","Ragic"], + ["ragie","Ragie"], + ["railway","Railway"], + ["railway_mcp","Railway MCP"], + ["raindrop","Raindrop.io"], + ["raisely","Raisely"], + ["ramp","Ramp"], + ["ramp_mcp","Ramp MCP"], + ["rawg_video_games_database","RAWG Video Games Database"], + ["razorpay","Razorpay"], + ["re_amaze","Re:amaze"], + ["read_ai_mcp","Read AI MCP"], + ["readwise_reader","Readwise Reader"], + ["realphonevalidation","Realphonevalidation"], + ["recallai","Recall.ai"], + ["recruitee","Recruitee"], + ["recurly","Recurly"], + ["redcircle_api","RedCircle API"], + ["reddit","Reddit"], + ["reddit_ads","Reddit Ads"], + ["redirect_pizza","redirect.pizza"], + ["referralrock","ReferralRock"], + ["refiner","Refiner"], + ["reloadly","Reloadly"], + ["remarkety","Remarkety"], + ["remote_retrieval","Remote Retrieval"], + ["remove_bg","Remove.bg"], + ["render","Render"], + ["renderform","Renderform"], + ["rentman","Rentman"], + ["repairshopr","RepairShopr"], + ["replicate","Replicate"], + ["reply","Reply"], + ["reply_io","Reply.io"], + ["request_tracker","Request Tracker (RT)"], + ["resend","Resend"], + ["resend_mcp","Resend MCP"], + ["resource_planner","Resource Planner"], + ["respond_io","Respond.io"], + ["retable","Retable"], + ["retailed","Retailed"], + ["retellai","Retell AI"], + ["reteno","Reteno"], + ["retently","Retently"], + ["rev","Rev AI"], + ["rev_ai","Rev AI"], + ["revolt","Revolt"], + ["ritekit","Ritekit"], + ["rize","Rize"], + ["rkvst","Rkvst"], + ["roam","Roam"], + ["roboflow","Roboflow"], + ["rocket_reach","RocketReach"], + ["rocketadmin","Rocketadmin"], + ["rocketlane","Rocketlane"], + ["rollbar","Rollbar"], + ["rootly","Rootly"], + ["rosette_text_analytics","Rosette Text Analytics"], + ["route4me","Route4me"], + ["rudderstack_transformation","RudderStack Transformation"], + ["runpod","RunPod"], + ["runway","Runway"], + ["safetyculture","SafetyCulture"], + ["salesflare","Salesflare"], + ["salesforce","Salesforce"], + ["salesforce_service_cloud","Salesforce Service Cloud"], + ["salesmate","Salesmate"], + ["salesrobot","SalesRobot"], + ["sanity","Sanity"], + ["sap_successfactors","SAP SuccessFactors"], + ["saperly","Saperly"], + ["satismeter","Satismeter"], + ["saucelabs","Saucelabs"], + ["scale_ai","Scale ai"], + ["scheduleonce","Scheduleonce"], + ["scorecard_mcp","Scorecard MCP"], + ["scrape_do","Scrape Do"], + ["scrapecreators","ScrapeCreators"], + ["scrapegraph_ai","Scrapegraph Ai"], + ["scrapfly","Scrapfly"], + ["scrapingant","Scrapingant"], + ["scrapingbee","Scrapingbee"], + ["screenshot_api","Screenshot API"], + ["screenshot_fyi","Screenshot.fyi"], + ["screenshotone","ScreenshotOne"], + ["seam","Seam"], + ["search_api","Search api"], + ["searchapi_mcp","SearchAPI MCP"], + ["seat_geek","Seat Geek"], + ["seatable","SeaTable"], + ["sec","SEC EDGAR"], + ["securitytrails","Securitytrails"], + ["seek_table","SeekTable"], + ["segment","Segment"], + ["segmetrics","Segmetrics"], + ["semanticscholar","Semantic Scholar"], + ["semrush","Semrush"], + ["semrush_mcp","Semrush MCP"], + ["sendbird","Sendbird"], + ["sendbird_ai_chabot","Sendbird AI Chatbot"], + ["sender","Sender"], + ["sendfox","Sendfox"], + ["sendgrid","SendGrid"], + ["sendlane","Sendlane"], + ["sendloop","Sendloop"], + ["sendspark","Sendspark"], + ["sensibo","Sensibo"], + ["sentry","Sentry"], + ["sentry_mcp","Sentry MCP"], + ["seqera","Seqera"], + ["sequenzy","Sequenzy"], + ["serpapi","SerpApi"], + ["serpdog","Serpdog"], + ["serper","Serper"], + ["serphouse","Serphouse"], + ["serply","Serply"], + ["serveravatar","Serveravatar"], + ["servicem8","Servicem8"], + ["servicenow","ServiceNow"], + ["sevdesk","Sevdesk"], + ["seven_io","Seven.io"], + ["share_point","SharePoint"], + ["sharepoint_graph","SharePoint Graph"], + ["shipday","Shipday"], + ["shipengine","Shipengine"], + ["shippo","Shippo"], + ["shopify","Shopify"], + ["short_io","Short Io"], + ["short_menu","Short Menu"], + ["shortcut","Shortcut"], + ["shorten_rest","Shorten.REST"], + ["shortpixel","Shortpixel"], + ["shotstack","Shotstack"], + ["sideshow","Sideshow"], + ["sideshow_mcp","Sideshow MCP"], + ["sidetracker","Sidetracker"], + ["signaturely","Signaturely"], + ["signpath","Signpath"], + ["signwell","Signwell"], + ["similarweb_digitalrank_api","SimilarWeb Digital Rank API"], + ["simla_com","Simla.com"], + ["simple_analytics","Simple Analytics"], + ["simple_localize","SimpleLocalize"], + ["simplekpi","Simplekpi"], + ["simplero","Simplero"], + ["simplesat","Simplesat"], + ["simplescraper_mcp","Simplescraper MCP"], + ["siteleaf","Siteleaf"], + ["sitespeakai","Sitespeakai"], + ["skyfire","Skyfire"], + ["skyvern","Skyvern"], + ["slack","Slack"], + ["slackbot","Slackbot"], + ["slazzer","Slazzer"], + ["slidesgpt_mcp","SlidesGPT MCP"], + ["slite","Slite"], + ["smartlead","Smartlead"], + ["smartproxy","Smartproxy"], + ["sms_alert","SMS Alert"], + ["smtp2go","Smtp2go"], + ["smugmug","SmugMug"], + ["snapchat","Snapchat"], + ["sncf","SNCF"], + ["snowflake","Snowflake"], + ["snowflake_basic","Snowflake Basic"], + ["softr","Softr"], + ["solcast","Solcast"], + ["soundcloud","SoundCloud"], + ["sourcegraph","Sourcegraph"], + ["specific","Specific"], + ["speechmatics","Speechmatics"], + ["splice_mcp","Splice MCP"], + ["splitwise","Splitwise"], + ["spoki","Spoki"], + ["spondyr","Spondyr"], + ["spotify","Spotify"], + ["spotlightr","Spotlightr"], + ["spydra","Spydra"], + ["square","Square"], + ["sslmate_cert_spotter_api","SSLMate Cert Spotter API"], + ["stack_ai","Stack Ai"], + ["stack_exchange","Stack Exchange"], + ["stackby","Stackby"], + ["stannp","Stannp"], + ["starshipit","Starshipit"], + ["starton","Starton"], + ["statsig","Statsig"], + ["statsig_mcp","Statsig MCP"], + ["statuscake","Statuscake"], + ["storeganise","Storeganise"], + ["storerocket","Storerocket"], + ["stormboard","Stormboard"], + ["stormglass_io","Stormglass Io"], + ["storyblok","Storyblok"], + ["strale","Strale"], + ["streamtime","Streamtime"], + ["stripe","Stripe"], + ["stripe_mcp","Stripe MCP"], + ["studio_by_ai21_labs","Studio By Ai21 Labs"], + ["stytch_mcp","Stytch MCP"], + ["suitedash","Suitedash"], + ["supabase","Supabase"], + ["supabase_mcp","Supabase MCP"], + ["supabase_read_mcp","Supabase Read MCP"], + ["supadata","Supadata"], + ["superchat","Superchat"], + ["supermemory","Supermemory"], + ["supersaas","Supersaas"], + ["supportbee","Supportbee"], + ["supportivekoala","Supportivekoala"], + ["surecontact","SureContact"], + ["survey_monkey","SurveyMonkey"], + ["svix","Svix"], + ["swaggerhub","Swaggerhub"], + ["swarmsyncai","Swarmsyncai"], + ["sympla","Sympla"], + ["synthesize_bio_mcp","Synthesize Bio MCP"], + ["synthflow_ai","Synthflow AI"], + ["tableau","Tableau"], + ["taggun","Taggun"], + ["tailscale","Tailscale"], + ["talenthr","Talenthr"], + ["tally","Tally"], + ["tapfiliate","Tapfiliate"], + ["taskrabbit_mcp","Taskrabbit MCP"], + ["tave","Tave"], + ["tavily","Tavily"], + ["tavily_mcp","Tavily MCP"], + ["taxjar","Taxjar"], + ["teamcamp","Teamcamp"], + ["teamwork","Teamwork"], + ["telegram","Telegram"], + ["telnyx","Telnyx"], + ["teltel","Teltel"], + ["templated","Templated"], + ["temporal","Temporal"], + ["text_to_pdf","Text to PDF"], + ["textcortex","Textcortex"], + ["textit","Textit"], + ["textrazor","TextRazor"], + ["thanks_io","Thanks.io"], + ["the_odds_api","The Odds API"], + ["the_org","The Org"], + ["thecompaniesapi","The Companies API"], + ["theirstack","TheirStack"], + ["thoughtspot_spotter_mcp","ThoughtSpot Spotter MCP"], + ["ticket_tailor_mcp","Ticket Tailor MCP"], + ["ticketmaster","Ticketmaster"], + ["ticktick","Ticktick"], + ["tidb_cloud","TiDB Cloud"], + ["tidy","Tidy"], + ["tik_hub","TikHub"], + ["tiktok","Tiktok"], + ["tiktok_ads","TikTok Ads"], + ["timecamp","TimeCamp"], + ["timelinesai","TimelinesAI"], + ["timelink","Timelink"], + ["timely","Timely"], + ["tinyfish","TinyFish"], + ["tinyfish_mcp","TinyFish MCP"], + ["tinypng","TinyPNG"], + ["tinyurl","Tinyurl"], + ["tisane","Tisane Labs"], + ["tldv","Tldv"], + ["todoist","Todoist"], + ["todoist_mcp","Todoist MCP"], + ["toggl","Toggl"], + ["toggl_track","Toggl Track"], + ["token_metrics","Token Metrics"], + ["tomba","Tomba"], + ["tomtom","Tomtom"], + ["toneden","Toneden"], + ["tpscheck","Tpscheck"], + ["trackdesk","Trackdesk"], + ["transloadit","Transloadit"], + ["trello","Trello"], + ["trigger_dev","Trigger.dev"], + ["triggercmd","Triggercmd"], + ["tripadvisor","Tripadvisor"], + ["tripadvisor_content_api","TripAdvisor Content API"], + ["tripo3d","Tripo3D"], + ["trivago_mcp","Trivago MCP"], + ["true_list","TrueList"], + ["truthscan","TruthScan"], + ["truvera","Truvera"], + ["turbot_pipes","Turbot Pipes"], + ["turso","Turso"], + ["twelve_data","Twelve Data"], + ["twenty","Twenty"], + ["twitch","Twitch"], + ["twitter","Twitter"], + ["twittershots","TwitterShots"], + ["twocaptcha","Twocaptcha"], + ["typecast","Typecast"], + ["typeform","Typeform"], + ["typefully","Typefully"], + ["typless","Typless"], + ["u301","U301"], + ["unifi","UniFi"], + ["unione","Unione"], + ["unipile","Unipile"], + ["unisender","Unisender"], + ["uniswap_api","Uniswap Api"], + ["updown_io","Updown.io"], + ["uplisting","Uplisting"], + ["upload_post","Upload-Post"], + ["upload_to_url","Upload to URL"], + ["uploadcare","Uploadcare"], + ["uptimerobot","Uptimerobot"], + ["urlscanio","urlscan.io"], + ["userflow","Userflow"], + ["userlist","Userlist"], + ["v0","V0"], + ["vantage_mcp","Vantage MCP"], + ["vapi","Vapi"], + ["vast_ai","Vast.ai"], + ["vectorshift","Vectorshift"], + ["veo","Veo"], + ["vercel","Vercel"], + ["vercel_ai_gateway","Vercel AI Gateway"], + ["vercel_mcp","Vercel MCP"], + ["verifi_email","Verifi.Email"], + ["verifiedemail","Verifiedemail"], + ["veriphone","Veriphone"], + ["veryfi","Veryfi OCR"], + ["vestaboard","Vestaboard"], + ["viator_mcp","Viator MCP"], + ["vibe","Vibe"], + ["vibe_prospecting_mcp","Vibe Prospecting MCP"], + ["video_db","VideoDB"], + ["vidiq_mcp","vidIQ MCP"], + ["vimeo","Vimeo"], + ["viral_loops","Viral Loops"], + ["virustotal","Virustotal"], + ["visualping","Visualping"], + ["vlm_run","VLM Run"], + ["voiceflow","Voiceflow"], + ["waboxapp","Waboxapp"], + ["wachete","Wachete"], + ["waiverfile","Waiverfile"], + ["wakatime","WakaTime"], + ["wanikani","WaniKani"], + ["wati","Wati"], + ["weathermap","OpenWeatherMap"], + ["web3_career","Web3 Career"], + ["webcrawlerapi","WebCrawlerAPI"], + ["webex","Webex"], + ["webflow","Webflow"], + ["webpushr","Webpushr"], + ["webscraper_io","Webscraper io"], + ["webscraping_ai","WebScraping.AI"], + ["webvizio","Webvizio"], + ["weights_and_biases","Weights & Biases"], + ["whatsapp","WhatsApp"], + ["whautomate","Whautomate"], + ["whoisfreaks","Whoisfreaks"], + ["whop","Whop"], + ["wild_apricot","WildApricot"], + ["windsor_ai","Windsor.ai"], + ["winston_ai","Winston AI"], + ["wise","Wise"], + ["wisepops","Wisepops"], + ["wispr_flow_mcp","Wispr Flow MCP"], + ["wistia","Wistia"], + ["wit_ai","Wit.ai"], + ["withpica","withPICA"], + ["wix","Wix"], + ["wix_mcp","Wix MCP"], + ["wiza","Wiza"], + ["wolfram_alpha_api","Wolfram Alpha Api"], + ["woodpecker_co","Woodpecker Co"], + ["woop_social","WoopSocial"], + ["wordpress_com","WordPress.com"], + ["workable","Workable"], + ["workday","Workday"], + ["workday_raas","Workday RaaS"], + ["workiom","Workiom"], + ["workiz","Workiz"], + ["workos","WorkOS"], + ["worksnaps","Worksnaps"], + ["world_news_api","World News API"], + ["wrike","Wrike"], + ["writer","Writer"], + ["wufoo","Wufoo"], + ["xata","Xata"], + ["xero","Xero"], + ["y_gy","Y gy"], + ["yandex","Yandex"], + ["yelp","Yelp"], + ["ynab","YNAB"], + ["yousearch","You.com"], + ["youtube","YouTube"], + ["youtube_transcript","YouTube Transcript"], + ["zendesk","Zendesk"], + ["zenrows","ZenRows"], + ["zenserp","Zenserp"], + ["zep","Zep"], + ["zeplin","Zeplin"], + ["zernio_mcp","Zernio MCP"], + ["zerobounce","Zerobounce"], + ["zigpoll","Zigpoll"], + ["ziprecruiter_mcp","ZipRecruiter MCP"], + ["zixflow","Zixflow"], + ["zoho","Zoho"], + ["zoho_bigin","Zoho Bigin"], + ["zoho_books","Zoho Books"], + ["zoho_desk","Zoho Desk"], + ["zoho_inventory","Zoho Inventory"], + ["zoho_invoice","Zoho Invoice"], + ["zoho_mail","Zoho Mail"], + ["zoom","Zoom"], + ["zoom_chat","Zoom Team Chat"], + ["zoominfo","Zoominfo"], + ["zulip","Zulip"], + ["zylvie","Zylvie"], + ["zyte_api","Zyte Api"] + ] +} diff --git a/packages/shared/src/connection-intent-guidance.ts b/packages/shared/src/connection-intent-guidance.ts index 69243dcf176..3866243d6ae 100644 --- a/packages/shared/src/connection-intent-guidance.ts +++ b/packages/shared/src/connection-intent-guidance.ts @@ -13,7 +13,7 @@ export const CONNECTION_INTENT_AGENT_GUIDANCE = [ "- Do not use connection tools for arbitrary MCP URLs or unrelated work.", ].join("\n"); -export const CONNECTIONS_SEARCH_TOOL_DESCRIPTION = "Search Paperclip connections first and eligible external aggregator routes when no built-in service matches. Use first when the user asks to connect a service, or when usable access is uncertain; follow the returned instruction and exact providerQuestion, if any. Do not use for arbitrary MCP URLs. Search is read-only."; +export const CONNECTIONS_SEARCH_TOOL_DESCRIPTION = "Search connections across tool, channel/email, and AI purposes with a service name or a natural-language description (up to 4000 characters). Results tolerate extra words, split names, and small typos. Choose the relevant match using its description, method purpose, and setupPath; tool methods use connection_request. Also discovers verified external aggregator apps. Use first when the user asks to connect a service, or when usable access is uncertain; follow the returned instruction and exact providerQuestion, if any. Do not use for arbitrary MCP URLs. Search is read-only."; export const CONNECTION_REQUEST_TOOL_DESCRIPTION = "Request the service identifier returned by connections_search as available or needs_user_action. Follow the search instruction; aggregator routes require the saved provider-selection interaction ID. Only this tool creates the real setup card. If user action is needed, finish independent work, then yield without retrying or asking for credentials in comments."; diff --git a/packages/shared/src/connection-routing.ts b/packages/shared/src/connection-routing.ts index 6ec772f8137..427437722db 100644 --- a/packages/shared/src/connection-routing.ts +++ b/packages/shared/src/connection-routing.ts @@ -6,6 +6,8 @@ import { isRemoteMcpConnectorId, type RemoteMcpConnectorId, } from "./remote-mcp-connectors.js"; +import composioCatalog from "./composio-search-catalog.json" with { type: "json" }; +import { prepareConnectionSearch, scoreConnectionSearch } from "./connection-search.js"; export const AGGREGATOR_PRIORITY = [ "composio", @@ -24,7 +26,7 @@ export const AGGREGATOR_NAMES: Record = { * Add only services confirmed in the cited official catalog. No provider credentials. * Executor has no universal app catalog: use the authorized workspace's indexed tools. */ -export const AGGREGATOR_SUPPORT_INDEX = [ +const REVIEWED_AGGREGATOR_SUPPORT = [ { slug: "hubspot", name: "HubSpot", @@ -182,6 +184,43 @@ export const AGGREGATOR_SUPPORT_INDEX = [ }, }, ] as const; + +export interface AggregatorServiceDefinition { + slug: string; + name: string; + aliases: readonly string[]; + providers: Partial>; + evidenceUrls?: Partial>; +} + +// Keep the other providers' reviewed support claims, and cover the complete +// public Composio catalog instead of maintaining a tiny app-name allowlist. +export const AGGREGATOR_SUPPORT_INDEX: AggregatorServiceDefinition[] = REVIEWED_AGGREGATOR_SUPPORT.map(entry => ({ ...entry })); +for (const [toolkit, name] of composioCatalog.toolkits as Array<[string, string]>) { + const slug = toolkit.replaceAll("_", "-").replace(/^-+/, ""); + const evidenceUrl = `https://docs.composio.dev/toolkits/${toolkit}`; + const existing = AGGREGATOR_SUPPORT_INDEX.find(entry => entry.slug === slug + || (toolkit.endsWith("_mcp") && entry.slug === slug.slice(0, -4))); + if (existing) { + existing.aliases = [...new Set([...existing.aliases, toolkit, name])]; + existing.providers = { ...existing.providers, composio: composioCatalog.verifiedAt }; + existing.evidenceUrls ??= { composio: evidenceUrl }; + } else { + AGGREGATOR_SUPPORT_INDEX.push({ slug, name, + aliases: [toolkit, name.replace(/ MCP$/i, "")], + providers: { composio: composioCatalog.verifiedAt }, + evidenceUrls: { composio: evidenceUrl }, + }); + } +} + +export function searchAggregatorServices(query: string | ReturnType) { + const prepared = typeof query === "string" ? prepareConnectionSearch(query) : query; + return AGGREGATOR_SUPPORT_INDEX.map(service => ({ service, + ...scoreConnectionSearch(prepared, [service.slug, service.name, ...service.aliases]), + })).filter(match => match.nameScore > 0 && !isRemoteMcpConnectorId(match.service.slug)) + .sort((a, b) => b.score - a.score || a.service.slug.localeCompare(b.service.slug)); +} export const AGGREGATOR_CATALOG_SOURCES: Partial< Record > = { @@ -197,7 +236,7 @@ export function normalizeConnectionQuery(value: string): string { .trim(); } -export function findAggregatorService(query: string) { +export function findAggregatorService(query: string, rankedMatches?: ReturnType) { const normalized = normalizeConnectionQuery(query); const exact = AGGREGATOR_SUPPORT_INDEX.find((entry) => [entry.slug, entry.name, ...entry.aliases].some( @@ -207,12 +246,14 @@ export function findAggregatorService(query: string) { if (exact) return exact; // Agents often include the desired capability ("HubSpot recent contacts"). // Whole phrases avoid substring guesses; multiple named services need clarification. - const matches = AGGREGATOR_SUPPORT_INDEX.filter((entry) => - [entry.slug, entry.name, ...entry.aliases].some((name) => - ` ${normalized} `.includes(` ${normalizeConnectionQuery(name)} `), - ), - ); - return matches.length === 1 ? matches[0] : undefined; + const matches = (rankedMatches ?? searchAggregatorServices(query)) + .filter(match => match.nameScore >= 500).map(match => match.service); + // "Atlassian Jira" identifies Jira, not both Jira and Atlassian's MCP. + const phrases = (entry: AggregatorServiceDefinition) => [entry.slug, entry.name, ...entry.aliases] + .map(normalizeConnectionQuery).filter(name => ` ${normalized} `.includes(` ${name} `)); + const specific = matches.filter(entry => !matches.some(other => other !== entry + && phrases(entry).every(name => phrases(other).some(longer => longer !== name && ` ${longer} `.includes(` ${name} `))))); + return specific.length === 1 ? specific[0] : undefined; } /** Preserve a provider explicitly named by the user instead of applying default ranking. */ diff --git a/packages/shared/src/connection-search.test.ts b/packages/shared/src/connection-search.test.ts new file mode 100644 index 00000000000..fc7a3ee0df2 --- /dev/null +++ b/packages/shared/src/connection-search.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { scoreConnectionSearch } from "./connection-search.js"; +import { AGGREGATOR_SUPPORT_INDEX, searchAggregatorServices } from "./connection-routing.js"; + +describe("connection search relevance", () => { + it.each(["agentmail", "agent mail", "AGENT-MAIL", "Agentmial", "Agentmai", "Agentmaill"])( + "recognizes a name in a long query: %s", name => { + expect(scoreConnectionSearch(`Please acquire an email address through ${name} and remember it`, ["AgentMail"]).nameScore).toBeGreaterThan(0); + }, + ); + it("ranks a named service above generic capability overlap", () => { + const query = "Help me connect Linear to read project documentation"; + expect(scoreConnectionSearch(query, ["Linear"]).score) + .toBeGreaterThan(scoreConnectionSearch(query, ["Notion"], "Read project documentation").score); + }); + it("ignores repeated filler and retains capability matches", () => { + const base = scoreConnectionSearch("spreadsheets", ["Sheets"], "Read spreadsheets"); + expect(scoreConnectionSearch("please please help me find tools for spreadsheets", ["Sheets"], "Read spreadsheets").score).toBe(base.score); + }); + it.each(["email", "contacts"])("retains the capability word %s without treating it as a product name", word => { + expect(scoreConnectionSearch(`Find a connection for ${word}`, ["Workspace"], `Search ${word}`).score).toBeGreaterThan(0); + expect(scoreConnectionSearch(`Find a connection for ${word}`, [word]).nameScore).toBe(0); + }); + it("supports a partial name without turning substrings of prose into service claims", () => { + expect(scoreConnectionSearch("agen", ["AgentMail"]).nameScore).toBeGreaterThan(0); + expect(scoreConnectionSearch("notionally similar", ["Notion"]).nameScore).toBe(0); + expect(scoreConnectionSearch("please find tools", ["Findymail"]).nameScore).toBe(0); + expect(scoreConnectionSearch("our team's projects", ["Teams"]).nameScore).toBe(0); + }); + it("indexes the public aggregator catalog with valid routes and evidence", () => { + expect(AGGREGATOR_SUPPORT_INDEX.length).toBeGreaterThan(1000); + expect(new Set(AGGREGATOR_SUPPORT_INDEX.map(app => app.slug)).size).toBe(AGGREGATOR_SUPPORT_INDEX.length); + for (const app of AGGREGATOR_SUPPORT_INDEX) expect(app.slug).toMatch(/^[a-z0-9][a-z0-9-]{0,79}$/); + expect(searchAggregatorServices("circle back meeting notes")[0]?.service).toMatchObject({ + slug: "circleback-mcp", evidenceUrls: { composio: "https://docs.composio.dev/toolkits/circleback_mcp" }, + }); + }); +}); diff --git a/packages/shared/src/connection-search.ts b/packages/shared/src/connection-search.ts new file mode 100644 index 00000000000..6aa86e3fb28 --- /dev/null +++ b/packages/shared/src/connection-search.ts @@ -0,0 +1,73 @@ +/** Retrieval only. A fuzzy match is never evidence of authorization or consent. */ +export function normalizeConnectionSearch(value: string): string { + return value.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/['’]s\b/g, "") + .replace(/[^\p{L}\p{N}]+/gu, " ").trim(); +} + +const STOP_WORDS = new Set("a an and are as at be by can connect connection connections do find for from get help i in is it me my need not of on or our please real service services so some that the their them there this to tool tools use want we with would you your".split(" ")); +const GENERIC_NAMES = new Set([...STOP_WORDS, "contacts", "email"]); + +/** Reuse normalized words and deduplicated name windows across a catalog scan. */ +export function prepareConnectionSearch(query: string) { + const normalized = normalizeConnectionSearch(query); + const words = normalized ? normalized.split(" ") : []; + const phrases = new Set(); + const phrasesByLength = new Map>(); + for (let start = 0; start < words.length; start++) { + let phrase = ""; + for (let size = 1; size <= 6 && start + size <= words.length; size++) { + phrase += words[start + size - 1]; + phrases.add(phrase); + const bucket = phrasesByLength.get(phrase.length) ?? new Set(); + bucket.add(phrase); + phrasesByLength.set(phrase.length, bucket); + } + } + return { normalized, compact: words.join(""), phrases, phrasesByLength, + terms: [...new Set(words.filter(word => word.length > 1 && !STOP_WORDS.has(word)))], + }; +} + +function oneEditApart(a: string, b: string): boolean { + if (Math.abs(a.length - b.length) > 1) return false; + let i = 0; + while (i < Math.min(a.length, b.length) && a[i] === b[i]) i++; + if (a.length === b.length) { + return a.slice(i + 1) === b.slice(i + 1) + || (a[i] === b[i + 1] && a[i + 1] === b[i] && a.slice(i + 2) === b.slice(i + 2)); + } + return a.length > b.length ? a.slice(i + 1) === b.slice(i) : a.slice(i) === b.slice(i + 1); +} + +export function scoreConnectionSearch(query: string | ReturnType, names: readonly string[], description = "") { + const prepared = typeof query === "string" ? prepareConnectionSearch(query) : query; + const { normalized } = prepared; + if (!normalized) return { score: 1, nameScore: 0 }; + let nameScore = 0; + for (const name of names) { + const normalizedName = normalizeConnectionSearch(name); + const compact = normalizedName.replaceAll(" ", ""); + if (!compact) continue; + if (compact === prepared.compact) { + nameScore = 1000; + break; + } + if (normalizedName.split(" ").every(word => GENERIC_NAMES.has(word))) continue; + if (prepared.phrases.has(compact)) { + nameScore = Math.max(nameScore, 500 + compact.length); + continue; + } + if (compact.length >= 5 && nameScore < 500) { + const fuzzy = [compact.length - 1, compact.length, compact.length + 1].some(length => + length >= 5 && [...(prepared.phrasesByLength.get(length) ?? [])].some(phrase => oneEditApart(phrase, compact))); + if (fuzzy) nameScore = Math.max(nameScore, 200 + compact.length); + } + if (prepared.compact.length >= 4 && compact.startsWith(prepared.compact)) { + nameScore = Math.max(nameScore, 100 + prepared.compact.length); + } + } + const haystack = new Set(normalizeConnectionSearch(description).split(" ")); + const matched = prepared.terms.filter(term => haystack.has(term) + || (term.length >= 4 && [...haystack].some(word => word.startsWith(term) || (word.length >= 4 && term.startsWith(word))))); + return { nameScore, score: nameScore + Math.min(80, matched.length * 5) }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3ee521d48fe..5785b87e742 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2783,6 +2783,7 @@ export * from "./slack-tools.js"; export { MEMORY_CONNECTOR_IDS, isMemoryConnectorId, type MemoryConnectorId } from "./memory-connectors.js"; export * from "./connection-routing.js"; +export * from "./connection-search.js"; export { WORKSPACE_RESTORE_FAILURE_CODES, hasWorkspaceRestoreFailure, safeWorkspaceRestorePath, isNativeWorkspaceExportRepairCause } from "./workspace-restore.js"; diff --git a/packages/shared/src/types/connection-intent.ts b/packages/shared/src/types/connection-intent.ts index 8ed66ef8e51..128e1bc6de1 100644 --- a/packages/shared/src/types/connection-intent.ts +++ b/packages/shared/src/types/connection-intent.ts @@ -28,6 +28,10 @@ export interface ConnectionSearchResultItem { key: string; label: string; auth: "oauth" | "api_key" | "none"; + /** Discovery is open across purposes; connection_request creates tool cards. */ + purpose?: "tool" | "channel" | "ai"; + /** Company-scoped setup destination for methods with a separate setup flow. */ + setupPath?: string; }>; state: ConnectionAvailabilityState; connectionId: string | null; diff --git a/packages/shared/src/validators/connection-intent.test.ts b/packages/shared/src/validators/connection-intent.test.ts index 557cfd3d98e..609569e31a9 100644 --- a/packages/shared/src/validators/connection-intent.test.ts +++ b/packages/shared/src/validators/connection-intent.test.ts @@ -63,4 +63,9 @@ describe("connection intent contracts", () => { expect(connectionsSearchInputSchema.parse({ query: " notion " })).toEqual({ query: "notion" }); expect(connectionRequestInputSchema.parse({ service: " notion " })).toEqual({ service: "notion" }); }); + it("accepts long natural-language searches while bounding input size", () => { + const query = "Please help me find a connection. ".repeat(30) + "AgentMail inbox"; + expect(connectionsSearchInputSchema.parse({ query }).query).toBe(query); + expect(connectionsSearchInputSchema.safeParse({ query: "x".repeat(4001) }).success).toBe(false); + }); }); diff --git a/packages/shared/src/validators/connection-intent.ts b/packages/shared/src/validators/connection-intent.ts index 11df06ce333..5bc7b48cbdd 100644 --- a/packages/shared/src/validators/connection-intent.ts +++ b/packages/shared/src/validators/connection-intent.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const connectionsSearchInputSchema = z.object({ - query: z.string().trim().max(200).default(""), + query: z.string().trim().max(4000).default(""), retryProviderChoice: z.boolean().optional().describe("Only when the user explicitly asks to reconsider a previous provider choice or decline"), }).strict(); diff --git a/scripts/update-composio-search-catalog.mjs b/scripts/update-composio-search-catalog.mjs new file mode 100644 index 00000000000..226c00acc6e --- /dev/null +++ b/scripts/update-composio-search-catalog.mjs @@ -0,0 +1,28 @@ +import { readFile, writeFile } from "node:fs/promises"; + +// Public names and toolkit identifiers only. Search never sends user queries +// to this website or requires a Composio account. +const source = "https://docs.composio.dev/toolkits"; +const response = process.argv[2] ? null : await fetch(source); +if (response && !response.ok) throw new Error(`Catalog returned ${response.status}`); +const html = process.argv[2] ? await readFile(process.argv[2], "utf8") : await response.text(); +const decode = (value) => value.replace(/&#x([0-9a-f]+);|&#(\d+);|&(amp|quot|apos|lt|gt);/gi, + (_, hex, decimal, named) => hex || decimal ? String.fromCodePoint(parseInt(hex ?? decimal, hex ? 16 : 10)) + : ({ amp: "&", quot: '"', apos: "'", lt: "<", gt: ">" })[named.toLowerCase()]); +const entries = new Map(); +for (const match of html.matchAll(/]*href="\/toolkits\/([a-z0-9_]+)"[^>]*>([\s\S]*?)<\/a>/g)) { + const name = match[2].match(/]*class="truncate text-sm font-medium[^"\n]*"[^>]*>([^<]+)<\/span>/)?.[1]; + if (name) entries.set(match[1], decode(name)); +} +if (entries.size < 1000) throw new Error(`Catalog extraction returned only ${entries.size} entries; inspect the source before updating`); +const rows = [...entries].sort(([a], [b]) => a.localeCompare(b)); +const result = `{ + "source": ${JSON.stringify(source)}, + "verifiedAt": ${JSON.stringify(new Date().toISOString().slice(0, 10))}, + "toolkits": [ +${rows.map(row => ` ${JSON.stringify(row)}`).join(",\n")} + ] +} +`; +await writeFile(new URL("../packages/shared/src/composio-search-catalog.json", import.meta.url), result); +console.log(`Saved ${rows.length} public toolkit names from ${source}`); diff --git a/server/src/__tests__/connection-aggregator-fallback.test.ts b/server/src/__tests__/connection-aggregator-fallback.test.ts index 80c905336e5..3698eeb1e3f 100644 --- a/server/src/__tests__/connection-aggregator-fallback.test.ts +++ b/server/src/__tests__/connection-aggregator-fallback.test.ts @@ -21,6 +21,7 @@ import { } from "@paperclipai/db"; import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js"; import { connectionIntentService } from "../services/connection-intents.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; import { issueThreadInteractionService } from "../services/issue-thread-interactions.js"; import { getEmbeddedPostgresTestSupport, @@ -256,6 +257,100 @@ const support = await getEmbeddedPostgresTestSupport(); } return connection!; } + it.each([ + ["AgentMail create an email address and manage an agent mailbox", "agentmail"], + ["Please acquire an email address through agent mail and remember it", "agentmail"], + ["I need an Agentmial inbox for receiving mail from customers", "agentmail"], + ["Please connect my Linear workspace so I can triage the team's backlog", "linear"], + ["Find a Notion connection to search our engineering documentation", "notion"], + ["Can you find the OpenRouter connection for the model I want to use?", "openrouter"], + ["Please help me connect to Git Hub to look at my pull requests", "github"], + ])("finds native connections in natural-language queries: %s", async (query, service) => { + await resetQuestions(); + await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true }); + try { + const result = await connectionIntentService(db).search(claims, query); + expect(result.results[0]).toMatchObject({ service, state: "available" }); + expect(result.providerQuestion).toBeUndefined(); + } finally { + await instanceSettingsService(db).updateExperimental({ enableChatConnectors: false }); + } + }); + it("discovers all purposes with truthful setup actions and the chat feature gate", async () => { + await resetQuestions(); + const service = connectionIntentService(db); + expect((await service.search(claims, "agentmail")).results.some(item => item.service === "agentmail")).toBe(false); + await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true }); + try { + const result = await service.search(claims, "agentmail"); + expect(result.results[0]?.methods).toEqual([expect.objectContaining({ + key: "email-agent", purpose: "channel", setupPath: `/AGG/apps/chat/connect?provider=agentmail&purpose=chat&agentId=${claims.sub}`, + })]); + expect(result.instruction).toContain("setupPath"); + const browse = await service.search(claims, ""); + expect(browse.results.some(item => item.service === "agentmail")).toBe(true); + expect(browse.results.some(item => item.service === "anthropic")).toBe(true); + } finally { + await instanceSettingsService(db).updateExperimental({ enableChatConnectors: false }); + } + }); + it("keeps useful capability matches even when other query words do not occur in the catalog", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search(claims, + "I need something that can search documents and spreadsheets for our quarterly planning discussion"); + expect(result.results.some(item => item.service === "google-drive")).toBe(true); + expect(result.results.some(item => item.service === "google-sheets")).toBe(true); + expect(result.instruction).not.toContain("search its exact name"); + }); + it.each([ + ["help me find tools for circle back", "circleback-mcp"], + ["Find Circlebak meeting transcripts and action items from yesterday", "circleback-mcp"], + ["Can you connect Circleback MCP to get all our meeting notes?", "circleback-mcp"], + ["Find Attio tools to review all our customer contacts before next week's meeting", "attio"], + ["Help me find a ClickUp connection to organize our team's projects", "clickup"], + ])("finds verified aggregator apps in natural-language queries: %s", async (query, target) => { + await resetQuestions(); + const result = await connectionIntentService(db).search(claims, query); + expect(result.results).toEqual(expect.arrayContaining([expect.objectContaining({ + service: `via:composio:${target}`, source: "aggregator", + aggregator: expect.objectContaining({ evidenceUrl: expect.stringContaining("composio.dev/"), targetService: target }), + })])); + expect(result.providerQuestion?.options.map(option => option.id)).toContain(`via:composio:${target}`); + await expect(connectionIntentService(db).request(claims, `via:composio:${target}`)).rejects.toThrow(); + }); + it.each([false, true])("prefers an exact Motion match over fuzzy Notion (Notion denied: %s)", async denied => { + await resetQuestions(); + if (denied) await seedProvider("notion", "notion_search", "responsible-user", false); + const result = await connectionIntentService(db).search(claims, "Help me find Motion tools"); + expect(result.results[0]?.service).toBe("via:composio:motion"); + expect(result.providerQuestion?.id).toBe("connection-provider:motion"); + expect(result.instruction).not.toContain("administratively restricted"); + }); + it("finds namespaced installed aggregator tools with extra query words", async () => { + await resetQuestions(); + await seedProvider("executor", "heliotrope:list_records"); + const result = await connectionIntentService(db).search(claims, "Please find heliotrope tools for our weekly report"); + expect(result.results[0]?.service).toBe("via:executor:heliotrope"); + }); + it("returns multiple named services so the agent can choose the relevant result", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search(claims, "Find Linear or Notion for our project planning"); + expect(result.results.map(item => item.service)).toEqual(expect.arrayContaining(["linear", "notion"])); + }); + it("returns multiple aggregator apps without inventing a combined route or choosing consent", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search(claims, "Find Circleback and Attio tools"); + expect(result.results.map(item => item.service)).toEqual(expect.arrayContaining(["via:composio:circleback-mcp", "via:composio:attio"])); + expect(result.providerQuestion).toBeUndefined(); + expect(result.instruction).toContain("aggregator.targetService"); + }); + it("keeps native and aggregator app matches from the same query", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search(claims, "Find Notion and Circleback tools"); + expect(result.results.map(item => item.service)).toEqual(expect.arrayContaining(["notion", "via:composio:circleback-mcp"])); + expect(result.providerQuestion).toBeUndefined(); + }); + it("prefers the built-in Jira connection and returns a direct instruction", async () => { await resetQuestions(); const result = await connectionIntentService(db).search( diff --git a/server/src/__tests__/connection-intents-service.test.ts b/server/src/__tests__/connection-intents-service.test.ts index 9c661e07444..15283bffa13 100644 --- a/server/src/__tests__/connection-intents-service.test.ts +++ b/server/src/__tests__/connection-intents-service.test.ts @@ -671,7 +671,8 @@ describeEmbeddedPostgres("connectionIntentService", () => { await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); await db.update(heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: issueId }).where(eq(heartbeatRuns.id, runId)); const authority = new PaperclipRunnerToolAuthority(db, { companyId: claims.company_id, issueId, agentId: claims.sub, runId }); - const result = await authority.execute({ tool: "connections_search", callId: "discover", arguments: { query: "github" } }); + const query = "Please help me find tools to continue this task. ".repeat(8) + "Look at my Git Hub pull requests"; + const result = await authority.execute({ tool: "connections_search", callId: "discover", arguments: { query } }); expect(result).toMatchObject({ results: expect.arrayContaining([expect.objectContaining({ service: "github", state: "available" })]) }); const request = await authority.execute({ tool: "connection_request", callId: "request", arguments: { service: "github" } }); expect(request).toMatchObject({ state: "needs_user_action", interactionId: expect.any(String) }); @@ -840,8 +841,20 @@ describeEmbeddedPostgres("connectionIntentService", () => { await expect(service.complete(toolRequest.id, connection!.id, claims.responsible_user_id!)).rejects.toThrow("cannot satisfy"); await expect(service.complete(aiRequest.interactionId!, connection!.id, claims.responsible_user_id!)).resolves.toMatchObject({ status: "accepted" }); expect((await service.request(aiClaims, "anthropic", { purpose: "ai" })).state).toBe("ready"); + const search = await service.search(aiClaims, "Find Anthropic authentication for this agent"); + expect(search.results[0]).toMatchObject({ service: "anthropic", state: "ready", connectionId: connection!.id }); + expect(search.instruction).not.toContain("Share its setupPath"); + const multipleAi = await service.search(aiClaims, "Anthropic and xAI"); + expect(multipleAi.results).toEqual(expect.arrayContaining([ + expect.objectContaining({ service: "anthropic", state: "ready" }), + expect.objectContaining({ service: "xai", state: "available" }), + ])); + expect(multipleAi.instruction).toContain("setupPath"); + expect(multipleAi.instruction).toContain("ready AI"); await expect(service.request(aiClaims, "anthropic")).rejects.toMatchObject({ status: 422 }); - expect((await service.search(aiClaims, "openrouter")).results.some(result => result.service === "openrouter")).toBe(false); + expect((await service.search(aiClaims, "openrouter")).results[0]).toMatchObject({ + service: "openrouter", methods: [expect.objectContaining({ purpose: "ai", setupPath: expect.any(String) })], + }); }); diff --git a/server/src/routes/connection-intents.test.ts b/server/src/routes/connection-intents.test.ts index 3c35b14cbdb..76f039bb19d 100644 --- a/server/src/routes/connection-intents.test.ts +++ b/server/src/routes/connection-intents.test.ts @@ -21,7 +21,7 @@ describe("runtime connection MCP contract", () => { inputSchema: { type: "object", properties: { - query: { type: "string" }, + query: { type: "string", maxLength: 4000 }, retryProviderChoice: { type: "boolean", description: "Only when the user explicitly asks to reconsider a previous provider choice or decline", diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index 46390d7da90..1a43c5d6e28 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -7,6 +7,7 @@ import { and, eq, desc, isNull, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, + companies, toolConnections, toolCatalogEntries, companyMemberships, @@ -18,13 +19,14 @@ import { import { APP_STORE_DEFINITIONS, AGGREGATOR_PRIORITY, AGGREGATOR_NAMES, AGGREGATOR_CATALOG_SOURCES, - findAggregatorService, explicitAggregatorQuery, normalizeConnectionQuery, parseAggregatorRoute, aggregatorProviderQuestion, + findAggregatorService, searchAggregatorServices, prepareConnectionSearch, scoreConnectionSearch, explicitAggregatorQuery, normalizeConnectionQuery, parseAggregatorRoute, aggregatorProviderQuestion, aggregatorContinuationInstruction, isRemoteMcpConnectorId, askUserQuestionsPayloadSchema, askUserQuestionsResultSchema, CONNECTABLE_APP_DEFINITIONS, connectionIntentPayloadSchema, getAvailableConnectionMethods, isToolConnectionAttentionHealth, type ConnectionSearchResultItem, + type AggregatorServiceDefinition, getAppStoreDefinition, type ConnectionIntentInteraction, type ConnectionIntentSetupOptions, @@ -35,6 +37,7 @@ import { type AiConnectionAttribution, type AiConnectionBinding, } from "@paperclipai/shared"; +import { instanceSettingsService } from "./instance-settings.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js"; import { issueThreadInteractionService } from "./issue-thread-interactions.js"; @@ -351,11 +354,18 @@ export function connectionIntentService(db: Db) { async function search(claims: ConnectionRunClaims, query: string, options: { retryProviderChoice?: boolean } = {}): Promise { const { run, agent, issue } = await loadRunContext(claims); - const normalized = query.trim().toLocaleLowerCase(); - const tokens = normalized.split(/[^\p{L}\p{N}]+/u).filter(Boolean); + const settings = await instanceSettingsService(db).getExperimental(); + const [company] = await db.select({ prefix: companies.issuePrefix }).from(companies).where(eq(companies.id, run.companyId)); + if (!company) throw notFound("Company was not found"); + const explicit = explicitAggregatorQuery(query); + const serviceQuery = explicit?.serviceQuery ?? query; + const preparedQuery = prepareConnectionSearch(serviceQuery); const inventory = await connectionInventory(run.companyId); - const candidates: Array<{ item: ConnectionSearchResultItem; score: number }> = []; - const services = [...APP_STORE_DEFINITIONS.filter(app => getAvailableConnectionMethods(app).some(method => method.transport !== "runtime_auth")).map((app) => app.slug), + const candidates: Array<{ item: ConnectionSearchResultItem; score: number; nameScore: number }> = []; + const authorizedCatalogs = new Map>>(); + const discoveryMethods = (app: (typeof APP_STORE_DEFINITIONS)[number]) => getAvailableConnectionMethods(app) + .filter(method => method.purpose !== "channel" || settings.enableChatConnectors); + const services = [...APP_STORE_DEFINITIONS.filter(app => discoveryMethods(app).length).map((app) => app.slug), ...inventory.connections.filter((connection) => sourceSlugForConnection(connection, inventory.applicationsById)?.startsWith("connection:") && connection.status !== "archived").map((connection) => `connection:${connection.id}`)]; @@ -363,8 +373,22 @@ export function connectionIntentService(db: Db) { let app; try { app = await resolveService(service, run.companyId, run.responsibleUserId!, agent.id); } catch (error) { if (service.startsWith("connection:") && (error as { status?: number }).status === 404) continue; throw error; } + const definition = getAppStoreDefinition(service); + if (definition) { + const methods = discoveryMethods(definition); + app = { ...app, searchCapabilities: methods.map(method => `${method.whenToUse} ${method.label ?? ""} ${method.capabilityProfile?.description ?? ""}`).join(" "), + methods: methods.map(method => ({ key: method.key, label: method.label ?? method.key, auth: method.auth, + purpose: method.purpose ?? "tool", + ...(method.purpose === "channel" && method.provider ? { + setupPath: `/${company.prefix}/apps/chat/connect?${new URLSearchParams({ provider: method.provider, purpose: "chat", agentId: agent.id })}`, + } : method.purpose === "ai" ? { setupPath: `/${company.prefix}/apps/connect?${new URLSearchParams({ source: service })}` } : {}), + })), + }; + } + const aiOnly = definition && discoveryMethods(definition).every(method => method.purpose === "ai"); const matching = inventory.connections.filter((connection) => - sourceSlugForConnection(connection, inventory.applicationsById) === service && connection.status !== "archived" && connection.connectionPurpose !== "ai"); + sourceSlugForConnection(connection, inventory.applicationsById) === service && connection.status !== "archived" + && (aiOnly ? connection.connectionPurpose === "ai" : connection.connectionPurpose !== "ai")); // Indexed descriptions can contain private workspace metadata, including // for catalog providers. Check each configured connection's audience first. const catalogs = await Promise.all(matching.map(async (connection) => { @@ -373,17 +397,18 @@ export function connectionIntentService(db: Db) { grant.kind === "organization" || (grant.kind === "user" && grant.subjectUserId === run.responsibleUserId) || (grant.kind === "agent" && grant.subjectAgentId === agent.id) )); - return authorized ? indexedCatalog(connection.id, run.companyId) : []; + const entries = authorized ? await indexedCatalog(connection.id, run.companyId) : []; + authorizedCatalogs.set(connection.id, entries); + return entries; })); const catalog = catalogs.flat().filter((entry) => entry.status === "active"); - const haystack = `${app.slug} ${app.name} ${app.description ?? ""} ${app.searchCapabilities} ${catalog.map((tool) => `${tool.toolName} ${tool.description ?? ""}`).join(" ")}`.toLocaleLowerCase(); - const score = !normalized ? 1 : app.slug === normalized || app.name.toLocaleLowerCase() === normalized - ? 1000 : tokens.reduce((sum, token) => sum + (haystack.includes(token) ? 1 : 0), 0); + const { score, nameScore } = scoreConnectionSearch(preparedQuery, [app.slug, app.name], + `${app.description ?? ""} ${app.searchCapabilities} ${catalog.map(tool => `${tool.toolName} ${tool.description ?? ""}`).join(" ")}`); if (!score) continue; const ready = await usableConnectionForAgent({ companyId: run.companyId, agentId: agent.id, - responsibleUserId: run.responsibleUserId!, serviceSlug: service, inventory }); - const denied = !ready && matching.length > 0 && await administrativeDenial(run.companyId, agent.id, service, inventory); - candidates.push({ score, item: { + responsibleUserId: run.responsibleUserId!, serviceSlug: service, purpose: aiOnly ? "ai" : undefined, inventory }); + const denied = !aiOnly && !ready && matching.length > 0 && await administrativeDenial(run.companyId, agent.id, service, inventory); + candidates.push({ score, nameScore, item: { service, name: app.name, description: app.description ?? null, logoUrl: app.branding.logoUrl ?? null, methods: app.methods, source: app.source, state: ready ? "ready" : denied ? "unavailable" : !app.available || !app.methods.length ? "unavailable" @@ -394,67 +419,107 @@ export function connectionIntentService(db: Db) { connectionId: ready?.id ?? null, }}); } - const explicit = explicitAggregatorQuery(query); - const serviceQuery = explicit?.serviceQuery ?? query; - const publicService = findAggregatorService(serviceQuery); - const exact = candidates.filter(({ item }) => normalizeConnectionQuery(item.service) === normalizeConnectionQuery(query) - || normalizeConnectionQuery(item.name) === normalizeConnectionQuery(query) - || item.service === publicService?.slug); - const targetService = publicService?.slug ?? normalizeConnectionQuery(serviceQuery).replaceAll(" ", "-"); + const publicMatches = searchAggregatorServices(preparedQuery); + const bestMatches = publicMatches.filter(match => Math.floor(match.nameScore / 100) === Math.floor((publicMatches[0]?.nameScore ?? 0) / 100)); + const publicService = findAggregatorService(serviceQuery, publicMatches) ?? (bestMatches.length === 1 ? bestMatches[0]!.service : undefined); + // Extract service namespaces only from the current identity's indexed tools. + // Generic provider search/execute descriptions do not prove app support. + const indexedServices = new Set(); + for (const connection of inventory.connections) { + const provider = sourceSlugForConnection(connection, inventory.applicationsById); + if (!isRemoteMcpConnectorId(provider) || !connection.enabled || connection.status === "archived") continue; + for (const entry of authorizedCatalogs.get(connection.id) ?? []) { + const namespace = entry.toolName.toLowerCase().match(/^([a-z0-9-]+)[_.:]/)?.[1]; + if (namespace && !isRemoteMcpConnectorId(namespace)) indexedServices.add(namespace); + if (provider === "executor" && entry.toolName === "execute") { + for (const line of (entry.description ?? "").split("\n")) { + const slug = line.trim().match(/^- `([a-z0-9][a-z0-9-]{0,79})`$/)?.[1]; + if (slug) indexedServices.add(slug); + } + } + } + } + const indexedMatches = [...indexedServices].map(slug => ({ slug, ...scoreConnectionSearch(preparedQuery, [slug]) })) + .filter(match => match.nameScore > 0).sort((a, b) => b.score - a.score); + const bestExternalTier = Math.floor(Math.max(publicMatches[0]?.nameScore ?? 0, indexedMatches[0]?.nameScore ?? 0) / 100); + // Native preference applies to the same app or equally strong name matches. + // A typo match such as Notion must not hide the distinctly named app Motion. + const exact = candidates.filter(({ item, nameScore }) => ((nameScore >= 200 && Math.floor(nameScore / 100) >= bestExternalTier) || item.service === publicService?.slug) + && (!isRemoteMcpConnectorId(item.service) || (!publicService && !indexedMatches.length))); + const ranked = () => candidates.sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name)) + .slice(0, 40).map(({ item }) => item); + const targetService = publicService?.slug ?? (indexedMatches.length === 1 ? indexedMatches[0]!.slug : normalizeConnectionQuery(serviceQuery).replaceAll(" ", "-")); // Indexed-only app labels must be identical when requests re-search the slug. const targetName = publicService?.name ?? targetService.split("-").map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" "); const previous = await providerSelections(run.companyId, issue.id, agent.id, run.responsibleUserId!, `connection-provider:${targetService}`); const explicitConsent = explicit && await hasExplicitProviderRequest(run.companyId, issue.id, run.responsibleUserId!, targetService, explicit.provider, previous[0]); - if (exact.length && (!explicitConsent || exact.some(({ item }) => item.state === "unavailable"))) return directSearchResult(query, exact.map(({ item }) => item)); - const alternatives: ConnectionSearchResultItem[] = []; - if (/^[a-z0-9][a-z0-9-]{0,79}$/.test(targetService)) { - for (const provider of AGGREGATOR_PRIORITY) { - // Broad execute/search descriptions are not evidence of app support. Only a - // namespaced action (or an explicitly listed Executor integration) qualifies. - const connections = inventory.connections.filter(connection => connection.status !== "archived" && connection.enabled && sourceSlugForConnection(connection, inventory.applicationsById) === provider); - let indexedAt: Date | undefined; - for (const connection of connections) { - const { grants } = await access.listConnectionGrants(connection.id, run.companyId); - if (!grants.some(grant => grant.status === "active" && (grant.kind === "organization" - || grant.kind === "user" && grant.subjectUserId === run.responsibleUserId - || grant.kind === "agent" && grant.subjectAgentId === agent.id))) continue; - const entries = await indexedCatalog(connection.id, run.companyId); - const matching = entries.filter(entry => { - const name = entry.toolName.toLowerCase(); - return name.startsWith(targetService + "_") || name.startsWith(targetService + ".") || name.startsWith(targetService + ":") - || provider === "executor" && entry.toolName === "execute" - && (entry.description ?? "").split("\n").some(line => line.trim() === "- `" + targetService + "`"); - }); - for (const entry of matching) if (!indexedAt || entry.lastSeenAt > indexedAt) indexedAt = entry.lastSeenAt; - } - const publishedAt = publicService ? (publicService.providers as Partial>)[provider] : undefined; - const published = Boolean(publishedAt); - if (!published && !indexedAt) continue; - if (await administrativeDenial(run.companyId, agent.id, provider, inventory)) continue; - const app = await resolveService(provider, run.companyId, run.responsibleUserId!, agent.id); - if (!app.available || !app.methods.length) continue; - const ready = await usableConnectionForAgent({ companyId: run.companyId, agentId: agent.id, responsibleUserId: run.responsibleUserId!, serviceSlug: provider, inventory }); - alternatives.push({ - service: `via:${provider}:${targetService}`, name: `${targetName} through ${app.name}`, - source: "aggregator", state: "available", description: `Connect ${targetName} through ${app.name}, an external service.`, - logoUrl: app.branding.logoUrl ?? null, methods: app.methods, connectionId: ready?.id ?? null, - reason: ready ? `${app.name} is connected; verify ${targetName} authorization and the requested action.` - : provider === "arcade" ? "Set up an Arcade gateway with this app's tools, then authorize the app." - : `Connect ${app.name}, then verify and authorize ${targetName}.`, - aggregator: { provider, targetService, targetName, evidenceUrl: published ? AGGREGATOR_CATALOG_SOURCES[provider] ?? null : null, - verifiedAt: publishedAt ?? indexedAt!.toISOString(), - readiness: ready ? "requires_app_verification" : "requires_provider_setup" }, - }); + if (exact.length && (!explicitConsent || exact.some(({ item }) => item.state === "unavailable"))) { + const otherApps = bestMatches.filter(({ service, nameScore }) => nameScore >= 200 && !exact.some(({ item }) => + item.service === service.slug || scoreConnectionSearch(service.name, [item.name]).nameScore === 1000)); + if (otherApps.length && !exact.some(({ item }) => item.state === "unavailable")) { + const otherRoutes = (await Promise.all(otherApps.slice(0, 10).map(({ service }) => + aggregatorAlternatives(service.slug, service.name, service)))).flat(); + if (otherRoutes.length) return discoverySuggestions([...ranked(), ...otherRoutes]); } + return directSearchResult(query, ranked()); } + async function aggregatorAlternatives(targetService: string, targetName: string, publicService?: AggregatorServiceDefinition) { + const alternatives: ConnectionSearchResultItem[] = []; + if (/^[a-z0-9][a-z0-9-]{0,79}$/.test(targetService)) { + for (const provider of AGGREGATOR_PRIORITY) { + // Broad execute/search descriptions are not evidence of app support. Only a + // namespaced action (or an explicitly listed Executor integration) qualifies. + const connections = inventory.connections.filter(connection => connection.status !== "archived" && connection.enabled && sourceSlugForConnection(connection, inventory.applicationsById) === provider); + let indexedAt: Date | undefined; + for (const connection of connections) { + const { grants } = await access.listConnectionGrants(connection.id, run.companyId); + if (!grants.some(grant => grant.status === "active" && (grant.kind === "organization" + || grant.kind === "user" && grant.subjectUserId === run.responsibleUserId + || grant.kind === "agent" && grant.subjectAgentId === agent.id))) continue; + const entries = authorizedCatalogs.get(connection.id) ?? []; + const matching = entries.filter(entry => { + const name = entry.toolName.toLowerCase(); + return name.startsWith(targetService + "_") || name.startsWith(targetService + ".") || name.startsWith(targetService + ":") + || provider === "executor" && entry.toolName === "execute" + && (entry.description ?? "").split("\n").some(line => line.trim() === "- `" + targetService + "`"); + }); + for (const entry of matching) if (!indexedAt || entry.lastSeenAt > indexedAt) indexedAt = entry.lastSeenAt; + } + const publishedAt = publicService ? (publicService.providers as Partial>)[provider] : undefined; + const published = Boolean(publishedAt); + if (!published && !indexedAt) continue; + if (await administrativeDenial(run.companyId, agent.id, provider, inventory)) continue; + const app = await resolveService(provider, run.companyId, run.responsibleUserId!, agent.id); + if (!app.available || !app.methods.length) continue; + const ready = await usableConnectionForAgent({ companyId: run.companyId, agentId: agent.id, responsibleUserId: run.responsibleUserId!, serviceSlug: provider, inventory }); + alternatives.push({ + service: `via:${provider}:${targetService}`, name: `${targetName} through ${app.name}`, + source: "aggregator", state: "available", description: `Connect ${targetName} through ${app.name}, an external service.`, + logoUrl: app.branding.logoUrl ?? null, methods: app.methods, connectionId: ready?.id ?? null, + reason: ready ? `${app.name} is connected; verify ${targetName} authorization and the requested action.` + : provider === "arcade" ? "Set up an Arcade gateway with this app's tools, then authorize the app." + : `Connect ${app.name}, then verify and authorize ${targetName}.`, + aggregator: { provider, targetService, targetName, evidenceUrl: published ? publicService?.evidenceUrls?.[provider] ?? AGGREGATOR_CATALOG_SOURCES[provider] ?? null : null, + verifiedAt: publishedAt ?? indexedAt!.toISOString(), + readiness: ready ? "requires_app_verification" : "requires_provider_setup" }, + }); + } + } + return alternatives; + } + const alternatives = await aggregatorAlternatives(targetService, targetName, publicService); if (explicit && explicitConsent) { const selected = alternatives.find(item => item.aggregator?.provider === explicit.provider); if (!selected) return { version: 1, query, results: [], instruction: "The explicitly requested external provider is unavailable or its app support could not be verified. Explain the limitation. Do not switch providers automatically." }; return { version: 1, query, results: [{ ...selected, service: explicit.provider }], instruction: `The user explicitly requested ${AGGREGATOR_NAMES[explicit.provider]}. Disclose that this external service handles the connection and requests to ${targetName}. Do not ask another provider-choice question or substitute another provider. Call connection_request with service ${explicit.provider} and targetService ${targetService}. Follow its returned instruction; app authorization is not yet verified.` }; } - if (!alternatives.length) return directSearchResult(query, candidates.filter(({ item, score }) => !isRemoteMcpConnectorId(item.service) && score >= tokens.length) - .sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name)).slice(0, 20).map(({ item }) => item), true); + if (!alternatives.length && bestMatches.length > 1) { + const matches = (await Promise.all(bestMatches.slice(0, 10).map(({ service }) => + aggregatorAlternatives(service.slug, service.name, service)))).flat(); + if (matches.length) return discoverySuggestions([...ranked(), ...matches]); + } + if (!alternatives.length) return directSearchResult(query, ranked(), true); const question = aggregatorProviderQuestion(targetService, targetName, alternatives); const latest = previous[0]; if (latest && (latest.status === "pending" || !options.retryProviderChoice)) { @@ -472,6 +537,11 @@ export function connectionIntentService(db: Db) { if (!offered.length) return { version: 1, query, results: [], instruction: "The requested external provider is unavailable. Do not switch providers automatically." }; return { version: 1, query, results: offered, providerQuestion: aggregatorProviderQuestion(targetService, targetName, offered), instruction: "No matching built-in Paperclip connection was found. Ask the responsible user with providerQuestion exactly as returned (including its id, full prompt, and options). With native request_human_input, use interactionKind questions, continuationPolicy wake_assignee, and payload {version:1, questions:[providerQuestion]}; do not add questionSet. Otherwise use ask_user_questions with the same questions payload. These are external services. Wait for the saved answer; then call connection_request with the selected service identifier and selectionInteractionId set to the answered question interaction ID. None for now means do not connect. Do not claim app access yet." }; + + function discoverySuggestions(results: ConnectionSearchResultItem[]): ConnectionsSearchResult { + return { version: 1, query, results: results.slice(0, 40), + instruction: "Multiple apps match this query. Choose the relevant service and method using descriptions and purposes. For available or needs_user_action results, tool methods use connection_request and channel or AI methods use setupPath. Use ready tools as installed; ready AI authentication applies to the next execution without reconnection. For an aggregator result, search its aggregator.targetService to obtain the provider-choice question and follow that instruction before requesting a connection. Respect unavailable states. Do not treat a search match as provider consent or app authorization." }; + } } function directSearchResult(query: string, results: ConnectionSearchResultItem[], suggestions = false): ConnectionsSearchResult { @@ -479,7 +549,12 @@ export function connectionIntentService(db: Db) { ? "No verified connection route was found. Explain that support could not be verified; do not invent a provider route or request unsupported services." : !suggestions && isRemoteMcpConnectorId(results[0]!.service) && results[0]!.state !== "unavailable" ? `${results[0]!.name} is an external service. When the user explicitly names this provider, disclose that it handles the connection and requests to the requested app; no additional provider-choice question is necessary. ${results[0]!.state === "ready" ? aggregatorContinuationInstruction(results[0]!.service, "The requested app") : "Call connection_request with the returned service identifier and follow its instruction. Provider setup does not yet verify underlying app access."}` - : suggestions ? "These are possible Paperclip matches, not an exact service match. Clarify the service if ambiguous, then search its exact name. Do not treat unrelated matches as support." + : !suggestions && results[0]!.state === "unavailable" ? "This connection is unavailable or administratively restricted. Explain the reason. Do not bypass it using another provider." + : suggestions || results.length > 1 ? "These are ranked connection matches. Choose the relevant service and method using their descriptions and purposes; extra query words need not match. For available or needs_user_action tool methods, call connection_request with the service identifier to present its setup card. For available or needs_user_action channel or AI methods, share the method's setupPath. Use ready tools as installed; ready AI authentication is available for the agent's next execution and does not need reconnection. Respect unavailable states and recorded user choices. Clarify only if the intended service is still ambiguous; unrelated matches are not evidence of support." + : results[0]!.state === "ready" && results[0]!.methods.every(method => method.purpose === "ai") + ? "AI authentication is available for this agent's next execution. Do not create another connection request or ask the user to reconnect." + : results[0]!.methods.every(method => method.purpose && method.purpose !== "tool") + ? "Choose the method relevant to the task using its purpose and label. Share its setupPath with the user to open the existing channel or AI setup flow. These methods do not use the tool connection_request card. Do not claim tools or an inbox are ready before setup finishes." : results[0]!.state === "ready" ? "Use the installed connection. Do not create another connection request." : results[0]!.state === "unavailable" ? "This connection is unavailable or administratively restricted. Explain the reason. Do not bypass it using another provider." : "Call connection_request with the returned service identifier. The user already asked to connect: do not ask a generic confirmation or imitate the setup card. Follow the returned instruction." }; diff --git a/server/src/services/connection-tool-definitions.ts b/server/src/services/connection-tool-definitions.ts index 51f1cf71288..e6cb15f7e69 100644 --- a/server/src/services/connection-tool-definitions.ts +++ b/server/src/services/connection-tool-definitions.ts @@ -6,7 +6,7 @@ export const RUNTIME_CONNECTION_TOOL_DEFINITIONS = [ description: CONNECTIONS_SEARCH_TOOL_DESCRIPTION, inputSchema: { type: "object", - properties: { query: { type: "string" }, retryProviderChoice: { type: "boolean", description: "Only when the user explicitly asks to reconsider a previous provider choice or decline" } }, + properties: { query: { type: "string", maxLength: 4000 }, retryProviderChoice: { type: "boolean", description: "Only when the user explicitly asks to reconsider a previous provider choice or decline" } }, additionalProperties: false, }, }, @@ -21,4 +21,3 @@ export const RUNTIME_CONNECTION_TOOL_DEFINITIONS = [ }, }, ] as const; -