formbro-mcp-server 1.0.0 → 1.0.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.
Files changed (54) hide show
  1. package/README.md +386 -48
  2. package/dist/index.js +58 -14
  3. package/dist/index.js.map +1 -1
  4. package/dist/services/api-client.d.ts +7 -0
  5. package/dist/services/api-client.d.ts.map +1 -1
  6. package/dist/services/api-client.js +113 -2
  7. package/dist/services/api-client.js.map +1 -1
  8. package/dist/services/idempotency.d.ts +6 -0
  9. package/dist/services/idempotency.d.ts.map +1 -0
  10. package/dist/services/idempotency.js +9 -0
  11. package/dist/services/idempotency.js.map +1 -0
  12. package/dist/tools/applications-extra.d.ts +3 -0
  13. package/dist/tools/applications-extra.d.ts.map +1 -0
  14. package/dist/tools/applications-extra.js +62 -0
  15. package/dist/tools/applications-extra.js.map +1 -0
  16. package/dist/tools/audit.d.ts +3 -0
  17. package/dist/tools/audit.d.ts.map +1 -0
  18. package/dist/tools/audit.js +34 -0
  19. package/dist/tools/audit.js.map +1 -0
  20. package/dist/tools/employers.d.ts.map +1 -1
  21. package/dist/tools/employers.js +17 -29
  22. package/dist/tools/employers.js.map +1 -1
  23. package/dist/tools/export.d.ts +3 -0
  24. package/dist/tools/export.d.ts.map +1 -0
  25. package/dist/tools/export.js +247 -0
  26. package/dist/tools/export.js.map +1 -0
  27. package/dist/tools/extract.d.ts +3 -0
  28. package/dist/tools/extract.d.ts.map +1 -0
  29. package/dist/tools/extract.js +208 -0
  30. package/dist/tools/extract.js.map +1 -0
  31. package/dist/tools/find.js +1 -1
  32. package/dist/tools/find.js.map +1 -1
  33. package/dist/tools/patch.d.ts +10 -0
  34. package/dist/tools/patch.d.ts.map +1 -0
  35. package/dist/tools/patch.js +276 -0
  36. package/dist/tools/patch.js.map +1 -0
  37. package/dist/tools/program-keys.d.ts +3 -0
  38. package/dist/tools/program-keys.d.ts.map +1 -0
  39. package/dist/tools/program-keys.js +13 -0
  40. package/dist/tools/program-keys.js.map +1 -0
  41. package/dist/tools/system.d.ts +3 -0
  42. package/dist/tools/system.d.ts.map +1 -0
  43. package/dist/tools/system.js +84 -0
  44. package/dist/tools/system.js.map +1 -0
  45. package/dist/tools/validate.d.ts +3 -0
  46. package/dist/tools/validate.d.ts.map +1 -0
  47. package/dist/tools/validate.js +68 -0
  48. package/dist/tools/validate.js.map +1 -0
  49. package/dist/tools/write.d.ts +4 -0
  50. package/dist/tools/write.d.ts.map +1 -0
  51. package/dist/tools/write.js +786 -0
  52. package/dist/tools/write.js.map +1 -0
  53. package/package.json +7 -3
  54. package/server.json +67 -0
@@ -0,0 +1,786 @@
1
+ import { z } from "zod";
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { makeApiRequest, handleApiError } from "../services/api-client.js";
7
+ import { getProgramInfo, buildApplicationPatchUrl } from "./patch.js";
8
+ // ============================================================================
9
+ // Input Schemas
10
+ // ============================================================================
11
+ const QuickCreatePersonSchema = z.object({
12
+ program_key: z.string().describe("Program key: TR programs use any key (dispatches to /api/tr/applicants/quick-create). " +
13
+ "PR programs: pgp, sps, general, express-entry, renewal, caregiver. " +
14
+ "LMIA streams: hws, lws, ee. Call describe_program first if unsure."),
15
+ role: z.enum(["applicant", "spouse", "dependent", "sponsor", "co_signer", "employer"]).describe("Person role within the program."),
16
+ first_name: z.string().optional().describe("Person's first name (required unless role=employer)."),
17
+ last_name: z.string().optional().describe("Person's last name."),
18
+ email: z.string().optional().describe("Email address."),
19
+ company_name: z.string().optional().describe("Company name (required when role=employer)."),
20
+ }).strict();
21
+ const QuickCreateEmployerSchema = z.object({
22
+ company_name: z.string().describe("Legal company name (required)."),
23
+ business_number: z.string().optional().describe("CRA business number."),
24
+ contact_first_name: z.string().optional().describe("Primary contact first name. Requires contact_email, contact_phone, and contact_position to be set together."),
25
+ contact_last_name: z.string().optional().describe("Primary contact last name."),
26
+ contact_email: z.string().optional().describe("Primary contact email (required if any contact_* field is set)."),
27
+ contact_phone: z.string().optional().describe("Primary contact phone (required if any contact_* field is set)."),
28
+ contact_position: z.string().optional().describe("Primary contact job title (required if any contact_* field is set)."),
29
+ }).strict();
30
+ const StartApplicationSchema = z.object({
31
+ program_key: z.string().describe("Program key identifying the program type. Call describe_program first if unsure."),
32
+ applicant_id: z.string().optional().describe("Applicant ID to associate with the application."),
33
+ }).strict();
34
+ const AttachPersonSchema = z.object({
35
+ application_id: z.string().describe("Application MongoDB ObjectId."),
36
+ role: z.string().describe("Role to assign (applicant|spouse|dependent|sponsor|co_signer|employer|rcic)."),
37
+ person_id: z.string().describe("Person/employer MongoDB ObjectId to attach."),
38
+ }).strict();
39
+ const ReplacePersonSchema = z.object({
40
+ application_id: z.string().describe("Application MongoDB ObjectId."),
41
+ role: z.string().describe("Role to replace (applicant|spouse|sponsor|co_signer|employer|rcic)."),
42
+ new_person_id: z.string().describe("New person/employer MongoDB ObjectId."),
43
+ }).strict();
44
+ const RemovePersonSchema = z.object({
45
+ application_id: z.string().describe("Application MongoDB ObjectId."),
46
+ role: z.string().describe("Role to remove (spouse|dependent|co_signer|rcic). Required roles cannot be removed."),
47
+ person_id: z.string().describe("Person ID to remove. Required for all roles — the backend DELETE route requires it in the URL path. " +
48
+ "For spouse/rcic/co_signer, pass the current person's ID (fetch from get_application_persons). " +
49
+ "For dependent, this identifies which dependent to remove."),
50
+ }).strict();
51
+ const VALID_UPLOAD_ENTITY_TYPES = [
52
+ "applicant", "tr_applicant", "pr_applicant",
53
+ "employer", "lmia_employer",
54
+ "lmia_hws_application", "lmia_lws_application", "lmia_ee_application",
55
+ "sponsor_sps", "sponsor_pgp", "co_signer", "lmia_applicant", "lmia_position",
56
+ ];
57
+ const RequestUploadSlotsSchema = z.object({
58
+ entity_id: z.string().describe("Entity MongoDB ObjectId."),
59
+ entity_type: z.enum(VALID_UPLOAD_ENTITY_TYPES).describe("Entity type for upload slot lookup. Valid values: applicant (TR/PR persons), " +
60
+ "employer (LMIA employers), lmia_hws_application / lmia_lws_application / lmia_ee_application (LMIA apps). " +
61
+ "Generic 'application' is NOT valid — use the specific LMIA application type instead."),
62
+ }).strict();
63
+ const CheckWebformRuntimeSchema = z.object({}).strict();
64
+ const CheckWebformPreflightSchema = z.object({
65
+ application_id: z.string().describe("Application MongoDB ObjectId."),
66
+ program_key: z.string().describe("Program key (e.g. sp-out, pgp, hws). Used to route to correct endpoint."),
67
+ case_id: z.string().optional().describe("Existing portal case ID for PR Renewal validation, e.g. hardcoded PR Card case 4351370."),
68
+ }).strict();
69
+ const StartWebformFillSchema = z.object({
70
+ application_id: z.string().describe("Application MongoDB ObjectId."),
71
+ program_key: z.string().describe("Program key to compute local webform actions."),
72
+ case_id: z.string().optional().describe("Existing portal case ID for PR Renewal validation. Use hardcoded bug-report-manager PR Card case 4351370 when validating PR Renewal."),
73
+ confirmed: z.boolean().describe("Must be true to trigger the fill. Set to false to get a dry-run description."),
74
+ headless: z.boolean().default(true).describe("Launch the local browser headless by default. Set false for local headed debugging."),
75
+ }).strict();
76
+ const GetWebformTaskStatusSchema = z.object({
77
+ application_id: z.string().describe("Application MongoDB ObjectId."),
78
+ program_key: z.string().describe("Program key to route to the correct status endpoint."),
79
+ }).strict();
80
+ const ChangeApplicationStatusSchema = z.object({
81
+ application_id: z.string().describe("Application MongoDB ObjectId."),
82
+ program_key: z.string().describe("Program key to route to the correct PATCH endpoint."),
83
+ new_status: z.enum([
84
+ "draft",
85
+ "in_progress",
86
+ "submitted",
87
+ "in_review",
88
+ "approved",
89
+ "refused",
90
+ "withdrawn",
91
+ "cancelled",
92
+ "completed",
93
+ "archived",
94
+ ]).describe("New status value. 'archived' soft-deletes the application via the backend DELETE endpoint."),
95
+ expected_version: z.number().optional().describe("Optimistic locking version; include to prevent concurrent edits."),
96
+ }).strict();
97
+ // ============================================================================
98
+ // Helpers
99
+ // ============================================================================
100
+ async function getProgramCategory(program_key) {
101
+ const info = await getProgramInfo(program_key);
102
+ return info?.category ?? null;
103
+ }
104
+ export function buildStartApplicationBody(applicant_id) {
105
+ if (!applicant_id)
106
+ return {};
107
+ return { case: { applicant_id } };
108
+ }
109
+ function isRecord(value) {
110
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
111
+ }
112
+ function extractValidationFields(source) {
113
+ const fields = new Set();
114
+ function visit(value) {
115
+ if (Array.isArray(value)) {
116
+ for (const item of value)
117
+ visit(item);
118
+ return;
119
+ }
120
+ if (!isRecord(value))
121
+ return;
122
+ const field = value.field;
123
+ if (typeof field === "string" && field.trim()) {
124
+ fields.add(field.trim());
125
+ }
126
+ const path = value.path;
127
+ if (Array.isArray(path) && path.length > 0) {
128
+ fields.add(path.map(String).join("."));
129
+ }
130
+ for (const child of Object.values(value)) {
131
+ if (isRecord(child) || Array.isArray(child))
132
+ visit(child);
133
+ }
134
+ }
135
+ visit(source);
136
+ const text = typeof source === "string" ? source : JSON.stringify(source ?? "");
137
+ for (const match of text.matchAll(/"field"\s*:\s*"([^"]+)"/g)) {
138
+ if (match[1]?.trim())
139
+ fields.add(match[1].trim());
140
+ }
141
+ for (const match of text.matchAll(/"path"\s*:\s*\[([^\]]+)\]/g)) {
142
+ const path = match[1]
143
+ .split(",")
144
+ .map((part) => part.trim().replace(/^"|"$/g, ""))
145
+ .filter(Boolean)
146
+ .join(".");
147
+ if (path)
148
+ fields.add(path);
149
+ }
150
+ return [...fields];
151
+ }
152
+ function explainWebformIssue(source) {
153
+ const text = typeof source === "string" ? source : JSON.stringify(source ?? "");
154
+ const normalized = text.toLowerCase();
155
+ const validationFields = extractValidationFields(source);
156
+ if (normalized.includes("cannot find module") && normalized.includes("playwright") ||
157
+ normalized.includes("no module named") && normalized.includes("playwright")) {
158
+ return {
159
+ reason: "The local runtime is missing Playwright.",
160
+ recommended_action: "Run `cd desktop && npm install && npx playwright install chromium`, then retry.",
161
+ };
162
+ }
163
+ if (normalized.includes("executable doesn't exist") || normalized.includes("playwright install")) {
164
+ return {
165
+ reason: "The local runtime cannot find Chromium for Playwright.",
166
+ recommended_action: "Run `cd desktop && npx playwright install chromium`, then retry.",
167
+ };
168
+ }
169
+ if (normalized.includes("host system is missing dependencies")) {
170
+ return {
171
+ reason: "Chromium is present but required OS libraries are missing.",
172
+ recommended_action: "Install Playwright system dependencies on this machine, then retry.",
173
+ };
174
+ }
175
+ if (normalized.includes("timeout")) {
176
+ return {
177
+ reason: "The browser timed out while waiting for a portal page, action, or selector.",
178
+ recommended_action: "Check portal reachability, login state/IP allowlisting, and whether the filler actions still match the portal UI.",
179
+ };
180
+ }
181
+ if (normalized.includes("selector") || normalized.includes("locator") || normalized.includes("strict mode violation")) {
182
+ return {
183
+ reason: "A webform action could not find or uniquely resolve the expected portal element.",
184
+ recommended_action: "Review the failing action and update the fillergraph only after confirming the portal UI changed.",
185
+ };
186
+ }
187
+ if (normalized.includes("credential") || normalized.includes("sign-in") || normalized.includes("login")) {
188
+ return {
189
+ reason: "The portal login step likely failed or the application is missing portal credentials.",
190
+ recommended_action: "Verify the account/password fields and run preflight again before retrying.",
191
+ };
192
+ }
193
+ if (validationFields.length > 0 ||
194
+ normalized.includes("validation") ||
195
+ normalized.includes("missing_fields") ||
196
+ normalized.includes("preflight_failed") ||
197
+ normalized.includes("invalid request")) {
198
+ const fieldList = validationFields.slice(0, 10).join(", ");
199
+ return {
200
+ reason: fieldList
201
+ ? `The application data failed backend validation. Missing or invalid fields include: ${fieldList}.`
202
+ : "The application data is incomplete or failed preflight validation.",
203
+ recommended_action: "Fix the reported missing or invalid fields in FormBro, then rerun check_webform_preflight before live filling.",
204
+ };
205
+ }
206
+ return {
207
+ reason: "The webform failure could not be classified from the MCP response alone.",
208
+ recommended_action: "Inspect the raw error/status payload and backend logs before retrying live filling.",
209
+ };
210
+ }
211
+ function formatWebformPayload(data) {
212
+ if (!isRecord(data)) {
213
+ return JSON.stringify(data, null, 2);
214
+ }
215
+ const issueSource = data.filling_error ??
216
+ data.error ??
217
+ data.message ??
218
+ data.raw_error ??
219
+ (isRecord(data.validation) && data.validation.valid === false ? data.validation : undefined) ??
220
+ (Array.isArray(data.validation_fields) && data.validation_fields.length > 0 ? data.validation_fields : undefined) ??
221
+ (data.ready === false ? data : undefined);
222
+ if (!issueSource) {
223
+ return JSON.stringify(data, null, 2);
224
+ }
225
+ return JSON.stringify({
226
+ ...data,
227
+ agent_explanation: explainWebformIssue(issueSource),
228
+ }, null, 2);
229
+ }
230
+ function webformErrorResult(error) {
231
+ const raw_error = handleApiError(error);
232
+ const validation_fields = extractValidationFields(raw_error);
233
+ return {
234
+ isError: true,
235
+ content: [
236
+ {
237
+ type: "text",
238
+ text: JSON.stringify({
239
+ raw_error,
240
+ ...(validation_fields.length > 0 ? { validation_fields } : {}),
241
+ agent_explanation: explainWebformIssue(raw_error),
242
+ }, null, 2),
243
+ },
244
+ ],
245
+ };
246
+ }
247
+ /**
248
+ * Derive the compute-actions and status URL segment for a program_key.
249
+ * Returns { computeUrl, statusUrl } or null if the category cannot be determined.
250
+ *
251
+ * TR → /api/form-filling/tr/{program}/applications/{app_id}/compute-actions
252
+ * (TR has no status endpoint — returns filling_status from the application itself)
253
+ * PR → /api/form-filling/pr/{program_key}/applications/{app_id}/compute-actions
254
+ * status: /api/form-filling/pr/{program_key}/applications/{app_id}/filling-status
255
+ * LMIA→ /api/form-filling/lmia/{stream}/cases/{case_id}/compute-actions
256
+ * status: /api/form-filling/lmia/{stream}/cases/{case_id}/filling-status
257
+ */
258
+ function buildFormFillingUrls(category, program_key, application_id) {
259
+ const cat = category.toUpperCase();
260
+ if (cat === "TR") {
261
+ const program = trWebformProgram(program_key);
262
+ return {
263
+ computeUrl: `/api/form-filling/tr/${encodeURIComponent(program)}/applications/${encodeURIComponent(application_id)}/compute-actions`,
264
+ statusUrl: null, // TR has no dedicated status endpoint
265
+ };
266
+ }
267
+ else if (cat === "PR") {
268
+ return {
269
+ computeUrl: `/api/form-filling/pr/${encodeURIComponent(program_key)}/applications/${encodeURIComponent(application_id)}/compute-actions`,
270
+ statusUrl: `/api/form-filling/pr/${encodeURIComponent(program_key)}/applications/${encodeURIComponent(application_id)}/filling-status`,
271
+ };
272
+ }
273
+ else if (cat === "LMIA") {
274
+ return {
275
+ computeUrl: `/api/form-filling/lmia/${encodeURIComponent(program_key)}/cases/${encodeURIComponent(application_id)}/compute-actions`,
276
+ statusUrl: `/api/form-filling/lmia/${encodeURIComponent(program_key)}/cases/${encodeURIComponent(application_id)}/filling-status`,
277
+ };
278
+ }
279
+ return {
280
+ computeUrl: `/api/form-filling/${cat.toLowerCase()}/${encodeURIComponent(program_key)}/applications/${encodeURIComponent(application_id)}/compute-actions`,
281
+ statusUrl: null,
282
+ };
283
+ }
284
+ function trWebformProgram(program) {
285
+ switch (program) {
286
+ case "sp-out":
287
+ case "sp-in":
288
+ case "study-permit-out":
289
+ case "study-permit-in":
290
+ return "study-permit";
291
+ case "wp-out":
292
+ case "wp-in":
293
+ case "work-permit-out":
294
+ case "work-permit-in":
295
+ return "work-permit";
296
+ case "visa-out":
297
+ case "visa-in":
298
+ case "visitor-visa-out":
299
+ case "visitor-visa-in":
300
+ return "visitor-visa";
301
+ default:
302
+ return program;
303
+ }
304
+ }
305
+ function findLocalWebformRunner() {
306
+ const envPath = process.env.FORMBRO_WEBFORM_RUNNER;
307
+ if (envPath && existsSync(envPath))
308
+ return envPath;
309
+ const here = path.dirname(fileURLToPath(import.meta.url));
310
+ const candidates = [
311
+ path.resolve(process.cwd(), "scripts/local-webform-runner.cjs"),
312
+ path.resolve(process.cwd(), "../scripts/local-webform-runner.cjs"),
313
+ path.resolve(here, "../../../scripts/local-webform-runner.cjs"),
314
+ ];
315
+ const found = candidates.find((candidate) => existsSync(candidate));
316
+ if (!found) {
317
+ throw new Error("Local webform runner not found. Set FORMBRO_WEBFORM_RUNNER to scripts/local-webform-runner.cjs.");
318
+ }
319
+ return found;
320
+ }
321
+ function runLocalWebformRunner(args, input) {
322
+ return new Promise((resolve, reject) => {
323
+ const child = spawn("node", [findLocalWebformRunner(), ...args], {
324
+ stdio: ["pipe", "pipe", "pipe"],
325
+ });
326
+ let stdout = "";
327
+ let stderr = "";
328
+ child.stdout.on("data", (chunk) => {
329
+ stdout += String(chunk);
330
+ });
331
+ child.stderr.on("data", (chunk) => {
332
+ stderr += String(chunk);
333
+ });
334
+ child.on("error", reject);
335
+ child.on("close", (code) => {
336
+ let parsed = null;
337
+ try {
338
+ parsed = stdout.trim() ? JSON.parse(stdout) : null;
339
+ }
340
+ catch (error) {
341
+ reject(new Error(`Local webform runner returned invalid JSON: ${String(error)}. stderr=${stderr}`));
342
+ return;
343
+ }
344
+ if (code === 0) {
345
+ resolve(parsed);
346
+ }
347
+ else {
348
+ reject({ runner_result: parsed, stderr, exit_code: code });
349
+ }
350
+ });
351
+ if (input !== undefined) {
352
+ child.stdin.write(JSON.stringify(input));
353
+ }
354
+ child.stdin.end();
355
+ });
356
+ }
357
+ // ============================================================================
358
+ // Tool Registration
359
+ // ============================================================================
360
+ export function registerWriteTools(server) {
361
+ // 1. quick_create_person
362
+ server.registerTool("quick_create_person", {
363
+ title: "Quick Create Person",
364
+ description: "Create a minimal person record (applicant/spouse/dependent/sponsor/co_signer) or employer with just a name. " +
365
+ "Dispatches to the correct TR/PR/LMIA endpoint based on program_key. " +
366
+ "Call describe_program first to verify the supported roles for the program. " +
367
+ "Returns id, display_name, and identifier of the created entity.",
368
+ inputSchema: QuickCreatePersonSchema.shape,
369
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
370
+ }, async ({ program_key, role, first_name, last_name, email, company_name }) => {
371
+ try {
372
+ const info = await getProgramInfo(program_key);
373
+ const category = info?.category ?? null;
374
+ if (role === "employer") {
375
+ if (category !== "LMIA") {
376
+ return {
377
+ isError: true,
378
+ content: [{ type: "text", text: "Error: role=employer is only supported for LMIA program keys (hws, lws, ee)." }],
379
+ };
380
+ }
381
+ if (!company_name) {
382
+ return {
383
+ isError: true,
384
+ content: [{ type: "text", text: "Error: company_name is required when role=employer." }],
385
+ };
386
+ }
387
+ const employerBody = {
388
+ general: {
389
+ legal_name: company_name,
390
+ ...(email ? { recruit_email: email } : {}),
391
+ },
392
+ };
393
+ const data = await makeApiRequest("/api/lmia/employers", "POST", employerBody);
394
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
395
+ }
396
+ if (!first_name) {
397
+ return {
398
+ isError: true,
399
+ content: [{ type: "text", text: "Error: first_name is required unless role=employer." }],
400
+ };
401
+ }
402
+ const body = { role, first_name };
403
+ if (last_name)
404
+ body.last_name = last_name;
405
+ if (email)
406
+ body.email = email;
407
+ if (company_name)
408
+ body.company_name = company_name;
409
+ let url;
410
+ if (category === "TR") {
411
+ url = "/api/tr/applicants/quick-create";
412
+ }
413
+ else if (category === "PR" && info?.api_endpoint) {
414
+ url = `${info.api_endpoint}/persons/quick-create`;
415
+ }
416
+ else if (category === "LMIA" && info?.api_endpoint) {
417
+ url = `${info.api_endpoint}/persons/quick-create`;
418
+ }
419
+ else {
420
+ return {
421
+ isError: true,
422
+ content: [{ type: "text", text: `Error: Unknown program_key '${program_key}'. Use list_programs to find valid program keys.` }],
423
+ };
424
+ }
425
+ const data = await makeApiRequest(url, "POST", body);
426
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
427
+ }
428
+ catch (error) {
429
+ return { isError: true, content: [{ type: "text", text: handleApiError(error) }] };
430
+ }
431
+ });
432
+ // 2. quick_create_employer
433
+ server.registerTool("quick_create_employer", {
434
+ title: "Quick Create LMIA Employer",
435
+ description: "Create a minimal LMIA employer record with company name and optional contact details. " +
436
+ "Employers are shared across all LMIA streams (hws/lws/ee). " +
437
+ "Returns the created employer with id and company details.",
438
+ inputSchema: QuickCreateEmployerSchema.shape,
439
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
440
+ }, async ({ company_name, business_number, contact_first_name, contact_last_name, contact_email, contact_phone, contact_position }) => {
441
+ try {
442
+ // Build a minimal LMIAEmployer payload
443
+ const body = {
444
+ general: {
445
+ legal_name: company_name,
446
+ },
447
+ };
448
+ if (business_number) {
449
+ body.general.business_number = business_number;
450
+ }
451
+ // Only include contacts if all required fields are provided (backend strict validation)
452
+ if (contact_first_name && contact_email && contact_phone && contact_position) {
453
+ body.contacts = {
454
+ primary_contact: {
455
+ first_name: contact_first_name,
456
+ last_name: contact_last_name ?? undefined,
457
+ email: contact_email,
458
+ phone: contact_phone,
459
+ position: contact_position,
460
+ },
461
+ };
462
+ }
463
+ const data = await makeApiRequest("/api/lmia/employers", "POST", body);
464
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
465
+ }
466
+ catch (error) {
467
+ return { isError: true, content: [{ type: "text", text: handleApiError(error) }] };
468
+ }
469
+ });
470
+ // 3. start_application
471
+ server.registerTool("start_application", {
472
+ title: "Start Application",
473
+ description: "Create a new immigration application for a given program_key. " +
474
+ "Dispatches to TR, PR, or LMIA endpoint based on program category. " +
475
+ "Optionally link an applicant_id at creation time. " +
476
+ "Returns the created application object.",
477
+ inputSchema: StartApplicationSchema.shape,
478
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
479
+ }, async ({ program_key, applicant_id }) => {
480
+ try {
481
+ const info = await getProgramInfo(program_key);
482
+ if (!info) {
483
+ return {
484
+ isError: true,
485
+ content: [{ type: "text", text: `Error: Unknown program_key '${program_key}'. Use list_programs to find valid program keys.` }],
486
+ };
487
+ }
488
+ const body = buildStartApplicationBody(applicant_id);
489
+ // TR/PR create at api_endpoint root; LMIA stream APIs nest applications under /applications.
490
+ const createUrl = info.category === "LMIA" ? `${info.api_endpoint}/applications` : info.api_endpoint;
491
+ const data = await makeApiRequest(createUrl, "POST", body);
492
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
493
+ }
494
+ catch (error) {
495
+ return { isError: true, content: [{ type: "text", text: handleApiError(error) }] };
496
+ }
497
+ });
498
+ // 4. attach_person_to_application
499
+ server.registerTool("attach_person_to_application", {
500
+ title: "Attach Person to Application",
501
+ description: "Add a person to a multi-person role in an application (e.g., add a dependent). " +
502
+ "For single-value roles (applicant, spouse, sponsor), use replace_person instead. " +
503
+ "Currently only 'dependent' role supports the add operation.",
504
+ inputSchema: AttachPersonSchema.shape,
505
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
506
+ }, async ({ application_id, role, person_id }) => {
507
+ try {
508
+ const url = `/api/applications/${encodeURIComponent(application_id)}/persons/${encodeURIComponent(role)}`;
509
+ const data = await makeApiRequest(url, "POST", { person_id });
510
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
511
+ }
512
+ catch (error) {
513
+ return { isError: true, content: [{ type: "text", text: handleApiError(error) }] };
514
+ }
515
+ });
516
+ // 5. replace_person
517
+ server.registerTool("replace_person", {
518
+ title: "Replace Person in Application",
519
+ description: "Replace the person for a specific role in an application. " +
520
+ "Valid roles: applicant, spouse, sponsor, co_signer, employer, rcic. " +
521
+ "Use this to change which applicant/sponsor/employer is linked to an application.",
522
+ inputSchema: ReplacePersonSchema.shape,
523
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
524
+ }, async ({ application_id, role, new_person_id }) => {
525
+ try {
526
+ const url = `/api/applications/${encodeURIComponent(application_id)}/persons/${encodeURIComponent(role)}`;
527
+ const data = await makeApiRequest(url, "PATCH", { person_id: new_person_id });
528
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
529
+ }
530
+ catch (error) {
531
+ return { isError: true, content: [{ type: "text", text: handleApiError(error) }] };
532
+ }
533
+ });
534
+ // 6. remove_person
535
+ server.registerTool("remove_person", {
536
+ title: "Remove Person from Application",
537
+ description: "Remove an optional person from an application. " +
538
+ "Only optional roles can be removed: spouse, dependent, co_signer, rcic. " +
539
+ "person_id is always required (use get_application_persons to find current IDs). " +
540
+ "Required roles (applicant, employer, sponsor) cannot be removed.",
541
+ inputSchema: RemovePersonSchema.shape,
542
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
543
+ }, async ({ application_id, role, person_id }) => {
544
+ try {
545
+ const personIdSegment = person_id ? `/${encodeURIComponent(person_id)}` : "";
546
+ const url = `/api/applications/${encodeURIComponent(application_id)}/persons/${encodeURIComponent(role)}${personIdSegment}`;
547
+ await makeApiRequest(url, "DELETE");
548
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, message: `Removed ${role} from application ${application_id}` }, null, 2) }] };
549
+ }
550
+ catch (error) {
551
+ return { isError: true, content: [{ type: "text", text: handleApiError(error) }] };
552
+ }
553
+ });
554
+ // 7. request_upload_slots
555
+ server.registerTool("request_upload_slots", {
556
+ title: "Get Upload Slots for Entity",
557
+ description: "Returns information about document upload slots available for an entity. " +
558
+ "entity_type must be one of: applicant/tr_applicant/pr_applicant (for persons), " +
559
+ "employer/lmia_employer (for employers), or lmia_hws_application/lmia_lws_application/lmia_ee_application (for LMIA apps). " +
560
+ "The response shows which document slots exist and their current upload status.",
561
+ inputSchema: RequestUploadSlotsSchema.shape,
562
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
563
+ }, async ({ entity_id, entity_type }) => {
564
+ try {
565
+ const url = `/api/uploads/${encodeURIComponent(entity_type)}/${encodeURIComponent(entity_id)}`;
566
+ const data = await makeApiRequest(url, "GET");
567
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
568
+ }
569
+ catch (error) {
570
+ return { isError: true, content: [{ type: "text", text: handleApiError(error) }] };
571
+ }
572
+ });
573
+ // 8. check_webform_runtime
574
+ server.registerTool("check_webform_runtime", {
575
+ title: "Check Local Webform Browser Runtime",
576
+ description: "Check whether this MCP host can import local Playwright and launch local Chromium before live webform filling. " +
577
+ "Use this before start_webform_fill_live. If not ready, the response explains the missing local runtime dependency.",
578
+ inputSchema: CheckWebformRuntimeSchema.shape,
579
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
580
+ }, async () => {
581
+ try {
582
+ const data = await runLocalWebformRunner(["--check"]);
583
+ return { content: [{ type: "text", text: formatWebformPayload(data) }] };
584
+ }
585
+ catch (error) {
586
+ return webformErrorResult(error);
587
+ }
588
+ });
589
+ // 9. check_webform_preflight
590
+ server.registerTool("check_webform_preflight", {
591
+ title: "Check Webform Preflight Status",
592
+ description: "Check current webform filling status and any blocking issues for an application. " +
593
+ "Returns filling_status (not_started|pending|in_progress|completed|failed|preflight_failed|validation_failed), " +
594
+ "missing_fields, and review_items. " +
595
+ "Use before start_webform_fill_live to verify the application is ready.",
596
+ inputSchema: CheckWebformPreflightSchema.shape,
597
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
598
+ }, async ({ application_id, program_key, case_id }) => {
599
+ try {
600
+ const info = await getProgramInfo(program_key);
601
+ if (!info) {
602
+ return {
603
+ isError: true,
604
+ content: [{ type: "text", text: `Error: Unknown program_key '${program_key}'.` }],
605
+ };
606
+ }
607
+ const { computeUrl } = buildFormFillingUrls(info.category, program_key, application_id);
608
+ const operation = info.category.toUpperCase() === "TR" ? "fill_webform_tr" : "fill_webform";
609
+ const entityType = info.form_ids?.application;
610
+ let validationData = null;
611
+ if (entityType) {
612
+ validationData = await makeApiRequest("/api/validate/operation", "POST", {
613
+ entity_id: application_id,
614
+ entity_type: entityType,
615
+ operation,
616
+ });
617
+ }
618
+ const computeData = await makeApiRequest(computeUrl, "POST", case_id ? { case_id } : {});
619
+ const actions = isRecord(computeData) ? computeData.actions : undefined;
620
+ const validationFields = extractValidationFields(validationData);
621
+ return {
622
+ content: [{
623
+ type: "text",
624
+ text: formatWebformPayload({
625
+ status: "ready",
626
+ execution_model: "local_playwright_compute_actions",
627
+ action_count: Array.isArray(actions) ? actions.length : null,
628
+ case_id,
629
+ validation: validationData,
630
+ compute_result: computeData,
631
+ ...(validationFields.length > 0 ? { validation_fields: validationFields } : {}),
632
+ }),
633
+ }],
634
+ };
635
+ }
636
+ catch (error) {
637
+ return webformErrorResult(error);
638
+ }
639
+ });
640
+ // 10. start_webform_fill_live
641
+ server.registerTool("start_webform_fill_live", {
642
+ title: "Start Webform Fill (Live)",
643
+ description: "Trigger live webform filling for an application. DESTRUCTIVE: this computes actions from FormBro API " +
644
+ "and executes them with local Playwright/Chromium on this MCP host. It does not use server-side backend Playwright. " +
645
+ "You MUST set confirmed=true to proceed. Call check_webform_runtime and check_webform_preflight first.",
646
+ inputSchema: StartWebformFillSchema.shape,
647
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
648
+ }, async ({ application_id, program_key, case_id, confirmed, headless }) => {
649
+ try {
650
+ if (!confirmed) {
651
+ return {
652
+ content: [
653
+ {
654
+ type: "text",
655
+ text: JSON.stringify({
656
+ dry_run: true,
657
+ message: "Webform fill NOT started. Set confirmed=true to trigger the actual fill.",
658
+ next_required_checks: ["check_webform_runtime", "check_webform_preflight"],
659
+ execution_model: "local_playwright_compute_actions",
660
+ program_key,
661
+ application_id,
662
+ case_id,
663
+ }, null, 2),
664
+ },
665
+ ],
666
+ };
667
+ }
668
+ const category = await getProgramCategory(program_key);
669
+ if (!category) {
670
+ return {
671
+ isError: true,
672
+ content: [{ type: "text", text: `Error: Unknown program_key '${program_key}'.` }],
673
+ };
674
+ }
675
+ const runtime = await runLocalWebformRunner(["--check"]);
676
+ if (isRecord(runtime) && runtime.ready === false) {
677
+ return {
678
+ isError: true,
679
+ content: [{ type: "text", text: formatWebformPayload(runtime) }],
680
+ };
681
+ }
682
+ const { computeUrl } = buildFormFillingUrls(category, program_key, application_id);
683
+ const actionData = await makeApiRequest(computeUrl, "POST", case_id ? { case_id } : {});
684
+ const actions = isRecord(actionData) ? actionData.actions : undefined;
685
+ if (!Array.isArray(actions)) {
686
+ return {
687
+ isError: true,
688
+ content: [{
689
+ type: "text",
690
+ text: formatWebformPayload({
691
+ error: "compute-actions did not return an actions array",
692
+ compute_result: actionData,
693
+ }),
694
+ }],
695
+ };
696
+ }
697
+ const data = await runLocalWebformRunner([], {
698
+ actions,
699
+ headless: headless ?? true,
700
+ timeout_ms: 120000,
701
+ program_type: category,
702
+ program_key,
703
+ application_id,
704
+ case_id,
705
+ });
706
+ return { content: [{ type: "text", text: formatWebformPayload(data) }] };
707
+ }
708
+ catch (error) {
709
+ return webformErrorResult(error);
710
+ }
711
+ });
712
+ // 11. get_webform_task_status
713
+ server.registerTool("get_webform_task_status", {
714
+ title: "Get Webform Task Status",
715
+ description: "Poll the current status of a running or completed webform fill task. " +
716
+ "Returns filling_status, progress, error details, and screenshot path if failed. " +
717
+ "Call repeatedly until status is 'completed' or 'failed'.",
718
+ inputSchema: GetWebformTaskStatusSchema.shape,
719
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
720
+ }, async ({ application_id, program_key }) => {
721
+ try {
722
+ const category = await getProgramCategory(program_key);
723
+ if (!category) {
724
+ return {
725
+ isError: true,
726
+ content: [{ type: "text", text: `Error: Unknown program_key '${program_key}'.` }],
727
+ };
728
+ }
729
+ return {
730
+ content: [{
731
+ type: "text",
732
+ text: formatWebformPayload({
733
+ status: "not_applicable",
734
+ execution_model: "local_playwright_sync",
735
+ program_key,
736
+ application_id,
737
+ message: "Local MCP webform filling runs synchronously on this machine and does not create a backend Playwright task to poll.",
738
+ }),
739
+ }],
740
+ };
741
+ }
742
+ catch (error) {
743
+ return webformErrorResult(error);
744
+ }
745
+ });
746
+ // 12. change_application_status
747
+ server.registerTool("change_application_status", {
748
+ title: "Change Application Status",
749
+ description: "Update the status of an application. Passing 'archived' soft-deletes the application. " +
750
+ "Optionally provide expected_version for optimistic locking to prevent concurrent edits. " +
751
+ "Use the unified applications PATCH endpoint.",
752
+ inputSchema: ChangeApplicationStatusSchema.shape,
753
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
754
+ }, async ({ application_id, program_key, new_status, expected_version }) => {
755
+ try {
756
+ const progInfo = await getProgramInfo(program_key);
757
+ if (!progInfo) {
758
+ return {
759
+ isError: true,
760
+ content: [{ type: "text", text: `Error: Unknown program_key '${program_key}'. Use list_programs to find valid keys.` }],
761
+ };
762
+ }
763
+ const params = { skip_validation: true };
764
+ if (expected_version !== undefined) {
765
+ params.if_match = expected_version;
766
+ }
767
+ const url = buildApplicationPatchUrl(progInfo.api_endpoint, progInfo.category, application_id);
768
+ if (new_status === "archived") {
769
+ await makeApiRequest(url, "DELETE", undefined, params);
770
+ return {
771
+ content: [{
772
+ type: "text",
773
+ text: JSON.stringify({ success: true, archived: true, application_id }, null, 2),
774
+ }],
775
+ };
776
+ }
777
+ // Status is nested under case.status in all program types
778
+ const data = await makeApiRequest(url, "PATCH", { case: { status: new_status } }, params);
779
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
780
+ }
781
+ catch (error) {
782
+ return { isError: true, content: [{ type: "text", text: handleApiError(error) }] };
783
+ }
784
+ });
785
+ }
786
+ //# sourceMappingURL=write.js.map