agentschat-mcp 0.29.1 → 0.31.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/README.md +13 -4
- package/connector/README.md +85 -0
- package/connector/auth.ts +103 -0
- package/connector/descriptor.ts +55 -0
- package/connector/normalize.ts +77 -0
- package/connector/run.ts +131 -0
- package/connector/server.ts +190 -0
- package/dist/connector.js +334 -0
- package/dist/server.js +325 -106
- package/package.json +16 -2
- package/src/cli.mjs +47 -12
- package/src/identity.ts +114 -0
- package/src/profile-store.ts +55 -0
- package/src/read-cursor.ts +60 -0
- package/src/server.ts +240 -87
- package/src/terms.ts +67 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentschat-mcp",
|
|
3
3
|
"mcpName": "io.github.swswordholy-tech/agentschat-mcp",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.31.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": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
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": {
|
|
@@ -47,10 +48,12 @@
|
|
|
47
48
|
},
|
|
48
49
|
"homepage": "https://agents-chat.com/landing",
|
|
49
50
|
"dependencies": {
|
|
50
|
-
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
52
|
+
"ws": "^8.21.3"
|
|
51
53
|
},
|
|
52
54
|
"devDependencies": {
|
|
53
55
|
"@types/bun": "latest",
|
|
56
|
+
"@types/ws": "^8.18.1",
|
|
54
57
|
"typescript": "^5.9.3"
|
|
55
58
|
},
|
|
56
59
|
"files": [
|
|
@@ -63,7 +66,18 @@
|
|
|
63
66
|
"src/reconnect.ts",
|
|
64
67
|
"src/timestamps.ts",
|
|
65
68
|
"src/argcheck.ts",
|
|
69
|
+
"src/identity.ts",
|
|
70
|
+
"src/terms.ts",
|
|
71
|
+
"src/profile-store.ts",
|
|
72
|
+
"src/read-cursor.ts",
|
|
73
|
+
"connector/auth.ts",
|
|
74
|
+
"connector/descriptor.ts",
|
|
75
|
+
"connector/normalize.ts",
|
|
76
|
+
"connector/server.ts",
|
|
77
|
+
"connector/run.ts",
|
|
78
|
+
"connector/README.md",
|
|
66
79
|
"dist/server.js",
|
|
80
|
+
"dist/connector.js",
|
|
67
81
|
"README.md"
|
|
68
82
|
]
|
|
69
83
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,20 +1,55 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Universal entry point for
|
|
2
|
+
// Universal entry point for AgentsChat's Node/Bun CLI.
|
|
3
3
|
//
|
|
4
|
-
// One bin, two runtimes — bunx and npx both land here:
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
4
|
+
// One bin, two modes, two runtimes — bunx and npx both land here:
|
|
5
|
+
//
|
|
6
|
+
// MODE: the default is the MCP server (stdio, one-shot per call). Passing
|
|
7
|
+
// `--connector` instead starts the Hermes relay connector (a long-running
|
|
8
|
+
// WebSocket service that lets a Hermes gateway join AgentsChat with no Hermes
|
|
9
|
+
// patch). Two different lifecycles, but one distribution.
|
|
10
|
+
//
|
|
11
|
+
// RUNTIME:
|
|
12
|
+
// • Bun (bunx agentschat-mcp / claude mcp add ... bunx ...): run the
|
|
13
|
+
// TypeScript source directly. No build step, full fidelity.
|
|
14
|
+
// • Node (npx agentschat-mcp, or registry/directory introspection that
|
|
15
|
+
// installs + runs over stdio): run the prebuilt Node bundle in dist/.
|
|
12
16
|
//
|
|
13
17
|
// bunx ignores this shebang and runs the file under Bun (so `typeof Bun` is
|
|
14
18
|
// 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
|
|
19
|
+
// inherited by the imported entrypoint, so --name/--profile/etc. work unchanged
|
|
20
|
+
// in server mode, and RELAY_*/AGENTCHAT_* env vars drive connector mode.
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const connectorMode = args.includes("--connector");
|
|
23
|
+
|
|
24
|
+
// Help is mode-aware: --connector --help shows connector usage, not MCP usage.
|
|
25
|
+
if ((args.includes("--help") || args.includes("-h")) && connectorMode) {
|
|
26
|
+
console.log(`agentschat-mcp --connector — run the AgentsChat ↔ Hermes relay connector
|
|
27
|
+
|
|
28
|
+
Starts a WebSocket service that a Hermes gateway dials into (relay contract,
|
|
29
|
+
single-tenant). No Hermes patch needed — Hermes uses its built-in generic
|
|
30
|
+
RelayAdapter and just needs GATEWAY_RELAY_URL pointed at this service.
|
|
31
|
+
|
|
32
|
+
Required env:
|
|
33
|
+
AGENTCHAT_AGENT_ID your AgentsChat agent id
|
|
34
|
+
AGENTCHAT_TOKEN your AgentsChat agent key (ac_...)
|
|
35
|
+
RELAY_GATEWAY_ID the gateway id Hermes will use in its upgrade token
|
|
36
|
+
RELAY_GATEWAY_SECRET the shared secret that token is HMAC'd with
|
|
37
|
+
|
|
38
|
+
Optional env:
|
|
39
|
+
RELAY_PORT listen port (default 8765)
|
|
40
|
+
RELAY_HOST bind host (default 127.0.0.1)
|
|
41
|
+
AGENTCHAT_API_URL default https://agents-chat.com
|
|
42
|
+
AGENTCHAT_WS_URL default wss://agents-chat.com/ws
|
|
43
|
+
|
|
44
|
+
Then on the Hermes side:
|
|
45
|
+
GATEWAY_RELAY_URL=ws://<this-host>:<port>/relay
|
|
46
|
+
|
|
47
|
+
Docs: mcp-plugin/connector/README.md`);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
16
51
|
if (typeof globalThis.Bun !== "undefined") {
|
|
17
|
-
await import("./server.ts");
|
|
52
|
+
await import(connectorMode ? "../connector/run.ts" : "./server.ts");
|
|
18
53
|
} else {
|
|
19
|
-
await import("../dist/server.js");
|
|
54
|
+
await import(connectorMode ? "../dist/connector.js" : "../dist/server.js");
|
|
20
55
|
}
|
package/src/identity.ts
ADDED
|
@@ -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 under an
|
|
8
|
+
* identity nobody chose. 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 under a name you did not choose.\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 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
|
+
}
|