@openvole/volenet-mcp 0.1.0 → 0.2.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/README.md +66 -2
- package/dist/index.d.ts +200 -14
- package/dist/index.js +980 -220
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -32,8 +32,10 @@ your behalf, not commands you type. Ask for them in your own words.
|
|
|
32
32
|
> *"pair with the agent at http://10.0.0.5:9700"* → `volenet_connect`, which reports the
|
|
33
33
|
> fingerprint first and pairs once you confirm it
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
It registers for **every project**, because the identity it installs is per machine — one keypair
|
|
36
|
+
in your home directory, shared by every session. Add `--local` to limit it to the current project.
|
|
37
|
+
If the `claude` CLI is not on PATH, the installer prints the one line to paste instead of guessing
|
|
38
|
+
at its config.
|
|
37
39
|
|
|
38
40
|
### Settings
|
|
39
41
|
|
|
@@ -52,6 +54,21 @@ Environment wins over stored settings, which win over defaults. None of it is re
|
|
|
52
54
|
|
|
53
55
|
That directory **is** your identity: back it up, and anyone who has it is you.
|
|
54
56
|
|
|
57
|
+
## Slash commands
|
|
58
|
+
|
|
59
|
+
The server ships its flows as MCP prompts, so the client surfaces them as commands — you pick one
|
|
60
|
+
rather than hoping a sentence matches the right tool.
|
|
61
|
+
|
|
62
|
+
| command | what it does |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `whoami` | this session's identity, and whether it can reach anything |
|
|
65
|
+
| `peers` | who is reachable, and by which route |
|
|
66
|
+
| `setup` | get onto the mesh — join a hub, or pair with an agent |
|
|
67
|
+
| `pair` | pair with an agent, fingerprint checked, asking for brain access if wanted |
|
|
68
|
+
| `rooms` | rooms this session is in, and how to say something to one |
|
|
69
|
+
| `catch-up` | read what arrived while away and say what needs answering |
|
|
70
|
+
| `reach` | message a peer and wait for the reply |
|
|
71
|
+
|
|
55
72
|
## Commands
|
|
56
73
|
|
|
57
74
|
Alongside the tools, a few things are useful before a session exists, or without one. These touch
|
|
@@ -81,6 +98,7 @@ than a command, because it needs a running node and a conversation to happen in.
|
|
|
81
98
|
| `volenet_ask` | Ask another **agent's** brain a question and wait for the answer. |
|
|
82
99
|
| `volenet_requests` | Trust decisions waiting on you; accept or deny. |
|
|
83
100
|
| `volenet_wait` | Wait for the next message instead of checking again later. |
|
|
101
|
+
| `volenet_room` | Rooms: list, post, create, join, leave, invite. |
|
|
84
102
|
| `volenet_hub` | Join a hub, leave one, or say which you are on. Remembered. |
|
|
85
103
|
| `volenet_connect` | Pair with a node, or ask a hub member for consent to chat. |
|
|
86
104
|
|
|
@@ -109,6 +127,15 @@ things close most of that gap:
|
|
|
109
127
|
called again. That is what makes a back-and-forth feel like a conversation instead of a
|
|
110
128
|
mailbox: say something, wait, get the reply in the same turn.
|
|
111
129
|
|
|
130
|
+
**The daemon notifies you when something arrives** — a desktop notification, which is the thing a
|
|
131
|
+
chat client actually does. It is the only part of the machinery that is always running, so it is
|
|
132
|
+
the only part that can. `VOLENET_MCP_NOTIFY=off` silences it; set it to a command name instead and
|
|
133
|
+
that command is run with the title and body as its two arguments.
|
|
134
|
+
|
|
135
|
+
**A message cannot answer itself.** MCP's only server-initiated model call is `sampling`, and
|
|
136
|
+
Claude Code declares no capabilities at all — `volenet_whoami` reports which it is, so nobody waits
|
|
137
|
+
for a reply that cannot come. What is left is making sure an arrived message is *seen* promptly.
|
|
138
|
+
|
|
112
139
|
For catch-up at the start of a session, ask for the inbox — or have it arrive before you type
|
|
113
140
|
anything, with a `SessionStart` hook in `.claude/settings.json`:
|
|
114
141
|
|
|
@@ -124,6 +151,43 @@ anything, with a `SessionStart` hook in `.claude/settings.json`:
|
|
|
124
151
|
|
|
125
152
|
`--read` marks them seen, since the hook has just put them in front of you.
|
|
126
153
|
|
|
154
|
+
Swap `SessionStart` for `UserPromptSubmit` and waiting messages arrive on every turn you take,
|
|
155
|
+
which is as close to unprompted as this client allows.
|
|
156
|
+
|
|
157
|
+
## The node runs in a daemon
|
|
158
|
+
|
|
159
|
+
An identity that exists only while an editor is open is offline most of the time: senders hold what
|
|
160
|
+
they cannot deliver, hubs record that somebody tried, and nothing arrives until you come back. So
|
|
161
|
+
the node lives in a small daemon — one per identity, started the first time a session wants it,
|
|
162
|
+
outliving every session. You are reachable whether or not anything is open.
|
|
163
|
+
|
|
164
|
+
It also settles what two open editors would otherwise do to each other: two nodes on one identity
|
|
165
|
+
means a hub binds one socket and the other goes deaf. One node, many sessions attached, no race.
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
volenet-mcp daemon # run it in the foreground; normally it is started for you
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Reading does not go through it. Messages are an append-only file, so a session reads them directly
|
|
172
|
+
and keeps its own cursor — the protocol covers acting, not looking, and your history is still there
|
|
173
|
+
if the daemon is gone. `VOLENET_MCP_NO_DAEMON=1` keeps the node in the session, which is the
|
|
174
|
+
fallback where spawning is not allowed.
|
|
175
|
+
|
|
176
|
+
## One identity, several sessions
|
|
177
|
+
|
|
178
|
+
The identity is per machine — one keypair in `~/.openvole/volenet-mcp/`, shared by every session,
|
|
179
|
+
because pairing once is the whole point of having one. Being *caught up* is not shared: messages
|
|
180
|
+
are an append-only log, and each session keeps its own read cursor, keyed by the directory it was
|
|
181
|
+
started in.
|
|
182
|
+
|
|
183
|
+
So a session opening its inbox does not mark those messages seen for the others; a session
|
|
184
|
+
reopened in the same project is the same reader and does not replay what it has already been
|
|
185
|
+
shown; and two projects open at once are two readers of one log. Appending rather than rewriting
|
|
186
|
+
is what makes that safe without a lock or a daemon.
|
|
187
|
+
|
|
188
|
+
`VOLENET_MCP_SESSION` names the reader explicitly, if you want two sessions in one directory kept
|
|
189
|
+
apart.
|
|
190
|
+
|
|
127
191
|
## Session lifetime
|
|
128
192
|
|
|
129
193
|
The node lives as long as the editor session. That is a supported shape, not a degraded one: a
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
-
import { VoleNetManager } from '@openvole/volenet';
|
|
3
2
|
|
|
4
3
|
interface StoredConfig {
|
|
5
4
|
name?: string;
|
|
@@ -11,6 +10,8 @@ interface Settings {
|
|
|
11
10
|
hub?: string;
|
|
12
11
|
dir: string;
|
|
13
12
|
port: number;
|
|
13
|
+
/** Which read state in the shared inbox is this session's. See {@link sessionKey}. */
|
|
14
|
+
session: string;
|
|
14
15
|
}
|
|
15
16
|
declare function defaultDir(): string;
|
|
16
17
|
/** A name that says what this is without needing to be chosen. Identity is the key, not this. */
|
|
@@ -32,20 +33,31 @@ interface Message {
|
|
|
32
33
|
id: string;
|
|
33
34
|
}
|
|
34
35
|
declare class Inbox {
|
|
35
|
-
private readonly
|
|
36
|
+
private readonly dir;
|
|
37
|
+
private readonly session;
|
|
36
38
|
private messages;
|
|
37
|
-
/** Per peer, the timestamp up to which
|
|
39
|
+
/** Per peer, the timestamp up to which *this* session has been shown its messages. */
|
|
38
40
|
private readAt;
|
|
39
41
|
private writing;
|
|
40
|
-
|
|
42
|
+
/**
|
|
43
|
+
* @param dir where the shared log and the cursors live
|
|
44
|
+
* @param session which read state is ours. Sessions in different projects are different
|
|
45
|
+
* readers; the same project reopened is the same reader, so restarting does
|
|
46
|
+
* not replay everything already seen.
|
|
47
|
+
*/
|
|
48
|
+
constructor(dir: string, session?: string);
|
|
49
|
+
private get log();
|
|
50
|
+
private get cursor();
|
|
41
51
|
load(): Promise<void>;
|
|
52
|
+
/** Re-read what other sessions have appended since we loaded. */
|
|
53
|
+
refresh(): Promise<void>;
|
|
42
54
|
/** Record a message. Returns false when this id was already recorded. */
|
|
43
55
|
add(m: Message): Promise<boolean>;
|
|
44
56
|
/** Everything with one peer, oldest first. */
|
|
45
57
|
history(peerId: string, limit?: number): Message[];
|
|
46
|
-
/** Inbound messages
|
|
58
|
+
/** Inbound messages this session has not been shown yet, oldest first. */
|
|
47
59
|
unread(): Message[];
|
|
48
|
-
/** Mark everything currently unread as seen. */
|
|
60
|
+
/** Mark everything currently unread as seen — for this session, and nobody else. */
|
|
49
61
|
markRead(): Promise<void>;
|
|
50
62
|
/** Every peer we have said anything to or heard anything from, most recent first. */
|
|
51
63
|
peers(): Array<{
|
|
@@ -55,8 +67,145 @@ declare class Inbox {
|
|
|
55
67
|
unread: number;
|
|
56
68
|
}>;
|
|
57
69
|
get size(): number;
|
|
58
|
-
/**
|
|
59
|
-
private
|
|
70
|
+
/** One line, one write — an append no other session can lose. */
|
|
71
|
+
private append;
|
|
72
|
+
/** Rewrite the log with the newest MAX_MESSAGES. Rare, and atomic via rename. */
|
|
73
|
+
private compact;
|
|
74
|
+
private persistCursor;
|
|
75
|
+
/**
|
|
76
|
+
* Carry over messages written before the log existed.
|
|
77
|
+
*
|
|
78
|
+
* Earlier versions kept one `inbox.json` holding both the messages and a single read state. The
|
|
79
|
+
* messages are still someone's; dropping them on upgrade would lose real conversations. The old
|
|
80
|
+
* read state is deliberately *not* carried over — it was one cursor for every session, so honouring
|
|
81
|
+
* it would mark messages seen for sessions that never saw them. Unread is the safe direction.
|
|
82
|
+
*/
|
|
83
|
+
private adoptLegacy;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Everything the tools need from a node, as a flat surface.
|
|
88
|
+
*
|
|
89
|
+
* The tools used to reach into `VoleNetManager` directly, including through the objects it hands
|
|
90
|
+
* back — `getTransport()?.getPeers()`, `getRemoteTaskManager().delegateTask()`. That is fine while
|
|
91
|
+
* the node lives in the same process, and impossible once it does not: a socket cannot return a
|
|
92
|
+
* transport. Flattening it to plain calls with plain values is what lets the same tools run
|
|
93
|
+
* against a node here or a node in a daemon, which is what being *present* while no session is
|
|
94
|
+
* open requires.
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
interface PeerInfo {
|
|
98
|
+
id: string;
|
|
99
|
+
name: string;
|
|
100
|
+
connected: boolean;
|
|
101
|
+
}
|
|
102
|
+
interface RelayMemberInfo {
|
|
103
|
+
id: string;
|
|
104
|
+
name: string;
|
|
105
|
+
viaHubName: string;
|
|
106
|
+
connected: boolean;
|
|
107
|
+
accepted: boolean;
|
|
108
|
+
incoming: boolean;
|
|
109
|
+
awaiting: boolean;
|
|
110
|
+
}
|
|
111
|
+
interface PairRequestInfo {
|
|
112
|
+
id: string;
|
|
113
|
+
name: string;
|
|
114
|
+
note?: string;
|
|
115
|
+
wants?: string[];
|
|
116
|
+
}
|
|
117
|
+
interface PairGrantInput {
|
|
118
|
+
trust?: 'full' | 'tool' | 'read';
|
|
119
|
+
allowBrain?: boolean;
|
|
120
|
+
}
|
|
121
|
+
interface SendResult {
|
|
122
|
+
ok: boolean;
|
|
123
|
+
delivered?: boolean;
|
|
124
|
+
relayed?: boolean;
|
|
125
|
+
error?: string;
|
|
126
|
+
}
|
|
127
|
+
interface AskResult {
|
|
128
|
+
status: string;
|
|
129
|
+
result?: string;
|
|
130
|
+
error?: string;
|
|
131
|
+
}
|
|
132
|
+
interface Identity {
|
|
133
|
+
instanceId: string;
|
|
134
|
+
publicKeyString: string;
|
|
135
|
+
}
|
|
136
|
+
interface RoomView {
|
|
137
|
+
room: string;
|
|
138
|
+
name: string;
|
|
139
|
+
topic?: string;
|
|
140
|
+
members: Array<{
|
|
141
|
+
instanceId: string;
|
|
142
|
+
name: string;
|
|
143
|
+
}>;
|
|
144
|
+
}
|
|
145
|
+
/** A node, wherever it happens to be running. */
|
|
146
|
+
interface NetLike {
|
|
147
|
+
identity(): Promise<Identity | null>;
|
|
148
|
+
/** Peers this node holds a direct link with, and whether the socket is live. */
|
|
149
|
+
instances(): Promise<PeerInfo[]>;
|
|
150
|
+
relayMembers(): Promise<RelayMemberInfo[]>;
|
|
151
|
+
sendChat(to: string, text: string): Promise<SendResult>;
|
|
152
|
+
askBrain(to: string, input: string, fromName: string, timeoutMs: number): Promise<AskResult>;
|
|
153
|
+
joinHub(url: string): Promise<{
|
|
154
|
+
ok: boolean;
|
|
155
|
+
pending?: boolean;
|
|
156
|
+
hubName?: string;
|
|
157
|
+
error?: string;
|
|
158
|
+
}>;
|
|
159
|
+
addPeer(url: string): Promise<void>;
|
|
160
|
+
forgetPeer(url: string): Promise<boolean>;
|
|
161
|
+
probePair(url: string): Promise<{
|
|
162
|
+
ok: boolean;
|
|
163
|
+
name?: string;
|
|
164
|
+
fingerprint?: string;
|
|
165
|
+
publicKey?: string;
|
|
166
|
+
alreadyTrusted?: boolean;
|
|
167
|
+
error?: string;
|
|
168
|
+
}>;
|
|
169
|
+
initiatePair(url: string, publicKey: string, note?: string, wants?: string[]): Promise<{
|
|
170
|
+
ok: boolean;
|
|
171
|
+
pending?: boolean;
|
|
172
|
+
error?: string;
|
|
173
|
+
}>;
|
|
174
|
+
requestRelayConnect(ref: string, note?: string): Promise<{
|
|
175
|
+
ok: boolean;
|
|
176
|
+
queued?: boolean;
|
|
177
|
+
error?: string;
|
|
178
|
+
}>;
|
|
179
|
+
approveRelayConnect(ref: string): Promise<{
|
|
180
|
+
ok: boolean;
|
|
181
|
+
error?: string;
|
|
182
|
+
}>;
|
|
183
|
+
denyRelayConnect(ref: string): Promise<{
|
|
184
|
+
ok: boolean;
|
|
185
|
+
error?: string;
|
|
186
|
+
}>;
|
|
187
|
+
/** Rooms this node is in, as its hub last described them (PROTOCOL.md §7c). */
|
|
188
|
+
rooms(): Promise<RoomView[]>;
|
|
189
|
+
roomCommand(hub: string, type: 'room:create' | 'room:join' | 'room:leave' | 'room:invite' | 'room:list', payload: Record<string, unknown>): Promise<{
|
|
190
|
+
ok: boolean;
|
|
191
|
+
error?: string;
|
|
192
|
+
}>;
|
|
193
|
+
/** Post to a room: one sealed copy per member, so there is no key and no rotation. */
|
|
194
|
+
postToRoom(room: string, text: string): Promise<{
|
|
195
|
+
ok: boolean;
|
|
196
|
+
sent: number;
|
|
197
|
+
held: number;
|
|
198
|
+
skipped: number;
|
|
199
|
+
error?: string;
|
|
200
|
+
}>;
|
|
201
|
+
listPairRequests(): Promise<PairRequestInfo[]>;
|
|
202
|
+
acceptPair(ref: string, grant?: PairGrantInput): Promise<{
|
|
203
|
+
ok: boolean;
|
|
204
|
+
error?: string;
|
|
205
|
+
}>;
|
|
206
|
+
denyPair(ref: string): Promise<{
|
|
207
|
+
ok: boolean;
|
|
208
|
+
}>;
|
|
60
209
|
}
|
|
61
210
|
|
|
62
211
|
/** What a node needs to start. Resolved from stored settings, env and defaults. */
|
|
@@ -76,28 +225,63 @@ interface Notice {
|
|
|
76
225
|
last: number;
|
|
77
226
|
}
|
|
78
227
|
interface Node {
|
|
79
|
-
net:
|
|
228
|
+
net: NetLike;
|
|
80
229
|
inbox: Inbox;
|
|
81
|
-
/** What happened when we tried to join the configured hub, for whoami to report honestly. */
|
|
82
|
-
hubStatus: string;
|
|
83
230
|
/** Trust decisions waiting on the person, newest last. */
|
|
84
231
|
requests: PendingRequest[];
|
|
85
232
|
/** Who tried to reach us while we were away, as the hub reports on reconnect. */
|
|
86
233
|
notices: Notice[];
|
|
87
234
|
options: NodeOptions;
|
|
235
|
+
/** What happened when the node last tried to join the configured hub. */
|
|
236
|
+
hubStatus: string;
|
|
88
237
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
238
|
+
* Whether the client will run a model when the server asks — MCP's `sampling` capability, and
|
|
239
|
+
* the only way an arriving message could ever answer itself. Set once the client has connected.
|
|
91
240
|
*/
|
|
241
|
+
canSample: boolean;
|
|
242
|
+
/** Where this node is running, which decides whether it is there when nothing is open. */
|
|
243
|
+
where: 'daemon' | 'in-process';
|
|
244
|
+
/** Be told when a message lands, so a session can wait for a reply rather than poll for one. */
|
|
92
245
|
onMessage: (fn: (m: Message) => void) => () => void;
|
|
93
246
|
stop: () => Promise<void>;
|
|
94
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Attach to this identity's daemon, starting one if none is running.
|
|
250
|
+
*
|
|
251
|
+
* Falls back to a node in this process when a daemon cannot be had — a sandbox that forbids
|
|
252
|
+
* spawning, say. Everything still works; it is simply only present while this session is.
|
|
253
|
+
*/
|
|
95
254
|
declare function startNode(options: NodeOptions): Promise<Node>;
|
|
96
255
|
|
|
97
256
|
declare function run(argv: string[], out?: NodeJS.WriteStream & {
|
|
98
257
|
fd: 1;
|
|
99
258
|
}): Promise<number>;
|
|
100
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Guided flows, shipped with the server.
|
|
262
|
+
*
|
|
263
|
+
* A tool list tells a session what it *can* do, not what to do first, in what order, or what the
|
|
264
|
+
* words mean. In a fresh session "pair with my agent at <url>" only works if the model happens to
|
|
265
|
+
* match the sentence to `volenet_connect` — which it usually will, and shouldn't have to. Prompts
|
|
266
|
+
* surface in the client as commands, so the flow is chosen rather than guessed.
|
|
267
|
+
*
|
|
268
|
+
* They ship in this package rather than as files written into someone's editor config, so
|
|
269
|
+
* installing is all it takes and they cannot drift from the tools they describe.
|
|
270
|
+
*/
|
|
271
|
+
interface PromptArgument {
|
|
272
|
+
name: string;
|
|
273
|
+
description: string;
|
|
274
|
+
required?: boolean;
|
|
275
|
+
}
|
|
276
|
+
interface PromptDef {
|
|
277
|
+
name: string;
|
|
278
|
+
description: string;
|
|
279
|
+
arguments?: PromptArgument[];
|
|
280
|
+
/** The instruction handed to the session, with any arguments already filled in. */
|
|
281
|
+
render: (args: Record<string, string>) => string;
|
|
282
|
+
}
|
|
283
|
+
declare const PROMPTS: PromptDef[];
|
|
284
|
+
|
|
101
285
|
interface ToolDef {
|
|
102
286
|
name: string;
|
|
103
287
|
description: string;
|
|
@@ -133,5 +317,7 @@ declare const TOOLS: ToolDef[];
|
|
|
133
317
|
declare function unreadFooter(node: Node, toolName: string): string;
|
|
134
318
|
/** Wire the tools to an MCP server. Separated so a test can drive it without a transport. */
|
|
135
319
|
declare function createServer(node: Node): Server;
|
|
320
|
+
/** Remember what the client can do, so a later session can say so without asking again. */
|
|
321
|
+
declare function recordClientCapabilities(dir: string, caps: unknown): Promise<void>;
|
|
136
322
|
|
|
137
|
-
export { Inbox, type Node, type NodeOptions, type Settings, TOOLS, type ToolDef, createServer, defaultDir, defaultName, loadStored, resolveSettings, run as runCli, saveStored, startNode, unreadFooter };
|
|
323
|
+
export { Inbox, type Node, type NodeOptions, PROMPTS, type PromptDef, type Settings, TOOLS, type ToolDef, createServer, defaultDir, defaultName, loadStored, recordClientCapabilities, resolveSettings, run as runCli, saveStored, startNode, unreadFooter };
|