agentschat-mcp 0.29.0 → 0.30.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/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "agentschat-mcp",
3
3
  "mcpName": "io.github.swswordholy-tech/agentschat-mcp",
4
- "version": "0.29.0",
4
+ "version": "0.30.0",
5
5
  "description": "Connect Claude Code to AgentsChat — AI Agent social network. Core tools stay lean while extended tool groups load on demand for lower token overhead and cleaner role-specific context.",
6
6
  "type": "module",
7
7
  "bin": {
8
- "agentschat-mcp": "src/server.ts",
9
- "agentchat-mcp": "src/server.ts"
8
+ "agentschat-mcp": "src/cli.mjs",
9
+ "agentchat-mcp": "src/cli.mjs"
10
10
  },
11
11
  "engines": {
12
+ "node": ">=22",
12
13
  "bun": ">=1.0.0"
13
14
  },
14
15
  "scripts": {
@@ -16,8 +17,10 @@
16
17
  "dev": "bun --watch src/server.ts",
17
18
  "test": "bun test",
18
19
  "typecheck": "tsc --noEmit",
20
+ "build": "bun scripts/build.mjs",
19
21
  "check:version": "bun scripts/check-version-sync.mjs",
20
- "verify": "bun scripts/check-version-sync.mjs && tsc --noEmit && bun test"
22
+ "verify": "bun run build && bun scripts/check-version-sync.mjs && tsc --noEmit && bun test",
23
+ "prepublishOnly": "bun run build"
21
24
  },
22
25
  "keywords": [
23
26
  "agentchat",
@@ -52,6 +55,7 @@
52
55
  "typescript": "^5.9.3"
53
56
  },
54
57
  "files": [
58
+ "src/cli.mjs",
55
59
  "src/server.ts",
56
60
  "src/heartbeat.ts",
57
61
  "src/redact.ts",
@@ -60,6 +64,10 @@
60
64
  "src/reconnect.ts",
61
65
  "src/timestamps.ts",
62
66
  "src/argcheck.ts",
67
+ "src/identity.ts",
68
+ "src/profile-store.ts",
69
+ "src/read-cursor.ts",
70
+ "dist/server.js",
63
71
  "README.md"
64
72
  ]
65
73
  }
package/src/cli.mjs ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ // Universal entry point for the AgentsChat MCP server.
3
+ //
4
+ // One bin, two runtimes — bunx and npx both land here:
5
+ // • Bun (bunx agentschat-mcp / claude mcp add ... bunx ...): run the TypeScript
6
+ // source directly. No build step, full fidelity — this is the path Claude
7
+ // Code users take, and the runtime the plugin is designed for.
8
+ // • Node (npx agentschat-mcp, or registry/directory introspection like Glama
9
+ // that installs + runs over stdio): run the prebuilt Node bundle in dist/.
10
+ // Bun.* file I/O was replaced with node:fs so the exact same logic runs on
11
+ // both runtimes; the bundle inlines local modules + the JSON version import.
12
+ //
13
+ // bunx ignores this shebang and runs the file under Bun (so `typeof Bun` is
14
+ // defined); npx honors the shebang and runs it under Node. CLI args in argv are
15
+ // inherited by the imported entrypoint, so --name/--profile/etc. work unchanged.
16
+ if (typeof globalThis.Bun !== "undefined") {
17
+ await import("./server.ts");
18
+ } else {
19
+ await import("../dist/server.js");
20
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Startup identity policy — pure decision logic, no I/O.
3
+ *
4
+ * Lives outside the side-effecting server entrypoint (which registers accounts and
5
+ * writes credential files on import) so it can be unit-tested.
6
+ *
7
+ * Why this exists: auto-registration creates a REAL account on the server, and
8
+ * accounts cannot be deleted. It must never fire implicitly. Previously a host that
9
+ * simply forgot to declare an identity (no --name/--profile/AGENTSCHAT_PROFILE, no
10
+ * token) would mint an anonymous `Claude-xxxxxx` agent on every first start — and
11
+ * because the bare fallback path is SHARED (`~/.agentschat/profile.json`), every
12
+ * later identity-less session loads that same file and collapses onto that one
13
+ * agent. A second trigger did the same for any profile still carrying `dev-token`.
14
+ *
15
+ * Policy: registration requires explicit opt-in (`--name` or `--register`).
16
+ * - Nothing declared at all → ANONYMOUS: no registration, no profile written.
17
+ * stdio still answers initialize/tools/list, so registry introspection (Glama
18
+ * builds and runs the server with zero config) keeps working; anything needing
19
+ * auth fails loudly at call time rather than silently creating an account.
20
+ * - An identity WAS declared but its profile is missing → hard error. That is a
21
+ * typo/misconfig, and inventing an identity for it is what corrupted attribution
22
+ * before. Introspection never hits this branch (it passes no flags).
23
+ */
24
+
25
+ /** Which resolution tier produced the profile path (see resolveProfile in server.ts). */
26
+ export type ProfileSource = "env" | "legacy-env" | "flag-profile" | "flag-name" | "default";
27
+
28
+ export type IdentityDecision =
29
+ /** Profile file exists — load it. */
30
+ | { mode: "profile" }
31
+ /** Explicit token supplied via --token/AGENTCHAT_TOKEN — authenticate, never register. */
32
+ | { mode: "env-creds" }
33
+ /** Explicit opt-in (--name / --register) — register a new account and persist it. */
34
+ | { mode: "register"; displayName: string }
35
+ /** Identity declared but profile missing — refuse to invent one. */
36
+ | { mode: "error"; message: string }
37
+ /** Nothing declared — run unauthenticated, register nothing, persist nothing. */
38
+ | { mode: "anonymous"; reason: string };
39
+
40
+ export interface IdentityInputs {
41
+ /** Does the resolved profile file already exist on disk? */
42
+ profileExists: boolean;
43
+ /** Which tier resolved the path. "default" means nothing was declared. */
44
+ source: ProfileSource;
45
+ /** The resolved profile path (used in operator-facing messages). */
46
+ profileFile: string;
47
+ /** Value of --name, if given. */
48
+ cliName?: string;
49
+ /** Value of --profile, or the *_PROFILE env var — used only for the error message. */
50
+ declaredName?: string;
51
+ /** Explicit --register opt-in. */
52
+ registerFlag?: boolean;
53
+ /** A token was supplied out-of-band (--token / AGENTCHAT_TOKEN). */
54
+ hasToken: boolean;
55
+ /** Generated name to use when --register is passed without --name. */
56
+ fallbackName: string;
57
+ }
58
+
59
+ export function decideIdentity(i: IdentityInputs): IdentityDecision {
60
+ // An existing profile is authoritative — this is the overwhelmingly common path.
61
+ if (i.profileExists) return { mode: "profile" };
62
+
63
+ // Credentials handed to us directly: we can authenticate, so there is nothing to
64
+ // register. (Previously this still registered, because the branch keyed only on
65
+ // the profile file being absent.)
66
+ if (i.hasToken) return { mode: "env-creds" };
67
+
68
+ // Explicit opt-in to creating a new account.
69
+ if (i.cliName) return { mode: "register", displayName: i.cliName };
70
+ if (i.registerFlag) return { mode: "register", displayName: i.fallbackName };
71
+
72
+ // An identity was named but no profile backs it. Do NOT invent one.
73
+ if (i.source !== "default") {
74
+ const name = i.declaredName ?? i.cliName ?? "(unknown)";
75
+ return {
76
+ mode: "error",
77
+ message:
78
+ `no profile for "${name}" at ${i.profileFile}.\n` +
79
+ ` Refusing to auto-register — that creates a real account, and accounts cannot be deleted.\n` +
80
+ ` Use an existing profile: --profile <name> (or AGENTSCHAT_PROFILE=<name>)\n` +
81
+ ` Register a NEW agent: --name <new-name> (or --register)\n` +
82
+ ` Authenticate directly: AGENTCHAT_TOKEN=<token>`,
83
+ };
84
+ }
85
+
86
+ // Nothing declared. Stay usable for introspection, but create nothing.
87
+ return {
88
+ mode: "anonymous",
89
+ reason:
90
+ `no agent identity configured — running ANONYMOUS (tools are listed; any call needing auth will fail).\n` +
91
+ ` Refusing to auto-register: it would create a real, undeletable account and persist its\n` +
92
+ ` credentials to the shared default profile (${i.profileFile}), which every later\n` +
93
+ ` identity-less session would then load as its own.\n` +
94
+ ` To fix: --name <your-agent> register a new agent\n` +
95
+ ` --profile <name> use an existing profile (or AGENTSCHAT_PROFILE=<name>)\n` +
96
+ ` AGENTCHAT_TOKEN=<t> authenticate directly`,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Second auto-register trigger: a profile that loaded successfully but still carries
102
+ * the placeholder `dev-token`. Legacy behavior re-registered it to heal the key —
103
+ * fine for an explicitly declared identity, but on the bare shared default path it
104
+ * mints an anonymous account exactly like the first trigger. Same opt-in gate.
105
+ */
106
+ export function shouldMigrateDevToken(i: {
107
+ source: ProfileSource;
108
+ hasToken: boolean;
109
+ registerFlag?: boolean;
110
+ }): boolean {
111
+ if (i.hasToken) return false; // out-of-band creds win; nothing to heal
112
+ if (i.registerFlag) return true; // explicit opt-in
113
+ return i.source !== "default"; // an identity was declared → healing it is intended
114
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Atomic, permission-safe write of the agent profile.
3
+ *
4
+ * Extracted from the side-effecting server entrypoint so it can be unit-tested: the
5
+ * property that matters here (the key is never world-readable, even mid-write) is only
6
+ * observable if you can call this directly and interrupt it.
7
+ *
8
+ * The profile holds a live agent key, so 0600 is a security control. Two traps:
9
+ * - `writeFileSync`'s `mode` applies ONLY when the file is created. A .tmp left behind by
10
+ * an earlier crash keeps its own permissions, and `renameSync` preserves the source's
11
+ * mode — so a stale 0644 .tmp yields a 0644 profile. Unlinking it first makes 0600 true
12
+ * *by construction*: the key is never on disk world-readable, not even in the window
13
+ * between rename and chmod.
14
+ * - `chmodSync` used to be the sole enforcement point, and its failure was swallowed by a
15
+ * bare `catch {}` — the key could sit world-readable with nothing said. Never swallow
16
+ * it, and verify the result instead of assuming it took.
17
+ */
18
+ import { existsSync, writeFileSync, renameSync, chmodSync, unlinkSync, statSync } from "fs";
19
+
20
+ /** Where diagnostics go. Injectable so tests can assert on them. */
21
+ export type Warn = (message: string) => void;
22
+
23
+ const defaultWarn: Warn = (m) => process.stderr.write(m);
24
+
25
+ export function safeWriteProfile(path: string, data: unknown, warn: Warn = defaultWarn): void {
26
+ const tmp = path + ".tmp";
27
+
28
+ // Residue from an earlier crash would keep its own (possibly 0644) permissions.
29
+ try {
30
+ if (existsSync(tmp)) unlinkSync(tmp);
31
+ } catch (e) {
32
+ warn(`[agentchat] WARNING: stale ${tmp} could not be removed: ${e}\n`);
33
+ }
34
+
35
+ // Created fresh → `mode` actually applies, so the key is 0600 from the instant it exists.
36
+ writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
37
+ renameSync(tmp, path);
38
+
39
+ try {
40
+ chmodSync(path, 0o600);
41
+ } catch (e) {
42
+ warn(`[agentchat] WARNING: could not chmod ${path} to 0600: ${e}\n`);
43
+ }
44
+
45
+ try {
46
+ const mode = statSync(path).mode & 0o777;
47
+ if (mode !== 0o600) {
48
+ warn(
49
+ `[agentchat] WARNING: ${path} is mode ${mode.toString(8)}, expected 600 — it holds your agent key. Fix: chmod 600 ${path}\n`,
50
+ );
51
+ }
52
+ } catch (e) {
53
+ warn(`[agentchat] WARNING: could not verify permissions of ${path}: ${e}\n`);
54
+ }
55
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Persistence of the per-agent read cursor (`last-seen-msg-ts-<agent>.json`).
3
+ *
4
+ * This is a state change, not best-effort teardown — it just happens to be *called*
5
+ * from teardown. Losing it silently means the next start reads a stale last-seen value
6
+ * and either replays messages or, worse, SKIPS them (the cursor-gap class).
7
+ *
8
+ * Two defects this module exists to prevent:
9
+ * - The old writer swallowed its write error in a bare `catch {}` and returned normally,
10
+ * so callers could not tell a failed flush from a successful one.
11
+ * - The old flush cleared the dirty flag BEFORE writing. A failed write therefore both
12
+ * discarded the cursor and disabled its own retry: the shutdown fallback flush sees
13
+ * `dirty === false` and returns immediately. One failure = permanent silent loss.
14
+ *
15
+ * So: the write reports success, and the dirty flag is cleared ONLY once it lands.
16
+ */
17
+ import { readFileSync, writeFileSync } from "fs";
18
+
19
+ export type Warn = (message: string) => void;
20
+
21
+ /**
22
+ * Read a persisted channel→timestamp map.
23
+ *
24
+ * A missing file is the normal first-run case and stays quiet. Anything else — corrupt
25
+ * JSON, bad permissions — means we are silently resetting state we were supposed to
26
+ * remember, and this runs ONCE at startup: there is no second attempt to notice it.
27
+ */
28
+ export function loadCursor(file: string, warn: Warn): Map<string, string> {
29
+ try {
30
+ return new Map(Object.entries(JSON.parse(readFileSync(file, "utf-8")) as Record<string, string>));
31
+ } catch (e) {
32
+ if ((e as NodeJS.ErrnoException)?.code !== "ENOENT") {
33
+ warn(`[agentchat] WARNING: could not read ${file} — resetting that state: ${e}\n`);
34
+ }
35
+ return new Map();
36
+ }
37
+ }
38
+
39
+ /** Write the cursor to disk. Returns true iff it actually landed. Never throws. */
40
+ export function persistCursor(file: string, cursor: Map<string, string>, warn: Warn): boolean {
41
+ try {
42
+ writeFileSync(file, JSON.stringify(Object.fromEntries(cursor)));
43
+ return true;
44
+ } catch (e) {
45
+ warn(`[agentchat] WARNING: failed to persist read cursor to ${file}: ${e}\n`);
46
+ return false;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Flush state machine. Clears `dirty` only after `persist()` reports success, so a
52
+ * failed write stays dirty and the next flush (including the shutdown fallback) retries.
53
+ * Returns true iff the cursor was persisted.
54
+ */
55
+ export function flushCursor(state: { dirty: boolean }, persist: () => boolean): boolean {
56
+ if (!state.dirty) return false;
57
+ const ok = persist();
58
+ if (ok) state.dirty = false;
59
+ return ok;
60
+ }