@agentcairn/mcp-server 0.1.5 → 0.2.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/ui/css/app.css CHANGED
@@ -172,7 +172,7 @@ textarea { font-family: var(--mono); font-size: 13px; width: 100%; resize: verti
172
172
  .badge svg { width: 11px; height: 11px; }
173
173
  .kind { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--text-2); font-weight: 520; }
174
174
  .kind::before { content: ""; width: 9px; height: 9px; border-radius: 3px; background: var(--k, var(--muted)); }
175
- .k-overview { --k: var(--s1); } .k-decision { --k: var(--s2); } .k-convention { --k: var(--s3); } .k-gotcha { --k: var(--s4); }
175
+ .k-handoff { --k: var(--s8); } .k-overview { --k: var(--s1); } .k-decision { --k: var(--s2); } .k-convention { --k: var(--s3); } .k-gotcha { --k: var(--s4); }
176
176
  .k-checkpoint { --k: var(--s5); } .k-thread { --k: var(--s6); } .k-glossary { --k: var(--s7); }
177
177
 
178
178
  /* ===== data display ===== */
@@ -269,7 +269,7 @@ textarea { font-family: var(--mono); font-size: 13px; width: 100%; resize: verti
269
269
  .graph-page { display: flex; flex-direction: column; height: calc(100vh - 56px); min-height: 560px; }
270
270
  .graph-layout { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 16px; flex: 1; min-height: 0; }
271
271
  .canvas-wrap { position: relative; min-height: 0; border-radius: var(--r-lg); overflow: hidden; border: 1px solid var(--border); background-color: var(--surface); box-shadow: var(--shadow-sm); }
272
- #cy { position: absolute; inset: 0; background-image: radial-gradient(var(--grid) 1px, transparent 1px); background-size: 24px 24px; }
272
+ #cy { position: absolute; inset: 0; background: var(--surface); }
273
273
  .canvas-overlay { position: absolute; z-index: 5; }
274
274
  .canvas-overlay.tl { left: 12px; top: 12px; right: 70px; display: flex; flex-wrap: wrap; gap: 6px; }
275
275
  .canvas-overlay.tl .chip { background: color-mix(in srgb, var(--surface) 88%, transparent); backdrop-filter: blur(6px); }
@@ -359,3 +359,41 @@ textarea { font-family: var(--mono); font-size: 13px; width: 100%; resize: verti
359
359
  .page-head { margin-bottom: 16px; }
360
360
  .toolbar { padding: 10px; }
361
361
  }
362
+
363
+ /* handoff and sessions on the project page */
364
+ .notes { display: flex; flex-direction: column; gap: 6px; font-size: 13.5px; line-height: 1.5; }
365
+ .notes-h { margin: 10px 0 0; font-size: 11.5px; font-weight: 640; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
366
+ .notes-h:first-child { margin-top: 0; }
367
+ .notes-p { margin: 0; color: var(--text-2); }
368
+ .notes-list { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 3px; color: var(--text); }
369
+ .resume-card { border-left: 3px solid var(--s8); margin-bottom: 14px; }
370
+ .resume-card details { border-top: 1px solid var(--border); padding-top: 10px; margin-top: 10px; }
371
+ .resume-card summary { cursor: pointer; font-weight: 560; }
372
+ .sessions { display: flex; flex-direction: column; margin-top: 4px; }
373
+ .session { padding: 12px 0; border-top: 1px solid var(--border); }
374
+ .session:first-child { border-top: 0; padding-top: 4px; }
375
+ .session summary { cursor: pointer; list-style: none; display: flex; flex-direction: column; gap: 4px; }
376
+ .session summary::-webkit-details-marker { display: none; }
377
+ .session .clamp { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; color: var(--text-2); font-size: 13px; }
378
+ .session .detail { margin-top: 10px; display: flex; flex-direction: column; gap: 8px; font-size: 13px; }
379
+ .session .detail b { font-weight: 600; }
380
+ .session .files { font-family: var(--mono); font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
381
+
382
+ /* ---- users, sharing, scopes ---- */
383
+ .member { gap: 12px; padding: 4px 0; align-items: center; }
384
+ .member .hint { margin-top: 1px; }
385
+ .scope summary { cursor: pointer; color: var(--dim); font-size: 12.5px; }
386
+ .scope[open] summary { color: var(--text); }
387
+ .tbl .sub { display: block; font-size: 11.5px; color: var(--muted); margin-top: 2px; }
388
+
389
+ /* graph overview pills and properties, after Neo4j Browser */
390
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; }
391
+ .nlabel { display: inline-flex; align-items: center; gap: 6px; border: 0; border-radius: 999px; padding: 3px 10px; font: 600 12px var(--sans); cursor: pointer; }
392
+ .nlabel.static { cursor: default; }
393
+ .nlabel .n, .rlabel .n { font-weight: 500; opacity: 0.8; }
394
+ .nlabel[aria-pressed="true"] { outline: 2px solid var(--accent); outline-offset: 2px; }
395
+ .rlabel { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border-strong); border-radius: 4px; padding: 2px 8px; font: 11.5px var(--mono); color: var(--text-2); }
396
+ .props { width: 100%; border-collapse: collapse; font-size: 12.5px; }
397
+ .props th { text-align: left; font: 500 11.5px var(--mono); color: var(--muted); padding: 5px 10px 5px 0; vertical-align: top; white-space: nowrap; }
398
+ .props td { padding: 5px 0; overflow-wrap: anywhere; border-top: 1px solid var(--border); }
399
+ .props tr:first-child td { border-top: 0; }
package/ui/js/app.js CHANGED
@@ -3,6 +3,7 @@ import { clear, h, icon, initial, startClock, svg } from './dom.js'
3
3
  import { closeDrawer } from './drawer.js'
4
4
  import { live } from './store.js'
5
5
  import { mountLogin, mountSetup } from './views/auth.js'
6
+ import { mountInvite } from './views/invite.js'
6
7
 
7
8
  const root = document.getElementById('root')
8
9
 
@@ -35,14 +36,20 @@ const ROUTES = [
35
36
  { pattern: /^\/graph$/, nav: 'graph', load: () => import('./views/graph.js') },
36
37
  { pattern: /^\/query$/, nav: 'query', load: () => import('./views/query.js') },
37
38
  { pattern: /^\/access$/, nav: 'access', load: () => import('./views/access.js') },
39
+ { pattern: /^\/users$/, nav: 'users', load: () => import('./views/users.js') },
40
+ { pattern: /^\/audit$/, nav: 'audit', load: () => import('./views/audit.js') },
38
41
  { pattern: /^\/system$/, nav: 'system', load: () => import('./views/system.js') },
39
42
  ]
40
43
 
41
44
  const NAV = [
42
45
  ['Monitor', [['live', 'Live', 'live']]],
43
46
  ['Explore', [['projects', 'Projects', 'folder'], ['graph', 'Graph', 'graph'], ['query', 'Query', 'terminal']]],
44
- ['Manage', [['access', 'Access', 'key'], ['system', 'System', 'server']]],
47
+ ['Manage', [['access', 'Access', 'key'], ['users', 'Users', 'users'], ['audit', 'Audit', 'list'], ['system', 'System', 'server']]],
45
48
  ]
49
+ /** Pages that show the whole server rather than your own projects: admins only. */
50
+ const ADMIN_ONLY = new Set(['live', 'query', 'system', 'users', 'audit'])
51
+ let me = { role: 'admin', solo: true }
52
+
46
53
  const TITLES = Object.fromEntries(NAV.flatMap(([, items]) => items.map(([key, label]) => [key, label])))
47
54
 
48
55
  export function navigate(hash) {
@@ -72,8 +79,9 @@ let shell = null
72
79
 
73
80
  function mountShell(username, version) {
74
81
  clear(root)
82
+ const visibleNav = NAV.map(([group, items]) => [group, items.filter(([key]) => me.role === 'admin' || !ADMIN_ONLY.has(key))]).filter(([, items]) => items.length)
75
83
  const brand = h('div', { class: 'brand' }, logo(), 'Agent Cairn', version && h('small', null, `v${version}`))
76
- const links = NAV.flatMap(([group, items]) => [
84
+ const links = visibleNav.flatMap(([group, items]) => [
77
85
  h('div', { class: 'label' }, group),
78
86
  ...items.map(([key, label, ic]) => {
79
87
  const count = h('span', { class: 'count', dataset: { count: key } })
@@ -142,6 +150,7 @@ async function route() {
142
150
  const { path, query } = parseHash()
143
151
  const match = ROUTES.map(r => [r, r.pattern.exec(path)]).find(([, m]) => m)
144
152
  const [def, m] = match ?? [ROUTES[0], null]
153
+ if (me.role !== 'admin' && ADMIN_ONLY.has(def.nav)) return navigate('#/projects')
145
154
  const params = Object.fromEntries((def.params ?? []).map((name, i) => [name, decodeURIComponent(m?.[i + 1] ?? '')]))
146
155
 
147
156
  document.title = `${TITLES[def.nav] ?? 'Dashboard'} · Agent Cairn`
@@ -161,7 +170,9 @@ async function route() {
161
170
  }
162
171
  }
163
172
 
164
- window.addEventListener('hashchange', route)
173
+ const inviteToken = () => /^#\/invite\/([^/?]+)/.exec(location.hash)?.[1]
174
+
175
+ window.addEventListener('hashchange', () => (inviteToken() || !shell ? boot() : route()))
165
176
 
166
177
  // ---- boot ------------------------------------------------------------------
167
178
 
@@ -171,6 +182,8 @@ async function boot() {
171
182
  shell?.unsubscribe()
172
183
  shell = null
173
184
  live.stop()
185
+ const invite = inviteToken()
186
+ if (invite) return mountInvite(root, decodeURIComponent(invite), boot)
174
187
  let state
175
188
  try {
176
189
  state = await api('state')
@@ -180,8 +193,9 @@ async function boot() {
180
193
  }
181
194
  if (state.setupRequired) return mountSetup(root, boot)
182
195
  if (!state.authenticated) return mountLogin(root, boot)
196
+ me = { role: state.role ?? 'admin', solo: state.solo ?? true }
183
197
  mountShell(state.username, state.version)
184
- live.start()
198
+ if (me.role === 'admin') live.start()
185
199
  route()
186
200
  }
187
201
 
package/ui/js/dom.js CHANGED
@@ -77,6 +77,8 @@ const ICONS = {
77
77
  fit: '<path d="M4 9V5h4M20 9V5h-4M4 15v4h4M20 15v4h-4"/>',
78
78
  info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>',
79
79
  arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>',
80
+ users: '<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0 1 13 0M16 4.6a3.5 3.5 0 0 1 0 6.8M18 14.2a6.5 6.5 0 0 1 3.5 5.8"/>',
81
+ list: '<path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01"/>',
80
82
  database: '<ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/>',
81
83
  }
82
84
 
@@ -208,7 +210,43 @@ export function debounce(fn, ms) {
208
210
  }
209
211
  }
210
212
 
211
- export const KINDS = ['overview', 'decision', 'convention', 'gotcha', 'checkpoint', 'thread', 'glossary']
213
+ export const KINDS = ['handoff', 'overview', 'decision', 'convention', 'gotcha', 'checkpoint', 'thread', 'glossary']
214
+
215
+ /**
216
+ * Renders the small Markdown a handoff is written in (`## headings`, `- bullets`, `1. steps`) as elements.
217
+ * Text only goes in through textContent, never as HTML.
218
+ */
219
+ export function renderNotes(body) {
220
+ const out = []
221
+ let list = null
222
+ const flush = () => {
223
+ if (list) out.push(list)
224
+ list = null
225
+ }
226
+ for (const raw of String(body).split('\n')) {
227
+ const line = raw.trimEnd()
228
+ if (!line.trim()) continue
229
+ const heading = /^##\s+(.*)$/.exec(line)
230
+ const bullet = /^-\s+(.*)$/.exec(line)
231
+ const step = /^\d+\.\s+(.*)$/.exec(line)
232
+ if (heading) {
233
+ flush()
234
+ out.push(h('h4', { class: 'notes-h' }, heading[1]))
235
+ } else if (bullet || step) {
236
+ const tag = step ? 'ol' : 'ul'
237
+ if (!list || list.tagName.toLowerCase() !== tag) {
238
+ flush()
239
+ list = h(tag, { class: 'notes-list' })
240
+ }
241
+ list.append(h('li', null, (bullet ?? step)[1]))
242
+ } else {
243
+ flush()
244
+ out.push(h('p', { class: 'notes-p' }, line))
245
+ }
246
+ }
247
+ flush()
248
+ return h('div', { class: 'notes' }, ...out)
249
+ }
212
250
 
213
251
  export function kindLabel(kind) {
214
252
  return h('span', { class: `kind k-${kind}` }, kind)
@@ -13,14 +13,19 @@ export async function mount(container, { query }) {
13
13
 
14
14
  // ---- tokens --------------------------------------------------------------
15
15
  const nameInput = h('input', { class: 'input', placeholder: 'Name, e.g. laptop or ci-runner', maxlength: '60', 'aria-label': 'Token name', style: { width: '250px' } })
16
+ const readOnly = h('input', { type: 'checkbox', id: 'tok-ro' })
17
+ const scopeBoxes = projects.map(p => ({ p, box: h('input', { type: 'checkbox', value: p.id, 'aria-label': p.name }) }))
18
+ const scope = h('details', { class: 'scope' }, h('summary', null, 'Limit to projects'), h('div', { class: 'stack', style: { gap: '6px', padding: '8px 0' } }, scopeBoxes.length ? scopeBoxes.map(({ p, box }) => h('label', { class: 'row tight' }, box, p.name)) : h('span', { class: 'hint' }, 'No projects yet.'), h('span', { class: 'hint' }, 'None ticked means every project you can reach. A limited token cannot create projects.')))
16
19
  const createBtn = h('button', { class: 'btn primary', type: 'button' }, icon('plus'), 'Create token')
17
20
 
18
21
  async function create() {
19
22
  createBtn.disabled = true
20
23
  try {
21
- const made = await api('tokens', { body: { name: nameInput.value } })
24
+ const made = await api('tokens', { body: { name: nameInput.value, readOnly: readOnly.checked, projects: scopeBoxes.filter(x => x.box.checked).map(x => x.p.id) } })
22
25
  fresh = made.token
23
26
  nameInput.value = ''
27
+ readOnly.checked = false
28
+ scopeBoxes.forEach(x => (x.box.checked = false))
24
29
  tokens = (await api('tokens')).tokens
25
30
  secretBox.replaceChildren(
26
31
  h(
@@ -56,12 +61,13 @@ export async function mount(container, { query }) {
56
61
  function drawTokens() {
57
62
  const active = tokens.filter(t => !t.revokedAt)
58
63
  tokenCard.replaceChildren(
59
- h('div', { class: 'head' }, h('div', null, h('h2', null, 'Access tokens'), h('div', { class: 'hint' }, `${active.length} active · agents and the cairn CLI send one as a bearer token`)), h('div', { class: 'tools' }, nameInput, createBtn)),
64
+ h('div', { class: 'head' }, h('div', null, h('h2', null, 'Access tokens'), h('div', { class: 'hint' }, `${active.length} active · agents and the cairn CLI send one as a bearer token`)), h('div', { class: 'tools' }, nameInput, h('label', { class: 'row tight', for: 'tok-ro', title: 'The token can read memories and briefings but never write' }, readOnly, 'Read-only'), createBtn)),
65
+ h('div', { style: { padding: '0 18px 10px' } }, scope),
60
66
  secretBox,
61
67
  tokens.length
62
68
  ? h('div', { class: 'scroll', style: { maxHeight: '340px' } }, h('table', { class: 'tbl' }, h('thead', null, h('tr', null, ['Name', 'Token', 'Created', 'Last used', 'Status', ''].map(t => h('th', null, t)))),
63
69
  h('tbody', null, tokens.map(t => h('tr', { style: { opacity: t.revokedAt ? 0.55 : 1 } },
64
- h('td', null, h('span', { class: 'tool-name' }, t.name)),
70
+ h('td', null, h('span', { class: 'tool-name' }, t.name), t.readOnly && h('span', { class: 'badge', style: { marginLeft: '8px' } }, 'read-only'), t.projects?.length > 0 && h('span', { class: 'badge', style: { marginLeft: '6px' }, title: t.projects.map(id => projects.find(p => p.id === id)?.name ?? id).join(', ') }, `${t.projects.length} project${t.projects.length === 1 ? '' : 's'}`)),
65
71
  h('td', { class: 'mono' }, `${t.prefix}…`),
66
72
  h('td', null, ago(t.createdAt), t.createdBy && h('span', { class: 'sub' }, `by ${t.createdBy}`)),
67
73
  h('td', null, t.lastUsedAt ? ago(t.lastUsedAt) : h('span', { class: 'muted' }, 'never')),
@@ -75,7 +81,7 @@ export async function mount(container, { query }) {
75
81
 
76
82
  // ---- connect -------------------------------------------------------------
77
83
  const origin = h('input', { class: 'input mono', value: location.origin, 'aria-label': 'Server address', style: { width: '280px' } })
78
- const projectSelect = h('select', { 'aria-label': 'Project' }, projects.map(p => h('option', { value: p.id }, p.id)), h('option', { value: '' }, 'A new project id…'))
84
+ const projectSelect = h('select', { 'aria-label': 'Project' }, projects.map(p => h('option', { value: p.id }, p.name)), h('option', { value: '' }, 'A project name or id…'))
79
85
  const customProject = h('input', { class: 'input', placeholder: 'project-id', 'aria-label': 'Project id', style: { display: 'none', width: '160px' } })
80
86
  if (query.project) projectSelect.value = query.project
81
87
  else if (!projects.length) projectSelect.value = ''
@@ -0,0 +1,77 @@
1
+ import { api, qs } from '../api.js'
2
+ import { emptyState, fmt, h, toast } from '../dom.js'
3
+
4
+ const GROUPS = [
5
+ ['', 'Everything'],
6
+ ['login', 'Sign-ins'],
7
+ ['user.', 'Accounts'],
8
+ ['invite.', 'Invitations'],
9
+ ['member.', 'Sharing'],
10
+ ['project.', 'Projects'],
11
+ ['token.', 'Tokens'],
12
+ ['console.', 'Query console'],
13
+ ]
14
+ const PAGE = 100
15
+
16
+ export async function mount(container) {
17
+ let events = []
18
+ let prefix = ''
19
+ let more = false
20
+
21
+ const body = h('tbody')
22
+ const table = h('div', { class: 'scroll' }, h('table', { class: 'tbl' }, h('thead', null, h('tr', null, ['When', 'Who', 'What', 'On', 'Detail'].map(t => h('th', null, t)))), body))
23
+ const holder = h('div', { class: 'card flush' })
24
+ const select = h('select', { 'aria-label': 'Kind of event' }, GROUPS.map(([v, label]) => h('option', { value: v }, label)))
25
+ const moreBtn = h('button', { class: 'btn', type: 'button' }, 'Load older')
26
+
27
+ function rows(list) {
28
+ return list.map(e =>
29
+ h(
30
+ 'tr',
31
+ null,
32
+ h('td', { title: e.at }, h('span', { class: 'mono', style: { fontSize: '12px' } }, fmt.dateTime(e.at))),
33
+ h('td', null, e.actor ?? h('span', { class: 'muted' }, 'system')),
34
+ h('td', null, h('span', { class: `badge ${/failed|removed|disabled|revoked|deleted/.test(e.action) ? 'err' : /created|accepted|added|enabled/.test(e.action) ? 'ok' : ''}` }, e.action)),
35
+ h('td', null, e.target ?? ''),
36
+ h('td', { class: 'dim' }, e.detail ?? ''),
37
+ ),
38
+ )
39
+ }
40
+
41
+ async function load(append) {
42
+ moreBtn.disabled = true
43
+ try {
44
+ const { events: got } = await api(`audit?${qs({ limit: PAGE, action: prefix, before: append ? events[events.length - 1]?.at : undefined })}`)
45
+ events = append ? [...events, ...got] : got
46
+ more = got.length === PAGE
47
+ draw(append ? got : events, append)
48
+ } catch (error) {
49
+ toast(error.message)
50
+ } finally {
51
+ moreBtn.disabled = false
52
+ }
53
+ }
54
+
55
+ function draw(list, append) {
56
+ if (!events.length) {
57
+ holder.replaceChildren(emptyState('inbox', 'Nothing recorded', prefix ? 'No events of this kind.' : 'Sign-ins and changes to accounts, sharing, tokens and projects show up here.'))
58
+ return
59
+ }
60
+ if (!holder.contains(table)) holder.replaceChildren(table, h('div', { class: 'row', style: { padding: '12px 18px', justifyContent: 'center' } }, moreBtn))
61
+ if (append) body.append(...rows(list))
62
+ else body.replaceChildren(...rows(list))
63
+ moreBtn.hidden = !more
64
+ }
65
+
66
+ select.addEventListener('change', () => {
67
+ prefix = select.value
68
+ load(false)
69
+ })
70
+ moreBtn.addEventListener('click', () => load(true))
71
+
72
+ container.append(
73
+ h('div', { class: 'page-head' }, h('div', null, h('h1', null, 'Audit log'), h('div', { class: 'sub' }, 'Who changed what, kept for 180 days. Secrets and memory content are never recorded.')), h('div', { class: 'row' }, select)),
74
+ holder,
75
+ )
76
+ await load(false)
77
+ }
@@ -1,33 +1,32 @@
1
1
  import { api } from '../api.js'
2
2
  import { clear, h, svg } from '../dom.js'
3
3
 
4
- function brand() {
4
+ export function brand() {
5
5
  return h(
6
6
  'div',
7
7
  { class: 'brand' },
8
8
  svg(
9
9
  'svg',
10
10
  { viewBox: '0 0 32 32', 'aria-hidden': 'true' },
11
- svg('g', { stroke: 'var(--s1)', 'stroke-width': 2, 'stroke-linecap': 'round' }, svg('path', { d: 'M9 21 16 10M16 10l8 11M9 21h15' })),
12
- svg('circle', { cx: 9, cy: 21, r: 3, fill: 'var(--text)' }),
13
- svg('circle', { cx: 16, cy: 10, r: 3.4, fill: 'var(--s1)' }),
14
- svg('circle', { cx: 24, cy: 21, r: 3, fill: 'var(--text)' }),
11
+ svg('ellipse', { cx: 16, cy: 23.5, rx: 9, ry: 3.8, fill: 'var(--text)' }),
12
+ svg('ellipse', { cx: 16, cy: 16.2, rx: 6.4, ry: 3.2, fill: 'var(--text)' }),
13
+ svg('ellipse', { cx: 16, cy: 9.6, rx: 3.8, ry: 2.8, fill: 'var(--s1)' }),
15
14
  ),
16
15
  'Agent Cairn',
17
16
  )
18
17
  }
19
18
 
20
- function field(label, input, hint) {
19
+ export function field(label, input, hint) {
21
20
  const id = `f-${Math.random().toString(36).slice(2, 8)}`
22
21
  input.id = id
23
22
  return h('div', { class: 'field' }, h('label', { for: id }, label), input, hint && h('span', { class: 'hint' }, hint))
24
23
  }
25
24
 
26
- function page(...children) {
25
+ export function page(...children) {
27
26
  return h('div', { class: 'auth-page' }, h('div', { class: 'card panel' }, brand(), ...children))
28
27
  }
29
28
 
30
- async function submit(form, button, errorEl, work) {
29
+ export async function submit(form, button, errorEl, work) {
31
30
  errorEl.textContent = ''
32
31
  button.disabled = true
33
32
  try {