Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ const PLAYBOOKS: Readonly<Record<string, readonly string[]>> = {
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<Record<string, { prefix: string; label: string }>> = {
Expand Down
6 changes: 6 additions & 0 deletions cli/src/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions deploy/stacks/acme/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions plugins/web-ui/src/connector-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const CONNECTOR_NAMES: Record<string, string> = {
github: "GitHub",
dropbox: "Dropbox",
x: "X",
bluenexus: "BlueNexus",
};

export interface ConnectorLink {
Expand Down
5 changes: 5 additions & 0 deletions plugins/web-ui/src/connectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ const CONNECTOR_LABELS: Record<string, { name: string; hosts: string; desc?: str
hosts: "Posts & profile",
desc: "Lets the agent read X and post, like, and follow as you — used when an action should come from your account rather than the org's.",
},
bluenexus: {
name: "BlueNexus",
hosts: "Every service you connected there",
desc: "Lets the agent reach the services you connected to BlueNexus — Slack, Notion, GitHub, Google Workspace, Telegram and more — through that one account, reading and acting on your behalf.",
},
};

const CONNECTOR_LOGOS: Record<string, string> = {
Expand Down
107 changes: 107 additions & 0 deletions skills-seed/bluenexus-connections/SKILL.md
Original file line number Diff line number Diff line change
@@ -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":"<tool>","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.
24 changes: 24 additions & 0 deletions src/connectors/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,30 @@ const notionExchange: OAuthExchangeAdapter = async ({ provider, client, code, re
};

export const PROVIDERS: Record<string, OAuthProviderConfig> = {
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",
Expand Down
1 change: 1 addition & 0 deletions src/credentials/connector-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ const PROVIDER_LABELS: Record<string, string> = {
linear: "Linear",
github: "GitHub",
dropbox: "Dropbox",
bluenexus: "BlueNexus",
};

export function connectorLabel(name: string): string {
Expand Down
3 changes: 3 additions & 0 deletions src/deployment/secret-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type SecretGate =
| "google-oauth"
| "dropbox-oauth"
| "linear-oauth"
| "bluenexus-oauth"
| "model-anthropic"
| "model-openai"
| "model-openrouter";
Expand All @@ -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<Record<SecretGate, (env: NodeJS.ProcessEnv) => boolean>> = {
Expand All @@ -50,6 +52,7 @@ const GATE_PREDICATES: Readonly<Record<SecretGate, (env: NodeJS.ProcessEnv) => 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",
Expand Down
2 changes: 1 addition & 1 deletion test/connector-byo-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:\/\//);
Expand Down
26 changes: 24 additions & 2 deletions test/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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`);
}
});
Expand Down Expand Up @@ -417,6 +420,25 @@ test("OAuth state round-trips the PKCE verifier", async () => {
assert.equal(opened.codeVerifier, "ver-abc");
});

const bluenexusClient = (): Promise<ResolvedClient> => 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<ResolvedClient> => resolve("x", {});

test("X authorize URL targets x.com with the tweet scopes and offline.access", async () => {
Expand Down