karma365-mcp 2.0.0 → 2.0.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/index.js +264 -32
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -215,6 +215,74 @@ function resolveTodo(nameOrId) {
|
|
|
215
215
|
return found
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
// ── Security & PIN System ─────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
function sha256(text) {
|
|
221
|
+
return crypto.createHash('sha256').update(String(text)).digest('hex')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function getSetting(key) {
|
|
225
|
+
try {
|
|
226
|
+
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key)
|
|
227
|
+
return row ? row.value : null
|
|
228
|
+
} catch {
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function setSetting(key, value) {
|
|
234
|
+
db.prepare(`INSERT INTO settings (key, value) VALUES (?, ?)
|
|
235
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(key, String(value))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function hasPin() {
|
|
239
|
+
const hash = getSetting('pin_hash')
|
|
240
|
+
const salt = getSetting('pin_salt')
|
|
241
|
+
return Boolean(hash && salt)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function setMasterPin(newPin) {
|
|
245
|
+
const trimmed = String(newPin ?? '').trim()
|
|
246
|
+
if (!trimmed) {
|
|
247
|
+
throw new Error('PIN cannot be empty. Please provide a numeric PIN (e.g. 111111).')
|
|
248
|
+
}
|
|
249
|
+
const salt = crypto.randomBytes(16).toString('hex')
|
|
250
|
+
const hash = sha256(salt + trimmed)
|
|
251
|
+
setSetting('pin_salt', salt)
|
|
252
|
+
setSetting('pin_hash', hash)
|
|
253
|
+
return true
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function verifyPin(pin) {
|
|
257
|
+
const salt = getSetting('pin_salt')
|
|
258
|
+
const storedHash = getSetting('pin_hash')
|
|
259
|
+
if (!salt || !storedHash) return false
|
|
260
|
+
const trimmed = String(pin ?? '').trim()
|
|
261
|
+
if (!trimmed) return false
|
|
262
|
+
return sha256(salt + trimmed) === storedHash
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Checks if a locked item requires PIN verification.
|
|
267
|
+
* Returns:
|
|
268
|
+
* 'ok' -> Not locked OR valid PIN provided
|
|
269
|
+
* 'required' -> Item is locked and no PIN provided
|
|
270
|
+
* 'invalid' -> Item is locked and wrong PIN provided
|
|
271
|
+
*/
|
|
272
|
+
function checkPinIfLocked(isPinned, pin) {
|
|
273
|
+
if (!isPinned || isPinned === 0) return 'ok'
|
|
274
|
+
if (!hasPin()) return 'required'
|
|
275
|
+
const trimmed = String(pin ?? '').trim()
|
|
276
|
+
if (!trimmed) return 'required'
|
|
277
|
+
return verifyPin(trimmed) ? 'ok' : 'invalid'
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const PIN_REQ = (name, action = 'this action') =>
|
|
281
|
+
`PIN_REQUIRED: "${name}" is locked/PIN-protected 🔒. Prompt the user for their PIN to authorize ${action}, then retry with pin="<their_pin>".`
|
|
282
|
+
|
|
283
|
+
const PIN_BAD = (name, action = 'performed') =>
|
|
284
|
+
`PIN_INVALID: Authentication failed — incorrect PIN entered ❌. "${name}" was NOT ${action}.`
|
|
285
|
+
|
|
218
286
|
// ── Search helpers (DuckDuckGo / SearXNG / YouTube) ───────────────────────────
|
|
219
287
|
|
|
220
288
|
const SEARCH_UA = 'Mozilla/5.0 (compatible; KarmaSearch/2.0)'
|
|
@@ -375,18 +443,19 @@ const TOOLS = [
|
|
|
375
443
|
},
|
|
376
444
|
{
|
|
377
445
|
name: 'get_topic',
|
|
378
|
-
description: 'Get full details of a topic including all its resources',
|
|
446
|
+
description: 'Get full details of a topic including all its resources. PIN required if topic is locked.',
|
|
379
447
|
inputSchema: {
|
|
380
448
|
type: 'object',
|
|
381
449
|
required: ['name'],
|
|
382
450
|
properties: {
|
|
383
|
-
name: { type: 'string', description: 'Topic name or ID' }
|
|
451
|
+
name: { type: 'string', description: 'Topic name or ID' },
|
|
452
|
+
pin: { type: 'string', description: 'Optional PIN required if the topic is locked' }
|
|
384
453
|
}
|
|
385
454
|
}
|
|
386
455
|
},
|
|
387
456
|
{
|
|
388
457
|
name: 'create_topic',
|
|
389
|
-
description: 'Create a new topic in KARMA 365',
|
|
458
|
+
description: 'Create a new topic in KARMA 365. If parent major topic is locked, PIN is required.',
|
|
390
459
|
inputSchema: {
|
|
391
460
|
type: 'object',
|
|
392
461
|
required: ['name'],
|
|
@@ -394,20 +463,24 @@ const TOOLS = [
|
|
|
394
463
|
name: { type: 'string', description: 'Topic name' },
|
|
395
464
|
difficulty: { type: 'string', enum: ['Easy', 'Medium', 'Hard'] },
|
|
396
465
|
major_topic_name: { type: 'string', description: 'Assign to this major topic' },
|
|
397
|
-
color: { type: 'string', description: 'Hex color code e.g. #FF5733' }
|
|
466
|
+
color: { type: 'string', description: 'Hex color code e.g. #FF5733' },
|
|
467
|
+
is_pinned: { type: 'boolean', description: 'Whether to lock/pin this topic' },
|
|
468
|
+
pin: { type: 'string', description: 'Optional PIN if parent major topic is locked' }
|
|
398
469
|
}
|
|
399
470
|
}
|
|
400
471
|
},
|
|
401
472
|
{
|
|
402
473
|
name: 'create_subtopic',
|
|
403
|
-
description: 'Create a subtopic under an existing topic',
|
|
474
|
+
description: 'Create a subtopic under an existing topic. If parent topic is locked, PIN is required.',
|
|
404
475
|
inputSchema: {
|
|
405
476
|
type: 'object',
|
|
406
477
|
required: ['name', 'parent_topic_name'],
|
|
407
478
|
properties: {
|
|
408
479
|
name: { type: 'string', description: 'Subtopic name' },
|
|
409
480
|
parent_topic_name: { type: 'string', description: 'Parent topic name or ID' },
|
|
410
|
-
difficulty: { type: 'string', enum: ['Easy', 'Medium', 'Hard'] }
|
|
481
|
+
difficulty: { type: 'string', enum: ['Easy', 'Medium', 'Hard'] },
|
|
482
|
+
is_pinned: { type: 'boolean', description: 'Whether to lock/pin this subtopic' },
|
|
483
|
+
pin: { type: 'string', description: 'Optional PIN if parent topic is locked' }
|
|
411
484
|
}
|
|
412
485
|
}
|
|
413
486
|
},
|
|
@@ -651,73 +724,102 @@ const TOOLS = [
|
|
|
651
724
|
},
|
|
652
725
|
{
|
|
653
726
|
name: 'pin_major_topic',
|
|
654
|
-
description: 'Pin (lock) or unpin a major topic.',
|
|
727
|
+
description: 'Pin (lock) or unpin a major topic. Unpinning requires PIN if locked.',
|
|
655
728
|
inputSchema: {
|
|
656
729
|
type: 'object',
|
|
657
730
|
required: ['name', 'pinned'],
|
|
658
731
|
properties: {
|
|
659
732
|
name: { type: 'string', description: 'Major topic name or ID' },
|
|
660
|
-
pinned: { type: 'boolean', description: 'true = lock/pin, false = unlock/unpin' }
|
|
733
|
+
pinned: { type: 'boolean', description: 'true = lock/pin, false = unlock/unpin' },
|
|
734
|
+
pin: { type: 'string', description: 'Optional PIN required if currently locked and unpinning' }
|
|
661
735
|
}
|
|
662
736
|
}
|
|
663
737
|
},
|
|
664
738
|
{
|
|
665
739
|
name: 'pin_topic',
|
|
666
|
-
description: 'Pin (lock) or unpin a topic.',
|
|
740
|
+
description: 'Pin (lock) or unpin a topic. Unpinning requires PIN if locked.',
|
|
667
741
|
inputSchema: {
|
|
668
742
|
type: 'object',
|
|
669
743
|
required: ['name', 'pinned'],
|
|
670
744
|
properties: {
|
|
671
745
|
name: { type: 'string', description: 'Topic name or ID' },
|
|
672
|
-
pinned: { type: 'boolean', description: 'true = lock/pin, false = unlock/unpin' }
|
|
746
|
+
pinned: { type: 'boolean', description: 'true = lock/pin, false = unlock/unpin' },
|
|
747
|
+
pin: { type: 'string', description: 'Optional PIN required if currently locked and unpinning' }
|
|
673
748
|
}
|
|
674
749
|
}
|
|
675
750
|
},
|
|
676
751
|
{
|
|
677
752
|
name: 'set_major_topic_color',
|
|
678
|
-
description: 'Change the colour of a major topic.',
|
|
753
|
+
description: 'Change the colour of a major topic. PIN required if locked.',
|
|
679
754
|
inputSchema: {
|
|
680
755
|
type: 'object',
|
|
681
756
|
required: ['name', 'color'],
|
|
682
757
|
properties: {
|
|
683
758
|
name: { type: 'string', description: 'Major topic name or ID' },
|
|
684
|
-
color: { type: 'string', description: 'Hex colour or color name' }
|
|
759
|
+
color: { type: 'string', description: 'Hex colour or color name' },
|
|
760
|
+
pin: { type: 'string', description: 'Optional PIN if locked' }
|
|
685
761
|
}
|
|
686
762
|
}
|
|
687
763
|
},
|
|
688
764
|
{
|
|
689
765
|
name: 'set_topic_color',
|
|
690
|
-
description: 'Change the colour of a topic.',
|
|
766
|
+
description: 'Change the colour of a topic. PIN required if locked.',
|
|
691
767
|
inputSchema: {
|
|
692
768
|
type: 'object',
|
|
693
769
|
required: ['name', 'color'],
|
|
694
770
|
properties: {
|
|
695
771
|
name: { type: 'string', description: 'Topic name or ID' },
|
|
696
|
-
color: { type: 'string', description: 'Hex colour or color name' }
|
|
772
|
+
color: { type: 'string', description: 'Hex colour or color name' },
|
|
773
|
+
pin: { type: 'string', description: 'Optional PIN if locked' }
|
|
697
774
|
}
|
|
698
775
|
}
|
|
699
776
|
},
|
|
700
777
|
{
|
|
701
778
|
name: 'rename_major_topic',
|
|
702
|
-
description: 'Rename a major topic.',
|
|
779
|
+
description: 'Rename a major topic. PIN required if locked.',
|
|
703
780
|
inputSchema: {
|
|
704
781
|
type: 'object',
|
|
705
782
|
required: ['name', 'new_name'],
|
|
706
783
|
properties: {
|
|
707
784
|
name: { type: 'string', description: 'CURRENT major topic name or ID' },
|
|
708
|
-
new_name: { type: 'string', description: 'The NEW name' }
|
|
785
|
+
new_name: { type: 'string', description: 'The NEW name' },
|
|
786
|
+
pin: { type: 'string', description: 'Optional PIN if locked' }
|
|
709
787
|
}
|
|
710
788
|
}
|
|
711
789
|
},
|
|
712
790
|
{
|
|
713
791
|
name: 'rename_topic',
|
|
714
|
-
description: 'Rename a topic.',
|
|
792
|
+
description: 'Rename a topic. PIN required if locked.',
|
|
715
793
|
inputSchema: {
|
|
716
794
|
type: 'object',
|
|
717
795
|
required: ['name', 'new_name'],
|
|
718
796
|
properties: {
|
|
719
797
|
name: { type: 'string', description: 'CURRENT topic name or ID' },
|
|
720
|
-
new_name: { type: 'string', description: 'The NEW name' }
|
|
798
|
+
new_name: { type: 'string', description: 'The NEW name' },
|
|
799
|
+
pin: { type: 'string', description: 'Optional PIN if locked' }
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
},
|
|
803
|
+
{
|
|
804
|
+
name: 'set_pin',
|
|
805
|
+
description: 'Set or update the security PIN for KARMA 365. Used to unlock, view, edit, or delete locked/PIN-protected topics and categories.',
|
|
806
|
+
inputSchema: {
|
|
807
|
+
type: 'object',
|
|
808
|
+
required: ['new_pin'],
|
|
809
|
+
properties: {
|
|
810
|
+
new_pin: { type: 'string', description: 'New numeric PIN (e.g. 111111)' },
|
|
811
|
+
current_pin: { type: 'string', description: 'Current PIN (required if a PIN is already set)' }
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
{
|
|
816
|
+
name: 'verify_pin',
|
|
817
|
+
description: 'Verify if a PIN is correct against the KARMA 365 master PIN',
|
|
818
|
+
inputSchema: {
|
|
819
|
+
type: 'object',
|
|
820
|
+
required: ['pin'],
|
|
821
|
+
properties: {
|
|
822
|
+
pin: { type: 'string', description: 'PIN to test' }
|
|
721
823
|
}
|
|
722
824
|
}
|
|
723
825
|
},
|
|
@@ -786,11 +888,12 @@ const TOOLS = [
|
|
|
786
888
|
]
|
|
787
889
|
|
|
788
890
|
// ── Phone Live Bridge Setup & Auto-Discovery (Wi-Fi, Hotspot, USB) ─────────
|
|
789
|
-
const
|
|
891
|
+
const LOCAL_ONLY = process.env.KARMA365_LOCAL_ONLY === 'true' || process.env.KARMA365_PHONE_URL === 'none' || process.env.KARMA365_PHONE_URL === 'false'
|
|
892
|
+
const EXPLICIT_PHONE_URL = (!LOCAL_ONLY && process.env.KARMA365_PHONE_URL) ? process.env.KARMA365_PHONE_URL.trim().replace(/\/+$/, '') : null
|
|
790
893
|
const KARMA_TOKEN = process.env.KARMA365_TOKEN ? process.env.KARMA365_TOKEN.trim() : null
|
|
791
894
|
const PHONE_CACHE_FILE = path.join(os.homedir(), '.karma365', 'last_phone_url.txt')
|
|
792
895
|
|
|
793
|
-
let activePhoneUrl = EXPLICIT_PHONE_URL || readCachedPhoneUrl()
|
|
896
|
+
let activePhoneUrl = LOCAL_ONLY ? null : (EXPLICIT_PHONE_URL || readCachedPhoneUrl())
|
|
794
897
|
let isDiscoveryRunning = false
|
|
795
898
|
let lastDiscoveryTime = 0
|
|
796
899
|
|
|
@@ -812,7 +915,7 @@ function writeCachedPhoneUrl(url) {
|
|
|
812
915
|
} catch (_) {}
|
|
813
916
|
}
|
|
814
917
|
|
|
815
|
-
async function probePhoneHealth(baseUrl, timeoutMs =
|
|
918
|
+
async function probePhoneHealth(baseUrl, timeoutMs = 1200) {
|
|
816
919
|
try {
|
|
817
920
|
const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(timeoutMs) })
|
|
818
921
|
if (!res.ok) return false
|
|
@@ -824,6 +927,7 @@ async function probePhoneHealth(baseUrl, timeoutMs = 600) {
|
|
|
824
927
|
}
|
|
825
928
|
|
|
826
929
|
async function autoDiscoverPhone(force = false) {
|
|
930
|
+
if (LOCAL_ONLY) return null
|
|
827
931
|
if (EXPLICIT_PHONE_URL) return EXPLICIT_PHONE_URL
|
|
828
932
|
const now = Date.now()
|
|
829
933
|
if (!force && activePhoneUrl && (now - lastDiscoveryTime < 15000)) {
|
|
@@ -842,7 +946,7 @@ async function autoDiscoverPhone(force = false) {
|
|
|
842
946
|
].filter(Boolean)
|
|
843
947
|
|
|
844
948
|
for (const url of quickCandidates) {
|
|
845
|
-
if (await probePhoneHealth(url,
|
|
949
|
+
if (await probePhoneHealth(url, 1500)) {
|
|
846
950
|
activePhoneUrl = url
|
|
847
951
|
writeCachedPhoneUrl(url)
|
|
848
952
|
process.stderr.write(`[karma365-mcp] 📱 Connected to KARMA 365 Mobile App: ${url}\n`)
|
|
@@ -927,7 +1031,12 @@ async function callPhoneBridge(name, args) {
|
|
|
927
1031
|
|
|
928
1032
|
if (res.status === 401) {
|
|
929
1033
|
const data = await res.json().catch(() => ({}))
|
|
930
|
-
|
|
1034
|
+
if (EXPLICIT_PHONE_URL) {
|
|
1035
|
+
throw new Error(`Phone Authentication Failed (401): ${data.error || 'Invalid or missing KARMA365_TOKEN'}. Check your pairing token in Karma 365 app settings.`)
|
|
1036
|
+
}
|
|
1037
|
+
process.stderr.write(`[karma365-mcp] Auto-discovered phone returned 401 (not paired). Falling back to local SQLite DB.\n`)
|
|
1038
|
+
activePhoneUrl = null
|
|
1039
|
+
return null
|
|
931
1040
|
}
|
|
932
1041
|
|
|
933
1042
|
if (!res.ok) {
|
|
@@ -943,7 +1052,7 @@ async function callPhoneBridge(name, args) {
|
|
|
943
1052
|
return data.result
|
|
944
1053
|
} catch (err) {
|
|
945
1054
|
clearTimeout(timeoutId)
|
|
946
|
-
if (err.message && err.message.includes('401')) {
|
|
1055
|
+
if (err.message && err.message.includes('401') && EXPLICIT_PHONE_URL) {
|
|
947
1056
|
throw err
|
|
948
1057
|
}
|
|
949
1058
|
process.stderr.write(`[karma365-mcp] Phone bridge unreachable at ${targetBase} (${err.message}). Checking auto-discovery or local SQLite...\n`)
|
|
@@ -1024,6 +1133,11 @@ async function handleTool(name, args) {
|
|
|
1024
1133
|
|
|
1025
1134
|
case 'delete_major_topic': {
|
|
1026
1135
|
const item = resolveMajorTopic(args.name)
|
|
1136
|
+
if (item.is_pinned === 1) {
|
|
1137
|
+
const guard = checkPinIfLocked(item.is_pinned, args.pin)
|
|
1138
|
+
if (guard === 'required') return PIN_REQ(item.name, 'deletion')
|
|
1139
|
+
if (guard === 'invalid') return PIN_BAD(item.name, 'deleted')
|
|
1140
|
+
}
|
|
1027
1141
|
db.prepare('UPDATE topics SET major_topic_id = NULL WHERE major_topic_id = ?').run(item.id)
|
|
1028
1142
|
db.prepare('DELETE FROM major_topics WHERE id = ?').run(item.id)
|
|
1029
1143
|
return `✓ Deleted major topic "${item.name}". Subtopics under it have been kept.`
|
|
@@ -1031,6 +1145,11 @@ async function handleTool(name, args) {
|
|
|
1031
1145
|
|
|
1032
1146
|
case 'rename_major_topic': {
|
|
1033
1147
|
const item = resolveMajorTopic(args.name)
|
|
1148
|
+
if (item.is_pinned === 1) {
|
|
1149
|
+
const guard = checkPinIfLocked(item.is_pinned, args.pin)
|
|
1150
|
+
if (guard === 'required') return PIN_REQ(item.name, 'renaming')
|
|
1151
|
+
if (guard === 'invalid') return PIN_BAD(item.name, 'renamed')
|
|
1152
|
+
}
|
|
1034
1153
|
const newName = args.new_name.trim()
|
|
1035
1154
|
db.prepare('UPDATE major_topics SET name = ? WHERE id = ?').run(newName, item.id)
|
|
1036
1155
|
return `✓ Renamed major topic from "${item.name}" to "${newName}"`
|
|
@@ -1038,6 +1157,11 @@ async function handleTool(name, args) {
|
|
|
1038
1157
|
|
|
1039
1158
|
case 'set_major_topic_color': {
|
|
1040
1159
|
const item = resolveMajorTopic(args.name)
|
|
1160
|
+
if (item.is_pinned === 1) {
|
|
1161
|
+
const guard = checkPinIfLocked(item.is_pinned, args.pin)
|
|
1162
|
+
if (guard === 'required') return PIN_REQ(item.name, 'changing colour')
|
|
1163
|
+
if (guard === 'invalid') return PIN_BAD(item.name, 'color-changed')
|
|
1164
|
+
}
|
|
1041
1165
|
const color = resolveColor(args.color)
|
|
1042
1166
|
db.prepare('UPDATE major_topics SET color = ? WHERE id = ?').run(color, item.id)
|
|
1043
1167
|
return `✓ Changed colour of major topic "${item.name}" to ${color}`
|
|
@@ -1046,6 +1170,11 @@ async function handleTool(name, args) {
|
|
|
1046
1170
|
case 'pin_major_topic': {
|
|
1047
1171
|
const item = resolveMajorTopic(args.name)
|
|
1048
1172
|
const pinned = args.pinned === true || String(args.pinned).toLowerCase() === 'true' ? 1 : 0
|
|
1173
|
+
if (!pinned && item.is_pinned === 1) {
|
|
1174
|
+
const guard = checkPinIfLocked(item.is_pinned, args.pin)
|
|
1175
|
+
if (guard === 'required') return PIN_REQ(item.name, 'unlocking')
|
|
1176
|
+
if (guard === 'invalid') return PIN_BAD(item.name, 'unlocked')
|
|
1177
|
+
}
|
|
1049
1178
|
db.prepare('UPDATE major_topics SET is_pinned = ? WHERE id = ?').run(pinned, item.id)
|
|
1050
1179
|
return pinned ? `✓ Locked/pinned major topic "${item.name}" 🔒` : `✓ Unlocked major topic "${item.name}"`
|
|
1051
1180
|
}
|
|
@@ -1094,6 +1223,18 @@ async function handleTool(name, args) {
|
|
|
1094
1223
|
|
|
1095
1224
|
case 'get_topic': {
|
|
1096
1225
|
const topic = resolveTopic(args.name)
|
|
1226
|
+
let majorIsPinned = 0
|
|
1227
|
+
if (topic.major_topic_id) {
|
|
1228
|
+
const m = db.prepare('SELECT is_pinned, name FROM major_topics WHERE id = ?').get(topic.major_topic_id)
|
|
1229
|
+
if (m && m.is_pinned === 1) majorIsPinned = 1
|
|
1230
|
+
}
|
|
1231
|
+
const isLocked = topic.is_pinned === 1 || majorIsPinned === 1
|
|
1232
|
+
if (isLocked) {
|
|
1233
|
+
const guard = checkPinIfLocked(1, args.pin)
|
|
1234
|
+
if (guard === 'required') return PIN_REQ(topic.name, 'opening / reading')
|
|
1235
|
+
if (guard === 'invalid') return PIN_BAD(topic.name, 'opened')
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1097
1238
|
const resources = db.prepare('SELECT * FROM resources WHERE topic_id = ? ORDER BY sort_order ASC, created_at DESC').all(topic.id)
|
|
1098
1239
|
let majorName = null
|
|
1099
1240
|
if (topic.major_topic_id) {
|
|
@@ -1124,25 +1265,48 @@ async function handleTool(name, args) {
|
|
|
1124
1265
|
let majorId = null
|
|
1125
1266
|
if (args.major_topic_name) {
|
|
1126
1267
|
const m = resolveMajorTopic(args.major_topic_name)
|
|
1268
|
+
if (m.is_pinned === 1) {
|
|
1269
|
+
const guard = checkPinIfLocked(m.is_pinned, args.pin)
|
|
1270
|
+
if (guard === 'required') return PIN_REQ(m.name, 'adding topic under it')
|
|
1271
|
+
if (guard === 'invalid') return PIN_BAD(m.name, 'modified')
|
|
1272
|
+
}
|
|
1127
1273
|
majorId = m.id
|
|
1128
1274
|
}
|
|
1129
1275
|
const id = newId()
|
|
1130
1276
|
const color = resolveColor(args.color)
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1277
|
+
const isPinned = args.is_pinned === true || args.pinned === true || String(args.is_pinned).toLowerCase() === 'true' || String(args.pinned).toLowerCase() === 'true' ? 1 : 0
|
|
1278
|
+
db.prepare(`INSERT INTO topics (id, name, difficulty, major_topic_id, color, is_pinned, created_at, synced)
|
|
1279
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`).run(id, args.name.trim(), args.difficulty || 'Medium', majorId, color, isPinned, nowIso())
|
|
1280
|
+
return `✓ Created topic "${args.name}" (ID: ${id})${isPinned ? ' 🔒' : ''}`
|
|
1134
1281
|
}
|
|
1135
1282
|
|
|
1136
1283
|
case 'create_subtopic': {
|
|
1137
1284
|
const parent = resolveTopic(args.parent_topic_name)
|
|
1285
|
+
if (parent.is_pinned === 1) {
|
|
1286
|
+
const guard = checkPinIfLocked(parent.is_pinned, args.pin)
|
|
1287
|
+
if (guard === 'required') return PIN_REQ(parent.name, 'adding subtopic under it')
|
|
1288
|
+
if (guard === 'invalid') return PIN_BAD(parent.name, 'modified')
|
|
1289
|
+
}
|
|
1138
1290
|
const id = newId()
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1291
|
+
const isPinned = args.is_pinned === true || args.pinned === true || String(args.is_pinned).toLowerCase() === 'true' || String(args.pinned).toLowerCase() === 'true' ? 1 : 0
|
|
1292
|
+
db.prepare(`INSERT INTO topics (id, name, difficulty, parent_topic_id, major_topic_id, is_pinned, created_at, synced)
|
|
1293
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`).run(id, args.name.trim(), args.difficulty || 'Medium', parent.id, parent.major_topic_id, isPinned, nowIso())
|
|
1294
|
+
return `✓ Created subtopic "${args.name}" under parent "${parent.name}" (ID: ${id})${isPinned ? ' 🔒' : ''}`
|
|
1142
1295
|
}
|
|
1143
1296
|
|
|
1144
1297
|
case 'delete_topic': {
|
|
1145
1298
|
const topic = resolveTopic(args.name)
|
|
1299
|
+
let majorIsPinned = 0
|
|
1300
|
+
if (topic.major_topic_id) {
|
|
1301
|
+
const m = db.prepare('SELECT is_pinned FROM major_topics WHERE id = ?').get(topic.major_topic_id)
|
|
1302
|
+
if (m && m.is_pinned === 1) majorIsPinned = 1
|
|
1303
|
+
}
|
|
1304
|
+
const isLocked = topic.is_pinned === 1 || majorIsPinned === 1
|
|
1305
|
+
if (isLocked) {
|
|
1306
|
+
const guard = checkPinIfLocked(1, args.pin)
|
|
1307
|
+
if (guard === 'required') return PIN_REQ(topic.name, 'deletion')
|
|
1308
|
+
if (guard === 'invalid') return PIN_BAD(topic.name, 'deleted')
|
|
1309
|
+
}
|
|
1146
1310
|
db.prepare('DELETE FROM resources WHERE topic_id = ?').run(topic.id)
|
|
1147
1311
|
db.prepare('DELETE FROM topics WHERE id = ?').run(topic.id)
|
|
1148
1312
|
return `✓ Deleted topic "${topic.name}" and all its resources.`
|
|
@@ -1150,6 +1314,11 @@ async function handleTool(name, args) {
|
|
|
1150
1314
|
|
|
1151
1315
|
case 'rename_topic': {
|
|
1152
1316
|
const topic = resolveTopic(args.name)
|
|
1317
|
+
if (topic.is_pinned === 1) {
|
|
1318
|
+
const guard = checkPinIfLocked(topic.is_pinned, args.pin)
|
|
1319
|
+
if (guard === 'required') return PIN_REQ(topic.name, 'renaming')
|
|
1320
|
+
if (guard === 'invalid') return PIN_BAD(topic.name, 'renamed')
|
|
1321
|
+
}
|
|
1153
1322
|
const newName = args.new_name.trim()
|
|
1154
1323
|
db.prepare('UPDATE topics SET name = ?, updated_at = ? WHERE id = ?').run(newName, nowIso(), topic.id)
|
|
1155
1324
|
return `✓ Renamed topic from "${topic.name}" to "${newName}"`
|
|
@@ -1157,6 +1326,11 @@ async function handleTool(name, args) {
|
|
|
1157
1326
|
|
|
1158
1327
|
case 'set_topic_color': {
|
|
1159
1328
|
const topic = resolveTopic(args.name)
|
|
1329
|
+
if (topic.is_pinned === 1) {
|
|
1330
|
+
const guard = checkPinIfLocked(topic.is_pinned, args.pin)
|
|
1331
|
+
if (guard === 'required') return PIN_REQ(topic.name, 'changing colour')
|
|
1332
|
+
if (guard === 'invalid') return PIN_BAD(topic.name, 'color-changed')
|
|
1333
|
+
}
|
|
1160
1334
|
const color = resolveColor(args.color)
|
|
1161
1335
|
db.prepare('UPDATE topics SET color = ?, updated_at = ? WHERE id = ?').run(color, nowIso(), topic.id)
|
|
1162
1336
|
return `✓ Changed colour of topic "${topic.name}" to ${color}`
|
|
@@ -1165,6 +1339,11 @@ async function handleTool(name, args) {
|
|
|
1165
1339
|
case 'pin_topic': {
|
|
1166
1340
|
const topic = resolveTopic(args.name)
|
|
1167
1341
|
const pinned = args.pinned === true || String(args.pinned).toLowerCase() === 'true' ? 1 : 0
|
|
1342
|
+
if (!pinned && topic.is_pinned === 1) {
|
|
1343
|
+
const guard = checkPinIfLocked(topic.is_pinned, args.pin)
|
|
1344
|
+
if (guard === 'required') return PIN_REQ(topic.name, 'unlocking')
|
|
1345
|
+
if (guard === 'invalid') return PIN_BAD(topic.name, 'unlocked')
|
|
1346
|
+
}
|
|
1168
1347
|
db.prepare('UPDATE topics SET is_pinned = ?, updated_at = ? WHERE id = ?').run(pinned, nowIso(), topic.id)
|
|
1169
1348
|
return pinned ? `✓ Locked/pinned topic "${topic.name}" 🔒` : `✓ Unlocked topic "${topic.name}"`
|
|
1170
1349
|
}
|
|
@@ -1199,6 +1378,11 @@ async function handleTool(name, args) {
|
|
|
1199
1378
|
case 'add_image':
|
|
1200
1379
|
case 'add_code': {
|
|
1201
1380
|
const topic = resolveTopic(args.topic_name)
|
|
1381
|
+
if (topic.is_pinned === 1) {
|
|
1382
|
+
const guard = checkPinIfLocked(topic.is_pinned, args.pin)
|
|
1383
|
+
if (guard === 'required') return PIN_REQ(topic.name, 'adding resource to it')
|
|
1384
|
+
if (guard === 'invalid') return PIN_BAD(topic.name, 'modified')
|
|
1385
|
+
}
|
|
1202
1386
|
const type = RESOURCE_TYPE_MAP[name]
|
|
1203
1387
|
const id = newId()
|
|
1204
1388
|
let finalPathOrUrl = String(args.url || '').trim()
|
|
@@ -1218,10 +1402,54 @@ async function handleTool(name, args) {
|
|
|
1218
1402
|
case 'delete_resource': {
|
|
1219
1403
|
const row = db.prepare('SELECT * FROM resources WHERE id = ?').get(args.resource_id)
|
|
1220
1404
|
if (!row) throw new Error(`Resource not found with ID: "${args.resource_id}"`)
|
|
1405
|
+
let isLocked = row.is_pinned || 0
|
|
1406
|
+
if (!isLocked && row.topic_id) {
|
|
1407
|
+
const t = db.prepare('SELECT is_pinned, major_topic_id FROM topics WHERE id = ?').get(row.topic_id)
|
|
1408
|
+
if (t && t.is_pinned === 1) isLocked = 1
|
|
1409
|
+
else if (t && t.major_topic_id) {
|
|
1410
|
+
const m = db.prepare('SELECT is_pinned FROM major_topics WHERE id = ?').get(t.major_topic_id)
|
|
1411
|
+
if (m && m.is_pinned === 1) isLocked = 1
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
if (isLocked) {
|
|
1415
|
+
const guard = checkPinIfLocked(1, args.pin)
|
|
1416
|
+
if (guard === 'required') return PIN_REQ(row.description || 'Resource', 'deletion')
|
|
1417
|
+
if (guard === 'invalid') return PIN_BAD(row.description || 'Resource', 'deleted')
|
|
1418
|
+
}
|
|
1221
1419
|
db.prepare('DELETE FROM resources WHERE id = ?').run(row.id)
|
|
1222
1420
|
return `✓ Deleted resource "${row.description || row.path_or_url}"`
|
|
1223
1421
|
}
|
|
1224
1422
|
|
|
1423
|
+
case 'set_pin':
|
|
1424
|
+
case 'update_pin': {
|
|
1425
|
+
const newPin = String(args.new_pin ?? args.pin ?? '').trim()
|
|
1426
|
+
const currentPin = args.current_pin ? String(args.current_pin).trim() : undefined
|
|
1427
|
+
if (!newPin) {
|
|
1428
|
+
return '❌ PIN cannot be empty. Please provide a numeric PIN (e.g. 111111).'
|
|
1429
|
+
}
|
|
1430
|
+
if (hasPin()) {
|
|
1431
|
+
if (!currentPin) {
|
|
1432
|
+
return 'PIN_REQUIRED: A PIN is already set. Please provide current_pin to authorize updating to a new PIN.'
|
|
1433
|
+
}
|
|
1434
|
+
if (!verifyPin(currentPin)) {
|
|
1435
|
+
return 'PIN_INVALID: Current PIN is incorrect. Master PIN was NOT updated.'
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
setMasterPin(newPin)
|
|
1439
|
+
return `✓ Master PIN successfully updated to ${newPin.replace(/./g, '•')} 🔒`
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
case 'verify_pin': {
|
|
1443
|
+
const pin = String(args.pin ?? '').trim()
|
|
1444
|
+
if (!hasPin()) {
|
|
1445
|
+
return 'ℹ️ No master PIN is set yet. Use set_pin to create one.'
|
|
1446
|
+
}
|
|
1447
|
+
if (verifyPin(pin)) {
|
|
1448
|
+
return '✓ PIN verified successfully.'
|
|
1449
|
+
}
|
|
1450
|
+
return 'PIN_INVALID: Incorrect PIN.'
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1225
1453
|
case 'mark_resource_complete': {
|
|
1226
1454
|
const isCompleted = args.is_completed === true || String(args.is_completed).toLowerCase() === 'true' ? 1 : 0
|
|
1227
1455
|
const row = db.prepare('SELECT * FROM resources WHERE id = ?').get(args.resource_id)
|
|
@@ -1289,7 +1517,11 @@ async function handleTool(name, args) {
|
|
|
1289
1517
|
|
|
1290
1518
|
case 'write_topic_note': {
|
|
1291
1519
|
const topic = resolveTopic(args.topic_name)
|
|
1292
|
-
|
|
1520
|
+
if (topic.is_pinned === 1) {
|
|
1521
|
+
const guard = checkPinIfLocked(topic.is_pinned, args.pin)
|
|
1522
|
+
if (guard === 'required') return PIN_REQ(topic.name, 'writing/updating note')
|
|
1523
|
+
if (guard === 'invalid') return PIN_BAD(topic.name, 'updated')
|
|
1524
|
+
}
|
|
1293
1525
|
const existing = (topic.note || '').trim()
|
|
1294
1526
|
const newNote = existing ? `${existing}\n\n${args.note.trim()}` : args.note.trim()
|
|
1295
1527
|
const title = args.title ? args.title.trim() : topic.note_title
|
|
@@ -1421,7 +1653,7 @@ async function handleTool(name, args) {
|
|
|
1421
1653
|
// ── MCP Server Startup ────────────────────────────────────────────────────────
|
|
1422
1654
|
|
|
1423
1655
|
const server = new Server(
|
|
1424
|
-
{ name: 'karma365-mcp', version: '2.0.
|
|
1656
|
+
{ name: 'karma365-mcp', version: '2.0.1' },
|
|
1425
1657
|
{ capabilities: { tools: {} } }
|
|
1426
1658
|
)
|
|
1427
1659
|
|