From a93c25973b957948d8342f60ff140a9df444b345 Mon Sep 17 00:00:00 2001 From: Chris Were Date: Wed, 5 Aug 2026 15:19:31 +0930 Subject: [PATCH] Add BlueNexus integration: 200+ connections via single OAuth grant --- cli/src/commands/setup.ts | 1 + cli/src/secrets.ts | 6 ++ deploy/stacks/acme/.env.example | 4 + plugins/web-ui/src/connector-link.ts | 1 + plugins/web-ui/src/connectors.ts | 5 + skills-seed/bluenexus-connections/SKILL.md | 107 +++++++++++++++++++++ src/connectors/oauth.ts | 24 +++++ src/credentials/connector-status.ts | 1 + src/deployment/secret-schema.ts | 3 + test/connector-byo-route.test.ts | 2 +- test/oauth.test.ts | 26 ++++- 11 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 skills-seed/bluenexus-connections/SKILL.md diff --git a/cli/src/commands/setup.ts b/cli/src/commands/setup.ts index c475f3a2..2fb426c0 100644 --- a/cli/src/commands/setup.ts +++ b/cli/src/commands/setup.ts @@ -111,6 +111,7 @@ const PLAYBOOKS: Readonly> = { GOOGLE_OAUTH_CLIENT_SECRET: ["From your Google Cloud OAuth client (APIs & Services -> Credentials)."], DROPBOX_OAUTH_CLIENT_SECRET: ["From your Dropbox app console (https://www.dropbox.com/developers/apps)."], LINEAR_OAUTH_CLIENT_SECRET: ["From your Linear OAuth application settings."], + BLUENEXUS_OAUTH_CLIENT_SECRET: ["From your BlueNexus OAuth client (Developer -> My Apps)."], }; const FORMAT_HINTS: Readonly> = { diff --git a/cli/src/secrets.ts b/cli/src/secrets.ts index ed061840..1af6d1ff 100644 --- a/cli/src/secrets.ts +++ b/cli/src/secrets.ts @@ -163,6 +163,12 @@ export const FIRST_PARTY_SECRET_SPECS: readonly SecretSpec[] = [ required: { when: { kind: "env-present", service: "core", name: "LINEAR_OAUTH_CLIENT_ID" } }, description: "Linear OAuth client secret.", }, + { + name: "BLUENEXUS_OAUTH_CLIENT_SECRET", + service: "core", + required: { when: { kind: "env-present", service: "core", name: "BLUENEXUS_OAUTH_CLIENT_ID" } }, + description: "BlueNexus OAuth client secret.", + }, { name: "SLACK_BOT_TOKEN", service: "slack", diff --git a/deploy/stacks/acme/.env.example b/deploy/stacks/acme/.env.example index c58163de..5788fc56 100644 --- a/deploy/stacks/acme/.env.example +++ b/deploy/stacks/acme/.env.example @@ -77,6 +77,10 @@ SKILL_SIGNING_SECRET= # Needed when env.core.LINEAR_OAUTH_CLIENT_ID is set. # LINEAR_OAUTH_CLIENT_SECRET= +# BlueNexus OAuth client secret. (core) +# Needed when env.core.BLUENEXUS_OAUTH_CLIENT_ID is set. +# BLUENEXUS_OAUTH_CLIENT_SECRET= + # Slack request-signing secret (from the Slack app's Basic Information page); HTTP events mode only. (slack) # Needed when env.slack.SLACK_EVENTS_MODE is "http". # SLACK_SIGNING_SECRET= diff --git a/plugins/web-ui/src/connector-link.ts b/plugins/web-ui/src/connector-link.ts index 5a9965cd..dcd235d5 100644 --- a/plugins/web-ui/src/connector-link.ts +++ b/plugins/web-ui/src/connector-link.ts @@ -9,6 +9,7 @@ export const CONNECTOR_NAMES: Record = { github: "GitHub", dropbox: "Dropbox", x: "X", + bluenexus: "BlueNexus", }; export interface ConnectorLink { diff --git a/plugins/web-ui/src/connectors.ts b/plugins/web-ui/src/connectors.ts index b4d0f40f..fd232a34 100644 --- a/plugins/web-ui/src/connectors.ts +++ b/plugins/web-ui/src/connectors.ts @@ -51,6 +51,11 @@ const CONNECTOR_LABELS: Record = { diff --git a/skills-seed/bluenexus-connections/SKILL.md b/skills-seed/bluenexus-connections/SKILL.md new file mode 100644 index 00000000..64181218 --- /dev/null +++ b/skills-seed/bluenexus-connections/SKILL.md @@ -0,0 +1,107 @@ +--- +name: bluenexus-connections +description: Reach the user's third-party services (Slack, Notion, GitHub, Google Workspace, Telegram, and more) through their connected BlueNexus account. +--- + +## BlueNexus connected services + +BlueNexus fronts every service the user has connected to it. One connection, many +services — you do not need a separate credential per service. + +This is an OAuth connector. The user's BlueNexus token already lives on your computer as +an environment variable, the way a logged-in CLI's cached credential would: + +- `$VAULT_TOKEN_BLUENEXUS_AI` — for `bluenexus.ai` and its subdomains + +If that variable is empty, the user has not connected BlueNexus. Tell them to connect it +on the Keychain page. Do not ask them for a token, log it, or use another principal's +credential. + +## How to call it + +The endpoint is **`https://api.bluenexus.ai/mcp`** and it speaks **JSON-RPC 2.0 over +POST**. It is stateless — no session, no handshake, every call is self-contained. + +Send `Accept: application/json` and you get a single JSON body back. If you send +`text/event-stream` you will get SSE instead and have to parse `data:` lines, so don't. + +List what the current grant can reach: + +```bash +curl -sS -X POST https://api.bluenexus.ai/mcp \ + -H "Authorization: Bearer $VAULT_TOKEN_BLUENEXUS_AI" \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +Call a tool: + +```bash +curl -sS -X POST https://api.bluenexus.ai/mcp \ + -H "Authorization: Bearer $VAULT_TOKEN_BLUENEXUS_AI" \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call", + "params":{"name":"","arguments":{...}}}' +``` + +The reply is JSON-RPC. The useful text is in `result.content[].text`; `result._meta` +sometimes carries structured extras. A tool that fails returns `result.isError: true` +with the reason in the same text field — check for it rather than assuming success. + +## The tools + +| Tool | Arguments | What it does | +| ----------------------- | ---------------- | --------------------------------------------------------------- | +| `list-connections` | `{}` | Which services are connected, and as which account. Start here. | +| `read-connections` | `{"prompt":"…"}` | Read-only task across the connected services. | +| `write-connections` | `{"prompt":"…"}` | Same, but permitted to make changes. | +| `search-knowledge-base` | `{"query":"…"}` | Search the user's BlueNexus knowledge base. | +| `add-to-knowledge-base` | `{…}` | Add to it. | +| `poll-agent-result` | `{"jobId":"…"}` | Collect a deferred long-running result. | + +`tools/list` is the source of truth for what this grant can actually do. If it returns +only four tools, the connection was made read-only: BlueNexus filters the list by scope, +so `write-connections` and `add-to-knowledge-base` are genuinely absent rather than merely +refused. In that case say the connection is read-only and the user can re-grant with write +access on the Keychain page — do not keep retrying the missing tool. + +## Describe the goal, not the API + +`read-connections` and `write-connections` do not take an endpoint or a method. They take +an instruction in plain words, and a BlueNexus agent picks the tools on the other side — +GitHub alone exposes 88, Notion 40. So write: + +```json +{ "prompt": "Summarise the unread Slack DMs I got this week" } +``` + +not a description of Slack's API. Naming a specific service, account, or time window +helps it choose well. Asking for something enormous ("audit every message in every +channel") makes it exhaust its reasoning budget and return an apology — split those up. + +Run `list-connections` first when you are not sure a service is actually connected. +Assuming it is and getting a confusing answer wastes a turn. + +## Long-running tasks + +A call that runs past about 50 seconds does not hang or fail. It returns text like: + +``` +Still working on your request. Call the `poll-agent-result` tool with jobId="…" +``` + +That is a normal outcome, not an error. Take the `jobId`, wait a little, then call +`poll-agent-result` with it. Broad reads and multi-step writes routinely go this way. + +## Care + +- `write-connections` acts on the user's real accounts — it sends messages, creates + issues, publishes posts. Confirm intent before calling it, and say plainly what you are + about to do. `read-connections` is safe to use freely. +- Rate limit is 30 requests per minute. Back off on `429` rather than retrying hard. +- Agent runs consume the user's credits; a failure mentioning credits means their balance + is out, not that you called it wrong. +- On `401`, the token has expired and core will refresh it on the next turn — say so + rather than trying to re-authenticate. You cannot log in from here. diff --git a/src/connectors/oauth.ts b/src/connectors/oauth.ts index db793959..e5877a6c 100644 --- a/src/connectors/oauth.ts +++ b/src/connectors/oauth.ts @@ -213,6 +213,30 @@ const notionExchange: OAuthExchangeAdapter = async ({ provider, client, code, re }; export const PROVIDERS: Record = { + bluenexus: { + hosts: ["bluenexus.ai"], + authUrl: "https://app.bluenexus.ai/oauth/authorize", + tokenUrl: "https://api.bluenexus.ai/api/v1/auth/token", + scopes: ["universal-mcp-read-write"], + clientIdEnv: "BLUENEXUS_OAUTH_CLIENT_ID", + clientSecretEnv: "BLUENEXUS_OAUTH_CLIENT_SECRET", + redirectPath: "bluenexus/callback", + consentMode: "standard", + egressRule: ["api.bluenexus.ai", "app.bluenexus.ai"], + pkce: true, + setupGuide: { + console: "BlueNexus → Developer → My Apps → Add App", + url: "https://app.bluenexus.ai/developer/clients", + steps: [ + "Create an OAuth client in Third-Party Integration mode.", + "Add the redirect URI shown below to that client.", + "Paste the Client ID + Client secret below.", + "Clients can also be registered over the API — the registration_endpoint in https://api.bluenexus.ai/.well-known/oauth-authorization-server accepts RFC 7591 requests.", + ], + scopesRationale: + "universal-mcp-read-write exposes all six Universal MCP tools. The server filters tools/list by scope, so universal-mcp-read yields only the four read tools and cannot write to connected services.", + }, + }, google: { hosts: [ "gmail.googleapis.com", diff --git a/src/credentials/connector-status.ts b/src/credentials/connector-status.ts index 5effbeaa..7ac137d7 100644 --- a/src/credentials/connector-status.ts +++ b/src/credentials/connector-status.ts @@ -100,6 +100,7 @@ const PROVIDER_LABELS: Record = { linear: "Linear", github: "GitHub", dropbox: "Dropbox", + bluenexus: "BlueNexus", }; export function connectorLabel(name: string): string { diff --git a/src/deployment/secret-schema.ts b/src/deployment/secret-schema.ts index 47e2e86f..fb5e9d5a 100644 --- a/src/deployment/secret-schema.ts +++ b/src/deployment/secret-schema.ts @@ -11,6 +11,7 @@ type SecretGate = | "google-oauth" | "dropbox-oauth" | "linear-oauth" + | "bluenexus-oauth" | "model-anthropic" | "model-openai" | "model-openrouter"; @@ -37,6 +38,7 @@ export const CORE_SECRET_SPECS: readonly RuntimeSecretSpec[] = [ { name: "GOOGLE_OAUTH_CLIENT_SECRET", requiredWhen: "google-oauth" }, { name: "DROPBOX_OAUTH_CLIENT_SECRET", requiredWhen: "dropbox-oauth" }, { name: "LINEAR_OAUTH_CLIENT_SECRET", requiredWhen: "linear-oauth" }, + { name: "BLUENEXUS_OAUTH_CLIENT_SECRET", requiredWhen: "bluenexus-oauth" }, ]; const GATE_PREDICATES: Readonly boolean>> = { @@ -50,6 +52,7 @@ const GATE_PREDICATES: Readonly b "google-oauth": (env) => Boolean(env.GOOGLE_OAUTH_CLIENT_ID), "dropbox-oauth": (env) => Boolean(env.DROPBOX_OAUTH_CLIENT_ID), "linear-oauth": (env) => Boolean(env.LINEAR_OAUTH_CLIENT_ID), + "bluenexus-oauth": (env) => Boolean(env.BLUENEXUS_OAUTH_CLIENT_ID), "model-anthropic": (env) => env.MODEL_PROVIDER?.trim() === "anthropic", "model-openai": (env) => env.MODEL_PROVIDER?.trim() === "openai", "model-openrouter": (env) => env.MODEL_PROVIDER?.trim() === "openrouter", diff --git a/test/connector-byo-route.test.ts b/test/connector-byo-route.test.ts index 62660545..e7fcd048 100644 --- a/test/connector-byo-route.test.ts +++ b/test/connector-byo-route.test.ts @@ -169,7 +169,7 @@ test("the catalog endpoint exposes per-provider setup guidance (no secrets)", as catalog: Array<{ provider: string; setupGuide: { url: string; steps: string[] }; consentMode: string }>; }; const names = catalog.map((c) => c.provider).sort(); - assert.deepEqual(names, ["dropbox", "github", "google", "linear", "notion", "slack", "x"]); + assert.deepEqual(names, ["bluenexus", "dropbox", "github", "google", "linear", "notion", "slack", "x"]); const google = catalog.find((c) => c.provider === "google")!; assert.ok(google.setupGuide.steps.length >= 3); assert.match(google.setupGuide.url, /^https:\/\//); diff --git a/test/oauth.test.ts b/test/oauth.test.ts index de0c8b92..43a04c70 100644 --- a/test/oauth.test.ts +++ b/test/oauth.test.ts @@ -27,6 +27,8 @@ const env = { GITHUB_OAUTH_CLIENT_SECRET: "ghsecret", X_OAUTH_CLIENT_ID: "xid", X_OAUTH_CLIENT_SECRET: "xsecret", + BLUENEXUS_OAUTH_CLIENT_ID: "bnid", + BLUENEXUS_OAUTH_CLIENT_SECRET: "bnsecret", } as NodeJS.ProcessEnv; const resolve = createSecretClientResolver(createEnvSecretSource(env)); @@ -383,9 +385,10 @@ test("authorizeUrl adds code_challenge + S256 only when a challenge is supplied" assert.equal(without.searchParams.get("code_challenge_method"), null); }); -test("only X opts into PKCE; the other providers leave the seam inert (regression guard)", () => { +test("only X and BlueNexus opt into PKCE; the other providers leave the seam inert (regression guard)", () => { + const pkceProviders = new Set(["x", "bluenexus"]); for (const [name, p] of Object.entries(PROVIDERS)) { - if (name === "x") assert.equal(p.pkce, true, "X requires PKCE"); + if (pkceProviders.has(name)) assert.equal(p.pkce, true, `${name} requires PKCE`); else assert.notEqual(p.pkce, true, `${name} must not enable PKCE`); } }); @@ -417,6 +420,25 @@ test("OAuth state round-trips the PKCE verifier", async () => { assert.equal(opened.codeVerifier, "ver-abc"); }); +const bluenexusClient = (): Promise => resolve("bluenexus", {}); + +test("BlueNexus authorizes on the app host but exchanges on the api host, with PKCE and the read-write MCP scope", async () => { + const u = new URL( + authorizeUrl("bluenexus", { + redirectUri: "https://app/cb", + state: "s", + client: await bluenexusClient(), + codeChallenge: "CH", + }), + ); + assert.equal(u.origin + u.pathname, "https://app.bluenexus.ai/oauth/authorize"); + assert.equal(PROVIDERS.bluenexus!.tokenUrl, "https://api.bluenexus.ai/api/v1/auth/token"); + assert.equal(u.searchParams.get("client_id"), "bnid"); + assert.equal(u.searchParams.get("code_challenge"), "CH"); + assert.equal(u.searchParams.get("code_challenge_method"), "S256"); + assert.equal(u.searchParams.get("scope"), "universal-mcp-read-write"); +}); + const xClient = (): Promise => resolve("x", {}); test("X authorize URL targets x.com with the tweet scopes and offline.access", async () => {