MCP connectors#
A connector is an upstream service the platform can reach on an agent's behalf. Register the MCP server your team already runs — Slack, Jira, an internal service — and its tools become available to agents, one allowlisted tool at a time.
Agents never talk to the upstream directly. That indirection is what makes tool-level control and credential isolation possible.
Register a connector#
curl -fsS -X POST \
-H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
-H "Content-Type: application/json" \
https://agents.example.com/v1/projects/$PROJECT_ID/mcp-servers \
-d '{
"name": "jira",
"displayName": "Jira",
"url": "https://mcp.internal.example.com/jira",
"transport": "streamable-http"
}'
Register an MCP connector called jira in the production project,
pointing at https://mcp.internal.example.com/jira over
streamable-http, then tell me which tools it discovered.
nexusctl connector create --project production \
--name jira \
--display-name "Jira" \
--url https://mcp.internal.example.com/jira \
--transport streamable-http
| Flag | Notes |
|---|---|
--name |
Lowercase slug, unique in the project. Becomes the tool namespace and the CLI name inside sandboxes. |
--transport |
streamable-http (default) or sse. |
--cluster |
Route through a cluster tunnel instead of dialling directly. |
--defer-discovery |
Skip the initial probe. Use when the upstream needs a credential attached first. |
On creation the platform probes the upstream and records its tool list.
Discovery and sync#
curl -fsS -X POST -H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
https://agents.example.com/v1/mcp-servers/$CONNECTOR_ID/sync
curl -fsS -H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
https://agents.example.com/v1/mcp-servers/$CONNECTOR_ID
Sync the jira connector in production and tell me which tools are
new since the last sync.
nexusctl connector sync jira --project production
nexusctl connector describe jira --project production
sync re-probes the upstream and refreshes the tool list. describe shows the connector's identity, status, transport, discovered tools, and attached credentials in one view.
New tools appearing upstream do not become available automatically — a policy has to name them. An upstream that grows a delete_everything tool grows it in your catalog, not in your agents' hands.
Control which discovered tools are even listed:
ENTRY=jira__delete_issue
curl -fsS -X PATCH \
-H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
-H "Content-Type: application/json" \
"https://agents.example.com/v1/mcp-servers/$CONNECTOR_ID/entries/$ENTRY/visibility" \
-d '{"visible": false}'
Hide the jira__delete_issue tool on the jira connector so it is
not listed at all.
Tool names are namespaced by the connector slug — jira__create_issue — so two connectors offering search do not collide.
Credentials#
Upstreams that need authentication get a connector credential. Three kinds:
authType |
How it works | Use for |
|---|---|---|
static |
A bearer token you supply, encrypted at rest. | Internal services with a long-lived token. |
oauth-client-credentials |
The platform exchanges a client id and secret for a bearer at create time, and refreshes on expiry. | Machine-to-machine OAuth. |
oauth-authorization-code |
Each actor authorizes through their own consent flow; tokens are stored per actor. | Services where actions must be attributable to a person. |
curl -fsS -X POST \
-H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
-H "Content-Type: application/json" \
https://agents.example.com/v1/projects/$PROJECT_ID/mcp-servers/$CONNECTOR_ID/credentials \
-d '{
"name": "jira-bot",
"authType": "oauth-client-credentials",
"tokenUrl": "https://auth.example.com/oauth/token",
"clientId": "...",
"clientSecret": "...",
"scope": "read:issues write:issues"
}'
An agent can do the same — "add an OAuth client-credentials credential called jira-bot to the jira connector" — but the client secret then lives in its conversation history. Use the API for production secrets.
For oauth-authorization-code, tokenUrl and clientId are optional — the platform discovers the endpoints, and falls back to dynamic client registration when no client id is given. Where the operator has configured the client for the whole install, the credential names that application instead. See OAuth applications for every organization.
Per-actor tokens and delegation#
With oauth-authorization-code, each actor authorizes once and their own token is used for their calls, so the upstream's audit log names the right person.
When a caller has no grant of their own, the platform falls back to the most recently authorized grant on the credential. One administrator authorizing once is therefore enough to delegate access to every actor a policy binds — convenient, and worth knowing before you rely on per-actor attribution upstream.
Change the saved scope#
Update an OAuth credential's scope in place. The value you send for scope decides the result:
scope in the request |
Result |
|---|---|
| Omitted | The saved scope is kept. |
| A space-separated string | The saved scope is replaced. |
"" |
An empty scope is requested explicitly, and later updates keep it. |
null |
The saved scope is removed, so authorization uses the provider's defaults. |
curl -fsS -X PATCH \
-H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
-H "Content-Type: application/json" \
https://agents.example.com/v1/projects/$PROJECT_ID/mcp-servers/$CONNECTOR_ID/credentials/$CREDENTIAL_ID \
-d '{"scope": null}'
null clears to the provider's defaults, not to the defaultScopes of a preconfigured application. A static credential refuses scope with 400.
Allow extra redirect URIs#
An oauth-authorization-code sign-in returns to the platform's own callback, <config.publicUrl>/v1/mcp-servers/oauth/callback, which is always allowed. Where a sign-in must return somewhere else, such as an application of yours, that address must forward the provider's query to the platform's callback, and the operator lists it in the Helm value config.upstreamOAuthAdditionalRedirectUris: at most 10 URIs, each used verbatim, because providers compare redirect URIs exactly. A project admin then allows any of them per credential, with additionalRedirectUris on create or update, or under Redirect URIs in the admin UI. The caller starting a sign-in picks one with redirectUri in the body of POST /v1/projects/{projectId}/mcp-servers/{serverId}/credentials/{credentialId}/oauth/start, and only a URI that both the install and the credential allow is accepted; anything else is refused with 400. Register every allowed URI with the OAuth client as well as the platform's own callback.
OAuth applications for every organization#
An operator can register an OAuth Login client once for the whole install, and every existing and future organization can use it. A project admin then picks the application for a connector and supplies no client id, secret, or token endpoint. The credential stores a reference to the application, never its client id or secret.
This applies to MCP connectors and HTTP connectors alike. It is separate from the platform's own sign-in.
Write the catalog#
The catalog is one JSON document:
{
"version": 1,
"applications": [
{
"id": "example-login",
"displayName": "Example",
"authorizationServerUrl": "https://identity.example.com",
"tokenUrl": "https://identity.example.com/oauth/token",
"clientId": "<CLIENT-ID>",
"clientSecret": "<CLIENT-SECRET>",
"upstreamUrls": ["https://tools.example.com/mcp"],
"defaultScopes": "read",
"prompt": "select_account consent"
}
]
}
| Field | Required | Description |
|---|---|---|
version |
Yes | Always 1. |
applications |
Yes | The applications. An empty list is valid. |
id |
Yes | Stable key. Lowercase letters, digits, and hyphens, starting with a letter or digit. Unique in the catalog. |
displayName |
Yes | Name shown in the admin UI. |
authorizationServerUrl |
Yes | Authorization server. Must match the one the upstream's OAuth discovery reports. |
tokenUrl |
Yes | Token endpoint. Must equal the discovered token endpoint exactly. |
clientId |
Yes | Client registered with the provider. |
clientSecret |
No | Omit for a public client. |
upstreamUrls |
Yes | At least one exact MCP URL or HTTP base URL the application is approved for. |
defaultScopes |
No | Space-separated scope a new credential requests when it names none. |
prompt |
No | OpenID Connect prompt for the authorization URL. |
accessType |
No | offline or online, sent as the authorization URL's access_type parameter. |
manageAccessUrl |
No | Where a signed-in person chooses what the application may reach, such as a GitHub App's installation page. Display only. |
Every URL must be https, or http on localhost, 127.0.0.1, or [::1] for development, with no userinfo and no fragment. An unknown field fails validation.
upstreamUrls are exact matches against the connector's URL, not wildcards or prefixes: path and query count. An upstream on HTTP still has to publish OAuth discovery metadata, as an MCP upstream does.
prompt takes login, consent, select_account, or none, as distinct values separated by single spaces. none must appear alone. The value replaces any prompt in the discovered authorization URL and is never sent to the token endpoint. Support varies by provider: Google, for example, takes select_account consent. Omit the field to keep the provider's own behaviour.
Set accessType: offline for providers that issue a refresh token only when the authorization URL asks for one with access_type, such as Google. Without a refresh token, a grant stops working when its first access token expires and the person has to sign in again. Such providers often issue a refresh token only on a person's first consent, so pair it with prompt: consent to give people who consented before a refresh token on their next sign-in. access_type is not part of OAuth 2.0: set it only for providers that document it. For providers that read the offline_access scope instead, add that scope to defaultScopes.
Set manageAccessUrl for a provider that asks, separately from sign-in, what an application may reach. A GitHub App reaches only the repositories it is installed on, and signing in does not install it, so point the field at https://github.com/apps/<app-slug>/installations/select_target. The platform never sends it to the provider. It lists it in GET /v1/projects/{projectId}/oauth-applications and as oauthApplicationManageAccessUrl on a credential that names the application, so a client can link to it after sign-in.
Register the platform's callback URL with the provider: your config.publicUrl, including any path prefix, followed by /v1/mcp-servers/oauth/callback.
https://agents.example.com/v1/mcp-servers/oauth/callback
Install the catalog#
Store the document in a Secret and reference it from Helm values:
kubectl create secret generic lens-agents-oauth-applications \
--from-file=applications.json=./applications.json
helm upgrade --install lens-agents oci://ghcr.io/lensapp/lens-agents \
--set oauthApplications.existingSecret=lens-agents-oauth-applications \
...
| Value | Default | Purpose |
|---|---|---|
oauthApplications.existingSecret |
(empty) | Secret holding the catalog. Empty configures no applications. |
oauthApplications.existingSecretKey |
applications.json |
Key within that Secret. |
The chart mounts the key read-only and points NEXUS_OAUTH_APPLICATIONS_FILE at it. Outside Helm, set NEXUS_OAUTH_APPLICATIONS_FILE to the file's path, or inject the whole document through NEXUS_OAUTH_APPLICATIONS_JSON from your secret manager. Set exactly one: setting both, even with one empty, stops the server from starting. The file mount keeps the client secrets out of the process environment, which child processes inherit.
The catalog is read once, at startup, before the server serves requests. An empty value, an unreadable file, invalid JSON, or an invalid catalog stops the server from starting. There is no live reload and no API that edits the catalog.
Use an application on a connector#
To use an application from the admin UI:
- On the connector, click Add credential.
- In Authentication, select OAuth (Login) -
<DISPLAY-NAME>. An application is listed only when the connector's URL is in itsupstreamUrls. - Each actor authorizes as with any
oauth-authorization-codecredential. See Per-actor tokens and delegation.
From the API, list the applications a project can use. The response carries each application's id, displayName, upstreamUrls, and defaultScopes — never a client id or secret. It needs project admin. The list_oauth_applications platform tool returns the same list.
curl -fsS -H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
https://agents.example.com/v1/projects/$PROJECT_ID/oauth-applications
Then create the credential with oauthApplicationId:
curl -fsS -X POST \
-H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
-H "Content-Type: application/json" \
https://agents.example.com/v1/projects/$PROJECT_ID/mcp-servers/$CONNECTOR_ID/credentials \
-d '{
"name": "example-login",
"authType": "oauth-authorization-code",
"oauthApplicationId": "example-login"
}'
oauthApplicationId requires oauth-authorization-code and refuses tokenUrl, clientId, clientSecret, and staticValue with 400. Omit scope to take the application's defaultScopes, which the credential keeps as its own scope. Send "" to request none.
The application reference is fixed for the life of the credential. An update cannot change or clear it, or override its client configuration: create a new credential instead.
Change or remove an application#
Restart every replica after any edit to the catalog, prompt and accessType included. Updating the Secret does not reload the process:
kubectl rollout restart deployment/lens-agents
kubectl rollout status deployment/lens-agents
During a rolling restart, old replicas keep the old catalog. A malformed catalog stops the new replicas from starting and leaves the old ones as they were.
| Change | Effect on linked credentials |
|---|---|
Rotate clientSecret |
None. No new consent is needed. |
Change prompt or accessType |
Applies to the next authorization. Grants stay valid. |
Change manageAccessUrl |
None. |
Change clientId, authorizationServerUrl, or tokenUrl |
Blocked. Give the application a new id and create new credentials. |
| Remove the application, or the approved URL the connector uses | Blocked, even while their access tokens are unexpired. |
| Remove the catalog source | Every credential that names an application is blocked. |
A blocked credential lists with oauthApplicationAvailable: false and the reason in oauthApplicationError. The same happens when the connector's own URL changes to one the application does not approve.
Granting tools to agents#
Registering a connector makes it available. A policy grants it, and must name the tools:
name: jira-readonly
connectors:
- connectorId: 123e4567-e89b-12d3-a456-426614174000
allowedTools:
- jira__search_issues
- jira__get_issue
credentialId: 223e4567-e89b-12d3-a456-426614174000
allowedTools is required and has no wildcard. An empty array denies every tool in that reference. Administrators choose tools explicitly — there is no "grant everything" shortcut, deliberately.
Reaching services behind a firewall#
An MCP server that only exists inside a registered cluster is reachable over the same reverse tunnel:
Register http://mcp-tools.internal.svc.cluster.local:8080 as a
connector called internal-tools in production, reached through the
prod-eu cluster.
nexusctl connector create --project production \
--name internal-tools \
--display-name "Internal Tools" \
--url http://mcp-tools.internal.svc.cluster.local:8080 \
--cluster prod-eu
Setting --cluster switches the connector's transport mode to tunnel. No public endpoint and no inbound firewall rule.
What agents see#
An agent connected to the MCP endpoint sees the allowlisted tools of every connector its policies grant, alongside the platform's own tools. Inside a sandbox, each connector also appears as a CLI.
Tools a policy does not grant are not listed at all, so an agent cannot discover its way to them.
Related#
- HTTP APIs — turning an OpenAPI document into governed tools
- MCP endpoint — how agents consume these tools
- Policies — granting connectors and tools
- Audit trail — every tool call, allowed and denied