@cybermem/dashboard 0.5.3 → 0.5.8
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/app/api/audit-logs/route.ts +26 -1
- package/app/api/backup/route.ts +67 -0
- package/app/api/metrics/route.ts +102 -118
- package/app/api/reset/route.ts +83 -0
- package/app/api/restore/route.ts +101 -0
- package/components/dashboard/audit-log-table.tsx +1 -1
- package/components/dashboard/mcp-config-modal.tsx +19 -22
- package/components/dashboard/metrics-chart.tsx +2 -2
- package/components/dashboard/metrics-grid.tsx +5 -15
- package/e2e/config-ui.spec.ts +5 -5
- package/e2e/infra-check.spec.ts +97 -0
- package/package.json +1 -1
- package/public/clients.json +11 -12
|
@@ -1,10 +1,35 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
1
2
|
import { NextResponse } from 'next/server'
|
|
3
|
+
import path from 'path'
|
|
2
4
|
|
|
3
5
|
export const dynamic = 'force-dynamic'
|
|
4
6
|
|
|
5
7
|
// Use env var for db-exporter URL (Docker internal vs local dev)
|
|
6
8
|
const DB_EXPORTER_URL = process.env.DB_EXPORTER_URL || 'http://localhost:8000'
|
|
7
9
|
|
|
10
|
+
// Load clients config for name normalization
|
|
11
|
+
let clientsConfig: any[] = []
|
|
12
|
+
try {
|
|
13
|
+
const configPath = path.join(process.cwd(), 'public', 'clients.json')
|
|
14
|
+
clientsConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
15
|
+
} catch (e) {
|
|
16
|
+
console.error('Failed to load clients.json:', e)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Normalize raw client name (e.g. "antigravity-client") to friendly name (e.g. "Antigravity")
|
|
20
|
+
function normalizeClientName(rawName: string): string {
|
|
21
|
+
if (!rawName) return 'Unknown'
|
|
22
|
+
const nameLower = rawName.toLowerCase()
|
|
23
|
+
const client = clientsConfig.find((c: any) => {
|
|
24
|
+
try {
|
|
25
|
+
return new RegExp(c.match, 'i').test(nameLower)
|
|
26
|
+
} catch {
|
|
27
|
+
return nameLower.includes(c.match)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
return client?.name || rawName
|
|
31
|
+
}
|
|
32
|
+
|
|
8
33
|
const CLIENTS = ["Claude Code", "v0", "Cursor", "GitHub Copilot", "Windsurf"]
|
|
9
34
|
const OPERATIONS = ["Read", "Write", "Update", "Delete", "Create"]
|
|
10
35
|
const STATUSES = ["Success", "Success", "Success", "Warning", "Error"]
|
|
@@ -45,7 +70,7 @@ export async function GET(request: Request) {
|
|
|
45
70
|
|
|
46
71
|
return {
|
|
47
72
|
timestamp: log.timestamp,
|
|
48
|
-
client: log.client_name
|
|
73
|
+
client: normalizeClientName(log.client_name),
|
|
49
74
|
operation: operation,
|
|
50
75
|
status: status,
|
|
51
76
|
method: log.method,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { exec } from 'child_process'
|
|
2
|
+
import { createReadStream, statSync } from 'fs'
|
|
3
|
+
import { NextResponse } from 'next/server'
|
|
4
|
+
import { tmpdir } from 'os'
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
import { promisify } from 'util'
|
|
7
|
+
|
|
8
|
+
export const dynamic = 'force-dynamic'
|
|
9
|
+
|
|
10
|
+
const execAsync = promisify(exec)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* GET /api/backup
|
|
14
|
+
* Creates and downloads a backup of the OpenMemory database
|
|
15
|
+
*/
|
|
16
|
+
export async function GET() {
|
|
17
|
+
try {
|
|
18
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
|
19
|
+
const backupName = `cybermem-backup-${timestamp}.tar.gz`
|
|
20
|
+
const tmpPath = join(tmpdir(), backupName)
|
|
21
|
+
|
|
22
|
+
// Create backup via docker
|
|
23
|
+
const containerName = process.env.OPENMEMORY_CONTAINER || 'cybermem-openmemory'
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
// Copy data from container to temp location
|
|
27
|
+
await execAsync(
|
|
28
|
+
`docker cp ${containerName}:/data - | gzip > "${tmpPath}"`
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
// Get file stats
|
|
32
|
+
const stats = statSync(tmpPath)
|
|
33
|
+
|
|
34
|
+
// Stream the file
|
|
35
|
+
const stream = createReadStream(tmpPath)
|
|
36
|
+
|
|
37
|
+
// Convert Node stream to Web ReadableStream
|
|
38
|
+
const webStream = new ReadableStream({
|
|
39
|
+
start(controller) {
|
|
40
|
+
stream.on('data', (chunk) => controller.enqueue(chunk))
|
|
41
|
+
stream.on('end', () => controller.close())
|
|
42
|
+
stream.on('error', (err) => controller.error(err))
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
return new NextResponse(webStream, {
|
|
47
|
+
headers: {
|
|
48
|
+
'Content-Type': 'application/gzip',
|
|
49
|
+
'Content-Disposition': `attachment; filename="${backupName}"`,
|
|
50
|
+
'Content-Length': String(stats.size)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
} catch (dockerError: any) {
|
|
55
|
+
return NextResponse.json(
|
|
56
|
+
{ error: `Backup failed: ${dockerError.message}` },
|
|
57
|
+
{ status: 500 }
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
} catch (error: any) {
|
|
62
|
+
return NextResponse.json(
|
|
63
|
+
{ error: error.message || 'Unknown error' },
|
|
64
|
+
{ status: 500 }
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
}
|
package/app/api/metrics/route.ts
CHANGED
|
@@ -1,137 +1,121 @@
|
|
|
1
|
-
import
|
|
2
|
-
getClientCount,
|
|
3
|
-
getCreatesByClient,
|
|
4
|
-
getDeletesByClient,
|
|
5
|
-
getErrorsByClient,
|
|
6
|
-
getLastReader,
|
|
7
|
-
getLastWriter,
|
|
8
|
-
getMemoryRecordsCount,
|
|
9
|
-
getMemoryRecordsSparkline,
|
|
10
|
-
getReadsByClient,
|
|
11
|
-
getRequestsByClient,
|
|
12
|
-
getRequestsByMethod,
|
|
13
|
-
getRequestsTimeSeries,
|
|
14
|
-
getRequestsTimeSeriesByMethod,
|
|
15
|
-
getResponseTimeTimeSeries,
|
|
16
|
-
getSuccessRate,
|
|
17
|
-
getSuccessRateByClient,
|
|
18
|
-
getSuccessRateSparkline,
|
|
19
|
-
getSuccessRateTimeSeries,
|
|
20
|
-
getSuccessRateTimeSeriesByClient,
|
|
21
|
-
getTopReader,
|
|
22
|
-
getTopWriter,
|
|
23
|
-
getTotalClientsSparkline,
|
|
24
|
-
getTotalRequests,
|
|
25
|
-
getTotalRequestsSparkline,
|
|
26
|
-
getUpdatesByClient
|
|
27
|
-
} from '@/lib/prometheus'
|
|
1
|
+
import fs from 'fs'
|
|
28
2
|
import { NextResponse } from 'next/server'
|
|
3
|
+
import path from 'path'
|
|
29
4
|
|
|
30
5
|
export const dynamic = 'force-dynamic'
|
|
31
6
|
export const revalidate = 0
|
|
32
7
|
|
|
8
|
+
// Use env var for db-exporter URL (Docker internal vs local dev)
|
|
9
|
+
const DB_EXPORTER_URL = process.env.DB_EXPORTER_URL || 'http://localhost:8000'
|
|
10
|
+
|
|
11
|
+
// Load clients config for name normalization
|
|
12
|
+
let clientsConfig: any[] = []
|
|
13
|
+
try {
|
|
14
|
+
const configPath = path.join(process.cwd(), 'public', 'clients.json')
|
|
15
|
+
clientsConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
16
|
+
} catch (e) {
|
|
17
|
+
console.error('Failed to load clients.json:', e)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Normalize raw client name to friendly name
|
|
21
|
+
function normalizeClientName(rawName: string): string {
|
|
22
|
+
if (!rawName || rawName === 'N/A') return rawName
|
|
23
|
+
const nameLower = rawName.toLowerCase()
|
|
24
|
+
const client = clientsConfig.find((c: any) => {
|
|
25
|
+
try {
|
|
26
|
+
return new RegExp(c.match, 'i').test(nameLower)
|
|
27
|
+
} catch {
|
|
28
|
+
return nameLower.includes(c.match)
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
return client?.name || rawName
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Normalize client names in time series data
|
|
35
|
+
function normalizeTimeSeries(data: any[]): any[] {
|
|
36
|
+
return data.map(point => {
|
|
37
|
+
const normalized: any = { time: point.time }
|
|
38
|
+
for (const [key, value] of Object.entries(point)) {
|
|
39
|
+
if (key !== 'time') {
|
|
40
|
+
normalized[normalizeClientName(key)] = value
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return normalized
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
33
47
|
export async function GET(request: Request) {
|
|
34
48
|
try {
|
|
35
49
|
const { searchParams } = new URL(request.url)
|
|
36
|
-
const period = searchParams.get('period') || '
|
|
50
|
+
const period = searchParams.get('period') || '24h'
|
|
51
|
+
|
|
52
|
+
const controller = new AbortController()
|
|
53
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000)
|
|
37
54
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
clientCount,
|
|
49
|
-
memoryRecordsSparkline,
|
|
50
|
-
totalRequestsSparkline,
|
|
51
|
-
totalClientsSparkline,
|
|
52
|
-
successRateSparkline,
|
|
53
|
-
successRateByClient,
|
|
54
|
-
successRateTimeSeriesByClient,
|
|
55
|
-
topWriter,
|
|
56
|
-
topReader,
|
|
57
|
-
lastWriter,
|
|
58
|
-
lastReader,
|
|
59
|
-
createsTimeSeries,
|
|
60
|
-
readsTimeSeries,
|
|
61
|
-
updatesTimeSeries,
|
|
62
|
-
deletesTimeSeries,
|
|
63
|
-
errorsTimeSeries
|
|
64
|
-
] = await Promise.all([
|
|
65
|
-
getTotalRequests(period),
|
|
66
|
-
getRequestsByClient(period),
|
|
67
|
-
getRequestsByMethod(period),
|
|
68
|
-
getSuccessRate(),
|
|
69
|
-
getRequestsTimeSeries(period),
|
|
70
|
-
getResponseTimeTimeSeries(period),
|
|
71
|
-
getSuccessRateTimeSeries(period),
|
|
72
|
-
getRequestsTimeSeriesByMethod('POST', period),
|
|
73
|
-
getMemoryRecordsCount(),
|
|
74
|
-
getClientCount(),
|
|
75
|
-
getMemoryRecordsSparkline(period),
|
|
76
|
-
getTotalRequestsSparkline(period),
|
|
77
|
-
getTotalClientsSparkline(period),
|
|
78
|
-
getSuccessRateSparkline(period),
|
|
79
|
-
getSuccessRateByClient(),
|
|
80
|
-
getSuccessRateTimeSeriesByClient(period),
|
|
81
|
-
getTopWriter(),
|
|
82
|
-
getTopReader(),
|
|
83
|
-
getLastWriter(),
|
|
84
|
-
getLastReader(),
|
|
85
|
-
getCreatesByClient(period),
|
|
86
|
-
getReadsByClient(period),
|
|
87
|
-
getUpdatesByClient(period),
|
|
88
|
-
getDeletesByClient(period),
|
|
89
|
-
getErrorsByClient(period)
|
|
55
|
+
// Fetch stats and timeseries from db-exporter (SQLite)
|
|
56
|
+
const [statsRes, timeseriesRes] = await Promise.all([
|
|
57
|
+
fetch(`${DB_EXPORTER_URL}/api/stats`, {
|
|
58
|
+
signal: controller.signal,
|
|
59
|
+
cache: 'no-store'
|
|
60
|
+
}),
|
|
61
|
+
fetch(`${DB_EXPORTER_URL}/api/timeseries?period=${period}`, {
|
|
62
|
+
signal: controller.signal,
|
|
63
|
+
cache: 'no-store'
|
|
64
|
+
})
|
|
90
65
|
])
|
|
91
66
|
|
|
92
|
-
|
|
93
|
-
const clientStats = Object.entries(requestsByClient)
|
|
94
|
-
.map(([client, total]) => ({
|
|
95
|
-
client,
|
|
96
|
-
total,
|
|
97
|
-
reads: requestsByMethod.reads[client] || 0,
|
|
98
|
-
writes: requestsByMethod.writes[client] || 0
|
|
99
|
-
}))
|
|
100
|
-
.sort((a, b) => b.total - a.total)
|
|
67
|
+
clearTimeout(timeoutId)
|
|
101
68
|
|
|
102
|
-
|
|
103
|
-
stats: {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
69
|
+
if (!statsRes.ok) {
|
|
70
|
+
throw new Error(`Failed to fetch stats: ${statsRes.statusText}`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const stats = await statsRes.json()
|
|
74
|
+
const timeseries = timeseriesRes.ok ? await timeseriesRes.json() : { creates: [], reads: [], updates: [], deletes: [] }
|
|
75
|
+
|
|
76
|
+
// Normalize client names
|
|
77
|
+
const normalizedStats = {
|
|
78
|
+
memoryRecords: stats.memoryRecords || 0,
|
|
79
|
+
totalClients: stats.totalClients || 0,
|
|
80
|
+
successRate: stats.successRate || 100,
|
|
81
|
+
totalRequests: stats.totalRequests || 0,
|
|
82
|
+
topWriter: {
|
|
83
|
+
name: normalizeClientName(stats.topWriter?.name || 'N/A'),
|
|
84
|
+
count: stats.topWriter?.count || 0
|
|
112
85
|
},
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
successRate: successRateTimeSeries,
|
|
117
|
-
successRateByClient: successRateTimeSeriesByClient,
|
|
118
|
-
writes: writesTimeSeries,
|
|
119
|
-
creates: createsTimeSeries,
|
|
120
|
-
reads: readsTimeSeries,
|
|
121
|
-
updates: updatesTimeSeries,
|
|
122
|
-
deletes: deletesTimeSeries,
|
|
123
|
-
errors: errorsTimeSeries
|
|
86
|
+
topReader: {
|
|
87
|
+
name: normalizeClientName(stats.topReader?.name || 'N/A'),
|
|
88
|
+
count: stats.topReader?.count || 0
|
|
124
89
|
},
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
90
|
+
lastWriter: {
|
|
91
|
+
name: normalizeClientName(stats.lastWriter?.name || 'N/A'),
|
|
92
|
+
timestamp: stats.lastWriter?.timestamp || 0
|
|
93
|
+
},
|
|
94
|
+
lastReader: {
|
|
95
|
+
name: normalizeClientName(stats.lastReader?.name || 'N/A'),
|
|
96
|
+
timestamp: stats.lastReader?.timestamp || 0
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return NextResponse.json({
|
|
101
|
+
stats: normalizedStats,
|
|
102
|
+
timeSeries: {
|
|
103
|
+
creates: normalizeTimeSeries(timeseries.creates || []),
|
|
104
|
+
reads: normalizeTimeSeries(timeseries.reads || []),
|
|
105
|
+
updates: normalizeTimeSeries(timeseries.updates || []),
|
|
106
|
+
deletes: normalizeTimeSeries(timeseries.deletes || [])
|
|
129
107
|
},
|
|
108
|
+
// Legacy fields for backward compatibility
|
|
130
109
|
sparklines: {
|
|
131
|
-
memoryRecords:
|
|
132
|
-
totalRequests:
|
|
133
|
-
totalClients:
|
|
134
|
-
successRate:
|
|
110
|
+
memoryRecords: [],
|
|
111
|
+
totalRequests: [],
|
|
112
|
+
totalClients: [],
|
|
113
|
+
successRate: []
|
|
114
|
+
},
|
|
115
|
+
clientStats: {
|
|
116
|
+
reads: {},
|
|
117
|
+
writes: {},
|
|
118
|
+
successRate: {}
|
|
135
119
|
}
|
|
136
120
|
})
|
|
137
121
|
} catch (error) {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { exec } from 'child_process'
|
|
2
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
3
|
+
import { promisify } from 'util'
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic'
|
|
6
|
+
|
|
7
|
+
const execAsync = promisify(exec)
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* POST /api/reset
|
|
11
|
+
* Wipes the OpenMemory database
|
|
12
|
+
*
|
|
13
|
+
* Requires confirmation token in body: { confirm: "RESET" }
|
|
14
|
+
*/
|
|
15
|
+
export async function POST(request: NextRequest) {
|
|
16
|
+
try {
|
|
17
|
+
const body = await request.json()
|
|
18
|
+
|
|
19
|
+
// Require explicit confirmation
|
|
20
|
+
if (body.confirm !== 'RESET') {
|
|
21
|
+
return NextResponse.json(
|
|
22
|
+
{ error: 'Confirmation required. Send { confirm: "RESET" }' },
|
|
23
|
+
{ status: 400 }
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Wipe database via docker exec
|
|
28
|
+
const containerName = process.env.OPENMEMORY_CONTAINER || 'cybermem-openmemory'
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
// Remove SQLite files
|
|
32
|
+
await execAsync(
|
|
33
|
+
`docker exec ${containerName} sh -c 'rm -f /data/openmemory.sqlite*'`
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// Restart container to reinitialize
|
|
37
|
+
await execAsync(`docker restart ${containerName}`)
|
|
38
|
+
|
|
39
|
+
// Wait for health
|
|
40
|
+
let healthy = false
|
|
41
|
+
for (let i = 0; i < 30; i++) {
|
|
42
|
+
await new Promise(r => setTimeout(r, 2000))
|
|
43
|
+
try {
|
|
44
|
+
const healthUrl = process.env.CYBERMEM_URL || 'http://localhost:8626'
|
|
45
|
+
const res = await fetch(`${healthUrl}/health`, { cache: 'no-store' })
|
|
46
|
+
if (res.ok) {
|
|
47
|
+
healthy = true
|
|
48
|
+
break
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// Still starting up
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!healthy) {
|
|
56
|
+
return NextResponse.json(
|
|
57
|
+
{ error: 'Database reset but container failed to become healthy' },
|
|
58
|
+
{ status: 500 }
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Restart log-exporter and db-exporter
|
|
63
|
+
await execAsync('docker restart cybermem-log-exporter cybermem-db-exporter').catch(() => {})
|
|
64
|
+
|
|
65
|
+
return NextResponse.json({
|
|
66
|
+
success: true,
|
|
67
|
+
message: 'Database reset successfully'
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
} catch (dockerError: any) {
|
|
71
|
+
return NextResponse.json(
|
|
72
|
+
{ error: `Docker command failed: ${dockerError.message}` },
|
|
73
|
+
{ status: 500 }
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
} catch (error: any) {
|
|
78
|
+
return NextResponse.json(
|
|
79
|
+
{ error: error.message || 'Unknown error' },
|
|
80
|
+
{ status: 500 }
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { exec } from 'child_process'
|
|
2
|
+
import { unlinkSync, writeFileSync } from 'fs'
|
|
3
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
4
|
+
import { tmpdir } from 'os'
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
import { promisify } from 'util'
|
|
7
|
+
|
|
8
|
+
export const dynamic = 'force-dynamic'
|
|
9
|
+
|
|
10
|
+
const execAsync = promisify(exec)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* POST /api/restore
|
|
14
|
+
* Restores the OpenMemory database from uploaded backup
|
|
15
|
+
*
|
|
16
|
+
* Accepts multipart/form-data with 'backup' file field
|
|
17
|
+
*/
|
|
18
|
+
export async function POST(request: NextRequest) {
|
|
19
|
+
try {
|
|
20
|
+
const formData = await request.formData()
|
|
21
|
+
const file = formData.get('backup') as File | null
|
|
22
|
+
|
|
23
|
+
if (!file) {
|
|
24
|
+
return NextResponse.json(
|
|
25
|
+
{ error: 'No backup file provided. Upload as "backup" field.' },
|
|
26
|
+
{ status: 400 }
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Validate file type
|
|
31
|
+
if (!file.name.endsWith('.tar.gz') && !file.name.endsWith('.tgz')) {
|
|
32
|
+
return NextResponse.json(
|
|
33
|
+
{ error: 'Invalid file type. Must be .tar.gz or .tgz' },
|
|
34
|
+
{ status: 400 }
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const containerName = process.env.OPENMEMORY_CONTAINER || 'cybermem-openmemory'
|
|
39
|
+
const tmpPath = join(tmpdir(), `restore-${Date.now()}.tar.gz`)
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
// Write uploaded file to temp location
|
|
43
|
+
const buffer = Buffer.from(await file.arrayBuffer())
|
|
44
|
+
writeFileSync(tmpPath, buffer)
|
|
45
|
+
|
|
46
|
+
// Stop container
|
|
47
|
+
await execAsync(`docker stop ${containerName}`)
|
|
48
|
+
|
|
49
|
+
// Extract backup to container volume
|
|
50
|
+
await execAsync(
|
|
51
|
+
`gunzip -c "${tmpPath}" | docker cp - ${containerName}:/`
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
// Clean up temp file
|
|
55
|
+
unlinkSync(tmpPath)
|
|
56
|
+
|
|
57
|
+
// Start container
|
|
58
|
+
await execAsync(`docker start ${containerName}`)
|
|
59
|
+
|
|
60
|
+
// Wait for health
|
|
61
|
+
let healthy = false
|
|
62
|
+
for (let i = 0; i < 30; i++) {
|
|
63
|
+
await new Promise(r => setTimeout(r, 2000))
|
|
64
|
+
try {
|
|
65
|
+
const healthUrl = process.env.CYBERMEM_URL || 'http://localhost:8626'
|
|
66
|
+
const res = await fetch(`${healthUrl}/health`, { cache: 'no-store' })
|
|
67
|
+
if (res.ok) {
|
|
68
|
+
healthy = true
|
|
69
|
+
break
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// Still starting up
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Restart exporters
|
|
77
|
+
await execAsync('docker restart cybermem-log-exporter cybermem-db-exporter').catch(() => {})
|
|
78
|
+
|
|
79
|
+
return NextResponse.json({
|
|
80
|
+
success: true,
|
|
81
|
+
message: 'Database restored successfully',
|
|
82
|
+
healthy
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
} catch (dockerError: any) {
|
|
86
|
+
// Clean up temp file on error
|
|
87
|
+
try { unlinkSync(tmpPath) } catch {}
|
|
88
|
+
|
|
89
|
+
return NextResponse.json(
|
|
90
|
+
{ error: `Restore failed: ${dockerError.message}` },
|
|
91
|
+
{ status: 500 }
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
} catch (error: any) {
|
|
96
|
+
return NextResponse.json(
|
|
97
|
+
{ error: error.message || 'Unknown error' },
|
|
98
|
+
{ status: 500 }
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -47,7 +47,7 @@ export default function AuditLogTable({
|
|
|
47
47
|
const getClientConfig = (rawName: string) => {
|
|
48
48
|
if (!rawName) return undefined
|
|
49
49
|
const nameLower = rawName.toLowerCase()
|
|
50
|
-
return clientConfigs.find((c: any) =>
|
|
50
|
+
return clientConfigs.find((c: any) => new RegExp(c.match, 'i').test(nameLower))
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const getClientDisplayName = (rawName: string) => {
|
|
@@ -83,29 +83,28 @@ export default function MCPConfigModal({ onClose }: { onClose: () => void }) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
const getMcpConfig = (clientId: string) => {
|
|
86
|
-
const isAntigravity = clientId === 'antigravity'
|
|
87
|
-
const urlKey = isAntigravity ? 'serverUrl' : 'url'
|
|
88
|
-
|
|
89
86
|
// Local mode: use stdio (command-based) - no server needed, runs via npx
|
|
90
87
|
if (isManaged) {
|
|
91
88
|
return {
|
|
92
89
|
mcpServers: {
|
|
93
90
|
cybermem: {
|
|
94
91
|
command: "npx",
|
|
95
|
-
args: ["@cybermem/mcp
|
|
92
|
+
args: ["@cybermem/mcp"]
|
|
96
93
|
}
|
|
97
94
|
}
|
|
98
95
|
}
|
|
99
96
|
}
|
|
100
97
|
|
|
101
|
-
// Remote mode: use
|
|
98
|
+
// Remote mode: use stdio with --url and --api-key args (universal for all clients)
|
|
102
99
|
return {
|
|
103
100
|
mcpServers: {
|
|
104
101
|
cybermem: {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
"
|
|
108
|
-
|
|
102
|
+
command: "npx",
|
|
103
|
+
args: [
|
|
104
|
+
"-y", "@cybermem/mcp",
|
|
105
|
+
"--url", baseUrl,
|
|
106
|
+
"--api-key", apiKey || "sk-your-generated-key"
|
|
107
|
+
]
|
|
109
108
|
}
|
|
110
109
|
}
|
|
111
110
|
}
|
|
@@ -119,9 +118,9 @@ export default function MCPConfigModal({ onClose }: { onClose: () => void }) {
|
|
|
119
118
|
// Handle TOML config (Codex)
|
|
120
119
|
if (config?.configType === 'toml') {
|
|
121
120
|
if (isManaged) {
|
|
122
|
-
return `# CyberMem Configuration (Local Mode)\n[mcp]\ncommand = "npx"\nargs = ["@cybermem/mcp
|
|
121
|
+
return `# CyberMem Configuration (Local Mode)\n[mcp]\ncommand = "npx"\nargs = ["@cybermem/mcp"]`;
|
|
123
122
|
}
|
|
124
|
-
return `# CyberMem Configuration\n[mcp]\
|
|
123
|
+
return `# CyberMem Configuration (Remote Mode)\n[mcp]\ncommand = "npx"\nargs = ["@cybermem/mcp", "--url", "${baseUrl}", "--api-key", "${maskKey ? displayKey : actualKey}"]`;
|
|
125
124
|
}
|
|
126
125
|
|
|
127
126
|
// Handle command-based configs (Claude Code, Gemini CLI, etc.)
|
|
@@ -134,16 +133,9 @@ export default function MCPConfigModal({ onClose }: { onClose: () => void }) {
|
|
|
134
133
|
cmd = config?.command?.replace("http://localhost:8080", baseUrl) || '';
|
|
135
134
|
}
|
|
136
135
|
|
|
137
|
-
// Substitute
|
|
138
|
-
cmd = cmd.replace('{{ENDPOINT}}',
|
|
139
|
-
|
|
140
|
-
// Remote mode - inject API key for SSE transport commands
|
|
141
|
-
if (!isManaged && cmd.includes('--transport sse')) {
|
|
142
|
-
const headerPart = `--header "x-api-key: ${maskKey ? displayKey : actualKey}"`;
|
|
143
|
-
if (!cmd.includes('x-api-key')) {
|
|
144
|
-
cmd = cmd.replace('mcp add', `mcp add ${headerPart}`);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
136
|
+
// Substitute placeholders with actual values
|
|
137
|
+
cmd = cmd.replace('{{ENDPOINT}}', baseUrl);
|
|
138
|
+
cmd = cmd.replace('{{API_KEY}}', maskKey ? displayKey : actualKey);
|
|
147
139
|
|
|
148
140
|
return cmd;
|
|
149
141
|
}
|
|
@@ -151,7 +143,12 @@ export default function MCPConfigModal({ onClose }: { onClose: () => void }) {
|
|
|
151
143
|
// Default to JSON config
|
|
152
144
|
const jsonConfig = getMcpConfig(selectedClient);
|
|
153
145
|
if (!isManaged && maskKey) {
|
|
154
|
-
|
|
146
|
+
// Mask the API key in args array
|
|
147
|
+
const args = (jsonConfig.mcpServers.cybermem as any).args;
|
|
148
|
+
const apiKeyIdx = args.indexOf('--api-key');
|
|
149
|
+
if (apiKeyIdx !== -1 && args[apiKeyIdx + 1]) {
|
|
150
|
+
args[apiKeyIdx + 1] = displayKey;
|
|
151
|
+
}
|
|
155
152
|
}
|
|
156
153
|
return JSON.stringify(jsonConfig, null, 2);
|
|
157
154
|
}
|
|
@@ -86,9 +86,9 @@ export default function MetricsChart({
|
|
|
86
86
|
)}
|
|
87
87
|
{isMultiSeries ? (
|
|
88
88
|
clientNames.map((client, i) => {
|
|
89
|
-
// Find matching config
|
|
89
|
+
// Find matching config using regex
|
|
90
90
|
const keyLower = client.toLowerCase()
|
|
91
|
-
const config = clientConfigs.find((c: any) =>
|
|
91
|
+
const config = clientConfigs.find((c: any) => new RegExp(c.match, 'i').test(keyLower))
|
|
92
92
|
|
|
93
93
|
// Use config if found, otherwise fallback
|
|
94
94
|
const name = config?.name || client
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { Card, CardContent } from "@/components/ui/card"
|
|
4
|
-
import { useDashboard } from "@/lib/data/dashboard-context"
|
|
5
4
|
import MetricCard from "./metric-card"
|
|
6
5
|
|
|
7
6
|
// Types
|
|
@@ -32,16 +31,7 @@ interface MetricsGridProps {
|
|
|
32
31
|
|
|
33
32
|
|
|
34
33
|
export default function MetricsGrid({ stats, trends }: MetricsGridProps) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const getClientDisplayName = (rawName: string) => {
|
|
38
|
-
if (!rawName || rawName === "N/A" || rawName === "unknown") return rawName
|
|
39
|
-
|
|
40
|
-
const nameLower = rawName.toLowerCase()
|
|
41
|
-
// Find matching client config (e.g. "antigravity" matches "antigravity-client")
|
|
42
|
-
const config = clientConfigs.find((c: any) => nameLower.includes(c.match))
|
|
43
|
-
return config ? config.name : rawName
|
|
44
|
-
}
|
|
34
|
+
// Note: Client names are now normalized by backend API, no frontend transformation needed
|
|
45
35
|
|
|
46
36
|
const formatTimestamp = (timestamp: number) => {
|
|
47
37
|
if (timestamp <= 0) return "No activity"
|
|
@@ -99,7 +89,7 @@ export default function MetricsGrid({ stats, trends }: MetricsGridProps) {
|
|
|
99
89
|
<Card className="bg-white/5 border-white/10 backdrop-blur-md text-white shadow-lg overflow-hidden">
|
|
100
90
|
<CardContent className="pt-6 pb-6 relative">
|
|
101
91
|
<div className="text-sm font-medium text-slate-400 mb-2">Top Writer</div>
|
|
102
|
-
<div className="text-4xl font-bold text-white mb-1 truncate">{
|
|
92
|
+
<div className="text-4xl font-bold text-white mb-1 truncate">{stats.topWriter.name}</div>
|
|
103
93
|
<div className="text-xl text-white/80 whitespace-nowrap">
|
|
104
94
|
{stats.topWriter.count > 0 ? `${stats.topWriter.count.toLocaleString()} writes` : ""}
|
|
105
95
|
</div>
|
|
@@ -111,7 +101,7 @@ export default function MetricsGrid({ stats, trends }: MetricsGridProps) {
|
|
|
111
101
|
<CardContent className="pt-6 pb-6 relative">
|
|
112
102
|
<div className="text-sm font-medium text-slate-400 mb-2">Top Reader</div>
|
|
113
103
|
<div className="text-4xl font-bold text-white mb-1 truncate">
|
|
114
|
-
{stats.topReader.count > 0 ?
|
|
104
|
+
{stats.topReader.count > 0 ? stats.topReader.name : "N/A"}
|
|
115
105
|
</div>
|
|
116
106
|
<div className="text-xl text-white/80 whitespace-nowrap">
|
|
117
107
|
{stats.topReader.count > 0 ? `${stats.topReader.count.toLocaleString()} reads` : ""}
|
|
@@ -124,7 +114,7 @@ export default function MetricsGrid({ stats, trends }: MetricsGridProps) {
|
|
|
124
114
|
<CardContent className="pt-6 pb-6 relative">
|
|
125
115
|
<div className="text-sm font-medium text-slate-400 mb-2">Last Writer</div>
|
|
126
116
|
<div className="text-4xl font-bold text-white mb-1 truncate">
|
|
127
|
-
{stats.lastWriter.name !== "N/A" ?
|
|
117
|
+
{stats.lastWriter.name !== "N/A" ? stats.lastWriter.name : "N/A"}
|
|
128
118
|
</div>
|
|
129
119
|
<div className="text-xl text-white/80 whitespace-nowrap">
|
|
130
120
|
{stats.lastWriter.timestamp > 0 ? formatTimestamp(stats.lastWriter.timestamp) : "No activity"}
|
|
@@ -137,7 +127,7 @@ export default function MetricsGrid({ stats, trends }: MetricsGridProps) {
|
|
|
137
127
|
<CardContent className="pt-6 pb-6 relative">
|
|
138
128
|
<div className="text-sm font-medium text-slate-400 mb-2">Last Reader</div>
|
|
139
129
|
<div className="text-4xl font-bold text-white mb-1 truncate">
|
|
140
|
-
{stats.lastReader.name !== "N/A" ?
|
|
130
|
+
{stats.lastReader.name !== "N/A" ? stats.lastReader.name : "N/A"}
|
|
141
131
|
</div>
|
|
142
132
|
<div className="text-xl text-white/80 whitespace-nowrap">
|
|
143
133
|
{stats.lastReader.timestamp > 0 ? formatTimestamp(stats.lastReader.timestamp) : "No activity"}
|
package/e2e/config-ui.spec.ts
CHANGED
|
@@ -72,10 +72,10 @@ test.describe('Dashboard Configuration UI', () => {
|
|
|
72
72
|
// Master API Key should NOT be visible in local mode
|
|
73
73
|
await expect(page.getByText('Master API Key')).not.toBeVisible();
|
|
74
74
|
|
|
75
|
-
// Code block should show stdio command with npx @cybermem/mcp
|
|
75
|
+
// Code block should show stdio command with npx @cybermem/mcp
|
|
76
76
|
await page.getByRole('button', { name: 'Gemini CLI' }).click();
|
|
77
77
|
const codeBlock = page.locator('pre');
|
|
78
|
-
await expect(codeBlock).toContainText('gemini mcp add cybermem npx @cybermem/mcp
|
|
78
|
+
await expect(codeBlock).toContainText('gemini mcp add cybermem npx @cybermem/mcp');
|
|
79
79
|
});
|
|
80
80
|
|
|
81
81
|
test('Remote Mode: shows API Key management', async ({ page }) => {
|
|
@@ -89,10 +89,10 @@ test.describe('Dashboard Configuration UI', () => {
|
|
|
89
89
|
// Master API Key should be visible
|
|
90
90
|
await expect(page.getByText('Master API Key')).toBeVisible();
|
|
91
91
|
|
|
92
|
-
// Gemini CLI should have
|
|
92
|
+
// Gemini CLI should have --url and --api-key args (universal stdio transport)
|
|
93
93
|
await page.getByRole('button', { name: 'Gemini CLI' }).click();
|
|
94
94
|
const codeBlock = page.locator('pre');
|
|
95
|
-
await expect(codeBlock).toContainText('--
|
|
96
|
-
await expect(codeBlock).toContainText('
|
|
95
|
+
await expect(codeBlock).toContainText('--url');
|
|
96
|
+
await expect(codeBlock).toContainText('--api-key');
|
|
97
97
|
});
|
|
98
98
|
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infrastructure Pre-flight Checks
|
|
3
|
+
*
|
|
4
|
+
* Run BEFORE any happy path tests to validate:
|
|
5
|
+
* - Dashboard API responds
|
|
6
|
+
* - db-exporter /api/stats works
|
|
7
|
+
* - Client name normalization works
|
|
8
|
+
*
|
|
9
|
+
* Usage: npm run test:e2e -- infra-check.spec.ts
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { expect, test } from '@playwright/test';
|
|
13
|
+
|
|
14
|
+
const DASHBOARD_URL = process.env.BASE_URL || 'http://localhost:3000';
|
|
15
|
+
const DB_EXPORTER_URL = process.env.DB_EXPORTER_URL || 'http://localhost:8000';
|
|
16
|
+
const MCP_URL = process.env.MCP_URL || 'http://localhost:8626';
|
|
17
|
+
|
|
18
|
+
test.describe('Infrastructure Pre-flight Checks', () => {
|
|
19
|
+
|
|
20
|
+
test('1. MCP Health endpoint responds', async ({ request }) => {
|
|
21
|
+
const res = await request.get(`${MCP_URL}/health`);
|
|
22
|
+
expect(res.ok()).toBeTruthy();
|
|
23
|
+
const data = await res.json();
|
|
24
|
+
expect(data.ok).toBe(true);
|
|
25
|
+
console.log('✅ MCP Health: OK');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('2. db-exporter /api/stats responds', async ({ request }) => {
|
|
29
|
+
const res = await request.get(`${DB_EXPORTER_URL}/api/stats`);
|
|
30
|
+
expect(res.ok()).toBeTruthy();
|
|
31
|
+
const data = await res.json();
|
|
32
|
+
expect(typeof data.memoryRecords).toBe('number');
|
|
33
|
+
expect(typeof data.totalClients).toBe('number');
|
|
34
|
+
expect(typeof data.successRate).toBe('number');
|
|
35
|
+
console.log(`✅ db-exporter: ${data.memoryRecords} memories, ${data.totalClients} clients`);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('3. db-exporter /api/timeseries responds', async ({ request }) => {
|
|
39
|
+
const res = await request.get(`${DB_EXPORTER_URL}/api/timeseries?period=24h`);
|
|
40
|
+
expect(res.ok()).toBeTruthy();
|
|
41
|
+
const data = await res.json();
|
|
42
|
+
expect(Array.isArray(data.creates)).toBeTruthy();
|
|
43
|
+
expect(Array.isArray(data.reads)).toBeTruthy();
|
|
44
|
+
console.log('✅ db-exporter timeseries: OK');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('4. Dashboard /api/metrics responds', async ({ request }) => {
|
|
48
|
+
const res = await request.get(`${DASHBOARD_URL}/api/metrics`);
|
|
49
|
+
expect(res.ok()).toBeTruthy();
|
|
50
|
+
const data = await res.json();
|
|
51
|
+
expect(data.stats).toBeDefined();
|
|
52
|
+
expect(typeof data.stats.memoryRecords).toBe('number');
|
|
53
|
+
console.log(`✅ Dashboard API: ${data.stats.memoryRecords} memories`);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('5. Dashboard /api/audit-logs responds', async ({ request }) => {
|
|
57
|
+
const res = await request.get(`${DASHBOARD_URL}/api/audit-logs`);
|
|
58
|
+
expect(res.ok()).toBeTruthy();
|
|
59
|
+
const data = await res.json();
|
|
60
|
+
expect(Array.isArray(data.logs)).toBeTruthy();
|
|
61
|
+
console.log(`✅ Audit Logs API: ${data.logs.length} entries`);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('6. Client name normalization works', async ({ request }) => {
|
|
65
|
+
// Check that clients.json is accessible
|
|
66
|
+
const configRes = await request.get(`${DASHBOARD_URL}/clients.json`);
|
|
67
|
+
expect(configRes.ok()).toBeTruthy();
|
|
68
|
+
const config = await configRes.json();
|
|
69
|
+
|
|
70
|
+
// Verify Antigravity mapping exists
|
|
71
|
+
const antigravity = config.find((c: any) => c.name === 'Antigravity');
|
|
72
|
+
expect(antigravity).toBeDefined();
|
|
73
|
+
expect(antigravity.match).toContain('antigravity');
|
|
74
|
+
console.log(`✅ Client normalization: Antigravity pattern = "${antigravity.match}"`);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('7. Dashboard UI loads', async ({ page }) => {
|
|
78
|
+
await page.goto(DASHBOARD_URL);
|
|
79
|
+
|
|
80
|
+
// Handle login if needed
|
|
81
|
+
const passwordInput = page.getByPlaceholder('Enter admin password');
|
|
82
|
+
if (await passwordInput.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
83
|
+
await passwordInput.fill('admin');
|
|
84
|
+
await page.keyboard.press('Enter');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Dismiss password warning if present
|
|
88
|
+
const dontShowBtn = page.locator('button:has-text("Don\'t show again")');
|
|
89
|
+
if (await dontShowBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
90
|
+
await dontShowBtn.click();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Verify dashboard loaded
|
|
94
|
+
await expect(page.getByRole('heading', { name: 'CyberMem' })).toBeVisible({ timeout: 15000 });
|
|
95
|
+
console.log('✅ Dashboard UI: Loaded');
|
|
96
|
+
});
|
|
97
|
+
});
|
package/package.json
CHANGED
package/public/clients.json
CHANGED
|
@@ -27,24 +27,24 @@
|
|
|
27
27
|
"Navigate to **Features** > **MCP**",
|
|
28
28
|
"Click **Add New MCP Server**",
|
|
29
29
|
"Enter Name: `cybermem`",
|
|
30
|
-
"Enter Type: `
|
|
31
|
-
"
|
|
30
|
+
"Enter Type: `Command`",
|
|
31
|
+
"Copy the configuration from below"
|
|
32
32
|
],
|
|
33
33
|
"configType": "json"
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
"id": "antigravity",
|
|
37
37
|
"name": "Antigravity",
|
|
38
|
-
"match": "antigravity",
|
|
38
|
+
"match": "antigravity|antigravity-client",
|
|
39
39
|
"color": "#d946ef",
|
|
40
40
|
"icon": "/icons/antigravity.png",
|
|
41
|
-
"description": "Antigravity supports MCP via raw JSON configuration.
|
|
41
|
+
"description": "Antigravity supports MCP via raw JSON configuration.",
|
|
42
42
|
"steps": [
|
|
43
43
|
"Open Antigravity and go to the **Agents** tab",
|
|
44
44
|
"Click on the three dots menu and select **MCP Servers**",
|
|
45
45
|
"Click **Manage MCP Servers**",
|
|
46
46
|
"Click **View RAW Config**",
|
|
47
|
-
"Paste the configuration block below
|
|
47
|
+
"Paste the configuration block below"
|
|
48
48
|
],
|
|
49
49
|
"configType": "json"
|
|
50
50
|
},
|
|
@@ -107,8 +107,8 @@
|
|
|
107
107
|
"The server will be available in all Claude Code sessions"
|
|
108
108
|
],
|
|
109
109
|
"configType": "command",
|
|
110
|
-
"localCommand": "claude mcp add cybermem npx @cybermem/mcp
|
|
111
|
-
"remoteCommand": "claude mcp add --
|
|
110
|
+
"localCommand": "claude mcp add cybermem npx @cybermem/mcp",
|
|
111
|
+
"remoteCommand": "claude mcp add cybermem -- npx @cybermem/mcp --url {{ENDPOINT}} --api-key {{API_KEY}}"
|
|
112
112
|
},
|
|
113
113
|
{
|
|
114
114
|
"id": "chatgpt",
|
|
@@ -170,8 +170,8 @@
|
|
|
170
170
|
"The server persists across sessions"
|
|
171
171
|
],
|
|
172
172
|
"configType": "cmd",
|
|
173
|
-
"localCommand": "gemini mcp add cybermem npx @cybermem/mcp
|
|
174
|
-
"remoteCommand": "gemini mcp add --
|
|
173
|
+
"localCommand": "gemini mcp add cybermem npx @cybermem/mcp",
|
|
174
|
+
"remoteCommand": "gemini mcp add cybermem -- npx @cybermem/mcp --url {{ENDPOINT}} --api-key {{API_KEY}}"
|
|
175
175
|
},
|
|
176
176
|
{
|
|
177
177
|
"id": "other",
|
|
@@ -181,9 +181,8 @@
|
|
|
181
181
|
"icon": null,
|
|
182
182
|
"description": "For any other MCP-compliant client, use the following connection details:",
|
|
183
183
|
"steps": [
|
|
184
|
-
"**Transport**:
|
|
185
|
-
"**
|
|
186
|
-
"**Authentication**: `x-api-key` header (if required)",
|
|
184
|
+
"**Transport**: stdio (via npx command)",
|
|
185
|
+
"**Command**: `npx @cybermem/mcp` with `--url` and `--api-key` args",
|
|
187
186
|
"Refer to your client's documentation for config file location"
|
|
188
187
|
],
|
|
189
188
|
"configType": "json"
|