poi-plugin-mcp 0.2.3 → 0.2.9

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/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # poi-plugin-mcp
2
2
 
3
- Poi plugin that starts a local HTTP/MCP bridge inside Poi so CLI/MCP agents can read current KanColle data.
3
+ Poi plugin that starts a loopback HTTP/MCP bridge inside Poi so local tools can
4
+ read current KanColle data, capture the game WebView, and use explicitly enabled
5
+ authenticated input.
4
6
 
5
7
  ## Install
6
8
 
@@ -42,8 +44,11 @@ The Poi plugin settings panel supports:
42
44
 
43
45
  - changing the HTTP port, saved in `~/.poi-mcp/settings.json`
44
46
  - manually starting and stopping the local bridge
47
+ - enabling WebView input, which defaults to off
45
48
 
46
- The default port is `17777`.
49
+ The default port is `17777`. A random 32-byte hex input token is generated at
50
+ `~/.poi-mcp/input-token` with restrictive best-effort file permissions. The
51
+ token is never returned by an HTTP endpoint.
47
52
 
48
53
  ## HTTP Endpoints
49
54
 
@@ -62,6 +67,8 @@ The default port is `17777`.
62
67
  | `/event` | Event ship tag definitions plus owned ships' current sally area |
63
68
  | `/planner` | Ship Info deck planner areas and ship assignments |
64
69
  | `/screenshot` | In-memory PNG capture of the game WebView |
70
+ | `/input/status` | Whether authenticated WebView input is enabled |
71
+ | `/input` | Authenticated, serialized WebView input |
65
72
  | `/all` | Combined basic runtime data |
66
73
 
67
74
  `/screenshot` accepts `GET` only. It uses Poi's existing
@@ -70,6 +77,39 @@ does not save a file, write the clipboard, capture the desktop, or appear in
70
77
  MCP resources and tools. Its response disables CORS and uses
71
78
  `Cache-Control: no-store`.
72
79
 
80
+ ### WebView Input
81
+
82
+ `GET /input/status` returns only `{"enabled":true|false}`. `POST /input`
83
+ requires `Authorization: Bearer <token>`, accepts at most 64 KiB of JSON, and
84
+ works only while WebView input is enabled in the plugin settings. Both routes
85
+ disable CORS and return `Cache-Control: no-store`.
86
+
87
+ Each POST accepts exactly one operation:
88
+
89
+ ```json
90
+ {"operation":"click","x":600,"y":360,"button":"left"}
91
+ ```
92
+
93
+ Click coordinates use a canonical 1200x720 layout and are scaled to Poi's
94
+ current game WebView. Supported buttons are `left`, `middle`, and `right`.
95
+
96
+ ```json
97
+ {"operation":"key","event":"keyDown","key":"Enter"}
98
+ ```
99
+
100
+ Key events are `keyDown` or `keyUp`. Supported keys are `Backspace`, `Delete`,
101
+ `End`, `Enter`, `Escape`, `Home`, `PageDown`, `PageUp`, `Space`, `Tab`, and the
102
+ four arrow keys.
103
+
104
+ ```json
105
+ {"operation":"text","text":"literal text"}
106
+ ```
107
+
108
+ Literal text must contain 1-256 printable characters. Successful requests
109
+ return a bounded response such as
110
+ `{"ok":true,"operation":"text","sequence":1}`. Complete operations execute in
111
+ sequence, so concurrent requests cannot interleave their WebView events.
112
+
73
113
  ## MCP Endpoint
74
114
 
75
115
  The same local server also exposes a JSON-RPC MCP endpoint:
package/index.js CHANGED
@@ -1,16 +1,28 @@
1
1
  const { createBridgeController } = require('./lib/bridge-controller')
2
+ const { createPoiTelemetry } = require('./lib/poi-telemetry')
2
3
  const { createSettingsClass } = require('./lib/settings-view')
3
4
 
4
- const controller = createBridgeController()
5
+ const telemetry = createPoiTelemetry()
6
+ const controller = createBridgeController({
7
+ getQuestList: telemetry.getQuestList,
8
+ getQuestAction: telemetry.getQuestAction,
9
+ getBattleTelemetry: telemetry.getBattleTelemetry,
10
+ })
5
11
  const settingsClass = createSettingsClass(controller)
6
12
 
7
13
  function pluginDidLoad() {
14
+ if (typeof window !== 'undefined') {
15
+ window.addEventListener('game.response', telemetry.handleGameResponse)
16
+ }
8
17
  controller.load().catch((error) => {
9
18
  console.error('[poi-plugin-mcp] Failed to start:', error.message)
10
19
  })
11
20
  }
12
21
 
13
22
  function pluginWillUnload() {
23
+ if (typeof window !== 'undefined') {
24
+ window.removeEventListener('game.response', telemetry.handleGameResponse)
25
+ }
14
26
  controller.unload().catch((error) => {
15
27
  console.error('[poi-plugin-mcp] Failed to stop:', error.message)
16
28
  })
@@ -1,4 +1,5 @@
1
1
  const { createPoiDataBridge } = require('./poi-http-bridge')
2
+ const { loadOrCreateInputToken } = require('./input-token')
2
3
  const {
3
4
  DEFAULT_SETTINGS_FILE,
4
5
  loadSettings,
@@ -13,6 +14,7 @@ function createBridgeController(options = {}) {
13
14
 
14
15
  let settings = loadSettings(settingsPath)
15
16
  let bridge = null
17
+ let inputToken = options.inputToken || null
16
18
  let pending = Promise.resolve()
17
19
 
18
20
  function enqueue(action) {
@@ -21,9 +23,18 @@ function createBridgeController(options = {}) {
21
23
  }
22
24
 
23
25
  function createCurrentBridge() {
26
+ if (!inputToken) {
27
+ inputToken = loadOrCreateInputToken(options.inputTokenFile)
28
+ }
24
29
  return createBridge({
25
30
  getStore: options.getStore,
26
31
  captureScreenshot: options.captureScreenshot,
32
+ performInput: options.performInput,
33
+ getQuestList: options.getQuestList,
34
+ getQuestAction: options.getQuestAction,
35
+ getBattleTelemetry: options.getBattleTelemetry,
36
+ inputEnabled: settings.inputEnabled,
37
+ inputToken,
27
38
  port: settings.port,
28
39
  portFile: options.portFile,
29
40
  logger,
@@ -83,6 +94,7 @@ function createBridgeController(options = {}) {
83
94
  const normalized = normalizeSettings({ ...settings, ...nextSettings })
84
95
  const portChanged = normalized.port !== settings.port
85
96
  const enabledChanged = normalized.enabled !== settings.enabled
97
+ const inputEnabledChanged = normalized.inputEnabled !== settings.inputEnabled
86
98
 
87
99
  persist(normalized)
88
100
 
@@ -91,7 +103,7 @@ function createBridgeController(options = {}) {
91
103
  return
92
104
  }
93
105
 
94
- if (portChanged || enabledChanged || !bridge) {
106
+ if (portChanged || enabledChanged || inputEnabledChanged || !bridge) {
95
107
  await stopCurrentBridge()
96
108
  await ensureStarted()
97
109
  }
@@ -109,6 +121,7 @@ function createBridgeController(options = {}) {
109
121
  running: actualPort > 0,
110
122
  port: settings.port,
111
123
  actualPort,
124
+ inputEnabled: settings.inputEnabled,
112
125
  }
113
126
  },
114
127
  }
@@ -0,0 +1,63 @@
1
+ const crypto = require('crypto')
2
+ const fs = require('fs')
3
+ const os = require('os')
4
+ const path = require('path')
5
+
6
+ const DEFAULT_INPUT_TOKEN_FILE = path.join(os.homedir(), '.poi-mcp', 'input-token')
7
+ const TOKEN_PATTERN = /^[a-f0-9]{64}$/
8
+
9
+ function loadOrCreateInputToken(tokenFile = DEFAULT_INPUT_TOKEN_FILE) {
10
+ const existing = readValidToken(tokenFile)
11
+ if (existing) {
12
+ restrictPermissions(tokenFile)
13
+ return existing
14
+ }
15
+
16
+ const directory = path.dirname(tokenFile)
17
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
18
+ restrictPermissions(directory, 0o700)
19
+
20
+ const token = crypto.randomBytes(32).toString('hex')
21
+ try {
22
+ fs.writeFileSync(tokenFile, `${token}\n`, {
23
+ encoding: 'utf8',
24
+ flag: 'wx',
25
+ mode: 0o600,
26
+ })
27
+ } catch (error) {
28
+ if (error.code !== 'EEXIST') throw error
29
+
30
+ const concurrent = readValidToken(tokenFile)
31
+ if (concurrent) {
32
+ restrictPermissions(tokenFile)
33
+ return concurrent
34
+ }
35
+ fs.writeFileSync(tokenFile, `${token}\n`, {
36
+ encoding: 'utf8',
37
+ mode: 0o600,
38
+ })
39
+ }
40
+
41
+ restrictPermissions(tokenFile)
42
+ return token
43
+ }
44
+
45
+ function readValidToken(tokenFile) {
46
+ try {
47
+ const token = fs.readFileSync(tokenFile, 'utf8').trim()
48
+ return TOKEN_PATTERN.test(token) ? token : null
49
+ } catch (_) {
50
+ return null
51
+ }
52
+ }
53
+
54
+ function restrictPermissions(target, mode = 0o600) {
55
+ try {
56
+ fs.chmodSync(target, mode)
57
+ } catch (_) {}
58
+ }
59
+
60
+ module.exports = {
61
+ DEFAULT_INPUT_TOKEN_FILE,
62
+ loadOrCreateInputToken,
63
+ }
@@ -1,8 +1,11 @@
1
1
  const fs = require('fs')
2
2
  const http = require('http')
3
+ const crypto = require('crypto')
3
4
  const os = require('os')
4
5
  const path = require('path')
5
6
  const packageJson = require('../package.json')
7
+ const { loadOrCreateInputToken } = require('./input-token')
8
+ const { createPoiInputProvider } = require('./poi-input')
6
9
  const { createPoiScreenshotProvider } = require('./poi-screenshot')
7
10
 
8
11
  const DEFAULT_PORT = 17777
@@ -14,6 +17,7 @@ const DEFAULT_PLANNER_FILE = path.join(
14
17
  )
15
18
  const JSONRPC_VERSION = '2.0'
16
19
  const MCP_PROTOCOL_VERSION = '2024-11-05'
20
+ const INPUT_BODY_LIMIT = 64 * 1024
17
21
 
18
22
  function createPoiDataBridge(options = {}) {
19
23
  const getStore = options.getStore || defaultGetStore
@@ -21,10 +25,21 @@ function createPoiDataBridge(options = {}) {
21
25
  const portFile = options.portFile || DEFAULT_PORT_FILE
22
26
  const plannerFile = options.plannerFile || DEFAULT_PLANNER_FILE
23
27
  const logger = options.logger || console
28
+ const getQuestList = options.getQuestList || (() => ({ available: false, generation: 0 }))
29
+ const getQuestAction = options.getQuestAction || (() => ({ available: false, generation: 0 }))
30
+ const getBattleTelemetry = options.getBattleTelemetry ||
31
+ (() => ({ available: false, generation: 0 }))
32
+ const inputEnabled = options.inputEnabled === true
33
+ const inputToken = options.inputToken || (
34
+ inputEnabled ? loadOrCreateInputToken(options.inputTokenFile) : null
35
+ )
24
36
  let captureScreenshot = options.captureScreenshot || null
37
+ let performInput = options.performInput || null
25
38
 
26
39
  let server = null
27
40
  let actualPort = 0
41
+ let inputPending = Promise.resolve()
42
+ let inputSequence = 0
28
43
 
29
44
  function readStore() {
30
45
  const store = getStore()
@@ -44,10 +59,82 @@ function createPoiDataBridge(options = {}) {
44
59
  if (options.noStore) {
45
60
  headers['Cache-Control'] = 'no-store'
46
61
  }
62
+ Object.assign(headers, options.headers)
47
63
  res.writeHead(statusCode, headers)
48
64
  res.end(JSON.stringify(data))
49
65
  }
50
66
 
67
+ function sendInputJson(res, statusCode, data, options = {}) {
68
+ sendJson(res, statusCode, data, {
69
+ allowCors: false,
70
+ noStore: true,
71
+ ...options,
72
+ })
73
+ }
74
+
75
+ function enqueueInput(operation) {
76
+ const execute = async () => {
77
+ if (!performInput) {
78
+ performInput = createPoiInputProvider({ getStore })
79
+ }
80
+ const operationName = await performInput(operation)
81
+ inputSequence += 1
82
+ return {
83
+ ok: true,
84
+ operation: operationName,
85
+ sequence: inputSequence,
86
+ }
87
+ }
88
+ const result = inputPending.then(execute, execute)
89
+ inputPending = result.catch(() => {})
90
+ return result
91
+ }
92
+
93
+ async function handleInputRequest(req, res) {
94
+ if (req.method !== 'POST') {
95
+ drainRequest(req)
96
+ sendInputJson(res, 405, { error: 'Input endpoint only accepts POST requests.' })
97
+ return
98
+ }
99
+ if (!hasValidBearerToken(req.headers.authorization, inputToken)) {
100
+ drainRequest(req)
101
+ sendInputJson(
102
+ res,
103
+ 401,
104
+ { error: 'A valid Bearer token is required.' },
105
+ { headers: { 'WWW-Authenticate': 'Bearer' } },
106
+ )
107
+ return
108
+ }
109
+ if (requestContentLength(req) > INPUT_BODY_LIMIT) {
110
+ drainRequest(req)
111
+ sendInputJson(res, 413, { error: 'Input request body exceeds 64KB.' })
112
+ return
113
+ }
114
+ if (!inputEnabled) {
115
+ drainRequest(req)
116
+ sendInputJson(res, 403, { error: 'WebView input is disabled.' })
117
+ return
118
+ }
119
+
120
+ try {
121
+ const body = await readRequestBody(
122
+ req,
123
+ INPUT_BODY_LIMIT,
124
+ 'Input request body exceeds 64KB.',
125
+ )
126
+ const operation = JSON.parse(body || '{}')
127
+ sendInputJson(res, 200, await enqueueInput(operation))
128
+ } catch (error) {
129
+ const statusCode = error.code === 'BODY_TOO_LARGE'
130
+ ? 413
131
+ : /WebView|dimensions/.test(error.message)
132
+ ? 503
133
+ : 400
134
+ sendInputJson(res, statusCode, { error: error.message })
135
+ }
136
+ }
137
+
51
138
  function handleMcpRequest(req, res) {
52
139
  if (req.method !== 'POST') {
53
140
  sendJson(res, 405, { error: 'MCP endpoint only accepts POST requests.' })
@@ -126,6 +213,35 @@ function createPoiDataBridge(options = {}) {
126
213
  return
127
214
  }
128
215
 
216
+ if (endpoint === '/input/status') {
217
+ if (req.method !== 'GET') {
218
+ drainRequest(req)
219
+ sendInputJson(
220
+ res,
221
+ 405,
222
+ { error: 'Input status endpoint only accepts GET requests.' },
223
+ )
224
+ return
225
+ }
226
+ sendInputJson(res, 200, { enabled: inputEnabled })
227
+ return
228
+ }
229
+
230
+ if (endpoint === '/input') {
231
+ await handleInputRequest(req, res)
232
+ return
233
+ }
234
+
235
+ if (endpoint === '/quest-list') {
236
+ sendJson(res, 200, getQuestList())
237
+ return
238
+ }
239
+
240
+ if (endpoint === '/quest-action') {
241
+ sendJson(res, 200, getQuestAction())
242
+ return
243
+ }
244
+
129
245
  const store = readStore()
130
246
  const info = store.info
131
247
 
@@ -166,6 +282,12 @@ function createPoiDataBridge(options = {}) {
166
282
  case '/planner':
167
283
  sendJson(res, 200, extractPlannerData(store, plannerFile))
168
284
  break
285
+ case '/battle':
286
+ sendJson(res, 200, combineBattleTelemetry(
287
+ getBattleTelemetry(),
288
+ extractProphetBattle(store),
289
+ ))
290
+ break
169
291
  case '/all':
170
292
  sendJson(res, 200, {
171
293
  basic: info.basic || {},
@@ -241,22 +363,64 @@ function createPoiDataBridge(options = {}) {
241
363
  }
242
364
  }
243
365
 
244
- function readRequestBody(req) {
366
+ function readRequestBody(
367
+ req,
368
+ maxBytes = 1024 * 1024,
369
+ tooLargeMessage = 'MCP request body is too large.',
370
+ ) {
245
371
  return new Promise((resolve, reject) => {
246
372
  let body = ''
373
+ let bodyBytes = 0
374
+ let tooLarge = false
247
375
  req.setEncoding('utf8')
248
376
  req.on('data', (chunk) => {
377
+ if (tooLarge) return
378
+ bodyBytes += Buffer.byteLength(chunk)
379
+ if (bodyBytes > maxBytes) {
380
+ tooLarge = true
381
+ body = ''
382
+ return
383
+ }
249
384
  body += chunk
250
- if (body.length > 1024 * 1024) {
251
- reject(new Error('MCP request body is too large.'))
252
- req.destroy()
385
+ })
386
+ req.on('end', () => {
387
+ if (tooLarge) {
388
+ const error = new Error(tooLargeMessage)
389
+ error.code = 'BODY_TOO_LARGE'
390
+ reject(error)
391
+ } else {
392
+ resolve(body)
253
393
  }
254
394
  })
255
- req.on('end', () => resolve(body))
256
395
  req.on('error', reject)
257
396
  })
258
397
  }
259
398
 
399
+ function requestContentLength(req) {
400
+ const value = req.headers['content-length']
401
+ if (value == null) return 0
402
+ const length = Number(value)
403
+ return Number.isSafeInteger(length) && length >= 0 ? length : Infinity
404
+ }
405
+
406
+ function drainRequest(req) {
407
+ req.resume()
408
+ }
409
+
410
+ function hasValidBearerToken(authorization, expectedToken) {
411
+ if (
412
+ typeof authorization !== 'string' ||
413
+ typeof expectedToken !== 'string' ||
414
+ !authorization.startsWith('Bearer ')
415
+ ) {
416
+ return false
417
+ }
418
+
419
+ const supplied = Buffer.from(authorization.slice('Bearer '.length), 'utf8')
420
+ const expected = Buffer.from(expectedToken, 'utf8')
421
+ return supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected)
422
+ }
423
+
260
424
  function handleMcpMessage(message, readStore, plannerFile) {
261
425
  const { id, method, params } = message || {}
262
426
 
@@ -565,13 +729,146 @@ function jsonRpcError(id, code, message) {
565
729
  return { jsonrpc: JSONRPC_VERSION, id, error: { code, message } }
566
730
  }
567
731
 
568
- function defaultGetStore() {
732
+ function defaultGetStore(storePath) {
569
733
  if (typeof window !== 'undefined' && typeof window.getStore === 'function') {
570
- return window.getStore()
734
+ return window.getStore(storePath)
571
735
  }
572
736
  return null
573
737
  }
574
738
 
739
+ function extractProphetBattle(store) {
740
+ const battle = store &&
741
+ store.ext &&
742
+ store.ext['poi-plugin-prophet'] &&
743
+ store.ext['poi-plugin-prophet']._ &&
744
+ store.ext['poi-plugin-prophet']._.battle
745
+ if (!battle || typeof battle !== 'object') {
746
+ return {
747
+ available: false,
748
+ source: 'poi-plugin-prophet',
749
+ engine: 'poi-lib-battle',
750
+ engineVersion: '3.0.5',
751
+ }
752
+ }
753
+
754
+ const fleets = {
755
+ main: compactBattleFleet(battle.mainFleet),
756
+ escort: compactBattleFleet(battle.escortFleet),
757
+ enemy: compactBattleFleet(battle.enemyFleet),
758
+ enemyEscort: compactBattleFleet(battle.enemyEscort),
759
+ }
760
+ const result = compactBattleResult(battle.result)
761
+ return {
762
+ available: true,
763
+ source: 'poi-plugin-prophet',
764
+ engine: 'poi-lib-battle',
765
+ engineVersion: '3.0.5',
766
+ sortieState: finiteNumber(battle.sortieState, 0),
767
+ sortieStateName: sortieStateName(battle.sortieState),
768
+ mapAreaId: finiteNumber(battle.mapAreaId, 0),
769
+ eventId: finiteNumber(battle.eventId, 0),
770
+ eventKind: finiteNumber(battle.eventKind, 0),
771
+ airControl: stringValue(battle.airControl),
772
+ battleForm: stringValue(battle.battleForm),
773
+ enemyFormation: stringValue(battle.eFormation),
774
+ rank: typeof result.rank === 'string' ? result.rank : null,
775
+ mvpIndex0Based: compactMvp(result.mvp),
776
+ heavilyDamaged: findHeavilyDamaged(fleets),
777
+ fleets,
778
+ }
779
+ }
780
+
781
+ function combineBattleTelemetry(telemetry, predicted) {
782
+ const current = telemetry && typeof telemetry === 'object'
783
+ ? telemetry
784
+ : { available: false, generation: 0 }
785
+ return {
786
+ available: current.available === true || predicted.available === true,
787
+ generation: finiteNumber(current.generation, 0),
788
+ status: typeof current.status === 'string' ? current.status : 'unavailable',
789
+ observed: current.observed || null,
790
+ predicted,
791
+ official: current.official || null,
792
+ }
793
+ }
794
+
795
+ function compactBattleResult(result) {
796
+ if (!result || typeof result !== 'object') return {}
797
+ return Object.fromEntries(
798
+ ['rank', 'mvp', 'getShip', 'getItem']
799
+ .filter((key) => result[key] !== undefined)
800
+ .map((key) => [key, result[key]]),
801
+ )
802
+ }
803
+
804
+ function compactMvp(value) {
805
+ const values = Array.isArray(value) ? value : [value, null]
806
+ return {
807
+ main: Number.isInteger(values[0]) && values[0] >= 0 ? values[0] : null,
808
+ escort: Number.isInteger(values[1]) && values[1] >= 0 ? values[1] : null,
809
+ }
810
+ }
811
+
812
+ function findHeavilyDamaged(fleets) {
813
+ return ['main', 'escort'].flatMap((fleetName) =>
814
+ fleets[fleetName].flatMap((ship) => {
815
+ if (
816
+ !Number.isFinite(ship.currentHp) ||
817
+ !Number.isFinite(ship.maxHp) ||
818
+ ship.maxHp <= 0 ||
819
+ ship.currentHp > ship.maxHp * 0.25
820
+ ) {
821
+ return []
822
+ }
823
+ return [{
824
+ fleet: fleetName,
825
+ position: ship.position,
826
+ instanceId: ship.instanceId,
827
+ currentHp: ship.currentHp,
828
+ maxHp: ship.maxHp,
829
+ }]
830
+ }),
831
+ )
832
+ }
833
+
834
+ function compactBattleFleet(fleet) {
835
+ if (!Array.isArray(fleet)) return []
836
+ return fleet.flatMap((ship) => {
837
+ if (!ship || typeof ship !== 'object') return []
838
+ const raw = ship.raw && typeof ship.raw === 'object' ? ship.raw : {}
839
+ return [{
840
+ id: nullableNumber(ship.id),
841
+ owner: nullableNumber(ship.owner),
842
+ position: nullableNumber(ship.pos),
843
+ maxHp: nullableNumber(ship.maxHP),
844
+ initialHp: nullableNumber(ship.initHP),
845
+ currentHp: nullableNumber(ship.nowHP),
846
+ lostHp: nullableNumber(ship.lostHP),
847
+ damage: nullableNumber(ship.damage),
848
+ items: Array.isArray(ship.items) ? [...ship.items] : [],
849
+ useItem: ship.useItem == null ? null : ship.useItem,
850
+ instanceId: Number.isInteger(raw.api_id) ? raw.api_id : null,
851
+ masterId: Number.isInteger(raw.api_ship_id) ? raw.api_ship_id : null,
852
+ }]
853
+ })
854
+ }
855
+
856
+ function sortieStateName(value) {
857
+ return ['in_port', 'navigation', 'battle', 'practice'][value] || 'unknown'
858
+ }
859
+
860
+ function finiteNumber(value, fallback) {
861
+ return Number.isFinite(value) ? value : fallback
862
+ }
863
+
864
+ function nullableNumber(value) {
865
+ return Number.isFinite(value) ? value : null
866
+ }
867
+
868
+ function stringValue(value) {
869
+ return typeof value === 'string' ? value : ''
870
+ }
871
+
575
872
  function cleanupPortFile(portFile) {
576
873
  try {
577
874
  fs.unlinkSync(portFile)
@@ -753,6 +1050,7 @@ module.exports = {
753
1050
  DEFAULT_PORT,
754
1051
  DEFAULT_PORT_FILE,
755
1052
  DEFAULT_PLANNER_FILE,
1053
+ INPUT_BODY_LIMIT,
756
1054
  extractEventData,
757
1055
  extractMasterData,
758
1056
  extractPlannerData,
@@ -0,0 +1,205 @@
1
+ const CANONICAL_WIDTH = 1200
2
+ const CANONICAL_HEIGHT = 720
3
+ const DEFAULT_CLICK_DELAY_MS = 10
4
+ const MAX_TEXT_LENGTH = 256
5
+
6
+ const SUPPORTED_BUTTONS = new Set(['left', 'middle', 'right'])
7
+ const SUPPORTED_KEY_EVENTS = new Set(['keyDown', 'keyUp'])
8
+ const SUPPORTED_KEYS = new Set([
9
+ 'Backspace',
10
+ 'Delete',
11
+ 'End',
12
+ 'Enter',
13
+ 'Escape',
14
+ 'Home',
15
+ 'PageDown',
16
+ 'PageUp',
17
+ 'Space',
18
+ 'Tab',
19
+ 'ArrowDown',
20
+ 'ArrowLeft',
21
+ 'ArrowRight',
22
+ 'ArrowUp',
23
+ ])
24
+
25
+ function createPoiInputProvider(options = {}) {
26
+ const getStore = options.getStore || defaultGetStore
27
+ const resolveWebContents =
28
+ options.resolveWebContents || defaultResolveWebContents
29
+ const delay = options.delay || defaultDelay
30
+ const clickDelayMs = options.clickDelayMs == null
31
+ ? DEFAULT_CLICK_DELAY_MS
32
+ : options.clickDelayMs
33
+
34
+ if (
35
+ !Number.isInteger(clickDelayMs) ||
36
+ clickDelayMs < 1 ||
37
+ clickDelayMs > 100
38
+ ) {
39
+ throw new Error('clickDelayMs must be an integer from 1 to 100')
40
+ }
41
+
42
+ return async function performPoiInput(operation) {
43
+ validateOperationObject(operation)
44
+ const layout = readLayout(getStore, resolveWebContents)
45
+
46
+ switch (operation.operation) {
47
+ case 'click':
48
+ validateClick(operation)
49
+ await sendClick(layout, operation, delay, clickDelayMs)
50
+ return 'click'
51
+ case 'key':
52
+ validateKey(operation)
53
+ await layout.webContents.sendInputEvent({
54
+ type: operation.event,
55
+ keyCode: operation.key,
56
+ })
57
+ return 'key'
58
+ case 'text':
59
+ validateText(operation)
60
+ for (const character of operation.text) {
61
+ await layout.webContents.sendInputEvent({
62
+ type: 'char',
63
+ keyCode: character,
64
+ })
65
+ }
66
+ return 'text'
67
+ default:
68
+ throw new Error(`Unsupported input operation: ${String(operation.operation)}`)
69
+ }
70
+ }
71
+ }
72
+
73
+ function validateOperationObject(operation) {
74
+ if (
75
+ !operation ||
76
+ typeof operation !== 'object' ||
77
+ Array.isArray(operation)
78
+ ) {
79
+ throw new Error('Input must be one operation object')
80
+ }
81
+ }
82
+
83
+ function readLayout(getStore, resolveWebContents) {
84
+ const layout = getStore('layout.webview')
85
+ if (!layout || !layout.ref) {
86
+ throw new Error('Poi game WebView is not ready')
87
+ }
88
+ if (
89
+ !Number.isFinite(layout.width) ||
90
+ layout.width <= 0 ||
91
+ !Number.isFinite(layout.height) ||
92
+ layout.height <= 0
93
+ ) {
94
+ throw new Error('Poi game WebView dimensions must be positive finite numbers')
95
+ }
96
+ let webContents
97
+ if (typeof layout.ref.getWebContents === 'function') {
98
+ webContents = layout.ref.getWebContents()
99
+ } else if (typeof layout.ref.getWebContentsId === 'function') {
100
+ const webContentsId = layout.ref.getWebContentsId()
101
+ if (!Number.isInteger(webContentsId) || webContentsId <= 0) {
102
+ throw new Error('Poi game WebContents id is invalid')
103
+ }
104
+ webContents = resolveWebContents(webContentsId)
105
+ } else {
106
+ throw new Error('Poi game WebView is not ready')
107
+ }
108
+ if (!webContents || typeof webContents.sendInputEvent !== 'function') {
109
+ throw new Error('Poi game WebContents is not ready')
110
+ }
111
+ return { ...layout, webContents }
112
+ }
113
+
114
+ function validateClick(operation) {
115
+ assertExactFields(operation, ['operation', 'x', 'y', 'button'])
116
+ if (!Number.isFinite(operation.x) || !Number.isFinite(operation.y)) {
117
+ throw new Error('Click coordinates must be finite numbers')
118
+ }
119
+ if (
120
+ operation.x < 0 ||
121
+ operation.x >= CANONICAL_WIDTH ||
122
+ operation.y < 0 ||
123
+ operation.y >= CANONICAL_HEIGHT
124
+ ) {
125
+ throw new Error('Click coordinates must be within canonical bounds')
126
+ }
127
+ if (!SUPPORTED_BUTTONS.has(operation.button)) {
128
+ throw new Error(`Unsupported mouse button: ${String(operation.button)}`)
129
+ }
130
+ }
131
+
132
+ function validateKey(operation) {
133
+ assertExactFields(operation, ['operation', 'event', 'key'])
134
+ if (!SUPPORTED_KEY_EVENTS.has(operation.event)) {
135
+ throw new Error(`Unsupported key event: ${String(operation.event)}`)
136
+ }
137
+ if (!SUPPORTED_KEYS.has(operation.key)) {
138
+ throw new Error(`Unsupported key: ${String(operation.key)}`)
139
+ }
140
+ }
141
+
142
+ function validateText(operation) {
143
+ assertExactFields(operation, ['operation', 'text'])
144
+ if (
145
+ typeof operation.text !== 'string' ||
146
+ operation.text.length === 0 ||
147
+ operation.text.length > MAX_TEXT_LENGTH
148
+ ) {
149
+ throw new Error('Literal text must contain 1 to 256 characters')
150
+ }
151
+ if (/[\u0000-\u001f\u007f-\u009f]/u.test(operation.text)) {
152
+ throw new Error('Literal text must contain printable characters only')
153
+ }
154
+ }
155
+
156
+ function assertExactFields(operation, allowedFields) {
157
+ const allowed = new Set(allowedFields)
158
+ const unexpected = Object.keys(operation).find((field) => !allowed.has(field))
159
+ if (unexpected) {
160
+ throw new Error(`Unexpected field for ${operation.operation}: ${unexpected}`)
161
+ }
162
+ const missing = allowedFields.find((field) => !Object.hasOwn(operation, field))
163
+ if (missing) {
164
+ throw new Error(`Missing field for ${operation.operation}: ${missing}`)
165
+ }
166
+ }
167
+
168
+ async function sendClick(layout, operation, delay, clickDelayMs) {
169
+ const event = {
170
+ x: Math.floor((operation.x * layout.width) / CANONICAL_WIDTH),
171
+ y: Math.floor((operation.y * layout.height) / CANONICAL_HEIGHT),
172
+ button: operation.button,
173
+ clickCount: 1,
174
+ }
175
+
176
+ await layout.webContents.sendInputEvent({ type: 'mouseDown', ...event })
177
+ try {
178
+ await delay(clickDelayMs)
179
+ } finally {
180
+ await layout.webContents.sendInputEvent({ type: 'mouseUp', ...event })
181
+ }
182
+ }
183
+
184
+ function defaultDelay(milliseconds) {
185
+ return new Promise((resolve) => setTimeout(resolve, milliseconds))
186
+ }
187
+
188
+ function defaultGetStore(path) {
189
+ if (typeof window !== 'undefined' && typeof window.getStore === 'function') {
190
+ return window.getStore(path)
191
+ }
192
+ return null
193
+ }
194
+
195
+ function defaultResolveWebContents(webContentsId) {
196
+ const { webContents } = require('@electron/remote')
197
+ return webContents.fromId(webContentsId)
198
+ }
199
+
200
+ module.exports = {
201
+ CANONICAL_HEIGHT,
202
+ CANONICAL_WIDTH,
203
+ MAX_TEXT_LENGTH,
204
+ createPoiInputProvider,
205
+ }
@@ -0,0 +1,215 @@
1
+ const QUEST_LIST_PATH = '/kcsapi/api_get_member/questlist'
2
+ const QUEST_ACTION_PATHS = new Set([
3
+ '/kcsapi/api_req_quest/start',
4
+ '/kcsapi/api_req_quest/stop',
5
+ ])
6
+ const BATTLE_RESULT_PATHS = new Set([
7
+ '/kcsapi/api_req_practice/battle_result',
8
+ '/kcsapi/api_req_sortie/battleresult',
9
+ '/kcsapi/api_req_combined_battle/battleresult',
10
+ ])
11
+ const BATTLE_PATHS = new Set([
12
+ '/kcsapi/api_req_practice/battle',
13
+ '/kcsapi/api_req_practice/midnight_battle',
14
+ '/kcsapi/api_req_sortie/battle',
15
+ '/kcsapi/api_req_sortie/airbattle',
16
+ '/kcsapi/api_req_sortie/ld_airbattle',
17
+ '/kcsapi/api_req_sortie/ld_shooting',
18
+ '/kcsapi/api_req_battle_midnight/battle',
19
+ '/kcsapi/api_req_battle_midnight/sp_midnight',
20
+ '/kcsapi/api_req_combined_battle/battle',
21
+ '/kcsapi/api_req_combined_battle/battle_water',
22
+ '/kcsapi/api_req_combined_battle/airbattle',
23
+ '/kcsapi/api_req_combined_battle/ld_airbattle',
24
+ '/kcsapi/api_req_combined_battle/ld_shooting',
25
+ '/kcsapi/api_req_combined_battle/ec_battle',
26
+ '/kcsapi/api_req_combined_battle/each_battle',
27
+ '/kcsapi/api_req_combined_battle/each_battle_water',
28
+ '/kcsapi/api_req_combined_battle/midnight_battle',
29
+ '/kcsapi/api_req_combined_battle/sp_midnight',
30
+ '/kcsapi/api_req_combined_battle/ec_midnight_battle',
31
+ '/kcsapi/api_req_combined_battle/ec_night_to_day',
32
+ ])
33
+
34
+ function createPoiTelemetry(options = {}) {
35
+ const now = options.now || (() => new Date())
36
+ let questGeneration = 0
37
+ let questList = null
38
+ let questActionGeneration = 0
39
+ let questAction = null
40
+ let battleGeneration = 0
41
+ let battleTelemetry = null
42
+
43
+ function handleGameResponse(event) {
44
+ const detail = event && event.detail
45
+ if (!detail || typeof detail.path !== 'string') return
46
+
47
+ if (detail.path === QUEST_LIST_PATH) {
48
+ captureQuestList(detail)
49
+ return
50
+ }
51
+ if (QUEST_ACTION_PATHS.has(detail.path)) {
52
+ captureQuestAction(detail)
53
+ return
54
+ }
55
+ if (BATTLE_RESULT_PATHS.has(detail.path)) {
56
+ captureBattleResult(detail)
57
+ return
58
+ }
59
+ if (BATTLE_PATHS.has(detail.path)) {
60
+ captureBattlePacket(detail)
61
+ }
62
+ }
63
+
64
+ function captureQuestList(detail) {
65
+ const body = detail.body
66
+ const postBody = detail.postBody
67
+ const tabId = toInteger(postBody && postBody.api_tab_id)
68
+ if (
69
+ !body ||
70
+ !Array.isArray(body.api_list) ||
71
+ tabId == null ||
72
+ tabId < 0 ||
73
+ tabId > 9
74
+ ) {
75
+ return
76
+ }
77
+
78
+ const quests = body.api_list.flatMap((quest, index) => {
79
+ if (!quest || typeof quest !== 'object' || !Number.isInteger(quest.api_no)) {
80
+ return []
81
+ }
82
+ return [{ ...quest, pageRow: index + 1 }]
83
+ })
84
+
85
+ questGeneration += 1
86
+ questList = {
87
+ available: true,
88
+ generation: questGeneration,
89
+ capturedAt: now().toISOString(),
90
+ tabId,
91
+ pageNo: nonNegativeInteger(body.api_disp_page),
92
+ pageCount: nonNegativeInteger(body.api_page_count),
93
+ count: nonNegativeInteger(body.api_count),
94
+ execCount: nonNegativeInteger(body.api_exec_count),
95
+ execType: nonNegativeInteger(body.api_exec_type),
96
+ quests,
97
+ }
98
+ }
99
+
100
+ function captureQuestAction(detail) {
101
+ const postBody = detail.postBody
102
+ const questId = toInteger(postBody && postBody.api_quest_id)
103
+ if (questId == null || questId <= 0) return
104
+
105
+ questActionGeneration += 1
106
+ questAction = {
107
+ available: true,
108
+ generation: questActionGeneration,
109
+ capturedAt: now().toISOString(),
110
+ path: detail.path,
111
+ questId,
112
+ flag: toInteger(postBody && postBody.api_quest_flag),
113
+ }
114
+ }
115
+
116
+ function captureBattlePacket(detail) {
117
+ if (!battleTelemetry || battleTelemetry.status === 'settled') {
118
+ battleGeneration += 1
119
+ }
120
+ battleTelemetry = {
121
+ available: true,
122
+ generation: battleGeneration,
123
+ status: 'in_progress',
124
+ observed: {
125
+ capturedAt: now().toISOString(),
126
+ path: detail.path,
127
+ time: finiteOrNull(detail.time),
128
+ phaseStartHp: {
129
+ friendlyMain: numberArray(detail.body && detail.body.api_f_nowhps),
130
+ friendlyEscort: numberArray(
131
+ detail.body && detail.body.api_f_nowhps_combined,
132
+ ),
133
+ enemyMain: numberArray(detail.body && detail.body.api_e_nowhps),
134
+ enemyEscort: numberArray(
135
+ detail.body && detail.body.api_e_nowhps_combined,
136
+ ),
137
+ },
138
+ },
139
+ official: null,
140
+ }
141
+ }
142
+
143
+ function captureBattleResult(detail) {
144
+ if (!battleTelemetry) battleGeneration += 1
145
+ const body = detail.body && typeof detail.body === 'object'
146
+ ? detail.body
147
+ : {}
148
+ battleTelemetry = {
149
+ available: true,
150
+ generation: battleGeneration,
151
+ status: 'settled',
152
+ observed: battleTelemetry ? battleTelemetry.observed : null,
153
+ official: {
154
+ capturedAt: now().toISOString(),
155
+ path: detail.path,
156
+ time: finiteOrNull(detail.time),
157
+ rank: typeof body.api_win_rank === 'string' ? body.api_win_rank : null,
158
+ mvpPosition: {
159
+ main: positiveIntegerOrNull(body.api_mvp),
160
+ escort: positiveIntegerOrNull(body.api_mvp_combined),
161
+ },
162
+ drop: {
163
+ ship: objectOrNull(body.api_get_ship),
164
+ useItem: objectOrNull(body.api_get_useitem),
165
+ },
166
+ },
167
+ }
168
+ }
169
+
170
+ return {
171
+ handleGameResponse,
172
+ getQuestList() {
173
+ return questList || { available: false, generation: 0 }
174
+ },
175
+ getQuestAction() {
176
+ return questAction || { available: false, generation: 0 }
177
+ },
178
+ getBattleTelemetry() {
179
+ return battleTelemetry || { available: false, generation: 0 }
180
+ },
181
+ }
182
+ }
183
+
184
+ function toInteger(value, fallback = null) {
185
+ if (value == null || value === '') return fallback
186
+ const parsed = Number(value)
187
+ return Number.isInteger(parsed) ? parsed : null
188
+ }
189
+
190
+ function nonNegativeInteger(value) {
191
+ const parsed = toInteger(value, 0)
192
+ return parsed != null && parsed >= 0 ? parsed : 0
193
+ }
194
+
195
+ function positiveIntegerOrNull(value) {
196
+ const parsed = toInteger(value)
197
+ return parsed != null && parsed > 0 ? parsed : null
198
+ }
199
+
200
+ function finiteOrNull(value) {
201
+ return Number.isFinite(value) ? value : null
202
+ }
203
+
204
+ function numberArray(value) {
205
+ if (!Array.isArray(value)) return []
206
+ return value.map((item) => finiteOrNull(item))
207
+ }
208
+
209
+ function objectOrNull(value) {
210
+ return value && typeof value === 'object' ? { ...value } : null
211
+ }
212
+
213
+ module.exports = {
214
+ createPoiTelemetry,
215
+ }
@@ -54,9 +54,20 @@ function renderStatefulSettings(React, controller) {
54
54
  validPort,
55
55
  onPortChange: (event) => setPortText(event.target.value),
56
56
  onApply: () => run(
57
- () => controller.applySettings({ port, enabled: status.enabled }),
57
+ () => controller.applySettings({
58
+ port,
59
+ enabled: status.enabled,
60
+ inputEnabled: status.inputEnabled,
61
+ }),
58
62
  'Port saved',
59
63
  ),
64
+ onInputToggle: (event) => {
65
+ const inputEnabled = event.target.checked
66
+ return run(
67
+ () => controller.applySettings({ inputEnabled }),
68
+ inputEnabled ? 'WebView input enabled' : 'WebView input disabled',
69
+ )
70
+ },
60
71
  onToggle: () => run(
61
72
  () => (status.running ? controller.stopBridge() : controller.startBridge()),
62
73
  status.running ? 'Stopped' : 'Started',
@@ -79,8 +90,17 @@ function renderStaticSettings(e, controller) {
79
90
  onApply: async () => {
80
91
  const input = typeof document !== 'undefined' ? document.getElementById('poi-mcp-port') : null
81
92
  const nextPort = input ? Number(input.value) : port
82
- await controller.applySettings({ port: nextPort, enabled: status.enabled })
93
+ await controller.applySettings({
94
+ port: nextPort,
95
+ enabled: status.enabled,
96
+ inputEnabled: status.inputEnabled,
97
+ })
83
98
  },
99
+ onInputToggle: (event) => controller.applySettings({
100
+ port,
101
+ enabled: status.enabled,
102
+ inputEnabled: event.target.checked,
103
+ }),
84
104
  onToggle: () => (status.running ? controller.stopBridge() : controller.startBridge()),
85
105
  })
86
106
  }
@@ -121,6 +141,20 @@ function renderSettings(e, props) {
121
141
  }, props.status.running ? 'Stop' : 'Start'),
122
142
  e('span', { style: styles.status }, statusText),
123
143
  ),
144
+ e('div', { style: styles.row },
145
+ e('label', { style: styles.label, htmlFor: 'poi-mcp-input-enabled' }, 'WebView input'),
146
+ e('input', {
147
+ id: 'poi-mcp-input-enabled',
148
+ type: 'checkbox',
149
+ checked: props.onPortChange ? props.status.inputEnabled : undefined,
150
+ defaultChecked: props.onPortChange ? undefined : props.status.inputEnabled,
151
+ disabled: props.busy,
152
+ onChange: props.onInputToggle,
153
+ }),
154
+ e('span', { style: styles.status },
155
+ props.status.inputEnabled ? 'Enabled' : 'Disabled',
156
+ ),
157
+ ),
124
158
  e('div', { style: styles.note },
125
159
  'Pi extension defaults to 127.0.0.1:17777; keep this port unless you also update Pi.',
126
160
  ),
package/lib/settings.js CHANGED
@@ -5,6 +5,7 @@ const path = require('path')
5
5
  const DEFAULT_SETTINGS = Object.freeze({
6
6
  port: 17777,
7
7
  enabled: true,
8
+ inputEnabled: false,
8
9
  })
9
10
 
10
11
  const DEFAULT_SETTINGS_FILE = path.join(os.homedir(), '.poi-mcp', 'settings.json')
@@ -14,6 +15,9 @@ function normalizeSettings(input = {}) {
14
15
  return {
15
16
  port: port == null ? DEFAULT_SETTINGS.port : port,
16
17
  enabled: typeof input.enabled === 'boolean' ? input.enabled : DEFAULT_SETTINGS.enabled,
18
+ inputEnabled: typeof input.inputEnabled === 'boolean'
19
+ ? input.inputEnabled
20
+ : DEFAULT_SETTINGS.inputEnabled,
17
21
  }
18
22
  }
19
23
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "poi-plugin-mcp",
3
- "version": "0.2.3",
4
- "description": "Poi read-only data and game WebView capture bridge for local KanColle tools.",
3
+ "version": "0.2.9",
4
+ "description": "Poi data, WebView capture, and opt-in authenticated input bridge for local KanColle tools.",
5
5
  "main": "index.js",
6
6
  "keywords": [
7
7
  "poi-plugin",
@@ -24,7 +24,7 @@
24
24
  "poiPlugin": {
25
25
  "title": "MCP 数据桥",
26
26
  "id": "mcp_data_bridge",
27
- "description": "Expose Poi state and in-memory game WebView captures on a local read-only bridge.",
27
+ "description": "Expose Poi state, in-memory game WebView captures, and opt-in authenticated input on a local bridge.",
28
28
  "icon": "fa/database",
29
29
  "priority": 53
30
30
  },