agencyhandy-mcp 1.7.2 → 1.9.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/.env.example CHANGED
@@ -8,6 +8,10 @@ AGENCY_BACKEND_API_KEY=
8
8
  # AGENCY_COMPANY_ID= # normally decoded from API key JWT
9
9
  # AGENCY_WORKSPACE_ID=
10
10
  # AGENCY_BACKEND_TIMEOUT_MS=45000
11
- # AGENCY_MCP_MAX_RPM=120 # in-process tool call rate limit
11
+ # AGENCY_MCP_MAX_RPM=120 # tool call rate limit per workspace member
12
+ # AGENCY_MCP_MAX_BODY_BYTES=1048576 # HTTP JSON body cap
13
+ # AGENCY_MCP_CORS_ORIGINS= # empty = no CORS; comma origins or *
12
14
  # AGENCY_MCP_STRICT_API_KEY=1 # require JWT exp (default on when NODE_ENV=production)
15
+ # TRANSPORT=http # stdio (default) or http
16
+ # PORT=3000
13
17
  # NODE_ENV=production
package/PRODUCTION.md CHANGED
@@ -5,29 +5,48 @@ Repo: https://github.com/AgencyHandy/agency-backend-mcp
5
5
 
6
6
  ## End-user flow
7
7
 
8
+ **Local host** (Cursor / Claude Desktop on a laptop):
9
+
8
10
  1. Agency Handy → **Workspace Config → API Key** → Generate
9
11
  2. Copy MCP config (key filled in once)
10
12
  3. Paste into Cursor `.mcp.json` or Claude Desktop config
11
13
  4. Restart host (Node.js 20+)
12
14
 
15
+ **Cloud agent** (Cloudflare Workers, Vercel, n8n, hosted Claude/Cursor): point at the hosted server below, same API key as a Bearer token. Nothing to install.
16
+
17
+ ## Hosted deployment
18
+
19
+ Live at **`https://mcp.agencyhandy.com/`**, running `TRANSPORT=http` behind nginx.
20
+
21
+ | | |
22
+ |---|---|
23
+ | MCP endpoint | `POST https://mcp.agencyhandy.com/` — the **root** path |
24
+ | Health | `GET https://mcp.agencyhandy.com/health` |
25
+ | Auth | `Authorization: Bearer <workspace API key>`; the JWT must carry `_id` and `company` |
26
+
27
+ The root path is the server's own route (`startHttp` in `src/server.js`), not a proxy rewrite — so it is the same path whether you use ours or self-host. If it ever moves, the README, this file, and the in-app config UI all need updating together: those three are what customers copy from.
28
+
13
29
  ## Ship checklist
14
30
 
15
- 1. Push this repo to GitHub
16
- 2. `npm publish --access public` (so `npx -y agencyhandy-mcp@1` works)
31
+ 1. Push this repo to GitHub — done
32
+ 2. `npm publish --access public` (so `npx -y agencyhandy-mcp@1` works) — **npm is still on 1.7.2**; 1.8.0 is unpublished, so stdio users lag the hosted server
17
33
  3. Merge/deploy ah-client PR that shows the MCP copy UI on the API Key tab
18
34
 
19
35
  ## Runtime guarantees
20
36
 
21
37
  - Fail fast on missing / placeholder / expired API keys
22
38
  - Stderr JSON audit logs (stdout = MCP protocol only)
23
- - Secret redaction, rate limit (120 RPM), one retry on 502/503
39
+ - Secret redaction, per-member rate limit (120 RPM), 1 MiB HTTP body cap, CORS off by default, one retry on 502/503
24
40
  - `companyid` from JWT; `ah_health` probes backend
25
41
  - Writes allowlisted: CRM (+ custom fields), work assign/status, invoices (+send), proposals, orders (create/update/assign)
26
42
  - Owner digest: `ah_dashboard_digest`
27
43
  - Resolve-only ids: `ah_member_resolve`, `ah_status_resolve` (no invented ObjectIds)
44
+ - Authority is checkable before a write: `ah_whoami` returns the key's member, workspace, role and effective permission names
28
45
 
29
46
  ## Version
30
47
 
48
+ `1.9.0` — `ah_whoami` (identity + effective permissions, `GET /accounts/whoami`) and `ah_service_create` (catalog services, needs `createService`). Service conditionals — reminders only on subscriptions, unique packageName/position — are checked before the call so a violation reads as a reason, not an opaque 400.
49
+ `1.8.0` — Streamable HTTP remote hosting (`TRANSPORT=http`, `POST /` with per-request Bearer JWT). Per-member rate limit, JSON body cap, CORS allowlist, HTTP unit tests.
31
50
  `1.7.2` — API builder context resource (`ah://api-context`): leads/orders/tasks/comments/labels/clients/invoices/vouchers/forms gotchas + unit/mutation tests.
32
51
  `1.7.1` — Security harden (SSRF path/url guards, axios/deps audit clean) + mutation/npx entry tests; owner insights tolerate null/sparse API rows.
33
52
  `1.7.0` — Owner insights: cash risk, proposal pipeline aging, lead segments, client churn heuristics; plus advanced context packs.
package/README.md CHANGED
@@ -1,19 +1,23 @@
1
1
  # Agency Handy MCP
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/agencyhandy-mcp)](https://www.npmjs.com/package/agencyhandy-mcp)
4
+ [![license](https://img.shields.io/npm/l/agencyhandy-mcp)](LICENSE)
5
+
3
6
  Official [Model Context Protocol](https://modelcontextprotocol.io) server for **Agency Handy**.
4
7
 
5
8
  Connect Claude Desktop, Cursor, or any MCP host to your Agency Handy workspace: inspect proposals, projects, tickets, invoices, and create leads & clients — using your existing workspace API key.
6
9
 
7
10
  | | |
8
11
  |---|---|
9
- | **npm** | `agencyhandy-mcp` (publish after this repo is set up) |
12
+ | **npm** | `agencyhandy-mcp` |
10
13
  | **Node** | ≥ 20 |
11
- | **Auth** | Workspace API key (`x-api-key`) from Agency Handy |
14
+ | **Transports** | stdio (local) · Streamable HTTP (remote) |
15
+ | **Auth** | Workspace API key (JWT) from Agency Handy |
12
16
  | **API** | Your Agency Handy backend (`AGENCY_BACKEND_URL`) |
13
17
 
14
18
  ---
15
19
 
16
- ## Quick start (Agency Handy users)
20
+ ## Quick start
17
21
 
18
22
  1. In Agency Handy go to **Workspace Config → API Key**
19
23
  2. **Generate** a key (or use one you already saved)
@@ -41,7 +45,9 @@ Connect Claude Desktop, Cursor, or any MCP host to your Agency Handy workspace:
41
45
 
42
46
  > Prefer the in-app config: it fills the correct API base URL for your environment (production, staging, etc.).
43
47
 
44
- Until the package is on npm, developers can run from this repo:
48
+ **Running your agent in the cloud** — Cloudflare Workers, Vercel, n8n, a hosted Claude or Cursor — where there's no local process to spawn? Skip this and point at the [hosted server](#option-a--use-the-hosted-server-no-install) instead.
49
+
50
+ To run from a clone of this repo instead of npm:
45
51
 
46
52
  ```json
47
53
  {
@@ -60,16 +66,132 @@ Until the package is on npm, developers can run from this repo:
60
66
 
61
67
  ---
62
68
 
63
- ## What you can do
69
+ ## Transport modes
64
70
 
65
- ### Product naming → API
71
+ | Mode | When to use | How it works |
72
+ |------|-------------|--------------|
73
+ | **stdio** (default) | Local development, single user | MCP host spawns the server as a child process. API key lives in env. |
74
+ | **Streamable HTTP** | Cloud agents, multi-user, team access | Hosted for you at `https://mcp.agencyhandy.com/` (or run your own). Each client connects via `POST /` with a Bearer token. |
66
75
 
67
- | In Agency Handy | Backend |
68
- |-----------------|---------|
69
- | Proposal | Quotations |
70
- | Project | Orders (`pid`) |
71
- | Ticket | Tasks with `type=issue` |
72
- | Task | Tasks with `type=task` |
76
+ ---
77
+
78
+ ## Remote hosting (HTTP mode)
79
+
80
+ Two ways to run over HTTP: use the server Agency Handy hosts, or run your own.
81
+
82
+ ### Option A — use the hosted server (no install)
83
+
84
+ Agency Handy runs this server for you at **`https://mcp.agencyhandy.com/`**. Cloud-hosted agents — Cloudflare Workers, Vercel functions, n8n, or any MCP host that can't spawn a local process — connect straight to it with their workspace API key. Nothing to install, no server to keep up.
85
+
86
+ - **MCP endpoint:** `POST https://mcp.agencyhandy.com/` (the root path)
87
+ - **Health:** `GET https://mcp.agencyhandy.com/health`
88
+
89
+ **Cursor** (and any client that sends custom headers):
90
+
91
+ ```json
92
+ {
93
+ "mcpServers": {
94
+ "agency-handy": {
95
+ "url": "https://mcp.agencyhandy.com/",
96
+ "headers": {
97
+ "Authorization": "Bearer YOUR_API_KEY"
98
+ }
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ **Claude Desktop** — bridge with `mcp-remote`, since it can't send custom headers:
105
+
106
+ ```json
107
+ {
108
+ "mcpServers": {
109
+ "agency-handy": {
110
+ "command": "npx",
111
+ "args": [
112
+ "-y", "mcp-remote",
113
+ "https://mcp.agencyhandy.com/",
114
+ "--header", "Authorization:Bearer YOUR_API_KEY"
115
+ ]
116
+ }
117
+ }
118
+ }
119
+ ```
120
+
121
+ **Claude Code:**
122
+
123
+ ```bash
124
+ claude mcp add agency-handy --transport http https://mcp.agencyhandy.com/ \
125
+ --header "Authorization: Bearer YOUR_API_KEY"
126
+ ```
127
+
128
+ Get the key from **Workspace Config → API Key**. Every request authenticates on its own — the server stores no keys, and `companyid` comes from the JWT.
129
+
130
+ ### Option B — run your own
131
+
132
+ Run the MCP server as an HTTP service on your own host.
133
+
134
+ #### 1. Start the server
135
+
136
+ ```bash
137
+ # npm
138
+ TRANSPORT=http npm start
139
+
140
+ # Docker
141
+ docker build -t agencyhandy-mcp .
142
+ docker run -p 3000:3000 \
143
+ -e AGENCY_BACKEND_URL=https://api.agencyhandy.com \
144
+ agencyhandy-mcp
145
+ ```
146
+
147
+ The server listens on `POST /` (Streamable HTTP) and `GET /health`.
148
+
149
+ #### 2. Connect clients
150
+
151
+ Each client passes their own Agency Handy API key as a Bearer token.
152
+
153
+ **Claude Desktop** — uses `mcp-remote` bridge (Claude Desktop doesn't support custom headers natively):
154
+
155
+ ```json
156
+ {
157
+ "mcpServers": {
158
+ "agency-handy": {
159
+ "command": "npx",
160
+ "args": [
161
+ "-y", "mcp-remote",
162
+ "https://your-server.com/",
163
+ "--header", "Authorization:Bearer YOUR_API_KEY"
164
+ ]
165
+ }
166
+ }
167
+ }
168
+ ```
169
+
170
+ **Cursor** — supports headers natively:
171
+
172
+ ```json
173
+ {
174
+ "mcpServers": {
175
+ "agency-handy": {
176
+ "url": "https://your-server.com/",
177
+ "headers": {
178
+ "Authorization": "Bearer YOUR_API_KEY"
179
+ }
180
+ }
181
+ }
182
+ }
183
+ ```
184
+
185
+ **Claude Code** — pass headers via CLI:
186
+
187
+ ```bash
188
+ claude mcp add agency-handy --transport http https://your-server.com/ \
189
+ --header "Authorization: Bearer YOUR_API_KEY"
190
+ ```
191
+
192
+ ---
193
+
194
+ ## Tools
73
195
 
74
196
  ### Write tools
75
197
 
@@ -97,8 +219,24 @@ Until the package is on npm, developers can run from this repo:
97
219
  | `ah_invoice_set_status` | Mark paid/void/open/… — `PUT /invoices/:id` |
98
220
  | `ah_work_assign` | Add/remove ticket or task assignees |
99
221
  | `ah_work_set_status` | Move ticket/task board status |
222
+ | `ah_service_create` | Create a catalog service — `POST /services`. Needs the `createService` permission |
223
+
224
+ ### Checking what your key may do
225
+
226
+ `ah_whoami` (`GET /accounts/whoami`) returns the calling key's member, workspace, role and **effective permission names**.
100
227
 
101
- **Owner digest (read)**
228
+ Check it before a write rather than inferring authority from the role name. Permissions are matched by name against the list attached to your role — there is no role-name shortcut, so a `superAdmin` whose role has had a permission removed will be refused, and a custom role that has it will be allowed.
229
+
230
+ ```json
231
+ {
232
+ "member": { "_id": "…", "email": "owner@example.com" },
233
+ "company": { "_id": "…", "name": "Acme", "subDomain": "acme" },
234
+ "role": { "name": "superAdmin", "displayName": "Super Admin" },
235
+ "permissions": ["addClient", "createService", "…"]
236
+ }
237
+ ```
238
+
239
+ ### Owner insights
102
240
 
103
241
  | Tool | Purpose |
104
242
  |------|---------|
@@ -107,25 +245,6 @@ Until the package is on npm, developers can run from this repo:
107
245
  | `ah_proposal_pipeline` | Waiting / stale / expiring proposals |
108
246
  | `ah_lead_segments` | Lead counts by status, source, optional custom field |
109
247
  | `ah_client_churn_risk` | Heuristic churn score (overdue + quiet orders) |
110
- | `ah_member_resolve` | Name/email → DB memberId (`unique` / `ambiguous` / `none`) — never invent ids |
111
- | `ah_status_resolve` | Status label → DB statusId — required before `ah_work_set_status` |
112
- | `ah_custom_field_list` | Workspace custom field definitions (lead/client/task) |
113
- | `ah_custom_field_seed_types` | Platform seed types (usually auto-resolved by create) |
114
- | `ah_member_custom_fields` | Member profile + custom field values |
115
- | `ah_task_context` / `ah_ticket_context` | Work item + **who to assign** (ranked candidates, order team/PM, workload) |
116
- | `ah_order_context` / `ah_project_context` | Order pack: tasks, tickets, files, related invoices/subscriptions |
117
- | `ah_proposal_context` | Proposal summary + comments + public + linked service |
118
- | `ah_invoice_context` | Invoice summary + public + linked order + related invoices |
119
-
120
- **Resolve rule:** If `ambiguous` or `none`, ask the user. Only assign/update with an id returned from these tools (or a user-confirmed match).
121
-
122
- **Assigning work**
123
-
124
- 1. `ah_task_context` / `ah_ticket_context` (pass `pid` when known)
125
- 2. Read `assignment.assignmentCandidates` + `assignment.advice`
126
- 3. `ah_work_assign` with a real `memberId` from candidates (or `ah_member_resolve` if the user named someone)
127
-
128
- **Owner insights**
129
248
 
130
249
  | Ask | Tool |
131
250
  |---|---|
@@ -135,7 +254,23 @@ Until the package is on npm, developers can run from this repo:
135
254
  | Quiet + overdue clients — watchlist & one next action | `ah_client_churn_risk` → order/invoice context |
136
255
  | Monday pulse: cash + cold proposals + slipping work | `ah_dashboard_digest` + the tools above |
137
256
 
138
- **Custom fields**
257
+ ### Read tools (highlights)
258
+
259
+ - **Health:** `ah_ping`, `ah_health`
260
+ - **Resolve:** `ah_member_resolve`, `ah_status_resolve` — never invent ObjectIds
261
+ - **Context packs:** `ah_proposal_context`, `ah_project_context`, `ah_ticket_context`, `ah_task_context`, `ah_invoice_context`, `ah_chat_context`, `ah_webhook_context`
262
+ - **Lists / gets:** proposals, projects, tickets, tasks, invoices, members, client companies, services, forms, dashboard
263
+ - **Platform:** chat, email-to-ticket settings, webhooks, order & platform subscriptions
264
+
265
+ **Resolve rule:** If `ambiguous` or `none`, ask the user. Only assign/update with an id returned from these tools (or a user-confirmed match).
266
+
267
+ **Assigning work:**
268
+
269
+ 1. `ah_task_context` / `ah_ticket_context` (pass `pid` when known)
270
+ 2. Read `assignment.assignmentCandidates` + `assignment.advice`
271
+ 3. `ah_work_assign` with a real `memberId` from candidates (or `ah_member_resolve` if the user named someone)
272
+
273
+ ### Custom fields
139
274
 
140
275
  Lead and client custom fields are **separate** catalogs (same display name may exist once per owner).
141
276
 
@@ -145,7 +280,19 @@ Lead and client custom fields are **separate** catalogs (same display name may e
145
280
  4. Admins can create definitions with `ah_custom_field_create` (name + type; options required for selects)
146
281
  5. **Duplicates:** creating a field whose name already exists for that owner (case/spacing ignored) returns `notCreatedCustomFields` with reason `Field already exists` — reuse the existing id instead
147
282
 
148
- **Lead example**
283
+ ### Product naming → API
284
+
285
+ | In Agency Handy | Backend |
286
+ |-----------------|---------|
287
+ | Proposal | Quotations |
288
+ | Project | Orders (`pid`) |
289
+ | Ticket | Tasks with `type=issue` |
290
+ | Task | Tasks with `type=task` |
291
+
292
+ ### Examples
293
+
294
+ <details>
295
+ <summary><strong>Lead</strong></summary>
149
296
 
150
297
  ```json
151
298
  {
@@ -156,8 +303,10 @@ Lead and client custom fields are **separate** catalogs (same display name may e
156
303
  "sendInvitationEmail": false
157
304
  }
158
305
  ```
306
+ </details>
159
307
 
160
- **Client example**
308
+ <details>
309
+ <summary><strong>Client</strong></summary>
161
310
 
162
311
  ```json
163
312
  {
@@ -166,8 +315,10 @@ Lead and client custom fields are **separate** catalogs (same display name may e
166
315
  "sendInvitationEmail": false
167
316
  }
168
317
  ```
318
+ </details>
169
319
 
170
- **Client company example**
320
+ <details>
321
+ <summary><strong>Client company</strong></summary>
171
322
 
172
323
  ```json
173
324
  {
@@ -176,8 +327,10 @@ Lead and client custom fields are **separate** catalogs (same display name may e
176
327
  "city": "Dhaka"
177
328
  }
178
329
  ```
330
+ </details>
179
331
 
180
- **Ticket example**
332
+ <details>
333
+ <summary><strong>Ticket</strong></summary>
181
334
 
182
335
  ```json
183
336
  {
@@ -188,8 +341,10 @@ Lead and client custom fields are **separate** catalogs (same display name may e
188
341
  "visibility": "client"
189
342
  }
190
343
  ```
344
+ </details>
191
345
 
192
- **Invoice example**
346
+ <details>
347
+ <summary><strong>Invoice</strong></summary>
193
348
 
194
349
  ```json
195
350
  {
@@ -199,23 +354,19 @@ Lead and client custom fields are **separate** catalogs (same display name may e
199
354
  "additionalFields": [{ "name": "Design", "quantity": 1, "value": 500 }]
200
355
  }
201
356
  ```
357
+ </details>
202
358
 
203
359
  Order create is intentionally omitted (multipart / service-package heavy). Use `ah_order_update` for existing orders.
204
360
 
205
- ### Read tools (highlights)
206
-
207
- - **Health:** `ah_ping`, `ah_health`
208
- - **Context packs:** `ah_proposal_context`, `ah_project_context`, `ah_ticket_context`, `ah_task_context`, `ah_invoice_context`, `ah_chat_context`, `ah_webhook_context`
209
- - **Lists / gets:** proposals, projects, tickets, tasks, invoices, members, client companies, services, forms, dashboard
210
- - **Platform:** chat, email-to-ticket settings, webhooks, order & platform subscriptions
361
+ ---
211
362
 
212
- Prefer `*_context` tools and the `ah://full-context` resource before inventing ObjectIds. For custom API tooling, also read `ah://api-context` (leads/orders/tasks/comments/labels/clients/invoices/vouchers/forms gotchas).
363
+ ## Resources & prompts
213
364
 
214
- ### Resources & prompts
365
+ **Resources:** `ah://full-context`, `ah://glossary`, `ah://models`, `ah://fields`, `ah://workflows`, `ah://workflows-platform`, `ah://api-context`, `ah://usage`
215
366
 
216
- Resources: `ah://full-context`, `ah://glossary`, `ah://models`, `ah://fields`, `ah://workflows`, `ah://workflows-platform`, `ah://api-context`, `ah://usage`, …
367
+ Prefer `*_context` tools and the `ah://full-context` resource before inventing ObjectIds. For custom API tooling, also read `ah://api-context` (leads/orders/tasks/comments/labels/clients/invoices/vouchers/forms gotchas).
217
368
 
218
- Prompts: `agency_handy_orientation`, `investigate_ticket`, `investigate_project`, `investigate_proposal`, `investigate_task`
369
+ **Prompts:** `agency_handy_orientation`, `investigate_ticket`, `investigate_project`, `investigate_proposal`, `investigate_task`
219
370
 
220
371
  ---
221
372
 
@@ -224,11 +375,15 @@ Prompts: `agency_handy_orientation`, `investigate_ticket`, `investigate_project`
224
375
  | Variable | Required | Description |
225
376
  |----------|----------|-------------|
226
377
  | `AGENCY_BACKEND_URL` | yes | API origin, e.g. `https://api.agencyhandy.com` (no `/api/v1` suffix) |
227
- | `AGENCY_BACKEND_API_KEY` | yes | Member API JWT from Workspace Config → API Key |
378
+ | `AGENCY_BACKEND_API_KEY` | stdio: yes / HTTP: no | Member API JWT. Required for stdio mode. For HTTP mode, clients provide it per-connection via Bearer token. |
379
+ | `TRANSPORT` | no | `stdio` (default) or `http` (Streamable HTTP) |
380
+ | `PORT` | no | HTTP mode listen port (default `3000`) |
228
381
  | `AGENCY_COMPANY_ID` | no | Override; normally taken from the JWT `company` claim |
229
382
  | `AGENCY_WORKSPACE_ID` | no | Optional workspace hint for tools that accept it |
230
383
  | `AGENCY_BACKEND_TIMEOUT_MS` | no | Default `45000` |
231
- | `AGENCY_MCP_MAX_RPM` | no | Tool rate limit per process (default `120`) |
384
+ | `AGENCY_MCP_MAX_RPM` | no | Tool rate limit **per workspace member** (default `120`) |
385
+ | `AGENCY_MCP_MAX_BODY_BYTES` | no | HTTP JSON body cap (default `1048576`) |
386
+ | `AGENCY_MCP_CORS_ORIGINS` | no | Comma-separated browser origins, or `*`. Empty = no CORS (default) |
232
387
  | `AGENCY_MCP_STRICT_API_KEY` | no | `1` to require JWT `exp`; on by default when `NODE_ENV=production` |
233
388
  | `NODE_ENV` | no | Set `production` for hosts |
234
389
 
@@ -236,7 +391,18 @@ Copy `.env.example` → `.env` for local development.
236
391
 
237
392
  ---
238
393
 
239
- ## Develop
394
+ ## Security
395
+
396
+ - The API key is a **full workspace credential** — treat it like a password
397
+ - Never commit `.env` or paste keys into public chats
398
+ - Rotate keys in Agency Handy if leaked
399
+ - **stdio mode:** local trust — the MCP host spawns the server directly
400
+ - **HTTP mode:** each request authenticates via Bearer JWT (mapped to `x-api-key`); rate-limited per workspace member; JSON body capped; CORS off unless `AGENCY_MCP_CORS_ORIGINS` is set; no session state persisted
401
+ - Allowed writes are limited to lead / client / client-company create, tickets, tasks, invoices, proposals, orders, comments, and work assign/status
402
+
403
+ ---
404
+
405
+ ## Development
240
406
 
241
407
  ```bash
242
408
  git clone https://github.com/AgencyHandy/agency-backend-mcp.git
@@ -264,16 +430,6 @@ See [PRODUCTION.md](./PRODUCTION.md) for the operator checklist.
264
430
 
265
431
  ---
266
432
 
267
- ## Security
268
-
269
- - The API key is a **full workspace credential** — treat it like a password
270
- - Never commit `.env` or paste keys into public chats
271
- - Rotate keys in Agency Handy if leaked
272
- - MCP is **stdio / local-trust**; do not expose it on a public network without extra auth
273
- - Allowed writes are limited to lead / client / client-company create
274
-
275
- ---
276
-
277
433
  ## License
278
434
 
279
435
  UNLICENSED — Agency Handy proprietary.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agencyhandy-mcp",
3
- "version": "1.7.2",
3
+ "version": "1.9.0",
4
4
  "description": "Official Agency Handy MCP server — owner insights, context packs, API builder context",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -18,13 +18,16 @@
18
18
  ],
19
19
  "scripts": {
20
20
  "start": "node src/server.js",
21
+ "start:remote": "TRANSPORT=http node src/server.js",
21
22
  "dev": "node --watch src/server.js",
22
- "test": "node scripts/smoke.mjs && node scripts/unit-api-context.mjs && node scripts/unit-custom-fields.mjs && node scripts/unit-work-context.mjs && node scripts/unit-owner-insights.mjs && node scripts/unit-security-mutate.mjs",
23
+ "test": "node scripts/smoke.mjs && node scripts/unit-api-context.mjs && node scripts/unit-custom-fields.mjs && node scripts/unit-work-context.mjs && node scripts/unit-owner-insights.mjs && node scripts/unit-security-mutate.mjs && node scripts/unit-service-create.mjs && node scripts/unit-http.mjs",
24
+ "test:http": "node scripts/unit-http.mjs",
23
25
  "test:security": "node scripts/unit-security-mutate.mjs",
24
26
  "test:api-context": "node scripts/unit-api-context.mjs",
25
27
  "test:live": "SMOKE_LIVE=1 node scripts/smoke.mjs",
26
28
  "test:live:custom-fields": "node scripts/live-custom-fields.mjs",
27
- "prepublishOnly": "npm test && npm audit --audit-level=high"
29
+ "prepublishOnly": "npm test && npm audit --audit-level=high",
30
+ "test:service": "node scripts/unit-service-create.mjs"
28
31
  },
29
32
  "keywords": [
30
33
  "agencyhandy",
package/src/lib.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import axios from "axios";
2
2
  import { z } from "zod";
3
3
 
4
- export const VERSION = "1.7.2";
4
+ export const VERSION = "1.8.0";
5
5
 
6
6
  export const READ_ONLY = false;
7
7
 
@@ -98,6 +98,7 @@ export const ALLOWED_WRITE_TOOLS = [
98
98
  "ah_work_assign",
99
99
  "ah_order_assign",
100
100
  "ah_work_set_status",
101
+ "ah_service_create",
101
102
  ];
102
103
 
103
104
  function serializeParams(p) {
@@ -247,14 +248,49 @@ export function validateApiKey(apiKey, { requireExp = false } = {}) {
247
248
  };
248
249
  }
249
250
 
250
- export function createRateLimiter({ maxPerMinute = 120 } = {}) {
251
+ export function rateLimitKey(keyCheck) {
252
+ if (keyCheck?.companyId && keyCheck?.memberId) {
253
+ return `${keyCheck.companyId}:${keyCheck.memberId}`;
254
+ }
255
+ return "global";
256
+ }
257
+
258
+ /**
259
+ * Sliding-window limiter. Pass a tenant key so HTTP mode cannot starve
260
+ * other workspaces from one shared bucket.
261
+ */
262
+ export function createRateLimiter({ maxPerMinute = 120, maxBuckets = 10_000 } = {}) {
251
263
  const windowMs = 60_000;
252
- const hits = [];
264
+ const buckets = new Map();
265
+
266
+ function prune(hits, now) {
267
+ while (hits.length && hits[0] <= now - windowMs) hits.shift();
268
+ }
269
+
270
+ function gc(now) {
271
+ if (buckets.size < maxBuckets) return;
272
+ for (const [key, hits] of buckets) {
273
+ prune(hits, now);
274
+ if (hits.length === 0) buckets.delete(key);
275
+ }
276
+ while (buckets.size >= maxBuckets) {
277
+ const oldest = buckets.keys().next().value;
278
+ if (oldest == null) break;
279
+ buckets.delete(oldest);
280
+ }
281
+ }
282
+
253
283
  return {
254
284
  maxPerMinute,
255
- tryConsume() {
285
+ tryConsume(key = "global") {
256
286
  const now = Date.now();
257
- while (hits.length && hits[0] <= now - windowMs) hits.shift();
287
+ gc(now);
288
+ let hits = buckets.get(key);
289
+ if (!hits) {
290
+ hits = [];
291
+ buckets.set(key, hits);
292
+ }
293
+ prune(hits, now);
258
294
  if (hits.length >= maxPerMinute) {
259
295
  return {
260
296
  ok: false,
package/src/server.js CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import path from "node:path";
3
+ import http from "node:http";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import dotenv from "dotenv";
5
6
  import { z } from "zod";
6
7
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8
10
  import {
9
11
  CallToolRequestSchema,
10
12
  GetPromptRequestSchema,
@@ -21,6 +23,7 @@ import {
21
23
  createRateLimiter,
22
24
  logEvent,
23
25
  optionalNonEmptyString,
26
+ rateLimitKey,
24
27
  validateApiKey,
25
28
  } from "./lib.js";
26
29
  import { PROMPTS, getPrompt } from "./prompts.js";
@@ -28,12 +31,13 @@ import { RESOURCES, readResource } from "./resources.js";
28
31
  import { handleTool, tools } from "./tools.js";
29
32
 
30
33
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
- // Never override real process env (CI / host injects secrets).
32
34
  dotenv.config({ path: path.join(__dirname, "..", ".env"), override: false });
33
35
 
36
+ const TRANSPORT = (process.env.TRANSPORT || "stdio").toLowerCase();
37
+
34
38
  const envSchema = z.object({
35
39
  AGENCY_BACKEND_URL: z.string().url(),
36
- AGENCY_BACKEND_API_KEY: z.string().min(1),
40
+ AGENCY_BACKEND_API_KEY: optionalNonEmptyString,
37
41
  AGENCY_WORKSPACE_ID: optionalNonEmptyString,
38
42
  AGENCY_COMPANY_ID: optionalNonEmptyString,
39
43
  AGENCY_BACKEND_TIMEOUT_MS: optionalNonEmptyString,
@@ -51,7 +55,7 @@ if (!envResult.success) {
51
55
  })),
52
56
  });
53
57
  process.stderr.write(
54
- "[agencyhandy-mcp] Invalid environment. Set AGENCY_BACKEND_URL and AGENCY_BACKEND_API_KEY.\n"
58
+ "[agencyhandy-mcp] Invalid environment. Set AGENCY_BACKEND_URL.\n"
55
59
  );
56
60
  process.exit(1);
57
61
  }
@@ -66,124 +70,349 @@ if (!urlCheck.ok) {
66
70
  env.AGENCY_BACKEND_URL = urlCheck.url;
67
71
 
68
72
  const strictKey =
69
- env.AGENCY_MCP_STRICT_API_KEY === "1" ||
70
- env.NODE_ENV === "production";
71
- const keyCheck = validateApiKey(env.AGENCY_BACKEND_API_KEY, {
72
- requireExp: strictKey,
73
- });
74
- if (!keyCheck.ok) {
75
- logEvent("error", "api_key_invalid", { error: keyCheck.error });
76
- process.stderr.write(`[agencyhandy-mcp] ${keyCheck.error}\n`);
77
- process.exit(1);
78
- }
73
+ env.AGENCY_MCP_STRICT_API_KEY === "1" || env.NODE_ENV === "production";
79
74
 
80
- const api = createApi(env);
81
75
  const rateLimiter = createRateLimiter({
82
- maxPerMinute: Math.max(10, Number(env.AGENCY_MCP_MAX_RPM) || 120),
76
+ maxPerMinute: Math.max(1, Number(env.AGENCY_MCP_MAX_RPM) || 120),
83
77
  });
84
78
 
79
+ const MAX_BODY_BYTES = Math.max(
80
+ 1024,
81
+ Number(process.env.AGENCY_MCP_MAX_BODY_BYTES) || 1_048_576
82
+ );
83
+
85
84
  logEvent("info", "server_starting", {
85
+ mode: TRANSPORT,
86
86
  backendBaseUrl: env.AGENCY_BACKEND_URL,
87
- companyId: keyCheck.companyId,
88
- memberId: keyCheck.memberId,
89
87
  maxRpm: rateLimiter.maxPerMinute,
90
88
  toolCount: tools.length,
91
89
  });
92
90
 
93
- const server = new Server(
94
- {
95
- name: "agency-handy",
96
- version: VERSION,
97
- },
98
- {
99
- capabilities: {
100
- tools: {},
101
- resources: {},
102
- prompts: {},
103
- },
104
- instructions: `Agency Handy backend MCP (production).
91
+ // ── HTTP helpers ──────────────────────────────────────────────────────────────
92
+
93
+ function extractBearerToken(req) {
94
+ const auth = req.headers["authorization"];
95
+ if (!auth) return null;
96
+ const match = auth.match(/^Bearer\s+(.+)$/i);
97
+ return match ? match[1].trim() : null;
98
+ }
99
+
100
+ function declaredBodyTooLarge(req) {
101
+ const n = Number(req.headers["content-length"]);
102
+ return Number.isFinite(n) && n > MAX_BODY_BYTES;
103
+ }
104
+
105
+ async function parseJsonBody(req, { maxBytes = MAX_BODY_BYTES } = {}) {
106
+ return new Promise((resolve, reject) => {
107
+ const chunks = [];
108
+ let size = 0;
109
+ let settled = false;
110
+ const fail = (err) => {
111
+ if (settled) return;
112
+ settled = true;
113
+ reject(err);
114
+ };
115
+ req.on("data", (chunk) => {
116
+ if (settled) return;
117
+ size += chunk.length;
118
+ if (size > maxBytes) {
119
+ const err = new Error("payload_too_large");
120
+ err.code = "PAYLOAD_TOO_LARGE";
121
+ req.destroy();
122
+ fail(err);
123
+ return;
124
+ }
125
+ chunks.push(chunk);
126
+ });
127
+ req.on("end", () => {
128
+ if (settled) return;
129
+ settled = true;
130
+ const raw = Buffer.concat(chunks).toString("utf8");
131
+ if (!raw) return resolve(null);
132
+ try {
133
+ resolve(JSON.parse(raw));
134
+ } catch {
135
+ reject(new Error("Invalid JSON"));
136
+ }
137
+ });
138
+ req.on("error", fail);
139
+ });
140
+ }
141
+
142
+ function sendJson(res, status, body) {
143
+ res.writeHead(status, {
144
+ "Content-Type": "application/json",
145
+ "Cache-Control": "no-cache",
146
+ });
147
+ res.end(JSON.stringify(body));
148
+ }
149
+
150
+ function sendError(res, status, message) {
151
+ sendJson(res, status, { error: message });
152
+ }
153
+
154
+ // ── MCP Server factory (per-connection in HTTP mode) ──────────────────────────
155
+
156
+ function createMcpServer(apiClient, connEnv, connKeyCheck) {
157
+ const server = new Server(
158
+ { name: "agency-handy", version: VERSION },
159
+ {
160
+ capabilities: { tools: {}, resources: {}, prompts: {} },
161
+ instructions: `Agency Handy backend MCP (production).
105
162
  Read ah://full-context first. Prefer *_context tools.
106
163
  NEVER invent ObjectIds. For people: ah_member_resolve → unique or user pick → then assign.
107
164
  For board labels: ah_status_resolve → then ah_work_set_status.
108
165
  Prefer ah_dashboard_digest for morning briefings. Call ah_health if connectivity is unclear.`,
109
- }
110
- );
166
+ }
167
+ );
111
168
 
112
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
169
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
113
170
 
114
- server.setRequestHandler(ListResourcesRequestSchema, async () => ({
115
- resources: RESOURCES,
116
- }));
171
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
172
+ resources: RESOURCES,
173
+ }));
117
174
 
118
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
119
- const result = readResource(request.params.uri);
120
- if (!result) {
121
- throw new Error(`Unknown resource: ${request.params.uri}`);
175
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
176
+ const result = readResource(request.params.uri);
177
+ if (!result) throw new Error(`Unknown resource: ${request.params.uri}`);
178
+ return result;
179
+ });
180
+
181
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
182
+ prompts: PROMPTS,
183
+ }));
184
+
185
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
186
+ const result = getPrompt(
187
+ request.params.name,
188
+ request.params.arguments || {}
189
+ );
190
+ if (!result) throw new Error(`Unknown prompt: ${request.params.name}`);
191
+ return result;
192
+ });
193
+
194
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
195
+ const name = request.params.name;
196
+ const started = Date.now();
197
+ const limited = rateLimiter.tryConsume(rateLimitKey(connKeyCheck));
198
+ if (!limited.ok) {
199
+ logEvent("warn", "rate_limited", { tool: name });
200
+ return asText({
201
+ ok: false,
202
+ error: "rate_limited",
203
+ message: `Too many tool calls. Retry after ~${Math.ceil(limited.retryAfterMs / 1000)}s.`,
204
+ retryAfterMs: limited.retryAfterMs,
205
+ });
206
+ }
207
+
208
+ try {
209
+ const result = await handleTool(name, request.params.arguments ?? {}, {
210
+ api: apiClient,
211
+ env: connEnv,
212
+ keyCheck: connKeyCheck,
213
+ });
214
+ logEvent("info", "tool_ok", { tool: name, ms: Date.now() - started });
215
+ return result;
216
+ } catch (error) {
217
+ logEvent("error", "tool_error", {
218
+ tool: name,
219
+ ms: Date.now() - started,
220
+ message: error?.message || "Unknown error",
221
+ });
222
+ if (error?.name === "ZodError") {
223
+ return asText({
224
+ ok: false,
225
+ error: "Invalid tool arguments",
226
+ details: error.issues,
227
+ });
228
+ }
229
+ return asText({
230
+ ok: false,
231
+ error: "Tool handler failed",
232
+ message: error?.message || "Unknown error",
233
+ });
234
+ }
235
+ });
236
+
237
+ return server;
238
+ }
239
+
240
+ // ── Streamable HTTP handler ───────────────────────────────────────────────────
241
+
242
+ async function handleMcpPost(req, res) {
243
+ const token = extractBearerToken(req);
244
+ if (!token) {
245
+ logEvent("warn", "http_missing_auth");
246
+ res.setHeader("WWW-Authenticate", 'Bearer realm="agencyhandy-mcp"');
247
+ return sendError(
248
+ res,
249
+ 401,
250
+ "Missing Authorization header (Bearer token required)"
251
+ );
122
252
  }
123
- return result;
124
- });
125
253
 
126
- server.setRequestHandler(ListPromptsRequestSchema, async () => ({
127
- prompts: PROMPTS,
128
- }));
254
+ const keyCheck = validateApiKey(token, { requireExp: strictKey });
255
+ if (!keyCheck.ok) {
256
+ logEvent("warn", "http_invalid_key", { error: keyCheck.error });
257
+ return sendError(res, 401, `Invalid API key: ${keyCheck.error}`);
258
+ }
129
259
 
130
- server.setRequestHandler(GetPromptRequestSchema, async (request) => {
131
- const result = getPrompt(
132
- request.params.name,
133
- request.params.arguments || {}
134
- );
135
- if (!result) {
136
- throw new Error(`Unknown prompt: ${request.params.name}`);
260
+ if (declaredBodyTooLarge(req)) {
261
+ return sendError(res, 413, "Request body too large");
137
262
  }
138
- return result;
139
- });
140
263
 
141
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
142
- const name = request.params.name;
143
- const started = Date.now();
144
- const limited = rateLimiter.tryConsume();
145
- if (!limited.ok) {
146
- logEvent("warn", "rate_limited", { tool: name });
147
- return asText({
148
- ok: false,
149
- error: "rate_limited",
150
- message: `Too many tool calls. Retry after ~${Math.ceil(limited.retryAfterMs / 1000)}s.`,
151
- retryAfterMs: limited.retryAfterMs,
152
- });
264
+ let body;
265
+ try {
266
+ body = await parseJsonBody(req);
267
+ } catch (err) {
268
+ if (err?.code === "PAYLOAD_TOO_LARGE") {
269
+ return sendError(res, 413, "Request body too large");
270
+ }
271
+ return sendError(res, 400, "Invalid JSON body");
272
+ }
273
+
274
+ if (!body) {
275
+ return sendError(res, 400, "Empty request body");
153
276
  }
154
277
 
278
+ const connEnv = { ...env, AGENCY_BACKEND_API_KEY: token };
279
+ const apiClient = createApi(connEnv);
280
+
281
+ logEvent("info", "http_connection", {
282
+ memberId: keyCheck.memberId,
283
+ companyId: keyCheck.companyId,
284
+ });
285
+
286
+ const server = createMcpServer(apiClient, connEnv, keyCheck);
287
+ const transport = new StreamableHTTPServerTransport({
288
+ sessionIdGenerator: undefined,
289
+ });
290
+
155
291
  try {
156
- const result = await handleTool(name, request.params.arguments ?? {}, {
157
- api,
158
- env,
159
- keyCheck,
160
- });
161
- logEvent("info", "tool_ok", {
162
- tool: name,
163
- ms: Date.now() - started,
164
- });
165
- return result;
292
+ await server.connect(transport);
293
+ await transport.handleRequest(req, res, body);
166
294
  } catch (error) {
167
- logEvent("error", "tool_error", {
168
- tool: name,
169
- ms: Date.now() - started,
295
+ logEvent("error", "http_transport_error", {
170
296
  message: error?.message || "Unknown error",
171
297
  });
172
- if (error?.name === "ZodError") {
173
- return asText({
174
- ok: false,
175
- error: "Invalid tool arguments",
176
- details: error.issues,
177
- });
298
+ if (!res.headersSent) {
299
+ sendError(res, 500, "Internal server error");
178
300
  }
179
- return asText({
180
- ok: false,
181
- error: "Tool handler failed",
182
- message: error?.message || "Unknown error",
183
- });
184
301
  }
185
- });
302
+ }
303
+
304
+ function corsAllowOrigin(req) {
305
+ const raw = (process.env.AGENCY_MCP_CORS_ORIGINS || "").trim();
306
+ if (!raw) return null;
307
+ if (raw === "*") return "*";
308
+ const requestOrigin = req.headers.origin;
309
+ if (!requestOrigin) return null;
310
+ const allowed = raw.split(",").map((s) => s.trim()).filter(Boolean);
311
+ return allowed.includes(requestOrigin) ? requestOrigin : null;
312
+ }
313
+
314
+ function applyCors(req, res) {
315
+ const origin = corsAllowOrigin(req);
316
+ if (!origin) return false;
317
+ res.setHeader("Access-Control-Allow-Origin", origin);
318
+ res.setHeader("Vary", "Origin");
319
+ res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
320
+ res.setHeader(
321
+ "Access-Control-Allow-Headers",
322
+ "Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version"
323
+ );
324
+ res.setHeader("Access-Control-Max-Age", "86400");
325
+ return true;
326
+ }
327
+
328
+ // ── Mode: stdio ───────────────────────────────────────────────────────────────
329
+
330
+ async function startStdio() {
331
+ if (!env.AGENCY_BACKEND_API_KEY) {
332
+ process.stderr.write(
333
+ "[agencyhandy-mcp] AGENCY_BACKEND_API_KEY required for stdio mode.\n"
334
+ );
335
+ process.exit(1);
336
+ }
337
+
338
+ const keyCheck = validateApiKey(env.AGENCY_BACKEND_API_KEY, {
339
+ requireExp: strictKey,
340
+ });
341
+ if (!keyCheck.ok) {
342
+ logEvent("error", "api_key_invalid", { error: keyCheck.error });
343
+ process.stderr.write(`[agencyhandy-mcp] ${keyCheck.error}\n`);
344
+ process.exit(1);
345
+ }
346
+
347
+ const api = createApi(env);
348
+
349
+ logEvent("info", "stdio_config", {
350
+ backendBaseUrl: env.AGENCY_BACKEND_URL,
351
+ companyId: keyCheck.companyId,
352
+ memberId: keyCheck.memberId,
353
+ });
354
+
355
+ const server = createMcpServer(api, env, keyCheck);
356
+ const transport = new StdioServerTransport();
357
+ await server.connect(transport);
358
+ logEvent("info", "server_ready", { version: VERSION, mode: "stdio" });
359
+ }
186
360
 
187
- const transport = new StdioServerTransport();
188
- await server.connect(transport);
189
- logEvent("info", "server_ready", { version: VERSION });
361
+ // ── Mode: Streamable HTTP ─────────────────────────────────────────────────────
362
+
363
+ function startHttp() {
364
+ const parsedPort = Number(process.env.PORT);
365
+ const listenPort =
366
+ Number.isFinite(parsedPort) && parsedPort >= 0 ? parsedPort : 3000;
367
+
368
+ const httpServer = http.createServer(async (req, res) => {
369
+ applyCors(req, res);
370
+
371
+ if (req.method === "OPTIONS") {
372
+ res.writeHead(204);
373
+ return res.end();
374
+ }
375
+
376
+ const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
377
+
378
+ if (url.pathname === "/health" && req.method === "GET") {
379
+ return sendJson(res, 200, {
380
+ ok: true,
381
+ version: VERSION,
382
+ mode: TRANSPORT,
383
+ backendUrl: env.AGENCY_BACKEND_URL,
384
+ });
385
+ }
386
+
387
+ if (url.pathname === "/" && req.method === "POST") {
388
+ return handleMcpPost(req, res);
389
+ }
390
+
391
+ if (url.pathname === "/" && req.method === "GET") {
392
+ return sendError(
393
+ res,
394
+ 405,
395
+ "GET not supported. Use POST with Streamable HTTP."
396
+ );
397
+ }
398
+
399
+ sendError(res, 404, "Not found");
400
+ });
401
+
402
+ httpServer.listen(listenPort, () => {
403
+ const addr = httpServer.address();
404
+ const bound = typeof addr === "object" && addr ? addr.port : listenPort;
405
+ logEvent("info", "http_listening", { port: bound, version: VERSION });
406
+ process.stderr.write(
407
+ `[agencyhandy-mcp] Streamable HTTP listening on :${bound} (POST /)\n`
408
+ );
409
+ });
410
+ }
411
+
412
+ // ── Start ─────────────────────────────────────────────────────────────────────
413
+
414
+ if (TRANSPORT === "http") {
415
+ startHttp();
416
+ } else {
417
+ await startStdio();
418
+ }
package/src/tools.js CHANGED
@@ -523,6 +523,17 @@ export const tools = [
523
523
  properties: { workspaceId: { type: "string" } },
524
524
  },
525
525
  },
526
+ {
527
+ name: "ah_whoami",
528
+ description:
529
+ "Who this API key is and what it may do: member, workspace, role, and the effective permission names. GET /accounts/whoami. Use this before a write to check authority (e.g. createService) instead of inferring it from the role name or discovering it from a 403.",
530
+ inputSchema: {
531
+ type: "object",
532
+ properties: {
533
+ workspaceId: { type: "string" },
534
+ },
535
+ },
536
+ },
526
537
  {
527
538
  name: "ah_member_custom_fields",
528
539
  description:
@@ -1404,6 +1415,36 @@ export async function handleTool(name, args, ctx) {
1404
1415
  });
1405
1416
  }
1406
1417
 
1418
+ if (name === "ah_whoami") {
1419
+ const input = z
1420
+ .object({ workspaceId: z.string().min(1).optional() })
1421
+ .parse(args);
1422
+ const ws = wsOf(input.workspaceId);
1423
+ const me = await backendGet(api, "/api/v1/accounts/whoami", withWs({}, ws));
1424
+ if (!me.ok) {
1425
+ return asText({
1426
+ ok: false,
1427
+ // Older backends have no /whoami; say so rather than implying the key
1428
+ // is bad.
1429
+ instruction:
1430
+ me.status === 404
1431
+ ? "This workspace's backend predates /accounts/whoami. Permissions cannot be listed; do not infer them from the role name."
1432
+ : "Identity lookup failed. Check the API key and companyid.",
1433
+ source: me,
1434
+ workspaceId: ws,
1435
+ });
1436
+ }
1437
+ const data = me.data || {};
1438
+ return asText({
1439
+ ok: true,
1440
+ member: data.member ?? null,
1441
+ company: data.company ?? null,
1442
+ role: data.role ?? null,
1443
+ permissions: data.permissions ?? [],
1444
+ workspaceId: ws,
1445
+ });
1446
+ }
1447
+
1407
1448
  if (name === "ah_member_custom_fields") {
1408
1449
  const input = z
1409
1450
  .object({
package/src/writeTools.js CHANGED
@@ -849,6 +849,91 @@ export const writeToolDefs = [
849
849
  required: ["type", "memberId", "values"],
850
850
  },
851
851
  },
852
+ {
853
+ name: "ah_service_create",
854
+ description:
855
+ "Create a catalog service. POST /services. Requires the createService permission — check with ah_whoami first rather than discovering it from a rejection. Required: name, serviceType, type, isPublished, packages (1-5, each with price + currency, unique packageName and position). reminderType/reminders are accepted only when type=subscription; files/mediaPositions only when serviceType=service.",
856
+ inputSchema: {
857
+ type: "object",
858
+ properties: {
859
+ name: { type: "string", description: "3-1000 chars" },
860
+ serviceType: {
861
+ type: "string",
862
+ enum: ["service", "addOn", "inline", "inlineAddOn"],
863
+ },
864
+ type: { type: "string", enum: ["oneTime", "subscription"] },
865
+ isPublished: { type: "boolean" },
866
+ slug: { type: "string" },
867
+ description: { type: "string" },
868
+ isPopular: { type: "boolean" },
869
+ billingCycleEvent: {
870
+ type: "string",
871
+ enum: ["createOrderWithTask", "noChange", "doNotCreateNewOrder"],
872
+ },
873
+ packages: {
874
+ type: "array",
875
+ minItems: 1,
876
+ maxItems: 5,
877
+ description:
878
+ "packageName and position must be unique within the service",
879
+ items: {
880
+ type: "object",
881
+ properties: {
882
+ packageName: { type: "string" },
883
+ position: { type: "number" },
884
+ price: { type: "number", minimum: 0 },
885
+ currency: { type: "string", description: "e.g. USD" },
886
+ brief: { type: "string" },
887
+ features: {
888
+ type: "array",
889
+ items: { type: "string" },
890
+ description: "1-15 entries",
891
+ },
892
+ duration: { type: "string" },
893
+ repeatCount: { type: "number" },
894
+ repeatDuration: {
895
+ type: "string",
896
+ enum: ["month", "week", "day", "year"],
897
+ },
898
+ billingCycleCount: { type: "number" },
899
+ trialPeriod: { type: "number" },
900
+ trialPeriodDuration: {
901
+ type: "string",
902
+ enum: ["month", "week", "day", "year"],
903
+ },
904
+ setupFeeAmount: { type: "number" },
905
+ setupFeeCount: { type: "number" },
906
+ },
907
+ required: ["price", "currency"],
908
+ },
909
+ },
910
+ faqs: {
911
+ type: "array",
912
+ items: {
913
+ type: "object",
914
+ properties: {
915
+ question: { type: "string" },
916
+ answer: { type: "string" },
917
+ },
918
+ required: ["question", "answer"],
919
+ },
920
+ },
921
+ reminderType: {
922
+ type: "string",
923
+ enum: ["sendReminder", "doNotSendReminder"],
924
+ description: "Only when type=subscription",
925
+ },
926
+ reminders: {
927
+ type: "array",
928
+ items: { type: "object" },
929
+ description: "Required when reminderType=sendReminder",
930
+ },
931
+ portfolios: { type: "array", items: { type: "string" } },
932
+ workspaceId: { type: "string" },
933
+ },
934
+ required: ["name", "serviceType", "type", "isPublished", "packages"],
935
+ },
936
+ },
852
937
  {
853
938
  name: "ah_custom_field_delete",
854
939
  description:
@@ -891,6 +976,44 @@ const memberUpdateZod = z.object({
891
976
  workspaceId: z.string().min(1).optional(),
892
977
  });
893
978
 
979
+ const servicePackageZod = z.object({
980
+ packageName: z.string().min(1).optional(),
981
+ position: z.coerce.number().optional(),
982
+ price: z.coerce.number().min(0),
983
+ currency: z.string().min(1),
984
+ brief: z.string().optional(),
985
+ features: z.array(z.string()).min(1).max(15).optional(),
986
+ duration: z.string().optional(),
987
+ repeatCount: z.coerce.number().optional(),
988
+ repeatDuration: z.enum(["month", "week", "day", "year"]).optional(),
989
+ billingCycleCount: z.coerce.number().optional(),
990
+ trialPeriod: z.coerce.number().min(1).optional(),
991
+ trialPeriodDuration: z.enum(["month", "week", "day", "year"]).optional(),
992
+ setupFeeAmount: z.coerce.number().min(0).optional(),
993
+ setupFeeCount: z.coerce.number().min(0).optional(),
994
+ });
995
+
996
+ const serviceCreateZod = z.object({
997
+ name: z.string().trim().min(3).max(1000),
998
+ serviceType: z.enum(["service", "addOn", "inline", "inlineAddOn"]),
999
+ type: z.enum(["oneTime", "subscription"]),
1000
+ isPublished: z.coerce.boolean(),
1001
+ slug: z.string().trim().min(1).max(1000).optional(),
1002
+ description: z.string().optional(),
1003
+ isPopular: z.coerce.boolean().optional(),
1004
+ billingCycleEvent: z
1005
+ .enum(["createOrderWithTask", "noChange", "doNotCreateNewOrder"])
1006
+ .optional(),
1007
+ packages: z.array(servicePackageZod).min(1).max(5),
1008
+ faqs: z
1009
+ .array(z.object({ question: z.string().min(1), answer: z.string().min(1) }))
1010
+ .optional(),
1011
+ reminderType: z.enum(["sendReminder", "doNotSendReminder"]).optional(),
1012
+ reminders: z.array(z.record(z.any())).optional(),
1013
+ portfolios: z.array(z.string().min(1)).optional(),
1014
+ workspaceId: z.string().min(1).optional(),
1015
+ });
1016
+
894
1017
  const taskCreateZod = z
895
1018
  .object({
896
1019
  name: z.string().min(1),
@@ -1954,6 +2077,60 @@ export async function handleWriteTool(name, args, ctx) {
1954
2077
  });
1955
2078
  }
1956
2079
 
2080
+ if (name === "ah_service_create") {
2081
+ const input = serviceCreateZod.parse(args);
2082
+ const ws = wsOf(input.workspaceId);
2083
+ const { workspaceId, ...body } = input;
2084
+
2085
+ // Fail here rather than at the API: these two are conditionals in the
2086
+ // backend schema, and a violation comes back as an opaque 400.
2087
+ if (body.type !== "subscription" && (body.reminderType || body.reminders)) {
2088
+ return asText({
2089
+ ok: false,
2090
+ error:
2091
+ "reminderType/reminders are only accepted when type is 'subscription'",
2092
+ workspaceId: ws,
2093
+ });
2094
+ }
2095
+ if (body.reminderType === "sendReminder" && !body.reminders?.length) {
2096
+ return asText({
2097
+ ok: false,
2098
+ error: "reminders is required when reminderType is 'sendReminder'",
2099
+ workspaceId: ws,
2100
+ });
2101
+ }
2102
+
2103
+ const names = body.packages.map((p) => p.packageName).filter(Boolean);
2104
+ if (new Set(names).size !== names.length) {
2105
+ return asText({
2106
+ ok: false,
2107
+ error: "packageName must be unique within a service",
2108
+ workspaceId: ws,
2109
+ });
2110
+ }
2111
+ const positions = body.packages
2112
+ .map((p) => p.position)
2113
+ .filter((p) => p !== undefined);
2114
+ if (new Set(positions).size !== positions.length) {
2115
+ return asText({
2116
+ ok: false,
2117
+ error: "package position must be unique within a service",
2118
+ workspaceId: ws,
2119
+ });
2120
+ }
2121
+
2122
+ const created = await backendPost(api, "/api/v1/services", body);
2123
+ return asText({
2124
+ ...created,
2125
+ ...(created.status === 403
2126
+ ? {
2127
+ hint: "A 403 here is usually the createService permission. Run ah_whoami to see the calling key's permissions.",
2128
+ }
2129
+ : {}),
2130
+ workspaceId: ws,
2131
+ });
2132
+ }
2133
+
1957
2134
  if (name === "ah_custom_field_delete") {
1958
2135
  const input = z
1959
2136
  .object({