poi-plugin-mcp 0.2.16 → 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.
@@ -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
+ }