stagebook-mcp 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +91 -0
- package/README.md +129 -2
- package/README.pt-BR.md +133 -0
- package/dist/client.js +277 -0
- package/dist/index.js +9 -0
- package/dist/server.js +399 -0
- package/package.json +50 -4
package/LICENSE.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Required Notice: Copyright Stagebook (https://stagebook.app.br)
|
|
2
|
+
|
|
3
|
+
# PolyForm Shield License 1.0.0
|
|
4
|
+
|
|
5
|
+
<https://polyformproject.org/licenses/shield/1.0.0>
|
|
6
|
+
|
|
7
|
+
## Acceptance
|
|
8
|
+
|
|
9
|
+
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
|
10
|
+
|
|
11
|
+
## Copyright License
|
|
12
|
+
|
|
13
|
+
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
|
14
|
+
|
|
15
|
+
## Distribution License
|
|
16
|
+
|
|
17
|
+
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
|
18
|
+
|
|
19
|
+
## Notices
|
|
20
|
+
|
|
21
|
+
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
|
22
|
+
|
|
23
|
+
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
24
|
+
|
|
25
|
+
## Changes and New Works License
|
|
26
|
+
|
|
27
|
+
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
|
28
|
+
|
|
29
|
+
## Patent License
|
|
30
|
+
|
|
31
|
+
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
|
32
|
+
|
|
33
|
+
## Noncompete
|
|
34
|
+
|
|
35
|
+
Any purpose is a permitted purpose, except for providing any product that competes with the software or any product the licensor or any of its affiliates provides using the software.
|
|
36
|
+
|
|
37
|
+
## Competition
|
|
38
|
+
|
|
39
|
+
Goods and services compete even when they provide functionality through different kinds of interfaces or for different technical platforms. Applications can compete with services, libraries with plugins, frameworks with development tools, and so on, even if they're written in different programming languages or for different computer architectures. Goods and services compete even when provided free of charge. If you market a product as a practical substitute for the software or another product, it definitely competes.
|
|
40
|
+
|
|
41
|
+
## New Products
|
|
42
|
+
|
|
43
|
+
If you are using the software to provide a product that does not compete, but the licensor or any of its affiliates brings your product into competition by providing a new version of the software or another product using the software, you may continue using versions of the software available under these terms beforehand to provide your competing product, but not any later versions.
|
|
44
|
+
|
|
45
|
+
## Discontinued Products
|
|
46
|
+
|
|
47
|
+
You may begin using the software to compete with a product or service that the licensor or any of its affiliates has stopped providing, unless the licensor includes a plain-text line beginning with `Licensor Line of Business:` with the software that mentions that line of business. For example:
|
|
48
|
+
|
|
49
|
+
> Licensor Line of Business: YoyodyneCMS Content Management System (http://example.com/cms)
|
|
50
|
+
|
|
51
|
+
## Sales of Business
|
|
52
|
+
|
|
53
|
+
If the licensor or any of its affiliates sells a line of business developing the software or using the software to provide a product, the buyer can also enforce [Noncompete](#noncompete) for that product.
|
|
54
|
+
|
|
55
|
+
## Fair Use
|
|
56
|
+
|
|
57
|
+
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
|
58
|
+
|
|
59
|
+
## No Other Rights
|
|
60
|
+
|
|
61
|
+
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
|
62
|
+
|
|
63
|
+
## Patent Defense
|
|
64
|
+
|
|
65
|
+
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
|
66
|
+
|
|
67
|
+
## Violations
|
|
68
|
+
|
|
69
|
+
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
|
70
|
+
|
|
71
|
+
## No Liability
|
|
72
|
+
|
|
73
|
+
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
|
74
|
+
|
|
75
|
+
## Definitions
|
|
76
|
+
|
|
77
|
+
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
|
78
|
+
|
|
79
|
+
A **product** can be a good or service, or a combination of them.
|
|
80
|
+
|
|
81
|
+
**You** refers to the individual or entity agreeing to these terms.
|
|
82
|
+
|
|
83
|
+
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all its affiliates.
|
|
84
|
+
|
|
85
|
+
**Affiliates** means the other organizations than an organization has control over, is under the control of, or is under common control with.
|
|
86
|
+
|
|
87
|
+
**Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
|
88
|
+
|
|
89
|
+
**Your licenses** are all the licenses granted to you for the software under these terms.
|
|
90
|
+
|
|
91
|
+
**Use** means anything you do with the software requiring one of your licenses.
|
package/README.md
CHANGED
|
@@ -1,3 +1,130 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Stagebook MCP
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**English** · [Português (Brasil)](README.pt-BR.md)
|
|
4
|
+
|
|
5
|
+
Lets AI assistants read and edit the songs and setlists on your Stagebook tablet: "build a 15-song setlist for Saturday
|
|
6
|
+
without repeating artists", "move every Skank song down a half tone", "check the chords of Hazard", "import this page".
|
|
7
|
+
It's a [Model Context Protocol](https://modelcontextprotocol.io) server that runs on your computer and talks to the
|
|
8
|
+
tablet over the local network, through the same connection as **Settings → Edit on the computer**. Nothing goes to the
|
|
9
|
+
internet, and the app itself doesn't change: what the assistant saves shows on the stage right away.
|
|
10
|
+
|
|
11
|
+
Works with Claude Code, Claude Desktop and any assistant that runs local MCP servers (Cursor, VS Code with Copilot,
|
|
12
|
+
Windsurf, Gemini CLI, OpenAI Codex CLI…).
|
|
13
|
+
|
|
14
|
+
## What it can do
|
|
15
|
+
|
|
16
|
+
| Area | Tools |
|
|
17
|
+
|---|---|
|
|
18
|
+
| Connection | `stagebook_connect` (finds the tablet and asks to come in), `stagebook_status` |
|
|
19
|
+
| Songs | `list_songs` (filters: text, tag, key), `get_song`, `create_song`, `update_song`, `delete_song`, `transpose_song`, `set_capo` |
|
|
20
|
+
| Import | `import_song` (pasted lyrics/chords in any common format, or a song page address) |
|
|
21
|
+
| Backing tracks | `set_backing_track` (OGG, MP3 or WAV from this computer), `remove_backing_track` |
|
|
22
|
+
| Setlists | `list_setlists`, `get_setlist`, `create_setlist`, `update_setlist`, `delete_setlist` |
|
|
23
|
+
| Receiving | `receive_by_code` (the 3 words), `receive_create`, `band_inbox`, `band_sync`, `open_band_item` |
|
|
24
|
+
| Backup | `restore_backup` (inspect a `.stagebook` file, add what's missing or replace the library) |
|
|
25
|
+
|
|
26
|
+
Also **resources** (`stagebook://songs`, `stagebook://setlists`, `stagebook://songs/{id}` as ChordPro) and **prompts**
|
|
27
|
+
(`build_setlist`, `fix_chords`, `transpose_for_singer`).
|
|
28
|
+
|
|
29
|
+
Deleting, removing a track, emptying a setlist, replacing the library and restoring settings (they replace the
|
|
30
|
+
tablet's stage look, pedals, USB audio interface and band) need `confirm: true`: the assistant has to ask you first.
|
|
31
|
+
|
|
32
|
+
Files to send (backing tracks, backups) take a full path; `~` is your home folder. The tablet takes tracks up to
|
|
33
|
+
200 MB and backups up to 1 GB. If the assistant gives up on a long call, the upload or restore stops too.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
Needs [Node.js](https://nodejs.org) 20 or newer.
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
npx -y stagebook-mcp
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
That's the server: your assistant starts it by itself with the settings below (`npx` downloads it the first time and
|
|
44
|
+
keeps it up to date), so there's nothing to run by hand.
|
|
45
|
+
|
|
46
|
+
## Connect your assistant
|
|
47
|
+
|
|
48
|
+
**Claude Code**
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
claude mcp add stagebook -- npx -y stagebook-mcp
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Claude Desktop** — Settings → Developer → Edit config (`claude_desktop_config.json`):
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{ "mcpServers": { "stagebook": { "command": "npx", "args": ["-y", "stagebook-mcp"] } } }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Cursor** (`~/.cursor/mcp.json`) and **Windsurf** (`~/.codeium/windsurf/mcp_config.json`): the same `mcpServers` block.
|
|
61
|
+
|
|
62
|
+
**VS Code** (`.vscode/mcp.json` in a project, or the user `mcp.json`):
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{ "servers": { "stagebook": { "type": "stdio", "command": "npx", "args": ["-y", "stagebook-mcp"] } } }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Gemini CLI** (`~/.gemini/settings.json`): the same `mcpServers` block as Claude Desktop.
|
|
69
|
+
|
|
70
|
+
**OpenAI Codex CLI** (`~/.codex/config.toml`):
|
|
71
|
+
|
|
72
|
+
```toml
|
|
73
|
+
[mcp_servers.stagebook]
|
|
74
|
+
command = "npx"
|
|
75
|
+
args = ["-y", "stagebook-mcp"]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
ChatGPT's own app only connects to remote servers (over the internet), so it can't reach the tablet on your Wi-Fi.
|
|
79
|
+
|
|
80
|
+
## First use
|
|
81
|
+
|
|
82
|
+
1. On the tablet: open Stagebook and turn on **Settings → Edit on the computer**. It shows the address
|
|
83
|
+
(e.g. `http://192.168.0.10:8090`). The computer and the tablet must be on the same Wi-Fi.
|
|
84
|
+
2. Ask the assistant to connect to Stagebook. It searches the network (or you give it the address) and the tablet shows
|
|
85
|
+
a request with a code. If more than one tablet with Stagebook answers (a band on the same Wi-Fi), it asks which
|
|
86
|
+
address is yours; if your tablet's address changed, it searches again.
|
|
87
|
+
3. If the code matches, tap **Allow** on the tablet. The key is saved on this computer
|
|
88
|
+
(`~/.config/stagebook-mcp/config.json`, readable only by you); next time it connects without asking.
|
|
89
|
+
|
|
90
|
+
The editor turns itself off on the tablet when unused: turn it on again when you want to use the assistant. The key
|
|
91
|
+
lasts 90 days without use; **Settings → Edit on the computer → Authorized computers** removes it at any time.
|
|
92
|
+
|
|
93
|
+
### Settings
|
|
94
|
+
|
|
95
|
+
| Variable | What for |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `STAGEBOOK_URL` | The tablet's address, skipping the search (e.g. `http://192.168.0.10:8090`). |
|
|
98
|
+
| `STAGEBOOK_KEY` | A key instead of the saved one. |
|
|
99
|
+
| `STAGEBOOK_CONFIG` | Another place for the saved address and key. |
|
|
100
|
+
|
|
101
|
+
## If it doesn't connect
|
|
102
|
+
|
|
103
|
+
- **"Stagebook wasn't found" / "Couldn't reach"**: the tablet's Stagebook must be open with **Settings → Edit on the
|
|
104
|
+
computer** on, on the same Wi-Fi as the computer (not a guest network). Pass the address shown there to the connect tool.
|
|
105
|
+
- **On a Mac, "macOS blocked the connection"**: macOS asks before an app talks to devices on the local network. Open
|
|
106
|
+
**System Settings → Privacy & Security → Local Network** and turn on the app that runs the assistant (Claude, Terminal,
|
|
107
|
+
iTerm, Cursor, VS Code…), then restart that app.
|
|
108
|
+
- **"No longer accepts this computer's key"**: it was removed on the tablet or went 90 days unused. Connect again.
|
|
109
|
+
- **"Already showing a request, or one was just denied"**: answer the request on the tablet; after a Deny it waits
|
|
110
|
+
5 minutes before taking a new one.
|
|
111
|
+
- **"Not found on the tablet, or that part is turned off"**: receiving by code needs the internet on, and the band
|
|
112
|
+
tools need the band on, in the tablet's Settings.
|
|
113
|
+
|
|
114
|
+
## Privacy
|
|
115
|
+
|
|
116
|
+
Everything stays on your network: the server talks only to the tablet. The assistant sees what its tools return
|
|
117
|
+
(titles, artists, chords and, when it asks for them, the lyrics) and sends that to its AI provider like the rest of your
|
|
118
|
+
conversation. Lyrics are copyrighted: the server tells the assistant not to reproduce them in full.
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
git clone https://github.com/julianoAffonso/stagebook.git
|
|
124
|
+
cd stagebook/mcp
|
|
125
|
+
npm install
|
|
126
|
+
npm test # builds and runs the tests against a fake tablet
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The tools are in `src/server.ts`; the calls to the tablet in `src/client.ts` (the same HTTP API as the computer editor's
|
|
130
|
+
page, in the app's `data/web/`).
|
package/README.pt-BR.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Stagebook MCP
|
|
2
|
+
|
|
3
|
+
[English](README.md) · **Português (Brasil)**
|
|
4
|
+
|
|
5
|
+
Deixa assistentes de IA lerem e editarem as músicas e os setlists do seu tablet com o Stagebook: "monta um setlist de
|
|
6
|
+
15 músicas para sábado sem repetir artista", "passa todas as do Skank meio tom abaixo", "confere os acordes de Hazard",
|
|
7
|
+
"importa esta página". É um servidor [Model Context Protocol](https://modelcontextprotocol.io) que roda no seu computador
|
|
8
|
+
e conversa com o tablet pela rede local, pela mesma conexão de **Ajustes → Editar no computador**. Nada vai para a
|
|
9
|
+
internet e o app não muda: o que o assistente salva aparece no palco na hora.
|
|
10
|
+
|
|
11
|
+
Funciona com o Claude Code, o Claude Desktop e qualquer assistente que roda servidores MCP locais (Cursor, VS Code com
|
|
12
|
+
Copilot, Windsurf, Gemini CLI, OpenAI Codex CLI…).
|
|
13
|
+
|
|
14
|
+
## O que ele faz
|
|
15
|
+
|
|
16
|
+
| Assunto | Ferramentas |
|
|
17
|
+
|---|---|
|
|
18
|
+
| Conexão | `stagebook_connect` (acha o tablet e pede para entrar), `stagebook_status` |
|
|
19
|
+
| Músicas | `list_songs` (filtros: texto, tag, tom), `get_song`, `create_song`, `update_song`, `delete_song`, `transpose_song`, `set_capo` |
|
|
20
|
+
| Importar | `import_song` (letra e cifra coladas em qualquer formato comum, ou o endereço de uma página) |
|
|
21
|
+
| Backing tracks | `set_backing_track` (OGG, MP3 ou WAV do computador), `remove_backing_track` |
|
|
22
|
+
| Setlists | `list_setlists`, `get_setlist`, `create_setlist`, `update_setlist`, `delete_setlist` |
|
|
23
|
+
| Receber | `receive_by_code` (as 3 palavras), `receive_create`, `band_inbox`, `band_sync`, `open_band_item` |
|
|
24
|
+
| Backup | `restore_backup` (ver o que um arquivo `.stagebook` tem, juntar o que falta ou substituir a biblioteca) |
|
|
25
|
+
|
|
26
|
+
Tem também **recursos** (`stagebook://songs`, `stagebook://setlists`, `stagebook://songs/{id}` em ChordPro) e **roteiros
|
|
27
|
+
prontos** (`build_setlist`, `fix_chords`, `transpose_for_singer`).
|
|
28
|
+
|
|
29
|
+
Apagar, tirar uma faixa, esvaziar um setlist, substituir a biblioteca e restaurar os ajustes (eles substituem o visual
|
|
30
|
+
do palco, os pedais, a interface de áudio USB e a banda do tablet) pedem `confirm: true`: o assistente precisa
|
|
31
|
+
perguntar a você antes.
|
|
32
|
+
|
|
33
|
+
Os arquivos a enviar (backing tracks, backups) vão com o caminho completo; `~` é a sua pasta pessoal. O tablet aceita
|
|
34
|
+
faixas de até 200 MB e backups de até 1 GB. Se o assistente desistir de uma chamada longa, o envio ou a restauração
|
|
35
|
+
param também.
|
|
36
|
+
|
|
37
|
+
## Instalar
|
|
38
|
+
|
|
39
|
+
Precisa do [Node.js](https://nodejs.org) 20 ou mais novo.
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
npx -y stagebook-mcp
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Esse é o servidor: o assistente abre ele sozinho com os ajustes abaixo (o `npx` baixa da primeira vez e mantém
|
|
46
|
+
atualizado), então não precisa rodar nada à mão.
|
|
47
|
+
|
|
48
|
+
## Ligar no seu assistente
|
|
49
|
+
|
|
50
|
+
**Claude Code**
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
claude mcp add stagebook -- npx -y stagebook-mcp
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Claude Desktop** — Configurações → Desenvolvedor → Editar configuração (`claude_desktop_config.json`):
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{ "mcpServers": { "stagebook": { "command": "npx", "args": ["-y", "stagebook-mcp"] } } }
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Cursor** (`~/.cursor/mcp.json`) e **Windsurf** (`~/.codeium/windsurf/mcp_config.json`): o mesmo bloco `mcpServers`.
|
|
63
|
+
|
|
64
|
+
**VS Code** (`.vscode/mcp.json` num projeto, ou o `mcp.json` do usuário):
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{ "servers": { "stagebook": { "type": "stdio", "command": "npx", "args": ["-y", "stagebook-mcp"] } } }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Gemini CLI** (`~/.gemini/settings.json`): o mesmo bloco `mcpServers` do Claude Desktop.
|
|
71
|
+
|
|
72
|
+
**OpenAI Codex CLI** (`~/.codex/config.toml`):
|
|
73
|
+
|
|
74
|
+
```toml
|
|
75
|
+
[mcp_servers.stagebook]
|
|
76
|
+
command = "npx"
|
|
77
|
+
args = ["-y", "stagebook-mcp"]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
O app do ChatGPT só se conecta a servidores remotos (pela internet), então não alcança o tablet no seu Wi-Fi.
|
|
81
|
+
|
|
82
|
+
## Primeira vez
|
|
83
|
+
|
|
84
|
+
1. No tablet: abra o Stagebook e ligue **Ajustes → Editar no computador**. Ele mostra o endereço
|
|
85
|
+
(ex.: `http://192.168.0.10:8090`). O computador e o tablet precisam estar no mesmo Wi-Fi.
|
|
86
|
+
2. Peça ao assistente para conectar no Stagebook. Ele procura na rede (ou você passa o endereço) e o tablet mostra um
|
|
87
|
+
pedido com um código. Se mais de um tablet com o Stagebook responder (uma banda no mesmo Wi-Fi), ele pergunta qual
|
|
88
|
+
endereço é o seu; se o endereço do seu tablet mudou, ele procura de novo.
|
|
89
|
+
3. Se o código bater, toque em **Permitir** no tablet. A chave fica guardada neste computador
|
|
90
|
+
(`~/.config/stagebook-mcp/config.json`, que só você lê); da próxima vez ele conecta sem perguntar.
|
|
91
|
+
|
|
92
|
+
O editor desliga sozinho no tablet quando fica sem uso: ligue de novo quando quiser usar o assistente. A chave dura 90
|
|
93
|
+
dias sem uso; **Ajustes → Editar no computador → Computadores autorizados** remove a qualquer hora.
|
|
94
|
+
|
|
95
|
+
### Ajustes
|
|
96
|
+
|
|
97
|
+
| Variável | Para quê |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `STAGEBOOK_URL` | O endereço do tablet, sem procurar (ex.: `http://192.168.0.10:8090`). |
|
|
100
|
+
| `STAGEBOOK_KEY` | Uma chave no lugar da guardada. |
|
|
101
|
+
| `STAGEBOOK_CONFIG` | Outro lugar para o endereço e a chave guardados. |
|
|
102
|
+
|
|
103
|
+
## Se não conectar
|
|
104
|
+
|
|
105
|
+
- **"Stagebook wasn't found" / "Couldn't reach"**: o Stagebook precisa estar aberto no tablet, com **Ajustes → Editar no
|
|
106
|
+
computador** ligado, no mesmo Wi-Fi do computador (não numa rede de convidados). Passe ao assistente o endereço que
|
|
107
|
+
aparece ali.
|
|
108
|
+
- **No Mac, "macOS blocked the connection"**: o macOS pergunta antes de um app conversar com aparelhos da rede local.
|
|
109
|
+
Abra **Ajustes do Sistema → Privacidade e Segurança → Rede Local** e ligue o app que roda o assistente (Claude,
|
|
110
|
+
Terminal, iTerm, Cursor, VS Code…). Depois reabra esse app.
|
|
111
|
+
- **"No longer accepts this computer's key"**: a chave foi removida no tablet ou passou 90 dias sem uso. Conecte de novo.
|
|
112
|
+
- **"Already showing a request, or one was just denied"**: responda o pedido no tablet; depois de um Negar, ele espera
|
|
113
|
+
5 minutos para aceitar outro.
|
|
114
|
+
- **"Not found on the tablet, or that part is turned off"**: receber por código precisa da internet ligada, e as
|
|
115
|
+
ferramentas de banda precisam da banda ligada, nos Ajustes do tablet.
|
|
116
|
+
|
|
117
|
+
## Privacidade
|
|
118
|
+
|
|
119
|
+
Tudo fica na sua rede: o servidor só conversa com o tablet. O assistente vê o que as ferramentas devolvem (títulos,
|
|
120
|
+
artistas, acordes e, quando ele pede, a letra) e manda isso para o provedor de IA dele, como o resto da conversa. Letras
|
|
121
|
+
têm direito autoral: o servidor orienta o assistente a não reproduzi-las inteiras.
|
|
122
|
+
|
|
123
|
+
## Para quem programa
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
git clone https://github.com/julianoAffonso/stagebook.git
|
|
127
|
+
cd stagebook/mcp
|
|
128
|
+
npm install
|
|
129
|
+
npm test # compila e roda os testes contra um tablet de mentira
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
As ferramentas ficam em `src/server.ts`; as chamadas ao tablet em `src/client.ts` (a mesma API HTTP da página do editor
|
|
133
|
+
no computador, em `data/web/` no app).
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Talks to Stagebook's "Edit on the computer" server on the tablet: the same HTTP API its web page uses.
|
|
2
|
+
// Every call carries the X-Stagebook header and, once approved on the tablet, the stagebook_key cookie.
|
|
3
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { networkInterfaces, homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
export const COOKIE = "stagebook_key";
|
|
7
|
+
const PORTS = [8090, 8091, 8092];
|
|
8
|
+
/** Where the tablet's address and key are kept between sessions (or STAGEBOOK_CONFIG). */
|
|
9
|
+
export function configPath() {
|
|
10
|
+
return process.env.STAGEBOOK_CONFIG ?? join(homedir(), ".config", "stagebook-mcp", "config.json");
|
|
11
|
+
}
|
|
12
|
+
export async function loadSaved() {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(await readFile(configPath(), "utf8"));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function save(s) {
|
|
21
|
+
await mkdir(dirname(configPath()), { recursive: true });
|
|
22
|
+
// the key gives access to the library: readable by this user only. The mode only applies when the file is created,
|
|
23
|
+
// so an existing one (or one at STAGEBOOK_CONFIG) is set again (audit 03/11)
|
|
24
|
+
await writeFile(configPath(), JSON.stringify(s, null, 2), { mode: 0o600 });
|
|
25
|
+
await chmod(configPath(), 0o600);
|
|
26
|
+
}
|
|
27
|
+
/** A problem the assistant should explain to the person, with what to do. */
|
|
28
|
+
export class StagebookError extends Error {
|
|
29
|
+
status;
|
|
30
|
+
constructor(message, status) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.status = status;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export class StagebookClient {
|
|
36
|
+
url;
|
|
37
|
+
key;
|
|
38
|
+
timeoutMs;
|
|
39
|
+
constructor(url, key, timeoutMs = 15000) {
|
|
40
|
+
this.url = url;
|
|
41
|
+
this.key = key;
|
|
42
|
+
this.timeoutMs = timeoutMs;
|
|
43
|
+
}
|
|
44
|
+
static async fromEnv() {
|
|
45
|
+
const saved = await loadSaved();
|
|
46
|
+
return new StagebookClient(process.env.STAGEBOOK_URL ?? saved.url, process.env.STAGEBOOK_KEY ?? saved.key);
|
|
47
|
+
}
|
|
48
|
+
base() {
|
|
49
|
+
if (!this.url) {
|
|
50
|
+
throw new StagebookError("Stagebook's address isn't known yet. Open Stagebook on the tablet, turn on Settings → Edit on the computer, " +
|
|
51
|
+
"then use the connect tool (it finds the tablet on the network) or set STAGEBOOK_URL (e.g. http://192.168.0.10:8090).");
|
|
52
|
+
}
|
|
53
|
+
return this.url.replace(/\/+$/, "");
|
|
54
|
+
}
|
|
55
|
+
/** One API call. [body]: JSON object, raw bytes (uploads) or nothing. */
|
|
56
|
+
async call(method, path, body, opts = {}) {
|
|
57
|
+
const url = new URL(this.base() + path);
|
|
58
|
+
for (const [k, v] of Object.entries(opts.query ?? {}))
|
|
59
|
+
url.searchParams.set(k, v);
|
|
60
|
+
const headers = { "X-Stagebook": "1" };
|
|
61
|
+
if (opts.auth !== false) {
|
|
62
|
+
await this.awaitApproval();
|
|
63
|
+
if (!this.key)
|
|
64
|
+
throw new StagebookError("This computer isn't approved on the tablet yet: use the connect tool first.");
|
|
65
|
+
headers.Cookie = `${COOKIE}=${this.key}`;
|
|
66
|
+
}
|
|
67
|
+
let payload;
|
|
68
|
+
if (body instanceof Uint8Array) {
|
|
69
|
+
payload = body;
|
|
70
|
+
headers["Content-Type"] = "application/octet-stream";
|
|
71
|
+
}
|
|
72
|
+
else if (body !== undefined) {
|
|
73
|
+
payload = JSON.stringify(body);
|
|
74
|
+
headers["Content-Type"] = "application/json";
|
|
75
|
+
}
|
|
76
|
+
let res;
|
|
77
|
+
try {
|
|
78
|
+
const limit = AbortSignal.timeout(opts.timeoutMs ?? this.timeoutMs);
|
|
79
|
+
res = await fetch(url, { method, headers, body: payload, signal: opts.signal ? AbortSignal.any([limit, opts.signal]) : limit });
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
if (opts.signal?.aborted)
|
|
83
|
+
throw new StagebookError("The call was cancelled.");
|
|
84
|
+
throw new StagebookError(unreachable(this.base(), e));
|
|
85
|
+
}
|
|
86
|
+
const text = await res.text();
|
|
87
|
+
let json = undefined;
|
|
88
|
+
if (text) {
|
|
89
|
+
try {
|
|
90
|
+
json = JSON.parse(text);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
json = text;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (res.status === 401) {
|
|
97
|
+
// the dead key goes: kept, the next call (after connecting again) sent it instead of waiting for Allow (audit 03/11)
|
|
98
|
+
if (opts.auth !== false)
|
|
99
|
+
this.key = undefined;
|
|
100
|
+
throw new StagebookError("The tablet no longer accepts this computer's key (removed, or 90 days unused): use the connect tool again.", 401);
|
|
101
|
+
}
|
|
102
|
+
if (res.status === 403)
|
|
103
|
+
throw new StagebookError("The tablet refused the call (forbidden).", 403);
|
|
104
|
+
// the tablet answers 429 to a new request while one is on its screen, and for 5 minutes after a Deny (this
|
|
105
|
+
// check sat in startPair, after the generic error had already been thrown: "HTTP 429"; audit 03/11)
|
|
106
|
+
if (res.status === 429) {
|
|
107
|
+
throw new StagebookError("The tablet is already showing a request, or one was just denied (it then waits 5 minutes): answer it there, or wait and connect again.", 429);
|
|
108
|
+
}
|
|
109
|
+
if (res.status >= 400 && res.status !== 404) {
|
|
110
|
+
const why = json && typeof json === "object" && json.error ? json.error : `HTTP ${res.status}`;
|
|
111
|
+
throw new StagebookError(problemText(why), res.status);
|
|
112
|
+
}
|
|
113
|
+
return { status: res.status, json, headers: res.headers };
|
|
114
|
+
}
|
|
115
|
+
async get(path) {
|
|
116
|
+
return this.send("GET", path);
|
|
117
|
+
}
|
|
118
|
+
async send(method, path, body, query, timeoutMs, signal) {
|
|
119
|
+
const r = await this.call(method, path, body, { query, timeoutMs, signal });
|
|
120
|
+
// the tablet answers 404 also for what is turned off there (internet, band): "not found" alone misled (audit 03/11)
|
|
121
|
+
if (r.status === 404)
|
|
122
|
+
throw new StagebookError("Not found on the tablet, or that part is turned off there (the internet or the band, in Settings).", 404);
|
|
123
|
+
return r.json;
|
|
124
|
+
}
|
|
125
|
+
/** The approval being waited for (pairing runs past the tool call: assistants give up on a call after ~60 s). */
|
|
126
|
+
pending;
|
|
127
|
+
pendingId;
|
|
128
|
+
/**
|
|
129
|
+
* Asks the tablet to let this computer in: Stagebook shows a request with a code. Returns the code at once; the wait
|
|
130
|
+
* for the person to tap Allow goes on in [pending] (up to [waitMs], the 2 minutes the tablet keeps a request), which
|
|
131
|
+
* saves the key when it comes.
|
|
132
|
+
*/
|
|
133
|
+
async startPair(name, waitMs = 120000) {
|
|
134
|
+
// a new request replaces whatever key was loaded (another tablet, or one the tablet no longer accepts): the next
|
|
135
|
+
// call waits for this approval instead of sending the old key (audit 03/11)
|
|
136
|
+
this.key = undefined;
|
|
137
|
+
const asked = await this.call("POST", "/api/ask", { name }, { auth: false });
|
|
138
|
+
const { id, code } = asked.json ?? {};
|
|
139
|
+
if (!id)
|
|
140
|
+
throw new StagebookError("The tablet didn't accept the request.");
|
|
141
|
+
// connecting twice gets the same request back: one wait for it (two polled it, and the tablet hands the answer
|
|
142
|
+
// out once, so the other said "expired" even after Allow; audit 03/11)
|
|
143
|
+
if (this.pending && this.pendingId === String(id))
|
|
144
|
+
return String(code ?? "");
|
|
145
|
+
this.pendingId = String(id);
|
|
146
|
+
const p = this.waitApproval(String(id), waitMs).finally(() => {
|
|
147
|
+
if (this.pending === p) {
|
|
148
|
+
this.pending = undefined;
|
|
149
|
+
this.pendingId = undefined;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
this.pending = p;
|
|
153
|
+
p.catch(() => { }); // a refusal is reported by the next call that waits on it
|
|
154
|
+
return String(code ?? "");
|
|
155
|
+
}
|
|
156
|
+
async waitApproval(id, waitMs) {
|
|
157
|
+
const until = Date.now() + waitMs;
|
|
158
|
+
while (Date.now() < until) {
|
|
159
|
+
let r;
|
|
160
|
+
try {
|
|
161
|
+
r = await this.call("GET", "/api/ask", undefined, { auth: false, query: { id } });
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
if (e instanceof StagebookError && e.status === 403)
|
|
165
|
+
throw new StagebookError("The request was denied on the tablet.");
|
|
166
|
+
throw e;
|
|
167
|
+
}
|
|
168
|
+
if (r.status === 200) {
|
|
169
|
+
const key = cookieValue(r.headers.get("set-cookie"));
|
|
170
|
+
if (!key)
|
|
171
|
+
throw new StagebookError("Approved, but the tablet sent no key.");
|
|
172
|
+
this.key = key;
|
|
173
|
+
await save({ url: this.url, key });
|
|
174
|
+
return key;
|
|
175
|
+
}
|
|
176
|
+
if (r.status === 404)
|
|
177
|
+
throw new StagebookError("The request expired on the tablet: connect again.");
|
|
178
|
+
await new Promise((ok) => setTimeout(ok, 1500));
|
|
179
|
+
}
|
|
180
|
+
throw new StagebookError("Nobody tapped Allow on the tablet in 2 minutes: connect again.");
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Waits a while for a pending approval before a call needs the key, within one tool call's time: 40 s plus the
|
|
184
|
+
* call's own 15 s stays under the ~60 s an assistant gives a tool.
|
|
185
|
+
*/
|
|
186
|
+
async awaitApproval(maxMs = 40000) {
|
|
187
|
+
if (this.key || !this.pending)
|
|
188
|
+
return;
|
|
189
|
+
let timer;
|
|
190
|
+
const timeout = new Promise((ok) => { timer = setTimeout(ok, maxMs); });
|
|
191
|
+
try {
|
|
192
|
+
await Promise.race([this.pending.then(() => undefined), timeout]);
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
clearTimeout(timer); // otherwise it held the process 40 s after the approval came
|
|
196
|
+
}
|
|
197
|
+
if (!this.key)
|
|
198
|
+
throw new StagebookError("Still waiting for Allow on the tablet: tap it there, then try again.");
|
|
199
|
+
}
|
|
200
|
+
/** Pairs and waits for the approval (tests and scripts; tools use startPair). */
|
|
201
|
+
async pair(name, onCode, waitMs = 120000) {
|
|
202
|
+
onCode(await this.startPair(name, waitMs));
|
|
203
|
+
return this.pending;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Why the tablet couldn't be reached, in words. EHOSTUNREACH while the tablet answers a browser is usually macOS's
|
|
208
|
+
* Local Network privacy blocking the program that runs this server (Node), not the network.
|
|
209
|
+
*/
|
|
210
|
+
export function unreachable(base, e) {
|
|
211
|
+
const code = e?.cause?.code;
|
|
212
|
+
if (code === "EHOSTUNREACH" && process.platform === "darwin") {
|
|
213
|
+
return `macOS blocked the connection to ${base} (Local Network privacy). Open System Settings → Privacy & Security → ` +
|
|
214
|
+
"Local Network and turn on the app that runs this assistant (Claude, Terminal, Cursor, VS Code…), then restart it.";
|
|
215
|
+
}
|
|
216
|
+
return `Couldn't reach Stagebook at ${base}. Check that the tablet is on the same Wi-Fi, Stagebook is open and ` +
|
|
217
|
+
"Settings → Edit on the computer is on (it turns itself off when unused).";
|
|
218
|
+
}
|
|
219
|
+
/** The stagebook_key value in a Set-Cookie header. */
|
|
220
|
+
export function cookieValue(setCookie) {
|
|
221
|
+
const m = setCookie?.match(new RegExp(`${COOKIE}=([^;]+)`));
|
|
222
|
+
return m?.[1];
|
|
223
|
+
}
|
|
224
|
+
/** The app's error codes, in words an assistant can pass on. */
|
|
225
|
+
export function problemText(code) {
|
|
226
|
+
const known = {
|
|
227
|
+
title_empty: "The song needs a title.",
|
|
228
|
+
title_taken: "There's already a song with this title and artist.",
|
|
229
|
+
not_song: "That doesn't look like lyrics or chords (Stagebook keeps only song text).",
|
|
230
|
+
offline: "Stagebook has the internet or the band turned off (essential mode), or there's no connection.",
|
|
231
|
+
bad_address: "The address must start with http:// or https://.",
|
|
232
|
+
unreadable: "Couldn't read that page or file.",
|
|
233
|
+
not_found: "Not found.",
|
|
234
|
+
search_page: "That's a search page, not a song page: pass the song's own address.",
|
|
235
|
+
expired: "That expired: start again.",
|
|
236
|
+
format: "That file isn't an audio format Stagebook plays (OGG, MP3 or WAV).",
|
|
237
|
+
};
|
|
238
|
+
return known[code] ?? `The tablet answered: ${code}`;
|
|
239
|
+
}
|
|
240
|
+
/** Does Stagebook's editor answer at [base]? (Its page's strings: the editor is on and it is Stagebook.) */
|
|
241
|
+
export async function answers(base, timeoutMs = 600) {
|
|
242
|
+
try {
|
|
243
|
+
const res = await fetch(base.replace(/\/+$/, "") + "/api/strings", { headers: { "X-Stagebook": "1" }, signal: AbortSignal.timeout(timeoutMs) });
|
|
244
|
+
return res.ok && (await res.text()).includes("{");
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Looks for Stagebook on the local networks (every address of each /24 this computer is on, ports 8090-8092): the
|
|
252
|
+
* editor answers its page there while it's on. Returns every tablet that answers: on a band's Wi-Fi there can be
|
|
253
|
+
* several, and the request must not land on a bandmate's tablet (audit 03/11).
|
|
254
|
+
*/
|
|
255
|
+
export async function discoverAll(timeoutMs = 600) {
|
|
256
|
+
const prefixes = new Set();
|
|
257
|
+
for (const list of Object.values(networkInterfaces())) {
|
|
258
|
+
for (const a of list ?? []) {
|
|
259
|
+
if (a.family === "IPv4" && !a.internal && /^(10|172|192)\./.test(a.address))
|
|
260
|
+
prefixes.add(a.address.split(".").slice(0, 3).join("."));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const candidates = [];
|
|
264
|
+
for (const p of prefixes)
|
|
265
|
+
for (let i = 1; i < 255; i++)
|
|
266
|
+
for (const port of PORTS)
|
|
267
|
+
candidates.push(`http://${p}.${i}:${port}`);
|
|
268
|
+
const found = [];
|
|
269
|
+
// in batches, so a whole /24 doesn't open hundreds of sockets at once
|
|
270
|
+
for (let i = 0; i < candidates.length; i += 128) {
|
|
271
|
+
const batch = candidates.slice(i, i + 128);
|
|
272
|
+
const ok = await Promise.all(batch.map((u) => answers(u, timeoutMs)));
|
|
273
|
+
batch.forEach((u, k) => { if (ok[k])
|
|
274
|
+
found.push(u); });
|
|
275
|
+
}
|
|
276
|
+
return found;
|
|
277
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Stagebook MCP server over stdio: what Claude Code, Claude Desktop, Cursor, VS Code, Gemini CLI, Codex and other MCP
|
|
3
|
+
// clients start as a local command.
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { StagebookClient } from "./client.js";
|
|
6
|
+
import { buildServer } from "./server.js";
|
|
7
|
+
const client = await StagebookClient.fromEnv();
|
|
8
|
+
const server = buildServer(client);
|
|
9
|
+
await server.connect(new StdioServerTransport());
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// The MCP side: tools, resources and prompts over the tablet's API (client.ts). Every write goes through the same rules
|
|
2
|
+
// as the app's editor (the tablet applies them), and the stage on the tablet follows right away.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { readFile, stat } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
7
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { StagebookError, answers, discoverAll, save } from "./client.js";
|
|
10
|
+
// the one in package.json (it ships beside dist/), so the two never disagree
|
|
11
|
+
export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
12
|
+
const text = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
|
|
13
|
+
// every tool answers a StagebookError as a readable message instead of failing the call. [signal]: the assistant gave up
|
|
14
|
+
// on the call (it waits ~60 s); long calls pass it on, so an upload or a restore stops instead of finishing unseen
|
|
15
|
+
// after the assistant was told it failed (audit 03/11)
|
|
16
|
+
function safe(run) {
|
|
17
|
+
return async (args, extra) => {
|
|
18
|
+
try {
|
|
19
|
+
return await run(args, extra?.signal);
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
const msg = e instanceof StagebookError ? e.message : `Unexpected error: ${e.message}`;
|
|
23
|
+
return { ...text(msg), isError: true };
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** A song for the assistant: without the lyrics unless asked (they are often long). */
|
|
28
|
+
function songOut(s, withText) {
|
|
29
|
+
const { text: lyrics, ...rest } = s;
|
|
30
|
+
return withText ? s : { ...rest, lines: lyrics.split("\n").length, chords: chordsIn(lyrics) };
|
|
31
|
+
}
|
|
32
|
+
/** The distinct chords of a ChordPro text, in order of appearance. */
|
|
33
|
+
export function chordsIn(chordpro) {
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
for (const m of chordpro.matchAll(/\[([A-G][^\[\]\s]*)\]/g))
|
|
36
|
+
seen.add(m[1]);
|
|
37
|
+
return [...seen];
|
|
38
|
+
}
|
|
39
|
+
// the tablet's limits (WebEditorServer): a backing track as in the app, a backup with every track
|
|
40
|
+
const TRACK_MAX = 200 * 1024 * 1024;
|
|
41
|
+
const BACKUP_MAX = 1024 * 1024 * 1024;
|
|
42
|
+
/**
|
|
43
|
+
* A file on this computer to send: `~` is the home folder, a relative path is refused (it would resolve against the
|
|
44
|
+
* assistant app's folder, not the person's), and one over the tablet's limit is refused here (the tablet closed the
|
|
45
|
+
* connection, which read as "Couldn't reach"). Read once: the copy into a new array held a backup twice (audit 03/11).
|
|
46
|
+
*/
|
|
47
|
+
export async function localFile(path, max) {
|
|
48
|
+
const full = path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
|
|
49
|
+
if (!isAbsolute(full))
|
|
50
|
+
throw new StagebookError(`Pass the file's full path (like /Users/name/Music/song.mp3 or ~/Music/song.mp3), not "${path}".`);
|
|
51
|
+
let size;
|
|
52
|
+
try {
|
|
53
|
+
size = (await stat(full)).size;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new StagebookError(`There's no file at ${full}.`);
|
|
57
|
+
}
|
|
58
|
+
if (size > max)
|
|
59
|
+
throw new StagebookError(`The file is ${Math.round(size / 1048576)} MB; the tablet takes up to ${Math.round(max / 1048576)} MB.`);
|
|
60
|
+
return readFile(full);
|
|
61
|
+
}
|
|
62
|
+
const norm = (s) => s.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
|
|
63
|
+
export function buildServer(client) {
|
|
64
|
+
const server = new McpServer({ name: "stagebook", version: VERSION }, {
|
|
65
|
+
instructions: "Stagebook is an Android app for lyrics, chords (ChordPro), setlists and backing tracks used live by musicians. " +
|
|
66
|
+
"These tools edit the library on the person's tablet over the local network; the stage updates right away. " +
|
|
67
|
+
"First use: call stagebook_connect (the person approves on the tablet). Song text is ChordPro: chords in brackets " +
|
|
68
|
+
"before the syllable ([G]Here I [D]go), sections as [Verse 1]/[Chorus] lines (Portuguese names work too) or " +
|
|
69
|
+
"{start_of_chorus}; key, capo and " +
|
|
70
|
+
"BPM are separate fields. Ask before deleting anything. Song lyrics are copyrighted: don't reproduce them in full " +
|
|
71
|
+
"to the person unless they ask for their own song.",
|
|
72
|
+
});
|
|
73
|
+
/**
|
|
74
|
+
* Transpose or capo: the tablet reworks the song's text with its own rules ([route]), then the song is saved with
|
|
75
|
+
* the new text, key and capo (the two tools were the same three calls).
|
|
76
|
+
*/
|
|
77
|
+
async function rework(id, route, change) {
|
|
78
|
+
const cur = (await client.get(`/api/songs/${encodeURIComponent(id)}`));
|
|
79
|
+
const out = await client.send("POST", route, { text: cur.text, ...change, meta: { key: cur.key, capo: cur.capo } });
|
|
80
|
+
const key = out.meta?.key ?? cur.key;
|
|
81
|
+
const capo = out.meta?.capo ?? change.capo ?? cur.capo;
|
|
82
|
+
await client.send("PUT", `/api/songs/${encodeURIComponent(id)}`, { ...cur, text: out.text, key, capo });
|
|
83
|
+
return { cur, key: out.meta?.key, capo: out.meta?.capo };
|
|
84
|
+
}
|
|
85
|
+
// ---------- connection ----------
|
|
86
|
+
server.registerTool("stagebook_connect", {
|
|
87
|
+
title: "Connect to the Stagebook tablet",
|
|
88
|
+
description: "Finds Stagebook on the local network (or uses the given address) and asks the tablet to let this computer in. " +
|
|
89
|
+
"The tablet shows a request with a code: this tool returns at once with the code; tell the person and ask them to tap " +
|
|
90
|
+
"Allow on the tablet, then call stagebook_status, which waits for it. Needs Stagebook open with Settings → Edit on " +
|
|
91
|
+
"the computer turned on. The key is saved for next time.",
|
|
92
|
+
inputSchema: {
|
|
93
|
+
url: z.string().optional().describe("Address shown in Stagebook, e.g. http://192.168.0.10:8090. Omit to search the network."),
|
|
94
|
+
name: z.string().optional().describe("How this computer shows on the tablet (default: 'AI assistant')."),
|
|
95
|
+
},
|
|
96
|
+
}, safe(async ({ url, name }) => {
|
|
97
|
+
if (url)
|
|
98
|
+
client.url = url;
|
|
99
|
+
// no address given: the saved one if it still answers, otherwise search again (the tablet's address can change
|
|
100
|
+
// with the Wi-Fi; the saved one was used forever, audit 03/11)
|
|
101
|
+
else if (!client.url || !(await answers(client.url))) {
|
|
102
|
+
const found = await discoverAll();
|
|
103
|
+
if (found.length === 0) {
|
|
104
|
+
throw new StagebookError("Stagebook wasn't found on this network. On the tablet: open Stagebook and turn on Settings → Edit on the computer; " +
|
|
105
|
+
"it shows the address to pass here. On a Mac, also check System Settings → Privacy & Security → Local Network " +
|
|
106
|
+
"for the app that runs this assistant.");
|
|
107
|
+
}
|
|
108
|
+
if (found.length > 1) {
|
|
109
|
+
throw new StagebookError(`Stagebook answers on more than one tablet here: ${found.join(", ")}. Ask the person which address their tablet ` +
|
|
110
|
+
"shows in Settings → Edit on the computer and pass it as url.");
|
|
111
|
+
}
|
|
112
|
+
client.url = found[0];
|
|
113
|
+
}
|
|
114
|
+
await save({ url: client.url, key: client.key });
|
|
115
|
+
const code = await client.startPair(name ?? "AI assistant");
|
|
116
|
+
return text(`Stagebook found at ${client.url}. The tablet now shows a request with the code ${code}: ask the person to check it ` +
|
|
117
|
+
"and tap Allow on the tablet. Then call stagebook_status (it waits for the approval); the key is saved for next time.");
|
|
118
|
+
}));
|
|
119
|
+
server.registerTool("stagebook_status", {
|
|
120
|
+
title: "Stagebook connection status",
|
|
121
|
+
description: "Checks the connection: the tablet's address, its name and how this computer is known there.",
|
|
122
|
+
inputSchema: {},
|
|
123
|
+
annotations: { readOnlyHint: true },
|
|
124
|
+
}, safe(async () => {
|
|
125
|
+
const s = await client.get("/api/session");
|
|
126
|
+
return text({ url: client.url, device: s?.app, thisComputer: s?.name });
|
|
127
|
+
}));
|
|
128
|
+
// ---------- songs ----------
|
|
129
|
+
server.registerTool("list_songs", {
|
|
130
|
+
title: "List songs",
|
|
131
|
+
description: "The library's songs (id, title, artist, year, key, tags), in the app's order. Optional filters.",
|
|
132
|
+
inputSchema: {
|
|
133
|
+
query: z.string().optional().describe("Part of the title or artist (accents and case ignored)."),
|
|
134
|
+
tag: z.string().optional().describe("Only songs with this tag."),
|
|
135
|
+
key: z.string().optional().describe("Only songs in this key, e.g. G or Am."),
|
|
136
|
+
},
|
|
137
|
+
annotations: { readOnlyHint: true },
|
|
138
|
+
}, safe(async ({ query, tag, key }) => {
|
|
139
|
+
let songs = (await client.get("/api/songs"));
|
|
140
|
+
if (query)
|
|
141
|
+
songs = songs.filter((s) => norm(`${s.title} ${s.author}`).includes(norm(query)));
|
|
142
|
+
if (tag)
|
|
143
|
+
songs = songs.filter((s) => s.tags.some((t) => norm(t) === norm(tag)));
|
|
144
|
+
if (key)
|
|
145
|
+
songs = songs.filter((s) => s.key.toLowerCase() === key.toLowerCase());
|
|
146
|
+
return text({ count: songs.length, songs });
|
|
147
|
+
}));
|
|
148
|
+
server.registerTool("get_song", {
|
|
149
|
+
title: "Get a song",
|
|
150
|
+
description: "One song's details: title, artist, year, BPM, key, capo, tags, backing track and its chords. With include_text, the full ChordPro text.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
id: z.string(),
|
|
153
|
+
include_text: z.boolean().optional().describe("Also return the lyrics and chords (ChordPro). Default false."),
|
|
154
|
+
},
|
|
155
|
+
annotations: { readOnlyHint: true },
|
|
156
|
+
}, safe(async ({ id, include_text }) => text(songOut((await client.get(`/api/songs/${encodeURIComponent(id)}`)), !!include_text))));
|
|
157
|
+
const songFields = {
|
|
158
|
+
title: z.string().optional(),
|
|
159
|
+
author: z.string().optional().describe("Artist."),
|
|
160
|
+
year: z.string().optional(),
|
|
161
|
+
tempo: z.string().optional().describe("BPM, e.g. '120'."),
|
|
162
|
+
text: z.string().optional().describe("Lyrics and chords in ChordPro (chords above the lyrics are converted too)."),
|
|
163
|
+
key: z.string().optional().describe("The song's key, e.g. G."),
|
|
164
|
+
capo: z.number().int().min(0).max(12).optional(),
|
|
165
|
+
tags: z.array(z.string()).optional(),
|
|
166
|
+
};
|
|
167
|
+
server.registerTool("create_song", {
|
|
168
|
+
title: "Create a song",
|
|
169
|
+
description: "Adds a song to the library. Title and text are required; the tablet refuses a repeated title+artist or text that isn't a song.",
|
|
170
|
+
inputSchema: { ...songFields, title: z.string(), text: z.string() },
|
|
171
|
+
}, safe(async (a) => {
|
|
172
|
+
const r = await client.send("POST", "/api/songs", { title: a.title, author: a.author ?? "", year: a.year ?? "", tempo: a.tempo ?? "", text: a.text, key: a.key ?? "", capo: a.capo ?? 0, tags: a.tags ?? [] });
|
|
173
|
+
return text({ created: r?.id });
|
|
174
|
+
}));
|
|
175
|
+
server.registerTool("update_song", {
|
|
176
|
+
title: "Update a song",
|
|
177
|
+
description: "Changes some fields of a song; the others stay as they are.",
|
|
178
|
+
inputSchema: { id: z.string(), ...songFields },
|
|
179
|
+
}, safe(async ({ id, ...changes }) => {
|
|
180
|
+
const cur = (await client.get(`/api/songs/${encodeURIComponent(id)}`));
|
|
181
|
+
const next = { ...cur, ...Object.fromEntries(Object.entries(changes).filter(([, v]) => v !== undefined)) };
|
|
182
|
+
await client.send("PUT", `/api/songs/${encodeURIComponent(id)}`, next);
|
|
183
|
+
return text({ updated: id });
|
|
184
|
+
}));
|
|
185
|
+
server.registerTool("delete_song", {
|
|
186
|
+
title: "Delete a song",
|
|
187
|
+
description: "Deletes a song from the library (and its backing track). It can't be undone: ask the person first and pass confirm=true.",
|
|
188
|
+
inputSchema: { id: z.string(), confirm: z.literal(true).describe("Must be true: the person agreed to delete it.") },
|
|
189
|
+
annotations: { destructiveHint: true },
|
|
190
|
+
}, safe(async ({ id }) => {
|
|
191
|
+
await client.send("DELETE", `/api/songs/${encodeURIComponent(id)}`);
|
|
192
|
+
return text({ deleted: id });
|
|
193
|
+
}));
|
|
194
|
+
server.registerTool("transpose_song", {
|
|
195
|
+
title: "Transpose a song",
|
|
196
|
+
description: "Moves every chord of a song up or down by semitones (and the key with it), then saves.",
|
|
197
|
+
inputSchema: { id: z.string(), semitones: z.number().int().min(-11).max(11).describe("+2 = up a whole tone, -1 = down a half tone.") },
|
|
198
|
+
}, safe(async ({ id, semitones }) => {
|
|
199
|
+
const { cur, key } = await rework(id, "/api/transpose", { semitones });
|
|
200
|
+
return text({ id, from: cur.key, to: key });
|
|
201
|
+
}));
|
|
202
|
+
server.registerTool("set_capo", {
|
|
203
|
+
title: "Set a song's capo",
|
|
204
|
+
description: "Puts the capo on a fret (0 = none). Like the app: the chord shapes stay, and the song sounds higher.",
|
|
205
|
+
inputSchema: { id: z.string(), capo: z.number().int().min(0).max(12) },
|
|
206
|
+
}, safe(async ({ id, capo }) => {
|
|
207
|
+
const r = await rework(id, "/api/capo", { capo });
|
|
208
|
+
return text({ id, capo: r.capo ?? capo, key: r.key });
|
|
209
|
+
}));
|
|
210
|
+
server.registerTool("import_song", {
|
|
211
|
+
title: "Import a song",
|
|
212
|
+
description: "Turns pasted lyrics/chords (any common format, chords above the lyrics included) or a song page address into a " +
|
|
213
|
+
"Stagebook song, with the app's own importer. With save=true it's added to the library; otherwise it's returned to review.",
|
|
214
|
+
inputSchema: {
|
|
215
|
+
text: z.string().optional().describe("Pasted lyrics and chords."),
|
|
216
|
+
url: z.string().optional().describe("Address of a song page (needs the tablet's internet on)."),
|
|
217
|
+
title: z.string().optional().describe("Title when pasting text without one."),
|
|
218
|
+
with_tab: z.boolean().optional().describe("Keep tablature when the song has it (otherwise the tool asks)."),
|
|
219
|
+
save: z.boolean().optional().describe("Add it to the library right away. Default false."),
|
|
220
|
+
},
|
|
221
|
+
}, safe(async ({ text: pasted, url, title, with_tab, save: keep }, signal) => {
|
|
222
|
+
if (!pasted && !url)
|
|
223
|
+
throw new StagebookError("Pass the text or the page address.");
|
|
224
|
+
const r = await client.send("POST", "/api/import", { text: pasted ?? "", url: url ?? "", title: title ?? "", withTab: with_tab ?? null }, undefined, 60000, signal);
|
|
225
|
+
if (r?.status === "askTab")
|
|
226
|
+
return text("The song has tablature: call again with with_tab true (keep it) or false (chords only).");
|
|
227
|
+
if (!keep)
|
|
228
|
+
return text({ toReview: { title: r.title, author: r.author, year: r.year, tempo: r.tempo, key: r.key, capo: r.capo, chords: chordsIn(r.text ?? ""), text: r.text } });
|
|
229
|
+
const created = await client.send("POST", "/api/songs", { title: r.title, author: r.author, year: r.year, tempo: r.tempo, text: r.text, key: r.key, capo: r.capo, tags: [] });
|
|
230
|
+
return text({ created: created?.id, title: r.title, author: r.author });
|
|
231
|
+
}));
|
|
232
|
+
server.registerTool("set_backing_track", {
|
|
233
|
+
title: "Set a song's backing track",
|
|
234
|
+
description: "Uploads an audio file from this computer (OGG with any number of channels, MP3 or WAV) as the song's backing track, replacing any previous one.",
|
|
235
|
+
inputSchema: { id: z.string(), path: z.string().describe("Path of the audio file on this computer.") },
|
|
236
|
+
}, safe(async ({ id, path }, signal) => {
|
|
237
|
+
const bytes = await localFile(path, TRACK_MAX);
|
|
238
|
+
const r = await client.send("PUT", `/api/songs/${encodeURIComponent(id)}/track`, bytes, { name: basename(path) }, 300000, signal);
|
|
239
|
+
return text({ id, track: r?.trackName ?? basename(path) });
|
|
240
|
+
}));
|
|
241
|
+
server.registerTool("remove_backing_track", {
|
|
242
|
+
title: "Remove a song's backing track",
|
|
243
|
+
description: "Takes the backing track off a song (the audio file is deleted from the tablet). Ask first.",
|
|
244
|
+
inputSchema: { id: z.string(), confirm: z.literal(true) },
|
|
245
|
+
annotations: { destructiveHint: true },
|
|
246
|
+
}, safe(async ({ id }) => {
|
|
247
|
+
await client.send("DELETE", `/api/songs/${encodeURIComponent(id)}/track`);
|
|
248
|
+
return text({ id, track: null });
|
|
249
|
+
}));
|
|
250
|
+
// ---------- setlists ----------
|
|
251
|
+
server.registerTool("list_setlists", {
|
|
252
|
+
title: "List setlists",
|
|
253
|
+
description: "The setlists (id, name, number of songs).",
|
|
254
|
+
inputSchema: {},
|
|
255
|
+
annotations: { readOnlyHint: true },
|
|
256
|
+
}, safe(async () => text(await client.get("/api/sets"))));
|
|
257
|
+
server.registerTool("get_setlist", {
|
|
258
|
+
title: "Get a setlist",
|
|
259
|
+
description: "A setlist with its songs in order (a song deleted from the library shows with an empty title).",
|
|
260
|
+
inputSchema: { id: z.string() },
|
|
261
|
+
annotations: { readOnlyHint: true },
|
|
262
|
+
}, safe(async ({ id }) => text(await client.get(`/api/sets/${encodeURIComponent(id)}`))));
|
|
263
|
+
server.registerTool("create_setlist", {
|
|
264
|
+
title: "Create a setlist",
|
|
265
|
+
description: "Creates a setlist with these songs, in this order (song ids from list_songs). Without a name it's named after the date and time.",
|
|
266
|
+
inputSchema: { name: z.string().optional(), song_ids: z.array(z.string()).optional() },
|
|
267
|
+
}, safe(async ({ name, song_ids }) => text({ created: (await client.send("POST", "/api/sets", { name: name ?? "", items: song_ids ?? [] }))?.id })));
|
|
268
|
+
server.registerTool("update_setlist", {
|
|
269
|
+
title: "Update a setlist",
|
|
270
|
+
description: "Renames a setlist and/or replaces its songs (the full list, in order). Omitted fields stay. Emptying it (song_ids []) needs confirm=true.",
|
|
271
|
+
inputSchema: {
|
|
272
|
+
id: z.string(), name: z.string().optional(), song_ids: z.array(z.string()).optional(),
|
|
273
|
+
confirm: z.boolean().optional().describe("Required true to leave the setlist with no songs."),
|
|
274
|
+
},
|
|
275
|
+
}, safe(async ({ id, name, song_ids, confirm }) => {
|
|
276
|
+
const cur = await client.get(`/api/sets/${encodeURIComponent(id)}`);
|
|
277
|
+
// an empty list takes every song out: like deleting, it asks first (audit 03/11)
|
|
278
|
+
if (song_ids?.length === 0 && cur.songs.length > 0 && !confirm) {
|
|
279
|
+
throw new StagebookError(`This takes all ${cur.songs.length} songs out of "${cur.name}": ask the person and pass confirm=true.`);
|
|
280
|
+
}
|
|
281
|
+
await client.send("PUT", `/api/sets/${encodeURIComponent(id)}`, { name: name ?? cur.name, items: song_ids ?? cur.songs.map((s) => s.id) });
|
|
282
|
+
return text({ updated: id });
|
|
283
|
+
}));
|
|
284
|
+
server.registerTool("delete_setlist", {
|
|
285
|
+
title: "Delete a setlist",
|
|
286
|
+
description: "Deletes a setlist (its songs stay in the library). Ask first and pass confirm=true.",
|
|
287
|
+
inputSchema: { id: z.string(), confirm: z.literal(true) },
|
|
288
|
+
annotations: { destructiveHint: true },
|
|
289
|
+
}, safe(async ({ id }) => {
|
|
290
|
+
await client.send("DELETE", `/api/sets/${encodeURIComponent(id)}`);
|
|
291
|
+
return text({ deleted: id });
|
|
292
|
+
}));
|
|
293
|
+
// ---------- receiving and the band ----------
|
|
294
|
+
server.registerTool("receive_by_code", {
|
|
295
|
+
title: "Receive a setlist by its 3 words",
|
|
296
|
+
description: "Fetches what someone sent over the internet with Stagebook's 3-word code (e.g. 47-violao-lua) and returns the " +
|
|
297
|
+
"review: each song found in the library, new, or with candidates. Then call receive_create with the token.",
|
|
298
|
+
inputSchema: { code: z.string() },
|
|
299
|
+
}, safe(async ({ code }, signal) => text(await client.send("POST", "/api/receive", { code }, undefined, 60000, signal))));
|
|
300
|
+
server.registerTool("receive_create", {
|
|
301
|
+
title: "Create the received setlist",
|
|
302
|
+
description: "After receive_by_code or open_band_item: creates the setlist from the review's token. choices maps a row index " +
|
|
303
|
+
"to the chosen library song id where the review offered candidates.",
|
|
304
|
+
inputSchema: { token: z.string(), name: z.string().optional(), choices: z.record(z.string(), z.string()).optional() },
|
|
305
|
+
}, safe(async ({ token, name, choices }) => {
|
|
306
|
+
const r = await client.send("POST", "/api/receive/create", { token, name: name ?? "", choices: choices ?? {} });
|
|
307
|
+
return text({ setlist: r?.setlist });
|
|
308
|
+
}));
|
|
309
|
+
server.registerTool("band_inbox", {
|
|
310
|
+
title: "What the band sent",
|
|
311
|
+
description: "Setlists and songs band members sent to this tablet that weren't opened yet.",
|
|
312
|
+
inputSchema: {},
|
|
313
|
+
annotations: { readOnlyHint: true },
|
|
314
|
+
}, safe(async () => text(await client.get("/api/band"))));
|
|
315
|
+
server.registerTool("band_sync", {
|
|
316
|
+
title: "Sync with the band",
|
|
317
|
+
description: "Asks the band (same Wi-Fi) and the internet inbox for anything new.",
|
|
318
|
+
inputSchema: {},
|
|
319
|
+
}, safe(async () => {
|
|
320
|
+
await client.send("POST", "/api/band/sync");
|
|
321
|
+
return text("Asked the band for anything new; check band_inbox in a few seconds.");
|
|
322
|
+
}));
|
|
323
|
+
server.registerTool("open_band_item", {
|
|
324
|
+
title: "Open something the band sent",
|
|
325
|
+
description: "Reviews an item from band_inbox (by id), like receive_by_code; then create it with receive_create.",
|
|
326
|
+
inputSchema: { id: z.string() },
|
|
327
|
+
}, safe(async ({ id }) => text(await client.send("POST", "/api/band/open", { code: id }))));
|
|
328
|
+
// ---------- backup ----------
|
|
329
|
+
server.registerTool("restore_backup", {
|
|
330
|
+
title: "Restore a backup file",
|
|
331
|
+
description: "Sends a .stagebook file from this computer to the tablet. Without parts it only inspects (what the file has). " +
|
|
332
|
+
"With parts (library, tracks, settings) it restores them: replace=true swaps the library for the file's, " +
|
|
333
|
+
"replace=false only adds the songs, setlists and tracks that are missing. Settings always replace the tablet's " +
|
|
334
|
+
"(stage look, pedals, USB audio interface, band). Replacing the library or restoring settings: ask first and pass confirm=true.",
|
|
335
|
+
inputSchema: {
|
|
336
|
+
path: z.string(),
|
|
337
|
+
parts: z.array(z.enum(["library", "tracks", "settings"])).optional(),
|
|
338
|
+
replace: z.boolean().optional(),
|
|
339
|
+
confirm: z.boolean().optional().describe("Required true with replace=true or with settings in parts."),
|
|
340
|
+
},
|
|
341
|
+
annotations: { destructiveHint: true },
|
|
342
|
+
}, safe(async ({ path, parts, replace, confirm }, signal) => {
|
|
343
|
+
const bytes = await localFile(path, BACKUP_MAX);
|
|
344
|
+
const info = await client.send("POST", "/api/backup/inspect", bytes, undefined, 600000, signal);
|
|
345
|
+
if (!parts?.length)
|
|
346
|
+
return text({ inspected: info });
|
|
347
|
+
if (replace && !confirm)
|
|
348
|
+
throw new StagebookError("Replacing swaps the whole library for the file's: ask the person and pass confirm=true.");
|
|
349
|
+
// the tablet's settings are always replaced by the file's, with replace or not (audit 03/11: it went without asking)
|
|
350
|
+
if (parts.includes("settings") && !confirm) {
|
|
351
|
+
throw new StagebookError("Restoring settings replaces the tablet's (stage look, pedals, USB audio interface, band): ask the person and pass confirm=true.");
|
|
352
|
+
}
|
|
353
|
+
// given up while the file went over: the restore doesn't start
|
|
354
|
+
if (signal?.aborted)
|
|
355
|
+
throw new StagebookError("The call was cancelled before restoring: nothing changed on the tablet.");
|
|
356
|
+
const r = await client.send("POST", "/api/backup/restore", { token: info.token, parts, replace: !!replace }, undefined, 600000, signal);
|
|
357
|
+
return text({ restored: parts, replace: !!replace, skipped: r?.skipped ?? 0 });
|
|
358
|
+
}));
|
|
359
|
+
// ---------- resources ----------
|
|
360
|
+
server.registerResource("songs", "stagebook://songs", { title: "Stagebook songs", description: "The library's songs (id, title, artist, year, key, tags).", mimeType: "application/json" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(await client.get("/api/songs"), null, 2) }] }));
|
|
361
|
+
server.registerResource("setlists", "stagebook://setlists", { title: "Stagebook setlists", description: "The setlists (id, name, count).", mimeType: "application/json" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(await client.get("/api/sets"), null, 2) }] }));
|
|
362
|
+
server.registerResource("song", new ResourceTemplate("stagebook://songs/{id}", { list: undefined }), { title: "A Stagebook song (ChordPro)", description: "One song as ChordPro, with its details as directives.", mimeType: "text/plain" }, async (uri, { id }) => {
|
|
363
|
+
const s = (await client.get(`/api/songs/${encodeURIComponent(String(id))}`));
|
|
364
|
+
const head = [`{title: ${s.title}}`, s.author && `{artist: ${s.author}}`, s.year && `{year: ${s.year}}`, s.key && `{key: ${s.key}}`, s.capo ? `{capo: ${s.capo}}` : "", s.tempo && `{tempo: ${s.tempo}}`].filter(Boolean);
|
|
365
|
+
return { contents: [{ uri: uri.href, mimeType: "text/plain", text: [...head, "", s.text].join("\n") }] };
|
|
366
|
+
});
|
|
367
|
+
// ---------- prompts ----------
|
|
368
|
+
server.registerPrompt("build_setlist", {
|
|
369
|
+
title: "Build a setlist",
|
|
370
|
+
description: "Picks songs from the library for a gig and creates the setlist.",
|
|
371
|
+
argsSchema: { occasion: z.string().describe("The gig: length, mood, audience…"), count: z.string().optional() },
|
|
372
|
+
}, ({ occasion, count }) => ({
|
|
373
|
+
messages: [{
|
|
374
|
+
role: "user",
|
|
375
|
+
content: { type: "text", text: `Build a Stagebook setlist for: ${occasion}. ${count ? `About ${count} songs. ` : ""}Use list_songs (and get_song for BPM/key when useful), balance energy and keys, avoid the same artist back to back, show me the list with a short reason, and create it with create_setlist after I agree.` },
|
|
376
|
+
}],
|
|
377
|
+
}));
|
|
378
|
+
server.registerPrompt("fix_chords", {
|
|
379
|
+
title: "Check a song's chords",
|
|
380
|
+
description: "Reviews a song's chords and sections and proposes fixes.",
|
|
381
|
+
argsSchema: { song: z.string().describe("Title or id.") },
|
|
382
|
+
}, ({ song }) => ({
|
|
383
|
+
messages: [{
|
|
384
|
+
role: "user",
|
|
385
|
+
content: { type: "text", text: `Find the Stagebook song "${song}" (list_songs), read it with get_song include_text, and check the chords (misplaced, wrong key, typos) and section titles. Propose the changes and only save with update_song after I agree.` },
|
|
386
|
+
}],
|
|
387
|
+
}));
|
|
388
|
+
server.registerPrompt("transpose_for_singer", {
|
|
389
|
+
title: "Transpose songs for a singer",
|
|
390
|
+
description: "Moves several songs to keys that suit a singer.",
|
|
391
|
+
argsSchema: { which: z.string().describe("Which songs (artist, tag, setlist…)."), range: z.string().describe("The singer's comfortable range or target keys.") },
|
|
392
|
+
}, ({ which, range }) => ({
|
|
393
|
+
messages: [{
|
|
394
|
+
role: "user",
|
|
395
|
+
content: { type: "text", text: `In Stagebook, for these songs: ${which}. The singer's range/target: ${range}. Propose a new key per song (with semitones), then transpose_song each one after I agree.` },
|
|
396
|
+
}],
|
|
397
|
+
}));
|
|
398
|
+
return server;
|
|
399
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stagebook-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "MCP server for Stagebook: lets AI assistants (Claude Code, Claude Desktop, Cursor, VS Code, Gemini CLI, Codex…) read and edit the songs and setlists on your Stagebook tablet over the local network.",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
|
+
"author": "Juliano Affonso",
|
|
7
|
+
"homepage": "https://github.com/julianoAffonso/stagebook/tree/main/mcp#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/julianoAffonso/stagebook.git",
|
|
11
|
+
"directory": "mcp"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/julianoAffonso/stagebook/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mcp",
|
|
18
|
+
"model-context-protocol",
|
|
19
|
+
"mcp-server",
|
|
20
|
+
"stagebook",
|
|
21
|
+
"setlist",
|
|
22
|
+
"chords",
|
|
23
|
+
"chordpro",
|
|
24
|
+
"lyrics",
|
|
25
|
+
"backing-track"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"bin": {
|
|
29
|
+
"stagebook-mcp": "dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc",
|
|
39
|
+
"test": "npm run build && node --test \"test/*.test.mjs\"",
|
|
40
|
+
"start": "node dist/index.js",
|
|
41
|
+
"prepack": "npm run build && cp ../LICENSE.md LICENSE.md",
|
|
42
|
+
"prepublishOnly": "npm test"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
46
|
+
"zod": "^3.25.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^22.0.0",
|
|
50
|
+
"typescript": "^5.6.0"
|
|
51
|
+
}
|
|
52
|
+
}
|