@wirecat/cli-core 0.469.0 → 0.470.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 +24 -0
- package/dist/cli/mcp-server-command.d.ts +10 -2
- package/dist/cli/mcp-server-command.js +28 -16
- package/dist/cli/messenger/mcp-command.d.ts +32 -3
- package/dist/cli/messenger/mcp-command.js +123 -45
- package/dist/cli/steps.js +1 -1
- package/package.json +1 -1
- package/parity.json +19 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ 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
|
|
9
|
+
|
|
10
|
+
## 0.470.0 — 27.10.2026
|
|
11
|
+
|
|
12
|
+
### Changed — may break callers
|
|
13
|
+
|
|
14
|
+
- **An MCP server serves only the profiles it is told**, the owner's choice written in the agent's config
|
|
15
|
+
([WC-MCP-9](https://github.com/WireCatLabs/cli-base/blob/main/docs/standards/mcp.md#wc-mcp-9--a-server-serves-the-profiles-it-is-told-with-one-connection-for-each)).
|
|
16
|
+
`<cli> mcp serve` with no profile no longer serves every profile: it exits 2 with `validation_error` and lists the
|
|
17
|
+
profiles here. Name one (`<cli> work mcp serve`), a list (`<cli> mcp serve --profiles work,personal`, each name
|
|
18
|
+
must exist) or every profile (`--profiles all`); the startup line on stderr names the profiles served. A first word
|
|
19
|
+
with `--profiles` is refused; under `<PREFIX>_PROFILE_LOCK`, `--profiles` may name the locked profile only
|
|
20
|
+
(`permission_error`). `all` alone is the keyword and is refused inside a list; a profile named `all` is served as
|
|
21
|
+
`<cli> all mcp serve`. The same choice applies to `--http`, `mcp server start|stop|status`, `mcp revoke` and
|
|
22
|
+
`mcp config`; the entry `mcp config --profiles all` prints runs `mcp serve --profiles all`. A list's HTTP server
|
|
23
|
+
has its own login (`mcp-http/_<names>.json`), so its login never opens a wider server; `--profiles all` keeps
|
|
24
|
+
`_all.json`. For callers: `serverEntry` takes `profile` or `profiles` and refuses neither;
|
|
25
|
+
`mcpServerCommand` takes `profiles: true` to give `start|stop|status` the option, and `McpServerTarget.profiles`
|
|
26
|
+
names the set. A client entry from 0.463.0–0.469.0 that runs `<cli> mcp serve` with no profile stops working: print
|
|
27
|
+
it again with `mcp config --profiles all`. A background server of every profile started by those versions: `upgrade`
|
|
28
|
+
stops it and the new version refuses to start it, so start it again with `mcp server start --profiles all`; one
|
|
29
|
+
still running without `upgrade` is not recognised by `mcp server stop --profiles all` (its marker changed) and is
|
|
30
|
+
stopped by its PID.
|
|
31
|
+
|
|
8
32
|
## 0.469.0 — 27.10.2026
|
|
9
33
|
|
|
10
34
|
### Changed — may break callers
|
|
@@ -5,8 +5,14 @@ export declare const MCP_HTTP_DEFAULT_PORT = 8765;
|
|
|
5
5
|
export interface McpServerTarget {
|
|
6
6
|
/** What a person types before `mcp`: `tg personal`, `memo`. */
|
|
7
7
|
invocation: string;
|
|
8
|
-
/** Absent for a CLI without profiles. */
|
|
8
|
+
/** Absent for a CLI without profiles, and for a server of several. */
|
|
9
9
|
profile?: string;
|
|
10
|
+
/** A server of several profiles: what `--profiles` took, its token file's name and the profiles it names. */
|
|
11
|
+
profiles?: {
|
|
12
|
+
option: string;
|
|
13
|
+
key: string;
|
|
14
|
+
label: string;
|
|
15
|
+
};
|
|
10
16
|
envPrefix: string;
|
|
11
17
|
env: NodeJS.ProcessEnv;
|
|
12
18
|
tokenFile: string;
|
|
@@ -26,6 +32,8 @@ export interface McpServerOptions {
|
|
|
26
32
|
* since a server that refuses the option dies before it listens.
|
|
27
33
|
*/
|
|
28
34
|
permission?: boolean;
|
|
35
|
+
/** `start`, `stop` and `status` take `--profiles`, for a CLI whose `mcp serve` serves several profiles. */
|
|
36
|
+
profiles?: boolean;
|
|
29
37
|
target: (self: Command) => McpServerTarget;
|
|
30
38
|
/** The CLI's own answer: the item in `--json`, else the lines. */
|
|
31
39
|
answer: (self: Command, item: object, lines: string[]) => void;
|
|
@@ -37,4 +45,4 @@ export interface McpServerOptions {
|
|
|
37
45
|
* survives the shell that started it, and `stop` ends only a process that carries this CLI's marker.
|
|
38
46
|
* None of it is offered over MCP: status holds a live login code.
|
|
39
47
|
*/
|
|
40
|
-
export declare const mcpServerCommand: ({ example, defaultPort, permission, target, answer, system, }: McpServerOptions) => Command;
|
|
48
|
+
export declare const mcpServerCommand: ({ example, defaultPort, permission, profiles, target, answer, system, }: McpServerOptions) => Command;
|
|
@@ -18,7 +18,16 @@ const connectorUrl = (lock) => mcpUrlOf(new URL(lock.publicUrl)).href;
|
|
|
18
18
|
const liveCode = (lock, now) => lock.loginCode && lock.loginCodeExpiresAt && Date.parse(lock.loginCodeExpiresAt) > now
|
|
19
19
|
? { code: lock.loginCode, expires_at: lock.loginCodeExpiresAt }
|
|
20
20
|
: null;
|
|
21
|
-
const
|
|
21
|
+
const markerValue = (target) => target.profile ?? target.profiles?.key ?? "default";
|
|
22
|
+
const markerOf = (target) => `${mcpHttpMarker(target.envPrefix)}=${markerValue(target)}`;
|
|
23
|
+
/** How a line names the server: `chat personal`, or `chat` and the profiles it serves. */
|
|
24
|
+
const shown = (target) => target.profiles ? `${target.invocation}, ${target.profiles.label},` : target.invocation;
|
|
25
|
+
/** The `mcp server <verb>` that finds this server again. */
|
|
26
|
+
const again = (target, verb) => `${target.invocation} mcp server ${verb}${target.profiles ? ` --profiles ${target.profiles.option}` : ""}`;
|
|
27
|
+
const named = (target) => ({
|
|
28
|
+
profile: target.profile ?? null,
|
|
29
|
+
...(target.profiles ? { profiles: target.profiles.option } : {}),
|
|
30
|
+
});
|
|
22
31
|
/** Who started it decides whether `stop` may end it: only a process that carries our marker. */
|
|
23
32
|
const byOf = (target, pid) => (carries(pid, markerOf(target)) ? "server" : "hand");
|
|
24
33
|
const serving = (target, lock, now) => ({
|
|
@@ -45,7 +54,7 @@ const clientLines = (clients) => clients.length === 0
|
|
|
45
54
|
* survives the shell that started it, and `stop` ends only a process that carries this CLI's marker.
|
|
46
55
|
* None of it is offered over MCP: status holds a live login code.
|
|
47
56
|
*/
|
|
48
|
-
export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT, permission = false, target, answer, system, }) => {
|
|
57
|
+
export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT, permission = false, profiles = false, target, answer, system, }) => {
|
|
49
58
|
const command = annotate(new Command("server"), { effects: LOCAL_READ, mcp: false })
|
|
50
59
|
.summary("run the HTTP MCP server in the background")
|
|
51
60
|
.description("start, stop and check `mcp serve --http` in the background; status shows the login code for a browser app");
|
|
@@ -74,8 +83,8 @@ export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT,
|
|
|
74
83
|
const lockFile = httpLockFile(one.tokenFile);
|
|
75
84
|
const before = readHttpLock(lockFile);
|
|
76
85
|
if (before && alive(before.pid)) {
|
|
77
|
-
answer(this, {
|
|
78
|
-
`Already serving ${one
|
|
86
|
+
answer(this, { ...named(one), started: false, running: true, ...serving(one, before, Date.now()) }, [
|
|
87
|
+
`Already serving ${shown(one)} over HTTP since ${clock(before.startedAt)} (PID ${before.pid}, port ${before.port}).`,
|
|
79
88
|
]);
|
|
80
89
|
return;
|
|
81
90
|
}
|
|
@@ -92,7 +101,7 @@ export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT,
|
|
|
92
101
|
url.pathname === "/" ? url.origin : url.href,
|
|
93
102
|
...(flags.permission ?? []).flatMap((entry) => ["--permission", entry]),
|
|
94
103
|
],
|
|
95
|
-
env: { ...one.childEnv, [mcpHttpMarker(one.envPrefix)]: one
|
|
104
|
+
env: { ...one.childEnv, [mcpHttpMarker(one.envPrefix)]: markerValue(one) },
|
|
96
105
|
};
|
|
97
106
|
writeSecurely(httpLaunchFile(lockFile), `${JSON.stringify(launch)}\n`, 0o600);
|
|
98
107
|
const pid = sys.spawn(launch.argv, { ...inherited, ...launch.env }, log);
|
|
@@ -100,8 +109,8 @@ export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT,
|
|
|
100
109
|
const now = readHttpLock(lockFile);
|
|
101
110
|
if (now?.pid === pid && now.listeningAt) {
|
|
102
111
|
const code = liveCode(now, Date.now());
|
|
103
|
-
answer(this, {
|
|
104
|
-
`Started: serving ${one
|
|
112
|
+
answer(this, { ...named(one), started: true, running: true, ...serving(one, now, Date.now()), log }, [
|
|
113
|
+
`Started: serving ${shown(one)} over HTTP (PID ${pid}) on 127.0.0.1:${now.port}.`,
|
|
105
114
|
`Browser apps connect to ${connectorUrl(now)}`,
|
|
106
115
|
...(code ? [`Login code for a new browser app: ${code.code} (until ${clock(code.expires_at)}).`] : []),
|
|
107
116
|
]);
|
|
@@ -113,7 +122,7 @@ export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT,
|
|
|
113
122
|
}
|
|
114
123
|
await sys.pause();
|
|
115
124
|
}
|
|
116
|
-
throw new CliError("timeout", `mcp serve --http is running but not listening yet — \`${one
|
|
125
|
+
throw new CliError("timeout", `mcp serve --http is running but not listening yet — \`${again(one, "status")}\`; its log: ${log}`);
|
|
117
126
|
});
|
|
118
127
|
destroysNothing(annotate(command.command("stop"), { effects: LOCAL_WRITE, mcp: false, examples: [`${example} mcp server stop`] }))
|
|
119
128
|
.summary("stop the background HTTP MCP server")
|
|
@@ -122,13 +131,13 @@ export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT,
|
|
|
122
131
|
const one = target(this);
|
|
123
132
|
const lock = readHttpLock(httpLockFile(one.tokenFile));
|
|
124
133
|
if (!lock || !alive(lock.pid)) {
|
|
125
|
-
answer(this, {
|
|
126
|
-
`Nothing to stop: ${one
|
|
134
|
+
answer(this, { ...named(one), stopped: false }, [
|
|
135
|
+
`Nothing to stop: ${shown(one)} is not serving MCP over HTTP.`,
|
|
127
136
|
]);
|
|
128
137
|
return;
|
|
129
138
|
}
|
|
130
139
|
if (byOf(one, lock.pid) !== "server") {
|
|
131
|
-
throw new CliError("validation_error", `mcp serve --http for ${one
|
|
140
|
+
throw new CliError("validation_error", `mcp serve --http for ${shown(one)} (PID ${lock.pid}) was not started by \`${again(one, "start")}\` — stop it where it runs`);
|
|
132
141
|
}
|
|
133
142
|
process.kill(lock.pid, "SIGTERM");
|
|
134
143
|
const sys = machine(this);
|
|
@@ -136,8 +145,8 @@ export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT,
|
|
|
136
145
|
await sys.pause();
|
|
137
146
|
if (alive(lock.pid))
|
|
138
147
|
throw new CliError("timeout", `mcp serve --http (PID ${lock.pid}) has not stopped yet`);
|
|
139
|
-
answer(this, {
|
|
140
|
-
`Stopped serving ${one
|
|
148
|
+
answer(this, { ...named(one), stopped: true, pid: lock.pid }, [
|
|
149
|
+
`Stopped serving ${shown(one)} over HTTP (PID ${lock.pid}).`,
|
|
141
150
|
]);
|
|
142
151
|
});
|
|
143
152
|
annotate(command.command("status"), {
|
|
@@ -155,7 +164,7 @@ export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT,
|
|
|
155
164
|
const clients = signedInClients(one.tokenFile, now);
|
|
156
165
|
const live = running ? serving(one, lock, now) : undefined;
|
|
157
166
|
answer(this, {
|
|
158
|
-
|
|
167
|
+
...named(one),
|
|
159
168
|
running,
|
|
160
169
|
...(live ?? {}),
|
|
161
170
|
...(lock && !running ? { stale: { pid: lock.pid, started_at: lock.startedAt } } : {}),
|
|
@@ -170,8 +179,8 @@ export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT,
|
|
|
170
179
|
log: httpLogFile(one.tokenFile),
|
|
171
180
|
}, [
|
|
172
181
|
live && lock
|
|
173
|
-
? `Serving ${one
|
|
174
|
-
: `Not serving ${one
|
|
182
|
+
? `Serving ${shown(one)} 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 \`${again(one, "start")}\`.` : "Started by hand — `mcp server stop` leaves it alone."}`
|
|
183
|
+
: `Not serving ${shown(one)} over HTTP.`,
|
|
175
184
|
...(live?.login_code
|
|
176
185
|
? [
|
|
177
186
|
`Login code for a new browser app: ${live.login_code.code} (until ${clock(live.login_code.expires_at)}).`,
|
|
@@ -185,5 +194,8 @@ export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT,
|
|
|
185
194
|
...clientLines(clients),
|
|
186
195
|
]);
|
|
187
196
|
});
|
|
197
|
+
if (profiles)
|
|
198
|
+
for (const one of command.commands)
|
|
199
|
+
one.option("--profiles <names|all>", "the server of several profiles that `mcp serve --profiles` serves: names, comma-separated, or all");
|
|
188
200
|
return command;
|
|
189
201
|
};
|
|
@@ -3,6 +3,7 @@ import { Command } from "commander";
|
|
|
3
3
|
import type { ServerSystem } from "../../background/system.js";
|
|
4
4
|
import { type AppIdentity } from "../../ports/app.js";
|
|
5
5
|
import { type BaseEnvironment } from "../context.js";
|
|
6
|
+
import { type Settings } from "../settings.js";
|
|
6
7
|
import { type Messenger } from "./context.js";
|
|
7
8
|
/** Where the running CLI really lives; a test hands in its own. */
|
|
8
9
|
export interface McpEnvironment extends BaseEnvironment {
|
|
@@ -17,12 +18,39 @@ export interface McpFlags {
|
|
|
17
18
|
http?: boolean;
|
|
18
19
|
port?: string;
|
|
19
20
|
publicUrl?: string;
|
|
21
|
+
profiles?: string;
|
|
20
22
|
}
|
|
21
|
-
/** The token file of `<tool> mcp serve --http
|
|
23
|
+
/** The token file of `<tool> mcp serve --http --profiles all`: no profile name can start with `_`. */
|
|
22
24
|
export declare const ALL_PROFILES = "_all";
|
|
25
|
+
/** `--profiles all`; a profile named `all` is served alone, as `<tool> all mcp serve`. */
|
|
26
|
+
export declare const EVERY_PROFILE = "all";
|
|
27
|
+
/** The profiles `--profiles` names: `all`, read again on each session, or a fixed list. */
|
|
28
|
+
export interface ProfileSet {
|
|
29
|
+
/** What `--profiles` takes to name this set again: `all`, or the names sorted, comma-separated. */
|
|
30
|
+
option: string;
|
|
31
|
+
profiles: () => string[];
|
|
32
|
+
/** The token file's name: a list's login never opens the server of other profiles. */
|
|
33
|
+
key: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The profile or profiles an MCP server is for, always named (NEED-1881, WC-MCP-9): a leading profile, or
|
|
37
|
+
* `--profiles` with a list or `all`. Neither, or both, is refused with the profiles on this computer. `--profiles`
|
|
38
|
+
* takes the place of `<PREFIX>_PROFILE`; under `<PREFIX>_PROFILE_LOCK` it may name the locked profile only
|
|
39
|
+
* (WC-ACCT-5).
|
|
40
|
+
*/
|
|
41
|
+
export declare const servedProfiles: (self: Command, messenger: Messenger, { settings, env }: {
|
|
42
|
+
settings: Pick<Settings, "profile" | "sources" | "profilesHere">;
|
|
43
|
+
env: NodeJS.ProcessEnv;
|
|
44
|
+
}, given: string | undefined, words: string) => {
|
|
45
|
+
profile: string;
|
|
46
|
+
} | {
|
|
47
|
+
set: ProfileSet;
|
|
48
|
+
};
|
|
23
49
|
/** Where `mcp serve --http` keeps the hashes of the tokens it issued — per profile, in the state folder. */
|
|
24
50
|
export declare const httpTokenFile: (app: AppIdentity, profile: string, env: NodeJS.ProcessEnv) => string;
|
|
25
51
|
export declare const mcpCommand: (messenger: Messenger) => Command;
|
|
52
|
+
/** How the startup and status lines name a set, so the owner sees which accounts an agent reaches. */
|
|
53
|
+
export declare const profileSetLabel: (set: ProfileSet) => string;
|
|
26
54
|
/**
|
|
27
55
|
* The server's own stderr, so a failed start says why rather than only that it exited. Read as it
|
|
28
56
|
* comes, which also keeps a chatty server from blocking on a full pipe. Only the end is kept, with the
|
|
@@ -42,9 +70,10 @@ export declare const redactTail: (text: string, home: string | undefined) => str
|
|
|
42
70
|
* store. So is `XDG_RUNTIME_DIR`: the keyring is reached through it, and an MCP client that starts
|
|
43
71
|
* servers with a trimmed environment leaves it out (measured 2026-09-28). Credentials are never copied.
|
|
44
72
|
*/
|
|
45
|
-
export declare const serverEntry: (app: AppIdentity, { profile, flags, execPath, scriptPath, env, }: {
|
|
46
|
-
/**
|
|
73
|
+
export declare const serverEntry: (app: AppIdentity, { profile, profiles, flags, execPath, scriptPath, env, }: {
|
|
74
|
+
/** One profile, or `profiles`: the `--profiles` of a server of several. */
|
|
47
75
|
profile?: string;
|
|
76
|
+
profiles?: string;
|
|
48
77
|
flags?: McpFlags;
|
|
49
78
|
execPath: string;
|
|
50
79
|
scriptPath: string;
|
|
@@ -14,13 +14,56 @@ import { grantsAuthority, LOCAL_READ, LOCAL_WRITE, REMOTE_WRITE } from "../effec
|
|
|
14
14
|
import { itemOf, renderAnswer } from "../envelope.js";
|
|
15
15
|
import { MCP_HTTP_DEFAULT_PORT, mcpServerCommand } from "../mcp-server-command.js";
|
|
16
16
|
import { rootOf } from "../profile.js";
|
|
17
|
-
import { namedProfile } from "../settings.js";
|
|
17
|
+
import { NoProfileHere, namedProfile } from "../settings.js";
|
|
18
18
|
import { nextStep } from "../steps.js";
|
|
19
19
|
import { recalledAccount } from "./accounts.js";
|
|
20
20
|
import { messengerContext } from "./context.js";
|
|
21
21
|
const DEFAULT_PORT = MCP_HTTP_DEFAULT_PORT;
|
|
22
|
-
/** The token file of `<tool> mcp serve --http
|
|
22
|
+
/** The token file of `<tool> mcp serve --http --profiles all`: no profile name can start with `_`. */
|
|
23
23
|
export const ALL_PROFILES = "_all";
|
|
24
|
+
/** `--profiles all`; a profile named `all` is served alone, as `<tool> all mcp serve`. */
|
|
25
|
+
export const EVERY_PROFILE = "all";
|
|
26
|
+
/**
|
|
27
|
+
* The profile or profiles an MCP server is for, always named (NEED-1881, WC-MCP-9): a leading profile, or
|
|
28
|
+
* `--profiles` with a list or `all`. Neither, or both, is refused with the profiles on this computer. `--profiles`
|
|
29
|
+
* takes the place of `<PREFIX>_PROFILE`; under `<PREFIX>_PROFILE_LOCK` it may name the locked profile only
|
|
30
|
+
* (WC-ACCT-5).
|
|
31
|
+
*/
|
|
32
|
+
export const servedProfiles = (self, messenger, { settings, env }, given, words) => {
|
|
33
|
+
const { app } = messenger;
|
|
34
|
+
const here = settings.profilesHere;
|
|
35
|
+
const listed = `profiles here: ${here.length > 0 ? here.join(", ") : "none"}`;
|
|
36
|
+
const named = namedProfile(settings);
|
|
37
|
+
if (given === undefined && named !== undefined)
|
|
38
|
+
return { profile: named };
|
|
39
|
+
if (given !== undefined && settings.sources.profile === "first word")
|
|
40
|
+
throw new CliError("validation_error", `name one profile or --profiles, not both: \`${app.command} ${named} ${words}\` or \`${app.command} ${words} ` +
|
|
41
|
+
`--profiles ${given}\``, { profiles: here });
|
|
42
|
+
if (given === undefined) {
|
|
43
|
+
if (here.length === 0)
|
|
44
|
+
throw new NoProfileHere(app);
|
|
45
|
+
throw new CliError("validation_error", `name the profiles to serve: \`${app.command} <profile> ${words}\`, or \`${app.command} ${words} --profiles ` +
|
|
46
|
+
`<a,b>\` or \`--profiles all\` — ${listed}`, { profiles: here });
|
|
47
|
+
}
|
|
48
|
+
const names = [...new Set(given.split(",").map((one) => one.trim()))].filter(Boolean).sort();
|
|
49
|
+
const lock = env[envName(app, "PROFILE_LOCK")]?.trim();
|
|
50
|
+
if (lock && names.some((one) => one !== lock))
|
|
51
|
+
throw new CliError("permission_error", `this process is locked to profile ${lock} (${envName(app, "PROFILE_LOCK")}) — --profiles may name only ${lock}`);
|
|
52
|
+
if (lock)
|
|
53
|
+
return { set: { option: lock, profiles: () => [lock], key: `_${lock}` } };
|
|
54
|
+
if (names.length === 1 && names[0] === EVERY_PROFILE) {
|
|
55
|
+
const every = () => messenger.resolveSettings(self.optsWithGlobals(), { env, needsProfile: false, readOnly: true }).profilesHere;
|
|
56
|
+
return { set: { option: EVERY_PROFILE, profiles: every, key: ALL_PROFILES } };
|
|
57
|
+
}
|
|
58
|
+
if (names.length === 0 || names.includes(EVERY_PROFILE))
|
|
59
|
+
throw new CliError("validation_error", `--profiles takes profile names, comma-separated, or \`all\` alone for every profile — a profile named all ` +
|
|
60
|
+
`is served as \`${app.command} all ${words}\`; ${listed}`, { profiles: here });
|
|
61
|
+
const unknown = names.filter((one) => !here.includes(one));
|
|
62
|
+
if (unknown.length > 0)
|
|
63
|
+
throw new CliError("validation_error", `no profile named ${unknown.map((one) => `"${one}"`).join(", ")} — ${listed}`, { profiles: here });
|
|
64
|
+
return { set: { option: names.join(","), profiles: () => names, key: `_${names.join(",")}` } };
|
|
65
|
+
};
|
|
66
|
+
const withProfiles = (command) => command.option("--profiles <names|all>", "serve several profiles from one server instead of one: names, comma-separated, or all for every profile");
|
|
24
67
|
/** Where `mcp serve --http` keeps the hashes of the tokens it issued — per profile, in the state folder. */
|
|
25
68
|
export const httpTokenFile = (app, profile, env) => join(resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).state, "mcp-http", `${profile}.json`);
|
|
26
69
|
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]);
|
|
@@ -29,26 +72,28 @@ export const mcpCommand = (messenger) => {
|
|
|
29
72
|
const command = annotate(new Command("mcp"), { effects: LOCAL_READ, mcp: false })
|
|
30
73
|
.summary("serve this profile's commands to an AI agent, and set that up")
|
|
31
74
|
.description(`serve this profile to an agent over MCP and set it up — \`claude mcp add ${app.command} -- ${app.command} <profile> mcp serve\``);
|
|
32
|
-
command.addCommand(withFlags(annotate(new Command("serve"), {
|
|
75
|
+
command.addCommand(withProfiles(withFlags(annotate(new Command("serve"), {
|
|
33
76
|
effects: REMOTE_WRITE,
|
|
34
77
|
lifetime: "persistent",
|
|
35
78
|
mcp: false,
|
|
36
79
|
examples: [
|
|
37
80
|
`${app.command} personal mcp serve`,
|
|
81
|
+
`${app.command} mcp serve --profiles work,personal`,
|
|
38
82
|
`${app.command} personal mcp serve --http --public-url https://laptop.example.ts.net`,
|
|
39
83
|
],
|
|
40
84
|
})
|
|
41
85
|
.summary("serve this profile's commands to an AI agent")
|
|
42
|
-
.description(`serve this profile to an agent over MCP, on stdin and stdout — \`claude mcp add ${app.command} -- ${app.command} <profile> mcp serve\`;
|
|
86
|
+
.description(`serve this profile to an agent over MCP, on stdin and stdout — \`claude mcp add ${app.command} -- ${app.command} <profile> mcp serve\`; \`${app.command} mcp serve --profiles a,b\` or \`--profiles all\` serves those profiles from one server and each tool asks which`)))
|
|
43
87
|
.option("--http", "serve over HTTP on 127.0.0.1 for ChatGPT and Claude in the browser, behind your tunnel; the profile's permissions decide")
|
|
44
88
|
.option("--port <port>", `the local port for --http (default ${DEFAULT_PORT})`)
|
|
45
89
|
.option("--public-url <url>", "the tunnel's https address the browser apps use, e.g. https://<name>.ts.net or https://<name>.ts.net/tg")
|
|
46
90
|
.action(async function () {
|
|
47
91
|
const flags = this.optsWithGlobals();
|
|
48
92
|
const context = messengerContext(this, messenger, { needsProfile: false });
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
51
|
-
return
|
|
93
|
+
const served = servedProfiles(this, messenger, context, flags.profiles, "mcp serve");
|
|
94
|
+
if ("set" in served)
|
|
95
|
+
return serveProfiles(this, context, messenger, flags, served.set);
|
|
96
|
+
const { profile } = served;
|
|
52
97
|
if (flags.http) {
|
|
53
98
|
const publicUrl = publicUrlOf(flags.publicUrl, {
|
|
54
99
|
missing: "--http",
|
|
@@ -70,68 +115,80 @@ export const mcpCommand = (messenger) => {
|
|
|
70
115
|
const { serveOverStdio } = await import("../../mcp/server.js");
|
|
71
116
|
await serveOverStdio(this, context, messenger, {});
|
|
72
117
|
}));
|
|
73
|
-
command.addCommand(annotate(new Command("revoke"), {
|
|
118
|
+
command.addCommand(withProfiles(annotate(new Command("revoke"), {
|
|
74
119
|
effects: LOCAL_WRITE,
|
|
75
120
|
mcp: false,
|
|
76
|
-
examples: [`${app.command} personal mcp revoke`],
|
|
121
|
+
examples: [`${app.command} personal mcp revoke`, `${app.command} mcp revoke --profiles all`],
|
|
77
122
|
})
|
|
78
123
|
.summary("forget every login given to a browser app")
|
|
79
|
-
.description(
|
|
80
|
-
.action(async function () {
|
|
124
|
+
.description("forget every login `mcp serve --http` gave a browser app, of this profile or of the server `--profiles` names; each app must log in again")).action(async function () {
|
|
81
125
|
const context = messengerContext(this, messenger, { needsProfile: false });
|
|
82
|
-
const
|
|
126
|
+
const served = servedProfiles(this, messenger, context, this.opts().profiles, "mcp revoke");
|
|
83
127
|
const { revokeAll } = await import("../../mcp/http/oauth.js");
|
|
84
|
-
revokeAll(httpTokenFile(app,
|
|
85
|
-
renderAnswer(this, context, itemOf(
|
|
128
|
+
revokeAll(httpTokenFile(app, "set" in served ? served.set.key : served.profile, context.env));
|
|
129
|
+
renderAnswer(this, context, itemOf("set" in served
|
|
130
|
+
? { revoked: true, profile: null, profiles: served.set.option }
|
|
131
|
+
: { revoked: true, profile: served.profile }));
|
|
86
132
|
}));
|
|
87
133
|
command.addCommand(mcpServerCommand({
|
|
88
134
|
example: `${app.command} personal`,
|
|
89
135
|
permission: true,
|
|
136
|
+
profiles: true,
|
|
90
137
|
target: (self) => {
|
|
91
|
-
const
|
|
92
|
-
|
|
138
|
+
const context = messengerContext(self, messenger, { needsProfile: false });
|
|
139
|
+
const { env } = context;
|
|
140
|
+
const served = servedProfiles(self, messenger, context, self.opts().profiles, `mcp server ${self.name()}`);
|
|
141
|
+
if ("set" in served) {
|
|
142
|
+
const { set } = served;
|
|
93
143
|
return {
|
|
94
144
|
invocation: app.command,
|
|
145
|
+
profiles: { option: set.option, key: set.key, label: profileSetLabel(set) },
|
|
95
146
|
envPrefix: app.envPrefix,
|
|
96
147
|
env,
|
|
97
|
-
tokenFile: httpTokenFile(app,
|
|
98
|
-
argv: ["mcp", "serve", "--http"],
|
|
148
|
+
tokenFile: httpTokenFile(app, set.key, env),
|
|
149
|
+
argv: ["mcp", "serve", "--http", "--profiles", set.option],
|
|
99
150
|
version: app.version,
|
|
100
151
|
};
|
|
152
|
+
}
|
|
153
|
+
const { profile } = served;
|
|
101
154
|
return {
|
|
102
|
-
invocation: `${app.command} ${
|
|
103
|
-
profile
|
|
155
|
+
invocation: `${app.command} ${profile}`,
|
|
156
|
+
profile,
|
|
104
157
|
envPrefix: app.envPrefix,
|
|
105
158
|
env,
|
|
106
|
-
tokenFile: httpTokenFile(app,
|
|
159
|
+
tokenFile: httpTokenFile(app, profile, env),
|
|
107
160
|
argv: ["mcp", "serve", "--http"],
|
|
108
|
-
childEnv: { [`${app.envPrefix}_PROFILE`]:
|
|
161
|
+
childEnv: { [`${app.envPrefix}_PROFILE`]: profile },
|
|
109
162
|
version: app.version,
|
|
110
163
|
};
|
|
111
164
|
},
|
|
112
165
|
answer: (self, item, lines) => renderAnswer(self, messengerContext(self, messenger, { needsProfile: false }), itemOf(item), () => lines.join("\n")),
|
|
113
166
|
system: (self) => environmentOf(self).system,
|
|
114
167
|
}));
|
|
115
|
-
command.addCommand(withFlags(annotate(new Command("config"), {
|
|
168
|
+
command.addCommand(withProfiles(withFlags(annotate(new Command("config"), {
|
|
116
169
|
effects: LOCAL_READ,
|
|
117
|
-
examples: [`${app.command} personal mcp config`],
|
|
170
|
+
examples: [`${app.command} personal mcp config`, `${app.command} mcp config --profiles all`],
|
|
118
171
|
})
|
|
119
|
-
.summary("print the local MCP configuration
|
|
120
|
-
.description("print the mcpServers entry for Claude Desktop, Cursor and others, with full paths; writes nothing")).action(async function () {
|
|
172
|
+
.summary("print the local MCP configuration of this profile, or of those --profiles names")
|
|
173
|
+
.description("print the mcpServers entry for Claude Desktop, Cursor and others, with full paths; writes nothing"))).action(async function () {
|
|
121
174
|
const flags = this.optsWithGlobals();
|
|
122
175
|
const context = messengerContext(this, messenger, { needsProfile: false });
|
|
123
|
-
const {
|
|
176
|
+
const { env } = context;
|
|
124
177
|
const given = environmentOf(this).mcp ?? {};
|
|
125
|
-
const
|
|
178
|
+
const served = servedProfiles(this, messenger, context, flags.profiles, "mcp config");
|
|
126
179
|
const entry = serverEntry(app, {
|
|
127
|
-
...(
|
|
180
|
+
...("set" in served ? { profiles: served.set.option } : { profile: served.profile }),
|
|
128
181
|
flags,
|
|
129
182
|
execPath: given.execPath ?? process.execPath,
|
|
130
183
|
scriptPath: given.scriptPath ?? realpathSync(process.argv[1] ?? ""),
|
|
131
184
|
env,
|
|
132
185
|
});
|
|
133
186
|
// Pasted into a file, so a person gets the client's own JSON, indented; a script finds it under `config`.
|
|
134
|
-
renderAnswer(this, context, itemOf({ config: entry.config }, {
|
|
187
|
+
renderAnswer(this, context, itemOf({ config: entry.config }, {
|
|
188
|
+
next: entry.nodeBound
|
|
189
|
+
? [nextStep("mcp.config", { args: { profiles: "set" in served ? served.set.option : undefined } })]
|
|
190
|
+
: [],
|
|
191
|
+
}), () => JSON.stringify(entry.config, null, 2));
|
|
135
192
|
}));
|
|
136
193
|
command.addCommand(withFlags(grantsAuthority(annotate(new Command("add"), {
|
|
137
194
|
effects: LOCAL_WRITE,
|
|
@@ -213,22 +270,30 @@ export const mcpCommand = (messenger) => {
|
|
|
213
270
|
}));
|
|
214
271
|
return command;
|
|
215
272
|
};
|
|
273
|
+
/** How the startup and status lines name a set, so the owner sees which accounts an agent reaches. */
|
|
274
|
+
export const profileSetLabel = (set) => {
|
|
275
|
+
const names = set.profiles().join(", ");
|
|
276
|
+
if (set.option === EVERY_PROFILE)
|
|
277
|
+
return `every profile (${names || "none yet"})`;
|
|
278
|
+
return set.profiles().length === 1 ? `profile ${names}` : `profiles ${names}`;
|
|
279
|
+
};
|
|
216
280
|
/**
|
|
217
|
-
* `<tool> mcp serve
|
|
218
|
-
* `<tool> <profile> mcp serve`
|
|
219
|
-
*
|
|
281
|
+
* `<tool> mcp serve --profiles <names|all>`: those profiles from one server, each call in that profile's own
|
|
282
|
+
* `<tool> <profile> mcp serve` (`src/mcp/all-profiles.ts`). Nothing picks a profile: each tool takes one, and the
|
|
283
|
+
* agent is told to ask.
|
|
220
284
|
*/
|
|
221
|
-
const
|
|
285
|
+
const serveProfiles = async (self, context, messenger, flags, set) => {
|
|
222
286
|
const { app } = messenger;
|
|
223
287
|
const { env } = context;
|
|
224
|
-
const tokenFile = httpTokenFile(app,
|
|
288
|
+
const tokenFile = httpTokenFile(app, set.key, env);
|
|
225
289
|
const state = (profile) => messenger.profileState?.(profile, env) ?? "signed-in";
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
290
|
+
const bots = set.option === EVERY_PROFILE ? [] : set.profiles().filter((profile) => state(profile) === "bot");
|
|
291
|
+
if (bots.length > 0)
|
|
292
|
+
throw new CliError("validation_error", `${bots.join(", ")} ${bots.length === 1 ? "is a bot profile" : "are bot profiles"}, served by \`${app.command} <profile> bot mcp serve\``);
|
|
293
|
+
// `all` reads the profiles again for each session, so one set up while the server runs appears.
|
|
294
|
+
const profiles = () => set.profiles().filter((profile) => state(profile) !== "bot");
|
|
230
295
|
if (profiles().length === 0)
|
|
231
|
-
throw new CliError("configuration_error", `no ${app.command} profile on this computer yet — set one up, then run \`${app.command} mcp serve\` again`);
|
|
296
|
+
throw new CliError("configuration_error", `no ${app.command} profile on this computer yet — set one up, then run \`${app.command} mcp serve --profiles all\` again`);
|
|
232
297
|
const given = environmentOf(self).mcp ?? {};
|
|
233
298
|
const execPath = given.execPath ?? process.execPath;
|
|
234
299
|
const scriptPath = given.scriptPath ?? realpathSync(process.argv[1] ?? "");
|
|
@@ -268,13 +333,13 @@ const serveEveryProfile = async (self, context, messenger, flags) => {
|
|
|
268
333
|
version: app.version,
|
|
269
334
|
onCode: (code, expires) => context.renderer.note(env[mcpHttpMarker(app.envPrefix)] === undefined
|
|
270
335
|
? `login code for a new browser app: ${code} (until ${expires.toTimeString().slice(0, 5)}; a new one after each login)`
|
|
271
|
-
: `a new login code for a browser app: \`${app.command} mcp server status\``),
|
|
336
|
+
: `a new login code for a browser app: \`${app.command} mcp server status --profiles ${set.option}\``),
|
|
272
337
|
}).catch((error) => {
|
|
273
338
|
if (error.code === "EADDRINUSE")
|
|
274
339
|
throw new CliError("configuration_error", `port ${port} is in use — pass another with --port`);
|
|
275
340
|
throw error;
|
|
276
341
|
});
|
|
277
|
-
context.renderer.note(`serving
|
|
342
|
+
context.renderer.note(`serving ${profileSetLabel(set)} on ${listening.url.href} — point your tunnel at it; connectors use ${mcpUrlOf(publicUrl).href}`);
|
|
278
343
|
await new Promise((resolve) => {
|
|
279
344
|
process.once("SIGINT", resolve);
|
|
280
345
|
process.once("SIGTERM", resolve);
|
|
@@ -283,6 +348,7 @@ const serveEveryProfile = async (self, context, messenger, flags) => {
|
|
|
283
348
|
return;
|
|
284
349
|
}
|
|
285
350
|
const { serveAllProfilesOverStdio } = await import("../../mcp/all-profiles.js");
|
|
351
|
+
context.renderer.note(`serving ${profileSetLabel(set)} over MCP on stdin and stdout`);
|
|
286
352
|
const { stdin, stdout, signal } = environmentOf(self);
|
|
287
353
|
await serveAllProfilesOverStdio(options, {
|
|
288
354
|
...(stdin ? { stdin: stdin } : {}),
|
|
@@ -331,6 +397,14 @@ export const redactTail = (text, home) => {
|
|
|
331
397
|
const lines = out.split("\n").filter((line) => line.trim() !== "");
|
|
332
398
|
return lines.slice(-TAIL_LINES).join("\n").slice(-TAIL_BYTES);
|
|
333
399
|
};
|
|
400
|
+
/** `tg-work`; `tg` for every profile; `tg-personal-work` for a list, a name every client accepts. */
|
|
401
|
+
const serverName = (app, profile, profiles) => {
|
|
402
|
+
if (profile !== undefined)
|
|
403
|
+
return `${app.command}-${profile}`;
|
|
404
|
+
if (profiles === EVERY_PROFILE)
|
|
405
|
+
return app.command;
|
|
406
|
+
return `${app.command}-${(profiles ?? "").split(",").join("-")}`;
|
|
407
|
+
};
|
|
334
408
|
const VERSION_MANAGER = /[\\/](\.nvm|nvm|\.fnm|fnm|fnm_multishells|\.volta|volta|\.asdf|mise)[\\/]/i;
|
|
335
409
|
/**
|
|
336
410
|
* `node` and the script by full path, on every platform: a client started from the desktop does
|
|
@@ -340,10 +414,13 @@ const VERSION_MANAGER = /[\\/](\.nvm|nvm|\.fnm|fnm|fnm_multishells|\.volta|volta
|
|
|
340
414
|
* store. So is `XDG_RUNTIME_DIR`: the keyring is reached through it, and an MCP client that starts
|
|
341
415
|
* servers with a trimmed environment leaves it out (measured 2026-09-28). Credentials are never copied.
|
|
342
416
|
*/
|
|
343
|
-
export const serverEntry = (app, { profile, flags = {}, execPath, scriptPath, env, }) => {
|
|
417
|
+
export const serverEntry = (app, { profile, profiles, flags = {}, execPath, scriptPath, env, }) => {
|
|
418
|
+
if ((profile === undefined) === (profiles === undefined))
|
|
419
|
+
throw new CliError("validation_error", "an MCP entry serves one profile or the profiles --profiles names");
|
|
420
|
+
const words = profile === undefined ? `mcp config --profiles ${profiles}` : `${profile} mcp config`;
|
|
344
421
|
if (installerOf(scriptPath) === "npx") {
|
|
345
422
|
throw new CliError("validation_error", `this ${app.command} runs from npx's cache, which gets cleared, and the path would stop working — ` +
|
|
346
|
-
`install it globally, then run \`${app.command} ${
|
|
423
|
+
`install it globally, then run \`${app.command} ${words}\` again`);
|
|
347
424
|
}
|
|
348
425
|
const names = [
|
|
349
426
|
...["CONFIG_DIR", "STATE_DIR", "CACHE_DIR"].map((name) => envName(app, name)),
|
|
@@ -360,12 +437,13 @@ export const serverEntry = (app, { profile, flags = {}, execPath, scriptPath, en
|
|
|
360
437
|
...(profile === undefined ? [] : [profile]),
|
|
361
438
|
"mcp",
|
|
362
439
|
"serve",
|
|
440
|
+
...(profiles === undefined ? [] : ["--profiles", profiles]),
|
|
363
441
|
...(flags.permission ?? []).flatMap((entry) => ["--permission", entry]),
|
|
364
442
|
],
|
|
365
443
|
...(Object.keys(directories).length > 0 ? { env: directories } : {}),
|
|
366
444
|
};
|
|
367
445
|
return {
|
|
368
|
-
config: { mcpServers: { [
|
|
446
|
+
config: { mcpServers: { [serverName(app, profile, profiles)]: server } },
|
|
369
447
|
/** The path belongs to one Node version of a version manager; switching Node breaks the entry. */
|
|
370
448
|
nodeBound: VERSION_MANAGER.test(execPath),
|
|
371
449
|
};
|
package/dist/cli/steps.js
CHANGED
|
@@ -189,7 +189,7 @@ export const STEPS = defineSteps([
|
|
|
189
189
|
{
|
|
190
190
|
id: "mcp.config",
|
|
191
191
|
command: "mcp config",
|
|
192
|
-
args: [],
|
|
192
|
+
args: [{ name: "profiles", required: false }],
|
|
193
193
|
why: "the entry names one Node version's path; after switching or upgrading Node, print it again",
|
|
194
194
|
},
|
|
195
195
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.470.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
|
@@ -802,6 +802,10 @@
|
|
|
802
802
|
"value": "<who>",
|
|
803
803
|
"meaning": "who sees the profile photo: everyone, contacts or nobody"
|
|
804
804
|
},
|
|
805
|
+
"--profiles": {
|
|
806
|
+
"value": "<names|all>",
|
|
807
|
+
"meaning": "the profiles one MCP server serves, comma-separated, or all of them"
|
|
808
|
+
},
|
|
805
809
|
"--public-url": {
|
|
806
810
|
"value": "<url>",
|
|
807
811
|
"meaning": "the tunnel's https address: the browser apps reach mcp serve --http through it, a messenger delivers to bot watch"
|
|
@@ -3160,6 +3164,7 @@
|
|
|
3160
3164
|
"in": "all",
|
|
3161
3165
|
"options": {
|
|
3162
3166
|
"--permission": "all",
|
|
3167
|
+
"--profiles": "all",
|
|
3163
3168
|
"--yes": {
|
|
3164
3169
|
"in": [],
|
|
3165
3170
|
"planned": {
|
|
@@ -3171,7 +3176,10 @@
|
|
|
3171
3176
|
}
|
|
3172
3177
|
},
|
|
3173
3178
|
"mcp revoke": {
|
|
3174
|
-
"in": "all"
|
|
3179
|
+
"in": "all",
|
|
3180
|
+
"options": {
|
|
3181
|
+
"--profiles": "all"
|
|
3182
|
+
}
|
|
3175
3183
|
},
|
|
3176
3184
|
"mcp serve": {
|
|
3177
3185
|
"in": "all",
|
|
@@ -3179,6 +3187,7 @@
|
|
|
3179
3187
|
"--http": "all",
|
|
3180
3188
|
"--permission": "all",
|
|
3181
3189
|
"--port": "all",
|
|
3190
|
+
"--profiles": "all",
|
|
3182
3191
|
"--public-url": "all",
|
|
3183
3192
|
"--yes": {
|
|
3184
3193
|
"in": [],
|
|
@@ -3198,14 +3207,21 @@
|
|
|
3198
3207
|
"options": {
|
|
3199
3208
|
"--permission": "all",
|
|
3200
3209
|
"--port": "all",
|
|
3210
|
+
"--profiles": "all",
|
|
3201
3211
|
"--public-url": "all"
|
|
3202
3212
|
}
|
|
3203
3213
|
},
|
|
3204
3214
|
"mcp server status": {
|
|
3205
|
-
"in": "all"
|
|
3215
|
+
"in": "all",
|
|
3216
|
+
"options": {
|
|
3217
|
+
"--profiles": "all"
|
|
3218
|
+
}
|
|
3206
3219
|
},
|
|
3207
3220
|
"mcp server stop": {
|
|
3208
|
-
"in": "all"
|
|
3221
|
+
"in": "all",
|
|
3222
|
+
"options": {
|
|
3223
|
+
"--profiles": "all"
|
|
3224
|
+
}
|
|
3209
3225
|
},
|
|
3210
3226
|
"mentions": {
|
|
3211
3227
|
"in": "all"
|