@geminilight/mindos 0.5.26 → 0.5.28

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.
@@ -1,20 +1,37 @@
1
1
  export const dynamic = 'force-dynamic';
2
- import { NextResponse } from 'next/server';
2
+ import { NextRequest, NextResponse } from 'next/server';
3
3
  import { readSettings } from '@/lib/settings';
4
+ import { maskToken } from '@/lib/format';
4
5
 
5
- export async function GET() {
6
+ /** Parse hostname from Host header, handling IPv6 brackets */
7
+ function parseHostname(host: string): string {
8
+ // IPv6: [::1]:3003 → [::1]
9
+ if (host.includes(']')) {
10
+ return host.slice(0, host.lastIndexOf(']') + 1);
11
+ }
12
+ // IPv4/hostname: 192.168.1.1:3003 → 192.168.1.1
13
+ const colonIdx = host.lastIndexOf(':');
14
+ return colonIdx > 0 ? host.slice(0, colonIdx) : host;
15
+ }
16
+
17
+ export async function GET(req: NextRequest) {
6
18
  try {
7
19
  const settings = readSettings();
8
20
  const port = settings.mcpPort ?? 8781;
9
- const baseUrl = `http://127.0.0.1:${port}`;
10
- const endpoint = `${baseUrl}/mcp`;
11
- const authConfigured = !!settings.authToken;
21
+ const token = settings.authToken ?? '';
22
+ const authConfigured = !!token;
23
+
24
+ // Derive endpoint from the request's host so remote users see the correct IP/hostname
25
+ const reqHost = req.headers.get('host') ?? `127.0.0.1:${port}`;
26
+ const hostname = parseHostname(reqHost);
27
+ const endpoint = `http://${hostname}:${port}/mcp`;
28
+
29
+ // Health check always goes to localhost (server-to-self)
30
+ const healthUrl = `http://127.0.0.1:${port}/api/health`;
12
31
 
13
32
  let running = false;
14
33
 
15
34
  try {
16
- // Use the health endpoint — avoids MCP handshake complexity
17
- const healthUrl = `${baseUrl}/api/health`;
18
35
  const controller = new AbortController();
19
36
  const timeout = setTimeout(() => controller.abort(), 2000);
20
37
  const res = await fetch(healthUrl, { signal: controller.signal, cache: 'no-store' });
@@ -35,6 +52,12 @@ export async function GET() {
35
52
  port,
36
53
  toolCount: running ? 20 : 0,
37
54
  authConfigured,
55
+ // Masked for display; full token only used server-side in snippet generation
56
+ maskedToken: authConfigured ? maskToken(token) : undefined,
57
+ // Full token for config snippet copy — this API is protected by proxy.ts middleware
58
+ // (same-origin or bearer token required). Consistent with /api/settings which also
59
+ // exposes the token to authenticated users.
60
+ authToken: authConfigured ? token : undefined,
38
61
  });
39
62
  } catch (err) {
40
63
  return NextResponse.json({ error: String(err) }, { status: 500 });
@@ -7,6 +7,7 @@ import rehypeRaw from 'rehype-raw';
7
7
  import rehypeSlug from 'rehype-slug';
8
8
  import { useState, useCallback } from 'react';
9
9
  import { Copy, Check } from 'lucide-react';
10
+ import { copyToClipboard } from '@/lib/clipboard';
10
11
  import type { Components } from 'react-markdown';
11
12
 
12
13
  interface MarkdownViewProps {
@@ -16,9 +17,11 @@ interface MarkdownViewProps {
16
17
  function CopyButton({ code }: { code: string }) {
17
18
  const [copied, setCopied] = useState(false);
18
19
  const handleCopy = useCallback(() => {
19
- navigator.clipboard.writeText(code).then(() => {
20
- setCopied(true);
21
- setTimeout(() => setCopied(false), 2000);
20
+ copyToClipboard(code).then((ok) => {
21
+ if (ok) {
22
+ setCopied(true);
23
+ setTimeout(() => setCopied(false), 2000);
24
+ }
22
25
  });
23
26
  }, [code]);
24
27
 
@@ -5,6 +5,7 @@ import { Copy, Check, RefreshCw, Trash2, Sparkles, ChevronDown, ChevronRight, Lo
5
5
  import type { KnowledgeTabProps } from './types';
6
6
  import { Field, Input, EnvBadge, SectionLabel, Toggle } from './Primitives';
7
7
  import { apiFetch } from '@/lib/api';
8
+ import { copyToClipboard } from '@/lib/clipboard';
8
9
  import { formatBytes, formatUptime } from '@/lib/format';
9
10
 
10
11
  export function KnowledgeTab({ data, setData, t }: KnowledgeTabProps) {
@@ -85,9 +86,11 @@ export function KnowledgeTab({ data, setData, t }: KnowledgeTabProps) {
85
86
  function handleCopy() {
86
87
  const text = revealedToken ?? data.authToken ?? '';
87
88
  if (!text) return;
88
- navigator.clipboard.writeText(text).then(() => {
89
- setCopied(true);
90
- setTimeout(() => setCopied(false), 2000);
89
+ copyToClipboard(text).then((ok) => {
90
+ if (ok) {
91
+ setCopied(true);
92
+ setTimeout(() => setCopied(false), 2000);
93
+ }
91
94
  });
92
95
  }
93
96
 
@@ -1,24 +1,26 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useMemo } from 'react';
4
- import { Plug, Copy, Check, ChevronDown } from 'lucide-react';
3
+ import { useState, useEffect, useMemo, useCallback } from 'react';
4
+ import { Plug, Copy, Check, ChevronDown, Monitor, Globe, Code } from 'lucide-react';
5
+ import { copyToClipboard } from '@/lib/clipboard';
5
6
  import type { McpStatus, AgentInfo, McpServerStatusProps } from './types';
6
7
 
7
8
  /* ── Helpers ───────────────────────────────────────────────────── */
8
9
 
9
10
  function CopyButton({ text, label, copiedLabel }: { text: string; label: string; copiedLabel?: string }) {
10
11
  const [copied, setCopied] = useState(false);
11
- const handleCopy = async () => {
12
- try {
13
- await navigator.clipboard.writeText(text);
12
+ const handleCopy = useCallback(async () => {
13
+ const ok = await copyToClipboard(text);
14
+ if (ok) {
14
15
  setCopied(true);
15
16
  setTimeout(() => setCopied(false), 2000);
16
- } catch { /* clipboard unavailable */ }
17
- };
17
+ }
18
+ }, [text]);
18
19
  return (
19
20
  <button
21
+ type="button"
20
22
  onClick={handleCopy}
21
- className="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
23
+ className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded-md border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer shrink-0 relative z-10"
22
24
  >
23
25
  {copied ? <Check size={11} /> : <Copy size={11} />}
24
26
  {copied ? (copiedLabel ?? 'Copied!') : label}
@@ -28,60 +30,91 @@ function CopyButton({ text, label, copiedLabel }: { text: string; label: string;
28
30
 
29
31
  /* ── Config Snippet Generator ─────────────────────────────────── */
30
32
 
31
- function generateConfigSnippet(
32
- agent: AgentInfo,
33
- status: McpStatus,
34
- token?: string,
35
- ): { snippet: string; path: string } {
36
- const isRunning = status.running;
33
+ interface ConfigSnippet {
34
+ /** Snippet with full token — for clipboard copy */
35
+ snippet: string;
36
+ /** Snippet with masked token — for display in UI */
37
+ displaySnippet: string;
38
+ path: string;
39
+ }
37
40
 
38
- // Determine entry (stdio vs http)
41
+ function generateStdioSnippet(agent: AgentInfo): ConfigSnippet {
39
42
  const stdioEntry: Record<string, unknown> = { type: 'stdio', command: 'mindos', args: ['mcp'] };
40
- const httpEntry: Record<string, unknown> = { url: status.endpoint };
41
- if (token) httpEntry.headers = { Authorization: `Bearer ${token}` };
42
- const entry = isRunning ? httpEntry : stdioEntry;
43
43
 
44
- // TOML format (Codex)
45
44
  if (agent.format === 'toml') {
46
- const lines: string[] = [`[${agent.configKey}.mindos]`];
47
- if (isRunning) {
48
- lines.push(`type = "http"`);
49
- lines.push(`url = "${status.endpoint}"`);
50
- if (token) {
45
+ const lines = [
46
+ `[${agent.configKey}.mindos]`,
47
+ `command = "mindos"`,
48
+ `args = ["mcp"]`,
49
+ '',
50
+ `[${agent.configKey}.mindos.env]`,
51
+ `MCP_TRANSPORT = "stdio"`,
52
+ ];
53
+ const s = lines.join('\n');
54
+ return { snippet: s, displaySnippet: s, path: agent.globalPath };
55
+ }
56
+
57
+ if (agent.globalNestedKey) {
58
+ const s = JSON.stringify({ [agent.configKey]: { mindos: stdioEntry } }, null, 2);
59
+ return { snippet: s, displaySnippet: s, path: agent.projectPath ?? agent.globalPath };
60
+ }
61
+
62
+ const s = JSON.stringify({ [agent.configKey]: { mindos: stdioEntry } }, null, 2);
63
+ return { snippet: s, displaySnippet: s, path: agent.globalPath };
64
+ }
65
+
66
+ function generateHttpSnippet(
67
+ agent: AgentInfo,
68
+ endpoint: string,
69
+ token?: string,
70
+ maskedToken?: string,
71
+ ): ConfigSnippet {
72
+ // Full token for copy
73
+ const httpEntry: Record<string, unknown> = { url: endpoint };
74
+ if (token) httpEntry.headers = { Authorization: `Bearer ${token}` };
75
+
76
+ // Masked token for display
77
+ const displayEntry: Record<string, unknown> = { url: endpoint };
78
+ if (maskedToken) displayEntry.headers = { Authorization: `Bearer ${maskedToken}` };
79
+
80
+ const buildSnippet = (entry: Record<string, unknown>) => {
81
+ if (agent.format === 'toml') {
82
+ const lines = [
83
+ `[${agent.configKey}.mindos]`,
84
+ `type = "http"`,
85
+ `url = "${endpoint}"`,
86
+ ];
87
+ const authVal = (entry.headers as Record<string, string>)?.Authorization;
88
+ if (authVal) {
51
89
  lines.push('');
52
90
  lines.push(`[${agent.configKey}.mindos.headers]`);
53
- lines.push(`Authorization = "Bearer ${token}"`);
91
+ lines.push(`Authorization = "${authVal}"`);
54
92
  }
55
- } else {
56
- lines.push(`command = "mindos"`);
57
- lines.push(`args = ["mcp"]`);
58
- lines.push('');
59
- lines.push(`[${agent.configKey}.mindos.env]`);
60
- lines.push(`MCP_TRANSPORT = "stdio"`);
93
+ return lines.join('\n');
61
94
  }
62
- return { snippet: lines.join('\n'), path: agent.globalPath };
63
- }
64
95
 
65
- // JSON with globalNestedKey (VS Code project-level uses flat key)
66
- if (agent.globalNestedKey) {
67
- // project-level: flat key structure
68
- const projectSnippet = JSON.stringify({ [agent.configKey]: { mindos: entry } }, null, 2);
69
- return { snippet: projectSnippet, path: agent.projectPath ?? agent.globalPath };
70
- }
96
+ if (agent.globalNestedKey) {
97
+ return JSON.stringify({ [agent.configKey]: { mindos: entry } }, null, 2);
98
+ }
99
+
100
+ return JSON.stringify({ [agent.configKey]: { mindos: entry } }, null, 2);
101
+ };
71
102
 
72
- // Standard JSON
73
- const snippet = JSON.stringify({ [agent.configKey]: { mindos: entry } }, null, 2);
74
- return { snippet, path: agent.globalPath };
103
+ return {
104
+ snippet: buildSnippet(httpEntry),
105
+ displaySnippet: buildSnippet(token ? displayEntry : httpEntry),
106
+ path: agent.format === 'toml' ? agent.globalPath : (agent.globalNestedKey ? (agent.projectPath ?? agent.globalPath) : agent.globalPath),
107
+ };
75
108
  }
76
109
 
77
110
  /* ── MCP Server Status ─────────────────────────────────────────── */
78
111
 
79
112
  export default function ServerStatus({ status, agents, t }: McpServerStatusProps) {
80
113
  const m = t.settings?.mcp;
81
- const [expanded, setExpanded] = useState(false);
82
114
  const [selectedAgent, setSelectedAgent] = useState<string>('');
115
+ const [mode, setMode] = useState<'stdio' | 'http'>('stdio');
116
+ const [showSnippet, setShowSnippet] = useState(false);
83
117
 
84
- // Auto-select first installed or first detected agent
85
118
  useEffect(() => {
86
119
  if (agents.length > 0 && !selectedAgent) {
87
120
  const first = agents.find(a => a.installed) ?? agents.find(a => a.present) ?? agents[0];
@@ -89,84 +122,153 @@ export default function ServerStatus({ status, agents, t }: McpServerStatusProps
89
122
  }
90
123
  }, [agents, selectedAgent]);
91
124
 
125
+ useEffect(() => {
126
+ if (status?.endpoint && !status.endpoint.includes('127.0.0.1') && !status.endpoint.includes('localhost')) {
127
+ setMode('http');
128
+ }
129
+ }, [status?.endpoint]);
130
+
92
131
  if (!status) return null;
93
132
 
94
133
  const currentAgent = agents.find(a => a.key === selectedAgent);
95
- // 🟡 MINOR #9: Memoize snippet generation to avoid recomputing on every render
96
- const snippetResult = useMemo(() => currentAgent ? generateConfigSnippet(currentAgent, status) : null, [currentAgent, status]);
134
+
135
+ const snippetResult = useMemo(() => {
136
+ if (!currentAgent) return null;
137
+ if (mode === 'stdio') return generateStdioSnippet(currentAgent);
138
+ return generateHttpSnippet(currentAgent, status.endpoint, status.authToken, status.maskedToken);
139
+ }, [currentAgent, status, mode]);
140
+
141
+ const isRemote = status.endpoint && !status.endpoint.includes('127.0.0.1') && !status.endpoint.includes('localhost');
97
142
 
98
143
  return (
99
144
  <div>
100
- {/* Summary line always visible */}
101
- <button
102
- type="button"
103
- onClick={() => setExpanded(!expanded)}
104
- className="w-full flex items-center gap-2.5 text-xs"
105
- >
106
- <Plug size={14} className="text-muted-foreground shrink-0" />
107
- <span className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${status.running ? 'bg-success' : 'bg-muted-foreground'}`} />
108
- <span className="text-foreground font-medium">
109
- {status.running ? (m?.running ?? 'Running') : (m?.stopped ?? 'Stopped')}
110
- </span>
111
- {status.running && (
112
- <>
113
- <span className="text-muted-foreground">·</span>
114
- <span className="font-mono text-muted-foreground">{status.transport.toUpperCase()}</span>
115
- <span className="text-muted-foreground">·</span>
116
- <span className="text-muted-foreground">{m?.toolsRegistered ? m.toolsRegistered(status.toolCount) : `${status.toolCount} tools`}</span>
117
- <span className="text-muted-foreground">·</span>
118
- <span className={status.authConfigured ? 'text-success' : 'text-muted-foreground'}>
119
- {status.authConfigured ? (m?.authSet ?? 'Token set') : (m?.authNotSet ?? 'No token')}
120
- </span>
121
- </>
122
- )}
123
- <ChevronDown size={12} className={`ml-auto text-muted-foreground transition-transform shrink-0 ${expanded ? 'rotate-180' : ''}`} />
124
- </button>
125
-
126
- {/* Expanded details */}
127
- {expanded && (
128
- <div className="pt-3 mt-3 border-t border-border space-y-3">
129
- {/* Endpoint + copy */}
130
- <div className="flex items-center gap-2 text-xs">
131
- <span className="text-muted-foreground shrink-0">{m?.endpoint ?? 'Endpoint'}</span>
132
- <span className="font-mono text-foreground truncate">{status.endpoint}</span>
133
- <CopyButton text={status.endpoint} label={m?.copyEndpoint ?? 'Copy'} copiedLabel={m?.copied} />
134
- </div>
145
+ <h3 className="text-sm font-medium text-foreground mb-3">{m?.serverTitle ?? 'MCP Server'}</h3>
135
146
 
136
- {/* Quick Setup */}
137
- {agents.length > 0 && (
138
- <div className="space-y-2.5">
139
- <div className="flex items-center gap-2 text-xs">
140
- <span className="text-muted-foreground shrink-0">{m?.configureFor ?? 'Configure for'}</span>
141
- <select
142
- value={selectedAgent}
143
- onChange={e => setSelectedAgent(e.target.value)}
144
- className="text-xs px-2 py-1 rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
147
+ <div className="rounded-xl border p-4 space-y-3" style={{ borderColor: 'var(--border)', background: 'var(--card)' }}>
148
+ {/* Status line */}
149
+ <div className="flex items-center gap-2.5 text-xs">
150
+ <Plug size={14} className="text-muted-foreground shrink-0" />
151
+ <span className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${status.running ? 'bg-success' : 'bg-muted-foreground'}`} />
152
+ <span className="text-foreground font-medium">
153
+ {status.running ? (m?.running ?? 'Running') : (m?.stopped ?? 'Stopped')}
154
+ </span>
155
+ {status.running && (
156
+ <>
157
+ <span className="text-muted-foreground">·</span>
158
+ <span className="font-mono text-muted-foreground">{status.transport.toUpperCase()}</span>
159
+ <span className="text-muted-foreground">·</span>
160
+ <span className="text-muted-foreground">{m?.toolsRegistered ? m.toolsRegistered(status.toolCount) : `${status.toolCount} tools`}</span>
161
+ <span className="text-muted-foreground">·</span>
162
+ <span className={status.authConfigured ? 'text-success' : 'text-muted-foreground'}>
163
+ {status.authConfigured ? (m?.authSet ?? 'Token set') : (m?.authNotSet ?? 'No token')}
164
+ </span>
165
+ </>
166
+ )}
167
+ </div>
168
+
169
+ {/* Endpoint + copy */}
170
+ <div className="flex items-center gap-2 text-xs">
171
+ <span className="text-muted-foreground shrink-0">{m?.endpoint ?? 'Endpoint'}</span>
172
+ <span className="font-mono text-foreground truncate">{status.endpoint}</span>
173
+ <CopyButton text={status.endpoint} label={m?.copyEndpoint ?? 'Copy'} copiedLabel={m?.copied} />
174
+ </div>
175
+
176
+ {/* Quick Setup */}
177
+ {agents.length > 0 && (
178
+ <div className="pt-2 border-t border-border space-y-2.5">
179
+ {/* Agent selector + transport mode toggle */}
180
+ <div className="flex items-center gap-2 text-xs flex-wrap">
181
+ <span className="text-muted-foreground shrink-0">{m?.configureFor ?? 'Configure for'}</span>
182
+ <select
183
+ value={selectedAgent}
184
+ onChange={e => setSelectedAgent(e.target.value)}
185
+ className="text-xs px-2 py-1 rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
186
+ >
187
+ {agents.map(a => (
188
+ <option key={a.key} value={a.key}>
189
+ {a.name}{a.installed ? ' ✓' : a.present ? ' ·' : ''}
190
+ </option>
191
+ ))}
192
+ </select>
193
+
194
+ <div className="flex items-center rounded-md border border-border overflow-hidden ml-auto">
195
+ <button
196
+ type="button"
197
+ onClick={() => setMode('stdio')}
198
+ className={`flex items-center gap-1 px-2 py-1 text-xs transition-colors ${
199
+ mode === 'stdio'
200
+ ? 'bg-muted text-foreground font-medium'
201
+ : 'text-muted-foreground hover:text-foreground'
202
+ }`}
203
+ title={m?.transportLocalHint ?? 'Local — same machine as MindOS server'}
204
+ >
205
+ <Monitor size={11} />
206
+ {m?.transportLocal ?? 'Local'}
207
+ </button>
208
+ <button
209
+ type="button"
210
+ onClick={() => setMode('http')}
211
+ className={`flex items-center gap-1 px-2 py-1 text-xs transition-colors ${
212
+ mode === 'http'
213
+ ? 'bg-muted text-foreground font-medium'
214
+ : 'text-muted-foreground hover:text-foreground'
215
+ }`}
216
+ title={m?.transportRemoteHint ?? 'Remote — connect from another device via HTTP'}
145
217
  >
146
- {agents.map(a => (
147
- <option key={a.key} value={a.key}>
148
- {a.name}{a.installed ? ' ✓' : a.present ? ' ·' : ''}
149
- </option>
150
- ))}
151
- </select>
218
+ <Globe size={11} />
219
+ {m?.transportRemote ?? 'Remote'}
220
+ </button>
152
221
  </div>
222
+ </div>
153
223
 
154
- {snippetResult && (
155
- <>
156
- <div className="flex items-center gap-2 text-xs">
157
- <span className="text-muted-foreground shrink-0">{m?.configPath ?? 'Config path'}</span>
158
- <span className="font-mono text-foreground text-2xs">{snippetResult.path}</span>
159
- </div>
160
- <pre className="text-xs font-mono bg-muted/50 border border-border rounded-lg p-3 overflow-x-auto whitespace-pre">
161
- {snippetResult.snippet}
162
- </pre>
224
+ {/* Hint for remote mode */}
225
+ {mode === 'http' && (
226
+ <div className="text-[11px] text-muted-foreground leading-relaxed space-y-1">
227
+ <p>
228
+ {isRemote
229
+ ? (m?.remoteDetectedHint ?? 'Using your current remote IP.')
230
+ : (m?.remoteManualHint ?? 'Replace 127.0.0.1 with your server\'s public or LAN IP.')}
231
+ </p>
232
+ <p>
233
+ {(m?.remoteSteps ?? 'To connect from another device: ① Open port {port} in firewall/security group ② Use the config below in your Agent ③ For public networks, consider SSH tunnel for encryption.')
234
+ .replace('{port}', String(status.port))}
235
+ </p>
236
+ {!status.authConfigured && (
237
+ <p className="text-amber-500">{m?.noAuthWarning ?? '⚠ No auth token — set one in Settings → General before enabling remote access.'}</p>
238
+ )}
239
+ </div>
240
+ )}
241
+
242
+ {/* Copy config + show JSON toggle */}
243
+ {snippetResult && (
244
+ <div className="space-y-2">
245
+ <div className="flex items-center gap-2 text-xs flex-wrap">
246
+ {/* Copy button uses full token (snippetResult.snippet) */}
163
247
  <CopyButton text={snippetResult.snippet} label={m?.copyConfig ?? 'Copy Config'} copiedLabel={m?.copied} />
164
- </>
165
- )}
166
- </div>
167
- )}
168
- </div>
169
- )}
248
+ <span className="text-muted-foreground">→</span>
249
+ <span className="font-mono text-muted-foreground text-[11px] truncate">{snippetResult.path}</span>
250
+ <button
251
+ type="button"
252
+ onClick={() => setShowSnippet(!showSnippet)}
253
+ className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors ml-auto"
254
+ >
255
+ <Code size={10} />
256
+ {showSnippet ? (m?.hideJson ?? 'Hide JSON') : (m?.showJson ?? 'Show JSON')}
257
+ <ChevronDown size={10} className={`transition-transform ${showSnippet ? 'rotate-180' : ''}`} />
258
+ </button>
259
+ </div>
260
+
261
+ {/* Display snippet uses masked token */}
262
+ {showSnippet && (
263
+ <pre className="text-xs font-mono bg-muted/50 border border-border rounded-lg p-3 overflow-x-auto whitespace-pre select-all">
264
+ {snippetResult.displaySnippet}
265
+ </pre>
266
+ )}
267
+ </div>
268
+ )}
269
+ </div>
270
+ )}
271
+ </div>
170
272
  </div>
171
273
  );
172
274
  }
@@ -42,10 +42,8 @@ export function McpTab({ t }: McpTabProps) {
42
42
 
43
43
  return (
44
44
  <div className="space-y-6">
45
- {/* MCP Server Status — compact card */}
46
- <div className="rounded-xl border p-4" style={{ borderColor: 'var(--border)', background: 'var(--card)' }}>
47
- <ServerStatus status={mcpStatus} agents={agents} t={t} />
48
- </div>
45
+ {/* MCP Server Status */}
46
+ <ServerStatus status={mcpStatus} agents={agents} t={t} />
49
47
 
50
48
  {/* Skills */}
51
49
  <div>
@@ -58,6 +58,8 @@ export interface McpStatus {
58
58
  port: number;
59
59
  toolCount: number;
60
60
  authConfigured: boolean;
61
+ maskedToken?: string;
62
+ authToken?: string;
61
63
  }
62
64
 
63
65
  export interface AgentInfo {
@@ -3,6 +3,7 @@
3
3
  import { useState, useEffect, useCallback } from 'react';
4
4
  import { Sparkles, Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
5
5
  import { useLocale } from '@/lib/LocaleContext';
6
+ import { copyToClipboard } from '@/lib/clipboard';
6
7
  import type { SetupState, PortStatus, AgentEntry, AgentInstallStatus } from './types';
7
8
  import { TOTAL_STEPS, STEP_KB, STEP_PORTS, STEP_AGENTS } from './constants';
8
9
  import StepKB from './StepKB';
@@ -229,7 +230,7 @@ export default function SetupWizard() {
229
230
  }, []);
230
231
 
231
232
  const copyToken = useCallback(() => {
232
- navigator.clipboard.writeText(state.authToken).catch(() => { /* clipboard unavailable in insecure context */ });
233
+ copyToClipboard(state.authToken).catch(() => {});
233
234
  setTokenCopied(true);
234
235
  setTimeout(() => setTokenCopied(false), 2000);
235
236
  }, [state.authToken]);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Copy text to clipboard with fallback for non-HTTPS environments.
3
+ * Returns true if successful.
4
+ */
5
+ export async function copyToClipboard(text: string): Promise<boolean> {
6
+ // Modern Clipboard API (requires HTTPS or localhost)
7
+ if (navigator.clipboard?.writeText) {
8
+ try {
9
+ await navigator.clipboard.writeText(text);
10
+ return true;
11
+ } catch { /* falls through to fallback */ }
12
+ }
13
+ // Fallback: textarea + execCommand (works over HTTP).
14
+ // execCommand('copy') is deprecated but remains the only option for
15
+ // non-secure contexts. No replacement exists yet — monitor browser support.
16
+ try {
17
+ const textarea = document.createElement('textarea');
18
+ textarea.value = text;
19
+ textarea.style.position = 'fixed';
20
+ textarea.style.opacity = '0';
21
+ document.body.appendChild(textarea);
22
+ textarea.select();
23
+ const ok = document.execCommand('copy');
24
+ document.body.removeChild(textarea);
25
+ return ok;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
package/app/lib/format.ts CHANGED
@@ -17,3 +17,9 @@ export function formatUptime(ms: number): string {
17
17
  const d = Math.floor(h / 24);
18
18
  return `${d}d ${h % 24}h`;
19
19
  }
20
+
21
+ /** Mask a token for display: show first 4 and last 4 chars, middle replaced with bullets */
22
+ export function maskToken(token: string): string {
23
+ if (token.length <= 12) return '••••';
24
+ return `${token.slice(0, 4)}••••${token.slice(-4)}`;
25
+ }
@@ -261,6 +261,16 @@ export const en = {
261
261
  quickSetup: 'Quick Setup',
262
262
  configureFor: 'Configure for',
263
263
  configPath: 'Config path',
264
+ transportLocal: 'Local',
265
+ transportRemote: 'Remote',
266
+ transportLocalHint: 'Local — same machine as MindOS server',
267
+ transportRemoteHint: 'Remote — connect from another device via HTTP',
268
+ remoteDetectedHint: 'Using your current remote IP.',
269
+ remoteManualHint: 'Replace 127.0.0.1 with your server\'s public or LAN IP.',
270
+ remoteSteps: 'To connect from another device: ① Open port {port} in firewall/security group ② Use the config below in your Agent ③ For public networks, consider SSH tunnel for encryption.',
271
+ noAuthWarning: '⚠ No auth token — set one in Settings → General before enabling remote access.',
272
+ showJson: 'Show JSON',
273
+ hideJson: 'Hide JSON',
264
274
  },
265
275
  monitoring: {
266
276
  system: 'System',
@@ -286,6 +286,16 @@ export const zh = {
286
286
  quickSetup: '快速配置',
287
287
  configureFor: '配置目标',
288
288
  configPath: '配置路径',
289
+ transportLocal: '本地',
290
+ transportRemote: '远程',
291
+ transportLocalHint: '本地 — 与 MindOS 服务在同一台机器上',
292
+ transportRemoteHint: '远程 — 从其他设备通过 HTTP 连接',
293
+ remoteDetectedHint: '使用当前远程 IP。',
294
+ remoteManualHint: '将 127.0.0.1 替换为服务器的公网或局域网 IP。',
295
+ remoteSteps: '从其他设备连接:① 在防火墙/安全组中开放端口 {port} ② 将下方配置粘贴到 Agent 中 ③ 公网环境建议使用 SSH 隧道加密传输。',
296
+ noAuthWarning: '⚠ 未设置认证令牌 — 请先在 设置 → 通用 中配置,再启用远程访问。',
297
+ showJson: '显示 JSON',
298
+ hideJson: '隐藏 JSON',
289
299
  },
290
300
  monitoring: {
291
301
  system: '系统',
@@ -3,7 +3,7 @@ import path from "path";
3
3
 
4
4
  const nextConfig: NextConfig = {
5
5
  transpilePackages: ['github-slugger'],
6
- serverExternalPackages: ['pdfjs-dist', 'pdf-parse', 'chokidar', 'openai', '@mariozechner/pi-ai', '@mariozechner/pi-agent-core'],
6
+ serverExternalPackages: ['chokidar', 'openai', '@mariozechner/pi-ai', '@mariozechner/pi-agent-core'],
7
7
  outputFileTracingRoot: path.join(__dirname),
8
8
  turbopack: {
9
9
  root: path.join(__dirname),
@@ -1,7 +1,7 @@
1
1
  import { execSync, spawn } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
- import { ROOT } from './constants.js';
4
+ import { ROOT, CONFIG_PATH } from './constants.js';
5
5
  import { bold, red, yellow } from './colors.js';
6
6
  import { npmInstall } from './utils.js';
7
7
 
@@ -14,10 +14,21 @@ export function spawnMcp(verbose = false) {
14
14
  console.log(yellow('Installing MCP dependencies (first run)...\n'));
15
15
  npmInstall(resolve(ROOT, 'mcp'), '--no-workspaces');
16
16
  }
17
+
18
+ // Read AUTH_TOKEN directly from config to avoid stale system env overriding
19
+ // the user's configured token. Config is the source of truth for auth.
20
+ let configAuthToken;
21
+ try {
22
+ const cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
23
+ configAuthToken = cfg.authToken;
24
+ } catch { /* config may not exist yet */ }
25
+
17
26
  const env = {
18
27
  ...process.env,
19
28
  MCP_PORT: mcpPort,
29
+ MCP_HOST: process.env.MCP_HOST || '0.0.0.0',
20
30
  MINDOS_URL: process.env.MINDOS_URL || `http://127.0.0.1:${webPort}`,
31
+ ...(configAuthToken ? { AUTH_TOKEN: configAuthToken } : {}),
21
32
  ...(verbose ? { MCP_VERBOSE: '1' } : {}),
22
33
  };
23
34
  const child = spawn('npx', ['tsx', 'src/index.ts'], {
package/mcp/src/index.ts CHANGED
@@ -25,7 +25,7 @@ import { z } from "zod";
25
25
  const BASE_URL = process.env.MINDOS_URL ?? "http://localhost:3456";
26
26
  const AUTH_TOKEN = process.env.AUTH_TOKEN;
27
27
  const MCP_TRANSPORT = process.env.MCP_TRANSPORT ?? "http"; // "http" | "stdio"
28
- const MCP_HOST = process.env.MCP_HOST ?? "127.0.0.1";
28
+ const MCP_HOST = process.env.MCP_HOST ?? "0.0.0.0";
29
29
  const MCP_PORT = parseInt(process.env.MCP_PORT ?? "8781", 10);
30
30
  const MCP_ENDPOINT = process.env.MCP_ENDPOINT ?? "/mcp";
31
31
  const CHARACTER_LIMIT = 25_000;
@@ -510,7 +510,8 @@ async function main() {
510
510
 
511
511
  const httpServer = createServer(expressApp as Parameters<typeof createServer>[1]);
512
512
  httpServer.listen(MCP_PORT, MCP_HOST, () => {
513
- console.error(`MindOS MCP server (HTTP) listening on http://${MCP_HOST}:${MCP_PORT}${MCP_ENDPOINT}`);
513
+ const displayHost = MCP_HOST === '0.0.0.0' ? '127.0.0.1' : MCP_HOST;
514
+ console.error(`MindOS MCP server (HTTP) listening on http://${displayHost}:${MCP_PORT}${MCP_ENDPOINT}`);
514
515
  console.error(`API backend: ${BASE_URL}`);
515
516
  });
516
517
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geminilight/mindos",
3
- "version": "0.5.26",
3
+ "version": "0.5.28",
4
4
  "description": "MindOS — Human-Agent Collaborative Mind System. Local-first knowledge base that syncs your mind to all AI Agents via MCP.",
5
5
  "keywords": [
6
6
  "mindos",
@@ -18,6 +18,67 @@ echo "🧪 Running tests..."
18
18
  npm test
19
19
  echo ""
20
20
 
21
+ # 3. Smoke test: pack → install in temp dir → verify CLI works
22
+ echo "🔍 Smoke testing package..."
23
+ SMOKE_DIR=$(mktemp -d)
24
+ TARBALL=$(npm pack --pack-destination "$SMOKE_DIR" 2>/dev/null | tail -1)
25
+ TARBALL_PATH="$SMOKE_DIR/$TARBALL"
26
+
27
+ if [ ! -f "$TARBALL_PATH" ]; then
28
+ echo "❌ npm pack failed — tarball not found"
29
+ rm -rf "$SMOKE_DIR"
30
+ exit 1
31
+ fi
32
+
33
+ TARBALL_SIZE=$(du -sh "$TARBALL_PATH" | cut -f1)
34
+ echo " 📦 Tarball: $TARBALL ($TARBALL_SIZE)"
35
+
36
+ # Install from tarball in isolation (production deps only)
37
+ cd "$SMOKE_DIR"
38
+ npm init -y --silent >/dev/null 2>&1
39
+ npm install "$TARBALL_PATH" --ignore-scripts >/dev/null 2>&1
40
+
41
+ # Verify bin entry exists and is executable
42
+ if [ ! -f "$SMOKE_DIR/node_modules/.bin/mindos" ]; then
43
+ echo "❌ 'mindos' binary not found after install"
44
+ rm -rf "$SMOKE_DIR"
45
+ exit 1
46
+ fi
47
+
48
+ # Verify --version works
49
+ INSTALLED_VERSION=$("$SMOKE_DIR/node_modules/.bin/mindos" --version 2>&1 || true)
50
+ if [ -z "$INSTALLED_VERSION" ]; then
51
+ echo "❌ 'mindos --version' returned empty"
52
+ rm -rf "$SMOKE_DIR"
53
+ exit 1
54
+ fi
55
+ echo " ✅ mindos --version → $INSTALLED_VERSION"
56
+
57
+ # Verify --help works (exits 0, produces output)
58
+ HELP_OUTPUT=$("$SMOKE_DIR/node_modules/.bin/mindos" --help 2>&1 || true)
59
+ if ! echo "$HELP_OUTPUT" | grep -qi "mindos"; then
60
+ echo "❌ 'mindos --help' did not produce expected output"
61
+ rm -rf "$SMOKE_DIR"
62
+ exit 1
63
+ fi
64
+ echo " ✅ mindos --help works"
65
+
66
+ # Verify key files are present in the installed package
67
+ for f in bin/cli.js app/package.json app/next.config.ts skills/mindos/SKILL.md; do
68
+ if [ ! -f "$SMOKE_DIR/node_modules/@geminilight/mindos/$f" ]; then
69
+ echo "❌ Missing file in package: $f"
70
+ rm -rf "$SMOKE_DIR"
71
+ exit 1
72
+ fi
73
+ done
74
+ echo " ✅ Key files present"
75
+
76
+ # Cleanup
77
+ rm -rf "$SMOKE_DIR"
78
+ cd - >/dev/null
79
+ echo " 🟢 Smoke test passed"
80
+ echo ""
81
+
21
82
  # 3. Bump version (creates commit + tag automatically)
22
83
  echo "📦 Bumping version ($BUMP)..."
23
84
  npm version "$BUMP" -m "%s"