@danhachuel/thunderbolt 0.2.26 → 0.2.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
4
4
 
5
- > **Versão deste manual:** 0.2.26
5
+ > **Versão deste manual:** 0.2.27
6
6
  > **Pacote npm:** `@danhachuel/thunderbolt`
7
7
  > **Porta padrão da UI:** `localhost:3030`
8
8
  > **Repositório:** [github.com/DanHachuel/thunderbolt](https://github.com/DanHachuel/thunderbolt)
@@ -100,13 +100,13 @@ Execute:
100
100
  Windows PowerShell ou MobaXterm:
101
101
 
102
102
  ```powershell
103
- npx.cmd --yes @danhachuel/thunderbolt@0.2.26 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.27 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.2.26 install
109
+ npx --yes @danhachuel/thunderbolt@0.2.27 install
110
110
  ```
111
111
 
112
112
  A instalação normal é **segura para actualizações**: preserva `storage`, Blueprints, Brandings, configurações e artefactos do utilizador. Remove apenas `.venv`, o clone técnico do MoneyPrinterTurbo e dependências que serão recriadas. Uma pasta antiga sem dados do utilizador, como `C:\Users\<utilizador>\AppData\Local\hermes` da tentativa incompleta, pode ser removida; uma pasta antiga que contenha Blueprints, Brandings ou storage é preservada e apenas avisada no terminal. Feche processos Python, Node, Streamlit e MobaXterm que estejam a usar as pastas antes de executar.
@@ -371,8 +371,9 @@ Após iniciar a aplicação, valide o seguinte percurso:
371
371
  11. **Automação:** no cartão de cada canal, escolha o **Blueprint padrão** e a **Voz padrão**, clique em `Guardar` e confirme que o resumo do cartão se actualiza. Configure também **Automação ON** e um horário `HH:MM`; confirme a lista de vídeos cadastrados. Os mesmos defaults aparecem em **Canais** e são usados em novas tarefas. A UI não inicia jobs em segundo plano nesta entrega.
372
372
  12. **Teste de vozes:** em Configurações, teste Edge/Azure ou provider configurado e confirme reprodução/download sem criação de tarefa.
373
373
  13. **Upload directo:** configure manualmente cookies, sessionInfo e INNERTUBE_API_KEY, atribua o `DELEGATED_SESSION_ID` no canal e teste apenas com um vídeo de validação. O método envia chunks de 256 KiB e não extrai cookies do navegador.
374
- 14. **MCP:** confirme que Short Video Maker, AutoVio, OpenMontage e OpenCut aparecem com as portas padrão editáveis. O estado **Activo** é apenas uma preferência local; a detecção deve indicar **Não detectado** quando os serviços externos não estiverem instalados ou iniciados.
375
- 15. **Skill MCP:** clique em **Guardar skill localmente** e confirme o ficheiro em `storage/skills/moneyprinterturbo-video.md`; opcionalmente use **Descarregar skill .md** para obter a cópia através do navegador.
374
+ 14. **MCP — Client MCP:** confirme que Short Video Maker, AutoVio, OpenMontage e OpenCut aparecem com as portas padrão editáveis. O estado **Activo** é uma preferência local; a detecção deve indicar **Não detectado** quando os serviços externos não estiverem instalados ou iniciados.
375
+ 15. **MCP — Servidor MCP:** abra a subaba, mantenha o host `127.0.0.1`, active **Servidor MCP ON** e clique em **Guardar e iniciar Servidor MCP**. Confirme o endpoint `/mcp` e o health endpoint `/health`. Mantenha **Permitir ferramentas de escrita** desactivado até precisar que um agente crie lotes.
376
+ 16. **MCP — Skill:** clique em **Guardar skill localmente** e confirme o ficheiro em `storage/skills/moneyprinterturbo-video.md`; opcionalmente use **Descarregar skill .md** para obter a cópia através do navegador.
376
377
 
377
378
  ## Novo vídeo, Automação e música
378
379
 
@@ -388,9 +389,11 @@ A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https:/
388
389
 
389
390
  ## MCP e integrações externas
390
391
 
391
- A aba **MCP** é um configurador local de quatro serviços opcionais. As portas iniciais são `3123` para Short Video Maker, `3001` para AutoVio, `8000` como referência editável para OpenMontage e `8787` para a API do OpenCut. A página consulta apenas `http://127.0.0.1:<porta>/` com timeout curto; não clona, instala ou inicia processos externos.
392
+ A subaba **Client MCP** é um configurador local de quatro serviços opcionais. As portas iniciais são `3123` para Short Video Maker, `3001` para AutoVio, `8000` como referência editável para OpenMontage e `8787` para a API do OpenCut. A página consulta apenas `http://127.0.0.1:<porta>/` com timeout curto; não clona, instala ou inicia processos externos.
392
393
 
393
- O botão **Guardar skill localmente** copia a skill anexada para `storage/skills/`. Esta pasta fica fora dos estados JSON e é criada pelo instalador e pelo launcher. Os quatro repositórios continuam fora do pacote npm.
394
+ A subaba **Servidor MCP** disponibiliza, após activação explícita, `http://127.0.0.1:3031/mcp` por JSON-RPC sobre HTTP POST. Para configurar um agente compatível, use esse URL como endpoint MCP. Se o host for alterado para uma interface externa, preencha primeiro um token e trate-o como segredo. O servidor disponibiliza ferramentas de leitura por padrão; a criação de lotes só é exposta quando **Permitir ferramentas de escrita** está activado.
395
+
396
+ A subaba **Skill** contém as acções para guardar a skill anexada em `storage/skills/` ou descarregá-la como Markdown. Esta pasta fica fora dos estados JSON e é criada pelo instalador e pelo launcher. Os quatro repositórios continuam fora do pacote npm.
394
397
 
395
398
  ## 12. Limpador de metadado
396
399
 
package/README.md CHANGED
@@ -104,9 +104,9 @@ thunderbolt
104
104
  No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
105
105
 
106
106
  ```powershell
107
- npx.cmd --yes @danhachuel/thunderbolt@0.2.26 install
108
- npx.cmd --yes @danhachuel/thunderbolt@0.2.26 doctor
109
- npx.cmd --yes @danhachuel/thunderbolt@0.2.26
107
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.27 install
108
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.27 doctor
109
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.27
110
110
  ```
111
111
 
112
112
  Como alternativa, pode permitir scripts para o seu utilizador:
@@ -193,11 +193,14 @@ A área **Configurações > Teste de vozes** é isolada da pipeline. Permite esc
193
193
 
194
194
  ## MCP e integrações externas opcionais
195
195
 
196
- A aba **MCP** funciona como um catálogo local de clientes externos. Ela lista **Short Video Maker**, **AutoVio**, **OpenMontage** e **OpenCut** com os respectivos links oficiais, protocolo/capacidade conhecida, porta local editável e toggle **Activo**. Os repositórios não são clonados, instalados ou incluídos no pacote npm do Thunderbolt.
196
+ A aba **MCP** está organizada em três subabas independentes. **Client MCP** lista **Short Video Maker**, **AutoVio**, **OpenMontage** e **OpenCut** com links oficiais, protocolo/capacidade conhecida, porta local editável, detecção passiva e toggle **Activo**. Os repositórios externos não são clonados, instalados ou incluídos no pacote npm do Thunderbolt.
197
197
 
198
- As portas iniciais são `3123` para Short Video Maker, `3001` para a API backend do AutoVio, `8000` como referência editável para OpenMontage — que não documenta uma porta HTTP padrão — e `8787` para a API do OpenCut, cujo frontend de desenvolvimento usa `5173`. A detecção é passiva: apenas consulta `localhost` e nunca inicia processos externos. Activar uma integração guarda a preferência local; não substitui a instalação/configuração do serviço externo.
198
+ As portas iniciais são `3123` para Short Video Maker, `3001` para a API backend do AutoVio, `8000` como referência editável para OpenMontage — que não documenta uma porta HTTP padrão — e `8787` para a API do OpenCut, cujo frontend de desenvolvimento usa `5173`. A detecção do Client MCP apenas consulta `localhost` e nunca inicia processos externos.
199
+
200
+ **Servidor MCP** activa, mediante confirmação explícita na UI, um endpoint local em `http://127.0.0.1:3031/mcp`. O endpoint usa JSON-RPC sobre HTTP POST, disponibiliza ferramentas de leitura para estado da pipeline, canais, vídeos e Blueprints e exige um token quando o host é exposto fora do computador local. As ferramentas de escrita ficam desactivadas por padrão; quando o utilizador as activa, o agente pode criar lotes de vídeos através de uma ferramenta controlada. O endpoint `/health` serve apenas para verificação de disponibilidade.
201
+
202
+ **Skill** contém exclusivamente os botões para guardar localmente ou descarregar `moneyprinterturbo-video.md`. A skill é guardada em `storage/skills/` e não é misturada com o catálogo de clientes MCP.
199
203
 
200
- A mesma aba permite **Guardar skill localmente**, copiando `moneyprinterturbo-video.md` para `storage/skills/`, e **Descarregar skill .md** através do navegador. A skill é um recurso separado dos quatro repositórios externos.
201
204
 
202
205
  ## TikTok
203
206
 
package/app/main.py CHANGED
@@ -20,7 +20,8 @@ from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_
20
20
  from hermes_ui.storage import BLUEPRINTS, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
21
21
  from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
22
22
  from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
23
- from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, read_packaged_skill, update_integration
23
+ from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
24
+ from hermes_ui.mcp_server import server_status, start_server, stop_server
24
25
  from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
25
26
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
26
27
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter
@@ -1077,73 +1078,141 @@ def render_settings():
1077
1078
 
1078
1079
  def render_mcp():
1079
1080
  st.title("MCP")
1080
- st.caption("Clientes opcionais para serviços externos. Os repositórios não são instalados nem incluídos no pacote Thunderbolt.")
1081
- st.info("Activar uma integração guarda apenas a preferência local. O Thunderbolt não inicia processos externos automaticamente; a detecção verifica passivamente se existe um serviço já disponível na porta configurada.")
1081
+ st.caption("Clientes externos, servidor MCP do Thunderbolt e a skill local ficam separados para evitar confundir funções diferentes.")
1082
+
1083
+ client_tab, server_tab, skill_tab = st.tabs(["Client MCP", "Servidor MCP", "Skill"])
1084
+
1085
+ with client_tab:
1086
+ st.subheader("Client MCP")
1087
+ st.info("Configure aqui os serviços MCP externos que o Thunderbolt pode detectar passivamente. Activar uma integração guarda apenas a preferência; não instala nem inicia processos externos.")
1088
+ integrations = load_integrations()
1089
+ for integration in integrations:
1090
+ integration_id = integration["id"]
1091
+ with st.container(border=True):
1092
+ header_cols = st.columns([2.6, 1.15, 1.65, 1.2])
1093
+ with header_cols[0]:
1094
+ st.write(f"**{integration['name']}**")
1095
+ st.caption(f"{integration['protocol']} · {integration['description']}")
1096
+ st.markdown(f"[Abrir repositório oficial]({integration['repository']})")
1097
+ with header_cols[1]:
1098
+ port = st.number_input(
1099
+ "Porta",
1100
+ min_value=1,
1101
+ max_value=65535,
1102
+ value=int(integration.get("port", 8000)),
1103
+ step=1,
1104
+ key=f"mcp_port_{integration_id}",
1105
+ )
1106
+ with header_cols[2]:
1107
+ status = detect_local_service({**integration, "port": port})
1108
+ if status["available"]:
1109
+ st.success("Disponível")
1110
+ else:
1111
+ st.caption("Não detectado")
1112
+ st.caption(status["message"])
1113
+ with header_cols[3]:
1114
+ active = st.toggle("Activo", value=bool(integration.get("active", False)), key=f"mcp_active_{integration_id}")
1115
+ if active != bool(integration.get("active", False)):
1116
+ update_integration(integration_id, active=active)
1117
+ st.rerun()
1082
1118
 
1083
- integrations = load_integrations()
1084
- for integration in integrations:
1085
- integration_id = integration["id"]
1086
- with st.container(border=True):
1087
- header_cols = st.columns([2.6, 1.15, 1.65, 1.2])
1088
- with header_cols[0]:
1089
- st.write(f"**{integration['name']}**")
1090
- st.caption(f"{integration['protocol']} · {integration['description']}")
1091
- st.markdown(f"[Abrir repositório oficial]({integration['repository']})")
1092
- with header_cols[1]:
1093
- port = st.number_input(
1094
- "Porta",
1095
- min_value=1,
1096
- max_value=65535,
1097
- value=int(integration.get("port", 8000)),
1098
- step=1,
1099
- key=f"mcp_port_{integration_id}",
1100
- )
1101
- with header_cols[2]:
1102
- status = detect_local_service({**integration, "port": port})
1103
- if status["available"]:
1104
- st.success("Disponível")
1105
- else:
1106
- st.caption("Não detectado")
1107
- st.caption(status["message"])
1108
- with header_cols[3]:
1109
- active = st.toggle("Activo", value=bool(integration.get("active", False)), key=f"mcp_active_{integration_id}")
1110
- if active != bool(integration.get("active", False)):
1111
- update_integration(integration_id, active=active)
1119
+ st.caption(integration.get("endpoint_note", "Porta editável para o serviço local."))
1120
+ if st.button("Guardar porta", key=f"mcp_save_port_{integration_id}", use_container_width=True):
1121
+ update_integration(integration_id, port=int(port))
1122
+ st.success(f"Porta de {integration['name']} guardada: {int(port)}")
1112
1123
  st.rerun()
1113
1124
 
1114
- st.caption(integration.get("endpoint_note", "Porta editável para o serviço local."))
1115
- if st.button("Guardar porta", key=f"mcp_save_port_{integration_id}", use_container_width=True):
1116
- update_integration(integration_id, port=int(port))
1117
- st.success(f"Porta de {integration['name']} guardada: {int(port)}")
1118
- st.rerun()
1119
-
1120
- st.divider()
1121
- st.subheader("Skill MoneyPrinterTurbo")
1122
- st.caption("A skill anexada pode ser guardada na pasta local do Thunderbolt ou descarregada como ficheiro Markdown. Nenhum dos quatro repositórios externos é copiado para o pacote.")
1123
- skill_cols = st.columns(2)
1124
- with skill_cols[0]:
1125
- if st.button("Guardar skill localmente", type="primary", use_container_width=True, key="mcp_install_mpt_skill"):
1125
+ with server_tab:
1126
+ st.subheader("Servidor MCP")
1127
+ st.caption("Servidor local do Thunderbolt para ser descoberto por um agente compatível com MCP. O endpoint usa JSON-RPC sobre HTTP POST em `/mcp`.")
1128
+ st.warning("Por segurança, o servidor inicia apenas quando o activar explicitamente. Por padrão fica acessível somente neste computador em 127.0.0.1:3031.")
1129
+ server_config = load_server_config()
1130
+ if server_config.get("enabled") and not server_status().get("running"):
1126
1131
  try:
1127
- destination = install_skill_locally()
1128
- st.success(f"Skill guardada em `{destination}`")
1129
- except (FileNotFoundError, OSError) as exc:
1130
- st.error(f"Não foi possível guardar a skill: {exc}")
1131
- with skill_cols[1]:
1132
- try:
1133
- skill_data = read_packaged_skill()
1134
- except FileNotFoundError:
1135
- skill_data = None
1136
- if skill_data is not None:
1137
- st.download_button(
1138
- "Descarregar skill .md",
1139
- data=skill_data,
1140
- file_name="moneyprinterturbo-video.md",
1141
- mime="text/markdown",
1142
- use_container_width=True,
1143
- key="mcp_download_mpt_skill",
1132
+ start_server(
1133
+ str(server_config.get("host", "127.0.0.1")),
1134
+ int(server_config.get("port", 3031)),
1135
+ str(server_config.get("auth_token", "")),
1136
+ bool(server_config.get("write_enabled", False)),
1137
+ )
1138
+ except Exception as exc:
1139
+ st.error(f"Não foi possível iniciar o Servidor MCP guardado: {exc}")
1140
+
1141
+ server_cols = st.columns([1.3, 1.4, 1.4])
1142
+ with server_cols[0]:
1143
+ server_enabled = st.checkbox("Servidor MCP ON", value=bool(server_config.get("enabled", False)), key="mcp_server_enabled")
1144
+ with server_cols[1]:
1145
+ server_host = st.text_input("Host", value=str(server_config.get("host", "127.0.0.1")), key="mcp_server_host", help="Use 127.0.0.1 para acesso local. Um host externo exige token.")
1146
+ with server_cols[2]:
1147
+ server_port = st.number_input("Porta do servidor", min_value=1, max_value=65535, value=int(server_config.get("port", 3031)), step=1, key="mcp_server_port")
1148
+ server_token = st.text_input("Token de acesso MCP (opcional no localhost)", value=str(server_config.get("auth_token", "")), type="password", key="mcp_server_token")
1149
+ write_enabled = st.checkbox("Permitir ferramentas de escrita", value=bool(server_config.get("write_enabled", False)), key="mcp_server_write", help="Desactivado por padrão. Quando activo, o agente pode criar lotes de vídeos através de uma ferramenta MCP; leituras continuam disponíveis sem esta opção.")
1150
+ if str(server_host).strip() not in {"127.0.0.1", "localhost", "::1"} and not str(server_token).strip():
1151
+ st.error("Para expor o servidor fora do computador local, defina um token de acesso MCP.")
1152
+
1153
+ action_label = "Guardar e iniciar Servidor MCP" if server_enabled else "Guardar e parar Servidor MCP"
1154
+ if st.button(action_label, type="primary", key="mcp_server_save", use_container_width=True):
1155
+ try:
1156
+ saved = save_server_config(enabled=server_enabled, host=server_host, port=int(server_port), auth_token=server_token, write_enabled=write_enabled)
1157
+ if saved["enabled"]:
1158
+ status = start_server(saved["host"], saved["port"], saved["auth_token"], saved["write_enabled"])
1159
+ st.success(f"Servidor MCP activo em `{status['endpoint']}`")
1160
+ else:
1161
+ stop_server()
1162
+ st.success("Servidor MCP parado.")
1163
+ st.rerun()
1164
+ except Exception as exc:
1165
+ st.error(f"Não foi possível guardar/iniciar o Servidor MCP: {exc}")
1166
+
1167
+ runtime = server_status()
1168
+ if runtime.get("running"):
1169
+ st.success(f"Servidor MCP activo: `{runtime['endpoint']}`")
1170
+ st.code(
1171
+ json.dumps(
1172
+ {
1173
+ "endpoint": runtime["endpoint"],
1174
+ "health": runtime["health_endpoint"],
1175
+ "transport": "Streamable HTTP / JSON-RPC POST",
1176
+ "write_tools": runtime["write_enabled"],
1177
+ },
1178
+ ensure_ascii=False,
1179
+ indent=2,
1180
+ ),
1181
+ language="json",
1144
1182
  )
1183
+ st.caption("Use o endpoint `/mcp` na configuração do agente. O endpoint `/health` serve apenas para verificar se o servidor está activo. Não partilhe o token.")
1145
1184
  else:
1146
- st.warning("A skill ainda não está disponível nesta instalação.")
1185
+ st.info("Servidor MCP parado. As configurações ficam guardadas localmente e só são usadas depois de clicar em Guardar e iniciar.")
1186
+ st.markdown("**Ferramentas disponibilizadas**")
1187
+ st.write("Leitura: estado da pipeline, canais, vídeos e Blueprints. Ferramentas de escrita: criação de lotes de vídeos, apenas quando a opção de escrita estiver activada pelo utilizador.")
1188
+
1189
+ with skill_tab:
1190
+ st.subheader("Skill")
1191
+ st.caption("A skill anexada pode ser guardada na pasta local do Thunderbolt ou descarregada como ficheiro Markdown. Nenhum dos quatro repositórios externos é copiado para o pacote.")
1192
+ skill_cols = st.columns(2)
1193
+ with skill_cols[0]:
1194
+ if st.button("Guardar skill localmente", type="primary", use_container_width=True, key="mcp_install_mpt_skill"):
1195
+ try:
1196
+ destination = install_skill_locally()
1197
+ st.success(f"Skill guardada em `{destination}`")
1198
+ except (FileNotFoundError, OSError) as exc:
1199
+ st.error(f"Não foi possível guardar a skill: {exc}")
1200
+ with skill_cols[1]:
1201
+ try:
1202
+ skill_data = read_packaged_skill()
1203
+ except FileNotFoundError:
1204
+ skill_data = None
1205
+ if skill_data is not None:
1206
+ st.download_button(
1207
+ "Descarregar skill .md",
1208
+ data=skill_data,
1209
+ file_name="moneyprinterturbo-video.md",
1210
+ mime="text/markdown",
1211
+ use_container_width=True,
1212
+ key="mcp_download_mpt_skill",
1213
+ )
1214
+ else:
1215
+ st.warning("A skill ainda não está disponível nesta instalação.")
1147
1216
 
1148
1217
 
1149
1218
  def render_metadata_cleaner():
package/hermes_ui/mcp.py CHANGED
@@ -9,6 +9,13 @@ from . import storage
9
9
 
10
10
 
11
11
  SKILL_FILENAME = "moneyprinterturbo-video.md"
12
+ MCP_SERVER_DEFAULTS: dict[str, Any] = {
13
+ "enabled": False,
14
+ "host": "127.0.0.1",
15
+ "port": 3031,
16
+ "auth_token": "",
17
+ "write_enabled": False,
18
+ }
12
19
 
13
20
  MCP_DEFAULTS: list[dict[str, Any]] = [
14
21
  {
@@ -72,6 +79,36 @@ def _merge_defaults(saved: Any) -> list[dict[str, Any]]:
72
79
  return merged
73
80
 
74
81
 
82
+ def load_server_config() -> dict[str, Any]:
83
+ saved = storage.read_json("mcp_server.json", MCP_SERVER_DEFAULTS)
84
+ config = {**MCP_SERVER_DEFAULTS, **(saved if isinstance(saved, dict) else {})}
85
+ config["enabled"] = bool(config.get("enabled", False))
86
+ config["write_enabled"] = bool(config.get("write_enabled", False))
87
+ config["host"] = str(config.get("host", "127.0.0.1") or "127.0.0.1").strip()
88
+ config["auth_token"] = str(config.get("auth_token", "") or "").strip()
89
+ try:
90
+ config["port"] = max(1, min(65535, int(config.get("port", 3031))))
91
+ except (TypeError, ValueError):
92
+ config["port"] = 3031
93
+ return config
94
+
95
+
96
+ def save_server_config(**updates: Any) -> dict[str, Any]:
97
+ config = load_server_config()
98
+ config.update(updates)
99
+ config = {**MCP_SERVER_DEFAULTS, **config}
100
+ config["enabled"] = bool(config.get("enabled", False))
101
+ config["write_enabled"] = bool(config.get("write_enabled", False))
102
+ config["host"] = str(config.get("host", "127.0.0.1") or "127.0.0.1").strip()
103
+ config["auth_token"] = str(config.get("auth_token", "") or "").strip()
104
+ try:
105
+ config["port"] = max(1, min(65535, int(config.get("port", 3031))))
106
+ except (TypeError, ValueError):
107
+ config["port"] = 3031
108
+ storage.write_json("mcp_server.json", config)
109
+ return config
110
+
111
+
75
112
  def load_integrations() -> list[dict[str, Any]]:
76
113
  return _merge_defaults(storage.read_json("mcp_integrations.json", MCP_DEFAULTS))
77
114
 
@@ -0,0 +1,400 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import json
5
+ import logging
6
+ import threading
7
+ from http import HTTPStatus
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from urllib.parse import urlparse
12
+
13
+ from . import storage
14
+ from .domain import create_batch, create_tasks_for_batch, pipeline_summary
15
+
16
+ LOGGER = logging.getLogger(__name__)
17
+ MCP_ENDPOINT_PATH = "/mcp"
18
+ MCP_HEALTH_PATH = "/health"
19
+ DEFAULT_HOST = "127.0.0.1"
20
+ DEFAULT_PORT = 3031
21
+ DEFAULT_PROTOCOL_VERSION = "2025-06-18"
22
+
23
+
24
+ def _version() -> str:
25
+ try:
26
+ package = json.loads((Path(__file__).resolve().parents[1] / "package.json").read_text(encoding="utf-8"))
27
+ return str(package.get("version") or "")
28
+ except (OSError, json.JSONDecodeError):
29
+ return ""
30
+
31
+
32
+ def _json_text(value: Any) -> str:
33
+ return json.dumps(value, ensure_ascii=False, indent=2, default=str)
34
+
35
+
36
+ def _channel_public(channel: dict[str, Any]) -> dict[str, Any]:
37
+ allowed = (
38
+ "id", "name", "url", "handle", "description", "thumbnail_url", "subscriber_count",
39
+ "video_count", "view_count", "metrics_source", "last_youtube_sync", "language",
40
+ "blueprint_id", "default_blueprint_id", "style_wide", "voice", "default_voice",
41
+ "automation_on", "automation_time", "active", "daily_limit", "backlog_total",
42
+ "created_at", "updated_at",
43
+ )
44
+ return {key: channel.get(key) for key in allowed if key in channel}
45
+
46
+
47
+ def _task_public(task: dict[str, Any]) -> dict[str, Any]:
48
+ allowed = (
49
+ "id", "batch_id", "creation_mode", "channel_id", "channel_name", "topic", "language",
50
+ "format", "style_wide", "style_ia", "music_mode", "music_source", "background_mode",
51
+ "blueprint_id", "voice", "automation_on", "automation_time", "stage", "state",
52
+ "progress", "error", "created_at", "updated_at",
53
+ )
54
+ return {key: task.get(key) for key in allowed if key in task}
55
+
56
+
57
+ def _list_blueprints() -> list[dict[str, Any]]:
58
+ storage.ensure_storage()
59
+ files = sorted(storage.BLUEPRINTS.rglob("*.json"))
60
+ return [
61
+ {
62
+ "id": path.stem,
63
+ "name": path.stem,
64
+ "filename": path.name,
65
+ "category": path.parent.name,
66
+ }
67
+ for path in files
68
+ ]
69
+
70
+
71
+ def _tool_definitions(write_enabled: bool) -> list[dict[str, Any]]:
72
+ tools = [
73
+ {
74
+ "name": "thunderbolt_get_status",
75
+ "title": "Thunderbolt — estado da pipeline",
76
+ "description": "Obtém o resumo actual de canais e tarefas do Thunderbolt.",
77
+ "inputSchema": {"type": "object", "additionalProperties": False},
78
+ },
79
+ {
80
+ "name": "thunderbolt_list_channels",
81
+ "title": "Thunderbolt — listar canais",
82
+ "description": "Lista os canais registados sem devolver cookies, tokens ou outros segredos.",
83
+ "inputSchema": {"type": "object", "additionalProperties": False},
84
+ },
85
+ {
86
+ "name": "thunderbolt_list_videos",
87
+ "title": "Thunderbolt — listar vídeos",
88
+ "description": "Lista tarefas de vídeo com filtros opcionais por canal e estado.",
89
+ "inputSchema": {
90
+ "type": "object",
91
+ "properties": {
92
+ "channel_id": {"type": "string", "description": "ID do canal opcional."},
93
+ "state": {"type": "string", "description": "Estado opcional: to_do, doing, blocked, done, failed ou cancelled."},
94
+ "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
95
+ },
96
+ "additionalProperties": False,
97
+ },
98
+ },
99
+ {
100
+ "name": "thunderbolt_list_blueprints",
101
+ "title": "Thunderbolt — listar Blueprints",
102
+ "description": "Lista os Blueprints JSON disponíveis na biblioteca local, sem expor caminhos absolutos.",
103
+ "inputSchema": {"type": "object", "additionalProperties": False},
104
+ },
105
+ ]
106
+ if write_enabled:
107
+ tools.append(
108
+ {
109
+ "name": "thunderbolt_create_video_batch",
110
+ "title": "Thunderbolt — criar lote de vídeos",
111
+ "description": "Cria um lote e as respectivas tarefas. Só aparece quando o utilizador activa ferramentas de escrita na UI.",
112
+ "inputSchema": {
113
+ "type": "object",
114
+ "properties": {
115
+ "mode": {"type": "string", "enum": ["single", "batch", "general"]},
116
+ "channel_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1},
117
+ "topic": {"type": "string", "minLength": 1},
118
+ "quantity": {"type": "integer", "minimum": 1, "maximum": 50},
119
+ "language": {"type": "string"},
120
+ "format": {"type": "string"},
121
+ "style_wide": {"type": "string"},
122
+ "style_ia": {"type": "string"},
123
+ "music_mode": {"type": "boolean"},
124
+ "background_mode": {"type": "string"},
125
+ },
126
+ "required": ["mode", "channel_ids", "topic", "quantity"],
127
+ "additionalProperties": False,
128
+ },
129
+ }
130
+ )
131
+ return tools
132
+
133
+
134
+ def _tool_call(name: str, arguments: dict[str, Any], write_enabled: bool) -> Any:
135
+ if name == "thunderbolt_get_status":
136
+ return pipeline_summary()
137
+ if name == "thunderbolt_list_channels":
138
+ channels = storage.read_json("channels.json", [])
139
+ return [_channel_public(channel) for channel in channels if isinstance(channel, dict)]
140
+ if name == "thunderbolt_list_videos":
141
+ channel_id = str(arguments.get("channel_id", "") or "").strip()
142
+ state = str(arguments.get("state", "") or "").strip()
143
+ limit = int(arguments.get("limit", 50) or 50)
144
+ if limit < 1 or limit > 100:
145
+ raise ValueError("limit deve estar entre 1 e 100.")
146
+ valid_states = {"to_do", "doing", "blocked", "done", "failed", "cancelled"}
147
+ if state and state not in valid_states:
148
+ raise ValueError(f"state inválido: {state}.")
149
+ tasks = storage.read_json("tasks.json", [])
150
+ filtered = [
151
+ task for task in tasks
152
+ if isinstance(task, dict)
153
+ and (not channel_id or task.get("channel_id") == channel_id)
154
+ and (not state or task.get("state") == state)
155
+ ]
156
+ return [_task_public(task) for task in filtered[:limit]]
157
+ if name == "thunderbolt_list_blueprints":
158
+ return _list_blueprints()
159
+ if name == "thunderbolt_create_video_batch":
160
+ if not write_enabled:
161
+ raise PermissionError("Ferramentas de escrita estão desactivadas no Servidor MCP.")
162
+ mode = str(arguments.get("mode", "")).strip()
163
+ if mode not in {"single", "batch", "general"}:
164
+ raise ValueError("mode deve ser single, batch ou general.")
165
+ channel_ids = arguments.get("channel_ids")
166
+ if not isinstance(channel_ids, list) or not channel_ids or not all(isinstance(item, str) and item.strip() for item in channel_ids):
167
+ raise ValueError("channel_ids deve conter pelo menos um ID de canal válido.")
168
+ topic = str(arguments.get("topic", "") or "").strip()
169
+ if not topic:
170
+ raise ValueError("topic é obrigatório.")
171
+ quantity = int(arguments.get("quantity", 1) or 1)
172
+ if quantity < 1 or quantity > 50:
173
+ raise ValueError("quantity deve estar entre 1 e 50.")
174
+ options = {
175
+ "language": str(arguments.get("language", "") or ""),
176
+ "format": str(arguments.get("format", "wide") or "wide"),
177
+ "style_wide": str(arguments.get("style_wide", "pexels") or "pexels"),
178
+ "style_ia": str(arguments.get("style_ia", "") or ""),
179
+ "music_mode": bool(arguments.get("music_mode", False)),
180
+ "background_mode": str(arguments.get("background_mode", "stock") or "stock"),
181
+ }
182
+ batch = create_batch(mode, [item.strip() for item in channel_ids], topic, quantity, options)
183
+ tasks = create_tasks_for_batch(batch)
184
+ return {"batch_id": batch["id"], "task_ids": [task["id"] for task in tasks], "created": len(tasks)}
185
+ raise KeyError(f"Ferramenta desconhecida: {name}")
186
+
187
+
188
+ def _rpc_error(request_id: Any, code: int, message: str) -> dict[str, Any]:
189
+ return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
190
+
191
+
192
+ def _tool_result(value: Any, *, is_error: bool = False) -> dict[str, Any]:
193
+ result: dict[str, Any] = {
194
+ "content": [{"type": "text", "text": _json_text(value)}],
195
+ "isError": is_error,
196
+ }
197
+ if not is_error:
198
+ result["structuredContent"] = value
199
+ return result
200
+
201
+
202
+ class _MCPDispatcher:
203
+ def __init__(self, auth_token: str = "", write_enabled: bool = False):
204
+ self.auth_token = auth_token.strip()
205
+ self.write_enabled = bool(write_enabled)
206
+
207
+ def handle(self, request: dict[str, Any]) -> dict[str, Any] | None:
208
+ request_id = request.get("id")
209
+ if request.get("jsonrpc") != "2.0":
210
+ return _rpc_error(request_id, -32600, "Pedido JSON-RPC inválido.")
211
+ method = request.get("method")
212
+ params = request.get("params") or {}
213
+ if not isinstance(method, str) or not isinstance(params, dict):
214
+ return _rpc_error(request_id, -32600, "method e params inválidos.")
215
+ if method in {"notifications/initialized", "notifications/cancelled", "notifications/progress"} or "id" not in request:
216
+ return None
217
+ if method == "initialize":
218
+ requested = params.get("protocolVersion")
219
+ protocol_version = requested if isinstance(requested, str) and requested else DEFAULT_PROTOCOL_VERSION
220
+ return {
221
+ "jsonrpc": "2.0",
222
+ "id": request_id,
223
+ "result": {
224
+ "protocolVersion": protocol_version,
225
+ "capabilities": {"tools": {"listChanged": False}},
226
+ "serverInfo": {"name": "Thunderbolt MCP Server", "version": _version()},
227
+ "instructions": "Use tools/list para descobrir as ferramentas disponíveis. Ferramentas de escrita só aparecem quando autorizadas pelo utilizador na UI.",
228
+ },
229
+ }
230
+ if method == "ping":
231
+ return {"jsonrpc": "2.0", "id": request_id, "result": {}}
232
+ if method == "tools/list":
233
+ return {"jsonrpc": "2.0", "id": request_id, "result": {"tools": _tool_definitions(self.write_enabled)}}
234
+ if method == "tools/call":
235
+ name = params.get("name")
236
+ arguments = params.get("arguments") or {}
237
+ if not isinstance(name, str) or not isinstance(arguments, dict):
238
+ return _rpc_error(request_id, -32602, "name e arguments são obrigatórios em tools/call.")
239
+ try:
240
+ value = _tool_call(name, arguments, self.write_enabled)
241
+ return {"jsonrpc": "2.0", "id": request_id, "result": _tool_result(value)}
242
+ except KeyError as exc:
243
+ return _rpc_error(request_id, -32602, str(exc))
244
+ except (ValueError, PermissionError) as exc:
245
+ return {"jsonrpc": "2.0", "id": request_id, "result": _tool_result(str(exc), is_error=True)}
246
+ except Exception:
247
+ LOGGER.exception("Falha ao executar ferramenta MCP %s", name)
248
+ return {"jsonrpc": "2.0", "id": request_id, "result": _tool_result("Erro interno ao executar a ferramenta.", is_error=True)}
249
+ return _rpc_error(request_id, -32601, f"Método não suportado: {method}")
250
+
251
+
252
+ class _HTTPServer(ThreadingHTTPServer):
253
+ daemon_threads = True
254
+ allow_reuse_address = True
255
+
256
+
257
+ class _RequestHandler(BaseHTTPRequestHandler):
258
+ server: _HTTPServer
259
+
260
+ def log_message(self, format: str, *args: Any) -> None:
261
+ LOGGER.info("MCP HTTP %s", format % args)
262
+
263
+ def _send_json(self, payload: Any, status: int = HTTPStatus.OK) -> None:
264
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
265
+ self.send_response(status)
266
+ self.send_header("Content-Type", "application/json; charset=utf-8")
267
+ self.send_header("Content-Length", str(len(body)))
268
+ self.send_header("Cache-Control", "no-store")
269
+ self.end_headers()
270
+ self.wfile.write(body)
271
+
272
+ def _authorized(self) -> bool:
273
+ token = self.server.dispatcher.auth_token
274
+ if not token:
275
+ return True
276
+ return self.headers.get("Authorization", "") == f"Bearer {token}" or self.headers.get("X-Thunderbolt-MCP-Token", "") == token
277
+
278
+ def do_GET(self) -> None:
279
+ path = urlparse(self.path).path
280
+ if path == MCP_HEALTH_PATH:
281
+ self._send_json({"ok": True, "server": "Thunderbolt MCP Server", "endpoint": MCP_ENDPOINT_PATH, "version": _version()})
282
+ return
283
+ if path == MCP_ENDPOINT_PATH:
284
+ self.send_response(HTTPStatus.METHOD_NOT_ALLOWED)
285
+ self.send_header("Allow", "POST")
286
+ self.end_headers()
287
+ return
288
+ self._send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND)
289
+
290
+ def do_POST(self) -> None:
291
+ path = urlparse(self.path).path
292
+ if path != MCP_ENDPOINT_PATH:
293
+ self._send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND)
294
+ return
295
+ if not self._authorized():
296
+ self._send_json({"error": "Unauthorized"}, HTTPStatus.UNAUTHORIZED)
297
+ return
298
+ try:
299
+ length = int(self.headers.get("Content-Length", "0"))
300
+ if length < 1 or length > 1_000_000:
301
+ raise ValueError("Corpo JSON inválido ou demasiado grande.")
302
+ payload = json.loads(self.rfile.read(length).decode("utf-8"))
303
+ except (ValueError, json.JSONDecodeError, UnicodeDecodeError) as exc:
304
+ self._send_json(_rpc_error(None, -32700, f"JSON inválido: {exc}"), HTTPStatus.BAD_REQUEST)
305
+ return
306
+ if isinstance(payload, list):
307
+ responses = [self.server.dispatcher.handle(item) for item in payload if isinstance(item, dict)]
308
+ responses = [item for item in responses if item is not None]
309
+ if responses:
310
+ self._send_json(responses)
311
+ else:
312
+ self.send_response(HTTPStatus.ACCEPTED)
313
+ self.end_headers()
314
+ return
315
+ if not isinstance(payload, dict):
316
+ self._send_json(_rpc_error(None, -32600, "O pedido MCP deve ser um objecto JSON-RPC."), HTTPStatus.BAD_REQUEST)
317
+ return
318
+ response = self.server.dispatcher.handle(payload)
319
+ if response is None:
320
+ self.send_response(HTTPStatus.ACCEPTED)
321
+ self.end_headers()
322
+ return
323
+ self._send_json(response)
324
+
325
+
326
+ class _Runtime:
327
+ def __init__(self) -> None:
328
+ self.lock = threading.RLock()
329
+ self.http_server: _HTTPServer | None = None
330
+ self.thread: threading.Thread | None = None
331
+ self.host = ""
332
+ self.port = 0
333
+ self.write_enabled = False
334
+
335
+ def start(self, host: str, port: int, auth_token: str = "", write_enabled: bool = False) -> dict[str, Any]:
336
+ host = (host or DEFAULT_HOST).strip()
337
+ port = int(port)
338
+ if not 1 <= port <= 65535:
339
+ raise ValueError("A porta MCP deve estar entre 1 e 65535.")
340
+ if host not in {"127.0.0.1", "localhost", "::1"} and not auth_token.strip():
341
+ raise ValueError("Defina um token MCP antes de expor o servidor fora do computador local.")
342
+ with self.lock:
343
+ if self.http_server and self.thread and self.thread.is_alive() and (self.host, self.port) == (host, port):
344
+ self.write_enabled = bool(write_enabled)
345
+ self.http_server.dispatcher.write_enabled = bool(write_enabled)
346
+ self.http_server.dispatcher.auth_token = auth_token.strip()
347
+ return self.status()
348
+ self.stop()
349
+ server = _HTTPServer((host, port), _RequestHandler)
350
+ server.dispatcher = _MCPDispatcher(auth_token, write_enabled)
351
+ thread = threading.Thread(target=server.serve_forever, name="thunderbolt-mcp", daemon=True)
352
+ thread.start()
353
+ self.http_server = server
354
+ self.thread = thread
355
+ self.host = host
356
+ self.port = port
357
+ self.write_enabled = bool(write_enabled)
358
+ return self.status()
359
+
360
+ def stop(self) -> None:
361
+ with self.lock:
362
+ if self.http_server:
363
+ self.http_server.shutdown()
364
+ self.http_server.server_close()
365
+ self.http_server = None
366
+ self.thread = None
367
+ self.host = ""
368
+ self.port = 0
369
+ self.write_enabled = False
370
+
371
+ def status(self) -> dict[str, Any]:
372
+ running = bool(self.http_server and self.thread and self.thread.is_alive())
373
+ return {
374
+ "running": running,
375
+ "host": self.host if running else "",
376
+ "port": self.port if running else 0,
377
+ "endpoint": f"http://{self.host}:{self.port}{MCP_ENDPOINT_PATH}" if running else "",
378
+ "health_endpoint": f"http://{self.host}:{self.port}{MCP_HEALTH_PATH}" if running else "",
379
+ "write_enabled": self.write_enabled if running else False,
380
+ }
381
+
382
+
383
+ _RUNTIME = _Runtime()
384
+ atexit.register(_RUNTIME.stop)
385
+
386
+
387
+ def start_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, auth_token: str = "", write_enabled: bool = False) -> dict[str, Any]:
388
+ return _RUNTIME.start(host, port, auth_token, write_enabled)
389
+
390
+
391
+ def stop_server() -> None:
392
+ _RUNTIME.stop()
393
+
394
+
395
+ def server_status() -> dict[str, Any]:
396
+ return _RUNTIME.status()
397
+
398
+
399
+ def tool_definitions(write_enabled: bool = False) -> list[dict[str, Any]]:
400
+ return _tool_definitions(write_enabled)
@@ -21,6 +21,13 @@ DEFAULTS: dict[str, Any] = {
21
21
  "batches.json": [],
22
22
  "uploads.json": [],
23
23
  "metadata_edits.json": [],
24
+ "mcp_server.json": {
25
+ "enabled": False,
26
+ "host": "127.0.0.1",
27
+ "port": 3031,
28
+ "auth_token": "",
29
+ "write_enabled": False,
30
+ },
24
31
  "mcp_integrations.json": [
25
32
  {
26
33
  "id": "short-video-maker",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.26",
3
+ "version": "0.2.27",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "main": "scripts/cli.mjs",
6
6
  "type": "module",