agencyhandy-mcp 1.7.0 → 1.7.2

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/PRODUCTION.md CHANGED
@@ -28,4 +28,6 @@ Repo: https://github.com/AgencyHandy/agency-backend-mcp
28
28
 
29
29
  ## Version
30
30
 
31
+ `1.7.2` — API builder context resource (`ah://api-context`): leads/orders/tasks/comments/labels/clients/invoices/vouchers/forms gotchas + unit/mutation tests.
32
+ `1.7.1` — Security harden (SSRF path/url guards, axios/deps audit clean) + mutation/npx entry tests; owner insights tolerate null/sparse API rows.
31
33
  `1.7.0` — Owner insights: cash risk, proposal pipeline aging, lead segments, client churn heuristics; plus advanced context packs.
package/README.md CHANGED
@@ -129,10 +129,11 @@ Until the package is on npm, developers can run from this repo:
129
129
 
130
130
  | Ask | Tool |
131
131
  |---|---|
132
- | Who owes us / aging AR | `ah_cash_risk` |
133
- | Which proposals are rotting | `ah_proposal_pipeline` |
134
- | How do leads segment | `ah_lead_segments` (optional `customFieldName`) |
135
- | Who might churn | `ah_client_churn_risk` |
132
+ | Top AR clients + past 30/60/90 — who to chase today | `ah_cash_risk` → then `ah_invoice_context` / send |
133
+ | Stale or expiring proposals — bump vs write off | `ah_proposal_pipeline` → `ah_proposal_context` |
134
+ | Lead mix by source / Industry — volume vs converting | `ah_lead_segments` (`customFieldName`) |
135
+ | Quiet + overdue clients — watchlist & one next action | `ah_client_churn_risk` → order/invoice context |
136
+ | Monday pulse: cash + cold proposals + slipping work | `ah_dashboard_digest` + the tools above |
136
137
 
137
138
  **Custom fields**
138
139
 
@@ -208,11 +209,11 @@ Order create is intentionally omitted (multipart / service-package heavy). Use `
208
209
  - **Lists / gets:** proposals, projects, tickets, tasks, invoices, members, client companies, services, forms, dashboard
209
210
  - **Platform:** chat, email-to-ticket settings, webhooks, order & platform subscriptions
210
211
 
211
- Prefer `*_context` tools and the `ah://full-context` resource before inventing ObjectIds.
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).
212
213
 
213
214
  ### Resources & prompts
214
215
 
215
- Resources: `ah://full-context`, `ah://glossary`, `ah://models`, `ah://fields`, `ah://workflows`, `ah://workflows-platform`, `ah://usage`, …
216
+ Resources: `ah://full-context`, `ah://glossary`, `ah://models`, `ah://fields`, `ah://workflows`, `ah://workflows-platform`, `ah://api-context`, `ah://usage`, …
216
217
 
217
218
  Prompts: `agency_handy_orientation`, `investigate_ticket`, `investigate_project`, `investigate_proposal`, `investigate_task`
218
219
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agencyhandy-mcp",
3
- "version": "1.7.0",
4
- "description": "Official Agency Handy MCP server — owner insights + advanced context packs",
3
+ "version": "1.7.2",
4
+ "description": "Official Agency Handy MCP server — owner insights, context packs, API builder context",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
7
7
  "bin": {
@@ -19,10 +19,12 @@
19
19
  "scripts": {
20
20
  "start": "node src/server.js",
21
21
  "dev": "node --watch src/server.js",
22
- "test": "node scripts/smoke.mjs && node scripts/unit-custom-fields.mjs && node scripts/unit-work-context.mjs && node scripts/unit-owner-insights.mjs",
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:security": "node scripts/unit-security-mutate.mjs",
24
+ "test:api-context": "node scripts/unit-api-context.mjs",
23
25
  "test:live": "SMOKE_LIVE=1 node scripts/smoke.mjs",
24
26
  "test:live:custom-fields": "node scripts/live-custom-fields.mjs",
25
- "prepublishOnly": "npm test"
27
+ "prepublishOnly": "npm test && npm audit --audit-level=high"
26
28
  },
27
29
  "keywords": [
28
30
  "agencyhandy",
@@ -34,7 +36,7 @@
34
36
  "license": "UNLICENSED",
35
37
  "dependencies": {
36
38
  "@modelcontextprotocol/sdk": "^1.12.3",
37
- "axios": "^1.9.0",
39
+ "axios": "^1.19.0",
38
40
  "dotenv": "^16.5.0",
39
41
  "zod": "^3.24.4"
40
42
  }
package/src/lib.js CHANGED
@@ -1,10 +1,76 @@
1
1
  import axios from "axios";
2
2
  import { z } from "zod";
3
3
 
4
- export const VERSION = "1.7.0";
4
+ export const VERSION = "1.7.2";
5
5
 
6
6
  export const READ_ONLY = false;
7
7
 
8
+ /**
9
+ * Block SSRF / open redirects via axios absolute URLs or path traversal.
10
+ * All backend calls must be relative paths under /api/.
11
+ */
12
+ export function assertSafeBackendPath(pathname) {
13
+ if (typeof pathname !== "string" || !pathname) {
14
+ return { ok: false, error: "backend path missing" };
15
+ }
16
+ if (/[\u0000-\u001f]/.test(pathname)) {
17
+ return { ok: false, error: "backend path contains control characters" };
18
+ }
19
+ const trimmed = pathname.trim();
20
+ if (
21
+ trimmed.startsWith("//") ||
22
+ /^https?:/i.test(trimmed) ||
23
+ /^[a-z][a-z0-9+.-]*:/i.test(trimmed)
24
+ ) {
25
+ return { ok: false, error: "backend path must not be an absolute URL" };
26
+ }
27
+ if (!trimmed.startsWith("/")) {
28
+ return { ok: false, error: "backend path must be absolute on the API host (start with /)" };
29
+ }
30
+ if (trimmed.includes("\\") || trimmed.includes("..")) {
31
+ return { ok: false, error: "backend path must not contain traversal sequences" };
32
+ }
33
+ if (!trimmed.startsWith("/api/")) {
34
+ return { ok: false, error: "backend path must start with /api/" };
35
+ }
36
+ return { ok: true, path: trimmed };
37
+ }
38
+
39
+ /**
40
+ * Validate AGENCY_BACKEND_URL before axios.create.
41
+ * Allow http only for loopback (local smoke); require https otherwise.
42
+ */
43
+ export function assertSafeBackendUrl(rawUrl) {
44
+ if (typeof rawUrl !== "string" || !rawUrl.trim()) {
45
+ return { ok: false, error: "AGENCY_BACKEND_URL missing" };
46
+ }
47
+ let u;
48
+ try {
49
+ u = new URL(rawUrl.trim());
50
+ } catch {
51
+ return { ok: false, error: "AGENCY_BACKEND_URL is not a valid URL" };
52
+ }
53
+ if (u.username || u.password) {
54
+ return { ok: false, error: "AGENCY_BACKEND_URL must not embed credentials" };
55
+ }
56
+ const host = u.hostname.toLowerCase();
57
+ const loopback =
58
+ host === "localhost" ||
59
+ host === "127.0.0.1" ||
60
+ host === "::1" ||
61
+ host === "[::1]";
62
+ if (u.protocol === "https:") {
63
+ return { ok: true, url: `${u.origin}${u.pathname}`.replace(/\/$/, "") || u.origin };
64
+ }
65
+ if (u.protocol === "http:" && loopback) {
66
+ return { ok: true, url: `${u.origin}${u.pathname}`.replace(/\/$/, "") || u.origin };
67
+ }
68
+ return {
69
+ ok: false,
70
+ error: "AGENCY_BACKEND_URL must use https (http allowed only for localhost)",
71
+ };
72
+ }
73
+
8
74
  export const ALLOWED_WRITE_TOOLS = [
9
75
  "ah_lead_create",
10
76
  "ah_client_create",
@@ -202,6 +268,10 @@ export function createRateLimiter({ maxPerMinute = 120 } = {}) {
202
268
  }
203
269
 
204
270
  export function createApi(env) {
271
+ const urlCheck = assertSafeBackendUrl(env.AGENCY_BACKEND_URL);
272
+ if (!urlCheck.ok) {
273
+ throw new Error(urlCheck.error);
274
+ }
205
275
  const headers = { "x-api-key": env.AGENCY_BACKEND_API_KEY };
206
276
  const companyId =
207
277
  env.AGENCY_COMPANY_ID || companyIdFromApiKey(env.AGENCY_BACKEND_API_KEY);
@@ -210,9 +280,10 @@ export function createApi(env) {
210
280
 
211
281
  const timeout = Number(env.AGENCY_BACKEND_TIMEOUT_MS) || 45_000;
212
282
  const client = axios.create({
213
- baseURL: env.AGENCY_BACKEND_URL.replace(/\/$/, ""),
283
+ baseURL: urlCheck.url,
214
284
  headers,
215
285
  timeout,
286
+ maxRedirects: 0,
216
287
  // Keep 4xx/5xx as resolved responses so callers can map status → ok.
217
288
  validateStatus: (s) => s >= 200 && s < 600,
218
289
  });
@@ -268,7 +339,21 @@ export function withWs(base = {}, ws = null) {
268
339
  return ws ? { ...base, workspaceId: ws } : { ...base };
269
340
  }
270
341
 
342
+ function rejectUnsafePath(pathname) {
343
+ const check = assertSafeBackendPath(pathname);
344
+ if (check.ok) return null;
345
+ logEvent("error", "backend_path_rejected", { path: String(pathname), error: check.error });
346
+ return {
347
+ ok: false,
348
+ path: pathname,
349
+ status: null,
350
+ message: check.error,
351
+ };
352
+ }
353
+
271
354
  export async function backendGet(api, pathname, query = {}) {
355
+ const rejected = rejectUnsafePath(pathname);
356
+ if (rejected) return rejected;
272
357
  const started = Date.now();
273
358
  try {
274
359
  const { data, status } = await api.get(pathname, {
@@ -302,6 +387,8 @@ export async function backendGet(api, pathname, query = {}) {
302
387
  }
303
388
 
304
389
  export async function backendPost(api, pathname, body, query = {}) {
390
+ const rejected = rejectUnsafePath(pathname);
391
+ if (rejected) return rejected;
305
392
  const started = Date.now();
306
393
  try {
307
394
  const { data, status } = await api.post(pathname, body, {
@@ -334,6 +421,8 @@ export async function backendPost(api, pathname, body, query = {}) {
334
421
  }
335
422
 
336
423
  export async function backendDelete(api, pathname, body, query = {}) {
424
+ const rejected = rejectUnsafePath(pathname);
425
+ if (rejected) return rejected;
337
426
  const started = Date.now();
338
427
  try {
339
428
  const { data, status } = await api.delete(pathname, {
@@ -368,6 +457,8 @@ export async function backendDelete(api, pathname, body, query = {}) {
368
457
  }
369
458
 
370
459
  export async function backendPut(api, pathname, body, query = {}) {
460
+ const rejected = rejectUnsafePath(pathname);
461
+ if (rejected) return rejected;
371
462
  const started = Date.now();
372
463
  try {
373
464
  const { data, status } = await api.put(pathname, body, {
@@ -42,6 +42,12 @@ function amountOf(invoice) {
42
42
  return Number.isFinite(n) ? n : 0;
43
43
  }
44
44
 
45
+ function solidRows(list) {
46
+ return (Array.isArray(list) ? list : []).filter(
47
+ (row) => row != null && typeof row === "object" && !Array.isArray(row)
48
+ );
49
+ }
50
+
45
51
  /**
46
52
  * Cash at risk: open invoices aged into buckets + top clients by amount due.
47
53
  */
@@ -53,7 +59,9 @@ export async function buildCashRisk(api, { limit, ws, backendGet, withWs }) {
53
59
  "/api/v1/invoices",
54
60
  withWs({ status: "open", limit: fetchLimit, skip: 0 }, ws)
55
61
  );
56
- const rows = pickList(invoicesRes, ["invoices", "docs", "items", "results"]);
62
+ const rows = solidRows(
63
+ pickList(invoicesRes, ["invoices", "docs", "items", "results"])
64
+ );
57
65
  const summarized = rows.map((row) => {
58
66
  const s = summarizeInvoice(row) || {};
59
67
  const dueDays = daysSince(s.dueDate || row.dueDate, now);
@@ -159,13 +167,15 @@ export async function buildProposalPipeline(
159
167
  "/api/v1/quotations",
160
168
  withWs({ page: 1, limit: Math.min(Math.max(limit * 4, 40), 100), sortBy: "updatedAt", order: "desc" }, ws)
161
169
  );
162
- const rows = pickList(proposalsRes, [
163
- "quotations",
164
- "proposals",
165
- "docs",
166
- "items",
167
- "results",
168
- ]);
170
+ const rows = solidRows(
171
+ pickList(proposalsRes, [
172
+ "quotations",
173
+ "proposals",
174
+ "docs",
175
+ "items",
176
+ "results",
177
+ ])
178
+ );
169
179
 
170
180
  const items = rows.map((row) => {
171
181
  const s = summarizeProposal(row) || {};
@@ -242,19 +252,21 @@ export async function buildLeadSegments(
242
252
  backendGet(api, "/api/v1/custom-fields", withWs({ type: "lead" }, ws)),
243
253
  ]);
244
254
 
245
- const leads = pickList(leadsRes, [
246
- "acceptedMembers",
247
- "members",
248
- "docs",
249
- "items",
250
- "results",
251
- ]);
252
- const fieldDefs = (() => {
255
+ const leads = solidRows(
256
+ pickList(leadsRes, [
257
+ "acceptedMembers",
258
+ "members",
259
+ "docs",
260
+ "items",
261
+ "results",
262
+ ])
263
+ );
264
+ const fieldDefs = solidRows((() => {
253
265
  const data = unwrap(fieldsRes);
254
266
  if (Array.isArray(data?.leadCustomFields)) return data.leadCustomFields;
255
267
  if (Array.isArray(data)) return data;
256
268
  return pickList(fieldsRes, ["leadCustomFields", "docs", "items"]);
257
- })();
269
+ })());
258
270
 
259
271
  const byStatus = {};
260
272
  const bySource = {};
@@ -382,15 +394,21 @@ export async function buildClientChurnRisk(
382
394
  ),
383
395
  ]);
384
396
 
385
- const clients = pickList(clientsRes, [
386
- "acceptedMembers",
387
- "members",
388
- "docs",
389
- "items",
390
- "results",
391
- ]);
392
- const invoices = pickList(invoicesRes, ["invoices", "docs", "items", "results"]);
393
- const orders = pickList(ordersRes, ["orders", "docs", "items", "results"]);
397
+ const clients = solidRows(
398
+ pickList(clientsRes, [
399
+ "acceptedMembers",
400
+ "members",
401
+ "docs",
402
+ "items",
403
+ "results",
404
+ ])
405
+ );
406
+ const invoices = solidRows(
407
+ pickList(invoicesRes, ["invoices", "docs", "items", "results"])
408
+ );
409
+ const orders = solidRows(
410
+ pickList(ordersRes, ["orders", "docs", "items", "results"])
411
+ );
394
412
 
395
413
  const unpaidByClient = new Map();
396
414
  for (const inv of invoices) {
package/src/prompts.js CHANGED
@@ -76,12 +76,13 @@ export function getPrompt(name, args = {}) {
76
76
  type: "text",
77
77
  text: `Read MCP resource ah://full-context (preferred single read).
78
78
  If the host cannot load the bundle, read in order:
79
- ah://glossary → ah://models → ah://fields → ah://workflows → ah://workflows-platform → ah://usage → ah://tool-guide → ah://response-shape
79
+ ah://glossary → ah://models → ah://fields → ah://workflows → ah://workflows-platform → ah://api-context → ah://usage → ah://tool-guide → ah://response-shape
80
80
 
81
81
  Then confirm:
82
82
  - proposal=quotation, project=order(pid), ticket=task type=issue
83
+ - leads live under /members (bulk-lead, isConvertedClient=false); never PUT /accounts/update without accountId
83
84
  - Core delivery workflows (A–O) plus platform/ops (P–CC): auth, email-to-ticket, chat, webhooks, subscriptions
84
- - Field meanings from ah://fields
85
+ - Field meanings from ah://fields; path/permission gotchas from ah://api-context
85
86
  - Prefer *_context tools; never invent ObjectIds
86
87
  - People by name: ah_member_resolve first (unique|ambiguous|none). If ambiguous, ask user. Never guess.
87
88
  - Status by label: ah_status_resolve first. Same rule.
package/src/resources.js CHANGED
@@ -9,7 +9,7 @@ export const RESOURCES = [
9
9
  name: "Full Agency Handy MCP context (bundle)",
10
10
  mimeType: "text/markdown",
11
11
  description:
12
- "Single bundle: glossary, models, fields, product + platform workflows, usage, tools.",
12
+ "Single bundle: glossary, models, fields, product + platform workflows, API builder context, usage, tools.",
13
13
  },
14
14
  {
15
15
  uri: "ah://glossary",
@@ -68,6 +68,13 @@ export const RESOURCES = [
68
68
  mimeType: "text/markdown",
69
69
  description: "How to interpret ok/status/data from ah_* tools.",
70
70
  },
71
+ {
72
+ uri: "ah://api-context",
73
+ name: "API builder context (gotchas)",
74
+ mimeType: "text/markdown",
75
+ description:
76
+ "Missing/ambiguous API context for MCP builders: leads, orders, tasks, comments, invoices, forms, webhooks, permissions.",
77
+ },
71
78
  ];
72
79
 
73
80
  function md(uri, text) {
@@ -88,6 +95,7 @@ const GLOSSARY = `# Agency Handy glossary
88
95
  | Client company | ClientCompany | \`/api/v1/client-companies\` | B2B org |
89
96
  | Form | Form / PublicForm | \`/forms\`, \`/public-forms\` | Intake → lead/order |
90
97
  | Comment | Comment / QuotationComment | \`/comments\`, \`/quotation-comments\` | Different namespaces |
98
+ | Lead | Member (client role, not converted) | \`/members\` + \`bulk-lead\` | **No** \`/leads\` mount — see \`ah://api-context\` |
91
99
  | Time entry | TimeTracker | \`/time-trackers\` | \`taskId\` / \`projectId\` |
92
100
  | Task status | TaskStatus | \`/taskStatus\` | Board lane ObjectIds |
93
101
  | Company | Company | \`/company\` | Workspace tenant |
@@ -96,6 +104,7 @@ const GLOSSARY = `# Agency Handy glossary
96
104
  | Dashboard | Dashboard | \`/dashboards\` | Aggregates |
97
105
 
98
106
  Auth: \`x-api-key\` (member API token). Optional \`AGENCY_WORKSPACE_ID\`.
107
+ Read **\`ah://api-context\`** for path renames, permission names, and write-side gotchas.
99
108
  `;
100
109
 
101
110
  const MODELS = `# Domain models & relationships
@@ -134,6 +143,7 @@ Company (workspace)
134
143
  - **Ticket vs Task**: same collection; filter/list with \`type=issue\` vs \`type=task\`.
135
144
  - **Project vs Order**: UI "Project" = API \`orders\` = tool \`pid\`.
136
145
  - **Proposal vs Quotation**: UI "Proposal" = API \`quotations\`.
146
+ - **Lead vs Client vs Team**: same \`Member\` collection — lead = client role + \`isConvertedClient=false\`; client = converted; team = non-client role.
137
147
  - **Human ids**: \`taskId\`, order \`id\`/\`customId\`/\`prefix\` are display ids — MCP tools need Mongo \`_id\`.
138
148
 
139
149
  ## ID args for tools
@@ -679,7 +689,7 @@ If only tools: \`ah_ping\` → use \`forAnyLlm\`; still call \`ah_*_context\`.
679
689
 
680
690
  ## Startup (every model)
681
691
  1. \`ah_ping\`
682
- 2. Read \`ah://full-context\` OR (\`ah://models\` + \`ah://fields\` + \`ah://workflows\` + \`ah://workflows-platform\` + \`ah://usage\`)
692
+ 2. Read \`ah://full-context\` OR (\`ah://models\` + \`ah://fields\` + \`ah://workflows\` + \`ah://workflows-platform\` + \`ah://api-context\` + \`ah://usage\`)
683
693
  3. One context pack / investigate prompt
684
694
  4. Filtered lists before more gets
685
695
 
@@ -691,6 +701,7 @@ If only tools: \`ah_ping\` → use \`forAnyLlm\`; still call \`ah_*_context\`.
691
701
  - Billing → \`ah_invoice_list\` → \`ah_invoice_context\`
692
702
  - Board → \`ah_task_statuses\` then filtered \`ah_task_list\` / \`ah_ticket_list\`
693
703
  - Email-created ticket / chat / webhooks → \`ah://workflows-platform\` then ticket/project tools
704
+ - Path / permission / lead confusion → \`ah://api-context\`
694
705
 
695
706
  ## Token hygiene
696
707
  - Prefer \`*_context\` (1 call) over 3–5 gets
@@ -701,9 +712,10 @@ If only tools: \`ah_ping\` → use \`forAnyLlm\`; still call \`ah_*_context\`.
701
712
  ## Anti-patterns
702
713
  - Inventing ObjectIds
703
714
  - Ticket id as \`pid\`
704
- - Writes (MCP is read-oriented)
705
- - Company-wide task list when \`pid\` known → use \`ah_project_tasks\`
715
+ - Calling stale OpenAPI paths (\`/projects\`, \`/company/.../members\`)
716
+ - \`PUT /accounts/update\` without \`accountId\` (updates API-key owner)
706
717
  - Mixing \`type=issue\` / \`type=task\`
718
+ - Treating \`/subscriptions\` as client order-subscriptions
707
719
  `;
708
720
 
709
721
  const TOOL_GUIDE = `# Tool selection
@@ -760,7 +772,7 @@ const MODULES = `# Modules
760
772
  | Platform SaaS sub | \`ah_platform_subscription_list\` | subscriptions/list |
761
773
  | Meta | \`ah_ping\` | local |
762
774
 
763
- Resources: full-context, glossary, models, fields, workflows, workflows-platform, usage, tool-guide, modules, response-shape.
775
+ Resources: full-context, glossary, models, fields, workflows, workflows-platform, usage, tool-guide, modules, response-shape, api-context.
764
776
  Prompts: orientation + investigate_*.
765
777
  `;
766
778
 
@@ -803,6 +815,126 @@ Zod argument errors: \`{ ok:false, error:"Invalid tool arguments", details:[...]
803
815
  Config errors: \`Invalid MCP server environment variables\`.
804
816
  `;
805
817
 
818
+ const API_CONTEXT = `# API builder context (missing / easy-to-miss)
819
+
820
+ Use this when mapping product tools to live Agency Handy HTTP APIs, or when OpenAPI and runtime disagree. Prefer \`ah_*\` tools when available — this doc is the ground truth for paths and gotchas.
821
+
822
+ ## Cross-cutting
823
+ - Live base: \`/api/v1/*\`. OpenAPI often still shows stale \`/projects/*\` and \`/company/{id}/members/*\` — **do not call those**.
824
+ - Auth: \`x-api-key\` **or** bearer — never both. API key JWT sets company from claims.
825
+ - Many deletes are **PUT + id array**, not HTTP DELETE (\`/tasks\`, \`/invoices\`, \`/quotations\`, \`/vouchers\`, \`/webhooks\`, \`/orders/delete\`, \`/members/bulk-delete\`).
826
+ - Pagination: orders/invoices/tasks often \`skip\`+\`limit\`; quotations use \`page\`+\`limit\`.
827
+ - 403 \`PermissionError\` ("Restricted - No permission…") = missing **permission name** on the API-key member role — not a broken token.
828
+ - Subscription expired: key still authenticates; routes with \`subscriptionExpireCheck\` return 403 \`SubscriptionExpirationError\` after grace.
829
+
830
+ ## Leads (no \`/leads\` API)
831
+ - Leads are \`Member\` rows: client role + \`isConvertedClient=false\` (same collection as team/clients).
832
+ - Create: \`POST /members/bulk-lead\` (not \`/members\` or \`/members/bulk\`).
833
+ - List: \`GET /members?isConvertedClient=false\` (+ optional pipeline \`status\`).
834
+ - Update / convert: \`PUT /accounts/update\` with **required \`accountId\`**.
835
+ - **Without \`accountId\`**, update targets the **API-key owner** (can rename the token user).
836
+ - Delete: \`PUT /members/bulk-delete\`.
837
+ - Custom fields: \`/custom-fields?type=lead\` (separate from \`client\` / \`task\`).
838
+ - Pipeline statuses: \`New | Contacted | Qualified | Working | Proposal Sent | Negotiation\`.
839
+ - Permissions: \`addNewMemberToCompany\`, \`getEmployeeDirectory\`. Plan may return \`Lead limit reached\`.
840
+ - MCP: \`ah_lead_create\`, \`ah_lead_convert\`, \`ah_member_update\` (always pass account/member id).
841
+
842
+ ## Orders / Projects
843
+ - UI Project = \`/orders\` only (not \`/projects\`).
844
+ - Get: \`GET /orders/:pid\`. Update: \`PUT /orders?pid=\` (query \`pid\` required).
845
+ - Status enum (title case): \`Pending | Ongoing | Review | Completed | Cancelled | Payment Failed\`.
846
+ - Client accept/reject: \`PUT /orders/:pid/actions\` with \`orderStatus: accept|reject\` — not the same as agency status PUT.
847
+ - Create needs \`service\`, \`packageId\`, \`packageName\`, \`clientTargetType\`, \`paymentType\` (\`oneTime|subscription\`), plus \`clientId\` **or** (\`clientCompanyId\` + \`sentToClientIds\`).
848
+ - Soft delete: \`PUT /orders/delete\`.
849
+
850
+ ## Tasks / tickets / statuses
851
+ - Board tickets = \`Task\` with \`type=issue\` via \`/company/all-tasks?type=issue|task\` — **not** the legacy \`/tickets\` collection.
852
+ - List/filter \`status\` = TaskStatus **ObjectId(s)**. Update \`PUT /tasks/:taskId\` \`status\` = **status name string** (controller resolves name → id). Passing ObjectId on update fails.
853
+ - Complete: \`PUT /tasks/mark-as-completed\` (needs \`_id\`, \`pid\`, \`memberId\`, \`assingedPersons\` — typo is real). Not the same as status \`done\`.
854
+ - OpenAPI \`POST /tasks/{id}/subtask\` and \`PATCH /tasks/{id}/reopen\` are **not mounted**. Reopen ≈ \`PUT\` with \`status: open\` on closed issues (limited). Subtasks = model \`type=subTask\` only — no external create route.
855
+ - Create requires \`name\`, \`priority\` (0–4), \`labels[]\` (required, may be \`[]\`), \`type\`; \`client\` required unless visibility \`internal|order\`.
856
+ - Permissions: \`updateTask\`, \`deleteTask\`; tickets delete via \`deleteTicket\` / \`PUT /tasks/delete-ticket\`.
857
+ - \`GET /taskStatus\` returns all types — filter \`type\` (\`task\` vs \`issue\`) client-side.
858
+
859
+ ## Assignment
860
+ - Add: \`POST /tasks/member/:taskId\` body \`{ assignmembers: [memberIds] }\`.
861
+ - Remove: \`PUT /tasks/member/:taskId\` body \`{ assignedMemberIds: [...] }\` — **different field name**.
862
+ - IDs are workspace **member** ObjectIds, not emails. Resolve with \`ah_member_resolve\`.
863
+ - Order PMs: \`assignedProjectManagers\` on \`PUT /orders?pid=\`, not task member endpoints.
864
+
865
+ ## Comments
866
+ - Task/ticket: \`/comments\` — create needs \`text\`, \`type\` (\`agencyComment|clientComment|…\`), \`taskId\`, and query **\`memberId\` required**.
867
+ - Update: \`POST /comments/:commentId/update\` (not PUT).
868
+ - Proposal comments: separate \`/quotation-comments\` — do not reuse \`/comments\`.
869
+ - \`agencyComment\` vs \`clientComment\` controls portal visibility.
870
+
871
+ ## Labels
872
+ - Company catalog create: \`POST /labels/create-for-company\`; suggest: \`GET /labels/suggest-by-company\`.
873
+ - \`POST /labels/\` is **task-scoped** (requires task id) — not global create.
874
+ - Attach/detach: \`/labels/task/:task\`, bulk \`/labels/bulk/:taskId\`.
875
+
876
+ ## Clients / members / groups
877
+ - Live path: \`/members\` (not OpenAPI \`/company/{COMPANY_ID}/members\`).
878
+ - List permission: \`getEmployeeDirectory\`. \`type\` is \`individual|company|leadCompanyMember\` — not \`client\`/\`lead\`. Role filter is a role ObjectId. \`tab=client|team\` only on \`/members/basic\`.
879
+ - Invite: \`POST /members/send-client-invite\`. Bulk team \`/members/bulk\`, bulk client \`/members/bulk-client\`, bulk lead \`/members/bulk-lead\`.
880
+ - Soft delete: \`PUT /members/bulk-delete\` (+ optional async poll).
881
+ - Client companies: \`/client-companies\` (B2B org ids) ≠ individual client members — orders/invoices need the right \`clientTargetType\`.
882
+ - Groups: \`/client-groups\` (hard DELETE on group id).
883
+
884
+ ## Invoices
885
+ - Mount: \`/invoices\`. Permissions: \`getInvoice\`, \`createInvoice\`, \`updateInvoice\`, \`deleteInvoice\`.
886
+ - Status: \`draft | open | paid | void | uncollectable\` (+ processing in places).
887
+ - Resend: \`PUT /invoices/resend\`. Public: \`/invoices/public/:id\`. Delete: \`PUT /invoices\` + ids.
888
+ - Create: \`currency\` + one of \`client\` | \`externalEmail\` | \`clientCompanyId\`; line items via \`additionalFields\` when required.
889
+
890
+ ## Proposals / quotations
891
+ - UI Proposal = \`/quotations\` always.
892
+ - Permissions: \`createQuotation\`, \`updateQuotation\`, \`deleteQuotation\`.
893
+ - Recipient: \`clientId\` **or** \`leadId\` **or** external email object.
894
+ - Send: \`PUT /quotations/send/:id\`. Accept/reject: \`/accept/:id\`, \`/reject/:id\`. Public: \`/quotations/public/:id\`.
895
+ - \`acceptAction\`: \`doNothing | createOrder | createOrderAndInvoice\` — accept can spawn order/invoice.
896
+ - Pagination: \`page\` (min 1) + \`limit\`.
897
+
898
+ ## Services & packages
899
+ - No \`/packages\` resource — packages nested on \`/services\`.
900
+ - Order create needs both \`packageId\` and \`packageName\` + parent \`service\` id.
901
+ - Permissions: \`createService\`, \`updateService\`, \`deleteService\`. Catalog: \`/services/catalog\`.
902
+
903
+ ## Vouchers
904
+ - Admin CRUD permissions: \`createVoucher\`, \`getVouchers\`, \`getVoucher\`, \`updateVoucher\`, \`deleteVoucher\`.
905
+ - \`POST /vouchers/apply\` is **unauthenticated checkout** (\`code\`, \`email\`, \`serviceId\`, \`packageId\`, \`quantity\`) — not admin CRUD.
906
+ - Delete: \`PUT /vouchers\` + \`voucherIds[]\`.
907
+
908
+ ## Webhooks
909
+ - Event ids on create come from \`GET /webhooks/events/list\` (ObjectIds), not free-form strings.
910
+ - Delete: \`PUT /webhooks\` + \`webhookIds[]\`. Logs: \`GET /webhooks/logs\`; attempts under \`/logs/:id/attempts\`.
911
+ - Auth configs (\`/webhook-auths\`) are separate from webhook URL rows.
912
+
913
+ ## Forms
914
+ - Three surfaces: agency \`/forms\`, share \`/forms/share/:formId\` + \`/forms/public/:shareToken/respond\`, and \`/public-forms\`.
915
+ - Agency submit: \`POST /forms/respond\` (and \`respond-update\`).
916
+ - \`GET /forms/:formId\` auth may be open — treat payload as potentially public-facing.
917
+
918
+ ## System
919
+ - Notifications \`GET /notifications\` = **caller inbox only**, not company-wide.
920
+ - Custom fields types: \`task|client|lead\`; mutate often admin/superAdmin only; values via \`/custom-fields/respond\` with \`resourceId\`.
921
+ - Roles: permission **camelCase names** from \`GET /roles/permissions\` (\`createOrder\`, \`getInvoice\`, \`addNewMemberToCompany\`, …).
922
+ - Email templates \`/email-templates\` ≠ builder \`/email-template-builder\`.
923
+ - \`/order-subscriptions\` = client recurring orders. \`/subscriptions\` = Agency Handy **SaaS** plan for the workspace — never mix them.
924
+
925
+ ## Highest-risk misunderstandings
926
+ 1. \`/projects\` vs \`/orders\`
927
+ 2. Proposal vs \`/quotations\`
928
+ 3. Board ticket vs legacy \`/tickets\`
929
+ 4. Status **name on write** / **ObjectId on list**
930
+ 5. OpenAPI subtask/reopen routes not mounted
931
+ 6. \`PUT /accounts/update\` without \`accountId\`
932
+ 7. Permission camelCase names → 403 "security"
933
+ 8. PUT-as-delete
934
+ 9. \`/subscriptions\` vs \`/order-subscriptions\`
935
+ 10. Leads sharing \`/members\` without \`isConvertedClient=false\`
936
+ `;
937
+
806
938
  const CONTENT = {
807
939
  "ah://glossary": GLOSSARY,
808
940
  "ah://models": MODELS,
@@ -813,6 +945,7 @@ const CONTENT = {
813
945
  "ah://tool-guide": TOOL_GUIDE,
814
946
  "ah://modules": MODULES,
815
947
  "ah://response-shape": RESPONSE_SHAPE,
948
+ "ah://api-context": API_CONTEXT,
816
949
  };
817
950
 
818
951
  export function readResource(uri) {
@@ -837,6 +970,8 @@ export function readResource(uri) {
837
970
  MODULES,
838
971
  "---",
839
972
  RESPONSE_SHAPE,
973
+ "---",
974
+ API_CONTEXT,
840
975
  ].join("\n");
841
976
  return md(uri, text);
842
977
  }
package/src/server.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  import {
17
17
  VERSION,
18
18
  asText,
19
+ assertSafeBackendUrl,
19
20
  createApi,
20
21
  createRateLimiter,
21
22
  logEvent,
@@ -56,6 +57,14 @@ if (!envResult.success) {
56
57
  }
57
58
 
58
59
  const env = envResult.data;
60
+ const urlCheck = assertSafeBackendUrl(env.AGENCY_BACKEND_URL);
61
+ if (!urlCheck.ok) {
62
+ logEvent("error", "backend_url_invalid", { error: urlCheck.error });
63
+ process.stderr.write(`[agencyhandy-mcp] ${urlCheck.error}\n`);
64
+ process.exit(1);
65
+ }
66
+ env.AGENCY_BACKEND_URL = urlCheck.url;
67
+
59
68
  const strictKey =
60
69
  env.AGENCY_MCP_STRICT_API_KEY === "1" ||
61
70
  env.NODE_ENV === "production";
package/src/tools.js CHANGED
@@ -761,6 +761,7 @@ export async function handleTool(name, args, ctx) {
761
761
  "ah://fields",
762
762
  "ah://workflows",
763
763
  "ah://workflows-platform",
764
+ "ah://api-context",
764
765
  "ah://usage",
765
766
  "ah://tool-guide",
766
767
  "ah://modules",