ollanet 0.2.1 → 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/README.md CHANGED
@@ -1,9 +1,15 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/Catalyst-Forge-LLC/ollanet/main/assets/ollanet-logo.png" alt="ollanet logo" width="360">
3
+ </p>
4
+
1
5
  # ollanet
2
6
 
3
7
  Chat with **Ollama** servers on **any network you can reach** — LAN, localhost, Tailscale, VPN, or a raw IP.
4
8
 
5
9
  Scan for models, fire prompts, and continue conversations later by a short hash. No browser UI required.
6
10
 
11
+ **Site:** [ollanet.dev](https://ollanet.dev) — Downpress + Cloudflare Pages (`site/`).
12
+
7
13
  ## Origin
8
14
 
9
15
  This started as a personal little script. I had Ollama running on a laptop on my Tailscale network and wanted an easy way to discover it, poke at the models, and actually talk to them from another machine — without babysitting IPs or opening a browser. One scan/prompt tool later, chats by hash showed up, LAN discovery crept in, and the thing grew into **ollanet**: a tiny CLI for using Ollama over whatever network you already have.
@@ -26,6 +32,26 @@ They share the same Ollama API; Finetuna shapes the models, ollanet finds and ch
26
32
  - **Persist** chats as `responses/<hash>.json` with topic, machine, model, timestamps
27
33
  - **Continue** any thread with `--chat <hash>`
28
34
  - **Configure** per-machine defaults (model, temperature, context, …)
35
+ - **MCP** — `ollanet mcp` exposes scan/prompt/chats as Model Context Protocol tools for agents
36
+
37
+ ## Use cases
38
+
39
+ The core idea: turn “I have to remember which IP has which models loaded” into a discoverable, stateful, network-native primitive. That unlocks more than interactive chat:
40
+
41
+ **For agents and automation** — prefer `ollanet mcp` (stdio MCP). The same flows also work via CLI flags:
42
+
43
+ - **Live model inventory / routing** — `ollanet_scan` (or `scan --json`) lists reachable hosts and models. Route each sub-task to the best host: the big-VRAM box for reasoning, the laptop for quick lookups.
44
+ - **Conversations that survive machines** — chats are stored by short hash with full history. Start a long thread on the GPU box, continue it later with `ollanet_prompt` + `chat_id` (or `--chat <hash>`).
45
+ - **A “talk to any Ollama on my network” tool** — `ollanet_prompt` / `prompt … --json --no-stream`; no hard-coded endpoints.
46
+ - **Fleet health and speed checks** — periodic `ollanet bench <host> --json` builds a record of which model on which machine is currently fastest (or has silently gone offline). Saved results live in `benchmarks/`.
47
+ - **The Finetuna loop** — [Finetuna](https://github.com/Catalyst-Forge-LLC/finetuna) shapes a GPU-tuned named variant on the host; ollanet discovers and uses it from anywhere on the network.
48
+
49
+ **For humans:**
50
+
51
+ - **Tailscale-native access** — prompt any machine by MagicDNS name from a laptop or phone (Termux) without babysitting IPs or opening a browser.
52
+ - **A private model mesh** — everyone on a shared tailnet or LAN can discover and use the same Ollama hosts, no public exposure, no central UI.
53
+ - **Multi-GPU lab** — `scan --lan` treats several machines as a pool of inference endpoints; `bench` compares the same model across hardware.
54
+ - **Audit trail / notes** — persisted chat JSON doubles as a research log you can resume from any device.
29
55
 
30
56
  ## Requirements
31
57
 
@@ -58,6 +84,8 @@ ollanet scan
58
84
  When installed this way, config lives at `~/.ollanet/config.json` and chats are
59
85
  saved to `~/.ollanet/responses/`.
60
86
 
87
+ Releases can publish from GitHub Actions via npm **trusted publishing** (OIDC + provenance). Setup notes: [docs/trusted-publishing.md](docs/trusted-publishing.md).
88
+
61
89
  ### From a checkout (development)
62
90
 
63
91
  ```bash
@@ -103,6 +131,7 @@ ollanet chats --id a1b2c3d4e5f6
103
131
  | `ollanet prompt …` | Send a prompt / continue a chat |
104
132
  | `ollanet chats` | List or inspect saved transcripts |
105
133
  | `ollanet bench …` | Benchmark models for tok/s + lightweight quality checks |
134
+ | `ollanet mcp` | Stdio [MCP](https://modelcontextprotocol.io) server for agents |
106
135
 
107
136
  ### `scan` options
108
137
 
@@ -140,6 +169,36 @@ Machine can be a discovered name, hostname, FQDN, or IP (`192.168.1.50`, `host:1
140
169
  - `--json` — list summary JSON
141
170
  - `--id <hash>` — show one chat (full JSON with `--json`)
142
171
 
172
+ ### `mcp`
173
+
174
+ ```bash
175
+ ollanet mcp
176
+ ```
177
+
178
+ Runs a **stdio MCP server** (no extra npm deps). Cursor / Claude Desktop / any MCP host can spawn it and call:
179
+
180
+ | Tool | What it does |
181
+ |---|---|
182
+ | `ollanet_scan` | Discover hosts + models (`lan`, `all` optional) |
183
+ | `ollanet_prompt` | Prompt a host or continue with `chat_id` |
184
+ | `ollanet_list_chats` | Summarize saved transcripts |
185
+ | `ollanet_get_chat` | Load one chat (full history) |
186
+
187
+ Example Cursor MCP config (`~/.cursor/mcp.json` or project `.cursor/mcp.json`):
188
+
189
+ ```json
190
+ {
191
+ "mcpServers": {
192
+ "ollanet": {
193
+ "command": "npx",
194
+ "args": ["-y", "ollanet", "mcp"]
195
+ }
196
+ }
197
+ }
198
+ ```
199
+
200
+ Or, from a checkout: `"command": "pnpm"`, `"args": ["ollanet", "--", "mcp"]` (with `cwd` set to the repo). Uses the same config/chats dirs as the CLI (`~/.ollanet/` when installed).
201
+
143
202
  ### `bench`
144
203
 
145
204
  ```bash
package/dist/cli.js CHANGED
@@ -17,6 +17,7 @@ Usage:
17
17
  ollanet prompt --chat <hash> <prompt...>
18
18
  ollanet chats [--json] [--id <hash>]
19
19
  ollanet bench <machine|ip> [model...] [--all] [options]
20
+ ollanet mcp
20
21
 
21
22
  Examples:
22
23
  ollanet scan
@@ -27,6 +28,7 @@ Examples:
27
28
  ollanet chats
28
29
  ollanet bench localhost --all
29
30
  ollanet bench localhost llama3.2:1b --runs 3
31
+ ollanet mcp # stdio MCP server for agents
30
32
 
31
33
  Config: ~/.ollanet/config.json when installed, ./config.json in a checkout
32
34
  (override with OLLANET_CONFIG)
@@ -77,6 +79,11 @@ async function main() {
77
79
  await run();
78
80
  return;
79
81
  }
82
+ case "mcp": {
83
+ const { main: run } = await import("./mcp.js");
84
+ await run();
85
+ return;
86
+ }
80
87
  default:
81
88
  console.error(`Unknown command: ${cmd}\n`);
82
89
  process.stderr.write(HELP);
package/dist/mcp.js ADDED
@@ -0,0 +1,285 @@
1
+ /**
2
+ * ollanet mcp — stdio MCP server (zero deps).
3
+ *
4
+ * Speaks the Model Context Protocol over stdin/stdout so agents (Cursor,
5
+ * Claude Desktop, etc.) can discover hosts, prompt models, and continue chats.
6
+ *
7
+ * ollanet mcp
8
+ *
9
+ * stdout is reserved for JSON-RPC; log only to stderr.
10
+ */
11
+ import { createInterface } from "node:readline";
12
+ import { readFile } from "node:fs/promises";
13
+ import { listChats, loadChat } from "./chat-store.js";
14
+ import { projectPath } from "./paths.js";
15
+ import { runPrompt } from "./prompt.js";
16
+ import { scanNetwork } from "./scan.js";
17
+ const PROTOCOL_VERSION = "2024-11-05";
18
+ const TOOLS = [
19
+ {
20
+ name: "ollanet_scan",
21
+ description: "Discover reachable Ollama hosts and list their models. " +
22
+ "Use this before routing work to pick a host/model. " +
23
+ "Optional LAN TCP scan (--lan equivalent) and include offline Tailscale peers.",
24
+ inputSchema: {
25
+ type: "object",
26
+ properties: {
27
+ lan: {
28
+ type: "boolean",
29
+ description: "TCP-scan local LAN CIDRs for open Ollama ports (default false)",
30
+ },
31
+ all: {
32
+ type: "boolean",
33
+ description: "Also probe offline Tailscale peers (default false)",
34
+ },
35
+ },
36
+ additionalProperties: false,
37
+ },
38
+ },
39
+ {
40
+ name: "ollanet_prompt",
41
+ description: "Send a prompt to an Ollama host on the network (or continue a saved chat by id). " +
42
+ "Returns the assistant reply and chat_id for follow-ups. " +
43
+ "Machine can be a discovered name, MagicDNS name, hostname, or IP[:port].",
44
+ inputSchema: {
45
+ type: "object",
46
+ properties: {
47
+ prompt: {
48
+ type: "string",
49
+ description: "User message to send",
50
+ },
51
+ machine: {
52
+ type: "string",
53
+ description: "Host name/IP (required for new chats; optional override when continuing)",
54
+ },
55
+ model: {
56
+ type: "string",
57
+ description: "Model name (optional; uses config default or chat model)",
58
+ },
59
+ chat_id: {
60
+ type: "string",
61
+ description: "Continue a saved chat by its hex hash id",
62
+ },
63
+ system: {
64
+ type: "string",
65
+ description: "System prompt (new chats, or if the chat has none yet)",
66
+ },
67
+ temperature: { type: "number" },
68
+ num_predict: { type: "integer" },
69
+ num_ctx: { type: "integer" },
70
+ keep_alive: {
71
+ description: 'Keep model loaded (e.g. "5m", 0, -1)',
72
+ },
73
+ think: {
74
+ type: "boolean",
75
+ description: "Enable model thinking (default false)",
76
+ },
77
+ save: {
78
+ type: "boolean",
79
+ description: "Persist the transcript (default true)",
80
+ },
81
+ },
82
+ required: ["prompt"],
83
+ additionalProperties: false,
84
+ },
85
+ },
86
+ {
87
+ name: "ollanet_list_chats",
88
+ description: "List saved ollanet chat transcripts (id, topic, machine, model, message count).",
89
+ inputSchema: {
90
+ type: "object",
91
+ properties: {},
92
+ additionalProperties: false,
93
+ },
94
+ },
95
+ {
96
+ name: "ollanet_get_chat",
97
+ description: "Load one saved chat by id, including full message history.",
98
+ inputSchema: {
99
+ type: "object",
100
+ properties: {
101
+ chat_id: {
102
+ type: "string",
103
+ description: "Hex chat hash from a prior prompt",
104
+ },
105
+ },
106
+ required: ["chat_id"],
107
+ additionalProperties: false,
108
+ },
109
+ },
110
+ ];
111
+ function writeMessage(msg) {
112
+ process.stdout.write(`${JSON.stringify(msg)}\n`);
113
+ }
114
+ function ok(id, result) {
115
+ if (id === undefined)
116
+ return;
117
+ writeMessage({ jsonrpc: "2.0", id, result });
118
+ }
119
+ function fail(id, code, message) {
120
+ if (id === undefined)
121
+ return;
122
+ writeMessage({ jsonrpc: "2.0", id, error: { code, message } });
123
+ }
124
+ function textResult(data, isError = false) {
125
+ const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
126
+ return {
127
+ content: [{ type: "text", text }],
128
+ ...(isError ? { isError: true } : {}),
129
+ };
130
+ }
131
+ async function packageVersion() {
132
+ try {
133
+ const pkg = JSON.parse(await readFile(projectPath("package.json"), "utf8"));
134
+ return pkg.version ?? "0.0.0";
135
+ }
136
+ catch {
137
+ return "0.0.0";
138
+ }
139
+ }
140
+ async function callTool(name, args) {
141
+ switch (name) {
142
+ case "ollanet_scan": {
143
+ const payload = await scanNetwork({
144
+ lanScan: Boolean(args.lan),
145
+ includeOffline: Boolean(args.all),
146
+ });
147
+ return textResult(payload);
148
+ }
149
+ case "ollanet_prompt": {
150
+ const prompt = typeof args.prompt === "string" ? args.prompt : "";
151
+ if (!prompt.trim()) {
152
+ return textResult({ error: "prompt is required" }, true);
153
+ }
154
+ const settings = {};
155
+ if (typeof args.system === "string")
156
+ settings.system = args.system;
157
+ if (typeof args.temperature === "number")
158
+ settings.temperature = args.temperature;
159
+ if (typeof args.num_predict === "number")
160
+ settings.num_predict = Math.trunc(args.num_predict);
161
+ if (typeof args.num_ctx === "number")
162
+ settings.num_ctx = Math.trunc(args.num_ctx);
163
+ if (typeof args.think === "boolean")
164
+ settings.think = args.think;
165
+ if (args.keep_alive !== undefined) {
166
+ const raw = args.keep_alive;
167
+ if (typeof raw === "number")
168
+ settings.keep_alive = raw;
169
+ else if (typeof raw === "string") {
170
+ const asNum = Number(raw);
171
+ settings.keep_alive =
172
+ raw.trim() !== "" && Number.isFinite(asNum) && String(asNum) === raw ? asNum : raw;
173
+ }
174
+ }
175
+ const result = await runPrompt({
176
+ prompt,
177
+ machine: typeof args.machine === "string" ? args.machine : undefined,
178
+ model: typeof args.model === "string" ? args.model : undefined,
179
+ chatId: typeof args.chat_id === "string" ? args.chat_id : undefined,
180
+ save: args.save === undefined ? true : Boolean(args.save),
181
+ settings,
182
+ writeStdout: false,
183
+ stream: false,
184
+ quiet: true,
185
+ });
186
+ return textResult(result);
187
+ }
188
+ case "ollanet_list_chats": {
189
+ const chats = await listChats();
190
+ return textResult(chats.map((c) => ({
191
+ id: c.id,
192
+ topic: c.topic,
193
+ machine: c.machine,
194
+ model: c.model,
195
+ messages: c.messages.length,
196
+ created_at: c.created_at,
197
+ updated_at: c.updated_at,
198
+ })));
199
+ }
200
+ case "ollanet_get_chat": {
201
+ const id = typeof args.chat_id === "string" ? args.chat_id : "";
202
+ if (!id)
203
+ return textResult({ error: "chat_id is required" }, true);
204
+ const chat = await loadChat(id);
205
+ return textResult(chat);
206
+ }
207
+ default:
208
+ return textResult({ error: `Unknown tool: ${name}` }, true);
209
+ }
210
+ }
211
+ async function handleRequest(msg, version) {
212
+ const { id, method } = msg;
213
+ if (!method) {
214
+ fail(id, -32600, "Invalid Request: missing method");
215
+ return;
216
+ }
217
+ // Notifications (no id) — acknowledge silently.
218
+ if (method === "notifications/initialized" || method.startsWith("notifications/")) {
219
+ return;
220
+ }
221
+ switch (method) {
222
+ case "initialize":
223
+ ok(id, {
224
+ protocolVersion: PROTOCOL_VERSION,
225
+ capabilities: { tools: {} },
226
+ serverInfo: { name: "ollanet", version },
227
+ });
228
+ return;
229
+ case "ping":
230
+ ok(id, {});
231
+ return;
232
+ case "tools/list":
233
+ ok(id, { tools: TOOLS });
234
+ return;
235
+ case "tools/call": {
236
+ const params = (msg.params ?? {});
237
+ const name = params.name;
238
+ if (!name) {
239
+ fail(id, -32602, "tools/call requires params.name");
240
+ return;
241
+ }
242
+ try {
243
+ const result = await callTool(name, params.arguments ?? {});
244
+ ok(id, result);
245
+ }
246
+ catch (err) {
247
+ const message = err instanceof Error ? err.message : String(err);
248
+ ok(id, textResult({ error: message }, true));
249
+ }
250
+ return;
251
+ }
252
+ default:
253
+ fail(id, -32601, `Method not found: ${method}`);
254
+ }
255
+ }
256
+ export async function main() {
257
+ const version = await packageVersion();
258
+ console.error(`ollanet mcp ${version} (stdio) — tools: ${TOOLS.map((t) => t.name).join(", ")}`);
259
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
260
+ for await (const line of rl) {
261
+ const trimmed = line.trim();
262
+ if (!trimmed)
263
+ continue;
264
+ let msg;
265
+ try {
266
+ msg = JSON.parse(trimmed);
267
+ }
268
+ catch {
269
+ writeMessage({
270
+ jsonrpc: "2.0",
271
+ id: null,
272
+ error: { code: -32700, message: "Parse error" },
273
+ });
274
+ continue;
275
+ }
276
+ // JSON-RPC batch
277
+ if (Array.isArray(msg)) {
278
+ for (const item of msg) {
279
+ await handleRequest(item, version);
280
+ }
281
+ continue;
282
+ }
283
+ await handleRequest(msg, version);
284
+ }
285
+ }
package/dist/prompt.js CHANGED
@@ -308,11 +308,13 @@ function formatDuration(ns) {
308
308
  return `${ms.toFixed(0)}ms`;
309
309
  return `${(ms / 1000).toFixed(1)}s`;
310
310
  }
311
- export async function main() {
312
- const parsed = parseArgs(process.argv.slice(2));
313
- const { stream, json, save } = parsed;
314
- let promptParts = parsed.promptParts;
315
- const fromStdin = await readStdinIfPiped();
311
+ /** Programmatic prompt used by CLI `--json` and the MCP server. */
312
+ export async function runPrompt(opts) {
313
+ const save = opts.save !== false;
314
+ const stream = opts.stream !== false && opts.writeStdout !== false;
315
+ const quiet = opts.quiet === true || opts.writeStdout === false;
316
+ const writeStdout = opts.writeStdout !== false;
317
+ let promptParts = opts.prompt ? [opts.prompt] : [];
316
318
  const config = await loadConfig();
317
319
  const { hosts: targets } = await discoverHosts({
318
320
  hosts: config.hosts,
@@ -320,50 +322,34 @@ export async function main() {
320
322
  });
321
323
  let chat;
322
324
  let isNewChat = false;
323
- let machineQuery = parsed.machine;
324
- if (parsed.chatId) {
325
- chat = await loadChat(parsed.chatId);
326
- // Allow `ollanet prompt <host> --chat HASH "follow-up"` only when the
327
- // leading token matches an already-discovered host (not direct-address fallback).
328
- if (!machineQuery && promptParts.length >= 2) {
329
- const hit = findDiscoveredHost(targets, promptParts[0]);
330
- if (hit) {
331
- machineQuery = promptParts[0];
332
- promptParts = promptParts.slice(1);
333
- }
334
- }
325
+ let machineQuery = opts.machine;
326
+ if (opts.chatId) {
327
+ chat = await loadChat(opts.chatId);
335
328
  }
336
329
  machineQuery = machineQuery ?? chat?.machine;
337
330
  if (!machineQuery) {
338
- console.error("Machine is required for a new chat (or use --chat <hash>).");
339
- usage();
331
+ throw new Error("Machine is required for a new chat (or pass chatId).");
340
332
  }
341
333
  const host = resolveHost(targets, machineQuery);
342
334
  if (!host.online && !host.isSelf && host.source === "tailscale") {
343
335
  throw new Error(`Machine "${shortName(host)}" appears offline.`);
344
336
  }
345
337
  let peeledModel;
346
- if (!parsed.chatId && !parsed.model) {
347
- const peeled = await maybePeelModel(host, promptParts, fromStdin);
338
+ if (!opts.chatId && !opts.model) {
339
+ const peeled = await maybePeelModel(host, promptParts, "");
348
340
  peeledModel = peeled.model;
349
341
  promptParts = peeled.promptParts;
350
342
  }
351
- const promptText = [promptParts.join(" ").trim(), fromStdin].filter(Boolean).join("\n\n").trim();
343
+ const promptText = promptParts.join(" ").trim();
352
344
  if (!promptText) {
353
- console.error("No prompt provided (pass args and/or pipe stdin).");
354
- usage();
345
+ throw new Error("No prompt provided.");
355
346
  }
356
- const model = parsed.model ??
357
- peeledModel ??
358
- chat?.model ??
359
- defaultModelForHost(config, host);
347
+ const model = opts.model ?? peeledModel ?? chat?.model ?? defaultModelForHost(config, host);
360
348
  if (!model) {
361
349
  throw new Error(`No model specified for "${shortName(host)}" and no default in ${configPath()}.\n` +
362
- `Pass a model, use --model <name>, or add it under defaultModels.`);
350
+ `Pass a model or add it under defaultModels.`);
363
351
  }
364
- const settings = mergeSettings(
365
- // Thinking models otherwise spend the whole num_predict budget on hidden reasoning.
366
- { think: false }, config.defaults, machineSettingsForHost(config, host), settingsFromEnv(), chat?.system ? { system: chat.system } : undefined, parsed.settings);
352
+ const settings = mergeSettings({ think: false }, config.defaults, machineSettingsForHost(config, host), settingsFromEnv(), chat?.system ? { system: chat.system } : undefined, opts.settings);
367
353
  if (!chat && save) {
368
354
  chat = createChat({
369
355
  machine: shortName(host),
@@ -374,7 +360,6 @@ export async function main() {
374
360
  isNewChat = true;
375
361
  }
376
362
  else if (!chat) {
377
- // --no-save one-shot: ephemeral transcript for the API call only
378
363
  chat = createChat({
379
364
  machine: shortName(host),
380
365
  model,
@@ -383,26 +368,26 @@ export async function main() {
383
368
  isNewChat = true;
384
369
  }
385
370
  else {
386
- if (parsed.settings.system && !chat.system) {
387
- chat.system = parsed.settings.system;
371
+ if (opts.settings?.system && !chat.system) {
372
+ chat.system = opts.settings.system;
388
373
  }
389
374
  chat.model = model;
390
375
  chat.machine = shortName(host);
391
376
  }
392
377
  appendMessage(chat, { role: "user", content: promptText });
393
378
  const baseUrl = ollamaBaseUrl(host);
394
- if (!json) {
395
- const src = parsed.model ? "explicit" : chat && !isNewChat ? "chat" : "default";
379
+ if (!quiet) {
380
+ const src = opts.model ? "explicit" : chat && !isNewChat ? "chat" : "default";
396
381
  const extras = settingsSummary(settings);
397
- const chatTag = parsed.chatId ? ` chat=${chat.id}` : "";
382
+ const chatTag = opts.chatId ? ` chat=${chat.id}` : "";
398
383
  console.error(`→ ${shortName(host)} (${host.ip}) model=${model} [${src}]${chatTag}${extras ? ` ${extras}` : ""}`);
399
384
  }
400
385
  const { content, thinking, chunk } = await ollamaChat({
401
386
  baseUrl,
402
387
  model,
403
388
  messages: toApiMessages(chat),
404
- stream: json ? false : stream,
405
- writeStdout: !json,
389
+ stream: writeStdout ? stream : false,
390
+ writeStdout,
406
391
  timeoutMs: PROMPT_TIMEOUT_MS,
407
392
  settings,
408
393
  });
@@ -429,30 +414,100 @@ export async function main() {
429
414
  }
430
415
  if (save) {
431
416
  const file = await saveChat(chat);
432
- if (!json) {
417
+ if (!quiet) {
433
418
  console.error(`chat ${chat.id} topic: ${chat.topic}`);
434
419
  console.error(`saved ${file}`);
435
420
  console.error(`continue: ollanet prompt --chat ${chat.id} "your follow-up"`);
436
421
  }
437
422
  }
438
- if (json) {
439
- console.log(JSON.stringify({
440
- chat_id: save ? chat.id : null,
441
- topic: chat.topic,
442
- content,
443
- thinking: thinking || null,
444
- ollama: chunk,
445
- }, null, 2));
446
- return;
423
+ return {
424
+ chat_id: save ? chat.id : null,
425
+ topic: chat.topic,
426
+ content,
427
+ thinking: thinking || null,
428
+ machine: shortName(host),
429
+ model,
430
+ ollama: chunk,
431
+ };
432
+ }
433
+ export async function main() {
434
+ const parsed = parseArgs(process.argv.slice(2));
435
+ const { stream, json, save } = parsed;
436
+ let promptParts = parsed.promptParts;
437
+ const fromStdin = await readStdinIfPiped();
438
+ const config = await loadConfig();
439
+ const { hosts: targets } = await discoverHosts({
440
+ hosts: config.hosts,
441
+ discovery: config.discovery,
442
+ });
443
+ let machineQuery = parsed.machine;
444
+ if (parsed.chatId) {
445
+ // Allow `ollanet prompt <host> --chat HASH "follow-up"` only when the
446
+ // leading token matches an already-discovered host (not direct-address fallback).
447
+ if (!machineQuery && promptParts.length >= 2) {
448
+ const hit = findDiscoveredHost(targets, promptParts[0]);
449
+ if (hit) {
450
+ machineQuery = promptParts[0];
451
+ promptParts = promptParts.slice(1);
452
+ }
453
+ }
454
+ }
455
+ let peeledModel;
456
+ if (!parsed.chatId && !parsed.model) {
457
+ if (!machineQuery) {
458
+ console.error("Machine is required for a new chat (or use --chat <hash>).");
459
+ usage();
460
+ }
461
+ const host = resolveHost(targets, machineQuery);
462
+ const peeled = await maybePeelModel(host, promptParts, fromStdin);
463
+ peeledModel = peeled.model;
464
+ promptParts = peeled.promptParts;
447
465
  }
448
- const stats = [
449
- chunk.eval_count != null ? `${chunk.eval_count} tokens` : "",
450
- chunk.eval_duration
451
- ? `${((chunk.eval_count ?? 0) / (chunk.eval_duration / 1e9)).toFixed(1)} tok/s`
452
- : "",
453
- chunk.total_duration ? formatDuration(chunk.total_duration) : "",
454
- ].filter(Boolean);
455
- if (stats.length > 0) {
456
- console.error(`(${stats.join(" · ")})`);
466
+ const promptText = [promptParts.join(" ").trim(), fromStdin].filter(Boolean).join("\n\n").trim();
467
+ if (!promptText) {
468
+ console.error("No prompt provided (pass args and/or pipe stdin).");
469
+ usage();
470
+ }
471
+ try {
472
+ const result = await runPrompt({
473
+ machine: machineQuery,
474
+ model: parsed.model ?? peeledModel,
475
+ prompt: promptText,
476
+ chatId: parsed.chatId,
477
+ save,
478
+ settings: parsed.settings,
479
+ writeStdout: !json,
480
+ stream: json ? false : stream,
481
+ quiet: json,
482
+ });
483
+ if (json) {
484
+ console.log(JSON.stringify({
485
+ chat_id: result.chat_id,
486
+ topic: result.topic,
487
+ content: result.content,
488
+ thinking: result.thinking,
489
+ ollama: result.ollama,
490
+ }, null, 2));
491
+ return;
492
+ }
493
+ const chunk = result.ollama;
494
+ const stats = [
495
+ chunk.eval_count != null ? `${chunk.eval_count} tokens` : "",
496
+ chunk.eval_duration
497
+ ? `${((Number(chunk.eval_count) || 0) / (Number(chunk.eval_duration) / 1e9)).toFixed(1)} tok/s`
498
+ : "",
499
+ chunk.total_duration ? formatDuration(Number(chunk.total_duration)) : "",
500
+ ].filter(Boolean);
501
+ if (stats.length > 0) {
502
+ console.error(`(${stats.join(" · ")})`);
503
+ }
504
+ }
505
+ catch (err) {
506
+ const message = err instanceof Error ? err.message : String(err);
507
+ if (message.startsWith("Machine is required") || message.startsWith("No prompt")) {
508
+ console.error(message);
509
+ usage();
510
+ }
511
+ throw err;
457
512
  }
458
513
  }
package/dist/scan.js CHANGED
@@ -113,6 +113,49 @@ function printResults(results, networkLabel, sources) {
113
113
  }
114
114
  console.log(`Found ${found.length} Ollama server(s) with ${found.reduce((n, r) => n + r.models.length, 0)} model entrie(s).`);
115
115
  }
116
+ function toPayload(results, networkLabel, sources) {
117
+ return {
118
+ network: networkLabel,
119
+ sources,
120
+ port: OLLAMA_PORT,
121
+ scanned: results.length,
122
+ servers: results
123
+ .filter((r) => r.ok)
124
+ .map((r) => ({
125
+ hostname: r.host.hostname,
126
+ dnsName: r.host.dnsName,
127
+ ip: r.host.ip,
128
+ port: r.host.port,
129
+ os: r.host.os,
130
+ source: r.host.source,
131
+ self: r.host.isSelf,
132
+ endpoint: `http://${r.host.ip}:${r.host.port}`,
133
+ models: r.models.map((m) => ({
134
+ name: m.name,
135
+ size: m.size,
136
+ modified_at: m.modified_at,
137
+ parameter_size: m.details?.parameter_size,
138
+ quantization_level: m.details?.quantization_level,
139
+ family: m.details?.family,
140
+ })),
141
+ })),
142
+ };
143
+ }
144
+ /** Programmatic scan used by CLI `--json` and the MCP server. */
145
+ export async function scanNetwork(options = {}) {
146
+ const config = await loadConfig();
147
+ const { hosts, sources, networkLabel } = await discoverHosts({
148
+ hosts: config.hosts,
149
+ discovery: config.discovery,
150
+ includeOffline: options.includeOffline ?? false,
151
+ lanScan: options.lanScan ?? false,
152
+ });
153
+ if (hosts.length === 0) {
154
+ throw new Error("No hosts to scan. Add config.hosts, set OLLANET_HOSTS, or enable Tailscale/LAN discovery.");
155
+ }
156
+ const results = await mapPool(hosts, CONCURRENCY, scanHost);
157
+ return toPayload(results, networkLabel, sources);
158
+ }
116
159
  export async function main() {
117
160
  const includeOffline = process.argv.includes("--all");
118
161
  const jsonOut = process.argv.includes("--json");
@@ -131,32 +174,7 @@ export async function main() {
131
174
  }
132
175
  const results = await mapPool(hosts, CONCURRENCY, scanHost);
133
176
  if (jsonOut) {
134
- console.log(JSON.stringify({
135
- network: networkLabel,
136
- sources,
137
- port: OLLAMA_PORT,
138
- scanned: results.length,
139
- servers: results
140
- .filter((r) => r.ok)
141
- .map((r) => ({
142
- hostname: r.host.hostname,
143
- dnsName: r.host.dnsName,
144
- ip: r.host.ip,
145
- port: r.host.port,
146
- os: r.host.os,
147
- source: r.host.source,
148
- self: r.host.isSelf,
149
- endpoint: `http://${r.host.ip}:${r.host.port}`,
150
- models: r.models.map((m) => ({
151
- name: m.name,
152
- size: m.size,
153
- modified_at: m.modified_at,
154
- parameter_size: m.details?.parameter_size,
155
- quantization_level: m.details?.quantization_level,
156
- family: m.details?.family,
157
- })),
158
- })),
159
- }, null, 2));
177
+ console.log(JSON.stringify(toPayload(results, networkLabel, sources), null, 2));
160
178
  return;
161
179
  }
162
180
  printResults(results, networkLabel, sources);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ollanet",
3
- "version": "0.2.1",
4
- "description": "Chat with Ollama servers on any reachable network — scan hosts, prompt models, continue chats by hash",
3
+ "version": "0.3.0",
4
+ "description": "Chat with Ollama servers on any reachable network — scan hosts, prompt models, continue chats by hash, MCP for agents",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "ollanet": "./dist/cli.js"
@@ -19,10 +19,14 @@
19
19
  "prompt": "tsx src/cli.ts prompt",
20
20
  "chats": "tsx src/cli.ts chats",
21
21
  "bench": "tsx src/cli.ts bench",
22
+ "mcp": "tsx src/cli.ts mcp",
22
23
  "start": "tsx src/cli.ts scan",
23
24
  "typecheck": "tsc --noEmit",
24
25
  "test": "tsc -p tsconfig.build.json && node --test test/*.test.mjs",
25
- "test:only": "node --test test/*.test.mjs"
26
+ "test:only": "node --test test/*.test.mjs",
27
+ "site:dev": "pnpm --dir site dev",
28
+ "site:build": "pnpm --dir site build",
29
+ "site:deploy": "pnpm --dir site deploy"
26
30
  },
27
31
  "keywords": [
28
32
  "ollama",
@@ -31,7 +35,9 @@
31
35
  "chat",
32
36
  "lan",
33
37
  "tailscale",
34
- "network"
38
+ "network",
39
+ "mcp",
40
+ "model-context-protocol"
35
41
  ],
36
42
  "author": "Catalyst Forge LLC",
37
43
  "license": "MIT",
@@ -39,7 +45,7 @@
39
45
  "type": "git",
40
46
  "url": "git+https://github.com/Catalyst-Forge-LLC/ollanet.git"
41
47
  },
42
- "homepage": "https://github.com/Catalyst-Forge-LLC/ollanet#readme",
48
+ "homepage": "https://ollanet.dev",
43
49
  "bugs": {
44
50
  "url": "https://github.com/Catalyst-Forge-LLC/ollanet/issues"
45
51
  },