watcher-mcp 0.3.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/LICENSE +21 -0
- package/README.md +140 -0
- package/lib/discovery.js +183 -0
- package/lib/gateway-client.js +145 -0
- package/lib/paths.js +10 -0
- package/lib/state.js +30 -0
- package/package.json +46 -0
- package/server.js +521 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Watcher Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# watcher-mcp
|
|
2
|
+
|
|
3
|
+
MCP server that gives AI agents real-world perception through the [Watcher](https://github.com/AAswordman/Watcher) Android app gateway.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- Discovers Watcher devices on the local network
|
|
8
|
+
- Binds a device and caches connection details
|
|
9
|
+
- Reads gateway capabilities and health
|
|
10
|
+
- Captures live camera snapshots
|
|
11
|
+
- Creates and manages monitoring/analysis tasks
|
|
12
|
+
- Polls task events and commentary state
|
|
13
|
+
|
|
14
|
+
## What it does not do
|
|
15
|
+
|
|
16
|
+
- Desktop automation (Git, file ops, lock screen)
|
|
17
|
+
- Replace the agent's own terminal or OS tools
|
|
18
|
+
- Hardcode scenario-specific workflows
|
|
19
|
+
|
|
20
|
+
Watcher is the agent's **eyes**, not its hands.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install -g watcher-mcp
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or use without installing:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx -y watcher-mcp
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Configure with Claude Code
|
|
35
|
+
|
|
36
|
+
One command:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
claude mcp add --transport stdio watcher -- npx -y watcher-mcp
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or add to your project's `.mcp.json`:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"mcpServers": {
|
|
47
|
+
"watcher": {
|
|
48
|
+
"command": "npx",
|
|
49
|
+
"args": ["-y", "watcher-mcp"]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Configure with Codex
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
codex mcp add watcher -- npx -y watcher-mcp
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Or in `~/.codex/config.toml`:
|
|
62
|
+
|
|
63
|
+
```toml
|
|
64
|
+
[mcp_servers.watcher]
|
|
65
|
+
command = "npx"
|
|
66
|
+
args = ["-y", "watcher-mcp"]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Requirements
|
|
70
|
+
|
|
71
|
+
- Node.js 18+
|
|
72
|
+
- Watcher app running on an Android device with gateway enabled
|
|
73
|
+
- Phone and computer on the same LAN
|
|
74
|
+
|
|
75
|
+
## Tools
|
|
76
|
+
|
|
77
|
+
| Tool | Description |
|
|
78
|
+
|------|-------------|
|
|
79
|
+
| `watcher_discover_devices` | Find Watcher devices on LAN via mDNS + subnet scan |
|
|
80
|
+
| `watcher_bind_device` | Pair with a device using its URL and API key |
|
|
81
|
+
| `watcher_get_device` | Read device identity and health |
|
|
82
|
+
| `watcher_get_capabilities` | Read the gateway protocol contract |
|
|
83
|
+
| `watcher_capture_snapshot` | Get current camera frame as JPEG |
|
|
84
|
+
| `watcher_create_task` | Create a monitor or video_analysis task |
|
|
85
|
+
| `watcher_list_tasks` | List recent tasks |
|
|
86
|
+
| `watcher_get_task` | Get one task with status and events |
|
|
87
|
+
| `watcher_list_task_events` | Poll task events incrementally |
|
|
88
|
+
| `watcher_wait_for_condition` | Block until a matching event fires |
|
|
89
|
+
| `watcher_cancel_task` | Cancel a running task |
|
|
90
|
+
| `watcher_get_commentary_state` | Read live commentary state |
|
|
91
|
+
| `watcher_list_commentary_entries` | Read commentary entries |
|
|
92
|
+
|
|
93
|
+
## Typical flow
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
1. User: "备份项目当我离开工位"
|
|
97
|
+
2. Agent → watcher_bind_device (if not cached)
|
|
98
|
+
3. Agent → watcher_create_task (monitor: "detect user leaving desk")
|
|
99
|
+
4. Agent → watcher_wait_for_condition (eventDataContains: "ALERT")
|
|
100
|
+
5. Watcher detects user left
|
|
101
|
+
6. Agent → git add && git commit && git push (using its own tools)
|
|
102
|
+
7. Agent → watcher_cancel_task
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Local state
|
|
106
|
+
|
|
107
|
+
Device bindings are cached at `~/.watcher-mcp/devices.json`. This file contains your API keys — do not share it.
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
git clone https://github.com/AAswordman/Watcher.git
|
|
113
|
+
cd Watcher/mcp
|
|
114
|
+
npm install
|
|
115
|
+
npm run check # syntax validation
|
|
116
|
+
npm test # integration tests (31 assertions)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
For local development, use a direct path in `.mcp.json`:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"mcpServers": {
|
|
124
|
+
"watcher": {
|
|
125
|
+
"command": "node",
|
|
126
|
+
"args": ["./mcp/server.js"]
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Known limitations
|
|
133
|
+
|
|
134
|
+
- Enterprise networks may block phone-to-computer communication
|
|
135
|
+
- mDNS discovery depends on network multicast support
|
|
136
|
+
- The gateway is designed for trusted LAN use, not public internet
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT
|
package/lib/discovery.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { Bonjour } from "bonjour-service";
|
|
3
|
+
import { loadDevices } from "./state.js";
|
|
4
|
+
|
|
5
|
+
const PROBE_TIMEOUT_MS = 1500;
|
|
6
|
+
|
|
7
|
+
function isPrivateIpv4(address) {
|
|
8
|
+
return address.startsWith("10.") || address.startsWith("192.168.") || /^172\.(1[6-9]|2\d|3[0-1])\./.test(address);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function enumerateCandidateHosts() {
|
|
12
|
+
const candidates = new Set();
|
|
13
|
+
const interfaces = os.networkInterfaces();
|
|
14
|
+
for (const iface of Object.values(interfaces)) {
|
|
15
|
+
for (const entry of iface || []) {
|
|
16
|
+
if (entry.family !== "IPv4" || entry.internal || !isPrivateIpv4(entry.address)) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const octets = entry.address.split(".");
|
|
20
|
+
const prefix = `${octets[0]}.${octets[1]}.${octets[2]}`;
|
|
21
|
+
for (let host = 1; host <= 254; host += 1) {
|
|
22
|
+
if (host === Number(octets[3])) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
candidates.add(`${prefix}.${host}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return Array.from(candidates);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function probeHealth(baseUrl) {
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetch(`${baseUrl}/api/health`, {
|
|
35
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
|
|
36
|
+
});
|
|
37
|
+
if (!response.ok) return null;
|
|
38
|
+
const payload = await response.json();
|
|
39
|
+
const health = payload.data ?? payload;
|
|
40
|
+
if (health.status !== "ok") return null;
|
|
41
|
+
return { baseUrl, health };
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function probeIdentity(baseUrl) {
|
|
48
|
+
try {
|
|
49
|
+
const response = await fetch(`${baseUrl}/api/device/identity`, {
|
|
50
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
|
|
51
|
+
});
|
|
52
|
+
if (!response.ok) return null;
|
|
53
|
+
const payload = await response.json();
|
|
54
|
+
return payload.data ?? payload;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function probeFull(baseUrl) {
|
|
61
|
+
const health = await probeHealth(baseUrl);
|
|
62
|
+
if (!health) return null;
|
|
63
|
+
const identity = await probeIdentity(baseUrl);
|
|
64
|
+
return { baseUrl, health: health.health, identity, source: "http_probe" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function discoverDevices({ timeoutMs = 5000, ports = [8080, 8081, 8090] } = {}) {
|
|
68
|
+
const results = [];
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
const diagnostics = {
|
|
71
|
+
cachedProbe: { attempted: false, hit: false },
|
|
72
|
+
mdns: { attempted: true, servicesSeen: 0 },
|
|
73
|
+
subnetScan: { attempted: false, candidateCount: 0, scannedCount: 0 }
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Phase 0: Try cached/known devices first (instant)
|
|
77
|
+
const cached = loadDevices();
|
|
78
|
+
if (cached.length > 0) {
|
|
79
|
+
diagnostics.cachedProbe.attempted = true;
|
|
80
|
+
const cachedProbes = await Promise.all(
|
|
81
|
+
cached.map((d) => probeFull(d.baseUrl))
|
|
82
|
+
);
|
|
83
|
+
for (const result of cachedProbes) {
|
|
84
|
+
if (result) {
|
|
85
|
+
diagnostics.cachedProbe.hit = true;
|
|
86
|
+
seen.add(result.baseUrl);
|
|
87
|
+
results.push({ ...result, source: "cached" });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (results.length > 0) {
|
|
91
|
+
return { devices: results, diagnostics };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Phase 1: mDNS + subnet scan in parallel, hard timeout wins
|
|
96
|
+
const abort = new AbortController();
|
|
97
|
+
const timer = setTimeout(() => abort.abort(), timeoutMs);
|
|
98
|
+
|
|
99
|
+
const [mdnsResult, scanResult] = await Promise.all([
|
|
100
|
+
mdnsDiscover(timeoutMs, seen, diagnostics, abort.signal),
|
|
101
|
+
subnetScan(ports, seen, diagnostics, abort.signal)
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
|
|
106
|
+
if (mdnsResult) results.push(mdnsResult);
|
|
107
|
+
if (scanResult) results.push(scanResult);
|
|
108
|
+
|
|
109
|
+
return { devices: results, diagnostics };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function mdnsDiscover(timeoutMs, seen, diagnostics, abortSignal) {
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
let resolved = false;
|
|
115
|
+
const bonjour = new Bonjour();
|
|
116
|
+
const browser = bonjour.find({ type: "watcher" });
|
|
117
|
+
|
|
118
|
+
const cleanup = () => {
|
|
119
|
+
browser.stop();
|
|
120
|
+
bonjour.destroy();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const done = (value) => {
|
|
124
|
+
if (resolved) return;
|
|
125
|
+
resolved = true;
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
cleanup();
|
|
128
|
+
resolve(value);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const timer = setTimeout(() => done(null), timeoutMs);
|
|
132
|
+
abortSignal?.addEventListener("abort", () => done(null), { once: true });
|
|
133
|
+
|
|
134
|
+
browser.on("up", async (service) => {
|
|
135
|
+
diagnostics.mdns.servicesSeen += 1;
|
|
136
|
+
const host = service.referer?.address || service.addresses?.find((a) => a.includes("."));
|
|
137
|
+
const port = service.port;
|
|
138
|
+
if (!host || !port) return;
|
|
139
|
+
|
|
140
|
+
const baseUrl = `http://${host}:${port}`;
|
|
141
|
+
if (seen.has(baseUrl)) return;
|
|
142
|
+
seen.add(baseUrl);
|
|
143
|
+
|
|
144
|
+
const result = await probeFull(baseUrl);
|
|
145
|
+
if (result) {
|
|
146
|
+
done({
|
|
147
|
+
...result,
|
|
148
|
+
source: "mdns",
|
|
149
|
+
mdns: { name: service.name, txt: service.txt || {} }
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function subnetScan(ports, seen, diagnostics, abortSignal) {
|
|
157
|
+
diagnostics.subnetScan.attempted = true;
|
|
158
|
+
const candidates = [];
|
|
159
|
+
for (const host of enumerateCandidateHosts()) {
|
|
160
|
+
for (const port of ports) {
|
|
161
|
+
const baseUrl = `http://${host}:${port}`;
|
|
162
|
+
if (!seen.has(baseUrl)) {
|
|
163
|
+
seen.add(baseUrl);
|
|
164
|
+
candidates.push(baseUrl);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
diagnostics.subnetScan.candidateCount = candidates.length;
|
|
169
|
+
|
|
170
|
+
const concurrency = 128;
|
|
171
|
+
for (let i = 0; i < candidates.length; i += concurrency) {
|
|
172
|
+
if (abortSignal?.aborted) break;
|
|
173
|
+
const batch = candidates.slice(i, i + concurrency);
|
|
174
|
+
diagnostics.subnetScan.scannedCount += batch.length;
|
|
175
|
+
const probes = await Promise.all(batch.map(probeHealth));
|
|
176
|
+
const hit = probes.find((p) => p !== null);
|
|
177
|
+
if (hit) {
|
|
178
|
+
const identity = await probeIdentity(hit.baseUrl);
|
|
179
|
+
return { baseUrl: hit.baseUrl, health: hit.health, identity, source: "subnet_scan" };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
function buildHeaders({ apiKey, bindingToken, accept } = {}) {
|
|
2
|
+
const headers = {};
|
|
3
|
+
if (apiKey) {
|
|
4
|
+
headers["X-API-Key"] = apiKey;
|
|
5
|
+
}
|
|
6
|
+
if (bindingToken) {
|
|
7
|
+
headers.Authorization = `Bearer ${bindingToken}`;
|
|
8
|
+
}
|
|
9
|
+
if (accept) {
|
|
10
|
+
headers.Accept = accept;
|
|
11
|
+
}
|
|
12
|
+
return headers;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function parseResponse(response) {
|
|
16
|
+
const contentType = response.headers.get("content-type") || "";
|
|
17
|
+
if (contentType.includes("application/json")) {
|
|
18
|
+
const payload = await response.json();
|
|
19
|
+
return payload.data ?? payload;
|
|
20
|
+
}
|
|
21
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
22
|
+
return {
|
|
23
|
+
rawBytesBase64: Buffer.from(arrayBuffer).toString("base64"),
|
|
24
|
+
mimeType: contentType || "application/octet-stream"
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function request(baseUrl, path, options = {}) {
|
|
29
|
+
const timeoutMs = options.timeoutMs || 10000;
|
|
30
|
+
delete options.timeoutMs;
|
|
31
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
32
|
+
...options,
|
|
33
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
34
|
+
});
|
|
35
|
+
const payload = await parseResponse(response);
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
const error = new Error(payload.error || `HTTP ${response.status}`);
|
|
38
|
+
error.status = response.status;
|
|
39
|
+
error.payload = payload;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
return payload;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function withQuery(path, query = {}) {
|
|
46
|
+
const params = new URLSearchParams();
|
|
47
|
+
for (const [key, value] of Object.entries(query)) {
|
|
48
|
+
if (value !== undefined && value !== null && value !== "") {
|
|
49
|
+
params.set(key, String(value));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const qs = params.toString();
|
|
53
|
+
return qs ? `${path}?${qs}` : path;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function fetchHealth(baseUrl) {
|
|
57
|
+
return request(baseUrl, "/api/health");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function fetchIdentity(baseUrl) {
|
|
61
|
+
return request(baseUrl, "/api/device/identity");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function fetchCapabilities({ baseUrl, apiKey }) {
|
|
65
|
+
return request(baseUrl, "/api/capabilities", {
|
|
66
|
+
headers: buildHeaders({ apiKey })
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function pairDevice({ baseUrl, apiKey, bridgeId, bridgeName }) {
|
|
71
|
+
return request(baseUrl, "/api/device/pair", {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: {
|
|
74
|
+
...buildHeaders({ apiKey }),
|
|
75
|
+
"Content-Type": "application/json"
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify({ bridgeId, bridgeName })
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function fetchSnapshot({ baseUrl, apiKey }) {
|
|
82
|
+
const payload = await request(baseUrl, "/api/stream/snapshot", {
|
|
83
|
+
headers: buildHeaders({ apiKey, accept: "image/jpeg" })
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
mimeType: payload.mimeType,
|
|
87
|
+
dataUrl: `data:${payload.mimeType};base64,${payload.rawBytesBase64}`,
|
|
88
|
+
sizeBytes: Buffer.from(payload.rawBytesBase64, "base64").length
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function createTask({ baseUrl, apiKey, payload }) {
|
|
93
|
+
return request(baseUrl, "/api/tasks", {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: {
|
|
96
|
+
...buildHeaders({ apiKey }),
|
|
97
|
+
"Content-Type": "application/json"
|
|
98
|
+
},
|
|
99
|
+
body: JSON.stringify(payload)
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function fetchTasks({ baseUrl, apiKey }) {
|
|
104
|
+
return request(baseUrl, "/api/tasks", {
|
|
105
|
+
headers: buildHeaders({ apiKey })
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function fetchTask({ baseUrl, apiKey, taskId }) {
|
|
110
|
+
return request(baseUrl, `/api/tasks/${encodeURIComponent(taskId)}`, {
|
|
111
|
+
headers: buildHeaders({ apiKey })
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function fetchTaskEvents({ baseUrl, apiKey, taskId, afterEventId, since }) {
|
|
116
|
+
return request(
|
|
117
|
+
baseUrl,
|
|
118
|
+
withQuery(`/api/tasks/${encodeURIComponent(taskId)}/events`, {
|
|
119
|
+
afterEventId,
|
|
120
|
+
since
|
|
121
|
+
}),
|
|
122
|
+
{
|
|
123
|
+
headers: buildHeaders({ apiKey })
|
|
124
|
+
}
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function cancelTask({ baseUrl, apiKey, taskId }) {
|
|
129
|
+
return request(baseUrl, `/api/tasks/${encodeURIComponent(taskId)}`, {
|
|
130
|
+
method: "DELETE",
|
|
131
|
+
headers: buildHeaders({ apiKey })
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function fetchCommentaryState({ baseUrl, apiKey }) {
|
|
136
|
+
return request(baseUrl, "/api/commentary/state", {
|
|
137
|
+
headers: buildHeaders({ apiKey })
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function fetchCommentaryEntries({ baseUrl, apiKey, since }) {
|
|
142
|
+
return request(baseUrl, withQuery("/api/commentary/entries", { since }), {
|
|
143
|
+
headers: buildHeaders({ apiKey })
|
|
144
|
+
});
|
|
145
|
+
}
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const STATE_DIR = path.join(os.homedir(), ".watcher-mcp");
|
|
6
|
+
export const DEVICES_FILE = path.join(STATE_DIR, "devices.json");
|
|
7
|
+
|
|
8
|
+
export function ensureStateDir() {
|
|
9
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
10
|
+
}
|
package/lib/state.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
ensureStateDir,
|
|
4
|
+
DEVICES_FILE
|
|
5
|
+
} from "./paths.js";
|
|
6
|
+
|
|
7
|
+
function readJson(filePath, fallback) {
|
|
8
|
+
ensureStateDir();
|
|
9
|
+
if (!fs.existsSync(filePath)) {
|
|
10
|
+
return fallback;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
14
|
+
} catch {
|
|
15
|
+
return fallback;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeJson(filePath, value) {
|
|
20
|
+
ensureStateDir();
|
|
21
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function loadDevices() {
|
|
25
|
+
return readJson(DEVICES_FILE, []);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function saveDevices(devices) {
|
|
29
|
+
writeJson(DEVICES_FILE, devices);
|
|
30
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "watcher-mcp",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "MCP server that gives AI agents real-world perception through the Watcher Android app gateway",
|
|
7
|
+
"author": "Watcher Contributors",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/AAswordman/Watcher.git",
|
|
11
|
+
"directory": "mcp"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/AAswordman/Watcher/tree/main/mcp#readme",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"mcp",
|
|
16
|
+
"model-context-protocol",
|
|
17
|
+
"watcher",
|
|
18
|
+
"ai-agent",
|
|
19
|
+
"vision",
|
|
20
|
+
"monitoring",
|
|
21
|
+
"android",
|
|
22
|
+
"gateway",
|
|
23
|
+
"iot"
|
|
24
|
+
],
|
|
25
|
+
"bin": {
|
|
26
|
+
"watcher-mcp": "./server.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"server.js",
|
|
30
|
+
"lib/",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"start": "node ./server.js",
|
|
36
|
+
"check": "node --check ./server.js && node --check ./lib/gateway-client.js && node --check ./lib/discovery.js",
|
|
37
|
+
"test": "node ./test/integration.test.js"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
44
|
+
"bonjour-service": "^1.3.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import {
|
|
5
|
+
CallToolRequestSchema,
|
|
6
|
+
ListToolsRequestSchema
|
|
7
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
+
import {
|
|
9
|
+
fetchCapabilities,
|
|
10
|
+
fetchCommentaryEntries,
|
|
11
|
+
fetchCommentaryState,
|
|
12
|
+
fetchHealth,
|
|
13
|
+
fetchIdentity,
|
|
14
|
+
fetchTask,
|
|
15
|
+
fetchTaskEvents,
|
|
16
|
+
fetchTasks,
|
|
17
|
+
pairDevice,
|
|
18
|
+
createTask,
|
|
19
|
+
cancelTask,
|
|
20
|
+
fetchSnapshot
|
|
21
|
+
} from "./lib/gateway-client.js";
|
|
22
|
+
import { discoverDevices } from "./lib/discovery.js";
|
|
23
|
+
import { loadDevices, saveDevices } from "./lib/state.js";
|
|
24
|
+
|
|
25
|
+
const toolDefinitions = [
|
|
26
|
+
{
|
|
27
|
+
name: "watcher.discover_devices",
|
|
28
|
+
description: "Discover Watcher devices on the current LAN and return diagnostics.",
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
timeoutMs: { type: "integer", default: 2500 }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: "watcher.bind_device",
|
|
38
|
+
description: "Bind one Watcher device using its baseUrl and API key.",
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: "object",
|
|
41
|
+
required: ["baseUrl", "apiKey"],
|
|
42
|
+
properties: {
|
|
43
|
+
baseUrl: { type: "string" },
|
|
44
|
+
apiKey: { type: "string" },
|
|
45
|
+
bridgeId: { type: "string", default: "watcher-mcp" },
|
|
46
|
+
bridgeName: { type: "string", default: "Watcher MCP" }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "watcher.get_device",
|
|
52
|
+
description: "Read one bound device's identity and health.",
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: {
|
|
56
|
+
deviceId: { type: "string" },
|
|
57
|
+
baseUrl: { type: "string" }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "watcher.get_capabilities",
|
|
63
|
+
description: "Read the Watcher gateway capabilities contract.",
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: {
|
|
67
|
+
deviceId: { type: "string" },
|
|
68
|
+
baseUrl: { type: "string" }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "watcher.capture_snapshot",
|
|
74
|
+
description: "Capture the current device frame as a base64 JPEG payload.",
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: {
|
|
78
|
+
deviceId: { type: "string" },
|
|
79
|
+
baseUrl: { type: "string" }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "watcher.create_task",
|
|
85
|
+
description: "Create a Watcher task such as snapshot, monitor, or video_analysis.",
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: "object",
|
|
88
|
+
required: ["tool"],
|
|
89
|
+
properties: {
|
|
90
|
+
deviceId: { type: "string" },
|
|
91
|
+
baseUrl: { type: "string" },
|
|
92
|
+
tool: { type: "string" },
|
|
93
|
+
params: { type: "object", additionalProperties: true }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: "watcher.list_tasks",
|
|
99
|
+
description: "List recent tasks on a Watcher device.",
|
|
100
|
+
inputSchema: {
|
|
101
|
+
type: "object",
|
|
102
|
+
properties: {
|
|
103
|
+
deviceId: { type: "string" },
|
|
104
|
+
baseUrl: { type: "string" }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "watcher.get_task",
|
|
110
|
+
description: "Read one task snapshot by id.",
|
|
111
|
+
inputSchema: {
|
|
112
|
+
type: "object",
|
|
113
|
+
required: ["taskId"],
|
|
114
|
+
properties: {
|
|
115
|
+
deviceId: { type: "string" },
|
|
116
|
+
baseUrl: { type: "string" },
|
|
117
|
+
taskId: { type: "string" }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "watcher.list_task_events",
|
|
123
|
+
description: "List task events incrementally.",
|
|
124
|
+
inputSchema: {
|
|
125
|
+
type: "object",
|
|
126
|
+
required: ["taskId"],
|
|
127
|
+
properties: {
|
|
128
|
+
deviceId: { type: "string" },
|
|
129
|
+
baseUrl: { type: "string" },
|
|
130
|
+
taskId: { type: "string" },
|
|
131
|
+
afterEventId: { type: "integer" },
|
|
132
|
+
since: { type: "integer" }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: "watcher.wait_for_condition",
|
|
138
|
+
description: "Poll a task until a matching event or terminal state is observed.",
|
|
139
|
+
inputSchema: {
|
|
140
|
+
type: "object",
|
|
141
|
+
required: ["taskId"],
|
|
142
|
+
properties: {
|
|
143
|
+
deviceId: { type: "string" },
|
|
144
|
+
baseUrl: { type: "string" },
|
|
145
|
+
taskId: { type: "string" },
|
|
146
|
+
eventType: { type: "string" },
|
|
147
|
+
eventDataContains: { type: "string" },
|
|
148
|
+
timeoutMs: { type: "integer", default: 120000 },
|
|
149
|
+
pollIntervalMs: { type: "integer", default: 3000 },
|
|
150
|
+
returnOnTerminal: { type: "boolean", default: true }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "watcher.cancel_task",
|
|
156
|
+
description: "Cancel a running Watcher task.",
|
|
157
|
+
inputSchema: {
|
|
158
|
+
type: "object",
|
|
159
|
+
required: ["taskId"],
|
|
160
|
+
properties: {
|
|
161
|
+
deviceId: { type: "string" },
|
|
162
|
+
baseUrl: { type: "string" },
|
|
163
|
+
taskId: { type: "string" }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "watcher.get_commentary_state",
|
|
169
|
+
description: "Read current commentary and speech state.",
|
|
170
|
+
inputSchema: {
|
|
171
|
+
type: "object",
|
|
172
|
+
properties: {
|
|
173
|
+
deviceId: { type: "string" },
|
|
174
|
+
baseUrl: { type: "string" }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: "watcher.list_commentary_entries",
|
|
180
|
+
description: "Read commentary entries, optionally incrementally.",
|
|
181
|
+
inputSchema: {
|
|
182
|
+
type: "object",
|
|
183
|
+
properties: {
|
|
184
|
+
deviceId: { type: "string" },
|
|
185
|
+
baseUrl: { type: "string" },
|
|
186
|
+
since: { type: "integer" }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
function asToolResult(payload) {
|
|
193
|
+
return {
|
|
194
|
+
content: [
|
|
195
|
+
{
|
|
196
|
+
type: "text",
|
|
197
|
+
text: JSON.stringify(payload, null, 2)
|
|
198
|
+
}
|
|
199
|
+
]
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function getStoredDevice(args = {}) {
|
|
204
|
+
const devices = loadDevices();
|
|
205
|
+
if (args.baseUrl) {
|
|
206
|
+
return devices.find((entry) => entry.baseUrl === args.baseUrl) || null;
|
|
207
|
+
}
|
|
208
|
+
if (args.deviceId) {
|
|
209
|
+
return devices.find((entry) => entry.deviceId === args.deviceId) || null;
|
|
210
|
+
}
|
|
211
|
+
return devices[devices.length - 1] || null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function requireDevice(args = {}) {
|
|
215
|
+
const device = getStoredDevice(args);
|
|
216
|
+
if (!device) {
|
|
217
|
+
throw new Error("No bound Watcher device found. Run watcher.bind_device first.");
|
|
218
|
+
}
|
|
219
|
+
return device;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function withDevice(args = {}) {
|
|
223
|
+
const device = requireDevice(args);
|
|
224
|
+
return {
|
|
225
|
+
device,
|
|
226
|
+
baseUrl: args.baseUrl || device.baseUrl,
|
|
227
|
+
apiKey: device.apiKey
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function handleDiscover(args) {
|
|
232
|
+
return discoverDevices({ timeoutMs: args.timeoutMs || 2500 });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function handleBind(args) {
|
|
236
|
+
const pairing = await pairDevice({
|
|
237
|
+
baseUrl: args.baseUrl,
|
|
238
|
+
apiKey: args.apiKey,
|
|
239
|
+
bridgeId: args.bridgeId || "watcher-mcp",
|
|
240
|
+
bridgeName: args.bridgeName || "Watcher MCP"
|
|
241
|
+
});
|
|
242
|
+
const [identity, health] = await Promise.all([
|
|
243
|
+
fetchIdentity(args.baseUrl),
|
|
244
|
+
fetchHealth(args.baseUrl)
|
|
245
|
+
]);
|
|
246
|
+
const devices = loadDevices().filter((entry) => entry.deviceId !== pairing.deviceId && entry.baseUrl !== args.baseUrl);
|
|
247
|
+
devices.push({
|
|
248
|
+
bridgeId: pairing.bridgeId,
|
|
249
|
+
bridgeName: pairing.bridgeName,
|
|
250
|
+
deviceId: pairing.deviceId,
|
|
251
|
+
bindingToken: pairing.bindingToken,
|
|
252
|
+
baseUrl: args.baseUrl,
|
|
253
|
+
apiKey: args.apiKey,
|
|
254
|
+
identity,
|
|
255
|
+
health,
|
|
256
|
+
pairedAt: pairing.createdAt
|
|
257
|
+
});
|
|
258
|
+
saveDevices(devices);
|
|
259
|
+
return {
|
|
260
|
+
paired: pairing,
|
|
261
|
+
identity,
|
|
262
|
+
health,
|
|
263
|
+
storedDeviceCount: devices.length
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function handleGetDevice(args) {
|
|
268
|
+
const { device, baseUrl } = withDevice(args);
|
|
269
|
+
const [identity, health] = await Promise.all([
|
|
270
|
+
fetchIdentity(baseUrl),
|
|
271
|
+
fetchHealth(baseUrl)
|
|
272
|
+
]);
|
|
273
|
+
const updatedDevices = loadDevices().map((entry) =>
|
|
274
|
+
entry.deviceId === device.deviceId ? { ...entry, identity, health, baseUrl } : entry
|
|
275
|
+
);
|
|
276
|
+
saveDevices(updatedDevices);
|
|
277
|
+
return {
|
|
278
|
+
deviceId: device.deviceId,
|
|
279
|
+
baseUrl,
|
|
280
|
+
identity,
|
|
281
|
+
health
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function handleCapabilities(args) {
|
|
286
|
+
const { device, baseUrl, apiKey } = withDevice(args);
|
|
287
|
+
const capabilities = await fetchCapabilities({ baseUrl, apiKey });
|
|
288
|
+
return {
|
|
289
|
+
deviceId: device.deviceId,
|
|
290
|
+
baseUrl,
|
|
291
|
+
capabilities
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function handleSnapshot(args) {
|
|
296
|
+
const { device, baseUrl, apiKey } = withDevice(args);
|
|
297
|
+
const snapshot = await fetchSnapshot({ baseUrl, apiKey });
|
|
298
|
+
return {
|
|
299
|
+
deviceId: device.deviceId,
|
|
300
|
+
baseUrl,
|
|
301
|
+
...snapshot
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function handleCreateTask(args) {
|
|
306
|
+
const { device, baseUrl, apiKey } = withDevice(args);
|
|
307
|
+
const params = args.params && typeof args.params === "object" ? args.params : {};
|
|
308
|
+
const task = await createTask({
|
|
309
|
+
baseUrl,
|
|
310
|
+
apiKey,
|
|
311
|
+
payload: {
|
|
312
|
+
tool: args.tool,
|
|
313
|
+
...params
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
return {
|
|
317
|
+
deviceId: device.deviceId,
|
|
318
|
+
baseUrl,
|
|
319
|
+
task
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function handleListTasks(args) {
|
|
324
|
+
const { device, baseUrl, apiKey } = withDevice(args);
|
|
325
|
+
const tasks = await fetchTasks({ baseUrl, apiKey });
|
|
326
|
+
return {
|
|
327
|
+
deviceId: device.deviceId,
|
|
328
|
+
baseUrl,
|
|
329
|
+
tasks
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function handleGetTask(args) {
|
|
334
|
+
const { device, baseUrl, apiKey } = withDevice(args);
|
|
335
|
+
const task = await fetchTask({ baseUrl, apiKey, taskId: args.taskId });
|
|
336
|
+
return {
|
|
337
|
+
deviceId: device.deviceId,
|
|
338
|
+
baseUrl,
|
|
339
|
+
task
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function handleListTaskEvents(args) {
|
|
344
|
+
const { device, baseUrl, apiKey } = withDevice(args);
|
|
345
|
+
const events = await fetchTaskEvents({
|
|
346
|
+
baseUrl,
|
|
347
|
+
apiKey,
|
|
348
|
+
taskId: args.taskId,
|
|
349
|
+
afterEventId: args.afterEventId,
|
|
350
|
+
since: args.since
|
|
351
|
+
});
|
|
352
|
+
return {
|
|
353
|
+
deviceId: device.deviceId,
|
|
354
|
+
baseUrl,
|
|
355
|
+
taskId: args.taskId,
|
|
356
|
+
events
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function eventMatches(event, args) {
|
|
361
|
+
if (args.eventType && event.type !== args.eventType) {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
if (args.eventDataContains) {
|
|
365
|
+
const haystack = JSON.stringify(event.data ?? event.payload ?? {});
|
|
366
|
+
if (!haystack.includes(args.eventDataContains)) {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function handleWaitForCondition(args) {
|
|
374
|
+
const { device, baseUrl, apiKey } = withDevice(args);
|
|
375
|
+
const timeoutMs = args.timeoutMs || 120000;
|
|
376
|
+
const pollIntervalMs = args.pollIntervalMs || 3000;
|
|
377
|
+
const returnOnTerminal = args.returnOnTerminal !== false;
|
|
378
|
+
const deadline = Date.now() + timeoutMs;
|
|
379
|
+
let afterEventId = 0;
|
|
380
|
+
|
|
381
|
+
while (Date.now() < deadline) {
|
|
382
|
+
const events = await fetchTaskEvents({
|
|
383
|
+
baseUrl,
|
|
384
|
+
apiKey,
|
|
385
|
+
taskId: args.taskId,
|
|
386
|
+
afterEventId
|
|
387
|
+
});
|
|
388
|
+
if (events.length > 0) {
|
|
389
|
+
afterEventId = Math.max(...events.map((entry) => entry.id || 0), afterEventId);
|
|
390
|
+
const matched = events.find((event) => eventMatches(event, args));
|
|
391
|
+
if (matched) {
|
|
392
|
+
return {
|
|
393
|
+
deviceId: device.deviceId,
|
|
394
|
+
baseUrl,
|
|
395
|
+
taskId: args.taskId,
|
|
396
|
+
matched: true,
|
|
397
|
+
reason: "event_match",
|
|
398
|
+
event: matched
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const task = await fetchTask({ baseUrl, apiKey, taskId: args.taskId });
|
|
404
|
+
if (returnOnTerminal && ["Completed", "Failed", "Cancelled"].includes(task.status)) {
|
|
405
|
+
return {
|
|
406
|
+
deviceId: device.deviceId,
|
|
407
|
+
baseUrl,
|
|
408
|
+
taskId: args.taskId,
|
|
409
|
+
matched: false,
|
|
410
|
+
reason: "task_terminal",
|
|
411
|
+
task
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const finalTask = await fetchTask({ baseUrl, apiKey, taskId: args.taskId });
|
|
419
|
+
return {
|
|
420
|
+
deviceId: device.deviceId,
|
|
421
|
+
baseUrl,
|
|
422
|
+
taskId: args.taskId,
|
|
423
|
+
matched: false,
|
|
424
|
+
reason: "timeout",
|
|
425
|
+
task: finalTask
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function handleCancelTask(args) {
|
|
430
|
+
const { device, baseUrl, apiKey } = withDevice(args);
|
|
431
|
+
const result = await cancelTask({ baseUrl, apiKey, taskId: args.taskId });
|
|
432
|
+
return {
|
|
433
|
+
deviceId: device.deviceId,
|
|
434
|
+
baseUrl,
|
|
435
|
+
taskId: args.taskId,
|
|
436
|
+
result
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function handleCommentaryState(args) {
|
|
441
|
+
const { device, baseUrl, apiKey } = withDevice(args);
|
|
442
|
+
const state = await fetchCommentaryState({ baseUrl, apiKey });
|
|
443
|
+
return {
|
|
444
|
+
deviceId: device.deviceId,
|
|
445
|
+
baseUrl,
|
|
446
|
+
state
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function handleCommentaryEntries(args) {
|
|
451
|
+
const { device, baseUrl, apiKey } = withDevice(args);
|
|
452
|
+
const entries = await fetchCommentaryEntries({ baseUrl, apiKey, since: args.since });
|
|
453
|
+
return {
|
|
454
|
+
deviceId: device.deviceId,
|
|
455
|
+
baseUrl,
|
|
456
|
+
entries
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const handlers = new Map([
|
|
461
|
+
["watcher.discover_devices", handleDiscover],
|
|
462
|
+
["watcher.bind_device", handleBind],
|
|
463
|
+
["watcher.get_device", handleGetDevice],
|
|
464
|
+
["watcher.get_capabilities", handleCapabilities],
|
|
465
|
+
["watcher.capture_snapshot", handleSnapshot],
|
|
466
|
+
["watcher.create_task", handleCreateTask],
|
|
467
|
+
["watcher.list_tasks", handleListTasks],
|
|
468
|
+
["watcher.get_task", handleGetTask],
|
|
469
|
+
["watcher.list_task_events", handleListTaskEvents],
|
|
470
|
+
["watcher.wait_for_condition", handleWaitForCondition],
|
|
471
|
+
["watcher.cancel_task", handleCancelTask],
|
|
472
|
+
["watcher.get_commentary_state", handleCommentaryState],
|
|
473
|
+
["watcher.list_commentary_entries", handleCommentaryEntries]
|
|
474
|
+
]);
|
|
475
|
+
|
|
476
|
+
const server = new Server(
|
|
477
|
+
{
|
|
478
|
+
name: "watcher-mcp",
|
|
479
|
+
version: "0.2.0"
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
capabilities: {
|
|
483
|
+
tools: {}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
489
|
+
tools: toolDefinitions
|
|
490
|
+
}));
|
|
491
|
+
|
|
492
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
493
|
+
const name = request.params.name;
|
|
494
|
+
const args = request.params.arguments || {};
|
|
495
|
+
const handler = handlers.get(name);
|
|
496
|
+
if (!handler) {
|
|
497
|
+
return {
|
|
498
|
+
content: [{ type: "text", text: JSON.stringify({ error: `Unknown tool: ${name}` }) }],
|
|
499
|
+
isError: true
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
return asToolResult(await handler(args));
|
|
504
|
+
} catch (err) {
|
|
505
|
+
const detail = {
|
|
506
|
+
error: err.message || String(err),
|
|
507
|
+
status: err.status || null,
|
|
508
|
+
tool: name
|
|
509
|
+
};
|
|
510
|
+
if (err.cause?.code === "UND_ERR_CONNECT_TIMEOUT" || err.name === "TimeoutError") {
|
|
511
|
+
detail.hint = "Device unreachable. Check network connectivity and that the Watcher gateway is running.";
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
content: [{ type: "text", text: JSON.stringify(detail, null, 2) }],
|
|
515
|
+
isError: true
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const transport = new StdioServerTransport();
|
|
521
|
+
await server.connect(transport);
|