diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index ca22800..97f0086 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -40,16 +40,21 @@ import * as attio from './intl/attio.json'; import * as basecamp from './intl/basecamp.json'; import * as beehiiv from './intl/beehiiv.json'; import * as bigcommerce from './intl/bigcommerce.json'; +import * as bluesky from './intl/bluesky.json'; import * as brevo from './intl/brevo.json'; +import * as bugsnag from './intl/bugsnag.json'; import * as calendly from './intl/calendly.json'; import * as chargebee from './intl/chargebee.json'; import * as clearbit from './intl/clearbit.json'; import * as clickup from './intl/clickup.json'; +import * as clockify from './intl/clockify.json'; import * as close from './intl/close.json'; import * as coda from './intl/coda.json'; +import * as coingecko from './intl/coingecko.json'; import * as convertkit from './intl/convertkit.json'; import * as copper from './intl/copper.json'; import * as crisp from './intl/crisp.json'; +import * as datadog from './intl/datadog.json'; import * as discordBot from './intl/discord-bot.json'; import * as drip from './intl/drip.json'; import * as dropboxSign from './intl/dropbox-sign.json'; @@ -59,9 +64,13 @@ import * as fathom from './intl/fathom.json'; import * as fillout from './intl/fillout.json'; import * as folk from './intl/folk.json'; import * as freshdesk from './intl/freshdesk.json'; +import * as freshservice from './intl/freshservice.json'; import * as front from './intl/front.json'; import * as ghost from './intl/ghost.json'; import * as gitbook from './intl/gitbook.json'; +import * as gorgias from './intl/gorgias.json'; +import * as hackernews from './intl/hackernews.json'; +import * as harvest from './intl/harvest.json'; import * as heap from './intl/heap.json'; import * as height from './intl/height.json'; import * as helpScout from './intl/help-scout.json'; @@ -78,6 +87,7 @@ import * as magento from './intl/magento.json'; import * as mailchimp from './intl/mailchimp.json'; import * as mailshake from './intl/mailshake.json'; import * as mapbox from './intl/mapbox.json'; +import * as mastodon from './intl/mastodon.json'; import * as medium from './intl/medium.json'; import * as messagebird from './intl/messagebird.json'; import * as microsoftBookings from './intl/microsoft-bookings.json'; @@ -85,7 +95,10 @@ import * as microsoftTeams from './intl/microsoft-teams.json'; import * as mintlify from './intl/mintlify.json'; import * as mollie from './intl/mollie.json'; import * as neverbounce from './intl/neverbounce.json'; +import * as newRelic from './intl/new-relic.json'; +import * as newsapi from './intl/newsapi.json'; import * as nominatim from './intl/nominatim.json'; +import * as openweather from './intl/openweather.json'; import * as outreach from './intl/outreach.json'; import * as pandadoc from './intl/pandadoc.json'; import * as pipedrive from './intl/pipedrive.json'; @@ -105,8 +118,10 @@ import * as tally from './intl/tally.json'; import * as telegramBot from './intl/telegram-bot.json'; import * as ticktick from './intl/ticktick.json'; import * as todoist from './intl/todoist.json'; +import * as togglTrack from './intl/toggl-track.json'; import * as trello from './intl/trello.json'; import * as typeform from './intl/typeform.json'; +import * as uptimeRobot from './intl/uptime-robot.json'; import * as vercelAnalytics from './intl/vercel-analytics.json'; import * as whatsappBusiness from './intl/whatsapp-business.json'; import * as woocommerce from './intl/woocommerce.json'; @@ -231,16 +246,21 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ basecamp as unknown as AdapterDefinition, beehiiv as unknown as AdapterDefinition, bigcommerce as unknown as AdapterDefinition, + bluesky as unknown as AdapterDefinition, brevo as unknown as AdapterDefinition, + bugsnag as unknown as AdapterDefinition, calendly as unknown as AdapterDefinition, chargebee as unknown as AdapterDefinition, clearbit as unknown as AdapterDefinition, clickup as unknown as AdapterDefinition, + clockify as unknown as AdapterDefinition, close as unknown as AdapterDefinition, coda as unknown as AdapterDefinition, + coingecko as unknown as AdapterDefinition, convertkit as unknown as AdapterDefinition, copper as unknown as AdapterDefinition, crisp as unknown as AdapterDefinition, + datadog as unknown as AdapterDefinition, discordBot as unknown as AdapterDefinition, drip as unknown as AdapterDefinition, dropboxSign as unknown as AdapterDefinition, @@ -250,9 +270,13 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ fillout as unknown as AdapterDefinition, folk as unknown as AdapterDefinition, freshdesk as unknown as AdapterDefinition, + freshservice as unknown as AdapterDefinition, front as unknown as AdapterDefinition, ghost as unknown as AdapterDefinition, gitbook as unknown as AdapterDefinition, + gorgias as unknown as AdapterDefinition, + hackernews as unknown as AdapterDefinition, + harvest as unknown as AdapterDefinition, heap as unknown as AdapterDefinition, height as unknown as AdapterDefinition, helpScout as unknown as AdapterDefinition, @@ -269,6 +293,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ mailchimp as unknown as AdapterDefinition, mailshake as unknown as AdapterDefinition, mapbox as unknown as AdapterDefinition, + mastodon as unknown as AdapterDefinition, medium as unknown as AdapterDefinition, messagebird as unknown as AdapterDefinition, microsoftBookings as unknown as AdapterDefinition, @@ -276,7 +301,10 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ mintlify as unknown as AdapterDefinition, mollie as unknown as AdapterDefinition, neverbounce as unknown as AdapterDefinition, + newRelic as unknown as AdapterDefinition, + newsapi as unknown as AdapterDefinition, nominatim as unknown as AdapterDefinition, + openweather as unknown as AdapterDefinition, outreach as unknown as AdapterDefinition, pandadoc as unknown as AdapterDefinition, pipedrive as unknown as AdapterDefinition, @@ -296,8 +324,10 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ telegramBot as unknown as AdapterDefinition, ticktick as unknown as AdapterDefinition, todoist as unknown as AdapterDefinition, + togglTrack as unknown as AdapterDefinition, trello as unknown as AdapterDefinition, typeform as unknown as AdapterDefinition, + uptimeRobot as unknown as AdapterDefinition, vercelAnalytics as unknown as AdapterDefinition, whatsappBusiness as unknown as AdapterDefinition, woocommerce as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/bluesky.json b/packages/backend/src/adapters/intl/bluesky.json new file mode 100644 index 0000000..544cb7c --- /dev/null +++ b/packages/backend/src/adapters/intl/bluesky.json @@ -0,0 +1,202 @@ +{ + "slug": "bluesky", + "name": "Bluesky", + "description": "Read & post on Bluesky (the AT Protocol social network) from any AI agent. 9 tools, app-password auth.", + "instructions": "This connector wraps the Bluesky AT Protocol XRPC API (bsky.social).\n\n**Setup**:\n1. In the Bluesky app or web client → **Settings → Privacy and Security → App Passwords → Add App Password**.\n2. Name it (e.g. 'mcp') and copy the generated `xxxx-xxxx-xxxx-xxxx` password. App passwords scope down what they can do — never use your account password.\n3. Set `BLUESKY_HANDLE` (e.g. `you.bsky.social`) and `BLUESKY_APP_PASSWORD`.\n\n**Two-step auth**: AT Protocol uses a session model. You can't just send the app password on every call — you must first POST to `/xrpc/com.atproto.server.createSession` with `{identifier, password}` and use the returned `accessJwt` as the Bearer token. The `bluesky_create_session` tool does step 1; subsequent tools use the JWT.\n\nFor production use the access JWT should be cached (it lasts ~2 hours) and refreshed with `refreshJwt` via `/xrpc/com.atproto.server.refreshSession`. This adapter exposes session create + refresh as tools so the caller can manage lifecycle.\n\n**DIDs vs handles**: Bluesky internally uses DIDs (`did:plc:abcd...`). Public APIs accept either DID or handle (`you.bsky.social`); use `bluesky_resolve_handle` to convert.\n\n**Post records**: posts are AT Protocol records of type `app.bsky.feed.post`. They have `text` (max 300 graphemes), `createdAt` (RFC3339), `langs[]`, and optional `embed` for images/links.\n\n**Rate limits**: 5k req / hour per JWT; create-session limited to 30/5min per IP. 429 with `Retry-After`.\n\n**Out of scope here**: image upload multipart (use the public URL embed pattern instead), labelers/moderation, custom feeds CRUD, DM (separate API), the firehose.", + "region": "intl", + "category": "social", + "icon": "bluesky", + "docsUrl": "https://docs.bsky.app/", + "requiredEnvVars": ["BLUESKY_HANDLE", "BLUESKY_APP_PASSWORD"], + "connector": { + "name": "Bluesky AT Protocol", + "type": "REST", + "baseUrl": "https://bsky.social", + "authType": "NONE", + "authConfig": {} + }, + "tools": [ + { + "name": "bluesky_create_session", + "description": "Exchange handle + app password for an access JWT (lasts ~2h) and refresh JWT. Returns `accessJwt`, `refreshJwt`, `handle`, `did`. Call this once at the start of a session, then pass `accessJwt` to other tools that need it.", + "parameters": { + "type": "object", + "properties": { + "identifier": { "type": "string", "description": "Handle (e.g. you.bsky.social) or email." }, + "password": { "type": "string", "description": "App password." } + }, + "required": ["identifier", "password"] + }, + "endpointMapping": { + "method": "POST", + "path": "/xrpc/com.atproto.server.createSession", + "bodyMapping": { "identifier": "$identifier", "password": "$password" } + } + }, + { + "name": "bluesky_refresh_session", + "description": "Refresh an access JWT using the refresh JWT.", + "parameters": { + "type": "object", + "properties": { + "refresh_jwt": { "type": "string", "description": "refreshJwt from create_session." } + }, + "required": ["refresh_jwt"] + }, + "endpointMapping": { + "method": "POST", + "path": "/xrpc/com.atproto.server.refreshSession", + "headers": { "Authorization": "Bearer ${refresh_jwt}" } + } + }, + { + "name": "bluesky_resolve_handle", + "description": "Resolve a Bluesky handle (e.g. `bsky.app`) to its DID. Public, no auth needed.", + "parameters": { + "type": "object", + "properties": { + "handle": { "type": "string", "description": "Handle (no @)." } + }, + "required": ["handle"] + }, + "endpointMapping": { + "method": "GET", + "path": "/xrpc/com.atproto.identity.resolveHandle", + "queryParams": { "handle": "$handle" } + } + }, + { + "name": "bluesky_get_profile", + "description": "Get a user's public profile: handle, did, displayName, description, followers, follows, posts count, avatar URL.", + "parameters": { + "type": "object", + "properties": { + "actor": { "type": "string", "description": "Handle or DID." }, + "access_jwt": { "type": "string", "description": "Access JWT from create_session." } + }, + "required": ["actor", "access_jwt"] + }, + "endpointMapping": { + "method": "GET", + "path": "/xrpc/app.bsky.actor.getProfile", + "queryParams": { "actor": "$actor" }, + "headers": { "Authorization": "Bearer ${access_jwt}" } + } + }, + { + "name": "bluesky_get_timeline", + "description": "Get the authenticated user's home timeline (chronological feed of follows). Paginated.", + "parameters": { + "type": "object", + "properties": { + "access_jwt": { "type": "string", "description": "Access JWT." }, + "limit": { "type": "integer", "description": "Max posts (1-100)." }, + "cursor": { "type": "string", "description": "Pagination cursor from previous response." } + }, + "required": ["access_jwt"] + }, + "endpointMapping": { + "method": "GET", + "path": "/xrpc/app.bsky.feed.getTimeline", + "queryParams": { "limit": "$limit", "cursor": "$cursor" }, + "headers": { "Authorization": "Bearer ${access_jwt}" } + } + }, + { + "name": "bluesky_get_author_feed", + "description": "Get posts by a specific user (their public feed).", + "parameters": { + "type": "object", + "properties": { + "actor": { "type": "string", "description": "Handle or DID." }, + "access_jwt": { "type": "string", "description": "Access JWT." }, + "limit": { "type": "integer", "description": "Max posts (1-100)." }, + "cursor": { "type": "string", "description": "Pagination cursor." }, + "filter": { "type": "string", "description": "'posts_with_replies', 'posts_no_replies', 'posts_with_media', 'posts_and_author_threads'." } + }, + "required": ["actor", "access_jwt"] + }, + "endpointMapping": { + "method": "GET", + "path": "/xrpc/app.bsky.feed.getAuthorFeed", + "queryParams": { "actor": "$actor", "limit": "$limit", "cursor": "$cursor", "filter": "$filter" }, + "headers": { "Authorization": "Bearer ${access_jwt}" } + } + }, + { + "name": "bluesky_search_posts", + "description": "Full-text search across public posts.", + "parameters": { + "type": "object", + "properties": { + "q": { "type": "string", "description": "Search query." }, + "access_jwt": { "type": "string", "description": "Access JWT." }, + "limit": { "type": "integer", "description": "Max results (1-100)." }, + "cursor": { "type": "string", "description": "Pagination cursor." }, + "lang": { "type": "string", "description": "ISO language filter." }, + "sort": { "type": "string", "description": "'top' or 'latest'." } + }, + "required": ["q", "access_jwt"] + }, + "endpointMapping": { + "method": "GET", + "path": "/xrpc/app.bsky.feed.searchPosts", + "queryParams": { "q": "$q", "limit": "$limit", "cursor": "$cursor", "lang": "$lang", "sort": "$sort" }, + "headers": { "Authorization": "Bearer ${access_jwt}" } + } + }, + { + "name": "bluesky_create_post", + "description": "Post a new skeet. `text` is required (max 300 graphemes). Returns the post's URI + CID.", + "parameters": { + "type": "object", + "properties": { + "access_jwt": { "type": "string", "description": "Access JWT." }, + "repo": { "type": "string", "description": "Your DID (from create_session response)." }, + "text": { "type": "string", "description": "Post text, max 300 graphemes." }, + "created_at": { "type": "string", "description": "RFC3339 datetime (defaults to now if omitted)." }, + "langs": { "type": "array", "description": "ISO 639-1 language codes, e.g. ['en']." } + }, + "required": ["access_jwt", "repo", "text", "created_at"] + }, + "endpointMapping": { + "method": "POST", + "path": "/xrpc/com.atproto.repo.createRecord", + "bodyMapping": { + "repo": "$repo", + "collection": "app.bsky.feed.post", + "record": { + "$type": "app.bsky.feed.post", + "text": "$text", + "createdAt": "$created_at", + "langs": "$langs" + } + }, + "headers": { "Authorization": "Bearer ${access_jwt}" } + } + }, + { + "name": "bluesky_delete_post", + "description": "Delete one of your own posts by its rkey (record key, the last segment of the at:// URI).", + "parameters": { + "type": "object", + "properties": { + "access_jwt": { "type": "string", "description": "Access JWT." }, + "repo": { "type": "string", "description": "Your DID." }, + "rkey": { "type": "string", "description": "Record key — the last segment of the post's at:// URI." } + }, + "required": ["access_jwt", "repo", "rkey"] + }, + "endpointMapping": { + "method": "POST", + "path": "/xrpc/com.atproto.repo.deleteRecord", + "bodyMapping": { + "repo": "$repo", + "collection": "app.bsky.feed.post", + "rkey": "$rkey" + }, + "headers": { "Authorization": "Bearer ${access_jwt}" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/bluesky.live.spec.ts b/packages/backend/src/adapters/intl/bluesky.live.spec.ts new file mode 100644 index 0000000..35c50e6 --- /dev/null +++ b/packages/backend/src/adapters/intl/bluesky.live.spec.ts @@ -0,0 +1,16 @@ +import * as adapter from './bluesky.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string }; + tools: Array<{ name: string; endpointMapping: { path: string } }>; +}; +describe('bluesky adapter — static spec conformance', () => { + it('bsky.social base URL', () => expect(a.connector.baseUrl).toBe('https://bsky.social')); + it('uses XRPC path namespace for every tool', () => { + for (const t of a.tools) expect(t.endpointMapping.path).toMatch(/^\/xrpc\//); + }); + it('exposes session create + refresh', () => { + const names = a.tools.map((t) => t.name); + expect(names).toContain('bluesky_create_session'); + expect(names).toContain('bluesky_refresh_session'); + }); +}); diff --git a/packages/backend/src/adapters/intl/bugsnag.json b/packages/backend/src/adapters/intl/bugsnag.json new file mode 100644 index 0000000..5c0d914 --- /dev/null +++ b/packages/backend/src/adapters/intl/bugsnag.json @@ -0,0 +1,166 @@ +{ + "slug": "bugsnag", + "name": "Bugsnag", + "description": "Read & triage Bugsnag (now SmartBear Insight Hub) errors, projects, organizations, releases, pivots from any AI agent. 9 tools, token auth.", + "instructions": "This connector wraps the Bugsnag Data Access API (api.bugsnag.com).\n\n**Setup**:\n1. Log into https://app.bugsnag.com → bottom-left profile → **My Account → Personal Auth Tokens** → Generate.\n2. Pick the scopes you need (`project errors`, `project events`, `project release groups`, `project comments`, `project pivots`).\n3. Set `BUGSNAG_AUTH_TOKEN`.\n\n**Authentication**: every request sends `Authorization: token ${BUGSNAG_AUTH_TOKEN}` AND `X-Version: 2`. The adapter wires both for you.\n\n**Hierarchy**: `Organization → Project → Error → Event(s)`. An \"Error\" is the deduplicated group; \"Events\" are the individual occurrences with stack trace + breadcrumbs + user. To get useful debug info you usually want the latest event of an error.\n\n**Bootstrapping the IDs**:\n1. `bugsnag_list_organizations` → grab `org_id`.\n2. `bugsnag_list_projects` → grab `project_id`.\n3. `bugsnag_list_errors` → grab `error_id`.\n4. `bugsnag_get_event` → full stack trace.\n\n**Filter syntax**: error/event list endpoints accept `filters[field][type][operator]=value` repeated. Common: `filters[event.since][][type]=eq&filters[event.since][][value]=7d` (last 7 days), `filters[error.status][][type]=eq&filters[error.status][][value]=open`. Stick to a few common patterns in tool args — full grammar lives at https://bugsnagapiv2.docs.apiary.io/.\n\n**Rate limits**: 60 req/min per token, 5k/hour. 429 with `Retry-After` header.\n\n**Out of scope here**: project CRUD, account billing, integrations setup, deploys via REST (use the dedicated build reporter SDK instead).", + "region": "intl", + "category": "monitoring", + "icon": "bugsnag", + "docsUrl": "https://bugsnagapiv2.docs.apiary.io/", + "requiredEnvVars": ["BUGSNAG_AUTH_TOKEN"], + "connector": { + "name": "Bugsnag Data Access API v2", + "type": "REST", + "baseUrl": "https://api.bugsnag.com", + "authType": "API_KEY", + "authConfig": { + "headerName": "Authorization", + "apiKey": "token {{BUGSNAG_AUTH_TOKEN}}", + "extraHeaders": { + "X-Version": "2" + } + } + }, + "tools": [ + { + "name": "bugsnag_current_user", + "description": "Return the user account the token belongs to: id, name, email, created_at.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/user" } + }, + { + "name": "bugsnag_list_organizations", + "description": "List orgs the user belongs to. Each has id, name, slug, billing/plan info, creator. Most users have exactly one.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/user/organizations" } + }, + { + "name": "bugsnag_list_projects", + "description": "List projects inside an org. Returns id, slug, name, api_key, type (e.g. js, node, ruby), open_error_count, urls.", + "parameters": { + "type": "object", + "properties": { + "organization_id": { "type": "string", "description": "Org ID from bugsnag_list_organizations." }, + "per_page": { "type": "integer", "description": "Per page (max 100)." }, + "q": { "type": "string", "description": "Optional name filter." } + }, + "required": ["organization_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/organizations/{organization_id}/projects", + "queryParams": { "per_page": "$per_page", "q": "$q" } + } + }, + { + "name": "bugsnag_list_errors", + "description": "List errors in a project. Errors are deduplicated stack-trace groups. Each has id, error_class, message, context, status (open/fixed/snoozed/ignored), severity, events count, users count, first_seen, last_seen.", + "parameters": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "Project ID." }, + "sort": { "type": "string", "description": "first_seen, last_seen, users, events, unsorted." }, + "direction": { "type": "string", "description": "asc / desc." }, + "per_page": { "type": "integer", "description": "Per page (max 30)." }, + "filters_status": { "type": "string", "description": "Shortcut filter by status: open, fixed, snoozed, ignored. Internally adds filters[error.status]." }, + "filters_since": { "type": "string", "description": "Shortcut filter by time window, e.g. '7d', '24h', '1m'. Internally adds filters[event.since]." } + }, + "required": ["project_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/projects/{project_id}/errors", + "queryParams": { + "sort": "$sort", + "direction": "$direction", + "per_page": "$per_page", + "filters[error.status][][type]": "eq", + "filters[error.status][][value]": "$filters_status", + "filters[event.since][][type]": "eq", + "filters[event.since][][value]": "$filters_since" + } + } + }, + { + "name": "bugsnag_get_error", + "description": "Get a single error by ID with its full metadata, severity, status, top stack frame.", + "parameters": { + "type": "object", + "properties": { + "error_id": { "type": "string", "description": "Error ID." } + }, + "required": ["error_id"] + }, + "endpointMapping": { "method": "GET", "path": "/errors/{error_id}" } + }, + { + "name": "bugsnag_list_events", + "description": "List events (individual occurrences) for an error. Each event has the full stack, breadcrumbs, user context, device info.", + "parameters": { + "type": "object", + "properties": { + "error_id": { "type": "string", "description": "Error ID." }, + "per_page": { "type": "integer", "description": "Per page (max 30)." }, + "full_reports": { "type": "boolean", "description": "If true, include full stack + breadcrumbs in each event (slower)." } + }, + "required": ["error_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/errors/{error_id}/events", + "queryParams": { "per_page": "$per_page", "full_reports": "$full_reports" } + } + }, + { + "name": "bugsnag_get_event", + "description": "Get a single event by ID — full stack trace, breadcrumbs, user, app, device, request, custom metadata. This is the most useful tool for debugging.", + "parameters": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "Project ID." }, + "event_id": { "type": "string", "description": "Event ID." } + }, + "required": ["project_id", "event_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/projects/{project_id}/events/{event_id}" + } + }, + { + "name": "bugsnag_update_error", + "description": "Update the status/severity of an error: fix, ignore, snooze, reopen, change severity.", + "parameters": { + "type": "object", + "properties": { + "error_id": { "type": "string", "description": "Error ID." }, + "operation": { "type": "string", "description": "fix, snooze, ignore, open, override_severity." }, + "severity": { "type": "string", "description": "If operation=override_severity: error, warning, info." } + }, + "required": ["error_id", "operation"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/errors/{error_id}", + "bodyMapping": { "operation": "$operation", "severity": "$severity" } + } + }, + { + "name": "bugsnag_list_releases", + "description": "List releases for a project. Useful for cross-referencing an error spike with a deploy.", + "parameters": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "Project ID." }, + "per_page": { "type": "integer", "description": "Per page (max 100)." } + }, + "required": ["project_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/projects/{project_id}/releases", + "queryParams": { "per_page": "$per_page" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/bugsnag.live.spec.ts b/packages/backend/src/adapters/intl/bugsnag.live.spec.ts new file mode 100644 index 0000000..872121f --- /dev/null +++ b/packages/backend/src/adapters/intl/bugsnag.live.spec.ts @@ -0,0 +1,17 @@ +import * as adapter from './bugsnag.json'; +const a = adapter as unknown as { + connector: { + baseUrl: string; + authType: string; + authConfig: { headerName: string; extraHeaders: Record }; + }; +}; +describe('bugsnag adapter — static spec conformance', () => { + it('api.bugsnag.com base URL', () => + expect(a.connector.baseUrl).toBe('https://api.bugsnag.com')); + it('Authorization header + X-Version: 2', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('Authorization'); + expect(a.connector.authConfig.extraHeaders['X-Version']).toBe('2'); + }); +}); diff --git a/packages/backend/src/adapters/intl/clockify.json b/packages/backend/src/adapters/intl/clockify.json new file mode 100644 index 0000000..60e4d1a --- /dev/null +++ b/packages/backend/src/adapters/intl/clockify.json @@ -0,0 +1,238 @@ +{ + "slug": "clockify", + "name": "Clockify", + "description": "Manage Clockify (time tracking, projects, clients, tags, reports) from any AI agent. 10 tools, X-Api-Key auth.", + "instructions": "This connector wraps the Clockify REST API v1 (api.clockify.me/api/v1).\n\n**Setup**:\n1. Sign in to https://clockify.me → bottom-left avatar → **Preferences → Advanced → API → Generate API key**.\n2. Set `CLOCKIFY_API_KEY`.\n\n**Authentication**: `X-Api-Key: ${CLOCKIFY_API_KEY}` on every request.\n\n**Workspace IDs**: Clockify is workspace-scoped. Use `clockify_get_user` first — its response contains `defaultWorkspace` and `activeWorkspace`. List workspaces with `clockify_list_workspaces`.\n\n**Time entry shape**: `start` + `end` are ISO 8601 UTC with the literal `Z` suffix (e.g. `2026-05-20T14:30:00Z`). For a running entry, OMIT `end` — the entry will be live until you call `clockify_stop_current_entry`.\n\n**Project / Task / Tag IDs**: every entity ID is a 24-char hex Mongo-style ID (e.g. `5b1e7b7b7e8d8e3a4c5d6f78`). Always look them up first via the list endpoints; user-facing names are not accepted.\n\n**Pagination**: `page` (1-based) + `page-size` (max 5000 but default 50). Some endpoints also support cursor pagination via `cursor`.\n\n**Rate limits**: 50 req/s per workspace; bursts tolerated. 429 with no Retry-After — back off 1s.\n\n**Out of scope here**: Reports endpoints (different base host `reports.api.clockify.me`, would be a sibling adapter), approval workflows, Pumble integration.", + "region": "intl", + "category": "time-tracking", + "icon": "clockify", + "docsUrl": "https://docs.clockify.me/", + "requiredEnvVars": ["CLOCKIFY_API_KEY"], + "connector": { + "name": "Clockify API v1", + "type": "REST", + "baseUrl": "https://api.clockify.me/api/v1", + "authType": "API_KEY", + "authConfig": { + "headerName": "X-Api-Key", + "apiKey": "{{CLOCKIFY_API_KEY}}" + } + }, + "tools": [ + { + "name": "clockify_get_user", + "description": "Return the authenticated user: id, email, name, activeWorkspace, defaultWorkspace, profile picture URL.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/user" } + }, + { + "name": "clockify_list_workspaces", + "description": "List workspaces the user belongs to.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/workspaces" } + }, + { + "name": "clockify_list_clients", + "description": "List clients in a workspace.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "string", "description": "Workspace ID." }, + "name": { "type": "string", "description": "Substring filter by name." }, + "archived": { "type": "boolean", "description": "Include archived." }, + "page": { "type": "integer", "description": "1-based." }, + "page_size": { "type": "integer", "description": "Default 50, max 5000." } + }, + "required": ["workspace_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_id}/clients", + "queryParams": { + "name": "$name", + "archived": "$archived", + "page": "$page", + "page-size": "$page_size" + } + } + }, + { + "name": "clockify_list_projects", + "description": "List projects in a workspace. Returns id, name, clientId, billable, color, archived, public, estimate, hourlyRate.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "string", "description": "Workspace ID." }, + "name": { "type": "string", "description": "Substring filter." }, + "clients": { "type": "string", "description": "Comma-separated client IDs to filter to." }, + "archived": { "type": "boolean", "description": "Include archived." }, + "billable": { "type": "boolean", "description": "Filter billable." }, + "page": { "type": "integer", "description": "1-based." }, + "page_size": { "type": "integer", "description": "Default 50." } + }, + "required": ["workspace_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_id}/projects", + "queryParams": { + "name": "$name", + "clients": "$clients", + "archived": "$archived", + "billable": "$billable", + "page": "$page", + "page-size": "$page_size" + } + } + }, + { + "name": "clockify_list_tags", + "description": "List tags in a workspace.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "string", "description": "Workspace ID." }, + "name": { "type": "string", "description": "Substring filter." }, + "archived": { "type": "boolean", "description": "Include archived." } + }, + "required": ["workspace_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_id}/tags", + "queryParams": { "name": "$name", "archived": "$archived" } + } + }, + { + "name": "clockify_list_time_entries", + "description": "List a user's time entries in a workspace. Filter by start/end window, project, tag, billable. Returns each entry's id, description, project, task, tags, billable, timeInterval (start/end/duration).", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "string", "description": "Workspace ID." }, + "user_id": { "type": "string", "description": "User ID (use clockify_get_user for own ID)." }, + "start": { "type": "string", "description": "ISO 8601 UTC (e.g. 2026-05-20T00:00:00Z)." }, + "end": { "type": "string", "description": "ISO 8601 UTC." }, + "project": { "type": "string", "description": "Project ID filter." }, + "tags": { "type": "string", "description": "Comma-separated tag IDs." }, + "billable": { "type": "boolean", "description": "Filter billable." }, + "in_progress": { "type": "boolean", "description": "Only currently running." }, + "page": { "type": "integer", "description": "1-based." }, + "page_size": { "type": "integer", "description": "Default 50, max 5000." } + }, + "required": ["workspace_id", "user_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_id}/user/{user_id}/time-entries", + "queryParams": { + "start": "$start", + "end": "$end", + "project": "$project", + "tags": "$tags", + "billable": "$billable", + "in-progress": "$in_progress", + "page": "$page", + "page-size": "$page_size" + } + } + }, + { + "name": "clockify_start_time_entry", + "description": "Start a new time entry. Omit `end` to start a running timer; supply both `start` and `end` to log a completed entry. Starting a new entry stops any currently-running one for the same user.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "string", "description": "Workspace ID." }, + "start": { "type": "string", "description": "ISO 8601 UTC start time." }, + "end": { "type": "string", "description": "ISO 8601 UTC end time (omit for running)." }, + "billable": { "type": "boolean", "description": "Billable." }, + "description": { "type": "string", "description": "Free-text." }, + "project_id": { "type": "string", "description": "Project ID." }, + "task_id": { "type": "string", "description": "Task ID inside the project." }, + "tag_ids": { "type": "array", "description": "Array of tag ID strings." } + }, + "required": ["workspace_id", "start"] + }, + "endpointMapping": { + "method": "POST", + "path": "/workspaces/{workspace_id}/time-entries", + "bodyMapping": { + "start": "$start", + "end": "$end", + "billable": "$billable", + "description": "$description", + "projectId": "$project_id", + "taskId": "$task_id", + "tagIds": "$tag_ids" + } + } + }, + { + "name": "clockify_stop_current_entry", + "description": "Stop the currently running time entry for a user. Sets `end` to the supplied timestamp (default now).", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "string", "description": "Workspace ID." }, + "user_id": { "type": "string", "description": "User ID." }, + "end": { "type": "string", "description": "ISO 8601 UTC end time." } + }, + "required": ["workspace_id", "user_id", "end"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/workspaces/{workspace_id}/user/{user_id}/time-entries", + "bodyMapping": { "end": "$end" } + } + }, + { + "name": "clockify_update_time_entry", + "description": "Update a time entry's description, project, task, tags, billable status, or time range.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "string", "description": "Workspace ID." }, + "time_entry_id": { "type": "string", "description": "Time entry ID." }, + "start": { "type": "string", "description": "New start (ISO 8601 UTC)." }, + "end": { "type": "string", "description": "New end (ISO 8601 UTC)." }, + "billable": { "type": "boolean", "description": "Billable." }, + "description": { "type": "string", "description": "New description." }, + "project_id": { "type": "string", "description": "Move to project." }, + "task_id": { "type": "string", "description": "Move to task." }, + "tag_ids": { "type": "array", "description": "Replace tags." } + }, + "required": ["workspace_id", "time_entry_id", "start"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/workspaces/{workspace_id}/time-entries/{time_entry_id}", + "bodyMapping": { + "start": "$start", + "end": "$end", + "billable": "$billable", + "description": "$description", + "projectId": "$project_id", + "taskId": "$task_id", + "tagIds": "$tag_ids" + } + } + }, + { + "name": "clockify_delete_time_entry", + "description": "Permanently delete a time entry.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "string", "description": "Workspace ID." }, + "time_entry_id": { "type": "string", "description": "Time entry ID." } + }, + "required": ["workspace_id", "time_entry_id"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/workspaces/{workspace_id}/time-entries/{time_entry_id}" + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/clockify.live.spec.ts b/packages/backend/src/adapters/intl/clockify.live.spec.ts new file mode 100644 index 0000000..f2982cf --- /dev/null +++ b/packages/backend/src/adapters/intl/clockify.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './clockify.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { headerName: string } }; +}; +describe('clockify adapter — static spec conformance', () => { + it('api.clockify.me/api/v1 base URL', () => + expect(a.connector.baseUrl).toBe('https://api.clockify.me/api/v1')); + it('X-Api-Key auth header', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('X-Api-Key'); + }); +}); diff --git a/packages/backend/src/adapters/intl/coingecko.json b/packages/backend/src/adapters/intl/coingecko.json new file mode 100644 index 0000000..c57edb0 --- /dev/null +++ b/packages/backend/src/adapters/intl/coingecko.json @@ -0,0 +1,168 @@ +{ + "slug": "coingecko", + "name": "CoinGecko", + "description": "Query CoinGecko (crypto prices, market data, historical, exchanges, NFT floor prices) from any AI agent. 8 tools, optional API-key auth.", + "instructions": "This connector wraps the CoinGecko REST API.\n\n**Setup**:\n1. The PUBLIC API (api.coingecko.com/api/v3) requires NO API key but is harshly rate-limited (~10-30 calls/min) and missing some endpoints.\n2. For higher throughput, sign up for the free DEMO plan at https://www.coingecko.com/en/developers/dashboard → create an `x-cg-demo-api-key`. This raises the limit to 30 calls/min and stays free.\n3. Paid PRO plans use a different host (pro-api.coingecko.com) and header `x-cg-pro-api-key` — switch baseUrl + auth header accordingly.\n4. Set `COINGECKO_API_KEY` if you have one; leave unset for anonymous public access.\n\n**Authentication**: optional. When `COINGECKO_API_KEY` is set, the adapter sends `x-cg-demo-api-key: ${KEY}`. When unset, no auth header is sent.\n\n**Coin IDs not symbols**: CoinGecko endpoints want the internal coin ID (`bitcoin`, `ethereum`, `solana`), not the ticker (`BTC`, `ETH`, `SOL`). Use `coingecko_search` or `coingecko_list_coins` first to look up the canonical ID. The mapping is one-way: 'BTC' → 'bitcoin'.\n\n**Pagination**: most list endpoints support `per_page` (1-250) and `page` (1-based). Default `per_page` is 100.\n\n**Currency codes**: pass `vs_currency=usd|eur|btc|eth` lowercase. The full list is at `/simple/supported_vs_currencies`.\n\n**Rate limits**: 429 responses come with a `Retry-After` header — respect it. Public tier: ~10-30/min; Demo: 30/min; Pro: from 500/min.\n\n**Out of scope here**: streaming/WebSocket price feeds, DEX-specific GraphQL endpoints (use the dedicated Onchain DEX API instead), CSV bulk export.", + "region": "intl", + "category": "data", + "icon": "coingecko", + "docsUrl": "https://docs.coingecko.com/reference/introduction", + "requiredEnvVars": [], + "connector": { + "name": "CoinGecko API v3", + "type": "REST", + "baseUrl": "https://api.coingecko.com/api/v3", + "authType": "API_KEY", + "authConfig": { + "headerName": "x-cg-demo-api-key", + "apiKey": "{{COINGECKO_API_KEY}}" + } + }, + "tools": [ + { + "name": "coingecko_ping", + "description": "Sanity check — returns `{gecko_says: '(V3) To the Moon!'}` if the API is up. Useful as a connectivity probe.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/ping" } + }, + { + "name": "coingecko_simple_price", + "description": "Cheapest way to get current price(s). Pass comma-separated coin IDs and currencies. Optionally include 24h volume, market cap, last-updated, 24h change. Use this for 'what's the price of X' — much faster than full market data.", + "parameters": { + "type": "object", + "properties": { + "ids": { "type": "string", "description": "Comma-separated CoinGecko coin IDs, e.g. 'bitcoin,ethereum,solana'." }, + "vs_currencies": { "type": "string", "description": "Comma-separated target currencies, e.g. 'usd,eur'." }, + "include_market_cap": { "type": "boolean", "description": "Include market cap." }, + "include_24hr_vol": { "type": "boolean", "description": "Include 24h volume." }, + "include_24hr_change": { "type": "boolean", "description": "Include 24h % change." }, + "include_last_updated_at": { "type": "boolean", "description": "Include last-updated UNIX timestamp." } + }, + "required": ["ids", "vs_currencies"] + }, + "endpointMapping": { + "method": "GET", + "path": "/simple/price", + "queryParams": { + "ids": "$ids", + "vs_currencies": "$vs_currencies", + "include_market_cap": "$include_market_cap", + "include_24hr_vol": "$include_24hr_vol", + "include_24hr_change": "$include_24hr_change", + "include_last_updated_at": "$include_last_updated_at" + } + } + }, + { + "name": "coingecko_search", + "description": "Search coins, categories and markets by free-text query. Returns matching CoinGecko IDs — use this when the user gives a ticker or partial name and you need the canonical ID for downstream calls.", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search text, e.g. 'bitcoin' or 'BTC' or 'arbitr'." } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "GET", + "path": "/search", + "queryParams": { "query": "$query" } + } + }, + { + "name": "coingecko_list_coins", + "description": "Full list of every coin with id, symbol, name. Heavy (~13k entries) — prefer `coingecko_search` unless you really need the whole map.", + "parameters": { + "type": "object", + "properties": { + "include_platform": { "type": "boolean", "description": "Include contract address on each chain." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/coins/list", + "queryParams": { "include_platform": "$include_platform" } + } + }, + { + "name": "coingecko_coin_markets", + "description": "Market data for top coins by market cap. Returns price, market cap, volume, supply, ATH/ATL, 24h/7d/30d % change. Sorted by market cap desc by default.", + "parameters": { + "type": "object", + "properties": { + "vs_currency": { "type": "string", "description": "Target currency, e.g. 'usd'." }, + "ids": { "type": "string", "description": "Optional comma-separated coin IDs to filter to." }, + "order": { "type": "string", "description": "market_cap_desc, market_cap_asc, volume_desc, volume_asc, id_asc, id_desc." }, + "per_page": { "type": "integer", "description": "1-250. Default 100." }, + "page": { "type": "integer", "description": "1-based page index." }, + "price_change_percentage": { "type": "string", "description": "Comma-separated windows: 1h,24h,7d,14d,30d,200d,1y." } + }, + "required": ["vs_currency"] + }, + "endpointMapping": { + "method": "GET", + "path": "/coins/markets", + "queryParams": { + "vs_currency": "$vs_currency", + "ids": "$ids", + "order": "$order", + "per_page": "$per_page", + "page": "$page", + "price_change_percentage": "$price_change_percentage" + } + } + }, + { + "name": "coingecko_coin_detail", + "description": "Full profile of one coin: description in many languages, links, image URLs, market data, community + developer stats, public interest score. Heavy payload — prefer simple_price for snippets.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "CoinGecko coin ID (e.g. 'bitcoin')." }, + "localization": { "type": "boolean", "description": "Include localized descriptions (large response)." }, + "tickers": { "type": "boolean", "description": "Include per-exchange tickers." }, + "market_data": { "type": "boolean", "description": "Include market data block." }, + "community_data": { "type": "boolean", "description": "Include Reddit / Twitter follower counts." }, + "developer_data": { "type": "boolean", "description": "Include GitHub stars/forks/PRs." } + }, + "required": ["id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/coins/{id}", + "queryParams": { + "localization": "$localization", + "tickers": "$tickers", + "market_data": "$market_data", + "community_data": "$community_data", + "developer_data": "$developer_data" + } + } + }, + { + "name": "coingecko_coin_market_chart", + "description": "Historical price/marketcap/volume series for a coin over a window. Granularity auto-selected by CoinGecko based on `days`: minutely for 1, hourly for 2-90, daily for >90.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "CoinGecko coin ID." }, + "vs_currency": { "type": "string", "description": "Target currency." }, + "days": { "type": "string", "description": "Window: '1', '7', '30', '90', '365', 'max'." }, + "interval": { "type": "string", "description": "Override granularity. Most users leave this empty." } + }, + "required": ["id", "vs_currency", "days"] + }, + "endpointMapping": { + "method": "GET", + "path": "/coins/{id}/market_chart", + "queryParams": { "vs_currency": "$vs_currency", "days": "$days", "interval": "$interval" } + } + }, + { + "name": "coingecko_trending", + "description": "Top-7 trending coins on CoinGecko in the last 24h (by search volume). Bonus payload: top NFT collections + categories trending.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/search/trending" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/coingecko.live.spec.ts b/packages/backend/src/adapters/intl/coingecko.live.spec.ts new file mode 100644 index 0000000..699be3a --- /dev/null +++ b/packages/backend/src/adapters/intl/coingecko.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './coingecko.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { headerName: string } }; +}; +describe('coingecko adapter — static spec conformance', () => { + it('api.coingecko.com/api/v3 base URL', () => + expect(a.connector.baseUrl).toBe('https://api.coingecko.com/api/v3')); + it('demo API key sent as x-cg-demo-api-key header', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('x-cg-demo-api-key'); + }); +}); diff --git a/packages/backend/src/adapters/intl/datadog.json b/packages/backend/src/adapters/intl/datadog.json new file mode 100644 index 0000000..8695100 --- /dev/null +++ b/packages/backend/src/adapters/intl/datadog.json @@ -0,0 +1,192 @@ +{ + "slug": "datadog", + "name": "Datadog", + "description": "Query Datadog (metrics, logs, monitors, events, incidents, dashboards) from any AI agent. 8 tools, dual-key auth.", + "instructions": "This connector wraps the Datadog REST API v1/v2 (api.datadoghq.com).\n\n**Setup**:\n1. In your Datadog org → **Personal Settings → Organization Settings → API Keys** → create or copy an API key. Set `DATADOG_API_KEY`.\n2. **Organization Settings → Application Keys** → create an application key with at least `monitors_read`, `logs_read_data`, `dashboards_read`, `events_read` scopes. Set `DATADOG_APP_KEY`.\n3. **Site matters**: Datadog has multiple regions: US1 (datadoghq.com — default), US3 (us3.datadoghq.com), US5 (us5.datadoghq.com), EU1 (datadoghq.eu), AP1 (ap1.datadoghq.com), US1-FED (ddog-gov.com). If your org is on a non-US1 site, change `baseUrl` accordingly. Wrong site = 403 with `Forbidden` and no useful body.\n\n**Authentication**: Datadog requires BOTH headers on every request:\n- `DD-API-KEY: ${DATADOG_API_KEY}` — identifies the org\n- `DD-APPLICATION-KEY: ${DATADOG_APP_KEY}` — identifies the user/permissions\n\n**Metrics query language**: `q` uses the Datadog metric query syntax — `avg:system.cpu.user{*}` or `sum:trace.web.request.hits{env:prod} by {service}`. See https://docs.datadoghq.com/dashboards/functions/.\n\n**Time windows**: `from` / `to` are UNIX epoch seconds (NOT milliseconds, NOT ISO strings). Default windows: last 1h for metrics, last 15min for logs.\n\n**Logs search**: uses the same query syntax as the Logs Explorer UI — `service:api status:error @http.status_code:>=500`. Returns `data[]` with `attributes.message`, `attributes.tags`, etc.\n\n**Rate limits**: most read endpoints are 300 req/h per org. Monitors API allows 100/min. 429 responses include `X-RateLimit-Reset` (epoch seconds).\n\n**Out of scope here**: agent install / install-script generation, SLO CRUD beyond list, RUM/synthetics CRUD.", + "region": "intl", + "category": "monitoring", + "icon": "datadog", + "docsUrl": "https://docs.datadoghq.com/api/latest/", + "requiredEnvVars": ["DATADOG_API_KEY", "DATADOG_APP_KEY"], + "connector": { + "name": "Datadog API", + "type": "REST", + "baseUrl": "https://api.datadoghq.com", + "authType": "API_KEY", + "authConfig": { + "headerName": "DD-API-KEY", + "apiKey": "{{DATADOG_API_KEY}}", + "extraHeaders": { + "DD-APPLICATION-KEY": "{{DATADOG_APP_KEY}}" + } + } + }, + "tools": [ + { + "name": "datadog_query_metrics", + "description": "Run a metrics query over a time window. `q` uses the Datadog metric query language: e.g. `avg:system.cpu.user{*}` or `sum:requests{service:api,env:prod} by {host}`. Returns a series array with per-point [timestamp, value] pairs.", + "parameters": { + "type": "object", + "properties": { + "from": { "type": "integer", "description": "Start UNIX epoch seconds." }, + "to": { "type": "integer", "description": "End UNIX epoch seconds." }, + "query": { "type": "string", "description": "Datadog metric query expression." } + }, + "required": ["from", "to", "query"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/query", + "queryParams": { "from": "$from", "to": "$to", "query": "$query" } + } + }, + { + "name": "datadog_search_logs", + "description": "Search logs using the Logs Search syntax. Use this for 'show me errors in the last hour' or 'find logs containing X in service Y'.", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Logs query string, e.g. 'service:api status:error'." }, + "from": { "type": "string", "description": "Start time: ISO 8601 OR relative like 'now-15m'." }, + "to": { "type": "string", "description": "End time: ISO 8601 OR 'now'." }, + "limit": { "type": "integer", "description": "Max events (1-1000). Default 10." }, + "sort": { "type": "string", "description": "'timestamp' (asc) or '-timestamp' (desc)." } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/v2/logs/events/search", + "bodyMapping": { + "filter": { "query": "$query", "from": "$from", "to": "$to" }, + "page": { "limit": "$limit" }, + "sort": "$sort" + } + } + }, + { + "name": "datadog_list_monitors", + "description": "List monitors. Filter by tags, name match, or monitor type. Returns each monitor's id, name, query, type, message, state.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Filter by name substring." }, + "tags": { "type": "string", "description": "Comma-separated tag list (AND)." }, + "monitor_tags": { "type": "string", "description": "Filter by monitor-level tags." }, + "page": { "type": "integer", "description": "0-based page index." }, + "page_size": { "type": "integer", "description": "Per page (max 100)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/monitor", + "queryParams": { + "name": "$name", + "tags": "$tags", + "monitor_tags": "$monitor_tags", + "page": "$page", + "page_size": "$page_size" + } + } + }, + { + "name": "datadog_get_monitor", + "description": "Get a single monitor by ID with full definition (query, thresholds, notifications, state).", + "parameters": { + "type": "object", + "properties": { + "monitor_id": { "type": "integer", "description": "Monitor numeric ID." } + }, + "required": ["monitor_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/monitor/{monitor_id}" + } + }, + { + "name": "datadog_list_dashboards", + "description": "List all dashboards in the org. Returns each dashboard's id, title, url, author, modified_at. Use this to find a dashboard ID before fetching its full layout.", + "parameters": { + "type": "object", + "properties": { + "filter_shared": { "type": "boolean", "description": "If true, only shared dashboards." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/dashboard", + "queryParams": { "filter[shared]": "$filter_shared" } + } + }, + { + "name": "datadog_get_dashboard", + "description": "Fetch a dashboard's full layout: widgets, queries, template variables.", + "parameters": { + "type": "object", + "properties": { + "dashboard_id": { "type": "string", "description": "Dashboard ID." } + }, + "required": ["dashboard_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/dashboard/{dashboard_id}" + } + }, + { + "name": "datadog_list_events", + "description": "List events in a time range. Use for 'what deploys/alerts/changes happened today'. Filter by tags, priority, sources, or text query.", + "parameters": { + "type": "object", + "properties": { + "start": { "type": "integer", "description": "Start UNIX epoch seconds." }, + "end": { "type": "integer", "description": "End UNIX epoch seconds." }, + "priority": { "type": "string", "description": "'normal' or 'low'." }, + "sources": { "type": "string", "description": "Comma-separated sources, e.g. 'github,jira'." }, + "tags": { "type": "string", "description": "Comma-separated tags." }, + "unaggregated": { "type": "boolean", "description": "If true, return all events (not deduped/aggregated)." } + }, + "required": ["start", "end"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/events", + "queryParams": { + "start": "$start", + "end": "$end", + "priority": "$priority", + "sources": "$sources", + "tags": "$tags", + "unaggregated": "$unaggregated" + } + } + }, + { + "name": "datadog_post_event", + "description": "Post an event to the Datadog event stream (useful for marking deploys, releases, manual incidents). Returns the created event's id + url.", + "parameters": { + "type": "object", + "properties": { + "title": { "type": "string", "description": "Event title." }, + "text": { "type": "string", "description": "Event body (supports @-mentions and Markdown)." }, + "tags": { "type": "array", "description": "Tag strings like 'env:prod' or 'service:api'." }, + "alert_type": { "type": "string", "description": "'info', 'warning', 'error', 'success', 'user_update', 'recommendation', 'snapshot'." }, + "priority": { "type": "string", "description": "'normal' or 'low'." } + }, + "required": ["title", "text"] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/v1/events", + "bodyMapping": { + "title": "$title", + "text": "$text", + "tags": "$tags", + "alert_type": "$alert_type", + "priority": "$priority" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/datadog.live.spec.ts b/packages/backend/src/adapters/intl/datadog.live.spec.ts new file mode 100644 index 0000000..87a6160 --- /dev/null +++ b/packages/backend/src/adapters/intl/datadog.live.spec.ts @@ -0,0 +1,17 @@ +import * as adapter from './datadog.json'; +const a = adapter as unknown as { + connector: { + baseUrl: string; + authType: string; + authConfig: { headerName: string; extraHeaders: Record }; + }; +}; +describe('datadog adapter — static spec conformance', () => { + it('api.datadoghq.com base URL (US1 default)', () => + expect(a.connector.baseUrl).toBe('https://api.datadoghq.com')); + it('dual-header auth: DD-API-KEY + DD-APPLICATION-KEY', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('DD-API-KEY'); + expect(Object.keys(a.connector.authConfig.extraHeaders)).toContain('DD-APPLICATION-KEY'); + }); +}); diff --git a/packages/backend/src/adapters/intl/freshservice.json b/packages/backend/src/adapters/intl/freshservice.json new file mode 100644 index 0000000..2df84ac --- /dev/null +++ b/packages/backend/src/adapters/intl/freshservice.json @@ -0,0 +1,262 @@ +{ + "slug": "freshservice", + "name": "Freshservice", + "description": "Manage Freshservice (ITSM: tickets, problems, changes, assets, agents, requesters, departments) from any AI agent. 10 tools, basic-auth with API key.", + "instructions": "This connector wraps the Freshservice API v2 (per-tenant — yourdomain.freshservice.com/api/v2).\n\n**Setup**:\n1. Log into your Freshservice instance → top-right avatar → **Profile Settings → View API Key** (you may need to log in again to reveal).\n2. Note your tenant subdomain (the part before `.freshservice.com`).\n3. Set `FRESHSERVICE_SUBDOMAIN` (just the subdomain) and `FRESHSERVICE_API_KEY`.\n\n**Authentication**: HTTP Basic, with the API key as username and the literal string `X` as the password:\n `Authorization: Basic base64(API_KEY:X)`\nThe adapter sets this via BASIC_AUTH (username = api key, password = 'X'). Using your account password instead of the API key is allowed but discouraged.\n\n**Per-tenant baseUrl**: `https://${FRESHSERVICE_SUBDOMAIN}.freshservice.com/api/v2`. Substituted at import time.\n\n**Ticket statuses** (integer codes):\n- 2: Open\n- 3: Pending\n- 4: Resolved\n- 5: Closed\nPriorities: 1=Low, 2=Medium, 3=High, 4=Urgent. Sources: 1=Email, 2=Portal, 3=Phone, 4=Chat, 5=Feedback widget, 6=Yammer, 7=AWS Cloudwatch, 8=Pagerduty, 9=Walkup, 10=Slack.\n\n**ITIL records**: Freshservice exposes Tickets, Problems, Changes, Releases. They share much of the same shape but live at different endpoints.\n\n**Pagination**: `page` (1-based) + `per_page` (max 100). Header `Link` carries the next-page URL. Many list endpoints cap at 9 pages total — for full export use the dedicated archive APIs.\n\n**Rate limits**: 100 req/min per API key on Starter, up to 1k/min on Enterprise. 429 returns `Retry-After` in seconds.\n\n**Out of scope here**: workflow automation, project portfolio management, contract CRUD beyond list, asset relationships graph.", + "region": "intl", + "category": "support", + "icon": "freshservice", + "docsUrl": "https://api.freshservice.com/v2/", + "requiredEnvVars": ["FRESHSERVICE_SUBDOMAIN", "FRESHSERVICE_API_KEY"], + "connector": { + "name": "Freshservice API v2", + "type": "REST", + "baseUrl": "https://{{FRESHSERVICE_SUBDOMAIN}}.freshservice.com/api/v2", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{FRESHSERVICE_API_KEY}}", + "password": "X" + } + }, + "tools": [ + { + "name": "freshservice_list_tickets", + "description": "List tickets. Filter by predefined views (`new_and_my_open`, `watching`, `spam`, `deleted`, `unresolved`, ...) or by requester_id/email. Returns each ticket's id, subject, status, priority, source, group_id, responder_id, requester_id, created_at.", + "parameters": { + "type": "object", + "properties": { + "filter": { "type": "string", "description": "Predefined view name." }, + "requester_id": { "type": "integer", "description": "Filter to one requester." }, + "email": { "type": "string", "description": "Filter to one requester email." }, + "updated_since": { "type": "string", "description": "ISO 8601 — only tickets updated after this." }, + "include": { "type": "string", "description": "Comma-separated: 'stats', 'requester', 'requested_for', 'department'." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tickets", + "queryParams": { + "filter": "$filter", + "requester_id": "$requester_id", + "email": "$email", + "updated_since": "$updated_since", + "include": "$include", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "freshservice_get_ticket", + "description": "Get one ticket by ID with full description + custom fields. Pass `include` to expand stats/requester/conversations.", + "parameters": { + "type": "object", + "properties": { + "ticket_id": { "type": "integer", "description": "Ticket ID." }, + "include": { "type": "string", "description": "Comma-separated: 'conversations', 'requester', 'requested_for', 'stats', 'problem', 'assets', 'change', 'related_tickets'." } + }, + "required": ["ticket_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/tickets/{ticket_id}", + "queryParams": { "include": "$include" } + } + }, + { + "name": "freshservice_create_ticket", + "description": "Create a new ticket. Required: `subject` + `description` + `email` (requester) + `priority` + `status` + `source`.", + "parameters": { + "type": "object", + "properties": { + "subject": { "type": "string", "description": "Ticket subject." }, + "description": { "type": "string", "description": "HTML description." }, + "email": { "type": "string", "description": "Requester email." }, + "priority": { "type": "integer", "description": "1=Low, 2=Medium, 3=High, 4=Urgent." }, + "status": { "type": "integer", "description": "2=Open, 3=Pending, 4=Resolved, 5=Closed." }, + "source": { "type": "integer", "description": "1=Email, 2=Portal, 3=Phone, 4=Chat, ..." }, + "category": { "type": "string", "description": "Top-level category name." }, + "sub_category": { "type": "string", "description": "Second-level category." }, + "item_category": { "type": "string", "description": "Third-level category." }, + "group_id": { "type": "integer", "description": "Assign to a group." }, + "responder_id": { "type": "integer", "description": "Assign to an agent." }, + "department_id": { "type": "integer", "description": "Department." }, + "tags": { "type": "array", "description": "Array of tag strings." }, + "custom_fields": { "type": "object", "description": "Custom field map (key = field name)." } + }, + "required": ["subject", "description", "email", "priority", "status", "source"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tickets", + "bodyMapping": { + "subject": "$subject", + "description": "$description", + "email": "$email", + "priority": "$priority", + "status": "$status", + "source": "$source", + "category": "$category", + "sub_category": "$sub_category", + "item_category": "$item_category", + "group_id": "$group_id", + "responder_id": "$responder_id", + "department_id": "$department_id", + "tags": "$tags", + "custom_fields": "$custom_fields" + } + } + }, + { + "name": "freshservice_update_ticket", + "description": "Update a ticket's status, priority, assignee, tags, custom fields, subject, or description.", + "parameters": { + "type": "object", + "properties": { + "ticket_id": { "type": "integer", "description": "Ticket ID." }, + "subject": { "type": "string", "description": "New subject." }, + "description": { "type": "string", "description": "New HTML description." }, + "priority": { "type": "integer", "description": "1-4." }, + "status": { "type": "integer", "description": "2-5." }, + "group_id": { "type": "integer", "description": "Move to group." }, + "responder_id": { "type": "integer", "description": "Move to agent." }, + "tags": { "type": "array", "description": "Replace tags." }, + "custom_fields": { "type": "object", "description": "Merge custom fields." } + }, + "required": ["ticket_id"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/tickets/{ticket_id}", + "bodyMapping": { + "subject": "$subject", + "description": "$description", + "priority": "$priority", + "status": "$status", + "group_id": "$group_id", + "responder_id": "$responder_id", + "tags": "$tags", + "custom_fields": "$custom_fields" + } + } + }, + { + "name": "freshservice_reply_to_ticket", + "description": "Add a public reply to a ticket — visible to the requester.", + "parameters": { + "type": "object", + "properties": { + "ticket_id": { "type": "integer", "description": "Ticket ID." }, + "body": { "type": "string", "description": "HTML body of the reply." }, + "from_email": { "type": "string", "description": "Override sending email." }, + "user_id": { "type": "integer", "description": "Agent user_id." } + }, + "required": ["ticket_id", "body"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tickets/{ticket_id}/reply", + "bodyMapping": { "body": "$body", "from_email": "$from_email", "user_id": "$user_id" } + } + }, + { + "name": "freshservice_add_note", + "description": "Add an internal (private) note to a ticket — NOT visible to the requester. Pass `private=true` (default) for internal, `false` for public.", + "parameters": { + "type": "object", + "properties": { + "ticket_id": { "type": "integer", "description": "Ticket ID." }, + "body": { "type": "string", "description": "HTML body." }, + "private": { "type": "boolean", "description": "Default true." }, + "user_id": { "type": "integer", "description": "Author agent user_id." } + }, + "required": ["ticket_id", "body"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tickets/{ticket_id}/notes", + "bodyMapping": { "body": "$body", "private": "$private", "user_id": "$user_id" } + } + }, + { + "name": "freshservice_list_problems", + "description": "List problem records.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/problems", + "queryParams": { "page": "$page", "per_page": "$per_page" } + } + }, + { + "name": "freshservice_list_changes", + "description": "List change requests.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/changes", + "queryParams": { "page": "$page", "per_page": "$per_page" } + } + }, + { + "name": "freshservice_list_assets", + "description": "List CI assets in the Freshservice CMDB. Filter by include or asset_type_id.", + "parameters": { + "type": "object", + "properties": { + "include": { "type": "string", "description": "'type_fields' to include CI-type custom fields." }, + "filter": { "type": "string", "description": "Query like \"asset_type_id:7 AND impact:'high'\"." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/assets", + "queryParams": { + "include": "$include", + "filter": "$filter", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "freshservice_list_agents", + "description": "List agents (your IT team members).", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Filter by email." }, + "state": { "type": "string", "description": "'fulltime' or 'occasional'." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/agents", + "queryParams": { + "email": "$email", + "state": "$state", + "page": "$page", + "per_page": "$per_page" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/freshservice.live.spec.ts b/packages/backend/src/adapters/intl/freshservice.live.spec.ts new file mode 100644 index 0000000..6753155 --- /dev/null +++ b/packages/backend/src/adapters/intl/freshservice.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './freshservice.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { password: string } }; +}; +describe('freshservice adapter — static spec conformance', () => { + it('per-tenant baseUrl with subdomain placeholder', () => + expect(a.connector.baseUrl).toBe('https://{{FRESHSERVICE_SUBDOMAIN}}.freshservice.com/api/v2')); + it('Basic auth with literal password "X"', () => { + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig.password).toBe('X'); + }); +}); diff --git a/packages/backend/src/adapters/intl/gorgias.json b/packages/backend/src/adapters/intl/gorgias.json new file mode 100644 index 0000000..34daa23 --- /dev/null +++ b/packages/backend/src/adapters/intl/gorgias.json @@ -0,0 +1,253 @@ +{ + "slug": "gorgias", + "name": "Gorgias", + "description": "Manage Gorgias (e-commerce helpdesk) tickets, messages, customers, integrations from any AI agent. 10 tools, basic-auth.", + "instructions": "This connector wraps the Gorgias REST API (per-subdomain).\n\n**Setup**:\n1. Log into your Gorgias dashboard at https://YOUR_SUBDOMAIN.gorgias.com → **Settings → REST API**.\n2. Click **Add new API Key**. The page shows your **email** (the account email) and a freshly-generated **API key**.\n3. Set `GORGIAS_SUBDOMAIN` (just the subdomain part, e.g. `mystore`), `GORGIAS_EMAIL`, `GORGIAS_API_KEY`.\n\n**Authentication**: HTTP Basic with `username = ${GORGIAS_EMAIL}` and `password = ${GORGIAS_API_KEY}`. The adapter wires this via BASIC_AUTH.\n\n**Per-tenant baseUrl**: Gorgias is hosted per-subdomain — `https://${GORGIAS_SUBDOMAIN}.gorgias.com/api`. baseUrl uses {{}} substitution at import time.\n\n**Ticket model**: `Ticket → Messages[]`. A ticket is a conversation thread with a customer; each message is one inbound/outbound email/chat/SMS. To reply, POST a new message to the ticket; to close, PUT the ticket with `status: 'closed'`.\n\n**Channels**: Gorgias unifies email, chat, SMS, Instagram DM, Facebook DM, Shopify, voice. `via` field on each message identifies the channel.\n\n**Pagination**: cursor-based. List endpoints return `meta.next_cursor` and `meta.previous_cursor`. Use `cursor` query param for the next page; `limit` controls page size (max 100).\n\n**Search**: the `tickets` endpoint accepts `view_id` to filter by saved view, and various direct filters. For more complex queries use the dedicated `/views/{id}/items` endpoint.\n\n**Rate limits**: 40 req/s per account; 2 concurrent connections. 429 returns `Retry-After` in seconds.\n\n**Out of scope here**: macro CRUD beyond list, rule management, satisfaction surveys, voice call orchestration.", + "region": "intl", + "category": "support", + "icon": "gorgias", + "docsUrl": "https://developers.gorgias.com/reference/introduction", + "requiredEnvVars": ["GORGIAS_SUBDOMAIN", "GORGIAS_EMAIL", "GORGIAS_API_KEY"], + "connector": { + "name": "Gorgias REST API", + "type": "REST", + "baseUrl": "https://{{GORGIAS_SUBDOMAIN}}.gorgias.com/api", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{GORGIAS_EMAIL}}", + "password": "{{GORGIAS_API_KEY}}" + } + }, + "tools": [ + { + "name": "gorgias_list_tickets", + "description": "List tickets. Filter by status, channel, assigned user, customer, view. Returns each ticket's id, subject, status, channel, customer, assignee_user, last_message_datetime, created/updated timestamps.", + "parameters": { + "type": "object", + "properties": { + "view_id": { "type": "integer", "description": "Filter to a saved view's tickets." }, + "customer_id": { "type": "integer", "description": "Filter to one customer." }, + "assignee_user_id": { "type": "integer", "description": "Filter to one assigned user." }, + "status": { "type": "string", "description": "open, closed, resolved." }, + "channel": { "type": "string", "description": "email, chat, sms, etc." }, + "cursor": { "type": "string", "description": "Pagination cursor." }, + "limit": { "type": "integer", "description": "Per page (max 100)." }, + "order_by": { "type": "string", "description": "created_datetime, updated_datetime, last_message_datetime — append :asc or :desc." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tickets", + "queryParams": { + "view_id": "$view_id", + "customer_id": "$customer_id", + "assignee_user_id": "$assignee_user_id", + "status": "$status", + "channel": "$channel", + "cursor": "$cursor", + "limit": "$limit", + "order_by": "$order_by" + } + } + }, + { + "name": "gorgias_get_ticket", + "description": "Get a single ticket by ID with its meta (does NOT include messages — use gorgias_list_ticket_messages).", + "parameters": { + "type": "object", + "properties": { + "ticket_id": { "type": "integer", "description": "Ticket ID." } + }, + "required": ["ticket_id"] + }, + "endpointMapping": { "method": "GET", "path": "/tickets/{ticket_id}" } + }, + { + "name": "gorgias_list_ticket_messages", + "description": "List messages on a ticket (the conversation thread). Each message has body_text, body_html, sender, receiver, via (channel), source.", + "parameters": { + "type": "object", + "properties": { + "ticket_id": { "type": "integer", "description": "Ticket ID." }, + "cursor": { "type": "string", "description": "Pagination cursor." }, + "limit": { "type": "integer", "description": "Per page (max 100)." } + }, + "required": ["ticket_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/tickets/{ticket_id}/messages", + "queryParams": { "cursor": "$cursor", "limit": "$limit" } + } + }, + { + "name": "gorgias_create_ticket", + "description": "Create a new ticket. Must include at least one message — Gorgias rejects empty tickets. Common pattern: pass `customer.email` + `messages: [{...}]`.", + "parameters": { + "type": "object", + "properties": { + "subject": { "type": "string", "description": "Ticket subject." }, + "status": { "type": "string", "description": "open, closed, resolved." }, + "channel": { "type": "string", "description": "email, chat, sms, api." }, + "customer": { "type": "object", "description": "Customer object {email, firstname, lastname} OR {id}." }, + "messages": { "type": "array", "description": "Array of message objects with body_text + sender + receiver + channel + via." }, + "tags": { "type": "array", "description": "Array of tag objects {name}." } + }, + "required": ["channel", "customer", "messages"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tickets", + "bodyMapping": { + "subject": "$subject", + "status": "$status", + "channel": "$channel", + "customer": "$customer", + "messages": "$messages", + "tags": "$tags" + } + } + }, + { + "name": "gorgias_reply_to_ticket", + "description": "Add a reply message to an existing ticket. Pass `body_text` + `sender` (your user) + `receiver` (the customer) + `channel`. Use `via: 'help-center'` to send as outbound.", + "parameters": { + "type": "object", + "properties": { + "ticket_id": { "type": "integer", "description": "Ticket ID." }, + "body_text": { "type": "string", "description": "Plain-text body." }, + "body_html": { "type": "string", "description": "HTML body (preferred for rich content)." }, + "sender": { "type": "object", "description": "{id} of the Gorgias user sending." }, + "receiver": { "type": "object", "description": "{id} of the customer." }, + "channel": { "type": "string", "description": "email, chat, sms." }, + "from_agent": { "type": "boolean", "description": "true for outbound." }, + "via": { "type": "string", "description": "Source: 'help-center', 'api', etc." } + }, + "required": ["ticket_id", "body_text", "sender", "channel"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tickets/{ticket_id}/messages", + "bodyMapping": { + "body_text": "$body_text", + "body_html": "$body_html", + "sender": "$sender", + "receiver": "$receiver", + "channel": "$channel", + "from_agent": "$from_agent", + "via": "$via" + } + } + }, + { + "name": "gorgias_update_ticket", + "description": "Update a ticket's status, assignee, tags, custom fields.", + "parameters": { + "type": "object", + "properties": { + "ticket_id": { "type": "integer", "description": "Ticket ID." }, + "status": { "type": "string", "description": "open, closed, resolved." }, + "assignee_user": { "type": "object", "description": "{id} of the Gorgias user." }, + "tags": { "type": "array", "description": "Array of {name} objects." }, + "priority": { "type": "string", "description": "low, normal, high, urgent." }, + "subject": { "type": "string", "description": "New subject." } + }, + "required": ["ticket_id"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/tickets/{ticket_id}", + "bodyMapping": { + "status": "$status", + "assignee_user": "$assignee_user", + "tags": "$tags", + "priority": "$priority", + "subject": "$subject" + } + } + }, + { + "name": "gorgias_list_customers", + "description": "List customers. Filter by email, external_id. Returns id, email, firstname, lastname, channels (linked accounts).", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Exact match on email." }, + "external_id": { "type": "string", "description": "Exact match on external (Shopify etc.) ID." }, + "cursor": { "type": "string", "description": "Pagination cursor." }, + "limit": { "type": "integer", "description": "Per page (max 100)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/customers", + "queryParams": { + "email": "$email", + "external_id": "$external_id", + "cursor": "$cursor", + "limit": "$limit" + } + } + }, + { + "name": "gorgias_get_customer", + "description": "Get a single customer by ID with full profile and integration data (e.g. Shopify orders count).", + "parameters": { + "type": "object", + "properties": { + "customer_id": { "type": "integer", "description": "Customer ID." } + }, + "required": ["customer_id"] + }, + "endpointMapping": { "method": "GET", "path": "/customers/{customer_id}" } + }, + { + "name": "gorgias_create_customer", + "description": "Create a customer record. Pass at least email + firstname + lastname.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Email." }, + "firstname": { "type": "string", "description": "First name." }, + "lastname": { "type": "string", "description": "Last name." }, + "external_id": { "type": "string", "description": "Optional external system ID (Shopify customer ID etc.)." }, + "language": { "type": "string", "description": "ISO 639-1 language code." }, + "timezone": { "type": "string", "description": "TZ database name." }, + "channels": { "type": "array", "description": "Additional channel objects: [{type:'email', address:'...'},{type:'phone', address:'+1...'}]." }, + "meta": { "type": "object", "description": "Free-form custom metadata object." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "POST", + "path": "/customers", + "bodyMapping": { + "email": "$email", + "firstname": "$firstname", + "lastname": "$lastname", + "external_id": "$external_id", + "language": "$language", + "timezone": "$timezone", + "channels": "$channels", + "meta": "$meta" + } + } + }, + { + "name": "gorgias_list_users", + "description": "List agents in your Gorgias workspace (the people on your team — sender IDs for reply messages).", + "parameters": { + "type": "object", + "properties": { + "cursor": { "type": "string", "description": "Pagination cursor." }, + "limit": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/users", + "queryParams": { "cursor": "$cursor", "limit": "$limit" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/gorgias.live.spec.ts b/packages/backend/src/adapters/intl/gorgias.live.spec.ts new file mode 100644 index 0000000..8bbf9ce --- /dev/null +++ b/packages/backend/src/adapters/intl/gorgias.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './gorgias.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('gorgias adapter — static spec conformance', () => { + it('per-subdomain baseUrl with placeholder', () => + expect(a.connector.baseUrl).toBe('https://{{GORGIAS_SUBDOMAIN}}.gorgias.com/api')); + it('Basic auth (email + API key)', () => + expect(a.connector.authType).toBe('BASIC_AUTH')); +}); diff --git a/packages/backend/src/adapters/intl/hackernews.json b/packages/backend/src/adapters/intl/hackernews.json new file mode 100644 index 0000000..3cc7443 --- /dev/null +++ b/packages/backend/src/adapters/intl/hackernews.json @@ -0,0 +1,80 @@ +{ + "slug": "hackernews", + "name": "Hacker News", + "description": "Read Hacker News (stories, comments, users, top/new/best/ask/show/job lists) from any AI agent via the official Firebase API. 8 tools, no auth.", + "instructions": "This connector wraps the official Hacker News API hosted on Firebase (hacker-news.firebaseio.com).\n\n**Setup**: nothing. The API is public, read-only, and unauthenticated.\n\n**No write access**: the official API is READ-ONLY. There is no way to post stories, comments, or vote via this connector — those require a session cookie obtained by submitting an HTML login form to news.ycombinator.com, which is out of scope for an MCP adapter.\n\n**Two-step pattern**: list endpoints return arrays of integer IDs only (e.g. top 500 story IDs). You must then fetch each ID via `hackernews_get_item` to get the actual title/url/text/score. Plan accordingly — fetching 500 top stories means 501 requests.\n\n**Item types**: `story`, `comment`, `job`, `poll`, `pollopt`. Each has different fields — comments have `parent` + `text`, stories have `title` + `url` (or `text` for Ask HN), jobs have `title` + `url` + `text`.\n\n**Comment trees**: comments are a tree via `kids` (array of IDs) on each item. Walk recursively, watch for `deleted: true` or `dead: true` entries.\n\n**Rate limits**: no published limit, but Firebase will throttle aggressive clients. Stay under ~30 req/s and you'll be fine.\n\n**Cache friendly**: every item is content-addressable by ID and immutable once posted (votes update but story body doesn't). Long TTLs work well.\n\n**Out of scope here**: HN Algolia full-text search (different API at hn.algolia.com — could be a separate adapter), write/vote, email subscription.", + "region": "intl", + "category": "data", + "icon": "hackernews", + "docsUrl": "https://github.com/HackerNews/API", + "requiredEnvVars": [], + "connector": { + "name": "Hacker News Firebase API", + "type": "REST", + "baseUrl": "https://hacker-news.firebaseio.com/v0", + "authType": "NONE", + "authConfig": {} + }, + "tools": [ + { + "name": "hackernews_top_stories", + "description": "Returns an array of up to 500 item IDs for the current front-page (top) stories. Use hackernews_get_item to resolve each ID into the actual story.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/topstories.json" } + }, + { + "name": "hackernews_new_stories", + "description": "Returns an array of up to 500 newest story IDs.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/newstories.json" } + }, + { + "name": "hackernews_best_stories", + "description": "Returns an array of up to 500 'best' story IDs (HN's algorithmic top).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/beststories.json" } + }, + { + "name": "hackernews_ask_stories", + "description": "Returns up to 200 Ask HN story IDs.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/askstories.json" } + }, + { + "name": "hackernews_show_stories", + "description": "Returns up to 200 Show HN story IDs.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/showstories.json" } + }, + { + "name": "hackernews_job_stories", + "description": "Returns up to 200 'Who is hiring / job' story IDs.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/jobstories.json" } + }, + { + "name": "hackernews_get_item", + "description": "Fetch a single item (story, comment, job, poll, pollopt) by ID. Returns the full payload including `type`, `by`, `time`, `title`, `url`, `text`, `score`, `kids` (child comment IDs), `parent`.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "HN item ID." } + }, + "required": ["id"] + }, + "endpointMapping": { "method": "GET", "path": "/item/{id}.json" } + }, + { + "name": "hackernews_get_user", + "description": "Fetch user profile by case-sensitive username: returns `id`, `created`, `karma`, `about`, `submitted` (array of recent item IDs).", + "parameters": { + "type": "object", + "properties": { + "username": { "type": "string", "description": "HN username, case-sensitive." } + }, + "required": ["username"] + }, + "endpointMapping": { "method": "GET", "path": "/user/{username}.json" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/hackernews.live.spec.ts b/packages/backend/src/adapters/intl/hackernews.live.spec.ts new file mode 100644 index 0000000..64bb033 --- /dev/null +++ b/packages/backend/src/adapters/intl/hackernews.live.spec.ts @@ -0,0 +1,7 @@ +import * as adapter from './hackernews.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('hackernews adapter — static spec conformance', () => { + it('hacker-news.firebaseio.com/v0 base URL', () => + expect(a.connector.baseUrl).toBe('https://hacker-news.firebaseio.com/v0')); + it('no auth (public API)', () => expect(a.connector.authType).toBe('NONE')); +}); diff --git a/packages/backend/src/adapters/intl/harvest.json b/packages/backend/src/adapters/intl/harvest.json new file mode 100644 index 0000000..497b0a9 --- /dev/null +++ b/packages/backend/src/adapters/intl/harvest.json @@ -0,0 +1,260 @@ +{ + "slug": "harvest", + "name": "Harvest", + "description": "Manage Harvest (time tracking, projects, clients, invoices, expenses) from any AI agent. 10 tools, Bearer auth with account-ID header.", + "instructions": "This connector wraps the Harvest API v2 (api.harvestapp.com/v2).\n\n**Setup**:\n1. Log into https://id.getharvest.com → bottom-left avatar → **Developers → Create new personal access token**.\n2. Copy the **token** and the **Account ID** shown on the same page (a 7-digit number like `1234567`).\n3. Set both: `HARVEST_ACCESS_TOKEN` and `HARVEST_ACCOUNT_ID`.\n\n**Authentication**: every request requires BOTH:\n- `Authorization: Bearer ${HARVEST_ACCESS_TOKEN}`\n- `Harvest-Account-Id: ${HARVEST_ACCOUNT_ID}`\n- A `User-Agent` header (Harvest API requires it — the adapter sets `AnythingMCP (support@anythingmcp.com)`).\n\nMissing the account header returns `401 Unauthorized` even with a valid token — confusing but documented.\n\n**Hierarchy**: `Client → Project → Task assignment → Time entry`. To log time, you need the project ID + task ID. List projects to find IDs.\n\n**Dates** are ISO 8601 (`2026-05-20`). `spent_date` on a time entry is required.\n\n**Pagination**: `page` (1-based) + `per_page` (max 100). Total in `total_pages`.\n\n**Rate limits**: 100 req / 15s per account; 1k req / 15s globally per token. 429 returns no Retry-After — back off 15s.\n\n**Out of scope here**: invoice line CRUD beyond list/get, estimates, contacts, expenses categories management, recurring schedules.", + "region": "intl", + "category": "time-tracking", + "icon": "harvest", + "docsUrl": "https://help.getharvest.com/api-v2/", + "requiredEnvVars": ["HARVEST_ACCESS_TOKEN", "HARVEST_ACCOUNT_ID"], + "connector": { + "name": "Harvest API v2", + "type": "REST", + "baseUrl": "https://api.harvestapp.com/v2", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{HARVEST_ACCESS_TOKEN}}" + }, + "headers": { + "Harvest-Account-Id": "{{HARVEST_ACCOUNT_ID}}", + "User-Agent": "AnythingMCP (support@anythingmcp.com)" + } + }, + "tools": [ + { + "name": "harvest_who_am_i", + "description": "Return the currently authenticated user: id, first_name, last_name, email, is_admin, default_hourly_rate, weekly_capacity.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/users/me" } + }, + { + "name": "harvest_list_clients", + "description": "List clients. Each has id, name, is_active, address, currency.", + "parameters": { + "type": "object", + "properties": { + "is_active": { "type": "boolean", "description": "Filter to active only." }, + "updated_since": { "type": "string", "description": "ISO 8601 datetime — only clients updated after this." }, + "page": { "type": "integer", "description": "1-based page index." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/clients", + "queryParams": { + "is_active": "$is_active", + "updated_since": "$updated_since", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "harvest_list_projects", + "description": "List projects. Most useful filter: `client_id` to drill into one client's projects. Returns id, name, code, is_active, is_billable, budget, hourly_rate, client info.", + "parameters": { + "type": "object", + "properties": { + "client_id": { "type": "integer", "description": "Filter to one client's projects." }, + "is_active": { "type": "boolean", "description": "Filter to active only." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/projects", + "queryParams": { + "client_id": "$client_id", + "is_active": "$is_active", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "harvest_list_tasks", + "description": "List task templates (the canonical Harvest 'task' types: 'Programming', 'Design', etc.) that can be assigned to projects.", + "parameters": { + "type": "object", + "properties": { + "is_active": { "type": "boolean", "description": "Filter to active only." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tasks", + "queryParams": { "is_active": "$is_active", "page": "$page", "per_page": "$per_page" } + } + }, + { + "name": "harvest_list_time_entries", + "description": "List time entries. Most useful filters: `from`/`to` for date range, `user_id` for one user, `project_id` for one project. Returns each entry with hours, notes, billable status, dates, project, task.", + "parameters": { + "type": "object", + "properties": { + "user_id": { "type": "integer", "description": "Filter to one user." }, + "client_id": { "type": "integer", "description": "Filter to one client." }, + "project_id": { "type": "integer", "description": "Filter to one project." }, + "task_id": { "type": "integer", "description": "Filter to one task." }, + "from": { "type": "string", "description": "Start date (ISO 8601 YYYY-MM-DD)." }, + "to": { "type": "string", "description": "End date (YYYY-MM-DD)." }, + "is_billed": { "type": "boolean", "description": "Filter by billed status." }, + "is_running": { "type": "boolean", "description": "Only currently-running timers." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/time_entries", + "queryParams": { + "user_id": "$user_id", + "client_id": "$client_id", + "project_id": "$project_id", + "task_id": "$task_id", + "from": "$from", + "to": "$to", + "is_billed": "$is_billed", + "is_running": "$is_running", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "harvest_create_time_entry", + "description": "Log time on a project + task. EITHER supply `hours` for a manual entry OR omit hours to start a running timer (you'll need to stop it later with harvest_stop_timer).", + "parameters": { + "type": "object", + "properties": { + "project_id": { "type": "integer", "description": "Project ID." }, + "task_id": { "type": "integer", "description": "Task ID assigned to this project." }, + "spent_date": { "type": "string", "description": "Date worked (YYYY-MM-DD)." }, + "hours": { "type": "number", "description": "Hours worked. Omit to start a running timer." }, + "notes": { "type": "string", "description": "Optional notes." }, + "user_id": { "type": "integer", "description": "Defaults to current user; admins can log for others." }, + "external_reference": { "type": "object", "description": "External system reference {id, group_id, account_id, permalink, service, service_icon_url}." } + }, + "required": ["project_id", "task_id", "spent_date"] + }, + "endpointMapping": { + "method": "POST", + "path": "/time_entries", + "bodyMapping": { + "project_id": "$project_id", + "task_id": "$task_id", + "spent_date": "$spent_date", + "hours": "$hours", + "notes": "$notes", + "user_id": "$user_id", + "external_reference": "$external_reference" + } + } + }, + { + "name": "harvest_update_time_entry", + "description": "Update fields on an existing time entry (notes, hours, project, task).", + "parameters": { + "type": "object", + "properties": { + "time_entry_id": { "type": "integer", "description": "Time entry ID." }, + "hours": { "type": "number", "description": "New hours." }, + "notes": { "type": "string", "description": "New notes." }, + "project_id": { "type": "integer", "description": "Move to a different project." }, + "task_id": { "type": "integer", "description": "Move to a different task." } + }, + "required": ["time_entry_id"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/time_entries/{time_entry_id}", + "bodyMapping": { + "hours": "$hours", + "notes": "$notes", + "project_id": "$project_id", + "task_id": "$task_id" + } + } + }, + { + "name": "harvest_stop_timer", + "description": "Stop a currently-running time entry timer.", + "parameters": { + "type": "object", + "properties": { + "time_entry_id": { "type": "integer", "description": "Running time entry ID." } + }, + "required": ["time_entry_id"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/time_entries/{time_entry_id}/stop" + } + }, + { + "name": "harvest_list_invoices", + "description": "List invoices. Filter by client_id, state ('draft', 'open', 'paid', 'closed'), date range. Returns each invoice's number, amount, due_date, paid_date, currency, line items count.", + "parameters": { + "type": "object", + "properties": { + "client_id": { "type": "integer", "description": "Filter to one client." }, + "project_id": { "type": "integer", "description": "Filter to one project." }, + "state": { "type": "string", "description": "draft, open, paid, closed." }, + "from": { "type": "string", "description": "Issued from (YYYY-MM-DD)." }, + "to": { "type": "string", "description": "Issued to (YYYY-MM-DD)." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/invoices", + "queryParams": { + "client_id": "$client_id", + "project_id": "$project_id", + "state": "$state", + "from": "$from", + "to": "$to", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "harvest_list_expenses", + "description": "List expenses. Filter by client, project, user, date range. Returns each expense's amount, units, billable status, receipt URL.", + "parameters": { + "type": "object", + "properties": { + "user_id": { "type": "integer", "description": "Filter to one user." }, + "client_id": { "type": "integer", "description": "Filter to one client." }, + "project_id": { "type": "integer", "description": "Filter to one project." }, + "is_billed": { "type": "boolean", "description": "Filter by billed status." }, + "from": { "type": "string", "description": "Spent from (YYYY-MM-DD)." }, + "to": { "type": "string", "description": "Spent to (YYYY-MM-DD)." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/expenses", + "queryParams": { + "user_id": "$user_id", + "client_id": "$client_id", + "project_id": "$project_id", + "is_billed": "$is_billed", + "from": "$from", + "to": "$to", + "page": "$page", + "per_page": "$per_page" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/harvest.live.spec.ts b/packages/backend/src/adapters/intl/harvest.live.spec.ts new file mode 100644 index 0000000..f0d5c58 --- /dev/null +++ b/packages/backend/src/adapters/intl/harvest.live.spec.ts @@ -0,0 +1,13 @@ +import * as adapter from './harvest.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; headers: Record }; +}; +describe('harvest adapter — static spec conformance', () => { + it('api.harvestapp.com/v2 base URL', () => + expect(a.connector.baseUrl).toBe('https://api.harvestapp.com/v2')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); + it('Harvest-Account-Id + User-Agent headers required', () => { + expect(a.connector.headers['Harvest-Account-Id']).toBeDefined(); + expect(a.connector.headers['User-Agent']).toBeDefined(); + }); +}); diff --git a/packages/backend/src/adapters/intl/mastodon.json b/packages/backend/src/adapters/intl/mastodon.json new file mode 100644 index 0000000..08cee3f --- /dev/null +++ b/packages/backend/src/adapters/intl/mastodon.json @@ -0,0 +1,199 @@ +{ + "slug": "mastodon", + "name": "Mastodon", + "description": "Read & post on any Mastodon instance from any AI agent. 10 tools, Bearer token auth.", + "instructions": "This connector wraps the Mastodon REST API (any instance — set baseUrl to your instance).\n\n**Setup**:\n1. Pick your instance (e.g. `https://mastodon.social`, `https://fosstodon.org`, your self-hosted one).\n2. Log in → **Preferences → Development → New application**.\n3. Name it (e.g. 'mcp'). Scopes: `read`, `write`, `follow`, `push` — tick whatever your use case needs (don't over-grant).\n4. Save → copy the **Your access token** value (shown only once). This is a personal access token tied to your account.\n5. Set `MASTODON_INSTANCE_URL` (e.g. `https://mastodon.social`) and `MASTODON_ACCESS_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${MASTODON_ACCESS_TOKEN}` on every request.\n\n**Per-instance API**: Mastodon is federated — every instance hosts its own API. baseUrl is your home instance. To read posts from other instances, the API will federate transparently when you provide a status ID OR you can use the public timeline endpoints.\n\n**IDs are per-instance**: a status ID from mastodon.social is NOT the same as the same status on fosstodon.org (each instance assigns its own internal ID even if the status was federated). To look up cross-instance: use `mastodon_search` with the full URL.\n\n**Post (toot) limits**: most instances allow 500 characters; some configure more. Polls have separate rules.\n\n**Pagination**: cursor-based via Link headers (`max_id`, `min_id`, `since_id`). The adapter exposes `max_id` / `since_id` as parameters.\n\n**Visibility**: when posting, `visibility` is one of `public`, `unlisted`, `private` (followers-only), `direct` (mentioned-only). Default per-account.\n\n**Rate limits**: 300 req / 5min per token on most instances. 429 with `X-RateLimit-Reset` header.\n\n**Out of scope here**: image/video upload multipart (use the dedicated media endpoint with a separate flow), instance admin, lists CRUD, push subscriptions.", + "region": "intl", + "category": "social", + "icon": "mastodon", + "docsUrl": "https://docs.joinmastodon.org/api/", + "requiredEnvVars": ["MASTODON_INSTANCE_URL", "MASTODON_ACCESS_TOKEN"], + "connector": { + "name": "Mastodon REST API", + "type": "REST", + "baseUrl": "{{MASTODON_INSTANCE_URL}}", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{MASTODON_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "mastodon_verify_credentials", + "description": "Return the authenticated account: id, username, acct (full handle), display_name, note, avatar, statuses_count, followers_count, following_count.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/api/v1/accounts/verify_credentials" } + }, + { + "name": "mastodon_get_account", + "description": "Fetch a public account by ID. For lookup by handle, use mastodon_search.", + "parameters": { + "type": "object", + "properties": { + "account_id": { "type": "string", "description": "Account ID." } + }, + "required": ["account_id"] + }, + "endpointMapping": { "method": "GET", "path": "/api/v1/accounts/{account_id}" } + }, + { + "name": "mastodon_account_statuses", + "description": "Get a user's recent statuses (toots).", + "parameters": { + "type": "object", + "properties": { + "account_id": { "type": "string", "description": "Account ID." }, + "limit": { "type": "integer", "description": "Max 40." }, + "max_id": { "type": "string", "description": "Pagination — return statuses older than this id." }, + "since_id": { "type": "string", "description": "Pagination — return statuses newer than this id." }, + "exclude_replies": { "type": "boolean", "description": "Skip replies." }, + "exclude_reblogs": { "type": "boolean", "description": "Skip boosts." }, + "only_media": { "type": "boolean", "description": "Only statuses with attachments." } + }, + "required": ["account_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/accounts/{account_id}/statuses", + "queryParams": { + "limit": "$limit", + "max_id": "$max_id", + "since_id": "$since_id", + "exclude_replies": "$exclude_replies", + "exclude_reblogs": "$exclude_reblogs", + "only_media": "$only_media" + } + } + }, + { + "name": "mastodon_home_timeline", + "description": "Get the authenticated user's home timeline (their follows).", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Max 40." }, + "max_id": { "type": "string", "description": "Older than." }, + "since_id": { "type": "string", "description": "Newer than." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/timelines/home", + "queryParams": { "limit": "$limit", "max_id": "$max_id", "since_id": "$since_id" } + } + }, + { + "name": "mastodon_public_timeline", + "description": "Get the public timeline. `local=true` = only this instance's accounts; `remote=true` = only federated. Default: federated public.", + "parameters": { + "type": "object", + "properties": { + "local": { "type": "boolean", "description": "Only this instance." }, + "remote": { "type": "boolean", "description": "Only federated remote accounts." }, + "only_media": { "type": "boolean", "description": "Only statuses with media." }, + "limit": { "type": "integer", "description": "Max 40." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/timelines/public", + "queryParams": { + "local": "$local", + "remote": "$remote", + "only_media": "$only_media", + "limit": "$limit" + } + } + }, + { + "name": "mastodon_hashtag_timeline", + "description": "Get statuses tagged with a hashtag (no # prefix).", + "parameters": { + "type": "object", + "properties": { + "hashtag": { "type": "string", "description": "Tag, no '#'." }, + "local": { "type": "boolean", "description": "Only local accounts." }, + "limit": { "type": "integer", "description": "Max 40." }, + "max_id": { "type": "string", "description": "Older than." } + }, + "required": ["hashtag"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/timelines/tag/{hashtag}", + "queryParams": { "local": "$local", "limit": "$limit", "max_id": "$max_id" } + } + }, + { + "name": "mastodon_search", + "description": "Full-text search across accounts, statuses and hashtags. Pass a full status URL in `q` to look up federated content by URL.", + "parameters": { + "type": "object", + "properties": { + "q": { "type": "string", "description": "Query text or URL." }, + "type": { "type": "string", "description": "Restrict: 'accounts', 'statuses', or 'hashtags'." }, + "resolve": { "type": "boolean", "description": "If true and `q` is a URL, fetch it from the remote instance." }, + "limit": { "type": "integer", "description": "Max 40." } + }, + "required": ["q"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/search", + "queryParams": { "q": "$q", "type": "$type", "resolve": "$resolve", "limit": "$limit" } + } + }, + { + "name": "mastodon_get_status", + "description": "Fetch a single status by ID. Returns content, account, media, mentions, tags, replies/favourites/reblogs counts.", + "parameters": { + "type": "object", + "properties": { + "status_id": { "type": "string", "description": "Status ID (per-instance)." } + }, + "required": ["status_id"] + }, + "endpointMapping": { "method": "GET", "path": "/api/v1/statuses/{status_id}" } + }, + { + "name": "mastodon_post_status", + "description": "Post a new status (toot). `status` is the text; optionally include `in_reply_to_id` to thread.", + "parameters": { + "type": "object", + "properties": { + "status": { "type": "string", "description": "Status text. Most instances cap at 500 chars." }, + "in_reply_to_id": { "type": "string", "description": "ID of status this is a reply to." }, + "sensitive": { "type": "boolean", "description": "Mark media as sensitive (CW)." }, + "spoiler_text": { "type": "string", "description": "Content warning text (subject line)." }, + "visibility": { "type": "string", "description": "'public', 'unlisted', 'private', 'direct'." }, + "language": { "type": "string", "description": "ISO 639-1 language code." } + }, + "required": ["status"] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/v1/statuses", + "bodyMapping": { + "status": "$status", + "in_reply_to_id": "$in_reply_to_id", + "sensitive": "$sensitive", + "spoiler_text": "$spoiler_text", + "visibility": "$visibility", + "language": "$language" + } + } + }, + { + "name": "mastodon_delete_status", + "description": "Delete one of your own statuses.", + "parameters": { + "type": "object", + "properties": { + "status_id": { "type": "string", "description": "Status ID." } + }, + "required": ["status_id"] + }, + "endpointMapping": { "method": "DELETE", "path": "/api/v1/statuses/{status_id}" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/mastodon.live.spec.ts b/packages/backend/src/adapters/intl/mastodon.live.spec.ts new file mode 100644 index 0000000..f7de6ed --- /dev/null +++ b/packages/backend/src/adapters/intl/mastodon.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './mastodon.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('mastodon adapter — static spec conformance', () => { + it('baseUrl is the per-instance placeholder', () => + expect(a.connector.baseUrl).toBe('{{MASTODON_INSTANCE_URL}}')); + it('Bearer auth (per-instance personal access token)', () => + expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/new-relic.json b/packages/backend/src/adapters/intl/new-relic.json new file mode 100644 index 0000000..005b74d --- /dev/null +++ b/packages/backend/src/adapters/intl/new-relic.json @@ -0,0 +1,159 @@ +{ + "slug": "new-relic", + "name": "New Relic", + "description": "Query New Relic (NRQL, alerts, incidents, dashboards, deployments, entities) from any AI agent via NerdGraph + REST. 7 tools, API-key auth.", + "instructions": "This connector wraps two New Relic APIs: the modern NerdGraph (GraphQL) at api.newrelic.com/graphql AND the legacy REST API for deployments (api.newrelic.com/v2).\n\n**Setup**:\n1. Log into https://one.newrelic.com → top-right avatar → **API keys**.\n2. Create a **User key** (type: USER, NOT 'License key' / 'Ingest key'). Scopes: account read at minimum; admin if you want to create deployments. Set `NEW_RELIC_USER_KEY`.\n3. Note your **Account ID** (visible in the URL or via the keys page). You'll pass it as a parameter to most tools.\n4. **EU region**: if your org is on the EU data centre, change baseUrl to `https://api.eu.newrelic.com`.\n\n**Authentication**: every request sends `Api-Key: ${NEW_RELIC_USER_KEY}` as a header.\n\n**NerdGraph is the modern API**: most data lives in GraphQL now. The `new-relic_run_nrql` tool wraps the common pattern — pass an account ID + NRQL string, get rows back. For arbitrary queries use `new-relic_graphql_query` (raw GraphQL passthrough).\n\n**NRQL = New Relic Query Language**: SQL-like — `SELECT count(*) FROM Transaction WHERE appName = 'web' SINCE 1 hour ago FACET name`. Use FACET for grouping, TIMESERIES for time-bucketed data, SINCE / UNTIL for windows.\n\n**Time windows**: NRQL uses natural language — `SINCE 30 minutes ago`, `SINCE '2025-01-01' UNTIL '2025-01-02'`.\n\n**Rate limits**: NerdGraph allows 3k req/min per user key; NRQL queries are also gated by a per-account query budget.\n\n**Out of scope here**: agent installation, Synthetics test CRUD, full Alert policy/condition CRUD beyond list (use the dedicated Terraform provider).", + "region": "intl", + "category": "monitoring", + "icon": "new-relic", + "docsUrl": "https://docs.newrelic.com/docs/apis/nerdgraph/get-started/introduction-new-relic-nerdgraph/", + "requiredEnvVars": ["NEW_RELIC_USER_KEY"], + "connector": { + "name": "New Relic NerdGraph + REST", + "type": "REST", + "baseUrl": "https://api.newrelic.com", + "authType": "API_KEY", + "authConfig": { + "headerName": "Api-Key", + "apiKey": "{{NEW_RELIC_USER_KEY}}" + } + }, + "tools": [ + { + "name": "new_relic_run_nrql", + "description": "Run an NRQL (New Relic Query Language) query against an account. Wraps the NerdGraph actor.account.nrql.results pattern — much simpler than authoring raw GraphQL. Use this for 'how many errors in the last hour by service' etc.", + "parameters": { + "type": "object", + "properties": { + "account_id": { "type": "integer", "description": "New Relic account ID." }, + "nrql": { "type": "string", "description": "NRQL query, e.g. 'SELECT count(*) FROM Transaction SINCE 1 hour ago FACET appName'." } + }, + "required": ["account_id", "nrql"] + }, + "endpointMapping": { + "method": "POST", + "path": "/graphql", + "bodyMapping": { + "query": "query($accountId: Int!, $nrql: Nrql!) { actor { account(id: $accountId) { nrql(query: $nrql) { results metadata { facets } } } } }", + "variables": { "accountId": "$account_id", "nrql": "$nrql" } + } + } + }, + { + "name": "new_relic_graphql_query", + "description": "Run an arbitrary NerdGraph (GraphQL) query/mutation. Use this when run_nrql isn't enough — for entity search, dashboards, workloads, etc.", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "GraphQL document." }, + "variables": { "type": "object", "description": "Variables map." } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "POST", + "path": "/graphql", + "bodyMapping": { "query": "$query", "variables": "$variables" } + } + }, + { + "name": "new_relic_search_entities", + "description": "Find entities (services, hosts, browsers, mobile apps, dashboards, workloads) by name + domain + type. Returns entity GUID, name, accountId, alertSeverity, reporting status.", + "parameters": { + "type": "object", + "properties": { + "query_string": { "type": "string", "description": "Entity search query, e.g. \"name LIKE 'api%' AND domain = 'APM'\"." } + }, + "required": ["query_string"] + }, + "endpointMapping": { + "method": "POST", + "path": "/graphql", + "bodyMapping": { + "query": "query($q: String!) { actor { entitySearch(query: $q) { results { entities { guid name domain type alertSeverity reporting accountId } } count } } }", + "variables": { "q": "$query_string" } + } + } + }, + { + "name": "new_relic_get_entity", + "description": "Fetch an entity by GUID — returns the full entity payload (golden metrics, golden tags, recent issues for APM apps).", + "parameters": { + "type": "object", + "properties": { + "guid": { "type": "string", "description": "Entity GUID from entitySearch." } + }, + "required": ["guid"] + }, + "endpointMapping": { + "method": "POST", + "path": "/graphql", + "bodyMapping": { + "query": "query($guid: EntityGuid!) { actor { entity(guid: $guid) { guid name domain type tags { key values } alertSeverity ... on ApmApplicationEntity { goldenMetrics { metrics { name } } } } } }", + "variables": { "guid": "$guid" } + } + } + }, + { + "name": "new_relic_list_deployments", + "description": "List recent deployments recorded for an APM application via the legacy REST API. Returns each deployment's id, revision, description, user, timestamp.", + "parameters": { + "type": "object", + "properties": { + "application_id": { "type": "integer", "description": "Legacy APM application numeric ID (NOT entity GUID)." } + }, + "required": ["application_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/applications/{application_id}/deployments.json" + } + }, + { + "name": "new_relic_create_deployment", + "description": "Record a deployment marker on an APM application's chart. Useful for correlating regressions with releases.", + "parameters": { + "type": "object", + "properties": { + "application_id": { "type": "integer", "description": "Legacy APM application numeric ID." }, + "revision": { "type": "string", "description": "Version / git SHA / build number." }, + "description": { "type": "string", "description": "What changed." }, + "user": { "type": "string", "description": "Who deployed (free-text)." }, + "changelog": { "type": "string", "description": "Optional release notes." } + }, + "required": ["application_id", "revision"] + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/applications/{application_id}/deployments.json", + "bodyMapping": { + "deployment": { + "revision": "$revision", + "description": "$description", + "user": "$user", + "changelog": "$changelog" + } + } + } + }, + { + "name": "new_relic_recent_incidents", + "description": "List recent open incidents for an account from the AI/AIops issue stream.", + "parameters": { + "type": "object", + "properties": { + "account_id": { "type": "integer", "description": "Account ID." } + }, + "required": ["account_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/graphql", + "bodyMapping": { + "query": "query($accountId: Int!) { actor { account(id: $accountId) { aiIssues { issues(filter: { states: [ACTIVE] }) { issues { issueId title priority createdAt entityNames } } } } } }", + "variables": { "accountId": "$account_id" } + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/new-relic.live.spec.ts b/packages/backend/src/adapters/intl/new-relic.live.spec.ts new file mode 100644 index 0000000..d14373e --- /dev/null +++ b/packages/backend/src/adapters/intl/new-relic.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './new-relic.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { headerName: string } }; +}; +describe('new-relic adapter — static spec conformance', () => { + it('api.newrelic.com base URL (US default)', () => + expect(a.connector.baseUrl).toBe('https://api.newrelic.com')); + it('Api-Key header auth', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('Api-Key'); + }); +}); diff --git a/packages/backend/src/adapters/intl/newsapi.json b/packages/backend/src/adapters/intl/newsapi.json new file mode 100644 index 0000000..d8e4bef --- /dev/null +++ b/packages/backend/src/adapters/intl/newsapi.json @@ -0,0 +1,104 @@ +{ + "slug": "newsapi", + "name": "NewsAPI", + "description": "Search worldwide news headlines, sources and articles from newsapi.org via any AI agent. 3 tools, API-key auth.", + "instructions": "This connector wraps the NewsAPI v2 REST API (newsapi.org).\n\n**Setup**:\n1. Register at https://newsapi.org/register for a free Developer key.\n2. Set `NEWSAPI_API_KEY`.\n\n**Authentication**: NewsAPI accepts the key as either the `X-Api-Key` header (used by this adapter) OR an `apiKey` query string. We use the header so it doesn't appear in URL logs.\n\n**Plan limits — IMPORTANT**:\n- Developer (free): 100 requests / day, articles delayed by 24h, no commercial use, results capped at 100 per query.\n- Paid plans: live articles, no daily cap, full historical archive.\n\n**Three endpoints**:\n- `everything` — full archive search, the most powerful. Filter by `q`, `sources`, `domains`, `from`/`to` date, `language`, `sortBy`.\n- `top-headlines` — breaking news, filtered by `country`, `category`, `sources`, or `q`. You CANNOT mix `country`/`category` with `sources` in the same call.\n- `top-headlines/sources` — list all sources, optional filters by `category`, `language`, `country`.\n\n**Query syntax**: `q` supports `AND`, `OR`, `NOT`, exact-phrase double quotes, and grouping with parentheses. Example: `q=(apple AND iphone) NOT macbook`.\n\n**Languages**: ISO-639-1 codes — `en`, `it`, `de`, `es`, `fr`, ... See docs for full list.\n\n**Out of scope here**: NewsAPI does NOT expose article full text — only `title`, `description`, `content` (truncated to ~200 chars). For full text you must fetch the `url` separately.", + "region": "intl", + "category": "data", + "icon": "newsapi", + "docsUrl": "https://newsapi.org/docs", + "requiredEnvVars": ["NEWSAPI_API_KEY"], + "connector": { + "name": "NewsAPI v2", + "type": "REST", + "baseUrl": "https://newsapi.org/v2", + "authType": "API_KEY", + "authConfig": { + "headerName": "X-Api-Key", + "apiKey": "{{NEWSAPI_API_KEY}}" + } + }, + "tools": [ + { + "name": "newsapi_everything", + "description": "Full-archive search across millions of articles from 80k+ sources. Use this when the user wants 'articles about X' or 'news mentioning Y this week'. Requires at least one of `q`, `qInTitle`, `sources`, or `domains`.", + "parameters": { + "type": "object", + "properties": { + "q": { "type": "string", "description": "Keywords/phrases. Supports AND/OR/NOT, quotes, parentheses." }, + "qInTitle": { "type": "string", "description": "Same as `q` but only matches against the article title." }, + "sources": { "type": "string", "description": "Comma-separated source IDs (e.g. 'bbc-news,the-verge'). Find IDs via newsapi_sources." }, + "domains": { "type": "string", "description": "Comma-separated domains (e.g. 'bbc.co.uk,nytimes.com')." }, + "excludeDomains": { "type": "string", "description": "Comma-separated domains to EXCLUDE." }, + "from": { "type": "string", "description": "ISO 8601 date or datetime. Oldest article date." }, + "to": { "type": "string", "description": "ISO 8601 date or datetime. Newest article date." }, + "language": { "type": "string", "description": "ISO-639-1: en, it, de, es, fr, ..." }, + "sortBy": { "type": "string", "description": "'relevancy', 'popularity', 'publishedAt'. Default 'publishedAt'." }, + "pageSize": { "type": "integer", "description": "Results per page (1-100). Default 100." }, + "page": { "type": "integer", "description": "1-based page index." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/everything", + "queryParams": { + "q": "$q", + "qInTitle": "$qInTitle", + "sources": "$sources", + "domains": "$domains", + "excludeDomains": "$excludeDomains", + "from": "$from", + "to": "$to", + "language": "$language", + "sortBy": "$sortBy", + "pageSize": "$pageSize", + "page": "$page" + } + } + }, + { + "name": "newsapi_top_headlines", + "description": "Breaking news headlines. Filter by `country` + `category` for a country front-page, OR by `sources` for specific outlets, OR by `q` for a topic. You CANNOT combine `country`/`category` with `sources` in one call.", + "parameters": { + "type": "object", + "properties": { + "country": { "type": "string", "description": "2-letter ISO 3166-1 (e.g. 'us', 'it', 'de'). Cannot be used with `sources`." }, + "category": { "type": "string", "description": "business, entertainment, general, health, science, sports, technology. Cannot be used with `sources`." }, + "sources": { "type": "string", "description": "Comma-separated source IDs. Cannot be used with `country`/`category`." }, + "q": { "type": "string", "description": "Keywords/phrases." }, + "pageSize": { "type": "integer", "description": "1-100." }, + "page": { "type": "integer", "description": "1-based." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/top-headlines", + "queryParams": { + "country": "$country", + "category": "$category", + "sources": "$sources", + "q": "$q", + "pageSize": "$pageSize", + "page": "$page" + } + } + }, + { + "name": "newsapi_sources", + "description": "List all news sources NewsAPI tracks. Returns each source's id (used by other endpoints), name, description, url, category, language, country.", + "parameters": { + "type": "object", + "properties": { + "category": { "type": "string", "description": "Filter by category." }, + "language": { "type": "string", "description": "ISO-639-1." }, + "country": { "type": "string", "description": "2-letter ISO 3166-1." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/top-headlines/sources", + "queryParams": { "category": "$category", "language": "$language", "country": "$country" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/newsapi.live.spec.ts b/packages/backend/src/adapters/intl/newsapi.live.spec.ts new file mode 100644 index 0000000..4b2cd12 --- /dev/null +++ b/packages/backend/src/adapters/intl/newsapi.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './newsapi.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { headerName: string } }; +}; +describe('newsapi adapter — static spec conformance', () => { + it('newsapi.org/v2 base URL', () => + expect(a.connector.baseUrl).toBe('https://newsapi.org/v2')); + it('X-Api-Key header auth', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('X-Api-Key'); + }); +}); diff --git a/packages/backend/src/adapters/intl/openweather.json b/packages/backend/src/adapters/intl/openweather.json new file mode 100644 index 0000000..55a6da7 --- /dev/null +++ b/packages/backend/src/adapters/intl/openweather.json @@ -0,0 +1,130 @@ +{ + "slug": "openweather", + "name": "OpenWeather", + "description": "Query OpenWeather (current weather, forecasts, air quality, geocoding) from any AI agent. 6 tools, API-key auth.", + "instructions": "This connector wraps the OpenWeather REST API (api.openweathermap.org).\n\n**Setup**:\n1. Sign up at https://openweathermap.org/users/sign_up (free tier: 60 calls/min, 1M/month).\n2. Go to https://home.openweathermap.org/api_keys → copy the default key OR create a new one labelled e.g. `mcp`.\n3. Wait ~10 minutes — new keys take time to activate on the free tier.\n4. Set `OPENWEATHER_API_KEY`.\n\n**Authentication**: every request takes `appid=${OPENWEATHER_API_KEY}` as a query string. The adapter injects it automatically — never include it in tool arguments.\n\n**Coordinates first**: most endpoints want `lat`/`lon`, not city names. Use `openweather_geocode_city` first to translate `London,GB` into coordinates, then call the weather endpoints with the coordinates. The Geocoding API is free and unlimited within the regular rate limit.\n\n**Units**: pass `units=metric` (Celsius, m/s) or `units=imperial` (Fahrenheit, mph). Default (`standard`) returns Kelvin and m/s — usually NOT what you want.\n\n**Language**: pass `lang=it` / `de` / `en` for localized weather descriptions. The numeric data is always identical, only the human-readable `weather[0].description` string changes.\n\n**Forecast endpoints**: the free tier exposes the 5-day/3-hour forecast (`/data/2.5/forecast`). The newer One Call 3.0 API (hourly + daily + minutely) requires a paid subscription — gated separately in the tool descriptions.\n\n**Rate limits**: 60 calls/min on free tier; tighter limits on burst. On 429, exponential backoff. Errors come back as `{cod, message}` with HTTP 401 for bad key, 404 for unknown city, 429 for rate-limit.\n\n**Out of scope here**: historical data (paid), weather maps tiles (different host, image returns), severe weather alerts (paid).", + "region": "intl", + "category": "data", + "icon": "openweather", + "docsUrl": "https://openweathermap.org/api", + "requiredEnvVars": ["OPENWEATHER_API_KEY"], + "connector": { + "name": "OpenWeather API", + "type": "REST", + "baseUrl": "https://api.openweathermap.org", + "authType": "QUERY_AUTH", + "authConfig": { + "appid": "{{OPENWEATHER_API_KEY}}" + } + }, + "tools": [ + { + "name": "openweather_geocode_city", + "description": "Translate a city name (optionally with state and country code) into latitude/longitude. Always run this first when the user asks about a city — every other tool wants lat/lon, not city names. Returns up to `limit` candidates; the first is usually the most populous.", + "parameters": { + "type": "object", + "properties": { + "q": { "type": "string", "description": "Location query, e.g. 'Rome,IT' or 'Springfield,IL,US' (city,state,country)." }, + "limit": { "type": "integer", "description": "Max results (1-5). Default 1." } + }, + "required": ["q"] + }, + "endpointMapping": { + "method": "GET", + "path": "/geo/1.0/direct", + "queryParams": { "q": "$q", "limit": "$limit" } + } + }, + { + "name": "openweather_reverse_geocode", + "description": "Translate latitude/longitude back into a list of nearby place names. Useful when you have GPS coordinates from another tool and need a human-readable label.", + "parameters": { + "type": "object", + "properties": { + "lat": { "type": "number", "description": "Latitude." }, + "lon": { "type": "number", "description": "Longitude." }, + "limit": { "type": "integer", "description": "Max results (1-5). Default 1." } + }, + "required": ["lat", "lon"] + }, + "endpointMapping": { + "method": "GET", + "path": "/geo/1.0/reverse", + "queryParams": { "lat": "$lat", "lon": "$lon", "limit": "$limit" } + } + }, + { + "name": "openweather_current_weather", + "description": "Current weather at given coordinates: temperature, humidity, wind, conditions, sunrise/sunset. Snapshot only — for forecasts use openweather_forecast_5day.", + "parameters": { + "type": "object", + "properties": { + "lat": { "type": "number", "description": "Latitude." }, + "lon": { "type": "number", "description": "Longitude." }, + "units": { "type": "string", "description": "'metric' (°C, m/s), 'imperial' (°F, mph), or 'standard' (K, m/s)." }, + "lang": { "type": "string", "description": "ISO 639-1 code for localized descriptions (it, de, en, es, ...)." } + }, + "required": ["lat", "lon"] + }, + "endpointMapping": { + "method": "GET", + "path": "/data/2.5/weather", + "queryParams": { "lat": "$lat", "lon": "$lon", "units": "$units", "lang": "$lang" } + } + }, + { + "name": "openweather_forecast_5day", + "description": "5-day weather forecast at 3-hour intervals. Returns a `list` of 40 entries; each has dt, temperature, weather[], wind, pop (precipitation probability). Use this for 'will it rain tomorrow' style questions on the free tier.", + "parameters": { + "type": "object", + "properties": { + "lat": { "type": "number", "description": "Latitude." }, + "lon": { "type": "number", "description": "Longitude." }, + "units": { "type": "string", "description": "'metric' / 'imperial' / 'standard'." }, + "lang": { "type": "string", "description": "Locale for descriptions." }, + "cnt": { "type": "integer", "description": "Limit number of 3-hour timestamps returned (1-40)." } + }, + "required": ["lat", "lon"] + }, + "endpointMapping": { + "method": "GET", + "path": "/data/2.5/forecast", + "queryParams": { "lat": "$lat", "lon": "$lon", "units": "$units", "lang": "$lang", "cnt": "$cnt" } + } + }, + { + "name": "openweather_air_pollution", + "description": "Current air-quality index (AQI 1-5) + concentrations of CO, NO, NO2, O3, SO2, PM2.5, PM10, NH3 at given coordinates.", + "parameters": { + "type": "object", + "properties": { + "lat": { "type": "number", "description": "Latitude." }, + "lon": { "type": "number", "description": "Longitude." } + }, + "required": ["lat", "lon"] + }, + "endpointMapping": { + "method": "GET", + "path": "/data/2.5/air_pollution", + "queryParams": { "lat": "$lat", "lon": "$lon" } + } + }, + { + "name": "openweather_air_pollution_forecast", + "description": "Hourly air-pollution forecast for the next ~4 days at given coordinates.", + "parameters": { + "type": "object", + "properties": { + "lat": { "type": "number", "description": "Latitude." }, + "lon": { "type": "number", "description": "Longitude." } + }, + "required": ["lat", "lon"] + }, + "endpointMapping": { + "method": "GET", + "path": "/data/2.5/air_pollution/forecast", + "queryParams": { "lat": "$lat", "lon": "$lon" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/openweather.live.spec.ts b/packages/backend/src/adapters/intl/openweather.live.spec.ts new file mode 100644 index 0000000..260e574 --- /dev/null +++ b/packages/backend/src/adapters/intl/openweather.live.spec.ts @@ -0,0 +1,19 @@ +import * as adapter from './openweather.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ name: string; endpointMapping: { path: string } }>; +}; +describe('openweather adapter — static spec conformance', () => { + it('api.openweathermap.org base URL', () => + expect(a.connector.baseUrl).toBe('https://api.openweathermap.org')); + it('QUERY_AUTH on appid', () => { + expect(a.connector.authType).toBe('QUERY_AUTH'); + expect(Object.keys(a.connector.authConfig)).toContain('appid'); + }); + it('exposes the geocode tool first in the list', () => + expect(a.tools[0].name).toBe('openweather_geocode_city')); + it('weather endpoints use /data/2.5/', () => { + const weather = a.tools.filter((t) => t.endpointMapping.path.startsWith('/data/2.5/')); + expect(weather.length).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/packages/backend/src/adapters/intl/toggl-track.json b/packages/backend/src/adapters/intl/toggl-track.json new file mode 100644 index 0000000..a67181f --- /dev/null +++ b/packages/backend/src/adapters/intl/toggl-track.json @@ -0,0 +1,175 @@ +{ + "slug": "toggl-track", + "name": "Toggl Track", + "description": "Manage Toggl Track (time entries, projects, clients, tags, workspaces) from any AI agent. 9 tools, basic-auth with API token.", + "instructions": "This connector wraps the Toggl Track API v9 (api.track.toggl.com/api/v9).\n\n**Setup**:\n1. Log into https://track.toggl.com → bottom-left avatar → **Profile settings** → scroll to **API Token** → reveal/copy.\n2. Set `TOGGL_API_TOKEN`.\n\n**Authentication**: HTTP Basic, with API token as the username and the literal string `api_token` as the password:\n `Authorization: Basic base64(TOKEN:api_token)`\nThe adapter sets this automatically via BASIC_AUTH (username=token, password=api_token). DO NOT pass real user/password — Toggl only accepts the token-pair.\n\n**Workspace IDs**: Toggl Track is workspace-scoped. Most endpoints need a `workspace_id`. Use `toggl_track_me` first — its response contains `default_workspace_id` and `workspaces[].id`.\n\n**Running timer**: Toggl allows exactly ONE running entry per user at a time. Starting a new one stops any prior. To start, POST a time entry with `duration: -1` and `start: `. To stop, PATCH the entry to set `stop: ` and `duration: `.\n\n**Dates**: ISO 8601 with timezone (`2026-05-20T14:30:00+00:00`). Toggl is strict about timezone — naive datetimes get rejected.\n\n**Rate limits**: 1 req/s per IP soft, burst tolerated. 429 with `Retry-After` for hard throttling.\n\n**Out of scope here**: Toggl Reports v3 (different host, paid plans get more — could be a sibling adapter), team management, billing.", + "region": "intl", + "category": "time-tracking", + "icon": "toggl-track", + "docsUrl": "https://engineering.toggl.com/docs/", + "requiredEnvVars": ["TOGGL_API_TOKEN"], + "connector": { + "name": "Toggl Track API v9", + "type": "REST", + "baseUrl": "https://api.track.toggl.com/api/v9", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{TOGGL_API_TOKEN}}", + "password": "api_token" + } + }, + "tools": [ + { + "name": "toggl_track_me", + "description": "Get the authenticated user including default workspace ID, all workspaces, default project/tag. Use this first to find the workspace ID needed by other tools.", + "parameters": { + "type": "object", + "properties": { + "with_related_data": { "type": "boolean", "description": "If true, include projects/clients/tags inline (heavier response)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/me", + "queryParams": { "with_related_data": "$with_related_data" } + } + }, + { + "name": "toggl_track_list_workspaces", + "description": "List workspaces the user belongs to.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/me/workspaces" } + }, + { + "name": "toggl_track_list_clients", + "description": "List clients in a workspace.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "integer", "description": "Workspace ID." } + }, + "required": ["workspace_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_id}/clients" + } + }, + { + "name": "toggl_track_list_projects", + "description": "List projects in a workspace. Returns id, name, client_id, color, billable, active, estimated_hours, hourly_rate.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "integer", "description": "Workspace ID." }, + "active": { "type": "boolean", "description": "Filter active." }, + "billable": { "type": "boolean", "description": "Filter billable." }, + "name": { "type": "string", "description": "Filter by name substring." } + }, + "required": ["workspace_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_id}/projects", + "queryParams": { + "active": "$active", + "billable": "$billable", + "name": "$name" + } + } + }, + { + "name": "toggl_track_get_current_entry", + "description": "Return the currently running time entry (if any), else null.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/me/time_entries/current" } + }, + { + "name": "toggl_track_list_time_entries", + "description": "List time entries for the current user. Filter by start/end date window. Returns each entry with id, description, project_id, billable, duration (seconds, negative for running), start/stop.", + "parameters": { + "type": "object", + "properties": { + "start_date": { "type": "string", "description": "ISO 8601 datetime or date." }, + "end_date": { "type": "string", "description": "ISO 8601 datetime or date." }, + "before": { "type": "string", "description": "ISO 8601: only entries with stop_time before this." }, + "since": { "type": "integer", "description": "UNIX timestamp; only entries modified after." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/me/time_entries", + "queryParams": { + "start_date": "$start_date", + "end_date": "$end_date", + "before": "$before", + "since": "$since" + } + } + }, + { + "name": "toggl_track_create_time_entry", + "description": "Create a new time entry. Pass `duration: -1` + `start` (ISO 8601) to start a running timer; pass positive duration (seconds) + start to log a completed entry.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "integer", "description": "Workspace ID." }, + "description": { "type": "string", "description": "What you're working on." }, + "project_id": { "type": "integer", "description": "Project ID (optional)." }, + "tags": { "type": "array", "description": "Array of tag name strings." }, + "start": { "type": "string", "description": "ISO 8601 datetime with timezone." }, + "duration": { "type": "integer", "description": "Seconds. -1 to start a running timer." }, + "billable": { "type": "boolean", "description": "Mark billable." }, + "created_with": { "type": "string", "description": "Client identifier (defaults to 'AnythingMCP')." } + }, + "required": ["workspace_id", "start", "duration"] + }, + "endpointMapping": { + "method": "POST", + "path": "/workspaces/{workspace_id}/time_entries", + "bodyMapping": { + "workspace_id": "$workspace_id", + "description": "$description", + "project_id": "$project_id", + "tags": "$tags", + "start": "$start", + "duration": "$duration", + "billable": "$billable", + "created_with": "$created_with" + } + } + }, + { + "name": "toggl_track_stop_time_entry", + "description": "Stop a running time entry. Sets stop=now and converts duration to positive seconds.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "integer", "description": "Workspace ID." }, + "time_entry_id": { "type": "integer", "description": "Time entry ID to stop." } + }, + "required": ["workspace_id", "time_entry_id"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/workspaces/{workspace_id}/time_entries/{time_entry_id}/stop" + } + }, + { + "name": "toggl_track_delete_time_entry", + "description": "Delete a time entry permanently.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "integer", "description": "Workspace ID." }, + "time_entry_id": { "type": "integer", "description": "Time entry ID." } + }, + "required": ["workspace_id", "time_entry_id"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/workspaces/{workspace_id}/time_entries/{time_entry_id}" + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/toggl-track.live.spec.ts b/packages/backend/src/adapters/intl/toggl-track.live.spec.ts new file mode 100644 index 0000000..8feab4a --- /dev/null +++ b/packages/backend/src/adapters/intl/toggl-track.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './toggl-track.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { password: string } }; +}; +describe('toggl-track adapter — static spec conformance', () => { + it('api.track.toggl.com/api/v9 base URL', () => + expect(a.connector.baseUrl).toBe('https://api.track.toggl.com/api/v9')); + it('Basic auth with literal password "api_token"', () => { + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig.password).toBe('api_token'); + }); +}); diff --git a/packages/backend/src/adapters/intl/uptime-robot.json b/packages/backend/src/adapters/intl/uptime-robot.json new file mode 100644 index 0000000..de5c187 --- /dev/null +++ b/packages/backend/src/adapters/intl/uptime-robot.json @@ -0,0 +1,194 @@ +{ + "slug": "uptime-robot", + "name": "UptimeRobot", + "description": "Manage UptimeRobot (uptime monitoring) monitors, alert contacts, maintenance windows, public status pages from any AI agent. 8 tools, API-key auth.", + "instructions": "This connector wraps the UptimeRobot API v2 (api.uptimerobot.com/v2).\n\n**Setup**:\n1. Sign in to https://uptimerobot.com → **My Settings → API Settings**.\n2. Generate the **Main API Key** (full access to your monitors) OR a per-monitor read-only key. Most users want the Main key.\n3. Set `UPTIMEROBOT_API_KEY`.\n\n**Authentication**: every request POSTs to /v2/* with `api_key=...` injected as a query parameter on the URL. The API accepts the key either in the URL or in the body — this adapter uses the URL so each tool's body remains clean form-encoded params. We also always send `format=json` so responses are JSON instead of XML.\n\n**Monitor types** (integer codes):\n- 1: HTTP(s)\n- 2: Keyword (HTTP body must/must-not contain string)\n- 3: Ping (ICMP)\n- 4: Port (TCP)\n- 5: Heartbeat (cron-style — your job pings UptimeRobot every N minutes)\n\n**Status codes** (integer):\n- 0: paused\n- 1: not checked yet\n- 2: up\n- 8: seems down\n- 9: down\n\n**Pagination**: `offset` (0-based) + `limit` (max 50). Total returned in the `pagination.total` field.\n\n**Rate limits**: 10 req/min per API key on the free plan; higher on paid. 429 has no Retry-After — back off 6s.\n\n**Out of scope here**: subscription management, agent / probe location selection (paid feature), white-label status page customization.", + "region": "intl", + "category": "monitoring", + "icon": "uptime-robot", + "docsUrl": "https://uptimerobot.com/api/", + "requiredEnvVars": ["UPTIMEROBOT_API_KEY"], + "connector": { + "name": "UptimeRobot API v2", + "type": "REST", + "baseUrl": "https://api.uptimerobot.com/v2", + "authType": "QUERY_AUTH", + "authConfig": { + "api_key": "{{UPTIMEROBOT_API_KEY}}", + "format": "json" + } + }, + "tools": [ + { + "name": "uptimerobot_get_account_details", + "description": "Returns account info: email, monitor_limit, monitor_interval, up_monitors, down_monitors, paused_monitors.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "POST", "path": "/getAccountDetails" } + }, + { + "name": "uptimerobot_get_monitors", + "description": "List monitors. Optional filters by id, status, search string. Use `logs=1` to include up/down event history; `response_times=1` for response-time series; both add latency.", + "parameters": { + "type": "object", + "properties": { + "monitors": { "type": "string", "description": "Comma-separated monitor IDs to fetch (default: all)." }, + "types": { "type": "string", "description": "Comma-separated type codes (1-5)." }, + "statuses": { "type": "string", "description": "Comma-separated status codes (0,1,2,8,9)." }, + "search": { "type": "string", "description": "Substring filter against friendly_name or url." }, + "logs": { "type": "integer", "description": "1 to include event log; 0 to skip." }, + "response_times": { "type": "integer", "description": "1 to include response time series." }, + "offset": { "type": "integer", "description": "Pagination offset (0-based)." }, + "limit": { "type": "integer", "description": "Page size (max 50)." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/getMonitors", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "monitors": "$monitors", + "types": "$types", + "statuses": "$statuses", + "search": "$search", + "logs": "$logs", + "response_times": "$response_times", + "offset": "$offset", + "limit": "$limit" + } + } + }, + { + "name": "uptimerobot_new_monitor", + "description": "Create a new monitor. `type` is required (1=HTTP, 2=Keyword, 3=Ping, 4=Port, 5=Heartbeat). For type=2 you must also supply `keyword_type` (1=exists, 2=not exists) + `keyword_value`.", + "parameters": { + "type": "object", + "properties": { + "friendly_name": { "type": "string", "description": "Display name." }, + "url": { "type": "string", "description": "URL or IP to monitor." }, + "type": { "type": "integer", "description": "1=HTTP, 2=Keyword, 3=Ping, 4=Port, 5=Heartbeat." }, + "interval": { "type": "integer", "description": "Check interval in seconds (60-86400). Free plan minimum 300." }, + "timeout": { "type": "integer", "description": "Timeout in seconds." }, + "port": { "type": "integer", "description": "Port number (required if type=4)." }, + "keyword_type": { "type": "integer", "description": "1=exists, 2=not exists (required if type=2)." }, + "keyword_value": { "type": "string", "description": "Keyword to look for (required if type=2)." }, + "alert_contacts": { "type": "string", "description": "Format: '__-_..'." } + }, + "required": ["friendly_name", "url", "type"] + }, + "endpointMapping": { + "method": "POST", + "path": "/newMonitor", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "friendly_name": "$friendly_name", + "url": "$url", + "type": "$type", + "interval": "$interval", + "timeout": "$timeout", + "port": "$port", + "keyword_type": "$keyword_type", + "keyword_value": "$keyword_value", + "alert_contacts": "$alert_contacts" + } + } + }, + { + "name": "uptimerobot_edit_monitor", + "description": "Edit an existing monitor. Pass `id` + any field you want to update.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "Monitor ID." }, + "friendly_name": { "type": "string", "description": "New display name." }, + "url": { "type": "string", "description": "New URL." }, + "interval": { "type": "integer", "description": "New check interval." }, + "status": { "type": "integer", "description": "0 to pause, 1 to resume." } + }, + "required": ["id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/editMonitor", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "id": "$id", + "friendly_name": "$friendly_name", + "url": "$url", + "interval": "$interval", + "status": "$status" + } + } + }, + { + "name": "uptimerobot_delete_monitor", + "description": "Permanently delete a monitor. Irreversible.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "Monitor ID." } + }, + "required": ["id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/deleteMonitor", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "id": "$id" } + } + }, + { + "name": "uptimerobot_get_alert_contacts", + "description": "List configured alert contacts (email, webhook, Slack, Telegram, SMS).", + "parameters": { + "type": "object", + "properties": { + "alert_contacts": { "type": "string", "description": "Comma-separated IDs (default: all)." }, + "offset": { "type": "integer", "description": "Pagination offset." }, + "limit": { "type": "integer", "description": "Page size." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/getAlertContacts", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "alert_contacts": "$alert_contacts", + "offset": "$offset", + "limit": "$limit" + } + } + }, + { + "name": "uptimerobot_get_mwindows", + "description": "List maintenance windows.", + "parameters": { + "type": "object", + "properties": { + "mwindows": { "type": "string", "description": "Comma-separated maintenance window IDs (default: all)." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/getMWindows", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "mwindows": "$mwindows" } + } + }, + { + "name": "uptimerobot_get_psps", + "description": "List public status pages (PSPs) configured for this account.", + "parameters": { + "type": "object", + "properties": { + "psps": { "type": "string", "description": "Comma-separated PSP IDs (default: all)." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/getPSPs", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "psps": "$psps" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/uptime-robot.live.spec.ts b/packages/backend/src/adapters/intl/uptime-robot.live.spec.ts new file mode 100644 index 0000000..b2df437 --- /dev/null +++ b/packages/backend/src/adapters/intl/uptime-robot.live.spec.ts @@ -0,0 +1,17 @@ +import * as adapter from './uptime-robot.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ endpointMapping: { method: string } }>; +}; +describe('uptime-robot adapter — static spec conformance', () => { + it('api.uptimerobot.com/v2 base URL', () => + expect(a.connector.baseUrl).toBe('https://api.uptimerobot.com/v2')); + it('QUERY_AUTH injects api_key + format=json on every request', () => { + expect(a.connector.authType).toBe('QUERY_AUTH'); + expect(Object.keys(a.connector.authConfig)).toContain('api_key'); + expect(a.connector.authConfig.format).toBe('json'); + }); + it('every tool POSTs', () => { + for (const t of a.tools) expect(t.endpointMapping.method).toBe('POST'); + }); +});