@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 +11 -0
- package/dist/cli/mcp-server-command.js +5 -1
- package/dist/mcp/http/activity.d.ts +24 -0
- package/dist/mcp/http/activity.js +86 -0
- package/dist/mcp/http/serve.js +14 -0
- package/dist/mcp/http/state.d.ts +5 -0
- package/dist/mcp/http/state.js +14 -4
- package/package.json +1 -1
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
|
+
};
|
package/dist/mcp/http/serve.js
CHANGED
|
@@ -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();
|
package/dist/mcp/http/state.d.ts
CHANGED
|
@@ -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
|
package/dist/mcp/http/state.js
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
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.
|
|
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",
|