poi-plugin-mcp 0.2.0

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.
@@ -0,0 +1,669 @@
1
+ const fs = require('fs')
2
+ const http = require('http')
3
+ const os = require('os')
4
+ const path = require('path')
5
+
6
+ const DEFAULT_PORT = 17777
7
+ const DEFAULT_PORT_FILE = path.join(os.homedir(), '.poi-mcp', 'port')
8
+ const DEFAULT_PLANNER_FILE = path.join(
9
+ process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
10
+ 'poi',
11
+ 'poi-plugin-ship-info.json',
12
+ )
13
+ const JSONRPC_VERSION = '2.0'
14
+ const MCP_PROTOCOL_VERSION = '2024-11-05'
15
+
16
+ function createPoiDataBridge(options = {}) {
17
+ const getStore = options.getStore || defaultGetStore
18
+ const configuredPort = options.port == null ? DEFAULT_PORT : options.port
19
+ const portFile = options.portFile || DEFAULT_PORT_FILE
20
+ const plannerFile = options.plannerFile || DEFAULT_PLANNER_FILE
21
+ const logger = options.logger || console
22
+
23
+ let server = null
24
+ let actualPort = 0
25
+
26
+ function readStore() {
27
+ const store = getStore()
28
+ if (!store || !store.info) {
29
+ throw new Error('POI store not ready. Enter the game first.')
30
+ }
31
+ return store
32
+ }
33
+
34
+ function sendJson(res, statusCode, data) {
35
+ res.writeHead(statusCode, {
36
+ 'Access-Control-Allow-Origin': '*',
37
+ 'Content-Type': 'application/json',
38
+ })
39
+ res.end(JSON.stringify(data))
40
+ }
41
+
42
+ function handleMcpRequest(req, res) {
43
+ if (req.method !== 'POST') {
44
+ sendJson(res, 405, { error: 'MCP endpoint only accepts POST requests.' })
45
+ return
46
+ }
47
+
48
+ readRequestBody(req)
49
+ .then((body) => {
50
+ const message = JSON.parse(body || '{}')
51
+ const response = handleMcpMessage(message, readStore, plannerFile)
52
+
53
+ if (response == null) {
54
+ res.writeHead(202, {
55
+ 'Access-Control-Allow-Origin': '*',
56
+ 'Content-Type': 'application/json',
57
+ })
58
+ res.end('')
59
+ return
60
+ }
61
+
62
+ sendJson(res, 200, response)
63
+ })
64
+ .catch((error) => {
65
+ sendJson(res, 200, jsonRpcError(null, -32700, error.message))
66
+ })
67
+ }
68
+
69
+ function handleRequest(req, res) {
70
+ try {
71
+ if (req.url === '/shutdown') {
72
+ sendJson(res, 200, { status: 'shutting down' })
73
+ stop()
74
+ return
75
+ }
76
+
77
+ const endpoint = new URL(req.url, `http://127.0.0.1:${actualPort || configuredPort}`).pathname
78
+
79
+ if (endpoint === '/health') {
80
+ sendJson(res, 200, { status: 'ok' })
81
+ return
82
+ }
83
+
84
+ if (endpoint === '/mcp') {
85
+ handleMcpRequest(req, res)
86
+ return
87
+ }
88
+
89
+ const store = readStore()
90
+ const info = store.info
91
+
92
+ switch (endpoint) {
93
+ case '/basic':
94
+ sendJson(res, 200, info.basic || {})
95
+ break
96
+ case '/fleets':
97
+ sendJson(res, 200, info.fleets || [])
98
+ break
99
+ case '/ships':
100
+ sendJson(res, 200, info.ships || {})
101
+ break
102
+ case '/equipment':
103
+ sendJson(res, 200, info.equips || {})
104
+ break
105
+ case '/resources':
106
+ sendJson(res, 200, info.resources || [])
107
+ break
108
+ case '/quests':
109
+ sendJson(res, 200, {
110
+ activeQuests: (info.quests && info.quests.activeQuests) || {},
111
+ records: (info.quests && info.quests.records) || {},
112
+ })
113
+ break
114
+ case '/airbase':
115
+ sendJson(res, 200, info.airbase || [])
116
+ break
117
+ case '/names':
118
+ sendJson(res, 200, extractNames(store))
119
+ break
120
+ case '/master':
121
+ sendJson(res, 200, extractMasterData(store))
122
+ break
123
+ case '/event':
124
+ sendJson(res, 200, extractEventData(store))
125
+ break
126
+ case '/planner':
127
+ sendJson(res, 200, extractPlannerData(store, plannerFile))
128
+ break
129
+ case '/all':
130
+ sendJson(res, 200, {
131
+ basic: info.basic || {},
132
+ fleets: info.fleets || [],
133
+ ships: info.ships || {},
134
+ equipment: info.equips || {},
135
+ resources: info.resources || [],
136
+ quests: {
137
+ activeQuests: (info.quests && info.quests.activeQuests) || {},
138
+ records: (info.quests && info.quests.records) || {},
139
+ },
140
+ airbase: info.airbase || [],
141
+ names: extractNames(store),
142
+ })
143
+ break
144
+ default:
145
+ sendJson(res, 404, { error: `Unknown endpoint: ${endpoint}` })
146
+ }
147
+ } catch (error) {
148
+ sendJson(res, 503, { error: error.message })
149
+ }
150
+ }
151
+
152
+ function start() {
153
+ if (server) return Promise.resolve()
154
+
155
+ fs.mkdirSync(path.dirname(portFile), { recursive: true })
156
+ server = http.createServer(handleRequest)
157
+
158
+ return new Promise((resolve, reject) => {
159
+ const onError = (error) => {
160
+ cleanupPortFile(portFile)
161
+ server = null
162
+ reject(error)
163
+ }
164
+
165
+ server.once('error', onError)
166
+ server.listen(configuredPort, '127.0.0.1', () => {
167
+ server.removeListener('error', onError)
168
+ actualPort = server.address().port
169
+ fs.writeFileSync(portFile, String(actualPort), 'utf8')
170
+ logger.log(`[poi-plugin-mcp] HTTP API started on http://127.0.0.1:${actualPort}`)
171
+ resolve()
172
+ })
173
+ })
174
+ }
175
+
176
+ function stop() {
177
+ if (!server) {
178
+ cleanupPortFile(portFile)
179
+ actualPort = 0
180
+ return Promise.resolve()
181
+ }
182
+
183
+ const closingServer = server
184
+ server = null
185
+
186
+ return new Promise((resolve) => {
187
+ closingServer.close(() => {
188
+ cleanupPortFile(portFile)
189
+ actualPort = 0
190
+ resolve()
191
+ })
192
+ })
193
+ }
194
+
195
+ return {
196
+ start,
197
+ stop,
198
+ getPort() {
199
+ return actualPort
200
+ },
201
+ }
202
+ }
203
+
204
+ function readRequestBody(req) {
205
+ return new Promise((resolve, reject) => {
206
+ let body = ''
207
+ req.setEncoding('utf8')
208
+ req.on('data', (chunk) => {
209
+ body += chunk
210
+ if (body.length > 1024 * 1024) {
211
+ reject(new Error('MCP request body is too large.'))
212
+ req.destroy()
213
+ }
214
+ })
215
+ req.on('end', () => resolve(body))
216
+ req.on('error', reject)
217
+ })
218
+ }
219
+
220
+ function handleMcpMessage(message, readStore, plannerFile) {
221
+ const { id, method, params } = message || {}
222
+
223
+ switch (method) {
224
+ case 'initialize':
225
+ return jsonRpcResult(id, {
226
+ protocolVersion: MCP_PROTOCOL_VERSION,
227
+ capabilities: {
228
+ resources: { subscribe: false },
229
+ tools: {},
230
+ },
231
+ serverInfo: { name: 'poi-plugin-mcp', version: '0.2.0' },
232
+ })
233
+
234
+ case 'notifications/initialized':
235
+ case 'notifications/cancelled':
236
+ return null
237
+
238
+ case 'ping':
239
+ return jsonRpcResult(id, {})
240
+
241
+ case 'resources/list':
242
+ return jsonRpcResult(id, {
243
+ resources: MCP_RESOURCE_ENDPOINTS.map(({ uri }) => ({
244
+ uri,
245
+ name: uri.replace('poi://', ''),
246
+ mimeType: 'application/json',
247
+ })),
248
+ })
249
+
250
+ case 'resources/read': {
251
+ const uri = params && params.uri
252
+ const endpoint = MCP_RESOURCE_ENDPOINTS.find((item) => item.uri === uri)
253
+ if (!endpoint) return jsonRpcError(id, -32602, `Unknown resource: ${uri}`)
254
+ const data = readBridgeData(endpoint.path, readStore, plannerFile)
255
+ return jsonRpcResult(id, {
256
+ contents: [{
257
+ uri,
258
+ mimeType: 'application/json',
259
+ text: JSON.stringify(data, null, 2),
260
+ }],
261
+ })
262
+ }
263
+
264
+ case 'tools/list':
265
+ return jsonRpcResult(id, { tools: MCP_TOOLS })
266
+
267
+ case 'tools/call': {
268
+ const toolName = params && params.name
269
+ const toolArgs = (params && params.arguments) || {}
270
+ const result = callMcpTool(toolName, toolArgs, readStore, plannerFile)
271
+ if (result.error) return jsonRpcError(id, -32602, result.error)
272
+ return jsonRpcResult(id, {
273
+ content: [{ type: 'text', text: JSON.stringify(result.value, null, 2) }],
274
+ })
275
+ }
276
+
277
+ default:
278
+ return jsonRpcError(id, -32601, `Unknown method: ${method}`)
279
+ }
280
+ }
281
+
282
+ const MCP_RESOURCE_ENDPOINTS = [
283
+ { uri: 'poi://basic', path: '/basic' },
284
+ { uri: 'poi://fleets', path: '/fleets' },
285
+ { uri: 'poi://ships', path: '/ships' },
286
+ { uri: 'poi://equipment', path: '/equipment' },
287
+ { uri: 'poi://resources', path: '/resources' },
288
+ { uri: 'poi://quests', path: '/quests' },
289
+ { uri: 'poi://airbase', path: '/airbase' },
290
+ { uri: 'poi://names', path: '/names' },
291
+ { uri: 'poi://master', path: '/master' },
292
+ { uri: 'poi://event', path: '/event' },
293
+ { uri: 'poi://planner', path: '/planner' },
294
+ { uri: 'poi://all', path: '/all' },
295
+ ]
296
+
297
+ const MCP_TOOLS = [
298
+ {
299
+ name: 'get_fleet_status',
300
+ description: 'Get one owned fleet with ship and equipped item instance details.',
301
+ inputSchema: {
302
+ type: 'object',
303
+ properties: { fleetId: { type: 'number', description: 'Fleet number, 1-4.' } },
304
+ required: ['fleetId'],
305
+ },
306
+ },
307
+ {
308
+ name: 'search_ships',
309
+ description: 'Search owned ship instances by level and morale.',
310
+ inputSchema: {
311
+ type: 'object',
312
+ properties: {
313
+ minLevel: { type: 'number' },
314
+ maxLevel: { type: 'number' },
315
+ minMorale: { type: 'number' },
316
+ },
317
+ },
318
+ },
319
+ {
320
+ name: 'search_equipment',
321
+ description: 'Search owned equipment instances by improvement level.',
322
+ inputSchema: {
323
+ type: 'object',
324
+ properties: {
325
+ minLevel: { type: 'number', description: 'Minimum improvement level.' },
326
+ },
327
+ },
328
+ },
329
+ {
330
+ name: 'get_resources',
331
+ description: 'Get current account resource array.',
332
+ inputSchema: { type: 'object', properties: {} },
333
+ },
334
+ {
335
+ name: 'get_all',
336
+ description: 'Get account basics, fleets, ships, equipment, resources, quests, airbase, and names.',
337
+ inputSchema: { type: 'object', properties: {} },
338
+ },
339
+ ]
340
+
341
+ function callMcpTool(toolName, args, readStore, plannerFile) {
342
+ switch (toolName) {
343
+ case 'get_fleet_status':
344
+ return { value: buildFleetStatus(args || {}, readStore) }
345
+ case 'search_ships':
346
+ return { value: searchShips(args || {}, readStore) }
347
+ case 'search_equipment':
348
+ return { value: searchEquipment(args || {}, readStore) }
349
+ case 'get_resources':
350
+ return { value: readBridgeData('/resources', readStore, plannerFile) }
351
+ case 'get_all':
352
+ return { value: readBridgeData('/all', readStore, plannerFile) }
353
+ default:
354
+ return { error: `Unknown tool: ${toolName}` }
355
+ }
356
+ }
357
+
358
+ function readBridgeData(endpoint, readStore, plannerFile) {
359
+ const store = readStore()
360
+ const info = store.info
361
+
362
+ switch (endpoint) {
363
+ case '/basic':
364
+ return info.basic || {}
365
+ case '/fleets':
366
+ return info.fleets || []
367
+ case '/ships':
368
+ return info.ships || {}
369
+ case '/equipment':
370
+ return info.equips || {}
371
+ case '/resources':
372
+ return info.resources || []
373
+ case '/quests':
374
+ return {
375
+ activeQuests: (info.quests && info.quests.activeQuests) || {},
376
+ records: (info.quests && info.quests.records) || {},
377
+ }
378
+ case '/airbase':
379
+ return info.airbase || []
380
+ case '/names':
381
+ return extractNames(store)
382
+ case '/master':
383
+ return extractMasterData(store)
384
+ case '/event':
385
+ return extractEventData(store)
386
+ case '/planner':
387
+ return extractPlannerData(store, plannerFile)
388
+ case '/all':
389
+ return {
390
+ basic: info.basic || {},
391
+ fleets: info.fleets || [],
392
+ ships: info.ships || {},
393
+ equipment: info.equips || {},
394
+ resources: info.resources || [],
395
+ quests: {
396
+ activeQuests: (info.quests && info.quests.activeQuests) || {},
397
+ records: (info.quests && info.quests.records) || {},
398
+ },
399
+ airbase: info.airbase || [],
400
+ names: extractNames(store),
401
+ }
402
+ default:
403
+ throw new Error(`Unknown endpoint: ${endpoint}`)
404
+ }
405
+ }
406
+
407
+ function buildFleetStatus(args, readStore) {
408
+ const fleetId = Number(args.fleetId)
409
+ const store = readStore()
410
+ const info = store.info
411
+ const fleets = Array.isArray(info.fleets) ? info.fleets : []
412
+ const fleet = fleets[fleetId - 1]
413
+ if (!fleet) return { error: `Fleet #${args.fleetId} not found` }
414
+
415
+ const ships = info.ships || {}
416
+ const equips = info.equips || {}
417
+
418
+ return {
419
+ id: fleet.api_id,
420
+ name: fleet.api_name,
421
+ mission: fleet.api_mission,
422
+ ships: (fleet.api_ship || []).filter((id) => id > 0).map((shipId) => {
423
+ const ship = ships[shipId]
424
+ if (!ship) return { id: shipId }
425
+
426
+ return {
427
+ id: ship.api_id,
428
+ shipId: ship.api_ship_id,
429
+ level: ship.api_lv,
430
+ hp: `${ship.api_nowhp}/${ship.api_maxhp}`,
431
+ morale: ship.api_cond,
432
+ locked: ship.api_locked,
433
+ slotItems: (ship.api_slot || []).filter((equipId) => equipId > 0).map((equipId) => {
434
+ const equip = equips[equipId]
435
+ return equip ? {
436
+ id: equip.api_id,
437
+ equipId: equip.api_slotitem_id,
438
+ level: equip.api_level || 0,
439
+ prof: equip.api_alv || 0,
440
+ } : null
441
+ }).filter(Boolean),
442
+ }
443
+ }),
444
+ }
445
+ }
446
+
447
+ function searchShips(args, readStore) {
448
+ const store = readStore()
449
+ const ships = Object.values((store.info && store.info.ships) || {}).filter((ship) => {
450
+ if (!ship) return false
451
+ if (args.minLevel != null && ship.api_lv < Number(args.minLevel)) return false
452
+ if (args.maxLevel != null && ship.api_lv > Number(args.maxLevel)) return false
453
+ if (args.minMorale != null && ship.api_cond < Number(args.minMorale)) return false
454
+ return true
455
+ })
456
+
457
+ return { total: ships.length, ships }
458
+ }
459
+
460
+ function searchEquipment(args, readStore) {
461
+ const store = readStore()
462
+ const equipment = Object.values((store.info && store.info.equips) || {}).filter((equip) => {
463
+ if (!equip) return false
464
+ if (args.minLevel != null && (equip.api_level || 0) < Number(args.minLevel)) return false
465
+ return true
466
+ })
467
+
468
+ return { total: equipment.length, equipment }
469
+ }
470
+
471
+ function jsonRpcResult(id, result) {
472
+ return { jsonrpc: JSONRPC_VERSION, id, result }
473
+ }
474
+
475
+ function jsonRpcError(id, code, message) {
476
+ return { jsonrpc: JSONRPC_VERSION, id, error: { code, message } }
477
+ }
478
+
479
+ function defaultGetStore() {
480
+ if (typeof window !== 'undefined' && typeof window.getStore === 'function') {
481
+ return window.getStore()
482
+ }
483
+ return null
484
+ }
485
+
486
+ function cleanupPortFile(portFile) {
487
+ try {
488
+ fs.unlinkSync(portFile)
489
+ } catch (_) {}
490
+ }
491
+
492
+ function extractNames(store) {
493
+ const result = { ships: {}, equipment: {}, missions: {} }
494
+ const constants = store.const || {}
495
+
496
+ collectApiNames(constants.$ships, result.ships)
497
+ collectApiNames(constants.$equips, result.equipment)
498
+ collectApiNames(constants.$missions, result.missions)
499
+
500
+ const wctf = store.wctf || {}
501
+ if (Object.keys(result.ships).length === 0) collectSimpleNames(wctf.ships, result.ships)
502
+ if (Object.keys(result.equipment).length === 0) collectSimpleNames(wctf.items, result.equipment)
503
+
504
+ return result
505
+ }
506
+
507
+ function collectApiNames(source, target) {
508
+ if (!source || typeof source !== 'object') return
509
+
510
+ for (const [id, value] of Object.entries(source)) {
511
+ if (value && value.api_name) target[id] = value.api_name
512
+ }
513
+ }
514
+
515
+ function collectSimpleNames(source, target) {
516
+ if (!source || typeof source !== 'object') return
517
+
518
+ for (const [id, value] of Object.entries(source)) {
519
+ if (value && value.name) target[id] = value.name
520
+ }
521
+ }
522
+
523
+ function extractMasterData(store) {
524
+ const constants = store.const || {}
525
+
526
+ return {
527
+ ships: constants.$ships || {},
528
+ equipment: constants.$equips || {},
529
+ shipTypes: constants.$shipTypes || {},
530
+ equipmentTypes:
531
+ constants.$equipTypes ||
532
+ constants.$equipmentTypes ||
533
+ constants.$slotitemTypes ||
534
+ constants.$slotItemTypes ||
535
+ {},
536
+ missions: constants.$missions || {},
537
+ }
538
+ }
539
+
540
+ function extractEventData(store) {
541
+ const tags = extractShipTags(store)
542
+ const ships = {}
543
+
544
+ for (const ship of Object.values((store.info && store.info.ships) || {})) {
545
+ if (!ship || typeof ship !== 'object') continue
546
+ const area = ship.api_sally_area || 0
547
+ const tag = tags[area - 1] || emptyTag(area)
548
+
549
+ ships[ship.api_id] = {
550
+ shipId: ship.api_id,
551
+ modelId: ship.api_ship_id,
552
+ area,
553
+ mapName: tag.mapName,
554
+ fleetName: tag.fleetName,
555
+ color: tag.color,
556
+ }
557
+ }
558
+
559
+ return {
560
+ tags,
561
+ ships,
562
+ }
563
+ }
564
+
565
+ function extractPlannerData(store, plannerFile = DEFAULT_PLANNER_FILE) {
566
+ const tags = extractShipTags(store)
567
+ const current = readPlannerCurrent(plannerFile)
568
+ const length = Math.max(tags.length, current.length)
569
+ const areas = []
570
+ const shipMap = {}
571
+
572
+ for (let index = 0; index < length; index += 1) {
573
+ const area = index + 1
574
+ const tag = tags[index] || emptyTag(area)
575
+ const shipIds = Array.isArray(current[index]) ? current[index] : []
576
+
577
+ areas.push({
578
+ area,
579
+ mapName: tag.mapName,
580
+ fleetName: tag.fleetName,
581
+ color: tag.color,
582
+ shipIds,
583
+ })
584
+
585
+ for (const shipId of shipIds) {
586
+ shipMap[shipId] = {
587
+ area,
588
+ mapName: tag.mapName,
589
+ fleetName: tag.fleetName,
590
+ color: tag.color,
591
+ }
592
+ }
593
+ }
594
+
595
+ return {
596
+ areas,
597
+ shipMap,
598
+ }
599
+ }
600
+
601
+ function readPlannerCurrent(plannerFile) {
602
+ try {
603
+ const data = JSON.parse(fs.readFileSync(plannerFile, 'utf8'))
604
+ if (Array.isArray(data.planner)) return data.planner
605
+ if (data.planner && Array.isArray(data.planner.current)) {
606
+ return data.planner.current
607
+ }
608
+ } catch (_) {}
609
+
610
+ return []
611
+ }
612
+
613
+ function extractShipTags(store) {
614
+ const shiptag = (store.fcd && store.fcd.shiptag) || {}
615
+ const mapNames = Array.isArray(shiptag.mapname) ? shiptag.mapname : []
616
+ const fleetNames = selectFleetNames(shiptag.fleetname)
617
+ const colors = Array.isArray(shiptag.color) ? shiptag.color : []
618
+
619
+ return mapNames.map((mapName, index) => ({
620
+ area: index + 1,
621
+ mapName,
622
+ fleetName: fleetNames[index] || mapName,
623
+ color: colors[index] || '',
624
+ }))
625
+ }
626
+
627
+ function selectFleetNames(fleetname) {
628
+ if (Array.isArray(fleetname)) return fleetname
629
+ if (!fleetname || typeof fleetname !== 'object') return []
630
+
631
+ const language = getWindowLanguage()
632
+ return (
633
+ fleetname[language] ||
634
+ fleetname['zh-CN'] ||
635
+ fleetname['zh-TW'] ||
636
+ fleetname.ja ||
637
+ fleetname['ja-JP'] ||
638
+ fleetname['en-US'] ||
639
+ Object.values(fleetname).find(Array.isArray) ||
640
+ []
641
+ )
642
+ }
643
+
644
+ function getWindowLanguage() {
645
+ if (typeof window !== 'undefined' && window.language) {
646
+ return window.language
647
+ }
648
+ return 'zh-CN'
649
+ }
650
+
651
+ function emptyTag(area) {
652
+ return {
653
+ area,
654
+ mapName: '',
655
+ fleetName: '',
656
+ color: '',
657
+ }
658
+ }
659
+
660
+ module.exports = {
661
+ createPoiDataBridge,
662
+ DEFAULT_PORT,
663
+ DEFAULT_PORT_FILE,
664
+ DEFAULT_PLANNER_FILE,
665
+ extractEventData,
666
+ extractMasterData,
667
+ extractPlannerData,
668
+ extractNames,
669
+ }