autojs6-mcp-bridge 0.1.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/CHANGELOG.md +8 -0
- package/LICENSE +373 -0
- package/README-zh-Hans.md +147 -0
- package/README.md +163 -0
- package/dist/bridge.d.ts +65 -0
- package/dist/bridge.js +186 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +80 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +62 -0
- package/dist/forward.d.ts +27 -0
- package/dist/forward.js +82 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/options.d.ts +39 -0
- package/dist/options.js +118 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# autojs6-mcp-bridge
|
|
2
|
+
|
|
3
|
+
Language: English | [简体中文](README-zh-Hans.md)
|
|
4
|
+
|
|
5
|
+
A small stdio to Streamable HTTP bridge for the
|
|
6
|
+
[AutoJs6 MCP Server plugin](https://github.com/SuperMonster003/AutoJs6-Plugin-MCP-Server).
|
|
7
|
+
MCP clients that can only launch stdio servers (Claude Desktop, older Cursor and
|
|
8
|
+
Cline builds, anything without an HTTP transport) start this program; it forwards
|
|
9
|
+
every JSON-RPC message to the phone over HTTP and returns the answers.
|
|
10
|
+
|
|
11
|
+
The bridge does not add tools, permissions or state of its own. The token and
|
|
12
|
+
the phone-side pairing of the plugin still apply, and the phone still asks you
|
|
13
|
+
to allow each new client.
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- Node.js 18 or newer.
|
|
18
|
+
- The AutoJs6 MCP Server plugin switched on in the AutoJs6 drawer, and its
|
|
19
|
+
token copied from the plugin settings page.
|
|
20
|
+
- For USB: Android platform-tools (`adb`) on the PATH or passed with `--adb`.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```shell
|
|
25
|
+
npm install -g autojs6-mcp-bridge
|
|
26
|
+
autojs6-mcp-bridge --version
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`npx autojs6-mcp-bridge` works too, but clients start the bridge on every
|
|
30
|
+
session, so a global install keeps startup fast.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
autojs6-mcp-bridge [options]
|
|
36
|
+
|
|
37
|
+
--url <url> Endpoint of the phone (default: http://127.0.0.1:9637/mcp)
|
|
38
|
+
--serial <serial> Run "adb -s <serial> forward" for the endpoint port and remove it on exit
|
|
39
|
+
--forward Run "adb forward" without -s (single device or emulator)
|
|
40
|
+
--adb <path> adb executable (default: adb on the PATH, or AUTOJS6_MCP_ADB)
|
|
41
|
+
--protocol <date> Request this MCP protocol version from the phone instead of the client's
|
|
42
|
+
--token <token> Bearer token; prefer the AUTOJS6_MCP_TOKEN environment variable
|
|
43
|
+
-h, --help Show this help
|
|
44
|
+
-v, --version Show the version
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The token is read from `AUTOJS6_MCP_TOKEN` first so that it does not appear in
|
|
48
|
+
process listings; `--token` is accepted for one-off use and prints a warning.
|
|
49
|
+
`--serial` and `--forward` require a loopback `--url`; over Wi-Fi pass the
|
|
50
|
+
address shown on the plugin settings page and leave forwarding off.
|
|
51
|
+
|
|
52
|
+
## Client configuration
|
|
53
|
+
|
|
54
|
+
The plugin settings page copies the HTTP configuration for clients that
|
|
55
|
+
support it. For stdio-only clients, point them at this bridge instead.
|
|
56
|
+
|
|
57
|
+
Claude Desktop (`claude_desktop_config.json`):
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"mcpServers": {
|
|
62
|
+
"autojs6": {
|
|
63
|
+
"command": "autojs6-mcp-bridge",
|
|
64
|
+
"args": ["--serial", "<serial from adb devices>"],
|
|
65
|
+
"env": {
|
|
66
|
+
"AUTOJS6_MCP_TOKEN": "<token>"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Claude Code (stdio fallback when the HTTP transport cannot be used):
|
|
74
|
+
|
|
75
|
+
```shell
|
|
76
|
+
claude mcp add autojs6 -e AUTOJS6_MCP_TOKEN=<token> -- autojs6-mcp-bridge --serial <serial>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Any other stdio client: command `autojs6-mcp-bridge`, arguments as above, and
|
|
80
|
+
`AUTOJS6_MCP_TOKEN` in the environment block of its configuration. Over Wi-Fi
|
|
81
|
+
replace `--serial <serial>` with `--url http://<phone-address>:9637/mcp`.
|
|
82
|
+
|
|
83
|
+
## How it works
|
|
84
|
+
|
|
85
|
+
- The client's stdin / stdout carry newline-delimited JSON-RPC (the MCP stdio
|
|
86
|
+
transport); the bridge uses the official MCP SDK transports on both sides.
|
|
87
|
+
- Each message is POSTed to the phone with `Authorization: Bearer <token>`,
|
|
88
|
+
the `Mcp-Session-Id` returned by the phone and the negotiated
|
|
89
|
+
`MCP-Protocol-Version`. Responses arrive as JSON or as SSE and are written
|
|
90
|
+
back to stdout unchanged. The phone's own errors, such as
|
|
91
|
+
`PAIRING_REQUIRED` on the first call of a new client, pass through as they
|
|
92
|
+
are; accept the pairing prompt on the phone and let the client retry.
|
|
93
|
+
- `--protocol <date>` rewrites the `protocolVersion` of the client's
|
|
94
|
+
`initialize` request. Without it the client's version is passed through and
|
|
95
|
+
the phone negotiates as usual.
|
|
96
|
+
- With `--serial` or `--forward`, the bridge runs `adb forward tcp:<port>
|
|
97
|
+
tcp:<port>` before the first request and `adb forward --remove` when the
|
|
98
|
+
client closes stdin or the process receives SIGINT / SIGTERM. Requests
|
|
99
|
+
that are still on their way to the phone when stdin ends get up to 10 s
|
|
100
|
+
to finish, so a one-shot `echo '{...}' | autojs6-mcp-bridge` still
|
|
101
|
+
receives its answer.
|
|
102
|
+
- The first connection through a forward that was just created can be
|
|
103
|
+
refused or reset while the phone's listener is coming up; the bridge
|
|
104
|
+
repeats `initialize` up to 4 times (500 ms apart) before reporting the
|
|
105
|
+
failure. Later requests are never repeated, because they may have
|
|
106
|
+
already acted on the phone.
|
|
107
|
+
- When a message cannot be delivered, the client receives a JSON-RPC error
|
|
108
|
+
(code -32000) with one readable sentence instead of a hanging request:
|
|
109
|
+
|
|
110
|
+
| Situation | Message starts with |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| Token rejected | `401 Unauthorized from <url>: the token was rejected. Copy it again ...` |
|
|
113
|
+
| Phone not listening or no forward | `Cannot connect to <url> (connection refused): switch MCP Server on ...` |
|
|
114
|
+
| Phone unreachable over Wi-Fi | `Cannot reach <url> (EHOSTUNREACH): the phone is not reachable on this network ...` |
|
|
115
|
+
| Host header not on the allow list | `421 Misdirected Request from <url>: the Host header is not accepted ...` |
|
|
116
|
+
| Listener restarted | `The connection to <url> was reset: the listener restarted ...` |
|
|
117
|
+
| adb problems at startup | `adb forward tcp:9637 failed (exit 1): ... Several devices are connected: pass --serial ...` (exit code 3) |
|
|
118
|
+
|
|
119
|
+
Exit codes: 0 on a normal close, 1 on an unexpected error, 2 for invalid
|
|
120
|
+
options, 3 when `adb forward` fails.
|
|
121
|
+
|
|
122
|
+
## Compatibility
|
|
123
|
+
|
|
124
|
+
| Bridge | Plugin | MCP protocol requested by the bridge | Node.js | Verified clients |
|
|
125
|
+
| --- | --- | --- | --- | --- |
|
|
126
|
+
| 0.1.0 | AutoJs6 MCP Server 1.0.0 | passes the client's version through (the plugin negotiates 2025-06-18 or older); `--protocol` overrides it | 18 or newer (`engines`); tested with 24.15 | Claude Code 2.1.257 (stdio, `claude mcp add ... -- autojs6-mcp-bridge --serial <serial>`) |
|
|
127
|
+
|
|
128
|
+
Keep this table in step with the plugin README; both sides list it.
|
|
129
|
+
|
|
130
|
+
## Security
|
|
131
|
+
|
|
132
|
+
- The token authorizes full control of the phone through the plugin. Keep it
|
|
133
|
+
in the environment block of the client configuration, not in shell history
|
|
134
|
+
or process arguments, and rotate it from the plugin settings page if it
|
|
135
|
+
leaks.
|
|
136
|
+
- The bridge talks plain HTTP to the phone. Over USB the traffic stays on the
|
|
137
|
+
adb forward; over Wi-Fi use a trusted network and the addresses listed on
|
|
138
|
+
the plugin settings page.
|
|
139
|
+
- Only stderr carries diagnostics; stdout is reserved for the protocol. The
|
|
140
|
+
token is never logged.
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
```shell
|
|
145
|
+
npm install
|
|
146
|
+
npm test # builds dist/ and runs node --test
|
|
147
|
+
npm run pack:check
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Tests cover option parsing, the adb forward lifecycle with a fake runner,
|
|
151
|
+
the error mapping, and the message flow against a local fake phone (protocol
|
|
152
|
+
passthrough and rewrite, session id and protocol headers, 401 and connection
|
|
153
|
+
refused mapping, session termination on close).
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
[Mozilla Public License 2.0](LICENSE).
|
|
158
|
+
|
|
159
|
+
## Links
|
|
160
|
+
|
|
161
|
+
- Plugin: https://github.com/SuperMonster003/AutoJs6-Plugin-MCP-Server
|
|
162
|
+
- AutoJs6: https://github.com/SuperMonster003/AutoJs6
|
|
163
|
+
- MCP specification: https://modelcontextprotocol.io/specification
|
package/dist/bridge.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Readable, Writable } from 'node:stream';
|
|
2
|
+
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
3
|
+
/** How long a closing bridge waits for requests that are still on their way to the phone. */
|
|
4
|
+
export declare const DRAIN_TIMEOUT_MS = 10000;
|
|
5
|
+
/** Attempts for the initialize request while the phone's listener is still coming up. */
|
|
6
|
+
export declare const INITIALIZE_ATTEMPTS = 4;
|
|
7
|
+
export declare const INITIALIZE_RETRY_DELAY_MS = 500;
|
|
8
|
+
/** True for connection-level failures that happen before the phone has seen the request. */
|
|
9
|
+
export declare function isRetryableConnectionError(error: unknown): boolean;
|
|
10
|
+
export interface BridgeConfig {
|
|
11
|
+
/** Streamable HTTP endpoint of the phone. */
|
|
12
|
+
url: URL;
|
|
13
|
+
/** Bearer token for the Authorization header. */
|
|
14
|
+
token: string;
|
|
15
|
+
/** Protocol version to request instead of the client's; undefined passes the client's through. */
|
|
16
|
+
protocol?: string;
|
|
17
|
+
/** stdio side; defaults to the process streams. */
|
|
18
|
+
input?: Readable;
|
|
19
|
+
output?: Writable;
|
|
20
|
+
/** Diagnostics sink; defaults to stderr. Never write to stdout, it carries the protocol. */
|
|
21
|
+
log?: (line: string) => void;
|
|
22
|
+
/** Custom fetch for tests. */
|
|
23
|
+
fetch?: FetchLike;
|
|
24
|
+
/** Extra headers for every request, for example a User-Agent. */
|
|
25
|
+
headers?: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Moves JSON-RPC messages between a stdio client and the phone's Streamable HTTP endpoint. The
|
|
29
|
+
* SDK transports own framing, the Bearer header, the session id and the SSE stream; the bridge
|
|
30
|
+
* only rewrites the requested protocol version when asked, records the negotiated version for the
|
|
31
|
+
* MCP-Protocol-Version header, and answers requests that could not be delivered with a readable
|
|
32
|
+
* JSON-RPC error instead of leaving the client waiting.
|
|
33
|
+
*/
|
|
34
|
+
export declare class Bridge {
|
|
35
|
+
private readonly stdio;
|
|
36
|
+
private readonly http;
|
|
37
|
+
private readonly input;
|
|
38
|
+
private readonly log;
|
|
39
|
+
private readonly url;
|
|
40
|
+
private readonly protocol?;
|
|
41
|
+
private initializeId;
|
|
42
|
+
private closed;
|
|
43
|
+
private inflight;
|
|
44
|
+
private idle;
|
|
45
|
+
/** Resolves when the client side closes (stdin ends) or `close` is called. */
|
|
46
|
+
readonly done: Promise<void>;
|
|
47
|
+
private finish;
|
|
48
|
+
constructor(config: BridgeConfig);
|
|
49
|
+
/** The session id assigned by the phone after initialize, if any. */
|
|
50
|
+
get sessionId(): string | undefined;
|
|
51
|
+
/** The protocol version negotiated with the phone, if initialize has completed. */
|
|
52
|
+
get protocolVersion(): string | undefined;
|
|
53
|
+
start(): Promise<void>;
|
|
54
|
+
close(): Promise<void>;
|
|
55
|
+
private toPhone;
|
|
56
|
+
/**
|
|
57
|
+
* `initialize` is the first request of a session and safe to repeat: when the listener has
|
|
58
|
+
* just been switched on (or restarted after a port change), the first connection through adb
|
|
59
|
+
* can be refused or reset, so it is retried a few times before the client is told.
|
|
60
|
+
*/
|
|
61
|
+
private sendWithRetry;
|
|
62
|
+
/** Resolves when no request is in flight, or after [timeoutMs] at the latest. */
|
|
63
|
+
private settle;
|
|
64
|
+
private toClient;
|
|
65
|
+
}
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
2
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
3
|
+
import { isInitializeRequest, isJSONRPCRequest, isJSONRPCResponse } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { BRIDGE_ERROR_CODE, describeError } from './errors.js';
|
|
5
|
+
/** How long a closing bridge waits for requests that are still on their way to the phone. */
|
|
6
|
+
export const DRAIN_TIMEOUT_MS = 10_000;
|
|
7
|
+
/** Attempts for the initialize request while the phone's listener is still coming up. */
|
|
8
|
+
export const INITIALIZE_ATTEMPTS = 4;
|
|
9
|
+
export const INITIALIZE_RETRY_DELAY_MS = 500;
|
|
10
|
+
const RETRYABLE_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'UND_ERR_SOCKET', 'EPIPE']);
|
|
11
|
+
/** True for connection-level failures that happen before the phone has seen the request. */
|
|
12
|
+
export function isRetryableConnectionError(error) {
|
|
13
|
+
let current = error;
|
|
14
|
+
for (let depth = 0; depth < 4 && current && typeof current === 'object'; depth += 1) {
|
|
15
|
+
const code = current.code;
|
|
16
|
+
if (typeof code === 'string' && RETRYABLE_CODES.has(code)) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
current = current.cause;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Moves JSON-RPC messages between a stdio client and the phone's Streamable HTTP endpoint. The
|
|
25
|
+
* SDK transports own framing, the Bearer header, the session id and the SSE stream; the bridge
|
|
26
|
+
* only rewrites the requested protocol version when asked, records the negotiated version for the
|
|
27
|
+
* MCP-Protocol-Version header, and answers requests that could not be delivered with a readable
|
|
28
|
+
* JSON-RPC error instead of leaving the client waiting.
|
|
29
|
+
*/
|
|
30
|
+
export class Bridge {
|
|
31
|
+
stdio;
|
|
32
|
+
http;
|
|
33
|
+
input;
|
|
34
|
+
log;
|
|
35
|
+
url;
|
|
36
|
+
protocol;
|
|
37
|
+
initializeId;
|
|
38
|
+
closed = false;
|
|
39
|
+
inflight = 0;
|
|
40
|
+
idle = [];
|
|
41
|
+
/** Resolves when the client side closes (stdin ends) or `close` is called. */
|
|
42
|
+
done;
|
|
43
|
+
finish;
|
|
44
|
+
constructor(config) {
|
|
45
|
+
this.url = config.url;
|
|
46
|
+
this.protocol = config.protocol;
|
|
47
|
+
this.log = config.log ?? (line => process.stderr.write(`${line}\n`));
|
|
48
|
+
this.input = config.input ?? process.stdin;
|
|
49
|
+
this.stdio = new StdioServerTransport(this.input, config.output ?? process.stdout);
|
|
50
|
+
this.http = new StreamableHTTPClientTransport(config.url, {
|
|
51
|
+
fetch: config.fetch,
|
|
52
|
+
requestInit: { headers: { Authorization: `Bearer ${config.token}`, ...(config.headers ?? {}) } }
|
|
53
|
+
});
|
|
54
|
+
this.done = new Promise(resolve => {
|
|
55
|
+
this.finish = resolve;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** The session id assigned by the phone after initialize, if any. */
|
|
59
|
+
get sessionId() {
|
|
60
|
+
return this.http.sessionId;
|
|
61
|
+
}
|
|
62
|
+
/** The protocol version negotiated with the phone, if initialize has completed. */
|
|
63
|
+
get protocolVersion() {
|
|
64
|
+
return this.http.protocolVersion;
|
|
65
|
+
}
|
|
66
|
+
async start() {
|
|
67
|
+
this.stdio.onmessage = message => {
|
|
68
|
+
void this.toPhone(message);
|
|
69
|
+
};
|
|
70
|
+
this.stdio.onerror = error => this.log(`client transport error: ${error.message}`);
|
|
71
|
+
this.stdio.onclose = () => {
|
|
72
|
+
void this.close();
|
|
73
|
+
};
|
|
74
|
+
this.http.onmessage = message => {
|
|
75
|
+
void this.toClient(message);
|
|
76
|
+
};
|
|
77
|
+
this.http.onerror = error => this.log(`phone transport error: ${describeError(error, this.url)}`);
|
|
78
|
+
this.http.onclose = () => this.log('phone transport closed');
|
|
79
|
+
// The SDK's stdio server transport does not watch for the end of stdin; a client that exits
|
|
80
|
+
// closes our stdin, and that must end the bridge (and remove the adb forward). Requests that
|
|
81
|
+
// are still on their way to the phone get a bounded grace period so that piped one-shot
|
|
82
|
+
// invocations still receive their answer.
|
|
83
|
+
const ended = () => {
|
|
84
|
+
void this.settle(DRAIN_TIMEOUT_MS).then(() => this.close());
|
|
85
|
+
};
|
|
86
|
+
this.input.once('end', ended);
|
|
87
|
+
this.input.once('close', ended);
|
|
88
|
+
await this.http.start();
|
|
89
|
+
await this.stdio.start();
|
|
90
|
+
}
|
|
91
|
+
async close() {
|
|
92
|
+
if (this.closed) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
this.closed = true;
|
|
96
|
+
try {
|
|
97
|
+
if (this.http.sessionId) {
|
|
98
|
+
await this.http.terminateSession();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// The phone may not support DELETE; the session expires on its own.
|
|
103
|
+
}
|
|
104
|
+
await this.http.close().catch(() => undefined);
|
|
105
|
+
await this.stdio.close().catch(() => undefined);
|
|
106
|
+
this.finish();
|
|
107
|
+
}
|
|
108
|
+
async toPhone(incoming) {
|
|
109
|
+
let message = incoming;
|
|
110
|
+
if (isJSONRPCRequest(message) && isInitializeRequest(message)) {
|
|
111
|
+
this.initializeId = message.id;
|
|
112
|
+
if (this.protocol && message.params.protocolVersion !== this.protocol) {
|
|
113
|
+
this.log(`requesting protocol ${this.protocol} instead of ${message.params.protocolVersion}`);
|
|
114
|
+
message = { ...message, params: { ...message.params, protocolVersion: this.protocol } };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
this.inflight += 1;
|
|
118
|
+
try {
|
|
119
|
+
await this.sendWithRetry(message);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const description = describeError(error, this.url);
|
|
123
|
+
this.log(description);
|
|
124
|
+
if (isJSONRPCRequest(message)) {
|
|
125
|
+
await this.stdio
|
|
126
|
+
.send({ jsonrpc: '2.0', id: message.id, error: { code: BRIDGE_ERROR_CODE, message: description } })
|
|
127
|
+
.catch(sendError => this.log(`cannot answer the client: ${String(sendError)}`));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
this.inflight -= 1;
|
|
132
|
+
if (this.inflight === 0) {
|
|
133
|
+
this.idle.splice(0).forEach(resolve => resolve());
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* `initialize` is the first request of a session and safe to repeat: when the listener has
|
|
139
|
+
* just been switched on (or restarted after a port change), the first connection through adb
|
|
140
|
+
* can be refused or reset, so it is retried a few times before the client is told.
|
|
141
|
+
*/
|
|
142
|
+
async sendWithRetry(message) {
|
|
143
|
+
const attempts = isInitializeRequest(message) ? INITIALIZE_ATTEMPTS : 1;
|
|
144
|
+
for (let attempt = 1;; attempt += 1) {
|
|
145
|
+
try {
|
|
146
|
+
await this.http.send(message);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (attempt >= attempts || this.closed || !isRetryableConnectionError(error)) {
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
this.log(`initialize attempt ${attempt} failed (${describeError(error, this.url)}); retrying in ${INITIALIZE_RETRY_DELAY_MS} ms`);
|
|
154
|
+
await new Promise(resolve => setTimeout(resolve, INITIALIZE_RETRY_DELAY_MS));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Resolves when no request is in flight, or after [timeoutMs] at the latest. */
|
|
159
|
+
settle(timeoutMs) {
|
|
160
|
+
if (this.inflight === 0) {
|
|
161
|
+
return Promise.resolve();
|
|
162
|
+
}
|
|
163
|
+
return new Promise(resolve => {
|
|
164
|
+
const timer = setTimeout(resolve, timeoutMs);
|
|
165
|
+
this.idle.push(() => {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
resolve();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
async toClient(message) {
|
|
172
|
+
if (this.initializeId !== undefined && isJSONRPCResponse(message) && message.id === this.initializeId) {
|
|
173
|
+
const version = message.result.protocolVersion;
|
|
174
|
+
if (typeof version === 'string') {
|
|
175
|
+
this.http.setProtocolVersion(version);
|
|
176
|
+
}
|
|
177
|
+
this.initializeId = undefined;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
await this.stdio.send(message);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
this.log(`cannot write to the client: ${error instanceof Error ? error.message : String(error)}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { Bridge } from './bridge.js';
|
|
4
|
+
import { AdbForward, AdbForwardError } from './forward.js';
|
|
5
|
+
import { endpointPort, OptionError, parseOptions, USAGE } from './options.js';
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const { version } = require('../package.json');
|
|
8
|
+
function log(line) {
|
|
9
|
+
process.stderr.write(`[autojs6-mcp-bridge] ${line}\n`);
|
|
10
|
+
}
|
|
11
|
+
async function main() {
|
|
12
|
+
let command;
|
|
13
|
+
try {
|
|
14
|
+
command = parseOptions(process.argv.slice(2), process.env);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error instanceof OptionError) {
|
|
18
|
+
process.stderr.write(`${error.message}\n`);
|
|
19
|
+
return 2;
|
|
20
|
+
}
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
if (command.kind === 'help') {
|
|
24
|
+
process.stdout.write(USAGE);
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
if (command.kind === 'version') {
|
|
28
|
+
process.stdout.write(`${version}\n`);
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
const { options } = command;
|
|
32
|
+
options.warnings.forEach(log);
|
|
33
|
+
const forward = options.forward ? new AdbForward(options.adb, endpointPort(options.url), options.serial) : undefined;
|
|
34
|
+
if (forward) {
|
|
35
|
+
try {
|
|
36
|
+
await forward.start();
|
|
37
|
+
log(`adb forward ${forward.spec} active${options.serial ? ` for ${options.serial}` : ''}`);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error instanceof AdbForwardError) {
|
|
41
|
+
process.stderr.write(`${error.message}\n`);
|
|
42
|
+
return 3;
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const bridge = new Bridge({
|
|
48
|
+
url: options.url,
|
|
49
|
+
token: options.token,
|
|
50
|
+
protocol: options.protocol,
|
|
51
|
+
log,
|
|
52
|
+
headers: { 'User-Agent': `autojs6-mcp-bridge/${version}` }
|
|
53
|
+
});
|
|
54
|
+
let exiting = false;
|
|
55
|
+
const shutdown = async () => {
|
|
56
|
+
if (exiting) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
exiting = true;
|
|
60
|
+
await bridge.close();
|
|
61
|
+
await forward?.stop();
|
|
62
|
+
};
|
|
63
|
+
process.on('SIGINT', () => {
|
|
64
|
+
void shutdown();
|
|
65
|
+
});
|
|
66
|
+
process.on('SIGTERM', () => {
|
|
67
|
+
void shutdown();
|
|
68
|
+
});
|
|
69
|
+
await bridge.start();
|
|
70
|
+
log(`bridging stdio to ${options.url}`);
|
|
71
|
+
await bridge.done;
|
|
72
|
+
await shutdown();
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
main().then(code => {
|
|
76
|
+
process.exitCode = code;
|
|
77
|
+
}, error => {
|
|
78
|
+
process.stderr.write(`[autojs6-mcp-bridge] ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
});
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** JSON-RPC error code the bridge uses for transport failures it reports on behalf of the phone. */
|
|
2
|
+
export declare const BRIDGE_ERROR_CODE = -32000;
|
|
3
|
+
/**
|
|
4
|
+
* Turns a transport failure into one readable sentence for the client (and the log). The phone's
|
|
5
|
+
* own JSON-RPC errors such as PAIRING_REQUIRED never pass through here; they are forwarded as-is.
|
|
6
|
+
*/
|
|
7
|
+
export declare function describeError(error: unknown, url: URL): string;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
2
|
+
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js';
|
|
3
|
+
/** JSON-RPC error code the bridge uses for transport failures it reports on behalf of the phone. */
|
|
4
|
+
export const BRIDGE_ERROR_CODE = -32000;
|
|
5
|
+
/**
|
|
6
|
+
* Turns a transport failure into one readable sentence for the client (and the log). The phone's
|
|
7
|
+
* own JSON-RPC errors such as PAIRING_REQUIRED never pass through here; they are forwarded as-is.
|
|
8
|
+
*/
|
|
9
|
+
export function describeError(error, url) {
|
|
10
|
+
const endpoint = url.toString();
|
|
11
|
+
if (error instanceof StreamableHTTPError || error instanceof UnauthorizedError) {
|
|
12
|
+
const code = error instanceof StreamableHTTPError ? error.code : 401;
|
|
13
|
+
switch (code) {
|
|
14
|
+
case 401:
|
|
15
|
+
return `401 Unauthorized from ${endpoint}: the token was rejected. Copy it again from the plugin settings page (Show token) and set AUTOJS6_MCP_TOKEN; a rotated token invalidates the old one.`;
|
|
16
|
+
case 403:
|
|
17
|
+
return `403 Forbidden from ${endpoint}: the request was refused by the plugin's allow list. Use a loopback URL over adb forward, or an address listed on the settings page when local network access is on.`;
|
|
18
|
+
case 404:
|
|
19
|
+
return `404 Not Found from ${endpoint}: the endpoint path is wrong; the plugin serves /mcp.`;
|
|
20
|
+
case 421:
|
|
21
|
+
return `421 Misdirected Request from ${endpoint}: the Host header is not accepted; use the address exactly as shown on the plugin settings page.`;
|
|
22
|
+
case 429:
|
|
23
|
+
return `429 Too Many Requests from ${endpoint}: the plugin is rate limiting this client; wait a moment and retry.`;
|
|
24
|
+
case 503:
|
|
25
|
+
return `503 Service Unavailable from ${endpoint}: the plugin is stopping or the host session is not ready; check the AutoJs6 drawer switch.`;
|
|
26
|
+
default:
|
|
27
|
+
return `${endpoint} answered with an error${code !== undefined && code >= 0 ? ` (HTTP ${code})` : ''}: ${error.message}`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const cause = causeCode(error);
|
|
31
|
+
switch (cause) {
|
|
32
|
+
case 'ECONNREFUSED':
|
|
33
|
+
return `Cannot connect to ${endpoint} (connection refused): switch MCP Server on in the AutoJs6 drawer; over USB also run adb forward (use --serial to let the bridge do it).`;
|
|
34
|
+
case 'ETIMEDOUT':
|
|
35
|
+
case 'EHOSTUNREACH':
|
|
36
|
+
case 'ENETUNREACH':
|
|
37
|
+
return `Cannot reach ${endpoint} (${cause}): the phone is not reachable on this network. Keep the PC and the phone on the same Wi-Fi without guest isolation, enable local network access on the settings page, and allow the port through the firewall.`;
|
|
38
|
+
case 'ENOTFOUND':
|
|
39
|
+
case 'EAI_AGAIN':
|
|
40
|
+
return `Cannot resolve the host of ${endpoint}: use the IP address shown on the plugin settings page.`;
|
|
41
|
+
case 'ECONNRESET':
|
|
42
|
+
case 'UND_ERR_SOCKET':
|
|
43
|
+
return `The connection to ${endpoint} was reset: the listener restarted (port or network change) or the phone dropped the network; retry the request.`;
|
|
44
|
+
default:
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
if (error instanceof Error) {
|
|
48
|
+
return `Request to ${endpoint} failed: ${error.message}`;
|
|
49
|
+
}
|
|
50
|
+
return `Request to ${endpoint} failed: ${String(error)}`;
|
|
51
|
+
}
|
|
52
|
+
function causeCode(error) {
|
|
53
|
+
let current = error;
|
|
54
|
+
for (let depth = 0; depth < 4 && current && typeof current === 'object'; depth += 1) {
|
|
55
|
+
const code = current.code;
|
|
56
|
+
if (typeof code === 'string') {
|
|
57
|
+
return code;
|
|
58
|
+
}
|
|
59
|
+
current = current.cause;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface CommandResult {
|
|
2
|
+
code: number | null;
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
}
|
|
6
|
+
/** Runs an executable with arguments; injected in tests. */
|
|
7
|
+
export type CommandRunner = (command: string, args: readonly string[]) => Promise<CommandResult>;
|
|
8
|
+
export declare const spawnRunner: CommandRunner;
|
|
9
|
+
export declare class AdbForwardError extends Error {
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* `adb forward tcp:<port> tcp:<port>` for the lifetime of the bridge. `start` fails with a readable
|
|
13
|
+
* message when adb is missing, no device is connected, or several devices are connected without
|
|
14
|
+
* `--serial`; `stop` removes the forward and never throws.
|
|
15
|
+
*/
|
|
16
|
+
export declare class AdbForward {
|
|
17
|
+
private readonly adb;
|
|
18
|
+
private readonly port;
|
|
19
|
+
private readonly serial;
|
|
20
|
+
private readonly run;
|
|
21
|
+
private active;
|
|
22
|
+
constructor(adb: string, port: number, serial: string | undefined, run?: CommandRunner);
|
|
23
|
+
get args(): string[];
|
|
24
|
+
get spec(): string;
|
|
25
|
+
start(): Promise<void>;
|
|
26
|
+
stop(): Promise<void>;
|
|
27
|
+
}
|
package/dist/forward.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
export const spawnRunner = (command, args) => new Promise((resolve, reject) => {
|
|
3
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
4
|
+
let stdout = '';
|
|
5
|
+
let stderr = '';
|
|
6
|
+
child.stdout.on('data', chunk => {
|
|
7
|
+
stdout += String(chunk);
|
|
8
|
+
});
|
|
9
|
+
child.stderr.on('data', chunk => {
|
|
10
|
+
stderr += String(chunk);
|
|
11
|
+
});
|
|
12
|
+
child.on('error', reject);
|
|
13
|
+
child.on('close', code => resolve({ code, stdout, stderr }));
|
|
14
|
+
});
|
|
15
|
+
export class AdbForwardError extends Error {
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* `adb forward tcp:<port> tcp:<port>` for the lifetime of the bridge. `start` fails with a readable
|
|
19
|
+
* message when adb is missing, no device is connected, or several devices are connected without
|
|
20
|
+
* `--serial`; `stop` removes the forward and never throws.
|
|
21
|
+
*/
|
|
22
|
+
export class AdbForward {
|
|
23
|
+
adb;
|
|
24
|
+
port;
|
|
25
|
+
serial;
|
|
26
|
+
run;
|
|
27
|
+
active = false;
|
|
28
|
+
constructor(adb, port, serial, run = spawnRunner) {
|
|
29
|
+
this.adb = adb;
|
|
30
|
+
this.port = port;
|
|
31
|
+
this.serial = serial;
|
|
32
|
+
this.run = run;
|
|
33
|
+
}
|
|
34
|
+
get args() {
|
|
35
|
+
return this.serial ? ['-s', this.serial] : [];
|
|
36
|
+
}
|
|
37
|
+
get spec() {
|
|
38
|
+
return `tcp:${this.port}`;
|
|
39
|
+
}
|
|
40
|
+
async start() {
|
|
41
|
+
let result;
|
|
42
|
+
try {
|
|
43
|
+
result = await this.run(this.adb, [...this.args, 'forward', this.spec, this.spec]);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
throw new AdbForwardError(`Cannot run "${this.adb}": ${describe(error)}. Install Android platform-tools and put adb on the PATH, or pass --adb <path>.`);
|
|
47
|
+
}
|
|
48
|
+
if (result.code !== 0) {
|
|
49
|
+
const detail = (result.stderr || result.stdout).trim();
|
|
50
|
+
let hint = 'Check "adb devices".';
|
|
51
|
+
if (/more than one device|multiple devices/i.test(detail)) {
|
|
52
|
+
hint = 'Several devices are connected: pass --serial <serial> from "adb devices".';
|
|
53
|
+
}
|
|
54
|
+
else if (/no devices|device '.*' not found|device not found|not found/i.test(detail)) {
|
|
55
|
+
hint = 'No matching device: connect the phone over USB with USB debugging on and check "adb devices".';
|
|
56
|
+
}
|
|
57
|
+
else if (/unauthorized/i.test(detail)) {
|
|
58
|
+
hint = 'The phone has not authorized this computer: accept the USB debugging prompt on the phone.';
|
|
59
|
+
}
|
|
60
|
+
throw new AdbForwardError(`adb forward ${this.spec} failed (exit ${result.code}): ${detail || 'no output'}. ${hint}`);
|
|
61
|
+
}
|
|
62
|
+
this.active = true;
|
|
63
|
+
}
|
|
64
|
+
async stop() {
|
|
65
|
+
if (!this.active) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
this.active = false;
|
|
69
|
+
try {
|
|
70
|
+
await this.run(this.adb, [...this.args, 'forward', '--remove', this.spec]);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Best effort: a missing adb at shutdown is not worth failing the exit.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function describe(error) {
|
|
78
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
79
|
+
return 'not found';
|
|
80
|
+
}
|
|
81
|
+
return error instanceof Error ? error.message : String(error);
|
|
82
|
+
}
|