@softov/ahpc 0.2.0 → 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,5 +1,6 @@
1
1
  # ahpc
2
2
 
3
+ [![CI](https://github.com/softov/ahpc/actions/workflows/ci.yml/badge.svg)](https://github.com/softov/ahpc/actions/workflows/ci.yml)
3
4
  [![npm](https://img.shields.io/npm/v/%40softov%2Fahpc)](https://www.npmjs.com/package/@softov/ahpc)
4
5
  ![license MIT](https://img.shields.io/badge/license-MIT-blue)
5
6
  ![node >=22](https://img.shields.io/badge/node-%3E%3D22-5fa04e)
@@ -214,8 +215,8 @@ Writes are guarded by the file's etag unless you pass `--force`, so two clients
214
215
 
215
216
  | Command | | |
216
217
  |---|---|---|
217
- | `mcp` | MCP on stdin and stdout, for a client that launches this process | |
218
- | `serve` | The same tools on a socket, shared | `--serve-host H` `--serve-port N` `--serve-token T` |
218
+ | `mcp` | MCP on stdin and stdout, for a client that launches this process | `--mcp-tools G,…` |
219
+ | `serve` | The same tools on a socket, shared | `--serve-host H` `--serve-port N` `--serve-token T` `--serve-origin URL` `--mcp-tools G,…` |
219
220
 
220
221
  ### Anything else
221
222
 
@@ -223,6 +224,7 @@ Writes are guarded by the file's etag unless you pass `--force`, so two clients
223
224
  |---|---|---|
224
225
  | `dispatch <uri> <type>` | Send a raw protocol action | `--field k=v` `--chat` |
225
226
  | `config` | Show the config file path and current values | `--json` |
227
+ | `--version` | What version this is | |
226
228
  | `help` | Print this command list | |
227
229
 
228
230
  ## As a tool server
@@ -252,10 +254,37 @@ curl -XPOST localhost:7431/api/new_session -d '{"workingDirectory":"/work"}'
252
254
  curl -XPOST localhost:7431/api/send_turn -d '{"session":"claude:/…","text":"what is in this directory"}'
253
255
  ```
254
256
 
257
+ ### More than the twelve
258
+
259
+ Files, terminals, automations and changesets are there too, one group at a time, and off unless asked for. That is on purpose: a tool table is read by a model alongside everything else it was given, and thirty tools is a worse server than twelve for the thing almost everybody wants, which is driving a session.
260
+
261
+ ```sh
262
+ ahpc --host ws://127.0.0.1:9187 mcp --mcp-tools resources,changes
263
+ ```
264
+
265
+ | Group | Tools |
266
+ |---|---|
267
+ | `resources` | `list_directory` `read_file` `write_file` `make_directory` `delete_path` `move_path` `copy_path` |
268
+ | `terminals` | `list_terminals` `new_terminal` `send_to_terminal` `read_terminal` `dispose_terminal` |
269
+ | `automations` | `list_automations` `run_automation` `set_automation_enabled` `remove_automation` |
270
+ | `changes` | `list_changesets` `show_changes` |
271
+
272
+ Repeatable as well as comma-separated. It is `--mcp-tools` rather than `--tools` because every other flag on this client is an AHP thing, and a bare `--tools` would read like it was choosing which tools the *agent* may call - a different question with a different answer.
273
+
274
+ Calling a tool from a group nobody turned on is refused with the flag that would turn it on, rather than with "no such tool", because those are different problems and only the person who started the server can fix the first.
275
+
276
+ Writing needs the host to have granted write access to that directory, and a host that has not refuses with `-32009` saying so. That is not something this client can grant on a model's behalf: it is the same question a person answers before a session may edit their repository.
277
+
278
+ `read_file` and `write_file` take a `file://` URI on the *host*, not a path on the machine running `ahpc` - the two may not be the same machine. `write_file` reads the file's etag first and refuses a write if it changed in between, unless passed `force`; a model reading a file, thinking, and writing it back is a read-modify-write with a person editing in the middle of it.
279
+
255
280
  `send_turn` blocks until the turn ends and returns what the agent said. A turn that stops to ask a person something is not finished: `wait_for_attention` says what it wants, and `confirm_tool_call` and `answer_question` answer it.
256
281
 
282
+ A caller that does not want to sit in silence for a minute puts a `progressToken` in the request's `_meta`, and gets a `notifications/progress` line for each tool the agent reaches for. On `serve` that also decides the shape of the reply: asked for, the POST is answered with an SSE stream carrying the notifications and then the result; not asked for, it is one JSON object. MCP has no shape for streaming partial *result* content, so the reply itself still arrives whole at the end - what this fixes is an agent that looked frozen, not one you want to watch write.
283
+
257
284
  It binds to `127.0.0.1` unless told otherwise, because anybody who can reach the port can drive every session on the host. `--serve-token` sets a bearer token, which is what makes `--serve-host 0.0.0.0` defensible.
258
285
 
286
+ Requests carrying a browser `Origin` are refused unless the origin is this server's own or was named with `--serve-origin`, repeatable. That is the transport's own rule and it is not paranoia: loopback is not the protection it looks like, because a page on any site can POST to `127.0.0.1` from inside the browser of the person running this, and the request arrives from their own machine. A program - a script, a webhook, an MCP client - sends no `Origin` and is let through.
287
+
259
288
  ## AHP support
260
289
 
261
290
  All 30 client-to-server requests are implemented, and 21 of the 45 client-dispatchable actions are used. `ahpc` subscribes to the root, session, chat, terminal and automation channels, plus the telemetry channel the host advertises for its log.
@@ -427,11 +427,12 @@ export function activityOf(status, asked, running, failed) {
427
427
  */
428
428
  function transcript(chat) {
429
429
  const out = [];
430
- const add = (value, running) => {
430
+ const built = (value, running) => {
431
+ const rows = [];
431
432
  const found = bag(value);
432
433
  const said = str(bag(found.message).text);
433
434
  if (said) {
434
- out.push({
435
+ rows.push({
435
436
  id: `${str(found.id) ?? ''}:said`,
436
437
  role: 'user',
437
438
  message: said,
@@ -442,14 +443,70 @@ function transcript(chat) {
442
443
  }
443
444
  const agent = turn(found, running);
444
445
  delete agent.message;
445
- out.push(agent);
446
+ rows.push(agent);
447
+ return rows;
446
448
  };
447
- for (const entry of list(chat.turns))
448
- add(entry, false);
449
+ const finished = list(chat.turns);
450
+ /*
451
+ * The whole finished prefix, when it is the same array it was last time.
452
+ *
453
+ * The per-turn cache below removes the building; this removes the walking.
454
+ * While a reply streams, only `activeTurn` changes - `turns` keeps its
455
+ * identity through every delta - so the history is copied once per turn
456
+ * rather than once per token, which is the difference between constant and
457
+ * linear on the path that runs most.
458
+ */
459
+ const whole = typeof chat.turns === 'object' && chat.turns !== null
460
+ ? prefixes.get(chat.turns)
461
+ : undefined;
462
+ if (whole !== undefined)
463
+ out.push(...whole);
464
+ else
465
+ for (const entry of finished) {
466
+ /*
467
+ * A finished turn is built once, and after that it is the same object.
468
+ *
469
+ * This runs after every reduced action, so a token arriving into a long
470
+ * conversation rebuilt every turn in it - measured at 2.5ms per token into
471
+ * two thousand turns, against a microsecond for the reduce that caused it.
472
+ *
473
+ * Keyed on the turn object rather than on its id, which is what makes the
474
+ * invalidation right by construction: the reducer is immutable, so a turn
475
+ * that changed is a new object and misses, and a turn that did not is the
476
+ * same one and hits. History loads, truncation and reconnect snapshots all
477
+ * build new objects, so there is nothing to remember to invalidate - which
478
+ * is the part of a cache like this that goes wrong.
479
+ *
480
+ * Weak, so nothing here keeps a conversation alive after the view on it
481
+ * has gone. Nothing mutates a `Turn` - every reader treats them as values -
482
+ * which is what makes handing the same array back twice safe.
483
+ */
484
+ if (typeof entry === 'object' && entry !== null) {
485
+ const had = ready.get(entry);
486
+ if (had !== undefined) {
487
+ out.push(...had);
488
+ continue;
489
+ }
490
+ const made = built(entry, false);
491
+ ready.set(entry, made);
492
+ out.push(...made);
493
+ continue;
494
+ }
495
+ out.push(...built(entry, false));
496
+ }
497
+ if (whole === undefined && typeof chat.turns === 'object' && chat.turns !== null) {
498
+ prefixes.set(chat.turns, [...out]);
499
+ }
500
+ // Never the running turn: it is a new object on every delta, so caching it
501
+ // would be a write per token and a hit never.
449
502
  if (chat.activeTurn)
450
- add(chat.activeTurn, true);
503
+ out.push(...built(chat.activeTurn, true));
451
504
  return out;
452
505
  }
506
+ /** What `transcript` has already built for a finished turn. See the note in it. */
507
+ const ready = new WeakMap();
508
+ /** And for a whole `turns` array, which is what a streaming reply does not change. */
509
+ const prefixes = new WeakMap();
453
510
  const KINDS = {
454
511
  text: 'text', number: 'number', integer: 'integer', boolean: 'boolean',
455
512
  'single-select': 'single-select', 'multi-select': 'multi-select',
@@ -1,5 +1,5 @@
1
1
  /** Every command, and the argv reading that picks one. */
2
- export declare const HELP = "ahpc - drive an agent host from a shell\n\n ahpc [--host ws://\u2026] <command> [args] the screen is 'ahpc' with no command\n\nSessions\n session list the catalogue, newest first [--archived] [--json]\n session show <uri> what the host says about one [--full] [--json]\n session new start one [--agent P] [--cwd DIR] [--set k=v]\u2026 [--json]\n session rm <uri> dispose it\n session history <uri> its turns [--all] [--full] [--json]\n session config <uri> the schema and what is in force [--json]\n session set <uri> <k> <v> change one config key\n session read <uri> mark read [--unread]\n session archive <uri> put it away [--undo]\n session customizations <uri> skills, prompts, agents, servers [--json]\n session export <uri> the whole session as one document\n [--json] [--markdown]\n session toggle <uri> <id> turn one on [--off]\n\nTurns\n prompt <uri> <text> say it and stream the answer [--model M] [--json]\n exec <text> a session, one turn, and dispose it\n [--agent P] [--cwd DIR] [--model M] [--json]\n cancel <uri> stop the running turn\n queue <uri> <text> say it after the one running [--model M]\n unqueue <uri> <id> take it back\n\nAnswering\n watch <uri> BLOCK until something wants a person, print, exit\n [--until turn|input|idle] [--timeout S] [--json]\n confirm <uri> <toolCallId> approve a tool call [--deny] [--option ID]\n answer <uri> <requestId> answer a question [--field k=v]\u2026 [--reject]\n\nChats\n chat list <uri> the conversations in a session [--json]\n chat new <uri> [text] another one beside it\n chat rm <chatUri> close one\n\nThe harness\n agents what it serves, and each one's models [--json]\n models every model, by harness [--json]\n commands what a slash offers [--json]\n customizations skills, prompts, agents and MCP servers,\n before any session exists [--kind k] [--json]\n completions <uri> <text> what the host would complete [--offset N] [--json]\n\nChanges and files\n changes <uri> the files a session touched [--json]\n [--list] [--scope s] [--<variable> v]\n [--reviewed f] [--unreviewed f]\n [--operations] what may be done to it\n [--run id] [--file f] [--yes] do one of them\n [--list] every changeset it offers\n [--scope <name>] one of them, e.g. turn\n [--turnId <id>] what a chosen scope still needs\n [--reviewed <file>] tick one off, repeatable\n [--unreviewed <file>] and clear one\n content <uri> <file> one of them, in full\n resource list <uri> a directory the host serves [--json]\n resource read <uri> a file on the host\n resource stat <uri> what it is, without reading it [--json]\n resource write <uri> [file] from a file, or from stdin [--create-only]\n guarded by the file's etag unless [--force]\n resource rm <uri> delete it [--recursive]\n resource mkdir <uri> make a directory\n resource mv <uri> <to> move it [--fail-if-exists]\n resource cp <uri> <to> copy it [--fail-if-exists]\n\nAutomations\n automation list what runs on its own [--json]\n automation show <uri> one of them [--json]\n automation triggers what this host can trigger on [--json]\n automation runs <uri> its history, every page [--json]\n automation run <uri> start it now\n automation enable <uri> switch it on\n automation disable <uri> switch it off\n automation rm <uri> forget it\n\nThe host's own log\n logs what the daemon is saying [--level L] [--follow]\n\nSigning in\n auth what this host protects [--json]\n auth <resource> push a token [--token T] [--expires-in S]\n or set AHPC_TOKEN_<RESOURCE>, or pipe one in\n\nTerminals\n terminal list what is running [--json]\n terminal new open a shell [--cwd DIR] [--name N]\n terminal rm <uri> kill it\n terminal send <uri> <text> type into it\n terminal watch <uri> follow its output [--timeout S]\n\nRecording\n AHPC_RECORD=<file> append every frame, both directions, for\n 'npm run wire' to check against the protocol\n\nServing these sessions to something else\n mcp MCP on stdin and stdout, for a client that\n launches this process\n serve the same tools on a socket, shared\n [--serve-host H] [--serve-port N] [--serve-token T]\n /mcp is MCP; /api/<tool> is plain JSON\n\nAnything else\n dispatch <uri> <type> send one action verbatim [--field k=v]\u2026 [--chat]\n status what this client is connected to [--json]\n help this\n\nThe host\n --host <url> ws://host:port, or AHPC_HOST, or the config file\n --token <tkn> a bearer token for it, or AHPC_TOKEN, or the config file\n --config-file read this instead of the one below\n (none) the scripted host, which needs nothing installed\n\nConfiguration\n config where the file is, and what is in force [--json]\n\nOutput is for reading. --json is the same answer for a program.\n";
2
+ export declare const HELP = "ahpc - drive an agent host from a shell\n\n ahpc [--host ws://\u2026] <command> [args] the screen is 'ahpc' with no command\n\nSessions\n session list the catalogue, newest first [--archived] [--json]\n session show <uri> what the host says about one [--full] [--json]\n session new start one [--agent P] [--cwd DIR] [--set k=v]\u2026 [--json]\n session rm <uri> dispose it\n session history <uri> its turns [--all] [--full] [--json]\n session config <uri> the schema and what is in force [--json]\n session set <uri> <k> <v> change one config key\n session read <uri> mark read [--unread]\n session archive <uri> put it away [--undo]\n session customizations <uri> skills, prompts, agents, servers [--json]\n session export <uri> the whole session as one document\n [--json] [--markdown]\n session toggle <uri> <id> turn one on [--off]\n\nTurns\n prompt <uri> <text> say it and stream the answer [--model M] [--json]\n exec <text> a session, one turn, and dispose it\n [--agent P] [--cwd DIR] [--model M] [--json]\n cancel <uri> stop the running turn\n queue <uri> <text> say it after the one running [--model M]\n unqueue <uri> <id> take it back\n\nAnswering\n watch <uri> BLOCK until something wants a person, print, exit\n [--until turn|input|idle] [--timeout S] [--json]\n confirm <uri> <toolCallId> approve a tool call [--deny] [--option ID]\n answer <uri> <requestId> answer a question [--field k=v]\u2026 [--reject]\n\nChats\n chat list <uri> the conversations in a session [--json]\n chat new <uri> [text] another one beside it\n chat rm <chatUri> close one\n\nThe harness\n agents what it serves, and each one's models [--json]\n models every model, by harness [--json]\n commands what a slash offers [--json]\n customizations skills, prompts, agents and MCP servers,\n before any session exists [--kind k] [--json]\n completions <uri> <text> what the host would complete [--offset N] [--json]\n\nChanges and files\n changes <uri> the files a session touched [--json]\n [--list] [--scope s] [--<variable> v]\n [--reviewed f] [--unreviewed f]\n [--operations] what may be done to it\n [--run id] [--file f] [--yes] do one of them\n [--list] every changeset it offers\n [--scope <name>] one of them, e.g. turn\n [--turnId <id>] what a chosen scope still needs\n [--reviewed <file>] tick one off, repeatable\n [--unreviewed <file>] and clear one\n content <uri> <file> one of them, in full\n resource list <uri> a directory the host serves [--json]\n resource read <uri> a file on the host\n resource stat <uri> what it is, without reading it [--json]\n resource write <uri> [file] from a file, or from stdin [--create-only]\n guarded by the file's etag unless [--force]\n resource rm <uri> delete it [--recursive]\n resource mkdir <uri> make a directory\n resource mv <uri> <to> move it [--fail-if-exists]\n resource cp <uri> <to> copy it [--fail-if-exists]\n\nAutomations\n automation list what runs on its own [--json]\n automation show <uri> one of them [--json]\n automation triggers what this host can trigger on [--json]\n automation runs <uri> its history, every page [--json]\n automation run <uri> start it now\n automation enable <uri> switch it on\n automation disable <uri> switch it off\n automation rm <uri> forget it\n\nThe host's own log\n logs what the daemon is saying [--level L] [--follow]\n\nSigning in\n auth what this host protects [--json]\n auth <resource> push a token [--token T] [--expires-in S]\n or set AHPC_TOKEN_<RESOURCE>, or pipe one in\n\nTerminals\n terminal list what is running [--json]\n terminal new open a shell [--cwd DIR] [--name N]\n terminal rm <uri> kill it\n terminal send <uri> <text> type into it\n terminal watch <uri> follow its output [--timeout S]\n\nRecording\n AHPC_RECORD=<file> append every frame, both directions, for\n 'npm run wire' to check against the protocol\n\nServing these sessions to something else\n mcp MCP on stdin and stdout, for a client that\n launches this process\n serve the same tools on a socket, shared\n [--serve-host H] [--serve-port N] [--serve-token T]\n [--serve-origin URL]\u2026 a browser page allowed in\n /mcp is MCP; /api/<tool> is plain JSON\n both take [--mcp-tools G,\u2026] extra tool groups to serve, on top of the\n sessions ones: resources, terminals,\n automations, changes\n\nAnything else\n dispatch <uri> <type> send one action verbatim [--field k=v]\u2026 [--chat]\n status what this client is connected to [--json]\n --version what version this is\n help this\n\nThe host\n --host <url> ws://host:port, or AHPC_HOST, or the config file\n --token <tkn> a bearer token for it, or AHPC_TOKEN, or the config file\n --config-file read this instead of the one below\n (none) the scripted host, which needs nothing installed\n\nConfiguration\n config where the file is, and what is in force [--json]\n\nOutput is for reading. --json is the same answer for a program.\n";
3
3
  /** A message for the person, not a stack trace. */
4
4
  export declare class Fault extends Error {
5
5
  }
@@ -6,7 +6,7 @@ import { ago, archived, branch, json, line, mark, project, table } from './rende
6
6
  import { operate } from '../ahp/operate.js';
7
7
  import { SWITCHES } from '../flags.js';
8
8
  import { spoken, turn as runTurn, until } from '../wait.js';
9
- import { TOOLS } from '../mcp/tools.js';
9
+ import { GROUPS, served } from '../mcp/tools.js';
10
10
  import { SERVER } from '../mcp/serve.js';
11
11
  import { stdio } from '../mcp/stdio.js';
12
12
  import { serve as serveHttp } from '../mcp/http.js';
@@ -112,11 +112,16 @@ Serving these sessions to something else
112
112
  launches this process
113
113
  serve the same tools on a socket, shared
114
114
  [--serve-host H] [--serve-port N] [--serve-token T]
115
+ [--serve-origin URL]… a browser page allowed in
115
116
  /mcp is MCP; /api/<tool> is plain JSON
117
+ both take [--mcp-tools G,…] extra tool groups to serve, on top of the
118
+ sessions ones: resources, terminals,
119
+ automations, changes
116
120
 
117
121
  Anything else
118
122
  dispatch <uri> <type> send one action verbatim [--field k=v]… [--chat]
119
123
  status what this client is connected to [--json]
124
+ --version what version this is
120
125
  help this
121
126
 
122
127
  The host
@@ -331,22 +336,27 @@ export async function cli(command, rest) {
331
336
  * `/api/<tool>` for anything that is not an MCP client.
332
337
  */
333
338
  case 'mcp': {
339
+ const groups = wanted(args);
334
340
  // Nothing but JSON-RPC on stdout, ever: a stray line here is a parse
335
341
  // error at the other end of a pipe nobody can see.
336
- process.stderr.write(`ahpc mcp on ${host.url || '(scripted host)'}, ${TOOLS.length} tools\n`);
337
- await stdio(host, { ...SERVER, onProblem: (said) => process.stderr.write(`${said}\n`) });
342
+ process.stderr.write(`ahpc mcp on ${host.url || '(scripted host)'}, ${served(groups).length} tools\n`);
343
+ await stdio(host, { ...SERVER, groups, onProblem: (said) => process.stderr.write(`${said}\n`) });
338
344
  return 0;
339
345
  }
340
346
  case 'serve': {
347
+ const groups = wanted(args);
341
348
  const at = await serveHttp(host, {
342
349
  ...SERVER,
350
+ groups,
343
351
  host: args.value('--serve-host') ?? '127.0.0.1',
344
352
  port: Number(args.value('--serve-port') ?? 7431),
345
353
  ...(args.value('--serve-token') === undefined ? {} : { token: args.value('--serve-token') }),
354
+ // Repeatable, because a page and its API are often two origins.
355
+ origins: args.every('--serve-origin'),
346
356
  onProblem: (said) => process.stderr.write(`${said}\n`),
347
357
  });
348
358
  line(`ahpc on http://${at.host}:${at.port} against ${host.url || '(scripted host)'}`);
349
- line(` /mcp MCP, ${TOOLS.length} tools`);
359
+ line(` /mcp MCP, ${served(groups).length} tools`);
350
360
  line(' /api/<tool> the same tools as plain JSON');
351
361
  if (args.value('--serve-token') === undefined && at.host !== '127.0.0.1' && at.host !== '::1') {
352
362
  // Said rather than refused: binding wide open is a decision somebody
@@ -997,6 +1007,26 @@ async function chats(host, args, wants) {
997
1007
  }
998
1008
  throw new Fault(`No 'chat ${verb}'. Try 'ahpc help'.`);
999
1009
  }
1010
+ /**
1011
+ * The tool groups `--mcp-tools` asked for.
1012
+ *
1013
+ * Comma-separated and repeatable, because both are what people type. Named
1014
+ * `--mcp-tools` and not `--tools`: every other flag on this client is an AHP
1015
+ * thing, and a bare `--tools` reads like one - it would look like it was
1016
+ * choosing which tools the *agent* may call, which is a different question
1017
+ * with a different answer.
1018
+ */
1019
+ function wanted(args) {
1020
+ const said = args.every('--mcp-tools')
1021
+ .flatMap((one) => one.split(','))
1022
+ .map((one) => one.trim())
1023
+ .filter((one) => one !== '');
1024
+ const strange = said.filter((one) => !GROUPS.includes(one));
1025
+ if (strange.length > 0) {
1026
+ throw new Fault(`No tool group called ${strange.join(', ')}. There is ${GROUPS.join(', ')}.`);
1027
+ }
1028
+ return said;
1029
+ }
1000
1030
  /** Everything under `terminal`. */
1001
1031
  async function shells(host, args, wants) {
1002
1032
  const verb = args.positional(0) ?? 'list';
package/dist/src/flags.js CHANGED
@@ -41,6 +41,9 @@ export const COMMANDS = new Set([
41
41
  export const SWITCHES = new Set([
42
42
  // The screen's own.
43
43
  '--static', '-s', '--settled', '--approve', '--answer', '--bood', '--help', '-h',
44
+ // Answered by the entry point before either front end is loaded, and here
45
+ // so that neither swallows the word after it.
46
+ '--version', '-v',
44
47
  // Shape and scope, on both sides.
45
48
  '--json', '--full', '--all', '--archived', '--unread', '--undo',
46
49
  '--off', '--deny', '--reject', '--claude', '--chat',
package/dist/src/main.js CHANGED
@@ -12,8 +12,20 @@
12
12
  // does not load a front end, which is the reason they were copied here.
13
13
  import { commandIn } from './flags.js';
14
14
  const argv = process.argv.slice(2);
15
- const first = commandIn(argv);
16
- if (first !== undefined) {
15
+ /*
16
+ * Answered before anything is loaded.
17
+ *
18
+ * `--version` with no command would otherwise open the screen, which is a
19
+ * question answered by a whole renderer starting up and then being read off a
20
+ * status bar. Neither front end is imported to answer it.
21
+ */
22
+ const asked = argv.includes('--version') || argv.includes('-v');
23
+ const first = asked ? undefined : commandIn(argv);
24
+ if (asked) {
25
+ const { version } = await import('./version.js');
26
+ process.stdout.write(`${version()}\n`);
27
+ }
28
+ else if (first !== undefined) {
17
29
  /*
18
30
  * A closed pipe is not an error.
19
31
  *
@@ -12,6 +12,16 @@ export interface ServeOptions {
12
12
  * token is what makes binding anywhere else defensible.
13
13
  */
14
14
  token?: string;
15
+ /**
16
+ * Browser origins allowed to reach this, beyond its own.
17
+ *
18
+ * Empty is the safe default and the usual answer: a shell script, a webhook
19
+ * and an MCP client send no `Origin` at all, so nothing legitimate is turned
20
+ * away by allowing none. This is for a page somebody serves themselves.
21
+ */
22
+ origins?: readonly string[];
23
+ /** The opt-in tool groups to serve, beyond the core table. */
24
+ groups?: readonly string[];
15
25
  onProblem?(said: string): void;
16
26
  }
17
27
  /** What this is listening on, and how to stop it. */
@@ -12,7 +12,7 @@
12
12
  * client that launched it, this is shared and outlives any of them.
13
13
  */
14
14
  import { createServer } from 'node:http';
15
- import { answer, call, listing } from './serve.js';
15
+ import { SPOKEN, answer, call, listing } from './serve.js';
16
16
  /** How much of a request body is read before it is refused, in bytes. */
17
17
  const LIMIT = 1_000_000;
18
18
  const body = async (request) => {
@@ -24,6 +24,39 @@ const body = async (request) => {
24
24
  }
25
25
  return read;
26
26
  };
27
+ /**
28
+ * Answer a request with a stream rather than one object.
29
+ *
30
+ * The transport allows either, and this is the branch that lets anything be
31
+ * said before the result: notifications go out as they happen and the response
32
+ * is the last event, after which the stream closes. Only opened where the
33
+ * caller asked for progress, because a stream is worse for everybody else -
34
+ * more to parse, and a connection held open for a request that answers at once.
35
+ */
36
+ const stream = (response) => {
37
+ response.writeHead(200, {
38
+ 'content-type': 'text/event-stream',
39
+ 'cache-control': 'no-cache, no-transform',
40
+ connection: 'keep-alive',
41
+ });
42
+ const event = (value) => { response.write(`data: ${JSON.stringify(value)}\n\n`); };
43
+ return {
44
+ event,
45
+ end: (value) => { event(value); response.end(); },
46
+ };
47
+ };
48
+ /**
49
+ * Whether this request asked to be told what is happening while it waits.
50
+ *
51
+ * A `progressToken` in `_meta`, which is the only way a caller asks. Read here
52
+ * rather than inside `answer`, because the decision it drives - a stream or an
53
+ * object - has to be made before a single byte of the response is written.
54
+ */
55
+ const wantsProgress = (message) => {
56
+ const params = message.params;
57
+ const token = params?._meta?.progressToken;
58
+ return typeof token === 'string' || typeof token === 'number';
59
+ };
27
60
  const send = (response, code, value) => {
28
61
  const text = JSON.stringify(value);
29
62
  response.writeHead(code, {
@@ -44,6 +77,52 @@ const allowed = (request, token) => {
44
77
  const said = request.headers.authorization;
45
78
  return typeof said === 'string' && said.trim() === `Bearer ${token}`;
46
79
  };
80
+ /**
81
+ * Whether a browser may talk to this.
82
+ *
83
+ * The transport says a server **MUST** validate `Origin` to stop DNS
84
+ * rebinding, and the reason it says so is that binding to loopback is not the
85
+ * protection it looks like: a page on any website can POST to
86
+ * `http://127.0.0.1:7431` from inside the browser of the person running this,
87
+ * and the request arrives from their own machine looking exactly like theirs.
88
+ * Without this, opening a tab would be enough to drive every session on the
89
+ * host.
90
+ *
91
+ * No header at all is allowed. A browser always sends one on a request like
92
+ * these; a program does not, and refusing those would refuse every real
93
+ * caller to guard against a thing that cannot happen.
94
+ */
95
+ const sameOrigin = (request, url, extra) => {
96
+ const said = request.headers.origin;
97
+ if (typeof said !== 'string' || said === '')
98
+ return true;
99
+ if (extra.includes(said))
100
+ return true;
101
+ try {
102
+ const from = new URL(said);
103
+ // The host and port this request came in on, whatever name was used to
104
+ // reach it - `localhost` and `127.0.0.1` are the same server and a person
105
+ // typing either should not be told no.
106
+ return from.host === url.host;
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ };
112
+ /**
113
+ * Whether this can speak the version the client says it is using.
114
+ *
115
+ * The transport says an invalid or unsupported `MCP-Protocol-Version` **MUST**
116
+ * be a 400. Absent is not unsupported: the same paragraph says to assume
117
+ * `2025-03-26` where there is no header, which is a client from before it
118
+ * existed.
119
+ */
120
+ const speakable = (request) => {
121
+ const said = request.headers['mcp-protocol-version'];
122
+ if (said === undefined)
123
+ return true;
124
+ return typeof said === 'string' && SPOKEN.includes(said);
125
+ };
47
126
  /** Start listening. Answers once the socket is up. */
48
127
  export async function serve(host, options) {
49
128
  const server = createServer((request, response) => {
@@ -51,6 +130,18 @@ export async function serve(host, options) {
51
130
  try {
52
131
  const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
53
132
  const path = url.pathname.replace(/\/+$/, '') || '/';
133
+ if (!sameOrigin(request, url, options.origins ?? [])) {
134
+ // 403 rather than 401: a token would not make this request
135
+ // acceptable, so inviting one would be the wrong thing to say.
136
+ send(response, 403, { error: `This server does not serve requests from ${String(request.headers.origin)}.` });
137
+ return;
138
+ }
139
+ if (!speakable(request)) {
140
+ send(response, 400, {
141
+ error: `This server does not speak MCP ${String(request.headers['mcp-protocol-version'])}. It speaks ${SPOKEN.join(', ')}.`,
142
+ });
143
+ return;
144
+ }
54
145
  if (!allowed(request, options.token)) {
55
146
  send(response, 401, { error: 'This server needs a bearer token.' });
56
147
  return;
@@ -59,7 +150,7 @@ export async function serve(host, options) {
59
150
  // what to call. Every tool with its schema, which is also what an MCP
60
151
  // client gets from `tools/list`.
61
152
  if (request.method === 'GET' && (path === '/api' || path === '/')) {
62
- send(response, 200, listing());
153
+ send(response, 200, listing(options.groups));
63
154
  return;
64
155
  }
65
156
  if (path === '/mcp') {
@@ -77,6 +168,22 @@ export async function serve(host, options) {
77
168
  send(response, 400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'That is not JSON.' } });
78
169
  return;
79
170
  }
171
+ if (wantsProgress(message)) {
172
+ const open = stream(response);
173
+ const reply = await answer(host, message, {
174
+ ...options,
175
+ notify: (notification) => open.event({ jsonrpc: '2.0', ...notification }),
176
+ });
177
+ // A notification that also carried a progress token has nothing to
178
+ // end the stream with, so it is closed rather than left open on a
179
+ // response that is never coming.
180
+ if (reply === undefined) {
181
+ response.end();
182
+ return;
183
+ }
184
+ open.end(reply);
185
+ return;
186
+ }
80
187
  const reply = await answer(host, message, options);
81
188
  // A notification is answered with 202 and no body, which is what the
82
189
  // transport says and what a client waiting on one would hang over.
@@ -105,7 +212,7 @@ export async function serve(host, options) {
105
212
  return;
106
213
  }
107
214
  }
108
- const result = await call(host, name, input);
215
+ const result = await call(host, name, input, undefined, options.groups);
109
216
  // Shaped for a program rather than for a model: the answer itself,
110
217
  // or the refusal as an error, without MCP's content envelope around
111
218
  // it. A caller that wants the envelope has `/mcp`.
@@ -1,17 +1,26 @@
1
1
  import type { HostConnection } from '../ahp/connection.js';
2
- /** The version of MCP this speaks, and the one it answers `initialize` with. */
2
+ /** The newest version of MCP this speaks, and what it answers an unknown one with. */
3
3
  export declare const PROTOCOL = "2025-06-18";
4
+ /**
5
+ * Every version this will serve, newest first.
6
+ *
7
+ * Both of these describe the same tools, and this server uses nothing either
8
+ * of them added or removed - no sessions, no resources, no sampling - so
9
+ * refusing the older one would be refusing a client for a difference that
10
+ * cannot reach it. `2024-11-05` is *not* here: its HTTP transport is a
11
+ * different shape, with an `endpoint` event and a second URL.
12
+ */
13
+ export declare const SPOKEN: readonly string[];
4
14
  /**
5
15
  * What this server calls itself.
6
16
  *
7
- * MCP requires a version where AHP's `clientInfo` does not, so this is the
8
- * only version string in the source. Cosmetic - a client displays it and
9
- * nothing branches on it - and deliberately not read out of `package.json`,
10
- * which sits at a different depth in the published tree than it does here.
17
+ * MCP requires a version where AHP's `clientInfo` does not. Read from the
18
+ * manifest rather than written here: the literal that used to be here said
19
+ * 0.1 while the package said 0.2, within a day of being written.
11
20
  */
12
21
  export declare const SERVER: {
13
- readonly name: "ahpc";
14
- readonly version: "0.1";
22
+ name: string;
23
+ version: string;
15
24
  };
16
25
  /** A JSON-RPC request or notification, as far as this needs to read one. */
17
26
  export interface Incoming {
@@ -20,6 +29,21 @@ export interface Incoming {
20
29
  method?: unknown;
21
30
  params?: unknown;
22
31
  }
32
+ /**
33
+ * One progress notification, as `notifications/progress` puts it on the wire.
34
+ *
35
+ * `progress` MUST increase on every one, `total` is left out because an
36
+ * agent's reply has no length known in advance, and `message` is what a person
37
+ * reads - which is the tool name a session stopped on, or the host's own words
38
+ * for what it is doing.
39
+ */
40
+ export interface Progress {
41
+ progressToken: string | number;
42
+ progress: number;
43
+ message?: string;
44
+ }
45
+ /** Somewhere to put progress while a tool runs, or nothing where nobody asked. */
46
+ export type Report = ((said: string) => void) | undefined;
23
47
  /** What goes back, or nothing at all where the message was a notification. */
24
48
  export type Outgoing = {
25
49
  jsonrpc: '2.0';
@@ -35,7 +59,7 @@ export type Outgoing = {
35
59
  result?: never;
36
60
  });
37
61
  /** The tools, in the shape `tools/list` puts them on the wire. */
38
- export declare const listing: () => unknown;
62
+ export declare const listing: (groups?: readonly string[]) => unknown;
39
63
  /**
40
64
  * Run one tool and shape the answer the way `tools/call` wants it.
41
65
  *
@@ -45,7 +69,7 @@ export declare const listing: () => unknown;
45
69
  * fault it can only give up over. So a refusal from the host - a session that
46
70
  * is gone, a directory it does not serve - comes back as content.
47
71
  */
48
- export declare function call(host: HostConnection, name: string, input: unknown): Promise<unknown>;
72
+ export declare function call(host: HostConnection, name: string, input: unknown, report?: Report, groups?: readonly string[]): Promise<unknown>;
49
73
  /**
50
74
  * Answer one message.
51
75
  *
@@ -55,4 +79,18 @@ export declare function call(host: HostConnection, name: string, input: unknown)
55
79
  export declare function answer(host: HostConnection, message: Incoming, options: {
56
80
  name: string;
57
81
  version: string;
82
+ /** The opt-in tool groups this server was started with, if any. */
83
+ groups?: readonly string[];
84
+ /**
85
+ * Somewhere to send a notification while this is being answered.
86
+ *
87
+ * Given by a transport that has one: stdio always does, and HTTP does only
88
+ * once it has decided to answer with a stream. Absent means there is
89
+ * nowhere to say anything until the result, which is the ordinary case and
90
+ * why every progress path here is optional.
91
+ */
92
+ notify?(notification: {
93
+ method: string;
94
+ params: unknown;
95
+ }): void;
58
96
  }): Promise<Outgoing | undefined>;