feat: neuron-connectd — MCP connector bridge (Accessor sidecar)
The sidecar that isolates all MCP wire complexity from the soul. Binds
loopback 127.0.0.1:7771 only. The soul reaches it over flat HTTP; the bridge
owns stdio/streamable-HTTP transports, OAuth (PKCE), Keychain secrets, server
lifecycle, config, and a tool-schema-hash poisoning guard.
HTTP contract: GET /mcp/tools, /mcp/servers, /mcp/auto-approved, /healthz;
POST /mcp/call, /mcp/oauth/start, /mcp/servers/{add,toggle,auto-approve,
remove,secret}; GET /mcp/oauth/callback.
Config: ~/.neuron/connectors.json (servers, no secrets). Secrets in macOS
Keychain (service ai.neuron.connect, account = serverId). Spec:
docs/research/mcp-connectors-adoption-spec.md.
Phases 1-3 verified end to end (stdio + HTTP transport, Keychain token auth,
OAuth round-trip); Phase 4/5 (CRUD + auto-approve + schema-hash) added for the
ConnectorsView UI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vendored
+78
@@ -0,0 +1,78 @@
|
||||
import { kcGet, kcSet, kcDelete, kcGetJson, kcSetJson } from "./keychain.js";
|
||||
import { HOST, PORT } from "./config.js";
|
||||
// When the SDK needs the user to authorize, it calls redirectToAuthorization()
|
||||
// with the provider URL. A headless bridge can't open a browser itself, so it
|
||||
// stashes the URL here keyed by serverId; POST /mcp/oauth/start returns it for
|
||||
// the UI to open. Cleared once consumed.
|
||||
const pendingAuthUrls = new Map();
|
||||
export function takePendingAuthUrl(serverId) {
|
||||
const u = pendingAuthUrls.get(serverId);
|
||||
pendingAuthUrls.delete(serverId);
|
||||
return u;
|
||||
}
|
||||
// OAuthClientProvider whose entire persistent state lives in the macOS Keychain
|
||||
// (service ai.neuron.connect). One instance per serverId. Survives bridge
|
||||
// restarts: tokens, the dynamic-registration client info, and the in-flight PKCE
|
||||
// verifier are all keyed under <serverId>:*.
|
||||
export class KeychainOAuthProvider {
|
||||
serverId;
|
||||
scope;
|
||||
constructor(serverId, scope) {
|
||||
this.serverId = serverId;
|
||||
this.scope = scope;
|
||||
}
|
||||
get redirectUrl() {
|
||||
return `http://${HOST}:${PORT}/mcp/oauth/callback?id=${encodeURIComponent(this.serverId)}`;
|
||||
}
|
||||
get clientMetadata() {
|
||||
return {
|
||||
client_name: `Neuron (${this.serverId})`,
|
||||
redirect_uris: [this.redirectUrl],
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: "none", // public client + PKCE
|
||||
...(this.scope ? { scope: this.scope } : {}),
|
||||
};
|
||||
}
|
||||
// Carry the serverId through the round-trip so the callback knows which
|
||||
// connector to finish, even across a bridge restart.
|
||||
state() {
|
||||
return this.serverId;
|
||||
}
|
||||
async clientInformation() {
|
||||
return kcGetJson(`${this.serverId}:client`);
|
||||
}
|
||||
async saveClientInformation(info) {
|
||||
await kcSetJson(`${this.serverId}:client`, info);
|
||||
}
|
||||
async tokens() {
|
||||
return kcGetJson(`${this.serverId}:oauth`);
|
||||
}
|
||||
async saveTokens(tokens) {
|
||||
await kcSetJson(`${this.serverId}:oauth`, tokens);
|
||||
}
|
||||
async redirectToAuthorization(url) {
|
||||
pendingAuthUrls.set(this.serverId, url.toString());
|
||||
}
|
||||
async saveCodeVerifier(verifier) {
|
||||
await kcSet(`${this.serverId}:verifier`, verifier);
|
||||
}
|
||||
async codeVerifier() {
|
||||
const v = await kcGet(`${this.serverId}:verifier`);
|
||||
if (!v)
|
||||
throw new Error(`no PKCE verifier stored for ${this.serverId}`);
|
||||
return v;
|
||||
}
|
||||
async invalidateCredentials(scope) {
|
||||
if (scope === "all" || scope === "tokens")
|
||||
await kcDelete(`${this.serverId}:oauth`);
|
||||
if (scope === "all" || scope === "client")
|
||||
await kcDelete(`${this.serverId}:client`);
|
||||
if (scope === "all" || scope === "verifier")
|
||||
await kcDelete(`${this.serverId}:verifier`);
|
||||
}
|
||||
// True once we hold an access token (used for status reporting).
|
||||
async hasTokens() {
|
||||
return (await this.tokens()) !== undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user