Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

coyote-mcp

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

Why this design

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.

Requirements

  • Node.js >= 20 (npm install pulls 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

Install

git clone https://github.com/EcstasyEngineer/coyote-mcp.git
cd coyote-mcp
npm install

Register 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 Desktopclaude_desktop_config.json, same shape.

Then restart the client so it spawns the server.

Quick start

  1. Call the start tool. It returns a pairing QR (ASCII + URL).
  2. 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.
  3. change_power({ channel: "A", delta: 5 }) — ramps power up one notch at a time.
  4. set_pattern({ channel: "A", name: "stroke" }) — loops a waveform pattern.
  5. pause for an intentional break (resume restores), stop for 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.

Tools

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.

Power contract (change_power)

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.

Built-in patterns

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

Custom patterns

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.

Configuration (env vars)

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

Safety model

  • 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 / stop use 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; resume after pause is 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.

Development

  • 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.get and acks ops — recipe in docs/SPEC.md's dev section, recreate as needed.

Status

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.

About

MCP server for DG-Lab Coyote e-stim via the DG-LAB 4 app socket protocol (LAN, no cloud relay)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages