@andespindola/brainlink 1.7.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.8.1
|
|
4
|
+
|
|
5
|
+
- **Fix the Connect page's Codex snippet auth.** The `/connect` page's Codex `config.toml` block passed the bearer token via a `MCP_AUTHORIZATION` env var, but `mcp-remote` does not read that env — so the request went out unauthenticated and `mcp-remote` fell back to a hanging OAuth flow. The token is now passed as an `mcp-remote` `--header "Authorization: Bearer <token>"` argument (verified to connect), and both the remote and local Codex snippets gained `startup_timeout_sec = 60` so Codex doesn't time out the cold-start MCP handshake.
|
|
6
|
+
|
|
7
|
+
## 1.8.0
|
|
8
|
+
|
|
9
|
+
- **"Connect your agent" page in the graph UI.** The exposed graph web UI (`brainlink server --expose`) now serves a new auth-gated `GET /connect` page, reachable from the account menu, with ready-to-paste MCP setup for wiring agents and chats (Codex, Claude Code, ChatGPT/generic MCP) to this brainlink. It detects its mode server-side: in **remote** mode — when the server is exposed and `BRAINLINK_MCP_TOKEN` is set — it builds the MCP endpoint from the request host as `https://<host>/mcp` and renders the Codex `~/.codex/config.toml` (`mcp-remote`), Claude Code `claude mcp add --transport http`, `.mcp.json` (streamable HTTP) and ChatGPT/generic connector snippets, each carrying the owner's bearer token; in **local** mode — no token / not exposed — it renders the stdio equivalents (`brainlink mcp-server --vault <path>`) and never shows a token. The page also ships a ready AGENTS.md operating contract, per-snippet copy-to-clipboard buttons, and the shared neon identity. It is served behind the same session as `/settings`, so the token is shown only to the authenticated graph owner and never on any unauthenticated route.
|
|
10
|
+
|
|
3
11
|
## 1.7.0
|
|
4
12
|
|
|
5
13
|
- **Unified neon graph UI.** The graph web frontend now shares one visual identity with the SaaS app so the app → SSO → graph handoff reads as a single product. Every self-contained page served by the graph server — the login and change-password pages, the settings page, the OAuth authorize/consent page, the graph explorer, and the injected account menu — is repainted onto the neon palette: `#0a0e1a` base with a faint cyan/violet cyber grid and soft ambient glow, cyan `#22d3ee` nodes, violet `#8b5cf6` edges/context, glass panels, gradient "Brainlink" wordmark, cyan focus glow, and a neon gradient primary button. Typography moves to Space Grotesk (headings) + DM Sans (body) via a Google Fonts `@import` with a robust system fallback so it degrades gracefully offline, while a monospace stack stays for graph node ids and code. All graph behavior (WebGL/worker/2D-fallback rendering, layout, zoom, pan, search, streaming, context grouping, versioning UI) and every endpoint, form field and OAuth flow are unchanged — this is a visual/identity pass only. Contrast on the new palette meets WCAG AA, focus rings are visible, and motion respects `prefers-reduced-motion`.
|
|
@@ -136,6 +136,10 @@ const startAccountMenu = () => {
|
|
|
136
136
|
button.setAttribute('aria-expanded', 'true')
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
addItem('Connect your agent', () => {
|
|
140
|
+
closeMenu()
|
|
141
|
+
window.location.href = '/connect'
|
|
142
|
+
})
|
|
139
143
|
addItem('Settings', () => {
|
|
140
144
|
closeMenu()
|
|
141
145
|
window.location.href = '/settings'
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
// Self-contained branded "Connect your agent" page for the graph web server.
|
|
2
|
+
// Served as a plain string (inline <style> + inline <script>, no external assets
|
|
3
|
+
// beyond the shared Google Fonts @import, no framework) so it works identically
|
|
4
|
+
// for a global npm install. The visual identity is the unified neon system
|
|
5
|
+
// shared with the auth pages, the settings page and the graph client (see
|
|
6
|
+
// neon-theme.ts): #0a0e1a base, faint cyber grid, cyan #22d3ee + violet #8b5cf6,
|
|
7
|
+
// Space Grotesk headings / DM Sans body, glass card + gradient wordmark.
|
|
8
|
+
//
|
|
9
|
+
// The page renders ready-to-paste MCP setup so a graph owner can wire their
|
|
10
|
+
// agents (Codex, Claude Code, ChatGPT/MCP) to this brainlink. It has two modes,
|
|
11
|
+
// picked server-side:
|
|
12
|
+
// - remote: the server is exposed AND an MCP token is configured. Snippets
|
|
13
|
+
// point at the request host over HTTPS and carry a bearer token.
|
|
14
|
+
// - local: no token / not exposed. Snippets run the local stdio MCP server
|
|
15
|
+
// against the server's configured vault path — never a token.
|
|
16
|
+
// This page is only ever served behind the same session gate as /settings, so
|
|
17
|
+
// the token is shown only to the authenticated graph owner.
|
|
18
|
+
import { neonAmbientEffects, neonFontImport, neonGridBackground, neonRootTokens, neonWordmark } from './neon-theme.js';
|
|
19
|
+
// HTML-escape for text interpolated into the served document. Snippets are
|
|
20
|
+
// rendered as textContent client-side, but the AGENTS.md / labels are inlined,
|
|
21
|
+
// so escape defensively to keep the page well-formed and injection-free.
|
|
22
|
+
const escapeHtml = (value) => value
|
|
23
|
+
.replace(/&/g, '&')
|
|
24
|
+
.replace(/</g, '<')
|
|
25
|
+
.replace(/>/g, '>')
|
|
26
|
+
.replace(/"/g, '"');
|
|
27
|
+
// The compact operating contract shown on the page and offered as a ready
|
|
28
|
+
// AGENTS.md. Neutral/professional, referencing only the agent product names.
|
|
29
|
+
export const connectAgentsMarkdown = () => `# Brainlink — canonical memory
|
|
30
|
+
|
|
31
|
+
Brainlink is your canonical, persistent memory across turns and sessions. Treat
|
|
32
|
+
it as the continuity of your cognition, not an optional lookup.
|
|
33
|
+
|
|
34
|
+
## At session start
|
|
35
|
+
- Run \`brainlink_bootstrap\` with a short \`query\` describing the task. Re-run it
|
|
36
|
+
whenever your context is stale.
|
|
37
|
+
|
|
38
|
+
## Before you act
|
|
39
|
+
- Consult Brainlink before any decision, recommendation, question, or
|
|
40
|
+
implementation. Never act from memory alone when durable context may exist.
|
|
41
|
+
- Retrieve with \`strategy: "auto"\` (CAG on a fresh pack, RAG otherwise) and
|
|
42
|
+
\`mode: "hybrid"\` (FTS + semantic) unless a narrower mode clearly fits.
|
|
43
|
+
|
|
44
|
+
## After you learn
|
|
45
|
+
- Write durable learnings back with \`brainlink_remember\` (or
|
|
46
|
+
\`brainlink_add_note\` for structured notes), then reindex with
|
|
47
|
+
\`brainlink_index\`.
|
|
48
|
+
- Write connected memory: include \`[[wiki links]]\`, tags, and priority markers
|
|
49
|
+
so notes join the knowledge graph instead of sitting orphaned.
|
|
50
|
+
`;
|
|
51
|
+
const connectStyles = () => `${neonFontImport()}
|
|
52
|
+
:root {
|
|
53
|
+
${neonRootTokens()}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
* {
|
|
57
|
+
box-sizing: border-box;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
html,
|
|
61
|
+
body {
|
|
62
|
+
width: 100%;
|
|
63
|
+
min-height: 100%;
|
|
64
|
+
margin: 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
body {
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: flex-start;
|
|
70
|
+
justify-content: center;
|
|
71
|
+
min-height: 100vh;
|
|
72
|
+
min-height: 100dvh;
|
|
73
|
+
padding: 40px 24px;
|
|
74
|
+
color: var(--text);
|
|
75
|
+
font-family: var(--font-body);
|
|
76
|
+
${neonGridBackground()}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
${neonAmbientEffects()}
|
|
80
|
+
|
|
81
|
+
button {
|
|
82
|
+
font: inherit;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
:focus-visible {
|
|
86
|
+
outline: 2px solid var(--accent);
|
|
87
|
+
outline-offset: 2px;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.connect-card {
|
|
91
|
+
width: 100%;
|
|
92
|
+
max-width: 720px;
|
|
93
|
+
padding: 32px 28px;
|
|
94
|
+
border: 1px solid var(--line);
|
|
95
|
+
border-radius: 16px;
|
|
96
|
+
background: var(--panel);
|
|
97
|
+
box-shadow: 0 24px 80px rgba(2, 6, 18, 0.55);
|
|
98
|
+
backdrop-filter: blur(16px);
|
|
99
|
+
animation: neon-fade-up 260ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@media (prefers-reduced-motion: reduce) {
|
|
103
|
+
.connect-card {
|
|
104
|
+
animation: none;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.connect-brand {
|
|
109
|
+
display: grid;
|
|
110
|
+
gap: 4px;
|
|
111
|
+
margin-bottom: 20px;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.connect-brand strong {
|
|
115
|
+
font-family: var(--font-heading);
|
|
116
|
+
font-size: 28px;
|
|
117
|
+
font-weight: 700;
|
|
118
|
+
letter-spacing: -0.01em;
|
|
119
|
+
${neonWordmark()}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.connect-brand span {
|
|
123
|
+
color: var(--muted);
|
|
124
|
+
font-size: 13px;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.connect-lede {
|
|
128
|
+
margin: 0 0 8px;
|
|
129
|
+
color: var(--text);
|
|
130
|
+
font-size: 14px;
|
|
131
|
+
line-height: 1.5;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.connect-mode {
|
|
135
|
+
display: inline-flex;
|
|
136
|
+
align-items: center;
|
|
137
|
+
gap: 8px;
|
|
138
|
+
margin-bottom: 20px;
|
|
139
|
+
padding: 6px 12px;
|
|
140
|
+
border: 1px solid var(--line);
|
|
141
|
+
border-radius: 999px;
|
|
142
|
+
background: rgba(19, 26, 42, 0.55);
|
|
143
|
+
color: var(--muted);
|
|
144
|
+
font-size: 12px;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.connect-mode strong {
|
|
148
|
+
color: var(--accent);
|
|
149
|
+
font-weight: 600;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.connect-endpoint {
|
|
153
|
+
display: grid;
|
|
154
|
+
gap: 6px;
|
|
155
|
+
margin-bottom: 24px;
|
|
156
|
+
padding: 12px 14px;
|
|
157
|
+
border: 1px solid var(--line);
|
|
158
|
+
border-radius: 10px;
|
|
159
|
+
background: rgba(19, 26, 42, 0.55);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.connect-endpoint span {
|
|
163
|
+
color: var(--muted);
|
|
164
|
+
font-size: 11px;
|
|
165
|
+
letter-spacing: 0.02em;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.connect-endpoint code {
|
|
169
|
+
font-family: var(--font-mono);
|
|
170
|
+
font-size: 12px;
|
|
171
|
+
color: var(--accent);
|
|
172
|
+
overflow-wrap: anywhere;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.connect-section {
|
|
176
|
+
margin-top: 26px;
|
|
177
|
+
padding-top: 22px;
|
|
178
|
+
border-top: 1px solid var(--line);
|
|
179
|
+
display: grid;
|
|
180
|
+
gap: 12px;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.connect-section h2 {
|
|
184
|
+
margin: 0;
|
|
185
|
+
font-family: var(--font-heading);
|
|
186
|
+
font-weight: 600;
|
|
187
|
+
font-size: 16px;
|
|
188
|
+
letter-spacing: 0.01em;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.connect-hint {
|
|
192
|
+
margin: 0;
|
|
193
|
+
color: var(--muted);
|
|
194
|
+
font-size: 12px;
|
|
195
|
+
line-height: 1.5;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.connect-hint code {
|
|
199
|
+
font-family: var(--font-mono);
|
|
200
|
+
color: var(--accent);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.connect-snippet {
|
|
204
|
+
position: relative;
|
|
205
|
+
display: grid;
|
|
206
|
+
gap: 8px;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.connect-snippet pre {
|
|
210
|
+
margin: 0;
|
|
211
|
+
padding: 14px 16px;
|
|
212
|
+
border: 1px solid var(--line);
|
|
213
|
+
border-radius: 10px;
|
|
214
|
+
background: rgba(10, 14, 26, 0.75);
|
|
215
|
+
overflow-x: auto;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.connect-snippet code {
|
|
219
|
+
font-family: var(--font-mono);
|
|
220
|
+
font-size: 12px;
|
|
221
|
+
line-height: 1.5;
|
|
222
|
+
color: var(--text);
|
|
223
|
+
white-space: pre;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.connect-copy {
|
|
227
|
+
justify-self: start;
|
|
228
|
+
height: 32px;
|
|
229
|
+
padding: 0 14px;
|
|
230
|
+
border: 1px solid var(--line);
|
|
231
|
+
border-radius: 10px;
|
|
232
|
+
background: rgba(19, 26, 42, 0.7);
|
|
233
|
+
color: var(--text);
|
|
234
|
+
cursor: pointer;
|
|
235
|
+
font-size: 12px;
|
|
236
|
+
transition: border-color 150ms ease, background 150ms ease, box-shadow 150ms ease;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.connect-copy:hover,
|
|
240
|
+
.connect-copy:focus {
|
|
241
|
+
border-color: var(--accent);
|
|
242
|
+
background: var(--accent-weak);
|
|
243
|
+
box-shadow: var(--glow-cyan);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.connect-back {
|
|
247
|
+
display: inline-block;
|
|
248
|
+
margin-top: 24px;
|
|
249
|
+
color: var(--muted);
|
|
250
|
+
font-size: 12px;
|
|
251
|
+
text-decoration: none;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.connect-back:hover,
|
|
255
|
+
.connect-back:focus {
|
|
256
|
+
color: var(--accent);
|
|
257
|
+
}`;
|
|
258
|
+
// One inline copy-button behaviour reused for every snippet, mirroring the
|
|
259
|
+
// settings page: prefer the async clipboard API, fall back to a text selection.
|
|
260
|
+
const connectScript = () => `(function () {
|
|
261
|
+
var buttons = document.querySelectorAll('.connect-copy');
|
|
262
|
+
|
|
263
|
+
function flash(button) {
|
|
264
|
+
var original = button.getAttribute('data-label') || 'Copy';
|
|
265
|
+
button.textContent = 'Copied';
|
|
266
|
+
setTimeout(function () {
|
|
267
|
+
button.textContent = original;
|
|
268
|
+
}, 1200);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
Array.prototype.forEach.call(buttons, function (button) {
|
|
272
|
+
button.setAttribute('data-label', button.textContent);
|
|
273
|
+
button.addEventListener('click', function () {
|
|
274
|
+
var targetId = button.getAttribute('data-target');
|
|
275
|
+
var target = targetId ? document.getElementById(targetId) : null;
|
|
276
|
+
if (!target) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
var text = target.textContent || '';
|
|
280
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
281
|
+
navigator.clipboard.writeText(text).then(
|
|
282
|
+
function () {
|
|
283
|
+
flash(button);
|
|
284
|
+
},
|
|
285
|
+
function () {
|
|
286
|
+
selectText(target);
|
|
287
|
+
}
|
|
288
|
+
);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
selectText(target);
|
|
292
|
+
try {
|
|
293
|
+
document.execCommand('copy');
|
|
294
|
+
flash(button);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
// Selection is left in place so the user can copy manually.
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
function selectText(node) {
|
|
302
|
+
if (typeof window.getSelection !== 'function' || typeof document.createRange !== 'function') {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
var selection = window.getSelection();
|
|
306
|
+
var range = document.createRange();
|
|
307
|
+
range.selectNodeContents(node);
|
|
308
|
+
selection.removeAllRanges();
|
|
309
|
+
selection.addRange(range);
|
|
310
|
+
}
|
|
311
|
+
})();`;
|
|
312
|
+
// A snippet block: a titled hint, a <pre><code> with a stable id, and its copy
|
|
313
|
+
// button. The code body is escaped so it survives interpolation verbatim.
|
|
314
|
+
const snippetBlock = (id, title, hint, body) => `
|
|
315
|
+
<div class="connect-snippet">
|
|
316
|
+
<p class="connect-hint"><strong>${escapeHtml(title)}</strong> — ${hint}</p>
|
|
317
|
+
<pre><code id="${escapeHtml(id)}">${escapeHtml(body)}</code></pre>
|
|
318
|
+
<button class="connect-copy" type="button" data-target="${escapeHtml(id)}">Copy</button>
|
|
319
|
+
</div>`;
|
|
320
|
+
const remoteSnippets = (mcpUrl, token) => {
|
|
321
|
+
// The bearer token MUST be an mcp-remote `--header` arg — mcp-remote does not
|
|
322
|
+
// read a MCP_AUTHORIZATION env var, so the env form leaves the request
|
|
323
|
+
// unauthenticated and mcp-remote falls back to a hanging OAuth flow.
|
|
324
|
+
const codexToml = `[mcp_servers.brainlink]
|
|
325
|
+
command = "npx"
|
|
326
|
+
args = ["-y", "mcp-remote", "${mcpUrl}", "--header", "Authorization: Bearer ${token}"]
|
|
327
|
+
startup_timeout_sec = 60`;
|
|
328
|
+
const claudeCmd = `claude mcp add --transport http brainlink ${mcpUrl} --header "Authorization: Bearer ${token}"`;
|
|
329
|
+
const mcpJson = `{
|
|
330
|
+
"mcpServers": {
|
|
331
|
+
"brainlink": {
|
|
332
|
+
"type": "http",
|
|
333
|
+
"url": "${mcpUrl}",
|
|
334
|
+
"headers": {
|
|
335
|
+
"Authorization": "Bearer ${token}"
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}`;
|
|
340
|
+
const chatgptCmd = `Connector URL: ${mcpUrl}
|
|
341
|
+
Authorization: Bearer ${token}`;
|
|
342
|
+
return `
|
|
343
|
+
<section class="connect-section" aria-labelledby="codexTitle">
|
|
344
|
+
<h2 id="codexTitle">Codex</h2>
|
|
345
|
+
${snippetBlock('codexSnippet', '~/.codex/config.toml', 'add this MCP server block, then restart Codex.', codexToml)}
|
|
346
|
+
</section>
|
|
347
|
+
<section class="connect-section" aria-labelledby="claudeTitle">
|
|
348
|
+
<h2 id="claudeTitle">Claude Code</h2>
|
|
349
|
+
${snippetBlock('claudeSnippet', 'terminal', 'register the remote server over streamable HTTP.', claudeCmd)}
|
|
350
|
+
</section>
|
|
351
|
+
<section class="connect-section" aria-labelledby="mcpJsonTitle">
|
|
352
|
+
<h2 id="mcpJsonTitle">.mcp.json</h2>
|
|
353
|
+
${snippetBlock('mcpJsonSnippet', 'project .mcp.json', 'drop-in config for any MCP client that reads .mcp.json.', mcpJson)}
|
|
354
|
+
</section>
|
|
355
|
+
<section class="connect-section" aria-labelledby="chatgptTitle">
|
|
356
|
+
<h2 id="chatgptTitle">ChatGPT / generic MCP</h2>
|
|
357
|
+
${snippetBlock('chatgptSnippet', 'connector', 'point a generic MCP connector at the endpoint with a bearer token.', chatgptCmd)}
|
|
358
|
+
</section>`;
|
|
359
|
+
};
|
|
360
|
+
const localSnippets = (vaultPath) => {
|
|
361
|
+
const codexToml = `[mcp_servers.brainlink]
|
|
362
|
+
command = "brainlink"
|
|
363
|
+
args = ["mcp-server", "--vault", "${vaultPath}"]
|
|
364
|
+
startup_timeout_sec = 60`;
|
|
365
|
+
const claudeCmd = `claude mcp add brainlink -- brainlink mcp-server --vault ${vaultPath}`;
|
|
366
|
+
const mcpJson = `{
|
|
367
|
+
"mcpServers": {
|
|
368
|
+
"brainlink": {
|
|
369
|
+
"command": "brainlink",
|
|
370
|
+
"args": ["mcp-server", "--vault", "${vaultPath}"]
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}`;
|
|
374
|
+
return `
|
|
375
|
+
<section class="connect-section" aria-labelledby="codexTitle">
|
|
376
|
+
<h2 id="codexTitle">Codex</h2>
|
|
377
|
+
${snippetBlock('codexSnippet', '~/.codex/config.toml', 'run the local MCP server over stdio, then restart Codex.', codexToml)}
|
|
378
|
+
</section>
|
|
379
|
+
<section class="connect-section" aria-labelledby="claudeTitle">
|
|
380
|
+
<h2 id="claudeTitle">Claude Code</h2>
|
|
381
|
+
${snippetBlock('claudeSnippet', 'terminal', 'register the local stdio server.', claudeCmd)}
|
|
382
|
+
</section>
|
|
383
|
+
<section class="connect-section" aria-labelledby="mcpJsonTitle">
|
|
384
|
+
<h2 id="mcpJsonTitle">.mcp.json</h2>
|
|
385
|
+
${snippetBlock('mcpJsonSnippet', 'project .mcp.json', 'drop-in stdio config for any MCP client that reads .mcp.json.', mcpJson)}
|
|
386
|
+
</section>`;
|
|
387
|
+
};
|
|
388
|
+
const modeBadge = (mode) => mode === 'remote'
|
|
389
|
+
? '<div class="connect-mode">Mode <strong>Remote</strong> · authenticated over HTTPS with your token</div>'
|
|
390
|
+
: '<div class="connect-mode">Mode <strong>Local</strong> · stdio MCP server on this machine</div>';
|
|
391
|
+
const endpointBlock = (data) => data.mode === 'remote'
|
|
392
|
+
? `<div class="connect-endpoint">
|
|
393
|
+
<span>MCP endpoint</span>
|
|
394
|
+
<code>${escapeHtml(data.mcpUrl)}</code>
|
|
395
|
+
<span>Authorization</span>
|
|
396
|
+
<code>Bearer ${escapeHtml(data.token)}</code>
|
|
397
|
+
</div>`
|
|
398
|
+
: `<div class="connect-endpoint">
|
|
399
|
+
<span>Vault path</span>
|
|
400
|
+
<code>${escapeHtml(data.vaultPath)}</code>
|
|
401
|
+
</div>`;
|
|
402
|
+
export const createConnectPageHtml = (data) => {
|
|
403
|
+
const snippets = data.mode === 'remote' ? remoteSnippets(data.mcpUrl, data.token) : localSnippets(data.vaultPath);
|
|
404
|
+
const lede = data.mode === 'remote'
|
|
405
|
+
? 'Wire your agents and chats to this brainlink over the remote MCP endpoint. Paste a snippet, keep your token private.'
|
|
406
|
+
: 'Wire your agents and chats to this brainlink by running the local MCP server against your vault. Paste a snippet to get started.';
|
|
407
|
+
return `<!DOCTYPE html>
|
|
408
|
+
<html lang="en">
|
|
409
|
+
<head>
|
|
410
|
+
<meta charset="utf-8" />
|
|
411
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
412
|
+
<title>Connect your agent · Brainlink</title>
|
|
413
|
+
<style>${connectStyles()}</style>
|
|
414
|
+
</head>
|
|
415
|
+
<body>
|
|
416
|
+
<main class="connect-card">
|
|
417
|
+
<div class="connect-brand">
|
|
418
|
+
<strong>Brainlink</strong>
|
|
419
|
+
<span>Connect your agent</span>
|
|
420
|
+
</div>
|
|
421
|
+
<p class="connect-lede">${escapeHtml(lede)}</p>
|
|
422
|
+
${modeBadge(data.mode)}
|
|
423
|
+
${endpointBlock(data)}
|
|
424
|
+
${snippets}
|
|
425
|
+
<section class="connect-section" aria-labelledby="agentsTitle">
|
|
426
|
+
<h2 id="agentsTitle">Ready AGENTS.md</h2>
|
|
427
|
+
<p class="connect-hint">Drop this operating contract into your agent so it treats this brainlink as canonical memory.</p>
|
|
428
|
+
<div class="connect-snippet">
|
|
429
|
+
<pre><code id="agentsSnippet">${escapeHtml(connectAgentsMarkdown())}</code></pre>
|
|
430
|
+
<button class="connect-copy" type="button" data-target="agentsSnippet">Copy</button>
|
|
431
|
+
</div>
|
|
432
|
+
</section>
|
|
433
|
+
<a class="connect-back" href="/">← Back to graph</a>
|
|
434
|
+
</main>
|
|
435
|
+
<script>${connectScript()}</script>
|
|
436
|
+
</body>
|
|
437
|
+
</html>`;
|
|
438
|
+
};
|
|
@@ -29,6 +29,7 @@ import { getGraphVersion } from './graph-version.js';
|
|
|
29
29
|
import { readAuthStatus, readEditableSettings, updateEditableSettings } from './settings-store.js';
|
|
30
30
|
import { connectVaultRemote, ensureDeployKey, readVersioningStatus } from './versioning.js';
|
|
31
31
|
import { createSettingsPageHtml } from '../frontend/settings-page.js';
|
|
32
|
+
import { createConnectPageHtml } from '../frontend/connect-page.js';
|
|
32
33
|
import { contentTypes, createJsonResponse, isReadMethod, parsePositiveInteger } from './http.js';
|
|
33
34
|
import { parseMultipartForm } from './multipart.js';
|
|
34
35
|
const readRuntimeDefaults = async (url) => {
|
|
@@ -90,6 +91,29 @@ const requestIsSecure = (request) => {
|
|
|
90
91
|
const proto = firstHeaderValue(request.headers['x-forwarded-proto']);
|
|
91
92
|
return typeof proto === 'string' && proto.split(',')[0]?.trim() === 'https';
|
|
92
93
|
};
|
|
94
|
+
// Resolve the "Connect your agent" page data at request time. Remote mode is
|
|
95
|
+
// picked only when the server is exposed (auth-gated) AND an MCP token is
|
|
96
|
+
// configured: only then can we hand out a working bearer + public endpoint.
|
|
97
|
+
// Otherwise the page falls back to the local stdio config and never shows a
|
|
98
|
+
// token. The host prefers the proxy-forwarded value since exposed mode sits
|
|
99
|
+
// behind a TLS-terminating reverse proxy.
|
|
100
|
+
const resolveConnectHost = (request, url) => {
|
|
101
|
+
const forwarded = firstHeaderValue(request.headers['x-forwarded-host']);
|
|
102
|
+
const forwardedHost = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : '';
|
|
103
|
+
if (forwardedHost) {
|
|
104
|
+
return forwardedHost;
|
|
105
|
+
}
|
|
106
|
+
const host = firstHeaderValue(request.headers.host);
|
|
107
|
+
return typeof host === 'string' && host.length > 0 ? host : url.host;
|
|
108
|
+
};
|
|
109
|
+
const resolveConnectPageData = (request, url, vaultPath, requireAuth) => {
|
|
110
|
+
const token = process.env.BRAINLINK_MCP_TOKEN?.trim() ?? '';
|
|
111
|
+
if (requireAuth && token.length > 0) {
|
|
112
|
+
const host = resolveConnectHost(request, url);
|
|
113
|
+
return { mode: 'remote', mcpUrl: `https://${host}/mcp`, token, vaultPath };
|
|
114
|
+
}
|
|
115
|
+
return { mode: 'local', mcpUrl: '', token: '', vaultPath };
|
|
116
|
+
};
|
|
93
117
|
const isHtmlNavigation = (request, url) => {
|
|
94
118
|
if (url.pathname.startsWith('/api/')) {
|
|
95
119
|
return false;
|
|
@@ -566,6 +590,12 @@ export const route = async (request, url, vaultPath, options = {}) => {
|
|
|
566
590
|
if (isReadMethod(request) && url.pathname === '/settings') {
|
|
567
591
|
return createResponse(createSettingsPageHtml(), 200, contentTypes['.html']);
|
|
568
592
|
}
|
|
593
|
+
// "Connect your agent": ready-to-paste MCP setup for wiring agents to this
|
|
594
|
+
// brainlink. Auth-gated with the rest of the exposed graph (runAuthGate ran
|
|
595
|
+
// above), so it may surface the owner's bearer token in remote mode.
|
|
596
|
+
if (isReadMethod(request) && url.pathname === '/connect') {
|
|
597
|
+
return createResponse(createConnectPageHtml(resolveConnectPageData(request, url, vaultPath, options.requireAuth === true)), 200, contentTypes['.html']);
|
|
598
|
+
}
|
|
569
599
|
// Vault git versioning: generate an SSH deploy key on demand and connect a
|
|
570
600
|
// GitHub repo (clone-and-aggregate, conflict-preserving — never overwrites).
|
|
571
601
|
if (isReadMethod(request) && url.pathname === '/api/versioning-status') {
|
package/package.json
CHANGED