smilenexus-mcp 1.0.0 → 1.0.3

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.
Files changed (3) hide show
  1. package/README.md +27 -0
  2. package/cli.mjs +217 -41
  3. package/package.json +1 -1
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # smilenexus-mcp
2
+
3
+ MCP (Model Context Protocol) connector for smileNexus.
4
+
5
+ ## Usage
6
+
7
+ This package is intended to be run via `npx` and handles authentication and tunneling for the smileNexus MCP server.
8
+
9
+ ```bash
10
+ npx -y smilenexus-mcp login
11
+ ```
12
+
13
+ ## Development & Publishing
14
+
15
+ To publish a new version of this package to npm:
16
+
17
+ 1. **Bump the version number** (This will update `package.json`):
18
+ ```bash
19
+ cd smilenexus-mcp
20
+ npm version patch # Use minor or major if appropriate
21
+ ```
22
+
23
+ 2. **Publish to npm**:
24
+ ```bash
25
+ npm publish
26
+ ```
27
+ *(Note: You must be logged in to npm via `npm login` with the appropriate account before publishing)*
package/cli.mjs CHANGED
@@ -3,83 +3,194 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
5
 
6
+ // ── Credentials helpers ────────────────────────────────────────────────────────
7
+
8
+ const CREDS_PATH = path.join(os.homedir(), '.config', 'smilenexus', 'credentials.json');
9
+
10
+ function readCredentials() {
11
+ try {
12
+ if (!fs.existsSync(CREDS_PATH)) return null;
13
+ return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8'));
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ function writeCredentials(data) {
20
+ const dir = path.dirname(CREDS_PATH);
21
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
22
+ fs.writeFileSync(CREDS_PATH, JSON.stringify(data, null, 2), { mode: 0o600 });
23
+ }
24
+
25
+ // ── Config ────────────────────────────────────────────────────────────────────
26
+
27
+ const MCP_URL = process.env.SMILENEXUS_URL ?? 'https://smile-nexus-dev.kantapon-r.workers.dev/mcp/sse';
28
+
29
+ // mutable — updated after successful login tool call during active MCP session
30
+ let currentApiKey = process.env.SMILENEXUS_API_KEY || readCredentials()?.api_key || null;
31
+
32
+ // ── Login tool definition (injected client-side into tools/list) ──────────────
33
+
34
+ const LOGIN_TOOL_DEF = {
35
+ name: 'login',
36
+ description: 'เชื่อมต่อ smileNexus ด้วย Microsoft account — รัน tool นี้ก่อนใช้งาน tool อื่น หรือเมื่อต้องการ rotate key',
37
+ inputSchema: { type: 'object', properties: {}, required: [] },
38
+ };
39
+
40
+ // ── Command dispatch ──────────────────────────────────────────────────────────
41
+
6
42
  const args = process.argv.slice(2);
7
43
  const command = args[0];
8
44
 
9
45
  if (command === 'install' || command === 'install-codex' || command === 'install-agy') {
10
46
  const customUrl = args[1];
11
- console.log(`\n========================================`);
12
- console.log(` smileNexus MCP Auto-Installer`);
13
- console.log(`========================================\n`);
14
-
47
+ process.stderr.write(`\n========================================\n smileNexus MCP Auto-Installer\n========================================\n\n`);
48
+
15
49
  if (command === 'install') {
16
- const defaultUrl = customUrl || "https://smile-nexus.kantapon-r.workers.dev/mcp/sse";
50
+ const defaultUrl = customUrl || 'https://smile-nexus.kantapon-r.workers.dev/mcp/sse';
17
51
  let configPath;
18
-
19
52
  if (process.platform === 'darwin') {
20
53
  configPath = path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
21
54
  } else if (process.platform === 'win32') {
22
55
  configPath = path.join(os.homedir(), 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json');
23
56
  } else {
24
- console.log('Unsupported OS for auto-install. Please configure manually using the JSON provided in the UI.');
57
+ process.stderr.write('Unsupported OS for auto-install. Please configure manually.\n');
25
58
  process.exit(0);
26
59
  }
27
-
60
+
61
+ let installOk = false;
28
62
  try {
29
63
  let config = { mcpServers: {} };
30
- if (fs.existsSync(configPath)) {
31
- config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
32
- }
33
-
64
+ if (fs.existsSync(configPath)) config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
34
65
  config.mcpServers = config.mcpServers || {};
35
66
  config.mcpServers.smileNexus = {
36
- command: "npx",
37
- args: ["-y", "smilenexus-mcp"],
38
- env: {
39
- "SMILENEXUS_URL": defaultUrl
40
- }
67
+ command: 'npx',
68
+ args: ['-y', 'smilenexus-mcp'],
69
+ env: { SMILENEXUS_URL: defaultUrl },
41
70
  };
42
-
43
71
  const dir = path.dirname(configPath);
44
72
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
45
-
46
73
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
47
- console.log(`✅ Successfully added smileNexus to Claude Desktop!`);
48
- console.log(`Config updated at: ${configPath}`);
49
- console.log(`Please restart Claude Desktop for the changes to take effect.`);
74
+ process.stderr.write(`✅ Successfully added smileNexus to Claude Desktop!\nConfig updated at: ${configPath}\n`);
75
+ installOk = true;
50
76
  } catch (err) {
51
- console.error(`❌ Failed to update Claude config: ${err.message}`);
52
- console.log(`Please configure manually using the JSON provided in the UI.`);
77
+ process.stderr.write(`❌ Failed to update Claude config: ${err.message}\n`);
78
+ }
79
+
80
+ // Option B: auto-login immediately after install (mandatory — login is required to use smileNexus)
81
+ if (installOk) {
82
+ process.stderr.write(`\n🔐 กำลัง login เพื่อเปิดใช้งาน...\n`);
83
+ try {
84
+ await runLogin(new URL(defaultUrl).origin);
85
+ process.stderr.write(`\nRestart Claude Desktop แล้วใช้งาน smileNexus ได้เลย!\n`);
86
+ } catch {
87
+ process.stderr.write(`\nLogin ล้มเหลว กรุณารัน \`npx smilenexus-mcp login\` ก่อนใช้งาน\n`);
88
+ }
53
89
  }
54
90
  } else {
55
- console.log(`Auto-install for ${command} is coming soon!`);
56
- console.log(`Please configure manually using the instructions in the UI.`);
91
+ process.stderr.write(`Auto-install for ${command} is coming soon!\nPlease configure manually.\n`);
57
92
  }
58
-
59
93
  process.exit(0);
60
94
  }
61
95
 
62
- // ---------------------------------------------------------
63
- // Proxy Logic (stdio <-> HTTP)
64
- // ---------------------------------------------------------
65
- const MCP_URL = process.env.SMILENEXUS_URL ?? "https://smile-nexus-dev.kantapon-r.workers.dev/mcp/sse";
96
+ if (command === 'login') {
97
+ try {
98
+ await runLogin();
99
+ process.exit(0);
100
+ } catch {
101
+ process.exit(1);
102
+ }
103
+ }
104
+
105
+ // ── Login flow ────────────────────────────────────────────────────────────────
106
+ // Throws on failure instead of calling process.exit — callers decide how to handle.
107
+
108
+ async function runLogin(baseUrl) {
109
+ if (!baseUrl) baseUrl = new URL(MCP_URL).origin;
110
+
111
+ // 1. Create session
112
+ let sessionData;
113
+ try {
114
+ const res = await fetch(`${baseUrl}/auth/mcp/session`, { method: 'POST' });
115
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
116
+ sessionData = await res.json();
117
+ } catch (err) {
118
+ process.stderr.write(`❌ ไม่สามารถสร้าง login session ได้: ${err.message}\n`);
119
+ throw err;
120
+ }
66
121
 
67
- let buf = "";
122
+ const { session_code, login_url } = sessionData;
123
+
124
+ // 2. Open browser
125
+ process.stderr.write(`\n🔐 เปิด URL นี้ใน browser:\n\n ${login_url}\n\n`);
126
+ try {
127
+ const { spawn } = await import('child_process');
128
+ const opener = process.platform === 'darwin' ? 'open'
129
+ : process.platform === 'win32' ? 'start'
130
+ : 'xdg-open';
131
+ spawn(opener, [login_url], { detached: true, stdio: 'ignore' }).unref();
132
+ } catch {}
133
+
134
+ // 3. Poll every 2s, max 10 minutes
135
+ const MAX_POLLS = 300;
136
+ const spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
137
+ let spinIdx = 0;
138
+
139
+ for (let i = 0; i < MAX_POLLS; i++) {
140
+ process.stderr.write(`\r${spinner[spinIdx++ % spinner.length]} รอการ login... `);
141
+ await new Promise(r => setTimeout(r, 2000));
142
+
143
+ let pollData;
144
+ let retries = 0;
145
+ while (true) {
146
+ try {
147
+ const res = await fetch(`${baseUrl}/auth/mcp/poll?code=${session_code}`);
148
+ pollData = await res.json();
149
+ break;
150
+ } catch {
151
+ retries++;
152
+ if (retries >= 3) {
153
+ process.stderr.write('❌ Network error. กรุณาตรวจสอบการเชื่อมต่อ\n');
154
+ throw new Error('Network error after 3 retries');
155
+ }
156
+ await new Promise(r => setTimeout(r, 2000));
157
+ }
158
+ }
159
+
160
+ if (pollData.status === 'done') {
161
+ writeCredentials({ api_key: pollData.api_key, created_at: new Date().toISOString() });
162
+ process.stderr.write(`\r\x1b[K✅ Login สำเร็จ! smileNexus พร้อมใช้งานแล้ว\n`);
163
+ return;
164
+ }
165
+ if (pollData.status === 'expired') {
166
+ process.stderr.write('\r\x1b[K❌ Session หมดอายุ กรุณาลองใหม่\n');
167
+ throw new Error('Session expired');
168
+ }
169
+ // status === 'pending' — continue polling
170
+ }
171
+
172
+ process.stderr.write('❌ หมดเวลา กรุณาลอง `npx smilenexus-mcp login` ใหม่\n');
173
+ throw new Error('Login timeout');
174
+ }
175
+
176
+ // ── Proxy Logic (stdio <-> HTTP) ──────────────────────────────────────────────
177
+
178
+ let buf = '';
68
179
  let pending = 0;
69
180
  let stdinDone = false;
70
- process.stdin.setEncoding("utf8");
181
+ process.stdin.setEncoding('utf8');
71
182
 
72
- process.stdin.on("data", (chunk) => {
183
+ process.stdin.on('data', (chunk) => {
73
184
  buf += chunk;
74
185
  let idx;
75
- while ((idx = buf.indexOf("\n")) !== -1) {
186
+ while ((idx = buf.indexOf('\n')) !== -1) {
76
187
  const line = buf.slice(0, idx).trim();
77
188
  buf = buf.slice(idx + 1);
78
189
  if (line) handle(line);
79
190
  }
80
191
  });
81
192
 
82
- process.stdin.on("end", () => {
193
+ process.stdin.on('end', () => {
83
194
  stdinDone = true;
84
195
  if (pending === 0) process.exit(0);
85
196
  });
@@ -92,25 +203,90 @@ async function handle(line) {
92
203
  return;
93
204
  }
94
205
 
206
+ // Option A: intercept tools/list — inject login tool
207
+ if (msg.method === 'tools/list') {
208
+ if (!currentApiKey) {
209
+ // Not authenticated — return only the login tool so the AI knows what to call
210
+ process.stdout.write(JSON.stringify({
211
+ jsonrpc: '2.0',
212
+ id: msg.id,
213
+ result: { tools: [LOGIN_TOOL_DEF] },
214
+ }) + '\n');
215
+ return;
216
+ }
217
+ // Authenticated — proxy and append login tool to the result
218
+ pending++;
219
+ try {
220
+ const res = await fetch(MCP_URL, {
221
+ method: 'POST',
222
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${currentApiKey}` },
223
+ body: JSON.stringify(msg),
224
+ });
225
+ if (res.status !== 202) {
226
+ const json = await res.json();
227
+ if (json.result?.tools) json.result.tools.push(LOGIN_TOOL_DEF);
228
+ process.stdout.write(JSON.stringify(json) + '\n');
229
+ }
230
+ } catch (err) {
231
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg?.id ?? null, error: { code: -32603, message: String(err) } }) + '\n');
232
+ } finally {
233
+ pending--;
234
+ if (stdinDone && pending === 0) process.exit(0);
235
+ }
236
+ return;
237
+ }
238
+
239
+ // Option A: intercept tools/call for the login tool — handle locally
240
+ if (msg.method === 'tools/call' && msg.params?.name === 'login') {
241
+ pending++;
242
+ try {
243
+ await runLogin();
244
+ currentApiKey = readCredentials()?.api_key ?? null;
245
+ process.stdout.write(JSON.stringify({
246
+ jsonrpc: '2.0',
247
+ id: msg.id,
248
+ result: { content: [{ type: 'text', text: '✅ Login สำเร็จ! smileNexus พร้อมใช้งานแล้ว ลองใช้ tool อื่นได้เลย' }] },
249
+ }) + '\n');
250
+ } catch (err) {
251
+ process.stdout.write(JSON.stringify({
252
+ jsonrpc: '2.0',
253
+ id: msg.id,
254
+ result: { content: [{ type: 'text', text: `❌ Login ล้มเหลว: ${err.message} — กรุณาลองใหม่` }], isError: true },
255
+ }) + '\n');
256
+ } finally {
257
+ pending--;
258
+ if (stdinDone && pending === 0) process.exit(0);
259
+ }
260
+ return;
261
+ }
262
+
263
+ // Normal proxy
95
264
  pending++;
96
265
  try {
97
266
  const res = await fetch(MCP_URL, {
98
- method: "POST",
99
- headers: { "content-type": "application/json" },
267
+ method: 'POST',
268
+ headers: {
269
+ 'content-type': 'application/json',
270
+ ...(currentApiKey ? { authorization: `Bearer ${currentApiKey}` } : {}),
271
+ },
100
272
  body: JSON.stringify(msg),
101
273
  });
102
274
 
103
275
  if (res.status !== 202) {
104
276
  const json = await res.json();
105
- process.stdout.write(JSON.stringify(json) + "\n");
277
+ // Transform auth error into actionable hint for the AI
278
+ if (!currentApiKey && json.error?.code === -32600) {
279
+ json.error.message = 'smileNexus: ยังไม่ได้ authenticate — กรุณาเรียกใช้ tool "login" เพื่อเชื่อมต่อก่อน';
280
+ }
281
+ process.stdout.write(JSON.stringify(json) + '\n');
106
282
  }
107
283
  } catch (err) {
108
284
  process.stdout.write(
109
285
  JSON.stringify({
110
- jsonrpc: "2.0",
286
+ jsonrpc: '2.0',
111
287
  id: msg?.id ?? null,
112
288
  error: { code: -32603, message: String(err) },
113
- }) + "\n",
289
+ }) + '\n',
114
290
  );
115
291
  } finally {
116
292
  pending--;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smilenexus-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.0.3",
4
4
  "description": "MCP connector for smileNexus",
5
5
  "type": "module",
6
6
  "bin": "./cli.mjs",