Model Context Pleasure.
MCP server for DG-Lab Coyote e-stim devices, driven through the official DG-LAB 4 phone app. The phone does all the Bluetooth; this server speaks the app's socket protocol directly over your LAN. No cloud, no account traffic through us, no extra hardware. Any MCP client — Claude Desktop, Claude Code, anything that speaks stdio MCP — gets a tool surface for power, waveform patterns, and session control, with a safety model designed for agents that are not realtime.
MCP client (Claude Desktop / Claude Code / ...)
│ stdio (MCP)
coyote-mcp (this, Node)
│ LAN websocket (DG-Lab V4 socket protocol)
DG-LAB 4 app on your phone
│ Bluetooth
Coyote 2.0 / 3.0
Direct-BLE control (this repo's previous life, and every fork that tried) fights the host Bluetooth stack; the vendor app's BLE loop is smooth on real hardware. So the app stays in the loop, and instead of routing through DG-Lab's stateless relay server, coyote-mcp implements the app-facing side of the relay wire protocol itself — written clean-room from captured traffic, no vendor code. That buys a static session id: the pairing QR never changes, and the app's reconnect button works across server restarts. You scan once, basically ever.
Full decision record and wire-protocol notes: docs/SPEC.md.
- Node.js >= 20 (
npm installpulls the few pure-JS deps) - DG-LAB 4 app on a phone on the same Wi-Fi, Coyote paired in the app
- An MCP client
git clone https://github.com/EcstasyEngineer/coyote-mcp.git
cd coyote-mcp
npm installRegister in your MCP client. Prefer a user/global MCP config (not a committed repo file) so machine paths never land in git:
Claude Code — e.g. ~/.claude/settings.json / project .mcp.json (gitignored):
{
"mcpServers": {
"coyote": {
"command": "node",
"args": ["/absolute/path/to/coyote-mcp/src/main.js"]
}
}
}Claude Desktop — claude_desktop_config.json, same shape.
Then restart the client so it spawns the server.
- Call the
starttool. It returns a pairing QR (ASCII + URL). - In the DG-LAB app, open the SOCKET / remote-control feature and scan it. The QR is stable across server restarts (static session id), and the app's reconnect button works after a server restart.
change_power({ channel: "A", delta: 5 })— ramps power up one notch at a time.set_pattern({ channel: "A", name: "stroke" })— loops a waveform pattern.pausefor an intentional break (resumerestores),stopfor end-of-scene.
Heads-up: if a channel is muted in the app, everything will report success and you will feel nothing. Check the app's channel mute toggles first.
| Tool | What it does |
|---|---|
start |
Begin listening + return the pairing QR. Idempotent. |
get_state |
The full state snapshot (device, channels, patterns, limits). |
change_power |
Relative power change (see contract below). Optional step_ms per notch. |
clear_power_queue |
Cancel pending ramp: freeze target at current actual (channel or both). |
pause |
Zero both channels, clear waveforms, halt feeding. Remembers patterns + power for resume. |
resume |
After pause: restore remembered patterns; ramp power back to remembered targets. |
stop |
Hard stop: zero, clear queues/patterns, forget the session (resume has nothing). |
set_pattern |
Loop a named pattern on a channel (or null to clear). Flushes the pulse queue so swaps are immediate. |
play_once |
One loop + ~3s silent tail in the buffer, then hard clear mid-tail (so timing jitter can’t re-hit the pattern head). Blocks until clear. |
add_pattern / edit_pattern / remove_pattern |
Manage custom patterns (saved to user-patterns.json; built-ins protected). |
pause / resume is the intentional break pair. stop is end-of-scene.
Power always re-ramps from 0 after pause/stop because the app rejects nonzero
absolute intensity sets over the socket — only relative ±1 steps are legal going up.
Every tool response carries the current state snapshot, including pending ramp steps, so agents always know present and queued-future state. There is no log surface — incoming app frames are folded into state and discarded.
Agents are not realtime, so one call can request a larger climb without a chat round-trip per notch:
| Direction | Per-call size | Step rate |
|---|---|---|
| Increase | +1..+10 |
default 500ms/notch (optional slower step_ms) |
| Increase | +11..+30 |
requires step_ms >= 5000 (slow climb, like app auto-increase) |
| Decrease | any size (floor 0) | default 500ms/notch; optional faster down to 100ms; no slow-gate |
clear_power_queue cancels whatever is still pending. Comfort/intensity caps
from the app still bound the absolute target. Over-limit requests are dropped
with errors that teach the live contract.
Each frame is 100ms of {freq, intensity}; a pattern loops. Deduped to one role
each (rolling/throb removed as near-dupes of stroke/pulse):
| Name | Feel |
|---|---|
milk |
1.5s sigmoid rise 0→100 |
milk-fast |
0.3s version of milk |
drip |
Double-tap tell → land/decay → long empty tail (tells at head so once-play after a delay still braces) |
full-on |
Constant intensity 100 |
buzz |
Steady high intensity with slight shimmer |
stroke |
5s intensity sine 0↔100 (stroking/squeezing AM) |
pulse |
Sharp metronome taps with silence between |
edge |
~5s climb then 1s hard zero (tease/deny) |
flutter |
Fast intensity chatter |
Built-ins cover common feels; anything else is add_pattern then set_pattern / play_once:
// tools/call add_pattern
{
"name": "tap",
"frames": [
{ "freq": 15, "intensity": 100 },
{ "freq": 15, "intensity": 0 },
{ "freq": 15, "intensity": 0 },
{ "freq": 15, "intensity": 0 },
{ "freq": 15, "intensity": 0 },
{ "freq": 15, "intensity": 0 },
{ "freq": 15, "intensity": 0 },
{ "freq": 15, "intensity": 0 },
{ "freq": 15, "intensity": 0 },
{ "freq": 15, "intensity": 0 }
]
}
// → 1.0s loop: sharp hit + rest. Then set_pattern({ channel: "A", name: "tap" })Rules: each frame = 100ms; freq 10–240 (app units, not Hz); intensity 0–100;
1–600 frames; name [a-zA-Z0-9_-]{1,32}; persisted to user-patterns.json
(or COYOTE_MCP_PATTERNS_FILE) on add/edit/remove — survives restarts; built-in
names are reserved and never written to disk.
| Var | Default | Meaning |
|---|---|---|
COYOTE_MCP_PORT |
9998 |
Websocket port the app connects to |
COYOTE_MCP_TID |
coyote |
Static session id baked into the QR |
COYOTE_MCP_HOST |
auto | LAN IP override for the QR (auto prefers 192.168/10/172.16 and avoids CGNAT) |
COYOTE_MCP_POWER_FAST_MAX |
10 |
Max increase without requiring slow step_ms |
COYOTE_MCP_POWER_SLOW_MAX |
30 |
Max increase per call (with slow step) |
COYOTE_MCP_POWER_SLOW_MIN_MS |
5000 |
Min ms/notch when increase > fast max |
COYOTE_MCP_POWER_STEP_INTERVAL_MS |
500 |
Default ms per notch |
COYOTE_MCP_POWER_DOWN_MIN_MS |
100 |
Fastest allowed decrease step |
COYOTE_MCP_PATTERNS_FILE |
./user-patterns.json |
Custom pattern store (JSON) |
COYOTE_MCP_ONCE_TAIL_SILENCE_MS |
3000 |
Silent padding after play_once content |
COYOTE_MCP_ONCE_CLEAR_SLACK_MS |
200 |
Clear this far into the silence tail |
- Power moves only through rate-limited ramps (relative ±1 ops confirmed by app
acks; single-flight; overshoot-hardened — see
docs/SPEC.md). Large increases must be slow; decreases may be large and fast. Pending ramps are cancellable. - The app's own comfort limits are read and respected as caps.
pause/stopuse the app-sanctioned absolute set-to-zero and clear queues.- On every app (re)connect the server assumes nothing: targets zero, lingering
physical power ramps down. Stimulation never silently resumes;
resumeafterpauseis the deliberate way back. - The phone app remains an independent stop layer. The device wheel still moves intensity, but our target is authoritative: if you wheel above the MCP target while settled, the ramp walks intensity back down to target (agent holds the leash). Snap-target-to-wheel is not implemented.
- Battery level is not exposed on the app socket path we use (no field in
devices.get/ snapshots today).
Known quirk: MCP pause is our safety stop (power zeroed, queues cleared), not
the app's own play/pause control — the app UI may still show the remote session
as "playing" and keep animating the last waveform chart while nothing is being
delivered. Cosmetic; see docs/SPEC.md §5.
Wire logs (>>/<<) go to stderr for humans; stdout is protocol-pure.
- Offline pattern-store self-test (no hardware, no stdio/ws):
COYOTE_MCP_SELFTEST=patterns node src/main.js— exercises add/edit/remove/reload against a temp file and exits. - Full smoke test without hardware: a fake-app driver that answers
devices.getand acks ops — recipe indocs/SPEC.md's dev section, recreate as needed.
Active, v0.4.2. The whole surface — pairing, reconnect, ramps, pattern swaps,
play_once, pause/resume — is validated live on Coyote 2.0 hardware; Coyote 3.0
is untested but uses the identical app-plane protocol (the app handles the
device side). Open items are tracked in docs/SPEC.md §7 and the issue tracker.