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.
- package/.env.example +90 -0
- package/CHANGELOG.md +104 -0
- package/DEPLOY.md +375 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/deploy/answers.schema.json +151 -0
- package/deploy/build.mjs +88 -0
- package/deploy/check-publishable.mjs +246 -0
- package/deploy/smoke.mjs +681 -0
- package/deploy/systemd/funoteka.env.example +41 -0
- package/deploy/systemd/funoteka.service +77 -0
- package/deploy/windows/funoteka-service.xml +58 -0
- package/deploy/windows/install-service.ps1 +158 -0
- package/dist/annotation/store.js +67 -0
- package/dist/api/admin-export.js +227 -0
- package/dist/api/admin-guard.js +181 -0
- package/dist/api/admin-library.js +113 -0
- package/dist/api/admin.js +1162 -0
- package/dist/api/annotation.js +193 -0
- package/dist/api/artistinfo.js +140 -0
- package/dist/api/audit.js +32 -0
- package/dist/api/auth.js +145 -0
- package/dist/api/bookmark.js +108 -0
- package/dist/api/browse.js +1241 -0
- package/dist/api/config-file.js +202 -0
- package/dist/api/config.js +285 -0
- package/dist/api/cover.js +304 -0
- package/dist/api/download.js +112 -0
- package/dist/api/envelope.js +162 -0
- package/dist/api/extensions.js +43 -0
- package/dist/api/genre.js +81 -0
- package/dist/api/health.js +35 -0
- package/dist/api/history.js +374 -0
- package/dist/api/idempotency.js +66 -0
- package/dist/api/keys.js +90 -0
- package/dist/api/log-file.js +65 -0
- package/dist/api/meta.js +1410 -0
- package/dist/api/playlist.js +311 -0
- package/dist/api/rescan.js +196 -0
- package/dist/api/roots.js +161 -0
- package/dist/api/router.js +133 -0
- package/dist/api/scanner.js +163 -0
- package/dist/api/search.js +82 -0
- package/dist/api/server.js +643 -0
- package/dist/api/settings.js +67 -0
- package/dist/api/stream.js +442 -0
- package/dist/api/stubs.js +183 -0
- package/dist/api/transcode.js +678 -0
- package/dist/api/user.js +73 -0
- package/dist/api/virtual.js +362 -0
- package/dist/api/visibility.js +21 -0
- package/dist/artist/apply.js +752 -0
- package/dist/artist/credit.js +95 -0
- package/dist/artist/folder.js +65 -0
- package/dist/artist/name.js +205 -0
- package/dist/artist/nfo.js +101 -0
- package/dist/artist/translit.js +79 -0
- package/dist/bookmark/store.js +54 -0
- package/dist/classify/classify.js +405 -0
- package/dist/classify/collision-name.js +109 -0
- package/dist/classify/folder-name.js +466 -0
- package/dist/classify/roles.js +277 -0
- package/dist/classify/shelf-name.js +178 -0
- package/dist/classify/tree.js +43 -0
- package/dist/cli/args.js +169 -0
- package/dist/cli/daemon.js +192 -0
- package/dist/cli/entry.js +25 -0
- package/dist/cli/keys.js +52 -0
- package/dist/cli.js +638 -0
- package/dist/cover/pick.js +58 -0
- package/dist/cover/picture.js +111 -0
- package/dist/cue/engine.js +759 -0
- package/dist/cue/match.js +142 -0
- package/dist/cue/parse.js +182 -0
- package/dist/cue/plan.js +286 -0
- package/dist/cue/track-name.js +120 -0
- package/dist/db/index.js +163 -0
- package/dist/db/issue.js +73 -0
- package/dist/db/ledger.js +84 -0
- package/dist/db/migrations/001_init.sql +196 -0
- package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
- package/dist/db/migrations/003_cue_metadata.sql +14 -0
- package/dist/db/migrations/004_cue_performer.sql +12 -0
- package/dist/db/migrations/005_incremental.sql +23 -0
- package/dist/db/migrations/006_tags.sql +57 -0
- package/dist/db/migrations/007_artist_credit.sql +35 -0
- package/dist/db/migrations/008_issue_stage.sql +39 -0
- package/dist/db/migrations/009_cue_title.sql +20 -0
- package/dist/db/migrations/010_duration_source.sql +31 -0
- package/dist/db/migrations/011_track_title_source.sql +28 -0
- package/dist/db/migrations/012_file_container.sql +25 -0
- package/dist/db/migrations/013_cover_art.sql +28 -0
- package/dist/db/migrations/014_album_year.sql +17 -0
- package/dist/db/migrations/015_probe_method.sql +14 -0
- package/dist/db/migrations/016_tags_method.sql +20 -0
- package/dist/db/migrations/017_cover_indirect.sql +23 -0
- package/dist/db/migrations/018_release_year.sql +14 -0
- package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
- package/dist/db/migrations/020_analyze_after_index.sql +16 -0
- package/dist/db/migrations/021_cue_index_files.sql +15 -0
- package/dist/db/migrations/022_sidecar_text.sql +19 -0
- package/dist/db/migrations/023_track_credit.sql +23 -0
- package/dist/db/migrations/024_playlists.sql +63 -0
- package/dist/db/migrations/025_playlist_sequence.sql +27 -0
- package/dist/db/migrations/026_playlist_source_file.sql +19 -0
- package/dist/db/migrations/027_lookup_indexes.sql +30 -0
- package/dist/db/migrations/028_annotations.sql +47 -0
- package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
- package/dist/db/migrations/030_history.sql +108 -0
- package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
- package/dist/db/migrations/032_bookmarks.sql +27 -0
- package/dist/db/migrations/033_bookmark_order.sql +14 -0
- package/dist/db/migrations/034_junk.sql +33 -0
- package/dist/db/migrations/035_api_key.sql +38 -0
- package/dist/db/migrations/036_file_tag_first.sql +88 -0
- package/dist/db/migrations/037_admin_idempotency.sql +31 -0
- package/dist/db/migrations/038_cue_audio_index.sql +24 -0
- package/dist/db/sweep.js +109 -0
- package/dist/history/store.js +151 -0
- package/dist/inventory/inventory.js +294 -0
- package/dist/junk/marks.js +143 -0
- package/dist/junk/rule.js +93 -0
- package/dist/mcp/client.js +48 -0
- package/dist/mcp/server.js +175 -0
- package/dist/mcp/tools.js +348 -0
- package/dist/playlist/files.js +116 -0
- package/dist/playlist/import.js +260 -0
- package/dist/playlist/store.js +240 -0
- package/dist/probe/ffprobe.js +179 -0
- package/dist/run.js +120 -0
- package/dist/scan/kinds.js +49 -0
- package/dist/scan/scan.js +503 -0
- package/dist/scan/schedule.js +132 -0
- package/dist/scan/settle.js +172 -0
- package/dist/scan/walk.js +132 -0
- package/dist/search/index.js +41 -0
- package/dist/search/query.js +28 -0
- package/dist/stream/flac.js +541 -0
- package/dist/stream/mpeg.js +179 -0
- package/dist/stream/recode.js +393 -0
- package/dist/stream/rewrite.js +163 -0
- package/dist/stream/segment.js +624 -0
- package/dist/tags/apply.js +703 -0
- package/dist/tags/encode.js +164 -0
- package/dist/tags/first.js +130 -0
- package/dist/tags/flac.js +196 -0
- package/dist/tags/genres.js +123 -0
- package/dist/tags/id3v1.js +209 -0
- package/dist/tags/id3v2.js +754 -0
- package/dist/tags/mp4.js +534 -0
- package/dist/tags/mpeg.js +355 -0
- package/dist/tags/ogg.js +463 -0
- package/dist/tags/picture.js +71 -0
- package/dist/tags/read.js +269 -0
- package/dist/tags/types.js +51 -0
- package/dist/tags/vorbis-comment.js +155 -0
- package/dist/text/encoding.js +384 -0
- package/dist/text/site-name.js +37 -0
- package/dist/util/child.js +21 -0
- package/dist/util/names.js +97 -0
- package/funoteka.json.example +27 -0
- 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
|
+
}
|