@wirecat/cli-core 0.453.0 → 0.454.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
@@ -7,6 +7,17 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.454.0 — 27.10.2026
11
+
12
+ ### Added
13
+
14
+ - **`mcp server status` says what each logged-in browser app last did.** Each client in `clients` carries
15
+ `last_request_at` (its last MCP request of any kind, so an app that logged in has also reached the server),
16
+ `last_tool` and `last_tool_at` (its last `tools/call`) and `tool_calls_today` (calls since local midnight); `null`
17
+ and `0` for an app that has done nothing yet. The running server keeps them in `<token file>.activity.json`, mode
18
+ 600, beside the token file: tool names and times only, never arguments or results, and a name that does not look
19
+ like a tool's is not kept. WireCat Desktop shows them as "last call 2 min ago · tg_read".
20
+
10
21
  ## 0.453.0 — 27.10.2026
11
22
 
12
23
  ### Added
@@ -36,7 +36,7 @@ const clientLines = (clients) => clients.length === 0
36
36
  ? ["No browser app is logged in."]
37
37
  : [
38
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)})`)
39
+ .map((one) => `${one.claimedName === null ? "no name" : JSON.stringify(one.claimedName)} (${clock(one.lastLoginAt)}${one.lastTool && one.lastToolAt ? `; last call ${one.lastTool} at ${clock(one.lastToolAt)}, ${one.toolCallsToday} today` : ""})`)
40
40
  .join(", ")}.`,
41
41
  ];
42
42
  /**
@@ -155,6 +155,10 @@ export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT,
155
155
  clients: clients.map((client) => ({
156
156
  claimed_name: client.claimedName,
157
157
  last_login_at: client.lastLoginAt,
158
+ last_request_at: client.lastRequestAt,
159
+ last_tool: client.lastTool,
160
+ last_tool_at: client.lastToolAt,
161
+ tool_calls_today: client.toolCallsToday,
158
162
  })),
159
163
  log: httpLogFile(one.tokenFile),
160
164
  }, [
@@ -0,0 +1,24 @@
1
+ /** What each logged-in app did last, keyed by its client id. Tool names and times only: never arguments. */
2
+ export interface ClientActivity {
3
+ lastRequestAt: string;
4
+ lastTool?: string;
5
+ lastToolAt?: string;
6
+ /** The local day `toolCalls` counts, `YYYY-MM-DD`. */
7
+ day: string;
8
+ toolCalls: number;
9
+ }
10
+ export type Activity = Record<string, ClientActivity>;
11
+ export declare const httpActivityFile: (tokenFile: string) => string;
12
+ export declare const localDay: (ms: number) => string;
13
+ export declare const readActivity: (path: string) => Activity;
14
+ /** The names of the tools a JSON-RPC body calls; a batch may call several. */
15
+ export declare const calledTools: (body: unknown) => string[];
16
+ /**
17
+ * Keeps the activity file of one running server. A call is written at once, so status shows it while the owner
18
+ * watches; other requests at most every two seconds. Its own file, not the token file: `mcp --revoke` rewrites that
19
+ * from another process.
20
+ */
21
+ export declare const activityRecorder: (path: string, now?: () => number) => {
22
+ record: (clientId: string, tools: string[]) => void;
23
+ flush: () => void;
24
+ };
@@ -0,0 +1,86 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ const WRITE_EVERY_MS = 2000;
5
+ /** Any app may send any name; one that does not look like a tool's is not kept. */
6
+ const TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/;
7
+ export const httpActivityFile = (tokenFile) => `${tokenFile.replace(/\.json$/, "")}.activity.json`;
8
+ export const localDay = (ms) => {
9
+ const d = new Date(ms);
10
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
11
+ };
12
+ export const readActivity = (path) => {
13
+ if (!existsSync(path))
14
+ return {};
15
+ try {
16
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
17
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
18
+ }
19
+ catch {
20
+ return {};
21
+ }
22
+ };
23
+ /** The names of the tools a JSON-RPC body calls; a batch may call several. */
24
+ export const calledTools = (body) => (Array.isArray(body) ? body : [body]).flatMap((message) => {
25
+ if (!message || typeof message !== "object")
26
+ return [];
27
+ const { method, params } = message;
28
+ const name = params?.name;
29
+ return method === "tools/call" && typeof name === "string" && TOOL_NAME.test(name) ? [name] : [];
30
+ });
31
+ /**
32
+ * Keeps the activity file of one running server. A call is written at once, so status shows it while the owner
33
+ * watches; other requests at most every two seconds. Its own file, not the token file: `mcp --revoke` rewrites that
34
+ * from another process.
35
+ */
36
+ export const activityRecorder = (path, now = Date.now) => {
37
+ const activity = readActivity(path);
38
+ let timer;
39
+ const write = () => {
40
+ timer = undefined;
41
+ try {
42
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
43
+ const temporary = `${path}.${randomBytes(8).toString("hex")}.tmp`;
44
+ writeFileSync(temporary, JSON.stringify(activity), { mode: 0o600, flag: "wx" });
45
+ chmodSync(temporary, 0o600);
46
+ renameSync(temporary, path);
47
+ }
48
+ catch {
49
+ // Activity is a convenience for the owner; serving must not fail over it.
50
+ }
51
+ };
52
+ return {
53
+ record: (clientId, tools) => {
54
+ const at = now();
55
+ const iso = new Date(at).toISOString();
56
+ const day = localDay(at);
57
+ const before = activity[clientId];
58
+ const counted = before?.day === day ? before.toolCalls : 0;
59
+ const last = tools.at(-1);
60
+ activity[clientId] = {
61
+ lastRequestAt: iso,
62
+ day,
63
+ toolCalls: counted + tools.length,
64
+ ...(last
65
+ ? { lastTool: last, lastToolAt: iso }
66
+ : before?.lastTool
67
+ ? { lastTool: before.lastTool, ...(before.lastToolAt ? { lastToolAt: before.lastToolAt } : {}) }
68
+ : {}),
69
+ };
70
+ if (last) {
71
+ clearTimeout(timer);
72
+ write();
73
+ }
74
+ else if (!timer) {
75
+ timer = setTimeout(write, WRITE_EVERY_MS);
76
+ timer.unref();
77
+ }
78
+ },
79
+ flush: () => {
80
+ if (!timer)
81
+ return;
82
+ clearTimeout(timer);
83
+ write();
84
+ },
85
+ };
86
+ };
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { createMcpHandler, getOAuthProtectedResourceMetadataUrl, hostHeaderValidationResponse, isLegacyRequest, oauthMetadataResponse, requireBearerAuth, WebStandardStreamableHTTPServerTransport, } from "@modelcontextprotocol/server";
3
3
  import { CliError } from "@wirecat/cli-base";
4
4
  import { listen } from "../../http/listen.js";
5
+ import { activityRecorder, calledTools, httpActivityFile } from "./activity.js";
5
6
  import { ownerLogin } from "./oauth.js";
6
7
  import { httpLock } from "./state.js";
7
8
  export const MCP_PATH = "/mcp";
@@ -74,6 +75,16 @@ const metadataRequest = (request, path, base) => {
74
75
  url.pathname = inserted ? AS_METADATA : path;
75
76
  return new Request(url, { method: request.method, headers: request.headers });
76
77
  };
78
+ const toolsIn = async (request) => {
79
+ if (request.method !== "POST")
80
+ return [];
81
+ try {
82
+ return calledTools(await request.clone().json());
83
+ }
84
+ catch {
85
+ return [];
86
+ }
87
+ };
77
88
  /**
78
89
  * MCP over Streamable HTTP on 127.0.0.1, behind the owner's tunnel (CLI-58). Every MCP request needs
79
90
  * a token from the owner login; the Host header must be the tunnel's or this machine's, so a page in
@@ -116,6 +127,7 @@ export const serveOverHttp = async (build, options) => {
116
127
  verifier: login.verifier,
117
128
  resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resource),
118
129
  });
130
+ const activity = activityRecorder(httpActivityFile(tokenFile), now);
119
131
  const allowedHosts = [...new Set([publicUrl.hostname, ...LOCAL])];
120
132
  const allowedOrigins = [publicUrl.origin];
121
133
  const listening = await listen(async (request) => {
@@ -145,6 +157,7 @@ export const serveOverHttp = async (build, options) => {
145
157
  const auth = await bearer(request);
146
158
  if (auth instanceof Response)
147
159
  return auth;
160
+ activity.record(auth.clientId, await toolsIn(request));
148
161
  return (await isLegacyRequest(request)) ? legacy.handle(request, auth) : mcp.fetch(request, { authInfo: auth });
149
162
  }, { host: "127.0.0.1", port }).catch((error) => {
150
163
  login.close();
@@ -156,6 +169,7 @@ export const serveOverHttp = async (build, options) => {
156
169
  url: listening.url,
157
170
  close: async () => {
158
171
  login.close();
172
+ activity.flush();
159
173
  try {
160
174
  await listening.close();
161
175
  await legacy.close();
@@ -44,6 +44,11 @@ export interface SignedInClient {
44
44
  /** What the app said its name was when it registered — anyone may register, so it proves nothing. */
45
45
  claimedName: string | null;
46
46
  lastLoginAt: string;
47
+ /** Any MCP request with its token, so the app reached the server after logging in. */
48
+ lastRequestAt: string | null;
49
+ lastTool: string | null;
50
+ lastToolAt: string | null;
51
+ toolCallsToday: number;
47
52
  }
48
53
  /**
49
54
  * The apps that hold a token now, and when each last got one. Ids and hashes stay in the file: what
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { holdLock, readLock, releaseLock, takeLock } from "../../background/lock.js";
3
+ import { httpActivityFile, localDay, readActivity } from "./activity.js";
3
4
  export const ACCESS_SECONDS = 60 * 60;
4
5
  export const REFRESH_SECONDS = 30 * 24 * 60 * 60;
5
6
  export const readStored = (path) => existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : { clients: [], tokens: [] };
@@ -32,6 +33,8 @@ export const httpLock = (path, first) => {
32
33
  export const signedInClients = (tokenFile, now = Date.now()) => {
33
34
  const seconds = Math.floor(now / 1000);
34
35
  const { clients, tokens } = readStored(tokenFile);
36
+ const activity = readActivity(httpActivityFile(tokenFile));
37
+ const today = localDay(now);
35
38
  const last = new Map();
36
39
  for (const token of tokens) {
37
40
  if (token.expiresAt <= seconds)
@@ -41,9 +44,16 @@ export const signedInClients = (tokenFile, now = Date.now()) => {
41
44
  }
42
45
  return clients
43
46
  .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
- }))
47
+ .map((client) => {
48
+ const did = activity[client.id];
49
+ return {
50
+ claimedName: client.name ?? null,
51
+ lastLoginAt: new Date((last.get(client.id) ?? 0) * 1000).toISOString(),
52
+ lastRequestAt: did?.lastRequestAt ?? null,
53
+ lastTool: did?.lastTool ?? null,
54
+ lastToolAt: did?.lastToolAt ?? null,
55
+ toolCallsToday: did?.day === today ? did.toolCalls : 0,
56
+ };
57
+ })
48
58
  .sort((a, b) => b.lastLoginAt.localeCompare(a.lastLoginAt));
49
59
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.453.0",
3
+ "version": "0.454.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",