poi-plugin-mcp 0.2.15 → 0.2.21
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 +290 -201
- package/index.js +3 -0
- package/lib/bridge-controller.js +75 -1
- package/lib/fleet-metrics.js +249 -0
- package/lib/poi-action-events.js +405 -291
- package/lib/poi-api-responses.js +165 -0
- package/lib/poi-auto-interaction-recorder.js +399 -0
- package/lib/poi-data-query.js +254 -0
- package/lib/poi-http-bridge.js +1673 -1320
- package/lib/poi-input.js +366 -302
- package/lib/poi-interaction-recorder.js +1693 -0
- package/lib/poi-telemetry.js +515 -439
- package/lib/poi-webview-runtime.js +1181 -0
- package/lib/settings-view.js +112 -0
- package/lib/settings.js +8 -0
- package/mcp-server.js +542 -456
- package/package.json +3 -3
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
const fs = require('node:fs')
|
|
2
|
+
const os = require('node:os')
|
|
3
|
+
const path = require('node:path')
|
|
4
|
+
const { createPoiWebviewRuntime } = require('./poi-webview-runtime')
|
|
5
|
+
|
|
6
|
+
const QUERY_SCHEMA_VERSION = 1
|
|
7
|
+
const MAX_QUERY_BODY_BYTES = 64 * 1024
|
|
8
|
+
const MAX_QUERY_RESULT_BYTES = 8 * 1024 * 1024
|
|
9
|
+
const MAX_CACHE_FILE_BYTES = 16 * 1024 * 1024
|
|
10
|
+
const MAX_PATH_SEGMENTS = 64
|
|
11
|
+
const SENSITIVE_KEY = /^(?:api_token|authorization|cookies?|credentials?|loginData|password|secret|ticket|accessToken|refreshToken)$/iu
|
|
12
|
+
|
|
13
|
+
function createPoiDataQuery(options = {}) {
|
|
14
|
+
const getStore = options.getStore || defaultGetStore
|
|
15
|
+
const getApiResponses = options.getApiResponses || (() => ({
|
|
16
|
+
available: false,
|
|
17
|
+
latestGeneration: 0,
|
|
18
|
+
responses: [],
|
|
19
|
+
}))
|
|
20
|
+
const cacheRoot = path.resolve(options.cacheRoot || defaultCacheRoot())
|
|
21
|
+
const runtime = options.runtime || createPoiWebviewRuntime({
|
|
22
|
+
getStore,
|
|
23
|
+
resolveWebContents: options.resolveWebContents,
|
|
24
|
+
logger: options.logger,
|
|
25
|
+
now: options.now,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
async function query(request = {}) {
|
|
29
|
+
const source = boundedString(request.source, 64, 'source')
|
|
30
|
+
let value
|
|
31
|
+
switch (source) {
|
|
32
|
+
case 'capabilities':
|
|
33
|
+
value = {
|
|
34
|
+
schemaVersion: QUERY_SCHEMA_VERSION,
|
|
35
|
+
sources: [
|
|
36
|
+
'poi.store',
|
|
37
|
+
'api.responses',
|
|
38
|
+
'cache.json',
|
|
39
|
+
'webview.frames',
|
|
40
|
+
'webview.storage',
|
|
41
|
+
'webview.path',
|
|
42
|
+
'webview.find',
|
|
43
|
+
],
|
|
44
|
+
pathFormat: 'array',
|
|
45
|
+
webviewRoots: ['globalThis', 'pixi.last-rendered'],
|
|
46
|
+
webviewProjections: ['pixi.interactive'],
|
|
47
|
+
cacheRoot,
|
|
48
|
+
}
|
|
49
|
+
break
|
|
50
|
+
case 'poi.store':
|
|
51
|
+
value = selectPath(getStore(), validatePath(request.path))
|
|
52
|
+
break
|
|
53
|
+
case 'api.responses': {
|
|
54
|
+
const response = getApiResponses({
|
|
55
|
+
after: optionalInteger(request.after, 0, Number.MAX_SAFE_INTEGER, 'after'),
|
|
56
|
+
limit: optionalInteger(request.limit, 1, 256, 'limit'),
|
|
57
|
+
path: request.apiPath == null
|
|
58
|
+
? undefined
|
|
59
|
+
: boundedString(request.apiPath, 512, 'apiPath'),
|
|
60
|
+
})
|
|
61
|
+
value = selectPath(response, validatePath(request.path))
|
|
62
|
+
break
|
|
63
|
+
}
|
|
64
|
+
case 'cache.json': {
|
|
65
|
+
const filename = resolveCacheFile(
|
|
66
|
+
cacheRoot,
|
|
67
|
+
boundedString(request.file, 1024, 'file'),
|
|
68
|
+
)
|
|
69
|
+
const stat = fs.statSync(filename)
|
|
70
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_CACHE_FILE_BYTES) {
|
|
71
|
+
throw queryError('CACHE_FILE_INVALID', 'Cache JSON file is empty or exceeds 16MB', 400)
|
|
72
|
+
}
|
|
73
|
+
const parsed = JSON.parse(fs.readFileSync(filename, 'utf8'))
|
|
74
|
+
value = selectPath(parsed, validatePath(request.path))
|
|
75
|
+
break
|
|
76
|
+
}
|
|
77
|
+
case 'webview.frames':
|
|
78
|
+
value = await runtime.listFrames()
|
|
79
|
+
break
|
|
80
|
+
case 'webview.storage':
|
|
81
|
+
value = await runtime.readStorage(request)
|
|
82
|
+
break
|
|
83
|
+
case 'webview.path':
|
|
84
|
+
value = await runtime.readPath(request)
|
|
85
|
+
break
|
|
86
|
+
case 'webview.find':
|
|
87
|
+
value = await runtime.findObjects(request)
|
|
88
|
+
break
|
|
89
|
+
default:
|
|
90
|
+
throw queryError('QUERY_SOURCE_UNKNOWN', `Unknown query source: ${source}`, 400)
|
|
91
|
+
}
|
|
92
|
+
const sanitized = sanitize(value)
|
|
93
|
+
assertResultSize(sanitized)
|
|
94
|
+
return {
|
|
95
|
+
schemaVersion: QUERY_SCHEMA_VERSION,
|
|
96
|
+
source,
|
|
97
|
+
value: sanitized,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return Object.freeze({ query, runtime })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validatePath(value) {
|
|
105
|
+
if (value == null) return []
|
|
106
|
+
if (!Array.isArray(value) || value.length > MAX_PATH_SEGMENTS) {
|
|
107
|
+
throw queryError(
|
|
108
|
+
'QUERY_PATH_INVALID',
|
|
109
|
+
`path must be an array with at most ${MAX_PATH_SEGMENTS} segments`,
|
|
110
|
+
400,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
return value.map((segment) => {
|
|
114
|
+
if (
|
|
115
|
+
(typeof segment !== 'string' && !Number.isInteger(segment)) ||
|
|
116
|
+
String(segment).length > 256
|
|
117
|
+
) {
|
|
118
|
+
throw queryError('QUERY_PATH_INVALID', 'path segments must be short strings or integers', 400)
|
|
119
|
+
}
|
|
120
|
+
const text = String(segment)
|
|
121
|
+
if (
|
|
122
|
+
['__proto__', 'prototype', 'constructor'].includes(text) ||
|
|
123
|
+
SENSITIVE_KEY.test(text)
|
|
124
|
+
) {
|
|
125
|
+
throw queryError('QUERY_PATH_INVALID', `Unsafe path segment: ${text}`, 400)
|
|
126
|
+
}
|
|
127
|
+
return text
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function selectPath(root, segments) {
|
|
132
|
+
let current = root
|
|
133
|
+
for (const segment of segments) {
|
|
134
|
+
if (
|
|
135
|
+
current === null ||
|
|
136
|
+
(typeof current !== 'object' && typeof current !== 'function') ||
|
|
137
|
+
!Object.prototype.hasOwnProperty.call(current, segment)
|
|
138
|
+
) {
|
|
139
|
+
throw queryError('QUERY_PATH_NOT_FOUND', `Query path was not found at ${segment}`, 404)
|
|
140
|
+
}
|
|
141
|
+
current = current[segment]
|
|
142
|
+
}
|
|
143
|
+
return current
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function resolveCacheFile(root, relativeFile) {
|
|
147
|
+
if (path.isAbsolute(relativeFile)) {
|
|
148
|
+
throw queryError('CACHE_PATH_INVALID', 'Cache file must be relative to the Poi data root', 400)
|
|
149
|
+
}
|
|
150
|
+
const segments = relativeFile.split(/[\\/]+/u).filter(Boolean)
|
|
151
|
+
if (
|
|
152
|
+
segments.length === 0 ||
|
|
153
|
+
segments.some((segment) =>
|
|
154
|
+
segment === '..' || SENSITIVE_KEY.test(path.parse(segment).name))
|
|
155
|
+
) {
|
|
156
|
+
throw queryError('CACHE_PATH_INVALID', 'Cache file path is unsafe', 400)
|
|
157
|
+
}
|
|
158
|
+
const resolved = path.resolve(root, ...segments)
|
|
159
|
+
const relative = path.relative(root, resolved)
|
|
160
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
161
|
+
throw queryError('CACHE_PATH_INVALID', 'Cache file escapes the Poi data root', 400)
|
|
162
|
+
}
|
|
163
|
+
if (path.extname(resolved).toLowerCase() !== '.json') {
|
|
164
|
+
throw queryError('CACHE_PATH_INVALID', 'Only JSON cache files can be queried', 400)
|
|
165
|
+
}
|
|
166
|
+
return resolved
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sanitize(value, depth = 0, seen = new WeakSet()) {
|
|
170
|
+
if (
|
|
171
|
+
value === null ||
|
|
172
|
+
typeof value === 'boolean' ||
|
|
173
|
+
(typeof value === 'number' && Number.isFinite(value))
|
|
174
|
+
) {
|
|
175
|
+
return value
|
|
176
|
+
}
|
|
177
|
+
if (typeof value === 'string') return value.slice(0, 256 * 1024)
|
|
178
|
+
if (typeof value === 'bigint') return String(value)
|
|
179
|
+
if (typeof value !== 'object') return undefined
|
|
180
|
+
if (seen.has(value)) return '[Circular]'
|
|
181
|
+
if (depth >= 16) return '[MaxDepth]'
|
|
182
|
+
seen.add(value)
|
|
183
|
+
if (Array.isArray(value)) {
|
|
184
|
+
return value.slice(0, 20_000).map((item) => sanitize(item, depth + 1, seen))
|
|
185
|
+
}
|
|
186
|
+
const output = {}
|
|
187
|
+
for (const [key, item] of Object.entries(value).slice(0, 20_000)) {
|
|
188
|
+
if (SENSITIVE_KEY.test(key)) {
|
|
189
|
+
output[key] = '[REDACTED]'
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
const sanitized = sanitize(item, depth + 1, seen)
|
|
193
|
+
if (sanitized !== undefined) output[key] = sanitized
|
|
194
|
+
}
|
|
195
|
+
return output
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function assertResultSize(value) {
|
|
199
|
+
const size = Buffer.byteLength(JSON.stringify(value), 'utf8')
|
|
200
|
+
if (size > MAX_QUERY_RESULT_BYTES) {
|
|
201
|
+
throw queryError('QUERY_RESULT_TOO_LARGE', 'Query result exceeds 8MB; select a narrower path', 413)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function boundedString(value, maximum, name) {
|
|
206
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > maximum) {
|
|
207
|
+
throw queryError(
|
|
208
|
+
'QUERY_ARGUMENT_INVALID',
|
|
209
|
+
`${name} must be a non-empty string up to ${maximum} characters`,
|
|
210
|
+
400,
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
return value
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function optionalInteger(value, minimum, maximum, name) {
|
|
217
|
+
if (value == null) return undefined
|
|
218
|
+
const parsed = Number(value)
|
|
219
|
+
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
|
|
220
|
+
throw queryError(
|
|
221
|
+
'QUERY_ARGUMENT_INVALID',
|
|
222
|
+
`${name} must be an integer from ${minimum} to ${maximum}`,
|
|
223
|
+
400,
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
return parsed
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function queryError(code, message, statusCode) {
|
|
230
|
+
const error = new Error(message)
|
|
231
|
+
error.code = code
|
|
232
|
+
error.statusCode = statusCode
|
|
233
|
+
return error
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function defaultCacheRoot() {
|
|
237
|
+
return path.join(
|
|
238
|
+
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
|
239
|
+
'poi',
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function defaultGetStore(storePath) {
|
|
244
|
+
if (typeof window !== 'undefined' && typeof window.getStore === 'function') {
|
|
245
|
+
return window.getStore(storePath)
|
|
246
|
+
}
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = {
|
|
251
|
+
MAX_QUERY_BODY_BYTES,
|
|
252
|
+
QUERY_SCHEMA_VERSION,
|
|
253
|
+
createPoiDataQuery,
|
|
254
|
+
}
|