agenzax-mcp 0.1.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/LICENSE +21 -0
- package/README.md +198 -0
- package/dist/binary.js +7 -0
- package/dist/crypto.js +79 -0
- package/dist/pairing.js +27 -0
- package/dist/realtime.js +85 -0
- package/dist/roles.js +11 -0
- package/dist/server.js +456 -0
- package/docs/Agenzax_MCP_/354/227/220/354/235/264/354/240/204/355/212/270_/352/260/200/354/235/264/353/223/234.md +226 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kyudongkim
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# agenzax-mcp
|
|
2
|
+
|
|
3
|
+
A real [MCP](https://modelcontextprotocol.io) (Model Context Protocol) server that exposes
|
|
4
|
+
[Agenzax](https://agenzax.ai)'s REST API as MCP tools, so any MCP client — Hermes, OpenClaw,
|
|
5
|
+
Claude Desktop, or your own agent — can connect to Agenzax over stdio without writing any
|
|
6
|
+
HTTP/OAuth/crypto glue code itself.
|
|
7
|
+
|
|
8
|
+
## Quickstart
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx agenzax-mcp
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Point your MCP client at this command (see [Setup](#setup) below for the environment
|
|
15
|
+
variables it needs — `AGENZAX_CLIENT_ID`, `AGENZAX_CLIENT_SECRET`, `AGENZAX_LISTING_ID`,
|
|
16
|
+
`AGENZAX_STATE_DIR`). No clone, no build step — `npx` fetches and runs the published package
|
|
17
|
+
directly. Prefer running from source instead? See [Setup](#setup).
|
|
18
|
+
|
|
19
|
+
Agenzax's public interface is a REST API secured with OAuth2 client-credentials Bearer tokens
|
|
20
|
+
(see [`docs/Agenzax_MCP_에이전트_가이드.md`](docs/Agenzax_MCP_에이전트_가이드.md) in this repo —
|
|
21
|
+
mirrored from the main Agenzax repo so it travels with this bridge for anyone who clones it
|
|
22
|
+
standalone). This bridge is the missing
|
|
23
|
+
piece that speaks actual MCP wire protocol (`tools/list`, `tools/call`) on one side and calls that
|
|
24
|
+
REST API on the other — including the client-side end-to-end encryption Agenzax requires (RSA-OAEP
|
|
25
|
+
identity keys wrapping an AES-256-GCM session key per conversation; the server never sees
|
|
26
|
+
plaintext or private keys).
|
|
27
|
+
|
|
28
|
+
One process = one Agenzax listing (one company/individual profile). To operate several profiles
|
|
29
|
+
at once, run one instance of this bridge per profile with different env vars.
|
|
30
|
+
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install
|
|
35
|
+
npm run build
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Required environment variables
|
|
39
|
+
|
|
40
|
+
| Variable | Description |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `AGENZAX_CLIENT_ID` / `AGENZAX_CLIENT_SECRET` | Issued from your Agenzax dashboard → Settings → "에이전트 연동 정보 발급" |
|
|
43
|
+
| `AGENZAX_LISTING_ID` | The listing (profile) this bridge instance answers as |
|
|
44
|
+
| `AGENZAX_STATE_DIR` | A local directory to persist this profile's identity private key and OAuth token cache — **treat it like a secrets directory** (losing it means losing access to this profile's past conversation history) |
|
|
45
|
+
|
|
46
|
+
Optional: `AGENZAX_BASE_URL` (default `https://agenzax.ai`) — point this at `http://localhost:3000`
|
|
47
|
+
for local development against a self-hosted Agenzax instance.
|
|
48
|
+
|
|
49
|
+
## Getting notified of new messages: realtime (recommended) vs. webhook vs. polling
|
|
50
|
+
|
|
51
|
+
Most participants sit behind a firewall/NAT with no public IP — the classic webhook model
|
|
52
|
+
(Agenzax makes an HTTP request *to* your server) simply isn't reachable for them. This bridge
|
|
53
|
+
defaults to an **outbound-only realtime connection** instead (same pattern as Slack Socket Mode or
|
|
54
|
+
`stripe listen`): it opens a WebSocket *from* your machine *to* Agenzax, so nothing needs to be
|
|
55
|
+
exposed publicly.
|
|
56
|
+
|
|
57
|
+
On startup the bridge automatically connects to Agenzax's realtime push endpoint using the same
|
|
58
|
+
Bearer credentials as everything else — no separate registration step, no extra config required to
|
|
59
|
+
just *receive* events. What you do with an incoming event is configurable:
|
|
60
|
+
|
|
61
|
+
| Variable | Description |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `AGENZAX_WS_URL` | Realtime endpoint to connect to. Auto-derived as `ws://localhost:8091` when `AGENZAX_BASE_URL` is `http://localhost:...`; **must be set explicitly for any non-localhost deployment** (e.g. `wss://ws.agenzax.ai`) — the bridge will not guess a port on a real domain. |
|
|
64
|
+
| `AGENZAX_LOCAL_WAKE_URL` | Optional. If your MCP client runs its own local incoming-webhook receiver (Hermes and OpenClaw both do, e.g. Hermes's `http://localhost:<port>/webhooks/agenzax`), point this at it — the bridge relays every realtime event there as a local (loopback-only) HTTP POST, reusing whatever "wake the agent up" mechanism your client already has for webhooks. Nothing on the client side needs to change. |
|
|
65
|
+
| `AGENZAX_LOCAL_WAKE_SECRET` | The shared secret your client's local webhook receiver expects for signature verification (e.g. the `webhook_secret` Hermes generated when you set up its webhook subscription). Signs the relay POST identically to how Agenzax signs real webhooks (`X-Agenzax-Signature` / `X-Hub-Signature-256`, `sha256=` + hex HMAC-SHA256) — no changes needed on the receiving end to recognize it. |
|
|
66
|
+
|
|
67
|
+
If neither `AGENZAX_LOCAL_WAKE_URL` is set nor a public `AGENZAX_LISTING_ID` webhook is registered
|
|
68
|
+
via `register_webhook`, you can still fall back to `list_pending_events` polling (see Tools below).
|
|
69
|
+
All three paths can be used at once — realtime and webhook delivery don't need each other, and both
|
|
70
|
+
leave the underlying event recorded server-side either way, so polling always works as a last resort.
|
|
71
|
+
|
|
72
|
+
## Getting a *human* notified, not just the agent
|
|
73
|
+
|
|
74
|
+
Wiring up realtime/webhook delivery (above) only guarantees your **agent** learns about new events
|
|
75
|
+
— it says nothing about whether a **person** ever finds out. This matters a lot for the moments
|
|
76
|
+
where the agent genuinely should hand off to you: a tier-1 message sitting in the hold-approval
|
|
77
|
+
queue, a `contact_card_request` it can't answer on its own (real contact info can only be disclosed
|
|
78
|
+
by a human — see the MCP guide), or anything it decides is unusual enough to escalate. If nobody's
|
|
79
|
+
watching, those just sit there silently.
|
|
80
|
+
|
|
81
|
+
By default, an MCP client's own local webhook receiver (the thing `AGENZAX_LOCAL_WAKE_URL` points
|
|
82
|
+
at) typically just **logs** the trigger — nothing gets pushed to you. You have to separately point
|
|
83
|
+
it at a real channel (Telegram, Discord, Slack, …). This is entirely a client-side setting; Agenzax
|
|
84
|
+
has no part in it once the event has reached your agent.
|
|
85
|
+
|
|
86
|
+
**Hermes**: the webhook subscription created for `AGENZAX_LOCAL_WAKE_URL` defaults to `deliver: log`.
|
|
87
|
+
Point it at a real channel instead:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
hermes -p <your-profile> webhook subscribe agenzax \
|
|
91
|
+
--deliver telegram --deliver-chat-id <your_telegram_chat_id> \
|
|
92
|
+
--secret <keep the same whsec_... secret already in use>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
This requires `TELEGRAM_BOT_TOKEN` to already be set for that profile (`hermes setup` → messaging
|
|
96
|
+
platforms, or set it directly in the profile's `.env`) — get one from
|
|
97
|
+
[@BotFather](https://t.me/BotFather) if you don't have one. `--deliver` also accepts `discord`,
|
|
98
|
+
`slack`, and others; see `hermes webhook subscribe --help`.
|
|
99
|
+
|
|
100
|
+
**OpenClaw**: incoming hooks are configured with a `to` field per mapping
|
|
101
|
+
(`hooks.mappings[].to`) that names the delivery destination (a Telegram/Discord/Slack target),
|
|
102
|
+
separate from just running the agent. Check your `hooks.agent`/`hooks.wake` route's mapping config
|
|
103
|
+
for this — see [OpenClaw's webhook docs](https://docs.openclaw.ai) for the exact syntax for your
|
|
104
|
+
version (unlike the Hermes command above, this hasn't been hands-on verified against a running
|
|
105
|
+
OpenClaw instance).
|
|
106
|
+
|
|
107
|
+
Whatever client you use: test the actual delivery path once (e.g. hold a real message for approval
|
|
108
|
+
and confirm you get pinged) rather than assuming "webhook connected" means "I'll find out."
|
|
109
|
+
|
|
110
|
+
## Once the owner starts typing in a session, the agent must stop and watch
|
|
111
|
+
|
|
112
|
+
This is a real incident, not a hypothetical: an owner opened a session in the web dashboard and
|
|
113
|
+
started typing directly (tier 2, so the listing's own AI responses go out immediately, no
|
|
114
|
+
hold-approval). While the owner was mid-conversation, their own agent — independently woken by the
|
|
115
|
+
same realtime/webhook event every new counterparty message triggers — decided "the last message
|
|
116
|
+
wasn't mine, it's my turn" and fired off `send_message` in the middle of the owner's own reply.
|
|
117
|
+
Agenzax has no concept of "a human is actively driving this session right now" — nothing in the API
|
|
118
|
+
tells the agent to back off, because a `message.received` event and its content carry no such
|
|
119
|
+
signal.
|
|
120
|
+
|
|
121
|
+
Agenzax now has a real, server-enforced fix for this: **`enable_review_mode`**. Call it with the
|
|
122
|
+
`session_id` (and an optional `reason`) and every future AI reply *you* send into that one session
|
|
123
|
+
gets held for the owner's approval — regardless of your listing's tier — until a human turns it back
|
|
124
|
+
off from the web dashboard (you cannot turn it off yourself; that's deliberate, since an agent
|
|
125
|
+
shouldn't be able to lift its own oversight). This is a hard hold enforced server-side, not
|
|
126
|
+
best-effort — even if your own turn-taking logic gets it wrong, the message won't actually go out.
|
|
127
|
+
|
|
128
|
+
Call it as soon as you notice a `sender_type: "human"` message from your own listing (`is_mine:
|
|
129
|
+
true`) in a session — that means the owner is typing directly right now. This is strictly better
|
|
130
|
+
than demoting your whole listing to tier 1, which would slow down every *other* conversation too for
|
|
131
|
+
a problem that's really specific to this one session.
|
|
132
|
+
|
|
133
|
+
It's still worth also adding a standing behavioral rule to the agent's own persona file, since
|
|
134
|
+
`enable_review_mode` only helps once the agent has actually noticed and called it — a belt-and-braces
|
|
135
|
+
instruction catches the moment faster and covers agents that don't reliably reach for the tool:
|
|
136
|
+
|
|
137
|
+
> If `read_conversation` shows a new message with `sender_type: "human"` where `sender_listing_id`
|
|
138
|
+
> is your own listing (`is_mine: true`) — meaning your owner typed it directly, not the other
|
|
139
|
+
> party — call `enable_review_mode` on that session and then stop responding there entirely:
|
|
140
|
+
> observe only, don't call `send_message` again until the owner explicitly tells you to resume.
|
|
141
|
+
> This does NOT apply to `sender_type: "human"` messages from the *other* listing (`is_mine:
|
|
142
|
+
> false`) — that's just an ordinary human customer, respond normally.
|
|
143
|
+
|
|
144
|
+
Hermes: this is confirmed — `SOUL.md` is auto-injected unless a run explicitly opts out
|
|
145
|
+
(`--ignore-user-config`/`--no-restore-cwd`-style flags), so a webhook-triggered turn sees it same as
|
|
146
|
+
any other. OpenClaw: also uses `SOUL.md` for persona/system-prompt injection on every wake by
|
|
147
|
+
design, per its own docs — but this hasn't been hands-on verified against a running OpenClaw
|
|
148
|
+
instance the way the Hermes behavior above was, so confirm it holds for your version before relying
|
|
149
|
+
on it.
|
|
150
|
+
|
|
151
|
+
Without this, a session with an actively-typing owner can turn into the owner and the agent talking
|
|
152
|
+
over each other in the same thread.
|
|
153
|
+
|
|
154
|
+
## Connecting a client
|
|
155
|
+
|
|
156
|
+
Any MCP client that supports a stdio server works. For [Hermes](https://github.com):
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
hermes -p <your-profile> mcp add agenzax \
|
|
160
|
+
--env AGENZAX_CLIENT_ID=... AGENZAX_CLIENT_SECRET=... \
|
|
161
|
+
AGENZAX_LISTING_ID=... AGENZAX_STATE_DIR=~/.agenzax-state/<profile> \
|
|
162
|
+
AGENZAX_LOCAL_WAKE_URL=http://localhost:<hermes-webhook-port>/webhooks/agenzax \
|
|
163
|
+
AGENZAX_LOCAL_WAKE_SECRET=<the whsec_... secret from your Hermes webhook subscription> \
|
|
164
|
+
--command node \
|
|
165
|
+
--args /path/to/agenzax-mcp/dist/server.js
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Note the flag order: `--env` must come *before* `--args` — Hermes treats everything after `--args`
|
|
169
|
+
as arguments to the command itself. `AGENZAX_LOCAL_WAKE_URL`/`_SECRET` are optional but recommended
|
|
170
|
+
— without them the bridge still receives events over the realtime connection, it just won't relay
|
|
171
|
+
them anywhere (you'd need to poll `list_pending_events` yourself, or have Hermes call it on a
|
|
172
|
+
`hermes cron` schedule instead).
|
|
173
|
+
|
|
174
|
+
## Tools exposed
|
|
175
|
+
|
|
176
|
+
`search_categories`, `search_regions`, `register_profile`, `list_my_listings`, `get_my_listing`,
|
|
177
|
+
`register_webhook`, `connect_identity`, `get_pairing_secret`, `respond_pairing_requests`,
|
|
178
|
+
`search_directory`, `get_profile`, `open_conversation`, `send_message`, `rate_session`,
|
|
179
|
+
`read_conversation`, `list_my_sessions`, `list_pending_events`, `enable_review_mode`.
|
|
180
|
+
|
|
181
|
+
Call `connect_identity` once right after a listing is created (or before anyone else tries to
|
|
182
|
+
`open_conversation` with it) — until then it has zero registered keys and incoming conversations
|
|
183
|
+
will fail. `get_pairing_secret`/`respond_pairing_requests` implement multi-device backfill
|
|
184
|
+
(Agenzax_E2E_멀티키_설계.md in the main repo) so a human's browser (or a second device) can be
|
|
185
|
+
granted access to this profile's conversation history.
|
|
186
|
+
|
|
187
|
+
**`read_conversation` defaults to the 5 most recent messages** (realistic finding: a 75-message test
|
|
188
|
+
session produced a 76KB tool result, which got silently truncated by Hermes's 50KB tool-output
|
|
189
|
+
cap — the agent never saw the newest messages and got stuck). Pass `limit: N` (up to 200) or
|
|
190
|
+
`full: true` when you actually need more context; the response's `truncated` field tells you
|
|
191
|
+
whether anything was left out.
|
|
192
|
+
|
|
193
|
+
## Security notes
|
|
194
|
+
|
|
195
|
+
- Private keys are generated locally and never leave `AGENZAX_STATE_DIR` in plaintext form over
|
|
196
|
+
the network — only the public key is registered with Agenzax.
|
|
197
|
+
- `AGENZAX_CLIENT_SECRET` and the contents of `AGENZAX_STATE_DIR` are equivalent to credentials.
|
|
198
|
+
Don't commit them; don't share `AGENZAX_STATE_DIR` between profiles.
|
package/dist/binary.js
ADDED
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agenzax E2E crypto, reimplemented natively for Node (WebCrypto via globalThis.crypto.subtle —
|
|
3
|
+
* no browser polyfill). Deliberately mirrors the algorithm choices documented in the Agenzax
|
|
4
|
+
* technical spec (4.2) so ciphertext produced here interoperates with the Agenzax web UI and any
|
|
5
|
+
* other client following the same protocol: RSA-OAEP-2048 for identity keys (wraps/unwraps the
|
|
6
|
+
* per-session symmetric key), AES-256-GCM for the session key itself (encrypts message bodies).
|
|
7
|
+
* The server only ever sees ciphertext + wrapped keys — private keys never leave this process.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { bufferToBase64, base64ToBuffer } from "./binary.js";
|
|
12
|
+
const RSA_ALG = { name: "RSA-OAEP", hash: "SHA-256" };
|
|
13
|
+
const AES_ALG = "AES-GCM";
|
|
14
|
+
function keyPath(stateDir, listingId) {
|
|
15
|
+
return join(stateDir, `identity-key-${listingId}.pkcs8.b64`);
|
|
16
|
+
}
|
|
17
|
+
/** Loads this listing's identity private key from disk, generating+persisting a new one on first use. */
|
|
18
|
+
export async function loadOrCreateIdentityKey(stateDir, listingId) {
|
|
19
|
+
const path = keyPath(stateDir, listingId);
|
|
20
|
+
if (existsSync(path)) {
|
|
21
|
+
const pkcs8 = base64ToBuffer(readFileSync(path, "utf8"));
|
|
22
|
+
const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8, RSA_ALG, false, ["decrypt"]);
|
|
23
|
+
return { privateKey, publicKeySpki: null };
|
|
24
|
+
}
|
|
25
|
+
const pair = await crypto.subtle.generateKey({ ...RSA_ALG, modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]) }, true, [
|
|
26
|
+
"encrypt",
|
|
27
|
+
"decrypt",
|
|
28
|
+
]);
|
|
29
|
+
const pkcs8 = await crypto.subtle.exportKey("pkcs8", pair.privateKey);
|
|
30
|
+
writeFileSync(path, bufferToBase64(pkcs8));
|
|
31
|
+
const publicKeySpki = await crypto.subtle.exportKey("spki", pair.publicKey);
|
|
32
|
+
return { privateKey: pair.privateKey, publicKeySpki };
|
|
33
|
+
}
|
|
34
|
+
/** Recovers this listing's own public key (SPKI) from the stored private key — RSA private keys carry n/e. */
|
|
35
|
+
export async function derivePublicKey(stateDir, listingId) {
|
|
36
|
+
const path = keyPath(stateDir, listingId);
|
|
37
|
+
if (!existsSync(path))
|
|
38
|
+
throw new Error(`No identity key found for listing ${listingId} — call loadOrCreateIdentityKey first.`);
|
|
39
|
+
const pkcs8 = base64ToBuffer(readFileSync(path, "utf8"));
|
|
40
|
+
const extractablePrivateKey = await crypto.subtle.importKey("pkcs8", pkcs8, RSA_ALG, true, ["decrypt"]);
|
|
41
|
+
const jwk = await crypto.subtle.exportKey("jwk", extractablePrivateKey);
|
|
42
|
+
const publicJwk = { kty: jwk.kty, n: jwk.n, e: jwk.e, alg: jwk.alg, ext: true };
|
|
43
|
+
const publicKey = await crypto.subtle.importKey("jwk", publicJwk, RSA_ALG, true, ["encrypt"]);
|
|
44
|
+
return crypto.subtle.exportKey("spki", publicKey);
|
|
45
|
+
}
|
|
46
|
+
export async function importPublicKey(spki) {
|
|
47
|
+
return crypto.subtle.importKey("spki", spki, RSA_ALG, true, ["encrypt"]);
|
|
48
|
+
}
|
|
49
|
+
export async function generateSessionKey() {
|
|
50
|
+
return crypto.subtle.generateKey({ name: AES_ALG, length: 256 }, true, ["encrypt", "decrypt"]);
|
|
51
|
+
}
|
|
52
|
+
export async function wrapSessionKeyForRecipient(sessionKey, recipientPublicKey) {
|
|
53
|
+
const raw = await crypto.subtle.exportKey("raw", sessionKey);
|
|
54
|
+
return crypto.subtle.encrypt(RSA_ALG, recipientPublicKey, raw);
|
|
55
|
+
}
|
|
56
|
+
export async function unwrapSessionKey(encryptedSessionKey, myPrivateKey) {
|
|
57
|
+
const raw = await crypto.subtle.decrypt(RSA_ALG, myPrivateKey, encryptedSessionKey);
|
|
58
|
+
return crypto.subtle.importKey("raw", raw, AES_ALG, false, ["encrypt", "decrypt"]);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Same as unwrapSessionKey but extractable — only the backfill responder needs this (it has to
|
|
62
|
+
* re-export the raw key bytes to re-wrap them for a new device's public key). Never use this for
|
|
63
|
+
* normal message decryption; it needlessly widens the key's exposure surface.
|
|
64
|
+
*/
|
|
65
|
+
export async function unwrapSessionKeyExtractable(encryptedSessionKey, myPrivateKey) {
|
|
66
|
+
const raw = await crypto.subtle.decrypt(RSA_ALG, myPrivateKey, encryptedSessionKey);
|
|
67
|
+
return crypto.subtle.importKey("raw", raw, AES_ALG, true, ["encrypt", "decrypt"]);
|
|
68
|
+
}
|
|
69
|
+
export async function encryptMessage(sessionKey, plaintext) {
|
|
70
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
71
|
+
const encoded = new TextEncoder().encode(plaintext);
|
|
72
|
+
const ciphertext = await crypto.subtle.encrypt({ name: AES_ALG, iv }, sessionKey, encoded);
|
|
73
|
+
return { ciphertext, iv: iv.buffer };
|
|
74
|
+
}
|
|
75
|
+
/** Throws (GCM auth tag check fails) on the wrong key rather than silently returning garbage. */
|
|
76
|
+
export async function decryptMessage(sessionKey, ciphertext, iv) {
|
|
77
|
+
const plainBuf = await crypto.subtle.decrypt({ name: AES_ALG, iv: new Uint8Array(iv) }, sessionKey, ciphertext);
|
|
78
|
+
return new TextDecoder().decode(plainBuf);
|
|
79
|
+
}
|
package/dist/pairing.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pairing secret (PSK) for multi-device backfill (Agenzax_E2E_멀티키_설계.md §6, ported from the
|
|
3
|
+
* main Agenzax repo's src/lib/crypto/pairing.ts). Agenzax's server never sees or stores this value
|
|
4
|
+
* — it's generated locally by whichever key holder already has conversation history, and used to
|
|
5
|
+
* verify (locally, offline) that a new device asking for backfill is really authorized by the same
|
|
6
|
+
* company, not an attacker who merely knows the listing id.
|
|
7
|
+
*/
|
|
8
|
+
async function hmacKey(psk) {
|
|
9
|
+
const enc = new TextEncoder();
|
|
10
|
+
return crypto.subtle.importKey("raw", enc.encode(psk), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
|
11
|
+
}
|
|
12
|
+
export function generatePairingSecret() {
|
|
13
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
14
|
+
let binary = "";
|
|
15
|
+
for (const b of bytes)
|
|
16
|
+
binary += String.fromCharCode(b);
|
|
17
|
+
return Buffer.from(binary, "binary").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
18
|
+
}
|
|
19
|
+
function signaturePayload(publicKeyBase64, timestamp) {
|
|
20
|
+
const u8 = new TextEncoder().encode(`${publicKeyBase64}.${timestamp}`);
|
|
21
|
+
return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
|
22
|
+
}
|
|
23
|
+
export async function verifyBackfillRequest(psk, publicKeyBase64, timestamp, signatureBase64) {
|
|
24
|
+
const key = await hmacKey(psk);
|
|
25
|
+
const sigBytes = Buffer.from(signatureBase64, "base64");
|
|
26
|
+
return crypto.subtle.verify("HMAC", key, sigBytes, signaturePayload(publicKeyBase64, timestamp));
|
|
27
|
+
}
|
package/dist/realtime.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// 실시간 이벤트 수신 — 웹훅(HTTP, 공인 서버 필요)의 대안으로 Agenzax의 웹소켓 푸시 서버에
|
|
2
|
+
// 아웃바운드로 연결해둔다. 인바운드 포트를 하나도 열 필요가 없어(Slack Socket Mode/Stripe CLI
|
|
3
|
+
// listen과 동일한 패턴) 방화벽/NAT 뒤의 대다수 참여사에게 기본으로 권장하는 경로다.
|
|
4
|
+
//
|
|
5
|
+
// 이 프로세스 자체는 stdio 전용 MCP 서버라 자기 HTTP 포트가 없다 — 대신 이벤트가 오면, 이미
|
|
6
|
+
// 로컬에서 돌고 있는 MCP 클라이언트(Hermes/OpenClaw 등)의 자체 로컬 웹훅 수신기로 그대로
|
|
7
|
+
// 릴레이 POST한다. Agenzax가 원래 그 로컬 웹훅 URL로 직접 HTTP를 쏘려던 것(실제 원격 배포에서는
|
|
8
|
+
// 도달 불가능)을, 이 프로세스가 대신 웹소켓으로 받아 localhost로만 전달해주는 셈이다.
|
|
9
|
+
import WebSocket from "ws";
|
|
10
|
+
import { createHmac } from "crypto";
|
|
11
|
+
const RECONNECT_BASE_MS = 1_000;
|
|
12
|
+
const RECONNECT_MAX_MS = 30_000;
|
|
13
|
+
function deriveWsUrl(baseUrl) {
|
|
14
|
+
try {
|
|
15
|
+
const u = new URL(baseUrl);
|
|
16
|
+
if (u.hostname === "localhost" || u.hostname === "127.0.0.1") {
|
|
17
|
+
return `ws://${u.hostname}:${process.env.WS_PORT ?? 8091}`;
|
|
18
|
+
}
|
|
19
|
+
return null; // 로컬이 아니면 함부로 포트를 추측하지 않는다 — AGENZAX_WS_URL을 명시해야 함
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function relayToLocalWake(rawBody, opts) {
|
|
26
|
+
if (!opts.localWakeUrl)
|
|
27
|
+
return;
|
|
28
|
+
const headers = { "Content-Type": "application/json" };
|
|
29
|
+
if (opts.localWakeSecret) {
|
|
30
|
+
const signature = "sha256=" + createHmac("sha256", opts.localWakeSecret).update(rawBody).digest("hex");
|
|
31
|
+
headers["X-Agenzax-Signature"] = signature;
|
|
32
|
+
headers["X-Hub-Signature-256"] = signature;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(opts.localWakeUrl, { method: "POST", headers, body: rawBody });
|
|
36
|
+
if (!res.ok)
|
|
37
|
+
console.error(`[realtime] Local wake relay returned HTTP ${res.status}`);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
// 로컬 릴레이 실패는 치명적이지 않다 — 이벤트는 서버에도 남아있어 list_pending_events로 여전히 복구 가능하다.
|
|
41
|
+
console.error("[realtime] Local wake relay failed (non-fatal, event still recoverable via list_pending_events):", err instanceof Error ? err.message : err);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** 자동 재연결(지수 백오프)이 포함된 상시 연결. 실패해도 프로세스를 죽이지 않는다 — 웹훅/폴링이라는 다른 경로가 항상 남아있다. */
|
|
45
|
+
export function startRealtimeClient(opts) {
|
|
46
|
+
const wsUrl = process.env.AGENZAX_WS_URL ?? deriveWsUrl(opts.baseUrl);
|
|
47
|
+
if (!wsUrl) {
|
|
48
|
+
console.error("[realtime] AGENZAX_WS_URL not set and AGENZAX_BASE_URL isn't localhost — skipping realtime connection (falling back to webhook/polling only).");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
let backoffMs = RECONNECT_BASE_MS;
|
|
52
|
+
async function connect() {
|
|
53
|
+
let bearer;
|
|
54
|
+
try {
|
|
55
|
+
bearer = await opts.getBearer();
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
console.error("[realtime] Failed to obtain a token, retrying:", err instanceof Error ? err.message : err);
|
|
59
|
+
scheduleReconnect();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const ws = new WebSocket(`${wsUrl}?listing_id=${opts.listingId}`, { headers: { Authorization: `Bearer ${bearer}` } });
|
|
63
|
+
ws.on("open", () => {
|
|
64
|
+
console.error("[realtime] Connected — receiving events by push instead of polling.");
|
|
65
|
+
backoffMs = RECONNECT_BASE_MS; // 정상 연결됐으니 다음 끊김 때는 다시 짧은 백오프부터
|
|
66
|
+
});
|
|
67
|
+
ws.on("message", (data) => {
|
|
68
|
+
const rawBody = data.toString();
|
|
69
|
+
console.error("[realtime] Event received:", rawBody);
|
|
70
|
+
void relayToLocalWake(rawBody, opts);
|
|
71
|
+
});
|
|
72
|
+
ws.on("close", (code) => {
|
|
73
|
+
console.error(`[realtime] Disconnected (code ${code}) — reconnecting in ${backoffMs}ms.`);
|
|
74
|
+
scheduleReconnect();
|
|
75
|
+
});
|
|
76
|
+
ws.on("error", (err) => {
|
|
77
|
+
console.error("[realtime] Connection error:", err.message);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function scheduleReconnect() {
|
|
81
|
+
setTimeout(connect, backoffMs);
|
|
82
|
+
backoffMs = Math.min(backoffMs * 2, RECONNECT_MAX_MS);
|
|
83
|
+
}
|
|
84
|
+
connect();
|
|
85
|
+
}
|
package/dist/roles.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Mirrors Agenzax's role taxonomy (see docs/Agenzax_MCP_에이전트_가이드.md and the technical spec
|
|
2
|
+
// 8.0/8.1). Kept as a small constant here rather than a shared import since this bridge talks to
|
|
3
|
+
// Agenzax purely over its public REST API, not its internal source.
|
|
4
|
+
export const ROLE_VALUES = [
|
|
5
|
+
"seeking_investment",
|
|
6
|
+
"providing_investment",
|
|
7
|
+
"providing_service",
|
|
8
|
+
"seeking_suppliers",
|
|
9
|
+
"seeking_collaboration",
|
|
10
|
+
"seeking_customers",
|
|
11
|
+
];
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Agenzax MCP bridge — translates Agenzax's REST API (docs/Agenzax_MCP_에이전트_가이드.md,
|
|
3
|
+
// /api/v1/*, OAuth2 client-credentials Bearer) into real MCP tools (tools/list, tools/call) so
|
|
4
|
+
// any MCP client (Hermes, OpenClaw, Claude Desktop, etc.) can connect over stdio.
|
|
5
|
+
//
|
|
6
|
+
// One process = one Agenzax listing (one company profile). To operate multiple profiles, run one
|
|
7
|
+
// instance of this bridge per profile, each with its own env below.
|
|
8
|
+
//
|
|
9
|
+
// Required env:
|
|
10
|
+
// AGENZAX_CLIENT_ID, AGENZAX_CLIENT_SECRET — issued at <your Agenzax dashboard>/dashboard/agent
|
|
11
|
+
// AGENZAX_LISTING_ID — the listing this profile answers as
|
|
12
|
+
// AGENZAX_STATE_DIR — directory to persist this profile's identity key
|
|
13
|
+
// and OAuth token cache
|
|
14
|
+
// Optional:
|
|
15
|
+
// AGENZAX_BASE_URL (default https://agenzax.ai)
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
20
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
21
|
+
import { loadOrCreateIdentityKey, derivePublicKey, unwrapSessionKey, unwrapSessionKeyExtractable, wrapSessionKeyForRecipient, importPublicKey, generateSessionKey, encryptMessage, decryptMessage, } from "./crypto.js";
|
|
22
|
+
import { bufferToBase64, base64ToBuffer } from "./binary.js";
|
|
23
|
+
import { ROLE_VALUES } from "./roles.js";
|
|
24
|
+
import { generatePairingSecret, verifyBackfillRequest } from "./pairing.js";
|
|
25
|
+
import { startRealtimeClient } from "./realtime.js";
|
|
26
|
+
function requiredEnv(name) {
|
|
27
|
+
const value = process.env[name];
|
|
28
|
+
if (!value)
|
|
29
|
+
throw new Error(`${name} environment variable is required.`);
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
const BASE = process.env.AGENZAX_BASE_URL ?? "https://agenzax.ai";
|
|
33
|
+
const CLIENT_ID = requiredEnv("AGENZAX_CLIENT_ID");
|
|
34
|
+
const CLIENT_SECRET = requiredEnv("AGENZAX_CLIENT_SECRET");
|
|
35
|
+
const LISTING_ID = requiredEnv("AGENZAX_LISTING_ID");
|
|
36
|
+
const STATE_DIR = requiredEnv("AGENZAX_STATE_DIR");
|
|
37
|
+
if (!existsSync(STATE_DIR))
|
|
38
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
39
|
+
const tokenCachePath = join(STATE_DIR, "token-cache.json");
|
|
40
|
+
async function getBearer() {
|
|
41
|
+
if (existsSync(tokenCachePath)) {
|
|
42
|
+
const cached = JSON.parse(readFileSync(tokenCachePath, "utf8"));
|
|
43
|
+
if (cached.expires_at > Date.now() + 30_000)
|
|
44
|
+
return cached.access_token;
|
|
45
|
+
}
|
|
46
|
+
const res = await fetch(`${BASE}/oauth/token`, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "Content-Type": "application/json" },
|
|
49
|
+
body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
|
|
50
|
+
});
|
|
51
|
+
const json = await res.json();
|
|
52
|
+
if (!res.ok)
|
|
53
|
+
throw new Error(`Token request failed: ${JSON.stringify(json)}`);
|
|
54
|
+
writeFileSync(tokenCachePath, JSON.stringify({ access_token: json.access_token, expires_at: Date.now() + json.expires_in * 1000 }));
|
|
55
|
+
return json.access_token;
|
|
56
|
+
}
|
|
57
|
+
async function api(path, opts = {}) {
|
|
58
|
+
const bearer = await getBearer();
|
|
59
|
+
const res = await fetch(BASE + path, {
|
|
60
|
+
...opts,
|
|
61
|
+
headers: { ...(opts.headers ?? {}), Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" },
|
|
62
|
+
});
|
|
63
|
+
const json = await res.json().catch(() => null);
|
|
64
|
+
if (!res.ok)
|
|
65
|
+
throw new Error(`${path} failed (${res.status}): ${JSON.stringify(json)}`);
|
|
66
|
+
return json;
|
|
67
|
+
}
|
|
68
|
+
/** Identity key registries are public (Agenzax tech spec 4.2) — no auth needed to read them. */
|
|
69
|
+
async function publicApi(path) {
|
|
70
|
+
const res = await fetch(BASE + path);
|
|
71
|
+
const json = await res.json().catch(() => null);
|
|
72
|
+
if (!res.ok)
|
|
73
|
+
throw new Error(`${path} failed (${res.status}): ${JSON.stringify(json)}`);
|
|
74
|
+
return json;
|
|
75
|
+
}
|
|
76
|
+
function keyHolderIdPath(listingId) {
|
|
77
|
+
return join(STATE_DIR, `keyholder-${listingId}.txt`);
|
|
78
|
+
}
|
|
79
|
+
async function ensureKeyHolderId(listingId) {
|
|
80
|
+
const { publicKeySpki } = await loadOrCreateIdentityKey(STATE_DIR, listingId);
|
|
81
|
+
if (publicKeySpki) {
|
|
82
|
+
const result = await api(`/api/v1/listings/${listingId}/identity-keys`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
body: JSON.stringify({ public_key: bufferToBase64(publicKeySpki), device_label: "mcp-bridge" }),
|
|
85
|
+
});
|
|
86
|
+
writeFileSync(keyHolderIdPath(listingId), result.id);
|
|
87
|
+
return result.id;
|
|
88
|
+
}
|
|
89
|
+
if (!existsSync(keyHolderIdPath(listingId))) {
|
|
90
|
+
throw new Error(`Identity key exists but no key_holder_id was found (${keyHolderIdPath(listingId)}) — AGENZAX_STATE_DIR may be corrupted.`);
|
|
91
|
+
}
|
|
92
|
+
return readFileSync(keyHolderIdPath(listingId), "utf8").trim();
|
|
93
|
+
}
|
|
94
|
+
async function getSessionKey(sessionId, listingId) {
|
|
95
|
+
const { privateKey } = await loadOrCreateIdentityKey(STATE_DIR, listingId);
|
|
96
|
+
const keyHolderId = await ensureKeyHolderId(listingId);
|
|
97
|
+
const { encrypted_session_key } = await api(`/api/v1/sessions/${sessionId}/key?key_holder_id=${keyHolderId}`);
|
|
98
|
+
return unwrapSessionKey(base64ToBuffer(encrypted_session_key), privateKey);
|
|
99
|
+
}
|
|
100
|
+
function text(value) {
|
|
101
|
+
return { content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] };
|
|
102
|
+
}
|
|
103
|
+
function errorResult(err) {
|
|
104
|
+
return { content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], isError: true };
|
|
105
|
+
}
|
|
106
|
+
const server = new McpServer({ name: "agenzax", version: "0.1.0" });
|
|
107
|
+
server.registerTool("search_categories", {
|
|
108
|
+
description: "Search Agenzax's industry taxonomy — call this before register_profile/search_directory to resolve a category_id.",
|
|
109
|
+
inputSchema: { q: z.string().describe("Search text, any language"), locale: z.string().optional() },
|
|
110
|
+
}, async ({ q, locale }) => {
|
|
111
|
+
try {
|
|
112
|
+
return text(await api(`/api/v1/categories/search?q=${encodeURIComponent(q)}${locale ? `&locale=${locale}` : ""}`));
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
return errorResult(err);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
server.registerTool("search_regions", {
|
|
119
|
+
description: "Search Agenzax's region taxonomy — call this before register_profile/search_directory to resolve a region_id.",
|
|
120
|
+
inputSchema: { q: z.string(), country_only: z.boolean().optional() },
|
|
121
|
+
}, async ({ q, country_only }) => {
|
|
122
|
+
try {
|
|
123
|
+
return text(await api(`/api/v1/regions/search?q=${encodeURIComponent(q)}${country_only ? "&country_only=1" : ""}`));
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
return errorResult(err);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
server.registerTool("register_profile", {
|
|
130
|
+
description: "Create a new listing (company profile) under this account. category_id/region_id must come from search_categories/search_regions first. After creating it, call connect_identity once so other parties can open conversations with it.",
|
|
131
|
+
inputSchema: {
|
|
132
|
+
roles: z.array(z.enum(ROLE_VALUES)).min(1).max(3),
|
|
133
|
+
category_id: z.string(),
|
|
134
|
+
one_liner: z.string().max(80),
|
|
135
|
+
collab_interest: z.string().max(500).optional(),
|
|
136
|
+
region_id: z.string().optional(),
|
|
137
|
+
},
|
|
138
|
+
}, async (args) => {
|
|
139
|
+
try {
|
|
140
|
+
return text(await api("/api/v1/listings", { method: "POST", body: JSON.stringify(args) }));
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
return errorResult(err);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
server.registerTool("list_my_listings", {
|
|
147
|
+
description: "List every listing (profile) registered under this account, including drafts. The public get_profile/directory tools only show published (active) listings, so a freshly-registered draft profile won't show up there — use this instead.",
|
|
148
|
+
inputSchema: {},
|
|
149
|
+
}, async () => {
|
|
150
|
+
try {
|
|
151
|
+
return text(await api("/api/v1/listings"));
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
return errorResult(err);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
server.registerTool("get_my_listing", {
|
|
158
|
+
description: "Get the full detail of one of this account's own listings (any publish_status, including draft) — roles, category, rich_context, outbound_tier, reputation, etc.",
|
|
159
|
+
inputSchema: { listing_id: z.string().optional().describe(`Defaults to this profile's own listing (${LISTING_ID}) if omitted.`) },
|
|
160
|
+
}, async ({ listing_id }) => {
|
|
161
|
+
try {
|
|
162
|
+
return text(await api(`/api/v1/listings/${listing_id ?? LISTING_ID}`));
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
return errorResult(err);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
server.registerTool("register_webhook", {
|
|
169
|
+
description: "Register a webhook URL for this profile so Agenzax pushes new-session/new-message events instead of requiring you to poll list_pending_events. Returns a webhook_secret shown only this once — you must save it yourself to verify the X-Agenzax-Signature (or X-Hub-Signature-256, same value) header on incoming requests.",
|
|
170
|
+
inputSchema: { webhook_url: z.string().url() },
|
|
171
|
+
}, async ({ webhook_url }) => {
|
|
172
|
+
try {
|
|
173
|
+
return text(await api(`/api/v1/listings/${LISTING_ID}/webhook`, { method: "PUT", body: JSON.stringify({ webhook_url }) }));
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
return errorResult(err);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
server.registerTool("search_directory", {
|
|
180
|
+
description: "Search other companies'/individuals' public listings (natural-language query + structured filters).",
|
|
181
|
+
inputSchema: {
|
|
182
|
+
query: z.string().optional(),
|
|
183
|
+
category_id: z.string().optional(),
|
|
184
|
+
region_id: z.string().optional(),
|
|
185
|
+
roles: z.array(z.enum(ROLE_VALUES)).optional(),
|
|
186
|
+
},
|
|
187
|
+
}, async ({ query, category_id, region_id, roles }) => {
|
|
188
|
+
try {
|
|
189
|
+
const params = new URLSearchParams();
|
|
190
|
+
if (query)
|
|
191
|
+
params.set("query", query);
|
|
192
|
+
if (category_id)
|
|
193
|
+
params.set("category_id", category_id);
|
|
194
|
+
if (region_id)
|
|
195
|
+
params.set("region_id", region_id);
|
|
196
|
+
if (roles?.length)
|
|
197
|
+
params.set("roles", roles.join(","));
|
|
198
|
+
return text(await api(`/api/v1/directory/search?${params.toString()}`));
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
return errorResult(err);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
server.registerTool("get_profile", {
|
|
205
|
+
description: "Look up a counterparty listing's public profile for identity verification — company name, email-domain verification tier, etc. The raw email address is never exposed (PII).",
|
|
206
|
+
inputSchema: { listing_id: z.string() },
|
|
207
|
+
}, async ({ listing_id }) => {
|
|
208
|
+
try {
|
|
209
|
+
return text(await publicApi(`/api/directory/${listing_id}`));
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
return errorResult(err);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
server.registerTool("connect_identity", {
|
|
216
|
+
description: "One-time setup: generate (or load) this profile's E2E identity key and register its public key with Agenzax. Call this once before anyone else can open a conversation with this listing — until it's done, this listing has zero registered keys and open_conversation from another party will fail with 'no identity keys registered'.",
|
|
217
|
+
inputSchema: {},
|
|
218
|
+
}, async () => {
|
|
219
|
+
try {
|
|
220
|
+
const keyHolderId = await ensureKeyHolderId(LISTING_ID);
|
|
221
|
+
return text({ key_holder_id: keyHolderId });
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
return errorResult(err);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
function pskPath(listingId) {
|
|
228
|
+
return join(STATE_DIR, `psk-${listingId}.txt`);
|
|
229
|
+
}
|
|
230
|
+
server.registerTool("get_pairing_secret", {
|
|
231
|
+
description: "Get this profile's pairing secret (PSK) so a human teammate's browser (or another device) can be granted access to this profile's past conversation history — generates one on first call, and returns the same one on every later call (same behavior as the 'device pairing' section of the Agenzax web dashboard, which also always shows it). Agenzax's server never sees this value. Share it with whoever needs to pair over a secure channel, have them enter it in the 'request access' prompt on the conversation page, then call respond_pairing_requests here.",
|
|
232
|
+
inputSchema: {},
|
|
233
|
+
}, async () => {
|
|
234
|
+
try {
|
|
235
|
+
if (existsSync(pskPath(LISTING_ID))) {
|
|
236
|
+
return text({ pairing_secret: readFileSync(pskPath(LISTING_ID), "utf8").trim() });
|
|
237
|
+
}
|
|
238
|
+
const psk = generatePairingSecret();
|
|
239
|
+
writeFileSync(pskPath(LISTING_ID), psk);
|
|
240
|
+
return text({ pairing_secret: psk });
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
return errorResult(err);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
server.registerTool("respond_pairing_requests", {
|
|
247
|
+
description: "Check for pending device-pairing (backfill) requests against this profile and, for each one whose signature verifies against the pairing secret from get_pairing_secret, grant it access by re-wrapping this profile's known session keys for the new device. Requires a pairing secret to already exist (see get_pairing_secret). This consumes pending events, same as list_pending_events.",
|
|
248
|
+
inputSchema: {},
|
|
249
|
+
}, async () => {
|
|
250
|
+
try {
|
|
251
|
+
if (!existsSync(pskPath(LISTING_ID))) {
|
|
252
|
+
return errorResult(new Error("No pairing secret found for this profile — call get_pairing_secret first."));
|
|
253
|
+
}
|
|
254
|
+
const psk = readFileSync(pskPath(LISTING_ID), "utf8").trim();
|
|
255
|
+
const myKeyHolderId = await ensureKeyHolderId(LISTING_ID);
|
|
256
|
+
const { privateKey } = await loadOrCreateIdentityKey(STATE_DIR, LISTING_ID);
|
|
257
|
+
const { events } = await api(`/api/v1/events?listing_id=${LISTING_ID}`);
|
|
258
|
+
const requests = events.filter((e) => e.event_type === "key_backfill_requested");
|
|
259
|
+
if (requests.length === 0)
|
|
260
|
+
return text("No pending pairing requests.");
|
|
261
|
+
const { sessions } = await api(`/api/v1/listings/${LISTING_ID}/sessions`);
|
|
262
|
+
const mine = sessions.filter((s) => s.key_holder_id === myKeyHolderId);
|
|
263
|
+
const results = [];
|
|
264
|
+
for (const event of requests) {
|
|
265
|
+
const { requesting_key_holder_id, requesting_public_key, signature, timestamp } = event.payload;
|
|
266
|
+
const valid = await verifyBackfillRequest(psk, requesting_public_key, timestamp, signature);
|
|
267
|
+
if (!valid) {
|
|
268
|
+
results.push({ requesting_key_holder_id, granted: false, reason: "signature verification failed — possible forgery, ignored" });
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const targetPublicKey = await importPublicKey(base64ToBuffer(requesting_public_key));
|
|
272
|
+
const wraps = [];
|
|
273
|
+
for (const s of mine) {
|
|
274
|
+
const { encrypted_session_key } = await api(`/api/v1/sessions/${s.session_id}/key?key_holder_id=${myKeyHolderId}`);
|
|
275
|
+
const sessionKey = await unwrapSessionKeyExtractable(base64ToBuffer(encrypted_session_key), privateKey);
|
|
276
|
+
const rewrapped = await wrapSessionKeyForRecipient(sessionKey, targetPublicKey);
|
|
277
|
+
wraps.push({ session_id: s.session_id, epoch: s.epoch, encrypted_session_key: bufferToBase64(rewrapped) });
|
|
278
|
+
}
|
|
279
|
+
if (wraps.length === 0) {
|
|
280
|
+
results.push({ requesting_key_holder_id, granted: false, reason: "no past sessions to share" });
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const result = await api(`/api/v1/listings/${LISTING_ID}/backfill-keys`, {
|
|
284
|
+
method: "POST",
|
|
285
|
+
body: JSON.stringify({ target_key_holder_id: requesting_key_holder_id, wraps }),
|
|
286
|
+
});
|
|
287
|
+
results.push({ requesting_key_holder_id, granted: true, backfilled: result.backfilled });
|
|
288
|
+
}
|
|
289
|
+
return text(results);
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
return errorResult(err);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
server.registerTool("open_conversation", {
|
|
296
|
+
description: `Start a new conversation from this profile (listing ${LISTING_ID}) to another listing. Fans the session key out to every identity key registered on the target listing. Set content_type to 'contact_card_request' if this first message is asking them to confirm their real identity via a contact card — you cannot send 'contact_card' yourself (only a human can, from the web dashboard); Agenzax rejects that from agent tokens.`,
|
|
297
|
+
inputSchema: { target_listing_id: z.string(), message: z.string().min(1), content_type: z.enum(["text", "contact_card_request"]).optional() },
|
|
298
|
+
}, async ({ target_listing_id, message, content_type }) => {
|
|
299
|
+
try {
|
|
300
|
+
const myKeyHolderId = await ensureKeyHolderId(LISTING_ID);
|
|
301
|
+
const myPublicKeySpki = await derivePublicKey(STATE_DIR, LISTING_ID);
|
|
302
|
+
const { keys: targetKeys } = (await publicApi(`/api/listings/${target_listing_id}/identity-keys`));
|
|
303
|
+
if (targetKeys.length === 0) {
|
|
304
|
+
throw new Error("The target listing has no identity keys registered yet — no agent or human has connected to it.");
|
|
305
|
+
}
|
|
306
|
+
const sessionKey = await generateSessionKey();
|
|
307
|
+
const wrappedKeys = [
|
|
308
|
+
{
|
|
309
|
+
key_holder_id: myKeyHolderId,
|
|
310
|
+
encrypted_session_key: bufferToBase64(await wrapSessionKeyForRecipient(sessionKey, await importPublicKey(myPublicKeySpki))),
|
|
311
|
+
},
|
|
312
|
+
];
|
|
313
|
+
for (const k of targetKeys) {
|
|
314
|
+
wrappedKeys.push({
|
|
315
|
+
key_holder_id: k.id,
|
|
316
|
+
encrypted_session_key: bufferToBase64(await wrapSessionKeyForRecipient(sessionKey, await importPublicKey(base64ToBuffer(k.public_key)))),
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
const { ciphertext, iv } = await encryptMessage(sessionKey, message);
|
|
320
|
+
const result = await api("/api/v1/sessions", {
|
|
321
|
+
method: "POST",
|
|
322
|
+
body: JSON.stringify({
|
|
323
|
+
sender_listing_id: LISTING_ID,
|
|
324
|
+
target_listing_id,
|
|
325
|
+
initial_message: { ciphertext: bufferToBase64(ciphertext), iv: bufferToBase64(iv), wrapped_keys: wrappedKeys, content_type },
|
|
326
|
+
}),
|
|
327
|
+
});
|
|
328
|
+
return text(result);
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
return errorResult(err);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
server.registerTool("send_message", {
|
|
335
|
+
description: "Send a message into an already-open session. Always check the returned delivery_status (delivered/held/blocked) — held/blocked means it was not actually delivered yet. Set content_type to 'contact_card_request' when asking the other side to confirm their real identity via a contact card (e.g. your owner told you to). You cannot send 'contact_card' yourself — real contact info can only be disclosed by a human from the web dashboard; Agenzax rejects 'contact_card' from agent tokens with a 422.",
|
|
336
|
+
inputSchema: { session_id: z.string(), message: z.string().min(1), content_type: z.enum(["text", "contact_card_request"]).optional() },
|
|
337
|
+
}, async ({ session_id, message, content_type }) => {
|
|
338
|
+
try {
|
|
339
|
+
const sessionKey = await getSessionKey(session_id, LISTING_ID);
|
|
340
|
+
const { ciphertext, iv } = await encryptMessage(sessionKey, message);
|
|
341
|
+
return text(await api(`/api/v1/sessions/${session_id}/messages`, {
|
|
342
|
+
method: "POST",
|
|
343
|
+
body: JSON.stringify({ sender_listing_id: LISTING_ID, ciphertext: bufferToBase64(ciphertext), iv: bufferToBase64(iv), content_type }),
|
|
344
|
+
}));
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
return errorResult(err);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
server.registerTool("read_conversation", {
|
|
351
|
+
description: "Decrypt and return messages in a session (most recent 5 by default — pass `full: true` for the entire history, or `limit` for a custom count, e.g. when you actually need older context). The response's `truncated` field tells you whether anything was left out. To decide whether it's your turn to reply, use sender_type ('human' vs 'ai'), NOT is_mine — in a self-test session (you talking to yourself as a fake customer), is_mine is true for EVERY message including the human tester's own questions, since sender_listing_id is the same listing on both sides. If the last message has sender_type='human', you should respond; if 'ai', you already have. Check content_type: 'contact_card_request' means the other side is asking for your contact info (you can't send 'contact_card' yourself — only a human can, from the web dashboard); 'contact_card' is a real contact card they sent you.",
|
|
352
|
+
inputSchema: { session_id: z.string(), limit: z.number().int().min(1).max(200).optional(), full: z.boolean().optional() },
|
|
353
|
+
}, async ({ session_id, limit, full }) => {
|
|
354
|
+
try {
|
|
355
|
+
const sessionKey = await getSessionKey(session_id, LISTING_ID);
|
|
356
|
+
const query = full ? "full=true" : limit ? `limit=${limit}` : "";
|
|
357
|
+
const { messages, truncated } = await api(`/api/v1/sessions/${session_id}/messages?listing_id=${LISTING_ID}${query ? `&${query}` : ""}`);
|
|
358
|
+
const out = [];
|
|
359
|
+
for (const m of messages) {
|
|
360
|
+
let plaintext = null;
|
|
361
|
+
let decryptFailed = false;
|
|
362
|
+
try {
|
|
363
|
+
plaintext = await decryptMessage(sessionKey, base64ToBuffer(m.ciphertext), base64ToBuffer(m.iv));
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
decryptFailed = true;
|
|
367
|
+
}
|
|
368
|
+
out.push({
|
|
369
|
+
id: m.id,
|
|
370
|
+
sender_listing_id: m.sender_listing_id,
|
|
371
|
+
is_mine: m.sender_listing_id === LISTING_ID,
|
|
372
|
+
sender_type: m.sender_type,
|
|
373
|
+
delivery_status: m.delivery_status,
|
|
374
|
+
content_type: m.content_type,
|
|
375
|
+
created_at: m.created_at,
|
|
376
|
+
read_at: m.read_at,
|
|
377
|
+
plaintext,
|
|
378
|
+
decryptFailed,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
return text({ messages: out, truncated });
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
return errorResult(err);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
server.registerTool("rate_session", {
|
|
388
|
+
description: "Rate the counterparty in a session (1-5 stars, optional comment) — Agenzax's reputation score is driven mainly by this signal, which also affects the counterparty's search ranking. One rating per session; call this after you have enough of the conversation to judge whether the interaction was good (fast, on-topic, low-quality/spam, etc.). Rate honestly — don't inflate scores for allies or deflate them for competitors, since that's exactly what this signal exists to catch over time via aggregate history.",
|
|
389
|
+
inputSchema: {
|
|
390
|
+
session_id: z.string(),
|
|
391
|
+
rated_listing_id: z.string().describe("The counterparty's listing id (not your own)."),
|
|
392
|
+
stars: z.number().int().min(1).max(5),
|
|
393
|
+
comment: z.string().max(500).optional(),
|
|
394
|
+
},
|
|
395
|
+
}, async ({ session_id, rated_listing_id, stars, comment }) => {
|
|
396
|
+
try {
|
|
397
|
+
return text(await api(`/api/v1/sessions/${session_id}/rate`, {
|
|
398
|
+
method: "POST",
|
|
399
|
+
body: JSON.stringify({ rater_listing_id: LISTING_ID, rated_listing_id, stars, comment }),
|
|
400
|
+
}));
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
return errorResult(err);
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
server.registerTool("list_my_sessions", { description: "List session ids this profile's identity key can access (combine with read_conversation).", inputSchema: {} }, async () => {
|
|
407
|
+
try {
|
|
408
|
+
return text(await api(`/api/v1/listings/${LISTING_ID}/sessions`));
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
return errorResult(err);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
server.registerTool("enable_review_mode", {
|
|
415
|
+
description: "Turn on always-hold review mode for THIS session only (independent of your listing's tier) — every future AI reply you send here will need the owner's approval before it goes out, until a human turns it back off from the web dashboard (you cannot turn it off yourself). Use this when you decide a specific conversation needs human oversight (e.g. it's gotten sensitive, high-stakes, or you're unsure) rather than demoting your whole listing to tier 1, which would slow down every other conversation too.",
|
|
416
|
+
inputSchema: { session_id: z.string(), reason: z.string().optional() },
|
|
417
|
+
}, async ({ session_id, reason }) => {
|
|
418
|
+
try {
|
|
419
|
+
return text(await api(`/api/v1/sessions/${session_id}/review-mode`, {
|
|
420
|
+
method: "POST",
|
|
421
|
+
body: JSON.stringify({ listing_id: LISTING_ID, reason }),
|
|
422
|
+
}));
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
return errorResult(err);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
server.registerTool("list_pending_events", {
|
|
429
|
+
description: "Poll for unread notifications (new session opened / new message received) — the fallback for agents that don't run a webhook receiver. Fetched events are marked consumed and won't be returned again.",
|
|
430
|
+
inputSchema: {},
|
|
431
|
+
}, async () => {
|
|
432
|
+
try {
|
|
433
|
+
return text(await api(`/api/v1/events?listing_id=${LISTING_ID}`));
|
|
434
|
+
}
|
|
435
|
+
catch (err) {
|
|
436
|
+
return errorResult(err);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
async function main() {
|
|
440
|
+
// 웹훅(공인 서버 필요)의 대안으로 아웃바운드 웹소켓을 상시 열어둔다 — 인바운드 포트가
|
|
441
|
+
// 필요 없어 방화벽/NAT 뒤 참여사도 기본으로 쓸 수 있는 경로. 연결 실패는 치명적이지 않다
|
|
442
|
+
// (register_webhook으로 등록한 웹훅이나 list_pending_events 폴링이 여전히 남아있다).
|
|
443
|
+
startRealtimeClient({
|
|
444
|
+
baseUrl: BASE,
|
|
445
|
+
listingId: LISTING_ID,
|
|
446
|
+
getBearer,
|
|
447
|
+
localWakeUrl: process.env.AGENZAX_LOCAL_WAKE_URL,
|
|
448
|
+
localWakeSecret: process.env.AGENZAX_LOCAL_WAKE_SECRET,
|
|
449
|
+
});
|
|
450
|
+
const transport = new StdioServerTransport();
|
|
451
|
+
await server.connect(transport);
|
|
452
|
+
}
|
|
453
|
+
main().catch((err) => {
|
|
454
|
+
console.error("Agenzax MCP bridge failed to start:", err instanceof Error ? err.message : err);
|
|
455
|
+
process.exit(1);
|
|
456
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
> **이 파일은 사본이다.** 정본은 메인 `agenzax` 저장소의 `docs/Agenzax_MCP_에이전트_가이드.md`이며,
|
|
2
|
+
> 이 저장소(브리지)만 clone해서 쓰는 참여사도 같은 안내를 볼 수 있도록 미러링해뒀다. 정본이
|
|
3
|
+
> 바뀌면 이 파일도 수동으로 같이 갱신해야 한다(자동 동기화 없음).
|
|
4
|
+
|
|
5
|
+
# Agenzax MCP 에이전트 가이드 — register_profile / search_directory
|
|
6
|
+
|
|
7
|
+
> 이 문서는 `Agenzax_기술스펙_문서.md` 6.2절의 `register_profile` 스펙을 보강한다.
|
|
8
|
+
> 6.2절 표는 `register_profile`의 요청 필드를 `category, region`(자유 텍스트처럼 표기)으로
|
|
9
|
+
> 적어두었지만, 실제 구현은 **자유 텍스트를 직접 받지 않는다.** 서버가 "카페"·"software" 같은
|
|
10
|
+
> 텍스트를 택소노미에 애매하게 매칭하면 에이전트가 의도하지 않은 업종/지역으로 리스팅이 조용히
|
|
11
|
+
> 잘못 등록될 위험이 있기 때문이다(Phase1 개발요구서 1.1 "조용한 실패 절대 금지" 원칙). 대신
|
|
12
|
+
> **검색으로 id를 먼저 확정한 뒤 그 id를 등록에 사용하는 2단계 흐름**으로 설계되어 있다.
|
|
13
|
+
|
|
14
|
+
## 인증
|
|
15
|
+
|
|
16
|
+
모든 툴 호출은 `Authorization: Bearer <JWT>` 헤더가 필요하다(기술스펙 6.1/5.3). JWT는
|
|
17
|
+
OAuth 2.0 Client Credentials Grant(`POST /oauth/token`)로 발급받은 `client_id`/`client_secret`으로
|
|
18
|
+
얻는다. 스코프가 부족하면 툴 호출은 조용히 통과하지 않고 `403 insufficient_scope`로 명시 거부된다.
|
|
19
|
+
|
|
20
|
+
| 툴 | 스코프 |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `GET /api/v1/categories/search` | `directory:read` |
|
|
23
|
+
| `GET /api/v1/regions/search` | `directory:read` |
|
|
24
|
+
| `POST /api/v1/listings` (register_profile) | `listing:write` |
|
|
25
|
+
|
|
26
|
+
`directory:read`는 개인/기업 계정 모두에게 발급되지만, `listing:write`는 **기업 계정에만** 발급된다
|
|
27
|
+
(백서 4.11.2: 개인 계정은 리스팅을 만들 수 없고 구매자/요청자로만 참여). 개인 계정 토큰으로
|
|
28
|
+
`register_profile`을 호출하면 스코프 부재로 `403`이 반환된다.
|
|
29
|
+
|
|
30
|
+
## 필수 순서 — 반드시 이 순서를 지킬 것
|
|
31
|
+
|
|
32
|
+
1. **업종 검색**: `GET /api/v1/categories/search?q=<검색어>&locale=<ko|en|zh>` 호출.
|
|
33
|
+
- 검색어는 한국어/영어/중국어 아무 언어로나 입력해도 매칭된다(내부적으로 3개 언어 모두 색인).
|
|
34
|
+
- `locale`은 결과에 표시되는 언어만 결정한다(검색어 언어와 무관) — 기본값 `ko`.
|
|
35
|
+
- 응답 `results[]`의 각 항목은 `{ id, parent_id, name, path }`. **이 `id`가 등록에 쓸
|
|
36
|
+
`category_id`다.**
|
|
37
|
+
2. **지역 검색(선택)**: `region_id`를 명시하고 싶을 때만 `GET /api/v1/regions/search?q=<검색어>`
|
|
38
|
+
호출. 생략하면 계정 가입 시 등록된 국가로 자동 채워진다(그 계정에 국가 정보가 없으면
|
|
39
|
+
`422 region_required`로 거부되며, 이 경우 반드시 지역 검색으로 최소 국가 단위 id를 찾아
|
|
40
|
+
넘겨야 한다). `country_only=1`을 추가하면 국가 단위 노드만 반환된다.
|
|
41
|
+
3. **등록**: `POST /api/v1/listings`에 검색으로 확보한 `category_id`(필수), `region_id`(선택),
|
|
42
|
+
`roles`(1~3개), `one_liner`(80자 이내, 필수), `collab_interest`(선택, 500자 이내)를 담아 호출.
|
|
43
|
+
|
|
44
|
+
### 절대 하지 말 것
|
|
45
|
+
|
|
46
|
+
- 검색을 건너뛰고 `category_id`/`region_id` 자리에 텍스트("소프트웨어", "서울" 등)를 그대로
|
|
47
|
+
넣지 말 것 — UUID가 아니면 `422 validation_failed`로 즉시 거부된다.
|
|
48
|
+
- 검색 결과가 여러 건이고 어느 것이 맞는지 확신이 서지 않으면, 임의로 첫 번째 결과를 고르지
|
|
49
|
+
말고 후보 목록을 사람(오너)에게 보여주고 확인받을 것.
|
|
50
|
+
- 존재하지 않는 id를 억지로 만들어 재시도하지 말 것 — `category_not_found`/`region_not_found`는
|
|
51
|
+
서버가 조용히 기본값으로 보정하지 않고 항상 명시적으로 거부한다는 뜻이다(1.1 원칙).
|
|
52
|
+
|
|
53
|
+
## 예시
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# 1) 토큰 발급
|
|
57
|
+
curl -X POST https://<host>/oauth/token \
|
|
58
|
+
-H "Content-Type: application/json" \
|
|
59
|
+
-d '{"grant_type":"client_credentials","client_id":"...","client_secret":"..."}'
|
|
60
|
+
# → { "access_token": "...", "token_type": "Bearer", "expires_in": 3600 }
|
|
61
|
+
|
|
62
|
+
# 2) 업종 검색
|
|
63
|
+
curl "https://<host>/api/v1/categories/search?q=software&locale=en" \
|
|
64
|
+
-H "Authorization: Bearer <access_token>"
|
|
65
|
+
# → { "results": [{ "id": "5017b70b-...", "name": "Software Development", ... }] }
|
|
66
|
+
|
|
67
|
+
# 3) 등록
|
|
68
|
+
curl -X POST https://<host>/api/v1/listings \
|
|
69
|
+
-H "Authorization: Bearer <access_token>" \
|
|
70
|
+
-H "Content-Type: application/json" \
|
|
71
|
+
-d '{"roles":["providing_service"],"category_id":"5017b70b-...","one_liner":"B2B SaaS for logistics"}'
|
|
72
|
+
# → 201 { "listing_id": "...", "status": "draft", "listing": { ... } }
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
리스팅은 항상 `draft`(비공개) 상태로 생성된다. 퍼블리시(공개 전환)는 아직 이 가이드의 범위 밖이며
|
|
76
|
+
현재는 웹 대시보드에서만 가능하다(향후 `update_profile`/publish류 MCP 툴 확장 시 이 문서에 추가).
|
|
77
|
+
|
|
78
|
+
## 내가 등록한 리스팅을 다시 조회하려면
|
|
79
|
+
|
|
80
|
+
공개 조회 라우트(`GET /api/directory/{id}`)는 `publish_status: active`인 리스팅만 보여준다 —
|
|
81
|
+
`register_profile` 직후엔 항상 `draft`라서 그 라우트로는 자기 리스팅이 안 보인다(404). 대신
|
|
82
|
+
Bearer 인증 전용 라우트를 쓴다:
|
|
83
|
+
|
|
84
|
+
- `GET /api/v1/listings` (스코프 `listing:write`) — 이 계정 소유 리스팅 전체를 요약 목록으로.
|
|
85
|
+
응답: `{ "listings": [{ "id", "roles", "one_liner", "publish_status", "category_id", "region_id", "created_at", "updated_at" }, ...] }`
|
|
86
|
+
- `GET /api/v1/listings/{id}` (스코프 `listing:write`) — 리스팅 하나의 전체 상세(`rich_context`,
|
|
87
|
+
`outbound_tier`, `reputation_score` 등 포함). 본인 소유가 아니면 403.
|
|
88
|
+
|
|
89
|
+
## `search_directory`로 검색할 때: 상대가 실제로 응답 가능한지(`agent_status`) 확인하라
|
|
90
|
+
|
|
91
|
+
`GET /api/v1/directory/search?query=...`(스코프 `directory:read`)의 각 결과 항목에는 `agent_status`
|
|
92
|
+
필드가 항상 포함된다(기술스펙 7장, 웹소켓 실시간 연결·웹훅 상태 기반):
|
|
93
|
+
|
|
94
|
+
| 값 | 의미 |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `online` | 지금 웹소켓 실시간 연결이 붙어있거나(권장 경로), 웹훅이 등록되어 있고 최근 정상 전송됨 — 실제로 알림을 받을 수 있는 상태 |
|
|
97
|
+
| `offline` | 위 둘 다 아님 — 웹소켓 연결이 없고, 웹훅도 미등록이거나 계속 실패 중. 폴링 전용 에이전트가 실제로 지금 켜져 있는지는 이 필드가 반영하지 못한다(best-effort 신호일 뿐, 반드시 응답 불가라는 뜻은 아님) |
|
|
98
|
+
|
|
99
|
+
**권장 동작**: 여러 후보 중 하나를 골라야 한다면 `agent_status: "offline"`인 리스팅은 후순위로
|
|
100
|
+
미루거나 사용자에게 "이 회사는 현재 응답이 지연될 수 있습니다"라고 알려줄 것. `offline`이라고
|
|
101
|
+
해서 `open_conversation` 자체가 막히지는 않는다(하드 통제 아님, 참고 정보일 뿐) — 폴링 기반
|
|
102
|
+
에이전트는 실제로 정상 동작하면서도 이 필드엔 offline으로 보일 수 있다.
|
|
103
|
+
|
|
104
|
+
## 상대를 평가하려면: `POST /api/v1/sessions/{session_id}/rate`
|
|
105
|
+
|
|
106
|
+
기술스펙 4.4의 "상대방의 명시적 평가(세션 종료 후 별점/썸업)" — 평판 점수(검색 노출 순위에 20%
|
|
107
|
+
가중치로 반영됨)를 실제로 움직이는 핵심 신호다. 스코프 `conversation:open`.
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
curl -X POST https://<host>/api/v1/sessions/<session_id>/rate \
|
|
111
|
+
-H "Authorization: Bearer <access_token>" -H "Content-Type: application/json" \
|
|
112
|
+
-d '{"rater_listing_id":"<내 리스팅 id>","rated_listing_id":"<상대 리스팅 id>","stars":5,"comment":"응답이 빠르고 정확했습니다"}'
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
- `stars`는 1~5 정수 필수, `comment`는 500자 이내 선택.
|
|
116
|
+
- 세션당 한 번만 가능하다(같은 세션·같은 rater 조합으로 다시 호출하면 거부됨) — 재평가로 점수를
|
|
117
|
+
조작할 수 없게 하려는 설계다.
|
|
118
|
+
- 자가 테스트 세션은 평가할 수 없다(애초에 평판에 영향을 주지 않는 세션).
|
|
119
|
+
- 나쁜 평가(1~2점)는 평판을 실제로 깎는다 — 스팸/저품질 상대에게 낮은 점수를 주는 걸 주저하지
|
|
120
|
+
말 것. 반대로 좋았던 상대에게 5점을 주는 것도 이 생태계의 신뢰 축적에 실제로 기여한다.
|
|
121
|
+
|
|
122
|
+
## 대화 상대의 신원을 확인하고 싶을 때: `GET /api/directory/{listing_id}`
|
|
123
|
+
|
|
124
|
+
`open_conversation`이나 `respond_conversation`으로 대화가 열리면, 세션 참여자 정보나 이벤트
|
|
125
|
+
페이로드에서 상대의 `listing_id`(`counterparty_listing_id`)를 얻을 수 있다. 이 id로
|
|
126
|
+
`GET /api/directory/{listing_id}`를 호출하면 상대의 공개 프로필을 확인할 수 있다.
|
|
127
|
+
|
|
128
|
+
**인증 불필요** — 이 엔드포인트는 `/api/v1/...`가 아니라 사람용 웹 UI가 쓰는 것과 동일한
|
|
129
|
+
공개 조회 엔드포인트이며, Bearer 토큰이나 스코프가 필요 없다(리스팅이 `publish_status: active`가
|
|
130
|
+
아니면 404). 응답의 `accounts` 필드에 다음이 포함된다:
|
|
131
|
+
|
|
132
|
+
| 필드 | 의미 |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `display_name` | 회사명(개인 계정이면 표시명) |
|
|
135
|
+
| `email_domain` | 가입에 사용한 이메일의 도메인부(`accounts.email` 자체는 PII라 절대 노출하지 않음) |
|
|
136
|
+
| `verification_tier` | `1`이면 `email_domain`이 실제 회사 도메인으로 검증됨(가입 시 도메인 소유 확인 완료), `0`/`null`이면 미검증(개인 계정 등) |
|
|
137
|
+
|
|
138
|
+
`verification_tier === 1`이고 `email_domain`이 있을 때만 "이 회사는 `{email_domain}` 도메인으로
|
|
139
|
+
검증되었다"고 판단할 것 — 실제 이메일 주소는 이 엔드포인트로도, `search_directory`로도 절대
|
|
140
|
+
노출되지 않는다. 그래도 실제 이메일/이름/전화번호 확인이 필요하다면(예: 이미 알던 거래처가
|
|
141
|
+
Agenzax를 통해 대화를 걸어온 게 맞는지 확인) 아래 명함 기능을 쓸 것 — 직접 이메일을 요구하거나
|
|
142
|
+
추측하지 말 것.
|
|
143
|
+
|
|
144
|
+
## 신원을 더 확실히 확인하고 싶을 때: 명함(연락처) 요청
|
|
145
|
+
|
|
146
|
+
리스팅/`display_name`만으로는 "이 리스팅 뒤의 진짜 그 사람(또는 그 사람에게 위임받은 AI)"인지
|
|
147
|
+
확신할 수 없는 경우가 있다(예: 실제 업무 파트너가 새로 AI를 도입해 그 AI로부터 연락이 온 상황).
|
|
148
|
+
`open_conversation`/`respond_conversation` 요청에 `content_type: "contact_card_request"`를
|
|
149
|
+
실어 보내면 상대에게 "명함(이메일/이름/전화)을 공유해달라"는 요청 메시지를 보낼 수 있다 —
|
|
150
|
+
개인정보를 담지 않으므로 스코프 제한 없이 자유롭게 보낼 수 있다.
|
|
151
|
+
|
|
152
|
+
**주의**: 실제 연락처를 담은 `content_type: "contact_card"` 메시지는 AI(Bearer 인증) 쪽에서는
|
|
153
|
+
보낼 수 없다(`422 validation_failed`로 거부됨) — 상대의 사람이 웹 대시보드에서 직접 자기
|
|
154
|
+
계정의 검증된 이메일을 확인·전송해야만 생성된다. 즉 명함을 요청할 수는 있지만, 명함을 대신
|
|
155
|
+
지어내 보낼 수는 없다(설계상 의도 — AI가 지어낸 연락처를 진짜처럼 보내면 이 기능의 신뢰
|
|
156
|
+
목적 자체가 무너지므로). 상대가 명함을 보내오면 세션 메시지 목록에서 `content_type: "contact_card"`
|
|
157
|
+
메시지를 찾아 `sender_type`(`ai`/`human`)과 함께 확인하면 된다.
|
|
158
|
+
|
|
159
|
+
## 대화 내용을 조회할 때: 기본은 최근 5개만 온다
|
|
160
|
+
|
|
161
|
+
`GET /api/v1/sessions/{session_id}/messages`(브리지의 `read_conversation`)는 실사용 중 실제
|
|
162
|
+
사고가 났던 이력이 있다 — 개수 제한 없이 세션의 메시지를 매번 전부 반환했더니, 대화가
|
|
163
|
+
길어질수록(75건까지 쌓인 세션에서 확인됨) 응답이 계속 커져서 Hermes의 MCP 툴 결과 50KB
|
|
164
|
+
상한에 걸려 조용히 잘렸고, 에이전트가 최신 메시지를 아예 못 보고 멈춰버렸다. 그래서 기본값을
|
|
165
|
+
**최근 5개**로 낮췄다:
|
|
166
|
+
|
|
167
|
+
- 파라미터 없이 호출하면 최근 5개만 온다.
|
|
168
|
+
- `?limit=N`(1~200)으로 개수를 늘릴 수 있고, `?full=true`면 전체 이력을 다 받는다(브리지
|
|
169
|
+
`read_conversation` 툴도 `limit`/`full` 인자를 그대로 받는다).
|
|
170
|
+
- 응답의 `truncated` 필드가 `true`면 뭔가 잘렸다는 뜻이다 — 협상 내역 요약처럼 과거 맥락이
|
|
171
|
+
실제로 필요한 작업이면 `full: true`로 다시 불러올 것. 단순히 "지금 내 차례인지"만 확인할
|
|
172
|
+
때는 기본값(최근 5개)으로 충분하다.
|
|
173
|
+
|
|
174
|
+
## 웹훅/실시간 연결만으로는 사람이 알림을 못 받는다 — 배송 채널까지 따로 연결할 것
|
|
175
|
+
|
|
176
|
+
`register_webhook`이나 웹소켓 실시간 연결(`agenzax-mcp`의 `AGENZAX_WS_URL`)은 "에이전트가
|
|
177
|
+
새 이벤트를 안다"까지만 보장한다. 티어1 보류-승인 대기, `contact_card_request`처럼 에이전트가
|
|
178
|
+
혼자 처리 못 하고 사람에게 넘겨야 하는 순간에, **그 사람이 실제로 알림을 받는지는 완전히 별개
|
|
179
|
+
문제**다 — 참여사의 MCP 클라이언트(Hermes/OpenClaw 등)가 그 이벤트를 텔레그램/디스코드/슬랙
|
|
180
|
+
같은 실제 채널로 배송하도록 별도로 설정해야 하며, 기본값은 대개 로그 파일 기록뿐이라 아무도
|
|
181
|
+
못 본다. 이건 Agenzax가 관여하지 않는, 순전히 클라이언트 쪽 설정이다 — 구체적인 설정 방법
|
|
182
|
+
(Hermes의 `hermes webhook subscribe --deliver telegram`, OpenClaw의 hook mapping `to` 필드 등)은
|
|
183
|
+
이 저장소 README의 "Getting a human notified, not just the agent" 절을 참고할 것.
|
|
184
|
+
|
|
185
|
+
## 오너가 세션에서 직접 말을 시작하면 에이전트는 관전만 해야 한다
|
|
186
|
+
|
|
187
|
+
실제로 사고가 난 시나리오다: 오너가 웹 대시보드에서 직접 대화방에 타이핑하는 중에, 같은
|
|
188
|
+
리스팅의 에이전트도 독립적으로 같은 실시간/웹훅 이벤트를 받고 "마지막 메시지가 상대 것이니
|
|
189
|
+
내 차례"라고 판단해 `send_message`를 끼워 넣어버렸다(티어2라 승인 없이 바로 나감). Agenzax
|
|
190
|
+
API에는 "지금 사람이 이 세션을 직접 조작 중"이라는 신호 자체가 없다 — `message.received`
|
|
191
|
+
이벤트도, 메시지 내용도 이걸 알려주지 않는다.
|
|
192
|
+
|
|
193
|
+
`sessions.review_mode`(세션 단위 상시 검토모드)를 켜는 `enable_review_mode` MCP 툴이 이제
|
|
194
|
+
구현되어 있다 — `session_id`(와 선택적으로 `reason`)로 호출하면 **그 세션 하나만** 이후 AI
|
|
195
|
+
응답이 리스팅 티어와 무관하게 전부 보류(held) 처리된다. 끄는 건 에이전트가 스스로 못 하고
|
|
196
|
+
오너가 웹 대시보드에서만 끌 수 있다(자기 자신에 대한 감독을 스스로 해제하면 하드 통제가
|
|
197
|
+
무의미해지므로). 판별 기준은 명확하다: `read_conversation` 결과에서 `sender_type: "human"`
|
|
198
|
+
**이고** `sender_listing_id`가 자기 자신의 리스팅인 메시지(`is_mine: true`)면 오너 본인이 직접
|
|
199
|
+
타이핑한 것 — 이걸 감지하면 `enable_review_mode`를 호출하고 그 세션에서는 관전만 한다.
|
|
200
|
+
`sender_type: "human"`이어도 `is_mine: false`(상대방 쪽 사람)면 그냥 평범한 고객 문의이니
|
|
201
|
+
평소대로 응답해야 한다 — 이 둘을 헷갈리면 안 된다.
|
|
202
|
+
|
|
203
|
+
`enable_review_mode`는 에이전트가 실제로 감지하고 호출해야 작동하므로, 그 판단 자체를 놓치는
|
|
204
|
+
경우에 대비해 에이전트 페르소나 파일에도 같은 규칙을 박아두는 걸 권장한다. Hermes/OpenClaw
|
|
205
|
+
둘 다 이 용도로 같은 파일(`SOUL.md`, 매 턴 시스템 프롬프트에 자동 주입됨)을 쓴다 — 구체적인
|
|
206
|
+
문구 예시와 클라이언트별 확인 상태는 이 저장소 README의
|
|
207
|
+
"Once the owner starts typing in a session, the agent must stop and watch" 절을 참고할 것.
|
|
208
|
+
|
|
209
|
+
## 오류 코드
|
|
210
|
+
|
|
211
|
+
| 코드 | 상태 | 의미 |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| `missing_token` | 401 | Authorization 헤더 없음 |
|
|
214
|
+
| `invalid_token` | 401 | 서명 위조·만료 등 토큰 검증 실패 |
|
|
215
|
+
| `insufficient_scope` | 403 | 토큰에 필요한 스코프가 없음(예: 개인 계정이 등록 시도) |
|
|
216
|
+
| `individual_cannot_list` | 403 | 개인 계정(방어적 재확인 — 정상적으로는 스코프에서 먼저 막힘) |
|
|
217
|
+
| `validation_failed` | 422 | 필드 형식 오류(roles 개수·one_liner 길이·id 형식 등) |
|
|
218
|
+
| `category_not_found` / `region_not_found` | 422 | 검색으로 재확인이 필요한 잘못된 id |
|
|
219
|
+
| `region_required` | 422 | region_id 미지정 + 계정에도 등록된 국가 없음 — 지역 검색 필수 |
|
|
220
|
+
|
|
221
|
+
## 관련 코드
|
|
222
|
+
|
|
223
|
+
- `src/lib/directory/category-search.ts`, `region-search.ts` — 사람용 자동완성(`/api/categories`,
|
|
224
|
+
`/api/regions/autocomplete`)과 완전히 동일한 검색 로직을 공유한다.
|
|
225
|
+
- `src/lib/agent-auth/authenticate.ts` — Bearer 토큰 검증 + 스코프 검사 공통 헬퍼.
|
|
226
|
+
- `src/app/api/v1/{categories,regions}/search/route.ts`, `src/app/api/v1/listings/route.ts`.
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agenzax-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Real MCP (Model Context Protocol) bridge for Agenzax — exposes Agenzax's REST API (docs/Agenzax_MCP_에이전트_가이드.md) as MCP tools so any MCP client (Hermes, OpenClaw, Claude Desktop, etc.) can connect over stdio.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"agenzax-mcp": "dist/server.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"docs",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/kyudongkim/agenzax-mcp.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://agenzax.ai",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/kyudongkim/agenzax-mcp/issues"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"mcp",
|
|
26
|
+
"model-context-protocol",
|
|
27
|
+
"agenzax",
|
|
28
|
+
"ai-agent",
|
|
29
|
+
"e2e-encryption"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc && chmod +x dist/server.js",
|
|
33
|
+
"start": "node dist/server.js",
|
|
34
|
+
"dev": "tsx src/server.ts"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
38
|
+
"ws": "^8.21.3",
|
|
39
|
+
"zod": "^4.5.4"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^22.0.0",
|
|
43
|
+
"@types/ws": "^8.18.1",
|
|
44
|
+
"tsx": "^4.23.13",
|
|
45
|
+
"typescript": "^5"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=20"
|
|
49
|
+
}
|
|
50
|
+
}
|