@tasksai/install 0.1.8 → 0.1.10

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/README.md CHANGED
@@ -5,7 +5,7 @@ Official installer CLI for TasksAI MCP verticals.
5
5
  Users normally run this through a product-specific GitHub manifest, for example:
6
6
 
7
7
  ```bash
8
- npx @tasksai/install lawtasksai --source https://github.com/laudoluxDev/lawtasksai-mcp
8
+ npm exec --package=@tasksai/install --call 'tasksai-install lawtasksai --source https://github.com/laudoluxDev/lawtasksai-mcp'
9
9
  ```
10
10
 
11
11
  In restricted agent environments, grant write access to the default TasksAI
@@ -13,9 +13,7 @@ application data folder and the selected MCP client config. If the runtime must
13
13
  be installed elsewhere, pass an exact product install directory:
14
14
 
15
15
  ```bash
16
- npx @tasksai/install farmer \
17
- --source https://github.com/laudoluxDev/farmertasksai-mcp \
18
- --install-dir /tmp/tasksai/farmer
16
+ npm exec --package=@tasksai/install --call 'tasksai-install farmer --source https://github.com/laudoluxDev/farmertasksai-mcp --install-dir /tmp/tasksai/farmer'
19
17
  ```
20
18
 
21
19
  `TASKSAI_INSTALL_DIR=/tmp/tasksai/farmer` is equivalent to `--install-dir`.
@@ -32,6 +30,27 @@ The installer:
32
30
  from writing local config files
33
31
  - supports Claude Desktop, Cursor, Windsurf, and Codex
34
32
  - runs a local health check
33
+ - exposes a local save-document tool for finished skill outputs
35
34
 
36
35
  TasksAI servers handle authentication, credits, catalog/search metadata, and
37
36
  licensed skill delivery. TasksAI does not process user task content.
37
+
38
+ ## Local document output
39
+
40
+ After a workflow is executed, the customer's AI assistant applies the expert
41
+ framework locally. When the final deliverable is ready, the assistant can call
42
+ the vertical-specific save tool, such as `lawtasksai_save_document` or
43
+ `farmertasksai_save_document`, to create a downloadable file on the customer's
44
+ machine.
45
+
46
+ By default, files are saved under:
47
+
48
+ ```text
49
+ ~/Documents/TasksAI/<ProductName>/
50
+ ```
51
+
52
+ Set `TASKSAI_OUTPUT_DIR` in the local runtime environment to use a different
53
+ folder. The save tool supports `.docx` and Markdown output, sanitizes filenames,
54
+ and avoids overwriting existing files unless explicitly told to overwrite.
55
+ Generated document content is handled by the customer's AI framework and local
56
+ MCP runtime; it is not sent to TasksAI websites or APIs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tasksai/install",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Shared TasksAI MCP installer CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "src/",
11
+ "runtime/",
11
12
  "README.md"
12
13
  ],
13
14
  "keywords": [
@@ -0,0 +1,4 @@
1
+ mcp>=1.0.0
2
+ httpx>=0.27.0
3
+ python-dotenv>=1.0.0
4
+ python-docx>=1.1.2
@@ -0,0 +1,1135 @@
1
+ """
2
+ TasksAI MCP Server — Universal Multi-Vertical Router
3
+
4
+ A single MCP server that works for all 29 TasksAI verticals.
5
+ On startup, calls GET /v1/me to detect the vertical from the license key,
6
+ then self-configures tool names, system prompt, and abbreviation maps.
7
+
8
+ Tools (names are vertical-prefixed at runtime, e.g. farmertasksai_search):
9
+ {prefix}_search — Find the right skill for your task
10
+ {prefix}_execute — Get the full expert framework for a skill (costs 1 credit)
11
+ {prefix}_save_document — Save the finished local output as a downloadable file
12
+ {prefix}_balance — Check your remaining credit balance
13
+ {prefix}_categories — Browse skills by category
14
+
15
+ Privacy: Your queries, documents, and client data never leave your machine.
16
+ Skills run entirely locally. The API only delivers skill metadata and
17
+ counts credits — it never sees what you're working on. Saved documents are
18
+ created by this local MCP server, not by TasksAI websites or APIs.
19
+ """
20
+
21
+ import os
22
+ import re
23
+ import sys
24
+ import time
25
+ import asyncio
26
+ import platform
27
+ import httpx
28
+ from datetime import datetime
29
+ from pathlib import Path
30
+
31
+ # Force UTF-8 stdout/stderr on Windows (default is CP1252 which breaks emoji)
32
+ if sys.stdout and hasattr(sys.stdout, 'reconfigure'):
33
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
34
+ if sys.stderr and hasattr(sys.stderr, 'reconfigure'):
35
+ sys.stderr.reconfigure(encoding='utf-8', errors='replace')
36
+ from dotenv import load_dotenv
37
+ from mcp.server import Server
38
+ from mcp.server.stdio import stdio_server
39
+ from mcp.types import Tool, TextContent
40
+
41
+ # ── .env resolution ──────────────────────────────────────────────────────────
42
+ # When running as a compiled binary (PyInstaller), the .env lives in the
43
+ # permanent install directory, not next to the executable.
44
+ # Search order: install dir → script/exe dir → cwd
45
+
46
+ def _find_dotenv() -> str | None:
47
+ """Return path to .env or None. Checks install dir first."""
48
+ system = platform.system()
49
+
50
+ # Determine app folder name from this binary's parent dir name,
51
+ # or fall back to checking all known vertical install dirs.
52
+ home = Path.home()
53
+ if system == "Windows":
54
+ local = Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local"))
55
+ search_bases = [local]
56
+ elif system == "Darwin":
57
+ search_bases = [home / "Library" / "Application Support"]
58
+ else:
59
+ search_bases = [home / ".local" / "share"]
60
+
61
+ # Check parent of the running binary first (most specific)
62
+ exe_dir = Path(sys.executable).parent if getattr(sys, 'frozen', False) else Path(__file__).parent
63
+ candidate = exe_dir / ".env"
64
+ if candidate.exists():
65
+ return str(candidate)
66
+
67
+ # Search known TasksAI install dirs under the base
68
+ for base in search_bases:
69
+ if not base.exists():
70
+ continue
71
+ for child in base.iterdir():
72
+ if child.is_dir() and "tasksai" in child.name.lower():
73
+ candidate = child / ".env"
74
+ if candidate.exists():
75
+ return str(candidate)
76
+
77
+ return None
78
+
79
+
80
+ _dotenv_path = _find_dotenv()
81
+ if _dotenv_path:
82
+ load_dotenv(_dotenv_path)
83
+ else:
84
+ load_dotenv() # fallback: search cwd and parent dirs
85
+
86
+ API_BASE = os.getenv("TASKSAI_API_BASE", os.getenv("LAWTASKSAI_API_BASE", "https://api.lawtasksai.com"))
87
+ LICENSE_KEY = os.getenv("TASKSAI_LICENSE_KEY", os.getenv("LAWTASKSAI_LICENSE_KEY", ""))
88
+ PRODUCT_ID = os.getenv("TASKSAI_PRODUCT_ID", "") # set by installer; used to resolve correct vertical
89
+
90
+ if not LICENSE_KEY:
91
+ print("ERROR: License key is required. Set TASKSAI_LICENSE_KEY in your .env file.", file=sys.stderr, flush=True)
92
+ print("Find your key in your purchase confirmation email.", file=sys.stderr, flush=True)
93
+ sys.exit(1)
94
+
95
+ SERVER_VERSION = "2.2.0"
96
+
97
+ AUTH_HEADERS = {
98
+ "Authorization": f"Bearer {LICENSE_KEY}",
99
+ "Content-Type": "application/json",
100
+ "X-Client-Type": "mcp-server",
101
+ "X-Client-Version": SERVER_VERSION,
102
+ }
103
+
104
+ # ── Per-vertical abbreviation maps ────────────────────────────────────────────
105
+ # Expands common shorthand before trigger-phrase matching.
106
+ # Fallback abbreviation maps used when GET /v1/abbreviations is unavailable.
107
+ # Sprint 6: DB is now the source of truth; these are a safety net only.
108
+
109
+ _ABBREVS_FALLBACK = {
110
+ "law": {
111
+ "mtc": "motion to compel",
112
+ "rogs": "interrogatories",
113
+ "rog": "interrogatory",
114
+ "rfa": "request for admission",
115
+ "rfas": "requests for admission",
116
+ "rfp": "request for production",
117
+ "rfps": "requests for production",
118
+ "tro": "temporary restraining order",
119
+ "pi": "personal injury",
120
+ "msj": "motion for summary judgment",
121
+ "msk": "motion to strike",
122
+ "sj": "summary judgment",
123
+ "jnov": "judgment notwithstanding verdict",
124
+ "mil": "motion in limine",
125
+ "sol": "statute of limitations",
126
+ "aff": "affidavit",
127
+ "decl": "declaration",
128
+ "depo": "deposition",
129
+ "deps": "depositions",
130
+ "frcp": "federal rules civil procedure",
131
+ "fre": "federal rules evidence",
132
+ "compl": "complaint",
133
+ "ans": "answer",
134
+ "roe": "rules of evidence",
135
+ "atty": "attorney",
136
+ },
137
+ "realtor": {
138
+ "mls": "multiple listing service",
139
+ "cma": "comparative market analysis",
140
+ "dom": "days on market",
141
+ "arv": "after repair value",
142
+ "hoa": "homeowners association",
143
+ "coe": "close of escrow",
144
+ "emd": "earnest money deposit",
145
+ "piti": "principal interest taxes insurance",
146
+ "ltv": "loan to value",
147
+ "nar": "national association of realtors",
148
+ "bom": "back on market",
149
+ "uc": "under contract",
150
+ "fs": "for sale",
151
+ "fsbo": "for sale by owner",
152
+ "reo": "real estate owned",
153
+ },
154
+ "contractor": {
155
+ "rfi": "request for information",
156
+ "sow": "scope of work",
157
+ "co": "change order",
158
+ "gc": "general contractor",
159
+ "ntp": "notice to proceed",
160
+ "pco": "potential change order",
161
+ "aia": "american institute of architects",
162
+ "lien": "mechanics lien",
163
+ "sub": "subcontractor",
164
+ "por": "purchase order request",
165
+ "cos": "certificate of substantial completion",
166
+ "punch": "punch list",
167
+ "g702": "payment application",
168
+ "g703": "schedule of values",
169
+ },
170
+ "farmer": {
171
+ "fsa": "farm service agency",
172
+ "nrcs": "natural resources conservation service",
173
+ "crp": "conservation reserve program",
174
+ "arc": "agriculture risk coverage",
175
+ "plc": "price loss coverage",
176
+ "usda": "united states department of agriculture",
177
+ "eqip": "environmental quality incentives program",
178
+ "csa": "community supported agriculture",
179
+ "gmp": "good manufacturing practices",
180
+ "gap": "good agricultural practices",
181
+ },
182
+ "hr": {
183
+ "pip": "performance improvement plan",
184
+ "pto": "paid time off",
185
+ "fmla": "family medical leave act",
186
+ "ada": "americans with disabilities act",
187
+ "eeoc": "equal employment opportunity commission",
188
+ "w2": "wage and tax statement",
189
+ "i9": "employment eligibility verification",
190
+ "cobra": "consolidated omnibus budget reconciliation act",
191
+ "osha": "occupational safety and health administration",
192
+ "erp": "employee relations policy",
193
+ },
194
+ "accounting": {
195
+ "p&l": "profit and loss",
196
+ "cogs": "cost of goods sold",
197
+ "ar": "accounts receivable",
198
+ "ap": "accounts payable",
199
+ "gaap": "generally accepted accounting principles",
200
+ "ytd": "year to date",
201
+ "mtd": "month to date",
202
+ "ebitda": "earnings before interest taxes depreciation amortization",
203
+ "cpa": "certified public accountant",
204
+ "sox": "sarbanes oxley",
205
+ },
206
+ "mortgage": {
207
+ "ltv": "loan to value",
208
+ "dti": "debt to income",
209
+ "arm": "adjustable rate mortgage",
210
+ "apr": "annual percentage rate",
211
+ "pmi": "private mortgage insurance",
212
+ "hud": "housing and urban development",
213
+ "fnma": "fannie mae",
214
+ "fhlmc": "freddie mac",
215
+ "heloc": "home equity line of credit",
216
+ "gfe": "good faith estimate",
217
+ "cd": "closing disclosure",
218
+ "le": "loan estimate",
219
+ },
220
+ "insurance": {
221
+ "doi": "department of insurance",
222
+ "e&o": "errors and omissions",
223
+ "gl": "general liability",
224
+ "wc": "workers compensation",
225
+ "coi": "certificate of insurance",
226
+ "dec": "declarations page",
227
+ "aob": "assignment of benefits",
228
+ "uwi": "underwriting information",
229
+ "clue": "comprehensive loss underwriting exchange",
230
+ "pip": "personal injury protection",
231
+ },
232
+ "therapist": {
233
+ "dap": "data assessment plan",
234
+ "soap": "subjective objective assessment plan",
235
+ "hipaa": "health insurance portability and accountability act",
236
+ "phi": "protected health information",
237
+ "dx": "diagnosis",
238
+ "tx": "treatment",
239
+ "iop": "intensive outpatient program",
240
+ "php": "partial hospitalization program",
241
+ "cbt": "cognitive behavioral therapy",
242
+ "dbt": "dialectical behavior therapy",
243
+ "emdr": "eye movement desensitization reprocessing",
244
+ },
245
+ "chiropractor": {
246
+ "soap": "subjective objective assessment plan",
247
+ "rom": "range of motion",
248
+ "pi": "personal injury",
249
+ "hipaa": "health insurance portability and accountability act",
250
+ "icd": "international classification of diseases",
251
+ "cpt": "current procedural terminology",
252
+ "eob": "explanation of benefits",
253
+ },
254
+ "dentist": {
255
+ "hipaa": "health insurance portability and accountability act",
256
+ "cddt": "current dental terminology",
257
+ "perio": "periodontal",
258
+ "ortho": "orthodontic",
259
+ "endo": "endodontic",
260
+ "eob": "explanation of benefits",
261
+ "pano": "panoramic radiograph",
262
+ },
263
+ "teacher": {
264
+ "iep": "individualized education program",
265
+ "504": "section 504 accommodation plan",
266
+ "ell": "english language learner",
267
+ "sped": "special education",
268
+ "pbis": "positive behavioral interventions and supports",
269
+ "mtss": "multi-tiered system of supports",
270
+ "rti": "response to intervention",
271
+ "ferpa": "family educational rights and privacy act",
272
+ "pd": "professional development",
273
+ "plc": "professional learning community",
274
+ },
275
+ "vet": {
276
+ "soap": "subjective objective assessment plan",
277
+ "avma": "american veterinary medical association",
278
+ "rx": "prescription",
279
+ "dx": "diagnosis",
280
+ "tx": "treatment",
281
+ "hx": "history",
282
+ "pe": "physical examination",
283
+ },
284
+ "electrician": {
285
+ "nec": "national electrical code",
286
+ "gfci": "ground fault circuit interrupter",
287
+ "afci": "arc fault circuit interrupter",
288
+ "atp": "ampere trip point",
289
+ "rfi": "request for information",
290
+ "co": "change order",
291
+ "ntp": "notice to proceed",
292
+ },
293
+ "plumber": {
294
+ "ipc": "international plumbing code",
295
+ "upc": "uniform plumbing code",
296
+ "rfi": "request for information",
297
+ "co": "change order",
298
+ "ntp": "notice to proceed",
299
+ "pex": "cross-linked polyethylene",
300
+ "abs": "acrylonitrile butadiene styrene",
301
+ },
302
+ # ── Additional verticals ──────────────────────────────────────────────────
303
+ "marketing": {
304
+ "seo": "search engine optimization",
305
+ "sem": "search engine marketing",
306
+ "ppc": "pay per click",
307
+ "ctr": "click through rate",
308
+ "cpc": "cost per click",
309
+ "cpa": "cost per acquisition",
310
+ "roi": "return on investment",
311
+ "kpi": "key performance indicator",
312
+ "crm": "customer relationship management",
313
+ "cta": "call to action",
314
+ "b2b": "business to business",
315
+ "b2c": "business to consumer",
316
+ "saas": "software as a service",
317
+ "mrr": "monthly recurring revenue",
318
+ "arr": "annual recurring revenue",
319
+ },
320
+ "pastor": {
321
+ "vbs": "vacation bible school",
322
+ "awana": "approved workmen are not ashamed",
323
+ "acl": "adult community life",
324
+ "lcm": "leadership core meeting",
325
+ "sml": "small group leader",
326
+ },
327
+ "salon": {
328
+ "pbe": "professional beauty equipment",
329
+ "cosmo": "cosmetology",
330
+ "esti": "esthetician",
331
+ "nail": "nail technician",
332
+ "hsc": "hair salon coordinator",
333
+ },
334
+ "travelagent": {
335
+ "gds": "global distribution system",
336
+ "iata": "international air transport association",
337
+ "fam": "familiarization trip",
338
+ "fx": "foreign exchange",
339
+ "ota": "online travel agency",
340
+ "pnr": "passenger name record",
341
+ "roi": "return on investment",
342
+ },
343
+ "restaurant": {
344
+ "cogs": "cost of goods sold",
345
+ "foh": "front of house",
346
+ "boh": "back of house",
347
+ "pos": "point of sale",
348
+ "haccp": "hazard analysis critical control points",
349
+ "fifo": "first in first out",
350
+ "eighty six": "item unavailable",
351
+ },
352
+ "landlord": {
353
+ "noi": "net operating income",
354
+ "cap": "capitalization rate",
355
+ "roi": "return on investment",
356
+ "hoa": "homeowners association",
357
+ "sec dep": "security deposit",
358
+ "ltv": "loan to value",
359
+ },
360
+ "principal": {
361
+ "iep": "individualized education program",
362
+ "504": "section 504 accommodation plan",
363
+ "pbis": "positive behavioral interventions and supports",
364
+ "mtss": "multi-tiered system of supports",
365
+ "ferpa": "family educational rights and privacy act",
366
+ "sped": "special education",
367
+ "ell": "english language learner",
368
+ "plc": "professional learning community",
369
+ },
370
+ "mortuary": {
371
+ "fda": "food and drug administration",
372
+ "ftc": "federal trade commission",
373
+ "osha": "occupational safety and health administration",
374
+ "dna": "do not autopsy",
375
+ "dnr": "do not resuscitate",
376
+ },
377
+ "eventplanner": {
378
+ "rsvp": "repondez sil vous plait",
379
+ "av": "audio visual",
380
+ "beo": "banquet event order",
381
+ "rfp": "request for proposal",
382
+ "roi": "return on investment",
383
+ "f&b": "food and beverage",
384
+ },
385
+ "church": {
386
+ "vbs": "vacation bible school",
387
+ "awana": "approved workmen are not ashamed",
388
+ "501c3": "nonprofit tax exempt status",
389
+ "aed": "automated external defibrillator",
390
+ "acl": "adult community life",
391
+ },
392
+ "personaltrainer": {
393
+ "rm": "repetition maximum",
394
+ "hiit": "high intensity interval training",
395
+ "bmr": "basal metabolic rate",
396
+ "tdee": "total daily energy expenditure",
397
+ "bmi": "body mass index",
398
+ "rom": "range of motion",
399
+ "par q": "physical activity readiness questionnaire",
400
+ },
401
+ "designer": {
402
+ "ui": "user interface",
403
+ "ux": "user experience",
404
+ "rgb": "red green blue",
405
+ "cmyk": "cyan magenta yellow key",
406
+ "dpi": "dots per inch",
407
+ "ppi": "pixels per inch",
408
+ "svg": "scalable vector graphics",
409
+ "sow": "scope of work",
410
+ },
411
+ "militaryspouse": {
412
+ "pcs": "permanent change of station",
413
+ "tdy": "temporary duty assignment",
414
+ "bah": "basic allowance for housing",
415
+ "bas": "basic allowance for subsistence",
416
+ "deers": "defense enrollment eligibility reporting system",
417
+ "tricare":"military health insurance",
418
+ "id card":"military dependent identification",
419
+ },
420
+ "funeral": {
421
+ "ftc": "federal trade commission",
422
+ "fda": "food and drug administration",
423
+ "osha": "occupational safety and health administration",
424
+ "dnr": "do not resuscitate",
425
+ "cremains":"cremated remains",
426
+ },
427
+ "nutritionist": {
428
+ "bmi": "body mass index",
429
+ "bmr": "basal metabolic rate",
430
+ "tdee": "total daily energy expenditure",
431
+ "gi": "glycemic index",
432
+ "gl": "glycemic load",
433
+ "dri": "dietary reference intake",
434
+ "rda": "recommended dietary allowance",
435
+ "ibw": "ideal body weight",
436
+ },
437
+ }
438
+
439
+ # Default empty map for verticals without specific abbreviations
440
+ _DEFAULT_ABBREVS = {}
441
+
442
+ # DB-loaded abbreviations (fetched from /v1/abbreviations at startup)
443
+ _abbrevs_db: dict | None = None
444
+ _abbrevs_db_ts: float = 0.0
445
+ _abbrevs_db_product: str | None = None
446
+
447
+
448
+ # ── Cache configuration ────────────────────────────────────────────────────────
449
+ CACHE_TTL = 600 # 10 minutes
450
+ ERROR_COOLDOWN = 30 # retry after failure
451
+
452
+ # Vertical metadata (loaded once at startup via GET /v1/me)
453
+ _vertical = None
454
+
455
+ # Skills cache
456
+ _skills_cache = None
457
+ _skills_cache_ts = 0.0
458
+ _skills_cache_err_until = 0.0
459
+
460
+ # Triggers cache — {skill_id: [phrase, ...]}
461
+ _triggers_cache = None
462
+ _triggers_cache_ts = 0.0
463
+ _triggers_cache_err_until = 0.0
464
+
465
+
466
+ async def api_get(path):
467
+ async with httpx.AsyncClient(timeout=30.0) as client:
468
+ resp = await client.get(
469
+ f"{API_BASE}{path}",
470
+ headers={**AUTH_HEADERS, "X-Product-ID": (_vertical or {}).get("product_id", "law")}
471
+ )
472
+ resp.raise_for_status()
473
+ return resp.json()
474
+
475
+
476
+ async def load_vertical():
477
+ """Fetch vertical metadata from /v1/me on startup. Falls back to farmer."""
478
+ global _vertical
479
+ try:
480
+ path = f"/v1/me?product_id={PRODUCT_ID}" if PRODUCT_ID else "/v1/me"
481
+ _vertical = await api_get(path)
482
+ except Exception:
483
+ # Fallback: derive from license key prefix client-side
484
+ prefix = LICENSE_KEY.split("_")[0] + "_" if "_" in LICENSE_KEY else "ft_"
485
+ _vertical = {
486
+ "product_id": "farmer",
487
+ "product_name": "FarmerTasksAI",
488
+ "display_name": "FarmerTasksAI",
489
+ "tool_prefix": "farmertasksai",
490
+ "occupation": "farmer",
491
+ "support_email":"support@farmertasksai.com",
492
+ "domain": "farmertasksai.com",
493
+ }
494
+ return _vertical
495
+
496
+
497
+ async def load_abbreviations():
498
+ """
499
+ Fetch abbreviations for this vertical from GET /v1/abbreviations.
500
+ Populates _abbrevs_db. Falls back silently to _ABBREVS_FALLBACK if unavailable.
501
+ Called once at startup after load_vertical().
502
+ """
503
+ global _abbrevs_db, _abbrevs_db_ts, _abbrevs_db_product
504
+ try:
505
+ data = await api_get("/v1/abbreviations")
506
+ _abbrevs_db = data.get("abbreviations", {})
507
+ _abbrevs_db_product = data.get("product_id")
508
+ _abbrevs_db_ts = time.monotonic()
509
+ except Exception:
510
+ # Non-fatal: _ABBREVS_FALLBACK will be used instead
511
+ _abbrevs_db = None
512
+ return _abbrevs_db
513
+
514
+
515
+ async def get_skills():
516
+ global _skills_cache, _skills_cache_ts, _skills_cache_err_until
517
+ now = time.monotonic()
518
+ if _skills_cache is not None and (now - _skills_cache_ts) < CACHE_TTL:
519
+ return _skills_cache
520
+ if now < _skills_cache_err_until:
521
+ return _skills_cache if _skills_cache is not None else []
522
+ try:
523
+ _skills_cache = await api_get("/v1/skills")
524
+ _skills_cache_ts = now
525
+ _skills_cache_err_until = 0.0
526
+ except Exception:
527
+ _skills_cache_err_until = now + ERROR_COOLDOWN
528
+ if _skills_cache is None:
529
+ _skills_cache = []
530
+ return _skills_cache
531
+
532
+
533
+ async def get_triggers():
534
+ """Return trigger phrases {skill_id: [phrase, ...]}. Fails silently."""
535
+ global _triggers_cache, _triggers_cache_ts, _triggers_cache_err_until
536
+ now = time.monotonic()
537
+ if _triggers_cache is not None and (now - _triggers_cache_ts) < CACHE_TTL:
538
+ return _triggers_cache
539
+ if now < _triggers_cache_err_until:
540
+ return _triggers_cache if _triggers_cache is not None else {}
541
+ try:
542
+ raw = await api_get("/v1/skills/triggers")
543
+ _triggers_cache = {
544
+ sid: [p.lower() for p in v.get("triggers", [])]
545
+ for sid, v in raw.items()
546
+ }
547
+ _triggers_cache_ts = now
548
+ _triggers_cache_err_until = 0.0
549
+ except Exception:
550
+ _triggers_cache_err_until = now + ERROR_COOLDOWN
551
+ if _triggers_cache is None:
552
+ _triggers_cache = {}
553
+ return _triggers_cache
554
+
555
+
556
+ def expand_query(query, product_id):
557
+ """Expand vertical-specific abbreviations before matching."""
558
+ # Prefer DB-loaded abbreviations; fall back to hardcoded map
559
+ if _abbrevs_db is not None:
560
+ abbrevs = _abbrevs_db
561
+ else:
562
+ abbrevs = _ABBREVS_FALLBACK.get(product_id, _DEFAULT_ABBREVS)
563
+ if not abbrevs:
564
+ return query
565
+ words = query.lower().split()
566
+ expansions = [abbrevs[w.strip(".,;:?!")] for w in words if w.strip(".,;:?!") in abbrevs]
567
+ return (query + " " + " ".join(expansions)).strip() if expansions else query
568
+
569
+
570
+ def _word_in_text(word, text):
571
+ """True if `word` appears as a whole word in `text`."""
572
+ return bool(re.search(r'(?<!\w)' + re.escape(word) + r'(?!\w)', text))
573
+
574
+
575
+ def score_skill(skill, query_lower, query_words, triggers):
576
+ """Three-tier scoring: trigger match (10) > name match (3) > description match (1)."""
577
+ skill_id = skill.get("id", "")
578
+ name_text = skill.get("name", "").lower()
579
+ desc_text = skill.get("description", "").lower()
580
+ full_text = name_text + " " + desc_text
581
+ # Tier 1 — trigger phrase (whole-word, bidirectional)
582
+ for phrase in triggers.get(skill_id, []):
583
+ if _word_in_text(phrase, query_lower) or _word_in_text(query_lower, phrase):
584
+ return 10
585
+ # Tier 2 — keyword
586
+ return sum(
587
+ 3 if _word_in_text(w, name_text) else 1
588
+ for w in query_words
589
+ if _word_in_text(w, full_text)
590
+ )
591
+
592
+
593
+ def safe_filename_component(value, fallback="tasksai-output", max_length=90):
594
+ """Return a conservative filename stem with no path separators."""
595
+ text = str(value or "").strip()
596
+ text = re.sub(r"[\\/]+", "-", text)
597
+ text = re.sub(r"[^A-Za-z0-9._ -]+", "", text)
598
+ text = re.sub(r"\s+", "-", text)
599
+ text = re.sub(r"-{2,}", "-", text)
600
+ text = text.strip(" ._-")
601
+ if not text:
602
+ text = fallback
603
+ return text[:max_length].strip(" ._-") or fallback
604
+
605
+
606
+ def output_root(product_name):
607
+ """Return the local folder used for saved customer deliverables."""
608
+ configured = os.getenv("TASKSAI_OUTPUT_DIR", "").strip()
609
+ if configured:
610
+ root = Path(configured).expanduser()
611
+ else:
612
+ root = Path.home() / "Documents" / "TasksAI" / safe_filename_component(product_name, "TasksAI")
613
+ root.mkdir(parents=True, exist_ok=True)
614
+ return root
615
+
616
+
617
+ def unique_output_path(root, filename, overwrite=False):
618
+ """Build a local output path without allowing arbitrary directories."""
619
+ candidate_name = Path(str(filename or "")).name
620
+ path = root / candidate_name
621
+ if overwrite or not path.exists():
622
+ return path
623
+
624
+ stem = path.stem
625
+ suffix = path.suffix
626
+ for index in range(2, 1000):
627
+ candidate = root / f"{stem}-{index}{suffix}"
628
+ if not candidate.exists():
629
+ return candidate
630
+ raise RuntimeError("Could not choose a unique output filename.")
631
+
632
+
633
+ def add_markdown_text(paragraph, text):
634
+ """Add basic inline markdown formatting to a python-docx paragraph."""
635
+ pattern = r"\*\*(.+?)\*\*"
636
+ last_end = 0
637
+ for match in re.finditer(pattern, text):
638
+ if match.start() > last_end:
639
+ paragraph.add_run(text[last_end:match.start()])
640
+ bold_run = paragraph.add_run(match.group(1))
641
+ bold_run.bold = True
642
+ last_end = match.end()
643
+ if last_end < len(text):
644
+ paragraph.add_run(text[last_end:])
645
+
646
+
647
+ def write_docx(path, title, content_markdown, product_name):
648
+ """Write a simple, polished DOCX file locally."""
649
+ try:
650
+ from docx import Document
651
+ from docx.shared import Pt
652
+ except Exception as exc:
653
+ raise RuntimeError(
654
+ "python-docx is not installed. Re-run the official installer or install python-docx in the MCP runtime environment."
655
+ ) from exc
656
+
657
+ doc = Document()
658
+ if title:
659
+ doc.add_heading(title, level=0)
660
+
661
+ lines = content_markdown.splitlines()
662
+ paragraph_buffer = []
663
+
664
+ def flush_paragraph():
665
+ if not paragraph_buffer:
666
+ return
667
+ text = " ".join(part.strip() for part in paragraph_buffer if part.strip())
668
+ paragraph_buffer.clear()
669
+ if text:
670
+ add_markdown_text(doc.add_paragraph(), text)
671
+
672
+ for raw_line in lines:
673
+ line = raw_line.rstrip()
674
+ stripped = line.strip()
675
+ if not stripped:
676
+ flush_paragraph()
677
+ continue
678
+
679
+ heading = re.match(r"^(#{1,4})\s+(.+)$", stripped)
680
+ bullet = re.match(r"^[-*]\s+(.+)$", stripped)
681
+ numbered = re.match(r"^\d+\.\s+(.+)$", stripped)
682
+
683
+ if heading:
684
+ flush_paragraph()
685
+ doc.add_heading(heading.group(2).strip(), level=min(len(heading.group(1)), 4))
686
+ elif bullet:
687
+ flush_paragraph()
688
+ para = doc.add_paragraph(style="List Bullet")
689
+ add_markdown_text(para, bullet.group(1).strip())
690
+ elif numbered:
691
+ flush_paragraph()
692
+ para = doc.add_paragraph(style="List Number")
693
+ add_markdown_text(para, numbered.group(1).strip())
694
+ elif stripped in {"---", "***", "___"}:
695
+ flush_paragraph()
696
+ doc.add_paragraph()
697
+ else:
698
+ paragraph_buffer.append(stripped)
699
+
700
+ flush_paragraph()
701
+
702
+ doc.add_paragraph()
703
+ footer = doc.add_paragraph()
704
+ run = footer.add_run(f"Generated locally by {product_name}. Content was not sent to TasksAI servers.")
705
+ run.italic = True
706
+ run.font.size = Pt(8)
707
+
708
+ doc.save(path)
709
+
710
+
711
+ def write_markdown(path, title, content_markdown, product_name):
712
+ """Write a Markdown file locally."""
713
+ parts = []
714
+ if title:
715
+ parts.append(f"# {title.strip()}\n")
716
+ parts.append(content_markdown.strip())
717
+ parts.append(f"\n\n---\nGenerated locally by {product_name}. Content was not sent to TasksAI servers.\n")
718
+ path.write_text("\n\n".join(part for part in parts if part), encoding="utf-8")
719
+
720
+
721
+ def save_document(arguments, product_name):
722
+ """Save AI-produced final content to a local downloadable file."""
723
+ content_markdown = (arguments.get("content_markdown") or arguments.get("content") or "").strip()
724
+ if not content_markdown:
725
+ raise ValueError("content_markdown is required.")
726
+
727
+ title = (arguments.get("title") or "").strip()
728
+ skill_id = (arguments.get("skill_id") or "").strip()
729
+ fmt = (arguments.get("format") or "docx").strip().lower()
730
+ if fmt == "md":
731
+ fmt = "markdown"
732
+ if fmt not in {"docx", "markdown"}:
733
+ raise ValueError("format must be 'docx' or 'markdown'.")
734
+
735
+ extension = ".docx" if fmt == "docx" else ".md"
736
+ supplied_filename = (arguments.get("filename") or "").strip()
737
+ if supplied_filename:
738
+ stem = safe_filename_component(Path(supplied_filename).stem)
739
+ else:
740
+ base = title or skill_id or f"{product_name} output"
741
+ stamp = datetime.now().strftime("%Y-%m-%d")
742
+ stem = f"{stamp}-{safe_filename_component(base)}"
743
+
744
+ root = output_root(product_name)
745
+ output_path = unique_output_path(root, f"{stem}{extension}", arguments.get("overwrite") is True)
746
+
747
+ if fmt == "docx":
748
+ write_docx(output_path, title, content_markdown, product_name)
749
+ else:
750
+ write_markdown(output_path, title, content_markdown, product_name)
751
+
752
+ return output_path, fmt
753
+
754
+
755
+ def build_tools(prefix, product_name, occupation):
756
+ """Build MCP tools with vertical-specific names and descriptions."""
757
+ # Build example queries from the top trigger phrases for this vertical
758
+ _examples = {
759
+ "attorney": "e.g. 'statute of limitations', 'motion to compel', 'demand letter', 'deposition prep', 'discovery requests'",
760
+ "realtor": "e.g. 'listing presentation', 'buyer consultation', 'CMA analysis', 'open house checklist'",
761
+ "farmer": "e.g. 'crop insurance claim', 'USDA loan application', 'conservation plan', 'farm succession'",
762
+ "teacher": "e.g. 'IEP meeting', 'lesson plan', 'parent conference', 'classroom management'",
763
+ "therapist": "e.g. 'treatment plan', 'SOAP note', 'intake assessment', 'crisis intervention'",
764
+ "contractor": "e.g. 'change order', 'bid proposal', 'punch list', 'lien waiver'",
765
+ "marketing": "e.g. 'content calendar', 'SEO audit', 'email campaign', 'brand strategy'",
766
+ }
767
+ occ_key = occupation.split(',')[0].strip()
768
+ audience = normalize_audience_label(occ_key)
769
+ domain_adjective = normalize_domain_adjective(occ_key)
770
+ examples = _examples.get(domain_adjective, _examples.get(product_name.replace('TasksAI','').lower().strip(), f"e.g. a {domain_adjective} task"))
771
+
772
+ return [
773
+ Tool(
774
+ name=f"{prefix}_search",
775
+ description=(
776
+ f"Search {product_name}'s library of interactive guided workflows for {audience}. "
777
+ f"Each workflow walks the user through a structured, multi-step process with intake questions, "
778
+ f"domain-specific checklists, recordkeeping steps, and document generation templates "
779
+ f"that cannot be replicated from general knowledge alone. "
780
+ f"Use this tool whenever the user has a {domain_adjective}-related question or task — "
781
+ f"even simple-sounding requests may have dedicated workflows with practical requirements, "
782
+ f"review points, and fillable templates. "
783
+ f"Returns a numbered list of matching workflows with descriptions."
784
+ ),
785
+ inputSchema={
786
+ "type": "object",
787
+ "properties": {
788
+ "query": {
789
+ "type": "string",
790
+ "description": f"{domain_adjective.title()} topic or task ({examples})"
791
+ }
792
+ },
793
+ "required": ["query"]
794
+ }
795
+ ),
796
+ Tool(
797
+ name=f"{prefix}_execute",
798
+ description=(
799
+ f"Launch a {product_name} guided workflow by its ID. Returns the full interactive framework "
800
+ f"including intake questions, step-by-step instructions, domain-specific guidance, "
801
+ f"and document templates. Costs 1 credit. Use {prefix}_search first to find the workflow ID."
802
+ ),
803
+ inputSchema={
804
+ "type": "object",
805
+ "properties": {
806
+ "skill_id": {
807
+ "type": "string",
808
+ "description": f"Workflow ID from {prefix}_search results"
809
+ }
810
+ },
811
+ "required": ["skill_id"]
812
+ }
813
+ ),
814
+ Tool(
815
+ name=f"{prefix}_save_document",
816
+ description=(
817
+ f"Save the finished {product_name} deliverable as a local downloadable file after you have "
818
+ "completed the user's final answer. The file is created by the local MCP server in the user's "
819
+ "TasksAI documents folder; customer content is not sent to TasksAI websites or APIs. "
820
+ "Use this after applying a workflow, not before drafting the final output."
821
+ ),
822
+ inputSchema={
823
+ "type": "object",
824
+ "properties": {
825
+ "content_markdown": {
826
+ "type": "string",
827
+ "description": "The final customer-facing deliverable content in Markdown."
828
+ },
829
+ "title": {
830
+ "type": "string",
831
+ "description": "Short document title."
832
+ },
833
+ "skill_id": {
834
+ "type": "string",
835
+ "description": "Workflow ID used to create the deliverable."
836
+ },
837
+ "format": {
838
+ "type": "string",
839
+ "enum": ["docx", "markdown"],
840
+ "description": "Output file format. Use docx unless the user asks for Markdown."
841
+ },
842
+ "filename": {
843
+ "type": "string",
844
+ "description": "Optional filename stem. Paths are ignored; files are saved in the local TasksAI output folder."
845
+ },
846
+ "overwrite": {
847
+ "type": "boolean",
848
+ "description": "Whether to overwrite an existing file with the same sanitized name."
849
+ }
850
+ },
851
+ "required": ["content_markdown"]
852
+ }
853
+ ),
854
+ Tool(
855
+ name=f"{prefix}_balance",
856
+ description=f"Check your remaining {product_name} credit balance.",
857
+ inputSchema={"type": "object", "properties": {}}
858
+ ),
859
+ Tool(
860
+ name=f"{prefix}_categories",
861
+ description=(
862
+ f"Browse all {product_name} workflow categories. "
863
+ "Use when the user isn't sure what to search for, "
864
+ "or when a search returns no results."
865
+ ),
866
+ inputSchema={"type": "object", "properties": {}}
867
+ ),
868
+ ]
869
+
870
+
871
+ def normalize_audience_label(label):
872
+ """Return a natural audience phrase without naive pluralization."""
873
+ label = (label or "professionals").strip()
874
+ if label in {"attorney", "lawyer"}:
875
+ return f"{label}s"
876
+ if label == "farmer":
877
+ return "farmers, ranchers, and agricultural operations managers"
878
+ if label in {"legal", "law"}:
879
+ return "legal professionals"
880
+ return label
881
+
882
+
883
+ def normalize_domain_adjective(label):
884
+ """Return a natural adjective for tool descriptions and query labels."""
885
+ label = (label or "professional").strip()
886
+ if label in {"attorneys and legal professionals", "legal professionals", "attorney", "lawyer", "law"}:
887
+ return "legal"
888
+ return label
889
+
890
+
891
+ def build_system_prompt(product_name, occupation, prefix, domain, support_email):
892
+ return f"""You are a {product_name} assistant for {occupation}.
893
+
894
+ When the user asks any {occupation.split(',')[0]}-related question, call {prefix}_search first to find a matching expert framework. The user is paying for {product_name}'s expert-built frameworks — always search before answering.
895
+
896
+ Workflow:
897
+ 1. Call {prefix}_search with the user's question
898
+ 2. Present the numbered results
899
+ 3. Let the user choose, then call {prefix}_execute with the skill_id (costs 1 credit)
900
+ 4. Show remaining credit balance
901
+
902
+ Rules:
903
+ - Always search first, even for simple questions
904
+ - Never call {prefix}_execute without user confirmation
905
+ - If no results, suggest {prefix}_categories
906
+
907
+ {domain} | Support: {support_email}"""
908
+
909
+
910
+ # ── Server initialization ──────────────────────────────────────────────────────
911
+ # Note: MCP server tools are registered at module load time, but we need
912
+ # vertical metadata from the API. We use a two-phase init:
913
+ # Phase 1: create server with placeholder tools (farmer defaults)
914
+ # Phase 2: on first tool call, ensure vertical is loaded and tools are current
915
+
916
+ # Placeholder system prompt (overwritten after /v1/me loads)
917
+ _system_prompt_text = build_system_prompt(
918
+ "FarmerTasksAI", "farmers, ranchers, and agricultural operations managers",
919
+ "farmertasksai", "farmertasksai.com", "support@farmertasksai.com"
920
+ )
921
+
922
+ # NOTE: Do NOT pass instructions= here. Testing showed that Claude Desktop
923
+ # v1.9+ ignores or deprioritizes MCP server instructions. The working March
924
+ # config had no instructions and no prompts — just clean tool descriptions.
925
+ server = Server("farmertasksai")
926
+
927
+ # Placeholder tools using farmer defaults (overwritten after /v1/me loads)
928
+ _tools = build_tools("farmertasksai", "FarmerTasksAI", "farmer")
929
+
930
+ # NOTE: Prompts capability intentionally removed. The working March 2026
931
+ # config had no prompts — just tools. Adding prompts may cause Claude Desktop
932
+ # to deprioritize tool auto-invocation.
933
+
934
+
935
+ @server.list_tools()
936
+ async def list_tools():
937
+ # Ensure vertical is loaded before advertising tools
938
+ if _vertical is None:
939
+ await load_vertical()
940
+ await load_abbreviations()
941
+ _rebuild_tools()
942
+ return _tools
943
+
944
+
945
+ def _rebuild_tools():
946
+ """Rebuild tools and system prompt once vertical metadata is available."""
947
+ global _tools, _system_prompt_text
948
+ if _vertical is None:
949
+ return
950
+ prefix = _vertical.get("tool_prefix", "farmertasksai")
951
+ name = _vertical.get("product_name", "FarmerTasksAI")
952
+ occ = _vertical.get("occupation", "professionals")
953
+ domain = _vertical.get("domain", "farmertasksai.com")
954
+ support = _vertical.get("support_email", "support@farmertasksai.com")
955
+ _tools = build_tools(prefix, name, occ)
956
+ _system_prompt_text = build_system_prompt(name, occ, prefix, domain, support)
957
+
958
+
959
+ @server.call_tool()
960
+ async def call_tool(name, arguments):
961
+ # Ensure vertical loaded on first tool call
962
+ if _vertical is None:
963
+ await load_vertical()
964
+ await load_abbreviations()
965
+ _rebuild_tools()
966
+
967
+ v = _vertical or {}
968
+ prefix = v.get("tool_prefix", "farmertasksai")
969
+ product_id = v.get("product_id", "farmer")
970
+ product_name = v.get("product_name", "FarmerTasksAI")
971
+ occupation = v.get("occupation", "professionals")
972
+
973
+ try:
974
+ # ── Search ────────────────────────────────────────────────────────────
975
+ if name == f"{prefix}_search":
976
+ skills, triggers = await get_skills(), await get_triggers()
977
+ query = expand_query(arguments.get("query", ""), product_id)
978
+ query_lower = query.lower()
979
+ STOP_WORDS = {"a","an","the","and","or","of","in","to","for","is","are",
980
+ "with","at","by","on","from","as","it","its","be","was","can"}
981
+ raw_words = query.split()
982
+ query_words = [
983
+ w_lower for w_orig, w_lower in zip(raw_words, query_lower.split())
984
+ if w_lower not in STOP_WORDS and (len(w_lower) > 2 or w_orig.isupper())
985
+ ]
986
+ scored = [(score_skill(s, query_lower, query_words, triggers), s) for s in skills]
987
+ scored = [(sc, s) for sc, s in scored if sc > 0]
988
+ scored.sort(key=lambda x: -x[0])
989
+ matches = [s for _, s in scored[:5]]
990
+
991
+ if not matches:
992
+ return [TextContent(type="text", text=(
993
+ f"No skills found matching **'{arguments.get('query', '')}'**.\n\n"
994
+ "**Suggestions:**\n"
995
+ "- Try different keywords or a more specific phrase\n"
996
+ f"- Use `{prefix}_categories` to browse all skill categories\n"
997
+ "- Ask the user to rephrase their request\n\n"
998
+ f"**DO NOT call `{prefix}_execute`** — no skill has been selected."
999
+ ))]
1000
+
1001
+ lines = [f"**{len(matches)} skills found for '{arguments.get('query', '')}':**\n"]
1002
+ for i, s in enumerate(matches, 1):
1003
+ desc = s.get("description", "")[:100]
1004
+ lines.append(f"{i}. **{s['name']}** (`{s['id']}`)\n {desc}\n")
1005
+
1006
+ lines.append("---")
1007
+ lines.append(
1008
+ "**\U0001f6d1 REQUIRED \u2014 DO NOT SKIP:**\n"
1009
+ "Present the numbered list above to the user EXACTLY as shown. "
1010
+ "Then ask: *\"Which of these best fits your situation? "
1011
+ "(Reply with a number, or describe your task differently and I'll search again.)\"*\n\n"
1012
+ f"**DO NOT call `{prefix}_execute` until the user replies with their choice. "
1013
+ "Each execution costs 1 credit and cannot be undone.**"
1014
+ )
1015
+ return [TextContent(type="text", text="\n".join(lines))]
1016
+
1017
+ # ── Execute ───────────────────────────────────────────────────────────
1018
+ elif name == f"{prefix}_execute":
1019
+ skill_id = arguments.get("skill_id", "")
1020
+ if not skill_id:
1021
+ return [TextContent(type="text", text="Error: skill_id is required.")]
1022
+
1023
+ result = await api_get(f"/v1/skills/{skill_id}/execute")
1024
+
1025
+ content = result.get("schema", result.get("content", ""))
1026
+ skill_name = result.get("skill_name", skill_id)
1027
+ credits_remaining = result.get("credits_remaining", "?")
1028
+
1029
+ return [TextContent(type="text", text=(
1030
+ f"# {skill_name}\n\n"
1031
+ f"{content}\n\n"
1032
+ f"---\n"
1033
+ f"**Local document output:** After you produce the user's final deliverable, call "
1034
+ f"`{prefix}_save_document` with the final Markdown content, title, skill_id, and "
1035
+ "`format: \"docx\"` unless the user asks for Markdown. The file is written locally; "
1036
+ f"{product_name} websites and APIs do not receive the user's content or generated output.\n\n"
1037
+ f"---\n*Credits remaining: {credits_remaining}*"
1038
+ ))]
1039
+
1040
+ # ── Save Document ───────────────────────────────────────────────────
1041
+ elif name == f"{prefix}_save_document":
1042
+ output_path, fmt = save_document(arguments or {}, product_name)
1043
+ return [TextContent(type="text", text=(
1044
+ "**Document Saved**\n\n"
1045
+ f"- File: `{output_path}`\n"
1046
+ f"- Format: {fmt}\n"
1047
+ f"- Privacy: saved locally by the {product_name} MCP server; content was not sent to TasksAI servers."
1048
+ ))]
1049
+
1050
+ # ── Balance ───────────────────────────────────────────────────────────
1051
+ elif name == f"{prefix}_balance":
1052
+ result = await api_get("/v1/credits/balance")
1053
+ balance = result.get("credits_balance", "?")
1054
+ lic_type = result.get("license_type", "")
1055
+ domain = v.get("domain", "farmertasksai.com")
1056
+ return [TextContent(type="text", text=(
1057
+ f"**{product_name} Credits**\n\n"
1058
+ f"- Balance: **{balance} credits**\n"
1059
+ f"- License type: {lic_type}\n"
1060
+ f"- MCP server version: {SERVER_VERSION}\n\n"
1061
+ f"Purchase more at: https://{domain}/#pricing"
1062
+ ))]
1063
+
1064
+ # ── Categories ────────────────────────────────────────────────────────
1065
+ elif name == f"{prefix}_categories":
1066
+ skills = await get_skills()
1067
+ cats: dict[str, int] = {}
1068
+ for s in skills:
1069
+ cat = s.get("category_id") or s.get("category", "General")
1070
+ cats[cat] = cats.get(cat, 0) + 1
1071
+ cats_sorted = sorted(cats.items(), key=lambda x: -x[1])
1072
+ lines = [f"**{product_name} Skill Categories** ({len(skills)} total skills)\n"]
1073
+ for cat, count in cats_sorted:
1074
+ lines.append(f"- **{cat}** ({count} skills)")
1075
+ lines.append(f"\nSearch within any category using `{prefix}_search`.")
1076
+ return [TextContent(type="text", text="\n".join(lines))]
1077
+
1078
+ else:
1079
+ return [TextContent(type="text", text=f"Unknown tool: {name}")]
1080
+
1081
+ except httpx.HTTPStatusError as e:
1082
+ if e.response.status_code == 402:
1083
+ domain = v.get("domain", "farmertasksai.com")
1084
+ return [TextContent(type="text", text=(
1085
+ f"**Insufficient credits.**\n\n"
1086
+ f"Purchase more at: https://{domain}/#pricing"
1087
+ ))]
1088
+ elif e.response.status_code == 401:
1089
+ return [TextContent(type="text", text=(
1090
+ "**Invalid or expired license key.**\n\n"
1091
+ "Check your purchase confirmation email or contact support."
1092
+ ))]
1093
+ return [TextContent(type="text", text=f"API error: {e.response.status_code}")]
1094
+ except Exception as e:
1095
+ return [TextContent(type="text", text=f"Error: {str(e)}")]
1096
+
1097
+
1098
+ async def _ping_first_connection():
1099
+ """Fire-and-forget: tell the API this license just connected for the first time."""
1100
+ try:
1101
+ async with httpx.AsyncClient(timeout=5.0) as client:
1102
+ await client.get(
1103
+ f"{API_BASE}/track/first-connection",
1104
+ params={"license_key": LICENSE_KEY}
1105
+ )
1106
+ except Exception:
1107
+ pass # non-fatal — never block startup
1108
+
1109
+
1110
+ async def main():
1111
+ # Load vertical metadata + abbreviations before accepting connections
1112
+ await load_vertical()
1113
+ await load_abbreviations()
1114
+ _rebuild_tools()
1115
+
1116
+ # Ping first-connection tracker (idempotent — API only records it once)
1117
+ asyncio.create_task(_ping_first_connection())
1118
+
1119
+ v = _vertical or {}
1120
+ abbrev_count = len(_abbrevs_db) if _abbrevs_db is not None else 0
1121
+ abbrev_src = "db" if _abbrevs_db is not None else "fallback"
1122
+ # MCP uses stdout for JSON-RPC — all logging MUST go to stderr
1123
+ import sys as _sys
1124
+ print(f"[OK] {v.get('product_name', 'TasksAI')} MCP Server ready (v{SERVER_VERSION})", file=_sys.stderr, flush=True)
1125
+ print(f" Abbreviations: {abbrev_count} loaded from {abbrev_src}", file=_sys.stderr, flush=True)
1126
+ print(f" Vertical: {v.get('product_id', 'unknown')} | "
1127
+ f"Tools: {v.get('tool_prefix', 'tasksai')}_search / execute / save_document / balance / categories",
1128
+ file=_sys.stderr, flush=True)
1129
+
1130
+ async with stdio_server() as (read_stream, write_stream):
1131
+ await server.run(read_stream, write_stream, server.create_initialization_options())
1132
+
1133
+
1134
+ if __name__ == "__main__":
1135
+ asyncio.run(main())
package/src/index.js CHANGED
@@ -8,11 +8,15 @@ import path from "node:path";
8
8
  import readline from "node:readline/promises";
9
9
  import { spawnSync } from "node:child_process";
10
10
  import { stdin as input, stdout as output } from "node:process";
11
+ import { fileURLToPath } from "node:url";
11
12
 
12
- const INSTALLER_VERSION = "0.1.8";
13
+ const INSTALLER_VERSION = "0.1.9";
14
+ const INSTALLER_DIR = path.dirname(fileURLToPath(import.meta.url));
15
+ const BUNDLED_RUNTIME_DIR = path.resolve(INSTALLER_DIR, "..", "runtime");
13
16
  const DEFAULT_SOURCES = {
14
17
  lawtasksai: "https://github.com/laudoluxDev/lawtasksai-mcp",
15
18
  farmer: "https://github.com/laudoluxDev/farmertasksai-mcp",
19
+ teacher: "https://github.com/laudoluxDev/teachertasksai-mcp",
16
20
  priorauthai: "https://github.com/laudoluxDev/priorauthai-mcp"
17
21
  };
18
22
 
@@ -178,11 +182,12 @@ async function install(options, { updateOnly = false } = {}) {
178
182
  await writeJson(path.join(installDir, "vertical.json"), vertical);
179
183
  await downloadRuntime(source, runtimeDir);
180
184
 
185
+ const licenseKey = await resolveLicenseKey(options, vertical, installDir);
186
+
181
187
  if (!options.skipPythonDeps) {
182
188
  installPythonDeps(runtimeDir, vendorDir);
183
189
  }
184
190
 
185
- const licenseKey = await resolveLicenseKey(options, vertical, installDir);
186
191
  const envEntries = {
187
192
  TASKSAI_LICENSE_KEY: licenseKey,
188
193
  LAWTASKSAI_LICENSE_KEY: licenseKey,
@@ -440,10 +445,11 @@ async function preflightWriteAccess({ operation, installDir, clients, options, v
440
445
 
441
446
  if (!failures.length) return;
442
447
 
443
- throw new Error(formatPermissionRecovery({ operation, failures, options, vertical, source }));
448
+ const recoveryScript = await writeRecoveryScript({ operation, options, source });
449
+ throw new Error(formatPermissionRecovery({ operation, failures, options, vertical, source, recoveryScript }));
444
450
  }
445
451
 
446
- function formatPermissionRecovery({ operation, failures, options, vertical, source }) {
452
+ function formatPermissionRecovery({ operation, failures, options, vertical, source, recoveryScript }) {
447
453
  const productName = vertical?.display_name || options.productId;
448
454
  const clientNames = resolveClients(options.client).map((client) => client.displayName).join(", ");
449
455
  const blockedPaths = failures
@@ -464,6 +470,7 @@ function formatPermissionRecovery({ operation, failures, options, vertical, sour
464
470
  "",
465
471
  "To finish setup, run this official installer command directly in Terminal:",
466
472
  installCommand,
473
+ recoveryScript ? `Recovery script: ${recoveryScript}` : null,
467
474
  "",
468
475
  "Then restart your AI app and run this health check:",
469
476
  doctorCommand,
@@ -476,6 +483,34 @@ function formatPermissionRecovery({ operation, failures, options, vertical, sour
476
483
  ].filter(Boolean).join("\n");
477
484
  }
478
485
 
486
+ async function writeRecoveryScript({ operation, options, source }) {
487
+ if (operation === "uninstall") return null;
488
+ const productId = String(options.productId || "tasksai").replace(/[^A-Za-z0-9_-]/g, "");
489
+ const scriptPath = path.join(os.tmpdir(), `${productId || "tasksai"}-finish-install.command`);
490
+ const installCommand = buildInstallCommand({ options, source });
491
+ const doctorCommand = buildDoctorCommand(options);
492
+ const body = [
493
+ "#!/bin/sh",
494
+ "set -e",
495
+ "echo 'Finishing TasksAI setup...'",
496
+ installCommand,
497
+ "echo",
498
+ "echo 'Running health check...'",
499
+ doctorCommand,
500
+ "echo",
501
+ "echo 'Done. Restart your AI app to load the MCP tools.'",
502
+ ""
503
+ ].join("\n");
504
+
505
+ try {
506
+ await fsp.writeFile(scriptPath, body, { mode: 0o755 });
507
+ await fsp.chmod(scriptPath, 0o755);
508
+ return scriptPath;
509
+ } catch {
510
+ return null;
511
+ }
512
+ }
513
+
479
514
  function buildInstallCommand({ options, source }) {
480
515
  const cliParts = ["tasksai-install", options.productId];
481
516
  const sourceUrl = source?.repoUrl || options.source;
@@ -534,12 +569,23 @@ async function checkWritable({ label, targetPath, kind }) {
534
569
  }
535
570
 
536
571
  async function downloadRuntime(source, runtimeDir) {
537
- const serverText = await loadText(source, "server.py");
538
- const requirementsText = await loadText(source, "requirements.txt");
572
+ const serverText = await loadRuntimeText(source, "server.py");
573
+ const requirementsText = await loadRuntimeText(source, "requirements.txt");
539
574
  await fsp.writeFile(path.join(runtimeDir, "server.py"), serverText, "utf8");
540
575
  await fsp.writeFile(path.join(runtimeDir, "requirements.txt"), requirementsText, "utf8");
541
576
  }
542
577
 
578
+ async function loadRuntimeText(source, filePath) {
579
+ try {
580
+ return await loadText(source, filePath);
581
+ } catch (error) {
582
+ if ((source.kind === "github" && /HTTP 404/.test(error.message)) || error.code === "ENOENT") {
583
+ return fsp.readFile(path.join(BUNDLED_RUNTIME_DIR, filePath), "utf8");
584
+ }
585
+ throw error;
586
+ }
587
+ }
588
+
543
589
  function installPythonDeps(runtimeDir, vendorDir) {
544
590
  const python = findPython();
545
591
  if (!python) throw new Error("Python 3 is required but was not found on PATH.");