mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
8efd7c6346
## Human summary:
This adds `argent server start` command to enable us to spawn tools
server in arbitrary location. we want this to enable using remote
tools-server via our mcp not only local ones.
## Summary
- Adds `argent server start` to spawn a tool-server that does **not**
auto-shutdown on idle, with configurable bind port and host — the
missing piece for running tool-server on a remote/dedicated host.
- Foreground by default so process supervisors (systemd, Docker,
supervisord) own the lifecycle; `--detach` keeps the existing
daemon-spawn behavior. Refuses to clobber a healthy running server
unless `--force`.
- Phase 2 (consumer-side `--tools-url` flag pointing the MCP / CLI at a
remote tool-server) is now a one-flag change away — `tools-client.ts`
and `mcp-server.ts` already honor `ARGENT_TOOLS_URL`. Out of scope for
this PR.
## What changed
- **`tool-server`** — read `HOST` env var (default `127.0.0.1` so the
existing auto-spawn path is unaffected), bind to it, attach an `error`
listener on `app.listen` so `EADDRINUSE` / `EACCES` exit cleanly instead
of routing through `uncaughtException`.
- **`argent-tools-client`** — broaden the readiness regex to match any
host; extend `buildToolsServerEnv` with optional `{ host,
idleTimeoutMinutes }`; record `host` in `~/.argent/tool-server.json`
(backward-compat: missing field defaults to `127.0.0.1`); export
`spawnToolsServer`, `findFreePort`, `isToolsServerHealthy`,
`isToolsServerProcessAlive`, the state read/write/clear helpers, and
`formatToolsServerUrl` so the CLI can reuse them.
- **`argent-cli`** — new `server start [flags]`: `--port`/`-p` (default
3001, `0` = pick free), `--host` (default `127.0.0.1`), `--idle-timeout`
(default `0` = never), `--detach`/`-d`, `--force`, `--help`. Warns when
host is non-loopback. Foreground mode forwards `SIGINT`/`SIGTERM` to the
child and writes the state file so local `argent run` / `argent mcp` can
attach.
- **`argent` dispatcher** — pass `BUNDLED_RUNTIME_PATHS` to `server()`
so the new subcommand can find the bundled tool-server.
## Test plan
- [x] `argent server --help` lists the new `start` subcommand
- [x] `argent server start --help` shows full flag reference
- [x] `argent server start --detach --port 0` → status alive/healthy,
`/tools` reachable, state file populated with `host` field
- [x] Second `argent server start --detach` refuses (`exit 1`) with
helpful message
- [x] `argent server start --detach --force` kills the old PID and
starts a new one
- [x] Foreground `argent server start --port 0` runs with inherited
stdio; child PID is the actual tool-server (not the wrapper); `argent
server stop` from a sibling shell tears down both
- [x] Existing auto-spawn path (`argent tools`) still works — verifies
the regex broadening + default-host change is backward-compatible
- [x] `tool-server` (444 tests) + `argent-tools-client` (1) + `argent`
dispatcher (1) suites pass
- [ ] Manual smoke on `--host 0.0.0.0` — could not exercise locally
(sandbox declined the public-interface bind); the warning + bind path is
identical to loopback besides the listen address
---------
Co-authored-by: pFornagiel <pawel.fornagiel2@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Paweł Fornagiel <85541429+pFornagiel@users.noreply.github.com>
134 lines
4.7 KiB
TypeScript
134 lines
4.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { parseStartFlags, parsePort, parseIdle, StartFlagError } from "../src/server.js";
|
|
|
|
describe("parseStartFlags", () => {
|
|
it("returns the documented defaults for an empty argv", () => {
|
|
expect(parseStartFlags([])).toEqual({
|
|
port: null,
|
|
host: "127.0.0.1",
|
|
idleTimeoutMinutes: 0,
|
|
detach: false,
|
|
force: false,
|
|
noAuth: false,
|
|
help: false,
|
|
});
|
|
});
|
|
|
|
it("parses --port in space and equals form, plus -p alias", () => {
|
|
expect(parseStartFlags(["--port", "4000"]).port).toBe(4000);
|
|
expect(parseStartFlags(["--port=4000"]).port).toBe(4000);
|
|
expect(parseStartFlags(["-p", "4000"]).port).toBe(4000);
|
|
});
|
|
|
|
it("accepts port 0 as the 'pick a free port' sentinel", () => {
|
|
expect(parseStartFlags(["--port", "0"]).port).toBe(0);
|
|
});
|
|
|
|
it("parses --host in space and equals form", () => {
|
|
expect(parseStartFlags(["--host", "0.0.0.0"]).host).toBe("0.0.0.0");
|
|
expect(parseStartFlags(["--host=::1"]).host).toBe("::1");
|
|
});
|
|
|
|
it("parses --idle-timeout in space and equals form", () => {
|
|
expect(parseStartFlags(["--idle-timeout", "5"]).idleTimeoutMinutes).toBe(5);
|
|
expect(parseStartFlags(["--idle-timeout=10"]).idleTimeoutMinutes).toBe(10);
|
|
expect(parseStartFlags(["--idle-timeout=0"]).idleTimeoutMinutes).toBe(0);
|
|
});
|
|
|
|
it("parses boolean flags --detach/-d, --force, --no-auth, --help/-h", () => {
|
|
expect(parseStartFlags(["--detach"]).detach).toBe(true);
|
|
expect(parseStartFlags(["-d"]).detach).toBe(true);
|
|
expect(parseStartFlags(["--force"]).force).toBe(true);
|
|
expect(parseStartFlags(["--no-auth"]).noAuth).toBe(true);
|
|
expect(parseStartFlags(["--help"]).help).toBe(true);
|
|
expect(parseStartFlags(["-h"]).help).toBe(true);
|
|
});
|
|
|
|
it("combines multiple flags in a single invocation", () => {
|
|
const flags = parseStartFlags([
|
|
"--detach",
|
|
"--force",
|
|
"--host",
|
|
"0.0.0.0",
|
|
"--port=4567",
|
|
"--idle-timeout",
|
|
"15",
|
|
]);
|
|
expect(flags).toEqual({
|
|
port: 4567,
|
|
host: "0.0.0.0",
|
|
idleTimeoutMinutes: 15,
|
|
detach: true,
|
|
force: true,
|
|
noAuth: false,
|
|
help: false,
|
|
});
|
|
});
|
|
|
|
it("throws StartFlagError on unknown flags", () => {
|
|
expect(() => parseStartFlags(["--bogus"])).toThrow(StartFlagError);
|
|
expect(() => parseStartFlags(["--bogus"])).toThrow(/Unknown flag: --bogus/);
|
|
});
|
|
|
|
it("throws when a value-taking flag is missing its value", () => {
|
|
expect(() => parseStartFlags(["--port"])).toThrow(/--port requires a value/);
|
|
expect(() => parseStartFlags(["--host"])).toThrow(/--host requires a value/);
|
|
expect(() => parseStartFlags(["--idle-timeout"])).toThrow(/--idle-timeout requires a value/);
|
|
});
|
|
|
|
it("does not consume the next token as a value for boolean flags", () => {
|
|
// `--detach` must NOT swallow the next token; here `--force` is a separate flag.
|
|
expect(parseStartFlags(["--detach", "--force"])).toMatchObject({
|
|
detach: true,
|
|
force: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("parsePort", () => {
|
|
it("accepts integers in 0..65535", () => {
|
|
expect(parsePort("0")).toBe(0);
|
|
expect(parsePort("3001")).toBe(3001);
|
|
expect(parsePort("65535")).toBe(65535);
|
|
});
|
|
|
|
it("rejects values out of range", () => {
|
|
expect(() => parsePort("65536")).toThrow(StartFlagError);
|
|
expect(() => parsePort("99999")).toThrow(/0\.\.65535/);
|
|
});
|
|
|
|
it("rejects values that parseInt would silently truncate", () => {
|
|
// Without the strict-digits regex these would silently parse to 123.
|
|
expect(() => parsePort("123abc")).toThrow(/got "123abc"/);
|
|
expect(() => parsePort("0x10")).toThrow(StartFlagError);
|
|
expect(() => parsePort("3.14")).toThrow(StartFlagError);
|
|
expect(() => parsePort("1e3")).toThrow(StartFlagError);
|
|
});
|
|
|
|
it("rejects negative numbers and signs", () => {
|
|
expect(() => parsePort("-1")).toThrow(StartFlagError);
|
|
expect(() => parsePort("+3001")).toThrow(StartFlagError);
|
|
});
|
|
|
|
it("rejects empty strings and whitespace", () => {
|
|
expect(() => parsePort("")).toThrow(StartFlagError);
|
|
expect(() => parsePort(" ")).toThrow(StartFlagError);
|
|
expect(() => parsePort(" 3001 ")).toThrow(StartFlagError);
|
|
});
|
|
});
|
|
|
|
describe("parseIdle", () => {
|
|
it("accepts non-negative integers including 0", () => {
|
|
expect(parseIdle("0")).toBe(0);
|
|
expect(parseIdle("5")).toBe(5);
|
|
expect(parseIdle("1440")).toBe(1440);
|
|
});
|
|
|
|
it("rejects negative, decimal, and garbage values", () => {
|
|
expect(() => parseIdle("-1")).toThrow(StartFlagError);
|
|
expect(() => parseIdle("3.5")).toThrow(StartFlagError);
|
|
expect(() => parseIdle("abc")).toThrow(/got "abc"/);
|
|
expect(() => parseIdle("")).toThrow(StartFlagError);
|
|
});
|
|
});
|