synqtune-mcp 0.1.1__tar.gz

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.
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: synqtune-mcp
3
+ Version: 0.1.1
4
+ Summary: MCP server for SynqTune: check runs and comparison reports, estimate costs and launch fine-tuning pipelines in your own AWS account from your coding agent.
5
+ Author: SynqTune
6
+ Project-URL: Homepage, https://synqtune.com
7
+ Project-URL: Documentation, https://synqtune.com/docs
8
+ Project-URL: Repository, https://github.com/saurabh201153/SLM
9
+ Project-URL: Changelog, https://synqtune.com/changelog
10
+ Keywords: mcp,model-context-protocol,fine-tuning,small-language-models,llm,aws,claude-code
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: mcp<2,>=1.28
23
+ Requires-Dist: httpx>=0.27
24
+
25
+ # SynqTune MCP server
26
+
27
+ Lets a coding agent (Claude Code, Cursor, Claude Desktop, or any MCP client) work with SynqTune: check runs, read comparison reports, estimate costs, and launch template pipelines in your own AWS account.
28
+
29
+ ## Setup
30
+
31
+ 1. In SynqTune, open **Settings → API tokens** and create a token. Tokens are read-only unless you tick **Can launch runs**.
32
+ 2. Add the server to your agent. You need [uv](https://docs.astral.sh/uv/) installed.
33
+
34
+ **Claude Code**
35
+
36
+ ```sh
37
+ claude mcp add synqtune \
38
+ --env SYNQTUNE_API_TOKEN=sqt_... \
39
+ --env SYNQTUNE_URL=https://synqtune.com \
40
+ -- uvx synqtune-mcp
41
+ ```
42
+
43
+ **Other clients** (JSON config):
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "synqtune": {
49
+ "command": "uvx",
50
+ "args": ["synqtune-mcp"],
51
+ "env": { "SYNQTUNE_API_TOKEN": "sqt_...", "SYNQTUNE_URL": "https://synqtune.com" }
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ Without uv: `pip install synqtune-mcp` and use `synqtune-mcp` as the command. From a local checkout: `uvx --from ./mcp_server synqtune-mcp`.
58
+
59
+ ## Tools
60
+
61
+ | Tool | What it does | Token needed |
62
+ |---|---|---|
63
+ | `list_templates` | Task templates, their inputs and the data format each expects | read |
64
+ | `list_runs` | Recent runs with status and links | read |
65
+ | `get_run` | One run's status, current step, error and evaluation summary | read |
66
+ | `get_comparison_report` | Verdict against your current LLM, reasons, weakest labels or fields, worst examples | read |
67
+ | `list_models` | Saved models and their versions | read |
68
+ | `estimate_cost` | GPU cost per hour, per day and for a run; monthly API spend vs GPU serving | read |
69
+ | `launch_pipeline` | Train a model from a template on data in S3 | launch |
70
+
71
+ ## Safety
72
+
73
+ - `launch_pipeline` returns the plan and cost estimate first. It only starts a run when called again with `confirm=true`, so the agent has to show you the plan before anything is billed.
74
+ - Runs use GPU instances in your AWS account, billed by AWS to you.
75
+ - Provider API keys are never passed through the MCP server. A comparison with your current LLM uses the key saved in SynqTune under Settings → Provider keys.
76
+ - Tokens can't create other tokens, read provider keys or change cloud accounts. Revoke a token in Settings and it stops working immediately.
77
+
78
+ ## Example
79
+
80
+ > Train a ticket router on s3://my-bucket/tickets.jsonl with labels billing, refund, shipping, technical, and compare it with gpt-4.1-mini using our production prompt.
81
+
82
+ The agent calls `launch_pipeline` without confirming, shows you the steps, the task instruction and the estimated cost, and launches once you agree. Later, ask "is the router ready to replace GPT?" and it reads the comparison report.
83
+
84
+ ## Development
85
+
86
+ To release, bump `version` in `pyproject.toml` and push a tag `mcp-v<version>` (for example `mcp-v0.1.1`). The *Publish MCP server* workflow builds the package and publishes it to PyPI.
87
+
88
+ Templates in `synqtune_mcp/templates.py` mirror `frontend/src/lib/task-templates.ts`; change both together. Tests: `python -m pytest tests/test_mcp_server.py` from the repository root.
@@ -0,0 +1,64 @@
1
+ # SynqTune MCP server
2
+
3
+ Lets a coding agent (Claude Code, Cursor, Claude Desktop, or any MCP client) work with SynqTune: check runs, read comparison reports, estimate costs, and launch template pipelines in your own AWS account.
4
+
5
+ ## Setup
6
+
7
+ 1. In SynqTune, open **Settings → API tokens** and create a token. Tokens are read-only unless you tick **Can launch runs**.
8
+ 2. Add the server to your agent. You need [uv](https://docs.astral.sh/uv/) installed.
9
+
10
+ **Claude Code**
11
+
12
+ ```sh
13
+ claude mcp add synqtune \
14
+ --env SYNQTUNE_API_TOKEN=sqt_... \
15
+ --env SYNQTUNE_URL=https://synqtune.com \
16
+ -- uvx synqtune-mcp
17
+ ```
18
+
19
+ **Other clients** (JSON config):
20
+
21
+ ```json
22
+ {
23
+ "mcpServers": {
24
+ "synqtune": {
25
+ "command": "uvx",
26
+ "args": ["synqtune-mcp"],
27
+ "env": { "SYNQTUNE_API_TOKEN": "sqt_...", "SYNQTUNE_URL": "https://synqtune.com" }
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ Without uv: `pip install synqtune-mcp` and use `synqtune-mcp` as the command. From a local checkout: `uvx --from ./mcp_server synqtune-mcp`.
34
+
35
+ ## Tools
36
+
37
+ | Tool | What it does | Token needed |
38
+ |---|---|---|
39
+ | `list_templates` | Task templates, their inputs and the data format each expects | read |
40
+ | `list_runs` | Recent runs with status and links | read |
41
+ | `get_run` | One run's status, current step, error and evaluation summary | read |
42
+ | `get_comparison_report` | Verdict against your current LLM, reasons, weakest labels or fields, worst examples | read |
43
+ | `list_models` | Saved models and their versions | read |
44
+ | `estimate_cost` | GPU cost per hour, per day and for a run; monthly API spend vs GPU serving | read |
45
+ | `launch_pipeline` | Train a model from a template on data in S3 | launch |
46
+
47
+ ## Safety
48
+
49
+ - `launch_pipeline` returns the plan and cost estimate first. It only starts a run when called again with `confirm=true`, so the agent has to show you the plan before anything is billed.
50
+ - Runs use GPU instances in your AWS account, billed by AWS to you.
51
+ - Provider API keys are never passed through the MCP server. A comparison with your current LLM uses the key saved in SynqTune under Settings → Provider keys.
52
+ - Tokens can't create other tokens, read provider keys or change cloud accounts. Revoke a token in Settings and it stops working immediately.
53
+
54
+ ## Example
55
+
56
+ > Train a ticket router on s3://my-bucket/tickets.jsonl with labels billing, refund, shipping, technical, and compare it with gpt-4.1-mini using our production prompt.
57
+
58
+ The agent calls `launch_pipeline` without confirming, shows you the steps, the task instruction and the estimated cost, and launches once you agree. Later, ask "is the router ready to replace GPT?" and it reads the comparison report.
59
+
60
+ ## Development
61
+
62
+ To release, bump `version` in `pyproject.toml` and push a tag `mcp-v<version>` (for example `mcp-v0.1.1`). The *Publish MCP server* workflow builds the package and publishes it to PyPI.
63
+
64
+ Templates in `synqtune_mcp/templates.py` mirror `frontend/src/lib/task-templates.ts`; change both together. Tests: `python -m pytest tests/test_mcp_server.py` from the repository root.
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "synqtune-mcp"
3
+ version = "0.1.1"
4
+ description = "MCP server for SynqTune: check runs and comparison reports, estimate costs and launch fine-tuning pipelines in your own AWS account from your coding agent."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ authors = [{ name = "SynqTune" }]
8
+ keywords = ["mcp", "model-context-protocol", "fine-tuning", "small-language-models", "llm", "aws", "claude-code"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.10",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
19
+ ]
20
+ dependencies = [
21
+ "mcp>=1.28,<2",
22
+ "httpx>=0.27",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://synqtune.com"
27
+ Documentation = "https://synqtune.com/docs"
28
+ Repository = "https://github.com/saurabh201153/SLM"
29
+ Changelog = "https://synqtune.com/changelog"
30
+
31
+ [project.scripts]
32
+ synqtune-mcp = "synqtune_mcp.server:main"
33
+
34
+ [build-system]
35
+ requires = ["setuptools>=68"]
36
+ build-backend = "setuptools.build_meta"
37
+
38
+ [tool.setuptools]
39
+ packages = ["synqtune_mcp"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """SynqTune MCP server."""
@@ -0,0 +1,3 @@
1
+ from synqtune_mcp.server import main
2
+
3
+ main()
@@ -0,0 +1,317 @@
1
+ """
2
+ SynqTune MCP server: lets a coding agent (Claude Code, Cursor, ...) check runs and comparison reports, estimate
3
+ costs, and launch template pipelines in the user's own AWS account.
4
+
5
+ Configuration (environment):
6
+ SYNQTUNE_API_TOKEN personal API token from Settings > API tokens (read-only by default)
7
+ SYNQTUNE_URL SynqTune web address, default https://synqtune.com
8
+
9
+ Launching spends money in the user's AWS account, so launch_pipeline only starts a run when called with
10
+ confirm=true after showing the plan and cost estimate, and needs a token created with "Can launch runs".
11
+ """
12
+ import os
13
+ import re
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ import httpx
17
+ from mcp.server.fastmcp import FastMCP
18
+
19
+ from synqtune_mcp.templates import PLUGINS, TEMPLATES
20
+
21
+ SOURCE_EXTENSIONS = (".jsonl", ".json", ".csv", ".tsv", ".parquet", ".txt")
22
+ DEFAULT_INSTANCE = "g4dn.xlarge"
23
+ PROVIDERS = ("anthropic", "openai", "gemini")
24
+
25
+ mcp = FastMCP("synqtune")
26
+
27
+
28
+ # ---------------------------------------------------------------- API client
29
+
30
+ class ApiError(Exception):
31
+ pass
32
+
33
+
34
+ class SynqTuneClient:
35
+ def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None, transport: Optional[httpx.BaseTransport] = None):
36
+ self.base_url = (base_url or os.environ.get("SYNQTUNE_URL") or "https://synqtune.com").rstrip("/")
37
+ self.token = token if token is not None else os.environ.get("SYNQTUNE_API_TOKEN", "")
38
+ self._http = httpx.Client(base_url=f"{self.base_url}/api/v1", timeout=30, transport=transport)
39
+
40
+ def run_url(self, run_id: str) -> str:
41
+ return f"{self.base_url}/monitor/{run_id}"
42
+
43
+ def request(self, method: str, path: str, json: Any = None, params: Optional[Dict] = None) -> Any:
44
+ if not self.token:
45
+ raise ApiError("SYNQTUNE_API_TOKEN is not set. Create a token under Settings > API tokens in SynqTune.")
46
+ res = self._http.request(method, path, json=json, params=params, headers={"Authorization": f"Bearer {self.token}"})
47
+ if res.status_code >= 400:
48
+ try:
49
+ detail = res.json().get("detail")
50
+ except ValueError:
51
+ detail = res.text[:300]
52
+ raise ApiError(f"SynqTune returned {res.status_code}: {detail}")
53
+ return res.json() if res.content else None
54
+
55
+
56
+ _client: Optional[SynqTuneClient] = None
57
+
58
+
59
+ def client() -> SynqTuneClient:
60
+ global _client
61
+ if _client is None:
62
+ _client = SynqTuneClient()
63
+ return _client
64
+
65
+
66
+ # ---------------------------------------------------------------- planning (pure, tested)
67
+
68
+ def schema_defaults(plugin: Optional[Dict]) -> Dict[str, Any]:
69
+ """Default settings of a plugin from its form schema, as the builder applies them."""
70
+ import json as _json
71
+
72
+ try:
73
+ fields = _json.loads((plugin or {}).get("ui_schema") or "[]")
74
+ except ValueError:
75
+ return {}
76
+ return {f["name"]: f["default"] for f in fields if isinstance(f, dict) and f.get("default") is not None and f.get("type") != "file"}
77
+
78
+
79
+ def estimate_minutes(template_id: str, train: Dict, compare: bool) -> float:
80
+ """Same rough estimate as the builder: start-up and installs, training steps, evaluation, optional comparison."""
81
+ steps = float(train.get("max_steps") or 60)
82
+ batch = float(train.get("batch_size") or 2)
83
+ minutes = 12 + steps * 0.4 * batch / 60 + 2 + 100 * 0.3 / 60
84
+ if compare:
85
+ minutes += 300 * (3 if template_id == "summarization" else 0.8) / 60
86
+ minutes += 2 # one preparation step (deduplication)
87
+ return minutes
88
+
89
+
90
+ def build_plan(template_id: str, source_uri: str, inputs: Optional[Dict[str, str]], run_name: str, instance: str,
91
+ compare_provider: str, compare_model: str, compare_prompt: str, plugins: List[Dict], instances: List[Dict]) -> Dict:
92
+ """Validates the request and builds the pipeline config the builder would build for this template."""
93
+ template = TEMPLATES.get(template_id)
94
+ if not template:
95
+ raise ApiError(f"Unknown template '{template_id}'. Use one of: {', '.join(TEMPLATES)}.")
96
+ problems: List[str] = []
97
+ if not re.match(r"^s3://[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]/.+", source_uri or ""):
98
+ problems.append("source_uri must be a full S3 path, e.g. s3://bucket/folder/data.jsonl")
99
+ elif not source_uri.split("?")[0].lower().endswith(SOURCE_EXTENSIONS):
100
+ problems.append(f"source_uri must end with one of: {', '.join(SOURCE_EXTENSIONS)}")
101
+ values = template.resolve_inputs(inputs)
102
+ problems += template.problems(values)
103
+ if compare_provider and compare_provider not in PROVIDERS:
104
+ problems.append(f"compare_provider must be one of: {', '.join(PROVIDERS)}")
105
+ if compare_provider and not compare_model:
106
+ problems.append("compare_model is required when compare_provider is set")
107
+ if template_id == "summarization" and compare_provider:
108
+ problems.append("Comparing summaries needs an LLM judge, which is set up in the builder. Launch without a comparison, or use the builder.")
109
+ price = next((i for i in instances if i.get("id") == instance), None)
110
+ if not price or price.get("type") != "gpu":
111
+ gpus = ", ".join(i["id"] for i in instances if i.get("type") == "gpu")
112
+ problems.append(f"instance must be a GPU instance: {gpus}")
113
+ by_id = {p["id"]: p for p in plugins}
114
+ missing = [p for p in PLUGINS if p not in by_id]
115
+ if missing:
116
+ problems.append(f"SynqTune is missing plugins: {', '.join(missing)}")
117
+ if problems:
118
+ raise ApiError("Can't launch yet:\n- " + "\n- ".join(problems))
119
+
120
+ instruction = template.instruction(values)
121
+ train = {**schema_defaults(by_id["unsloth"]), **template.train}
122
+ evaluation = {
123
+ **schema_defaults(by_id["eval_holdout"]),
124
+ **template.evaluation,
125
+ **template.evaluation_from_inputs(values),
126
+ "compare_provider": compare_provider or "",
127
+ "compare_model": compare_model or "",
128
+ "compare_system_prompt": compare_prompt or "",
129
+ "compare_gpu_hourly_usd": price["on_demand_usd_per_hour"],
130
+ }
131
+ nodes = [
132
+ {"id": "node_source", "type": "source", "plugin": "s3", "config": {"source_uri": source_uri}},
133
+ {"id": "node_step_1", "type": "modification", "plugin": "deduplication", "config": schema_defaults(by_id["deduplication"])},
134
+ {"id": "node_train", "type": "training", "plugin": "unsloth", "config": {
135
+ **train, "task_instruction": instruction, "task_template": template_id, "task_inputs": values,
136
+ "test_split_pct": evaluation.get("test_split_pct", 10), "randomize_seed": evaluation.get("randomize_seed", True), "custom_eval_uri": "",
137
+ }},
138
+ {"id": "node_evaluate", "type": "evaluation", "plugin": "eval_holdout", "config": evaluation},
139
+ {"id": "node_publish", "type": "destination", "plugin": "checkpoint_tracker", "config": {
140
+ **schema_defaults(by_id["checkpoint_tracker"]), "save_as": "new", "model_name": run_name, "version": "1",
141
+ }},
142
+ ]
143
+ minutes = estimate_minutes(template_id, train, bool(compare_provider))
144
+ hourly = float(price["on_demand_usd_per_hour"])
145
+ return {
146
+ "template": template.title,
147
+ "task_instruction": instruction,
148
+ "steps": ["Load data (S3)", "Deduplicate", f"Fine-tune {train['base_model']} ({train['max_steps']} steps)",
149
+ "Evaluate on held-out data" + (f", compared with {compare_provider}/{compare_model}" if compare_provider else ""),
150
+ f"Save model '{run_name}' v1"],
151
+ "instance": instance,
152
+ "cost": {
153
+ "usd_per_hour": hourly,
154
+ "usd_per_day": round(hourly * 24, 2),
155
+ "estimated_minutes": round(minutes),
156
+ "estimated_usd": round(hourly * minutes / 60, 2),
157
+ "basis": "AWS on-demand list price in us-east-1, billed by AWS to your account; rough time estimate",
158
+ },
159
+ "pipeline_config": {"nodes": nodes, "task_template": template_id, "name": run_name},
160
+ }
161
+
162
+
163
+ # ---------------------------------------------------------------- tools
164
+
165
+ @mcp.tool()
166
+ def list_templates() -> List[Dict]:
167
+ """Task templates a pipeline can start from, with the inputs each needs and the data format it expects."""
168
+ return [t.describe() for t in TEMPLATES.values()]
169
+
170
+
171
+ @mcp.tool()
172
+ def list_runs(status: str = "all", limit: int = 10) -> List[Dict]:
173
+ """Recent pipeline runs, newest first. status: all, running, completed, failed or cancelled."""
174
+ data = client().request("GET", "/pipeline/jobs", params={"status": status, "page_size": max(1, min(limit, 50))})
175
+ return [
176
+ {"id": j["id"], "name": j.get("name"), "type": j.get("job_type"), "status": j.get("status"),
177
+ "current_step": j.get("current_stage"), "created_at": j.get("created_at"), "url": client().run_url(j["id"])}
178
+ for j in data.get("jobs", [])
179
+ ]
180
+
181
+
182
+ @mcp.tool()
183
+ def get_run(run_id: str) -> Dict:
184
+ """Status of one run: current step, progress, error, and a summary of its evaluation and comparison."""
185
+ j = client().request("GET", f"/training/{run_id}/status")
186
+ ev = j.get("eval_metrics") or {}
187
+ parity = ev.get("parity") or {}
188
+ return {
189
+ "id": j.get("job_id"), "name": j.get("name"), "status": j.get("status"), "progress_pct": j.get("progress"),
190
+ "current_step": j.get("current_stage"), "details": j.get("stage_details"), "error": j.get("error_message"),
191
+ "started_at": j.get("started_at"), "completed_at": j.get("completed_at"), "output_uri": j.get("output_uri"),
192
+ "evaluation": {k: ev.get(k) for k in ("perplexity", "base_perplexity", "improvement_pct", "samples_evaluated", "held_out") if k in ev} or None,
193
+ "comparison_verdict": parity.get("verdict") if parity.get("status") == "ok" else parity.get("status"),
194
+ "url": client().run_url(run_id),
195
+ }
196
+
197
+
198
+ @mcp.tool()
199
+ def get_comparison_report(run_id: str) -> Dict:
200
+ """
201
+ The comparison between the fine-tuned model and the LLM it should replace: verdict (ready / close / not_ready),
202
+ reasons, scores, the weakest labels or fields, and the examples where the fine-tuned model was worse.
203
+ """
204
+ j = client().request("GET", f"/training/{run_id}/status")
205
+ parity = (j.get("eval_metrics") or {}).get("parity")
206
+ if not parity:
207
+ return {"status": "none", "message": "This run has no comparison. Add one in the Evaluate step (compare against your current LLM)."}
208
+ if parity.get("status") != "ok":
209
+ return {"status": parity.get("status"), "message": parity.get("message")}
210
+ breakdown = parity.get("breakdown") or {}
211
+ weakest = sorted(breakdown.get("rows") or [], key=lambda r: (r.get("slm") or 0) - (r.get("incumbent") or 0))[:5]
212
+ return {
213
+ "verdict": parity.get("verdict"), "reasons": parity.get("reasons"), "task": parity.get("task"), "metric": parity.get("metric"),
214
+ "examples_compared": parity.get("examples_compared"), "tolerance_pts": parity.get("tolerance_pts"),
215
+ "fine_tuned": parity.get("slm"), "incumbent": parity.get("incumbent"),
216
+ "metrics": parity.get("rows"), "weakest": {"by": breakdown.get("group"), "rows": weakest} if weakest else None,
217
+ "cost": parity.get("cost"), "failure_counts": parity.get("failure_counts"),
218
+ "failures": [{k: f.get(k) for k in ("kind", "prompt", "reference", "slm_answer", "incumbent_answer")} for f in (parity.get("failures") or [])[:5]],
219
+ "url": client().run_url(run_id),
220
+ }
221
+
222
+
223
+ @mcp.tool()
224
+ def list_models() -> List[Dict]:
225
+ """Saved models with their latest and production versions."""
226
+ rows = client().request("GET", "/model-registry")
227
+ out = []
228
+ for m in rows or []:
229
+ latest, prod = m.get("latest") or {}, m.get("production") or {}
230
+ out.append({
231
+ "id": m.get("id"), "name": m.get("name"), "versions": m.get("version_count"), "base_models": m.get("base_models"),
232
+ "latest_version": latest.get("version_number"), "latest_status": latest.get("status"),
233
+ "latest_comparison_verdict": (latest.get("eval") or {}).get("parity_verdict"),
234
+ "production_version": prod.get("version_number"),
235
+ })
236
+ return out
237
+
238
+
239
+ @mcp.tool()
240
+ def estimate_cost(template: str = "classification", instance: str = DEFAULT_INSTANCE, with_comparison: bool = False,
241
+ monthly_requests: Optional[float] = None, input_tokens: Optional[float] = None, output_tokens: Optional[float] = None,
242
+ price_in_per_mtok: Optional[float] = None, price_out_per_mtok: Optional[float] = None,
243
+ serving_hours_per_day: float = 24) -> Dict:
244
+ """
245
+ GPU cost of a training run (per hour, per day, rough total for the run) and, if you give current LLM usage and
246
+ prices, the monthly API spend vs serving the model on the same GPU.
247
+ """
248
+ instances = client().request("GET", "/cluster/instances")
249
+ price = next((i for i in instances if i.get("id") == instance), None)
250
+ if not price:
251
+ raise ApiError(f"Unknown instance '{instance}'. GPU instances: {', '.join(i['id'] for i in instances if i.get('type') == 'gpu')}")
252
+ t = TEMPLATES.get(template)
253
+ if not t:
254
+ raise ApiError(f"Unknown template '{template}'. Use one of: {', '.join(TEMPLATES)}.")
255
+ hourly = float(price["on_demand_usd_per_hour"])
256
+ minutes = estimate_minutes(template, t.train, with_comparison)
257
+ out: Dict[str, Any] = {
258
+ "instance": instance, "usd_per_hour": hourly, "usd_per_day": round(hourly * 24, 2),
259
+ "training_run": {"estimated_minutes": round(minutes), "estimated_usd": round(hourly * minutes / 60, 2)},
260
+ "basis": "AWS on-demand list price in us-east-1; spot and your discounts are lower. The time is a rough estimate.",
261
+ }
262
+ if None not in (monthly_requests, input_tokens, output_tokens, price_in_per_mtok, price_out_per_mtok):
263
+ api = monthly_requests * (input_tokens * price_in_per_mtok + output_tokens * price_out_per_mtok) / 1e6
264
+ serving = hourly * min(24, serving_hours_per_day) * 30
265
+ out["monthly"] = {
266
+ "current_api_usd": round(api, 2), "gpu_serving_usd": round(serving, 2), "difference_usd": round(api - serving, 2),
267
+ "tokens_no_longer_sent": int(monthly_requests * (input_tokens + output_tokens)),
268
+ "note": "Quality is checked separately by the comparison report on your own data.",
269
+ }
270
+ return out
271
+
272
+
273
+ @mcp.tool()
274
+ def launch_pipeline(template: str, source_uri: str, run_name: str, inputs: Optional[Dict[str, str]] = None,
275
+ instance: str = DEFAULT_INSTANCE, compare_provider: str = "", compare_model: str = "",
276
+ compare_system_prompt: str = "", cloud_account_id: str = "", confirm: bool = False) -> Dict:
277
+ """
278
+ Train a model from a task template on data in S3, in the user's AWS account.
279
+
280
+ Without confirm=true this only returns the plan and cost estimate: show it to the user and call again with
281
+ confirm=true once they agree. Launching starts GPU instances billed to the user's AWS account.
282
+
283
+ inputs: the template's inputs (see list_templates), e.g. {"labels": "billing, refund, shipping"}.
284
+ compare_*: optionally compare with the LLM the model should replace (provider anthropic, openai or gemini).
285
+ The provider key must already be saved in SynqTune under Settings > Provider keys; keys are never passed here.
286
+ """
287
+ api = client()
288
+ plan = build_plan(template, source_uri, inputs, run_name.strip() or f"{template}-model", instance, compare_provider,
289
+ compare_model, compare_system_prompt, api.request("GET", "/plugins"), api.request("GET", "/cluster/instances"))
290
+ summary = {k: plan[k] for k in ("template", "task_instruction", "steps", "instance", "cost")}
291
+ if not confirm:
292
+ return {"status": "plan", **summary,
293
+ "next": "Nothing has been launched. Show this plan and cost to the user; call again with confirm=true to start the run."}
294
+
295
+ accounts = api.request("GET", "/accounts") or []
296
+ account = next((a for a in accounts if a.get("id") == cloud_account_id), None) if cloud_account_id else (accounts[0] if accounts else None)
297
+ if not account:
298
+ raise ApiError("No connected AWS account found. Connect one in SynqTune first." if not cloud_account_id else f"Cloud account '{cloud_account_id}' not found.")
299
+ config = plan["pipeline_config"]
300
+ for node in config["nodes"]:
301
+ if node["type"] in ("source", "training", "destination"):
302
+ node["config"]["cloud_account_id"] = account["id"]
303
+ project = api.request("POST", "/projects", json={"name": run_name, "description": f"{plan['template']} pipeline (launched via MCP)"})
304
+ launched = api.request("POST", "/pipeline/launch", json={
305
+ "project_id": project["id"], "name": run_name, "job_type": "training", "cloud_account_id": account["id"],
306
+ "pipeline_config": {**config, "cloud_account_id": account["id"]},
307
+ "cluster_config": {"machine_type": instance, "storage_gb": 100, "provider": "aws", "accelerator_type": "gpu"},
308
+ })
309
+ return {"status": "launched", "run_id": launched["job_id"], "url": api.run_url(launched["job_id"]), "aws_account": account.get("name"), **summary}
310
+
311
+
312
+ def main() -> None:
313
+ mcp.run()
314
+
315
+
316
+ if __name__ == "__main__":
317
+ main()
@@ -0,0 +1,120 @@
1
+ """
2
+ Task templates, mirroring frontend/src/lib/task-templates.ts so runs launched by an agent match runs from the
3
+ builder: same steps, training defaults, task instruction and comparison settings. Change both together.
4
+ """
5
+ import json
6
+ import re
7
+ from typing import Callable, Dict, List, Optional
8
+
9
+ BASE_MODEL = "unsloth/gemma-2-2b-it"
10
+ PLUGINS = ["s3", "deduplication", "unsloth", "eval_holdout", "checkpoint_tracker"]
11
+
12
+
13
+ def split_list(value: str) -> List[str]:
14
+ return [v.strip() for v in re.split(r"[,\n]", value or "") if v.strip()]
15
+
16
+
17
+ def _json_object_error(value: str) -> Optional[str]:
18
+ try:
19
+ parsed = json.loads(value)
20
+ except ValueError:
21
+ return "is not valid JSON"
22
+ return None if isinstance(parsed, dict) else "must be a JSON object"
23
+
24
+
25
+ class Input:
26
+ def __init__(self, name: str, description: str, required: bool = False, default: str = "",
27
+ check: Optional[Callable[[str], Optional[str]]] = None):
28
+ self.name, self.description, self.required, self.default, self.check = name, description, required, default, check
29
+
30
+
31
+ class Template:
32
+ def __init__(self, id: str, title: str, summary: str, data: str, train: Dict, evaluation: Dict, inputs: List[Input],
33
+ instruction: Callable[[Dict[str, str]], str], evaluation_from_inputs: Callable[[Dict[str, str]], Dict] = lambda v: {}):
34
+ self.id, self.title, self.summary, self.data = id, title, summary, data
35
+ self.train, self.evaluation, self.inputs = train, evaluation, inputs
36
+ self.instruction, self.evaluation_from_inputs = instruction, evaluation_from_inputs
37
+
38
+ def resolve_inputs(self, given: Optional[Dict[str, str]]) -> Dict[str, str]:
39
+ values = {i.name: i.default for i in self.inputs}
40
+ values.update({k: str(v) for k, v in (given or {}).items() if k in values})
41
+ return values
42
+
43
+ def problems(self, values: Dict[str, str]) -> List[str]:
44
+ out = []
45
+ for i in self.inputs:
46
+ value = (values.get(i.name) or "").strip()
47
+ if i.required and not value:
48
+ out.append(f"'{i.name}' is required: {i.description}")
49
+ elif value and i.check:
50
+ err = i.check(value)
51
+ if err:
52
+ out.append(f"'{i.name}' {err}")
53
+ return out
54
+
55
+ def describe(self) -> Dict:
56
+ return {
57
+ "id": self.id,
58
+ "title": self.title,
59
+ "summary": self.summary,
60
+ "data_format": self.data,
61
+ "inputs": [{"name": i.name, "required": i.required, "default": i.default or None, "description": i.description} for i in self.inputs],
62
+ }
63
+
64
+
65
+ def _labels_check(v: str) -> Optional[str]:
66
+ return None if len(split_list(v)) >= 2 else "needs at least two comma-separated labels"
67
+
68
+
69
+ TEMPLATES: Dict[str, Template] = {t.id: t for t in [
70
+ Template(
71
+ "classification", "Classification & routing", "Pick exactly one label for each input.",
72
+ "Each row: the input text (instruction or prompt column) and its label (output or response column).",
73
+ {"base_model": BASE_MODEL, "max_steps": 300, "learning_rate": "2e-4", "lora_r": 16, "lora_alpha": 16, "batch_size": 4},
74
+ {"compare_task": "classification", "compare_tolerance_pts": 2},
75
+ [Input("labels", "Comma-separated labels; every answer in the data must be one of them.", required=True, check=_labels_check)],
76
+ lambda v: f"Classify the input into exactly one of these labels: {', '.join(split_list(v['labels']))}. Reply with the label only.",
77
+ lambda v: {"compare_labels": ", ".join(split_list(v["labels"]))},
78
+ ),
79
+ Template(
80
+ "extraction", "Structured extraction", "Turn a document into JSON that matches your schema.",
81
+ "Each row: the document text, and the JSON object to extract as the answer.",
82
+ {"base_model": BASE_MODEL, "max_steps": 400, "learning_rate": "2e-4", "lora_r": 16, "lora_alpha": 16, "batch_size": 2},
83
+ {"compare_task": "extraction", "compare_tolerance_pts": 2},
84
+ [Input("schema", "JSON schema of the object to extract (required keys and property types are checked).", required=True, check=_json_object_error)],
85
+ lambda v: ("Extract the fields defined by this JSON schema from the input. Reply with a single JSON object only, "
86
+ f"using null for fields that are not present.\n\nSchema:\n{v['schema']}"),
87
+ lambda v: {"compare_json_schema": v["schema"]},
88
+ ),
89
+ Template(
90
+ "tagging", "Tagging", "Attach every tag that applies, zero or more per input.",
91
+ "Each row: the input text, and its tags as a comma-separated list or JSON array.",
92
+ {"base_model": BASE_MODEL, "max_steps": 300, "learning_rate": "2e-4", "lora_r": 16, "lora_alpha": 16, "batch_size": 4},
93
+ {"compare_task": "tagging", "compare_tolerance_pts": 3},
94
+ [Input("tags", "Optional comma-separated allowed tags; empty allows any tag in the data.")],
95
+ lambda v: ("List every tag that applies to the input"
96
+ + (f", choosing only from: {', '.join(split_list(v['tags']))}" if split_list(v.get("tags", "")) else "")
97
+ + '. Reply with a comma-separated list, or "none".'),
98
+ ),
99
+ Template(
100
+ "redaction", "Redaction", "Remove sensitive details and keep everything else.",
101
+ "Each row: the original text, and the same text with sensitive details replaced.",
102
+ {"base_model": BASE_MODEL, "max_steps": 400, "learning_rate": "2e-4", "lora_r": 16, "lora_alpha": 16, "batch_size": 2},
103
+ {"compare_task": "redaction", "compare_tolerance_pts": 1},
104
+ [Input("entities", "What to remove.", required=True, default="names, email addresses, phone numbers, postal addresses, account and card numbers"),
105
+ Input("placeholder", "Replacement text.", required=True, default="[REDACTED]")],
106
+ lambda v: f"Rewrite the input with all {v['entities']} replaced by {v['placeholder']}. Keep all other text exactly as it is.",
107
+ ),
108
+ Template(
109
+ "summarization", "Summarization", "Short, faithful summaries in your format.",
110
+ "Each row: the text to summarize, and a good summary written by a person.",
111
+ {"base_model": BASE_MODEL, "max_steps": 500, "learning_rate": "2e-4", "lora_r": 16, "lora_alpha": 16, "batch_size": 2},
112
+ # Preference gap runs from -100 to +100 points: 10 means preferred at least ~45% of the time
113
+ {"compare_task": "summarization", "compare_tolerance_pts": 10},
114
+ [Input("length", "Maximum length, e.g. '3 sentences'.", required=True, default="3 sentences"),
115
+ Input("focus", "Optional: what the summary should focus on.")],
116
+ lambda v: (f"Summarize the input in at most {v['length']}"
117
+ + (f", focusing on {v['focus'].strip()}" if (v.get("focus") or "").strip() else "")
118
+ + ". Use only facts from the input."),
119
+ ),
120
+ ]}
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: synqtune-mcp
3
+ Version: 0.1.1
4
+ Summary: MCP server for SynqTune: check runs and comparison reports, estimate costs and launch fine-tuning pipelines in your own AWS account from your coding agent.
5
+ Author: SynqTune
6
+ Project-URL: Homepage, https://synqtune.com
7
+ Project-URL: Documentation, https://synqtune.com/docs
8
+ Project-URL: Repository, https://github.com/saurabh201153/SLM
9
+ Project-URL: Changelog, https://synqtune.com/changelog
10
+ Keywords: mcp,model-context-protocol,fine-tuning,small-language-models,llm,aws,claude-code
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: mcp<2,>=1.28
23
+ Requires-Dist: httpx>=0.27
24
+
25
+ # SynqTune MCP server
26
+
27
+ Lets a coding agent (Claude Code, Cursor, Claude Desktop, or any MCP client) work with SynqTune: check runs, read comparison reports, estimate costs, and launch template pipelines in your own AWS account.
28
+
29
+ ## Setup
30
+
31
+ 1. In SynqTune, open **Settings → API tokens** and create a token. Tokens are read-only unless you tick **Can launch runs**.
32
+ 2. Add the server to your agent. You need [uv](https://docs.astral.sh/uv/) installed.
33
+
34
+ **Claude Code**
35
+
36
+ ```sh
37
+ claude mcp add synqtune \
38
+ --env SYNQTUNE_API_TOKEN=sqt_... \
39
+ --env SYNQTUNE_URL=https://synqtune.com \
40
+ -- uvx synqtune-mcp
41
+ ```
42
+
43
+ **Other clients** (JSON config):
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "synqtune": {
49
+ "command": "uvx",
50
+ "args": ["synqtune-mcp"],
51
+ "env": { "SYNQTUNE_API_TOKEN": "sqt_...", "SYNQTUNE_URL": "https://synqtune.com" }
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ Without uv: `pip install synqtune-mcp` and use `synqtune-mcp` as the command. From a local checkout: `uvx --from ./mcp_server synqtune-mcp`.
58
+
59
+ ## Tools
60
+
61
+ | Tool | What it does | Token needed |
62
+ |---|---|---|
63
+ | `list_templates` | Task templates, their inputs and the data format each expects | read |
64
+ | `list_runs` | Recent runs with status and links | read |
65
+ | `get_run` | One run's status, current step, error and evaluation summary | read |
66
+ | `get_comparison_report` | Verdict against your current LLM, reasons, weakest labels or fields, worst examples | read |
67
+ | `list_models` | Saved models and their versions | read |
68
+ | `estimate_cost` | GPU cost per hour, per day and for a run; monthly API spend vs GPU serving | read |
69
+ | `launch_pipeline` | Train a model from a template on data in S3 | launch |
70
+
71
+ ## Safety
72
+
73
+ - `launch_pipeline` returns the plan and cost estimate first. It only starts a run when called again with `confirm=true`, so the agent has to show you the plan before anything is billed.
74
+ - Runs use GPU instances in your AWS account, billed by AWS to you.
75
+ - Provider API keys are never passed through the MCP server. A comparison with your current LLM uses the key saved in SynqTune under Settings → Provider keys.
76
+ - Tokens can't create other tokens, read provider keys or change cloud accounts. Revoke a token in Settings and it stops working immediately.
77
+
78
+ ## Example
79
+
80
+ > Train a ticket router on s3://my-bucket/tickets.jsonl with labels billing, refund, shipping, technical, and compare it with gpt-4.1-mini using our production prompt.
81
+
82
+ The agent calls `launch_pipeline` without confirming, shows you the steps, the task instruction and the estimated cost, and launches once you agree. Later, ask "is the router ready to replace GPT?" and it reads the comparison report.
83
+
84
+ ## Development
85
+
86
+ To release, bump `version` in `pyproject.toml` and push a tag `mcp-v<version>` (for example `mcp-v0.1.1`). The *Publish MCP server* workflow builds the package and publishes it to PyPI.
87
+
88
+ Templates in `synqtune_mcp/templates.py` mirror `frontend/src/lib/task-templates.ts`; change both together. Tests: `python -m pytest tests/test_mcp_server.py` from the repository root.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ synqtune_mcp/__init__.py
4
+ synqtune_mcp/__main__.py
5
+ synqtune_mcp/server.py
6
+ synqtune_mcp/templates.py
7
+ synqtune_mcp.egg-info/PKG-INFO
8
+ synqtune_mcp.egg-info/SOURCES.txt
9
+ synqtune_mcp.egg-info/dependency_links.txt
10
+ synqtune_mcp.egg-info/entry_points.txt
11
+ synqtune_mcp.egg-info/requires.txt
12
+ synqtune_mcp.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ synqtune-mcp = synqtune_mcp.server:main
@@ -0,0 +1,2 @@
1
+ mcp<2,>=1.28
2
+ httpx>=0.27
@@ -0,0 +1 @@
1
+ synqtune_mcp