@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 +23 -4
- package/package.json +2 -1
- package/runtime/requirements.txt +4 -0
- package/runtime/server.py +1135 -0
- package/src/index.js +52 -6
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,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.
|
|
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
|
-
|
|
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
|
|
538
|
-
const requirementsText = await
|
|
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.");
|