react-native-mcp-kit 1.0.0 → 2.0.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.
- package/README.md +220 -720
- package/dist/babel/stripPlugin.d.ts.map +1 -1
- package/dist/babel/stripPlugin.js +40 -3
- package/dist/babel/stripPlugin.js.map +1 -1
- package/dist/bin/ios-hid +0 -0
- package/dist/client/contexts/McpContext/McpProvider.d.ts +1 -1
- package/dist/client/contexts/McpContext/McpProvider.d.ts.map +1 -1
- package/dist/client/contexts/McpContext/McpProvider.js +67 -4
- package/dist/client/contexts/McpContext/McpProvider.js.map +1 -1
- package/dist/client/contexts/McpContext/types.d.ts +11 -1
- package/dist/client/contexts/McpContext/types.d.ts.map +1 -1
- package/dist/client/core/McpClient.d.ts +7 -0
- package/dist/client/core/McpClient.d.ts.map +1 -1
- package/dist/client/core/McpClient.js +73 -2
- package/dist/client/core/McpClient.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/modules/device/device.d.ts.map +1 -1
- package/dist/modules/device/device.js +16 -4
- package/dist/modules/device/device.js.map +1 -1
- package/dist/modules/fiberTree/fiberTree.d.ts.map +1 -1
- package/dist/modules/fiberTree/fiberTree.js +61 -15
- package/dist/modules/fiberTree/fiberTree.js.map +1 -1
- package/dist/modules/fiberTree/types.d.ts +9 -0
- package/dist/modules/fiberTree/types.d.ts.map +1 -1
- package/dist/modules/fiberTree/utils.d.ts +2 -1
- package/dist/modules/fiberTree/utils.d.ts.map +1 -1
- package/dist/modules/fiberTree/utils.js +57 -1
- package/dist/modules/fiberTree/utils.js.map +1 -1
- package/dist/modules/index.d.ts +0 -1
- package/dist/modules/index.d.ts.map +1 -1
- package/dist/modules/index.js +1 -3
- package/dist/modules/index.js.map +1 -1
- package/dist/modules/navigation/navigation.d.ts.map +1 -1
- package/dist/modules/navigation/navigation.js +32 -40
- package/dist/modules/navigation/navigation.js.map +1 -1
- package/dist/modules/navigation/types.d.ts +2 -1
- package/dist/modules/navigation/types.d.ts.map +1 -1
- package/dist/modules/network/network.d.ts.map +1 -1
- package/dist/modules/network/network.js +16 -1
- package/dist/modules/network/network.js.map +1 -1
- package/dist/server/bridge.d.ts +38 -12
- package/dist/server/bridge.d.ts.map +1 -1
- package/dist/server/bridge.js +136 -56
- package/dist/server/bridge.js.map +1 -1
- package/dist/server/cli.js +10 -1
- package/dist/server/cli.js.map +1 -1
- package/dist/server/host/deviceResolver.d.ts +53 -0
- package/dist/server/host/deviceResolver.d.ts.map +1 -0
- package/dist/server/host/deviceResolver.js +555 -0
- package/dist/server/host/deviceResolver.js.map +1 -0
- package/dist/server/host/helpers.d.ts +33 -0
- package/dist/server/host/helpers.d.ts.map +1 -0
- package/dist/server/host/helpers.js +42 -0
- package/dist/server/host/helpers.js.map +1 -0
- package/dist/server/host/hostModule.d.ts +4 -0
- package/dist/server/host/hostModule.d.ts.map +1 -0
- package/dist/server/host/hostModule.js +26 -0
- package/dist/server/host/hostModule.js.map +1 -0
- package/dist/server/host/index.d.ts +4 -0
- package/dist/server/host/index.d.ts.map +1 -0
- package/dist/server/host/index.js +8 -0
- package/dist/server/host/index.js.map +1 -0
- package/dist/server/host/iosInput.d.ts +15 -0
- package/dist/server/host/iosInput.d.ts.map +1 -0
- package/dist/server/host/iosInput.js +93 -0
- package/dist/server/host/iosInput.js.map +1 -0
- package/dist/server/host/modules/screenshot.d.ts +4 -0
- package/dist/server/host/modules/screenshot.d.ts.map +1 -0
- package/dist/server/host/modules/screenshot.js +615 -0
- package/dist/server/host/modules/screenshot.js.map +1 -0
- package/dist/server/host/processRunner.d.ts +19 -0
- package/dist/server/host/processRunner.d.ts.map +1 -0
- package/dist/server/host/processRunner.js +58 -0
- package/dist/server/host/processRunner.js.map +1 -0
- package/dist/server/host/tools/capture.d.ts +6 -0
- package/dist/server/host/tools/capture.d.ts.map +1 -0
- package/dist/server/host/tools/capture.js +148 -0
- package/dist/server/host/tools/capture.js.map +1 -0
- package/dist/server/host/tools/devices.d.ts +4 -0
- package/dist/server/host/tools/devices.d.ts.map +1 -0
- package/dist/server/host/tools/devices.js +17 -0
- package/dist/server/host/tools/devices.js.map +1 -0
- package/dist/server/host/tools/input.d.ts +7 -0
- package/dist/server/host/tools/input.d.ts.map +1 -0
- package/dist/server/host/tools/input.js +286 -0
- package/dist/server/host/tools/input.js.map +1 -0
- package/dist/server/host/tools/lifecycle.d.ts +6 -0
- package/dist/server/host/tools/lifecycle.d.ts.map +1 -0
- package/dist/server/host/tools/lifecycle.js +271 -0
- package/dist/server/host/tools/lifecycle.js.map +1 -0
- package/dist/server/host/types.d.ts +17 -0
- package/dist/server/host/types.d.ts.map +1 -0
- package/dist/{modules/components → server/host}/types.js.map +1 -1
- package/dist/server/host/wda.d.ts +15 -0
- package/dist/server/host/wda.d.ts.map +1 -0
- package/dist/server/host/wda.js +100 -0
- package/dist/server/host/wda.js.map +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +10 -17
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcpServer.d.ts +5 -10
- package/dist/server/mcpServer.d.ts.map +1 -1
- package/dist/server/mcpServer.js +438 -153
- package/dist/server/mcpServer.js.map +1 -1
- package/dist/server/types.d.ts +2 -8
- package/dist/server/types.d.ts.map +1 -1
- package/dist/shared/protocol.d.ts +6 -0
- package/dist/shared/protocol.d.ts.map +1 -1
- package/package.json +6 -12
- package/dist/client/contexts/McpTreeContext/McpTracker.d.ts +0 -10
- package/dist/client/contexts/McpTreeContext/McpTracker.d.ts.map +0 -1
- package/dist/client/contexts/McpTreeContext/McpTracker.js +0 -101
- package/dist/client/contexts/McpTreeContext/McpTracker.js.map +0 -1
- package/dist/client/contexts/McpTreeContext/McpTreeContext.d.ts +0 -2
- package/dist/client/contexts/McpTreeContext/McpTreeContext.d.ts.map +0 -1
- package/dist/client/contexts/McpTreeContext/McpTreeContext.js +0 -6
- package/dist/client/contexts/McpTreeContext/McpTreeContext.js.map +0 -1
- package/dist/client/contexts/McpTreeContext/McpTreeRegistry.d.ts +0 -16
- package/dist/client/contexts/McpTreeContext/McpTreeRegistry.d.ts.map +0 -1
- package/dist/client/contexts/McpTreeContext/McpTreeRegistry.js +0 -96
- package/dist/client/contexts/McpTreeContext/McpTreeRegistry.js.map +0 -1
- package/dist/client/contexts/McpTreeContext/index.d.ts +0 -5
- package/dist/client/contexts/McpTreeContext/index.d.ts.map +0 -1
- package/dist/client/contexts/McpTreeContext/index.js +0 -10
- package/dist/client/contexts/McpTreeContext/index.js.map +0 -1
- package/dist/client/contexts/McpTreeContext/types.d.ts +0 -14
- package/dist/client/contexts/McpTreeContext/types.d.ts.map +0 -1
- package/dist/client/contexts/McpTreeContext/types.js.map +0 -1
- package/dist/modules/components/components.d.ts +0 -8
- package/dist/modules/components/components.d.ts.map +0 -1
- package/dist/modules/components/components.js +0 -315
- package/dist/modules/components/components.js.map +0 -1
- package/dist/modules/components/index.d.ts +0 -3
- package/dist/modules/components/index.d.ts.map +0 -1
- package/dist/modules/components/index.js +0 -6
- package/dist/modules/components/index.js.map +0 -1
- package/dist/modules/components/types.d.ts +0 -18
- package/dist/modules/components/types.d.ts.map +0 -1
- package/dist/modules/components/types.js +0 -3
- package/dist/modules/components/utils.d.ts +0 -18
- package/dist/modules/components/utils.d.ts.map +0 -1
- package/dist/modules/components/utils.js +0 -386
- package/dist/modules/components/utils.js.map +0 -1
- package/dist/modules/screenshot/index.d.ts +0 -3
- package/dist/modules/screenshot/index.d.ts.map +0 -1
- package/dist/modules/screenshot/index.js +0 -6
- package/dist/modules/screenshot/index.js.map +0 -1
- package/dist/modules/screenshot/screenshot.d.ts +0 -4
- package/dist/modules/screenshot/screenshot.d.ts.map +0 -1
- package/dist/modules/screenshot/screenshot.js +0 -89
- package/dist/modules/screenshot/screenshot.js.map +0 -1
- package/dist/modules/screenshot/types.d.ts +0 -5
- package/dist/modules/screenshot/types.d.ts.map +0 -1
- package/dist/modules/screenshot/types.js +0 -3
- package/dist/modules/screenshot/types.js.map +0 -1
- package/dist/modules/tree/index.d.ts +0 -2
- package/dist/modules/tree/index.d.ts.map +0 -1
- package/dist/modules/tree/index.js +0 -6
- package/dist/modules/tree/index.js.map +0 -1
- package/dist/modules/tree/tree.d.ts +0 -3
- package/dist/modules/tree/tree.d.ts.map +0 -1
- package/dist/modules/tree/tree.js +0 -274
- package/dist/modules/tree/tree.js.map +0 -1
- /package/dist/{client/contexts/McpTreeContext → server/host}/types.js +0 -0
package/dist/server/mcpServer.js
CHANGED
|
@@ -7,39 +7,117 @@ const zod_1 = require("zod");
|
|
|
7
7
|
const protocol_1 = require("../shared/protocol");
|
|
8
8
|
const BASE_INSTRUCTIONS = `You are connected to a running React Native app via the react-native-mcp-kit bridge.
|
|
9
9
|
|
|
10
|
+
Multiple React Native apps can connect simultaneously — each is identified by a short ID like "ios-1", "android-1", or "client-1". Use \`connection_status\` or \`list_tools\` to see which clients are connected and their IDs, platforms, and labels.
|
|
11
|
+
|
|
10
12
|
## How to interact
|
|
11
13
|
|
|
12
|
-
1. Use \`connection_status\` to check
|
|
13
|
-
2. Use \`list_tools\` to
|
|
14
|
-
3. Use \`
|
|
15
|
-
4. Use \`
|
|
14
|
+
1. Use \`connection_status\` to check which clients are connected.
|
|
15
|
+
2. Use \`list_tools\` to browse all available tool names and short descriptions. The response is compact — modules that are structurally identical across multiple clients are deduplicated into a single entry with a \`clientIds\` array, and input schemas are omitted.
|
|
16
|
+
3. Use \`describe_tool\` with \`{ tool, clientId? }\` to fetch the full input schema of a specific tool before calling it. Required when you need to know the argument shape. Host tools are resolved directly (no clientId needed). For in-app tools, omit \`clientId\` to auto-pick; specify it only when multiple clients have the same tool with different schemas.
|
|
17
|
+
4. Use \`call\` to invoke any tool with format: module${protocol_1.MODULE_SEPARATOR}method (e.g. navigation${protocol_1.MODULE_SEPARATOR}navigate). When more than one client is connected, specify \`clientId\`. When exactly one client is connected, \`clientId\` is optional — it's auto-picked.
|
|
18
|
+
5. Use \`state_list\` / \`state_get\` to read app state exposed via useMcpState. State is scoped per client; specify \`clientId\` when multiple clients are connected.
|
|
19
|
+
|
|
20
|
+
Some tools run inline on the MCP server host (e.g. \`host${protocol_1.MODULE_SEPARATOR}screenshot\`, \`host${protocol_1.MODULE_SEPARATOR}list_devices\`, \`host${protocol_1.MODULE_SEPARATOR}launch_app\`, \`host${protocol_1.MODULE_SEPARATOR}terminate_app\`, \`host${protocol_1.MODULE_SEPARATOR}restart_app\`) and work even when no React Native client is connected. They use xcrun simctl / adb on the dev machine. When \`clientId\` is provided, host tools use that client's platform/label/deviceId as hints to resolve the target device; otherwise they prefer the device of the single connected client, falling back to the single booted sim / online device. \`launch_app\`, \`terminate_app\`, and \`restart_app\` accept an \`appId\` arg (iOS bundle ID / Android package name); omit it to reuse the target client's registered \`bundleId\` from its connection metadata.
|
|
21
|
+
|
|
22
|
+
## Driving the UI — pick the right tool
|
|
23
|
+
1. **\`fiber_tree${protocol_1.MODULE_SEPARATOR}find_all\` with \`select: ["mcpId", "name", "bounds"]\` + \`host${protocol_1.MODULE_SEPARATOR}tap\` with \`bounds.centerX\`/\`bounds.centerY\`** — the default for touch interactions. Exercises the real OS gesture pipeline, so Pressable/TouchableOpacity feedback, gesture responders, and hit-test logic all run. The bounds come back in physical pixels — pass them straight to \`host${protocol_1.MODULE_SEPARATOR}tap\`, no scaling. The \`select\` parameter also saves tokens by omitting heavy \`props\` from the response.
|
|
24
|
+
2. **\`fiber_tree${protocol_1.MODULE_SEPARATOR}invoke\`** when you need to bypass the gesture pipeline — e.g. for non-tap callbacks like \`onChangeText\` / \`onValueChange\` / \`onRefresh\`, or when a component is inside a scroll/gesture-handler parent that swallows taps. Calls the prop directly — faster and immune to overlay/occlusion, but does not exercise touch handlers.
|
|
25
|
+
3. **\`host${protocol_1.MODULE_SEPARATOR}screenshot\` + manual coordinate estimation + \`host${protocol_1.MODULE_SEPARATOR}tap\`** ONLY for non-React surfaces: system permission dialogs, native alerts, the on-screen keyboard, WebView content, native splash. These have no fiber and no bounds.
|
|
26
|
+
|
|
27
|
+
\`host${protocol_1.MODULE_SEPARATOR}tap\` / \`host${protocol_1.MODULE_SEPARATOR}swipe\` / \`host${protocol_1.MODULE_SEPARATOR}type_text\` / \`host${protocol_1.MODULE_SEPARATOR}press_key\` work on both platforms with no external daemons: Android via \`adb shell input\`, iOS via a bundled \`ios-hid\` binary that injects HID events directly into iOS Simulator through SimulatorKit.
|
|
16
28
|
`;
|
|
29
|
+
const jsonError = (msg) => {
|
|
30
|
+
return {
|
|
31
|
+
content: [{ text: JSON.stringify({ error: msg }), type: 'text' }],
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Recursively serializes a value to JSON with sorted object keys, producing a
|
|
36
|
+
* stable canonical form that's safe to use as a dedup Map key. Arrays keep
|
|
37
|
+
* their original order — caller is responsible for normalizing them when
|
|
38
|
+
* order-independence is desired.
|
|
39
|
+
*/
|
|
40
|
+
const canonicalize = (value) => {
|
|
41
|
+
return JSON.stringify(value, (_key, v) => {
|
|
42
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
43
|
+
const sorted = {};
|
|
44
|
+
for (const k of Object.keys(v).sort()) {
|
|
45
|
+
sorted[k] = v[k];
|
|
46
|
+
}
|
|
47
|
+
return sorted;
|
|
48
|
+
}
|
|
49
|
+
return v;
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Produces a canonical key for a ToolGroup that's independent of tool
|
|
54
|
+
* registration order. Two modules with the same name + tools (regardless of
|
|
55
|
+
* order) + descriptions + schemas produce the same key.
|
|
56
|
+
*/
|
|
57
|
+
const canonicalizeGroup = (group) => {
|
|
58
|
+
const normalized = {
|
|
59
|
+
description: group.description,
|
|
60
|
+
module: group.module,
|
|
61
|
+
tools: [...group.tools].sort((a, b) => {
|
|
62
|
+
return a.name.localeCompare(b.name);
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
return canonicalize(normalized);
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Looks up a full tool descriptor on a client by its full name
|
|
69
|
+
* (`module__method`). Checks both static modules and dynamic tools registered
|
|
70
|
+
* via useMcpTool. Returns null if the tool is not on this client.
|
|
71
|
+
*/
|
|
72
|
+
const findToolInClient = (client, toolFullName) => {
|
|
73
|
+
for (const mod of client.modules) {
|
|
74
|
+
const prefix = `${mod.name}${protocol_1.MODULE_SEPARATOR}`;
|
|
75
|
+
if (toolFullName.startsWith(prefix)) {
|
|
76
|
+
const methodName = toolFullName.slice(prefix.length);
|
|
77
|
+
const toolDef = mod.tools.find((t) => {
|
|
78
|
+
return t.name === methodName;
|
|
79
|
+
});
|
|
80
|
+
if (toolDef) {
|
|
81
|
+
return {
|
|
82
|
+
description: toolDef.description,
|
|
83
|
+
inputSchema: toolDef.inputSchema,
|
|
84
|
+
name: toolFullName,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const dynamicEntry = client.dynamicTools.get(toolFullName);
|
|
90
|
+
if (dynamicEntry) {
|
|
91
|
+
return {
|
|
92
|
+
description: dynamicEntry.description,
|
|
93
|
+
inputSchema: dynamicEntry.inputSchema,
|
|
94
|
+
name: toolFullName,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
};
|
|
17
99
|
class McpServerWrapper {
|
|
18
100
|
bridge;
|
|
19
|
-
|
|
101
|
+
hostModules;
|
|
102
|
+
hostToolMap = new Map();
|
|
20
103
|
mcp;
|
|
21
|
-
|
|
22
|
-
stateStore = new Map();
|
|
23
|
-
constructor(bridge) {
|
|
104
|
+
constructor(bridge, hostModules = []) {
|
|
24
105
|
this.bridge = bridge;
|
|
106
|
+
this.hostModules = hostModules;
|
|
107
|
+
for (const mod of hostModules) {
|
|
108
|
+
for (const [toolName, tool] of Object.entries(mod.tools)) {
|
|
109
|
+
const fullName = `${mod.name}${protocol_1.MODULE_SEPARATOR}${toolName}`;
|
|
110
|
+
this.hostToolMap.set(fullName, {
|
|
111
|
+
handler: tool.handler,
|
|
112
|
+
moduleName: mod.name,
|
|
113
|
+
timeout: tool.timeout,
|
|
114
|
+
toolName,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
25
118
|
this.mcp = new mcp_js_1.McpServer({ name: 'react-native-mcp-kit', version: '1.0.0' }, { instructions: BASE_INSTRUCTIONS });
|
|
26
119
|
this.registerTools();
|
|
27
120
|
}
|
|
28
|
-
addDynamicTool(module, name, description) {
|
|
29
|
-
this.dynamicTools.set(`${module}${protocol_1.MODULE_SEPARATOR}${name}`, { description, module });
|
|
30
|
-
}
|
|
31
|
-
removeDynamicTool(module, name) {
|
|
32
|
-
this.dynamicTools.delete(`${module}${protocol_1.MODULE_SEPARATOR}${name}`);
|
|
33
|
-
}
|
|
34
|
-
setModules(modules) {
|
|
35
|
-
this.modules = modules;
|
|
36
|
-
}
|
|
37
|
-
setState(key, value) {
|
|
38
|
-
this.stateStore.set(key, value);
|
|
39
|
-
}
|
|
40
|
-
removeState(key) {
|
|
41
|
-
this.stateStore.delete(key);
|
|
42
|
-
}
|
|
43
121
|
async start() {
|
|
44
122
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
45
123
|
await this.mcp.connect(transport);
|
|
@@ -50,32 +128,54 @@ class McpServerWrapper {
|
|
|
50
128
|
openWorldHint: true,
|
|
51
129
|
title: 'Call Tool',
|
|
52
130
|
},
|
|
53
|
-
description: 'Call a tool registered by
|
|
131
|
+
description: 'Call a tool registered by a React Native app client. Use list_tools first to see available tools. When multiple clients are connected, specify clientId; otherwise it is auto-picked.',
|
|
54
132
|
inputSchema: {
|
|
55
133
|
args: zod_1.z
|
|
56
134
|
.string()
|
|
57
135
|
.optional()
|
|
58
136
|
.describe('Arguments as JSON string (e.g. {"screen": "AUTH_LOGIN_SCREEN"})'),
|
|
137
|
+
clientId: zod_1.z
|
|
138
|
+
.string()
|
|
139
|
+
.optional()
|
|
140
|
+
.describe('Target client ID (e.g. "ios-1", "android-1"). Optional when exactly one client is connected.'),
|
|
59
141
|
tool: zod_1.z
|
|
60
142
|
.string()
|
|
61
143
|
.describe(`Tool name in format "module${protocol_1.MODULE_SEPARATOR}method" (e.g. "navigation${protocol_1.MODULE_SEPARATOR}navigate")`),
|
|
62
144
|
},
|
|
63
|
-
}, async ({ args, tool }) => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
};
|
|
145
|
+
}, async ({ args, clientId, tool }) => {
|
|
146
|
+
let parsedArgs = {};
|
|
147
|
+
if (args) {
|
|
148
|
+
try {
|
|
149
|
+
parsedArgs = JSON.parse(args);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return jsonError('Invalid JSON in args');
|
|
153
|
+
}
|
|
73
154
|
}
|
|
74
|
-
//
|
|
155
|
+
// Host dispatch — runs inline on the Node server, may work without any connected client
|
|
156
|
+
const hostEntry = this.hostToolMap.get(tool);
|
|
157
|
+
if (hostEntry) {
|
|
158
|
+
try {
|
|
159
|
+
const result = await hostEntry.handler(parsedArgs, {
|
|
160
|
+
bridge: this.bridge,
|
|
161
|
+
requestedClientId: clientId,
|
|
162
|
+
});
|
|
163
|
+
return { content: this.formatResult(result) };
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
return jsonError(`Host tool "${tool}" threw: ${err.message}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const resolution = this.bridge.resolveClient(clientId);
|
|
170
|
+
if (!resolution.ok) {
|
|
171
|
+
return jsonError(resolution.error);
|
|
172
|
+
}
|
|
173
|
+
const client = resolution.client;
|
|
174
|
+
// Find the module by matching prefix in this client's modules
|
|
75
175
|
let mod;
|
|
76
176
|
let moduleName = '';
|
|
77
177
|
let methodName = '';
|
|
78
|
-
for (const m of
|
|
178
|
+
for (const m of client.modules) {
|
|
79
179
|
const prefix = `${m.name}${protocol_1.MODULE_SEPARATOR}`;
|
|
80
180
|
if (tool.startsWith(prefix)) {
|
|
81
181
|
mod = m;
|
|
@@ -84,7 +184,7 @@ class McpServerWrapper {
|
|
|
84
184
|
break;
|
|
85
185
|
}
|
|
86
186
|
}
|
|
87
|
-
// If no module matched, check for dynamic tool prefix
|
|
187
|
+
// If no module matched, check for dynamic tool prefix or generic module__method
|
|
88
188
|
if (!mod) {
|
|
89
189
|
if (tool.startsWith(protocol_1.DYNAMIC_PREFIX)) {
|
|
90
190
|
moduleName = `${protocol_1.MODULE_SEPARATOR}dynamic`;
|
|
@@ -93,79 +193,37 @@ class McpServerWrapper {
|
|
|
93
193
|
else {
|
|
94
194
|
const idx = tool.indexOf(protocol_1.MODULE_SEPARATOR);
|
|
95
195
|
if (idx <= 0) {
|
|
96
|
-
return {
|
|
97
|
-
content: [
|
|
98
|
-
{
|
|
99
|
-
text: JSON.stringify({
|
|
100
|
-
error: `Invalid tool name "${tool}". Use "module${protocol_1.MODULE_SEPARATOR}method" format.`,
|
|
101
|
-
}),
|
|
102
|
-
type: 'text',
|
|
103
|
-
},
|
|
104
|
-
],
|
|
105
|
-
};
|
|
196
|
+
return jsonError(`Invalid tool name "${tool}". Use "module${protocol_1.MODULE_SEPARATOR}method" format.`);
|
|
106
197
|
}
|
|
107
198
|
moduleName = tool.slice(0, idx);
|
|
108
199
|
methodName = tool.slice(idx + protocol_1.MODULE_SEPARATOR.length);
|
|
109
200
|
}
|
|
110
|
-
|
|
111
|
-
let parsedArgs = {};
|
|
112
|
-
if (args) {
|
|
201
|
+
// Try dispatching via bridge — might be a dynamic tool registered on this client
|
|
113
202
|
try {
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
return {
|
|
118
|
-
content: [
|
|
119
|
-
{ text: JSON.stringify({ error: 'Invalid JSON in args' }), type: 'text' },
|
|
120
|
-
],
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
if (!mod) {
|
|
125
|
-
// No module matched — try as dynamic tool via bridge
|
|
126
|
-
try {
|
|
127
|
-
const result = await this.bridge.call(moduleName, methodName, parsedArgs);
|
|
203
|
+
const result = await this.bridge.call(client.id, moduleName, methodName, parsedArgs);
|
|
128
204
|
return { content: this.formatResult(result) };
|
|
129
205
|
}
|
|
130
206
|
catch {
|
|
131
|
-
const allModules =
|
|
207
|
+
const allModules = client.modules
|
|
132
208
|
.map((m) => {
|
|
133
209
|
return m.name;
|
|
134
210
|
})
|
|
135
211
|
.join(', ');
|
|
136
|
-
const dynNames = [...
|
|
137
|
-
return {
|
|
138
|
-
content: [
|
|
139
|
-
{
|
|
140
|
-
text: JSON.stringify({
|
|
141
|
-
error: `Tool "${tool}" not found. Modules: ${allModules}. Dynamic: ${dynNames || 'none'}`,
|
|
142
|
-
}),
|
|
143
|
-
type: 'text',
|
|
144
|
-
},
|
|
145
|
-
],
|
|
146
|
-
};
|
|
212
|
+
const dynNames = [...client.dynamicTools.keys()].join(', ');
|
|
213
|
+
return jsonError(`Tool "${tool}" not found on client '${client.id}'. Modules: ${allModules || '(none)'}. Dynamic: ${dynNames || '(none)'}`);
|
|
147
214
|
}
|
|
148
215
|
}
|
|
149
216
|
const toolDef = mod.tools.find((t) => {
|
|
150
217
|
return t.name === methodName;
|
|
151
218
|
});
|
|
152
219
|
if (!toolDef) {
|
|
153
|
-
return {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
.map((t) => {
|
|
159
|
-
return t.name;
|
|
160
|
-
})
|
|
161
|
-
.join(', ')}`,
|
|
162
|
-
}),
|
|
163
|
-
type: 'text',
|
|
164
|
-
},
|
|
165
|
-
],
|
|
166
|
-
};
|
|
220
|
+
return jsonError(`Tool "${methodName}" not found in module "${moduleName}" on client '${client.id}'. Available: ${mod.tools
|
|
221
|
+
.map((t) => {
|
|
222
|
+
return t.name;
|
|
223
|
+
})
|
|
224
|
+
.join(', ')}`);
|
|
167
225
|
}
|
|
168
|
-
const result = await this.bridge.call(moduleName, methodName, parsedArgs, toolDef.timeout);
|
|
226
|
+
const result = await this.bridge.call(client.id, moduleName, methodName, parsedArgs, toolDef.timeout);
|
|
169
227
|
return { content: this.formatResult(result) };
|
|
170
228
|
});
|
|
171
229
|
this.mcp.registerTool('list_tools', {
|
|
@@ -173,56 +231,71 @@ class McpServerWrapper {
|
|
|
173
231
|
readOnlyHint: true,
|
|
174
232
|
title: 'List Tools',
|
|
175
233
|
},
|
|
176
|
-
description: '
|
|
234
|
+
description: 'Browse available tools with compact (schema-free) descriptions. Modules with identical shape across multiple clients are deduplicated into a single entry with a clientIds array. Use describe_tool to fetch the full input schema for a specific tool before calling it.',
|
|
177
235
|
}, async () => {
|
|
178
|
-
|
|
236
|
+
const clients = this.bridge.listClients();
|
|
237
|
+
// Dedup tool groups across clients by canonical shape
|
|
238
|
+
const dedupMap = new Map();
|
|
239
|
+
for (const client of clients) {
|
|
240
|
+
const groups = this.buildToolGroups(client);
|
|
241
|
+
for (const group of groups) {
|
|
242
|
+
const key = canonicalizeGroup(group);
|
|
243
|
+
const existing = dedupMap.get(key);
|
|
244
|
+
if (existing) {
|
|
245
|
+
existing.clientIds.push(client.id);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
dedupMap.set(key, { clientIds: [client.id], group });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const modulesPayload = [...dedupMap.values()].map(({ clientIds, group }) => {
|
|
179
253
|
return {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
254
|
+
clientIds,
|
|
255
|
+
description: group.description,
|
|
256
|
+
name: group.module,
|
|
257
|
+
tools: group.tools.map((t) => {
|
|
258
|
+
return {
|
|
259
|
+
description: t.description,
|
|
260
|
+
name: t.name,
|
|
261
|
+
};
|
|
262
|
+
}),
|
|
189
263
|
};
|
|
190
|
-
}
|
|
191
|
-
const
|
|
264
|
+
});
|
|
265
|
+
const hostToolsPayload = this.hostModules.map((mod) => {
|
|
192
266
|
return {
|
|
193
267
|
description: mod.description,
|
|
194
|
-
|
|
195
|
-
tools: mod.tools.map((
|
|
268
|
+
name: mod.name,
|
|
269
|
+
tools: Object.entries(mod.tools).map(([toolName, tool]) => {
|
|
196
270
|
return {
|
|
197
|
-
description:
|
|
198
|
-
|
|
199
|
-
name: `${mod.name}${protocol_1.MODULE_SEPARATOR}${t.name}`,
|
|
271
|
+
description: tool.description,
|
|
272
|
+
name: `${mod.name}${protocol_1.MODULE_SEPARATOR}${toolName}`,
|
|
200
273
|
};
|
|
201
274
|
}),
|
|
202
275
|
};
|
|
203
276
|
});
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
277
|
+
const clientsPayload = clients.map((client) => {
|
|
278
|
+
return {
|
|
279
|
+
appName: client.appName,
|
|
280
|
+
appVersion: client.appVersion,
|
|
281
|
+
bundleId: client.bundleId,
|
|
282
|
+
deviceId: client.deviceId,
|
|
283
|
+
id: client.id,
|
|
284
|
+
label: client.label,
|
|
285
|
+
platform: client.platform,
|
|
286
|
+
};
|
|
287
|
+
});
|
|
288
|
+
const payload = {
|
|
289
|
+
clientCount: clients.length,
|
|
290
|
+
clients: clientsPayload,
|
|
291
|
+
hostTools: hostToolsPayload,
|
|
292
|
+
modules: modulesPayload,
|
|
293
|
+
};
|
|
294
|
+
if (clients.length === 0) {
|
|
295
|
+
payload.clientError = 'No React Native clients connected';
|
|
223
296
|
}
|
|
224
297
|
return {
|
|
225
|
-
content: [{ text: JSON.stringify(
|
|
298
|
+
content: [{ text: JSON.stringify(payload, null, 2), type: 'text' }],
|
|
226
299
|
};
|
|
227
300
|
});
|
|
228
301
|
this.mcp.registerTool('connection_status', {
|
|
@@ -230,20 +303,32 @@ class McpServerWrapper {
|
|
|
230
303
|
readOnlyHint: true,
|
|
231
304
|
title: 'Connection Status',
|
|
232
305
|
},
|
|
233
|
-
description: '
|
|
306
|
+
description: 'List connected React Native clients with their IDs, platforms, labels, and registered module names.',
|
|
234
307
|
}, async () => {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
308
|
+
const clients = this.bridge.listClients();
|
|
309
|
+
const payload = {
|
|
310
|
+
clientCount: clients.length,
|
|
311
|
+
clients: clients.map((c) => {
|
|
312
|
+
return {
|
|
313
|
+
appName: c.appName,
|
|
314
|
+
appVersion: c.appVersion,
|
|
315
|
+
bundleId: c.bundleId,
|
|
316
|
+
connectedAt: new Date(c.connectedAt).toISOString(),
|
|
317
|
+
deviceId: c.deviceId,
|
|
318
|
+
id: c.id,
|
|
319
|
+
label: c.label,
|
|
320
|
+
modules: c.modules.map((m) => {
|
|
321
|
+
return m.name;
|
|
243
322
|
}),
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
323
|
+
platform: c.platform,
|
|
324
|
+
};
|
|
325
|
+
}),
|
|
326
|
+
hostModules: this.hostModules.map((m) => {
|
|
327
|
+
return m.name;
|
|
328
|
+
}),
|
|
329
|
+
};
|
|
330
|
+
return {
|
|
331
|
+
content: [{ text: JSON.stringify(payload, null, 2), type: 'text' }],
|
|
247
332
|
};
|
|
248
333
|
});
|
|
249
334
|
this.mcp.registerTool('state_get', {
|
|
@@ -251,40 +336,240 @@ class McpServerWrapper {
|
|
|
251
336
|
readOnlyHint: true,
|
|
252
337
|
title: 'Get State',
|
|
253
338
|
},
|
|
254
|
-
description: 'Read a state value exposed by
|
|
339
|
+
description: 'Read a state value exposed by a React Native client via useMcpState. State is scoped per client; specify clientId when multiple clients are connected.',
|
|
255
340
|
inputSchema: {
|
|
341
|
+
clientId: zod_1.z
|
|
342
|
+
.string()
|
|
343
|
+
.optional()
|
|
344
|
+
.describe('Target client ID. Optional when exactly one client is connected.'),
|
|
256
345
|
key: zod_1.z.string().describe('State key to read (e.g. "cart", "auth")'),
|
|
257
346
|
},
|
|
258
|
-
}, async ({ key }) => {
|
|
259
|
-
const
|
|
347
|
+
}, async ({ clientId, key }) => {
|
|
348
|
+
const resolution = this.bridge.resolveClient(clientId);
|
|
349
|
+
if (!resolution.ok) {
|
|
350
|
+
return jsonError(resolution.error);
|
|
351
|
+
}
|
|
352
|
+
const value = resolution.client.stateStore.get(key);
|
|
260
353
|
if (value === undefined) {
|
|
354
|
+
return jsonError(`State "${key}" not found on client '${resolution.client.id}'. Use state_list to see available keys.`);
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
content: [{ text: JSON.stringify(value, null, 2), type: 'text' }],
|
|
358
|
+
};
|
|
359
|
+
});
|
|
360
|
+
this.mcp.registerTool('state_list', {
|
|
361
|
+
annotations: {
|
|
362
|
+
readOnlyHint: true,
|
|
363
|
+
title: 'List State',
|
|
364
|
+
},
|
|
365
|
+
description: "List all available state keys. When a specific clientId is given, returns that client's keys; otherwise auto-picks the sole connected client or groups by client when multiple are connected.",
|
|
366
|
+
inputSchema: {
|
|
367
|
+
clientId: zod_1.z
|
|
368
|
+
.string()
|
|
369
|
+
.optional()
|
|
370
|
+
.describe('Target client ID. Optional when exactly one client is connected.'),
|
|
371
|
+
},
|
|
372
|
+
}, async ({ clientId }) => {
|
|
373
|
+
if (clientId) {
|
|
374
|
+
const resolution = this.bridge.resolveClient(clientId);
|
|
375
|
+
if (!resolution.ok) {
|
|
376
|
+
return jsonError(resolution.error);
|
|
377
|
+
}
|
|
261
378
|
return {
|
|
262
379
|
content: [
|
|
263
380
|
{
|
|
264
381
|
text: JSON.stringify({
|
|
265
|
-
|
|
266
|
-
|
|
382
|
+
clientId: resolution.client.id,
|
|
383
|
+
keys: [...resolution.client.stateStore.keys()],
|
|
384
|
+
}, null, 2),
|
|
385
|
+
type: 'text',
|
|
386
|
+
},
|
|
387
|
+
],
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
const clients = this.bridge.listClients();
|
|
391
|
+
if (clients.length === 0) {
|
|
392
|
+
return jsonError('No React Native clients connected');
|
|
393
|
+
}
|
|
394
|
+
if (clients.length === 1) {
|
|
395
|
+
const client = clients[0];
|
|
396
|
+
return {
|
|
397
|
+
content: [
|
|
398
|
+
{
|
|
399
|
+
text: JSON.stringify({ clientId: client.id, keys: [...client.stateStore.keys()] }, null, 2),
|
|
267
400
|
type: 'text',
|
|
268
401
|
},
|
|
269
402
|
],
|
|
270
403
|
};
|
|
271
404
|
}
|
|
272
405
|
return {
|
|
273
|
-
content: [
|
|
406
|
+
content: [
|
|
407
|
+
{
|
|
408
|
+
text: JSON.stringify({
|
|
409
|
+
clients: clients.map((c) => {
|
|
410
|
+
return { id: c.id, keys: [...c.stateStore.keys()] };
|
|
411
|
+
}),
|
|
412
|
+
}, null, 2),
|
|
413
|
+
type: 'text',
|
|
414
|
+
},
|
|
415
|
+
],
|
|
274
416
|
};
|
|
275
417
|
});
|
|
276
|
-
this.mcp.registerTool('
|
|
418
|
+
this.mcp.registerTool('describe_tool', {
|
|
277
419
|
annotations: {
|
|
278
420
|
readOnlyHint: true,
|
|
279
|
-
title: '
|
|
421
|
+
title: 'Describe Tool',
|
|
280
422
|
},
|
|
281
|
-
description: '
|
|
282
|
-
|
|
283
|
-
|
|
423
|
+
description: 'Fetch the full description and input schema for a single tool. Use this after list_tools to learn how to construct arguments for a tool before calling it. For host tools, clientId is ignored. For in-app tools, omit clientId to auto-pick the shared descriptor; specify it only when multiple clients have the same tool with different schemas.',
|
|
424
|
+
inputSchema: {
|
|
425
|
+
clientId: zod_1.z
|
|
426
|
+
.string()
|
|
427
|
+
.optional()
|
|
428
|
+
.describe('Target client ID for in-app tools. Required only when multiple clients have the same tool with different schemas. Ignored for host tools.'),
|
|
429
|
+
tool: zod_1.z
|
|
430
|
+
.string()
|
|
431
|
+
.describe(`Full tool name in the format "module${protocol_1.MODULE_SEPARATOR}method" (e.g. "navigation${protocol_1.MODULE_SEPARATOR}navigate", "host${protocol_1.MODULE_SEPARATOR}screenshot").`),
|
|
432
|
+
},
|
|
433
|
+
}, async ({ clientId, tool }) => {
|
|
434
|
+
// 1. Host tool path — resolved via hostToolMap, clientId is ignored
|
|
435
|
+
const hostEntry = this.hostToolMap.get(tool);
|
|
436
|
+
if (hostEntry) {
|
|
437
|
+
const mod = this.hostModules.find((m) => {
|
|
438
|
+
return m.name === hostEntry.moduleName;
|
|
439
|
+
});
|
|
440
|
+
const hostTool = mod?.tools[hostEntry.toolName];
|
|
441
|
+
if (!hostTool) {
|
|
442
|
+
return jsonError(`Host tool '${tool}' metadata inconsistent — entry in hostToolMap but missing from hostModules.`);
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
content: [
|
|
446
|
+
{
|
|
447
|
+
text: JSON.stringify({
|
|
448
|
+
description: hostTool.description,
|
|
449
|
+
inputSchema: hostTool.inputSchema,
|
|
450
|
+
name: tool,
|
|
451
|
+
scope: 'host',
|
|
452
|
+
}, null, 2),
|
|
453
|
+
type: 'text',
|
|
454
|
+
},
|
|
455
|
+
],
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
// 2. Explicit clientId — look up the specific client
|
|
459
|
+
if (clientId) {
|
|
460
|
+
const client = this.bridge.getClient(clientId);
|
|
461
|
+
if (!client) {
|
|
462
|
+
const available = this.bridge
|
|
463
|
+
.listClients()
|
|
464
|
+
.map((c) => {
|
|
465
|
+
return c.id;
|
|
466
|
+
})
|
|
467
|
+
.join(', ') || '(none)';
|
|
468
|
+
return jsonError(`Client '${clientId}' not connected. Available: ${available}`);
|
|
469
|
+
}
|
|
470
|
+
const found = findToolInClient(client, tool);
|
|
471
|
+
if (!found) {
|
|
472
|
+
return jsonError(`Tool '${tool}' not found on client '${clientId}'.`);
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
content: [
|
|
476
|
+
{
|
|
477
|
+
text: JSON.stringify({
|
|
478
|
+
clientIds: [clientId],
|
|
479
|
+
description: found.description,
|
|
480
|
+
inputSchema: found.inputSchema,
|
|
481
|
+
name: tool,
|
|
482
|
+
scope: 'client',
|
|
483
|
+
}, null, 2),
|
|
484
|
+
type: 'text',
|
|
485
|
+
},
|
|
486
|
+
],
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
// 3. Auto-pick across all connected clients
|
|
490
|
+
const clients = this.bridge.listClients();
|
|
491
|
+
const matches = [];
|
|
492
|
+
for (const c of clients) {
|
|
493
|
+
const found = findToolInClient(c, tool);
|
|
494
|
+
if (found) {
|
|
495
|
+
matches.push({ clientId: c.id, descriptor: found });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (matches.length === 0) {
|
|
499
|
+
return jsonError(`Tool '${tool}' not found on any client. Use list_tools to see available tools.`);
|
|
500
|
+
}
|
|
501
|
+
// Group by canonical descriptor shape — same shape across clients is not ambiguous
|
|
502
|
+
const byShape = new Map();
|
|
503
|
+
for (const match of matches) {
|
|
504
|
+
const key = canonicalize(match.descriptor);
|
|
505
|
+
const existing = byShape.get(key);
|
|
506
|
+
if (existing) {
|
|
507
|
+
existing.clientIds.push(match.clientId);
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
byShape.set(key, { clientIds: [match.clientId], descriptor: match.descriptor });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (byShape.size === 1) {
|
|
514
|
+
const [first] = byShape.values();
|
|
515
|
+
const { clientIds, descriptor } = first;
|
|
516
|
+
return {
|
|
517
|
+
content: [
|
|
518
|
+
{
|
|
519
|
+
text: JSON.stringify({
|
|
520
|
+
clientIds,
|
|
521
|
+
description: descriptor.description,
|
|
522
|
+
inputSchema: descriptor.inputSchema,
|
|
523
|
+
name: tool,
|
|
524
|
+
scope: 'client',
|
|
525
|
+
}, null, 2),
|
|
526
|
+
type: 'text',
|
|
527
|
+
},
|
|
528
|
+
],
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
const candidates = [...byShape.values()]
|
|
532
|
+
.map(({ clientIds }) => {
|
|
533
|
+
return clientIds.join('+');
|
|
534
|
+
})
|
|
535
|
+
.join('; ');
|
|
536
|
+
return jsonError(`Tool '${tool}' exists on multiple clients with different schemas: ${candidates}. Specify clientId.`);
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
buildToolGroups(client) {
|
|
540
|
+
const groups = client.modules.map((mod) => {
|
|
284
541
|
return {
|
|
285
|
-
|
|
542
|
+
description: mod.description,
|
|
543
|
+
module: mod.name,
|
|
544
|
+
tools: mod.tools.map((t) => {
|
|
545
|
+
return {
|
|
546
|
+
description: t.description,
|
|
547
|
+
inputSchema: t.inputSchema,
|
|
548
|
+
name: `${mod.name}${protocol_1.MODULE_SEPARATOR}${t.name}`,
|
|
549
|
+
};
|
|
550
|
+
}),
|
|
286
551
|
};
|
|
287
552
|
});
|
|
553
|
+
if (client.dynamicTools.size > 0) {
|
|
554
|
+
const dynamicByModule = new Map();
|
|
555
|
+
for (const [fullName, info] of client.dynamicTools) {
|
|
556
|
+
const existing = dynamicByModule.get(info.module) ?? [];
|
|
557
|
+
existing.push({
|
|
558
|
+
description: info.description,
|
|
559
|
+
inputSchema: info.inputSchema,
|
|
560
|
+
name: fullName,
|
|
561
|
+
});
|
|
562
|
+
dynamicByModule.set(info.module, existing);
|
|
563
|
+
}
|
|
564
|
+
for (const [module, dynTools] of dynamicByModule) {
|
|
565
|
+
groups.push({
|
|
566
|
+
description: 'Dynamically registered tools from useMcpTool hooks',
|
|
567
|
+
module: `${module} (dynamic)`,
|
|
568
|
+
tools: dynTools,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return groups;
|
|
288
573
|
}
|
|
289
574
|
formatResult(result) {
|
|
290
575
|
if (Array.isArray(result) && result.length > 0) {
|