funoteka 0.1.0

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.
Files changed (162) hide show
  1. package/.env.example +90 -0
  2. package/CHANGELOG.md +104 -0
  3. package/DEPLOY.md +375 -0
  4. package/LICENSE +21 -0
  5. package/README.md +138 -0
  6. package/deploy/answers.schema.json +151 -0
  7. package/deploy/build.mjs +88 -0
  8. package/deploy/check-publishable.mjs +246 -0
  9. package/deploy/smoke.mjs +681 -0
  10. package/deploy/systemd/funoteka.env.example +41 -0
  11. package/deploy/systemd/funoteka.service +77 -0
  12. package/deploy/windows/funoteka-service.xml +58 -0
  13. package/deploy/windows/install-service.ps1 +158 -0
  14. package/dist/annotation/store.js +67 -0
  15. package/dist/api/admin-export.js +227 -0
  16. package/dist/api/admin-guard.js +181 -0
  17. package/dist/api/admin-library.js +113 -0
  18. package/dist/api/admin.js +1162 -0
  19. package/dist/api/annotation.js +193 -0
  20. package/dist/api/artistinfo.js +140 -0
  21. package/dist/api/audit.js +32 -0
  22. package/dist/api/auth.js +145 -0
  23. package/dist/api/bookmark.js +108 -0
  24. package/dist/api/browse.js +1241 -0
  25. package/dist/api/config-file.js +202 -0
  26. package/dist/api/config.js +285 -0
  27. package/dist/api/cover.js +304 -0
  28. package/dist/api/download.js +112 -0
  29. package/dist/api/envelope.js +162 -0
  30. package/dist/api/extensions.js +43 -0
  31. package/dist/api/genre.js +81 -0
  32. package/dist/api/health.js +35 -0
  33. package/dist/api/history.js +374 -0
  34. package/dist/api/idempotency.js +66 -0
  35. package/dist/api/keys.js +90 -0
  36. package/dist/api/log-file.js +65 -0
  37. package/dist/api/meta.js +1410 -0
  38. package/dist/api/playlist.js +311 -0
  39. package/dist/api/rescan.js +196 -0
  40. package/dist/api/roots.js +161 -0
  41. package/dist/api/router.js +133 -0
  42. package/dist/api/scanner.js +163 -0
  43. package/dist/api/search.js +82 -0
  44. package/dist/api/server.js +643 -0
  45. package/dist/api/settings.js +67 -0
  46. package/dist/api/stream.js +442 -0
  47. package/dist/api/stubs.js +183 -0
  48. package/dist/api/transcode.js +678 -0
  49. package/dist/api/user.js +73 -0
  50. package/dist/api/virtual.js +362 -0
  51. package/dist/api/visibility.js +21 -0
  52. package/dist/artist/apply.js +752 -0
  53. package/dist/artist/credit.js +95 -0
  54. package/dist/artist/folder.js +65 -0
  55. package/dist/artist/name.js +205 -0
  56. package/dist/artist/nfo.js +101 -0
  57. package/dist/artist/translit.js +79 -0
  58. package/dist/bookmark/store.js +54 -0
  59. package/dist/classify/classify.js +405 -0
  60. package/dist/classify/collision-name.js +109 -0
  61. package/dist/classify/folder-name.js +466 -0
  62. package/dist/classify/roles.js +277 -0
  63. package/dist/classify/shelf-name.js +178 -0
  64. package/dist/classify/tree.js +43 -0
  65. package/dist/cli/args.js +169 -0
  66. package/dist/cli/daemon.js +192 -0
  67. package/dist/cli/entry.js +25 -0
  68. package/dist/cli/keys.js +52 -0
  69. package/dist/cli.js +638 -0
  70. package/dist/cover/pick.js +58 -0
  71. package/dist/cover/picture.js +111 -0
  72. package/dist/cue/engine.js +759 -0
  73. package/dist/cue/match.js +142 -0
  74. package/dist/cue/parse.js +182 -0
  75. package/dist/cue/plan.js +286 -0
  76. package/dist/cue/track-name.js +120 -0
  77. package/dist/db/index.js +163 -0
  78. package/dist/db/issue.js +73 -0
  79. package/dist/db/ledger.js +84 -0
  80. package/dist/db/migrations/001_init.sql +196 -0
  81. package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
  82. package/dist/db/migrations/003_cue_metadata.sql +14 -0
  83. package/dist/db/migrations/004_cue_performer.sql +12 -0
  84. package/dist/db/migrations/005_incremental.sql +23 -0
  85. package/dist/db/migrations/006_tags.sql +57 -0
  86. package/dist/db/migrations/007_artist_credit.sql +35 -0
  87. package/dist/db/migrations/008_issue_stage.sql +39 -0
  88. package/dist/db/migrations/009_cue_title.sql +20 -0
  89. package/dist/db/migrations/010_duration_source.sql +31 -0
  90. package/dist/db/migrations/011_track_title_source.sql +28 -0
  91. package/dist/db/migrations/012_file_container.sql +25 -0
  92. package/dist/db/migrations/013_cover_art.sql +28 -0
  93. package/dist/db/migrations/014_album_year.sql +17 -0
  94. package/dist/db/migrations/015_probe_method.sql +14 -0
  95. package/dist/db/migrations/016_tags_method.sql +20 -0
  96. package/dist/db/migrations/017_cover_indirect.sql +23 -0
  97. package/dist/db/migrations/018_release_year.sql +14 -0
  98. package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
  99. package/dist/db/migrations/020_analyze_after_index.sql +16 -0
  100. package/dist/db/migrations/021_cue_index_files.sql +15 -0
  101. package/dist/db/migrations/022_sidecar_text.sql +19 -0
  102. package/dist/db/migrations/023_track_credit.sql +23 -0
  103. package/dist/db/migrations/024_playlists.sql +63 -0
  104. package/dist/db/migrations/025_playlist_sequence.sql +27 -0
  105. package/dist/db/migrations/026_playlist_source_file.sql +19 -0
  106. package/dist/db/migrations/027_lookup_indexes.sql +30 -0
  107. package/dist/db/migrations/028_annotations.sql +47 -0
  108. package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
  109. package/dist/db/migrations/030_history.sql +108 -0
  110. package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
  111. package/dist/db/migrations/032_bookmarks.sql +27 -0
  112. package/dist/db/migrations/033_bookmark_order.sql +14 -0
  113. package/dist/db/migrations/034_junk.sql +33 -0
  114. package/dist/db/migrations/035_api_key.sql +38 -0
  115. package/dist/db/migrations/036_file_tag_first.sql +88 -0
  116. package/dist/db/migrations/037_admin_idempotency.sql +31 -0
  117. package/dist/db/migrations/038_cue_audio_index.sql +24 -0
  118. package/dist/db/sweep.js +109 -0
  119. package/dist/history/store.js +151 -0
  120. package/dist/inventory/inventory.js +294 -0
  121. package/dist/junk/marks.js +143 -0
  122. package/dist/junk/rule.js +93 -0
  123. package/dist/mcp/client.js +48 -0
  124. package/dist/mcp/server.js +175 -0
  125. package/dist/mcp/tools.js +348 -0
  126. package/dist/playlist/files.js +116 -0
  127. package/dist/playlist/import.js +260 -0
  128. package/dist/playlist/store.js +240 -0
  129. package/dist/probe/ffprobe.js +179 -0
  130. package/dist/run.js +120 -0
  131. package/dist/scan/kinds.js +49 -0
  132. package/dist/scan/scan.js +503 -0
  133. package/dist/scan/schedule.js +132 -0
  134. package/dist/scan/settle.js +172 -0
  135. package/dist/scan/walk.js +132 -0
  136. package/dist/search/index.js +41 -0
  137. package/dist/search/query.js +28 -0
  138. package/dist/stream/flac.js +541 -0
  139. package/dist/stream/mpeg.js +179 -0
  140. package/dist/stream/recode.js +393 -0
  141. package/dist/stream/rewrite.js +163 -0
  142. package/dist/stream/segment.js +624 -0
  143. package/dist/tags/apply.js +703 -0
  144. package/dist/tags/encode.js +164 -0
  145. package/dist/tags/first.js +130 -0
  146. package/dist/tags/flac.js +196 -0
  147. package/dist/tags/genres.js +123 -0
  148. package/dist/tags/id3v1.js +209 -0
  149. package/dist/tags/id3v2.js +754 -0
  150. package/dist/tags/mp4.js +534 -0
  151. package/dist/tags/mpeg.js +355 -0
  152. package/dist/tags/ogg.js +463 -0
  153. package/dist/tags/picture.js +71 -0
  154. package/dist/tags/read.js +269 -0
  155. package/dist/tags/types.js +51 -0
  156. package/dist/tags/vorbis-comment.js +155 -0
  157. package/dist/text/encoding.js +384 -0
  158. package/dist/text/site-name.js +37 -0
  159. package/dist/util/child.js +21 -0
  160. package/dist/util/names.js +97 -0
  161. package/funoteka.json.example +27 -0
  162. package/package.json +55 -0
@@ -0,0 +1,93 @@
1
+ /**
2
+ * What in the collection is not a record.
3
+ *
4
+ * The contract asks for a junk filter — a label, a hiding, a switch, and an
5
+ * allow/block edit — and never a deletion (requirements:47 §11). This module is
6
+ * the label: the one place that decides whether a folder is a record, so the
7
+ * scan that writes the label and the command that re-derives it after a hand
8
+ * edit cannot come to two answers.
9
+ *
10
+ * **The rule is about the folder, not about the music.** Nothing here reads a
11
+ * title, a tag, a bitrate or an artist, and that is deliberate: a rule that
12
+ * judged the music would be this server deciding taste, and the collection is
13
+ * the operator's. What it judges is whether the folder is a folder of music at
14
+ * all — which is a question about the disk, and has an answer.
15
+ */
16
+ /**
17
+ * How many files a record may hold that are neither the music nor the paperwork.
18
+ *
19
+ * A record folder holds audio, the artwork that came with it, and the notes —
20
+ * a cue, an `.nfo`, a `.log`. Everything else is `kind = 'other'`. Measured on
21
+ * the live collection, the albums that *are* records hold at most **four** of
22
+ * those: an enhanced CD's data track (`AUTORUN.INF`, `OSC.EXE`, `LINKIN.MOV`,
23
+ * `JACKET01.00J` — the sixteen Linkin Park and Cure pressings that carry one)
24
+ * and a `foo_dr.txt` beside a rip.
25
+ */
26
+ export const STRANGERS_FLOOR = 5;
27
+ /**
28
+ * How far the music may be outnumbered before the folder stops being about it.
29
+ *
30
+ * **The count alone was not enough, and the live check is what said so.** With a
31
+ * bare floor of five the rule hid `Standalone-Music Access Virus TI` — one demo
32
+ * track, a Virus TI patch bank (`.lib`, `.mid`, `.syx`), a readme and a patch
33
+ * list, which is a single with its patch data and not a dumping ground. The
34
+ * floor separates a record's paperwork from a rubbish heap; it does not
35
+ * separate *a record with unusual paperwork* from a heap, and no count can,
36
+ * because a heap has no ceiling.
37
+ *
38
+ * What separates them is the shape, and it is the same measurement read twice:
39
+
40
+ * | folder | not music | music | ratio |
41
+ * |---|---|---|---|
42
+ * | `Downloads` (the root itself) | 199 | 9 | 22.1 |
43
+ * | `Telegram Desktop` | 98 | 7 | 14.0 |
44
+ * | `Standalone-Music Access Virus TI` | 6 | 1 | 6.0 |
45
+ * | every record in the collection | ≤ 4 | ≥ 1 | ≤ 1.0 |
46
+ *
47
+ * A record's companions scale with the record; a dumping ground accumulates with
48
+ * no relation to the music that happens to be in it. Ten is the number that
49
+ * falls between 6.0 and 14.0, and the *statement* is what makes the position
50
+ * defensible rather than the arithmetic: **a folder holding ten files that are
51
+ * not music for every one that is is not a folder of music.**
52
+ *
53
+ * The two conditions are kept together and not folded into the ratio alone: a
54
+ * single track beside eleven stray files is a ratio of eleven, and calling that
55
+ * a dumping ground on the strength of one song is the sort of guess this rule
56
+ * exists to avoid.
57
+ */
58
+ export const STRANGERS_PER_SONG = 10;
59
+ /** The files of a folder that are neither the music nor the paperwork. */
60
+ export function strangers(files) {
61
+ return files.filter((file) => file.kind === 'other').length;
62
+ }
63
+ /** The files of a folder that are music. */
64
+ export function songs(files) {
65
+ return files.filter((file) => file.kind === 'audio').length;
66
+ }
67
+ /**
68
+ * Whether a folder is a record, and why not.
69
+ *
70
+ * The mark wins where there is one. That is the whole of the allow/block edit:
71
+ * `trust` on a folder the rule would hide, `junk` on one it would keep — and the
72
+ * reason it carries says which of the two happened, because a hidden album that
73
+ * cannot say why is a hidden album nobody can argue with.
74
+ *
75
+ * The reason states both numbers rather than only the one that tripped the rule.
76
+ * A reader checking the filter's work is asking "was this folder mostly not
77
+ * music", and `6 against 1` answers it where `6 files that are not music` leaves
78
+ * them to guess at the denominator — the guess being the thing this rule was
79
+ * wrong about once already.
80
+ */
81
+ export function junkReason(files, mark) {
82
+ if (mark === 'trust')
83
+ return null;
84
+ if (mark === 'junk')
85
+ return 'marked junk by hand';
86
+ const strangers_ = strangers(files);
87
+ if (strangers_ < STRANGERS_FLOOR)
88
+ return null;
89
+ const songs_ = songs(files);
90
+ if (strangers_ <= STRANGERS_PER_SONG * songs_)
91
+ return null;
92
+ return `${strangers_} files that are not music, against ${songs_} that are`;
93
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The admin API, called over HTTP, by an MCP server that is a process of its own.
3
+ *
4
+ * This is the client the stdio transport uses: an agent starts `funoteka mcp`,
5
+ * and every tool call it makes leaves this process as one authenticated HTTP
6
+ * request to the admin port. That is the design rather than a shortcut — the
7
+ * gate, the audit line, the lockout and the refusal sentences all belong to that
8
+ * port, and a second path into the same work would be a second place for them to
9
+ * be forgotten.
10
+ *
11
+ * The token is a bearer header and never a query parameter, for the reason the
12
+ * admin surface keeps saying: a query string is what ends up in a log line.
13
+ */
14
+ export function adminClient(url, token) {
15
+ return async (method, path, body, idempotencyKey) => {
16
+ const response = await fetch(new URL(path, url), {
17
+ method,
18
+ headers: {
19
+ authorization: `Bearer ${token}`,
20
+ // **The retry key, which this client used to drop on the floor.** The
21
+ // tool layer hands it over as a fourth argument and an implementation
22
+ // that ignores one is not a type error — so an agent calling over stdio
23
+ // got no idempotency at all while the HTTP transport had it, which is
24
+ // the half that would never have been noticed.
25
+ ...(idempotencyKey === undefined ? {} : { 'idempotency-key': idempotencyKey }),
26
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
27
+ },
28
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
29
+ });
30
+ const text = await response.text();
31
+ return { status: response.status, body: parse(text) };
32
+ };
33
+ }
34
+ /**
35
+ * A body, parsed if it is JSON.
36
+ *
37
+ * A route that answers with something else — the inventory dump is text — is
38
+ * still an answer, and a client that threw on it would report a working route as
39
+ * a transport failure. What comes back is the text under a name that says so.
40
+ */
41
+ function parse(text) {
42
+ try {
43
+ return JSON.parse(text);
44
+ }
45
+ catch {
46
+ return { text };
47
+ }
48
+ }
@@ -0,0 +1,175 @@
1
+ import { SERVER_VERSION } from "../api/envelope.js";
2
+ import { IDEMPOTENCY_ARGUMENT, TOOLS, callTool, mutatingTool } from "./tools.js";
3
+ /**
4
+ * The protocol itself: JSON-RPC 2.0, four methods, and no dependencies.
5
+ *
6
+ * MCP over stdio is a line-delimited exchange — one JSON object per line, in
7
+ * both directions — and over HTTP it is a POST carrying one. Neither needs a
8
+ * library: the protocol this server has to speak is `initialize`, `tools/list`
9
+ * and `tools/call`, and a project that carries no dependencies to serve music is
10
+ * not going to carry one to answer three questions.
11
+ *
12
+ * **What is deliberately missing, and would be the next thing.** Prompts,
13
+ * resources, sampling, notifications the server originates, SSE streaming, and
14
+ * sessions. None of them is needed by an agent that wants to operate a music
15
+ * server, and every one of them is a surface with its own security story — this
16
+ * file says so rather than leaving a reader to guess whether it was forgotten.
17
+ */
18
+ /**
19
+ * The protocol revision this server speaks.
20
+ *
21
+ * **It answers with its own name, and not the client's.** An earlier version
22
+ * echoed whatever non-empty string the client sent, which reads as agreement
23
+ * with a revision this server has never implemented — and a client that then
24
+ * used a feature of that revision would be talking to a server that had agreed
25
+ * to something it does not do. A client that cannot work with this one says so,
26
+ * which is the outcome worth having.
27
+ */
28
+ export const PROTOCOL_VERSION = '2025-06-18';
29
+ /**
30
+ * One message in, one message out — or nothing at all, for a notification.
31
+ *
32
+ * Nothing is the correct answer to a notification and it is not the same as an
33
+ * error: `notifications/initialized` is a client telling this server it is
34
+ * ready, and a server that replied would be talking when it was spoken to.
35
+ */
36
+ export async function handleMessage(client, message) {
37
+ if (message === null || typeof message !== 'object') {
38
+ return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'not a JSON-RPC message' } };
39
+ }
40
+ const { id = null, method, params = {} } = message;
41
+ if (typeof method !== 'string') {
42
+ return { jsonrpc: '2.0', id, error: { code: -32600, message: 'no method' } };
43
+ }
44
+ // A notification has no id, and nothing comes back for one.
45
+ const notification = message.id === undefined;
46
+ switch (method) {
47
+ case 'initialize': {
48
+ return reply(id, {
49
+ protocolVersion: PROTOCOL_VERSION,
50
+ capabilities: { tools: {} },
51
+ serverInfo: { name: 'funoteka', version: SERVER_VERSION },
52
+ instructions: 'Operates a funoteka music server. Reads are safe and cheap; funoteka_scan_start reads the disk and runs as a separate process, and funoteka_roots_remove and funoteka_restore change the library. Every call goes through the admin API and is audited there.',
53
+ });
54
+ }
55
+ case 'notifications/initialized':
56
+ case 'notifications/cancelled':
57
+ return null;
58
+ case 'ping':
59
+ return reply(id, {});
60
+ case 'tools/list':
61
+ return reply(id, {
62
+ tools: TOOLS.map((tool) => ({
63
+ name: tool.name,
64
+ description: tool.description,
65
+ // A tool that changes something is offered the retry key; a read has
66
+ // nothing to retry and is not told about one.
67
+ inputSchema: mutatingTool(tool)
68
+ ? {
69
+ ...tool.inputSchema,
70
+ properties: {
71
+ ...tool.inputSchema.properties,
72
+ ...IDEMPOTENCY_ARGUMENT,
73
+ },
74
+ }
75
+ : tool.inputSchema,
76
+ })),
77
+ });
78
+ case 'tools/call': {
79
+ const name = params.name;
80
+ if (typeof name !== 'string') {
81
+ return { jsonrpc: '2.0', id, error: { code: -32602, message: 'tools/call needs a name' } };
82
+ }
83
+ const args = (params.arguments ?? {});
84
+ const called = await callTool(client, name, args);
85
+ if (!called.ok) {
86
+ // An unknown tool, a missing argument, or an admin API that is not
87
+ // answering: there is no result to report, so this is a protocol error
88
+ // rather than a tool that answered.
89
+ return { jsonrpc: '2.0', id, error: { code: -32602, message: called.error } };
90
+ }
91
+ return reply(id, {
92
+ content: [{ type: 'text', text: called.text }],
93
+ // **A refusal is content, not an error.** A scan refused because one is
94
+ // already running is an answer from the right tool, and an agent that
95
+ // saw a protocol error would reach for a different tool instead of
96
+ // reading what the server said. `isError` is the protocol's own way of
97
+ // saying "this is an answer, and it is not good news".
98
+ isError: called.refused,
99
+ });
100
+ }
101
+ default:
102
+ // Notifications for methods this server does not implement are dropped, as
103
+ // the specification asks; anything else is a method error.
104
+ if (notification)
105
+ return null;
106
+ return { jsonrpc: '2.0', id, error: { code: -32601, message: `no such method: ${method}` } };
107
+ }
108
+ }
109
+ function reply(id, result) {
110
+ return { jsonrpc: '2.0', id, result };
111
+ }
112
+ /**
113
+ * The stdio loop: read lines, answer lines.
114
+ *
115
+ * **A line that is not JSON is answered, not fatal.** The caller here is an
116
+ * agent's transport, and a server that exited on one malformed line would look
117
+ * to it like a server that died — the failure would be investigated in the wrong
118
+ * place. One error message back, and the loop keeps reading.
119
+ *
120
+ * Nothing is written to stdout except protocol messages: an agent reads that
121
+ * stream as the protocol, and a stray `console.log` (of ours or of anything we
122
+ * import) would be a message it could not parse.
123
+ */
124
+ export function serveStdio(client, input = process.stdin, output = process.stdout) {
125
+ let buffer = '';
126
+ const write = (message) => {
127
+ output.write(`${JSON.stringify(message)}\n`);
128
+ };
129
+ /**
130
+ * **One message at a time, in the order they arrived.**
131
+ *
132
+ * JSON-RPC does not require this — every answer carries the id of the question
133
+ * it belongs to, and a client is entitled to match them up however they come
134
+ * back. It is done anyway because the cost is a promise chain and what it buys
135
+ * is that a client which reads its answers in order is not surprised. The
136
+ * first version of this loop fired each message off as it arrived, and a
137
+ * garbled line's answer overtook a `initialize` that was waiting on an HTTP
138
+ * call — legal, and exactly the kind of thing that gets diagnosed in the wrong
139
+ * place.
140
+ */
141
+ let queue = Promise.resolve();
142
+ return new Promise((resolve) => {
143
+ input.setEncoding?.('utf8');
144
+ input.on('data', (chunk) => {
145
+ buffer += chunk;
146
+ // One message per line, and a partial line waits for the rest of itself —
147
+ // a JSON object split across two chunks is ordinary, not an error.
148
+ let newline = buffer.indexOf('\n');
149
+ while (newline !== -1) {
150
+ const line = buffer.slice(0, newline).trim();
151
+ buffer = buffer.slice(newline + 1);
152
+ if (line !== '') {
153
+ queue = queue.then(() => deliver(client, line, write));
154
+ }
155
+ newline = buffer.indexOf('\n');
156
+ }
157
+ });
158
+ input.on('end', () => {
159
+ void queue.then(() => resolve());
160
+ });
161
+ });
162
+ }
163
+ async function deliver(client, line, write) {
164
+ let parsed;
165
+ try {
166
+ parsed = JSON.parse(line);
167
+ }
168
+ catch (err) {
169
+ write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: `not JSON: ${err.message}` } });
170
+ return;
171
+ }
172
+ const answer = await handleMessage(client, parsed);
173
+ if (answer !== null)
174
+ write(answer);
175
+ }
@@ -0,0 +1,348 @@
1
+ /**
2
+ * The `funoteka_*` tools an agent drives this server with.
3
+ *
4
+ * **Every tool is one admin route, and this file is a table rather than a second
5
+ * implementation.** The contract says MCP *over* the admin API, and that is the
6
+ * whole design: an agent calling `funoteka_scan_start` goes through the same
7
+ * gate, the same audit line, the same idempotency record and the same refusal
8
+ * sentences as an operator with `curl`. A tool layer that reached into the
9
+ * database itself would be a second control surface to secure, a second place
10
+ * behaviour is defined, and a second answer to keep in step with the first.
11
+ *
12
+ * So a tool is a method, a path, and a way of turning its arguments into a body
13
+ * — and the table below is the entire surface. `tools/list` publishes it, which
14
+ * is how an agent finds out what it can do here without being told.
15
+ */
16
+ /**
17
+ * The argument that makes a retry safe, offered on the tools that change
18
+ * something.
19
+ *
20
+ * The HTTP surface takes an `Idempotency-Key` header. A tool call is one JSON
21
+ * object and has nowhere to put a header, so the key is an argument here — and
22
+ * it reaches the same record the header does, which is what keeps this file's
23
+ * opening claim true: the same idempotency record as an operator with `curl`.
24
+ *
25
+ * **What it is for is not symmetry.** Until it existed, a client that lost an
26
+ * answer and repeated the call did the work twice, and the tool where that is
27
+ * not harmless is `funoteka_user_set {"rotate":"apiKey"}`: the second call mints
28
+ * a second key and invalidates the one the first call handed back. The contract
29
+ * asks for idempotent mutations without qualifying the transport (§3), and this
30
+ * was the transport that did not have them.
31
+ *
32
+ * Declared once and spread into every mutating tool by `server.ts`, rather than
33
+ * written into each of them: "which tools mutate" is one rule, and a dozen
34
+ * copies of it would be a dozen things to forget.
35
+ */
36
+ export const IDEMPOTENCY_ARGUMENT = {
37
+ idempotencyKey: {
38
+ type: 'string',
39
+ description: 'Answers a repeated call with the first answer instead of doing the work twice. Send the same value when retrying.',
40
+ },
41
+ };
42
+ /** Whether a tool changes something, which is when a retry needs a key. */
43
+ export function mutatingTool(tool) {
44
+ return tool.method !== 'GET';
45
+ }
46
+ const NOBODY = { type: 'object', properties: {}, additionalProperties: false };
47
+ /**
48
+ * The tools, in the order an operator would meet them.
49
+ *
50
+ * The descriptions are written for the caller that has none of this server's
51
+ * context — an agent that has just been told to add a shelf has to be told, in
52
+ * the description, that a scan is a separate and slower thing.
53
+ */
54
+ export const TOOLS = [
55
+ {
56
+ name: 'funoteka_status',
57
+ description: 'What this server is and what it is doing: version, uptime, how big the collection is, whether a scan is running, and how the admin surface itself is configured.',
58
+ inputSchema: NOBODY,
59
+ method: 'GET',
60
+ path: '/status',
61
+ },
62
+ {
63
+ name: 'funoteka_stats',
64
+ description: 'The library in numbers: roots, folders, files, songs, albums, artists, playlists, hidden albums, issues, and the size of the meta layer.',
65
+ inputSchema: NOBODY,
66
+ method: 'GET',
67
+ path: '/stats',
68
+ },
69
+ {
70
+ name: 'funoteka_issues',
71
+ description: 'What the scanner could not understand — every guess, skip, unmatched cue and refused tag — with a count of each kind. This is the only place those findings are visible.',
72
+ inputSchema: {
73
+ type: 'object',
74
+ properties: {
75
+ limit: { type: 'number', description: 'how many to return, 1–1000 (default 100)' },
76
+ severity: { type: 'string', description: 'only this severity: info, warn or error' },
77
+ },
78
+ },
79
+ method: 'GET',
80
+ path: '/issues',
81
+ },
82
+ {
83
+ name: 'funoteka_logs',
84
+ description: 'The last lines of the file this server narrates to. The read is bounded, so it is safe on a log that has been growing for months.',
85
+ inputSchema: {
86
+ type: 'object',
87
+ properties: { lines: { type: 'number', description: 'how many lines from the end, 1–5000 (default 200)' } },
88
+ },
89
+ method: 'GET',
90
+ path: '/logs',
91
+ },
92
+ {
93
+ name: 'funoteka_config_get',
94
+ description: 'Every setting, in force and where it came from (default, file, environment, flag). Secrets are reported as set and never as themselves.',
95
+ inputSchema: NOBODY,
96
+ method: 'GET',
97
+ path: '/config',
98
+ },
99
+ {
100
+ name: 'funoteka_config_set',
101
+ description: 'Write settings into the config file. The answer says whether each value is actually in force — the environment wins over the file — and nothing takes effect until the server is restarted.',
102
+ inputSchema: {
103
+ type: 'object',
104
+ properties: {
105
+ settings: {
106
+ type: 'object',
107
+ description: 'setting name to value, e.g. {"port": 8080, "logRequests": true}. null removes a key.',
108
+ },
109
+ },
110
+ required: ['settings'],
111
+ },
112
+ method: 'POST',
113
+ path: '/config',
114
+ body: (args) => args.settings,
115
+ },
116
+ {
117
+ name: 'funoteka_roots_list',
118
+ description: 'The directories this server reads, with what came from each and when it was last scanned. A root that has never been scanned is configured but not yet read.',
119
+ inputSchema: NOBODY,
120
+ method: 'GET',
121
+ path: '/roots',
122
+ },
123
+ {
124
+ name: 'funoteka_roots_add',
125
+ description: 'Configure a directory as something this server reads. The path must be a directory on the machine the *server* runs on. Adding one does not read it — start a scan for that.',
126
+ inputSchema: {
127
+ type: 'object',
128
+ properties: { path: { type: 'string', description: 'an absolute path as the server sees it' } },
129
+ required: ['path'],
130
+ },
131
+ method: 'POST',
132
+ path: '/roots',
133
+ body: (args) => ({ path: args.path }),
134
+ },
135
+ {
136
+ name: 'funoteka_roots_remove',
137
+ description: 'Stop reading a directory. Everything derived from it leaves the library with it (the answer says how much), and the files on disk are not touched.',
138
+ inputSchema: {
139
+ type: 'object',
140
+ properties: { path: { type: 'string', description: 'the root to stop reading' } },
141
+ required: ['path'],
142
+ },
143
+ method: 'DELETE',
144
+ path: '/roots',
145
+ body: (args) => ({ path: args.path }),
146
+ },
147
+ {
148
+ name: 'funoteka_scan_start',
149
+ description: 'Start reading the configured roots. The scan runs as a process of its own and this answers as soon as it has started — watch it with funoteka_scan_status. "full" reads every file again even if it has not changed; "incremental" trusts size and modification time.',
150
+ inputSchema: {
151
+ type: 'object',
152
+ properties: { mode: { type: 'string', enum: ['incremental', 'full'], description: 'default incremental' } },
153
+ },
154
+ method: 'POST',
155
+ path: '/scan',
156
+ body: (args) => (args.mode === undefined ? {} : { mode: args.mode }),
157
+ },
158
+ {
159
+ name: 'funoteka_scan_status',
160
+ description: 'Whether a scan is running now (and its pid), and what the last one did.',
161
+ inputSchema: NOBODY,
162
+ method: 'GET',
163
+ path: '/scan',
164
+ },
165
+ {
166
+ name: 'funoteka_scan_cancel',
167
+ description: 'Stop the scan that is running — or settle the record of one whose process is gone, which is the way out of a run that would otherwise refuse every later scan.',
168
+ inputSchema: NOBODY,
169
+ method: 'POST',
170
+ path: '/scan/cancel',
171
+ },
172
+ {
173
+ name: 'funoteka_scan_history',
174
+ description: 'What the last few scans did, newest first.',
175
+ inputSchema: {
176
+ type: 'object',
177
+ properties: { limit: { type: 'number', description: 'how many runs, 1–200 (default 20)' } },
178
+ },
179
+ method: 'GET',
180
+ path: '/scan/history',
181
+ },
182
+ {
183
+ name: 'funoteka_junk_list',
184
+ description: 'What the junk filter is keeping out of the default view, with the reason for each, and separately the edits a person made by hand.',
185
+ inputSchema: NOBODY,
186
+ method: 'GET',
187
+ path: '/junk',
188
+ },
189
+ {
190
+ name: 'funoteka_junk_mark',
191
+ description: 'Decide about a folder by hand, overruling the filter. verdict "junk" keeps it out of every listing; "trust" serves it whatever the rule says. Takes effect at once — no rescan.',
192
+ inputSchema: {
193
+ type: 'object',
194
+ properties: {
195
+ path: { type: 'string', description: 'the folder, as the server sees it' },
196
+ verdict: { type: 'string', enum: ['junk', 'trust'] },
197
+ note: { type: 'string', description: 'why, for whoever reads this back in six months' },
198
+ },
199
+ required: ['path', 'verdict'],
200
+ },
201
+ method: 'POST',
202
+ path: '/junk',
203
+ body: (args) => ({ path: args.path, verdict: args.verdict, note: args.note }),
204
+ },
205
+ {
206
+ name: 'funoteka_junk_unmark',
207
+ description: 'Take a hand edit back and let the filter decide that folder again.',
208
+ inputSchema: {
209
+ type: 'object',
210
+ properties: { path: { type: 'string' } },
211
+ required: ['path'],
212
+ },
213
+ method: 'DELETE',
214
+ path: '/junk',
215
+ body: (args) => ({ path: args.path }),
216
+ },
217
+ {
218
+ name: 'funoteka_playlists_import',
219
+ description: 'Read the collection\'s .m3u files again and import any that are lists of their own.',
220
+ inputSchema: NOBODY,
221
+ method: 'POST',
222
+ path: '/playlists/import',
223
+ },
224
+ {
225
+ name: 'funoteka_export',
226
+ description: 'Everything a rescan cannot rebuild: playlists, stars, ratings, bookmarks, hand edits and api keys. The document also says what it is not holding.',
227
+ inputSchema: NOBODY,
228
+ method: 'GET',
229
+ path: '/export',
230
+ },
231
+ {
232
+ name: 'funoteka_restore',
233
+ description: 'Write an export document back into this library, by the files it names rather than by row ids. A merge: what is already here is left alone, and everything that could not be placed is listed.',
234
+ inputSchema: {
235
+ type: 'object',
236
+ properties: { document: { type: 'object', description: 'an object from funoteka_export' } },
237
+ required: ['document'],
238
+ },
239
+ method: 'POST',
240
+ path: '/restore',
241
+ body: (args) => args.document,
242
+ },
243
+ {
244
+ name: 'funoteka_user_get',
245
+ description: 'Who may listen: whether a password and an api key are set and where from, and the keys in the registry. Never the secrets themselves.',
246
+ inputSchema: NOBODY,
247
+ method: 'GET',
248
+ path: '/user',
249
+ },
250
+ {
251
+ name: 'funoteka_user_set',
252
+ description: 'Change the listener credentials. {"rotate": "apiKey"} mints a key and shows it once — the only moment this surface hands a secret back. Refused if the change would leave nobody able to get in.',
253
+ inputSchema: {
254
+ type: 'object',
255
+ properties: {
256
+ user: { type: 'string' },
257
+ password: { type: 'string' },
258
+ apiKey: { type: 'string' },
259
+ rotate: { type: 'string', enum: ['apiKey'] },
260
+ },
261
+ },
262
+ method: 'POST',
263
+ path: '/user',
264
+ body: (args) => {
265
+ const body = {};
266
+ for (const key of ['user', 'password', 'apiKey', 'rotate']) {
267
+ if (args[key] !== undefined)
268
+ body[key] = args[key];
269
+ }
270
+ return body;
271
+ },
272
+ },
273
+ {
274
+ name: 'funoteka_restart',
275
+ description: 'Restart the server: exit and be started again by whatever supervises it. Refused where nothing does, because there a restart would only stop it.',
276
+ inputSchema: NOBODY,
277
+ method: 'POST',
278
+ path: '/restart',
279
+ },
280
+ ];
281
+ export function toolNamed(name) {
282
+ return TOOLS.find((one) => one.name === name);
283
+ }
284
+ /**
285
+ * The arguments a tool insists on, or nothing when it has them all.
286
+ *
287
+ * **Read from the schema, which is the one place they are declared.** They used
288
+ * to be written twice — in `inputSchema.required` and in a field beside it — and
289
+ * a test existed only to check the two agreed, which is a test standing in for
290
+ * the second copy not existing.
291
+ */
292
+ export function missingArguments(tool, args) {
293
+ const required = tool.inputSchema.required;
294
+ if (!Array.isArray(required))
295
+ return [];
296
+ return required.filter((key) => args[key] === undefined);
297
+ }
298
+ /**
299
+ * The protocol's answer to one `tools/call`.
300
+ *
301
+ * Two things are deliberately *not* errors here. A route that refused — a scan
302
+ * refused because one is running, a root that is not a directory — is a
303
+ * successful tool call whose content says what the server said: an agent that
304
+ * saw an MCP-level error would reach for a different tool, and the truth is that
305
+ * it asked the right one and got an answer. And a status the *transport* could
306
+ * not produce at all (the API is not answering) is the one case that is an
307
+ * error, because there is no answer to pass on.
308
+ */
309
+ export async function callTool(client, name, args) {
310
+ const tool = toolNamed(name);
311
+ if (tool === undefined)
312
+ return { ok: false, error: `no such tool: ${name}` };
313
+ const missing = missingArguments(tool, args);
314
+ if (missing.length > 0) {
315
+ return { ok: false, error: `${name} needs ${missing.join(', ')}` };
316
+ }
317
+ // Pulled out before the arguments become a query or a body: the key is
318
+ // addressed to the transport, and a route that received it as a parameter
319
+ // would be a route that has to know about retries.
320
+ const { idempotencyKey, ...rest } = args;
321
+ const key = typeof idempotencyKey === 'string' && idempotencyKey !== '' ? idempotencyKey : undefined;
322
+ const query = tool.method === 'GET' ? asQuery(tool.path, rest) : tool.path;
323
+ const body = tool.method === 'GET' ? undefined : tool.body?.(rest);
324
+ try {
325
+ const answer = await client(tool.method, query, body, key);
326
+ return {
327
+ ok: true,
328
+ text: JSON.stringify(answer.body),
329
+ refused: answer.status >= 400,
330
+ };
331
+ }
332
+ catch (err) {
333
+ return { ok: false, error: err.message };
334
+ }
335
+ }
336
+ /**
337
+ * A `GET` route's arguments, as the query string it reads them from.
338
+ *
339
+ * The admin routes take their filters in the query — `?limit=`, `?severity=` —
340
+ * and one place has to know that, rather than each tool carrying a URL builder.
341
+ */
342
+ function asQuery(path, args) {
343
+ const asked = Object.entries(args).filter(([, value]) => value !== undefined);
344
+ if (asked.length === 0)
345
+ return path;
346
+ const query = new URLSearchParams(asked.map(([key, value]) => [key, String(value)]));
347
+ return `${path}?${query.toString()}`;
348
+ }