diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..3b446daad
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,25 @@
+node_modules
+dist
+.git
+.github
+__tests__
+tests
+src/keboola_mcp_server
+3.10.venv
+3.12.venv
+.venv
+.tox
+.mypy_cache
+.pytest_cache
+*.venv
+coverage.xml
+.coverage
+test-results.xml
+feature_spec
+docs
+*.md
+!README.md
+.env
+.mcp.json
+uv.lock
+pyproject.toml
diff --git a/.github/actions/export-kbc-projects/action.yml b/.github/actions/export-kbc-projects/action.yml
new file mode 100644
index 000000000..e00a7cc4d
--- /dev/null
+++ b/.github/actions/export-kbc-projects/action.yml
@@ -0,0 +1,30 @@
+name: 'Export kbc projects with secrets'
+description: 'Generates projects.json from .github/ci/projects.json, substituting TEST_KBC_PROJECT_* secrets'
+inputs:
+ secrets:
+ description: workflow secrets (pass `toJSON(secrets)`)
+ required: true
+outputs:
+ projects-file:
+ description: Absolute path to the generated projects.json
+ value: ${{ steps.gen.outputs.projects-file }}
+runs:
+ using: 'composite'
+ steps:
+ - name: Generate projects.json file, replace secrets
+ id: gen
+ shell: bash
+ run: |
+ # Export every TEST_KBC_PROJECT_* secret as an env var, then envsubst the template.
+ jqQuery='to_entries[] | select(.key | startswith("TEST_KBC_PROJECT_")) | "\(.key)=\(.value|tostring) "'
+ export $(
+ (jq -r -j "$jqQuery") << EndOfSecrets
+ ${{ inputs.secrets }}
+ EndOfSecrets
+ )
+
+ envsubst < .github/ci/projects.json > projects.json
+
+ # Stable mtime so test caching is not invalidated by regeneration.
+ touch -d '1970-01-01T00:00:01' projects.json
+ echo "projects-file=$(pwd)/projects.json" >> "$GITHUB_OUTPUT"
diff --git a/.github/ci/projects.json b/.github/ci/projects.json
new file mode 100644
index 000000000..92811b6b2
--- /dev/null
+++ b/.github/ci/projects.json
@@ -0,0 +1,37 @@
+[
+ {
+ "host": "connection.europe-west3.gcp.keboola.com",
+ "project": 3053,
+ "stagingStorage": "gcs",
+ "backend": "snowflake",
+ "token": "$TEST_KBC_PROJECT_3053_TOKEN"
+ },
+ {
+ "host": "connection.europe-west3.gcp.keboola.com",
+ "project": 3054,
+ "stagingStorage": "gcs",
+ "backend": "snowflake",
+ "token": "$TEST_KBC_PROJECT_3054_TOKEN"
+ },
+ {
+ "host": "connection.europe-west3.gcp.keboola.com",
+ "project": 3056,
+ "stagingStorage": "gcs",
+ "backend": "bigquery",
+ "token": "$TEST_KBC_PROJECT_3056_TOKEN"
+ },
+ {
+ "host": "connection.europe-west3.gcp.keboola.com",
+ "project": 3057,
+ "stagingStorage": "gcs",
+ "backend": "bigquery",
+ "token": "$TEST_KBC_PROJECT_3057_TOKEN"
+ },
+ {
+ "host": "connection.europe-west3.gcp.keboola.com",
+ "project": 3055,
+ "stagingStorage": "gcs",
+ "backend": "snowflake",
+ "token": "$TEST_KBC_PROJECT_3055_TOKEN"
+ }
+]
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index e5f99620b..580003ea7 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -4,13 +4,11 @@
# Cooldown (supply-chain hardening): Dependabot will not open an update PR for a
# version until it has been published for at least `default-days` days. Most
# malicious package releases are detected and yanked within this window, so we
-# avoid pulling a freshly published (potentially compromised) version. This
-# mirrors the `exclude-newer = "7 days"` setting in pyproject.toml [tool.uv],
-# which guards manual `uv lock` regeneration.
+# avoid pulling a freshly published (potentially compromised) version.
version: 2
updates:
- # Python dependencies managed via uv / pyproject.toml + uv.lock
- - package-ecosystem: "uv"
+ # npm dependencies managed via package.json + package-lock.json
+ - package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
diff --git a/.github/scripts/kaibench-cost-guardrails.py b/.github/scripts/kaibench-cost-guardrails.py
deleted file mode 100644
index 5f50574b7..000000000
--- a/.github/scripts/kaibench-cost-guardrails.py
+++ /dev/null
@@ -1,42 +0,0 @@
-import json
-from pathlib import Path
-
-MAX_TOKENS_PER_QUESTION = 100000
-MAX_TOTAL_TOKENS = 2000000
-
-run_dirs = sorted(Path('results').glob('run_*'), key=lambda p: p.stat().st_mtime)
-if not run_dirs:
- print("::notice::No results/run_* directories found; skipping token usage checks.")
- raise SystemExit(0)
-latest = run_dirs[-1]
-results = []
-results_file = latest / 'results.jsonl'
-if results_file.exists():
- for line in results_file.read_text().splitlines():
- if line.strip():
- try:
- results.append(json.loads(line))
- except json.JSONDecodeError:
- continue
-
-evaluated = [r for r in results if r.get('status') != 'skipped']
-total_tokens = sum(r.get('trace', {}).get('total_tokens', 0) or 0 for r in evaluated)
-violations = []
-
-if total_tokens == 0:
- print("::notice::Token usage not available — KaiClient may need updating")
-else:
- if total_tokens > MAX_TOTAL_TOKENS:
- violations.append(f"Total tokens ({total_tokens:,}) exceeds limit ({MAX_TOTAL_TOKENS:,})")
-
- for r in evaluated:
- qid = r.get('question_id', '?')
- tokens = r.get('trace', {}).get('total_tokens', 0) or 0
- if tokens > MAX_TOKENS_PER_QUESTION:
- violations.append(f"Q{qid}: {tokens:,} tokens exceeds per-question limit ({MAX_TOKENS_PER_QUESTION:,})")
-
- if violations:
- for v in violations:
- print(f"::warning::{v}")
- else:
- print(f"::notice::Token usage OK — {total_tokens:,} total tokens across {len(evaluated)} questions")
diff --git a/.github/scripts/kaibench-parse-results.py b/.github/scripts/kaibench-parse-results.py
deleted file mode 100644
index e6ea4382a..000000000
--- a/.github/scripts/kaibench-parse-results.py
+++ /dev/null
@@ -1,53 +0,0 @@
-import json
-from pathlib import Path
-
-run_dirs = sorted(Path('results').glob('run_*'), key=lambda p: p.stat().st_mtime)
-if not run_dirs:
- raise SystemExit(0)
-latest = run_dirs[-1]
-if not (latest / 'summary.json').exists():
- raise SystemExit(0)
-s = json.loads((latest / 'summary.json').read_text())
-m = s['metrics']
-
-# Load results.jsonl to compute partial count (m dict never contains 'partial' key)
-results = []
-results_file = latest / 'results.jsonl'
-if results_file.exists():
- for line in results_file.read_text().splitlines():
- if line.strip():
- try:
- results.append(json.loads(line))
- except json.JSONDecodeError:
- continue
-evaluated = [r for r in results if r.get('status') != 'skipped']
-partial_count = sum(1 for r in evaluated if r.get('status') == 'partial')
-
-print(f"passed={m['passed']}")
-print(f"failed={m['failed']}")
-print(f"total={m['total_questions']}")
-print(f"pass_rate={m['overall_pass_rate']:.2f}")
-print(f"duration={m['duration_seconds']:.0f}")
-status = 'passed' if m['failed'] == 0 and m.get('errors', 0) == 0 and partial_count == 0 else 'failed'
-print(f"status={status}")
-
-# Count regressions vs previous run (downloaded into prev-results/)
-regressions = 0
-prev_runs = sorted(Path('prev-results').glob('run_*'), key=lambda p: p.stat().st_mtime) if Path('prev-results').exists() else []
-if prev_runs:
- prev_file = prev_runs[-1] / 'results.jsonl'
- if prev_file.exists() and results_file.exists():
- prev_by_qid = {}
- for line in prev_file.read_text().splitlines():
- if line.strip():
- try:
- pr = json.loads(line)
- except json.JSONDecodeError:
- continue
- prev_by_qid[str(pr.get('question_id', ''))] = pr
- for r in evaluated:
- qid = str(r.get('question_id', ''))
- if qid in prev_by_qid:
- if prev_by_qid[qid].get('status') == 'passed' and r.get('status') not in ('passed', 'skipped'):
- regressions += 1
-print(f"regressions={regressions}")
diff --git a/.github/scripts/kaibench-step-summary.py b/.github/scripts/kaibench-step-summary.py
deleted file mode 100644
index 3a31c89a5..000000000
--- a/.github/scripts/kaibench-step-summary.py
+++ /dev/null
@@ -1,346 +0,0 @@
-import json, os
-from pathlib import Path
-
-runs = sorted(Path('results').glob('run_*'), key=lambda p: p.stat().st_mtime)
-if not runs:
- raise SystemExit(0)
-latest = runs[-1]
-if not (latest / 'summary.json').exists():
- raise SystemExit(0)
-s = json.loads((latest / 'summary.json').read_text())
-m = s['metrics']
-mcp_sha = os.environ.get('GITHUB_SHA', 'N/A')[:12]
-
-# Load per-question results for tool usage stats
-results = []
-results_file = latest / 'results.jsonl'
-if results_file.exists():
- for line in results_file.read_text().splitlines():
- if line.strip():
- try:
- results.append(json.loads(line))
- except json.JSONDecodeError:
- continue
-
-evaluated = [r for r in results if r.get('status') != 'skipped']
-
-# Load previous run for regression comparison (downloaded into prev-results/)
-prev_by_qid = {}
-prev_summary = None
-prev_runs = sorted(Path('prev-results').glob('run_*'), key=lambda p: p.stat().st_mtime) if Path('prev-results').exists() else []
-if prev_runs:
- prev_run = prev_runs[-1]
- prev_file = prev_run / 'results.jsonl'
- prev_summary_file = prev_run / 'summary.json'
- if prev_file.exists():
- for line in prev_file.read_text().splitlines():
- if line.strip():
- try:
- pr = json.loads(line)
- except json.JSONDecodeError:
- continue
- prev_by_qid[str(pr.get('question_id', ''))] = pr
- if prev_summary_file.exists():
- prev_summary = json.loads(prev_summary_file.read_text())
-
-# Aggregate tool stats
-all_tool_calls = []
-for r in evaluated:
- all_tool_calls.extend(r.get('trace', {}).get('tool_calls', []))
-total_tools = len(all_tool_calls)
-avg_tools = total_tools / len(evaluated) if evaluated else 0
-avg_duration = sum(r.get('duration_ms', 0) for r in evaluated) / len(evaluated) / 1000 if evaluated else 0
-total_tokens = sum(r.get('trace', {}).get('total_tokens', 0) or 0 for r in evaluated)
-avg_tokens = total_tokens / len(evaluated) if evaluated else 0
-
-# Tool call metrics
-tool_errors = [tc for tc in all_tool_calls if tc.get('is_error')]
-tool_approvals = [tc for tc in all_tool_calls if tc.get('approval_required')]
-tool_denied = [tc for tc in all_tool_calls if tc.get('approval_required') and not tc.get('was_approved')]
-tool_success_rate = (total_tools - len(tool_errors)) / total_tools if total_tools else 0
-
-# Tool name frequency
-from collections import Counter
-tool_freq = Counter(tc.get('tool_name', 'unknown') for tc in all_tool_calls)
-
-# Stream health
-stream_errors = [r for r in evaluated if r.get('trace', {}).get('stream_error')]
-early_terminations = [r for r in evaluated if r.get('trace', {}).get('stream_terminated_early')]
-orphaned_questions = [r for r in evaluated if r.get('trace', {}).get('orphaned_tool_calls')]
-
-print('## KaiBench Evaluation Results')
-print()
-print(f'MCP server commit: `{mcp_sha}`')
-print()
-print('| Metric | Value |')
-print('|--------|-------|')
-print(f'| Run ID | `{s["run_id"]}` |')
-print(f'| Duration | {m["duration_seconds"]:.0f}s |')
-print(f'| **Total** | **{m["total_questions"]}** |')
-print(f'| Passed | {m["passed"]} |')
-print(f'| Failed | {m["failed"]} |')
-print(f'| Skipped | {m.get("skipped", 0)} |')
-print(f'| Errors | {m.get("errors", 0)} |')
-print(f'| **Pass Rate** | **{m["overall_pass_rate"]:.1%}** |')
-avg_score = m.get('average_score')
-if avg_score is not None:
- print(f'| Avg Score | {avg_score:.2f} |')
-partial_count = sum(1 for r in evaluated if r.get('status') == 'partial')
-if partial_count:
- print(f'| Partial | {partial_count} |')
-print(f'| Total Tool Calls | {total_tools} |')
-print(f'| Avg Tool Calls/Question | {avg_tools:.1f} |')
-print(f'| Avg Duration/Question | {avg_duration:.0f}s |')
-print(f'| Total Tokens | {total_tokens:,} |')
-print(f'| Avg Tokens/Question | {avg_tokens:,.0f} |')
-
-# Tool call metrics section
-if all_tool_calls:
- print()
- print('### Tool Call Metrics')
- print()
- print('| Metric | Value |')
- print('|--------|-------|')
- print(f'| Tool Success Rate | {tool_success_rate:.1%} ({total_tools - len(tool_errors)}/{total_tools}) |')
- if tool_errors:
- print(f'| Tool Errors | {len(tool_errors)} |')
- if tool_approvals:
- print(f'| Approvals Required | {len(tool_approvals)} |')
- if tool_denied:
- print(f'| Approvals Denied | {len(tool_denied)} |')
- print()
- print('Tool usage breakdown
')
- print()
- print('| Tool | Calls | Errors |')
- print('|------|-------|--------|')
- error_by_tool = Counter(tc.get('tool_name', 'unknown') for tc in tool_errors)
- for tool_name, count in tool_freq.most_common(20):
- errs = error_by_tool.get(tool_name, 0)
- err_str = str(errs) if errs else '-'
- print(f'| `{tool_name}` | {count} | {err_str} |')
- print()
- print(' ')
-
-# Stream health section
-if stream_errors or early_terminations or orphaned_questions:
- print()
- print('### Stream Health')
- print()
- if stream_errors:
- print(f':warning: **{len(stream_errors)} question(s) had stream errors**')
- for r in stream_errors:
- trace = r.get('trace', {})
- qid = r.get('question_id', '?')
- err = trace.get('stream_error', 'unknown')
- code = trace.get('stream_error_code', '')
- code_str = f' (code: {code})' if code else ''
- print(f'- Q{qid}: {err}{code_str}')
- print()
- if orphaned_questions:
- print(f':warning: **{len(orphaned_questions)} question(s) had orphaned tool calls** (started but no output)')
- for r in orphaned_questions:
- trace = r.get('trace', {})
- qid = r.get('question_id', '?')
- orphaned = trace.get('orphaned_tool_calls', [])
- print(f'- Q{qid}: {len(orphaned)} orphaned call(s)')
- print()
- early_only = [r for r in early_terminations if r not in stream_errors and r not in orphaned_questions]
- if early_only:
- print(f':warning: **{len(early_only)} question(s) had early stream termination**')
- for r in early_only:
- print(f'- Q{r.get("question_id", "?")}')
- print()
-
-# Regression comparison
-if prev_by_qid or prev_summary is not None:
- print()
- print('### Regression Comparison')
- print()
- print(f'Previous run: `{prev_runs[-1].name}`')
- print()
- if prev_summary is not None:
- pm = prev_summary['metrics']
- prev_rate = pm.get('overall_pass_rate', 0)
- curr_rate = m['overall_pass_rate']
- delta = curr_rate - prev_rate
- arrow = ':arrow_up:' if delta > 0 else ':arrow_down:' if delta < 0 else ':left_right_arrow:'
- print('| Metric | Previous | Current | Delta |')
- print('|--------|----------|---------|-------|')
- print(f'| Overall Pass Rate | {prev_rate:.1%} | {curr_rate:.1%} | {arrow} {delta:+.1%} |')
- prev_passed = pm.get('passed', 0)
- curr_passed = m['passed']
- print(f'| Passed | {prev_passed} | {curr_passed} | {curr_passed - prev_passed:+d} |')
-
- # Per-type comparison
- prev_by_type = {t['question_type']: t for t in prev_summary.get('by_question_type', [])}
- for t in s.get('by_question_type', []):
- qt = t['question_type']
- if qt in prev_by_type:
- pt = prev_by_type[qt]
- pr = pt.get('pass_rate', 0)
- cr = t.get('pass_rate', 0)
- td = cr - pr
- ta = ':arrow_up:' if td > 0 else ':arrow_down:' if td < 0 else ':left_right_arrow:'
- print(f'| {qt} | {pr:.1%} | {cr:.1%} | {ta} {td:+.1%} |')
- else:
- print('_Previous run artifacts are incomplete (no `summary.json`) — aggregate comparison unavailable._')
- print()
-
- # Per-question regressions and improvements
- regressions = []
- improvements = []
- if prev_by_qid:
- for r in evaluated:
- qid = str(r.get('question_id', ''))
- if qid in prev_by_qid:
- prev_st = prev_by_qid[qid].get('status', '?')
- curr_st = r.get('status', '?')
- if prev_st == 'passed' and curr_st not in ('passed', 'skipped'):
- regressions.append((qid, r.get('question_type', ''), prev_st, curr_st))
- elif prev_st != 'passed' and curr_st == 'passed':
- improvements.append((qid, r.get('question_type', ''), prev_st, curr_st))
-
- if regressions:
- print()
- print(f':rotating_light: **{len(regressions)} Regression(s)**')
- print()
- print('| Q | Type | Previous | Current |')
- print('|---|------|----------|---------|')
- for qid, qt, ps, cs in regressions:
- print(f'| {qid} | {qt} | {ps} | {cs} |')
-
- if improvements:
- print()
- print(f':tada: **{len(improvements)} Improvement(s)**')
- print()
- print('| Q | Type | Previous | Current |')
- print('|---|------|----------|---------|')
- for qid, qt, ps, cs in improvements:
- print(f'| {qid} | {qt} | {ps} | {cs} |')
-
-# Errors & failures detail
-errors_and_failures = [r for r in evaluated if r.get('status') in ('error', 'failed', 'partial')]
-if errors_and_failures:
- print()
- print('### Errors & Failures Detail')
- print()
- by_type = {}
- for r in errors_and_failures:
- qt = r.get('question_type', 'Unknown')
- by_type.setdefault(qt, []).append(r)
- for qt, items in sorted(by_type.items()):
- print(f'**{qt}**')
- print()
- for r in items:
- qid = r.get('question_id', '?')
- status = r.get('status', '?')
- emoji = {'failed': ':x:', 'error': ':warning:', 'partial': ':large_orange_diamond:'}.get(status, status)
- print(f'{emoji} Q{qid} ({status})
')
- print()
- if r.get('error_message'):
- print(f'**Error:** `{r["error_message"][:200]}`')
- print()
- expected = str(r.get('expected_answer') or '-')[:200]
- extracted = str(r.get('extracted_answer') or '-')[:200]
- print(f'**Expected:** {expected}')
- print()
- print(f'**Extracted:** {extracted}')
- notes = r.get('verification', {}).get('notes', '')
- if notes:
- print()
- print(f'**Notes:** {notes[:300]}')
- score = r.get('verification', {}).get('score', r.get('score'))
- if score is not None:
- print()
- print(f'**Score:** {score}')
- print()
- print(' ')
- print()
-
-# Per-type breakdown
-for t in s.get('by_question_type', []):
- print()
- print(f'### {t["question_type"]}')
- print(f'{t["passed_count"]}/{t["total_count"]} passed ({t.get("pass_rate", 0):.1%})')
- if t.get('skipped_count', 0):
- print(f'_{t["skipped_count"]} skipped_')
-
-# Per-question table (only evaluated questions)
-if evaluated:
- print()
- print('### Per-Question Results')
- print()
- print('| Q | Type | Status | Tools | Tokens | Duration | Expected | Extracted | Notes |')
- print('|---|------|--------|-------|--------|----------|----------|-----------|-------|')
- def sort_key(x):
- qid = str(x.get('question_id', '0'))
- try:
- return (0, int(qid), '')
- except ValueError:
- last = qid.split('-')[-1]
- if last.isdigit():
- return (0, int(last), '')
- return (1, 0, qid)
- def _cell(s: str) -> str:
- return s.replace('|', r'\|').replace('\n', ' ').replace('\r', ' ')
- for r in sorted(evaluated, key=sort_key):
- qid = r.get('question_id', '?')
- qtype = (r.get('question_type') or '')[:12]
- status = r.get('status', '?')
- emoji = {'passed': ':white_check_mark:', 'failed': ':x:', 'error': ':warning:', 'partial': ':large_orange_diamond:'}.get(status, status)
- tools = len(r.get('trace', {}).get('tool_calls', []))
- tokens = r.get('trace', {}).get('total_tokens', 0) or 0
- tokens_str = f'{tokens:,}' if tokens else '-'
- dur = f'{r.get("duration_ms", 0)/1000:.0f}s'
- expected = _cell(str(r.get('expected_answer') or '-')[:25])
- extracted = _cell(str(r.get('extracted_answer') or '-')[:25])
- notes = _cell((r.get('verification', {}).get('notes') or '')[:40])
- health = ''
- trace = r.get('trace', {})
- if trace.get('stream_error'):
- health = ' :boom:'
- elif trace.get('orphaned_tool_calls'):
- health = ' :grey_question:'
- print(f'| {qid} | {qtype} | {emoji}{health} | {tools} | {tokens_str} | {dur} | {expected} | {extracted} | {notes} |')
-
-# MCP Tool Validation detail section
-mcp_results = [r for r in evaluated if r.get('question_type') == 'MCP Tool Validation']
-if mcp_results:
- print()
- print('### MCP Tool Validation Detail')
- print()
- for r in sorted(mcp_results, key=lambda x: (
- (0, int(str(x.get('question_id', '')).split('-')[-1]))
- if str(x.get('question_id', '')).split('-')[-1].isdigit()
- else (1, str(x.get('question_id', '')))
- )):
- phase = r.get('question_id', '?')
- phase_status = r.get('status', '?')
- phase_emoji = {'passed': ':white_check_mark:', 'failed': ':x:', 'error': ':warning:', 'partial': ':large_orange_diamond:'}.get(phase_status, phase_status)
- extracted = r.get('extracted_answer')
- if isinstance(extracted, dict) and extracted:
- pass_ct = sum(1 for t in extracted.values() if t.get('status') == 'PASS')
- fail_ct = sum(1 for t in extracted.values() if t.get('status') == 'FAIL')
- warn_ct = sum(1 for t in extracted.values() if t.get('status') == 'WARN')
- skip_ct = sum(1 for t in extracted.values() if t.get('status') == 'SKIP')
- total_ct = len(extracted)
- print(f'{phase_emoji} {phase} — {pass_ct}/{total_ct} passed'
- + (f', {warn_ct} warn' if warn_ct else '')
- + (f', {fail_ct} fail' if fail_ct else '')
- + (f', {skip_ct} skip' if skip_ct else '')
- + '
')
- print()
- print('| Test | Tool | Status | Trace | Description |')
- print('|------|------|--------|-------|-------------|')
- for tid in sorted(extracted.keys(), key=lambda x: (0, int(x.split('-')[1])) if '-' in x and x.split('-')[1].isdigit() else (1, x)):
- t = extracted[tid]
- st = t.get('status', '?')
- st_emoji = {'PASS': ':white_check_mark:', 'FAIL': ':x:', 'WARN': ':warning:', 'SKIP': ':fast_forward:'}.get(st, st)
- traced = ':white_check_mark:' if t.get('trace_verified') else ':x:'
- desc = _cell((t.get('description') or '')[:60])
- print(f'| {tid} | `{t.get("tool_name", "?")}` | {st_emoji} | {traced} | {desc} |')
- print()
- print(' ')
- else:
- notes = (r.get('verification', {}).get('notes') or r.get('error_message') or '')[:80]
- print(f'- {phase_emoji} **{phase}**: {notes}')
diff --git a/.github/scripts/kaibench-verbose-results.py b/.github/scripts/kaibench-verbose-results.py
deleted file mode 100644
index cd91abe79..000000000
--- a/.github/scripts/kaibench-verbose-results.py
+++ /dev/null
@@ -1,329 +0,0 @@
-import json
-from pathlib import Path
-from collections import Counter
-
-runs = sorted(Path('results').glob('run_*'), key=lambda p: p.stat().st_mtime)
-if not runs:
- print("No runs found in results/")
- exit(0)
-latest = runs[-1]
-results_file = latest / 'results.jsonl'
-if not results_file.exists():
- print("No results.jsonl found")
- exit(0)
-
-results = []
-for _l in results_file.read_text().splitlines():
- if _l.strip():
- try:
- results.append(json.loads(_l))
- except json.JSONDecodeError:
- continue
-evaluated = [r for r in results if r.get('status') != 'skipped']
-summary_file = latest / 'summary.json'
-if not summary_file.exists():
- print("No summary.json found (run may have been interrupted)")
- exit(0)
-summary = json.loads(summary_file.read_text())
-m = summary['metrics']
-
-# Load previous run (downloaded into prev-results/)
-prev_by_qid = {}
-prev_summary = None
-prev_runs = sorted(Path('prev-results').glob('run_*'), key=lambda p: p.stat().st_mtime) if Path('prev-results').exists() else []
-if prev_runs:
- prev_run = prev_runs[-1]
- prev_file = prev_run / 'results.jsonl'
- prev_sum_file = prev_run / 'summary.json'
- if prev_file.exists():
- for line in prev_file.read_text().splitlines():
- if line.strip():
- try:
- pr = json.loads(line)
- except json.JSONDecodeError:
- continue
- prev_by_qid[str(pr.get('question_id', ''))] = pr
- if prev_sum_file.exists():
- prev_summary = json.loads(prev_sum_file.read_text())
-
-W = 72 # display width
-
-# ── Overall Summary ──
-print('=' * W)
-print(f' KAIBENCH VERBOSE RESULTS — {latest.name}')
-print('=' * W)
-print()
-
-passed = m['passed']
-failed = m['failed']
-errors = m.get('errors', 0)
-partial = sum(1 for r in evaluated if r.get('status') == 'partial')
-total = m['total_questions']
-skipped = m.get('skipped', 0)
-avg_score = m.get('average_score', 0)
-dur = m.get('duration_seconds', 0)
-
-# Bar chart for pass/partial/fail/error
-bar_w = 40
-if total > 0:
- p_w = round(passed / total * bar_w)
- pt_w = round(partial / total * bar_w)
- f_w = round(failed / total * bar_w)
- e_w = bar_w - p_w - pt_w - f_w
- bar = '\u2588' * p_w + '\u2593' * pt_w + '\u2591' * f_w + '\u00b7' * max(0, e_w)
-else:
- bar = '\u00b7' * bar_w
-print(f' [{bar}] {passed}/{total} passed ({m["overall_pass_rate"]:.1%})')
-legend_parts = [f'\u2588 passed={passed}']
-if partial: legend_parts.append(f'\u2593 partial={partial}')
-if failed: legend_parts.append(f'\u2591 failed={failed}')
-if errors or skipped:
- dot_parts = []
- if errors: dot_parts.append(f'errors={errors}')
- if skipped: dot_parts.append(f'skipped={skipped}')
- legend_parts.append('\u00b7 ' + ', '.join(dot_parts))
-print(f' {" | ".join(legend_parts)}')
-print(f' Avg Score: {avg_score:.2f} | Duration: {dur:.0f}s')
-print()
-
-# ── Aggregate Tool & Token Stats ──
-all_tool_calls = []
-for r in evaluated:
- all_tool_calls.extend(r.get('trace', {}).get('tool_calls', []))
-total_tools = len(all_tool_calls)
-total_tokens = sum(r.get('trace', {}).get('total_tokens', 0) or 0 for r in evaluated)
-durations = [r.get('duration_ms', 0) / 1000 for r in evaluated]
-tool_counts = [len(r.get('trace', {}).get('tool_calls', [])) for r in evaluated]
-token_counts = [r.get('trace', {}).get('total_tokens', 0) or 0 for r in evaluated]
-n = len(evaluated) or 1
-
-tool_errors = [tc for tc in all_tool_calls if tc.get('is_error')]
-tool_approvals = [tc for tc in all_tool_calls if tc.get('approval_required')]
-tool_denied = [tc for tc in all_tool_calls if tc.get('approval_required') and not tc.get('was_approved')]
-
-print('-' * W)
-print(' AGGREGATE METRICS')
-print('-' * W)
-print(f' {"Tool Calls":<24} total={total_tools:<8} avg={total_tools/n:<8.1f} '
- f'min={min(tool_counts) if tool_counts else 0:<6} max={max(tool_counts) if tool_counts else 0}')
-print(f' {"Tokens":<24} total={total_tokens:<8,} avg={total_tokens/n:<8,.0f} '
- f'min={min(token_counts) if token_counts else 0:<6,} max={max(token_counts) if token_counts else 0:,}')
-print(f' {"Duration (s)":<24} total={sum(durations):<8.0f} avg={sum(durations)/n:<8.0f} '
- f'min={min(durations) if durations else 0:<6.0f} max={max(durations) if durations else 0:.0f}')
-if total_tools:
- err_rate = len(tool_errors) / total_tools
- print(f' {"Tool Success Rate":<24} {(1 - err_rate):.1%} ({total_tools - len(tool_errors)}/{total_tools})'
- + (f' | errors={len(tool_errors)}' if tool_errors else ''))
-if tool_approvals:
- print(f' {"Approvals":<24} required={len(tool_approvals)} denied={len(tool_denied)}')
-print()
-
-# ── Tool Usage Breakdown (top 15) ──
-if all_tool_calls:
- tool_freq = Counter(tc.get('tool_name', '?') for tc in all_tool_calls)
- error_by_tool = Counter(tc.get('tool_name', '?') for tc in tool_errors)
- print('-' * W)
- print(' TOOL USAGE (top 15)')
- print('-' * W)
- print(f' {"Tool":<36} {"Calls":>6} {"Errors":>7} {"Err%":>6}')
- print(f' {"─" * 36} {"─" * 6} {"─" * 7} {"─" * 6}')
- for name, count in tool_freq.most_common(15):
- errs = error_by_tool.get(name, 0)
- err_pct = f'{errs/count:.0%}' if errs else '-'
- print(f' {name:<36} {count:>6} {errs:>7} {err_pct:>6}')
- print()
-
-# ── Stream Health ──
-stream_errors = [r for r in evaluated if r.get('trace', {}).get('stream_error')]
-early_terms = [r for r in evaluated if r.get('trace', {}).get('stream_terminated_early')]
-orphaned = [r for r in evaluated if r.get('trace', {}).get('orphaned_tool_calls')]
-if stream_errors or early_terms or orphaned:
- print('-' * W)
- print(' STREAM HEALTH ISSUES')
- print('-' * W)
- for r in stream_errors:
- trace = r.get('trace', {})
- qid = r.get('question_id', '?')
- err = trace.get('stream_error', '?')
- code = trace.get('stream_error_code', '')
- code_s = f' (code: {code})' if code else ''
- print(f' [STREAM_ERR] Q{qid}: {err}{code_s}')
- for r in orphaned:
- qid = r.get('question_id', '?')
- orph = r.get('trace', {}).get('orphaned_tool_calls', [])
- print(f' [ORPHANED] Q{qid}: {len(orph)} orphaned tool call(s)')
- for r in early_terms:
- if r not in stream_errors and r not in orphaned:
- print(f' [EARLY_TERM] Q{r.get("question_id", "?")}: stream terminated early')
- print()
-
-# ── By Question Type (with partial, avg score, tool/token stats) ──
-print('-' * W)
-print(' BY QUESTION TYPE')
-print('-' * W)
-by_type = {}
-for r in evaluated:
- qt = r.get('question_type', 'Unknown')
- by_type.setdefault(qt, []).append(r)
-for qt in sorted(by_type):
- items = by_type[qt]
- t_passed = sum(1 for r in items if r.get('status') == 'passed')
- t_failed = sum(1 for r in items if r.get('status') == 'failed')
- t_partial = sum(1 for r in items if r.get('status') == 'partial')
- t_error = sum(1 for r in items if r.get('status') == 'error')
- t_total = len(items)
- t_rate = t_passed / t_total if t_total else 0
- t_scores = [r.get('verification', {}).get('score', r.get('score')) for r in items]
- t_scores = [s for s in t_scores if s is not None and isinstance(s, (int, float))]
- t_avg_score = sum(t_scores) / len(t_scores) if t_scores else 0
- t_tools = sum(len(r.get('trace', {}).get('tool_calls', [])) for r in items)
- t_tokens = sum(r.get('trace', {}).get('total_tokens', 0) or 0 for r in items)
- t_dur = sum(r.get('duration_ms', 0) for r in items) / 1000
- print()
- bw = 20
- pw = round(t_passed / t_total * bw) if t_total else 0
- ptw = round(t_partial / t_total * bw) if t_total else 0
- fw = bw - pw - ptw
- type_bar = '\u2588' * pw + '\u2593' * ptw + '\u2591' * fw
- print(f' {qt}')
- print(f' [{type_bar}] {t_passed}/{t_total} passed ({t_rate:.0%})'
- + (f' + {t_partial} partial' if t_partial else '')
- + (f' + {t_error} errors' if t_error else ''))
- print(f' avg_score={t_avg_score:.2f} tools={t_tools} '
- f'tokens={t_tokens:,} duration={t_dur:.0f}s')
-
-print()
-
-# ── Regression Comparison ──
-regressions = []
-improvements = []
-if prev_by_qid or prev_summary:
- for r in evaluated:
- qid = str(r.get('question_id', ''))
- if qid in prev_by_qid:
- ps = prev_by_qid[qid].get('status', '?')
- cs = r.get('status', '?')
- if ps == 'passed' and cs != 'passed':
- regressions.append((qid, r.get('question_type', ''), ps, cs))
- elif ps != 'passed' and cs == 'passed':
- improvements.append((qid, r.get('question_type', ''), ps, cs))
- if prev_summary or regressions or improvements:
- print('-' * W)
- print(' REGRESSION COMPARISON')
- print('-' * W)
- if prev_summary:
- pm = prev_summary['metrics']
- prev_rate = pm.get('overall_pass_rate', 0)
- curr_rate = m['overall_pass_rate']
- delta = curr_rate - prev_rate
- arrow = '\u25b2' if delta > 0 else '\u25bc' if delta < 0 else '='
- print(f' Pass rate: {prev_rate:.1%} -> {curr_rate:.1%} ({arrow} {delta:+.1%})')
- if regressions:
- print(f' REGRESSIONS ({len(regressions)}):')
- for qid, qt, ps, cs in regressions:
- print(f' Q{qid} ({qt}): {ps} -> {cs}')
- if improvements:
- print(f' IMPROVEMENTS ({len(improvements)}):')
- for qid, qt, ps, cs in improvements:
- print(f' Q{qid} ({qt}): {ps} -> {cs}')
- if regressions or improvements:
- print()
-
-# ── Per-Question Detail ──
-print('-' * W)
-print(' PER-QUESTION DETAIL')
-print('-' * W)
-
-def sort_key(x):
- qid = str(x.get('question_id', '0'))
- try:
- return (0, int(qid), '')
- except ValueError:
- last = qid.split('-')[-1]
- if last.isdigit():
- return (0, int(last), '')
- return (1, 0, qid)
-
-for r in sorted(evaluated, key=sort_key):
- qid = str(r.get('question_id', '?'))
- status = r.get('status', '?')
- qtype = r.get('question_type', '')
- score = r.get('verification', {}).get('score', r.get('score', '-'))
- trace = r.get('trace', {})
- tc_list = trace.get('tool_calls', [])
- tools = len(tc_list)
- tokens = trace.get('total_tokens', 0) or 0
- input_tok = trace.get('input_tokens', 0) or 0
- output_tok = trace.get('output_tokens', 0) or 0
- dur_s = r.get('duration_ms', 0) / 1000
-
- icon = {'passed': '\u2714', 'failed': '\u2718', 'error': '\u26a0', 'partial': '\u25d2'}.get(status, '?')
-
- # Regression indicator
- reg = ''
- if qid in prev_by_qid:
- ps = prev_by_qid[qid].get('status', '?')
- if ps == 'passed' and status != 'passed':
- reg = ' <<< REGRESSED'
- elif ps != 'passed' and status == 'passed':
- reg = ' >>> IMPROVED'
-
- print()
- print(f' {icon} Q{qid} [{status.upper()}] — {qtype}{reg}')
- print(f' score={score} tools={tools} tokens={tokens:,}'
- + (f' (in={input_tok:,} out={output_tok:,})' if input_tok or output_tok else '')
- + f' duration={dur_s:.0f}s')
-
- # Stream health
- if trace.get('stream_error'):
- err = trace.get('stream_error', '?')
- code = trace.get('stream_error_code', '')
- print(f' STREAM ERROR: {err}' + (f' (code: {code})' if code else ''))
- if trace.get('orphaned_tool_calls'):
- print(f' ORPHANED TOOL CALLS: {len(trace["orphaned_tool_calls"])}')
-
- # Tool call summary for this question
- if tc_list:
- tc_freq = Counter(tc.get('tool_name', '?') for tc in tc_list)
- tc_errs = [tc for tc in tc_list if tc.get('is_error')]
- tool_summary = ', '.join(f'{n}x{c}' if c > 1 else n for n, c in tc_freq.most_common(8))
- if len(tc_freq) > 8:
- tool_summary += f' (+{len(tc_freq) - 8} more)'
- print(f' tools: {tool_summary}')
- if tc_errs:
- err_names = Counter(tc.get('tool_name', '?') for tc in tc_errs)
- print(f' tool errors: {", ".join(f"{n}({c})" for n, c in err_names.most_common())}')
-
- # Answer detail for non-passed
- if status == 'error':
- err = r.get('error_message', '')
- if err:
- print(f' ERROR: {err[:300]}')
- elif status in ('failed', 'partial'):
- expected = str(r.get('expected_answer', ''))[:150]
- extracted = str(r.get('extracted_answer', ''))[:150]
- notes = r.get('verification', {}).get('notes', '')[:200]
- print(f' expected: {expected}')
- print(f' extracted: {extracted}')
- if notes:
- print(f' notes: {notes}')
-
- # MCP Tool Validation sub-tests
- if qtype == 'MCP Tool Validation':
- extracted = r.get('extracted_answer')
- if isinstance(extracted, dict) and extracted:
- sub_pass = sum(1 for t in extracted.values() if t.get('status') == 'PASS')
- sub_fail = sum(1 for t in extracted.values() if t.get('status') == 'FAIL')
- sub_warn = sum(1 for t in extracted.values() if t.get('status') == 'WARN')
- sub_skip = sum(1 for t in extracted.values() if t.get('status') == 'SKIP')
- print(f' sub-tests: {sub_pass} pass, {sub_fail} fail, '
- f'{sub_warn} warn, {sub_skip} skip / {len(extracted)} total')
- for tid in sorted(extracted.keys(), key=lambda x: (0, int(x.split('-')[1])) if '-' in x and x.split('-')[1].isdigit() else (1, x)):
- t = extracted[tid]
- st = t.get('status', '?')
- if st != 'PASS':
- desc = (t.get('description') or '')[:80]
- print(f' [{st}] {tid} ({t.get("tool_name", "?")}) — {desc}')
-
-print()
-print('=' * W)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b5c166510..30d3dc307 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,183 +2,161 @@ name: CI
# How the CI Pipeline Works
# - The CI workflow runs on all push events and pull requests.
-# - For pull requests from forks (coming from a different repository), we skip jobs/actions that need secrets (e.g.,
-# publishing results, integration tests) to avoid exposing them. Only safe checks like local tests and flake8 are run.
-# - For pull requests from branches in this repository, the workflow is skipped to avoid running it twice (once for the
-# push and once for the PR).
-# - The full workflow (including jobs requiring secrets) runs only on pushes events to branches in this repository
-on: [ push, pull_request ]
+# - For pull requests from forks (coming from a different repository), we still run the full
+# build/test/lint matrix — none of those steps need secrets.
+# - For pull requests from branches in this repository, the workflow is skipped to avoid running
+# it twice (once for the push and once for the PR).
+# - npm publish + Anthropic registration run only on semantic version tags (vX.Y.Z) pushed to
+# this repository.
+on: [push, pull_request]
concurrency: ci-${{ github.ref }}
permissions:
contents: read
- checks: write
jobs:
build:
name: Build, test and package
- # run this job only for push (avoiding pull requests from the same repo) or for pull requests from different repo
+ # run this job only for push (avoiding pull requests from the same repo) or for pull requests from a different repo
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest
strategy:
matrix:
- python-version: ["3.10", "3.11", "3.12"]
+ node-version: ['22', '24']
outputs:
is_semantic_tag: ${{ steps.get_package_version.outputs.is_semantic_tag }}
tag: ${{ steps.get_package_version.outputs.tag }}
version: ${{ steps.get_package_version.outputs.version }}
- wheel_artifact_id: ${{ steps.wheel_artifact_upload.outputs.artifact-id }}
steps:
- uses: actions/checkout@v6
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v6
+ - name: Set up Node ${{ matrix.node-version }}
+ uses: actions/setup-node@v4
with:
- python-version: ${{ matrix.python-version }}
+ node-version: ${{ matrix.node-version }}
+ cache: npm
- name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install uv
- uv sync --frozen --no-editable --extra dev
-
- # Runs tests with coverage and generates coverage.xml file and test-results.xml file
- # and checks flake8 code-style formatting that is compatible with isort & black
- # and verifies TOOLS.md is up-to-date with tool definitions
- # see setup in pyproject.toml
- - name: Unit tests, code style and documentation check
- run: |
- uv run tox
-
- - name: Publish test results
- uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
- # don't fail the job if the Checks API call flakes — the tests themselves already ran
- continue-on-error: true
- # run this step even if a previous step failed, but only for push events or pull requests from the same repo
- if: (success() || failure()) && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository)
- with:
- name: Test results (${{ matrix.python-version }})
- path: ./test-results.xml
- reporter: 'java-junit'
-
- - name: Upload coverage to Codecov
- # run this step only for push events or pull requests from the same repo
- if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
- uses: codecov/codecov-action@v7.0.0
- with:
- file: ./coverage.xml
- fail_ci_if_error: false # Codecov upload is best-effort — transient service outages should not block CI
- token: ${{ secrets.CODECOV_TOKEN }}
+ run: npm ci
- - name: Build wheels package
- run: |
- uv build --wheel --no-sources
+ - name: Type-check
+ run: npm run type-check
+
+ - name: Lint and format check
+ run: npm run lint
+
+ - name: Unit tests
+ run: npm test
+
+ - name: Verify TOOLS.md is up to date
+ run: npm run check:tools-docs
+
+ - name: Build package
+ run: npm run build
+ env:
+ SKIP_ENV_VALIDATION: '1'
- name: Get package version
id: get_package_version
- if: matrix.python-version == '3.10'
+ if: matrix.node-version == '22'
run: |
- VERSION=`uv run python3 -c 'import importlib.metadata; print(importlib.metadata.version("keboola_mcp_server"))'`
+ VERSION=$(node -p "require('./package.json').version")
TAG="${GITHUB_REF##*/}"
IS_SEMANTIC_TAG=$(echo "$TAG" | grep -q '^v\?[0-9]\+\.[0-9]\+\.[0-9]\+$' && echo true || echo false)
echo "Version = '$VERSION', Tag = '$TAG', is semantic tag = '$IS_SEMANTIC_TAG'"
- echo "is_semantic_tag=$IS_SEMANTIC_TAG" >> $GITHUB_OUTPUT
- echo "tag=$TAG" >> $GITHUB_OUTPUT
- echo "version=${VERSION}" >> $GITHUB_OUTPUT
-
- - name: Upload wheel package
- id: wheel_artifact_upload
- # run this step only for push events or pull requests from the same repo
- if: matrix.python-version == '3.10' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository)
- uses: actions/upload-artifact@v7
- with:
- name: keboola_mcp_server-${{ steps.get_package_version.outputs.version }}-py3-none-any.whl
- path: dist/keboola_mcp_server-${{ steps.get_package_version.outputs.version }}-py3-none-any.whl
- if-no-files-found: error
- compression-level: 0 # wheels are ZIP archives
- retention-days: 7
+ echo "is_semantic_tag=$IS_SEMANTIC_TAG" >> "$GITHUB_OUTPUT"
+ echo "tag=$TAG" >> "$GITHUB_OUTPUT"
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
integration_tests:
- name: Integration Tests
- needs: build
- # run this job only for push events (not pull requests)
+ name: Integration tests
+ # Runs in parallel with `build` (independent job) so the fast unit lane isn't blocked.
+ # Push-only: PRs from forks have no access to the project-pool secrets.
if: github.event_name == 'push'
runs-on: ubuntu-latest
- strategy:
- matrix:
- python-version: ["3.10", "3.11", "3.12"]
- # This ensures tests run sequentially, not in parallel
- max-parallel: 1
steps:
- uses: actions/checkout@v6
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v6
+ - name: Set up Node
+ uses: actions/setup-node@v4
with:
- python-version: ${{ matrix.python-version }}
+ node-version: '22'
+ cache: npm
- name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install uv
- uv sync --frozen --no-editable --extra dev
+ run: npm ci
- - name: Integration tests
- # run actual tests only for pushes to the same repository (not forks)
- if: github.repository == github.event.repository.full_name
- env:
- INTEGTEST_STORAGE_TOKENS: ${{ secrets.INTEGTEST_STORAGE_TOKENS }}
- INTEGTEST_POOL_STORAGE_API_URL: ${{ vars.INTEGTEST_POOL_STORAGE_API_URL }}
- INTEGTEST_STORAGE_TOKEN_STORAGE_BRANCHES: ${{ secrets.INTEGTEST_STORAGE_TOKEN_STORAGE_BRANCHES }}
- run: |
- uv run tox -e integtests
+ # Postgres + pgvector backs the docs-search index (docs_query / find_component_id).
+ # The docs integ test seeds it with the deterministic stub embedder and reads it back.
+ - name: Start pgvector with docker compose
+ run: docker compose up -d --wait pgvector
- - name: Skip integration tests for forks
- # show a message when tests are skipped for forks
- if: github.repository != github.event.repository.full_name
- run: |
- echo "Integration tests skipped for fork repository"
- echo "Dependencies installed successfully - setup is working correctly"
-
- - name: Publish integration test results
- uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
- # don't fail the job if the Checks API call flakes — the tests themselves already ran
- continue-on-error: true
- # publish results only when actual tests were run (same repository)
- if: (always()) && github.repository == github.event.repository.full_name
+ # Unwrap the project pool: substitute $TEST_KBC_PROJECT_*_TOKEN secrets into
+ # .github/ci/projects.json -> ./projects.json (mirrors keboola/go-monorepo).
+ - name: Export Keboola test projects
+ uses: ./.github/actions/export-kbc-projects
with:
- name: Integration test results (${{ matrix.python-version }})
- path: ./integtest-results.xml
- reporter: 'java-junit'
+ secrets: ${{ toJSON(secrets) }}
- deploy_to_pypi:
- name: Deploy to pypi.org
+ - name: Integration tests
+ env:
+ # Cross-runner project lease via the shared redis (same key scheme as go-monorepo,
+ # so leases coordinate with other consumers of the pool).
+ TEST_MCP_PROJECTS_LOCK_HOST: ${{ vars.TEST_MCP_PROJECTS_LOCK_HOST }}
+ TEST_MCP_PROJECTS_LOCK_PASSWORD: ${{ secrets.TEST_MCP_PROJECTS_LOCK_PASSWORD }}
+ # Path to the pool file, taken from the repo/org variable you maintain centrally
+ # (same convention as keboola/go-monorepo). The export step above writes the file
+ # of this name at the workspace root.
+ TEST_KBC_PROJECTS_FILE: ${{ github.workspace }}/${{ vars.TEST_KBC_PROJECTS_FILE }}
+ # Docs-search index: the docker-compose pgvector above + the deterministic offline
+ # embedder. Enables docs_query / find_component_id for the docs integ test; the
+ # rest of the suite is unaffected.
+ DATABASE_URL: postgres://mcp:mcp@localhost:5432/docs
+ DOCS_EMBEDDER_MODEL: stub
+ DOCS_EMBEDDER_DIM: '3072'
+ run: npm run test:integ
+
+ publish_npm:
+ name: Publish to npm
needs:
- build
- - integration_tests
runs-on: ubuntu-latest
+ # Publish only on a semantic vX.Y.Z tag that matches package.json's version.
if: |
startsWith(github.ref, 'refs/tags/') &&
needs.build.outputs.is_semantic_tag == 'true' &&
needs.build.outputs.tag == format('v{0}', needs.build.outputs.version)
+ permissions:
+ contents: read
+ id-token: write # npm provenance
steps:
- - name: Download wheel package
- uses: actions/download-artifact@v8
- with:
- name: keboola_mcp_server-${{ needs.build.outputs.version }}-py3-none-any.whl
- path: dist/
+ - uses: actions/checkout@v6
- - name: Publish package
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
+ - name: Set up Node
+ uses: actions/setup-node@v4
with:
- user: __token__
- password: ${{ secrets.PYPI_API_TOKEN }}
+ node-version: '22'
+ cache: npm
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build package
+ run: npm run build
+ env:
+ SKIP_ENV_VALIDATION: '1'
+
+ - name: Publish
+ run: npm publish --provenance --access public
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
register-with-anthropic:
name: Register with Anthropic
needs:
- build
- - deploy_to_pypi
+ - publish_npm
runs-on: ubuntu-latest
if: |
startsWith(github.ref, 'refs/tags/') &&
diff --git a/.github/workflows/openwiki-update.yml b/.github/workflows/openwiki-update.yml
new file mode 100644
index 000000000..cc7139368
--- /dev/null
+++ b/.github/workflows/openwiki-update.yml
@@ -0,0 +1,50 @@
+name: OpenWiki Update
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: "0 8 * * *"
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ update:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+
+ - name: Install OpenWiki
+ run: npm install --global openwiki
+
+ - name: Run OpenWiki
+ run: openwiki code --update --print
+ env:
+ OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
+ OPENWIKI_MODEL_ID: z-ai/glm-5.2
+ LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
+ LANGCHAIN_PROJECT: openwiki
+ LANGCHAIN_TRACING_V2: "true"
+
+ - name: Create OpenWiki update pull request
+ uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
+ with:
+ add-paths: |
+ openwiki
+ AGENTS.md
+ CLAUDE.md
+ .github/workflows/openwiki-update.yml
+ branch: openwiki/update
+ commit-message: "docs: update OpenWiki"
+ title: "docs: update OpenWiki"
+ body: |
+ Automated OpenWiki documentation update.
+
+ This PR was generated by the scheduled OpenWiki workflow.
diff --git a/.gitignore b/.gitignore
index d0145cacd..17a92cdbc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,12 @@ venv
# Environment variables
.env
+# Local integration-test project pool (contains real Storage API tokens) — never commit.
+# The committed template with $TEST_KBC_PROJECT_*_TOKEN placeholders lives at
+# .github/ci/projects.json (anchored ignores below don't match that path).
+/projects.json
+/projects.local.json
+
# Test related files
.coverage
.coverage.*
@@ -32,4 +38,9 @@ integtest-results.xml
test-results.xml
.tox/
local_testing
-.mcp.json
\ No newline at end of file
+.mcp.json
+# Node / TypeScript (MCP server rewrite)
+node_modules/
+dist/
+*.tsbuildinfo
+.DS_Store
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
new file mode 100644
index 000000000..97799c1f3
--- /dev/null
+++ b/.oxfmtrc.json
@@ -0,0 +1,15 @@
+{
+ "printWidth": 100,
+ "tabWidth": 2,
+ "singleQuote": true,
+ "arrowParens": "always",
+ "jsxSingleQuote": false,
+ "bracketSameLine": false,
+ "sortPackageJson": false,
+ "ignorePatterns": [
+ "dist",
+ "**/CHANGELOG.md",
+ "src/resources",
+ "src/tools/storage-schema.json"
+ ]
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..8c941b6d2
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,9 @@
+
+
+## OpenWiki
+
+This repository uses OpenWiki for recurring code documentation. Start with `openwiki/quickstart.md`, then follow its links to architecture, workflows, domain concepts, operations, integrations, testing guidance, and source maps.
+
+The scheduled OpenWiki GitHub Actions workflow refreshes the repository wiki. Do not hand-edit generated OpenWiki pages unless explicitly asked; prefer updating source code/docs and letting OpenWiki regenerate.
+
+
diff --git a/CLAUDE.md b/CLAUDE.md
index 5485b8277..ef0f77247 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -155,3 +155,13 @@ server always reflecting your latest code changes:
## Security Considerations
- When whitelisting domains in OAuth, prefer **explicit domain lists over regex patterns**
- Regex could unintentionally allow future domains that weren't reviewed (principle of least privilege)
+
+
+
+## OpenWiki
+
+This repository uses OpenWiki for recurring code documentation. Start with `openwiki/quickstart.md`, then follow its links to architecture, workflows, domain concepts, operations, integrations, testing guidance, and source maps.
+
+The scheduled OpenWiki GitHub Actions workflow refreshes the repository wiki. Do not hand-edit generated OpenWiki pages unless explicitly asked; prefer updating source code/docs and letting OpenWiki regenerate.
+
+
diff --git a/Dockerfile b/Dockerfile
index 38fb9f01b..b363bc14a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,44 +1,67 @@
-# Use a Python image with uv pre-installed
-FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS uv
+# syntax=docker/dockerfile:1
+#
+# Node multi-stage build for @keboola/mcp-server (replaces the Python/uv image).
+# Build-time inputs are ARGs; runtime configuration is ENV — kept strictly
+# separate so a build never needs runtime secrets and the running container is
+# configured only at `docker run` / k8s.
-# Install the project into /app
+# Pin a specific Node 22 patch rather than a floating tag (matches kai-agent's
+# stance) so a bad upstream release can't silently change the runtime.
+FROM node:22.20.0-slim AS base
WORKDIR /app
+ENV NODE_ENV=production
-# Enable bytecode compilation
-ENV UV_COMPILE_BYTECODE=1
+# ----------------------------------------------------------------------------
+# Stage: deps — install ALL deps (incl. dev) from the lockfile for the build.
+# ----------------------------------------------------------------------------
+FROM base AS deps
+COPY package.json package-lock.json ./
+RUN --mount=type=cache,target=/root/.npm npm ci --include=dev
-# Copy from the cache instead of linking since it's a mounted volume
-ENV UV_LINK_MODE=copy
+# ----------------------------------------------------------------------------
+# Stage: builder — compile TypeScript to dist/. SKIP_ENV_VALIDATION keeps the
+# build from requiring any runtime env var (build-time vs run-time separation).
+# ----------------------------------------------------------------------------
+FROM base AS builder
+# Build-time-only metadata (consumed by the build, not the runtime contract).
+ARG APP_VERSION=DEV
+ENV SKIP_ENV_VALIDATION=1
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+RUN npm run build
-# Install the project's dependencies using the lockfile and settings
-COPY uv.lock pyproject.toml README.md /app/
-RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-install-project --no-dev --no-editable
-
-# Then, add the rest of the project source code and install it
-# Installing separately from its dependencies allows optimal layer caching
-ADD src /app/src
-RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev --no-editable
-RUN --mount=type=cache,target=/root/.cache/uv uv pip install ddtrace~=3.0
-
-FROM python:3.12-slim-bookworm
-
-ARG APP_USER_NAME=app
-ARG APP_USER_UID=1000
-ARG APP_USER_GID=1000
-
-RUN groupadd --gid ${APP_USER_GID} ${APP_USER_NAME} \
- && useradd --uid ${APP_USER_UID} --gid ${APP_USER_GID} ${APP_USER_NAME}
+# Reduce node_modules to production-only for the runtime image.
+RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
+# ----------------------------------------------------------------------------
+# Stage: runner — slim runtime image.
+# ----------------------------------------------------------------------------
+FROM node:22.20.0-slim AS runner
+# Tell the DD agent to aggregate multiline logs (stack traces).
+LABEL com.datadoghq.ad.logs='[{"auto_multi_line_detection": true}]'
WORKDIR /app
-ENV LOG_CONFIG=/app/logging-json.conf
-COPY --from=uv --chown=${APP_USER_UID}:${APP_USER_GID} /app/.venv /app/.venv
-COPY logging-json.conf /app/logging-json.conf
+COPY --from=builder /app/dist ./dist
+COPY --from=builder /app/node_modules ./node_modules
+COPY --from=builder /app/package.json ./package.json
-# Place executables in the environment at the front of the path
-ENV PATH="/app/.venv/bin:$PATH"
+# ---- Runtime configuration (provided at `docker run` / k8s) ----
+ENV NODE_ENV=production
+# Bind to all interfaces inside the container; map a port on the host.
+ENV HOST=0.0.0.0
+ENV PORT=8000
+ENV LOG_LEVEL=INFO
+# Datadog APM: load the tracer before app code. dd-trace reads DD_* at runtime.
+ENV NODE_OPTIONS="--import dd-trace/initialize.mjs"
+ENV DD_LOGS_INJECTION=true
+# Runtime config NOT baked into the image (set per deployment):
+# HOSTNAME_SUFFIX, KBC_STORAGE_API_URL, KBC_STORAGE_TOKEN, KBC_BRANCH_ID,
+# KBC_WORKSPACE_SCHEMA, KBC_OAUTH_CLIENT_ID/SECRET, KBC_JWT_SECRET, DD_SERVICE/ENV/VERSION.
-USER $APP_USER_NAME
+# Non-root (uid 1000), matching the previous image.
+USER 1000
+EXPOSE 8000
-# when running the container, add KBC_STORAGE_API_URL environment variable and a bind mount to the host's db file
-ENTRYPOINT ["python", "-m", "keboola_mcp_server", "--log-level", "DEBUG"]
+# Default to the streamable-HTTP server (the deployed mode); stdio is for local
+# CLI/npx use. Override args in k8s if needed.
+CMD ["node", "dist/index.js", "--transport", "streamable-http"]
diff --git a/TOOLS.md b/TOOLS.md
index 337f826ea..b45962bad 100644
--- a/TOOLS.md
+++ b/TOOLS.md
@@ -4,19 +4,16 @@ This document provides details about the tools available in the Keboola MCP serv
## Index
### Component Tools
-- [add_config_row](#add_config_row): Creates a component configuration row in the specified configuration_id, using the specified name,
-component ID, configuration JSON, and description.
+- [add_config_row](#add_config_row): Creates a component configuration row in the specified configuration_id, using the specified name, component ID, configuration JSON, and description.
- [create_config](#create_config): Creates a root component configuration using the specified name, component ID, configuration JSON, and description.
-- [create_sql_transformation](#create_sql_transformation): Creates an SQL transformation using the specified name, SQL query following the current SQL dialect, a detailed
-description, and a list of created table names.
+- [create_sql_transformation](#create_sql_transformation): Creates an SQL transformation using the specified name, SQL query following the current SQL dialect, a detailed description, and a list of created table names.
- [get_components](#get_components): Retrieves detailed information about one or more components by their IDs.
- [get_config_examples](#get_config_examples): Retrieves sample configuration examples for a specific component.
- [get_configs](#get_configs): Retrieves component configurations in the project with optional filtering.
- [run_sync_action](#run_sync_action): Executes a synchronous action for a component configuration or a component row configuration.
- [update_config](#update_config): Updates an existing root component configuration by modifying its parameters, storage mappings, name or description.
- [update_config_row](#update_config_row): Updates an existing component configuration row by modifying its parameters, storage mappings, name, or description.
-- [update_sql_transformation](#update_sql_transformation): Updates an existing SQL transformation configuration by modifying its SQL code, storage mappings,
-name or description.
+- [update_sql_transformation](#update_sql_transformation): Updates an existing SQL transformation configuration by modifying its SQL code, storage mappings, name or description.
### Documentation Tools
- [docs_query](#docs_query): Answers a question using the Keboola documentation as a source.
@@ -38,28 +35,22 @@ name or description.
- [create_oauth_url](#create_oauth_url): Generates an OAuth authorization URL for a Keboola component configuration.
### Other Tools
-- [create_python_js_data_app_git_credential](#create_python_js_data_app_git_credential): Mints a one-time HTTPS token on a python-js **prod** data app so the caller can clone, pull,
-and push to the app's managed git repo over HTTPS.
-- [delete_python_js_data_app_draft](#delete_python_js_data_app_draft): Deletes a python-js DRAFT data app — both the data-app instance (DSAPI) and its Storage
-configuration.
-- [deploy_data_app](#deploy_data_app): Deploys/redeploys a data app or stops a running data app in the Keboola environment asynchronously, given the
-action and the configuration ID.
-- [get_data_apps](#get_data_apps): Lists summaries of data apps in the project given the limit and offset or gets details of a data apps by
-providing their configuration IDs.
+- [create_python_js_data_app_git_credential](#create_python_js_data_app_git_credential): Mints a one-time HTTPS token on a python-js **prod** data app so the caller can clone, pull, and push to the app's managed git repo over HTTPS.
+- [delete_python_js_data_app_draft](#delete_python_js_data_app_draft): Deletes a python-js DRAFT data app — both the data-app instance (DSAPI) and its Storage configuration.
+- [deploy_data_app](#deploy_data_app): Deploys/redeploys a data app or stops a running data app in the Keboola environment asynchronously, given the action and the configuration ID.
+- [get_data_apps](#get_data_apps): Lists summaries of data apps in the project given the limit and offset or gets details of a data apps by providing their configuration IDs.
- [modify_python_js_data_app](#modify_python_js_data_app): Creates or updates a python-js data app.
- [modify_streamlit_data_app](#modify_streamlit_data_app): Creates or updates a Streamlit data app.
### Project Tools
-- [get_project_info](#get_project_info): Retrieves structured information about the current project,
-including essential context and base instructions for working with it
-(e.
+- [get_project_info](#get_project_info): Retrieves structured information about the current project, including essential context and base instructions for working with it (e.
- [update_project_description](#update_project_description): Updates the description of the current Keboola project.
### SQL Tools
- [query_data](#query_data): Executes an SQL SELECT query to get the data from the underlying database.
### Search Tools
-- [find_component_id](#find_component_id): Returns list of component IDs that match the given query.
+- [find_component_id](#find_component_id): Returns a list of component IDs that match the given natural-language query.
- [search](#search): Searches for Keboola items (tables, buckets, components, configurations, transformations, flows, data-apps, etc.
### Semantic Tools
@@ -75,7 +66,7 @@ the expected semantic objects provided.
lineage references (created/updated by), and links.
- [get_tables](#get_tables): Lists tables in buckets or retrieves full details of specific tables, including fully qualified database name,
column definitions, lineage references (created/updated by) and links.
-- [update_descriptions](#update_descriptions): Updates the description for a Keboola storage item.
+- [update_descriptions](#update_descriptions): Updates the description for Keboola storage items (buckets, tables, or columns).
---
@@ -88,79 +79,88 @@ column definitions, lineage references (created/updated by) and links.
**Description**:
-Creates a component configuration row in the specified configuration_id, using the specified name,
-component ID, configuration JSON, and description.
-
-BEFORE CALLING - REQUIRED STEPS:
-1. Call `get_components([component_id])` to retrieve the component's `configuration_row_schema`.
-2. Read `configuration_row_schema.required` to find ALL mandatory top-level fields.
-3. Call `get_config_examples(component_id)` to see real-world row parameter examples.
-4. Populate `parameters` with every required field before calling this tool.
-Skipping these steps will cause a schema validation error.
-
-USAGE:
-- Use when you want to create a new row configuration for a specific component configuration.
-
-WHEN NOT TO USE:
-- `keboola.orchestrator` / `keboola.flow` → use flows tools
-- `keboola.data-apps` → use data applications tools
-- `keboola.snowflake-transformation` / `keboola.google-bigquery-transformation` → use SQL transformation tools
-
-EXAMPLES:
-- user_input: `Create a new configuration row for component X with these settings`
- - set the component_id, configuration_id and configuration parameters accordingly
- - returns the created component configuration if successful.
+Creates a component configuration row in the specified configuration_id, using the specified name, component ID, configuration JSON, and description.
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"name": {
- "description": "A short, descriptive name summarizing the purpose of the component configuration.",
- "type": "string"
+ "type": "string",
+ "description": "A short, descriptive name summarizing the purpose of the component configuration."
},
"description": {
- "description": "The detailed description of the component configuration explaining its purpose and functionality.",
- "type": "string"
+ "type": "string",
+ "description": "The detailed description of the component configuration explaining its purpose and functionality."
},
"component_id": {
- "description": "The ID of the component for which to create the configuration.",
- "type": "string"
+ "type": "string",
+ "description": "The ID of the component for which to create the configuration."
},
"configuration_id": {
- "description": "The ID of the configuration for which to create the configuration row.",
- "type": "string"
+ "type": "string",
+ "description": "The ID of the configuration for which to create the configuration row."
},
"parameters": {
- "additionalProperties": true,
- "description": "The component row configuration parameters, adhering to the configuration_row_schema",
- "type": "object"
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {},
+ "description": "The component row configuration parameters, adhering to the configuration_row_schema"
},
"storage": {
- "additionalProperties": true,
- "default": null,
"description": "The table and/or file input / output mapping of the component configuration. It is present only for components that have tables or file input mapping defined",
- "type": "object"
+ "anyOf": [
+ {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
+ },
+ {
+ "type": "null"
+ }
+ ]
},
"processors_before": {
- "default": null,
- "description": "The list of processors that will run before the configured component row runs.",
- "items": {
- "additionalProperties": true,
- "type": "object"
- },
- "type": "array"
+ "description": "The list of processors that will run before the configured component runs.",
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
+ }
+ },
+ {
+ "type": "null"
+ }
+ ]
},
"processors_after": {
- "default": null,
- "description": "The list of processors that will run after the configured component row runs.",
- "items": {
- "additionalProperties": true,
- "type": "object"
- },
- "type": "array"
+ "description": "The list of processors that will run after the configured component runs.",
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
+ }
+ },
+ {
+ "type": "null"
+ }
+ ]
}
},
"required": [
@@ -170,7 +170,7 @@ EXAMPLES:
"configuration_id",
"parameters"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -185,123 +185,125 @@ EXAMPLES:
Creates a root component configuration using the specified name, component ID, configuration JSON, and description.
-BEFORE CALLING - REQUIRED STEPS:
-1. Call `get_components([component_id])` to retrieve the component's `configuration_schema`.
-2. Read `configuration_schema.required` to find ALL mandatory top-level fields.
-3. Call `get_config_examples(component_id)` to see real-world parameter examples.
-4. Populate `parameters` with every required field before calling this tool.
-Skipping these steps will cause a schema validation error.
-
-USAGE:
-- Use when you want to create a new root configuration for a specific component.
-
-WHEN NOT TO USE:
-- `keboola.orchestrator` / `keboola.flow` → use flows tools
-- `keboola.data-apps` → use data applications tools
-- `keboola.snowflake-transformation` / `keboola.google-bigquery-transformation` → use SQL transformation tools
-
-EXAMPLES:
-- user_input: `Create a new configuration for component X with these settings`
- - set the component_id and configuration parameters accordingly
- - returns the created component configuration if successful.
-
**Input JSON Schema**:
```json
{
- "$defs": {
- "VariableDefinition": {
- "description": "A single variable definition to attach to a configuration.",
- "properties": {
- "name": {
- "description": "Variable name.",
- "type": "string"
- },
- "type": {
- "default": "string",
- "description": "Variable type: \"string\" or \"vault\".",
- "enum": [
- "string",
- "vault"
- ],
- "type": "string"
- },
- "default_value": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "default": null,
- "description": "Optional default value bound at creation time."
- }
- },
- "required": [
- "name"
- ],
- "type": "object"
- }
- },
- "additionalProperties": false,
+ "type": "object",
"properties": {
"name": {
- "description": "A short, descriptive name summarizing the purpose of the component configuration.",
- "type": "string"
+ "type": "string",
+ "description": "A short, descriptive name summarizing the purpose of the component configuration."
},
"description": {
- "description": "The detailed description of the component configuration explaining its purpose and functionality.",
- "type": "string"
+ "type": "string",
+ "description": "The detailed description of the component configuration explaining its purpose and functionality."
},
"component_id": {
- "description": "The ID of the component for which to create the configuration.",
- "type": "string"
+ "type": "string",
+ "description": "The ID of the component for which to create the configuration."
},
"parameters": {
- "additionalProperties": true,
- "description": "The component configuration parameters, adhering to the configuration_schema",
- "type": "object"
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {},
+ "description": "The component configuration parameters, adhering to the configuration_schema"
},
"storage": {
- "additionalProperties": true,
- "default": null,
"description": "The table and/or file input / output mapping of the component configuration. It is present only for components that have tables or file input mapping defined",
- "type": "object"
+ "anyOf": [
+ {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
+ },
+ {
+ "type": "null"
+ }
+ ]
},
"processors_before": {
- "default": null,
"description": "The list of processors that will run before the configured component runs.",
- "items": {
- "additionalProperties": true,
- "type": "object"
- },
- "type": "array"
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
+ }
+ },
+ {
+ "type": "null"
+ }
+ ]
},
"processors_after": {
- "default": null,
"description": "The list of processors that will run after the configured component runs.",
- "items": {
- "additionalProperties": true,
- "type": "object"
- },
- "type": "array"
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
+ }
+ },
+ {
+ "type": "null"
+ }
+ ]
},
"variables": {
+ "description": "Variable definitions to attach to this configuration. Each entry specifies a name, type (\"string\" or \"vault\"), and an optional default value. On creation, both `None` (omitted) and `[]` (empty list) mean \"do not attach variables\" — no `keboola.variables` config is created. To remove variables from an existing configuration, use `update_config` with `variables=[]`.",
"anyOf": [
{
+ "type": "array",
"items": {
- "$ref": "#/$defs/VariableDefinition"
- },
- "type": "array"
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Variable name."
+ },
+ "type": {
+ "default": "string",
+ "description": "Variable type: \"string\" or \"vault\".",
+ "type": "string",
+ "enum": [
+ "string",
+ "vault"
+ ]
+ },
+ "default_value": {
+ "description": "Optional default value bound at creation time.",
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "name"
+ ]
+ }
},
{
"type": "null"
}
- ],
- "default": null,
- "description": "Variable definitions to attach to this configuration. Each entry specifies a name, type (\"string\" or \"vault\"), and an optional default value. On creation, both `None` (omitted) and `[]` (empty list) mean \"do not attach variables\" \u2014 no `keboola.variables` config is created. To remove variables from an existing configuration, use `update_config` with `variables=[]`."
+ ]
}
},
"required": [
@@ -310,7 +312,7 @@ EXAMPLES:
"component_id",
"parameters"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -323,119 +325,50 @@ EXAMPLES:
**Description**:
-Creates an SQL transformation using the specified name, SQL query following the current SQL dialect, a detailed
-description, and a list of created table names.
-
-CONSIDERATIONS:
-- By default, SQL transformation must create at least one table to produce a result; omit only if the user
- explicitly indicates that no table creation is needed.
-- Each SQL code block must include descriptive name that reflects its purpose and group one or more executable
- semantically related SQL statements.
-- Each SQL query statement within a code block must be executable and follow the current SQL dialect.
-- Use delimited identifiers for the current SQL dialect for all identifiers and FQN references.
-- When referring to the input tables within the SQL query, use fully qualified table names, which can be
- retrieved using appropriate tools.
-- When creating a new table within the SQL query (e.g. CREATE TABLE ...): use only the table name with
- delimited identifiers, without the fully qualified path; add the plain table name without delimiters
- to the `created_table_names` list.
-- Unless otherwise specified by user, transformation name and description are generated based on the SQL query
- and user intent.
-- If there are 20 or more SQL transformations in the project, consider organizing them with a folder: existing
- folder names are surfaced in the response's change_summary — use one of them or create a new one.
-
-USAGE:
-- Use when you want to create a new SQL transformation.
-
-EXAMPLES:
-- user_input: `Can you create a new transformation out of this sql query?`
- - set the sql_code_blocks to the query, and set other parameters accordingly.
- - returns the created SQL transformation configuration if successful.
-- user_input: `Generate me an SQL transformation which [USER INTENT]`
- - set the sql_code_blocks to the query based on the [USER INTENT], and set other parameters accordingly.
- - returns the created SQL transformation configuration if successful.
+Creates an SQL transformation using the specified name, SQL query following the current SQL dialect, a detailed description, and a list of created table names.
**Input JSON Schema**:
```json
{
- "$defs": {
- "Code": {
- "description": "The code block for the transformation block.",
- "properties": {
- "name": {
- "description": "A descriptive name for the code block",
- "type": "string"
- },
- "script": {
- "description": "The SQL script of the code block",
- "type": "string"
- }
- },
- "required": [
- "name",
- "script"
- ],
- "type": "object"
- },
- "VariableDefinition": {
- "description": "A single variable definition to attach to a configuration.",
- "properties": {
- "name": {
- "description": "Variable name.",
- "type": "string"
- },
- "type": {
- "default": "string",
- "description": "Variable type: \"string\" or \"vault\".",
- "enum": [
- "string",
- "vault"
- ],
- "type": "string"
- },
- "default_value": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "default": null,
- "description": "Optional default value bound at creation time."
- }
- },
- "required": [
- "name"
- ],
- "type": "object"
- }
- },
- "additionalProperties": false,
+ "type": "object",
"properties": {
"name": {
- "description": "A short, descriptive name summarizing the purpose of the SQL transformation.",
- "type": "string"
+ "type": "string",
+ "description": "A short, descriptive name summarizing the purpose of the SQL transformation."
},
"description": {
- "description": "The detailed description of the SQL transformation capturing the user intent, explaining the SQL query, and the expected output.",
- "type": "string"
+ "type": "string",
+ "description": "The detailed description of the SQL transformation capturing the user intent, explaining the SQL query, and the expected output."
},
"sql_code_blocks": {
- "description": "The SQL query code blocks, each containing a descriptive name and an executable SQL script written in the current SQL dialect. The query will be automatically reformatted to be more readable.",
+ "type": "array",
"items": {
- "$ref": "#/$defs/Code"
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "A descriptive name for the code block"
+ },
+ "script": {
+ "type": "string",
+ "description": "The SQL script of the code block"
+ }
+ },
+ "required": [
+ "name",
+ "script"
+ ]
},
- "type": "array"
+ "description": "The SQL query code blocks, each containing a descriptive name and an executable SQL script written in the current SQL dialect. The query will be automatically reformatted to be more readable."
},
"created_table_names": {
"default": [],
"description": "A list of created table names if they are generated within the SQL query statements (e.g., using `CREATE TABLE ...`).",
+ "type": "array",
"items": {
"type": "string"
- },
- "type": "array"
+ }
},
"folder": {
"default": "",
@@ -443,19 +376,47 @@ EXAMPLES:
"type": "string"
},
"variables": {
+ "description": "Variable definitions to attach to this transformation. Each entry specifies a name, type (\"string\" or \"vault\"), and an optional default value. On creation, both `None` (omitted) and `[]` (empty list) mean \"do not attach variables\" — no `keboola.variables` config is created. To remove variables from an existing transformation, use `update_sql_transformation` with `variables=[]`.",
"anyOf": [
{
+ "type": "array",
"items": {
- "$ref": "#/$defs/VariableDefinition"
- },
- "type": "array"
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Variable name."
+ },
+ "type": {
+ "default": "string",
+ "description": "Variable type: \"string\" or \"vault\".",
+ "type": "string",
+ "enum": [
+ "string",
+ "vault"
+ ]
+ },
+ "default_value": {
+ "description": "Optional default value bound at creation time.",
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "name"
+ ]
+ }
},
{
"type": "null"
}
- ],
- "default": null,
- "description": "Variable definitions to attach to this transformation. Each entry specifies a name, type (\"string\" or \"vault\"), and an optional default value. On creation, both `None` (omitted) and `[]` (empty list) mean \"do not attach variables\" \u2014 no `keboola.variables` config is created. To remove variables from an existing transformation, use `update_sql_transformation` with `variables=[]`."
+ ]
}
},
"required": [
@@ -463,7 +424,7 @@ EXAMPLES:
"description",
"sql_code_blocks"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -478,44 +439,24 @@ EXAMPLES:
Retrieves detailed information about one or more components by their IDs.
-RETURNS FOR EACH COMPONENT:
-- Component metadata (name, type, description)
-- Documentation and usage instructions
-- Configuration JSON schema (required for creating/updating configurations)
-- Links to component dashboard in Keboola UI
-
-WHEN TO USE:
-- Before creating a new configuration: fetch the component to get its configuration schema
-- Before updating a configuration: fetch the component to understand valid configuration options
-- When user asks about component capabilities or documentation
-
-PREREQUISITES:
-- You must know the component_id(s). If unknown, first use `find_component_id` or `docs` tool to discover them.
-
-EXAMPLES:
-- User: "Create a generic extractor configuration"
- → First call `find_component_id` to get the component_id, then call this tool to get the schema
-- User: "What options does the Snowflake writer support?"
- → Call this tool with the Snowflake writer component_id to retrieve its documentation and schema
-
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"component_ids": {
- "description": "IDs of the components",
+ "type": "array",
"items": {
"type": "string"
},
- "type": "array"
+ "description": "IDs of the components to retrieve."
}
},
"required": [
"component_ids"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -530,30 +471,21 @@ EXAMPLES:
Retrieves sample configuration examples for a specific component.
-USAGE:
-- Use before calling `create_config` or `add_config_row` to understand the expected parameters structure.
-- Use when you want to see example configurations for a specific component.
-
-EXAMPLES:
-- user_input: `Show me example configurations for component X`
- - set the component_id parameter accordingly
- - returns a markdown formatted string with configuration examples
-
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"component_id": {
- "description": "The ID of the component to get configuration examples for.",
- "type": "string"
+ "type": "string",
+ "description": "The ID of the component to get configuration examples for."
}
},
"required": [
"component_id"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -568,100 +500,63 @@ EXAMPLES:
Retrieves component configurations in the project with optional filtering.
-Can list summaries of multiple configurations (grouped by component) or retrieve full details
-for specific configurations.
-
-Returns a list of components, each containing:
-- Component metadata (ID, name, type, description)
-- Configurations for that component (summaries by default, full details if requested)
-- Links to the Keboola UI
-
-PARAMETER BEHAVIOR:
-- If configs is provided (non-empty): Returns FULL details ONLY for those configs.
-- Else if component_ids is provided (non-empty): Lists config summaries for those components.
-- Else: Lists configs based on component_types (all types if empty).
-
-WHEN TO USE:
-- For listing: Use component_types/component_ids.
-- For details: Use configs (can handle multiple).
-
-WHEN NOT TO USE:
-- Do NOT list all configs just to find a configuration by name. Use `search` with
- item_types=["configuration", "transformation"] instead.
-- Only use broad listing (empty component_types and component_ids) when you need
- a complete inventory of all configurations in the project.
-
-EXAMPLES:
-- List all configs (summaries): component_types=[], component_ids=[]
-- List extractors (summaries): component_types=["extractor"]
-- Get details for specific configs:
- configs=[{"component_id": "keboola.ex-db-mysql", "configuration_id": "12345"}]
-
**Input JSON Schema**:
```json
{
- "$defs": {
- "FullConfigId": {
- "description": "Composite configuration ID (component ID + configuration ID).",
- "properties": {
- "component_id": {
- "description": "ID of the component",
- "type": "string"
- },
- "configuration_id": {
- "description": "ID of the configuration",
- "type": "string"
- }
- },
- "required": [
- "component_id",
- "configuration_id"
- ],
- "type": "object"
- }
- },
- "additionalProperties": false,
+ "type": "object",
"properties": {
"component_types": {
"default": [],
- "description": "Filter by component types. Options: \"application\", \"extractor\", \"transformation\", \"writer\". Empty list [] means ALL component types will be returned. This parameter is IGNORED when configs is provided (non-empty) or component_ids is non-empty.",
+ "description": "Filter by component types; empty = all. Ignored when configs/component_ids given.",
+ "type": "array",
"items": {
+ "type": "string",
"enum": [
"application",
"extractor",
"transformation",
"writer"
- ],
- "type": "string"
- },
- "type": "array"
+ ]
+ }
},
"component_ids": {
"default": [],
- "description": "Filter by specific component IDs (e.g., [\"keboola.ex-db-mysql\", \"keboola.wr-google-sheets\"]). Empty list [] uses component_types filtering instead. When provided (non-empty) and configs is empty, lists summaries for these components. Ignored if configs is provided.",
+ "description": "Filter by specific component IDs. Ignored when configs is given.",
+ "type": "array",
"items": {
"type": "string"
- },
- "type": "array"
+ }
},
"configs": {
"default": [],
- "description": "List of specific configurations to retrieve full details for. Each dict must have \"component_id\" (str) and \"configuration_id\" (str). Example: [{\"component_id\": \"keboola.ex-db-mysql\", \"configuration_id\": \"12345\"}]. If provided (non-empty), ignores other filters and returns full details only for these configs, grouped by component. Use this for detailed retrieval.",
+ "description": "Specific configs to retrieve full details for (grouped by component).",
+ "type": "array",
"items": {
- "$ref": "#/$defs/FullConfigId"
- },
- "type": "array"
+ "type": "object",
+ "properties": {
+ "component_id": {
+ "type": "string"
+ },
+ "configuration_id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "component_id",
+ "configuration_id"
+ ]
+ }
}
},
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
---
## run_sync_action
-**Annotations**: `read-only`
+**Annotations**:
**Tags**: `components`
@@ -669,30 +564,26 @@ EXAMPLES:
Executes a synchronous action for a component configuration or a component row configuration.
-WHEN TO USE:
-- For finding available values of a configuration field
-- For validating already configured values (e.g. testing a database connection)
-- For listing remote resources such as endpoints, schemas or tables
-
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"action_name": {
- "description": "The sync action to execute (e.g., \"testConnection\", \"getTables\").",
- "type": "string"
+ "type": "string",
+ "description": "The sync action to execute (e.g., \"testConnection\", \"getTables\")."
},
"component_id": {
- "description": "The ID of the component (e.g., \"keboola.ex-db-mysql\").",
- "type": "string"
+ "type": "string",
+ "description": "The ID of the component (e.g., \"keboola.ex-db-mysql\")."
},
"configuration_id": {
- "description": "The ID of the configuration to use for the sync action.",
- "type": "string"
+ "type": "string",
+ "description": "The ID of the configuration to use for the sync action."
},
"configuration_row_id": {
+ "description": "Optional row ID; row parameters/storage are shallow-merged on top of root config.",
"anyOf": [
{
"type": "string"
@@ -700,9 +591,7 @@ WHEN TO USE:
{
"type": "null"
}
- ],
- "default": null,
- "description": "Optional row ID for row-level actions. When provided, the row parameters and storage are shallow-merged on top of root config."
+ ]
}
},
"required": [
@@ -710,7 +599,7 @@ WHEN TO USE:
"component_id",
"configuration_id"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -723,185 +612,25 @@ WHEN TO USE:
**Description**:
-Updates an existing root component configuration by modifying its parameters, storage mappings, name or description.
-
-This tool allows PARTIAL parameter updates - you only need to provide the fields you want to change.
-All other fields will remain unchanged.
-Use this tool when modifying existing configurations; for configuration rows, use update_config_row instead.
-
-WHEN TO USE:
-- Modifying configuration parameters (credentials, settings, API keys, etc.)
-- Updating storage mappings (input/output tables or files)
-- Changing configuration name or description
-- Any combination of the above
-
-WHEN NOT TO USE:
-- `keboola.orchestrator` / `keboola.flow` → use flows tools
-- `keboola.data-apps` → use data applications tools
-- `keboola.snowflake-transformation` / `keboola.google-bigquery-transformation` → use SQL transformation tools
-
-PREREQUISITES:
-- Configuration must already exist (use create_config for new configurations)
-- You must know both component_id and configuration_id
-- For parameter updates: Review the component's root_configuration_schema using get_components.
-- For storage updates: Ensure mappings are valid for the component type
-
-IMPORTANT CONSIDERATIONS:
-- Parameter updates are PARTIAL - only specify fields you want to change
-- parameter_updates supports granular operations: set keys, replace strings, remove keys, or append to lists
-- Parameters must conform to the component's root_configuration_schema
-- Validate schemas before calling: use get_components to retrieve root_configuration_schema
-- For row-based components, this updates the ROOT only (use update_config_row for individual rows)
-
-WORKFLOW:
-1. Retrieve current configuration using get_configs (to understand current state)
-2. Identify specific parameters/storage mappings to modify
-3. Prepare parameter_updates list with targeted operations
-4. Call update_config with only the fields to change
+Updates an existing root component configuration by modifying its parameters, storage mappings, name or description. Updates are PARTIAL — only provide the fields you want to change; parameter_updates apply granular diff operations to the existing parameters.
**Input JSON Schema**:
```json
{
- "$defs": {
- "ConfigParamListAppend": {
- "description": "Append a value to a list parameter.",
- "properties": {
- "op": {
- "const": "list_append",
- "type": "string"
- },
- "path": {
- "description": "JSONPath to the list parameter",
- "type": "string"
- },
- "value": {
- "description": "Value to append to the list"
- }
- },
- "required": [
- "op",
- "path",
- "value"
- ],
- "type": "object"
- },
- "ConfigParamRemove": {
- "description": "Remove a parameter key.",
- "properties": {
- "op": {
- "const": "remove",
- "type": "string"
- },
- "path": {
- "description": "JSONPath to the parameter key to remove",
- "type": "string"
- }
- },
- "required": [
- "op",
- "path"
- ],
- "type": "object"
- },
- "ConfigParamReplace": {
- "description": "Replace a substring in a string parameter.",
- "properties": {
- "op": {
- "const": "str_replace",
- "type": "string"
- },
- "path": {
- "description": "JSONPath to the parameter key to modify",
- "type": "string"
- },
- "search_for": {
- "description": "Substring to search for (non-empty)",
- "type": "string"
- },
- "replace_with": {
- "description": "Replacement string (can be empty for deletion)",
- "type": "string"
- }
- },
- "required": [
- "op",
- "path",
- "search_for",
- "replace_with"
- ],
- "type": "object"
- },
- "ConfigParamSet": {
- "description": "Set or create a parameter value at the specified path.\n\nUse this operation to:\n- Update an existing parameter value\n- Create a new parameter key\n- Replace a nested parameter value",
- "properties": {
- "op": {
- "const": "set",
- "type": "string"
- },
- "path": {
- "description": "JSONPath to the parameter key to set (e.g., \"api_key\", \"database.host\")",
- "type": "string"
- },
- "value": {
- "description": "New value to set"
- }
- },
- "required": [
- "op",
- "path",
- "value"
- ],
- "type": "object"
- },
- "VariableDefinition": {
- "description": "A single variable definition to attach to a configuration.",
- "properties": {
- "name": {
- "description": "Variable name.",
- "type": "string"
- },
- "type": {
- "default": "string",
- "description": "Variable type: \"string\" or \"vault\".",
- "enum": [
- "string",
- "vault"
- ],
- "type": "string"
- },
- "default_value": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "default": null,
- "description": "Optional default value bound at creation time."
- }
- },
- "required": [
- "name"
- ],
- "type": "object"
- }
- },
- "additionalProperties": false,
+ "type": "object",
"properties": {
"change_description": {
- "description": "A clear, human-readable summary of what changed in this update. Be specific: e.g., \"Updated API key\", \"Added customers table to input mapping\".",
- "type": "string"
+ "type": "string",
+ "description": "A clear, human-readable summary of what changed in this update. Be specific: e.g., \"Updated API key\", \"Added customers table to input mapping\"."
},
"component_id": {
- "description": "The ID of the component the configuration belongs to.",
- "type": "string"
+ "type": "string",
+ "description": "The ID of the component the configuration belongs to."
},
"configuration_id": {
- "description": "The ID of the configuration to update.",
- "type": "string"
+ "type": "string",
+ "description": "The ID of the configuration to update."
},
"name": {
"default": "",
@@ -914,891 +643,462 @@ WORKFLOW:
"type": "string"
},
"parameter_updates": {
- "default": null,
"description": "List of granular parameter update operations to apply. Each operation (set, str_replace, remove, list_append) modifies a specific value using JSONPath notation. Only provide if updating parameters - do not use for changing description, storage or processors. Paths are relative to the `parameters` object, not the configuration root (e.g. use `tables`, not `parameters.tables`). Prefer simple JSONPaths (e.g., \"array_param[1]\", \"object_param.key\") and make the smallest possible updates - only change what needs changing. In case you need to replace the whole parameters section, you can use the `set` operation with `$` as path.",
- "items": {
- "discriminator": {
- "mapping": {
- "list_append": "#/$defs/ConfigParamListAppend",
- "remove": "#/$defs/ConfigParamRemove",
- "set": "#/$defs/ConfigParamSet",
- "str_replace": "#/$defs/ConfigParamReplace"
- },
- "propertyName": "op"
- },
- "oneOf": [
- {
- "$ref": "#/$defs/ConfigParamSet"
- },
- {
- "$ref": "#/$defs/ConfigParamReplace"
- },
- {
- "$ref": "#/$defs/ConfigParamRemove"
- },
- {
- "$ref": "#/$defs/ConfigParamListAppend"
- }
- ]
- },
- "type": "array"
- },
- "storage": {
- "additionalProperties": true,
- "default": null,
- "description": "Complete storage configuration containing input/output table and file mappings. Only provide if updating storage mappings - this replaces the ENTIRE storage configuration. \n\nWhen to use:\n- Adding/removing input or output tables\n- Modifying table/file mappings\n- Updating table destinations or sources\n\nImportant:\n- Not applicable for row-based components (they use row-level storage)\n- Must conform to the Keboola storage schema\n- Replaces ALL existing storage config - include all mappings you want to keep\n- Use get_configs first to see current storage configuration\n- Leave unfilled to preserve existing storage configuration",
- "type": "object"
- },
- "processors_before": {
- "default": null,
- "description": "The list of processors that will run before the configured component row runs.",
- "items": {
- "additionalProperties": true,
- "type": "object"
- },
- "type": "array"
- },
- "processors_after": {
- "default": null,
- "description": "The list of processors that will run after the configured component row runs.",
- "items": {
- "additionalProperties": true,
- "type": "object"
- },
- "type": "array"
- },
- "folder": {
"anyOf": [
{
- "type": "string"
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "op": {
+ "type": "string",
+ "const": "set"
+ },
+ "path": {
+ "type": "string",
+ "description": "JSONPath to the parameter key to set (e.g., \"api_key\", \"database.host\")"
+ },
+ "value": {
+ "description": "New value to set"
+ }
+ },
+ "required": [
+ "op",
+ "path",
+ "value"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "op": {
+ "type": "string",
+ "const": "str_replace"
+ },
+ "path": {
+ "type": "string",
+ "description": "JSONPath to the parameter key to modify"
+ },
+ "search_for": {
+ "type": "string",
+ "description": "Substring to search for (non-empty)"
+ },
+ "replace_with": {
+ "type": "string",
+ "description": "Replacement string (can be empty for deletion)"
+ }
+ },
+ "required": [
+ "op",
+ "path",
+ "search_for",
+ "replace_with"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "op": {
+ "type": "string",
+ "const": "remove"
+ },
+ "path": {
+ "type": "string",
+ "description": "JSONPath to the parameter key to remove"
+ }
+ },
+ "required": [
+ "op",
+ "path"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "op": {
+ "type": "string",
+ "const": "list_append"
+ },
+ "path": {
+ "type": "string",
+ "description": "JSONPath to the list parameter"
+ },
+ "value": {
+ "description": "Value to append to the list"
+ }
+ },
+ "required": [
+ "op",
+ "path",
+ "value"
+ ]
+ }
+ ]
+ }
},
{
"type": "null"
}
- ],
- "default": null,
- "description": "Folder name to organize this configuration in the Keboola UI. Pass an empty string to remove an existing folder assignment. Existing folder names are returned in the response change_summary when no folder is provided and there are 20 or more configurations in the project. If there are 20 or more configurations, you should assign one of the existing folders or create a new one that clearly reflects the configuration purpose."
+ ]
},
- "variables": {
+ "storage": {
+ "description": "Complete storage configuration containing input/output table and file mappings. Only provide if updating storage mappings - this replaces the ENTIRE storage configuration.",
"anyOf": [
{
- "items": {
- "$ref": "#/$defs/VariableDefinition"
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
},
- "type": "array"
+ "additionalProperties": {}
},
{
"type": "null"
}
- ],
- "default": null,
- "description": "Variable definitions for this configuration. Provide a non-empty list to create or replace all variable definitions. Provide an empty list ([]) to remove all variables. Omit (None) to leave existing variables unchanged."
- }
- },
- "required": [
- "change_description",
- "component_id",
- "configuration_id"
- ],
- "type": "object"
-}
-```
-
----
-
-## update_config_row
-**Annotations**: `destructive`
-
-**Tags**: `components, config-diff-preview`
-
-**Description**:
-
-Updates an existing component configuration row by modifying its parameters, storage mappings, name, or description.
-
-This tool allows PARTIAL parameter updates - you only need to provide the fields you want to change.
-All other fields will remain unchanged.
-Configuration rows are individual items within a configuration, often representing separate data sources,
-tables, or endpoints that share the same component type and parent configuration settings.
-
-WHEN TO USE:
-- Modifying row-specific parameters (table sources, filters, credentials, etc.)
-- Updating storage mappings for a specific row (input/output tables or files)
-- Changing row name or description
-- Any combination of the above
-
-WHEN NOT TO USE:
-- `keboola.orchestrator` / `keboola.flow` → use flows tools
-- `keboola.data-apps` → use data applications tools
-- `keboola.snowflake-transformation` / `keboola.google-bigquery-transformation` → use SQL transformation tools
-
-PREREQUISITES:
-- The configuration row must already exist (use add_config_row for new rows)
-- You must know component_id, configuration_id, and configuration_row_id
-- For parameter updates: Review the component's row_configuration_schema using get_components
-- For storage updates: Ensure mappings are valid for row-level storage
-
-IMPORTANT CONSIDERATIONS:
-- Parameter updates are PARTIAL - only specify fields you want to change
-- parameter_updates supports granular operations: set individual keys, replace strings, or remove keys
-- Parameters must conform to the component's row_configuration_schema (not root schema)
-- Validate schemas before calling: use get_components to retrieve row_configuration_schema
-- Each row operates independently - changes to one row don't affect others
-- Row-level storage is separate from root-level storage configuration
-
-WORKFLOW:
-1. Retrieve current configuration using get_configs to see existing rows
-2. Identify the specific row to modify by its configuration_row_id
-3. Prepare parameter_updates list with targeted operations for this row
-4. Call update_config_row with only the fields to change
-
-
-**Input JSON Schema**:
-```json
-{
- "$defs": {
- "ConfigParamListAppend": {
- "description": "Append a value to a list parameter.",
- "properties": {
- "op": {
- "const": "list_append",
- "type": "string"
- },
- "path": {
- "description": "JSONPath to the list parameter",
- "type": "string"
- },
- "value": {
- "description": "Value to append to the list"
- }
- },
- "required": [
- "op",
- "path",
- "value"
- ],
- "type": "object"
+ ]
},
- "ConfigParamRemove": {
- "description": "Remove a parameter key.",
- "properties": {
- "op": {
- "const": "remove",
- "type": "string"
+ "processors_before": {
+ "description": "The list of processors that will run before the configured component runs.",
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
+ }
},
- "path": {
- "description": "JSONPath to the parameter key to remove",
- "type": "string"
+ {
+ "type": "null"
}
- },
- "required": [
- "op",
- "path"
- ],
- "type": "object"
+ ]
},
- "ConfigParamReplace": {
- "description": "Replace a substring in a string parameter.",
- "properties": {
- "op": {
- "const": "str_replace",
- "type": "string"
- },
- "path": {
- "description": "JSONPath to the parameter key to modify",
- "type": "string"
- },
- "search_for": {
- "description": "Substring to search for (non-empty)",
- "type": "string"
+ "processors_after": {
+ "description": "The list of processors that will run after the configured component runs.",
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
+ }
},
- "replace_with": {
- "description": "Replacement string (can be empty for deletion)",
- "type": "string"
+ {
+ "type": "null"
}
- },
- "required": [
- "op",
- "path",
- "search_for",
- "replace_with"
- ],
- "type": "object"
+ ]
},
- "ConfigParamSet": {
- "description": "Set or create a parameter value at the specified path.\n\nUse this operation to:\n- Update an existing parameter value\n- Create a new parameter key\n- Replace a nested parameter value",
- "properties": {
- "op": {
- "const": "set",
- "type": "string"
- },
- "path": {
- "description": "JSONPath to the parameter key to set (e.g., \"api_key\", \"database.host\")",
+ "folder": {
+ "description": "Folder name to organize this configuration in the Keboola UI. Pass an empty string to remove an existing folder assignment. Existing folder names are returned in the response change_summary when no folder is provided and there are 20 or more configurations in the project. If there are 20 or more configurations, you should assign one of the existing folders or create a new one that clearly reflects the configuration purpose.",
+ "anyOf": [
+ {
"type": "string"
},
- "value": {
- "description": "New value to set"
+ {
+ "type": "null"
}
- },
- "required": [
- "op",
- "path",
- "value"
- ],
- "type": "object"
- }
- },
- "additionalProperties": false,
- "properties": {
- "change_description": {
- "description": "A clear, human-readable summary of what changed in this row update. Be specific.",
- "type": "string"
- },
- "component_id": {
- "description": "The ID of the component the configuration belongs to.",
- "type": "string"
- },
- "configuration_id": {
- "description": "The ID of the parent configuration containing the row to update.",
- "type": "string"
- },
- "configuration_row_id": {
- "description": "The ID of the specific configuration row to update.",
- "type": "string"
- },
- "name": {
- "default": "",
- "description": "New name for the configuration row. Only provide if changing the name. Name should be short (typically under 50 characters) and descriptive of this specific row.",
- "type": "string"
- },
- "description": {
- "default": "",
- "description": "New detailed description for the configuration row. Only provide if changing the description. Should explain the specific purpose and behavior of this individual row.",
- "type": "string"
- },
- "parameter_updates": {
- "default": null,
- "description": "List of granular parameter update operations to apply to this row. Each operation (set, str_replace, remove, list_append) modifies a specific parameter using JSONPath notation. Only provide if updating parameters - do not use for changing description or storage. Paths are relative to the row's `parameters` object, not the row root (e.g. use `tables`, not `parameters.tables`). Prefer simple dot-delimited JSONPaths and make the smallest possible updates - only change what needs changing. In case you need to replace the whole parameters, you can use the `set` operation with `$` as path.",
- "items": {
- "discriminator": {
- "mapping": {
- "list_append": "#/$defs/ConfigParamListAppend",
- "remove": "#/$defs/ConfigParamRemove",
- "set": "#/$defs/ConfigParamSet",
- "str_replace": "#/$defs/ConfigParamReplace"
- },
- "propertyName": "op"
- },
- "oneOf": [
- {
- "$ref": "#/$defs/ConfigParamSet"
- },
- {
- "$ref": "#/$defs/ConfigParamReplace"
- },
- {
- "$ref": "#/$defs/ConfigParamRemove"
- },
- {
- "$ref": "#/$defs/ConfigParamListAppend"
- }
- ]
- },
- "type": "array"
- },
- "storage": {
- "additionalProperties": true,
- "default": null,
- "description": "Complete storage configuration for this row containing input/output table and file mappings. Only provide if updating storage mappings - this replaces the ENTIRE storage configuration for this row. \n\nWhen to use:\n- Adding/removing input or output tables for this specific row\n- Modifying table/file mappings for this row\n- Updating table destinations or sources for this row\n\nImportant:\n- Must conform to the component's row storage schema\n- Replaces ALL existing storage config for this row - include all mappings you want to keep\n- Use get_configs first to see current row storage configuration\n- Leave unfilled to preserve existing storage configuration",
- "type": "object"
- },
- "processors_before": {
- "default": null,
- "description": "The list of processors that will run before the configured component row runs.",
- "items": {
- "additionalProperties": true,
- "type": "object"
- },
- "type": "array"
- },
- "processors_after": {
- "default": null,
- "description": "The list of processors that will run after the configured component row runs.",
- "items": {
- "additionalProperties": true,
- "type": "object"
- },
- "type": "array"
+ ]
},
- "is_disabled": {
+ "variables": {
+ "description": "Variable definitions for this configuration. Provide a non-empty list to create or replace all variable definitions. Provide an empty list ([]) to remove all variables. Omit (None) to leave existing variables unchanged.",
"anyOf": [
{
- "type": "boolean"
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Variable name."
+ },
+ "type": {
+ "default": "string",
+ "description": "Variable type: \"string\" or \"vault\".",
+ "type": "string",
+ "enum": [
+ "string",
+ "vault"
+ ]
+ },
+ "default_value": {
+ "description": "Optional default value bound at creation time.",
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "name"
+ ]
+ }
},
{
"type": "null"
}
- ],
- "default": null,
- "description": "Enable or disable the configuration row. Set to True to disable execution (config row won't run), False to enable execution (config row will run). Only provide if changing the status, leave as null to preserve current state."
+ ]
}
},
"required": [
"change_description",
"component_id",
- "configuration_id",
- "configuration_row_id"
+ "configuration_id"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
---
-
-## update_sql_transformation
+
+## update_config_row
**Annotations**: `destructive`
**Tags**: `components, config-diff-preview`
**Description**:
-Updates an existing SQL transformation configuration by modifying its SQL code, storage mappings,
-name or description.
-
-This tool allows PARTIAL parameter updates for transformation SQL blocks and code - you only need to provide
-the operations you want to perform. All other fields will remain unchanged.
-Use this for modifying SQL transformations created with create_sql_transformation.
-
-WHEN TO USE:
-- SQL transformations only (Snowflake/BigQuery); use update_config for Python/R transformations
-- Modifying SQL queries in transformation (add/edit/remove SQL statements)
-- Updating transformation block or code block names
-- Changing input/output table mappings for the transformation
-- Updating the transformation name or description
-- Any combination of the above
-
-PREREQUISITES:
-- Transformation must already exist (use create_sql_transformation for new transformations)
-- You must know the configuration_id of the transformation
-- SQL dialect is determined automatically from the workspace
-- CRITICAL: Use get_configs first to see the current transformation structure and get block_id/code_id values
-
-TRANSFORMATION STRUCTURE:
-A transformation has this hierarchy:
- transformation
- └─ blocks[] - List of transformation blocks (each has a unique block_id)
- └─ block.name - Descriptive name for the block
- └─ block.codes[] - List of code blocks within the block (each has a unique code_id)
- └─ code.name - Descriptive name for the code block
- └─ code.script - SQL script (string with SQL statements)
-
-Example structure from get_configs:
-{
- "blocks": [
- {
- "id": "b0", ← block_id needed for operations (format: b{index})
- "name": "Data Preparation",
- "codes": [
- {
- "id": "b0.c0", ← code_id needed for operations (format: b{block_index}.c{code_index})
- "name": "Load customers",
- "script": "SELECT * FROM customers WHERE status = 'active';"
- }
- ]
- }
- ]
-}
-
-PARAMETER UPDATE OPERATIONS:
-All operations use block_id and code_id to identify elements (get these from get_configs first).
-
-ID Format:
-- block_id: "b0", "b1", "b2", etc. (format: b{index})
-- code_id: "b0.c0", "b0.c1", "b1.c0", etc. (format: b{block_index}.c{code_index})
-
-1. BLOCK OPERATIONS:
- - add_block: Create a new block in the transformation
- {"op": "add_block", "block": {"name": "New Block", "codes": []}, "position": "end"}
-
- - remove_block: Delete an entire block
- {"op": "remove_block", "block_id": "b0"}
-
- - rename_block: Change a block's name
- {"op": "rename_block", "block_id": "b2", "block_name": "Updated Name"}
-
-2. CODE BLOCK OPERATIONS:
- - add_code: Create a new code block within an existing block
- {"op": "add_code", "block_id": "b1", "code": {"name": "New Code", "script": "SELECT 1;"}, "position": "end"}
-
- - remove_code: Delete a code block
- {"op": "remove_code", "block_id": "b0", "code_id": "b0.c0"}
-
- - rename_code: Change a code block's name
- {"op": "rename_code", "block_id": "b1", "code_id": "b1.c2", "code_name": "Updated Name"}
-
-3. SQL SCRIPT OPERATIONS:
- - set_code: Replace the entire SQL script (overwrites existing)
- {"op": "set_code", "block_id": "b0", "code_id": "b0.c0", "script": "SELECT * FROM new_table;"}
-
- - add_script: Append or prepend SQL to existing script (preserves existing)
- {"op": "add_script", "block_id": "b2", "code_id": "b2.c1", "script": "WHERE date > '2024-01-01'",
- "position": "end"}
-
- - str_replace: Find and replace text in SQL scripts
- {"op": "str_replace", "search_for": "old_table", "replace_with": "new_table", "block_id": "b0",'
- "code_id": "b0.c0"}
- - Omit code_id to replace in all codes of a block
- - Omit both block_id and code_id to replace everywhere
-
-IMPORTANT CONSIDERATIONS:
-- Parameter updates are PARTIAL - only the operations you specify are applied
-- All other parts of the transformation remain unchanged
-- Each SQL script must be executable and follow the current SQL dialect:
- - Use delimited identifiers for the current SQL dialect.
- - Never mix delimiter styles within a single query.
-- Storage configuration is COMPLETE REPLACEMENT - include ALL mappings you want to keep
-- Leave updated_description empty to preserve the original description
-- SCHEMA CHANGES: Destructive schema changes (removing columns, changing types, renaming columns) require
- manually deleting the output table before running the updated transformation to avoid schema mismatch errors.
- Non-destructive changes (adding columns) typically do not require table deletion.
-
-WORKFLOW:
-1. Call get_configs to retrieve current transformation structure and identify block_id/code_id values
-2. Identify what needs to change (SQL code, storage, description)
-3. For SQL changes: Prepare parameter_updates list with targeted operations
-4. For storage changes: Build complete storage configuration (include all mappings)
-5. Call update_sql_transformation with change_description and only the fields to change
-
-EXAMPLE WORKFLOWS:
-
-Example 1 - Update SQL script in existing code block:
-Step 1: Get current config
- result = get_configs(component_id="keboola.snowflake-transformation", configuration_id="12345")
- # Note the block_id (e.g., "b0") and code_id (e.g., "b0.c1") from result
-
-Step 2: Update the SQL
- update_sql_transformation(
- configuration_id="12345",
- change_description="Updated WHERE clause to filter active customers only",
- parameter_updates=[
- {
- "op": "set_code",
- "block_id": "b0", # from step 1
- "code_id": "b0.c0", # from step 1
- "script": "SELECT * FROM customers WHERE status = 'active' AND region = 'US';"
- }
- ]
- )
-
-Example 2 - Append a new code block to the second block of an existing transformation:
- update_sql_transformation(
- configuration_id="12345",
- change_description="Added aggregation step",
- parameter_updates=[
- {
- "op": "add_code",
- "block_id": "b1", # second block
- "code": {
- "name": "Aggregate Sales",
- "script": "SELECT customer_id, SUM(amount) as total FROM orders GROUP BY customer_id;"
- },
- "position": "end"
- }
- ]
- )
-
-Example 3 - Replace table name across all SQL scripts:
- update_sql_transformation(
- configuration_id="12345",
- change_description="Renamed source table from old_customers to customers",
- parameter_updates=[
- {
- "op": "str_replace",
- "search_for": "old_customers",
- "replace_with": "customers"
- # No block_id or code_id = applies to all scripts
- }
- ]
- )
-
-Example 4 - Update storage mappings:
- update_sql_transformation(
- configuration_id="12345",
- change_description="Added new input table",
- storage={
- "input": {
- "tables": [
- {
- "source": "in.c-main.customers",
- "destination": "customers"
- },
- {
- "source": "in.c-main.orders",
- "destination": "orders"
- }
- ]
- },
- "output": {
- "tables": [
- {
- "source": "result",
- "destination": "out.c-main.customer_summary"
- }
- ]
- }
- }
- )
+Updates an existing component configuration row by modifying its parameters, storage mappings, name, or description. Updates are PARTIAL — only provide the fields you want to change; parameter_updates apply granular diff operations to the existing row parameters.
**Input JSON Schema**:
```json
{
- "$defs": {
- "Block": {
- "description": "The transformation block.",
- "properties": {
- "name": {
- "description": "A descriptive name for the code block",
- "type": "string"
- },
- "codes": {
- "description": "SQL code sub-blocks",
- "items": {
- "$ref": "#/$defs/Code"
- },
- "type": "array"
- }
- },
- "required": [
- "name",
- "codes"
- ],
- "type": "object"
- },
- "Code": {
- "description": "The code block for the transformation block.",
- "properties": {
- "name": {
- "description": "A descriptive name for the code block",
- "type": "string"
- },
- "script": {
- "description": "The SQL script of the code block",
- "type": "string"
- }
- },
- "required": [
- "name",
- "script"
- ],
- "type": "object"
- },
- "TfAddBlock": {
- "description": "Add a new block to the transformation.",
- "properties": {
- "op": {
- "const": "add_block",
- "type": "string"
- },
- "block": {
- "$ref": "#/$defs/Block",
- "description": "The block to add"
- },
- "position": {
- "default": "end",
- "description": "The position of the block to add",
- "enum": [
- "start",
- "end"
- ],
- "type": "string"
- }
- },
- "required": [
- "op",
- "block"
- ],
- "type": "object"
- },
- "TfAddCode": {
- "description": "Add a new code to an existing block in the transformation.",
- "properties": {
- "op": {
- "const": "add_code",
- "type": "string"
- },
- "block_id": {
- "description": "The ID of the block to add the code to",
- "type": "string"
- },
- "code": {
- "$ref": "#/$defs/Code",
- "description": "The code to add"
- },
- "position": {
- "default": "end",
- "description": "The position of the code to add",
- "enum": [
- "start",
- "end"
- ],
- "type": "string"
- }
- },
- "required": [
- "op",
- "block_id",
- "code"
- ],
- "type": "object"
- },
- "TfAddScript": {
- "description": "Append or prepend SQL script text to an existing code in an existing block in the transformation.",
- "properties": {
- "op": {
- "const": "add_script",
- "type": "string"
- },
- "block_id": {
- "description": "The ID of the block to add the script to",
- "type": "string"
- },
- "code_id": {
- "description": "The ID of the code to add the script to",
- "type": "string"
- },
- "script": {
- "description": "The SQL script to add",
- "type": "string"
- },
- "position": {
- "default": "end",
- "description": "The position of the script to add",
- "enum": [
- "start",
- "end"
- ],
- "type": "string"
- }
- },
- "required": [
- "op",
- "block_id",
- "code_id",
- "script"
- ],
- "type": "object"
+ "type": "object",
+ "properties": {
+ "change_description": {
+ "type": "string",
+ "description": "A clear, human-readable summary of what changed in this row update. Be specific."
},
- "TfRemoveBlock": {
- "description": "Remove an existing block from the transformation.",
- "properties": {
- "op": {
- "const": "remove_block",
- "type": "string"
- },
- "block_id": {
- "description": "The ID of the block to remove",
- "type": "string"
- }
- },
- "required": [
- "op",
- "block_id"
- ],
- "type": "object"
+ "component_id": {
+ "type": "string",
+ "description": "The ID of the component the configuration belongs to."
},
- "TfRemoveCode": {
- "description": "Remove an existing code from an existing block in the transformation.",
- "properties": {
- "op": {
- "const": "remove_code",
- "type": "string"
- },
- "block_id": {
- "description": "The ID of the block to remove the code from",
- "type": "string"
- },
- "code_id": {
- "description": "The ID of the code to remove",
- "type": "string"
- }
- },
- "required": [
- "op",
- "block_id",
- "code_id"
- ],
- "type": "object"
+ "configuration_id": {
+ "type": "string",
+ "description": "The ID of the parent configuration containing the row to update."
},
- "TfRenameBlock": {
- "description": "Rename an existing block in the transformation.",
- "properties": {
- "op": {
- "const": "rename_block",
- "type": "string"
- },
- "block_id": {
- "description": "The ID of the block to rename",
- "type": "string"
- },
- "block_name": {
- "description": "The new name of the block",
- "type": "string"
- }
- },
- "required": [
- "op",
- "block_id",
- "block_name"
- ],
- "type": "object"
+ "configuration_row_id": {
+ "type": "string",
+ "description": "The ID of the specific configuration row to update."
},
- "TfRenameCode": {
- "description": "Rename an existing code in an existing block in the transformation.",
- "properties": {
- "op": {
- "const": "rename_code",
- "type": "string"
- },
- "block_id": {
- "description": "The ID of the block to rename the code in",
- "type": "string"
- },
- "code_id": {
- "description": "The ID of the code to rename",
- "type": "string"
+ "name": {
+ "default": "",
+ "description": "New name for the configuration row. Only provide if changing the name. Name should be short (typically under 50 characters) and descriptive of this specific row.",
+ "type": "string"
+ },
+ "description": {
+ "default": "",
+ "description": "New detailed description for the configuration row. Only provide if changing the description. Should explain the specific purpose and behavior of this individual row.",
+ "type": "string"
+ },
+ "parameter_updates": {
+ "description": "List of granular parameter update operations to apply to this row. Each operation (set, str_replace, remove, list_append) modifies a specific parameter using JSONPath notation. Only provide if updating parameters - do not use for changing description or storage. Paths are relative to the row's `parameters` object, not the row root (e.g. use `tables`, not `parameters.tables`). Prefer simple dot-delimited JSONPaths and make the smallest possible updates - only change what needs changing. In case you need to replace the whole parameters, you can use the `set` operation with `$` as path.",
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "op": {
+ "type": "string",
+ "const": "set"
+ },
+ "path": {
+ "type": "string",
+ "description": "JSONPath to the parameter key to set (e.g., \"api_key\", \"database.host\")"
+ },
+ "value": {
+ "description": "New value to set"
+ }
+ },
+ "required": [
+ "op",
+ "path",
+ "value"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "op": {
+ "type": "string",
+ "const": "str_replace"
+ },
+ "path": {
+ "type": "string",
+ "description": "JSONPath to the parameter key to modify"
+ },
+ "search_for": {
+ "type": "string",
+ "description": "Substring to search for (non-empty)"
+ },
+ "replace_with": {
+ "type": "string",
+ "description": "Replacement string (can be empty for deletion)"
+ }
+ },
+ "required": [
+ "op",
+ "path",
+ "search_for",
+ "replace_with"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "op": {
+ "type": "string",
+ "const": "remove"
+ },
+ "path": {
+ "type": "string",
+ "description": "JSONPath to the parameter key to remove"
+ }
+ },
+ "required": [
+ "op",
+ "path"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "op": {
+ "type": "string",
+ "const": "list_append"
+ },
+ "path": {
+ "type": "string",
+ "description": "JSONPath to the list parameter"
+ },
+ "value": {
+ "description": "Value to append to the list"
+ }
+ },
+ "required": [
+ "op",
+ "path",
+ "value"
+ ]
+ }
+ ]
+ }
},
- "code_name": {
- "description": "The new name of the code",
- "type": "string"
+ {
+ "type": "null"
}
- },
- "required": [
- "op",
- "block_id",
- "code_id",
- "code_name"
- ],
- "type": "object"
+ ]
},
- "TfSetCode": {
- "description": "Set the SQL script of an existing code in an existing block in the transformation.",
- "properties": {
- "op": {
- "const": "set_code",
- "type": "string"
- },
- "block_id": {
- "description": "The ID of the block to set the code in",
- "type": "string"
- },
- "code_id": {
- "description": "The ID of the code to set",
- "type": "string"
+ "storage": {
+ "description": "Complete storage configuration for this row containing input/output table and file mappings. Only provide if updating storage mappings - this replaces the ENTIRE storage configuration for this row.",
+ "anyOf": [
+ {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
},
- "script": {
- "description": "The SQL script of the code to set",
- "type": "string"
+ {
+ "type": "null"
}
- },
- "required": [
- "op",
- "block_id",
- "code_id",
- "script"
- ],
- "type": "object"
+ ]
},
- "TfStrReplace": {
- "description": "Replace a substring in SQL statements in the transformation.",
- "properties": {
- "op": {
- "const": "str_replace",
- "type": "string"
- },
- "block_id": {
- "anyOf": [
- {
+ "processors_before": {
+ "description": "The list of processors that will run before the configured component runs.",
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "propertyNames": {
"type": "string"
},
- {
- "type": "null"
- }
- ],
- "default": null,
- "description": "The ID of the block to replace substrings in. If not provided, all blocks will be updated."
+ "additionalProperties": {}
+ }
},
- "code_id": {
- "anyOf": [
- {
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "processors_after": {
+ "description": "The list of processors that will run after the configured component runs.",
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "propertyNames": {
"type": "string"
},
- {
- "type": "null"
- }
- ],
- "default": null,
- "description": "The ID of the code to replace substrings in. If not provided, all codes in the block will be updated."
- },
- "search_for": {
- "description": "Substring to search for (non-empty)",
- "type": "string"
+ "additionalProperties": {}
+ }
},
- "replace_with": {
- "description": "Replacement string (can be empty for deletion)",
- "type": "string"
+ {
+ "type": "null"
}
- },
- "required": [
- "op",
- "search_for",
- "replace_with"
- ],
- "type": "object"
+ ]
},
- "VariableDefinition": {
- "description": "A single variable definition to attach to a configuration.",
- "properties": {
- "name": {
- "description": "Variable name.",
- "type": "string"
- },
- "type": {
- "default": "string",
- "description": "Variable type: \"string\" or \"vault\".",
- "enum": [
- "string",
- "vault"
- ],
- "type": "string"
+ "is_disabled": {
+ "description": "Enable or disable the configuration row. Set to True to disable execution (config row won't run), False to enable execution (config row will run). Only provide if changing the status, leave as null to preserve current state.",
+ "anyOf": [
+ {
+ "type": "boolean"
},
- "default_value": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "default": null,
- "description": "Optional default value bound at creation time."
+ {
+ "type": "null"
}
- },
- "required": [
- "name"
- ],
- "type": "object"
+ ]
}
},
- "additionalProperties": false,
+ "required": [
+ "change_description",
+ "component_id",
+ "configuration_id",
+ "configuration_row_id"
+ ],
+ "$schema": "http://json-schema.org/draft-07/schema#"
+}
+```
+
+---
+
+## update_sql_transformation
+**Annotations**: `destructive`
+
+**Tags**: `components, config-diff-preview`
+
+**Description**:
+
+Updates an existing SQL transformation configuration by modifying its SQL code, storage mappings, name or description. parameter_updates apply PARTIAL, granular diff operations to the transformation blocks/codes; storage is a complete replacement.
+
+
+**Input JSON Schema**:
+```json
+{
+ "type": "object",
"properties": {
"change_description": {
- "description": "A clear, human-readable summary of what changed in this transformation update. Be specific: e.g., \"Added JOIN with customers table\", \"Updated WHERE clause to filter active records\".",
- "type": "string"
+ "type": "string",
+ "description": "A clear, human-readable summary of what changed in this transformation update. Be specific: e.g., \"Added JOIN with customers table\", \"Updated WHERE clause to filter active records\"."
},
"configuration_id": {
- "description": "The ID of the transformation configuration to update.",
- "type": "string"
+ "type": "string",
+ "description": "The ID of the transformation configuration to update."
},
"name": {
"default": "",
@@ -1811,62 +1111,319 @@ Example 4 - Update storage mappings:
"type": "string"
},
"parameter_updates": {
- "default": null,
- "description": "List of operations to apply to the transformation structure (blocks, codes, SQL scripts). Each operation modifies specific elements using block_id and code_id identifiers. Only provide if updating SQL code or block structure - do not use for description or storage changes. \n\nIMPORTANT: Use get_configs first to retrieve the current transformation structure and identify the block_id and code_id values needed for your operations. IDs are automatically assigned.\n\nAvailable operations:\n1. add_block: Add a new block to the transformation\n - Fields: op=\"add_block\", block={name, codes}, position=\"start\"|\"end\"\n2. remove_block: Remove an existing block\n - Fields: op=\"remove_block\", block_id (e.g., \"b0\")\n3. rename_block: Rename an existing block\n - Fields: op=\"rename_block\", block_id (e.g., \"b0\"), block_name\n4. add_code: Add a new code block to an existing block\n - Fields: op=\"add_code\", block_id (e.g., \"b0\"), code={name, script}, position=\"start\"|\"end\"\n5. remove_code: Remove an existing code block\n - Fields: op=\"remove_code\", block_id (e.g., \"b0\"), code_id (e.g., \"b0.c0\")\n6. rename_code: Rename an existing code block\n - Fields: op=\"rename_code\", block_id (e.g., \"b0\"), code_id (e.g., \"b0.c0\"), code_name\n7. set_code: Replace the entire SQL script of a code block\n - Fields: op=\"set_code\", block_id (e.g., \"b0\"), code_id (e.g., \"b0.c0\"), script\n8. add_script: Append or prepend SQL to a code block\n - Fields: op=\"add_script\", block_id (e.g., \"b0\"), code_id (e.g., \"b0.c0\"), script, position=\"start\"|\"end\"\n9. str_replace: Replace substring in SQL scripts\n - Fields: op=\"str_replace\", search_for, replace_with, block_id (optional), code_id (optional)\n - If block_id omitted: replaces in all blocks\n - If code_id omitted: replaces in all codes of the specified block\n",
- "items": {
- "discriminator": {
- "mapping": {
- "add_block": "#/$defs/TfAddBlock",
- "add_code": "#/$defs/TfAddCode",
- "add_script": "#/$defs/TfAddScript",
- "remove_block": "#/$defs/TfRemoveBlock",
- "remove_code": "#/$defs/TfRemoveCode",
- "rename_block": "#/$defs/TfRenameBlock",
- "rename_code": "#/$defs/TfRenameCode",
- "set_code": "#/$defs/TfSetCode",
- "str_replace": "#/$defs/TfStrReplace"
- },
- "propertyName": "op"
- },
- "oneOf": [
- {
- "$ref": "#/$defs/TfAddBlock"
- },
- {
- "$ref": "#/$defs/TfRemoveBlock"
- },
- {
- "$ref": "#/$defs/TfRenameBlock"
- },
- {
- "$ref": "#/$defs/TfAddCode"
- },
- {
- "$ref": "#/$defs/TfRemoveCode"
- },
- {
- "$ref": "#/$defs/TfRenameCode"
- },
- {
- "$ref": "#/$defs/TfSetCode"
- },
- {
- "$ref": "#/$defs/TfAddScript"
- },
- {
- "$ref": "#/$defs/TfStrReplace"
+ "description": "List of operations to apply to the transformation structure (blocks, codes, SQL scripts). Each operation modifies specific elements using block_id and code_id identifiers. Only provide if updating SQL code or block structure - do not use for description or storage changes. Use get_configs first to retrieve the current transformation structure and identify the block_id and code_id values needed for your operations. IDs are automatically assigned. Available operations: add_block, remove_block, rename_block, add_code, remove_code, rename_code, set_code, add_script, str_replace.",
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "op": {
+ "type": "string",
+ "const": "add_block"
+ },
+ "block": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "A descriptive name for the code block"
+ },
+ "codes": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "A descriptive name for the code block"
+ },
+ "script": {
+ "type": "string",
+ "description": "The SQL script of the code block"
+ }
+ },
+ "required": [
+ "name",
+ "script"
+ ]
+ },
+ "description": "SQL code sub-blocks"
+ }
+ },
+ "required": [
+ "name",
+ "codes"
+ ],
+ "description": "The block to add"
+ },
+ "position": {
+ "default": "end",
+ "type": "string",
+ "enum": [
+ "start",
+ "end"
+ ]
+ }
+ },
+ "required": [
+ "op",
+ "block"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "op": {
+ "type": "string",
+ "const": "remove_block"
+ },
+ "block_id": {
+ "type": "string",
+ "description": "The ID of the block to remove"
+ }
+ },
+ "required": [
+ "op",
+ "block_id"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "op": {
+ "type": "string",
+ "const": "rename_block"
+ },
+ "block_id": {
+ "type": "string"
+ },
+ "block_name": {
+ "type": "string",
+ "description": "The new name of the block"
+ }
+ },
+ "required": [
+ "op",
+ "block_id",
+ "block_name"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "op": {
+ "type": "string",
+ "const": "add_code"
+ },
+ "block_id": {
+ "type": "string"
+ },
+ "code": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "A descriptive name for the code block"
+ },
+ "script": {
+ "type": "string",
+ "description": "The SQL script of the code block"
+ }
+ },
+ "required": [
+ "name",
+ "script"
+ ],
+ "description": "The code to add"
+ },
+ "position": {
+ "default": "end",
+ "type": "string",
+ "enum": [
+ "start",
+ "end"
+ ]
+ }
+ },
+ "required": [
+ "op",
+ "block_id",
+ "code"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "op": {
+ "type": "string",
+ "const": "remove_code"
+ },
+ "block_id": {
+ "type": "string"
+ },
+ "code_id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "op",
+ "block_id",
+ "code_id"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "op": {
+ "type": "string",
+ "const": "rename_code"
+ },
+ "block_id": {
+ "type": "string"
+ },
+ "code_id": {
+ "type": "string"
+ },
+ "code_name": {
+ "type": "string",
+ "description": "The new name of the code"
+ }
+ },
+ "required": [
+ "op",
+ "block_id",
+ "code_id",
+ "code_name"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "op": {
+ "type": "string",
+ "const": "set_code"
+ },
+ "block_id": {
+ "type": "string"
+ },
+ "code_id": {
+ "type": "string"
+ },
+ "script": {
+ "type": "string",
+ "description": "The SQL script of the code to set"
+ }
+ },
+ "required": [
+ "op",
+ "block_id",
+ "code_id",
+ "script"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "op": {
+ "type": "string",
+ "const": "add_script"
+ },
+ "block_id": {
+ "type": "string"
+ },
+ "code_id": {
+ "type": "string"
+ },
+ "script": {
+ "type": "string",
+ "description": "The SQL script to add"
+ },
+ "position": {
+ "default": "end",
+ "type": "string",
+ "enum": [
+ "start",
+ "end"
+ ]
+ }
+ },
+ "required": [
+ "op",
+ "block_id",
+ "code_id",
+ "script"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "op": {
+ "type": "string",
+ "const": "str_replace"
+ },
+ "block_id": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "code_id": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "search_for": {
+ "type": "string",
+ "description": "Substring to search for (non-empty)"
+ },
+ "replace_with": {
+ "type": "string",
+ "description": "Replacement string (can be empty for deletion)"
+ }
+ },
+ "required": [
+ "op",
+ "search_for",
+ "replace_with"
+ ]
+ }
+ ]
}
- ]
- },
- "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ]
},
"storage": {
- "additionalProperties": true,
- "default": null,
- "description": "Complete storage configuration for transformation input/output table mappings. Only provide if updating storage mappings - this replaces the ENTIRE storage configuration. \n\nWhen to use:\n- Adding/removing input tables for the transformation\n- Modifying output table mappings and destinations\n- Changing table aliases used in SQL\n\nImportant:\n- Must conform to transformation storage schema (input/output tables)\n- Replaces ALL existing storage config - include all mappings you want to keep\n- Use get_configs first to see current storage configuration\n- Leave unfilled to preserve existing storage configuration",
- "type": "object"
+ "description": "Complete storage configuration for transformation input/output table mappings. Only provide if updating storage mappings - this replaces the ENTIRE storage configuration.",
+ "anyOf": [
+ {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
+ },
+ {
+ "type": "null"
+ }
+ ]
},
"folder": {
+ "description": "Folder name to organize this transformation in the Keboola UI. Pass an empty string to remove an existing folder assignment. Existing folder names are returned in the response change_summary when no folder is provided and there are 20 or more transformations in the project. If there are 20 or more transformations, you should assign one of the existing folders or create a new one that clearly reflects the transformation purpose.",
"anyOf": [
{
"type": "string"
@@ -1874,31 +1431,57 @@ Example 4 - Update storage mappings:
{
"type": "null"
}
- ],
- "default": null,
- "description": "Folder name to organize this transformation in the Keboola UI. Pass an empty string to remove an existing folder assignment. Existing folder names are returned in the response change_summary when no folder is provided and there are 20 or more transformations in the project. If there are 20 or more transformations, you should assign one of the existing folders or create a new one that clearly reflects the transformation purpose."
+ ]
},
"variables": {
+ "description": "Variable definitions for this transformation. Provide a non-empty list to create or replace all variable definitions. Provide an empty list ([]) to remove all variables. Omit (None) to leave existing variables unchanged.",
"anyOf": [
{
+ "type": "array",
"items": {
- "$ref": "#/$defs/VariableDefinition"
- },
- "type": "array"
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Variable name."
+ },
+ "type": {
+ "default": "string",
+ "description": "Variable type: \"string\" or \"vault\".",
+ "type": "string",
+ "enum": [
+ "string",
+ "vault"
+ ]
+ },
+ "default_value": {
+ "description": "Optional default value bound at creation time.",
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "name"
+ ]
+ }
},
{
"type": "null"
}
- ],
- "default": null,
- "description": "Variable definitions for this transformation. Provide a non-empty list to create or replace all variable definitions. Provide an empty list ([]) to remove all variables. Omit (None) to leave existing variables unchanged."
+ ]
}
},
"required": [
"change_description",
"configuration_id"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -1913,60 +1496,33 @@ Example 4 - Update storage mappings:
**Description**:
-Mints a one-time HTTPS token on a python-js **prod** data app so the caller can clone, pull,
-and push to the app's managed git repo over HTTPS.
-
-**Always call against the prod app's configuration_id** — drafts have no managed repo of their
-own, so calling this on a draft fails. The prod app is the canonical repo owner; drafts
-iterate against branches of that same repo.
-
-**MCP never runs git on your behalf.** All git work — clone, branch, commit, push, merge,
-branch-delete — is yours. This tool only mints credentials.
+Mints a one-time HTTPS token on a python-js **prod** data app so the caller can clone, pull, and push to the app's managed git repo over HTTPS.
-Returns a ready-to-use `git_clone_url` of the form `https://kai:@/.git`
-plus the raw `secret`. The token is returned **only** at creation — the platform cannot return
-it again on any subsequent read. Stash the URL (or the secret) somewhere the LLM can reuse for
-the rest of the session.
+**Always call against the prod app's configuration_id** — drafts have no managed repo of their own, so calling this on a draft fails. The prod app is the canonical repo owner; drafts iterate against branches of that same repo.
-The data-science API accepts multiple credentials per app, so calling this again mints an
-additional token without invalidating any tokens already held by other clients.
+**MCP never runs git on your behalf.** All git work — clone, branch, commit, push, merge, branch-delete — is yours. This tool only mints credentials.
-## When to call
-
-1. **Right after `modify_python_js_data_app` create of a prod app** — the new prod has a
- managed repo but no credentials yet. Call this tool with the new app's `configuration_id`
- to enable git access. (Note: when creating a **draft**, the prod-side token is minted and
- embedded into the returned `git_clone_url` automatically — no separate call needed.)
-
-2. **Recovery when the cached token is gone / continuing an unfinished draft** — e.g., a fresh
- sandbox continuing yesterday's work, with the previous sandbox's filesystem wiped. The
- cached `git_clone_url` is lost; the configuration ID for the prod app is all you have.
- Call this tool with the **prod app's** `configuration_id` to mint a fresh token (drafts
- have no managed repo, so always mint against prod). Existing credentials remain valid, so
- other clients are not disrupted.
+Returns a ready-to-use `git_clone_url` of the form `https://kai:@/.git` plus the raw `secret`. The token is returned **only** at creation — the platform cannot return it again on any subsequent read. Stash the URL (or the secret) somewhere the LLM can reuse for the rest of the session.
## Constraints
-- Only python-js prod data apps have a managed git repo. Streamlit apps reject the call with
- a clear error.
-- Permissions are always `readWrite` — the LLM virtually always needs push access. The
- data-science API supports read-only credentials, but the tool does not expose that knob;
- revisit once a real use case appears.
+- Only python-js prod data apps have a managed git repo. Streamlit apps reject the call with a clear error.
+- Permissions are always `readWrite`.
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"configuration_id": {
- "description": "Storage configuration ID of the python-js data app.",
- "type": "string"
+ "type": "string",
+ "description": "Storage configuration ID of the python-js data app."
}
},
"required": [
"configuration_id"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -1979,16 +1535,11 @@ additional token without invalidating any tokens already held by other clients.
**Description**:
-Deletes a python-js DRAFT data app — both the data-app instance (DSAPI) and its Storage
-configuration.
+Deletes a python-js DRAFT data app — both the data-app instance (DSAPI) and its Storage configuration.
-**MCP never runs git on your behalf.** Deleting the feature branch on the remote is your job;
-this tool only tears down the draft config and its data-app instance.
+**MCP never runs git on your behalf.** Deleting the feature branch on the remote is your job; this tool only tears down the draft config and its data-app instance.
-WHEN TO CALL: at the end of a promote-to-prod sequence, after you have merged the draft's
-branch into `main`, pushed, deleted the feature branch from the remote, and redeployed the
-prod app. The Keboola UI lists drafts under their parent prod app; once you call this tool,
-the draft disappears from that list.
+WHEN TO CALL: at the end of a promote-to-prod sequence, after you have merged the draft's branch into `main`, pushed, deleted the feature branch from the remote, and redeployed the prod app. The Keboola UI lists drafts under their parent prod app; once you call this tool, the draft disappears from that list.
WHAT THIS TOOL REFUSES:
- prod apps (no `isDraft` flag) — protects against accidental prod deletion;
@@ -1996,27 +1547,25 @@ WHAT THIS TOOL REFUSES:
WHAT THIS TOOL DOES NOT DO:
- Run git. Deleting the feature branch on the remote is your job.
- - Revoke the prod-side git credential minted when the draft was created. Credential
- rotation is the user's job via the Keboola UI.
+ - Revoke the prod-side git credential minted when the draft was created.
-After a successful call, pivot back to the parent prod app (its configuration_id is returned
-in the response) or to `get_data_apps` for further work.
+After a successful call, pivot back to the parent prod app (its configuration_id is returned in the response) or to `get_data_apps` for further work.
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"configuration_id": {
- "description": "Storage configuration ID of the python-js draft data app to delete.",
- "type": "string"
+ "type": "string",
+ "description": "Storage configuration ID of the python-js draft data app to delete."
}
},
"required": [
"configuration_id"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -2029,74 +1578,62 @@ in the response) or to `get_data_apps` for further work.
**Description**:
-Deploys/redeploys a data app or stops a running data app in the Keboola environment asynchronously, given the
-action and the configuration ID.
+Deploys/redeploys a data app or stops a running data app in the Keboola environment asynchronously, given the action and the configuration ID.
-**MCP never runs git on your behalf.** All git work — clone, branch, commit, push, merge,
-branch-delete — is yours. This tool only triggers deploys against existing git state.
+**MCP never runs git on your behalf.** All git work — clone, branch, commit, push, merge, branch-delete — is yours. This tool only triggers deploys against existing git state.
## Mode (python-js apps)
-- `mode='dev'` deploys the target as a **dev version of the data app** — the runtime uses a
- development `setup.sh` (hot reload) and the data-app proxy enables an auto-auth path so an
- iframe preview can render without a manual login. Only meaningful on **draft** configs
- (python-js apps with `isDraft=true`).
-- For prod redeploys (including after merging a draft's branch into `main`), use no `mode` —
- the prod app picks up the current `main`.
-- The branch a draft deploys from is pinned in `parameters.dataApp.git.branch` at create time;
- there is no deploy-time override.
-- python-js apps do NOT fetch a Storage `configVersion` for deployment (their source lives in
- git, not in the Storage configuration); this is handled automatically.
+- `mode='dev'` deploys the target as a **dev version of the data app** — the runtime uses a development `setup.sh` (hot reload) and the data-app proxy enables an auto-auth path so an iframe preview can render without a manual login. Only meaningful on **draft** configs (python-js apps with `isDraft=true`).
+- For prod redeploys (including after merging a draft's branch into `main`), use no `mode` — the prod app picks up the current `main`.
+- The branch a draft deploys from is pinned in `parameters.dataApp.git.branch` at create time; there is no deploy-time override.
+- python-js apps do NOT fetch a Storage `configVersion` for deployment (their source lives in git, not in the Storage configuration); this is handled automatically.
## Streamlit apps
-Streamlit apps have no managed git repo, so `mode` has no effect on the deployed app.
-`mode=None` is the expected call shape.
+Streamlit apps have no managed git repo, so `mode` has no effect on the deployed app. `mode=None` is the expected call shape.
## General considerations
-- Redeploying a data app takes some time, and the app may temporarily report status "stopped" during the
- restart.
-- After deployment, the deployment info includes the app URL and the latest logs to help diagnose in-app
- errors.
+- Redeploying a data app takes some time, and the app may temporarily report status "stopped" during the restart.
+- After deployment, the deployment info includes the app URL and the latest logs to help diagnose in-app errors.
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"action": {
- "description": "The action to perform.",
+ "type": "string",
"enum": [
"deploy",
"stop"
],
- "type": "string"
+ "description": "The action to perform."
},
"configuration_id": {
- "description": "The ID of the data app configuration.",
- "type": "string"
+ "type": "string",
+ "description": "The ID of the data app configuration."
},
"mode": {
+ "description": "Deployment mode. Set to \"dev\" to deploy a python-js draft as a **dev version of the data app** — the runtime uses a development `setup.sh` (hot reload), and the data-app proxy enables an auto-auth path so an iframe preview can render without a manual login. Only meaningful on **draft** configs (python-js apps with `isDraft=true`). Leave None (default) for prod redeploys and for Streamlit apps.",
"anyOf": [
{
+ "type": "string",
"enum": [
"dev",
"production"
- ],
- "type": "string"
+ ]
},
{
"type": "null"
}
- ],
- "default": null,
- "description": "Deployment mode. Set to \"dev\" to deploy a python-js draft as a **dev version of the data app** \u2014 the runtime uses a development `setup.sh` (hot reload), and the data-app proxy enables an auto-auth path so an iframe preview can render without a manual login. Only meaningful on **draft** configs (python-js apps with `isDraft=true`). Leave None (default) for prod redeploys and for Streamlit apps."
+ ]
}
},
"required": [
"action",
"configuration_id"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -2109,60 +1646,50 @@ Streamlit apps have no managed git repo, so `mode` has no effect on the deployed
**Description**:
-Lists summaries of data apps in the project given the limit and offset or gets details of a data apps by
-providing their configuration IDs.
+Lists summaries of data apps in the project given the limit and offset or gets details of a data apps by providing their configuration IDs.
WHEN NOT TO USE:
-- Do NOT list all data apps just to find one by name. Use `search` with
- item_types=["data-app"] instead.
+- Do NOT list all data apps just to find one by name. Use `search` with item_types=["data-app"] instead.
- Only list all data apps when you need a complete inventory.
Considerations:
- If configuration_ids are provided, the tool will return details of the data apps by their configuration IDs.
- If no configuration_ids are provided, the tool will list all data apps in the project given the limit and offset.
-- Data App detail contains configuration, metadata, source code, links, and deployment info along with the latest
-data app logs to investigate in-app errors. The logs may be updated after opening the data app URL.
-- `deployment_info.last_run` carries the outcome of the most recent deployment attempt. For an app
- that fails to start, check its `failure_reason`/`failure_message` FIRST — they cover setup-phase
- failures (e.g. invalid secrets, git clone errors, failing setup scripts) that happen before the
- container starts and therefore never appear in the regular logs.
-- `repo_url` (managed git repo URL for python-js apps) is ONLY populated on the detail path
- (when `configuration_ids` is provided). The inventory list always returns `repo_url=None`,
- even for python-js apps with a managed repo — to retrieve the URL, call this tool again
- with the target `configuration_ids`.
-- When called with `configuration_ids=[]` for a python-js **prod** app, the response
- includes a `drafts: [...]` array of every draft (configs with `isDraft=true` and
- `parentConfigurationId == `) currently in the project. Drafts in trash are not
- included. Use this to discover existing drafts when continuing a previously abandoned
- iteration (Scenario C in `modify_python_js_data_app`). The array is empty for drafts
- themselves and for Streamlit apps.
+- Data App detail contains configuration, metadata, source code, links, and deployment info along with the latest data app logs to investigate in-app errors. The logs may be updated after opening the data app URL.
+- `deployment_info.last_run` carries the outcome of the most recent deployment attempt. For an app that fails to start, check its `failure_reason`/`failure_message` FIRST — they cover setup-phase failures (e.g. invalid secrets, git clone errors, failing setup scripts) that happen before the container starts and therefore never appear in the regular logs.
+- `repo_url` (managed git repo URL for python-js apps) is ONLY populated on the detail path (when `configuration_ids` is provided). The inventory list always returns `repo_url=None`, even for python-js apps with a managed repo — to retrieve the URL, call this tool again with the target `configuration_ids`.
+- When called with `configuration_ids=[]` for a python-js **prod** app, the response includes a `drafts: [...]` array of every draft (configs with `isDraft=true` and `parentConfigurationId == `) currently in the project. Drafts in trash are not included. The array is empty for drafts themselves and for Streamlit apps.
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"configuration_ids": {
"default": [],
"description": "The IDs of the data app configurations.",
+ "type": "array",
"items": {
"type": "string"
- },
- "type": "array"
+ }
},
"limit": {
"default": 100,
"description": "The limit of the data apps to fetch.",
- "type": "integer"
+ "type": "integer",
+ "minimum": -9007199254740991,
+ "maximum": 9007199254740991
},
"offset": {
"default": 0,
"description": "The offset of the data apps to fetch.",
- "type": "integer"
+ "type": "integer",
+ "minimum": -9007199254740991,
+ "maximum": 9007199254740991
}
},
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -2177,110 +1704,37 @@ data app logs to investigate in-app errors. The logs may be updated after openin
Creates or updates a python-js data app.
-Two-app project model. Every python-js project has a persistent **prod app** that owns the
-only managed git repository for the project, and zero or more **drafts** parented to that
-prod app. A draft is a Storage configuration with `parameters.dataApp.isDraft=true` and
-`parameters.dataApp.parentConfigurationId=`; it's an *external-git* app that
-clones the parent prod's repo at a pinned branch on every deploy. Drafts are surfaced in the
-Keboola UI under their parent prod app. Use `deploy_data_app(mode='dev')` to deploy a draft
-as a dev version of the data app (hot reload + auto-auth for iframe preview); use
-`delete_python_js_data_app_draft` to tear a draft down after its branch has been promoted.
-
-**MCP never runs git on your behalf.** All git work — clone, branch, commit, push, merge,
-branch-delete — is yours. MCP gives you authenticated clone URLs and manages configs/deploys;
-it never invokes git.
-
-**The draft flow below is mandatory — never edit prod source directly.** Every source-code
-change goes through a draft branch that the user previews and explicitly approves first. NEVER
-push directly to `main`: `main` only ever advances by merging an approved draft branch, and
-only after the user has approved that draft's preview.
-
-Three scenarios the agent has to distinguish:
-
-## Scenario A — Create a brand-new data app
-
-1. `modify_python_js_data_app(slug='demo')` → `(configuration_id=PROD, repo_url=R)`.
- PROD owns the only managed repo for this app.
-2. `modify_python_js_data_app(slug='demo-draft', parent_configuration_id=PROD)`
- → `(configuration_id=DRAFT, repo_url=R, git_clone_url=U, branch='init')`.
- Default draft branch is `'init'`. Override with `branch=` for a descriptive name.
-3. YOU: `git clone U`; `git checkout init` (creating it if the repo is empty); write source;
- `git push origin init`.
-4. `deploy_data_app(action='deploy', configuration_id=DRAFT, mode='dev')`
- → preview URL serving the `init` branch as a dev version. Iterate with the user.
-5. Once approved — YOU: `git checkout main`; `git merge init`; `git push origin main`;
- `git push origin --delete init`.
-6. `deploy_data_app(action='deploy', configuration_id=PROD)`
- → prod URL now serves the merged `main`.
-7. `delete_python_js_data_app_draft(configuration_id=DRAFT)`
- → tears down the draft's config + data-app instance. Always run this once promoted.
-
-## Scenario B — Edit an existing data app
-
-You already have PROD's `configuration_id` (from `get_data_apps` or earlier conversation).
-
-1. `create_python_js_data_app_git_credential(configuration_id=PROD)`
- → fresh `git_clone_url U` with an embedded one-time token.
-2. `modify_python_js_data_app(
- slug='demo-draft-',
- parent_configuration_id=PROD,
- branch='', # e.g. 'add-revenue-filter'
- )` → `(DRAFT, R, U2, branch)`. Use U2 (it has its own fresh token).
-3. YOU: `git clone U2`; `git checkout ` (creating it from `main`); edit source;
- `git push origin `.
-4–7. Same as Scenario A steps 4–7.
-
-## Scenario C — Continue an unfinished draft
-
-The previous sandbox is gone. You have PROD's `configuration_id` but no working clone and no
-draft handle.
-
-1. `get_data_apps(configuration_ids=[PROD])` → returns PROD's detail including `drafts: [...]`.
- Pick the draft the user means (ask if multiple and unclear). Each entry exposes its
- `configuration_id`, slug, and pinned branch.
-2. `create_python_js_data_app_git_credential(configuration_id=PROD)`
- → fresh `git_clone_url U` (the previous one was minted in a wiped sandbox and is lost).
- Drafts have no managed repo of their own — always mint against PROD.
-3. YOU: `git clone U`; `git checkout `; resume work; `git push`.
-4. `deploy_data_app(action='deploy', configuration_id=, mode='dev')` → preview URL.
- The draft's branch is already pinned in its config.
-5–7. Same promote/cleanup sequence as Scenario A steps 5–7.
+Two-app project model. Every python-js project has a persistent **prod app** that owns the only managed git repository for the project, and zero or more **drafts** parented to that prod app. A draft is a Storage configuration with `parameters.dataApp.isDraft=true` and `parameters.dataApp.parentConfigurationId=`; it's an *external-git* app that clones the parent prod's repo at a pinned branch on every deploy. Drafts are surfaced in the Keboola UI under their parent prod app. Use `deploy_data_app(mode='dev')` to deploy a draft as a dev version of the data app (hot reload + auto-auth for iframe preview); use `delete_python_js_data_app_draft` to tear a draft down after its branch has been promoted.
-## Argument rules
+**MCP never runs git on your behalf.** All git work — clone, branch, commit, push, merge, branch-delete — is yours. MCP gives you authenticated clone URLs and manages configs/deploys; it never invokes git.
+**The draft flow is mandatory — never edit prod source directly.** Every source-code change goes through a draft branch that the user previews and explicitly approves first. NEVER push directly to `main`: `main` only ever advances by merging an approved draft branch, and only after the user has approved that draft's preview.
+
+## Argument rules
- `parent_configuration_id` is **create-only**. Rejected on update.
-- `branch` is **create-only** and only valid when `parent_configuration_id` is set.
- Defaults to `'init'`. Must not be `'main'`. Rejected on prod create and on update.
+- `branch` is **create-only** and only valid when `parent_configuration_id` is set. Defaults to `'init'`. Must not be `'main'`. Rejected on prod create and on update.
- `slug` is required on create and immutable after.
-- The **update path** (passing `configuration_id`) is for changing `name`, `description`,
- `authentication_type`, `auto_suspend_after_seconds`, `storage` on either a prod app or
- a draft. Source code changes go through the git flow above, not this tool.
+- The **update path** (passing `configuration_id`) is for changing `name`, `description`, `authentication_type`, `auto_suspend_after_seconds`, `storage` on either a prod app or a draft. Source code changes go through the git flow above, not this tool.
## Authentication
-
-New apps default to HTTP basic authentication for safety. Pass `authentication_type='no-auth'`
-to expose publicly. On update, `authentication_type='default'` preserves the existing
-`authorization` block (including OIDC setups configured outside the MCP); `'basic-auth'` /
-`'no-auth'` overwrite it.
+New apps default to HTTP basic authentication for safety. Pass `authentication_type='no-auth'` to expose publicly. On update, `authentication_type='default'` preserves the existing `authorization` block (including OIDC setups configured outside the MCP); `'basic-auth'` / `'no-auth'` overwrite it.
## Slug constraint
-
-Must be DNS-label-safe (lowercase letters, digits, hyphens, ≤63 chars). For drafts, append a
-short suffix (e.g. `-draft-abc123`) to keep slugs unique across the prod and its drafts.
+Must be DNS-label-safe (lowercase letters, digits, hyphens, ≤63 chars). For drafts, append a short suffix (e.g. `-draft-abc123`) to keep slugs unique across the prod and its drafts.
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"name": {
- "description": "Name of the data app (max ~50 chars to fit DNS label limit).",
- "type": "string"
+ "type": "string",
+ "description": "Name of the data app (max ~50 chars to fit DNS label limit)."
},
"description": {
- "description": "Description of the data app.",
- "type": "string"
+ "type": "string",
+ "description": "Description of the data app."
},
"configuration_id": {
"default": "",
@@ -2293,6 +1747,7 @@ short suffix (e.g. `-draft-abc123`) to keep slugs unique across the prod and its
"type": "string"
},
"slug": {
+ "description": "URL-safe slug for the data app (used as a subdomain). Required when creating; immutable after.",
"anyOf": [
{
"type": "string"
@@ -2300,11 +1755,10 @@ short suffix (e.g. `-draft-abc123`) to keep slugs unique across the prod and its
{
"type": "null"
}
- ],
- "default": null,
- "description": "URL-safe slug for the data app (used as a subdomain). Required when creating; immutable after."
+ ]
},
"parent_configuration_id": {
+ "description": "Storage configuration ID of the prod python-js data app this draft will iterate against. When set on create, the new app is created as a **draft**: no managed repo is provisioned for it; instead its `parameters.dataApp.git` block is populated to point at the prod app's managed repo, with a freshly-minted prod-app HTTPS token and the chosen draft branch. Leave None on create to make a **prod app** (which gets its own managed repo). Rejected on update.",
"anyOf": [
{
"type": "string"
@@ -2312,11 +1766,10 @@ short suffix (e.g. `-draft-abc123`) to keep slugs unique across the prod and its
{
"type": "null"
}
- ],
- "default": null,
- "description": "Storage configuration ID of the prod python-js data app this draft will iterate against. When set on create, the new app is created as a **draft**: no managed repo is provisioned for it; instead its `parameters.dataApp.git` block is populated to point at the prod app's managed repo, with a freshly-minted prod-app HTTPS token and the chosen draft branch. Leave None on create to make a **prod app** (which gets its own managed repo). Rejected on update."
+ ]
},
"branch": {
+ "description": "Draft branch to pin the new draft to. Only valid on the draft create path (when `parent_configuration_id` is set). Defaults to `init` when unset. Must not be `main` (reserved for the prod app). Rejected on prod create and on update.",
"anyOf": [
{
"type": "string"
@@ -2324,39 +1777,42 @@ short suffix (e.g. `-draft-abc123`) to keep slugs unique across the prod and its
{
"type": "null"
}
- ],
- "default": null,
- "description": "Draft branch to pin the new draft to. Only valid on the draft create path (when `parent_configuration_id` is set). Defaults to `init` when unset (a sensible name for the first draft of a brand-new prod app). For subsequent edit-existing drafts, pass a descriptive branch name like 'add-revenue-filter'. Must not be `main` (reserved for the prod app). Rejected on prod create and on update."
+ ]
},
"authentication_type": {
"default": "default",
"description": "Authentication type. \"no-auth\" removes authentication completely, \"basic-auth\" secures the data app via HTTP basic authentication, and \"default\" means: on create, apply basic auth (safe default for new apps); on update, keep the existing authentication configuration (including OIDC setups configured outside the MCP).",
+ "type": "string",
"enum": [
"no-auth",
"basic-auth",
"default"
- ],
- "type": "string"
+ ]
},
"auto_suspend_after_seconds": {
"default": 900,
"description": "Number of seconds after which the running data app is automatically suspended.",
- "type": "integer"
+ "type": "integer",
+ "minimum": -9007199254740991,
+ "maximum": 9007199254740991
},
"storage": {
+ "description": "Complete storage configuration for the data app (input/output table mappings). Replaces the ENTIRE storage block when updating an existing app. Leave unset (None) to preserve the existing storage configuration; pass an empty dict to explicitly clear it.",
"anyOf": [
{
- "additionalProperties": true,
- "type": "object"
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
},
{
"type": "null"
}
- ],
- "default": null,
- "description": "Complete storage configuration for the data app (input/output table mappings). Validated against the storage JSON schema. Replaces the ENTIRE storage block when updating an existing app. For data apps with Storage Access, declare output tables with `unload_strategy: \"direct-grant\"` (in that case `source` is not required and the workspace is granted direct SELECT/INSERT/UPDATE/DELETE/TRUNCATE on the destination Storage table). Leave unset (None) to preserve the existing storage configuration; pass an empty dict to explicitly clear it."
+ ]
},
"folder": {
+ "description": "Folder name to organize this data app in the Keboola UI. Pass an empty string to remove an existing folder assignment. Existing folder names are returned in the response change_summary when no folder is provided and there are 20 or more data apps in the project. If there are 20 or more data apps, you should assign one of the existing folders or create a new one that clearly reflects the data app purpose.",
"anyOf": [
{
"type": "string"
@@ -2364,16 +1820,14 @@ short suffix (e.g. `-draft-abc123`) to keep slugs unique across the prod and its
{
"type": "null"
}
- ],
- "default": null,
- "description": "Folder name to organize this data app in the Keboola UI. Pass an empty string to remove an existing folder assignment. Existing folder names are returned in the response change_summary when no folder is provided and there are 20 or more data apps in the project. If there are 20 or more data apps, you should assign one of the existing folders or create a new one that clearly reflects the data app purpose."
+ ]
}
},
"required": [
"name",
"description"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -2389,63 +1843,49 @@ short suffix (e.g. `-draft-abc123`) to keep slugs unique across the prod and its
Creates or updates a Streamlit data app.
Considerations:
-- The `source_code` parameter must be a complete and runnable Streamlit app. It must include a placeholder
-`{QUERY_DATA_FUNCTION}` where a `query_data` function will be injected. This function queries the workspace to get
-data, it accepts a string of SQL query following current sql dialect and returns a pandas DataFrame with the results
-from the workspace.
-- Write SQL queries so they are compatible with the current workspace backend, you can ensure this by using the
-`query_data` tool to inspect the data in the workspace before using it in the data app.
-- If you're updating an existing data app, provide the `configuration_id` parameter and the `change_description`
-parameter. To keep existing data app values during an update, leave them as empty strings, lists, or None
-appropriately based on the parameter type.
-- After creating or updating a data app with this tool, ALWAYS call
-`deploy_data_app(action="deploy", configuration_id=...)` to start a new app or restart an existing app so
-changes take effect. Without this step, a newly created app will not start, and an existing app will keep
-running the previous deployment without the latest changes.
-- New apps use the HTTP basic authentication by default for security unless explicitly specified otherwise; when
-updating, set `authentication_type` to `default` to keep the existing authentication type configuration
-(including OIDC setups) unless explicitly specified otherwise.
+- The `source_code` parameter must be a complete and runnable Streamlit app. It must include a placeholder `{QUERY_DATA_FUNCTION}` where a `query_data` function will be injected. This function queries the workspace to get data, it accepts a string of SQL query following current sql dialect and returns a pandas DataFrame with the results from the workspace.
+- Write SQL queries so they are compatible with the current workspace backend, you can ensure this by using the `query_data` tool to inspect the data in the workspace before using it in the data app.
+- If you're updating an existing data app, provide the `configuration_id` parameter and the `change_description` parameter. To keep existing data app values during an update, leave them as empty strings, lists, or None appropriately based on the parameter type.
+- After creating or updating a data app with this tool, ALWAYS call `deploy_data_app(action="deploy", configuration_id=...)` to start a new app or restart an existing app so changes take effect. Without this step, a newly created app will not start, and an existing app will keep running the previous deployment without the latest changes.
+- New apps use the HTTP basic authentication by default for security unless explicitly specified otherwise; when updating, set `authentication_type` to `default` to keep the existing authentication type configuration (including OIDC setups) unless explicitly specified otherwise.
SQL & DATA TYPE RULES:
-- Use delimited identifiers for the current SQL dialect for all column names and aliases in SQL.
- Match the exact identifier case used in SQL when referencing columns in Python code.
-- `query_data` RETURNS ALL COLUMNS AS STRINGS regardless of SQL CAST. Always convert types in Python after loading:
-`df["col"] = pd.to_numeric(df["col"], errors="coerce").fillna(0)` and
-`df["date"] = pd.to_datetime(df["date"], errors="coerce")`.
+- Use delimited identifiers for the current SQL dialect for all column names and aliases in SQL. Match the exact identifier case used in SQL when referencing columns in Python code.
+- `query_data` RETURNS ALL COLUMNS AS STRINGS regardless of SQL CAST. Always convert types in Python after loading: `df["col"] = pd.to_numeric(df["col"], errors="coerce").fillna(0)` and `df["date"] = pd.to_datetime(df["date"], errors="coerce")`.
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"name": {
- "description": "Name of the data app (max ~50 chars to fit DNS label limit).",
- "type": "string"
+ "type": "string",
+ "description": "Name of the data app (max ~50 chars to fit DNS label limit)."
},
"description": {
- "description": "Description of the data app.",
- "type": "string"
+ "type": "string",
+ "description": "Description of the data app."
},
"source_code": {
- "description": "Complete Python/Streamlit source code for the data app.",
- "type": "string"
+ "type": "string",
+ "description": "Complete Python/Streamlit source code for the data app."
},
"packages": {
- "description": "Python packages used in the source code that will be installed by `pip install` into the environment before the code runs. For example: [\"pandas\", \"requests~=2.32\"].",
+ "type": "array",
"items": {
"type": "string"
},
- "type": "array"
+ "description": "Python packages used in the source code that will be installed by `pip install` into the environment before the code runs. For example: [\"pandas\", \"requests~=2.32\"]."
},
"authentication_type": {
- "description": "Authentication type, \"no-auth\" removes authentication completely, \"basic-auth\" sets the data app to be secured using the HTTP basic authentication, and \"default\" keeps the existing authentication type when updating.",
+ "type": "string",
"enum": [
"no-auth",
"basic-auth",
"default"
],
- "type": "string"
+ "description": "Authentication type, \"no-auth\" removes authentication completely, \"basic-auth\" sets the data app to be secured using the HTTP basic authentication, and \"default\" keeps the existing authentication type when updating."
},
"configuration_id": {
"default": "",
@@ -2458,6 +1898,7 @@ SQL & DATA TYPE RULES:
"type": "string"
},
"folder": {
+ "description": "Folder name to organize this data app in the Keboola UI. Pass an empty string to remove an existing folder assignment. Existing folder names are returned in the response change_summary when no folder is provided and there are 20 or more data apps in the project. If there are 20 or more data apps, you should assign one of the existing folders or create a new one that clearly reflects the data app purpose.",
"anyOf": [
{
"type": "string"
@@ -2465,9 +1906,7 @@ SQL & DATA TYPE RULES:
{
"type": "null"
}
- ],
- "default": null,
- "description": "Folder name to organize this data app in the Keboola UI. Pass an empty string to remove an existing folder assignment. Existing folder names are returned in the response change_summary when no folder is provided and there are 20 or more data apps in the project. If there are 20 or more data apps, you should assign one of the existing folders or create a new one that clearly reflects the data app purpose."
+ ]
}
},
"required": [
@@ -2477,7 +1916,7 @@ SQL & DATA TYPE RULES:
"packages",
"authentication_type"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -2498,17 +1937,17 @@ Answers a question using the Keboola documentation as a source.
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"query": {
- "description": "Natural language query to search for in the documentation.",
- "type": "string"
+ "type": "string",
+ "description": "Natural language query to search for in the documentation."
}
},
"required": [
"query"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -2544,31 +1983,37 @@ WHEN TO USE:
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"name": {
- "description": "A short, descriptive name for the flow.",
- "type": "string"
+ "type": "string",
+ "description": "A short, descriptive name for the flow."
},
"description": {
- "description": "Detailed description of the flow purpose.",
- "type": "string"
+ "type": "string",
+ "description": "Detailed description of the flow purpose."
},
"phases": {
- "description": "List of phase definitions for conditional flows.",
+ "type": "array",
"items": {
- "additionalProperties": true,
- "type": "object"
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
},
- "type": "array"
+ "description": "List of phase definitions for conditional flows."
},
"tasks": {
- "description": "List of task definitions for conditional flows.",
+ "type": "array",
"items": {
- "additionalProperties": true,
- "type": "object"
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
},
- "type": "array"
+ "description": "List of task definitions for conditional flows."
},
"folder": {
"default": "",
@@ -2582,7 +2027,7 @@ WHEN TO USE:
"phases",
"tasks"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -2615,31 +2060,37 @@ WHEN TO USE:
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"name": {
- "description": "A short, descriptive name for the flow.",
- "type": "string"
+ "type": "string",
+ "description": "A short, descriptive name for the flow."
},
"description": {
- "description": "Detailed description of the flow purpose.",
- "type": "string"
+ "type": "string",
+ "description": "Detailed description of the flow purpose."
},
"phases": {
- "description": "List of phase definitions.",
+ "type": "array",
"items": {
- "additionalProperties": true,
- "type": "object"
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
},
- "type": "array"
+ "description": "List of phase definitions."
},
"tasks": {
- "description": "List of task definitions.",
+ "type": "array",
"items": {
- "additionalProperties": true,
- "type": "object"
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
},
- "type": "array"
+ "description": "List of task definitions."
},
"folder": {
"default": "",
@@ -2653,7 +2104,7 @@ WHEN TO USE:
"phases",
"tasks"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -2680,21 +2131,21 @@ RULES:
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"flow_type": {
- "description": "The type of the flow to retrieve examples for.",
+ "type": "string",
"enum": [
"keboola.flow",
"keboola.orchestrator"
],
- "type": "string"
+ "description": "The type of the flow to retrieve examples for."
}
},
"required": [
"flow_type"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -2721,21 +2172,21 @@ RULES:
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"flow_type": {
- "description": "The type of flow for which to fetch schema.",
+ "type": "string",
"enum": [
"keboola.flow",
"keboola.orchestrator"
],
- "type": "string"
+ "description": "The type of flow for which to fetch schema."
}
},
"required": [
"flow_type"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -2763,18 +2214,18 @@ OPTIONS:
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"flow_ids": {
"default": [],
"description": "IDs of flows to retrieve full details for. When provided (non-empty), returns full flow configurations including phases and tasks. When empty [], lists all flows in the project as summaries.",
+ "type": "array",
"items": {
"type": "string"
- },
- "type": "array"
+ }
}
},
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -2814,117 +2265,64 @@ LEGACY FLOWS (`keboola.orchestrator`):
WHEN TO USE:
- Renaming a flow, updating descriptions, adding/removing phases or tasks, updating schedules,
adjusting dependencies, or enabling/disabling flow execution
-
-
-**Input JSON Schema**:
-```json
-{
- "$defs": {
- "ScheduleRequest": {
- "properties": {
- "action": {
- "description": "Action to perform on the schedule.",
- "enum": [
- "add",
- "update",
- "remove"
- ],
- "type": "string"
- },
- "scheduleId": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "default": null,
- "description": "ID of the schedule configuration to update. None if creating a new schedule."
- },
- "timezone": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "default": null,
- "description": "Timezone for the schedule. Default UTC if None provided."
- },
- "cronTab": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "default": null,
- "description": "Cron expression for the schedule following the format: `* * * * *`.Where 1. minutes, 2. hours, 3. days of month, 4. months, 5. days of week. Example: `15,45 1,13 * * 0`"
- },
- "state": {
- "anyOf": [
- {
- "enum": [
- "enabled",
- "disabled"
- ],
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "default": null,
- "description": "Enable or disable the schedule."
- }
- },
- "required": [
- "action"
- ],
- "type": "object"
- }
- },
- "additionalProperties": false,
+
+
+**Input JSON Schema**:
+```json
+{
+ "type": "object",
"properties": {
"configuration_id": {
- "description": "ID of the flow configuration.",
- "type": "string"
+ "type": "string",
+ "description": "ID of the flow configuration."
},
"flow_type": {
- "description": "The type of flow to update. Use \"keboola.flow\" for conditional flows or \"keboola.orchestrator\" for legacy flows. This MUST match the existing flow type.",
+ "type": "string",
"enum": [
"keboola.flow",
"keboola.orchestrator"
],
- "type": "string"
+ "description": "The type of flow to update. Use \"keboola.flow\" for conditional flows or \"keboola.orchestrator\" for legacy flows. This MUST match the existing flow type."
},
"change_description": {
- "description": "Description of changes made.",
- "type": "string"
+ "type": "string",
+ "description": "Description of changes made."
},
"phases": {
- "default": null,
"description": "Updated list of phase definitions.",
- "items": {
- "additionalProperties": true,
- "type": "object"
- },
- "type": "array"
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
+ }
+ },
+ {
+ "type": "null"
+ }
+ ]
},
"tasks": {
- "default": null,
"description": "Updated list of task definitions.",
- "items": {
- "additionalProperties": true,
- "type": "object"
- },
- "type": "array"
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
+ }
+ },
+ {
+ "type": "null"
+ }
+ ]
},
"name": {
"default": "",
@@ -2939,12 +2337,75 @@ adjusting dependencies, or enabling/disabling flow execution
"schedules": {
"default": [],
"description": "Optional sequence of schedule requests to add/update/remove schedules for this flow. Each request must have \"action\": \"add\"|\"update\"|\"remove\". For add: include \"cron_tab\", \"state\" (\"enabled\"|\"disabled\"), \"timezone\". For update/remove: include \"schedule_id\". Example: [{\"action\": \"add\", \"cron_tab\": \"0 8 * * 1-5\", \"state\": \"enabled\", \"timezone\": \"UTC\"}]",
+ "type": "array",
"items": {
- "$ref": "#/$defs/ScheduleRequest"
- },
- "type": "array"
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": [
+ "add",
+ "update",
+ "remove"
+ ],
+ "description": "Action to perform on the schedule."
+ },
+ "schedule_id": {
+ "description": "ID of the schedule configuration to update. None if creating a new schedule.",
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "timezone": {
+ "description": "Timezone for the schedule. Default UTC if None provided.",
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "cron_tab": {
+ "description": "Cron expression for the schedule following the format: `* * * * *`.Where 1. minutes, 2. hours, 3. days of month, 4. months, 5. days of week. Example: `15,45 1,13 * * 0`",
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "state": {
+ "description": "Enable or disable the schedule.",
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "enabled",
+ "disabled"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "action"
+ ]
+ }
},
"is_disabled": {
+ "description": "Enable or disable the flow. Set to True to disable execution (flow won't run), False to enable execution (flow will run). Only provide if changing the status, leave as null to preserve current state.",
"anyOf": [
{
"type": "boolean"
@@ -2952,11 +2413,10 @@ adjusting dependencies, or enabling/disabling flow execution
{
"type": "null"
}
- ],
- "default": null,
- "description": "Enable or disable the flow. Set to True to disable execution (flow won't run), False to enable execution (flow will run). Only provide if changing the status, leave as null to preserve current state."
+ ]
},
"folder": {
+ "description": "Folder name to organize this flow in the Keboola UI. Pass an empty string to remove an existing folder assignment. Existing folder names are returned in the response change_summary when no folder is provided and there are 20 or more flows in the project. If there are 20 or more flows, you should assign one of the existing folders or create a new one that clearly reflects the flow purpose.",
"anyOf": [
{
"type": "string"
@@ -2964,9 +2424,7 @@ adjusting dependencies, or enabling/disabling flow execution
{
"type": "null"
}
- ],
- "default": null,
- "description": "Folder name to organize this flow in the Keboola UI. Pass an empty string to remove an existing folder assignment. Existing folder names are returned in the response change_summary when no folder is provided and there are 20 or more flows in the project. If there are 20 or more flows, you should assign one of the existing folders or create a new one that clearly reflects the flow purpose."
+ ]
}
},
"required": [
@@ -2974,7 +2432,7 @@ adjusting dependencies, or enabling/disabling flow execution
"flow_type",
"change_description"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -3016,41 +2474,59 @@ or enabling/disabling flow execution
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"configuration_id": {
- "description": "ID of the flow configuration.",
- "type": "string"
+ "type": "string",
+ "description": "ID of the flow configuration."
},
"flow_type": {
- "description": "The type of flow to update. Use \"keboola.flow\" for conditional flows or \"keboola.orchestrator\" for legacy flows. This MUST match the existing flow type.",
+ "type": "string",
"enum": [
"keboola.flow",
"keboola.orchestrator"
],
- "type": "string"
+ "description": "The type of flow to update. Use \"keboola.flow\" for conditional flows or \"keboola.orchestrator\" for legacy flows. This MUST match the existing flow type."
},
"change_description": {
- "description": "Description of changes made.",
- "type": "string"
+ "type": "string",
+ "description": "Description of changes made."
},
"phases": {
- "default": null,
"description": "Updated list of phase definitions.",
- "items": {
- "additionalProperties": true,
- "type": "object"
- },
- "type": "array"
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
+ }
+ },
+ {
+ "type": "null"
+ }
+ ]
},
"tasks": {
- "default": null,
"description": "Updated list of task definitions.",
- "items": {
- "additionalProperties": true,
- "type": "object"
- },
- "type": "array"
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
+ }
+ },
+ {
+ "type": "null"
+ }
+ ]
},
"name": {
"default": "",
@@ -3063,6 +2539,7 @@ or enabling/disabling flow execution
"type": "string"
},
"is_disabled": {
+ "description": "Enable or disable the flow. Set to True to disable execution (flow won't run), False to enable execution (flow will run). Only provide if changing the status, leave as null to preserve current state.",
"anyOf": [
{
"type": "boolean"
@@ -3070,11 +2547,10 @@ or enabling/disabling flow execution
{
"type": "null"
}
- ],
- "default": null,
- "description": "Enable or disable the flow. Set to True to disable execution (flow won't run), False to enable execution (flow will run). Only provide if changing the status, leave as null to preserve current state."
+ ]
},
"folder": {
+ "description": "Folder name to organize this flow in the Keboola UI. Pass an empty string to remove an existing folder assignment. Existing folder names are returned in the response change_summary when no folder is provided and there are 20 or more flows in the project. If there are 20 or more flows, you should assign one of the existing folders or create a new one that clearly reflects the flow purpose.",
"anyOf": [
{
"type": "string"
@@ -3082,9 +2558,7 @@ or enabling/disabling flow execution
{
"type": "null"
}
- ],
- "default": null,
- "description": "Folder name to organize this flow in the Keboola UI. Pass an empty string to remove an existing folder assignment. Existing folder names are returned in the response change_summary when no folder is provided and there are 20 or more flows in the project. If there are 20 or more flows, you should assign one of the existing folders or create a new one that clearly reflects the flow purpose."
+ ]
}
},
"required": [
@@ -3092,7 +2566,7 @@ or enabling/disabling flow execution
"flow_type",
"change_description"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -3109,85 +2583,23 @@ or enabling/disabling flow execution
Retrieves job execution information from the Keboola project.
-CONTEXT:
-Jobs in Keboola are execution records of components (extractors, transformations, writers, flows).
-Each job represents a single run with its status, timing, configuration, and results.
-
-TWO MODES OF OPERATION (controlled by job_ids parameter):
-
-MODE 1: GET DETAILS FOR SPECIFIC JOBS (job_ids is non-empty)
-- Provide one or more job IDs: job_ids=["12345", "67890"]
-- Returns: FULL details for each job including status, config_data, results, timing, and metadata
-- Ignores: All filtering/sorting parameters (status, component_id, config_id, limit, offset, sort_by, sort_order)
-- Use when: You know specific job IDs and need complete information about them
-
-MODE 2: LIST/SEARCH JOBS (job_ids is empty)
-- Leave job_ids empty: job_ids=[]
-- Returns: SUMMARY list of jobs (id, status, component_id, config_id, timing only - no config_data or results)
-- Supports: Filtering by status/component_id/config_id, pagination with limit/offset, sorting
-- Use when: You need to find jobs, see recent executions, or monitor job history
-
-DECISION GUIDE:
-- Start with MODE 2 (list) to find jobs → then use MODE 1 (details) if you need full information
-- If you already know job IDs → use MODE 1 directly
-- For monitoring/browsing → use MODE 2 with filters
-
-NOTE: Jobs cannot be found by name using the `search` tool. However, always use the filtering
-parameters (status, component_id, config_id) to narrow results rather than listing all jobs
-with no filters. If you need to find jobs for a specific configuration but only know its name,
-first use `search` to find the configuration ID, then filter jobs by that config_id.
-
-COMMON WORKFLOWS:
-1. Find failed jobs: job_ids=[], status="error" → identify problematic job IDs → get details with MODE 1
-2. Check recent runs: job_ids=[], component_id="...", limit=10 → see latest executions
-3. Monitor specific job: job_ids=["123"] → poll for status and results
-4. Troubleshoot config: job_ids=[], component_id="...", config_id="...", status="error" → find which runs failed
-
-EXAMPLES:
-
-MODE 1 - Get full details:
-- job_ids=["12345"] → detailed info for job 12345
-- job_ids=["12345", "67890"] → detailed info for multiple jobs
-
-MODE 2 - List/search jobs:
-- job_ids=[] → list latest 100 jobs (default)
-- job_ids=[], status="error" → list only failed jobs
-- job_ids=[], status="processing" → list currently running jobs
-- job_ids=[], component_id="keboola.ex-aws-s3" → list jobs for S3 extractor
-- job_ids=[], component_id="keboola.ex-aws-s3", config_id="12345" → list jobs for specific configuration
-- job_ids=[], limit=50, offset=100 → pagination (skip first 100, get next 50)
-- job_ids=[], sort_by="endTime", sort_order="asc" → oldest completed first
-- job_ids=[], sort_by="durationSeconds", sort_order="desc" → longest running first
-
-LOG RETRIEVAL (only in MODE 1):
-- Set include_logs=True to fetch execution logs from the Storage API events
-- Logs are fetched using the job's runId and returned in chronological order
-- Use log_tail_lines to control how many recent log events to return (default 50, max 500)
-- Use log_event_types to filter by event type: ["error"] for just errors, ["error", "warn"] for errors and warnings
-- If a job has no runId (e.g., not yet started), logs will be None
-
-EXAMPLES WITH LOGS:
-- job_ids=["12345"], include_logs=True → job details + last 50 log events
-- job_ids=["12345"], include_logs=True, log_event_types=["error"] → job details + only error events
-- job_ids=["12345"], include_logs=True, log_tail_lines=200 → job details + last 200 log events
-
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"job_ids": {
"default": [],
- "description": "IDs of jobs to retrieve full details for. When provided (non-empty), returns full job details including status, parameters, results, and metadata. When empty [], lists jobs in the project as summaries with optional filtering.",
+ "description": "IDs of jobs to retrieve full details for; empty lists jobs as summaries.",
+ "type": "array",
"items": {
"type": "string"
- },
- "type": "array"
+ }
},
"status": {
- "default": null,
- "description": "The optional status of the jobs to filter by when listing (ignored if job_ids is provided). If None then all statuses are included.",
+ "description": "Filter listed jobs by status (ignored if job_ids given).",
+ "type": "string",
"enum": [
"waiting",
"processing",
@@ -3198,88 +2610,78 @@ EXAMPLES WITH LOGS:
"terminating",
"cancelled",
"terminated"
- ],
- "type": "string"
+ ]
},
"component_id": {
- "default": null,
- "description": "The optional ID of the component whose jobs you want to list (ignored if job_ids is provided). Default = None.",
+ "description": "Filter listed jobs by component id (ignored if job_ids given).",
"type": "string"
},
"config_id": {
- "default": null,
- "description": "The optional ID of the component configuration whose jobs you want to list (ignored if job_ids is provided). Default = None.",
+ "description": "Filter listed jobs by configuration id (ignored if job_ids given).",
"type": "string"
},
"limit": {
"default": 100,
- "description": "The number of jobs to list when listing (ignored if job_ids is provided), default = 100, max = 500.",
- "maximum": 500,
+ "description": "Number of jobs to list (max 500).",
+ "type": "integer",
"minimum": 1,
- "type": "integer"
+ "maximum": 500
},
"offset": {
"default": 0,
- "description": "The offset of the jobs to list when listing (ignored if job_ids is provided), default = 0.",
+ "description": "Offset of jobs to list.",
+ "type": "integer",
"minimum": 0,
- "type": "integer"
+ "maximum": 9007199254740991
},
"sort_by": {
"default": "startTime",
- "description": "The field to sort the jobs by when listing (ignored if job_ids is provided), default = \"startTime\".",
+ "description": "Field to sort listed jobs by.",
+ "type": "string",
"enum": [
"startTime",
"endTime",
"createdTime",
"durationSeconds",
"id"
- ],
- "type": "string"
+ ]
},
"sort_order": {
"default": "desc",
- "description": "The order to sort the jobs by when listing (ignored if job_ids is provided), default = \"desc\".",
+ "description": "Sort order for listed jobs.",
+ "type": "string",
"enum": [
"asc",
"desc"
- ],
- "type": "string"
+ ]
},
"include_logs": {
"default": false,
- "description": "Whether to include execution logs for each job. Only used when job_ids is provided (MODE 1). Logs are fetched from the Storage API events endpoint using the job's runId. Default is False.",
+ "description": "Include execution logs (only when job_ids given).",
"type": "boolean"
},
"log_tail_lines": {
"default": 50,
- "description": "Maximum number of log events to return per job (most recent first). Only used when include_logs=True. Default = 50, max = 500.",
- "maximum": 500,
+ "description": "Max log events per job (most recent).",
+ "type": "integer",
"minimum": 1,
- "type": "integer"
+ "maximum": 500
},
"log_event_types": {
- "anyOf": [
- {
- "items": {
- "enum": [
- "info",
- "warn",
- "error",
- "success"
- ],
- "type": "string"
- },
- "type": "array"
- },
- {
- "type": "null"
- }
- ],
- "default": null,
- "description": "Filter log events by type. Only used when include_logs=True. If None, all event types are included. Example: [\"error\"] to only show errors, [\"error\", \"warn\"] for errors and warnings."
+ "description": "Filter log events by type (only when include_logs=true).",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "info",
+ "warn",
+ "error",
+ "success"
+ ]
+ }
}
},
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -3298,37 +2700,29 @@ Starts a new job for a given component or transformation.
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"component_id": {
- "description": "The ID of the component or transformation for which to start a job.",
- "type": "string"
+ "type": "string",
+ "description": "The ID of the component or transformation to start a job for."
},
"configuration_id": {
- "description": "The ID of the configuration for which to start a job.",
- "type": "string"
+ "type": "string",
+ "description": "The ID of the configuration to start a job for."
},
"configuration_row_ids": {
- "anyOf": [
- {
- "items": {
- "type": "string"
- },
- "type": "array"
- },
- {
- "type": "null"
- }
- ],
- "default": null,
- "description": "Optional list of configuration row IDs to run. If not provided, all rows are executed."
+ "description": "Optional configuration row IDs to run; if omitted, all rows are executed.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
}
},
"required": [
"component_id",
"configuration_id"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -3337,7 +2731,7 @@ Starts a new job for a given component or transformation.
# OAuth Tools
## create_oauth_url
-**Annotations**: `destructive`
+**Annotations**:
**Tags**: `oauth`
@@ -3345,32 +2739,26 @@ Starts a new job for a given component or transformation.
Generates an OAuth authorization URL for a Keboola component configuration.
-When using this tool, be very concise in your response. Just guide the user to click the
-authorization link.
-
-Note that this tool should be called specifically for the OAuth-requiring components after their
-configuration is created e.g. keboola.ex-google-analytics-v4 and keboola.ex-gmail.
-
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"component_id": {
- "description": "The component ID to grant access to (e.g., \"keboola.ex-google-analytics-v4\").",
- "type": "string"
+ "type": "string",
+ "description": "The component ID to grant access to (e.g., \"keboola.ex-google-analytics-v4\")."
},
"config_id": {
- "description": "The configuration ID for the component.",
- "type": "string"
+ "type": "string",
+ "description": "The configuration ID for the component."
}
},
"required": [
"component_id",
"config_id"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -3385,20 +2773,17 @@ configuration is created e.g. keboola.ex-google-analytics-v4 and keboola.ex-gmai
**Description**:
-Retrieves structured information about the current project,
-including essential context and base instructions for working with it
-(e.g., transformations, components, workflows, and dependencies).
+Retrieves structured information about the current project, including essential context and base instructions for working with it (e.g., transformations, components, workflows, and dependencies).
-Always call this tool at least once at the start of a conversation
-to establish the project context before using other tools.
+Always call this tool at least once at the start of a conversation to establish the project context before using other tools.
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {},
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -3417,17 +2802,17 @@ Updates the description of the current Keboola project.
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"description": {
- "description": "The new project description text.",
- "type": "string"
+ "type": "string",
+ "description": "The new project description text."
}
},
"required": [
"description"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -3442,30 +2827,23 @@ Updates the description of the current Keboola project.
**Description**:
-Returns list of component IDs that match the given query.
-
-WHEN TO USE:
-- Use when you want to find the component for a specific purpose.
-
-USAGE EXAMPLES:
-- user_input: "I am looking for a salesforce extractor component"
- → Returns a list of component IDs that match the query, ordered by relevance/best match.
+Returns a list of component IDs that match the given natural-language query.
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"query": {
- "description": "Natural language query to find the requested component.",
- "type": "string"
+ "type": "string",
+ "description": "Natural language query to find the requested component."
}
},
"required": [
"query"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -3478,147 +2856,27 @@ USAGE EXAMPLES:
**Description**:
-Searches for Keboola items (tables, buckets, components, configurations, transformations, flows, data-apps, etc.)
-in the current project and returns matching ID + metadata.
-
-This tool supports two complementary search types:
-
-1) textual
-- Searches items by name, server-side (fast, independent of project size).
-- Tokenized full-text name matching, case- and diacritics-insensitive. Pass the plain name; do NOT build
- regex (rejected). It is NOT typo-corrected — misspellings may not match.
-- Prefers the current branch context; when nothing is found there, automatically widens the search to all
- branches of the project — such hits carry `branch_id`/`branch_name` so you can tell where they live.
-
-2) config-based
-- Searches item configurations (JSON objects) by matching patterns against the configuration values converted
- to a string, optionally narrowed by JSON path `scopes`.
-- Returns also `match_scopes` with JSON paths and matched patterns per scope.
-
-THIS IS THE PRIMARY DISCOVERY TOOL. Always use it BEFORE any get_* tool when you need to find items
-by name or specific configuration content. Do NOT enumerate items with get_buckets, get_tables, get_configs,
-get_flows, or get_data_apps just to locate a specific item — use this tool instead.
-
-WHEN TO USE:
-- User asks to "find", "locate", or "search for" something by name, keyword, text pattern, configuration content or
-value
-- User mentions a partial name and you need to find the full item (e.g., "find the customer table")
-- User asks "what tables/configs/flows do I have with X in the name?"
-- You need to discover items before performing operations on them
-- User asks to "list all items with [name] or [configuration value/part] in it"
-- User asks where a value, table, component, specific configuration ID, or specific settings is used in components,
-data-apps, flows, or transformations
-- You need to trace lineage by searching for IDs referenced in configurations, or to find flows using a
- specific component, or find usage of a bucket/table in transformations or components, or to find items with
- specific parameters.
-- User asks to "what is the genesis of this item?" or "explain me business logic of this item?"
-
-HOW IT WORKS:
-- Supports two types:
- - search_type="textual": tokenized full-text name search, server-side. Names only — descriptions, column
- names, IDs and configuration contents are NOT searched (use config-based search for configuration contents,
- or get_tables for columns). Matching is case- and diacritics-insensitive but NOT typo-corrected.
- - search_type="config-based": matches inside configuration JSON objects, optionally narrowed by JSON path `scopes`
-- case-insensitive search
-- mode for pattern search: applies to config-based only — `literal` (default) or `regex`. Textual search ignores
- `mode` (always full-text) and rejects `regex`.
-- Multiple patterns work as OR condition - matches items containing ANY of the patterns
-- Each result includes the item's ID, name, creation date, and relevant metadata; the response also carries
- `total` and `by_type` counts and the `branch_scope` the hits come from
-- textual search prefers the current branch; on zero hits it automatically retries across all branches of the
- project and marks the response with branch_scope="all-branches"
-- scopes (config-based) narrow matching to specific JSONPath areas within configurations; matching is performed
- against the stringified JSON node content in those areas.
-- config-based always returns all matched paths per item in `match_scopes` (including matched patterns)
-
-IMPORTANT:
-- Always use this tool when the user mentions a name but you don't have the exact ID
-- The search returns IDs that you can use with other tools (e.g., get_tables, get_configs, get_flows)
-- Results are ordered by the `updated` field, most recent first. `updated` is the item's last update time
- when available, or its creation time otherwise (textual/global-search hits expose only the creation time).
-- Textual search matches names only, with tokenized full-text matching (case/diacritics-insensitive; not
- typo-corrected; no regex). It may not return every item the legacy enumeration did. To find items by
- description or by table column, use get_tables; to find items by configuration content, use config-based search.
-- For exact ID lookups, use specific tools like get_tables, get_configs, get_flows instead
-- Use specific `scopes` only when you know the config structure (schema or real example); otherwise run config-based
- search without scopes.
-- Use find_component_id and get_configs tools to find configurations related to a specific component
-- If results are too numerous or empty, ask the user to refine their query rather than enumerating all items.
-
-USAGE EXAMPLES:
-1) textual search examples:
-- user_input: "Find all tables with 'customer' in the name"
- → patterns=["customer"], item_types=["table"]
- → Returns all tables whose name matches "customer"
-
-- user_input: "Search for the sales transformation"
- → patterns=["sales"], item_types=["transformation"]
- → Returns transformations with "sales" in the name
-
-- user_input: "Find items named 'daily report' or 'weekly summary'"
- → patterns=["daily report", "weekly summary"], item_types=[]
- → Returns all items matching any of these patterns
-
-- user_input: "Show me all configurations related to Google Analytics"
- → patterns=["google analytics"], item_types=["configuration"]
- → Returns configurations with matching names
-
-2) config-based search examples:
-- user_input: "Find transformations/configs/components referencing table in.c-prod.customers"
- -> patterns=["in.c-prod.customers"], item_types=["transformation", "configuration"],
- search_type="config-based"
- -> No scopes = search whole stringified config; result includes `match_scopes` with exact paths + patterns
-
-- user_input: "Find configurations/transformations (etc.) using specific setting / id anywhere"
- -> patterns=["setting", "id"], item_types=["configuration", "transformations"], search_type="config-based",
-
-- user_input: "Find configurations/transformations (etc.) using specific setting / id in parameters"
--> patterns=["setting", "id"], item_types=["configuration", "transformations"], search_type="config-based",
-scopes=["parameters"]
-
-- user_input: "Find configurations/transformations (etc.) using specific setting / id in storage"
--> patterns=["setting", "id"], item_types=["configuration", "transformations"], search_type="config-based",
-scopes=["storage"]
-
-- user_input: "Find configurations/transformations (etc.) using specific setting / id in authorization"
- -> patterns=["setting", "id"], item_types=["configuration", "transformations"], search_type="config-based",
- scopes=["parameters.authorization", "authorization"]
-
-- user_input: "Find components/transformations using my_bucket in input or output mappings"
- -> patterns=["my_bucket"], item_types=["configuration", "transformation"], search_type="config-based",
- scopes=["storage.input", "storage.output"]
- -> Returns matches with paths like `storage.input.tables[0].source`, `storage.input.files[0].source`,
- or `storage.output.tables[0].destination`
-
-- user_input: "Find flows using configuration ID 01k9cz233cvd1rga3zzx40g8qj"
- -> patterns=["01k9cz233cvd1rga3zzx40g8qj"], item_types=["flow"], search_type="config-based",
- scopes=["tasks", "phases"]
-
-- user_input: "Find transformations using this table / column / specific code in its script"
- -> patterns=["element"], item_types=["transformation"], search_type="config-based",
- scopes=["parameters", "storage"]
-
-- user_input: "Find data apps using something in its config / python code / setting"
- -> patterns=["something"], item_types=["data-app"], search_type="config-based"
- -> Returns data apps where script/config sections contain the keyword and includes `match_scopes`
+Searches for Keboola items (tables, buckets, components, configurations, transformations, flows, data-apps, etc.) in the current project and returns matching IDs and metadata. Supports textual search (matches item names, server-side) and config-based search (matches patterns against the configuration JSON content, optionally narrowed by JSONPath scopes). THIS IS THE PRIMARY DISCOVERY TOOL — use it before any get_* tool when you need to find items by name or configuration content. Multiple patterns work as an OR condition. Textual search prefers the current branch and, when nothing is found there, automatically widens to all branches of the project.
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"patterns": {
- "description": "One or more search patterns. For textual search they match item names (server-side, tokenized full-text); for config-based search they match the configuration JSON content. Case-insensitive by default. Examples: [\"customer\"], [\"sales\", \"revenue\"], [\"my_bucket\"]. Do not use empty strings or empty lists.",
+ "type": "array",
"items": {
"type": "string"
},
- "type": "array"
+ "description": "One or more search patterns. For textual search they match item names (server-side, tokenized full-text); for config-based search they match the configuration JSON content. Case-insensitive by default. Examples: [\"customer\"], [\"sales\", \"revenue\"], [\"my_bucket\"]. Do not use empty strings or empty lists."
},
"item_types": {
"default": [],
"description": "Filter for specific Keboola item types. Common values: \"table\" (data tables), \"bucket\" (table containers), \"transformation\" (SQL/Python transformations), \"component\" (extractor/writer/application components), \"data-app\" (data apps), \"flow\" (orchestration flows). Use when you know what type of item you're looking for or leave empty to search all types.",
+ "type": "array",
"items": {
+ "type": "string",
"enum": [
"bucket",
"table",
@@ -3632,52 +2890,50 @@ scopes=["storage"]
"shared-code",
"rows",
"state"
- ],
- "type": "string"
- },
- "type": "array"
+ ]
+ }
},
"search_type": {
"default": "textual",
"description": "Search mode: \"textual\" (name/id/description) or \"config-based\" (stringified configuration payloads). (default: \"textual\")",
+ "type": "string",
"enum": [
"textual",
"config-based"
- ],
- "type": "string"
+ ]
},
"scopes": {
"default": [],
"description": "JSONPath expressions to narrow config-based search to specific parts of the configuration. Simple dot-notation (e.g. \"parameters\", \"storage.input\") and full JSONPath (e.g. \"$.tasks[*]\") are both supported (e.g. \"parameters.host\", \"storage.input[0].source\"). Leave empty to search the whole configuration.",
+ "type": "array",
"items": {
"type": "string"
- },
- "type": "array"
+ }
},
"mode": {
"default": "literal",
"description": "How to interpret patterns. Applies to config-based search only: \"regex\" for regular expressions or \"literal\" for exact text (default: \"literal\"). Ignored by textual search, which is always a tokenized full-text name query (not typo-corrected) and rejects \"regex\".",
+ "type": "string",
"enum": [
"regex",
"literal"
- ],
- "type": "string"
+ ]
},
"limit": {
"default": 50,
"description": "Maximum number of items to return (default: 50, max: 100).",
- "type": "integer"
+ "type": "number"
},
"offset": {
"default": 0,
"description": "Number of matching items to skip for pagination (default: 0).",
- "type": "integer"
+ "type": "number"
}
},
"required": [
"patterns"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -3723,62 +2979,53 @@ EXAMPLES:
**Input JSON Schema**:
```json
{
- "$defs": {
- "SemanticObjectType": {
- "enum": [
- "semantic-model",
- "semantic-dataset",
- "semantic-metric",
- "semantic-relationship",
- "semantic-glossary",
- "semantic-constraint"
- ],
- "type": "string"
- },
- "SemanticObjectTypeSelection": {
- "description": "Semantic object type selection used by semantic tools.",
- "properties": {
- "object_type": {
- "$ref": "#/$defs/SemanticObjectType",
- "description": "Semantic object type to load."
- },
- "ids": {
- "default": [],
- "description": "Specific object UUIDs to include. Empty list [] means include all objects of this type.",
- "items": {
- "type": "string"
- },
- "type": "array"
- }
- },
- "required": [
- "object_type"
- ],
- "type": "object"
- }
- },
- "additionalProperties": false,
+ "type": "object",
"properties": {
"semantic_objects": {
- "description": "List of semantic object selections to load. Each item contains \"object_type\" and optional \"ids\". If \"ids\" is empty, all objects of that type are returned in compact form. If \"ids\" is non-empty, only those objects are returned with full attributes.",
+ "type": "array",
"items": {
- "$ref": "#/$defs/SemanticObjectTypeSelection"
+ "type": "object",
+ "properties": {
+ "object_type": {
+ "type": "string",
+ "enum": [
+ "semantic-model",
+ "semantic-dataset",
+ "semantic-metric",
+ "semantic-relationship",
+ "semantic-glossary",
+ "semantic-constraint"
+ ],
+ "description": "Semantic object type to load."
+ },
+ "ids": {
+ "default": [],
+ "description": "Specific object UUIDs to include. Empty list [] means include all objects of this type.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required": [
+ "object_type"
+ ]
},
- "type": "array"
+ "description": "List of semantic object selections to load. Each item contains \"object_type\" and optional \"ids\". If \"ids\" is empty, all objects of that type are returned in compact form. If \"ids\" is non-empty, only those objects are returned with full attributes."
},
"semantic_model_ids": {
"default": [],
"description": "Optional list of semantic model IDs to restrict loading to specific models. Empty list [] means load across all semantic models.",
+ "type": "array",
"items": {
"type": "string"
- },
- "type": "array"
+ }
}
},
"required": [
"semantic_objects"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -3800,33 +3047,28 @@ WHEN TO USE:
**Input JSON Schema**:
```json
{
- "$defs": {
- "SemanticObjectType": {
- "enum": [
- "semantic-model",
- "semantic-dataset",
- "semantic-metric",
- "semantic-relationship",
- "semantic-glossary",
- "semantic-constraint"
- ],
- "type": "string"
- }
- },
- "additionalProperties": false,
+ "type": "object",
"properties": {
"semantic_types": {
- "description": "List of semantic object types for which JSON schemas should be returned. Each returned item contains the requested semantic type and its metastore schema.",
+ "type": "array",
"items": {
- "$ref": "#/$defs/SemanticObjectType"
+ "type": "string",
+ "enum": [
+ "semantic-model",
+ "semantic-dataset",
+ "semantic-metric",
+ "semantic-relationship",
+ "semantic-glossary",
+ "semantic-constraint"
+ ]
},
- "type": "array"
+ "description": "List of semantic object types for which JSON schemas should be returned. Each returned item contains the requested semantic type and its metastore schema."
}
},
"required": [
"semantic_types"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -3882,43 +3124,38 @@ EXAMPLES:
**Input JSON Schema**:
```json
{
- "$defs": {
- "SemanticObjectType": {
- "enum": [
- "semantic-model",
- "semantic-dataset",
- "semantic-metric",
- "semantic-relationship",
- "semantic-glossary",
- "semantic-constraint"
- ],
- "type": "string"
- }
- },
- "additionalProperties": false,
+ "type": "object",
"properties": {
"patterns": {
- "description": "One or more regex patterns used to search semantic metadata. The search checks semantic model names plus semantic object names and nested attribute values. Use multiple patterns when you need to find objects related to several business terms at once.",
+ "type": "array",
"items": {
"type": "string"
},
- "type": "array"
+ "description": "One or more regex patterns used to search semantic metadata. The search checks semantic model names plus semantic object names and nested attribute values. Use multiple patterns when you need to find objects related to several business terms at once."
},
"semantic_types": {
"default": [],
"description": "Optional semantic object types to search. Empty list [] means ALL semantic object types are searched. Use this to narrow the search when you already know whether you want datasets, metrics, relationships, glossary terms, constraints, or models.",
+ "type": "array",
"items": {
- "$ref": "#/$defs/SemanticObjectType"
- },
- "type": "array"
+ "type": "string",
+ "enum": [
+ "semantic-model",
+ "semantic-dataset",
+ "semantic-metric",
+ "semantic-relationship",
+ "semantic-glossary",
+ "semantic-constraint"
+ ]
+ }
},
"semantic_model_ids": {
"default": [],
"description": "Optional list of semantic model IDs to restrict the search to specific models. Empty list [] means search across all semantic models.",
+ "type": "array",
"items": {
"type": "string"
- },
- "type": "array"
+ }
},
"case_sensitive": {
"default": false,
@@ -3928,13 +3165,15 @@ EXAMPLES:
"max_results": {
"default": 100,
"description": "Maximum number of matched semantic objects to return. Use a smaller value for quick discovery and a larger value only when you need a broader result set.",
- "type": "integer"
+ "type": "integer",
+ "minimum": -9007199254740991,
+ "maximum": 9007199254740991
}
},
"required": [
"patterns"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -3990,67 +3229,58 @@ EXAMPLES:
**Input JSON Schema**:
```json
{
- "$defs": {
- "SemanticObjectType": {
- "enum": [
- "semantic-model",
- "semantic-dataset",
- "semantic-metric",
- "semantic-relationship",
- "semantic-glossary",
- "semantic-constraint"
- ],
- "type": "string"
- },
- "SemanticObjectTypeSelection": {
- "description": "Semantic object type selection used by semantic tools.",
- "properties": {
- "object_type": {
- "$ref": "#/$defs/SemanticObjectType",
- "description": "Semantic object type to load."
- },
- "ids": {
- "default": [],
- "description": "Specific object UUIDs to include. Empty list [] means include all objects of this type.",
- "items": {
- "type": "string"
- },
- "type": "array"
- }
- },
- "required": [
- "object_type"
- ],
- "type": "object"
- }
- },
- "additionalProperties": false,
+ "type": "object",
"properties": {
"sql_query": {
- "description": "SQL query that should be checked against the semantic layer. The query is not executed; the tool performs best-effort semantic detection and rule validation using heuristic string matching, so the detected objects may be incomplete or imperfect.",
- "type": "string"
+ "type": "string",
+ "description": "SQL query that should be checked against the semantic layer. The query is not executed; the tool performs best-effort semantic detection and rule validation using heuristic string matching, so the detected objects may be incomplete or imperfect."
},
"semantic_model_ids": {
- "description": "One or more semantic model IDs against which the SQL should be validated. Contexts from all models are merged into a single universe for object detection. Constraint evaluation is performed per model to avoid cross-model rule contamination.",
+ "type": "array",
"items": {
"type": "string"
},
- "type": "array"
+ "description": "One or more semantic model IDs against which the SQL should be validated. Contexts from all models are merged into a single universe for object detection. Constraint evaluation is performed per model to avoid cross-model rule contamination."
},
"expected_semantic_objects": {
"default": [],
"description": "Optional semantic object selections that define the expected semantic scope of the query. These expectations are compared with the objects actually detected in the SQL. Use `ids` when you want to assert that specific semantic objects should be present.",
+ "type": "array",
"items": {
- "$ref": "#/$defs/SemanticObjectTypeSelection"
- },
- "type": "array"
+ "type": "object",
+ "properties": {
+ "object_type": {
+ "type": "string",
+ "enum": [
+ "semantic-model",
+ "semantic-dataset",
+ "semantic-metric",
+ "semantic-relationship",
+ "semantic-glossary",
+ "semantic-constraint"
+ ],
+ "description": "Semantic object type to load."
+ },
+ "ids": {
+ "default": [],
+ "description": "Specific object UUIDs to include. Empty list [] means include all objects of this type.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required": [
+ "object_type"
+ ]
+ }
}
},
"required": [
"sql_query",
"semantic_model_ids"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -4065,66 +3295,68 @@ EXAMPLES:
**Description**:
-Executes an SQL SELECT query to get the data from the underlying database.
-BEFORE QUERYING:
-* Always verify the table has a non-null fullyQualifiedName from get_tables tool.
- If it does not, the table is not SQL-accessible from this workspace — do not attempt the query and inform user.
+ Executes an SQL SELECT query to get the data from the underlying database.
-CRITICAL SQL REQUIREMENTS:
+ BEFORE QUERYING:
+ * Always verify the table has a non-null fullyQualifiedName from get_tables tool.
+ If it does not, the table is not SQL-accessible from this workspace — do not attempt the query and inform user.
-* ALWAYS check the SQL dialect before constructing queries.
-* Do not include any comments in the SQL code
-* Use delimited identifiers and FQN format for the current SQL dialect.
+ CRITICAL SQL REQUIREMENTS:
-TABLE AND COLUMN REFERENCES:
-* Always use fully qualified table names in the exact FQN format provided by table information tools
-* Follow the identifier structure exactly as shown by table info tools for the current SQL dialect
-* Always use delimited identifiers when referring to table columns
+ * ALWAYS check the SQL dialect before constructing queries.
+ * Do not include any comments in the SQL code
+ * Use delimited identifiers and FQN format for the current SQL dialect.
-CTE (WITH CLAUSE) RULES:
-* ALL column references in main query MUST match exact case used in the CTE
-* If you alias a column in a CTE, reference it under the aliased name in the subsequent queries
-* Define all column aliases explicitly in CTEs
-* Use delimited identifiers in both CTE definition and references to preserve case
+ TABLE AND COLUMN REFERENCES:
+ * Always use fully qualified table names in the exact FQN format provided by table information tools
+ * Follow the identifier structure exactly as shown by table info tools for the current SQL dialect
+ * Always use delimited identifiers when referring to table columns
-FUNCTION COMPATIBILITY:
-* Check data types before using date functions (DATE_TRUNC, EXTRACT require proper date/timestamp types)
-* Cast VARCHAR columns to appropriate types before using in date/numeric functions
+ CTE (WITH CLAUSE) RULES:
+ * ALL column references in main query MUST match exact case used in the CTE
+ * If you alias a column in a CTE, reference it under the aliased name in the subsequent queries
+ * Define all column aliases explicitly in CTEs
+ * Use delimited identifiers in both CTE definition and references to preserve case
-ERROR PREVENTION:
-* Never pass empty strings ('') where numeric or date values are expected
-* Use NULLIF or CASE statements to handle empty values
-* Always use TRY_CAST or similar safe casting functions when converting data types
-* Check for division by zero using NULLIF(denominator, 0)
-* Always use the LIMIT clause in your SELECT statements when fetching data. There are hard limits imposed
- by this tool on the maximum number of rows that can be fetched and the maximum number of characters.
- The tool will truncate the data if those limits are exceeded.
+ FUNCTION COMPATIBILITY:
+ * Check data types before using date functions (DATE_TRUNC, EXTRACT require proper date/timestamp types)
+ * Cast VARCHAR columns to appropriate types before using in date/numeric functions
-DATA VALIDATION:
-* When querying columns with categorical values, use query_data tool to inspect distinct values beforehand
-* Ensure valid filtering by checking actual data values first
+ ERROR PREVENTION:
+ * Never pass empty strings ('') where numeric or date values are expected
+ * Use NULLIF or CASE statements to handle empty values
+ * Always use TRY_CAST or similar safe casting functions when converting data types
+ * Check for division by zero using NULLIF(denominator, 0)
+ * Always use the LIMIT clause in your SELECT statements when fetching data. There are hard limits imposed
+ by this tool on the maximum number of rows that can be fetched and the maximum number of characters.
+ The tool will truncate the data if those limits are exceeded.
+
+ DATA VALIDATION:
+ * When querying columns with categorical values, use query_data tool to inspect distinct values beforehand
+ * Ensure valid filtering by checking actual data values first
+
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"sql_query": {
- "description": "SQL SELECT query to run.",
- "type": "string"
+ "type": "string",
+ "description": "SQL SELECT query to run."
},
"query_name": {
- "description": "A concise, human-readable name for this query based on its purpose and what data it retrieves. Use normal words with spaces (e.g., \"Customer Orders Last Month\", \"Top Selling Products\", \"User Activity Summary\").",
- "type": "string"
+ "type": "string",
+ "description": "A concise, human-readable name for this query based on its purpose and what data it retrieves. Use normal words with spaces (e.g., \"Customer Orders Last Month\", \"Top Selling Products\", \"User Activity Summary\")."
}
},
"required": [
"sql_query",
"query_name"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -4155,18 +3387,18 @@ EXAMPLES:
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"bucket_ids": {
"default": [],
"description": "Filter by specific bucket IDs.",
+ "type": "array",
"items": {
"type": "string"
- },
- "type": "array"
+ }
}
},
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
@@ -4219,23 +3451,23 @@ EXAMPLES:
**Input JSON Schema**:
```json
{
- "additionalProperties": false,
+ "type": "object",
"properties": {
"bucket_ids": {
"default": [],
"description": "Filter by specific bucket IDs.",
+ "type": "array",
"items": {
"type": "string"
- },
- "type": "array"
+ }
},
"table_ids": {
"default": [],
"description": "Filter by specific table IDs.",
+ "type": "array",
"items": {
"type": "string"
- },
- "type": "array"
+ }
},
"include_usage": {
"default": false,
@@ -4243,73 +3475,53 @@ EXAMPLES:
"type": "boolean"
}
},
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
---
## update_descriptions
-**Annotations**: `destructive`
+**Annotations**:
**Tags**: `storage`
**Description**:
-Updates the description for a Keboola storage item.
-
-This tool supports three item types, inferred from the provided item_id:
-
-- bucket: item_id = "in.c-bucket"
-- table: item_id = "in.c-bucket.table"
-- column: item_id = "in.c-bucket.table.column"
-
-Usage examples (payload uses a list of DescriptionUpdate objects):
-- Update a bucket:
- updates=[DescriptionUpdate(item_id="in.c-my-bucket", description="New bucket description")]
-- Update a table:
- updates=[DescriptionUpdate(item_id="in.c-my-bucket.my-table", description="New table description")]
-- Update a column:
- updates=[DescriptionUpdate(item_id="in.c-my-bucket.my-table.my_column", description="New column description")]
+Updates the description for Keboola storage items (buckets, tables, or columns).
**Input JSON Schema**:
```json
{
- "$defs": {
- "DescriptionUpdate": {
- "description": "Structured update describing a storage item and its new description.",
- "properties": {
- "item_id": {
- "description": "Storage item name: \"bucket_id\", \"bucket_id.table_id\", \"bucket_id.table_id.column_name\"",
- "type": "string"
- },
- "description": {
- "description": "New description to set for the storage item.",
- "type": "string"
- }
- },
- "required": [
- "item_id",
- "description"
- ],
- "type": "object"
- }
- },
- "additionalProperties": false,
+ "type": "object",
"properties": {
"updates": {
- "description": "List of DescriptionUpdate objects with storage item_id and new description. Examples: \"bucket_id\", \"bucket_id.table_id\", \"bucket_id.table_id.column_name\"",
+ "type": "array",
"items": {
- "$ref": "#/$defs/DescriptionUpdate"
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string",
+ "description": "Storage item: \"bucket_id\", \"bucket_id.table_id\", or \"bucket_id.table_id.column_name\"."
+ },
+ "description": {
+ "type": "string",
+ "description": "New description to set."
+ }
+ },
+ "required": [
+ "item_id",
+ "description"
+ ]
},
- "type": "array"
+ "description": "List of description updates to apply."
}
},
"required": [
"updates"
],
- "type": "object"
+ "$schema": "http://json-schema.org/draft-07/schema#"
}
```
diff --git a/__tests__/clients.test.ts b/__tests__/clients.test.ts
new file mode 100644
index 000000000..c28ac6610
--- /dev/null
+++ b/__tests__/clients.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest';
+
+import { createKeboolaClients } from '@/clients/keboola';
+import { deriveServiceUrls } from '@/clients/urls';
+import { Config } from '@/config';
+
+describe('deriveServiceUrls', () => {
+ it('derives every service URL from the storage hostname suffix', () => {
+ const urls = deriveServiceUrls('https://connection.eu-central-1.keboola.com');
+ expect(urls).toEqual({
+ storage: 'https://connection.eu-central-1.keboola.com',
+ metastore: 'https://metastore.eu-central-1.keboola.com',
+ queue: 'https://queue.eu-central-1.keboola.com',
+ ai: 'https://ai.eu-central-1.keboola.com',
+ dataScience: 'https://data-science.eu-central-1.keboola.com',
+ encryption: 'https://encryption.eu-central-1.keboola.com',
+ scheduler: 'https://scheduler.eu-central-1.keboola.com',
+ syncActions: 'https://sync-actions.eu-central-1.keboola.com',
+ queryService: 'https://query.eu-central-1.keboola.com',
+ });
+ });
+
+ it.each(['https://example.com', 'not-a-url', 'https://storage.keboola.com'])(
+ 'rejects a non-connection Storage API URL %j',
+ (url) => {
+ expect(() => deriveServiceUrls(url)).toThrow(/Invalid Keboola Storage API URL/);
+ },
+ );
+});
+
+describe('createKeboolaClients', () => {
+ it('builds clients from a configured token + url', () => {
+ const config = new Config({
+ storageApiUrl: 'https://connection.keboola.com',
+ storageToken: 'token',
+ });
+ const clients = createKeboolaClients(config);
+ expect(clients.storage.buckets).toBeDefined();
+ expect(clients.queue).toBeDefined();
+ });
+
+ it.each([
+ ['missing url', { storageToken: 'token' }, /Storage API URL is not configured/],
+ [
+ 'missing token',
+ { storageApiUrl: 'https://connection.keboola.com' },
+ /token is not configured/,
+ ],
+ ])('throws on %s', (_label, fields, pattern) => {
+ expect(() => createKeboolaClients(new Config(fields))).toThrow(pattern);
+ });
+});
diff --git a/__tests__/config.test.ts b/__tests__/config.test.ts
new file mode 100644
index 000000000..47038a681
--- /dev/null
+++ b/__tests__/config.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest';
+
+import { Config } from '@/config';
+
+describe('Config', () => {
+ it.each([
+ ['canonical field name', { storageToken: 'abc' }],
+ ['KBC_ env-style key', { KBC_STORAGE_TOKEN: 'abc' }],
+ ['X- header-style key', { 'X-StorageApiToken': 'abc' }],
+ ['snake_case alias', { storage_api_token: 'abc' }],
+ ])('resolves storageToken from %s', (_label, map) => {
+ expect(Config.fromMap(map).storageToken).toBe('abc');
+ });
+
+ it('amends a Storage API URL down to scheme + host', () => {
+ const config = Config.fromMap({ KBC_STORAGE_API_URL: 'connection.keboola.com/some/path' });
+ expect(config.storageApiUrl).toBe('https://connection.keboola.com');
+ });
+
+ it.each(['', 'none', 'null', 'default', 'production', 'PRODUCTION'])(
+ 'normalizes branch id %j to undefined',
+ (branch) => {
+ expect(Config.fromMap({ KBC_BRANCH_ID: branch }).branchId).toBeUndefined();
+ },
+ );
+
+ it('keeps a real branch id', () => {
+ expect(Config.fromMap({ KBC_BRANCH_ID: '12345' }).branchId).toBe('12345');
+ });
+
+ it('redacts secret fields in the string form', () => {
+ const text = new Config({ storageToken: 'super-secret', branchId: '1' }).toString();
+ expect(text).toContain("storageToken='****'");
+ expect(text).not.toContain('super-secret');
+ expect(text).toContain("branchId='1'");
+ });
+
+ it('layers replaceBy values over the base config', () => {
+ const base = new Config({ storageToken: 'a', branchId: '1' });
+ const next = base.replaceBy({ storageToken: 'b' });
+ expect(next.storageToken).toBe('b');
+ expect(next.branchId).toBe('1');
+ });
+});
diff --git a/__tests__/docsSearch.embedder.test.ts b/__tests__/docsSearch.embedder.test.ts
new file mode 100644
index 000000000..57d1e54b8
--- /dev/null
+++ b/__tests__/docsSearch.embedder.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest';
+
+import { migrationSql } from '../scripts/docsIndex';
+
+import {
+ createEmbedderFromEnv,
+ DEFAULT_LOCAL_MODEL,
+ LocalEmbedder,
+ StubEmbedder,
+} from '@/clients/docsSearch';
+import { parseEnv } from '@/env';
+
+const env = (m: Record) => parseEnv(m);
+
+describe('createEmbedderFromEnv', () => {
+ it('selects the stub embedder with a default 3072 dim', () => {
+ const e = createEmbedderFromEnv(env({ DOCS_EMBEDDER_MODEL: 'stub' }));
+ expect(e).toBeInstanceOf(StubEmbedder);
+ expect(e?.dim).toBe(3072);
+ });
+
+ it('honors a custom dim for the stub embedder', () => {
+ const e = createEmbedderFromEnv(
+ env({ DOCS_EMBEDDER_MODEL: 'stub', DOCS_EMBEDDER_DIM: '1024' }),
+ );
+ expect(e?.dim).toBe(1024);
+ });
+
+ it('selects the local embedder with a default HF model + 384 dim', () => {
+ const e = createEmbedderFromEnv(env({ DOCS_EMBEDDER_MODEL: 'local' }));
+ expect(e).toBeInstanceOf(LocalEmbedder);
+ expect(e?.model).toBe(DEFAULT_LOCAL_MODEL);
+ expect(e?.dim).toBe(384);
+ });
+
+ it('honors a custom local HF model + dim (e.g. a 1024 model)', () => {
+ const e = createEmbedderFromEnv(
+ env({
+ DOCS_EMBEDDER_MODEL: 'local',
+ DOCS_EMBEDDER_LOCAL_MODEL: 'Xenova/bge-large-en-v1.5',
+ DOCS_EMBEDDER_DIM: '1024',
+ }),
+ );
+ expect(e).toBeInstanceOf(LocalEmbedder);
+ expect(e?.model).toBe('Xenova/bge-large-en-v1.5');
+ expect(e?.dim).toBe(1024);
+ });
+
+ it('selects a remote embedder when endpoint + key + model are set', () => {
+ const e = createEmbedderFromEnv(
+ env({
+ DOCS_EMBEDDER_MODEL: 'text-embedding-3-large',
+ DOCS_EMBEDDER_ENDPOINT: 'https://embed.example/v1/embeddings',
+ DOCS_EMBEDDER_API_KEY: 'secret',
+ }),
+ );
+ expect(e).not.toBeInstanceOf(StubEmbedder);
+ expect(e).not.toBeInstanceOf(LocalEmbedder);
+ expect(e?.model).toBe('text-embedding-3-large');
+ expect(e?.dim).toBe(3072);
+ });
+
+ it('returns null when unconfigured or a remote model lacks endpoint/key', () => {
+ expect(createEmbedderFromEnv(env({}))).toBeNull();
+ expect(
+ createEmbedderFromEnv(env({ DOCS_EMBEDDER_MODEL: 'text-embedding-3-large' })),
+ ).toBeNull();
+ });
+});
+
+describe('migrationSql', () => {
+ it('parametrizes the halfvec column dimension', () => {
+ expect(migrationSql(1024)).toContain('embedding halfvec(1024) not null');
+ expect(migrationSql(384)).toContain('halfvec(384)');
+ expect(migrationSql(3072)).toContain('halfvec(3072)');
+ });
+});
diff --git a/__tests__/env.test.ts b/__tests__/env.test.ts
new file mode 100644
index 000000000..fbd92edf1
--- /dev/null
+++ b/__tests__/env.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it } from 'vitest';
+
+import { Config } from '@/config';
+import { applyDeploymentDefaults, parseEnv, redactedEnv } from '@/env';
+
+describe('redactedEnv', () => {
+ it('masks secrets, strips DATABASE_URL credentials, and keeps non-secrets', () => {
+ const dump = redactedEnv(
+ parseEnv({
+ DATABASE_URL: 'postgres://mcp:s3cret@db.internal:5432/docs',
+ DOCS_EMBEDDER_MODEL: 'local',
+ DOCS_EMBEDDER_DIM: '384',
+ DOCS_EMBEDDER_API_KEY: 'sk-abc',
+ KBC_JWT_SECRET: 'jwt-xyz',
+ KBC_OAUTH_CLIENT_ID: 'client-123',
+ }),
+ );
+ // Credentials stripped, host/db preserved.
+ expect(dump.DATABASE_URL).toBe('postgres://***:***@db.internal:5432/docs');
+ // Secrets masked.
+ expect(dump.DOCS_EMBEDDER_API_KEY).toBe('***');
+ expect(dump.KBC_JWT_SECRET).toBe('***');
+ // Non-secrets visible — this is the whole point (see the embedder config at a glance).
+ expect(dump.DOCS_EMBEDDER_MODEL).toBe('local');
+ expect(dump.DOCS_EMBEDDER_DIM).toBe(384);
+ expect(dump.KBC_OAUTH_CLIENT_ID).toBe('client-123');
+ // Unset values shown as null so the dump is complete.
+ expect(dump.HOSTNAME_SUFFIX).toBeNull();
+ });
+});
+
+describe('parseEnv', () => {
+ it('applies defaults and coerces PORT', () => {
+ const env = parseEnv({});
+ expect(env.HOST).toBe('localhost');
+ expect(env.PORT).toBe(8000);
+ expect(env.LOG_LEVEL).toBe('INFO');
+ expect(env.APP_ENV).toBe('local');
+ expect(env.APP_VERSION).toBe('DEV');
+ });
+
+ it('treats empty strings as unset (falls back to defaults)', () => {
+ const env = parseEnv({ HOST: '', PORT: '', APP_ENV: '' });
+ expect(env.HOST).toBe('localhost');
+ expect(env.PORT).toBe(8000);
+ expect(env.APP_ENV).toBe('local');
+ });
+
+ it('reads provided values and coerces booleans', () => {
+ const env = parseEnv({
+ PORT: '3000',
+ HOSTNAME_SUFFIX: 'keboola.com',
+ DD_LOGS_INJECTION: 'true',
+ });
+ expect(env.PORT).toBe(3000);
+ expect(env.HOSTNAME_SUFFIX).toBe('keboola.com');
+ expect(env.DD_LOGS_INJECTION).toBe(true);
+ });
+
+ it('throws on an invalid PORT when validation is not skipped', () => {
+ expect(() => parseEnv({ PORT: 'not-a-number' })).toThrow(/Invalid deployment environment/);
+ });
+
+ it('does not throw on a build (SKIP_ENV_VALIDATION)', () => {
+ expect(() => parseEnv({ SKIP_ENV_VALIDATION: '1', PORT: 'not-a-number' })).not.toThrow();
+ });
+});
+
+describe('applyDeploymentDefaults (HOSTNAME_SUFFIX derivation)', () => {
+ it('derives the Storage API URL from HOSTNAME_SUFFIX when unset', () => {
+ const config = applyDeploymentDefaults(
+ new Config(),
+ parseEnv({ HOSTNAME_SUFFIX: 'keboola.com' }),
+ );
+ expect(config.storageApiUrl).toBe('https://connection.keboola.com');
+ });
+
+ it('does not override an explicit Storage API URL', () => {
+ const base = new Config({ storageApiUrl: 'https://connection.north-europe.azure.keboola.com' });
+ const config = applyDeploymentDefaults(base, parseEnv({ HOSTNAME_SUFFIX: 'keboola.com' }));
+ expect(config.storageApiUrl).toBe('https://connection.north-europe.azure.keboola.com');
+ });
+
+ it('derives OAuth + MCP URLs and default scope only when OAuth is configured', () => {
+ const base = new Config({ oauthClientId: 'cid', oauthClientSecret: 'sec' });
+ const config = applyDeploymentDefaults(base, parseEnv({ HOSTNAME_SUFFIX: 'keboola.com' }));
+ expect(config.oauthServerUrl).toBe('https://connection.keboola.com');
+ expect(config.mcpServerUrl).toBe('https://mcp.keboola.com');
+ expect(config.oauthScope).toBe('email');
+ });
+
+ it('leaves OAuth/MCP URLs unset when OAuth is not configured', () => {
+ const config = applyDeploymentDefaults(
+ new Config(),
+ parseEnv({ HOSTNAME_SUFFIX: 'keboola.com' }),
+ );
+ expect(config.oauthServerUrl).toBeUndefined();
+ expect(config.mcpServerUrl).toBeUndefined();
+ });
+
+ it('is a no-op when HOSTNAME_SUFFIX is absent', () => {
+ const config = applyDeploymentDefaults(new Config(), parseEnv({}));
+ expect(config.storageApiUrl).toBeUndefined();
+ });
+});
diff --git a/__tests__/errors.test.ts b/__tests__/errors.test.ts
new file mode 100644
index 000000000..bc3a6ee7c
--- /dev/null
+++ b/__tests__/errors.test.ts
@@ -0,0 +1,125 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+import { describe, expect, it } from 'vitest';
+import { z } from 'zod';
+
+import { formatValidationErrors, prettifyValidationError } from '@/mcp/errors';
+import { registerTool } from '@/mcp/tool';
+
+/**
+ * Port of `tests/test_errors.py`.
+ *
+ * The Python `errors` module bundled three concerns: (1) Pydantic validation-error
+ * prettifying, (2) the `tool_errors` decorator (recovery hints + logging + SAPI event
+ * telemetry), and (3) a FastMCP `ValidationErrorMiddleware`.
+ *
+ * In the TypeScript port those concerns are split: the recovery-hint / error-result /
+ * logging behavior lives in `@/mcp/tool` (`registerTool`), so it is tested here against
+ * that surface; the SAPI-event telemetry and FastMCP middleware have no TS equivalent
+ * (different runtime) and are not ported. The validation-error formatting helpers were
+ * ported to `@/mcp/errors` (adapted from Pydantic's `ValidationError` to Zod's
+ * `ZodError`) and are tested directly.
+ */
+
+const zodError = (shape: z.ZodRawShape, input: unknown): z.ZodError => {
+ const result = z.object(shape).safeParse(input);
+ if (result.success) throw new Error('expected a validation failure');
+ return result.error;
+};
+
+describe('formatValidationErrors', () => {
+ it('extracts field, message and extra (code) from each issue', () => {
+ const err = zodError({ sql_query: z.string(), query_name: z.string() }, { foo: 'bar' });
+ const formatted = formatValidationErrors(err.issues);
+
+ expect(formatted.errors).toHaveLength(2);
+ const fields = formatted.errors.map((e) => e.field).sort();
+ expect(fields).toEqual(['query_name', 'sql_query']);
+ for (const e of formatted.errors) {
+ expect(e.message).toBeTruthy();
+ // The Zod issue `code` is carried through under `extra`, mirroring Pydantic's `type`.
+ expect(e.extra.code).toBe('invalid_type');
+ }
+ });
+
+ it('joins nested paths with dots', () => {
+ const err = zodError({ outer: z.object({ inner: z.string() }) }, { outer: { inner: 1 } });
+ const formatted = formatValidationErrors(err.issues);
+ expect(formatted.errors[0]!.field).toBe('outer.inner');
+ });
+});
+
+describe('prettifyValidationError', () => {
+ it('renders the count header with the model name', () => {
+ const err = zodError({ a: z.string(), b: z.number() }, {});
+ const text = prettifyValidationError(err, 'MyTool');
+ const lines = text.split('\n');
+ expect(lines[0]).toBe('Found 2 validation error(s) for MyTool');
+ // Field locations are surfaced explicitly in the body.
+ expect(text).toContain('field: a');
+ expect(text).toContain('field: b');
+ });
+
+ it('defaults the model name to "unknown"', () => {
+ const err = zodError({ a: z.string() }, {});
+ expect(prettifyValidationError(err).split('\n')[0]).toBe(
+ 'Found 1 validation error(s) for unknown',
+ );
+ });
+});
+
+// --- registerTool error path (the TS home of Python's `tool_errors` recovery/logging) ---
+
+const callTool = async (
+ def: Parameters[1],
+ args: Record = {},
+) => {
+ const [clientT, serverT] = InMemoryTransport.createLinkedPair();
+ const mcp = new McpServer({ name: 'test', version: '0.0.0' });
+ registerTool(mcp, def);
+ await mcp.connect(serverT);
+ const client = new Client({ name: 'test', version: '0.0.0' });
+ await client.connect(clientT);
+ return client.callTool({ name: def.name, arguments: args });
+};
+
+describe('registerTool error handling', () => {
+ it('returns an error result carrying the exception message', async () => {
+ const result = await callTool({
+ name: 'boom',
+ description: 'd',
+ handler: () => {
+ throw new Error('Simulated failure');
+ },
+ });
+ expect(result.isError).toBe(true);
+ expect((result.content as { text: string }[])[0]!.text).toBe('Simulated failure');
+ });
+
+ it('appends the recovery hint when one is configured', async () => {
+ const result = await callTool({
+ name: 'boom_recover',
+ description: 'd',
+ recovery: 'Check that data has valid types.',
+ handler: () => {
+ throw new Error('Simulated failure');
+ },
+ });
+ expect(result.isError).toBe(true);
+ const text = (result.content as { text: string }[])[0]!.text;
+ expect(text).toContain('Simulated failure');
+ expect(text).toContain('Recovery: Check that data has valid types.');
+ });
+
+ it('omits the Recovery line when no hint is configured', async () => {
+ const result = await callTool({
+ name: 'boom_plain',
+ description: 'd',
+ handler: () => {
+ throw new Error('Simulated failure');
+ },
+ });
+ expect((result.content as { text: string }[])[0]!.text).not.toContain('Recovery:');
+ });
+});
diff --git a/__tests__/http.test.ts b/__tests__/http.test.ts
new file mode 100644
index 000000000..56eeccf58
--- /dev/null
+++ b/__tests__/http.test.ts
@@ -0,0 +1,63 @@
+import { serve } from '@hono/node-server';
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
+import type { AddressInfo } from 'node:net';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { Config } from '@/config';
+import { createHttpApp } from '@/transports/http';
+
+let httpServer: ReturnType;
+let port: number;
+
+const connect = async (headers?: Record) => {
+ const url = new URL(`http://localhost:${port}/mcp`);
+ const client = new Client({ name: 'test-client', version: '0.0.0' });
+ await client.connect(new StreamableHTTPClientTransport(url, { requestInit: { headers } }));
+ return client;
+};
+
+// Returns the TOON-encoded tool output text.
+const callServerInfo = async (client: Awaited>) => {
+ const result = await client.callTool({ name: 'get_server_info', arguments: {} });
+ const content = result.content as { type: string; text: string }[];
+ return content[0]!.text;
+};
+
+describe('streamable-http transport', () => {
+ beforeAll(async () => {
+ const app = createHttpApp(new Config({ branchId: '999' }));
+ port = await new Promise((resolve) => {
+ httpServer = serve({ fetch: app.fetch, port: 0 }, (info: AddressInfo) => resolve(info.port));
+ });
+ });
+
+ afterAll(() => {
+ httpServer.close();
+ });
+
+ it('serves a health check', async () => {
+ const res = await fetch(`http://localhost:${port}/health-check`);
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ status: 'ok' });
+ });
+
+ it('lists tools over the MCP endpoint', async () => {
+ const client = await connect();
+ const { tools } = await client.listTools();
+ expect(tools.map((tool) => tool.name)).toContain('get_server_info');
+ await client.close();
+ });
+
+ it('uses the base config when no headers override it', async () => {
+ const client = await connect();
+ expect(await callServerInfo(client)).toContain('branchId: "999"');
+ await client.close();
+ });
+
+ it('layers X- headers over the base config per request', async () => {
+ const client = await connect({ 'X-Branch-Id': '123', 'X-StorageApiToken': 'tok' });
+ expect(await callServerInfo(client)).toContain('branchId: "123"');
+ await client.close();
+ });
+});
diff --git a/__tests__/links.test.ts b/__tests__/links.test.ts
new file mode 100644
index 000000000..804e91f8f
--- /dev/null
+++ b/__tests__/links.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest';
+
+import { ProjectLinksManager } from '@/links';
+
+const BASE = 'https://connection.keboola.com';
+
+const manager = (branchId?: string) =>
+ new ProjectLinksManager({ baseUrl: BASE, projectId: '1234', branchId });
+
+describe('ProjectLinksManager', () => {
+ it('builds production URLs without a branch segment', () => {
+ expect(manager().getProjectDetailLink()).toEqual({
+ type: 'ui-detail',
+ title: 'Project Dashboard',
+ url: `${BASE}/admin/projects/1234/`,
+ });
+ });
+
+ it('inserts the branch segment on a development branch', () => {
+ expect(manager('567').getBucketDetailLink('in.c-main', 'main').url).toBe(
+ `${BASE}/admin/projects/1234/branch/567/storage/in.c-main`,
+ );
+ });
+
+ it.each([
+ ['keboola.flow', 'flows-v2'],
+ ['keboola.orchestrator', 'flows'],
+ ] as const)('routes %s flows to /%s', (flowType, path) => {
+ expect(manager().getFlowDetailLink('99', 'My Flow', flowType).url).toBe(
+ `${BASE}/admin/projects/1234/${path}/99`,
+ );
+ });
+
+ it('routes transformation components to the transformations path via getComponentConfigLink', () => {
+ const link = manager().getComponentConfigLink('keboola.snowflake-transformation', 'cfg1', 'T');
+ expect(link.url).toBe(
+ `${BASE}/admin/projects/1234/transformations-v2/keboola.snowflake-transformation/cfg1`,
+ );
+ });
+
+ it('routes data-app components to the data-apps path', () => {
+ const link = manager().getComponentConfigLink('keboola.data-apps', 'cfg1', 'App');
+ expect(link.url).toBe(`${BASE}/admin/projects/1234/data-apps/cfg1`);
+ });
+
+ it('splits a fully-qualified table id into bucket + table', () => {
+ expect(manager().getTableDetailLinkFromTableId('in.c-main.users').url).toBe(
+ `${BASE}/admin/projects/1234/storage/in.c-main/table/users`,
+ );
+ });
+
+ it('getLinks picks the config link when component + configuration are given', () => {
+ const links = manager().getLinks({
+ componentId: 'keboola.ex-aws-s3',
+ configurationId: 'c1',
+ name: 'My cfg',
+ });
+ expect(links).toHaveLength(1);
+ expect(links[0]!.url).toBe(`${BASE}/admin/projects/1234/components/keboola.ex-aws-s3/c1`);
+ });
+});
diff --git a/__tests__/mcp.authorization.test.ts b/__tests__/mcp.authorization.test.ts
new file mode 100644
index 000000000..f1c5ff77b
--- /dev/null
+++ b/__tests__/mcp.authorization.test.ts
@@ -0,0 +1,157 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ hasAuthorizationFilters,
+ isToolNameAuthorized,
+ parseAuthorizationConfig,
+} from '@/mcp/authorization';
+
+// Port of tests/test_authorization.py. The Python middleware reads HTTP headers;
+// here the header values are surfaced onto Config (allowedTools / disallowedTools /
+// readOnlyMode), parsed by parseAuthorizationConfig, and applied per tool by
+// isToolNameAuthorized.
+
+// 3 read-only (get_configs, get_buckets, query_data), 2 write (create_config, update_descriptions).
+const TOOLS: { name: string; readOnly: boolean }[] = [
+ { name: 'get_configs', readOnly: true },
+ { name: 'create_config', readOnly: false },
+ { name: 'get_buckets', readOnly: true },
+ { name: 'update_descriptions', readOnly: false },
+ { name: 'query_data', readOnly: true },
+];
+const ALL_TOOLS = new Set(TOOLS.map((t) => t.name));
+const READ_ONLY_TOOLS = new Set(TOOLS.filter((t) => t.readOnly).map((t) => t.name));
+
+type Headers = { allowedTools?: string; disallowedTools?: string; readOnlyMode?: string };
+
+const filterList = (headers: Headers): Set => {
+ const config = parseAuthorizationConfig(headers);
+ if (!hasAuthorizationFilters(config)) return new Set(TOOLS.map((t) => t.name));
+ return new Set(
+ TOOLS.filter((t) => isToolNameAuthorized(t.name, t.readOnly, config)).map((t) => t.name),
+ );
+};
+
+describe('parseAuthorizationConfig + isToolNameAuthorized (list filtering)', () => {
+ it.each<[string, Headers, Set]>([
+ ['no_headers', {}, ALL_TOOLS],
+ [
+ 'allowed_tools_only',
+ { allowedTools: 'get_configs, get_buckets' },
+ new Set(['get_configs', 'get_buckets']),
+ ],
+ ['read_only_mode_only', { readOnlyMode: 'true' }, READ_ONLY_TOOLS],
+ [
+ 'disallowed_tools_only',
+ { disallowedTools: 'create_config, update_descriptions' },
+ READ_ONLY_TOOLS,
+ ],
+ [
+ 'allowed_and_read_only',
+ { allowedTools: 'get_configs, create_config, get_buckets', readOnlyMode: 'true' },
+ new Set(['get_configs', 'get_buckets']),
+ ],
+ [
+ 'allowed_and_disallowed',
+ { allowedTools: 'get_configs, create_config, get_buckets', disallowedTools: 'create_config' },
+ new Set(['get_configs', 'get_buckets']),
+ ],
+ [
+ 'read_only_and_disallowed',
+ { readOnlyMode: 'true', disallowedTools: 'get_configs' },
+ new Set(['get_buckets', 'query_data']),
+ ],
+ [
+ 'all_three_headers',
+ {
+ allowedTools: 'get_configs, get_buckets, query_data, create_config',
+ readOnlyMode: 'true',
+ disallowedTools: 'query_data',
+ },
+ new Set(['get_configs', 'get_buckets']),
+ ],
+ ['empty_allowed_tools', { allowedTools: '' }, ALL_TOOLS],
+ ['whitespace_only_allowed_tools', { allowedTools: ' , , ' }, ALL_TOOLS],
+ ['empty_disallowed_tools', { disallowedTools: '' }, ALL_TOOLS],
+ [
+ 'allowed_tools_with_whitespace',
+ { allowedTools: ' get_configs , get_buckets , ' },
+ new Set(['get_configs', 'get_buckets']),
+ ],
+ [
+ 'disallowed_tools_with_whitespace',
+ { disallowedTools: ' create_config , update_descriptions , ' },
+ READ_ONLY_TOOLS,
+ ],
+ ])('%s', (_label, headers, expected) => {
+ expect(filterList(headers)).toEqual(expected);
+ });
+
+ it.each(['true', 'True', 'TRUE', '1', 'yes', 'Yes', 'YES'])(
+ 'read-only mode enabled for truthy value %j',
+ (value) => {
+ expect(filterList({ readOnlyMode: value })).toEqual(READ_ONLY_TOOLS);
+ },
+ );
+
+ it.each(['false', 'False', '0', 'no', '', 'random'])(
+ 'read-only mode disabled for falsy value %j',
+ (value) => {
+ expect(filterList({ readOnlyMode: value })).toEqual(ALL_TOOLS);
+ },
+ );
+});
+
+describe('isToolNameAuthorized (call decision)', () => {
+ it.each<[string, string, boolean, Headers, boolean]>([
+ ['no_headers_write_tool', 'create_config', false, {}, true],
+ ['no_headers_read_tool', 'get_configs', true, {}, true],
+ [
+ 'allowed_tool_in_list',
+ 'get_configs',
+ true,
+ { allowedTools: 'get_configs, get_buckets' },
+ true,
+ ],
+ [
+ 'allowed_tool_not_in_list',
+ 'create_config',
+ false,
+ { allowedTools: 'get_configs, get_buckets' },
+ false,
+ ],
+ ['read_only_mode_read_tool', 'get_configs', true, { readOnlyMode: 'true' }, true],
+ ['read_only_mode_write_tool', 'create_config', false, { readOnlyMode: 'true' }, false],
+ [
+ 'disallowed_tool_in_list',
+ 'create_config',
+ false,
+ { disallowedTools: 'create_config, update_descriptions' },
+ false,
+ ],
+ [
+ 'disallowed_tool_not_in_list',
+ 'get_configs',
+ true,
+ { disallowedTools: 'create_config, update_descriptions' },
+ true,
+ ],
+ [
+ 'allowed_and_read_only_write_tool',
+ 'create_config',
+ false,
+ { allowedTools: 'get_configs, create_config', readOnlyMode: 'true' },
+ false,
+ ],
+ [
+ 'allowed_and_disallowed_same_tool',
+ 'get_configs',
+ true,
+ { allowedTools: 'get_configs, get_buckets', disallowedTools: 'get_configs' },
+ false,
+ ],
+ ])('%s', (_label, toolName, readOnly, headers, shouldAllow) => {
+ const config = parseAuthorizationConfig(headers);
+ expect(isToolNameAuthorized(toolName, readOnly, config)).toBe(shouldAllow);
+ });
+});
diff --git a/__tests__/mcp.filtering.test.ts b/__tests__/mcp.filtering.test.ts
new file mode 100644
index 000000000..942b7cbf9
--- /dev/null
+++ b/__tests__/mcp.filtering.test.ts
@@ -0,0 +1,367 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+import { Config } from '@/config';
+import {
+ authorizeToolCall,
+ filterToolsList,
+ type GatedTool,
+ type GatingContext,
+ getProjectFeatures,
+ getTokenRole,
+} from '@/mcp/filtering';
+import { createServer } from '@/server';
+
+// --- Pure gating functions (port of tests/test_mcp.py TestToolsFilteringMiddleware) ---
+
+const tool = (name: string, readOnly = false): GatedTool => ({ name, readOnly });
+
+const ctx = (over: Partial = {}): GatingContext => ({
+ tokenRole: '',
+ features: new Set(),
+ isOauth: false,
+ isMainBranch: true,
+ docsIndexAvailable: true,
+ ...over,
+});
+
+describe('getProjectFeatures / getTokenRole', () => {
+ it('reads features and role from token info', () => {
+ const info = { owner: { features: ['a', '', 'b'] }, admin: { role: 'admin' } };
+ expect(getProjectFeatures(info)).toEqual(new Set(['a', 'b']));
+ expect(getTokenRole(info)).toBe('admin');
+ });
+
+ it('defaults to empty when missing', () => {
+ expect(getProjectFeatures({})).toEqual(new Set());
+ expect(getTokenRole({})).toBe('');
+ });
+});
+
+describe('filterToolsList — data app tools by branch', () => {
+ const dataAppTools = [
+ 'modify_streamlit_data_app',
+ 'get_data_apps',
+ 'deploy_data_app',
+ 'delete_python_js_data_app_draft',
+ ];
+ const tools = [...dataAppTools.map((n) => tool(n)), tool('other_tool')];
+
+ it.each<[boolean, boolean]>([
+ [false, true], // non-main branch -> filtered out
+ [true, false], // main branch -> kept
+ ])('isMainBranch=%s filters=%s', (isMainBranch, expectFiltered) => {
+ const names = new Set(filterToolsList(tools, ctx({ isMainBranch })).map((t) => t.name));
+ for (const n of dataAppTools) {
+ expect(names.has(n)).toBe(!expectFiltered);
+ }
+ expect(names.has('other_tool')).toBe(true);
+ });
+});
+
+describe('filterToolsList — flow tools by role / oauth', () => {
+ const tools = [
+ tool('modify_flow'),
+ tool('update_flow'),
+ tool('other_tool'),
+ tool('read_only_tool', true),
+ ];
+
+ it.each<[string, boolean, string[], string[]]>([
+ ['admin', false, ['update_flow'], ['modify_flow', 'read_only_tool']],
+ ['share', false, ['update_flow'], ['modify_flow', 'read_only_tool']],
+ ['', false, ['modify_flow'], ['update_flow', 'read_only_tool']],
+ ['readOnly', false, ['modify_flow', 'update_flow'], ['read_only_tool']],
+ ['guest', false, ['modify_flow'], ['update_flow', 'read_only_tool']],
+ // OAuth regular user gets modify_flow (different from SAPI regular).
+ ['', true, ['update_flow'], ['modify_flow', 'read_only_tool']],
+ ])('role=%j oauth=%s', (tokenRole, isOauth, hidden, visible) => {
+ const names = new Set(filterToolsList(tools, ctx({ tokenRole, isOauth })).map((t) => t.name));
+ for (const n of hidden) expect(names.has(n)).toBe(false);
+ for (const n of visible) expect(names.has(n)).toBe(true);
+ });
+});
+
+describe('filterToolsList — semantic tools by feature', () => {
+ const tools = [
+ tool('search_semantic_context'),
+ tool('get_semantic_context'),
+ tool('get_semantic_schema'),
+ tool('validate_semantic_query'),
+ tool('other_tool'),
+ ];
+
+ it.each<[string[], string, boolean]>([
+ [[], 'search_semantic_context', true],
+ [[], 'get_semantic_schema', true],
+ [['mcp-semantic-tooling'], 'search_semantic_context', false],
+ [['mcp-semantic-tooling'], 'get_semantic_schema', false],
+ [['other-feature'], 'search_semantic_context', true],
+ ])('features=%j tool=%s filtered=%s', (features, name, expectFiltered) => {
+ const names = new Set(
+ filterToolsList(tools, ctx({ features: new Set(features) })).map((t) => t.name),
+ );
+ expect(names.has(name)).toBe(!expectFiltered);
+ expect(names.has('other_tool')).toBe(true);
+ });
+});
+
+describe('filterToolsList — conditional vs legacy flow', () => {
+ const tools = [tool('create_flow'), tool('create_conditional_flow'), tool('other_tool')];
+
+ it('hides create_conditional_flow when hide-conditional-flows feature is on', () => {
+ const names = new Set(
+ filterToolsList(tools, ctx({ features: new Set(['hide-conditional-flows']) })).map(
+ (t) => t.name,
+ ),
+ );
+ expect(names.has('create_flow')).toBe(true);
+ expect(names.has('create_conditional_flow')).toBe(false);
+ });
+
+ it('hides create_flow when the feature is off', () => {
+ const names = new Set(filterToolsList(tools, ctx()).map((t) => t.name));
+ expect(names.has('create_flow')).toBe(false);
+ expect(names.has('create_conditional_flow')).toBe(true);
+ });
+});
+
+describe('filterToolsList / authorizeToolCall — docs tools by index availability', () => {
+ const docsTools = [tool('docs_query', true), tool('find_component_id', true), tool('get_jobs')];
+
+ it('keeps docs tools when the index is available, drops them when not', () => {
+ const withIndex = filterToolsList(docsTools, ctx({ docsIndexAvailable: true })).map(
+ (t) => t.name,
+ );
+ expect(withIndex).toContain('docs_query');
+ expect(withIndex).toContain('find_component_id');
+
+ const withoutIndex = filterToolsList(docsTools, ctx({ docsIndexAvailable: false })).map(
+ (t) => t.name,
+ );
+ expect(withoutIndex).not.toContain('docs_query');
+ expect(withoutIndex).not.toContain('find_component_id');
+ expect(withoutIndex).toContain('get_jobs'); // non-docs tools unaffected
+ });
+
+ it('denies a docs tool call when the index is unavailable', () => {
+ expect(
+ authorizeToolCall({
+ toolName: 'docs_query',
+ isReadOnly: true,
+ isSemantic: false,
+ tokenRole: 'admin',
+ features: new Set(),
+ isOauth: false,
+ isMainBranch: true,
+ docsIndexAvailable: false,
+ }),
+ ).toContain('documentation index is not');
+ });
+});
+
+describe('authorizeToolCall — flow tools by role / oauth', () => {
+ it.each<[string, boolean, string, boolean, boolean]>([
+ ['admin', false, 'modify_flow', false, false],
+ ['admin', false, 'update_flow', false, true],
+ ['share', false, 'modify_flow', false, false],
+ ['share', false, 'update_flow', false, true],
+ ['', false, 'modify_flow', false, true],
+ ['', false, 'update_flow', false, false],
+ ['guest', false, 'write_tool', false, false],
+ ['guest', false, 'read_only_tool', true, false],
+ ['readOnly', false, 'write_tool', false, true],
+ ['readOnly', false, 'read_only_tool', true, false],
+ ['', true, 'modify_flow', false, false],
+ ['', true, 'update_flow', false, true],
+ ])('role=%j oauth=%s tool=%s', (tokenRole, isOauth, toolName, isReadOnly, expectDenied) => {
+ const denial = authorizeToolCall({
+ toolName,
+ isReadOnly,
+ isSemantic: false,
+ tokenRole,
+ features: new Set(),
+ isOauth,
+ isMainBranch: true,
+ docsIndexAvailable: true,
+ });
+ expect(denial !== null).toBe(expectDenied);
+ });
+});
+
+describe('authorizeToolCall — data apps by branch', () => {
+ it.each<[boolean, boolean]>([
+ [false, true],
+ [true, false],
+ ])('isMainBranch=%s denied=%s', (isMainBranch, expectDenied) => {
+ const denial = authorizeToolCall({
+ toolName: 'modify_streamlit_data_app',
+ isReadOnly: false,
+ isSemantic: false,
+ tokenRole: 'admin',
+ features: new Set(),
+ isOauth: false,
+ isMainBranch,
+ docsIndexAvailable: true,
+ });
+ if (expectDenied) {
+ expect(denial).toContain('main production branch');
+ } else {
+ expect(denial).toBeNull();
+ }
+ });
+});
+
+describe('authorizeToolCall — semantic tools by feature', () => {
+ it.each<[string[], string, boolean]>([
+ [[], 'search_semantic_context', true],
+ [[], 'get_semantic_schema', true],
+ [['mcp-semantic-tooling'], 'search_semantic_context', false],
+ [['mcp-semantic-tooling'], 'get_semantic_schema', false],
+ [[], 'other_tool', false],
+ ])('features=%j tool=%s denied=%s', (features, toolName, expectDenied) => {
+ const isSemantic = ['search_semantic_context', 'get_semantic_schema'].includes(toolName);
+ const denial = authorizeToolCall({
+ toolName,
+ isReadOnly: true,
+ isSemantic,
+ tokenRole: 'admin',
+ features: new Set(features),
+ isOauth: false,
+ isMainBranch: true,
+ docsIndexAvailable: true,
+ });
+ if (expectDenied) {
+ expect(denial).toContain('Semantic Layer Tooling');
+ } else {
+ expect(denial).toBeNull();
+ }
+ });
+});
+
+// --- Integration: gating wired into createServer (tools/list + tools/call) ---
+
+const msw = setupServer();
+beforeAll(() => msw.listen({ onUnhandledRequest: 'error' }));
+afterEach(() => msw.resetHandlers());
+afterAll(() => msw.close());
+
+const verify = (body: unknown) =>
+ http.get('https://connection.test/*', ({ request }) => {
+ if (new URL(request.url).pathname.endsWith('/tokens/verify')) {
+ return HttpResponse.json(body as object);
+ }
+ return undefined;
+ });
+
+const connect = async (config: Config) => {
+ const [clientT, serverT] = InMemoryTransport.createLinkedPair();
+ await createServer(config).connect(serverT);
+ const client = new Client({ name: 'test', version: '0.0.0' });
+ await client.connect(clientT);
+ return client;
+};
+
+const baseConfig = (over: Record = {}) =>
+ new Config({ storageApiUrl: 'https://connection.test', storageToken: 'tok', ...over });
+
+describe('createServer gating integration', () => {
+ it('hides create_flow for a no-feature project and shows create_conditional_flow', async () => {
+ msw.use(verify({ owner: { features: [] }, admin: { role: 'admin' } }));
+ const client = await connect(baseConfig());
+ const names = (await client.listTools()).tools.map((t) => t.name);
+ expect(names).toContain('create_conditional_flow');
+ expect(names).not.toContain('create_flow');
+ await client.close();
+ });
+
+ it('shows only modify_flow (not update_flow) for an admin token', async () => {
+ msw.use(verify({ owner: { features: [] }, admin: { role: 'admin' } }));
+ const client = await connect(baseConfig());
+ const names = (await client.listTools()).tools.map((t) => t.name);
+ expect(names).toContain('modify_flow');
+ expect(names).not.toContain('update_flow');
+ await client.close();
+ });
+
+ it('shows update_flow (not modify_flow) for a guest token', async () => {
+ msw.use(verify({ owner: { features: [] }, admin: { role: 'guest' } }));
+ const client = await connect(baseConfig());
+ const names = (await client.listTools()).tools.map((t) => t.name);
+ expect(names).toContain('update_flow');
+ expect(names).not.toContain('modify_flow');
+ await client.close();
+ });
+
+ it('keeps data app tools in tools/list even on a non-main branch (discovery forces main)', async () => {
+ // Parity with Python: tools/list always discovers against the main branch, so data
+ // app tools stay visible during discovery and are only blocked at call time.
+ msw.use(verify({ owner: { features: [] }, admin: { role: 'admin' } }));
+ const client = await connect(baseConfig({ branchId: '123' }));
+ const names = (await client.listTools()).tools.map((t) => t.name);
+ expect(names).toContain('get_data_apps');
+ expect(names).toContain('deploy_data_app');
+ await client.close();
+ });
+
+ it('blocks a data app tools/call on a non-main branch', async () => {
+ msw.use(verify({ owner: { features: [] }, admin: { role: 'admin' } }));
+ const client = await connect(baseConfig({ branchId: '123' }));
+ await expect(client.callTool({ name: 'get_data_apps', arguments: {} })).rejects.toThrow(
+ /main production branch/,
+ );
+ await client.close();
+ });
+
+ it('restricts list to read-only tools for a readonly role', async () => {
+ msw.use(verify({ owner: { features: [] }, admin: { role: 'readonly' } }));
+ const client = await connect(baseConfig());
+ const tools = (await client.listTools()).tools;
+ expect(tools.length).toBeGreaterThan(0);
+ expect(tools.every((t) => t.annotations?.readOnlyHint === true)).toBe(true);
+ await client.close();
+ });
+
+ it('hides semantic tools without the semantic feature, shows them with it', async () => {
+ msw.use(verify({ owner: { features: [] }, admin: { role: 'admin' } }));
+ let client = await connect(baseConfig());
+ let names = (await client.listTools()).tools.map((t) => t.name);
+ expect(names).not.toContain('search_semantic_context');
+ await client.close();
+
+ msw.use(verify({ owner: { features: ['mcp-semantic-tooling'] }, admin: { role: 'admin' } }));
+ client = await connect(baseConfig());
+ names = (await client.listTools()).tools.map((t) => t.name);
+ expect(names).toContain('search_semantic_context');
+ await client.close();
+ });
+
+ it('blocks a tools/call that the project gating denies (update_flow for admin)', async () => {
+ msw.use(verify({ owner: { features: [] }, admin: { role: 'admin' } }));
+ const client = await connect(baseConfig());
+ await expect(client.callTool({ name: 'update_flow', arguments: {} })).rejects.toThrow(
+ /update_flow/,
+ );
+ await client.close();
+ });
+
+ it('blocks a tools/call denied by X-Disallowed-Tools header authorization', async () => {
+ msw.use(verify({ owner: { features: [] }, admin: { role: 'admin' } }));
+ const client = await connect(baseConfig({ disallowedTools: 'get_jobs' }));
+ await expect(client.callTool({ name: 'get_jobs', arguments: { job_ids: [] } })).rejects.toThrow(
+ /not authorized/,
+ );
+ await client.close();
+ });
+
+ it('hides tools excluded by X-Allowed-Tools header authorization in tools/list', async () => {
+ msw.use(verify({ owner: { features: [] }, admin: { role: 'admin' } }));
+ const client = await connect(baseConfig({ allowedTools: 'get_jobs,get_buckets' }));
+ const names = (await client.listTools()).tools.map((t) => t.name);
+ expect(names.sort()).toEqual(['get_buckets', 'get_jobs']);
+ await client.close();
+ });
+});
diff --git a/__tests__/mcp.tool.test.ts b/__tests__/mcp.tool.test.ts
new file mode 100644
index 000000000..f0aab0a08
--- /dev/null
+++ b/__tests__/mcp.tool.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest';
+
+import { describeToolError } from '@/mcp/tool';
+
+describe('describeToolError', () => {
+ it('enriches an api-client ApiError with the response body detail + exception id', () => {
+ // Shape of @keboola/api-client ApiError: message is the HTTP status text; the real
+ // reason + support id are on `.data` (this is the run_sync_action "Bad Request" case).
+ const apiError = Object.assign(new Error('Bad Request'), {
+ data: { error: 'Invalid access token\n', code: 0, exceptionId: 'exception-abc123' },
+ });
+ expect(describeToolError(apiError)).toBe(
+ 'Bad Request: Invalid access token (exception ID: exception-abc123)',
+ );
+ });
+
+ it('uses data.message when data.error is absent', () => {
+ const err = Object.assign(new Error('Unprocessable Entity'), {
+ data: { message: 'query must not be empty' },
+ });
+ expect(describeToolError(err)).toBe('Unprocessable Entity: query must not be empty');
+ });
+
+ it('passes a plain Error (e.g. our raw client, which already composes detail) through', () => {
+ expect(describeToolError(new Error('404 Not Found\nAPI error: nope'))).toBe(
+ '404 Not Found\nAPI error: nope',
+ );
+ });
+
+ it('does not duplicate when the detail equals the base message', () => {
+ const err = Object.assign(new Error('Invalid access token'), {
+ data: { error: 'Invalid access token' },
+ });
+ expect(describeToolError(err)).toBe('Invalid access token');
+ });
+
+ it('handles non-Error values', () => {
+ expect(describeToolError('boom')).toBe('boom');
+ });
+});
diff --git a/__tests__/oauth.provider.test.ts b/__tests__/oauth.provider.test.ts
new file mode 100644
index 000000000..5991b0d95
--- /dev/null
+++ b/__tests__/oauth.provider.test.ts
@@ -0,0 +1,305 @@
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+import {
+ type AccessToken,
+ type ExtendedAuthorizationCode,
+ InvalidRedirectUriError,
+ type RefreshToken,
+ SimpleOAuthProvider,
+ validateRedirectUri,
+} from '@/oauth';
+
+const JWT_KEY = 'secret';
+
+const server = setupServer();
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+const newProvider = (): SimpleOAuthProvider =>
+ new SimpleOAuthProvider({
+ storageApiUrl: 'https://sapi',
+ mcpServerUrl: 'https://mcp',
+ callbackEndpoint: '/callback',
+ clientId: 'mcp-server-id',
+ clientSecret: 'mcp-server-secret',
+ serverUrl: 'https://oauth',
+ scope: 'scope',
+ jwtSecret: JWT_KEY,
+ });
+
+const accessToken = (overrides: Partial = {}): AccessToken => ({
+ token: 'oauth-access-token',
+ client_id: 'mcp-server',
+ scopes: ['foo'],
+ expires_at: null,
+ ...overrides,
+});
+
+const refreshToken = (overrides: Partial = {}): RefreshToken => ({
+ token: 'oauth-refresh-token',
+ client_id: 'mcp-server',
+ scopes: ['foo'],
+ expires_at: null,
+ ...overrides,
+});
+
+const authorizationCode = (opts: {
+ scopes?: string[];
+ expiresAt?: number;
+}): ExtendedAuthorizationCode => ({
+ code: 'foo',
+ scopes: opts.scopes ?? [],
+ expires_at: opts.expiresAt ?? Date.now() / 1000 + 5 * 60, // 5 minutes from now
+ client_id: 'foo-client-id',
+ code_challenge: 'foo-code-challenge',
+ redirect_uri: 'foo://bar',
+ redirect_uri_provided_explicitly: true,
+ oauth_access_token: accessToken(),
+ oauth_refresh_token: refreshToken(),
+});
+
+describe('SimpleOAuthProvider', () => {
+ describe('loadAuthorizationCode', () => {
+ it.each([
+ { name: 'valid, no scopes', code: authorizationCode({}), key: JWT_KEY, valid: true },
+ {
+ name: 'valid, scopes',
+ code: authorizationCode({ scopes: ['foo', 'bar'] }),
+ key: JWT_KEY,
+ valid: true,
+ },
+ {
+ name: 'expired, no scopes',
+ code: authorizationCode({ expiresAt: 1 }),
+ key: JWT_KEY,
+ valid: true,
+ },
+ { name: 'wrong encryption key', code: authorizationCode({}), key: '!@#$%^&', valid: false },
+ ])('$name', async ({ code, key, valid }) => {
+ const provider = newProvider();
+ const authCodeStr = await provider.encode(code, key);
+ const loaded = await provider.loadAuthorizationCode(authCodeStr);
+ if (valid) {
+ expect(loaded).toEqual(code);
+ } else {
+ expect(loaded).toBeNull();
+ }
+ });
+ });
+
+ describe('readOauthTokens', () => {
+ it.each([
+ { rawAt: 'foo', rawRt: 'bar', scopes: ['email'], atExpiresIn: 3600, rtExpiresIn: 168 * 3600 },
+ {
+ rawAt: 'foo',
+ rawRt: 'bar',
+ scopes: ['user', 'email'],
+ atExpiresIn: 3600,
+ rtExpiresIn: 168 * 3600,
+ },
+ { rawAt: 'foo', rawRt: 'bar', scopes: [], atExpiresIn: 3600, rtExpiresIn: 168 * 3600 },
+ // 168 * 1 second rounded up to the nearest hour -> 3600
+ { rawAt: 'foo', rawRt: 'bar', scopes: [], atExpiresIn: 1, rtExpiresIn: 3600 },
+ { rawAt: 'foo', rawRt: 'bar', scopes: [], atExpiresIn: 7200, rtExpiresIn: 168 * 3600 },
+ ])(
+ 'scopes=$scopes atExpiresIn=$atExpiresIn',
+ ({ rawAt, rawRt, scopes, atExpiresIn, rtExpiresIn }) => {
+ const provider = newProvider();
+ const [at, rt] = provider.readOauthTokens(
+ { access_token: rawAt, refresh_token: rawRt, expires_in: atExpiresIn },
+ scopes,
+ );
+
+ const now = Date.now() / 1000;
+ expect(at.token).toBe(rawAt);
+ expect(at.scopes).toEqual(scopes);
+ expect(atExpiresIn - ((at.expires_at ?? 0) - now)).toBeGreaterThanOrEqual(0);
+ expect(atExpiresIn - ((at.expires_at ?? 0) - now)).toBeLessThan(1);
+
+ expect(rt.token).toBe(rawRt);
+ expect(rt.scopes).toEqual(scopes);
+ expect(rtExpiresIn - ((rt.expires_at ?? 0) - now)).toBeGreaterThanOrEqual(0);
+ expect(rtExpiresIn - ((rt.expires_at ?? 0) - now)).toBeLessThan(1);
+ },
+ );
+ });
+
+ describe('validateRedirectUri', () => {
+ const cases: [string | null, boolean][] = [
+ // === HTTP scheme - localhost only ===
+ ['http://localhost:8080/foo', true],
+ ['http://localhost:20388/oauth/callback', true],
+ ['http://localhost/callback', true],
+ ['http://127.0.0.1:1234/bar', true],
+ ['http://127.0.0.1:54750/auth/callback', true],
+ ['http://127.0.0.1/callback', true],
+ // IPv6 localhost
+ ['http://[::1]:8080/callback', true],
+ ['http://[::1]/callback', true],
+ // HTTP to non-localhost should be rejected
+ ['http://example.com/callback', false],
+ ['http://keboola.com/callback', false],
+ ['http://192.168.1.1/callback', false],
+ // === HTTPS scheme - whitelisted domains ===
+ ['https://foo.keboola.com/bar/baz', true],
+ ['https://bar.keboola.dev/baz', true],
+ ['https://connection.keboola.com/oauth/callback', true],
+ ['https://keboola.com/callback', false], // requires subdomain
+ ['https://keboola.dev/callback', false], // requires subdomain
+ // Data-app 'hub' subdomains are user-deployable and must be rejected (RISK-76)
+ ['https://my-app.hub.keboola.com/callback', false],
+ ['https://my-app.hub.north-europe.azure.keboola.com/callback', false],
+ ['https://my-app.hub.keboola.dev/callback', false],
+ ['https://hub.keboola.com/callback', false], // the hub root itself
+ ['https://my-app.hub.us-east4.gcp.keboola.com/callback', false],
+ // ChatGPT (subdomain optional)
+ ['https://chatgpt.com', true],
+ ['https://foo.chatgpt.com/bar', true],
+ ['https://chatgpt.com/connector_platform_oauth_redirect', true],
+ // Claude (subdomain optional)
+ ['https://claude.ai', true],
+ ['https://foo.claude.ai/bar', true],
+ ['https://claude.ai/api/mcp/auth_callback', true],
+ // LibreChat (no subdomains allowed)
+ ['https://librechat.glami-ml.com', true],
+ ['https://librechat.glami-ml.com/api/mcp/keboola/oauth/callback', true],
+ ['https://foo.librechat.glami-ml.com/bar', false], // no subdomains allowed
+ // Make.com (subdomain optional)
+ ['https://make.com', true],
+ ['https://foo.make.com/bar', true],
+ ['https://www.make.com/oauth/cb/mcp', true],
+ // Devin (exact domain only)
+ ['https://api.devin.ai/callback', true],
+ ['https://api.devin.ai', true],
+ ['https://devin.ai/callback', false], // must be api.devin.ai
+ ['https://foo.api.devin.ai/callback', false], // no subdomains
+ // Onyx (no subdomains allowed)
+ ['https://cloud.onyx.app', true],
+ ['https://cloud.onyx.app/mcp/oauth/callback', true],
+ ['https://foo.cloud.onyx.app/bar', false], // no subdomains allowed
+ ['https://onyx.app/callback', false], // must be cloud.onyx.app
+ // Azure APIM (no subdomains allowed)
+ ['https://global.consent.azure-apim.net', true],
+ ['https://global.consent.azure-apim.net/oauth/callback', true],
+ ['https://foo.global.consent.azure-apim.net/bar', false], // no subdomains allowed
+ // n8n at Groupon (no subdomains allowed)
+ ['https://n8n.groupondev.com', true],
+ ['https://n8n.groupondev.com/rest/oauth2-credential/callback', true],
+ ['https://n8n-business.groupondev.com', true],
+ ['https://n8n-business.groupondev.com/rest/oauth2-credential/callback', true],
+ ['https://n8n-merchant.groupondev.com', true],
+ ['https://n8n-merchant.groupondev.com/rest/oauth2-credential/callback', true],
+ ['https://n8n-llm-traffic.groupondev.com', true],
+ ['https://n8n-llm-traffic.groupondev.com/rest/oauth2-credential/callback', true],
+ ['https://n8n-finance.groupondev.com', true],
+ ['https://n8n-finance.groupondev.com/rest/oauth2-credential/callback', true],
+ ['https://n8n-playground.groupondev.com', true],
+ ['https://n8n-playground.groupondev.com/rest/oauth2-credential/callback', true],
+ ['https://n8n-staging.groupondev.com', true],
+ ['https://n8n-staging.groupondev.com/rest/oauth2-credential/callback', true],
+ ['https://foo.n8n-playground.groupondev.com/bar', false], // no subdomains allowed
+ ['https://n8n-unknown.groupondev.com', false], // not whitelisted
+ // Unknown HTTPS domains should be rejected
+ ['https://foo.bar.com/callback', false],
+ ['https://evil.com/callback', false],
+ ['https://fakechatgpt.com/callback', false],
+ ['https://evilclaude.ai/callback', false],
+ // === Cursor scheme - specific hosts only ===
+ ['cursor://anysphere.cursor-retrieval/oauth/user-keboola-Data_warehouse/callback', true],
+ ['cursor://anysphere.cursor-mcp/oauth/callback', true],
+ ['cursor://anysphere.cursor-mcp/some/path', true],
+ // Cursor with unknown hosts should be rejected
+ ['cursor://evil.com/callback', false],
+ ['cursor://localhost/callback', false],
+ ['cursor://anysphere.cursor-other/callback', false],
+ // === Unknown/forbidden schemes should be rejected ===
+ ['ftp://foo.bar.com', false],
+ ['file:///etc/passwd', false],
+ ['javascript://alert(1)', false],
+ ['data://text/html,', false],
+ // Custom schemes that are NOT whitelisted should be rejected
+ ['vscode://localhost/callback', false],
+ ['jetbrains://localhost/callback', false],
+ ['zed://localhost/callback', false],
+ ['myapp://localhost/callback', false],
+ ['evil://localhost/callback', false],
+ // === Edge cases ===
+ [null, false], // no redirect_uri
+ ];
+
+ it.each(cases)('%s -> %s', (uri, valid) => {
+ if (valid) {
+ expect(validateRedirectUri(uri)).toBe(uri);
+ } else {
+ expect(() => validateRedirectUri(uri)).toThrow(InvalidRedirectUriError);
+ }
+ });
+ });
+
+ describe('encode/decode', () => {
+ it('round-trips an arbitrary payload through the signed gzip JWS', async () => {
+ const provider = newProvider();
+ const payload = { hello: 'world', nested: { n: [1, 2, 3] } };
+ const encoded = await provider.encode(payload);
+ expect(await provider.decode(encoded)).toEqual(payload);
+ });
+
+ it('rejects a token signed with a different key', async () => {
+ const provider = newProvider();
+ const encoded = await provider.encode({ a: 1 }, 'other-key');
+ await expect(provider.decode(encoded)).rejects.toThrow();
+ });
+ });
+
+ describe('createSapiToken', () => {
+ it('POSTs to the Storage API tokens endpoint and returns the new token', async () => {
+ let body: Record = {};
+ let auth: string | null = null;
+ server.use(
+ http.post('https://sapi/v2/storage/tokens', async ({ request }) => {
+ auth = request.headers.get('authorization');
+ body = (await request.json()) as Record;
+ return HttpResponse.json({ token: 'new-sapi-token' });
+ }),
+ );
+
+ const provider = newProvider();
+ const token = await provider.createSapiToken('oauth-at', 7200);
+ expect(token).toBe('new-sapi-token');
+ expect(auth).toBe('Bearer oauth-at');
+ expect(body).toMatchObject({
+ expiresIn: 7200,
+ canReadAllFileUploads: true,
+ canManageBuckets: true,
+ });
+ });
+ });
+
+ describe('exchangeAuthorizationCode', () => {
+ it('mints proxy access/refresh tokens carrying a SAPI token', async () => {
+ server.use(
+ http.post('https://sapi/v2/storage/tokens', () =>
+ HttpResponse.json({ token: 'sapi-from-exchange' }),
+ ),
+ );
+
+ const provider = newProvider();
+ const code = authorizationCode({ scopes: ['email'] });
+ code.oauth_access_token = accessToken({ expires_at: Date.now() / 1000 + 3600 });
+ code.oauth_refresh_token = refreshToken({ expires_at: Date.now() / 1000 + 168 * 3600 });
+
+ const result = await provider.exchangeAuthorizationCode('claude', code);
+ expect(result.token_type).toBe('Bearer');
+ expect(result.scope).toBe('email');
+ expect(result.expires_in).toBeGreaterThan(0);
+
+ const decodedAccess = await provider.decode(result.access_token);
+ expect(decodedAccess['sapi_token']).toBe('sapi-from-exchange');
+ expect(decodedAccess['client_id']).toBe('claude');
+ expect((decodedAccess['token'] as string).startsWith('mcp_')).toBe(true);
+ });
+ });
+});
diff --git a/__tests__/preview.test.ts b/__tests__/preview.test.ts
new file mode 100644
index 000000000..b858390c7
--- /dev/null
+++ b/__tests__/preview.test.ts
@@ -0,0 +1,376 @@
+import { serve } from '@hono/node-server';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import type { AddressInfo } from 'node:net';
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+import { Config } from '@/config';
+import { runPreviewConfigDiff } from '@/preview';
+import { createHttpApp } from '@/transports/http';
+
+// ---------------------------------------------------------------------------
+// HTTP mocks (msw). The preview path issues: tokens/verify, configuration_detail,
+// component fetch (ai + storage). Handlers are registered per-test via configure().
+// ---------------------------------------------------------------------------
+
+const msw = setupServer();
+beforeAll(() =>
+ msw.listen({
+ onUnhandledRequest: (request, print) => {
+ // Let the real Hono test server (localhost) through; error on anything else.
+ if (new URL(request.url).hostname === 'localhost') return;
+ print.error();
+ },
+ }),
+);
+afterEach(() => msw.resetHandlers());
+afterAll(() => msw.close());
+
+const baseConfig = (over: Record = {}) =>
+ new Config({ storageApiUrl: 'https://connection.test', storageToken: 'tok', ...over });
+
+/** A loosely-typed config record shape for asserting on nested diff fields. */
+type Cfg = {
+ name?: string;
+ description?: string;
+ changeDescription?: string;
+ configuration: { parameters: Record };
+};
+const asCfg = (value: unknown): Cfg => value as Cfg;
+
+type ConfigureOpts = {
+ role?: string;
+ features?: string[];
+ config?: Record | (() => never);
+ component?: Record;
+};
+
+/**
+ * Registers msw handlers for the storage + ai services. `config` is the value returned
+ * by configuration_detail / configuration_row_detail; pass a function to make it throw.
+ */
+const configure = (opts: ConfigureOpts = {}) => {
+ const role = opts.role ?? 'admin';
+ const features = opts.features ?? [];
+ const component = opts.component ?? {
+ id: 'keboola.ex-test',
+ name: 'Test Extractor',
+ type: 'extractor',
+ configurationSchema: {},
+ flags: [],
+ };
+
+ msw.use(
+ http.get('https://connection.test/*', ({ request }) => {
+ const path = new URL(request.url).pathname;
+ if (path.endsWith('/tokens/verify')) {
+ return HttpResponse.json({ owner: { features }, admin: { role } });
+ }
+ if (path.includes('/components/') && path.includes('/configs/')) {
+ if (typeof opts.config === 'function') {
+ return HttpResponse.json({ error: 'Invalid configuration ID' }, { status: 404 });
+ }
+ return HttpResponse.json(opts.config ?? {});
+ }
+ if (path.includes('/components/')) {
+ return HttpResponse.json(component);
+ }
+ return undefined;
+ }),
+ // Component docs from the AI service — 404 so fetchComponent falls back to storage.
+ http.get('https://ai.test/*', () => HttpResponse.json({ error: 'nope' }, { status: 404 })),
+ );
+};
+
+// ---------------------------------------------------------------------------
+// runPreviewConfigDiff (logic) — port of tests/test_preview.py.
+// ---------------------------------------------------------------------------
+
+describe('runPreviewConfigDiff — authorization', () => {
+ it.each<[Record, number]>([
+ [{}, 200],
+ [{ allowedTools: 'update_config,get_tables' }, 200],
+ [{ allowedTools: 'get_tables,get_buckets' }, 403],
+ [{ disallowedTools: 'update_config' }, 403],
+ [{ readOnlyMode: 'true' }, 403],
+ ])('header auth %j -> %s', async (over, expectedStatus) => {
+ configure({ config: { id: 'config-123', name: 'C', configuration: { parameters: {} } } });
+ const rq = {
+ toolName: 'update_config',
+ toolParams: {
+ component_id: 'keboola.ex-test',
+ configuration_id: 'config-123',
+ change_description: 'Test change',
+ },
+ };
+ if (expectedStatus === 200) {
+ const resp = await runPreviewConfigDiff(baseConfig(over), rq);
+ expect(resp.isValid).toBe(true);
+ } else {
+ await expect(runPreviewConfigDiff(baseConfig(over), rq)).rejects.toMatchObject({
+ status: 403,
+ message: expect.stringContaining('not authorized'),
+ });
+ }
+ });
+
+ it.each<[string, string, string | undefined, string]>([
+ ['update_config', 'readOnly', undefined, 'read-only operations'],
+ ['modify_streamlit_data_app', 'admin', 'dev-123', 'main production branch'],
+ ['update_flow', 'admin', undefined, 'admin/OAuth'],
+ ])('project/role/branch gate: %s role=%s -> %s', async (toolName, role, branchId, fragment) => {
+ configure({ role });
+ const cfg = baseConfig(branchId ? { branchId } : {});
+ await expect(
+ runPreviewConfigDiff(cfg, { toolName, toolParams: { configuration_id: 'cfg-1' } }),
+ ).rejects.toMatchObject({ status: 403, message: expect.stringContaining(fragment) });
+ });
+});
+
+describe('runPreviewConfigDiff — update_config diff', () => {
+ const originalConfigData = {
+ id: 'config-123',
+ name: 'Original Config Name',
+ description: 'Original description',
+ configuration: { parameters: { foo: 'bar', baz: 42 } },
+ };
+
+ it('previews update_config with parameter updates, name and description', async () => {
+ configure({ config: structuredClone(originalConfigData) });
+ const resp = await runPreviewConfigDiff(baseConfig(), {
+ toolName: 'update_config',
+ toolParams: {
+ component_id: 'keboola.ex-test',
+ configuration_id: 'config-123',
+ change_description: 'Test change',
+ name: 'Updated Config Name',
+ description: 'Updated description',
+ parameter_updates: [
+ { op: 'set', path: 'foo', value: 'updated_bar' },
+ { op: 'set', path: 'new_param', value: 'new_value' },
+ ],
+ },
+ });
+
+ expect(resp.coordinates).toMatchObject({
+ componentId: 'keboola.ex-test',
+ configurationId: 'config-123',
+ });
+ expect(resp.coordinates).not.toHaveProperty('configurationRowId');
+ expect(resp.isValid).toBe(true);
+ expect(resp).not.toHaveProperty('validationErrors');
+
+ const original = asCfg(resp.originalConfig);
+ const updated = asCfg(resp.updatedConfig);
+ expect(original.name).toBe('Original Config Name');
+ expect(original.configuration.parameters.foo).toBe('bar');
+ expect(updated.name).toBe('Updated Config Name');
+ expect(updated.description).toBe('Updated description');
+ expect(updated.configuration.parameters.foo).toBe('updated_bar');
+ expect(updated.configuration.parameters.new_param).toBe('new_value');
+ expect(updated.configuration.parameters.baz).toBe(42);
+ expect(updated.changeDescription).toBe('Test change');
+ });
+
+ it('returns isValid=false with empty configs when the mutator throws', async () => {
+ configure({ config: () => undefined as never });
+ const resp = await runPreviewConfigDiff(baseConfig(), {
+ toolName: 'update_config',
+ toolParams: {
+ component_id: 'keboola.ex-test',
+ configuration_id: 'invalid-config',
+ change_description: 'Test change',
+ },
+ });
+ expect(resp.isValid).toBe(false);
+ expect((resp.validationErrors as string[]).length).toBeGreaterThan(0);
+ expect(resp.originalConfig).toEqual({});
+ expect(resp.updatedConfig).toEqual({});
+ });
+
+ it('leaves name/description unchanged when only required params are given', async () => {
+ configure({
+ config: {
+ id: 'config-123',
+ name: 'Original Config',
+ description: 'Original description',
+ configuration: { parameters: { foo: 'bar' } },
+ },
+ });
+ const resp = await runPreviewConfigDiff(baseConfig(), {
+ toolName: 'update_config',
+ toolParams: {
+ component_id: 'keboola.ex-test',
+ configuration_id: 'config-123',
+ change_description: 'Test change',
+ },
+ });
+ const updated = asCfg(resp.updatedConfig);
+ expect(resp.isValid).toBe(true);
+ expect(updated.name).toBe('Original Config');
+ expect(updated.description).toBe('Original description');
+ expect(updated.configuration.parameters.foo).toBe('bar');
+ });
+});
+
+describe('runPreviewConfigDiff — update_config_row diff', () => {
+ it('previews update_config_row including the row coordinate', async () => {
+ configure({
+ config: {
+ id: 'row-456',
+ name: 'Original Row Name',
+ description: 'Original row description',
+ configuration: { parameters: { foo: 'bar', baz: 42 } },
+ },
+ });
+ const resp = await runPreviewConfigDiff(baseConfig(), {
+ toolName: 'update_config_row',
+ toolParams: {
+ component_id: 'keboola.ex-test',
+ configuration_id: 'config-123',
+ configuration_row_id: 'row-456',
+ change_description: 'Row change',
+ parameter_updates: [{ op: 'set', path: 'foo', value: 'updated_bar' }],
+ },
+ });
+ expect(resp.isValid).toBe(true);
+ expect(resp.coordinates).toMatchObject({
+ componentId: 'keboola.ex-test',
+ configurationId: 'config-123',
+ configurationRowId: 'row-456',
+ });
+ expect(asCfg(resp.updatedConfig).configuration.parameters.foo).toBe('updated_bar');
+ });
+});
+
+describe('runPreviewConfigDiff — schema validation', () => {
+ it('returns isValid=false for a missing required param', async () => {
+ configure();
+ const resp = await runPreviewConfigDiff(baseConfig(), {
+ toolName: 'update_config',
+ toolParams: { component_id: 'keboola.ex-test', change_description: 'Test' },
+ });
+ expect(resp.isValid).toBe(false);
+ expect(JSON.stringify(resp.validationErrors)).toContain('configuration_id');
+ expect(resp.originalConfig).toEqual({});
+ expect(resp.updatedConfig).toEqual({});
+ });
+
+ it('returns isValid=false for an invalid parameter_updates entry', async () => {
+ configure();
+ const resp = await runPreviewConfigDiff(baseConfig(), {
+ toolName: 'update_config',
+ toolParams: {
+ component_id: 'keboola.ex-test',
+ configuration_id: 'config-123',
+ change_description: 'Test change',
+ parameter_updates: [
+ { op: 'set', path: 'foo', value: 'x' },
+ { op: 'foo', path: 'bar', value: 'y' },
+ ],
+ },
+ });
+ expect(resp.isValid).toBe(false);
+ expect(JSON.stringify(resp.validationErrors)).toContain('parameter_updates');
+ });
+});
+
+describe('runPreviewConfigDiff — unsupported tools', () => {
+ it('rejects an unknown tool name with 400', async () => {
+ configure();
+ await expect(
+ runPreviewConfigDiff(baseConfig(), {
+ toolName: 'invalid_tool',
+ toolParams: { component_id: 'keboola.ex-test', configuration_id: 'config-123' },
+ }),
+ ).rejects.toMatchObject({ status: 400 });
+ });
+
+ it.each(['update_sql_transformation', 'update_flow', 'modify_streamlit_data_app'])(
+ 'returns 400 for the not-yet-diffable config tool %s',
+ async (toolName) => {
+ // Use a role/branch that clears the project/role/branch gate for each tool.
+ const role = toolName === 'update_flow' ? 'guest' : 'admin';
+ configure({ role });
+ const params: Record = {
+ configuration_id: 'cfg-1',
+ change_description: 'x',
+ };
+ if (toolName === 'update_flow') params.flow_type = 'keboola.orchestrator';
+ if (toolName === 'modify_streamlit_data_app') {
+ params.name = 'My App';
+ params.description = 'desc';
+ params.source_code = 'print(1)';
+ params.packages = ['streamlit'];
+ params.authentication_type = 'default';
+ }
+ await expect(
+ runPreviewConfigDiff(baseConfig(), { toolName, toolParams: params }),
+ ).rejects.toMatchObject({ status: 400, message: expect.stringContaining('not supported') });
+ },
+ );
+});
+
+// ---------------------------------------------------------------------------
+// Hono route — status codes + JSON body errors.
+// ---------------------------------------------------------------------------
+
+describe('POST /preview/configuration (Hono route)', () => {
+ let httpServer: ReturnType;
+ let port: number;
+
+ beforeAll(async () => {
+ const app = createHttpApp(baseConfig());
+ port = await new Promise((resolve) => {
+ httpServer = serve({ fetch: app.fetch, port: 0 }, (info: AddressInfo) => resolve(info.port));
+ });
+ });
+ afterAll(() => httpServer.close());
+
+ const post = (body: unknown, headers: Record = {}) =>
+ fetch(`http://localhost:${port}/preview/configuration`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', ...headers },
+ body: typeof body === 'string' ? body : JSON.stringify(body),
+ });
+
+ it('returns 400 for malformed JSON', async () => {
+ const res = await post('{ not json');
+ expect(res.status).toBe(400);
+ expect(((await res.json()) as { message: string }).message).toContain('Invalid JSON');
+ });
+
+ it('returns 400 for a missing toolName', async () => {
+ const res = await post({ toolParams: {} });
+ expect(res.status).toBe(400);
+ });
+
+ it('returns 200 with a diff for a valid request', async () => {
+ configure({ config: { id: 'config-123', name: 'C', configuration: { parameters: {} } } });
+ const res = await post({
+ toolName: 'update_config',
+ toolParams: {
+ component_id: 'keboola.ex-test',
+ configuration_id: 'config-123',
+ change_description: 'Test change',
+ },
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { isValid: boolean; coordinates: { componentId: string } };
+ expect(body.isValid).toBe(true);
+ expect(body.coordinates.componentId).toBe('keboola.ex-test');
+ });
+
+ it('returns 403 when header authorization denies the tool', async () => {
+ configure();
+ const res = await post(
+ {
+ toolName: 'update_config',
+ toolParams: { component_id: 'keboola.ex-test', configuration_id: 'config-123' },
+ },
+ { 'X-Disallowed-Tools': 'update_config' },
+ );
+ expect(res.status).toBe(403);
+ expect(((await res.json()) as { message: string }).message).toContain('not authorized');
+ });
+});
diff --git a/__tests__/prompts.test.ts b/__tests__/prompts.test.ts
new file mode 100644
index 000000000..279507c00
--- /dev/null
+++ b/__tests__/prompts.test.ts
@@ -0,0 +1,44 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
+import { describe, expect, it } from 'vitest';
+
+import { Config } from '@/config';
+import { createServer } from '@/server';
+
+const config = new Config({ storageApiUrl: 'https://connection.test', storageToken: 'tok' });
+
+const connect = async () => {
+ const [clientT, serverT] = InMemoryTransport.createLinkedPair();
+ await createServer(config).connect(serverT);
+ const client = new Client({ name: 'test', version: '0.0.0' });
+ await client.connect(clientT);
+ return client;
+};
+
+describe('prompts', () => {
+ it('registers the six one-click Keboola prompts', async () => {
+ const client = await connect();
+ const { prompts } = await client.listPrompts();
+ const names = prompts.map((p) => p.name).sort();
+ expect(names).toEqual(
+ [
+ 'analyze_project_structure',
+ 'component_usage_summary',
+ 'create_project_documentation',
+ 'data_quality_assessment',
+ 'error_analysis_report',
+ 'project_health_check',
+ ].sort(),
+ );
+ await client.close();
+ });
+
+ it('returns a single user message for a prompt', async () => {
+ const client = await connect();
+ const result = await client.getPrompt({ name: 'project_health_check' });
+ expect(result.messages).toHaveLength(1);
+ expect(result.messages[0]!.role).toBe('user');
+ expect((result.messages[0]!.content as { text: string }).text).toContain('health check');
+ await client.close();
+ });
+});
diff --git a/__tests__/retry.test.ts b/__tests__/retry.test.ts
new file mode 100644
index 000000000..b469f2a6f
--- /dev/null
+++ b/__tests__/retry.test.ts
@@ -0,0 +1,65 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { createRetryMiddleware } from '@/clients/retry';
+
+// A thrown error shaped like the api-client ApiError ({ response: Response }).
+const apiError = (status: number): Error =>
+ Object.assign(new Error(`HTTP ${status}`), { response: { status } });
+
+const okResponse = { response: { status: 200 }, data: {} };
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe('createRetryMiddleware', () => {
+ it('retries a 500 then returns the eventual success', async () => {
+ vi.useFakeTimers();
+ let calls = 0;
+ const next = vi.fn(async () => {
+ calls += 1;
+ if (calls <= 2) throw apiError(500);
+ return okResponse;
+ });
+
+ const run = createRetryMiddleware(3)(next as never)({} as never);
+ await vi.runAllTimersAsync();
+ const result = await run;
+
+ expect(calls).toBe(3); // two 500s + one success
+ expect(result).toBe(okResponse);
+ });
+
+ it('does not retry a non-retryable status (400)', async () => {
+ const next = vi.fn(async () => {
+ throw apiError(400);
+ });
+ await expect(createRetryMiddleware(3)(next as never)({} as never)).rejects.toThrow('HTTP 400');
+ expect(next).toHaveBeenCalledTimes(1);
+ });
+
+ it('gives up after maxRetries and rethrows the last error', async () => {
+ vi.useFakeTimers();
+ const next = vi.fn(async () => {
+ throw apiError(503);
+ });
+ const run = createRetryMiddleware(2)(next as never)({} as never);
+ const assertion = expect(run).rejects.toThrow('HTTP 503');
+ await vi.runAllTimersAsync();
+ await assertion;
+ expect(next).toHaveBeenCalledTimes(3); // initial + 2 retries
+ });
+
+ it('retries when a retryable status is returned (not thrown)', async () => {
+ vi.useFakeTimers();
+ let calls = 0;
+ const next = vi.fn(async () => {
+ calls += 1;
+ return calls === 1 ? { response: { status: 502 }, data: {} } : okResponse;
+ });
+ const run = createRetryMiddleware(3)(next as never)({} as never);
+ await vi.runAllTimersAsync();
+ expect(await run).toBe(okResponse);
+ expect(calls).toBe(2);
+ });
+});
diff --git a/__tests__/serialize.test.ts b/__tests__/serialize.test.ts
new file mode 100644
index 000000000..8a02706dc
--- /dev/null
+++ b/__tests__/serialize.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest';
+
+import { filterToonNulls, toonSerialize, toonSerializeCompact } from '@/serialize';
+
+describe('filterToonNulls', () => {
+ it('drops null/undefined keys from a plain object', () => {
+ expect(filterToonNulls({ a: 1, b: null, c: undefined, d: 'x' })).toEqual({ a: 1, d: 'x' });
+ });
+
+ it('drops null keys from a single-item object list', () => {
+ expect(filterToonNulls([{ a: 1, b: null }])).toEqual([{ a: 1 }]);
+ });
+
+ it('keeps union of value-bearing keys across a multi-item list, aligned with null', () => {
+ const input = [
+ { id: 1, name: 'a', extra: null },
+ { id: 2, name: null, extra: 'y' },
+ ];
+ // `name` and `extra` each have a value in some row, so both columns survive,
+ // with null where a given row lacks the value.
+ expect(filterToonNulls(input)).toEqual([
+ { id: 1, name: 'a', extra: null },
+ { id: 2, name: null, extra: 'y' },
+ ]);
+ });
+
+ it('recurses into nested objects', () => {
+ expect(filterToonNulls({ a: { b: null, c: 2 } })).toEqual({ a: { c: 2 } });
+ });
+});
+
+describe('toon serializers', () => {
+ it('encodes an object list as an aligned TOON table', () => {
+ const text = toonSerialize([
+ { id: 'a', name: 'x' },
+ { id: 'b', name: 'y' },
+ ]);
+ expect(text).toContain('[2]{id,name}');
+ expect(text).toContain('a,x');
+ });
+
+ it('compact form omits all-null columns', () => {
+ const text = toonSerializeCompact({ a: 1, b: null });
+ expect(text).toContain('a: 1');
+ expect(text).not.toContain('b:');
+ });
+});
diff --git a/__tests__/server.test.ts b/__tests__/server.test.ts
new file mode 100644
index 000000000..1b8bec5e5
--- /dev/null
+++ b/__tests__/server.test.ts
@@ -0,0 +1,39 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
+import { describe, expect, it } from 'vitest';
+
+import { Config } from '@/config';
+import { createServer, SERVER_NAME } from '@/server';
+
+const connect = async (config: Config) => {
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+ const server = createServer(config);
+ await server.connect(serverTransport);
+
+ const client = new Client({ name: 'test-client', version: '0.0.0' });
+ await client.connect(clientTransport);
+ return client;
+};
+
+describe('MCP server', () => {
+ it('lists registered tools over an in-memory transport', async () => {
+ const client = await connect(new Config());
+ const { tools } = await client.listTools();
+
+ const names = tools.map((tool) => tool.name);
+ expect(names).toContain('get_server_info');
+ // Every tool must expose an input schema (drives the tool-filtering / docs).
+ expect(tools.every((tool) => tool.inputSchema?.type === 'object')).toBe(true);
+ });
+
+ it('runs a tool and returns the server name', async () => {
+ const client = await connect(new Config({ branchId: '123' }));
+ const result = await client.callTool({ name: 'get_server_info', arguments: {} });
+
+ // Output is TOON, not JSON: a flat object renders as `key: value` lines.
+ const content = result.content as { type: string; text: string }[];
+ const text = content[0]!.text;
+ expect(text).toContain(`name: ${SERVER_NAME}`);
+ expect(text).toContain('branchId: "123"');
+ });
+});
diff --git a/__tests__/testproject.test.ts b/__tests__/testproject.test.ts
new file mode 100644
index 000000000..f64601803
--- /dev/null
+++ b/__tests__/testproject.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it } from 'vitest';
+
+import type { Locker } from '../integtests/testproject/locker';
+import { createPool } from '../integtests/testproject/pool';
+import { parseProjects } from '../integtests/testproject/projects';
+import { isCompatible, lockKey, type ProjectDefinition } from '../integtests/testproject/types';
+
+const def = (over: Partial = {}): ProjectDefinition => ({
+ host: 'connection.keboola.com',
+ project: 1,
+ token: 'tok',
+ backend: 'snowflake',
+ stagingStorage: 's3',
+ legacyTransformation: false,
+ isGuest: false,
+ ...over,
+});
+
+/** In-memory locker for deterministic pool tests (no redis/fs). */
+const fakeLocker = (): { locker: Locker; held: Set } => {
+ const held = new Set();
+ return {
+ held,
+ locker: {
+ forProject: (d) => ({
+ tryLock: async () => {
+ const k = lockKey(d);
+ if (held.has(k)) return null;
+ held.add(k);
+ return async () => {
+ held.delete(k);
+ };
+ },
+ }),
+ close: async () => {},
+ },
+ };
+};
+
+describe('parseProjects', () => {
+ it('parses the array form and applies defaults', () => {
+ const defs = parseProjects(
+ JSON.stringify([
+ {
+ host: 'connection.keboola.com',
+ project: 5684,
+ token: 't',
+ backend: 'bigquery',
+ stagingStorage: 'gcs',
+ },
+ ]),
+ );
+ expect(defs).toHaveLength(1);
+ expect(defs[0]!.project).toBe(5684);
+ expect(defs[0]!.legacyTransformation).toBe(false);
+ expect(defs[0]!.isGuest).toBe(false);
+ });
+
+ it('rejects an empty array', () => {
+ expect(() => parseProjects('[]')).toThrow(/non-empty array/);
+ });
+
+ it('rejects an entry missing a required field', () => {
+ expect(() =>
+ parseProjects(
+ JSON.stringify([{ host: 'h', project: 1, backend: 'snowflake', stagingStorage: 's3' }]),
+ ),
+ ).toThrow(/\[0\] is invalid/);
+ });
+});
+
+describe('isCompatible', () => {
+ it('matches any backend when none requested, else exact', () => {
+ expect(isCompatible(def({ backend: 'snowflake' }), {})).toBe(true);
+ expect(isCompatible(def({ backend: 'snowflake' }), { backend: 'snowflake' })).toBe(true);
+ expect(isCompatible(def({ backend: 'snowflake' }), { backend: 'bigquery' })).toBe(false);
+ });
+});
+
+describe('createPool.getTestProject', () => {
+ it('leases a free project and exposes its URL/token', async () => {
+ const { locker, held } = fakeLocker();
+ const pool = createPool([def({ project: 1 })], locker);
+ const p = await pool.getTestProject();
+ expect(p.storageApiUrl).toBe('https://connection.keboola.com');
+ expect(p.storageApiToken).toBe('tok');
+ expect(held.size).toBe(1);
+ await p.release();
+ expect(held.size).toBe(0);
+ });
+
+ it('honors the backend selector', async () => {
+ const { locker } = fakeLocker();
+ const pool = createPool(
+ [def({ project: 1, backend: 'snowflake' }), def({ project: 2, backend: 'bigquery' })],
+ locker,
+ );
+ const p = await pool.getTestProject({ backend: 'bigquery' });
+ expect(p.definition.project).toBe(2);
+ });
+
+ it('throws when no compatible project exists', async () => {
+ const { locker } = fakeLocker();
+ const pool = createPool([def({ backend: 'snowflake' })], locker);
+ await expect(pool.getTestProject({ backend: 'bigquery' })).rejects.toThrow(/No compatible/);
+ });
+
+ it('retries (does not error) until a busy project frees up', async () => {
+ const { locker, held } = fakeLocker();
+ const d = def({ project: 7 });
+ held.add(lockKey(d)); // pre-hold the only project
+ const pool = createPool([d], locker);
+
+ const acquired = pool.getTestProject();
+ // Free it shortly after; the pool should keep retrying and then resolve.
+ setTimeout(() => held.delete(lockKey(d)), 250);
+
+ const p = await acquired;
+ expect(p.definition.project).toBe(7);
+ });
+});
diff --git a/__tests__/tools.components.test.ts b/__tests__/tools.components.test.ts
new file mode 100644
index 000000000..3cf33a06f
--- /dev/null
+++ b/__tests__/tools.components.test.ts
@@ -0,0 +1,645 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+import { Config } from '@/config';
+import { createServer } from '@/server';
+import {
+ cleanBucketName,
+ createTransformationConfiguration,
+ joinSqlStatements,
+ splitSqlStatements,
+ updateParams,
+ updateTransformationParameters,
+} from '@/tools/components';
+import {
+ __testing,
+ validateRootParametersConfiguration,
+ validateRootStorageConfiguration,
+} from '@/tools/validation';
+
+const server = setupServer();
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+const config = new Config({ storageApiUrl: 'https://connection.test', storageToken: 'tok' });
+
+const callTool = async (name: string, args: Record) => {
+ const [clientT, serverT] = InMemoryTransport.createLinkedPair();
+ await createServer(config).connect(serverT);
+ const client = new Client({ name: 't', version: '0' });
+ await client.connect(clientT);
+ const result = await client.callTool({ name, arguments: args });
+ const text = (result.content as { text: string }[])[0]!.text;
+ await client.close();
+ return { text, isError: result.isError };
+};
+
+describe('get_config_examples', () => {
+ it('renders root and row configuration examples as markdown', async () => {
+ server.use(
+ http.get('https://ai.test/*', ({ request }) => {
+ expect(new URL(request.url).pathname).toBe('/docs/components/keboola.ex-aws-s3');
+ return HttpResponse.json({
+ rootConfigurationExamples: [{ foo: 'bar' }],
+ rowConfigurationExamples: [{ baz: 1 }],
+ });
+ }),
+ );
+
+ const { text } = await callTool('get_config_examples', { component_id: 'keboola.ex-aws-s3' });
+ expect(text).toContain('# Configuration Examples for `keboola.ex-aws-s3`');
+ expect(text).toContain('## Root Configuration Examples');
+ expect(text).toContain('"foo": "bar"');
+ expect(text).toContain('## Row Configuration Examples');
+ });
+
+ it('returns an empty string when the component lookup fails', async () => {
+ server.use(http.get('https://ai.test/*', () => new HttpResponse(null, { status: 404 })));
+ const { text, isError } = await callTool('get_config_examples', { component_id: 'nope' });
+ expect(isError).toBeFalsy();
+ expect(text).toBe('');
+ });
+});
+
+const verify = () =>
+ http.get('https://connection.test/*', ({ request }) => {
+ const { pathname } = new URL(request.url);
+ if (pathname.endsWith('/tokens/verify')) return HttpResponse.json({ owner: { id: '42' } });
+ return undefined;
+ });
+
+describe('get_components', () => {
+ it('merges AI catalog metadata with Storage data and derives capabilities', async () => {
+ server.use(
+ verify(),
+ http.get('https://ai.test/*', () =>
+ HttpResponse.json({
+ id: 'keboola.ex-aws-s3',
+ name: 'AWS S3',
+ type: 'extractor',
+ flags: ['genericDockerUI-rows', 'genericDockerUI-tableOutput'],
+ documentation: 'docs here',
+ }),
+ ),
+ http.get('https://connection.test/*', ({ request }) => {
+ const { pathname } = new URL(request.url);
+ if (pathname.endsWith('/tokens/verify')) return HttpResponse.json({ owner: { id: '42' } });
+ if (pathname.includes('/components/')) {
+ return HttpResponse.json({ data: { synchronous_actions: ['testConnection'] } });
+ }
+ return undefined;
+ }),
+ );
+
+ const { text } = await callTool('get_components', { component_ids: ['keboola.ex-aws-s3'] });
+ expect(text).toContain('AWS S3');
+ expect(text).toContain('is_row_based: true');
+ expect(text).toContain('testConnection');
+ expect(text).toContain('docs here');
+ });
+
+ it('falls back to the Storage API when the AI catalog returns 404', async () => {
+ server.use(
+ verify(),
+ http.get('https://ai.test/*', () => new HttpResponse(null, { status: 404 })),
+ http.get('https://connection.test/*', ({ request }) => {
+ const { pathname } = new URL(request.url);
+ if (pathname.endsWith('/tokens/verify')) return HttpResponse.json({ owner: { id: '42' } });
+ return HttpResponse.json({ id: 'priv.comp', name: 'Private', type: 'writer', flags: [] });
+ }),
+ );
+
+ const { text } = await callTool('get_components', { component_ids: ['priv.comp'] });
+ expect(text).toContain('Private');
+ });
+});
+
+describe('get_configs', () => {
+ it('lists configs by component id grouped under the component', async () => {
+ server.use(
+ verify(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const { pathname } = new URL(request.url);
+ if (pathname.endsWith('/tokens/verify')) return HttpResponse.json({ owner: { id: '42' } });
+ if (pathname.endsWith('/components/keboola.ex-db-mysql/configs')) {
+ return HttpResponse.json([{ id: '100', name: 'My DB', isDisabled: false }]);
+ }
+ if (pathname.endsWith('/components/keboola.ex-db-mysql')) {
+ return HttpResponse.json({
+ id: 'keboola.ex-db-mysql',
+ name: 'MySQL',
+ type: 'extractor',
+ flags: [],
+ });
+ }
+ return undefined;
+ }),
+ );
+
+ const { text } = await callTool('get_configs', { component_ids: ['keboola.ex-db-mysql'] });
+ expect(text).toContain('My DB');
+ expect(text).toContain('MySQL');
+ });
+
+ it('returns full details with redacted secrets for specific configs', async () => {
+ server.use(
+ verify(),
+ http.get('https://ai.test/*', () => new HttpResponse(null, { status: 404 })),
+ http.get('https://connection.test/*', ({ request }) => {
+ const { pathname } = new URL(request.url);
+ if (pathname.endsWith('/tokens/verify')) return HttpResponse.json({ owner: { id: '42' } });
+ if (pathname.endsWith('/configs/100')) {
+ return HttpResponse.json({
+ id: '100',
+ name: 'My DB',
+ version: 3,
+ configuration: { parameters: { host: 'db', '#password': 'plaintext' } },
+ });
+ }
+ if (pathname.endsWith('/components/keboola.ex-db-mysql')) {
+ return HttpResponse.json({
+ id: 'keboola.ex-db-mysql',
+ name: 'MySQL',
+ type: 'extractor',
+ flags: [],
+ });
+ }
+ return undefined;
+ }),
+ );
+
+ const { text } = await callTool('get_configs', {
+ configs: [{ component_id: 'keboola.ex-db-mysql', configuration_id: '100' }],
+ });
+ expect(text).toContain('[REDACTED]');
+ expect(text).not.toContain('plaintext');
+ expect(text).toContain('host');
+ });
+});
+
+describe('run_sync_action', () => {
+ it('merges row config over root and posts to the sync-actions endpoint', async () => {
+ let actionBody: { configData?: Record; action?: string } | undefined;
+ server.use(
+ http.get('https://connection.test/*', ({ request }) => {
+ const { pathname } = new URL(request.url);
+ if (pathname.endsWith('/configs/cfg/rows/r1')) {
+ return HttpResponse.json({
+ configuration: { parameters: { row: 1 }, storage: { input: 'r' } },
+ });
+ }
+ if (pathname.endsWith('/configs/cfg')) {
+ return HttpResponse.json({
+ configuration: {
+ parameters: { base: 1 },
+ storage: {},
+ authorization: { oauth_api: { id: 'a' } },
+ },
+ });
+ }
+ return undefined;
+ }),
+ http.post('https://sync-actions.test/*', async ({ request }) => {
+ expect(new URL(request.url).pathname).toBe('/actions');
+ actionBody = (await request.json()) as {
+ configData?: Record;
+ action?: string;
+ };
+ return HttpResponse.json({ status: 'success', tables: [] });
+ }),
+ );
+
+ const { text } = await callTool('run_sync_action', {
+ action_name: 'getTables',
+ component_id: 'keboola.ex-db-mysql',
+ configuration_id: 'cfg',
+ configuration_row_id: 'r1',
+ });
+
+ expect(actionBody?.action).toBe('getTables');
+ // row parameters merged on top of root; authorization carried from root.
+ expect(actionBody?.configData).toMatchObject({
+ parameters: { base: 1, row: 1 },
+ authorization: { oauth_api: { id: 'a' } },
+ });
+ expect(text).toContain('success');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// WRITE TOOLS
+// ---------------------------------------------------------------------------
+
+/** AI catalog 404 so fetchComponent falls back to Storage component_detail. */
+const aiNotFound = () =>
+ http.get('https://ai.test/*', () => new HttpResponse(null, { status: 404 }));
+
+describe('create_config', () => {
+ it('validates, creates the config, and stamps creation metadata', async () => {
+ let createBody: Record | undefined;
+ let metadataBody: Record | undefined;
+ server.use(
+ verify(),
+ aiNotFound(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const { pathname } = new URL(request.url);
+ if (pathname.endsWith('/tokens/verify')) return HttpResponse.json({ owner: { id: '42' } });
+ if (pathname.endsWith('/components/keboola.ex-generic')) {
+ return HttpResponse.json({
+ id: 'keboola.ex-generic',
+ name: 'Generic',
+ type: 'extractor',
+ flags: [],
+ configurationSchema: {
+ type: 'object',
+ required: ['host'],
+ properties: { host: { type: 'string' } },
+ },
+ });
+ }
+ return undefined;
+ }),
+ http.post('https://connection.test/*', async ({ request }) => {
+ const { pathname } = new URL(request.url);
+ if (pathname.endsWith('/configs')) {
+ createBody = (await request.json()) as Record;
+ return HttpResponse.json({ id: '555', version: 1 });
+ }
+ if (pathname.endsWith('/metadata')) {
+ metadataBody = (await request.json()) as Record;
+ return HttpResponse.json([]);
+ }
+ return undefined;
+ }),
+ );
+
+ const { text, isError } = await callTool('create_config', {
+ name: 'My config',
+ description: 'desc',
+ component_id: 'keboola.ex-generic',
+ parameters: { host: 'db.example.com' },
+ });
+
+ expect(isError).toBeFalsy();
+ expect(createBody?.name).toBe('My config');
+ expect((createBody?.configuration as Record).parameters).toMatchObject({
+ host: 'db.example.com',
+ });
+ // creation metadata is KBC.MCP.createdBy
+ expect(JSON.stringify(metadataBody)).toContain('KBC.MCP.createdBy');
+ expect(text).toContain('555');
+ expect(text).toContain('version: 1');
+ });
+
+ it('fails a schema-required violation with a recoverable hint', async () => {
+ server.use(
+ verify(),
+ aiNotFound(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const { pathname } = new URL(request.url);
+ if (pathname.endsWith('/tokens/verify')) return HttpResponse.json({ owner: { id: '42' } });
+ return HttpResponse.json({
+ id: 'keboola.ex-generic',
+ name: 'Generic',
+ type: 'extractor',
+ flags: [],
+ configurationSchema: { type: 'object', required: ['host'], properties: {} },
+ });
+ }),
+ );
+
+ const { text, isError } = await callTool('create_config', {
+ name: 'X',
+ description: 'd',
+ component_id: 'keboola.ex-generic',
+ parameters: {},
+ });
+
+ expect(isError).toBe(true);
+ expect(text).toContain('required property');
+ expect(text).toContain('HINT: Ensure ALL of the following required fields');
+ });
+
+ it('refuses suitable-only components (SQL transformation)', async () => {
+ const { text, isError } = await callTool('create_config', {
+ name: 'X',
+ description: 'd',
+ component_id: 'keboola.snowflake-transformation',
+ parameters: {},
+ });
+ expect(isError).toBe(true);
+ expect(text).toContain('cannot be used with keboola.snowflake-transformation');
+ });
+});
+
+describe('update_config', () => {
+ it('applies a parameter diff over the existing config and bumps metadata', async () => {
+ let putBody: Record | undefined;
+ server.use(
+ verify(),
+ aiNotFound(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const { pathname } = new URL(request.url);
+ if (pathname.endsWith('/tokens/verify')) return HttpResponse.json({ owner: { id: '42' } });
+ if (pathname.endsWith('/configs/100')) {
+ return HttpResponse.json({
+ id: '100',
+ name: 'Cfg',
+ version: 3,
+ configuration: { parameters: { host: 'old', port: 5432 }, storage: {} },
+ });
+ }
+ if (pathname.endsWith('/components/keboola.ex-db-mysql')) {
+ return HttpResponse.json({
+ id: 'keboola.ex-db-mysql',
+ name: 'MySQL',
+ type: 'extractor',
+ flags: [],
+ });
+ }
+ return undefined;
+ }),
+ http.put('https://connection.test/*', async ({ request }) => {
+ putBody = (await request.json()) as Record;
+ return HttpResponse.json({ id: '100', name: 'Cfg', version: 4, description: 'd' });
+ }),
+ http.post('https://connection.test/*', () => HttpResponse.json([])),
+ );
+
+ const { text, isError } = await callTool('update_config', {
+ change_description: 'switch host',
+ component_id: 'keboola.ex-db-mysql',
+ configuration_id: '100',
+ parameter_updates: [{ op: 'set', path: 'host', value: 'new' }],
+ });
+
+ expect(isError).toBeFalsy();
+ const cfg = putBody?.configuration as Record;
+ expect(cfg.parameters).toMatchObject({ host: 'new', port: 5432 }); // diff preserved port
+ expect(putBody?.changeDescription).toBe('switch host');
+ expect(text).toContain('version: 4');
+ });
+});
+
+describe('update_config_row', () => {
+ it('updates a row with str_replace diff and is_disabled', async () => {
+ let putBody: Record | undefined;
+ server.use(
+ verify(),
+ aiNotFound(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const { pathname } = new URL(request.url);
+ if (pathname.endsWith('/tokens/verify')) return HttpResponse.json({ owner: { id: '42' } });
+ if (pathname.endsWith('/rows/r1')) {
+ return HttpResponse.json({
+ id: 'r1',
+ configuration: { parameters: { table: 'old_name' } },
+ });
+ }
+ if (pathname.endsWith('/components/keboola.ex-db-mysql')) {
+ return HttpResponse.json({
+ id: 'keboola.ex-db-mysql',
+ name: 'M',
+ type: 'extractor',
+ flags: [],
+ });
+ }
+ return undefined;
+ }),
+ http.put('https://connection.test/*', async ({ request }) => {
+ putBody = (await request.json()) as Record;
+ return HttpResponse.json({ id: 'r1', version: 2 });
+ }),
+ http.post('https://connection.test/*', () => HttpResponse.json([])),
+ );
+
+ const { isError } = await callTool('update_config_row', {
+ change_description: 'rename table',
+ component_id: 'keboola.ex-db-mysql',
+ configuration_id: '100',
+ configuration_row_id: 'r1',
+ parameter_updates: [
+ { op: 'str_replace', path: 'table', search_for: 'old', replace_with: 'new' },
+ ],
+ is_disabled: true,
+ });
+
+ expect(isError).toBeFalsy();
+ const cfg = putBody?.configuration as Record;
+ expect(cfg.parameters).toMatchObject({ table: 'new_name' });
+ expect(putBody?.isDisabled).toBe(true);
+ });
+});
+
+describe('create_sql_transformation', () => {
+ it('resolves dialect from token defaultBackend and builds the transformation payload', async () => {
+ let createBody: Record | undefined;
+ let createPath = '';
+ server.use(
+ aiNotFound(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const { pathname } = new URL(request.url);
+ if (pathname.endsWith('/tokens/verify')) {
+ return HttpResponse.json({ owner: { id: '42', defaultBackend: 'snowflake' } });
+ }
+ if (pathname.endsWith('/workspaces')) return HttpResponse.json([]);
+ // search/component-configurations folder lookup
+ if (pathname.includes('/search/component-configurations')) return HttpResponse.json([]);
+ if (pathname.endsWith('/configs')) return HttpResponse.json([]); // configuration_list for folder count
+ if (pathname.includes('/components/keboola.snowflake-transformation')) {
+ return HttpResponse.json({
+ id: 'keboola.snowflake-transformation',
+ name: 'Snowflake',
+ type: 'transformation',
+ flags: [],
+ });
+ }
+ return undefined;
+ }),
+ http.post('https://connection.test/*', async ({ request }) => {
+ const { pathname } = new URL(request.url);
+ if (pathname.endsWith('/configs')) {
+ createPath = pathname;
+ createBody = (await request.json()) as Record;
+ return HttpResponse.json({ id: 'tf1', version: 1 });
+ }
+ if (pathname.endsWith('/metadata')) return HttpResponse.json([]);
+ return undefined;
+ }),
+ );
+
+ const { text, isError } = await callTool('create_sql_transformation', {
+ name: 'My TF',
+ description: 'transform stuff',
+ sql_code_blocks: [{ name: 'step', script: 'CREATE TABLE out AS SELECT 1;' }],
+ created_table_names: ['out'],
+ });
+
+ expect(isError).toBeFalsy();
+ expect(createPath).toContain('keboola.snowflake-transformation');
+ const cfg = createBody?.configuration as Record;
+ const storage = cfg.storage as { output: { tables: { destination: string }[] } };
+ expect(storage.output.tables[0]!.destination).toContain('out.c-');
+ expect(text).toContain('tf1');
+ });
+});
+
+describe('update_sql_transformation', () => {
+ it('returns a Python/R hint when the config is missing (404)', async () => {
+ server.use(
+ aiNotFound(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const { pathname } = new URL(request.url);
+ if (pathname.endsWith('/tokens/verify')) {
+ return HttpResponse.json({ owner: { id: '42', defaultBackend: 'snowflake' } });
+ }
+ if (pathname.endsWith('/workspaces')) return HttpResponse.json([]);
+ if (pathname.includes('/configs/nope')) return new HttpResponse(null, { status: 404 });
+ return undefined;
+ }),
+ );
+
+ const { text, isError } = await callTool('update_sql_transformation', {
+ change_description: 'x',
+ configuration_id: 'nope',
+ });
+
+ expect(isError).toBe(true);
+ expect(text).toContain("use 'update_config'");
+ expect(text).toContain('keboola.python-transformation-v2');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// VALIDATION + MODEL UNIT TESTS
+// ---------------------------------------------------------------------------
+
+describe('validation: sanitizeSchema', () => {
+ it.each([
+ [{ type: 'object', required: true }, { type: 'object' }],
+ [{ type: 'object', required: false }, { type: 'object' }],
+ [
+ { type: 'object', required: ['foo', 'bar'] },
+ { type: 'object', required: ['foo', 'bar'] },
+ ],
+ [{ type: 'string', enum: [] }, { type: 'string' }],
+ ])('normalizes required/enum %#', (input, expected) => {
+ expect(__testing.sanitizeSchema(input as Record)).toEqual(expected);
+ });
+
+ it('propagates a boolean child-required flag up to the parent', () => {
+ const out = __testing.sanitizeSchema({
+ type: 'object',
+ properties: { foo: { type: 'string', required: true } },
+ });
+ expect(out).toEqual({
+ type: 'object',
+ required: ['foo'],
+ properties: { foo: { type: 'string' } },
+ });
+ });
+
+ it('converts an empty-list properties to an empty dict', () => {
+ const out = __testing.sanitizeSchema({ type: 'object', properties: [] });
+ expect(out.properties).toEqual({});
+ });
+});
+
+describe('validation: parameters & storage', () => {
+ const component = {
+ component_id: 'keboola.ex-generic',
+ component_type: 'extractor',
+ capabilities: { is_row_based: false },
+ configuration_schema: {
+ type: 'object',
+ required: ['host'],
+ properties: { host: { type: 'string' }, port: { type: 'integer' } },
+ },
+ };
+
+ it('accepts valid parameters', () => {
+ expect(() =>
+ validateRootParametersConfiguration({ host: 'h', port: 1 }, component),
+ ).not.toThrow();
+ });
+
+ it('rejects wrong type', () => {
+ expect(() =>
+ validateRootParametersConfiguration({ host: 'h', port: 'nope' }, component),
+ ).toThrow(/not of type/);
+ });
+
+ it('skips validation when the component has no schema', () => {
+ const noSchema = { ...component, configuration_schema: null };
+ expect(() => validateRootParametersConfiguration({ anything: true }, noSchema)).not.toThrow();
+ });
+
+ it('requires writer root storage to contain input mappings', () => {
+ const writer = {
+ component_id: 'keboola.wr-x',
+ component_type: 'writer',
+ capabilities: { is_row_based: false },
+ configuration_schema: null,
+ };
+ expect(() => validateRootStorageConfiguration({}, writer)).toThrow(/must contain "input"/);
+ });
+});
+
+describe('model: SQL + param utils', () => {
+ it('splits and joins SQL statements', () => {
+ const stmts = splitSqlStatements('SELECT 1; SELECT 2;');
+ expect(stmts).toHaveLength(2);
+ expect(joinSqlStatements(stmts)).toContain('SELECT 1');
+ });
+
+ it('updateParams applies a diff without mutating the input', () => {
+ const original = { a: 1, b: { c: 2 } };
+ const out = updateParams(original, [{ op: 'set', path: 'b.c', value: 9 }]);
+ expect(out).toMatchObject({ a: 1, b: { c: 9 } });
+ expect(original.b.c).toBe(2);
+ });
+
+ it('updateParams str_replace and remove', () => {
+ const out = updateParams({ name: 'old_table', drop: 1 }, [
+ { op: 'str_replace', path: 'name', search_for: 'old', replace_with: 'new' },
+ { op: 'remove', path: 'drop' },
+ ]);
+ expect(out).toEqual({ name: 'new_table' });
+ });
+
+ it('updateTransformationParameters renames a code and summarizes structure on structural change', () => {
+ const [updated, summary] = updateTransformationParameters(
+ { blocks: [{ name: 'B', codes: [{ name: 'c', script: 'SELECT 1' }] }] },
+ [
+ {
+ op: 'add_code',
+ block_id: 'b0',
+ code: { name: 'c2', script: 'SELECT 2' },
+ position: 'end',
+ },
+ ],
+ );
+ expect(updated.blocks[0]!.codes).toHaveLength(2);
+ expect(summary).toContain('Updated Transformation Structure');
+ });
+
+ it('cleanBucketName folds diacritics and strips invalid chars', () => {
+ expect(cleanBucketName('Český Bucket!')).toBe('Cesky-Bucket');
+ });
+
+ it('createTransformationConfiguration builds output table destinations', () => {
+ const cfg = createTransformationConfiguration(
+ [{ name: 's', script: 'CREATE TABLE t AS SELECT 1;' }],
+ 'My TF',
+ ['t'],
+ ) as { storage: { output: { tables: { source: string; destination: string }[] } } };
+ expect(cfg.storage.output.tables[0]).toMatchObject({
+ source: 't',
+ destination: 'out.c-My-TF.t',
+ });
+ });
+});
diff --git a/__tests__/tools.data_apps.test.ts b/__tests__/tools.data_apps.test.ts
new file mode 100644
index 000000000..2bc4b7beb
--- /dev/null
+++ b/__tests__/tools.data_apps.test.ts
@@ -0,0 +1,553 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+import { Config } from '@/config';
+import { registerDataAppTools } from '@/tools/data_apps';
+
+const server = setupServer();
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+const config = new Config({
+ storageApiUrl: 'https://connection.test',
+ storageToken: 'tok',
+ workspaceSchema: 'WS_SCHEMA',
+});
+
+const connect = async (cfg: Config = config) => {
+ const [clientT, serverT] = InMemoryTransport.createLinkedPair();
+ const mcp = new McpServer({ name: 'test', version: '0.0.0' });
+ registerDataAppTools(mcp, cfg);
+ await mcp.connect(serverT);
+ const client = new Client({ name: 'test', version: '0.0.0' });
+ await client.connect(clientT);
+ return client;
+};
+
+// tokens/verify backs both the links manager (project id) and feature checks.
+const verifyHandler = (features: string[] = []) =>
+ http.get('https://connection.test/*', ({ request }) => {
+ if (new URL(request.url).pathname.endsWith('/tokens/verify')) {
+ return HttpResponse.json({ owner: { id: '42', features } });
+ }
+ return undefined;
+ });
+
+const call = async (
+ client: Awaited>,
+ name: string,
+ args: Record,
+) => {
+ const result = await client.callTool({ name, arguments: args });
+ return result;
+};
+
+const text = (result: Awaited>): string =>
+ (result.content as { text: string }[])[0]!.text;
+
+describe('get_data_apps', () => {
+ it('lists data app summaries filtered to the data-apps component', async () => {
+ server.use(
+ verifyHandler(),
+ http.get('https://data-science.test/apps', () =>
+ HttpResponse.json([
+ {
+ id: 'app1',
+ projectId: '42',
+ componentId: 'keboola.data-apps',
+ branchId: null,
+ configId: 'cfg1',
+ configVersion: '3',
+ type: 'streamlit',
+ state: 'running',
+ desiredState: 'running',
+ url: 'https://app1.run',
+ },
+ {
+ id: 'other',
+ projectId: '42',
+ componentId: 'keboola.other',
+ branchId: null,
+ configId: 'cfgX',
+ configVersion: '1',
+ type: 'streamlit',
+ state: 'stopped',
+ desiredState: 'stopped',
+ },
+ ]),
+ ),
+ );
+ const result = await call(await connect(), 'get_data_apps', {});
+ expect(result.isError).toBeFalsy();
+ const out = text(result);
+ expect(out).toContain('cfg1');
+ expect(out).not.toContain('cfgX'); // filtered out (wrong component)
+ expect(out).toContain('data-apps'); // dashboard link
+ });
+
+ it('returns detail with deployment info and drafts for a python-js prod app', async () => {
+ const prodConfig = {
+ id: 'prod-cfg',
+ name: 'My Prod',
+ version: 5,
+ configuration: { parameters: { id: 'prod-app' } },
+ metadata: [],
+ };
+ const draftConfig = {
+ id: 'draft-cfg',
+ name: 'My Draft',
+ version: 2,
+ configuration: {
+ parameters: {
+ id: 'draft-app',
+ dataApp: { isDraft: true, parentConfigurationId: 'prod-cfg' },
+ },
+ },
+ metadata: [],
+ };
+ const appResponse = (id: string, cfgId: string) => ({
+ id,
+ projectId: '42',
+ componentId: 'keboola.data-apps',
+ branchId: null,
+ configId: cfgId,
+ configVersion: '5',
+ type: 'python-js',
+ state: 'running',
+ desiredState: 'running',
+ url: `https://${id}.run`,
+ });
+
+ server.use(
+ verifyHandler(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const path = new URL(request.url).pathname;
+ if (path.endsWith('/tokens/verify')) return HttpResponse.json({ owner: { id: '42' } });
+ if (path.endsWith('/configs/prod-cfg')) return HttpResponse.json(prodConfig);
+ if (path.endsWith('/configs/draft-cfg')) return HttpResponse.json(draftConfig);
+ if (path.endsWith('/keboola.data-apps/configs'))
+ return HttpResponse.json([prodConfig, draftConfig]);
+ return undefined;
+ }),
+ http.get('https://data-science.test/apps/prod-app', () =>
+ HttpResponse.json(appResponse('prod-app', 'prod-cfg')),
+ ),
+ http.get('https://data-science.test/apps/draft-app', () =>
+ HttpResponse.json(appResponse('draft-app', 'draft-cfg')),
+ ),
+ http.get('https://data-science.test/apps/:id/git-repo', () =>
+ HttpResponse.json({ httpsUrl: 'https://git.test/repo.git', isManagedGitRepo: true }),
+ ),
+ http.get('https://data-science.test/apps/:id/logs/tail', () =>
+ HttpResponse.text('log line 1\nlog line 2'),
+ ),
+ http.get('https://data-science.test/apps/:id/runs', () =>
+ HttpResponse.json([{ id: 'run1', state: 'running', appId: 'prod-app' }]),
+ ),
+ );
+
+ const result = await call(await connect(), 'get_data_apps', {
+ configuration_ids: ['prod-cfg'],
+ });
+ expect(result.isError).toBeFalsy();
+ const out = text(result);
+ expect(out).toContain('prod-cfg');
+ expect(out).toContain('https://git.test/repo.git'); // repo_url on detail path
+ expect(out).toContain('draft-cfg'); // inline drafts
+ expect(out).toContain('log line 1'); // deployment logs
+ });
+});
+
+describe('create_python_js_data_app_git_credential', () => {
+ it('mints a token and returns an authenticated clone URL', async () => {
+ const cfg = {
+ id: 'prod-cfg',
+ name: 'Prod',
+ version: 1,
+ configuration: { parameters: { id: 'prod-app' } },
+ metadata: [],
+ };
+ server.use(
+ verifyHandler(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const path = new URL(request.url).pathname;
+ if (path.endsWith('/tokens/verify')) return HttpResponse.json({ owner: { id: '42' } });
+ if (path.endsWith('/configs/prod-cfg')) return HttpResponse.json(cfg);
+ return undefined;
+ }),
+ http.get('https://data-science.test/apps/prod-app', () =>
+ HttpResponse.json({
+ id: 'prod-app',
+ projectId: '42',
+ componentId: 'keboola.data-apps',
+ branchId: null,
+ configId: 'prod-cfg',
+ configVersion: '1',
+ type: 'python-js',
+ state: 'stopped',
+ desiredState: 'stopped',
+ }),
+ ),
+ http.get('https://data-science.test/apps/prod-app/git-repo', () =>
+ HttpResponse.json({ httpsUrl: 'https://git.test/my/repo.git', isManagedGitRepo: true }),
+ ),
+ http.post('https://data-science.test/apps/prod-app/git-repo/credentials', () =>
+ HttpResponse.json({
+ id: 'cred1',
+ type: 'http_token',
+ permissions: 'readWrite',
+ secret: 'sup3r/secret',
+ }),
+ ),
+ );
+
+ const result = await call(await connect(), 'create_python_js_data_app_git_credential', {
+ configuration_id: 'prod-cfg',
+ });
+ expect(result.isError).toBeFalsy();
+ const out = text(result);
+ // username embedded + secret URL-encoded.
+ expect(out).toContain('https://kai:sup3r%2Fsecret@git.test/my/repo.git');
+ expect(out).toContain('readWrite');
+ });
+
+ it('rejects a streamlit app', async () => {
+ const cfg = {
+ id: 'st-cfg',
+ name: 'St',
+ version: 1,
+ configuration: { parameters: { id: 'st-app' } },
+ metadata: [],
+ };
+ server.use(
+ verifyHandler(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const path = new URL(request.url).pathname;
+ if (path.endsWith('/tokens/verify')) return HttpResponse.json({ owner: { id: '42' } });
+ if (path.endsWith('/configs/st-cfg')) return HttpResponse.json(cfg);
+ return undefined;
+ }),
+ http.get('https://data-science.test/apps/st-app', () =>
+ HttpResponse.json({
+ id: 'st-app',
+ projectId: '42',
+ componentId: 'keboola.data-apps',
+ branchId: null,
+ configId: 'st-cfg',
+ configVersion: '1',
+ type: 'streamlit',
+ state: 'stopped',
+ desiredState: 'stopped',
+ }),
+ ),
+ );
+ const result = await call(await connect(), 'create_python_js_data_app_git_credential', {
+ configuration_id: 'st-cfg',
+ });
+ expect(result.isError).toBeTruthy();
+ expect(text(result)).toContain('only supports python-js');
+ });
+});
+
+describe('deploy_data_app', () => {
+ const streamlitCfg = {
+ id: 'st-cfg',
+ name: 'St',
+ version: 7,
+ configuration: { parameters: { id: 'st-app' }, authorization: {} },
+ metadata: [],
+ };
+ const stApp = (state: string) => ({
+ id: 'st-app',
+ projectId: '42',
+ componentId: 'keboola.data-apps',
+ branchId: null,
+ configId: 'st-cfg',
+ configVersion: '7',
+ type: 'streamlit',
+ state,
+ desiredState: 'running',
+ url: 'https://st.run',
+ });
+
+ it('deploys a streamlit app fetching the latest config version', async () => {
+ let patchBody: Record = {};
+ server.use(
+ verifyHandler(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const path = new URL(request.url).pathname;
+ if (path.endsWith('/tokens/verify')) return HttpResponse.json({ owner: { id: '42' } });
+ if (path.endsWith('/configs/st-cfg/versions'))
+ return HttpResponse.json([{ version: 6 }, { version: 7 }]);
+ if (path.endsWith('/configs/st-cfg')) return HttpResponse.json(streamlitCfg);
+ return undefined;
+ }),
+ http.get('https://data-science.test/apps/st-app', () => HttpResponse.json(stApp('running'))),
+ http.patch('https://data-science.test/apps/st-app', async ({ request }) => {
+ patchBody = (await request.json()) as Record;
+ return HttpResponse.json(stApp('running'));
+ }),
+ http.get('https://data-science.test/apps/st-app/logs/tail', () => HttpResponse.text('hi')),
+ http.get('https://data-science.test/apps/st-app/runs', () => HttpResponse.json([])),
+ );
+ const result = await call(await connect(), 'deploy_data_app', {
+ action: 'deploy',
+ configuration_id: 'st-cfg',
+ });
+ expect(result.isError).toBeFalsy();
+ expect(patchBody.configVersion).toBe('7'); // latest version sent for streamlit
+ expect(text(result)).toContain('running');
+ });
+
+ it('refuses to deploy an app that is stopping', async () => {
+ server.use(
+ verifyHandler(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const path = new URL(request.url).pathname;
+ if (path.endsWith('/tokens/verify')) return HttpResponse.json({ owner: { id: '42' } });
+ if (path.endsWith('/configs/st-cfg')) return HttpResponse.json(streamlitCfg);
+ return undefined;
+ }),
+ http.get('https://data-science.test/apps/st-app', () => HttpResponse.json(stApp('stopping'))),
+ );
+ const result = await call(await connect(), 'deploy_data_app', {
+ action: 'deploy',
+ configuration_id: 'st-cfg',
+ });
+ expect(result.isError).toBeTruthy();
+ expect(text(result)).toContain('stopping');
+ });
+});
+
+describe('delete_python_js_data_app_draft', () => {
+ it('deletes a draft and surfaces the parent configuration id', async () => {
+ const draftCfg = {
+ id: 'draft-cfg',
+ name: 'Draft',
+ version: 1,
+ configuration: {
+ parameters: {
+ id: 'draft-app',
+ dataApp: { isDraft: true, parentConfigurationId: 'prod-cfg' },
+ },
+ },
+ metadata: [],
+ };
+ let deleted = false;
+ server.use(
+ verifyHandler(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const path = new URL(request.url).pathname;
+ if (path.endsWith('/tokens/verify')) return HttpResponse.json({ owner: { id: '42' } });
+ if (path.endsWith('/configs/draft-cfg')) return HttpResponse.json(draftCfg);
+ return undefined;
+ }),
+ http.get('https://data-science.test/apps/draft-app', () =>
+ HttpResponse.json({
+ id: 'draft-app',
+ projectId: '42',
+ componentId: 'keboola.data-apps',
+ branchId: null,
+ configId: 'draft-cfg',
+ configVersion: '1',
+ type: 'python-js',
+ state: 'stopped',
+ desiredState: 'stopped',
+ }),
+ ),
+ http.get('https://data-science.test/apps/draft-app/git-repo', () =>
+ HttpResponse.json({ httpsUrl: 'https://git.test/repo.git', isManagedGitRepo: true }),
+ ),
+ http.delete('https://data-science.test/apps/draft-app', () => {
+ deleted = true;
+ return new HttpResponse(null, { status: 204 });
+ }),
+ );
+ const result = await call(await connect(), 'delete_python_js_data_app_draft', {
+ configuration_id: 'draft-cfg',
+ });
+ expect(result.isError).toBeFalsy();
+ expect(deleted).toBe(true);
+ const out = text(result);
+ expect(out).toContain('deleted');
+ expect(out).toContain('prod-cfg');
+ });
+
+ it('refuses to delete a prod (non-draft) app', async () => {
+ const prodCfg = {
+ id: 'prod-cfg',
+ name: 'Prod',
+ version: 1,
+ configuration: { parameters: { id: 'prod-app' } },
+ metadata: [],
+ };
+ server.use(
+ verifyHandler(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const path = new URL(request.url).pathname;
+ if (path.endsWith('/tokens/verify')) return HttpResponse.json({ owner: { id: '42' } });
+ if (path.endsWith('/configs/prod-cfg')) return HttpResponse.json(prodCfg);
+ return undefined;
+ }),
+ http.get('https://data-science.test/apps/prod-app', () =>
+ HttpResponse.json({
+ id: 'prod-app',
+ projectId: '42',
+ componentId: 'keboola.data-apps',
+ branchId: null,
+ configId: 'prod-cfg',
+ configVersion: '1',
+ type: 'python-js',
+ state: 'stopped',
+ desiredState: 'stopped',
+ }),
+ ),
+ http.get('https://data-science.test/apps/prod-app/git-repo', () =>
+ HttpResponse.json({ httpsUrl: 'https://git.test/repo.git', isManagedGitRepo: true }),
+ ),
+ );
+ const result = await call(await connect(), 'delete_python_js_data_app_draft', {
+ configuration_id: 'prod-cfg',
+ });
+ expect(result.isError).toBeTruthy();
+ expect(text(result)).toContain('prod** app');
+ });
+});
+
+describe('modify_python_js_data_app', () => {
+ it('rejects branch on the update path', async () => {
+ const result = await call(await connect(), 'modify_python_js_data_app', {
+ name: 'X',
+ description: 'd',
+ configuration_id: 'cfg1',
+ branch: 'feature',
+ });
+ expect(result.isError).toBeTruthy();
+ expect(text(result)).toContain('branch is only valid');
+ });
+
+ it('requires slug on create', async () => {
+ const result = await call(await connect(), 'modify_python_js_data_app', {
+ name: 'X',
+ description: 'd',
+ });
+ expect(result.isError).toBeTruthy();
+ expect(text(result)).toContain('slug is required');
+ });
+
+ it('creates a prod app with a managed repo (feature enabled, no workspace secret lookup)', async () => {
+ let createBody: Record = {};
+ server.use(
+ verifyHandler(['data-apps-storage-workspace']),
+ http.post('https://encryption.test/encrypt', async ({ request }) =>
+ HttpResponse.json((await request.json()) as Record),
+ ),
+ http.post('https://data-science.test/apps', async ({ request }) => {
+ createBody = (await request.json()) as Record;
+ return HttpResponse.json({
+ id: 'new-app',
+ projectId: '42',
+ componentId: 'keboola.data-apps',
+ branchId: null,
+ configId: 'new-cfg',
+ configVersion: '1',
+ type: 'python-js',
+ state: 'created',
+ desiredState: 'stopped',
+ });
+ }),
+ http.get('https://data-science.test/apps/new-app/git-repo', () =>
+ HttpResponse.json({ httpsUrl: 'https://git.test/new.git', isManagedGitRepo: true }),
+ ),
+ http.post('https://connection.test/*', ({ request }) => {
+ if (new URL(request.url).pathname.endsWith('/metadata')) return HttpResponse.json([]);
+ return undefined;
+ }),
+ http.get('https://connection.test/*', ({ request }) => {
+ const path = new URL(request.url).pathname;
+ if (path.endsWith('/tokens/verify'))
+ return HttpResponse.json({
+ owner: { id: '42', features: ['data-apps-storage-workspace'] },
+ });
+ if (path.endsWith('/keboola.data-apps/configs')) return HttpResponse.json([]);
+ return undefined;
+ }),
+ );
+
+ const result = await call(await connect(), 'modify_python_js_data_app', {
+ name: 'My App',
+ description: 'desc',
+ slug: 'my-app',
+ });
+ expect(result.isError).toBeFalsy();
+ const out = text(result);
+ expect(createBody.useManagedGitRepo).toBe(true);
+ expect(createBody.type).toBe('python-js');
+ expect(out).toContain('created');
+ expect(out).toContain('https://git.test/new.git');
+ });
+});
+
+describe('modify_streamlit_data_app', () => {
+ it('creates a streamlit app, injecting the query function and encrypting the config', async () => {
+ let createBody: Record = {};
+ server.use(
+ http.get('https://connection.test/*', ({ request }) => {
+ const path = new URL(request.url).pathname;
+ if (path.endsWith('/tokens/verify')) return HttpResponse.json({ owner: { id: '42' } });
+ if (path.endsWith('/dev-branches'))
+ return HttpResponse.json([{ id: 'main-branch', isDefault: true }]);
+ if (path.endsWith('/workspaces'))
+ return HttpResponse.json([
+ { id: 'ws-1', connection: { schema: 'WS_SCHEMA', backend: 'snowflake' } },
+ ]);
+ if (path.endsWith('/keboola.data-apps/configs')) return HttpResponse.json([]);
+ return undefined;
+ }),
+ http.post('https://connection.test/*', ({ request }) => {
+ if (new URL(request.url).pathname.endsWith('/metadata')) return HttpResponse.json([]);
+ return undefined;
+ }),
+ http.post('https://encryption.test/encrypt', async ({ request }) =>
+ HttpResponse.json((await request.json()) as Record),
+ ),
+ http.post('https://data-science.test/apps', async ({ request }) => {
+ createBody = (await request.json()) as Record;
+ return HttpResponse.json({
+ id: 'st-app',
+ projectId: '42',
+ componentId: 'keboola.data-apps',
+ branchId: null,
+ configId: 'st-cfg',
+ configVersion: '1',
+ type: 'streamlit',
+ state: 'created',
+ desiredState: 'stopped',
+ url: 'https://st.run',
+ });
+ }),
+ );
+
+ const result = await call(await connect(), 'modify_streamlit_data_app', {
+ name: 'Dashboard App',
+ description: 'desc',
+ source_code: 'import streamlit as st\n{QUERY_DATA_FUNCTION}\nst.write("hi")',
+ packages: ['plotly'],
+ authentication_type: 'default',
+ });
+ expect(result.isError).toBeFalsy();
+ const out = text(result);
+ expect(out).toContain('created');
+ // injected query_data function replaced the placeholder.
+ const script = (
+ (createBody.config as Record).parameters as Record
+ ).script as string[];
+ expect(script[0]).toContain('def query_data');
+ expect(script[0]).not.toContain('{QUERY_DATA_FUNCTION}');
+ });
+});
diff --git a/__tests__/tools.doc.test.ts b/__tests__/tools.doc.test.ts
new file mode 100644
index 000000000..063006ce4
--- /dev/null
+++ b/__tests__/tools.doc.test.ts
@@ -0,0 +1,74 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+import type { DocsSearch } from '@/clients/docsSearch';
+import { setDocsSearchForTests } from '@/clients/docsSearch';
+import { Config } from '@/config';
+import { createServer } from '@/server';
+
+const server = setupServer(
+ http.get('https://connection.test/*', ({ request }) =>
+ new URL(request.url).pathname.endsWith('/tokens/verify')
+ ? HttpResponse.json({ owner: { id: '42' } })
+ : undefined,
+ ),
+);
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+const config = new Config({ storageApiUrl: 'https://connection.test', storageToken: 'tok' });
+
+/** A minimal fake docs-search provider; only the methods under test are implemented. */
+const fakeDocsSearch = (overrides: Partial = {}): DocsSearch => ({
+ search: async () => [],
+ answerQuestion: async () => ({ text: '', sourceUrls: [] }),
+ recommendComponents: async () => [],
+ isReady: async () => true,
+ close: async () => {},
+ ...overrides,
+});
+
+const connect = async (): Promise => {
+ const [clientT, serverT] = InMemoryTransport.createLinkedPair();
+ await createServer(config).connect(serverT);
+ const client = new Client({ name: 't', version: '0' });
+ await client.connect(clientT);
+ return client;
+};
+
+afterEach(() => setDocsSearchForTests(undefined));
+
+describe('docs_query', () => {
+ it('answers via the docs-search index and returns text + source urls', async () => {
+ let asked: string | undefined;
+ setDocsSearchForTests(
+ fakeDocsSearch({
+ answerQuestion: async (question) => {
+ asked = question;
+ return { text: 'Use the API.', sourceUrls: ['https://help.keboola.com/x'] };
+ },
+ }),
+ );
+
+ const client = await connect();
+ const result = await client.callTool({ name: 'docs_query', arguments: { query: 'how to?' } });
+ const text = (result.content as { text: string }[])[0]!.text;
+
+ expect(asked).toBe('how to?');
+ expect(text).toContain('Use the API.');
+ expect(text).toContain('https://help.keboola.com/x');
+ await client.close();
+ });
+
+ it('is filtered out of tools/list when no docs index is configured', async () => {
+ setDocsSearchForTests(null);
+ const client = await connect();
+ const { tools } = await client.listTools();
+ expect(tools.map((t) => t.name)).not.toContain('docs_query');
+ await client.close();
+ });
+});
diff --git a/__tests__/tools.flow.test.ts b/__tests__/tools.flow.test.ts
new file mode 100644
index 000000000..38de40d2d
--- /dev/null
+++ b/__tests__/tools.flow.test.ts
@@ -0,0 +1,521 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+import { Config } from '@/config';
+import { registerFlowTools } from '@/tools/flow';
+
+const server = setupServer();
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+const config = new Config({ storageApiUrl: 'https://connection.test', storageToken: 'tok' });
+
+// A standalone server registering only the flow tools (the real server.ts is not edited).
+const makeServer = (cfg = config) => {
+ const mcp = new McpServer({ name: 'test', version: '0.0.0' });
+ registerFlowTools(mcp, cfg);
+ return mcp;
+};
+
+const connect = async (cfg = config) => {
+ const [clientT, serverT] = InMemoryTransport.createLinkedPair();
+ await makeServer(cfg).connect(serverT);
+ const client = new Client({ name: 'test', version: '0.0.0' });
+ await client.connect(clientT);
+ return client;
+};
+
+const call = async (name: string, args: Record, cfg = config) => {
+ const client = await connect(cfg);
+ const result = await client.callTool({ name, arguments: args });
+ const text = (result.content as { text: string }[])[0]!.text;
+ await client.close();
+ return { text, isError: result.isError };
+};
+
+// Token verify backs links manager (project id) + project context (name/features).
+const verifyOwner = (features: unknown = []) =>
+ http.get('https://connection.test/*', ({ request }) => {
+ if (new URL(request.url).pathname.endsWith('/tokens/verify')) {
+ return HttpResponse.json({ owner: { id: '42', name: 'Proj', features } });
+ }
+ return undefined;
+ });
+
+// Scheduler list (used by get_flows). Returns [] unless overridden.
+const schedulerEmpty = () => http.get('https://scheduler.test/*', () => HttpResponse.json([]));
+
+describe('get_flow_examples', () => {
+ it('renders legacy examples as markdown', async () => {
+ server.use(verifyOwner());
+ const { text, isError } = await call('get_flow_examples', {
+ flow_type: 'keboola.orchestrator',
+ });
+ expect(isError).toBeFalsy();
+ expect(text).toContain('# Flow Configuration Examples for `keboola.orchestrator`');
+ expect(text).toContain('1. Flow Configuration:');
+ expect(text).toContain('```json');
+ });
+
+ it('refuses conditional examples when the feature is disabled', async () => {
+ server.use(verifyOwner(['hide-conditional-flows']));
+ const { text, isError } = await call('get_flow_examples', { flow_type: 'keboola.flow' });
+ expect(isError).toBeTruthy();
+ expect(text).toContain('Conditional flows are not supported');
+ expect(text).toContain('legacy flow examples');
+ });
+
+ it('renders conditional examples when enabled', async () => {
+ server.use(verifyOwner([]));
+ const { text, isError } = await call('get_flow_examples', { flow_type: 'keboola.flow' });
+ expect(isError).toBeFalsy();
+ expect(text).toContain('# Flow Configuration Examples for `keboola.flow`');
+ });
+});
+
+describe('get_flow_schema', () => {
+ it('returns the bundled legacy schema as markdown', async () => {
+ server.use(verifyOwner());
+ const { text, isError } = await call('get_flow_schema', {
+ flow_type: 'keboola.orchestrator',
+ });
+ expect(isError).toBeFalsy();
+ expect(text).toContain('"phases"');
+ expect(text).toContain('"tasks"');
+ expect(text).toContain('componentId');
+ });
+
+ it('fetches the live conditional schema from the AI catalog', async () => {
+ server.use(
+ verifyOwner([]),
+ http.get('https://ai.test/*', () =>
+ HttpResponse.json({
+ id: 'keboola.flow',
+ name: 'Flow',
+ type: 'application',
+ configurationSchema: { type: 'object', properties: { phases: { type: 'array' } } },
+ }),
+ ),
+ http.get('https://connection.test/v2/storage/*', () => HttpResponse.json({ data: {} })),
+ );
+ const { text, isError } = await call('get_flow_schema', { flow_type: 'keboola.flow' });
+ expect(isError).toBeFalsy();
+ expect(text).toContain('"phases"');
+ });
+
+ it('refuses conditional schema when the feature is disabled', async () => {
+ server.use(verifyOwner(['hide-conditional-flows']));
+ const { text, isError } = await call('get_flow_schema', { flow_type: 'keboola.flow' });
+ expect(isError).toBeTruthy();
+ expect(text).toContain('conditional_flows=false');
+ });
+});
+
+describe('create_flow (legacy)', () => {
+ it('creates a flow, normalizes ids, sets MCP metadata, returns links', async () => {
+ let createdBody: Record = {};
+ server.use(
+ verifyOwner(),
+ // create config
+ http.post(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.orchestrator/configs',
+ async ({ request }) => {
+ const url = new URL(request.url);
+ if (url.pathname.endsWith('/configs')) {
+ createdBody = (await request.json()) as Record;
+ return HttpResponse.json({
+ id: '123',
+ name: 'My Flow',
+ description: 'desc',
+ version: 1,
+ });
+ }
+ return undefined;
+ },
+ ),
+ // metadata POST (createdBy)
+ http.post(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.orchestrator/configs/123/metadata',
+ () => HttpResponse.json([]),
+ ),
+ // folder search + config list (folder hint, returns few configs -> no hint)
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/search/component-configurations',
+ () => HttpResponse.json([]),
+ ),
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.orchestrator/configs',
+ () => HttpResponse.json([{ id: '123' }]),
+ ),
+ );
+
+ const { text, isError } = await call('create_flow', {
+ name: 'My Flow',
+ description: 'desc',
+ phases: [{ name: 'Phase 1' }],
+ tasks: [{ name: 'T', phase: 1, task: { componentId: 'keboola.ex-aws-s3', configId: 'c1' } }],
+ });
+ expect(isError).toBeFalsy();
+ expect(text).toContain('123');
+ expect(text).toContain('keboola.orchestrator');
+ expect(text).toContain('/flows/123'); // flow detail link
+ // phase got id=1, task got id=20001 and mode=run
+ const cfg = createdBody.configuration as {
+ phases: { id: number }[];
+ tasks: { id: number; task: { mode: string } }[];
+ };
+ expect(cfg.phases[0]!.id).toBe(1);
+ expect(cfg.tasks[0]!.id).toBe(20001);
+ expect(cfg.tasks[0]!.task.mode).toBe('run');
+ });
+
+ it('rejects a task referencing a non-existent phase', async () => {
+ server.use(verifyOwner());
+ const { text, isError } = await call('create_flow', {
+ name: 'Bad',
+ description: 'd',
+ phases: [{ id: 1, name: 'P1' }],
+ tasks: [{ id: 5, name: 'T', phase: 99, task: { componentId: 'x' } }],
+ });
+ expect(isError).toBeTruthy();
+ expect(text).toContain('non-existent phase');
+ });
+});
+
+describe('create_conditional_flow', () => {
+ const conditionalSchema = () =>
+ http.get('https://ai.test/*', () =>
+ HttpResponse.json({
+ id: 'keboola.flow',
+ name: 'Flow',
+ type: 'application',
+ configurationSchema: { type: 'object' },
+ }),
+ );
+
+ it('validates structure (entry/reachability) and creates the flow', async () => {
+ let body: Record = {};
+ server.use(
+ verifyOwner([]),
+ conditionalSchema(),
+ http.get('https://connection.test/v2/storage/branch/default/components/keboola.flow', () =>
+ HttpResponse.json({ data: {} }),
+ ),
+ http.post(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.flow/configs',
+ async ({ request }) => {
+ body = (await request.json()) as Record;
+ return HttpResponse.json({ id: 'f1', name: 'CF', description: 'd', version: 1 });
+ },
+ ),
+ http.post(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.flow/configs/f1/metadata',
+ () => HttpResponse.json([]),
+ ),
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/search/component-configurations',
+ () => HttpResponse.json([]),
+ ),
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.flow/configs',
+ () => HttpResponse.json([]),
+ ),
+ );
+
+ const { text, isError } = await call('create_conditional_flow', {
+ name: 'CF',
+ description: 'd',
+ phases: [
+ { id: 'a', name: 'A', next: [{ id: 't1', goto: 'b' }] },
+ { id: 'b', name: 'B', next: [] },
+ ],
+ tasks: [
+ { id: 't', name: 'Task', phase: 'a', task: { type: 'job', componentId: 'x', mode: 'run' } },
+ ],
+ });
+ expect(isError).toBeFalsy();
+ expect(text).toContain('keboola.flow');
+ expect(text).toContain('/flows-v2/f1');
+ // ending phase 'b' had empty next -> dropped from serialized config
+ const cfg = body.configuration as { phases: Record[] };
+ expect(cfg.phases[1]!.next).toBeUndefined();
+ });
+
+ it('rejects multiple entry phases', async () => {
+ server.use(verifyOwner([]), conditionalSchema());
+ const { text, isError } = await call('create_conditional_flow', {
+ name: 'CF',
+ description: 'd',
+ phases: [
+ { id: 'a', name: 'A', next: [] },
+ { id: 'b', name: 'B', next: [] },
+ ],
+ tasks: [],
+ });
+ expect(isError).toBeTruthy();
+ expect(text).toContain('entry phase');
+ });
+
+ it('fails when the conditional feature is disabled', async () => {
+ server.use(verifyOwner(['hide-conditional-flows']));
+ const { text, isError } = await call('create_conditional_flow', {
+ name: 'CF',
+ description: 'd',
+ phases: [{ id: 'a', name: 'A', next: [] }],
+ tasks: [],
+ });
+ expect(isError).toBeTruthy();
+ expect(text).toContain('Conditional flows are not supported');
+ });
+});
+
+describe('get_flows', () => {
+ it('lists all flow summaries with dashboard links', async () => {
+ server.use(
+ verifyOwner(),
+ schedulerEmpty(),
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.flow/configs',
+ () => HttpResponse.json([]),
+ ),
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.orchestrator/configs',
+ () =>
+ HttpResponse.json([
+ {
+ id: '7',
+ name: 'Flow7',
+ version: 2,
+ configuration: { phases: [{ id: 1 }], tasks: [{ id: 2 }, { id: 3 }] },
+ },
+ ]),
+ ),
+ );
+ const { text, isError } = await call('get_flows', { flow_ids: [] });
+ expect(isError).toBeFalsy();
+ expect(text).toContain('Flow7');
+ expect(text).toContain('phases_count');
+ expect(text).toContain('/flows'); // dashboard link
+ });
+
+ it('returns full details for a specific flow id, resolving its type', async () => {
+ server.use(
+ verifyOwner(),
+ schedulerEmpty(),
+ // keboola.flow lookup 404s -> falls back to orchestrator
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.flow/configs/7',
+ () => new HttpResponse(null, { status: 404 }),
+ ),
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.orchestrator/configs/7',
+ () =>
+ HttpResponse.json({
+ id: '7',
+ name: 'Flow7',
+ version: 2,
+ configuration: { phases: [{ id: 1, name: 'P' }], tasks: [] },
+ }),
+ ),
+ );
+ const { text, isError } = await call('get_flows', { flow_ids: ['7'] });
+ expect(isError).toBeFalsy();
+ expect(text).toContain('Flow7');
+ expect(text).toContain('keboola.orchestrator');
+ expect(text).toContain('phases');
+ });
+});
+
+describe('modify_flow', () => {
+ it('updates phases/tasks and sets update metadata', async () => {
+ let putBody: Record = {};
+ server.use(
+ verifyOwner([]),
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.orchestrator/configs/9',
+ () =>
+ HttpResponse.json({
+ id: '9',
+ name: 'F9',
+ version: 3,
+ configuration: { phases: [{ id: 1, name: 'Old' }], tasks: [] },
+ }),
+ ),
+ http.put(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.orchestrator/configs/9',
+ async ({ request }) => {
+ putBody = (await request.json()) as Record;
+ return HttpResponse.json({ id: '9', name: 'F9', description: 'd', version: 4 });
+ },
+ ),
+ http.post(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.orchestrator/configs/9/metadata',
+ () => HttpResponse.json([]),
+ ),
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/search/component-configurations',
+ () => HttpResponse.json([]),
+ ),
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.orchestrator/configs',
+ () => HttpResponse.json([{ id: '9' }]),
+ ),
+ );
+ const { text, isError } = await call('modify_flow', {
+ configuration_id: '9',
+ flow_type: 'keboola.orchestrator',
+ change_description: 'update phases',
+ phases: [{ id: 1, name: 'New Phase' }],
+ tasks: [{ id: 2, name: 'T', phase: 1, task: { componentId: 'x' } }],
+ });
+ expect(isError).toBeFalsy();
+ expect(text).toContain('version: 4');
+ // The typed Storage client (updateConfiguration) serializes `configuration` as a JSON
+ // string field (the canonical SAPI contract), unlike the previous raw nested-object body.
+ const cfg = JSON.parse(putBody.configuration as string) as { phases: { name: string }[] };
+ expect(cfg.phases[0]!.name).toBe('New Phase');
+ expect(putBody.changeDescription).toBe('update phases');
+ });
+
+ it('processes an add-schedule request and appends a scheduler link', async () => {
+ server.use(
+ verifyOwner([]),
+ // no config changes -> just detail fetch
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.orchestrator/configs/9',
+ () =>
+ HttpResponse.json({
+ id: '9',
+ name: 'F9',
+ description: 'd',
+ version: 3,
+ configuration: {},
+ }),
+ ),
+ // folder hint
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/search/component-configurations',
+ () => HttpResponse.json([]),
+ ),
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.orchestrator/configs',
+ () => HttpResponse.json([{ id: '9' }]),
+ ),
+ // scheduler list (current schedules) -> empty
+ http.get('https://scheduler.test/schedules', () => HttpResponse.json([])),
+ // create scheduler config in storage
+ http.post(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.scheduler/configs',
+ () => HttpResponse.json({ id: 'sched1', version: 1 }),
+ ),
+ http.post(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.scheduler/configs/sched1/metadata',
+ () => HttpResponse.json([]),
+ ),
+ // activate schedule
+ http.post('https://scheduler.test/schedules', () =>
+ HttpResponse.json({
+ id: 'act1',
+ configurationId: 'sched1',
+ schedule: { cronTab: '0 8 * * 1', timezone: 'UTC', state: 'enabled' },
+ }),
+ ),
+ );
+ const { text, isError } = await call('modify_flow', {
+ configuration_id: '9',
+ flow_type: 'keboola.orchestrator',
+ change_description: 'add schedule',
+ schedules: [{ action: 'add', cron_tab: '0 8 * * 1', state: 'enabled', timezone: 'UTC' }],
+ });
+ expect(isError).toBeFalsy();
+ expect(text).toContain('Schedules request processed successfully');
+ expect(text).toContain('Created schedule: sched1');
+ expect(text).toContain('/schedules'); // scheduler detail link
+ });
+
+ it('rejects an invalid cron expression', async () => {
+ server.use(
+ verifyOwner([]),
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.orchestrator/configs/9',
+ () =>
+ HttpResponse.json({
+ id: '9',
+ name: 'F9',
+ description: 'd',
+ version: 3,
+ configuration: {},
+ }),
+ ),
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/search/component-configurations',
+ () => HttpResponse.json([]),
+ ),
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.orchestrator/configs',
+ () => HttpResponse.json([{ id: '9' }]),
+ ),
+ http.get('https://scheduler.test/schedules', () => HttpResponse.json([])),
+ );
+ const { text, isError } = await call('modify_flow', {
+ configuration_id: '9',
+ flow_type: 'keboola.orchestrator',
+ change_description: 'bad cron',
+ schedules: [{ action: 'add', cron_tab: '99 99 * *', state: 'enabled' }],
+ });
+ expect(isError).toBeTruthy();
+ expect(text).toContain('Invalid cron tab expression');
+ });
+});
+
+describe('update_flow', () => {
+ it('delegates to modify_flow without schedules', async () => {
+ let putBody: Record = {};
+ server.use(
+ verifyOwner([]),
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.orchestrator/configs/5',
+ () =>
+ HttpResponse.json({
+ id: '5',
+ name: 'F5',
+ version: 1,
+ configuration: { phases: [{ id: 1, name: 'P' }], tasks: [] },
+ }),
+ ),
+ http.put(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.orchestrator/configs/5',
+ async ({ request }) => {
+ putBody = (await request.json()) as Record;
+ return HttpResponse.json({ id: '5', name: 'Renamed', description: 'd', version: 2 });
+ },
+ ),
+ http.post(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.orchestrator/configs/5/metadata',
+ () => HttpResponse.json([]),
+ ),
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/search/component-configurations',
+ () => HttpResponse.json([]),
+ ),
+ http.get(
+ 'https://connection.test/v2/storage/branch/default/components/keboola.orchestrator/configs',
+ () => HttpResponse.json([{ id: '5' }]),
+ ),
+ );
+ const { text, isError } = await call('update_flow', {
+ configuration_id: '5',
+ flow_type: 'keboola.orchestrator',
+ change_description: 'rename',
+ name: 'Renamed',
+ });
+ expect(isError).toBeFalsy();
+ expect(putBody.name).toBe('Renamed');
+ expect(text).toContain('Renamed');
+ });
+});
diff --git a/__tests__/tools.jobs.test.ts b/__tests__/tools.jobs.test.ts
new file mode 100644
index 000000000..67e2c154a
--- /dev/null
+++ b/__tests__/tools.jobs.test.ts
@@ -0,0 +1,183 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+import { Config } from '@/config';
+import { createServer } from '@/server';
+
+const server = setupServer();
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+const config = new Config({ storageApiUrl: 'https://connection.test', storageToken: 'tok' });
+
+const connect = async () => {
+ const [clientT, serverT] = InMemoryTransport.createLinkedPair();
+ await createServer(config).connect(serverT);
+ const client = new Client({ name: 'test', version: '0.0.0' });
+ await client.connect(clientT);
+ return client;
+};
+
+// Token verify backs the links manager (project id). Always available.
+const verifyHandler = () =>
+ http.get('https://connection.test/*', ({ request }) => {
+ if (new URL(request.url).pathname.endsWith('/tokens/verify')) {
+ return HttpResponse.json({ owner: { id: '42' } });
+ }
+ return undefined;
+ });
+
+const callText = async (
+ client: Awaited>,
+ args: Record,
+) => {
+ const result = await client.callTool({ name: 'get_jobs', arguments: args });
+ expect(result.isError).toBeFalsy();
+ return (result.content as { text: string }[])[0]!.text;
+};
+
+describe('get_jobs', () => {
+ it('lists job summaries and maps component/config aliases', async () => {
+ server.use(
+ verifyHandler(),
+ http.get('https://queue.test/*', () =>
+ HttpResponse.json([
+ {
+ id: '1',
+ status: 'success',
+ component: 'keboola.ex-aws-s3',
+ config: 'c1',
+ isFinished: true,
+ },
+ ]),
+ ),
+ );
+
+ const text = await callText(await connect(), { job_ids: [] });
+ expect(text).toContain('keboola.ex-aws-s3');
+ expect(text).toContain('componentId');
+ // Listing surfaces the jobs dashboard link.
+ expect(text).toContain('queue');
+ });
+
+ it('returns full details for specific job ids with a job link', async () => {
+ server.use(
+ verifyHandler(),
+ http.get('https://queue.test/*', () =>
+ HttpResponse.json({
+ id: '99',
+ status: 'error',
+ component: 'x',
+ config: 'c',
+ url: 'https://job/99',
+ }),
+ ),
+ );
+
+ const text = await callText(await connect(), { job_ids: ['99'] });
+ expect(text).toContain('https://job/99');
+ expect(text).toContain('/queue/99'); // job detail link
+ });
+
+ it('includes filtered, chronologically-ordered logs when requested', async () => {
+ server.use(
+ verifyHandler(),
+ http.get('https://queue.test/*', () =>
+ HttpResponse.json({ id: '7', status: 'error', component: 'x', config: 'c', url: 'u' }),
+ ),
+ );
+ // Events come newest-first; the tool filters by type then reverses to chronological.
+ server.use(
+ http.get('https://connection.test/*', ({ request }) => {
+ const url = new URL(request.url);
+ if (url.pathname.endsWith('/tokens/verify'))
+ return HttpResponse.json({ owner: { id: '42' } });
+ if (url.pathname.endsWith('/events')) {
+ return HttpResponse.json([
+ { message: 'boom', type: 'error', created: 't2' },
+ { message: 'starting', type: 'info', created: 't1' },
+ ]);
+ }
+ return undefined;
+ }),
+ );
+
+ const text = await callText(await connect(), {
+ job_ids: ['7'],
+ include_logs: true,
+ log_event_types: ['error'],
+ });
+ expect(text).toContain('boom');
+ expect(text).not.toContain('starting');
+ });
+});
+
+describe('run_job', () => {
+ const runJob = async (args: Record, capture: (body: unknown) => void) => {
+ server.use(
+ verifyHandler(),
+ http.post('https://queue.test/*', async ({ request }) => {
+ capture(await request.json());
+ return HttpResponse.json({
+ id: '500',
+ status: 'created',
+ component: args.component_id,
+ config: args.configuration_id,
+ url: 'https://job/500',
+ });
+ }),
+ );
+ const client = await connect();
+ const result = await client.callTool({ name: 'run_job', arguments: args });
+ expect(result.isError).toBeFalsy();
+ const text = (result.content as { text: string }[])[0]!.text;
+ await client.close();
+ return text;
+ };
+
+ it('creates a job with mode=run and returns its details + link', async () => {
+ let body: Record = {};
+ const text = await runJob(
+ { component_id: 'keboola.ex-aws-s3', configuration_id: 'c1' },
+ (b) => {
+ body = b as Record;
+ },
+ );
+ expect(body).toMatchObject({ component: 'keboola.ex-aws-s3', config: 'c1', mode: 'run' });
+ expect(body.branchId).toBeUndefined(); // production: no branchId in payload
+ expect(text).toContain('https://job/500');
+ expect(text).toContain('/queue/500');
+ });
+
+ it('passes config row ids and the branch id on a development branch', async () => {
+ let body: Record = {};
+ const [clientT, serverT] = InMemoryTransport.createLinkedPair();
+ await createServer(config.replaceBy({ branchId: '789' })).connect(serverT);
+ const client = new Client({ name: 't', version: '0' });
+ await client.connect(clientT);
+ server.use(
+ verifyHandler(),
+ http.post('https://queue.test/*', async ({ request }) => {
+ body = (await request.json()) as Record;
+ return HttpResponse.json({
+ id: '501',
+ status: 'created',
+ component: 'x',
+ config: 'c',
+ url: 'u',
+ });
+ }),
+ );
+ await client.callTool({
+ name: 'run_job',
+ arguments: { component_id: 'x', configuration_id: 'c', configuration_row_ids: ['r1', 'r2'] },
+ });
+ expect(body.branchId).toBe('789');
+ expect(body.configRowIds).toEqual(['r1', 'r2']);
+ await client.close();
+ });
+});
diff --git a/__tests__/tools.oauth.test.ts b/__tests__/tools.oauth.test.ts
new file mode 100644
index 000000000..06bfcdc47
--- /dev/null
+++ b/__tests__/tools.oauth.test.ts
@@ -0,0 +1,45 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+import { Config } from '@/config';
+import { createServer } from '@/server';
+
+const server = setupServer();
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+const config = new Config({ storageApiUrl: 'https://connection.test', storageToken: 'tok' });
+
+describe('create_oauth_url', () => {
+ it('mints a scoped short-lived token and builds the external OAuth URL', async () => {
+ let body: Record = {};
+ server.use(
+ http.post('https://connection.test/*', async ({ request }) => {
+ expect(new URL(request.url).pathname).toMatch(/\/v2\/storage\/tokens$/);
+ body = (await request.json()) as Record;
+ return HttpResponse.json({ token: 'short-lived-123' });
+ }),
+ );
+
+ const [clientT, serverT] = InMemoryTransport.createLinkedPair();
+ await createServer(config).connect(serverT);
+ const client = new Client({ name: 't', version: '0' });
+ await client.connect(clientT);
+
+ const result = await client.callTool({
+ name: 'create_oauth_url',
+ arguments: { component_id: 'keboola.ex-gmail', config_id: 'cfg1' },
+ });
+ const url = (result.content as { text: string }[])[0]!.text;
+
+ expect(body).toMatchObject({ componentAccess: ['keboola.ex-gmail'], expiresIn: 3600 });
+ expect(url).toBe(
+ 'https://external.keboola.com/oauth/index.html?token=short-lived-123&sapiUrl=https%3A%2F%2Fconnection.test#/keboola.ex-gmail/cfg1',
+ );
+ await client.close();
+ });
+});
diff --git a/__tests__/tools.project.test.ts b/__tests__/tools.project.test.ts
new file mode 100644
index 000000000..39d7eb177
--- /dev/null
+++ b/__tests__/tools.project.test.ts
@@ -0,0 +1,222 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+import { Config } from '@/config';
+import { createServer } from '@/server';
+
+const server = setupServer();
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+const connect = async (config: Config) => {
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+ await createServer(config).connect(serverTransport);
+ const client = new Client({ name: 'test-client', version: '0.0.0' });
+ await client.connect(clientTransport);
+ return client;
+};
+
+const config = new Config({
+ storageApiUrl: 'https://connection.test',
+ storageToken: 'test-token',
+});
+
+describe('update_project_description', () => {
+ it('posts the description to the current branch metadata endpoint', async () => {
+ let captured: { pathname: string; body: unknown } | undefined;
+ server.use(
+ http.post('https://connection.test/*', async ({ request }) => {
+ captured = { pathname: new URL(request.url).pathname, body: await request.json() };
+ return HttpResponse.json([{ key: 'KBC.projectDescription', value: 'New desc' }]);
+ }),
+ );
+
+ const client = await connect(config);
+ const result = await client.callTool({
+ name: 'update_project_description',
+ arguments: { description: 'New desc' },
+ });
+
+ // Branch resolution: production maps to the `default` branch alias.
+ expect(captured?.pathname).toMatch(/\/branch\/default\/metadata$/);
+ expect(JSON.stringify(captured?.body)).toContain('New desc');
+
+ const content = result.content as { type: string; text: string }[];
+ expect(content[0]!.text).toContain('updated successfully');
+ expect(result.isError).toBeFalsy();
+ await client.close();
+ });
+
+ it('targets a development branch when one is configured', async () => {
+ let pathname: string | undefined;
+ server.use(
+ http.post('https://connection.test/*', ({ request }) => {
+ pathname = new URL(request.url).pathname;
+ return HttpResponse.json([]);
+ }),
+ );
+
+ const client = await connect(config.replaceBy({ branchId: '567' }));
+ await client.callTool({ name: 'update_project_description', arguments: { description: 'x' } });
+ expect(pathname).toMatch(/\/branch\/567\/metadata$/);
+ await client.close();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// get_project_info
+// ---------------------------------------------------------------------------
+
+type ProjectInfoHandlers = {
+ verifyToken?: Record;
+ devBranches?: unknown[];
+ branchMetadata?: { key: string; value: string }[];
+ workspaces?: unknown[];
+};
+
+/** Registers GET handlers for the four storage endpoints get_project_info reads. */
+const useProjectInfoHandlers = (opts: ProjectInfoHandlers = {}) => {
+ const {
+ verifyToken = {
+ owner: { id: '42', name: 'My Project', features: [] },
+ organization: { id: '7' },
+ admin: { role: 'admin' },
+ },
+ devBranches = [{ id: 123, name: 'Main', isDefault: true }],
+ branchMetadata = [
+ { key: 'KBC.projectDescription', value: 'Some description' },
+ { key: 'KBC.McpServer.v2.workspaceId', value: '999' },
+ ],
+ workspaces = [
+ {
+ id: 999,
+ connection: { backend: 'snowflake', schema: 'WORKSPACE_SCHEMA' },
+ readOnlyStorageAccess: true,
+ },
+ ],
+ } = opts;
+
+ server.use(
+ http.get('https://connection.test/v2/storage/tokens/verify', () =>
+ HttpResponse.json(verifyToken),
+ ),
+ http.get('https://connection.test/v2/storage/dev-branches', () =>
+ HttpResponse.json(devBranches),
+ ),
+ http.get('https://connection.test/v2/storage/branch/:branchId/metadata', () =>
+ HttpResponse.json(branchMetadata),
+ ),
+ // WorkspaceManager fetches a workspace by id (resolved from the MCP metadata key)…
+ http.get(
+ 'https://connection.test/v2/storage/branch/:branchId/workspaces/:wsId',
+ ({ params }) => {
+ const ws = (workspaces as { id: number }[]).find(
+ (w) => String(w.id) === String(params.wsId),
+ );
+ return ws ? HttpResponse.json(ws) : new HttpResponse(null, { status: 404 });
+ },
+ ),
+ // …or lists them when resolving by the configured workspace schema.
+ http.get('https://connection.test/v2/storage/branch/:branchId/workspaces', () =>
+ HttpResponse.json(workspaces),
+ ),
+ );
+};
+
+const callProjectInfo = async (cfg: Config) => {
+ const client = await connect(cfg);
+ const result = await client.callTool({ name: 'get_project_info', arguments: {} });
+ await client.close();
+ const content = (result.content as { type: string; text: string }[])[0]!.text;
+ return { result, text: content };
+};
+
+describe('get_project_info', () => {
+ it('returns unified project info on the default branch', async () => {
+ useProjectInfoHandlers();
+ const { result, text } = await callProjectInfo(config);
+
+ expect(result.isError).toBeFalsy();
+ expect(text).toContain('project_id: "42"');
+ expect(text).toContain('My Project');
+ expect(text).toContain('Some description');
+ expect(text).toContain('organization_id: "7"');
+ expect(text).toContain('sql_dialect: Snowflake');
+ expect(text).toContain('workspace_id: 999');
+ expect(text).toContain('user_role: admin');
+ // Default branch resolution.
+ expect(text).toContain('branch_id: 123');
+ expect(text).toContain('branch_name: Main');
+ expect(text).toContain('is_development_branch: false');
+ // Conditional flows enabled when the feature flag is absent.
+ expect(text).toContain('conditional_flows: true');
+ // The base system prompt (and the Snowflake dialect section) is embedded.
+ expect(text).toContain('### SQL Identifiers');
+ expect(text).toContain('Snowflake');
+ expect(text).toContain('Finding Items');
+ // admin role => no toolset restrictions (null dropped from compact TOON output).
+ expect(text).not.toContain('toolset_restrictions');
+ });
+
+ it('resolves the configured development branch and surfaces role restrictions', async () => {
+ useProjectInfoHandlers({
+ verifyToken: {
+ owner: { id: '42', name: 'My Project', features: ['hide-conditional-flows'] },
+ organization: { id: '7' },
+ admin: { role: 'readonly' },
+ },
+ devBranches: [
+ { id: 123, name: 'Main', isDefault: true },
+ { id: 456, name: 'feature-x', isDefault: false },
+ ],
+ });
+
+ const { text } = await callProjectInfo(config.replaceBy({ branchId: '456' }));
+
+ expect(text).toContain('branch_id: 456');
+ expect(text).toContain('branch_name: feature-x');
+ expect(text).toContain('is_development_branch: true');
+ // hide-conditional-flows feature present => conditional flows disabled.
+ expect(text).toContain('conditional_flows: false');
+ expect(text).toContain('user_role: readonly');
+ expect(text).toContain('read-only tools are available');
+ });
+
+ it('selects a BigQuery workspace by the configured workspace schema', async () => {
+ useProjectInfoHandlers({
+ workspaces: [
+ {
+ id: 111,
+ connection: { backend: 'snowflake', schema: 'OTHER' },
+ readOnlyStorageAccess: true,
+ },
+ {
+ id: 222,
+ connection: {
+ backend: 'bigquery',
+ schema: 'TARGET_SCHEMA',
+ // BigQuery workspaces carry the service-account JSON in `connection.user`;
+ // WorkspaceManager parses project_id out of it.
+ user: JSON.stringify({ project_id: 'my-bq-project' }),
+ },
+ readOnlyStorageAccess: true,
+ },
+ ],
+ });
+
+ const { text } = await callProjectInfo(config.replaceBy({ workspaceSchema: 'TARGET_SCHEMA' }));
+
+ expect(text).toContain('sql_dialect: BigQuery');
+ expect(text).toContain('workspace_id: 222');
+ // BigQuery dialect section uses backtick FQNs.
+ expect(text).toContain('`project`.`dataset`.`table`');
+ });
+
+ // Note: when no workspace exists, get_project_info now CREATES one via WorkspaceManager
+ // (parity with the Python behavior / query_data), rather than erroring. The create-and-poll
+ // path is covered in __tests__/workspace.test.ts.
+});
diff --git a/__tests__/tools.search.test.ts b/__tests__/tools.search.test.ts
new file mode 100644
index 000000000..a303b9905
--- /dev/null
+++ b/__tests__/tools.search.test.ts
@@ -0,0 +1,272 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+import type { DocsSearch, RetrievedDoc } from '@/clients/docsSearch';
+import { setDocsSearchForTests } from '@/clients/docsSearch';
+import { Config } from '@/config';
+import { createServer } from '@/server';
+
+const server = setupServer();
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
+afterEach(() => {
+ server.resetHandlers();
+ setDocsSearchForTests(undefined);
+});
+afterAll(() => server.close());
+
+/** A minimal fake docs-search provider; only the methods under test are implemented. */
+const fakeDocsSearch = (overrides: Partial = {}): DocsSearch => ({
+ search: async () => [],
+ answerQuestion: async () => ({ text: '', sourceUrls: [] }),
+ recommendComponents: async () => [],
+ isReady: async () => true,
+ close: async () => {},
+ ...overrides,
+});
+
+const retrieved = (sourceKey: string, score: number): RetrievedDoc => ({
+ id: 'doc-1',
+ sourceKey,
+ sourceUrl: 'https://components.keboola.com/x',
+ title: 'x',
+ content: 'x',
+ componentType: 'extractor',
+ score,
+});
+
+const config = new Config({ storageApiUrl: 'https://connection.test', storageToken: 'tok' });
+
+const callSearch = async (args: Record): Promise => {
+ const [clientT, serverT] = InMemoryTransport.createLinkedPair();
+ await createServer(config).connect(serverT);
+ const client = new Client({ name: 't', version: '0' });
+ await client.connect(clientT);
+ const result = await client.callTool({ name: 'search', arguments: args });
+ const text = (result.content as { text: string }[])[0]!.text;
+ await client.close();
+ return text;
+};
+
+describe('search (global textual)', () => {
+ it('runs server-side global search when the feature is enabled and maps hits', async () => {
+ let searchQuery: URL | undefined;
+ server.use(
+ http.get('https://connection.test/*', ({ request }) => {
+ const url = new URL(request.url);
+ if (url.pathname.endsWith('/tokens/verify')) {
+ return HttpResponse.json({ owner: { id: '42', features: ['global-search'] } });
+ }
+ if (url.pathname.endsWith('/global-search')) {
+ searchQuery = url;
+ return HttpResponse.json({
+ all: 1,
+ byType: { table: 1 },
+ items: [
+ {
+ id: 'in.c-main.customers',
+ name: 'customers',
+ type: 'table',
+ fullPath: { bucket: { id: 'in.c-main' } },
+ created: '2024-01-01T00:00:00Z',
+ },
+ ],
+ });
+ }
+ return undefined;
+ }),
+ );
+
+ const text = await callSearch({ patterns: ['customer'], item_types: ['table'] });
+
+ // Production branch context -> branchTypes[]=production, scoped to the project.
+ expect(searchQuery?.searchParams.get('branchTypes[]')).toBe('production');
+ expect(searchQuery?.searchParams.get('projectIds[]')).toBe('42');
+ expect(searchQuery?.searchParams.get('query')).toBe('customer');
+ expect(text).toContain('in.c-main.customers');
+ expect(text).toContain('current-branch');
+ // Link to the table detail page.
+ expect(text).toContain('storage/in.c-main/table/customers');
+ });
+
+ it('widens to all branches when the current branch context returns nothing', async () => {
+ const scopes: string[] = [];
+ server.use(
+ http.get('https://connection.test/*', ({ request }) => {
+ const url = new URL(request.url);
+ if (url.pathname.endsWith('/tokens/verify')) {
+ return HttpResponse.json({ owner: { id: '42', features: ['global-search'] } });
+ }
+ if (url.pathname.endsWith('/global-search')) {
+ const hasBranch = url.searchParams.has('branchTypes[]');
+ scopes.push(hasBranch ? 'current' : 'all');
+ if (hasBranch) {
+ return HttpResponse.json({ all: 0, byType: {}, items: [] });
+ }
+ return HttpResponse.json({
+ all: 1,
+ byType: { bucket: 1 },
+ items: [
+ {
+ id: 'in.c-dev',
+ name: 'dev-bucket',
+ type: 'bucket',
+ fullPath: { branch: { id: '789', name: 'feature-x' } },
+ created: '2024-02-02T00:00:00Z',
+ },
+ ],
+ });
+ }
+ return undefined;
+ }),
+ );
+
+ const text = await callSearch({ patterns: ['dev'], item_types: ['bucket'] });
+
+ expect(scopes).toEqual(['current', 'all']);
+ expect(text).toContain('all-branches');
+ expect(text).toContain('feature-x');
+ expect(text).toContain('in.c-dev');
+ });
+});
+
+describe('search (enumeration fallback)', () => {
+ it('config-based search matches inside configuration JSON and reports scopes', async () => {
+ server.use(
+ http.get('https://connection.test/*', ({ request }) => {
+ const url = new URL(request.url);
+ if (url.pathname.endsWith('/tokens/verify')) {
+ return HttpResponse.json({ owner: { id: '42', features: [] } });
+ }
+ if (url.pathname.endsWith('/components')) {
+ return HttpResponse.json([
+ {
+ id: 'keboola.snowflake-transformation',
+ type: 'transformation',
+ configurations: [
+ {
+ id: '123',
+ name: 'My SQL',
+ description: 'desc',
+ created: '2024-03-03T00:00:00Z',
+ configuration: {
+ storage: { input: { tables: [{ source: 'in.c-prod.customers' }] } },
+ },
+ rows: [],
+ },
+ ],
+ },
+ ]);
+ }
+ return undefined;
+ }),
+ );
+
+ const text = await callSearch({
+ patterns: ['in.c-prod.customers'],
+ item_types: ['transformation'],
+ search_type: 'config-based',
+ });
+
+ expect(text).toContain('123');
+ expect(text).toContain('keboola.snowflake-transformation');
+ // The matched JSONPath scope is reported.
+ expect(text).toContain('storage.input.tables[0].source');
+ });
+
+ it('textual search without the global-search feature enumerates configurations', async () => {
+ server.use(
+ http.get('https://connection.test/*', ({ request }) => {
+ const url = new URL(request.url);
+ if (url.pathname.endsWith('/tokens/verify')) {
+ return HttpResponse.json({ owner: { id: '42', features: [] } });
+ }
+ if (url.pathname.endsWith('/components')) {
+ return HttpResponse.json([
+ {
+ id: 'keboola.ex-db',
+ type: 'extractor',
+ configurations: [
+ {
+ id: '7',
+ name: 'Sales report',
+ description: null,
+ created: '2024-04-04T00:00:00Z',
+ rows: [],
+ },
+ ],
+ },
+ ]);
+ }
+ return undefined;
+ }),
+ );
+
+ const text = await callSearch({ patterns: ['sales'], item_types: ['configuration'] });
+ expect(text).toContain('Sales report');
+ expect(text).toContain('keboola.ex-db');
+ });
+});
+
+describe('find_component_id', () => {
+ it('recommends component ids from the docs index with scores and a dashboard link', async () => {
+ let asked: string | undefined;
+ server.use(
+ http.get('https://connection.test/*', ({ request }) =>
+ new URL(request.url).pathname.endsWith('/tokens/verify')
+ ? HttpResponse.json({ owner: { id: '42' } })
+ : undefined,
+ ),
+ );
+ setDocsSearchForTests(
+ fakeDocsSearch({
+ recommendComponents: async (query) => {
+ asked = query;
+ return [
+ retrieved('component:keboola.ex-salesforce', 0.9),
+ // A non-component doc (no `component:` prefix) is dropped from the result.
+ retrieved('help:some-doc', 0.8),
+ ];
+ },
+ }),
+ );
+
+ const [clientT, serverT] = InMemoryTransport.createLinkedPair();
+ await createServer(config).connect(serverT);
+ const client = new Client({ name: 't', version: '0' });
+ await client.connect(clientT);
+ const result = await client.callTool({
+ name: 'find_component_id',
+ arguments: { query: 'salesforce extractor' },
+ });
+ const text = (result.content as { text: string }[])[0]!.text;
+
+ expect(asked).toBe('salesforce extractor');
+ expect(text).toContain('keboola.ex-salesforce');
+ expect(text).toContain('components/keboola.ex-salesforce'); // dashboard link
+ expect(text).not.toContain('help:some-doc');
+ await client.close();
+ });
+
+ it('is denied on call when no docs index is configured', async () => {
+ server.use(
+ http.get('https://connection.test/*', ({ request }) =>
+ new URL(request.url).pathname.endsWith('/tokens/verify')
+ ? HttpResponse.json({ owner: { id: '42' } })
+ : undefined,
+ ),
+ );
+ setDocsSearchForTests(null);
+
+ const [clientT, serverT] = InMemoryTransport.createLinkedPair();
+ await createServer(config).connect(serverT);
+ const client = new Client({ name: 't', version: '0' });
+ await client.connect(clientT);
+ await expect(
+ client.callTool({ name: 'find_component_id', arguments: { query: 'salesforce' } }),
+ ).rejects.toThrow(/documentation index is not/);
+ await client.close();
+ });
+});
diff --git a/__tests__/tools.semantic.test.ts b/__tests__/tools.semantic.test.ts
new file mode 100644
index 000000000..9e1b5f93a
--- /dev/null
+++ b/__tests__/tools.semantic.test.ts
@@ -0,0 +1,327 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+import { Config } from '@/config';
+import { registerSemanticTools } from '@/tools/semantic';
+
+const mswServer = setupServer();
+beforeAll(() => mswServer.listen({ onUnhandledRequest: 'error' }));
+afterEach(() => mswServer.resetHandlers());
+afterAll(() => mswServer.close());
+
+const config = new Config({ storageApiUrl: 'https://connection.test', storageToken: 'tok' });
+
+const connect = async () => {
+ const [clientT, serverT] = InMemoryTransport.createLinkedPair();
+ const server = new McpServer({ name: 'test', version: '0.0.0' });
+ registerSemanticTools(server, config);
+ await server.connect(serverT);
+ const client = new Client({ name: 'test', version: '0.0.0' });
+ await client.connect(clientT);
+ return client;
+};
+
+const callText = async (
+ client: Awaited>,
+ name: string,
+ args: Record,
+) => {
+ const result = await client.callTool({ name, arguments: args });
+ return result;
+};
+
+const text = (result: Awaited>): string =>
+ (result.content as { text: string }[])[0]!.text;
+
+// JSON:API list envelope; each item has top-level type/id/attributes/meta.
+const listResponse = (
+ items: {
+ type: string;
+ id: string;
+ attributes?: Record;
+ meta?: Record;
+ }[],
+) => HttpResponse.json({ data: items });
+
+const objectResponse = (item: {
+ type: string;
+ id: string;
+ attributes?: Record;
+ meta?: Record;
+}) => HttpResponse.json({ data: item });
+
+// Helper: route metastore repository/schema requests on metastore.test.
+// Paged list endpoints page until a short page is returned; to make finite,
+// only serve list items on offset=0 and return an empty page otherwise.
+const metastore = (handlers: (url: URL, objectType: string) => Response | undefined) =>
+ http.get('https://metastore.test/*', ({ request }) => {
+ const url = new URL(request.url);
+ const m = url.pathname.match(/\/api\/v1\/(?:repository|schema)\/([^/]+)/);
+ const objectType = m ? m[1]! : '';
+ const isList =
+ url.pathname.includes('/repository/') && !/\/repository\/[^/]+\/.+/.test(url.pathname);
+ const offset = Number(url.searchParams.get('offset') ?? '0');
+ if (isList && offset > 0) {
+ return HttpResponse.json({ data: [] });
+ }
+ return handlers(url, objectType) ?? new HttpResponse(null, { status: 500 });
+ });
+
+describe('get_semantic_schema', () => {
+ it('returns JSON schema per requested semantic type', async () => {
+ mswServer.use(
+ metastore((url, objectType) => {
+ if (url.pathname.includes('/schema/')) {
+ return HttpResponse.json({ objectType, schema: { type: 'object', title: objectType } });
+ }
+ return undefined;
+ }),
+ );
+ const result = await callText(await connect(), 'get_semantic_schema', {
+ semantic_types: ['semantic-dataset'],
+ });
+ expect(result.isError).toBeFalsy();
+ expect(text(result)).toContain('semantic-dataset');
+ });
+
+ it('errors on empty semantic_types', async () => {
+ const result = await callText(await connect(), 'get_semantic_schema', { semantic_types: [] });
+ expect(result.isError).toBe(true);
+ expect(text(result)).toContain('At least one semantic type');
+ });
+});
+
+describe('get_semantic_context', () => {
+ it('lists compact objects when ids are empty', async () => {
+ mswServer.use(
+ metastore((url, objectType) => {
+ if (objectType === 'semantic-model' && url.pathname.endsWith('/semantic-model')) {
+ // First page full (limit=20 -> return < limit to stop).
+ return listResponse([
+ {
+ type: 'semantic-model',
+ id: 'm1',
+ attributes: { name: 'Sales Model', sql_dialect: 'snowflake' },
+ },
+ ]);
+ }
+ return undefined;
+ }),
+ );
+ const result = await callText(await connect(), 'get_semantic_context', {
+ semantic_objects: [{ object_type: 'semantic-model' }],
+ });
+ expect(result.isError).toBeFalsy();
+ const out = text(result);
+ expect(out).toContain('Sales Model');
+ expect(out).toContain('snowflake');
+ });
+
+ it('returns full attributes when ids are provided', async () => {
+ mswServer.use(
+ metastore((url, objectType) => {
+ if (objectType === 'semantic-dataset' && url.pathname.endsWith('/d1')) {
+ return objectResponse({
+ type: 'semantic-dataset',
+ id: 'd1',
+ attributes: { name: 'Orders', tableId: 'in.c-x.orders', secretKey: 'keepme' },
+ });
+ }
+ return undefined;
+ }),
+ );
+ const result = await callText(await connect(), 'get_semantic_context', {
+ semantic_objects: [{ object_type: 'semantic-dataset', ids: ['d1'] }],
+ });
+ expect(result.isError).toBeFalsy();
+ const out = text(result);
+ // Full attributes view includes the raw attributes map.
+ expect(out).toContain('keepme');
+ expect(out).toContain('attributes');
+ });
+
+ it('errors on empty semantic_objects', async () => {
+ const result = await callText(await connect(), 'get_semantic_context', {
+ semantic_objects: [],
+ });
+ expect(result.isError).toBe(true);
+ });
+});
+
+describe('search_semantic_context', () => {
+ it('matches by attribute value and groups by model, with matched paths', async () => {
+ mswServer.use(
+ metastore((_url, objectType) => {
+ if (objectType === 'semantic-dataset') {
+ return listResponse([
+ {
+ type: 'semantic-dataset',
+ id: 'd1',
+ attributes: { name: 'Revenue Facts', tableId: 'in.c-x.rev', modelUUID: 'm1' },
+ },
+ ]);
+ }
+ // All other types empty.
+ return listResponse([]);
+ }),
+ );
+ const result = await callText(await connect(), 'search_semantic_context', {
+ patterns: ['revenue'],
+ semantic_types: ['semantic-dataset'],
+ });
+ expect(result.isError).toBeFalsy();
+ const out = text(result);
+ expect(out).toContain('m1'); // grouped by semantic_model_id
+ expect(out).toContain('Revenue Facts');
+ expect(out).toContain('meta.name'); // matched on display name
+ });
+
+ it('errors when no usable patterns are provided', async () => {
+ const result = await callText(await connect(), 'search_semantic_context', {
+ patterns: [' '],
+ });
+ expect(result.isError).toBe(true);
+ expect(text(result)).toContain('At least one regex pattern');
+ });
+
+ it('errors on invalid regex', async () => {
+ mswServer.use(metastore(() => listResponse([])));
+ const result = await callText(await connect(), 'search_semantic_context', { patterns: ['('] });
+ expect(result.isError).toBe(true);
+ expect(text(result)).toContain('Invalid regex pattern');
+ });
+});
+
+describe('validate_semantic_query', () => {
+ // A model with a dataset (in.table), a metric SUM("AMOUNT") on that dataset, and a
+ // post-query 'range' constraint scoped to the metric.
+ const buildModelHandlers = () =>
+ metastore((url, objectType) => {
+ if (url.pathname.includes('/schema/')) return undefined;
+ // get model by id
+ if (objectType === 'semantic-model' && url.pathname.endsWith('/m1')) {
+ return objectResponse({
+ type: 'semantic-model',
+ id: 'm1',
+ attributes: { name: 'M1', sql_dialect: 'snowflake' },
+ });
+ }
+ if (objectType === 'semantic-model') {
+ return listResponse([
+ {
+ type: 'semantic-model',
+ id: 'm1',
+ attributes: { name: 'M1', sql_dialect: 'snowflake' },
+ },
+ ]);
+ }
+ if (objectType === 'semantic-dataset' && url.pathname.endsWith('/d1')) {
+ return objectResponse({
+ type: 'semantic-dataset',
+ id: 'd1',
+ attributes: {
+ name: 'Orders',
+ tableId: 'orders_tbl',
+ fqn: 'DB.SCHEMA.ORDERS',
+ modelUUID: 'm1',
+ },
+ });
+ }
+ if (objectType === 'semantic-dataset') {
+ return listResponse([
+ {
+ type: 'semantic-dataset',
+ id: 'd1',
+ attributes: {
+ name: 'Orders',
+ tableId: 'orders_tbl',
+ fqn: 'DB.SCHEMA.ORDERS',
+ modelUUID: 'm1',
+ },
+ },
+ ]);
+ }
+ if (objectType === 'semantic-metric') {
+ return listResponse([
+ {
+ type: 'semantic-metric',
+ id: 'mt1',
+ attributes: {
+ name: 'Total Amount',
+ sql: 'SUM("AMOUNT")',
+ dataset: 'orders_tbl',
+ modelUUID: 'm1',
+ },
+ },
+ ]);
+ }
+ if (objectType === 'semantic-relationship') return listResponse([]);
+ if (objectType === 'semantic-constraint') {
+ return listResponse([
+ {
+ type: 'semantic-constraint',
+ id: 'c1',
+ attributes: {
+ name: 'Amount range check',
+ constraintType: 'range',
+ severity: 'warning',
+ metrics: ['Total Amount'],
+ modelUUID: 'm1',
+ },
+ },
+ ]);
+ }
+ return undefined;
+ });
+
+ it('auto-detects dataset + metric and surfaces a post-execution check', async () => {
+ mswServer.use(buildModelHandlers());
+ const result = await callText(await connect(), 'validate_semantic_query', {
+ sql_query: 'SELECT SUM("AMOUNT") FROM DB.SCHEMA.ORDERS',
+ semantic_model_ids: ['m1'],
+ });
+ expect(result.isError).toBeFalsy();
+ const out = text(result);
+ expect(out).toContain('validation_auto_detected');
+ expect(out).toContain('orders_tbl'); // used dataset tableId
+ expect(out).toContain('Total Amount'); // used metric
+ expect(out).toContain('post_execution_checks');
+ expect(out).toContain('Amount range check');
+ expect(out).toContain('valid'); // valid true (warning severity)
+ });
+
+ it('compares expected objects and reports missing ones', async () => {
+ mswServer.use(buildModelHandlers());
+ const result = await callText(await connect(), 'validate_semantic_query', {
+ sql_query: 'SELECT 1',
+ semantic_model_ids: ['m1'],
+ expected_semantic_objects: [{ object_type: 'semantic-dataset', ids: ['d1'] }],
+ });
+ expect(result.isError).toBeFalsy();
+ const out = text(result);
+ // d1 was expected but not detected in `SELECT 1`.
+ expect(out).toContain('missing_expected_objects');
+ expect(out).toContain('validation_detected_from_expected');
+ });
+
+ it('errors on empty sql', async () => {
+ const result = await callText(await connect(), 'validate_semantic_query', {
+ sql_query: ' ',
+ semantic_model_ids: ['m1'],
+ });
+ expect(result.isError).toBe(true);
+ expect(text(result)).toContain('sql_query must not be empty');
+ });
+
+ it('errors when no model ids', async () => {
+ const result = await callText(await connect(), 'validate_semantic_query', {
+ sql_query: 'SELECT 1',
+ semantic_model_ids: [],
+ });
+ expect(result.isError).toBe(true);
+ });
+});
diff --git a/__tests__/tools.sql.test.ts b/__tests__/tools.sql.test.ts
new file mode 100644
index 000000000..b1a98168d
--- /dev/null
+++ b/__tests__/tools.sql.test.ts
@@ -0,0 +1,238 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+import { Config } from '@/config';
+import { registerSqlTools } from '@/tools/sql';
+
+const server = setupServer();
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+const config = new Config({ storageApiUrl: 'https://connection.test', storageToken: 'tok' });
+
+const connect = async (cfg: Config = config) => {
+ const [clientT, serverT] = InMemoryTransport.createLinkedPair();
+ const mcp = new McpServer({ name: 'test', version: '0.0.0' });
+ registerSqlTools(mcp, cfg);
+ await mcp.connect(serverT);
+ const client = new Client({ name: 'test', version: '0.0.0' });
+ await client.connect(clientT);
+ return client;
+};
+
+const callTool = async (
+ client: Awaited>,
+ args: Record,
+) => client.callTool({ name: 'query_data', arguments: args });
+
+const callText = async (
+ client: Awaited>,
+ args: Record,
+) => {
+ const result = await callTool(client, args);
+ expect(result.isError).toBeFalsy();
+ return (result.content as { text: string }[])[0]!.text;
+};
+
+// --- Storage-side mocks (workspace discovery) ---
+
+const SNOWFLAKE_WS = {
+ id: 123,
+ connection: { backend: 'snowflake', schema: 'WORKSPACE_123', user: 'u' },
+ readOnlyStorageAccess: true,
+};
+
+/**
+ * Handles every Storage API call (`connection.test/v2/storage/...`) needed to resolve
+ * a workspace from the production-branch metadata. `wsOverride` swaps the workspace
+ * detail payload (e.g. for the BigQuery case).
+ */
+const storageHandler = (wsOverride?: Record) =>
+ http.all('https://connection.test/*', ({ request }) => {
+ const url = new URL(request.url);
+ const p = url.pathname;
+ if (p.endsWith('/branch/default/metadata')) {
+ return HttpResponse.json([{ key: 'KBC.McpServer.v2.workspaceId', value: 123 }]);
+ }
+ if (p.endsWith('/branch/default/workspaces/123')) {
+ return HttpResponse.json(wsOverride ?? SNOWFLAKE_WS);
+ }
+ if (p.endsWith('/dev-branches')) {
+ return HttpResponse.json([{ id: '999', isDefault: true }]);
+ }
+ return undefined;
+ });
+
+// --- Query Service mocks ---
+
+const querySuccessHandlers = (opts: {
+ columns: { name: string }[];
+ data: unknown[][];
+ numberOfRows?: number;
+ message?: string;
+}) => [
+ http.post('https://query.test/api/v1/branches/:bid/workspaces/:wid/queries', () =>
+ HttpResponse.json({ queryJobId: 'job-1' }),
+ ),
+ http.get('https://query.test/api/v1/queries/job-1', () =>
+ HttpResponse.json({ status: 'completed', statements: [{ id: 'stmt-1' }] }),
+ ),
+ http.get('https://query.test/api/v1/queries/job-1/stmt-1/results', () =>
+ HttpResponse.json({
+ status: 'completed',
+ columns: opts.columns,
+ data: opts.data,
+ numberOfRows: opts.numberOfRows ?? opts.data.length,
+ message: opts.message,
+ }),
+ ),
+];
+
+describe('query_data', () => {
+ it('runs a SELECT and returns CSV with a selected-rows message', async () => {
+ server.use(
+ storageHandler(),
+ ...querySuccessHandlers({
+ columns: [{ name: 'id' }, { name: 'name' }],
+ data: [
+ ['1', 'Alice'],
+ ['2', 'Bob'],
+ ],
+ numberOfRows: 2,
+ }),
+ );
+
+ const text = await callText(await connect(), {
+ sql_query: 'SELECT * FROM t',
+ query_name: 'My Query',
+ });
+ expect(text).toContain('id,name');
+ expect(text).toContain('Alice');
+ expect(text).toContain('Bob');
+ // Selected-rows message is surfaced.
+ expect(text).toContain('Returning 2 of 2 selected rows.');
+ expect(text).toContain('My Query');
+ });
+
+ it('quotes CSV fields that contain commas or quotes', async () => {
+ server.use(
+ storageHandler(),
+ ...querySuccessHandlers({
+ columns: [{ name: 'val' }],
+ data: [['a,b'], ['he said "hi"']],
+ }),
+ );
+
+ const text = await callText(await connect(), {
+ sql_query: 'SELECT val FROM t',
+ query_name: 'Q',
+ });
+ // The CSV is TOON-encoded as a quoted string, so embedded double-quotes are escaped.
+ expect(text).toContain('\\"a,b\\"');
+ expect(text).toContain('\\"he said \\"\\"hi\\"\\"\\"');
+ });
+
+ it('returns an error result when the query fails', async () => {
+ server.use(
+ storageHandler(),
+ http.post('https://query.test/api/v1/branches/:bid/workspaces/:wid/queries', () =>
+ HttpResponse.json({ queryJobId: 'job-1' }),
+ ),
+ http.get('https://query.test/api/v1/queries/job-1', () =>
+ HttpResponse.json({ status: 'failed', statements: [{ id: 'stmt-1' }] }),
+ ),
+ http.get('https://query.test/api/v1/queries/job-1/stmt-1/results', () =>
+ HttpResponse.json({
+ status: 'failed',
+ columns: [],
+ data: [],
+ message: 'boom syntax error',
+ }),
+ ),
+ );
+
+ const result = await callTool(await connect(), {
+ sql_query: 'SELECT bad',
+ query_name: 'Bad Query',
+ });
+ expect(result.isError).toBe(true);
+ expect((result.content as { text: string }[])[0]!.text).toContain('boom syntax error');
+ });
+
+ it('normalizes BigQuery error messages and uses backtick quoting', async () => {
+ server.use(
+ storageHandler({
+ id: 123,
+ connection: {
+ backend: 'bigquery',
+ schema: 'dataset_123',
+ user: JSON.stringify({ project_id: 'my-proj' }),
+ },
+ readOnlyStorageAccess: true,
+ }),
+ http.post('https://query.test/api/v1/branches/:bid/workspaces/:wid/queries', () =>
+ HttpResponse.json({ queryJobId: 'job-1' }),
+ ),
+ http.get('https://query.test/api/v1/queries/job-1', () =>
+ HttpResponse.json({ status: 'failed', statements: [{ id: 'stmt-1' }] }),
+ ),
+ http.get('https://query.test/api/v1/queries/job-1/stmt-1/results', () =>
+ HttpResponse.json({
+ status: 'failed',
+ columns: [],
+ data: [],
+ message:
+ 'Location: "query"; Message: "Syntax error: Unexpected identifier"; Reason: "invalidQuery"',
+ }),
+ ),
+ );
+
+ const result = await callTool(await connect(), {
+ sql_query: 'SELECT bad',
+ query_name: 'BQ Query',
+ });
+ expect(result.isError).toBe(true);
+ const text = (result.content as { text: string }[])[0]!.text;
+ expect(text).toContain('Syntax error: Unexpected identifier');
+ expect(text).not.toContain('Reason:');
+ });
+
+ it('surfaces a cancelled query as a clean cancellation error', async () => {
+ server.use(
+ storageHandler(),
+ http.post('https://query.test/api/v1/branches/:bid/workspaces/:wid/queries', () =>
+ HttpResponse.json({ queryJobId: 'job-1' }),
+ ),
+ http.get('https://query.test/api/v1/queries/job-1', () =>
+ HttpResponse.json({ status: 'canceled', statements: [{ id: 'stmt-1' }] }),
+ ),
+ );
+
+ const result = await callTool(await connect(), {
+ sql_query: 'SELECT 1',
+ query_name: 'Cancelled Query',
+ });
+ expect(result.isError).toBe(true);
+ expect((result.content as { text: string }[])[0]!.text).toContain('Query was cancelled');
+ });
+
+ it('truncates by max rows across pagination', async () => {
+ // Page returns more than MAX_ROWS would, but the tool requests pageSize capped to remaining.
+ // Here we just verify a short result passes through; pagination math is covered by the prefix.
+ server.use(
+ storageHandler(),
+ ...querySuccessHandlers({
+ columns: [{ name: 'n' }],
+ data: [['1'], ['2'], ['3']],
+ numberOfRows: 3,
+ }),
+ );
+ const text = await callText(await connect(), { sql_query: 'SELECT n', query_name: 'Rows' });
+ expect(text).toContain('Returning 3 of 3 selected rows.');
+ });
+});
diff --git a/__tests__/tools.storage.test.ts b/__tests__/tools.storage.test.ts
new file mode 100644
index 000000000..f7cbf69df
--- /dev/null
+++ b/__tests__/tools.storage.test.ts
@@ -0,0 +1,264 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+import { Config } from '@/config';
+import { createServer } from '@/server';
+
+const server = setupServer();
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+const config = new Config({ storageApiUrl: 'https://connection.test', storageToken: 'tok' });
+
+const connect = async () => {
+ const [clientT, serverT] = InMemoryTransport.createLinkedPair();
+ await createServer(config).connect(serverT);
+ const client = new Client({ name: 'test', version: '0.0.0' });
+ await client.connect(clientT);
+ return client;
+};
+
+const call = async (updates: { item_id: string; description: string }[]) => {
+ const client = await connect();
+ const result = await client.callTool({ name: 'update_descriptions', arguments: { updates } });
+ const text = (result.content as { text: string }[])[0]!.text;
+ await client.close();
+ return text;
+};
+
+// Token verify backs the links manager (project id).
+const verifyHandler = () =>
+ http.get('https://connection.test/*', ({ request }) => {
+ if (new URL(request.url).pathname.endsWith('/tokens/verify')) {
+ return HttpResponse.json({ owner: { id: '42' } });
+ }
+ return undefined;
+ });
+
+const callTool = async (name: string, args: Record) => {
+ const client = await connect();
+ const result = await client.callTool({ name, arguments: args });
+ expect(result.isError).toBeFalsy();
+ const text = (result.content as { text: string }[])[0]!.text;
+ await client.close();
+ return text;
+};
+
+describe('update_descriptions', () => {
+ it('updates a bucket description via the bucket metadata endpoint', async () => {
+ let captured: { path: string; body: unknown } | undefined;
+ server.use(
+ http.post('https://connection.test/*', async ({ request }) => {
+ captured = { path: new URL(request.url).pathname, body: await request.json() };
+ return HttpResponse.json([
+ { key: 'KBC.description', value: 'desc', timestamp: '2026-01-01' },
+ ]);
+ }),
+ );
+
+ const text = await call([{ item_id: 'in.c-main', description: 'desc' }]);
+ expect(captured?.path).toMatch(/\/v2\/storage\/buckets\/in\.c-main\/metadata$/);
+ expect(JSON.stringify(captured?.body)).toContain('KBC.description');
+ expect(text).toContain('successful: 1');
+ });
+
+ it('updates a column description via the table metadata endpoint', async () => {
+ let body: { columnsMetadata?: Record } | undefined;
+ server.use(
+ http.post('https://connection.test/*', async ({ request }) => {
+ body = (await request.json()) as { columnsMetadata?: Record };
+ return HttpResponse.json({
+ columnsMetadata: {
+ age: [{ key: 'KBC.description', value: 'years', timestamp: '2026-01-02' }],
+ },
+ });
+ }),
+ );
+
+ const text = await call([{ item_id: 'in.c-main.users.age', description: 'years' }]);
+ expect(body?.columnsMetadata).toBeDefined();
+ expect(text).toContain('successful: 1');
+ });
+
+ it('reports invalid item ids without calling the API', async () => {
+ // No handlers registered -> any HTTP call would error (onUnhandledRequest: 'error').
+ const text = await call([{ item_id: 'bad-id', description: 'x' }]);
+ expect(text).toContain('failed: 1');
+ expect(text).toContain('Invalid item_id format');
+ });
+});
+
+describe('get_buckets', () => {
+ it('lists all buckets with stage counts and the dashboard link', async () => {
+ server.use(
+ verifyHandler(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const url = new URL(request.url);
+ if (url.pathname.endsWith('/tokens/verify'))
+ return HttpResponse.json({ owner: { id: '42' } });
+ if (url.pathname.endsWith('/buckets')) {
+ return HttpResponse.json([
+ {
+ id: 'in.c-main',
+ name: 'main',
+ displayName: 'Main',
+ stage: 'in',
+ created: '2026-01-01T00:00:00+0000',
+ dataSizeBytes: 100,
+ description: 'legacy',
+ },
+ {
+ id: 'out.c-result',
+ name: 'result',
+ displayName: 'Result',
+ stage: 'out',
+ created: '2026-01-02T00:00:00+0000',
+ },
+ ]);
+ }
+ return undefined;
+ }),
+ );
+
+ const text = await callTool('get_buckets', { bucket_ids: [] });
+ expect(text).toContain('in.c-main');
+ expect(text).toContain('out.c-result');
+ expect(text).toContain('total_buckets: 2');
+ expect(text).toContain('input_buckets: 1');
+ expect(text).toContain('output_buckets: 1');
+ // metadata description takes precedence over legacy, but absent here -> legacy used
+ expect(text).toContain('legacy');
+ // bucket dashboard link
+ expect(text).toContain('/storage');
+ });
+
+ it('prefers KBC.description metadata over the legacy description and reports missing ids', async () => {
+ server.use(
+ verifyHandler(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const url = new URL(request.url);
+ if (url.pathname.endsWith('/tokens/verify'))
+ return HttpResponse.json({ owner: { id: '42' } });
+ if (url.pathname.endsWith('/buckets/in.c-main')) {
+ return HttpResponse.json({
+ id: 'in.c-main',
+ name: 'main',
+ displayName: 'Main',
+ stage: 'in',
+ created: '2026-01-01T00:00:00+0000',
+ description: 'legacy auto-generated',
+ metadata: [
+ {
+ key: 'KBC.description',
+ value: 'curated description',
+ timestamp: '2026-01-03T00:00:00+0000',
+ },
+ ],
+ });
+ }
+ if (url.pathname.endsWith('/buckets/in.c-missing')) {
+ return new HttpResponse(JSON.stringify({ error: 'not found' }), { status: 404 });
+ }
+ return undefined;
+ }),
+ );
+
+ const text = await callTool('get_buckets', { bucket_ids: ['in.c-main', 'in.c-missing'] });
+ expect(text).toContain('curated description');
+ expect(text).not.toContain('legacy auto-generated');
+ expect(text).toContain('in.c-missing'); // buckets_not_found
+ });
+});
+
+describe('get_tables', () => {
+ it('lists table summaries for a bucket (no FQN / columns)', async () => {
+ server.use(
+ verifyHandler(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const url = new URL(request.url);
+ if (url.pathname.endsWith('/tokens/verify'))
+ return HttpResponse.json({ owner: { id: '42' } });
+ if (url.pathname.endsWith('/buckets/in.c-main')) {
+ return HttpResponse.json({ id: 'in.c-main', name: 'main', stage: 'in', created: 'c' });
+ }
+ if (url.pathname.endsWith('/buckets/in.c-main/tables')) {
+ return HttpResponse.json([
+ {
+ id: 'in.c-main.users',
+ name: 'users',
+ displayName: 'Users',
+ primaryKey: ['id', 'email'],
+ rowsCount: 5,
+ },
+ ]);
+ }
+ return undefined;
+ }),
+ );
+
+ const text = await callTool('get_tables', { bucket_ids: ['in.c-main'] });
+ expect(text).toContain('in.c-main.users');
+ expect(text).toContain('id|email'); // primary key serialized as joined string
+ expect(text).not.toContain('fullyQualifiedName');
+ });
+
+ it('returns full table detail with columns and a Snowflake FQN from backendPath', async () => {
+ server.use(
+ verifyHandler(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const url = new URL(request.url);
+ if (url.pathname.endsWith('/tokens/verify'))
+ return HttpResponse.json({ owner: { id: '42' } });
+ if (url.pathname.endsWith('/tables/in.c-main.users')) {
+ return HttpResponse.json({
+ id: 'in.c-main.users',
+ name: 'users',
+ displayName: 'Users',
+ created: 'c',
+ bucket: { id: 'in.c-main', backendPath: ['DB', 'in.c-main'] },
+ columns: ['id', 'name'],
+ columnMetadata: {
+ id: [
+ { key: 'KBC.datatype.type', value: 'NUMBER', timestamp: 't' },
+ { key: 'KBC.datatype.nullable', value: '0', timestamp: 't' },
+ { key: 'KBC.description', value: 'identifier', timestamp: 't' },
+ ],
+ name: [{ key: 'KBC.datatype.nullable', value: '1', timestamp: 't' }],
+ },
+ });
+ }
+ return undefined;
+ }),
+ );
+
+ const text = await callTool('get_tables', { table_ids: ['in.c-main.users'] });
+ expect(text).toContain('fullyQualifiedName'); // FQN derived from backendPath
+ expect(text).toContain('DB'); // Snowflake-quoted backendPath parts present
+ expect(text).toContain('NUMBER');
+ expect(text).toContain('identifier');
+ // name column has no datatype.type -> defaults to VARCHAR
+ expect(text).toContain('VARCHAR');
+ });
+
+ it('reports tables_not_found for unknown table ids', async () => {
+ server.use(
+ verifyHandler(),
+ http.get('https://connection.test/*', ({ request }) => {
+ const url = new URL(request.url);
+ if (url.pathname.endsWith('/tokens/verify'))
+ return HttpResponse.json({ owner: { id: '42' } });
+ if (url.pathname.includes('/tables/')) {
+ return new HttpResponse(JSON.stringify({ error: 'nope' }), { status: 404 });
+ }
+ return undefined;
+ }),
+ );
+
+ const text = await callTool('get_tables', { table_ids: ['in.c-main.ghost'] });
+ expect(text).toContain('in.c-main.ghost');
+ });
+});
diff --git a/__tests__/workspace.test.ts b/__tests__/workspace.test.ts
new file mode 100644
index 000000000..48e98467f
--- /dev/null
+++ b/__tests__/workspace.test.ts
@@ -0,0 +1,528 @@
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+import { createRawClient } from '@/clients/raw';
+import { Config } from '@/config';
+import type { JobSubmittedInfo } from '@/workspace';
+import { WorkspaceManager } from '@/workspace';
+
+/**
+ * Port of `tests/test_workspace.py`.
+ *
+ * The TypeScript workspace layer is built on the `@keboola/api-client` Query Service
+ * client + a raw Storage client (no per-workspace `QueryServiceClient` mock surface like
+ * the Python version had), so these tests exercise the *exported* `WorkspaceManager`
+ * end-to-end over msw — mirroring `tools.sql.test.ts` — rather than reaching into private
+ * internals. They cover the same behaviors as the Python suite: branch-aware resolution,
+ * storage-branches fallback, schema/metadata/auto-create discovery, the on-job-submitted
+ * callback, cancellation short-circuiting, dialect quoting, and BigQuery error normalization.
+ */
+
+const STORAGE_URL = 'https://connection.test';
+const QUERY_URL = 'https://query.test';
+
+const server = setupServer();
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+const SNOWFLAKE_WS = {
+ id: 123,
+ connection: { backend: 'snowflake', schema: 'WORKSPACE_123', user: 'u' },
+ readOnlyStorageAccess: true,
+};
+
+const BIGQUERY_WS = {
+ id: 123,
+ connection: {
+ backend: 'bigquery',
+ schema: 'dataset_123',
+ user: JSON.stringify({ project_id: 'my-proj' }),
+ },
+ readOnlyStorageAccess: true,
+};
+
+/** Builds a WorkspaceManager wired to the msw-backed Storage + Query Service. */
+const makeManager = async (cfg: Config): Promise => {
+ const token = cfg.bearerToken ? `Bearer ${cfg.bearerToken}` : (cfg.storageToken ?? '');
+ const makeStorage = () => createRawClient({ baseUrl: `${STORAGE_URL}/v2/storage`, token });
+ return WorkspaceManager.create(cfg, {
+ rawStorage: makeStorage(),
+ makeProdRawStorage: makeStorage,
+ queryServiceUrl: QUERY_URL,
+ queryServiceToken: token,
+ });
+};
+
+/** Records every Storage API path requested, so tests can assert branch routing. */
+const recordingStorageHandler = (
+ paths: string[],
+ opts: {
+ features?: string[];
+ wsDetail?: Record;
+ metadata?: { key: string; value: unknown }[];
+ wsList?: Record[];
+ } = {},
+) =>
+ http.all(`${STORAGE_URL}/*`, ({ request }) => {
+ const p = new URL(request.url).pathname;
+ paths.push(p);
+ if (p.endsWith('/tokens/verify')) {
+ return HttpResponse.json({
+ owner: {
+ id: '42',
+ features: opts.features ?? [],
+ defaultBackend: 'snowflake',
+ },
+ });
+ }
+ if (/\/branch\/[^/]+\/metadata$/.test(p)) {
+ return HttpResponse.json(
+ opts.metadata ?? [{ key: WorkspaceManager.MCP_META_KEY, value: 123 }],
+ );
+ }
+ if (/\/branch\/[^/]+\/workspaces\/123$/.test(p)) {
+ return HttpResponse.json(opts.wsDetail ?? SNOWFLAKE_WS);
+ }
+ if (/\/branch\/[^/]+\/workspaces$/.test(p)) {
+ return HttpResponse.json(opts.wsList ?? [SNOWFLAKE_WS]);
+ }
+ if (p.endsWith('/dev-branches')) {
+ return HttpResponse.json([{ id: '999', isDefault: true }]);
+ }
+ return undefined;
+ });
+
+const querySuccessHandlers = (opts: {
+ columns?: { name: string }[];
+ data?: unknown[][];
+ numberOfRows?: number;
+ message?: string;
+ jobStatus?: string;
+ resultsStatus?: string;
+}) => [
+ http.post(`${QUERY_URL}/api/v1/branches/:bid/workspaces/:wid/queries`, () =>
+ HttpResponse.json({ queryJobId: 'job-1' }),
+ ),
+ http.get(`${QUERY_URL}/api/v1/queries/job-1`, () =>
+ HttpResponse.json({
+ status: opts.jobStatus ?? 'completed',
+ statements: [{ id: 'stmt-1' }],
+ }),
+ ),
+ http.get(`${QUERY_URL}/api/v1/queries/job-1/stmt-1/results`, () =>
+ HttpResponse.json({
+ status: opts.resultsStatus ?? 'completed',
+ columns: opts.columns ?? [{ name: 'col' }],
+ data: opts.data ?? [['v']],
+ numberOfRows: opts.numberOfRows ?? (opts.data ?? [['v']]).length,
+ message: opts.message ?? 'ok',
+ }),
+ ),
+];
+
+describe('WorkspaceManager.create — branch awareness', () => {
+ it.each([
+ // [label, branchId, hasSbFeature, expectedBranchInPath]
+ ['default branch always production (feature on)', undefined, true, 'default'],
+ ['default branch always production (feature off)', undefined, false, 'default'],
+ ['dev branch + storage-branches keeps dev branch', '456', true, '456'],
+ ['dev branch without storage-branches falls back to production', '456', false, 'default'],
+ ])('%s', async (_label, branchId, hasSb, expectedBranch) => {
+ const paths: string[] = [];
+ server.use(
+ recordingStorageHandler(paths, {
+ features: hasSb ? ['storage-branches'] : [],
+ }),
+ ...querySuccessHandlers({}),
+ );
+
+ const cfg = new Config({
+ storageApiUrl: STORAGE_URL,
+ storageToken: 'tok',
+ branchId,
+ });
+ const manager = await makeManager(cfg);
+ // Force workspace resolution (which issues the branch-scoped metadata lookup).
+ await manager.getWorkspaceId();
+
+ const metaPath = paths.find((p) => /\/branch\/[^/]+\/metadata$/.test(p));
+ expect(metaPath).toBeDefined();
+ expect(metaPath).toContain(`/branch/${expectedBranch}/metadata`);
+ });
+
+ it('skips the feature lookup on the default branch', async () => {
+ const paths: string[] = [];
+ server.use(recordingStorageHandler(paths, {}), ...querySuccessHandlers({}));
+
+ const cfg = new Config({ storageApiUrl: STORAGE_URL, storageToken: 'tok' });
+ const manager = await makeManager(cfg);
+ await manager.getWorkspaceId();
+
+ // On the default branch the feature check (tokens/verify) is short-circuited.
+ expect(paths.some((p) => p.endsWith('/tokens/verify'))).toBe(false);
+ });
+
+ it('performs the feature lookup on a dev branch', async () => {
+ const paths: string[] = [];
+ server.use(
+ recordingStorageHandler(paths, { features: ['storage-branches'] }),
+ ...querySuccessHandlers({}),
+ );
+
+ const cfg = new Config({
+ storageApiUrl: STORAGE_URL,
+ storageToken: 'tok',
+ branchId: '456',
+ });
+ const manager = await makeManager(cfg);
+ await manager.getWorkspaceId();
+
+ expect(paths.some((p) => p.endsWith('/tokens/verify'))).toBe(true);
+ });
+});
+
+describe('WorkspaceManager — workspace discovery', () => {
+ it('resolves a workspace via branch metadata + read-only detail', async () => {
+ const paths: string[] = [];
+ server.use(recordingStorageHandler(paths, {}), ...querySuccessHandlers({}));
+
+ const cfg = new Config({ storageApiUrl: STORAGE_URL, storageToken: 'tok' });
+ const manager = await makeManager(cfg);
+
+ expect(await manager.getWorkspaceId()).toBe(123);
+ expect(await manager.getSqlDialect()).toBe('Snowflake');
+ expect(paths.some((p) => /\/branch\/[^/]+\/metadata$/.test(p))).toBe(true);
+ });
+
+ it('resolves a workspace by explicit schema (never touches branch metadata)', async () => {
+ const paths: string[] = [];
+ server.use(
+ recordingStorageHandler(paths, { wsList: [SNOWFLAKE_WS] }),
+ ...querySuccessHandlers({}),
+ );
+
+ const cfg = new Config({
+ storageApiUrl: STORAGE_URL,
+ storageToken: 'tok',
+ workspaceSchema: 'WORKSPACE_123',
+ });
+ const manager = await makeManager(cfg);
+
+ expect(await manager.getWorkspaceId()).toBe(123);
+ // The schema path lists workspaces; it must not read branch metadata.
+ expect(paths.some((p) => /\/branch\/[^/]+\/workspaces$/.test(p))).toBe(true);
+ expect(paths.some((p) => /\/branch\/[^/]+\/metadata$/.test(p))).toBe(false);
+ });
+
+ it('throws when an explicit schema matches no workspace', async () => {
+ const paths: string[] = [];
+ server.use(recordingStorageHandler(paths, { wsList: [] }));
+
+ const cfg = new Config({
+ storageApiUrl: STORAGE_URL,
+ storageToken: 'tok',
+ workspaceSchema: 'MISSING',
+ });
+ const manager = await makeManager(cfg);
+
+ await expect(manager.getWorkspaceId()).rejects.toThrow(/No Keboola workspace found/);
+ });
+
+ it('auto-creates a workspace + writes metadata when none exists', async () => {
+ const posts: { path: string; body: unknown }[] = [];
+ server.use(
+ http.all(`${STORAGE_URL}/*`, async ({ request }) => {
+ const p = new URL(request.url).pathname;
+ if (request.method === 'POST') {
+ posts.push({ path: p, body: await request.clone().json() });
+ }
+ if (p.endsWith('/tokens/verify')) {
+ return HttpResponse.json({
+ owner: { id: '42', defaultBackend: 'snowflake' },
+ });
+ }
+ // No existing workspace recorded in metadata.
+ if (/\/branch\/[^/]+\/metadata$/.test(p) && request.method === 'GET') {
+ return HttpResponse.json([]);
+ }
+ // POST metadata write-back.
+ if (/\/branch\/[^/]+\/metadata$/.test(p) && request.method === 'POST') {
+ return HttpResponse.json([{ key: WorkspaceManager.MCP_META_KEY, value: 123 }]);
+ }
+ // Create config under the billing component.
+ if (/\/components\/[^/]+\/configs$/.test(p) && request.method === 'POST') {
+ return HttpResponse.json({ id: 'cfg-1' });
+ }
+ // Create workspace -> returns an async job id.
+ if (/\/configs\/[^/]+\/workspaces$/.test(p) && request.method === 'POST') {
+ return HttpResponse.json({ id: 9001 });
+ }
+ // Poll the job: immediately successful.
+ if (p.endsWith('/jobs/9001')) {
+ return HttpResponse.json({ status: 'success', results: { id: 123 } });
+ }
+ // Resolve the created workspace by id.
+ if (/\/branch\/[^/]+\/workspaces\/123$/.test(p)) {
+ return HttpResponse.json(SNOWFLAKE_WS);
+ }
+ return undefined;
+ }),
+ );
+
+ const cfg = new Config({ storageApiUrl: STORAGE_URL, storageToken: 'tok' });
+ const manager = await makeManager(cfg);
+
+ expect(await manager.getWorkspaceId()).toBe(123);
+ // A config + a workspace were created, and the id was written back to metadata.
+ expect(posts.some((x) => /\/components\/[^/]+\/configs$/.test(x.path))).toBe(true);
+ expect(posts.some((x) => /\/configs\/[^/]+\/workspaces$/.test(x.path))).toBe(true);
+ const metaWrite = posts.find((x) => /\/branch\/[^/]+\/metadata$/.test(x.path));
+ expect(metaWrite).toBeDefined();
+ expect(JSON.stringify(metaWrite!.body)).toContain(WorkspaceManager.MCP_META_KEY);
+ });
+
+ it('cleans up the created config when workspace creation fails', async () => {
+ const deleted: string[] = [];
+ server.use(
+ http.all(`${STORAGE_URL}/*`, ({ request }) => {
+ const p = new URL(request.url).pathname;
+ if (request.method === 'DELETE') deleted.push(p);
+ if (p.endsWith('/tokens/verify')) {
+ return HttpResponse.json({
+ owner: { id: '42', defaultBackend: 'snowflake' },
+ });
+ }
+ if (/\/branch\/[^/]+\/metadata$/.test(p) && request.method === 'GET') {
+ return HttpResponse.json([]);
+ }
+ if (/\/components\/[^/]+\/configs$/.test(p) && request.method === 'POST') {
+ return HttpResponse.json({ id: 'cfg-1' });
+ }
+ // Workspace creation fails hard.
+ if (/\/configs\/[^/]+\/workspaces$/.test(p) && request.method === 'POST') {
+ return new HttpResponse('boom', { status: 500 });
+ }
+ // Config cleanup.
+ if (/\/components\/[^/]+\/configs\/cfg-1$/.test(p) && request.method === 'DELETE') {
+ return new HttpResponse(null, { status: 204 });
+ }
+ return undefined;
+ }),
+ );
+
+ const cfg = new Config({ storageApiUrl: STORAGE_URL, storageToken: 'tok' });
+ const manager = await makeManager(cfg);
+
+ await expect(manager.getWorkspaceId()).rejects.toThrow();
+ expect(deleted.some((p) => /\/components\/[^/]+\/configs\/cfg-1$/.test(p))).toBe(true);
+ });
+});
+
+describe('WorkspaceManager — dialect quoting', () => {
+ it('Snowflake quotes identifiers with double quotes', async () => {
+ server.use(recordingStorageHandler([], {}));
+ const cfg = new Config({ storageApiUrl: STORAGE_URL, storageToken: 'tok' });
+ const manager = await makeManager(cfg);
+ expect(await manager.getQuotedName('foo')).toBe('"foo"');
+ });
+
+ it('BigQuery quotes identifiers with backticks', async () => {
+ server.use(recordingStorageHandler([], { wsDetail: BIGQUERY_WS }));
+ const cfg = new Config({ storageApiUrl: STORAGE_URL, storageToken: 'tok' });
+ const manager = await makeManager(cfg);
+ expect(await manager.getQuotedName('foo')).toBe('`foo`');
+ expect(await manager.getSqlDialect()).toBe('BigQuery');
+ });
+
+ it('rejects a BigQuery workspace without a project id in credentials', async () => {
+ server.use(
+ recordingStorageHandler([], {
+ wsDetail: {
+ id: 123,
+ connection: { backend: 'bigquery', schema: 'ds', user: '{}' },
+ readOnlyStorageAccess: true,
+ },
+ }),
+ );
+ const cfg = new Config({ storageApiUrl: STORAGE_URL, storageToken: 'tok' });
+ const manager = await makeManager(cfg);
+ await expect(manager.getSqlDialect()).rejects.toThrow(/no project ID/i);
+ });
+});
+
+describe('WorkspaceManager.executeQuery — Query Service submit/poll/paginate', () => {
+ it('runs a SELECT and returns columns/rows + selected-rows message', async () => {
+ server.use(
+ recordingStorageHandler([], {}),
+ ...querySuccessHandlers({
+ columns: [{ name: 'id' }, { name: 'name' }],
+ data: [
+ ['1', 'Alice'],
+ ['2', 'Bob'],
+ ],
+ numberOfRows: 2,
+ }),
+ );
+
+ const cfg = new Config({ storageApiUrl: STORAGE_URL, storageToken: 'tok' });
+ const manager = await makeManager(cfg);
+ const result = await manager.executeQuery('SELECT * FROM t');
+
+ expect(result.status).toBe('ok');
+ expect(result.data?.columns).toEqual(['id', 'name']);
+ expect(result.data?.rows).toEqual([
+ { id: '1', name: 'Alice' },
+ { id: '2', name: 'Bob' },
+ ]);
+ expect(result.message).toContain('Returning 2 of 2 selected rows.');
+ });
+
+ it('invokes on_job_submitted once with the full job info', async () => {
+ server.use(recordingStorageHandler([], {}), ...querySuccessHandlers({}));
+
+ const cfg = new Config({ storageApiUrl: STORAGE_URL, storageToken: 'tok' });
+ const manager = await makeManager(cfg);
+
+ const received: JobSubmittedInfo[] = [];
+ await manager.executeQuery('SELECT 1', {
+ onJobSubmitted: async (info) => {
+ received.push(info);
+ },
+ });
+
+ expect(received).toHaveLength(1);
+ expect(received[0]).toEqual({
+ job_id: 'job-1',
+ cancellation_url: `${QUERY_URL}/api/v1/queries/job-1/cancel`,
+ backend: 'snowflake',
+ });
+ });
+
+ it('swallows a callback exception and still completes the query', async () => {
+ server.use(recordingStorageHandler([], {}), ...querySuccessHandlers({}));
+
+ const cfg = new Config({ storageApiUrl: STORAGE_URL, storageToken: 'tok' });
+ const manager = await makeManager(cfg);
+
+ const result = await manager.executeQuery('SELECT 1', {
+ onJobSubmitted: async () => {
+ throw new Error('progress send failed');
+ },
+ });
+
+ expect(result.status).toBe('ok');
+ });
+
+ it('rejects non-positive max_rows / max_chars', async () => {
+ server.use(recordingStorageHandler([], {}), ...querySuccessHandlers({}));
+ const cfg = new Config({ storageApiUrl: STORAGE_URL, storageToken: 'tok' });
+ const manager = await makeManager(cfg);
+
+ await expect(manager.executeQuery('SELECT 1', { maxRows: 0 })).rejects.toThrow(/max_rows/);
+ await expect(manager.executeQuery('SELECT 1', { maxChars: 0 })).rejects.toThrow(/max_chars/);
+ });
+
+ it('truncates results to max_rows', async () => {
+ server.use(
+ recordingStorageHandler([], {}),
+ ...querySuccessHandlers({
+ columns: [{ name: 'n' }],
+ data: [['1'], ['2'], ['3'], ['4'], ['5']],
+ numberOfRows: 5,
+ }),
+ );
+ const cfg = new Config({ storageApiUrl: STORAGE_URL, storageToken: 'tok' });
+ const manager = await makeManager(cfg);
+
+ const result = await manager.executeQuery('SELECT n', { maxRows: 2 });
+ expect(result.data?.rows).toHaveLength(2);
+ expect(result.message).toContain('Returning 2 of 5 selected rows.');
+ });
+
+ it('truncates results to max_chars on the first row that does not fit', async () => {
+ server.use(
+ recordingStorageHandler([], {}),
+ ...querySuccessHandlers({
+ columns: [{ name: 'v' }],
+ data: [['aaa'], ['bbb'], ['ccc']], // 3 chars each
+ numberOfRows: 3,
+ }),
+ );
+ const cfg = new Config({ storageApiUrl: STORAGE_URL, storageToken: 'tok' });
+ const manager = await makeManager(cfg);
+
+ // Room for two rows (6 chars), not the third.
+ const result = await manager.executeQuery('SELECT v', { maxChars: 6 });
+ expect(result.data?.rows).toEqual([{ v: 'aaa' }, { v: 'bbb' }]);
+ });
+});
+
+describe('WorkspaceManager.executeQuery — error handling', () => {
+ it('returns an error result when the query fails', async () => {
+ server.use(
+ recordingStorageHandler([], {}),
+ ...querySuccessHandlers({
+ jobStatus: 'failed',
+ resultsStatus: 'failed',
+ columns: [],
+ data: [],
+ message: 'boom syntax error',
+ }),
+ );
+
+ const cfg = new Config({ storageApiUrl: STORAGE_URL, storageToken: 'tok' });
+ const manager = await makeManager(cfg);
+ const result = await manager.executeQuery('SELECT bad');
+
+ expect(result.status).toBe('error');
+ expect(result.data).toBeFalsy();
+ expect(result.message).toContain('boom syntax error');
+ });
+
+ it('short-circuits a cancelled job with a clean message', async () => {
+ server.use(
+ recordingStorageHandler([], {}),
+ http.post(`${QUERY_URL}/api/v1/branches/:bid/workspaces/:wid/queries`, () =>
+ HttpResponse.json({ queryJobId: 'job-1' }),
+ ),
+ http.get(`${QUERY_URL}/api/v1/queries/job-1`, () =>
+ HttpResponse.json({
+ status: 'canceled',
+ statements: [{ id: 'stmt-1' }],
+ }),
+ ),
+ );
+
+ const cfg = new Config({ storageApiUrl: STORAGE_URL, storageToken: 'tok' });
+ const manager = await makeManager(cfg);
+ const result = await manager.executeQuery('SELECT 1');
+
+ expect(result.status).toBe('error');
+ expect(result.data).toBeNull();
+ expect(result.message).toBe('Query was cancelled');
+ });
+
+ it('normalizes BigQuery error messages to the Message: "..." part', async () => {
+ server.use(
+ recordingStorageHandler([], { wsDetail: BIGQUERY_WS }),
+ ...querySuccessHandlers({
+ jobStatus: 'failed',
+ resultsStatus: 'failed',
+ columns: [],
+ data: [],
+ message:
+ 'Location: "query"; Message: "Syntax error: Unexpected identifier"; Reason: "invalidQuery"',
+ }),
+ );
+
+ const cfg = new Config({ storageApiUrl: STORAGE_URL, storageToken: 'tok' });
+ const manager = await makeManager(cfg);
+ const result = await manager.executeQuery('SELECT bad');
+
+ expect(result.status).toBe('error');
+ expect(result.message).toBe('Syntax error: Unexpected identifier');
+ expect(result.message).not.toContain('Reason:');
+ });
+});
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 000000000..f96eda377
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,82 @@
+# Local dev stack for the MCP server + its docs-search index.
+#
+# Three services:
+# pgvector — Postgres + pgvector, backs the docs-search index (docs_query / find_component_id).
+# docs-seed — one-shot: applies the schema + seeds the fixture corpus (migrate + seed), then exits.
+# mcp — the MCP server (production image) over streamable-HTTP on :8000.
+#
+# The MCP only ever *reads* the index; docs-seed is the local mirror of the production
+# out-of-band build (see feature_spec/docs-search-pgvector/).
+#
+# Embedder: defaults to the deterministic `stub` (offline, no download, no key) — good for a
+# smoke stack, but NOT semantic, so realistic queries return nothing. For real semantic
+# search with no external service, flip the shared `x-docs-embedder` anchor below to the
+# in-process HuggingFace model:
+# DOCS_EMBEDDER_MODEL: local
+# DOCS_EMBEDDER_DIM: '384'
+# (or point DOCS_EMBEDDER_ENDPOINT/API_KEY/MODEL at a remote embedder). The anchor is shared
+# by docs-seed and mcp so build-time and query-time embedders can never drift.
+#
+# Usage:
+# docker compose up -d --wait pgvector # just Postgres (used by `npm run test:integ`)
+# docker compose up --build # full stack: pgvector -> docs-seed -> mcp
+# curl localhost:8000/health-check # MCP health once up ({"status":"ok"})
+
+# Shared docs-index env — edit here to switch stub / local / remote for BOTH services at once.
+x-docs-embedder: &docs-embedder
+ DATABASE_URL: postgres://mcp:mcp@pgvector:5432/docs
+ DOCS_EMBEDDER_MODEL: stub
+ DOCS_EMBEDDER_DIM: '3072'
+
+services:
+ pgvector:
+ # pgvector >= 0.7 is required for halfvec(3072); the pg17 tag ships it.
+ image: pgvector/pgvector:pg17
+ environment:
+ POSTGRES_USER: mcp
+ POSTGRES_PASSWORD: mcp
+ POSTGRES_DB: docs
+ ports:
+ - '5432:5432'
+ volumes:
+ - pgvector-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ['CMD-SHELL', 'pg_isready -U mcp -d docs']
+ interval: 5s
+ timeout: 3s
+ retries: 10
+
+ # Migrate the schema + seed the fixture corpus, then exit. Runs the compiled seeder
+ # (`dist/docs-build.js`) from the same image the MCP runs — no tsx/scripts needed.
+ docs-seed:
+ build: .
+ image: keboola-mcp-server:local
+ command: ['node', 'dist/docs-build.js']
+ environment: *docs-embedder
+ depends_on:
+ pgvector:
+ condition: service_healthy
+ restart: 'no'
+
+ mcp:
+ image: keboola-mcp-server:local
+ # Reuses the image built by docs-seed (build: .). streamable-HTTP is the deployed mode.
+ command: ['node', 'dist/index.js', '--transport', 'streamable-http']
+ ports:
+ - '8000:8000'
+ environment:
+ # Shared docs-index embedder (same as the seeder — must match, hence the anchor).
+ <<: *docs-embedder
+ HOST: 0.0.0.0
+ PORT: '8000'
+ # HTTP mode is multi-tenant: clients pass the Storage token per request via
+ # X-StorageApi-Token / Authorization headers, so no KBC_* token is needed at boot.
+ # For a single-tenant local run you can bake them in instead:
+ # KBC_STORAGE_API_URL: https://connection..keboola.com
+ # KBC_STORAGE_TOKEN:
+ depends_on:
+ docs-seed:
+ condition: service_completed_successfully
+
+volumes:
+ pgvector-data:
diff --git a/feature_spec/docs-search-pgvector/RFC.md b/feature_spec/docs-search-pgvector/RFC.md
new file mode 100644
index 000000000..91f4b86ec
--- /dev/null
+++ b/feature_spec/docs-search-pgvector/RFC.md
@@ -0,0 +1,183 @@
+# RFC: Replace the AI docs-service with the pgvector docs-search SDK
+
+Linear: PSGO-268 (TypeScript rewrite follow-up) · depends on
+[`keboola/ui#6672`](https://github.com/keboola/ui/pull/6672) —
+`@keboola/docs-search` (pgvector index builder + retrieval SDK).
+
+## Problem
+
+Three MCP tools currently reach the **AI service** (`ai.` / the legacy Python
+LanceDB-Qdrant docs index) for documentation intelligence:
+
+| Tool | Today (AI service) | What it needs |
+| --- | --- | --- |
+| `docs_query` | `rawAi` `POST docs/question` | Q&A over Keboola docs with source URLs |
+| `find_component_id` | `ai.suggestComponent` (`POST suggest/component`) | component recommendation for a task |
+| `get_config_examples` / `fetchComponent` docs | `rawAi` `GET docs/components/{id}` | a component's documentation page |
+
+Problems with the AI-service dependency:
+- **Reliability**: live runs show intermittent `422 Request contents is not valid` from
+ `docs/question` and read-timeouts — the docs path is the flakiest surface in the suite.
+- **Opaque, external, single-region**: an out-of-band service the MCP can't reason about,
+ version, or run locally; couples MCP availability to it.
+- **Duplication**: `keboola/ui#6672` introduces `@keboola/docs-search`, a first-class SDK
+ that builds the docs index into **pgvector** and serves the exact same three retrieval
+ shapes. Continuing to call the AI service means maintaining two doc backends.
+
+The rewrite already reuses `@keboola/api-client` for everything else; docs should likewise
+move to the published SDK, backed by a Postgres/pgvector index that is **built out-of-band
+and only read at runtime**.
+
+## Required Behavior
+
+| # | Requirement |
+| --- | --- |
+| 1 | `docs_query`, `find_component_id`, and component-docs retrieval are served by `@keboola/docs-search` (`answerQuestion` / `recommendComponents` / `getComponentDoc`) against a pgvector index — **same tool names, params, and output shapes** (`DocsAnswer{text, source_urls}`, suggested-component list, component doc). |
+| 2 | The **AI-service integration is removed entirely**: `rawAi`, `ai` (typed client), the `ai`/`docs/*`/`suggest/*` endpoints and their URL derivation are deleted from the MCP. |
+| 3 | **Postgres is added to `docker-compose`** (dev) and consumed via a single `DATABASE_URL` (pgvector-enabled instance) at runtime. |
+| 4 | The index is **filled by a separate build job** (the `@keboola/docs-search` `runIndexBuild` + the source connectors), not by the MCP. A **cron job** rebuilds it periodically (incremental: only changed docs are re-embedded). |
+| 5 | The docs tools are available **only when the MCP has access to the docs index** (a reachable, migrated, non-empty pgvector DB). When it isn't, the three docs tools degrade gracefully (filtered out / clear "docs index unavailable" error) — the rest of the server is unaffected. |
+| 6 | **Auth/gate** (open question, see below): the docs index is **global, non-tenant data**; the MCP reads it with its own server-side `DATABASE_URL`. No per-user docs token is introduced; the tools follow normal read-only tool visibility. |
+| 7 | The MCP has **no build-time dependency on index creation** — it starts and serves even if the index is stale/missing; index building lives entirely in the aside job. |
+
+### Retrieval mapping (SDK → tools)
+
+```
+docs_query(query) -> createDocsSearch({pool,embedder,llm}).answerQuestion(query) -> {text, source_urls}
+find_component_id(query) -> .recommendComponents(query) -> [{componentId, score}]
+get_config_examples(id) /
+ component docs (fetchComponent) -> .getComponentDoc(id) -> ParentDoc | null
+```
+
+The SDK is **dependency-injected** (`pool: pg.Pool`, `embedder: Embedder`, optional `llm: Llm`)
+and owns no globals — it fits the MCP's per-request client-factory model.
+
+### Open question — the access gate (point 5)
+
+The docs index is **global** (Keboola help/dev docs + the public component catalog), not
+project data, so reading it does not require a Storage-scoped grant. Candidate gates, with a
+recommendation:
+
+| Option | Meaning | Assessment |
+| --- | --- | --- |
+| **No auth on the index; tool gated by a valid MCP session** (recommended) | MCP holds `DATABASE_URL` (infra secret); the three docs tools are offered to any authenticated session, like other read-only tools | Simplest; matches that docs are non-sensitive/global; the DB is never exposed to the client |
+| Storage token required | Reuse the per-request Storage token as the gate | Adds no real security (docs aren't project-scoped) but keeps "must be a Keboola user" |
+| Management token | Gate on a management-scoped token | Overkill; docs are not org-admin data |
+| Application/session token to the embedder | Needed only for the **embedder** (query embedding) if it calls a hosted embedding API | Orthogonal to index access — see below |
+
+**Recommendation:** treat index access as **infrastructure** (server-side `DATABASE_URL`), keep
+the tools visible to any authenticated session, and make the **embedder** credential a
+deployment secret (the same way the AI service key is a deployment concern today), not a user
+token. Final call to be confirmed with platform security before implementation.
+
+## Resolution Strategy
+
+Runtime (MCP) — thin, read-only:
+- Add a `docsSearch` to the client factory: build a shared `pg.Pool` from `DATABASE_URL` and an
+ `Embedder` from deployment config, then `createDocsSearch({ pool, embedder, llm })`. The pool
+ is process-scoped (not per-request) and injected where the three tools need it.
+- Rewrite `src/tools/doc.ts` (`docs_query` → `answerQuestion`), the `find_component_id` handler
+ in `src/tools/search/` (→ `recommendComponents`), and the component-docs reads in
+ `src/tools/components/` + `get_config_examples` (→ `getComponentDoc`). Preserve tool
+ names/descriptions/output shapes exactly.
+- **Delete** the AI surface: `rawAi`, the typed `ai` client, `urls.ai`, and related config.
+- Availability gate: at server build, probe the index (`index_meta.last_success_at` present +
+ reachable). If absent, filter the three docs tools out of `tools/list` and deny calls with a
+ clear message (parity with the existing feature-gating in `src/mcp/filtering.ts`).
+- Config: add `DATABASE_URL` (+ embedder endpoint/key/model) to `src/env.ts` as **optional**
+ deployment env; the server boots without them (docs tools just gate off).
+
+Index build (aside — NOT in the MCP request path):
+- A separate job (own package/app or a small `scripts/` entry) runs the connectors (git clone
+ of help/dev docs + component catalog + frontmatter parse), then `runIndexBuild(pool, {sources,
+ embedder, gates})`. Transactional + incremental: COMMIT iff gates pass, else ROLLBACK (the
+ prior index stays intact). This is scheduled by **cron** (see the architecture doc).
+- The MCP and the build job share only the Postgres database (the index), never code paths.
+
+Local dev:
+- `docker-compose` gains a `pgvector/pgvector` service; `migrate(pool)` (from the SDK) applies
+ `migrations/*.sql` (creates the `vector` extension + `doc`/`doc_chunk`/`index_manifest`/
+ `index_meta` tables + HNSW index). A `npm run docs:build` convenience wires the connectors +
+ `runIndexBuild` against the local DB so a developer can populate and query locally.
+
+### Non-obvious trade-offs
+- **Embedder at query time**: `answerQuestion`/`recommendComponents` embed the query, so the MCP
+ needs an `Embedder` (a hosted embedding endpoint or a local model). This is the one remaining
+ external call; it replaces the AI service with a much narrower dependency (embeddings only,
+ no bespoke retrieval service). `getComponentDoc` needs **no** embedder (direct lookup).
+- **`llm` for `answerQuestion`**: Q&A needs an LLM to synthesize the answer from retrieved docs.
+ Provided via deployment config; if absent, `docs_query` can fall back to returning top
+ retrieved snippets (`search`) rather than a synthesized answer.
+- **Stale index tolerance**: because the MCP only reads, a failed/late build serves the last
+ good index; the MCP never blocks on building.
+
+## Scope
+
+In scope: swapping the three docs tools onto `@keboola/docs-search`; removing the AI-service
+client/URLs/config; adding Postgres to docker-compose + optional `DATABASE_URL`/embedder env;
+the availability gate; a local `docs:build` path; unit tests (mock the SDK) + an integ test
+(against a seeded local pgvector).
+
+Out of scope (tracked in the architecture doc): the production index-build **app + cron**
+deployment, the **source connectors** (git clone/frontmatter — owned by the build side per
+#6672), Terraform for the provisioned Postgres + pgvector extension, and embedder-provider
+selection/procurement.
+
+## Implementation status
+
+Landed in the TypeScript server (PSGO-268), with two deliberate deviations from the
+original plan, agreed during implementation:
+
+1. **`get_config_examples` and `fetchComponent` stay on the AI catalog.** The pgvector
+ index holds *markdown documentation pages*, not the structured component metadata
+ (`configurationSchema`, `rootConfigurationExamples`, sync actions) these paths need —
+ `getComponentDoc` returns `{content, sourceUrl, title}`, which cannot back config
+ validation. So **only the two genuine semantic-retrieval tools were migrated**:
+ `docs_query` → `answerQuestion`, `find_component_id` → `recommendComponents`. The raw
+ AI-catalog client (`rawAi`) is retained solely for `docs/components/{id}`; the unused
+ typed `ai` client (`suggestComponent`) was removed. Full AI removal (point 2) is a
+ follow-up that needs a component-metadata replacement, not just docs-search.
+2. **The SDK is vendored, temporarily.** `@keboola/docs-search` is a private,
+ unpublished workspace package (keboola/ui#6672), so its ~150-line retrieval tier is
+ copied into `src/clients/docsSearch.ts` behind the exact `DocsSearch` interface, with a
+ documented swap-to-published-package path. The one SDK gap this needed —
+ `recommendComponents` exposing each result's `sourceKey` (the component id lives in
+ `source_key = 'component:'`, which the SDK's SELECT dropped) — was fixed on
+ keboola/ui#6672 (`aa8a779`), so the vendored SELECT matches the published shape and the
+ swap will be a clean drop-in.
+
+The gate (point 5) is wired via `DOCS_INDEX_TOOL_NAMES` in `mcp/filtering.ts`: the two
+tools are filtered from `tools/list` and denied on call when `getDocsSearch()` returns
+`null` (no `DATABASE_URL` / embedder creds). Env (point 3) is `DATABASE_URL` +
+`DOCS_EMBEDDER_*` + optional `DOCS_LLM_*` in `env.ts`; `docker-compose.yml` adds the
+`pgvector/pgvector` service. The production index build (point 4) remains out of scope
+here — see the architecture doc.
+
+**Local + CI end-to-end.** A developer can run the two tools against a real local index
+with no live services and no API key:
+
+```
+docker compose up -d pgvector
+DATABASE_URL=postgres://mcp:mcp@localhost:5432/docs DOCS_EMBEDDER_MODEL=stub npm run docs:build
+# then point the MCP at DATABASE_URL + DOCS_EMBEDDER_MODEL=stub
+```
+
+`npm run docs:build` (`scripts/docs-build.ts` + `scripts/docsIndex.ts`) migrates the schema
+and seeds a small fixture corpus using the deterministic offline `StubEmbedder`
+(`DOCS_EMBEDDER_MODEL=stub`), which the provider also uses at query time so build and query
+embed identically. This is the dev mirror of the production out-of-band build (it seeds
+fixtures, not real docs — the source connectors stay on the build side per #6672).
+
+## Testing / Verification
+- **Unit**: `docs_query` / `find_component_id` / `get_config_examples` with an injected fake
+ `DocsSearch` (stub `search`/`answerQuestion`/`getComponentDoc`/`recommendComponents`) — assert
+ the tools map inputs/outputs unchanged. Availability-gate tests (index present/absent → tool
+ visible/denied). No network.
+- **Integration** (`integtests/tools/doc.test.ts`): provisions a real `pgvector` via
+ testcontainers, seeds the fixture corpus with the deterministic `StubEmbedder`, and drives
+ `docs_query` + `find_component_id` through the MCP client, asserting real retrieval and
+ component-id recovery. Self-contained (needs Docker; skips with a warning if unavailable),
+ so the existing `integration_tests` CI job runs it with no extra wiring. Mirrors the SDK's
+ own testcontainers integ tier.
+- **Parity check**: compare outputs against the current AI-service tools for a fixed query set
+ before deleting the AI path.
diff --git a/feature_spec/docs-search-pgvector/architecture.md b/feature_spec/docs-search-pgvector/architecture.md
new file mode 100644
index 000000000..01304eb59
--- /dev/null
+++ b/feature_spec/docs-search-pgvector/architecture.md
@@ -0,0 +1,156 @@
+# Architecture & Rollout: Docs-search on Postgres/pgvector
+
+Companion to [`RFC.md`](./RFC.md). This document covers the **infrastructure and rollout** for
+serving MCP documentation intelligence from a pgvector index built out-of-band — the pieces the
+platform doesn't have yet (a provisioned Postgres with `pgvector`, the extension install path, a
+scheduled index-build job) and how they fit together.
+
+Guiding principle: **the MCP server reads a prebuilt index; it never builds one.** Index
+creation is a separate, scheduled concern. If the builder is down or mid-run, the MCP keeps
+serving the last committed index.
+
+## 1. Components & topology
+
+```
+ ┌──────────────────────────────────────────────────────────┐
+ docs sources │ Index Builder (aside job / cron) │
+ (help repo, │ connectors: git clone + frontmatter parse + URL derive │
+ dev-portal, │ @keboola/docs-search: chunk → hash-diff → embed(changed)│
+ component │ → runIndexBuild(): txn COMMIT iff gates pass else ROLLBACK│
+ catalog) └───────────────┬──────────────────────────────────────────┘
+ │ writes (transactional)
+ ▼
+ ┌────────────────────────┐ embeddings
+ │ Postgres + pgvector │◀─────── Embedder (hosted embedding API
+ │ doc / doc_chunk / │ or self-hosted model)
+ │ index_manifest / │
+ │ index_meta (HNSW idx) │
+ └───────────┬─────────────┘
+ │ reads only (SELECT + vector search)
+ ▼
+ ┌────────────────────────┐
+ │ MCP server (per pod) │ createDocsSearch({pool, embedder, llm})
+ │ docs_query / │ → answerQuestion / recommendComponents
+ │ find_component_id / │ → getComponentDoc
+ │ get_config_examples │
+ └────────────────────────┘
+```
+
+Two independent deployables share **only the database**:
+- **Index Builder** — writes the index. Scheduled (cron). Owns the connectors + `runIndexBuild`.
+- **MCP server** — reads the index. Stateless w.r.t. the index; one shared `pg.Pool` per pod.
+
+## 2. Postgres + pgvector provisioning (new dependency)
+
+The platform has no Postgres for the MCP today. Rollout:
+
+- **Instance**: a managed Postgres (per stack/region) reachable by both the MCP pods and the
+ builder job. Sized for the index (docs are small; the embeddings dominate — `halfvec(3072)`
+ ≈ 6 KB/chunk; a few 10k chunks ⇒ low hundreds of MB + the HNSW index). Start small; it is
+ read-mostly with a periodic write burst.
+- **Extension `pgvector` (≥ 0.7 for `halfvec`)**: must be installed/allow-listed on the
+ instance. Managed-Postgres offerings differ:
+ - Cloud SQL / RDS / Azure Flexible: enable `vector` from the supported-extensions list, then
+ `CREATE EXTENSION vector;` (the SDK's idempotent migration does this — but the extension
+ must be *permitted* first).
+ - Self-managed: install the `pgvector` package into the image/host.
+- **Terraform** (per the platform's `kbc-stacks` conventions):
+ - a `postgresql` instance/database + a least-privilege role for the MCP (read: `SELECT` on
+ `doc`/`doc_chunk`/`index_meta`) and a separate role for the builder (read/write + DDL for
+ migrations).
+ - enable the `vector` extension flag on the instance (provider-specific: e.g. Cloud SQL
+ `database_flags`/enabled-extensions, or an `apt`/image step for self-managed).
+ - output `DATABASE_URL` (or split host/port/db/user/password) into the MCP + builder secrets.
+ - network/SG rules so both workloads can reach the DB; TLS required.
+- **Migrations**: `@keboola/docs-search` ships idempotent `migrations/*.sql` (`migrate(pool)`),
+ applied by the **builder** at build start (it owns DDL). The MCP role needs no DDL.
+
+## 3. The index build job (cron)
+
+- **What it runs**: the source connectors (git clone of the help + dev-portal repos, fetch the
+ component catalog, parse frontmatter, derive canonical URLs) → `runIndexBuild(pool, {sources,
+ embedder, gates})`.
+- **Incremental**: content-hash diff via `index_manifest` — only changed docs are re-embedded
+ (embedding is the cost/latency driver). Unchanged docs are untouched.
+- **Transactional + gated**: the build COMMITs only if validation gates pass
+ (`minDocs`/`minComponents`/`maxDocDropPct`) — otherwise ROLLBACK, leaving the previous index
+ intact. A bad docs push or a connector regression can never publish an empty/broken index.
+- **Schedule**: cron (e.g. hourly/daily depending on docs churn) as a k8s `CronJob` (or the
+ platform's scheduler). Single-flight: overlapping runs must not race — use a build advisory
+ lock or `concurrencyPolicy: Forbid`.
+- **Embedder**: the builder needs the embedding provider credential (deployment secret). Same
+ model/dim as the MCP's query-time embedder (must match — `index_meta.embedding_model`/`dim`
+ record it; the MCP can assert compatibility on startup).
+- **Observability**: emit build duration, docs added/changed/removed, embed calls, gate
+ outcome, and `index_meta.last_success_at`. Alert if `last_success_at` age exceeds an SLO.
+
+## 4. Index lifecycle & the MCP's read contract
+
+- **Freshness**: MCP reads whatever is committed; staleness bounded by the cron cadence. No
+ runtime coupling to the builder.
+- **Availability probe**: on server build the MCP checks the index is reachable + populated
+ (`index_meta.last_success_at IS NOT NULL`, `doc_count > 0`). Result drives tool visibility
+ (RFC point 5): index healthy → docs tools offered; else filtered out + calls denied with a
+ clear message. The rest of the MCP is unaffected.
+- **Model-compatibility guard**: if the MCP's query embedder model/dim ≠ `index_meta`'s, the
+ docs tools gate off (mismatched vectors would return garbage) and log a loud warning.
+- **Connection management**: one `pg.Pool` per MCP pod (small max; the docs tools are
+ low-QPS), created at startup, closed on shutdown.
+
+## 5. Failure modes
+
+| Failure | Effect | Mitigation |
+| --- | --- | --- |
+| Builder fails / gates fail | No new index published | ROLLBACK keeps last good index; MCP unaffected; alert on `last_success_at` age |
+| Postgres unreachable from MCP | Docs tools unavailable | Availability probe gates the 3 tools off; other tools keep working |
+| Embedder (query) down | `docs_query`/`find_component_id` error | Tool-level error; `getComponentDoc` still works (no embedder); optional `search`-only fallback for `docs_query` |
+| Extension not enabled | Migration/build fails | Terraform enables `vector`; builder migration is the canary |
+| Model/dim drift builder↔MCP | Wrong results | Startup compatibility guard gates docs tools off |
+| Index empty on first rollout | Docs tools gated off until first successful build | Ship builder + run once before enabling docs tools in prod |
+
+## 6. Rollout sequence
+
+1. **Land `@keboola/docs-search`** (#6672) + the source connectors (its follow-up).
+2. **Provision Postgres + `pgvector`** per stack via Terraform; wire `DATABASE_URL` secrets to
+ both the MCP and the builder.
+3. **Deploy the builder + cron**; run once; verify `index_meta` populated and gates pass.
+4. **Ship the MCP docs-search integration** behind the availability probe (RFC) — with the
+ AI-service path still present, feature-flagged, so we can A/B the outputs.
+5. **Parity-verify** the three tools against the AI service on a fixed query set.
+6. **Cut over**: enable docs-search, **remove the AI-service integration** (RFC point 2).
+7. **Decommission** the legacy AI docs service once no MCP/stack references it.
+
+## 7. Security & cost notes
+
+- **Index access is infrastructure**, not a user grant: the MCP holds `DATABASE_URL`; clients
+ never touch Postgres. Docs are global/non-tenant, so no per-project authorization is needed
+ (see RFC §"the access gate"). Confirm with platform security before cutover.
+- **Least privilege**: distinct DB roles for MCP (read) vs builder (read/write/DDL).
+- **Embedder credential** is a deployment secret (MCP query-time + builder), rotated like any
+ other; it is the only external call left on the docs path.
+- **Cost**: embeddings are computed only for changed docs (incremental) at build time, and once
+ per query at read time — far cheaper than a bespoke hosted retrieval service; Postgres is
+ read-mostly and small.
+
+## 8. Embedding model & dimensions
+
+pgvector (like LanceDB) is only the vector **store** — it does not generate embeddings. A
+model must turn text → vector; that model is the one external piece. The MCP + the seeder
+select it via `DOCS_EMBEDDER_MODEL`, and **build-time and query-time must use the same model
+and dimension** (mismatched vectors return garbage):
+
+| `DOCS_EMBEDDER_MODEL` | Embedder | Infra to provision | Dim |
+| --- | --- | --- | --- |
+| `stub` | Deterministic offline hash (CI/tests only — **not semantic**) | none | 3072 (default) |
+| `local` | In-process HuggingFace model via transformers.js (ONNX, CPU) — no service, no key | **none** (optional `@huggingface/transformers` dep; model weights cached on first use) | model-native (e.g. 384 `all-MiniLM-L6-v2`, 1024 `bge-large`) |
+| *(remote model name)* | OpenAI/Azure-compatible endpoint (`DOCS_EMBEDDER_ENDPOINT`/`API_KEY`) — e.g. reuse kai-bot's `embeddings` deployment | reuse existing / provision one | 3072 `text-embedding-3-large` (or 1536 small) |
+
+- **Dimension is configurable** end-to-end: `DOCS_EMBEDDER_DIM` drives the embedder and the
+ `halfvec(N)` column (`halfvec` indexes up to 4000 dims, so 384/768/1024/1536/3072 all work;
+ under 2000 a plain `vector(N)` is also possible). The local seeder recreates the tables if
+ the dim changes.
+- **3072 vs 1024**: more dims = marginally better retrieval + ~3× storage/search cost. On a
+ bounded docs corpus 1024 is within a couple % of 3072 — a good default for local models.
+- **To avoid provisioning any embedding infra**, use `local` (only Postgres is provisioned) or
+ reuse kai-bot's already-provisioned Azure `embeddings` deployment. Provisioning a *new*
+ dedicated deployment is the only option that adds infra.
diff --git a/feature_spec/integration-tests/RFC.md b/feature_spec/integration-tests/RFC.md
new file mode 100644
index 000000000..7ded0c5f8
--- /dev/null
+++ b/feature_spec/integration-tests/RFC.md
@@ -0,0 +1,133 @@
+# RFC: TypeScript Integration Tests — per-case redis-leased project pool
+
+Linear: PSGO-268 (TypeScript rewrite) · branch
+`martinvasko-psgo-268-rewrite-keboola-mcp-server-from-python-to-typescript-11`
+
+## Problem
+
+The Python integration suite acquires **one** shared test project for an entire CI run
+(`integtests/conftest.py` → `ProjectPool`, session-scoped) and serializes concurrent runners
+with a **Storage-API branch-metadata** lock (`integtests/project_lock.py`). Two problems:
+
+1. **Coarse acquisition.** A whole CI run holds a single project for its full duration, even
+ though most test cases are independent. With a pool of N projects, at most N runs can
+ proceed; a run that only needs a project for one case still blocks one for minutes.
+2. **Inconsistent locking.** The branch-metadata lock works but is intricate
+ (write-and-verify window, oldest-timestamp-wins, stale-TTL cleanup, anti-collision sleeps)
+ and the team already has a battle-tested redis lease (`keboola/go-utils`
+ `pkg/testproject`) used across the go monorepo that "works perfectly". Reimplementing a
+ second bespoke protocol in TS is wasted risk.
+
+The TS rewrite has **no integration tests yet**. Rather than port the branch-metadata lock,
+this RFC adopts the go-utils model: a **redis-leased pool**, acquired **per test case**, with
+the same `projects.json` layout and CI export mechanism the go monorepo uses.
+
+## Required Behavior
+
+| Concern | Behavior |
+| --- | --- |
+| Pool source | A `projects.json` array of project definitions (host, project id, token, backend, stagingStorage), the same schema as [`go-monorepo/build/ci/projects.json`](https://github.com/keboola/go-monorepo/blob/main/build/ci/projects.json). Path from `TEST_KBC_PROJECTS_FILE` (absolute). |
+| CI secret injection | A composite action mirroring [`export-kbc-projects`](https://github.com/keboola/go-monorepo/blob/main/.github/actions/export-kbc-projects/action.yml): `envsubst` the `$TEST_KBC_PROJECT__TOKEN` placeholders in `.github/ci/projects.json` from `TEST_KBC_PROJECT_*` secrets into a runtime `projects.json`. |
+| Acquisition granularity | **Per test case**, not per run. A test calls `getTestProject(...)`; the lease is released automatically at the end of that test. |
+| Mutual exclusion | A **redis lease** per `(host, projectId)` key (port of go-utils `redislocker.go`): `SET key token NX PX ttl`; auto-extend at `ttl/4`; release via compare-and-delete. TTL 2 min. |
+| Local fallback | When no redis is configured, fall back to a **host-local file lock** (port of `fslocker.go`) so the suite runs on a developer machine without redis. |
+| Exhaustion policy | When every compatible project is currently leased, **do not error** — sleep briefly and retry the whole pool **forever** (matching `go-utils` `GetTestProject`). The only failure is "no *compatible* project exists at all" (e.g. asked for BigQuery, pool has none). |
+| Compatibility filter | Optional `{ backend }` selector so a test can require `snowflake` / `bigquery`; only matching definitions are considered. |
+| Project hygiene | On acquisition, the project is reset to a known-clean state (`cleanProject`, port of the Python `_purge_project` guard + wipe) before the test runs; a dedicated-project guard refuses to wipe a project holding non-`*.c-test*` buckets. |
+| Parallelism | Vitest runs files in parallel workers; each worker leases independently. Two cases on different workers may hold two different projects at once; two cases never share one project. |
+
+### Environment contract
+
+| Variable | Meaning | Required |
+| --- | --- | --- |
+| `TEST_KBC_PROJECTS_FILE` | Absolute path to the generated `projects.json` | yes |
+| `TEST_MCP_PROJECTS_LOCK_HOST` | redis URL (`redis://host:port`, `+tls` suffix for TLS) | CI only |
+| `TEST_MCP_PROJECTS_LOCK_PASSWORD` | redis password | CI only |
+| `TEST_MCP_PROJECTS_LOCK_DIR_NAME` | dir for the fs-locker fallback | local optional |
+
+No per-token env arrays (`INTEGTEST_STORAGE_TOKENS`) anymore — the pool is the JSON file.
+
+## Resolution Strategy
+
+New harness under `integtests/testproject/` (a faithful TS port of the go-utils package +
+the go-monorepo `internal/utils/testproject/project.go` wrapper):
+
+| File | Responsibility | Ported from |
+| --- | --- | --- |
+| `types.ts` | `ProjectDefinition` (zod-validated), `LockedProject`, `Backend`/`StagingStorage` enums | `testproject.go` `Definition` |
+| `projects.ts` | Load + parse + validate `projects.json` from `TEST_KBC_PROJECTS_FILE`; process-singleton pool | `getProjects`/`GetProjectsFrom` |
+| `redisLocker.ts` | ioredis lease: `SET NX PX` obtain, Lua compare-and-`pexpire` refresh, Lua compare-and-`del` release; background auto-extend timer at `TTL/4` | `redislocker.go` |
+| `fsLocker.ts` | `proper-lockfile`/`O_CREAT|O_EXCL` host-local lock | `fslocker.go` |
+| `pool.ts` | `getTestProject({backend?})`: loop compatible defs, `tryLock` each; if none free sleep 100 ms and retry forever; throw only when no compatible def exists | `ProjectsPool.GetTestProject` |
+| `clean.ts` | `cleanProject(endpoint)`: dedicated-project guard + wipe buckets / configs / workspaces / MCP branch metadata via `@keboola/api-client` | Python `_purge_project` |
+| `fixture.ts` | `getTestProjectForTest()` — acquires, registers `onTestFinished(release)`, returns `{ config, storageApiUrl, storageApiToken, backend, cleanup }` | `GetTestProjectForTest` |
+
+Test harness:
+
+- `vitest.integ.config.ts` — separate config (`include: integtests/**/*.test.ts`, long
+ `testTimeout`, `fileParallelism: true`, `globalSetup` validates the pool loads once).
+- npm scripts: `test:integ` (run), `test:integ:watch`.
+- Each ported test calls `getTestProjectForTest()` in the body (or a small per-test fixture),
+ builds the in-memory MCP client against that project's `Config`, exercises tools, asserts.
+- Reuse the existing unit-test harness shape (`InMemoryTransport` + MCP `Client`) — the only
+ difference is the `Config` comes from a leased project and the calls hit the real stack.
+
+Redis semantics (port of `redislocker.go`, no extra lock library — raw ioredis + Lua so the
+compare-and-swap matches `bsm/redislock` exactly):
+
+```
+obtain : SET - NX PX -> ok ? leased : busy
+refresh: if GET key == token then PEXPIRE key (Lua, every ttl/4)
+release: if GET key == token then DEL key (Lua)
+```
+
+The auto-extend timer holds the lease for the (unknown, possibly long) duration of a single
+test and is cleared on release, so a crashed worker's lease expires after at most `TTL`.
+
+CI: a dedicated `integration_tests` job (separate from `ci.yml`) runs
+`export-kbc-projects` → `npm run test:integ` with redis service + `TEST_KBC_PROJECT_*`
+secrets. Sketch lives in this folder's `ci-job.yml` and is wired into the workflow at
+implementation time.
+
+### Non-obvious trade-offs
+
+- **Per-case vs per-file acquisition.** Per *case* maximizes pool utilization but multiplies
+ lease churn and `cleanProject` cost (a wipe per case). We default to per-case (as
+ requested) but expose a per-file helper for suites whose cases share expensive fixtures
+ (e.g. the storage suite that seeds buckets/tables once). The lease helper is the same; only
+ the scope of `onTestFinished` vs `beforeAll/afterAll` differs.
+- **Infinite retry.** Matches go-utils and is correct for a bounded CI pool, but a
+ genuinely deadlocked pool would hang until the job timeout rather than failing fast. We
+ rely on the job-level timeout + the `TTL`-bounded lease expiry as the backstop, and log the
+ wait every few seconds so a stuck pool is visible.
+- **Redis as the single point of coordination.** If redis is down, CI integtests can't
+ coordinate. The fs-locker fallback is host-local only (no cross-runner safety), so CI
+ always uses redis; the fallback exists purely for local single-host runs.
+
+## Scope
+
+In scope:
+- The `integtests/testproject/` harness, `projects.json` layout, `export-kbc-projects` action,
+ `vitest.integ.config.ts`, npm scripts, `integtests/README.md`, and the CI job.
+- Porting the Python integration test *cases* (`integtests/**`) to vitest on top of the new
+ harness — done incrementally per module after this RFC is agreed.
+
+Out of scope:
+- Changing production server behavior. Integtests exercise the shipped tools as-is.
+- Cross-host fs-locking (redis is the cross-runner mechanism).
+- Removing the Python integtests until the TS ports reach parity (kept as the reference).
+- The unit-test suite (already complete: 383 tests).
+
+## Testing / Verification
+
+- **Harness unit tests** (run in the normal `vitest` suite, no redis/projects needed):
+ `projects.ts` parsing/validation, `pool.ts` selection + infinite-retry (with a fake
+ in-memory locker), `redisLocker.ts` against `ioredis-mock`, the dedicated-project guard in
+ `clean.ts`. These give us confidence without a live stack.
+- **Live integration run** (CI `integration_tests` job): redis service + `TEST_KBC_PROJECT_*`
+ secrets → `export-kbc-projects` → `npm run test:integ`. Verifies real leasing, parallel
+ workers never collide on a project, and the ported tool tests pass against a real stack.
+- **Parity check:** each ported module is cross-checked against its Python counterpart in
+ `integtests/` (same scenarios, same assertions) before the Python file is removed.
+- **Manual local run:** developer sets `TEST_KBC_PROJECTS_FILE` to a one-project file (no
+ redis) and runs `npm run test:integ` — exercises the fs-locker path.
diff --git a/feature_spec/mcp-typescript-rewrite/CICD-RUNBOOK.md b/feature_spec/mcp-typescript-rewrite/CICD-RUNBOOK.md
new file mode 100644
index 000000000..1f0ebc680
--- /dev/null
+++ b/feature_spec/mcp-typescript-rewrite/CICD-RUNBOOK.md
@@ -0,0 +1,110 @@
+# Keboola MCP Server (TS) — Config, Distribution & CI/CD Runbook
+
+Operational runbook for running, publishing, and CI-testing the rewritten
+`@keboola/mcp-server`. **No credential values here** — only what to set and where.
+Repo stays standalone (`keboola/keboola-mcp-server`); CI workflows are adapted in place.
+
+---
+
+## 1. Runtime config (the server itself)
+
+Resolution precedence (highest first): **CLI arg → `KBC_*` env var → `X-*` HTTP header**
+(same model as the Python `Config`).
+
+| Purpose | Env var | CLI flag | HTTP header | Required |
+| ----------------------------------------------------------- | ------------------------------------------------- | -------------------- | ------------------------------- | ----------------- |
+| Storage API URL (`https://connection..keboola.com`) | `KBC_STORAGE_API_URL` | `--api-url` | `X-StorageApiUrl` | yes |
+| Storage API token | `KBC_STORAGE_TOKEN` | `--storage-token` | `X-StorageApiToken` | yes (token mode) |
+| Branch id (`null`/`default`/`production` ⇒ prod) | `KBC_BRANCH_ID` | — | `X-Branch-Id` | no |
+| Workspace schema (SQL) | `KBC_WORKSPACE_SCHEMA` | `--workspace-schema` | `X-Workspace-Schema` | for SQL tools |
+| OAuth bearer (HTTP transports) | — | — | `Authorization: Bearer ` | OAuth mode |
+| OAuth client id / secret | `KBC_OAUTH_CLIENT_ID` / `KBC_OAUTH_CLIENT_SECRET` | — | — | OAuth server mode |
+| OAuth server URL / scope | `KBC_OAUTH_SERVER_URL` / `KBC_OAUTH_SCOPE` | — | — | OAuth server mode |
+| MCP server public URL | `KBC_MCP_SERVER_URL` | — | — | OAuth mode |
+| JWT signing key | `KBC_JWT_SECRET` | — | — | OAuth mode |
+| Read-only mode | — | — | `X-Read-Only-Mode` | no |
+| Conversation id | — | — | `X-Conversation-Id` | no |
+| Log level | — | `--log-level` | — | no |
+| App env / version (telemetry) | `APP_ENV` / `APP_VERSION` | — | — | no |
+| Datadog tracing | `DD_*` (dd-trace) | — | — | container only |
+
+Rules (port from Python): stdio transport **rejects** OAuth config (HTTP only); secret
+fields redacted in logs; never commit credentials (`.env` gitignored); local dev via
+`--env-file=.env`.
+
+---
+
+## 2. npm distribution (`npx @keboola/mcp-server`)
+
+Published from this repo (standalone). `package.json` must have: `"type":"module"`,
+`bin` entry, `files:["dist"]`, `"publishConfig":{"access":"public"}`, ESM+CJS exports
+(model `@keboola/api-client`). Build with tsup before publish.
+
+Required CI repo secret:
+
+- **`NPM_TOKEN`** — npm automation token with publish rights to the `@keboola` scope.
+
+Publish trigger: on `v*` release tags (wire into `release.yml` or a dedicated
+`npm-publish.yml`). Verify after first publish: `npx @keboola/mcp-server` (stdio),
+`npm i -g`, `npm i @keboola/mcp-server` as a lib.
+
+---
+
+## 3. Docker image distribution
+
+Image `keboola/mcp-server` on Docker Hub. Tag→image mapping kept **identical** to the
+current Python `release.yml` so `kbc-stacks` routing is unchanged:
+
+| Git tag | Image tag | Stack |
+| --------------------------- | ------------------------------- | ------------ |
+| `vX.Y.Z` | `production-` + `latest` | production |
+| `agent-vX.Y.Z` | `production-` (agent helm) | production |
+| `canary-orion-vX.Y.Z-dev.N` | `canary-orion-` | canary-orion |
+| `dev-vX.Y.Z-dev.N` | `dev-` | testing |
+
+Required repo secrets (already present for the Python build):
+
+- **`DOCKERHUB_PUSH_USER`** + **`DOCKERHUB_PUSH_TOKEN`**.
+
+Dockerfile: Node 24 multi-stage, non-root user, `dd-trace` preloaded,
+`ENTRYPOINT ["node","dist/index.js","--transport","streamable-http"]`.
+
+---
+
+## 4. CI/CD secret summary (set in `keboola/keboola-mcp-server`)
+
+| Repo secret / var | Used by | Purpose | Status |
+| ------------------------------------------------------------ | ------------ | ----------------------------- | -------------------- |
+| `DOCKERHUB_PUSH_USER` / `DOCKERHUB_PUSH_TOKEN` | release.yml | Docker Hub push | already set (Python) |
+| `NPM_TOKEN` | npm publish | publish `@keboola/mcp-server` | **add** |
+| `CODECOV_TOKEN` | ci.yml | coverage upload (optional) | already set |
+| KaiBench: model/credential secrets, kai-assistant image pull | kaibench.yml | eval run | already set (Python) |
+
+KaiBench: the workflow spins up MCP server (from branch) + kai-assistant (prebuilt image)
+
+- Postgres + Redis and runs the eval suite on production `v*` tags. Reuse the existing
+ `kaibench.yml`; only the MCP server build step changes (Node instead of uv/Python).
+
+---
+
+## 5. Coordinated `@keboola/api-client` work (cross-repo, in `keboola/ui`)
+
+Endpoints the MCP server needs that api-client v4 does not yet expose — add as new
+subpaths in `ui/packages/api-client`, publish, then bump the dependency here:
+
+- **scheduler** (flow activation/scheduling)
+- **AI service**: `docs_query`, semantic context/schema/search, global `search`,
+ config examples, component finder, description suggestions
+
+Until published, ship thin local clients in `src/clients/` and swap to api-client later.
+
+---
+
+## 6. First-run checklist
+
+- [ ] `NPM_TOKEN` added to repo secrets; `@keboola` scope publish dry-run OK.
+- [ ] `DOCKERHUB_PUSH_*` confirmed (carried over from Python build).
+- [ ] KaiBench services/credentials confirmed in `kaibench.yml`.
+- [ ] Local `.env` (gitignored) with `KBC_STORAGE_API_URL` + `KBC_STORAGE_TOKEN`.
+- [ ] Smoke: `npx @keboola/mcp-server` lists 39 tools over stdio.
+- [ ] api-client gap endpoints tracked (scheduler, AI service) — local clients vs. published.
diff --git a/feature_spec/mcp-typescript-rewrite/PLAN.md b/feature_spec/mcp-typescript-rewrite/PLAN.md
new file mode 100644
index 000000000..1d78976bd
--- /dev/null
+++ b/feature_spec/mcp-typescript-rewrite/PLAN.md
@@ -0,0 +1,204 @@
+# Plan: Rewrite Keboola MCP Server (Python → TypeScript)
+
+**Linear:** PSGO-268 · branch `martinvasko-psgo-268-rewrite-keboola-mcp-server-from-python-to-typescript-11`
+
+**Goal:** Replace the Python (FastMCP, v1.72.8) implementation **in this same standalone
+public repo** with a TypeScript implementation at **1:1 functional parity**. Ship as a
+Docker container AND publish to npm so it installs via `npx @keboola/mcp-server` /
+`npm i -g`. Migrate unit tests to vitest and iterate until green. Integtests last.
+Delivered as a **draft branch + draft PR**.
+
+> Parity source of truth: the current Python tree (before replacement), `TOOLS.md`
+> (39 tools), and the unit test suite (`tests/`, 33 files).
+
+---
+
+## 0. Decisions (confirmed)
+
+- **Standalone repo.** Rewrite in place; do NOT fold into the `ui` monorepo. Later,
+ consider extracting reusable packages _out of_ MCP for `ui` to consume.
+- **Reuse `@keboola/api-client`** as a **published npm dependency** (v4) for the client
+ layer: `storage`, `queue` (jobs), `queryService`, `oauth`, `encryption`, `metastore`,
+ `syncActions`, `dataScience`, `management`.
+- **MCP protocol**: `@modelcontextprotocol/sdk` (replaces FastMCP) — stdio +
+ streamable-HTTP transports, zod tool schemas.
+- **HTTP server**: Hono + `@hono/node-server` (model: `ui/apps/kai-agent`).
+- **API gaps** (scheduler, AI service: `docs_query` / semantic / global `search`):
+ **add to `@keboola/api-client`** in the `ui` repo (coordinated cross-repo workstream),
+ publish, then consume here. Track as a dependency of the relevant tool phases.
+- **MCP server only** (In Platform Agent variant deferred), but keep this repo's release
+ tag scheme (`v*`, `agent-v*`, `canary-orion-*`, `dev-*`) and CI workflows, adapted to TS.
+- **Models**: pydantic → **zod v4**.
+- **SQL**: runs via the Query Service **HTTP API** — no DB drivers.
+
+---
+
+## 1. What we reuse vs. rewrite (laziness budget)
+
+| Python component | TS strategy |
+| ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
+| `fastmcp` / `mcp`, stdio + streamable-http | `@modelcontextprotocol/sdk`. No reimplementation. |
+| `clients/` (storage, jobs_queue, query, oauth, encryption, metastore, sync_actions, data_science) | `@keboola/api-client` subpaths. Thin adapters only. |
+| `clients/scheduler.py`, `clients/ai_service.py`, semantic + global search + docs | Gap → add to `@keboola/api-client`, then consume. |
+| `clients/base.py` (httpx + retries) | api-client `fetchClient`/middlewares. |
+| pydantic models | zod v4. |
+| sqlglot (identifier quoting / dialect) | port the small quoting helpers; `node-sql-parser` only if strictly needed. |
+| Starlette/uvicorn | Hono + `@hono/node-server`. |
+| `cli.py` argparse | small arg+env parser; `bin` for npx. |
+| Dockerfile (uv/python) | Node 24 multi-stage. |
+| pytest (parametrize, fixtures) | vitest 4 (`test.each`) + msw. |
+| `generate_tool_docs.py` | `gen:tools-docs` script; keep `check-tools-docs` CI gate. |
+
+**Net:** client layer (~3.4k LOC) and MCP protocol layer become dependencies. The real
+work is **tools (15.5k LOC) + models + tests**.
+
+---
+
+## 2. Target repo layout (replacing Python in place)
+
+```
+package.json # name @keboola/mcp-server, bin, type module, exports, tsup, vitest
+tsconfig.json
+tsup.config.ts # ESM+CJS + bin; model ui/apps/kai-agent/tsup.config.ts
+oxlint.config.ts # match monorepo lint (oxlint/oxfmt) OR keep eslint — see §9
+vitest.config.ts
+Dockerfile # node:24 multi-stage; replaces the Python one
+.github/workflows/ # ci.yml, release.yml, kaibench.yml — adapted to Node/TS
+src/
+ index.ts # bin entry: parse args/env -> Config -> start transport
+ config.ts # Config (env KBC_*, X-* headers, CLI) — port config.py
+ server.ts # build McpServer, register tools/prompts/resources, lifespan
+ transports/{stdio.ts,http.ts} # http.ts = Hono app + routes
+ mcp/{toolFiltering.ts,authorization.ts,errors.ts}
+ oauth.ts # SimpleOAuthProvider (oauth.py)
+ preview.ts # /preview/configuration (preview.py)
+ workspace.ts # Workspace + SQL dialect quoting (workspace.py)
+ clients/ # thin adapters over @keboola/api-client (+ gap clients until api-client ships)
+ tools/ # one module per Python tools/ submodule (see §4)
+ models/ # zod schemas
+ prompts/ resources/
+__tests__/ # ported unit tests, mirror Python tests/
+README.md
+TOOLS.md # regenerated
+feature_spec/mcp-typescript-rewrite/ # this PLAN + SECRETS doc
+```
+
+HTTP routes on the Hono app (parity with cli.py/server.py): `/mcp` (streamable-http),
+`/` (info), `/health-check`, `/preview/configuration` (POST), `/oauth/callback` (GET).
+Default transport `stdio`; `--transport streamable-http` (alias `http-compat`) for server.
+
+---
+
+## 3. Core infra (Phase 1)
+
+1. **Config** — port `config.py` field set and resolution (CLI → `KBC_*` env → `X-*`
+ header), alias/normalize logic, URL amendment, branch_id "production/default/none"→null,
+ secret redaction.
+2. **Transports** via SDK: `StdioServerTransport`; stateless streamable-HTTP at `/mcp`.
+ Reject OAuth config on stdio (cli.py parity).
+3. **Per-request config**: Hono middleware reads `X-*` + `Authorization: Bearer` per
+ request, layered over base Config via AsyncLocalStorage context.
+4. **Logging**: pino (JSON), `--log-level`. **Errors**: 400 for validation/value/JSON,
+ 500 otherwise; debug includes stack. **dd-trace** for the container.
+
+**Async stance:** native async.
+
+- Await-to-completion: `query_data` (submit query job → poll Query Service), reads,
+ `run_sync_action`. Parallelize independent fetches with `Promise.all`.
+- Fire-and-return-progress: `run_job`, `deploy_data_app` — return job/task id + status.
+
+---
+
+## 4. Tools (Phase 4) — all 39
+
+Each = zod input schema + handler + `server.registerTool` (preserve names, descriptions,
+read-only annotations exactly — they drive TOOLS.md and tool filtering).
+
+| Module | Tools |
+| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| components | get_components, find_component_id, get_configs, get_config_examples, create_config, add_config_row, update_config, update_config_row, create_sql_transformation, update_sql_transformation, run_sync_action |
+| flow | get_flows, get_flow_schema, get_flow_examples, create_flow, create_conditional_flow, update_flow, modify_flow |
+| storage | get_buckets, get_tables, update_descriptions |
+| jobs | get_jobs, run_job |
+| sql | query_data |
+| project | get_project_info, update_project_description |
+| search | search |
+| semantic | get_semantic_context, get_semantic_schema, search_semantic_context, validate_semantic_query |
+| oauth | create_oauth_url |
+| data_apps | get_data_apps, deploy_data_app, modify_streamlit_data_app, modify_python_js_data_app, create_python_js_data_app_git_credential, delete_python_js_data_app_draft |
+| doc | docs_query |
+
+Cross-cutting (Phase 5): tool filtering (`is_read_only_tool`/`is_semantic_tool`,
+`X-Read-Only-Mode`, semantic gating), authorization + `/preview/configuration`
+(AI-3438 hardening — port checks exactly), OAuth provider, prompts, resources,
+TOOLS.md generator + `check-tools-docs` gate.
+
+---
+
+## 5. Models (Phase 3)
+
+Port pydantic → zod across `tools/*/model.py`, `*/api_models.py`, `search_models.py`,
+`flow/scheduler_model.py`, `semantic/model.py`, `components/model.py`. Reuse api-client
+`*/types` where API shapes already match. Confirm whether `toon-format` output encoding
+must be byte-preserved (§9 Q1).
+
+---
+
+## 6. Tests (Phase 6)
+
+Port `tests/` (33 files) → `__tests__/` mirroring structure. parametrize → `test.each`;
+`conftest.py` → vitest setup + factories. **HTTP mocking via msw** (don't over-mock
+client internals). Iterate module-by-module to green. `integtests/` ported **last**.
+
+---
+
+## 7. CI/CD (Phase 7) — adapt existing workflows in place
+
+- **`ci.yml`**: replace Python matrix with Node 24; run `vitest`, `tsc`, lint, `build`,
+ and the TOOLS.md check. Keep Codecov.
+- **`release.yml`**: keep the tag→image mapping verbatim (`v*`/`agent-v*` →
+ `production-`+`latest`; `canary-orion-*`; `dev-*`) so kbc-stacks routing is
+ unchanged. Build the Node Dockerfile. Secrets: `DOCKERHUB_PUSH_USER`,
+ `DOCKERHUB_PUSH_TOKEN`.
+- **npm publish**: add a publish step (npm token) — publish `@keboola/mcp-server` on
+ `v*` tags (or via release). `bin` + `files:[dist]` + `publishConfig.access:public`.
+- **`kaibench.yml`**: keep the service wiring (MCP server from branch + kai-assistant +
+ postgres + redis); point it at the Node build.
+
+---
+
+## 8. Phasing (draft PR, iterate to green)
+
+0. Branch off `main`; scaffold TS skeleton alongside Python (keep Python running until
+ parity), open **draft PR** early.
+1. Core infra; smoke: `tools/list` over stdio returns 39 tools with correct schemas.
+2. Client adapters (+ track api-client gap additions).
+3. Models for first module → 4. Tools module-by-module, porting each test file with it
+ (storage → jobs → sql → components → flow → project → search → semantic → oauth →
+ data_apps → doc).
+4. Cross-cutting (filtering, auth, preview, oauth, prompts, resources, TOOLS.md gen).
+5. Unit-test parity green; TOOLS.md regenerated + gated; **remove Python sources**.
+6. CI/CD adapted; Docker + npm + KaiBench green.
+7. Integtests ported. 9. README + SECRETS doc finalized.
+
+Commits start with `PSGO-268:`; PR references the issue.
+
+---
+
+## 9. Open questions
+
+1. **Output encoding**: Python uses `toon-format` for some tool outputs. Preserve
+ byte-for-byte or is JSON acceptable for parity?
+2. **Lint stack**: match the `ui` monorepo (oxlint/oxfmt) or keep an eslint/prettier setup
+ standalone? (Affects CI + dev ergonomics.)
+3. **api-client gap timing**: can we land scheduler/AI-service endpoints in
+ `@keboola/api-client` in time, or ship temporary local clients and swap later?
+
+---
+
+## 10. Out of scope / deferred
+
+- In Platform Agent variant (follow-up; tags kept reserved).
+- Integtests ported last.
+- DB drivers (SQL is HTTP via Query Service).
+- No reimplementation of MCP protocol or HTTP client.
diff --git a/integtests/README.md b/integtests/README.md
index ff73d4691..368c3b044 100644
--- a/integtests/README.md
+++ b/integtests/README.md
@@ -1,332 +1,133 @@
# Integration Tests
-Each test session creates and deletes real objects (buckets, tables, configurations) in a
-Keboola project. The `project_lock` fixture ensures that only one session owns a given
-project at a time, so concurrent CI jobs or overlapping local runs do not corrupt each
-other's data.
+Integration tests run the shipped MCP tools against **real** Keboola projects. Each test case
+leases a project from a shared pool, runs against it, and releases it — so many CI runners
+(and many test cases) can proceed in parallel without corrupting each other's data.
-> **Important**: The integration tests are **not** tied to specific projects. Any clean
-> Keboola project with a workspace can be used. For local development you only need **two**
-> projects in the pool (one Snowflake, one BigQuery) plus one dedicated project for branch
-> storage tests (with the `storage-branches` feature).
+Design rationale and the full protocol are in
+[`feature_spec/integration-tests/RFC.md`](../feature_spec/integration-tests/RFC.md). This file
+is the operational guide.
----
-
-## 1. Setting Up Your Own Test Environment
-
-### Do NOT reuse the CI projects
-
-The CI pool projects are shared across all CI runners. If you point your local `.env` at
-them, you will interfere with CI runs: the pool lock mechanism will block or — worse — your
-local run may acquire a CI project, crash mid-way, and leave it in a dirty state that
-causes random CI failures.
-
-**Always create your own dedicated test projects for local development.**
-
-### Creating test projects
-
-You need:
-
-1. **Two pool projects** — one on Snowflake backend, one on BigQuery (or two of the same
- backend if you only need one). These must be **completely empty** (no buckets, no tables,
- no configurations) when a test session starts. The lock mechanism cleans up after each
- session, but if a session crashes, leftovers may remain and cause the next run to fail.
+> **Status:** the harness (`integtests/testproject/`) and pool/CI wiring are in place. The
+> individual test *cases* are being ported from the Python suite (`integtests/*.py`, kept as
+> the parity reference) module by module.
-2. **One storage-branches project** — a project **with** the `storage-branches` feature,
- used by `test_storage_branches.py`.
-
-The test session creates its own read-only workspace in the locked pool project and deletes
-it on teardown, so you do **not** need to pre-create a workspace — the project token alone is
-enough.
-
-### `.env` file
-
-Create a `.env` file in the project root:
+---
-```dotenv
-# Required — pool projects
-INTEGTEST_POOL_STORAGE_API_URL=https://connection.europe-west3.gcp.keboola.com
-INTEGTEST_STORAGE_TOKENS=
+## 1. The project pool (`projects.json`)
-# Required — branch storage tests (dedicated project, not in pool)
-INTEGTEST_STORAGE_TOKEN_STORAGE_BRANCHES=
+The pool is a JSON **array** of project definitions — the same schema the go monorepo uses
+([`go-monorepo/build/ci/projects.json`](https://github.com/keboola/go-monorepo/blob/main/build/ci/projects.json)):
-# Optional — second project for multi-client tests
-INTEGTEST_STORAGE_TOKEN_PRJ2=
-INTEGTEST_WORKSPACE_SCHEMA_PRJ2=
+```json
+[
+ { "host": "connection.keboola.com", "project": 1234, "token": "", "backend": "snowflake", "stagingStorage": "s3" },
+ { "host": "connection.keboola.com", "project": 1235, "token": "", "backend": "bigquery", "stagingStorage": "gcs" }
+]
```
-### Running the tests
+The committed `.github/ci/projects.json` is a **template** whose tokens are `$TEST_KBC_PROJECT__TOKEN`
+placeholders. In CI the `export-kbc-projects` action substitutes them from `TEST_KBC_PROJECT_*`
+secrets into a runtime `projects.json`.
-```bash
-source /bin/activate
-pytest integtests/ -v --log-cli-level=INFO
-```
+The harness loads the pool once per process from the absolute path in **`TEST_KBC_PROJECTS_FILE`**.
-`--log-cli-level=INFO` shows lock acquisition and release messages in real time, which is
-useful when diagnosing a stall.
+### Environment contract
-### Avoiding token leaks in test output
+| Variable | Meaning | Where |
+| --- | --- | --- |
+| `TEST_KBC_PROJECTS_FILE` | Absolute path to the generated `projects.json` | always |
+| `TEST_MCP_PROJECTS_LOCK_HOST` | redis URL (`redis://host:port`, `rediss://` or `+tls` for TLS) | CI |
+| `TEST_MCP_PROJECTS_LOCK_PASSWORD` | redis password | CI |
+| `TEST_MCP_PROJECTS_LOCK_DIR_NAME` | dir for the local fs-lock fallback | local (optional) |
-**Important for anyone writing new integration tests or fixtures.**
-
-Pytest prints fixture parameter values in error tracebacks. If a fixture takes
-`storage_api_token: str` as a parameter and the test fails, the raw token appears in
-the CI log in plain text. This is a security risk.
-
-**Bad** — token leaks on failure:
-```python
-@pytest.fixture
-def my_fixture(storage_api_token: str, storage_api_url: str):
- # If this fixture or any test using it fails, pytest prints:
- # storage_api_token = '2728-6118086-cHM8a1s6c1AR...'
- ...
-```
-
-**Good** — read tokens from env vars inside the function body:
-```python
-@pytest.fixture
-def my_fixture(keboola_project, storage_api_url: str):
- # keboola_project dependency ensures env_init has run and set KBC_STORAGE_TOKEN
- token = os.environ['KBC_STORAGE_TOKEN']
- # On failure, pytest only shows: keboola_project=ProjectDef(...), storage_api_url='https://...'
- ...
-```
-
-The `env_init` fixture (triggered via `keboola_project`) copies the pool token into
-`KBC_STORAGE_TOKEN` and the URL into `STORAGE_API_URL`. Reading from these env vars
-inside the function body keeps the token out of pytest's traceback display.
-
-For tokens not in the pool (e.g. `INTEGTEST_STORAGE_TOKEN_STORAGE_BRANCHES`), use
-`os.getenv(...)` / `os.environ[...]` directly — never accept them as fixture parameters.
+When `TEST_MCP_PROJECTS_LOCK_HOST` is set, projects are leased via **redis** (cross-runner
+safe). Otherwise the harness falls back to a **host-local file lock** — fine for a single
+developer machine, but it provides no cross-runner safety, so CI always uses redis.
---
-## 2. How the Pool and Locking Work
-
-### Overview
-
-The pool manages a set of test projects. Each test session acquires **one** project from the
-pool, runs all tests against it, then releases it. The lock ensures no two sessions use the
-same project simultaneously.
-
-**Key behavior**:
-- A session acquires one project and holds it for the entire run
-- The project must be **completely clean** (no buckets, configs, tables) at the start — the
- `keboola_project` fixture explicitly checks this and fails if anything exists
-- After all tests complete, `keboola_project` deletes everything it created
-- If a session crashes without cleaning up, the next session that acquires the same project
- will detect the stale lock and run `clean_project` to wipe everything before proceeding
-
-### Lock storage: Keboola branch metadata
-
-Lock state is stored in the metadata of the default branch of the test project, accessed
-via the Storage API. No external service is required.
-
-Each runner writes up to two metadata keys per acquisition attempt:
-
-| Key | Value |
-|---|---|
-| `KBC.integtest.lock.` | JSON: `lock_id`, `acquired_at` (ISO 8601 UTC), `runner_info` |
-| `KBC.integtest.lock..released` | ISO 8601 timestamp written on release |
-
-An entry without a corresponding `.released` key is considered active.
-
-### Acquisition protocol
-
-1. **Write a candidate entry** with the current UTC timestamp.
-2. **Anti-collision window** — sleep 3 seconds to allow any concurrent writers to finish.
-3. **Read all active entries** (those without a `.released` counterpart).
-4. **Oldest timestamp wins** — the runner whose `acquired_at` is earliest holds the lock.
- Ties are broken by `lock_id` (lexicographic). If this runner's entry wins, it proceeds.
- Otherwise it writes its own `.released` key and retries after
- `INTEGTEST_LOCK_POLL_INTERVAL_SECONDS`.
+## 2. How leasing works
-### Stale lock detection and cleanup
+A test acquires a project with the per-case helper:
-If the winning entry is older than `INTEGTEST_LOCK_TTL_MINUTES` it is considered abandoned
-(the previous runner crashed without releasing). The detecting runner:
+```ts
+import { getTestProjectForTest } from '../testproject/fixture';
-1. Writes `.released` keys for all stale entries.
-2. Calls `clean_project`: deletes all buckets (with their tables) and all component
- configurations from the project, restoring the clean state the tests require.
-3. Re-enters the acquisition protocol from step 1.
-
-If the integration tests ever take close to
-60 minutes to complete, raise `INTEGTEST_LOCK_TTL_MINUTES` to roughly 2x the expected
-duration — otherwise a slow-but-healthy runner may have its lock stolen mid-run.
-
-### Workspaces
-
-Tests do not rely on a persistent workspace. Each session creates its own read-only
-workspace in the locked project (the `workspace_schema` fixture) and deletes it on teardown.
-`clean_project` deletes **all** workspaces (along with all buckets and configs), so any
-workspace leaked by a crashed run is wiped when the project is next acquired.
-
-### Pool of projects
-
-`ProjectPool` holds a list of `ProjectEndpoint` objects. During each acquisition pass it
-tries them in randomized order and returns the first one it can lock. If all are busy it
-sleeps for `INTEGTEST_LOCK_POLL_INTERVAL_SECONDS` and retries the full list. Raising the
-pool size allows multiple CI jobs to run concurrently, each against a different project.
-
-Before the pool is created, `verify_project_endpoint` calls `GET /v2/storage/tokens/verify`
-for every configured token. A revoked or misspelled token therefore causes an immediate
-`pytest.fail` rather than a confusing mid-run error.
-
-### Fixture dependency chain
-
-`project_lock` is a session-scoped fixture. `storage_api_token` and `workspace_schema`
-derive their values from the acquired endpoint, so every fixture that touches the project
-is automatically blocked until the lock is held:
-
-```
-env_file_loaded
- └── storage_api_url <-- reads INTEGTEST_POOL_STORAGE_API_URL
- └── project_lock <-- lock acquired at session start
- ├── storage_api_token
- └── workspace_schema
- └── env_init
- └── keboola_project (creates test buckets, tables, configs)
+it('lists buckets', async () => {
+ const { config, backend } = await getTestProjectForTest(); // or { backend: 'snowflake' }
+ // build an in-memory MCP client from `config`, call tools, assert…
+ // lease is released automatically when this test finishes
+});
```
-The lock is released in `project_lock`'s teardown, after every session-scoped fixture that
-depends on it has been torn down and the project has been cleaned up by `keboola_project`.
-
-### Lock tuning
-
-The defaults work for both local and CI use. Override only if you have a reason to.
-
-| Variable | Default | Meaning |
-|---|---|---|
-| `INTEGTEST_LOCK_TTL_MINUTES` | `60` | A lock older than this is considered abandoned and cleaned up by the next runner that detects it |
-| `INTEGTEST_LOCK_POLL_INTERVAL_SECONDS` | `30` | How long to wait between retries when all projects in the pool are busy |
-| `INTEGTEST_LOCK_MAX_WAIT_MINUTES` | `90` | Raise `TimeoutError` after this many minutes of waiting |
+- **Per case, not per run.** The lease is held only for the duration of the calling test
+ (released via vitest `onTestFinished`).
+- **Redis lease** keyed by `-`: `SET NX PX` to obtain, auto-extended at
+ `TTL/4`, compare-and-delete to release. A crashed worker's lease self-expires after `TTL`
+ (2 min). Port of go-utils [`redislocker.go`](https://github.com/keboola/go-utils/blob/main/pkg/testproject/redislocker.go).
+- **Exhaustion → wait, never error.** If every compatible project is busy, the pool sleeps
+ briefly and retries the whole pool **forever** (matching go-utils `GetTestProject`). The
+ only hard error is asking for a backend the pool has none of.
+- **Clean on acquire.** Before handing the project over, `cleanProject` resets it (deletes
+ buckets/configs/workspaces + MCP branch metadata). A guard refuses to wipe a project that
+ holds any non-`*.c-test*` bucket — protection against a misconfigured pool pointing at a
+ real project.
---
-## 3. Test-Specific Projects
+## 3. Running locally
-### Branch storage tests (one dedicated project)
+Create a one- or two-project `projects.json` for **your own dedicated** test projects (never
+the CI pool — you would interfere with CI), then:
-The branch storage tests (`test_storage_branches.py`) validate the deference mechanism on
-`storage-branches` projects. They use **one dedicated project outside the pool**:
-
-```dotenv
-INTEGTEST_STORAGE_TOKEN_STORAGE_BRANCHES=
-```
-
-The variable is required. The tests fail if it is missing or points to a project without
-the `storage-branches` feature.
-
-This project is **not** in the pool and has no lock mechanism. Concurrent access is safe
-because production data (`in.c-test_bucket_01` with `test_table_01`) is created
-idempotently and each session only manages its own branches (with unique names).
-
-No workspace schemas are needed — these tests only exercise bucket/table listing, not
-`query_data`.
-
-**Concurrency rules for branch tests**: These projects have no lock mechanism, so any new
-tests added to `test_storage_branches.py` must be safe for concurrent execution:
-- **Use unique names** for branches (the current tests use a UUID suffix). Never use
- hardcoded branch names.
-- **Production data must be idempotent** — use `_ensure_bucket` / `_ensure_table` which
- create only if not already present. Never delete production data in teardown.
-- **Only clean up your own branches** — teardown must only delete the branches created by
- the current session (tracked via the `BranchTestProject` dataclass).
-- **Do not modify or delete production buckets/tables** — they are shared across sessions.
-
-### Second project (two-project tests)
-
-A small number of tests exercise simultaneous access to two separate projects. They need:
-
-```dotenv
-INTEGTEST_STORAGE_TOKEN_PRJ2=
-INTEGTEST_WORKSPACE_SCHEMA_PRJ2=
+```bash
+export TEST_KBC_PROJECTS_FILE="$(pwd)/projects.local.json"
+# no redis → host-local fs lock is used automatically
+npm run test:integ
```
-These are read directly without locking and can be omitted if you are not running those
-tests. No lock is needed because the only test that uses PRJ2
-(`test_http_multiple_clients_with_different_headers`) is strictly read-only against it —
-it calls `list_tools`, `list_resources`, and `get_project_info`, and never creates,
-modifies, or deletes any object. Any future test that writes to PRJ2 must acquire a lock
-for it first.
+Add `TEST_MCP_PROJECTS_LOCK_HOST` / `_PASSWORD` only if you want to exercise the redis path
+locally (e.g. `docker run -p 6379:6379 redis`).
-### Metastore tests
-
-A subset of tests exercises the Metastore API. The Metastore URL is derived automatically
-from `INTEGTEST_POOL_STORAGE_API_URL` by replacing the `connection.` prefix with `metastore.`
-(e.g. `https://connection.north-europe.azure.keboola.com` -> `https://metastore.north-europe.azure.keboola.com`).
-Authentication reuses the storage API token — no additional environment variables are needed.
+Harness logic itself (pool selection, parsing, retry) is covered by ordinary unit tests in
+`__tests__/testproject.test.ts` and runs in the normal `npm test` — no projects or redis
+needed.
---
-## 4. Migrating from the Old Setup
-
-The pool is configured by `INTEGTEST_STORAGE_TOKENS` (space-separated project tokens).
-Earlier single-value (`INTEGTEST_STORAGE_TOKEN`) and per-project workspace-schema
-(`INTEGTEST_WORKSPACE_SCHEMA` / `INTEGTEST_WORKSPACE_SCHEMAS`) variables are no longer read —
-each session now creates its own read-only workspace, so no workspace schema is configured
-for the pool.
+## 4. Security: don't leak tokens in test output
-`INTEGTEST_STORAGE_TOKEN_PRJ2` and `INTEGTEST_WORKSPACE_SCHEMA_PRJ2` (the optional second
-project) are unchanged.
+Vitest prints values in failure output. **Never** put a raw token where a failed assertion or
+thrown error would render it. Read the token from the leased `config`/`TestProject` object and
+pass it into clients — do not interpolate it into assertion messages or `console.log`. Tokens
+in `projects.json` come from CI secrets; keep them out of logs and snapshots.
---
-## 5. GitHub CI Setup
-
-### Pool projects
-
-The CI pool consists of four Keboola projects, all on
-`https://connection.europe-west3.gcp.keboola.com`:
+## 5. CI
-| Project ID | Dashboard URL | Backend | Notes |
-|------------|-------------------------------------------------------------------------------|-----------|--------------|
-| 2728 | https://connection.europe-west3.gcp.keboola.com/admin/projects/2728/dashboard | Snowflake | |
-| 2729 | https://connection.europe-west3.gcp.keboola.com/admin/projects/2729/dashboard | Snowflake | |
-| 2731 | https://connection.europe-west3.gcp.keboola.com/admin/projects/2731/dashboard | BigQuery | |
-| 2732 | https://connection.europe-west3.gcp.keboola.com/admin/projects/2732/dashboard | BigQuery | |
+The `integration_tests` job (see the RFC's `ci-job.yml` sketch) runs:
-Having four slots means up to four CI jobs can run concurrently — each acquires a
-different project from the pool and they do not block each other.
+1. `export-kbc-projects` → generates `projects.json` from `TEST_KBC_PROJECT_*` secrets and
+ exports its absolute path as `TEST_KBC_PROJECTS_FILE`.
+2. Starts a redis service and sets `TEST_MCP_PROJECTS_LOCK_HOST` / `_PASSWORD`.
+3. `npm run test:integ`.
-### Branch storage test project (not in pool)
+Pool size = max concurrent runners. Add projects to `.github/ci/projects.json` (and a matching
+`TEST_KBC_PROJECT__TOKEN` secret) to raise the ceiling. Integration tests are skipped for
+fork PRs (no access to secrets).
-| Project ID | Dashboard URL | Backend | Notes |
-|------------|-------------------------------------------------------------------------------|-----------|------------------------------------|
-| 2908 | https://connection.europe-west3.gcp.keboola.com/admin/projects/2908/dashboard | Snowflake | Has `storage-branches` feature |
+### The committed pool (`.github/ci/projects.json`)
-This project is used by `test_storage_branches.py` via `INTEGTEST_STORAGE_TOKEN_STORAGE_BRANCHES`.
-It is **not** part of the pool and has no lock mechanism — concurrent access is safe because
-production data is created idempotently and each session only manages its own branches.
+All on the `connection.europe-west3.gcp.keboola.com` stack (GCP → `gcs` staging):
-### Secrets and variables
-
-The `integration_tests` job in `.github/workflows/ci.yml` reads the following from the
-repository's GitHub Secrets/Variables:
-
-| Name | Kind | Purpose |
+| Project ID | Backend | Role |
|---|---|---|
-| `INTEGTEST_STORAGE_TOKENS` | Secret | Space-separated master tokens for all four pool projects |
-| `INTEGTEST_POOL_STORAGE_API_URL` | Variable | `https://connection.europe-west3.gcp.keboola.com` |
-| `INTEGTEST_STORAGE_TOKEN_STORAGE_BRANCHES` | Secret | Master token for a project **with** the `storage-branches` feature (used by `test_storage_branches.py`) |
-
-### Concurrency
-
-- **Within a single CI run** — the matrix covers Python 3.10, 3.11, and 3.12 with
- `max-parallel: 1`, so the three versions run sequentially. This is intentional: a
- single run only needs one project slot, not three.
-- **Across concurrent CI runs** — the project-pool locking protocol (described in
- section 2) handles collisions. Each run acquires a different project, so up to four
- runs can proceed in parallel without interfering.
-- **Duplicate-run prevention** — a workflow-level concurrency key
- (`ci-${{ github.ref }}`) cancels any in-progress run on the same branch when a new
- push arrives.
-
-### Fork behaviour
-
-Integration tests are skipped for pull requests from forks
-(`github.repository != github.event.repository.full_name`) because forks do not have
-access to the repository secrets. The dependencies are still installed so the
-environment setup can be validated.
\ No newline at end of file
+| 3053, 3054 | Snowflake | pool |
+| 3056, 3057 | BigQuery | pool |
+| 3055 | Snowflake | has the `storage-branches` feature (used by the branch-storage tests) |
+
+Each needs a `TEST_KBC_PROJECT__TOKEN` GitHub secret (a Storage API master token). The
+redis lease comes from `vars.TEST_MCP_PROJECTS_LOCK_HOST` + `secrets.TEST_MCP_PROJECTS_LOCK_PASSWORD`,
+and the pool file path from `vars.TEST_KBC_PROJECTS_FILE` — same convention as keboola/go-monorepo.
diff --git a/integtests/clients/test_client.py b/integtests/clients/test_client.py
deleted file mode 100644
index ca4f71dbd..000000000
--- a/integtests/clients/test_client.py
+++ /dev/null
@@ -1,42 +0,0 @@
-import logging
-
-import pytest
-
-from integtests.conftest import ProjectDef, TableDef
-from keboola_mcp_server.clients.client import KeboolaClient
-from keboola_mcp_server.clients.storage import AsyncStorageClient, GlobalSearchResponse
-
-LOG = logging.getLogger(__name__)
-
-
-class TestAsyncStorageClient:
-
- @pytest.fixture
- def storage_client(self, keboola_client: KeboolaClient, keboola_project: ProjectDef) -> AsyncStorageClient:
- return keboola_client.storage_client
-
- @pytest.mark.asyncio
- @pytest.mark.skip(reason='Unstable')
- async def test_global_search(self, storage_client: AsyncStorageClient):
- not_existing_id = 'not-existing-id'
- ret = await storage_client.global_search(query=not_existing_id)
- assert isinstance(ret, GlobalSearchResponse)
- assert ret.all == 0
- assert ret.items == []
- assert ret.by_type == {'total': 0}
- assert ret.by_project == {}
-
- @pytest.mark.asyncio
- @pytest.mark.skip(reason='Unstable')
- async def test_global_search_with_results(self, storage_client: AsyncStorageClient, tables: list[TableDef]):
- search_for_name = 'test'
- is_global_search_enabled = await storage_client.is_enabled('global-search')
- if not is_global_search_enabled:
- LOG.warning('Global search is not enabled in the project. Skipping test. Please enable it in the project.')
- pytest.skip('Global search is not enabled in the project. Skipping test.')
-
- ret = await storage_client.global_search(query=search_for_name, types=['table'])
- assert isinstance(ret, GlobalSearchResponse)
- assert ret.all == len(tables)
- assert len(ret.items) == len(tables)
- assert all(item.type == 'table' for item in ret.items)
diff --git a/integtests/clients/test_data_science.py b/integtests/clients/test_data_science.py
deleted file mode 100644
index 9a780312f..000000000
--- a/integtests/clients/test_data_science.py
+++ /dev/null
@@ -1,124 +0,0 @@
-import logging
-from typing import AsyncGenerator
-
-import pytest
-import pytest_asyncio
-
-from keboola_mcp_server.clients.client import DATA_APP_COMPONENT_ID, KeboolaClient
-from keboola_mcp_server.clients.data_science import DataAppConfig, DataAppResponse, DataScienceClient
-
-LOG = logging.getLogger(__name__)
-
-
-def _minimal_parameters(slug: str) -> dict[str, object]:
- """Build minimal valid parameters for a code-based Streamlit data app."""
- return {
- 'size': 'tiny',
- 'autoSuspendAfterSeconds': 600,
- 'dataApp': {
- 'slug': slug,
- 'streamlit': {
- 'config.toml': '[theme]\nbase = "light"',
- },
- },
- 'script': [
- 'import streamlit as st',
- "st.write('Hello from integration test')",
- ],
- }
-
-
-def _public_access_authorization() -> dict[str, object]:
- """Allow public access to all paths; no providers required."""
- return {
- 'app_proxy': {
- 'auth_providers': [],
- 'auth_rules': [
- {'type': 'pathPrefix', 'value': '/', 'auth_required': False},
- ],
- }
- }
-
-
-@pytest.fixture
-def ds_client(keboola_client: KeboolaClient) -> DataScienceClient:
- return keboola_client.data_science_client
-
-
-@pytest_asyncio.fixture
-async def initial_data_app(ds_client: DataScienceClient, unique_id: str) -> AsyncGenerator[DataAppResponse, None]:
- data_app: DataAppResponse | None = None
- try:
- slug = f'test-app-{unique_id}'
- config = DataAppConfig.model_validate(
- {'parameters': _minimal_parameters(slug), 'authorization': _public_access_authorization()}
- )
- data_app = await ds_client.create_data_app(
- name=f'IntegTest {slug}',
- description='Created by integration tests',
- configuration=config,
- )
- assert isinstance(data_app, DataAppResponse)
- yield data_app
- finally:
- if data_app:
- try:
- # The DSAPI delete endpoint removes a data app only if its desired and current states match.
- # Otherwise, it returns a 400 Bad Request.
- # When deploying/deleting/suspending/updating/etc. the data app, the desired state is set according to
- # the action. Then there is a background task that runs for the given action and after it finishes,
- # the current state is updated to match the desired state.
- await ds_client.delete_data_app(data_app.id)
- except Exception as e:
- LOG.exception(f'Failed to delete data app: {e}')
- raise
-
-
-@pytest.mark.asyncio
-async def test_create_and_fetch_data_app(
- ds_client: DataScienceClient, initial_data_app: DataAppResponse, keboola_client: KeboolaClient
-) -> None:
- """Test creating a data app and fetching it from detail and list endpoints"""
- # Check if the created data app is valid
- created = initial_data_app
- assert isinstance(created, DataAppResponse)
- assert created.id
- assert created.state == 'created'
- assert created.type == 'streamlit'
- assert created.component_id == DATA_APP_COMPONENT_ID
-
- # Fetch the data app from data science
- fetched_ds = await ds_client.get_data_app(created.id)
- assert fetched_ds.id == created.id
- assert fetched_ds.type == created.type
- assert fetched_ds.component_id == created.component_id
- assert fetched_ds.project_id == created.project_id
- assert fetched_ds.config_id == created.config_id
- assert fetched_ds.config_version == created.config_version
-
- # Fetch the data app config from storage
- fetched_s = await keboola_client.storage_client.configuration_detail(
- component_id=DATA_APP_COMPONENT_ID,
- configuration_id=created.config_id,
- )
-
- # check if the data app ids are the same (data app from data science and config from storage)
- assert 'configuration' in fetched_s
- assert isinstance(fetched_s['configuration'], dict)
- assert 'parameters' in fetched_s['configuration']
- assert isinstance(fetched_s['configuration']['parameters'], dict)
- assert 'id' in fetched_s['configuration']['parameters']
- assert fetched_ds.id == fetched_s['configuration']['parameters']['id']
-
- # Fetch the all data apps and check if the created data app is in the list
- # TODO: Remove this limit once DSAPI is fixed.
- # The limit is temporarily increased to 500 to prevent leftover data apps from previous tests.
- # These apps cannot be deleted because their configurations were removed in SAPI first,
- # causing the DSAPI delete endpoint to return a 500 error afterward.
- data_apps = await ds_client.list_data_apps(limit=500)
- assert isinstance(data_apps, list)
- assert len(data_apps) > 0
- assert any(app.id == created.id for app in data_apps)
- # TODO(REMOVE): Remove this assertion once DSAPI is fixed. This only checks that we do not leave any data apps
- # in the CI project after test executions except those which are already there and cannot be deleted.
- assert len(data_apps) < 110
diff --git a/integtests/clients/test_encryption.py b/integtests/clients/test_encryption.py
deleted file mode 100644
index 00a822a23..000000000
--- a/integtests/clients/test_encryption.py
+++ /dev/null
@@ -1,43 +0,0 @@
-from typing import Any
-
-import pytest
-
-from keboola_mcp_server.clients.client import DATA_APP_COMPONENT_ID, KeboolaClient
-
-
-def test_client_does_not_send_authorization_headers(keboola_client: KeboolaClient) -> None:
- """Check that the encryption client does not send any authorization headers."""
- assert 'Authorization' not in keboola_client.encryption_client.raw_client.headers
- assert 'X-StorageAPI-Token' not in keboola_client.encryption_client.raw_client.headers
-
-
-@pytest.mark.asyncio
-async def test_encrypt_string_not_equal(keboola_client: KeboolaClient) -> None:
- project_id = await keboola_client.storage_client.project_id()
- plaintext = 'my-plain-text'
- encrypted = await keboola_client.encryption_client.encrypt(
- value=plaintext,
- project_id=str(project_id),
- component_id=DATA_APP_COMPONENT_ID,
- )
- assert isinstance(encrypted, str)
- assert encrypted != plaintext
-
-
-@pytest.mark.asyncio
-async def test_encrypt_dict_hash_keys_only(keboola_client: KeboolaClient) -> None:
- project_id = await keboola_client.storage_client.project_id()
- payload: dict[str, Any] = {
- '#secret': 'sensitive-value',
- 'public': 'visible-value',
- }
- result = await keboola_client.encryption_client.encrypt(
- value=payload,
- project_id=str(project_id),
- component_id=DATA_APP_COMPONENT_ID,
- )
- assert isinstance(result, dict)
- # Values under keys beginning with '#' should be encrypted (changed)
- assert result['#secret'] != payload['#secret']
- # Non-secret values should remain the same
- assert result['public'] == payload['public']
diff --git a/integtests/clients/test_metastore.py b/integtests/clients/test_metastore.py
deleted file mode 100644
index dfb8cdb18..000000000
--- a/integtests/clients/test_metastore.py
+++ /dev/null
@@ -1,193 +0,0 @@
-from __future__ import annotations
-
-import logging
-from urllib.parse import urljoin
-from uuid import uuid4
-
-import httpx
-import pytest
-
-from keboola_mcp_server.clients.metastore import MetastoreClient
-
-LOG = logging.getLogger(__name__)
-
-
-async def delete_metastore_object(client: MetastoreClient, object_type: str, uuid: str) -> None:
- try:
- await client.delete_object(object_type, uuid)
- # Delete the soft deleted object
- await client.delete_object(object_type, uuid)
- except httpx.HTTPStatusError as exc:
- if exc.response.status_code not in (401, 403, 404):
- raise
-
-
-@pytest.fixture(scope='session')
-def metastore_url(storage_api_url: str) -> str:
- """Derive metastore URL from storage API URL by replacing 'connection.' prefix."""
- return storage_api_url.replace('connection.', 'metastore.', 1)
-
-
-@pytest.fixture
-def metastore_client(storage_api_token: str, metastore_url: str) -> MetastoreClient:
- return MetastoreClient.create(root_url=metastore_url, token=storage_api_token)
-
-
-@pytest.fixture(scope='module', autouse=True)
-def _require_metastore_available(
- storage_api_token: str,
- metastore_url: str,
-) -> None:
- try:
- probe_url = urljoin(metastore_url, '/health-check')
- with httpx.Client(
- headers={'X-StorageApi-Token': storage_api_token},
- timeout=httpx.Timeout(3.0, connect=1.0),
- ) as client:
- response = client.get(probe_url)
- response.raise_for_status()
- except httpx.HTTPStatusError as exc:
- _skip_unauthorized(exc)
- raise
- except httpx.ConnectError as exc:
- pytest.skip(f'Metastore endpoint is not reachable in this environment: {exc}')
- except httpx.TimeoutException as exc:
- pytest.skip(f'Metastore endpoint timed out in this environment: {exc}')
-
-
-def _skip_unauthorized(exc: httpx.HTTPStatusError) -> None:
- if exc.response.status_code == 401:
- details = ''
- try:
- details = exc.response.text[:300]
- except Exception:
- details = ''
- LOG.warning(f'Metastore unauthorized (401) for {exc.request.url}: {details}')
- pytest.skip('Token is not authorized for configured Metastore.')
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize(
- 'object_type',
- [
- 'semantic-model',
- 'semantic-dataset',
- 'semantic-metric',
- 'semantic-relationship',
- 'semantic-glossary',
- 'semantic-constraint',
- ],
-)
-async def test_get_schema_for_semantic_types(metastore_client: MetastoreClient, object_type: str) -> None:
- schema_doc = await metastore_client.get_schema(object_type)
- assert isinstance(schema_doc, dict)
-
-
-@pytest.mark.asyncio
-async def test_get_schema_specific_version(metastore_client: MetastoreClient) -> None:
- latest = await metastore_client.get_schema('semantic-model')
- version = latest.get('version')
- if not version:
- pytest.skip('Schema version not available in metastore response.')
- specific = await metastore_client.get_schema('semantic-model', version=version)
- assert specific.get('title') == 'semantic-model'
- assert specific.get('version') == version
-
-
-@pytest.mark.asyncio
-async def test_list_semantic_models_and_optional_detail(metastore_client: MetastoreClient) -> None:
- try:
- models = await metastore_client.list_objects('semantic-model')
- except httpx.HTTPStatusError as exc:
- _skip_unauthorized(exc)
- raise
- assert isinstance(models, list)
-
- if not models:
- pytest.skip('No semantic-model objects available in project scope for this token.')
-
- model = await metastore_client.get_object('semantic-model', models[0].id)
- assert model.id
- assert model.type == 'semantic-model'
- assert isinstance(model.attributes, dict)
-
-
-@pytest.mark.asyncio
-async def test_get_organization_models_list(metastore_client: MetastoreClient) -> None:
- try:
- models = await metastore_client.list_objects('semantic-model', organization_scope=True)
- except httpx.HTTPStatusError as exc:
- if exc.response.status_code in (401, 403, 404):
- pytest.skip('Organization scope endpoint is not accessible for this token/environment.')
- raise
- assert isinstance(models, list)
-
-
-@pytest.mark.asyncio
-async def test_crud_walkthrough_post_get_put_delete_and_revisions(metastore_client: MetastoreClient) -> None:
- object_type = 'semantic-model'
- model_name = f'ai2607-it-model-{uuid4().hex[:8]}'
- model_uuid: str | None = None
-
- try:
- try:
- created = await metastore_client.create_object(
- object_type,
- name=model_name,
- data={
- 'name': model_name,
- 'sql_dialect': 'Snowflake',
- 'description': 'Integration test model',
- },
- )
- except httpx.HTTPStatusError as exc:
- _skip_unauthorized(exc)
- raise
-
- model_uuid = created.id
- assert model_uuid
- assert created.type == object_type
- assert created.attributes.get('name') == model_name
-
- fetched = await metastore_client.get_object(object_type, model_uuid)
- assert fetched.id == model_uuid
- assert fetched.attributes.get('sql_dialect') == 'Snowflake'
-
- replaced = await metastore_client.put_object(
- object_type,
- model_uuid,
- name=model_name,
- data={
- 'name': model_name,
- 'sql_dialect': 'BigQuery',
- 'description': 'Replaced via PUT',
- },
- )
- assert replaced.id == model_uuid
-
- fetched_after_put = await metastore_client.get_object(object_type, model_uuid)
- assert fetched_after_put.attributes.get('sql_dialect') == 'BigQuery'
- assert fetched_after_put.meta.revision >= 2
-
- revisions = await metastore_client.list_revisions(object_type, filter_by=f'id={model_uuid}')
- assert isinstance(revisions, list)
- assert len(revisions) >= 1
-
- rev1 = await metastore_client.get_revision(object_type, model_uuid, 1)
- assert rev1.id == model_uuid
- assert rev1.attributes.get('sql_dialect') == 'Snowflake'
-
- if fetched_after_put.meta.revision >= 2:
- rev2 = await metastore_client.get_revision(object_type, model_uuid, 2)
- assert rev2.id == model_uuid
- assert rev2.attributes.get('sql_dialect') == 'BigQuery'
-
- await delete_metastore_object(metastore_client, object_type, model_uuid)
-
- with pytest.raises(httpx.HTTPStatusError) as exc_info:
- await metastore_client.get_object(object_type, model_uuid)
- assert exc_info.value.response.status_code == 404
-
- finally:
- if model_uuid:
- await delete_metastore_object(metastore_client, object_type, model_uuid)
diff --git a/integtests/conftest.py b/integtests/conftest.py
deleted file mode 100644
index 4626fb9dc..000000000
--- a/integtests/conftest.py
+++ /dev/null
@@ -1,636 +0,0 @@
-import dataclasses
-import importlib.metadata
-import json
-import logging
-import os
-import time
-import uuid
-from collections.abc import AsyncGenerator
-from dataclasses import dataclass
-from pathlib import Path
-from typing import Any, Generator
-
-import pytest
-import pytest_asyncio
-import requests
-from dotenv import load_dotenv
-from fastmcp import Client, Context, FastMCP
-from kbcstorage.client import Client as SyncStorageClient
-from mcp.server.session import ServerSession
-from mcp.shared.context import RequestContext
-from mcp.types import ClientCapabilities, Implementation, InitializeRequestParams
-
-from integtests.project_lock import (
- DEFAULT_MAX_WAIT_MINUTES,
- DEFAULT_POLL_INTERVAL_SECONDS,
- DEFAULT_TTL_MINUTES,
- AcquiredProject,
- ProjectPool,
- verify_project_endpoint,
-)
-from keboola_mcp_server.clients.client import KeboolaClient
-from keboola_mcp_server.config import Config, ServerRuntimeInfo
-from keboola_mcp_server.mcp import ServerState, SessionStateMiddleware
-from keboola_mcp_server.server import create_server
-from keboola_mcp_server.workspace import WorkspaceManager
-
-LOG = logging.getLogger(__name__)
-
-_project_pool: ProjectPool | None = None
-_acquired_project: AcquiredProject | None = None
-_project_info: str | None = None
-_project_info_printed: bool = False
-
-POOL_STORAGE_API_URL_ENV_VAR = 'INTEGTEST_POOL_STORAGE_API_URL'
-STORAGE_API_TOKENS_ENV_VAR = 'INTEGTEST_STORAGE_TOKENS' # space-separated pool of tokens
-# The second pair of token/schema for testing simultaneous access to two different projects.
-STORAGE_API_TOKEN_ENV_VAR_2 = 'INTEGTEST_STORAGE_TOKEN_PRJ2'
-WORKSPACE_SCHEMA_ENV_VAR_2 = 'INTEGTEST_WORKSPACE_SCHEMA_PRJ2'
-# We reset dev environment variables to integtest values to ensure tests run locally using .env settings.
-DEV_STORAGE_API_URL_ENV_VAR = 'STORAGE_API_URL'
-DEV_STORAGE_TOKEN_ENV_VAR = 'KBC_STORAGE_TOKEN'
-DEV_WORKSPACE_SCHEMA_ENV_VAR = 'KBC_WORKSPACE_SCHEMA'
-INTEGTEST_CLIENT_INFO = Implementation(
- name='kbc-mcp-integtests', version=importlib.metadata.version('keboola_mcp_server')
-)
-INTEGTEST_USER_AGENT = f'{INTEGTEST_CLIENT_INFO.name}/{INTEGTEST_CLIENT_INFO.version}'
-
-
-@dataclass(frozen=True)
-class BucketDef:
- bucket_id: str
- display_name: str
-
-
-@dataclass(frozen=True)
-class TableDef:
- bucket_id: str
- table_name: str
- table_id: str
-
- @property
- def file_path(self) -> Path:
- """
- Path to the CSV file containing the table data.
- """
- return _data_dir() / 'proj' / 'buckets' / self.bucket_id / f'{self.table_name}.csv'
-
-
-@dataclass(frozen=True)
-class ConfigDef:
- component_id: str
- configuration_id: str | None # Will be generated by Storage API
- internal_id: str
-
- @property
- def file_path(self) -> Path:
- """
- Path to the JSON file containing the configuration.
- """
- return _data_dir() / 'proj' / 'configs' / self.component_id / f'{self.internal_id}.json'
-
-
-@dataclass(frozen=True)
-class ProjectDef:
- project_id: str
- buckets: list[BucketDef]
- tables: list[TableDef]
- configs: list[ConfigDef]
-
-
-@pytest.fixture(scope='session')
-def env_file_loaded() -> bool:
- return load_dotenv()
-
-
-@pytest.fixture(autouse=True)
-def _patch_fastmcp_client_default_info(mocker) -> None:
- # Ensure all fastmcp.Client instances in integration tests use a distinct identity
- # unless a test intentionally provides a different client_info.
- original_init = Client.__init__
-
- def _init_with_integtest_client_info(self, *args: Any, **kwargs: Any) -> None:
- kwargs.setdefault('client_info', INTEGTEST_CLIENT_INFO)
- original_init(self, *args, **kwargs)
-
- mocker.patch.object(Client, '__init__', _init_with_integtest_client_info)
-
-
-@pytest.fixture(scope='session', autouse=True)
-def _patch_session_middleware_user_agent() -> Generator[None, None, None]:
- # Force a distinct User-Agent for outbound Keboola API requests during integration tests.
- monkeypatch = pytest.MonkeyPatch()
- original_get_headers = SessionStateMiddleware._get_headers.__func__
-
- def _get_headers_with_integtest_ua(
- cls: type[SessionStateMiddleware], runtime_info: ServerRuntimeInfo
- ) -> dict[str, Any]:
- headers = original_get_headers(cls, runtime_info)
- headers['User-Agent'] = INTEGTEST_USER_AGENT
- return headers
-
- monkeypatch.setattr(SessionStateMiddleware, '_get_headers', classmethod(_get_headers_with_integtest_ua))
- try:
- yield
- finally:
- monkeypatch.undo()
-
-
-@pytest.fixture(scope='session')
-def env_init(env_file_loaded: bool, storage_api_token: str, storage_api_url: str, workspace_schema: str) -> bool:
- # We reset the development environment variables to the values of the integtest environment variables.
- os.environ[DEV_STORAGE_API_URL_ENV_VAR] = storage_api_url
- os.environ[DEV_STORAGE_TOKEN_ENV_VAR] = storage_api_token
- os.environ[DEV_WORKSPACE_SCHEMA_ENV_VAR] = workspace_schema
- return env_file_loaded
-
-
-def _data_dir() -> Path:
- return Path(__file__).parent / 'data'
-
-
-@pytest.fixture(scope='session')
-def storage_api_url(env_file_loaded: bool) -> str:
- storage_api_url = os.getenv(POOL_STORAGE_API_URL_ENV_VAR)
- assert storage_api_url, f'{POOL_STORAGE_API_URL_ENV_VAR} must be set'
- return storage_api_url
-
-
-@pytest.fixture(scope='session')
-def storage_api_token(project_lock: AcquiredProject) -> str:
- return project_lock.endpoint.storage_api_token
-
-
-@pytest.fixture(scope='session')
-def mcp_config(storage_api_token: str, storage_api_url: str) -> Config:
- return Config(storage_api_url=storage_api_url, storage_token=storage_api_token)
-
-
-@pytest.fixture(scope='session')
-def workspace_schema(_clean_project: None, storage_api_token: str, storage_api_url: str) -> Generator[str, Any, None]:
- """
- Create one read-only workspace for the whole test session and yield its schema.
-
- The workspace is created once (session scope), reused across all tests, and deleted
- on teardown.
- """
- # sync_storage_client is function-scoped; build a session-scoped client here.
- storage_client = _sync_storage_client(storage_api_token, storage_api_url)
- token_info = storage_client.tokens.verify()
- backend = token_info['owner'].get('defaultBackend')
- # Mirror the MCP server's own per-backend workspace login type (see WorkspaceManager._create_ws).
- if backend == 'snowflake':
- login_type = 'snowflake-person-sso'
- elif backend == 'bigquery':
- login_type = 'default'
- else:
- raise RuntimeError(f'Unexpected project default backend: {backend!r} (expected snowflake or bigquery)')
-
- LOG.info(f'Creating a read-only {backend} workspace for the test session')
- workspace = storage_client.workspaces.create(
- backend=backend,
- login_type=login_type,
- # kbcstorage maps read_all_objects to the readOnlyStorageAccess flag (read-only storage access).
- read_all_objects=True,
- )
- workspace_id = workspace['id']
- schema = workspace['connection']['schema']
- LOG.info(f'Created read-only workspace {workspace_id} (schema {schema})')
- try:
- yield schema
- finally:
- LOG.info(f'Deleting test-session workspace {workspace_id}')
- try:
- storage_client.workspaces.delete(workspace_id)
- except Exception:
- LOG.exception(f'Failed to delete test-session workspace {workspace_id}')
-
-
-@pytest.fixture(scope='session')
-def storage_api_token_2(env_file_loaded: bool) -> str | None:
- return os.getenv(STORAGE_API_TOKEN_ENV_VAR_2)
-
-
-@pytest.fixture(scope='session')
-def workspace_schema_2(env_file_loaded: bool) -> str | None:
- return os.getenv(WORKSPACE_SCHEMA_ENV_VAR_2)
-
-
-@pytest.fixture(scope='session')
-def shared_datadir_ro() -> Path:
- """
- Session-scoped access to shared data directory for integration tests.
- Do not modify the data in this directory.
- For function-scoped access to the data, use `shared_datadir` fixture provided by `pytest-datadir`,
- which creates a temporary copy of the data which can therefore be modified.
- """
- return _data_dir()
-
-
-_BUCKET_DEFS_IN = [
- BucketDef(bucket_id='in.c-test_bucket_01', display_name='test_bucket_01'),
- BucketDef(bucket_id='in.c-test_bucket_02', display_name='test_bucket_02'),
-]
-
-
-def _create_buckets(storage_client: SyncStorageClient) -> list[BucketDef]:
- for bucket in _BUCKET_DEFS_IN:
- LOG.info(f'Creating bucket with display name={bucket.display_name}')
- created_bucket = storage_client.buckets.create(bucket.display_name)
- assert created_bucket['id'] == bucket.bucket_id
- assert created_bucket['displayName'] == bucket.display_name
-
- return _BUCKET_DEFS_IN
-
-
-_TABLE_DEFS_IN = [
- TableDef(
- bucket_id='in.c-test_bucket_01',
- table_name='test_table_01',
- table_id='in.c-test_bucket_01.test_table_01',
- ),
-]
-
-
-def _create_tables(storage_client: SyncStorageClient) -> list[TableDef]:
- for table in _TABLE_DEFS_IN:
- LOG.info(f'Creating table with name={table.table_name}')
- created_table_id = storage_client.tables.create(
- bucket_id=table.bucket_id,
- name=table.table_name,
- file_path=str(table.file_path),
- )
- assert created_table_id == table.table_id
-
- return _TABLE_DEFS_IN
-
-
-_CONFIG_DEFS_IN = [
- ConfigDef(
- component_id='ex-generic-v2',
- configuration_id=None,
- internal_id='test_config1',
- ),
- ConfigDef(
- component_id='keboola.snowflake-transformation',
- configuration_id=None,
- internal_id='test_config2',
- ),
-]
-
-
-def _create_configs(storage_client: SyncStorageClient) -> list[ConfigDef]:
- configs = []
- for config in _CONFIG_DEFS_IN:
- LOG.info(f'Creating config with internal ID={config.internal_id}')
- with config.file_path.open('r', encoding='utf-8') as cfg_file:
- created_config = storage_client.configurations.create(
- component_id=config.component_id,
- name=config.internal_id,
- configuration_id=None,
- configuration=json.load(cfg_file),
- )
- config = dataclasses.replace(config, configuration_id=created_config['id'])
- configs.append(config)
- LOG.info(f'Created config with component ID={config.component_id} and config ID={config.configuration_id}')
-
- return configs
-
-
-def _sync_storage_client(storage_api_token: str, storage_api_url: str) -> SyncStorageClient:
- client = SyncStorageClient(storage_api_url, storage_api_token)
- token_info = client.tokens.verify()
- LOG.info(
- f'Authorized as "{token_info["description"]}" ({token_info["id"]}) '
- f'to project "{token_info["owner"]["name"]}" ({token_info["owner"]["id"]}) '
- f'at "{client.root_url}" stack.'
- )
- return client
-
-
-# Buckets the integration fixtures/tests create are all stage-prefixed "c-test*". A real
-# (non-dedicated) project's buckets won't match, so this doubles as a safety guard against a
-# misconfigured INTEGTEST_STORAGE_TOKENS pointing at a project whose data must not be wiped.
-_TEST_BUCKET_ID_PREFIXES = ('in.c-test', 'out.c-test', 'sys.c-test')
-
-# Workspaces not created by the tests that must never be deleted (matched on the creator token).
-_STATIC_WORKSPACE_CREATORS = frozenset({'Background Indexing Token'})
-
-
-def _guard_dedicated_test_project(storage_client: SyncStorageClient, project_id: str) -> None:
- """Refuse to reset the project unless it looks like a dedicated integtest project.
-
- The integ fixtures/tests only ever create stage-prefixed ``*.c-test*`` buckets, so a project
- holding any other bucket is almost certainly not a dedicated test project. Failing here (instead
- of deleting) protects against a misconfigured ``INTEGTEST_STORAGE_TOKENS`` that points at a
- project whose data must not be wiped.
- """
- foreign = [b['id'] for b in storage_client.buckets.list() if not b['id'].startswith(_TEST_BUCKET_ID_PREFIXES)]
- if foreign:
- pytest.fail(
- f'Refusing to reset project {project_id}: found non-test buckets {foreign}. '
- f'INTEGTEST_STORAGE_TOKENS may be pointing at a non-dedicated project.'
- )
-
-
-def _purge_project(storage_client: SyncStorageClient, storage_api_url: str, project_id: str) -> None:
- """Reset a dedicated integtest project to a clean state.
-
- Integration tests run against a shared pool of projects acquired per run. A prior session that
- was cancelled (a new push/rebase cancels the in-flight CI run) or timed out before its teardown
- leaves buckets, configurations, workspaces and branch metadata behind. The next run that locks
- the project would then fail an empty-project assertion *before* its own teardown runs, so the
- project stays dirty and wedges every subsequent run. Purging here lets the shared pool self-heal.
- """
- _guard_dedicated_test_project(storage_client, project_id)
-
- buckets = storage_client.buckets.list()
- workspaces = [
- w
- for w in storage_client.workspaces.list()
- if w.get('creatorToken', {}).get('description') not in _STATIC_WORKSPACE_CREATORS
- ]
- components = storage_client.components.list(include=['configuration'])
- branch_meta = [
- m for m in storage_client.branches.metadata('default') if m.get('key') == WorkspaceManager.MCP_META_KEY
- ]
-
- if buckets or workspaces or any(c.get('configurations') for c in components) or branch_meta:
- LOG.warning(
- f'Project {project_id} was not clean: {len(buckets)} bucket(s), {len(workspaces)} workspace(s), '
- f'{sum(len(c.get("configurations", [])) for c in components)} config(s), '
- f'{len(branch_meta)} workspace-metadata entr(ies) — likely an interrupted prior run. Purging.'
- )
-
- for bucket in buckets:
- storage_client.buckets.delete(bucket['id'], force=True)
-
- for workspace in workspaces:
- storage_client.workspaces.delete(workspace['id'])
-
- for component in components:
- component_id = component['id']
- for config in component.get('configurations', []):
- # Double delete because the first delete only moves the configuration to the trash.
- storage_client.configurations.delete(component_id, config['id'])
- storage_client.configurations.delete(component_id, config['id'])
-
- # kbcstorage exposes no branch-metadata delete; the MCP WorkspaceManager stamps MCP_META_KEY on
- # the default branch when it creates a workspace, so remove leftovers with a raw request.
- for meta in branch_meta:
- resp = requests.delete(
- f'{storage_api_url.rstrip("/")}/v2/storage/branch/default/metadata/{meta["id"]}',
- headers={'X-StorageApi-Token': storage_client.token},
- )
- resp.raise_for_status()
-
-
-@pytest.fixture(scope='session')
-def _clean_project(storage_api_token: str, storage_api_url: str) -> None:
- """Reset the acquired pool project before any other fixture creates resources, so an interrupted
- prior run can't wedge the shared pool. Other session fixtures depend on this to order it first.
-
- Side-effect only (no return value), so the leading underscore is kept per pytest-style PT005.
- """
- storage_client = _sync_storage_client(storage_api_token, storage_api_url)
- project_id: str = storage_client.tokens.verify()['owner']['id']
- _purge_project(storage_client, storage_api_url, project_id)
-
-
-@pytest.fixture(scope='session')
-def keboola_project(
- _clean_project: None, env_init: bool, storage_api_token: str, storage_api_url: str
-) -> Generator[ProjectDef, Any, None]:
- """
- Sets up a Keboola project with items needed for integration tests,
- such as buckets, tables and configurations.
- The project is reset to a clean state first (see _clean_project) and cleaned up after the tests.
- """
- # Cannot use keboola_client fixture because it is function-scoped
- storage_client = _sync_storage_client(storage_api_token, storage_api_url)
- token_info = storage_client.tokens.verify()
- project_id: str = token_info['owner']['id']
-
- buckets = _create_buckets(storage_client)
- tables = _create_tables(storage_client)
- configs = _create_configs(storage_client)
-
- if 'global-search' in token_info['owner'].get('features', []):
- # Give the global search time to catch up on the changes done in the testing project.
- # See https://help.keboola.com/management/global-search/#limitations for more info.
- time.sleep(10)
-
- LOG.info(f'Test setup for project {project_id} complete')
- yield ProjectDef(project_id=project_id, buckets=buckets, tables=tables, configs=configs)
-
- LOG.info(f'Cleaning up Keboola project with ID={project_id}')
- current_buckets = storage_client.buckets.list()
- for bucket in current_buckets:
- bucket_id = bucket['id']
- LOG.info(f'Deleting bucket with ID={bucket_id}')
- storage_client.buckets.delete(bucket_id, force=True)
-
- for config in configs:
- LOG.info(f'Deleting config with component ID={config.component_id} and config ID={config.configuration_id}')
- storage_client.configurations.delete(config.component_id, config.configuration_id)
- # Double delete because the first delete moves the configuration to the trash
- storage_client.configurations.delete(config.component_id, config.configuration_id)
-
-
-def _setup_pool(storage_api_url: str) -> tuple[ProjectPool, AcquiredProject]:
- tokens_raw = os.getenv(STORAGE_API_TOKENS_ENV_VAR, '').strip()
- if not tokens_raw:
- raise RuntimeError(
- f'{STORAGE_API_TOKENS_ENV_VAR} must be set to a non-empty space-separated list of project tokens'
- )
- tokens = tokens_raw.split()
-
- endpoints = [verify_project_endpoint(storage_api_url, t) for t in tokens]
-
- pool = ProjectPool(
- endpoints=endpoints,
- ttl_minutes=int(os.getenv('INTEGTEST_LOCK_TTL_MINUTES', str(DEFAULT_TTL_MINUTES))),
- poll_interval_seconds=int(
- os.getenv('INTEGTEST_LOCK_POLL_INTERVAL_SECONDS', str(DEFAULT_POLL_INTERVAL_SECONDS))
- ),
- max_wait_minutes=int(os.getenv('INTEGTEST_LOCK_MAX_WAIT_MINUTES', str(DEFAULT_MAX_WAIT_MINUTES))),
- )
- return pool, pool.acquire()
-
-
-def pytest_collection_finish(session: pytest.Session) -> None:
- """Pytest hook called after collection is finished, before any tests run.
-
- Eagerly acquires the project lock and prints project/token info to the terminal
- so it appears before test progress output — avoiding the ANSI overwrite issue that
- occurs when writing during fixture setup (which runs mid-progress-line in compact mode).
-
- If acquisition fails (e.g. missing env vars), the hook logs a warning and returns
- without raising; the ``project_lock`` fixture will then acquire lazily and call
- ``pytest.fail()`` with a proper error message.
-
- See also:
- https://docs.pytest.org/en/stable/reference/reference.html#pytest.hookspec.pytest_collection_finish
- """
- global _project_pool, _acquired_project, _project_info, _project_info_printed
- needs_lock = any('project_lock' in getattr(item, 'fixturenames', []) for item in session.items)
- if not needs_lock:
- return
- load_dotenv()
- storage_api_url = os.getenv(POOL_STORAGE_API_URL_ENV_VAR)
- if not storage_api_url:
- return
- try:
- _project_pool, _acquired_project = _setup_pool(storage_api_url)
- except Exception as exc:
- LOG.warning(f'[integtest] Eager project lock acquisition failed: {exc}')
- return
- ep = _acquired_project.endpoint
- _project_info = ep.describe()
- reporter = session.config.pluginmanager.get_plugin('terminalreporter')
- if reporter is not None:
- reporter.write_sep('-', 'integtest project')
- reporter.write_line(_project_info)
- _project_info_printed = True
-
-
-def pytest_terminal_summary(terminalreporter: Any, exitstatus: int, config: pytest.Config) -> None:
- if _project_info is not None and not _project_info_printed:
- terminalreporter.write_sep('-', 'integtest project')
- terminalreporter.write_line(_project_info)
-
-
-@pytest.fixture(scope='session')
-def project_lock(env_file_loaded: bool, storage_api_url: str) -> Generator[AcquiredProject, Any, None]:
- """
- Acquires a distributed lock on a Keboola test project via branch metadata.
-
- Requires INTEGTEST_STORAGE_TOKENS to be set to a space-separated list of project
- tokens, one per project in the pool. The Storage API URL is provided by the
- storage_api_url fixture (INTEGTEST_POOL_STORAGE_API_URL).
-
- Yields AcquiredProject(endpoint, lock_info) so callers know which project was
- selected. The storage_api_token fixture reads its value from the acquired endpoint.
-
- The project lock is normally acquired eagerly in pytest_collection_finish before
- tests start. This fixture falls back to acquiring it lazily if that hook did not run.
- """
- global _project_pool, _acquired_project, _project_info
- if _acquired_project is None:
- try:
- _project_pool, _acquired_project = _setup_pool(storage_api_url)
- except RuntimeError as exc:
- pytest.fail(str(exc))
- ep = _acquired_project.endpoint
- _project_info = ep.describe()
- try:
- yield _acquired_project
- finally:
- if _project_pool is not None and _acquired_project is not None:
- _project_pool.release(_acquired_project)
- _acquired_project = None
- _project_pool = None
-
-
-@pytest.fixture(scope='session')
-def buckets(keboola_project: ProjectDef) -> list[BucketDef]:
- return keboola_project.buckets
-
-
-@pytest.fixture(scope='session')
-def tables(keboola_project: ProjectDef) -> list[TableDef]:
- return keboola_project.tables
-
-
-@pytest.fixture(scope='session')
-def configs(keboola_project: ProjectDef) -> list[ConfigDef]:
- return keboola_project.configs
-
-
-@pytest.fixture
-def sync_storage_client(storage_api_token: str, storage_api_url: str) -> SyncStorageClient:
- """Gets the ordinary (synchronous) client from the official Keboola SDK (i.e. `kbcstorage` package)."""
- return _sync_storage_client(storage_api_token, storage_api_url)
-
-
-@pytest.fixture
-def keboola_client(sync_storage_client: SyncStorageClient) -> KeboolaClient:
- return KeboolaClient(
- storage_api_token=sync_storage_client.token,
- storage_api_url=sync_storage_client.root_url,
- headers={'User-Agent': INTEGTEST_USER_AGENT},
- )
-
-
-@pytest.fixture
-def unique_id() -> str:
- """Generates a unique ID string for test resources."""
- return str(uuid.uuid4())[:8]
-
-
-@pytest_asyncio.fixture
-async def workspace_manager(keboola_client: KeboolaClient, workspace_schema: str) -> WorkspaceManager:
- return await WorkspaceManager.create(keboola_client, workspace_schema)
-
-
-@pytest_asyncio.fixture()
-async def require_snowflake(workspace_manager: WorkspaceManager) -> None:
- sql_dialect = await workspace_manager.get_sql_dialect()
- if sql_dialect != 'Snowflake':
- pytest.skip(f'Snowflake backend required, got: {sql_dialect}')
-
-
-@pytest_asyncio.fixture()
-async def require_bigquery(workspace_manager: WorkspaceManager) -> None:
- sql_dialect = await workspace_manager.get_sql_dialect()
- if sql_dialect != 'BigQuery':
- pytest.skip(f'BigQuery backend required, got: {sql_dialect}')
-
-
-@pytest.fixture
-def mcp_context(
- mocker,
- keboola_client: KeboolaClient,
- workspace_manager: WorkspaceManager,
- keboola_project: ProjectDef,
- mcp_config: Config,
-) -> Context:
- """
- MCP context containing the Keboola client and workspace manager.
- """
- client_context = mocker.MagicMock(Context)
- client_context.session = mocker.MagicMock(ServerSession)
- # We set the user session state as it is done in the @with_session_state decorator
- client_context.session.state = {
- KeboolaClient.STATE_KEY: keboola_client,
- WorkspaceManager.STATE_KEY: workspace_manager,
- }
- client_context.session.client_params = InitializeRequestParams(
- protocolVersion='1',
- capabilities=ClientCapabilities(),
- clientInfo=INTEGTEST_CLIENT_INFO,
- )
- client_context.client_id = INTEGTEST_USER_AGENT
- client_context.session_id = None
- client_context.request_context = mocker.MagicMock(RequestContext)
- client_context.request_context.lifespan_context = ServerState(mcp_config, ServerRuntimeInfo(transport='stdio'))
- # `meta` is an instance attribute of RequestContext (set in __init__), not a class attribute,
- # so MagicMock(spec=RequestContext) doesn't expose it. Default it to None so tools that read
- # the progressToken (e.g. query_data) don't trip AttributeError; individual tests can override.
- client_context.request_context.meta = None
-
- return client_context
-
-
-@pytest.fixture
-def mcp_server(storage_api_url: str, storage_api_token: str, workspace_schema: str) -> FastMCP:
- config = Config(storage_api_url=storage_api_url, storage_token=storage_api_token, workspace_schema=workspace_schema)
- server = create_server(config, runtime_info=ServerRuntimeInfo(transport='stdio'))
- assert isinstance(server, FastMCP)
- return server
-
-
-@pytest_asyncio.fixture
-async def mcp_client(mcp_server: FastMCP) -> AsyncGenerator[Client, None]:
- async with Client(mcp_server, client_info=INTEGTEST_CLIENT_INFO) as client:
- yield client
diff --git a/integtests/errors.test.ts b/integtests/errors.test.ts
new file mode 100644
index 000000000..24990f209
--- /dev/null
+++ b/integtests/errors.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from 'vitest';
+
+import { callToolRaw, connectMcp } from './helpers/mcp';
+import { getTestProjectForTest } from './testproject/fixture';
+
+// Ported from integtests/test_errors.py.
+//
+// The Python tests call the in-process tool functions directly and assert on raised
+// exceptions / typed outputs. Here we exercise the same error paths end-to-end through
+// the MCP client: tools that swallow "not found" into a structured output return text
+// (no isError), while tools that propagate an upstream HTTP/SQL error surface it as a
+// CallToolResult with `isError: true` and the formatted message in the text content.
+
+const errorText = (result: unknown): string =>
+ (result as { content: { text: string }[] }).content[0]!.text;
+
+describe('error handling (integration)', () => {
+ // test_storage_api_404_error_maintains_standard_behavior: a non-existent bucket is
+ // reported via the structured `buckets_not_found` field, not as a tool error.
+ it('get_buckets reports a missing bucket without erroring', async () => {
+ const { config } = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(config);
+ try {
+ const result = await callToolRaw(session.client, 'get_buckets', {
+ bucket_ids: ['non.existent.bucket'],
+ });
+ expect(result.isError).toBeFalsy();
+ expect(errorText(result)).toContain('non.existent.bucket');
+ } finally {
+ await session.close();
+ }
+ });
+
+ // test_concurrent_error_handling: many concurrent not-found lookups are each handled
+ // consistently (no error, each reported in buckets_not_found).
+ it('handles concurrent missing-bucket lookups consistently', async () => {
+ const { config } = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(config);
+ try {
+ const results = await Promise.all(
+ Array.from({ length: 5 }, (_, i) =>
+ callToolRaw(session.client, 'get_buckets', {
+ bucket_ids: [`non.existent.bucket.${i}`],
+ }),
+ ),
+ );
+ for (let i = 0; i < results.length; i++) {
+ const result = results[i]!;
+ expect(result.isError).toBeFalsy();
+ expect(errorText(result)).toContain(`non.existent.bucket.${i}`);
+ }
+ } finally {
+ await session.close();
+ }
+ });
+
+ // test_jobs_api_404_error_: requesting a non-existent job id propagates the upstream
+ // 404 as a tool error mentioning the job id.
+ it('get_jobs surfaces a 404 for a non-existent job id as a tool error', async () => {
+ const { config } = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(config);
+ try {
+ const result = await callToolRaw(session.client, 'get_jobs', { job_ids: ['999999999'] });
+ expect(result.isError).toBeTruthy();
+ // The TS queue client surfaces the upstream 404 as a generic "Not Found" message
+ // (it does not echo the job id / URL the Python HTTPStatusError carried), so we
+ // assert only that the not-found error reached the tool layer.
+ expect(errorText(result)).toMatch(/404|not found/i);
+ } finally {
+ await session.close();
+ }
+ });
+
+ // NOTE: the Python test_docs_api_empty_query_error (a 422 from the AI docs service on an
+ // empty query) was dropped: docs_query is now served by the pgvector docs-search index
+ // (RFC: feature_spec/docs-search-pgvector/), where an empty query returns no results
+ // rather than erroring. The docs happy-path + index gating are covered by
+ // integtests/tools/doc.test.ts.
+
+ // test_sql_api_invalid_query_error_(snowflake|bigquery): an invalid SQL query is
+ // surfaced as a tool error with the "Failed to run SQL query" prefix, regardless of
+ // the backend dialect.
+ it('query_data surfaces an invalid SQL query as a tool error', async () => {
+ const { config } = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(config);
+ try {
+ const result = await callToolRaw(session.client, 'query_data', {
+ sql_query: 'INVALID SQL SYNTAX HERE',
+ query_name: 'Invalid SQL query.',
+ });
+ expect(result.isError).toBeTruthy();
+ expect(errorText(result)).toMatch(/Failed to run SQL query/i);
+ } finally {
+ await session.close();
+ }
+ });
+
+ // Bad tool input (schema violation) is rejected by the server before the handler runs
+ // and is reported as a tool error — the structured-error end-to-end contract.
+ it('rejects a tool call with invalid input as a structured error', async () => {
+ const { config } = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(config);
+ try {
+ // get_jobs.limit is an int in [1, 500]; an out-of-range value must be rejected.
+ const result = await callToolRaw(session.client, 'get_jobs', { limit: 99999 });
+ expect(result.isError).toBeTruthy();
+ } finally {
+ await session.close();
+ }
+ });
+
+ // test_event_emitted / TestStorageEvents: the Python suite verifies that a SAPI Storage
+ // event is emitted (and its mcpServerContext payload) for every tool call. That is an
+ // internal telemetry concern asserted by polling the SAPI events endpoint, not a
+ // tool-output contract reachable through the MCP client, so it is not ported here.
+ it.skip('emits a SAPI storage event per tool call (telemetry; not a tool-layer concern)', () => {
+ // Intentionally skipped — see comment above. Would require polling client.storage
+ // events and is out of scope for the in-memory server/middleware-level suite.
+ });
+});
diff --git a/integtests/helpers/mcp.ts b/integtests/helpers/mcp.ts
new file mode 100644
index 000000000..fc0492c2e
--- /dev/null
+++ b/integtests/helpers/mcp.ts
@@ -0,0 +1,47 @@
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
+
+import type { Config } from '@/config';
+import { createServer } from '@/server';
+
+// Shared integration-test harness: connect a real MCP server (built from a leased project's
+// Config) over an in-memory transport and call its tools — the same shape as the unit tests
+// (__tests__/*), but hitting the real Keboola stack instead of msw mocks.
+
+export type McpSession = {
+ client: Client;
+ close: () => Promise;
+};
+
+export const connectMcp = async (config: Config): Promise => {
+ const [clientT, serverT] = InMemoryTransport.createLinkedPair();
+ const server = createServer(config);
+ await server.connect(serverT);
+ const client = new Client({ name: 'kbc-mcp-integtests', version: '0.0.0' });
+ await client.connect(clientT);
+ return {
+ client,
+ close: async () => {
+ await client.close();
+ await server.close();
+ },
+ };
+};
+
+/** Calls a tool and returns its text content, asserting the call did not error. */
+export const callToolText = async (
+ client: Client,
+ name: string,
+ args: Record = {},
+): Promise => {
+ const result = await client.callTool({ name, arguments: args });
+ if (result.isError) {
+ const text = (result.content as { text?: string }[])[0]?.text ?? '';
+ throw new Error(`Tool "${name}" returned an error: ${text}`);
+ }
+ return (result.content as { text: string }[])[0]!.text;
+};
+
+/** Calls a tool and returns the raw CallToolResult (for negative-path / isError assertions). */
+export const callToolRaw = (client: Client, name: string, args: Record = {}) =>
+ client.callTool({ name, arguments: args });
diff --git a/integtests/helpers/seed.ts b/integtests/helpers/seed.ts
new file mode 100644
index 000000000..2ae41042b
--- /dev/null
+++ b/integtests/helpers/seed.ts
@@ -0,0 +1,98 @@
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import type { TestProject } from '../testproject/fixture';
+
+// Seeds a leased project with the same fixtures the Python integ suite creates
+// (integtests/conftest.py: _create_buckets/_create_tables/_create_configs): two input
+// buckets, one CSV table, and two component configurations. Uses the raw Storage API
+// (form-encoded, matching the kbcstorage SDK the Python tests used) so it does not depend
+// on api-client method shapes.
+
+const DATA_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'data', 'proj');
+
+export type SeedBucket = { id: string; displayName: string };
+export type SeedTable = { id: string; bucketId: string; name: string };
+export type SeedConfig = { componentId: string; configurationId: string; internalId: string };
+export type SeededProject = {
+ buckets: SeedBucket[];
+ tables: SeedTable[];
+ configs: SeedConfig[];
+};
+
+const BUCKETS: { displayName: string; id: string }[] = [
+ { displayName: 'test_bucket_01', id: 'in.c-test_bucket_01' },
+ { displayName: 'test_bucket_02', id: 'in.c-test_bucket_02' },
+];
+const TABLES: { bucketId: string; name: string; id: string }[] = [
+ { bucketId: 'in.c-test_bucket_01', name: 'test_table_01', id: 'in.c-test_bucket_01.test_table_01' },
+];
+const CONFIGS: { componentId: string; internalId: string; file: string }[] = [
+ { componentId: 'ex-generic-v2', internalId: 'test_config1', file: 'ex-generic-v2/test_config1.json' },
+ {
+ componentId: 'keboola.snowflake-transformation',
+ internalId: 'test_config2',
+ file: 'keboola.snowflake-transformation/test_config2.json',
+ },
+];
+
+/** Form-encoded POST to the Storage API (the shape kbcstorage write endpoints expect). */
+const form = async (
+ base: string,
+ token: string,
+ path: string,
+ fields: Record,
+): Promise> => {
+ const body = new URLSearchParams(fields);
+ const res = await fetch(`${base}/v2/storage/${path}`, {
+ method: 'POST',
+ headers: {
+ 'X-StorageApi-Token': token,
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body,
+ });
+ const text = await res.text();
+ if (!res.ok) throw new Error(`Seed POST ${path} failed: ${res.status} ${text}`);
+ return text ? (JSON.parse(text) as Record) : {};
+};
+
+export const seedProject = async (project: TestProject): Promise => {
+ const base = project.storageApiUrl;
+ const token = project.storageApiToken;
+
+ const buckets: SeedBucket[] = [];
+ for (const b of BUCKETS) {
+ const created = await form(base, token, 'buckets', { name: b.displayName, stage: 'in' });
+ buckets.push({ id: String(created.id ?? b.id), displayName: b.displayName });
+ }
+
+ const tables: SeedTable[] = [];
+ for (const t of TABLES) {
+ const csv = readFileSync(join(DATA_DIR, 'buckets', t.bucketId, `${t.name}.csv`), 'utf-8');
+ // Synchronous create-from-string: the simplest way to seed a small table without the
+ // file-upload + async-import dance.
+ const created = await form(base, token, `buckets/${t.bucketId}/tables`, {
+ name: t.name,
+ dataString: csv,
+ });
+ tables.push({ id: String(created.id ?? t.id), bucketId: t.bucketId, name: t.name });
+ }
+
+ const configs: SeedConfig[] = [];
+ for (const c of CONFIGS) {
+ const configuration = readFileSync(join(DATA_DIR, 'configs', c.file), 'utf-8');
+ const created = await form(base, token, `branch/default/components/${c.componentId}/configs`, {
+ name: c.internalId,
+ configuration,
+ });
+ configs.push({
+ componentId: c.componentId,
+ configurationId: String(created.id ?? ''),
+ internalId: c.internalId,
+ });
+ }
+
+ return { buckets, tables, configs };
+};
diff --git a/integtests/mcp_server.test.ts b/integtests/mcp_server.test.ts
new file mode 100644
index 000000000..e2cddf417
--- /dev/null
+++ b/integtests/mcp_server.test.ts
@@ -0,0 +1,149 @@
+import { describe, expect, it } from 'vitest';
+
+import { callToolText, connectMcp } from './helpers/mcp';
+import { getTestProjectForTest } from './testproject/fixture';
+
+// Ported from integtests/test_mcp_server.py.
+//
+// The Python suite spins up the server over stdio / streamable-http subprocesses and
+// asserts: the expected tool set is present (tools/list), 6 prompts (prompts/list),
+// 0 resources (resources/list), and a get_configs round-trip returns the seeded
+// component config. Here we connect the in-process MCP server (built from a leased
+// project's Config) over the in-memory transport and make the same assertions through
+// the MCP client. The transport-matrix / multi-client / different-header cases are
+// stdio/HTTP-subprocess concerns that do not apply to the in-memory harness, so they
+// are not ported.
+
+// The tool set the server must expose. Tools gated on project features (conditional /
+// classic flows, search, semantic) are excluded from the strict comparison because
+// whether they appear depends on the leased project's enabled features — mirroring the
+// Python `exclude` set.
+const EXCLUDE = new Set([
+ 'create_conditional_flow',
+ 'create_flow',
+ 'search',
+ 'update_flow',
+ 'modify_flow',
+ 'get_semantic_context',
+ 'get_semantic_schema',
+ 'search_semantic_context',
+ 'validate_semantic_query',
+ // Docs-search tools are gated on a configured pgvector index (DATABASE_URL); whether they
+ // appear depends on the run's env, so exclude them from the strict set (covered by
+ // integtests/tools/doc.test.ts).
+ 'docs_query',
+ 'find_component_id',
+]);
+
+const EXPECTED_TOOLS = new Set(
+ [
+ 'add_config_row',
+ 'create_conditional_flow',
+ 'create_config',
+ 'create_flow',
+ 'create_oauth_url',
+ 'create_python_js_data_app_git_credential',
+ 'create_sql_transformation',
+ 'delete_python_js_data_app_draft',
+ 'deploy_data_app',
+ 'docs_query',
+ 'find_component_id',
+ 'get_buckets',
+ 'get_components',
+ 'get_config_examples',
+ 'get_configs',
+ 'get_data_apps',
+ 'get_flow_examples',
+ 'get_flow_schema',
+ 'get_flows',
+ 'get_jobs',
+ 'get_project_info',
+ 'get_tables',
+ 'modify_flow',
+ 'modify_python_js_data_app',
+ 'modify_streamlit_data_app',
+ 'query_data',
+ 'run_job',
+ 'run_sync_action',
+ 'search',
+ 'update_config',
+ 'update_config_row',
+ 'update_descriptions',
+ 'update_flow',
+ 'update_project_description',
+ 'update_sql_transformation',
+ // The TS port carries a `get_server_info` scaffold tool that has no Python equivalent;
+ // it is expected to be present until the scaffold is removed.
+ 'get_server_info',
+ ].filter((name) => !EXCLUDE.has(name)),
+);
+
+describe('MCP server wiring (integration)', () => {
+ it('exposes the expected tool set, 6 prompts, and no resources', async () => {
+ const { config } = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(config);
+ try {
+ const { tools } = await session.client.listTools();
+ const actual = new Set(tools.map((t) => t.name).filter((name) => !EXCLUDE.has(name)));
+
+ const missing = [...EXPECTED_TOOLS].filter((name) => !actual.has(name));
+ expect(missing, `Missing tools: ${missing.join(', ')}`).toEqual([]);
+
+ const unexpected = [...actual].filter((name) => !EXPECTED_TOOLS.has(name));
+ expect(unexpected, `Unexpected new tools: ${unexpected.join(', ')}`).toEqual([]);
+
+ const { prompts } = await session.client.listPrompts();
+ expect(prompts.length).toBe(6);
+
+ // No resources are exposed. The SDK only registers a `resources/list` handler when
+ // at least one resource is registered, so with none the call raises "Method not
+ // found" (-32601) — which is itself proof the server exposes zero resources, the
+ // same fact the Python `len(resources) == 0` assertion checked.
+ const resourceCount = await session.client
+ .listResources()
+ .then((r) => r.resources.length)
+ .catch((err: { code?: number }) => {
+ if (err?.code === -32601) return 0;
+ throw err;
+ });
+ expect(resourceCount).toBe(0);
+ } finally {
+ await session.close();
+ }
+ });
+
+ it('round-trips a component configuration through create_config + get_configs', async () => {
+ // Port of _assert_get_component_details_tool_call: the Python test seeds a component
+ // config and fetches its detail through get_configs. We create the config through the
+ // tool layer instead (no dependency on the clean+seed wipe path, which is gated on a
+ // dedicated project), then fetch it back — the same create→read round-trip through the
+ // in-process MCP server.
+ const COMPONENT_ID = 'ex-generic-v2';
+ const { config } = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(config);
+ try {
+ const created = await callToolText(session.client, 'create_config', {
+ name: `integ_roundtrip_${Date.now()}`,
+ description: 'Created by the mcp_server integration round-trip test.',
+ component_id: COMPONENT_ID,
+ parameters: { api: { baseUrl: 'https://example.com' } },
+ });
+ // The create output carries the new configuration_id.
+ const configurationId = created.match(/configuration_id:\s*"?([^\s"]+)"?/)?.[1];
+ expect(configurationId, `create_config output had no configuration_id:\n${created}`).toBeTruthy();
+
+ const detail = await callToolText(session.client, 'get_configs', {
+ configs: [{ component_id: COMPONENT_ID, configuration_id: configurationId }],
+ });
+
+ // The detail output names the requested component + configuration. (Python validated
+ // the GetConfigsDetailOutput model fields; we assert on the TOON text shape.)
+ expect(detail).toContain(COMPONENT_ID);
+ expect(detail).toContain(configurationId!);
+ // Component metadata (type/name) is resolved and present.
+ expect(detail).toMatch(/component_type|component_name/);
+ } finally {
+ await session.close();
+ }
+ });
+});
diff --git a/integtests/project_lock.py b/integtests/project_lock.py
deleted file mode 100644
index 7d713c188..000000000
--- a/integtests/project_lock.py
+++ /dev/null
@@ -1,523 +0,0 @@
-"""
-Distributed project lock backed by Keboola branch metadata.
-
-Prevents concurrent CI runners from corrupting shared integration-test data via
-a write-and-verify window + oldest-timestamp-wins protocol.
-
-Lock state is represented by two metadata keys per runner:
- Active : KBC.integtest.lock. (JSON payload)
- Released : KBC.integtest.lock..released (ISO timestamp string)
-
-For a pool of projects, use ProjectPool, which tries each ProjectEndpoint in
-order and returns the first one that can be locked.
-
-This file has no dependency on the keboola_mcp_server package — it only uses
-httpx (already a project dependency) for direct Storage API calls.
-"""
-
-import json
-import logging
-import os
-import random
-import socket
-import time
-import uuid
-from dataclasses import dataclass
-from datetime import datetime, timezone
-from typing import Any
-
-import httpx
-
-LOG = logging.getLogger(__name__)
-
-LOCK_KEY_PREFIX = 'KBC.integtest.lock.'
-DEFAULT_TTL_MINUTES = 60
-DEFAULT_POLL_INTERVAL_SECONDS = 30
-DEFAULT_MAX_WAIT_MINUTES = 90
-DEFAULT_ANTI_COLLISION_SECONDS = 3
-
-
-@dataclass(frozen=True)
-class LockInfo:
- lock_id: str
- acquired_at: datetime # UTC, timezone-aware
- runner_info: str
- metadata_key: str # 'KBC.integtest.lock.'
-
-
-@dataclass(frozen=True)
-class ProjectEndpoint:
- """A Keboola project identified by its Storage API URL, token, and metadata."""
-
- storage_api_url: str
- storage_api_token: str
- project_id: str
- project_name: str
- token_id: str = ''
- token_description: str = ''
-
- def describe(self) -> str:
- """Return a human-readable summary of the project and token."""
- return (
- f'Authorized as "{self.token_description}" ({self.token_id}, ...{self.storage_api_token[-4:]}) '
- f'to project "{self.project_name}" ({self.project_id}) '
- f'at "{self.storage_api_url}" stack.'
- )
-
-
-@dataclass(frozen=True)
-class AcquiredProject:
- """Result of acquiring a lock from a pool: which project was selected and its lock."""
-
- endpoint: ProjectEndpoint
- lock_info: LockInfo
-
-
-def verify_project_endpoint(
- storage_api_url: str,
- storage_api_token: str,
-) -> ProjectEndpoint:
- """
- Verify a Storage API token and return a fully populated ProjectEndpoint.
-
- Calls GET /v2/storage/tokens/verify to confirm the token is valid and to
- fetch the project name and ID. Raises httpx.HTTPStatusError if the token
- is invalid or the request fails.
- """
- base_url = storage_api_url.rstrip('/')
- with httpx.Client(headers={'X-StorageApi-Token': storage_api_token}, timeout=30.0) as client:
- resp = client.get(f'{base_url}/v2/storage/tokens/verify')
- resp.raise_for_status()
- token_info = resp.json()
- token_id = str(token_info['id'])
- token_description = str(token_info['description'])
- project_id = str(token_info['owner']['id'])
- project_name = token_info['owner']['name']
- LOG.info(f'[project_lock] Verified token ...{storage_api_token[-4:]} — project "{project_name}" ({project_id})')
- return ProjectEndpoint(
- storage_api_url=storage_api_url,
- storage_api_token=storage_api_token,
- project_id=project_id,
- project_name=project_name,
- token_id=token_id,
- token_description=token_description,
- )
-
-
-class ProjectLock:
- def __init__(
- self,
- storage_api_url: str,
- storage_api_token: str,
- ttl_minutes: int = DEFAULT_TTL_MINUTES,
- poll_interval_seconds: int = DEFAULT_POLL_INTERVAL_SECONDS,
- max_wait_minutes: int = DEFAULT_MAX_WAIT_MINUTES,
- anti_collision_seconds: int = DEFAULT_ANTI_COLLISION_SECONDS,
- ) -> None:
- self._base_url = storage_api_url.rstrip('/')
- self._token = storage_api_token
- self._ttl_minutes = ttl_minutes
- self._poll_interval_seconds = poll_interval_seconds
- self._max_wait_minutes = max_wait_minutes
- self._anti_collision_seconds = anti_collision_seconds
-
- # ------------------------------------------------------------------
- # Public API
- # ------------------------------------------------------------------
-
- def acquire(self) -> LockInfo:
- """Block until this runner owns the project lock; return LockInfo."""
- deadline = datetime.now(timezone.utc).timestamp() + self._max_wait_minutes * 60
-
- while True:
- if datetime.now(timezone.utc).timestamp() > deadline:
- raise TimeoutError(f'Could not acquire project lock within {self._max_wait_minutes} minutes')
-
- result = self._try_acquire_once()
- if result is not None:
- return result
-
- LOG.info(f'[project_lock] Waiting {self._poll_interval_seconds}s before retrying.')
- time.sleep(self._poll_interval_seconds)
-
- def release(self, lock: LockInfo) -> None:
- """Mark the lock as released by writing the .released key."""
- released_key = lock.metadata_key + '.released'
- LOG.info(f'[project_lock] Releasing lock {lock.lock_id}')
- self._write_metadata({released_key: datetime.now(timezone.utc).isoformat()})
-
- # ------------------------------------------------------------------
- # Internal helpers
- # ------------------------------------------------------------------
-
- def _try_acquire_once(self) -> LockInfo | None:
- """
- Single non-looping acquisition attempt.
-
- Returns LockInfo if this runner wins the lock (including after cleaning
- a stale lock). Returns None if another runner holds an active non-stale
- lock — signals the caller to try a different project or wait and retry.
- """
- runner_info = self._runner_info()
- lock_id = str(uuid.uuid4())
- acquired_at = datetime.now(timezone.utc)
- key = LOCK_KEY_PREFIX + lock_id
- payload = json.dumps(
- {
- 'lock_id': lock_id,
- 'acquired_at': acquired_at.isoformat(),
- 'runner_info': runner_info,
- }
- )
- LOG.info(f'[project_lock] Writing candidate lock {lock_id} (runner: {runner_info})')
- self._write_metadata({key: payload})
-
- # Anti-collision window: let concurrent writers finish their writes
- time.sleep(self._anti_collision_seconds)
-
- active = self._read_active_locks()
- winner = min(active, key=lambda li: (li.acquired_at, li.lock_id)) if active else None
-
- # Case 1: We are the winner
- if winner is not None and winner.lock_id == lock_id:
- LOG.info(f'[project_lock] Acquired lock {lock_id}')
- self._cleanup_old_locks(lock_id)
- self.clean_project()
- return LockInfo(
- lock_id=lock_id,
- acquired_at=acquired_at,
- runner_info=runner_info,
- metadata_key=key,
- )
-
- # Case 2: The winner is stale — clean up and retry immediately once
- if winner is not None and self._is_stale(winner):
- LOG.warning(
- f'[project_lock] Stale lock detected: {winner.lock_id} '
- f'(acquired_at={winner.acquired_at.isoformat()}). '
- 'Releasing stale entries and cleaning project.'
- )
- for stale in active:
- if self._is_stale(stale):
- self._release_lock_entry(stale.lock_id)
- # Release our pending entry and write a fresh candidate
- self._release_lock_entry(lock_id)
- time.sleep(2)
-
- lock_id2 = str(uuid.uuid4())
- acquired_at2 = datetime.now(timezone.utc)
- key2 = LOCK_KEY_PREFIX + lock_id2
- payload2 = json.dumps(
- {
- 'lock_id': lock_id2,
- 'acquired_at': acquired_at2.isoformat(),
- 'runner_info': runner_info,
- }
- )
- LOG.info(f'[project_lock] Post-stale-clean: writing candidate lock {lock_id2}')
- self._write_metadata({key2: payload2})
- time.sleep(self._anti_collision_seconds)
-
- active2 = self._read_active_locks()
- winner2 = min(active2, key=lambda li: (li.acquired_at, li.lock_id)) if active2 else None
- if winner2 is not None and winner2.lock_id == lock_id2:
- LOG.info(f'[project_lock] Acquired lock {lock_id2} after stale cleanup')
- self._cleanup_old_locks(lock_id2)
- return LockInfo(
- lock_id=lock_id2,
- acquired_at=acquired_at2,
- runner_info=runner_info,
- metadata_key=key2,
- )
- # Another runner raced us after the cleanup — withdraw and signal the caller
- self._release_lock_entry(lock_id2)
- return None
-
- # Case 3: Another runner holds an active (non-stale) lock
- if winner is not None:
- expires_approx = winner.acquired_at.timestamp() + self._ttl_minutes * 60
- expires_str = datetime.fromtimestamp(expires_approx, tz=timezone.utc).isoformat()
- LOG.info(
- f'[project_lock] Lock held by {winner.runner_info} '
- f'(id={winner.lock_id}), expires ~{expires_str}. '
- 'Releasing our candidate.'
- )
- else:
- LOG.info('[project_lock] No winner determined yet. Releasing candidate.')
- self._release_lock_entry(lock_id)
- return None
-
- def _write_metadata(self, kv: dict[str, str]) -> None:
- payload = {'metadata': [{'key': k, 'value': v} for k, v in kv.items()]}
- self._post('/v2/storage/branch/default/metadata', data=payload)
-
- def _read_metadata(self) -> list[dict[str, Any]]:
- return self._get('/v2/storage/branch/default/metadata')
-
- def _read_active_locks(self) -> list[LockInfo]:
- """Return all active (not-released) lock entries, sorted by acquired_at ASC."""
- entries = self._read_metadata()
-
- # Build sets of known lock UUIDs and released UUIDs
- lock_entries: dict[str, dict[str, Any]] = {} # lock_id -> raw metadata entry
- released_ids: set[str] = set()
-
- for entry in entries:
- key: str = entry.get('key', '')
- if not key.startswith(LOCK_KEY_PREFIX):
- continue
- suffix = key[len(LOCK_KEY_PREFIX) :]
- if suffix.endswith('.released'):
- released_ids.add(suffix[: -len('.released')])
- else:
- lock_entries[suffix] = entry
-
- active: list[LockInfo] = []
- for lock_id, entry in lock_entries.items():
- if lock_id in released_ids:
- continue
- try:
- data = json.loads(entry['value'])
- acquired_at = datetime.fromisoformat(data['acquired_at'])
- if acquired_at.tzinfo is None:
- acquired_at = acquired_at.replace(tzinfo=timezone.utc)
- active.append(
- LockInfo(
- lock_id=lock_id,
- acquired_at=acquired_at,
- runner_info=data.get('runner_info', ''),
- metadata_key=LOCK_KEY_PREFIX + lock_id,
- )
- )
- except (KeyError, ValueError, json.JSONDecodeError) as exc:
- LOG.warning(f'[project_lock] Skipping malformed lock entry {lock_id!r}: {exc}')
-
- return sorted(active, key=lambda li: (li.acquired_at, li.lock_id))
-
- def _is_stale(self, lock: LockInfo) -> bool:
- expiry = lock.acquired_at.timestamp() + self._ttl_minutes * 60
- return expiry <= datetime.now(timezone.utc).timestamp()
-
- def _release_lock_entry(self, lock_id: str) -> None:
- released_key = LOCK_KEY_PREFIX + lock_id + '.released'
- self._write_metadata({released_key: datetime.now(timezone.utc).isoformat()})
-
- def _delete_metadata_by_id(self, metadata_id: str) -> None:
- """Delete a single branch metadata entry by its numeric Storage API id."""
- self._delete(f'/v2/storage/branch/default/metadata/{metadata_id}')
-
- def _cleanup_old_locks(self, current_lock_id: str) -> None:
- """
- Delete all released lock metadata entries to prevent metadata accumulation.
-
- Deletion order per pair: main entry first, then .released entry.
- This prevents transient reappearance of a released lock as active.
- Errors are swallowed so cleanup never breaks lock acquisition.
- """
- try:
- entries = self._read_metadata()
- except Exception as exc:
- LOG.warning(f'[project_lock] _cleanup_old_locks: failed to read metadata: {exc}')
- return
-
- main_entries: dict[str, dict[str, Any]] = {} # lock_id -> raw entry
- released_entries: dict[str, dict[str, Any]] = {} # lock_id -> raw entry
-
- for entry in entries:
- key: str = entry.get('key', '')
- if not key.startswith(LOCK_KEY_PREFIX):
- continue
- suffix = key[len(LOCK_KEY_PREFIX) :]
- if suffix.endswith('.released'):
- lock_id = suffix[: -len('.released')]
- released_entries[lock_id] = entry
- else:
- main_entries[suffix] = entry
-
- for lock_id, released_entry in released_entries.items():
- if lock_id == current_lock_id:
- continue # never touch the active lock
- main_entry = main_entries.get(lock_id)
- if main_entry is not None:
- # Delete main entry FIRST (safety: prevents transient reactivation)
- try:
- LOG.info(f'[project_lock] Cleaning up released lock {lock_id} (main)')
- self._delete_metadata_by_id(str(main_entry['id']))
- except Exception as exc:
- LOG.warning(
- f'[project_lock] Failed to delete main lock entry {lock_id}: {exc}. '
- 'Skipping .released deletion to avoid reactivating the lock.'
- )
- continue
- # Delete .released entry SECOND — only if main was successfully deleted
- try:
- LOG.info(f'[project_lock] Cleaning up released lock {lock_id} (.released)')
- self._delete_metadata_by_id(str(released_entry['id']))
- except Exception as exc:
- LOG.warning(f'[project_lock] Failed to delete .released entry {lock_id}: {exc}')
- else:
- # Orphaned .released entry — main was already deleted or never written
- try:
- LOG.info(f'[project_lock] Cleaning up orphaned .released entry {lock_id}')
- self._delete_metadata_by_id(str(released_entry['id']))
- except Exception as exc:
- LOG.warning(f'[project_lock] Failed to delete orphaned .released entry {lock_id}: {exc}')
-
- def clean_project(self) -> None:
- """Delete all buckets, component configurations, and workspaces from the project.
-
- Integration tests no longer rely on a persistent workspace — each session creates its
- own read-only workspace and deletes it on teardown — so cleaning wipes everything,
- including any workspace leaked by a crashed run.
- """
- LOG.info('[project_lock] Cleaning project (deleting all buckets, configs and workspaces)')
-
- # Delete all buckets (force=True also removes tables inside them)
- buckets = self._get('/v2/storage/buckets')
- for bucket in buckets:
- bucket_id = bucket['id']
- LOG.info(f'[project_lock] Deleting bucket {bucket_id}')
- self._delete(f'/v2/storage/buckets/{bucket_id}', force='true')
-
- # Delete all component configurations
- components = self._get('/v2/storage/components', include='configuration')
- for component in components:
- comp_id = component['id']
- for cfg in component.get('configurations', []):
- cfg_id = cfg['id']
- LOG.info(f'[project_lock] Deleting config {comp_id}/{cfg_id}')
- # First delete moves to trash; second delete removes from trash
- self._delete(f'/v2/storage/components/{comp_id}/configs/{cfg_id}')
- self._delete(f'/v2/storage/components/{comp_id}/configs/{cfg_id}')
-
- # Delete all workspaces (none are persistent anymore)
- workspaces = self._get('/v2/storage/branch/default/workspaces')
- for workspace in workspaces:
- workspace_id = workspace['id']
- LOG.info(f'[project_lock] Deleting workspace {workspace_id}')
- try:
- self._delete(f'/v2/storage/workspaces/{workspace_id}')
- except httpx.HTTPStatusError as exc:
- # A workspace backed by a sandbox config may already be gone after config deletion.
- LOG.warning(f'[project_lock] Failed to delete workspace {workspace_id}: {exc}')
-
- @staticmethod
- def _runner_info() -> str:
- hostname = socket.gethostname()
- pid = os.getpid()
- base = f'{hostname}/{pid}'
- run_id = os.getenv('GITHUB_RUN_ID')
- if run_id:
- return f'CI={run_id} {base}'
- return base
-
- # ------------------------------------------------------------------
- # HTTP helpers
- # ------------------------------------------------------------------
-
- def _client(self) -> httpx.Client:
- return httpx.Client(
- headers={'X-StorageApi-Token': self._token},
- timeout=30.0,
- )
-
- def _get(self, path: str, **params: Any) -> Any:
- with self._client() as client:
- resp = client.get(self._base_url + path, params=params or None)
- resp.raise_for_status()
- return resp.json()
-
- def _post(self, path: str, data: dict[str, Any]) -> Any:
- with self._client() as client:
- resp = client.post(self._base_url + path, json=data)
- resp.raise_for_status()
- return resp.json()
-
- def _delete(self, path: str, **params: Any) -> None:
- with self._client() as client:
- resp = client.delete(self._base_url + path, params=params or None)
- resp.raise_for_status()
-
-
-class ProjectPool:
- """
- Manages a pool of Keboola projects for integration tests.
-
- Tries each endpoint in order on every acquisition pass; returns the first
- project whose lock can be acquired. If all projects are held by active
- runners, sleeps poll_interval_seconds and retries the whole pool.
- Raises TimeoutError after max_wait_minutes.
-
- Stale locks on any project are detected and cleaned automatically before
- claiming that project (handled inside ProjectLock._try_acquire_once).
- """
-
- def __init__(
- self,
- endpoints: list[ProjectEndpoint],
- ttl_minutes: int = DEFAULT_TTL_MINUTES,
- poll_interval_seconds: int = DEFAULT_POLL_INTERVAL_SECONDS,
- max_wait_minutes: int = DEFAULT_MAX_WAIT_MINUTES,
- anti_collision_seconds: int = DEFAULT_ANTI_COLLISION_SECONDS,
- ) -> None:
- if not endpoints:
- raise ValueError('ProjectPool requires at least one endpoint')
- self._endpoints = endpoints
- self._ttl_minutes = ttl_minutes
- self._poll_interval_seconds = poll_interval_seconds
- self._max_wait_minutes = max_wait_minutes
- self._anti_collision_seconds = anti_collision_seconds
-
- def acquire(self) -> AcquiredProject:
- """
- Try each endpoint in order; return the first one successfully locked.
- Retries the whole pool until max_wait_minutes is exceeded.
- """
- deadline = datetime.now(timezone.utc).timestamp() + self._max_wait_minutes * 60
-
- while True:
- if datetime.now(timezone.utc).timestamp() > deadline:
- raise TimeoutError(
- f'Could not acquire any project lock within {self._max_wait_minutes} minutes '
- f'(pool size: {len(self._endpoints)})'
- )
-
- start = random.randrange(len(self._endpoints))
- rotated = self._endpoints[start:] + self._endpoints[:start]
- for endpoint in rotated:
- LOG.info(
- f'[project_pool] Trying to acquire lock for '
- f'"{endpoint.project_name}" ({endpoint.project_id}) (...{endpoint.storage_api_token[-4:]})'
- )
- lock_info = self._make_lock(endpoint)._try_acquire_once()
- if lock_info is not None:
- LOG.info(
- f'[project_pool] Acquired project '
- f'"{endpoint.project_name}" ({endpoint.project_id}) (...{endpoint.storage_api_token[-4:]})'
- )
- return AcquiredProject(endpoint=endpoint, lock_info=lock_info)
-
- LOG.info(
- f'[project_pool] All {len(self._endpoints)} projects busy. '
- f'Sleeping {self._poll_interval_seconds}s before retry.'
- )
- time.sleep(self._poll_interval_seconds)
-
- def release(self, acquired: AcquiredProject) -> None:
- """Release the lock held on acquired.endpoint."""
- LOG.info(
- f'[project_pool] Releasing lock on '
- f'"{acquired.endpoint.project_name}" ({acquired.endpoint.project_id})'
- f' (...{acquired.endpoint.storage_api_token[-4:]})'
- )
- self._make_lock(acquired.endpoint).release(acquired.lock_info)
-
- def _make_lock(self, endpoint: ProjectEndpoint) -> ProjectLock:
- return ProjectLock(
- storage_api_url=endpoint.storage_api_url,
- storage_api_token=endpoint.storage_api_token,
- ttl_minutes=self._ttl_minutes,
- poll_interval_seconds=self._poll_interval_seconds,
- max_wait_minutes=self._max_wait_minutes,
- anti_collision_seconds=self._anti_collision_seconds,
- )
diff --git a/integtests/test_errors.py b/integtests/test_errors.py
deleted file mode 100644
index f797fe499..000000000
--- a/integtests/test_errors.py
+++ /dev/null
@@ -1,179 +0,0 @@
-import asyncio
-import json
-import math
-import re
-import uuid
-from importlib.metadata import distribution
-from typing import Any, Mapping
-from urllib.parse import urlparse
-
-import httpx
-import pytest
-from fastmcp import Context
-from mcp.types import ClientCapabilities, Implementation, InitializeRequestParams
-
-from keboola_mcp_server.clients.client import KeboolaClient
-from keboola_mcp_server.errors import tool_errors
-from keboola_mcp_server.mcp import CONVERSATION_ID, AggregateError
-from keboola_mcp_server.tools.doc import docs_query
-from keboola_mcp_server.tools.jobs import get_jobs
-from keboola_mcp_server.tools.sql import query_data
-from keboola_mcp_server.tools.storage.tools import GetBucketsOutput, get_buckets
-
-
-class TestHttpErrors:
- """Test different HTTP error scenarios to ensure enhanced error handling works correctly."""
-
- @pytest.mark.asyncio
- async def test_storage_api_404_error_maintains_standard_behavior(self, mcp_context: Context):
- result = await get_buckets(mcp_context, ['non.existent.bucket'])
- assert 'non.existent.bucket' in result.buckets_not_found
-
- @pytest.mark.asyncio
- async def test_jobs_api_404_error_(self, mcp_context: Context, storage_api_url: str):
- hostname_suffix = urlparse(storage_api_url).hostname.split('connection.')[1]
- queue_url = f'https://queue.{hostname_suffix}'
- match = re.compile(
- r"Client error '404 [^']+' "
- rf"for url '{re.escape(queue_url)}/jobs/999999999'\n"
- r'For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404\n'
- r'API error: Job "999999999" not found\n'
- r'Exception ID: .+\n'
- r'When contacting Keboola support please provide the exception ID\.',
- re.IGNORECASE,
- )
- with pytest.raises(AggregateError) as exc_info:
- await get_jobs(ctx=mcp_context, job_ids=('999999999',))
-
- # Verify AggregateError contains the HTTPStatusError
- err = exc_info.value
- assert len(err.exceptions) == 1
- assert isinstance(err.exceptions[0], httpx.HTTPStatusError)
- assert match.search(str(err.exceptions[0])) is not None
-
- @pytest.mark.asyncio
- async def test_docs_api_empty_query_error(self, mcp_context: Context, storage_api_url: str):
- """Test that docs_query raises 422 error for empty queries."""
- hostname_suffix = urlparse(storage_api_url).hostname.split('connection.')[1]
- ai_url = f'https://ai.{hostname_suffix}'
- match = re.compile(
- r"Client error '422 [^']+' "
- rf"for url '{re.escape(ai_url)}/docs/question'\n"
- r'For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422\n'
- r'API error: Request contents is not valid\n'
- r'Exception ID: .+\n'
- r'When contacting Keboola support please provide the exception ID\.',
- re.IGNORECASE,
- )
- with pytest.raises(httpx.HTTPStatusError, match=match):
- await docs_query(ctx=mcp_context, query='')
-
- @pytest.mark.asyncio
- async def test_sql_api_invalid_query_error_snowflake(self, mcp_context: Context, require_snowflake: None):
- match = re.compile(
- r'Failed to run SQL query, error: SQL compilation error:\n'
- r"syntax error line 1 at position 0 unexpected 'INVALID'\.",
- re.IGNORECASE,
- )
- with pytest.raises(ValueError, match=match):
- await query_data('INVALID SQL SYNTAX HERE', 'Invalid SQL query.', mcp_context)
-
- @pytest.mark.asyncio
- async def test_sql_api_invalid_query_error_bigquery(self, mcp_context: Context, require_bigquery: None):
- match = re.compile(
- r'Failed to run SQL query, error: Syntax error: Unexpected identifier "INVALID" at \[1:1]',
- re.IGNORECASE,
- )
- with pytest.raises(ValueError, match=match):
- await query_data('INVALID SQL SYNTAX HERE', 'Invalid SQL query.', mcp_context)
-
- @pytest.mark.asyncio
- async def test_concurrent_error_handling(self, mcp_context: Context):
- # Run multiple concurrent operations that will trigger 404 errors
- tasks = [get_buckets(mcp_context, [f'non.existent.bucket.{i}']) for i in range(5)]
- results = await asyncio.gather(*tasks, return_exceptions=True)
-
- # Verify all errors are handled consistently
- pattern = re.compile(r'non\.existent\.bucket\.\d+', re.IGNORECASE)
-
- for result in results:
- assert isinstance(result, GetBucketsOutput)
- assert result.buckets_not_found
- assert len(result.buckets_not_found) == 1
- assert pattern.fullmatch(result.buckets_not_found[0])
-
-
-class TestStorageEvents:
- @staticmethod
- @tool_errors()
- async def foo(unique: str, ctx: Context):
- """A fake MCP tool to test events emitting."""
- await asyncio.sleep(0.1)
-
- @staticmethod
- @tool_errors()
- async def bar(unique: str, ctx: Context):
- """A fake MCP tool that fails by raising an error to test events emitting."""
- await asyncio.sleep(0.1)
- raise ValueError('Intentional error in bar tool.')
-
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- ('tool_name', 'event_message', 'event_type'),
- [
- ('foo', 'MCP tool "foo" call succeeded.', 'success'),
- ('bar', 'MCP tool "bar" call failed. ValueError: Intentional error in bar tool.', 'error'),
- ],
- )
- async def test_event_emitted(self, tool_name: str, event_message: str, event_type: str, mcp_context: Context):
- mcp_context.session_id = 'deadbee'
- mcp_context.session.client_params = InitializeRequestParams(
- protocolVersion='1',
- capabilities=ClientCapabilities(),
- clientInfo=Implementation(name='kbc-mcp-integtests-test-events-emitted', version='1.2.3'),
- )
- mcp_context.session.state[CONVERSATION_ID] = '#987654321'
- unique = uuid.uuid4().hex
- tool_func = getattr(self, tool_name)
- try:
- await tool_func(unique, mcp_context)
- except ValueError:
- pass # ignore
- await asyncio.sleep(1) # give SAPI time to digest the event
-
- client = KeboolaClient.from_state(mcp_context.session.state)
- events = await client.storage_client.get(
- endpoint='events',
- params={
- 'component': 'keboola.mcp-server-tool',
- 'q': f'message:"MCP tool "{tool_name}" call*"',
- 'limit': 10,
- },
- )
- emitted_event = self._find_event(events, tool_name=tool_name, param_name='unique', param_value=unique)
- assert emitted_event is not None
- assert emitted_event['message'] == event_message
- assert emitted_event['type'] == event_type
- # SAPI events don't support float durations, so the duration is rounded up to the nearest second.
- assert math.fabs(emitted_event['performance']['duration'] - 0.1) < 1
- assert emitted_event['params']['mcpServerContext'] == {
- 'appEnv': 'DEV',
- 'version': distribution('keboola_mcp_server').version,
- 'userAgent': 'kbc-mcp-integtests-test-events-emitted/1.2.3',
- 'sessionId': 'deadbee',
- 'serverTransport': 'stdio',
- 'conversationId': '#987654321',
- }
-
- @staticmethod
- def _find_event(
- events: list[Mapping[str, Any]], *, tool_name: str, param_name: str, param_value: str
- ) -> Mapping[str, Any] | None:
- for event in events:
- event_tool = event['params']['tool']
- if event_tool['name'] != tool_name:
- continue
- for argument in event_tool['arguments']:
- if argument['key'] == param_name and json.loads(argument['value']) == param_value:
- return event
- return None
diff --git a/integtests/test_mcp_server.py b/integtests/test_mcp_server.py
deleted file mode 100644
index 9ea7ff3c8..000000000
--- a/integtests/test_mcp_server.py
+++ /dev/null
@@ -1,330 +0,0 @@
-import logging
-import os
-import random
-import subprocess
-import time
-from contextlib import asynccontextmanager
-from typing import Any, AsyncGenerator, Iterable, Literal, cast
-
-import pytest
-from fastmcp import Client
-from fastmcp.client import StdioTransport, StreamableHttpTransport
-from mcp.types import TextContent
-
-from integtests.conftest import (
- DEV_STORAGE_API_URL_ENV_VAR,
- DEV_STORAGE_TOKEN_ENV_VAR,
- DEV_WORKSPACE_SCHEMA_ENV_VAR,
- ConfigDef,
-)
-from keboola_mcp_server.tools.components.model import GetConfigsDetailOutput
-from keboola_mcp_server.tools.project import ProjectInfo
-
-LOG = logging.getLogger(__name__)
-HttpTransportStr = Literal['streamable-http', 'http-compat']
-
-
-@pytest.mark.asyncio
-async def test_stdio_setup(
- configs: list[ConfigDef],
- storage_api_token: str,
- workspace_schema: str,
- storage_api_url: str,
-):
- assert storage_api_token is not None
- assert workspace_schema is not None
- assert storage_api_url is not None
-
- transport = StdioTransport(
- command='python',
- args=[
- '-m',
- 'keboola_mcp_server',
- '--api-url',
- storage_api_url,
- '--storage-token',
- storage_api_token,
- '--workspace-schema',
- workspace_schema,
- ],
- env={}, # make sure no env vars are passed from the test environment
- )
- component_config = configs[0]
- async with Client(transport) as client:
- await _assert_basic_setup(client)
- await _assert_get_component_details_tool_call(client, component_config)
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize('transport', ['streamable-http', 'http-compat'])
-async def test_remote_setup(
- transport: HttpTransportStr,
- configs: list[ConfigDef],
- storage_api_token: str,
- workspace_schema: str,
- storage_api_url: str,
-):
- assert storage_api_token is not None
- assert workspace_schema is not None
- assert storage_api_url is not None
-
- component_config = configs[0]
- for url in _run_server_remote(storage_api_url, transport):
- # test both cases: with headers and without headers using query params
- headers = {'storage_token': storage_api_token, 'workspace_schema': workspace_schema}
- async with _run_client(url, headers) as client:
- await _assert_basic_setup(client)
- await _assert_get_component_details_tool_call(client, component_config)
-
-
-@pytest.mark.asyncio
-async def test_http_multiple_clients(
- configs: list[ConfigDef],
- storage_api_token: str,
- workspace_schema: str,
- storage_api_url: str,
-):
- transport: HttpTransportStr = 'streamable-http'
- component_config = configs[0]
- for url in _run_server_remote(storage_api_url, transport):
- headers = {
- 'storage_token': storage_api_token,
- 'workspace_schema': workspace_schema,
- 'storage_api_url': storage_api_url,
- }
- async with (
- _run_client(url, headers) as client_1,
- _run_client(url, headers) as client_2,
- _run_client(url, headers) as client_3,
- ):
- await _assert_basic_setup(client_1)
- await _assert_basic_setup(client_2)
- await _assert_basic_setup(client_3)
- await _assert_get_component_details_tool_call(client_1, component_config)
- await _assert_get_component_details_tool_call(client_2, component_config)
- await _assert_get_component_details_tool_call(client_3, component_config)
-
-
-@pytest.mark.asyncio
-async def test_http_multiple_clients_with_different_headers(
- storage_api_url: str,
- storage_api_token: str,
- workspace_schema: str,
- storage_api_token_2: str | None,
- workspace_schema_2: str | None,
-):
- """
- Test that the server can handle multiple clients with different headers and checks the values of the headers.
-
- This test accesses the second project (PRJ2) without a lock. That is safe only because
- it is strictly read-only against PRJ2 (list_tools, list_resources, get_project_info).
- Do not add any writes to PRJ2 here without acquiring a lock for it first.
- """
- if not storage_api_token_2 or not workspace_schema_2:
- pytest.skip('No SAPI token or workspace schema for the second client. Skipping test.')
-
- headers = {
- 'client_1': {'storage_token': storage_api_token, 'workspace_schema': workspace_schema},
- 'client_2': {'storage_token': storage_api_token_2, 'workspace_schema': workspace_schema_2},
- }
-
- transport: HttpTransportStr = 'streamable-http'
- for url in _run_server_remote(storage_api_url, transport):
- async with (
- _run_client(url, headers['client_1']) as client_1,
- _run_client(url, headers['client_2']) as client_2,
- ):
- await _assert_basic_setup(client_1)
- await _assert_basic_setup(client_2)
-
- response_1 = await client_1.call_tool('get_project_info')
- project_info_1 = ProjectInfo.model_validate(response_1.structured_content)
- project_info_1.project_id = storage_api_token.split(sep='-')[0]
- LOG.info(f'project_info_1={project_info_1}')
-
- response_2 = await client_2.call_tool('get_project_info')
- project_info_2 = ProjectInfo.model_validate(response_2.structured_content)
- project_info_2.project_id = storage_api_token_2.split(sep='-')[0]
- LOG.info(f'project_info_2={project_info_2}')
-
-
-async def _assert_basic_setup(client: Client):
- tools = await client.list_tools()
- # the create_conditional_flow, create_flow, search, and semantic tools may not be present
- # based on the testing project features
- exclude = {
- 'create_conditional_flow',
- 'create_flow',
- 'search',
- 'update_flow',
- 'modify_flow',
- 'get_semantic_context',
- 'get_semantic_schema',
- 'search_semantic_context',
- 'validate_semantic_query',
- }
- expected_tools = {
- 'add_config_row',
- 'create_conditional_flow',
- 'create_config',
- 'create_flow',
- 'create_oauth_url',
- 'create_python_js_data_app_git_credential',
- 'create_sql_transformation',
- 'delete_python_js_data_app_draft',
- 'deploy_data_app',
- 'docs_query',
- 'find_component_id',
- 'get_buckets',
- 'get_components',
- 'get_config_examples',
- 'get_configs',
- 'get_data_apps',
- 'get_flow_examples',
- 'get_flow_schema',
- 'get_flows',
- 'get_jobs',
- 'get_project_info',
- 'get_tables',
- 'modify_flow',
- 'modify_python_js_data_app',
- 'modify_streamlit_data_app',
- 'query_data',
- 'run_job',
- 'run_sync_action',
- 'search',
- 'update_config',
- 'update_config_row',
- 'update_descriptions',
- 'update_flow',
- 'update_project_description',
- 'update_sql_transformation',
- }
- expected_tools = expected_tools - exclude
-
- actual_tools = {tool.name for tool in tools}
- actual_tools = actual_tools - exclude
-
- missing_tools = expected_tools - actual_tools
- assert not missing_tools, f'Missing tools: {missing_tools}'
-
- unexpected_tools = actual_tools - expected_tools
- assert not unexpected_tools, f'Unexpected new tools: {unexpected_tools}'
-
- prompts = await client.list_prompts()
- assert len(prompts) == 6
-
- # there are no resources exposed in the MCP server; just check that the call succeeds
- resources = await client.list_resources()
- assert len(resources) == 0
-
-
-async def _assert_get_component_details_tool_call(client: Client, config: ConfigDef):
- assert config.configuration_id is not None
-
- tool_result = await client.call_tool(
- 'get_configs',
- {'configs': [{'configuration_id': config.configuration_id, 'component_id': config.component_id}]},
- )
-
- assert tool_result is not None
- assert len(tool_result.content) == 1
- tool_result_content = tool_result.content[0]
- assert isinstance(tool_result_content, TextContent) # only one tool call is executed
-
- component_configs = GetConfigsDetailOutput.model_validate(
- cast(dict[str, Any], tool_result.structured_content)['result']
- )
- assert len(component_configs.configs) == 1
- component_config = component_configs.configs[0]
- assert component_config.component is not None
- assert component_config.component.component_id == config.component_id
- assert component_config.component.component_type is not None
- assert component_config.component.component_name is not None
-
- assert component_config.configuration_root is not None
- assert component_config.configuration_root.configuration_id == config.configuration_id
-
- assert component_config.configuration_rows is None
-
-
-def _run_server_remote(storage_api_url: str, transport: HttpTransportStr) -> Iterable[str]:
- """
- Run the server in a subprocess.
- :param storage_api_url: The Storage API URL to use.
- :param transport: The transport to use.
- :return: The url of the remote server.
- """
-
- port = random.randint(8000, 9000)
- p = subprocess.Popen(
- [
- 'python',
- '-m',
- 'keboola_mcp_server',
- '--transport',
- transport,
- '--api-url',
- storage_api_url,
- '--port',
- str(port),
- ],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- env={
- name: val
- for name, val in os.environ.items()
- if name not in [DEV_STORAGE_API_URL_ENV_VAR, DEV_STORAGE_TOKEN_ENV_VAR, DEV_WORKSPACE_SCHEMA_ENV_VAR]
- },
- )
- try:
- urls: list[str] = []
- if transport in ['streamable-http', 'http-compat']:
- urls.append(f'http://127.0.0.1:{port}/mcp')
- if not urls:
- raise ValueError(f'Unknown transport: {transport}')
-
- LOG.info(f'Running MCP server in subprocess with {transport} transport, listening on: {urls}')
- time.sleep(5) # wait for the server to start
- yield from urls
- finally:
- LOG.info('Terminating MCP server subprocess.')
- p.terminate()
- stdout, stderr = p.communicate()
- LOG.info(f'-- MCP server stdout --\n{stdout}\n-- end stdout --')
- LOG.info(f'-- MCP server stderr --\n{stderr}\n-- end stderr --')
-
-
-@asynccontextmanager
-async def _run_client(url: str, headers: dict[str, str] | None = None) -> AsyncGenerator[Client, None]:
- """
- Run the client in an async context manager which will ensure that the client is properly closed after the test.
- The client is created with the given transport and connected to the url of the remote server with which it
- communicates.
- :param url: The url of the remote server to which the client will be connected.
- :param headers: The headers to use for the client.
- :return: The Client connected to the remote server.
- """
- if url.endswith('/mcp'):
- transport = StreamableHttpTransport(url=url, headers=headers)
- else:
- raise ValueError(f'Unknown transport: {url}')
-
- client_explicit = Client(transport)
- exception_from_client = None
-
- LOG.info(f'Running MCP client connecting to {url} and expecting `{transport}` server transport.')
- try:
- async with client_explicit:
- try:
- yield client_explicit
- except Exception as e:
- LOG.error(f'Error in client TaskGroup: {e}')
- exception_from_client = e
- # we need to keep an exception from the client TaskGroup and raise it
- # outside the context manager, otherwise it will inform only about task group error
- finally:
- del client_explicit
- if isinstance(exception_from_client, Exception):
- raise exception_from_client
diff --git a/integtests/test_project_lock.py b/integtests/test_project_lock.py
deleted file mode 100644
index 2d6f77336..000000000
--- a/integtests/test_project_lock.py
+++ /dev/null
@@ -1,1138 +0,0 @@
-"""
-Unit tests for integtests/project_lock.py.
-
-All HTTP calls are mocked via pytest-mock — no real Storage API or Keboola project is
-ever touched. These tests do not require any INTEGTEST_* environment variables.
-"""
-
-import json
-import os
-import socket
-from datetime import datetime, timedelta, timezone
-from typing import Any
-from unittest.mock import MagicMock, call
-
-import httpx
-import pytest
-
-from integtests.project_lock import (
- LOCK_KEY_PREFIX,
- AcquiredProject,
- LockInfo,
- ProjectEndpoint,
- ProjectLock,
- ProjectPool,
- verify_project_endpoint,
-)
-
-# ---------------------------------------------------------------------------
-# Helpers
-# ---------------------------------------------------------------------------
-
-
-def _make_lock(
- *,
- lock_id: str = 'test-uuid',
- minutes_ago: float = 0,
- runner_info: str = 'host/1',
- meta_id: int | None = None,
-) -> dict:
- """Build a raw branch-metadata dict as returned by the Storage API."""
- acquired_at = datetime.now(timezone.utc) - timedelta(minutes=minutes_ago)
- payload = json.dumps(
- {
- 'lock_id': lock_id,
- 'acquired_at': acquired_at.isoformat(),
- 'runner_info': runner_info,
- }
- )
- result = {'key': LOCK_KEY_PREFIX + lock_id, 'value': payload}
- if meta_id is not None:
- result['id'] = meta_id
- return result
-
-
-def _released_entry(lock_id: str, meta_id: int | None = None) -> dict:
- result = {
- 'key': LOCK_KEY_PREFIX + lock_id + '.released',
- 'value': datetime.now(timezone.utc).isoformat(),
- }
- if meta_id is not None:
- result['id'] = meta_id
- return result
-
-
-def _make_project_lock(**kwargs) -> ProjectLock:
- defaults = dict(
- storage_api_url='https://connection.keboola.com',
- storage_api_token='test-token',
- ttl_minutes=60,
- poll_interval_seconds=1,
- max_wait_minutes=2,
- anti_collision_seconds=0,
- )
- defaults.update(kwargs)
- return ProjectLock(**defaults)
-
-
-# ---------------------------------------------------------------------------
-# test_acquire_happy_path
-# ---------------------------------------------------------------------------
-
-
-def test_acquire_happy_path(mocker):
- """Single runner: writes lock, reads it back as winner, returns LockInfo."""
- lock = _make_project_lock()
- my_lock_id = 'aaaaaaaa-0000-0000-0000-000000000001'
-
- post_mock = mocker.patch.object(lock, '_post', return_value=[])
- mocker.patch('uuid.uuid4', return_value=MagicMock(__str__=lambda _: my_lock_id))
- mocker.patch.object(lock, 'clean_project')
-
- # _read_metadata returns only our own entry after the anti-collision sleep
- my_entry = _make_lock(lock_id=my_lock_id, minutes_ago=0)
- mocker.patch.object(lock, '_read_metadata', return_value=[my_entry])
- mocker.patch('time.sleep')
-
- result = lock.acquire()
-
- assert isinstance(result, LockInfo)
- assert result.lock_id == my_lock_id
- assert result.metadata_key == LOCK_KEY_PREFIX + my_lock_id
- # Verify that the lock key was written
- written_keys = [
- entry['key']
- for call_args in post_mock.call_args_list
- for entry in call_args.kwargs.get('data', {}).get('metadata', [])
- ]
- assert LOCK_KEY_PREFIX + my_lock_id in written_keys
-
-
-# ---------------------------------------------------------------------------
-# test_acquire_anti_collision_waits
-# ---------------------------------------------------------------------------
-
-
-def test_acquire_anti_collision_waits(mocker):
- """time.sleep is called with anti_collision_seconds before reading back."""
- anti_collision = 3
- lock = _make_project_lock(anti_collision_seconds=anti_collision)
- my_lock_id = 'aaaaaaaa-0000-0000-0000-000000000002'
-
- mocker.patch.object(lock, '_post', return_value=[])
- mocker.patch('uuid.uuid4', return_value=MagicMock(__str__=lambda _: my_lock_id))
- mocker.patch.object(lock, 'clean_project')
-
- my_entry = _make_lock(lock_id=my_lock_id, minutes_ago=0)
- mocker.patch.object(lock, '_read_metadata', return_value=[my_entry])
- sleep_mock = mocker.patch('time.sleep')
-
- lock.acquire()
-
- # At minimum one sleep with the anti-collision value must have occurred
- assert any(c == call(anti_collision) for c in sleep_mock.call_args_list)
-
-
-# ---------------------------------------------------------------------------
-# test_acquire_win_oldest_timestamp
-# ---------------------------------------------------------------------------
-
-
-def test_acquire_win_oldest_timestamp(mocker):
- """Two active entries; ours is the oldest → we win."""
- lock = _make_project_lock()
- my_lock_id = 'aaaaaaaa-0000-0000-0000-aaaaaaaaaaaa'
- other_lock_id = 'aaaaaaaa-0000-0000-0000-bbbbbbbbbbbb'
-
- mocker.patch.object(lock, '_post', return_value=[])
- mocker.patch('uuid.uuid4', return_value=MagicMock(__str__=lambda _: my_lock_id))
- mocker.patch.object(lock, 'clean_project')
-
- # Our entry is 5 minutes older than the other
- my_entry = _make_lock(lock_id=my_lock_id, minutes_ago=5)
- other_entry = _make_lock(lock_id=other_lock_id, minutes_ago=0)
- mocker.patch.object(lock, '_read_metadata', return_value=[my_entry, other_entry])
- mocker.patch('time.sleep')
-
- result = lock.acquire()
- assert result.lock_id == my_lock_id
-
-
-# ---------------------------------------------------------------------------
-# test_acquire_lose_to_older
-# ---------------------------------------------------------------------------
-
-
-def test_acquire_lose_to_older(mocker):
- """
- Two active entries; theirs is older → we release our candidate, sleep, then
- on the second iteration ours is oldest and we acquire.
- """
- lock = _make_project_lock(poll_interval_seconds=1, max_wait_minutes=5)
-
- my_lock_id_1 = 'my-lock-id-0001'
- my_lock_id_2 = 'my-lock-id-0002'
- other_lock_id = 'other-lock-id-00'
-
- uuid_iter = iter([my_lock_id_1, my_lock_id_2])
- mocker.patch('uuid.uuid4', side_effect=lambda: MagicMock(__str__=lambda _: next(uuid_iter)))
-
- post_mock = mocker.patch.object(lock, '_post', return_value=[])
- mocker.patch.object(lock, 'clean_project')
-
- other_entry = _make_lock(lock_id=other_lock_id, minutes_ago=10)
-
- def metadata_side_effect():
- # Check how many times _post was called to determine which iteration we're in
- release_calls = [
- c
- for c in post_mock.call_args_list
- if any(
- k.endswith('.released')
- for entry in c.kwargs.get('data', {}).get('metadata', [])
- for k in [entry['key']]
- )
- ]
- if not release_calls:
- # First read: both entries active, other is older
- my_entry = _make_lock(lock_id=my_lock_id_1, minutes_ago=0)
- return [other_entry, my_entry]
- else:
- # Second iteration: only our new entry (other runner released theirs)
- my_entry2 = _make_lock(lock_id=my_lock_id_2, minutes_ago=0)
- return [my_entry2]
-
- mocker.patch.object(lock, '_read_metadata', side_effect=metadata_side_effect)
- mocker.patch('time.sleep')
-
- result = lock.acquire()
- assert result.lock_id == my_lock_id_2
-
- # We should have released the first candidate
- released_keys = [
- entry['key']
- for c in post_mock.call_args_list
- for entry in c.kwargs.get('data', {}).get('metadata', [])
- if entry['key'].endswith('.released')
- ]
- assert LOCK_KEY_PREFIX + my_lock_id_1 + '.released' in released_keys
-
-
-# ---------------------------------------------------------------------------
-# test_acquire_stale_detected
-# ---------------------------------------------------------------------------
-
-
-def test_acquire_stale_detected(mocker):
- """Stale entry detected → releases it, cleans project, re-acquires."""
- lock = _make_project_lock(ttl_minutes=60)
-
- stale_lock_id = 'stale-lock-id-001'
- my_lock_id = 'my-fresh-lock-001'
-
- uuid_iter = iter([my_lock_id, my_lock_id]) # second acquire returns same id
- mocker.patch('uuid.uuid4', side_effect=lambda: MagicMock(__str__=lambda _: next(uuid_iter)))
-
- post_mock = mocker.patch.object(lock, '_post', return_value=[])
- clean_mock = mocker.patch.object(lock, 'clean_project')
-
- # Build a stale entry (acquired 120 minutes ago, TTL=60)
- stale_entry = _make_lock(lock_id=stale_lock_id, minutes_ago=120)
-
- read_call_count = [0]
-
- def metadata_side_effect():
- read_call_count[0] += 1
- if read_call_count[0] == 1:
- # First read: stale entry + our pending entry
- my_entry = _make_lock(lock_id=my_lock_id, minutes_ago=0)
- return [stale_entry, my_entry]
- else:
- # After cleanup: only our fresh entry
- my_entry = _make_lock(lock_id=my_lock_id, minutes_ago=0)
- return [my_entry]
-
- mocker.patch.object(lock, '_read_metadata', side_effect=metadata_side_effect)
- mocker.patch('time.sleep')
-
- result = lock.acquire()
-
- # _clean_project must have been called
- clean_mock.assert_not_called()
-
- # The stale lock must have been released
- released_keys = [
- entry['key']
- for c in post_mock.call_args_list
- for entry in c.kwargs.get('data', {}).get('metadata', [])
- if entry['key'].endswith('.released')
- ]
- assert LOCK_KEY_PREFIX + stale_lock_id + '.released' in released_keys
-
- assert result.lock_id == my_lock_id
-
-
-# ---------------------------------------------------------------------------
-# test_clean_project_deletes_buckets
-# ---------------------------------------------------------------------------
-
-
-def test_clean_project_deletes_buckets(mocker):
- """_clean_project calls DELETE for each bucket with force=true."""
- lock = _make_project_lock()
-
- mocker.patch.object(
- lock,
- '_get',
- side_effect=lambda path, **params: (
- [{'id': 'in.c-bucket1'}, {'id': 'in.c-bucket2'}] if path.endswith('/buckets') else []
- ),
- )
- delete_mock = mocker.patch.object(lock, '_delete')
-
- lock.clean_project()
-
- delete_calls = [c for c in delete_mock.call_args_list if 'buckets' in c.args[0]]
- deleted_bucket_paths = {c.args[0] for c in delete_calls}
- assert '/v2/storage/buckets/in.c-bucket1' in deleted_bucket_paths
- assert '/v2/storage/buckets/in.c-bucket2' in deleted_bucket_paths
- # Each bucket deleted with force=true
- for c in delete_calls:
- assert c.kwargs.get('force') == 'true'
-
-
-# ---------------------------------------------------------------------------
-# test_clean_project_deletes_configs
-# ---------------------------------------------------------------------------
-
-
-def test_clean_project_deletes_configs(mocker):
- """_clean_project calls DELETE twice for each config (move to trash + purge)."""
- lock = _make_project_lock()
-
- components = [
- {
- 'id': 'ex-generic-v2',
- 'configurations': [{'id': '123'}, {'id': '456'}],
- }
- ]
-
- mocker.patch.object(
- lock,
- '_get',
- side_effect=lambda path, **params: ([] if path.endswith('/buckets') else components),
- )
- delete_mock = mocker.patch.object(lock, '_delete')
-
- lock.clean_project()
-
- config_delete_paths = [c.args[0] for c in delete_mock.call_args_list if 'configs' in c.args[0]]
- # Each config deleted twice
- assert config_delete_paths.count('/v2/storage/components/ex-generic-v2/configs/123') == 2
- assert config_delete_paths.count('/v2/storage/components/ex-generic-v2/configs/456') == 2
-
-
-# ---------------------------------------------------------------------------
-# test_clean_project_deletes_workspaces
-# ---------------------------------------------------------------------------
-
-
-def test_clean_project_deletes_workspaces(mocker):
- """clean_project deletes every workspace — none are persistent anymore."""
- lock = _make_project_lock()
-
- def _get(path: str, **params: Any) -> list[dict]:
- if path.endswith('/workspaces'):
- return [{'id': 9001}, {'id': 9002}]
- return []
-
- mocker.patch.object(lock, '_get', side_effect=_get)
- delete_mock = mocker.patch.object(lock, '_delete')
-
- lock.clean_project()
-
- workspace_delete_paths = [c.args[0] for c in delete_mock.call_args_list if '/workspaces/' in c.args[0]]
- assert '/v2/storage/workspaces/9001' in workspace_delete_paths
- assert '/v2/storage/workspaces/9002' in workspace_delete_paths
-
-
-def test_clean_project_tolerates_workspace_delete_failure(mocker):
- """A workspace already removed via its sandbox config must not abort cleanup."""
- lock = _make_project_lock()
-
- def _get(path: str, **params: Any) -> list[dict]:
- if path.endswith('/workspaces'):
- return [{'id': 9001}]
- return []
-
- mocker.patch.object(lock, '_get', side_effect=_get)
- mocker.patch.object(
- lock,
- '_delete',
- side_effect=httpx.HTTPStatusError('404', request=mocker.MagicMock(), response=mocker.MagicMock()),
- )
-
- # Should not raise.
- lock.clean_project()
-
-
-# ---------------------------------------------------------------------------
-# test_release_writes_released_key
-# ---------------------------------------------------------------------------
-
-
-def test_release_writes_released_key(mocker):
- """release() writes the .released metadata key for the given lock_id."""
- lock = _make_project_lock()
- post_mock = mocker.patch.object(lock, '_post', return_value=[])
-
- lock_info = LockInfo(
- lock_id='release-test-id',
- acquired_at=datetime.now(timezone.utc),
- runner_info='host/99',
- metadata_key=LOCK_KEY_PREFIX + 'release-test-id',
- )
- lock.release(lock_info)
-
- written_keys = [
- entry['key'] for c in post_mock.call_args_list for entry in c.kwargs.get('data', {}).get('metadata', [])
- ]
- assert LOCK_KEY_PREFIX + 'release-test-id.released' in written_keys
-
-
-# ---------------------------------------------------------------------------
-# test_runner_info_includes_hostname_pid
-# ---------------------------------------------------------------------------
-
-
-def test_runner_info_includes_hostname_pid(monkeypatch):
- """runner_info contains hostname and PID."""
- monkeypatch.delenv('GITHUB_RUN_ID', raising=False)
- info = ProjectLock._runner_info()
- assert socket.gethostname() in info
- assert str(os.getpid()) in info
-
-
-# ---------------------------------------------------------------------------
-# test_runner_info_includes_ci_job
-# ---------------------------------------------------------------------------
-
-
-def test_runner_info_includes_ci_job(monkeypatch):
- """runner_info includes GITHUB_RUN_ID when set."""
- monkeypatch.setenv('GITHUB_RUN_ID', '987654321')
- info = ProjectLock._runner_info()
- assert 'CI=987654321' in info
-
-
-# ---------------------------------------------------------------------------
-# test_max_wait_exceeded_raises
-# ---------------------------------------------------------------------------
-
-
-def test_max_wait_exceeded_raises(mocker):
- """Raises TimeoutError after max_wait_minutes is exhausted."""
- lock = _make_project_lock(
- poll_interval_seconds=1,
- max_wait_minutes=0, # expire immediately
- anti_collision_seconds=0,
- )
-
- other_lock_id = 'other-runner-lock'
- other_entry = _make_lock(lock_id=other_lock_id, minutes_ago=0)
- my_lock_id = 'my-candidate-lock'
- my_entry = _make_lock(lock_id=my_lock_id, minutes_ago=0)
-
- mocker.patch('uuid.uuid4', return_value=MagicMock(__str__=lambda _: my_lock_id))
- mocker.patch.object(lock, '_post', return_value=[])
- # The other runner always holds the lock; their entry is older
- mocker.patch.object(lock, '_read_metadata', return_value=[other_entry, my_entry])
- mocker.patch('time.sleep')
-
- # Patch datetime.now to return a time past the deadline on the second call
- original_now = datetime.now
-
- call_count = [0]
-
- def fake_now(tz=None):
- call_count[0] += 1
- if call_count[0] <= 2:
- return original_now(tz)
- # Return a time far in the future to trigger the deadline
- return datetime(2099, 1, 1, tzinfo=timezone.utc)
-
- mocker.patch('integtests.project_lock.datetime', wraps=datetime)
- mocker.patch('integtests.project_lock.datetime.now', side_effect=fake_now)
-
- with pytest.raises(TimeoutError, match='Could not acquire project lock'):
- lock.acquire()
-
-
-# ===========================================================================
-# Helpers for ProjectPool / _try_acquire_once tests
-# ===========================================================================
-
-
-def _make_endpoint(
- url: str = 'https://connection.keboola.com',
- token: str = 'test-token',
- project_id: str = 'proj-001',
- project_name: str = 'Test Project',
-) -> ProjectEndpoint:
- return ProjectEndpoint(
- storage_api_url=url,
- storage_api_token=token,
- project_id=project_id,
- project_name=project_name,
- )
-
-
-def _make_pool(**kwargs) -> ProjectPool:
- defaults = dict(
- endpoints=[_make_endpoint()],
- ttl_minutes=60,
- poll_interval_seconds=1,
- max_wait_minutes=5,
- anti_collision_seconds=0,
- )
- defaults.update(kwargs)
- return ProjectPool(**defaults)
-
-
-def _make_lock_info(lock_id: str = 'test-lock-id') -> LockInfo:
- return LockInfo(
- lock_id=lock_id,
- acquired_at=datetime.now(timezone.utc),
- runner_info='host/1',
- metadata_key=LOCK_KEY_PREFIX + lock_id,
- )
-
-
-# ===========================================================================
-# _try_acquire_once tests
-# ===========================================================================
-
-
-# ---------------------------------------------------------------------------
-# test_try_acquire_once_happy_path
-# ---------------------------------------------------------------------------
-
-
-def test_try_acquire_once_happy_path(mocker):
- """Single candidate, we are oldest → returns LockInfo."""
- lock = _make_project_lock()
- my_lock_id = 'try-once-happy-01'
-
- mocker.patch('uuid.uuid4', return_value=MagicMock(__str__=lambda _: my_lock_id))
- mocker.patch.object(lock, '_post', return_value=[])
- my_entry = _make_lock(lock_id=my_lock_id, minutes_ago=0)
- mocker.patch.object(lock, '_read_metadata', return_value=[my_entry])
- sleep_mock = mocker.patch('time.sleep')
- cleanup_mock = mocker.patch.object(lock, '_cleanup_old_locks')
- clean_mock = mocker.patch.object(lock, 'clean_project')
-
- result = lock._try_acquire_once()
-
- assert isinstance(result, LockInfo)
- assert result.lock_id == my_lock_id
- assert result.metadata_key == LOCK_KEY_PREFIX + my_lock_id
- assert any(c == call(0) for c in sleep_mock.call_args_list) # anti_collision=0
- cleanup_mock.assert_called_once_with(my_lock_id)
- clean_mock.assert_called_once()
-
-
-# ---------------------------------------------------------------------------
-# test_try_acquire_once_loses_to_active_runner
-# ---------------------------------------------------------------------------
-
-
-def test_try_acquire_once_loses_to_active_runner(mocker):
- """Other runner is older → returns None; our candidate is released."""
- lock = _make_project_lock()
- my_lock_id = 'try-once-lose-001'
- other_lock_id = 'try-once-other-01'
-
- mocker.patch('uuid.uuid4', return_value=MagicMock(__str__=lambda _: my_lock_id))
- post_mock = mocker.patch.object(lock, '_post', return_value=[])
- clean_mock = mocker.patch.object(lock, 'clean_project')
-
- other_entry = _make_lock(lock_id=other_lock_id, minutes_ago=5) # older
- my_entry = _make_lock(lock_id=my_lock_id, minutes_ago=0)
- mocker.patch.object(lock, '_read_metadata', return_value=[other_entry, my_entry])
- mocker.patch('time.sleep')
-
- result = lock._try_acquire_once()
-
- assert result is None
- # Our candidate must be released
- released_keys = [
- entry['key']
- for c in post_mock.call_args_list
- for entry in c.kwargs.get('data', {}).get('metadata', [])
- if entry['key'].endswith('.released')
- ]
- assert LOCK_KEY_PREFIX + my_lock_id + '.released' in released_keys
- clean_mock.assert_not_called()
-
-
-# ---------------------------------------------------------------------------
-# test_try_acquire_once_stale_then_wins
-# ---------------------------------------------------------------------------
-
-
-def test_try_acquire_once_stale_then_wins(mocker):
- """Stale detected → cleans project, second candidate wins → returns LockInfo."""
- lock = _make_project_lock(ttl_minutes=60)
-
- stale_id = 'stale-entry-0001'
- my_id_1 = 'my-first-cand-001'
- my_id_2 = 'my-second-cand-01'
-
- uuid_iter = iter([my_id_1, my_id_2])
- mocker.patch('uuid.uuid4', side_effect=lambda: MagicMock(__str__=lambda _: next(uuid_iter)))
-
- post_mock = mocker.patch.object(lock, '_post', return_value=[])
- clean_mock = mocker.patch.object(lock, 'clean_project')
- cleanup_mock = mocker.patch.object(lock, '_cleanup_old_locks')
-
- stale_entry = _make_lock(lock_id=stale_id, minutes_ago=120)
-
- read_call = [0]
-
- def metadata_side_effect():
- read_call[0] += 1
- if read_call[0] == 1:
- return [stale_entry, _make_lock(lock_id=my_id_1, minutes_ago=0)]
- else:
- return [_make_lock(lock_id=my_id_2, minutes_ago=0)]
-
- mocker.patch.object(lock, '_read_metadata', side_effect=metadata_side_effect)
- mocker.patch('time.sleep')
-
- result = lock._try_acquire_once()
-
- assert isinstance(result, LockInfo)
- assert result.lock_id == my_id_2
- clean_mock.assert_not_called()
- cleanup_mock.assert_called_once_with(my_id_2)
-
- released_keys = [
- entry['key']
- for c in post_mock.call_args_list
- for entry in c.kwargs.get('data', {}).get('metadata', [])
- if entry['key'].endswith('.released')
- ]
- assert LOCK_KEY_PREFIX + stale_id + '.released' in released_keys
- assert LOCK_KEY_PREFIX + my_id_1 + '.released' in released_keys
-
-
-# ---------------------------------------------------------------------------
-# test_try_acquire_once_stale_then_loses
-# ---------------------------------------------------------------------------
-
-
-def test_try_acquire_once_stale_then_loses(mocker):
- """Stale cleaned but second attempt loses to a racing runner → returns None."""
- lock = _make_project_lock(ttl_minutes=60)
-
- stale_id = 'stale-entry-0002'
- my_id_1 = 'my-first-cand-002'
- my_id_2 = 'my-second-cand-02'
- other_id = 'other-racer-0001'
-
- uuid_iter = iter([my_id_1, my_id_2])
- mocker.patch('uuid.uuid4', side_effect=lambda: MagicMock(__str__=lambda _: next(uuid_iter)))
-
- post_mock = mocker.patch.object(lock, '_post', return_value=[])
- clean_mock = mocker.patch.object(lock, 'clean_project')
-
- stale_entry = _make_lock(lock_id=stale_id, minutes_ago=120)
-
- read_call = [0]
-
- def metadata_side_effect():
- read_call[0] += 1
- if read_call[0] == 1:
- return [stale_entry, _make_lock(lock_id=my_id_1, minutes_ago=0)]
- else:
- # Another runner snuck in and is older than our second candidate
- other_entry = _make_lock(lock_id=other_id, minutes_ago=1)
- return [other_entry, _make_lock(lock_id=my_id_2, minutes_ago=0)]
-
- mocker.patch.object(lock, '_read_metadata', side_effect=metadata_side_effect)
- mocker.patch('time.sleep')
-
- result = lock._try_acquire_once()
-
- assert result is None
- clean_mock.assert_not_called()
-
- released_keys = [
- entry['key']
- for c in post_mock.call_args_list
- for entry in c.kwargs.get('data', {}).get('metadata', [])
- if entry['key'].endswith('.released')
- ]
- # Both candidates must be released
- assert LOCK_KEY_PREFIX + my_id_1 + '.released' in released_keys
- assert LOCK_KEY_PREFIX + my_id_2 + '.released' in released_keys
-
-
-# ---------------------------------------------------------------------------
-# test_acquire_still_works_via_try_acquire_once
-# ---------------------------------------------------------------------------
-
-
-def test_acquire_still_works_via_try_acquire_once(mocker):
- """acquire() loops _try_acquire_once(); None on first call, LockInfo on second."""
- lock = _make_project_lock(poll_interval_seconds=7)
-
- lock_info = _make_lock_info('final-lock-0001')
- mocker.patch.object(lock, '_try_acquire_once', side_effect=[None, lock_info])
- sleep_mock = mocker.patch('time.sleep')
-
- result = lock.acquire()
-
- assert result == lock_info
- assert call(7) in sleep_mock.call_args_list
-
-
-# ===========================================================================
-# ProjectPool tests
-# ===========================================================================
-
-
-# ---------------------------------------------------------------------------
-# test_pool_empty_endpoints_raises
-# ---------------------------------------------------------------------------
-
-
-def test_pool_empty_endpoints_raises():
- """ProjectPool(endpoints=[]) raises ValueError."""
- with pytest.raises(ValueError, match='at least one endpoint'):
- ProjectPool(endpoints=[])
-
-
-# ---------------------------------------------------------------------------
-# test_pool_single_endpoint_acquires
-# ---------------------------------------------------------------------------
-
-
-def test_pool_single_endpoint_acquires(mocker):
- """Pool of one endpoint: _try_acquire_once wins on first try → AcquiredProject."""
- endpoint = _make_endpoint()
- pool = _make_pool(endpoints=[endpoint])
-
- lock_info = _make_lock_info('pool-single-001')
- mock_lock = mocker.MagicMock()
- mock_lock._try_acquire_once.return_value = lock_info
- mocker.patch.object(pool, '_make_lock', return_value=mock_lock)
- mocker.patch('time.sleep')
-
- result = pool.acquire()
-
- assert isinstance(result, AcquiredProject)
- assert result.endpoint == endpoint
- assert result.lock_info == lock_info
-
-
-# ---------------------------------------------------------------------------
-# test_pool_first_busy_second_free
-# ---------------------------------------------------------------------------
-
-
-def test_pool_first_busy_second_free(mocker):
- """First endpoint is locked; second is free → result uses second endpoint, no poll sleep."""
- endpoint1 = _make_endpoint(token='token-aaa')
- endpoint2 = _make_endpoint(token='token-bbb')
- pool = _make_pool(endpoints=[endpoint1, endpoint2], poll_interval_seconds=30)
-
- lock_info = _make_lock_info('pool-second-001')
- mock_lock1 = mocker.MagicMock()
- mock_lock1._try_acquire_once.return_value = None
- mock_lock2 = mocker.MagicMock()
- mock_lock2._try_acquire_once.return_value = lock_info
-
- mocker.patch.object(pool, '_make_lock', side_effect=[mock_lock1, mock_lock2])
- mocker.patch('integtests.project_lock.random.randrange', return_value=0)
- sleep_mock = mocker.patch('time.sleep')
-
- result = pool.acquire()
-
- assert result.endpoint == endpoint2
- assert result.lock_info == lock_info
- # No poll sleep — a project was found within the first pass
- assert call(30) not in sleep_mock.call_args_list
-
-
-# ---------------------------------------------------------------------------
-# test_pool_all_busy_then_one_frees
-# ---------------------------------------------------------------------------
-
-
-def test_pool_all_busy_then_one_frees(mocker):
- """Both endpoints busy on pass 1; first frees on pass 2 → poll sleep called once."""
- endpoint1 = _make_endpoint(token='token-ccc')
- endpoint2 = _make_endpoint(token='token-ddd')
- pool = _make_pool(endpoints=[endpoint1, endpoint2], poll_interval_seconds=11)
-
- lock_info = _make_lock_info('pool-retry-001')
- # Pass 1: both locked
- mock_lock1a = mocker.MagicMock()
- mock_lock1a._try_acquire_once.return_value = None
- mock_lock2a = mocker.MagicMock()
- mock_lock2a._try_acquire_once.return_value = None
- # Pass 2: endpoint1 succeeds
- mock_lock1b = mocker.MagicMock()
- mock_lock1b._try_acquire_once.return_value = lock_info
-
- mocker.patch.object(pool, '_make_lock', side_effect=[mock_lock1a, mock_lock2a, mock_lock1b])
- mocker.patch('integtests.project_lock.random.randrange', return_value=0)
- sleep_mock = mocker.patch('time.sleep')
-
- result = pool.acquire()
-
- assert result.endpoint == endpoint1
- assert result.lock_info == lock_info
- # Poll sleep must have been called between the two passes
- assert call(11) in sleep_mock.call_args_list
-
-
-# ---------------------------------------------------------------------------
-# test_pool_stale_project_claimed_not_skipped
-# ---------------------------------------------------------------------------
-
-
-def test_pool_stale_project_claimed_not_skipped(mocker):
- """
- First endpoint's _try_acquire_once returns LockInfo (stale-path internal win)
- → pool claims it immediately; second endpoint is never tried.
- """
- endpoint1 = _make_endpoint(token='token-eee')
- endpoint2 = _make_endpoint(token='token-fff')
- pool = _make_pool(endpoints=[endpoint1, endpoint2])
-
- lock_info = _make_lock_info('pool-stale-win-01')
- mock_lock1 = mocker.MagicMock()
- mock_lock1._try_acquire_once.return_value = lock_info
-
- make_lock_mock = mocker.patch.object(pool, '_make_lock', return_value=mock_lock1)
- mocker.patch('integtests.project_lock.random.randrange', return_value=0)
- mocker.patch('time.sleep')
-
- result = pool.acquire()
-
- assert result.endpoint == endpoint1
- assert result.lock_info == lock_info
- # _make_lock called only once (endpoint2 was never tried)
- assert make_lock_mock.call_count == 1
- make_lock_mock.assert_called_once_with(endpoint1)
-
-
-# ---------------------------------------------------------------------------
-# test_pool_timeout_raises
-# ---------------------------------------------------------------------------
-
-
-def test_pool_timeout_raises(mocker):
- """Raises TimeoutError when no project can be acquired within max_wait_minutes."""
- pool = _make_pool(max_wait_minutes=0)
- mocker.patch('time.sleep')
-
- with pytest.raises(TimeoutError, match='Could not acquire any project lock'):
- pool.acquire()
-
-
-# ---------------------------------------------------------------------------
-# test_pool_release_delegates_to_lock
-# ---------------------------------------------------------------------------
-
-
-def test_pool_release_delegates_to_lock(mocker):
- """pool.release(acquired) delegates to _make_lock(endpoint).release(lock_info)."""
- endpoint = _make_endpoint()
- pool = _make_pool(endpoints=[endpoint])
-
- lock_info = _make_lock_info('pool-release-001')
- acquired = AcquiredProject(endpoint=endpoint, lock_info=lock_info)
-
- mock_lock = mocker.MagicMock()
- make_lock_mock = mocker.patch.object(pool, '_make_lock', return_value=mock_lock)
-
- pool.release(acquired)
-
- make_lock_mock.assert_called_once_with(endpoint)
- mock_lock.release.assert_called_once_with(lock_info)
-
-
-# ===========================================================================
-# ProjectEndpoint / pool acquisition tests
-# ===========================================================================
-
-
-# ---------------------------------------------------------------------------
-# test_project_endpoint_stores_all_fields
-# ---------------------------------------------------------------------------
-
-
-def test_project_endpoint_stores_all_fields():
- """ProjectEndpoint stores all fields correctly."""
- ep = ProjectEndpoint(
- storage_api_url='https://connection.keboola.com',
- storage_api_token='my-token',
- project_id='99',
- project_name='My Project',
- token_id='5',
- token_description='My test token',
- )
- assert ep.storage_api_token == 'my-token'
- assert ep.project_id == '99'
- assert ep.project_name == 'My Project'
- assert ep.token_id == '5'
- assert ep.token_description == 'My test token'
-
-
-# ---------------------------------------------------------------------------
-# test_pool_acquired_project_carries_endpoint
-# ---------------------------------------------------------------------------
-
-
-def test_pool_acquired_project_carries_endpoint(mocker):
- """AcquiredProject.endpoint carries the acquired endpoint."""
- endpoint = _make_endpoint(token='token-a')
- pool = _make_pool(endpoints=[endpoint])
-
- lock_info = _make_lock_info('ws-endpoint-test-001')
- mock_lock = mocker.MagicMock()
- mock_lock._try_acquire_once.return_value = lock_info
- mocker.patch.object(pool, '_make_lock', return_value=mock_lock)
- mocker.patch('time.sleep')
-
- result = pool.acquire()
-
- assert result.endpoint is endpoint
- assert result.endpoint.storage_api_token == 'token-a'
-
-
-# ---------------------------------------------------------------------------
-# test_pool_selects_correct_endpoint_when_acquired
-# ---------------------------------------------------------------------------
-
-
-def test_pool_selects_correct_endpoint_when_acquired(mocker):
- """When the second endpoint is acquired, that endpoint is returned, not the first's."""
- endpoint1 = _make_endpoint(token='token-aaa')
- endpoint2 = _make_endpoint(token='token-bbb')
- pool = _make_pool(endpoints=[endpoint1, endpoint2], poll_interval_seconds=30)
-
- lock_info = _make_lock_info('ws-endpoint-second-001')
- mock_lock1 = mocker.MagicMock()
- mock_lock1._try_acquire_once.return_value = None
- mock_lock2 = mocker.MagicMock()
- mock_lock2._try_acquire_once.return_value = lock_info
- mocker.patch.object(pool, '_make_lock', side_effect=[mock_lock1, mock_lock2])
- mocker.patch('integtests.project_lock.random.randrange', return_value=0)
- mocker.patch('time.sleep')
-
- result = pool.acquire()
-
- assert result.endpoint is endpoint2
- assert result.endpoint.storage_api_token == 'token-bbb'
-
-
-# ---------------------------------------------------------------------------
-# test_pool_acquire_randomizes_start_per_pass
-# ---------------------------------------------------------------------------
-
-
-def test_pool_acquire_randomizes_start_per_pass(mocker):
- """random.randrange is called once per pool pass."""
- endpoints = [
- _make_endpoint(token='token-aaa'),
- _make_endpoint(token='token-bbb'),
- _make_endpoint(token='token-ccc'),
- ]
- pool = _make_pool(endpoints=endpoints, poll_interval_seconds=1)
-
- lock_info = _make_lock_info('rand-start-001')
- # Pass 1: all busy (None). Pass 2: first tried endpoint succeeds.
- try_once_results = iter([None, None, None, lock_info])
- mock_lock = mocker.MagicMock()
- mock_lock._try_acquire_once.side_effect = lambda: next(try_once_results)
- mocker.patch.object(pool, '_make_lock', return_value=mock_lock)
- mocker.patch('time.sleep')
-
- randrange_mock = mocker.patch('integtests.project_lock.random.randrange', return_value=0)
-
- pool.acquire()
-
- # randrange must be called once per pass (2 passes: one all-busy + one with a winner)
- assert randrange_mock.call_count == 2
- # Each call must pass the pool size as the upper bound
- for c in randrange_mock.call_args_list:
- assert c == call(len(endpoints))
-
-
-# ===========================================================================
-# verify_project_endpoint tests
-# ===========================================================================
-
-
-# ---------------------------------------------------------------------------
-# test_verify_project_endpoint_happy_path
-# ---------------------------------------------------------------------------
-
-
-def test_verify_project_endpoint_happy_path(mocker):
- """verify_project_endpoint returns a fully populated ProjectEndpoint on success."""
- token_info = {
- 'id': 7,
- 'description': 'CI integration test token',
- 'owner': {'id': 42, 'name': 'My CI Project'},
- }
- mock_resp = mocker.MagicMock()
- mock_resp.json.return_value = token_info
- mock_client = mocker.MagicMock()
- mock_client.__enter__ = mocker.MagicMock(return_value=mock_client)
- mock_client.__exit__ = mocker.MagicMock(return_value=False)
- mock_client.get.return_value = mock_resp
- mocker.patch('integtests.project_lock.httpx.Client', return_value=mock_client)
-
- ep = verify_project_endpoint(
- storage_api_url='https://connection.keboola.com',
- storage_api_token='my-secret-token',
- )
-
- assert ep.project_id == '42'
- assert ep.project_name == 'My CI Project'
- assert ep.storage_api_token == 'my-secret-token'
- assert ep.token_id == '7'
- assert ep.token_description == 'CI integration test token'
- mock_client.get.assert_called_once_with('https://connection.keboola.com/v2/storage/tokens/verify')
- mock_resp.raise_for_status.assert_called_once()
-
-
-# ---------------------------------------------------------------------------
-# test_verify_project_endpoint_bad_token_raises
-# ---------------------------------------------------------------------------
-
-
-def test_verify_project_endpoint_bad_token_raises(mocker):
- """verify_project_endpoint propagates HTTPStatusError on a bad token."""
- mock_resp = mocker.MagicMock()
- mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
- '401 Unauthorized', request=mocker.MagicMock(), response=mocker.MagicMock()
- )
- mock_client = mocker.MagicMock()
- mock_client.__enter__ = mocker.MagicMock(return_value=mock_client)
- mock_client.__exit__ = mocker.MagicMock(return_value=False)
- mock_client.get.return_value = mock_resp
- mocker.patch('integtests.project_lock.httpx.Client', return_value=mock_client)
-
- with pytest.raises(httpx.HTTPStatusError):
- verify_project_endpoint(
- storage_api_url='https://connection.keboola.com',
- storage_api_token='bad-token',
- )
-
-
-# ===========================================================================
-# _delete_metadata_by_id / _cleanup_old_locks tests
-# ===========================================================================
-
-
-# ---------------------------------------------------------------------------
-# test_delete_metadata_by_id
-# ---------------------------------------------------------------------------
-
-
-def test_delete_metadata_by_id(mocker):
- """_delete_metadata_by_id calls DELETE on the correct metadata path."""
- lock = _make_project_lock()
- delete_mock = mocker.patch.object(lock, '_delete')
- lock._delete_metadata_by_id('9876')
- delete_mock.assert_called_once_with('/v2/storage/branch/default/metadata/9876')
-
-
-# ---------------------------------------------------------------------------
-# test_cleanup_old_locks
-# ---------------------------------------------------------------------------
-
-_OLD_ID = 'old-lock-id-0001'
-_CUR_ID = 'cur-lock-id-0001'
-
-
-@pytest.mark.parametrize(
- ('scenario', 'entries', 'current_lock_id', 'expected_delete_calls'),
- [
- (
- 'no_released_entries',
- lambda: [_make_lock(lock_id=_OLD_ID, meta_id=1)],
- _CUR_ID,
- [],
- ),
- (
- 'full_released_pair',
- lambda: [
- _make_lock(lock_id=_OLD_ID, meta_id=10),
- _released_entry(_OLD_ID, meta_id=11),
- ],
- _CUR_ID,
- ['10', '11'],
- ),
- (
- 'orphaned_released_only',
- lambda: [_released_entry(_OLD_ID, meta_id=20)],
- _CUR_ID,
- ['20'],
- ),
- (
- 'skip_current_lock',
- lambda: [
- _make_lock(lock_id=_CUR_ID, meta_id=30),
- _released_entry(_CUR_ID, meta_id=31),
- ],
- _CUR_ID,
- [],
- ),
- (
- 'mixed_old_and_current',
- lambda: [
- _make_lock(lock_id=_OLD_ID, meta_id=40),
- _released_entry(_OLD_ID, meta_id=41),
- _make_lock(lock_id=_CUR_ID, meta_id=42),
- _released_entry(_CUR_ID, meta_id=43),
- ],
- _CUR_ID,
- ['40', '41'],
- ),
- (
- 'main_deletion_error_skips_released',
- lambda: [
- _make_lock(lock_id=_OLD_ID, meta_id=50),
- _released_entry(_OLD_ID, meta_id=51),
- ],
- _CUR_ID,
- 'error_on_main',
- ),
- ],
-)
-def test_cleanup_old_locks(mocker, scenario, entries, current_lock_id, expected_delete_calls):
- lock = _make_project_lock()
- mocker.patch.object(lock, '_read_metadata', side_effect=entries)
-
- if expected_delete_calls == 'error_on_main':
- delete_mock = mocker.patch.object(lock, '_delete_metadata_by_id', side_effect=RuntimeError('fail'))
- # Should not raise; the error is swallowed
- lock._cleanup_old_locks(current_lock_id)
- # Only one call attempted (the main entry); .released is skipped
- delete_mock.assert_called_once_with('50')
- else:
- delete_mock = mocker.patch.object(lock, '_delete_metadata_by_id')
- lock._cleanup_old_locks(current_lock_id)
- if not expected_delete_calls:
- delete_mock.assert_not_called()
- else:
- assert delete_mock.call_count == len(expected_delete_calls)
- # Verify the calls were made in the correct order
- assert delete_mock.call_args_list == [call(mid) for mid in expected_delete_calls]
diff --git a/integtests/test_validate.py b/integtests/test_validate.py
deleted file mode 100644
index 31ec29f33..000000000
--- a/integtests/test_validate.py
+++ /dev/null
@@ -1,82 +0,0 @@
-"""
-This test is used to validate both row and root parameter schemas of all components.
-- Serves as a sanity check for the schemas validation, identifying invalid schemas and proposing two possible solutions:
- - Fix the json schema to be valid
- - Improve the KeboolaParametersValidator to accept the schema
-- Ensures that all parameter schemas are valid and that the MCP server will them correctly to validate the parameters
-received from the LLM Agent.
-- In case the schema is invalid, we skip the validation, log the schema error but continue with the action (creation or
-update of the component) assuming that the validation of json object against the schema had been correct. That is the
-reason why we are having those tests.
-"""
-
-import logging
-from typing import cast
-
-import httpx
-import jsonschema
-import pytest
-
-from keboola_mcp_server.clients.base import JsonDict
-from keboola_mcp_server.clients.client import KeboolaClient
-from keboola_mcp_server.clients.storage import ComponentAPIResponse
-from keboola_mcp_server.tools.components.model import Component
-from keboola_mcp_server.tools.validation import KeboolaParametersValidator
-
-LOG = logging.getLogger(__name__)
-
-
-def _check_schema(schema: JsonDict, dummy_parameters: JsonDict) -> None:
- try:
- KeboolaParametersValidator.validate(dummy_parameters, schema)
- except jsonschema.ValidationError:
- # We care only about schema errors, ignore validation errors since we are using dummy parameters.
- # The schema itself is checked just before we validate JSON object against it. Hence, we can ignore
- # ValidationError because the schema is valid which is our objective, but our dummy_parameters violates
- # the schema - we are not interested in the dummy parameters.
- pass
-
-
-@pytest.mark.asyncio
-async def test_validate_parameters(keboola_client: KeboolaClient):
- # Fetch the storage stack index directly to avoid the trailing-slash 301 that
- # `storage_client.get('')` triggers on some stacks (it builds `/`).
- raw = keboola_client.storage_client.raw_client
- async with httpx.AsyncClient(timeout=raw.timeout, transport=raw._create_transport()) as client:
- response = await client.get(raw.base_api_url, headers=raw.headers)
- response.raise_for_status()
- data = cast(JsonDict, response.json())
- LOG.info(f'Fetched information: {data.keys()}')
- components = cast(list[JsonDict], data['components'])
- components.sort(key=lambda x: (x['type'], x['name'])) # sort by type and then by name
- LOG.info(f'Fetched total of {len(components)} components')
-
- row_counts, root_counts = 0, 0
- invalid_row_schemas, invalid_root_schemas = [], []
- for raw_component in components:
- api_component = ComponentAPIResponse.model_validate(raw_component)
- component = Component.from_api_response(api_component)
- if component.configuration_schema:
- try:
- root_counts += 1
- _check_schema(component.configuration_schema, dummy_parameters={})
- except jsonschema.SchemaError as e:
- LOG.exception(f'Root schema error for {raw_component["id"]}: {e}')
- invalid_root_schemas.append(raw_component['id'])
- if component.configuration_row_schema:
- try:
- row_counts += 1
- _check_schema(component.configuration_row_schema, dummy_parameters={})
- except jsonschema.SchemaError as e:
- LOG.exception(f'Row schema error for {raw_component["id"]}: {e}')
- invalid_row_schemas.append(raw_component['id'])
-
- if invalid_root_schemas:
- pytest.fail(f'Invalid root schemas({len(invalid_root_schemas)}): {invalid_root_schemas}')
- if invalid_row_schemas:
- pytest.fail(f'Invalid row schemas({len(invalid_row_schemas)}): {invalid_row_schemas}')
-
- LOG.info(
- f'Total components: {len(components)}, from which {root_counts} have root configuration schema and '
- f'{row_counts} have row configuration schema. All schemas are valid.'
- )
diff --git a/integtests/test_workspace.py b/integtests/test_workspace.py
deleted file mode 100644
index 3b5de0bca..000000000
--- a/integtests/test_workspace.py
+++ /dev/null
@@ -1,128 +0,0 @@
-import logging
-from collections.abc import AsyncGenerator, Mapping
-from typing import Any
-
-import pytest
-import pytest_asyncio
-import requests
-from kbcstorage.client import Client as SyncStorageClient
-
-from keboola_mcp_server.clients.client import KeboolaClient
-from keboola_mcp_server.workspace import WorkspaceManager
-
-LOG = logging.getLogger(__name__)
-
-
-@pytest_asyncio.fixture
-async def dynamic_manager(
- keboola_client: KeboolaClient, sync_storage_client: SyncStorageClient, workspace_schema: str
-) -> AsyncGenerator[WorkspaceManager, Any]:
- storage_client = sync_storage_client
- token_info = storage_client.tokens.verify()
- project_id: str = token_info['owner']['id']
-
- def _get_workspace_meta() -> list[Mapping[str, Any]]:
- metadata: list[Mapping[str, Any]] = []
- for m in storage_client.branches.metadata('default'):
- if m.get('key') == WorkspaceManager.MCP_META_KEY:
- metadata.append(m)
- return metadata
-
- metas = _get_workspace_meta()
- if metas:
- pytest.fail(f'Expecting empty Keboola project {project_id}, but found {metas} in the default branch')
-
- workspaces = storage_client.workspaces.list()
- # ignore the static workspaces
- workspaces = [
- w
- for w in workspaces
- if all(
- [
- w['connection']['schema'] != workspace_schema,
- w.get('creatorToken', {}).get('description') != 'Background Indexing Token',
- ]
- )
- ]
- if workspaces:
- pytest.fail(
- f'Expecting empty Keboola project {project_id}, but found {len(workspaces)} extra workspaces: '
- f'{[{"id": w["id"], "name": w["name"]} for w in workspaces]}'
- )
-
- component_id = WorkspaceManager.MCP_WORKSPACE_COMPONENT_ID
- existing_configs = list(storage_client.configurations.list(component_id=component_id))
- if existing_configs:
- pytest.fail(
- f'Expecting no MCP workspace configs in project {project_id}, '
- f'but found: {[c.get("id") for c in existing_configs]}'
- )
-
- yield await WorkspaceManager.create(keboola_client)
-
- LOG.info(f'Cleaning up workspaces in Keboola project with ID={project_id}')
- metas = _get_workspace_meta()
- if len(metas) > 1:
- LOG.info(f'Multiple metadata entries found: {metas}')
- for meta in metas:
- try:
- storage_client.workspaces.delete(meta['value'])
- LOG.info(f'Deleted workspaces: {meta["value"]}')
- except requests.HTTPError:
- LOG.exception(f'Failed to delete workspace {meta["value"]}')
- try:
- url = storage_client.branches.base_url.rstrip('/')
- storage_client.branches._delete(f'{url}/branch/default/metadata/{meta["id"]}')
- LOG.info(f'Deleted workspaces metadata: {meta["id"]}')
- except requests.HTTPError as e:
- LOG.exception(f'Failed to delete workspace metadata {meta["id"]}: {e}')
-
- # Clean up configurations created under the MCP workspace component
- component_id = WorkspaceManager.MCP_WORKSPACE_COMPONENT_ID
- try:
- configs = storage_client.configurations.list(component_id=component_id)
- for cfg in configs:
- cfg_id = cfg.get('id')
- if cfg_id:
- try:
- storage_client.configurations.delete(component_id, cfg_id)
- # Double delete to skip trash
- storage_client.configurations.delete(component_id, cfg_id)
- LOG.info(f'Deleted component config: {component_id}/{cfg_id}')
- except requests.HTTPError:
- LOG.exception(f'Failed to delete component config {component_id}/{cfg_id}')
- except requests.HTTPError:
- LOG.exception(f'Failed to list configs for {component_id}')
-
-
-class TestWorkspaceManager:
-
- @pytest.mark.asyncio
- async def test_static_workspace(self, workspace_manager: WorkspaceManager, workspace_schema: str):
- assert workspace_manager._workspace_schema == workspace_schema
-
- info = await workspace_manager._find_ws_by_schema(workspace_schema)
- assert info is not None
- assert info.schema == workspace_schema
- assert info.backend in ['snowflake', 'bigquery']
-
- workspace = await workspace_manager._get_workspace()
- assert workspace is not None
- assert workspace.id == info.id
-
- @pytest.mark.asyncio
- async def test_dynamic_workspace(self, dynamic_manager: WorkspaceManager):
- assert dynamic_manager._workspace_schema is None
-
- # check that there is no workspace in the branch
- info = await dynamic_manager._find_ws_in_branch()
- assert info is None
-
- # create workspace
- workspace = await dynamic_manager._get_workspace()
- assert workspace is not None
-
- # check that the new workspace is recorded in the branch
- info = await dynamic_manager._find_ws_in_branch()
- assert info is not None
- assert workspace.id == info.id
diff --git a/integtests/testproject/clean.ts b/integtests/testproject/clean.ts
new file mode 100644
index 000000000..f8d577a06
--- /dev/null
+++ b/integtests/testproject/clean.ts
@@ -0,0 +1,90 @@
+import { createRawClient } from '@/clients/raw';
+import { WorkspaceManager } from '@/workspace';
+import { type ProjectDefinition, storageApiUrl } from './types';
+
+// Reset a leased project to a clean state before a test runs — port of the Python
+// integtests _purge_project + _guard_dedicated_test_project. Uses the raw Storage API
+// (rooted at /v2/storage) so it has no dependency on the per-request server config.
+
+// The integ fixtures only ever create stage-prefixed `*.c-test*` buckets. A project holding
+// any other bucket is almost certainly NOT a dedicated test project — refuse to wipe it.
+const TEST_BUCKET_PREFIXES = ['in.c-test', 'out.c-test', 'sys.c-test'];
+
+type Bucket = { id: string };
+type Component = { id: string; configurations?: { id: string }[] };
+type Workspace = { id: string | number; creatorToken?: { description?: string } };
+type Meta = { id: string | number; key?: string };
+type StorageJob = { id?: string | number; status?: string };
+
+const STATIC_WORKSPACE_CREATORS = new Set(['Background Indexing Token']);
+
+type RawClient = ReturnType;
+const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms));
+
+/** Polls a Storage async job to completion (used for async bucket drop). */
+const waitForStorageJob = async (raw: RawClient, job: StorageJob): Promise => {
+ if (!job?.id) return; // synchronous response (older stacks) — nothing to wait for
+ const deadline = Date.now() + 120_000;
+ for (;;) {
+ const current = await raw.get(`jobs/${job.id}`);
+ const status = current.status ?? '';
+ if (status === 'success' || status === 'error' || status === 'cancelled') return;
+ if (Date.now() > deadline) throw new Error(`Storage job ${job.id} did not finish within 120s`);
+ await sleep(1000);
+ }
+};
+
+export const cleanProject = async (def: ProjectDefinition): Promise => {
+ const raw = createRawClient({ baseUrl: `${storageApiUrl(def)}/v2/storage`, token: def.token });
+
+ // Guard: refuse to reset a project that holds non-test buckets.
+ const buckets = await raw.get('buckets');
+ const foreign = buckets.filter((b) => !TEST_BUCKET_PREFIXES.some((p) => b.id.startsWith(p)));
+ if (foreign.length > 0) {
+ throw new Error(
+ `Refusing to reset project ${def.project}: found non-test buckets ${foreign
+ .map((b) => b.id)
+ .join(', ')}. The projects.json pool may point at a non-dedicated project.`,
+ );
+ }
+
+ // Bucket drop must be async — the Storage API rejects a synchronous force-drop
+ // ("Synchronous drop is not supported, use async call"). Each delete returns a storage
+ // job; wait for it so a subsequent seed doesn't race a half-deleted bucket.
+ for (const bucket of buckets) {
+ const job = await raw.delete(`buckets/${bucket.id}`, {
+ params: { force: 'true', async: 'true' },
+ });
+ await waitForStorageJob(raw, job);
+ }
+
+ const components = await raw.get('components', { params: { include: 'configuration' } });
+ for (const component of components) {
+ for (const config of component.configurations ?? []) {
+ // First delete moves to trash; second purges it. Best-effort: some pooled projects'
+ // tokens return 403 on the purge ("You don't have access to the resource") — leaving a
+ // trashed config behind is harmless, so tolerate it rather than aborting the reset.
+ await raw.delete(`components/${component.id}/configs/${config.id}`).catch(() => {});
+ await raw.delete(`components/${component.id}/configs/${config.id}`).catch(() => {});
+ }
+ }
+
+ const workspaces = await raw.get('branch/default/workspaces');
+ for (const ws of workspaces) {
+ if (STATIC_WORKSPACE_CREATORS.has(ws.creatorToken?.description ?? '')) continue;
+ await raw.delete(`workspaces/${ws.id}`).catch(() => {
+ /* a workspace backed by a deleted sandbox config may already be gone */
+ });
+ }
+
+ const metadata = await raw.get('branch/default/metadata');
+ for (const meta of metadata) {
+ if (meta.key === WorkspaceManager.MCP_META_KEY) {
+ // Some pooled projects return 403 on branch-metadata delete (token scope); the stale
+ // workspace-id metadata is harmless to leave behind, so tolerate it.
+ await raw.delete(`branch/default/metadata/${meta.id}`).catch(() => {
+ /* best-effort: leftover MCP workspace-id metadata does not break the next run */
+ });
+ }
+ }
+};
diff --git a/integtests/testproject/fixture.ts b/integtests/testproject/fixture.ts
new file mode 100644
index 000000000..22ee237e4
--- /dev/null
+++ b/integtests/testproject/fixture.ts
@@ -0,0 +1,60 @@
+import { onTestFinished } from 'vitest';
+
+import { Config } from '@/config';
+import { cleanProject } from './clean';
+import { getPool } from './pool';
+import type { AcquireOptions, Backend } from './types';
+
+// Per-test-case project acquisition — port of go-utils GetTestProjectForTest. The lease is
+// acquired now and released automatically when the current test finishes (vitest
+// onTestFinished), so each case holds a project only for its own duration.
+
+export type TestProject = {
+ config: Config;
+ storageApiUrl: string;
+ storageApiToken: string;
+ backend: Backend;
+ projectId: number;
+};
+
+export type GetTestProjectOptions = AcquireOptions & {
+ /**
+ * Reset the project to a clean state before returning (default `true`). Tests that don't
+ * mutate project state (e.g. docs_query, get_project_info, a literal SELECT) can pass
+ * `false` to skip the wipe — useful against shared projects the dedicated-project guard
+ * would otherwise refuse to clean.
+ */
+ clean?: boolean;
+};
+
+/**
+ * Leases a project for the calling test, resets it to a clean state, and returns a ready
+ * `Config`. The lease is released on test completion. If the whole pool is busy this blocks
+ * (and retries) until a project frees up rather than failing.
+ *
+ * Must be called from within a running test (it registers onTestFinished).
+ */
+export const getTestProjectForTest = async (
+ opts: GetTestProjectOptions = {},
+): Promise => {
+ const pool = getPool();
+ const leased = await pool.getTestProject(opts);
+ onTestFinished(async () => {
+ await leased.release();
+ });
+
+ if (opts.clean !== false) {
+ await cleanProject(leased.definition);
+ }
+
+ return {
+ config: new Config({
+ storageApiUrl: leased.storageApiUrl,
+ storageToken: leased.storageApiToken,
+ }),
+ storageApiUrl: leased.storageApiUrl,
+ storageApiToken: leased.storageApiToken,
+ backend: leased.backend,
+ projectId: leased.definition.project,
+ };
+};
diff --git a/integtests/testproject/fsLocker.ts b/integtests/testproject/fsLocker.ts
new file mode 100644
index 000000000..21feee63f
--- /dev/null
+++ b/integtests/testproject/fsLocker.ts
@@ -0,0 +1,48 @@
+import { closeSync, mkdirSync, openSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import type { Locker, ProjectLocker, ReleaseFn } from './locker';
+import { lockKey, type ProjectDefinition } from './types';
+
+/**
+ * Host-local file lock — port of go-utils pkg/testproject/fslocker.go. Used only as a
+ * fallback for local single-host runs when no redis is configured; it provides NO
+ * cross-runner safety (CI always uses the redis locker).
+ *
+ * A project is leased by exclusively creating `/-.lock`
+ * (O_CREAT|O_EXCL); a concurrent creator gets EEXIST and treats the project as busy.
+ * Release unlinks the file.
+ */
+export const createFsLocker = (dirName?: string): Locker => {
+ const dir = dirName ?? join(tmpdir(), 'kbc-mcp-testproject-locks');
+ mkdirSync(dir, { recursive: true });
+
+ const forProject = (def: ProjectDefinition): ProjectLocker => {
+ const file = join(dir, `${lockKey(def)}.lock`);
+ return {
+ tryLock: async (): Promise => {
+ let fd: number;
+ try {
+ fd = openSync(file, 'wx'); // exclusive create; throws EEXIST if held
+ } catch {
+ return null;
+ }
+ closeSync(fd);
+ let released = false;
+ return async (): Promise => {
+ if (released) return;
+ released = true;
+ rmSync(file, { force: true });
+ };
+ },
+ };
+ };
+
+ return {
+ forProject,
+ close: async (): Promise => {
+ /* nothing to close */
+ },
+ };
+};
diff --git a/integtests/testproject/locker.ts b/integtests/testproject/locker.ts
new file mode 100644
index 000000000..fa29a43cd
--- /dev/null
+++ b/integtests/testproject/locker.ts
@@ -0,0 +1,24 @@
+import type { ProjectDefinition } from './types';
+
+/**
+ * Locker abstraction (port of the go-utils `locker` / `projectLocker` interfaces).
+ *
+ * `tryLock` performs a single, non-blocking attempt to lease the project. It returns a
+ * `release` function on success, or `null` if the project is currently leased elsewhere.
+ * The pool (pool.ts) is responsible for the retry loop.
+ */
+export type ReleaseFn = () => Promise;
+
+export type ProjectLocker = {
+ tryLock: () => Promise;
+};
+
+export type Locker = {
+ forProject: (def: ProjectDefinition) => ProjectLocker;
+ /** Releases any locker-wide resources (e.g. the redis connection). */
+ close: () => Promise;
+};
+
+export const LOCK_HOST_ENV = 'TEST_MCP_PROJECTS_LOCK_HOST';
+export const LOCK_PASSWORD_ENV = 'TEST_MCP_PROJECTS_LOCK_PASSWORD';
+export const LOCK_DIR_ENV = 'TEST_MCP_PROJECTS_LOCK_DIR_NAME';
diff --git a/integtests/testproject/pool.ts b/integtests/testproject/pool.ts
new file mode 100644
index 000000000..14a53b13c
--- /dev/null
+++ b/integtests/testproject/pool.ts
@@ -0,0 +1,83 @@
+import { createFsLocker } from './fsLocker';
+import { LOCK_DIR_ENV, LOCK_HOST_ENV, LOCK_PASSWORD_ENV,type Locker } from './locker';
+import { loadProjects } from './projects';
+import { createRedisLocker } from './redisLocker';
+import {
+ type AcquireOptions,
+ isCompatible,
+ type LockedProject,
+ type ProjectDefinition,
+ storageApiUrl,
+} from './types';
+
+// Port of go-utils ProjectsPool.GetTestProject: try each compatible project once; if all are
+// busy, sleep briefly and retry the WHOLE pool forever. The only hard error is "no compatible
+// project exists in the pool at all".
+
+const RETRY_DELAY_MS = 100;
+const WAIT_LOG_EVERY_MS = 5000;
+
+const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms));
+
+/** Chooses the redis locker when LOCK_HOST/PASSWORD are set, else the host-local fs fallback. */
+export const newLocker = (): Locker => {
+ const host = process.env[LOCK_HOST_ENV];
+ const password = process.env[LOCK_PASSWORD_ENV];
+ if (host) {
+ if (!password) throw new Error(`${LOCK_PASSWORD_ENV} is required when ${LOCK_HOST_ENV} is set.`);
+ return createRedisLocker(host, password);
+ }
+ return createFsLocker(process.env[LOCK_DIR_ENV]);
+};
+
+export type Pool = {
+ getTestProject: (opts?: AcquireOptions) => Promise;
+ close: () => Promise;
+};
+
+export const createPool = (defs: ProjectDefinition[], locker: Locker): Pool => {
+ const lockers = new Map(defs.map((d) => [d, locker.forProject(d)]));
+
+ const getTestProject = async (opts: AcquireOptions = {}): Promise => {
+ const compatible = defs.filter((d) => isCompatible(d, opts));
+ if (compatible.length === 0) {
+ throw new Error(`No compatible test project in the pool (backend=${opts.backend ?? 'any'}).`);
+ }
+
+ let lastLog = 0;
+ // Randomize the start so parallel workers don't all stampede the first project.
+ const start = Math.floor(Math.random() * compatible.length);
+ for (let attempt = 0; ; attempt++) {
+ for (let i = 0; i < compatible.length; i++) {
+ const def = compatible[(start + i) % compatible.length]!;
+ const release = await lockers.get(def)!.tryLock();
+ if (release) {
+ return {
+ definition: def,
+ storageApiUrl: storageApiUrl(def),
+ storageApiToken: def.token,
+ backend: def.backend,
+ release,
+ };
+ }
+ }
+ const now = Date.now();
+ if (now - lastLog >= WAIT_LOG_EVERY_MS) {
+ // eslint-disable-next-line no-console
+ console.info(`[testproject] all ${compatible.length} project(s) busy; waiting…`);
+ lastLog = now;
+ }
+ await sleep(RETRY_DELAY_MS);
+ }
+ };
+
+ return { getTestProject, close: () => locker.close() };
+};
+
+let singleton: Pool | undefined;
+
+/** Process-singleton pool built from TEST_KBC_PROJECTS_FILE + the env-selected locker. */
+export const getPool = (): Pool => {
+ if (!singleton) singleton = createPool(loadProjects(), newLocker());
+ return singleton;
+};
diff --git a/integtests/testproject/projects.ts b/integtests/testproject/projects.ts
new file mode 100644
index 000000000..20bafaa7e
--- /dev/null
+++ b/integtests/testproject/projects.ts
@@ -0,0 +1,47 @@
+import { readFileSync } from 'node:fs';
+import { isAbsolute } from 'node:path';
+
+import { type ProjectDefinition, projectDefinitionSchema } from './types';
+
+// Port of go-utils getProjects / GetProjectsFrom: load + validate the projects.json pool
+// once per process from TEST_KBC_PROJECTS_FILE (absolute path).
+
+export const TEST_KBC_PROJECTS_FILE = 'TEST_KBC_PROJECTS_FILE';
+
+/** Parses + validates a projects.json document (the array form). */
+export const parseProjects = (json: string): ProjectDefinition[] => {
+ const raw: unknown = JSON.parse(json);
+ if (!Array.isArray(raw) || raw.length === 0) {
+ throw new Error(
+ 'projects.json must be a non-empty array of {host, project, token, backend, stagingStorage}.',
+ );
+ }
+ return raw.map((entry, i) => {
+ const result = projectDefinitionSchema.safeParse(entry);
+ if (!result.success) {
+ throw new Error(`projects.json[${i}] is invalid: ${result.error.issues.map((x) => x.message).join('; ')}`);
+ }
+ return result.data;
+ });
+};
+
+let cached: ProjectDefinition[] | undefined;
+
+/** Loads the pool once per process. `path` overrides TEST_KBC_PROJECTS_FILE (must be absolute). */
+export const loadProjects = (path?: string): ProjectDefinition[] => {
+ if (cached) return cached;
+ const file = path ?? process.env[TEST_KBC_PROJECTS_FILE];
+ if (!file) {
+ throw new Error(`Set ${TEST_KBC_PROJECTS_FILE} to the absolute path of the projects.json pool file.`);
+ }
+ if (!isAbsolute(file)) {
+ throw new Error(`${TEST_KBC_PROJECTS_FILE} must be an absolute path, got: ${file}`);
+ }
+ cached = parseProjects(readFileSync(file, 'utf-8'));
+ return cached;
+};
+
+/** Test-only: clears the process-singleton cache. */
+export const _resetProjectsCache = (): void => {
+ cached = undefined;
+};
diff --git a/integtests/testproject/redisLocker.ts b/integtests/testproject/redisLocker.ts
new file mode 100644
index 000000000..e454e469c
--- /dev/null
+++ b/integtests/testproject/redisLocker.ts
@@ -0,0 +1,105 @@
+import Redis from 'ioredis';
+import { randomUUID } from 'node:crypto';
+
+import type { Locker, ProjectLocker, ReleaseFn } from './locker';
+import { lockKey, type ProjectDefinition } from './types';
+
+/**
+ * Redis-backed cross-runner project lease — port of go-utils
+ * pkg/testproject/redislocker.go (which wraps bsm/redislock).
+ *
+ * We use raw ioredis + small Lua scripts instead of a lock library so the
+ * compare-and-swap semantics match the go implementation exactly:
+ * obtain : SET key token NX PX ttl
+ * refresh: if GET key == token then PEXPIRE key ttl (every ttl/4)
+ * release: if GET key == token then DEL key
+ * A crashed worker's lease therefore self-expires after at most TTL.
+ */
+const TTL_MS = 2 * 60 * 1000;
+const REFRESH_MS = TTL_MS / 4;
+
+const REFRESH_LUA =
+ "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end";
+const RELEASE_LUA =
+ "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
+
+/** Parses `redis://host:port` / `rediss://...` / a `+tls` suffix into ioredis options. */
+const parseRedisUrl = (url: string): { host: string; port: number; tls: boolean } => {
+ const sep = url.indexOf('://');
+ if (sep === -1) throw new Error(`${url}: no protocol specified (expected redis://...)`);
+ const scheme = url.slice(0, sep);
+ const hostPort = url.slice(sep + 3);
+ const [host, port] = hostPort.split(':');
+ return {
+ host: host || '127.0.0.1',
+ port: port ? Number(port) : 6379,
+ tls: scheme.includes('+tls') || scheme === 'rediss',
+ };
+};
+
+export const createRedisLocker = (redisUrl: string, password: string): Locker => {
+ const { host, port, tls } = parseRedisUrl(redisUrl);
+ const client = new Redis({
+ host,
+ port,
+ password,
+ ...(tls ? { tls: { minVersion: 'TLSv1.2' as const } } : {}),
+ // Tolerate a flaky/briefly-unavailable redis: retry each command a few times and
+ // reconnect with capped exponential backoff instead of failing immediately.
+ maxRetriesPerRequest: 3,
+ retryStrategy: (times) => Math.min(times * 200, 2000),
+ });
+ client.on('error', (err) => {
+ // ioredis emits connection errors on this channel; without a listener they become
+ // unhandled 'error' events that crash the process. Swallow — command-level failures
+ // surface where they're awaited and are handled there.
+ console.warn(`[testproject] redis error: ${err.message}`);
+ });
+
+ const forProject = (def: ProjectDefinition): ProjectLocker => {
+ const key = lockKey(def);
+ return {
+ tryLock: async (): Promise => {
+ const token = randomUUID();
+ let ok: string | null;
+ try {
+ ok = await client.set(key, token, 'PX', TTL_MS, 'NX');
+ } catch (err) {
+ // Transient redis failure (after the per-command retries above): treat the project
+ // as unavailable for now so the pool moves on and retries, rather than failing the
+ // whole test on a blip. A persistently-down redis is bounded by the job timeout.
+ console.warn(
+ `[testproject] redis SET failed for ${key}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ return null;
+ }
+ if (ok !== 'OK') return null;
+
+ // Auto-extend the lease for the (unknown) lifetime of the test.
+ const timer = setInterval(() => {
+ client.eval(REFRESH_LUA, 1, key, token, String(TTL_MS)).catch(() => {
+ /* best-effort; lease will expire on its own if refresh fails */
+ });
+ }, REFRESH_MS);
+ timer.unref?.();
+
+ let released = false;
+ return async (): Promise => {
+ if (released) return;
+ released = true;
+ clearInterval(timer);
+ await client.eval(RELEASE_LUA, 1, key, token).catch(() => {
+ /* lease will expire via TTL even if the explicit release fails */
+ });
+ };
+ },
+ };
+ };
+
+ return {
+ forProject,
+ close: async (): Promise => {
+ await client.quit().catch(() => client.disconnect());
+ },
+ };
+};
diff --git a/integtests/testproject/types.ts b/integtests/testproject/types.ts
new file mode 100644
index 000000000..f58e668c7
--- /dev/null
+++ b/integtests/testproject/types.ts
@@ -0,0 +1,45 @@
+import { z } from 'zod';
+
+// Port of go-utils pkg/testproject Definition. The projects.json array uses this exact
+// shape (same as keboola/go-monorepo build/ci/projects.json).
+
+export const BACKENDS = ['snowflake', 'bigquery'] as const;
+export type Backend = (typeof BACKENDS)[number];
+
+export const STAGING_STORAGES = ['abs', 'gcs', 's3'] as const;
+export type StagingStorage = (typeof STAGING_STORAGES)[number];
+
+export const projectDefinitionSchema = z.object({
+ host: z.string().min(1),
+ project: z.number().int().positive(),
+ token: z.string().min(1),
+ backend: z.enum(BACKENDS),
+ stagingStorage: z.enum(STAGING_STORAGES),
+ legacyTransformation: z.boolean().default(false),
+ isGuest: z.boolean().default(false),
+});
+
+export type ProjectDefinition = z.infer;
+
+/** Unique redis/fs lock key for a project: host + numeric id (port of go `host-projectID`). */
+export const lockKey = (def: ProjectDefinition): string => `${def.host}-${def.project}`;
+
+/** Storage API base URL derived from the project's bare `host`. */
+export const storageApiUrl = (def: ProjectDefinition): string => `https://${def.host}`;
+
+/** A project leased for the duration of one test; `release` frees the lease. */
+export type LockedProject = {
+ definition: ProjectDefinition;
+ storageApiUrl: string;
+ storageApiToken: string;
+ backend: Backend;
+ release: () => Promise;
+};
+
+/** Optional selector when a test requires a specific backend. */
+export type AcquireOptions = {
+ backend?: Backend;
+};
+
+export const isCompatible = (def: ProjectDefinition, opts: AcquireOptions): boolean =>
+ !opts.backend || def.backend === opts.backend;
diff --git a/integtests/tools/__init__.py b/integtests/tools/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/integtests/tools/components.test.ts b/integtests/tools/components.test.ts
new file mode 100644
index 000000000..2133289a7
--- /dev/null
+++ b/integtests/tools/components.test.ts
@@ -0,0 +1,503 @@
+import { describe, expect, it } from 'vitest';
+
+import { callToolText, connectMcp } from '../helpers/mcp';
+import { seedProject } from '../helpers/seed';
+import { getTestProjectForTest } from '../testproject/fixture';
+
+// Ported from integtests/tools/components/test_tools.py. The Python suite calls the tool
+// functions directly and asserts on typed Pydantic models; here the tools return TOON text
+// via the MCP client, so we assert on substrings/regex. Read tests seed the standard fixtures
+// (two configs: ex-generic-v2 extractor + keboola.snowflake-transformation transformation);
+// write tests lease a clean project and create their own data.
+
+const EXTRACTOR_COMPONENT = 'ex-generic-v2';
+const TRANSFORMATION_COMPONENT = 'keboola.snowflake-transformation';
+
+describe('components tools (integration)', () => {
+ // --- get_configs ---------------------------------------------------------
+
+ it('get_configs returns detailed configuration for specific configs', async () => {
+ const project = await getTestProjectForTest();
+ const seeded = await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ for (const cfg of seeded.configs) {
+ const text = await callToolText(session.client, 'get_configs', {
+ configs: [
+ { component_id: cfg.componentId, configuration_id: cfg.configurationId },
+ ],
+ });
+ // Detailed Configuration output carries the component, the configuration_root with the
+ // matching IDs, and a non-empty links list.
+ expect(text).toContain(cfg.componentId);
+ expect(text).toContain(cfg.configurationId);
+ expect(text).toMatch(/component_type/);
+ expect(text).toMatch(/component_name/);
+ expect(text).toMatch(/links/);
+ }
+ } finally {
+ await session.close();
+ }
+ });
+
+ it('get_configs lists components filtered by component IDs', async () => {
+ const project = await getTestProjectForTest();
+ await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ const text = await callToolText(session.client, 'get_configs', {
+ component_ids: [EXTRACTOR_COMPONENT, TRANSFORMATION_COMPONENT],
+ });
+ expect(text).toContain(EXTRACTOR_COMPONENT);
+ expect(text).toContain(TRANSFORMATION_COMPONENT);
+ expect(text).toMatch(/components_with_configs|component/);
+ } finally {
+ await session.close();
+ }
+ });
+
+ // Mirrors the Python parametrized test_get_configs_list_by_types: the seeded project has one
+ // extractor and one transformation config.
+ it.each([
+ { types: ['extractor'], expected: [EXTRACTOR_COMPONENT], absent: [TRANSFORMATION_COMPONENT] },
+ {
+ types: ['transformation'],
+ expected: [TRANSFORMATION_COMPONENT],
+ absent: [EXTRACTOR_COMPONENT],
+ },
+ {
+ types: ['application', 'extractor', 'transformation'],
+ expected: [EXTRACTOR_COMPONENT, TRANSFORMATION_COMPONENT],
+ absent: [],
+ },
+ { types: [], expected: [EXTRACTOR_COMPONENT, TRANSFORMATION_COMPONENT], absent: [] },
+ ])('get_configs filters by component types %o', async ({ types, expected, absent }) => {
+ const project = await getTestProjectForTest();
+ await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ const text = await callToolText(session.client, 'get_configs', { component_types: types });
+ for (const id of expected) expect(text).toContain(id);
+ for (const id of absent) expect(text).not.toContain(id);
+ } finally {
+ await session.close();
+ }
+ });
+
+ // --- get_components ------------------------------------------------------
+
+ it('get_components returns details for multiple components', async () => {
+ const project = await getTestProjectForTest();
+ await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ const text = await callToolText(session.client, 'get_components', {
+ component_ids: [EXTRACTOR_COMPONENT, TRANSFORMATION_COMPONENT],
+ });
+ expect(text).toContain(EXTRACTOR_COMPONENT);
+ expect(text).toContain(TRANSFORMATION_COMPONENT);
+ expect(text).toMatch(/component_name/);
+ expect(text).toMatch(/component_type/);
+ // both per-component links and output-level links should be present
+ expect(text).toMatch(/links/);
+ } finally {
+ await session.close();
+ }
+ });
+
+ // --- get_config_examples -------------------------------------------------
+
+ it('get_config_examples returns markdown examples for a component', async () => {
+ const project = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(project.config);
+ try {
+ const text = await callToolText(session.client, 'get_config_examples', {
+ component_id: EXTRACTOR_COMPONENT,
+ });
+ expect(text).toContain(`# Configuration Examples for \`${EXTRACTOR_COMPONENT}\``);
+ expect(text).toContain('parameters');
+ } finally {
+ await session.close();
+ }
+ });
+
+ it('get_config_examples returns empty string for an invalid component', async () => {
+ const project = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(project.config);
+ try {
+ const text = await callToolText(session.client, 'get_config_examples', {
+ component_id: 'completely-non-existent-component-12345',
+ });
+ expect(text).toBe('');
+ } finally {
+ await session.close();
+ }
+ });
+
+ // --- create_config -------------------------------------------------------
+
+ it('create_config creates a configuration with success metadata and links', async () => {
+ const project = await getTestProjectForTest();
+ const session = await connectMcp(project.config);
+ try {
+ const text = await callToolText(session.client, 'create_config', {
+ name: 'Test Configuration',
+ description: 'Test configuration created by automated test',
+ component_id: EXTRACTOR_COMPONENT,
+ parameters: {},
+ storage: {},
+ });
+ expect(text).toContain(EXTRACTOR_COMPONENT);
+ expect(text).toContain('Test configuration created by automated test');
+ expect(text).toMatch(/success/);
+ expect(text).toMatch(/true/);
+ // links: ui-detail + ui-dashboard for the created configuration
+ expect(text).toContain(`/components/${EXTRACTOR_COMPONENT}`);
+ expect(text).toContain('Configuration: Test Configuration');
+ } finally {
+ await session.close();
+ }
+ });
+
+ // --- add_config_row ------------------------------------------------------
+
+ it('add_config_row adds a row to a configuration', async () => {
+ const project = await getTestProjectForTest();
+ const session = await connectMcp(project.config);
+ try {
+ const rootText = await callToolText(session.client, 'create_config', {
+ name: 'Root Configuration for Row Test',
+ description: 'Root configuration created for row configuration test',
+ component_id: EXTRACTOR_COMPONENT,
+ parameters: {},
+ storage: {},
+ });
+ const configurationId = extractConfigurationId(rootText);
+
+ const rowText = await callToolText(session.client, 'add_config_row', {
+ name: 'Test Row Configuration',
+ description: 'Test row configuration created by automated test',
+ component_id: EXTRACTOR_COMPONENT,
+ configuration_id: configurationId,
+ parameters: { row_param: 'row_value' },
+ storage: {},
+ });
+ expect(rowText).toContain(EXTRACTOR_COMPONENT);
+ expect(rowText).toContain(configurationId);
+ expect(rowText).toContain('Test row configuration created by automated test');
+ expect(rowText).toMatch(/success/);
+ expect(rowText).toContain('Configuration: Test Row Configuration');
+ } finally {
+ await session.close();
+ }
+ });
+
+ // --- update_config -------------------------------------------------------
+
+ // Mirrors Python test_update_config parametrize cases (only the fields-affecting-output ones
+ // are asserted via text, since backend re-fetch is not available through the MCP client).
+ it.each([
+ {
+ label: 'all fields',
+ updates: {
+ name: 'Updated Test Configuration',
+ description: 'Updated test configuration by automated test',
+ parameter_updates: [{ op: 'set', path: 'updated_param', value: 'updated_value' }],
+ storage: { output: { tables: [{ source: 'output.csv', destination: 'out.c-bucket.table' }] } },
+ },
+ expectName: 'Updated Test Configuration',
+ expectDescription: 'Updated test configuration by automated test',
+ },
+ {
+ label: 'just name',
+ updates: { name: 'Updated just name' },
+ expectName: 'Updated just name',
+ expectDescription: 'Initial test configuration created by automated test',
+ },
+ {
+ label: 'just description',
+ updates: { description: 'Updated just description' },
+ expectName: 'Initial Test Configuration',
+ expectDescription: 'Updated just description',
+ },
+ {
+ label: 'just parameters',
+ updates: {
+ parameter_updates: [{ op: 'set', path: 'updated_param', value: 'Updated just parameters' }],
+ },
+ expectName: 'Initial Test Configuration',
+ expectDescription: 'Initial test configuration created by automated test',
+ },
+ {
+ label: 'just storage',
+ updates: {
+ storage: { output: { tables: [{ source: 'output.csv', destination: 'out.c-bucket.table' }] } },
+ },
+ expectName: 'Initial Test Configuration',
+ expectDescription: 'Initial test configuration created by automated test',
+ },
+ ])('update_config updates a configuration ($label)', async ({ updates, expectName, expectDescription }) => {
+ const project = await getTestProjectForTest();
+ const session = await connectMcp(project.config);
+ try {
+ const createText = await callToolText(session.client, 'create_config', {
+ name: 'Initial Test Configuration',
+ description: 'Initial test configuration created by automated test',
+ component_id: EXTRACTOR_COMPONENT,
+ parameters: { initial_param: 'initial_value' },
+ storage: { input: { tables: [{ source: 'in.c-bucket.table', destination: 'input.csv' }] } },
+ });
+ const configurationId = extractConfigurationId(createText);
+
+ const text = await callToolText(session.client, 'update_config', {
+ change_description: 'Integration test update',
+ component_id: EXTRACTOR_COMPONENT,
+ configuration_id: configurationId,
+ ...updates,
+ });
+ expect(text).toContain(EXTRACTOR_COMPONENT);
+ expect(text).toContain(configurationId);
+ expect(text).toMatch(/success/);
+ expect(text).toContain(expectDescription);
+ expect(text).toContain(`Configuration: ${expectName}`);
+ } finally {
+ await session.close();
+ }
+ });
+
+ // --- update_config_row ---------------------------------------------------
+
+ it.each([
+ {
+ label: 'all fields',
+ updates: {
+ name: 'Updated Row Configuration',
+ description: 'Updated row configuration by automated test',
+ parameter_updates: [{ op: 'set', path: '$', value: { updated_row_param: 'updated_row_value' } }],
+ storage: {},
+ },
+ expectName: 'Updated Row Configuration',
+ expectDescription: 'Updated row configuration by automated test',
+ },
+ {
+ label: 'just name',
+ updates: { name: 'Updated just name' },
+ expectName: 'Updated just name',
+ expectDescription: 'Initial row configuration for update test',
+ },
+ {
+ label: 'just description',
+ updates: { description: 'Updated just description' },
+ expectName: 'Initial Test Row Configuration',
+ expectDescription: 'Updated just description',
+ },
+ {
+ label: 'is_disabled',
+ updates: { is_disabled: true },
+ expectName: 'Initial Test Row Configuration',
+ expectDescription: 'Initial row configuration for update test',
+ },
+ ])('update_config_row updates a row configuration ($label)', async ({ updates, expectName, expectDescription }) => {
+ const project = await getTestProjectForTest();
+ const session = await connectMcp(project.config);
+ try {
+ const createText = await callToolText(session.client, 'create_config', {
+ name: 'Initial Test Configuration',
+ description: 'Initial test configuration created by automated test',
+ component_id: EXTRACTOR_COMPONENT,
+ parameters: { initial_param: 'initial_value' },
+ storage: {},
+ });
+ const configurationId = extractConfigurationId(createText);
+
+ // create the initial row
+ await callToolText(session.client, 'add_config_row', {
+ name: 'Initial Test Row Configuration',
+ description: 'Initial row configuration for update test',
+ component_id: EXTRACTOR_COMPONENT,
+ configuration_id: configurationId,
+ parameters: { initial_row_param: 'initial_row_value' },
+ storage: {},
+ });
+
+ // fetch the row id from the configuration detail (raw Storage API)
+ const rowId = await fetchFirstRowId(project, EXTRACTOR_COMPONENT, configurationId);
+
+ const text = await callToolText(session.client, 'update_config_row', {
+ change_description: 'Integration test update',
+ component_id: EXTRACTOR_COMPONENT,
+ configuration_id: configurationId,
+ configuration_row_id: rowId,
+ ...updates,
+ });
+ expect(text).toContain(EXTRACTOR_COMPONENT);
+ expect(text).toContain(configurationId);
+ expect(text).toMatch(/success/);
+ expect(text).toContain(expectDescription);
+ expect(text).toContain(`Configuration: ${expectName}`);
+ } finally {
+ await session.close();
+ }
+ });
+
+ // --- create_sql_transformation -------------------------------------------
+
+ it('create_sql_transformation creates a SQL transformation', async () => {
+ const project = await getTestProjectForTest({ backend: 'snowflake' });
+ const session = await connectMcp(project.config);
+ try {
+ const text = await callToolText(session.client, 'create_sql_transformation', {
+ name: 'Test SQL Transformation',
+ description: 'Test SQL transformation created by automated test',
+ sql_code_blocks: [
+ {
+ name: 'Main transformation',
+ script: 'SELECT 1 as test_column; SELECT 2 as another_column;',
+ },
+ ],
+ created_table_names: ['test_output_table'],
+ });
+ expect(text).toContain('Test SQL transformation created by automated test');
+ expect(text).toContain('keboola.snowflake-transformation');
+ expect(text).toMatch(/success/);
+ expect(text).toContain('Transformation: Test SQL Transformation');
+ expect(text).toContain('Transformations dashboard');
+ } finally {
+ await session.close();
+ }
+ });
+
+ // --- update_sql_transformation -------------------------------------------
+
+ it.each([
+ {
+ label: 'all fields',
+ updates: {
+ name: 'Updated SQL transformation name',
+ description: 'Updated SQL transformation description',
+ parameter_updates: [
+ { op: 'rename_block', block_id: 'b0', block_name: 'Updated block' },
+ { op: 'rename_code', block_id: 'b0', code_id: 'b0.c0', code_name: 'Updated code' },
+ {
+ op: 'set_code',
+ block_id: 'b0',
+ code_id: 'b0.c0',
+ script:
+ 'SELECT 1 as updated_column;\n\nSELECT 2 as additional_column;\n\nSELECT 3 as third_column;\n\n',
+ },
+ ],
+ storage: {
+ input: { tables: [{ source: 'in.c-bucket.input_table', destination: 'input.csv' }] },
+ output: {
+ tables: [
+ { source: 'updated_output_table', destination: 'out.c-bucket.updated_output_table' },
+ { source: 'second_output_table', destination: 'out.c-bucket.second_output_table' },
+ ],
+ },
+ },
+ },
+ expectName: 'Updated SQL transformation name',
+ expectDescription: 'Updated SQL transformation description',
+ },
+ {
+ label: 'just name',
+ updates: { name: 'Updated SQL transformation name' },
+ expectName: 'Updated SQL transformation name',
+ expectDescription: 'Initial SQL transformation for update test',
+ },
+ {
+ label: 'just description',
+ updates: { description: 'Updated SQL transformation description' },
+ expectName: 'Initial Test SQL Transformation',
+ expectDescription: 'Updated SQL transformation description',
+ },
+ {
+ label: 'just parameters',
+ updates: {
+ parameter_updates: [
+ {
+ op: 'str_replace',
+ block_id: 'b0',
+ code_id: 'b0.c0',
+ search_for: 'SELECT 1',
+ replace_with: 'SELECT 12',
+ },
+ {
+ op: 'add_script',
+ block_id: 'b0',
+ code_id: 'b0.c0',
+ script: 'SELECT 2 as additional_column',
+ position: 'end',
+ },
+ ],
+ },
+ expectName: 'Initial Test SQL Transformation',
+ expectDescription: 'Initial SQL transformation for update test',
+ },
+ {
+ label: 'just storage',
+ updates: {
+ storage: {
+ input: { tables: [{ source: 'in.c-bucket.input_table', destination: 'input.csv' }] },
+ output: {
+ tables: [
+ { source: 'updated_output_table', destination: 'out.c-bucket.updated_output_table' },
+ { source: 'second_output_table', destination: 'out.c-bucket.second_output_table' },
+ ],
+ },
+ },
+ },
+ expectName: 'Initial Test SQL Transformation',
+ expectDescription: 'Initial SQL transformation for update test',
+ },
+ ])('update_sql_transformation updates a transformation ($label)', async ({ updates, expectName, expectDescription }) => {
+ const project = await getTestProjectForTest({ backend: 'snowflake' });
+ const session = await connectMcp(project.config);
+ try {
+ const createText = await callToolText(session.client, 'create_sql_transformation', {
+ name: 'Initial Test SQL Transformation',
+ description: 'Initial SQL transformation for update test',
+ sql_code_blocks: [{ name: 'Initial transformation', script: 'SELECT 1 as initial_column;' }],
+ created_table_names: ['initial_output_table'],
+ });
+ const configurationId = extractConfigurationId(createText);
+
+ const text = await callToolText(session.client, 'update_sql_transformation', {
+ change_description: 'Integration test update',
+ configuration_id: configurationId,
+ ...updates,
+ });
+ expect(text).toContain('keboola.snowflake-transformation');
+ expect(text).toContain(configurationId);
+ expect(text).toMatch(/success/);
+ expect(text).toContain(expectDescription);
+ expect(text).toContain(`Transformation: ${expectName}`);
+ } finally {
+ await session.close();
+ }
+ });
+});
+
+/** Pulls the configuration_id out of a TOON ConfigToolOutput text blob. */
+function extractConfigurationId(text: string): string {
+ const match = text.match(/configuration_id:\s*([^\s,]+)/);
+ if (!match) throw new Error(`Could not find configuration_id in tool output:\n${text}`);
+ return match[1]!.replace(/["']/g, '');
+}
+
+/** Reads the first row id of a configuration via the raw Storage API. */
+async function fetchFirstRowId(
+ project: { storageApiUrl: string; storageApiToken: string },
+ componentId: string,
+ configurationId: string,
+): Promise {
+ const res = await fetch(
+ `${project.storageApiUrl}/v2/storage/branch/default/components/${componentId}/configs/${configurationId}`,
+ { headers: { 'X-StorageApi-Token': project.storageApiToken } },
+ );
+ if (!res.ok) throw new Error(`Failed to fetch config detail: ${res.status} ${await res.text()}`);
+ const detail = (await res.json()) as { rows?: { id: string }[] };
+ const rows = detail.rows ?? [];
+ if (rows.length === 0) throw new Error('No rows found in configuration');
+ return String(rows[0]!.id);
+}
diff --git a/integtests/tools/components/__init__.py b/integtests/tools/components/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/integtests/tools/components/test_tools.py b/integtests/tools/components/test_tools.py
deleted file mode 100644
index d73d4cf69..000000000
--- a/integtests/tools/components/test_tools.py
+++ /dev/null
@@ -1,1052 +0,0 @@
-import logging
-from typing import Any, AsyncGenerator, cast
-
-import pytest
-import pytest_asyncio
-import toon_format
-from fastmcp import Client, Context
-from pydantic import TypeAdapter
-
-from integtests.conftest import ConfigDef, ProjectDef
-from keboola_mcp_server.clients.client import KeboolaClient, get_metadata_property
-from keboola_mcp_server.config import MetadataField
-from keboola_mcp_server.links import Link
-from keboola_mcp_server.tools.components import (
- add_config_row,
- create_config,
- create_sql_transformation,
- get_components,
- get_config_examples,
- get_configs,
-)
-from keboola_mcp_server.tools.components.model import (
- ComponentType,
- ComponentWithConfigs,
- ConfigParamUpdate,
- ConfigToolOutput,
- Configuration,
- FullConfigId,
- GetComponentsOutput,
- GetConfigsDetailOutput,
- GetConfigsListOutput,
- SimplifiedTfBlocks,
- TfAddScript,
- TfParamUpdate,
- TfRenameBlock,
- TfRenameCode,
- TfSetCode,
- TfStrReplace,
- TransformationConfiguration,
-)
-from keboola_mcp_server.tools.components.sql_utils import split_sql_statements
-from keboola_mcp_server.tools.components.utils import (
- clean_bucket_name,
- expand_component_types,
- get_sql_transformation_id_from_sql_dialect,
- update_params,
- update_transformation_parameters,
-)
-from keboola_mcp_server.workspace import WorkspaceManager
-
-LOG = logging.getLogger(__name__)
-
-
-@pytest.mark.asyncio
-async def test_get_configs_detail(mcp_context: Context, configs: list[ConfigDef]):
- """Tests that `get_configs` with specific configs returns detailed `Configuration` instances."""
-
- for config in configs:
- assert config.configuration_id is not None
-
- result = await get_configs(
- ctx=mcp_context,
- configs=[FullConfigId(component_id=config.component_id, configuration_id=config.configuration_id)],
- )
-
- assert isinstance(result, GetConfigsDetailOutput)
- assert len(result.configs) == 1
-
- configuration = result.configs[0]
- assert isinstance(configuration, Configuration)
- assert configuration.component is not None
- assert configuration.component.component_id == config.component_id
- assert configuration.component.component_type is not None
- assert configuration.component.component_name is not None
-
- assert configuration.configuration_root is not None
- assert configuration.configuration_root.configuration_id == config.configuration_id
- assert configuration.configuration_root.component_id == config.component_id
- # Check links field
- assert configuration.links, 'Links list should not be empty.'
- for link in configuration.links:
- assert isinstance(link, Link)
-
-
-@pytest.mark.asyncio
-async def test_get_configs_list_by_ids(mcp_context: Context, configs: list[ConfigDef]):
- """Tests that `get_configs` returns components filtered by component IDs."""
-
- # Get unique component IDs from test configs
- component_ids = list({config.component_id for config in configs})
- assert len(component_ids) > 0
-
- result = await get_configs(ctx=mcp_context, component_ids=component_ids)
-
- # Verify result structure and content
- assert isinstance(result, GetConfigsListOutput)
- assert len(result.components_with_configs) == len(component_ids)
-
- for item in result.components_with_configs:
- assert isinstance(item, ComponentWithConfigs)
- assert item.component.component_id in component_ids
-
- # Check that configurations belong to this component
- for config in item.configs:
- assert config.configuration_root.component_id == item.component.component_id
-
-
-@pytest.mark.skip(reason='bug in toon_format library')
-@pytest.mark.asyncio
-async def test_get_configs_output_format(mcp_client: Client, configs: list[ConfigDef]):
- """Tests that `get_configs` returns the tool output in TOON format."""
- # Temporarily skip this test due to bug in the toon-format library:
- # See: https://github.com/toon-format/toon-python/pull/36
- # The bug creates TOON which not valid according to the TOON specs but still readable to the agents.
- component_ids = list({config.component_id for config in configs})
- assert len(component_ids) > 0
-
- tool_result = await mcp_client.call_tool(name='get_configs', arguments={'component_ids': component_ids})
-
- # Verify structured content
- assert tool_result.structured_content is not None
- result = GetConfigsListOutput.model_validate(tool_result.structured_content)
- assert len(result.components_with_configs) > 0
-
- # Verify TOON formatted text content matches structured content
- assert len(tool_result.content) == 1
- assert tool_result.content[0].type == 'text'
- toon_decoded = toon_format.decode(tool_result.content[0].text)
- assert GetConfigsListOutput.model_validate(toon_decoded) == result
-
-
-@pytest.mark.parametrize(
- ('component_types', 'expected_count'),
- [
- (['extractor'], 1),
- (['transformation'], 1),
- (['application', 'extractor', 'transformation'], 2),
- ([], 2),
- ],
-)
-@pytest.mark.asyncio
-async def test_get_configs_list_by_types(
- mcp_context: Context, configs: list[ConfigDef], component_types: list[ComponentType], expected_count: int
-):
- """Tests that `get_configs` returns components filtered by component types."""
-
- result = await get_configs(ctx=mcp_context, component_types=component_types)
-
- assert isinstance(result, GetConfigsListOutput)
-
- assert sum(len(cmp.configs) for cmp in result.components_with_configs) == expected_count
-
- for item in result.components_with_configs:
- assert isinstance(item, ComponentWithConfigs)
- assert item.component.component_type in expand_component_types(component_types)
-
-
-@pytest.mark.asyncio
-async def test_create_config(
- mcp_context: Context, configs: list[ConfigDef], keboola_project: ProjectDef, storage_api_url: str
-):
- """Tests that `create_config` creates a configuration with correct metadata."""
-
- # Use the first component from configs for testing
- test_config = configs[0]
- component_id = test_config.component_id
-
- # Define test configuration data
- test_name = 'Test Configuration'
- test_description = 'Test configuration created by automated test'
- test_parameters = {}
- test_storage = {}
-
- client = KeboolaClient.from_state(mcp_context.session.state)
-
- project_id = keboola_project.project_id
-
- # Create the configuration
- created_config = await create_config(
- ctx=mcp_context,
- name=test_name,
- description=test_description,
- component_id=component_id,
- parameters=test_parameters,
- storage=test_storage,
- )
- try:
- # Verify the response structure
- assert isinstance(created_config, ConfigToolOutput)
- assert created_config.component_id == component_id
- assert created_config.configuration_id is not None
- assert created_config.description == test_description
- assert created_config.success is True
- assert created_config.timestamp is not None
- assert created_config.version is not None
- assert frozenset(created_config.links) == frozenset(
- [
- Link(
- type='ui-detail',
- title=f'Configuration: {test_name}',
- url=(
- f'{storage_api_url}/admin/projects/{project_id}/components/{component_id}/'
- + f'{created_config.configuration_id}'
- ),
- ),
- Link(
- type='ui-dashboard',
- title=f'Component "{component_id}" Configurations Dashboard',
- url=f'{storage_api_url}/admin/projects/{project_id}/components/{component_id}',
- ),
- ]
- )
-
- # Verify the configuration exists in the backend by fetching it
- config_detail = await client.storage_client.configuration_detail(
- component_id=component_id, configuration_id=created_config.configuration_id
- )
-
- assert config_detail['name'] == test_name
- assert config_detail['description'] == test_description
- assert 'configuration' in config_detail
-
- # Verify the parameters and storage were set correctly
- configuration_data = cast(dict, config_detail['configuration'])
- assert configuration_data['parameters'] == test_parameters
- assert configuration_data['storage'] == test_storage
-
- # Verify the metadata - check that KBC.MCP.createdBy is set to 'true'
- metadata = await client.storage_client.configuration_metadata_get(
- component_id=component_id, configuration_id=created_config.configuration_id
- )
-
- # Convert metadata list to dictionary for easier checking
- # metadata is a list of dicts with 'key' and 'value' keys
- assert isinstance(metadata, list)
- metadata_dict = {item['key']: item['value'] for item in metadata if isinstance(item, dict)}
- assert MetadataField.CREATED_BY_MCP in metadata_dict
- assert metadata_dict[MetadataField.CREATED_BY_MCP] == 'true'
-
- finally:
- # Clean up: Delete the configuration
- await client.storage_client.configuration_delete(
- component_id=component_id,
- configuration_id=created_config.configuration_id,
- skip_trash=True,
- )
-
-
-@pytest_asyncio.fixture
-async def initial_cmpconf(
- mcp_client: Client, configs: list[ConfigDef], keboola_client: KeboolaClient
-) -> AsyncGenerator[ConfigToolOutput, None]:
- # Create the initial component configuration test data
- tool_result = await mcp_client.call_tool(
- name='create_config',
- arguments={
- 'name': 'Initial Test Configuration',
- 'description': 'Initial test configuration created by automated test',
- 'component_id': configs[0].component_id,
- 'parameters': {'initial_param': 'initial_value'},
- 'storage': {'input': {'tables': [{'source': 'in.c-bucket.table', 'destination': 'input.csv'}]}},
- },
- )
- try:
- yield ConfigToolOutput.model_validate(tool_result.structured_content)
- finally:
- # Clean up: Delete the configuration
- await keboola_client.storage_client.configuration_delete(
- component_id=configs[0].component_id,
- configuration_id=tool_result.structured_content['configuration_id'],
- skip_trash=True,
- )
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize(
- 'updates',
- [
- {
- 'name': 'Updated Test Configuration',
- 'description': 'Updated test configuration by automated test',
- 'parameter_updates': [{'op': 'set', 'path': 'updated_param', 'value': 'updated_value'}],
- 'storage': {'output': {'tables': [{'source': 'output.csv', 'destination': 'out.c-bucket.table'}]}},
- },
- {'name': 'Updated just name'},
- {'description': 'Updated just description'},
- {'parameter_updates': [{'op': 'set', 'path': 'updated_param', 'value': 'Updated just parameters'}]},
- {'storage': {'output': {'tables': [{'source': 'output.csv', 'destination': 'out.c-bucket.table'}]}}},
- ],
-)
-async def test_update_config(
- updates: dict[str, Any],
- initial_cmpconf: ConfigToolOutput,
- mcp_client: Client,
- keboola_project: ProjectDef,
- keboola_client: KeboolaClient,
- storage_api_url: str,
-):
- """Tests that `update_config` updates a configuration with correct metadata."""
- project_id = keboola_project.project_id
- component_id = initial_cmpconf.component_id
- configuration_id = initial_cmpconf.configuration_id
- param_update_dicts = updates.get('parameter_updates')
-
- if param_update_dicts is not None:
- # Get the original configuration so we can compare the parameters
- orig_config = await keboola_client.storage_client.configuration_detail(
- component_id=component_id, configuration_id=configuration_id
- )
- orig_parameters = cast(dict, orig_config.get('configuration', {}).get('parameters', {}))
-
- # Convert the parameter update dicts to ConfigParamUpdate objects
- param_updates = []
- for update_dict in param_update_dicts:
- update = TypeAdapter(ConfigParamUpdate).validate_python(update_dict)
- param_updates.append(update)
-
- tool_result = await mcp_client.call_tool(
- name='update_config',
- arguments={
- 'change_description': 'Integration test update',
- 'component_id': component_id,
- 'configuration_id': configuration_id,
- **updates,
- },
- )
-
- # Check the tool's output
- update_result = ConfigToolOutput.model_validate(tool_result.structured_content)
- assert update_result.component_id == component_id
- assert update_result.configuration_id == configuration_id
- assert update_result.success is True
- assert update_result.timestamp is not None
- assert update_result.version is not None
-
- expected_name = updates.get('name') or 'Initial Test Configuration'
- expected_description = updates.get('description') or initial_cmpconf.description
- assert update_result.description == expected_description
- assert frozenset(update_result.links) == frozenset(
- [
- Link(
- type='ui-detail',
- title=f'Configuration: {expected_name}',
- url=f'{storage_api_url}/admin' f'/projects/{project_id}/components/{component_id}/{configuration_id}',
- ),
- Link(
- type='ui-dashboard',
- title=f'Component "{component_id}" Configurations Dashboard',
- url=f'{storage_api_url}/admin/projects/{project_id}/components/{component_id}',
- ),
- ]
- )
-
- # Verify the configuration was updated
- updated_config = await keboola_client.storage_client.configuration_detail(
- component_id=update_result.component_id, configuration_id=update_result.configuration_id
- )
-
- assert updated_config['name'] == expected_name
- assert updated_config['description'] == expected_description
-
- updated_config_data = updated_config.get('configuration')
- assert isinstance(updated_config_data, dict), f'Expecting dict, got: {type(updated_config_data)}'
-
- if param_update_dicts is not None:
- expected_parameters = update_params(orig_parameters, param_updates)
- assert updated_config_data['parameters'] == expected_parameters
-
- if (expected_storage := updates.get('storage')) is not None:
- # Storage API might return more keys than what we set, so we check subset
- for k, v in expected_storage.items():
- assert k in updated_config_data['storage']
- assert updated_config_data['storage'][k] == v
-
- current_version = updated_config['version']
- assert isinstance(current_version, int), f'Expecting int, got: {type(current_version)}'
- assert current_version == 2
-
- # Check that KBC.MCP.updatedBy.version.{version} is set to 'true'
- metadata = await keboola_client.storage_client.configuration_metadata_get(
- component_id=update_result.component_id, configuration_id=update_result.configuration_id
- )
- assert isinstance(metadata, list), f'Expecting list, got: {type(metadata)}'
-
- meta_key = f'{MetadataField.UPDATED_BY_MCP_PREFIX}{current_version}'
- meta_value = get_metadata_property(metadata, meta_key)
- assert meta_value == 'true'
- # Check that the original creation metadata is still there
- assert get_metadata_property(metadata, MetadataField.CREATED_BY_MCP) == 'true'
-
-
-@pytest.mark.asyncio
-async def test_add_config_row(
- mcp_context: Context, configs: list[ConfigDef], keboola_project: ProjectDef, storage_api_url: str
-):
- """Tests that `add_config_row` creates a row configuration with correct metadata."""
-
- # Use the first component from configs for testing
- test_config = configs[0]
- component_id = test_config.component_id
-
- # Define root configuration test data
- root_config_name = 'Root Configuration for Row Test'
- root_config_description = 'Root configuration created for row configuration test'
- root_config_parameters = {}
- root_config_storage = {}
-
- # Define row configuration test data
- row_name = 'Test Row Configuration'
- row_description = 'Test row configuration created by automated test'
- row_parameters = {'row_param': 'row_value'}
- row_storage = {}
-
- client = KeboolaClient.from_state(mcp_context.session.state)
-
- project_id = keboola_project.project_id
-
- # First create a root configuration to add row to
- root_config = await create_config(
- ctx=mcp_context,
- name=root_config_name,
- description=root_config_description,
- component_id=component_id,
- parameters=root_config_parameters,
- storage=root_config_storage,
- )
-
- try:
-
- # Create the row configuration
- created_row_config = await add_config_row(
- ctx=mcp_context,
- name=row_name,
- description=row_description,
- component_id=component_id,
- configuration_id=root_config.configuration_id,
- parameters=row_parameters,
- storage=row_storage,
- )
-
- assert isinstance(created_row_config, ConfigToolOutput)
- assert created_row_config.success is True
- assert created_row_config.timestamp is not None
- assert created_row_config.description == row_description
- assert created_row_config.component_id == component_id
- assert created_row_config.configuration_id == root_config.configuration_id
- assert created_row_config.version is not None
- assert frozenset(created_row_config.links) == frozenset(
- [
- Link(
- type='ui-detail',
- title=f'Configuration: {row_name}',
- url=(
- f'{storage_api_url}/admin/projects/{project_id}/components/{component_id}/'
- + f'{root_config.configuration_id}'
- ),
- ),
- Link(
- type='ui-dashboard',
- title=f'Component "{component_id}" Configurations Dashboard',
- url=f'{storage_api_url}/admin/projects/{project_id}/components/{component_id}',
- ),
- ]
- )
-
- # Verify the row configuration exists by fetching the root configuration and checking its rows
- config_detail = await client.storage_client.configuration_detail(
- component_id=component_id, configuration_id=root_config.configuration_id
- )
-
- assert 'rows' in config_detail
- rows = cast(list, config_detail['rows'])
- assert len(rows) == 1
-
- # Find the row we just created
- created_row = None
- for row in rows:
- if isinstance(row, dict) and row.get('name') == row_name:
- created_row = row
- break
-
- assert created_row is not None
- assert created_row['description'] == row_description
- assert 'configuration' in created_row
-
- # Verify the parameters and storage were set correctly
- row_configuration_data = cast(dict, created_row['configuration'])
- assert row_configuration_data['parameters'] == row_parameters
- assert row_configuration_data['storage'] == row_storage
-
- # Verify metadata was set for the parent configuration
- metadata = await client.storage_client.configuration_metadata_get(
- component_id=component_id, configuration_id=root_config.configuration_id
- )
-
- assert isinstance(metadata, list)
- metadata_dict = {item['key']: item['value'] for item in metadata if isinstance(item, dict)}
- # The updated metadata should be present since we added a row to the configuration
- updated_by_md_keys = [
- key
- for key in metadata_dict.keys()
- if isinstance(key, str) and key.startswith(MetadataField.UPDATED_BY_MCP_PREFIX)
- ]
- assert len(updated_by_md_keys) > 0
-
- finally:
- # Delete the configuration (this will also delete the rows)
- await client.storage_client.configuration_delete(
- component_id=component_id,
- configuration_id=root_config.configuration_id,
- skip_trash=True,
- )
-
-
-@pytest_asyncio.fixture
-async def initial_cmpconf_row(
- initial_cmpconf: ConfigToolOutput, mcp_client: Client, keboola_client: KeboolaClient
-) -> ConfigToolOutput:
- # Create initial row configuration test data
- tool_result = await mcp_client.call_tool(
- name='add_config_row',
- arguments={
- 'name': 'Initial Test Row Configuration',
- 'description': 'Initial row configuration for update test',
- 'component_id': initial_cmpconf.component_id,
- 'configuration_id': initial_cmpconf.configuration_id,
- 'parameters': {'initial_row_param': 'initial_row_value'},
- 'storage': {},
- },
- )
- return ConfigToolOutput.model_validate(tool_result.structured_content)
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize(
- 'updates',
- [
- {
- 'name': 'Updated Row Configuration',
- 'description': 'Updated row configuration by automated test',
- 'parameter_updates': [{'op': 'set', 'path': '$', 'value': {'updated_row_param': 'updated_row_value'}}],
- 'storage': {},
- },
- {'name': 'Updated just name'},
- {'description': 'Updated just description'},
- {'parameter_updates': [{'op': 'set', 'path': '$', 'value': {'updated_row_param': 'Updated just parameters'}}]},
- {'storage': {'output': {'tables': [{'source': 'output.csv', 'destination': 'out.c-bucket.table'}]}}},
- {'is_disabled': True},
- ],
-)
-async def test_update_config_row(
- updates: dict[str, Any],
- initial_cmpconf_row: ConfigToolOutput,
- mcp_client: Client,
- keboola_project: ProjectDef,
- keboola_client: KeboolaClient,
- storage_api_url: str,
-):
- """Tests that `update_config_row` updates a row configuration with correct metadata."""
- project_id = keboola_project.project_id
- component_id = initial_cmpconf_row.component_id
- configuration_id = initial_cmpconf_row.configuration_id
-
- # Get the row ID from the configuration detail
- config_detail = await keboola_client.storage_client.configuration_detail(
- component_id=component_id, configuration_id=configuration_id
- )
- rows = config_detail['rows']
- assert isinstance(rows, list)
- assert len(rows) == 1
- row_id = rows[0]['id']
-
- tool_result = await mcp_client.call_tool(
- name='update_config_row',
- arguments={
- 'change_description': 'Integration test update',
- 'component_id': component_id,
- 'configuration_id': configuration_id,
- 'configuration_row_id': row_id,
- **updates,
- },
- )
-
- # Check the tool's output
- updated_row_config = ConfigToolOutput.model_validate(tool_result.structured_content)
- assert updated_row_config.component_id == component_id
- assert updated_row_config.configuration_id == configuration_id
- assert updated_row_config.success is True
- assert updated_row_config.timestamp is not None
- assert updated_row_config.version is not None
-
- expected_row_name = updates.get('name') or 'Initial Test Row Configuration'
- expected_row_description = updates.get('description') or initial_cmpconf_row.description
- expected_row_is_disabled = updates.get('is_disabled') or False
- assert updated_row_config.description == expected_row_description
- assert frozenset(updated_row_config.links) == frozenset(
- [
- Link(
- type='ui-detail',
- title=f'Configuration: {expected_row_name}',
- url=f'{storage_api_url}/admin' f'/projects/{project_id}/components/{component_id}/{configuration_id}',
- ),
- Link(
- type='ui-dashboard',
- title=f'Component "{component_id}" Configurations Dashboard',
- url=f'{storage_api_url}/admin/projects/{project_id}/components/{component_id}',
- ),
- ]
- )
-
- # Verify the row configuration was updated
- row_config_detail = await keboola_client.storage_client.configuration_detail(
- component_id=updated_row_config.component_id, configuration_id=updated_row_config.configuration_id
- )
- updated_rows = row_config_detail['rows']
- assert isinstance(updated_rows, list), f'Expecting list, got: {type(updated_rows)}'
- # Find the updated row
- updated_row = next(filter(lambda x: x.get('id') == row_id, updated_rows), None)
- assert updated_row, f'No row for row_id: {row_id}'
-
- assert isinstance(updated_row, dict), f'Expecting dict, got: {type(updated_row)}'
- assert updated_row['name'] == expected_row_name
- assert updated_row['description'] == expected_row_description
- assert updated_row['isDisabled'] == expected_row_is_disabled
-
- row_config_data = updated_row['configuration']
- assert isinstance(row_config_data, dict), f'Expecting dict, got: {type(row_config_data)}'
-
- if (parameter_updates := updates.get('parameter_updates')) is not None:
- # Using the assumption that parameter_updates is a list with one element with 'set' operation on root path
- assert row_config_data['parameters'] == parameter_updates[0]['value']
-
- if (expected_storage := updates.get('storage')) is not None:
- # Storage API might return more keys than what we set, so we check subset
- for k, v in expected_storage.items():
- assert k in row_config_data['storage']
- assert row_config_data['storage'][k] == v
-
- current_version = config_detail['version']
- assert isinstance(current_version, int), f'Expecting int, got: {type(current_version)}'
- assert current_version == 2
-
- # Check that KBC.MCP.updatedBy.version.{version} is set to 'true'
- metadata = await keboola_client.storage_client.configuration_metadata_get(
- component_id=updated_row_config.component_id, configuration_id=updated_row_config.configuration_id
- )
- assert isinstance(metadata, list), f'Expecting list, got: {type(metadata)}'
-
- meta_key = f'{MetadataField.UPDATED_BY_MCP_PREFIX}{current_version}'
- meta_value = get_metadata_property(metadata, meta_key)
- assert meta_value == 'true'
- # Check that the original creation metadata is still there
- assert get_metadata_property(metadata, MetadataField.CREATED_BY_MCP) == 'true'
-
-
-@pytest.mark.asyncio
-async def test_create_sql_transformation(mcp_context: Context, keboola_project: ProjectDef, storage_api_url: str):
- """Tests that `create_sql_transformation` creates a SQL transformation with correct configuration."""
-
- test_name = 'Test SQL Transformation'
- test_description = 'Test SQL transformation created by automated test'
-
- # Define test SQL code blocks
- test_sql_code_blocks = [
- SimplifiedTfBlocks.Block.Code(
- name='Main transformation', script='SELECT 1 as test_column; SELECT 2 as another_column;'
- )
- ]
-
- test_created_table_names = ['test_output_table']
-
- client = KeboolaClient.from_state(mcp_context.session.state)
-
- # Create the SQL transformation
- created_transformation = await create_sql_transformation(
- ctx=mcp_context,
- name=test_name,
- description=test_description,
- sql_code_blocks=test_sql_code_blocks,
- created_table_names=test_created_table_names,
- )
- sql_dialect = await WorkspaceManager.from_state(mcp_context.session.state).get_sql_dialect()
- expected_component_id = get_sql_transformation_id_from_sql_dialect(sql_dialect)
- project_id = keboola_project.project_id
-
- try:
- # Verify the response structure
- assert isinstance(created_transformation, ConfigToolOutput)
- assert created_transformation.success is True
- assert created_transformation.timestamp is not None
- assert created_transformation.description == test_description
- assert created_transformation.component_id == expected_component_id
- assert created_transformation.configuration_id is not None
- assert created_transformation.version is not None
- expected_links = frozenset(
- [
- Link(
- type='ui-detail',
- title=f'Transformation: {test_name}',
- url=(
- f'{storage_api_url}/admin/projects/{project_id}/transformations-v2/'
- f'{expected_component_id}/{created_transformation.configuration_id}'
- ),
- ),
- Link(
- type='ui-dashboard',
- title='Transformations dashboard',
- url=(f'{storage_api_url}/admin/projects/{project_id}/transformations-v2'),
- ),
- ]
- )
-
- assert frozenset(created_transformation.links) == expected_links
-
- # Verify the configuration exists in the backend by fetching it
- config_detail = await client.storage_client.configuration_detail(
- component_id=created_transformation.component_id, configuration_id=created_transformation.configuration_id
- )
-
- assert config_detail['name'] == test_name
- assert config_detail['description'] == test_description
- assert 'configuration' in config_detail
-
- # Verify the configuration structure
- configuration_data = cast(dict, config_detail['configuration'])
- assert 'parameters' in configuration_data
- assert 'storage' in configuration_data
-
- # Verify the parameters structure matches expected
- bucket_name = clean_bucket_name(test_name)
- expected_script = test_sql_code_blocks[0].script
- expected_script = await split_sql_statements(expected_script)
- expected_parameters = {
- 'blocks': [
- {
- 'name': 'Blocks',
- 'codes': [
- {
- 'name': test_sql_code_blocks[0].name,
- 'script': expected_script,
- }
- ],
- }
- ]
- }
- assert configuration_data['parameters'] == expected_parameters
-
- # Verify the storage structure matches expected
- expected_storage = {
- 'input': {'tables': []},
- 'output': {
- 'tables': [
- {
- 'source': test_created_table_names[0],
- 'destination': f'out.c-{bucket_name}.{test_created_table_names[0]}',
- }
- ]
- },
- }
- assert configuration_data['storage'] == expected_storage
-
- # Verify the metadata - check that KBC.MCP.createdBy is set to 'true'
- metadata = await client.storage_client.configuration_metadata_get(
- component_id=created_transformation.component_id, configuration_id=created_transformation.configuration_id
- )
-
- # Convert metadata list to dictionary for easier checking
- assert isinstance(metadata, list)
- metadata_dict = {item['key']: item['value'] for item in metadata if isinstance(item, dict)}
- assert MetadataField.CREATED_BY_MCP in metadata_dict
- assert metadata_dict[MetadataField.CREATED_BY_MCP] == 'true'
-
- finally:
- # Clean up: Delete the configuration
- await client.storage_client.configuration_delete(
- component_id=created_transformation.component_id,
- configuration_id=created_transformation.configuration_id,
- skip_trash=True,
- )
-
-
-@pytest_asyncio.fixture
-async def initial_sqltrfm(
- mcp_client: Client, configs: list[ConfigDef], keboola_client: KeboolaClient
-) -> AsyncGenerator[ConfigToolOutput, None]:
- # Create the initial component configuration test data
- tool_result = await mcp_client.call_tool(
- name='create_sql_transformation',
- arguments={
- 'name': 'Initial Test SQL Transformation',
- 'description': 'Initial SQL transformation for update test',
- 'sql_code_blocks': [{'name': 'Initial transformation', 'script': 'SELECT 1 as initial_column;'}],
- 'created_table_names': ['initial_output_table'],
- },
- )
- try:
- yield ConfigToolOutput.model_validate(tool_result.structured_content)
- finally:
- # Clean up: Delete the configuration
- await keboola_client.storage_client.configuration_delete(
- component_id=tool_result.structured_content['component_id'],
- configuration_id=tool_result.structured_content['configuration_id'],
- skip_trash=True,
- )
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize(
- 'updates',
- [
- {
- 'name': 'Updated SQL transformation name',
- 'description': 'Updated SQL transformation description',
- 'parameter_updates': [
- TfRenameBlock(op='rename_block', block_id='b0', block_name='Updated block'),
- TfRenameCode(op='rename_code', block_id='b0', code_id='b0.c0', code_name='Updated code'),
- TfSetCode(
- op='set_code',
- block_id='b0',
- code_id='b0.c0',
- script=(
- 'SELECT 1 as updated_column;\n\nSELECT 2 as additional_column;\n\n'
- 'SELECT 3 as third_column;\n\n'
- ),
- ),
- ],
- 'storage': {
- 'input': {'tables': [{'source': 'in.c-bucket.input_table', 'destination': 'input.csv'}]},
- 'output': {
- 'tables': [
- {'source': 'updated_output_table', 'destination': 'out.c-bucket.updated_output_table'},
- {'source': 'second_output_table', 'destination': 'out.c-bucket.second_output_table'},
- ]
- },
- },
- },
- {'name': 'Updated SQL transformation name'},
- {'description': 'Updated SQL transformation description'},
- {
- 'parameter_updates': [
- TfStrReplace(
- op='str_replace',
- block_id='b0',
- code_id='b0.c0',
- search_for='SELECT 1',
- replace_with='SELECT 12',
- ),
- TfAddScript(
- op='add_script',
- block_id='b0',
- code_id='b0.c0',
- script='SELECT 2 as additional_column',
- position='end',
- ),
- ]
- },
- {
- 'storage': {
- 'input': {'tables': [{'source': 'in.c-bucket.input_table', 'destination': 'input.csv'}]},
- 'output': {
- 'tables': [
- {'source': 'updated_output_table', 'destination': 'out.c-bucket.updated_output_table'},
- {'source': 'second_output_table', 'destination': 'out.c-bucket.second_output_table'},
- ]
- },
- }
- },
- ],
-)
-async def test_update_sql_transformation(
- updates: dict[str, Any],
- initial_sqltrfm: ConfigToolOutput,
- mcp_client: Client,
- keboola_project: ProjectDef,
- keboola_client: KeboolaClient,
- storage_api_url: str,
-):
- """Tests that `update_sql_transformation` updates an existing SQL transformation correctly."""
- project_id = keboola_project.project_id
- component_id = initial_sqltrfm.component_id
- configuration_id = initial_sqltrfm.configuration_id
- param_update_objects = updates.get('parameter_updates')
-
- if param_update_objects is not None:
- # Get the original configuration so we can compare the parameters
- orig_config = await keboola_client.storage_client.configuration_detail(
- component_id=component_id, configuration_id=configuration_id
- )
- orig_parameters_dict = cast(dict, orig_config.get('configuration', {}).get('parameters', {}))
-
- # Convert the parameter update objects to TfParamUpdate if needed
- param_updates: list[TfParamUpdate] = []
- for update_obj in param_update_objects:
- if isinstance(update_obj, dict):
- update = TypeAdapter(TfParamUpdate).validate_python(update_obj)
- else:
- update = update_obj
- param_updates.append(update)
-
- # Convert parameter update objects to dict format for tool call if needed
- updates_dict = updates.copy()
- if param_update_objects is not None:
- param_updates_list = []
- for update_obj in param_update_objects:
- if isinstance(update_obj, dict):
- param_updates_list.append(update_obj)
- else:
- # Convert Pydantic model to dict
- param_updates_list.append(update_obj.model_dump())
- updates_dict['parameter_updates'] = param_updates_list
-
- tool_result = await mcp_client.call_tool(
- name='update_sql_transformation',
- arguments={
- 'change_description': 'Integration test update',
- 'configuration_id': configuration_id,
- **updates_dict,
- },
- )
-
- # Check the tool's output
- updated_trfm = ConfigToolOutput.model_validate(tool_result.structured_content)
-
- assert updated_trfm.component_id == component_id
- assert updated_trfm.configuration_id == configuration_id
- assert updated_trfm.success is True
- assert updated_trfm.timestamp is not None
- assert updated_trfm.version is not None
-
- expected_name = updates.get('name') or 'Initial Test SQL Transformation'
- expected_description = updates.get('description') or initial_sqltrfm.description
- assert updated_trfm.description == expected_description
- assert frozenset(updated_trfm.links) == frozenset(
- [
- Link(
- type='ui-detail',
- title=f'Transformation: {expected_name}',
- url=f'{storage_api_url}/admin'
- f'/projects/{project_id}/transformations-v2/{component_id}/{configuration_id}',
- ),
- Link(
- type='ui-dashboard',
- title='Transformations dashboard',
- url=f'{storage_api_url}/admin/projects/{project_id}/transformations-v2',
- ),
- ]
- )
-
- # Verify the transformation was updated
- trfm_detail = await keboola_client.storage_client.configuration_detail(
- component_id=updated_trfm.component_id, configuration_id=updated_trfm.configuration_id
- )
-
- assert trfm_detail['name'] == expected_name
- assert trfm_detail['description'] == expected_description
-
- trfm_data = trfm_detail.get('configuration')
- assert isinstance(trfm_data, dict), f'Expecting dict, got: {type(trfm_data)}'
-
- actual_parameters = trfm_data.get('parameters')
- assert isinstance(actual_parameters, dict), f'Expecting dict, got: {type(actual_parameters)}'
-
- if param_update_objects is not None:
- # Convert original parameters to SimplifiedTfBlocks, apply updates, then convert back
- orig_raw_parameters = TransformationConfiguration.Parameters.model_validate(orig_parameters_dict)
- orig_simplified_parameters = await orig_raw_parameters.to_simplified_parameters()
-
- updated_params, _ = update_transformation_parameters(
- orig_simplified_parameters, param_updates, sql_dialect='snowflake'
- )
- updated_raw_parameters = await updated_params.to_raw_parameters()
-
- expected_parameters = updated_raw_parameters.model_dump(exclude_none=True)
- assert actual_parameters == expected_parameters
-
- actual_storage = trfm_data.get('storage')
- assert isinstance(actual_storage, dict), f'Expecting dict, got: {type(actual_storage)}'
- if (expected_storage := updates.get('storage')) is not None:
- # Storage API might return more keys than what we set, so we check subset
- for k, v in expected_storage.items():
- assert k in trfm_data['storage']
- assert trfm_data['storage'][k] == v
-
- current_version = trfm_detail['version']
- assert isinstance(current_version, int), f'Expecting int, got: {type(current_version)}'
- assert current_version == 2
-
- # Check that KBC.MCP.updatedBy.version.{version} is set to 'true'
- metadata = await keboola_client.storage_client.configuration_metadata_get(
- component_id=updated_trfm.component_id, configuration_id=updated_trfm.configuration_id
- )
- assert isinstance(metadata, list), f'Expecting list, got: {type(metadata)}'
-
- meta_key = f'{MetadataField.UPDATED_BY_MCP_PREFIX}{current_version}'
- meta_value = get_metadata_property(metadata, meta_key)
- assert meta_value == 'true'
- # Check that the original creation metadata is still there
- assert get_metadata_property(metadata, MetadataField.CREATED_BY_MCP) == 'true'
-
-
-@pytest.mark.asyncio
-async def test_get_components(mcp_context: Context, configs: list[ConfigDef]):
- """Tests that `get_components` returns component details for multiple components."""
- # Get unique component IDs from test configs
- component_ids = list({config.component_id for config in configs})
- assert len(component_ids) > 0
-
- result = await get_components(component_ids=component_ids, ctx=mcp_context)
-
- # Verify result structure
- assert isinstance(result, GetComponentsOutput)
- assert len(result.components) == len(component_ids)
-
- # Verify each component
- returned_ids = {comp.component_id for comp in result.components}
- assert returned_ids == set(component_ids)
-
- for component in result.components:
- assert component.component_id in component_ids
- assert component.component_name is not None
- assert component.component_type is not None
- # Verify links are present
- assert component.links, 'Component links should not be empty.'
- for link in component.links:
- assert isinstance(link, Link)
-
- # Verify output-level links
- assert result.links, 'Output links should not be empty.'
-
-
-@pytest.mark.asyncio
-async def test_get_config_examples(mcp_context: Context, configs: list[ConfigDef]):
- """Tests that `get_config_examples` returns configuration examples in markdown format."""
- test_config = configs[0]
- component_id = test_config.component_id
-
- result = await get_config_examples(component_id=component_id, ctx=mcp_context)
-
- # Verify the result is a markdown formatted string
- assert isinstance(result, str)
- assert f'# Configuration Examples for `{component_id}`' in result
- assert f'{component_id}`' in result
- assert 'parameters' in result
-
-
-@pytest.mark.asyncio
-async def test_get_config_examples_with_invalid_component(mcp_context: Context):
- """Tests that `get_config_examples` handles non-existent components properly."""
-
- result = await get_config_examples(ctx=mcp_context, component_id='completely-non-existent-component-12345')
-
- assert result == ''
diff --git a/integtests/tools/data_apps.test.ts b/integtests/tools/data_apps.test.ts
new file mode 100644
index 000000000..d68f2c7a6
--- /dev/null
+++ b/integtests/tools/data_apps.test.ts
@@ -0,0 +1,416 @@
+import { execFileSync } from 'node:child_process';
+import { randomUUID } from 'node:crypto';
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { callToolText, connectMcp } from '../helpers/mcp';
+import { getTestProjectForTest, type TestProject } from '../testproject/fixture';
+
+import { createKeboolaClients } from '@/clients/keboola';
+import { Config } from '@/config';
+import { createDataScience, type DataScience } from '@/tools/data_apps/client';
+
+// Ported from integtests/tools/test_data_apps.py.
+//
+// Data App tools are gated to the MAIN/production branch (filtering.ts
+// DATA_APP_BRANCH_GATED_TOOLS). The pool leases its default (main) branch, so the tools
+// are always available here — confirmed live: get_data_apps + modify_* + deploy_data_app
+// are all listed and callable.
+//
+// These tests create REAL data-science apps. The MCP surface has no delete tool for
+// Streamlit apps, so teardown talks to the data-science client directly (the same path
+// the Python suite's `initial_data_app` fixture used:
+// keboola_client.data_science_client.delete_data_app). Each created app is registered for
+// best-effort suspend+delete in an afterEach so a leaked app never lingers in the project.
+//
+// The MCP client returns TOON text (snake_case field names preserved by the tools), so we
+// assert on substrings / regexes rather than re-parsing structured content.
+
+const SAMPLE_STREAMLIT_IMPORTS = 'import streamlit as st\n\n';
+const SAMPLE_STREAMLIT_ENTRYPOINT =
+ 'def main():\n' +
+ " st.title('Integration Test Data App')\n" +
+ " st.write('Hello from integration test')\n\n" +
+ 'if __name__ == "__main__":\n' +
+ ' main()\n';
+// Mirrors the Python `sample_streamlit_app` fixture: imports + {QUERY_DATA_FUNCTION}
+// placeholder (where the tool injects the query_data helper) + entrypoint.
+const SAMPLE_STREAMLIT_APP = `${SAMPLE_STREAMLIT_IMPORTS}{QUERY_DATA_FUNCTION}\n\n${SAMPLE_STREAMLIT_ENTRYPOINT}`;
+
+const PYTHON_JS_APP_PY =
+ 'from http.server import BaseHTTPRequestHandler, HTTPServer\n' +
+ 'import os\n\n' +
+ 'class H(BaseHTTPRequestHandler):\n' +
+ ' def do_GET(self):\n' +
+ ' self.send_response(200)\n' +
+ ' self.end_headers()\n' +
+ " self.wfile.write(b'integration-test-ok')\n\n" +
+ "if __name__ == '__main__':\n" +
+ " port = int(os.environ.get('PORT', '8000'))\n" +
+ " HTTPServer(('0.0.0.0', port), H).serve_forever()\n";
+
+const uniqueSuffix = (): string => randomUUID().replace(/-/g, '').slice(0, 8);
+
+// TOON renders string scalars that look numeric (or otherwise need quoting) wrapped in
+// double quotes, e.g. `data_app_id: "74015536"`, while opaque ids stay bare, e.g.
+// `configuration_id: 01kw...`. Strip an optional pair of surrounding quotes when extracting.
+const unquote = (value: string): string => value.replace(/^"(.*)"$/, '$1');
+
+/** Pulls the data_app_id out of a TOON tool response (`data_app_id: `). */
+const extractDataAppId = (toon: string): string | null => {
+ const m = toon.match(/data_app_id:\s*("[^"]*"|\S+)/);
+ return m ? unquote(m[1]!) : null;
+};
+
+/** Pulls the (first) configuration_id out of a TOON tool response. */
+const extractConfigurationId = (toon: string): string | null => {
+ const m = toon.match(/configuration_id:\s*("[^"]*"|\S+)/);
+ return m ? unquote(m[1]!) : null;
+};
+
+/**
+ * Provisions a read-only workspace (mirrors the Python `workspace_schema` fixture) and
+ * returns its schema plus a deleter. Streamlit data app tools require a configured
+ * workspace schema (resolveWorkspace throws otherwise), so the Streamlit tests build a
+ * Config carrying this schema.
+ */
+const provisionWorkspace = async (
+ project: TestProject,
+): Promise<{ schema: string; remove: () => Promise }> => {
+ const base = `${project.storageApiUrl}/v2/storage`;
+ const headers = {
+ 'X-StorageApi-Token': project.storageApiToken,
+ 'Content-Type': 'application/json',
+ };
+ const verify = (await (
+ await fetch(`${base}/tokens/verify`, { headers })
+ ).json()) as { owner?: { defaultBackend?: string } };
+ const backend = verify.owner?.defaultBackend;
+ const loginType =
+ backend === 'snowflake' ? 'snowflake-person-sso' : backend === 'bigquery' ? 'default' : null;
+ if (!loginType) throw new Error(`Unexpected project default backend: ${backend}`);
+
+ const res = await fetch(`${base}/branch/default/workspaces`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ backend, loginType, readOnlyStorageAccess: true }),
+ });
+ const ws = (await res.json()) as {
+ id?: number | string;
+ connection?: { schema?: string };
+ };
+ if (!res.ok || !ws.id || !ws.connection?.schema) {
+ throw new Error(`Failed to create workspace: ${res.status} ${JSON.stringify(ws)}`);
+ }
+ const schema = ws.connection.schema;
+ const workspaceId = ws.id;
+ return {
+ schema,
+ remove: async () => {
+ try {
+ await fetch(`${base}/branch/default/workspaces/${workspaceId}`, {
+ method: 'DELETE',
+ headers,
+ });
+ } catch {
+ // best-effort
+ }
+ },
+ };
+};
+
+/** Builds a Config identical to the leased one but carrying a workspace schema. */
+const withWorkspaceSchema = (config: Config, schema: string): Config =>
+ new Config({ ...config.toFields(), workspaceSchema: schema });
+
+describe('data app tools (integration)', () => {
+ // Best-effort teardown registry: { ds, dataAppId } pairs to suspend+delete after each test.
+ let cleanup: { ds: DataScience; dataAppId: string }[] = [];
+ // Workspaces provisioned for Streamlit tests, deleted after each test.
+ let workspaceCleanup: (() => Promise)[] = [];
+
+ afterEach(async () => {
+ for (const { ds, dataAppId } of cleanup.reverse()) {
+ try {
+ await ds.suspendDataApp(dataAppId);
+ } catch {
+ // ignore: app may already be stopped or gone.
+ }
+ try {
+ await ds.deleteDataApp(dataAppId);
+ } catch {
+ // ignore: best-effort.
+ }
+ }
+ cleanup = [];
+ for (const remove of workspaceCleanup.reverse()) {
+ await remove();
+ }
+ workspaceCleanup = [];
+ });
+
+ const dataScienceFor = (config: Config): DataScience =>
+ createDataScience(createKeboolaClients(config), config);
+
+ // Port of test_get_data_apps_listing: create an app, then get_data_apps must list it.
+ it('get_data_apps lists a freshly created Streamlit app', async () => {
+ const project = await getTestProjectForTest({ clean: false });
+ const ws = await provisionWorkspace(project);
+ workspaceCleanup.push(ws.remove);
+ const config = withWorkspaceSchema(project.config, ws.schema);
+ const ds = dataScienceFor(config);
+ const session = await connectMcp(config);
+ try {
+ const appName = `Integration Test Data App ${uniqueSuffix()}`;
+ const created = await callToolText(session.client, 'modify_streamlit_data_app', {
+ name: appName,
+ description: 'Data app created by integration test',
+ source_code: SAMPLE_STREAMLIT_APP,
+ packages: ['numpy', 'streamlit'],
+ authentication_type: 'no-auth',
+ });
+ expect(created).toContain('response: created');
+ const dataAppId = extractDataAppId(created);
+ const configurationId = extractConfigurationId(created);
+ expect(dataAppId).toBeTruthy();
+ expect(configurationId).toBeTruthy();
+ cleanup.push({ ds, dataAppId: dataAppId! });
+
+ const listed = await callToolText(session.client, 'get_data_apps', { limit: 500 });
+ // Listing returns DataAppSummary entries; our app's configuration_id must appear.
+ expect(listed).toContain(configurationId!);
+ } finally {
+ await session.close();
+ }
+ });
+
+ // Port of test_data_app_lifecycle (streamlit): create -> detail -> update -> detail.
+ it('Streamlit data app create/detail/update lifecycle', async () => {
+ const project = await getTestProjectForTest({ clean: false });
+ const ws = await provisionWorkspace(project);
+ workspaceCleanup.push(ws.remove);
+ const config = withWorkspaceSchema(project.config, ws.schema);
+ const ds = dataScienceFor(config);
+ const session = await connectMcp(config);
+ try {
+ const appName = `Integration Test Data App ${uniqueSuffix()}`;
+ const appDescription = 'Data app created by integration test';
+
+ // Create.
+ const created = await callToolText(session.client, 'modify_streamlit_data_app', {
+ name: appName,
+ description: appDescription,
+ source_code: SAMPLE_STREAMLIT_APP,
+ packages: ['numpy', 'streamlit'],
+ authentication_type: 'no-auth',
+ });
+ expect(created).toContain('response: created');
+ const dataAppId = extractDataAppId(created)!;
+ const configurationId = extractConfigurationId(created)!;
+ expect(dataAppId).toBeTruthy();
+ expect(configurationId).toBeTruthy();
+ cleanup.push({ ds, dataAppId });
+
+ // Detail by configuration_id reflects the created app, the injected query_data
+ // function, and the imports/entrypoint we sent.
+ const detail = await callToolText(session.client, 'get_data_apps', {
+ configuration_ids: [configurationId],
+ });
+ expect(detail).toContain(configurationId);
+ expect(detail).toContain(dataAppId);
+ expect(detail).toContain(appName);
+ expect(detail).toContain(appDescription);
+ expect(detail).toContain('import streamlit as st');
+ expect(detail).toContain("st.title('Integration Test Data App')");
+
+ // Update: new name/description, new source + packages.
+ const updatedName = `${appName} - Updated`;
+ const updatedDescription = 'Data app updated by integration test';
+ const updated = await callToolText(session.client, 'modify_streamlit_data_app', {
+ name: updatedName,
+ description: updatedDescription,
+ source_code: 'import numpy as np\n\n',
+ packages: ['streamlit'],
+ authentication_type: 'no-auth',
+ configuration_id: configurationId,
+ change_description: 'Update Code',
+ });
+ // Same app/config, response is an update (not "created").
+ expect(updated).toContain(dataAppId);
+ expect(updated).toContain(configurationId);
+ expect(updated).not.toContain('response: created');
+
+ // Detail reflects the updated name/description + the new source code; the old
+ // streamlit imports/entrypoint are gone.
+ const detail2 = await callToolText(session.client, 'get_data_apps', {
+ configuration_ids: [configurationId],
+ });
+ expect(detail2).toContain(updatedName);
+ expect(detail2).toContain(updatedDescription);
+ expect(detail2).toContain('import numpy as np');
+ expect(detail2).not.toContain("st.title('Integration Test Data App')");
+ } finally {
+ await session.close();
+ }
+ });
+
+ // Port of test_python_js_data_app_prod_and_draft_lifecycle.
+ //
+ // create prod (managed repo) -> create draft (external-git pointing at prod's repo) ->
+ // clone via embedded credential, push branch -> deploy draft mode='dev' -> assert the
+ // prod detail surfaces the draft under `drafts:` -> merge into main, redeploy prod ->
+ // delete the draft via delete_python_js_data_app_draft, assert it's gone from prod drafts.
+ it('python-js prod + external-git draft lifecycle', async () => {
+ const project = await getTestProjectForTest({ clean: false });
+ // modify_python_js_data_app injects the query_data code, which needs the workspace
+ // dialect — provision a read-only workspace and pass its schema (as the Streamlit tests do).
+ const ws = await provisionWorkspace(project);
+ const cfg = withWorkspaceSchema(project.config, ws.schema);
+ const ds = dataScienceFor(cfg);
+ const session = await connectMcp(cfg);
+ const repoDir = mkdtempSync(join(tmpdir(), 'kbc-pyjs-'));
+ const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
+ const git = (...args: string[]): void => {
+ execFileSync('git', args, { cwd: repoDir, env: gitEnv, stdio: 'pipe' });
+ };
+
+ const unique = uniqueSuffix();
+ let prodDataAppId: string | null = null;
+ let draftDataAppId: string | null = null;
+ let draftDeletedViaTool = false;
+
+ try {
+ // Step 1: create prod (managed repo). Response carries repo_url, no git_clone_url/branch.
+ const prodResp = await callToolText(session.client, 'modify_python_js_data_app', {
+ name: `Integration prod ${unique}`,
+ description: 'AI-3286 prod app integration test',
+ slug: `int-prod-${unique}`,
+ authentication_type: 'no-auth',
+ });
+ expect(prodResp).toContain('response: created');
+ const prodConfigId = extractConfigurationId(prodResp)!;
+ prodDataAppId = extractDataAppId(prodResp)!;
+ expect(prodConfigId).toBeTruthy();
+ expect(prodDataAppId).toBeTruthy();
+ cleanup.push({ ds, dataAppId: prodDataAppId });
+ expect(prodResp).toMatch(/repo_url:\s*"?https:\/\//);
+
+ // Step 2: create draft pointing at prod's repo. Branch defaults to 'init'.
+ const draftResp = await callToolText(session.client, 'modify_python_js_data_app', {
+ name: `Integration draft ${unique}`,
+ description: 'AI-3286 draft integration test',
+ slug: `int-draft-${unique}`,
+ parent_configuration_id: prodConfigId,
+ authentication_type: 'no-auth',
+ });
+ expect(draftResp).toContain('response: created');
+ const draftConfigId = extractConfigurationId(draftResp)!;
+ draftDataAppId = extractDataAppId(draftResp)!;
+ expect(draftConfigId).toBeTruthy();
+ expect(draftDataAppId).toBeTruthy();
+ cleanup.push({ ds, dataAppId: draftDataAppId });
+ // git_clone_url is an authenticated https://kai:@... clone URL; branch is 'init'.
+ const cloneMatch = draftResp.match(/git_clone_url:\s*("[^"]*"|\S+)/);
+ expect(cloneMatch).toBeTruthy();
+ const gitCloneUrl = unquote(cloneMatch![1]!);
+ expect(gitCloneUrl).toMatch(/^https:\/\/kai:/);
+ expect(draftResp).toMatch(/branch:\s*"?init\b/);
+
+ // Step 3: clone via the embedded credential; init main if empty, branch off, push.
+ execFileSync('git', ['clone', gitCloneUrl, repoDir], { env: gitEnv, stdio: 'pipe' });
+ git('config', 'user.email', 'mcp-integration@keboola.com');
+ git('config', 'user.name', 'MCP Integration Test');
+ let hasMain = true;
+ try {
+ execFileSync('git', ['rev-parse', '--verify', 'refs/heads/main'], {
+ cwd: repoDir,
+ env: gitEnv,
+ stdio: 'pipe',
+ });
+ } catch {
+ hasMain = false;
+ }
+ if (!hasMain) {
+ git('checkout', '-b', 'main');
+ writeFileSync(join(repoDir, 'README.md'), `# integration test ${unique}\n`);
+ git('add', 'README.md');
+ git('commit', '-m', 'init main');
+ git('push', '-u', 'origin', 'main');
+ }
+ git('checkout', '-b', 'init', 'main');
+ writeFileSync(join(repoDir, 'app.py'), PYTHON_JS_APP_PY);
+ git('add', 'app.py');
+ git('commit', '-m', `AI-3286 integration test commit ${unique}`);
+ git('push', '-u', 'origin', 'init');
+
+ // Step 4: deploy draft in mode='dev'. Fire-and-return: we only assert it was accepted.
+ const draftDeploy = await callToolText(session.client, 'deploy_data_app', {
+ action: 'deploy',
+ configuration_id: draftConfigId,
+ mode: 'dev',
+ });
+ expect(draftDeploy).toMatch(/state:/);
+
+ // Prod detail must now list the draft under `drafts`.
+ const prodDetailBefore = await callToolText(session.client, 'get_data_apps', {
+ configuration_ids: [prodConfigId],
+ });
+ expect(prodDetailBefore).toContain(draftConfigId);
+
+ // Draft's stored config carries the external-git block + parent linkage.
+ const draftDetail = await callToolText(session.client, 'get_data_apps', {
+ configuration_ids: [draftConfigId],
+ });
+ expect(draftDetail).toContain(prodConfigId); // parentConfigurationId
+ expect(draftDetail).toMatch(/isDraft/);
+ // Encrypted git password stored as KBC::-prefixed secret.
+ expect(draftDetail).toContain('KBC::');
+
+ // Step 5: merge into main and push.
+ git('checkout', 'main');
+ git('merge', '--no-ff', '-m', 'Merge init', 'init');
+ git('push', 'origin', 'main');
+
+ // Step 6: redeploy prod (no mode, no branch). Fire-and-return.
+ const prodDeploy = await callToolText(session.client, 'deploy_data_app', {
+ action: 'deploy',
+ configuration_id: prodConfigId,
+ });
+ expect(prodDeploy).toMatch(/state:/);
+
+ // Step 7: stop the draft (DSAPI delete requires desiredState == currentState), then
+ // delete it via the MCP tool and verify it's gone from prod's drafts.
+ try {
+ await ds.suspendDataApp(draftDataAppId);
+ } catch {
+ // best-effort
+ }
+ const deleted = await callToolText(session.client, 'delete_python_js_data_app_draft', {
+ configuration_id: draftConfigId,
+ });
+ expect(deleted).toContain('response: deleted');
+ expect(deleted).toContain(draftConfigId);
+ expect(deleted).toContain(prodConfigId); // parent_configuration_id
+ draftDeletedViaTool = true;
+
+ const prodDetailAfter = await callToolText(session.client, 'get_data_apps', {
+ configuration_ids: [prodConfigId],
+ });
+ // The draft config id should no longer appear in the prod's drafts listing. (It may
+ // still appear as part of the prod's own fields? No — drafts is the only place a draft
+ // cfg id is surfaced on the prod detail, so absence is meaningful.)
+ expect(prodDetailAfter).not.toContain(draftConfigId);
+ } finally {
+ // If the tool already deleted the draft, drop it from the cleanup registry so we don't
+ // try to delete it twice (which would log a spurious DSAPI error).
+ if (draftDeletedViaTool && draftDataAppId) {
+ cleanup = cleanup.filter((c) => c.dataAppId !== draftDataAppId);
+ }
+ rmSync(repoDir, { recursive: true, force: true });
+ await session.close();
+ await ws.remove();
+ }
+ });
+});
diff --git a/integtests/tools/doc.test.ts b/integtests/tools/doc.test.ts
new file mode 100644
index 000000000..6b2afb346
--- /dev/null
+++ b/integtests/tools/doc.test.ts
@@ -0,0 +1,77 @@
+import { Pool } from 'pg';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { embedInputFor, FIXTURE_SOURCES, migrateDocsIndex, seedDocsIndex } from '../../scripts/docsIndex';
+import { callToolText, connectMcp } from '../helpers/mcp';
+import { getTestProjectForTest } from '../testproject/fixture';
+
+import { StubEmbedder } from '@/clients/docsSearch';
+
+// Ported from integtests/tools/test_doc.py, rebuilt on the pgvector docs-search index
+// (RFC: feature_spec/docs-search-pgvector/). Drives docs_query + find_component_id through
+// the MCP against a real Postgres provided by docker-compose (see the `integration_tests`
+// CI job). The suite seeds the fixture corpus with the deterministic StubEmbedder — the
+// same embedder the server uses at query time (DOCS_EMBEDDER_MODEL=stub) — so retrieval is
+// reproducible offline.
+//
+// Requires DATABASE_URL + DOCS_EMBEDDER_MODEL=stub; skips otherwise (parity with
+// storage_branches). Locally:
+// docker compose up -d pgvector
+// DATABASE_URL=postgres://mcp:mcp@localhost:5432/docs DOCS_EMBEDDER_MODEL=stub npm run test:integ
+//
+// StubEmbedder is deterministic but not semantic, so queries use a fixture's exact embed
+// input (title\ncontent) to guarantee a top hit — mirroring the SDK's own integ tier.
+
+const DATABASE_URL = (process.env.DATABASE_URL ?? '').trim();
+const STUB = process.env.DOCS_EMBEDDER_MODEL === 'stub';
+const describeDocs = DATABASE_URL && STUB ? describe : describe.skip;
+if (!(DATABASE_URL && STUB)) {
+ console.warn('SKIP: docs tools integration — set DATABASE_URL + DOCS_EMBEDDER_MODEL=stub.');
+}
+
+const OVERVIEW = FIXTURE_SOURCES.find((d) => d.sourceKey === 'connection-docs:overview')!;
+const MYSQL = FIXTURE_SOURCES.find((d) => d.sourceKey === 'component:keboola.ex-db-mysql')!;
+
+describeDocs('docs tools (integration, pgvector)', () => {
+ let pool: Pool | undefined;
+
+ beforeAll(async () => {
+ pool = new Pool({ connectionString: DATABASE_URL });
+ const embedder = new StubEmbedder(3072);
+ await migrateDocsIndex(pool, embedder.dim);
+ await seedDocsIndex(pool, embedder, FIXTURE_SOURCES);
+ }, 60_000);
+
+ afterAll(async () => {
+ await pool?.end();
+ });
+
+ it('docs_query returns an answer with text and source URLs', async () => {
+ const { config } = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(config);
+ try {
+ const text = await callToolText(session.client, 'docs_query', {
+ query: embedInputFor(OVERVIEW),
+ });
+ expect(text.length).toBeGreaterThan(0);
+ expect(text).toContain('Keboola Connection');
+ expect(text).toContain(OVERVIEW.sourceUrl);
+ } finally {
+ await session.close();
+ }
+ });
+
+ it('find_component_id recommends a component id from the index', async () => {
+ const { config } = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(config);
+ try {
+ const text = await callToolText(session.client, 'find_component_id', {
+ query: embedInputFor(MYSQL),
+ });
+ // The component id is recovered from the doc's `component:` source key.
+ expect(text).toContain('keboola.ex-db-mysql');
+ } finally {
+ await session.close();
+ }
+ });
+});
diff --git a/integtests/tools/flow.test.ts b/integtests/tools/flow.test.ts
new file mode 100644
index 000000000..08be31a4a
--- /dev/null
+++ b/integtests/tools/flow.test.ts
@@ -0,0 +1,859 @@
+import { describe, expect, it } from 'vitest';
+
+import { callToolRaw, callToolText, connectMcp, type McpSession } from '../helpers/mcp';
+import { seedProject } from '../helpers/seed';
+import { getTestProjectForTest, type TestProject } from '../testproject/fixture';
+
+import { CONDITIONAL_FLOW_COMPONENT_ID, ORCHESTRATOR_COMPONENT_ID } from '@/constants';
+
+// Ported from integtests/tools/flow/test_tools.py (10 tests) and
+// integtests/tools/flow/test_scheduler.py (2 tests).
+//
+// The Python suite drove the tool *functions* directly (and mocked out the tool-filtering
+// middleware so every tool was always callable). Here we go through the real MCP server over an
+// in-memory transport, so two server-side gates apply that the Python tests bypassed:
+//
+// 1. Conditional-vs-legacy mutual exclusivity (feature `hide-conditional-flows`):
+// - conditional-enabled project -> ONLY `create_conditional_flow` is callable;
+// `create_flow` is blocked ("...use create_conditional_flow tool instead").
+// - legacy-only project -> ONLY `create_flow` is callable;
+// `create_conditional_flow` is blocked.
+// So a given leased project supports exactly one create variant. Each create/update test
+// detects the project's variant via get_project_info (`conditional_flows`) and returns
+// early with a logged SKIP when the leased project is the wrong variant. (Vitest has no
+// late `it.skip`, so the early-return-with-warning is the equivalent.)
+//
+// 2. update_flow vs modify_flow depends on the token role: admin/OAuth tokens may call
+// ONLY `modify_flow`; other tokens may call ONLY `update_flow`. We pick the right tool
+// from get_project_info (`user_role`).
+//
+// Positive assertions are on the TOON text the client returns (substrings/regex); negative
+// paths assert on the raw CallToolResult (isError + message).
+
+// =============================================================================
+// HELPERS
+// =============================================================================
+
+/** Pulls the first `configuration_id: ` out of a tool's TOON text. */
+const extractConfigId = (text: string): string => {
+ const m = text.match(/configuration_id:\s*(\S+)/);
+ if (!m) throw new Error(`No configuration_id found in tool output:\n${text}`);
+ return m[1]!;
+};
+
+const projectInfo = (session: McpSession): Promise =>
+ callToolText(session.client, 'get_project_info');
+
+/** conditional_flows == true (no `hide-conditional-flows` feature). */
+const isConditionalProject = (info: string): boolean => /conditional_flows:\s*true/i.test(info);
+
+/** The update tool callable for this token: admin/OAuth -> modify_flow, else update_flow. */
+const updateToolName = (info: string): 'modify_flow' | 'update_flow' =>
+ /user_role:\s*(admin|share)/i.test(info) ? 'modify_flow' : 'update_flow';
+
+const isAdmin = (info: string): boolean => /user_role:\s*admin/i.test(info);
+
+const skip = (reason: string): void => {
+ // eslint-disable-next-line no-console
+ console.warn(`SKIP: ${reason}`);
+};
+
+/** Seeds the standard fixtures and returns the first real config (component + config id). */
+const seedFirstConfig = async (
+ project: TestProject,
+): Promise<{ componentId: string; configurationId: string }> => {
+ const seeded = await seedProject(project);
+ const c = seeded.configs[0]!;
+ return { componentId: c.componentId, configurationId: c.configurationId };
+};
+
+/** Creates the standard "Initial Test Flow" legacy flow (port of conftest.initial_lf). */
+const createInitialLegacyFlow = async (
+ session: McpSession,
+ componentId: string,
+ configurationId: string,
+): Promise => {
+ const text = await callToolText(session.client, 'create_flow', {
+ name: 'Initial Test Flow',
+ description: 'Initial test flow created by automated test',
+ phases: [{ name: 'Phase1', dependsOn: [], description: 'First phase' }],
+ tasks: [
+ {
+ id: 20001,
+ name: 'Task1',
+ phase: 1,
+ continueOnFailure: false,
+ enabled: false,
+ task: { componentId, configId: configurationId, mode: 'run' },
+ },
+ ],
+ });
+ return extractConfigId(text);
+};
+
+/** Creates the standard "Initial Test Flow" conditional flow (port of conftest.initial_cf). */
+const createInitialConditionalFlow = async (
+ session: McpSession,
+ componentId: string,
+ configurationId: string,
+): Promise => {
+ const text = await callToolText(session.client, 'create_conditional_flow', {
+ name: 'Initial Test Flow',
+ description: 'Initial test flow created by automated test',
+ phases: [
+ {
+ id: 'phase1',
+ name: 'Phase1',
+ description: 'First phase',
+ next: [{ id: 'phase1_end', name: 'End Flow', goto: null }],
+ },
+ ],
+ tasks: [
+ {
+ id: 'task1',
+ name: 'Task1',
+ phase: 'phase1',
+ task: { type: 'job', componentId, configId: configurationId, mode: 'run' },
+ },
+ ],
+ });
+ return extractConfigId(text);
+};
+
+// Flow tests are backend-agnostic; pin to snowflake (the bigquery pool entries currently fail
+// the harness's synchronous-bucket-drop reset, unrelated to flows).
+const leaseProject = (clean = true) => getTestProjectForTest({ backend: 'snowflake', clean });
+
+describe('flow tools (integration)', () => {
+ // ===========================================================================
+ // test_create_and_retrieve_flow (legacy) — runs only on a legacy-only project
+ // ===========================================================================
+ it('create_flow creates a legacy flow and get_flows retrieves it', async () => {
+ const project = await leaseProject();
+ const { componentId, configurationId } = await seedFirstConfig(project);
+ const session = await connectMcp(project.config);
+ try {
+ if (isConditionalProject(await projectInfo(session))) {
+ return skip('project is conditional-enabled; create_flow (legacy) is not available.');
+ }
+ const flowName = 'Integration Test Flow';
+ const created = await callToolText(session.client, 'create_flow', {
+ name: flowName,
+ description: 'Flow created by integration test.',
+ phases: [
+ { name: 'Extract', dependsOn: [], description: 'Extract data' },
+ { name: 'Transform', dependsOn: [1], description: 'Transform data' },
+ ],
+ tasks: [
+ { name: 'Extract Task', phase: 1, task: { componentId, configId: configurationId } },
+ { name: 'Transform Task', phase: 2, task: { componentId, configId: configurationId } },
+ ],
+ });
+ expect(created).toContain(ORCHESTRATOR_COMPONENT_ID);
+ expect(created).toContain('Flow created by integration test.');
+ expect(created).toMatch(/success:\s*true/i);
+ expect(created).toMatch(/version:/);
+ expect(created).toContain('https://help.keboola.com/flows/');
+ const flowId = extractConfigId(created);
+
+ const list = await callToolText(session.client, 'get_flows');
+ expect(list).toContain(flowName);
+ expect(list).toContain(flowId);
+
+ const detail = await callToolText(session.client, 'get_flows', { flow_ids: [flowId] });
+ expect(detail).toContain(ORCHESTRATOR_COMPONENT_ID);
+ expect(detail).toContain('Extract');
+ expect(detail).toContain('Transform');
+ expect(detail).toContain(componentId);
+ } finally {
+ await session.close();
+ }
+ });
+
+ // ===========================================================================
+ // test_create_and_retrieve_conditional_flow — runs only on a conditional project
+ // ===========================================================================
+ it('create_conditional_flow creates a conditional flow and get_flows retrieves it', async () => {
+ const project = await leaseProject();
+ const { componentId, configurationId } = await seedFirstConfig(project);
+ const session = await connectMcp(project.config);
+ try {
+ if (!isConditionalProject(await projectInfo(session))) {
+ return skip('project is legacy-only; create_conditional_flow is not available.');
+ }
+ const flowName = 'Integration Test Conditional Flow';
+ const created = await callToolText(session.client, 'create_conditional_flow', {
+ name: flowName,
+ description: 'Conditional flow created by integration test.',
+ phases: [
+ {
+ id: 'extract_phase',
+ name: 'Extract',
+ description: 'Extract data',
+ next: [{ id: 'extract_to_transform', name: 'Extract to Transform', goto: 'transform_phase' }],
+ },
+ {
+ id: 'transform_phase',
+ name: 'Transform',
+ description: 'Transform data',
+ next: [{ id: 'transform_end', name: 'End Flow', goto: null }],
+ },
+ ],
+ tasks: [
+ {
+ id: 'extract_task',
+ name: 'Extract Task',
+ phase: 'extract_phase',
+ task: { type: 'job', componentId, configId: configurationId, mode: 'run' },
+ },
+ {
+ id: 'transform_task',
+ name: 'Transform Task',
+ phase: 'transform_phase',
+ task: { type: 'job', componentId, configId: configurationId, mode: 'run' },
+ },
+ ],
+ });
+ expect(created).toContain(CONDITIONAL_FLOW_COMPONENT_ID);
+ expect(created).toContain('Conditional flow created by integration test.');
+ expect(created).toMatch(/success:\s*true/i);
+ expect(created).toMatch(/version:/);
+ const flowId = extractConfigId(created);
+
+ const list = await callToolText(session.client, 'get_flows');
+ expect(list).toContain(flowName);
+ expect(list).toContain(flowId);
+
+ const detail = await callToolText(session.client, 'get_flows', { flow_ids: [flowId] });
+ expect(detail).toContain(CONDITIONAL_FLOW_COMPONENT_ID);
+ expect(detail).toContain('Extract');
+ expect(detail).toContain('Transform');
+ expect(detail).toContain(componentId);
+ } finally {
+ await session.close();
+ }
+ });
+
+ // ===========================================================================
+ // test_update_flow — legacy parametrized cases (legacy-only projects)
+ // ===========================================================================
+ const legacyUpdateCases: { label: string; updates: Record }[] = [
+ {
+ label: 'phases + tasks + name + description',
+ updates: {
+ phases: [
+ { id: 1, name: 'Phase1', dependsOn: [], description: 'First phase updated' },
+ { id: 2, name: 'Phase2', dependsOn: [], description: 'Second phase added' },
+ ],
+ tasks: [
+ {
+ id: 20001,
+ name: 'Task1 - Updated',
+ phase: 1,
+ continueOnFailure: false,
+ enabled: false,
+ task: { componentId: 'ex-generic-v2', configId: 'test_config_001', mode: 'run' },
+ },
+ {
+ id: 20002,
+ name: 'Task2 - Added',
+ phase: 2,
+ continueOnFailure: false,
+ enabled: false,
+ task: { componentId: 'ex-generic-v2', configId: 'test_config_002', mode: 'run' },
+ },
+ ],
+ name: 'Updated Test Flow',
+ description: 'The test flow updated by an automated test.',
+ },
+ },
+ {
+ label: 'phases only',
+ updates: {
+ phases: [
+ { id: 1, name: 'Phase1', dependsOn: [], description: 'First phase updated' },
+ { id: 2, name: 'Phase2', dependsOn: [], description: 'Second phase added' },
+ ],
+ },
+ },
+ {
+ label: 'tasks only',
+ updates: {
+ tasks: [
+ {
+ id: 20001,
+ name: 'Task1 - Updated',
+ phase: 1,
+ continueOnFailure: false,
+ enabled: false,
+ task: { componentId: 'ex-generic-v2', configId: 'test_config_001', mode: 'run' },
+ },
+ {
+ id: 20002,
+ name: 'Task2 - Added',
+ phase: 1,
+ continueOnFailure: false,
+ enabled: false,
+ task: { componentId: 'ex-generic-v2', configId: 'test_config_002', mode: 'run' },
+ },
+ ],
+ },
+ },
+ { label: 'name only', updates: { name: 'Updated just name' } },
+ { label: 'description only', updates: { description: 'Updated just description' } },
+ { label: 'is_disabled true', updates: { is_disabled: true } },
+ ];
+
+ it.each(legacyUpdateCases)('update legacy flow ($label)', async ({ updates }) => {
+ const project = await leaseProject();
+ const { componentId, configurationId } = await seedFirstConfig(project);
+ const session = await connectMcp(project.config);
+ try {
+ const info = await projectInfo(session);
+ if (isConditionalProject(info)) {
+ return skip('project is conditional-enabled; legacy create_flow is not available.');
+ }
+ const tool = updateToolName(info);
+ const flowId = await createInitialLegacyFlow(session, componentId, configurationId);
+
+ const result = await callToolText(session.client, tool, {
+ configuration_id: flowId,
+ flow_type: ORCHESTRATOR_COMPONENT_ID,
+ change_description: 'Integration test update',
+ ...updates,
+ });
+ expect(result).toContain(flowId);
+ expect(result).toContain(ORCHESTRATOR_COMPONENT_ID);
+ expect(result).toMatch(/success:\s*true/i);
+ expect(result).toMatch(/timestamp:/);
+ expect(result).toMatch(/version:/);
+
+ const expectedName = (updates.name as string) ?? 'Initial Test Flow';
+ const expectedDescription =
+ (updates.description as string) ?? 'Initial test flow created by automated test';
+
+ const detail = await callToolText(session.client, 'get_flows', { flow_ids: [flowId] });
+ expect(detail).toContain(expectedName);
+ expect(detail).toContain(expectedDescription);
+ // The update bumps the configuration to version 2. (The Python test also asserted the
+ // KBC.MCP.updatedBy/createdBy metadata, but that is read via the Storage client, not
+ // surfaced by the get_flows MCP tool, so it is not assertable through this surface.)
+ expect(detail).toMatch(/version:\s*2/);
+ if (updates.is_disabled === true) expect(detail).toMatch(/is_disabled:\s*true/i);
+ } finally {
+ await session.close();
+ }
+ });
+
+ // ===========================================================================
+ // test_update_flow — conditional parametrized cases (conditional projects)
+ // ===========================================================================
+ const conditionalUpdateCases: { label: string; updates: Record }[] = [
+ {
+ label: 'phases + tasks',
+ updates: {
+ phases: [
+ {
+ id: 'phase1',
+ name: 'Phase1',
+ description: 'First phase updated',
+ next: [{ id: 'phase1_phase2', name: 'End Flow', goto: 'phase2' }],
+ },
+ {
+ id: 'phase2',
+ name: 'Phase2',
+ description: 'Second phase added',
+ next: [{ id: 'phase2_end', name: 'End Flow', goto: null }],
+ },
+ ],
+ tasks: [
+ {
+ id: 'task1',
+ name: 'Task1 - Updated',
+ phase: 'phase1',
+ task: { type: 'job', componentId: 'ex-generic-v2', configId: 'test_config_001', mode: 'run' },
+ },
+ {
+ id: 'task2',
+ name: 'Task2 - Added',
+ phase: 'phase2',
+ task: { type: 'job', componentId: 'ex-generic-v2', configId: 'test_config_002', mode: 'run' },
+ },
+ ],
+ },
+ },
+ {
+ label: 'phases only',
+ updates: {
+ phases: [
+ {
+ id: 'phase1',
+ name: 'Phase1',
+ description: 'First phase updated',
+ next: [{ id: 'phase1_phase2', name: 'End Flow', goto: 'phase2' }],
+ },
+ {
+ id: 'phase2',
+ name: 'Phase2',
+ description: 'Second phase added',
+ next: [{ id: 'phase2_end', name: 'End Flow', goto: null }],
+ },
+ ],
+ },
+ },
+ {
+ label: 'tasks only',
+ updates: {
+ tasks: [
+ {
+ id: 'task1',
+ name: 'Task1 - Updated',
+ phase: 'phase1',
+ task: { type: 'job', componentId: 'ex-generic-v2', configId: 'test_config_001', mode: 'run' },
+ },
+ {
+ id: 'task2',
+ name: 'Task2 - Added',
+ phase: 'phase1',
+ task: { type: 'job', componentId: 'ex-generic-v2', configId: 'test_config_002', mode: 'run' },
+ },
+ ],
+ },
+ },
+ { label: 'name only', updates: { name: 'Updated just name' } },
+ { label: 'description only', updates: { description: 'Updated just description' } },
+ { label: 'is_disabled true', updates: { is_disabled: true } },
+ ];
+
+ it.each(conditionalUpdateCases)('update conditional flow ($label)', async ({ updates }) => {
+ const project = await leaseProject();
+ const { componentId, configurationId } = await seedFirstConfig(project);
+ const session = await connectMcp(project.config);
+ try {
+ const info = await projectInfo(session);
+ if (!isConditionalProject(info)) {
+ return skip('project is legacy-only; create_conditional_flow is not available.');
+ }
+ const tool = updateToolName(info);
+ const flowId = await createInitialConditionalFlow(session, componentId, configurationId);
+
+ const result = await callToolText(session.client, tool, {
+ configuration_id: flowId,
+ flow_type: CONDITIONAL_FLOW_COMPONENT_ID,
+ change_description: 'Integration test update',
+ ...updates,
+ });
+ expect(result).toContain(flowId);
+ expect(result).toContain(CONDITIONAL_FLOW_COMPONENT_ID);
+ expect(result).toMatch(/success:\s*true/i);
+
+ const expectedName = (updates.name as string) ?? 'Initial Test Flow';
+ const expectedDescription =
+ (updates.description as string) ?? 'Initial test flow created by automated test';
+
+ const detail = await callToolText(session.client, 'get_flows', { flow_ids: [flowId] });
+ expect(detail).toContain(expectedName);
+ expect(detail).toContain(expectedDescription);
+ // version bumps to 2 on update; MCP-tracking metadata is not surfaced by get_flows.
+ expect(detail).toMatch(/version:\s*2/);
+ if (updates.is_disabled === true) expect(detail).toMatch(/is_disabled:\s*true/i);
+ } finally {
+ await session.close();
+ }
+ });
+
+ // ===========================================================================
+ // test_get_flows_empty
+ // ===========================================================================
+ it('get_flows returns an empty list when no flows exist', async () => {
+ const project = await leaseProject();
+ const session = await connectMcp(project.config);
+ try {
+ const text = await callToolText(session.client, 'get_flows');
+ expect(text).not.toMatch(/configuration_id:/);
+ } finally {
+ await session.close();
+ }
+ });
+
+ // ===========================================================================
+ // test_get_flows_list — creates whichever flow variant the project supports
+ // ===========================================================================
+ it('get_flows lists created flows with dashboard links', async () => {
+ const project = await leaseProject();
+ const { componentId, configurationId } = await seedFirstConfig(project);
+ const session = await connectMcp(project.config);
+ try {
+ const info = await projectInfo(session);
+ const id = isConditionalProject(info)
+ ? await createInitialConditionalFlow(session, componentId, configurationId)
+ : await createInitialLegacyFlow(session, componentId, configurationId);
+
+ const list = await callToolText(session.client, 'get_flows');
+ expect(list).toContain(id);
+ // Dashboard links for both flow surfaces are always present in the list output.
+ expect(list).toContain('/flows');
+ expect(list).toContain('/flows-v2');
+ } finally {
+ await session.close();
+ }
+ });
+
+ // ===========================================================================
+ // test_get_flow_schema (read-only; no project reset needed)
+ // ===========================================================================
+ it('get_flow_schema returns the legacy (and conditional) JSON schema', async () => {
+ const project = await leaseProject(false);
+ const session = await connectMcp(project.config);
+ try {
+ const legacy = await callToolText(session.client, 'get_flow_schema', {
+ flow_type: ORCHESTRATOR_COMPONENT_ID,
+ });
+ expect(legacy.startsWith('```json\n')).toBe(true);
+ expect(legacy.endsWith('\n```')).toBe(true);
+ expect(legacy).toContain('dependsOn');
+ const legacyParsed = JSON.parse(legacy.slice(8, -4));
+ expect(legacyParsed).toHaveProperty('$schema');
+ expect(legacyParsed.properties).toHaveProperty('phases');
+ expect(legacyParsed.properties).toHaveProperty('tasks');
+
+ if (isConditionalProject(await projectInfo(session))) {
+ const conditional = await callToolText(session.client, 'get_flow_schema', {
+ flow_type: CONDITIONAL_FLOW_COMPONENT_ID,
+ });
+ expect(conditional.startsWith('```json\n')).toBe(true);
+ expect(conditional).not.toBe(legacy);
+ const conditionalParsed = JSON.parse(conditional.slice(8, -4));
+ expect(conditionalParsed.properties.phases.items.properties).toHaveProperty('next');
+ expect(conditionalParsed.properties.tasks.items.properties.task).toHaveProperty('oneOf');
+ } else {
+ const result = await callToolRaw(session.client, 'get_flow_schema', {
+ flow_type: CONDITIONAL_FLOW_COMPONENT_ID,
+ });
+ expect(result.isError).toBeTruthy();
+ expect((result.content as { text: string }[])[0]!.text).toMatch(
+ /conditional flows are not supported/i,
+ );
+ }
+ } finally {
+ await session.close();
+ }
+ });
+
+ // ===========================================================================
+ // get_flow_examples (read-only)
+ // ===========================================================================
+ it('get_flow_examples returns example configurations for the legacy flow type', async () => {
+ const project = await leaseProject(false);
+ const session = await connectMcp(project.config);
+ try {
+ const text = await callToolText(session.client, 'get_flow_examples', {
+ flow_type: ORCHESTRATOR_COMPONENT_ID,
+ });
+ expect(text).toContain(`Flow Configuration Examples for \`${ORCHESTRATOR_COMPONENT_ID}\``);
+ expect(text).toContain('```json');
+ expect(text).toContain('phases');
+ expect(text).toContain('tasks');
+ } finally {
+ await session.close();
+ }
+ });
+
+ // ===========================================================================
+ // test_create_legacy_flow_invalid_structure
+ // (legacy validation runs only on legacy-only projects, where create_flow is callable)
+ // ===========================================================================
+ it('create_flow rejects a legacy flow that depends on a non-existent phase', async () => {
+ const project = await leaseProject(false);
+ const session = await connectMcp(project.config);
+ try {
+ if (isConditionalProject(await projectInfo(session))) {
+ return skip('project is conditional-enabled; create_flow (legacy) is not available.');
+ }
+ const result = await callToolRaw(session.client, 'create_flow', {
+ name: 'Invalid Legacy Flow',
+ description: 'Should fail',
+ phases: [{ name: 'Phase1', dependsOn: [99], description: 'Depends on non-existent phase' }],
+ tasks: [{ name: 'Task1', phase: 1, task: { componentId: 'ex-generic-v2', configId: 'x' } }],
+ });
+ expect(result.isError).toBeTruthy();
+ expect((result.content as { text: string }[])[0]!.text).toMatch(/non-existent phase/i);
+ } finally {
+ await session.close();
+ }
+ });
+
+ // ===========================================================================
+ // test_create_conditional_flow_invalid_structure (type/schema validation)
+ // ===========================================================================
+ it('create_conditional_flow rejects structurally invalid phases/tasks', async () => {
+ const project = await leaseProject(false);
+ const session = await connectMcp(project.config);
+ try {
+ if (!isConditionalProject(await projectInfo(session))) {
+ return skip('project is legacy-only; create_conditional_flow is not available.');
+ }
+ const result = await callToolRaw(session.client, 'create_conditional_flow', {
+ name: 'Invalid Conditional Flow',
+ description: 'Should fail',
+ phases: [
+ {
+ id: 123, // invalid: should be a string
+ name: '', // invalid: empty
+ next: [{ id: 'transition-1', goto: 'phase-2' }],
+ },
+ ],
+ tasks: [
+ {
+ id: 'task-1',
+ name: 'Task1',
+ phase: 'phase-1',
+ enabled: true,
+ task: { type: 'invalid_type', componentId: 'ex-generic-v2', configId: 'x', mode: 'invalid_mode' },
+ },
+ ],
+ });
+ expect(result.isError).toBeTruthy();
+ } finally {
+ await session.close();
+ }
+ });
+
+ // ===========================================================================
+ // test_create_conditional_flow_semantically_invalid_structure
+ // ===========================================================================
+ const semanticInvalidCases: {
+ label: string;
+ phases: Record[];
+ tasks: Record[];
+ expected: RegExp;
+ }[] = [
+ {
+ label: 'multiple entry phases',
+ phases: [
+ { id: 'phase-1', name: 'Phase1', next: [{ id: 'transition-1', goto: null }] },
+ { id: 'phase-2', name: 'Phase2', next: [{ id: 'transition-2', goto: null }] },
+ ],
+ tasks: [
+ {
+ id: 'task-1',
+ name: 'Task1',
+ phase: 'phase-1',
+ task: { type: 'job', componentId: 'ex-generic-v2', configId: 'test_config_002', mode: 'run' },
+ },
+ ],
+ expected: /multiple entry phases/i,
+ },
+ {
+ label: 'no ending phases',
+ phases: [
+ { id: 'phase-1', name: 'Phase1', next: [{ id: 'transition-1', goto: 'phase-2' }] },
+ { id: 'phase-2', name: 'Phase2', next: [{ id: 'transition-2', goto: 'phase-1' }] },
+ ],
+ tasks: [
+ {
+ id: 'task-1',
+ name: 'Task1',
+ phase: 'phase-1',
+ task: { type: 'job', componentId: 'ex-generic-v2', configId: 'test_config_002', mode: 'run' },
+ },
+ {
+ id: 'task-2',
+ name: 'Task2',
+ phase: 'phase-2',
+ task: { type: 'job', componentId: 'ex-generic-v2', configId: 'test_config_002', mode: 'run' },
+ },
+ ],
+ expected: /no ending phases/i,
+ },
+ ];
+
+ it.each(semanticInvalidCases)(
+ 'create_conditional_flow rejects a semantically invalid flow ($label)',
+ async ({ phases, tasks, expected }) => {
+ const project = await leaseProject(false);
+ const session = await connectMcp(project.config);
+ try {
+ if (!isConditionalProject(await projectInfo(session))) {
+ return skip('project is legacy-only; create_conditional_flow is not available.');
+ }
+ const result = await callToolRaw(session.client, 'create_conditional_flow', {
+ name: 'Invalid Conditional Flow',
+ description: 'Should fail',
+ phases,
+ tasks,
+ });
+ expect(result.isError).toBeTruthy();
+ expect((result.content as { text: string }[])[0]!.text).toMatch(expected);
+ } finally {
+ await session.close();
+ }
+ },
+ );
+
+ // ===========================================================================
+ // test_flow_lifecycle_integration — create the supported variant, retrieve, list
+ // ===========================================================================
+ it('full flow lifecycle: create, retrieve individually, list', async () => {
+ const project = await leaseProject();
+ const { componentId, configurationId } = await seedFirstConfig(project);
+ const session = await connectMcp(project.config);
+ try {
+ const info = await projectInfo(session);
+ const created: { type: string; id: string }[] = [];
+
+ if (isConditionalProject(info)) {
+ const text = await callToolText(session.client, 'create_conditional_flow', {
+ name: 'Integration Test Conditional Flow',
+ description: 'Conditional flow created by integration test',
+ phases: [
+ {
+ id: 'phase-1',
+ name: 'Extract',
+ description: 'Extract data from source',
+ next: [{ id: 'transition-1', goto: 'phase-2' }],
+ },
+ { id: 'phase-2', name: 'Load', description: 'Load data to destination', next: [] },
+ ],
+ tasks: [
+ {
+ id: 'task-1',
+ name: 'Extract from API',
+ phase: 'phase-1',
+ enabled: true,
+ task: { type: 'job', componentId, configId: configurationId, mode: 'run' },
+ },
+ {
+ id: 'task-2',
+ name: 'Load to Warehouse',
+ phase: 'phase-2',
+ enabled: true,
+ task: { type: 'job', componentId, configId: configurationId, mode: 'run' },
+ },
+ ],
+ });
+ expect(text).toMatch(/success:\s*true/i);
+ created.push({ type: CONDITIONAL_FLOW_COMPONENT_ID, id: extractConfigId(text) });
+ } else {
+ const text = await callToolText(session.client, 'create_flow', {
+ name: 'Integration Test Orchestrator Flow',
+ description: 'Orchestrator flow created by integration test',
+ phases: [
+ { id: 1, name: 'Extract', description: 'Extract data from source', dependsOn: [] },
+ { id: 2, name: 'Load', description: 'Load data to destination', dependsOn: [1] },
+ ],
+ tasks: [
+ {
+ id: 20001,
+ name: 'Extract from API',
+ phase: 1,
+ enabled: true,
+ continueOnFailure: false,
+ task: { componentId, configId: configurationId, mode: 'run' },
+ },
+ {
+ id: 20002,
+ name: 'Load to Warehouse',
+ phase: 2,
+ enabled: true,
+ continueOnFailure: false,
+ task: { componentId, configId: configurationId, mode: 'run' },
+ },
+ ],
+ });
+ expect(text).toMatch(/success:\s*true/i);
+ created.push({ type: ORCHESTRATOR_COMPONENT_ID, id: extractConfigId(text) });
+ }
+
+ // Retrieve each individually.
+ for (const { type, id } of created) {
+ const detail = await callToolText(session.client, 'get_flows', { flow_ids: [id] });
+ expect(detail).toContain(id);
+ expect(detail).toContain(type);
+ expect(detail).toContain('Extract');
+ expect(detail).toContain('Load');
+ }
+
+ // List all and verify presence.
+ const list = await callToolText(session.client, 'get_flows');
+ for (const { id } of created) expect(list).toContain(id);
+ } finally {
+ await session.close();
+ }
+ });
+
+ // ===========================================================================
+ // test_scheduler_lifecycle_tooling — modify_flow add/update/remove schedules
+ // (admin token only; admins must use modify_flow)
+ // ===========================================================================
+ it('modify_flow manages a flow schedule via tooling (add, update, remove)', async () => {
+ const project = await leaseProject();
+ const { componentId, configurationId } = await seedFirstConfig(project);
+ const session = await connectMcp(project.config);
+ try {
+ const info = await projectInfo(session);
+ if (!isAdmin(info)) return skip('scheduler tooling requires an admin token (modify_flow).');
+
+ // Create whatever flow variant the project supports, schedule that one.
+ const conditional = isConditionalProject(info);
+ const flowType = conditional ? CONDITIONAL_FLOW_COMPONENT_ID : ORCHESTRATOR_COMPONENT_ID;
+ const flowId = conditional
+ ? await createInitialConditionalFlow(session, componentId, configurationId)
+ : await createInitialLegacyFlow(session, componentId, configurationId);
+
+ // Add a schedule.
+ const added = await callToolText(session.client, 'modify_flow', {
+ configuration_id: flowId,
+ flow_type: flowType,
+ change_description: 'Add scheduler via tooling',
+ schedules: [{ action: 'add', cron_tab: '0 8 * * *', timezone: 'UTC', state: 'enabled' }],
+ });
+ expect(added).toMatch(/success:\s*true/i);
+
+ let detail = await callToolText(session.client, 'get_flows', { flow_ids: [flowId] });
+ expect(detail).toMatch(/n_schedules:\s*1/);
+ expect(detail).toContain('0 8 * * *');
+ expect(detail).toContain('UTC');
+ expect(detail).toMatch(/state:\s*enabled/);
+ const scheduleId = detail.match(/scheduleId:\s*(\S+)/)?.[1];
+ expect(scheduleId).toBeTruthy();
+
+ // Update the schedule.
+ const updated = await callToolText(session.client, 'modify_flow', {
+ configuration_id: flowId,
+ flow_type: flowType,
+ change_description: 'Update scheduler via tooling',
+ schedules: [
+ {
+ action: 'update',
+ schedule_id: scheduleId,
+ cron_tab: '0 12 * * *',
+ timezone: 'America/New_York',
+ state: 'disabled',
+ },
+ ],
+ });
+ expect(updated).toMatch(/success:\s*true/i);
+
+ detail = await callToolText(session.client, 'get_flows', { flow_ids: [flowId] });
+ expect(detail).toMatch(/n_schedules:\s*1/);
+ expect(detail).toContain('0 12 * * *');
+ expect(detail).toContain('America/New_York');
+ expect(detail).toMatch(/state:\s*disabled/);
+
+ // Remove the schedule.
+ const removed = await callToolText(session.client, 'modify_flow', {
+ configuration_id: flowId,
+ flow_type: flowType,
+ change_description: 'Remove scheduler via tooling',
+ schedules: [{ action: 'remove', schedule_id: scheduleId }],
+ });
+ expect(removed).toMatch(/success:\s*true/i);
+
+ detail = await callToolText(session.client, 'get_flows', { flow_ids: [flowId] });
+ expect(detail).toMatch(/n_schedules:\s*0/);
+ } finally {
+ await session.close();
+ }
+ });
+});
diff --git a/integtests/tools/flow/__init__.py b/integtests/tools/flow/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/integtests/tools/flow/conftest.py b/integtests/tools/flow/conftest.py
deleted file mode 100644
index af9c2aabc..000000000
--- a/integtests/tools/flow/conftest.py
+++ /dev/null
@@ -1,126 +0,0 @@
-import logging
-from typing import AsyncGenerator
-from unittest.mock import AsyncMock
-
-import pytest
-import pytest_asyncio
-from fastmcp import Client, FastMCP
-from fastmcp.server.middleware import CallNext, MiddlewareContext
-from mcp import types as mt
-
-from integtests.conftest import ConfigDef
-from keboola_mcp_server.clients.client import (
- CONDITIONAL_FLOW_COMPONENT_ID,
- ORCHESTRATOR_COMPONENT_ID,
- KeboolaClient,
-)
-from keboola_mcp_server.config import Config, ServerRuntimeInfo
-from keboola_mcp_server.server import create_server
-from keboola_mcp_server.tools.flow.tools import FlowToolOutput
-
-LOG = logging.getLogger(__name__)
-
-
-@pytest.fixture
-def mcp_server(storage_api_url: str, storage_api_token: str, workspace_schema: str, mocker) -> FastMCP:
- # allow all tool calls regardless the testing project features
- async def on_call_tool(
- context: MiddlewareContext[mt.CallToolRequestParams],
- call_next: CallNext[mt.CallToolRequestParams, mt.CallToolResult],
- ) -> mt.CallToolResult:
- return await call_next(context)
-
- mocker.patch(
- 'keboola_mcp_server.server.ToolsFilteringMiddleware.on_call_tool', new=AsyncMock(side_effect=on_call_tool)
- )
-
- config = Config(storage_api_url=storage_api_url, storage_token=storage_api_token, workspace_schema=workspace_schema)
- return create_server(config, runtime_info=ServerRuntimeInfo(transport='stdio'))
-
-
-@pytest_asyncio.fixture
-async def mcp_client(mcp_server: FastMCP) -> AsyncGenerator[Client, None]:
- async with Client(mcp_server) as client:
- yield client
-
-
-@pytest_asyncio.fixture
-async def initial_lf(
- mcp_client: Client, configs: list[ConfigDef], keboola_client: KeboolaClient
-) -> AsyncGenerator[FlowToolOutput, None]:
- # Create the initial component configuration test data
- tool_result = await mcp_client.call_tool(
- name='create_flow',
- arguments={
- 'name': 'Initial Test Flow',
- 'description': 'Initial test flow created by automated test',
- 'phases': [{'name': 'Phase1', 'dependsOn': [], 'description': 'First phase'}],
- 'tasks': [
- {
- 'id': 20001,
- 'name': 'Task1',
- 'phase': 1,
- 'continueOnFailure': False,
- 'enabled': False,
- 'task': {
- 'componentId': configs[0].component_id,
- 'configId': configs[0].configuration_id,
- 'mode': 'run',
- },
- }
- ],
- },
- )
- try:
- yield FlowToolOutput.model_validate(tool_result.structured_content)
- finally:
- # Clean up: Delete the configuration
- await keboola_client.storage_client.configuration_delete(
- component_id=ORCHESTRATOR_COMPONENT_ID,
- configuration_id=tool_result.structured_content['configuration_id'],
- skip_trash=True,
- )
-
-
-@pytest_asyncio.fixture
-async def initial_cf(
- mcp_client: Client, configs: list[ConfigDef], keboola_client: KeboolaClient
-) -> AsyncGenerator[FlowToolOutput, None]:
- # Create the initial component configuration test data
- tool_result = await mcp_client.call_tool(
- name='create_conditional_flow',
- arguments={
- 'name': 'Initial Test Flow',
- 'description': 'Initial test flow created by automated test',
- 'phases': [
- {
- 'id': 'phase1',
- 'name': 'Phase1',
- 'description': 'First phase',
- 'next': [{'id': 'phase1_end', 'name': 'End Flow', 'goto': None}],
- },
- ],
- 'tasks': [
- {
- 'id': 'task1',
- 'name': 'Task1',
- 'phase': 'phase1',
- 'task': {
- 'type': 'job',
- 'componentId': configs[0].component_id,
- 'configId': configs[0].configuration_id,
- 'mode': 'run',
- },
- },
- ],
- },
- )
- try:
- yield FlowToolOutput.model_validate(tool_result.structured_content)
- finally:
- # Clean up: Delete the configuration
- await keboola_client.storage_client.configuration_delete(
- component_id=CONDITIONAL_FLOW_COMPONENT_ID,
- configuration_id=tool_result.structured_content['configuration_id'],
- skip_trash=True,
- )
diff --git a/integtests/tools/flow/test_scheduler.py b/integtests/tools/flow/test_scheduler.py
deleted file mode 100644
index 6c22f33c7..000000000
--- a/integtests/tools/flow/test_scheduler.py
+++ /dev/null
@@ -1,277 +0,0 @@
-import logging
-from typing import Any, cast
-
-import pytest
-from fastmcp import Client
-
-from integtests.conftest import ConfigDef
-from keboola_mcp_server.clients.client import ORCHESTRATOR_COMPONENT_ID, KeboolaClient
-from keboola_mcp_server.tools.flow.model import GetFlowsDetailOutput
-from keboola_mcp_server.tools.flow.scheduler import (
- SCHEDULER_COMPONENT_ID,
- create_schedule,
- list_schedules_for_config,
- remove_schedule,
- update_schedule,
-)
-from keboola_mcp_server.tools.flow.scheduler_model import ScheduleDetail
-from keboola_mcp_server.tools.flow.tools import FlowToolOutput
-
-LOG = logging.getLogger(__name__)
-
-
-@pytest.mark.asyncio
-async def test_scheduler_lifecycle(mcp_context, configs, keboola_client) -> None:
- """
- Test complete scheduler lifecycle: create, retrieve, update, and delete.
-
- :param mcp_context: The test context fixture.
- :param configs: List of real configuration definitions.
- :param keboola_client: KeboolaClient instance.
- """
- token_info = await keboola_client.storage_client.verify_token()
- admin_data = token_info.get('admin', {})
- token_role = admin_data.get('role') if isinstance(admin_data, dict) else None
- if token_role != 'admin':
- pytest.skip('Scheduler tooling requires an admin token, skipping test.')
-
- assert configs
- assert configs[0].configuration_id is not None
-
- # Use the first config as our target for scheduling
- target_component_id = configs[0].component_id
- target_configuration_id = configs[0].configuration_id
-
- # Initial schedule parameters
- initial_cron_tab = '0 8 * * *' # Daily at 8 AM
- initial_timezone = 'UTC'
- initial_state = 'enabled'
- schedule_name = 'Integration Test Schedule'
- schedule_description = 'Schedule created by integration test'
-
- created_schedule: ScheduleDetail | None = None
- scheduler_id: str | None = None
- try:
- # Step 1: Create a schedule
- LOG.info(f'Creating schedule for {target_component_id}/{target_configuration_id}')
- created_schedule = await create_schedule(
- client=keboola_client,
- target_component_id=target_component_id,
- target_configuration_id=target_configuration_id,
- cron_tab=initial_cron_tab,
- timezone=initial_timezone,
- state=initial_state,
- schedule_name=schedule_name,
- schedule_description=schedule_description,
- )
- scheduler_id = created_schedule.schedule_id
- assert isinstance(created_schedule, ScheduleDetail)
- assert created_schedule.schedule_id is not None
- assert created_schedule.cron_tab == initial_cron_tab
- assert created_schedule.timezone == initial_timezone
- assert created_schedule.state == initial_state
- LOG.info(f'Created schedule with ID: {created_schedule.schedule_id}')
-
- # Step 2: Retrieve the schedule using list_schedules_for_config
- LOG.info('Retrieving schedules for configuration')
- schedules = await list_schedules_for_config(
- client=keboola_client,
- component_id=target_component_id,
- configuration_id=target_configuration_id,
- )
-
- assert len(schedules) >= 1, 'At least one schedule should exist'
- found_schedule = next((s for s in schedules if s.schedule_id == created_schedule.schedule_id), None)
- assert found_schedule is not None, 'Created schedule should be in the list'
- assert found_schedule.cron_tab == initial_cron_tab
- assert found_schedule.timezone == initial_timezone
- assert found_schedule.state == initial_state
-
- # Step 3: Update the schedule
- updated_cron_tab = '0 12 * * *' # Daily at 12 PM
- updated_timezone = 'America/New_York'
- updated_state = 'disabled'
-
- LOG.info(f'Updating schedule {created_schedule.schedule_id}')
- updated_schedule = await update_schedule(
- client=keboola_client,
- schedule_config_id=created_schedule.schedule_id,
- cron_tab=updated_cron_tab,
- timezone=updated_timezone,
- state=updated_state,
- change_description='Integration test update',
- )
-
- assert isinstance(updated_schedule, ScheduleDetail)
- assert updated_schedule.schedule_id == created_schedule.schedule_id
- assert updated_schedule.cron_tab == updated_cron_tab
- assert updated_schedule.timezone == updated_timezone
- assert updated_schedule.state == updated_state
-
- # Step 4: Retrieve the schedule again to verify the update
- LOG.info('Retrieving schedules after update')
- schedules_after_update = await list_schedules_for_config(
- client=keboola_client,
- component_id=target_component_id,
- configuration_id=target_configuration_id,
- )
-
- found_updated_schedule = next(
- (s for s in schedules_after_update if s.schedule_id == created_schedule.schedule_id), None
- )
- assert found_updated_schedule is not None
- assert found_updated_schedule.cron_tab == updated_cron_tab
- assert found_updated_schedule.timezone == updated_timezone
- assert found_updated_schedule.state == updated_state
-
- # Step 5: Delete the schedule
- LOG.info(f'Deleting schedule {created_schedule.schedule_id}')
- await remove_schedule(
- client=keboola_client,
- schedule_config_id=created_schedule.schedule_id,
- )
-
- # Step 6: Verify the schedule is deleted
- LOG.info('Verifying schedule deletion')
- schedules_after_delete = await list_schedules_for_config(
- client=keboola_client,
- component_id=target_component_id,
- configuration_id=target_configuration_id,
- )
-
- deleted_schedule_exists = any(s.schedule_id == created_schedule.schedule_id for s in schedules_after_delete)
- assert not deleted_schedule_exists, 'Schedule should be deleted'
-
- LOG.info('Scheduler lifecycle test completed successfully')
- finally:
- if scheduler_id:
- try:
- await remove_schedule(client=keboola_client, schedule_config_id=scheduler_id)
- await remove_schedule(client=keboola_client, schedule_config_id=scheduler_id)
- await keboola_client.storage_client.configuration_delete(
- component_id=SCHEDULER_COMPONENT_ID, configuration_id=scheduler_id, skip_trash=True
- )
- except Exception:
- LOG.info('Schedule cleanup error; schedule already removed.')
-
-
-@pytest.mark.asyncio
-async def test_scheduler_lifecycle_tooling(
- initial_lf: FlowToolOutput, mcp_client: Client, configs: list[ConfigDef], keboola_client: KeboolaClient
-) -> None:
- """
- Test scheduler lifecycle using MCP tools: create schedule for a flow, update, and remove it.
- """
- token_info = await keboola_client.storage_client.verify_token()
- token_role = (token_info.get('admin', {}) or {}).get('role')
- if token_role != 'admin':
- pytest.skip('Scheduler tooling requires an admin token, skipping test.')
-
- assert configs
- assert configs[0].configuration_id is not None
-
- flow_id = initial_lf.configuration_id
-
- schedule_id: str | None = None
- try:
- initial_cron_tab = '0 8 * * *'
- initial_timezone = 'UTC'
-
- add_result = await mcp_client.call_tool(
- name='modify_flow',
- arguments={
- 'configuration_id': flow_id,
- 'flow_type': ORCHESTRATOR_COMPONENT_ID,
- 'change_description': 'Add scheduler via tooling',
- 'schedules': [
- {
- 'action': 'add',
- 'cron_tab': initial_cron_tab,
- 'timezone': initial_timezone,
- 'state': 'enabled',
- }
- ],
- },
- )
- add_output = FlowToolOutput.model_validate(add_result.structured_content)
- assert add_output.success is True
-
- tool_call_result = await mcp_client.call_tool(name='get_flows', arguments={'flow_ids': [flow_id]})
- struct_call_result = cast(dict[str, Any], tool_call_result.structured_content)
- flow_detail_result = GetFlowsDetailOutput.model_validate(struct_call_result['result'])
- flow_detail = flow_detail_result.flows[0]
- schedule = flow_detail.schedules.schedules[0]
- schedule_id = schedule.schedule_id
-
- assert flow_detail.schedules is not None
- assert flow_detail.schedules.n_schedules == 1
- assert schedule.cron_tab == initial_cron_tab
- assert schedule.timezone == initial_timezone
- assert schedule.state == 'enabled'
-
- updated_cron_tab = '0 12 * * *'
- updated_timezone = 'America/New_York'
-
- update_result = await mcp_client.call_tool(
- name='modify_flow',
- arguments={
- 'configuration_id': flow_id,
- 'flow_type': ORCHESTRATOR_COMPONENT_ID,
- 'change_description': 'Update scheduler via tooling',
- 'schedules': [
- {
- 'action': 'update',
- 'schedule_id': schedule_id,
- 'cron_tab': updated_cron_tab,
- 'timezone': updated_timezone,
- 'state': 'disabled',
- }
- ],
- },
- )
- update_output = FlowToolOutput.model_validate(update_result.structured_content)
- assert update_output.success is True
-
- tool_call_result = await mcp_client.call_tool(name='get_flows', arguments={'flow_ids': [flow_id]})
- struct_call_result = cast(dict[str, Any], tool_call_result.structured_content)
- flow_detail_result = GetFlowsDetailOutput.model_validate(struct_call_result['result'])
- flow_detail = flow_detail_result.flows[0]
-
- assert flow_detail.schedules is not None
- assert flow_detail.schedules.n_schedules == 1
- schedule = flow_detail.schedules.schedules[0]
- assert schedule.schedule_id == schedule_id
- assert schedule.cron_tab == updated_cron_tab
- assert schedule.timezone == updated_timezone
- assert schedule.state == 'disabled'
-
- remove_result = await mcp_client.call_tool(
- name='modify_flow',
- arguments={
- 'configuration_id': flow_id,
- 'flow_type': ORCHESTRATOR_COMPONENT_ID,
- 'change_description': 'Remove scheduler via tooling',
- 'schedules': [{'action': 'remove', 'schedule_id': schedule_id}],
- },
- )
- remove_output = FlowToolOutput.model_validate(remove_result.structured_content)
- assert remove_output.success is True
-
- tool_call_result = await mcp_client.call_tool(name='get_flows', arguments={'flow_ids': [flow_id]})
- struct_call_result = cast(dict[str, Any], tool_call_result.structured_content)
- flow_detail_result = GetFlowsDetailOutput.model_validate(struct_call_result['result'])
- flow_detail = flow_detail_result.flows[0]
-
- assert flow_detail.schedules is not None
- assert flow_detail.schedules.n_schedules == 0
- assert flow_detail.schedules.schedules == []
- finally:
- if schedule_id:
- try:
- await remove_schedule(client=keboola_client, schedule_config_id=schedule_id)
- await remove_schedule(client=keboola_client, schedule_config_id=schedule_id)
- await keboola_client.storage_client.configuration_delete(
- component_id=SCHEDULER_COMPONENT_ID, configuration_id=schedule_id, skip_trash=True
- )
- except Exception:
- LOG.info('Schedule cleanup error; schedule already removed.')
diff --git a/integtests/tools/flow/test_tools.py b/integtests/tools/flow/test_tools.py
deleted file mode 100644
index d1aadb157..000000000
--- a/integtests/tools/flow/test_tools.py
+++ /dev/null
@@ -1,1032 +0,0 @@
-import json
-import logging
-from typing import Any, cast
-
-import pydantic
-import pytest
-import toon_format
-import yaml
-from fastmcp import Client, Context
-from pydantic import ValidationError
-
-from integtests.conftest import ConfigDef, ProjectDef
-from keboola_mcp_server.clients.client import (
- CONDITIONAL_FLOW_COMPONENT_ID,
- ORCHESTRATOR_COMPONENT_ID,
- FlowType,
- KeboolaClient,
- get_metadata_property,
-)
-from keboola_mcp_server.config import MetadataField
-from keboola_mcp_server.errors import ToolError
-from keboola_mcp_server.links import Link, ProjectLinksManager
-from keboola_mcp_server.tools.constants import MODIFY_FLOW_TOOL_NAME, UPDATE_FLOW_TOOL_NAME
-from keboola_mcp_server.tools.flow.model import ConditionalFlowPhase, Flow, GetFlowsDetailOutput, GetFlowsListOutput
-from keboola_mcp_server.tools.flow.tools import (
- FlowToolOutput,
- create_conditional_flow,
- create_flow,
- get_flow_schema,
- get_flows,
-)
-from keboola_mcp_server.tools.project import get_project_info
-
-LOG = logging.getLogger(__name__)
-
-
-PYDANTIC_DOCS_VERSION = '.'.join(pydantic.__version__.split('.')[:2])
-
-
-@pytest.mark.asyncio
-async def test_create_and_retrieve_flow(mcp_context: Context, configs: list[ConfigDef]) -> None:
- """
- Create a flow and retrieve it using get_flows.
- :param mcp_context: The test context fixture.
- :param configs: List of real configuration definitions.
- """
- assert configs
- assert configs[0].configuration_id is not None
- flow_type = ORCHESTRATOR_COMPONENT_ID
- phases = [
- {'name': 'Extract', 'dependsOn': [], 'description': 'Extract data'},
- {'name': 'Transform', 'dependsOn': [1], 'description': 'Transform data'},
- ]
- tasks = [
- {
- 'name': 'Extract Task',
- 'phase': 1,
- 'task': {
- 'componentId': configs[0].component_id,
- 'configId': configs[0].configuration_id,
- },
- },
- {
- 'name': 'Transform Task',
- 'phase': 2,
- 'task': {
- 'componentId': configs[0].component_id,
- 'configId': configs[0].configuration_id,
- },
- },
- ]
- flow_name = 'Integration Test Flow'
- flow_description = 'Flow created by integration test.'
-
- created = await create_flow(
- ctx=mcp_context,
- name=flow_name,
- description=flow_description,
- phases=phases,
- tasks=tasks,
- )
- flow_id = created.configuration_id
- client = KeboolaClient.from_state(mcp_context.session.state)
- links_manager = await ProjectLinksManager.from_client(client)
- expected_links = [
- links_manager.get_flow_detail_link(flow_id=flow_id, flow_name=flow_name, flow_type=flow_type),
- links_manager.get_flows_dashboard_link(flow_type=flow_type),
- links_manager.get_flows_docs_link(),
- ]
- try:
- assert isinstance(created, FlowToolOutput)
- assert created.component_id == ORCHESTRATOR_COMPONENT_ID
- assert created.description == flow_description
- # Verify the links of created flow
- assert created.success is True
- assert set(created.links) == set(expected_links)
- assert created.version is not None
-
- # Verify the flow is listed in the get_flows tool
- result = await get_flows(mcp_context)
- assert isinstance(result, GetFlowsListOutput)
- assert any(f.name == flow_name for f in result.flows)
- found = [f for f in result.flows if f.configuration_id == flow_id][0]
- flow_detail_result = await get_flows(mcp_context, flow_ids=[found.configuration_id])
- assert isinstance(flow_detail_result, GetFlowsDetailOutput)
- flow = flow_detail_result.flows[0]
-
- assert isinstance(flow, Flow)
- assert flow.component_id == ORCHESTRATOR_COMPONENT_ID
- assert flow.configuration_id == found.configuration_id
- assert flow.configuration.phases[0].name == 'Extract'
- assert flow.configuration.phases[1].name == 'Transform'
- assert flow.configuration.tasks[0].task['componentId'] == configs[0].component_id
- assert set(flow.links) == set(expected_links)
-
- # Verify the metadata - check that KBC.MCP.createdBy is set to 'true'
- metadata = await client.storage_client.configuration_metadata_get(
- component_id=ORCHESTRATOR_COMPONENT_ID, configuration_id=flow_id
- )
-
- # Convert metadata list to dictionary for easier checking
- # metadata is a list of dicts with 'key' and 'value' keys
- assert isinstance(metadata, list)
- metadata_dict = {item['key']: item['value'] for item in metadata if isinstance(item, dict)}
- assert MetadataField.CREATED_BY_MCP in metadata_dict
- assert metadata_dict[MetadataField.CREATED_BY_MCP] == 'true'
- finally:
- await client.storage_client.configuration_delete(
- component_id=ORCHESTRATOR_COMPONENT_ID,
- configuration_id=flow_id,
- skip_trash=True,
- )
-
-
-@pytest.mark.asyncio
-async def test_create_and_retrieve_conditional_flow(mcp_context: Context, configs: list[ConfigDef]) -> None:
- """
- Create a conditional flow and retrieve it using get_flows.
- :param mcp_context: The test context fixture.
- :param configs: List of real configuration definitions.
- """
- assert configs
- assert configs[0].configuration_id is not None
- flow_type = CONDITIONAL_FLOW_COMPONENT_ID
-
- phases = [
- {
- 'id': 'extract_phase',
- 'name': 'Extract',
- 'description': 'Extract data',
- 'next': [{'id': 'extract_to_transform', 'name': 'Extract to Transform', 'goto': 'transform_phase'}],
- },
- {
- 'id': 'transform_phase',
- 'name': 'Transform',
- 'description': 'Transform data',
- 'next': [{'id': 'transform_end', 'name': 'End Flow', 'goto': None}],
- },
- ]
- tasks = [
- {
- 'id': 'extract_task',
- 'name': 'Extract Task',
- 'phase': 'extract_phase',
- 'task': {
- 'type': 'job',
- 'componentId': configs[0].component_id,
- 'configId': configs[0].configuration_id,
- 'mode': 'run',
- },
- },
- {
- 'id': 'transform_task',
- 'name': 'Transform Task',
- 'phase': 'transform_phase',
- 'task': {
- 'type': 'job',
- 'componentId': configs[0].component_id,
- 'configId': configs[0].configuration_id,
- 'mode': 'run',
- },
- },
- ]
- flow_name = 'Integration Test Conditional Flow'
- flow_description = 'Conditional flow created by integration test.'
-
- created = await create_conditional_flow(
- ctx=mcp_context,
- name=flow_name,
- description=flow_description,
- phases=phases,
- tasks=tasks,
- )
- flow_id = created.configuration_id
- client = KeboolaClient.from_state(mcp_context.session.state)
- links_manager = await ProjectLinksManager.from_client(client)
- expected_links = [
- links_manager.get_flow_detail_link(flow_id=flow_id, flow_name=flow_name, flow_type=flow_type),
- links_manager.get_flows_dashboard_link(flow_type=flow_type),
- links_manager.get_flows_docs_link(),
- ]
- try:
- assert isinstance(created, FlowToolOutput)
- assert created.component_id == CONDITIONAL_FLOW_COMPONENT_ID
- assert created.description == flow_description
- assert created.success is True
- assert set(created.links) == set(expected_links)
- assert created.version is not None
-
- # Verify the flow is listed in the get_flows tool
- result = await get_flows(mcp_context)
- assert isinstance(result, GetFlowsListOutput)
- assert any(f.name == flow_name for f in result.flows)
- found = [f for f in result.flows if f.configuration_id == flow_id][0]
- flow_detail_result = await get_flows(mcp_context, flow_ids=[found.configuration_id])
- assert isinstance(flow_detail_result, GetFlowsDetailOutput)
- flow = flow_detail_result.flows[0]
-
- assert isinstance(flow, Flow)
- assert flow.component_id == CONDITIONAL_FLOW_COMPONENT_ID
- assert flow.configuration_id == found.configuration_id
- assert flow.configuration.phases[0].name == 'Extract'
- assert flow.configuration.phases[1].name == 'Transform'
- assert flow.configuration.tasks[0].task.component_id == configs[0].component_id
- assert set(flow.links) == set(expected_links)
-
- # Verify the metadata - check that KBC.MCP.createdBy is set to 'true'
- metadata = await client.storage_client.configuration_metadata_get(
- component_id=CONDITIONAL_FLOW_COMPONENT_ID, configuration_id=flow_id
- )
-
- # Convert metadata list to dictionary for easier checking
- # metadata is a list of dicts with 'key' and 'value' keys
- assert isinstance(metadata, list)
- metadata_dict = {item['key']: item['value'] for item in metadata if isinstance(item, dict)}
- assert MetadataField.CREATED_BY_MCP in metadata_dict
- assert metadata_dict[MetadataField.CREATED_BY_MCP] == 'true'
- finally:
- await client.storage_client.configuration_delete(
- component_id=CONDITIONAL_FLOW_COMPONENT_ID,
- configuration_id=flow_id,
- skip_trash=True,
- )
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize(
- ('flow_type', 'updates'),
- [
- (
- ORCHESTRATOR_COMPONENT_ID,
- {
- 'phases': [
- {'id': 1, 'name': 'Phase1', 'dependsOn': [], 'description': 'First phase updated'},
- {'id': 2, 'name': 'Phase2', 'dependsOn': [], 'description': 'Second phase added'},
- ],
- 'tasks': [
- {
- 'id': 20001,
- 'name': 'Task1 - Updated',
- 'phase': 1,
- 'continueOnFailure': False,
- 'enabled': False,
- 'task': {'componentId': 'ex-generic-v2', 'configId': 'test_config_001', 'mode': 'run'},
- },
- {
- 'id': 20002,
- 'name': 'Task2 - Added',
- 'phase': 2,
- 'continueOnFailure': False,
- 'enabled': False,
- 'task': {'componentId': 'ex-generic-v2', 'configId': 'test_config_002', 'mode': 'run'},
- },
- ],
- 'name': 'Updated Test Flow',
- 'description': 'The test flow updated by an automated test.',
- },
- ),
- (
- ORCHESTRATOR_COMPONENT_ID,
- {
- 'phases': [
- {'id': 1, 'name': 'Phase1', 'dependsOn': [], 'description': 'First phase updated'},
- {'id': 2, 'name': 'Phase2', 'dependsOn': [], 'description': 'Second phase added'},
- ]
- },
- ),
- (
- ORCHESTRATOR_COMPONENT_ID,
- {
- 'tasks': [
- {
- 'id': 20001,
- 'name': 'Task1 - Updated',
- 'phase': 1,
- 'continueOnFailure': False,
- 'enabled': False,
- 'task': {'componentId': 'ex-generic-v2', 'configId': 'test_config_001', 'mode': 'run'},
- },
- {
- 'id': 20002,
- 'name': 'Task2 - Added',
- 'phase': 1,
- 'continueOnFailure': False,
- 'enabled': False,
- 'task': {'componentId': 'ex-generic-v2', 'configId': 'test_config_002', 'mode': 'run'},
- },
- ]
- },
- ),
- (ORCHESTRATOR_COMPONENT_ID, {'name': 'Updated just name'}),
- (ORCHESTRATOR_COMPONENT_ID, {'description': 'Updated just description'}),
- (
- CONDITIONAL_FLOW_COMPONENT_ID,
- {
- 'phases': [
- {
- 'id': 'phase1',
- 'name': 'Phase1',
- 'description': 'First phase updated',
- 'next': [{'id': 'phase1_phase2', 'name': 'End Flow', 'goto': 'phase2'}],
- },
- {
- 'id': 'phase2',
- 'name': 'Phase2',
- 'description': 'Second phase added',
- 'next': [{'id': 'phase2_end', 'name': 'End Flow', 'goto': None}],
- },
- ],
- 'tasks': [
- {
- 'id': 'task1',
- 'name': 'Task1 - Updated',
- 'phase': 'phase1',
- 'task': {
- 'type': 'job',
- 'componentId': 'ex-generic-v2',
- 'configId': 'test_config_001',
- 'mode': 'run',
- },
- },
- {
- 'id': 'task2',
- 'name': 'Task2 - Added',
- 'phase': 'phase2',
- 'task': {
- 'type': 'job',
- 'componentId': 'ex-generic-v2',
- 'configId': 'test_config_002',
- 'mode': 'run',
- },
- },
- ],
- },
- ),
- (
- CONDITIONAL_FLOW_COMPONENT_ID,
- {
- 'phases': [
- {
- 'id': 'phase1',
- 'name': 'Phase1',
- 'description': 'First phase updated',
- 'next': [{'id': 'phase1_phase2', 'name': 'End Flow', 'goto': 'phase2'}],
- },
- {
- 'id': 'phase2',
- 'name': 'Phase2',
- 'description': 'Second phase added',
- 'next': [{'id': 'phase2_end', 'name': 'End Flow', 'goto': None}],
- },
- ]
- },
- ),
- (
- CONDITIONAL_FLOW_COMPONENT_ID,
- {
- 'tasks': [
- {
- 'id': 'task1',
- 'name': 'Task1 - Updated',
- 'phase': 'phase1',
- 'task': {
- 'type': 'job',
- 'componentId': 'ex-generic-v2',
- 'configId': 'test_config_001',
- 'mode': 'run',
- },
- },
- {
- 'id': 'task2',
- 'name': 'Task2 - Added',
- 'phase': 'phase1',
- 'task': {
- 'type': 'job',
- 'componentId': 'ex-generic-v2',
- 'configId': 'test_config_002',
- 'mode': 'run',
- },
- },
- ]
- },
- ),
- (CONDITIONAL_FLOW_COMPONENT_ID, {'name': 'Updated just name'}),
- (CONDITIONAL_FLOW_COMPONENT_ID, {'description': 'Updated just description'}),
- (ORCHESTRATOR_COMPONENT_ID, {'is_disabled': True}),
- (CONDITIONAL_FLOW_COMPONENT_ID, {'is_disabled': True}),
- ],
-)
-async def test_update_flow(
- flow_type: FlowType,
- updates: dict[str, Any],
- initial_lf: FlowToolOutput,
- initial_cf: FlowToolOutput,
- mcp_client: Client,
- keboola_project: ProjectDef,
- keboola_client: KeboolaClient,
- storage_api_url: str,
-) -> None:
- """Tests that 'update_flow' tool works as expected."""
- flow_id = initial_lf.configuration_id if flow_type == ORCHESTRATOR_COMPONENT_ID else initial_cf.configuration_id
- tool_call_result = await mcp_client.call_tool(name='get_flows', arguments={'flow_ids': [flow_id]})
- struct_call_result = cast(dict[str, Any], tool_call_result.structured_content)
- initial_flow_result = GetFlowsDetailOutput.model_validate(struct_call_result['result'])
- initial_flow = initial_flow_result.flows[0]
-
- # Determine the tool name to use based on the token role, should not break if not using schedulers
- token_info = await keboola_client.storage_client.verify_token()
- token_role = (token_info.get('admin', {}) or {}).get('role')
- if token_role == 'admin':
- tool_name = MODIFY_FLOW_TOOL_NAME
- else:
- tool_name = UPDATE_FLOW_TOOL_NAME
-
- project_id = keboola_project.project_id
- tool_result = await mcp_client.call_tool(
- name=tool_name,
- arguments={
- 'configuration_id': flow_id,
- 'flow_type': flow_type,
- 'change_description': 'Integration test update',
- **updates,
- },
- )
-
- # Check the tool's output
- updated_flow = FlowToolOutput.model_validate(tool_result.structured_content)
- assert updated_flow.configuration_id == flow_id
- assert updated_flow.component_id == flow_type
- assert updated_flow.success is True
- assert updated_flow.timestamp is not None
- assert updated_flow.version is not None
-
- expected_name = updates.get('name') or 'Initial Test Flow'
- expected_description = updates.get('description') or initial_flow.description
- assert updated_flow.description == expected_description
- if flow_type == ORCHESTRATOR_COMPONENT_ID:
- flow_path = 'flows'
- flow_label = 'Flows'
- else:
- flow_path = 'flows-v2'
- flow_label = 'Conditional Flows'
- assert frozenset(updated_flow.links) == frozenset(
- [
- Link(
- type='ui-detail',
- title=f'Flow: {expected_name}',
- url=f'{storage_api_url}/admin/projects/{project_id}/{flow_path}/{flow_id}',
- ),
- Link(
- type='ui-dashboard',
- title=f'{flow_label} in the project',
- url=f'{storage_api_url}/admin/projects/{project_id}/{flow_path}',
- ),
- Link(type='docs', title='Documentation for Keboola Flows', url='https://help.keboola.com/flows/'),
- ]
- )
-
- # Verify the configuration was updated
- tool_call_result = await mcp_client.call_tool(name='get_flows', arguments={'flow_ids': [flow_id]})
- struct_call_result = cast(dict[str, Any], tool_call_result.structured_content)
- flow_detail_result = GetFlowsDetailOutput.model_validate(struct_call_result['result'])
- flow_detail = flow_detail_result.flows[0]
-
- assert flow_detail.name == expected_name
- assert flow_detail.description == expected_description
-
- # Verify is_disabled if it was updated
- expected_is_disabled = updates.get('is_disabled')
- if expected_is_disabled is not None:
- assert flow_detail.is_disabled == expected_is_disabled
-
- flow_data = flow_detail.configuration.model_dump(exclude_unset=True, by_alias=True)
-
- # Check that ids, names, and transitions match for phases using assert all
- if updates.get('phases'):
- # Convert the phases to get the expected format.
- if flow_type == ORCHESTRATOR_COMPONENT_ID:
- expected_phases = updates['phases']
- else:
- expected_phases = [
- ConditionalFlowPhase.model_validate(phase).model_dump(exclude_unset=True, by_alias=True)
- for phase in updates['phases']
- ]
- else:
- expected_phases = [
- phase.model_dump(exclude_unset=True, by_alias=True) for phase in initial_flow.configuration.phases
- ]
- assert len(flow_data['phases']) == len(
- expected_phases
- ), f"Phases count mismatch: {len(flow_data['phases'])} vs {len(expected_phases)}"
- assert all(
- actual['id'] == expected['id']
- and actual['name'] == expected['name']
- and len(actual.get('next', [])) == len(expected.get('next', []))
- and all(
- act_tr['id'] == exp_tr['id'] and act_tr['goto'] == exp_tr['goto']
- for act_tr, exp_tr in zip(actual.get('next', []), expected.get('next', []))
- )
- for actual, expected in zip(flow_data['phases'], expected_phases)
- ), f"Phase id, name, or transitions do not match!\nExpected: {expected_phases}\nGot: {flow_data['phases']}"
-
- # Check that all task ids and names match between actual and expected using all()
- if updates.get('tasks'):
- expected_tasks = updates['tasks']
- else:
- expected_tasks = [
- task.model_dump(exclude_unset=True, by_alias=True) for task in initial_flow.configuration.tasks
- ]
- assert all(
- actual_task['id'] == expected_task['id'] and actual_task['name'] == expected_task['name']
- for actual_task, expected_task in zip(flow_data['tasks'], expected_tasks)
- ), f"Task id or name mismatch!\nExpected: {expected_tasks}\nGot: {flow_data['tasks']}"
-
- current_version = flow_detail.version
- assert current_version == 2
-
- # Verify is_disabled in the raw API configuration if it was updated
- if expected_is_disabled is not None:
- raw_config = await keboola_client.storage_client.configuration_detail(
- component_id=flow_type, configuration_id=flow_id
- )
- assert raw_config.get('isDisabled') == expected_is_disabled
-
- # Check that KBC.MCP.updatedBy.version.{version} is set to 'true'
- metadata = await keboola_client.storage_client.configuration_metadata_get(
- component_id=flow_type, configuration_id=updated_flow.configuration_id
- )
- assert isinstance(metadata, list), f'Expecting list, got: {type(metadata)}'
-
- meta_key = f'{MetadataField.UPDATED_BY_MCP_PREFIX}{current_version}'
- meta_value = get_metadata_property(metadata, meta_key)
- assert meta_value == 'true'
- # Check that the original creation metadata is still there
- assert get_metadata_property(metadata, MetadataField.CREATED_BY_MCP) == 'true'
-
-
-@pytest.mark.asyncio
-async def test_get_flows_empty(mcp_context: Context) -> None:
- """
- Retrieve flows when none exist (should not error, may return empty list).
- :param mcp_context: The test context fixture.
- """
- flows = await get_flows(mcp_context)
- assert isinstance(flows, GetFlowsListOutput)
- assert len(flows.flows) == 0
-
-
-@pytest.mark.asyncio
-async def test_get_flows_list(
- keboola_project: ProjectDef,
- mcp_client: Client,
- initial_lf: FlowToolOutput,
- initial_cf: FlowToolOutput,
- storage_api_url: str,
-) -> None:
- """Tests that `get_flows` tool works as expected when listing all flows."""
- tool_call_result = await mcp_client.call_tool(name='get_flows', arguments={})
- struct_call_result = cast(dict[str, Any], tool_call_result.structured_content)
- flows = GetFlowsListOutput.model_validate(struct_call_result['result'])
- assert len(flows.flows) == 2
- assert frozenset(flows.links) == frozenset(
- [
- Link(
- type='ui-dashboard',
- title='Flows in the project',
- url=f'{storage_api_url}/admin/projects/{keboola_project.project_id}/flows',
- ),
- Link(
- type='ui-dashboard',
- title='Conditional Flows in the project',
- url=f'{storage_api_url}/admin/projects/{keboola_project.project_id}/flows-v2',
- ),
- ]
- )
- assert flows.flows[0].configuration_id == initial_cf.configuration_id
- assert flows.flows[1].configuration_id == initial_lf.configuration_id
- assert tool_call_result.content is not None
- assert len(tool_call_result.content) == 1
- assert tool_call_result.content[0].type == 'text'
- toon_decoded = toon_format.decode(tool_call_result.content[0].text)
- assert GetFlowsListOutput.model_validate(toon_decoded) == flows
-
-
-@pytest.mark.asyncio
-async def test_get_flow_schema(mcp_context: Context) -> None:
- """
- Test that get_flow_schema returns the flow configuration JSON schema.
- Tests the conditional behavior where the tool might return a different schema
- than requested based on project settings.
- """
- project_info = await get_project_info(mcp_context)
-
- # Test 1: Request orchestrator schema (should always work)
- legacy_flow_schema = await get_flow_schema(mcp_context, ORCHESTRATOR_COMPONENT_ID)
-
- assert isinstance(legacy_flow_schema, str)
- assert legacy_flow_schema.startswith('```json\n')
- assert legacy_flow_schema.endswith('\n```')
- assert 'dependsOn' in legacy_flow_schema
-
- # Extract and parse the JSON content to verify it's valid
- json_content = legacy_flow_schema[8:-4] # Remove ```json\n and \n```
- parsed_legacy_schema = json.loads(json_content)
-
- # Verify basic schema structure for legacy flow
- assert isinstance(parsed_legacy_schema, dict)
- assert '$schema' in parsed_legacy_schema
- assert 'properties' in parsed_legacy_schema
- assert 'phases' in parsed_legacy_schema['properties']
- assert 'tasks' in parsed_legacy_schema['properties']
-
- # Test 2: Request conditional flow schema (behavior depends on project settings)
- conditional_schema = await get_flow_schema(mcp_context, CONDITIONAL_FLOW_COMPONENT_ID)
-
- assert isinstance(conditional_schema, str)
- assert conditional_schema.startswith('```json\n')
- assert conditional_schema.endswith('\n```')
-
- # Extract and parse the JSON content
- json_content = conditional_schema[8:-4] # Remove ```json\n and \n```
- parsed_conditional_schema = json.loads(json_content)
-
- # Test 3: Verify the conditional behavior
- if not project_info.conditional_flows:
- # If the project does not support conditional flows, both requests should return the same schema
- assert legacy_flow_schema == conditional_schema
- LOG.info('Project has conditional flows disabled - both schemas are identical')
- else:
- # If conditional flows are enabled, the schemas should be different
- assert legacy_flow_schema != conditional_schema
- LOG.info('Project has conditional flows enabled - schemas are different')
-
- # Verify that the conditional schema has conditional-specific properties
- conditional_phases = parsed_conditional_schema['properties']['phases']['items']['properties']
- assert 'next' in conditional_phases # Conditional flows use 'next' instead of 'dependsOn'
-
- conditional_tasks = parsed_conditional_schema['properties']['tasks']['items']['properties']['task']
- assert 'oneOf' in conditional_tasks # Conditional flows have structured task types
-
- # The conditional schema is sourced live from the Developer Portal — it must be non-empty
- # and structurally a flow schema (not a stale/empty bundled placeholder).
- assert parsed_conditional_schema # non-empty dict
- assert parsed_conditional_schema['properties']['phases']['items']['properties']
- assert parsed_conditional_schema['properties']['tasks']['items']['properties']
-
-
-@pytest.mark.asyncio
-async def test_create_legacy_flow_invalid_structure(mcp_context: Context, configs: list[ConfigDef]) -> None:
- """
- Create a legacy flow with invalid structure (should raise ValueError).
- :param mcp_context: The test context fixture.
- :param configs: List of real configuration definitions.
- """
- assert configs
- assert configs[0].configuration_id is not None
- phases = [
- {'name': 'Phase1', 'dependsOn': [99], 'description': 'Depends on non-existent phase'},
- ]
- tasks = [
- {
- 'name': 'Task1',
- 'phase': 1,
- 'task': {
- 'componentId': configs[0].component_id,
- 'configId': configs[0].configuration_id,
- },
- },
- ]
- with pytest.raises(ValueError, match='depends on non-existent phase'):
- await create_flow(
- ctx=mcp_context,
- name='Invalid Legacy Flow',
- description='Should fail',
- phases=phases,
- tasks=tasks,
- )
-
-
-@pytest.mark.asyncio
-async def test_create_conditional_flow_invalid_structure(mcp_context: Context, configs: list[ConfigDef]) -> None:
- """
- Create a conditional flow with invalid structure (should raise ValueError).
- :param mcp_context: The test context fixture.
- :param configs: List of real configuration definitions.
- """
- assert configs
- assert configs[0].configuration_id is not None
-
- # Test invalid conditional flow structure - missing required fields and invalid types
- phases = [
- {
- 'id': 123, # Invalid: should be string, not integer
- 'name': '', # Invalid: empty string not allowed
- 'next': [{'id': 'transition-1', 'goto': 'phase-2'}],
- }
- ]
-
- tasks = [
- {
- 'id': 'task-1',
- 'name': 'Task1',
- 'phase': 'phase-1',
- 'enabled': True,
- 'task': {
- 'type': 'invalid_type', # Invalid: not one of job, notification, variable
- 'componentId': configs[0].component_id,
- 'configId': configs[0].configuration_id,
- 'mode': 'invalid_mode', # Invalid: should be 'run'
- },
- }
- ]
-
- with pytest.raises(ToolError) as exc_info:
- await create_conditional_flow(
- ctx=mcp_context,
- name='Invalid Conditional Flow',
- description='Should fail',
- phases=phases,
- tasks=tasks,
- )
-
- err = exc_info.value
- assert isinstance(err.__cause__, ValidationError)
-
- lines = str(err).splitlines()
- assert len(lines) > 0, 'Empty error message'
- assert lines[0] == 'Found 2 validation error(s) for ConditionalFlowPhase'
- assert yaml.safe_load('\n'.join(lines[1:])) == {
- 'errors': [
- {
- 'field': 'id',
- 'message': 'Input should be a valid string',
- 'extra': {
- 'type': 'string_type',
- 'input': '123',
- 'url': f'https://errors.pydantic.dev/{PYDANTIC_DOCS_VERSION}/v/string_type',
- },
- },
- {
- 'field': 'name',
- 'message': 'String should have at least 1 character',
- 'extra': {
- 'type': 'string_too_short',
- 'input': '',
- 'ctx': "{'min_length': 1}",
- 'url': f'https://errors.pydantic.dev/{PYDANTIC_DOCS_VERSION}/v/string_too_short',
- },
- },
- ]
- }
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize(
- ('new_config', 'expected_error_message'),
- [
- (
- {
- 'phases': [
- {
- 'id': 'phase-1',
- 'name': 'Phase1',
- 'next': [{'id': 'transition-1', 'goto': None}],
- },
- {
- 'id': 'phase-2',
- 'name': 'Phase2',
- 'next': [{'id': 'transition-2', 'goto': None}],
- },
- ],
- 'tasks': [
- {
- 'id': 'task-1',
- 'name': 'Task1',
- 'phase': 'phase-1',
- 'task': {
- 'type': 'job',
- 'componentId': 'ex-generic-v2',
- 'configId': 'test_config_002',
- 'mode': 'run',
- },
- }
- ],
- },
- 'Flow has multiple entry phases',
- ),
- (
- {
- 'phases': [
- {
- 'id': 'phase-1',
- 'name': 'Phase1',
- 'next': [{'id': 'transition-1', 'goto': 'phase-2'}],
- },
- {
- 'id': 'phase-2',
- 'name': 'Phase2',
- 'next': [{'id': 'transition-2', 'goto': 'phase-1'}],
- },
- ],
- 'tasks': [
- {
- 'id': 'task-1',
- 'name': 'Task1',
- 'phase': 'phase-1',
- 'task': {
- 'type': 'job',
- 'componentId': 'ex-generic-v2',
- 'configId': 'test_config_002',
- 'mode': 'run',
- },
- },
- {
- 'id': 'task-2',
- 'name': 'Task2',
- 'phase': 'phase-2',
- 'task': {
- 'type': 'job',
- 'componentId': 'ex-generic-v2',
- 'configId': 'test_config_002',
- 'mode': 'run',
- },
- },
- ],
- },
- 'Flow has no ending phases',
- ),
- ],
-)
-async def test_create_conditional_flow_semantically_invalid_structure(
- mcp_context: Context, new_config: dict[str, list[dict]], expected_error_message: str
-) -> None:
- # Test invalid conditional flow structure - missing required fields and invalid types
- phases = new_config['phases']
- tasks = new_config['tasks']
-
- with pytest.raises(ValueError, match=expected_error_message):
- await create_conditional_flow(
- ctx=mcp_context,
- name='Invalid Conditional Flow',
- description='Should fail',
- phases=phases,
- tasks=tasks,
- )
-
-
-@pytest.mark.asyncio
-async def test_flow_lifecycle_integration(mcp_context: Context, configs: list[ConfigDef]) -> None:
- """
- Test complete flow lifecycle for both legacy and conditional flows.
- Creates flows, retrieves them individually, and lists all flows.
- Tests project-aware behavior based on conditional flows setting.
- """
- assert configs
- assert configs[0].configuration_id is not None
-
- project_info = await get_project_info(mcp_context)
-
- # Test data for legacy flow
- legacy_phases = [
- {'id': 1, 'name': 'Extract', 'description': 'Extract data from source', 'dependsOn': []},
- {'id': 2, 'name': 'Load', 'description': 'Load data to destination', 'dependsOn': [1]},
- ]
-
- legacy_tasks = [
- {
- 'id': 20001,
- 'name': 'Extract from API',
- 'phase': 1,
- 'enabled': True,
- 'continueOnFailure': False,
- 'task': {'componentId': configs[0].component_id, 'configId': configs[0].configuration_id, 'mode': 'run'},
- },
- {
- 'id': 20002,
- 'name': 'Load to Warehouse',
- 'phase': 2,
- 'enabled': True,
- 'continueOnFailure': False,
- 'task': {'componentId': configs[0].component_id, 'configId': configs[0].configuration_id, 'mode': 'run'},
- },
- ]
-
- # Test data for conditional flow
- conditional_phases = [
- {
- 'id': 'phase-1',
- 'name': 'Extract',
- 'description': 'Extract data from source',
- 'next': [{'id': 'transition-1', 'goto': 'phase-2'}],
- },
- {'id': 'phase-2', 'name': 'Load', 'description': 'Load data to destination', 'next': []},
- ]
-
- conditional_tasks = [
- {
- 'id': 'task-1',
- 'name': 'Extract from API',
- 'phase': 'phase-1',
- 'enabled': True,
- 'task': {
- 'type': 'job',
- 'componentId': configs[0].component_id,
- 'configId': configs[0].configuration_id,
- 'mode': 'run',
- },
- },
- {
- 'id': 'task-2',
- 'name': 'Load to Warehouse',
- 'phase': 'phase-2',
- 'enabled': True,
- 'task': {
- 'type': 'job',
- 'componentId': configs[0].component_id,
- 'configId': configs[0].configuration_id,
- 'mode': 'run',
- },
- },
- ]
-
- created_flows = []
-
- # Step 1: Create orchestrator flow (should always work)
- orchestrator_flow_name = 'Integration Test Orchestrator Flow'
- orchestrator_flow_description = 'Orchestrator flow created by integration test'
-
- orchestrator_result = await create_flow(
- ctx=mcp_context,
- name=orchestrator_flow_name,
- description=orchestrator_flow_description,
- phases=legacy_phases,
- tasks=legacy_tasks,
- )
-
- assert isinstance(orchestrator_result, FlowToolOutput)
- assert orchestrator_result.success is True
- assert orchestrator_result.component_id == ORCHESTRATOR_COMPONENT_ID
- assert orchestrator_result.description == orchestrator_flow_description
- assert orchestrator_result.version is not None
- created_flows.append((ORCHESTRATOR_COMPONENT_ID, orchestrator_result.configuration_id))
-
- # Step 2: Try to create conditional flow (only if project allows it)
- conditional_flow_name = 'Integration Test Conditional Flow'
- conditional_flow_description = 'Conditional flow created by integration test'
-
- if project_info.conditional_flows:
- conditional_result = await create_conditional_flow(
- ctx=mcp_context,
- name=conditional_flow_name,
- description=conditional_flow_description,
- phases=conditional_phases,
- tasks=conditional_tasks,
- )
-
- assert isinstance(conditional_result, FlowToolOutput)
- assert conditional_result.success is True
- assert conditional_result.component_id == CONDITIONAL_FLOW_COMPONENT_ID
- assert conditional_result.description == conditional_flow_description
- assert conditional_result.version is not None
- created_flows.append((CONDITIONAL_FLOW_COMPONENT_ID, conditional_result.configuration_id))
- else:
- LOG.info('Conditional flows are disabled in this project, skipping conditional flow creation')
-
- # Step 3: Get individual flows
- for flow_type, flow_id in created_flows:
- flow_result = await get_flows(mcp_context, flow_ids=[flow_id])
- assert isinstance(flow_result, GetFlowsDetailOutput)
- flow = flow_result.flows[0]
-
- assert isinstance(flow, Flow)
- assert flow.configuration_id == flow_id
-
- if flow_type == ORCHESTRATOR_COMPONENT_ID:
- assert flow.name == orchestrator_flow_name
- assert flow.component_id == ORCHESTRATOR_COMPONENT_ID
- assert len(flow.configuration.phases) == 2
- assert len(flow.configuration.tasks) == 2
- assert flow.configuration.phases[0].name == 'Extract'
- assert flow.configuration.phases[1].name == 'Load'
- else:
- assert flow.name == conditional_flow_name
- assert flow.component_id == CONDITIONAL_FLOW_COMPONENT_ID
- assert len(flow.configuration.phases) == 2
- assert len(flow.configuration.tasks) == 2
- assert flow.configuration.phases[0].name == 'Extract'
- assert flow.configuration.phases[1].name == 'Load'
-
- # Step 4: List all flows and verify our created flows are there
- flows_list = await get_flows(mcp_context)
-
- assert isinstance(flows_list, GetFlowsListOutput)
- assert len(flows_list.flows) >= len(created_flows)
-
- # Verify our created flows are in the list
- flow_ids = [flow.configuration_id for flow in flows_list.flows]
- for flow_type, flow_id in created_flows:
- assert flow_id in flow_ids, f'Created {flow_type} flow {flow_id} not found in flows list'
-
- # Step 5: Clean up - delete all created flows
- client = KeboolaClient.from_state(mcp_context.session.state)
- for flow_type, flow_id in created_flows:
- try:
- await client.storage_client.configuration_delete(
- component_id=flow_type,
- configuration_id=flow_id,
- skip_trash=True,
- )
- LOG.info(f'Successfully deleted {flow_type} flow {flow_id}')
- except Exception as e:
- LOG.warning(f'Failed to delete {flow_type} flow {flow_id}: {e}')
diff --git a/integtests/tools/jobs.test.ts b/integtests/tools/jobs.test.ts
new file mode 100644
index 000000000..dce4acd46
--- /dev/null
+++ b/integtests/tools/jobs.test.ts
@@ -0,0 +1,180 @@
+import { describe, expect, it } from 'vitest';
+
+import { callToolText, connectMcp } from '../helpers/mcp';
+import { seedProject } from '../helpers/seed';
+import { getTestProjectForTest } from '../testproject/fixture';
+
+// Ported from integtests/tools/test_jobs.py. Each case leases a fresh project, seeds the
+// standard fixtures (which include an ex-generic-v2 config), then runs a job against that
+// config and polls get_jobs. Real jobs against the live stack take ~10-40s, so the suite is
+// intentionally slow.
+
+const RUN_TIMEOUT = 120_000;
+
+/**
+ * Polls get_jobs (listing mode, filtered by component + config) until the just-started job
+ * id shows up, mirroring the Python `_wait_for_job_in_list` retry helper (the queue is
+ * eventually-consistent right after a job is created).
+ */
+const waitForJobInList = async (
+ client: Parameters[0],
+ jobId: string,
+ componentId: string,
+ configId: string,
+ maxRetries = 15,
+ delayMs = 1000,
+): Promise => {
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
+ const text = await callToolText(client, 'get_jobs', {
+ component_id: componentId,
+ config_id: configId,
+ limit: 10,
+ sort_by: 'startTime',
+ sort_order: 'desc',
+ });
+ if (text.includes(jobId)) return text;
+ if (attempt < maxRetries - 1) await new Promise((r) => setTimeout(r, delayMs));
+ }
+ throw new Error(`Job ${jobId} not found in job list after ${maxRetries} attempts`);
+};
+
+/** Extracts the started job id from the run_job TOON output (top-level `id: `). */
+const extractJobId = (runJobText: string): string => {
+ // TOON quotes numeric-looking string scalars, so the id may be `id: "123"` or `id: 123`.
+ const match = runJobText.match(/\bid:\s*"?(\d+)"?/);
+ expect(match, `run_job output should contain a job id. Got: ${runJobText}`).not.toBeNull();
+ return match![1]!;
+};
+
+describe('jobs tools (integration)', () => {
+ it(
+ 'run_job starts a job and get_jobs lists it under the component/config filter',
+ async () => {
+ const project = await getTestProjectForTest();
+ const seeded = await seedProject(project);
+ const config = seeded.configs.find((c) => c.componentId === 'ex-generic-v2')!;
+ const session = await connectMcp(project.config);
+ try {
+ const runText = await callToolText(session.client, 'run_job', {
+ component_id: config.componentId,
+ configuration_id: config.configurationId,
+ });
+ expect(runText).toContain(config.componentId);
+ expect(runText).toContain(config.configurationId);
+ const jobId = extractJobId(runText);
+
+ const listText = await waitForJobInList(
+ session.client,
+ jobId,
+ config.componentId,
+ config.configurationId,
+ );
+ // Every listed job under this filter must belong to the same component + config.
+ expect(listText).toContain(config.componentId);
+ expect(listText).toContain(config.configurationId);
+ } finally {
+ await session.close();
+ }
+ },
+ RUN_TIMEOUT,
+ );
+
+ it(
+ 'run_job then get_jobs(job_ids) returns the job detail with status, url and links',
+ async () => {
+ const project = await getTestProjectForTest();
+ const seeded = await seedProject(project);
+ const config = seeded.configs.find((c) => c.componentId === 'ex-generic-v2')!;
+ const session = await connectMcp(project.config);
+ try {
+ const runText = await callToolText(session.client, 'run_job', {
+ component_id: config.componentId,
+ configuration_id: config.configurationId,
+ });
+ const jobId = extractJobId(runText);
+ // The started-job response carries the component/config + UI links.
+ expect(runText).toContain(config.componentId);
+ expect(runText).toContain(config.configurationId);
+ expect(runText).toMatch(/queue\/\d+/);
+ expect(runText).toContain(`/queue/${jobId}`);
+
+ const detailText = await callToolText(session.client, 'get_jobs', { job_ids: [jobId] });
+ expect(detailText).toContain(jobId);
+ expect(detailText).toContain(config.componentId);
+ expect(detailText).toContain(config.configurationId);
+ // Detail includes a status, a url and the ui-detail / ui-dashboard links.
+ expect(detailText).toMatch(/status/i);
+ expect(detailText).toContain(`/queue/${jobId}`);
+ expect(detailText).toContain('ui-detail');
+ expect(detailText).toContain('ui-dashboard');
+ } finally {
+ await session.close();
+ }
+ },
+ RUN_TIMEOUT,
+ );
+
+ it(
+ 'get_jobs(job_ids, include_logs) returns the job detail and a logs section',
+ async () => {
+ const project = await getTestProjectForTest();
+ const seeded = await seedProject(project);
+ const config = seeded.configs.find((c) => c.componentId === 'ex-generic-v2')!;
+ const session = await connectMcp(project.config);
+ try {
+ const runText = await callToolText(session.client, 'run_job', {
+ component_id: config.componentId,
+ configuration_id: config.configurationId,
+ });
+ const jobId = extractJobId(runText);
+
+ const detailText = await callToolText(session.client, 'get_jobs', {
+ job_ids: [jobId],
+ include_logs: true,
+ });
+ expect(detailText).toContain(jobId);
+ expect(detailText).toMatch(/logs/i);
+ } finally {
+ await session.close();
+ }
+ },
+ RUN_TIMEOUT,
+ );
+
+ it(
+ 'run_job works against a freshly created config',
+ async () => {
+ const project = await getTestProjectForTest();
+ const session = await connectMcp(project.config);
+ try {
+ const componentId = 'ex-generic-v2';
+ const createText = await callToolText(session.client, 'create_config', {
+ name: 'Test Config for Job Run',
+ description: 'Test configuration created for job run test',
+ component_id: componentId,
+ parameters: { api: { baseUrl: 'https://wttr.in' } },
+ storage: {},
+ });
+ const cfgMatch = createText.match(/configuration_id:\s*"?([^\s"]+)"?/);
+ expect(cfgMatch, `create_config should return a configuration id. Got: ${createText}`).not.toBeNull();
+ const configurationId = cfgMatch![1]!;
+
+ const runText = await callToolText(session.client, 'run_job', {
+ component_id: componentId,
+ configuration_id: configurationId,
+ });
+ const jobId = extractJobId(runText);
+ expect(runText).toContain(componentId);
+ expect(runText).toContain(configurationId);
+
+ const detailText = await callToolText(session.client, 'get_jobs', { job_ids: [jobId] });
+ expect(detailText).toContain(jobId);
+ expect(detailText).toContain(componentId);
+ expect(detailText).toContain(configurationId);
+ } finally {
+ await session.close();
+ }
+ },
+ RUN_TIMEOUT,
+ );
+});
diff --git a/integtests/tools/project.test.ts b/integtests/tools/project.test.ts
new file mode 100644
index 000000000..b79031029
--- /dev/null
+++ b/integtests/tools/project.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+
+import { callToolText, connectMcp } from '../helpers/mcp';
+import { getTestProjectForTest } from '../testproject/fixture';
+
+// Ported from integtests/tools/test_project.py.
+describe('get_project_info (integration)', () => {
+ it('returns project id, sql dialect, links, and branch context', async () => {
+ const { config, projectId } = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(config);
+ try {
+ const text = await callToolText(session.client, 'get_project_info');
+
+ // project_id of the leased project.
+ expect(text).toContain(String(projectId));
+ // sql_dialect is one of the two supported backends.
+ expect(text).toMatch(/Snowflake|BigQuery/);
+ // links list is present (ui-detail / ui-dashboard / docs).
+ expect(text).toMatch(/ui-detail|ui-dashboard|docs/);
+ // The pool runs on the default (production) branch.
+ expect(text).toMatch(/is_development_branch[^\n]*false/i);
+ } finally {
+ await session.close();
+ }
+ });
+});
diff --git a/integtests/tools/search.test.ts b/integtests/tools/search.test.ts
new file mode 100644
index 000000000..be128586a
--- /dev/null
+++ b/integtests/tools/search.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest';
+
+import { callToolText, connectMcp } from '../helpers/mcp';
+import { seedProject } from '../helpers/seed';
+import { getTestProjectForTest } from '../testproject/fixture';
+
+// Ported from integtests/tools/test_search.py. (find_component_id moved to
+// integtests/tools/doc.test.ts — it is now served by the pgvector docs-search index.)
+describe('search (integration)', () => {
+ it('finds seeded buckets, tables and configs end-to-end', async () => {
+ const project = await getTestProjectForTest();
+ const seeded = await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ // Unscoped textual search across all item types for the 'test' name prefix.
+ const text = await callToolText(session.client, 'search', {
+ patterns: ['test'],
+ limit: 50,
+ offset: 0,
+ });
+
+ // The seeded buckets and table appear by id.
+ for (const bucket of seeded.buckets) expect(text).toContain(bucket.id);
+ for (const table of seeded.tables) expect(text).toContain(table.id);
+ // Both seeded configurations appear by id (ex-generic-v2 + snowflake-transformation).
+ for (const config of seeded.configs) expect(text).toContain(config.configurationId);
+ } finally {
+ await session.close();
+ }
+ });
+
+ it('config-based scoped search matches the ex-generic-v2 config by api.baseUrl', async () => {
+ const project = await getTestProjectForTest();
+ const seeded = await seedProject(project);
+ const config = seeded.configs.find((c) => c.componentId === 'ex-generic-v2')!;
+ const session = await connectMcp(project.config);
+ try {
+ const text = await callToolText(session.client, 'search', {
+ patterns: ['wttr.in'],
+ item_types: ['configuration'],
+ search_type: 'config-based',
+ scopes: ['parameters.api.baseUrl'],
+ limit: 20,
+ offset: 0,
+ });
+ expect(text).toContain('ex-generic-v2');
+ expect(text).toContain(config.configurationId);
+ } finally {
+ await session.close();
+ }
+ });
+});
diff --git a/integtests/tools/semantic.test.ts b/integtests/tools/semantic.test.ts
new file mode 100644
index 000000000..3fbe46b5a
--- /dev/null
+++ b/integtests/tools/semantic.test.ts
@@ -0,0 +1,346 @@
+import { randomUUID } from 'node:crypto';
+import { describe, expect, it } from 'vitest';
+
+import { callToolRaw, callToolText, connectMcp, type McpSession } from '../helpers/mcp';
+import { getTestProjectForTest, type TestProject } from '../testproject/fixture';
+
+import { createRawClient } from '@/clients/raw';
+import { deriveServiceUrls } from '@/clients/urls';
+import type { Config } from '@/config';
+
+// Ported from integtests/tools/semantic/test_tools.py.
+//
+// The four semantic tools (get_semantic_context, get_semantic_schema,
+// search_semantic_context, validate_semantic_query) are gated behind the
+// `mcp-semantic-tooling` project feature (src/mcp/filtering.ts). When the feature is
+// absent the tools are FILTERED OUT of tools/list and any call is DENIED with an
+// McpError -> the SDK surfaces that as a thrown/rejected promise carrying the message
+// 'is not available in this project ... "Semantic Layer Tooling" feature'.
+//
+// The shared pool projects do NOT have this feature (verified live: tokens/verify returns
+// no mcp-semantic-tooling for any pool project). So in this environment every semantic
+// test is expected to take the "feature absent" branch: we assert the documented
+// unavailable behavior (tool hidden + call denied) once, and skip the metastore-seeded
+// happy-path tests with a clear reason. The happy-path assertions are kept inline (guarded
+// behind the feature probe) so they run as soon as a project with the feature is leased.
+
+const SEMANTIC_TOOL_NAMES = [
+ 'search_semantic_context',
+ 'get_semantic_context',
+ 'get_semantic_schema',
+ 'validate_semantic_query',
+] as const;
+
+/** True if the leased project has the semantic-tooling feature surfaced (tools listed). */
+const semanticToolsAvailable = async (session: McpSession): Promise => {
+ const { tools } = await session.client.listTools();
+ const names = new Set(tools.map((t) => t.name));
+ return SEMANTIC_TOOL_NAMES.every((n) => names.has(n));
+};
+
+// ---------------------------------------------------------------------------
+// Metastore seeding (port of the Python `semantic_test_setup` fixture). Only used when the
+// feature is present; the raw metastore client mirrors keboola_client.metastore_client.
+// ---------------------------------------------------------------------------
+
+type MetastoreObject = { id: string; [k: string]: unknown };
+
+const createMetastore = (config: Config) => {
+ const urls = deriveServiceUrls(config.storageApiUrl!);
+ const token = config.bearerToken ? `Bearer ${config.bearerToken}` : config.storageToken!;
+ const raw = createRawClient({ baseUrl: urls.metastore, token });
+ return {
+ createObject: async (
+ objectType: string,
+ name: string,
+ data: Record,
+ ): Promise =>
+ raw.post(`objects/${objectType}`, { body: { name, data } }),
+ deleteObject: async (objectType: string, id: string): Promise => {
+ await raw.delete(`objects/${objectType}/${id}`);
+ },
+ };
+};
+
+type SemanticSetup = {
+ slug: string;
+ modelId: string;
+ modelName: string;
+ primaryDatasetId: string;
+ secondaryDatasetId: string;
+ primaryTableId: string;
+ primaryFqn: string;
+ metricId: string;
+ metricName: string;
+ relationshipId: string;
+ constraintId: string;
+ cleanup: () => Promise;
+};
+
+/**
+ * Seeds a full semantic model (model + 2 datasets + metric + relationship + constraint),
+ * returning the created IDs plus a teardown that deletes them (twice, to clear the
+ * soft-delete), mirroring the Python fixture. Only invoked when the feature is enabled.
+ */
+const seedSemanticModel = async (project: TestProject): Promise => {
+ const metastore = createMetastore(project.config);
+ const uniqueId = randomUUID().slice(0, 8);
+ const slug = `it-semantic-${uniqueId}`;
+ const primaryTableId = `in.c-it-semantic.${slug}_orders`;
+ const secondaryTableId = `in.c-it-semantic.${slug}_orders_aux`;
+ const primaryFqn = `${slug}_orders`;
+ const secondaryFqn = `${slug}_orders_aux`;
+ const sqlDialect = project.backend === 'bigquery' ? 'bigquery' : 'snowflake';
+
+ const created: [string, string][] = [];
+ const cleanup = async (): Promise => {
+ for (const [objectType, id] of [...created].reverse()) {
+ try {
+ await metastore.deleteObject(objectType, id);
+ await metastore.deleteObject(objectType, id);
+ } catch {
+ // best-effort (401/403/404 expected on the second pass).
+ }
+ }
+ };
+
+ try {
+ const modelName = `${slug} model`;
+ const model = await metastore.createObject('semantic-model', modelName, {
+ name: modelName,
+ description: `Semantic walkthrough model ${slug}`,
+ sql_dialect: sqlDialect,
+ });
+ created.push(['semantic-model', model.id]);
+
+ const primaryDataset = await metastore.createObject('semantic-dataset', `${slug} orders`, {
+ name: `${slug} orders`,
+ description: `Primary walkthrough dataset ${slug}`,
+ tableId: primaryTableId,
+ fqn: primaryFqn,
+ modelUUID: model.id,
+ });
+ created.push(['semantic-dataset', primaryDataset.id]);
+
+ const secondaryDataset = await metastore.createObject(
+ 'semantic-dataset',
+ `${slug} orders aux`,
+ {
+ name: `${slug} orders aux`,
+ description: `Secondary walkthrough dataset ${slug}`,
+ tableId: secondaryTableId,
+ fqn: secondaryFqn,
+ modelUUID: model.id,
+ },
+ );
+ created.push(['semantic-dataset', secondaryDataset.id]);
+
+ const metricName = `${slug} total items`;
+ const metric = await metastore.createObject('semantic-metric', metricName, {
+ name: metricName,
+ description: `Walkthrough metric ${slug}`,
+ sql: 'SUM(item_count)',
+ dataset: primaryTableId,
+ modelUUID: model.id,
+ });
+ created.push(['semantic-metric', metric.id]);
+
+ const relationship = await metastore.createObject(
+ 'semantic-relationship',
+ `${slug} relationship`,
+ {
+ name: `${slug} relationship`,
+ modelUUID: model.id,
+ from: primaryTableId,
+ to: secondaryTableId,
+ type: 'left',
+ on: 'orders.id = orders_aux.id',
+ },
+ );
+ created.push(['semantic-relationship', relationship.id]);
+
+ const constraintName = `it_semantic_${uniqueId}_constraint`;
+ const constraint = await metastore.createObject('semantic-constraint', constraintName, {
+ name: constraintName,
+ description: `Walkthrough exclusion rule ${slug}`,
+ modelUUID: model.id,
+ constraintType: 'exclusion',
+ severity: 'warning',
+ rule: 'Do not combine both walkthrough datasets in one query.',
+ metrics: [metricName],
+ datasets: [primaryTableId, secondaryTableId],
+ });
+ created.push(['semantic-constraint', constraint.id]);
+
+ return {
+ slug,
+ modelId: model.id,
+ modelName,
+ primaryDatasetId: primaryDataset.id,
+ secondaryDatasetId: secondaryDataset.id,
+ primaryTableId,
+ primaryFqn,
+ metricId: metric.id,
+ metricName,
+ relationshipId: relationship.id,
+ constraintId: constraint.id,
+ cleanup,
+ };
+ } catch (err) {
+ await cleanup();
+ throw err;
+ }
+};
+
+describe('semantic tools (integration)', () => {
+ // Documented unavailable-behavior: with the feature absent the tools are hidden from
+ // tools/list and any call is denied. This keeps the suite meaningful (not pure skips)
+ // against the feature-less pool. When the feature IS enabled this asserts availability.
+ it('semantic tools follow the project-feature gate', async () => {
+ const project = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(project.config);
+ try {
+ const available = await semanticToolsAvailable(session);
+ if (available) {
+ // Feature present: a call must NOT be denied by the gate (it may still need data,
+ // but get_semantic_schema works with no project data).
+ const schema = await callToolText(session.client, 'get_semantic_schema', {
+ semantic_types: ['semantic-dataset'],
+ });
+ expect(schema).toContain('semantic-dataset');
+ return;
+ }
+ // Feature absent: tool hidden from list AND call denied with the gating message.
+ const result = await callToolRaw(session.client, 'get_semantic_schema', {
+ semantic_types: ['semantic-dataset'],
+ }).then(
+ (r) => ({ thrown: false as const, r }),
+ (e: unknown) => ({ thrown: true as const, message: (e as Error).message }),
+ );
+ expect(result.thrown).toBe(true);
+ if (result.thrown) {
+ expect(result.message).toMatch(/not available in this project/i);
+ expect(result.message).toMatch(/Semantic Layer Tooling/i);
+ }
+ } finally {
+ await session.close();
+ }
+ });
+
+ // Port of test_search_semantic_context.
+ it('search_semantic_context groups matches by semantic model', async (ctx) => {
+ const project = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(project.config);
+ try {
+ if (!(await semanticToolsAvailable(session))) {
+ // Skipped: project lacks the `mcp-semantic-tooling` feature (none of the shared pool
+ // projects have it), so the tool is filtered out / denied. See the gate test above.
+ return ctx.skip();
+ }
+ const setup = await seedSemanticModel(project);
+ try {
+ const text = await callToolText(session.client, 'search_semantic_context', {
+ patterns: [setup.slug],
+ max_results: 20,
+ });
+ // One model group, matching our seeded model, surfacing all object types.
+ expect(text).toContain(setup.modelId);
+ expect(text).toContain('semantic-model');
+ expect(text).toContain('semantic-dataset');
+ expect(text).toContain('semantic-metric');
+ expect(text).toContain('semantic-relationship');
+ expect(text).toContain('semantic-constraint');
+ } finally {
+ await setup.cleanup();
+ }
+ } finally {
+ await session.close();
+ }
+ });
+
+ // Port of test_get_semantic_context.
+ it('get_semantic_context returns objects grouped by type', async (ctx) => {
+ const project = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(project.config);
+ try {
+ if (!(await semanticToolsAvailable(session))) {
+ return ctx.skip();
+ }
+ const setup = await seedSemanticModel(project);
+ try {
+ const text = await callToolText(session.client, 'get_semantic_context', {
+ semantic_objects: [
+ { object_type: 'semantic-model', ids: [setup.modelId] },
+ {
+ object_type: 'semantic-dataset',
+ ids: [setup.primaryDatasetId, setup.secondaryDatasetId],
+ },
+ { object_type: 'semantic-metric', ids: [setup.metricId] },
+ { object_type: 'semantic-relationship', ids: [setup.relationshipId] },
+ { object_type: 'semantic-constraint', ids: [setup.constraintId] },
+ ],
+ semantic_model_ids: [setup.modelId],
+ });
+ expect(text).toContain(setup.modelId);
+ expect(text).toContain(setup.primaryDatasetId);
+ expect(text).toContain(setup.secondaryDatasetId);
+ // ids-supplied selections return full objects with an `attributes` block.
+ expect(text).toMatch(/attributes/);
+ } finally {
+ await setup.cleanup();
+ }
+ } finally {
+ await session.close();
+ }
+ });
+
+ // Port of test_get_semantic_schema. Needs no seeded data.
+ it('get_semantic_schema returns JSON schemas for requested types', async (ctx) => {
+ const project = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(project.config);
+ try {
+ if (!(await semanticToolsAvailable(session))) {
+ return ctx.skip();
+ }
+ const text = await callToolText(session.client, 'get_semantic_schema', {
+ semantic_types: ['semantic-dataset', 'semantic-metric'],
+ });
+ expect(text).toContain('semantic-dataset');
+ expect(text).toContain('semantic-metric');
+ expect(text).toMatch(/schema/);
+ } finally {
+ await session.close();
+ }
+ });
+
+ // Port of test_validate_semantic_query.
+ it('validate_semantic_query validates a query against the seeded model', async (ctx) => {
+ const project = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(project.config);
+ try {
+ if (!(await semanticToolsAvailable(session))) {
+ return ctx.skip();
+ }
+ const setup = await seedSemanticModel(project);
+ try {
+ const text = await callToolText(session.client, 'validate_semantic_query', {
+ sql_query: `SELECT SUM(item_count) AS total_items FROM ${setup.primaryFqn}`,
+ semantic_model_ids: [setup.modelId],
+ expected_semantic_objects: [
+ { object_type: 'semantic-dataset', ids: [setup.primaryDatasetId] },
+ { object_type: 'semantic-metric', ids: [setup.metricId] },
+ ],
+ });
+ // Auto-detected validation is valid and resolves to our model.
+ expect(text).toMatch(/valid:\s*true/i);
+ expect(text).toContain(setup.modelId);
+ expect(text).toContain(setup.metricId);
+ expect(text).toContain(setup.primaryDatasetId);
+ } finally {
+ await setup.cleanup();
+ }
+ } finally {
+ await session.close();
+ }
+ });
+});
diff --git a/integtests/tools/semantic/__init__.py b/integtests/tools/semantic/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/integtests/tools/semantic/test_tools.py b/integtests/tools/semantic/test_tools.py
deleted file mode 100644
index 1f694832a..000000000
--- a/integtests/tools/semantic/test_tools.py
+++ /dev/null
@@ -1,344 +0,0 @@
-from __future__ import annotations
-
-from collections import Counter
-from dataclasses import dataclass
-from typing import Any, cast
-from urllib.parse import urljoin
-
-import httpx
-import pytest
-import pytest_asyncio
-from fastmcp import Client
-
-from keboola_mcp_server.clients.client import KeboolaClient
-from keboola_mcp_server.tools.semantic.model import SemanticObjectType, SemanticSchemaDefinition
-from keboola_mcp_server.tools.semantic.tools import (
- SemanticObjectTypeContext,
- SemanticSearchModelGroup,
- ValidateSemanticQueryOutput,
-)
-from keboola_mcp_server.workspace import WorkspaceManager
-
-SEMANTIC_TOOLING_FEATURE = 'mcp-semantic-tooling'
-
-
-@dataclass(frozen=True)
-class SemanticTestSetup:
- slug: str
- model_id: str
- model_name: str
- primary_dataset_id: str
- secondary_dataset_id: str
- primary_table_id: str
- secondary_table_id: str
- primary_fqn: str
- metric_id: str
- metric_name: str
- relationship_id: str
- constraint_id: str
-
-
-async def _delete_metastore_object(client: KeboolaClient, object_type: str, object_id: str) -> None:
- try:
- await client.metastore_client.delete_object(object_type, object_id)
- # Delete the soft deleted object
- await client.metastore_client.delete_object(object_type, object_id)
- except httpx.HTTPStatusError as exc:
- if exc.response.status_code not in (401, 403, 404):
- raise
-
-
-@pytest.fixture(scope='module')
-def metastore_url(storage_api_url: str) -> str:
- return storage_api_url.replace('connection.', 'metastore.', 1)
-
-
-@pytest.fixture(scope='module', autouse=True)
-def _require_metastore_available(
- storage_api_token: str,
- metastore_url: str,
-) -> None:
- try:
- probe_url = urljoin(metastore_url, '/health-check')
- with httpx.Client(
- headers={'X-StorageApi-Token': storage_api_token},
- timeout=httpx.Timeout(3.0, connect=1.0),
- ) as client:
- response = client.get(probe_url)
- response.raise_for_status()
- except httpx.ConnectError as exc:
- pytest.skip(f'Metastore endpoint is not reachable in this environment: {exc}')
- except httpx.TimeoutException as exc:
- pytest.skip(f'Metastore endpoint timed out in this environment: {exc}')
- except httpx.HTTPStatusError as exc:
- pytest.skip(f'Metastore endpoint returned HTTP {exc.response.status_code}: {exc}')
-
-
-@pytest_asyncio.fixture(autouse=True)
-async def semantic_tools_enabled(keboola_client: KeboolaClient) -> None:
- token_info = await keboola_client.storage_client.verify_token()
- owner = token_info.get('owner', {})
- features = owner.get('features', []) if isinstance(owner, dict) else []
- if SEMANTIC_TOOLING_FEATURE not in features:
- pytest.skip(f'Semantic tooling feature "{SEMANTIC_TOOLING_FEATURE}" is not enabled in this environment.')
-
-
-@pytest_asyncio.fixture
-async def semantic_test_setup(
- keboola_client: KeboolaClient,
- unique_id: str,
- workspace_manager: WorkspaceManager,
-) -> SemanticTestSetup:
- sql_dialect = await workspace_manager.get_sql_dialect()
- slug = f'it-semantic-{unique_id}'
- primary_table_id = f'in.c-it-semantic.{slug}_orders'
- secondary_table_id = f'in.c-it-semantic.{slug}_orders_aux'
- primary_fqn = f'{slug}_orders'
- secondary_fqn = f'{slug}_orders_aux'
-
- created_objects: list[tuple[str, str]] = []
-
- try:
- model_name = f'{slug} model'
- constraint_name = f'it_semantic_{unique_id}_constraint'
- model = await keboola_client.metastore_client.create_object(
- SemanticObjectType.SEMANTIC_MODEL.value,
- name=model_name,
- data={
- 'name': model_name,
- 'description': f'Semantic walkthrough model {slug}',
- 'sql_dialect': sql_dialect,
- },
- )
- created_objects.append((SemanticObjectType.SEMANTIC_MODEL.value, model.id))
-
- primary_dataset = await keboola_client.metastore_client.create_object(
- SemanticObjectType.SEMANTIC_DATASET.value,
- name=f'{slug} orders',
- data={
- 'name': f'{slug} orders',
- 'description': f'Primary walkthrough dataset {slug}',
- 'tableId': primary_table_id,
- 'fqn': primary_fqn,
- 'modelUUID': model.id,
- },
- )
- created_objects.append((SemanticObjectType.SEMANTIC_DATASET.value, primary_dataset.id))
-
- secondary_dataset = await keboola_client.metastore_client.create_object(
- SemanticObjectType.SEMANTIC_DATASET.value,
- name=f'{slug} orders aux',
- data={
- 'name': f'{slug} orders aux',
- 'description': f'Secondary walkthrough dataset {slug}',
- 'tableId': secondary_table_id,
- 'fqn': secondary_fqn,
- 'modelUUID': model.id,
- },
- )
- created_objects.append((SemanticObjectType.SEMANTIC_DATASET.value, secondary_dataset.id))
-
- metric_name = f'{slug} total items'
- metric = await keboola_client.metastore_client.create_object(
- SemanticObjectType.SEMANTIC_METRIC.value,
- name=metric_name,
- data={
- 'name': metric_name,
- 'description': f'Walkthrough metric {slug}',
- 'sql': 'SUM(item_count)',
- 'dataset': primary_table_id,
- 'modelUUID': model.id,
- },
- )
- created_objects.append((SemanticObjectType.SEMANTIC_METRIC.value, metric.id))
-
- relationship = await keboola_client.metastore_client.create_object(
- SemanticObjectType.SEMANTIC_RELATIONSHIP.value,
- name=f'{slug} relationship',
- data={
- 'name': f'{slug} relationship',
- 'modelUUID': model.id,
- 'from': primary_table_id,
- 'to': secondary_table_id,
- 'type': 'left',
- 'on': 'orders.id = orders_aux.id',
- },
- )
- created_objects.append((SemanticObjectType.SEMANTIC_RELATIONSHIP.value, relationship.id))
-
- constraint = await keboola_client.metastore_client.create_object(
- SemanticObjectType.SEMANTIC_CONSTRAINT.value,
- name=constraint_name,
- data={
- 'name': constraint_name,
- 'description': f'Walkthrough exclusion rule {slug}',
- 'modelUUID': model.id,
- 'constraintType': 'exclusion',
- 'severity': 'warning',
- 'rule': 'Do not combine both walkthrough datasets in one query.',
- 'metrics': [metric_name],
- 'datasets': [primary_table_id, secondary_table_id],
- },
- )
- created_objects.append((SemanticObjectType.SEMANTIC_CONSTRAINT.value, constraint.id))
-
- yield SemanticTestSetup(
- slug=slug,
- model_id=model.id,
- model_name=model_name,
- primary_dataset_id=primary_dataset.id,
- secondary_dataset_id=secondary_dataset.id,
- primary_table_id=primary_table_id,
- secondary_table_id=secondary_table_id,
- primary_fqn=primary_fqn,
- metric_id=metric.id,
- metric_name=metric_name,
- relationship_id=relationship.id,
- constraint_id=constraint.id,
- )
- finally:
- for object_type, object_id in reversed(created_objects):
- await _delete_metastore_object(keboola_client, object_type, object_id)
-
-
-@pytest.mark.asyncio
-async def test_search_semantic_context(
- mcp_client: Client,
- semantic_test_setup: SemanticTestSetup,
-) -> None:
- search_result = await mcp_client.call_tool(
- 'search_semantic_context',
- {
- 'patterns': [semantic_test_setup.slug],
- 'max_results': 20,
- },
- )
- search_payload = cast(dict[str, Any], search_result.structured_content)['result']
- search_groups = [
- SemanticSearchModelGroup.model_validate(item) for item in cast(list[dict[str, Any]], search_payload)
- ]
-
- assert len(search_groups) == 1
- assert search_groups[0].semantic_model_id == semantic_test_setup.model_id
- match_counts = Counter(match.object_type for match in search_groups[0].matches)
- assert match_counts == Counter(
- {
- SemanticObjectType.SEMANTIC_MODEL: 1,
- SemanticObjectType.SEMANTIC_DATASET: 2,
- SemanticObjectType.SEMANTIC_METRIC: 1,
- SemanticObjectType.SEMANTIC_RELATIONSHIP: 1,
- SemanticObjectType.SEMANTIC_CONSTRAINT: 1,
- }
- )
-
-
-@pytest.mark.asyncio
-async def test_get_semantic_context(
- mcp_client: Client,
- semantic_test_setup: SemanticTestSetup,
-) -> None:
- context_result = await mcp_client.call_tool(
- 'get_semantic_context',
- {
- 'semantic_objects': [
- {'object_type': SemanticObjectType.SEMANTIC_MODEL.value, 'ids': [semantic_test_setup.model_id]},
- {
- 'object_type': SemanticObjectType.SEMANTIC_DATASET.value,
- 'ids': [semantic_test_setup.primary_dataset_id, semantic_test_setup.secondary_dataset_id],
- },
- {'object_type': SemanticObjectType.SEMANTIC_METRIC.value, 'ids': [semantic_test_setup.metric_id]},
- {
- 'object_type': SemanticObjectType.SEMANTIC_RELATIONSHIP.value,
- 'ids': [semantic_test_setup.relationship_id],
- },
- {
- 'object_type': SemanticObjectType.SEMANTIC_CONSTRAINT.value,
- 'ids': [semantic_test_setup.constraint_id],
- },
- ],
- 'semantic_model_ids': [semantic_test_setup.model_id],
- },
- )
- context_payload = cast(dict[str, Any], context_result.structured_content)['result']
- contexts = [SemanticObjectTypeContext.model_validate(item) for item in cast(list[dict[str, Any]], context_payload)]
- contexts_by_type = {context.object_type: context for context in contexts}
-
- assert contexts_by_type[SemanticObjectType.SEMANTIC_MODEL].objects[0].id == semantic_test_setup.model_id
- dataset_context = contexts_by_type[SemanticObjectType.SEMANTIC_DATASET]
- assert {item.id for item in dataset_context.objects} == {
- semantic_test_setup.primary_dataset_id,
- semantic_test_setup.secondary_dataset_id,
- }
- assert all(hasattr(item, 'attributes') for item in dataset_context.objects)
-
-
-@pytest.mark.asyncio
-async def test_get_semantic_schema(
- mcp_client: Client,
-) -> None:
- schema_result = await mcp_client.call_tool(
- 'get_semantic_schema',
- {
- 'semantic_types': [
- SemanticObjectType.SEMANTIC_DATASET.value,
- SemanticObjectType.SEMANTIC_METRIC.value,
- ]
- },
- )
- schema_payload = cast(dict[str, Any], schema_result.structured_content)['result']
- schemas = [SemanticSchemaDefinition.model_validate(item) for item in cast(list[dict[str, Any]], schema_payload)]
- schemas_by_type = {item.semantic_type: item for item in schemas}
-
- assert set(schemas_by_type) == {
- SemanticObjectType.SEMANTIC_DATASET,
- SemanticObjectType.SEMANTIC_METRIC,
- }
- assert isinstance(schemas_by_type[SemanticObjectType.SEMANTIC_DATASET].schema_definition, dict)
- assert isinstance(schemas_by_type[SemanticObjectType.SEMANTIC_METRIC].schema_definition, dict)
- assert schemas_by_type[SemanticObjectType.SEMANTIC_DATASET].schema_definition
- assert schemas_by_type[SemanticObjectType.SEMANTIC_METRIC].schema_definition
-
-
-@pytest.mark.asyncio
-async def test_validate_semantic_query(
- mcp_client: Client,
- semantic_test_setup: SemanticTestSetup,
-) -> None:
- validate_result = await mcp_client.call_tool(
- 'validate_semantic_query',
- {
- 'sql_query': f'SELECT SUM(item_count) AS total_items FROM {semantic_test_setup.primary_fqn}',
- 'semantic_model_ids': [semantic_test_setup.model_id],
- 'expected_semantic_objects': [
- {
- 'object_type': SemanticObjectType.SEMANTIC_DATASET.value,
- 'ids': [semantic_test_setup.primary_dataset_id],
- },
- {
- 'object_type': SemanticObjectType.SEMANTIC_METRIC.value,
- 'ids': [semantic_test_setup.metric_id],
- },
- ],
- },
- )
- validation = ValidateSemanticQueryOutput.model_validate(validate_result.structured_content)
-
- assert validation.validation_auto_detected.valid is True
- assert len(validation.validation_auto_detected.semantic_models) == 1
- assert validation.validation_auto_detected.semantic_models[0].id == semantic_test_setup.model_id
- assert validation.validation_auto_detected.semantic_models[0].name == semantic_test_setup.model_name
- assert validation.validation_detected_from_expected is not None
- assert validation.validation_detected_from_expected.valid is True
- assert {(item.object_type, item.id) for item in validation.matched_expected_objects} == {
- (SemanticObjectType.SEMANTIC_DATASET, semantic_test_setup.primary_dataset_id),
- (SemanticObjectType.SEMANTIC_METRIC, semantic_test_setup.metric_id),
- }
- assert validation.missing_expected_objects == []
- assert validation.unexpected_detected_objects == []
- assert [dataset.id for dataset in validation.validation_auto_detected.used_datasets] == [
- semantic_test_setup.primary_dataset_id
- ]
- assert [metric.id for metric in validation.validation_auto_detected.used_metrics] == [semantic_test_setup.metric_id]
- assert validation.validation_auto_detected.matched_relationships == []
- assert validation.validation_auto_detected.violations == []
- assert validation.validation_auto_detected.post_execution_checks == []
diff --git a/integtests/tools/sql.test.ts b/integtests/tools/sql.test.ts
new file mode 100644
index 000000000..059eded33
--- /dev/null
+++ b/integtests/tools/sql.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from 'vitest';
+
+import { callToolRaw, callToolText, connectMcp } from '../helpers/mcp';
+import { seedProject } from '../helpers/seed';
+import { getTestProjectForTest } from '../testproject/fixture';
+import type { Backend } from '../testproject/types';
+
+// Ported from integtests/tools/test_sql.py: a literal SELECT, the invalid-query error path,
+// and the faithful seeded COUNT(*) case (get_buckets -> get_tables -> query_data against the
+// table's fully-qualified name).
+describe('query_data (integration)', () => {
+ it.each(['snowflake', 'bigquery'])(
+ 'counts rows of a seeded table via its fully-qualified name (%s)',
+ async (backend) => {
+ // get_tables now resolves the FQN dialect-aware, so the seeded COUNT works on both
+ // Snowflake (double-quoted db.schema.table) and BigQuery (backtick dataset.table).
+ const project = await getTestProjectForTest({ backend });
+ await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ // Resolve the seeded table's FQN: list a bucket, then fetch table detail.
+ const tablesListing = await callToolText(session.client, 'get_tables', {
+ bucket_ids: ['in.c-test_bucket_01'],
+ });
+ expect(tablesListing).toContain('in.c-test_bucket_01.test_table_01');
+
+ const detail = await callToolText(session.client, 'get_tables', {
+ table_ids: ['in.c-test_bucket_01.test_table_01'],
+ });
+ // Pull the fully-qualified name out of the detail. The Snowflake FQN ("DB"."SCHEMA"."TBL")
+ // contains quotes/dots, so TOON emits it as a double-quoted (JSON-escaped) scalar. Capture
+ // the whole value on the line and JSON-decode it when quoted — a naive non-greedy match
+ // would truncate at the first inner quote and produce invalid SQL.
+ const fqnMatch = detail.match(/fullyQualifiedName:\s*(.+?)\s*$/m);
+ expect(fqnMatch, `Table detail should expose a fullyQualifiedName. Got: ${detail}`).not.toBeNull();
+ let fqn = fqnMatch![1]!.trim();
+ if (fqn.startsWith('"') && fqn.endsWith('"')) fqn = JSON.parse(fqn) as string;
+ expect(fqn.length).toBeGreaterThan(0);
+
+ const text = await callToolText(session.client, 'query_data', {
+ sql_query: `SELECT COUNT(*) as row_count FROM ${fqn}`,
+ query_name: 'Row Count Query',
+ });
+ expect(text).toContain('Row Count Query');
+ // CSV must have a header (ROW_COUNT on Snowflake / row_count on BigQuery) plus a numeric row.
+ expect(text).toMatch(/row_count/i);
+ expect(text).toMatch(/\b\d+\b/);
+ } finally {
+ await session.close();
+ }
+ },
+ );
+
+ it('runs a literal query and returns CSV data', async () => {
+ const { config } = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(config);
+ try {
+ const text = await callToolText(session.client, 'query_data', {
+ sql_query: 'SELECT 1 AS one',
+ query_name: 'Smoke Query',
+ });
+ expect(text).toContain('Smoke Query');
+ expect(text).toMatch(/\b1\b/);
+ } finally {
+ await session.close();
+ }
+ });
+
+ it('reports an error for invalid SQL', async () => {
+ const { config } = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(config);
+ try {
+ const result = await callToolRaw(session.client, 'query_data', {
+ sql_query: 'INVALID SQL SYNTAX SELECT * FROM',
+ query_name: 'Invalid Query Test',
+ });
+ expect(result.isError).toBeTruthy();
+ expect((result.content as { text: string }[])[0]!.text).toMatch(/Failed to run SQL query/i);
+ } finally {
+ await session.close();
+ }
+ });
+});
diff --git a/integtests/tools/storage.test.ts b/integtests/tools/storage.test.ts
new file mode 100644
index 000000000..845079748
--- /dev/null
+++ b/integtests/tools/storage.test.ts
@@ -0,0 +1,319 @@
+import { describe, expect, it } from 'vitest';
+
+import { callToolRaw, callToolText, connectMcp } from '../helpers/mcp';
+import { seedProject } from '../helpers/seed';
+import { getTestProjectForTest } from '../testproject/fixture';
+import type { Backend } from '../testproject/types';
+
+// Ported from integtests/tools/test_storage.py. Each test leases a fresh project, resets it,
+// seeds the standard fixtures (2 input buckets, 1 CSV table with columns id/name/item_count,
+// 2 component configs), then exercises the storage tools.
+//
+// The MCP server returns tool output as TOON text (token-oriented notation), so the assertions
+// match substrings / regex / the TOON tabular header rather than reconstructing pydantic models
+// the way the Python tests did against the in-process tool functions.
+const SEEDED_BUCKET_IDS = ['in.c-test_bucket_01', 'in.c-test_bucket_02'];
+const SEEDED_TABLE_ID = 'in.c-test_bucket_01.test_table_01';
+const SEEDED_COLUMNS = ['id', 'name', 'item_count'];
+
+describe('storage tools (integration)', () => {
+ // --- get_buckets ---------------------------------------------------------
+
+ it('get_buckets lists the seeded buckets', async () => {
+ const project = await getTestProjectForTest();
+ await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ const text = await callToolText(session.client, 'get_buckets');
+ for (const id of SEEDED_BUCKET_IDS) expect(text).toContain(id);
+ } finally {
+ await session.close();
+ }
+ });
+
+ it('get_buckets reports bucket counts by stage (port of test_get_buckets)', async () => {
+ const project = await getTestProjectForTest();
+ await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ const text = await callToolText(session.client, 'get_buckets');
+ // bucket_counts: both seeded buckets are stage 'in' → total=2, input=2, output=0.
+ // TOON renders the nested object as indented `key: value` lines.
+ expect(text).toMatch(/total_buckets:\s*2/);
+ expect(text).toMatch(/input_buckets:\s*2/);
+ expect(text).toMatch(/output_buckets:\s*0/);
+ // Every listed bucket carries an explicit stage column.
+ expect(text).toContain('stage');
+ } finally {
+ await session.close();
+ }
+ });
+
+ it('get_buckets returns full detail for specific bucket ids (port of test_get_bucket)', async () => {
+ const project = await getTestProjectForTest();
+ await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ for (const bucketId of SEEDED_BUCKET_IDS) {
+ const text = await callToolText(session.client, 'get_buckets', { bucket_ids: [bucketId] });
+ expect(text).toContain(bucketId);
+ }
+ } finally {
+ await session.close();
+ }
+ });
+
+ it('get_buckets emits TOON tabular output (port of test_get_buckets_output_format)', async () => {
+ const project = await getTestProjectForTest();
+ await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ const text = await callToolText(session.client, 'get_buckets');
+ // Two buckets are presented in TOON's list-of-objects tabular form:
+ // buckets[2]{id,name,displayName,stage,...}:
+ expect(text).toMatch(/^buckets\[2\]\{[^}]*\bid\b[^}]*\}:/m);
+ } finally {
+ await session.close();
+ }
+ });
+
+ // --- get_tables ----------------------------------------------------------
+
+ // FQN + warehouse-native types are now resolved dialect-aware (Snowflake double-quoted
+ // 3-part db.schema.table; BigQuery backtick-quoted 2-part dataset.table). Both backends
+ // must expose a fully_qualified_name for the seeded table.
+ const FQN_QUOTE: Record = { snowflake: '"', bigquery: '`' };
+
+ it.each(['snowflake', 'bigquery'])(
+ 'get_tables lists a bucket and returns table detail with an FQN (%s)',
+ async (backend) => {
+ const project = await getTestProjectForTest({ backend });
+ await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ const listed = await callToolText(session.client, 'get_tables', {
+ bucket_ids: ['in.c-test_bucket_01'],
+ });
+ expect(listed).toContain(SEEDED_TABLE_ID);
+
+ const detail = await callToolText(session.client, 'get_tables', {
+ table_ids: [SEEDED_TABLE_ID],
+ });
+ // The FQN must be present and use the backend's quote char.
+ const fqnMatch = detail.match(/fullyQualifiedName:\s*(.+?)\s*$/m);
+ expect(
+ fqnMatch,
+ `Table detail should expose a fullyQualifiedName. Got: ${detail}`,
+ ).not.toBeNull();
+ let fqn = fqnMatch![1]!.trim();
+ if (fqn.startsWith('"') && fqn.endsWith('"')) fqn = JSON.parse(fqn) as string;
+ expect(fqn).toContain(FQN_QUOTE[backend]);
+ expect(detail).toContain('item_count');
+ } finally {
+ await session.close();
+ }
+ },
+ );
+
+ it.each(['snowflake', 'bigquery'])(
+ 'get_tables detail returns the seeded columns with types (%s) (port of test_get_table)',
+ async (backend) => {
+ const project = await getTestProjectForTest({ backend });
+ await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ const detail = await callToolText(session.client, 'get_tables', {
+ table_ids: [SEEDED_TABLE_ID],
+ });
+ expect(detail).toContain(SEEDED_TABLE_ID);
+ expect(detail).toContain('test_table_01');
+ // Every CSV column must appear in the detail's column listing.
+ for (const col of SEEDED_COLUMNS) expect(detail).toContain(col);
+ // Detail carries database-native type info per column.
+ expect(detail).toContain('database_native_type');
+ } finally {
+ await session.close();
+ }
+ },
+ );
+
+ it('get_tables listing returns summaries without FQN/columns (port of test_get_tables)', async () => {
+ const project = await getTestProjectForTest();
+ await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ // Bucket with the seeded table: exactly one table, summary shape.
+ const withTable = await callToolText(session.client, 'get_tables', {
+ bucket_ids: ['in.c-test_bucket_01'],
+ });
+ expect(withTable).toContain(SEEDED_TABLE_ID);
+ // Summaries never resolve the warehouse FQN nor expand columns — those fields must be
+ // absent from the output entirely (not emitted as a misleading null).
+ expect(withTable).not.toMatch(/fullyQualifiedName|fully_qualified_name/);
+ expect(withTable).not.toContain('database_native_type');
+
+ // The second seeded bucket has no tables → empty table list.
+ const emptyBucket = await callToolText(session.client, 'get_tables', {
+ bucket_ids: ['in.c-test_bucket_02'],
+ });
+ expect(emptyBucket).not.toContain(SEEDED_TABLE_ID);
+ } finally {
+ await session.close();
+ }
+ });
+
+ it('get_tables emits TOON tabular output (port of test_get_tables_output_format)', async () => {
+ const project = await getTestProjectForTest();
+ await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ const text = await callToolText(session.client, 'get_tables', {
+ bucket_ids: ['in.c-test_bucket_01'],
+ });
+ // One table in the bucket → TOON list-of-objects tabular header `tables[1]{...}:`.
+ expect(text).toMatch(/^tables\[1\]\{[^}]*\bid\b[^}]*\}:/m);
+ } finally {
+ await session.close();
+ }
+ });
+
+ // --- update_descriptions -------------------------------------------------
+
+ it('update_descriptions updates a bucket description (port of test_update_descriptions_bucket)', async () => {
+ const project = await getTestProjectForTest();
+ await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ const bucketId = SEEDED_BUCKET_IDS[0]!;
+ const text = await callToolText(session.client, 'update_descriptions', {
+ updates: [{ item_id: bucketId, description: 'New Description' }],
+ });
+ expect(text).toMatch(/total_processed:\s*1/);
+ expect(text).toMatch(/successful:\s*1/);
+ expect(text).toMatch(/failed:\s*0/);
+ expect(text).toContain(bucketId);
+
+ // Verify the description actually landed: get_buckets detail surfaces it.
+ const detail = await callToolText(session.client, 'get_buckets', { bucket_ids: [bucketId] });
+ expect(detail).toContain('New Description');
+ } finally {
+ await session.close();
+ }
+ });
+
+ it('update_descriptions updates a table description (port of test_update_descriptions_table)', async () => {
+ const project = await getTestProjectForTest();
+ await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ const text = await callToolText(session.client, 'update_descriptions', {
+ updates: [{ item_id: SEEDED_TABLE_ID, description: 'New Table Description' }],
+ });
+ expect(text).toMatch(/total_processed:\s*1/);
+ expect(text).toMatch(/successful:\s*1/);
+ expect(text).toMatch(/failed:\s*0/);
+ expect(text).toContain(SEEDED_TABLE_ID);
+
+ const detail = await callToolText(session.client, 'get_tables', {
+ table_ids: [SEEDED_TABLE_ID],
+ });
+ expect(detail).toContain('New Table Description');
+ } finally {
+ await session.close();
+ }
+ });
+
+ it('update_descriptions updates a column description (port of test_update_descriptions_table_column)', async () => {
+ const project = await getTestProjectForTest();
+ await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ const columnName = SEEDED_COLUMNS[0]!;
+ const columnId = `${SEEDED_TABLE_ID}.${columnName}`;
+ const text = await callToolText(session.client, 'update_descriptions', {
+ updates: [{ item_id: columnId, description: 'New Table Column Description' }],
+ });
+ expect(text).toMatch(/total_processed:\s*1/);
+ expect(text).toMatch(/successful:\s*1/);
+ expect(text).toMatch(/failed:\s*0/);
+ expect(text).toContain(columnId);
+
+ // The column description must surface in the table detail's column listing.
+ const detail = await callToolText(session.client, 'get_tables', {
+ table_ids: [SEEDED_TABLE_ID],
+ });
+ expect(detail).toContain('New Table Column Description');
+ } finally {
+ await session.close();
+ }
+ });
+
+ it('update_descriptions handles mixed item types in one call (port of test_update_descriptions_mixed_types)', async () => {
+ const project = await getTestProjectForTest();
+ await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ const bucketId = SEEDED_BUCKET_IDS[0]!;
+ const columnName = SEEDED_COLUMNS[0]!;
+ const text = await callToolText(session.client, 'update_descriptions', {
+ updates: [
+ { item_id: bucketId, description: 'Mixed Bucket Description' },
+ { item_id: SEEDED_TABLE_ID, description: 'Mixed Table Description' },
+ { item_id: `${SEEDED_TABLE_ID}.${columnName}`, description: 'Mixed Column Description' },
+ ],
+ });
+ expect(text).toMatch(/total_processed:\s*3/);
+ expect(text).toMatch(/successful:\s*3/);
+ expect(text).toMatch(/failed:\s*0/);
+
+ const bucketDetail = await callToolText(session.client, 'get_buckets', {
+ bucket_ids: [bucketId],
+ });
+ expect(bucketDetail).toContain('Mixed Bucket Description');
+
+ const tableDetail = await callToolText(session.client, 'get_tables', {
+ table_ids: [SEEDED_TABLE_ID],
+ });
+ expect(tableDetail).toContain('Mixed Table Description');
+ expect(tableDetail).toContain('Mixed Column Description');
+ } finally {
+ await session.close();
+ }
+ });
+
+ it('update_descriptions reports failure for an invalid item id (port of test_update_descriptions_invalid_path)', async () => {
+ const project = await getTestProjectForTest();
+ await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ // The tool itself succeeds (no isError); the per-item result records the failure.
+ const text = await callToolText(session.client, 'update_descriptions', {
+ updates: [{ item_id: 'invalid-path', description: 'This should fail' }],
+ });
+ expect(text).toMatch(/total_processed:\s*1/);
+ expect(text).toMatch(/successful:\s*0/);
+ expect(text).toMatch(/failed:\s*1/);
+ expect(text).toContain('invalid-path');
+ expect(text).toContain('Invalid item_id format');
+ } finally {
+ await session.close();
+ }
+ });
+
+ it('get_tables flags missing tables rather than erroring', async () => {
+ const project = await getTestProjectForTest();
+ await seedProject(project);
+ const session = await connectMcp(project.config);
+ try {
+ // A non-existent table id is reported via tables_not_found, not a tool error.
+ const result = await callToolRaw(session.client, 'get_tables', {
+ table_ids: ['in.c-test_bucket_01.does_not_exist'],
+ });
+ expect(result.isError).toBeFalsy();
+ const text = (result.content as { text: string }[])[0]!.text;
+ expect(text).toContain('does_not_exist');
+ } finally {
+ await session.close();
+ }
+ });
+});
diff --git a/integtests/tools/storage_branches.test.ts b/integtests/tools/storage_branches.test.ts
new file mode 100644
index 000000000..3e7c9e85f
--- /dev/null
+++ b/integtests/tools/storage_branches.test.ts
@@ -0,0 +1,365 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { callToolText, connectMcp, type McpSession } from '../helpers/mcp';
+
+import { Config } from '@/config';
+
+// Ported from integtests/tools/test_storage_branches.py — validates the storage-branches
+// deference mechanism (a dev-branch MCP context sees production buckets/tables deferred into the
+// branch, plus the branch's own new objects, and gets a branch-scoped workspace for SQL).
+//
+// These tests require a project WITH the `storage-branches` feature, which the regular local pool
+// projects do NOT have (CI uses a dedicated project — 3055 — supplied out-of-band). The project is
+// addressed directly by env vars, mirroring the Python fixtures:
+// - INTEGTEST_POOL_STORAGE_API_URL → storage API base URL
+// - INTEGTEST_STORAGE_TOKEN_STORAGE_BRANCHES → token for the storage-branches project
+//
+// When either is absent the whole suite is skipped (see SKIP_REASON). The setup faithfully mirrors
+// the Python harness: it provisions production data idempotently, creates two dev branches, and
+// runs Python transformations in each branch so they run unchanged once such a project is wired up.
+
+const STORAGE_BRANCHES_TOKEN_ENV_VAR = 'INTEGTEST_STORAGE_TOKEN_STORAGE_BRANCHES';
+const POOL_STORAGE_API_URL_ENV_VAR = 'INTEGTEST_POOL_STORAGE_API_URL';
+const PYTHON_TRANSFORMATION_COMPONENT = 'keboola.python-transformation-v2';
+
+const token = (process.env[STORAGE_BRANCHES_TOKEN_ENV_VAR] ?? '').trim();
+const storageApiUrl = (process.env[POOL_STORAGE_API_URL_ENV_VAR] ?? '').trim();
+
+// Gate the suite: without the dedicated project's token + URL there is nothing to run against.
+// The dedicated storage-branches project is 3055 in CI, not necessarily in the local pool.
+const SKIP_REASON = !token
+ ? `${STORAGE_BRANCHES_TOKEN_ENV_VAR} not set (storage-branches project unavailable in this pool)`
+ : !storageApiUrl
+ ? `${POOL_STORAGE_API_URL_ENV_VAR} not set (storage-branches project URL unavailable)`
+ : null;
+
+// --- HTTP helpers (ports of the Python _api_request / job-wait helpers) ----------------------
+
+const apiRequest = async (
+ method: string,
+ url: string,
+ init: { json?: unknown; form?: Record } = {},
+): Promise> => {
+ const headers: Record = { 'X-StorageApi-Token': token };
+ let body: string | URLSearchParams | undefined;
+ if (init.json !== undefined) {
+ headers['Content-Type'] = 'application/json';
+ body = JSON.stringify(init.json);
+ } else if (init.form !== undefined) {
+ body = new URLSearchParams(init.form);
+ }
+ const resp = await fetch(url, { method, headers, body });
+ const text = await resp.text();
+ if (!resp.ok) throw new Error(`${method} ${url} failed: ${resp.status} ${text}`);
+ return text ? (JSON.parse(text) as Record) : {};
+};
+
+const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms));
+
+const waitForStorageJob = async (jobId: string, timeoutMs = 120_000): Promise => {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const job = await apiRequest('GET', `${storageApiUrl}/v2/storage/jobs/${jobId}`);
+ const status = job.status;
+ if (status === 'success') return;
+ if (status === 'error' || status === 'cancelled') {
+ throw new Error(`Storage job ${jobId} failed: ${JSON.stringify(job)}`);
+ }
+ await sleep(2_000);
+ }
+ throw new Error(`Storage job ${jobId} did not complete within ${timeoutMs}ms`);
+};
+
+const waitForQueueJob = async (jobId: string, timeoutMs = 300_000): Promise => {
+ const queueUrl = storageApiUrl.replace('connection.', 'queue.');
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const job = await apiRequest('GET', `${queueUrl}/jobs/${jobId}`);
+ const status = job.status;
+ if (status === 'success') return;
+ if (status === 'error' || status === 'cancelled' || status === 'terminated') {
+ throw new Error(`Queue job ${jobId} failed with status=${String(status)}`);
+ }
+ await sleep(5_000);
+ }
+ throw new Error(`Queue job ${jobId} did not complete within ${timeoutMs}ms`);
+};
+
+const createBranch = async (name: string): Promise => {
+ const job = await apiRequest('POST', `${storageApiUrl}/v2/storage/dev-branches`, { json: { name } });
+ const jobId = String(job.id);
+ await waitForStorageJob(jobId);
+ const done = await apiRequest('GET', `${storageApiUrl}/v2/storage/jobs/${jobId}`);
+ return String((done.results as Record).id);
+};
+
+const deleteBranch = async (branchId: string): Promise => {
+ try {
+ const job = await apiRequest('DELETE', `${storageApiUrl}/v2/storage/dev-branches/${branchId}`);
+ await waitForStorageJob(String(job.id));
+ } catch {
+ // Best-effort cleanup, matching the Python teardown.
+ }
+};
+
+const ensureBucket = async (name: string, stage = 'in'): Promise => {
+ const bucketId = `${stage}.c-${name}`;
+ const resp = await fetch(`${storageApiUrl}/v2/storage/buckets/${bucketId}`, {
+ headers: { 'X-StorageApi-Token': token },
+ });
+ if (resp.ok) return bucketId;
+ if (resp.status !== 404) throw new Error(`GET bucket ${bucketId} failed: ${resp.status}`);
+ const created = await apiRequest('POST', `${storageApiUrl}/v2/storage/buckets`, {
+ json: { name, stage, description: 'Integration test bucket' },
+ });
+ return String(created.id);
+};
+
+const ensureTable = async (bucketId: string, tableName: string, csvData: string): Promise => {
+ const tableId = `${bucketId}.${tableName}`;
+ const resp = await fetch(`${storageApiUrl}/v2/storage/tables/${tableId}`, {
+ headers: { 'X-StorageApi-Token': token },
+ });
+ if (resp.ok) return tableId;
+ if (resp.status !== 404) throw new Error(`GET table ${tableId} failed: ${resp.status}`);
+ const created = await apiRequest('POST', `${storageApiUrl}/v2/storage/buckets/${bucketId}/tables`, {
+ form: { name: tableName, delimiter: ',', dataString: csvData },
+ });
+ return String(created.id);
+};
+
+const pythonTransformConfig = (
+ destination: string,
+ csvFilename: string,
+ fieldnames: string[],
+ row: Record,
+): Record => {
+ const fieldsStr = JSON.stringify(fieldnames).replace(/"/g, "'");
+ const rowStr = JSON.stringify(row).replace(/"/g, "'");
+ const script = [
+ 'import csv',
+ 'import os',
+ "os.makedirs('out/tables', exist_ok=True)",
+ `with open('out/tables/${csvFilename}', mode='wt', encoding='utf-8') as f:`,
+ ` writer = csv.DictWriter(f, fieldnames=${fieldsStr}, dialect='kbc')`,
+ ' writer.writeheader()',
+ ` writer.writerow(${rowStr})`,
+ ].join('\n');
+ return {
+ storage: {
+ output: {
+ tables: [{ source: csvFilename, destination, primary_key: ['id'] }],
+ },
+ },
+ parameters: {
+ blocks: [{ name: 'Generate data', codes: [{ name: 'script', script: [script] }] }],
+ packages: [],
+ },
+ };
+};
+
+const createConfigInBranch = async (
+ branchId: string,
+ componentId: string,
+ name: string,
+ config: Record,
+): Promise => {
+ const created = await apiRequest(
+ 'POST',
+ `${storageApiUrl}/v2/storage/branch/${branchId}/components/${componentId}/configs`,
+ { json: { name, description: `Integration test config: ${name}`, configuration: JSON.stringify(config) } },
+ );
+ return String(created.id);
+};
+
+const runJobInBranch = async (branchId: string, componentId: string, configId: string): Promise => {
+ const queueUrl = storageApiUrl.replace('connection.', 'queue.');
+ const job = await apiRequest('POST', `${queueUrl}/jobs`, {
+ json: { component: componentId, config: configId, mode: 'run', branchId },
+ });
+ await waitForQueueJob(String(job.id));
+};
+
+// --- Suite -----------------------------------------------------------------------------------
+
+type BranchProject = { branchAId: string; branchBId: string };
+
+const describeBranches = SKIP_REASON ? describe.skip : describe;
+
+describeBranches('storage-branches tools (integration)', () => {
+ let project: BranchProject;
+ // Per-branch Config: production-branch alias when branchId is undefined.
+ const configFor = (branchId?: string): Config =>
+ new Config({ storageApiUrl, storageToken: token, branchId });
+
+ beforeAll(async () => {
+ // Confirm the token's project actually carries the storage-branches feature before paying for
+ // the (slow) branch + transformation-job setup.
+ const verify = await apiRequest('GET', `${storageApiUrl}/v2/storage/tokens/verify`);
+ const owner = (verify.owner ?? {}) as Record;
+ const features = Array.isArray(owner.features) ? (owner.features as string[]) : [];
+ if (!features.includes('storage-branches')) {
+ throw new Error(
+ `project ${String(owner.name)} must have the storage-branches feature enabled to run these tests`,
+ );
+ }
+
+ // Idempotent production data (shared safely across concurrent sessions).
+ await ensureBucket('test_bucket_01');
+ await ensureTable(
+ 'in.c-test_bucket_01',
+ 'test_table_01',
+ '"id","name","item_count"\n1,"item1",10\n2,"item2",20',
+ );
+
+ const uid = Math.random().toString(36).slice(2, 10);
+ const branchAId = await createBranch(`integtest-branch-A-${uid}`);
+ const branchBId = await createBranch(`integtest-branch-B-${uid}`);
+
+ // Branch A: update the existing production table (creates a branched version).
+ let cid = await createConfigInBranch(
+ branchAId,
+ PYTHON_TRANSFORMATION_COMPONENT,
+ 'update-tbl',
+ pythonTransformConfig('in.c-test_bucket_01.test_table_01', 'test_table_01.csv', ['id', 'name', 'item_count'], {
+ id: '99',
+ name: 'branched_item',
+ item_count: '999',
+ }),
+ );
+ await runJobInBranch(branchAId, PYTHON_TRANSFORMATION_COMPONENT, cid);
+
+ // Branch A: create a new bucket + table that exists only in the branch.
+ cid = await createConfigInBranch(
+ branchAId,
+ PYTHON_TRANSFORMATION_COMPONENT,
+ 'create-tbl',
+ pythonTransformConfig('in.c-test_branch.test_table_branch', 'test_table_branch.csv', ['id', 'name', 'value'], {
+ id: '1',
+ name: 'branch_a_data',
+ value: '100',
+ }),
+ );
+ await runJobInBranch(branchAId, PYTHON_TRANSFORMATION_COMPONENT, cid);
+
+ // Branch B: create a different branch-only bucket + table.
+ cid = await createConfigInBranch(
+ branchBId,
+ PYTHON_TRANSFORMATION_COMPONENT,
+ 'create-b-tbl',
+ pythonTransformConfig('in.c-test_branch_2.test_table_branch', 'test_table_branch.csv', ['id', 'name', 'value'], {
+ id: '1',
+ name: 'branch_b_data',
+ value: '200',
+ }),
+ );
+ await runJobInBranch(branchBId, PYTHON_TRANSFORMATION_COMPONENT, cid);
+
+ project = { branchAId, branchBId };
+ }, 600_000);
+
+ afterAll(async () => {
+ if (project) {
+ await deleteBranch(project.branchAId);
+ await deleteBranch(project.branchBId);
+ }
+ });
+
+ const withSession = async (config: Config, fn: (session: McpSession) => Promise): Promise => {
+ const session = await connectMcp(config);
+ try {
+ await fn(session);
+ } finally {
+ await session.close();
+ }
+ };
+
+ it('get_buckets from Branch A includes production + Branch A buckets, not Branch B (test_list_buckets_includes_branch_a_bucket)', async () => {
+ await withSession(configFor(project.branchAId), async (session) => {
+ const text = await callToolText(session.client, 'get_buckets');
+ expect(text).toContain('in.c-test_bucket_01');
+ expect(text).toContain('in.c-test_branch');
+ expect(text).not.toContain('in.c-test_branch_2');
+ });
+ });
+
+ it('get_tables lists a branch-only bucket with a production-like id (test_list_tables_in_branched_bucket)', async () => {
+ await withSession(configFor(project.branchAId), async (session) => {
+ const text = await callToolText(session.client, 'get_tables', { bucket_ids: ['in.c-test_branch'] });
+ expect(text).toContain('test_table_branch');
+ expect(text).toContain('in.c-test_branch.test_table_branch');
+ // branch_id is internal-only and must not leak into the deferred (production-like) output.
+ expect(text).not.toMatch(/branch_id/);
+ });
+ });
+
+ it('get_tables for the production bucket sees the branched table (test_deference_branched_table)', async () => {
+ await withSession(configFor(project.branchAId), async (session) => {
+ const text = await callToolText(session.client, 'get_tables', { bucket_ids: ['in.c-test_bucket_01'] });
+ expect(text).toContain('in.c-test_bucket_01.test_table_01');
+ expect(text).not.toMatch(/branch_id/);
+ });
+ });
+
+ it('get_project_info from a dev branch reports is_development_branch=true (test_get_project_info_reports_dev_branch)', async () => {
+ await withSession(configFor(project.branchAId), async (session) => {
+ const text = await callToolText(session.client, 'get_project_info');
+ expect(text).toContain(String(project.branchAId));
+ expect(text).toMatch(/is_development_branch:\s*true/);
+ expect(text).toMatch(/branch_name:/);
+ });
+ });
+
+ it('get_project_info from the default branch reports is_development_branch=false (test_get_project_info_reports_default_branch)', async () => {
+ await withSession(configFor(undefined), async (session) => {
+ const text = await callToolText(session.client, 'get_project_info');
+ expect(text).toMatch(/is_development_branch:\s*false/);
+ expect(text).toMatch(/branch_name:/);
+ // The default branch must not be either dev branch created for this session.
+ expect(text).not.toContain(String(project.branchAId));
+ expect(text).not.toContain(String(project.branchBId));
+ });
+ });
+
+ it.each([
+ ['in.c-test_branch.test_table_branch', 'branch-only table created via transformation'],
+ ['in.c-test_bucket_01.test_table_01', 'production table (also branched in Branch A)'],
+ ])(
+ 'query_data from a dev branch reaches %s (test_query_data_from_dev_branch_reaches_both_kinds_of_tables)',
+ async (tableId, description) => {
+ await withSession(configFor(project.branchAId), async (session) => {
+ const tablesListing = await callToolText(session.client, 'get_tables', { table_ids: [tableId] });
+ // Pull the FQN out of the table detail; without it the table is not queryable.
+ const fqnMatch = tablesListing.match(/(?:fully_qualified_name|fullyQualifiedName):\s*(\S+)/);
+ expect(fqnMatch, `${description}: table ${tableId} has no FQN, cannot query`).not.toBeNull();
+ const fqn = fqnMatch![1]!;
+
+ const text = await callToolText(session.client, 'query_data', {
+ sql_query: `SELECT COUNT(*) AS row_count FROM ${fqn}`,
+ query_name: `Row count for ${tableId}`,
+ });
+ // csv_data holds a header row + a single COUNT(*) data row (a positive integer).
+ expect(text).toContain('csv_data');
+ const countMatch = text.match(/row_count[^\d]*(\d+)/i);
+ expect(countMatch, `${description}: expected a numeric row count`).not.toBeNull();
+ expect(Number(countMatch![1])).toBeGreaterThanOrEqual(1);
+ });
+ },
+ );
+
+ it('dev-branch and default-branch contexts get different workspaces (test_workspace_id_is_branch_aware)', async () => {
+ let devWorkspace = '';
+ let defaultWorkspace = '';
+ await withSession(configFor(project.branchAId), async (session) => {
+ const text = await callToolText(session.client, 'get_project_info');
+ devWorkspace = text.match(/workspace_id:\s*(\d+)/)?.[1] ?? '';
+ });
+ await withSession(configFor(undefined), async (session) => {
+ const text = await callToolText(session.client, 'get_project_info');
+ defaultWorkspace = text.match(/workspace_id:\s*(\d+)/)?.[1] ?? '';
+ });
+ expect(Number(devWorkspace)).toBeGreaterThan(0);
+ expect(Number(defaultWorkspace)).toBeGreaterThan(0);
+ expect(devWorkspace).not.toBe(defaultWorkspace);
+ });
+});
diff --git a/integtests/tools/test_data_apps.py b/integtests/tools/test_data_apps.py
deleted file mode 100644
index 1a9067ec2..000000000
--- a/integtests/tools/test_data_apps.py
+++ /dev/null
@@ -1,509 +0,0 @@
-import logging
-import os
-import subprocess
-import uuid
-from pathlib import Path
-from typing import Any, AsyncGenerator, Mapping, cast
-
-import pytest
-import pytest_asyncio
-import toon_format
-from fastmcp import Client, FastMCP
-
-from keboola_mcp_server.clients.client import DATA_APP_COMPONENT_ID, KeboolaClient, get_metadata_property
-from keboola_mcp_server.config import Config, MetadataField, ServerRuntimeInfo
-from keboola_mcp_server.server import create_server
-from keboola_mcp_server.tools.data_apps import (
- _DEFAULT_PACKAGES,
- DataApp,
- DataAppSummary,
- GetDataAppsOutput,
- ModifiedDataAppOutput,
- ModifiedPythonJsDataAppOutput,
- _get_query_function_code,
-)
-from keboola_mcp_server.workspace import WorkspaceManager
-
-LOG = logging.getLogger(__name__)
-
-
-@pytest.fixture
-def streamlit_app_imports() -> str:
- return 'import streamlit as st\n\n'
-
-
-@pytest.fixture
-def streamlit_app_entrypoint() -> str:
- return (
- 'def main():\n'
- " st.title('Integration Test Data App')\n"
- " st.write('Hello from integration test')\n"
- ' # Optionally query data (kept commented to avoid side-effects during tests)\n'
- " # df = query_data('select 1 as col')\n"
- ' # st.dataframe(df)\n\n'
- 'if __name__ == "__main__":\n'
- ' main()\n'
- )
-
-
-@pytest.fixture
-def sample_streamlit_app(streamlit_app_imports: str, streamlit_app_entrypoint: str) -> str:
- """Return a minimal Streamlit app template that supports query injection."""
- return f'{streamlit_app_imports}' '{QUERY_DATA_FUNCTION}\n\n' f'{streamlit_app_entrypoint}'
-
-
-@pytest.fixture
-def mcp_server(storage_api_url: str, storage_api_token: str, workspace_schema: str) -> FastMCP:
- config = Config(storage_api_url=storage_api_url, storage_token=storage_api_token, workspace_schema=workspace_schema)
- mcp_server = create_server(config, runtime_info=ServerRuntimeInfo(transport='stdio'))
- assert isinstance(mcp_server, FastMCP)
- return mcp_server
-
-
-@pytest_asyncio.fixture
-async def mcp_client(mcp_server: FastMCP) -> AsyncGenerator[Client, None]:
- async with Client(mcp_server) as client:
- yield client
-
-
-@pytest.fixture
-def app_name() -> str:
- unique_suffix = uuid.uuid4().hex[:8]
- return f'Integration Test Data App {unique_suffix}'
-
-
-@pytest.fixture
-def app_description() -> str:
- return 'Data app created by integration test'
-
-
-@pytest_asyncio.fixture
-async def initial_data_app(
- mcp_client: Client,
- keboola_client: KeboolaClient,
- app_name: str,
- app_description: str,
- sample_streamlit_app: str,
-) -> AsyncGenerator[ModifiedDataAppOutput, None]:
- sync_output: ModifiedDataAppOutput | None = None
- try:
- # Create
- created_result = await mcp_client.call_tool(
- name='modify_streamlit_data_app',
- arguments={
- 'name': app_name,
- 'description': app_description,
- 'source_code': sample_streamlit_app,
- 'packages': ['numpy', 'streamlit'],
- 'authentication_type': 'no-auth',
- },
- )
- assert created_result.structured_content is not None
- sync_output = ModifiedDataAppOutput.model_validate(created_result.structured_content)
- yield sync_output
- finally:
- if sync_output:
- try:
- # Delete the data app from the data science API and the configuration from the storage API as well.
- await keboola_client.data_science_client.delete_data_app(sync_output.data_app.data_app_id)
- except Exception as e:
- LOG.error(f'Error deleting data app: {e}')
- else:
- LOG.error('No data app to delete')
-
-
-@pytest.mark.asyncio
-async def test_get_data_apps_listing(mcp_client: Client, initial_data_app: ModifiedDataAppOutput) -> None:
- """Test listing data apps returns valid TOON formatted output."""
- tool_result = await mcp_client.call_tool(name='get_data_apps', arguments={})
-
- # Verify structured content
- assert tool_result.structured_content is not None
- apps = GetDataAppsOutput.model_validate(tool_result.structured_content)
- assert len(apps.data_apps) > 0
-
- # Verify TOON formatted text content matches structured content
- assert len(tool_result.content) == 1
- assert tool_result.content[0].type == 'text'
- toon_decoded = GetDataAppsOutput.model_validate(toon_format.decode(tool_result.content[0].text))
- assert toon_decoded == apps
-
-
-@pytest.mark.asyncio
-async def test_data_app_lifecycle(
- mcp_client: Client,
- keboola_client: KeboolaClient,
- workspace_manager: WorkspaceManager,
- app_name: str,
- app_description: str,
- initial_data_app: ModifiedDataAppOutput,
- streamlit_app_imports: str,
- streamlit_app_entrypoint: str,
-) -> None:
- """
- End-to-end lifecycle for data apps:
- Starts with a created app.
- - get details and list of created app
- - update app
- - get details and list of updated app
- Always deletes the data app in teardown.
- """
-
- # Check created app basic details
- assert initial_data_app.response == 'created'
- data_app_id = initial_data_app.data_app.data_app_id
- configuration_id = initial_data_app.data_app.configuration_id
- assert data_app_id
- assert configuration_id
-
- # Verify the metadata - check that KBC.MCP.createdBy is set to 'true'
- metadata = await keboola_client.storage_client.configuration_metadata_get(
- component_id=DATA_APP_COMPONENT_ID, configuration_id=configuration_id
- )
- assert isinstance(metadata, list)
- metadata_dict = {item['key']: item['value'] for item in metadata if isinstance(item, dict)}
- assert MetadataField.CREATED_BY_MCP in metadata_dict
- assert metadata_dict[MetadataField.CREATED_BY_MCP] == 'true'
-
- # Check created app details by configuration_id
- details_result = await mcp_client.call_tool(
- name='get_data_apps', arguments={'configuration_ids': [configuration_id]}
- )
- assert details_result.structured_content is not None
- details = GetDataAppsOutput.model_validate(details_result.structured_content)
- assert len(details.data_apps) == 1
- data_app_details = details.data_apps[0]
- assert isinstance(data_app_details, DataApp)
-
- assert data_app_details.configuration_id == configuration_id
- assert data_app_details.data_app_id == data_app_id
- assert data_app_details.name == app_name
- assert data_app_details.description == app_description
- # Check code and code injection
- data_app_details_parameters = data_app_details.configuration.get('parameters') or {}
- assert streamlit_app_imports in data_app_details_parameters['script'][0]
- assert streamlit_app_entrypoint in data_app_details_parameters['script'][0]
- sql_dialect = await workspace_manager.get_sql_dialect()
- assert _get_query_function_code(sql_dialect) in data_app_details_parameters['script'][0]
- # Check packages
- assert set(data_app_details_parameters['packages']) == set(['numpy', 'streamlit'] + _DEFAULT_PACKAGES)
-
- # Check listing contains our app
- # TODO(REMOVE): Set the limit back to the default value once DSAPI is fixed. The limit is temporarily increased to
- # 500 to prevent listing only the leftover data apps from previous tests (100). These apps cannot be deleted
- # because their configurations were removed in SAPI first, causing the DSAPI delete endpoint to return a 500 error
- # afterward.
- listed_result = await mcp_client.call_tool(name='get_data_apps', arguments={'limit': 500})
- assert listed_result.structured_content is not None
- listed = GetDataAppsOutput.model_validate(listed_result.structured_content)
- assert len(listed.data_apps) > 0
- assert all(isinstance(app, DataAppSummary) for app in listed.data_apps)
- assert configuration_id in [a.configuration_id for a in listed.data_apps]
- # TODO(REMOVE): Remove this assertion once DSAPI is fixed. This only checks that we do not leave any data apps
- # in the CI project after test executions except those which are already there and cannot be deleted.
- assert len(listed.data_apps) < 110
-
- # Update app
- updated_name = f'{app_name} - Updated'
- updated_description = 'Data app updated by integration test'
- updated_source_code = 'import numpy as np\n\n'
- updated_result = await mcp_client.call_tool(
- name='modify_streamlit_data_app',
- arguments={
- 'name': updated_name,
- 'description': updated_description,
- 'source_code': updated_source_code,
- 'packages': ['streamlit'],
- 'authentication_type': 'no-auth',
- 'configuration_id': configuration_id,
- 'change_description': 'Update Code',
- },
- )
- # Check updated app basic details
- assert updated_result.structured_content is not None
- updated = ModifiedDataAppOutput.model_validate(updated_result.structured_content)
- assert updated.response == 'updated'
- assert updated.data_app.data_app_id == data_app_id
- assert updated.data_app.configuration_id == configuration_id
-
- # Check that KBC.MCP.updatedBy.version.{version} is set to 'true'
- metadata = cast(
- list[Mapping[str, Any]],
- await keboola_client.storage_client.configuration_metadata_get(
- component_id=DATA_APP_COMPONENT_ID, configuration_id=configuration_id
- ),
- )
- meta_key = f'{MetadataField.UPDATED_BY_MCP_PREFIX}{updated.data_app.config_version}'
- meta_value = get_metadata_property(metadata, meta_key)
- assert meta_value == 'true'
- # Check that the original creation metadata is still there
- assert get_metadata_property(metadata, MetadataField.CREATED_BY_MCP) == 'true'
-
- # Check updated app details by configuration_id
- fetched_app = await mcp_client.call_tool(name='get_data_apps', arguments={'configuration_ids': [configuration_id]})
- assert fetched_app.structured_content is not None
- fetched = GetDataAppsOutput.model_validate(fetched_app.structured_content)
- assert len(fetched.data_apps) == 1
- assert isinstance(fetched.data_apps[0], DataApp)
- assert fetched.data_apps[0].name == updated_name
- assert fetched.data_apps[0].description == updated_description
- # Check that the source code is updated
- fetched_data_app_parameters = fetched.data_apps[0].configuration.get('parameters') or {}
- assert _get_query_function_code(sql_dialect) in fetched_data_app_parameters['script'][0]
- assert updated_source_code in fetched_data_app_parameters['script'][0]
- assert streamlit_app_imports not in fetched_data_app_parameters['script'][0]
- assert streamlit_app_entrypoint not in fetched_data_app_parameters['script'][0]
- # Check that the packages are updated
- assert set(fetched_data_app_parameters['packages']) == set(['streamlit'] + _DEFAULT_PACKAGES)
-
-
-# ===== python-js data app: prod + external-git draft (AI-3005 / AI-3286) =====
-
-
-@pytest.fixture
-def python_js_app_py() -> str:
- """Minimal python-js entrypoint: a tiny HTTP server returning a fixed string."""
- return (
- 'from http.server import BaseHTTPRequestHandler, HTTPServer\n'
- 'import os\n\n'
- 'class H(BaseHTTPRequestHandler):\n'
- ' def do_GET(self):\n'
- ' self.send_response(200)\n'
- ' self.end_headers()\n'
- " self.wfile.write(b'integration-test-ok')\n\n"
- "if __name__ == '__main__':\n"
- " port = int(os.environ.get('PORT', '8000'))\n"
- " HTTPServer(('0.0.0.0', port), H).serve_forever()\n"
- )
-
-
-def _git(*args: str, cwd: Path) -> None:
- """Run a git subcommand inside `cwd`, failing loudly on non-zero exit."""
- subprocess.run(['git', *args], cwd=cwd, check=True, capture_output=True, text=True)
-
-
-@pytest.mark.asyncio
-async def test_python_js_data_app_prod_and_draft_lifecycle(
- mcp_client: Client,
- keboola_client: KeboolaClient,
- tmp_path: Path,
- python_js_app_py: str,
-) -> None:
- """End-to-end on canary-orion: create prod (managed repo), create draft
- (external-git pointing at prod's repo), push branch, deploy draft in mode='dev',
- merge into main, redeploy prod, then delete the draft via the new MCP tool.
-
- Also asserts that fetching the prod's detail surfaces the draft in `drafts: [...]`
- before deletion and returns an empty `drafts` array after.
- """
-
- unique = uuid.uuid4().hex[:8]
- prod_slug = f'int-prod-{unique}'
- draft_slug = f'int-draft-{unique}'
- prod_output: ModifiedPythonJsDataAppOutput | None = None
- draft_output: ModifiedPythonJsDataAppOutput | None = None
- draft_deleted_via_tool = False
-
- try:
- # Step 1: create prod (managed repo).
- prod_result = await mcp_client.call_tool(
- name='modify_python_js_data_app',
- arguments={
- 'name': f'Integration prod {unique}',
- 'description': 'AI-3286 prod app integration test',
- 'slug': prod_slug,
- 'authentication_type': 'no-auth',
- },
- )
- assert prod_result.structured_content is not None
- prod_output = ModifiedPythonJsDataAppOutput.model_validate(prod_result.structured_content)
- assert prod_output.response == 'created'
- assert prod_output.repo_url is not None
- assert prod_output.repo_url.startswith('https://')
- assert prod_output.git_clone_url is None
- assert prod_output.branch is None
-
- # Step 2: create draft pointing at prod's repo. Branch defaults to 'init'.
- draft_result = await mcp_client.call_tool(
- name='modify_python_js_data_app',
- arguments={
- 'name': f'Integration draft {unique}',
- 'description': 'AI-3286 draft integration test',
- 'slug': draft_slug,
- 'parent_configuration_id': prod_output.data_app.configuration_id,
- 'authentication_type': 'no-auth',
- },
- )
- assert draft_result.structured_content is not None
- draft_output = ModifiedPythonJsDataAppOutput.model_validate(draft_result.structured_content)
- assert draft_output.response == 'created'
- assert draft_output.repo_url == prod_output.repo_url
- assert draft_output.git_clone_url is not None
- assert draft_output.git_clone_url.startswith('https://kai:')
- assert draft_output.branch == 'init'
-
- # Step 3: clone via the embedded credential. Initialize main if the freshly provisioned
- # repo is empty, then branch off and push the draft branch.
- repo_dir = tmp_path / 'repo'
- env = {**os.environ, 'GIT_TERMINAL_PROMPT': '0'}
- subprocess.run(
- ['git', 'clone', draft_output.git_clone_url, str(repo_dir)],
- check=True,
- capture_output=True,
- text=True,
- env=env,
- )
- _git('config', 'user.email', 'mcp-integration@keboola.com', cwd=repo_dir)
- _git('config', 'user.name', 'MCP Integration Test', cwd=repo_dir)
- # The platform-provisioned repo may be empty. Make sure `main` exists with at least one
- # commit so the post-merge push has somewhere to land.
- has_main = (
- subprocess.run(
- ['git', 'rev-parse', '--verify', 'refs/heads/main'],
- cwd=repo_dir,
- capture_output=True,
- text=True,
- ).returncode
- == 0
- )
- if not has_main:
- _git('checkout', '-b', 'main', cwd=repo_dir)
- (repo_dir / 'README.md').write_text(f'# integration test {unique}\n')
- _git('add', 'README.md', cwd=repo_dir)
- _git('commit', '-m', 'init main', cwd=repo_dir)
- subprocess.run(
- ['git', 'push', '-u', 'origin', 'main'],
- cwd=repo_dir,
- check=True,
- capture_output=True,
- text=True,
- env=env,
- )
- _git('checkout', '-b', draft_output.branch, 'main', cwd=repo_dir)
- (repo_dir / 'app.py').write_text(python_js_app_py)
- _git('add', 'app.py', cwd=repo_dir)
- _git('commit', '-m', f'AI-3286 integration test commit {unique}', cwd=repo_dir)
- subprocess.run(
- ['git', 'push', '-u', 'origin', draft_output.branch],
- cwd=repo_dir,
- check=True,
- capture_output=True,
- text=True,
- env=env,
- )
-
- # Step 4: deploy draft in mode='dev'.
- draft_deploy = await mcp_client.call_tool(
- name='deploy_data_app',
- arguments={
- 'action': 'deploy',
- 'configuration_id': draft_output.data_app.configuration_id,
- 'mode': 'dev',
- },
- )
- assert draft_deploy.structured_content is not None
- # The data-app runtime is async — we only assert the deploy call was accepted; not its
- # eventual state, since CI cannot afford to poll the full startup loop.
-
- # Fetching the prod's detail must now list the draft under `drafts`.
- prod_detail_before = await mcp_client.call_tool(
- name='get_data_apps',
- arguments={'configuration_ids': [prod_output.data_app.configuration_id]},
- )
- assert prod_detail_before.structured_content is not None
- prod_apps_before = GetDataAppsOutput.model_validate(prod_detail_before.structured_content)
- assert len(prod_apps_before.data_apps) == 1
- prod_app_before = prod_apps_before.data_apps[0]
- assert isinstance(prod_app_before, DataApp)
- draft_cfg_ids_before = [d.configuration_id for d in prod_app_before.drafts]
- assert draft_output.data_app.configuration_id in draft_cfg_ids_before
-
- # Confirm the draft's stored config carries the external-git block we sent and the
- # parent linkage we expect.
- draft_detail = await mcp_client.call_tool(
- name='get_data_apps',
- arguments={'configuration_ids': [draft_output.data_app.configuration_id]},
- )
- assert draft_detail.structured_content is not None
- draft_apps = GetDataAppsOutput.model_validate(draft_detail.structured_content)
- assert len(draft_apps.data_apps) == 1
- draft_detail_app = draft_apps.data_apps[0]
- assert isinstance(draft_detail_app, DataApp)
- draft_data_app_block = draft_detail_app.configuration.get('parameters', {}).get('dataApp', {})
- draft_git_block = draft_data_app_block.get('git', {})
- assert draft_git_block.get('repository') == prod_output.repo_url
- assert draft_git_block.get('branch') == draft_output.branch
- assert draft_git_block.get('username') == 'kai'
- encrypted_pw = draft_git_block.get('#password', '')
- assert encrypted_pw.startswith('KBC::'), f'expected encrypted #password, got {encrypted_pw!r}'
- assert draft_data_app_block.get('isDraft') is True
- assert draft_data_app_block.get('parentConfigurationId') == prod_output.data_app.configuration_id
- # Drafts of a draft are always empty.
- assert draft_detail_app.drafts == []
-
- # Step 5: merge into main and push.
- _git('checkout', 'main', cwd=repo_dir)
- _git('merge', '--no-ff', '-m', f'Merge {draft_output.branch}', draft_output.branch, cwd=repo_dir)
- subprocess.run(
- ['git', 'push', 'origin', 'main'],
- cwd=repo_dir,
- check=True,
- capture_output=True,
- text=True,
- env=env,
- )
-
- # Step 6: redeploy prod (no mode, no branch).
- prod_deploy = await mcp_client.call_tool(
- name='deploy_data_app',
- arguments={
- 'action': 'deploy',
- 'configuration_id': prod_output.data_app.configuration_id,
- },
- )
- assert prod_deploy.structured_content is not None
-
- # Step 7: delete the draft via the new MCP tool, then verify it's gone from prod's drafts.
- # Stop the draft first — DSAPI delete requires desiredState == currentState.
- try:
- await keboola_client.data_science_client.suspend_data_app(draft_output.data_app.data_app_id)
- except Exception as exc:
- LOG.info(f'suspend failed for {draft_output.data_app.data_app_id}: {exc}')
- delete_result = await mcp_client.call_tool(
- name='delete_python_js_data_app_draft',
- arguments={'configuration_id': draft_output.data_app.configuration_id},
- )
- assert delete_result.structured_content is not None
- deleted = delete_result.structured_content
- assert deleted['response'] == 'deleted'
- assert deleted['configuration_id'] == draft_output.data_app.configuration_id
- assert deleted['parent_configuration_id'] == prod_output.data_app.configuration_id
- draft_deleted_via_tool = True
-
- prod_detail_after = await mcp_client.call_tool(
- name='get_data_apps',
- arguments={'configuration_ids': [prod_output.data_app.configuration_id]},
- )
- assert prod_detail_after.structured_content is not None
- prod_apps_after = GetDataAppsOutput.model_validate(prod_detail_after.structured_content)
- prod_app_after = prod_apps_after.data_apps[0]
- assert isinstance(prod_app_after, DataApp)
- draft_cfg_ids_after = [d.configuration_id for d in prod_app_after.drafts]
- assert draft_output.data_app.configuration_id not in draft_cfg_ids_after
-
- finally:
- # Teardown: best-effort cleanup. Skip draft if the test already deleted it via the tool.
- cleanup_targets = [(prod_output, 'prod')]
- if not draft_deleted_via_tool:
- cleanup_targets.insert(0, (draft_output, 'draft'))
- for app, role in cleanup_targets:
- if app is None:
- continue
- try:
- await keboola_client.data_science_client.suspend_data_app(app.data_app.data_app_id)
- except Exception as exc:
- LOG.info(f'suspend failed for {role} {app.data_app.data_app_id}: {exc}')
- try:
- await keboola_client.data_science_client.delete_data_app(app.data_app.data_app_id)
- except Exception as exc:
- LOG.error(f'delete failed for {role} {app.data_app.data_app_id}: {exc}')
diff --git a/integtests/tools/test_doc.py b/integtests/tools/test_doc.py
deleted file mode 100644
index 4f931a657..000000000
--- a/integtests/tools/test_doc.py
+++ /dev/null
@@ -1,18 +0,0 @@
-import httpx
-import pytest
-from fastmcp import Context
-
-from keboola_mcp_server.tools.doc import DocsAnswer, docs_query
-
-
-@pytest.mark.asyncio
-@pytest.mark.xfail(raises=httpx.ReadTimeout, strict=False, reason='AI service may exceed read timeout in CI')
-async def test_docs_query(mcp_context: Context) -> None:
- """Tests that `docs_query` returns a valid `DocsAnswer` with text and source URLs."""
- query = 'What is Keboola Connection?'
-
- result = await docs_query(ctx=mcp_context, query=query)
-
- assert isinstance(result, DocsAnswer)
- assert len(result.text) > 0, 'Answer text should not be empty'
- assert len(result.source_urls) > 0, 'Source URLs list should not be empty'
diff --git a/integtests/tools/test_jobs.py b/integtests/tools/test_jobs.py
deleted file mode 100644
index b4b66695b..000000000
--- a/integtests/tools/test_jobs.py
+++ /dev/null
@@ -1,266 +0,0 @@
-import asyncio
-import logging
-
-import pytest
-from mcp.server.fastmcp import Context
-
-from integtests.conftest import ConfigDef, ProjectDef
-from keboola_mcp_server.clients.client import KeboolaClient
-from keboola_mcp_server.links import Link
-from keboola_mcp_server.tools.components import create_config
-from keboola_mcp_server.tools.jobs import (
- GetJobsDetailOutput,
- GetJobsListOutput,
- JobDetail,
- get_jobs,
- run_job,
-)
-
-LOG = logging.getLogger(__name__)
-
-
-async def _wait_for_job_in_list(
- mcp_context: Context,
- job_id: str,
- component_id: str | None,
- config_id: str | None,
- max_retries: int = 10,
- delay: float = 0.5,
-) -> GetJobsListOutput:
- """
- Wait for a job to appear in the job list with retry mechanism.
-
- :param mcp_context: MCP context
- :param job_id: ID of the job to find
- :param component_id: Component ID to filter by (can be None)
- :param config_id: Config ID to filter by (can be None)
- :param max_retries: Maximum number of retry attempts
- :param delay: Delay between retries in seconds
- :return: GetJobsListOutput containing the job
- :raises AssertionError: If job is not found after all retries
- """
- for attempt in range(max_retries):
- result = await get_jobs(
- ctx=mcp_context,
- job_ids=tuple(),
- component_id=component_id,
- config_id=config_id,
- limit=10,
- sort_by='startTime',
- sort_order='desc',
- )
-
- assert isinstance(result, GetJobsListOutput)
- job_ids = {job.id for job in result.jobs}
- if job_id in job_ids:
- LOG.info(f'Job {job_id} found in list after {attempt + 1} attempts')
- return result
-
- if attempt < max_retries - 1:
- LOG.info(f'Job {job_id} not found in list, attempt {attempt + 1}/{max_retries}, retrying in {delay}s...')
- await asyncio.sleep(delay)
-
- raise AssertionError(f'Job {job_id} not found in job list after {max_retries} attempts')
-
-
-@pytest.mark.asyncio
-async def test_get_jobs_listing_with_component_and_config_filter(mcp_context: Context, configs: list[ConfigDef]):
- """Tests that `get_jobs` works with component and config filtering when listing."""
-
- # Use first config to create jobs for testing
- test_config = configs[0]
- component_id = test_config.component_id
- configuration_id = test_config.configuration_id
-
- job = await run_job(ctx=mcp_context, component_id=component_id, configuration_id=configuration_id)
-
- # Wait for the job to appear in the list (handles race condition)
- result = await _wait_for_job_in_list(
- mcp_context=mcp_context,
- job_id=job.id,
- component_id=component_id,
- config_id=configuration_id,
- )
-
- assert isinstance(result, GetJobsListOutput)
- assert len(result.jobs) >= 1
-
- # Verify our created jobs appear in the results
- job_ids = {job.id for job in result.jobs}
- assert job.id in job_ids
-
- for job in result.jobs:
- assert job.component_id == component_id
- assert job.config_id == configuration_id
-
-
-@pytest.mark.asyncio
-async def test_run_job_and_get_jobs(
- mcp_context: Context, configs: list[ConfigDef], keboola_project: ProjectDef, storage_api_url: str
-):
- """Tests that `run_job` creates a job and `get_jobs` retrieves its details."""
-
- project_id = keboola_project.project_id
-
- test_config = configs[0]
- component_id = test_config.component_id
- configuration_id = test_config.configuration_id
-
- started_job = await run_job(ctx=mcp_context, component_id=component_id, configuration_id=configuration_id)
-
- # Verify the started job response
- assert isinstance(started_job, JobDetail)
- assert started_job.id is not None
- assert started_job.component_id == component_id
- assert started_job.config_id == configuration_id
- assert started_job.status is not None
- assert frozenset(started_job.links) == frozenset(
- [
- Link(
- type='ui-detail',
- title=f'Job: {started_job.id}',
- url=f'{storage_api_url}/admin/projects/{project_id}/queue/{started_job.id}',
- ),
- Link(
- type='ui-dashboard',
- title='Jobs in the project',
- url=f'{storage_api_url}/admin/projects/{project_id}/queue',
- ),
- ]
- )
-
- result = await get_jobs(ctx=mcp_context, job_ids=(started_job.id,))
-
- # Verify the job detail response
- assert isinstance(result, GetJobsDetailOutput)
- assert len(result.jobs) == 1
- job_detail = result.jobs[0]
- assert isinstance(job_detail, JobDetail)
- assert job_detail.id == started_job.id
- assert job_detail.component_id == component_id
- assert job_detail.config_id == configuration_id
- assert job_detail.status is not None
- assert job_detail.url is not None
- assert frozenset(job_detail.links) == frozenset(
- [
- Link(
- type='ui-detail',
- title=f'Job: {job_detail.id}',
- url=f'{storage_api_url}/admin/projects/{project_id}/queue/{job_detail.id}',
- ),
- Link(
- type='ui-dashboard',
- title='Jobs in the project',
- url=f'{storage_api_url}/admin/projects/{project_id}/queue',
- ),
- ]
- )
-
-
-@pytest.mark.asyncio
-async def test_get_jobs_detail(
- mcp_context: Context, configs: list[ConfigDef], keboola_project: ProjectDef, storage_api_url: str
-):
- """Tests `get_jobs` by creating a job and then retrieving its details."""
-
- project_id = keboola_project.project_id
-
- # Use first config to create a specific job
- test_config = configs[0]
- component_id = test_config.component_id
- configuration_id = test_config.configuration_id
-
- # Create a specific job to test get_jobs with
- created_job = await run_job(ctx=mcp_context, component_id=component_id, configuration_id=configuration_id)
-
- # Now test get_jobs on the job we just created
- result = await get_jobs(ctx=mcp_context, job_ids=(created_job.id,))
-
- # Verify all expected fields are present
- assert isinstance(result, GetJobsDetailOutput)
- assert len(result.jobs) == 1
- job_detail = result.jobs[0]
- assert job_detail.id == created_job.id
- assert job_detail.component_id == component_id
- assert job_detail.config_id == configuration_id
- assert job_detail.status is not None
- assert frozenset(job_detail.links) == frozenset(
- [
- Link(
- type='ui-detail',
- title=f'Job: {created_job.id}',
- url=f'{storage_api_url}/admin/projects/{project_id}/queue/{created_job.id}',
- ),
- Link(
- type='ui-dashboard',
- title='Jobs in the project',
- url=f'{storage_api_url}/admin/projects/{project_id}/queue',
- ),
- ]
- )
-
-
-@pytest.mark.asyncio
-async def test_run_job_with_newly_created_config(
- mcp_context: Context, configs: list[ConfigDef], keboola_project: ProjectDef, storage_api_url: str
-):
- """Tests that `run_job` works with a newly created configuration."""
-
- project_id = keboola_project.project_id
-
- test_config = configs[0]
- component_id = test_config.component_id
-
- # Create a new configuration for testing
- new_config = await create_config(
- ctx=mcp_context,
- name='Test Config for Job Run',
- description='Test configuration created for job run test',
- component_id=component_id,
- parameters={},
- storage={},
- )
-
- try:
- # Run a job on the new configuration
- started_job = await run_job(
- ctx=mcp_context, component_id=component_id, configuration_id=new_config.configuration_id
- )
-
- # Verify the job was started successfully
- assert isinstance(started_job, JobDetail)
- assert started_job.id is not None
- assert started_job.component_id == component_id
- assert started_job.config_id == new_config.configuration_id
- assert started_job.status is not None
- assert frozenset(started_job.links) == frozenset(
- [
- Link(
- type='ui-detail',
- title=f'Job: {started_job.id}',
- url=f'{storage_api_url}/admin/projects/{project_id}/queue/{started_job.id}',
- ),
- Link(
- type='ui-dashboard',
- title='Jobs in the project',
- url=f'{storage_api_url}/admin/projects/{project_id}/queue',
- ),
- ]
- )
-
- # Verify job can be retrieved
- result = await get_jobs(ctx=mcp_context, job_ids=(started_job.id,))
- assert isinstance(result, GetJobsDetailOutput)
- assert len(result.jobs) == 1
- job_detail = result.jobs[0]
- assert isinstance(job_detail, JobDetail)
- assert job_detail.id == started_job.id
- assert job_detail.component_id == component_id
- assert job_detail.config_id == new_config.configuration_id
-
- finally:
- # Clean up: Delete the configuration
- client = KeboolaClient.from_state(mcp_context.session.state)
- await client.storage_client.configuration_delete(
- component_id=component_id, configuration_id=new_config.configuration_id, skip_trash=True
- )
diff --git a/integtests/tools/test_project.py b/integtests/tools/test_project.py
deleted file mode 100644
index 7da79b70c..000000000
--- a/integtests/tools/test_project.py
+++ /dev/null
@@ -1,33 +0,0 @@
-import pytest
-from fastmcp import Context
-
-from keboola_mcp_server.links import Link
-from keboola_mcp_server.tools.project import ProjectInfo, get_project_info
-
-
-@pytest.mark.asyncio
-async def test_get_project_info(mcp_context: Context, keboola_project) -> None:
- result = await get_project_info(mcp_context)
-
- assert isinstance(result, ProjectInfo)
- assert str(result.project_id) == str(keboola_project.project_id)
- assert isinstance(result.project_name, str)
- assert isinstance(result.organization_id, (str, int))
- assert isinstance(result.project_description, str)
- assert isinstance(result.sql_dialect, str)
- assert result.sql_dialect in {'Snowflake', 'BigQuery'}
- assert isinstance(result.workspace_id, int)
- assert result.workspace_id > 0
- assert isinstance(result.links, list)
- assert result.links, 'Links list should not be empty.'
- for link in result.links:
- assert isinstance(link, Link)
- assert link.type in {'ui-detail', 'ui-dashboard', 'docs'}
- assert isinstance(link.title, str)
- assert isinstance(link.url, str)
-
- assert isinstance(result.branch_id, (str, int))
- assert isinstance(result.branch_name, str)
- assert result.branch_name
- # The pool fixture runs on the default branch — see integtests/conftest.py.
- assert result.is_development_branch is False
diff --git a/integtests/tools/test_search.py b/integtests/tools/test_search.py
deleted file mode 100644
index 9c179c96b..000000000
--- a/integtests/tools/test_search.py
+++ /dev/null
@@ -1,145 +0,0 @@
-import logging
-
-import httpx
-import pytest
-import toon_format
-from fastmcp import Client
-from fastmcp.exceptions import ToolError
-
-from integtests.conftest import BucketDef, ConfigDef, TableDef
-from keboola_mcp_server.tools.search import SearchHit, SuggestedComponentOutput
-
-LOG = logging.getLogger(__name__)
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize('item_type', [None, 'bucket', 'table', 'configuration', 'transformation'])
-async def test_search_end_to_end(
- item_type: str | None,
- mcp_client: Client,
- buckets: list[BucketDef],
- tables: list[TableDef],
- configs: list[ConfigDef],
-) -> None:
- """
- Test the search tool end-to-end by searching for items that exist in the test project.
- This verifies that the search returns expected results for buckets, tables, and configurations.
- """
- item_types = (item_type,) if item_type else tuple()
-
- full_result = await mcp_client.call_tool(
- 'search', {'patterns': ['test'], 'item_types': item_types, 'limit': 50, 'offset': 0}
- )
- assert full_result.structured_content is not None
- LOG.info(f'result: {full_result.structured_content}')
- # The search tool returns a SearchOutput envelope: {'hits': [...], 'total', 'by_type', 'branch_scope'}.
- result = [SearchHit.model_validate(hit) for hit in full_result.structured_content['hits']]
-
- # check validity of the TOON formatted unstructured result
- assert len(full_result.content) == 1
- assert full_result.content[0].type == 'text'
- decoded_toon = toon_format.decode(full_result.content[0].text)
- assert isinstance(decoded_toon, dict)
- toon_result = [SearchHit.model_validate(hit) for hit in decoded_toon['hits']]
- assert toon_result == result
-
- # filter out data apps that seem to often be left behind in the testing project
- result = [hit for hit in result if hit.item_type != 'configuration' or hit.component_id != 'keboola.data-apps']
-
- # Verify the result structure
- assert isinstance(result, list)
-
- # Verify we found some results
- assert len(result) > 0, 'Should find at least some test items'
-
- # Create sets of expected IDs for verification
- if not item_type or item_type == 'bucket':
- expected_bucket_ids = {bucket.bucket_id for bucket in buckets}
- actual_bucket_ids = {hit.bucket_id for hit in result if hit.item_type == 'bucket'}
- assert actual_bucket_ids == expected_bucket_ids, f'Should find all test buckets. Found: {result}'
-
- if not item_type or item_type == 'table':
- expected_table_ids = {table.table_id for table in tables}
- actual_table_ids = {hit.table_id for hit in result if hit.item_type == 'table'}
- assert actual_table_ids == expected_table_ids, f'Should find all test tables. Found: {result}'
-
- if not item_type:
- expected_config_ids = {config.configuration_id for config in configs}
- actual_config_ids = {
- hit.configuration_id for hit in result if hit.item_type in ['configuration', 'transformation']
- }
- assert actual_config_ids == expected_config_ids, f'Should find all test configurations. Found: {result}'
-
- elif item_type == 'configuration':
- expected_config_ids = {config.configuration_id for config in configs if config.component_id == 'ex-generic-v2'}
- actual_config_ids = {hit.configuration_id for hit in result if hit.item_type == 'configuration'}
- assert actual_config_ids == expected_config_ids, f'Should find all test configurations. Found: {result}'
-
- elif item_type == 'transformation':
- expected_config_ids = {
- config.configuration_id for config in configs if config.component_id == 'keboola.snowflake-transformation'
- }
- actual_config_ids = {hit.configuration_id for hit in result if hit.item_type == 'transformation'}
- assert actual_config_ids == expected_config_ids, f'Should find all test transformations. Found: {result}'
-
-
-@pytest.mark.asyncio
-async def test_find_component_id(mcp_client: Client):
- """Tests that `find_component_id` returns relevant component IDs for a query."""
- query = 'generic extractor - extract data from many APIs'
- generic_extractor_id = 'ex-generic-v2'
-
- try:
- full_result = await mcp_client.call_tool('find_component_id', {'query': query})
- except (httpx.ReadTimeout, ToolError) as e:
- # The AI service backing `find_component_id` can exceed its read timeout in CI. The timeout
- # surfaces either as a raw httpx.ReadTimeout or, when it round-trips through the MCP tool, as
- # a ToolError carrying an "...timed out..." message. Tolerate only that timeout signature; any
- # other ToolError is a real failure and must propagate.
- if isinstance(e, httpx.ReadTimeout) or 'timed out' in str(e).lower():
- pytest.xfail(f'AI service exceeded read timeout in CI: {e}')
- raise
-
- assert full_result.structured_content is not None
- result = full_result.structured_content['result']
-
- assert isinstance(result, list)
- assert len(result) > 0
- LOG.info(f'result: {result}')
- structured_result = [SuggestedComponentOutput.model_validate(component) for component in result]
- assert generic_extractor_id in [component.component_id for component in structured_result]
-
- # check validity of the TOON formatted unstructured result
- assert len(full_result.content) == 1
- assert full_result.content[0].type == 'text'
- decoded_toon = toon_format.decode(full_result.content[0].text)
- assert decoded_toon == result
-
-
-@pytest.mark.asyncio
-async def test_search_config_based_simple_query(
- mcp_client: Client,
- configs: list[ConfigDef],
-) -> None:
- """
- Test config-based search with a simple scoped query.
- """
- config = next(cfg for cfg in configs if cfg.component_id == 'ex-generic-v2')
- full_result = await mcp_client.call_tool(
- 'search',
- {
- 'patterns': ['wttr.in'],
- 'item_types': ['configuration'],
- 'search_type': 'config-based',
- 'scopes': ['parameters.api.baseUrl'],
- 'limit': 20,
- 'offset': 0,
- },
- )
-
- assert full_result.structured_content is not None
- result = [SearchHit.model_validate(hit) for hit in full_result.structured_content['hits']]
-
- assert any(
- hit.component_id == 'ex-generic-v2' and hit.configuration_id == config.configuration_id for hit in result
- ), f'Expected config {config.configuration_id} to be returned. Found: {result}'
diff --git a/integtests/tools/test_sql.py b/integtests/tools/test_sql.py
deleted file mode 100644
index 54c96d6f6..000000000
--- a/integtests/tools/test_sql.py
+++ /dev/null
@@ -1,60 +0,0 @@
-import csv
-import logging
-from io import StringIO
-
-import pytest
-from mcp.server.fastmcp import Context
-
-from keboola_mcp_server.tools.sql import QueryDataOutput, query_data
-from keboola_mcp_server.tools.storage.tools import get_buckets, get_tables
-from keboola_mcp_server.workspace import WorkspaceManager
-
-LOG = logging.getLogger(__name__)
-
-
-@pytest.mark.asyncio
-async def test_query_data(mcp_context: Context):
- """Tests basic functionality of SQL tools: get_sql_dialect and query_data."""
-
- buckets_listing = await get_buckets(ctx=mcp_context)
-
- tables_listing = await get_tables(bucket_ids=[buckets_listing.buckets[0].id], ctx=mcp_context)
- tables_listing = await get_tables(table_ids=[tables_listing.tables[0].id], ctx=mcp_context)
- table = tables_listing.tables[0]
-
- assert table.fully_qualified_name is not None, 'Table should have fully qualified name'
-
- sql_query = f'SELECT COUNT(*) as row_count FROM {table.fully_qualified_name}'
- result = await query_data(sql_query=sql_query, query_name='Row Count Query', ctx=mcp_context)
-
- # Verify result is structured output
- assert isinstance(result, QueryDataOutput)
- assert result.query_name == 'Row Count Query'
- assert isinstance(result.csv_data, str)
- assert len(result.csv_data) > 0
-
- # Parse the CSV to verify structure
- csv_reader = csv.reader(StringIO(result.csv_data))
- rows = list(csv_reader)
-
- manager = WorkspaceManager.from_state(mcp_context.session.state)
- sql_dialect = await manager.get_sql_dialect()
-
- # Should have a header and one data row
- assert len(rows) == 2, 'COUNT query should return header + one data row'
- expected_header = 'ROW_COUNT' if sql_dialect == 'Snowflake' else 'row_count'
- assert rows[0] == [expected_header], f'Expected [{expected_header!r}], got {rows[0]}'
-
- # Count should be a number
- count_value = rows[1][0]
- assert count_value.isdigit(), f'Count value should be numeric, got: {count_value}'
-
-
-@pytest.mark.asyncio
-async def test_query_data_invalid_query(mcp_context: Context):
- """Tests that `query_data` properly handles invalid SQL queries."""
-
- invalid_sql = 'INVALID SQL SYNTAX SELECT * FROM'
-
- with pytest.raises(ValueError, match='Failed to run SQL query'):
- await query_data(sql_query=invalid_sql, query_name='Invalid Query Test', ctx=mcp_context)
diff --git a/integtests/tools/test_storage.py b/integtests/tools/test_storage.py
deleted file mode 100644
index d20e29599..000000000
--- a/integtests/tools/test_storage.py
+++ /dev/null
@@ -1,346 +0,0 @@
-import csv
-import logging
-from typing import Any, cast
-
-import pytest
-import toon_format
-from fastmcp import Client, Context
-
-from integtests.conftest import BucketDef, TableDef
-from keboola_mcp_server.clients.client import KeboolaClient, get_metadata_property
-from keboola_mcp_server.config import MetadataField
-from keboola_mcp_server.tools.storage.tools import (
- BucketDetail,
- DescriptionUpdate,
- GetBucketsOutput,
- GetTablesOutput,
- TableDetail,
- TableSummary,
- UpdateDescriptionsOutput,
- get_buckets,
- get_tables,
- update_descriptions,
-)
-
-LOG = logging.getLogger(__name__)
-
-
-@pytest.mark.asyncio
-async def test_get_buckets(mcp_context: Context, buckets: list[BucketDef]):
- """Tests that `get_buckets` returns a list of `BucketDetail` instances."""
- result = await get_buckets(mcp_context)
-
- assert isinstance(result, GetBucketsOutput)
- for item in result.buckets:
- assert isinstance(item, BucketDetail)
-
- assert len(result.buckets) == len(buckets)
- assert result.bucket_counts.total_buckets == len(buckets)
-
- # Count buckets by stage from the actual result (since BucketDef doesn't have stage info)
- actual_input_count = sum(1 for bucket in result.buckets if bucket.stage == 'in')
- actual_output_count = sum(1 for bucket in result.buckets if bucket.stage == 'out')
-
- # Verify our counts match what we calculated
- assert result.bucket_counts.input_buckets == actual_input_count
- assert result.bucket_counts.output_buckets == actual_output_count
-
- # Verify the counts add up to the total
- assert (
- result.bucket_counts.input_buckets + result.bucket_counts.output_buckets == result.bucket_counts.total_buckets
- )
-
-
-@pytest.mark.asyncio
-async def test_get_buckets_output_format(mcp_client: Client, buckets: list[BucketDef]):
- """Tests that `get_buckets` returns the tool output in TOON format."""
- result = await mcp_client.call_tool('get_buckets')
- assert len(result.content) == 1
- assert result.content[0].type == 'text'
- result_text = result.content[0].text
- structured_output = GetBucketsOutput.model_validate(result.structured_content)
- assert GetBucketsOutput.model_validate(toon_format.decode(result_text)) == structured_output
-
- # check that the buckets are presented in tabular format
- expected_keys = list(
- {k: 'foo' for b in structured_output.buckets for k in b.model_dump(exclude_none=True).keys()}.keys()
- )
- assert result_text.startswith(f"buckets[2]{{{','.join(expected_keys)}}}:")
-
-
-@pytest.mark.asyncio
-async def test_get_bucket(mcp_context: Context, buckets: list[BucketDef]):
- """Tests that for each test bucket, `get_bucket` returns a `BucketDetail` instance."""
- for bucket in buckets:
- result = await get_buckets(mcp_context, [bucket.bucket_id])
- assert isinstance(result, GetBucketsOutput)
- assert len(result.buckets) == 1
- assert result.buckets[0].id == bucket.bucket_id
-
-
-@pytest.mark.asyncio
-async def test_get_table(mcp_context: Context, tables: list[TableDef]):
- """Tests that for each test table, `get_table` returns a `TableDetail` instance with correct fields."""
-
- for table_def in tables:
- with table_def.file_path.open('r', encoding='utf-8') as f:
- reader = csv.reader(f)
- col_names = frozenset(next(reader))
-
- result = await get_tables(mcp_context, table_ids=[table_def.table_id])
- assert isinstance(result, GetTablesOutput)
- assert len(result.tables) == 1
- assert result.tables[0].id == table_def.table_id
- assert result.tables[0].name == table_def.table_name
- assert result.tables[0].columns is not None
- assert {col.name for col in result.tables[0].columns} == col_names
-
-
-@pytest.mark.asyncio
-async def test_get_tables(mcp_context: Context, tables: list[TableDef], buckets: list[BucketDef]):
- """Tests that `get_tables` returns the correct tables for each bucket."""
- # Group tables by bucket to verify counts
- tables_by_bucket: dict[str, list[TableDef]] = {}
- for table_def in tables:
- if table_def.bucket_id not in tables_by_bucket:
- tables_by_bucket[table_def.bucket_id] = []
- tables_by_bucket[table_def.bucket_id].append(table_def)
-
- for bucket in buckets:
- result = await get_tables(mcp_context, [bucket.bucket_id])
-
- assert isinstance(result, GetTablesOutput)
- # Listing returns summaries that never resolve the warehouse FQN, so the field must be
- # absent from the (structured) output entirely — not emitted as a misleading `null`, which
- # the query_data queryability rule would read as "not queryable".
- for table, dumped in zip(result.tables, result.model_dump(by_alias=True)['tables']):
- assert isinstance(table, TableSummary)
- assert not isinstance(table, TableDetail)
- assert 'fullyQualifiedName' not in dumped
- assert 'columns' not in dumped
-
- # Verify the count matches expected tables for this bucket
- expected_tables = tables_by_bucket.get(bucket.bucket_id, [])
- assert len(result.tables) == len(expected_tables)
-
- # Verify table IDs match
- result_table_ids = {table.id for table in cast(list[TableSummary], result.tables)}
- expected_table_ids = {table_def.table_id for table_def in expected_tables}
- assert result_table_ids == expected_table_ids
-
-
-@pytest.mark.asyncio
-async def test_get_tables_output_format(mcp_client: Client, tables: list[TableDef], buckets: list[BucketDef]):
- """Tests that `get_tables` returns the tool output in TOON format."""
- result = await mcp_client.call_tool('get_tables', {'bucket_ids': [buckets[0].bucket_id]})
- assert len(result.content) == 1
- assert result.content[0].type == 'text'
- result_text = result.content[0].text
- structured_output = GetTablesOutput.model_validate(result.structured_content)
- assert GetTablesOutput.model_validate(toon_format.decode(result_text)) == structured_output
-
- first_table = structured_output.tables[0]
- expected_keys = list(first_table.model_dump(exclude_none=True).keys())
- assert result_text.startswith(f"tables[1]{{{','.join(expected_keys)}}}:")
-
-
-@pytest.mark.asyncio
-async def test_update_descriptions_bucket(mcp_context: Context, buckets: list[BucketDef]):
- """Tests that `update_descriptions` updates bucket descriptions correctly."""
- bucket = buckets[0]
- client = KeboolaClient.from_state(mcp_context.session.state)
-
- result = await update_descriptions(
- ctx=mcp_context,
- updates=[DescriptionUpdate(item_id=bucket.bucket_id, description='New Description')],
- )
-
- assert isinstance(result, UpdateDescriptionsOutput)
- assert result.total_processed == 1
- assert result.successful == 1
- assert result.failed == 0
- assert len(result.results) == 1
-
- bucket_result = result.results[0]
- assert bucket_result.item_id == bucket.bucket_id
- assert bucket_result.success is True
- assert bucket_result.error is None
- assert bucket_result.timestamp is not None
-
- # Verify the description was actually updated
- metadata = await client.storage_client.bucket_metadata_get(bucket.bucket_id)
- assert get_metadata_property(metadata, MetadataField.DESCRIPTION) == 'New Description'
-
-
-@pytest.mark.asyncio
-async def test_update_descriptions_table(mcp_context: Context, mcp_client: Client, tables: list[TableDef]):
- """
- Tests that `update_descriptions` updates table descriptions correctly.
- Also tests that the tool output is in TOON format.
- """
- table = tables[0]
- storage_client = KeboolaClient.from_state(mcp_context.session.state).storage_client
-
- call_result = await mcp_client.call_tool(
- 'update_descriptions',
- {
- 'updates': [{'item_id': table.table_id, 'description': 'New Table Description'}],
- },
- )
- assert len(call_result.content) == 1
- assert call_result.content[0].type == 'text'
-
- result = UpdateDescriptionsOutput.model_validate(call_result.structured_content)
-
- toon_result = UpdateDescriptionsOutput.model_validate(toon_format.decode(call_result.content[0].text))
- assert toon_result == result
-
- assert isinstance(result, UpdateDescriptionsOutput)
- assert result.total_processed == 1
- assert result.successful == 1
- assert result.failed == 0
- assert len(result.results) == 1
-
- table_result = result.results[0]
- assert table_result.item_id == table.table_id
- assert table_result.success is True
- assert table_result.error is None
- assert table_result.timestamp is not None
-
- # Verify the description was actually updated
- metadata = await storage_client.table_metadata_get(table.table_id)
- assert get_metadata_property(metadata, MetadataField.DESCRIPTION) == 'New Table Description'
-
-
-@pytest.mark.asyncio
-async def test_update_descriptions_table_column(mcp_context: Context, tables: list[TableDef]):
- """Tests that `update_descriptions` updates table descriptions correctly."""
- table = tables[0]
-
- with table.file_path.open('r', encoding='utf-8') as f:
- reader = csv.reader(f)
- col_names = next(reader)
- column_name = col_names[0]
-
- column_id = f'{table.table_id}.{column_name}'
- result = await update_descriptions(
- ctx=mcp_context,
- updates=[DescriptionUpdate(item_id=column_id, description='New Table Column Description')],
- )
-
- assert isinstance(result, UpdateDescriptionsOutput)
- assert result.total_processed == 1
- assert result.successful == 1
- assert result.failed == 0
- assert len(result.results) == 1
-
- column_result = result.results[0]
- assert column_result.item_id == column_id
- assert column_result.success is True
- assert column_result.error is None
- assert column_result.timestamp is not None
-
- # Verify the description is available in the table detail
- tables_output = await get_tables(mcp_context, table_ids=[table.table_id])
- assert isinstance(tables_output, GetTablesOutput)
- assert len(tables_output.tables) == 1
- table_detail = tables_output.tables[0]
- assert table_detail.columns is not None
- column_detail = next((col for col in table_detail.columns if col.name == column_name), None)
- assert column_detail is not None
- assert column_detail.description == 'New Table Column Description'
-
-
-@pytest.mark.asyncio
-async def test_update_descriptions_mixed_types(mcp_context: Context, buckets: list[BucketDef], tables: list[TableDef]):
- """Tests that `update_descriptions` can handle mixed types in a single call."""
- bucket = buckets[0]
- table = tables[0]
-
- # Get the first column name from the table CSV file
- with table.file_path.open('r', encoding='utf-8') as f:
- reader = csv.reader(f)
- columns = next(reader)
- column_name = columns[0]
-
- md_ids: list[tuple[str, str, str]] = []
- client = KeboolaClient.from_state(mcp_context.session.state)
- try:
- result = await update_descriptions(
- ctx=mcp_context,
- updates=[
- DescriptionUpdate(item_id=bucket.bucket_id, description='Mixed Bucket Description'),
- DescriptionUpdate(item_id=table.table_id, description='Mixed Table Description'),
- DescriptionUpdate(item_id=f'{table.table_id}.{column_name}', description='Mixed Column Description'),
- ],
- )
-
- assert isinstance(result, UpdateDescriptionsOutput)
- assert result.total_processed == 3
- assert result.successful == 3
- assert result.failed == 0
- assert len(result.results) == 3
-
- # Verify all results are successful
- for item_result in result.results:
- assert item_result.success is True
- assert item_result.error is None
- assert item_result.timestamp is not None
-
- # Verify bucket description was updated
- bucket_metadata = await client.storage_client.bucket_metadata_get(bucket.bucket_id)
- bucket_entry = next((entry for entry in bucket_metadata if entry.get('key') == MetadataField.DESCRIPTION), None)
- if bucket_entry:
- assert bucket_entry['value'] == 'Mixed Bucket Description'
- md_ids.append(('bucket', bucket.bucket_id, str(bucket_entry['id'])))
-
- # Verify table description was updated
- table_metadata = await client.storage_client.table_metadata_get(table.table_id)
- table_entry = next((entry for entry in table_metadata if entry.get('key') == MetadataField.DESCRIPTION), None)
- if table_entry:
- assert table_entry['value'] == 'Mixed Table Description'
- md_ids.append(('table', table.table_id, str(table_entry['id'])))
-
- # Verify column description was updated
- table_detail = await client.storage_client.table_detail(table.table_id)
- assert 'columnMetadata' in table_detail
- column_metadata = cast(dict[str, list[dict[str, Any]]], table_detail['columnMetadata'])
- assert column_name in column_metadata
- column_entry = next(
- (entry for entry in column_metadata[column_name] if entry.get('key') == MetadataField.DESCRIPTION), None
- )
- if column_entry:
- assert column_entry['value'] == 'Mixed Column Description'
- md_ids.append(('column', f'{table.table_id}.{column_name}', str(column_entry['id'])))
-
- finally:
- # Clean up metadata
- for md_type, item_id, md_id in md_ids:
- if md_type == 'bucket':
- await client.storage_client.bucket_metadata_delete(bucket_id=item_id, metadata_id=md_id)
- elif md_type == 'table':
- await client.storage_client.table_metadata_delete(table_id=item_id, metadata_id=md_id)
- elif md_type == 'column':
- await client.storage_client.column_metadata_delete(column_id=item_id, metadata_id=md_id)
-
-
-@pytest.mark.asyncio
-async def test_update_descriptions_invalid_path(mcp_context: Context):
- """Tests that `update_descriptions` handles invalid paths gracefully."""
- result = await update_descriptions(
- ctx=mcp_context,
- updates=[DescriptionUpdate(item_id='invalid-path', description='This should fail')],
- )
-
- assert isinstance(result, UpdateDescriptionsOutput)
- assert result.total_processed == 1
- assert result.successful == 0
- assert result.failed == 1
- assert len(result.results) == 1
-
- error_result = result.results[0]
- assert error_result.item_id == 'invalid-path'
- assert error_result.success is False
- assert error_result.error is not None
- assert 'Invalid item_id format' in error_result.error
- assert error_result.timestamp is None
diff --git a/integtests/tools/test_storage_branches.py b/integtests/tools/test_storage_branches.py
deleted file mode 100644
index 46feacdc5..000000000
--- a/integtests/tools/test_storage_branches.py
+++ /dev/null
@@ -1,550 +0,0 @@
-"""Integration tests for branched storage — validates the storage-branches deference mechanism."""
-
-import csv
-import json
-import logging
-import os
-import time
-import uuid
-from dataclasses import dataclass
-from io import StringIO
-from typing import Any, Generator
-
-import httpx
-import pytest
-import pytest_asyncio
-from fastmcp import Context
-from mcp.server.session import ServerSession
-from mcp.types import ClientCapabilities, InitializeRequestParams
-
-from keboola_mcp_server.clients.client import KeboolaClient
-from keboola_mcp_server.config import Config
-from keboola_mcp_server.mcp import ServerRuntimeInfo, ServerState
-from keboola_mcp_server.tools.project import get_project_info
-from keboola_mcp_server.tools.sql import QueryDataOutput, query_data
-from keboola_mcp_server.tools.storage.tools import get_buckets, get_tables
-from keboola_mcp_server.workspace import WorkspaceManager
-
-LOG = logging.getLogger(__name__)
-
-PYTHON_TRANSFORMATION_COMPONENT = 'keboola.python-transformation-v2'
-STORAGE_BRANCHES_TOKEN_ENV_VAR = 'INTEGTEST_STORAGE_TOKEN_STORAGE_BRANCHES'
-
-
-# --- Helper functions ---
-
-
-def _python_transform_config(destination: str, csv_filename: str, fieldnames: list[str], row: dict[str, str]) -> dict:
- """Build a Python transformation config that generates a single-row CSV and writes to destination."""
- fields_str = str(fieldnames)
- row_str = str(row)
- script = (
- 'import csv\n'
- 'import os\n'
- "os.makedirs('out/tables', exist_ok=True)\n"
- f"with open('out/tables/{csv_filename}', mode='wt', encoding='utf-8') as f:\n"
- f" writer = csv.DictWriter(f, fieldnames={fields_str}, dialect='kbc')\n"
- ' writer.writeheader()\n'
- f' writer.writerow({row_str})'
- )
- return {
- 'storage': {
- 'output': {
- 'tables': [
- {
- 'source': csv_filename,
- 'destination': destination,
- 'primary_key': ['id'],
- }
- ]
- }
- },
- 'parameters': {
- 'blocks': [
- {
- 'name': 'Generate data',
- 'codes': [
- {
- 'name': 'script',
- 'script': [script],
- }
- ],
- }
- ],
- 'packages': [],
- },
- }
-
-
-def _api_request(method: str, url: str, token: str, **kwargs: Any) -> dict:
- """Make a synchronous HTTP request to the Keboola API."""
- headers = {'X-StorageApi-Token': token, 'Content-Type': 'application/json'}
- resp = httpx.request(method, url, headers=headers, **kwargs)
- resp.raise_for_status()
- return resp.json() if resp.content else {}
-
-
-def _wait_for_storage_job(base_url: str, token: str, job_id: str, timeout: int = 120) -> None:
- """Wait for a Storage API job to complete."""
- deadline = time.time() + timeout
- while time.time() < deadline:
- job = _api_request('GET', f'{base_url}/v2/storage/jobs/{job_id}', token)
- status = job.get('status')
- if status == 'success':
- return
- if status in ('error', 'cancelled'):
- raise RuntimeError(f'Storage job {job_id} failed: {job}')
- time.sleep(2)
- raise TimeoutError(f'Storage job {job_id} did not complete within {timeout}s')
-
-
-def _wait_for_queue_job(base_url: str, token: str, job_id: str, timeout: int = 300) -> None:
- """Wait for a Job Queue job to complete."""
- queue_url = base_url.replace('connection.', 'queue.')
- deadline = time.time() + timeout
- while time.time() < deadline:
- resp = httpx.get(f'{queue_url}/jobs/{job_id}', headers={'X-StorageApi-Token': token})
- resp.raise_for_status()
- job = resp.json()
- status = job.get('status')
- if status == 'success':
- return
- if status in ('error', 'cancelled', 'terminated'):
- raise RuntimeError(
- f'Queue job {job_id} failed with status={status}: {json.dumps(job.get("result", {}))[:500]}'
- )
- time.sleep(5)
- raise TimeoutError(f'Queue job {job_id} did not complete within {timeout}s')
-
-
-def _create_branch(base_url: str, token: str, name: str) -> str:
- """Create a dev branch and return its ID."""
- job = _api_request('POST', f'{base_url}/v2/storage/dev-branches', token, json={'name': name})
- job_id = str(job['id'])
- _wait_for_storage_job(base_url, token, job_id)
- job = _api_request('GET', f'{base_url}/v2/storage/jobs/{job_id}', token)
- branch_id = str(job['results']['id'])
- LOG.info(f'Created branch {name!r} with id={branch_id}')
- return branch_id
-
-
-def _delete_branch(base_url: str, token: str, branch_id: str) -> None:
- """Delete a dev branch."""
- try:
- job = _api_request('DELETE', f'{base_url}/v2/storage/dev-branches/{branch_id}', token)
- job_id = str(job['id'])
- _wait_for_storage_job(base_url, token, job_id)
- LOG.info(f'Deleted branch {branch_id}')
- except Exception:
- LOG.exception(f'Failed to delete branch {branch_id}')
-
-
-def _ensure_bucket(base_url: str, token: str, name: str, stage: str = 'in') -> str:
- """Create a bucket if it doesn't already exist. Returns the bucket ID."""
- bucket_id = f'{stage}.c-{name}'
- try:
- _api_request('GET', f'{base_url}/v2/storage/buckets/{bucket_id}', token)
- LOG.info(f'Bucket {bucket_id} already exists')
- except httpx.HTTPStatusError as e:
- if e.response.status_code != 404:
- raise
- result = _api_request(
- 'POST',
- f'{base_url}/v2/storage/buckets',
- token,
- json={'name': name, 'stage': stage, 'description': 'Integration test bucket'},
- )
- bucket_id = result['id']
- LOG.info(f'Created bucket {bucket_id}')
- return bucket_id
-
-
-def _ensure_table(base_url: str, token: str, bucket_id: str, table_name: str, csv_data: str) -> str:
- """Create a table if it doesn't already exist. Returns the table ID."""
- table_id = f'{bucket_id}.{table_name}'
- try:
- _api_request('GET', f'{base_url}/v2/storage/tables/{table_id}', token)
- LOG.info(f'Table {table_id} already exists')
- except httpx.HTTPStatusError as e:
- if e.response.status_code != 404:
- raise
- resp = httpx.post(
- f'{base_url}/v2/storage/buckets/{bucket_id}/tables',
- headers={'X-StorageApi-Token': token},
- data={'name': table_name, 'delimiter': ',', 'dataString': csv_data},
- )
- resp.raise_for_status()
- result = resp.json()
- table_id = result['id']
- LOG.info(f'Created table {table_id}')
- return table_id
-
-
-def _create_config_in_branch(
- base_url: str, token: str, branch_id: str, component_id: str, name: str, config: dict
-) -> str:
- """Create a component configuration in a specific branch. Returns the config ID."""
- result = _api_request(
- 'POST',
- f'{base_url}/v2/storage/branch/{branch_id}/components/{component_id}/configs',
- token,
- json={'name': name, 'description': f'Integration test config: {name}', 'configuration': json.dumps(config)},
- )
- config_id = str(result['id'])
- LOG.info(f'Created config {name!r} (id={config_id}) in branch {branch_id}')
- return config_id
-
-
-def _run_job_in_branch(base_url: str, token: str, branch_id: str, component_id: str, config_id: str) -> None:
- """Run a job in a specific branch and wait for completion."""
- queue_url = base_url.replace('connection.', 'queue.')
- resp = httpx.post(
- f'{queue_url}/jobs',
- headers={'X-StorageApi-Token': token, 'Content-Type': 'application/json'},
- json={'component': component_id, 'config': config_id, 'mode': 'run', 'branchId': branch_id},
- )
- resp.raise_for_status()
- job = resp.json()
- job_id = str(job['id'])
- LOG.info(f'Started job {job_id} for {component_id}/{config_id} in branch {branch_id}')
- _wait_for_queue_job(base_url, token, job_id)
- LOG.info(f'Job {job_id} completed successfully')
-
-
-# --- Test data setup/teardown ---
-
-
-@dataclass
-class BranchTestProject:
- """A project with branches set up for testing."""
-
- storage_api_url: str
- storage_api_token: str
- branch_a_id: str
- branch_b_id: str
- label: str
-
-
-def _setup_branch_test_project(
- storage_api_url: str,
- token: str,
- label: str,
-) -> BranchTestProject:
- """
- Set up production data, branches, and branched data in a project.
-
- Production data (bucket + table) is created idempotently so multiple
- concurrent sessions can share the same project.
-
- Branch_A:
- - Updates in.c-test_bucket_01.test_table_01 (creates branched version)
- - Creates new bucket in.c-test_branch with test_table_branch
-
- Branch_B:
- - Creates new bucket in.c-test_branch_2 with test_table_branch
- """
- token_info = _api_request('GET', f'{storage_api_url}/v2/storage/tokens/verify', token)
- project_name = token_info['owner']['name']
- features = token_info.get('owner', {}).get('features', [])
- if 'storage-branches' not in features:
- pytest.fail(f'[{label}] project {project_name!r} must have the storage-branches feature enabled')
- LOG.info(f'[{label}] Setting up project {project_name!r}')
-
- _ensure_bucket(storage_api_url, token, 'test_bucket_01')
- _ensure_table(
- storage_api_url,
- token,
- 'in.c-test_bucket_01',
- 'test_table_01',
- '"id","name","item_count"\n1,"item1",10\n2,"item2",20',
- )
-
- # Create branches
- uid = str(uuid.uuid4())[:8]
- branch_a_id = _create_branch(storage_api_url, token, f'integtest-branch-A-{uid}')
- branch_b_id = _create_branch(storage_api_url, token, f'integtest-branch-B-{uid}')
-
- # Branch A: update existing table (creates branched version)
- config = _python_transform_config(
- destination='in.c-test_bucket_01.test_table_01',
- csv_filename='test_table_01.csv',
- fieldnames=['id', 'name', 'item_count'],
- row={'id': '99', 'name': 'branched_item', 'item_count': '999'},
- )
- cid = _create_config_in_branch(
- storage_api_url, token, branch_a_id, PYTHON_TRANSFORMATION_COMPONENT, 'update-tbl', config
- )
- _run_job_in_branch(storage_api_url, token, branch_a_id, PYTHON_TRANSFORMATION_COMPONENT, cid)
-
- # Branch A: create new bucket + table
- config = _python_transform_config(
- destination='in.c-test_branch.test_table_branch',
- csv_filename='test_table_branch.csv',
- fieldnames=['id', 'name', 'value'],
- row={'id': '1', 'name': 'branch_a_data', 'value': '100'},
- )
- cid = _create_config_in_branch(
- storage_api_url, token, branch_a_id, PYTHON_TRANSFORMATION_COMPONENT, 'create-tbl', config
- )
- _run_job_in_branch(storage_api_url, token, branch_a_id, PYTHON_TRANSFORMATION_COMPONENT, cid)
-
- # Branch B: create new bucket + table
- config = _python_transform_config(
- destination='in.c-test_branch_2.test_table_branch',
- csv_filename='test_table_branch.csv',
- fieldnames=['id', 'name', 'value'],
- row={'id': '1', 'name': 'branch_b_data', 'value': '200'},
- )
- cid = _create_config_in_branch(
- storage_api_url, token, branch_b_id, PYTHON_TRANSFORMATION_COMPONENT, 'create-b-tbl', config
- )
- _run_job_in_branch(storage_api_url, token, branch_b_id, PYTHON_TRANSFORMATION_COMPONENT, cid)
-
- LOG.info(f'[{label}] Setup complete: branch_a={branch_a_id}, branch_b={branch_b_id}')
- return BranchTestProject(
- storage_api_url=storage_api_url,
- storage_api_token=token,
- branch_a_id=branch_a_id,
- branch_b_id=branch_b_id,
- label=label,
- )
-
-
-def _teardown_branch_test_project(project: BranchTestProject) -> None:
- """Clean up branches only. Production data is kept for reuse across sessions."""
- LOG.info(f'[{project.label}] Tearing down')
- _delete_branch(project.storage_api_url, project.storage_api_token, project.branch_a_id)
- _delete_branch(project.storage_api_url, project.storage_api_token, project.branch_b_id)
-
-
-# --- Fixtures ---
-
-
-@pytest.fixture(scope='session')
-def branch_project(
- storage_api_url: str,
- env_file_loaded: bool,
-) -> Generator[BranchTestProject, Any, None]:
- """
- Sets up a dedicated project (outside the pool) with the `storage-branches`
- feature enabled.
-
- Idempotent production data setup and unique branch names allow multiple
- concurrent sessions to safely share the same project.
- """
- token = os.getenv(STORAGE_BRANCHES_TOKEN_ENV_VAR, '').strip()
- if not token:
- pytest.fail(
- f'{STORAGE_BRANCHES_TOKEN_ENV_VAR} must be set to a storage token '
- f'for a project WITH the storage-branches feature'
- )
-
- project: BranchTestProject | None = None
- try:
- project = _setup_branch_test_project(storage_api_url, token, 'storage-branches')
- yield project
- finally:
- if project is not None:
- _teardown_branch_test_project(project)
-
-
-async def _build_context(
- mocker,
- branch_project: BranchTestProject,
- *,
- branch_id: str | None,
-) -> Context:
- """Build an MCP context bound to a specific branch (or the default branch when None)."""
- keboola_client = KeboolaClient(
- storage_api_url=branch_project.storage_api_url,
- storage_api_token=branch_project.storage_api_token,
- headers={'User-Agent': 'KeboolaMCPServer/integtest'},
- )
- if branch_id is not None:
- keboola_client = await keboola_client.with_branch_id(branch_id)
- workspace_manager = await WorkspaceManager.create(keboola_client)
-
- mcp_config = Config(
- storage_api_url=branch_project.storage_api_url,
- storage_token=branch_project.storage_api_token,
- )
- ctx = mocker.MagicMock(Context)
- ctx.session = mocker.MagicMock(ServerSession)
- ctx.session.state = {
- KeboolaClient.STATE_KEY: keboola_client,
- WorkspaceManager.STATE_KEY: workspace_manager,
- }
- ctx.session.client_params = InitializeRequestParams(
- protocolVersion='1',
- capabilities=ClientCapabilities(),
- clientInfo={'name': 'integtest-branches', 'version': '0.0.1'},
- )
- ctx.client_id = 'KeboolaMCPServer/integtest'
- ctx.session_id = None
- ctx.request_context = mocker.MagicMock()
- ctx.request_context.lifespan_context = ServerState(mcp_config, ServerRuntimeInfo(transport='stdio'))
- return ctx
-
-
-@pytest_asyncio.fixture
-async def branch_context(
- mocker,
- branch_project: BranchTestProject,
-) -> Context:
- """MCP context bound to Branch A of the current parametrized project."""
- return await _build_context(mocker, branch_project, branch_id=branch_project.branch_a_id)
-
-
-@pytest_asyncio.fixture
-async def default_branch_context(
- mocker,
- branch_project: BranchTestProject,
-) -> Context:
- """MCP context bound to the default/production branch of the current parametrized project."""
- return await _build_context(mocker, branch_project, branch_id=None)
-
-
-# --- Tests ---
-
-
-@pytest.mark.asyncio
-async def test_list_buckets_includes_branch_a_bucket(
- branch_context: Context,
- branch_project: BranchTestProject,
-) -> None:
- """get_buckets from Branch A should include production buckets + Branch A's new bucket, but not Branch B's."""
- result = await get_buckets(branch_context)
- bucket_ids = {b.id for b in result.buckets}
-
- expected_ids = {'in.c-test_bucket_01', 'in.c-test_branch'}
- assert bucket_ids == expected_ids, f'Expected exactly {expected_ids}, got {bucket_ids}'
-
-
-@pytest.mark.asyncio
-async def test_list_tables_in_branched_bucket(
- branch_context: Context,
- branch_project: BranchTestProject,
-) -> None:
- """get_tables for Branch A's new bucket should return the table with a production-like ID."""
- result = await get_tables(branch_context, bucket_ids=['in.c-test_branch'])
-
- assert len(result.tables) == 1
- table = result.tables[0]
- assert table.name == 'test_table_branch'
- assert table.id == 'in.c-test_branch.test_table_branch'
- assert table.branch_id is None
-
-
-@pytest.mark.asyncio
-async def test_deference_branched_table(
- branch_context: Context,
- branch_project: BranchTestProject,
-) -> None:
- """get_tables for the production bucket should include the branched version of test_table_01."""
- result = await get_tables(branch_context, bucket_ids=['in.c-test_bucket_01'])
- table_ids = {t.id for t in result.tables}
-
- assert 'in.c-test_bucket_01.test_table_01' in table_ids, f'Branched table missing. Got: {table_ids}'
-
- table = next(t for t in result.tables if t.id == 'in.c-test_bucket_01.test_table_01')
- assert table.branch_id is None
-
-
-@pytest.mark.asyncio
-async def test_get_project_info_reports_dev_branch(
- branch_context: Context,
- branch_project: BranchTestProject,
-) -> None:
- """get_project_info from a dev-branch context should populate branch fields and is_development_branch=True."""
- result = await get_project_info(branch_context)
-
- assert str(result.branch_id) == str(branch_project.branch_a_id)
- assert isinstance(result.branch_name, str)
- assert result.branch_name
- assert result.is_development_branch is True
-
-
-@pytest.mark.asyncio
-async def test_get_project_info_reports_default_branch(
- default_branch_context: Context,
- branch_project: BranchTestProject,
-) -> None:
- """get_project_info from a default-branch context should populate branch fields and is_development_branch=False."""
- result = await get_project_info(default_branch_context)
-
- assert isinstance(result.branch_id, (str, int))
- assert isinstance(result.branch_name, str)
- assert result.branch_name
- assert result.is_development_branch is False
- # Sanity: the default branch must not be the dev branch we created for this session.
- assert str(result.branch_id) != str(branch_project.branch_a_id)
- assert str(result.branch_id) != str(branch_project.branch_b_id)
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize(
- ('table_id', 'description'),
- [
- ('in.c-test_branch.test_table_branch', 'branch-only table created via transformation'),
- ('in.c-test_bucket_01.test_table_01', 'production table (also has a branched version in Branch A)'),
- ],
- ids=['branch_only_table', 'production_table'],
-)
-async def test_query_data_from_dev_branch_reaches_both_kinds_of_tables(
- branch_context: Context,
- branch_project: BranchTestProject,
- table_id: str,
- description: str,
-) -> None:
- """
- From a dev-branch MCP context, `query_data` must successfully execute SELECT against
- both a table that exists only in the branch and a table that exists in production.
- Verifies the branch-aware workspace selection unblocks SQL on both kinds of tables.
- """
- tables_listing = await get_tables(branch_context, table_ids=[table_id])
- assert len(tables_listing.tables) == 1, f'Expected exactly one table for {table_id}'
- table = tables_listing.tables[0]
- assert table.fully_qualified_name, f'{description}: table {table_id} has no FQN, cannot query'
-
- sql_query = f'SELECT COUNT(*) AS row_count FROM {table.fully_qualified_name}'
- result = await query_data(
- sql_query=sql_query,
- query_name=f'Row count for {table_id}',
- ctx=branch_context,
- )
-
- assert isinstance(result, QueryDataOutput)
- assert result.csv_data, f'{description}: query returned no CSV data'
-
- # Sanity-check: COUNT(*) returns a numeric value. Specific row count varies by data
- # (fixture writes 1 row to each branched table; the production source table has 2 rows).
- csv_reader = csv.reader(StringIO(result.csv_data))
- rows = list(csv_reader)
- assert len(rows) == 2, f'{description}: COUNT query should return header + 1 data row, got {rows}'
- count_value = rows[1][0]
- assert count_value.isdigit(), f'{description}: expected numeric row count, got {count_value!r}'
- assert int(count_value) >= 1, f'{description}: expected positive row count, got {count_value!r}'
-
-
-@pytest.mark.asyncio
-async def test_workspace_id_is_branch_aware(
- branch_context: Context,
- default_branch_context: Context,
- branch_project: BranchTestProject,
-) -> None:
- """
- On a `storage-branches` project, the dev-branch context must return a DIFFERENT
- workspace than the default branch.
- """
- dev_result = await get_project_info(branch_context)
- default_result = await get_project_info(default_branch_context)
-
- assert isinstance(dev_result.workspace_id, int)
- assert dev_result.workspace_id > 0
- assert isinstance(default_result.workspace_id, int)
- assert default_result.workspace_id > 0
-
- assert dev_result.workspace_id != default_result.workspace_id, (
- f'storage-branches project expected per-branch workspace, '
- f'got the same id {dev_result.workspace_id} from both contexts'
- )
diff --git a/integtests/validate.test.ts b/integtests/validate.test.ts
new file mode 100644
index 000000000..bd8d07e10
--- /dev/null
+++ b/integtests/validate.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from 'vitest';
+
+import { __testing } from '@/tools/validation';
+import { getTestProjectForTest } from './testproject/fixture';
+
+// Ported from integtests/test_validate.py.
+//
+// The Python test fetches the storage stack index (which lists every component with its
+// root/row configuration schema) and runs each schema through KeboolaParametersValidator
+// to confirm none of them are *structurally* invalid (jsonschema.SchemaError) — a sanity
+// check that the MCP server can sanitize+validate every real component schema. Validation
+// errors against dummy parameters are irrelevant and ignored; only schema errors fail.
+//
+// The TS validator's schema sanitization (sanitizeSchema, the equivalent of
+// KeboolaParametersValidator.sanitize_schema) is the structural check: it throws on a
+// structurally invalid schema and returns normally otherwise. We run it over every
+// component's root and row schema fetched from the live stack index.
+
+type RawComponent = {
+ id: string;
+ type?: string;
+ name?: string;
+ configurationSchema?: Record | null;
+ configurationRowSchema?: Record | null;
+};
+
+describe('component schema validation (integration)', () => {
+ it('sanitizes every root and row schema on the stack without a structural error', async () => {
+ const project = await getTestProjectForTest({ clean: false });
+
+ // Fetch the storage stack index directly — it carries the full component list with
+ // their configuration schemas (the same source the Python test reads).
+ const res = await fetch(`${project.storageApiUrl}/v2/storage`, {
+ headers: { 'X-StorageApi-Token': project.storageApiToken },
+ });
+ expect(res.ok, `Storage index fetch failed: ${res.status}`).toBeTruthy();
+ const data = (await res.json()) as { components?: RawComponent[] };
+ const components = data.components ?? [];
+ expect(components.length).toBeGreaterThan(0);
+
+ let rootCount = 0;
+ let rowCount = 0;
+ const invalidRoot: string[] = [];
+ const invalidRow: string[] = [];
+
+ for (const component of components) {
+ if (component.configurationSchema) {
+ rootCount++;
+ try {
+ __testing.sanitizeSchema(component.configurationSchema);
+ } catch {
+ invalidRoot.push(component.id);
+ }
+ }
+ if (component.configurationRowSchema) {
+ rowCount++;
+ try {
+ __testing.sanitizeSchema(component.configurationRowSchema);
+ } catch {
+ invalidRow.push(component.id);
+ }
+ }
+ }
+
+ expect(invalidRoot, `Invalid root schemas (${invalidRoot.length}): ${invalidRoot}`).toEqual([]);
+ expect(invalidRow, `Invalid row schemas (${invalidRow.length}): ${invalidRow}`).toEqual([]);
+ // Sanity: the stack exposed at least some schemas to validate.
+ expect(rootCount + rowCount).toBeGreaterThan(0);
+ });
+});
diff --git a/integtests/workspace.test.ts b/integtests/workspace.test.ts
new file mode 100644
index 000000000..67b21ba98
--- /dev/null
+++ b/integtests/workspace.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest';
+
+import { callToolText, connectMcp } from './helpers/mcp';
+import { getTestProjectForTest } from './testproject/fixture';
+
+// Ported from integtests/test_workspace.py.
+//
+// The Python tests poke WorkspaceManager internals directly: test_static_workspace
+// resolves a workspace by its configured schema and reads its backend; test_dynamic_workspace
+// creates a workspace on demand and confirms it is recorded in the branch. The TS port has
+// no configured workspace_schema in the pooled Config — the WorkspaceManager always resolves
+// (or provisions) the read-only SQL workspace on first use. We therefore assert the
+// observable end-to-end behavior the manager is responsible for, through the tools that use
+// it: the workspace is provisioned, its backend is resolved, and SQL actually runs over it.
+
+describe('workspace provisioning + SQL execution (integration)', () => {
+ it('resolves the workspace backend dialect via get_project_info', async () => {
+ // Port of test_static_workspace's `info.backend in ['snowflake', 'bigquery']`:
+ // get_project_info surfaces the sql_dialect, which the WorkspaceManager derives from
+ // the resolved workspace's backend.
+ const { config } = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(config);
+ try {
+ const text = await callToolText(session.client, 'get_project_info');
+ expect(text).toMatch(/Snowflake|BigQuery/i);
+ } finally {
+ await session.close();
+ }
+ });
+
+ it('provisions a workspace and runs a SELECT through it', async () => {
+ // Port of test_dynamic_workspace: with no preconfigured workspace schema, the first
+ // query_data call must provision/resolve the workspace and execute the SQL over it.
+ // A literal SELECT proves the workspace is live and queryable end-to-end.
+ const { config } = await getTestProjectForTest({ clean: false });
+ const session = await connectMcp(config);
+ try {
+ const text = await callToolText(session.client, 'query_data', {
+ sql_query: 'SELECT 1 AS one',
+ query_name: 'Workspace Smoke Query',
+ });
+ expect(text).toContain('Workspace Smoke Query');
+ expect(text).toMatch(/\b1\b/);
+ } finally {
+ await session.close();
+ }
+ });
+});
diff --git a/logging-json.conf b/logging-json.conf
deleted file mode 100644
index 9ecbabbf7..000000000
--- a/logging-json.conf
+++ /dev/null
@@ -1,40 +0,0 @@
-[loggers]
-keys=root,mcp.server,uvicorn.access,uvicorn.error,fastmcp
-
-[handlers]
-keys=root
-
-[formatters]
-keys=json
-
-[logger_root]
-level=INFO
-handlers=root
-
-[logger_mcp.server]
-level=WARNING
-qualname=mcp.server
-handlers=
-
-[logger_uvicorn.access]
-qualname=uvicorn.access
-handlers=
-propagate=1
-
-[logger_uvicorn.error]
-qualname=uvicorn.error
-handlers=
-propagate=1
-
-[logger_fastmcp]
-qualname=fastmcp
-handlers=
-propagate=1
-
-[handler_root]
-class=logging.StreamHandler
-formatter=json
-args=(sys.stdout,)
-
-[formatter_json]
-class=json_log_formatter.VerboseJSONFormatter
\ No newline at end of file
diff --git a/mypy.ini b/mypy.ini
deleted file mode 100644
index 09d4a00bc..000000000
--- a/mypy.ini
+++ /dev/null
@@ -1,30 +0,0 @@
-[mypy]
-python_version = 3.10
-warn_return_any = True
-warn_unused_configs = True
-disallow_untyped_defs = True
-disallow_incomplete_defs = True
-check_untyped_defs = True
-disallow_untyped_decorators = False
-no_implicit_optional = True
-warn_redundant_casts = True
-warn_unused_ignores = True
-warn_no_return = True
-warn_unreachable = True
-show_error_codes = True
-ignore_missing_imports = True
-
-[mypy.plugins.numpy.*]
-ignore_errors = True
-
-[mypy-pandas.*]
-ignore_missing_imports = True
-
-[mypy-kbcstorage.*]
-ignore_missing_imports = True
-
-[mypy-mcp.*]
-ignore_missing_imports = True
-
-[mypy-httpx.*]
-ignore_missing_imports = True
\ No newline at end of file
diff --git a/openwiki/INSTRUCTIONS.md b/openwiki/INSTRUCTIONS.md
new file mode 100644
index 000000000..b0e3e331f
--- /dev/null
+++ b/openwiki/INSTRUCTIONS.md
@@ -0,0 +1 @@
+A code wiki for this local repository. Prioritize a concise quickstart, architecture overview, source map, key workflows, domain concepts, operations/runbook notes, testing guidance, and integration points. Inspect git history to understand reasoning behind code changes and the progression of the repository. Keep pages grounded in the repository structure and recent code changes. Prefer practical navigation for engineers over generic summaries.
diff --git a/oxlint.config.ts b/oxlint.config.ts
new file mode 100644
index 000000000..a3354fc5f
--- /dev/null
+++ b/oxlint.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from 'oxlint';
+
+import keboolaShared from '@keboola/oxlint-config';
+
+export default defineConfig({
+ extends: [keboolaShared],
+ env: {
+ node: true,
+ },
+ ignorePatterns: ['dist/'],
+ rules: {
+ // Not a turborepo; env vars are validated by Config, not turbo.json.
+ 'turbo/no-undeclared-env-vars': 'off',
+ },
+});
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 000000000..cd9fdac5d
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,10604 @@
+{
+ "name": "@keboola/mcp-server",
+ "version": "2.0.0-alpha.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@keboola/mcp-server",
+ "version": "2.0.0-alpha.1",
+ "license": "MIT",
+ "dependencies": {
+ "@hono/node-server": "^1.19.14",
+ "@keboola/api-client": "^5.0.0",
+ "@modelcontextprotocol/sdk": "^1.29.0",
+ "@toon-format/toon": "^2.3.0",
+ "ajv": "^8.20.0",
+ "cheerio": "*",
+ "dd-trace": "^5.69.0",
+ "hono": "^4.12.27",
+ "jose": "^6.2.3",
+ "pg": "^8.22.0",
+ "pino": "^9.14.0",
+ "zod": "^4.3.6"
+ },
+ "bin": {
+ "keboola-mcp-server": "dist/index.js"
+ },
+ "devDependencies": {
+ "@keboola/oxlint-config": "^0.1.2",
+ "@keboola/tsconfig": "^0.1.1",
+ "@types/node": "^24.0.0",
+ "@types/pg": "^8.20.0",
+ "ioredis": "^5.4.1",
+ "msw": "^2.14.6",
+ "oxfmt": "^0.44.0",
+ "oxlint": "^1.56.0",
+ "tsup": "^8.5.1",
+ "tsx": "^4.19.4",
+ "typescript": "^5.9.0",
+ "vitest": "^4.1.8"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "optionalDependencies": {
+ "@huggingface/transformers": "^4.2.0",
+ "cheerio": "^1.2.0"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@datadog/flagging-core": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@datadog/flagging-core/-/flagging-core-1.2.1.tgz",
+ "integrity": "sha512-qeDkki9fFlqyoZBrn7tneT6pZ04EKKvf3xxisYw1a74zbJihvQui/ARUsjXCurRpzpFqGGTJw/oz+HnXaKhcdw==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "spark-md5": "^3.0.2"
+ }
+ },
+ "node_modules/@datadog/libdatadog": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/@datadog/libdatadog/-/libdatadog-0.9.4.tgz",
+ "integrity": "sha512-55wHHAuhHOOrBGoWV+fRuEDypN6PMJZq4LTOwExnhoDMvVcZKtqYT05xea/toRs0o75+A1IeNAQHsRLbJMf5oA==",
+ "license": "Apache-2.0",
+ "optional": true
+ },
+ "node_modules/@datadog/native-appsec": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/@datadog/native-appsec/-/native-appsec-11.0.1.tgz",
+ "integrity": "sha512-Y/XfknUmmJcw4hhQVhqzgdQvfjy+EGmXuUBgtVkI1r+/qS00egYu+wD/x7pOvjdbZNqN96znVszAnXvDQAzMDQ==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "node-gyp-build": "^3.9.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@datadog/native-iast-taint-tracking": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@datadog/native-iast-taint-tracking/-/native-iast-taint-tracking-4.2.0.tgz",
+ "integrity": "sha512-NpZABJQoNMzF6cU521RT4GQ8/FbfFRoDepOLTcLYKyw0DY2WmSpg3iG+PoQNK4O3jPSXC++K3rg59GiQgA3Mog==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "node-gyp-build": "^3.9.0"
+ }
+ },
+ "node_modules/@datadog/native-metrics": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@datadog/native-metrics/-/native-metrics-3.1.2.tgz",
+ "integrity": "sha512-7AEWt0ZLr/ogR/9if1DmFBDTg3y67xM+gdhXUXKs+UQMxK0lnjrOHgN7fkpEmUG1uL+EkX2BDE3ENDlQ23J7OQ==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "node-addon-api": "^6.1.0",
+ "node-gyp-build": "^3.9.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@datadog/openfeature-node-server": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@datadog/openfeature-node-server/-/openfeature-node-server-2.0.0.tgz",
+ "integrity": "sha512-Ummu/Bd7ZJpCCNdFnZUt/JI+L1+8OMK53+MyIXbS7dCt4JXWWwelbpzSGqmC2jWbWQ/mTo4hEfnFw3kYOINbXA==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@datadog/flagging-core": "1.2.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@openfeature/server-sdk": ">=1.15.1"
+ }
+ },
+ "node_modules/@datadog/pprof": {
+ "version": "5.15.1",
+ "resolved": "https://registry.npmjs.org/@datadog/pprof/-/pprof-5.15.1.tgz",
+ "integrity": "sha512-4mI750tX6okNROS4YKvGQjyAQ+VqfzDqzysCytOJhL2E2ktK/q4M0PtC7j70tiirGb/fO9fjma3IMNSRLYX+xQ==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "node-gyp-build": "^4.8.4",
+ "pprof-format": "^2.2.1",
+ "source-map": "^0.7.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@datadog/pprof/node_modules/node-gyp-build": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+ "license": "MIT",
+ "optional": true,
+ "bin": {
+ "node-gyp-build": "bin.js",
+ "node-gyp-build-optional": "optional.js",
+ "node-gyp-build-test": "build-test.js"
+ }
+ },
+ "node_modules/@datadog/wasm-js-rewriter": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@datadog/wasm-js-rewriter/-/wasm-js-rewriter-5.0.1.tgz",
+ "integrity": "sha512-EzbV3Lrdt3udQEsbDOVC5gB1y7yxfpBbrSIk4jaEsGjyj0Dbv2HGj7tZjs+qXzIzNonHc8h5El2bYZOGfC2wwg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "js-yaml": "^4.1.0",
+ "lru-cache": "^7.14.0",
+ "module-details-from-path": "^1.0.3",
+ "node-gyp-build": "^4.5.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@datadog/wasm-js-rewriter/node_modules/node-gyp-build": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+ "license": "MIT",
+ "optional": true,
+ "bin": {
+ "node-gyp-build": "bin.js",
+ "node-gyp-build-optional": "optional.js",
+ "node-gyp-build-test": "build-test.js"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint-react/ast": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/@eslint-react/ast/-/ast-4.2.3.tgz",
+ "integrity": "sha512-/XHJPFX8lsp+c/gMzFOnIxqH7YIXVX8SlMHuZ6XTUlYHkGquhydTtgso0VFiLQN1z3dThrybdgBq+JD+LSwK2w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@typescript-eslint/types": "^8.58.0",
+ "@typescript-eslint/typescript-estree": "^8.58.0",
+ "@typescript-eslint/utils": "^8.58.0",
+ "string-ts": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0",
+ "typescript": "*"
+ }
+ },
+ "node_modules/@eslint-react/core": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/@eslint-react/core/-/core-4.2.3.tgz",
+ "integrity": "sha512-r0cgJlCemBb61f0qCrXS95hNq2ajIku5V7Tk45fROQu4HIV55ILJeN2ceea1LKmgRWy/pQw8+SvImronwWo16A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@eslint-react/ast": "4.2.3",
+ "@eslint-react/jsx": "4.2.3",
+ "@eslint-react/shared": "4.2.3",
+ "@eslint-react/var": "4.2.3",
+ "@typescript-eslint/scope-manager": "^8.58.0",
+ "@typescript-eslint/types": "^8.58.0",
+ "@typescript-eslint/utils": "^8.58.0",
+ "ts-pattern": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0",
+ "typescript": "*"
+ }
+ },
+ "node_modules/@eslint-react/jsx": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/@eslint-react/jsx/-/jsx-4.2.3.tgz",
+ "integrity": "sha512-lSwRo/PAwf1EvXRxpXA5yBhPIxahFuC4uHh84nc5OxE0mJ7YEmzmASR+ug3QOnVnfDsJDVo6AWVR7PSL99YkOQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@eslint-react/ast": "4.2.3",
+ "@eslint-react/shared": "4.2.3",
+ "@eslint-react/var": "4.2.3",
+ "@typescript-eslint/types": "^8.58.0",
+ "@typescript-eslint/utils": "^8.58.0",
+ "ts-pattern": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0",
+ "typescript": "*"
+ }
+ },
+ "node_modules/@eslint-react/shared": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/@eslint-react/shared/-/shared-4.2.3.tgz",
+ "integrity": "sha512-6HermdKaTWkID0coAK46ynA9XIwUWGgA2Y+NK6qcmL/qbYzyRYs4hq+SmLMvZZ8DV/SFOaHRXl9iCTvjf6DvXQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@typescript-eslint/utils": "^8.58.0",
+ "ts-pattern": "^5.9.0",
+ "zod": "^4.3.6"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0",
+ "typescript": "*"
+ }
+ },
+ "node_modules/@eslint-react/var": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/@eslint-react/var/-/var-4.2.3.tgz",
+ "integrity": "sha512-zkQki2eYbQrMW4O6DCZDQzslFvw0sWAlvW/WWjocEIGHqRGC3IHWcRt3xsq8JPNOW4WjF4/LZ8czkyLoINV9rw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@eslint-react/ast": "4.2.3",
+ "@eslint-react/shared": "4.2.3",
+ "@typescript-eslint/scope-manager": "^8.58.0",
+ "@typescript-eslint/types": "^8.58.0",
+ "@typescript-eslint/utils": "^8.58.0",
+ "ts-pattern": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0",
+ "typescript": "*"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.23.5",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
+ "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@eslint/object-schema": "^3.0.5",
+ "debug": "^4.3.1",
+ "minimatch": "^10.2.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
+ "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@eslint/core": "^1.2.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
+ "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
+ "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz",
+ "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@eslint/core": "^1.2.1",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@hono/node-server": {
+ "version": "1.19.14",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
+ "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.14.1"
+ },
+ "peerDependencies": {
+ "hono": "^4"
+ }
+ },
+ "node_modules/@huggingface/jinja": {
+ "version": "0.5.9",
+ "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz",
+ "integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@huggingface/tokenizers": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz",
+ "integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==",
+ "license": "Apache-2.0",
+ "optional": true
+ },
+ "node_modules/@huggingface/transformers": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz",
+ "integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@huggingface/jinja": "^0.5.6",
+ "@huggingface/tokenizers": "^0.1.3",
+ "onnxruntime-node": "1.24.3",
+ "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c",
+ "sharp": "^0.34.5"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@inquirer/ansi": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz",
+ "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ }
+ },
+ "node_modules/@inquirer/confirm": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz",
+ "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^11.2.1",
+ "@inquirer/type": "^4.0.7"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/core": {
+ "version": "11.2.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz",
+ "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^2.0.7",
+ "@inquirer/figures": "^2.0.7",
+ "@inquirer/type": "^4.0.7",
+ "cli-width": "^4.1.0",
+ "fast-wrap-ansi": "^0.2.0",
+ "mute-stream": "^3.0.0",
+ "signal-exit": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/figures": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz",
+ "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ }
+ },
+ "node_modules/@inquirer/type": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz",
+ "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@ioredis/commands": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz",
+ "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@keboola/api-client": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@keboola/api-client/-/api-client-5.0.0.tgz",
+ "integrity": "sha512-e1Qt6cusBltMQD6cVPG5J7dtu8bbLXftT5/x8wouD8oOWzr05YvMmr9SoxyS4mi2HBYnDoZAJ3qZomZ15n32sw==",
+ "license": "MIT",
+ "dependencies": {
+ "dayjs": "^1.11.19",
+ "qs": "^6.15.2",
+ "zod": "^4.3.6"
+ },
+ "engines": {
+ "node": "24.x"
+ }
+ },
+ "node_modules/@keboola/oxlint-config": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/@keboola/oxlint-config/-/oxlint-config-0.1.2.tgz",
+ "integrity": "sha512-TjCOk3u18ct7xrDhA4JQ5rqk+96Kqjd4DgVXESCu7/sMNjErCoRl3dGKRKVg1yBqm666XxC4gwozRg1wTH1+nA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@keboola/tailwind-config": "^0.1.1",
+ "eslint": "^10.2.0",
+ "eslint-plugin-react-dom": "^4.2.3",
+ "eslint-plugin-react-jsx": "^4.2.3",
+ "eslint-plugin-react-naming-convention": "^4.2.3",
+ "eslint-plugin-react-web-api": "^4.2.3",
+ "eslint-plugin-react-x": "^4.2.3",
+ "eslint-plugin-simple-import-sort": "^13.0.0",
+ "eslint-plugin-tailwindcss": "^3.18.2",
+ "eslint-plugin-turbo": "^2.9.5",
+ "oxlint": "^1.56.0"
+ }
+ },
+ "node_modules/@keboola/tailwind-config": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@keboola/tailwind-config/-/tailwind-config-0.1.1.tgz",
+ "integrity": "sha512-PdHfIWd8Mkm2ZydMGriUtj3t/2K8S5cwBdwPwGxNA1zQPzggAKqLNMJ/L3c2IuS8117BUGKloPL7N0a1bMP/aQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@tailwindcss/container-queries": "^0.1.1",
+ "@tailwindcss/typography": "^0.5.19",
+ "tailwindcss-animate": "1.0.7",
+ "tailwindcss-scoped-preflight": "3.5.2"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "tailwindcss": "^3.4.0"
+ }
+ },
+ "node_modules/@keboola/tsconfig": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@keboola/tsconfig/-/tsconfig-0.1.1.tgz",
+ "integrity": "sha512-29aoQJclUv7wEbc+4hJNMK83896G8ObXwJcSnO4bzk5ZOxxC88fRhELkBsb9no3rp5zQsV0iLg2uiG1h51Rlug==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": "^5 || ^6"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk": {
+ "version": "1.29.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
+ "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@hono/node-server": "^1.19.9",
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
+ "content-type": "^1.0.5",
+ "cors": "^2.8.5",
+ "cross-spawn": "^7.0.5",
+ "eventsource": "^3.0.2",
+ "eventsource-parser": "^3.0.0",
+ "express": "^5.2.1",
+ "express-rate-limit": "^8.2.1",
+ "hono": "^4.11.4",
+ "jose": "^6.1.3",
+ "json-schema-typed": "^8.0.2",
+ "pkce-challenge": "^5.0.0",
+ "raw-body": "^3.0.0",
+ "zod": "^3.25 || ^4.0",
+ "zod-to-json-schema": "^3.25.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@cfworker/json-schema": "^4.1.1",
+ "zod": "^3.25 || ^4.0"
+ },
+ "peerDependenciesMeta": {
+ "@cfworker/json-schema": {
+ "optional": true
+ },
+ "zod": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/@mswjs/interceptors": {
+ "version": "0.41.9",
+ "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz",
+ "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@open-draft/deferred-promise": "^2.2.0",
+ "@open-draft/logger": "^0.3.0",
+ "@open-draft/until": "^2.0.0",
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.3",
+ "strict-event-emitter": "^0.5.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz",
+ "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@open-draft/deferred-promise": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz",
+ "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@open-draft/logger": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz",
+ "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.0"
+ }
+ },
+ "node_modules/@open-draft/until": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz",
+ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@openfeature/core": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/@openfeature/core/-/core-1.11.0.tgz",
+ "integrity": "sha512-P0u3/ht/oZCQT89fOed+laLk0kZR529a825cS02uPDglxXbE97irWYpDAeRGGVETIzKfuy+H2g8c3Ccv/tXJNQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "peer": true
+ },
+ "node_modules/@openfeature/server-sdk": {
+ "version": "1.22.0",
+ "resolved": "https://registry.npmjs.org/@openfeature/server-sdk/-/server-sdk-1.22.0.tgz",
+ "integrity": "sha512-YBrf6SQkn0FNB/dRAtLEs41dvFMUE8CrQTwI+iLaMFUIqWlqGNJfGnulKSneEKS+2OgKTAC6DdmKcZ6tK7kBcg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@openfeature/core": "^1.11.0"
+ }
+ },
+ "node_modules/@opentelemetry/api": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
+ "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/@opentelemetry/api-logs": {
+ "version": "0.219.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz",
+ "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-android-arm-eabi": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.132.0.tgz",
+ "integrity": "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-android-arm64": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.132.0.tgz",
+ "integrity": "sha512-SThDrSeamB/kG2+NxcJ5/wSLcV6dUqDknrPLqFYQ0ST/55mtBP4M7Q/f3QbubH6aAd11wpzZn/nwbVRSdobOpg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-darwin-arm64": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.132.0.tgz",
+ "integrity": "sha512-Lc0f/TYoKBghE5/2Gsv7bLXk+TJZunx2Tf61X8hG4ARXdc8UYI26dCGccFSd1AyFbK3jfaNXtMnupggDbjPXdQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-darwin-x64": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.132.0.tgz",
+ "integrity": "sha512-RG2eJIpf7C21z9HSSXFw1bTArdpKe7Y4fwcJTwRq1yCSe1vSavaN9GA1sm9KqzemTLAGVktQ+7qBTGp0vQeUZg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-freebsd-x64": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.132.0.tgz",
+ "integrity": "sha512-wQIPntPLtJ8NcBpvKPbEv3NqzV6k8eP8tP/jE9Rg8HTg/j7urZGFSsTCPCW5k77Qfw2DM4vRvc9p3I4yq/Shvw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.132.0.tgz",
+ "integrity": "sha512-PixKEpeSe3yxQWqNyOCBALRYc72+Tj7ILDofUl3iXo25cVOzLA6jHUhmOINRtWIPh7dbUie3QNeabwaQpZTw6w==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-arm-musleabihf": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.132.0.tgz",
+ "integrity": "sha512-sCR+DzGHlyHKnbA2z9zWjTUhIo8Sy0enJl4RDsBwPmkxYynPatpwOAWe8W5127SlW0boqUWHGtr1NWn5UwIhXQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-arm64-gnu": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.132.0.tgz",
+ "integrity": "sha512-sQBix5P2cW+IpzTcCwYxnh9yALrKSIkKJThspBvMGcygSMnbzkSvhN7SfuX1hvBk8y1XEChsdkU3ET0V5DmzUw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-arm64-musl": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.132.0.tgz",
+ "integrity": "sha512-WozHg3Kc//8Sk756HXXgMbEAvqtG+Lzb9JOojwQzIGDtN78Az2dLttkb71akWYUF/8IgYfDSlfKh4Uot8is5Vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-ppc64-gnu": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.132.0.tgz",
+ "integrity": "sha512-CmX/ulNBOEwWTyVRmcpYKAcAizW6+OjtLJgo7fXoL9OqQvjF4VER8tPomv44vwzfSCy1BHbsB0ZlZYzYJNj4cA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-riscv64-gnu": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.132.0.tgz",
+ "integrity": "sha512-j9oQS+hM90SdhviNGWbPgT4+Rlq+ac++q/zjgwPD1mVHgxHzATvoRGtDx0sXGmFOQ9J9YkwAhYGb5MAHL6TAsA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-riscv64-musl": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.132.0.tgz",
+ "integrity": "sha512-bLz+Xi+Agnfmd7kWPEsSVwCn2k4EyIalZkNBcQ0OGIv9rqn8VgCPLNd03tM9mKX/5TdlvDXalz0q71BIrOPNqg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-s390x-gnu": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.132.0.tgz",
+ "integrity": "sha512-U6t2qbJU0ypTfyj9QV3W1Y6mITDTL8ai/OR6NUn85vyHthOvobKWgXzU4tu0EskSzlpuVFz1g0jFGulDIUKHxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-x64-gnu": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.132.0.tgz",
+ "integrity": "sha512-WcEaSNHFk8yz5YFlQQAlhq6jOFmZBB/RKE7uzhyCIf+pF1Lmv9gUH4221mle2Gd9iHyWT3ySNph8yZgb1xYdWg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-x64-musl": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.132.0.tgz",
+ "integrity": "sha512-iQrV4iJzQgRwK3BWRmQl1C3C6g3wYpXN2WLdQdyR+efoUnncdShZAVp9OgcojtlD3MDRbuOMGG3SjxF4fL4nlQ==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-openharmony-arm64": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.132.0.tgz",
+ "integrity": "sha512-FWzmUGrZ6GUby4U7WIwcCtab6tdmlTO3xTRRKyb5kjIJVEiaUAT8animUG/nK8ZCA8gkRkPOTId4rl6uTqUmJQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-wasm32-wasi": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.132.0.tgz",
+ "integrity": "sha512-TlbMppxJI5CjWDes0QaP6G3aneVg1yikBu5QYI+DUShF9WDL66ccgKFNNGmi/Wybtszw6hxwAvv76T4DaPKnHw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.10.0",
+ "@emnapi/runtime": "1.10.0",
+ "@napi-rs/wasm-runtime": "^1.1.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-win32-arm64-msvc": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.132.0.tgz",
+ "integrity": "sha512-RH/NbFjGKqdUAUi7Oh3LQPxUk2hsWFEEQ38HSnbRQT8QjBZFKqL1fMbmsB3N4jy/KPh9iX94+9dmkEMBBbambw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-win32-ia32-msvc": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.132.0.tgz",
+ "integrity": "sha512-JUr4jQY9jxoIB/YTLXr6XofSi5xikj6p5/Ns1h0VOBDT0j1jKU+kMsv2xxv51RwnETcXpA1Yw/9oUAfcqfaqEA==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-win32-x64-msvc": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.132.0.tgz",
+ "integrity": "sha512-2dapgHpA5X8DSXF4AU36hJWYf6zP0tKjMXFRAZFBD62pkevW/uhFDXoFH9Y/3Fd2EtDrw5ByNnR1wVE9X9y0SQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
+ "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@oxfmt/binding-android-arm-eabi": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.44.0.tgz",
+ "integrity": "sha512-5UvghMd9SA/yvKTWCAxMAPXS1d2i054UeOf4iFjZjfayTwCINcC3oaSXjtbZfCaEpxgJod7XiOjTtby5yEv/BQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-android-arm64": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.44.0.tgz",
+ "integrity": "sha512-IVudM1BWfvrYO++Khtzr8q9n5Rxu7msUvoFMqzGJVdX7HfUXUDHwaH2zHZNB58svx2J56pmCUzophyaPFkcG/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-darwin-arm64": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.44.0.tgz",
+ "integrity": "sha512-eWCLAIKAHfx88EqEP1Ga2yz7qVcqDU5lemn4xck+07bH182hDdprOHjbogyk0In1Djys3T0/pO2JepFnRJ41Mg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-darwin-x64": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.44.0.tgz",
+ "integrity": "sha512-eHTBznHLM49++dwz07MblQ2cOXyIgeedmE3Wgy4ptUESj38/qYZyRi1MPwC9olQJWssMeY6WI3UZ7YmU5ggvyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-freebsd-x64": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.44.0.tgz",
+ "integrity": "sha512-jLMmbj0u0Ft43QpkUVr/0v1ZfQCGWAvU+WznEHcN3wZC/q6ox7XeSJtk9P36CCpiDSUf3sGnzbIuG1KdEMEDJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-arm-gnueabihf": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.44.0.tgz",
+ "integrity": "sha512-n+A/u/ByK1qV8FVGOwyaSpw5NPNl0qlZfgTBqHeGIqr8Qzq1tyWZ4lAaxPoe5mZqE3w88vn3+jZtMxriHPE7tg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-arm-musleabihf": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.44.0.tgz",
+ "integrity": "sha512-5eax+FkxyCqAi3Rw0mrZFr7+KTt/XweFsbALR+B5ljWBLBl8nHe4ADrUnb1gLEfQCJLl+Ca5FIVD4xEt95AwIw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-arm64-gnu": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.44.0.tgz",
+ "integrity": "sha512-58l8JaHxSGOmOMOG2CIrNsnkRJAj0YcHQCmvNACniOa/vd1iRHhlPajczegzS5jwMENlqgreyiTR9iNlke8qCw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-arm64-musl": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.44.0.tgz",
+ "integrity": "sha512-AlObQIXyVRZ96LbtVljtFq0JqH5B92NU+BQeDFrXWBUWlCKAM0wF5GLfIhCLT5kQ3Sl+U0YjRJ7Alqj5hGQaCg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-ppc64-gnu": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.44.0.tgz",
+ "integrity": "sha512-YcFE8/q/BbrCiIiM5piwbkA6GwJc5QqhMQp2yDrqQ2fuVkZ7CInb1aIijZ/k8EXc72qXMSwKpVlBv1w/MsGO/A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-riscv64-gnu": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.44.0.tgz",
+ "integrity": "sha512-eOdzs6RqkRzuqNHUX5C8ISN5xfGh4xDww8OEd9YAmc3OWN8oAe5bmlIqQ+rrHLpv58/0BuU48bxkhnIGjA/ATQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-riscv64-musl": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.44.0.tgz",
+ "integrity": "sha512-YBgNTxntD/QvlFUfgvh8bEdwOhXiquX8gaofZJAwYa/Xp1S1DQrFVZEeck7GFktr24DztsSp8N8WtWCBwxs0Hw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-s390x-gnu": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.44.0.tgz",
+ "integrity": "sha512-GLIh1R6WHWshl/i4QQDNgj0WtT25aRO4HNUWEoitxiywyRdhTFmFEYT2rXlcl9U6/26vhmOqG5cRlMLG3ocaIA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-x64-gnu": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.44.0.tgz",
+ "integrity": "sha512-gZOpgTlOsLcLfAF9qgpTr7FIIFSKnQN3hDf/0JvQ4CIwMY7h+eilNjxq/CorqvYcEOu+LRt1W4ZS7KccEHLOdA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-linux-x64-musl": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.44.0.tgz",
+ "integrity": "sha512-1CyS9JTB+pCUFYFI6pkQGGZaT/AY5gnhHVrQQLhFba6idP9AzVYm1xbdWfywoldTYvjxQJV6x4SuduCIfP3W+A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-openharmony-arm64": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.44.0.tgz",
+ "integrity": "sha512-bmEv70Ak6jLr1xotCbF5TxIKjsmQaiX+jFRtnGtfA03tJPf6VG3cKh96S21boAt3JZc+Vjx8PYcDuLj39vM2Pw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-win32-arm64-msvc": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.44.0.tgz",
+ "integrity": "sha512-yWzB+oCpSnP/dmw85eFLAT5o35Ve5pkGS2uF/UCISpIwDqf1xa7OpmtomiqY/Vzg8VyvMbuf6vroF2khF/+1Vg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-win32-ia32-msvc": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.44.0.tgz",
+ "integrity": "sha512-TcWpo18xEIE3AmIG2kpr3kz5IEhQgnx0lazl2+8L+3eTopOAUevQcmlr4nhguImNWz0OMeOZrYZOhJNCf16nlQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxfmt/binding-win32-x64-msvc": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.44.0.tgz",
+ "integrity": "sha512-oj8aLkPJZppIM4CMQNsyir9ybM1Xw/CfGPTSsTnzpVGyljgfbdP0EVUlURiGM0BDrmw5psQ6ArmGCcUY/yABaQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm-eabi": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.71.0.tgz",
+ "integrity": "sha512-ImGmd1njEg4FEJH03jhRnveEegtO3czCtfptvaHivKAZQIYATbVFBrrzbaYMYv0oJioTnxZAZVSyV+oL7W8S2g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm64": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.71.0.tgz",
+ "integrity": "sha512-4A5BEexBrwY1YFF8Kiq/lp/wQPRG79G3BWIE1FuWaM5MvmpYSd+7ZySVcKkHdwo0UDzdQGddp6pD9mpctMqLnw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-arm64": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.71.0.tgz",
+ "integrity": "sha512-9wJA9GJulLwS2usU3CEisI/ESDO1n1z9eyTCvApMDrAkbJ1ve0mORgTMjcWWsKxkzkeZ2N/Gpra5IQE7x8tYgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-x64": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.71.0.tgz",
+ "integrity": "sha512-PlLCjS06V0PeJMAJwzjrExw1sYNW9Gch3JtNlcwwZDXGlTYDuwHNN89zYH8LTXFfgkVtsYvs2nv0FqrzyuFDzg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-freebsd-x64": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.71.0.tgz",
+ "integrity": "sha512-Lhil7bWre0ncxbUoDoxfS0JzpTz17BRQKW7iwoAUY8GJ66+WwJEfYPCFJ1P0WgVZR5/O/b3Q2pENlHOjeXLOGQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.71.0.tgz",
+ "integrity": "sha512-Oo9/L58PYD3RC0x05d2upAPLllHytTjHQGsnC06P6Ynn7jKkp5mdImQxXdJ3+FnBaKspNpGogzgVsi6g872LiA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-musleabihf": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.71.0.tgz",
+ "integrity": "sha512-mSHfyfgJrEbyIR29ejaeS50BdPk+GoNPlC1dckpDiUZbJAIel68sjSMdOt4WY0/gva+ECC7FNITQkxMJU+vSBw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-gnu": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.71.0.tgz",
+ "integrity": "sha512-n9yY4M2tiy3aij4AqtlnspzpfdpeT5JQfK2/w2d8oyp5W0FRwOb1dIeX99nORNcxGr08iD9bH8N5XFz3I2iy8w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-musl": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.71.0.tgz",
+ "integrity": "sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-ppc64-gnu": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.71.0.tgz",
+ "integrity": "sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-gnu": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.71.0.tgz",
+ "integrity": "sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-musl": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.71.0.tgz",
+ "integrity": "sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-s390x-gnu": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.71.0.tgz",
+ "integrity": "sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-gnu": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.71.0.tgz",
+ "integrity": "sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-musl": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.71.0.tgz",
+ "integrity": "sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-openharmony-arm64": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.71.0.tgz",
+ "integrity": "sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-arm64-msvc": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.71.0.tgz",
+ "integrity": "sha512-bd5kI8spYwTm3BILDtGhi73zoup5dw8MlPQNT8YB3BD5UIsjNe3K9/4ctrzQMX4SZMoK5HgzVLkLJzacEXB7fA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-ia32-msvc": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.71.0.tgz",
+ "integrity": "sha512-W4HvOHGzVLHcrmFu+bMrJlho+/yrlX5ZNdJZqGe8MEldkQG+RHYhxxad9P4jvWAYFmIqUA5i9DQ8QsJqSU9GIw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-x64-msvc": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.71.0.tgz",
+ "integrity": "sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@pinojs/redact": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
+ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
+ "license": "MIT"
+ },
+ "node_modules/@pkgr/core": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz",
+ "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/pkgr"
+ }
+ },
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause",
+ "optional": true
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause",
+ "optional": true
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
+ "license": "BSD-3-Clause",
+ "optional": true
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
+ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
+ "license": "BSD-3-Clause",
+ "optional": true
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
+ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause",
+ "optional": true
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause",
+ "optional": true
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause",
+ "optional": true
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
+ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
+ "license": "BSD-3-Clause",
+ "optional": true
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz",
+ "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz",
+ "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz",
+ "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz",
+ "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz",
+ "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz",
+ "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz",
+ "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz",
+ "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz",
+ "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz",
+ "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz",
+ "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz",
+ "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz",
+ "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.1",
+ "@emnapi/runtime": "1.11.1",
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
+ "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz",
+ "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/container-queries": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz",
+ "integrity": "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "peerDependencies": {
+ "tailwindcss": ">=3.2.0"
+ }
+ },
+ "node_modules/@tailwindcss/typography": {
+ "version": "0.5.20",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz",
+ "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "postcss-selector-parser": "6.0.10"
+ },
+ "peerDependencies": {
+ "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders"
+ }
+ },
+ "node_modules/@toon-format/toon": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-2.3.0.tgz",
+ "integrity": "sha512-/Ew9etdRQKVMnm9fDaCG0JjyAOK/O7T0M97oum1aW4W+UR8ZhVVPBanIV7oWgHBiGlnVxV9M55PWQCHofDV07w==",
+ "license": "MIT"
+ },
+ "node_modules/@turbo/darwin-64": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.10.0.tgz",
+ "integrity": "sha512-EwvHThXzpY0KGd1/NAmuewI5D+aVa3Rl/OlxE36yfjUKb/+ySrfJrSlEFt8aD1OXwnnaHnQnPKHFndor0Zxlsg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true
+ },
+ "node_modules/@turbo/darwin-arm64": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/@turbo/darwin-arm64/-/darwin-arm64-2.10.0.tgz",
+ "integrity": "sha512-9d2fTyyG0lf5Wq1bwJA9qUaeecViMkLcdctWaMMmCkxZ/JqypmqOwK3W6vmejeKVgkr06gSoiX8bD+xN5Jpxcg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true
+ },
+ "node_modules/@turbo/linux-64": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/@turbo/linux-64/-/linux-64-2.10.0.tgz",
+ "integrity": "sha512-sZBtjMuufitanjzi6UssoUpJMnnPlLMcdcJj3m3ptNsSq31Xh7MnjhwA5nWvLDTfEFg8GPcbYFXMo8vSdKRfqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true
+ },
+ "node_modules/@turbo/linux-arm64": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/@turbo/linux-arm64/-/linux-arm64-2.10.0.tgz",
+ "integrity": "sha512-vkq/Z8R+1DQ+kifWFa810IjRy2NNBVvha3cg9sWA3nFh6nnGrHSMnnJKrzH7c/No9kq4Jb55Ru44YKsCSBgrKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true
+ },
+ "node_modules/@turbo/windows-64": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/@turbo/windows-64/-/windows-64-2.10.0.tgz",
+ "integrity": "sha512-CRUEguLWxFQHptYZS7HjPhNhAFawfea07iR+xAQ5e4klgLrPCMdexBkXwSCwOxqTFknJ7RZFN3gOaADsw+Gttg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true
+ },
+ "node_modules/@turbo/windows-arm64": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/@turbo/windows-arm64/-/windows-arm64-2.10.0.tgz",
+ "integrity": "sha512-dVHGaf9F8twzgibcBqKoADT/LLqf9++jDb+hq/LPWWaOmRpp4M+/pVOm7vy4z9D++xg8eaxWLT0+wQxFwhYu9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/esrecurse": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
+ "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/node": {
+ "version": "24.13.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
+ "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/pg": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
+ "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "pg-protocol": "*",
+ "pg-types": "^2.2.0"
+ }
+ },
+ "node_modules/@types/set-cookie-parser": {
+ "version": "2.4.10",
+ "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz",
+ "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/statuses": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz",
+ "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.62.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz",
+ "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.62.0",
+ "@typescript-eslint/types": "^8.62.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.62.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz",
+ "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@typescript-eslint/types": "8.62.0",
+ "@typescript-eslint/visitor-keys": "8.62.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.62.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz",
+ "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.62.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz",
+ "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@typescript-eslint/types": "8.62.0",
+ "@typescript-eslint/typescript-estree": "8.62.0",
+ "@typescript-eslint/utils": "8.62.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.62.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz",
+ "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.62.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz",
+ "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.62.0",
+ "@typescript-eslint/tsconfig-utils": "8.62.0",
+ "@typescript-eslint/types": "8.62.0",
+ "@typescript-eslint/visitor-keys": "8.62.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.62.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz",
+ "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.62.0",
+ "@typescript-eslint/types": "8.62.0",
+ "@typescript-eslint/typescript-estree": "8.62.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.62.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz",
+ "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@typescript-eslint/types": "8.62.0",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
+ "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.9",
+ "@vitest/utils": "4.1.9",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
+ "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.9",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
+ "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
+ "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.9",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
+ "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.9",
+ "@vitest/utils": "4.1.9",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
+ "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
+ "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.9",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.17.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
+ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-import-attributes": {
+ "version": "1.9.5",
+ "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",
+ "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/adm-zip": {
+ "version": "0.5.18",
+ "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz",
+ "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=12.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "peer": true,
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0",
+ "optional": true
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/atomic-sleep": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
+ "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/birecord": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/birecord/-/birecord-0.1.1.tgz",
+ "integrity": "sha512-VUpsf/qykW0heRlC8LooCq28Kxn3mAqKohhDG/49rrsQ1dT1CXyj/pgXS+5BSRzFTR/3DyIBOqQOrGyZOh71Aw==",
+ "dev": true,
+ "license": "(MIT OR Apache-2.0)",
+ "peer": true
+ },
+ "node_modules/body-parser": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+ "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^2.0.0",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.1",
+ "iconv-lite": "^0.7.2",
+ "on-finished": "^2.4.1",
+ "qs": "^6.15.2",
+ "raw-body": "^3.0.2",
+ "type-is": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/boolean": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
+ "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/bundle-require": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz",
+ "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "load-tsconfig": "^0.2.3"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "esbuild": ">=0.18"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cheerio": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
+ "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "cheerio-select": "^2.1.0",
+ "dom-serializer": "^2.0.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "encoding-sniffer": "^0.2.1",
+ "htmlparser2": "^10.1.0",
+ "parse5": "^7.3.0",
+ "parse5-htmlparser2-tree-adapter": "^7.1.0",
+ "parse5-parser-stream": "^7.1.2",
+ "undici": "^7.19.0",
+ "whatwg-mimetype": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=20.18.1"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
+ }
+ },
+ "node_modules/cheerio-select": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
+ "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-select": "^5.1.0",
+ "css-what": "^6.1.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "peer": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/cjs-module-lexer": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
+ "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==",
+ "license": "MIT"
+ },
+ "node_modules/cli-width": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
+ "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
+ "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/compare-versions": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",
+ "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/confbox": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
+ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/consola": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
+ "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.18.0 || >=16.10.0"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cors": {
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/css-select": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
+ "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.1.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "nth-check": "^2.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
+ "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/dayjs": {
+ "version": "1.11.21",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
+ "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
+ "license": "MIT"
+ },
+ "node_modules/dc-polyfill": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/dc-polyfill/-/dc-polyfill-0.1.11.tgz",
+ "integrity": "sha512-TyyeGcjx0YeThAI9fTFtgsvj5qd4R+aGfVmXiUhevbgzWFDr7IK4tv4YjE6jaGzLHQTchk4h7DHdr5q4WGgaZw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.17"
+ }
+ },
+ "node_modules/dd-trace": {
+ "version": "5.110.0",
+ "resolved": "https://registry.npmjs.org/dd-trace/-/dd-trace-5.110.0.tgz",
+ "integrity": "sha512-/PeVFa9lSbaJ2b1M233nX0LF+5RMRwdjv22uotNbdtWz7lQk1om8U8ngm07Cf/hrzVMMPvA3Cufy3iZgZOTNkA==",
+ "license": "(Apache-2.0 OR BSD-3-Clause)",
+ "dependencies": {
+ "dc-polyfill": "^0.1.11",
+ "import-in-the-middle": "^3.1.0",
+ "opentracing": ">=0.14.7"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@datadog/libdatadog": "0.9.4",
+ "@datadog/native-appsec": "11.0.1",
+ "@datadog/native-iast-taint-tracking": "4.2.0",
+ "@datadog/native-metrics": "3.1.2",
+ "@datadog/openfeature-node-server": "2.0.0",
+ "@datadog/pprof": "5.15.1",
+ "@datadog/wasm-js-rewriter": "5.0.1",
+ "@opentelemetry/api": ">=1.0.0 <1.10.0",
+ "@opentelemetry/api-logs": "<1.0.0",
+ "oxc-parser": "^0.132.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/detect-node": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
+ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause",
+ "optional": true
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.0.3",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz",
+ "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/encoding-sniffer": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
+ "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "iconv-lite": "^0.6.3",
+ "whatwg-encoding": "^3.1.1"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
+ }
+ },
+ "node_modules/encoding-sniffer/node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.24.1",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.1.tgz",
+ "integrity": "sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
+ "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es6-error": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
+ "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "devOptional": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz",
+ "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "workspaces": [
+ "packages/*"
+ ],
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@eslint/config-array": "^0.23.5",
+ "@eslint/config-helpers": "^0.6.0",
+ "@eslint/core": "^1.2.1",
+ "@eslint/plugin-kit": "^0.7.2",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^9.1.2",
+ "eslint-visitor-keys": "^5.0.1",
+ "espree": "^11.2.0",
+ "esquery": "^1.7.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "minimatch": "^10.2.4",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-react-dom": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-dom/-/eslint-plugin-react-dom-4.2.3.tgz",
+ "integrity": "sha512-7FCB+kx0iwWw2OOb0aDrXU4Eds5ihrq6UACNVMmtv5c4qd82n+wRGQwXBQKlTbwR9gpfn3HRDlaofZX93gShlA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@eslint-react/ast": "4.2.3",
+ "@eslint-react/core": "4.2.3",
+ "@eslint-react/jsx": "4.2.3",
+ "@eslint-react/shared": "4.2.3",
+ "@eslint-react/var": "4.2.3",
+ "@typescript-eslint/scope-manager": "^8.58.0",
+ "@typescript-eslint/types": "^8.58.0",
+ "@typescript-eslint/utils": "^8.58.0",
+ "compare-versions": "^6.1.1",
+ "ts-pattern": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0",
+ "typescript": "*"
+ }
+ },
+ "node_modules/eslint-plugin-react-jsx": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-jsx/-/eslint-plugin-react-jsx-4.2.3.tgz",
+ "integrity": "sha512-IUiYO1Qm/NDo/CVBa/nOP6lKJvPtDz7ucKsfcmrDYFS7NGyLJedubB4vFtMGZ2XGBFJeEYLnSo7Y+89b0qdynA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@eslint-react/ast": "4.2.3",
+ "@eslint-react/core": "4.2.3",
+ "@eslint-react/jsx": "4.2.3",
+ "@eslint-react/shared": "4.2.3",
+ "@eslint-react/var": "4.2.3",
+ "@typescript-eslint/scope-manager": "^8.58.0",
+ "@typescript-eslint/types": "^8.58.0",
+ "@typescript-eslint/utils": "^8.58.0",
+ "compare-versions": "^6.1.1",
+ "ts-pattern": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0",
+ "typescript": "*"
+ }
+ },
+ "node_modules/eslint-plugin-react-naming-convention": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-naming-convention/-/eslint-plugin-react-naming-convention-4.2.3.tgz",
+ "integrity": "sha512-H4eq0ajs+K+tgn6/eeglkLN3HBWm4QyWbJ2jbwPo75gyPmEP7Xvr0jslcnAwnmQfiiKX+KqKuiOMAqOr0SXubg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@eslint-react/ast": "4.2.3",
+ "@eslint-react/core": "4.2.3",
+ "@eslint-react/shared": "4.2.3",
+ "@eslint-react/var": "4.2.3",
+ "@typescript-eslint/scope-manager": "^8.58.0",
+ "@typescript-eslint/type-utils": "^8.58.0",
+ "@typescript-eslint/types": "^8.58.0",
+ "@typescript-eslint/utils": "^8.58.0",
+ "compare-versions": "^6.1.1",
+ "string-ts": "^2.3.1",
+ "ts-pattern": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0",
+ "typescript": "*"
+ }
+ },
+ "node_modules/eslint-plugin-react-web-api": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-web-api/-/eslint-plugin-react-web-api-4.2.3.tgz",
+ "integrity": "sha512-iHXFiURfokcTicZ9DZsQHCV9BuVRqve7GFYNBBD5AVFzEWseCV+lXc6y2EoXxsQW8WfYhAwTZ5Yhr+fKJR7t1w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@eslint-react/ast": "4.2.3",
+ "@eslint-react/core": "4.2.3",
+ "@eslint-react/shared": "4.2.3",
+ "@eslint-react/var": "4.2.3",
+ "@typescript-eslint/scope-manager": "^8.58.0",
+ "@typescript-eslint/types": "^8.58.0",
+ "@typescript-eslint/utils": "^8.58.0",
+ "birecord": "^0.1.1",
+ "ts-pattern": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0",
+ "typescript": "*"
+ }
+ },
+ "node_modules/eslint-plugin-react-x": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-x/-/eslint-plugin-react-x-4.2.3.tgz",
+ "integrity": "sha512-kJZXa5QsGA4FzuTyKLKjFt9nm78CZcfHshfgfSXjVOshvlVGeg1RWyNZnXDW3hASdZ/REsPg2mGFYqwUPXnJ5Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@eslint-react/ast": "4.2.3",
+ "@eslint-react/core": "4.2.3",
+ "@eslint-react/jsx": "4.2.3",
+ "@eslint-react/shared": "4.2.3",
+ "@eslint-react/var": "4.2.3",
+ "@typescript-eslint/scope-manager": "^8.58.0",
+ "@typescript-eslint/type-utils": "^8.58.0",
+ "@typescript-eslint/types": "^8.58.0",
+ "@typescript-eslint/utils": "^8.58.0",
+ "compare-versions": "^6.1.1",
+ "string-ts": "^2.3.1",
+ "ts-api-utils": "^2.5.0",
+ "ts-pattern": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0",
+ "typescript": "*"
+ }
+ },
+ "node_modules/eslint-plugin-simple-import-sort": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-13.0.0.tgz",
+ "integrity": "sha512-McAc+/Nlvcg4byY/CABGH8kqnefWBj8s3JA2okEtz8ixbECQgU46p0HkTUKa4YS7wvgGceimlc34p1nXqbWqtA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "peerDependencies": {
+ "eslint": ">=5.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-tailwindcss": {
+ "version": "3.18.3",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-tailwindcss/-/eslint-plugin-tailwindcss-3.18.3.tgz",
+ "integrity": "sha512-lqjNX7mt1Ip2qR236hvhbZ9ff2TFLUWou+tBHz82SA1nWFzOZSoEOI+9UBZmuf2977r2MMp9/y3/broyz8AYig==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "fast-glob": "^3.2.5",
+ "postcss": "^8.4.4",
+ "synckit": "^0.11.4",
+ "tailwind-api-utils": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ },
+ "peerDependencies": {
+ "tailwindcss": "^3.4.0 || ^4.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-turbo": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-turbo/-/eslint-plugin-turbo-2.10.0.tgz",
+ "integrity": "sha512-0HoDr8jdJ5oN9n2ip1DMDtFwmkXYc1ry/JsqGLA1fv6w3kdpvUinPm1FiJPcNnXLxO38N6It//YhW3tfk5j8TQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "dotenv": "16.0.3"
+ },
+ "peerDependencies": {
+ "eslint": ">6.6.0",
+ "turbo": ">2.0.0"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
+ "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "peer": true,
+ "dependencies": {
+ "@types/esrecurse": "^4.3.1",
+ "@types/estree": "^1.0.8",
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint/node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/eslint/node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/espree": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
+ "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "peer": true,
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^5.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "peer": true,
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "peer": true,
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "peer": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/eventsource": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+ "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+ "license": "MIT",
+ "dependencies": {
+ "eventsource-parser": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/eventsource-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
+ "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "8.5.2",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
+ "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^10.2.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/exsolve": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz",
+ "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "peer": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/fast-string-truncated-width": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
+ "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-string-width": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz",
+ "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-string-truncated-width": "^3.0.2"
+ }
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
+ "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/fast-wrap-ansi": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz",
+ "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-string-width": "^3.0.2"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "peer": true,
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/fix-dts-default-cjs-exports": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz",
+ "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "magic-string": "^0.30.17",
+ "mlly": "^1.7.4",
+ "rollup": "^4.34.8"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatbuffers": {
+ "version": "25.9.23",
+ "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
+ "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
+ "license": "Apache-2.0",
+ "optional": true
+ },
+ "node_modules/flatted": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
+ "dev": true,
+ "license": "ISC",
+ "peer": true
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "peer": true,
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/global-agent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
+ "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "boolean": "^3.0.1",
+ "es6-error": "^4.1.1",
+ "matcher": "^3.0.0",
+ "roarr": "^2.15.3",
+ "semver": "^7.3.2",
+ "serialize-error": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=10.0"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC",
+ "peer": true
+ },
+ "node_modules/graphql": {
+ "version": "16.14.2",
+ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz",
+ "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
+ }
+ },
+ "node_modules/guid-typescript": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
+ "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/headers-polyfill": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz",
+ "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/set-cookie-parser": "^2.4.10",
+ "set-cookie-parser": "^3.0.1"
+ }
+ },
+ "node_modules/hono": {
+ "version": "4.12.27",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz",
+ "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-in-the-middle": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.2.0.tgz",
+ "integrity": "sha512-vR2B6HKIhaBjcZr2bLpFiJ1VbzOlRQ7aby4/gw5WPIzToLjqpfWw3VJ4sk1uDchoOODEirvO2jyrSPtUSL5CrQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-import-attributes": "^1.9.5",
+ "cjs-module-lexer": "^2.2.0",
+ "module-details-from-path": "^1.0.4"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ioredis": {
+ "version": "5.11.1",
+ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz",
+ "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ioredis/commands": "1.10.0",
+ "cluster-key-slot": "1.1.1",
+ "debug": "4.4.3",
+ "denque": "2.1.0",
+ "redis-errors": "1.2.0",
+ "redis-parser": "3.0.0",
+ "standard-as-callback": "2.1.0"
+ },
+ "engines": {
+ "node": ">=12.22.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ioredis"
+ }
+ },
+ "node_modules/ip-address": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
+ "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.2",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
+ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "hasown": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-node-process": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
+ "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/jose": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
+ "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/joycon": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
+ "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
+ "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/json-schema-typed": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/json-stringify-safe": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/load-tsconfig": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz",
+ "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/local-pkg": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
+ "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "mlly": "^1.7.4",
+ "pkg-types": "^2.3.0",
+ "quansync": "^0.2.11"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0",
+ "optional": true
+ },
+ "node_modules/lru-cache": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+ "license": "ISC",
+ "optional": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/matcher": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
+ "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "peer": true,
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/mlly": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
+ "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "pathe": "^2.0.3",
+ "pkg-types": "^1.3.1",
+ "ufo": "^1.6.3"
+ }
+ },
+ "node_modules/mlly/node_modules/confbox": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
+ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mlly/node_modules/pkg-types": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
+ "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.1.8",
+ "mlly": "^1.7.4",
+ "pathe": "^2.0.1"
+ }
+ },
+ "node_modules/module-details-from-path": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
+ "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/msw": {
+ "version": "2.14.6",
+ "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz",
+ "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/confirm": "^6.0.11",
+ "@mswjs/interceptors": "^0.41.3",
+ "@open-draft/deferred-promise": "^3.0.0",
+ "@types/statuses": "^2.0.6",
+ "cookie": "^1.1.1",
+ "graphql": "^16.13.2",
+ "headers-polyfill": "^5.0.1",
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.3",
+ "path-to-regexp": "^6.3.0",
+ "picocolors": "^1.1.1",
+ "rettime": "^0.11.11",
+ "statuses": "^2.0.2",
+ "strict-event-emitter": "^0.5.1",
+ "tough-cookie": "^6.0.1",
+ "type-fest": "^5.5.0",
+ "until-async": "^3.0.2",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "msw": "cli/index.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mswjs"
+ },
+ "peerDependencies": {
+ "typescript": ">= 4.8.x"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/msw/node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/msw/node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mute-stream": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz",
+ "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.15",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-addon-api": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
+ "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/node-gyp-build": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-3.9.0.tgz",
+ "integrity": "sha512-zLcTg6P4AbcHPq465ZMFNXx7XpKKJh+7kkN699NiQWisR2uWYOWNWqRHAmbnmKiL4e9aLSlmy5U7rEMUXV59+A==",
+ "license": "MIT",
+ "optional": true,
+ "bin": {
+ "node-gyp-build": "bin.js",
+ "node-gyp-build-optional": "optional.js",
+ "node-gyp-build-test": "build-test.js"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "dependencies": {
+ "boolbase": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
+ "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/on-exit-leak-free": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
+ "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/onnxruntime-common": {
+ "version": "1.24.3",
+ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz",
+ "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/onnxruntime-node": {
+ "version": "1.24.3",
+ "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz",
+ "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32",
+ "darwin",
+ "linux"
+ ],
+ "dependencies": {
+ "adm-zip": "^0.5.16",
+ "global-agent": "^3.0.0",
+ "onnxruntime-common": "1.24.3"
+ }
+ },
+ "node_modules/onnxruntime-web": {
+ "version": "1.26.0-dev.20260416-b7804b056c",
+ "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz",
+ "integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "flatbuffers": "^25.1.24",
+ "guid-typescript": "^1.0.9",
+ "long": "^5.2.3",
+ "onnxruntime-common": "1.24.0-dev.20251116-b39e144322",
+ "platform": "^1.3.6",
+ "protobufjs": "^7.2.4"
+ }
+ },
+ "node_modules/onnxruntime-web/node_modules/onnxruntime-common": {
+ "version": "1.24.0-dev.20251116-b39e144322",
+ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz",
+ "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/opentracing": {
+ "version": "0.14.7",
+ "resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.7.tgz",
+ "integrity": "sha512-vz9iS7MJ5+Bp1URw8Khvdyw1H/hGvzHWlKQ7eRrQojSCDL1/SrWfrY9QebLw97n2deyRtzHRC3MkQfVNUCo91Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/outvariant": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
+ "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/oxc-parser": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.132.0.tgz",
+ "integrity": "sha512-+0LAPHaqtfQlvWdpaAa09SmOaZZgP8C552xosEkGJ4+ruEwP1Vgx+sqBgcBCNfR6KDCmagGOZTde8wmAvcI/Hg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@oxc-project/types": "^0.132.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxc-parser/binding-android-arm-eabi": "0.132.0",
+ "@oxc-parser/binding-android-arm64": "0.132.0",
+ "@oxc-parser/binding-darwin-arm64": "0.132.0",
+ "@oxc-parser/binding-darwin-x64": "0.132.0",
+ "@oxc-parser/binding-freebsd-x64": "0.132.0",
+ "@oxc-parser/binding-linux-arm-gnueabihf": "0.132.0",
+ "@oxc-parser/binding-linux-arm-musleabihf": "0.132.0",
+ "@oxc-parser/binding-linux-arm64-gnu": "0.132.0",
+ "@oxc-parser/binding-linux-arm64-musl": "0.132.0",
+ "@oxc-parser/binding-linux-ppc64-gnu": "0.132.0",
+ "@oxc-parser/binding-linux-riscv64-gnu": "0.132.0",
+ "@oxc-parser/binding-linux-riscv64-musl": "0.132.0",
+ "@oxc-parser/binding-linux-s390x-gnu": "0.132.0",
+ "@oxc-parser/binding-linux-x64-gnu": "0.132.0",
+ "@oxc-parser/binding-linux-x64-musl": "0.132.0",
+ "@oxc-parser/binding-openharmony-arm64": "0.132.0",
+ "@oxc-parser/binding-wasm32-wasi": "0.132.0",
+ "@oxc-parser/binding-win32-arm64-msvc": "0.132.0",
+ "@oxc-parser/binding-win32-ia32-msvc": "0.132.0",
+ "@oxc-parser/binding-win32-x64-msvc": "0.132.0"
+ }
+ },
+ "node_modules/oxc-parser/node_modules/@oxc-project/types": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
+ "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
+ "license": "MIT",
+ "optional": true,
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/oxfmt": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.44.0.tgz",
+ "integrity": "sha512-lnncqvHewyRvaqdrnntVIrZV2tEddz8lbvPsQzG/zlkfvgZkwy0HP1p/2u1aCDToeg1jb9zBpbJdfkV73Itw+w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinypool": "2.1.0"
+ },
+ "bin": {
+ "oxfmt": "bin/oxfmt"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxfmt/binding-android-arm-eabi": "0.44.0",
+ "@oxfmt/binding-android-arm64": "0.44.0",
+ "@oxfmt/binding-darwin-arm64": "0.44.0",
+ "@oxfmt/binding-darwin-x64": "0.44.0",
+ "@oxfmt/binding-freebsd-x64": "0.44.0",
+ "@oxfmt/binding-linux-arm-gnueabihf": "0.44.0",
+ "@oxfmt/binding-linux-arm-musleabihf": "0.44.0",
+ "@oxfmt/binding-linux-arm64-gnu": "0.44.0",
+ "@oxfmt/binding-linux-arm64-musl": "0.44.0",
+ "@oxfmt/binding-linux-ppc64-gnu": "0.44.0",
+ "@oxfmt/binding-linux-riscv64-gnu": "0.44.0",
+ "@oxfmt/binding-linux-riscv64-musl": "0.44.0",
+ "@oxfmt/binding-linux-s390x-gnu": "0.44.0",
+ "@oxfmt/binding-linux-x64-gnu": "0.44.0",
+ "@oxfmt/binding-linux-x64-musl": "0.44.0",
+ "@oxfmt/binding-openharmony-arm64": "0.44.0",
+ "@oxfmt/binding-win32-arm64-msvc": "0.44.0",
+ "@oxfmt/binding-win32-ia32-msvc": "0.44.0",
+ "@oxfmt/binding-win32-x64-msvc": "0.44.0"
+ }
+ },
+ "node_modules/oxlint": {
+ "version": "1.71.0",
+ "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.71.0.tgz",
+ "integrity": "sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "oxlint": "bin/oxlint"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxlint/binding-android-arm-eabi": "1.71.0",
+ "@oxlint/binding-android-arm64": "1.71.0",
+ "@oxlint/binding-darwin-arm64": "1.71.0",
+ "@oxlint/binding-darwin-x64": "1.71.0",
+ "@oxlint/binding-freebsd-x64": "1.71.0",
+ "@oxlint/binding-linux-arm-gnueabihf": "1.71.0",
+ "@oxlint/binding-linux-arm-musleabihf": "1.71.0",
+ "@oxlint/binding-linux-arm64-gnu": "1.71.0",
+ "@oxlint/binding-linux-arm64-musl": "1.71.0",
+ "@oxlint/binding-linux-ppc64-gnu": "1.71.0",
+ "@oxlint/binding-linux-riscv64-gnu": "1.71.0",
+ "@oxlint/binding-linux-riscv64-musl": "1.71.0",
+ "@oxlint/binding-linux-s390x-gnu": "1.71.0",
+ "@oxlint/binding-linux-x64-gnu": "1.71.0",
+ "@oxlint/binding-linux-x64-musl": "1.71.0",
+ "@oxlint/binding-openharmony-arm64": "1.71.0",
+ "@oxlint/binding-win32-arm64-msvc": "1.71.0",
+ "@oxlint/binding-win32-ia32-msvc": "1.71.0",
+ "@oxlint/binding-win32-x64-msvc": "1.71.0"
+ },
+ "peerDependencies": {
+ "oxlint-tsgolint": ">=0.22.1",
+ "vite-plus": "*"
+ },
+ "peerDependenciesMeta": {
+ "oxlint-tsgolint": {
+ "optional": true
+ },
+ "vite-plus": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5-htmlparser2-tree-adapter": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
+ "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "domhandler": "^5.0.3",
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5-parser-stream": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
+ "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5/node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pg": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
+ "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.15.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
+ "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pino": {
+ "version": "9.14.0",
+ "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
+ "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
+ "license": "MIT",
+ "dependencies": {
+ "@pinojs/redact": "^0.4.0",
+ "atomic-sleep": "^1.0.0",
+ "on-exit-leak-free": "^2.1.0",
+ "pino-abstract-transport": "^2.0.0",
+ "pino-std-serializers": "^7.0.0",
+ "process-warning": "^5.0.0",
+ "quick-format-unescaped": "^4.0.3",
+ "real-require": "^0.2.0",
+ "safe-stable-stringify": "^2.3.1",
+ "sonic-boom": "^4.0.1",
+ "thread-stream": "^3.0.0"
+ },
+ "bin": {
+ "pino": "bin.js"
+ }
+ },
+ "node_modules/pino-abstract-transport": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
+ "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.0.0"
+ }
+ },
+ "node_modules/pino-std-serializers": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
+ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
+ "license": "MIT"
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/pkce-challenge": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+ "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/pkg-types": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
+ "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "confbox": "^0.2.4",
+ "exsolve": "^1.0.8",
+ "pathe": "^2.0.3"
+ }
+ },
+ "node_modules/platform": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
+ "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/postcss": {
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-nested/node_modules/postcss-selector-parser": {
+ "version": "6.1.4",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
+ "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.0.10",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
+ "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pprof-format": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/pprof-format/-/pprof-format-2.2.2.tgz",
+ "integrity": "sha512-hd90rHVDhNOhgHTmazVzDSVwTLOBjpZQ26AO/0j46sAFZ9uSWY0DcK2zJcwnuo2R6EzufBbOoWlPazA5nSyHcg==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/process-warning": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
+ "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/protobufjs": {
+ "version": "7.6.4",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
+ "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.5",
+ "@protobufjs/eventemitter": "^1.1.1",
+ "@protobufjs/fetch": "^1.1.1",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.1",
+ "@types/node": ">=13.7.0",
+ "long": "^5.3.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/quansync": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
+ "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/antfu"
+ },
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/sxzz"
+ }
+ ],
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/quick-format-unescaped": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
+ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
+ "license": "MIT"
+ },
+ "node_modules/range-parser": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+ "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/real-require": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
+ "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12.13.0"
+ }
+ },
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/rettime": {
+ "version": "0.11.11",
+ "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz",
+ "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/roarr": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
+ "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "boolean": "^3.0.1",
+ "detect-node": "^2.0.4",
+ "globalthis": "^1.0.1",
+ "json-stringify-safe": "^5.0.1",
+ "semver-compare": "^1.0.0",
+ "sprintf-js": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
+ "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.137.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.1.3",
+ "@rolldown/binding-darwin-arm64": "1.1.3",
+ "@rolldown/binding-darwin-x64": "1.1.3",
+ "@rolldown/binding-freebsd-x64": "1.1.3",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.3",
+ "@rolldown/binding-linux-arm64-gnu": "1.1.3",
+ "@rolldown/binding-linux-arm64-musl": "1.1.3",
+ "@rolldown/binding-linux-ppc64-gnu": "1.1.3",
+ "@rolldown/binding-linux-s390x-gnu": "1.1.3",
+ "@rolldown/binding-linux-x64-gnu": "1.1.3",
+ "@rolldown/binding-linux-x64-musl": "1.1.3",
+ "@rolldown/binding-openharmony-arm64": "1.1.3",
+ "@rolldown/binding-wasm32-wasi": "1.1.3",
+ "@rolldown/binding-win32-arm64-msvc": "1.1.3",
+ "@rolldown/binding-win32-x64-msvc": "1.1.3"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/safe-stable-stringify": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
+ "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "devOptional": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/semver-compare": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
+ "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serialize-error": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
+ "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "type-fest": "^0.13.1"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/serialize-error/node_modules/type-fest": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
+ "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
+ "license": "(MIT OR CC0-1.0)",
+ "optional": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.1.tgz",
+ "integrity": "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/sonic-boom": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
+ "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "atomic-sleep": "^1.0.0"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.7.6",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
+ "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
+ "devOptional": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/spark-md5": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz",
+ "integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==",
+ "license": "(WTFPL OR MIT)",
+ "optional": true
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
+ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
+ "license": "BSD-3-Clause",
+ "optional": true
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/std-env": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strict-event-emitter": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
+ "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-ts": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/string-ts/-/string-ts-2.3.1.tgz",
+ "integrity": "sha512-xSJq+BS52SaFFAVxuStmx6n5aYZU571uYUnUrPXkPFCfdHyZMMlbP2v2Wx5sNBnAVzq/2+0+mcBLBa3Xa5ubYw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/synckit": {
+ "version": "0.11.13",
+ "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz",
+ "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@pkgr/core": "^0.3.6"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/synckit"
+ }
+ },
+ "node_modules/tagged-tag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
+ "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/tailwind-api-utils": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tailwind-api-utils/-/tailwind-api-utils-1.0.3.tgz",
+ "integrity": "sha512-KpzUHkH1ug1sq4394SLJX38ZtpeTiqQ1RVyFTTSY2XuHsNSTWUkRo108KmyyrMWdDbQrLYkSHaNKj/a3bmA4sQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "enhanced-resolve": "^5.18.1",
+ "jiti": "^2.4.2",
+ "local-pkg": "^1.1.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/hyoban"
+ },
+ "peerDependencies": {
+ "tailwindcss": "^3.3.0 || ^4.0.0 || ^4.0.0-beta"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tailwindcss-animate": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
+ "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "peerDependencies": {
+ "tailwindcss": ">=3.0.0 || insiders"
+ }
+ },
+ "node_modules/tailwindcss-scoped-preflight": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/tailwindcss-scoped-preflight/-/tailwindcss-scoped-preflight-3.5.2.tgz",
+ "integrity": "sha512-W+aWoA1Ia7M/Gaw5EZ5wN+OplbSgOkS4UKfJOZqTqKm4XN2Sv8c5gPaNavxIKwaPKX9A62kP/9Y7xELe5tHvNA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "peerDependencies": {
+ "postcss": "^8",
+ "tailwindcss": "^3 || ^4"
+ }
+ },
+ "node_modules/tailwindcss/node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/tailwindcss/node_modules/postcss-selector-parser": {
+ "version": "6.1.4",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
+ "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/thread-stream": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz",
+ "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==",
+ "license": "MIT",
+ "dependencies": {
+ "real-require": "^0.2.0"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz",
+ "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.0.0 || >=22.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.4.tgz",
+ "integrity": "sha512-kFXFK7O4WPextIUAOk8qtnw9dxR9UIXP9CjuH1cTBVBZMDeQcUPgr/IazGiw1B0Yiw5L75gHLWeW4iD793r90g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.4"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.4.tgz",
+ "integrity": "sha512-vwVLJVvvpslm7vqAH7+XNj/neA/Ynq7DT2EEcMuwc5YzN5XaMyRAqxwU+uX3azZ1FQtB2gvrvnLnAEkvYlVdfg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+ "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/ts-pattern": {
+ "version": "5.9.0",
+ "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.9.0.tgz",
+ "integrity": "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/tsup": {
+ "version": "8.5.1",
+ "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz",
+ "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bundle-require": "^5.1.0",
+ "cac": "^6.7.14",
+ "chokidar": "^4.0.3",
+ "consola": "^3.4.0",
+ "debug": "^4.4.0",
+ "esbuild": "^0.27.0",
+ "fix-dts-default-cjs-exports": "^1.0.0",
+ "joycon": "^3.1.1",
+ "picocolors": "^1.1.1",
+ "postcss-load-config": "^6.0.1",
+ "resolve-from": "^5.0.0",
+ "rollup": "^4.34.8",
+ "source-map": "^0.7.6",
+ "sucrase": "^3.35.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.11",
+ "tree-kill": "^1.2.2"
+ },
+ "bin": {
+ "tsup": "dist/cli-default.js",
+ "tsup-node": "dist/cli-node.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@microsoft/api-extractor": "^7.36.0",
+ "@swc/core": "^1",
+ "postcss": "^8.4.12",
+ "typescript": ">=4.5.0"
+ },
+ "peerDependenciesMeta": {
+ "@microsoft/api-extractor": {
+ "optional": true
+ },
+ "@swc/core": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tsup/node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/tsup/node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/tsx": {
+ "version": "4.22.4",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
+ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.28.0"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/turbo": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.10.0.tgz",
+ "integrity": "sha512-o016H9PPtuH2deb3mh3Vci3Avfi9UYgM/RONQisY7HnloupP0IFSbFS3gFYJgFJP8nwBrByHWFQIDa8T2zIXPw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "turbo": "bin/turbo"
+ },
+ "optionalDependencies": {
+ "@turbo/darwin-64": "2.10.0",
+ "@turbo/darwin-arm64": "2.10.0",
+ "@turbo/linux-64": "2.10.0",
+ "@turbo/linux-arm64": "2.10.0",
+ "@turbo/windows-64": "2.10.0",
+ "@turbo/windows-arm64": "2.10.0"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "5.7.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz",
+ "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "dependencies": {
+ "tagged-tag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.0.0",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/type-is/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/ufo": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
+ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/undici": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
+ "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/until-async": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz",
+ "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/kettanaito"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "peer": true,
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
+ "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.15",
+ "rolldown": "~1.1.2",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.3.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
+ "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.9",
+ "@vitest/mocker": "4.1.9",
+ "@vitest/pretty-format": "4.1.9",
+ "@vitest/runner": "4.1.9",
+ "@vitest/snapshot": "4.1.9",
+ "@vitest/spy": "4.1.9",
+ "@vitest/utils": "4.1.9",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.9",
+ "@vitest/browser-preview": "4.1.9",
+ "@vitest/browser-webdriverio": "4.1.9",
+ "@vitest/coverage-istanbul": "4.1.9",
+ "@vitest/coverage-v8": "4.1.9",
+ "@vitest/ui": "4.1.9",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vitest/node_modules/tinyexec": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-encoding/node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.3",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
+ "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-to-json-schema": {
+ "version": "3.25.2",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+ "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "zod": "^3.25.28 || ^4"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 000000000..5a2776159
--- /dev/null
+++ b/package.json
@@ -0,0 +1,91 @@
+{
+ "name": "@keboola/mcp-server",
+ "version": "2.0.0-alpha.2",
+ "description": "MCP server for interacting with Keboola Connection",
+ "keywords": [
+ "ai",
+ "data-platform",
+ "keboola",
+ "mcp",
+ "model-context-protocol"
+ ],
+ "homepage": "https://github.com/keboola/keboola-mcp-server#readme",
+ "bugs": {
+ "url": "https://github.com/keboola/keboola-mcp-server/issues"
+ },
+ "license": "MIT",
+ "author": "Keboola ",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/keboola/keboola-mcp-server.git"
+ },
+ "bin": {
+ "keboola-mcp-server": "dist/index.js"
+ },
+ "files": [
+ "dist",
+ "README.md"
+ ],
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "build": "tsup",
+ "dev": "tsx watch src/index.ts",
+ "start": "node dist/index.js",
+ "type-check": "tsc --noEmit",
+ "lint": "oxlint . --quiet && oxfmt --check src __tests__ *.ts *.json",
+ "lint:fix": "oxlint --fix . --quiet && oxfmt src __tests__ *.ts *.json",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:integ": "vitest run --config vitest.integ.config.ts",
+ "test:integ:watch": "vitest --config vitest.integ.config.ts",
+ "docs:build": "tsx scripts/docs-build.ts",
+ "docs:crawl": "tsx scripts/docs-crawl.ts",
+ "gen:tools-docs": "tsx scripts/gen-tools-docs.ts",
+ "check:tools-docs": "tsx scripts/gen-tools-docs.ts --check"
+ },
+ "dependencies": {
+ "@hono/node-server": "^1.19.14",
+ "@keboola/api-client": "^5.0.0",
+ "@modelcontextprotocol/sdk": "^1.29.0",
+ "@toon-format/toon": "^2.3.0",
+ "ajv": "^8.20.0",
+ "dd-trace": "^5.69.0",
+ "hono": "^4.12.27",
+ "jose": "^6.2.3",
+ "pg": "^8.22.0",
+ "pino": "^9.14.0",
+ "zod": "^4.3.6"
+ },
+ "devDependencies": {
+ "@keboola/oxlint-config": "^0.1.2",
+ "@keboola/tsconfig": "^0.1.1",
+ "@types/node": "^24.0.0",
+ "@types/pg": "^8.20.0",
+ "ioredis": "^5.4.1",
+ "msw": "^2.14.6",
+ "oxfmt": "^0.44.0",
+ "oxlint": "^1.56.0",
+ "tsup": "^8.5.1",
+ "tsx": "^4.19.4",
+ "typescript": "^5.9.0",
+ "vitest": "^4.1.8"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "optionalDependencies": {
+ "@huggingface/transformers": "^4.2.0",
+ "cheerio": "^1.2.0"
+ }
+}
diff --git a/pyproject.toml b/pyproject.toml
deleted file mode 100644
index aa137d0bf..000000000
--- a/pyproject.toml
+++ /dev/null
@@ -1,223 +0,0 @@
-[build-system]
-requires = ["hatchling"]
-build-backend = "hatchling.build"
-
-[project]
-name = "keboola-mcp-server"
-version = "1.72.8"
-description = "MCP server for interacting with Keboola Connection"
-readme = "README.md"
-requires-python = ">=3.10"
-license = "MIT"
-authors = [{ name = "Keboola", email = "devel@keboola.com" }]
-dependencies = [
- "fastmcp == 3.4.2",
- "mcp == 1.27.2",
- "httpx ~= 0.28",
- "httpx-retries~=0.5",
- "jsonpath-ng ~= 1.8",
- "jsonschema ~= 4.26",
- "pyjwt ~= 2.13",
- "json-log-formatter ~= 1.1",
- "cryptography ~= 48.0",
- "pydantic ~= 2.13.0",
- "sqlglot ~= 30.0",
- "toon-format ~= 0.9.0b1",
- "pyyaml ~= 6.0",
-]
-[project.optional-dependencies]
-codestyle = [
- "black ~= 26.3",
- "isort ~= 8.0",
- "flake8 ~= 7.3",
- "Flake8-pyproject ~= 1.2",
- "flake8-bugbear ~= 25.11",
- "flake8-colors ~= 0.1",
- "flake8-isort ~= 7.0",
- "flake8-pytest-style ~= 2.2",
- "flake8-quotes ~= 3.4",
- "flake8-typing-imports ~= 1.17",
- "pep8-naming ~= 0.15",
-]
-tests = [
- "pytest ~= 9.0",
- "pytest-asyncio ~= 1.4",
- "pytest-cov ~= 7.0",
- "pytest-datadir ~= 1.8",
- "pytest-mock ~= 3.15",
- "python-dateutil ~= 2.9",
- "python-dotenv ~= 1.2",
-]
-integtests = [
- "kbcstorage ~= 0.9",
- "requests ~= 2.34",
-]
-dev = [
- "tox ~= 4.35",
-]
-
-[project.scripts]
-keboola_mcp_server = "keboola_mcp_server.cli:main"
-keboola-mcp-server = "keboola_mcp_server.cli:main"
-
-[tool.setuptools.package-data]
-"keboola_mcp_server.resources" = ["storage-schema.json"]
-
-[tool.black]
-target-version = ["py310"]
-skip-string-normalization = true
-line-length = 120
-extend-exclude = '''
-/(
- # directories
- \.eggs
- | \.git
- | \.hg
- | \.mypy_cache
- | \.tox
- | \.venv
- | _build
- | buck-out
- | build
- | dist
-)/
-'''
-
-[tool.isort]
-profile = "black"
-line_length = 120
-multi_line_output = 3
-use_parentheses = true
-
-[tool.uv]
-# Dependency cooldown (supply-chain hardening): never resolve a package version
-# published within the last 7 days. Freshly published malicious releases are
-# typically detected and yanked within hours to a few days, so a 7-day buffer
-# removes most of that attack window before a version can reach our lock file.
-# This is a resolution-time filter — it applies whenever `uv lock` regenerates
-# the lock; CI installs with `uv sync --frozen` and is unaffected.
-# Temporarily relax this (e.g. `--exclude-newer` on the CLI) when an urgent
-# security fix needs to be picked up before the window elapses.
-exclude-newer = "7 days"
-
-[tool.hatch.build.targets.wheel]
-packages = ["src/keboola_mcp_server"]
-
-[tool.pytest.ini_options]
-addopts = [
- "--import-mode=importlib",
-]
-asyncio_default_fixture_loop_scope = "function"
-asyncio_mode = "strict"
-log_cli = false
-log_level = "INFO"
-log_cli_format = "%(asctime)s [%(levelname)8s] %(name)s: %(message)s (%(filename)s:%(lineno)s)"
-log_cli_date_format = "%Y-%m-%d %H:%M:%S"
-
-[tool.flake8]
-max-line-length = 120
-import-order-style = "edited"
-application-package-names = ["keboola_mcp_server"]
-min-python-version = "3.10.0"
-pytest-fixture-no-parentheses = true
-# Skip unused import checks F401 in __init__.py files; re-exported imports are intentional.
-per-file-ignores = [
- "__init__.py: F401",
-]
-# For Compatibility with black we skip: E203 whitespace before ':', W503 checks line break before binary operator
-# For Compatibility with isort we skip: I101 Imported names are in the wrong order since we follow isort format using
-# alphabetical case-sensitive sort A, B, a, b
-extend-ignore = ["E203", "W503", "I101"]
-
-[tool.tox]
-requires = ["tox>=4.23"]
-env_list = ["python", "black", "isort", "flake8", "check-tools-docs"]
-labels = { cs-fix = ["black", "isort"], cs = ["isort", "flake8"] }
-
-[tool.tox.env_run_base]
-description = "Run tests"
-extras = ["tests"]
-package = "wheel"
-wheel_build_env = ".pkg"
-commands = [
- [
- "pytest",
- { replace = "posargs", default = [
- "tests",
- "--cov=keboola_mcp_server",
- "--cov-report=term-missing",
- "--cov-report=xml",
- "--junitxml",
- "./test-results.xml",
- ], extend = true },
- ],
-]
-
-[tool.tox.env.integtests]
-description = "Run integration tests"
-extras = ["tests", "integtests"]
-pass_env = [
- "INTEGTEST_STORAGE_API_URL",
- "INTEGTEST_POOL_STORAGE_API_URL",
- "INTEGTEST_STORAGE_TOKENS",
- "INTEGTEST_WORKSPACE_SCHEMAS",
- "INTEGTEST_STORAGE_TOKEN_PRJ2",
- "INTEGTEST_WORKSPACE_SCHEMA_PRJ2",
- "INTEGTEST_STORAGE_TOKEN_STORAGE_BRANCHES",
- "INTEGTEST_LOCK_TTL_MINUTES",
- "INTEGTEST_LOCK_POLL_INTERVAL_SECONDS",
- "INTEGTEST_LOCK_MAX_WAIT_MINUTES",
-]
-commands = [
- [
- "pytest",
- { replace = "posargs", default = [
- "integtests",
- "--junitxml",
- "./integtest-results.xml",
- ], extend = true },
- ],
-]
-
-[tool.tox.env.black]
-description = "Fix code formatting using black"
-package = "skip"
-deps = [
- "black ~= 26.3",
-]
-commands = [["black", "src/", "tests/", "integtests/"]]
-
-[tool.tox.env.isort]
-description = "Sort imports using isort"
-package = "skip"
-deps = [
- "isort ~= 8.0",
-]
-commands = [["isort", "src/", "tests/", "integtests/"]]
-
-[tool.tox.env.flake8]
-description = "Run code style check using flake8"
-package = "skip"
-deps = [
- "flake8 ~= 7.3",
- "Flake8-pyproject ~= 1.2",
- "flake8-bugbear ~= 25.11",
- "flake8-colors ~= 0.1",
- "flake8-isort ~= 7.0",
- "flake8-pytest-style ~= 2.2",
- "flake8-quotes ~= 3.4",
- "flake8-typing-imports ~= 1.17",
- "pep8-naming ~= 0.15",
-]
-commands = [["flake8", "src/", "tests/", "integtests/"]]
-
-[tool.tox.env.check-tools-docs]
-description = "Check if TOOLS.md is up-to-date with tool definitions"
-extras = []
-package = "wheel"
-wheel_build_env = ".pkg"
-allowlist_externals = ["git"]
-commands = [
- ["python", "-m", "keboola_mcp_server.generate_tool_docs"],
- ["git", "diff", "--exit-code", "TOOLS.md"],
-]
diff --git a/scripts/docs-build.ts b/scripts/docs-build.ts
new file mode 100644
index 000000000..c5a8a797f
--- /dev/null
+++ b/scripts/docs-build.ts
@@ -0,0 +1,52 @@
+/**
+ * Local docs-index builder — `npm run docs:build`.
+ *
+ * Populates the pgvector docs index at DATABASE_URL with a small fixture corpus so a
+ * developer can run `docs_query` / `find_component_id` against a real local index. This is
+ * the dev mirror of the production out-of-band build (see
+ * feature_spec/docs-search-pgvector/architecture.md); it does NOT fetch real docs.
+ *
+ * Local quickstart:
+ * docker compose up -d pgvector
+ * DATABASE_URL=postgres://mcp:mcp@localhost:5432/docs DOCS_EMBEDDER_MODEL=stub \
+ * npm run docs:build
+ *
+ * With DOCS_EMBEDDER_MODEL=stub the deterministic offline embedder is used (no API key).
+ * Point DOCS_EMBEDDER_* at a real embedding endpoint for semantically meaningful vectors.
+ */
+import { Pool } from 'pg';
+
+import { createEmbedderFromEnv } from '@/clients/docsSearch';
+import { parseEnv } from '@/env';
+import { logger } from '@/logger';
+import { FIXTURE_SOURCES, migrateDocsIndex, seedDocsIndex } from './docsIndex';
+
+const main = async (): Promise => {
+ const env = parseEnv();
+ if (!env.DATABASE_URL) {
+ throw new Error('DATABASE_URL is required (e.g. postgres://mcp:mcp@localhost:5432/docs).');
+ }
+ const embedder = createEmbedderFromEnv(env);
+ if (!embedder) {
+ throw new Error(
+ 'No embedder configured. Set DOCS_EMBEDDER_MODEL to: "stub" (offline CI), "local" ' +
+ '(in-process HuggingFace, no key), or a remote model name with ' +
+ 'DOCS_EMBEDDER_ENDPOINT/API_KEY.',
+ );
+ }
+
+ const pool = new Pool({ connectionString: env.DATABASE_URL });
+ try {
+ logger.info(`Building docs index with embedder "${embedder.model}" (dim ${embedder.dim})…`);
+ await migrateDocsIndex(pool, embedder.dim);
+ const { docCount, chunkCount } = await seedDocsIndex(pool, embedder, FIXTURE_SOURCES);
+ logger.info(`Docs index built: ${docCount} docs, ${chunkCount} chunks.`);
+ } finally {
+ await pool.end();
+ }
+};
+
+main().catch((err) => {
+ logger.error({ err }, 'docs:build failed');
+ process.exitCode = 1;
+});
diff --git a/scripts/docs-crawl.ts b/scripts/docs-crawl.ts
new file mode 100644
index 000000000..2113aa036
--- /dev/null
+++ b/scripts/docs-crawl.ts
@@ -0,0 +1,154 @@
+/**
+ * Build a REAL local docs index from the public Keboola documentation — `npm run docs:crawl`.
+ *
+ * A simplified, local stand-in for the production out-of-band index builder (whose full
+ * connectors live on the @keboola/docs-search side, keboola/ui#6672). It crawls the public
+ * help + developer docs sitemaps, extracts each page's main text, chunks it, embeds with the
+ * configured embedder, and writes the pgvector index the MCP reads. No Keboola stack / token
+ * needed — only public HTTP + your local Postgres.
+ *
+ * Quickstart:
+ * docker compose up -d --wait pgvector
+ * DATABASE_URL=postgres://mcp:mcp@localhost:5432/docs DOCS_EMBEDDER_MODEL=local DOCS_EMBEDDER_DIM=384 \
+ * npm run docs:crawl -- --limit 50 # omit --limit for the full crawl
+ *
+ * Then point the MCP at the same DATABASE_URL + embedder and query docs_query / find_component_id.
+ * Flags: --limit N (cap pages, for a quick run), --source help|dev|all (default all).
+ */
+import type * as CheerioNS from 'cheerio'; // type-only (erased) — cheerio is a runtime-optional dep
+import { parseArgs } from 'node:util';
+import { Pool } from 'pg';
+
+import { createEmbedderFromEnv } from '@/clients/docsSearch';
+import { parseEnv } from '@/env';
+import { logger } from '@/logger';
+import { migrateDocsIndex, seedDocsIndex, type SourceDoc } from './docsIndex';
+
+type CheerioLoad = typeof CheerioNS.load;
+
+type Source = { type: 'help' | 'dev'; sitemap: string };
+
+const SOURCES: Record = {
+ help: { type: 'help', sitemap: 'https://help.keboola.com/sitemap-index.xml' },
+ dev: { type: 'dev', sitemap: 'https://developers.keboola.com/sitemap.xml' },
+};
+
+const CONCURRENCY = 6;
+
+const fetchText = async (url: string): Promise => {
+ const res = await fetch(url, { headers: { 'user-agent': 'keboola-mcp-docs-crawl' } });
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
+ return res.text();
+};
+
+/** Recursively resolve a sitemap or sitemap-index into a flat list of page URLs. */
+const sitemapUrls = async (sitemapUrl: string): Promise => {
+ const xml = await fetchText(sitemapUrl);
+ const locs = [...xml.matchAll(/([^<]+)<\/loc>/g)].map((m) => m[1]!.trim());
+ const isIndex = / sitemapUrls(u).catch(() => [])));
+ return nested.flat();
+};
+
+/** Extract a page's title + main content text, dropping nav/script/style/etc. */
+const extractPage = (html: string, load: CheerioLoad): { title: string; content: string } => {
+ const $ = load(html);
+ $('script, style, nav, header, footer, aside, noscript, svg').remove();
+ const main = $('main, article, .sl-markdown-content, [role="main"]').first();
+ const root = main.length ? main : $('body');
+ const content = root.text().replace(/\s+/g, ' ').trim();
+ const title = ($('h1').first().text() || $('title').text() || '').replace(/\s+/g, ' ').trim();
+ return { title, content };
+};
+
+/** Run `worker` over `items` with bounded concurrency, collecting non-null results. */
+const mapPool = async