@beeos-ai/device-mcp-server 0.2.3 → 0.4.2
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/dist/backends/android-adb.d.ts +147 -6
- package/dist/backends/android-adb.js +776 -40
- package/dist/backends/android-adb.js.map +1 -1
- package/dist/backends/base.d.ts +243 -7
- package/dist/backends/base.js +81 -2
- package/dist/backends/base.js.map +1 -1
- package/dist/backends/desktop.d.ts +3 -2
- package/dist/backends/desktop.js +9 -3
- package/dist/backends/desktop.js.map +1 -1
- package/dist/backends/linux.js +3 -0
- package/dist/backends/linux.js.map +1 -1
- package/dist/backends/mac.d.ts +11 -2
- package/dist/backends/mac.js +39 -1
- package/dist/backends/mac.js.map +1 -1
- package/dist/backends/stubs/windows.js +3 -0
- package/dist/backends/stubs/windows.js.map +1 -1
- package/dist/cli.d.ts +40 -26
- package/dist/cli.js +118 -84
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +9 -6
- package/dist/index.js +9 -6
- package/dist/index.js.map +1 -1
- package/dist/server/app.d.ts +60 -17
- package/dist/server/app.js +182 -138
- package/dist/server/app.js.map +1 -1
- package/dist/server/mcp-server.d.ts +25 -0
- package/dist/server/mcp-server.js +33 -0
- package/dist/server/mcp-server.js.map +1 -0
- package/dist/server/registry.d.ts +111 -0
- package/dist/server/registry.js +191 -0
- package/dist/server/registry.js.map +1 -0
- package/dist/server/stdio.d.ts +29 -0
- package/dist/server/stdio.js +35 -0
- package/dist/server/stdio.js.map +1 -0
- package/dist/server/tool-registry.d.ts +60 -35
- package/dist/server/tool-registry.js +911 -434
- package/dist/server/tool-registry.js.map +1 -1
- package/dist/util/adb-files.d.ts +25 -1
- package/dist/util/adb-files.js +95 -0
- package/dist/util/adb-files.js.map +1 -1
- package/dist/util/locale.d.ts +16 -0
- package/dist/util/locale.js +31 -0
- package/dist/util/locale.js.map +1 -0
- package/dist/util/logger.d.ts +27 -0
- package/dist/util/logger.js +27 -0
- package/dist/util/logger.js.map +1 -0
- package/dist/util/output-path.d.ts +60 -0
- package/dist/util/output-path.js +123 -0
- package/dist/util/output-path.js.map +1 -0
- package/dist/util/package-name.d.ts +26 -0
- package/dist/util/package-name.js +41 -0
- package/dist/util/package-name.js.map +1 -0
- package/package.json +6 -4
- package/dist/backends/stubs/macos.d.ts +0 -13
- package/dist/backends/stubs/macos.js +0 -27
- package/dist/backends/stubs/macos.js.map +0 -1
- package/dist/server/action-mapping.d.ts +0 -21
- package/dist/server/action-mapping.js +0 -153
- package/dist/server/action-mapping.js.map +0 -1
package/dist/server/app.js
CHANGED
|
@@ -1,157 +1,201 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* with all routes wired up. Decoupled from `cli.ts` so unit tests can spin up
|
|
4
|
-
* an in-process server with a mock backend.
|
|
2
|
+
* Express HTTP host for `device-mcp-server`'s Streamable HTTP transport.
|
|
5
3
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
4
|
+
* 0.4.0 reshape: the previous Fastify routes (`/healthz`, `/mcp/tools/list`,
|
|
5
|
+
* `/mcp/tools/call`, `/act`, `/screen.png`, `/events`) are GONE. The only
|
|
6
|
+
* surface this app exposes is `POST /mcp` — the Streamable HTTP endpoint
|
|
7
|
+
* defined by the official MCP wire spec, hosted by
|
|
8
|
+
* `@modelcontextprotocol/sdk`'s `StreamableHTTPServerTransport`.
|
|
9
|
+
*
|
|
10
|
+
* Stateless mode is intentional:
|
|
11
|
+
* - `sessionIdGenerator: undefined` → no session header, no in-memory
|
|
12
|
+
* subscriber map, every POST is fully self-contained.
|
|
13
|
+
* - GET / DELETE on `/mcp` return 405 — those verbs only make sense in
|
|
14
|
+
* stateful (subscribe-for-notifications) mode which we don't use.
|
|
15
|
+
* - The transport is recreated per request so concurrent calls don't
|
|
16
|
+
* trip over a shared `_initialized` flag (this matches the SDK
|
|
17
|
+
* stateless example in the README).
|
|
18
|
+
*
|
|
19
|
+
* Auth (0.4.1): the loopback server still binds to `127.0.0.1` by
|
|
20
|
+
* default and is safe in that posture. When the operator exposes it on
|
|
21
|
+
* a routable interface (`--host 0.0.0.0`, container forwarding, …) two
|
|
22
|
+
* defence-in-depth gates kick in:
|
|
23
|
+
*
|
|
24
|
+
* - `BEEOS_DEVICE_AUTH=<token>` → `Authorization: Bearer <token>` is
|
|
25
|
+
* required on every `POST /mcp`. Constant-time compared so the
|
|
26
|
+
* check doesn't leak length differences.
|
|
27
|
+
* - `BEEOS_DEVICE_ALLOWED_ORIGINS=https://a,https://b` → if the
|
|
28
|
+
* incoming request carries an `Origin` header it must match one of
|
|
29
|
+
* the listed origins. Browser callers without an origin (curl /
|
|
30
|
+
* server-to-server) are still allowed unless `BEEOS_DEVICE_REQUIRE_ORIGIN=1`.
|
|
31
|
+
*
|
|
32
|
+
* Both are optional — leaving them unset preserves the legacy "loopback
|
|
33
|
+
* is trusted" stance.
|
|
13
34
|
*/
|
|
14
|
-
import
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
35
|
+
import express from "express";
|
|
36
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
37
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
38
|
+
import { buildMcpServer } from "./mcp-server.js";
|
|
39
|
+
import { stderrLogger } from "../util/logger.js";
|
|
40
|
+
export function buildApp(opts) {
|
|
41
|
+
const log = opts.logger ?? stderrLogger;
|
|
42
|
+
const app = express();
|
|
43
|
+
app.use(express.json({ limit: opts.bodyLimit ?? 16 * 1024 * 1024 }));
|
|
44
|
+
// Per-request request id for log correlation (matches Fastify's old
|
|
45
|
+
// `reqId` semantic). Falls through if upstream proxies set their own.
|
|
46
|
+
app.use((req, _res, next) => {
|
|
47
|
+
req.reqId =
|
|
48
|
+
req.headers["x-request-id"] ?? randomUUID();
|
|
49
|
+
next();
|
|
22
50
|
});
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
51
|
+
// ── Auth + CORS gate ───────────────────────────────────────────────────
|
|
52
|
+
// Resolve config from explicit opts > env. We intentionally read env
|
|
53
|
+
// here (not in module top-level) so tests can manipulate the env
|
|
54
|
+
// between buildApp() calls.
|
|
55
|
+
const expectedToken = opts.authToken ?? process.env.BEEOS_DEVICE_AUTH ?? "";
|
|
56
|
+
const allowedOriginsRaw = opts.allowedOrigins ?? process.env.BEEOS_DEVICE_ALLOWED_ORIGINS ?? "";
|
|
57
|
+
const allowedOrigins = parseOriginList(allowedOriginsRaw);
|
|
58
|
+
const requireOrigin = opts.requireOrigin ?? process.env.BEEOS_DEVICE_REQUIRE_ORIGIN === "1";
|
|
59
|
+
app.use("/mcp", (req, res, next) => {
|
|
60
|
+
const reqId = req.reqId;
|
|
61
|
+
// 1) Origin allow-list. We only enforce when the operator has set
|
|
62
|
+
// an explicit allow-list — otherwise loopback callers without
|
|
63
|
+
// an Origin header (Cursor, Claude Desktop) keep working.
|
|
64
|
+
if (allowedOrigins.length > 0) {
|
|
65
|
+
const origin = req.headers.origin?.trim();
|
|
66
|
+
if (!origin) {
|
|
67
|
+
if (requireOrigin) {
|
|
68
|
+
log.warn?.({ reqId }, "device_mcp_origin_missing");
|
|
69
|
+
return jsonRpcError(res, 403, -32001, "Origin header required");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else if (!allowedOrigins.includes(origin)) {
|
|
73
|
+
log.warn?.({ reqId, origin }, "device_mcp_origin_rejected");
|
|
74
|
+
return jsonRpcError(res, 403, -32001, `Origin '${origin}' is not in the allow-list`);
|
|
75
|
+
}
|
|
34
76
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
77
|
+
// 2) Bearer auth. Constant-time compare so a wrong-length / wrong
|
|
78
|
+
// bytes attempt doesn't leak information through timing.
|
|
79
|
+
if (expectedToken) {
|
|
80
|
+
const presented = parseBearer(req.headers.authorization);
|
|
81
|
+
if (!presented || !constantTimeEquals(presented, expectedToken)) {
|
|
82
|
+
log.warn?.({ reqId }, "device_mcp_auth_rejected");
|
|
83
|
+
return jsonRpcError(res, 401, -32001, "Invalid bearer token");
|
|
84
|
+
}
|
|
38
85
|
}
|
|
39
|
-
|
|
40
|
-
});
|
|
41
|
-
/* ---------- /healthz ---------- */
|
|
42
|
-
app.get("/healthz", async () => {
|
|
43
|
-
return {
|
|
44
|
-
ok: true,
|
|
45
|
-
// Wire field name kept as `backend` even though the source is
|
|
46
|
-
// now `os` rather than `family`. Values are unchanged
|
|
47
|
-
// (`"android"` / `"desktop-macos"` / etc) — see the BackendOs
|
|
48
|
-
// taxonomy in @beeos-ai/device-common/backend.ts.
|
|
49
|
-
backend: opts.backend.os,
|
|
50
|
-
version: process.env.npm_package_version ?? "0.2.0",
|
|
51
|
-
// Tool-set-filtered list — agents that pick a tool out of this
|
|
52
|
-
// list are guaranteed it'll dispatch on the active backend.
|
|
53
|
-
tools: listToolDescriptorsFor(opts.backend).map((t) => t.name),
|
|
54
|
-
};
|
|
55
|
-
});
|
|
56
|
-
/* ---------- /mcp/tools/list ---------- */
|
|
57
|
-
const toolListHandler = async () => ({
|
|
58
|
-
tools: listToolDescriptorsFor(opts.backend),
|
|
86
|
+
next();
|
|
59
87
|
});
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
reply.status(400).send({ error: "missing tool name" });
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
const tool = getToolFor(name, opts.backend);
|
|
70
|
-
if (!tool) {
|
|
71
|
-
// Either the tool is unknown OR the backend lacks the required
|
|
72
|
-
// capability — collapse both to 404 so MCP clients reading
|
|
73
|
-
// `/mcp/tools/list` cannot bypass the filter via direct call.
|
|
74
|
-
reply.status(404).send({ error: `unknown tool '${name}'` });
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
const startedAt = Date.now();
|
|
88
|
+
// ── POST /mcp ──────────────────────────────────────────────────────────
|
|
89
|
+
// SDK stateless mode: spin up a fresh transport per request, hand it
|
|
90
|
+
// to the McpServer, and let `transport.handleRequest` turn the Express
|
|
91
|
+
// (req, res, body) trio into JSON-RPC.
|
|
92
|
+
app.post("/mcp", async (req, res) => {
|
|
93
|
+
const reqId = req.reqId;
|
|
78
94
|
try {
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
95
|
+
const server = buildMcpServer({
|
|
96
|
+
registry: opts.registry,
|
|
97
|
+
name: opts.name,
|
|
98
|
+
version: opts.version,
|
|
99
|
+
});
|
|
100
|
+
const transport = new StreamableHTTPServerTransport({
|
|
101
|
+
sessionIdGenerator: undefined, // stateless
|
|
102
|
+
enableJsonResponse: true,
|
|
103
|
+
});
|
|
104
|
+
// Make sure the per-request server/transport are released as soon
|
|
105
|
+
// as the response closes — otherwise we leak one McpServer per
|
|
106
|
+
// call.
|
|
107
|
+
res.on("close", () => {
|
|
108
|
+
transport.close().catch(() => undefined);
|
|
109
|
+
server.close().catch(() => undefined);
|
|
110
|
+
});
|
|
111
|
+
await server.connect(transport);
|
|
112
|
+
await transport.handleRequest(req, res, req.body);
|
|
87
113
|
}
|
|
88
114
|
catch (e) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
115
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
116
|
+
log.error?.({ err: message, reqId }, "mcp_request_failed");
|
|
117
|
+
if (!res.headersSent) {
|
|
118
|
+
res.status(500).json({
|
|
119
|
+
jsonrpc: "2.0",
|
|
120
|
+
error: { code: -32603, message: `Internal error: ${message}` },
|
|
121
|
+
id: null,
|
|
122
|
+
});
|
|
94
123
|
}
|
|
95
|
-
throw e;
|
|
96
124
|
}
|
|
97
125
|
});
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (!tool) {
|
|
119
|
-
reply.status(404).send({ error: `tool '${mapped.toolName}' not registered` });
|
|
126
|
+
// GET / DELETE /mcp — explicit 405 so clients get a structured response
|
|
127
|
+
// instead of Express's default 404. The SDK's stateful flows would map
|
|
128
|
+
// these onto SSE / session teardown; stateless mode has no use for them.
|
|
129
|
+
const methodNotAllowed = (_req, res) => {
|
|
130
|
+
res.status(405).json({
|
|
131
|
+
jsonrpc: "2.0",
|
|
132
|
+
error: {
|
|
133
|
+
code: -32000,
|
|
134
|
+
message: "Method Not Allowed: device-mcp-server runs in stateless mode (POST /mcp only).",
|
|
135
|
+
},
|
|
136
|
+
id: null,
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
app.get("/mcp", methodNotAllowed);
|
|
140
|
+
app.delete("/mcp", methodNotAllowed);
|
|
141
|
+
// Final error fallback so synchronous middleware throws don't crash
|
|
142
|
+
// the process. Stays JSON-RPC-shaped for client uniformity.
|
|
143
|
+
app.use((err, _req, res, _next) => {
|
|
144
|
+
log.error?.({ err: err.message }, "express_error");
|
|
145
|
+
if (res.headersSent)
|
|
120
146
|
return;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
action: action.type,
|
|
126
|
-
tool: mapped.toolName,
|
|
127
|
-
durationMs: Date.now() - startedAt,
|
|
128
|
-
result,
|
|
129
|
-
};
|
|
130
|
-
reply.send(response);
|
|
131
|
-
});
|
|
132
|
-
/* ---------- /screen.png ---------- */
|
|
133
|
-
app.get("/screen.png", async (_req, reply) => {
|
|
134
|
-
// 0.2.3: backend reports the encoded format directly so we never
|
|
135
|
-
// need to magic-byte-sniff the buffer here.
|
|
136
|
-
const shot = await opts.backend.screenshot();
|
|
137
|
-
reply
|
|
138
|
-
.header("content-type", shot.format === "png" ? "image/png" : "image/jpeg")
|
|
139
|
-
.send(shot.data);
|
|
140
|
-
});
|
|
141
|
-
/* ---------- /events (SSE placeholder) ---------- */
|
|
142
|
-
app.get("/events", async (_req, reply) => {
|
|
143
|
-
reply.raw.writeHead(200, {
|
|
144
|
-
"Content-Type": "text/event-stream",
|
|
145
|
-
"Cache-Control": "no-cache",
|
|
146
|
-
Connection: "keep-alive",
|
|
147
|
+
res.status(500).json({
|
|
148
|
+
jsonrpc: "2.0",
|
|
149
|
+
error: { code: -32603, message: err.message },
|
|
150
|
+
id: null,
|
|
147
151
|
});
|
|
148
|
-
reply.raw.write(": connected\n\n");
|
|
149
|
-
const interval = setInterval(() => {
|
|
150
|
-
reply.raw.write(`: heartbeat ${Date.now()}\n\n`);
|
|
151
|
-
}, 15_000);
|
|
152
|
-
reply.raw.on("close", () => clearInterval(interval));
|
|
153
|
-
return reply;
|
|
154
152
|
});
|
|
155
153
|
return app;
|
|
156
154
|
}
|
|
155
|
+
/* ----------------------------------------------------------------------- */
|
|
156
|
+
/* Auth + CORS helpers */
|
|
157
|
+
/* ----------------------------------------------------------------------- */
|
|
158
|
+
/** Split a comma-separated allow-list and trim whitespace. */
|
|
159
|
+
function parseOriginList(raw) {
|
|
160
|
+
return raw
|
|
161
|
+
.split(",")
|
|
162
|
+
.map((s) => s.trim())
|
|
163
|
+
.filter((s) => s.length > 0);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Parse `Authorization: Bearer <token>` (case-insensitive on the
|
|
167
|
+
* scheme) and return the token, or `undefined` if absent / malformed.
|
|
168
|
+
*/
|
|
169
|
+
function parseBearer(header) {
|
|
170
|
+
if (!header)
|
|
171
|
+
return undefined;
|
|
172
|
+
const m = /^bearer\s+(\S+)\s*$/i.exec(header);
|
|
173
|
+
return m ? m[1] : undefined;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Constant-time string comparison. Returns false fast on length
|
|
177
|
+
* mismatch (timingSafeEqual itself requires equal-length buffers, so
|
|
178
|
+
* we pad the shorter one to the longer one's length and still emit
|
|
179
|
+
* `false` afterwards).
|
|
180
|
+
*/
|
|
181
|
+
function constantTimeEquals(a, b) {
|
|
182
|
+
const aBuf = Buffer.from(a, "utf8");
|
|
183
|
+
const bBuf = Buffer.from(b, "utf8");
|
|
184
|
+
if (aBuf.length !== bBuf.length) {
|
|
185
|
+
// Still pay the cost of one timingSafeEqual on equal-sized garbage
|
|
186
|
+
// so timing leaks one bit at most.
|
|
187
|
+
const filler = Buffer.alloc(aBuf.length, 0);
|
|
188
|
+
timingSafeEqual(aBuf, filler);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
return timingSafeEqual(aBuf, bBuf);
|
|
192
|
+
}
|
|
193
|
+
/** Emit a JSON-RPC-shaped error response on the auth/CORS gate. */
|
|
194
|
+
function jsonRpcError(res, status, code, message) {
|
|
195
|
+
res.status(status).json({
|
|
196
|
+
jsonrpc: "2.0",
|
|
197
|
+
error: { code, message },
|
|
198
|
+
id: null,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
157
201
|
//# sourceMappingURL=app.js.map
|
package/dist/server/app.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app.js","sourceRoot":"","sources":["../../src/server/app.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"app.js","sourceRoot":"","sources":["../../src/server/app.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `buildMcpServer({ registry })` — construct a configured
|
|
3
|
+
* `@modelcontextprotocol/sdk` server with every device-mcp-server tool
|
|
4
|
+
* registered against the multi-device `BackendRegistry`.
|
|
5
|
+
*
|
|
6
|
+
* Transport-agnostic. Callers pick:
|
|
7
|
+
* - Express + `StreamableHTTPServerTransport` (see `app.ts`) for the
|
|
8
|
+
* HTTP loopback path used by `device-agent` siblings.
|
|
9
|
+
* - `StdioServerTransport` (see `stdio.ts`) for the IDE / Cursor path.
|
|
10
|
+
*
|
|
11
|
+
* Both paths share the same `McpServer` instance type — sessionId
|
|
12
|
+
* semantics differ (HTTP stateless = undefined, stdio = stable for the
|
|
13
|
+
* lifetime of the connection) but the tool layer handles that uniformly
|
|
14
|
+
* via the `extra.sessionId` argument.
|
|
15
|
+
*/
|
|
16
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
|
+
import type { BackendRegistry } from "./registry.js";
|
|
18
|
+
export interface BuildMcpServerOptions {
|
|
19
|
+
registry: BackendRegistry;
|
|
20
|
+
/** Override the advertised server `name` (defaults to package name). */
|
|
21
|
+
name?: string;
|
|
22
|
+
/** Override the advertised server `version` (defaults to package version). */
|
|
23
|
+
version?: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function buildMcpServer(opts: BuildMcpServerOptions): McpServer;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `buildMcpServer({ registry })` — construct a configured
|
|
3
|
+
* `@modelcontextprotocol/sdk` server with every device-mcp-server tool
|
|
4
|
+
* registered against the multi-device `BackendRegistry`.
|
|
5
|
+
*
|
|
6
|
+
* Transport-agnostic. Callers pick:
|
|
7
|
+
* - Express + `StreamableHTTPServerTransport` (see `app.ts`) for the
|
|
8
|
+
* HTTP loopback path used by `device-agent` siblings.
|
|
9
|
+
* - `StdioServerTransport` (see `stdio.ts`) for the IDE / Cursor path.
|
|
10
|
+
*
|
|
11
|
+
* Both paths share the same `McpServer` instance type — sessionId
|
|
12
|
+
* semantics differ (HTTP stateless = undefined, stdio = stable for the
|
|
13
|
+
* lifetime of the connection) but the tool layer handles that uniformly
|
|
14
|
+
* via the `extra.sessionId` argument.
|
|
15
|
+
*/
|
|
16
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
|
+
import { registerAllTools } from "./tool-registry.js";
|
|
18
|
+
export function buildMcpServer(opts) {
|
|
19
|
+
const server = new McpServer({
|
|
20
|
+
name: opts.name ?? "device-mcp-server",
|
|
21
|
+
version: opts.version ?? process.env.npm_package_version ?? "0.4.1",
|
|
22
|
+
}, {
|
|
23
|
+
capabilities: {
|
|
24
|
+
// Tools are the only surface device-mcp-server exposes today.
|
|
25
|
+
// Resources / prompts / logging are intentionally left off so
|
|
26
|
+
// MCP clients don't pop empty UI sections for them.
|
|
27
|
+
tools: { listChanged: true },
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
registerAllTools(server, opts.registry);
|
|
31
|
+
return server;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=mcp-server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp-server.js","sourceRoot":"","sources":["../../src/server/mcp-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAGpE,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAUtD,MAAM,UAAU,cAAc,CAAC,IAA2B;IACxD,MAAM,MAAM,GAAG,IAAI,SAAS,CAC1B;QACE,IAAI,EAAE,IAAI,CAAC,IAAI,IAAI,mBAAmB;QACtC,OAAO,EAAE,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,GAAG,CAAC,mBAAmB,IAAI,OAAO;KACpE,EACD;QACE,YAAY,EAAE;YACZ,8DAA8D;YAC9D,8DAA8D;YAC9D,oDAAoD;YACpD,KAAK,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE;SAC7B;KACF,CACF,CAAC;IAEF,gBAAgB,CAAC,MAAM,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC;IACxC,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BackendRegistry — manages multiple `SandboxBackend` instances inside a
|
|
3
|
+
* **single** device-mcp-server process.
|
|
4
|
+
*
|
|
5
|
+
* Replaces the legacy "one process per device" topology (mirrored from the
|
|
6
|
+
* `mobile-mcp` design): a single `device-mcp-server` discovers every adb device
|
|
7
|
+
* in the host's `adb devices` output and lazily wraps each one in an
|
|
8
|
+
* `AndroidAdbBackend`. Tool handlers resolve the right backend through three
|
|
9
|
+
* fallbacks (in priority order):
|
|
10
|
+
*
|
|
11
|
+
* 1. explicit `device` argument on the tool call (`device: "emulator-5554"`)
|
|
12
|
+
* 2. session-sticky default set via the `use_device` MCP tool (keyed by
|
|
13
|
+
* `extra.sessionId`, only meaningful for stdio / single-session clients)
|
|
14
|
+
* 3. single-device shortcut: if exactly one device is connected, use it
|
|
15
|
+
*
|
|
16
|
+
* If none of the above resolves to a backend the registry throws
|
|
17
|
+
* `DeviceError("device required", subtype:"invalid_args")` so the MCP tool
|
|
18
|
+
* call returns a structured error instead of silently picking a random device.
|
|
19
|
+
*
|
|
20
|
+
* The registry also owns a periodic `adb devices` poll (default 10s) so newly
|
|
21
|
+
* plugged devices show up in `list_available_devices` without restarting the
|
|
22
|
+
* process. Connect-state validation per backend is lazy — backends are
|
|
23
|
+
* constructed with `skipConnectValidation: true` and only emit errors at
|
|
24
|
+
* tool-call time.
|
|
25
|
+
*
|
|
26
|
+
* Desktop / stub backends are registered up front (one each) and re-used
|
|
27
|
+
* across every session — the device id for a desktop backend is its
|
|
28
|
+
* `os` string (`"desktop-macos"`, `"desktop-linux"`, ...).
|
|
29
|
+
*/
|
|
30
|
+
import { type AdbRunner } from "../backends/android-adb.js";
|
|
31
|
+
import type { AndroidAdbBackendOptions } from "../backends/android-adb.js";
|
|
32
|
+
import type { SandboxBackend } from "../backends/base.js";
|
|
33
|
+
export interface AvailableDevice {
|
|
34
|
+
/** Stable device identifier — pass to tool calls as `device: "..."`. */
|
|
35
|
+
id: string;
|
|
36
|
+
/** Backend OS family — drives capability hints in MCP clients. */
|
|
37
|
+
os: SandboxBackend["os"];
|
|
38
|
+
/** True if this is the only connected device (the single-device default). */
|
|
39
|
+
isDefault: boolean;
|
|
40
|
+
/** Human-readable hint (e.g. `"adb"` / `"desktop"`). Optional sugar. */
|
|
41
|
+
source: "adb" | "desktop";
|
|
42
|
+
}
|
|
43
|
+
export interface BackendRegistryOptions {
|
|
44
|
+
/**
|
|
45
|
+
* Pre-registered "static" backends (desktop / stubs / iOS). Their `os`
|
|
46
|
+
* field becomes the device id used in tool calls. Use this when the
|
|
47
|
+
* server should ALWAYS expose a non-adb backend regardless of the host's
|
|
48
|
+
* adb state (e.g. when running on a Mac dev machine with `--backend
|
|
49
|
+
* desktop`).
|
|
50
|
+
*/
|
|
51
|
+
initialBackends?: SandboxBackend[];
|
|
52
|
+
/**
|
|
53
|
+
* Whether to run the adb-devices discovery poll. Set to `false` when the
|
|
54
|
+
* caller only wants the static `initialBackends` (e.g. `--backend desktop`
|
|
55
|
+
* on a host without adb installed).
|
|
56
|
+
*
|
|
57
|
+
* Default: `true`.
|
|
58
|
+
*/
|
|
59
|
+
enableAdbDiscovery?: boolean;
|
|
60
|
+
/** Override the spawn-backed adb runner — tests inject spies. */
|
|
61
|
+
adbRunner?: AdbRunner;
|
|
62
|
+
/** Discovery poll interval in milliseconds. Default `10_000`. */
|
|
63
|
+
pollIntervalMs?: number;
|
|
64
|
+
/**
|
|
65
|
+
* Construction options forwarded to every newly-discovered
|
|
66
|
+
* `AndroidAdbBackend`. `serial` is overridden per device, but timeouts /
|
|
67
|
+
* runner injection / sleep helpers flow through.
|
|
68
|
+
*/
|
|
69
|
+
androidDefaults?: Omit<AndroidAdbBackendOptions, "serial" | "skipConnectValidation">;
|
|
70
|
+
/** Initial seed serials — useful for tests that want a known device set without polling. */
|
|
71
|
+
initialAdbSerials?: string[];
|
|
72
|
+
}
|
|
73
|
+
export declare class BackendRegistry {
|
|
74
|
+
private readonly backends;
|
|
75
|
+
private readonly enableAdbDiscovery;
|
|
76
|
+
private readonly adbRunner?;
|
|
77
|
+
private readonly pollIntervalMs;
|
|
78
|
+
private readonly androidDefaults;
|
|
79
|
+
private pollTimer?;
|
|
80
|
+
/**
|
|
81
|
+
* Per-session active-device map (only meaningful for stdio / single-session
|
|
82
|
+
* transports). For stateless HTTP `extra.sessionId` is undefined and this
|
|
83
|
+
* map is never written.
|
|
84
|
+
*/
|
|
85
|
+
private readonly sessionDevice;
|
|
86
|
+
constructor(opts?: BackendRegistryOptions);
|
|
87
|
+
/**
|
|
88
|
+
* Kick off the adb-discovery poll and run one immediate scan. Returns
|
|
89
|
+
* after the first scan completes (so callers can `await` and immediately
|
|
90
|
+
* see a populated `list()`).
|
|
91
|
+
*
|
|
92
|
+
* Idempotent — safe to call multiple times.
|
|
93
|
+
*/
|
|
94
|
+
start(): Promise<void>;
|
|
95
|
+
/** Stop the discovery poll and disconnect every backend. */
|
|
96
|
+
stop(): Promise<void>;
|
|
97
|
+
/** Snapshot of every currently-known device. */
|
|
98
|
+
list(): AvailableDevice[];
|
|
99
|
+
/**
|
|
100
|
+
* Resolve a backend for a single tool call. `args.device` wins; if absent,
|
|
101
|
+
* fall back to the session-sticky `use_device` value; if also absent and
|
|
102
|
+
* exactly one backend is registered, use it. Otherwise throw with a clear
|
|
103
|
+
* "device required" error so the MCP client knows it must call
|
|
104
|
+
* `list_available_devices` / `use_device` first.
|
|
105
|
+
*/
|
|
106
|
+
resolve(args: Record<string, unknown> | undefined, sessionId?: string): SandboxBackend;
|
|
107
|
+
/** Set the session-sticky default device. Used by the `use_device` tool. */
|
|
108
|
+
useDevice(sessionId: string | undefined, deviceId: string): void;
|
|
109
|
+
private pollOnce;
|
|
110
|
+
private makeAdbBackend;
|
|
111
|
+
}
|