@wirecat/cli-core 0.444.0 → 0.445.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/CHANGELOG.md CHANGED
@@ -5,7 +5,21 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
- ## Unreleased
8
+ ## 0.445.0 — 26.10.2026
9
+
10
+ ### Added
11
+
12
+ - **`mcp server start|stop|status` runs the HTTP MCP server in the background** (WireCat Desktop step 3). `start`
13
+ takes `--public-url`, `--port` and `--permission` as `mcp --http` does and answers once it listens; `stop` ends
14
+ only a server `start` started; `status --json` gives the port, the connector address, the login code for a new
15
+ browser app with when it runs out, and the apps logged in, by the name each gave itself. `mcp --http` now keeps a
16
+ lock beside its token file, so a second one for the same profile is refused. A CLI with its own `mcp` mounts
17
+ `mcpServerCommand` and passes `lockFile: httpLockFile(tokenFile)` to `serveOverHttp`.
18
+
19
+ ### Fixed
20
+
21
+ - **The login code is renewed when it runs out**, not only when someone types a wrong or late one, and the login
22
+ page says it is shown in WireCat Desktop or the terminal.
9
23
 
10
24
  ## 0.444.0 — 26.10.2026
11
25
 
@@ -76,6 +76,7 @@ export { type HelpSection, inSection, SECTIONS } from "./help/sections.js";
76
76
  export { bufferedInput, type InputPolicy, inputPolicy, MAX_BUFFERED_INPUT, MAX_SECRET_INPUT, provideInputPolicy, } from "./input-policy.js";
77
77
  export { answerFields, checkJson, type EntityFields, entityFields, type JsonFieldsResolver, type JsonRequest, liftJson, } from "./json-fields.js";
78
78
  export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTerms, pagedList, pageOf, type QueryWords, queryArgument, queryHelp, queryTerms, searchText, termField, timeBounds, typedQuery, unfilteredQuery, withQuery, } from "./list-query.js";
79
+ export { MCP_HTTP_DEFAULT_PORT, type McpServerOptions, type McpServerTarget, mcpServerCommand, } from "./mcp-server-command.js";
79
80
  export { formatMeetingBatch, prepareMeetingBatch } from "./meeting-batch.js";
80
81
  export { meetingEvidenceItem, meetingPersonContextItem } from "./meeting-evidence-answer.js";
81
82
  export { parseEvidenceFindingsJson, parseMeetingFindingsJson } from "./meeting-findings-input.js";
package/dist/cli/index.js CHANGED
@@ -69,6 +69,7 @@ export { inSection, SECTIONS } from "./help/sections.js";
69
69
  export { bufferedInput, inputPolicy, MAX_BUFFERED_INPUT, MAX_SECRET_INPUT, provideInputPolicy, } from "./input-policy.js";
70
70
  export { answerFields, checkJson, entityFields, liftJson, } from "./json-fields.js";
71
71
  export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTerms, pagedList, pageOf, queryArgument, queryHelp, queryTerms, searchText, termField, timeBounds, typedQuery, unfilteredQuery, withQuery, } from "./list-query.js";
72
+ export { MCP_HTTP_DEFAULT_PORT, mcpServerCommand, } from "./mcp-server-command.js";
72
73
  export { formatMeetingBatch, prepareMeetingBatch } from "./meeting-batch.js";
73
74
  export { meetingEvidenceItem, meetingPersonContextItem } from "./meeting-evidence-answer.js";
74
75
  export { parseEvidenceFindingsJson, parseMeetingFindingsJson } from "./meeting-findings-input.js";
@@ -0,0 +1,35 @@
1
+ import { Command } from "commander";
2
+ import { type ServerSystem } from "../background/system.js";
3
+ export declare const MCP_HTTP_DEFAULT_PORT = 8765;
4
+ /** What one CLI tells `mcp server` about the server it would run. */
5
+ export interface McpServerTarget {
6
+ /** What a person types before `mcp`: `tg personal`, `memo`. */
7
+ invocation: string;
8
+ /** Absent for a CLI without profiles. */
9
+ profile?: string;
10
+ envPrefix: string;
11
+ env: NodeJS.ProcessEnv;
12
+ tokenFile: string;
13
+ /** The words after the CLI's own entry that run `mcp --http` in the foreground, before `--port`. */
14
+ argv: string[];
15
+ /** Added to the server's environment: the profile, for a CLI that reads it from there. */
16
+ childEnv?: Record<string, string>;
17
+ version: string;
18
+ }
19
+ export interface McpServerOptions {
20
+ /** How help shows the CLI before `mcp`: `tg personal`. */
21
+ example: string;
22
+ /** The CLI's own `mcp --http` default, when it is not 8765. */
23
+ defaultPort?: number;
24
+ target: (self: Command) => McpServerTarget;
25
+ /** The CLI's own answer: the item in `--json`, else the lines. */
26
+ answer: (self: Command, item: object, lines: string[]) => void;
27
+ system?: (self: Command) => ServerSystem | undefined;
28
+ }
29
+ /**
30
+ * `mcp --http` in the background: `start`, `stop`, `status`, as `server` does for `serve`. The server
31
+ * keeps a lock beside its token file (`state.ts`) with its port, address and login code, so status
32
+ * survives the shell that started it, and `stop` ends only a process that carries this CLI's marker.
33
+ * None of it is offered over MCP: status holds a live login code.
34
+ */
35
+ export declare const mcpServerCommand: ({ example, defaultPort, target, answer, system, }: McpServerOptions) => Command;
@@ -0,0 +1,174 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { annotate } from "@wirecat/cli-base/commands";
3
+ import { Command } from "commander";
4
+ import { alive, carries } from "../background/processes.js";
5
+ import { thisMachine } from "../background/system.js";
6
+ import { readableLog, tail } from "../background/units.js";
7
+ import { portOf, publicUrlOf } from "../http/public-url.js";
8
+ import { httpLockFile, httpLogFile, mcpHttpMarker, readHttpLock, signedInClients, } from "../mcp/http/state.js";
9
+ import { destroysNothing, LOCAL_READ, LOCAL_WRITE } from "./effects.js";
10
+ export const MCP_HTTP_DEFAULT_PORT = 8765;
11
+ const LOOKS = 120;
12
+ const clock = (iso) => {
13
+ const when = new Date(iso);
14
+ const time = when.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
15
+ return when.toDateString() === new Date().toDateString() ? time : `${when.toLocaleDateString("sv-SE")} ${time}`;
16
+ };
17
+ const connectorUrl = (lock) => new URL("/mcp", lock.publicUrl).href;
18
+ const liveCode = (lock, now) => lock.loginCode && lock.loginCodeExpiresAt && Date.parse(lock.loginCodeExpiresAt) > now
19
+ ? { code: lock.loginCode, expires_at: lock.loginCodeExpiresAt }
20
+ : null;
21
+ const markerOf = (target) => `${mcpHttpMarker(target.envPrefix)}=${target.profile ?? "default"}`;
22
+ /** Who started it decides whether `stop` may end it: only a process that carries our marker. */
23
+ const byOf = (target, pid) => (carries(pid, markerOf(target)) ? "server" : "hand");
24
+ const serving = (target, lock, now) => ({
25
+ pid: lock.pid,
26
+ started_at: lock.startedAt,
27
+ listening: lock.listeningAt !== undefined,
28
+ by: byOf(target, lock.pid),
29
+ port: lock.port,
30
+ public_url: lock.publicUrl,
31
+ connector_url: connectorUrl(lock),
32
+ login_code: liveCode(lock, now),
33
+ ...(lock.version ? { version: lock.version, outdated: lock.version !== target.version } : {}),
34
+ });
35
+ const clientLines = (clients) => clients.length === 0
36
+ ? ["No browser app is logged in."]
37
+ : [
38
+ `Logged-in apps (names are what each app calls itself): ${clients
39
+ .map((one) => `${one.claimedName === null ? "no name" : JSON.stringify(one.claimedName)} (${clock(one.lastLoginAt)})`)
40
+ .join(", ")}.`,
41
+ ];
42
+ /**
43
+ * `mcp --http` in the background: `start`, `stop`, `status`, as `server` does for `serve`. The server
44
+ * keeps a lock beside its token file (`state.ts`) with its port, address and login code, so status
45
+ * survives the shell that started it, and `stop` ends only a process that carries this CLI's marker.
46
+ * None of it is offered over MCP: status holds a live login code.
47
+ */
48
+ export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT, target, answer, system, }) => {
49
+ const command = annotate(new Command("server"), { mcp: false })
50
+ .summary("run the HTTP MCP server in the background")
51
+ .description("start, stop and check `mcp --http` in the background; status shows the login code for a browser app");
52
+ const machine = (self) => system?.(self) ?? thisMachine();
53
+ destroysNothing(annotate(command.command("start"), {
54
+ effects: LOCAL_WRITE,
55
+ mcp: false,
56
+ examples: [`${example} mcp server start --public-url https://laptop.example.ts.net`],
57
+ }))
58
+ .summary("start the HTTP MCP server in the background")
59
+ .description("start `mcp --http` apart from this shell and answer once it listens, with its login code")
60
+ .option("--port <port>", `the local port (default ${defaultPort})`)
61
+ .option("--public-url <url>", "the tunnel's https address the browser apps use, e.g. https://<name>.ts.net")
62
+ .option("--permission <key=level>", "make a permission stricter for this server only; repeat for more keys", (value, previous = []) => [...previous, value])
63
+ .action(async function () {
64
+ // A CLI whose `mcp` takes the same flags reads them first, so they are looked up on both.
65
+ const flags = this.optsWithGlobals();
66
+ const one = target(this);
67
+ const url = publicUrlOf(flags.publicUrl, { missing: "mcp server start", reaches: "the browser apps reach it" });
68
+ const port = portOf(flags.port, defaultPort);
69
+ const lockFile = httpLockFile(one.tokenFile);
70
+ const before = readHttpLock(lockFile);
71
+ if (before && alive(before.pid)) {
72
+ answer(this, { profile: one.profile ?? null, started: false, running: true, ...serving(one, before, Date.now()) }, [
73
+ `Already serving ${one.invocation} over HTTP since ${clock(before.startedAt)} (PID ${before.pid}, port ${before.port}).`,
74
+ ]);
75
+ return;
76
+ }
77
+ const sys = machine(this);
78
+ const log = httpLogFile(one.tokenFile);
79
+ const { [`${one.envPrefix}_TIMEOUT`]: _timeout, ...inherited } = one.env;
80
+ const pid = sys.spawn([
81
+ ...one.argv,
82
+ "--port",
83
+ String(port),
84
+ "--public-url",
85
+ url.origin,
86
+ ...(flags.permission ?? []).flatMap((entry) => ["--permission", entry]),
87
+ ], { ...inherited, ...one.childEnv, [mcpHttpMarker(one.envPrefix)]: one.profile ?? "default" }, log);
88
+ for (let look = 0; look < LOOKS; look++) {
89
+ const now = readHttpLock(lockFile);
90
+ if (now?.pid === pid && now.listeningAt) {
91
+ const code = liveCode(now, Date.now());
92
+ answer(this, { profile: one.profile ?? null, started: true, running: true, ...serving(one, now, Date.now()), log }, [
93
+ `Started: serving ${one.invocation} over HTTP (PID ${pid}) on 127.0.0.1:${now.port}.`,
94
+ `Browser apps connect to ${connectorUrl(now)}`,
95
+ ...(code ? [`Login code for a new browser app: ${code.code} (until ${clock(code.expires_at)}).`] : []),
96
+ ]);
97
+ return;
98
+ }
99
+ if (!alive(pid)) {
100
+ const last = readableLog(tail(log, 5)).trim();
101
+ throw new CliError("provider_unavailable", `mcp --http stopped before it listened${last ? `:\n${last}` : ""}`);
102
+ }
103
+ await sys.pause();
104
+ }
105
+ throw new CliError("timeout", `mcp --http is running but not listening yet — \`${one.invocation} mcp server status\`; its log: ${log}`);
106
+ });
107
+ destroysNothing(annotate(command.command("stop"), { effects: LOCAL_WRITE, mcp: false, examples: [`${example} mcp server stop`] }))
108
+ .summary("stop the background HTTP MCP server")
109
+ .description("stop `mcp --http` — only one that `mcp server start` started")
110
+ .action(async function () {
111
+ const one = target(this);
112
+ const lock = readHttpLock(httpLockFile(one.tokenFile));
113
+ if (!lock || !alive(lock.pid)) {
114
+ answer(this, { profile: one.profile ?? null, stopped: false }, [
115
+ `Nothing to stop: ${one.invocation} is not serving MCP over HTTP.`,
116
+ ]);
117
+ return;
118
+ }
119
+ if (byOf(one, lock.pid) !== "server") {
120
+ throw new CliError("validation_error", `mcp --http for ${one.invocation} (PID ${lock.pid}) was not started by \`${one.invocation} mcp server start\` — stop it where it runs`);
121
+ }
122
+ process.kill(lock.pid, "SIGTERM");
123
+ const sys = machine(this);
124
+ for (let look = 0; look < LOOKS && alive(lock.pid); look++)
125
+ await sys.pause();
126
+ if (alive(lock.pid))
127
+ throw new CliError("timeout", `mcp --http (PID ${lock.pid}) has not stopped yet`);
128
+ answer(this, { profile: one.profile ?? null, stopped: true, pid: lock.pid }, [
129
+ `Stopped serving ${one.invocation} over HTTP (PID ${lock.pid}).`,
130
+ ]);
131
+ });
132
+ annotate(command.command("status"), {
133
+ effects: LOCAL_READ,
134
+ mcp: false,
135
+ examples: [`${example} mcp server status --json`],
136
+ })
137
+ .summary("whether the HTTP MCP server runs, its login code and the apps logged in")
138
+ .description("whether `mcp --http` runs, on which port and address, the login code for a new browser app, and the apps logged in")
139
+ .action(function () {
140
+ const one = target(this);
141
+ const lock = readHttpLock(httpLockFile(one.tokenFile));
142
+ const now = Date.now();
143
+ const running = lock !== undefined && alive(lock.pid);
144
+ const clients = signedInClients(one.tokenFile, now);
145
+ const live = running ? serving(one, lock, now) : undefined;
146
+ answer(this, {
147
+ profile: one.profile ?? null,
148
+ running,
149
+ ...(live ?? {}),
150
+ ...(lock && !running ? { stale: { pid: lock.pid, started_at: lock.startedAt } } : {}),
151
+ clients: clients.map((client) => ({
152
+ claimed_name: client.claimedName,
153
+ last_login_at: client.lastLoginAt,
154
+ })),
155
+ log: httpLogFile(one.tokenFile),
156
+ }, [
157
+ live && lock
158
+ ? `Serving ${one.invocation} over HTTP since ${clock(lock.startedAt)} (PID ${lock.pid}) on port ${lock.port}${live.listening ? "" : ", not listening yet"}; browser apps connect to ${live.connector_url}. ${live.by === "server" ? `Started by \`${one.invocation} mcp server start\`.` : "Started by hand — `mcp server stop` leaves it alone."}`
159
+ : `Not serving ${one.invocation} over HTTP.`,
160
+ ...(live?.login_code
161
+ ? [
162
+ `Login code for a new browser app: ${live.login_code.code} (until ${clock(live.login_code.expires_at)}).`,
163
+ ]
164
+ : []),
165
+ ...(lock && !running
166
+ ? [
167
+ `A server that started ${clock(lock.startedAt)} (PID ${lock.pid}) is gone; the next start takes its place.`,
168
+ ]
169
+ : []),
170
+ ...clientLines(clients),
171
+ ]);
172
+ });
173
+ return command;
174
+ };
@@ -1,5 +1,6 @@
1
1
  import { type ChildProcessWithoutNullStreams } from "node:child_process";
2
2
  import { Command } from "commander";
3
+ import type { ServerSystem } from "../../background/system.js";
3
4
  import { type AppIdentity } from "../../ports/app.js";
4
5
  import { type BaseEnvironment } from "../context.js";
5
6
  import { type Messenger } from "./context.js";
@@ -9,6 +10,7 @@ export interface McpEnvironment extends BaseEnvironment {
9
10
  scriptPath?: string;
10
11
  execPath?: string;
11
12
  };
13
+ system?: ServerSystem;
12
14
  }
13
15
  export interface McpFlags {
14
16
  permission?: string[];
@@ -7,15 +7,17 @@ import { installerOf } from "@wirecat/cli-base/update";
7
7
  import { Command } from "commander";
8
8
  import { ATTACHMENT_LIMIT_ENV } from "../../attachments/limits.js";
9
9
  import { portOf, publicUrlOf } from "../../http/public-url.js";
10
+ import { httpLockFile } from "../../mcp/http/state.js";
10
11
  import { envName } from "../../ports/app.js";
11
12
  import { environmentOf } from "../context.js";
12
13
  import { grantsAuthority, LOCAL_READ, LOCAL_WRITE, REMOTE_WRITE } from "../effects.js";
13
14
  import { itemOf, renderAnswer } from "../envelope.js";
15
+ import { MCP_HTTP_DEFAULT_PORT, mcpServerCommand } from "../mcp-server-command.js";
14
16
  import { rootOf } from "../profile.js";
15
17
  import { nextStep } from "../steps.js";
16
18
  import { recalledAccount } from "./accounts.js";
17
19
  import { messengerContext } from "./context.js";
18
- const DEFAULT_PORT = 8765;
20
+ const DEFAULT_PORT = MCP_HTTP_DEFAULT_PORT;
19
21
  /** Where `mcp --http` keeps the hashes of the tokens it issued — per profile, in the state folder. */
20
22
  export const httpTokenFile = (app, profile, env) => join(resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).state, "mcp-http", `${profile}.json`);
21
23
  const withFlags = (command) => command.option("--permission <key=level>", "make a permission stricter for this server only, like messages.send=deny; it never loosens the profile; repeat for more keys", (value, previous = []) => [...previous, value]);
@@ -43,10 +45,13 @@ export const mcpCommand = (messenger) => {
43
45
  reaches: `the browser apps reach ${app.command}`,
44
46
  });
45
47
  const { serveOverHttpUntilStopped } = await import("../../mcp/server.js");
48
+ const tokenFile = httpTokenFile(app, context.settings.profile, context.env);
46
49
  await serveOverHttpUntilStopped(this, context, messenger, {}, {
47
50
  publicUrl,
48
51
  port: portOf(flags.port, DEFAULT_PORT),
49
- tokenFile: httpTokenFile(app, context.settings.profile, context.env),
52
+ tokenFile,
53
+ lockFile: httpLockFile(tokenFile),
54
+ version: app.version,
50
55
  });
51
56
  return;
52
57
  }
@@ -54,6 +59,24 @@ export const mcpCommand = (messenger) => {
54
59
  const { serveOverStdio } = await import("../../mcp/server.js");
55
60
  await serveOverStdio(this, context, messenger, {});
56
61
  });
62
+ command.addCommand(mcpServerCommand({
63
+ example: `${app.command} personal`,
64
+ target: (self) => {
65
+ const { settings, env } = messengerContext(self, messenger);
66
+ return {
67
+ invocation: `${app.command} ${settings.profile}`,
68
+ profile: settings.profile,
69
+ envPrefix: app.envPrefix,
70
+ env,
71
+ tokenFile: httpTokenFile(app, settings.profile, env),
72
+ argv: ["mcp", "--http"],
73
+ childEnv: { [`${app.envPrefix}_PROFILE`]: settings.profile },
74
+ version: app.version,
75
+ };
76
+ },
77
+ answer: (self, item, lines) => renderAnswer(self, messengerContext(self, messenger), itemOf(item), () => lines.join("\n")),
78
+ system: (self) => environmentOf(self).system,
79
+ }));
57
80
  command.addCommand(withFlags(annotate(new Command("config"), {
58
81
  effects: LOCAL_READ,
59
82
  examples: [`${app.command} personal mcp config`],
@@ -7,6 +7,7 @@ export interface OwnerLogin {
7
7
  verifier: OAuthTokenVerifier;
8
8
  /** Answers the login routes; `undefined` for any other path. */
9
9
  handle(request: Request): Promise<Response | undefined>;
10
+ close(): void;
10
11
  }
11
12
  /**
12
13
  * An OAuth 2.1 authorization server for exactly one owner (CLI-58). Anyone may register a client —
@@ -18,7 +19,7 @@ export declare const ownerLogin: ({ issuer, resource, file, appName, onCode, now
18
19
  resource: URL;
19
20
  file: string;
20
21
  appName: string;
21
- /** Shown to the owner on stderr: the code for the next login. */
22
+ /** The code for the next login, for the owner's eyes only: their terminal, or the server's status file. */
22
23
  onCode: (code: string, expires: Date) => void;
23
24
  now?: () => number;
24
25
  }) => OwnerLogin;
@@ -1,9 +1,8 @@
1
1
  import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { OAuthError, OAuthErrorCode, } from "@modelcontextprotocol/server";
5
- const ACCESS_SECONDS = 60 * 60;
6
- const REFRESH_SECONDS = 30 * 24 * 60 * 60;
5
+ import { ACCESS_SECONDS, REFRESH_SECONDS, readStored } from "./state.js";
7
6
  const AUTH_CODE_MS = 60 * 1000;
8
7
  const LOGIN_CODE_MS = 10 * 60 * 1000;
9
8
  const WRONG_TRIES = 5;
@@ -20,7 +19,7 @@ const same = (given, expected) => {
20
19
  };
21
20
  /** Only hashes reach the disk: the file is worth nothing to someone who copies it. */
22
21
  const tokenFile = (path) => ({
23
- read: () => existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : { clients: [], tokens: [] },
22
+ read: () => readStored(path),
24
23
  write: (stored) => {
25
24
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
26
25
  const temporary = `${path}.${randomBytes(16).toString("hex")}.tmp`;
@@ -58,9 +57,14 @@ export const ownerLogin = ({ issuer, resource, file, appName, onCode, now = () =
58
57
  let login = { value: "", expires: 0 };
59
58
  let wrong = 0;
60
59
  let retryAt = 0;
60
+ let renewal;
61
+ // Renewed when it runs out, not only when someone tries it: a code read from the status file must still work.
61
62
  const freshCode = () => {
62
63
  const part = () => Array.from({ length: 4 }, () => CODE_ALPHABET[randomInt(CODE_ALPHABET.length)]).join("");
63
64
  login = { value: `${part()}-${part()}`, expires: now() + LOGIN_CODE_MS };
65
+ clearTimeout(renewal);
66
+ renewal = setTimeout(freshCode, LOGIN_CODE_MS);
67
+ renewal.unref();
64
68
  onCode(login.value, new Date(login.expires));
65
69
  };
66
70
  freshCode();
@@ -147,7 +151,7 @@ export const ownerLogin = ({ issuer, resource, file, appName, onCode, now = () =
147
151
  .join("");
148
152
  const who = escapeHtml(client.name ?? "an application");
149
153
  const target = new URL(params.get("redirect_uri") ?? "");
150
- const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(appName)} login</title><style>body{font:16px system-ui,sans-serif;max-width:28rem;margin:3rem auto;padding:0 1rem;color:#1d2430}input[type=text]{font:20px ui-monospace,monospace;letter-spacing:.1em;width:100%;padding:.5rem;margin:.5rem 0 1rem;box-sizing:border-box}button{font:inherit;padding:.5rem 1rem}.error{color:#b42318}</style></head><body><h1>${escapeHtml(appName)}</h1><p>An app that calls itself <strong>${who}</strong> asks to reach your messages through <code>${escapeHtml(appName)} mcp --http</code>.</p><p>After you allow it, the login goes to <strong>${escapeHtml(target.host)}</strong>. Allow only if that is the app you are connecting, such as chatgpt.com or claude.ai.</p><p>Type the code shown in the terminal where it runs. If you did not start this login, close this page.</p>${message ? `<p class="error">${escapeHtml(message)}</p>` : ""}<form method="post" action="/authorize">${hidden}<label>Code from the terminal<input type="text" name="login_code" autocomplete="off" autofocus required></label><button type="submit">Allow</button></form></body></html>`;
154
+ const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(appName)} login</title><style>body{font:16px system-ui,sans-serif;max-width:28rem;margin:3rem auto;padding:0 1rem;color:#1d2430}input[type=text]{font:20px ui-monospace,monospace;letter-spacing:.1em;width:100%;padding:.5rem;margin:.5rem 0 1rem;box-sizing:border-box}button{font:inherit;padding:.5rem 1rem}.error{color:#b42318}</style></head><body><h1>${escapeHtml(appName)}</h1><p>An app that calls itself <strong>${who}</strong> asks to reach your messages through <code>${escapeHtml(appName)} mcp --http</code>.</p><p>After you allow it, the login goes to <strong>${escapeHtml(target.host)}</strong>. Allow only if that is the app you are connecting, such as chatgpt.com or claude.ai.</p><p>Type the code shown in WireCat Desktop, or in the terminal where the server runs. If you did not start this login, close this page.</p>${message ? `<p class="error">${escapeHtml(message)}</p>` : ""}<form method="post" action="/authorize">${hidden}<label>Login code<input type="text" name="login_code" autocomplete="off" autofocus required></label><button type="submit">Allow</button></form></body></html>`;
151
155
  return new Response(html, {
152
156
  status: message ? 400 : 200,
153
157
  headers: {
@@ -174,7 +178,7 @@ export const ownerLogin = ({ issuer, resource, file, appName, onCode, now = () =
174
178
  return new Response("This server needs response_type=code and PKCE with S256.", { status: 400 });
175
179
  if (request.method !== "POST")
176
180
  return page(params, client);
177
- const throttled = () => new Response("Too many wrong codes. Wait before trying the new terminal code.", {
181
+ const throttled = () => new Response("Too many wrong codes. Wait, then try the new code.", {
178
182
  status: 429,
179
183
  headers: { "retry-after": String(Math.ceil((retryAt - now()) / 1000)) },
180
184
  });
@@ -187,7 +191,7 @@ export const ownerLogin = ({ issuer, resource, file, appName, onCode, now = () =
187
191
  const typed = (params.get("login_code") ?? "").trim().toUpperCase();
188
192
  if (login.expires < now()) {
189
193
  freshCode();
190
- return page(params, client, "The code expired. A new one is shown in the terminal.");
194
+ return page(params, client, "The code expired. A new one is shown in WireCat Desktop and in the terminal.");
191
195
  }
192
196
  if (!same(typed, login.value)) {
193
197
  wrong += 1;
@@ -266,6 +270,7 @@ export const ownerLogin = ({ issuer, resource, file, appName, onCode, now = () =
266
270
  };
267
271
  return {
268
272
  code: () => login.value,
273
+ close: () => clearTimeout(renewal),
269
274
  metadata,
270
275
  verifier,
271
276
  handle: async (request) => {
@@ -11,6 +11,12 @@ export interface HttpOptions {
11
11
  onCode: (code: string, expires: Date) => void;
12
12
  onError?: (error: Error) => void;
13
13
  now?: () => number;
14
+ /**
15
+ * `httpLockFile(tokenFile)`: one server per token file, and what `mcp server status` reads — the
16
+ * port, the address and the current login code. Without it nothing is written.
17
+ */
18
+ lockFile?: string;
19
+ version?: string;
14
20
  }
15
21
  /**
16
22
  * MCP over Streamable HTTP on 127.0.0.1, behind the owner's tunnel (CLI-58). Every MCP request needs
@@ -1,7 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { createMcpHandler, getOAuthProtectedResourceMetadataUrl, hostHeaderValidationResponse, isLegacyRequest, oauthMetadataResponse, requireBearerAuth, WebStandardStreamableHTTPServerTransport, } from "@modelcontextprotocol/server";
3
+ import { CliError } from "@wirecat/cli-base";
3
4
  import { listen } from "../../http/listen.js";
4
5
  import { ownerLogin } from "./oauth.js";
6
+ import { httpLock } from "./state.js";
5
7
  export const MCP_PATH = "/mcp";
6
8
  const LOCAL = ["localhost", "127.0.0.1"];
7
9
  const MAX_SESSIONS = 20;
@@ -65,10 +67,31 @@ const legacySessions = (build, now) => {
65
67
  * the owner's browser cannot reach the port through a rebound DNS name.
66
68
  */
67
69
  export const serveOverHttp = async (build, options) => {
68
- const { publicUrl, port, tokenFile, appName, onCode, onError, now } = options;
70
+ const { publicUrl, port, tokenFile, appName, onCode, onError, now, lockFile, version } = options;
71
+ const lock = lockFile
72
+ ? httpLock(lockFile, {
73
+ pid: process.pid,
74
+ startedAt: new Date().toISOString(),
75
+ port,
76
+ publicUrl: publicUrl.href,
77
+ ...(version ? { version } : {}),
78
+ })
79
+ : undefined;
80
+ if (lock?.held)
81
+ throw new CliError("validation_error", `${appName} already serves MCP over HTTP for this profile on port ${lock.held.port} (PID ${lock.held.pid}, since ${lock.held.startedAt})`);
69
82
  const issuer = new URL(publicUrl.origin);
70
83
  const resource = new URL(MCP_PATH, issuer);
71
- const login = ownerLogin({ issuer, resource, file: tokenFile, appName, onCode, ...(now ? { now } : {}) });
84
+ const login = ownerLogin({
85
+ issuer,
86
+ resource,
87
+ file: tokenFile,
88
+ appName,
89
+ onCode: (code, expires) => {
90
+ lock?.update({ loginCode: code, loginCodeExpiresAt: expires.toISOString() });
91
+ onCode(code, expires);
92
+ },
93
+ ...(now ? { now } : {}),
94
+ });
72
95
  const insecure = issuer.protocol === "http:" && LOCAL.includes(issuer.hostname);
73
96
  const mcp = createMcpHandler(build, { legacy: "reject", ...(onError ? { onerror: onError } : {}) });
74
97
  const legacy = legacySessions(build, now ?? Date.now);
@@ -106,13 +129,24 @@ export const serveOverHttp = async (build, options) => {
106
129
  if (auth instanceof Response)
107
130
  return auth;
108
131
  return (await isLegacyRequest(request)) ? legacy.handle(request, auth) : mcp.fetch(request, { authInfo: auth });
109
- }, { host: "127.0.0.1", port });
132
+ }, { host: "127.0.0.1", port }).catch((error) => {
133
+ login.close();
134
+ lock?.release();
135
+ throw error;
136
+ });
137
+ lock?.update({ listeningAt: new Date().toISOString() });
110
138
  return {
111
139
  url: listening.url,
112
140
  close: async () => {
113
- await listening.close();
114
- await legacy.close();
115
- await mcp.close();
141
+ login.close();
142
+ try {
143
+ await listening.close();
144
+ await legacy.close();
145
+ await mcp.close();
146
+ }
147
+ finally {
148
+ lock?.release();
149
+ }
116
150
  },
117
151
  };
118
152
  };
@@ -0,0 +1,52 @@
1
+ import { type Lock } from "../../background/lock.js";
2
+ export declare const ACCESS_SECONDS: number;
3
+ export declare const REFRESH_SECONDS: number;
4
+ export interface StoredClient {
5
+ id: string;
6
+ name?: string;
7
+ secretHash?: string;
8
+ redirectUris: string[];
9
+ authMethod: "none" | "client_secret_post" | "client_secret_basic";
10
+ issuedAt: number;
11
+ }
12
+ export interface StoredToken {
13
+ hash: string;
14
+ kind: "access" | "refresh";
15
+ clientId: string;
16
+ expiresAt: number;
17
+ }
18
+ /** The token file: registered clients and the hashes of the tokens they hold. */
19
+ export interface Stored {
20
+ clients: StoredClient[];
21
+ tokens: StoredToken[];
22
+ }
23
+ export declare const readStored: (path: string) => Stored;
24
+ /** What a running `mcp --http` writes beside its token file, mode 600 — the login code is a live credential. */
25
+ export interface HttpLock extends Lock {
26
+ port: number;
27
+ publicUrl: string;
28
+ loginCode?: string;
29
+ loginCodeExpiresAt?: string;
30
+ }
31
+ /** The variable a server started by `mcp server start` carries; a serve's `<PREFIX>_SERVER` never ends with it. */
32
+ export declare const mcpHttpMarker: (envPrefix: string) => string;
33
+ export declare const httpLogFile: (tokenFile: string) => string;
34
+ /** Beside the token file, so each CLI's own layout decides both: `mcp-http/<profile>.json` → `.lock`. */
35
+ export declare const httpLockFile: (tokenFile: string) => string;
36
+ export declare const readHttpLock: (path: string) => HttpLock | undefined;
37
+ /** The lock a serving process keeps current: taken before it listens, given up when it closes. */
38
+ export declare const httpLock: (path: string, first: HttpLock) => {
39
+ held: HttpLock | undefined;
40
+ update: (change: Partial<HttpLock>) => void;
41
+ release: () => void;
42
+ };
43
+ export interface SignedInClient {
44
+ /** What the app said its name was when it registered — anyone may register, so it proves nothing. */
45
+ claimedName: string | null;
46
+ lastLoginAt: string;
47
+ }
48
+ /**
49
+ * The apps that hold a token now, and when each last got one. Ids and hashes stay in the file: what
50
+ * leaves is enough to recognise an app, never to act as it.
51
+ */
52
+ export declare const signedInClients: (tokenFile: string, now?: number) => SignedInClient[];
@@ -0,0 +1,49 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { holdLock, readLock, releaseLock, takeLock } from "../../background/lock.js";
3
+ export const ACCESS_SECONDS = 60 * 60;
4
+ export const REFRESH_SECONDS = 30 * 24 * 60 * 60;
5
+ export const readStored = (path) => existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : { clients: [], tokens: [] };
6
+ /** The variable a server started by `mcp server start` carries; a serve's `<PREFIX>_SERVER` never ends with it. */
7
+ export const mcpHttpMarker = (envPrefix) => `${envPrefix}_MCP_HTTP`;
8
+ export const httpLogFile = (tokenFile) => `${tokenFile.replace(/\.json$/, "")}.log`;
9
+ /** Beside the token file, so each CLI's own layout decides both: `mcp-http/<profile>.json` → `.lock`. */
10
+ export const httpLockFile = (tokenFile) => `${tokenFile.replace(/\.json$/, "")}.lock`;
11
+ export const readHttpLock = (path) => {
12
+ const lock = readLock(path);
13
+ return lock && typeof lock.port === "number" && typeof lock.publicUrl === "string" ? lock : undefined;
14
+ };
15
+ /** The lock a serving process keeps current: taken before it listens, given up when it closes. */
16
+ export const httpLock = (path, first) => {
17
+ let current = first;
18
+ const held = takeLock(path, current);
19
+ return {
20
+ held: held,
21
+ update: (change) => {
22
+ current = { ...current, ...change };
23
+ holdLock(path, current);
24
+ },
25
+ release: () => releaseLock(path),
26
+ };
27
+ };
28
+ /**
29
+ * The apps that hold a token now, and when each last got one. Ids and hashes stay in the file: what
30
+ * leaves is enough to recognise an app, never to act as it.
31
+ */
32
+ export const signedInClients = (tokenFile, now = Date.now()) => {
33
+ const seconds = Math.floor(now / 1000);
34
+ const { clients, tokens } = readStored(tokenFile);
35
+ const last = new Map();
36
+ for (const token of tokens) {
37
+ if (token.expiresAt <= seconds)
38
+ continue;
39
+ const issued = token.expiresAt - (token.kind === "access" ? ACCESS_SECONDS : REFRESH_SECONDS);
40
+ last.set(token.clientId, Math.max(last.get(token.clientId) ?? 0, issued));
41
+ }
42
+ return clients
43
+ .filter((client) => last.has(client.id))
44
+ .map((client) => ({
45
+ claimedName: client.name ?? null,
46
+ lastLoginAt: new Date((last.get(client.id) ?? 0) * 1000).toISOString(),
47
+ }))
48
+ .sort((a, b) => b.lastLoginAt.localeCompare(a.lastLoginAt));
49
+ };
@@ -3,4 +3,5 @@ export { type AgentAnswer, type AgentContext, type AgentProgram, type AgentTool,
3
3
  export { type BotServerOptions, createBotServer, type RunBotCommand } from "./bot/server.js";
4
4
  export { revokeAll } from "./http/oauth.js";
5
5
  export { type HttpOptions, MCP_PATH, serveOverHttp } from "./http/serve.js";
6
+ export { httpLockFile } from "./http/state.js";
6
7
  export { type McpCommand, registerCommands } from "./surface.js";
package/dist/mcp/index.js CHANGED
@@ -4,4 +4,5 @@ export { runCommandForAgent, toolsFromCommands, } from "./agent.js";
4
4
  export { createBotServer } from "./bot/server.js";
5
5
  export { revokeAll } from "./http/oauth.js";
6
6
  export { MCP_PATH, serveOverHttp } from "./http/serve.js";
7
+ export { httpLockFile } from "./http/state.js";
7
8
  export { registerCommands } from "./surface.js";
@@ -25,6 +25,7 @@ import { levelFor, readKeysForCommand } from "../sends/permissions.js";
25
25
  import { openStore } from "../store/store.js";
26
26
  import { runCommandForAgent, toolsFromCommands } from "./agent.js";
27
27
  import { agentLog } from "./agent-log.js";
28
+ import { mcpHttpMarker } from "./http/state.js";
28
29
  import { instructions } from "./instructions.js";
29
30
  import { registerPrompts } from "./prompts.js";
30
31
  import { registerResources } from "./resources.js";
@@ -469,10 +470,14 @@ export const serveOverHttpUntilStopped = async (command, context, messenger, opt
469
470
  const { session, embedders, build, log } = createServer(command, context, messenger, options);
470
471
  const { serveOverHttp } = await import("./http/serve.js");
471
472
  const appName = messenger.app.command;
473
+ const appEnvPrefix = messenger.app.envPrefix;
472
474
  const listening = await serveOverHttp(build, {
473
475
  ...http,
474
476
  appName,
475
- onCode: (code, expires) => context.renderer.note(`login code for a new browser app: ${code} (until ${expires.toTimeString().slice(0, 5)}; a new one after each login)`),
477
+ // In the background its log is a file; the code goes only to the lock that `mcp server status` reads.
478
+ onCode: (code, expires) => context.renderer.note(context.env[mcpHttpMarker(appEnvPrefix)] === undefined
479
+ ? `login code for a new browser app: ${code} (until ${expires.toTimeString().slice(0, 5)}; a new one after each login)`
480
+ : `a new login code for a browser app: \`${appName} ${context.settings.profile} mcp server status\``),
476
481
  onError: (error) => context.renderer.note(`mcp: ${error.message}`),
477
482
  }).catch(async (error) => {
478
483
  await session.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.444.0",
3
+ "version": "0.445.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
package/parity.json CHANGED
@@ -3179,6 +3179,23 @@
3179
3179
  }
3180
3180
  }
3181
3181
  },
3182
+ "mcp server": {
3183
+ "in": "all"
3184
+ },
3185
+ "mcp server start": {
3186
+ "in": "all",
3187
+ "options": {
3188
+ "--permission": "all",
3189
+ "--port": "all",
3190
+ "--public-url": "all"
3191
+ }
3192
+ },
3193
+ "mcp server status": {
3194
+ "in": "all"
3195
+ },
3196
+ "mcp server stop": {
3197
+ "in": "all"
3198
+ },
3182
3199
  "mentions": {
3183
3200
  "in": "all"
3184
3201
  },