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 +2 -0
- package/README.md +7 -6
- package/package.json +7 -5
- package/src/lib.js +93 -2
- package/src/ownerInsights.js +44 -26
- package/src/prompts.js +3 -2
- package/src/resources.js +140 -5
- package/src/server.js +9 -0
- package/src/tools.js +1 -0
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
|
-
|
|
|
133
|
-
|
|
|
134
|
-
|
|
|
135
|
-
|
|
|
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.
|
|
4
|
-
"description": "Official Agency Handy MCP server — owner insights
|
|
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.
|
|
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.
|
|
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:
|
|
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, {
|
package/src/ownerInsights.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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 =
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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 =
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
-
|
|
705
|
-
-
|
|
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";
|