diff --git a/.env.example b/.env.example index d874559e9..8da63f2b3 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,16 @@ NEXTCLOUD_CONNECTOR_PORT=4014 PAPERLESS_CONNECTOR_PORT=4015 GOOGLE_ADS_CONNECTOR_PORT=4016 DARWINBOX_CONNECTOR_PORT=4017 +WINDSHIFT_CONNECTOR_PORT=4018 + +# Public URL of the Windshift instance this Omni install talks to. +# Reachable from the user's browser for OAuth consent. One Omni install = one +# Windshift instance in v1. Set WINDSHIFT_INTERNAL_BASE_URL below when the +# connector container needs a different route to that instance. +WINDSHIFT_BASE_URL=https://windshift.example.com +# Optional connector-only URL for local/private networking. OAuth URLs and +# token audience still use WINDSHIFT_BASE_URL. +WINDSHIFT_INTERNAL_BASE_URL= # Sandbox Port SANDBOX_PORT=8090 @@ -92,7 +102,7 @@ DOCLING_MEMORY=2g # # Enable connectors you want to run by adding their profile to ENABLED_CONNECTORS (comma-separated). # Available connector names: -# google, google_ads, slack, atlassian, web, github, notion, hubspot, fireflies, microsoft, filesystem, imap, linear, clickup, nextcloud, paperless, darwinbox +# google, google_ads, slack, atlassian, web, github, notion, hubspot, fireflies, microsoft, filesystem, imap, linear, clickup, nextcloud, paperless, darwinbox, windshift # # Example: ENABLED_CONNECTORS=google,slack # diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3c3cc7ae..1185af1ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,7 @@ jobs: linear-connector: ${{ steps.filter.outputs.linear-connector }} nextcloud-connector: ${{ steps.filter.outputs.nextcloud-connector }} paperless-connector: ${{ steps.filter.outputs.paperless-connector }} + windshift-connector: ${{ steps.filter.outputs.windshift-connector }} docling: ${{ steps.filter.outputs.docling }} deployment: ${{ steps.filter.outputs.deployment }} cli: ${{ steps.filter.outputs.cli }} @@ -220,6 +221,11 @@ jobs: - 'sdk/python/**' - '.github/workflows/ci.yml' - '.github/workflows/build-connector.yml' + windshift-connector: + - 'connectors/windshift/**' + - 'sdk/typescript/**' + - '.github/workflows/ci.yml' + - '.github/workflows/build-connector.yml' deployment: - 'docker/docker-compose.yml' - 'docker/docker-compose.gpu.yml' @@ -533,6 +539,15 @@ jobs: connector-type: typescript secrets: inherit + build-windshift-connector: + needs: detect-changes + if: needs.detect-changes.outputs.is-tag != 'true' && needs.detect-changes.outputs.windshift-connector == 'true' + uses: ./.github/workflows/build-connector.yml + with: + connector-name: windshift + connector-type: typescript + secrets: inherit + # --------------------------------------------------------------------------- # Release: build all images from scratch on tag push # --------------------------------------------------------------------------- @@ -633,6 +648,8 @@ jobs: connector-type: python - connector-name: linear connector-type: typescript + - connector-name: windshift + connector-type: typescript uses: ./.github/workflows/build-connector.yml with: connector-name: ${{ matrix.connector-name }} diff --git a/connectors/atlassian/src/connector.rs b/connectors/atlassian/src/connector.rs index 9f1a6d2cc..372a05f75 100644 --- a/connectors/atlassian/src/connector.rs +++ b/connectors/atlassian/src/connector.rs @@ -75,6 +75,7 @@ impl Connector for AtlassianConnector { "required": ["type"] }), mode: ActionMode::Read, + required_scopes: None, source_types: Vec::new(), admin_only: true, hidden: false, diff --git a/connectors/darwinbox/src/actions.rs b/connectors/darwinbox/src/actions.rs index 95f615fd5..23156e333 100644 --- a/connectors/darwinbox/src/actions.rs +++ b/connectors/darwinbox/src/actions.rs @@ -1280,6 +1280,7 @@ fn action( description: description.to_string(), input_schema, mode, + required_scopes: None, source_types: source_types.to_vec(), admin_only, hidden: false, diff --git a/connectors/filesystem/src/connector.rs b/connectors/filesystem/src/connector.rs index 9a5142f9f..0bb958b1f 100644 --- a/connectors/filesystem/src/connector.rs +++ b/connectors/filesystem/src/connector.rs @@ -64,6 +64,7 @@ impl Connector for FileSystemConnector { }, "required": ["base_path"] }), + required_scopes: None, source_types: Vec::new(), admin_only: false, hidden: false, diff --git a/connectors/google/src/connector.rs b/connectors/google/src/connector.rs index f560739c9..640653a96 100644 --- a/connectors/google/src/connector.rs +++ b/connectors/google/src/connector.rs @@ -862,6 +862,7 @@ impl Connector for GoogleConnector { }, "required": ["file_id"] }), + required_scopes: None, source_types: vec![SourceType::GoogleDrive, SourceType::Gmail], admin_only: false, hidden: false, @@ -879,6 +880,7 @@ impl Connector for GoogleConnector { }, "required": [] }), + required_scopes: None, source_types: vec![SourceType::GoogleDrive, SourceType::GoogleChat], admin_only: true, hidden: false, @@ -903,6 +905,7 @@ impl Connector for GoogleConnector { }, "required": ["schema"] }), + required_scopes: None, source_types: vec![SourceType::GoogleDrive, SourceType::Gmail], admin_only: false, hidden: false, @@ -955,6 +958,7 @@ impl Connector for GoogleConnector { }, "required": ["service", "resource", "method"] }), + required_scopes: None, source_types: vec![SourceType::GoogleDrive, SourceType::Gmail], admin_only: false, hidden: false, diff --git a/connectors/imap/src/connector.rs b/connectors/imap/src/connector.rs index 9d7c5a80c..7e599b73c 100644 --- a/connectors/imap/src/connector.rs +++ b/connectors/imap/src/connector.rs @@ -95,6 +95,7 @@ impl Connector for ImapConnector { // Setup/util action with no chat-facing use: hidden from every // chat/MCP tool surface, admins included, but still in the // manifest (Read mode) and dispatchable by name. + required_scopes: None, source_types: Vec::new(), admin_only: false, hidden: true, @@ -115,6 +116,7 @@ impl Connector for ImapConnector { // Setup/util action with no chat-facing use: hidden from every // chat/MCP tool surface, admins included, but still in the // manifest (Read mode) and dispatchable by name. + required_scopes: None, source_types: Vec::new(), admin_only: false, hidden: true, diff --git a/connectors/nextcloud/src/connector.rs b/connectors/nextcloud/src/connector.rs index ae0b5d896..93b0b5a74 100644 --- a/connectors/nextcloud/src/connector.rs +++ b/connectors/nextcloud/src/connector.rs @@ -120,6 +120,7 @@ impl Connector for NextcloudConnector { description: "Verify that the provided Nextcloud credentials are valid".into(), input_schema: json!({}), mode: omni_connector_sdk::ActionMode::Read, + required_scopes: None, source_types: Vec::new(), admin_only: false, hidden: false, @@ -138,6 +139,7 @@ impl Connector for NextcloudConnector { "required": ["file_id"] }), mode: omni_connector_sdk::ActionMode::Read, + required_scopes: None, source_types: Vec::new(), // Internal action with no chat-facing use: hidden from every // chat/MCP tool surface, admins included, but still in the diff --git a/connectors/windshift/Dockerfile b/connectors/windshift/Dockerfile new file mode 100644 index 000000000..c17e6c31c --- /dev/null +++ b/connectors/windshift/Dockerfile @@ -0,0 +1,17 @@ +FROM node:24-slim AS builder +WORKDIR /build + +COPY sdk/typescript/ /sdk/typescript/ +RUN cd /sdk/typescript && npm ci && npm run build + +COPY connectors/windshift/package.json connectors/windshift/package-lock.json connectors/windshift/tsconfig.json /build/ +RUN npm ci --install-links +COPY connectors/windshift/src/ /build/src/ +RUN npm run build + +FROM node:24-slim +WORKDIR /app +COPY --from=builder /build/dist /app/dist +COPY --from=builder /build/node_modules /app/node_modules +ENV NODE_ENV=production +CMD ["node", "dist/main.js"] diff --git a/connectors/windshift/README.md b/connectors/windshift/README.md new file mode 100644 index 000000000..98977b0fd --- /dev/null +++ b/connectors/windshift/README.md @@ -0,0 +1,46 @@ +# Windshift Connector + +Indexes Windshift work items, descriptions, and comments into Omni and exposes +Windshift's MCP tools as Omni actions. + +## Configuration + +Set `WINDSHIFT_BASE_URL` to the externally reachable Windshift base URL. If +Windshift uses a context path, include it in the URL. Enable Windshift's MCP +server with `MCP_ENABLED=true`. + +For local or private networking, `WINDSHIFT_INTERNAL_BASE_URL` may point the +connector container at the same Windshift instance through a different route. +Browser authorization, resource binding, and document links still use +`WINDSHIFT_BASE_URL`; server-side client registration, token exchange, user-info, +sync, and MCP requests use the internal route. + +No OAuth client ID or secret is configured manually. Omni dynamically registers +as a public client, uses S256 PKCE, and requests tokens bound to +`${WINDSHIFT_BASE_URL}/mcp`. Windshift 0.8.4 or newer is required. + +Each user connects Windshift from **My Integrations**. The initial authorization +grants read access for that user's sync and read-only MCP tools. Write and +destructive tools request expanded authorization when first used. Access tokens +are refreshed automatically; rotated refresh tokens are persisted under the same +per-credential database lock. + +## Data model + +| Windshift | Omni document | +| -------------------------- | ------------------------------------------ | +| Item ID | `external_id = windshift:item:` | +| Title | Document title | +| Description and comments | Markdown content | +| Workspace | `attributes.workspace` | +| Status | `attributes.status` | +| Priority | `attributes.priority` | +| Assignee | `attributes.assignee` and `assignee_email` | +| Created/updated timestamps | Document metadata | + +Full sync walks visible workspaces and items while capturing each workspace's +change watermark. Incremental sync then reads Windshift's ordered item change +log, including comment activity and deletions. Optional `workspace_keys` +restricts sync to specific Windshift workspaces. Windshift is a personal source +in Omni: every user has an independent sync backed by their own OAuth +credential, and indexed items are visible only to that Omni user. diff --git a/connectors/windshift/package-lock.json b/connectors/windshift/package-lock.json new file mode 100644 index 000000000..82fb0188d --- /dev/null +++ b/connectors/windshift/package-lock.json @@ -0,0 +1,1792 @@ +{ + "name": "@getomnico/windshift-connector", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@getomnico/windshift-connector", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "@getomnico/connector": "file:../../sdk/typescript", + "@modelcontextprotocol/sdk": "^1.29.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=24.0.0" + } + }, + "node_modules/@getomnico/connector": { + "version": "0.1.0", + "resolved": "file:../../sdk/typescript", + "license": "Apache-2.0", + "dependencies": { + "express": "^4.21.0", + "pino": "^10.3.1", + "zod": "^3.23.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-rate-limit/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/connectors/windshift/package.json b/connectors/windshift/package.json new file mode 100644 index 000000000..5fe633e6c --- /dev/null +++ b/connectors/windshift/package.json @@ -0,0 +1,27 @@ +{ + "name": "@getomnico/windshift-connector", + "version": "1.0.0", + "description": "Windshift connector for Omni", + "type": "module", + "main": "./dist/main.js", + "scripts": { + "build": "tsc", + "test": "npm run build && NODE_ENV=production node --test dist/*.test.js", + "start": "node dist/main.js", + "dev": "tsc --watch", + "lint": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "engines": { + "node": ">=24.0.0" + }, + "dependencies": { + "@getomnico/connector": "file:../../sdk/typescript", + "@modelcontextprotocol/sdk": "^1.29.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + }, + "license": "Apache-2.0" +} diff --git a/connectors/windshift/src/client.test.ts b/connectors/windshift/src/client.test.ts new file mode 100644 index 000000000..a980a4aa0 --- /dev/null +++ b/connectors/windshift/src/client.test.ts @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { WindshiftApiClient } from "./client.js"; + +test("item pagination uses REST v1 and normalizes nested fields", async () => { + const originalFetch = globalThis.fetch; + const requestedPages: string[] = []; + const requestedPaths: string[] = []; + + globalThis.fetch = async (input, init) => { + const url = new URL(String(input)); + const page = url.searchParams.get("page") ?? "1"; + requestedPages.push(page); + requestedPaths.push(url.pathname); + assert.equal( + init?.headers && (init.headers as Record).Authorization, + "Bearer token", + ); + assert.equal(url.searchParams.get("sort"), "key"); + assert.equal(url.searchParams.get("order"), "asc"); + assert.equal(url.searchParams.get("workspace_id"), "1"); + return new Response( + JSON.stringify({ + data: [ + { + id: Number(page), + workspace_id: 1, + workspace_key: "ENG", + workspace_item_number: Number(page), + title: `Item ${page}`, + status: { id: 2, name: "In Progress" }, + workspace: { id: 1, key: "ENG", name: "Engineering" }, + milestones: [{ id: 3, name: "0.8.3" }], + created_at: "2026-07-21T12:00:00Z", + updated_at: "2026-07-21T12:00:00Z", + }, + ], + pagination: { + page: Number(page), + limit: 100, + total: 2, + total_pages: 2, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }; + + try { + const client = new WindshiftApiClient("https://windshift.example", "token"); + const itemIds: number[] = []; + for await (const item of client.fetchItems(1)) { + itemIds.push(item.id); + } + + assert.deepEqual(itemIds, [1, 2]); + assert.deepEqual(requestedPages, ["1", "2"]); + assert.deepEqual(requestedPaths, [ + "/rest/api/v1/items", + "/rest/api/v1/items", + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("workspace pagination and comments use REST v1 response shapes", async () => { + const originalFetch = globalThis.fetch; + const paths: string[] = []; + + globalThis.fetch = async (input) => { + const url = new URL(String(input)); + paths.push(url.pathname); + if (url.pathname.endsWith("/workspaces")) { + return Response.json({ + data: [{ id: 1, key: "ENG", name: "Engineering" }], + pagination: { + page: 1, + limit: 100, + total: 1, + total_pages: 1, + has_more: false, + }, + }); + } + assert.equal(url.searchParams.get("page"), "1"); + assert.equal(url.searchParams.get("limit"), "50"); + assert.equal(url.searchParams.get("order"), "desc"); + return Response.json({ + data: [ + { + id: 9, + item_id: 7, + content: "OAuth now works", + author: { id: 2, full_name: "Ada Lovelace" }, + created_at: "2026-07-21T12:00:00Z", + updated_at: "2026-07-21T12:00:00Z", + }, + ], + pagination: { + page: 1, + limit: 50, + total: 1, + total_pages: 1, + has_more: false, + }, + }); + }; + + try { + const client = new WindshiftApiClient("https://windshift.example/", "token"); + assert.deepEqual(await client.fetchWorkspaces(), [ + { id: 1, key: "ENG", name: "Engineering" }, + ]); + assert.deepEqual(await client.fetchItemComments(7), [ + { + id: 9, + item_id: 7, + user_id: 2, + user_name: "Ada Lovelace", + body: "OAuth now works", + created_at: "2026-07-21T12:00:00Z", + updated_at: "2026-07-21T12:00:00Z", + }, + ]); + assert.deepEqual(paths, [ + "/rest/api/v1/workspaces", + "/rest/api/v1/items/7/comments", + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("item changes use string cursors and batch-fetch changed items", async () => { + const originalFetch = globalThis.fetch; + const requestedPaths: string[] = []; + globalThis.fetch = async (input) => { + const url = new URL(String(input)); + requestedPaths.push(`${url.pathname}?${url.searchParams}`); + if (url.pathname.endsWith("/items/changes")) { + assert.equal(url.searchParams.get("workspace_id"), "1"); + assert.equal(url.searchParams.get("since"), "10"); + assert.equal(url.searchParams.get("through"), "13"); + assert.equal(url.searchParams.get("limit"), "500"); + return Response.json({ + changes: [ + { item_id: 7, change_type: "upsert" }, + { item_id: 8, change_type: "delete" }, + ], + next_cursor: "13", + watermark: "13", + has_more: false, + reset_required: false, + }); + } + assert.equal(url.pathname.endsWith("/items/batch"), true); + assert.equal(url.searchParams.get("ids"), "7,9"); + return Response.json([ + { + id: 7, + workspace_id: 1, + workspace_key: "ENG", + workspace_item_number: 7, + title: "Changed item", + created_at: "2026-07-21T12:00:00Z", + updated_at: "2026-07-21T12:00:00Z", + }, + ]); + }; + + try { + const client = new WindshiftApiClient("https://windshift.example", "token"); + const changes = await client.fetchItemChanges(1, "10", "13"); + assert.deepEqual(changes.changes, [ + { item_id: 7, change_type: "upsert" }, + { item_id: 8, change_type: "delete" }, + ]); + assert.equal(changes.next_cursor, "13"); + + const items = await client.fetchItemsByIds([7, 9]); + assert.deepEqual( + items.map((item) => item.id), + [7], + ); + assert.equal(requestedPaths.length, 2); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/connectors/windshift/src/client.ts b/connectors/windshift/src/client.ts new file mode 100644 index 000000000..376fc2702 --- /dev/null +++ b/connectors/windshift/src/client.ts @@ -0,0 +1,244 @@ +import type { + WindshiftItem, + WindshiftPaginatedResponse, + WindshiftWorkspace, + WindshiftComment, + WindshiftItemChangesResponse, +} from "./types.js"; + +const PAGE_SIZE = 100; +const CHANGE_PAGE_SIZE = 500; +const MAX_COMMENTS_PER_ITEM = 50; + +function joinUrl(baseUrl: string, path: string): string { + const trimmed = baseUrl.replace(/\/+$/, ""); + return `${trimmed}/rest/api/v1${path}`; +} + +type WindshiftUserResponse = { + id: number; + email?: string; + username?: string; + full_name?: string; +}; + +type WindshiftItemResponse = { + id: number; + workspace_id: number; + workspace_key?: string; + key?: string; + workspace_item_number?: number; + title: string; + description?: string | null; + status?: { id: number; name: string } | null; + priority?: { id: number; name: string } | null; + assignee?: WindshiftUserResponse | null; + creator?: WindshiftUserResponse | null; + workspace?: { id: number; name: string; key: string } | null; + milestones?: Array<{ id: number; name: string }>; + iteration?: { id: number; name: string } | null; + created_at: string; + updated_at: string; + completed_at?: string | null; +}; + +type WindshiftCommentResponse = { + id: number; + item_id: number; + content: string; + author?: WindshiftUserResponse | null; + created_at: string; + updated_at: string; +}; + +function userName(user: WindshiftUserResponse | null | undefined): string | null { + return user?.full_name || user?.username || user?.email || null; +} + +function mapItem(item: WindshiftItemResponse): WindshiftItem { + return { + id: item.id, + workspace_id: item.workspace_id, + workspace_name: item.workspace?.name ?? null, + workspace_key: item.workspace_key ?? item.workspace?.key ?? null, + workspace_item_number: item.workspace_item_number ?? null, + title: item.title, + description: item.description ?? null, + status_id: item.status?.id ?? null, + status_name: item.status?.name ?? null, + priority_id: item.priority?.id ?? null, + priority_name: item.priority?.name ?? null, + assignee_id: item.assignee?.id ?? null, + assignee_name: userName(item.assignee), + assignee_email: item.assignee?.email ?? null, + creator_id: item.creator?.id ?? null, + creator_name: userName(item.creator), + milestones: item.milestones ?? [], + iteration: item.iteration ?? null, + created_at: item.created_at, + updated_at: item.updated_at, + completed_at: item.completed_at ?? null, + }; +} + +function mapComment(comment: WindshiftCommentResponse): WindshiftComment { + return { + id: comment.id, + item_id: comment.item_id, + user_id: comment.author?.id ?? null, + user_name: userName(comment.author), + body: comment.content, + created_at: comment.created_at, + updated_at: comment.updated_at, + }; +} + +export class WindshiftApiClient { + constructor( + private readonly baseUrl: string, + private readonly apiToken: string, + ) {} + + private async request(path: string): Promise { + const res = await fetch(joinUrl(this.baseUrl, path), { + headers: { + Authorization: `Bearer ${this.apiToken}`, + Accept: "application/json", + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error( + `Windshift API ${res.status} for ${path}: ${body.slice(0, 200)}`, + ); + } + return (await res.json()) as T; + } + + async fetchWorkspaces(): Promise { + const workspaces: WindshiftWorkspace[] = []; + let page = 1; + while (true) { + const params = new URLSearchParams({ + page: String(page), + limit: String(PAGE_SIZE), + }); + const response = await this.request< + WindshiftPaginatedResponse + >(`/workspaces?${params}`); + workspaces.push(...response.data); + if (!response.pagination.has_more) return workspaces; + page = response.pagination.page + 1; + } + } + + async *fetchItems(workspaceId?: number): AsyncGenerator { + let page = 1; + while (true) { + const params = new URLSearchParams({ + page: String(page), + limit: String(PAGE_SIZE), + sort: "key", + order: "asc", + }); + if (workspaceId !== undefined) { + params.set("workspace_id", String(workspaceId)); + } + const res = await this.request< + WindshiftPaginatedResponse + >( + `/items?${params}`, + ); + for (const item of res.data) { + yield mapItem(item); + } + if ( + res.data.length === 0 || + res.pagination.page >= res.pagination.total_pages + ) { + return; + } + page = res.pagination.page + 1; + } + } + + async fetchItemChanges( + workspaceId: number, + since?: string, + through?: string, + ): Promise { + const params = new URLSearchParams({ + workspace_id: String(workspaceId), + limit: String(CHANGE_PAGE_SIZE), + }); + if (since !== undefined) params.set("since", since); + if (through !== undefined) params.set("through", through); + return this.request( + `/items/changes?${params}`, + ); + } + + async fetchItemsByIds(itemIds: number[]): Promise { + if (itemIds.length === 0) return []; + const params = new URLSearchParams({ ids: itemIds.join(",") }); + const items = await this.request( + `/items/batch?${params}`, + ); + return items.map(mapItem); + } + + async fetchItemComments(itemId: number): Promise { + const response = await this.request< + WindshiftPaginatedResponse + >( + `/items/${itemId}/comments?page=1&limit=${MAX_COMMENTS_PER_ITEM}&order=desc`, + ); + return response.data.map(mapComment); + } + + async getItem(itemId: number): Promise { + const item = await this.request(`/items/${itemId}`); + return mapItem(item); + } + + async transitionItem(itemId: number, toStatusId: number): Promise { + return this.write("POST", `/items/${itemId}/transition`, { + to_status_id: toStatusId, + }); + } + + async updateItem( + itemId: number, + fields: Record, + ): Promise { + return this.write("PUT", `/items/${itemId}`, fields); + } + + async createItem(fields: Record): Promise { + return this.write("POST", `/items`, fields); + } + + private async write( + method: "POST" | "PUT", + path: string, + body: Record, + ): Promise { + const res = await fetch(joinUrl(this.baseUrl, path), { + method, + headers: { + Authorization: `Bearer ${this.apiToken}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `Windshift API ${res.status} for ${method} ${path}: ${text.slice(0, 300)}`, + ); + } + if (res.status === 204) return null; + return res.json(); + } +} diff --git a/connectors/windshift/src/connector.test.ts b/connectors/windshift/src/connector.test.ts new file mode 100644 index 000000000..662efdd62 --- /dev/null +++ b/connectors/windshift/src/connector.test.ts @@ -0,0 +1,375 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SyncMode, type SyncContext } from "@getomnico/connector"; +import { WindshiftConnector } from "./connector.js"; + +test("uses the public issuer for browser OAuth and internal server routes", () => { + const previousPublicUrl = process.env.WINDSHIFT_BASE_URL; + const previousInternalUrl = process.env.WINDSHIFT_INTERNAL_BASE_URL; + process.env.WINDSHIFT_BASE_URL = "http://localhost:8080/"; + process.env.WINDSHIFT_INTERNAL_BASE_URL = "http://host.docker.internal:8080/"; + + try { + const connector = new WindshiftConnector(); + assert.deepEqual(connector.mcpServer, { + transport: "http", + url: "http://host.docker.internal:8080/mcp", + }); + assert.equal( + connector.oauthConfig?.auth_endpoint, + "http://localhost:8080/oauth/authorize", + ); + assert.equal( + connector.oauthConfig?.registration_endpoint, + "http://host.docker.internal:8080/api/oauth/register", + ); + assert.equal( + connector.oauthConfig?.token_endpoint, + "http://host.docker.internal:8080/api/oauth/token", + ); + assert.equal( + connector.oauthConfig?.userinfo_endpoint, + "http://host.docker.internal:8080/api/oauth/userinfo", + ); + assert.equal(connector.oauthConfig?.resource, "http://localhost:8080/mcp"); + } finally { + if (previousPublicUrl === undefined) delete process.env.WINDSHIFT_BASE_URL; + else process.env.WINDSHIFT_BASE_URL = previousPublicUrl; + if (previousInternalUrl === undefined) + delete process.env.WINDSHIFT_INTERNAL_BASE_URL; + else process.env.WINDSHIFT_INTERNAL_BASE_URL = previousInternalUrl; + } +}); + +test("builds MCP authorization from sync and action credential shapes", () => { + const connector = new WindshiftConnector(); + + assert.deepEqual(connector.prepareMcpHeaders({ access_token: "sync-token" }), { + Authorization: "Bearer sync-token", + }); + assert.deepEqual( + connector.prepareMcpHeaders({ + credentials: { access_token: "action-token" }, + }), + { Authorization: "Bearer action-token" }, + ); + assert.deepEqual(connector.prepareMcpHeaders({}), {}); +}); + +test("full sync checkpoints only after indexing visible items", async () => { + const previousPublicUrl = process.env.WINDSHIFT_BASE_URL; + const previousInternalUrl = process.env.WINDSHIFT_INTERNAL_BASE_URL; + const previousFetch = globalThis.fetch; + process.env.WINDSHIFT_BASE_URL = "http://localhost:5111"; + process.env.WINDSHIFT_INTERNAL_BASE_URL = "http://windshift:8080"; + + let emitted = 0; + let scanned = 0; + let completed = false; + let emittedPermissions: unknown; + let completedState: unknown; + + globalThis.fetch = async (input) => { + const url = String(input); + if (url.includes("/workspaces?")) { + return Response.json({ + data: [{ id: 1, key: "W1", name: "Workspace 1" }], + pagination: { page: 1, total_pages: 1, has_more: false }, + }); + } + if (url.includes("/items/changes?")) { + return Response.json({ + changes: [], + next_cursor: "5", + watermark: "5", + has_more: false, + reset_required: false, + }); + } + if (url.includes("/items?")) { + return Response.json({ + data: [ + { + id: 2, + workspace_id: 1, + workspace_key: "W1", + workspace_item_number: 2, + title: "Milestone item", + milestones: [{ id: 1, name: "0.8.3" }], + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-02T00:00:00.000Z", + }, + ], + pagination: { page: 1, total_pages: 1, has_more: false }, + }); + } + if (url.includes("/items/2/comments")) { + return Response.json({ data: [] }); + } + throw new Error(`Unexpected request: ${url}`); + }; + + const ctx = { + syncMode: SyncMode.FULL, + isCancelled: () => false, + incrementScanned: async () => { + scanned++; + }, + contentStorage: { save: async () => "content-1" }, + emit: async (document: { permissions?: unknown }) => { + emitted++; + emittedPermissions = document.permissions; + }, + complete: async (state: unknown) => { + completed = true; + completedState = state; + }, + fail: async (message: string) => { + throw new Error(message); + }, + emitError: () => {}, + getUserEmailForSource: async () => "owner@example.com", + get documentsScanned() { + return scanned; + }, + get documentsEmitted() { + return emitted; + }, + } as unknown as SyncContext; + + try { + const connector = new WindshiftConnector(); + assert.deepEqual(connector.syncModes, ["full", "incremental"]); + connector.bootstrapMcp = async () => {}; + await connector.sync({}, { access_token: "token" }, null, ctx); + + assert.equal(scanned, 1); + assert.equal(emitted, 1); + assert.equal(completed, true); + assert.deepEqual(completedState, { + workspace_cursors: { "1": "5" }, + }); + assert.deepEqual(emittedPermissions, { + public: false, + users: ["owner@example.com"], + groups: [], + }); + + completed = false; + let failedMessage = ""; + await connector.sync( + {}, + { access_token: "token" }, + null, + { + ...ctx, + contentStorage: { + save: async () => { + throw new Error("storage unavailable"); + }, + }, + complete: async () => { + completed = true; + }, + fail: async (message: string) => { + failedMessage = message; + }, + } as unknown as SyncContext, + ); + assert.match(failedMessage, /storage unavailable/); + assert.equal(completed, false); + } finally { + globalThis.fetch = previousFetch; + if (previousPublicUrl === undefined) delete process.env.WINDSHIFT_BASE_URL; + else process.env.WINDSHIFT_BASE_URL = previousPublicUrl; + if (previousInternalUrl === undefined) + delete process.env.WINDSHIFT_INTERNAL_BASE_URL; + else process.env.WINDSHIFT_INTERNAL_BASE_URL = previousInternalUrl; + } +}); + +test("incremental sync consumes change pages and emits deletions", async () => { + const previousPublicUrl = process.env.WINDSHIFT_BASE_URL; + const previousInternalUrl = process.env.WINDSHIFT_INTERNAL_BASE_URL; + const previousFetch = globalThis.fetch; + process.env.WINDSHIFT_BASE_URL = "http://localhost:5111"; + process.env.WINDSHIFT_INTERNAL_BASE_URL = "http://windshift:8080"; + + const requestedPaths: string[] = []; + let changePage = 0; + let failureMode = false; + globalThis.fetch = async (input) => { + const url = new URL(String(input)); + requestedPaths.push(`${url.pathname}?${url.searchParams}`); + if (url.pathname.endsWith("/workspaces")) { + return Response.json({ + data: [{ id: 1, key: "W1", name: "Workspace 1" }], + pagination: { page: 1, total_pages: 1, has_more: false }, + }); + } + if (url.pathname.endsWith("/items/changes")) { + if (failureMode) { + return Response.json({ + changes: [{ item_id: 9, change_type: "upsert" }], + next_cursor: "13", + watermark: "13", + has_more: false, + reset_required: false, + }); + } + changePage++; + if (changePage === 1) { + assert.equal(url.searchParams.get("since"), "10"); + assert.equal(url.searchParams.has("through"), false); + return Response.json({ + changes: [{ item_id: 7, change_type: "upsert" }], + next_cursor: "11", + watermark: "12", + has_more: true, + reset_required: false, + }); + } + assert.equal(url.searchParams.get("since"), "11"); + assert.equal(url.searchParams.get("through"), "12"); + return Response.json({ + changes: [{ item_id: 8, change_type: "delete" }], + next_cursor: "12", + watermark: "12", + has_more: false, + reset_required: false, + }); + } + if (url.pathname.endsWith("/items/batch")) { + if (failureMode) { + return Response.json([ + { + id: 9, + workspace_id: 1, + workspace_key: "W1", + workspace_item_number: 9, + title: "Broken item", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-02T00:00:00.000Z", + }, + ]); + } + assert.equal(url.searchParams.get("ids"), "7"); + return Response.json([ + { + id: 7, + workspace_id: 1, + workspace_key: "W1", + workspace_item_number: 7, + title: "Changed item", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-02T00:00:00.000Z", + }, + ]); + } + if (url.pathname.endsWith("/items/7/comments")) { + return Response.json({ data: [] }); + } + if (url.pathname.endsWith("/items/9/comments")) { + return Response.json({ data: [] }); + } + throw new Error(`Unexpected request: ${url}`); + }; + + let updated = 0; + const deleted: string[] = []; + const savedStates: unknown[] = []; + let completedState: unknown; + const ctx = { + syncMode: SyncMode.INCREMENTAL, + isCancelled: () => false, + incrementScanned: async () => {}, + contentStorage: { save: async () => "content-7" }, + emitUpdated: async () => { + updated++; + }, + emitDeleted: async (externalId: string) => { + deleted.push(externalId); + }, + saveState: async (state: unknown) => { + savedStates.push(structuredClone(state)); + }, + complete: async (state: unknown) => { + completedState = state; + }, + fail: async (message: string) => { + throw new Error(message); + }, + emitError: () => {}, + getUserEmailForSource: async () => "owner@example.com", + get documentsScanned() { + return 1; + }, + get documentsEmitted() { + return updated; + }, + } as unknown as SyncContext; + + try { + const connector = new WindshiftConnector(); + connector.bootstrapMcp = async () => {}; + await connector.sync( + {}, + { access_token: "token" }, + { workspace_cursors: { "1": "10" } }, + ctx, + ); + + assert.equal(updated, 1); + assert.deepEqual(deleted, ["windshift:item:8"]); + assert.deepEqual(savedStates, [ + { workspace_cursors: { "1": "11" } }, + { workspace_cursors: { "1": "12" } }, + ]); + assert.deepEqual(completedState, { + workspace_cursors: { "1": "12" }, + }); + assert.equal( + requestedPaths.some((path) => path.startsWith("/rest/api/v1/items?")), + false, + ); + + failureMode = true; + let failedMessage = ""; + let savedAfterFailure = false; + let completedAfterFailure = false; + const failedCtx = { + ...ctx, + contentStorage: { + save: async () => { + throw new Error("storage unavailable"); + }, + }, + saveState: async () => { + savedAfterFailure = true; + }, + complete: async () => { + completedAfterFailure = true; + }, + fail: async (message: string) => { + failedMessage = message; + }, + } as unknown as SyncContext; + await connector.sync( + {}, + { access_token: "token" }, + { workspace_cursors: { "1": "12" } }, + failedCtx, + ); + assert.match(failedMessage, /storage unavailable/); + assert.equal(savedAfterFailure, false); + assert.equal(completedAfterFailure, false); + } finally { + globalThis.fetch = previousFetch; + if (previousPublicUrl === undefined) delete process.env.WINDSHIFT_BASE_URL; + else process.env.WINDSHIFT_BASE_URL = previousPublicUrl; + if (previousInternalUrl === undefined) + delete process.env.WINDSHIFT_INTERNAL_BASE_URL; + else process.env.WINDSHIFT_INTERNAL_BASE_URL = previousInternalUrl; + } +}); diff --git a/connectors/windshift/src/connector.ts b/connectors/windshift/src/connector.ts new file mode 100644 index 000000000..c67ac8939 --- /dev/null +++ b/connectors/windshift/src/connector.ts @@ -0,0 +1,410 @@ +import { + Connector, + SyncMode, + type SyncContext, + type SearchOperator, + type ActionDefinition, + getLogger, +} from "@getomnico/connector"; +import type { McpServer } from "@getomnico/connector"; +import { WindshiftApiClient } from "./client.js"; +import { generateItemContent, mapItemToDocument } from "./mappers.js"; +import type { + WindshiftCredentials, + WindshiftItem, + WindshiftSourceConfig, + WindshiftSyncState, +} from "./types.js"; + +const logger = getLogger("windshift"); + +const READ_SCOPES = [ + "mcp:access", + "items:read", + // Item updates and comments are core Windshift actions. Omni still requires + // explicit approval for every write tool invocation. + "items:write", + "workspaces:read", + "custom-fields:read", + "users:read", + "milestones:read", + "iterations:read", + "actions:read", + "pages:read", + "tests:read", + "time:read", +]; + +const WRITE_SCOPES = [ + ...READ_SCOPES, + "items:delete", + "actions:write", + "pages:write", + "pages:delete", + "tests:write", + "time:write", +]; + +function normalizedEnvUrl(name: string): string | undefined { + const url = process.env[name]; + if (!url) return undefined; + return url.replace(/\/+$/, ""); +} + +function windshiftPublicBaseUrl(): string | undefined { + return normalizedEnvUrl("WINDSHIFT_BASE_URL"); +} + +function windshiftTransportBaseUrl(): string | undefined { + return ( + normalizedEnvUrl("WINDSHIFT_INTERNAL_BASE_URL") ?? windshiftPublicBaseUrl() + ); +} + +function windshiftAccessToken( + credentials: WindshiftCredentials, +): string | undefined { + return credentials?.access_token ?? credentials?.credentials?.access_token; +} + +export class WindshiftConnector extends Connector< + WindshiftSourceConfig, + WindshiftCredentials, + WindshiftSyncState +> { + readonly name = "windshift"; + readonly version = "1.0.0"; + readonly sourceTypes = ["windshift"]; + + get description(): string { + return "Connect to Windshift items across your workspaces"; + } + + get displayName(): string { + return "Windshift"; + } + + readonly syncModes = ["full", "incremental"]; + + // No static actions — the action surface comes from Windshift's `/mcp` + // server via the HTTP MCP transport below. The Omni connector-manager + // discovers the tools after first sync (per the SDK's bootstrapMcp flow) + // and surfaces them as connector actions automatically. + readonly actions: ActionDefinition[] = []; + + readonly searchOperators: SearchOperator[] = [ + { operator: "status", attribute_key: "status", value_type: "text" }, + { operator: "priority", attribute_key: "priority", value_type: "text" }, + { operator: "assignee", attribute_key: "assignee", value_type: "person" }, + { operator: "workspace", attribute_key: "workspace", value_type: "text" }, + { operator: "milestone", attribute_key: "milestone", value_type: "text" }, + { operator: "iteration", attribute_key: "iteration", value_type: "text" }, + ]; + + readonly attributesSchema = { + type: "object", + properties: { + status: { type: "string" }, + priority: { type: "string" }, + assignee: { type: "string" }, + assignee_email: { type: "string", format: "email" }, + workspace: { type: "string" }, + identifier: { type: "string" }, + milestone: { type: "string" }, + iteration: { type: "string" }, + }, + }; + + readonly extraSchema = { + type: "object", + properties: { + workspace_keys: { + type: "array", + items: { type: "string" }, + description: "Restrict sync to these workspace keys (omit for all)", + }, + }, + }; + + // Wrap Windshift's existing /mcp server (Streamable HTTP, bearer auth) + // so every Windshift MCP tool — list_items, transition_item, add_comment, + // etc. — becomes an Omni connector action without per-tool wiring here. + // Returns undefined when WINDSHIFT_BASE_URL isn't set; the SDK then + // skips MCP discovery and the connector falls back to read-only sync. + get mcpServer(): McpServer | undefined { + const url = windshiftTransportBaseUrl(); + if (!url) return undefined; + return { transport: "http", url: `${url}/mcp` }; + } + + // Bridges OAuth credentials to the Authorization header the remote MCP + // server expects. Omni's web layer wrote the token after the user + // completed the per-user OAuth flow. Sync dispatches the token directly; + // action dispatch wraps it in Omni's ServiceCredential envelope. + prepareMcpHeaders(credentials: WindshiftCredentials): Record { + const accessToken = windshiftAccessToken(credentials); + if (!accessToken) return {}; + return { + Authorization: `Bearer ${accessToken}`, + }; + } + + // Windshift exposes a public-client DCR endpoint. Omni registers + // itself automatically, uses S256 PKCE, and binds every issued token to + // this exact MCP resource. No administrator-managed client secret is needed. + override get oauthConfig() { + const publicBaseUrl = windshiftPublicBaseUrl(); + const transportBaseUrl = windshiftTransportBaseUrl(); + if (!publicBaseUrl || !transportBaseUrl) return undefined; + return { + provider: "windshift", + // The browser must use the public issuer. Registration, token exchange, + // and user-info requests are server-to-server and may need the private + // route when Omni and Windshift run in separate containers. + auth_endpoint: `${publicBaseUrl}/oauth/authorize`, + token_endpoint: `${transportBaseUrl}/api/oauth/token`, + registration_endpoint: `${transportBaseUrl}/api/oauth/register`, + userinfo_endpoint: `${transportBaseUrl}/api/oauth/userinfo`, + userinfo_email_field: "email", + identity_scopes: [], + scopes: { + windshift: { + read: READ_SCOPES, + write: WRITE_SCOPES, + }, + }, + extra_auth_params: { resource: `${publicBaseUrl}/mcp` }, + scope_separator: " ", + token_endpoint_auth_method: "none" as const, + resource: `${publicBaseUrl}/mcp`, + }; + } + + async sync( + config: WindshiftSourceConfig, + credentials: WindshiftCredentials, + state: WindshiftSyncState | null, + ctx: SyncContext, + ): Promise { + const publicBaseUrl = windshiftPublicBaseUrl(); + const transportBaseUrl = windshiftTransportBaseUrl(); + if (!publicBaseUrl || !transportBaseUrl) { + await ctx.fail( + "Connector container is missing WINDSHIFT_BASE_URL env var", + ); + return; + } + const accessToken = windshiftAccessToken(credentials); + if (!accessToken) { + await ctx.fail("Missing 'access_token' in credentials"); + return; + } + + let sourceOwnerEmail: string; + try { + sourceOwnerEmail = await ctx.getUserEmailForSource(); + } catch (e) { + logger.error({ err: e }, "Source owner lookup failed"); + await ctx.fail(`Failed to resolve source owner: ${e}`); + return; + } + + // The SDK exposes MCP discovery but does not invoke it automatically. + // Bootstrap once credentials are available; failures are logged by the + // SDK and do not block document sync. + await this.bootstrapMcp(credentials); + + const client = new WindshiftApiClient(transportBaseUrl, accessToken); + + let allWorkspaces; + try { + allWorkspaces = await client.fetchWorkspaces(); + logger.info( + `Starting Windshift sync (${allWorkspaces.length} workspaces visible)`, + ); + } catch (e) { + logger.error({ err: e }, "Authentication / workspace fetch failed"); + await ctx.fail(`Authentication failed: ${e}`); + return; + } + + try { + const workspaceFilter = config.workspace_keys; + const workspaces = workspaceFilter + ? allWorkspaces.filter((w: { key: string }) => + workspaceFilter.includes(w.key), + ) + : allWorkspaces; + const incremental = ctx.syncMode === SyncMode.INCREMENTAL; + const nextState: WindshiftSyncState = { + workspace_cursors: incremental + ? { ...(state?.workspace_cursors ?? {}) } + : {}, + }; + + for (const workspace of workspaces) { + if (ctx.isCancelled()) { + await ctx.fail("Cancelled by user"); + return; + } + logger.info( + `Syncing items for workspace: ${workspace.name} (${workspace.key})`, + ); + + const workspaceKey = String(workspace.id); + const cursor = nextState.workspace_cursors[workspaceKey]; + if (incremental && cursor !== undefined) { + await this.syncWorkspaceChanges( + client, + workspace.id, + cursor, + nextState, + publicBaseUrl, + sourceOwnerEmail, + ctx, + ); + continue; + } + + // Capture the watermark before crawling. Changes committed while the + // crawl is running remain above it and are replayed incrementally. + const primed = await client.fetchItemChanges(workspace.id); + await this.syncWorkspaceFull( + client, + workspace.id, + incremental, + publicBaseUrl, + sourceOwnerEmail, + ctx, + ); + nextState.workspace_cursors[workspaceKey] = primed.watermark; + if (incremental) await ctx.saveState(nextState); + } + + await ctx.complete(nextState); + logger.info( + `Sync completed: ${ctx.documentsScanned} scanned, ${ctx.documentsEmitted} emitted`, + ); + } catch (e) { + logger.error({ err: e }, "Sync failed with unexpected error"); + await ctx.fail(String(e)); + } + } + + private async syncWorkspaceFull( + client: WindshiftApiClient, + workspaceId: number, + emitAsUpdate: boolean, + publicBaseUrl: string, + sourceOwnerEmail: string, + ctx: SyncContext, + ): Promise { + for await (const item of client.fetchItems(workspaceId)) { + if (ctx.isCancelled()) throw new Error("Cancelled by user"); + try { + await this.emitItem( + client, + item, + emitAsUpdate, + publicBaseUrl, + sourceOwnerEmail, + ctx, + ); + } catch (e) { + const externalId = `windshift:item:${item.id}`; + logger.warn(`Error processing ${externalId}: ${e}`); + ctx.emitError(externalId, String(e)); + throw e; + } + } + } + + private async syncWorkspaceChanges( + client: WindshiftApiClient, + workspaceId: number, + initialCursor: string, + state: WindshiftSyncState, + publicBaseUrl: string, + sourceOwnerEmail: string, + ctx: SyncContext, + ): Promise { + let cursor = initialCursor; + let through: string | undefined; + while (true) { + if (ctx.isCancelled()) throw new Error("Cancelled by user"); + const page = await client.fetchItemChanges(workspaceId, cursor, through); + if (page.reset_required) { + throw new Error( + `Windshift change cursor for workspace ${workspaceId} is no longer valid; run a full sync`, + ); + } + through ??= page.watermark; + + const latestChanges = new Map(); + for (const change of page.changes) { + latestChanges.set(change.item_id, change.change_type); + } + const upsertIds = [...latestChanges] + .filter(([, changeType]) => changeType === "upsert") + .map(([itemId]) => itemId); + const items = await client.fetchItemsByIds(upsertIds); + const itemsById = new Map(items.map((item) => [item.id, item])); + + for (const [itemId, changeType] of latestChanges) { + if (ctx.isCancelled()) throw new Error("Cancelled by user"); + if (changeType === "delete") { + await ctx.emitDeleted(`windshift:item:${itemId}`); + continue; + } + const item = itemsById.get(itemId); + if (!item) { + // The item was deleted or became invisible after the change event. + await ctx.emitDeleted(`windshift:item:${itemId}`); + continue; + } + try { + await this.emitItem( + client, + item, + true, + publicBaseUrl, + sourceOwnerEmail, + ctx, + ); + } catch (e) { + const externalId = `windshift:item:${item.id}`; + ctx.emitError(externalId, String(e)); + throw new Error(`Failed to process ${externalId}: ${e}`); + } + } + + cursor = page.next_cursor; + state.workspace_cursors[String(workspaceId)] = cursor; + await ctx.saveState(state); + if (!page.has_more) return; + } + } + + private async emitItem( + client: WindshiftApiClient, + item: WindshiftItem, + update: boolean, + publicBaseUrl: string, + sourceOwnerEmail: string, + ctx: SyncContext, + ): Promise { + await ctx.incrementScanned(); + const comments = await client.fetchItemComments(item.id); + const content = generateItemContent(item, comments); + const contentId = await ctx.contentStorage.save(content, "text/markdown"); + const doc = mapItemToDocument( + item, + comments, + contentId, + publicBaseUrl, + sourceOwnerEmail, + ); + if (update) await ctx.emitUpdated(doc); + else await ctx.emit(doc); + } +} diff --git a/connectors/windshift/src/main.ts b/connectors/windshift/src/main.ts new file mode 100644 index 000000000..907fa507d --- /dev/null +++ b/connectors/windshift/src/main.ts @@ -0,0 +1,4 @@ +import { WindshiftConnector } from './connector.js'; + +const connector = new WindshiftConnector(); +connector.serve(); diff --git a/connectors/windshift/src/mappers.test.ts b/connectors/windshift/src/mappers.test.ts new file mode 100644 index 000000000..9fca29dc6 --- /dev/null +++ b/connectors/windshift/src/mappers.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { generateItemContent, mapItemToDocument } from "./mappers.js"; +import type { WindshiftItem } from "./types.js"; + +test("maps item links to Windshift's stable workspace-key route", () => { + const item: WindshiftItem = { + id: 42, + workspace_id: 7, + workspace_key: "ENG", + workspace_item_number: 12, + title: "OAuth conformance", + created_at: "2026-07-21T12:00:00Z", + updated_at: "2026-07-21T12:00:00Z", + }; + + const document = mapItemToDocument( + item, + [], + "content-1", + "https://windshift.example/base/", + "Owner@Example.COM", + ); + + assert.equal( + document.metadata?.url, + "https://windshift.example/base/workspace/ENG/item/12", + ); + assert.deepEqual(document.permissions, { + public: false, + users: ["owner@example.com"], + groups: [], + }); +}); + +test("indexes milestone and iteration context", () => { + const item: WindshiftItem = { + id: 42, + workspace_id: 7, + workspace_name: "Engineering", + workspace_key: "ENG", + workspace_item_number: 12, + title: "OAuth conformance", + milestones: [{ id: 3, name: "0.8.3" }], + iteration: { id: 5, name: "Sprint 14" }, + created_at: "2026-07-21T12:00:00Z", + updated_at: "2026-07-21T12:00:00Z", + }; + + const document = mapItemToDocument( + item, + [], + "content-1", + "https://windshift.example", + "owner@example.com", + ); + assert.equal(document.attributes?.milestone, "0.8.3"); + assert.equal(document.attributes?.iteration, "Sprint 14"); + assert.match(generateItemContent(item, []), /Milestones: 0\.8\.3/); + assert.match(generateItemContent(item, []), /Iteration: Sprint 14/); +}); diff --git a/connectors/windshift/src/mappers.ts b/connectors/windshift/src/mappers.ts new file mode 100644 index 000000000..0189c9232 --- /dev/null +++ b/connectors/windshift/src/mappers.ts @@ -0,0 +1,130 @@ +import type { + Document, + DocumentMetadata, + DocumentPermissions, +} from "@getomnico/connector"; +import type { + WindshiftAttributes, + WindshiftComment, + WindshiftItem, +} from "./types.js"; + +const MAX_CONTENT_LENGTH = 100_000; + +function truncate(content: string): string { + if (content.length > MAX_CONTENT_LENGTH) { + return content.slice(0, MAX_CONTENT_LENGTH) + "\n... (truncated)"; + } + return content; +} + +function itemUrl(baseUrl: string, item: WindshiftItem): string | undefined { + const root = baseUrl.replace(/\/+$/, ""); + if (item.workspace_key && item.workspace_item_number != null) { + return `${root}/workspace/${encodeURIComponent(item.workspace_key)}/item/${item.workspace_item_number}`; + } + return `${root}/workspaces/${item.workspace_id}/items/${item.id}`; +} + +function itemIdentifier(item: WindshiftItem): string | undefined { + if (!item.workspace_key || item.workspace_item_number == null) + return undefined; + return `${item.workspace_key}-${item.workspace_item_number}`; +} + +function itemPermissions(sourceOwnerEmail: string): DocumentPermissions { + return { + public: false, + users: [sourceOwnerEmail.trim().toLowerCase()], + groups: [], + }; +} + +export function mapItemToDocument( + item: WindshiftItem, + comments: WindshiftComment[], + contentId: string, + baseUrl: string, + sourceOwnerEmail: string, +): Document { + const identifier = itemIdentifier(item); + const attributes: WindshiftAttributes = { + status: item.status_name ?? null, + priority: item.priority_name ?? null, + assignee: item.assignee_name ?? null, + assignee_email: item.assignee_email ?? null, + workspace: item.workspace_name ?? null, + identifier, + milestone: + item.milestones && item.milestones.length > 0 + ? item.milestones.map((milestone) => milestone.name).join(", ") + : null, + iteration: item.iteration?.name ?? null, + }; + + const pathParts = [item.workspace_name, identifier].filter( + Boolean, + ) as string[]; + + const metadata: DocumentMetadata = { + author: item.creator_name ?? undefined, + created_at: item.created_at, + updated_at: item.updated_at, + content_type: "item", + url: itemUrl(baseUrl, item), + mime_type: "text/markdown", + path: pathParts.join(" / ") || undefined, + }; + + const title = identifier ? `${identifier} - ${item.title}` : item.title; + + return { + external_id: `windshift:item:${item.id}`, + title, + content_id: contentId, + metadata, + permissions: itemPermissions(sourceOwnerEmail), + attributes, + }; +} + +export function generateItemContent( + item: WindshiftItem, + comments: WindshiftComment[], +): string { + const lines: string[] = []; + const identifier = itemIdentifier(item); + lines.push(identifier ? `${identifier}: ${item.title}` : item.title); + + const headerParts: string[] = []; + if (item.status_name) headerParts.push(`Status: ${item.status_name}`); + if (item.priority_name) headerParts.push(`Priority: ${item.priority_name}`); + if (item.workspace_name) + headerParts.push(`Workspace: ${item.workspace_name}`); + if (headerParts.length > 0) lines.push(headerParts.join(" | ")); + + if (item.assignee_name) lines.push(`Assignee: ${item.assignee_name}`); + if (item.creator_name) lines.push(`Created by: ${item.creator_name}`); + if (item.milestones && item.milestones.length > 0) { + lines.push( + `Milestones: ${item.milestones.map((milestone) => milestone.name).join(", ")}`, + ); + } + if (item.iteration) lines.push(`Iteration: ${item.iteration.name}`); + + lines.push(""); + if (item.description) lines.push(item.description); + + if (comments.length > 0) { + lines.push(""); + lines.push("--- Comments ---"); + for (const comment of comments) { + const dateStr = comment.created_at.split("T")[0]; + lines.push(`${comment.user_name ?? "Unknown"} (${dateStr}):`); + if (comment.body) lines.push(comment.body); + lines.push(""); + } + } + + return truncate(lines.join("\n")); +} diff --git a/connectors/windshift/src/types.ts b/connectors/windshift/src/types.ts new file mode 100644 index 000000000..2f3617d76 --- /dev/null +++ b/connectors/windshift/src/types.ts @@ -0,0 +1,112 @@ +export type WindshiftSourceConfig = { + workspace_keys?: string[]; +}; + +export type WindshiftSyncState = { + workspace_cursors: Record; +}; + +// Credentials are written by Omni's generic OAuth dispatcher +// (`web/src/lib/server/oauth/connectorOAuth.ts`) after the user completes +// the authorization-code flow against Windshift's `/oauth/authorize` + +// `/api/oauth/token`. The connector container reaches Windshift through +// WINDSHIFT_BASE_URL (process env), not through credentials — one Omni +// install pointing at one Windshift instance. +export type WindshiftTokenCredentials = { + access_token?: string; + refresh_token?: string; + token_type?: string; // 'Bearer' + expires_at?: string; // ISO8601 +}; + +// Sync requests contain the token fields directly. Action requests contain +// Omni's ServiceCredential envelope, whose provider payload is nested under +// `credentials`. +export type WindshiftCredentials = WindshiftTokenCredentials & { + credentials?: WindshiftTokenCredentials; +}; + +export type WindshiftAttributes = { + status?: string | null; + priority?: string | null; + assignee?: string | null; + assignee_email?: string | null; + workspace?: string | null; + identifier?: string; + milestone?: string | null; + iteration?: string | null; +}; + +export type WindshiftMilestone = { + id: number; + name: string; +}; + +export type WindshiftIteration = { + id: number; + name: string; +}; + +export type WindshiftItem = { + id: number; + workspace_id: number; + workspace_name?: string | null; + workspace_key?: string | null; + workspace_item_number?: number | null; + title: string; + description?: string | null; + status_id?: number | null; + status_name?: string | null; + priority_id?: number | null; + priority_name?: string | null; + assignee_id?: number | null; + assignee_name?: string | null; + assignee_email?: string | null; + creator_id?: number | null; + creator_name?: string | null; + milestones?: WindshiftMilestone[]; + iteration?: WindshiftIteration | null; + created_at: string; + updated_at: string; + completed_at?: string | null; +}; + +export type WindshiftPaginatedResponse = { + data: T[]; + pagination: { + page: number; + limit: number; + total: number; + total_pages: number; + has_more: boolean; + }; +}; + +export type WindshiftItemChange = { + item_id: number; + change_type: "upsert" | "delete"; +}; + +export type WindshiftItemChangesResponse = { + changes: WindshiftItemChange[]; + next_cursor: string; + watermark: string; + has_more: boolean; + reset_required: boolean; +}; + +export type WindshiftWorkspace = { + id: number; + key: string; + name: string; +}; + +export type WindshiftComment = { + id: number; + item_id: number; + user_id?: number | null; + user_name?: string | null; + body: string; + created_at: string; + updated_at: string; +}; diff --git a/connectors/windshift/tsconfig.json b/connectors/windshift/tsconfig.json new file mode 100644 index 000000000..aef3e691a --- /dev/null +++ b/connectors/windshift/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index e1a0341db..ab8ce1b9a 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -214,6 +214,12 @@ services: context: .. dockerfile: connectors/linear/Dockerfile + windshift-connector: + image: omni-windshift-connector:dev + build: + context: .. + dockerfile: connectors/windshift/Dockerfile + microsoft-connector: image: omni-microsoft-connector:dev build: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 0d0d9ee05..33d173e2e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -700,6 +700,33 @@ services: restart: unless-stopped logging: *default-logging + windshift-connector: + image: ghcr.io/getomnico/omni/omni-windshift-connector:${OMNI_VERSION:-latest} + <<: *resources-connector + cpus: ${OMNI_CONNECTOR_CPUS:-0.2} + mem_limit: ${OMNI_CONNECTOR_MEMORY:-384m} + container_name: omni-windshift-connector + profiles: + - windshift + expose: + - "${WINDSHIFT_CONNECTOR_PORT}" + environment: + <<: *otel-config + PORT: ${WINDSHIFT_CONNECTOR_PORT} + CONNECTOR_MANAGER_URL: ${CONNECTOR_MANAGER_URL} + CONNECTOR_HOST_NAME: windshift-connector + # Public OAuth issuer; must be reachable from the user's browser. + WINDSHIFT_BASE_URL: ${WINDSHIFT_BASE_URL} + # Optional connector-only route to the same Windshift instance. + WINDSHIFT_INTERNAL_BASE_URL: ${WINDSHIFT_INTERNAL_BASE_URL:-} + networks: + - omni-network + depends_on: + connector-manager: + condition: service_started + restart: unless-stopped + logging: *default-logging + microsoft-connector: image: ghcr.io/getomnico/omni/omni-microsoft-connector:${OMNI_VERSION:-latest} <<: *resources-connector diff --git a/infra/aws/terraform/main.tf b/infra/aws/terraform/main.tf index d0e3bf726..af277b4a6 100644 --- a/infra/aws/terraform/main.tf +++ b/infra/aws/terraform/main.tf @@ -200,6 +200,7 @@ module "compute" { agents_enabled = var.agents_enabled enabled_connectors = var.enabled_connectors + windshift_base_url = var.windshift_base_url sandbox_url = var.sandbox_url agent_max_iterations = var.agent_max_iterations approval_timeout_seconds = var.approval_timeout_seconds diff --git a/infra/aws/terraform/modules/compute/outputs.tf b/infra/aws/terraform/modules/compute/outputs.tf index 234120112..3dab9ffe9 100644 --- a/infra/aws/terraform/modules/compute/outputs.tf +++ b/infra/aws/terraform/modules/compute/outputs.tf @@ -118,6 +118,11 @@ output "linear_connector_service_name" { value = try(aws_ecs_service.linear_connector[0].name, null) } +output "windshift_connector_service_name" { + description = "Windshift connector service name" + value = try(aws_ecs_service.windshift_connector[0].name, null) +} + output "clickup_connector_service_name" { description = "ClickUp connector service name" value = try(aws_ecs_service.clickup_connector[0].name, null) diff --git a/infra/aws/terraform/modules/compute/service_discovery.tf b/infra/aws/terraform/modules/compute/service_discovery.tf index 3ce292129..2069bbc1c 100644 --- a/infra/aws/terraform/modules/compute/service_discovery.tf +++ b/infra/aws/terraform/modules/compute/service_discovery.tf @@ -330,6 +330,25 @@ resource "aws_service_discovery_service" "linear_connector" { } } +resource "aws_service_discovery_service" "windshift_connector" { + count = contains(var.enabled_connectors, "windshift") ? 1 : 0 + + name = "windshift-connector" + + dns_config { + namespace_id = var.service_discovery_namespace_id + + dns_records { + ttl = 300 + type = "A" + } + } + + health_check_custom_config { + failure_threshold = 1 + } +} + resource "aws_service_discovery_service" "clickup_connector" { count = contains(var.enabled_connectors, "clickup") ? 1 : 0 diff --git a/infra/aws/terraform/modules/compute/services.tf b/infra/aws/terraform/modules/compute/services.tf index 5b39e3499..80e9aa2cc 100644 --- a/infra/aws/terraform/modules/compute/services.tf +++ b/infra/aws/terraform/modules/compute/services.tf @@ -480,6 +480,33 @@ resource "aws_ecs_service" "linear_connector" { }) } +# Windshift Connector Service +resource "aws_ecs_service" "windshift_connector" { + count = contains(var.enabled_connectors, "windshift") ? 1 : 0 + + name = "omni-${var.customer_name}-windshift-connector" + cluster = var.cluster_arn + task_definition = aws_ecs_task_definition.windshift_connector[0].arn + launch_type = "FARGATE" + desired_count = var.desired_count + + enable_execute_command = true + + network_configuration { + security_groups = [var.security_group_id] + subnets = var.subnet_ids + assign_public_ip = false + } + + service_registries { + registry_arn = aws_service_discovery_service.windshift_connector[0].arn + } + + tags = merge(local.common_tags, { + Name = "omni-${var.customer_name}-windshift-connector" + }) +} + # ClickUp Connector Service resource "aws_ecs_service" "clickup_connector" { count = contains(var.enabled_connectors, "clickup") ? 1 : 0 diff --git a/infra/aws/terraform/modules/compute/task_definitions.tf b/infra/aws/terraform/modules/compute/task_definitions.tf index b6ff8ce63..c8f01b67d 100644 --- a/infra/aws/terraform/modules/compute/task_definitions.tf +++ b/infra/aws/terraform/modules/compute/task_definitions.tf @@ -919,6 +919,51 @@ resource "aws_ecs_task_definition" "linear_connector" { }) } +# Windshift Connector Task Definition +resource "aws_ecs_task_definition" "windshift_connector" { + count = contains(var.enabled_connectors, "windshift") ? 1 : 0 + + family = "omni-${var.customer_name}-windshift-connector" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.task_cpu + memory = var.task_memory + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.ecs_task.arn + + container_definitions = jsonencode([{ + name = "omni-windshift-connector" + image = "ghcr.io/${var.github_org}/omni/omni-windshift-connector:latest" + essential = true + + portMappings = [{ + containerPort = 4018 + protocol = "tcp" + }] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = var.log_group_name + "awslogs-region" = var.region + "awslogs-stream-prefix" = "windshift-connector" + } + } + + environment = concat(local.connector_base_environment, [ + { name = "PORT", value = "4018" }, + { name = "CONNECTOR_HOST_NAME", value = "windshift-connector" }, + { name = "WINDSHIFT_BASE_URL", value = var.windshift_base_url } + ]) + + secrets = [] + }]) + + tags = merge(local.common_tags, { + Name = "omni-${var.customer_name}-windshift-connector" + }) +} + # ClickUp Connector Task Definition resource "aws_ecs_task_definition" "clickup_connector" { count = contains(var.enabled_connectors, "clickup") ? 1 : 0 diff --git a/infra/aws/terraform/modules/compute/variables.tf b/infra/aws/terraform/modules/compute/variables.tf index 152ba19b2..0baf45439 100644 --- a/infra/aws/terraform/modules/compute/variables.tf +++ b/infra/aws/terraform/modules/compute/variables.tf @@ -327,6 +327,12 @@ variable "enabled_connectors" { default = ["web"] } +variable "windshift_base_url" { + description = "Public Windshift base URL used by the Windshift connector" + type = string + default = "" +} + variable "sandbox_url" { description = "URL of the sandbox service for code execution" type = string diff --git a/infra/aws/terraform/variables.tf b/infra/aws/terraform/variables.tf index 9bf846e7c..54bb5c2a3 100644 --- a/infra/aws/terraform/variables.tf +++ b/infra/aws/terraform/variables.tf @@ -290,6 +290,12 @@ variable "enabled_connectors" { default = ["web"] } +variable "windshift_base_url" { + description = "Public Windshift base URL used by the Windshift connector" + type = string + default = "" +} + variable "sandbox_url" { description = "URL of the sandbox service for code execution (leave empty to disable)" type = string diff --git a/infra/gcp/terraform/main.tf b/infra/gcp/terraform/main.tf index bd0d208bb..234a0edaa 100644 --- a/infra/gcp/terraform/main.tf +++ b/infra/gcp/terraform/main.tf @@ -164,6 +164,7 @@ module "compute" { ai_answer_enabled = var.ai_answer_enabled agents_enabled = var.agents_enabled enabled_connectors = var.enabled_connectors + windshift_base_url = var.windshift_base_url sandbox_url = var.sandbox_url agent_max_iterations = var.agent_max_iterations approval_timeout_seconds = var.approval_timeout_seconds diff --git a/infra/gcp/terraform/modules/compute/services.tf b/infra/gcp/terraform/modules/compute/services.tf index 18e3d0d38..34b54f09a 100644 --- a/infra/gcp/terraform/modules/compute/services.tf +++ b/infra/gcp/terraform/modules/compute/services.tf @@ -10,6 +10,7 @@ locals { "google-conn", "slack-conn", "atlassian-conn", "web-conn", "github-conn", "hubspot-conn", "google-ads-conn", "microsoft-conn", "notion-conn", "fireflies-conn", "imap-conn", "clickup-conn", "linear-conn", "filesystem-conn", "nextcloud-conn", "paperless-conn", + "windshift-conn", ] : name => "https://omni-${var.customer_name}-${name}-${local.project_number}.${var.region}.run.app" } db_env = { @@ -62,6 +63,7 @@ locals { filesystem = { port = 4013, image = "omni-filesystem-connector", extra_env = {} } nextcloud = { port = 4014, image = "omni-nextcloud-connector", extra_env = {} } paperless = { port = 4015, image = "omni-paperless-connector", extra_env = {} } + windshift = { port = 4018, image = "omni-windshift-connector", extra_env = { WINDSHIFT_BASE_URL = var.windshift_base_url } } } simple_connectors = { for k, v in local.all_simple_connectors : k => v if contains(var.enabled_connectors, k) } diff --git a/infra/gcp/terraform/modules/compute/variables.tf b/infra/gcp/terraform/modules/compute/variables.tf index df5520174..619a330b7 100644 --- a/infra/gcp/terraform/modules/compute/variables.tf +++ b/infra/gcp/terraform/modules/compute/variables.tf @@ -291,6 +291,12 @@ variable "enabled_connectors" { default = ["web"] } +variable "windshift_base_url" { + description = "Public Windshift base URL used by the Windshift connector" + type = string + default = "" +} + variable "sandbox_url" { description = "URL of the sandbox service for code execution" type = string diff --git a/infra/gcp/terraform/variables.tf b/infra/gcp/terraform/variables.tf index 77a480527..980bfafd8 100644 --- a/infra/gcp/terraform/variables.tf +++ b/infra/gcp/terraform/variables.tf @@ -282,6 +282,12 @@ variable "enabled_connectors" { default = ["web"] } +variable "windshift_base_url" { + description = "Public Windshift base URL used by the Windshift connector" + type = string + default = "" +} + variable "sandbox_url" { description = "URL of the sandbox service for code execution (leave empty to disable)" type = string diff --git a/sdk/python/omni_connector/models.py b/sdk/python/omni_connector/models.py index 7b8f14caf..5ce17def9 100644 --- a/sdk/python/omni_connector/models.py +++ b/sdk/python/omni_connector/models.py @@ -145,6 +145,11 @@ class ActionDefinition(BaseModel): # Hidden from every chat/agent tool surface, admins included (unlike # admin_only, not bypassed for admin users). Still dispatchable by name. hidden: bool = False + # OAuth scopes required to invoke this action, when declared by the + # connector or its upstream MCP tool metadata. + # None means the connector has not declared action-level scopes and + # Omni should fall back to the coarse credential-existence check. + required_scopes: Optional[list[str]] = None class SearchOperator(BaseModel): diff --git a/sdk/rust/src/mcp_adapter.rs b/sdk/rust/src/mcp_adapter.rs index d6fe45ca8..773775b04 100644 --- a/sdk/rust/src/mcp_adapter.rs +++ b/sdk/rust/src/mcp_adapter.rs @@ -442,6 +442,8 @@ async fn fetch_actions(client: &RmcpClient) -> Result> { } else { ActionMode::Write }, + // Rust MCP adapter does not extract tool-level scope metadata yet. + required_scopes: None, source_types: Vec::new(), admin_only: false, hidden: false, diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 3aacc66d6..ec34d1232 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -275,6 +275,22 @@ export class SdkClient { return SdkSourceSyncDataSchema.parse(raw); } + async getUserEmailForSource(sourceId: string): Promise { + const response = await this.get(`/sdk/source/${sourceId}/user-email`); + if (!response.ok) { + const text = await response.text(); + throw new SdkClientError( + `Failed to get user email for source: ${response.status} - ${text}`, + response.status + ); + } + const result = (await response.json()) as { email?: unknown }; + if (typeof result.email !== 'string' || result.email.length === 0) { + throw new SdkClientError('Failed to get user email for source: invalid response'); + } + return result.email; + } + private async get(path: string): Promise { const url = `${this.baseUrl}${path}`; return fetch(url, { diff --git a/sdk/typescript/src/connector.ts b/sdk/typescript/src/connector.ts index a43a77c02..6e818b6d0 100644 --- a/sdk/typescript/src/connector.ts +++ b/sdk/typescript/src/connector.ts @@ -5,6 +5,7 @@ import type { ActionDefinition, SearchOperator, OAuthManifestConfig, + OAuthCredentialReadyRequest, Source, } from './models.js'; import { ActionResponse } from './models.js'; @@ -103,6 +104,21 @@ export abstract class Connector< } } + /** + * React to a newly stored OAuth credential. MCP-backed connectors use it to + * restore their authenticated catalog after OAuth or a connector restart. + */ + async oauthCredentialReady( + request: OAuthCredentialReadyRequest + ): Promise { + const adapter = await this.getMcpAdapter(); + if (!adapter) { + return false; + } + await this.bootstrapMcp(request.credentials as TCredentials); + return adapter.hasCachedCatalog(); + } + prepareMcpAuth(credentials: TCredentials): { env?: Record; headers?: Record; @@ -141,6 +157,7 @@ export abstract class Connector< extra_schema: this.extraSchema, attributes_schema: this.attributesSchema, mcp_enabled: adapter !== undefined, + mcp_catalog_loaded: adapter?.hasCachedCatalog() ?? false, resources: adapter ? await adapter.getResourceDefinitions() : [], prompts: adapter ? await adapter.getPromptDefinitions() : [], oauth: this.oauthConfig, diff --git a/sdk/typescript/src/context.ts b/sdk/typescript/src/context.ts index 1bb850e7a..e29e35c27 100644 --- a/sdk/typescript/src/context.ts +++ b/sdk/typescript/src/context.ts @@ -271,6 +271,11 @@ export class SyncContext { return true; } + /** Return the Omni email of the user who owns this source. */ + async getUserEmailForSource(): Promise { + return this.client.getUserEmailForSource(this._sourceId); + } + /** * Checkpoint state for resumability. Call periodically for long syncs. * diff --git a/sdk/typescript/src/mcp-adapter.ts b/sdk/typescript/src/mcp-adapter.ts index 8f254b05d..4324bedaf 100644 --- a/sdk/typescript/src/mcp-adapter.ts +++ b/sdk/typescript/src/mcp-adapter.ts @@ -55,6 +55,14 @@ export class McpAdapter { this.server = server; } + hasCachedCatalog(): boolean { + return ( + this.cachedActions !== null && + this.cachedResources !== null && + this.cachedPrompts !== null + ); + } + private async withSession( env: Record | undefined, headers: Record | undefined, @@ -292,11 +300,18 @@ export class McpAdapter { const actions: ActionDefinition[] = []; for (const tool of tools) { const isReadOnly = tool.annotations?.readOnlyHint === true; + const meta = (tool as { _meta?: Record })._meta; + const requiredScopes = Array.isArray(meta?.required_scopes) + ? meta.required_scopes.filter( + (scope): scope is string => typeof scope === 'string' + ) + : undefined; actions.push({ name: tool.name, description: tool.description ?? '', input_schema: tool.inputSchema ?? { type: 'object', properties: {} }, mode: isReadOnly ? 'read' : 'write', + required_scopes: requiredScopes, source_types: [], admin_only: false, }); diff --git a/sdk/typescript/src/models.ts b/sdk/typescript/src/models.ts index 5f06eedcc..9aa8a38e0 100644 --- a/sdk/typescript/src/models.ts +++ b/sdk/typescript/src/models.ts @@ -73,6 +73,7 @@ export const ActionDefinitionSchema = z.object({ description: z.string(), input_schema: z.record(z.any()).default({ type: 'object', properties: {} }), mode: z.enum(['read', 'write']).default('write'), + required_scopes: z.array(z.string()).optional(), source_types: z.array(z.string()).default([]), admin_only: z.boolean().default(false), }); @@ -171,6 +172,7 @@ export const ConnectorManifestSchema = z.object({ extra_schema: z.record(z.unknown()).optional(), attributes_schema: z.record(z.unknown()).optional(), mcp_enabled: z.boolean().default(false), + mcp_catalog_loaded: z.boolean().default(false), resources: z.array(McpResourceDefinitionSchema).default([]), prompts: z.array(McpPromptDefinitionSchema).default([]), oauth: OAuthManifestConfigSchema.nullable().optional(), @@ -232,6 +234,17 @@ export const ActionRequestSchema = z.object({ }); export type ActionRequest = z.infer; +export const OAuthCredentialReadyRequestSchema = z.object({ + source_id: z.string(), + user_id: z.string().nullable().optional(), + provider: z.string(), + flow: z.string(), + credentials: z.record(z.unknown()).default({}), +}); +export type OAuthCredentialReadyRequest = z.infer< + typeof OAuthCredentialReadyRequestSchema +>; + export const ActionResponseSchema = z.object({ status: z.string(), result: z.record(z.unknown()).optional(), diff --git a/sdk/typescript/src/server.ts b/sdk/typescript/src/server.ts index 8138d451a..8dcf3fb8d 100644 --- a/sdk/typescript/src/server.ts +++ b/sdk/typescript/src/server.ts @@ -8,6 +8,7 @@ import { SyncRequestSchema, CancelRequestSchema, ActionRequestSchema, + OAuthCredentialReadyRequestSchema, ResourceRequestSchema, PromptRequestSchema, createSyncResponseStarted, @@ -75,7 +76,29 @@ export function createServer(connector: Connector): Express { res.json(manifest); }); - app.get("/sync/:syncRunId", (req: Request, res: Response) => { + app.post('/oauth/credential-ready', async (req: Request, res: Response) => { + const parseResult = OAuthCredentialReadyRequestSchema.safeParse(req.body); + if (!parseResult.success) { + res.status(400).json({ error: 'Invalid request body' }); + return; + } + + const changed = await connector.oauthCredentialReady(parseResult.data); + if (!changed) { + res.status(204).send(); + return; + } + + const manifest = await connector.getManifest(connectorUrl); + try { + await getSdkClient().register(manifest); + } catch (err) { + logger.warn({ err }, 'OAuth credential-ready manifest registration failed'); + } + res.json(manifest); + }); + + app.get('/sync/:syncRunId', (req: Request, res: Response) => { const { syncRunId } = req.params; let running = false; for (const ctx of activeSyncs.values()) { diff --git a/sdk/typescript/tests/client.test.ts b/sdk/typescript/tests/client.test.ts index add212574..1050e0213 100644 --- a/sdk/typescript/tests/client.test.ts +++ b/sdk/typescript/tests/client.test.ts @@ -334,6 +334,34 @@ describe('SdkClient', () => { }); }); + describe('getUserEmailForSource', () => { + it('returns the source owner email', async () => { + server.use( + http.get(`${BASE_URL}/sdk/source/source-123/user-email`, () => + HttpResponse.json({ email: 'owner@example.com' }) + ) + ); + + const client = new SdkClient(BASE_URL); + await expect(client.getUserEmailForSource('source-123')).resolves.toBe( + 'owner@example.com' + ); + }); + + it('rejects an invalid response', async () => { + server.use( + http.get(`${BASE_URL}/sdk/source/source-123/user-email`, () => + HttpResponse.json({}) + ) + ); + + const client = new SdkClient(BASE_URL); + await expect(client.getUserEmailForSource('source-123')).rejects.toThrow( + 'invalid response' + ); + }); + }); + describe('error handling', () => { it('throws SdkClientError on non-2xx response', async () => { server.use( diff --git a/sdk/typescript/tests/context.test.ts b/sdk/typescript/tests/context.test.ts index 7959dedaa..bcb18b9f3 100644 --- a/sdk/typescript/tests/context.test.ts +++ b/sdk/typescript/tests/context.test.ts @@ -209,6 +209,23 @@ describe('SyncContext.shouldIndexUser', () => { }); }); +describe('SyncContext.getUserEmailForSource', () => { + it('returns the owner email for its source', async () => { + server.use( + http.get(`${BASE_URL}/sdk/source/source-owner/user-email`, () => + HttpResponse.json({ email: 'owner@example.com' }) + ) + ); + const ctx = new SyncContext( + new SdkClient(BASE_URL), + 'sync-owner', + 'source-owner' + ); + + await expect(ctx.getUserEmailForSource()).resolves.toBe('owner@example.com'); + }); +}); + describe('SyncContext.incrementUpdated', () => { it('POSTs the count to /sdk/sync/:id/updated', async () => { const calls: Array<{ syncRunId: string; count: number }> = []; diff --git a/sdk/typescript/tests/fixtures/test-mcp-server.mjs b/sdk/typescript/tests/fixtures/test-mcp-server.mjs index 126e3a2fe..964d6d25a 100644 --- a/sdk/typescript/tests/fixtures/test-mcp-server.mjs +++ b/sdk/typescript/tests/fixtures/test-mcp-server.mjs @@ -21,15 +21,31 @@ function buildServer() { }) ); - server.tool( + server.registerTool( 'add', - 'Add two numbers', - { a: z.number(), b: z.number() }, + { + description: 'Add two numbers', + inputSchema: { a: z.number(), b: z.number() }, + _meta: { required_scopes: ['numbers:write'] }, + }, async (args) => ({ content: [{ type: 'text', text: String(args.a + args.b) }], }) ); + server.registerTool( + 'ping', + { + description: 'Return pong without requiring OAuth scopes', + annotations: { readOnlyHint: true }, + inputSchema: {}, + _meta: { required_scopes: [] }, + }, + async () => ({ + content: [{ type: 'text', text: 'pong' }], + }) + ); + server.resource( 'item', new ResourceTemplate('test://item/{item_id}', { list: undefined }), diff --git a/sdk/typescript/tests/mcp-adapter.test.ts b/sdk/typescript/tests/mcp-adapter.test.ts index 258dd2993..77eac70b6 100644 --- a/sdk/typescript/tests/mcp-adapter.test.ts +++ b/sdk/typescript/tests/mcp-adapter.test.ts @@ -54,12 +54,16 @@ describe('McpAdapter (stdio)', () => { const adapter = new McpAdapter(STDIO_SERVER); const actions = await adapter.getActionDefinitions({ TEST_MODE: '1' }); const names = actions.map((a) => a.name).sort(); - expect(names).toEqual(['add', 'greet']); + expect(names).toEqual(['add', 'greet', 'ping']); const greet = actions.find((a) => a.name === 'greet')!; expect(greet.mode).toBe('read'); expect(greet.description).toBe('Greet someone by name'); + expect(greet.required_scopes).toBeUndefined(); const add = actions.find((a) => a.name === 'add')!; expect(add.mode).toBe('write'); + expect(add.required_scopes).toEqual(['numbers:write']); + const ping = actions.find((a) => a.name === 'ping')!; + expect(ping.required_scopes).toEqual([]); }); it('lists resources', async () => { @@ -117,7 +121,7 @@ describe('McpAdapter (stdio)', () => { await adapter.discover({ TEST_MODE: '1' }); // No env — returns from cache without spawning const actions = await adapter.getActionDefinitions(); - expect(actions.map((a) => a.name).sort()).toEqual(['add', 'greet']); + expect(actions.map((a) => a.name).sort()).toEqual(['add', 'greet', 'ping']); const resources = await adapter.getResourceDefinitions(); expect(resources).toHaveLength(1); const prompts = await adapter.getPromptDefinitions(); @@ -158,7 +162,7 @@ describe('McpAdapter (Streamable HTTP)', () => { it('lists tools', async () => { const adapter = new McpAdapter({ transport: 'http', url }); const actions = await adapter.getActionDefinitions(undefined, { 'X-Test': '1' }); - expect(actions.map((a) => a.name).sort()).toEqual(['add', 'greet']); + expect(actions.map((a) => a.name).sort()).toEqual(['add', 'greet', 'ping']); }); it('executes a tool', async () => { @@ -197,7 +201,7 @@ describe('McpAdapter (Streamable HTTP)', () => { await adapter.discover(undefined, { 'X-Test': '1' }); expect( (await adapter.getActionDefinitions()).map((a) => a.name).sort() - ).toEqual(['add', 'greet']); + ).toEqual(['add', 'greet', 'ping']); expect(await adapter.getResourceDefinitions()).toHaveLength(1); expect(await adapter.getPromptDefinitions()).toHaveLength(1); }); @@ -212,7 +216,7 @@ describe('McpAdapter (Streamable HTTP)', () => { const actions = await adapter.getActionDefinitions(undefined, { 'X-Per-Call': 'yes', }); - expect(actions).toHaveLength(2); + expect(actions).toHaveLength(3); }); }); @@ -234,6 +238,7 @@ describe('Connector MCP integration', () => { await connector.bootstrapMcp({}); const manifest: ConnectorManifest = await connector.getManifest('http://test:8000'); expect(manifest.mcp_enabled).toBe(true); + expect(manifest.mcp_catalog_loaded).toBe(true); const actionNames = manifest.actions.map((a) => a.name); expect(actionNames).toContain('greet'); expect(actionNames).toContain('add'); diff --git a/sdk/typescript/tests/models.test.ts b/sdk/typescript/tests/models.test.ts index d6d8d894b..9ea566cdb 100644 --- a/sdk/typescript/tests/models.test.ts +++ b/sdk/typescript/tests/models.test.ts @@ -3,6 +3,7 @@ import { DocumentMetadataSchema, DocumentPermissionsSchema, DocumentSchema, + ActionDefinitionSchema, ConnectorManifestSchema, SyncRequestSchema, EventType, @@ -97,6 +98,30 @@ describe('DocumentSchema', () => { }); }); +describe('ActionDefinitionSchema', () => { + const action = { + name: 'example', + description: 'Example action', + input_schema: {}, + mode: 'read' as const, + }; + + it('keeps omitted required scopes undeclared', () => { + const result = ActionDefinitionSchema.parse(action); + + expect(result.required_scopes).toBeUndefined(); + }); + + it('preserves an explicit empty required scope list', () => { + const result = ActionDefinitionSchema.parse({ + ...action, + required_scopes: [], + }); + + expect(result.required_scopes).toEqual([]); + }); +}); + describe('ConnectorManifestSchema', () => { it('validates a manifest', () => { const manifest = { diff --git a/sdk/typescript/tests/server.test.ts b/sdk/typescript/tests/server.test.ts index 058bff97f..fcb721baf 100644 --- a/sdk/typescript/tests/server.test.ts +++ b/sdk/typescript/tests/server.test.ts @@ -54,6 +54,13 @@ const mockServer = setupServer( http.post(`${MANAGER_URL}/sdk/sync/:id/fail`, () => HttpResponse.json({ success: true }), ), + http.post(`${MANAGER_URL}/sdk/events`, () => HttpResponse.json({ success: true })), + http.post(`${MANAGER_URL}/sdk/content`, () => HttpResponse.json({ content_id: 'content-123' })), + http.post(`${MANAGER_URL}/sdk/sync/:id/heartbeat`, () => HttpResponse.json({ success: true })), + http.post(`${MANAGER_URL}/sdk/sync/:id/scanned`, () => HttpResponse.json({ success: true })), + http.post(`${MANAGER_URL}/sdk/sync/:id/complete`, () => HttpResponse.json({ success: true })), + http.post(`${MANAGER_URL}/sdk/sync/:id/fail`, () => HttpResponse.json({ success: true })), + http.post(`${MANAGER_URL}/sdk/register`, () => HttpResponse.json({ success: true })) ); beforeAll(() => { @@ -103,14 +110,43 @@ describe("Connector Server", () => { actions: [], search_operators: [], mcp_enabled: false, + mcp_catalog_loaded: false, resources: [], prompts: [], }); }); }); - describe("POST /sync", () => { - it("returns 400 for invalid request body", async () => { + describe('POST /oauth/credential-ready', () => { + it('refreshes and returns a changed connector manifest', async () => { + const connector = new MockConnector(); + const credentialReady = vi + .spyOn(connector, 'oauthCredentialReady') + .mockResolvedValue(true); + const app = createServer(connector); + + const response = await request(app).post('/oauth/credential-ready').send({ + source_id: 'source-456', + user_id: 'user-123', + provider: 'windshift', + flow: 'user_write', + credentials: { access_token: 'token' }, + }); + + expect(response.status).toBe(200); + expect(response.body.connector_id).toBe('mock-connector'); + expect(credentialReady).toHaveBeenCalledWith({ + source_id: 'source-456', + user_id: 'user-123', + provider: 'windshift', + flow: 'user_write', + credentials: { access_token: 'token' }, + }); + }); + }); + + describe('POST /sync', () => { + it('returns 400 for invalid request body', async () => { const connector = new MockConnector(); const app = createServer(connector); diff --git a/services/ai/routers/chat.py b/services/ai/routers/chat.py index faa019b6a..cc2ac0986 100644 --- a/services/ai/routers/chat.py +++ b/services/ai/routers/chat.py @@ -104,6 +104,7 @@ ToolsetSummary, sources_from_sync_overview_response, ) +from tools.meta_handler import MetaToolHandler, OnLoad, exact_tool_names_for_query from tools.mcp_capability_handler import McpCapabilityHandler from tools.meta_handler import MetaToolHandler, OnLoad from tools.omni_tool_result import OAuthRequiredPayload @@ -191,6 +192,10 @@ def _loaded_tools_from_meta_call( tool_input: dict[str, object], connector_handler: ConnectorToolHandler, ) -> set[str]: + if tool_name == "tool_search": + query = tool_input.get("query") + if isinstance(query, str): + return exact_tool_names_for_query(query, connector_handler.actions) if tool_name == "load_tool": requested = tool_input.get("tool_name") if isinstance(requested, str) and requested in connector_handler.actions: diff --git a/services/ai/streaming/generate.py b/services/ai/streaming/generate.py index 069e46e11..cfb2b0ce8 100644 --- a/services/ai/streaming/generate.py +++ b/services/ai/streaming/generate.py @@ -66,6 +66,12 @@ logger = logging.getLogger(__name__) +_EMPTY_RESPONSE_RECOVERY_PROMPT = ( + "Continue the original request. If the last result discovered or loaded a tool, " + "call the appropriate available tool now. Do not stop without either making the " + "next tool call or giving the user a clear explanation." +) + # --------------------------------------------------------------------------- # Per-iteration event stream provider (with compaction retry) @@ -673,6 +679,7 @@ async def stream_generator( # ----- Main agent loop ------------------------------------------------- model_iteration = 0 + empty_response_retries = 0 loop_passes = AGENT_MAX_ITERATIONS + (1 if resumable_tool_calls else 0) for _ in range(loop_passes): if await is_run_cancelled(redis_client, chat_id): @@ -718,6 +725,7 @@ async def stream_generator( event_index = 0 message_stop_received = False + pending_message_start_sse: str | None = None cancelled = False last_cancel_check_at = 0.0 async for event in stream: @@ -828,10 +836,22 @@ async def stream_generator( logger.info("Message stop received.") message_stop_received = True - logger.debug( - f"Yielding event to client: {event.to_json(indent=None)}" - ) - yield f"event: message\ndata: {event.to_json(indent=None)}\n\n" + event_json = event.to_json(indent=None) + event_sse = f"event: message\ndata: {event_json}\n\n" + if event.type == "message_start": + # Hold this until the provider emits actual content. If + # it immediately stops, the retry below stays invisible + # and the persistence wrapper does not create an empty + # assistant row. + pending_message_start_sse = event_sse + elif event.type == "message_stop" and not content_blocks: + pass + else: + if pending_message_start_sse is not None: + yield pending_message_start_sse + pending_message_start_sse = None + logger.debug("Yielding event to client: %s", event_json) + yield event_sse if message_stop_received: break @@ -845,6 +865,23 @@ async def stream_generator( break tool_calls = [b for b in content_blocks if b["type"] == "tool_use"] + has_text = any( + b["type"] == "text" and str(b.get("text", "")).strip() + for b in content_blocks + ) + if not tool_calls and not has_text and empty_response_retries < 1: + empty_response_retries += 1 + logger.warning( + "Provider returned an empty response in iteration %s; " + "retrying once with a continuation prompt", + model_iteration, + ) + conversation_messages.append( + MessageParam( + role="user", content=_EMPTY_RESPONSE_RECOVERY_PROMPT + ) + ) + continue parse_errors = parse_tool_call_inputs( cast(list[ToolUseBlockParam], tool_calls) ) diff --git a/services/ai/tests/helpers.py b/services/ai/tests/helpers.py index 77a99521e..7e02a222e 100644 --- a/services/ai/tests/helpers.py +++ b/services/ai/tests/helpers.py @@ -296,7 +296,10 @@ async def stream_response(*_args, **_kwargs): idx = min(call_count, len(responses) - 1) call_count += 1 kind, data = responses[idx] - if kind == "tool_call": + if kind == "empty": + yield message_start_event() + yield RawMessageStopEvent(type="message_stop") + elif kind == "tool_call": for evt in tool_call_events(data): yield evt else: @@ -433,6 +436,7 @@ class GatedRecordingLLM: Each response entry follows the same convention as ``create_mock_llm_multi``: + * ``("empty", None)`` * ``("text", "response string")`` * ``("tool_call", {"name": ..., "input": ..., "id": ...})`` @@ -450,6 +454,10 @@ class GatedRecordingLLM: model_name = "gated-test" provider_type = "test" + @property + def supports_citations(self) -> bool: + return False + def __init__( self, responses: list[tuple[str, Any]], @@ -504,7 +512,10 @@ async def stream_response(self, **kwargs): idx = min(call_idx, len(self.responses) - 1) kind, payload = self.responses[idx] - if kind == "tool_call": + if kind == "empty": + yield message_start_event() + yield RawMessageStopEvent(type="message_stop") + elif kind == "tool_call": for event in tool_call_events( payload["input"], tool_name=payload.get("name", "search_documents"), diff --git a/services/ai/tests/integration/test_chat_stream_lifecycle.py b/services/ai/tests/integration/test_chat_stream_lifecycle.py index 36d404c41..23f766bf4 100644 --- a/services/ai/tests/integration/test_chat_stream_lifecycle.py +++ b/services/ai/tests/integration/test_chat_stream_lifecycle.py @@ -17,6 +17,7 @@ from __future__ import annotations import asyncio +import contextlib import json from unittest.mock import AsyncMock @@ -841,10 +842,8 @@ async def test_producer_crash_delivers_stream_error( # Wait for the producer task to finish its cleanup producer_task = chat_module._run_tasks_by_chat.get(chat_id) if producer_task is not None: - try: + with contextlib.suppress(asyncio.CancelledError, Exception): await asyncio.wait_for(producer_task, timeout=5) - except (asyncio.CancelledError, Exception): - pass event_types = [et for et, _d, _sid in events] assert ( @@ -915,7 +914,7 @@ async def test_status_pending_approval_true( parent_id = active[-1].id tool_use_id = "toolu_for_approval" - assistant_msg = await msgs_repo.create( + await msgs_repo.create( chat_id, { "role": "assistant", @@ -970,10 +969,11 @@ class TestPartialAssistant: def test_partial_assistant_strips_empty_text_blocks(self): """Empty text blocks are removed; non-empty text blocks are kept. Tool inputs with string JSON are parsed to dict.""" + from anthropic.types import TextBlockParam, ToolUseBlockParam + from streaming.persist import ( partial_assistant_message as _partial_assistant_message, ) - from anthropic.types import TextBlockParam, ToolUseBlockParam blocks: list[TextBlockParam | ToolUseBlockParam] = [ TextBlockParam(type="text", text=" "), # stripped @@ -996,10 +996,11 @@ def test_partial_assistant_strips_empty_text_blocks(self): def test_partial_assistant_returns_none_when_all_blocks_empty(self): """All-empty blocks → returns None.""" + from anthropic.types import TextBlockParam + from streaming.persist import ( partial_assistant_message as _partial_assistant_message, ) - from anthropic.types import TextBlockParam result = _partial_assistant_message( [ @@ -1011,10 +1012,11 @@ def test_partial_assistant_returns_none_when_all_blocks_empty(self): def test_partial_assistant_parses_empty_tool_input(self): """Empty string tool input becomes empty dict.""" + from anthropic.types import ToolUseBlockParam + from streaming.persist import ( partial_assistant_message as _partial_assistant_message, ) - from anthropic.types import ToolUseBlockParam result = _partial_assistant_message( [ @@ -1470,13 +1472,11 @@ class TestEmptyRowDelete: empty assistant row is deleted from the DB.""" @pytest.mark.asyncio - async def test_early_cancel_deletes_empty_assistant_row( + async def test_early_cancel_does_not_persist_empty_assistant( self, seeded_chat, redis_client, redis_keys ): - """LLM yields ``message_start`` (which triggers an early-persisted - assistant row), then cancel fires before any content block. The - empty row should be deleted, leaving zero assistant rows in the DB - for this chat.""" + """Cancel after ``message_start`` but before content leaves no empty + assistant event or database row.""" import routers.chat as chat_module chat_id, _user_id, model_id = seeded_chat @@ -1504,16 +1504,14 @@ async def test_early_cancel_deletes_empty_assistant_row( # Wait for the producer task to finish its cleanup producer_task = chat_module._run_tasks_by_chat.get(chat_id) if producer_task is not None: - try: + with contextlib.suppress(asyncio.CancelledError, Exception): await asyncio.wait_for(producer_task, timeout=5) - except (asyncio.CancelledError, Exception): - pass event_types = [et for et, _d, _sid in events] - # Should only have message_start + end_of_stream, no content + # The pending message_start is suppressed because no content arrived. msg_count = sum(1 for et, _, _ in events if et == "message") - assert msg_count == 1, ( - f"Expected 1 message event (message_start), got {msg_count}. " + assert msg_count == 0, ( + f"Expected no message events, got {msg_count}. " f"Event types: {event_types}" ) @@ -1661,10 +1659,11 @@ async def test_multi_turn_executes_tool_then_responds( user_with_tool_result = None for um in user_msgs: content = um.message.get("content", []) - if isinstance(content, list): - if any(b.get("type") == "tool_result" for b in content): - user_with_tool_result = um - break + if isinstance(content, list) and any( + b.get("type") == "tool_result" for b in content + ): + user_with_tool_result = um + break assert user_with_tool_result is not None, ( "No user message with tool_result found. " "Tool was not executed." ) @@ -2508,3 +2507,41 @@ async def test_context_overflow_triggers_compaction_retry( db_msgs = await MessagesRepository().get_active_path(chat_id) assistant_msgs = [m for m in db_msgs if m.message["role"] == "assistant"] assert assistant_msgs, "No assistant message persisted after retry" + + @pytest.mark.asyncio + async def test_empty_provider_response_retries_once( + self, seeded_chat, redis_client, redis_keys + ): + """Retry a provider turn containing only message_start/message_stop.""" + chat_id, _user_id, model_id = seeded_chat + llm = GatedRecordingLLM( + [("empty", None), ("text", "Recovered after an empty response.")], + model_id, + ) + + app = _build_chat_app(llm, redis_client, model_id) + async with _client(app) as client: + events = await collect_sse_events(client, chat_id) + + assert any(et == "end_of_stream" for et, _, _ in events) + assert len(llm.calls) == 2 + retry_messages = llm.calls[1]["messages"] + assert retry_messages[-1] == { + "role": "user", + "content": ( + "Continue the original request. If the last result discovered or " + "loaded a tool, call the appropriate available tool now. Do not stop " + "without either making the next tool call or giving the user a clear " + "explanation." + ), + } + + db_msgs = await MessagesRepository().get_active_path(chat_id) + assistant_msgs = [m for m in db_msgs if m.message["role"] == "assistant"] + assert len(assistant_msgs) == 1 + text = " ".join( + block["text"] + for block in assistant_msgs[0].message["content"] + if block.get("type") == "text" + ) + assert text == "Recovered after an empty response." diff --git a/services/ai/tests/unit/test_lazy_tool_loading.py b/services/ai/tests/unit/test_lazy_tool_loading.py index dc04c41db..e38d5ad31 100644 --- a/services/ai/tests/unit/test_lazy_tool_loading.py +++ b/services/ai/tests/unit/test_lazy_tool_loading.py @@ -76,6 +76,91 @@ async def test_chat_resume_restores_loaded_tool_from_successful_tool_call(): assert names == {"gmail__send_email"} +@pytest.mark.asyncio +async def test_chat_resume_restores_unique_exact_tool_search_match(): + connector_handler = _connector_with( + [ + _action("src-windshift-1", "windshift", "add_comment"), + _action("src-windshift-1", "windshift", "delete_comment"), + ] + ) + + messages = [ + MessageParam( + role="assistant", + content=[ + ToolUseBlockParam( + type="tool_use", + id="toolu_1", + name="tool_search", + input={"query": "add_comment"}, + ) + ], + ), + MessageParam( + role="user", + content=[ + ToolResultBlockParam( + type="tool_result", + tool_use_id="toolu_1", + content=[ + { + "type": "text", + "text": ( + "Exact match loaded and now callable: " + "windshift__add_comment" + ), + } + ], + is_error=False, + ) + ], + ), + ] + + loaded = _loaded_tools_from_history(messages, connector_handler) + + assert loaded == {"windshift__add_comment"} + + +@pytest.mark.asyncio +async def test_chat_resume_restores_tools_from_exact_source_search(): + connector_handler = _connector_with( + [ + _action("src-windshift-1", "windshift", "add_comment"), + _action("src-windshift-1", "windshift", "delete_comment"), + ] + ) + messages = [ + MessageParam( + role="assistant", + content=[ + ToolUseBlockParam( + type="tool_use", + id="toolu_1", + name="tool_search", + input={"query": "windshift"}, + ) + ], + ), + MessageParam( + role="user", + content=[ + ToolResultBlockParam( + type="tool_result", + tool_use_id="toolu_1", + content=[{"type": "text", "text": "Exact source match loaded."}], + is_error=False, + ) + ], + ), + ] + + loaded = _loaded_tools_from_history(messages, connector_handler) + + assert loaded == {"windshift__add_comment", "windshift__delete_comment"} + + @pytest.mark.asyncio async def test_chat_resume_restores_loaded_tool_set_from_tool_call(): connector_handler = _connector_with( diff --git a/services/ai/tests/unit/test_meta_handler.py b/services/ai/tests/unit/test_meta_handler.py index af8fb04a0..94b2bae96 100644 --- a/services/ai/tests/unit/test_meta_handler.py +++ b/services/ai/tests/unit/test_meta_handler.py @@ -6,8 +6,8 @@ from tools.connector_handler import ConnectorAction, ConnectorToolHandler from tools.meta_handler import MetaToolHandler -from tools.searcher_client import CapabilitySearchResponse, CapabilitySearchResult from tools.registry import ToolContext +from tools.searcher_client import CapabilitySearchResponse, CapabilitySearchResult def _make_action( @@ -145,6 +145,49 @@ async def on_load(newly: set[str]) -> None: assert fired == [] +@pytest.mark.asyncio +async def test_tool_search_loads_unique_exact_action_name_match(actions): + handler = _make_handler(actions) + loaded: set[str] = set() + fired: list[set[str]] = [] + + async def on_load(newly: set[str]) -> None: + fired.append(newly) + + searcher = _FakeSearcherClient(["gmail__list_threads"]) + meta = MetaToolHandler(handler, loaded, on_load, searcher_client=searcher) + result = await meta.execute("tool_search", {"query": "list_threads"}, _ctx()) + + assert not result.is_error + assert loaded == {"gmail__list_threads"} + assert fired == [{"gmail__list_threads"}] + assert "Exact match loaded and now callable: gmail__list_threads" in result.content[ + 0 + ]["text"] + + +@pytest.mark.asyncio +async def test_tool_search_loads_all_tools_for_exact_source_match(actions): + handler = _make_handler(actions) + loaded: set[str] = set() + fired: list[set[str]] = [] + + async def on_load(newly: set[str]) -> None: + fired.append(newly) + + searcher = _FakeSearcherClient(["gmail__send_email"]) + meta = MetaToolHandler(handler, loaded, on_load, searcher_client=searcher) + result = await meta.execute("tool_search", {"query": "gmail"}, _ctx()) + + gmail_tools = {"gmail__send_email", "gmail__list_threads"} + assert not result.is_error + assert loaded == gmail_tools + assert fired == [gmail_tools] + assert "Exact match loaded and now callable: 2 matching source tools" in result.content[ + 0 + ]["text"] + + @pytest.mark.asyncio async def test_tool_search_uses_searcher_without_loading(actions): handler = _make_handler(actions) @@ -176,11 +219,17 @@ async def test_publish_tool_capabilities_skips_unchanged_refresh(actions): await meta.publish_tool_capabilities() await meta.publish_tool_capabilities() - assert len(searcher.upserts) == 1 + assert len(searcher.upserts) == 4 + assert {call.publisher_id for call in searcher.upserts} == { + "src-gmail-1", + "src-outlook-1", + "src-drive-1", + "src-slack-1", + } @pytest.mark.asyncio -async def test_publish_tool_capabilities_chunks_large_batches(): +async def test_publish_tool_capabilities_groups_by_publisher(): many_actions = [ _make_action( f"src-{idx}", @@ -196,7 +245,8 @@ async def test_publish_tool_capabilities_chunks_large_batches(): await meta.publish_tool_capabilities() - assert [len(call.capabilities) for call in searcher.upserts] == [500, 1] + assert len(searcher.upserts) == 501 + assert all(len(call.capabilities) == 1 for call in searcher.upserts) @pytest.mark.asyncio diff --git a/services/ai/tests/unit/test_oauth_required.py b/services/ai/tests/unit/test_oauth_required.py index 44f0aee96..6d71091e4 100644 --- a/services/ai/tests/unit/test_oauth_required.py +++ b/services/ai/tests/unit/test_oauth_required.py @@ -10,6 +10,8 @@ import respx from httpx import Response +import tools.connector_handler as connector_handler_module +from db.models import Source from tools.connector_handler import ConnectorAction, ConnectorToolHandler from tools.omni_tool_result import ( OAuthRequiredPayload, @@ -18,10 +20,52 @@ ) from tools.registry import ToolContext - pytestmark = pytest.mark.unit +@pytest.mark.asyncio +async def test_manifest_preserves_undeclared_and_explicit_empty_action_scopes(): + source = Source( + id="src-1", + source_type="example", + name="Example", + is_active=True, + is_deleted=False, + ) + handler = ConnectorToolHandler( + connector_manager_url="http://cm.test", + user_id="user-1", + prefetched_sources=[source], + ) + manifest = { + "source_type": "example", + "healthy": True, + "manifest": { + "actions": [ + { + "name": "undeclared", + "description": "No action-level scope metadata", + }, + { + "name": "explicit_empty", + "description": "Explicitly requires no OAuth scopes", + "required_scopes": [], + }, + ] + }, + } + + with respx.mock(assert_all_called=True) as mock: + mock.get("http://cm.test/connectors").mock( + return_value=Response(200, json=[manifest]) + ) + actions = await handler._fetch_actions() + + by_name = {action.action_name: action for action in actions} + assert by_name["undeclared"].required_scopes is None + assert by_name["explicit_empty"].required_scopes == [] + + def _register_action(handler: ConnectorToolHandler, source_id: str) -> None: """Force a fake gmail__send_email action so the handler can dispatch.""" handler._actions["gmail__send_email"] = ConnectorAction( @@ -36,7 +80,124 @@ def _register_action(handler: ConnectorToolHandler, source_id: str) -> None: handler._initialized = True +class _CredentialConnection: + def __init__(self, credential: dict) -> None: + self.credential = credential + + async def fetchrow(self, _query: str, *_args: object) -> dict: + return self.credential + + +class _AcquireConnection: + def __init__(self, credential: dict) -> None: + self.connection = _CredentialConnection(credential) + + async def __aenter__(self) -> _CredentialConnection: + return self.connection + + async def __aexit__(self, *_args: object) -> None: + return None + + +class _CredentialPool: + def __init__(self, credential: dict) -> None: + self.credential = credential + + def acquire(self) -> _AcquireConnection: + return _AcquireConnection(self.credential) + + class TestConnectorHandlerOAuthRequired: + @pytest.mark.asyncio + async def test_existing_credential_missing_action_scope_requires_oauth( + self, monkeypatch: pytest.MonkeyPatch + ): + handler = ConnectorToolHandler( + connector_manager_url="http://cm.test", + user_id="user-1", + ) + handler._actions["windshift__add_comment"] = ConnectorAction( + source_id="src-1", + source_type="windshift", + source_name="Windshift", + action_name="add_comment", + description="Add a comment", + input_schema={"type": "object", "properties": {}}, + mode="write", + required_scopes=["items:write"], + ) + handler._initialized = True + + async def fake_get_db_pool() -> _CredentialPool: + return _CredentialPool( + { + "id": "credential-1", + "provider": "windshift", + "config": json.dumps( + {"granted_scopes": ["mcp:access", "items:read"]} + ), + } + ) + + monkeypatch.setattr(connector_handler_module, "get_db_pool", fake_get_db_pool) + + payload = await handler.check_oauth_required( + "windshift__add_comment", + {}, + ToolContext(chat_id="c1", user_id="user-1"), + ) + + assert payload is not None + assert payload.oauth_start_url == ( + "/api/oauth/start?source_id=src-1&flow=user_write&" + "required_scopes=items%3Awrite" + ) + + @pytest.mark.asyncio + async def test_existing_credential_with_action_scope_does_not_require_oauth( + self, monkeypatch: pytest.MonkeyPatch + ): + handler = ConnectorToolHandler( + connector_manager_url="http://cm.test", + user_id="user-1", + ) + handler._actions["windshift__add_comment"] = ConnectorAction( + source_id="src-1", + source_type="windshift", + source_name="Windshift", + action_name="add_comment", + description="Add a comment", + input_schema={"type": "object", "properties": {}}, + mode="write", + required_scopes=["items:write"], + ) + handler._initialized = True + + async def fake_get_db_pool() -> _CredentialPool: + return _CredentialPool( + { + "id": "credential-1", + "provider": "windshift", + "config": { + "granted_scopes": [ + "mcp:access", + "items:read", + "items:write", + ] + }, + } + ) + + monkeypatch.setattr(connector_handler_module, "get_db_pool", fake_get_db_pool) + + payload = await handler.check_oauth_required( + "windshift__add_comment", + {}, + ToolContext(chat_id="c1", user_id="user-1"), + ) + + assert payload is None + @pytest.mark.asyncio async def test_412_response_produces_structured_oauth_required(self): """A 412 needs_user_auth response from connector-manager must yield a diff --git a/services/ai/tools/connector_handler.py b/services/ai/tools/connector_handler.py index 3abc00743..8bc9bdd3e 100644 --- a/services/ai/tools/connector_handler.py +++ b/services/ai/tools/connector_handler.py @@ -8,6 +8,7 @@ from collections.abc import Mapping from dataclasses import asdict, dataclass from typing import Literal, TypedDict +from urllib.parse import urlencode import httpx import redis.asyncio as aioredis @@ -81,6 +82,7 @@ class ConnectorAction: description: str input_schema: dict mode: SourceMode + required_scopes: list[str] | None = None admin_only: bool = False hidden: bool = False @@ -237,6 +239,7 @@ async def _fetch_actions(self) -> list[ConnectorAction]: "input_schema", {"type": "object", "properties": {}} ), mode=action_def.get("mode", "write"), + required_scopes=action_def.get("required_scopes"), admin_only=action_def.get("admin_only", False), hidden=action_def.get("hidden", False), ) @@ -274,9 +277,11 @@ def _build_tools(self, actions: list[ConnectorAction]) -> None: base_tool_name = f"{action.source_type}__{action.action_name}" # Apply action_whitelist: skip actions not in whitelist - if self._action_whitelist is not None: - if base_tool_name not in self._action_whitelist: - continue + if ( + self._action_whitelist is not None + and base_tool_name not in self._action_whitelist + ): + continue occurrence = base_name_counts.get(base_tool_name, 0) base_name_counts[base_tool_name] = occurrence + 1 @@ -325,7 +330,7 @@ def list_toolsets(self) -> list[ToolsetSummary]: sample_tool_names (up to 3 for the LLM to skim). """ by_source: dict[str, list[ConnectorAction]] = {} - for tool_name, action in self._actions.items(): + for _tool_name, action in self._actions.items(): by_source.setdefault(action.source_id, []).append(action) toolsets: list[ToolsetSummary] = [] @@ -373,7 +378,7 @@ async def check_oauth_required( async with pool.acquire() as conn: user_credential = await conn.fetchrow( """ - SELECT id + SELECT id, provider, config FROM service_credentials WHERE source_id = $1 AND user_id = $2 LIMIT 1 @@ -382,7 +387,42 @@ async def check_oauth_required( context.user_id, ) if user_credential is not None: - return None + # None = connector has not declared action-level scopes. + # Fall back to original behavior: credential existence is + # sufficient and Omni does not attempt incremental consent. + if action.required_scopes is None: + return None + + required_scopes = set(action.required_scopes) + config = user_credential["config"] or {} + if isinstance(config, str): + try: + config = json.loads(config) + except json.JSONDecodeError: + config = {} + if not isinstance(config, Mapping): + config = {} + granted_scopes = set(config.get("granted_scopes") or []) + missing_scopes = sorted(required_scopes - granted_scopes) + if not missing_scopes: + return None + + provider = user_credential["provider"] + if not provider: + return None + query = urlencode( + { + "source_id": action.source_id, + "flow": "user_write", + "required_scopes": ",".join(missing_scopes), + } + ) + return OAuthRequiredPayload( + source_id=action.source_id, + source_type=action.source_type, + provider=provider, + oauth_start_url=f"/api/oauth/start?{query}", + ) org_credential = await conn.fetchrow( """ diff --git a/services/ai/tools/meta_handler.py b/services/ai/tools/meta_handler.py index b8a8af2d4..e0320fdad 100644 --- a/services/ai/tools/meta_handler.py +++ b/services/ai/tools/meta_handler.py @@ -13,7 +13,7 @@ import json import logging import re -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from anthropic.types import ToolParam @@ -35,6 +35,37 @@ OnLoad = Callable[[set[str]], Awaitable[None]] +def exact_tool_names_for_query( + query: str, actions: Mapping[str, ConnectorAction] +) -> set[str]: + """Return a unique exact action or every tool for an exact source query.""" + query_tokens = set(_TOKEN_RE.findall(query.lower())) + if not query_tokens: + return set() + + exact_action_matches = { + tool_name + for tool_name, action in actions.items() + if query_tokens + in ( + set(_TOKEN_RE.findall(tool_name.lower())), + set(_TOKEN_RE.findall(action.action_name.lower())), + ) + } + if len(exact_action_matches) == 1: + return exact_action_matches + + return { + tool_name + for tool_name, action in actions.items() + if query_tokens + in ( + set(_TOKEN_RE.findall(action.source_type.lower())), + set(_TOKEN_RE.findall(action.source_name.lower())), + ) + } + + class MetaToolHandler: """Meta-tools that let the LLM discover and load connector tools on demand.""" @@ -193,7 +224,26 @@ async def _tool_search(self, tool_input: dict) -> ToolResult: else "" ) lines.append(f"- {tool_name} — {desc}") - lines.append("Call load_tool with the exact tool name for any tools you need.") + + exact_tool_names = exact_tool_names_for_query(query, self._ch.actions) + if exact_tool_names: + newly_loaded = await self._mark_loaded(exact_tool_names) + tool_label = ( + next(iter(exact_tool_names)) + if len(exact_tool_names) == 1 + else f"{len(exact_tool_names)} matching source tools" + ) + if newly_loaded: + lines.append( + f"Exact match loaded and now callable: {tool_label}. " + "Call it on your next turn." + ) + else: + lines.append( + f"Exact match already loaded and callable: {tool_label}." + ) + else: + lines.append("Call load_tool with the exact tool name for any tools you need.") return ToolResult(content=[{"type": "text", "text": "\n".join(lines)}]) diff --git a/services/connector-manager/src/handlers.rs b/services/connector-manager/src/handlers.rs index 546e4e16f..42038f9d5 100644 --- a/services/connector-manager/src/handlers.rs +++ b/services/connector-manager/src/handlers.rs @@ -2704,6 +2704,7 @@ mod tests { description: "Export a database".to_string(), input_schema, mode: ActionMode::Read, + required_scopes: None, source_types: Vec::new(), admin_only: false, hidden: false, diff --git a/services/indexer/src/queue_processor.rs b/services/indexer/src/queue_processor.rs index d18b0d8c2..8fe66b562 100644 --- a/services/indexer/src/queue_processor.rs +++ b/services/indexer/src/queue_processor.rs @@ -1,5 +1,5 @@ -use crate::AppState; use crate::people_extractor; +use crate::AppState; use anyhow::{Context, Result}; use shared::db::repositories::{ DocumentRepository, GroupRepository, PersonRepository, SyncRunRepository, @@ -14,7 +14,7 @@ use shared::storage::gc::{ContentBlobGC, GCConfig}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::{Mutex, Semaphore}; -use tokio::time::{Duration, MissedTickBehavior, interval}; +use tokio::time::{interval, Duration, MissedTickBehavior}; use tracing::{debug, error, info, warn}; // Default poll interval for draining the queue. Overridable via INDEXER_POLL_INTERVAL_SECS. @@ -110,7 +110,9 @@ impl BatchingConfig { SyncType::Realtime => (self.realtime_batch_size, self.realtime_max_age_secs), }; - if metrics.count >= size_threshold { + if metrics.has_completed_sync { + ready.push((sync_type.clone(), format!("{} sync completed", sync_type))); + } else if metrics.count >= size_threshold { ready.push(( sync_type.clone(), format!( @@ -168,6 +170,7 @@ struct PendingMetrics { count: i64, oldest_age_secs: i64, size_bytes: i64, + has_completed_sync: bool, } type PendingBySyncType = HashMap; @@ -194,6 +197,7 @@ fn summarize_pending(summary: &shared::queue::QueueSummary) -> (PendingBySyncTyp count: entry.count, oldest_age_secs, size_bytes: entry.size_bytes, + has_completed_sync: entry.has_completed_sync, }, ); } @@ -1363,6 +1367,7 @@ mod tests { count: 2, oldest: Some(chrono::Utc::now()), size_bytes: 150, + has_completed_sync: false, }], }; @@ -1382,4 +1387,29 @@ mod tests { assert_eq!(ready[0].0, SyncType::Incremental); assert!(ready[0].1.contains("pending bytes 150 >= 100")); } + + #[test] + fn test_completed_sync_is_ready_below_batch_threshold() { + let summary = QueueSummary { + entries: vec![QueueSummaryEntry { + sync_type: Some(SyncType::Full), + status: EventStatus::Pending, + count: 4, + oldest: Some(chrono::Utc::now()), + size_bytes: 100, + has_completed_sync: true, + }], + }; + + let (by_sync_type, orphan_count) = summarize_pending(&summary); + let ready = BatchingConfig::default().ready_sync_types( + &by_sync_type, + orphan_count, + DEFAULT_BATCH_MAX_BYTES, + ); + + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].0, SyncType::Full); + assert!(ready[0].1.contains("sync completed")); + } } diff --git a/services/migrations/107_add_windshift_source_type.sql b/services/migrations/107_add_windshift_source_type.sql new file mode 100644 index 000000000..1d48c782a --- /dev/null +++ b/services/migrations/107_add_windshift_source_type.sql @@ -0,0 +1,52 @@ +-- Add Windshift as a valid source_type and service_credentials provider. +ALTER TABLE sources DROP CONSTRAINT IF EXISTS sources_source_type_check; +ALTER TABLE sources ADD CONSTRAINT sources_source_type_check +CHECK (source_type IN ( + 'google_drive', + 'gmail', + 'google_chat', + 'confluence', + 'jira', + 'slack', + 'notion', + 'web', + 'github', + 'local_files', + 'file_system', + 'fireflies', + 'hubspot', + 'one_drive', + 'share_point', + 'outlook', + 'outlook_calendar', + 'imap', + 'clickup', + 'linear', + 'ms_teams', + 'paperless_ngx', + 'nextcloud', + 'google_ads', + 'darwinbox', + 'windshift' +)); + +ALTER TABLE service_credentials DROP CONSTRAINT IF EXISTS service_credentials_provider_check; +ALTER TABLE service_credentials ADD CONSTRAINT service_credentials_provider_check +CHECK (provider IN ( + 'google', + 'slack', + 'atlassian', + 'github', + 'notion', + 'fireflies', + 'hubspot', + 'microsoft', + 'imap', + 'clickup', + 'linear', + 'paperless_ngx', + 'nextcloud', + 'google_ads', + 'darwinbox', + 'windshift' +)); diff --git a/shared/src/db/repositories/service_credentials.rs b/shared/src/db/repositories/service_credentials.rs index c90c58ecf..ed5708c83 100644 --- a/shared/src/db/repositories/service_credentials.rs +++ b/shared/src/db/repositories/service_credentials.rs @@ -1,9 +1,126 @@ -use anyhow::Result; +use anyhow::{anyhow, Context, Result}; +use serde::Deserialize; use serde_json::Value as JsonValue; use sqlx::PgPool; +use time::{Duration, OffsetDateTime}; use crate::encryption::{EncryptedData, EncryptionService}; -use crate::models::{ServiceCredential, Source, SourceScope}; +use crate::models::{AuthType, ServiceCredential, Source, SourceScope}; + +const OAUTH_REFRESH_SKEW: Duration = Duration::minutes(1); + +#[derive(Deserialize)] +struct OAuthRefreshResponse { + access_token: String, + refresh_token: Option, + token_type: Option, + expires_in: Option, +} + +fn oauth_needs_refresh(creds: &ServiceCredential) -> bool { + creds.auth_type == AuthType::OAuth + && creds + .expires_at + .is_some_and(|expires_at| expires_at <= OffsetDateTime::now_utc() + OAUTH_REFRESH_SKEW) +} + +fn oauth_is_refreshable(creds: &ServiceCredential) -> bool { + let Some(values) = creds.credentials.as_object() else { + return false; + }; + ["refresh_token", "client_id", "token_uri"] + .iter() + .all(|key| { + values + .get(*key) + .and_then(JsonValue::as_str) + .is_some_and(|value| !value.is_empty()) + }) +} + +async fn refresh_oauth_tokens(creds: &mut ServiceCredential) -> Result<()> { + let values = creds + .credentials + .as_object_mut() + .ok_or_else(|| anyhow!("OAuth credentials must be a JSON object"))?; + let string_value = |key: &str| { + values + .get(key) + .and_then(JsonValue::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + }; + let refresh_token = string_value("refresh_token").context("missing OAuth refresh_token")?; + let client_id = string_value("client_id").context("missing OAuth client_id")?; + let token_uri = string_value("token_uri").context("missing OAuth token_uri")?; + let client_secret = string_value("client_secret"); + let auth_method = string_value("token_endpoint_auth_method").unwrap_or_else(|| { + if client_secret.is_some() { + "client_secret_post".to_string() + } else { + "none".to_string() + } + }); + + let mut form = vec![ + ("grant_type", "refresh_token".to_string()), + ("refresh_token", refresh_token), + ]; + if auth_method != "client_secret_basic" { + form.push(("client_id", client_id.clone())); + } + if let Some(resource) = string_value("resource") { + form.push(("resource", resource)); + } + if auth_method == "client_secret_post" { + form.push(( + "client_secret", + client_secret + .clone() + .context("missing OAuth client_secret")?, + )); + } + + let client = reqwest::Client::new(); + let mut request = client.post(&token_uri).form(&form); + if auth_method == "client_secret_basic" { + request = request.basic_auth( + client_id, + Some(client_secret.context("missing OAuth client_secret")?), + ); + } else if auth_method != "none" && auth_method != "client_secret_post" { + return Err(anyhow!("unsupported OAuth token endpoint auth method")); + } + let response = request + .send() + .await + .context("OAuth token refresh request failed")?; + if !response.status().is_success() { + return Err(anyhow!( + "OAuth token refresh failed with status {}", + response.status() + )); + } + let refreshed: OAuthRefreshResponse = response + .json() + .await + .context("invalid OAuth token refresh response")?; + let now = OffsetDateTime::now_utc(); + values.insert("access_token".to_string(), refreshed.access_token.into()); + if let Some(refresh_token) = refreshed.refresh_token { + values.insert("refresh_token".to_string(), refresh_token.into()); + } + if let Some(token_type) = refreshed.token_type { + values.insert("token_type".to_string(), token_type.into()); + } + let expires_in = refreshed + .expires_in + .filter(|seconds| *seconds > 0) + .unwrap_or(3600); + creds.expires_at = Some(now + Duration::seconds(expires_in)); + creds.last_validated_at = Some(now); + Ok(()) +} /// Service credentials repository with encryption support. pub struct ServiceCredentialsRepo { @@ -33,7 +150,10 @@ impl ServiceCredentialsRepo { self.decrypt_credentials_in_place(creds)?; } - Ok(creds) + match creds { + Some(creds) => Ok(Some(self.refresh_oauth_if_needed(creds).await?)), + None => Ok(None), + } } /// Fetch the credential row that "owns" a source — the one used for sync @@ -71,7 +191,10 @@ impl ServiceCredentialsRepo { self.decrypt_credentials_in_place(creds)?; } - Ok(creds) + match creds { + Some(creds) => Ok(Some(self.refresh_oauth_if_needed(creds).await?)), + None => Ok(None), + } } fn decrypt_credentials_in_place(&self, creds: &mut ServiceCredential) -> Result<()> { @@ -92,6 +215,51 @@ impl ServiceCredentialsRepo { })) } + async fn refresh_oauth_if_needed(&self, creds: ServiceCredential) -> Result { + if !oauth_needs_refresh(&creds) || !oauth_is_refreshable(&creds) { + return Ok(creds); + } + self.refresh_oauth_credential(&creds.id).await + } + + async fn refresh_oauth_credential(&self, credential_id: &str) -> Result { + let mut tx = self.pool.begin().await?; + sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1))") + .bind(credential_id) + .execute(&mut *tx) + .await?; + + let mut creds = sqlx::query_as::<_, ServiceCredential>( + "SELECT * FROM service_credentials WHERE id = $1 FOR UPDATE", + ) + .bind(credential_id) + .fetch_one(&mut *tx) + .await?; + self.decrypt_credentials_in_place(&mut creds)?; + + // Another request may have refreshed this credential while we waited for + // the per-row advisory lock. Recheck under the lock before calling the + // provider so rotating refresh tokens are never replayed concurrently. + if !oauth_needs_refresh(&creds) || !oauth_is_refreshable(&creds) { + tx.commit().await?; + return Ok(creds); + } + + refresh_oauth_tokens(&mut creds).await?; + let encrypted_credentials = self.encrypt_credentials(&creds)?; + sqlx::query( + "UPDATE service_credentials SET credentials = $2, expires_at = $3, last_validated_at = $4, updated_at = CURRENT_TIMESTAMP WHERE id = $1", + ) + .bind(&creds.id) + .bind(encrypted_credentials) + .bind(creds.expires_at) + .bind(creds.last_validated_at) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(creds) + } + pub async fn create(&self, creds: ServiceCredential) -> Result { let encrypted_credentials = self.encrypt_credentials(&creds)?; @@ -225,3 +393,123 @@ impl ServiceCredentialsRepo { Ok(count) } } + +#[cfg(test)] +mod oauth_refresh_tests { + use std::{collections::HashMap, sync::Arc}; + + use axum::{ + extract::{Form, State}, + routing::post, + Json, Router, + }; + use serde_json::json; + use tokio::sync::Mutex; + + use super::*; + use crate::models::ServiceProvider; + + fn oauth_credential( + credentials: JsonValue, + expires_at: Option, + ) -> ServiceCredential { + let now = OffsetDateTime::now_utc(); + ServiceCredential { + id: "credential-1".to_string(), + source_id: "source-1".to_string(), + user_id: None, + provider: ServiceProvider::Clickup, + auth_type: AuthType::OAuth, + principal_email: Some("user@example.com".to_string()), + credentials, + config: json!({}), + expires_at, + last_validated_at: None, + created_at: now, + updated_at: now, + } + } + + #[test] + fn refreshes_only_expiring_oauth_credentials_with_refresh_metadata() { + let complete = json!({ + "refresh_token": "refresh-old", + "client_id": "client-1", + "token_uri": "https://windshift.example/api/oauth/token" + }); + let expiring = oauth_credential(complete.clone(), Some(OffsetDateTime::now_utc())); + assert!(oauth_needs_refresh(&expiring)); + assert!(oauth_is_refreshable(&expiring)); + + let valid = oauth_credential( + complete, + Some(OffsetDateTime::now_utc() + Duration::hours(1)), + ); + assert!(!oauth_needs_refresh(&valid)); + + let incomplete = oauth_credential(json!({ "access_token": "token" }), None); + assert!(!oauth_is_refreshable(&incomplete)); + } + + #[tokio::test] + async fn refreshes_public_client_tokens_and_preserves_resource_binding() { + type CapturedForm = Arc>>>; + + async fn token_endpoint( + State(captured): State, + Form(form): Form>, + ) -> Json { + *captured.lock().await = Some(form); + Json(json!({ + "access_token": "access-new", + "refresh_token": "refresh-new", + "token_type": "Bearer", + "expires_in": 120 + })) + } + + let captured: CapturedForm = Arc::new(Mutex::new(None)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let app = Router::new() + .route("/token", post(token_endpoint)) + .with_state(captured.clone()); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let resource = "https://windshift.example/mcp"; + let mut credential = oauth_credential( + json!({ + "access_token": "access-old", + "refresh_token": "refresh-old", + "client_id": "client-1", + "token_uri": format!("http://{address}/token"), + "token_endpoint_auth_method": "none", + "resource": resource + }), + Some(OffsetDateTime::now_utc()), + ); + + refresh_oauth_tokens(&mut credential).await.unwrap(); + server.abort(); + + assert_eq!(credential.credentials["access_token"], "access-new"); + assert_eq!(credential.credentials["refresh_token"], "refresh-new"); + assert_eq!(credential.credentials["token_type"], "Bearer"); + assert!( + credential.expires_at.unwrap() > OffsetDateTime::now_utc() + Duration::seconds(100) + ); + + let form = captured.lock().await.clone().unwrap(); + assert_eq!( + form.get("grant_type").map(String::as_str), + Some("refresh_token") + ); + assert_eq!( + form.get("refresh_token").map(String::as_str), + Some("refresh-old") + ); + assert_eq!(form.get("client_id").map(String::as_str), Some("client-1")); + assert_eq!(form.get("resource").map(String::as_str), Some(resource)); + assert!(!form.contains_key("client_secret")); + } +} diff --git a/shared/src/models.rs b/shared/src/models.rs index 13f754086..d7c105273 100644 --- a/shared/src/models.rs +++ b/shared/src/models.rs @@ -192,6 +192,7 @@ pub enum SourceType { Nextcloud, GoogleAds, Darwinbox, + Windshift, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, sqlx::Type, PartialEq)] @@ -217,6 +218,7 @@ pub enum ServiceProvider { #[serde(rename = "google_ads")] GoogleAds, Darwinbox, + Windshift, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, sqlx::Type, PartialEq)] @@ -752,6 +754,12 @@ pub struct ActionDefinition { pub input_schema: JsonValue, #[serde(default)] pub mode: ActionMode, + /// OAuth scopes required to invoke this action, when declared by the + /// connector or its upstream MCP tool metadata. + /// `None` means the connector has not declared action-level scopes and + /// Omni should fall back to the coarse credential-existence check. + #[serde(default)] + pub required_scopes: Option>, /// Restrict this action to a subset of the connector's `source_types`. /// Empty = applies to all source_types the connector supports. #[serde(default)] diff --git a/shared/src/queue.rs b/shared/src/queue.rs index fc992ee29..5a0631158 100644 --- a/shared/src/queue.rs +++ b/shared/src/queue.rs @@ -466,7 +466,8 @@ impl EventQueue { q.status, COUNT(*) as count, MIN(q.created_at) as oldest, - COALESCE(SUM(COALESCE(cb.size_bytes, 0)), 0)::BIGINT as size_bytes + COALESCE(SUM(COALESCE(cb.size_bytes, 0)), 0)::BIGINT as size_bytes, + COALESCE(BOOL_OR(s.status = 'completed'), false) as has_completed_sync FROM connector_events_queue q LEFT JOIN sync_runs s ON q.sync_run_id = s.id LEFT JOIN content_blobs cb ON cb.id = CASE @@ -488,6 +489,7 @@ impl EventQueue { let count: i64 = row.get("count"); let oldest: Option> = row.get("oldest"); let size_bytes: i64 = row.get("size_bytes"); + let has_completed_sync: bool = row.get("has_completed_sync"); let sync_type = match sync_type_str.as_deref() { None => None, @@ -511,6 +513,7 @@ impl EventQueue { count, oldest, size_bytes, + has_completed_sync, }); } @@ -673,6 +676,7 @@ pub struct QueueSummaryEntry { pub count: i64, pub oldest: Option>, pub size_bytes: i64, + pub has_completed_sync: bool, } #[derive(Debug)] diff --git a/shared/tests/action_definition_test.rs b/shared/tests/action_definition_test.rs new file mode 100644 index 000000000..ad31eda64 --- /dev/null +++ b/shared/tests/action_definition_test.rs @@ -0,0 +1,28 @@ +use serde_json::json; +use shared::models::ActionDefinition; + +fn action_json() -> serde_json::Value { + json!({ + "name": "example", + "description": "Example action", + "input_schema": {}, + "mode": "read" + }) +} + +#[test] +fn omitted_required_scopes_deserializes_as_undeclared() { + let action: ActionDefinition = serde_json::from_value(action_json()).unwrap(); + + assert_eq!(action.required_scopes, None); +} + +#[test] +fn explicit_empty_required_scopes_remains_distinct_from_undeclared() { + let mut value = action_json(); + value["required_scopes"] = json!([]); + + let action: ActionDefinition = serde_json::from_value(value).unwrap(); + + assert_eq!(action.required_scopes, Some(Vec::new())); +} diff --git a/web/src/lib/components/windshift-connector-setup.svelte b/web/src/lib/components/windshift-connector-setup.svelte new file mode 100644 index 000000000..6a37d7cab --- /dev/null +++ b/web/src/lib/components/windshift-connector-setup.svelte @@ -0,0 +1,58 @@ + + + !value && onCancel?.()}> + + + Connect Windshift + + Connect your Windshift account and index the workspaces you can access. + + + +
+ {#if baseUrl} +
+
Windshift URL
+ + {baseUrl} + +
+ {/if} +

+ Windshift will show the exact read permissions Omni needs. The OAuth client is + registered automatically and uses PKCE; there is no client secret to configure. +

+

+ Indexed content is private to your Omni account. Write actions request expanded + authorization when they are first used. +

+
+ + + + + +
+
diff --git a/web/src/lib/images/icons/windshift.png b/web/src/lib/images/icons/windshift.png new file mode 100644 index 000000000..d067d3424 Binary files /dev/null and b/web/src/lib/images/icons/windshift.png differ diff --git a/web/src/lib/server/oauth/connectorOAuth.test.ts b/web/src/lib/server/oauth/connectorOAuth.test.ts index 75fa6beb7..84b53df9c 100644 --- a/web/src/lib/server/oauth/connectorOAuth.test.ts +++ b/web/src/lib/server/oauth/connectorOAuth.test.ts @@ -1,8 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { OAuthStateManager } from './state' import { + dynamicRegistrationPayload, isAutoManagedOAuthProvider, isClientConfigComplete, + oauthServiceBaseUrl, + scopesForExistingSourceUserFlow, tokenEndpointAuthMethodForConfig, type OAuthManifestConfig, } from './connectorOAuth' @@ -65,6 +68,34 @@ describe('OAuth connector helpers', () => { ).toBe(false) }) + it('builds provider-specific public-client registration metadata', () => { + expect( + dynamicRegistrationPayload( + 'windshift', + 'https://omni.example/api/oauth/callback', + 'mcp:access', + ), + ).toEqual({ + client_name: 'Omni Windshift MCP', + redirect_uris: ['https://omni.example/api/oauth/callback'], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + scope: 'mcp:access', + }) + + expect( + dynamicRegistrationPayload( + 'clickup', + 'https://omni.example/api/oauth/callback', + 'tasks:read', + ), + ).toMatchObject({ + client_name: 'Omni ClickUp MCP', + grant_types: ['authorization_code'], + }) + }) + it('checks configured state based on token endpoint auth method', () => { expect(isClientConfigComplete({ oauth_client_id: 'public-client' }, 'none')).toBe(true) expect( @@ -96,4 +127,35 @@ describe('OAuth connector helpers', () => { ).toBe('client_secret_basic') expect(tokenEndpointAuthMethodForConfig(undefined, undefined)).toBe('client_secret_post') }) + + it('derives a deployment base URL from its OAuth authorization endpoint', () => { + expect(oauthServiceBaseUrl('https://windshift.example/oauth/authorize')).toBe( + 'https://windshift.example', + ) + expect( + oauthServiceBaseUrl('https://example.com/windshift/oauth/authorize?prompt=login'), + ).toBe('https://example.com/windshift') + }) + + it('limits write elevation to the requested connector scopes', () => { + const config: OAuthManifestConfig = { + ...baseManifest, + scopes: { + windshift: { + read: ['mcp:access', 'items:read'], + write: ['mcp:access', 'items:read', 'items:write', 'items:delete'], + }, + }, + } + + expect( + scopesForExistingSourceUserFlow(config, 'windshift', 'write', ['items:write']), + ).toEqual(['mcp:access', 'items:read', 'items:write']) + }) + + it('rejects requested scopes not declared by the connector', () => { + expect(() => + scopesForExistingSourceUserFlow(baseManifest, 'example', 'write', ['unexpected:write']), + ).toThrow('Unsupported write scopes') + }) }) diff --git a/web/src/lib/server/oauth/connectorOAuth.ts b/web/src/lib/server/oauth/connectorOAuth.ts index 2aa09b882..bfdf39544 100644 --- a/web/src/lib/server/oauth/connectorOAuth.ts +++ b/web/src/lib/server/oauth/connectorOAuth.ts @@ -71,6 +71,22 @@ export function callbackUrl(): string { return `${app.publicUrl}/api/oauth/callback` } +/** Return the public service URL represented by an OAuth authorization endpoint. */ +export function oauthServiceBaseUrl(authEndpoint: string): string { + const authorizationPath = '/oauth/authorize' + try { + const url = new URL(authEndpoint) + if (url.pathname.endsWith(authorizationPath)) { + url.pathname = url.pathname.slice(0, -authorizationPath.length) || '/' + } + url.search = '' + url.hash = '' + return url.toString().replace(/\/$/, '') + } catch { + return authEndpoint.replace(/\/oauth\/authorize\/?$/, '').replace(/\/$/, '') + } +} + /// Fetch a connector manifest from connector-manager by source_type. Returns /// the manifest's oauth block, or null if the connector either isn't /// registered or doesn't declare an OAuth config. @@ -113,7 +129,18 @@ async function loadClientCreds( const clientId = storedConfig.oauth_client_id const clientSecret = storedConfig.oauth_client_secret - if (clientId && isClientConfigComplete(storedConfig, tokenEndpointAuthMethod)) { + const dynamicRegistrationIsCurrent = + !manifestConfig || + !isAutoManagedOAuthProvider(manifestConfig) || + storedConfig.oauth_dynamic_client_registration !== 'true' || + (storedConfig.oauth_registration_endpoint === manifestConfig.registration_endpoint && + storedConfig.oauth_redirect_uri === callbackUrl()) + + if ( + clientId && + isClientConfigComplete(storedConfig, tokenEndpointAuthMethod) && + dynamicRegistrationIsCurrent + ) { return { clientId, clientSecret: clientSecret || undefined, @@ -147,14 +174,7 @@ async function dynamicallyRegisterClient( 'Content-Type': 'application/json', Accept: 'application/json', }, - body: JSON.stringify({ - client_name: 'Omni ClickUp MCP', - redirect_uris: [redirectUri], - grant_types: ['authorization_code'], - response_types: ['code'], - token_endpoint_auth_method: 'none', - scope, - }), + body: JSON.stringify(dynamicRegistrationPayload(provider, redirectUri, scope)), }) } catch { return null @@ -167,6 +187,8 @@ async function dynamicallyRegisterClient( oauth_client_id: data.client_id, oauth_token_endpoint_auth_method: 'none', oauth_dynamic_client_registration: 'true', + oauth_registration_endpoint: config.registration_endpoint!, + oauth_redirect_uri: redirectUri, } await upsertConnectorConfig(provider, stored, null) @@ -178,6 +200,28 @@ async function dynamicallyRegisterClient( } } +export function dynamicRegistrationPayload(provider: string, redirectUri: string, scope: string) { + const providerName = + provider === 'clickup' + ? 'ClickUp' + : provider + .split(/[-_\s]+/) + .filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(' ') + return { + client_name: `Omni ${providerName} MCP`, + redirect_uris: [redirectUri], + grant_types: + provider === 'windshift' + ? ['authorization_code', 'refresh_token'] + : ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'none' as const, + scope, + } +} + export function tokenEndpointAuthMethodForConfig( config: Record | undefined, manifestConfig?: OAuthManifestConfig, @@ -232,6 +276,26 @@ function scopesForFlow( return [...out] } +export function scopesForExistingSourceUserFlow( + config: OAuthManifestConfig, + sourceType: string, + mode: 'read' | 'write', + requiredScopes?: string[], +): string[] { + const readScopes = config.scopes[sourceType]?.read ?? [] + if (mode === 'read') return readScopes + + const writeScopes = config.scopes[sourceType]?.write ?? [] + const requestedWriteScopes = requiredScopes?.length ? [...new Set(requiredScopes)] : writeScopes + const invalidScopes = requestedWriteScopes.filter((scope) => !writeScopes.includes(scope)) + if (invalidScopes.length > 0) { + throw new Error( + `Unsupported write scopes for source_type=${sourceType}: ${invalidScopes.join(', ')}`, + ) + } + return [...new Set([...readScopes, ...requestedWriteScopes])] +} + /// Build the authorization URL for a given flow. export async function generateAuthUrl(args: { flow: Extract @@ -328,6 +392,7 @@ async function generateAuthUrlForExistingSourceUserFlow(args: { approvalId?: string approvalChatId?: string mode: 'read' | 'write' + requiredScopes?: string[] }): Promise<{ url: string; requiredScopes: string[] }> { const manifestConfig = await getOAuthManifestForSourceType(args.sourceType) if (!manifestConfig) { @@ -338,10 +403,12 @@ async function generateAuthUrlForExistingSourceUserFlow(args: { throw new Error(`OAuth client not configured for provider=${manifestConfig.provider}`) } - const readScopes = manifestConfig.scopes[args.sourceType]?.read ?? [] - const writeScopes = manifestConfig.scopes[args.sourceType]?.write ?? [] - const actionScopes = - args.mode === 'write' ? [...new Set([...readScopes, ...writeScopes])] : readScopes + const actionScopes = scopesForExistingSourceUserFlow( + manifestConfig, + args.sourceType, + args.mode, + args.requiredScopes, + ) if (actionScopes.length === 0) { throw new Error(`No ${args.mode} action scopes declared for source_type=${args.sourceType}`) } @@ -403,6 +470,7 @@ export async function generateAuthUrlForUserWrite(args: { returnTo?: string approvalId?: string approvalChatId?: string + requiredScopes?: string[] }): Promise<{ url: string; requiredScopes: string[] }> { return generateAuthUrlForExistingSourceUserFlow({ ...args, mode: 'write' }) } diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 3371ae6c8..8b5add17b 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -23,6 +23,7 @@ export enum SourceType { NEXTCLOUD = 'nextcloud', GOOGLE_ADS = 'google_ads', DARWINBOX = 'darwinbox', + WINDSHIFT = 'windshift', } export enum ServiceProvider { @@ -41,6 +42,7 @@ export enum ServiceProvider { NEXTCLOUD = 'nextcloud', GOOGLE_ADS = 'google_ads', DARWINBOX = 'darwinbox', + WINDSHIFT = 'windshift', } export enum AuthType { @@ -212,6 +214,15 @@ export interface DarwinboxSourceConfig { > } +export interface WindshiftSourceConfig { + workspace_keys?: string[] +} + +export interface WindshiftCredentials { + access_token: string + refresh_token?: string +} + export const DEFAULT_SYNC_INTERVAL_SECONDS: Record = { [SourceType.GOOGLE_DRIVE]: 1800, [SourceType.GMAIL]: 1800, @@ -237,6 +248,7 @@ export const DEFAULT_SYNC_INTERVAL_SECONDS: Record = { [SourceType.NEXTCLOUD]: 3600, [SourceType.GOOGLE_ADS]: 3600, [SourceType.DARWINBOX]: 3600, + [SourceType.WINDSHIFT]: 1800, } export const EMBEDDING_PROVIDER_TYPES = ['local', 'jina', 'openai', 'cohere', 'bedrock'] as const diff --git a/web/src/lib/utils/icons.ts b/web/src/lib/utils/icons.ts index aefcee150..cdd0e2e5b 100644 --- a/web/src/lib/utils/icons.ts +++ b/web/src/lib/utils/icons.ts @@ -28,6 +28,7 @@ import githubIcon from '$lib/images/icons/github.svg' import nextcloudIcon from '$lib/images/icons/nextcloud.svg' import paperlessIcon from '$lib/images/icons/paperless.svg' import imapIcon from '$lib/images/icons/imap.svg' +import windshiftIcon from '$lib/images/icons/windshift.png' // Google Workspace MIME types const GOOGLE_DOCS_MIMETYPES = [ @@ -74,6 +75,7 @@ const SOURCE_TYPE_ICONS: Record = { [SourceType.IMAP]: imapIcon, [SourceType.GOOGLE_ADS]: googleAdsIcon, [SourceType.DARWINBOX]: darwinboxIcon, + [SourceType.WINDSHIFT]: windshiftIcon, } // Get icon based on source type and content type @@ -226,6 +228,7 @@ export function getSourceDisplayName(sourceType: SourceType) { [SourceType.PAPERLESS_NGX]: 'Paperless-ngx', [SourceType.GOOGLE_ADS]: 'Google Ads', [SourceType.DARWINBOX]: 'Darwinbox', + [SourceType.WINDSHIFT]: 'Windshift', } return sourceDisplayNames[sourceType] diff --git a/web/src/routes/(admin)/admin/settings/integrations/+page.server.ts b/web/src/routes/(admin)/admin/settings/integrations/+page.server.ts index 4ee605ddd..e70e2417e 100644 --- a/web/src/routes/(admin)/admin/settings/integrations/+page.server.ts +++ b/web/src/routes/(admin)/admin/settings/integrations/+page.server.ts @@ -141,7 +141,12 @@ export const load: PageServerLoad = async ({ locals }) => { // Group by connector_id to build integration list const integrationMap = new Map< string, - { id: string; name: string; description: string; connected: boolean } + { + id: string + name: string + description: string + connected: boolean + } >() const sourceTypesByOAuthProvider = new Map>() const oauthManifestByProvider = new Map() @@ -203,13 +208,16 @@ export const load: PageServerLoad = async ({ locals }) => { }) .sort((a, b) => a.displayName.localeCompare(b.displayName)) - availableIntegrations = Array.from(integrationMap.values()).sort((a, b) => { - const idxA = CONNECTOR_DISPLAY_ORDER.indexOf(a.id) - const idxB = CONNECTOR_DISPLAY_ORDER.indexOf(b.id) - const orderA = idxA === -1 ? CONNECTOR_DISPLAY_ORDER.length : idxA - const orderB = idxB === -1 ? CONNECTOR_DISPLAY_ORDER.length : idxB - return orderA !== orderB ? orderA - orderB : a.id.localeCompare(b.id) - }) + availableIntegrations = Array.from(integrationMap.values()) + // Windshift is a personal OAuth source. Users connect it under My Integrations. + .filter((integration) => integration.id !== 'windshift') + .sort((a, b) => { + const idxA = CONNECTOR_DISPLAY_ORDER.indexOf(a.id) + const idxB = CONNECTOR_DISPLAY_ORDER.indexOf(b.id) + const orderA = idxA === -1 ? CONNECTOR_DISPLAY_ORDER.length : idxA + const orderB = idxB === -1 ? CONNECTOR_DISPLAY_ORDER.length : idxB + return orderA !== orderB ? orderA - orderB : a.id.localeCompare(b.id) + }) } } catch (error) { locals.logger.error('Failed to fetch connector manager data', error) diff --git a/web/src/routes/(admin)/admin/settings/integrations/+page.svelte b/web/src/routes/(admin)/admin/settings/integrations/+page.svelte index 2699a401d..6b38a6a11 100644 --- a/web/src/routes/(admin)/admin/settings/integrations/+page.svelte +++ b/web/src/routes/(admin)/admin/settings/integrations/+page.svelte @@ -290,7 +290,7 @@ {#if data.connectedSources.length > 0}
- {#each data.connectedSources as source} + {#each data.connectedSources as source (source.id)} {@const noun = getSourceNoun(source.sourceType as SourceType)} {@const sync = latestSyncRuns.get(source.id)} {@const health = sourceHealth.get(source.id)} @@ -498,7 +498,7 @@
- {#each data.availableIntegrations as integration} + {#each data.availableIntegrations as integration (integration.id)} @@ -575,7 +575,7 @@
Last updated
- {#each data.oauthProviders as provider} + {#each data.oauthProviders as provider (provider.provider)}
diff --git a/web/src/routes/(app)/settings/integrations/+page.server.ts b/web/src/routes/(app)/settings/integrations/+page.server.ts index dd6c9dce0..53589f2a5 100644 --- a/web/src/routes/(app)/settings/integrations/+page.server.ts +++ b/web/src/routes/(app)/settings/integrations/+page.server.ts @@ -5,6 +5,11 @@ import { sources, documents } from '$lib/server/db/schema' import { eq, and, count, inArray } from 'drizzle-orm' import { updateSourceById } from '$lib/server/db/sources' import { sourcesRepository } from '$lib/server/repositories/sources' +import { + getOAuthManifestForSourceType, + oauthServiceBaseUrl, +} from '$lib/server/oauth/connectorOAuth' +import { SourceType } from '$lib/types' import type { PageServerLoad, Actions } from './$types' export const load: PageServerLoad = async ({ locals }) => { @@ -14,6 +19,14 @@ export const load: PageServerLoad = async ({ locals }) => { const googleConnectorConfig = await getConnectorConfigPublic('google') + let windshiftBaseUrl: string | null = null + try { + const oauth = await getOAuthManifestForSourceType(SourceType.WINDSHIFT) + if (oauth?.auth_endpoint) windshiftBaseUrl = oauthServiceBaseUrl(oauth.auth_endpoint) + } catch (err) { + locals.logger.warn('Failed to load the Windshift connector URL', err) + } + const userSources = await sourcesRepository.getByUserId(locals.user.id) // Load sync status and document counts for personal sources owned by this user @@ -39,6 +52,7 @@ export const load: PageServerLoad = async ({ locals }) => { googleOAuthConfigured: !!( googleConnectorConfig && googleConnectorConfig.config.oauth_client_id ), + windshiftBaseUrl, userSources, latestSyncRuns, documentCounts, diff --git a/web/src/routes/(app)/settings/integrations/+page.svelte b/web/src/routes/(app)/settings/integrations/+page.svelte index 52ba6ec6d..7950b4db7 100644 --- a/web/src/routes/(app)/settings/integrations/+page.svelte +++ b/web/src/routes/(app)/settings/integrations/+page.svelte @@ -12,8 +12,10 @@ import * as AlertDialog from '$lib/components/ui/alert-dialog' import type { PageProps } from './$types' import googleLogo from '$lib/images/icons/google.svg' + import windshiftLogo from '$lib/images/icons/windshift.png' import { Globe, HardDrive, Mail, Trash2 } from '@lucide/svelte' import GoogleOAuthSetup from '$lib/components/google-oauth-setup.svelte' + import WindshiftConnectorSetup from '$lib/components/windshift-connector-setup.svelte' import { getSourceIconPath } from '$lib/utils/icons' import { formatDate, getSourceNoun } from '$lib/utils/sources' import { SourceType } from '$lib/types' @@ -117,10 +119,12 @@ } let showGoogleOAuthSetup = $state(false) + let showWindshiftSetup = $state(false) let hasGoogleDrive = $derived(data.userSources.some((s) => s.sourceType === 'google_drive')) let hasGmail = $derived(data.userSources.some((s) => s.sourceType === 'gmail')) let hasAllGoogleSources = $derived(hasGoogleDrive && hasGmail) + let hasWindshift = $derived(data.userSources.some((s) => s.sourceType === 'windshift')) function handleGoogleOAuthSetupSuccess() { showGoogleOAuthSetup = false @@ -258,7 +262,7 @@ - {#if data.googleOAuthConfigured} + {#if data.googleOAuthConfigured || data.windshiftBaseUrl}

Available Integrations

@@ -266,41 +270,84 @@
- - - -
- Google -
- Google - {#if hasAllGoogleSources} - - Connected - - {/if} -
-
- -

- Connect your own Google Drive and Gmail with read-only access. -

-
- {#if !hasAllGoogleSources} - - - - {/if} -
+ {#if data.googleOAuthConfigured} + + + +
+ Google +
+ Google + {#if hasAllGoogleSources} + + Connected + + {/if} +
+
+ +

+ Connect your own Google Drive and Gmail with read-only access. +

+
+ {#if !hasAllGoogleSources} + + + + {/if} +
+ {/if} + + {#if data.windshiftBaseUrl} + + + +
+ Windshift +
+ Windshift + {#if hasWindshift} + + Connected + + {/if} +
+
+ +

+ Index work items from the Windshift workspaces you can access. +

+ + {data.windshiftBaseUrl} + +
+ {#if !hasWindshift} + + + + {/if} +
+ {/if}
{:else if data.userSources.length === 0} @@ -319,6 +366,11 @@ onSuccess={handleGoogleOAuthSetupSuccess} onCancel={() => (showGoogleOAuthSetup = false)} /> + (showWindshiftSetup = false)} /> + { diff --git a/web/src/routes/api/oauth/callback/+server.ts b/web/src/routes/api/oauth/callback/+server.ts index ea102c1be..e1566e2cd 100644 --- a/web/src/routes/api/oauth/callback/+server.ts +++ b/web/src/routes/api/oauth/callback/+server.ts @@ -115,6 +115,8 @@ export const GET: RequestHandler = async ({ url, locals, fetch }) => { client_id: clientCreds.clientId, ...(clientCreds.clientSecret ? { client_secret: clientCreds.clientSecret } : {}), token_uri: clientCreds.tokenEndpoint ?? config.token_endpoint, + token_endpoint_auth_method: clientCreds.tokenEndpointAuthMethod, + ...(config.resource ? { resource: config.resource } : {}), }) const notifyOAuthCredentialReady = async (sourceId: string, userId?: string) => { @@ -232,6 +234,7 @@ export const GET: RequestHandler = async ({ url, locals, fetch }) => { // connect_source flow: for each requested source_type, create or refresh this // user's personal source. Org-level sources are managed separately under // /admin/settings/integrations. + const connectedSourceIds: string[] = [] for (const sourceType of flow.sourceTypes) { const sourcesOfType = await getSourcesByType(sourceType) const existing = sourcesOfType.find((s) => s.scope === 'user' && s.createdBy === user.id) @@ -258,6 +261,7 @@ export const GET: RequestHandler = async ({ url, locals, fetch }) => { config: { granted_scopes: effectiveGrantedScopes }, expiresAt, }) + connectedSourceIds.push(existing.id) continue } @@ -284,9 +288,32 @@ export const GET: RequestHandler = async ({ url, locals, fetch }) => { config: { granted_scopes: effectiveGrantedScopes }, expiresAt, }) + connectedSourceIds.push(newSource.id) logger.info(`Created personal source ${newSource.id} (${sourceType}) for user ${user.id}`) } + const connectorManagerUrl = getConfig().services.connectorManagerUrl + for (const sourceId of connectedSourceIds) { + await notifyOAuthCredentialReady(sourceId, user.id) + try { + const syncResponse = await fetch(`${connectorManagerUrl}/sync/${sourceId}`, { + method: 'POST', + }) + if (!syncResponse.ok && syncResponse.status !== 409) { + logger.warn('Failed to trigger personal source sync after OAuth', { + sourceId, + status: syncResponse.status, + body: await syncResponse.text(), + }) + } + } catch (syncError) { + logger.warn('Failed to trigger personal source sync after OAuth', { + sourceId, + syncError, + }) + } + } + throw redirect(302, flow.returnTo ?? '/settings/integrations?success=connected') } diff --git a/web/src/routes/api/oauth/start/+server.ts b/web/src/routes/api/oauth/start/+server.ts index dbf5fc536..1f7dda1eb 100644 --- a/web/src/routes/api/oauth/start/+server.ts +++ b/web/src/routes/api/oauth/start/+server.ts @@ -35,6 +35,10 @@ export const GET: RequestHandler = async ({ url, locals }) => { const returnTo = url.searchParams.get('return_to') ?? undefined const approvalId = url.searchParams.get('approval_id') ?? undefined const approvalChatId = url.searchParams.get('chat_id') ?? undefined + const requiredScopes = (url.searchParams.get('required_scopes') ?? '') + .split(',') + .map((scope) => scope.trim()) + .filter(Boolean) if (sourceId) { if (flow !== 'org_source' && flow !== 'user_read' && flow !== 'user_write') { @@ -93,16 +97,23 @@ export const GET: RequestHandler = async ({ url, locals }) => { throw redirect(302, authUrl) } - const generator = - flow === 'user_read' ? generateAuthUrlForUserRead : generateAuthUrlForUserWrite - const { url: authUrl } = await generator({ - sourceId, - sourceType: source.sourceType, - userId: locals.user.id, - returnTo, - approvalId, - approvalChatId, - }) + const { url: authUrl } = + flow === 'user_read' + ? await generateAuthUrlForUserRead({ + sourceId, + sourceType: source.sourceType, + userId: locals.user.id, + returnTo, + }) + : await generateAuthUrlForUserWrite({ + sourceId, + sourceType: source.sourceType, + userId: locals.user.id, + returnTo, + approvalId, + approvalChatId, + requiredScopes, + }) throw redirect(302, authUrl) }