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 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
@@ -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);