@vdoninja/mcp 0.4.1 → 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/LICENSE +21 -661
- package/README.md +59 -272
- package/assets/browser-agent/audio-worklet.js +17 -0
- package/assets/browser-agent/client.js +124 -0
- package/assets/browser-agent/index.html +50 -0
- package/assets/cloudflare/worker-proxy.js +3 -0
- package/docs/.nojekyll +0 -0
- package/docs/404.html +28 -0
- package/docs/advanced-diagnostics.html +101 -0
- package/docs/assets/apple-touch-icon.png +0 -0
- package/docs/assets/favicon.svg +7 -0
- package/docs/assets/how-it-works.svg +62 -0
- package/docs/assets/icon-192.png +0 -0
- package/docs/assets/icon-512.png +0 -0
- package/docs/assets/icon-maskable-512.png +0 -0
- package/docs/assets/icons.svg +42 -0
- package/docs/assets/logo.svg +7 -0
- package/docs/assets/og-image.png +0 -0
- package/docs/assets/site.css +735 -0
- package/docs/assets/site.js +289 -0
- package/docs/browser-agent.html +122 -0
- package/docs/browser-worker.html +165 -0
- package/docs/client-config-examples.html +254 -0
- package/docs/direction.html +175 -0
- package/docs/favicon.ico +0 -0
- package/docs/guide.html +872 -0
- package/docs/http-relay.html +154 -0
- package/docs/index.html +834 -0
- package/docs/mcp-tool-contract.html +226 -0
- package/docs/quickstart-and-compat.html +222 -0
- package/docs/site.webmanifest +16 -0
- package/docs/sitemap.xml +63 -0
- package/docs/ssapp-integration.html +594 -0
- package/docs/vdo-ninja-p2p-data-notes.html +225 -0
- package/package.json +51 -9
- package/references/advanced-diagnostics.md +2 -0
- package/references/browser-agent.md +53 -0
- package/references/browser-worker.md +142 -0
- package/references/client-config-examples.md +2 -0
- package/references/direction.md +88 -0
- package/references/http-relay.md +107 -0
- package/references/mcp-tool-contract.md +11 -1
- package/references/quickstart-and-compat.md +4 -1
- package/references/ssapp-integration.md +371 -0
- package/references/vdo-ninja-p2p-data-notes.md +2 -0
- package/scripts/demo-file-workflow.js +3 -0
- package/scripts/demo-message-workflow.js +3 -0
- package/scripts/install-mcp.js +5 -2
- package/scripts/lib/bridge-session.js +143 -26
- package/scripts/lib/bridge-utils.js +3 -0
- package/scripts/lib/browser-agent.js +255 -0
- package/scripts/lib/browser-media.js +79 -0
- package/scripts/lib/browser-routes.js +119 -0
- package/scripts/lib/browser-rpc.js +214 -0
- package/scripts/lib/browser-setup.js +133 -0
- package/scripts/lib/browser-worker.js +216 -0
- package/scripts/lib/fake-network-sdk.js +3 -0
- package/scripts/lib/http-relay-transport.js +294 -0
- package/scripts/lib/load-vdo-sdk.js +3 -0
- package/scripts/lib/mcp-stdio-parser.js +3 -0
- package/scripts/lib/ssapp-worker.js +198 -0
- package/scripts/lib/tool-profiles.js +3 -0
- package/scripts/vdo-data-bridge.js +3 -0
- package/scripts/vdo-mcp-browser-agent.js +132 -0
- package/scripts/vdo-mcp-browser.js +218 -0
- package/scripts/vdo-mcp-relay.js +207 -0
- package/scripts/vdo-mcp-server.js +50 -7
- package/scripts/vdo-mcp-ssapp.js +208 -0
- package/scripts/vdo-mcp-streamable-http.js +23 -4
- package/server.json +22 -4
- package/LICENSE-SDK-EXCEPTION +0 -54
package/README.md
CHANGED
|
@@ -1,324 +1,111 @@
|
|
|
1
1
|
# VDO.Ninja MCP (`@vdoninja/mcp`)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Give an MCP client live tools for exchanging messages, files, and small shared-state updates with peers on other computers. The MCP server manages active connections while your AI client calls the tools and consumes incoming events.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
WebRTC is the default peer transport. An optional [self-hosted HTTP relay](https://steveseguin.github.io/ninjamcp/http-relay.html) works when WebRTC is unavailable.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
[Package](https://www.npmjs.com/package/@vdoninja/mcp) | [HTML guides](https://steveseguin.github.io/ninjamcp/guide.html#guides) | [Client setup](https://steveseguin.github.io/ninjamcp/client-config-examples.html) | [Tool reference](https://steveseguin.github.io/ninjamcp/mcp-tool-contract.html) | [Support](https://discord.vdo.ninja)
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
HTML guides are also included in the package's `docs/` folder for offline reading.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
## Get started
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Install from a stable folder; the installer registers an absolute script path with your MCP client.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
```json
|
|
20
|
-
{ "name": "vdo_connect", "arguments": { "room": "family_calendar", "stream_id": "planner" } }
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
A second agent joins the same room, then sends it a message:
|
|
24
|
-
|
|
25
|
-
```json
|
|
26
|
-
{ "name": "vdo_connect", "arguments": { "room": "family_calendar", "stream_id": "reviewer", "target_stream_id": "planner" } }
|
|
27
|
-
{ "name": "vdo_send", "arguments": { "session_id": "<reviewer-session>", "data": { "text": "I checked the plan. Tuesday has a conflict." } } }
|
|
15
|
+
```bash
|
|
16
|
+
npm install @vdoninja/mcp
|
|
17
|
+
npx vdon-mcp-install
|
|
28
18
|
```
|
|
29
19
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
## Which VDO.Ninja Package Do I Need?
|
|
33
|
-
|
|
34
|
-
| Your goal | Use |
|
|
35
|
-
| --- | --- |
|
|
36
|
-
| Add connect/send/file/state tools to an MCP client | **`@vdoninja/mcp`** |
|
|
37
|
-
| Give agents a persistent room and local inbox | [`@vdoninja/ninja-p2p`](https://github.com/steveseguin/ninja-p2p) |
|
|
38
|
-
| Build directly with WebRTC media or data channels | [`@vdoninja/sdk`](https://github.com/steveseguin/ninjasdk) |
|
|
39
|
-
|
|
40
|
-
This package uses VDO.Ninja's existing room, discovery, and WebRTC behavior. It does not add signaling-server commands. `@vdoninja/sdk` and the Node WebRTC runtime install as package dependencies.
|
|
41
|
-
|
|
42
|
-
## Why This MCP
|
|
43
|
-
|
|
44
|
-
- Open source (`AGPL-3.0-only` with additional unmodified-distribution exception) and free to use.
|
|
45
|
-
- Peer-to-peer first: no central relay required for normal data flow.
|
|
46
|
-
- End-to-end encrypted WebRTC data transport (DTLS + SCTP data channels).
|
|
47
|
-
- Reliable data channel mode available for ordered/loss-recovered delivery.
|
|
48
|
-
- Low infrastructure overhead for bot-to-bot messaging and file sync.
|
|
49
|
-
- Works for local LAN, remote internet peers, and hybrid topologies.
|
|
50
|
-
- TURN support improves connectivity when direct P2P is blocked.
|
|
51
|
-
- Good fit for multi-agent coordination without exposing plaintext payloads to a middlebox.
|
|
52
|
-
|
|
53
|
-
## Common and Interesting Use Cases
|
|
54
|
-
|
|
55
|
-
- AI swarm coordination: multiple agents in a private room exchanging tasks, state, and results.
|
|
56
|
-
- Bot-to-bot handoff: one agent collects context, another executes, another validates, all over P2P.
|
|
57
|
-
- Private file exchange: send logs, artifacts, prompts, and generated outputs directly between agents.
|
|
58
|
-
- Human + AI war room: operator plus several bots sharing near-real-time status updates.
|
|
59
|
-
- Edge + cloud hybrid: local on-device agent syncs with remote agents without standing up a custom tunnel.
|
|
60
|
-
- Temporary secure collaboration spaces: ad-hoc rooms with join tokens and peer allowlists.
|
|
61
|
-
- Cross-tool interop: Codex and Claude instances sharing updates through the same bridge contract.
|
|
62
|
-
|
|
63
|
-
## Fun Agent-to-Agent Rooms
|
|
64
|
-
|
|
65
|
-
These are real patterns teams are starting to use:
|
|
66
|
-
|
|
67
|
-
- Claude Code to Codex CLI pair mode where one agent asks for a second opinion and gets patch/test feedback.
|
|
68
|
-
- OpenClaw-to-OpenClaw specialist rooms where one local assistant asks another for tool-specific help.
|
|
69
|
-
- Small LLM idea circles (3-5 agents) sharing proposals, critiques, and final votes.
|
|
70
|
-
- Private "night shift" automation where bots sync status/events/files without broad network exposure.
|
|
71
|
-
|
|
72
|
-
OpenClaw project link:
|
|
20
|
+
The installer supports Codex and Claude Code. Use `--codex-only` or `--claude-only` to choose one, or follow the [manual setup examples](https://steveseguin.github.io/ninjamcp/client-config-examples.html) for other MCP clients. Your client launches the server; `npx vdon-mcp-server` starts it directly for manual use.
|
|
73
21
|
|
|
74
|
-
|
|
22
|
+
Ask the first agent to connect:
|
|
75
23
|
|
|
76
|
-
## Quick Example: Claude + Codex Helper Loop
|
|
77
|
-
|
|
78
|
-
1. Claude-side session:
|
|
79
|
-
```json
|
|
80
|
-
{
|
|
81
|
-
"name": "vdo_connect",
|
|
82
|
-
"arguments": {
|
|
83
|
-
"room": "agent_help_room",
|
|
84
|
-
"stream_id": "claude_agent"
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
2. Codex-side session:
|
|
90
24
|
```json
|
|
91
|
-
{
|
|
92
|
-
"name": "vdo_connect",
|
|
93
|
-
"arguments": {
|
|
94
|
-
"room": "agent_help_room",
|
|
95
|
-
"stream_id": "codex_agent",
|
|
96
|
-
"target_stream_id": "claude_agent"
|
|
97
|
-
}
|
|
98
|
-
}
|
|
25
|
+
{ "name": "vdo_connect", "arguments": { "room": "project_room", "stream_id": "planner" } }
|
|
99
26
|
```
|
|
100
27
|
|
|
101
|
-
|
|
102
|
-
```json
|
|
103
|
-
{
|
|
104
|
-
"name": "vdo_send",
|
|
105
|
-
"arguments": {
|
|
106
|
-
"session_id": "<claude_session>",
|
|
107
|
-
"data": {
|
|
108
|
-
"type": "help.request",
|
|
109
|
-
"task": "review patch for race condition"
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
```
|
|
28
|
+
Ask the second agent to join and connect to the first:
|
|
114
29
|
|
|
115
|
-
4. Codex replies:
|
|
116
30
|
```json
|
|
117
|
-
{
|
|
118
|
-
"name": "vdo_send",
|
|
119
|
-
"arguments": {
|
|
120
|
-
"session_id": "<codex_session>",
|
|
121
|
-
"data": {
|
|
122
|
-
"type": "help.reply",
|
|
123
|
-
"summary": "found 2 edge cases",
|
|
124
|
-
"next_steps": ["add timeout guard", "add retry test"]
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
## High-Value AI Advantages
|
|
131
|
-
|
|
132
|
-
- Agents share messages/files/state directly, without opening broad machine-to-machine network access.
|
|
133
|
-
- You can limit tool access with profiles (`core|file|state|full`) so each agent only gets what it needs.
|
|
134
|
-
- Rooms can be short-lived: create for a task, then close when done.
|
|
135
|
-
- Easy to start: no private subnet setup, no custom tunnel setup.
|
|
136
|
-
- Works between browser peers and Node peers.
|
|
137
|
-
- Data channels can be reliable and ordered, which is good for control messages.
|
|
138
|
-
- Security options are built in: join tokens, stream allowlists, and session MAC checks.
|
|
139
|
-
|
|
140
|
-
## Clever Real-World Patterns
|
|
141
|
-
|
|
142
|
-
- Reviewer team: one writer agent, several reviewer agents, and one decider agent sharing feedback in the same room.
|
|
143
|
-
- State recovery: agents can re-sync shared state with `vdo_state_sync` after reconnects.
|
|
144
|
-
- File pipeline: one agent sends artifacts, another indexes them, another summarizes them.
|
|
145
|
-
- Human override: a person can join the room to inspect progress and step in when needed.
|
|
146
|
-
- Local-first flow: keep sensitive work local and only send required outputs to remote agents.
|
|
147
|
-
- Burst jobs: create short-lived agent groups per task instead of running a permanent private network.
|
|
148
|
-
|
|
149
|
-
## Compared with VPN Overlays (e.g., Tailscale)
|
|
150
|
-
|
|
151
|
-
Where this MCP is often better for AI workflows:
|
|
152
|
-
|
|
153
|
-
- You need agent-to-agent messaging/file/state, not full network access between hosts.
|
|
154
|
-
- You want tool-level permissions instead of giving every machine broad private-network reach.
|
|
155
|
-
- You want browser and Node agents working together quickly.
|
|
156
|
-
- You want temporary collaboration sessions without managing a permanent virtual network.
|
|
157
|
-
|
|
158
|
-
Where overlays are often better:
|
|
159
|
-
|
|
160
|
-
- You need always-on private IP connectivity across many services.
|
|
161
|
-
- You need many protocols (databases, SSH, internal web apps) over one virtual network.
|
|
162
|
-
- You need centralized network policy and audit at the infrastructure layer.
|
|
163
|
-
|
|
164
|
-
Practical framing:
|
|
165
|
-
|
|
166
|
-
- This MCP is not a full replacement for Tailscale.
|
|
167
|
-
- It is a strong complement for AI-native, data-channel-first collaboration.
|
|
168
|
-
|
|
169
|
-
## Transport Truth
|
|
170
|
-
|
|
171
|
-
- This is **WebRTC data transport**, not a generic TCP/SSH tunnel.
|
|
172
|
-
- Data channels can run in reliable/ordered mode (SCTP over DTLS).
|
|
173
|
-
- Default behavior is direct P2P when possible (great for LAN/local-network speed and low latency).
|
|
174
|
-
- Privacy mode is opt-in via `force_turn=true`: this can reduce direct IP exposure between peers, but may reduce connection success in some environments.
|
|
175
|
-
- TURN improves success on restrictive networks, but it is not a guaranteed firewall bypass for every enterprise policy.
|
|
176
|
-
|
|
177
|
-
## Quickstart (No Clone)
|
|
178
|
-
|
|
179
|
-
Install from a stable folder. The installer stores an absolute script path in Codex/Claude config.
|
|
180
|
-
|
|
181
|
-
```bash
|
|
182
|
-
npm i @vdoninja/mcp
|
|
183
|
-
npx vdon-mcp-install
|
|
184
|
-
npx vdon-mcp-server
|
|
31
|
+
{ "name": "vdo_connect", "arguments": { "room": "project_room", "stream_id": "reviewer", "target_stream_id": "planner" } }
|
|
185
32
|
```
|
|
186
33
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
First MCP call (recommended):
|
|
34
|
+
Wait for the peer to appear in `vdo_sync_peers`, then send a message using the returned session ID:
|
|
190
35
|
|
|
191
36
|
```json
|
|
192
|
-
{ "name": "
|
|
37
|
+
{ "name": "vdo_send", "arguments": { "session_id": "<reviewer-session>", "target": "planner", "data": { "text": "Please send me the plan to review." } } }
|
|
38
|
+
{ "name": "vdo_receive", "arguments": { "session_id": "<planner-session>", "wait_ms": 1000 } }
|
|
193
39
|
```
|
|
194
40
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
- `npx vdon-mcp-install --codex-only`
|
|
198
|
-
- `npx vdon-mcp-install --claude-only`
|
|
199
|
-
- `npx vdon-mcp-install --preset core|file|state|secure-core|secure-full`
|
|
200
|
-
- `npx vdon-mcp-install --uninstall`
|
|
201
|
-
|
|
202
|
-
## Onboarding Guides
|
|
203
|
-
|
|
204
|
-
- Operator quickstart: `references/quickstart-and-compat.md`
|
|
205
|
-
- Client setup examples (Cursor, Gemini CLI, OpenCode): `references/client-config-examples.md`
|
|
206
|
-
- First functional workflows (Codex-to-Codex and Claude-to-Claude): `references/client-config-examples.md`
|
|
207
|
-
|
|
208
|
-
## Typical Workflows
|
|
209
|
-
|
|
210
|
-
Message demo:
|
|
211
|
-
|
|
212
|
-
```bash
|
|
213
|
-
npx vdon-mcp-demo-message
|
|
214
|
-
```
|
|
41
|
+
These are MCP tool calls made by your AI client. A successful send means the transport accepted the payload; ask the recipient to reply when you need confirmation. Clients use `vdo_receive` to retrieve incoming events.
|
|
215
42
|
|
|
216
|
-
|
|
43
|
+
See the [quickstart](https://steveseguin.github.io/ninjamcp/quickstart-and-compat.html) for connection troubleshooting and secure presets.
|
|
217
44
|
|
|
218
|
-
|
|
45
|
+
## What you can do
|
|
219
46
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
47
|
+
| Goal | Tools |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| Connect, discover peers, and inspect a room | `vdo_connect`, `vdo_sync_peers`, `vdo_status` |
|
|
50
|
+
| Exchange messages and hand off work | `vdo_send`, `vdo_receive` |
|
|
51
|
+
| Send and save artifacts with integrity checks and retries | `vdo_file_send_path`, `vdo_file_receive`, `vdo_file_save` |
|
|
52
|
+
| Share small task state and recover a snapshot | `vdo_state_set`, `vdo_state_get`, `vdo_state_sync` |
|
|
53
|
+
| Inspect available tools and transport behavior | `vdo_capabilities` |
|
|
54
|
+
| End a session | `vdo_disconnect` |
|
|
223
55
|
|
|
224
|
-
|
|
56
|
+
Choose a tool profile with `npx vdon-mcp-install --preset core`, `file`, or `state`. The `full` profile includes all tools. Use `secure-core` or `secure-full` with a configured `VDON_MCP_JOIN_TOKEN_SECRET` for join-token enforcement and session MACs. The [tool reference](https://steveseguin.github.io/ninjamcp/mcp-tool-contract.html) describes peer allowlists and other connection controls.
|
|
225
57
|
|
|
226
|
-
|
|
58
|
+
## Choose a connection
|
|
227
59
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
60
|
+
| Path | When to use it |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| WebRTC | Default for direct peer messaging and files. Payloads travel over encrypted data channels. |
|
|
63
|
+
| WebRTC with `force_turn: true` | Route through TURN when you need it. TURN still requires a WebRTC runtime and network support. |
|
|
64
|
+
| HTTP relay with `transport: "http"` | Use a relay you operate when WebRTC cannot run or connect. Follow the [relay setup guide](https://steveseguin.github.io/ninjamcp/http-relay.html). |
|
|
232
65
|
|
|
233
|
-
|
|
66
|
+
The HTTP relay uses encrypted room messages, POST, and long polling. Both peers must select HTTP and share the relay settings. It has bounded in-memory replay, with visible events for lost history; use an application inbox when work must survive process restarts or long periods offline. Room-key holders can decrypt the room's traffic, so use separate rooms for separate trust groups.
|
|
234
67
|
|
|
235
|
-
|
|
236
|
-
MCP_DEMO_FAKE=0 MCP_DEMO_FORCE_TURN=1 npx vdon-mcp-demo-message
|
|
237
|
-
MCP_DEMO_FAKE=0 MCP_DEMO_FORCE_TURN=1 npx vdon-mcp-demo-file
|
|
238
|
-
```
|
|
68
|
+
The native WebRTC dependency installs by default. For HTTP-only machines, install with `npm install @vdoninja/mcp --omit=optional` and configure a relay. Ordinary room sessions select their transport explicitly; the optional browser adapter also supports automatic routing.
|
|
239
69
|
|
|
240
|
-
##
|
|
70
|
+
## Where this fits
|
|
241
71
|
|
|
242
|
-
|
|
72
|
+
| Your goal | Use |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| Connect an MCP client to live peers and exchange messages, files, and state | **This package** |
|
|
75
|
+
| Keep a team connected between agent turns, with local inboxes, handoffs, wake hooks, and a dashboard | [ninja-p2p](https://github.com/steveseguin/ninja-p2p) |
|
|
76
|
+
| Build directly with WebRTC audio, video, or data | [VDO.Ninja SDK](https://github.com/steveseguin/ninjasdk) |
|
|
243
77
|
|
|
244
|
-
-
|
|
245
|
-
- `enforce_join_token: true`
|
|
246
|
-
- `allow_peer_stream_ids: [...]`
|
|
247
|
-
- `require_session_mac: true`
|
|
78
|
+
Choose Ninja MCP when your client should manage an active interaction through MCP tools. Choose ninja-p2p when a background sidecar should retain incoming work and notify an agent between turns. Ninja MCP's ordinary event queues and shared state live in the server process; use ninja-p2p for persistent agent inbox workflows.
|
|
248
79
|
|
|
249
|
-
|
|
80
|
+
Both use the VDO.Ninja SDK, but their message and file protocols differ. A common room name alone does not connect their higher-level workflows. A dedicated adapter is planned; until then, use matching packages on both ends of each workflow.
|
|
250
81
|
|
|
251
|
-
|
|
252
|
-
VDON_MCP_TOOL_PROFILE=core npx vdon-mcp-server
|
|
253
|
-
```
|
|
82
|
+
Ninja MCP's direction is live access to remote peers, tools, and perception through MCP. ninja-p2p's direction is the continuing team workspace and its handoffs. See [Ninja MCP's direction](https://steveseguin.github.io/ninjamcp/direction.html) and [ninja-p2p's direction](https://github.com/steveseguin/ninja-p2p/blob/main/docs/north-star.md).
|
|
254
83
|
|
|
255
|
-
|
|
84
|
+
## Optional remote Chrome
|
|
256
85
|
|
|
257
|
-
|
|
86
|
+
The [browser worker](https://steveseguin.github.io/ninjamcp/browser-worker.html) lets agents share an isolated, headless Chrome tab over WebRTC or your HTTP relay. Each agent has a separate credential; one controller holds the tab while others observe. Tools return page snapshots and screenshots. Hosts can also enable live tab audio/video through the VDO.Ninja SDK.
|
|
258
87
|
|
|
259
|
-
-
|
|
260
|
-
- Claude Code MCP CLI
|
|
261
|
-
- Other MCP clients that support stdio command servers
|
|
88
|
+
Install the optional browser packages on the host, run `vdon-mcp-browser --setup`, and register it as a separate MCP server. The [setup guide](https://steveseguin.github.io/ninjamcp/browser-worker.html) covers connection checks, automatic WebRTC/HTTP routing, control handoff, and private viewing links.
|
|
262
89
|
|
|
263
|
-
|
|
90
|
+
The optional [browser companion](https://steveseguin.github.io/ninjamcp/browser-agent.html) adds a speaking agent with microphone, browser-frame sharing, interruption, and control permissions. It uses an OpenAI API key; ordinary MCP tools and live browser viewing do not require a model account.
|
|
264
91
|
|
|
265
|
-
|
|
266
|
-
{
|
|
267
|
-
"name": "vdo-ninja-mcp",
|
|
268
|
-
"command": "node",
|
|
269
|
-
"args": ["node_modules/@vdoninja/mcp/scripts/vdo-mcp-server.js"]
|
|
270
|
-
}
|
|
271
|
-
```
|
|
92
|
+
## Optional remote SSApp
|
|
272
93
|
|
|
273
|
-
|
|
94
|
+
For an existing Social Stream Ninja Standalone instance, the optional [SSApp bridge](https://steveseguin.github.io/ninjamcp/ssapp-integration.html)
|
|
95
|
+
forwards its MCP tools, screenshots, and input over WebRTC or the HTTP relay. Agents use separate credentials
|
|
96
|
+
and take turns controlling the app; SSApp keeps its existing sessions and windows.
|
|
274
97
|
|
|
275
|
-
|
|
98
|
+
## Run from a checkout
|
|
276
99
|
|
|
277
100
|
```bash
|
|
101
|
+
npm install
|
|
278
102
|
node scripts/vdo-mcp-server.js
|
|
279
103
|
```
|
|
280
104
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
```bash
|
|
284
|
-
node scripts/install-mcp.js
|
|
285
|
-
```
|
|
286
|
-
|
|
287
|
-
## Testing
|
|
288
|
-
|
|
289
|
-
Fast product-focused suite:
|
|
290
|
-
|
|
291
|
-
```bash
|
|
292
|
-
node tests/run-mcp-tests-minimal.js
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
Full suite:
|
|
296
|
-
|
|
297
|
-
```bash
|
|
298
|
-
node tests/run-mcp-tests.js
|
|
299
|
-
```
|
|
300
|
-
|
|
301
|
-
Live diagnostics:
|
|
302
|
-
|
|
303
|
-
```bash
|
|
304
|
-
node tests/live-turn-smoke.js
|
|
305
|
-
node tests/live-turn-soak.js
|
|
306
|
-
node tests/live-turn-matrix.js
|
|
307
|
-
node tests/live-turn-preset-matrix.js
|
|
308
|
-
```
|
|
309
|
-
|
|
310
|
-
## Key Files
|
|
311
|
-
|
|
312
|
-
- `scripts/vdo-mcp-server.js` (stdio MCP server)
|
|
313
|
-
- `scripts/vdo-mcp-streamable-http.js` (streamable HTTP bridge)
|
|
314
|
-
- `scripts/install-mcp.js` (Codex/Claude installer automation)
|
|
315
|
-
- `references/quickstart-and-compat.md` (operator quickstart)
|
|
316
|
-
- `references/mcp-tool-contract.md` (tool semantics and contracts)
|
|
317
|
-
- `server.json` (MCP discovery manifest metadata)
|
|
105
|
+
`npm run check` runs the existing suite. `npm run demo:message` and `npm run demo:file` run local simulated workflows; set `MCP_DEMO_FAKE=0` to use live WebRTC peers. See [advanced diagnostics](https://steveseguin.github.io/ninjamcp/advanced-diagnostics.html) for live network checks.
|
|
318
106
|
|
|
319
107
|
## License
|
|
320
108
|
|
|
321
|
-
|
|
109
|
+
MIT. Copyright (c) 2026 Steve Seguin.
|
|
322
110
|
|
|
323
|
-
-
|
|
324
|
-
- Additional permission: `LICENSE-SDK-EXCEPTION`
|
|
111
|
+
The `@vdoninja/sdk` dependency has its own terms: MPL-2.0, with some separately marked wrappers and adapters under MIT. Preserve its copyright and license notices when redistributing it and comply with the MPL-2.0 for covered SDK files. Those terms do not require the rest of an application to use the MPL-2.0.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
class NinjaMicrophone extends AudioWorkletProcessor {
|
|
3
|
+
constructor() { super(); this.samples = new Int16Array(2400); this.offset = 0; }
|
|
4
|
+
process(inputs) {
|
|
5
|
+
const input = inputs[0] && inputs[0][0];
|
|
6
|
+
if (!input) return true;
|
|
7
|
+
for (const value of input) {
|
|
8
|
+
this.samples[this.offset++] = Math.round(Math.max(-1, Math.min(1, value)) * 32767);
|
|
9
|
+
if (this.offset === this.samples.length) {
|
|
10
|
+
this.port.postMessage(this.samples.buffer, [this.samples.buffer]);
|
|
11
|
+
this.samples = new Int16Array(2400); this.offset = 0;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
registerProcessor('ninja-microphone', NinjaMicrophone);
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const $ = (id) => document.getElementById(id);
|
|
3
|
+
const token = location.hash.slice(1);
|
|
4
|
+
history.replaceState(null, '', location.pathname);
|
|
5
|
+
const socket = new WebSocket(`ws://${location.host}/operator?token=${encodeURIComponent(token)}`);
|
|
6
|
+
let connected = false, micStream, micContext, micNode, outputContext, sdk, mediaStream;
|
|
7
|
+
let micEpoch = 0, mediaEpoch = 0, playbackEpoch = 0, playbackPlayed = 0, playbackEnd = 0, playbackItem = null;
|
|
8
|
+
const sources = new Map();
|
|
9
|
+
const send = (event) => { if (socket.readyState === WebSocket.OPEN && socket.bufferedAmount < 1000000) socket.send(JSON.stringify(event)); };
|
|
10
|
+
const log = (text) => { $('log').textContent = ($('log').textContent + text).slice(-20000); $('log').scrollTop = $('log').scrollHeight; };
|
|
11
|
+
function sharing() {
|
|
12
|
+
send({ type: 'permissions', microphone: connected && $('microphone').checked, share: $('frames').checked, control: $('control').checked });
|
|
13
|
+
const parts = [];
|
|
14
|
+
if (connected && $('microphone').checked) parts.push('microphone');
|
|
15
|
+
if (connected && $('frames').checked) parts.push('browser frames and page content');
|
|
16
|
+
$('sharing').textContent = parts.length ? `Sending ${parts.join(' + ')} to OpenAI.${$('control').checked ? ' Browser control enabled.' : ''}` : 'Microphone and browser sharing are off.';
|
|
17
|
+
}
|
|
18
|
+
async function stopMic() {
|
|
19
|
+
micEpoch++;
|
|
20
|
+
if (micNode) micNode.disconnect(); micNode = null;
|
|
21
|
+
if (micStream) micStream.getTracks().forEach((track) => track.stop()); micStream = null;
|
|
22
|
+
const context = micContext; micContext = null;
|
|
23
|
+
if (context) await context.close().catch(() => {});
|
|
24
|
+
}
|
|
25
|
+
async function startMic() {
|
|
26
|
+
if (!connected || !$('microphone').checked || micStream) return;
|
|
27
|
+
const epoch = ++micEpoch;
|
|
28
|
+
try {
|
|
29
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false });
|
|
30
|
+
if (epoch !== micEpoch || !connected || !$('microphone').checked) { stream.getTracks().forEach((track) => track.stop()); return; }
|
|
31
|
+
micStream = stream;
|
|
32
|
+
const context = new AudioContext({ sampleRate: 24000 });
|
|
33
|
+
micContext = context;
|
|
34
|
+
await context.audioWorklet.addModule('/audio-worklet.js');
|
|
35
|
+
if (epoch !== micEpoch) return;
|
|
36
|
+
micNode = new AudioWorkletNode(micContext, 'ninja-microphone');
|
|
37
|
+
micNode.port.onmessage = (event) => {
|
|
38
|
+
if (!connected || !$('microphone').checked) return;
|
|
39
|
+
send({ type: 'audio', data: btoa(String.fromCharCode(...new Uint8Array(event.data))) });
|
|
40
|
+
};
|
|
41
|
+
const silent = micContext.createGain(); silent.gain.value = 0;
|
|
42
|
+
micContext.createMediaStreamSource(micStream).connect(micNode); micNode.connect(silent); silent.connect(micContext.destination);
|
|
43
|
+
await micContext.resume();
|
|
44
|
+
} catch (error) { if (epoch !== micEpoch) return; $('error').textContent = `Microphone: ${error.message}`; $('microphone').checked = false; await stopMic(); sharing(); }
|
|
45
|
+
}
|
|
46
|
+
function stopPlayback(report = true) {
|
|
47
|
+
playbackEpoch++;
|
|
48
|
+
let played = playbackPlayed;
|
|
49
|
+
if (outputContext) for (const segment of sources.values()) played += Math.max(0, Math.min(segment.duration, outputContext.currentTime - segment.start));
|
|
50
|
+
if (playbackItem && report) send({ type: 'playback', item_id: playbackItem, played_ms: played * 1000 });
|
|
51
|
+
for (const source of sources.keys()) { source.onended = null; try { source.stop(); } catch (_) {} }
|
|
52
|
+
sources.clear(); playbackItem = null; playbackEnd = 0; playbackPlayed = 0;
|
|
53
|
+
}
|
|
54
|
+
async function audio(event) {
|
|
55
|
+
if (!connected) return;
|
|
56
|
+
const epoch = playbackEpoch;
|
|
57
|
+
outputContext = outputContext || new AudioContext({ sampleRate: 24000 });
|
|
58
|
+
await outputContext.resume();
|
|
59
|
+
if (epoch !== playbackEpoch || !connected) return;
|
|
60
|
+
const bytes = Uint8Array.from(atob(event.data), (value) => value.charCodeAt(0));
|
|
61
|
+
const samples = new Int16Array(bytes.buffer);
|
|
62
|
+
const buffer = outputContext.createBuffer(1, samples.length, 24000);
|
|
63
|
+
const data = buffer.getChannelData(0); for (let i = 0; i < samples.length; i++) data[i] = samples[i] / 32768;
|
|
64
|
+
if (playbackItem !== event.item_id) { stopPlayback(false); playbackItem = event.item_id; playbackEnd = outputContext.currentTime; }
|
|
65
|
+
if (playbackEnd - outputContext.currentTime > 30) { send({ type: 'interrupt' }); stopPlayback(); return; }
|
|
66
|
+
const source = outputContext.createBufferSource(); source.buffer = buffer; source.connect(outputContext.destination);
|
|
67
|
+
const start = Math.max(outputContext.currentTime, playbackEnd); source.start(start); playbackEnd = start + buffer.duration;
|
|
68
|
+
sources.set(source, { start, duration: buffer.duration }); source.onended = () => { if (sources.delete(source)) playbackPlayed += buffer.duration; };
|
|
69
|
+
}
|
|
70
|
+
async function stopWatching() {
|
|
71
|
+
mediaEpoch++;
|
|
72
|
+
const viewer = sdk; sdk = null;
|
|
73
|
+
if (mediaStream) mediaStream.getTracks().forEach((track) => track.stop()); mediaStream = null;
|
|
74
|
+
$('video').srcObject = null; $('video').hidden = true; $('video').muted = true;
|
|
75
|
+
$('watch').disabled = false; $('unwatch').hidden = true; $('sound').hidden = true;
|
|
76
|
+
$('placeholder').hidden = !$('preview').hidden;
|
|
77
|
+
if (viewer) await viewer.disconnect().catch(() => {});
|
|
78
|
+
}
|
|
79
|
+
async function watch(media) {
|
|
80
|
+
if (!media.enabled) throw new Error('Live browser media is off or unavailable. Enable media on the host, or share browser frames for HTTP-compatible vision.');
|
|
81
|
+
await stopWatching();
|
|
82
|
+
const epoch = mediaEpoch;
|
|
83
|
+
$('watch').disabled = true; $('unwatch').hidden = false; $('sound').hidden = false;
|
|
84
|
+
const viewer = new VDONinjaSDK({ password: media.password, ...(media.signaling_host ? { host: media.signaling_host } : {}) });
|
|
85
|
+
sdk = viewer;
|
|
86
|
+
const stream = new MediaStream(); mediaStream = stream; $('video').srcObject = stream;
|
|
87
|
+
viewer.addEventListener('track', (event) => { if (epoch !== mediaEpoch) { event.detail.track.stop(); return; } stream.addTrack(event.detail.track); $('video').hidden = false; $('preview').hidden = true; $('placeholder').hidden = true; });
|
|
88
|
+
await viewer.connect(); if (epoch !== mediaEpoch) { await viewer.disconnect(); return; }
|
|
89
|
+
await viewer.joinRoom({ room: media.room, password: media.password }); if (epoch !== mediaEpoch) { await viewer.disconnect(); return; }
|
|
90
|
+
await viewer.view(media.stream_id, { audio: true, video: true });
|
|
91
|
+
}
|
|
92
|
+
socket.onmessage = async ({ data }) => {
|
|
93
|
+
try {
|
|
94
|
+
const event = JSON.parse(data);
|
|
95
|
+
if (event.type === 'hello') { $('identity').textContent = `${event.worker} · ${event.transport} · ${event.provider} / ${event.model}`; if (!event.key_available) $('error').textContent = 'Set OPENAI_API_KEY before starting this operator server to enable the agent. Browser checks and live viewing still work.'; }
|
|
96
|
+
else if (event.type === 'status') {
|
|
97
|
+
connected = !!event.connected; $('status').textContent = connected ? 'Agent connected' : 'Stopped'; $('start').disabled = connected;
|
|
98
|
+
if (connected) { sharing(); await startMic(); } else { await stopMic(); stopPlayback(); $('microphone').checked = false; $('frames').checked = false; $('control').checked = false; sharing(); }
|
|
99
|
+
if (event.message) log(event.message + '\n');
|
|
100
|
+
} else if (event.type === 'audio') await audio(event);
|
|
101
|
+
else if (event.type === 'interrupt') stopPlayback();
|
|
102
|
+
else if (event.type === 'text_delta') log(event.text);
|
|
103
|
+
else if (event.type === 'turn_done') log('\n');
|
|
104
|
+
else if (event.type === 'tool') log(`\n${event.name}: ${event.stage}${event.route ? ` (${event.route})` : ''}\n`);
|
|
105
|
+
else if (event.type === 'error') { $('error').textContent = event.message; $('start').disabled = connected; }
|
|
106
|
+
else if (event.type === 'worker') log(`Worker authenticated · ${event.role} · ${event.route}\n`);
|
|
107
|
+
else if (event.type === 'preview') { if ($('video').hidden) { $('preview').src = `data:${event.mime};base64,${event.data}`; $('preview').hidden = false; $('placeholder').hidden = true; } $('frameTime').textContent = `Frame sent to OpenAI · ${event.captured_at}`; }
|
|
108
|
+
else if (event.type === 'media') await watch(event);
|
|
109
|
+
} catch (error) { $('error').textContent = error.message; }
|
|
110
|
+
};
|
|
111
|
+
socket.onclose = () => { connected = false; stopMic(); stopPlayback(false); stopWatching(); $('status').textContent = 'Disconnected'; $('error').textContent = 'The local operator disconnected. Reopen its private URL to reconnect.'; $('sharing').textContent = 'Sharing stopped.'; };
|
|
112
|
+
$('start').onclick = async () => { outputContext = outputContext || new AudioContext({ sampleRate: 24000 }); await outputContext.resume(); $('error').textContent = ''; $('start').disabled = true; send({ type: 'start' }); };
|
|
113
|
+
$('stop').onclick = () => { stopPlayback(); send({ type: 'stop' }); stopMic(); };
|
|
114
|
+
$('interrupt').onclick = () => { stopPlayback(); send({ type: 'interrupt' }); };
|
|
115
|
+
$('microphone').onchange = async () => { sharing(); if ($('microphone').checked) await startMic(); else await stopMic(); };
|
|
116
|
+
$('frames').onchange = () => { if (!$('frames').checked) $('control').checked = false; sharing(); if (connected && $('frames').checked) send({ type: 'frame' }); };
|
|
117
|
+
$('control').onchange = () => { if ($('control').checked) $('frames').checked = true; sharing(); };
|
|
118
|
+
$('check').onclick = () => send({ type: 'check' });
|
|
119
|
+
$('watch').onclick = () => send({ type: 'watch' });
|
|
120
|
+
$('unwatch').onclick = stopWatching;
|
|
121
|
+
$('sound').onclick = () => { $('video').muted = !$('video').muted; $('sound').textContent = $('video').muted ? 'Hear browser audio' : 'Mute browser audio'; };
|
|
122
|
+
$('message').onsubmit = (event) => { event.preventDefault(); if (!connected) { $('error').textContent = 'Start the agent before sending a message.'; return; } const text = $('text').value.trim(); if (!text) return; stopPlayback(); log(`\nYou: ${text}\n`); send({ type: 'text', text }); $('text').value = ''; };
|
|
123
|
+
setInterval(() => { if (connected && $('frames').checked) send({ type: 'frame' }); }, 3000);
|
|
124
|
+
window.addEventListener('pagehide', () => { stopMic(); if (sdk) sdk.disconnect(); socket.close(); });
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<meta charset="utf-8">
|
|
4
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
5
|
+
<title>Ninja · Browser companion</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root { color-scheme: dark; font: 16px/1.5 system-ui, sans-serif; background: #10151e; color: #e9eef7; }
|
|
8
|
+
body { margin: 0 auto; padding: 28px; max-width: 1160px; }
|
|
9
|
+
header { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
|
10
|
+
h1 { font-size: 26px; margin: 0; } h2 { font-size: 17px; margin-top: 0; }
|
|
11
|
+
.muted { color: #a9b7ca; } .status { border: 1px solid #455369; border-radius: 20px; padding: 5px 12px; }
|
|
12
|
+
.layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 20px; margin-top: 24px; }
|
|
13
|
+
.panel { background: #182130; border: 1px solid #2f4058; border-radius: 14px; padding: 20px; }
|
|
14
|
+
.stage { display: grid; place-items: center; min-height: 300px; background: #090d13; border-radius: 9px; overflow: hidden; }
|
|
15
|
+
.stage img, .stage video { width: 100%; max-height: 450px; object-fit: contain; }
|
|
16
|
+
[hidden] { display: none !important; }
|
|
17
|
+
label { display: block; margin: 16px 0; } input[type=checkbox] { width: 18px; height: 18px; vertical-align: middle; margin-right: 8px; accent-color: #6dcfbc; }
|
|
18
|
+
button, input[type=text] { font: inherit; border-radius: 7px; border: 1px solid #50647e; padding: 9px 12px; }
|
|
19
|
+
button { background: #283b53; color: white; cursor: pointer; } button:hover { background: #345471; }
|
|
20
|
+
button:disabled { opacity: .45; cursor: default; } button.primary { background: #257c6f; } button.stop { background: #773c48; }
|
|
21
|
+
.row { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
|
|
22
|
+
form { display: flex; gap: 8px; margin-top: 16px; } input[type=text] { min-width: 0; flex: 1; background: #111923; color: white; }
|
|
23
|
+
#log { white-space: pre-wrap; max-height: 260px; overflow: auto; font-size: 14px; }
|
|
24
|
+
#sharing { color: #8ee5ce; min-height: 48px; } #error { color: #ffc4bb; min-height: 24px; }
|
|
25
|
+
small { display: block; color: #a9b7ca; }
|
|
26
|
+
@media(max-width: 780px) { .layout { grid-template-columns: 1fr; } body { padding: 16px; } header { align-items: start; } }
|
|
27
|
+
</style>
|
|
28
|
+
<header><div><h1>Ninja browser companion</h1><div class="muted" id="identity">Connecting to the local operator…</div></div><span class="status" id="status">Stopped</span></header>
|
|
29
|
+
<div class="layout">
|
|
30
|
+
<main class="panel">
|
|
31
|
+
<div class="stage"><span id="placeholder" class="muted">Choose a browser view to begin.</span><img id="preview" alt="Latest shared browser frame" hidden><video id="video" autoplay muted playsinline hidden></video></div>
|
|
32
|
+
<div class="row"><button id="watch">Watch live browser</button><button id="unwatch" hidden>Stop watching</button><button id="sound" hidden>Hear browser audio</button><button id="check">Check worker</button></div>
|
|
33
|
+
<small id="frameTime">Live viewing uses VDO.Ninja. Model sharing is controlled separately.</small>
|
|
34
|
+
<form id="message"><input id="text" type="text" maxlength="8000" placeholder="Ask your browser companion…" aria-label="Message to the agent"><button id="send">Send</button></form>
|
|
35
|
+
<p id="error" role="alert"></p><div id="log" aria-live="polite"></div>
|
|
36
|
+
</main>
|
|
37
|
+
<aside class="panel">
|
|
38
|
+
<h2>Talk and work together</h2>
|
|
39
|
+
<p class="muted">Start an OpenAI voice session, then choose what the agent can hear, see, and control.</p>
|
|
40
|
+
<label><input id="microphone" type="checkbox">Share my microphone</label>
|
|
41
|
+
<label><input id="frames" type="checkbox">Share browser frames</label>
|
|
42
|
+
<label><input id="control" type="checkbox">Allow browser control</label>
|
|
43
|
+
<div id="sharing">Microphone and browser sharing are off.</div>
|
|
44
|
+
<div class="row"><button id="start" class="primary">Start agent</button><button id="interrupt">Interrupt</button><button id="stop" class="stop">End session</button></div>
|
|
45
|
+
<small>Shared microphone audio, browser images, and tool results go to OpenAI. Browser frames update about every three seconds. Control follows the worker's tab lease. Interrupt stops speech and queued actions; an action already sent may finish.</small>
|
|
46
|
+
<p><small>AI-generated voice. No local recording. Sessions end after 15 minutes. Watching the SDK stream alone does not send it to OpenAI.</small></p>
|
|
47
|
+
</aside>
|
|
48
|
+
</div>
|
|
49
|
+
<script src="/sdk.js"></script><script src="/client.js"></script>
|
|
50
|
+
</html>
|
package/docs/.nojekyll
ADDED
|
File without changes
|
package/docs/404.html
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-icons="/ninjamcp/assets/icons.svg">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Page not found · VDO.Ninja MCP</title>
|
|
7
|
+
<meta name="robots" content="noindex">
|
|
8
|
+
<meta name="color-scheme" content="light dark">
|
|
9
|
+
<link rel="icon" href="/ninjamcp/favicon.ico" sizes="32x32">
|
|
10
|
+
<link rel="icon" href="/ninjamcp/assets/favicon.svg" type="image/svg+xml">
|
|
11
|
+
<link rel="apple-touch-icon" href="/ninjamcp/assets/apple-touch-icon.png">
|
|
12
|
+
<link rel="stylesheet" href="/ninjamcp/assets/site.css">
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<main class="hero" style="min-height:100vh;display:grid;place-items:center;padding:24px">
|
|
16
|
+
<div class="wrap">
|
|
17
|
+
<a href="/ninjamcp/" aria-label="VDO.Ninja MCP home"><img src="/ninjamcp/assets/logo.svg" alt="" width="64" height="64" style="margin:0 auto"></a>
|
|
18
|
+
<h1 style="font-size:clamp(36px,6vw,56px);max-width:none">This line is dead.</h1>
|
|
19
|
+
<p class="lede">The page you asked for isn't here. It may have moved when the docs were reorganized.</p>
|
|
20
|
+
<div class="hero-actions">
|
|
21
|
+
<a class="btn btn-dark btn-lg" href="/ninjamcp/">Home</a>
|
|
22
|
+
<a class="btn btn-lg" href="/ninjamcp/guide.html">Documentation</a>
|
|
23
|
+
<a class="btn btn-lg" href="/ninjamcp/#faq">FAQ</a>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</main>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|